From 5b24404eacce57768815604bcb362fb653a3f8c9 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Tue, 20 Oct 2026 21:59:54 +0200 Subject: [PATCH] Spionen: rettelser fra review og QA, og bots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replikker: - To spioner siges i flertal ("Spionerne var ..."), også i den generiske udgave; fanges den ene, og den anden slipper, er det en halv sejr ("Den ene spion er fanget, men den anden slap væk"), ikke "Agenterne vinder". Storskærmens overskrift følger med. - Er spionen gået i løbet af runden, siges "Spionen forlod missionen", og navnet står stadig i afsløringen (motoren husker spionernes navne fra rundens start; storskærmen viser dem uden farve). - Fire små replikker skrevet om (vedkommende, "Måske er det ansigtet", skarpeste øje = flest rigtige stemmer, vinderen gentog ikke den faste). Ord: Spionen bruger ikke ord under tre bogstaver ("is", "fe"); de står inde i for mange andre ord (isvaffel), til at stikordene kan holdes fri af dem. Mad har 17 ord, Eventyr 15. Test: speaker-testen kører uden rigtige ventetider (37 s til 4 s for hele filen); skift-testen venter på det nye spils lobby i stedet for at gætte på tiden. deploy/bots.py --game spion: tilfældige stikord (prøver et andet, hvis det afvises), tilfældige stemmer og gæt; værten forkorter snakken. En stemme, der kommer et øjeblik efter, at fasen sluttede, er ikke en fejl. Co-Authored-By: Claude Opus 5.5 --- deploy/bots.py | 77 ++++++++++++++++++++++++++++++++--- party/spion.py | 9 +++- party/static/party/spion.js | 9 +++- party/tests/test_spion.py | 53 +++++++++++++++++++++++- party/tests/test_themes.py | 3 +- party/themes.py | 3 +- spionen/engine.py | 8 +++- spionen/narrator/generic.json | 15 +++++-- spionen/narrator/lines.json | 19 ++++++++- 9 files changed, 175 insertions(+), 21 deletions(-) diff --git a/deploy/bots.py b/deploy/bots.py index 2b284ae..a3f059e 100644 --- a/deploy/bots.py +++ b/deploy/bots.py @@ -11,6 +11,10 @@ faser blev nået og slutstillingen blev vist. Tegn og Gæt (mindst 3 spillere): python deploy/bots.py --base http://127.0.0.1:8000 --game tegn --players 5 Bots tegner tilfældige streger og gætter tilfældige ord. + +Spionen (mindst 4 spillere): + python deploy/bots.py --base http://127.0.0.1:8000 --game spion --players 10 +Bots giver tilfældige stikord, stemmer tilfældigt, og en anklaget spion gætter tilfældigt. Værten forkorter snakken. """ import argparse @@ -60,6 +64,8 @@ async def host(ws_base, code, seen: list, done: asyncio.Event): st = msg["state"] if not seen or seen[-1] != st["phase"]: seen.append(st["phase"]) + if st.get("game") == "spion" and st["phase"] == "talk": # værten forkorter snakken (bots snakker ikke) + await ws.send(json.dumps({"action": "skip"})) extra = st.get("question", {}).get("prompt", "") if st["phase"] == "lie" else "" print(f"[vært] {st['phase']:8} {extra}") if st["phase"] == "final": @@ -74,7 +80,8 @@ async def host(ws_base, code, seen: list, done: asyncio.Event): async def player(ws_base, code, name, n_players, done: asyncio.Event, rng: random.Random, stop_at_final=False, game="fof"): try: - await (_tegn_player if game == "tegn" else _player)(ws_base, code, name, n_players, done, rng, stop_at_final) + play = {"tegn": _tegn_player, "spion": _spion_player}.get(game, _player) + await play(ws_base, code, name, n_players, done, rng, stop_at_final) except websockets.ConnectionClosed: print(f"[{name}] forbindelsen blev lukket (findes rummet?)") done.set() @@ -211,6 +218,64 @@ async def _tegn_player(ws_base, code, name, n_players, done, rng, stop_at_final) await ws.send(json.dumps({"action": "react", "emoji": rng.choice(st.get("reactions") or ["😂"])})) +SPION_CLUES = ["rund", "lille", "stor", "blød", "hård", "rød", "grøn", "kold", "varm", "hurtig", "langsom", "farlig", + "sjov", "gammel", "ny", "tung", "let", "lang", "kort", "våd", "tør", "glat", "skarp", "larm", "stille", + "dyr", "billig", "udenfor", "indenfor", "sommer", "vinter", "morgen", "aften", "hjem", "skole"] + + +async def _spion_player(ws_base, code, name, n_players, done, rng, stop_at_final): + async with websockets.connect(f"{ws_base}/ws/play/{code}/") as ws: + await ws.send(json.dumps({"action": "join", "name": name})) + acted, state = set(), None + while not done.is_set(): + try: + raw = await asyncio.wait_for(ws.recv(), timeout=1) + except asyncio.TimeoutError: + continue + msg = json.loads(raw) + if msg["type"] == "error": + if msg["code"] in ("clue_is_word", "clue_is_theme", "clue_taken", "clue_invalid") and state: + # afvist stikord: prøv et andet (det er en del af spillet, ikke en fejl) + await ws.send(json.dumps({"action": "submit_clue", "text": rng.choice(SPION_CLUES)})) + continue + if msg["code"] == "wrong_phase": + continue # stemte et øjeblik efter, at alle andre havde stemt: helt normalt + print(f"[{name}] fejl: {msg['code']} {msg['message']}") + if msg["code"] == "unknown_room": + done.set() + return + continue + if msg["type"] != "state": + continue + state = st = msg["state"] + me = st["me"] + if st["phase"] == "final" and stop_at_final: + done.set() + return + key = (st["phase"], (st.get("round") or {}).get("number")) + pause = lambda: asyncio.sleep(rng.uniform(0.05, 0.4)) # noqa: E731 + if st["phase"] == "lobby" and me["vip"] and len(st["players"]) >= n_players and key not in acted: + acted.add(key) + await pause() + await ws.send(json.dumps({"action": "start"})) + elif st["phase"] == "clue" and me.get("card") and not me.get("clue") and key not in acted: + acted.add(key) + await pause() + await ws.send(json.dumps({"action": "submit_clue", "text": rng.choice(SPION_CLUES)})) + elif st["phase"] == "vote" and not me.get("vote") and key not in acted: + options = [p["id"] for p in st["players"] if p["in_round"] and p["id"] != me["id"]] + if options: + acted.add(key) + await pause() + await ws.send(json.dumps({"action": "vote", "player_id": rng.choice(options)})) + elif st["phase"] == "guess" and me.get("can_guess") and key not in acted: + acted.add(key) + await pause() + await ws.send(json.dumps({"action": "spy_guess", "text": rng.choice(SPION_CLUES)})) + if st["phase"] in ("talk", "result") and rng.random() < 0.2: + await ws.send(json.dumps({"action": "react", "emoji": rng.choice(st.get("reactions") or ["😂"])})) + + async def main(): ap = argparse.ArgumentParser() ap.add_argument("--base", default="http://127.0.0.1:8000") @@ -219,7 +284,8 @@ async def main(): ap.add_argument("--seed", type=int, default=None) ap.add_argument("--pack", default="alle", help="spørgsmålspakke (alle, familie, nørd, arbejde, fest)") ap.add_argument("--code", help="join et eksisterende rum i stedet for at oprette et (du er selv vært/VIP)") - ap.add_argument("--game", default="fof", choices=["fof", "tegn"], help="spillet (fof = Fup og Fakta, tegn = Tegn og Gæt)") + ap.add_argument("--game", default="fof", choices=["fof", "tegn", "spion"], + help="spillet (fof = Fup og Fakta, tegn = Tegn og Gæt, spion = Spionen)") args = ap.parse_args() rng = random.Random(args.seed) if args.code: @@ -236,7 +302,7 @@ async def main(): await asyncio.sleep(0.1) # Publikum joiner, når spillet er i gang (i lobbyen ville de blive spillere). for _ in range(200): - if "category" in seen or "theme" in seen: + if "category" in seen or "theme" in seen or "clue" in seen: break await asyncio.sleep(0.1) for i in range(args.audience): @@ -248,8 +314,9 @@ async def main(): return 1 for t in tasks: t.cancel() - needed = ({"lobby", "rules", "theme", "word", "draw", "guess", "reveal", "final"} if args.game == "tegn" else - {"lobby", "rules", "category", "lie", "present", "guess", "reveal", "scores", "final"}) + needed = {"tegn": {"lobby", "rules", "theme", "word", "draw", "guess", "reveal", "final"}, + "spion": {"lobby", "rules", "clue", "clues", "talk", "vote", "verdict", "result", "final"}, + }.get(args.game, {"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/party/spion.py b/party/spion.py index e208f40..d7b63ea 100644 --- a/party/spion.py +++ b/party/spion.py @@ -28,7 +28,7 @@ HURRY_BEFORE = 10 # Replikker uden navne og uden ordet: dem kan tts_warm lave på forhånd (resten har en generisk afløser). FIXED_LINES = ("lobby_welcome", "round_first", "round_last", "clue_hurry", "clue_all_done", "clues_intro", "clues_missing", "talk_start", "vote_start", "vote_all_done", "verdict_tie", "verdict_none", - "result_guessed", "result_missed", "result_timeout", "result_free", "play_again") + "result_guessed", "result_missed", "result_timeout", "result_free", "result_left", "result_half", "play_again") AWARD_LINES = {"liar": "award_liar", "sharp": "award_sharp", "suspected": "award_suspected"} @@ -118,12 +118,17 @@ class SpionRoom(rooms.BaseRoom): out = [] if ev["accused_innocent"]: out.append(line("result_innocent", navn=self._accused_name())) + elif ev["spies_left"]: + out.append(line("result_left")) + elif ev["caught"] and len(ev["spies"]) > 1 and not ev["guessed"]: + out.append(line("result_half")) # den ene fanget, den anden slap elif ev["caught"]: g = self.game out.append(line("result_guessed" if ev["guessed"] else "result_missed" if g.guess else "result_timeout")) else: out.append(line("result_free")) - out.append(line("result_reveal", spioner=join_names(ev["spies"]), ord=ev["word"])) + two = len(ev["spies"]) > 1 + out.append(line("result_reveal_two" if two else "result_reveal", spioner=join_names(ev["spies"]), ord=ev["word"])) return out if t == "final": out = [line("winner", vinder=ev["winner"], point=ev["points"])] diff --git a/party/static/party/spion.js b/party/static/party/spion.js index 5701ded..b0c1222 100644 --- a/party/static/party/spion.js +++ b/party/static/party/spion.js @@ -194,12 +194,17 @@ }; host.result = function (state) { var players = WPP.playerMap(state), r = state.result || {}; - var spies = (r.spies || []).map(function (id) { return chipOf(players, id); }).join(' '); + var spies = (r.spies || []).map(function (id, i) { /* en spion, der er gået, står kun med navn */ + return players[id] ? chipOf(players, id) : WPP.chip({ name: (r.spy_names || [])[i] || '?', color: '#8d99ae' }); + }).join(' '); var g = r.guess ? '

Spionens gæt: ' + esc(r.guess.text) + ' ' + (r.guess.correct ? '✓' : '✗') + '

' : ''; var deltas = Object.keys(r.deltas || {}).filter(function (id) { return r.deltas[id] > 0; }).map(function (id) { return '
  • ' + chipOf(players, id) + '+' + WPP.fmt(r.deltas[id]) + '
  • '; }).join(''); - return '

    ' + (r.spy_won ? 'Spionen vandt runden!' : 'Spionen er fanget!') + '

    ' + + var two = (r.spies || []).length > 1; + var head = two ? (r.caught && !r.spy_won ? 'En spion er fanget, den anden slap!' : r.caught ? 'Spionen gættede ordet!' : 'Spionerne slap væk!') + : r.spy_won ? 'Spionen vandt runden!' : 'Spionen er fanget!'; + return '

    ' + head + '

    ' + '

    ' + ((r.spies || []).length > 1 ? 'Spionerne var' : 'Spionen var') + '

    ' + spies + '
    ' + '

    Ordet var

    ' + esc(r.word || '') + '
    ' + g + '
    ' + (deltas ? '' : ''); diff --git a/party/tests/test_spion.py b/party/tests/test_spion.py index 764d5cd..728af62 100644 --- a/party/tests/test_spion.py +++ b/party/tests/test_spion.py @@ -140,7 +140,10 @@ class SpionWebSocketTests(TransactionTestCase): self.assertIsInstance(room, SpionRoom) self.assertEqual(room.game.rounds, 3) for p in phones: - await p.pump(0.1) + for _ in range(30): # vent på det nye spils lobby (højst 3 s) + await p.pump(0.1) + if p.state and p.state["game"] == "spion": + break self.assertEqual((p.state["game"], p.state["phase"]), ("spion", "lobby")) p.id = p.state["me"]["id"] # nyt id i det nye spil, samme forbindelse host = await self.host(code) @@ -192,7 +195,8 @@ class SpionSpeakerTests(SimpleTestCase): if action: action() room.handle_events() - await asyncio.sleep(0.02) + for _ in range(5): # lad stemme-opgaverne køre (ingen rigtige ventetider) + await asyncio.sleep(0) await step(room.host_opened) ps = [g.join(n) for n in names] @@ -231,3 +235,48 @@ class SpionSpeakerTests(SimpleTestCase): warm = set(warm_texts()) | set(warm_generic()) plain = [t for t, _, w in said if not any(n.lower() in t.lower() for n in names) and not (w and w in t.lower())] self.assertEqual(sorted({t for t in plain if t not in warm}), []) + + +class SpionResultLineTests(SimpleTestCase): + def room(self, n): + g = Game("TEST", spy_themes(), rng=random.Random(5), rounds=3) + g.show_rules = False + for name in ["Asger", "Sus", "Bo", "Mette", "Kasper", "Line", "Jens", "Ida"][:n]: + g.join(name) + g.start(None, 0) + return SpionRoom("TEST", g, "agw"), g + + def result_lines(self, room, g): + while g.phase != "result": + g.skip(10) + ev = next(e for e in g.drain_events() if e["type"] == "result") + return [str(t) for t in room.lines_for(ev)], ev + + def test_a_spy_who_left_is_still_named(self): + room, g = self.room(5) + name = g.players[g.spies[0]].name + g.kick(g.spies[0], 1) + lines, ev = self.result_lines(room, g) + self.assertTrue(ev["spies_left"]) + self.assertIn(lines[0], room.narrator.variants("result_left")) + self.assertIn(name, lines[1]) + self.assertEqual(g.result["spy_names"], [name]) + + def test_two_spies_in_plural(self): + room, g = self.room(8) + lines, ev = self.result_lines(room, g) + self.assertEqual(len(ev["spies"]), 2) + self.assertIn("spioner", lines[-1].lower()) + self.assertTrue(all(n in lines[-1] for n in ev["spies"])) + + def test_one_of_two_spies_caught_is_a_half_win(self): + room, g = self.room(8) + while g.phase != "vote": + g.skip(10) + s1 = g.spies[0] + for pid in g.order: + g.vote(pid, s1 if pid != s1 else g.spies[1], 3) + lines, ev = self.result_lines(room, g) + self.assertTrue(ev["caught"] and not ev["guessed"]) + self.assertIn(lines[0], room.narrator.variants("result_half")) + self.assertIn("spioner", lines[-1].lower()) diff --git a/party/tests/test_themes.py b/party/tests/test_themes.py index fba3376..cb3a660 100644 --- a/party/tests/test_themes.py +++ b/party/tests/test_themes.py @@ -24,4 +24,5 @@ class ThemeBankTests(SimpleTestCase): def test_skip_list(self): self.assertNotIn("si", spy_words({"words": ["si", "ovn"], "spion_skip": ["si"]})) self.assertEqual(spy_words({"words": ["sort hul", "måne"]}), ["måne"]) - self.assertEqual(spy_words({"words": ["x"], "spion": False}), []) + self.assertEqual(spy_words({"words": ["ovn"], "spion": False}), []) + self.assertEqual(spy_words({"words": ["is", "fe", "ovn"]}), ["ovn"]) # under tre bogstaver diff --git a/party/themes.py b/party/themes.py index c303e45..0fa8a3e 100644 --- a/party/themes.py +++ b/party/themes.py @@ -20,7 +20,8 @@ def spy_words(theme: dict) -> list[str]: if theme.get("spion") is False: return [] skip = set(theme.get("spion_skip", [])) - return [w for w in theme.get("words", []) if " " not in w and w not in skip] + # ord under tre bogstaver ("is", "fe") står inde i for mange andre ord til, at stikordene kan holdes fri af dem + return [w for w in theme.get("words", []) if " " not in w and len(w) >= 3 and w not in skip] def spy_themes() -> list[dict]: diff --git a/spionen/engine.py b/spionen/engine.py index db7841d..a79fb2a 100644 --- a/spionen/engine.py +++ b/spionen/engine.py @@ -171,6 +171,7 @@ class Game: self.round = 0 self.order: list[str] = [] # spillerne i runden (dem der var med, da den startede) self.spies: list[str] = [] + self.spy_names: dict[str, str] = {} self.last_spies: set[str] = set() self.used_words: set[str] = set() self.used_themes: list[str] = [] @@ -354,6 +355,7 @@ class Game: self.order = list(self.players) self._pick_theme_and_word() self.spies = self._pick_spies() + self.spy_names = {pid: self.players[pid].name for pid in self.spies} # også hvis en spion går undervejs self.last_spies = set(self.spies) self.clues, self.clue_order, self.clues_started_at = {}, [], None self.votes, self.audience_votes = {}, {} @@ -469,11 +471,13 @@ class Game: p.score += d deltas[pid] = d counts = self._tally(self.votes) - self.result = {"spies": list(self.spies), "word": self.word, "caught": caught, "guess": self.guess, + self.result = {"spies": list(self.spies), "spy_names": [self.spy_names.get(s, "?") for s in self.spies], + "word": self.word, "caught": caught, "guess": self.guess, "spy_won": caught is None or guessed, "deltas": deltas} self.phase = "result" self.deadline = now + RESULT_SECONDS - self._emit("result", spies=[self._name(s) for s in self.spies if s in self.players], word=self.word, + self._emit("result", spies=[self.spy_names.get(s, "") for s in self.spies], word=self.word, + spies_left=not any(s in self.players for s in self.spies), caught=bool(caught), guessed=guessed, accused_innocent=bool(self.accused and not caught), tie=self.accused is None and bool(counts), last=self.round >= self.rounds) self._changed() diff --git a/spionen/narrator/generic.json b/spionen/narrator/generic.json index 332e4a9..e61ceb5 100644 --- a/spionen/narrator/generic.json +++ b/spionen/narrator/generic.json @@ -34,9 +34,16 @@ "Masken falder. Se selv, hvem det var.", "Nu kan alle se, hvem spionen var, og hvad ordet var." ], + "result_reveal_two": [ + "Begge spioner og ordet står nu på skærmen.", + "Her er sandheden om de to spioner. Kig på skærmen.", + "[dramatic] Begge masker falder. Se selv, hvem det var.", + "Nu kan alle se, hvem de to spioner var, og hvad ordet var.", + "Afsløringen er på skærmen. Fik I fat i dem begge?" + ], "winner": [ "Vi har en vinder! Mission fuldført.", - "[excited] Tillykke til vinderen! Medaljen sendes diskret.", + "[excited] Tillykke til vinderen! Ordenen er på vej med posten.", "Spillet er slut. Vinderen må gerne træde frem fra skyggerne nu.", "Tillykke! Flest point og fuld tillid. Næsten.", "Sejren er afgjort. Hemmeligholdelsen ophæves." @@ -49,7 +56,7 @@ "Bedste løgner. Pokerfjæset var perfekt." ], "award_sharp": [ - "Skarpeste øje! Ingen spion slap forbi.", + "Skarpeste øje! Flest rigtige stemmer i aften.", "Prisen for skarpeste øje. Den her agent ser alt.", "[impressed] Skarpeste øje. Spionerne havde ikke en chance.", "Ingen snyder den her. Skarpeste øje!", @@ -58,8 +65,8 @@ "award_suspected": [ "Mest mistænkt i aften. Retfærdigt eller ej.", "[laughs] Prisen for mest mistænkt. Alle pegede igen og igen.", - "Mest mistænkt. Det siger noget. Om noget.", + "Mest mistænkt. Måske er det ansigtet.", "Prisen for flest stemmer går til en med et meget skyldigt ansigt.", - "Mest mistænkt. Måske er det ansigtet. Måske er det sandheden." + "Mest mistænkt. Stemmerne pegede samme vej, igen og igen." ] } diff --git a/spionen/narrator/lines.json b/spionen/narrator/lines.json index 273c47d..204efba 100644 --- a/spionen/narrator/lines.json +++ b/spionen/narrator/lines.json @@ -11,7 +11,7 @@ "Velkommen, {navn}. Du ser uskyldig ud. Det er mistænkeligt.", "{navn} er med. Agent eller spion? Det finder vi ud af.", "Hej {navn}. Jeg har allerede åbnet en sag på dig.", - "{navn} er landet. Hold øje med den.", + "{navn} er landet. Hold øje med vedkommende.", "[suspicious] Velkommen, {navn}. Hvor var du i går klokken otte?", "Plads til {navn}. Solbrillerne må gerne blive på." ], @@ -141,11 +141,26 @@ "[mischievously] Spionen gik under radaren.", "Spionen klarede den. Agenterne må tage sig sammen." ], + "result_left": [ + "Spionen forlod missionen. Ingen point for det.", + "[sighs] Spionen er gået. Så må vi tage runden uden.", + "Spionen smuttede, før vi nåede at afsløre noget." + ], + "result_half": [ + "Den ene spion er fanget, men den anden slap væk.", + "[sighs] Halv sejr. Én spion er afsløret, den anden gik fri.", + "I fik fat i den ene. Den anden smiler stadig." + ], "result_reveal": [ "Spionen var {spioner}, og ordet var {ord}.", "Ordet var {ord}. Og spionen var {spioner}.", "Afsløring: {spioner} var spion, og ordet var {ord}." ], + "result_reveal_two": [ + "Spionerne var {spioner}, og ordet var {ord}.", + "Ordet var {ord}. Og spionerne var {spioner}.", + "Afsløring: {spioner} var spioner, og ordet var {ord}." + ], "winner": [ "Og vinderen er {vinder} med {point} point!", "[excited] {vinder} vinder med {point} point. Mission fuldført.", @@ -168,7 +183,7 @@ "Mest mistænkt i aften: {navn}.", "[laughs] Alle pegede på {navn}. Igen og igen.", "Prisen for mest mistænkt går til {navn}. Retfærdigt eller ej.", - "{navn} fik flest stemmer i aften. Det siger noget. Om noget." + "{navn} fik flest stemmer i aften. Måske er det ansigtet." ], "play_again": [ "En gang til! Nye ord, nye spioner.",