Fra QA: sammensatte ord med ordet i (pingvinunge, hundehus, pizzabager,
togskinne) blev godtaget, og så stod ordet på storskærmen for spionen.
Nu afvises et stikord fra en, der kender ordet, hvis ordet står inde i det
(ord på tre bogstaver eller flere; "is" ville ellers ramme alt for meget).
Prisen er, at et andet ord, der tilfældigvis indeholder ordet, også
afvises ("hvalp" til "hval"); en lækage er værre end en afvisning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
360 lines
15 KiB
Python
360 lines
15 KiB
Python
"""Spionens motor: roller, stikord, afstemning, spionens gæt, point og at hemmeligheden holder."""
|
|
|
|
import json
|
|
import random
|
|
from unittest import TestCase
|
|
|
|
from fupogfakta.engine import GameError
|
|
from spionen import engine
|
|
from spionen.engine import Game, same_word
|
|
|
|
THEMES = [
|
|
{"id": "dyr", "name": "Dyr", "icon": "🐘", "words": ["kat", "pingvin", "kænguru", "isbjørn", "pindsvin"]},
|
|
{"id": "koekken", "name": "Ting i køkkenet", "icon": "🍳", "words": ["pande", "gaffel", "ovn"]},
|
|
]
|
|
NAMES = ["Asger", "Sus", "Bo", "Mette", "Kasper", "Line", "Jens", "Ida", "Ole", "Pia"]
|
|
|
|
|
|
def game(n=4, seed=1, rounds=4, themes=THEMES):
|
|
g = Game("TEST", themes, rng=random.Random(seed), rounds=rounds)
|
|
g.show_rules = False
|
|
ps = [g.join(NAMES[i]) for i in range(n)]
|
|
g.start(None, 0)
|
|
return g, ps
|
|
|
|
|
|
def play_clues(g, now=1):
|
|
"""Alle skriver et stikord, der ikke røber ordet (spionerne et tilfældigt)."""
|
|
for i, pid in enumerate(g.order):
|
|
g.submit_clue(pid, f"stikord{chr(97 + i)}", now)
|
|
|
|
|
|
def to_vote(g, now=2):
|
|
if g.phase == "clue":
|
|
play_clues(g, now)
|
|
while g.phase in ("clues", "talk"):
|
|
g.skip(now)
|
|
assert g.phase == "vote"
|
|
|
|
|
|
class WordTests(TestCase):
|
|
def test_inflections_case_spaces_and_hyphens(self):
|
|
for form in ("KAT", "katten", "katte", "kattene", "kattens", "kat-ten", " Kat "):
|
|
self.assertTrue(same_word(form, "kat"), form)
|
|
for form in ("panden", "pander", "panderne", "pandens"):
|
|
self.assertTrue(same_word(form, "pande"), form)
|
|
self.assertTrue(same_word("is bjørn", "isbjørn"))
|
|
self.assertTrue(same_word("isbjørnen", "isbjørn"))
|
|
self.assertTrue(same_word("kænguruen", "kænguru"))
|
|
self.assertFalse(same_word("katteskål", "kat")) # sammensatte ord er nye ord
|
|
self.assertFalse(same_word("hund", "kat"))
|
|
|
|
def test_small_typos_scaled_by_length(self):
|
|
self.assertTrue(same_word("pingvn", "pingvin")) # en fejl i et ord på syv
|
|
self.assertTrue(same_word("pindsvn", "pindsvin"))
|
|
self.assertTrue(same_word("pndsvn", "pindsvin")) # to fejl i et ord på otte
|
|
self.assertTrue(same_word("kroodile", "krokodille"))
|
|
self.assertFalse(same_word("pinsvni", "pingvin")) # tre fejl
|
|
self.assertFalse(same_word("kas", "kat")) # ingen stavefejl i korte ord
|
|
self.assertFalse(same_word("katten", "kat", typos=False) is False) # bøjning gælder uden stavefejl
|
|
self.assertFalse(same_word("pingvn", "pingvin", typos=False))
|
|
|
|
|
|
class RoleTests(TestCase):
|
|
def test_one_spy_and_two_from_eight(self):
|
|
for n, spies in ((4, 1), (7, 1), (8, 2), (10, 2)):
|
|
g, _ = game(n)
|
|
self.assertEqual(len(g.spies), spies, n)
|
|
self.assertEqual(len(set(g.spies)), spies)
|
|
|
|
def test_nobody_is_spy_twice_in_a_row(self):
|
|
for seed in range(30):
|
|
g, _ = game(4, seed=seed, rounds=5)
|
|
seen = []
|
|
while g.phase != "final":
|
|
if g.phase == "clue":
|
|
seen.append(list(g.spies))
|
|
g.skip(10)
|
|
for a, b in zip(seen, seen[1:]):
|
|
self.assertFalse(set(a) & set(b), (seed, seen))
|
|
|
|
def test_words_and_themes_vary(self):
|
|
g, _ = game(4, rounds=5)
|
|
words, themes = [], []
|
|
while g.phase != "final":
|
|
if g.phase == "clue":
|
|
words.append(g.word)
|
|
themes.append(g.theme["id"])
|
|
g.skip(10)
|
|
self.assertEqual(len(words), len(set(words)))
|
|
self.assertTrue(all(a != b for a, b in zip(themes, themes[1:]))) # to temaer: skiftevis
|
|
|
|
|
|
class ClueTests(TestCase):
|
|
def setUp(self):
|
|
self.g, self.ps = game(4)
|
|
self.g.word, self.g.theme = "kat", THEMES[0]
|
|
self.agent = next(pid for pid in self.g.order if pid not in self.g.spies)
|
|
self.spy = self.g.spies[0]
|
|
|
|
def bad(self, pid, text, code):
|
|
with self.assertRaises(GameError) as e:
|
|
self.g.submit_clue(pid, text, 1)
|
|
self.assertEqual(e.exception.code, code)
|
|
|
|
def test_word_and_inflections_are_refused_but_near_words_are_fine(self):
|
|
for text in ("kat", "Katten", "kattene", "kattekilling", "missekat"):
|
|
self.bad(self.agent, text, "clue_is_word")
|
|
self.g.word = "hund"
|
|
self.bad(self.agent, "hundehus", "clue_is_word") # sammensat: ordet stod på skærmen
|
|
self.g.word = "is"
|
|
self.g.submit_clue(self.agent, "vis", 1) # korte ord: kun ordet selv og bøjninger
|
|
self.g.word = "hval"
|
|
self.bad(self.agent, "hvalp", "clue_is_word") # ordet står i det; prisen for at fange "hvalfangst"
|
|
self.g.word = "slot"
|
|
self.g.submit_clue(self.agent, "plot", 1)
|
|
|
|
def test_theme_spaces_length_and_duplicates(self):
|
|
self.bad(self.agent, "dyr", "clue_is_theme")
|
|
self.bad(self.agent, "dyrene", "clue_is_theme")
|
|
self.bad(self.agent, "to ord", "clue_invalid")
|
|
self.bad(self.agent, "x" * 21, "clue_invalid")
|
|
self.bad(self.agent, "", "clue_invalid")
|
|
self.g.submit_clue(self.agent, "Mjav", 1)
|
|
other = next(pid for pid in self.g.order if pid not in (self.agent, self.spy))
|
|
self.bad(other, "mjav", "clue_taken")
|
|
self.g.submit_clue(self.agent, "spinder", 1) # sit eget må man skifte
|
|
self.g.submit_clue(other, "mjav", 1)
|
|
|
|
def test_the_spy_is_never_told_the_word_or_the_others_clues(self):
|
|
self.g.submit_clue(self.spy, "kat", 1) # spionen ramte ordet: ingen afvisning, ingen lækage
|
|
self.assertEqual(self.g.clues[self.spy], "kat")
|
|
self.g.submit_clue(self.agent, "hav", 1)
|
|
self.g.submit_clue(self.spy, "hav", 1) # en andens stikord: heller ingen afvisning
|
|
self.assertEqual(self.g.clues[self.spy], "hav")
|
|
self.bad(self.spy, "dyr", "clue_is_theme")
|
|
|
|
def test_audience_and_wrong_phase(self):
|
|
u = self.g.join("Sene") # kom efter start: publikum
|
|
self.assertEqual(u.role, "audience")
|
|
self.bad(u.id, "mjav", "not_in_round")
|
|
play_clues(self.g)
|
|
self.bad(self.agent, "mjav", "wrong_phase")
|
|
|
|
|
|
class VoteAndPointsTests(TestCase):
|
|
def round_to(self, n=4, seed=1):
|
|
g, ps = game(n, seed=seed)
|
|
to_vote(g)
|
|
return g, ps
|
|
|
|
def test_no_self_vote_and_audience_counts_separately(self):
|
|
g, ps = self.round_to()
|
|
a = g.order[0]
|
|
with self.assertRaises(GameError):
|
|
g.vote(a, a, 3)
|
|
u = g.join("Sene")
|
|
g.vote(u.id, g.spies[0], 3)
|
|
self.assertEqual(g.audience_votes, {u.id: g.spies[0]})
|
|
self.assertNotIn(u.id, g.votes)
|
|
|
|
def test_majority_catches_the_spy_who_then_guesses(self):
|
|
g, ps = self.round_to()
|
|
spy = g.spies[0]
|
|
agents = [pid for pid in g.order if pid != spy]
|
|
for pid in agents:
|
|
g.vote(pid, spy, 3)
|
|
g.vote(spy, agents[0], 3) # alle har stemt: videre med det samme
|
|
self.assertEqual((g.phase, g.accused), ("verdict", spy))
|
|
g.skip(4)
|
|
self.assertEqual(g.phase, "guess")
|
|
with self.assertRaises(GameError):
|
|
g.spy_guess(agents[0], g.word, 5) # kun spionen gætter
|
|
g.spy_guess(spy, g.word.upper() + "en", 5)
|
|
self.assertEqual(g.phase, "result")
|
|
self.assertTrue(g.result["guess"]["correct"] and g.result["spy_won"])
|
|
self.assertEqual(g.players[spy].score, engine.POINTS_SPY_GUESS)
|
|
self.assertTrue(all(g.players[pid].score == engine.POINTS_VOTE for pid in agents))
|
|
|
|
def test_caught_spy_who_misses_gets_nothing(self):
|
|
g, ps = self.round_to()
|
|
spy = g.spies[0]
|
|
for pid in g.order:
|
|
g.vote(pid, spy if pid != spy else next(x for x in g.order if x != spy), 3)
|
|
g.skip(4)
|
|
g.spy_guess(spy, "noget helt andet", 5)
|
|
self.assertFalse(g.result["spy_won"])
|
|
self.assertEqual(g.players[spy].score, 0)
|
|
|
|
def test_tie_lets_the_spy_go(self):
|
|
g, ps = self.round_to()
|
|
spy = g.spies[0]
|
|
agents = [pid for pid in g.order if pid != spy]
|
|
g.vote(agents[0], spy, 3)
|
|
g.vote(agents[1], agents[2], 3) # 1-1: lighed
|
|
g.skip(3)
|
|
self.assertIsNone(g.accused)
|
|
g.skip(4) # ingen anklaget: intet gæt
|
|
self.assertEqual(g.phase, "result")
|
|
self.assertEqual(g.players[spy].score, engine.POINTS_SPY_FREE)
|
|
self.assertEqual(g.players[agents[0]].score, engine.POINTS_VOTE) # stemte stadig rigtigt
|
|
self.assertEqual(g.players[agents[1]].score, 0)
|
|
|
|
def test_no_votes_at_all(self):
|
|
g, ps = self.round_to()
|
|
g.skip(3)
|
|
g.skip(4)
|
|
self.assertEqual((g.phase, g.accused), ("result", None))
|
|
self.assertEqual(g.players[g.spies[0]].score, engine.POINTS_SPY_FREE)
|
|
|
|
def test_innocent_accused(self):
|
|
g, ps = self.round_to()
|
|
spy = g.spies[0]
|
|
agents = [pid for pid in g.order if pid != spy]
|
|
for pid in g.order:
|
|
g.vote(pid, agents[0] if pid != agents[0] else agents[1], 3)
|
|
g.skip(4)
|
|
self.assertEqual(g.phase, "result") # ikke spion: intet gæt
|
|
self.assertEqual(g.players[spy].score, engine.POINTS_SPY_FREE)
|
|
self.assertEqual(g.result["caught"], None)
|
|
|
|
def test_two_spies(self):
|
|
"""Den anklagede spion er fanget og gætter; den anden slap. En stemme på en af dem er rigtig."""
|
|
g, ps = self.round_to(8)
|
|
s1, s2 = g.spies
|
|
agents = [pid for pid in g.order if pid not in g.spies]
|
|
for i, pid in enumerate(agents):
|
|
g.vote(pid, s1 if i < 4 else s2, 3)
|
|
g.vote(s1, agents[0], 3)
|
|
g.vote(s2, agents[0], 3)
|
|
self.assertEqual(g.accused, s1)
|
|
g.skip(4)
|
|
self.assertEqual(g.phase, "guess")
|
|
g.skip(5) # tiden løb ud
|
|
self.assertEqual(g.players[s1].score, 0)
|
|
self.assertEqual(g.players[s2].score, engine.POINTS_SPY_FREE)
|
|
self.assertTrue(all(g.players[pid].score == engine.POINTS_VOTE for pid in agents))
|
|
|
|
def test_whole_game_ends_with_podium_and_awards(self):
|
|
g, ps = game(5, rounds=3)
|
|
rounds = 0
|
|
while g.phase != "final":
|
|
if g.phase == "clue":
|
|
rounds += 1
|
|
if g.phase == "vote":
|
|
spy = g.spies[0]
|
|
for pid in g.order:
|
|
g.vote(pid, spy if pid != spy else next(x for x in g.order if x != spy), 3)
|
|
continue
|
|
g.skip(10)
|
|
self.assertEqual(rounds, 3)
|
|
fin = g.final_result
|
|
self.assertEqual(len(fin["ranking"]), 5)
|
|
self.assertIn("sharp", [a["kind"] for a in fin["awards"]])
|
|
self.assertIn("suspected", [a["kind"] for a in fin["awards"]])
|
|
|
|
|
|
class NeverHangTests(TestCase):
|
|
def test_missing_clues_and_votes_just_time_out(self):
|
|
g, _ = game(4)
|
|
for phase in ("clue", "clues", "talk", "vote", "verdict"):
|
|
self.assertEqual(g.phase, phase)
|
|
self.assertTrue(g.tick(g.deadline))
|
|
self.assertEqual(g.phase, "result")
|
|
self.assertIn(None, [c["text"] for c in g._clue_items()]) # ingen stikord: står tomt
|
|
|
|
def test_disconnected_players_do_not_hold_the_round(self):
|
|
g2, _ = game(4)
|
|
g2.set_connected(g2.order[3], False, 1)
|
|
for pid in g2.order[:3]:
|
|
g2.submit_clue(pid, f"ord{pid}", 1)
|
|
self.assertEqual(g2.phase, "clues") # de forbundne er færdige
|
|
|
|
def test_accused_spy_leaves_during_guess(self):
|
|
g, _ = game(4)
|
|
to_vote(g)
|
|
spy = g.spies[0]
|
|
for pid in g.order:
|
|
g.vote(pid, spy if pid != spy else next(x for x in g.order if x != spy), 3)
|
|
g.skip(4)
|
|
self.assertEqual(g.phase, "guess")
|
|
g.kick(spy, 5)
|
|
self.assertEqual(g.phase, "result")
|
|
|
|
def test_too_few_players_ends_the_game(self):
|
|
g, _ = game(4)
|
|
g.kick(g.order[0], 1)
|
|
while g.phase not in ("final", "lobby"):
|
|
g.skip(10)
|
|
self.assertEqual(g.phase, "final") # runden blev spillet færdig; ingen ny med tre
|
|
|
|
def test_everyone_leaves(self):
|
|
g, _ = game(4)
|
|
for pid in list(g.players):
|
|
g.kick(pid, 1)
|
|
self.assertEqual(g.phase, "lobby")
|
|
|
|
|
|
class SecrecyTests(TestCase):
|
|
"""Ordet kun til dem, der kender det; spionen ingen steder før resultatet. Hvert snapshot i hver fase læses."""
|
|
|
|
def snaps(self, g):
|
|
out = {"host": g.snapshot(5, host=True)}
|
|
for p in list(g.players.values()) + list(g.audience.values()):
|
|
out[p.id] = g.snapshot(5, viewer=p)
|
|
return out
|
|
|
|
def test_every_snapshot_in_every_phase(self):
|
|
g, ps = game(8, seed=4)
|
|
u = g.join("Sene")
|
|
checked = set()
|
|
while g.phase != "final":
|
|
snaps = self.snaps(g)
|
|
word, spies = g.word, set(g.spies)
|
|
for who, st in snaps.items():
|
|
text = json.dumps(st, ensure_ascii=False).lower()
|
|
with self.subTest(phase=g.phase, who=who):
|
|
if g.phase == "result":
|
|
self.assertIn(word, text)
|
|
continue
|
|
self.assertNotIn("spies\": [", text) # aldrig en liste over spionerne
|
|
knows = who in g.order and who not in spies
|
|
if not knows: # spionen, publikum og storskærmen
|
|
self.assertNotIn(f"\"{word}\"", text)
|
|
self.assertNotIn(word, json.dumps(st.get("me", {}), ensure_ascii=False).lower())
|
|
else:
|
|
self.assertEqual(st["me"]["card"]["word"], word)
|
|
card = (st.get("me") or {}).get("card")
|
|
if card:
|
|
self.assertEqual(card["spy"], who in spies) # kun ens egen rolle
|
|
accused_is_spy = (st.get("votes") or {}).get("accused_is_spy")
|
|
if accused_is_spy:
|
|
self.assertIn(g.phase, ("verdict", "guess"))
|
|
self.assertIn(st["votes"]["accused"], spies)
|
|
if g.phase == "verdict":
|
|
self.assertEqual(who, "host") # telefonerne først i "guess"
|
|
checked.add(g.phase)
|
|
if g.phase == "clue":
|
|
play_clues(g)
|
|
continue
|
|
if g.phase == "vote":
|
|
g.vote(u.id, g.spies[0], 3) # publikum stemmer for sjov
|
|
for pid in g.order:
|
|
g.vote(pid, g.spies[0] if pid != g.spies[0] else g.spies[1], 3)
|
|
continue
|
|
g.skip(10)
|
|
self.assertLessEqual({"clue", "clues", "talk", "vote", "verdict", "guess", "result"}, checked)
|
|
|
|
def test_clue_order_is_shuffled_and_phones_wait_for_the_tv(self):
|
|
g, ps = game(6, seed=2)
|
|
play_clues(g)
|
|
self.assertEqual(g.phase, "clues")
|
|
orders = set()
|
|
for seed in range(8):
|
|
h, _ = game(6, seed=seed)
|
|
play_clues(h)
|
|
orders.add(tuple(h.clue_order.index(pid) for pid in h.order))
|
|
self.assertGreater(len(orders), 1)
|
|
self.assertEqual(g.snapshot(5, viewer=ps[0])["clues"]["items"], []) # telefonerne kigger på TV'et
|
|
self.assertEqual(len(g.snapshot(5, host=True)["clues"]["items"]), 6)
|