Files
agw c3f9a5c8b4
CI / test (push) Successful in 1m32s
Fest-Z: eget emoji-sæt og nyt navn
Fest-spillet hedder nu Fest-Z (kategorierne "Fest-Z: …"), og telefonerne
får zoomer-emojis (💀 😭 🧢 🗿 💅 🍻) i stedet for familie-sættet. Serveren
sender sættet i tilstanden, så klienten altid viser det, spillet tager imod.
Reglerne og speakeren taler om slang og memes frem for pop-sladder.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-10-18 13:17:26 +02:00

961 lines
45 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.
# Runder: (navn, pointmultiplikator, antal spørgsmål, oplæsning). Sidste runde er finalen.
# Oplæsning: før der gættes, læser speakeren svarene op ét ad gangen (fasen "present").
ROUNDS = [("Runde 1", 1, 3, False), ("Runde 2", 2, 3, True), ("Finale", 3, 1, True)]
POINTS_CORRECT = 1000
POINTS_FOOLED = 500
POINTS_LIKE = 100
POINTS_AUDIENCE = 500
CATEGORY_SECONDS = 15
INTRO_SECONDS = 5 # kategorien præsenteres på storskærmen med sin egen lyd
LIE_SECONDS = 60
GUESS_SECONDS = 30
SCORES_SECONDS = 10
REVEAL_STEP_MS = 4000
PRESENT_STEP_MS = 2600 # pr. svar under oplæsningen, når der ikke er stemme
PRESENT_INTRO_MS = 1500
REACTIONS = ("😂", "😱", "👏", "🤯", "🙈", "🔥")
REACTIONS_FEST = ("💀", "😭", "🧢", "🗿", "💅", "🍻") # fest: dødt af grin, cap, moai-blik, slay, skål
REACT_PER_SECOND = 6 # man må gerne spamme, men ikke uendeligt
REVEAL_TAIL_MS = 8000 # opsummeringen efter sidste trin: løgne der eksploderer, point der tæller op
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 = {"rules", "category", "intro", "lie", "present", "guess", "reveal", "scores"}
# Reglerne: storskærmen viser ét kort ad gangen, mens speakeren forklarer det (kun rummets første spil).
RULES = [
{"icon": "🤥", "title": "Find på en løgn", "body": "Et sandt, men skørt faktum mangler et ord. Skriv en løgn, der lyder sand.",
"say": "Okay, sådan her spiller vi. I får et faktum, der er helt sandt, men der mangler et ord. Jeres job? "
"At finde på en løgn, der lyder så troværdig, at de andre hopper lige i fælden."},
{"icon": "🔍", "title": "Find sandheden", "body": "Alle løgne blandes med sandheden. Gæt den rigtige, så får du point.",
"say": "Bagefter blander jeg alle jeres løgne med sandheden. Find den rigtige, og I får point."},
{"icon": "🎣", "title": "Narre de andre", "body": "Hver gang nogen vælger din løgn, får du point.",
"say": "Og hver gang nogen falder for jeres løgn, er det jer, der scorer. Jo flere I narrer, jo bedre."},
{"icon": "🤖", "title": "Lyv for mig", "body": "Helt blank? Få en løgn foræret. Den giver kun halve point.",
"say": "Er hovedet helt tomt, kan I trykke Lyv for mig, så får I en løgn foræret. "
"Men pas på: den giver kun halve point, når nogen falder for den."},
{"icon": "❤️", "title": "Hjerter og emojis", "body": "Giv hjerter til de bedste løgne, og spam løs med emojis.",
"say": "Under afsløringen kan I give hjerter til de bedste løgne. Og I må gerne spamme med emojis undervejs. Det elsker jeg."},
{"icon": "🏆", "title": "Dobbelt op", "body": "Runde 2 giver dobbelt point, finalen tredobbelt.",
"say": "Runde to giver dobbelt point, og finalen tredobbelt, så intet er afgjort før til sidst. Held og lykke, og lyv løs!"},
]
RULES_STEP_MS = 5000
# Fest (18+): samme spil, men det handler om tår. Narret = drik; ingen fandt sandheden = alle drikker.
# Antal tårer følger rundens gange (×1/×2/×3), og finalen kaldes en shot.
RULES_FEST = [
{"icon": "🤥", "title": "Lyv som en influencer", "body": "Et sandt faktum om slang, memes eller dansk pop mangler et ord. Skriv en løgn, der lyder sand.",
"say": "Yo, bestie, velkommen til festen. I får ægte facts om slang, memes og dansk pop, men der mangler et ord. "
"Jeres job? At lyve så hårdt, at de andre hopper lige i det. Ingen cap."},
{"icon": "🥃", "title": "Narret? Drik!", "body": "Falder du for en løgn, tager du en tår. Point er sekundært.",
"say": "Og nu kommer det vigtige. Falder du for en løgn, så tager du en tår. Det er hele pointen. Point er lowkey ligegyldige."},
{"icon": "🍻", "title": "Ingen fandt sandheden?", "body": "Så drikker alle. Sådan er reglerne.",
"say": "Finder ingen af jer sandheden, så drikker alle sammen. Det er bare sådan, det er."},
{"icon": "🔥", "title": "Det eskalerer", "body": "Runde 2 er to tår. Finalen er en shot.",
"say": "Runde to er to tår, og finalen er en hel shot. Drik med omtanke, og drik vand imellem. Lad os gå!"},
]
def slugify(name: str) -> str:
"""Kategorinavn til filnavn: "Mad og drikke" -> "mad_og_drikke" (bruges til kategoriens lyd)."""
s = name.casefold().replace("æ", "ae").replace("ø", "oe").replace("å", "aa")
return re.sub(r"[^a-z0-9]+", "_", s).strip("_")
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()
LIE_PUNCT = "-'.,&"
NAME_PUNCT = "-'"
def sanitize(text: str, allowed: str = LIE_PUNCT) -> str:
"""Kun bogstaver, tal, mellemrum og lidt tegnsætning (ingen emojis, styretegn eller usynlige tegn).
Telefonen filtrerer det samme, men serveren stoler ikke på klienten."""
text = unicodedata.normalize("NFKC", text or "")
kept = "".join(ch if (unicodedata.category(ch)[0] in "LN" or ch in allowed) else (" " if ch.isspace() else "")
for ch in text)
return clean(kept).strip(allowed + " ")
@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
sips: int = 0 # fest: tår i alt
last_sips: int | None = None
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:
show_rules = True # reglerne før rummets første spil (testene slår dem fra)
def __init__(self, code: str, questions: list[dict], join_url: str = "", rng: random.Random | None = None,
fest: bool = False):
self.code = code
self.fest = fest
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
# Oplæsning (fasen "present"): tidslinje som afsløringen
self.present_started_at: float | None = None
self.present_steps_ms: list[int] = []
self.present_intro_ms = PRESENT_INTRO_MS
self.present_audio: list[dict | None] = []
self.present_intro_audio: dict | None = None
# Reaktioner: pr. svar, og en lille spand pr. spiller mod uendelig spam
self.reactions: dict[str, int] = {}
self._react_bucket: dict[str, list[float]] = {}
# Opsummeringen efter afsløringen (rummet sætter tider og lyd, når der er stemme)
self.summary: dict | None = None
# Speakeren: så længe hold_until ligger i fremtiden, venter alle automatiske faseskift
# (tid udløbet, alle har svaret, kategori valgt). Rummet sætter den ud fra talens længde.
self.hold_until: float | None = None
# Reglerne (fasen "rules"): kun rummets første spil
self.rules_done = False
self.rules_started_at: float | None = None
self.rules_steps_ms: list[int] = []
self.rules_audio: list | None = None
self.chosen_category = None
self.lie_deadline: float | None = None
self.truth_warned = False
# "Lyv for mig": forslag givet pr. spiller, og hvem der afleverede et uændret forslag (halve point)
self.suggestions: dict[str, set[str]] = {}
self.assisted: set[str] = set()
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 held(self, now: float) -> bool:
return self.hold_until is not None and now < self.hold_until
def phase_key(self) -> str:
"""Identificerer den aktuelle skærm; speakerens replikker mærkes med den."""
return f"{self.round_idx}.{self.q_idx}.{self.phase}"
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 read_aloud(self) -> bool:
return self.round[3]
@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 = 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_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))
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_question(now)
@property
def reaction_set(self) -> tuple[str, ...]:
return REACTIONS_FEST if self.fest else REACTIONS
@property
def rules(self) -> list[dict]:
return RULES_FEST if self.fest else RULES
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.")
# 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 = p.sips = 0
p.last_delta = p.last_sips = 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.chosen_category = None
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.")
if self.chosen_category is not None:
raise GameError("already_done", "Kategorien er valgt.")
self.chosen_category = category_id
if self.held(now):
self.deadline = now # skiftet sker, når speakeren er færdig
self._changed()
else:
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
least = min(q.get("times_used", 0) for q in pool) # variation på tværs af aftener
self.question = self.rng.choice([q for q in pool if q.get("times_used", 0) == least])
self.used_questions.add(self.question["id"])
if self.reactions and self.answers:
top = max(self.reactions, key=self.reactions.get)
text = next((a.text for a in self.answers if a.id == top), "")
self._emit("reactions_summary", text=text, count=self.reactions[top])
self.lies = {}
self.reactions = {}
self.summary = None
self.present_started_at = None
self.present_audio = []
self.present_intro_audio = None
self.truth_warned = False
self.suggestions = {}
self.assisted = set()
self.guesses = {}
self.audience_votes = {}
self.likes = {}
self.answers = []
self.reveal_steps = []
for p in self.players.values():
p.last_delta = None
p.last_sips = None
self.reveal_started_at = None
self.reveal_note_audio = None
self.phase = "intro"
self.deadline = now + INTRO_SECONDS
self._emit("category_intro", category=self.question["category"], final=self.is_final_round)
self._changed()
def _start_lie(self, now: float):
self.phase = "lie"
self.deadline = self.lie_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.")
# Små bogstaver: iOS' store forbogstav må ikke afsløre, hvilke svar spillerne har skrevet
# (skærmen viser alle svar med versaler, og speakeren læser dem ens op).
text = sanitize(text).lower()
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} bogstaver eller tal.")
if self._is_truth(text):
if not self.truth_warned: # speakeren kommenterer det højst én gang pr. spørgsmål
self.truth_warned = True
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
if normalize(text) in self.suggestions.get(member_id, ()):
self.assisted.add(member_id) # husets forslag: halve point for at narre
self._emit("lie_submitted", player=member_id, text=text)
self._changed()
self._maybe_finish_early(now)
def reject_lie(self, member_id: str, text: str) -> bool:
"""Dommeren afviste løgnen (sandheden eller upassende): spilleren skal skrive en ny."""
if self.phase != "lie" or self.lies.get(member_id) != text:
return False
del self.lies[member_id]
self.assisted.discard(member_id)
# Var fasen sat til at slutte tidligt, fordi alle havde løjet, får spilleren sin tid tilbage.
if self.deadline is not None and self.lie_deadline and self.deadline < self.lie_deadline:
self.deadline = self.lie_deadline
self._changed()
return True
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"]
pick = self.rng.choice(options)
self.suggestions.setdefault(member_id, set()).add(normalize(pick))
return sanitize(pick).lower()
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
if self.read_aloud:
self.phase = "present"
self.start_present_timeline(now)
self._emit("present")
self._changed()
return
self._begin_guessing(now)
def _begin_guessing(self, now: float):
self.phase = "guess"
self.deadline = now + GUESS_SECONDS
self._emit("guess_start")
self._changed()
def start_present_timeline(self, now: float, steps_ms: list[int] | None = None, intro_ms: int | None = None):
"""Svarene læses op ét ad gangen fra `now`; hvert svar kan få sin egen længde (speakerens replik)."""
self.present_steps_ms = list(steps_ms or [PRESENT_STEP_MS] * len(self.answers))
self.present_intro_ms = PRESENT_INTRO_MS if intro_ms is None else intro_ms
self.present_started_at = now
self.deadline = now + (self.present_intro_ms + sum(self.present_steps_ms)) / 1000 + 1
self._changed()
def hold_present(self, now: float, max_wait: float = 10.0):
if self.phase == "present":
self.present_started_at = None
self.deadline = now + max_wait
self._changed()
def featured_answer(self, now: float) -> str | None:
"""Det svar, der er i fokus på storskærmen lige nu (oplæsning eller afsløring)."""
if self.phase == "present" and self.present_started_at:
el = (now - self.present_started_at) * 1000 - self.present_intro_ms
for a, ms in zip(self.answers, self.present_steps_ms):
if 0 <= el < ms:
return a.id
el -= ms
if self.phase == "reveal" and self.reveal_started_at:
el = (now - self.reveal_started_at) * 1000
for st, ms in zip(self.reveal_steps, self.reveal_steps_ms):
if el < ms:
return st["answer_id"]
el -= ms
return None
def react(self, member_id: str, emoji: str, now: float) -> dict | None:
"""En emoji fra en telefon. Returnerer reaktionen (til storskærmen) eller None, hvis den droppes."""
if self.phase not in ("present", "guess", "reveal") or emoji not in self.reaction_set:
return None
member = self._member(member_id)
if not member:
return None
bucket = [t for t in self._react_bucket.get(member_id, []) if now - t < 1.0]
if len(bucket) >= REACT_PER_SECOND:
self._react_bucket[member_id] = bucket
return None
bucket.append(now)
self._react_bucket[member_id] = bucket
answer_id = self.featured_answer(now)
if answer_id:
self.reactions[answer_id] = self.reactions.get(answer_id, 0) + 1
return {"emoji": emoji, "color": member.color, "answer_id": answer_id}
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("reveal") # hjerter gives kun, mens løgnene afsløres
member = self._member(member_id)
if not member:
raise GameError("not_allowed", "Du er ikke med i spillet.")
answer = self._answer(answer_id)
if answer.is_truth:
raise GameError("answer_invalid", "Hjerter er til løgnene.")
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 _all_done(self) -> bool:
active = self._active_players()
if self.phase == "lie":
return bool(active) and all(p.id in self.lies for p in active)
if self.phase == "guess":
return bool(active) and all(p.id in self.guesses for p in active)
return False
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._advance(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 _drink(self, pid: str, n: int, step: dict):
p = self.players.get(pid)
if not p or n <= 0:
return
p.sips += n
p.last_sips = (p.last_sips or 0) + n
step["sips"].append({"player_id": pid, "n": n})
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": [], "sips": [],
"assisted": [x for x in a.authors if x in self.assisted]}
if a.is_truth:
for pid in picked_by:
self.players[pid].correct += 1
self._award(pid, POINTS_CORRECT * m, step)
if self.fest and not picked_by: # ingen fandt sandheden: alle drikker
step["everyone_drinks"] = True
for pid in self.players:
self._drink(pid, m, step)
else:
if self.fest:
for pid in picked_by:
self._drink(pid, m, step)
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)
if author in self.assisted:
bonus //= 2 # løgnen kom fra "Lyv for mig"
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)})
if self.fest:
best("sips", "Tørstigste", lambda n: f"{n} tår i aften")
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 or self.held(now):
return False
self._advance(now)
return True
def _advance(self, now: float):
if self.phase == "category":
if self.chosen_category is not None:
self._choose(self.chosen_category, now)
else:
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 == "rules":
if self.rules_started_at is None:
self.start_rules_timeline(now) # lyden blev aldrig klar: vis kortene uden stemme
else:
self._begin_question(now)
elif self.phase == "intro":
self._start_lie(now)
elif self.phase == "present":
if self.present_started_at is None:
self.start_present_timeline(now) # lyden blev aldrig klar: læs op uden stemme
else:
self._begin_guessing(now)
elif self.phase == "lie":
if self._all_done():
self._emit("lie_all_done")
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, **({"sips": p.sips} if self.fest else {})}
for p in self.players.values()
],
"audience_count": len(self.audience),
"fest": self.fest,
"reactions": list(self.reaction_set),
}
if self.phase not in ("lobby", "final"):
name, mult, count, _read = 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,
"chosen_id": self.chosen_category}
if self.phase == "intro" and self.question:
state["intro"] = {"category": self.question["category"], "slug": slugify(self.question["category"]),
"final": self.is_final_round}
if self.phase in ("lie", "present", "guess", "reveal") and self.question:
state["question"] = {"category": self.question["category"], "prompt": self.question["prompt"],
"slug": slugify(self.question["category"])}
if self.phase == "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 self.phase == "present":
state["present"] = {"started_at": int(self.present_started_at * 1000) if self.present_started_at else None,
"intro_ms": self.present_intro_ms, "steps_ms": self.present_steps_ms,
"intro_audio": self.present_intro_audio, "audio": self.present_audio,
# oplæsningens rækkefølge; steps_ms/audio følger den (spillerens eget svar er
# udeladt af answers, men læses stadig op på storskærmen)
"ids": [a.id for a in self.answers]}
if self.phase in ("present", "guess", "reveal"):
hide = viewer.id if viewer and viewer.role == "player" and self.phase in ("present", "guess") else None
state["answers"] = [{"id": a.id, "text": a.text, **({"likes": len(self.likes.get(a.id, ()))}
if self.phase == "reveal" else {})}
for a in self.answers if hide not in a.authors]
if self.phase == "reveal":
picked = set(self.guesses.values()) | set(self.audience_votes.values())
state["reveal"] = {
# trinene med live antal hjerter (der kan likes under hele afsløringen)
"steps": [{**st, "likes": len(self.likes.get(st["answer_id"], ()))} for st in self.reveal_steps],
# opsummeringen: alle svar, også dem ingen valgte (de "eksploderer")
"all": [{"id": a.id, "text": a.text, "is_truth": a.is_truth, "house": a.house,
"authors": list(a.authors), "assisted": [x for x in a.authors if x in self.assisted],
"picked": a.id in picked, "likes": len(self.likes.get(a.id, ())),
"reactions": self.reactions.get(a.id, 0)} for a in self.answers],
# opsummeringens tidsplan (ms fra halens start) og speakerens replikker til den
"summary": self.summary,
# point i dette spørgsmål pr. spiller (inkl. hjerter), til optællingen
"deltas": [{"player_id": p.id, "delta": p.last_delta or 0} for p in self.players.values()],
**({"sip_deltas": [{"player_id": p.id, "n": p.last_sips or 0} for p in self.players.values()]}
if self.fest else {}),
"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,
**({"last_sips": viewer.last_sips if self.phase == "reveal" else None, "sips": viewer.sips}
if self.fest else {}),
"score": viewer.score,
}
return state