CI / test (push) Successful in 23s
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
252 lines
9.7 KiB
Python
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
|