Files
weirsoe-party-protocol/docs/PROTOCOL.md
T
agwandClaude Opus 5.5 5de261acd7 Tegn og Gæt: kæden slutter altid på et gæt
Ved et lige antal spillere får kæden et trin mere: i trin 1 tegner
ejeren sit eget ord, og rotationen starter et trin senere. Antal trin
hedder nu steps og bruges i step-eventet, snapshot og afsløringen;
self.n er antal spillere. Snapshot får step.own til trin 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:59:59 +02:00

385 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Protokol: server ↔ klient
Al spilkommunikation går over én WebSocket pr. skærm. Serveren er autoritativ: klienten sender **handlinger** og modtager **hele tilstanden** (et snapshot tilpasset modtagerens rolle) efter hver ændring. Klienten gemmer intet spil-state ud over sin `token` og tegner altid fra seneste snapshot.
## Endpoints
| Sti | Hvem | Hvad |
| --- | --- | --- |
| `GET /` | spillere/publikum | Join-side (`?code=ABCD` udfylder koden) |
| `GET /host/` | vært (Authentik) | Spilmenuen (`?code=ABCD`: vælg næste spil i et kørende rum) |
| `GET /host/intro/<spil>` | vært | Speakerens intro til menuen: `{"url", "ms"}`, eller `{}` uden stemme |
| `POST /host/new` | vært | `game=<spil>` + spillets indstillinger (+ `code` ved skift); redirect til `/host/<CODE>/` |
| `GET /host/<CODE>/` | vært | Storskærm |
| `GET /qr/<CODE>.svg` | alle | QR-kode til `https://<host>/?code=<CODE>` |
| `WS /ws/play/<CODE>/` | spillere/publikum | |
| `WS /ws/host/<CODE>/` | vært (Authentik) | |
Koder er 4 store bogstaver (`BCDFGHJKLMNPQRSTVWXZ`), fx `KLMT`.
## Spillene og skift af spil
Spillene står i ét register, `party/games.py`: nøgle, navn, tagline, min/max spillere, ca. minutter, trin, ikon,
spillets egne skærme (JS), indstillinger og en funktion, der laver rummet. Menuen, `/host/new`, storskærmen og
telefonen læser derfra; et nyt spil registrerer sig dér, og dets JS melder sig i `window.WPPGameUI[<nøgle>]`.
| Spil | `game` | Indstillinger på `/host/new` |
| --- | --- | --- |
| Fup og Fakta | `fof` | `pack` = `alle` \| `familie` \| `nørd` \| `arbejde` \| `fest` |
| Tegn og Gæt | `tegn` | `rules` = `1` (vis reglerne først) \| `0` |
Uden `game` oprettes det første spil i registret (Fup og Fakta); et ukendt spil giver 400, ukendte indstillinger
giver spillets standard. Hvert snapshot, til storskærm og telefon, har `"game"`.
**Skift af spil:** podiet har "Vælg andet spil", som går til `/host/?code=ABCD`. Menuen sender `code` med, og så
laves det nye spil under samme kode: spillerne kommer med i den rækkefølge de kom, med samme navn og `token`; er der
flere end det nye spils max, bliver resten publikum. Point starter forfra. Telefonerne bliver på deres WebSocket og
får det nye spils lobby som et almindeligt `state`; de skal ikke joine igen, og `resume` med det gamle token virker.
Det gamle rum lukkes. Mens værten er i menuen, viser telefonerne stadig podiet.
## Klient → server
Alle beskeder er JSON `{"action": "...", ...}`.
**Spiller/publikum (`/ws/play/`)**
| action | felter | hvornår |
| --- | --- | --- |
| `join` | `name` (1-12 tegn) | første gang. Svar: `joined`. Er der 8 spillere, eller er spillet i gang, bliver man publikum. |
| `resume` | `token` | ved genforbindelse (token fra `localStorage["wpp:"+code]`). Ukendt token → `error` `unknown_token`, klienten viser join-formularen igen. |
| `start` | | VIP (første spiller) starter fra lobby |
| `choose_category` | `category_id` | kun den valgte spiller i fasen `category` |
| `submit_lie` | `text` (1-40 tegn) | fase `lie` |
| `lie_for_me` | | fase `lie`; svar: `suggestion` |
| `guess` | `answer_id` | fase `guess`; spillere og publikum, én gang |
| `like` | `answer_id` | kun fase `reveal`; slår til/fra; ikke eget svar og ikke sandheden (`answer_invalid`) |
| `react` | `emoji` (en af tilstandens `reactions`: `😂 😱 👏 🤯 🙈 🔥`, i fest-spil `💀 😭 🧢 🗿 💅 🍻`) | fase `present`, `guess`, `reveal`; må spammes (serveren dropper stille over 6/s pr. person). Intet svar; storskærmen får `react`. Tælles på det svar, der er i fokus på storskærmen. |
| `play_again` | | VIP i fasen `final` |
| `ping` | | svar `pong` |
**Vært (`/ws/host/`)**: `start`, `skip` (afslut nuværende fase nu), `play_again`, `kick` (`player_id`), `ping`.
## Server → klient
| type | felter |
| --- | --- |
| `state` | `state`: snapshot (se nedenfor) |
| `joined` | `token`, `player_id`, `role` (`player`/`audience`) |
| `suggestion` | `text` (svar på `lie_for_me`) |
| `tease` | `text`, `image` (URL), `sound` (URL eller `null`) – kun til den spiller, der lige faldt for en løgn, i det øjeblik stemplet "LØGN" lander på storskærmen. Telefonen viser et fuldskærms-drilleri (~2,5 s, lukkes ved tryk), spiller `boo` og derefter `sound`, hvis lyden er låst op. Højst 2 pr. spørgsmål. |
| `react` | `emoji`, `color` (afsenderens farve), `answer_id` (svaret i fokus eller `null`) – kun til storskærmen; emojien flyver op over skærmen. |
| `error` | `code`, `message` (dansk, kan vises direkte) |
| `pong` | |
Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_allowed`, `wrong_phase`, `lie_is_truth`, `lie_invalid`, `already_done`, `answer_invalid`, `too_few_players`.
## Snapshot
```jsonc
{
"game": "fof", // spillets nøgle i party/games.py
"code": "KLMT",
"phase": "lobby", // lobby | category | intro | lie | present | guess | reveal | scores | final
"server_time": 1790000000000,// ms epoch; klienten udregner sin forskydning
"deadline": 1790000060000, // ms epoch eller null
"join_url": "https://party.weircon.dk/?code=KLMT",
"min_players": 2, "max_players": 8,
"round": {"number": 1, "name": "Runde 1", "multiplier": 1,
"question_number": 2, "questions_in_round": 3}, // null i lobby/final
"players": [
{"id": "p1", "name": "Asger", "color": "#ff4d6d", "score": 1500,
"connected": true, "vip": true, "done": false} // done = har løjet/gættet i nuværende fase
],
"audience_count": 0,
"category": {"chooser_id": "p2", "options": [{"id": 3, "name": "Dyreriget"}],
"chosen_id": null}, // kun i fase category; id når der er valgt (ellers null)
"intro": {"category": "Dyreriget", "slug": "dyreriget", "final": false}, // kun i fase intro
"question": {"category": "Dyreriget", "prompt": "En flok _____ kaldes et parlament.",
"slug": "dyreriget"}, // lie/present/guess/reveal
"present": { // kun i fase present (runde 2 og finalen)
"started_at": 1790000000000, // ms epoch; null = serveren laver stadig lyd
"intro_ms": 1500, "steps_ms": [3100, 2900], // speakerens intro, så ét svar ad gangen
"ids": ["a3", "a1"], // oplæsningens rækkefølge (steps_ms/audio følger den)
"intro_audio": {"url": "…", "ms": 1200}, // eller null
"audio": [{"url": "…", "ms": 2600, "text": "Krager. Det giver da mening…"}] // pr. svar eller null
},
"answers": [{"id": "a1", "text": "ugler"}], // present + guess + reveal (i reveal med "likes"); egen løgn udeladt i present/guess
"reveal": { // kun i fase reveal
"steps": [ // løgne først (kun dem der blev valgt), sandheden sidst
{"answer_id": "a3", "text": "krager", "is_truth": false, "house": false,
"authors": ["p2", "p5"], "assisted": ["p5"], // assisted = forfattere der brugte "Lyv for mig" uændret
"guessed_by": ["p1", "p3"], "audience_votes": 2, "likes": 1,
"points": [{"player_id": "p2", "delta": 1000}, {"player_id": "p5", "delta": 500}],
"audio": {"url": "/media/tts/…mp3", "ms": 3100, "text": "…"}}, // speakerens replik til trinnet eller null
{"answer_id": "a1", "text": "ugler", "is_truth": true, "house": false,
"authors": [], "assisted": [], "guessed_by": ["p4"], "audience_votes": 1, "likes": 0,
"points": [{"player_id": "p4", "delta": 1000}], "audio": null}
],
"all": [ // opsummeringen: ALLE svar, også dem ingen valgte
{"id": "a7", "text": "skildpadder", "is_truth": false, "house": true,
"authors": [], "assisted": [], "picked": false, "likes": 0, // picked=false → svaret "eksploderer"
"reactions": 3} // emojis sendt, mens svaret var i fokus
],
"summary": { // opsummeringens tidsplan i ms fra halens start, eller null
"start_ms": 1200, "explode_at": 3900, "points_at": 7200, // ikke-valgte eksploderer fra explode_at (250 ms imellem); point tælles fra points_at
"audio": [{"url": "…", "ms": 2100, "at": 1600}] // speakerens "lad os rydde op" / "så til pointene", afspilles ved at
},
"deltas": [{"player_id": "p1", "delta": 1500}], // point i dette spørgsmål pr. spiller (inkl. hjerter)
"step_ms": 4000, // standardlængde pr. trin (bagudkompatibel)
"steps_ms": [4200, 3900], // faktisk længde pr. trin (følger speakerens replik)
"tail_ms": 8000, // opsummeringen efter sidste trin
"started_at": 1790000001000, // ms epoch; null = serveren laver stadig lyd (vis "trommehvirvel")
"note": "Forklaring på faktummet", // vises under sandheden
"note_audio": {"url": "/media/tts/…mp3", "ms": 5200} // forklaringen læst op (starter med sandhedens trin) eller null
},
"deltas": [{"player_id": "p1", "delta": 1500}], // fase scores: point i den netop afsluttede runde
"final": { // kun i fase final
"ranking": [{"player_id": "p1", "score": 9000, "place": 1}],
"awards": [{"title": "Største løgner", "player_id": "p2", "detail": "narrede 7 gange"}]
},
"me": { // kun til spiller/publikum
"id": "p1", "name": "Asger", "role": "player", "vip": true, "score": 1500,
"lie": "krager", // egen løgn i nuværende spørgsmål eller null
"own_answer_id": "a3", // id på eget svar i guess/reveal (kan ikke gættes eller likes)
"guess": "a3", // eget gæt eller null
"likes": ["a2"],
"is_chooser": false,
"last_delta": 1500 // point fra seneste spørgsmål (reveal/scores)
}
}
```
Værten får samme snapshot uden `me`, men med `"host": true` og desuden:
```jsonc
{
"phase_key": "1.0.lie", // "<runde>.<spørgsmål>.<fase>" – identificerer den aktuelle skærm
"narration": [ // speakerens seneste replikker (rullende vindue)
{"id": 41, "url": "/media/tts/…mp3", "ms": 2300, "at": 1790000000000, "text": "…",
"key": "1.0.intro"} // den skærm (phase_key) replikken hører til; er skærmen væk, springes den over
]
}
```
Klienten afspiller nye replikker (`id` > sidst sete) i rækkefølge; ved første snapshot kun dem fra de sidste ~4 s.
Afsløringens replikker ligger i `reveal.steps[].audio` / `reveal.note_audio` og startes af tidslinjen.
### Afsløringens tidslinje
Trin `i` starter ved `started_at + sum(steps_ms[0..i-1])`. Inden for et trin vises først svaret, så hvem der valgte det
(~28 % / højst 1,1 s), så stemplet (~55 % / højst 2,3 s). Efter sidste trin kører opsummeringen i `tail_ms`:
alle svar (`all`), de ikke-valgte eksploderer, og `deltas` tælles op. `deadline` = slutningen af halen.
Mens `started_at` er `null`, venter serveren på speakerens lydfiler (højst ~10 s) og sætter så tiderne.
Opsummeringen følger `summary`: speakeren siger noget i stil med "dem her troede ingen på, lad os rydde op",
så eksploderer de ikke-valgte svar, så "og så til pointene", og først derefter tælles pointene op.
### Oplæsningens tidslinje (fasen `present`)
I runde 2 og finalen læser speakeren svarene op, før der gættes: først `intro_ms`, så svar `i` fra
`started_at + intro_ms + sum(steps_ms[0..i-1])`. Hvert svar præsenteres overbevisende (med hjernen en
kommentar pr. svar, som aldrig må afsløre, hvad der er sandt). Storskærm og telefoner viser svarene i samme takt;
der kan først gættes i `guess`. `deadline` = slutningen + 1 s.
### Lyd- og billedfiler
Templates får `assets` = `{"sfx": {navn: url}, "tease": [url]}` indlejret med
`{{ assets|json_script:"wpp-assets" }}`. URL'erne er versionerede (hashede filnavne i drift → caches for altid);
klienten slår alle lyde op her og falder tilbage til `/static/party/sfx/<navn>.mp3`. Kategoriens jingle hedder
`cat_<slug>` (fx `cat_mad_og_drikke`); mangler den, bruges `category`.
## Spillets gang (Fibbage-klassisk)
| Runde | Spørgsmål | Point: rigtigt gæt / pr. narret spiller |
| --- | --- | --- |
| Runde 1 | 3 | 1000 / 500 |
| Runde 2 | 3 | 2000 / 1000 |
| Finale | 1 | 3000 / 1500 |
For hvert spørgsmål: `category` (15 s; skiftende spiller vælger blandt 4; i finalen springes fasen over) → `intro` (≈5 s; storskærmen præsenterer kategorien med sin jingle, speakeren siger kategorien, og serveren venter til speakeren er færdig) → `lie` (60 s, speakeren læser spørgsmålet op; slutter når alle har løjet) → `guess` (30 s, slutter når alle spillere har gættet) → `reveal` (automatisk: `sum(steps_ms)` + `tail_ms`; værten kan `skip`). Efter sidste spørgsmål i en runde: `scores` (10 s). Efter finalen: `final`.
Rækkefølge: `category → intro → lie → guess → reveal (→ scores)`; i runde 2 `category → intro → lie → present → guess → reveal (→ scores)`; i finalen `intro → lie → present → guess → reveal → final`.
- Løgn lig sandheden (eller næsten lig) afvises med `lie_is_truth`. Ens løgne slås sammen, og alle forfattere får point.
- Spillere, der ikke løj i tide, får ingen løgn; huset fylder op med lokkesvar, så der er mindst `max(4, spillere+1)` svar.
- Man kan ikke vælge sin egen løgn.
- Hjerter (like) gives kun under `reveal`: +100 til forfatteren pr. hjerte (uden multiplikator); ikke til sandheden og ikke til eget svar.
- "Lyv for mig": bruger man forslaget uændret, får man kun halve point for at narre andre (forfatteren står i `assisted`).
- Publikum: forfatteren får `round(500 × multiplikator × andel af publikum narret)`.
- Spil igen: samme spillere, point nulstilles.
## Fest-Z (pakken "fest", 18+)
Oprettes med `pack=fest` på `/host/new`. Snapshot har altid `fest` (bool). I et fest-spil desuden:
- `players[].sips`: tår i alt.
- `reveal.steps[].sips`: `[{player_id, n}]`; `n` = rundens gange (×1/×2/×3, finalen vises som shots). `everyone_drinks: true` på sandhedens trin, når ingen fandt den.
- `reveal.sip_deltas`: `[{player_id, n}]` for spørgsmålet (opsummeringen tæller tår op i stedet for point).
- `me.sips`, `me.last_sips` (sidstnævnte kun i `reveal`).
- `final.awards` får `kind: "sips"` ("Tørstigste").
- Reglerne (`rules.items`) er fest-udgaven, og speakeren bruger `<begivenhed>_fest`-replikker og `FEST_VOICE_ID`.
## Tegn og Gæt
Oprettes med `game=tegn` på `/host/new` (se [Spillene og skift af spil](#spillene-og-skift-af-spil)).
Samme endpoints, samme `join`/`resume`/`ping`/`react`, og samme `joined`/`state`/`error`. Snapshot har `"game": "tegn"`.
3-10 spillere; dem der joiner efter start, bliver publikum (de kan give hjerter).
Med N spillere er der N kæder, én pr. spiller: trin 0 er et ord, ulige trin tegnes, lige trin gættes, og kæden
slutter altid på et gæt. Ulige N: N trin; i trin `t` arbejder spiller `i` (rækkefølgen ved start) på kæde
`(i - t) mod N`. Lige N: N+1 trin; i trin 1 tegner ejeren sit eget ord (`step.own: true`), og i trin `t >= 1`
arbejder spiller `i` på kæde `(i - (t-1)) mod N`. Ingen gætter på sin egen kæde.
Faser: `lobby → rules` (kun rummets første spil) `→ theme` (20 s) `→ word` (30 s) `→ draw` (75 s) / `guess` (30 s) skiftevis
`→ reveal → final`. Et trin slutter, når alle forbundne spillere har afleveret, eller når tiden er gået. Så fyldes det
manglende ud med spillerens kladde, ellers et eksempel-ord fra temaet, en tom tegning eller `???`. Spillet hænger aldrig.
**Spiller (`/ws/play/`)**
| action | felter | hvornår |
| --- | --- | --- |
| `start` | | VIP'en (første spiller) i lobbyen |
| `choose_theme` | `theme_id` | VIP'en i fasen `theme` |
| `submit_word` | `text` (1-30 tegn) | fase `word` |
| `suggest` | | fase `word`; svar `suggestion` med et eksempel-ord, som ingen andre har skrevet |
| `submit_drawing` | `strokes` (se nedenfor) | fase `draw` |
| `submit_guess` | `text` (1-30 tegn) | fase `guess` |
| `draft` | `strokes` (fase `draw`) eller `text` | undervejs; bruges, hvis tiden løber ud. Intet svar, og en ugyldig kladde ignoreres stille |
| `like` | `entry_id` | fase `reveal`; slår til/fra; kun tegninger og gæt, som storskærmen har vist, og ikke ens egne. +100 til den, der lavede bidraget |
| `react` | `emoji` | fase `reveal`; som i Fup og Fakta (`answer_id` i `react` er bidraget i fokus) |
| `play_again` | | VIP'en i fasen `final` |
**Vært (`/ws/host/`)**: som Fup og Fakta, plus `choose_theme` (`theme_id`). `skip` i `reveal` springer til næste kæde
(efter sidste kæde: `final`); i de andre faser afslutter det fasen.
**Tegninger** er vektorstrøg, ikke billeder: `[{"color": "#e63946", "width": 0.008, "points": [[x, y], ...]}]`.
Koordinaterne er normaliserede `0..1` på et lærred i forholdet 4:3, `width` er en andel af bredden (`0.002..0.06`).
Farven skal være en af `#1d1b2e #e63946 #2a7de1 #2bb673`. Serveren afviser (`drawing_invalid`) mere end 400 strøg,
mere end 20.000 punkter i alt, mere end 64 KB JSON, punkter uden for lærredet og alt andet, der ikke har formen ovenfor.
Punkterne rundes til 4 decimaler.
**Server → klient**, ud over Fup og Fakta: `drawings` (kun til storskærmen, én gang pr. afsløring og ved genforbindelse,
før `state`): `{"type": "drawings", "reveal_id": 1, "drawings": {"c0e1": [strøg...]}}`. Tegningerne følger ikke med i
snapshottet, så et hjerte ikke sender dem alle igen.
Fejlkoder ud over Fup og Fakta: `word_invalid`, `guess_invalid`, `drawing_invalid`.
```jsonc
{
"game": "tegn",
"phase": "draw", // lobby | rules | theme | word | draw | guess | reveal | final
"min_players": 3, "max_players": 10,
"round": null,
"players": [{"id": "p1", "name": "Asger", "color": "#ff4d6d", "score": 0, "connected": true, "vip": true,
"done": false}], // done = har afleveret i trinnet
"rules": {...}, // fase rules, som Fup og Fakta
"theme": {"options": [{"id": "dyr", "name": "Dyr", "icon": "🐘"}], "chosen_id": null}, // fase theme
"theme": {"name": "Dyr", "icon": "🐘"}, // word/draw/guess/reveal
"step": {"index": 1, "number": 2, "of": 5, "kind": "draw", "own": false}, // word/draw/guess; own = tegn dit eget ord
"reveal": {
"id": 1,
"started_at": 1790000000000, // ms epoch; null = serveren laver stadig lyd
"plan": [ // tidslinjen: pr. kæde en intro (entry -1), hvert bidrag, og slutningen (entry = antal trin);
// audio (speakerens replik) kun til storskærmen, da slutningen nævner sidste gæt
{"chain": 0, "entry": -1, "ms": 2500, "audio": null},
{"chain": 0, "entry": 0, "ms": 3000, "audio": null},
{"chain": 0, "entry": 5, "ms": 4500, "audio": {"url": "…", "ms": 3200, "text": "Fra kat til sofa."}}
],
"chains": [{"owner": "p1", "survived": false, // survived: sidste gæt = første ord (normaliseret)
"entries": [{"id": "c0e0", "kind": "word", "by": "p1", "text": "kat", "auto": false, "likes": 0},
{"id": "c0e1", "kind": "drawing", "by": "p2", "text": null, "auto": false, "likes": 2}]}]
},
"final": {"ranking": [...], "awards": [{"title": "Bedste tegning", "player_id": "p2", "detail": "3 hjerter",
"kind": "drawing"}]}, // kind: drawing | guess | survived | transformed
"me": {
"id": "p1", "name": "Asger", "role": "player", "vip": true, "score": 0,
"can_choose": false, // fase theme: VIP'en vælger
"likes": ["c0e1"], // fase reveal
"task": { // word/draw/guess; null for publikum
"kind": "draw", "done": false,
"from": "p5", // hvem det forrige bidrag er fra
"text": "giraf", // det, der skal tegnes (fase draw); ellers null
"drawing": null, // tegningen, der skal gættes (fase guess); ellers null
"mine": null, // eget ord/gæt, når det er afleveret
"draft": null // serverens kladde (genindlæst side)
}
}
}
```
Telefonen ser kun sit eget trin (forrige ord eller forrige tegning), aldrig resten af kæden før afsløringen. Under
afsløringen får telefonerne kun de bidrag, storskærmen har vist (og `survived` først, når kædens slutning er vist;
indtil da `null`); rummet sender et nyt snapshot, hver gang et trin begynder. Storskærmen får hele afsløringen og
styrer selv tempoet ud fra `started_at` og `plan[].ms`.
Point: +100 pr. hjerte til den, der lavede bidraget, og +500 til ejeren af en kæde, der overlevede. Priser:
"Bedste tegning" og "Bedste gæt" (flest hjerter, ved lighed det første i afsløringen), "Kæden der overlevede" og
"Største forvandling" (mindst lighed mellem første ord og sidste gæt). Temaerne står i `party/data/themes.json`
og speakerens replikker i `tegnoggaet/narrator/lines.json`.
## Spionen
Oprettes med `game=spion` på `/host/new` med `rounds` = `3` | `4` | `5` (standard 4) og `rules` = `1` | `0`.
Samme endpoints, samme `join`/`resume`/`ping`/`react`, og samme `joined`/`state`/`error`. Snapshot har `"game": "spion"`.
4-10 spillere; dem der joiner efter start, bliver publikum. Temaerne er fælles med Tegn og Gæt (`party/data/themes.json`);
Spionen bruger kun de konkrete navneord på mindst tre bogstaver (`party/themes.py`, `spy_themes()`).
En runde: alle undtagen spionen (to spioner fra otte spillere) ser et hemmeligt ord; spionen ser kun temaet. Ingen er
spion to runder i træk, hvis det kan undgås. Faser: `lobby → rules` (kun rummets første spil) `→ clue` (40 s) `→ clues`
(stikordene ét ad gangen på storskærmen, tilfældig rækkefølge) `→ talk` (60 s) `→ vote` (30 s) `→ verdict` (7 s)
`→ guess` (30 s, kun hvis den anklagede er spion) `→ result` (10 s), så næste runde eller `final`. `clue` og `vote`
slutter, når alle forbundne spillere er færdige; alle tidsfaser kan springes over af værten. Spillet hænger aldrig:
manglende stikord og stemmer løber ud, går den anklagede spion under gættet, slutter runden, og er der færre end fire
spillere tilbage, slutter spillet efter runden.
**Stikord:** ét ord, højst 20 tegn, ikke temaet. For dem, der kender ordet, heller ikke ordet selv, en bøjning af det
(en/et/er/erne/ene/e/s, også med fordoblet konsonant og uden slut-e), et ord, hvor det står inde i (`hundehus`), eller et
stikord, en anden har skrevet i runden. Spionens stikord holdes hverken op mod ordet eller de andres (en afvisning ville
røbe dem). Store/små bogstaver, mellemrum og bindestreger tæller ikke.
**Afstemning:** ikke på sig selv; flest stemmer bliver anklaget; lighed eller ingen stemmer = ingen anklages, og
spionerne slipper. Publikum kan stemme for sjov (`audience` i `votes`, tæller ikke). Med to spioner er den anklagede
fanget og får gættet; den anden slap. **Spionens gæt** er rigtigt, hvis det er ordet med de samme regler som stikord,
plus små stavefejl (ingen under fire bogstaver, én op til syv, to derover).
**Point:** +100 for at stemme på en spion; +300 til en spion, der slap; +200 til en fanget spion, der gætter ordet.
Priser: Bedste løgner (flest point som spion), Skarpeste øje (flest rigtige stemmer), Mest mistænkt (flest stemmer).
**Spiller (`/ws/play/`)**
| action | felter | hvornår |
| --- | --- | --- |
| `start` | | VIP'en i lobbyen |
| `submit_clue` | `text` (ét ord, 1-20 tegn) | fase `clue`; må skiftes, til fasen slutter |
| `vote` | `player_id` | fase `vote`; må skiftes; publikum også |
| `spy_guess` | `text` (1-30 tegn) | fase `guess`, kun den anklagede spion |
| `react`, `play_again` | | som i Tegn og Gæt |
Fejlkoder ud over Fup og Fakta: `clue_invalid`, `clue_is_word`, `clue_is_theme`, `clue_taken`, `bad_vote`,
`not_in_round`, `guess_invalid`.
**Hemmeligheden:** ordet står kun i `me.card` til dem, der kender det, aldrig til spionen, publikum eller storskærmen før
`result`. Hvem der er spion står ingen steder før dommen: ikke i spillerlisten, ikke i rækkefølger eller tider.
`votes.accused_is_spy` får storskærmen under `verdict` (den afslører det i fasens sidste sekunder); telefonerne først i
`guess`. Telefonerne får ingen stikord i `clues` (de kigger på storskærmen). Speakeren siger aldrig ordet eller spionen
før `result`; dommens replikker spørger kun.
```jsonc
{
"game": "spion",
"phase": "vote", // lobby | rules | clue | clues | talk | vote | verdict | guess | result | final
"round": {"number": 2, "of": 4, "name": "Runde 2 af 4"},
"players": [{"id": "p1", "name": "Asger", "color": "#ff4d6d", "score": 100, "connected": true, "vip": true,
"done": false, "in_round": true}], // done = har skrevet stikord (clue) / stemt (vote)
"theme": {"name": "Transport", "icon": "🚂"},
"spies": 1, // hvor mange, ikke hvem
"clues": {"items": [{"player_id": "p3", "text": "vinger"}, {"player_id": "p1", "text": null}]},
// fase clues (kun storskærmen har items): {"started_at", "intro_ms", "clue_ms", "items"}
"votes": {"counts": [{"player_id": "p2", "votes": 4}], "audience": [{"player_id": "p2", "votes": 1}],
"accused": "p2", "accused_is_spy": true, "by": []}, // verdict/guess/result; by (hvem stemte på hvem) i result
"result": {"spies": ["p2"], "spy_names": ["Sus"], "word": "flyvemaskine", "caught": "p2",
"guess": {"text": "helikopter", "correct": false}, "spy_won": false, "deltas": {"p1": 100}},
"me": {"id": "p1", "role": "player", "card": {"spy": false, "theme": "Transport", "word": "flyvemaskine"},
"clue": "vinger", "vote": "p2", "can_guess": false}
}
```