Files
weirsoe-party-protocol/fupogfakta/engine.py
T
agw d2dfeafea0
CI / test (push) Successful in 23s
Speaker med stemme: replikker læses op med ElevenLabs
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
2026-10-01 21:57:00 +02:00

643 lines
27 KiB
Python

"""Spilmotoren for Fup og Fakta.
Ren Python uden Django og uden I/O: alle handlinger tager `now` (sekunder siden
epoch) som parameter, så hele spillet kan testes deterministisk. Rummet i
`party.rooms` ejer en instans pr. spil, kalder handlingerne fra WebSocket'en og
`tick()` når en deadline udløber, og sender `snapshot()` til hver skærm.
"""
from __future__ import annotations
import difflib
import random
import re
import secrets
import unicodedata
from dataclasses import dataclass, field
# Runder: (navn, pointmultiplikator, antal spørgsmål). Sidste runde er finalen.
ROUNDS = [("Runde 1", 1, 3), ("Runde 2", 2, 3), ("Finale", 3, 1)]
POINTS_CORRECT = 1000
POINTS_FOOLED = 500
POINTS_LIKE = 100
POINTS_AUDIENCE = 500
CATEGORY_SECONDS = 15
LIE_SECONDS = 60
GUESS_SECONDS = 30
SCORES_SECONDS = 10
REVEAL_STEP_MS = 4000
REVEAL_TAIL_MS = 3000
MIN_PLAYERS = 2
MAX_PLAYERS = 8
MAX_NAME = 12
MAX_LIE = 40
CATEGORY_OPTIONS = 4
COLORS = ["#ff4d6d", "#4cc9f0", "#ffd166", "#06d6a0", "#b388ff", "#ff9f1c", "#f15bb5", "#90e0ef"]
TIMED_PHASES = {"category", "lie", "guess", "reveal", "scores"}
class GameError(Exception):
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
self.message = message
def normalize(text: str) -> str:
"""Til sammenligning af svar: små bogstaver, ingen tegnsætning, ingen indledende kendeord."""
text = unicodedata.normalize("NFC", text).casefold()
text = re.sub(r"[^\w\s]", " ", text)
text = re.sub(r"\s+", " ", text).strip()
text = re.sub(r"^(en|et|den|det|de|the|a|an) ", "", text)
return text
def clean(text: str) -> str:
return re.sub(r"\s+", " ", (text or "")).strip()
@dataclass
class Player:
id: str
name: str
color: str
token: str
role: str = "player" # player | audience
score: int = 0
connected: bool = False
joined_order: int = 0
fooled: int = 0 # antal gange andre valgte spillerens løgn
correct: int = 0
likes: int = 0
round_delta: int = 0
last_delta: int | None = None
@dataclass
class Answer:
id: str
text: str
is_truth: bool = False
house: bool = False
authors: list[str] = field(default_factory=list)
class Game:
def __init__(self, code: str, questions: list[dict], join_url: str = "", rng: random.Random | None = None):
self.code = code
self.join_url = join_url
self.rng = rng or random.Random()
self.questions = list(questions)
self.used_questions: set = set()
self.players: dict[str, Player] = {}
self.audience: dict[str, Player] = {}
self._counter = 0
self.phase = "lobby"
self.deadline: float | None = None
self.round_idx = 0
self.q_idx = 0
self.chooser_rotation = 0
self.chooser_id: str | None = None
self.category_options: list[dict] = []
self.question: dict | None = None
self.lies: dict[str, str] = {}
self.answers: list[Answer] = []
self.guesses: dict[str, str] = {}
self.audience_votes: dict[str, str] = {}
self.likes: dict[str, set[str]] = {}
self.reveal_steps: list[dict] = []
self.final_result: dict | None = None
self.reveal_started_at: float | None = None
self.reveal_steps_ms: list[int] = []
self.reveal_tail_ms = REVEAL_TAIL_MS
self.reveal_note_audio: dict | None = None
self.version = 0
# Begivenheder til speakeren (party.narration). Motoren lægger dem her;
# rummet tømmer listen efter hver handling.
self.events: list[dict] = []
# ── hjælpere ────────────────────────────────────────────────────────────
def _next_id(self, prefix: str) -> str:
self._counter += 1
return f"{prefix}{self._counter}"
def _changed(self):
self.version += 1
def _emit(self, kind: str, **data):
self.events.append({"type": kind, **data})
def drain_events(self) -> list[dict]:
events, self.events = self.events, []
return events
def standings(self) -> tuple[Player | None, Player | None]:
"""(førende, sidst) blandt spillerne."""
ranked = sorted(self.players.values(), key=lambda p: (-p.score, p.joined_order))
return (ranked[0], ranked[-1]) if ranked else (None, None)
def _member(self, member_id: str) -> Player | None:
return self.players.get(member_id) or self.audience.get(member_id)
def _require_phase(self, *phases):
if self.phase not in phases:
raise GameError("wrong_phase", "Det kan du ikke lige nu.")
def _require_player(self, member_id: str) -> Player:
p = self.players.get(member_id)
if not p:
raise GameError("not_allowed", "Det kan kun spillere.")
return p
@property
def round(self):
return ROUNDS[self.round_idx]
@property
def multiplier(self) -> int:
return self.round[1]
@property
def is_final_round(self) -> bool:
return self.round_idx == len(ROUNDS) - 1
def vip_id(self) -> str | None:
return next(iter(self.players), None)
def _active_players(self) -> list[Player]:
"""Spillere, der kan holde spillet tilbage: dem der er forbundet."""
return [p for p in self.players.values() if p.connected]
# ── medlemmer ───────────────────────────────────────────────────────────
def join(self, name: str) -> Player:
name = clean(name)
if not name or len(name) > MAX_NAME:
raise GameError("name_invalid", f"Navnet skal være 1-{MAX_NAME} tegn.")
taken = {normalize(p.name) for p in list(self.players.values()) + list(self.audience.values())}
if normalize(name) in taken:
raise GameError("name_taken", "Det navn er taget. Vælg et andet.")
as_player = self.phase == "lobby" and len(self.players) < MAX_PLAYERS
used_colors = {p.color for p in self.players.values()}
color = next((c for c in COLORS if c not in used_colors), COLORS[0]) if as_player else "#8d99ae"
p = Player(id=self._next_id("p" if as_player else "u"), name=name, color=color,
token=secrets.token_urlsafe(16), role="player" if as_player else "audience",
connected=True, joined_order=self._counter)
(self.players if as_player else self.audience)[p.id] = p
self._emit("join", name=p.name, role=p.role)
self._changed()
return p
def resume(self, token: str) -> Player:
for p in list(self.players.values()) + list(self.audience.values()):
if secrets.compare_digest(p.token, token or ""):
return p
raise GameError("unknown_token", "Vi kunne ikke finde dig i spillet. Join igen.")
def set_connected(self, member_id: str, connected: bool, now: float) -> None:
p = self._member(member_id)
if p and p.connected != connected:
p.connected = connected
self._changed()
# En der forsvinder, må ikke holde de andre tilbage.
if not connected:
self._maybe_finish_early(now)
def kick(self, member_id: str, now: float) -> None:
p = self.players.pop(member_id, None) or self.audience.pop(member_id, None)
if not p:
return
self.lies.pop(member_id, None)
self.guesses.pop(member_id, None)
self.audience_votes.pop(member_id, None)
if self.phase == "category" and self.chooser_id == member_id:
self._choose(self.rng.choice(self.category_options)["id"], now)
else:
self._maybe_finish_early(now)
if self.phase != "lobby" and len(self.players) == 0:
self._to_lobby()
self._changed()
# ── spilforløb ──────────────────────────────────────────────────────────
def start(self, member_id: str | None, now: float) -> None:
"""member_id None = værten."""
self._require_phase("lobby")
if member_id is not None and member_id != self.vip_id():
raise GameError("not_allowed", "Det er kun VIP'en, der kan starte spillet.")
if len(self.players) < MIN_PLAYERS:
raise GameError("too_few_players", f"Der skal være mindst {MIN_PLAYERS} spillere.")
self._reset_scores()
self.round_idx = 0
self.q_idx = 0
self._emit("game_start", players=len(self.players))
self._begin_question(now)
def play_again(self, member_id: str | None, now: float) -> None:
self._require_phase("final")
if member_id is not None and member_id != self.vip_id():
raise GameError("not_allowed", "Det er kun VIP'en, der kan starte et nyt spil.")
# Publikum, der kom til undervejs, rykker op som spillere, hvis der er plads.
for a in list(self.audience.values()):
if len(self.players) >= MAX_PLAYERS:
break
del self.audience[a.id]
a.role = "player"
used = {p.color for p in self.players.values()}
a.color = next((c for c in COLORS if c not in used), COLORS[0])
self.players[a.id] = a
self.phase = "lobby"
self._emit("play_again")
self.start(None, now)
def _to_lobby(self):
self.phase = "lobby"
self.deadline = None
self.question = None
def _reset_scores(self):
for p in self.players.values():
p.score = p.fooled = p.correct = p.likes = p.round_delta = 0
p.last_delta = None
self.final_result = None
def _available_categories(self) -> dict:
cats: dict = {}
for q in self.questions:
if q["id"] not in self.used_questions:
cats.setdefault(q["category_id"], q["category"])
if not cats and self.questions:
self.used_questions.clear() # banken er brugt op: start forfra
return self._available_categories()
return cats
def _begin_question(self, now: float):
cats = self._available_categories()
if not cats:
raise GameError("no_questions", "Der er ingen spørgsmål i banken.")
self.round_deltas_reset_if_new_round()
if self.q_idx == 0:
leader, last = self.standings()
self._emit("round_intro", round=self.round_idx, leader=leader.name if leader else "",
last=last.name if last else "")
if self.is_final_round:
self._choose(self.rng.choice(list(cats)), now)
return
players = list(self.players.values())
connected = [p for p in players if p.connected] or players
chooser = connected[self.chooser_rotation % len(connected)]
self.chooser_rotation += 1
self.chooser_id = chooser.id
picks = self.rng.sample(list(cats.items()), min(CATEGORY_OPTIONS, len(cats)))
self.category_options = [{"id": cid, "name": name} for cid, name in picks]
self.phase = "category"
self.deadline = now + CATEGORY_SECONDS
self._emit("category", chooser=chooser.name)
self._changed()
def round_deltas_reset_if_new_round(self):
if self.q_idx == 0:
for p in self.players.values():
p.round_delta = 0
def choose_category(self, member_id: str, category_id, now: float) -> None:
self._require_phase("category")
if member_id != self.chooser_id:
raise GameError("not_allowed", "Det er ikke dig, der vælger kategori.")
if category_id not in {c["id"] for c in self.category_options}:
raise GameError("answer_invalid", "Ukendt kategori.")
self._choose(category_id, now)
def _choose(self, category_id, now: float):
pool = [q for q in self.questions if q["category_id"] == category_id and q["id"] not in self.used_questions]
if not pool:
pool = [q for q in self.questions if q["id"] not in self.used_questions] or self.questions
self.question = self.rng.choice(pool)
self.used_questions.add(self.question["id"])
self.lies = {}
self.guesses = {}
self.audience_votes = {}
self.likes = {}
self.answers = []
self.reveal_steps = []
for p in self.players.values():
p.last_delta = None
self.reveal_started_at = None
self.reveal_note_audio = None
self.phase = "lie"
self.deadline = now + LIE_SECONDS
self._emit("lie_start", category=self.question["category"], prompt=self.question["prompt"])
self._changed()
def _truths(self) -> list[str]:
q = self.question
return [normalize(t) for t in [q["answer"], *q.get("alternatives", [])] if normalize(t)]
def _is_truth(self, text: str) -> bool:
n = normalize(text)
for t in self._truths():
if n == t:
return True
if len(t) >= 4 and difflib.SequenceMatcher(None, n, t).ratio() >= 0.85:
return True
return False
def submit_lie(self, member_id: str, text: str, now: float) -> None:
self._require_phase("lie")
self._require_player(member_id)
if member_id in self.lies:
raise GameError("already_done", "Du har allerede afleveret din løgn.")
text = clean(text)
if not text or len(text) > MAX_LIE or not normalize(text):
raise GameError("lie_invalid", f"Løgnen skal være 1-{MAX_LIE} tegn.")
if self._is_truth(text):
self._emit("lie_was_truth")
raise GameError("lie_is_truth", "Det er faktisk sandheden! Find på en løgn i stedet.")
self.lies[member_id] = text
self._changed()
self._maybe_finish_early(now)
def lie_for_me(self, member_id: str) -> str:
self._require_phase("lie")
self._require_player(member_id)
taken = {normalize(t) for t in self.lies.values()}
options = [d for d in self.question.get("decoys", []) if normalize(d) not in taken]
if not options:
options = self.question.get("decoys", []) or ["noget helt andet"]
return self.rng.choice(options)
def _start_guess(self, now: float):
grouped: dict[str, Answer] = {}
for pid, text in self.lies.items():
key = normalize(text)
if key not in grouped:
grouped[key] = Answer(id="", text=text)
grouped[key].authors.append(pid)
answers = list(grouped.values())
answers.append(Answer(id="", text=self.question["answer"], is_truth=True))
target = max(4, len(self.players) + 1)
taken = set(grouped) | set(self._truths())
decoys = [d for d in self.question.get("decoys", []) if normalize(d) not in taken]
self.rng.shuffle(decoys)
while len(answers) < target and decoys:
answers.append(Answer(id="", text=decoys.pop(), house=True))
self.rng.shuffle(answers)
for i, a in enumerate(answers, 1):
a.id = f"a{i}"
self.answers = answers
self.phase = "guess"
self.deadline = now + GUESS_SECONDS
self._emit("guess_start")
self._changed()
def _answer(self, answer_id: str) -> Answer:
for a in self.answers:
if a.id == answer_id:
return a
raise GameError("answer_invalid", "Det svar findes ikke.")
def guess(self, member_id: str, answer_id: str, now: float) -> None:
self._require_phase("guess")
member = self._member(member_id)
if not member:
raise GameError("not_allowed", "Du er ikke med i spillet.")
answer = self._answer(answer_id)
if member.role == "player":
if member_id in self.guesses:
raise GameError("already_done", "Du har allerede gættet.")
if member_id in answer.authors:
raise GameError("answer_invalid", "Du kan ikke vælge din egen løgn.")
self.guesses[member_id] = answer_id
else:
if member_id in self.audience_votes:
raise GameError("already_done", "Du har allerede stemt.")
self.audience_votes[member_id] = answer_id
self._changed()
self._maybe_finish_early(now)
def like(self, member_id: str, answer_id: str) -> None:
self._require_phase("guess", "reveal")
member = self._member(member_id)
if not member:
raise GameError("not_allowed", "Du er ikke med i spillet.")
if self.phase == "guess" and member.role == "player" and member_id not in self.guesses:
raise GameError("wrong_phase", "Gæt først, så kan du like bagefter.")
answer = self._answer(answer_id)
if member_id in answer.authors:
raise GameError("answer_invalid", "Du kan ikke like din egen løgn.")
voters = self.likes.setdefault(answer_id, set())
sign = -1 if member_id in voters else 1
(voters.discard if sign < 0 else voters.add)(member_id)
for author in answer.authors:
if author in self.players:
a = self.players[author]
a.score += sign * POINTS_LIKE
a.round_delta += sign * POINTS_LIKE
a.likes += sign
a.last_delta = (a.last_delta or 0) + sign * POINTS_LIKE
self._changed()
def _maybe_finish_early(self, now: float):
active = self._active_players()
if self.phase == "lie" and active and all(p.id in self.lies for p in active):
self._emit("lie_all_done")
self._start_guess(now)
elif self.phase == "guess" and active and all(p.id in self.guesses for p in active):
self._start_reveal(now)
def _award(self, pid: str, delta: int, step: dict):
p = self.players.get(pid)
if not p or delta == 0:
return
p.score += delta
p.round_delta += delta
p.last_delta = (p.last_delta or 0) + delta
step["points"].append({"player_id": pid, "delta": delta})
def _start_reveal(self, now: float):
m = self.multiplier
audience_total = len(self.audience_votes)
steps = []
for a in self.answers:
picked_by = [pid for pid, aid in self.guesses.items() if aid == a.id]
votes = sum(1 for aid in self.audience_votes.values() if aid == a.id)
if not a.is_truth and not picked_by and not votes:
continue
step = {"answer_id": a.id, "text": a.text, "is_truth": a.is_truth, "house": a.house,
"authors": list(a.authors), "guessed_by": picked_by, "audience_votes": votes,
"likes": len(self.likes.get(a.id, ())), "points": []}
if a.is_truth:
for pid in picked_by:
self.players[pid].correct += 1
self._award(pid, POINTS_CORRECT * m, step)
else:
for author in a.authors:
if author in self.players:
self.players[author].fooled += len(picked_by)
bonus = POINTS_FOOLED * m * len(picked_by)
if audience_total:
bonus += round(POINTS_AUDIENCE * m * votes / audience_total)
self._award(author, bonus, step)
steps.append(step)
# Løgne først (den mindst valgte først), sandheden til sidst.
steps.sort(key=lambda s: (s["is_truth"], len(s["guessed_by"]) + s["audience_votes"]))
self.reveal_steps = steps
self.phase = "reveal"
self.start_reveal_timeline(now)
self._emit("reveal")
self._changed()
def start_reveal_timeline(self, now: float, steps_ms: list[int] | None = None, tail_ms: int | None = None):
"""Afsløringen kører trin for trin fra `now`. Speakeren kan give hvert trin sin egen længde."""
self.reveal_steps_ms = list(steps_ms or [REVEAL_STEP_MS] * len(self.reveal_steps))
self.reveal_tail_ms = REVEAL_TAIL_MS if tail_ms is None else tail_ms
self.reveal_started_at = now
self.deadline = now + (sum(self.reveal_steps_ms) + self.reveal_tail_ms) / 1000
self._changed()
def hold_reveal(self, now: float, max_wait: float = 10.0):
"""Vent med at starte afsløringen (speakeren laver lyd). Starter selv efter max_wait."""
if self.phase == "reveal":
self.reveal_started_at = None
self.deadline = now + max_wait
self._changed()
def _after_reveal(self, now: float):
self.q_idx += 1
if self.q_idx < self.round[2]:
self._begin_question(now)
elif self.is_final_round:
self._finish()
else:
self.phase = "scores"
self.deadline = now + SCORES_SECONDS
leader, last = self.standings()
self._emit("scores", leader=leader.name if leader else "", last=last.name if last else "")
self._changed()
def _after_scores(self, now: float):
self.round_idx += 1
self.q_idx = 0
self._begin_question(now)
def _finish(self):
ranking = sorted(self.players.values(), key=lambda p: (-p.score, p.joined_order))
places, last_score, place = [], None, 0
for i, p in enumerate(ranking, 1):
if p.score != last_score:
place, last_score = i, p.score
places.append({"player_id": p.id, "score": p.score, "place": place})
awards = []
def best(attr, title, detail):
top = max(self.players.values(), key=lambda p: getattr(p, attr), default=None)
if top and getattr(top, attr) > 0:
awards.append({"title": title, "player_id": top.id, "detail": detail(getattr(top, attr)),
"kind": attr, "count": getattr(top, attr)})
best("fooled", "Største løgner", lambda n: f"narrede andre {n} gang{'e' if n != 1 else ''}")
best("correct", "Sandhedsvidne", lambda n: f"fandt sandheden {n} gang{'e' if n != 1 else ''}")
best("likes", "Folkets favorit", lambda n: f"fik {n} like{'s' if n != 1 else ''}")
self.final_result = {"ranking": places, "awards": awards}
winner = ranking[0] if ranking else None
self._emit("final", winner=winner.name if winner else "", points=winner.score if winner else 0,
awards=[{"kind": a["kind"], "name": self.players[a["player_id"]].name, "count": a["count"]}
for a in awards])
self.phase = "final"
self.deadline = None
self._changed()
def skip(self, now: float) -> None:
if self.phase not in TIMED_PHASES:
raise GameError("wrong_phase", "Der er ikke noget at springe over.")
self._advance(now)
def tick(self, now: float) -> bool:
"""Kaldes når deadline er nået. Returnerer True hvis noget ændrede sig."""
if self.deadline is None or now < self.deadline:
return False
self._advance(now)
return True
def _advance(self, now: float):
if self.phase == "category":
pick = self.rng.choice(self.category_options)
chooser = self.players.get(self.chooser_id)
self._emit("category_timeout", chooser=chooser.name if chooser else "", category=pick["name"])
self._choose(pick["id"], now)
elif self.phase == "lie":
self._start_guess(now)
elif self.phase == "guess":
self._start_reveal(now)
elif self.phase == "reveal":
if self.reveal_started_at is None:
self.start_reveal_timeline(now) # lyden blev aldrig klar: kør uden
else:
self._after_reveal(now)
elif self.phase == "scores":
self._after_scores(now)
# ── snapshot ────────────────────────────────────────────────────────────
def snapshot(self, now: float, viewer: Player | None = None, host: bool = False) -> dict:
vip = self.vip_id()
done_set = set(self.lies) if self.phase == "lie" else set(self.guesses) if self.phase == "guess" else set()
state = {
"code": self.code,
"phase": self.phase,
"server_time": int(now * 1000),
"deadline": int(self.deadline * 1000) if self.deadline else None,
"join_url": self.join_url,
"min_players": MIN_PLAYERS,
"max_players": MAX_PLAYERS,
"round": None,
"players": [
{"id": p.id, "name": p.name, "color": p.color, "score": p.score, "connected": p.connected,
"vip": p.id == vip, "done": p.id in done_set}
for p in self.players.values()
],
"audience_count": len(self.audience),
}
if self.phase not in ("lobby", "final"):
name, mult, count = self.round
state["round"] = {"number": self.round_idx + 1, "name": name, "multiplier": mult,
"question_number": self.q_idx + 1, "questions_in_round": count}
if self.phase == "category":
state["category"] = {"chooser_id": self.chooser_id, "options": self.category_options}
if self.phase in ("lie", "guess", "reveal") and self.question:
state["question"] = {"category": self.question["category"], "prompt": self.question["prompt"]}
if self.phase in ("guess", "reveal"):
hide = viewer.id if viewer and viewer.role == "player" and self.phase == "guess" else None
state["answers"] = [{"id": a.id, "text": a.text} for a in self.answers if hide not in a.authors]
if self.phase == "reveal":
state["reveal"] = {"steps": self.reveal_steps, "step_ms": REVEAL_STEP_MS,
"steps_ms": self.reveal_steps_ms, "tail_ms": self.reveal_tail_ms,
"started_at": int(self.reveal_started_at * 1000) if self.reveal_started_at else None,
"note": self.question.get("note", "") if self.question else "",
"note_audio": self.reveal_note_audio}
if self.phase == "scores":
state["deltas"] = [{"player_id": p.id, "delta": p.round_delta} for p in self.players.values()]
if self.phase == "final":
state["final"] = self.final_result
if host:
state["host"] = True
elif viewer:
own_answer = next((a.id for a in self.answers if viewer.id in a.authors), None)
state["me"] = {
"id": viewer.id,
"name": viewer.name,
"role": viewer.role,
"vip": viewer.id == vip,
"lie": self.lies.get(viewer.id) if self.phase in ("lie", "guess", "reveal") else None,
"own_answer_id": own_answer if self.phase in ("guess", "reveal") else None,
"guess": (self.guesses if viewer.role == "player" else self.audience_votes).get(viewer.id)
if self.phase in ("guess", "reveal") else None,
"likes": sorted(aid for aid, voters in self.likes.items() if viewer.id in voters),
"is_chooser": self.phase == "category" and viewer.id == self.chooser_id,
"last_delta": viewer.last_delta if self.phase in ("reveal", "scores") else None,
"score": viewer.score,
}
return state