diff --git a/party/narrator.py b/party/narrator.py index fa804dc..fc614a1 100644 --- a/party/narrator.py +++ b/party/narrator.py @@ -91,15 +91,16 @@ TEASES_FEST = ["Bro er cooked!", "[laughs] L, tag en tår!", "Ohio-moment! Skål class Narrator: - def __init__(self, rng: random.Random | None = None, fest: bool = False): + def __init__(self, rng: random.Random | None = None, fest: bool = False, lines: dict | None = None): self.rng = rng or random.Random() + self.lines = LINES if lines is None else lines # et andet spil har sin egen lines.json self.fest = fest # fest: replikken "_fest" bruges, når den findes self._recent: dict[str, list[int]] = {} def line(self, event: str, **values) -> str | None: - if self.fest and LINES.get(event + "_fest"): + if self.fest and self.lines.get(event + "_fest"): event += "_fest" - variants = LINES.get(event) + variants = self.lines.get(event) if not variants: return None recent = self._recent.setdefault(event, []) diff --git a/party/rooms.py b/party/rooms.py index 54c58ba..e1c55be 100644 --- a/party/rooms.py +++ b/party/rooms.py @@ -50,11 +50,16 @@ EXPLODE_EACH_MS = 250 # opsummeringen: tid pr. løgn, der eksploderer COUNT_UP_MS = 2600 # opsummeringen: pointene tæller op MIC_ACTIVE_FOR = 25 # mikrofonen regnes som tændt så længe efter sidste lydstump -_rooms: dict[str, "Room"] = {} +_rooms: dict[str, "BaseRoom"] = {} -class Room: - def __init__(self, code: str, game: Game, host_username: str): +class BaseRoom: + """Det fælles for alle spil: forbindelser, timer, udsendelse og speakerens kø. + Spillet (`game`) skal have deadline, hold_until, tick(), phase_key() og drain_events().""" + + mic_laughs = 0 + + def __init__(self, code: str, game, host_username: str): self.code = code self.game = game self.host_username = host_username @@ -62,31 +67,16 @@ class Room: self.last_activity = time.time() self._timer: asyncio.TimerHandle | None = None self._broadcast_pending = False - self.narrator = Narrator(fest=game.fest) - # Fest-spil har sin egen speaker til alt; ellers bruges kategoriens/standardstemmen - self.voice = (settings.FEST_VOICE_ID or None) if game.fest else None + self.voice: str | None = None 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 # Tale-tidslinje: hvornår speakeren forventes at være færdig med den aktuelle skærms replikker self.speech_end = 0.0 self._phase_key = game.phase_key() self._pending: dict[str, list[float]] = {} # fasenøgle -> starttider for replikker under generering - # Hjernen og hukommelsen - self.brain = get_brain(fest=game.fest) - self.memory: list[str] = [] - self._tease_texts: dict[str, str] = {} - self.fooled_pairs: dict[tuple[str, str], int] = {} # (narret, forfatter) -> antal gange i aften - self._progress_said: list[float] = [] - self._attempts: dict[str, int] = {} - # Rummets mikrofon (eksperimentel) - self.mic_last = 0.0 - self.mic_laughs = 0 - self._mic_busy = False def touch(self): self.last_activity = time.time() @@ -165,6 +155,112 @@ class Room: for h in self._side_timers: h.cancel() + def handle_events(self): + self.game.drain_events() + + def host_opened(self): + pass + + def mic_on(self) -> bool: + return False + + async def hear(self, audio: bytes, content_type: str) -> dict: + return {"heard": False} + + async def before_host_state(self, conn): + """Kaldes før hvert snapshot til storskærmen (fx store data, der kun sendes én gang).""" + + async def host_action(self, conn, action: str, content: dict, now: float) -> bool: + """Værtshandlinger ud over start/skip/play_again/kick. False = ukendt.""" + return False + + async def play_action(self, conn, member_id: str, action: str, content: dict, now: float): + """Telefonens handlinger i andre spil end Fup og Fakta (som consumers selv håndterer).""" + await conn.send_error("bad_request", "Ukendt handling.") + + # ── speakerens kø ──────────────────────────────────────────────────────── + def _synth(self, text: str, voice: str | None = None): + return tts.synth(text, voice or self.voice) + + def say(self, text: str | None, blocking: bool = True, voice: str | None = None): + """Læg en replik i kø. Lyden genereres med det samme, men udgives i rækkefølge. + blocking=False: en sidebemærkning, som ikke holder skærmen tilbage (fx "det var sandheden").""" + if not text: + return + if self._voice_queue is None: + self._voice_queue = asyncio.Queue() + asyncio.get_running_loop().create_task(self._voice_worker()) + key = self.game.phase_key() + started = time.time() if blocking else None + if blocking: + self._pending.setdefault(key, []).append(started) + task = asyncio.get_running_loop().create_task(self._synth(text, voice)) + self._voice_queue.put_nowait((text, task, key, started)) + self._update_hold() + + async def _voice_worker(self): + while True: + text, task, key, started = await self._voice_queue.get() + try: + audio = await task + except Exception: # noqa: BLE001 + audio = None + pending = self._pending.get(key, []) + if started is not None and started in pending: + pending.remove(started) + if not pending: + self._pending.pop(key, None) + # En replik til en skærm, der allerede er væk, afspilles ikke. + if audio and key == self.game.phase_key(): + self._publish(text, audio, key, blocking=started is not None) + self._update_hold() + self._schedule_timer() + + def _publish(self, text: str, audio: dict, key: str, blocking: bool = True): + now = time.time() + if blocking: + start = max(now, self.speech_end) + self.speech_end = start + audio["ms"] / 1000 + SPEECH_GAP + self._narr_seq += 1 + self.narration.append({"id": self._narr_seq, "url": audio["url"], "ms": audio["ms"], + "at": int(now * 1000), "text": untag(text), "key": key}) + del self.narration[:-NARRATION_KEEP] + self.broadcast() + + async def forward_reaction(self, reaction: dict): + """Emojis fra telefonerne går direkte til storskærmen (ingen fuld tilstand; de må gerne spammes).""" + msg = {"type": "react", **reaction} + for conn in list(self.connections): + if getattr(conn, "is_host", False): + try: + await conn.send_json(msg) + except Exception: # noqa: BLE001 + pass + + +class Room(BaseRoom): + """Et rum med Fup og Fakta.""" + kind = "fof" + + def __init__(self, code: str, game: Game, host_username: str): + super().__init__(code, game, host_username) + self.narrator = Narrator(fest=game.fest) + # Fest-spil har sin egen speaker til alt; ellers bruges kategoriens/standardstemmen + self.voice = (settings.FEST_VOICE_ID or None) if game.fest else None + self._last_join_line = 0.0 + self._lobby_waiting_said = False + # Hjernen og hukommelsen + self.brain = get_brain(fest=game.fest) + self.memory: list[str] = [] + self._tease_texts: dict[str, str] = {} + self.fooled_pairs: dict[tuple[str, str], int] = {} # (narret, forfatter) -> antal gange i aften + self._progress_said: list[float] = [] + self._attempts: dict[str, int] = {} + # Rummets mikrofon (eksperimentel) + self.mic_last = 0.0 + self.mic_laughs = 0 + self._mic_busy = False + # ── speakeren ──────────────────────────────────────────────────────────── def handle_events(self): """Tøm motorens begivenheder og lad speakeren reagere. Kaldes også efter afviste handlinger.""" @@ -388,54 +484,6 @@ class Room: def _question_voice(self) -> str | None: return (self.game.question or {}).get("voice") - def _synth(self, text: str, voice: str | None = None): - return tts.synth(text, voice or self.voice) - - def say(self, text: str | None, blocking: bool = True, voice: str | None = None): - """Læg en replik i kø. Lyden genereres med det samme, men udgives i rækkefølge. - blocking=False: en sidebemærkning, som ikke holder skærmen tilbage (fx "det var sandheden").""" - if not text: - return - if self._voice_queue is None: - self._voice_queue = asyncio.Queue() - asyncio.get_running_loop().create_task(self._voice_worker()) - key = self.game.phase_key() - started = time.time() if blocking else None - if blocking: - self._pending.setdefault(key, []).append(started) - task = asyncio.get_running_loop().create_task(self._synth(text, voice)) - self._voice_queue.put_nowait((text, task, key, started)) - self._update_hold() - - async def _voice_worker(self): - while True: - text, task, key, started = await self._voice_queue.get() - try: - audio = await task - except Exception: # noqa: BLE001 - audio = None - pending = self._pending.get(key, []) - if started is not None and started in pending: - pending.remove(started) - if not pending: - self._pending.pop(key, None) - # En replik til en skærm, der allerede er væk, afspilles ikke. - if audio and key == self.game.phase_key(): - self._publish(text, audio, key, blocking=started is not None) - self._update_hold() - self._schedule_timer() - - def _publish(self, text: str, audio: dict, key: str, blocking: bool = True): - now = time.time() - if blocking: - start = max(now, self.speech_end) - self.speech_end = start + audio["ms"] / 1000 + SPEECH_GAP - self._narr_seq += 1 - self.narration.append({"id": self._narr_seq, "url": audio["url"], "ms": audio["ms"], - "at": int(now * 1000), "text": untag(text), "key": key}) - 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: @@ -594,16 +642,6 @@ class Room: g.start_rules_timeline(time.time(), [max(2500, a["ms"] + 600) if a else engine.RULES_STEP_MS for a in results]) self.changed() - async def forward_reaction(self, reaction: dict): - """Emojis fra telefonerne går direkte til storskærmen (ingen fuld tilstand; de må gerne spammes).""" - msg = {"type": "react", **reaction} - for conn in list(self.connections): - if getattr(conn, "is_host", False): - try: - await conn.send_json(msg) - except Exception: # noqa: BLE001 - pass - async def _brain_teases(self, names: dict): """Personlige drillerier til de narrede; lyden laves med det samme, så den er klar, når stemplet lander.""" g = self.game