Rum: fælles BaseRoom til timer, udsendelse og speaker

Det, der ikke handler om Fup og Fakta, flytter op i BaseRoom, så et
andet spil kan bruge det. Speakeren kan få sin egen lines.json.
Fup og Fakta opfører sig som før.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agw
2026-10-20 17:05:22 +02:00
co-authored by Claude Opus 5.5
parent dd07843db1
commit e2862d6200
2 changed files with 119 additions and 80 deletions
+4 -3
View File
@@ -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 "<begivenhed>_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, [])
+115 -77
View File
@@ -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