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:
@@ -71,6 +71,7 @@ INSTALLED_APPS = [
|
||||
"django.contrib.staticfiles",
|
||||
"channels",
|
||||
"fupogfakta",
|
||||
"tegnoggaet",
|
||||
"party",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class TegnOgGaetConfig(AppConfig):
|
||||
name = "tegnoggaet"
|
||||
verbose_name = "Tegn og Gæt"
|
||||
@@ -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"]}
|
||||
]
|
||||
@@ -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"}
|
||||
@@ -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."
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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"))
|
||||
Reference in New Issue
Block a user