From 5de261acd7e34bde4f53dd36636470e5c35a3ba3 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Tue, 20 Oct 2026 21:59:59 +0200 Subject: [PATCH] =?UTF-8?q?Tegn=20og=20G=C3=A6t:=20k=C3=A6den=20slutter=20?= =?UTF-8?q?altid=20p=C3=A5=20et=20g=C3=A6t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ved et lige antal spillere får kæden et trin mere: i trin 1 tegner ejeren sit eget ord, og rotationen starter et trin senere. Antal trin hedder nu steps og bruges i step-eventet, snapshot og afsløringen; self.n er antal spillere. Snapshot får step.own til trin 1. Co-Authored-By: Claude Opus 5.5 --- docs/PROTOCOL.md | 11 +++---- tegnoggaet/engine.py | 34 +++++++++++++++------ tegnoggaet/tests/test_engine.py | 52 ++++++++++++++++++++++++++------- 3 files changed, 72 insertions(+), 25 deletions(-) diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 3ad873f..9f581cc 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -224,9 +224,10 @@ Oprettes med `game=tegn` på `/host/new` (se [Spillene og skift af spil](#spille Samme endpoints, samme `join`/`resume`/`ping`/`react`, og samme `joined`/`state`/`error`. Snapshot har `"game": "tegn"`. 3-10 spillere; dem der joiner efter start, bliver publikum (de kan give hjerter). -Med N spillere er der N kæder, én pr. spiller. I trin `t` (0..N-1) arbejder spiller `i` (rækkefølgen ved start) -på kæde `(i - t) mod N`: trin 0 er et ord, ulige trin tegnes, lige trin gættes. Ingen får sin egen kæde igen, og -hver kæde ender med N bidrag. Med et lige antal spillere ender kæden på en tegning; så er "sidste gæt" gættet før den. +Med N spillere er der N kæder, én pr. spiller: trin 0 er et ord, ulige trin tegnes, lige trin gættes, og kæden +slutter altid på et gæt. Ulige N: N trin; i trin `t` arbejder spiller `i` (rækkefølgen ved start) på kæde +`(i - t) mod N`. Lige N: N+1 trin; i trin 1 tegner ejeren sit eget ord (`step.own: true`), og i trin `t >= 1` +arbejder spiller `i` på kæde `(i - (t-1)) mod N`. Ingen gætter på sin egen kæde. Faser: `lobby → rules` (kun rummets første spil) `→ theme` (20 s) `→ word` (30 s) `→ draw` (75 s) / `guess` (30 s) skiftevis `→ reveal → final`. Et trin slutter, når alle forbundne spillere har afleveret, eller når tiden er gået. Så fyldes det @@ -273,11 +274,11 @@ Fejlkoder ud over Fup og Fakta: `word_invalid`, `guess_invalid`, `drawing_invali "rules": {...}, // fase rules, som Fup og Fakta "theme": {"options": [{"id": "dyr", "name": "Dyr", "icon": "🐘"}], "chosen_id": null}, // fase theme "theme": {"name": "Dyr", "icon": "🐘"}, // word/draw/guess/reveal - "step": {"index": 1, "number": 2, "of": 5, "kind": "draw"}, // word/draw/guess + "step": {"index": 1, "number": 2, "of": 5, "kind": "draw", "own": false}, // word/draw/guess; own = tegn dit eget ord "reveal": { "id": 1, "started_at": 1790000000000, // ms epoch; null = serveren laver stadig lyd - "plan": [ // tidslinjen: pr. kæde en intro (entry -1), hvert bidrag, og slutningen (entry N); + "plan": [ // tidslinjen: pr. kæde en intro (entry -1), hvert bidrag, og slutningen (entry = antal trin); // audio (speakerens replik) kun til storskærmen, da slutningen nævner sidste gæt {"chain": 0, "entry": -1, "ms": 2500, "audio": null}, {"chain": 0, "entry": 0, "ms": 3000, "audio": null}, diff --git a/tegnoggaet/engine.py b/tegnoggaet/engine.py index 21e5b08..55cb057 100644 --- a/tegnoggaet/engine.py +++ b/tegnoggaet/engine.py @@ -5,9 +5,11 @@ handlinger tager `now` (sekunder siden epoch) som parameter, rummet i `party.tegn` kalder `tick()` når en deadline udløber og sender `snapshot()` til hver skærm. -Med N spillere er der N kæder, én pr. spiller. I trin t (0..N-1) arbejder -spiller i på kæde (i - t) mod N: trin 0 er et ord, ulige trin tegnes, lige trin -gættes. Ingen får sin egen kæde igen, og hver kæde ender med N bidrag. +Med N spillere er der N kæder, én pr. spiller: trin 0 er et ord, ulige trin +tegnes, lige trin gættes, og kæden slutter altid på et gæt. Ulige N: N trin, og i +trin t arbejder spiller i på kæde (i - t) mod N. Lige N: N+1 trin; i trin 1 +tegner ejeren sit eget ord, og i trin t >= 1 arbejder spiller i på kæde +(i - (t-1)) mod N. Ingen gætter på sin egen kæde. """ from __future__ import annotations @@ -204,8 +206,19 @@ class Game: @property def n(self) -> int: + """Antal spillere i kæderne.""" return len(self.order) + @property + def steps(self) -> int: + """Antal trin. Kæden skal slutte på et gæt: ved et lige antal spillere tegner ejeren selv sit ord + i trin 1, og så bliver der et trin mere.""" + return self.n if self.n % 2 else self.n + 1 + + def own_step(self, t: int | None = None) -> bool: + """Trin 1 ved et lige antal spillere: ejeren tegner sit eget ord.""" + return (self.step if t is None else t) == 1 and self.n % 2 == 0 + @property def rules(self) -> list[dict]: return RULES @@ -219,6 +232,8 @@ class Game: if member_id not in self.order: return None t = self.step if t is None else t + if self.n % 2 == 0 and t > 0: + t -= 1 # trin 1 bliver hos ejeren, rotationen starter et trin senere return (self.order.index(member_id) - t) % self.n def _entry(self, entry_id: str) -> dict | None: @@ -371,7 +386,8 @@ class Game: self.drafts = {} self.phase = step_kind(t) self.deadline = now + {"word": WORD_SECONDS, "draw": DRAW_SECONDS, "guess": GUESS_SECONDS}[self.phase] - self._emit("step", step=self.phase, number=t + 1, of=self.n, theme=self.theme["name"]) + self._emit("step", step=self.phase, number=t + 1, of=self.steps, theme=self.theme["name"], + own=self.own_step(t)) self._changed() def task(self, member_id: str) -> dict | None: @@ -481,7 +497,7 @@ class Game: else: value = draft or EMPTY_GUESS self._put(c, pid, value, auto=True) - if self.step + 1 < self.n: + if self.step + 1 < self.steps: self._begin_step(self.step + 1, now) else: self._start_reveal(now) @@ -492,8 +508,7 @@ class Game: self.likes = {} self.survived = [] for chain in self.chains: - # Sidste gæt mod første ord. Med et lige antal spillere ender kæden på en tegning, - # og så er det gættet før den, der tæller. + # Sidste gæt mod første ord last = self._last_guess(chain) self.survived.append(last is not None and last != EMPTY_GUESS and normalize(last) == normalize(chain[0]["text"])) @@ -505,7 +520,7 @@ class Game: self.plan.append({"chain": k, "entry": i, "ms": ms, "audio": None}) self.plan.append({"chain": k, "entry": len(chain), "ms": CHAIN_END_MS, "audio": None}) self.phase = "reveal" - self.step = self.n + self.step = self.steps self.start_reveal_timeline(now) self._emit("reveal") self._changed() @@ -728,7 +743,8 @@ class Game: if stepping or self.phase == "reveal": state["theme"] = {"name": self.theme["name"], "icon": self.theme.get("icon", "")} if stepping: - state["step"] = {"index": self.step, "number": self.step + 1, "of": self.n, "kind": self.phase} + state["step"] = {"index": self.step, "number": self.step + 1, "of": self.steps, "kind": self.phase, + "own": self.own_step()} if self.phase == "reveal": state["reveal"] = { "id": self.reveal_id, diff --git a/tegnoggaet/tests/test_engine.py b/tegnoggaet/tests/test_engine.py index ba73b98..787aac9 100644 --- a/tegnoggaet/tests/test_engine.py +++ b/tegnoggaet/tests/test_engine.py @@ -54,24 +54,54 @@ class TegnTestCase(SimpleTestCase): class RotationTests(TegnTestCase): - def test_rotation_never_gives_own_chain_and_all_chains_get_n_entries(self): - for n in range(3, 11): + def test_every_chain_ends_on_a_guess(self): + """Ulige N: N trin. Lige N: N+1 trin, og i trin 1 tegner ejeren sit eget ord.""" + for n in (3, 4, 5, 6, 7, 8, 9, 10): with self.subTest(n=n): self.g = Game("TEST", THEMES, rng=random.Random(n)) ps = self.start(n) + steps = n if n % 2 else n + 1 + self.assertEqual(self.g.steps, steps) owner = {k: pid for k, pid in enumerate(self.g.order)} - for t in range(n): + own = {self.g.order.index(p.id): p.id for p in ps} + for t in range(steps): chains = [self.g.chain_for(p.id, t) for p in ps] self.assertEqual(sorted(chains), list(range(n)), "hver kæde har præcis én spiller pr. trin") + at_home = t == 0 or (t == 1 and n % 2 == 0) for p, c in zip(ps, chains): - self.assertEqual(c == self.g.order.index(p.id), t == 0, "egen kæde kun i trin 0") - self.play_all(ps) + self.assertEqual(own[c] == p.id, at_home, "egen kæde kun i trin 0 (og 1 ved lige N)") + t = 0 + while self.g.phase in ("word", "draw", "guess"): + self.assertEqual(self.g.step, t) + snap = self.g.snapshot(self.now, host=True) + self.assertEqual(snap["step"], {"index": t, "number": t + 1, "of": steps, "kind": self.g.phase, + "own": t == 1 and n % 2 == 0}) + for p in ps: + task = self.g.task(p.id) + if t == 1 and n % 2 == 0: # ejeren får sit eget ord + self.assertEqual((task["kind"], task["from"], task["text"]), ("draw", p.id, f"ord {p.name}".lower())) + elif t > 0: + self.assertNotEqual(task["from"], p.id) + self.play_step(ps) + t += 1 + self.assertEqual(t, steps) + self.assertEqual(self.g.phase, "reveal") for k, chain in enumerate(self.g.chains): - self.assertEqual(len(chain), n) - self.assertEqual(len({e["by"] for e in chain}), n, "alle bidrager én gang til hver kæde") + self.assertEqual(len(chain), steps) + self.assertEqual(chain[-1]["kind"], "guess", "kæden slutter på et gæt") self.assertEqual(chain[0]["by"], owner[k]) self.assertEqual([e["kind"] for e in chain], - ["word"] + ["drawing" if t % 2 else "guess" for t in range(1, n)]) + ["word"] + ["drawing" if t % 2 else "guess" for t in range(1, steps)]) + self.assertNotIn(owner[k], [e["by"] for e in chain if e["kind"] == "guess"], + "ingen gætter på sin egen kæde") + self.assertEqual(len({e["by"] for e in chain}), n, "alle bidrager til hver kæde") + if n % 2 == 0: + self.assertEqual(chain[1]["by"], owner[k], "ejeren tegner selv i trin 1") + self.assertEqual(len({e["by"] for e in chain[1 if n % 2 == 0 else 0:]}), n, + "ud over ejerens to første trin ser ingen kæden to gange") + for t in range(steps): + by = sorted(chain[t]["by"] for chain in self.g.chains) + self.assertEqual(by, sorted(p.id for p in ps), "hver spiller ét bidrag pr. trin") def test_kicks_before_the_chains_start_go_back_to_lobby(self): a, b, c = self.join(3) @@ -193,7 +223,7 @@ class TimeoutTests(TegnTestCase): a, b, c, d = self.start(4) self.g.kick(d.id, self.now) self.play_all([a, b, c]) - self.assertTrue(all(len(chain) == 4 for chain in self.g.chains)) + self.assertTrue(all(len(chain) == 5 for chain in self.g.chains)) self.assertTrue(all(e["auto"] for chain in self.g.chains for e in chain if e["by"] == d.id)) def test_held_phase_waits_for_the_speaker(self): @@ -249,8 +279,8 @@ class RevealTests(TegnTestCase): def test_plan_covers_every_chain_and_entry(self): self.play_game(4) plan = self.g.plan - self.assertEqual(len(plan), 4 * (4 + 2)) - self.assertEqual([b["entry"] for b in plan[:6]], [-1, 0, 1, 2, 3, 4]) + self.assertEqual(len(plan), 4 * (5 + 2)) # 4 spillere: 5 trin, så kæden slutter på et gæt + self.assertEqual([b["entry"] for b in plan[:7]], [-1, 0, 1, 2, 3, 4, 5]) snap = self.g.snapshot(self.now, host=True) self.assertNotIn("strokes", str(snap["reveal"])) # tegningerne kommer i beskeden "drawings" self.assertEqual(len(self.g.drawings()), 4 * 2)