Den tidligere version (Angular-SPA, REST og polling) er gemt på branchen legacy. Denne version samler alle regler i en ren Python-spilmotor (fupogfakta/engine.py), holder rummene i hukommelsen i én proces og sender hele spiltilstanden pr. rolle over én WebSocket. Vært-login sker via Authentik forward-auth i NPMplus. Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
116 lines
3.5 KiB
Python
116 lines
3.5 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
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
CODE_LETTERS = "BCDFGHJKLMNPQRSTVWXZ"
|
|
IDLE_TIMEOUT = 3 * 3600
|
|
|
|
_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
|
|
|
|
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._schedule_timer()
|
|
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()
|
|
|
|
|
|
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
|