Tegn og Gæt: kæden slutter altid på et gæt

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 <noreply@anthropic.com>
This commit is contained in:
agw
2026-10-20 21:59:59 +02:00
co-authored by Claude Opus 5.5
parent 7a5c37290f
commit 5de261acd7
3 changed files with 72 additions and 25 deletions
+6 -5
View File
@@ -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},
+25 -9
View File
@@ -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,
+41 -11
View File
@@ -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)