Skærmen følger speakeren, opsummering, musik, lydeffekter og drillerier
CI / test (push) Successful in 28s

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
This commit is contained in:
agw
2026-10-01 21:59:40 +02:00
parent 2da781e361
commit 16054a9b67
66 changed files with 1741 additions and 253 deletions
+60 -15
View File
@@ -31,7 +31,7 @@ Alle beskeder er JSON `{"action": "...", ...}`.
| `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` | fase `guess` (efter eget gæt) og `reveal`; slår til/fra; ikke eget svar |
| `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` |
@@ -43,7 +43,8 @@ Alle beskeder er JSON `{"action": "...", ...}`.
| --- | --- |
| `state` | `state`: snapshot (se nedenfor) |
| `joined` | `token`, `player_id`, `role` (`player`/`audience`) |
| `suggestion` | `text` |
| `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` | |
@@ -54,7 +55,7 @@ Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_a
```jsonc
{
"code": "KLMT",
"phase": "lobby", // lobby | category | lie | guess | reveal | scores | final
"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",
@@ -66,20 +67,34 @@ Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_a
"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"}]}, // kun i fase category
"question": {"category": "Dyreriget", "prompt": "En flok _____ kaldes en parlament."}, // lie/guess/reveal
"answers": [{"id": "a1", "text": "ugler"}], // guess + reveal; spillerens egen løgn er udeladt i guess
"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"], "guessed_by": ["p1", "p3"], "audience_votes": 2, "likes": 1,
"points": [{"player_id": "p2", "delta": 1000}]},
"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": [], "guessed_by": ["p4"], "audience_votes": 1, "likes": 0,
"points": [{"player_id": "p4", "delta": 1000}]}
"authors": [], "assisted": [], "guessed_by": ["p4"], "audience_votes": 1, "likes": 0,
"points": [{"player_id": "p4", "delta": 1000}], "audio": null}
],
"step_ms": 4000, // værtsskærmen viser ét trin pr. step_ms
"note": "Forklaring på faktummet" // vises under sandheden
"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
@@ -98,7 +113,34 @@ Fejlkoder: `unknown_room`, `unknown_token`, `name_taken`, `name_invalid`, `not_a
}
```
Værten får samme snapshot uden `me`, men med `"host": true`.
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.
### 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)
@@ -108,11 +150,14 @@ Værten får samme snapshot uden `me`, men med `"host": true`.
| 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) → `lie` (60 s, slutter når alle har løjet) → `guess` (30 s, slutter når alle spillere har gættet) → `reveal` (automatisk: antal trin × `step_ms` + 3 s; værten kan `skip`). Efter sidste spørgsmål i en runde: `scores` (10 s). Efter finalen: `final`.
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.
- Like: +100 til forfatteren pr. like (uden multiplikator).
- 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.