Tegn og Gæt: spilmotor, temaer og speakerens replikker

Ny app tegnoggaet med en ren motor i samme stil som Fup og Fakta:
kæder der går bordet rundt (ord, tegn, gæt ...), timere, kladder ved
timeout, validering af tegninger som vektorstrøg, afsløringens plan,
hjerter, point og priser. Temaer i data/themes.json og speakerens
replikker i narrator/lines.json, med tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
agw
2026-10-20 17:00:01 +02:00
co-authored by Claude Opus 5.5
parent 760e094017
commit 151ec52cf0
10 changed files with 1367 additions and 0 deletions
+1
View File
@@ -71,6 +71,7 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
"channels",
"fupogfakta",
"tegnoggaet",
"party",
]
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class TegnOgGaetConfig(AppConfig):
name = "tegnoggaet"
verbose_name = "Tegn og Gæt"
+45
View File
@@ -0,0 +1,45 @@
[
{"id": "dyr", "name": "Dyr", "icon": "🐘", "words": [
"kat", "hund", "elefant", "giraf", "pingvin", "krokodille", "blæksprutte", "flodhest", "pindsvin", "edderkop",
"sommerfugl", "kænguru", "hval", "ugle", "snegl", "zebra", "papegøje", "isbjørn"]},
{"id": "film", "name": "Film og serier", "icon": "🎬", "words": [
"løvernes konge", "frost", "star wars", "harry potter", "titanic", "jurassic park", "find nemo", "shrek",
"toy story", "ringenes herre", "olsen banden", "spiderman", "batman", "pippi langstrømpe", "minions",
"mary poppins", "ghostbusters"]},
{"id": "koekken", "name": "Ting i køkkenet", "icon": "🍳", "words": [
"gaffel", "pande", "køleskab", "brødrister", "piskeris", "kaffemaskine", "grydeske", "ovn", "mikroovn",
"kagerulle", "si", "osteskærer", "opvaskemaskine", "tekande", "skærebræt", "kasserolle", "køkkenrulle"]},
{"id": "jul", "name": "Jul", "icon": "🎄", "words": [
"juletræ", "julemand", "nisse", "rensdyr", "snemand", "gave", "risalamande", "pebernødder", "stjerne",
"kravlenisse", "slæde", "julehjerte", "lys", "and", "juletræsfod", "mandelgave", "kalenderlys", "sne"]},
{"id": "sport", "name": "Sport", "icon": "⚽", "words": [
"fodbold", "håndbold", "cykelløb", "svømning", "tennis", "golf", "skihop", "boksning", "badminton",
"basketball", "bueskydning", "surfing", "ishockey", "kunstskøjteløb", "maraton", "vægtløftning", "ridning"]},
{"id": "mad", "name": "Mad", "icon": "🍕", "words": [
"pizza", "pandekager", "spaghetti", "hotdog", "frikadeller", "sushi", "burger", "is", "banan", "vandmelon",
"æbleskiver", "smørrebrød", "popcorn", "rugbrød", "flæskesteg", "kanelsnegl", "tacos", "lagkage"]},
{"id": "strand", "name": "På stranden", "icon": "🏖️", "words": [
"sandslot", "parasol", "badebold", "krabbe", "solcreme", "fyrtårn", "bølge", "måge", "badehåndklæde",
"vandmand", "luftmadras", "solbriller", "spand og skovl", "muslingeskal", "isbod", "badebukser", "redningsvest"]},
{"id": "job", "name": "Job", "icon": "👩‍🚒", "words": [
"brandmand", "læge", "politibetjent", "kok", "pilot", "frisør", "tandlæge", "landmand", "maler", "astronaut",
"postbud", "skuespiller", "lærer", "dyrlæge", "tømrer", "dommer", "klovn"]},
{"id": "skole", "name": "I skolen", "icon": "🏫", "words": [
"tavle", "lommeregner", "skoletaske", "lineal", "blyant", "frikvarter", "madpakke", "globus", "lektier",
"viskelæder", "gymnastiksal", "skolebus", "mikroskop", "saks", "atlas", "klokke", "penalhus"]},
{"id": "eventyr", "name": "Eventyr", "icon": "🏰", "words": [
"drage", "prinsesse", "slot", "trold", "heks", "enhjørning", "havfrue", "trylledrik", "ridder", "frø",
"glasko", "den grimme ælling", "tinsoldat", "kejserens nye klæder", "fe", "skattekiste", "tryllestav", "ulv"]},
{"id": "transport", "name": "Transport", "icon": "🚂", "words": [
"tog", "cykel", "helikopter", "ubåd", "traktor", "rumraket", "sejlbåd", "brandbil", "løbehjul", "luftballon",
"skraldebil", "færge", "motorcykel", "flyvemaskine", "rulleskøjter", "taxa", "kran"]},
{"id": "musik", "name": "Musik", "icon": "🎸", "words": [
"guitar", "trommer", "klaver", "trompet", "violin", "mikrofon", "harmonika", "dirigent", "kor", "koncert",
"høretelefoner", "saxofon", "blokfløjte", "rockstjerne", "luftguitar", "kontrabas", "tamburin"]},
{"id": "danmark", "name": "Danmark", "icon": "🇩🇰", "words": [
"den lille havfrue", "dannebrog", "vikingeskib", "lego", "rundetårn", "tivoli", "cykelsti", "pølsevogn",
"kronborg", "dronning", "hygge", "klitter", "vindmølle", "kongekrone", "fastelavn", "sankthans", "kolonihave"]},
{"id": "rummet", "name": "Rummet", "icon": "🚀", "words": [
"astronaut", "planet", "måne", "rumvæsen", "stjerneskud", "satellit", "sort hul", "saturn", "teleskop",
"rumstation", "ufo", "komet", "månelanding", "solsystem", "mars", "rumdragt"]}
]
+756
View File
@@ -0,0 +1,756 @@
"""Spilmotoren for Tegn og Gæt (Telestrations-formatet).
Ren Python uden Django og uden I/O, i samme stil som `fupogfakta.engine`: alle
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.
"""
from __future__ import annotations
import difflib
import json
import math
import random
import secrets
from dataclasses import dataclass
from fupogfakta.engine import NAME_PUNCT, GameError, normalize, sanitize
WORD_SECONDS = 30
DRAW_SECONDS = 75
GUESS_SECONDS = 30
THEME_SECONDS = 20
RULES_STEP_MS = 5000
# Afsløringen: kæde for kæde, bidrag for bidrag (uden stemme; speakeren kan forlænge et trin)
CHAIN_INTRO_MS = 2500
WORD_MS = 3000
DRAWING_MS = 5500 # tegningen "tegnes frem" (ca. 3,5 s) og står lidt
GUESS_MS = 3000
CHAIN_END_MS = 4500 # første ord mod sidste gæt
MIN_PLAYERS = 3
MAX_PLAYERS = 10
MAX_NAME = 12
MAX_TEXT = 30 # ord og gæt
POINTS_LIKE = 100
POINTS_SURVIVED = 500 # til ejeren af en kæde, hvor sidste gæt er det første ord
EMPTY_GUESS = "???"
# Tegninger: vektorstrøg i normaliserede koordinater (0..1, lærredet er 4:3)
PALETTE = ("#1d1b2e", "#e63946", "#2a7de1", "#2bb673")
MIN_WIDTH, MAX_WIDTH = 0.002, 0.06
MAX_STROKES = 400
MAX_POINTS = 20_000
MAX_BYTES = 64 * 1024
REACTIONS = ("😂", "😱", "👏", "🤯", "🙈", "🔥")
REACT_PER_SECOND = 6
COLORS = ["#ff4d6d", "#4cc9f0", "#ffd166", "#06d6a0", "#b388ff", "#ff9f1c", "#f15bb5", "#90e0ef",
"#c0ca33", "#e0aaff"]
TIMED_PHASES = {"rules", "theme", "word", "draw", "guess", "reveal"}
RULES = [
{"icon": "✏️", "title": "Skriv et ord", "body": "Alle skriver et ord eller en kort frase inden for temaet.",
"say": "Sådan her spiller vi. Først skriver alle et ord inden for temaet. Det må gerne være lidt svært at tegne."},
{"icon": "🎨", "title": "Tegn det", "body": "Telefonen sender ordet videre. Den næste tegner det.",
"say": "Så sender jeg ordet videre til den næste, som skal tegne det. Ingen bogstaver, kun streger."},
{"icon": "🤔", "title": "Gæt tegningen", "body": "Den næste ser kun tegningen og skriver, hvad den forestiller.",
"say": "Den næste ser kun tegningen og skal gætte, hvad det er. Og så tegner den næste igen det gæt."},
{"icon": "🔁", "title": "Hele vejen rundt", "body": "Skiftevis tegn og gæt, til kæden har været hos alle.",
"say": "Sådan går det skiftevis, til kæden har været hele vejen rundt. Lad os se, hvad der er tilbage."},
{"icon": "❤️", "title": "Hjerter og priser", "body": "Giv hjerter til de bedste tegninger og gæt. Point og priser til sidst.",
"say": "Til sidst viser jeg alle kæderne. Giv hjerter til de bedste tegninger og gæt. Held og lykke!"},
]
def _num(v) -> bool:
return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v)
def clean_drawing(strokes) -> list[dict]:
"""Validerer en tegning fra telefonen. Serveren stoler ikke på klienten: ukendte farver, punkter
uden for lærredet og for store tegninger afvises med `drawing_invalid`."""
def bad(msg="Tegningen kunne ikke læses."):
return GameError("drawing_invalid", msg)
if not isinstance(strokes, list):
raise bad()
if len(strokes) > MAX_STROKES:
raise bad("Tegningen har for mange streger.")
out, total = [], 0
for s in strokes:
if not isinstance(s, dict) or s.get("color") not in PALETTE:
raise bad()
width, points = s.get("width"), s.get("points")
if not _num(width) or not MIN_WIDTH <= width <= MAX_WIDTH or not isinstance(points, list) or not points:
raise bad()
total += len(points)
if total > MAX_POINTS:
raise bad("Tegningen er for stor.")
clean = []
for p in points:
if not isinstance(p, list) or len(p) != 2 or not all(_num(v) and 0 <= v <= 1 for v in p):
raise bad()
clean.append([round(p[0], 4), round(p[1], 4)])
out.append({"color": s["color"], "width": round(width, 4), "points": clean})
if len(json.dumps(out, separators=(",", ":"))) > MAX_BYTES:
raise bad("Tegningen er for stor.")
return out
def clean_text(text: str, code: str, what: str) -> str:
"""Ord og gæt: samme rensning som løgnene i Fup og Fakta, små bogstaver (storskærmen viser versaler)."""
text = sanitize(str(text or "")).lower()
if not text or len(text) > MAX_TEXT or not normalize(text):
raise GameError(code, f"{what} skal være 1-{MAX_TEXT} bogstaver eller tal.")
return text
def step_kind(t: int) -> str:
return "word" if t == 0 else "draw" if t % 2 else "guess"
@dataclass
class Player:
id: str
name: str
color: str
token: str
role: str = "player" # player | audience
score: int = 0
connected: bool = False
joined_order: int = 0
likes: int = 0
class Game:
game = "tegn"
show_rules = True # reglerne før rummets første spil (testene slår dem fra)
fest = False
def __init__(self, code: str, themes: list[dict], join_url: str = "", rng: random.Random | None = None):
self.code = code
self.join_url = join_url
self.rng = rng or random.Random()
# [{"id": "dyr", "name": "Dyr", "words": [...]}]
self.themes = [t for t in themes if t.get("words")]
self.players: dict[str, Player] = {}
self.audience: dict[str, Player] = {}
self._counter = 0
self.phase = "lobby"
self.deadline: float | None = None
self.hold_until: float | None = None
self.version = 0
self.events: list[dict] = []
self.rules_done = False
self.rules_started_at: float | None = None
self.rules_steps_ms: list[int] = []
self.rules_audio: list | None = None
self.theme: dict | None = None
self.chosen_theme: str | None = None
# Kæderne: order[k] ejer kæde k; chains[k][t] er bidraget i trin t
self.order: list[str] = []
self.chains: list[list[dict]] = []
self.step = 0
self.submitted: set[str] = set()
self.drafts: dict[str, object] = {}
# Afsløringen
self.reveal_id = 0
self.plan: list[dict] = [] # [{"chain", "entry", "ms", "audio"}]; entry -1 = intro, N = slut
self.reveal_started_at: float | None = None
self.likes: dict[str, set[str]] = {}
self.survived: list[bool] = []
self.final_result: dict | None = None
self._react_bucket: dict[str, list[float]] = {}
# ── hjælpere ────────────────────────────────────────────────────────────
def _next_id(self, prefix: str) -> str:
self._counter += 1
return f"{prefix}{self._counter}"
def _changed(self):
self.version += 1
def _emit(self, kind: str, **data):
self.events.append({"type": kind, **data})
def drain_events(self) -> list[dict]:
events, self.events = self.events, []
return events
def held(self, now: float) -> bool:
return self.hold_until is not None and now < self.hold_until
def phase_key(self) -> str:
return f"{self.reveal_id}.{self.step}.{self.phase}"
def vip_id(self) -> str | None:
return next(iter(self.players), None)
def _member(self, member_id: str) -> Player | None:
return self.players.get(member_id) or self.audience.get(member_id)
def _require_phase(self, *phases):
if self.phase not in phases:
raise GameError("wrong_phase", "Det kan du ikke lige nu.")
@property
def n(self) -> int:
return len(self.order)
@property
def rules(self) -> list[dict]:
return RULES
@property
def reaction_set(self) -> tuple[str, ...]:
return REACTIONS
def chain_for(self, member_id: str, t: int | None = None) -> int | None:
"""Kæden, spilleren arbejder på i trin t (None hvis spilleren ikke er med i runden)."""
if member_id not in self.order:
return None
t = self.step if t is None else t
return (self.order.index(member_id) - t) % self.n
def _entry(self, entry_id: str) -> dict | None:
for chain in self.chains:
for e in chain:
if e["id"] == entry_id:
return e
return None
# ── medlemmer ───────────────────────────────────────────────────────────
def join(self, name: str) -> Player:
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} 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.")
as_player = self.phase == "lobby" and len(self.players) < MAX_PLAYERS
used = {p.color for p in self.players.values()}
color = next((c for c in COLORS if c not in used), COLORS[0]) if as_player else "#8d99ae"
p = Player(id=self._next_id("p" if as_player else "u"), name=name, color=color,
token=secrets.token_urlsafe(16), role="player" if as_player else "audience",
connected=True, joined_order=self._counter)
(self.players if as_player else self.audience)[p.id] = p
self._emit("join", name=p.name, role=p.role)
self._changed()
return p
def resume(self, token: str) -> Player:
for p in list(self.players.values()) + list(self.audience.values()):
if secrets.compare_digest(p.token, token or ""):
return p
raise GameError("unknown_token", "Vi kunne ikke finde dig i spillet. Join igen.")
def set_connected(self, member_id: str, connected: bool, now: float) -> None:
p = self._member(member_id)
if p and p.connected != connected:
p.connected = connected
self._changed()
if not connected:
self._maybe_finish_early(now) # en der forsvinder, må ikke holde de andre tilbage
def kick(self, member_id: str, now: float) -> None:
p = self.players.pop(member_id, None) or self.audience.pop(member_id, None)
if not p:
return
# Spillerens kæde og pladser i rotationen bliver; de tomme trin fyldes ud, når tiden går.
self._maybe_finish_early(now)
if self.phase != "lobby" and not self.players:
self.phase, self.deadline = "lobby", None
self._changed()
# ── spilforløb ──────────────────────────────────────────────────────────
def start(self, member_id: str | None, now: float) -> None:
"""member_id None = værten."""
self._require_phase("lobby")
if member_id is not None and member_id != self.vip_id():
raise GameError("not_allowed", "Det er kun VIP'en, der kan starte spillet.")
if len(self.players) < MIN_PLAYERS:
raise GameError("too_few_players", f"Der skal være mindst {MIN_PLAYERS} spillere.")
for p in self.players.values():
p.score = p.likes = 0
self.final_result = None
self.order = list(self.players)
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_theme(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(self.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():
raise GameError("not_allowed", "Det er kun VIP'en, der kan starte et nyt spil.")
# Publikum, der kom til undervejs, rykker op som spillere, hvis der er plads.
for a in list(self.audience.values()):
if len(self.players) >= MAX_PLAYERS:
break
del self.audience[a.id]
a.role = "player"
used = {p.color for p in self.players.values()}
a.color = next((c for c in COLORS if c not in used), COLORS[0])
self.players[a.id] = a
self.phase = "lobby"
self._emit("play_again")
self.start(None, now)
def _begin_theme(self, now: float):
if not self.themes:
raise GameError("no_themes", "Der er ingen temaer.")
self.phase = "theme"
self.chosen_theme = None
self.deadline = now + THEME_SECONDS
vip = self.players.get(self.vip_id() or "")
self._emit("theme", vip=vip.name if vip else "")
self._changed()
def choose_theme(self, member_id: str | None, theme_id: str, now: float) -> None:
"""Værten (member_id None) eller VIP'en vælger temaet."""
self._require_phase("theme")
if member_id is not None and member_id != self.vip_id():
raise GameError("not_allowed", "Det er VIP'en eller værten, der vælger tema.")
if theme_id not in {t["id"] for t in self.themes}:
raise GameError("answer_invalid", "Ukendt tema.")
if self.chosen_theme is not None:
raise GameError("already_done", "Temaet er valgt.")
self.chosen_theme = theme_id
if self.held(now):
self.deadline = now # skiftet sker, når speakeren er færdig
self._changed()
else:
self._start_chains(now)
def _start_chains(self, now: float, timeout: bool = False):
if self.chosen_theme is None:
self.chosen_theme = self.rng.choice(self.themes)["id"]
self.theme = next(t for t in self.themes if t["id"] == self.chosen_theme)
self._emit("theme_chosen", theme=self.theme["name"], timeout=timeout)
self.order = [pid for pid in self.order if pid in self.players]
self.chains = [[] for _ in self.order]
self.likes = {}
self.survived = []
self.plan = []
self.reveal_started_at = None
self._begin_step(0, now)
def _begin_step(self, t: int, now: float):
self.step = t
self.submitted = set()
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._changed()
def task(self, member_id: str) -> dict | None:
"""Spillerens trin lige nu: kun det forrige bidrag i kæden, aldrig resten."""
if self.phase not in ("word", "draw", "guess"):
return None
c = self.chain_for(member_id)
if c is None:
return None
prev = self.chains[c][self.step - 1] if self.step > 0 else None
mine = self.chains[c][self.step] if len(self.chains[c]) > self.step else None
return {"kind": self.phase, "done": member_id in self.submitted,
"from": prev["by"] if prev else None,
"text": prev["text"] if prev and prev["kind"] != "drawing" else None,
"drawing": prev["strokes"] if prev and prev["kind"] == "drawing" else None,
"mine": mine["text"] if mine and mine["kind"] != "drawing" else None,
"draft": self.drafts.get(member_id)}
def _require_task(self, member_id: str, phase: str) -> int:
self._require_phase(phase)
if member_id not in self.players:
raise GameError("not_allowed", "Det kan kun spillere.")
c = self.chain_for(member_id)
if c is None:
raise GameError("not_allowed", "Du er ikke med i denne runde.")
if member_id in self.submitted:
raise GameError("already_done", "Du har allerede afleveret.")
return c
def _put(self, c: int, member_id: str, value, auto: bool = False):
kind = {"word": "word", "draw": "drawing", "guess": "guess"}[self.phase]
entry = {"id": f"c{c}e{self.step}", "kind": kind, "by": member_id, "auto": auto,
"text": None if kind == "drawing" else value, "strokes": value if kind == "drawing" else None}
self.chains[c].append(entry)
def submit_word(self, member_id: str, text: str, now: float) -> None:
c = self._require_task(member_id, "word")
self._submit(c, member_id, clean_text(text, "word_invalid", "Ordet"), now)
def submit_drawing(self, member_id: str, strokes, now: float) -> None:
c = self._require_task(member_id, "draw")
self._submit(c, member_id, clean_drawing(strokes), now)
def submit_guess(self, member_id: str, text: str, now: float) -> None:
c = self._require_task(member_id, "guess")
self._submit(c, member_id, clean_text(text, "guess_invalid", "Gættet"), now)
def _submit(self, c: int, member_id: str, value, now: float):
self._put(c, member_id, value)
self.submitted.add(member_id)
self.drafts.pop(member_id, None)
self._emit("submitted", player=member_id, step=self.phase)
self._changed()
self._maybe_finish_early(now)
def save_draft(self, member_id: str, value) -> None:
"""Det, telefonen har nået indtil nu. Bruges, hvis tiden løber ud. Ingen ny tilstand til skærmene."""
self._require_phase("word", "draw", "guess")
self._require_task(member_id, self.phase)
if self.phase == "draw":
self.drafts[member_id] = clean_drawing(value)
else:
text = sanitize(str(value or "")).lower()[:MAX_TEXT]
if normalize(text):
self.drafts[member_id] = text
else:
self.drafts.pop(member_id, None)
def suggest(self, member_id: str) -> str:
""""Giv mig et forslag": et eksempel-ord fra temaet, som ingen andre har skrevet."""
self._require_task(member_id, "word")
return self._example_word()
def _example_word(self) -> str:
taken = {normalize(e["text"]) for chain in self.chains for e in chain if e["kind"] == "word"}
words = self.theme["words"]
free = [w for w in words if normalize(w) not in taken] or words
return sanitize(self.rng.choice(free)).lower()
def _all_done(self) -> bool:
if self.phase not in ("word", "draw", "guess"):
return False
active = [pid for pid in self.order if pid in self.players and self.players[pid].connected]
return bool(active) and all(pid in self.submitted for pid in active)
def _maybe_finish_early(self, now: float):
if not self._all_done():
return
if self.held(now):
self.deadline = now # alle er færdige; skiftet venter på speakeren
self._changed()
else:
self._emit("step_all_done")
self._end_step(now)
def _end_step(self, now: float):
"""Tiden er gået (eller alle er færdige): det, der mangler, fyldes ud, så spillet aldrig hænger."""
for pid in self.order:
c = self.chain_for(pid)
if len(self.chains[c]) > self.step:
continue
draft = self.drafts.get(pid)
if self.phase == "word":
value = draft or self._example_word()
elif self.phase == "draw":
value = draft or []
else:
value = draft or EMPTY_GUESS
self._put(c, pid, value, auto=True)
if self.step + 1 < self.n:
self._begin_step(self.step + 1, now)
else:
self._start_reveal(now)
# ── afsløringen ─────────────────────────────────────────────────────────
def _start_reveal(self, now: float):
self.reveal_id += 1
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.
last = self._last_guess(chain)
self.survived.append(last is not None and last != EMPTY_GUESS
and normalize(last) == normalize(chain[0]["text"]))
self.plan = []
for k, chain in enumerate(self.chains):
self.plan.append({"chain": k, "entry": -1, "ms": CHAIN_INTRO_MS, "audio": None})
for i, e in enumerate(chain):
ms = {"word": WORD_MS, "drawing": DRAWING_MS, "guess": GUESS_MS}[e["kind"]]
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.start_reveal_timeline(now)
self._emit("reveal")
self._changed()
@staticmethod
def _last_guess(chain: list[dict]) -> str | None:
return next((e["text"] for e in reversed(chain) if e["kind"] == "guess"), None)
def start_reveal_timeline(self, now: float, steps_ms: list[int] | None = None):
if steps_ms:
for beat, ms in zip(self.plan, steps_ms):
beat["ms"] = ms
self.reveal_started_at = now
self.deadline = now + sum(b["ms"] for b in self.plan) / 1000
self._changed()
def hold_reveal(self, now: float, max_wait: float = 10.0):
"""Vent med at starte afsløringen (speakeren laver lyd). Starter selv efter max_wait."""
if self.phase == "reveal":
self.reveal_started_at = None
self.deadline = now + max_wait
self._changed()
def beat_at(self, now: float) -> int | None:
"""Indeks i planen, der er på storskærmen lige nu (None før start)."""
if self.phase != "reveal" or self.reveal_started_at is None:
return None
el = (now - self.reveal_started_at) * 1000
for i, b in enumerate(self.plan):
if el < b["ms"]:
return i
el -= b["ms"]
return len(self.plan) - 1
def _revealed(self, entry_id: str, now: float) -> bool:
i = self.beat_at(now)
if i is None:
return False
for b in self.plan[: i + 1]:
chain = self.chains[b["chain"]]
if 0 <= b["entry"] < len(chain) and chain[b["entry"]]["id"] == entry_id:
return True
return False
def like(self, member_id: str, entry_id: str, now: float) -> None:
self._require_phase("reveal")
if not self._member(member_id):
raise GameError("not_allowed", "Du er ikke med i spillet.")
e = self._entry(str(entry_id))
if not e or e["kind"] not in ("drawing", "guess"):
raise GameError("answer_invalid", "Hjerter er til tegninger og gæt.")
if e["by"] == member_id:
raise GameError("answer_invalid", "Du kan ikke give dig selv et hjerte.")
if not self._revealed(e["id"], now):
raise GameError("answer_invalid", "Den er ikke vist endnu.")
voters = self.likes.setdefault(e["id"], set())
sign = -1 if member_id in voters else 1
(voters.discard if sign < 0 else voters.add)(member_id)
author = self.players.get(e["by"])
if author:
author.score += sign * POINTS_LIKE
author.likes += sign
self._changed()
def featured(self, now: float) -> str | None:
"""Bidraget i fokus på storskærmen (reaktioner flyver op fra det)."""
i = self.beat_at(now)
if i is None:
return None
b = self.plan[i]
chain = self.chains[b["chain"]]
return chain[b["entry"]]["id"] if 0 <= b["entry"] < len(chain) else None
def react(self, member_id: str, emoji: str, now: float) -> dict | None:
if self.phase != "reveal" or emoji not in self.reaction_set:
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
return {"emoji": emoji, "color": member.color, "answer_id": self.featured(now)}
def skip_chain(self, now: float):
"""Værten springer resten af den aktuelle kæde over."""
i = self.beat_at(now)
offset = 0
for j, b in enumerate(self.plan):
if j > i and b["entry"] == -1:
self.reveal_started_at = now - offset / 1000
self._changed()
return
offset += b["ms"]
self._finish()
def _finish(self):
for k, ok in enumerate(self.survived):
owner = self.players.get(self.order[k])
if ok and owner:
owner.score += POINTS_SURVIVED
ranking = sorted(self.players.values(), key=lambda p: (-p.score, p.joined_order))
places, last_score, place = [], None, 0
for i, p in enumerate(ranking, 1):
if p.score != last_score:
place, last_score = i, p.score
places.append({"player_id": p.id, "score": p.score, "place": place})
self.final_result = {"ranking": places, "awards": self._awards()}
winner = ranking[0] if ranking else None
self._emit("final", winner=winner.name if winner else "", points=winner.score if winner else 0,
awards=[{"kind": a["kind"], "name": self.players[a["player_id"]].name}
for a in self.final_result["awards"]])
self.phase = "final"
self.deadline = None
self._changed()
def _awards(self) -> list[dict]:
"""Priserne. Ved lighed vinder den, der blev vist først."""
awards = []
entries = [e for chain in self.chains for e in chain]
def most_liked(kind, title, detail):
best = None
for e in entries:
n = len(self.likes.get(e["id"], ()))
if e["kind"] == kind and n and e["by"] in self.players and (best is None or n > best[1]):
best = (e, n)
if best:
e, n = best
awards.append({"title": title, "player_id": e["by"], "detail": detail(e, n), "kind": kind,
"entry_id": e["id"], "count": n})
hearts = lambda n: f"{n} hjerte{'r' if n != 1 else ''}" # noqa: E731
most_liked("drawing", "Bedste tegning", lambda e, n: hearts(n))
most_liked("guess", "Bedste gæt", lambda e, n: f"\"{e['text']}\", {hearts(n)}")
owners = [(k, self.order[k]) for k in range(len(self.chains)) if self.order[k] in self.players]
alive = [(k, pid) for k, pid in owners if self.survived[k]]
if alive:
k, pid = alive[0]
more = f" (og {len(alive) - 1} til)" if len(alive) > 1 else ""
awards.append({"title": "Kæden der overlevede", "player_id": pid, "kind": "survived",
"detail": f"\"{self.chains[k][0]['text']}\" hele vejen rundt{more}", "count": len(alive)})
changed = []
for k, pid in owners:
first, last = self.chains[k][0]["text"], self._last_guess(self.chains[k])
if self.survived[k] or last in (None, EMPTY_GUESS):
continue
changed.append((difflib.SequenceMatcher(None, normalize(first), normalize(last)).ratio(), k, pid, first, last))
if changed:
_, k, pid, first, last = min(changed, key=lambda x: (x[0], x[1]))
awards.append({"title": "Største forvandling", "player_id": pid, "kind": "transformed",
"detail": f"\"{first}\" blev til \"{last}\"", "count": 1})
return awards
# ── tid ─────────────────────────────────────────────────────────────────
def skip(self, now: float) -> None:
if self.phase not in TIMED_PHASES:
raise GameError("wrong_phase", "Der er ikke noget at springe over.")
if self.phase == "reveal" and self.reveal_started_at is not None:
self.skip_chain(now)
return
self._advance(now)
def tick(self, now: float) -> bool:
"""Kaldes når deadline er nået. Returnerer True hvis noget ændrede sig."""
if self.deadline is None or now < self.deadline or self.held(now):
return False
self._advance(now)
return True
def _advance(self, now: float):
if 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_theme(now)
elif self.phase == "theme":
self._start_chains(now, timeout=self.chosen_theme is None)
elif self.phase in ("word", "draw", "guess"):
self._end_step(now)
elif self.phase == "reveal":
if self.reveal_started_at is None:
self.start_reveal_timeline(now) # lyden blev aldrig klar: kør uden
else:
self._finish()
# ── snapshot ────────────────────────────────────────────────────────────
def snapshot(self, now: float, viewer: Player | None = None, host: bool = False) -> dict:
vip = self.vip_id()
stepping = self.phase in ("word", "draw", "guess")
state = {
"game": self.game,
"code": self.code,
"phase": self.phase,
"server_time": int(now * 1000),
"deadline": int(self.deadline * 1000) if self.deadline else None,
"join_url": self.join_url,
"min_players": MIN_PLAYERS,
"max_players": MAX_PLAYERS,
"round": None,
"players": [
{"id": p.id, "name": p.name, "color": p.color, "score": p.score, "connected": p.connected,
"vip": p.id == vip, "done": stepping and p.id in self.submitted}
for p in self.players.values()
],
"audience_count": len(self.audience),
"fest": False,
"reactions": list(self.reaction_set),
}
if self.phase == "rules":
state["rules"] = {"items": [{k: r[k] for k in ("icon", "title", "body")} for r in self.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 == "theme":
state["theme"] = {"options": [{"id": t["id"], "name": t["name"], "icon": t.get("icon", "")}
for t in self.themes], "chosen_id": self.chosen_theme}
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}
if self.phase == "reveal":
state["reveal"] = {
"id": self.reveal_id,
"started_at": int(self.reveal_started_at * 1000) if self.reveal_started_at else None,
"plan": [{"chain": b["chain"], "entry": b["entry"], "ms": b["ms"], "audio": b["audio"]} for b in self.plan],
# Tegningerne følger ikke med her (de er store); storskærmen får dem i beskeden "drawings".
"chains": [{"owner": self.order[k], "survived": self.survived[k],
"entries": [{"id": e["id"], "kind": e["kind"], "by": e["by"], "text": e["text"],
"auto": e["auto"], "likes": len(self.likes.get(e["id"], ()))} for e in chain]}
for k, chain in enumerate(self.chains)],
}
if self.phase == "final":
state["final"] = self.final_result
if host:
state["host"] = True
elif viewer:
state["me"] = {
"id": viewer.id, "name": viewer.name, "role": viewer.role, "vip": viewer.id == vip,
"score": viewer.score,
"task": self.task(viewer.id) if viewer.role == "player" else None,
"likes": sorted(eid for eid, voters in self.likes.items() if viewer.id in voters)
if self.phase == "reveal" else [],
"can_choose": self.phase == "theme" and viewer.id == vip,
}
return state
def drawings(self) -> dict[str, list]:
"""Alle tegninger i den aktuelle afsløring (til storskærmen)."""
return {e["id"]: e["strokes"] for chain in self.chains for e in chain if e["kind"] == "drawing"}
+158
View File
@@ -0,0 +1,158 @@
{
"lobby_welcome": [
"Velkommen til Tegn og Gæt. Find telefonen frem og gør fingrene klar.",
"[excited] Tegn og Gæt! Ingen kan tegne, og det er hele pointen.",
"Velkommen. I aften bliver en kat til en hund, og en hund til en sofa.",
"Hej og velkommen. Her tæller fantasi mere end talent. Heldigvis.",
"[mischievously] Velkommen til Tegn og Gæt. Jeg glæder mig allerede til jeres streger.",
"Telefonerne frem. Kunstskolen kan blive derhjemme."
],
"lobby_join": [
"Velkommen, {navn}. Har du taget farverne med?",
"{navn} er med. Så er der en kunstner mere.",
"Hej {navn}. Jeg har hørt, du tegner som en gud. Eller som en giraf.",
"{navn} er landet. Blyanten er spidset.",
"[excited] {navn} er her! Nu kan det kun gå skævt.",
"Velkommen til, {navn}. Husk, streger tæller, talent gør ikke."
],
"lobby_waiting": [
"{vip}, vi er klar, når du er. Tryk start.",
"Så er vi nok, {vip}. Skal vi tegne lidt?",
"{vip}, pennene tørrer ud. Start spillet.",
"[sighs] {vip}, kunsten venter på dig.",
"Alle sidder klar, {vip}. Du har startknappen."
],
"game_start": [
"Så kører vi. {antal_spillere} kunstnere, {antal_spillere} kæder.",
"[excited] Lad os tegne! {antal_spillere} spillere er med.",
"Okay, {antal_spillere} spillere. Det bliver kaos på den gode måde.",
"Spillet starter. {antal_spillere} kæder skal hele vejen rundt.",
"Velkommen til {antal_spillere} spillere. Gør fingrene klar."
],
"theme_choose": [
"{vip}, vælg et tema.",
"Hvad skal vi tegne i aften, {vip}? Vælg et tema.",
"{vip} bestemmer temaet. Vælg med omhu.",
"Så er det dig, {vip}. Hvilket tema?",
"[curious] {vip}, hvad bliver det? Vælg et tema på telefonen."
],
"theme_chosen": [
"Temaet er {tema}.",
"{tema}. Godt valg.",
"[excited] {tema}! Det bliver godt.",
"Så er det {tema}. Find på noget svært at tegne.",
"Vi kører med {tema}."
],
"theme_timeout": [
"Ingen valgte, så jeg tager {tema}.",
"[sighs] For langsomt. Det bliver {tema}.",
"Tiden er gået. Temaet er {tema}.",
"Så vælger jeg. {tema}!",
"Ingen tøven mere. Vi tegner {tema}."
],
"word_start": [
"Skriv et ord inden for {tema}. Gerne noget, der er svært at tegne.",
"Alle skriver et ord om {tema}. Ingen kigger.",
"Tema {tema}. Skriv et ord eller en kort frase.",
"[mischievously] Find på et ord om {tema}. Gør det lidt svært for de andre.",
"Så er det ordet. Noget med {tema}."
],
"draw_start": [
"Tegn det, du har fået. Ingen bogstaver!",
"[excited] Tegnetid! Kun streger, ingen ord.",
"Nu skal der tegnes. Gør dit bedste. Eller dit sjoveste.",
"Tegn løs. Det behøver ikke være kønt, bare genkendeligt.",
"Fingrene frem. Tegn det, du ser på skærmen."
],
"guess_start": [
"Hvad forestiller tegningen? Skriv dit gæt.",
"Kig godt på tegningen. Hvad er det?",
"[curious] Hvad i alverden er det? Gæt løs.",
"Nu skal der gættes. Stol på din første tanke.",
"Tegningen er din. Skriv, hvad du tror, det er."
],
"step_hurry": [
"Ti sekunder tilbage!",
"Skynd jer lidt!",
"[excited] Sidste chance!",
"Tiden er snart gået.",
"Hurtigt nu!"
],
"step_all_done": [
"Alle er færdige. Videre!",
"Hurtigt klaret. Kæderne går videre.",
"Så er alle klar. Næste!",
"Flot tempo. Videre til næste.",
"Alle har afleveret. Telefonerne skifter."
],
"reveal_intro": [
"Så er kæderne hele vejen rundt. Lad os se, hvad der skete.",
"[mischievously] Nu kommer det sjove. Lad os se kæderne.",
"Kæderne er færdige. Hold fast.",
"Tid til afsløring. Giv hjerter til det bedste.",
"Lad os se, hvor galt det gik."
],
"chain_intro": [
"Her er {navn}s kæde.",
"Vi starter med {navn}s kæde.",
"Så er det {navn}s kæde.",
"[curious] Lad os se {navn}s kæde.",
"Næste kæde. Den startede hos {navn}."
],
"chain_survived": [
"[gasps] {ord} kom hele vejen rundt!",
"Utroligt. Den startede som {ord} og sluttede som {ord}.",
"[impressed] {ord} overlevede! Godt tegnet, godt gættet.",
"Den overlevede! {ord} hele vejen.",
"Perfekt kæde. {ord} fra start til slut."
],
"chain_changed": [
"Den startede som {ord} og endte som {gaet}.",
"[laughs] Fra {ord} til {gaet}. Hvordan skete det?",
"{ord} blev til {gaet}. Klassisk.",
"Ordet var {ord}. Det blev til {gaet}.",
"[chuckles] Fra {ord} til {gaet}. Det er kunst."
],
"award_best_drawing": [
"Prisen for bedste tegning går til {navn}.",
"Bedste tegning? Den tegnede {navn}.",
"[impressed] {navn} får prisen for bedste tegning.",
"Årets kunstner er {navn}. Bedste tegning.",
"Flest hjerter til en tegning. {navn}!"
],
"award_best_guess": [
"Bedste gæt kom fra {navn}.",
"Prisen for bedste gæt går til {navn}.",
"[laughs] {navn} fik flest hjerter for et gæt.",
"Det sjoveste gæt? {navn}.",
"{navn} gættede bedst i aften."
],
"award_survived": [
"Kæden der overlevede startede hos {navn}.",
"{navn}s kæde kom hele vejen rundt.",
"[impressed] {navn}s ord overlevede hele turen.",
"Bonus til {navn}. Den kæde holdt.",
"{navn} får bonus for en kæde, der overlevede."
],
"award_transformed": [
"Største forvandling? {navn}s kæde.",
"{navn}s ord blev til noget helt andet.",
"[laughs] Prisen for største forvandling går til {navn}s kæde.",
"Ingen kæde ændrede sig mere end {navn}s.",
"{navn}s kæde tog den længste omvej."
],
"winner": [
"Og vinderen er {vinder} med {point} point!",
"[excited] {vinder} vinder med {point} point!",
"Tillykke, {vinder}. {point} point.",
"{vinder} tager sejren med {point} point.",
"Aftenens kunstner er {vinder} med {point} point."
],
"play_again": [
"En gang til! Nyt tema.",
"Vi kører igen. Spids blyanterne.",
"[excited] Ny runde! Nye kæder.",
"Igen? Selvfølgelig.",
"Samme hold, ny kunst."
]
}
View File
+342
View File
@@ -0,0 +1,342 @@
import random
from django.test import SimpleTestCase
from fupogfakta.engine import GameError
from tegnoggaet import engine
from tegnoggaet.engine import Game, clean_drawing
from tegnoggaet.themes import load_themes
THEMES = [{"id": "dyr", "name": "Dyr", "words": ["kat", "hund", "ko", "gris"]},
{"id": "mad", "name": "Mad", "words": ["pizza", "is"]}]
STROKE = {"color": engine.PALETTE[0], "width": 0.01, "points": [[0.1, 0.1], [0.5, 0.5]]}
def setUpModule():
assert Game.show_rules is True # standarden i drift
Game.show_rules = False # reglerne testes for sig (RulesTests)
def tearDownModule():
Game.show_rules = True
class TegnTestCase(SimpleTestCase):
def setUp(self):
self.now = 1000.0
self.g = Game("TEST", THEMES, join_url="http://x/?code=TEST", rng=random.Random(4))
def join(self, n):
return [self.g.join(f"Spiller{i}") for i in range(n)]
def start(self, n, theme="dyr"):
ps = self.join(n)
self.g.start(None, self.now)
self.g.choose_theme(None, theme, self.now)
self.assertEqual(self.g.phase, "word")
return ps
def play_step(self, ps, value=None):
for p in ps:
if p.id not in self.g.players:
continue
if self.g.phase == "word":
self.g.submit_word(p.id, value or f"ord {p.name}", self.now)
elif self.g.phase == "draw":
self.g.submit_drawing(p.id, [STROKE], self.now)
else:
self.g.submit_guess(p.id, value or f"gæt {p.name}", self.now)
def play_all(self, ps):
while self.g.phase in ("word", "draw", "guess"):
self.play_step(ps)
self.assertEqual(self.g.phase, "reveal")
class RotationTests(TegnTestCase):
def test_rotation_never_gives_own_chain_and_all_chains_get_n_entries(self):
for n in range(3, 11):
with self.subTest(n=n):
self.g = Game("TEST", THEMES, rng=random.Random(n))
ps = self.start(n)
owner = {k: pid for k, pid in enumerate(self.g.order)}
for t in range(n):
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")
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)
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(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)])
def test_too_few_players(self):
self.join(2)
with self.assertRaises(GameError) as e:
self.g.start(None, self.now)
self.assertEqual(e.exception.code, "too_few_players")
class FlowTests(TegnTestCase):
def test_theme_vip_or_host_chooses_and_timeout_picks_one(self):
a, b, c = self.join(3)
self.g.start(a.id, self.now)
self.assertEqual(self.g.phase, "theme")
with self.assertRaises(GameError):
self.g.choose_theme(b.id, "dyr", self.now)
with self.assertRaises(GameError):
self.g.choose_theme(a.id, "findes-ikke", self.now)
self.g.tick(self.now + engine.THEME_SECONDS)
self.assertEqual(self.g.phase, "word")
self.assertIn(self.g.theme["id"], {"dyr", "mad"})
def test_task_shows_only_the_previous_entry(self):
a, b, c = self.start(3)
self.assertEqual(self.g.task(a.id)["kind"], "word")
self.g.submit_word(a.id, "Kat", self.now)
self.g.submit_word(b.id, "hund", self.now)
self.g.submit_word(c.id, "ko", self.now)
self.assertEqual(self.g.phase, "draw")
# a tegner c's ord (kæde 2), og ser kun ordet
t = self.g.task(a.id)
self.assertEqual((t["kind"], t["text"], t["drawing"], t["from"]), ("draw", "ko", None, c.id))
snap = self.g.snapshot(self.now, viewer=a)
self.assertNotIn("reveal", snap)
self.assertNotIn("kat", str(snap)) # eget ord er ikke synligt mere, og andres aldrig
self.assertNotIn("hund", str(snap))
self.g.submit_drawing(a.id, [STROKE], self.now)
self.g.submit_drawing(b.id, [dict(STROKE, color=engine.PALETTE[1])], self.now)
self.g.submit_drawing(c.id, [dict(STROKE, color=engine.PALETTE[2])], self.now)
self.assertEqual(self.g.phase, "guess")
# a gætter i kæde (0 - 2) mod 3 = 1, b's kæde, som c tegnede
t = self.g.task(a.id)
self.assertEqual(t["kind"], "guess")
self.assertIsNone(t["text"])
self.assertEqual(t["drawing"][0]["color"], engine.PALETTE[2])
self.assertNotIn("hund", str(self.g.snapshot(self.now, viewer=a)))
def test_texts_are_cleaned_and_validated(self):
a, b, c = self.start(3)
with self.assertRaises(GameError) as e:
self.g.submit_word(a.id, "🙂🙂", self.now)
self.assertEqual(e.exception.code, "word_invalid")
with self.assertRaises(GameError):
self.g.submit_word(a.id, "x" * (engine.MAX_TEXT + 1), self.now)
self.g.submit_word(a.id, " Rød Kat! ", self.now)
self.assertEqual(self.g.chains[0][0]["text"], "rød kat")
with self.assertRaises(GameError) as e:
self.g.submit_word(a.id, "igen", self.now)
self.assertEqual(e.exception.code, "already_done")
def test_suggestion_avoids_taken_words(self):
a, b, c = self.start(3, theme="mad")
self.g.submit_word(a.id, "pizza", self.now)
self.assertEqual({self.g.suggest(b.id) for _ in range(5)}, {"is"})
def test_audience_and_late_joiners(self):
ps = self.start(3)
late = self.g.join("Sen")
self.assertEqual(late.role, "audience")
self.assertIsNone(self.g.snapshot(self.now, viewer=late)["me"]["task"])
with self.assertRaises(GameError):
self.g.submit_word(late.id, "hej", self.now)
class TimeoutTests(TegnTestCase):
def test_timeouts_use_drafts_or_defaults_and_never_hang(self):
a, b, c = self.start(3)
self.g.save_draft(b.id, "Halvt ord")
self.g.submit_word(a.id, "kat", self.now)
self.now += engine.WORD_SECONDS
self.assertTrue(self.g.tick(self.now))
self.assertEqual(self.g.phase, "draw")
words = {e["by"]: e for chain in self.g.chains for e in chain}
self.assertEqual(words[b.id]["text"], "halvt ord")
self.assertTrue(words[b.id]["auto"])
self.assertIn(words[c.id]["text"], THEMES[0]["words"]) # ingen kladde: et eksempel-ord fra temaet
self.g.save_draft(a.id, [STROKE])
with self.assertRaises(GameError):
self.g.save_draft(b.id, [{"color": "red", "width": 0.01, "points": [[0, 0]]}])
self.now += engine.DRAW_SECONDS
self.g.tick(self.now)
self.assertEqual(self.g.phase, "guess")
drawings = {e["by"]: e["strokes"] for chain in self.g.chains for e in chain if e["kind"] == "drawing"}
self.assertEqual(drawings[a.id], [STROKE])
self.assertEqual(drawings[b.id], []) # tom tegning sendes alligevel
self.now += engine.GUESS_SECONDS
self.g.tick(self.now)
self.assertEqual(self.g.phase, "reveal")
self.assertEqual({e["text"] for chain in self.g.chains for e in chain if e["kind"] == "guess"}, {"???"})
def test_disconnected_players_do_not_hold_the_others_back(self):
a, b, c = self.start(3)
self.g.set_connected(c.id, False, self.now)
self.g.submit_word(a.id, "kat", self.now)
self.g.submit_word(b.id, "hund", self.now)
self.assertEqual(self.g.phase, "draw")
self.g.submit_drawing(a.id, [STROKE], self.now)
self.assertEqual(self.g.phase, "draw")
self.g.submit_drawing(b.id, [STROKE], self.now)
self.assertEqual(self.g.phase, "guess")
def test_kicked_player_leaves_the_chains_intact(self):
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(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):
a, b, c = self.start(3)
self.g.hold_until = self.now + 3
self.play_step([a, b, c])
self.assertEqual(self.g.phase, "word")
self.assertFalse(self.g.tick(self.now + 1))
self.assertTrue(self.g.tick(self.now + 3))
self.assertEqual(self.g.phase, "draw")
class DrawingValidationTests(SimpleTestCase):
def test_valid_drawing_is_rounded(self):
out = clean_drawing([{"color": engine.PALETTE[1], "width": 0.0123456, "points": [[0.123456, 1], [0, 0.5]]}])
self.assertEqual(out, [{"color": engine.PALETTE[1], "width": 0.0123, "points": [[0.1235, 1], [0, 0.5]]}])
self.assertEqual(clean_drawing([]), [])
def test_invalid_drawings_are_rejected(self):
ok = dict(STROKE)
bad = [
"streger", {"strokes": []}, [ok, "x"],
[dict(ok, color="red")], [dict(ok, color="javascript:alert(1)")],
[dict(ok, width=0)], [dict(ok, width=1)], [dict(ok, width=True)], [dict(ok, width=float("nan"))],
[dict(ok, points=[])], [dict(ok, points=[[0.1]])], [dict(ok, points=[[0.1, 1.2]])],
[dict(ok, points=[[-0.1, 0.5]])], [dict(ok, points=[[True, 0.5]])], [dict(ok, points=[["0.1", 0.5]])],
[ok] * (engine.MAX_STROKES + 1),
[dict(ok, points=[[0.5, 0.5]] * (engine.MAX_POINTS + 1))],
]
for strokes in bad:
with self.subTest(strokes=str(strokes)[:60]):
with self.assertRaises(GameError) as e:
clean_drawing(strokes)
self.assertEqual(e.exception.code, "drawing_invalid")
def test_size_limit(self):
# under punktgrænsen, men over 64 KB
many = [{"color": engine.PALETTE[0], "width": 0.01, "points": [[0.1234, 0.5678]] * 50} for _ in range(390)]
with self.assertRaises(GameError):
clean_drawing(many)
class RevealTests(TegnTestCase):
def play_game(self, n=3, survive=False):
ps = self.start(n)
while self.g.phase in ("word", "draw", "guess"):
if survive and self.g.phase != "draw":
self.play_step(ps, value="kat") # alle skriver/gætter "kat": alle kæder overlever
else:
self.play_step(ps)
return ps
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])
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)
self.assertEqual(snap["reveal"]["chains"][0]["entries"][0]["kind"], "word")
def test_likes_only_on_revealed_drawings_and_guesses_by_others(self):
a, b, c = self.play_game(3)
word, drawing, guess = self.g.chains[0] # a's kæde: ord af a, tegning af b, gæt af c
self.assertEqual((word["by"], drawing["by"], guess["by"]), (a.id, b.id, c.id))
with self.assertRaises(GameError):
self.g.like(b.id, drawing["id"], self.now) # endnu ikke vist
t = self.now + (engine.CHAIN_INTRO_MS + engine.WORD_MS + 10) / 1000
with self.assertRaises(GameError):
self.g.like(b.id, word["id"], t) # ord får ikke hjerter
with self.assertRaises(GameError):
self.g.like(b.id, drawing["id"], t) # ikke sin egen
self.g.like(a.id, drawing["id"], t)
self.assertEqual(self.g.players[b.id].score, engine.POINTS_LIKE)
self.g.like(a.id, drawing["id"], t) # slår fra igen
self.assertEqual(self.g.players[b.id].score, 0)
fan = self.g.join("Fan")
self.g.like(fan.id, drawing["id"], t)
self.g.like(a.id, drawing["id"], t)
self.assertEqual(self.g.players[b.id].likes, 2)
self.assertEqual(self.g.snapshot(t, viewer=fan)["me"]["likes"], [drawing["id"]])
def test_survived_bonus_and_awards(self):
a, b, c = self.play_game(3, survive=True)
self.assertEqual(self.g.survived, [True, True, True])
g = self.g.chains[0][2]
t = self.now + 60
self.g.like(a.id, g["id"], t)
self.g.tick(self.now + 600)
self.assertEqual(self.g.phase, "final")
scores = {p.id: p.score for p in self.g.players.values()}
self.assertEqual(scores[a.id], engine.POINTS_SURVIVED)
self.assertEqual(scores[g["by"]], engine.POINTS_SURVIVED + engine.POINTS_LIKE)
kinds = [x["kind"] for x in self.g.final_result["awards"]]
self.assertEqual(kinds, ["guess", "survived"])
self.assertEqual(self.g.final_result["ranking"][0]["player_id"], g["by"])
def test_biggest_transformation(self):
a, b, c = self.play_game(3)
self.g.tick(self.now + 600)
awards = {x["kind"]: x for x in self.g.final_result["awards"]}
self.assertNotIn("survived", awards)
self.assertIn("transformed", awards)
self.assertIn("blev til", awards["transformed"]["detail"])
def test_skip_jumps_to_next_chain_then_final(self):
self.play_game(3)
self.assertEqual(self.g.plan[self.g.beat_at(self.now)]["chain"], 0)
self.g.skip(self.now + 1)
self.assertEqual(self.g.beat_at(self.now + 1), 5) # kæde 1's intro (hver kæde: intro, 3 bidrag, slut)
self.g.skip(self.now + 1)
self.g.skip(self.now + 1)
self.assertEqual(self.g.phase, "final")
def test_reveal_waits_for_audio_then_starts_by_itself(self):
self.play_game(3)
self.g.hold_reveal(self.now, max_wait=5)
self.assertIsNone(self.g.snapshot(self.now, host=True)["reveal"]["started_at"])
self.g.tick(self.now + 5)
self.assertEqual(self.g.reveal_started_at, self.now + 5)
def test_play_again_keeps_players_and_promotes_audience(self):
a, b, c = self.play_game(3)
late = self.g.join("Sen")
self.g.tick(self.now + 600)
self.g.play_again(a.id, self.now + 601)
self.assertEqual(self.g.phase, "theme") # reglerne kun første gang
self.assertIn(late.id, self.g.players)
self.assertTrue(all(p.score == 0 for p in self.g.players.values()))
class RulesTests(TegnTestCase):
def test_rules_once_then_theme(self):
self.g.show_rules = True
self.join(3)
self.g.start(None, self.now)
self.assertEqual(self.g.phase, "rules")
self.g.tick(self.now + 60)
self.assertEqual(self.g.phase, "theme")
class ThemeFileTests(SimpleTestCase):
def test_themes_file(self):
themes = load_themes()
self.assertTrue(10 <= len(themes) <= 15)
self.assertEqual(len({t["id"] for t in themes}), len(themes))
for t in themes:
with self.subTest(theme=t["name"]):
self.assertTrue(15 <= len(t["words"]) <= 20)
self.assertEqual(len(set(t["words"])), len(t["words"]))
for w in t["words"]:
self.assertEqual(engine.clean_text(w, "x", "x"), w) # kan bruges som ord uden rettelser
+49
View File
@@ -0,0 +1,49 @@
"""Speakerens replikker til Tegn og Gæt (tegnoggaet/narrator/lines.json) følger samme regler som Fup og Fakta."""
import json
import re
from pathlib import Path
from django.test import SimpleTestCase
from party.narrator import AUDIO_TAGS as TAGS
LINES = Path(__file__).resolve().parents[1] / "narrator" / "lines.json"
# begivenhed -> tilladte pladsholdere
EVENTS = {
"lobby_welcome": set(), "lobby_join": {"navn"}, "lobby_waiting": {"vip"}, "game_start": {"antal_spillere"},
"theme_choose": {"vip"}, "theme_chosen": {"tema"}, "theme_timeout": {"tema"}, "word_start": {"tema"},
"draw_start": set(), "guess_start": set(), "step_hurry": set(), "step_all_done": set(),
"reveal_intro": set(), "chain_intro": {"navn"}, "chain_survived": {"ord"}, "chain_changed": {"ord", "gaet"},
"award_best_drawing": {"navn"}, "award_best_guess": {"navn"}, "award_survived": {"navn"},
"award_transformed": {"navn"}, "winner": {"vinder", "point"}, "play_again": set(),
}
class TegnLinesTests(SimpleTestCase):
def setUp(self):
self.lines = json.loads(LINES.read_text(encoding="utf-8"))
def test_all_events_present_with_variants(self):
self.assertEqual(set(self.lines), set(EVENTS))
for event, variants in self.lines.items():
self.assertGreaterEqual(len(variants), 5, event)
self.assertEqual(len(variants), len(set(variants)), f"dubletter i {event}")
def test_placeholders_tags_and_style(self):
for event, variants in self.lines.items():
for line in variants:
with self.subTest(event=event, line=line):
self.assertLessEqual(set(re.findall(r"\{(\w+)\}", line)), EVENTS[event])
self.assertLessEqual(set(re.findall(r"\[([a-z ]+)\]", line)), TAGS)
spoken = re.sub(r"\[[a-z ]+\]", "", line)
self.assertLessEqual(len(spoken.split()), 20)
self.assertNotRegex(line, r"—|–| - ", "ingen tankestreger")
self.assertNotRegex(line, r"\d", "tal skrives med bogstaver")
def test_rules_cards_follow_the_same_style(self):
from tegnoggaet.engine import RULES
for r in RULES:
for text in (r["title"], r["body"], r["say"]):
self.assertNotRegex(text, r"—|–| - |\d")
+10
View File
@@ -0,0 +1,10 @@
"""Temaerne til Tegn og Gæt ligger i data/themes.json (eksempel-ordene bruges til "Giv mig et forslag")."""
import json
from pathlib import Path
THEMES_FILE = Path(__file__).resolve().parent / "data" / "themes.json"
def load_themes() -> list[dict]:
return json.loads(THEMES_FILE.read_text(encoding="utf-8"))