"""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