- 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:
+183
-12
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user