From 151ec52cf057c43b542c6a550810358ab2fd8266 Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Tue, 20 Oct 2026 17:00:01 +0200 Subject: [PATCH] =?UTF-8?q?Tegn=20og=20G=C3=A6t:=20spilmotor,=20temaer=20o?= =?UTF-8?q?g=20speakerens=20replikker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- partyhub/settings.py | 1 + tegnoggaet/__init__.py | 0 tegnoggaet/apps.py | 6 + tegnoggaet/data/themes.json | 45 ++ tegnoggaet/engine.py | 756 ++++++++++++++++++++++++++++++++ tegnoggaet/narrator/lines.json | 158 +++++++ tegnoggaet/tests/__init__.py | 0 tegnoggaet/tests/test_engine.py | 342 +++++++++++++++ tegnoggaet/tests/test_lines.py | 49 +++ tegnoggaet/themes.py | 10 + 10 files changed, 1367 insertions(+) create mode 100644 tegnoggaet/__init__.py create mode 100644 tegnoggaet/apps.py create mode 100644 tegnoggaet/data/themes.json create mode 100644 tegnoggaet/engine.py create mode 100644 tegnoggaet/narrator/lines.json create mode 100644 tegnoggaet/tests/__init__.py create mode 100644 tegnoggaet/tests/test_engine.py create mode 100644 tegnoggaet/tests/test_lines.py create mode 100644 tegnoggaet/themes.py diff --git a/partyhub/settings.py b/partyhub/settings.py index 5e572d3..bb424e4 100644 --- a/partyhub/settings.py +++ b/partyhub/settings.py @@ -71,6 +71,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "channels", "fupogfakta", + "tegnoggaet", "party", ] diff --git a/tegnoggaet/__init__.py b/tegnoggaet/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tegnoggaet/apps.py b/tegnoggaet/apps.py new file mode 100644 index 0000000..824351c --- /dev/null +++ b/tegnoggaet/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TegnOgGaetConfig(AppConfig): + name = "tegnoggaet" + verbose_name = "Tegn og Gæt" diff --git a/tegnoggaet/data/themes.json b/tegnoggaet/data/themes.json new file mode 100644 index 0000000..ed36017 --- /dev/null +++ b/tegnoggaet/data/themes.json @@ -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"]} +] diff --git a/tegnoggaet/engine.py b/tegnoggaet/engine.py new file mode 100644 index 0000000..e7003a4 --- /dev/null +++ b/tegnoggaet/engine.py @@ -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"} diff --git a/tegnoggaet/narrator/lines.json b/tegnoggaet/narrator/lines.json new file mode 100644 index 0000000..0a293fd --- /dev/null +++ b/tegnoggaet/narrator/lines.json @@ -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." + ] +} diff --git a/tegnoggaet/tests/__init__.py b/tegnoggaet/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tegnoggaet/tests/test_engine.py b/tegnoggaet/tests/test_engine.py new file mode 100644 index 0000000..e2fad83 --- /dev/null +++ b/tegnoggaet/tests/test_engine.py @@ -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 diff --git a/tegnoggaet/tests/test_lines.py b/tegnoggaet/tests/test_lines.py new file mode 100644 index 0000000..646efe1 --- /dev/null +++ b/tegnoggaet/tests/test_lines.py @@ -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") diff --git a/tegnoggaet/themes.py b/tegnoggaet/themes.py new file mode 100644 index 0000000..02a7a38 --- /dev/null +++ b/tegnoggaet/themes.py @@ -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"))