# 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) | Opret nyt spil | | `POST /host/new` | vært | Opretter rum, 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`. ## 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`) | | `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. | | `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 { "code": "KLMT", "phase": "lobby", // lobby | category | intro | lie | 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/guess/reveal "answers": [{"id": "a1", "text": "ugler"}], // guess + reveal (i reveal med "likes"); egen løgn udeladt i 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" ], "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. ### 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 finalen `intro → lie → 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.