Ved et lige antal spillere får kæden et trin mere: i trin 1 tegner ejeren sit eget ord, og rotationen starter et trin senere. Antal trin hedder nu steps og bruges i step-eventet, snapshot og afsløringen; self.n er antal spillere. Snapshot får step.own til trin 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
385 lines
26 KiB
Markdown
385 lines
26 KiB
Markdown
# 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/<spil>` | vært | Speakerens intro til menuen: `{"url", "ms"}`, eller `{}` uden stemme |
|
||
| `POST /host/new` | vært | `game=<spil>` + spillets indstillinger (+ `code` ved skift); 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`.
|
||
|
||
## 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[<nøgle>]`.
|
||
|
||
| 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", // "<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`.
|
||
|
||
## 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: trin 0 er et ord, ulige trin tegnes, lige trin gættes, og kæden
|
||
slutter altid på et gæt. Ulige N: N trin; i trin `t` arbejder spiller `i` (rækkefølgen ved start) på kæde
|
||
`(i - t) mod N`. Lige N: N+1 trin; i trin 1 tegner ejeren sit eget ord (`step.own: true`), og i trin `t >= 1`
|
||
arbejder spiller `i` på kæde `(i - (t-1)) mod N`. Ingen gætter på sin egen kæde.
|
||
|
||
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", "own": false}, // word/draw/guess; own = tegn dit eget ord
|
||
"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 = antal trin);
|
||
// 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`.
|
||
|
||
## Spionen
|
||
|
||
Oprettes med `game=spion` på `/host/new` med `rounds` = `3` | `4` | `5` (standard 4) og `rules` = `1` | `0`.
|
||
Samme endpoints, samme `join`/`resume`/`ping`/`react`, og samme `joined`/`state`/`error`. Snapshot har `"game": "spion"`.
|
||
4-10 spillere; dem der joiner efter start, bliver publikum. Temaerne er fælles med Tegn og Gæt (`party/data/themes.json`);
|
||
Spionen bruger kun de konkrete navneord på mindst tre bogstaver (`party/themes.py`, `spy_themes()`).
|
||
|
||
En runde: alle undtagen spionen (to spioner fra otte spillere) ser et hemmeligt ord; spionen ser kun temaet. Ingen er
|
||
spion to runder i træk, hvis det kan undgås. Faser: `lobby → rules` (kun rummets første spil) `→ clue` (40 s) `→ clues`
|
||
(stikordene ét ad gangen på storskærmen, tilfældig rækkefølge) `→ talk` (60 s) `→ vote` (30 s) `→ verdict` (7 s)
|
||
`→ guess` (30 s, kun hvis den anklagede er spion) `→ result` (10 s), så næste runde eller `final`. `clue` og `vote`
|
||
slutter, når alle forbundne spillere er færdige; alle tidsfaser kan springes over af værten. Spillet hænger aldrig:
|
||
manglende stikord og stemmer løber ud, går den anklagede spion under gættet, slutter runden, og er der færre end fire
|
||
spillere tilbage, slutter spillet efter runden.
|
||
|
||
**Stikord:** ét ord, højst 20 tegn, ikke temaet. For dem, der kender ordet, heller ikke ordet selv, en bøjning af det
|
||
(en/et/er/erne/ene/e/s, også med fordoblet konsonant og uden slut-e), et ord, hvor det står inde i (`hundehus`), eller et
|
||
stikord, en anden har skrevet i runden. Spionens stikord holdes hverken op mod ordet eller de andres (en afvisning ville
|
||
røbe dem). Store/små bogstaver, mellemrum og bindestreger tæller ikke.
|
||
|
||
**Afstemning:** ikke på sig selv; flest stemmer bliver anklaget; lighed eller ingen stemmer = ingen anklages, og
|
||
spionerne slipper. Publikum kan stemme for sjov (`audience` i `votes`, tæller ikke). Med to spioner er den anklagede
|
||
fanget og får gættet; den anden slap. **Spionens gæt** er rigtigt, hvis det er ordet med de samme regler som stikord,
|
||
plus små stavefejl (ingen under fire bogstaver, én op til syv, to derover).
|
||
|
||
**Point:** +100 for at stemme på en spion; +300 til en spion, der slap; +200 til en fanget spion, der gætter ordet.
|
||
Priser: Bedste løgner (flest point som spion), Skarpeste øje (flest rigtige stemmer), Mest mistænkt (flest stemmer).
|
||
|
||
**Spiller (`/ws/play/`)**
|
||
|
||
| action | felter | hvornår |
|
||
| --- | --- | --- |
|
||
| `start` | | VIP'en i lobbyen |
|
||
| `submit_clue` | `text` (ét ord, 1-20 tegn) | fase `clue`; må skiftes, til fasen slutter |
|
||
| `vote` | `player_id` | fase `vote`; må skiftes; publikum også |
|
||
| `spy_guess` | `text` (1-30 tegn) | fase `guess`, kun den anklagede spion |
|
||
| `react`, `play_again` | | som i Tegn og Gæt |
|
||
|
||
Fejlkoder ud over Fup og Fakta: `clue_invalid`, `clue_is_word`, `clue_is_theme`, `clue_taken`, `bad_vote`,
|
||
`not_in_round`, `guess_invalid`.
|
||
|
||
**Hemmeligheden:** ordet står kun i `me.card` til dem, der kender det, aldrig til spionen, publikum eller storskærmen før
|
||
`result`. Hvem der er spion står ingen steder før dommen: ikke i spillerlisten, ikke i rækkefølger eller tider.
|
||
`votes.accused_is_spy` får storskærmen under `verdict` (den afslører det i fasens sidste sekunder); telefonerne først i
|
||
`guess`. Telefonerne får ingen stikord i `clues` (de kigger på storskærmen). Speakeren siger aldrig ordet eller spionen
|
||
før `result`; dommens replikker spørger kun.
|
||
|
||
```jsonc
|
||
{
|
||
"game": "spion",
|
||
"phase": "vote", // lobby | rules | clue | clues | talk | vote | verdict | guess | result | final
|
||
"round": {"number": 2, "of": 4, "name": "Runde 2 af 4"},
|
||
"players": [{"id": "p1", "name": "Asger", "color": "#ff4d6d", "score": 100, "connected": true, "vip": true,
|
||
"done": false, "in_round": true}], // done = har skrevet stikord (clue) / stemt (vote)
|
||
"theme": {"name": "Transport", "icon": "🚂"},
|
||
"spies": 1, // hvor mange, ikke hvem
|
||
"clues": {"items": [{"player_id": "p3", "text": "vinger"}, {"player_id": "p1", "text": null}]},
|
||
// fase clues (kun storskærmen har items): {"started_at", "intro_ms", "clue_ms", "items"}
|
||
"votes": {"counts": [{"player_id": "p2", "votes": 4}], "audience": [{"player_id": "p2", "votes": 1}],
|
||
"accused": "p2", "accused_is_spy": true, "by": []}, // verdict/guess/result; by (hvem stemte på hvem) i result
|
||
"result": {"spies": ["p2"], "spy_names": ["Sus"], "word": "flyvemaskine", "caught": "p2",
|
||
"guess": {"text": "helikopter", "correct": false}, "spy_won": false, "deltas": {"p1": 100}},
|
||
"me": {"id": "p1", "role": "player", "card": {"spy": false, "theme": "Transport", "word": "flyvemaskine"},
|
||
"clue": "vinger", "vote": "p2", "can_guess": false}
|
||
}
|
||
```
|