# 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/` | vært | Speakerens intro til menuen: `{"url", "ms"}`, eller `{}` uden stemme | | `POST /host/new` | vært | `game=` + spillets indstillinger (+ `code` ved skift); redirect til `/host//` | | `GET /host//` | vært | Storskærm | | `GET /qr/.svg` | alle | QR-kode til `https:///?code=` | | `WS /ws/play//` | spillere/publikum | | | `WS /ws/host//` | 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[]`. | 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", // ".." – 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/.mp3`. Kategoriens jingle hedder `cat_` (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 `_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. I trin `t` (0..N-1) arbejder spiller `i` (rækkefølgen ved start) 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. Med et lige antal spillere ender kæden på en tegning; så er "sidste gæt" gættet før den. 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"}, // word/draw/guess "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 N); // 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`.