Start forfra: server-autoritativ spilmotor over WebSocket

Den tidligere version (Angular-SPA, REST og polling) er gemt på branchen
legacy. Denne version samler alle regler i en ren Python-spilmotor
(fupogfakta/engine.py), holder rummene i hukommelsen i én proces og sender
hele spiltilstanden pr. rolle over én WebSocket. Vært-login sker via
Authentik forward-auth i NPMplus.

Claude-Session: https://claude.ai/code/session_01JmhJG6JnhXfzNiKv8f2vHu
This commit is contained in:
agw
2026-10-01 20:40:00 +02:00
parent d86941fef8
commit 9a53d8fe04
198 changed files with 3064 additions and 30073 deletions
+116
View File
@@ -0,0 +1,116 @@
# 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` | fase `guess` (efter eget gæt) og `reveal`; slår til/fra; ikke eget svar |
| `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` |
| `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 | 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"}]}, // 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
"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}]},
{"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}]}
],
"step_ms": 4000 // værtsskærmen viser ét trin pr. step_ms
},
"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", "role": "player", "vip": true,
"lie": "krager", // egen løgn i nuværende spørgsmål eller null
"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`.
## 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) → `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`.
- 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).
- Publikum: forfatteren får `round(500 × multiplikator × andel af publikum narret)`.
- Spil igen: samme spillere, point nulstilles.