Files
weirsoe-party-protocol/party/rooms.py
T
agw d2dfeafea0
CI / test (push) Successful in 23s
Speaker med stemme: replikker læses op med ElevenLabs
Spilmotoren udsender begivenheder, rummet vælger en replik, indsætter navne
og talord og genererer lyden (cachet på disk ud fra tekst, stemme og
model). Storskærmen afspiller replikkerne i rækkefølge. Afsløringen venter
på lyden og får en længde pr. trin, så stemplet lander, mens speakeren
taler. Uden nøgle eller ved fejl kører spillet som før, bare uden stemme.

Spørgsmålene er gjort familievenlige, og de otte mest kluntede replikker er
skrevet om.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-10-01 21:57:00 +02:00

252 lines
9.7 KiB
Python

"""Rum: ét kørende spil, dets forbindelser og dets timer.
Alt kører i én proces og på ét event loop. Spilmotoren er synkron og
foretager ingen I/O, så en handling kører færdig uden afbrydelse: der er ingen
kapløb mellem to spillere, der afleverer samtidig. Derfor skal serveren køre
med præcis én worker.
"""
from __future__ import annotations
import asyncio
import logging
import random
import time
from fupogfakta.engine import Game
from . import tts
from .narrator import Narrator
log = logging.getLogger(__name__)
CODE_LETTERS = "BCDFGHJKLMNPQRSTVWXZ"
IDLE_TIMEOUT = 3 * 3600
NARRATION_KEEP = 12 # så mange seneste replikker følger med i værtens snapshot
LOBBY_WAITING_AFTER = 40 # sekunder i lobbyen med nok spillere, før speakeren rykker VIP'en
REVEAL_AUDIO_WAIT = 8 # længst vi venter på lyd til afsløringen
REVEAL_MIN_STEP_MS = 3500
REVEAL_PAD_MS = 900 # luft efter hver replik i afsløringen
_rooms: dict[str, "Room"] = {}
class Room:
def __init__(self, code: str, game: Game, host_username: str):
self.code = code
self.game = game
self.host_username = host_username
self.connections: set = set() # consumers med .send_state()
self.last_activity = time.time()
self._timer: asyncio.TimerHandle | None = None
self._broadcast_pending = False
self.narrator = Narrator()
self.narration: list[dict] = [] # [{id, url, ms, at, text}] til værtens storskærm
self._narr_seq = 0
self._voice_queue: asyncio.Queue | None = None
self._side_timers: list[asyncio.TimerHandle] = []
self._welcomed = False
self._last_join_line = 0.0
self._lobby_waiting_said = False
def touch(self):
self.last_activity = time.time()
def changed(self):
"""Kald efter hver ændring: planlæg timer og send nyt snapshot til alle."""
self.touch()
self.handle_events()
self._schedule_timer()
self.broadcast()
def broadcast(self):
if not self._broadcast_pending:
self._broadcast_pending = True
asyncio.get_running_loop().create_task(self._broadcast())
async def _broadcast(self):
await asyncio.sleep(0) # saml flere ændringer i samme tick til én udsendelse
self._broadcast_pending = False
for conn in list(self.connections):
try:
await conn.send_state()
except Exception: # en død forbindelse må ikke stoppe de andre
log.exception("send_state fejlede i rum %s", self.code)
def _schedule_timer(self):
if self._timer:
self._timer.cancel()
self._timer = None
deadline = self.game.deadline
if deadline is not None:
loop = asyncio.get_running_loop()
delay = max(0.0, deadline - time.time()) + 0.05
self._timer = loop.call_later(delay, self._on_timer)
def _on_timer(self):
self._timer = None
try:
changed = self.game.tick(time.time())
except Exception:
log.exception("tick fejlede i rum %s", self.code)
return
if changed:
self.changed()
else:
self._schedule_timer()
def close(self):
if self._timer:
self._timer.cancel()
for h in self._side_timers:
h.cancel()
# ── speakeren ────────────────────────────────────────────────────────────
def handle_events(self):
"""Tøm motorens begivenheder og lad speakeren reagere. Kaldes også efter afviste handlinger."""
events = self.game.drain_events()
if not tts.enabled():
return
skip_start = any(e["type"] == "play_again" for e in events)
for ev in events:
if ev["type"] == "game_start" and skip_start:
continue
if ev["type"] == "reveal":
asyncio.get_running_loop().create_task(self._narrate_reveal())
continue
if ev["type"] == "join":
now = time.time()
if now - self._last_join_line < 2.5: # otte på én gang skal ikke alle kommenteres
continue
self._last_join_line = now
self._maybe_schedule_lobby_waiting()
for text in self.narrator.for_event(ev, self.game):
self.say(text)
if ev["type"] == "lie_start":
self._at_deadline_minus(10, "lie", "lie_hurry")
elif ev["type"] == "guess_start":
self._at_deadline_minus(5, "guess", "guess_hurry")
def host_opened(self):
if tts.enabled() and not self._welcomed and self.game.phase == "lobby" and not self.game.players:
self._welcomed = True
for text in self.narrator.for_event({"type": "host_open"}, self.game):
self.say(text)
def say(self, text: str | None):
"""Læg en replik i kø. Lyden genereres med det samme, men udgives i rækkefølge."""
if not text:
return
if self._voice_queue is None:
self._voice_queue = asyncio.Queue()
asyncio.get_running_loop().create_task(self._voice_worker())
task = asyncio.get_running_loop().create_task(tts.synth(text))
self._voice_queue.put_nowait((text, task))
async def _voice_worker(self):
while True:
text, task = await self._voice_queue.get()
audio = await task
if audio:
self._publish(text, audio)
def _publish(self, text: str, audio: dict):
self._narr_seq += 1
self.narration.append({"id": self._narr_seq, "url": audio["url"], "ms": audio["ms"],
"at": int(time.time() * 1000), "text": text})
del self.narration[:-NARRATION_KEEP]
self.broadcast()
def _at_deadline_minus(self, seconds: int, phase: str, event: str):
g = self.game
if g.deadline is None:
return
ident = (g.round_idx, g.q_idx, phase)
delay = g.deadline - seconds - time.time()
if delay <= 1:
return
def fire():
if (g.round_idx, g.q_idx, g.phase) != ident:
return
if phase == "lie" and all(p.id in g.lies for p in g.players.values() if p.connected):
return
for text in self.narrator.for_event({"type": event}, g):
self.say(text)
self._side_timers.append(asyncio.get_running_loop().call_later(delay, fire))
self._side_timers = [h for h in self._side_timers if not h.cancelled()][-20:]
def _maybe_schedule_lobby_waiting(self):
g = self.game
if self._lobby_waiting_said or g.phase != "lobby" or len(g.players) < 2:
return
self._lobby_waiting_said = True
def fire():
if g.phase == "lobby" and len(g.players) >= 2:
for text in self.narrator.for_event({"type": "lobby_waiting"}, g):
self.say(text)
self._side_timers.append(asyncio.get_running_loop().call_later(LOBBY_WAITING_AFTER, fire))
async def _narrate_reveal(self):
"""Lav lyd til hvert trin, og lad afsløringens tempo følge replikkerne."""
g = self.game
ident = (g.round_idx, g.q_idx)
g.hold_reveal(time.time(), max_wait=REVEAL_AUDIO_WAIT + 2)
self.changed()
names = {p.id: p.name for p in g.players.values()}
texts = [self.narrator.reveal_step(step, names, len(g.players)) for step in g.reveal_steps]
note = (g.question or {}).get("note", "")
jobs = [tts.synth(t) if t else asyncio.sleep(0, None) for t in texts]
jobs.append(tts.synth(note) if note else asyncio.sleep(0, None))
try:
results = await asyncio.wait_for(asyncio.gather(*jobs), timeout=REVEAL_AUDIO_WAIT)
except asyncio.TimeoutError:
results = [None] * len(jobs)
if g.phase != "reveal" or (g.round_idx, g.q_idx) != ident or g.reveal_started_at is not None:
return # værten sprang over, eller tiden løb ud
steps_ms = []
for step, text, audio in zip(g.reveal_steps, texts, results[:-1]):
step["audio"] = {"url": audio["url"], "ms": audio["ms"], "text": text} if audio else None
steps_ms.append(max(REVEAL_MIN_STEP_MS, (audio["ms"] + REVEAL_PAD_MS) if audio else 0))
note_audio = results[-1]
g.reveal_note_audio = {"url": note_audio["url"], "ms": note_audio["ms"]} if note_audio else None
tail = 3000 + (note_audio["ms"] + REVEAL_PAD_MS if note_audio else 0)
g.start_reveal_timeline(time.time(), steps_ms, tail)
self.changed()
def new_code() -> str:
for _ in range(1000):
code = "".join(random.choice(CODE_LETTERS) for _ in range(4))
if code not in _rooms:
return code
raise RuntimeError("Ingen ledige rumkoder")
def create_room(questions: list[dict], host_username: str, public_url: str) -> Room:
cleanup()
code = new_code()
game = Game(code, questions, join_url=f"{public_url}/?code={code}")
room = Room(code, game, host_username)
_rooms[code] = room
log.info("Rum %s oprettet af %s med %d spørgsmål", code, host_username, len(questions))
return room
def get_room(code: str) -> Room | None:
return _rooms.get((code or "").upper())
def cleanup(now: float | None = None):
now = now or time.time()
for code, room in list(_rooms.items()):
if not room.connections and now - room.last_activity > IDLE_TIMEOUT:
room.close()
del _rooms[code]
log.info("Rum %s ryddet op", code)
def all_rooms() -> dict[str, Room]:
return _rooms