Files
weirsoe-party-protocol/party/rooms.py
T
agw 9a53d8fe04 Start forfra: server-autoritativ spilmotor over WebSocket
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
2026-10-01 20:40:00 +02:00

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