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
643 lines
27 KiB
Python
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
|