Files
weirsoe-party-protocol/docs/PROTOCOL.md
T
agw c3f9a5c8b4
CI / test (push) Successful in 1m32s
Fest-Z: eget emoji-sæt og nyt navn
Fest-spillet hedder nu Fest-Z (kategorierne "Fest-Z: …"), og telefonerne
får zoomer-emojis (💀 😭 🧢 🗿 💅 🍻) i stedet for familie-sættet. Serveren
sender sættet i tilstanden, så klienten altid viser det, spillet tager imod.
Reglerne og speakeren taler om slang og memes frem for pop-sladder.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-10-18 13:17:26 +02:00

13 KiB
Raw Blame History

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/<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.

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

{
  "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:

{
  "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.