Files
weirsoe-party-protocol/spionen/engine.py
T
agwandClaude Opus 5.5 7a5c37290f
CI / test (push) Successful in 47s
Spionen: skærmenes overskrifter følger, hvem der blev fanget
Fra review og QA:
- Telefonen siger "Du slap væk!" til en spion, der ikke selv blev fanget
  (også når den anden spion blev det), "Du gættede ordet!" til den fangede
  spion, der gættede, og agenterne får "I fangede den ene spion", når den
  anden slap.
- Går den eneste spion i runden, siger storskærmen "Spionen forlod
  missionen" og telefonerne "Spionen forlod spillet" i stedet for, at
  spionen vandt (result.spies_left).
- Lighedsstemplet siger "spionerne slipper" med to spioner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:59:59 +02:00

667 lines
33 KiB
Python

"""Spilmotoren for Spionen (social deduktion: skriv et stikord, snak, stem).
Ren Python uden Django og uden I/O, i samme stil som `tegnoggaet.engine`: alle handlinger tager `now` (sekunder
siden epoch), rummet i `party.spion` kalder `tick()` når en deadline udløber og sender `snapshot()` til hver skærm.
En runde: alle undtagen spionen (to ved otte eller flere spillere) ser et hemmeligt ord; spionen ser kun temaet.
Alle skriver ét stikord, stikordene vises på storskærmen, I snakker, I stemmer. Flest stemmer bliver anklaget
(lighed: ingen anklages, og spionerne slipper). Er den anklagede spion, får den et sidste gæt på ordet.
Hemmeligheden er spillet: ordet og hvem der er spion står kun i det snapshot, der må se det (se snapshot()).
"""
from __future__ import annotations
import random
import re
import secrets
import unicodedata
from dataclasses import dataclass
from fupogfakta.engine import NAME_PUNCT, GameError, normalize, sanitize
MIN_PLAYERS = 4
MAX_PLAYERS = 10
TWO_SPIES_FROM = 8 # så mange spillere eller flere: to spioner
ROUNDS = 4 # standard; værten kan vælge 3-5
ROUND_CHOICES = (3, 4, 5)
MAX_NAME = 12
MAX_CLUE = 20
MAX_GUESS = 30
CLUE_SECONDS = 40
TALK_SECONDS = 60
VOTE_SECONDS = 30
GUESS_SECONDS = 30
VERDICT_SECONDS = 7 # stemmerne og den anklagede vises; til sidst afsløres, om det var spionen
RESULT_SECONDS = 10
RULES_STEP_MS = 5000
CLUES_INTRO_MS = 2000 # afsløringen af stikordene: kort intro, så ét stikord ad gangen
CLUE_MS = 2600
POINTS_VOTE = 100 # en, der ikke er spion, og som stemte på en spion
POINTS_SPY_FREE = 300 # spionen blev ikke fanget
POINTS_SPY_GUESS = 200 # spionen blev fanget, men gættede ordet
REACTIONS = ("😂", "😱", "👏", "🤯", "🙈", "🔥")
REACT_PER_SECOND = 6
COLORS = ["#ff4d6d", "#4cc9f0", "#ffd166", "#06d6a0", "#b388ff", "#ff9f1c", "#f15bb5", "#90e0ef",
"#c0ca33", "#e0aaff"]
TIMED_PHASES = {"rules", "clue", "clues", "talk", "vote", "verdict", "guess", "result"}
RULES = [
{"icon": "🕵️", "title": "Et hemmeligt ord", "body": "Alle ser det samme ord på telefonen. Spionen ser kun temaet.",
"say": "Sådan her spiller vi. Alle får det samme hemmelige ord på telefonen. Alle undtagen spionen, "
"som kun får temaet at vide."},
{"icon": "✏️", "title": "Ét stikord", "body": "Skriv ét ord, der viser, at du kender ordet. Uden at afsløre det.",
"say": "Alle skriver ét stikord. Det skal vise, at I kender ordet, men uden at spionen kan gætte det."},
{"icon": "🗣️", "title": "Snak sammen", "body": "Stikordene kommer på skærmen. Så har I et minut til at snakke.",
"say": "Så viser jeg stikordene på skærmen, og I får et minut til at snakke sammen. Hvem lyder mistænkelig?"},
{"icon": "🗳️", "title": "Stem", "body": "Stem på den, I tror er spionen. Lige mange stemmer: spionen slipper.",
"say": "Til sidst stemmer I på telefonen. Får to lige mange stemmer, slipper spionen."},
{"icon": "🎯", "title": "Sidste chance", "body": "Bliver spionen anklaget, kan den stadig redde sig ved at gætte ordet.",
"say": "Og bliver spionen anklaget, får den en sidste chance. Gætter spionen ordet, er det alligevel en sejr."},
]
# Bøjninger: ordet med en af de endelser (også med fordoblet slutkonsonant, "kat" -> "katten", og uden slut-e,
# "pande" -> "pander"). Det er groft, men dækker navneordenes ental, flertal og bestemt form plus genitiv.
ENDINGS = ("", "e", "n", "en", "et", "t", "r", "er", "ne", "ene", "rne", "erne", "s", "es", "ns", "ens", "ets",
"rs", "ers", "nes", "enes", "rnes", "ernes")
@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
spy_points: int = 0 # til prisen "Bedste løgner"
right_votes: int = 0 # "Skarpeste øje"
suspected: int = 0 # "Mest mistænkt": stemmer fået i alt
# ── ord ──────────────────────────────────────────────────────────────────────
def squash(text: str) -> str:
"""Til sammenligning: små bogstaver, kun bogstaver og tal (mellemrum og bindestreger væk), æ, ø og å bevares."""
text = unicodedata.normalize("NFC", str(text or "")).casefold()
return "".join(ch for ch in text if ch.isalnum())
def _inflection(form: str, base: str) -> bool:
"""Er form en bøjning af base?"""
if not base:
return False
stems = [base]
if len(base) >= 3 and base[-1] not in "aeiouyæøå":
stems.append(base + base[-1]) # kat -> katt(en)
if len(base) >= 4 and base.endswith("e"):
stems.append(base[:-1]) # pande -> pand(er) (og "pander" er også base + "r")
return any(form.startswith(s) and form[len(s):] in ENDINGS for s in stems)
def levenshtein(a: str, b: str) -> int:
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i]
for j, cb in enumerate(b, 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
prev = cur
return prev[-1]
def typo_allowance(word: str) -> int:
"""Små stavefejl: ingen i ord under fire bogstaver, én op til syv, to i længere ord."""
n = len(word)
return 0 if n < 4 else 1 if n < 8 else 2
def same_word(a: str, b: str, typos: bool = True) -> bool:
"""Samme ord, uanset store/små bogstaver, mellemrum og bindestreger, bøjning og (med typos) små stavefejl."""
x, y = squash(a), squash(b)
if not x or not y:
return False
if x == y or _inflection(x, y) or _inflection(y, x):
return True
return typos and levenshtein(x, y) <= typo_allowance(y)
def contains_word(clue: str, word: str) -> bool:
"""Står ordet inde i stikordet (sammensatte ord: "hundehus", "pizzabager")? Kun for ord på tre bogstaver eller
flere, ellers ville fx "is" ramme alt for meget."""
w = squash(word)
return len(w) >= 3 and w in squash(clue)
def clean_text(text: str, code: str, what: str, max_len: int) -> str:
text = sanitize(str(text or "")).lower()
if not text or len(text) > max_len or not normalize(text):
raise GameError(code, f"{what} skal være 1-{max_len} bogstaver eller tal.")
return text
class Game:
game = "spion"
show_rules = True # reglerne før rummets første spil (testene slår dem fra)
fest = False
def __init__(self, code: str, themes: list[dict], join_url: str = "", rng: random.Random | None = None,
rounds: int = ROUNDS):
self.code = code
self.join_url = join_url
self.rng = rng or random.Random()
self.themes = [t for t in themes if t.get("words")] # [{"id", "name", "icon", "words": [...]}]
self.rounds = rounds if rounds in ROUND_CHOICES else ROUNDS
self.players: dict[str, Player] = {}
self.audience: dict[str, Player] = {}
self._counter = 0
self.phase = "lobby"
self.deadline: float | None = None
self.hold_until: float | None = None
self.version = 0
self.events: list[dict] = []
self.rules_done = False
self.rules_started_at: float | None = None
self.rules_steps_ms: list[int] = []
self.rules_audio: list | None = None
self.round = 0
self.order: list[str] = [] # spillerne i runden (dem der var med, da den startede)
self.spies: list[str] = []
self.spy_names: dict[str, str] = {}
self.last_spies: set[str] = set()
self.used_words: set[str] = set()
self.used_themes: list[str] = []
self.theme: dict | None = None
self.word: str | None = None
self.clues: dict[str, str] = {} # spiller -> stikord
self.clue_order: list[str] = [] # afsløringens rækkefølge (tilfældig)
self.clues_started_at: float | None = None
self.votes: dict[str, str] = {} # spiller -> den, spilleren stemte på
self.audience_votes: dict[str, str] = {}
self.accused: str | None = None
self.guess: dict | None = None # {"text", "correct"}
self.result: dict | None = None
self.final_result: dict | None = None
self._react_bucket: dict[str, list[float]] = {}
# ── 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 held(self, now: float) -> bool:
return self.hold_until is not None and now < self.hold_until
def phase_key(self) -> str:
return f"{self.round}.{self.phase}"
def vip_id(self) -> str | None:
return next(iter(self.players), 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 _in_round(self, member_id: str) -> bool:
return member_id in self.order and member_id in self.players
@property
def rules(self) -> list[dict]:
return RULES
@property
def reaction_set(self) -> tuple[str, ...]:
return REACTIONS
def _name(self, pid: str | None) -> str:
p = self.players.get(pid) if pid else None
return p.name if p else ""
# ── medlemmer ───────────────────────────────────────────────────────────
def join(self, name: str) -> Player:
name = sanitize(name, NAME_PUNCT)
if not name or len(name) > MAX_NAME:
raise GameError("name_invalid", f"Navnet skal være 1-{MAX_NAME} bogstaver eller tal.")
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 = {p.color for p in self.players.values()}
color = next((c for c in COLORS if c not in used), 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()
if not connected:
self._maybe_finish_early(now) # en der forsvinder, må ikke holde de andre tilbage
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.audience_votes.pop(member_id, None)
if self.phase != "lobby" and not self.players:
self.phase, self.deadline = "lobby", None
elif self.phase == "guess" and member_id == self.accused:
self._end_round(now) # den anklagede spion gik: intet gæt
else:
self._maybe_finish_early(now)
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.")
if not self.themes:
raise GameError("no_themes", "Der er ingen temaer.")
for p in self.players.values():
p.score = p.spy_points = p.right_votes = p.suspected = 0
self.round, self.final_result, self.last_spies = 0, None, set()
self.used_words, self.used_themes = set(), []
self._emit("game_start", players=len(self.players))
if self.show_rules and not self.rules_done:
self.rules_done = True
self.phase = "rules"
self.rules_audio = None
self.start_rules_timeline(now)
self._emit("rules")
return
self._begin_round(now)
def start_rules_timeline(self, now: float, steps_ms: list[int] | None = None):
self.rules_steps_ms = list(steps_ms or [RULES_STEP_MS] * len(self.rules))
self.rules_started_at = now
self.deadline = now + sum(self.rules_steps_ms) / 1000 + 1
self._changed()
def hold_rules(self, now: float, max_wait: float = 10.0):
if self.phase == "rules":
self.rules_started_at = None
self.deadline = now + max_wait
self._changed()
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.")
for a in list(self.audience.values()): # publikum rykker op, hvis der er plads
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 _pick_theme_and_word(self):
themes = [t for t in self.themes if t["id"] not in self.used_themes[-len(self.themes) + 1:]] or self.themes
self.theme = self.rng.choice(themes)
self.used_themes.append(self.theme["id"])
words = [w for w in self.theme["words"] if w not in self.used_words] or list(self.theme["words"])
self.word = self.rng.choice(words)
self.used_words.add(self.word)
def _pick_spies(self) -> list[str]:
"""Tilfældige spioner, men ingen er spion to runder i træk, hvis det kan undgås."""
n = 2 if len(self.order) >= TWO_SPIES_FROM else 1
fresh = [pid for pid in self.order if pid not in self.last_spies]
pool = fresh if len(fresh) >= n else list(self.order)
return self.rng.sample(pool, n)
def _begin_round(self, now: float):
if len(self.players) < MIN_PLAYERS: # for få tilbage til en runde til
self._finish() if self.round else self._to_lobby()
return
self.round += 1
self.order = list(self.players)
self._pick_theme_and_word()
self.spies = self._pick_spies()
self.spy_names = {pid: self.players[pid].name for pid in self.spies} # også hvis en spion går undervejs
self.last_spies = set(self.spies)
self.clues, self.clue_order, self.clues_started_at = {}, [], None
self.votes, self.audience_votes = {}, {}
self.accused, self.guess, self.result = None, None, None
self.phase = "clue"
self.deadline = now + CLUE_SECONDS
self._emit("round_start", round=self.round, of=self.rounds, theme=self.theme["name"],
last=self.round == self.rounds)
self._changed()
def _to_lobby(self):
self.phase, self.deadline = "lobby", None
self._changed()
# ── stikord ─────────────────────────────────────────────────────────────
def check_clue(self, member_id: str, text: str) -> str:
"""Ét ord, højst MAX_CLUE tegn, og ikke temaet. For dem, der kender ordet, heller ikke ordet selv eller en
bøjning af det (stavefejl tæller ikke her: "hvalp" er et fint stikord til "hval"), og ikke et stikord, en
anden har skrevet i runden. Spionens stikord holdes hverken op mod ordet eller de andres stikord: en
afvisning ville røbe ordet eller et af de spor, spionen skal gætte ud fra (skriver spionen det samme som en
anden, står det to gange på skærmen, og det er et godt spor)."""
clue = clean_text(text, "clue_invalid", "Stikordet", MAX_CLUE)
if " " in clue:
raise GameError("clue_invalid", "Skriv kun ét ord.")
spy = member_id in self.spies
if not spy and (same_word(clue, self.word, typos=False) or contains_word(clue, self.word)):
raise GameError("clue_is_word", "Det er jo ordet selv. Find på noget andet.")
theme_words = [w for w in re.split(r"[\s-]+", self.theme["name"]) if len(squash(w)) >= 3]
if any(same_word(clue, w, typos=False) for w in theme_words) or same_word(clue, self.theme["name"], typos=False):
raise GameError("clue_is_theme", "Temaet må ikke være dit stikord.")
if not spy and any(same_word(clue, c, typos=False) for pid, c in self.clues.items() if pid != member_id):
raise GameError("clue_taken", "Det stikord har en anden allerede skrevet.")
return clue
def submit_clue(self, member_id: str, text: str, now: float) -> None:
self._require_phase("clue")
if not self._in_round(member_id):
raise GameError("not_in_round", "Du er ikke med i denne runde.")
self.clues[member_id] = self.check_clue(member_id, text)
self._changed()
self._maybe_finish_early(now)
def _start_clues(self, now: float):
self.clue_order = [pid for pid in self.order if pid in self.players]
self.rng.shuffle(self.clue_order) # tilfældig rækkefølge: den siger intet om, hvem der er spion
self.phase = "clues"
self.clues_started_at = now
self.deadline = now + (CLUES_INTRO_MS + CLUE_MS * len(self.clue_order)) / 1000
self._emit("clues", count=len(self.clue_order), missing=sum(pid not in self.clues for pid in self.clue_order))
self._changed()
# ── afstemning ──────────────────────────────────────────────────────────
def vote(self, member_id: str, target: str, now: float) -> None:
self._require_phase("vote")
if target not in self.order or target not in self.players:
raise GameError("bad_vote", "Den spiller er ikke med i runden.")
if member_id in self.audience: # publikum stemmer for sjov; tæller ikke
self.audience_votes[member_id] = target
self._changed()
return
if not self._in_round(member_id):
raise GameError("not_in_round", "Du er ikke med i denne runde.")
if target == member_id:
raise GameError("bad_vote", "Du kan ikke stemme på dig selv.")
self.votes[member_id] = target
self._changed()
self._maybe_finish_early(now)
def _tally(self, votes: dict[str, str]) -> dict[str, int]:
counts: dict[str, int] = {}
for voter, target in votes.items():
if target in self.players and (voter in self.players or voter in self.audience):
counts[target] = counts.get(target, 0) + 1
return counts
def _verdict(self, now: float):
counts = self._tally(self.votes)
top = max(counts.values(), default=0)
leaders = [pid for pid, n in counts.items() if n == top]
self.accused = leaders[0] if top and len(leaders) == 1 else None # lighed: ingen anklages
for pid, n in counts.items():
self.players[pid].suspected += n
self.phase = "verdict"
self.deadline = now + VERDICT_SECONDS
self._emit("verdict", accused=self._name(self.accused), tie=self.accused is None and top > 0,
no_votes=top == 0, spy=self.accused in self.spies)
self._changed()
# ── spionens sidste chance ─────────────────────────────────────────────
def spy_guess(self, member_id: str, text: str, now: float) -> None:
self._require_phase("guess")
if member_id != self.accused:
raise GameError("not_allowed", "Det er kun den anklagede spion, der gætter.")
guess = clean_text(text, "guess_invalid", "Gættet", MAX_GUESS)
self.guess = {"text": guess, "correct": same_word(guess, self.word)}
self._end_round(now)
# ── resultat ────────────────────────────────────────────────────────────
def _end_round(self, now: float):
caught = self.accused if self.accused in self.spies else None
guessed = bool(caught and self.guess and self.guess["correct"])
deltas: dict[str, int] = {}
for pid in self.order:
p = self.players.get(pid)
if not p:
continue
if pid in self.spies:
d = POINTS_SPY_FREE if pid != caught else POINTS_SPY_GUESS if guessed else 0
p.spy_points += d
else:
d = POINTS_VOTE if self.votes.get(pid) in self.spies else 0
p.right_votes += d > 0
p.score += d
deltas[pid] = d
counts = self._tally(self.votes)
self.result = {"spies": list(self.spies), "spy_names": [self.spy_names.get(s, "?") for s in self.spies],
"word": self.word, "caught": caught, "guess": self.guess,
"spy_won": caught is None or guessed, "deltas": deltas,
"spies_left": not any(s in self.players for s in self.spies)}
self.phase = "result"
self.deadline = now + RESULT_SECONDS
self._emit("result", spies=[self.spy_names.get(s, "") for s in self.spies], word=self.word,
spies_left=not any(s in self.players for s in self.spies),
caught=bool(caught), guessed=guessed, accused_innocent=bool(self.accused and not caught),
tie=self.accused is None and bool(counts), last=self.round >= self.rounds)
self._changed()
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})
self.final_result = {"ranking": places, "awards": self._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}
for a in self.final_result["awards"]])
self.phase = "final"
self.deadline = None
self._changed()
def _awards(self) -> list[dict]:
"""Priserne. Ved lighed vinder den, der kom først i lobbyen."""
awards = []
players = sorted(self.players.values(), key=lambda p: p.joined_order)
def best(attr, kind, title, detail):
top = max(players, key=lambda p: getattr(p, attr), default=None)
if top and getattr(top, attr) > 0:
n = getattr(top, attr)
awards.append({"title": title, "player_id": top.id, "kind": kind, "detail": detail(n), "count": n})
best("spy_points", "liar", "Bedste løgner", lambda n: f"{n} point som spion")
best("right_votes", "sharp", "Skarpeste øje", lambda n: f"stemte rigtigt {n} gang{'e' if n != 1 else ''}")
best("suspected", "suspected", "Mest mistænkt", lambda n: f"{n} stemme{'r' if n != 1 else ''} i alt")
return awards
# ── reaktioner ──────────────────────────────────────────────────────────
def react(self, member_id: str, emoji: str, now: float) -> dict | None:
p = self._member(member_id)
if not p or emoji not in self.reaction_set:
return None
bucket = [t for t in self._react_bucket.get(member_id, []) if now - t < 1]
if len(bucket) >= REACT_PER_SECOND:
return None
bucket.append(now)
self._react_bucket[member_id] = bucket
return {"emoji": emoji, "color": p.color, "name": p.name}
# ── tid ─────────────────────────────────────────────────────────────────
def _all_done(self) -> bool:
if self.phase not in ("clue", "vote"):
return False
done = self.clues if self.phase == "clue" else self.votes
active = [pid for pid in self.order if pid in self.players and self.players[pid].connected]
return bool(active) and all(pid in done for pid in active)
def _maybe_finish_early(self, now: float):
if not self._all_done():
return
if self.held(now):
self.deadline = now # alle er færdige; skiftet venter på speakeren
self._changed()
else:
self._emit(f"{self.phase}_all_done")
self._advance(now)
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 or self.held(now):
return False
self._advance(now)
return True
def _advance(self, now: float):
if self.phase == "rules":
if self.rules_started_at is None:
self.start_rules_timeline(now) # lyden blev aldrig klar: vis kortene uden stemme
else:
self._begin_round(now)
elif self.phase == "clue":
self._start_clues(now)
elif self.phase == "clues":
self.phase, self.deadline = "talk", now + TALK_SECONDS
self._emit("talk")
self._changed()
elif self.phase == "talk":
self.phase, self.deadline = "vote", now + VOTE_SECONDS
self._emit("vote")
self._changed()
elif self.phase == "vote":
self._verdict(now)
elif self.phase == "verdict":
if self.accused in self.spies and self.accused in self.players:
self.phase, self.deadline = "guess", now + GUESS_SECONDS
self._emit("guess", name=self._name(self.accused))
self._changed()
else:
self._end_round(now)
elif self.phase == "guess":
self._end_round(now) # tiden løb ud: intet gæt
elif self.phase == "result":
if self.round >= self.rounds:
self._finish()
else:
self._begin_round(now)
# ── snapshot ────────────────────────────────────────────────────────────
def snapshot(self, now: float, viewer: Player | None = None, host: bool = False) -> dict:
"""Tilstanden til én skærm. Ordet står kun i kortet til dem, der kender det (aldrig til spionen, publikum
eller storskærmen før resultatet), og hvem der er spion står intet sted før resultatet: ikke i spillerlisten,
ikke i rækkefølger eller tider."""
vip = self.vip_id()
p_ph = self.phase
in_round = p_ph in ("clue", "clues", "talk", "vote", "verdict", "guess", "result")
done = self.clues if p_ph == "clue" else self.votes if p_ph == "vote" else {}
state = {
"game": self.game,
"code": self.code,
"phase": p_ph,
"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": {"number": self.round, "of": self.rounds, "name": f"Runde {self.round} af {self.rounds}"}
if in_round else 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, "in_round": p.id in self.order}
for p in self.players.values()
],
"audience_count": len(self.audience),
"fest": False,
"reactions": list(self.reaction_set),
}
if p_ph == "rules":
state["rules"] = {"items": [{k: r[k] for k in ("icon", "title", "body")} for r in self.rules],
"started_at": int(self.rules_started_at * 1000) if self.rules_started_at else None,
"steps_ms": self.rules_steps_ms, "audio": self.rules_audio}
if in_round:
state["theme"] = {"name": self.theme["name"], "icon": self.theme.get("icon", "")}
state["spies"] = 2 if len(self.order) >= TWO_SPIES_FROM else 1 # hvor mange, ikke hvem
if p_ph == "clues": # telefonerne kigger på storskærmen; den får stikordene og styrer tempoet
state["clues"] = {"started_at": int(self.clues_started_at * 1000), "intro_ms": CLUES_INTRO_MS,
"clue_ms": CLUE_MS, "items": self._clue_items() if host else []}
elif p_ph in ("talk", "vote", "verdict", "guess", "result"):
state["clues"] = {"items": self._clue_items()}
if p_ph in ("verdict", "guess", "result"):
counts, aud = self._tally(self.votes), self._tally(self.audience_votes)
state["votes"] = {"counts": [{"player_id": pid, "votes": counts.get(pid, 0)} for pid in self.order
if pid in self.players],
"audience": [{"player_id": pid, "votes": n} for pid, n in aud.items()],
"by": [{"voter": v, "target": t} for v, t in self.votes.items()] if p_ph == "result" else [],
"accused": self.accused,
# om den anklagede var spion: storskærmen afslører det i slutningen af "verdict";
# telefonerne får det først i "guess" (ellers stod svaret i deres data før tid)
"accused_is_spy": bool(self.accused in self.spies and (host or p_ph != "verdict"))}
if p_ph == "result":
state["result"] = self.result
if p_ph == "final":
state["final"] = self.final_result
if host:
state["host"] = True
elif viewer:
card = None
if in_round and viewer.role == "player" and viewer.id in self.order:
spy = viewer.id in self.spies
card = {"spy": spy, "theme": self.theme["name"], "word": None if spy else self.word}
state["me"] = {
"id": viewer.id, "name": viewer.name, "role": viewer.role, "vip": viewer.id == vip,
"score": viewer.score, "card": card,
"clue": self.clues.get(viewer.id) if in_round else None,
"vote": (self.audience_votes if viewer.role == "audience" else self.votes).get(viewer.id)
if in_round else None,
"can_guess": p_ph == "guess" and viewer.id == self.accused,
}
return state
def _clue_items(self) -> list[dict]:
return [{"player_id": pid, "text": self.clues.get(pid)} for pid in self.clue_order]