CI / test (push) Successful in 47s
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>
667 lines
33 KiB
Python
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]
|