Files
weirsoe-party-protocol/docs/PROTOCOL.md
T
agw 16054a9b67
CI / test (push) Successful in 28s
Skærmen følger speakeren, opsummering, musik, lydeffekter og drillerier
Faseskift venter, til speakeren er færdig med den aktuelle skærm, og
replikker til en skærm, der er væk, afspilles ikke. Ny kategori-intro med
en lyd pr. kategori, musik pr. fase med bløde overgange og lydeffekter til
alt, der flyver over skærmen. Efter afsløringen eksploderer løgne, ingen
valgte, og pointene tæller op. Hjerter gives kun under afsløringen og ses
live på storskærmen. Lyv for mig giver halve point. Narrede spilleres
telefon kan blive drillet med et fjollet billede og et buh. Alle lyd- og
billedfiler serveres med versionerede navne og caches af browseren.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
2026-10-01 21:59:40 +02:00

10 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)
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

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

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

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