- party/games.py: Spionen i menuen (4-10 spillere, 10-15 min, runder 3/4/5,
regler til/fra), så intro og skift af spil i rummet virker.
- party/spion.py: SpionRoom med telefonens handlinger (start, submit_clue,
vote, spy_guess, react, play_again), speakerens replikker pr. begivenhed
og et "ti sekunder" før stikordenes deadline. Reglernes oplæsning er
BaseRoom's.
- Speakeren (spionen/narrator/lines.json og generic.json) siger aldrig
ordet eller hvem der er spion før resultatet: dommen spørger kun, svaret
kommer med gættet eller resultatet. Replikker med navne eller ordet har
generiske afløsere, så spillet kører med WPP_TTS_LIVE=off. Replikken med
ordet ved resultatet forvarmes ikke (et klip pr. ord); uden live-stemme
siges den generiske ("Spionen og ordet står nu på skærmen").
- spion.js: telefonens rollekort (skjult, til man trykker, og lukker igen
ved hver ny fase), stikord, afstemning med store knapper, spionens gæt og
resultatet; storskærmens tema, stikordene et ad gangen, snakken med
nedtælling, stemmerne og stemplet "SPION!"/"Uskyldig!" i dommens sidste
sekunder, gættet og resultatet. Under dommen viser telefonen kun
"Stemmerne er talt". host.js: et spil kan melde sine egne tidsfaser
(spring over).
Test (test_spion): helt spil over WebSocket med fire telefoner, og skift
fra Tegn og Gæt til Spionen i samme rum; hver besked til hver telefon
læses for ordet og spionlisten. Speakeren: ordet siges aldrig før
resultatet, og alt uden navne eller ordet er forvarmet. Replikkerne er med
i sprogtesten.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
662 lines
32 KiB
Python
662 lines
32 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.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.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), "word": self.word, "caught": caught, "guess": self.guess,
|
|
"spy_won": caught is None or guessed, "deltas": deltas}
|
|
self.phase = "result"
|
|
self.deadline = now + RESULT_SECONDS
|
|
self._emit("result", spies=[self._name(s) for s in self.spies if s in self.players], word=self.word,
|
|
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]
|