Oplæsningsrunde, emoji-reaktioner, regler og renere løgne
CI / test (push) Successful in 1m4s

- Runde 2 og finalen: speakeren læser svarene op ét ad gangen (med hjernen
  en overbevisende kommentar pr. svar), i samme takt på storskærm og telefoner
- Emoji-reaktioner fra telefonerne, der må spammes; de flyver op på storskærmen
  og tælles på svaret i fokus
- Opsummeringen følger speakeren: "lad os rydde op", eksplosioner, så pointene
- Speakeren kommenterer, hvordan løgnene skrider frem, uden at røbe indhold,
  og driller dem, der falder for svage løgne
- Reglerne forklares med kort og stemme før rummets første spil
- "Lyv for mig" låser forslaget i feltet (halve point), og feltet bliver ikke
  længere kæmpestort
- Svar vises med versaler, og spillernes løgne gemmes med små bogstaver, så
  store forbogstaver ikke afslører dem
- Løgne og navne renses: kun bogstaver, tal og lidt tegnsætning
- Valgfrit workspace-id til Anthropic-brugernøgler

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
agw
2026-10-03 18:18:38 +02:00
parent 69012bd051
commit fc76356f56
23 changed files with 1002 additions and 75 deletions
+183 -12
View File
@@ -16,7 +16,9 @@ import unicodedata
from dataclasses import dataclass, field
# Runder: (navn, pointmultiplikator, antal spørgsmål). Sidste runde er finalen.
ROUNDS = [("Runde 1", 1, 3), ("Runde 2", 2, 3), ("Finale", 3, 1)]
# 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
@@ -29,6 +31,10 @@ 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 = ("😂", "😱", "👏", "🤯", "🙈", "🔥")
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
@@ -39,7 +45,26 @@ CATEGORY_OPTIONS = 4
COLORS = ["#ff4d6d", "#4cc9f0", "#ffd166", "#06d6a0", "#b388ff", "#ff9f1c", "#f15bb5", "#90e0ef"]
TIMED_PHASES = {"category", "intro", "lie", "guess", "reveal", "scores"}
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
def slugify(name: str) -> str:
@@ -49,6 +74,8 @@ def slugify(name: str) -> str:
class GameError(Exception):
show_rules = True # reglerne før rummets første spil (testene slår dem fra)
def __init__(self, code: str, message: str):
super().__init__(message)
self.code = code
@@ -68,6 +95,19 @@ 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
@@ -123,9 +163,25 @@ class Game:
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
@@ -185,6 +241,10 @@ class Game:
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
@@ -198,9 +258,9 @@ class Game:
# ── medlemmer ───────────────────────────────────────────────────────────
def join(self, name: str) -> Player:
name = clean(name)
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} tegn.")
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.")
@@ -257,8 +317,27 @@ class Game:
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)
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(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():
@@ -349,7 +428,16 @@ class Game:
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()
@@ -391,9 +479,11 @@ class Game:
self._require_player(member_id)
if member_id in self.lies:
raise GameError("already_done", "Du har allerede afleveret din løgn.")
text = clean(text)
# 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} tegn.")
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
@@ -427,7 +517,7 @@ class Game:
options = self.question.get("decoys", []) or ["noget helt andet"]
pick = self.rng.choice(options)
self.suggestions.setdefault(member_id, set()).add(normalize(pick))
return pick
return sanitize(pick).lower()
def _start_guess(self, now: float):
grouped: dict[str, Answer] = {}
@@ -448,11 +538,68 @@ class Game:
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 REACTIONS:
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:
@@ -643,8 +790,18 @@ class Game:
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")
@@ -680,7 +837,7 @@ class Game:
"audience_count": len(self.audience),
}
if self.phase not in ("lobby", "final"):
name, mult, count = self.round
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":
@@ -689,11 +846,22 @@ class Game:
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", "guess", "reveal") and self.question:
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 in ("guess", "reveal"):
hide = viewer.id if viewer and viewer.role == "player" and self.phase == "guess" else None
if self.phase == "rules":
state["rules"] = {"items": [{k: r[k] for k in ("icon", "title", "body")} for r in 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]
@@ -705,7 +873,10 @@ class Game:
# 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, ()))} for a in self.answers],
"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()],
"step_ms": REVEAL_STEP_MS,