diff --git a/deploy/bots.py b/deploy/bots.py index 0f4f8dd..f98207b 100644 --- a/deploy/bots.py +++ b/deploy/bots.py @@ -101,6 +101,9 @@ async def _player(ws_base, code, name, n_players, done, rng, stop_at_final): return key = (st["phase"], (st.get("round") or {}).get("number"), (st.get("round") or {}).get("question_number")) await asyncio.sleep(rng.uniform(0.05, 0.4)) + if st["phase"] in ("present", "guess", "reveal") and rng.random() < 0.25: + for _ in range(rng.randint(1, 8)): # emojis må spammes; serveren drosler + await ws.send(json.dumps({"action": "react", "emoji": rng.choice(["😂", "😱", "👏", "🤯", "🙈", "🔥"])})) if st["phase"] == "lobby" and me["vip"] and len(st["players"]) >= n_players and key not in acted: acted.add(key) await ws.send(json.dumps({"action": "start"})) @@ -160,7 +163,7 @@ async def main(): return 1 for t in tasks: t.cancel() - needed = {"lobby", "category", "lie", "guess", "reveal", "scores", "final"} + needed = {"lobby", "rules", "category", "lie", "present", "guess", "reveal", "scores", "final"} missing = needed - set(seen) if missing: print(f"Faser der aldrig blev nået: {missing}", file=sys.stderr) diff --git a/deploy/wpp.env.example b/deploy/wpp.env.example index ad54352..f1f390c 100644 --- a/deploy/wpp.env.example +++ b/deploy/wpp.env.example @@ -7,3 +7,10 @@ DB_PATH=/path/to/db.sqlite3 STATIC_ROOT=/path/to/static # Valgfrit: oplæsning ELEVENLABS_API_KEY= + +# Spillets "hjerne" (valgfri; uden den bruges faste replikker). Se party/brain/providers.py. +# BRAIN_PROVIDER=anthropic +# BRAIN_MODEL=claude-opus-5 +# BRAIN_API_KEY= +# BRAIN_WORKSPACE= # kun ved Anthropic-brugernøgler (sk-ant-usr…) +# BRAIN_EFFORT=low diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 2840227..05129d3 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -32,6 +32,7 @@ Alle beskeder er JSON `{"action": "...", ...}`. | `lie_for_me` | | fase `lie`; svar: `suggestion` | | `guess` | `answer_id` | fase `guess`; spillere og publikum, én gang | | `like` | `answer_id` | kun fase `reveal`; slår til/fra; ikke eget svar og ikke sandheden (`answer_invalid`) | +| `react` | `emoji` (en af `😂 😱 👏 🤯 🙈 🔥`) | fase `present`, `guess`, `reveal`; må spammes (serveren dropper stille over 6/s pr. person). Intet svar; storskærmen får `react`. Tælles på det svar, der er i fokus på storskærmen. | | `play_again` | | VIP i fasen `final` | | `ping` | | svar `pong` | @@ -45,6 +46,7 @@ Alle beskeder er JSON `{"action": "...", ...}`. | `joined` | `token`, `player_id`, `role` (`player`/`audience`) | | `suggestion` | `text` (svar på `lie_for_me`) | | `tease` | `text`, `image` (URL), `sound` (URL eller `null`) – kun til den spiller, der lige faldt for en løgn, i det øjeblik stemplet "LØGN" lander på storskærmen. Telefonen viser et fuldskærms-drilleri (~2,5 s, lukkes ved tryk), spiller `boo` og derefter `sound`, hvis lyden er låst op. Højst 2 pr. spørgsmål. | +| `react` | `emoji`, `color` (afsenderens farve), `answer_id` (svaret i fokus eller `null`) – kun til storskærmen; emojien flyver op over skærmen. | | `error` | `code`, `message` (dansk, kan vises direkte) | | `pong` | | @@ -55,7 +57,7 @@ Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_a ```jsonc { "code": "KLMT", - "phase": "lobby", // lobby | category | intro | lie | guess | reveal | scores | final + "phase": "lobby", // lobby | category | intro | lie | present | guess | reveal | scores | final "server_time": 1790000000000,// ms epoch; klienten udregner sin forskydning "deadline": 1790000060000, // ms epoch eller null "join_url": "https://party.weircon.dk/?code=KLMT", @@ -71,8 +73,15 @@ Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_a "chosen_id": null}, // kun i fase category; id når der er valgt (ellers null) "intro": {"category": "Dyreriget", "slug": "dyreriget", "final": false}, // kun i fase intro "question": {"category": "Dyreriget", "prompt": "En flok _____ kaldes et parlament.", - "slug": "dyreriget"}, // lie/guess/reveal - "answers": [{"id": "a1", "text": "ugler"}], // guess + reveal (i reveal med "likes"); egen løgn udeladt i guess + "slug": "dyreriget"}, // lie/present/guess/reveal + "present": { // kun i fase present (runde 2 og finalen) + "started_at": 1790000000000, // ms epoch; null = serveren laver stadig lyd + "intro_ms": 1500, "steps_ms": [3100, 2900], // speakerens intro, så ét svar ad gangen + "ids": ["a3", "a1"], // oplæsningens rækkefølge (steps_ms/audio følger den) + "intro_audio": {"url": "…", "ms": 1200}, // eller null + "audio": [{"url": "…", "ms": 2600, "text": "Krager. Det giver da mening…"}] // pr. svar eller null + }, + "answers": [{"id": "a1", "text": "ugler"}], // present + guess + reveal (i reveal med "likes"); egen løgn udeladt i present/guess "reveal": { // kun i fase reveal "steps": [ // løgne først (kun dem der blev valgt), sandheden sidst {"answer_id": "a3", "text": "krager", "is_truth": false, "house": false, @@ -86,8 +95,13 @@ Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_a ], "all": [ // opsummeringen: ALLE svar, også dem ingen valgte {"id": "a7", "text": "skildpadder", "is_truth": false, "house": true, - "authors": [], "assisted": [], "picked": false, "likes": 0} // picked=false → svaret "eksploderer" + "authors": [], "assisted": [], "picked": false, "likes": 0, // picked=false → svaret "eksploderer" + "reactions": 3} // emojis sendt, mens svaret var i fokus ], + "summary": { // opsummeringens tidsplan i ms fra halens start, eller null + "start_ms": 1200, "explode_at": 3900, "points_at": 7200, // ikke-valgte eksploderer fra explode_at (250 ms imellem); point tælles fra points_at + "audio": [{"url": "…", "ms": 2100, "at": 1600}] // speakerens "lad os rydde op" / "så til pointene", afspilles ved at + }, "deltas": [{"player_id": "p1", "delta": 1500}], // point i dette spørgsmål pr. spiller (inkl. hjerter) "step_ms": 4000, // standardlængde pr. trin (bagudkompatibel) "steps_ms": [4200, 3900], // faktisk længde pr. trin (følger speakerens replik) @@ -134,6 +148,15 @@ Trin `i` starter ved `started_at + sum(steps_ms[0..i-1])`. Inden for et trin vis (~28 % / højst 1,1 s), så stemplet (~55 % / højst 2,3 s). Efter sidste trin kører opsummeringen i `tail_ms`: alle svar (`all`), de ikke-valgte eksploderer, og `deltas` tælles op. `deadline` = slutningen af halen. Mens `started_at` er `null`, venter serveren på speakerens lydfiler (højst ~10 s) og sætter så tiderne. +Opsummeringen følger `summary`: speakeren siger noget i stil med "dem her troede ingen på, lad os rydde op", +så eksploderer de ikke-valgte svar, så "og så til pointene", og først derefter tælles pointene op. + +### Oplæsningens tidslinje (fasen `present`) + +I runde 2 og finalen læser speakeren svarene op, før der gættes: først `intro_ms`, så svar `i` fra +`started_at + intro_ms + sum(steps_ms[0..i-1])`. Hvert svar præsenteres overbevisende (med hjernen en +kommentar pr. svar, som aldrig må afsløre, hvad der er sandt). Storskærm og telefoner viser svarene i samme takt; +der kan først gættes i `guess`. `deadline` = slutningen + 1 s. ### Lyd- og billedfiler @@ -152,7 +175,7 @@ klienten slår alle lyde op her og falder tilbage til `/static/party/sfx/. For hvert spørgsmål: `category` (15 s; skiftende spiller vælger blandt 4; i finalen springes fasen over) → `intro` (≈5 s; storskærmen præsenterer kategorien med sin jingle, speakeren siger kategorien, og serveren venter til speakeren er færdig) → `lie` (60 s, speakeren læser spørgsmålet op; slutter når alle har løjet) → `guess` (30 s, slutter når alle spillere har gættet) → `reveal` (automatisk: `sum(steps_ms)` + `tail_ms`; værten kan `skip`). Efter sidste spørgsmål i en runde: `scores` (10 s). Efter finalen: `final`. -Rækkefølge: `category → intro → lie → guess → reveal (→ scores)`; i finalen `intro → lie → guess → reveal → final`. +Rækkefølge: `category → intro → lie → guess → reveal (→ scores)`; i runde 2 `category → intro → lie → present → guess → reveal (→ scores)`; i finalen `intro → lie → present → guess → reveal → final`. - Løgn lig sandheden (eller næsten lig) afvises med `lie_is_truth`. Ens løgne slås sammen, og alle forfattere får point. - Spillere, der ikke løj i tide, får ingen løgn; huset fylder op med lokkesvar, så der er mindst `max(4, spillere+1)` svar. diff --git a/fupogfakta/engine.py b/fupogfakta/engine.py index e15158e..829bd7a 100644 --- a/fupogfakta/engine.py +++ b/fupogfakta/engine.py @@ -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, diff --git a/fupogfakta/narrator/lines.json b/fupogfakta/narrator/lines.json index 3115aa5..398203b 100644 --- a/fupogfakta/narrator/lines.json +++ b/fupogfakta/narrator/lines.json @@ -302,5 +302,45 @@ "[excited] Så tager vi en til. Nye løgne, samme sofa. Den har efterhånden hørt lidt af hvert.", "Vi spiller igen. Jeg henter flere fakta. I kan bare genbruge jeres selvtillid.", "En ny omgang. Jeg er glad for tilliden. Især fordi jeg tydeligvis ikke har fortjent den." + ], + "present_intro": [ + "Lad os høre jeres svar. Lyt godt efter.", + "[mischievously] Her er svarene. Et af dem er sandt. Resten er jer.", + "Svarene, tak. Jeg læser dem op med min mest troværdige stemme.", + "Lad os høre, hvad I har fundet på. Og hvad virkeligheden har fundet på.", + "[excited] Svarene kommer nu. Gæt ikke endnu, bare lyt." + ], + "present_answer": [ + "{svar}.", + "Måske {svar}.", + "Eller hvad med {svar}?", + "Nogen siger {svar}.", + "{svar}. Det lyder da rimeligt.", + "{svar}? Hmm.", + "Så er der {svar}.", + "[whispers] {svar}.", + "{svar}. Tænk lige over den.", + "Og {svar}. Hvorfor ikke?" + ], + "summary_cleanup": [ + "De her løgne troede ingen på. Lad os lige rydde op.", + "[sighs] Ingen faldt for de her. Væk med dem.", + "Disse løgne fik ikke en eneste stemme. Farvel.", + "Lad os feje de løgne væk, som ingen gad tro på.", + "[mischievously] Ingen troede på dem her. De ryger ud med et brag." + ], + "summary_points": [ + "Og så til pointene.", + "[excited] Lad os se, hvem der tjente noget.", + "Nu tæller vi op.", + "Pointene, tak.", + "Så er det tid til at dele point ud." + ], + "summary_favorite": [ + "Publikums favorit var {svar}. I kunne ikke styre jer.", + "[laughs] {svar} fik stuen op at køre.", + "I reagerede vildt på {svar}. Det noterer jeg mig.", + "{svar} fik flest reaktioner. Selv jeg måtte trække på smilebåndet.", + "Stuen elskede {svar}. Så er det sagt." ] } \ No newline at end of file diff --git a/fupogfakta/tests/test_engine.py b/fupogfakta/tests/test_engine.py index 853c8df..1e2b363 100644 --- a/fupogfakta/tests/test_engine.py +++ b/fupogfakta/tests/test_engine.py @@ -16,6 +16,16 @@ def bank(n_cats=5, per_cat=6): return qs + +def setUpModule(): + from fupogfakta.engine import Game + Game.show_rules = False # reglerne testes for sig (RulesTests) + + +def tearDownModule(): + from fupogfakta.engine import Game + Game.show_rules = True + class GameTestCase(SimpleTestCase): def setUp(self): self.now = 1000.0 @@ -229,3 +239,84 @@ class FullGameTests(GameTestCase): self.g.kick(self.g.chooser_id, self.now) self.assertEqual(self.g.phase, "intro") self.assertEqual(len(self.g.players), 2) + + +class PresentAndReactionTests(GameTestCase): + def _to_round2_lie(self): + a, b, c = self.join("A", "B", "C") + self.g.start(None, self.now) + self.g.round_idx, self.g.q_idx = 1, 0 # spring direkte til runde 2 (oplæsning) + self.g._begin_question(self.now) + self.through_category() + return a, b, c + + def test_round2_reads_answers_before_guessing(self): + a, b, c = self._to_round2_lie() + for p, lie in [(a, "katte"), (b, "hunde"), (c, "heste")]: + self.g.submit_lie(p.id, lie, self.now) + self.assertEqual(self.g.phase, "present") + with self.assertRaises(GameError): # ingen gæt under oplæsningen + self.g.guess(a.id, self.g.answers[0].id, self.now) + snap = self.g.snapshot(self.now, viewer=a) + self.assertEqual(len(snap["present"]["steps_ms"]), len(self.g.answers)) + self.g.tick(self.g.deadline) + self.assertEqual(self.g.phase, "guess") + + def test_reactions_are_rate_limited_and_follow_the_featured_answer(self): + a, b, c = self._to_round2_lie() + for p, lie in [(a, "katte"), (b, "hunde"), (c, "heste")]: + self.g.submit_lie(p.id, lie, self.now) + t = self.g.present_started_at + (self.g.present_intro_ms + 100) / 1000 # første svar i fokus + first = self.g.answers[0].id + sent = [self.g.react(b.id, "😂", t) for _ in range(10)] + self.assertEqual(sum(1 for r in sent if r), engine.REACT_PER_SECOND) + self.assertEqual(self.g.reactions[first], engine.REACT_PER_SECOND) + self.assertIsNone(self.g.react(b.id, "💩", t + 2)) # kun de faste emojis + self.assertIsNotNone(self.g.react(b.id, "🔥", t + 2)) # spanden er tom igen efter et sekund + + +class RulesTests(GameTestCase): + def setUp(self): + super().setUp() + self.g.show_rules = True + + def test_rules_before_first_game_only(self): + a, b = self.join("Asger", "Sus") + self.g.start(a.id, self.now) + self.assertEqual(self.g.phase, "rules") + snap = self.g.snapshot(self.now, viewer=a) + self.assertEqual(len(snap["rules"]["items"]), len(engine.RULES)) + self.assertNotIn("say", snap["rules"]["items"][0]) # replikken er til speakeren, kortet har sin egen tekst + self.g.hold_rules(self.now, max_wait=3) # speakeren laver lyd: ingen tidslinje endnu + self.g.tick(self.now + 3) # lyden blev aldrig klar: kortene vises uden stemme + self.assertEqual(self.g.phase, "rules") + self.g.tick(self.g.deadline) + self.assertEqual(self.g.phase, "category") + # spil igen: ingen regler + self.g.phase = "final" + self.g.play_again(a.id, self.now) + self.assertEqual(self.g.phase, "category") + + +class SanitizeTests(GameTestCase): + def test_lies_are_cleaned_and_lowercased(self): + a, b = self.join("Asger", "Sus") + self.g.start(a.id, self.now) + self.through_category() + with self.assertRaises(GameError) as e: + self.g.submit_lie(a.id, "😂🔥", self.now) # kun emojis: intet tilbage + self.assertEqual(e.exception.code, "lie_invalid") + with self.assertRaises(GameError): + self.g.submit_lie(a.id, "x" * (engine.MAX_LIE + 1), self.now) + self.g.submit_lie(a.id, " Røde​ Krager 🐦!! ", self.now) + self.assertEqual(self.g.lies[a.id], "røde krager") + + def test_sanitize_and_names(self): + self.assertEqual(engine.sanitize("Rock'n'roll & co."), "Rock'n'roll & co") + self.assertEqual(engine.sanitize("full width 12"), "full width 12") + self.assertEqual(engine.sanitize("a\tb\nc"), "a b c") + p = self.g.join("Bo 👑") + self.assertEqual(p.name, "Bo") + with self.assertRaises(GameError) as e: + self.g.join("🎉🎉") + self.assertEqual(e.exception.code, "name_invalid") diff --git a/fupogfakta/tests/test_narrator.py b/fupogfakta/tests/test_narrator.py index 04bc802..b3dc7a2 100644 --- a/fupogfakta/tests/test_narrator.py +++ b/fupogfakta/tests/test_narrator.py @@ -21,6 +21,8 @@ EVENTS = { "reveal_truth_everyone": {"sandhed"}, "scores_intro": {"foerende", "sidst"}, "winner": {"vinder", "point"}, "award_biggest_liar": {"navn", "antal"}, "award_truth_seeker": {"navn", "antal"}, "award_most_liked": {"navn", "antal"}, "play_again": set(), + "present_intro": set(), "present_answer": {"svar"}, "summary_cleanup": set(), "summary_points": set(), + "summary_favorite": {"svar"}, } TAGS = {"laughs", "chuckles", "sighs", "whispers", "excited", "sarcastic", "dramatic", "pause", "mischievously", "impressed", "disappointed", "suspicious", "cheering"} diff --git a/party/brain/game.py b/party/brain/game.py index 2f81ac2..a9be861 100644 --- a/party/brain/game.py +++ b/party/brain/game.py @@ -39,6 +39,8 @@ def _schema(props: dict, required: list[str] | None = None) -> dict: COMMENTS = _schema({"comments": {"type": "array", "items": _schema({"id": {"type": "string"}, "line": {"type": "string"}})}}) JUDGE = _schema({"is_truth": {"type": "boolean"}, "inappropriate": {"type": "boolean"}, "message": {"type": "string"}}) TEASES = _schema({"teases": {"type": "array", "items": _schema({"player_id": {"type": "string"}, "text": {"type": "string"}})}}) +LINE = _schema({"line": {"type": "string"}}) +REVEAL = _schema({"lines": {"type": "array", "items": _schema({"answer_id": {"type": "string"}, "line": {"type": "string"}})}}) REVIEW = _schema({"lines": {"type": "array", "items": _schema({"player_id": {"type": "string"}, "line": {"type": "string"}})}}) @@ -50,20 +52,47 @@ class GameBrain: user = f"{task}\n\nData (JSON):\n{json.dumps(payload, ensure_ascii=False)}" return await self.provider.complete_json(SYSTEM, user, schema, max_tokens=max_tokens, timeout=timeout) - async def comment_answers(self, question: dict, answers: list[dict], memory: list[str]) -> dict[str, str]: - """{answer_id: kommentar}. answers: [{id, text, kind}] med kind = løgn | husets løgn | sandheden.""" + async def present_lines(self, question: dict, answers: list[dict]) -> dict[str, str]: + """{answer_id: overbevisende kommentar} til oplæsningen. answers: [{id, text}] uden at sige hvilket der er sandt.""" res = await self._ask( - "Skriv én kort kommentar (højst tolv ord) til HVERT svar. Kommentaren læses op, lige når svaret " - "afsløres som løgn eller sandhed. Kommentér selve svaret, ikke hvem der faldt for det (det ved du " - "ikke endnu), og nævn ikke hvem der skrev det. Til sandheden: reagér på hvor vildt faktummet er.", - {"spørgsmål": question.get("prompt"), "sandheden": question.get("answer"), - "forklaring": question.get("note", ""), "svar": answers, "aftenens_hukommelse": memory[-15:]}, - COMMENTS, max_tokens=900, timeout=settings.BRAIN_TIMEOUT_SLOW) + "Du læser svarene op ét ad gangen, før spillerne gætter. Skriv til HVERT svar én kort kommentar " + "(højst ti ord), der får netop det svar til at lyde troværdigt, som om det kunne være sandt. " + "Alle svar skal lyde lige overbevisende, så ingen kan høre, hvilket der er sandheden. Røb ALDRIG, " + "hvad der er sandt eller løgn, og nævn ingen spillere.", + {"spørgsmål": question.get("prompt"), "svar": answers}, + COMMENTS, max_tokens=700, timeout=settings.BRAIN_TIMEOUT_FAST) if not res: return {} ids = {a["id"] for a in answers} return {c["id"]: c["line"].strip() for c in res.get("comments", []) if c.get("id") in ids and c.get("line")} + async def reveal_lines(self, question: dict, steps: list[dict], stats: list[str], memory: list[str]) -> dict[str, str]: + """{answer_id: hel replik} til afsløringen, med fuld viden om hvem der skrev og hvem der faldt. + steps: [{answer_id, svar, type (løgn|husets løgn|sandheden), skrevet_af, valgt_af, publikum}].""" + res = await self._ask( + "Skriv replikken til HVERT trin i afsløringen (højst tyve ord). For en løgn: sig hvem der skrev den, " + "og hvem der faldt for den, og roast løgnen konkret. Var løgnen åbenlyst svag eller tåbelig, så drill " + "dem, der alligevel valgte den. Husets løgn er din egen; vær stolt. For sandheden: sig den, og hvem " + "der fandt den. Brug gerne statistikken over, hvem der falder for hvem ('tredje gang'), og aftenens " + "hukommelse. Start ikke alle replikker ens.", + {"spørgsmål": question.get("prompt"), "trin": steps, "statistik": stats, "aftenens_hukommelse": memory[-15:]}, + REVEAL, max_tokens=1200, timeout=settings.BRAIN_TIMEOUT_FAST) + if not res: + return {} + ids = {st["answer_id"] for st in steps} + return {x["answer_id"]: x["line"].strip() for x in res.get("lines", []) if x.get("answer_id") in ids and x.get("line")} + + async def lie_progress(self, info: dict, memory: list[str]) -> str | None: + """En kort bemærkning om en SPILLER (ikke løgnen) mens der skrives. info indeholder aldrig løgnens tekst.""" + res = await self._ask( + "Spillerne skriver løgne lige nu. Skriv én kort, drillende bemærkning (højst tolv ord) om spilleren i " + "data, fx om tempo, antal forsøg eller tidligere bedrifter i aftenens hukommelse. Du kender IKKE " + "løgnen og må ikke gætte på dens indhold eller sige noget, der kan afsløre den.", + {"spiller": info, "aftenens_hukommelse": memory[-10:]}, + LINE, max_tokens=120, timeout=settings.BRAIN_TIMEOUT_FAST) + line = (res or {}).get("line", "").strip() + return line or None + async def judge_lie(self, question: dict, lie: str) -> dict | None: """{is_truth, inappropriate, message} eller None, hvis dommeren ikke svarede.""" return await self._ask( @@ -105,5 +134,5 @@ class GameBrain: @lru_cache(maxsize=1) def get_brain() -> GameBrain | None: provider = build_provider(settings.BRAIN_PROVIDER, settings.BRAIN_MODEL, settings.BRAIN_API_KEY, - settings.BRAIN_BASE_URL, settings.BRAIN_EFFORT) + settings.BRAIN_BASE_URL, settings.BRAIN_EFFORT, settings.BRAIN_WORKSPACE) return GameBrain(provider) if provider else None diff --git a/party/brain/providers.py b/party/brain/providers.py index 560f751..2532cd4 100644 --- a/party/brain/providers.py +++ b/party/brain/providers.py @@ -2,7 +2,7 @@ Spillet kender kun dette interface, så modellen kan skiftes med miljøvariabler: - BRAIN_PROVIDER=anthropic BRAIN_MODEL=claude-opus-5 BRAIN_API_KEY=... + BRAIN_PROVIDER=anthropic BRAIN_MODEL=claude-opus-5 BRAIN_API_KEY=... (+ BRAIN_WORKSPACE ved brugernøgler) BRAIN_PROVIDER=openai BRAIN_MODEL= BRAIN_API_KEY=... (OpenAI/Codex, vLLM, LM Studio, Ollama /v1) BRAIN_PROVIDER=ollama BRAIN_MODEL=gemma4:12b-it-qat BRAIN_BASE_URL=http://:11434 @@ -24,8 +24,9 @@ log = logging.getLogger(__name__) class Provider: name = "base" - def __init__(self, model: str, api_key: str = "", base_url: str = "", effort: str = "low"): + def __init__(self, model: str, api_key: str = "", base_url: str = "", effort: str = "low", workspace: str = ""): self.model, self.api_key, self.base_url, self.effort = model, api_key, base_url.rstrip("/"), effort + self.workspace = workspace async def complete_json(self, system: str, user: str, schema: dict, *, max_tokens: int = 1500, timeout: float = 8.0) -> dict | None: @@ -51,7 +52,9 @@ class AnthropicProvider(Provider): import anthropic # kun nødvendig, når denne udbyder er valgt self._anthropic = anthropic - self.client = anthropic.AsyncAnthropic(api_key=self.api_key or None, max_retries=1) + # Brugernøgler (sk-ant-usr…) er ikke bundet til et workspace og skal sige hvilket i hvert kald. + headers = {"anthropic-workspace-id": self.workspace} if self.workspace else None + self.client = anthropic.AsyncAnthropic(api_key=self.api_key or None, max_retries=1, default_headers=headers) async def _complete(self, system, user, schema, max_tokens, timeout): resp = await self.client.with_options(timeout=timeout).beta.messages.create( @@ -117,8 +120,9 @@ class OllamaProvider(Provider): PROVIDERS = {"anthropic": AnthropicProvider, "openai": OpenAICompatibleProvider, "ollama": OllamaProvider} -def build_provider(kind: str, model: str, api_key: str = "", base_url: str = "", effort: str = "low") -> Provider | None: +def build_provider(kind: str, model: str, api_key: str = "", base_url: str = "", effort: str = "low", + workspace: str = "") -> Provider | None: cls = PROVIDERS.get((kind or "").lower()) if not cls or not model: return None - return cls(model, api_key=api_key, base_url=base_url, effort=effort) + return cls(model, api_key=api_key, base_url=base_url, effort=effort, workspace=workspace) diff --git a/party/consumers.py b/party/consumers.py index 932439d..6a6275f 100644 --- a/party/consumers.py +++ b/party/consumers.py @@ -75,6 +75,8 @@ class _RoomConsumer(AsyncJsonWebsocketConsumer): class HostConsumer(_RoomConsumer): + is_host = True + def authorized(self): return scope_identity(self.scope) is not None @@ -164,6 +166,10 @@ class PlayConsumer(_RoomConsumer): g.guess(mid, str(content["answer_id"]), now) elif action == "like": g.like(mid, str(content["answer_id"])) + elif action == "react": + reaction = g.react(mid, str(content.get("emoji", "")), now) + if reaction: + await self.room.forward_reaction(reaction) elif action == "play_again": g.play_again(mid, now) else: diff --git a/party/management/commands/tts_warm.py b/party/management/commands/tts_warm.py index cc96c34..5438300 100644 --- a/party/management/commands/tts_warm.py +++ b/party/management/commands/tts_warm.py @@ -3,6 +3,7 @@ import re from django.core.management.base import BaseCommand from party import tts +from fupogfakta.engine import RULES from party.narrator import LINES, TEASES @@ -15,5 +16,6 @@ class Command(BaseCommand): return static = [line for variants in LINES.values() for line in variants if not re.search(r"\{\w+\}", line)] static += TEASES + static += [r["say"] for r in RULES] ok = sum(1 for line in static if tts.synth_sync(line)) self.stdout.write(self.style.SUCCESS(f"{ok} af {len(static)} faste replikker ligger i cachen.")) diff --git a/party/narrator.py b/party/narrator.py index 1a7ce3e..034b932 100644 --- a/party/narrator.py +++ b/party/narrator.py @@ -106,6 +106,16 @@ class Narrator: return self.line("reveal_lie_fooled", svar=step["text"], forfatter=join_names(authors), ofre=join_names(picked)) + def present_answer(self, svar: str, comment: str | None = None) -> str: + if comment: + return capitalize(f"{safe(svar)}. {self.clean_ai(comment)}") + return self.line("present_answer", svar=svar) + + @staticmethod + def clean_ai(text: str) -> str: + """Hjernens replik: lyd-tags må gerne stå, men ingen pladsholdere eller markup.""" + return capitalize(re.sub(r"[{}<>]", "", text).strip()) + def _reveal_with_comment(self, step, names, picked, n_players, comment) -> str | None: """Hjernen har skrevet pointen; her lægges fakta (hvem skrev, hvem faldt) foran.""" comment = re.sub(r"[{}<>]", "", comment).strip() diff --git a/party/rooms.py b/party/rooms.py index e45171b..3ce419b 100644 --- a/party/rooms.py +++ b/party/rooms.py @@ -41,7 +41,12 @@ TEASE_CHANCE = 0.45 # sandsynlighed for at en narret spillers telefon b TEASES_PER_QUESTION = 2 TEASE_AT_MS = 2400 # når stemplet lander i et trin (svar → hvem → stempel) MEMORY_KEEP = 60 # linjer i rummets hukommelse (til hjernen) -COMMENT_WAIT = 4 # så længe afsløringen højst venter på hjernens kommentarer +COMMENT_WAIT = 7 # så længe afsløringen/oplæsningen højst venter på hjernen +PRESENT_MIN_STEP_MS = 1800 +PROGRESS_EVERY = 12 # hjernens bemærkninger mens der skrives: højst én pr. så mange sekunder +PROGRESS_PER_QUESTION = 2 +EXPLODE_EACH_MS = 250 # opsummeringen: tid pr. løgn, der eksploderer +COUNT_UP_MS = 2600 # opsummeringen: pointene tæller op MIC_ACTIVE_FOR = 25 # mikrofonen regnes som tændt så længe efter sidste lydstump _rooms: dict[str, "Room"] = {} @@ -71,8 +76,10 @@ class Room: # Hjernen og hukommelsen self.brain = get_brain() self.memory: list[str] = [] - self._comment_tasks: dict[tuple, asyncio.Task] = {} self._tease_texts: dict[str, str] = {} + self.fooled_pairs: dict[tuple[str, str], int] = {} # (narret, forfatter) -> antal gange i aften + self._progress_said: list[float] = [] + self._attempts: dict[str, int] = {} # Rummets mikrofon (eksperimentel) self.mic_last = 0.0 self.mic_laughs = 0 @@ -170,6 +177,12 @@ class Room: if ev["type"] == "reveal": asyncio.get_running_loop().create_task(self._narrate_reveal()) continue + if ev["type"] == "present": + asyncio.get_running_loop().create_task(self._narrate_present()) + continue + if ev["type"] == "rules": + asyncio.get_running_loop().create_task(self._narrate_rules()) + continue if ev["type"] == "join": now = time.time() if now - self._last_join_line < 2.5: # otte på én gang skal ikke alle kommenteres @@ -220,12 +233,10 @@ class Room: names = {p.id: p.name for p in g.players.values()} if t == "lie_submitted" and self.brain: self._hold_for(self._judge(ev["player"], ev["text"], dict(g.question or {}))) - elif t == "guess_start" and self.brain and g.question: - answers = [{"id": a.id, "text": a.text, - "kind": "sandheden" if a.is_truth else "husets løgn" if a.house else "løgn"} for a in g.answers] - task = asyncio.get_running_loop().create_task( - self.brain.comment_answers(dict(g.question), answers, list(self.memory))) - self._comment_tasks = {(g.round_idx, g.q_idx): task} + elif t == "lie_start": + self._progress_said, self._attempts = [], {} + elif t == "reactions_summary": + self.remember(f"Rummet reagerede mest på '{ev['text']}' ({ev['count']} reaktioner).") elif t == "reveal": truth = next((st for st in g.reveal_steps if st["is_truth"]), None) if truth and g.question and isinstance(g.question.get("id"), int): @@ -241,6 +252,13 @@ class Room: elif fooled: who = "husets løgn" if st["house"] else f"{', '.join(names.get(a, '?') for a in st['authors'])}s løgn" self.remember(f"{', '.join(fooled)} faldt for {who} '{st['text']}'.") + for victim in st["guessed_by"]: + for author in (st["authors"] or ["huset"]): + n = self.fooled_pairs.get((victim, author), 0) + 1 + self.fooled_pairs[(victim, author)] = n + if n >= 2: + self.remember(f"{names.get(victim, '?')} er nu faldet for " + f"{names.get(author, 'huset')} {n} gange i aften.") elif t == "scores": self.remember(f"Efter runden fører {ev['leader']}, og {ev['last']} ligger sidst.") elif t == "final" and self.brain: @@ -250,8 +268,10 @@ class Room: asyncio.get_running_loop().create_task(self._final_review(players)) async def _judge(self, pid: str, text: str, question: dict): + self._attempts[pid] = self._attempts.get(pid, 0) + 1 verdict = await self.brain.judge_lie(question, text) if not verdict or not (verdict.get("is_truth") or verdict.get("inappropriate")): + asyncio.get_running_loop().create_task(self._progress_remark(pid)) return if not self.game.reject_lie(pid, text): return @@ -267,18 +287,40 @@ class Room: self.say(line, blocking=False) self.changed() + async def _progress_remark(self, pid: str): + """Mens der skrives: en drillende bemærkning om spilleren (aldrig om løgnen).""" + g, now = self.game, time.time() + p = g.players.get(pid) + if (not p or not tts.enabled() or g.phase != "lie" or len(self._progress_said) >= PROGRESS_PER_QUESTION + or (self._progress_said and now - self._progress_said[-1] < PROGRESS_EVERY)): + return + self._progress_said.append(now) + info = {"navn": p.name, "sekunder_om_det": round(now - (g.lie_deadline - engine.LIE_SECONDS)), + "forsøg": self._attempts.get(pid, 1), "færdige": f"{len(g.lies)} af {len(g.players)}", + "point_indtil_nu": p.score} + line = await self.brain.lie_progress(info, list(self.memory)) + if line and g.phase == "lie": + self.say(line, blocking=False) + + def _pair_stats(self, names: dict) -> list[str]: + return [f"{names.get(v, '?')} er faldet for {names.get(a, 'huset')} {n} gange i aften" + for (v, a), n in sorted(self.fooled_pairs.items(), key=lambda x: -x[1]) if n >= 2][:8] + async def _final_review(self, players: list[dict]): lines = await self.brain.final_review(players, list(self.memory)) for _pid, line in lines: self.say(line) - async def _comments_for_current(self) -> dict: + async def _brain_reveal_lines(self, names: dict) -> dict: g = self.game - task = self._comment_tasks.get((g.round_idx, g.q_idx)) - if not task: - return {} + steps = [{"answer_id": st["answer_id"], "svar": st["text"], + "type": "sandheden" if st["is_truth"] else "husets løgn" if st["house"] else "løgn", + "skrevet_af": [names.get(a, "?") for a in st["authors"]], + "valgt_af": [names.get(p, "?") for p in st["guessed_by"]], "publikum": st["audience_votes"]} + for st in g.reveal_steps] try: - return await asyncio.wait_for(asyncio.shield(task), timeout=COMMENT_WAIT) + return await asyncio.wait_for(self.brain.reveal_lines(dict(g.question or {}), steps, self._pair_stats(names), + list(self.memory)), timeout=COMMENT_WAIT) except (asyncio.TimeoutError, Exception): # noqa: BLE001 return {} @@ -412,13 +454,19 @@ class Room: names = {p.id: p.name for p in g.players.values()} if self.brain: tease_job = asyncio.get_running_loop().create_task(self._brain_teases(names)) - comments = await self._comments_for_current() + ai_lines = await self._brain_reveal_lines(names) else: - tease_job, comments = None, {} - texts = [self.narrator.reveal_step(step, names, len(g.players), comments.get(step["answer_id"])) - for step in g.reveal_steps] + tease_job, ai_lines = None, {} + texts = [(self.narrator.clean_ai(ai_lines[step["answer_id"]]) if step["answer_id"] in ai_lines + else self.narrator.reveal_step(step, names, len(g.players))) for step in g.reveal_steps] note = (g.question or {}).get("note", "") + picked = {aid for aid in g.guesses.values()} | {aid for aid in g.audience_votes.values()} + unpicked = [a for a in g.answers if not a.is_truth and a.id not in picked] + cleanup_text = self.narrator.line("summary_cleanup") if unpicked else None + points_text = self.narrator.line("summary_points") jobs = [tts.synth(t) if t else asyncio.sleep(0, None) for t in texts] + jobs.append(tts.synth(cleanup_text) if cleanup_text else asyncio.sleep(0, None)) + jobs.append(tts.synth(points_text)) jobs.append(tts.synth(note) if note else asyncio.sleep(0, None)) try: results = await asyncio.wait_for(asyncio.gather(*jobs), timeout=REVEAL_AUDIO_WAIT) @@ -429,8 +477,9 @@ class Room: await asyncio.sleep(wait) if g.phase != "reveal" or (g.round_idx, g.q_idx) != ident or g.reveal_started_at is not None: return # værten sprang over, eller tiden løb ud + cleanup_audio, points_audio = results[-3], results[-2] steps_ms = [] - for step, text, audio in zip(g.reveal_steps, texts, results[:-1]): + for step, text, audio in zip(g.reveal_steps, texts, results[:-3]): step["audio"] = {"url": audio["url"], "ms": audio["ms"], "text": text} if audio else None steps_ms.append(max(REVEAL_MIN_STEP_MS, (audio["ms"] + REVEAL_PAD_MS) if audio else 0)) note_audio = results[-1] @@ -438,7 +487,21 @@ class Room: # Forklaringen starter med sandhedens trin og må gerne fortsætte ind i opsummeringen, # men opsummeringen skal have sin fulde tid, efter at speakeren er færdig. truth_ms = next((ms for st, ms in zip(g.reveal_steps, steps_ms) if st["is_truth"]), 0) - tail = engine.REVEAL_TAIL_MS + max(0, (note_audio["ms"] + REVEAL_PAD_MS - truth_ms) if note_audio else 0) + # Opsummeringen: (forklaringen tales færdig) → "ingen troede på dem" → eksplosioner → + # "og så til pointene" → pointene tæller op. Tider i ms fra halens start. + start = max(0, (note_audio["ms"] + REVEAL_PAD_MS - truth_ms) if note_audio else 0) + audio_list, at = [], start + 400 + if cleanup_audio: + audio_list.append({"url": cleanup_audio["url"], "ms": cleanup_audio["ms"], "at": at}) + at += cleanup_audio["ms"] + 200 + explode_at = at + at += (700 + EXPLODE_EACH_MS * len(unpicked)) if unpicked else 300 + if points_audio: + audio_list.append({"url": points_audio["url"], "ms": points_audio["ms"], "at": at}) + at += points_audio["ms"] + 150 + count_at = at + tail = max(engine.REVEAL_TAIL_MS, count_at + COUNT_UP_MS + 1800) + g.summary = {"start_ms": start, "explode_at": explode_at, "points_at": count_at, "audio": audio_list} if tease_job: try: await asyncio.wait_for(asyncio.shield(tease_job), timeout=0.1) @@ -448,6 +511,69 @@ class Room: self.changed() self._schedule_teases() + async def _narrate_present(self): + """Svarene læses op ét ad gangen; med hjernen får hvert svar en overbevisende kommentar.""" + g = self.game + ident = (g.round_idx, g.q_idx) + g.hold_present(time.time(), max_wait=COMMENT_WAIT + REVEAL_AUDIO_WAIT + 2 + max(0.0, self.speech_end - time.time())) + self.changed() + comments = {} + if self.brain: + try: + comments = await asyncio.wait_for( + self.brain.present_lines(dict(g.question or {}), [{"id": a.id, "text": a.text} for a in g.answers]), + timeout=COMMENT_WAIT) + except (asyncio.TimeoutError, Exception): # noqa: BLE001 + comments = {} + intro = self.narrator.line("present_intro") + texts = [self.narrator.present_answer(a.text, comments.get(a.id)) for a in g.answers] + try: + results = await asyncio.wait_for(asyncio.gather(tts.synth(intro), *[tts.synth(t) for t in texts]), + timeout=REVEAL_AUDIO_WAIT) + except asyncio.TimeoutError: + results = [None] * (len(texts) + 1) + wait = min(self.speech_end - time.time(), MAX_HOLD) + if wait > 0: + await asyncio.sleep(wait) + if g.phase != "present" or (g.round_idx, g.q_idx) != ident or g.present_started_at is not None: + return + intro_audio, answer_audio = results[0], results[1:] + g.present_intro_audio = {"url": intro_audio["url"], "ms": intro_audio["ms"]} if intro_audio else None + g.present_audio = [{"url": a["url"], "ms": a["ms"], "text": t} if a else None for a, t in zip(answer_audio, texts)] + steps_ms = [max(PRESENT_MIN_STEP_MS, a["ms"] + 500) if a else engine.PRESENT_STEP_MS for a in answer_audio] + g.start_present_timeline(time.time(), steps_ms, (intro_audio["ms"] + 300) if intro_audio else None) + self.changed() + + async def _narrate_rules(self): + """Reglerne: ét kort pr. replik; kortet skifter, når speakeren er færdig med det.""" + g = self.game + g.hold_rules(time.time(), max_wait=REVEAL_AUDIO_WAIT + 2 + max(0.0, self.speech_end - time.time())) + self.changed() + try: + results = await asyncio.wait_for(asyncio.gather(*[tts.synth(r["say"]) for r in engine.RULES]), + timeout=REVEAL_AUDIO_WAIT) + except asyncio.TimeoutError: + results = [None] * len(engine.RULES) + wait = min(self.speech_end - time.time(), MAX_HOLD) + if wait > 0: # "lad spillet begynde" tales færdig først + await asyncio.sleep(wait) + if g.phase != "rules" or g.rules_started_at is not None: + return # værten sprang over + g.rules_audio = [{"url": a["url"], "ms": a["ms"], "text": r["say"]} if a else None + for a, r in zip(results, engine.RULES)] + g.start_rules_timeline(time.time(), [max(2500, a["ms"] + 600) if a else engine.RULES_STEP_MS for a in results]) + self.changed() + + async def forward_reaction(self, reaction: dict): + """Emojis fra telefonerne går direkte til storskærmen (ingen fuld tilstand; de må gerne spammes).""" + msg = {"type": "react", **reaction} + for conn in list(self.connections): + if getattr(conn, "is_host", False): + try: + await conn.send_json(msg) + except Exception: # noqa: BLE001 + pass + async def _brain_teases(self, names: dict): """Personlige drillerier til de narrede; lyden laves med det samme, så den er klar, når stemplet lander.""" g = self.game diff --git a/party/static/party/common.js b/party/static/party/common.js index 90f007e..2b7218b 100644 --- a/party/static/party/common.js +++ b/party/static/party/common.js @@ -314,6 +314,34 @@ var stage = el < Math.min(1100, durs[i] * 0.28) ? 0 : el < Math.min(2300, durs[i] * 0.55) ? 1 : 2; return { done: false, i: i, stage: stage, n: steps.length, start: start, key: key + '|' + i + '.' + stage }; }; + /* Oplæsningen (runde 2 og finalen): først speakerens intro (i = -1), så ét svar ad gangen. + ids = rækkefølgen; telefonen mangler sit eget svar i answers, men det tæller stadig med i tiden. */ + WPP.presentProgress = function (state) { + var p = state.present || {}; + var ids = p.ids || (state.answers || []).map(function (a) { return a.id; }); + var key = (state.round ? state.round.number + '.' + state.round.question_number : '') + ':' + ((state.question || {}).prompt || ''); + if (!p.started_at) return { pending: true, done: false, i: -1, ids: ids, key: key + '|pending' }; + var el = Math.max(0, WPP.now() - p.started_at) - (p.intro_ms || 0); + if (el < 0) return { pending: false, done: false, i: -1, ids: ids, start: p.started_at, key: key + '|intro' }; + var i = 0; + while (i < ids.length && el >= ((p.steps_ms && p.steps_ms[i]) || 2600)) { el -= (p.steps_ms && p.steps_ms[i]) || 2600; i++; } + return { pending: false, done: i >= ids.length, i: i, ids: ids, start: p.started_at, key: key + '|' + i }; + }; + /* Reglerne: ét kort ad gangen i speakerens takt (i = kortet i fokus; pending = lyden laves stadig) */ + WPP.rulesProgress = function (state) { + var r = state.rules || {}, n = (r.items || []).length; + if (!r.started_at) return { pending: true, done: false, i: -1, n: n, key: 'rules|pending' }; + var el = Math.max(0, WPP.now() - r.started_at), i = 0; + while (i < n && el >= ((r.steps_ms && r.steps_ms[i]) || 5000)) { el -= (r.steps_ms && r.steps_ms[i]) || 5000; i++; } + return { pending: false, done: i >= n, i: Math.min(i, n - 1), n: n, key: 'rules|' + i }; + }; + /* Tekstfelter: kun bogstaver, tal, mellemrum og lidt tegnsætning (serveren renser det samme) */ + WPP.cleanInput = function (s, punct) { + var re = punct === 'name' ? /[^\p{L}\p{N} '-]/gu : /[^\p{L}\p{N} '.,&-]/gu; + try { return String(s || '').normalize('NFKC').replace(re, ''); } catch (e) { return String(s || ''); } + }; + /* emojis, telefonerne kan spamme under oplæsning, gæt og afsløring (samme liste som serveren) */ + WPP.REACTIONS = ['😂', '😱', '👏', '🤯', '🙈', '🔥']; WPP.reducedMotion = function () { try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) { return false; } }; diff --git a/party/static/party/dev-fixtures.js b/party/static/party/dev-fixtures.js index 6b0bc42..bff6f37 100644 --- a/party/static/party/dev-fixtures.js +++ b/party/static/party/dev-fixtures.js @@ -3,7 +3,7 @@ 'use strict'; var COLORS = ['#ff4d6d', '#3a86ff', '#ffbe0b', '#06d6a0', '#fb5607', '#8338ec', '#ff006e', '#00b4d8']; var NAMES = ['Asger', 'Sus', 'Bo', 'Mette', 'Kasper', 'Line']; - var PHASES = ['join', 'lobby', 'category', 'category-chosen', 'intro', 'intro-final', 'lie', 'guess', 'reveal-wait', 'reveal', 'tease', 'summary', 'scores', 'final']; + var PHASES = ['join', 'lobby', 'rules', 'category', 'category-chosen', 'intro', 'intro-final', 'lie', 'present', 'guess', 'reveal-wait', 'reveal', 'tease', 'summary', 'scores', 'final']; /* fixture-navn → spillets fase ("tease" = afsløringen, hvor telefon 1 bliver drillet) */ var REAL = { join: 'lobby', 'category-chosen': 'category', 'intro-final': 'intro', 'reveal-wait': 'reveal', summary: 'reveal', tease: 'reveal' }; /* drilleri-billede til harnessen (i drift: et tilfældigt billede fra static/party/tease/) */ @@ -75,8 +75,9 @@ started_at: phase === 'reveal-wait' ? null : now - t, note: 'Ugler kaldes et parlament – efter C.S. Lewis og gamle engelske jagtbøger.', all: ans.map(function (a) { - return { id: a.id, text: a.text, is_truth: a.is_truth, house: a.house, authors: a.authors, assisted: a.assisted || [], picked: !!picked[a.id], likes: likes[a.id] || 0 }; + return { id: a.id, text: a.text, is_truth: a.is_truth, house: a.house, authors: a.authors, assisted: a.assisted || [], picked: !!picked[a.id], likes: likes[a.id] || 0, reactions: { a2: 7, a4: 3 }[a.id] || 0 }; }), + summary: { start_ms: 0, explode_at: 2200, points_at: 4600, audio: [] }, deltas: [{ player_id: 'p1', delta: 2100 }, { player_id: 'p2', delta: 1700 }, { player_id: 'p3', delta: 0 }, { player_id: 'p4', delta: 0 }, { player_id: 'p5', delta: 450 }, { player_id: 'p6', delta: 0 }] } @@ -150,6 +151,25 @@ { label: 'Publikum', state: Object.assign(clone(host), { me: aud }) } ]; break; + case 'rules': + /* opts.t = ms siden reglerne startede (5 s pr. kort) */ + var rt = opts.t != null ? Number(opts.t) : 5000 * 3 + 500; + var RULES = [['🤥', 'Find på en løgn', 'Et sandt, men skørt faktum mangler et ord. Skriv en løgn, der lyder sand.'], + ['🔍', 'Find sandheden', 'Alle løgne blandes med sandheden. Gæt den rigtige, så får du point.'], + ['🎣', 'Narre de andre', 'Hver gang nogen vælger din løgn, får du point.'], + ['🤖', 'Lyv for mig', 'Helt blank? Få en løgn foræret. Den giver kun halve point.'], + ['❤️', 'Hjerter og emojis', 'Giv hjerter til de bedste løgne, og spam løs med emojis.'], + ['🏆', 'Dobbelt op', 'Runde 2 giver dobbelt point, finalen tredobbelt.']]; + host = Object.assign(base, { + round: null, deadline: now - rt + 31000, + rules: { started_at: now - rt, steps_ms: RULES.map(function () { return 5000; }), audio: null, + items: RULES.map(function (r) { return { icon: r[0], title: r[1], body: r[2] }; }) } + }); + phones = [ + { label: 'Spiller', state: Object.assign(clone(host), { me: me({}) }) }, + { label: 'Publikum', state: Object.assign(clone(host), { me: aud }) } + ]; + break; case 'lie': host = Object.assign(base, { deadline: now + 42000, question: question, @@ -161,6 +181,24 @@ { label: 'Publikum', state: Object.assign(clone(host), { me: aud }) } ]; break; + case 'present': + /* opts.t = ms siden oplæsningen startede (intro 1500 ms, så 2600 ms pr. svar) */ + var pt = opts.t != null ? Number(opts.t) : 1500 + 2600 * 2 + 800; + host = Object.assign(base, { + deadline: now - pt + 1500 + 2600 * answers.length + 1000, question: question, answers: answers, + round: { number: 2, name: 'Runde 2', multiplier: 2, question_number: 1, questions_in_round: 3 }, + present: { + started_at: now - pt, intro_ms: 1500, steps_ms: answers.map(function () { return 2600; }), intro_audio: null, + ids: answers.map(function (a) { return a.id; }), + audio: answers.map(function (a) { return { url: null, ms: 2100, text: a.text.replace(/<[^>]*>/g, '') + '. Det her lyder bare for overbevisende til at være løgn.' }; }) + } + }); + phones = [ + { label: 'Spiller (løj "papegøjer")', state: Object.assign(clone(host), { answers: answers.filter(function (a) { return a.id !== 'a3'; }), me: me({ lie: 'papegøjer' }) }) }, + { label: 'VIP', state: Object.assign(clone(host), { answers: answers.filter(function (a) { return a.id !== 'a2'; }), me: me({ id: 'p1', vip: true, lie: 'krager' }) }) }, + { label: 'Publikum', state: Object.assign(clone(host), { me: aud }) } + ]; + break; case 'guess': host = Object.assign(base, { deadline: now + 4000, question: question, answers: answers, diff --git a/party/static/party/host.js b/party/static/party/host.js index 1979551..4bf9b1b 100644 --- a/party/static/party/host.js +++ b/party/static/party/host.js @@ -2,7 +2,7 @@ (function () { 'use strict'; var WPP = window.WPP, esc = WPP.esc; - var TIMED = { category: 1, intro: 1, lie: 1, guess: 1, reveal: 1, scores: 1 }; + var TIMED = { rules: 1, category: 1, intro: 1, lie: 1, present: 1, guess: 1, reveal: 1, scores: 1 }; function ui(root) { return root._ui || (root._ui = { lastPhase: null, revealKey: null, revealSeen: {}, lastTick: null, state: null, ctx: null, sfxIds: {}, sfxKey: null }); @@ -14,7 +14,7 @@ function sfx(u, name, opts) { if (audible(u.ctx)) WPP.sfx(name, opts); } /* musik pr. fase; samme nummer i to faser i træk (lie → guess) spiller videre uden at starte forfra. intro har ingen musik: kategoriens jingle står alene. final: fanfare, så music_final. */ - var MUSIC = { lobby: 'music_lobby', category: 'music_choose', lie: 'music_think', guess: 'music_think', reveal: 'music_reveal', scores: 'music_lobby' }; + var MUSIC = { lobby: 'music_lobby', rules: 'music_lobby', category: 'music_choose', lie: 'music_think', present: 'music_think', guess: 'music_think', reveal: 'music_reveal', scores: 'music_lobby' }; var STING_FADE = 0.25; /* kort udtoning før en jingle/fanfare */ /* ---------- hjælpere ---------- */ @@ -37,11 +37,15 @@ /* Opsummeringen: hvornår hvert ikke-valgt svar eksploderer, og hvornår pointene tælles op (ms inde i halen). */ var BOOM0 = 1500, BOOM_GAP = 250, BOOM_MS = 900, BURST_MS = 520; + /* Serveren lægger tidsplanen (reveal.summary), så speakeren kan sige "lad os rydde op", før det brager, + og "så til pointene", før de tælles op. Uden den: faste tider. */ function sumPlan(state) { - var all = (state.reveal && state.reveal.all) || []; - var at = {}, k = 0; - all.forEach(function (a) { if (!a.picked && !a.is_truth) at[a.id] = BOOM0 + (k++) * BOOM_GAP; }); - return { at: at, n: k, pointsAt: k ? BOOM0 + (k - 1) * BOOM_GAP + BOOM_MS + 100 : 1200 }; + var r = state.reveal || {}, sm = r.summary; + var all = r.all || []; + var at = {}, k = 0, boom0 = sm ? sm.explode_at : BOOM0; + all.forEach(function (a) { if (!a.picked && !a.is_truth) at[a.id] = boom0 + (k++) * BOOM_GAP; }); + var pointsAt = sm ? sm.points_at : k ? BOOM0 + (k - 1) * BOOM_GAP + BOOM_MS + 100 : 1200; + return { at: at, n: k, pointsAt: pointsAt }; } /* nøglen der styrer gen-tegning under afsløringen (opsummeringen har to trin: svar → point) */ function viewKey(state, u) { @@ -144,6 +148,20 @@ '
' + WPP.timerHtml(state, 'ring') + '
'; } + /* reglerne: seks kort, der vendes ét ad gangen, mens speakeren forklarer dem */ + function rules(state) { + var rl = state.rules || {}, items = rl.items || []; + var pr = WPP.rulesProgress(state); + var cards = items.map(function (it, i) { + var shown = !pr.pending && i <= pr.i, now = shown && i === pr.i && !pr.done; + if (!shown) return '
' + (i + 1) + '
'; + return '
' + + '' + esc(it.icon) + '

' + esc(it.title) + '

' + esc(it.body) + '

'; + }).join(''); + return '

Sådan spiller man

' + + '
' + cards + '
'; + } + /* kategorien præsenteres: et stort kort flyver ind, hopper og gnistrer (finalen: et dramatisk FINALE-skilt først) */ function intro(state) { var it = state.intro || {}; @@ -182,6 +200,29 @@ '
' + WPP.timerHtml(state, 'ring') + '
'; } + /* oplæsningen: svarene kommer frem ét ad gangen, mens speakeren læser dem op (og sælger dem) */ + function present(state, u) { + var pr = WPP.presentProgress(state); + var p = state.present || {}; + var byId = {}; + (state.answers || []).forEach(function (a) { byId[a.id] = a; }); + var ids = pr.ids.filter(function (id) { return byId[id]; }); + var cols = ids.length > 6 ? 3 : 2; + var tiles = ids.map(function (id, i) { + var idx = pr.ids.indexOf(id); + var shown = !pr.pending && pr.i >= idx, now = !pr.done && pr.i === idx; + if (!shown) return '
?
'; + return '
' + esc(byId[id].text) + '
'; + }).join(''); + var said = !pr.pending && pr.i >= 0 && !pr.done && p.audio && p.audio[pr.i] ? p.audio[pr.i].text : ''; + var head = pr.pending ? 'Speakeren varmer stemmen op…' : pr.i < 0 ? 'Her er jeres svar…' : pr.done ? 'Gør jer klar til at gætte!' : 'Lyt godt efter… hvad er sandheden?'; + return questionBlock(state, 'small') + + '

' + head + '

' + + '
' + tiles + '
' + + '

' + (said ? esc(said) : '') + '

'; + } + function guess(state) { var ans = state.answers || []; var cols = ans.length > 6 ? 3 : 2; @@ -281,11 +322,12 @@ }).join(''); var info = a.is_truth ? (fooled ? 'Fundet af ' + fooled : 'Ingen fandt den') : a.picked ? 'Narrede ' + fooled : 'Ingen faldt for den'; var likes = a.is_truth ? 0 : (a.likes || 0); + var rx = a.reactions ? '' + WPP.REACTIONS[a.reactions % WPP.REACTIONS.length] + ' ' + a.reactions + '' : ''; return '
' + tag + '
' + esc(a.text) + '
' + (by ? '
' + by + '
' : '') + - '
' + info + '
' + (likes ? '♥ ' + likes + '' : '') + '
'; + '
' + info + rx + '
' + (likes ? '♥ ' + likes + '' : '') + ''; } function sumRow(p, i, d, live) { var score = p.score || 0, prev = score - d, delay = 150 + i * 160; @@ -434,6 +476,21 @@ root._shakeT = setTimeout(function () { root.classList.remove('shake'); }, 900); } + /* en emoji fra en telefon: flyver op fra svaret i fokus (eller fra bunden), i afsenderens farve */ + function flyReaction(root, msg) { + var u = ui(root); + if (WPP.reducedMotion() || !msg || !msg.emoji) return; + var layer = fxLayer(root); + if (layer.querySelectorAll('.freact').length > 80) return; /* spam: skærmen kan kun rumme så meget */ + var el = msg.answer_id ? byAid(root, msg.answer_id) : null, x, y; + if (el) { var c = centerOf(root, el, 0.15 + Math.random() * 0.7, 0.2); x = c.x; y = c.y; } + else { var lr = layer.getBoundingClientRect(); x = lr.width * (0.06 + Math.random() * 0.88); y = lr.height - 30; } + var e = spawn(root, 'freact', x, y, '--pc:' + (msg.color || '#fff') + ';--dx:' + Math.round((Math.random() - 0.5) * 160) + 'px;--s:' + + (0.8 + Math.random() * 0.7).toFixed(2) + ';--r:' + Math.round((Math.random() - 0.5) * 50) + 'deg', 2600); + e.textContent = msg.emoji; + sfx(u, 'pop_in', { volume: 0.16, gap: 110, rate: 1.15 + Math.random() * 0.5 }); + } + /* et ikke-valgt svar i opsummeringen eksploderer */ function boomTile(root, u, armKey, id) { if (u.sumArm !== armKey) return; @@ -609,6 +666,15 @@ if (urls[0] || urls[1]) WPP.voice.now(urls); } } + /* opsummeringens replikker ("lad os rydde op", "så til pointene") på serverens tidsplan */ + var sm = s.reveal && s.reveal.summary; + if (pr.done && sm && sm.audio && !(u.ctx && u.ctx.harness)) { + u.sumVoice = u.sumVoice || {}; + sm.audio.forEach(function (a, idx) { + var id = pr.key + '@' + pr.start + '#' + idx; + if (!u.sumVoice[id] && pr.tail >= a.at && pr.tail < a.at + 1500) { u.sumVoice[id] = 1; WPP.voice.enqueue({ url: a.url }); } + }); + } var key = viewKey(s, u); if (key !== u.revealKey) { var prev = u.revealKey; @@ -616,6 +682,27 @@ if (prev) revealCue(root, u, s, pr, key); } } + if (s.phase === 'rules') { + var rp = WPP.rulesProgress(s); + if (rp.key !== u.rulesKey) { + u.rulesKey = rp.key; + renderHost(root, s, u.ctx); + var ra = (s.rules && s.rules.audio) || []; + if (!rp.pending && !rp.done && ra[rp.i] && ra[rp.i].url && !(u.ctx && u.ctx.harness)) WPP.voice.now([ra[rp.i].url]); + } + } + if (s.phase === 'present') { + var pp = WPP.presentProgress(s); + if (pp.key !== u.presentKey) { + u.presentKey = pp.key; + renderHost(root, s, u.ctx); + /* speakeren: introen, så hvert svars replik i samme øjeblik, som svaret kommer frem */ + var pa = s.present || {}; + var clip = pp.pending || pp.done ? null : pp.i < 0 ? pa.intro_audio : (pa.audio || [])[pp.i]; + if (clip && clip.url && !(u.ctx && u.ctx.harness)) WPP.voice.now([clip.url]); + if (!pp.pending && !pp.done && pp.i >= 0) sfx(u, 'whoosh', { volume: 0.3 }); + } + } if (s.deadline && (s.phase === 'lie' || s.phase === 'guess' || s.phase === 'category')) { var left = WPP.secondsLeft(s.deadline); if (left !== u.lastTick) { @@ -683,9 +770,11 @@ var main, foot = {}, pr = null; switch (phase) { case 'lobby': main = lobby(state, ctx); break; + case 'rules': main = rules(state); u.rulesKey = WPP.rulesProgress(state).key; break; case 'category': main = category(state); break; case 'intro': main = intro(state); break; case 'lie': main = lie(state); foot.doneChips = true; break; + case 'present': main = present(state, u); u.presentKey = WPP.presentProgress(state).key; break; case 'guess': main = guess(state); foot.doneChips = true; break; case 'reveal': main = reveal(state, u); pr = revealProgress(state, u); u.revealKey = viewKey(state, u); break; case 'scores': main = scores(state); break; @@ -716,13 +805,14 @@ root: root, onMessage: function (msg) { if (msg.type === 'state' && msg.state) renderHost(root, msg.state, ctx); + else if (msg.type === 'react') flyReaction(root, msg); else if (msg.type === 'error') WPP.toast(root, msg.message || 'Ups, noget gik galt.'); } }); return ctx; } - window.WPPHost = { renderHost: renderHost, mount: mount, revealProgress: revealProgress, sumPlan: sumPlan }; + window.WPPHost = { renderHost: renderHost, mount: mount, revealProgress: revealProgress, sumPlan: sumPlan, flyReaction: flyReaction }; window.renderHost = renderHost; if (!window.WPP_HARNESS) { diff --git a/party/static/party/party.css b/party/static/party/party.css index 9bec343..6b95262 100644 --- a/party/static/party/party.css +++ b/party/static/party/party.css @@ -218,7 +218,7 @@ button, input { font-family: inherit; } .qcat { display: inline-block; font-size: .85rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--cyan); margin-bottom: 6px; } .qprompt { font-size: 1.35rem; font-weight: 600; line-height: 1.3; overflow-wrap: anywhere; } .lie-form { display: flex; flex-direction: column; gap: 14px; } -.lie-form input.flash { animation: flash .6s; } +.lie-form input.sug-in { animation: sug-in .5s cubic-bezier(.3, 1.6, .5, 1); } @keyframes flash { 0% { background: var(--yellow); } } .mylie { text-align: center; color: var(--muted); overflow-wrap: anywhere; } .mylie b { color: #fff; } @@ -819,3 +819,81 @@ button, input { font-family: inherit; } @keyframes micpulse { 50% { opacity: .25; } } @keyframes miclaugh { 0% { transform: scale(.2) translateY(10px); opacity: 0; } 25% { transform: scale(1.4); opacity: 1; } 100% { transform: scale(1) translateY(-30px); opacity: 0; } } @media (prefers-reduced-motion: reduce) { .mic-box .mic-live i, .mic-box .mic-laugh.pop { animation: none; } } + +/* ---------- oplæsning (runde 2 og finalen) ---------- */ +.hs-present .hs-main { justify-content: flex-start; gap: 1.1em; padding-top: .4em; } +.ans-grid.present .ans-tile { transition: opacity .3s, transform .3s, filter .3s; } +.ans-tile.ghost { background: var(--card); color: var(--muted); box-shadow: inset 0 0 0 .08em var(--card-b); animation: none; opacity: .5; } +.ans-tile.read { opacity: .72; filter: saturate(.8); } +.ans-tile.now { transform: scale(1.07); box-shadow: 0 0 0 .14em #fff, 0 .14em 0 var(--cd), 0 0 1.4em rgba(255, 255, 255, .45); z-index: 1; } +.pr-say { min-height: 2.6em; max-width: 60em; margin: 0; font-size: 1.45em; font-style: italic; color: var(--ink); text-align: center; animation: rise .35s ease-out both; } +.pr-say:empty { visibility: hidden; } +.pa-list { display: flex; flex-direction: column; gap: 10px; } +.pa-row { + padding: 12px 16px; border-radius: 14px; font-size: 1.2rem; font-weight: 700; color: var(--navy); background: var(--c); + box-shadow: 0 3px 0 var(--cd); opacity: .7; overflow-wrap: anywhere; animation: pop .4s cubic-bezier(.3, 1.6, .5, 1) both; +} +.pa-row.now { opacity: 1; outline: 3px solid #fff; outline-offset: 2px; } +.pa-row.mine { background: var(--card); color: var(--muted); box-shadow: inset 0 0 0 2px var(--card-b); } +.pa-row small { font-weight: 600; font-size: .75em; } + +/* ---------- reaktioner ---------- */ +.reacts { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 18px; } +.react { + font: inherit; font-size: 1.7rem; line-height: 1; padding: 10px 0; border: 0; border-radius: 14px; background: var(--card); + box-shadow: inset 0 0 0 1px var(--card-b); color: inherit; cursor: pointer; touch-action: manipulation; -webkit-tap-highlight-color: transparent; + user-select: none; -webkit-user-select: none; +} +.react:active { transform: scale(.88); background: var(--card-b); } +.rpuff { position: absolute; z-index: 50; pointer-events: none; font-style: normal; font-size: 1.8rem; margin-left: -.5em; animation: rpuff .9s ease-out both; } +@keyframes rpuff { from { transform: translate(0, 0) scale(.6); opacity: 1; } to { transform: translate(var(--dx), -90px) scale(1.3); opacity: 0; } } +.freact { + margin: -.6em 0 0 -.5em; font-size: calc(3.2em * var(--s, 1)); line-height: 1; + filter: drop-shadow(0 0 .18em var(--pc)) drop-shadow(0 .05em 0 rgba(0, 0, 0, .35)); + animation: react-up 2.4s cubic-bezier(.2, .7, .4, 1) both; +} +@keyframes react-up { + 0% { transform: translate(0, 0) scale(.2) rotate(0); opacity: 0; } + 12% { transform: translate(0, -.6em) scale(1.2) rotate(var(--r)); opacity: 1; } + 100% { transform: translate(var(--dx), -6em) scale(.9) rotate(calc(var(--r) * -1)); opacity: 0; } +} +.st-info .rx { margin-left: .5em; opacity: 1; white-space: nowrap; } + +/* ---------- svar med versaler (som Jackbox): ingen kan se forskel på spillernes, husets og sandhedens svar ---------- */ +.ans-tile, .rv-ans, .st-text, .btn-ans, .pa-row, .hrow span, .pick, .mylie b, .blank.filled, #wpp-lie { text-transform: uppercase; } +#wpp-lie::placeholder { text-transform: none; } + +/* ---------- "Lyv for mig": låst forslag ---------- */ +.lie-form .sug-note, .lie-form .own { display: none; } +.lie-form.has-sug .sug-note, .lie-form.has-sug .own { display: block; } +.lie-form.has-sug [data-act="lie_for_me"] { display: none; } +.lie-form input.sug { background: rgba(255, 255, 255, .16); color: rgba(255, 255, 255, .78); border-style: dashed; cursor: default; } +.lie-form input.sug:focus { box-shadow: none; border-color: var(--card-b); } +.sug-note { margin: -4px 0 0; font-size: .95rem; color: var(--muted); text-align: center; } +.sug-note b { color: var(--yellow); } +.btn-ghost { background: transparent; color: var(--muted); box-shadow: inset 0 0 0 2px var(--card-b); font-size: 1rem; padding: 10px 14px; } +@keyframes sug-in { 0% { transform: scale(.94); } 60% { transform: scale(1.03); } } +.btn.nudge { animation: nudge 1.2s ease-in-out 2; } +@keyframes nudge { 0%, 100% { transform: none; } 15% { transform: scale(1.06) rotate(-1.5deg); } 30% { transform: scale(1.06) rotate(1.5deg); } 45% { transform: none; } } + +/* ---------- reglerne ---------- */ +.hs-rules .hs-main { gap: 1.2em; } +.rl-title { margin: 0; font-size: 3.2em; color: var(--yellow); text-shadow: 0 .06em 0 var(--yellow-d); } +.rl-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1em; width: 100%; max-width: 76em; } +.rl-card { + min-height: 11em; padding: .8em 1em; border-radius: .7em; background: var(--c); color: var(--navy); text-align: center; + box-shadow: 0 .14em 0 var(--cd), 0 .3em .6em rgba(0, 0, 0, .25); opacity: .75; transition: transform .3s, opacity .3s; + animation: tile-in .5s cubic-bezier(.3, 1.6, .5, 1) both; +} +.rl-card.now { opacity: 1; transform: scale(1.06); box-shadow: 0 0 0 .14em #fff, 0 .14em 0 var(--cd), 0 0 1.4em rgba(255, 255, 255, .45); } +.rl-card.ghost { display: grid; place-items: center; background: var(--card); box-shadow: inset 0 0 0 .08em var(--card-b); animation: none; opacity: .5; color: var(--muted); } +.rl-card .rl-n { font-size: 3em; font-weight: 700; } +.rl-card .rl-icon { display: block; font-size: 2.6em; line-height: 1.1; } +.rl-card h3 { margin: .15em 0 .2em; font-size: 1.7em; } +.rl-card p { margin: 0; font-size: 1.25em; font-weight: 600; line-height: 1.25; } +.rl-list { display: flex; flex-direction: column; gap: 10px; } +.rl-row { display: flex; gap: 12px; align-items: center; padding: 10px 14px; border-radius: 14px; background: var(--card); box-shadow: inset 0 0 0 1px var(--card-b); opacity: .75; animation: pop .4s cubic-bezier(.3, 1.6, .5, 1) both; } +.rl-row.now { opacity: 1; box-shadow: inset 0 0 0 2px #fff; } +.rl-row .rl-icon { font-size: 1.8rem; } +.rl-row b { display: block; } +.rl-row span { font-size: .95rem; color: var(--muted); } diff --git a/party/static/party/play.js b/party/static/party/play.js index 83d2e69..1501700 100644 --- a/party/static/party/play.js +++ b/party/static/party/play.js @@ -4,7 +4,7 @@ var WPP = window.WPP, esc = WPP.esc; function ui(root) { - return root._ui || (root._ui = { busy: false, busyTimer: null, drafts: {}, lastPhase: null, revealSeen: {}, state: null, revealKey: null }); + return root._ui || (root._ui = { busy: false, busyTimer: null, drafts: {}, sug: {}, lastPhase: null, revealSeen: {}, state: null, revealKey: null }); } /* ---------- travlhed (forhindrer dobbelt-tryk) ---------- */ @@ -37,12 +37,26 @@ if (a === 'like') { var c = root._ctx || {}; if (c.send) c.send({ action: 'like', answer_id: arg }); b.classList.toggle('on'); } else act(root, { action: a, answer_id: arg }); } + else if (a === 'react') { + /* må gerne spammes: ingen travlhed, serveren drosler selv */ + var cx = root._ctx || {}; + if (cx.send) cx.send({ action: 'react', emoji: arg }); + reactPuff(root, b, arg); + } else if (a === 'lie_for_me') act(root, { action: a }); + else if (a === 'own_lie') { + var inp0 = root.querySelector('#wpp-lie'); + if (inp0) { var q0 = inp0.getAttribute('data-q') || ''; delete ui(root).sug[q0]; ui(root).drafts[q0] = ''; inp0.value = ''; lockSuggestion(root, inp0, false); inp0.focus(); } + } else if (a === 'start' || a === 'play_again') act(root, { action: a }); else if (a === 'forget') { if (root._ctx && root._ctx.onForget) root._ctx.onForget(); } }); root.addEventListener('input', function (e) { var t = e.target; + if (t.id === 'wpp-lie' || t.name === 'name') { + var cleaned = WPP.cleanInput(t.value, t.name === 'name' ? 'name' : 'lie'); + if (cleaned !== t.value) t.value = cleaned; /* emojis o.l. kan ikke tastes */ + } if (t.id === 'wpp-lie') ui(root).drafts[t.getAttribute('data-q') || ''] = t.value; if (t.name === 'code') { var v = t.value.toUpperCase().replace(/[^A-Z]/g, '').slice(0, 4); @@ -123,6 +137,19 @@ return out; } + /* reglerne: samme kort som på storskærmen, i samme takt */ + function screenRules(state) { + var items = (state.rules && state.rules.items) || []; + var pr = WPP.rulesProgress(state); + var shown = pr.pending ? [] : items.slice(0, pr.i + 1); + return '
Sådan spiller man
' + + '
' + shown.map(function (it, i) { + return '
' + esc(it.icon) + '
' + esc(it.title) + + '' + esc(it.body) + '
'; + }).join('') + '
' + + '

Kig op på skærmen!

'; + } + function screenCategory(state, me, players) { var cat = state.category || {}; var opts = cat.options || []; @@ -170,9 +197,11 @@ out += '
' + '' + '' + + 'autocapitalize="characters" autocorrect="off" spellcheck="false" placeholder="Skriv en troværdig løgn…">' + + '

Husets løgn: kun halve point, hvis nogen falder for den.

' + '' + '' + + '' + '
'; return out + progress(state); } @@ -212,6 +241,41 @@ }).join('') + ''; } + /* ---------- reaktioner ---------- */ + function reactBar() { + return '
' + WPP.REACTIONS.map(function (e) { + return ''; + }).join('') + '
'; + } + /* lille ekko på telefonen, så man kan se, at trykket kom afsted */ + function reactPuff(root, btn, emoji) { + if (WPP.reducedMotion()) return; + var r = btn.getBoundingClientRect(), rr = root.getBoundingClientRect(); + var e = document.createElement('i'); + e.className = 'rpuff'; + e.textContent = emoji; + e.style.cssText = 'left:' + (r.left - rr.left + r.width / 2) + 'px;top:' + (r.top - rr.top) + 'px;--dx:' + Math.round((Math.random() - 0.5) * 60) + 'px'; + root.appendChild(e); + setTimeout(function () { if (e.parentNode) e.parentNode.removeChild(e); }, 1000); + } + + /* oplæsningen: svarene dukker op i samme takt som på storskærmen; der kan ikke gættes endnu */ + function screenPresent(state, me) { + var pr = WPP.presentProgress(state); + var q = state.question || {}; + var byId = {}; + (state.answers || []).forEach(function (a) { byId[a.id] = a; }); + var rows = pr.pending ? [] : pr.ids.slice(0, pr.i + 1).map(function (id, i) { + var now = !pr.done && i === pr.i; + var a = byId[id]; + var text = a ? esc(a.text) : esc(me.lie || '') + ' din løgn'; + return '
' + text + '
'; + }); + return questionCard(state, 'pq:' + (q.prompt || '')) + + '

' + (pr.done ? 'Gør dig klar til at gætte!' : 'Lyt efter – svarene læses op') + '

' + + '
' + rows.join('') + '
' + reactBar(); + } + function screenGuess(state, me) { var q = state.question || {}; var out = questionCard(state, 'gq:' + (q.prompt || '')); @@ -220,14 +284,14 @@ var picked = ans.filter(function (a) { return a.id === me.guess; })[0]; out += '

Du gættede på

' + esc(picked ? picked.text : '…') + '
'; out += '

Venter på de andre…
Når løgnene afsløres, kan du give hjerter.

'; - return out; + return out + reactBar(); } out += '

Hvad er sandheden?

'; out += '
' + ans.map(function (a, i) { return ''; }).join('') + '
'; - return out; + return out + reactBar(); } function screenReveal(state, me, players, u) { @@ -240,7 +304,7 @@ : '0point denne gang') + ''; } out += heartList(state, me, pr); - return out; + return out + reactBar(); } function rankOf(state, id) { @@ -315,9 +379,11 @@ var body; switch (phase) { case 'lobby': body = screenLobby(state, me, players); break; + case 'rules': body = screenRules(state); u.rulesKey = WPP.rulesProgress(state).key; break; case 'category': body = screenCategory(state, me, players); break; case 'intro': body = screenIntro(state); break; case 'lie': body = screenLie(state, me, players, root); break; + case 'present': body = screenPresent(state, me); u.presentKey = WPP.presentProgress(state).key; break; case 'guess': body = screenGuess(state, me, players); break; case 'reveal': body = screenReveal(state, me, players, u); u.revealKey = WPP.revealProgress(state, u).key; break; case 'scores': body = screenScores(state, me, players); break; @@ -336,6 +402,7 @@ var d = u.drafts[inp.getAttribute('data-q') || '']; if (d) inp.value = d; } + if (inp) lockSuggestion(root, inp, !!u.sug[inp.getAttribute('data-q') || '']); if (u.lastPhase !== phase) { if (u.lastPhase && !ctx.harness) { try { window.scrollTo(0, 0); } catch (e) { /* */ } @@ -349,8 +416,11 @@ /* afsløringen skrider frem uden nye snapshots: tegn igen, når storskærmen skifter trin */ function playTick(root) { var u = ui(root), s = u.state; - if (!s || s.phase !== 'reveal' || !root.isConnected || !root._ctx) return; - if (WPP.revealProgress(s, u).key !== u.revealKey) renderPlay(root, s, Object.assign({}, root._ctx, { keepBusy: true })); + if (!s || !root.isConnected || !root._ctx) return; + var stale = s.phase === 'reveal' ? WPP.revealProgress(s, u).key !== u.revealKey + : s.phase === 'present' ? WPP.presentProgress(s).key !== u.presentKey + : s.phase === 'rules' ? WPP.rulesProgress(s).key !== u.rulesKey : false; + if (stale) renderPlay(root, s, Object.assign({}, root._ctx, { keepBusy: true })); } function renderJoin(root, ctx, opts) { @@ -382,13 +452,28 @@ WPP.patch(sh.screen, '

' + esc(text || 'Forbinder…') + '

'); } + /* "Lyv for mig": forslaget står gråt og låst i feltet (ellers kunne man rette ét bogstav og få fulde point); + "Nej, jeg skriver selv" tømmer det igen */ function setSuggestion(root, text) { setBusy(root, false); var inp = root.querySelector('#wpp-lie'); if (!inp) return; + var q = inp.getAttribute('data-q') || ''; inp.value = String(text || '').slice(0, 40); - ui(root).drafts[inp.getAttribute('data-q') || ''] = inp.value; - inp.classList.remove('flash'); void inp.offsetWidth; inp.classList.add('flash'); + ui(root).drafts[q] = inp.value; + ui(root).sug[q] = true; + lockSuggestion(root, inp, true); + inp.classList.remove('sug-in'); void inp.offsetWidth; inp.classList.add('sug-in'); + var go = root.querySelector('.lie-form [type="submit"]'); + if (go) { go.classList.remove('nudge'); void go.offsetWidth; go.classList.add('nudge'); } + } + function lockSuggestion(root, inp, on) { + inp.readOnly = on; + inp.classList.toggle('sug', on); + var f = inp.closest('.lie-form'); + if (f) f.classList.toggle('has-sug', on); + var go = f && f.querySelector('[type="submit"]'); + if (go) go.textContent = on ? 'Send husets løgn' : 'Send løgn'; } /* ---------- drilleri: "Buh!", når man lige er faldet for en løgn ---------- diff --git a/party/tests/test_brain.py b/party/tests/test_brain.py index 5d96c5b..2e10542 100644 --- a/party/tests/test_brain.py +++ b/party/tests/test_brain.py @@ -14,6 +14,16 @@ from .test_narration import fake_synth from .test_ws import HOST_HEADERS, make_bank, recv_until + +def setUpModule(): + from fupogfakta.engine import Game + Game.show_rules = False # reglerne testes for sig (RulesTests) + + +def tearDownModule(): + from fupogfakta.engine import Game + Game.show_rules = True + class FakeProvider: """Svarer som en sprogmodel ville, ud fra hvilket skema der bedes om.""" name = "fake" @@ -27,10 +37,14 @@ class FakeProvider: if "is_truth" in props: bad = '"løgn": "grim løgn"' in user return {"is_truth": False, "inappropriate": bad, "message": "Prøv en pænere løgn." if bad else ""} - if "comments" in props: - import json, re - data = json.loads(user.split("Data (JSON):\n", 1)[1]) - return {"comments": [{"id": a["id"], "line": f"KOMMENTAR {a['text']}"} for a in data["svar"]]} + import json + data = json.loads(user.split("Data (JSON):\n", 1)[1]) + if "comments" in props: # oplæsning + return {"comments": [{"id": a["id"], "line": f"OVERBEVISENDE {a['text']}"} for a in data["svar"]]} + if "lines" in props and "answer_id" in props["lines"]["items"]["properties"]: # afsløring + return {"lines": [{"answer_id": st["answer_id"], "line": f"KOMMENTAR {st['svar']}"} for st in data["trin"]]} + if "line" in props: # mens der skrives + return {"line": "Du var hurtig."} if "teases" in props: return {"teases": []} if "lines" in props: @@ -123,3 +137,52 @@ class BrainInGameTests(TransactionTestCase): self.assertTrue(any("grinede" in m for m in room.memory)) self.assertTrue(any("helt sikkert ugler" in m for m in room.memory)) self.assertTrue(room.mic_on()) + + +@override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None) +class PresentAndSummaryTests(TransactionTestCase): + def setUp(self): + rooms.all_rooms().clear() + make_bank() + from fupogfakta import engine as _engine + self._intro, _engine.INTRO_SECONDS = _engine.INTRO_SECONDS, 0.2 + self.addCleanup(setattr, _engine, "INTRO_SECONDS", self._intro) + + async def test_present_uses_convincing_comments_and_summary_has_timeline(self): + from fupogfakta.models import load_game_questions + fake = FakeProvider() + with mock.patch.object(rooms, "get_brain", return_value=GameBrain(fake)): + room = rooms.create_room(await sync_to_async(load_game_questions)(), "agw", "http://testserver") + g = room.game + with mock.patch.object(tts, "enabled", return_value=True), mock.patch.object(tts, "synth", fake_synth): + import time as _t + a, b = g.join("Asger"), g.join("Sus") + g.start(None, _t.time()) + g.round_idx, g.q_idx = 1, 0 + g._begin_question(_t.time()) + g.choose_category(g.chooser_id, g.category_options[0]["id"], _t.time()) + g._start_lie(_t.time()) + g.hold_until = None + room.speech_end = 0 + g.submit_lie(a.id, "løgn a", _t.time()) + g.submit_lie(b.id, "løgn b", _t.time()) + room.changed() + for _ in range(100): # oplæsningen laver lyd, så starter den + await asyncio.sleep(0.05) + if g.phase == "present" and g.present_started_at: + break + self.assertTrue(any(x and "OVERBEVISENDE" in x["text"] for x in g.present_audio)) + g._begin_guessing(_t.time()) + g.hold_until = None + room.speech_end = 0 + truth = next(x.id for x in g.answers if x.is_truth) + g.guess(a.id, truth, _t.time()) + g.guess(b.id, truth, _t.time()) + room.changed() + for _ in range(100): + await asyncio.sleep(0.05) + if g.reveal_started_at and g.summary: + break + s = g.summary + self.assertLess(s["explode_at"], s["points_at"]) # først oprydning, så point + self.assertTrue(any(x["at"] == s["start_ms"] + 400 for x in s["audio"])) # "ingen troede på dem" diff --git a/party/tests/test_narration.py b/party/tests/test_narration.py index 6e8e70b..3bbacbb 100644 --- a/party/tests/test_narration.py +++ b/party/tests/test_narration.py @@ -14,6 +14,16 @@ from partyhub.asgi import application from .test_ws import HOST_HEADERS, make_bank, recv_until + +def setUpModule(): + from fupogfakta.engine import Game + Game.show_rules = False # reglerne testes for sig (RulesTests) + + +def tearDownModule(): + from fupogfakta.engine import Game + Game.show_rules = True + class NarratorTests(SimpleTestCase): def test_number_words(self): cases = {0: "nul", 1: "én", 3: "tre", 21: "enogtyve", 47: "syvogfyrre", 100: "et hundrede", diff --git a/party/tests/test_views.py b/party/tests/test_views.py index bc60c9a..1758d0e 100644 --- a/party/tests/test_views.py +++ b/party/tests/test_views.py @@ -5,6 +5,16 @@ from party import rooms @override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None) + +def setUpModule(): + from fupogfakta.engine import Game + Game.show_rules = False # reglerne testes for sig (RulesTests) + + +def tearDownModule(): + from fupogfakta.engine import Game + Game.show_rules = True + class ViewTests(TestCase): def setUp(self): rooms.all_rooms().clear() diff --git a/party/tests/test_ws.py b/party/tests/test_ws.py index a8d43d8..c67ec7c 100644 --- a/party/tests/test_ws.py +++ b/party/tests/test_ws.py @@ -26,6 +26,16 @@ HOST_HEADERS = [(b"x-authentik-username", b"agw"), (b"x-authentik-groups", b"Fam @override_settings(TRUSTED_PROXIES=["127.0.0.1"], DEV_HOST_USER=None) + +def setUpModule(): + from fupogfakta.engine import Game + Game.show_rules = False # reglerne testes for sig (RulesTests) + + +def tearDownModule(): + from fupogfakta.engine import Game + Game.show_rules = True + class WebSocketGameTests(TransactionTestCase): def setUp(self): rooms.all_rooms().clear() diff --git a/partyhub/settings.py b/partyhub/settings.py index 6efb073..9bea75a 100644 --- a/partyhub/settings.py +++ b/partyhub/settings.py @@ -41,6 +41,7 @@ BRAIN_MODEL = env("BRAIN_MODEL", "") BRAIN_API_KEY = env("BRAIN_API_KEY", "") BRAIN_BASE_URL = env("BRAIN_BASE_URL", "") BRAIN_EFFORT = env("BRAIN_EFFORT", "low") # kun Anthropic +BRAIN_WORKSPACE = env("BRAIN_WORKSPACE", "") # kun Anthropic: workspace-id, når nøglen ikke selv er bundet til et BRAIN_TIMEOUT_FAST = float(env("BRAIN_TIMEOUT_FAST", "6")) # dommer, drillerier BRAIN_TIMEOUT_SLOW = float(env("BRAIN_TIMEOUT_SLOW", "25")) # kommentarer, anmeldelse