CI / test (push) Successful in 1m32s
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
961 lines
45 KiB
Python
961 lines
45 KiB
Python
"""Spilmotoren for Fup og Fakta.
|
||
|
||
Ren Python uden Django og uden I/O: alle handlinger tager `now` (sekunder siden
|
||
epoch) som parameter, så hele spillet kan testes deterministisk. Rummet i
|
||
`party.rooms` ejer en instans pr. spil, kalder handlingerne fra WebSocket'en og
|
||
`tick()` når en deadline udløber, og sender `snapshot()` til hver skærm.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import difflib
|
||
import random
|
||
import re
|
||
import secrets
|
||
import unicodedata
|
||
from dataclasses import dataclass, field
|
||
|
||
# Runder: (navn, pointmultiplikator, antal spørgsmål). Sidste runde er finalen.
|
||
# 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
|