1 Commits

Author SHA1 Message Date
37b88b8cb0 chore: preflight write check [skip ci] 2026-03-02 00:30:11 +00:00
81 changed files with 806 additions and 10252 deletions

View File

@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash"
]
}
}

View File

@@ -38,10 +38,7 @@ jobs:
node-version: "22"
- name: Install SPA dependencies
run: |
npm ci --prefix frontend/angular
node -e "require('./frontend/angular/node_modules/rollup/dist/native.js')" \
|| npm install --prefix frontend/angular
run: npm ci --prefix frontend/angular
- name: SPA Angular smoke tests
run: npm --prefix frontend/angular test

View File

@@ -1,10 +1,5 @@
# Changelog
## [Unreleased]
### Docs
- Added `docs/ISSUE-279-I18N-MVP-CLOSEOUT.md` with the issue #279 i18n MVP close-out note, including migration impact, reusable release-note text, and a release-readiness checklist refreshed against `main@1bc4c27` after PR #282/#283 landed on 2026-03-13 UTC.
- Clarified that the close-out note supersedes earlier PR snapshot assumptions and now treats PR #282 (`6ad5430`) and PR #283 (`1bc4c27`) as already merged on `main`.
## [0.1.0] - 2026-02-27
### Added
- Projekt scaffold for Weirsøe Party Protocol (Django 6.0.2)

119
CLAUDE.md
View File

@@ -1,119 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
**Weirsøe Party Protocol** is a Danish party game web platform (Jackbox-style) where games display on a primary screen and players participate via mobile. The MVP game is "Fup og Fakta" (a Fibbage-style lie-and-guess game).
- Backend: Django 6.0.2 + Django Channels (WebSockets) + Redis
- Frontend: Angular 19 shell + shared TypeScript API client library
- Database: MySQL (SQLite fallback for dev)
- Deployment: Proxmox LXC containers (not Docker)
## Commands
### Backend (Django)
```bash
python manage.py runserver # Dev server
python manage.py migrate # Apply migrations
python manage.py test # Run all backend tests
python manage.py test lobby # Run tests for a single app
python manage.py shell # Django shell
```
### Frontend — API client (`/frontend`)
```bash
cd frontend
npm install
npm test # Vitest unit tests
npm run build # TypeScript compile check (--noEmit)
```
### Frontend — Angular shell (`/frontend/angular`)
```bash
cd frontend/angular
npm install
npm start # Dev server (ng serve)
npm run build # Production build
npm run test # Vitest unit tests
```
### i18n validation
```bash
python scripts/check_i18n_drift.py # Check for key drift between locales
```
## Architecture
### Backend apps
| App | Purpose |
|-----|---------|
| `partyhub/` | Main Django project — settings, root URLs, ASGI/WSGI, i18n bootstrap |
| `lobby/` | Session & player management — create/join session, locale-aware error responses |
| `fupogfakta/` | Game logic — all domain models, score calculation (server-authoritative) |
| `realtime/` | WebSocket event layer (stub) |
| `voice/` | Voice/TTS interface (stub, Phase 2) |
| `core_admin/` | Health endpoint (`/healthz`), global admin |
**Key domain models** (all in `fupogfakta/models.py`): `GameSession`, `Player`, `Category`, `Question`, `RoundConfig`, `RoundQuestion`, `LieAnswer`, `Guess`, `ScoreEvent`.
Score calculation is server-side only. `ScoreEvent` provides an auditable trail of all point changes.
### Frontend layers
1. **Shared API client** (`frontend/src/`) — pure TypeScript, framework-agnostic. Defines all API types (`api/types.ts`) and HTTP client abstraction (`api/client.ts`).
2. **Angular shell** (`frontend/angular/`) — Angular 19 standalone components (no NgModules), hash-based routing. `host-shell.component` for the presenter screen; `player-shell.component` for mobile players.
The Angular shell consumes the shared client via `frontend/src/api/angular-client.ts`.
### Real-time flow
`LOBBY → LIE → GUESS → REVEAL → FINISHED` — phase transitions broadcast a `PhaseViewModel` to all connected clients via WebSocket. Clients are read-only; only the server is authoritative for state.
### i18n
- **Single source of truth**: `shared/i18n/lobby.json` (keys in both `en` and `da`)
- Loaded once at startup with LRU cache (`partyhub/i18n_bootstrap.py`)
- Key naming: domain-first — `frontend.ui.host.*`, `frontend.ui.player.*`, `backend.errors.*`, `backend.error_codes.*`
- Locale resolved from `Accept-Language` header; missing key returns key + logs warning; missing translation falls back to `en`
## Key Conventions
### Errors
Backend error responses use stable machine-readable codes (`backend.error_codes.*`) with separately localized messages. Never couple error code strings to locale.
### Game constraints (MVP)
- 312 players per session
- Session codes: 6-char alphanumeric (no 0/O/1/I/L)
- Anti-cheat: no duplicate lies, lies cannot match the correct answer, answer order randomized
### Git workflow
- `main`: stable baseline
- `feature/<name>`: development branches
- `release/vX.Y.Z`: release preparation
- Release: merge → create release branch → update `VERSION` + `CHANGELOG.md` → tag → push
### TypeScript
Strict mode required. Target ES2022. API response interfaces in `frontend/src/api/types.ts` must match backend responses exactly.
### Database
Use `ForeignKey` with explicit `on_delete` (`PROTECT`/`CASCADE`/`SET_NULL`). Add `db_index=True` on frequently queried fields. Migrations are auto-generated by Django and versioned in `migrations/`.
## Environment Variables
```
DJANGO_SECRET_KEY, DJANGO_DEBUG, DJANGO_ALLOWED_HOSTS
DB_ENGINE, DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, DB_PORT
CHANNEL_REDIS_HOST, CHANNEL_REDIS_PORT
USE_SPA_UI (fallback: WPP_SPA_ENABLED)
WPP_SPA_ASSET_BASE, WPP_SPA_ASSET_VERSION
```
## Test Files of Note
- `lobby/tests.py` — comprehensive Django TestCase coverage for session/player/i18n/error flows
- `frontend/angular/src/app/api-contract-smoke.spec.ts` — API contract smoke tests
- `frontend/angular/src/app/lobby-i18n.spec.ts` — i18n parity checks
- `frontend/tests/lobby-loader.parity.test.ts` — shared i18n loader parity

View File

@@ -1,71 +0,0 @@
# Ralph Loop: Implement WebSocket push for Weirsøe Party Protocol
## Context
- Project: /home/agw/projects/weirsoe-party-protocol
- Backend: Django 6.0.2 + Django Channels + Redis
- The full game REST flow is already implemented in lobby/views.py
(create_session, join_session, start_round, show_question, submit_lie,
mix_answers, submit_guess, calculate_scores, reveal_scoreboard, finish_game)
- realtime/ app exists but is a stub (no consumers.py, no routing)
- partyhub/settings.py has channels in INSTALLED_APPS but no CHANNEL_LAYERS or routing
- PO hard requirement: WebSocket push is mandatory in MVP (no polling)
## What to build
### 1. realtime/consumers.py — GameConsumer
- AsyncJsonWebsocketConsumer
- Connects to group game_{session_code} on connect (session_code from URL)
- Player auth: session_token query param validated against Player model
- Host auth: query param role=host, no token required for MVP
- On disconnect: clean leave from group
- Handles incoming message type "ping" -> replies with {"type": "pong"}
- Forwards broadcast group events to WebSocket client
### 2. partyhub/settings.py — CHANNEL_LAYERS
Add CHANNEL_LAYERS using channels_redis.core.RedisChannelLayer.
Read CHANNEL_REDIS_HOST (default 127.0.0.1) and CHANNEL_REDIS_PORT (default 6379) from env.
### 3. partyhub/asgi.py — ASGI routing
Wire URLRouter so ws/game/<session_code>/ routes to GameConsumer.
Keep existing HTTP routing intact.
### 4. realtime/routing.py
Define websocket_urlpatterns list.
### 5. realtime/broadcast.py — broadcast helper
- async def broadcast_phase_event(session_code, event_type, payload)
Sends to group game_{session_code} via channel layer.
- def sync_broadcast_phase_event(session_code, event_type, payload)
Sync wrapper using async_to_sync for calling from sync REST views.
### 6. lobby/views.py — hook broadcasts into phase transitions
After each phase transition, call sync_broadcast_phase_event:
- start_round -> phase.lie_started (question prompt + time limit)
- show_question -> phase.question_shown (question text)
- mix_answers -> phase.guess_started (shuffled answers + time limit)
- calculate_scores -> phase.scores_calculated (per-player score delta)
- reveal_scoreboard -> phase.scoreboard (ranked player list)
- finish_game -> phase.game_over (final rankings)
### 7. realtime/tests.py — basic tests
- Connect/disconnect test using channels.testing.WebsocketCommunicator
- Verify a broadcast reaches a connected client
## Constraints
- Keep auth simple: session_token query param for players, unauthenticated host in MVP
- Use async_to_sync wrapper for sync REST views calling async broadcast
- Do not break existing REST tests (python manage.py test lobby must still pass)
- After each file written, run: python manage.py check
- Follow existing code style in lobby/views.py
## Completion criteria
Output the exact text: WEBSOCKET COMPLETE
...when ALL of the following are true:
- realtime/consumers.py exists and handles connect/disconnect/ping
- realtime/broadcast.py exists with sync_broadcast_phase_event
- partyhub/settings.py has CHANNEL_LAYERS configured
- partyhub/asgi.py routes ws/game/<code>/ to GameConsumer
- All 6 phase transitions in lobby/views.py call sync_broadcast_phase_event
- python manage.py check passes with no errors
- python manage.py test lobby passes (existing tests not broken)

26
TODO.md
View File

@@ -37,8 +37,8 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
- [x] `core_admin` (global administration)
- [x] `fupogfakta` (Spil 1)
- [x] `lobby` (room/session/player join flow)
- [x] `realtime` (app-skelet oprettet — consumers/routing IKKE implementeret endnu)
- [x] `voice` (fælles voice-acting interface — stub)
- [x] `realtime` (channels events, game state broadcast)
- [x] `voice` (fælles voice-acting interface)
- [x] Miljøfiler (`.env.test`, `.env.prod` skabeloner)
- [x] Konfig for MySQL test/prod
@@ -53,15 +53,14 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
- [x] `ScoreEvent` (auditérbar pointslog)
### Fase 3 — Spilflow `Fup og Fakta`
- [x] Lobby: host opretter session, spillere joiner via kode (REST)
- [x] Runde starter med kategori (REST)
- [x] Spørgsmål vises -> alle skriver løgn inden X sek (REST)
- [x] System blander korrekt svar + løgne (persisted i JSONField, anti-cheat dedup)
- [x] Guessfase: alle gætter inden Z sek (REST)
- [x] Pointudregning (konfigurerbar pr. runde, ScoreEvent audit trail)
- [x] Scoreboard + næste spørgsmål/runde (REST)
- [x] Slutresultat (REST)
- [x] **WebSocket push af phase-events til host + spillere** (GameConsumer + broadcast.py, InMemoryChannelLayer i tests)
- [x] Lobby: host opretter session, spillere joiner via kode
- [x] Runde starter med kategori
- [x] Spørgsmål vises -> alle skriver løgn inden X sek
- [x] System blander korrekt svar + løgne
- [x] Guessfase: alle gætter inden Z sek
- [x] Pointudregning (konfigurerbar pr. runde)
- [x] Scoreboard + næste spørgsmål/runde
- [x] Slutresultat
### Fase 4 — Voice-acting (platformkrav)
- [ ] Definér TTS provider-interface
@@ -104,11 +103,10 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
- [ ] Migrations + static + health checks
### Backlog — Need-to-have / Nice-to-have
- [x] (Need-to-have) Persistér mixed svarrækkefølge pr. round question — DONE (JSONField + migration 0003 + test)
- [ ] (Need-to-have) Persistér mixed svarrækkefølge pr. round question, så alle spillere ser samme rækkefølge ved reconnect/refresh
- [x] (Need-to-have) Tilføj spiller-auth/session-token for submit_lie (pt. baseret på player_id i payload)
- [ ] (Nice-to-have) Endpoint til status/progress i løgnfasen (antal indsendt ud af total)
- [ ] (Need-to-have) Fjern ubrugte scaffold-imports i core_admin/*, realtime/*, voice/*, fupogfakta/views.py (kør `ruff check --fix`) så CI quality gate er grøn
- [x] (Need-to-have) [Issue #251] Release-often lane: SPA MVP opdelt i 3 merge-klare micro-PR batches (plan + acceptance criteria dokumenteret i `docs/ISSUE-251-RELEASE-OFTEN-SPA-MVP-BATCH-PLAN.md`).
- [ ] (Need-to-have) [Fejltype: CI/lint F401] [Fil/område: core_admin/*, fupogfakta/tests.py+views.py, lobby/admin.py+models.py, realtime/*, voice/*] [Branch/PR: feature/f3-lobby-create-join, feature/fase0-mvp-fup-og-fakta, feature/lobby-mvp (ingen åbne PRs fundet)] Fjern ubrugte scaffold-imports (eller kør ruff check --fix) så quality gate kan blive grøn før merge.
- [ ] (Need-to-have) Rate limiting på join/submit endpoints
- [ ] (Need-to-have) Session-kode brute-force beskyttelse
- [ ] (Need-to-have) Audit-log for host-handlinger (start/stop/skip)

View File

@@ -9,7 +9,7 @@ Issue #175 requires one shared i18n contract for MVP host/player flows across fr
- supported: `en`, `da`
- default/fallback: `en`
Django (`lobby/i18n.py`) reads directly from the catalog. Frontend runtimes are Angular-first and use a shared loader (`frontend/shared/i18n/lobby-loader.ts`) so Angular shell and SPA fallback consume the same keyspace + locale normalization.
Both Angular (`frontend/angular/src/app/lobby-i18n.ts`) and Django (`lobby/i18n.py`) read from this catalog.
## Key naming convention
- Domain-first namespaces:
@@ -46,4 +46,3 @@ Django (`lobby/i18n.py`) reads directly from the catalog. Frontend runtimes are
- `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts`
- `frontend/angular/src/app/features/host/host-shell.component.spec.ts`
- `frontend/angular/src/app/features/player/player-shell.component.spec.ts`
- `frontend/tests/lobby-loader.parity.test.ts` (minimal da/en key parity guard for shared keyspace)

View File

@@ -1,44 +0,0 @@
# ISSUE-225 Artifact — Backend i18n baseline (resolver + fallback)
Issue: **#225** (`[MVP][READY] #175-A: Backend i18n baseline (resolver + fallback)`)
## Scope verified
- Backend locale resolver supports `da` + `en` and normalizes language tags.
- Unsupported locale requests fall back to default locale (`en`).
- Error payload contract is stable for API errors:
- `error_code`
- `error`
- `locale`
## Implementation references
- Locale resolution + fallback chain:
- `lobby/i18n.py`
- `resolve_locale()`
- `resolve_error_message()`
- `api_error()`
- Shared locale contract source:
- `shared/i18n/lobby.json` (`locales.default=en`, supported includes `en`, `da`)
## Acceptance checks run
Command:
```bash
.venv/bin/python manage.py test \
lobby.tests.I18nResolverTests \
lobby.tests.LobbyFlowTests \
lobby.tests.StartRoundTests
```
Result (2026-03-02):
- `Ran 28 tests in 24.781s — OK`
- `System check identified no issues (0 silenced).`
- Confirms resolver behavior for locale normalization + fallback and stable error payload fields across flow endpoints.
## Notes
- Existing merged follow-ups in `main` include Accept-Language parsing fixes for q-values and locale tag normalization.
- This artifact documents the final baseline state and verification evidence for #225.

View File

@@ -1,16 +0,0 @@
# Issue #250 Artifact — MVP guardrail (telefon-klient uden lydafspilning)
## Scope
Implementeret guardrail for `primary-device only` audio policy i SPA player-flow.
## Acceptance mapping
1. **Telefon-klient flow indeholder ingen audio-play init-path**
- Test: `player-shell.component.spec.ts``does not trigger original media play during player-shell init path`.
2. **Primær enhed policy er dokumenteret og testbar**
- Policy i contract: `shared/i18n/lobby.json``frontend.capabilities.client_has_no_audio_output=true`.
- Testbar via eksisterende guard-tests + init-path test i player-shell specs.
3. **Krav er refereret i SPA-plan/cutover-noter**
- Dokumenteret i `docs/spa-cutover-flag.md` under *MVP audio policy guardrail (telefon-klient)*.
## UX/i18n note
- Player shell viser advarselstekst via i18n key: `frontend.ui.player.audio_policy_notice`.

View File

@@ -1,124 +0,0 @@
# Issue #251 — Release-often lane for SPA MVP (3 micro-PR batches)
## Formål
Bryde SPA MVP-arbejdet op i **3 merge-klare micro-PRs** med tydelige acceptance criteria,
så vi kan levere værdi oftere, reducere review-risiko og holde `main` grøn.
## Scope (issue-bound)
Denne plan dækker kun planlægning/acceptance for den kommende SPA MVP-lane.
Implementering af de konkrete features sker i efterfølgende PRs.
## Hard acceptance criteria for issue #251
- [x] Der findes en dokumenteret plan med præcis **3 batches**.
- [x] Hver batch har:
- [x] mål og afgrænsning
- [x] konkrete leverancer (kodeområder)
- [x] test/checks før merge
- [x] rollback-note
- [x] "ikke med i denne batch" for at undgå scope creep
- [x] Batch-rækkefølgen er dependencies-sikker (batch B bygger på batch A, batch C på batch B).
- [x] Hver batch kan merges/releaseres uafhængigt uden at blokere drift på `main`.
- [x] Planen linker til konkrete dev-opgaver for lane-kørsel.
## Batch-plan (merge-klare micro-PRs)
### Batch A — SPA shell + routing baseline
**Mål:** Et stabilt SPA-skelet med route-struktur og guard-basics.
**Leverancer (kodeområder)**
- `frontend/angular/src/app/app.routes.ts` (host/player entry routes + fallback)
- `frontend/angular/src/app/session-route-context.ts` (baseline route guards)
- `frontend/angular/src/app/app.component.*` (shell-nav + route outlet wiring)
- `lobby/templates/lobby/spa_shell.html` (kompatibel shell-entry ved SPA cutover)
**Done-kriterier**
- Host- og player-entry routes kan åbnes uden runtime-fejl i samme SPA-shell.
- Route guards afviser ugyldige parametre deterministisk (ingen hard crash).
- `USE_SPA_UI=false` fortsætter med legacy-flow uden regression.
**Checks før merge**
- `cd frontend/angular && npm test -- --run src/app/app.routes.spec.ts src/app/session-route-context.spec.ts`
- `cd frontend/angular && npm run build`
- Manual smoke: `/lobby/ui/host` + `/lobby/ui/player` (både `USE_SPA_UI=false` og `true`).
**Rollback-note**
- Sæt `USE_SPA_UI=false` og redeploy; verificér legacy routes svarer 200.
**Ikke med i batch A**
- Fuld gameplay-state synkronisering.
- Audio/polish og i18n finpudsning ud over baseline wiring.
---
### Batch B — Session-state + host/player sync MVP
**Mål:** Korrekt synkronisering af session-state mellem host og spillerklient.
**Leverancer (kodeområder)**
- `frontend/angular/src/app/features/host/host-shell.component.ts`
- `frontend/angular/src/app/features/player/player-shell.component.ts`
- `frontend/src/api/angular-client.ts` (MVP-kald for status/phase-overgange)
**Done-kriterier**
- Host handlinger (`start`, `show`, `mix`, `score`, `next`, `finish`) afspejles hos player uden side-reload.
- Session-phase transitions er deterministiske i happy-path (`lobby -> question -> score -> next/finish`).
- Guardrails reducerer race-condition regressions ved hurtige phase-skift.
**Checks før merge**
- `cd frontend/angular && npm test -- --run src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts`
- `cd frontend && npm test -- --run tests/angular-api-client.test.ts`
- Manual smoke: host action -> player phase sync indenfor forventet latenstid.
**Rollback-note**
- Slå `USE_SPA_UI=false`, redeploy, og kør hurtig gameplay-smoke i legacy flow.
**Ikke med i batch B**
- Avanceret UX-polish/animation.
- Udvidet observability udenfor MVP-kritiske logs.
---
### Batch C — Lobby/join/start minimal flow + release readiness
**Mål:** Gøre SPA MVP release-klar med fokus på stabilitet og driftssikkerhed omkring det minimale flow.
**Leverancer (kodeområder)**
- `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts` + relevante shell-tests
- `docs/UI_SMOKE.md` + `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md`
- `CHANGELOG.md` release-input for SPA MVP lane
**Done-kriterier**
- End-to-end minimal flow (`lobby -> join -> start`) er dokumenteret PASS i SPA.
- Fejl-/empty-/loading states for flowets kritiske skærme er verificeret.
- Driftsteam kan udføre cutover + rollback uden tvetydighed.
**Checks før merge**
- `cd frontend/angular && npm test -- --run src/app/i18n-mvp-flow-smoke.spec.ts`
- `python manage.py test lobby.tests.LobbyFlowTests`
- Opdateret staging-smoke artifact med UTC tidsstempler og gate-resultat.
**Rollback-note**
- Brug eksisterende playbook i `docs/spa-cutover-flag.md` (`USE_SPA_UI=false` + asset-version rollback).
**Ikke med i batch C**
- Post-MVP featureudvidelser.
- Større refactors uden direkte release-værdi.
## Rækkefølge og parallel-kørsel
- **Dependency-rækkefølge:** A -> B -> C.
- **Kan køres parallelt uden konflikt:**
- Test-/doc-forberedelse til C kan startes parallelt med B (ingen blokering af runtime-kode), men merges først efter B.
- Drift-smoke templates kan opdateres tidligt, så længe de ikke ændrer runtime-adfærd.
- **Må ikke køre parallelt:**
- Runtime routing/guard ændringer i A og session-sync logik i B på samme filer uden feature-flag koordinering.
## Konkret lane-opgavebinding (dev-opgaver)
- Batch A PR: `feat/issue-251-batch-1-spa-shell-routing`
- Batch B PR: `feat/issue-251-batch-2-session-sync`
- Batch C PR: `feat/issue-251-batch-3-lobby-join-start-release-readiness`
Hver PR skal linke tilbage til issue #251 og inkludere test-evidence + rollback-check.
## Merge-gate for alle 3 batches
- Små PRs (mål: reviewbar størrelse, helst < ~300 netto-linjer når muligt).
- Grøn CI/checks før review-request.
- Tydelig PR-beskrivelse med: scope, test evidence, out-of-scope.
- Ingen skjulte sideeffekter på tværs af apps/domæner.

View File

@@ -1,27 +0,0 @@
# ISSUE-252 Artifact — React fallback trigger criteria (delivery-blocking only)
Issue: **#252**
## Leveret ændring
Dokumentationen i `docs/spa-cutover-flag.md` er opdateret med en dedikeret sektion:
- **React fallback trigger-kriterier (kun delivery-blocking)**
- klare **tilladelses-kriterier** (alle skal være opfyldt)
- tydelige **scope-limits**
- eksplicitte **ikke-tilladte** anvendelser
## Acceptance mapping
1. **Clear trigger criteria**
- Definerer præcist hvornår fallback er tilladt:
- aktiv delivery-blocking fejl i Angular SPA
- ingen sikker Angular-fix inden release-vinduet
- rollback alene er utilstrækkelig for leveringsbehovet
- beslutning + evidens logges eksplicit (inkl. issue/incident-reference)
2. **Scope limits**
- Begrænset til delivery-blocking host/player-paths.
- Ingen feature-bundling eller ikke-kritiske ændringer.
- Midlertidig anvendelse kun i aktiv incident/release-vindue.
3. **When fallback is allowed**
- Kun når alle trigger-kriterier er opfyldt og dokumenteret.
## Resultat
Issue #252 er dokumenteret med operationelle guardrails, så React fallback kun bruges i kontrollerede, leveringsblokerende situationer.

View File

@@ -1,32 +0,0 @@
# ISSUE-257 Artifact — Shared i18n keyspace + frontend loader (da/en, Angular-first)
Issue: **#257** (`[MVP][READY] #175-B: Shared i18n keyspace + frontend loader`)
## Acceptance checklist
- [x] **Delt key-strategi dokumenteret (frontend/backend)**
- Arkitektur/deling beskrevet i `docs/I18N_ARCHITECTURE.md`.
- Shared contract + keyspace source of truth: `shared/i18n/lobby.json`.
- [x] **Frontend loader kan indlæse da+en med samme keyspace**
- Shared loader: `frontend/shared/i18n/lobby-loader.ts`.
- Angular-first integration via `frontend/angular/src/app/lobby-i18n.ts` (samme loader/samme keyspace).
- Locale-normalisering inkluderer underscore/hyphen variants (`da_DK``da`).
- [x] **Minimal check for key-paritet da/en**
- `collectLocaleParityIssues(...)` i shared loader.
- Testet i `frontend/tests/lobby-loader.parity.test.ts`.
- [x] **Ingen API-kontraktbrud**
- Contract-test: `frontend/tests/lobby-i18n.contract.test.ts`.
- Drift-check mellem manifest og katalog: `scripts/check_i18n_drift.py`.
## Kørte checks
```bash
python3 scripts/check_i18n_drift.py
cd frontend && npm test -- --run tests/lobby-loader.parity.test.ts tests/lobby-i18n.contract.test.ts
cd frontend/angular && npm test -- --run src/app/lobby-i18n.spec.ts src/app/i18n-mvp-flow-smoke.spec.ts
```
Resultat: alle checks grønne.

View File

@@ -1,37 +0,0 @@
# ISSUE-257 Artifact — shared i18n keyspace + frontend loader (Angular-first)
Issue: **#257** (`[MVP][READY] #175-B: Shared i18n keyspace + frontend loader (da/en, Angular-first)`)
## Acceptance mapping
### 1) Delt key-strategi dokumenteret (frontend/backend)
- Shared contract source: `shared/i18n/lobby.json`
- Architecture doc: `docs/I18N_ARCHITECTURE.md`
- Key-map/contract doc: `docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md`
### 2) Frontend loader kan indlæse da+en med samme keyspace
- Shared loader: `frontend/shared/i18n/lobby-loader.ts`
- Angular-first consumer path:
- `frontend/src/spa/lobby-i18n.ts`
- Angular shell/tests continue to consume same shared catalog through shared loader contract.
### 3) Minimal check for key-paritet da/en
- Guard test: `frontend/tests/lobby-loader.parity.test.ts`
- Contract test: `frontend/tests/lobby-i18n.contract.test.ts`
### 4) Ingen API-kontraktbrud
- Frontend API contract smoke:
- `frontend/angular/src/app/api-contract-smoke.spec.ts`
- `frontend/tests/angular-api-client.test.ts`
## Verification run (this lane)
```bash
cd frontend
npm test -- --run tests/lobby-loader.parity.test.ts tests/lobby-i18n.contract.test.ts tests/angular-api-client.test.ts
cd ../frontend/angular
npm test -- --run src/app/api-contract-smoke.spec.ts
```
Result: PASS (all selected suites green).

View File

@@ -1,18 +0,0 @@
# Issue #260 Artifact — Phone/client no-audio guard (primary-device only playback)
## Scope
Added regression coverage for MVP audio policy to ensure phone/client flows never claim playback ownership, while primary-device playback stays unaffected.
## Acceptance mapping
1. **client/phone triggers no playback**
- Existing test coverage retained in `player-shell.component.spec.ts`:
- `does not trigger original media play during player-shell init path`
- `installs secondary-device audio guard while player shell is mounted`
2. **primary device playback unaffected**
- New negative test in `player-shell.component.spec.ts`:
- `keeps primary-device playback untouched when no-audio capability is disabled`
3. **one negative test for phone audio**
- Existing negative path preserved:
- `does not trigger original media play during player-shell init path`
4. **no backend contract changes**
- Frontend test/docs-only scope; no backend contract files changed.

View File

@@ -1,80 +0,0 @@
# ISSUE-277 Artifact — shared i18n registry parity report (Django ↔ Angular MVP)
Issue: **#277** (`[READY][#175][P3] Shared i18n registry artifact: backend/frontend keyspace parity report`)
## Artifact metadata
- `artifact_id`: `issue-277-shared-i18n-parity-report`
- `artifact_version`: `1.0`
- `catalog_source`: `shared/i18n/lobby.json`
- `generator`: `scripts/report_i18n_parity.py`
## Naming/version rules (email-manager-inspired strategy)
- **Single canonical artifact per issue**: issue-bundne rapporter navngives `docs/ISSUE-<nr>-<slug>-ARTIFACT.md`.
- **Stable artifact identity**: `artifact_id` ændres ikke ved tekstlige opdateringer i samme rapporttype; det er den faste reference i review/ops.
- **Explicit artifact versioning**: `artifact_version` bumpes, når rapportlogik eller scope ændres, så drift/review kan se forskel på format- vs. dataændringer.
- **Shared namespace first**: keys refereres med fulde navnerum (`frontend.ui.*`, `frontend.errors.*`, `backend.error_codes.*`, `backend.errors.*`) i stedet for lokale aliases i artefakter.
- **Source-of-truth before consumers**: rapporten afledes fra `shared/i18n/lobby.json`; Django/Angular beskrives som consumers af samme registry og ikke som parallelle kontrakter.
## MVP-critical parity summary
- Frontend UI gameplay keys checked: **16**`OK`
- Frontend error keys checked: **7**`OK`
- Backend gameplay/error codes checked: **9**`OK`
- Distinct frontend error keys reached from backend MVP flow: **6** (`join_failed, nickname_invalid, nickname_taken, session_code_required, session_not_found, start_round_failed`)
Status: **Shared locale matrix is aligned (`en`, `da`) and backend→frontend error handling is contract-complete for MVP-critical flow.**
## Django ↔ Angular parity matrix (MVP-critical error contract)
| Backend code (`backend.error_codes.*`) | Django message key (`backend.errors.*`) | Angular key (`frontend.errors.*`) | Locales `en/da` | Parity note |
|---|---|---|---|---|
| `session_code_required` | `session_code_required` | `session_code_required` | `OK` | 1:1 |
| `nickname_invalid` | `nickname_invalid` | `nickname_invalid` | `OK` | 1:1 |
| `session_not_found` | `session_not_found` | `session_not_found` | `OK` | 1:1 |
| `session_not_joinable` | `session_not_joinable` | `join_failed` | `OK` | mapped alias |
| `nickname_taken` | `nickname_taken` | `nickname_taken` | `OK` | 1:1 |
| `category_slug_required` | `category_slug_required` | `start_round_failed` | `OK` | many:1 collapse |
| `category_not_found` | `category_not_found` | `start_round_failed` | `OK` | many:1 collapse |
| `round_start_invalid_phase` | `round_start_invalid_phase` | `start_round_failed` | `OK` | many:1 collapse |
| `round_already_configured` | `round_already_configured` | `start_round_failed` | `OK` | many:1 collapse |
## Scope notes
- **Django** consumes backend codes/messages directly from `shared/i18n/lobby.json` via `lobby/i18n.py`.
- **Angular** consumes the same registry via `frontend/shared/i18n/lobby-loader.ts` and runtime helpers in `frontend/angular/src/app/lobby-i18n.ts`.
- **Parity in MVP** is therefore strongest on the shared error contract and locale matrix; gameplay UI labels are frontend-owned but still live in the same registry.
## Verified MVP gameplay UI keyspace present in the shared registry
- `frontend.ui.host.title`
- `frontend.ui.player.title`
- `frontend.ui.common.session_code`
- `frontend.ui.player.nickname`
- `frontend.ui.player.join`
- `frontend.ui.host.start_round`
- `frontend.ui.host.show_question`
- `frontend.ui.player.lie_label`
- `frontend.ui.player.submit_lie`
- `frontend.ui.player.submit_guess`
- `frontend.ui.host.mix_answers`
- `frontend.ui.host.calculate_scores`
- `frontend.ui.host.load_scoreboard`
- `frontend.ui.host.final_leaderboard`
- `frontend.ui.player.final_leaderboard`
- `frontend.ui.common.points_short`
## Concrete deviations / follow-up items
1. **Error granularity collapse remains intentional**: backend codes `category_slug_required, category_not_found, round_start_invalid_phase, round_already_configured` all map to `frontend.errors.start_round_failed`. Follow-up only if product wants case-specific Angular copy instead of one shared host failure message.
2. **Frontend-only fallback copy is not mirrored in Django**: `frontend.errors.unknown` and `frontend.errors.session_fetch_failed` are Angular-side resilience keys, not backend contract keys. Follow-up if API responses should expose stable backend equivalents for these states.
3. **Gameplay UI labels are registry-shared but not backend-rendered**: `frontend.ui.host.*`, `frontend.ui.player.*`, and `frontend.ui.common.*` are available in the shared artifact, but Django currently consumes only the backend error slice. Follow-up only if server-rendered views must guarantee the same UI label surface as Angular.
## Re-run
```bash
python3 scripts/check_i18n_drift.py
python3 scripts/report_i18n_parity.py
python3 scripts/check_i18n_parity_artifact.py
```

View File

@@ -1,36 +0,0 @@
# Issue #278 Artifact — smoke/e2e gate for da+en locale flow and primary-only audio
## Scope
Acceptance for `[READY][#175][P4]`:
1. Verify one MVP host+player smoke run in `en`.
2. Verify one MVP host+player smoke run in `da`.
3. Verify audio routing remains `primary-device only` so phone/player clients never take playback ownership.
Dette er en gate-/evidensleverance. Ingen ny produktfunktion ud over test/verifikation.
## Implemented smoke gate
Angular smoke spec: `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts`
The gate now runs two explicit locale scenarios:
- `en`: host refresh/start-round copy + player submit-guess copy
- `da`: samme flow med dansk copy
Audio-policy delen af samme smoke-spec verificerer:
- host/primary playback path er uændret før player mount
- player mount installerer no-audio guard på secondary device
- guard fjernes igen ved unmount, så primary path fortsat er eneste aktive output
## Recommended verification command
Køres fra `frontend/angular`:
```bash
npm test -- --run src/app/i18n-mvp-flow-smoke.spec.ts src/app/lobby-i18n.spec.ts src/app/features/player/player-shell.component.spec.ts
```
## Why this is the gate
- `i18n-mvp-flow-smoke.spec.ts` giver en lille, samlet smoke/e2e-lignende verifikation af host+player i begge locale-kontekster.
- `lobby-i18n.spec.ts` holder shared locale propagation + contract fallback grøn.
- `player-shell.component.spec.ts` dækker den dybere regressionflade for audio-guard på secondary device.
## Conclusion
Gateen verificerer nu eksplicit begge locale-runs (`da` + `en`) og bekræfter primary-only audio routing i MVP-flowet.

View File

@@ -1,168 +0,0 @@
# ISSUE-279 — i18n MVP close-out note
Issue: **#279** (`[READY][#175][P5] MVP close-out note: migration/changelog + release-readiness checklist for i18n`)
## Scope
Dette dokument lukker MVP-sporet for issue #175 med tre konkrete ting:
1. en migrationsnote for release/deploy,
2. changelog-indhold der kan genbruges i næste release-note,
3. en release-readiness checkliste for i18n, forankret i et verificeret snapshot af `main` ved reviewtidspunktet.
Repo-state ved review-opdatering:
- `main` peger nu på merge commit `1bc4c27` (PR #283), og inkluderer også PR #282 via merge commit `6ad5430`.
- Denne note er opdateret mod repo-tilstanden verificeret 2026-03-13 UTC, ikke en løbende garanti for senere `main`-ændringer.
- Denne revision afløser de tidligere snapshot-antagelser fra PR-historikken, hvor #282/#283 endnu ikke var landet.
- Der er ingen åbne release-afklaringer tilbage for PR #282/#283; begge er allerede landet på `main`.
## Current i18n MVP state on `main`
Følgende er allerede til stede på `main`:
- **Shared contract** i `shared/i18n/lobby.json`
- default locale: `en`
- supported locales: `en`, `da`
- fælles frontend/backend keyspace + fallback-regler
- **Django bootstrap** via `partyhub/i18n_bootstrap.py` og `partyhub/settings.py`
- `LocaleMiddleware` aktiv
- `LANGUAGE_CODE` + `LANGUAGES` bootstrappes fra shared catalog
- **Backend locale/error flow** via `lobby/i18n.py`
- normalisering af locale-tags
- locale-aware fejlpayload med `error_code`, `error`, `locale`
- fallback til `en` når locale eller oversættelse mangler
- **Angular MVP wiring** via
- `frontend/shared/i18n/lobby-loader.ts`
- `frontend/angular/src/app/lobby-i18n.ts`
- host/player shells med locale switch og shared copy-opslag
- **Drift/parity guardrails**
- `shared/i18n/key-manifest.json`
- `scripts/check_i18n_drift.py`
- frontend parity/contract tests
- **Existing documentation/artifacts**
- `docs/I18N_ARCHITECTURE.md`
- `docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md`
- `docs/ISSUE-225-BACKEND-I18N-BASELINE-ARTIFACT.md`
- `docs/ISSUE-257-SHARED-I18N-KEYSPACE-FRONTEND-LOADER-ARTIFACT.md`
- `docs/ISSUE-207-I18N-AUDIO-SMOKE-ARTIFACT.md`
- `docs/i18n-drift-check.md`
## Migration note for release
### Schema impact
**Der er ingen nye Django-migrations i selve i18n-MVP-sporet på `main`.**
Den i18n-relaterede leverance ligger i shared catalog, locale-bootstrap, error-payload-kontrakt, Angular wiring og test/documentation. Den kræver derfor ikke en særskilt i18n-database-migration for at gå i release.
### Release/deploy expectation
Selv om issue #279 ikke introducerer schemaændringer, skal release-flow stadig følge repoets generelle migreringsgate:
```bash
python manage.py makemigrations --check --dry-run
python manage.py migrate --check --noinput
```
Hvorfor: release-policyen kræver, at vi undgår code/schema drift, og staging-smoke-suiten forventer eksplicit migration consistency check.
### Praktisk migrationskonklusion
Til release-notes/deploy-runbook kan i18n-sporet beskrives sådan her:
- **Migration impact:** none for i18n MVP itself
- **Deploy requirement:** run standard Django migration consistency checks anyway
- **Rollback note:** rollback er primært kode-/asset-baseret (shared catalog, frontend bundles, backend locale resolver), ikke schema-baseret
## Suggested changelog content
Følgende tekst kan bruges direkte i næste unreleased/release-sektion:
```markdown
### i18n
- Shared da/en lobby i18n contract is now wired across Django and Angular MVP flows via `shared/i18n/lobby.json`.
- Backend error payloads expose stable locale-aware fields (`error_code`, `error`, `locale`) with fallback to English for unsupported locales.
- Angular host/player shells now consume shared i18n copy, persist preferred locale, and keep audio-policy messaging aligned with the shared catalog.
- Added repo guardrails for i18n drift/parity through the shared key manifest, drift checker, and focused frontend/backend contract tests.
- Release migration impact for the i18n MVP is **none** beyond the standard Django migration consistency checks.
```
Kort version til annoterede release-notes:
```markdown
## i18n MVP close-out
- Shared da/en contract is active across backend + Angular MVP shell.
- Locale fallback remains `en` for unsupported requests and missing translations.
- No i18n-specific schema migration is required; keep standard `migrate --check --noinput` in release verification.
```
## Release-readiness checklist for i18n
Status er vurderet mod verificeret snapshot `main@1bc4c27` (reviewet 2026-03-13 UTC, inkl. PR #282/#283).
### 1) Shared contract and locale behavior
- [x] Shared catalog findes i `shared/i18n/lobby.json`.
- [x] Default/supported locales er dokumenteret og implementeret som `en` + `da`.
- [x] Backend bruger shared contract til locale-aware fejlbeskeder.
- [x] Frontend/Angular bruger shared loader + shared keyspace.
- [x] Fallback-regel til `en` er dokumenteret og testet.
### 2) Verification artifacts and local checks
- [x] Arkitektur-note findes: `docs/I18N_ARCHITECTURE.md`.
- [x] Baseline artifact for issue #175 findes.
- [x] Backend artifact for issue #225 findes.
- [x] Frontend/shared loader artifact for issue #257 findes.
- [x] Drift-check dokumentation findes i `docs/i18n-drift-check.md`.
- [x] Parity artifact fra issue #277 er på `main` via PR #282 (merge commit `6ad5430`).
### 3) Code readiness on current branch topology
- [x] Angular MVP host/player i18n flow er på `main` (PR #281).
- [x] Shared locale/bootstrap wiring er på `main`.
- [x] Django i18n hardening fra issue #275 er på `main` via PR #283 (merge commit `1bc4c27`).
- [x] PR #283 er ikke længere en separat release-afklaring; hardeningen er allerede indarbejdet på `main`.
### 4) Release gate before shipping i18n as “done”
- [x] PR #282 er allerede merged, så parity-artifact-status er afklaret på `main`.
- [x] PR #283 er allerede merged, så backend hardening-status er afklaret på `main`.
- [ ] Kør drift-check fra repo root:
```bash
python3 scripts/check_i18n_drift.py
```
- [ ] Kør backend i18n regressions:
```bash
. .venv/bin/activate && python manage.py test \
partyhub.tests_i18n_bootstrap \
lobby.tests.I18nResolverTests
```
- [ ] Kør frontend shared-contract/parity checks:
```bash
cd frontend && npm test -- --run \
tests/lobby-loader.parity.test.ts \
tests/lobby-i18n.contract.test.ts
```
- [ ] Kør Angular MVP locale smoke:
```bash
cd frontend/angular && npm test -- --run \
src/app/lobby-i18n.spec.ts \
src/app/i18n-mvp-flow-smoke.spec.ts \
src/app/features/host/host-shell.component.spec.ts \
src/app/features/player/player-shell.component.spec.ts
```
- [ ] Bekræft standard migration consistency gate:
```bash
. .venv/bin/activate && python manage.py makemigrations --check --dry-run
. .venv/bin/activate && python manage.py migrate --check --noinput
```
- [ ] Følg `docs/RELEASE_POLICY.md`: staging deploy, `/healthz`, smoke-resultat og changelog-reference før tag.
## Close-out conclusion
**Konklusion:** i18n-MVP'en er implementeret på `main`, og issue #279 leverer den manglende release-/migration-closeout dokumentation uden nye kodeændringer i app-logikken.
PR #282 (parity artifact) og PR #283 (Django i18n hardening) er nu begge merged på `main`, så close-out-noten, changelog-teksten og release-readiness-checklisten kan behandles som indbyrdes konsistente for det verificerede snapshot.
Det betyder, at de resterende release-gates for i18n nu er de almindelige verificeringstrin (drift-check, backend/frontend-smoke, migrations-konsistens, staging deploy og changelog-reference) — ikke længere afklaring af om #282/#283 skal lande.

View File

@@ -1,22 +0,0 @@
# Issue #287 — Canonical round-flow backend artifact
## State-transition matrix
| Trigger | From | To | Server-owned effect |
|---|---|---|---|
| `POST /lobby/sessions/{code}/rounds/start` | `lobby` | `lie` | Opretter `RoundConfig`, vælger/låser konkret `RoundQuestion`, eksponerer prompt + lie-deadline i samme svar |
| Sidste gyldige `submit_lie` for aktivt spørgsmål | `lie` | `guess` | Dedupe/shuffle `correct_answer + lies`, persisterer `mixed_answers`, broadcaster `phase.guess_started` |
| Sidste gyldige `submit_guess` for aktivt spørgsmål | `guess` | `reveal` | Beregner score deterministisk, persisterer `ScoreEvent` + opdaterede `Player.score`, returnerer canonical reveal payload |
| Første canonical state-read efter resolved reveal (`session_detail`, og idempotent `GET /scoreboard` hvis state allerede er resolved) | `reveal` | `scoreboard` | Promoverer scoreboard som state, broadcaster `phase.scoreboard`, eksponerer leaderboard + readiness |
| `POST /lobby/sessions/{code}/rounds/next` | `scoreboard` | `lie` | Increment round counter, kopierer seneste `RoundConfig`, vælger/låser næste spørgsmål i samme kategori og broadcaster `phase.lie_started` |
| `POST /lobby/sessions/{code}/finish` | `scoreboard` | `finished` | Fryser slutresultat og returnerer final leaderboard |
## Flow-log (happy path)
1. Host starter runde med kategori.
2. Server vælger straks spørgsmål og går i `lie`.
3. Spillere sender løgne; sidste submission auto-advancer til `guess`.
4. Spillere sender gæt; sidste submission auto-advancer til `reveal` og scorer runden.
5. Næste canonical state-read promoverer resolved reveal til `scoreboard`; state findes uden separat debug-knap.
6. Host kan nu kun vælge `next round` eller `finish game`.
7. `next round` starter næste runde direkte i `lie` med nyt konkret spørgsmål; ingen mellem-hop tilbage til `lobby`.

View File

@@ -1,52 +0,0 @@
# Issue #301 Artifact — Client action gating from canonical phase state
Refs: #287, #301
## What changed
Frontend host/player shells now prefer the canonical phase exposed by `phase_view_model.current_phase` when deciding:
- which gameplay actions are enabled
- whether reveal data should still be shown
- which SPA hash-route should represent the active game state
This tightens the #301 slice so the client stays aligned with backend canonicalisation even when `session.status` lags during reveal/scoreboard promotion.
## Gated UI actions by phase
### Lobby
- **Host:** `startRound`
- **Player:** `join`
### Bluff / lie
- **Host:** `showQuestion`
- **Player:** `submitLie`
- **Blocked:** guess submission, scoreboard load, next round, finish game
### Guess
- **Host:** `mixAnswers`, `calculateScores`
- **Player:** `submitGuess`
- **Blocked:** lie submission, scoreboard load, next round, finish game
### Reveal
- **Host:** `loadScoreboard`
- **Player:** display-only reveal state
- **Blocked:** start next round, finish game, guess/lie submission
### Scoreboard
- **Host:** `startNextRound`, `finishGame`
- **Player:** display-only reveal/scoreboard state
- **Blocked:** scoreboard reload, guess/lie submission
## Test evidence
Targeted tests added/updated for:
- host shell canonical gating and route sync when `current_phase` differs from `session.status`
- player shell canonical gating and route sync when `current_phase` differs from `session.status`
- shared gameplay phase machine gating from canonical permissions
- shared API mapper contract coverage, including reveal/scoreboard payload stability
## Contract note
No backend protocol redesign was introduced. This follow-up only preserves and consumes the existing canonical phase/action contract more strictly on the client side.

View File

@@ -1,55 +0,0 @@
# Issue #302 Evidence — canonical bluff → guess → reveal → scoreboard regression
## Runnable command
```bash
python manage.py migrate --noinput
python manage.py smoke_staging --artifact docs/artifacts/issue-302-canonical-loop-smoke.json
```
`migrate` is the normal local bootstrap precondition when the database has not been initialized yet; the regression evidence itself is produced by `smoke_staging`.
## What the regression proves
`smoke_staging` now exercises one full canonical round and fails fast with step-specific diagnostics if any of these break:
1. `start_round` lands the session in `lie` and returns a concrete `round_question_id`.
2. Final `submit_lie` auto-advances the session to `guess` and exposes mixed answers containing both the correct answer and player bluffs.
3. Final `submit_guess` auto-advances the session to `reveal` and returns the canonical reveal payload.
4. The reveal payload includes:
- correct answer
- all lies
- all guesses
- fooled-player references for bluff hits
5. The first canonical state read after reveal promotes the session to `scoreboard`.
6. Scoreboard promotion preserves the same reveal payload and exposes a leaderboard with `scoreboard_ready=true`.
## Artifact shape
When `--artifact` is provided, the JSON file records:
- the exact smoke command
- session code and round question id
- deterministic guess plan used to produce both bluff hits and one correct guess
- per-step evidence for:
- `create_session`
- `join_players`
- `start_round`
- `auto_guess_transition`
- `submit_guesses`
- `auto_reveal_transition`
- `auto_scoreboard_transition`
- `finish_game`
- reveal summary (`correct_answer`, lie/guess counts, fooled-player ids, correct guess player ids)
- promoted scoreboard leaderboard payload
## Targeted test coverage
Backend regression coverage lives in `lobby/tests.py`:
- `test_smoke_staging_command_runs_full_flow`
- `test_smoke_staging_writes_phase_evidence_artifact_when_requested`
Together they ensure the command stays runnable in normal workflow and that the evidence artifact contains phase-by-phase proof instead of only a generic pass/fail.
Refs #287 #302

View File

@@ -1,33 +0,0 @@
# Issue #310 — Host transition idempotency and error catalog
## Scope
This artifact hardens the two host-owned scoreboard exits in the canonical gameplay flow:
- `POST /lobby/sessions/{code}/rounds/next`
- `POST /lobby/sessions/{code}/finish`
The goal is retry-safe host behavior when the scoreboard transition already succeeded server-side but the client retries because of a duplicate click, timeout, or lost response.
## Transition contract
| Endpoint | First valid transition | Idempotent replay state | Replay result | Broadcast behavior | Still-invalid states |
|---|---|---|---|---|---|
| `POST /lobby/sessions/{code}/rounds/next` | `scoreboard -> lie` | `lie` with persisted current-round bootstrap (`RoundConfig` + `RoundQuestion`) | `200 OK` with the same canonical next-round payload shape | `phase.lie_started` fires only on the first transition | `lobby`, `guess`, `reveal`, `finished``next_round_invalid_phase` |
| `POST /lobby/sessions/{code}/finish` | `scoreboard -> finished` | `finished` | `200 OK` with the same final leaderboard payload shape | `phase.game_over` fires only on the first transition | `lobby`, `lie`, `guess`, `reveal``finish_game_invalid_phase` |
## Error catalog notes
No new backend error codes were introduced for this slice.
The contract change is behavioral:
- `next_round_invalid_phase` now means the session is in a phase where the scoreboard → next-round transition has **not** already been completed, or the expected bootstrap artifact for the already-started round is missing.
- `finish_game_invalid_phase` now means the session is in a phase where the scoreboard → finish transition has **not** already been completed.
- Successful replays are returned as normal `200 OK` canonical responses instead of phase errors.
## Acceptance evidence
- Repeated `rounds/next` calls after a successful scoreboard exit return the same canonical lie/bootstrap payload without incrementing the round twice.
- Repeated `finish` calls after a successful scoreboard exit return the same finished leaderboard payload without rebroadcasting game-over.
- Wrong-phase calls outside those replay states still return the existing shared error codes.

View File

@@ -1,202 +0,0 @@
# Issue #312 — FupOgFakta extraction map for logic currently living in `lobby/`
Parent: #311
Issue: #312
## Purpose
This artifact documents the concrete FupOgFakta-specific logic that still lives in `lobby/`, separates it from true platform/session concerns, and names the intended destination ownership before any larger code move happens.
It is intentionally an inventory + extraction plan only. It does **not** perform the full move.
## Architectural boundary this map is enforcing
The target boundary is already described in:
- `docs/plans/2026-03-09-fupogfakta-game-engine-design.md`
- `docs/plans/2026-03-09-fupogfakta-implementation-plan.md`
- `docs/ARCHITECTURE.md`
Those docs consistently describe:
- `lobby/` as the **platform layer** for session lifecycle, player presence, host ownership, generic game-run orchestration, and transport-facing platform concerns.
- `fupogfakta/` as the **game cartridge** that owns question selection rules, round config semantics, lie/guess/reveal/scoreboard flow, answer mixing, scoring, and game-specific response/event payloads.
In other words:
- **Platform (`lobby/`)** should know that a session exists and that a game can be started/observed.
- **Cartridge (`fupogfakta/`)** should know what a lie is, what a guess is, how answers are mixed, when phases advance, and what payload shape those game phases expose.
## Summary split
### Generic platform/session concerns that belong in `lobby/`
These are not FupOgFakta-specific and should remain platform-owned:
- Session code parsing/generation:
- `lobby/views.py::_generate_session_code`
- `lobby/views.py::_normalize_session_code`
- `lobby/views.py::_create_unique_session_code`
- Generic request parsing:
- `lobby/views.py::_json_body`
- Session lifecycle and player presence endpoints:
- `lobby/views.py::create_session`
- `lobby/views.py::join_session`
- `lobby/views.py::session_detail` **only for the generic session/player shell part**
- Generic ownership / host authorization checks
- Generic session detail payload fields:
- `session.code`
- `session.status`
- `session.host_id`
- `session.current_round`
- `session.players_count`
- `players[].id|nickname|score|is_connected`
- Generic i18n/error transport helper usage:
- `lobby/i18n.py`
- `api_error(...)`
- Route mounting / namespace ownership in `lobby/urls.py` for platform routes only
### FupOgFakta-specific logic currently misplaced in `lobby/`
These items are game-cartridge logic and should move behind `fupogfakta/` ownership:
- Round question selection by category and previously-used questions
- Lie-phase payload construction and lie timer semantics
- Mixed-answer preparation for bluff gameplay
- Guess correctness / fooled-player detection
- Bluff/correct-answer score resolution
- Reveal payload construction
- Reveal → scoreboard promotion rules
- Start round / mix answers / submit lie / submit guess / calculate scores / reveal scoreboard / next round / finish game gameplay endpoints
- Phase view-model booleans that encode FupOgFakta rules rather than generic platform readiness
## Extraction map
| Source file | Current function / concern | Why it is FupOgFakta-specific | Intended destination / owner |
| --- | --- | --- | --- |
| `lobby/views.py` | `_build_player_ref(player)` | Helper is only used to shape FupOgFakta reveal payloads; not a generic platform concern today. | `fupogfakta/serializers.py` or `fupogfakta/payloads.py` owned by cartridge. |
| `lobby/views.py` | `_build_reveal_payload(round_question)` | Encodes FupOgFakta reveal contract: lies, guesses, fooled-player refs, correct answer, prompt. | `fupogfakta/payloads.py::build_reveal_payload` or equivalent cartridge response builder. |
| `lobby/views.py` | `_build_leaderboard(session)` | Current implementation is generic-ish, but used exclusively by FupOgFakta scoreboard/finish flow and coupled to that response shape. | Short term: keep shared helper if multiple games will consume same contract; otherwise move to `fupogfakta/payloads.py` until a true shared scoreboard contract exists. |
| `lobby/views.py` | `_get_current_round_question(session)` | Depends on FupOgFakta `RoundQuestion` model and current-round semantics. | `fupogfakta/services/rounds.py` or `fupogfakta/queries.py`. |
| `lobby/views.py` | `_select_round_question(session, round_config)` | Implements FupOgFakta question selection rules by category, active questions, and not-yet-used question set. | `fupogfakta/services/rounds.py::select_round_question`. |
| `lobby/views.py` | `_build_lie_started_payload(session, round_config, round_question)` | Builds a FupOgFakta event/response contract for lie phase, including category, prompt, lie deadline, round question id. | `fupogfakta/payloads.py::build_lie_started_payload`. |
| `lobby/views.py` | `_prepare_mixed_answers(round_question)` | Bluff-answer dedupe and shuffle is core FupOgFakta gameplay logic. | `fupogfakta/services/answers.py::prepare_mixed_answers`. |
| `lobby/views.py` | `_resolve_scores(session, round_question, round_config)` | Applies FupOgFakta scoring rules for correct guesses and successful bluffs; depends on `Guess`, `LieAnswer`, `ScoreEvent`, `points_correct`, `points_bluff`. | `fupogfakta/services/scoring.py::resolve_scores`. |
| `lobby/views.py` | `_maybe_promote_reveal_to_scoreboard(session)` | Encodes FupOgFakta reveal completion semantics and scoreboard transition trigger. | `fupogfakta/services/phases.py::maybe_promote_reveal_to_scoreboard`. |
| `lobby/views.py` | `_build_phase_view_model(session, players_count, has_round_question)` | Most booleans are not platform-generic; they encode FupOgFakta phase names (`lie`, `guess`, `scoreboard`) and MVP constraints (`3-5 players`, round-question readiness, next-round/finish gating). | Split: keep platform-shell fields in `lobby/`; move game-specific readiness/action flags to `fupogfakta/payloads.py::build_phase_view_model` or cartridge driver payload builder. |
| `lobby/views.py` | `start_round(request, code)` | Starts FupOgFakta round, binds category, creates `RoundConfig`, selects `RoundQuestion`, transitions to `LIE`, broadcasts `phase.lie_started`. | `fupogfakta/views.py` or cartridge command handler behind a future `GameDriver.on_game_start` / round bootstrap service. |
| `lobby/views.py` | `show_question(request, code)` | Emits lie-phase question payload using FupOgFakta `RoundQuestion` and `RoundConfig`. | `fupogfakta/views.py` or remove entirely once canonical driver flow owns the transition. |
| `lobby/views.py` | `submit_lie(request, code, round_question_id)` | Pure FupOgFakta gameplay endpoint: lie validation, deadline semantics, auto-advance to guess phase, `phase.guess_started` payload. | `fupogfakta/views.py::submit_lie` (or cartridge intent handler). |
| `lobby/views.py` | `mix_answers(request, code, round_question_id)` | Manual FupOgFakta host action for lie→guess transition and answer mixing. | `fupogfakta/views.py` short term; long term likely deleted in favor of cartridge-driven automatic transition. |
| `lobby/views.py` | `submit_guess(request, code, round_question_id)` | Pure FupOgFakta gameplay endpoint: validates answer choice, resolves correctness/bluff source, auto-calculates scores, transitions to reveal. | `fupogfakta/views.py::submit_guess` plus `fupogfakta/services/scoring.py` and `fupogfakta/services/phases.py`. |
| `lobby/views.py` | `reveal_scoreboard(request, code)` | FupOgFakta reveal/scoreboard progression, not a generic platform capability. | `fupogfakta/views.py::reveal_scoreboard` or cartridge phase service. |
| `lobby/views.py` | `start_next_round(request, code)` | FupOgFakta next-round bootstrap: copies prior `RoundConfig`, increments round, picks next question, re-enters lie phase. | `fupogfakta/services/rounds.py::start_next_round` plus cartridge-owned endpoint/driver integration. |
| `lobby/views.py` | `finish_game(request, code)` | Current finish path is tied to FupOgFakta scoreboard semantics and winner payload. | `fupogfakta/views.py::finish_game` until a truly generic platform finish contract exists. |
| `lobby/views.py` | `calculate_scores(request, code, round_question_id)` | Explicit FupOgFakta score resolution endpoint. | `fupogfakta/services/scoring.py` and/or remove when fully absorbed by cartridge phase driver. |
| `lobby/urls.py` | Gameplay routes for rounds, lies, guesses, scoreboard, finish | These route names expose FupOgFakta-specific phase/actions from the platform namespace. | Re-home under `fupogfakta/urls.py` or leave mounted under `/lobby/sessions/...` only as a temporary façade delegating to cartridge-owned code. |
| `lobby/tests.py` | `StartRoundTests`, `LieSubmissionTests`, `MixAnswersTests`, `GuessSubmissionTests`, `CanonicalRoundFlowTests`, `ScoreCalculationTests`, `RevealRoundFlowTests`, `SessionDetailRoundQuestionTests`, `SessionDetailPhaseViewModelTests`, `SmokeStagingCommandTests` | These test classes verify FupOgFakta game flow rather than platform mechanics. | Move/split into `fupogfakta/tests/` with only session creation/join/platform transport tests left in `lobby/tests.py`. |
| `lobby/management/commands/smoke_staging.py` | End-to-end gameplay smoke through lies/guesses/finish | Script executes one concrete game flow and should be cartridge-aware, not platform-owned. | `fupogfakta/management/commands/` or a shared smoke harness that delegates into cartridge-specific scenario runners. |
## Recommended ownership split by module
### Keep in `lobby/`
- Session creation/join and session-code lifecycle
- Generic player membership/presence reads
- Generic auth/host checks helpers (if extracted from views)
- Generic API error/i18n plumbing
- Future `GameRun` / driver orchestration, timers, and cartridge dispatch
- A slim generic `session_detail` envelope that can embed cartridge payloads under a dedicated game key
### Move to `fupogfakta/`
- Round state queries
- Question selection
- Lie/guess/reveal/scoreboard/finish transition rules
- Score calculation
- Answer mixing
- Gameplay payload/response builders
- Gameplay endpoints and tests
- Gameplay smoke command
## Explicit boundary for `session_detail`
`session_detail` is currently mixed.
### Generic part that should remain platform-owned
- Session identity/status metadata
- Player list / presence list
- Generic host/player capability envelope if it is game-agnostic
### FupOgFakta part that should move or be delegated
- `round_question` payload
- `reveal` payload
- `scoreboard` payload
- `phase_view_model` fields keyed to `lie`, `guess`, `scoreboard`, `finished`, `question_ready`, and 35-player MVP rules
A clean future shape would be:
```json
{
"session": {"code": "ABC123", "status": "active", "game_type": "fupogfakta"},
"players": [...],
"game": {
"phase": "lie",
"payload": {"round_question": {...}, "reveal": null, "scoreboard": null}
}
}
```
That makes `lobby/` the shell and `fupogfakta/` the authority for game-state payloads.
## Concrete extraction sequence
1. **Move pure helpers first**
- `_get_current_round_question`
- `_select_round_question`
- `_prepare_mixed_answers`
- `_resolve_scores`
- `_build_lie_started_payload`
- `_build_reveal_payload`
2. **Move gameplay endpoints behind cartridge-owned service functions**
- `submit_lie`
- `submit_guess`
- `start_round`
- `start_next_round`
- `finish_game`
- `reveal_scoreboard`
- `calculate_scores`
3. **Slim `session_detail` into platform envelope + delegated cartridge payload**
4. **Move gameplay tests out of `lobby/tests.py`**
5. **Optionally leave compatibility routes in `lobby/urls.py` as a façade** until clients are rewired
## Risks this map is explicitly preventing
- Moving only models but leaving hidden phase-transition rules in `lobby/views.py`
- Treating `session_detail` as platform-generic while it still leaks cartridge payload semantics
- Leaving scoreboard/reveal transition logic behind as an undocumented coupling
- Splitting tests incorrectly so regressions stay "green" in `lobby/` while FupOgFakta behavior silently drifts
## Decision
For #311 / #312, the repository should treat the following as **game-specific and extraction candidates**:
- round-question selection
- lie/guess/reveal/scoreboard/finish transitions
- answer mixing
- score resolution
- reveal/scoreboard payload builders
- FupOgFakta-specific session-detail subpayloads
- gameplay flow tests and smoke command
And it should treat the following as **platform-generic**:
- session identity/lifecycle
- player presence/membership
- host authorization shell
- generic error transport
- future game-driver dispatch/orchestration
That is the explicit `lobby` vs `fupogfakta` boundary this issue needs before code extraction proceeds.

View File

@@ -1,110 +0,0 @@
{
"ok": true,
"command": "python manage.py smoke_staging --artifact <path>",
"generated_at": "2026-03-16T15:19:30.105231+00:00",
"question": {
"prompt": "Smoke prompt?",
"correct_answer": "Correct"
},
"steps": [
{
"step": "create_session",
"session_status": "lobby"
},
{
"step": "join_players",
"players_count": 3
},
{
"step": "start_round",
"session_status": "lie",
"round_question_id": 1
},
{
"step": "auto_guess_transition",
"session_status": "guess",
"answers": [
"Lie from P3",
"Lie from P1",
"Lie from P2",
"Correct"
]
},
{
"step": "submit_guesses",
"guess_results": [
{
"player_id": 1,
"selected_text": "Lie from P2",
"is_correct": false,
"fooled_player_id": 2
},
{
"player_id": 2,
"selected_text": "Correct",
"is_correct": true,
"fooled_player_id": null
},
{
"player_id": 3,
"selected_text": "Lie from P1",
"is_correct": false,
"fooled_player_id": 1
}
]
},
{
"step": "auto_reveal_transition",
"session_status": "reveal",
"reveal": {
"correct_answer": "Correct",
"lies_count": 3,
"guesses_count": 3,
"fooled_player_ids": [
1,
2
],
"correct_guess_player_ids": [
2
]
}
},
{
"step": "auto_scoreboard_transition",
"session_status": "scoreboard",
"leaderboard": [
{
"id": 2,
"nickname": "P2",
"score": 7
},
{
"id": 1,
"nickname": "P1",
"score": 2
},
{
"id": 3,
"nickname": "P3",
"score": 0
}
]
},
{
"step": "finish_game",
"session_status": "finished"
}
],
"session_code": "7YV59E",
"players": [
"P1",
"P2",
"P3"
],
"round_question_id": 1,
"guess_plan": {
"P1": "Lie from P2",
"P2": "Correct",
"P3": "Lie from P1"
}
}

View File

@@ -1,272 +0,0 @@
# Design: Fup og Fakta — Game Engine & Platform Architecture
**Date:** 2026-03-09
**Status:** Approved
---
## Overview
Build a working Fup og Fakta game (Fibbage-style) on top of a **pluggable game platform**. The platform handles sessions, players, WebSocket push, and Celery-driven timers. Each game is a self-contained **cartridge** that implements a shared driver interface and owns its own models, config, and phase logic.
---
## Platform Architecture
```
partyhub/ Django project — settings, Celery app, ASGI
lobby/ Platform layer — sessions, players, GameRun, timer dispatch
realtime/ WebSocket consumers (already built)
fupogfakta/ Game cartridge #1
future_game/ Game cartridge #N (same interface)
```
### Platform provides (`lobby/`)
#### Models
**`GameSession`** (exists, minor additions)
- `game_type` (CharField) — e.g. `"fupogfakta"`
- `host` (FK → User)
- `code` (6-char session code)
- `status` (LOBBY / ACTIVE / FINISHED)
- `config_id` / `config_snapshot` — see Config section
**`GameRun`** (new — ephemeral, deleted on game exit)
- `session` (OneToOne → GameSession)
- `current_state` (CharField — game-defined state string)
- `phase_deadline` (DateTimeField, nullable)
- `is_paused` (BooleanField, default False)
- `paused_remaining_seconds` (FloatField, nullable)
- `celery_task_id` (CharField, nullable)
- `state_data` (JSONField) — game-specific snapshot for current phase
**`Player`** (exists)
- `session`, `nickname`, `score`, `session_token`, `is_connected`
#### GameDriver interface
Each cartridge implements:
```python
class GameDriver:
game_type: str # e.g. "fupogfakta"
def on_game_start(session, run, config) -> PhaseResult
def on_timer_expired(session, run, config) -> PhaseResult
def on_pause(session, run) -> None
def on_resume(session, run) -> None
def on_exit(session, run) -> None # must clean up all game data
def get_ws_payload(state, state_data) -> dict
```
`PhaseResult` = `(next_state: str, duration_seconds: int | None, broadcast_payload: dict)`
#### Celery task
```python
@app.task
def handle_timer_expired(run_id: int, expected_state: str):
# If run no longer exists or state has changed → stale task, ignore
# Call driver.on_timer_expired(session, run, config)
# Apply PhaseResult: update run, broadcast via channel layer, schedule next task
```
`expected_state` prevents stale tasks from firing after pause/resume or manual state changes.
#### REST endpoints (platform-level)
- `POST /sessions/{code}/play` — start or resume
- `POST /sessions/{code}/pause` — pause current phase timer
- `POST /sessions/{code}/exit` — end game, delete GameRun + all game data
---
## Configuration System
### Base config model (`partyhub/`)
```python
class BaseGameConfig(models.Model):
class Meta:
abstract = True
name = models.CharField(max_length=100) # "Quick game", "Full evening"
user = models.ForeignKey(User, null=True, ...) # null = system default
is_default = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
```
### Game-specific config (`fupogfakta/`)
```python
class FupOgFaktaConfig(BaseGameConfig):
num_rounds = PositiveIntegerField(default=3)
questions_per_round = PositiveIntegerField(default=3)
min_players = PositiveIntegerField(default=2)
max_players = PositiveIntegerField(default=8)
lie_seconds = PositiveIntegerField(default=45)
guess_seconds = PositiveIntegerField(default=30)
reveal_seconds_per_lie = PositiveIntegerField(default=8)
scoreboard_recap_seconds = PositiveIntegerField(default=10)
# Escalating scoring per round (stored as arrays or separate fields)
points_correct = JSONField(default=[1500, 3000, 4500])
points_bluff = JSONField(default=[500, 1000, 1500])
# Reaction bonus (static, feeds post-game awards only)
reaction_bonus = IntegerField(default=5)
```
### Default resolution at session start
1. User has `is_default=True` row for this game type → use that
2. System default (`user=null, is_default=True`) — set in Django admin
3. Model field `default=` values (hardcoded)
User can have **multiple named presets** (one-to-many). When starting a session they choose which to use (or it auto-selects their default). The chosen config's values are **snapshotted into `GameRun.state_data`** at game start — immutable for the life of the session.
---
## Fup og Fakta — Game States
```
LOBBY
│ (host presses Play)
LIE_PHASE timer: lie_seconds
│ (all submitted OR timer expires)
GUESS_PHASE timer: guess_seconds
│ (timer expires — no mercy)
REVEAL_LIE_{n} timer: reveal_seconds_per_lie (one per lie with ≥1 guess)
│ → score liar incrementally as each is shown
REVEAL_TRUTH timer: reveal_seconds_per_lie
│ → score correct guessers
SCOREBOARD_RECAP timer: scoreboard_recap_seconds
├─ more questions in round → back to LIE_PHASE (next question)
├─ round done, more rounds → back to LIE_PHASE (next round, next category)
└─ all rounds done → POST_GAME_AWARDS
timer: configurable
→ FINISHED (GameRun deleted, GameSession status = FINISHED)
```
---
## Fup og Fakta — Phase Details
### LIE_PHASE
- Question shown to all clients via WebSocket (`phase.lie_started` event)
- Players submit lie via `POST /fupogfakta/{code}/lie`
- **If lie matches correct answer (case-insensitive):** return `error_code: lie_matches_correct_answer` — player prompted again, does not consume their submission
- Anonymous to other players during this phase
- `state_data` tracks: question id, round number, how many have submitted (for progress display on host screen)
- Timer expires → transition to GUESS_PHASE regardless of how many submitted
### GUESS_PHASE
- Answers mixed (lies + truth, deduped) broadcast to all clients (`phase.guess_started`)
- Players guess via `POST /fupogfakta/{code}/guess`
- **After selecting:** player can react to other lies with 👍 😂 ❤️ etc. until timer expires. Cannot change guess.
- Reactions stored in `LieReaction` model (player, lie, reaction_type)
- Timer expires → transition to first REVEAL_LIE (or REVEAL_TRUTH if no lies had guesses)
### REVEAL_LIE_{n}
- One Celery task per lie to reveal (only lies with ≥1 guesser)
- Broadcast: which lie, who wrote it, who guessed it (`phase.reveal_lie`)
- Score awarded to liar: `points_bluff[round_index] × guesser_count`
- Score broadcast immediately (`phase.score_delta`)
- Skipped lies (0 guesses): not shown at all
### REVEAL_TRUTH
- Broadcast: correct answer, who guessed correctly (`phase.reveal_truth`)
- Score awarded: `points_correct[round_index]` per correct guesser
- Also show reaction totals on each lie during this phase
### SCOREBOARD_RECAP
- Full leaderboard broadcast (`phase.scoreboard`)
- Auto-advances to next question, next round, or post-game
### POST_GAME_AWARDS
- Computed from `LieReaction` aggregate:
- "Most Hilarious Liar" — most 😂 reactions total
- "Most Beloved Lie" — most ❤️ reactions on a single lie
- etc. (extensible)
- Broadcast as `phase.awards`
- Then FINISHED → GameRun deleted, all session game data wiped
---
## Fup og Fakta — Models
**Existing (keep):** `Category`, `Question`, `RoundQuestion`, `LieAnswer`, `Guess`
**Remove:** `ScoreEvent` (no audit trail needed — game state is ephemeral)
**New:**
```python
class LieReaction(models.Model):
lie = ForeignKey(LieAnswer, on_delete=CASCADE)
player = ForeignKey(Player, on_delete=CASCADE)
reaction = CharField(max_length=20) # "laugh", "heart", "fire", etc.
created_at = auto_now_add
class Meta:
unique_together = [("lie", "player", "reaction")]
```
**Modify `RoundQuestion`:**
- Add `reveal_order` (PositiveIntegerField, nullable) — set when GUESS_PHASE ends, determines reveal sequence
---
## Pause / Resume
- **Pause:** compute `remaining = phase_deadline - now`, store in `paused_remaining_seconds`, set `is_paused=True`, revoke Celery task by `celery_task_id`
- **Resume:** set `phase_deadline = now + paused_remaining_seconds`, schedule new Celery task, clear pause fields
- Stale task guard: every Celery task checks `expected_state == run.current_state` before firing
---
## Host Controls (Session Owner Only)
| Action | Effect |
|--------|--------|
| Play | Starts game from LOBBY, or resumes from paused |
| Pause | Freezes current phase timer, broadcasts `phase.paused` |
| Exit | Ends game immediately, deletes GameRun + all game data |
Cannot skip. Cannot manually advance phases.
---
## WebSocket Event Reference
| Event | Triggered by | Payload |
|-------|-------------|---------|
| `phase.lie_started` | LIE_PHASE start | question prompt, deadline, round info |
| `phase.lie_progress` | Each lie submitted | n_submitted / n_players (no names) |
| `phase.guess_started` | GUESS_PHASE start | mixed answers, deadline |
| `phase.reveal_lie` | REVEAL_LIE_{n} | lie text, author, guessers, score delta |
| `phase.reveal_truth` | REVEAL_TRUTH | correct answer, correct guessers, score delta |
| `phase.scoreboard` | SCOREBOARD_RECAP | full leaderboard |
| `phase.awards` | POST_GAME_AWARDS | award winners |
| `phase.paused` | Pause | remaining_seconds |
| `phase.resumed` | Resume | new deadline |
| `phase.game_over` | FINISHED | final leaderboard |
---
## Data Lifecycle
All game session data (`GameRun`, `RoundQuestion`, `LieAnswer`, `Guess`, `LieReaction`, `Player`) is **deleted when host exits or game reaches FINISHED**. `GameSession` row is kept (with status=FINISHED) for the session code uniqueness constraint. `Category` and `Question` content is permanent.
---
## Not In Scope (This Implementation)
- TTS / read-aloud (Fase 4, deferred)
- Reconnect recovery after server restart (game is gone if server dies)
- Spectator/viewer mode (post-MVP)
- Rate limiting on endpoints (backlog)
- Bulk question import (Fase 5)

File diff suppressed because it is too large Load Diff

View File

@@ -59,41 +59,9 @@ Trin-for-trin:
Target: rollback + sanity-verifikation inden for 10 minutter.
## React fallback trigger-kriterier (kun delivery-blocking)
Formål: React fallback må kun bruges som kortvarig leverings-sikring, når release ellers er blokeret.
### Hvornår fallback er tilladt
Alle punkter skal være opfyldt:
1. **Delivery-blocking fejl i Angular SPA**
- Host/player kerneflow kan ikke leveres i release-vinduet (fx login/join/start/round/scoreboard stopper).
2. **Ingen hurtig Angular-fix inden for release-vinduet**
- Teamet har vurderet at patch + verificering ikke kan nås sikkert i tide.
3. **Rollback alene løser ikke leveringsbehovet**
- `USE_SPA_UI=false` (legacy) er enten utilstrækkelig for den konkrete leverance eller allerede verificeret som ikke tilstrækkelig.
4. **Beslutning er eksplicit logget**
- Trigger, impact, UTC-tid, ansvarlig, issue/incident-reference og plan for tilbagevenden til Angular er dokumenteret i release/smoke artifact.
### Scope-limits for fallback
- Fallback omfatter kun **delivery-blocking host/player-paths**.
- Ingen nye features, UX-forbedringer eller ikke-kritiske ændringer må bundtes ind i fallback.
- Fallback er **midlertidig** og gælder kun for aktiv incident/release-vindue.
- Når blocker er fjernet, skal miljøet tilbage på standard cutover-spor (Angular + `USE_SPA_UI` styring).
### Ikke tilladt
- Proaktiv fallback "for en sikkerheds skyld" uden aktiv blocker.
- Brug af fallback til at omgå normale kvalitetsgates eller testkrav.
- Langvarig drift i fallback-mode uden dokumenteret blocker og opfølgningsplan.
## Verifikation
- Flag OFF: `UiScreenTests.test_legacy_templates_are_used_when_spa_flag_is_off`
- Flag ON (host): `UiScreenTests.test_host_screen_can_render_angular_shell_when_feature_flag_enabled`
- Flag ON (host deep-link): `UiScreenTests.test_host_screen_deeplink_preserves_spa_path_when_feature_flag_enabled`
- Flag ON (player): `UiScreenTests.test_player_screen_can_render_angular_shell_when_feature_flag_enabled`
- Smoke-checkliste for cutover paths: `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md` + `docs/UI_SMOKE.md`
## MVP audio policy guardrail (telefon-klient)
- Telefon-/player-klienten må ikke starte lydafspilning lokalt i MVP (`primary-device only`).
- Policy er bundet til capability-flaget `frontend.capabilities.client_has_no_audio_output=true` i `shared/i18n/lobby.json`.
- Brugeradvarsel i player UI leveres via i18n key: `frontend.ui.player.audio_policy_notice`.
- Acceptance-spec er dækket i Angular tests (`player-shell.component.spec.ts`), inkl. at init-path ikke kalder original media `play`.

View File

@@ -19,24 +19,6 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }]
},
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Q?',
correct_answer: 'A',
lies: [{ player_id: 2, nickname: 'Maja', text: 'B', created_at: '2026-03-01T18:00:05Z' }],
guesses: [
{
player_id: 3,
nickname: 'Bo',
selected_text: 'B',
is_correct: false,
fooled_player_id: 2,
fooled_player_nickname: 'Maja',
created_at: '2026-03-01T18:00:15Z'
}
]
},
phase_view_model: {
status: 'lobby',
round_number: 1,
@@ -49,17 +31,17 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
},
host: {
can_start_round: true,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
can_show_question: true,
can_mix_answers: true,
can_calculate_scores: true,
can_reveal_scoreboard: true,
can_start_next_round: true,
can_finish_game: true
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_submit_lie: true,
can_submit_guess: true,
can_view_final_result: false
}
}
@@ -68,7 +50,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
if (url === '/lobby/sessions/ABCD12/scoreboard') {
return {
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
leaderboard: [
{ id: 9, nickname: 'Maja', score: 200 },
{ id: 10, nickname: 'Bo', score: 150 }
@@ -125,12 +107,6 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 77, round_number: 1 },
events_created: 2,
reveal: {
round_question_id: 77,
correct_answer: 'A',
lies: [],
guesses: []
},
leaderboard: [
{ id: 9, nickname: 'Maja', score: 200 },
{ id: 10, nickname: 'Bo', score: 150 }
@@ -194,10 +170,8 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
expect(session.ok).toBe(true);
if (session.ok) {
expect(session.data.session.code).toBe('ABCD12');
expect(session.data.phase_view_model.host.can_start_next_round).toBe(false);
expect(session.data.phase_view_model.player.can_submit_guess).toBe(false);
expect(session.data.reveal?.correct_answer).toBe('A');
expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja');
expect(session.data.phase_view_model.host.can_start_next_round).toBe(true);
expect(session.data.phase_view_model.player.can_submit_guess).toBe(true);
}
expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true);

View File

@@ -1,15 +1,7 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withHashLocation } from '@angular/router';
import { routes } from './app.routes';
import { createWppApiClient, WPP_API_CLIENT } from './wpp-api-client';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withHashLocation()),
{
provide: WPP_API_CLIENT,
useFactory: () => createWppApiClient(),
},
],
providers: [provideRouter(routes, withHashLocation())],
};

View File

@@ -4,8 +4,6 @@ import { HostShellComponent } from './host-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
type FetchRouteHandler = (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>;
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
@@ -14,31 +12,7 @@ function jsonResponse(status: number, body: unknown) {
} as unknown as Response;
}
function createFetchRouteMock(handler: FetchRouteHandler): FetchMock {
return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init)));
}
function sessionDetailPayload(
status: string,
options?: {
currentPhase?: string;
roundQuestionId?: number | null;
reveal?: {
correct_answer: string;
prompt?: string;
lies?: Array<{ player_id: number; nickname: string; text: string; created_at?: string }>;
guesses?: Array<{
player_id: number;
nickname: string;
selected_text: string;
is_correct: boolean;
fooled_player_id: number | null;
fooled_player_nickname?: string;
created_at?: string;
}>;
} | null;
}
) {
function sessionDetailPayload(status: string, options?: { roundQuestionId?: number | null }) {
const roundQuestionId = options?.roundQuestionId ?? 41;
return {
@@ -63,26 +37,8 @@ function sessionDetailPayload(
{ id: 1, nickname: 'Host', score: 0, is_connected: true },
{ id: 2, nickname: 'Mads', score: 120, is_connected: true },
],
reveal:
options?.reveal === undefined || options?.reveal === null
? null
: {
round_question_id: roundQuestionId,
round_number: 1,
prompt: options.reveal.prompt ?? 'Q?',
correct_answer: options.reveal.correct_answer,
lies: (options.reveal.lies ?? []).map((lie) => ({
...lie,
created_at: lie.created_at ?? '2026-01-01T00:00:05Z',
})),
guesses: (options.reveal.guesses ?? []).map((guess) => ({
...guess,
created_at: guess.created_at ?? '2026-01-01T00:00:10Z',
})),
},
phase_view_model: {
status,
current_phase: options?.currentPhase ?? status,
round_number: 1,
players_count: 2,
constraints: {
@@ -91,18 +47,14 @@ function sessionDetailPayload(
min_players_reached: true,
max_players_allowed: true,
},
readiness: {
question_ready: (options?.currentPhase ?? status) !== 'lobby',
scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard',
},
host: {
can_start_round: (options?.currentPhase ?? status) === 'lobby',
can_show_question: (options?.currentPhase ?? status) === 'lie',
can_mix_answers: (options?.currentPhase ?? status) === 'lie' || (options?.currentPhase ?? status) === 'guess',
can_calculate_scores: (options?.currentPhase ?? status) === 'guess',
can_reveal_scoreboard: (options?.currentPhase ?? status) === 'reveal',
can_start_next_round: (options?.currentPhase ?? status) === 'scoreboard',
can_finish_game: (options?.currentPhase ?? status) === 'scoreboard',
can_start_round: status === 'lobby',
can_show_question: status === 'lie',
can_mix_answers: status === 'lie',
can_calculate_scores: status === 'guess',
can_reveal_scoreboard: status === 'reveal',
can_start_next_round: status === 'scoreboard',
can_finish_game: status === 'scoreboard',
},
player: {
can_join: status === 'lobby',
@@ -149,76 +101,54 @@ describe('HostShellComponent gameplay wiring', () => {
expect(component.loading).toBe(false);
});
it('hydrates canonical reveal payload in reveal phase', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(
200,
sessionDetailPayload('reveal', {
roundQuestionId: 77,
reveal: {
correct_answer: 'Mercury',
lies: [{ player_id: 2, nickname: 'Mads', text: 'Venus' }],
guesses: [
{
player_id: 3,
nickname: 'Luna',
selected_text: 'Venus',
is_correct: false,
fooled_player_id: 2,
fooled_player_nickname: 'Mads',
},
],
},
})
)
);
it('captures scoreboard error for retry path', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' }));
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
await component.loadScoreboard();
expect(component.session?.reveal?.correct_answer).toBe('Mercury');
expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 2, nickname: 'Mads', text: 'Venus' });
expect(component.session?.reveal?.guesses[0]).toMatchObject({
player_id: 3,
nickname: 'Luna',
selected_text: 'Venus',
fooled_player_id: 2,
fooled_player_nickname: 'Mads',
});
expect(fetchMock).toHaveBeenCalledWith('/lobby/sessions/ABCD12/scoreboard', expect.objectContaining({ method: 'GET' }));
expect(component.scoreboardError).toContain('Scoreboard failed: Scoreboard unavailable');
expect(component.loading).toBe(false);
});
it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => {
let refreshCount = 0;
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/show') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
}
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/99/answers/mix') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 2 } });
}
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 2 } });
}
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
refreshCount += 1;
if (refreshCount === 1) {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
}
if (refreshCount === 2) {
return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }));
}
return jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
it('wires showQuestion, mixAnswers and calculateScores with expected request payloads', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(
jsonResponse(200, {
round_question: {
id: 77,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-01-01T00:00:00Z',
lie_deadline_at: '2026-01-01T00:00:45Z',
},
config: { lie_seconds: 45 },
})
)
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 77 })))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 77, round_number: 1 },
answers: [{ text: 'A' }],
})
)
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 77, round_number: 1 },
events_created: 2,
leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }],
})
)
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 })));
vi.stubGlobal('fetch', fetchMock);
@@ -226,37 +156,19 @@ describe('HostShellComponent gameplay wiring', () => {
component.sessionCode = ' abcd12 ';
component.roundQuestionId = ' 77 ';
component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any;
await component.showQuestion();
expect(component.session?.session.status).toBe('lie');
expect(component.roundQuestionId).toBe('99');
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
await component.mixAnswers();
expect(component.session?.session.status).toBe('guess');
await component.calculateScores();
expect(component.session?.session.status).toBe('reveal');
expect(component.error).toBe('');
expect(component.loading).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(6);
});
it('runs next-round transition without reload and clears scoreboard payload', async () => {
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'POST' && url === '/lobby/sessions/ABCD12/rounds/next') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
}
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } }))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null })));
vi.stubGlobal('fetch', fetchMock);
@@ -265,7 +177,6 @@ describe('HostShellComponent gameplay wiring', () => {
component.scoreboardPayload = '{"leaderboard":[]}';
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ;
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.startNextRound();
@@ -275,8 +186,8 @@ describe('HostShellComponent gameplay wiring', () => {
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' }));
expect(component.session?.session.status).toBe('lie');
expect(component.roundQuestionId).toBe('99');
expect(component.session?.session.status).toBe('lobby');
expect(component.scoreboardPayload).toBe('');
expect(component.finalLeaderboardPayload).toBe('');
expect(component.finalLeaderboard).toEqual([]);
expect(component.nextRoundError).toBe('');
@@ -302,7 +213,6 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.finishGame();
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
@@ -326,7 +236,6 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent();
component.sessionCode = ' ';
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.startNextRound();
await component.finishGame();
@@ -336,77 +245,6 @@ describe('HostShellComponent gameplay wiring', () => {
expect(component.finishError).toContain('Session code is required');
});
it('blocks illegal host actions outside canonical phase permissions', async () => {
const fetchMock: FetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
component.roundQuestionId = '77';
for (const status of ['guess', 'reveal', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.showQuestion();
}
for (const status of ['lie', 'reveal', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.calculateScores();
}
for (const status of ['lie', 'guess', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.loadScoreboard();
}
for (const status of ['lie', 'guess', 'reveal'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.startNextRound();
await component.finishGame();
}
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
expect(component.canShowQuestion).toBe(false);
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
expect(component.canCalculateScores).toBe(false);
expect(component.canLoadScoreboard).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
expect(component.canLoadScoreboard).toBe(false);
expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
});
it('prefers canonical current_phase for reveal panel and host routing when status lags behind', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'scoreboard', roundQuestionId: 77, reveal: { correct_answer: 'Mercury' } }))
);
vi.stubGlobal('fetch', fetchMock);
const replaceState = vi.fn();
vi.stubGlobal('window', {
location: { hash: '#/host/reveal/ABCD12' },
history: { state: null, replaceState },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn() },
});
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.gameplayPhase).toBe('scoreboard');
expect(component.showRevealPanel).toBe(true);
expect(component.canLoadScoreboard).toBe(false);
expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).toBe(true);
expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/scoreboard/ABCD12');
});
it('syncs host hash-route with latest phase after refresh without page reload', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })));
vi.stubGlobal('fetch', fetchMock);
@@ -424,32 +262,5 @@ describe('HostShellComponent gameplay wiring', () => {
await component.refreshSession();
expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12');
expect(component.canStartRound).toBe(false);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
});
it('uses phase_view_model to keep host action surface bound to round boundaries only', async () => {
const component = new HostShellComponent();
expect(component.canStartRound).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
component.session = sessionDetailPayload('lie') as any;
expect(component.canStartRound).toBe(false);
expect(component.canShowQuestion).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
component.session = sessionDetailPayload('reveal') as any;
expect(component.canLoadScoreboard).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
component.session = sessionDetailPayload('scoreboard') as any;
expect(component.canLoadScoreboard).toBe(false);
expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).toBe(true);
});
});

View File

@@ -3,14 +3,17 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client';
import type { FinishGameResponse, ScoreboardResponse, SessionDetailResponse } from '../../../../../src/api/types';
import { deriveGameplayPhase, isHostGameplayActionAllowed } from '../../../../../src/spa/gameplay-phase-machine';
import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
type SessionDetail = SessionDetailResponse;
interface SessionDetail {
session: { code: string; status: string; current_round: number };
round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null;
players: Array<{ id: number; nickname: string; score: number }>;
}
type LeaderboardEntry = FinishGameResponse['leaderboard'][number];
type LeaderboardEntry = ScoreboardResponse['leaderboard'][number];
type LeaderboardResponse = FinishGameResponse;
@Component({
@@ -22,22 +25,23 @@ type LeaderboardResponse = FinishGameResponse;
<div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput">
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
<label *ngIf="canStartRound">{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
<label>{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button (click)="startRound()" [disabled]="loading || !canStartRound">{{ copy('host.start_round') }}</button>
<button (click)="showQuestion()" [disabled]="loading || !canShowQuestion">{{ copy('host.show_question') }}</button>
<button (click)="mixAnswers()" [disabled]="loading || !canMixAnswers">{{ copy('host.mix_answers') }}</button>
<button (click)="calculateScores()" [disabled]="loading || !canCalculateScores">{{ copy('host.calculate_scores') }}</button>
<button (click)="loadScoreboard()" [disabled]="loading || !canLoadScoreboard">{{ copy('host.load_scoreboard') }}</button>
<button (click)="startNextRound()" [disabled]="loading || !canStartNextRound">{{ copy('host.start_next_round') }}</button>
<button (click)="finishGame()" [disabled]="loading || !canFinishGame">{{ copy('host.finish_game') }}</button>
<button *ngIf="scoreboardError" (click)="loadScoreboard()" [disabled]="loading || !canLoadScoreboard">{{ copy('host.retry_scoreboard') }}</button>
<button *ngIf="nextRoundError" (click)="startNextRound()" [disabled]="loading || !canStartNextRound">{{ copy('host.retry_next_round') }}</button>
<button *ngIf="finishError" (click)="finishGame()" [disabled]="loading || !canFinishGame">{{ copy('host.retry_finish') }}</button>
<button (click)="startRound()" [disabled]="loading">{{ copy('host.start_round') }}</button>
<button (click)="showQuestion()" [disabled]="loading || !roundQuestionId">{{ copy('host.show_question') }}</button>
<button (click)="mixAnswers()" [disabled]="loading || !roundQuestionId">{{ copy('host.mix_answers') }}</button>
<button (click)="calculateScores()" [disabled]="loading || !roundQuestionId">{{ copy('host.calculate_scores') }}</button>
<button (click)="loadScoreboard()" [disabled]="loading">{{ copy('host.load_scoreboard') }}</button>
<button (click)="startNextRound()" [disabled]="loading">{{ copy('host.start_next_round') }}</button>
<button (click)="finishGame()" [disabled]="loading">{{ copy('host.finish_game') }}</button>
<button *ngIf="scoreboardError" (click)="loadScoreboard()" [disabled]="loading">{{ copy('host.retry_scoreboard') }}</button>
<button *ngIf="nextRoundError" (click)="startNextRound()" [disabled]="loading">{{ copy('host.retry_next_round') }}</button>
<button *ngIf="finishError" (click)="finishGame()" [disabled]="loading">{{ copy('host.retry_finish') }}</button>
</div>
<p *ngIf="session" class="hint">{{ copy('host.audio_locale_hint') }}: {{ locale }}</p>
<p *ngIf="error" class="error">{{ error }}</p>
<p *ngIf="scoreboardError" class="error">{{ scoreboardError }}</p>
<p *ngIf="nextRoundError" class="error">{{ nextRoundError }}</p>
<p *ngIf="finishError" class="error">{{ finishError }}</p>
@@ -48,28 +52,7 @@ type LeaderboardResponse = FinishGameResponse;
<ul>
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
</ul>
<div class="panel" *ngIf="showRevealPanel">
<h3>Reveal</h3>
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
<div *ngIf="session.reveal.lies.length">
<strong>Løgne</strong>
<ul>
<li *ngFor="let lie of session.reveal.lies">{{ lie.nickname }} løj: {{ lie.text }}</li>
</ul>
</div>
<div *ngIf="session.reveal.guesses.length">
<strong>Gæt</strong>
<ul>
<li *ngFor="let guess of session.reveal.guesses">
{{ guess.nickname }} valgte {{ guess.selected_text }}
<span *ngIf="guess.is_correct">· korrekt</span>
<span *ngIf="!guess.is_correct && guess.fooled_player_nickname">· narret af {{ guess.fooled_player_nickname }}</span>
<span *ngIf="!guess.is_correct && !guess.fooled_player_nickname">· forkert</span>
</li>
</ul>
</div>
</div>
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
<div *ngIf="finalLeaderboard.length">
<h3>{{ copy('host.final_leaderboard') }}</h3>
<p *ngIf="finalWinner"><strong>{{ copy('host.winner') }}:</strong> {{ finalWinner.nickname }} ({{ finalWinner.score }} {{ copy('common.points_short') }})</p>
@@ -131,42 +114,6 @@ export class HostShellComponent implements OnInit, OnDestroy {
this.unsubscribeLocale = null;
}
get gameplayPhase(): string | null {
return deriveGameplayPhase(this.session as any);
}
get canStartRound(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'startRound');
}
get canShowQuestion(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'showQuestion');
}
get canMixAnswers(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'mixAnswers');
}
get canCalculateScores(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'calculateScores');
}
get canLoadScoreboard(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'loadScoreboard');
}
get canStartNextRound(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'startNextRound');
}
get canFinishGame(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'finishGame');
}
get showRevealPanel(): boolean {
return Boolean(this.session?.reveal && (this.gameplayPhase === 'reveal' || this.gameplayPhase === 'scoreboard'));
}
copy(key: string): string {
return t(key, this.locale);
}
@@ -227,10 +174,6 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
async startRound(): Promise<void> {
if (!this.canStartRound) {
return;
}
await this.runAction(async () => {
const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim());
if (!state.session || state.errorMessage) {
@@ -240,16 +183,13 @@ export class HostShellComponent implements OnInit, OnDestroy {
this.sessionCode = this.session.session.code;
this.persistSessionCode(this.sessionCode);
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
this.scoreboardPayload = '';
this.resetFinalLeaderboard();
this.syncRouteFromSession();
});
}
async showQuestion(): Promise<void> {
if (!this.canShowQuestion) {
return;
}
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {});
@@ -258,10 +198,6 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
async mixAnswers(): Promise<void> {
if (!this.canMixAnswers) {
return;
}
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
const roundQuestionId = this.roundQuestionId.trim();
@@ -271,10 +207,6 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
async calculateScores(): Promise<void> {
if (!this.canCalculateScores) {
return;
}
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
const roundQuestionId = this.roundQuestionId.trim();
@@ -284,16 +216,12 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
async loadScoreboard(): Promise<void> {
if (!this.canLoadScoreboard) {
return;
}
this.loading = true;
this.scoreboardError = '';
this.error = '';
try {
const code = this.normalizeCode(this.sessionCode);
const payload = await this.request<ScoreboardResponse>(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET');
const payload = await this.request<unknown>(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET');
this.scoreboardPayload = JSON.stringify(payload, null, 2);
await this.refreshSession();
} catch (error) {
@@ -304,10 +232,6 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
async startNextRound(): Promise<void> {
if (!this.canStartNextRound) {
return;
}
this.loading = true;
this.nextRoundError = '';
this.error = '';
@@ -317,6 +241,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
throw new Error(this.copy('host.session_code_required'));
}
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {});
this.scoreboardPayload = '';
this.resetFinalLeaderboard();
await this.refreshSession();
} catch (error) {
@@ -327,10 +252,6 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
async finishGame(): Promise<void> {
if (!this.canFinishGame) {
return;
}
this.loading = true;
this.finishError = '';
this.error = '';
@@ -357,7 +278,6 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
private resetFinalLeaderboard(): void {
this.scoreboardPayload = '';
this.finalLeaderboardPayload = '';
this.finalLeaderboard = [];
this.finalWinner = null;
@@ -368,7 +288,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
return;
}
const phase = this.gameplayPhase ?? this.session.session.status ?? 'lobby';
const phase = this.session.session.status || 'lobby';
const code = this.normalizeCode(this.session.session.code || this.sessionCode);
if (!code) {
return;

View File

@@ -1,6 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import lobbyCatalog from '../../../../../../shared/i18n/lobby.json';
import { PlayerShellComponent } from './player-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
@@ -13,29 +12,7 @@ function jsonResponse(status: number, body: unknown) {
} as unknown as Response;
}
function sessionDetailPayload(
status: string,
options?: {
currentPhase?: string;
answers?: string[];
players?: Array<{ id: number; nickname: string; score: number }>;
roundQuestionId?: number | null;
reveal?: {
correct_answer: string;
prompt?: string;
lies?: Array<{ player_id: number; nickname: string; text: string; created_at?: string }>;
guesses?: Array<{
player_id: number;
nickname: string;
selected_text: string;
is_correct: boolean;
fooled_player_id: number | null;
fooled_player_nickname?: string;
created_at?: string;
}>;
} | null;
}
) {
function sessionDetailPayload(status: string, options?: { answers?: string[]; players?: Array<{ id: number; nickname: string; score: number }>; roundQuestionId?: number | null }) {
const answers = options?.answers ?? [];
const roundQuestionId = options?.roundQuestionId ?? 11;
@@ -61,26 +38,8 @@ function sessionDetailPayload(
...player,
is_connected: true,
})),
reveal:
options?.reveal === undefined || options?.reveal === null
? null
: {
round_question_id: roundQuestionId,
round_number: 1,
prompt: options.reveal.prompt ?? 'Q?',
correct_answer: options.reveal.correct_answer,
lies: (options.reveal.lies ?? []).map((lie) => ({
...lie,
created_at: lie.created_at ?? '2026-01-01T00:00:05Z',
})),
guesses: (options.reveal.guesses ?? []).map((guess) => ({
...guess,
created_at: guess.created_at ?? '2026-01-01T00:00:10Z',
})),
},
phase_view_model: {
status,
current_phase: options?.currentPhase ?? status,
round_number: 1,
players_count: (options?.players ?? []).length,
constraints: {
@@ -89,10 +48,6 @@ function sessionDetailPayload(
min_players_reached: true,
max_players_allowed: true,
},
readiness: {
question_ready: (options?.currentPhase ?? status) !== 'lobby',
scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard',
},
host: {
can_start_round: false,
can_show_question: false,
@@ -103,10 +58,10 @@ function sessionDetailPayload(
can_finish_game: false,
},
player: {
can_join: (options?.currentPhase ?? status) === 'lobby',
can_submit_lie: (options?.currentPhase ?? status) === 'lie',
can_submit_guess: (options?.currentPhase ?? status) === 'guess',
can_view_final_result: (options?.currentPhase ?? status) === 'finished',
can_join: status === 'lobby',
can_submit_lie: status === 'lie',
can_submit_guess: status === 'guess',
can_view_final_result: status === 'finished',
},
},
};
@@ -153,8 +108,9 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.sessionToken = 'token-1';
component.lieText = 'my lie';
component.session = {
...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any),
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [] },
players: [],
};
await component.submitLie();
@@ -201,63 +157,6 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']);
});
it('hydrates canonical reveal payload after guess -> reveal', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(
200,
sessionDetailPayload('reveal', {
answers: ['A', 'B'],
reveal: {
correct_answer: 'A',
lies: [{ player_id: 3, nickname: 'Løgnhals', text: 'B' }],
guesses: [
{
player_id: 9,
nickname: 'Detektiv',
selected_text: 'B',
is_correct: false,
fooled_player_id: 3,
fooled_player_nickname: 'Løgnhals',
},
{
player_id: 10,
nickname: 'Sandhed',
selected_text: 'A',
is_correct: true,
fooled_player_id: null,
},
],
},
})
)
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.session?.reveal?.correct_answer).toBe('A');
expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 3, nickname: 'Løgnhals', text: 'B' });
expect(component.session?.reveal?.guesses[0]).toMatchObject({
player_id: 9,
nickname: 'Detektiv',
selected_text: 'B',
is_correct: false,
fooled_player_id: 3,
fooled_player_nickname: 'Løgnhals',
});
expect(component.session?.reveal?.guesses[1]).toMatchObject({
player_id: 10,
nickname: 'Sandhed',
selected_text: 'A',
is_correct: true,
fooled_player_id: null,
});
});
it('surfaces guess submit error and retries with selected answer payload', async () => {
const fetchMock: FetchMock = vi
.fn()
@@ -273,8 +172,9 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.sessionToken = 'token-1';
component.selectedGuess = 'B';
component.session = {
...(sessionDetailPayload('guess', { answers: ['A', 'B'], roundQuestionId: 11 }) as any),
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
players: [],
};
await component.submitGuess();
@@ -298,29 +198,6 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('blocks illegal player guess submission outside canonical guess phase', async () => {
const fetchMock: FetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'token-1';
component.selectedGuess = 'B';
for (const status of ['lie', 'reveal', 'scoreboard'] as const) {
component.session = {
...(sessionDetailPayload(status, { answers: ['A', 'B'] }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
};
await component.submitGuess();
}
expect(component.canSubmitGuess).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it('auto-refreshes player session to avoid host/player state desync between rounds', async () => {
vi.useFakeTimers();
@@ -443,34 +320,6 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(values.get('wpp.session-context')).toBeUndefined();
});
it('prefers canonical current_phase for player reveal panel and routing when status lags behind', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'scoreboard', roundQuestionId: 11, reveal: { correct_answer: 'A' } }))
);
vi.stubGlobal('fetch', fetchMock);
const replaceState = vi.fn();
const localStorage = { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() };
vi.stubGlobal('window', {
location: { hash: '#/player/reveal/ABCD12' },
history: { state: null, replaceState },
localStorage,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.gameplayPhase).toBe('scoreboard');
expect(component.showRevealPanel).toBe(true);
expect(component.showGuessControls).toBe(false);
expect(replaceState).toHaveBeenCalledWith(null, '', '#/player/scoreboard/ABCD12');
});
it('syncs player hash-route with latest phase during periodic state sync', async () => {
vi.useFakeTimers();
@@ -551,14 +400,7 @@ describe('PlayerShellComponent gameplay wiring', () => {
const component = new PlayerShellComponent();
component.ngOnInit();
const pause = vi.fn();
const audioElement = { muted: false, defaultMuted: false, volume: 1, pause };
await expect(mediaPrototype.play.call(audioElement)).resolves.toBeUndefined();
expect(audioElement.muted).toBe(true);
expect(audioElement.defaultMuted).toBe(true);
expect(audioElement.volume).toBe(0);
expect(pause).toHaveBeenCalledTimes(1);
await expect(mediaPrototype.play()).resolves.toBeUndefined();
component.ngOnDestroy();
@@ -594,102 +436,4 @@ describe('PlayerShellComponent gameplay wiring', () => {
secondComponent.ngOnDestroy();
await expect(mediaPrototype.play()).rejects.toThrow('original play');
});
it('does not trigger original media play during player-shell init path', () => {
const originalPlay = vi.fn().mockResolvedValue(undefined);
const mediaPrototype = { play: originalPlay };
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
HTMLMediaElement: { prototype: mediaPrototype },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const component = new PlayerShellComponent();
component.ngOnInit();
expect(originalPlay).not.toHaveBeenCalled();
component.ngOnDestroy();
});
it('keeps primary-device playback untouched when no-audio capability is disabled', async () => {
const originalPlay = vi.fn().mockResolvedValue(undefined);
const mediaPrototype = { play: originalPlay };
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
HTMLMediaElement: { prototype: mediaPrototype },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const component = new PlayerShellComponent();
(component as any).clientHasNoAudioOutput = false;
component.ngOnInit();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
expect(mediaPrototype.play).toBe(originalPlay);
expect((mediaPrototype as any).__wppSecondaryDeviceAudioGuard__).toBeUndefined();
component.ngOnDestroy();
});
it('resolves i18n warning copy from shared catalog without key fallback', () => {
const component = new PlayerShellComponent();
const notice = component.copy('player.audio_policy_notice');
const expected = lobbyCatalog.frontend.ui.player.audio_policy_notice[component.locale];
expect(notice).toBe(expected);
expect(notice).not.toBe('player.audio_policy_notice');
});
it('gates template warning notice on the no-audio-output capability flag', () => {
const templateSource = String((PlayerShellComponent as any).ɵcmp?.template);
expect(templateSource).toContain('clientHasNoAudioOutput');
const component = new PlayerShellComponent();
expect(component.copy('player.audio_policy_notice')).not.toBe('player.audio_policy_notice');
expect(component.clientHasNoAudioOutput).toBe(true);
(component as any).clientHasNoAudioOutput = false;
expect(component.clientHasNoAudioOutput).toBe(false);
});
it('keeps phone client controls phase-specific and low-complexity', () => {
const component = new PlayerShellComponent();
expect(component.showJoinControls).toBe(true);
expect(component.showLieControls).toBe(false);
expect(component.showGuessControls).toBe(false);
expect(component.showFinalLeaderboard).toBe(false);
component.session = sessionDetailPayload('lie') as any;
component.playerId = 9;
component.sessionToken = 'tok';
expect(component.showJoinControls).toBe(false);
expect(component.showLieControls).toBe(true);
expect(component.showGuessControls).toBe(false);
component.session = sessionDetailPayload('guess', { answers: ['A', 'B'] }) as any;
expect(component.showLieControls).toBe(false);
expect(component.showGuessControls).toBe(true);
component.session = sessionDetailPayload('finished', { players: [{ id: 1, nickname: 'Luna', score: 8 }] }) as any;
expect(component.showGuessControls).toBe(false);
expect(component.showFinalLeaderboard).toBe(true);
});
});

View File

@@ -3,35 +3,31 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client';
import type { SessionDetailResponse } from '../../../../../src/api/types';
import {
deriveGameplayPhase,
isPlayerGameplayActionAllowed,
} from '../../../../../src/spa/gameplay-phase-machine';
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
type SessionDetail = SessionDetailResponse;
interface SessionDetail {
session: { code: string; status: string; current_round: number };
round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null;
players: Array<{ id: number; nickname: string; score: number }>;
}
type ConnectionState = 'online' | 'reconnecting' | 'offline';
type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null;
type GuardableMediaElement = {
muted?: boolean;
defaultMuted?: boolean;
volume?: number;
pause?: () => void;
};
type MediaPrototypeWithGuardState = {
play?: (this: GuardableMediaElement) => Promise<void>;
play?: () => Promise<void>;
__wppSecondaryDeviceAudioGuard__?: {
originalPlay: (this: GuardableMediaElement) => Promise<void>;
originalPlay: () => Promise<void>;
installs: number;
};
};
type GuardableMediaElement = {
muted?: boolean;
pause?: () => void;
};
function resolveLocalStorage(): Storage | undefined {
if (typeof window === 'undefined') {
@@ -46,13 +42,12 @@ function resolveLocalStorage(): Storage | undefined {
imports: [CommonModule, FormsModule],
template: `
<h2>{{ copy('player.title') }}</h2>
<p *ngIf="clientHasNoAudioOutput" class="hint">{{ copy('player.audio_policy_notice') }}</p>
<div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput">
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
<label *ngIf="showJoinControls">{{ copy('player.nickname') }} <input [(ngModel)]="nickname" /></label>
<label>{{ copy('player.nickname') }} <input [(ngModel)]="nickname" /></label>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button *ngIf="showJoinControls" (click)="joinSession()" [disabled]="loading">{{ copy('player.join') }}</button>
<button (click)="joinSession()" [disabled]="loading">{{ copy('player.join') }}</button>
</div>
<p *ngIf="connectionState === 'reconnecting'" class="error">
@@ -72,53 +67,26 @@ function resolveLocalStorage(): Storage | undefined {
<p><strong>{{ copy('common.status') }}:</strong> {{ session.session.status }}</p>
<p *ngIf="session.round_question"><strong>{{ copy('common.prompt') }}:</strong> {{ session.round_question.prompt }}</p>
<ng-container *ngIf="showLieControls">
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading || !canSubmitLie" /></label>
<button (click)="submitLie()" [disabled]="loading || !canSubmitLie">{{ copy('player.submit_lie') }}</button>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading || !canSubmitLie">{{ copy('player.retry_lie_submit') }}</button>
</ng-container>
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading || session.session.status !== 'lie'" /></label>
<button (click)="submitLie()" [disabled]="loading || session.session.status !== 'lie'">{{ copy('player.submit_lie') }}</button>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading">{{ copy('player.retry_lie_submit') }}</button>
<ng-container *ngIf="showGuessControls">
<div class="answers" *ngIf="session.round_question?.answers?.length">
<button
type="button"
*ngFor="let answer of session.round_question?.answers"
(click)="selectedGuess = answer.text"
[class.active]="selectedGuess === answer.text"
[disabled]="loading || !canSubmitGuess"
>
{{ answer.text }}
</button>
</div>
<button (click)="submitGuess()" [disabled]="loading || !canSubmitGuess || !selectedGuess">{{ copy('player.submit_guess') }}</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading || !canSubmitGuess">{{ copy('player.retry_guess_submit') }}</button>
</ng-container>
<div class="panel" *ngIf="showRevealPanel">
<h3>Reveal</h3>
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
<div *ngIf="session.reveal.lies.length">
<strong>Løgne</strong>
<ul>
<li *ngFor="let lie of session.reveal.lies">{{ lie.nickname }} løj: {{ lie.text }}</li>
</ul>
</div>
<div *ngIf="session.reveal.guesses.length">
<strong>Gæt</strong>
<ul>
<li *ngFor="let guess of session.reveal.guesses">
{{ guess.nickname }} valgte {{ guess.selected_text }}
<span *ngIf="guess.is_correct">· korrekt</span>
<span *ngIf="!guess.is_correct && guess.fooled_player_nickname">· narret af {{ guess.fooled_player_nickname }}</span>
<span *ngIf="!guess.is_correct && !guess.fooled_player_nickname">· forkert</span>
</li>
</ul>
</div>
<div class="answers" *ngIf="session.round_question?.answers?.length">
<button
type="button"
*ngFor="let answer of session.round_question?.answers"
(click)="selectedGuess = answer.text"
[class.active]="selectedGuess === answer.text"
[disabled]="loading || session.session.status !== 'guess'"
>
{{ answer.text }}
</button>
</div>
<div *ngIf="showFinalLeaderboard && finalLeaderboard.length">
<button (click)="submitGuess()" [disabled]="loading || session.session.status !== 'guess' || !selectedGuess">{{ copy('player.submit_guess') }}</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button>
<div *ngIf="session.session.status === 'finished' && finalLeaderboard.length">
<h3>{{ copy('player.final_leaderboard') }}</h3>
<ol>
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
@@ -209,22 +177,6 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.restoreAudioGuard = null;
}
get gameplayPhase(): string | null {
return deriveGameplayPhase(this.session as any);
}
get canSubmitLie(): boolean {
return isPlayerGameplayActionAllowed(this.session as any, 'submitLie');
}
get canSubmitGuess(): boolean {
return isPlayerGameplayActionAllowed(this.session as any, 'submitGuess');
}
get showRevealPanel(): boolean {
return Boolean(this.session?.reveal && (this.gameplayPhase === 'reveal' || this.gameplayPhase === 'scoreboard'));
}
private readonly handleOnline = (): void => {
this.connectionState = 'reconnecting';
void this.retryReconnect();
@@ -269,15 +221,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
guardState.installs += 1;
} else {
const originalPlay = mediaPrototype.play;
mediaPrototype.play = function mediaGuardedPlay(this: GuardableMediaElement): Promise<void> {
this.muted = true;
this.defaultMuted = true;
if (typeof this.volume === 'number') {
this.volume = 0;
}
this.pause?.();
return Promise.resolve();
};
mediaPrototype.play = () => Promise.resolve();
mediaPrototype.__wppSecondaryDeviceAudioGuard__ = {
originalPlay,
installs: 1,
@@ -298,14 +242,13 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
};
}
private silenceExistingMediaElements(): void {
if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') {
return;
}
const activeElements = document.querySelectorAll('audio,video') as
| NodeListOf<HTMLMediaElement>
| NodeListOf<GuardableMediaElement>
| GuardableMediaElement[]
| undefined;
@@ -315,10 +258,6 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
activeElements.forEach((element) => {
element.muted = true;
element.defaultMuted = true;
if (typeof element.volume === 'number') {
element.volume = 0;
}
element.pause?.();
});
}
@@ -344,25 +283,6 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}, 3000);
}
get showJoinControls(): boolean {
if (!this.session) {
return true;
}
return Boolean(this.session?.phase_view_model?.player?.can_join && !this.playerId && !this.sessionToken);
}
get showLieControls(): boolean {
return Boolean(this.session?.phase_view_model?.player?.can_submit_lie);
}
get showGuessControls(): boolean {
return Boolean(this.session?.phase_view_model?.player?.can_submit_guess);
}
get showFinalLeaderboard(): boolean {
return Boolean(this.session?.phase_view_model?.player?.can_view_final_result);
}
get loadingMessage(): string {
switch (this.loadingTransition) {
case 'join':
@@ -473,7 +393,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
return;
}
const phase = this.gameplayPhase ?? this.session.session.status ?? 'lobby';
const phase = this.session.session.status || 'lobby';
const code = this.normalizeCode(this.session.session.code || this.sessionCode);
if (!code) {
return;
@@ -563,7 +483,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
async submitLie(): Promise<void> {
if (!this.session?.round_question?.id || !this.canSubmitLie) {
if (!this.session?.round_question?.id) {
return;
}
this.loading = true;
@@ -591,7 +511,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
async submitGuess(): Promise<void> {
if (!this.session?.round_question?.id || !this.selectedGuess || !this.canSubmitGuess) {
if (!this.session?.round_question?.id || !this.selectedGuess) {
return;
}
this.loading = true;

View File

@@ -4,58 +4,13 @@ import { HostShellComponent } from './features/host/host-shell.component';
import { PlayerShellComponent } from './features/player/player-shell.component';
import { setPreferredLocale } from './lobby-i18n';
function stubShellGlobals(initialLocale: string) {
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(initialLocale), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: `${initialLocale}-US`, onLine: true });
}
describe('i18n MVP flow smoke (host/player + audio policy)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it.each([
{
locale: 'en',
hostRefresh: 'Refresh',
hostStartRound: 'Start round',
playerSubmitGuess: 'Submit guess',
},
{
locale: 'da',
hostRefresh: 'Opdatér',
hostStartRound: 'Start runde',
playerSubmitGuess: 'Send gæt',
},
])('resolves one host/player locale run for $locale', ({ locale, hostRefresh, hostStartRound, playerSubmitGuess }) => {
stubShellGlobals(locale);
const host = new HostShellComponent();
const player = new PlayerShellComponent();
host.ngOnInit();
player.ngOnInit();
setPreferredLocale(locale);
expect(host.copy('common.refresh')).toBe(hostRefresh);
expect(host.copy('game.host.start_round')).toBe(hostStartRound);
expect(player.copy('game.player.submit_guess')).toBe(playerSubmitGuess);
player.ngOnDestroy();
host.ngOnDestroy();
});
it('keeps audio routing primary-only by guarding player playback without muting the host path', async () => {
const originalPlay = vi.fn().mockRejectedValue(new Error('primary host playback'));
const mediaPrototype = { play: originalPlay };
it('resolves host/player copy in en and da from shared catalog', () => {
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
@@ -63,25 +18,44 @@ describe('i18n MVP flow smoke (host/player + audio policy)', () => {
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
HTMLMediaElement: { prototype: mediaPrototype },
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
vi.stubGlobal('document', { querySelectorAll: vi.fn().mockReturnValue([]) });
const host = new HostShellComponent();
host.ngOnInit();
await expect(mediaPrototype.play()).rejects.toThrow('primary host playback');
const player = new PlayerShellComponent();
host.ngOnInit();
player.ngOnInit();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
expect(host.copy('game.host.start_round')).toBe('Start round');
expect(player.copy('game.player.submit_guess')).toBe('Submit guess');
setPreferredLocale('da');
expect(host.copy('game.host.start_round')).toBe('Start runde');
expect(player.copy('game.player.submit_guess')).toBe('Send gæt');
player.ngOnDestroy();
host.ngOnDestroy();
});
await expect(mediaPrototype.play()).rejects.toThrow('primary host playback');
it('keeps audio routing policy primary-only (client has no audio output)', () => {
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const host = new HostShellComponent();
const player = new PlayerShellComponent();
expect(host.clientHasNoAudioOutput).toBe(true);
expect(player.clientHasNoAudioOutput).toBe(true);
player.ngOnDestroy();
host.ngOnDestroy();
});
});

View File

@@ -1,15 +1,26 @@
import {
DEFAULT_LOCALE,
LOBBY_I18N_CATALOG,
normalizeLocale,
type SupportedLocale,
translateCatalogPath,
} from '../../../shared/i18n/lobby-loader';
import lobbyCatalog from '../../../../shared/i18n/lobby.json';
type SupportedLocale = (typeof lobbyCatalog.locales.supported)[number];
const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale;
const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[];
let activeLocale: SupportedLocale | null = null;
const localeSubscribers = new Set<(locale: SupportedLocale) => void>();
export { normalizeLocale };
export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
const locale = (rawLocale ?? '').trim().toLowerCase();
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
return locale as SupportedLocale;
}
const shortLocale = locale.split('-')[0] ?? '';
if ((SUPPORTED_LOCALES as readonly string[]).includes(shortLocale)) {
return shortLocale as SupportedLocale;
}
return DEFAULT_LOCALE;
}
export function resolvePreferredLocale(): SupportedLocale {
if (activeLocale) {
@@ -68,7 +79,24 @@ function resolveCatalogPath(key: string): string {
}
export function t(key: string, locale: string): string {
return translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, resolveCatalogPath(key), locale);
const normalizedLocale = normalizeLocale(locale);
const fallbackLocale = DEFAULT_LOCALE;
const segments = resolveCatalogPath(key).split('.');
let cursor: unknown = lobbyCatalog.frontend.ui;
for (const segment of segments) {
if (!cursor || typeof cursor !== 'object' || !(segment in (cursor as Record<string, unknown>))) {
return key;
}
cursor = (cursor as Record<string, unknown>)[segment];
}
if (!cursor || typeof cursor !== 'object') {
return key;
}
const translations = cursor as Record<string, string>;
return translations[normalizedLocale] ?? translations[fallbackLocale] ?? key;
}
export const clientHasNoAudioOutput = Boolean(LOBBY_I18N_CATALOG.frontend.capabilities.client_has_no_audio_output);
export const clientHasNoAudioOutput = Boolean(lobbyCatalog.frontend.capabilities.client_has_no_audio_output);

View File

@@ -69,7 +69,6 @@ describe('session route context', () => {
sessionCode: 'AB12',
playerId: 5,
token: 'tok-5',
locale: 'en',
});
});
@@ -81,25 +80,7 @@ describe('session route context', () => {
sessionCode: 'AB12',
playerId: null,
token: null,
locale: 'en',
});
expect(sessionStorage.setItem).toHaveBeenCalledWith('wpp.host-session-code', 'AB12');
});
it('resolvers normalize and expose locale from lang query param', () => {
setWindow(storageMock(), storageMock());
expect(hostRouteContextResolver(route({}, { lang: 'da-DK' }) as never, {} as never).locale).toBe('da');
expect(playerRouteContextResolver(route({}, { lang: 'EN' }) as never, {} as never).locale).toBe('en');
});
it('does not reset persisted preferred locale when lang query param is absent', () => {
const localStorage = storageMock({ 'wpp.locale': 'da' });
setWindow(localStorage, storageMock());
expect(hostRouteContextResolver(route({}, { lang: 'da' }) as never, {} as never).locale).toBe('da');
expect(hostRouteContextResolver(route({}, {}) as never, {} as never).locale).toBe('da');
expect(localStorage.setItem).toHaveBeenCalledTimes(1);
expect(localStorage.setItem).toHaveBeenCalledWith('wpp.locale', 'da');
});
});

View File

@@ -2,13 +2,11 @@ import { inject } from '@angular/core';
import { type ActivatedRouteSnapshot, type CanActivateFn, type ResolveFn, Router, type UrlTree } from '@angular/router';
import { createSessionContextStore } from '../../../src/spa/session-context-store';
import { normalizeLocale, resolvePreferredLocale, setPreferredLocale } from './lobby-i18n';
export interface RouteSessionContext {
sessionCode: string | null;
playerId: number | null;
token: string | null;
locale: string;
}
const HOST_STORAGE_KEY = 'wpp.host-session-code';
@@ -63,17 +61,6 @@ export function resolveSessionCode(route: ActivatedRouteSnapshot, mode: 'host' |
return null;
}
function resolveRouteLocale(route: ActivatedRouteSnapshot): string {
const langParam = route.queryParamMap.get('lang');
if (langParam !== null) {
const locale = normalizeLocale(langParam);
setPreferredLocale(locale);
return locale;
}
return resolvePreferredLocale();
}
async function sessionExists(code: string): Promise<boolean> {
const response = await fetch(`/lobby/sessions/${encodeURIComponent(code)}`, {
method: 'GET',
@@ -131,26 +118,23 @@ export const playerRouteGuard: CanActivateFn = (route) => guard('player', route)
export const hostRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => {
const code = resolveSessionCode(route, 'host');
const locale = resolveRouteLocale(route);
if (code) {
window.sessionStorage.setItem(HOST_STORAGE_KEY, code);
}
return { sessionCode: code, playerId: null, token: null, locale };
return { sessionCode: code, playerId: null, token: null };
};
export const playerRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => {
const code = resolveSessionCode(route, 'player');
const locale = resolveRouteLocale(route);
const context = createSessionContextStore(window.localStorage).get();
if (!code || !context || normalizeCode(context.sessionCode) !== code) {
return { sessionCode: code, playerId: null, token: null, locale };
return { sessionCode: code, playerId: null, token: null };
}
return {
sessionCode: code,
playerId: Number.isInteger(context.playerId) && context.playerId > 0 ? context.playerId : null,
token: context.token.trim() || null,
locale,
};
};

View File

@@ -1,43 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { createWppApiClient } from './wpp-api-client';
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
describe('WPP Angular API client skeleton', () => {
it('normalizes host/player API calls through fetch transport', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 1 }, players: [], round_question: null, phase_view_model: { status: 'lobby', round_number: 1, players_count: 1, constraints: { min_players_to_start: 2, max_players_mvp: 8, min_players_reached: false, max_players_allowed: true }, host: { can_start_round: false, can_show_question: false, can_mix_answers: false, can_calculate_scores: false, can_reveal_scoreboard: false, can_start_next_round: false, can_finish_game: false }, player: { can_join: true, can_submit_lie: false, can_submit_guess: false, can_view_final_result: false } } }))
.mockResolvedValueOnce(jsonResponse(201, { player: { id: 1, nickname: 'Luna', session_token: 'tok', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } }));
const client = createWppApiClient(fetchMock);
const session = await client.getSession(' abcd12 ');
const joined = await client.joinSession({ code: ' abcd12 ', nickname: ' Luna ' });
expect(session.ok).toBe(true);
expect(joined.ok).toBe(true);
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12',
expect.objectContaining({ method: 'GET', credentials: 'same-origin' })
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/join',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({ code: 'ABCD12', nickname: 'Luna' }),
})
);
});
});

View File

@@ -1,58 +0,0 @@
import { InjectionToken } from '@angular/core';
import {
createAngularApiClient,
type AngularApiClient,
type AngularHttpClientLike,
} from '../../../src/api/angular-client';
export const WPP_API_CLIENT = new InjectionToken<AngularApiClient>('WPP_API_CLIENT');
export interface FetchLike {
(input: string, init?: RequestInit): Promise<Response>;
}
export function createFetchHttpClient(fetchImpl: FetchLike): AngularHttpClientLike {
return {
async get<T>(url: string): Promise<T> {
const response = await fetchImpl(url, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw {
status: response.status,
message: (payload as { error?: string }).error ?? `HTTP ${response.status}`,
error: payload,
};
}
return payload as T;
},
async post<T>(url: string, body: unknown): Promise<T> {
const response = await fetchImpl(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
credentials: 'same-origin',
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw {
status: response.status,
message: (payload as { error?: string }).error ?? `HTTP ${response.status}`,
error: payload,
};
}
return payload as T;
},
};
}
export function createWppApiClient(fetchImpl: FetchLike = fetch.bind(globalThis)): AngularApiClient {
return createAngularApiClient(createFetchHttpClient(fetchImpl));
}

View File

@@ -7,125 +7,12 @@
"": {
"name": "wpp-frontend-api-client-baseline",
"version": "0.1.0",
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.7.3",
"vitest": "^2.1.9"
}
},
"node_modules/@angular/common": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.20.tgz",
"integrity": "sha512-1M3W3FjUUbVKXDMs+yQpBhnkD/pCe0Jn79rPE5W+EGWWxFoLSyGX+fhnRO5m4c9k66p3nvYrikWQ0ZzMv3M5tw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/core": "19.2.20",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/compiler": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.20.tgz",
"integrity": "sha512-LvjE8W58EACgTFaAoqmNe7FRsbvoQ0GvCB/rmm6AEMWx/0W/JBvWkQTrOQlwpoeYOHcMZRGdmPcZoUDwU3JySQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
}
},
"node_modules/@angular/core": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.20.tgz",
"integrity": "sha512-pxzQh8ouqfE57lJlXjIzXFuRETwkfMVwS+NFCfv2yh01Qtx+vymO8ZClcJMgLPfBYinhBYX+hrRYVSa1nzlkRQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0"
}
},
"node_modules/@angular/forms": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.20.tgz",
"integrity": "sha512-agi7InbMzop1jrud6L7SlNwnZk3iNolORcFIwBQMvKxLkcJ+ttbSYuM0KAw56IundWHf4dL9GP4cSygm4kUeFA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/common": "19.2.20",
"@angular/core": "19.2.20",
"@angular/platform-browser": "19.2.20",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/platform-browser": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.20.tgz",
"integrity": "sha512-O9ZoQKILPC1T2c64OASS75XlOLBxY81m5AAgsBKhwiFWq+V28RsO0cnwpi1YSh/z4ryH8Fe7IUFz8jGrsJi3hQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/animations": "19.2.20",
"@angular/common": "19.2.20",
"@angular/core": "19.2.20"
},
"peerDependenciesMeta": {
"@angular/animations": {
"optional": true
}
}
},
"node_modules/@angular/router": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.20.tgz",
"integrity": "sha512-y0fyKycxJHr82kxXKE50Vac5hPn5Kx3gw9CfqyEuwJ9VQzEixDljU+chrQK4Wods14jJn9Tt2ncNPGH1rLya3Q==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/common": "19.2.20",
"@angular/core": "19.2.20",
"@angular/platform-browser": "19.2.20",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -1301,15 +1188,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1385,12 +1263,6 @@
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1577,12 +1449,6 @@
"engines": {
"node": ">=8"
}
},
"node_modules/zone.js": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
"license": "MIT"
}
}
}

View File

@@ -7,17 +7,6 @@
"test": "vitest run",
"build": "tsc --noEmit"
},
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.7.3",

View File

@@ -1,74 +0,0 @@
import lobbyCatalog from '../../../shared/i18n/lobby.json';
export type LobbyCatalog = typeof lobbyCatalog;
export type SupportedLocale = LobbyCatalog['locales']['supported'][number];
export const LOBBY_I18N_CATALOG = lobbyCatalog;
export const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale;
export const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[];
export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
const locale = (rawLocale ?? '').trim().toLowerCase().replace(/_/g, '-');
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
return locale as SupportedLocale;
}
const shortLocale = locale.split('-')[0] ?? '';
if ((SUPPORTED_LOCALES as readonly string[]).includes(shortLocale)) {
return shortLocale as SupportedLocale;
}
return DEFAULT_LOCALE;
}
export function translateCatalogPath(
root: Record<string, unknown>,
keyPath: string,
locale: string,
fallback = DEFAULT_LOCALE,
): string {
const normalizedLocale = normalizeLocale(locale);
const segments = keyPath.split('.');
let cursor: unknown = root;
for (const segment of segments) {
if (!cursor || typeof cursor !== 'object' || !(segment in (cursor as Record<string, unknown>))) {
return keyPath;
}
cursor = (cursor as Record<string, unknown>)[segment];
}
if (!cursor || typeof cursor !== 'object') {
return keyPath;
}
const translations = cursor as Record<string, string>;
return translations[normalizedLocale] ?? translations[fallback] ?? keyPath;
}
export function collectLocaleParityIssues(
node: unknown,
locales: readonly string[] = SUPPORTED_LOCALES,
path = '',
): string[] {
if (!node || typeof node !== 'object') {
return [];
}
const record = node as Record<string, unknown>;
const keys = Object.keys(record);
const isTranslationLeaf = keys.length > 0 && locales.every((locale) => locale in record);
if (isTranslationLeaf) {
const issues: string[] = [];
for (const locale of locales) {
const value = record[locale];
if (typeof value !== 'string' || !value.trim()) {
issues.push(`${path || '<root>'} missing non-empty '${locale}' translation`);
}
}
return issues;
}
return keys.flatMap((key) => collectLocaleParityIssues(record[key], locales, path ? `${path}.${key}` : key));
}

View File

@@ -4,10 +4,10 @@ import {
mapHealthResponse,
mapJoinSessionResponse,
mapMixAnswersResponse,
mapNextRoundResponse,
mapScoreboardResponse,
mapSessionDetailResponse,
mapShowQuestionResponse,
mapStartNextRoundResponse,
mapStartRoundResponse,
mapSubmitGuessResponse,
mapSubmitLieResponse
@@ -20,10 +20,10 @@ import type {
JoinSessionRequest,
JoinSessionResponse,
MixAnswersResponse,
NextRoundResponse,
ScoreboardResponse,
SessionDetailResponse,
ShowQuestionResponse,
StartNextRoundResponse,
StartRoundRequest,
StartRoundResponse,
SubmitGuessRequest,
@@ -41,7 +41,7 @@ export interface ApiClient {
mixAnswers(code: string, roundQuestionId: number): Promise<ApiResult<MixAnswersResponse>>;
calculateScores(code: string, roundQuestionId: number): Promise<ApiResult<CalculateScoresResponse>>;
getScoreboard(code: string): Promise<ApiResult<ScoreboardResponse>>;
startNextRound(code: string): Promise<ApiResult<StartNextRoundResponse>>;
startNextRound(code: string): Promise<ApiResult<NextRoundResponse>>;
finishGame(code: string): Promise<ApiResult<FinishGameResponse>>;
submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise<ApiResult<SubmitLieResponse>>;
submitGuess(code: string, roundQuestionId: number, payload: SubmitGuessRequest): Promise<ApiResult<SubmitGuessResponse>>;
@@ -167,10 +167,10 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
mapScoreboardResponse
),
startNextRound: (code: string) =>
request<StartNextRoundResponse>(
request<NextRoundResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`,
'POST',
mapStartNextRoundResponse,
mapNextRoundResponse,
{}
),
finishGame: (code: string) =>

View File

@@ -60,17 +60,6 @@ function readBoolean(record: Record<string, unknown>, key: string, path: string)
return value;
}
function readNullableNumber(record: Record<string, unknown>, key: string, path: string): number | null {
const value = record[key];
if (value === undefined || value === null) {
return null;
}
if (!isNumber(value)) {
throw new Error(`Invalid API contract: expected number|null at ${path}.${key}`);
}
return value;
}
export function mapHealthResponse(payload: unknown): HealthResponse {
const root = asRecord(payload, 'health');
return {
@@ -113,58 +102,6 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
const host = asRecord(phase.host, 'session_detail.phase_view_model.host');
const player = asRecord(phase.player, 'session_detail.phase_view_model.player');
const revealRaw = root.reveal;
let reveal: SessionDetailResponse['reveal'] = null;
if (revealRaw !== null && revealRaw !== undefined) {
const revealRecord = asRecord(revealRaw, 'session_detail.reveal');
const liesRaw = revealRecord.lies;
const guessesRaw = revealRecord.guesses;
if (!Array.isArray(liesRaw)) {
throw new Error('Invalid API contract: expected array at session_detail.reveal.lies');
}
if (!Array.isArray(guessesRaw)) {
throw new Error('Invalid API contract: expected array at session_detail.reveal.guesses');
}
reveal = {
round_question_id: readNumber(revealRecord, 'round_question_id', 'session_detail.reveal'),
round_number: readNumber(revealRecord, 'round_number', 'session_detail.reveal'),
prompt: readString(revealRecord, 'prompt', 'session_detail.reveal'),
correct_answer: readString(revealRecord, 'correct_answer', 'session_detail.reveal'),
lies: liesRaw.map((lie, index) => {
const record = asRecord(lie, `session_detail.reveal.lies[${index}]`);
return {
player_id: readNumber(record, 'player_id', `session_detail.reveal.lies[${index}]`),
nickname: readString(record, 'nickname', `session_detail.reveal.lies[${index}]`),
text: readString(record, 'text', `session_detail.reveal.lies[${index}]`),
created_at: readString(record, 'created_at', `session_detail.reveal.lies[${index}]`)
};
}),
guesses: guessesRaw.map((guess, index) => {
const path = `session_detail.reveal.guesses[${index}]`;
const record = asRecord(guess, path);
const fooledPlayerId = readNullableNumber(record, 'fooled_player_id', path);
const fooledPlayerNickname = record.fooled_player_nickname;
if (fooledPlayerId === null) {
if (fooledPlayerNickname !== undefined) {
throw new Error(`Invalid API contract: expected ${path}.fooled_player_nickname to be omitted when fooled_player_id is null`);
}
} else if (!isString(fooledPlayerNickname)) {
throw new Error(`Invalid API contract: expected string at ${path}.fooled_player_nickname when fooled_player_id is set`);
}
return {
player_id: readNumber(record, 'player_id', path),
nickname: readString(record, 'nickname', path),
selected_text: readString(record, 'selected_text', path),
is_correct: readBoolean(record, 'is_correct', path),
fooled_player_id: fooledPlayerId,
...(fooledPlayerNickname === undefined ? {} : { fooled_player_nickname: fooledPlayerNickname }),
created_at: readString(record, 'created_at', path)
};
})
};
}
return {
session: {
code: readString(session, 'code', 'session_detail.session'),
@@ -192,10 +129,8 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
};
}),
round_question: roundQuestion,
reveal,
phase_view_model: {
status: readString(phase, 'status', 'session_detail.phase_view_model'),
current_phase: typeof phase.current_phase === 'string' ? phase.current_phase : undefined,
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'),
constraints: {
@@ -204,19 +139,6 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
min_players_reached: readBoolean(constraints, 'min_players_reached', 'session_detail.phase_view_model.constraints'),
max_players_allowed: readBoolean(constraints, 'max_players_allowed', 'session_detail.phase_view_model.constraints')
},
readiness:
phase.readiness && typeof phase.readiness === 'object'
? {
question_ready:
typeof (phase.readiness as Record<string, unknown>).question_ready === 'boolean'
? ((phase.readiness as Record<string, unknown>).question_ready as boolean)
: undefined,
scoreboard_ready:
typeof (phase.readiness as Record<string, unknown>).scoreboard_ready === 'boolean'
? ((phase.readiness as Record<string, unknown>).scoreboard_ready as boolean)
: undefined,
}
: undefined,
host: {
can_start_round: readBoolean(host, 'can_start_round', 'session_detail.phase_view_model.host'),
can_show_question: readBoolean(host, 'can_show_question', 'session_detail.phase_view_model.host'),
@@ -415,7 +337,10 @@ export function mapSubmitGuessResponse(payload: unknown): SubmitGuessResponse {
const root = asRecord(payload, 'submit_guess');
const guess = asRecord(root.guess, 'submit_guess.guess');
const window = asRecord(root.window, 'submit_guess.window');
const fooledPlayerId = readNullableNumber(guess, 'fooled_player_id', 'submit_guess.guess');
const fooledPlayerId = guess.fooled_player_id;
if (fooledPlayerId !== null && !isNumber(fooledPlayerId)) {
throw new Error('Invalid API contract: expected number|null at submit_guess.guess.fooled_player_id');
}
return {
guess: {

View File

@@ -32,7 +32,6 @@ export interface SessionRoundQuestion {
export interface PhaseViewModel {
status: string;
current_phase?: string;
round_number: number;
players_count: number;
constraints: {
@@ -41,10 +40,6 @@ export interface PhaseViewModel {
min_players_reached: boolean;
max_players_allowed: boolean;
};
readiness?: {
question_ready?: boolean;
scoreboard_ready?: boolean;
};
host: {
can_start_round: boolean;
can_show_question: boolean;
@@ -62,37 +57,10 @@ export interface PhaseViewModel {
};
}
export interface RevealLie {
player_id: number;
nickname: string;
text: string;
created_at: string;
}
export interface RevealGuess {
player_id: number;
nickname: string;
selected_text: string;
is_correct: boolean;
fooled_player_id: number | null;
fooled_player_nickname?: string;
created_at: string;
}
export interface RevealPayload {
round_question_id: number;
round_number: number;
prompt: string;
correct_answer: string;
lies: RevealLie[];
guesses: RevealGuess[];
}
export interface SessionDetailResponse {
session: SessionSummary;
players: SessionPlayer[];
round_question: SessionRoundQuestion | null;
reveal: RevealPayload | null;
phase_view_model: PhaseViewModel;
}

View File

@@ -1,15 +1,6 @@
import type { PhaseViewModel, SessionDetailResponse } from '../api/types';
import type { SessionDetailResponse } from '../api/types';
export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard';
export type HostGameplayAction =
| 'startRound'
| 'showQuestion'
| 'mixAnswers'
| 'calculateScores'
| 'loadScoreboard'
| 'startNextRound'
| 'finishGame';
export type PlayerGameplayAction = 'join' | 'submitLie' | 'submitGuess' | 'viewFinalResult';
export type GameplayPhaseEvent =
| 'LIES_LOCKED'
@@ -49,7 +40,8 @@ export function allowedGameplayEvents(phase: GameplayPhase): GameplayPhaseEvent[
return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[];
}
function derivePhaseFromStatus(status: string | null | undefined): GameplayPhase | null {
export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null {
const status = session?.session.status;
if (!status) {
return null;
}
@@ -64,59 +56,3 @@ function derivePhaseFromStatus(status: string | null | undefined): GameplayPhase
return null;
}
function deriveCanonicalPhaseStatus(phaseViewModel: PhaseViewModel | null | undefined): string | null {
if (!phaseViewModel) {
return null;
}
const currentPhase = (phaseViewModel as PhaseViewModel & { current_phase?: string }).current_phase;
return currentPhase ?? phaseViewModel.status ?? null;
}
export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null {
const canonicalStatus = deriveCanonicalPhaseStatus(session?.phase_view_model);
return derivePhaseFromStatus(canonicalStatus ?? session?.session.status);
}
export function isHostGameplayActionAllowed(session: SessionDetailResponse | null, action: HostGameplayAction): boolean {
if (!session) {
return action === 'startRound';
}
const host = session.phase_view_model?.host;
switch (action) {
case 'startRound':
return Boolean(host?.can_start_round ?? false);
case 'showQuestion':
return Boolean(host?.can_show_question ?? false);
case 'mixAnswers':
return Boolean(host?.can_mix_answers ?? false);
case 'calculateScores':
return Boolean(host?.can_calculate_scores ?? false);
case 'loadScoreboard':
return Boolean(host?.can_reveal_scoreboard ?? false);
case 'startNextRound':
return Boolean(host?.can_start_next_round ?? false);
case 'finishGame':
return Boolean(host?.can_finish_game ?? false);
}
}
export function isPlayerGameplayActionAllowed(session: SessionDetailResponse | null, action: PlayerGameplayAction): boolean {
if (!session) {
return action === 'join';
}
const player = session.phase_view_model?.player;
switch (action) {
case 'join':
return Boolean(player?.can_join ?? false);
case 'submitLie':
return Boolean(player?.can_submit_lie ?? false);
case 'submitGuess':
return Boolean(player?.can_submit_guess ?? false);
case 'viewFinalResult':
return Boolean(player?.can_view_final_result ?? false);
}
}

View File

@@ -1,17 +1,24 @@
import { DEFAULT_LOCALE, LOBBY_I18N_CATALOG, normalizeLocale } from '../../shared/i18n/lobby-loader';
import lobbyCatalog from '../../../shared/i18n/lobby.json';
const frontendErrors = LOBBY_I18N_CATALOG.frontend.errors;
const backendToFrontendErrorKeys = LOBBY_I18N_CATALOG.contract.backend_to_frontend_error_keys as Record<
string,
keyof typeof frontendErrors
>;
const frontendErrors = lobbyCatalog.frontend.errors;
const localeConfig = lobbyCatalog.locales;
const backendToFrontendErrorKeys = lobbyCatalog.contract.backend_to_frontend_error_keys as Record<string, keyof typeof frontendErrors>;
type FrontendErrorKey = keyof typeof frontendErrors;
type SupportedLocale = (typeof localeConfig.supported)[number];
function isFrontendErrorKey(value: string): value is FrontendErrorKey {
return value in frontendErrors;
}
function normalizeLocale(rawLocale?: string): SupportedLocale {
const requested = (rawLocale ?? '').trim().toLowerCase();
if (localeConfig.supported.includes(requested as SupportedLocale)) {
return requested as SupportedLocale;
}
return localeConfig.default;
}
export function lobbyMessage(key: FrontendErrorKey, locale?: string): string {
const resolvedLocale = normalizeLocale(locale);
const translations = frontendErrors[key] as Record<string, string>;
@@ -19,8 +26,8 @@ export function lobbyMessage(key: FrontendErrorKey, locale?: string): string {
if (translations[resolvedLocale]) {
return translations[resolvedLocale];
}
if (translations[DEFAULT_LOCALE]) {
return translations[DEFAULT_LOCALE];
if (translations[localeConfig.default]) {
return translations[localeConfig.default];
}
return key;

View File

@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { createAngularApiClient, type AngularHttpClientLike } from '../src/api/angular-client';
import { mapSessionDetailResponse, mapSubmitGuessResponse } from '../src/api/mappers';
describe('createAngularApiClient', () => {
it('reads health and session detail using Django-compatible endpoints', async () => {
@@ -207,391 +206,11 @@ describe('createAngularApiClient', () => {
}
});
it('keeps canonical reveal payload stable when session detail is already in scoreboard phase', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/lobby/sessions/ABCD12') {
return {
session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }]
},
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Q?',
correct_answer: 'A',
lies: [{ player_id: 2, nickname: 'Maja', text: 'B', created_at: '2026-03-01T18:00:05Z' }],
guesses: [
{
player_id: 3,
nickname: 'Bo',
selected_text: 'B',
is_correct: false,
fooled_player_id: 2,
fooled_player_nickname: 'Maja',
created_at: '2026-03-01T18:00:15Z'
}
]
},
phase_view_model: {
status: 'scoreboard',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: true,
can_finish_game: true
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
} as T;
}
throw { status: 404, error: { error: 'Not found' } };
});
const client = createAngularApiClient({ get, post: vi.fn() } as unknown as AngularHttpClientLike);
const session = await client.getSession('abcd12');
expect(session.ok).toBe(true);
if (session.ok) {
expect(session.data.session.status).toBe('scoreboard');
expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja');
expect(session.data.phase_view_model.host.can_start_next_round).toBe(true);
expect(session.data.phase_view_model.host.can_finish_game).toBe(true);
}
});
it('normalizes omitted fooled_player_id to null in canonical reveal payloads', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/lobby/sessions/ABCD12') {
return {
session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }]
},
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Q?',
correct_answer: 'A',
lies: [],
guesses: [
{
player_id: 3,
nickname: 'Bo',
selected_text: 'A',
is_correct: true,
created_at: '2026-03-01T18:00:15Z'
}
]
},
phase_view_model: {
status: 'reveal',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: true,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
} as T;
}
throw { status: 404, error: { error: 'Not found' } };
});
const post = vi.fn<AngularHttpClientLike['post']>(async <T>(url: string, body: unknown) => {
if (url === '/lobby/sessions/ABCD12/questions/77/guesses/submit') {
expect(body).toEqual({ player_id: 9, session_token: 'tok', selected_text: 'A' });
return {
guess: {
id: 200,
player_id: 9,
round_question_id: 77,
selected_text: 'A',
is_correct: false,
created_at: '2026-03-01T16:01:00Z'
},
window: { guess_deadline_at: '2026-03-01T16:01:30Z' }
} as T;
}
throw { status: 404, error: { error: 'Not found' } };
});
const client = createAngularApiClient({ get, post } as AngularHttpClientLike);
const session = await client.getSession('abcd12');
expect(session.ok).toBe(true);
if (session.ok) {
expect(session.data.reveal?.guesses[0].fooled_player_id).toBeNull();
expect(session.data.reveal?.guesses[0]).not.toHaveProperty('fooled_player_nickname');
}
const submitGuess = await client.submitGuess('ABCD12', 77, {
player_id: 9,
session_token: 'tok',
selected_text: 'A'
});
expect(submitGuess.ok).toBe(true);
if (submitGuess.ok) {
expect(submitGuess.data.guess.fooled_player_id).toBeNull();
}
});
it('maps omitted fooled_player_id to null in submit guess mapper payloads', () => {
const mapped = mapSubmitGuessResponse({
guess: {
id: 200,
player_id: 9,
round_question_id: 77,
selected_text: 'A',
is_correct: false,
created_at: '2026-03-01T16:01:00Z'
},
window: { guess_deadline_at: '2026-03-01T16:01:30Z' }
});
expect(mapped.guess.fooled_player_id).toBeNull();
});
it('keeps fooled_player_nickname omitted when canonical reveal payload omits fooled player refs', () => {
const mapped = mapSessionDetailResponse({
session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }]
},
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Q?',
correct_answer: 'A',
lies: [],
guesses: [
{
player_id: 3,
nickname: 'Bo',
selected_text: 'A',
is_correct: true,
created_at: '2026-03-01T18:00:15Z'
}
]
},
phase_view_model: {
status: 'reveal',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: true,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
});
expect(mapped.reveal?.guesses[0].fooled_player_id).toBeNull();
expect(mapped.reveal?.guesses[0]).not.toHaveProperty('fooled_player_nickname');
});
it('rejects canonical reveal payloads that include fooled_player_nickname without fooled_player_id', () => {
expect(() =>
mapSessionDetailResponse({
session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }]
},
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Q?',
correct_answer: 'A',
lies: [],
guesses: [
{
player_id: 3,
nickname: 'Bo',
selected_text: 'A',
is_correct: true,
fooled_player_nickname: 'Maja',
created_at: '2026-03-01T18:00:15Z'
}
]
},
phase_view_model: {
status: 'reveal',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: true,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
})
).toThrow('fooled_player_nickname to be omitted when fooled_player_id is null');
});
it('rejects canonical reveal payloads that omit fooled_player_nickname when fooled_player_id is set', () => {
expect(() =>
mapSessionDetailResponse({
session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: 'Q?',
shown_at: '2026-03-01T18:00:00Z',
answers: [{ text: 'A' }, { text: 'B' }]
},
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Q?',
correct_answer: 'A',
lies: [],
guesses: [
{
player_id: 3,
nickname: 'Bo',
selected_text: 'B',
is_correct: false,
fooled_player_id: 2,
created_at: '2026-03-01T18:00:15Z'
}
]
},
phase_view_model: {
status: 'reveal',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: true,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
})
).toThrow('fooled_player_nickname when fooled_player_id is set');
});
it('maps host/player gameplay endpoints through typed response mappers', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/lobby/sessions/ABCD12/scoreboard') {
return {
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
leaderboard: [
{ id: 2, nickname: 'Maja', score: 11 },
{ id: 3, nickname: 'Bo', score: 7 }
@@ -626,7 +245,7 @@ describe('createAngularApiClient', () => {
if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
expect(body).toEqual({});
return {
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 77, round_number: 1 },
events_created: 3,
leaderboard: [{ id: 2, nickname: 'Maja', score: 11 }]
@@ -634,7 +253,7 @@ describe('createAngularApiClient', () => {
}
if (url === '/lobby/sessions/ABCD12/rounds/next') {
expect(body).toEqual({});
return { session: { code: 'ABCD12', status: 'lie', current_round: 2 } } as T;
return { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } } as T;
}
if (url === '/lobby/sessions/ABCD12/finish') {
expect(body).toEqual({});

View File

@@ -2,8 +2,6 @@ import { describe, expect, it } from 'vitest';
import {
allowedGameplayEvents,
deriveGameplayPhase,
isHostGameplayActionAllowed,
isPlayerGameplayActionAllowed,
transitionGameplayPhase,
type GameplayPhase
} from '../src/spa/gameplay-phase-machine';
@@ -42,7 +40,6 @@ describe('gameplay phase machine skeleton', () => {
session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
reveal: null,
phase_view_model: {
status: 'lie',
round_number: 1,
@@ -77,7 +74,6 @@ describe('gameplay phase machine skeleton', () => {
session: { code: 'ABCD12', status: 'finished', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
reveal: null,
phase_view_model: {
status: 'finished',
round_number: 1,
@@ -107,44 +103,4 @@ describe('gameplay phase machine skeleton', () => {
})
).toBe('scoreboard');
});
it('gates host and player actions from canonical phase_view_model permissions', () => {
const session = {
session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: { id: 77, prompt: 'Q?', answers: [] },
phase_view_model: {
status: 'reveal',
round_number: 1,
players_count: 3,
constraints: {
min_players_to_start: 3,
max_players_mvp: 5,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: true,
can_start_next_round: true,
can_finish_game: true
},
player: {
can_join: false,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
} as const;
expect(deriveGameplayPhase(session as any)).toBe('reveal');
expect(isHostGameplayActionAllowed(session as any, 'loadScoreboard')).toBe(true);
expect(isHostGameplayActionAllowed(session as any, 'startNextRound')).toBe(true);
expect(isHostGameplayActionAllowed(session as any, 'finishGame')).toBe(true);
expect(isPlayerGameplayActionAllowed(session as any, 'submitGuess')).toBe(false);
});
});

View File

@@ -1,38 +0,0 @@
import { describe, expect, it } from 'vitest';
import {
collectLocaleParityIssues,
LOBBY_I18N_CATALOG,
normalizeLocale,
SUPPORTED_LOCALES,
translateCatalogPath,
} from '../shared/i18n/lobby-loader';
describe('shared lobby i18n loader parity', () => {
it('keeps da/en translation parity in shared keyspace', () => {
const issues = collectLocaleParityIssues(LOBBY_I18N_CATALOG, SUPPORTED_LOCALES);
expect(issues).toEqual([]);
});
it('normalizes browser-style locale tags to supported keyspace locales', () => {
expect(normalizeLocale('da-DK')).toBe('da');
expect(normalizeLocale('da_DK')).toBe('da');
expect(normalizeLocale('en-US')).toBe('en');
expect(normalizeLocale('en_US')).toBe('en');
expect(normalizeLocale('fr-FR')).toBe('en');
});
it('resolves shared frontend ui keys with fallback-safe behavior', () => {
expect(
translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, 'host.start_round', 'da-DK'),
).toBe('Start runde');
expect(
translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, 'app.language_label', 'en-US'),
).toBe('Language');
expect(
translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, 'host.non_existing_key', 'da'),
).toBe('host.non_existing_key');
});
});

View File

@@ -16,7 +16,6 @@ function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
reveal: null,
phase_view_model: {
status: 'lobby',
round_number: 1,
@@ -57,15 +56,7 @@ function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round: { number: 1, category: { slug: 'history', name: 'History' } }
}
}),
showQuestion: vi.fn(),
mixAnswers: vi.fn(),
calculateScores: vi.fn(),
getScoreboard: vi.fn(),
startNextRound: vi.fn(),
finishGame: vi.fn(),
submitLie: vi.fn(),
submitGuess: vi.fn()
})
};
return { ...base, ...overrides };

View File

@@ -2,8 +2,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts', 'angular/src/**/*.spec.ts'],
setupFiles: ['angular/src/test-setup.ts'],
include: ['tests/**/*.test.ts'],
exclude: ['**/node_modules/**']
}
});

View File

@@ -1,18 +0,0 @@
# Generated by Django 6.0.2 on 2026-03-13 16:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fupogfakta', '0004_player_session_token'),
]
operations = [
migrations.AlterField(
model_name='gamesession',
name='status',
field=models.CharField(choices=[('lobby', 'Lobby'), ('lie', 'Løgnfase'), ('guess', 'Gættefase'), ('reveal', 'Reveal'), ('scoreboard', 'Scoreboard'), ('finished', 'Afsluttet')], default='lobby', max_length=16),
),
]

View File

@@ -1,26 +0,0 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("fupogfakta", "0004_player_session_token"),
]
operations = [
migrations.AlterField(
model_name="gamesession",
name="status",
field=models.CharField(
choices=[
("lobby", "Lobby"),
("lie", "Løgnfase"),
("guess", "Gættefase"),
("reveal", "Reveal"),
("scoreboard", "Scoreboard"),
("finished", "Afsluttet"),
],
default="lobby",
max_length=16,
),
),
]

View File

@@ -1,10 +0,0 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("fupogfakta", "0005_alter_gamesession_status"),
("fupogfakta", "0005_gamesession_scoreboard_status"),
]
operations = []

View File

@@ -1,18 +0,0 @@
# Generated by Django 6.0.2 on 2026-03-17 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fupogfakta', '0006_merge_20260315_1249'),
]
operations = [
migrations.AddField(
model_name='roundconfig',
name='started_from_scoreboard',
field=models.BooleanField(default=False),
),
]

View File

@@ -42,7 +42,6 @@ class GameSession(models.Model):
LIE = "lie", "Løgnfase"
GUESS = "guess", "Gættefase"
REVEAL = "reveal", "Reveal"
SCOREBOARD = "scoreboard", "Scoreboard"
FINISHED = "finished", "Afsluttet"
host = models.ForeignKey(User, on_delete=models.PROTECT, related_name="hosted_sessions")
@@ -83,7 +82,6 @@ class RoundConfig(models.Model):
points_bluff = models.IntegerField(default=2)
lie_seconds = models.PositiveIntegerField(default=45)
guess_seconds = models.PositiveIntegerField(default=30)
started_from_scoreboard = models.BooleanField(default=False)
class Meta:
unique_together = (("session", "number"),)

View File

@@ -1,275 +0,0 @@
from datetime import timedelta
from .models import GameSession, Player, RoundConfig, RoundQuestion
def build_player_ref(player: Player | None) -> dict | None:
if player is None:
return None
return {
"player_id": player.id,
"nickname": player.nickname,
}
def build_round_question_payload(round_question: RoundQuestion | None) -> dict | None:
if round_question is None:
return None
return {
"id": round_question.id,
"round_number": round_question.round_number,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"answers": [{"text": text} for text in (round_question.mixed_answers or [])],
}
def build_reveal_payload(round_question: RoundQuestion | None) -> dict | None:
if round_question is None:
return None
lies = [
{
**build_player_ref(lie.player),
"text": lie.text,
"created_at": lie.created_at.isoformat(),
}
for lie in round_question.lies.select_related("player").order_by("created_at", "id")
]
guesses = []
for guess in round_question.guesses.select_related("player", "fooled_player").order_by("created_at", "id"):
guess_payload = {
**build_player_ref(guess.player),
"selected_text": guess.selected_text,
"is_correct": guess.is_correct,
"created_at": guess.created_at.isoformat(),
"fooled_player_id": guess.fooled_player_id,
}
if guess.fooled_player is not None:
guess_payload["fooled_player_nickname"] = guess.fooled_player.nickname
guesses.append(guess_payload)
return {
"round_question_id": round_question.id,
"round_number": round_question.round_number,
"prompt": round_question.question.prompt,
"correct_answer": round_question.correct_answer,
"lies": lies,
"guesses": guesses,
}
def build_leaderboard(session: GameSession) -> list[dict]:
return list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
def build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict:
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
return {
"round_number": session.current_round,
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
"lie_seconds": round_config.lie_seconds,
}
def build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
status = session.status
in_lobby = status == GameSession.Status.LOBBY
in_lie = status == GameSession.Status.LIE
in_guess = status == GameSession.Status.GUESS
in_scoreboard = status == GameSession.Status.SCOREBOARD
in_finished = status == GameSession.Status.FINISHED
min_players_reached = players_count >= 3
max_players_allowed = players_count <= 5
return {
"status": status,
"current_phase": status,
"round_number": session.current_round,
"players_count": players_count,
"constraints": {
"min_players_to_start": 3,
"max_players_mvp": 5,
"min_players_reached": min_players_reached,
"max_players_allowed": max_players_allowed,
},
"readiness": {
"question_ready": has_round_question,
"scoreboard_ready": status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED},
"can_advance_to_next_round": in_scoreboard,
},
"host": {
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
"can_show_question": False,
"can_mix_answers": False,
"can_calculate_scores": False,
"can_reveal_scoreboard": False,
"can_start_next_round": in_scoreboard,
"can_finish_game": in_scoreboard,
},
"player": {
"can_join": status in {
GameSession.Status.LOBBY,
GameSession.Status.LIE,
GameSession.Status.GUESS,
GameSession.Status.REVEAL,
GameSession.Status.SCOREBOARD,
},
"can_submit_lie": in_lie and has_round_question,
"can_submit_guess": in_guess and has_round_question,
"can_view_final_result": in_finished,
},
}
def build_session_detail_gameplay_payload(
session: GameSession,
*,
current_round_question: RoundQuestion | None,
players_count: int,
) -> dict:
return {
"round_question": build_round_question_payload(current_round_question),
"reveal": build_reveal_payload(current_round_question)
if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question
else None,
"scoreboard": build_scoreboard_phase_event(session)["payload"]["leaderboard"]
if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}
else None,
"phase_view_model": build_phase_view_model(
session,
players_count=players_count,
has_round_question=bool(current_round_question),
),
}
def build_start_round_response(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
lie_started_payload = build_lie_started_payload(session, round_config, round_question)
return {
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"round": {
"number": round_config.number,
"category": {
"slug": round_config.category.slug,
"name": round_config.category.name,
},
},
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_started_payload["lie_deadline_at"],
},
"config": {
"lie_seconds": round_config.lie_seconds,
},
}
def build_question_shown_payload(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict:
return {
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at,
"lie_seconds": lie_seconds,
}
def build_question_shown_response(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict:
return {
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at,
},
"config": {
"lie_seconds": lie_seconds,
},
}
def build_start_next_round_response(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
return build_start_round_response(session, round_config, round_question)
def build_start_next_round_phase_event(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
return {
"name": "phase.lie_started",
"payload": build_lie_started_payload(session, round_config, round_question),
}
def build_scoreboard_phase_event(session: GameSession, leaderboard: list[dict] | None = None) -> dict:
return {
"name": "phase.scoreboard",
"payload": {
"leaderboard": leaderboard if leaderboard is not None else build_leaderboard(session),
"current_round": session.current_round,
},
}
def build_reveal_scoreboard_response(session: GameSession, leaderboard: list[dict]) -> dict:
return {
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"leaderboard": leaderboard,
}
def build_finish_game_phase_event(session: GameSession) -> dict:
leaderboard = build_leaderboard(session)
winner = leaderboard[0] if leaderboard else None
return {
"name": "phase.game_over",
"payload": {"winner": winner, "leaderboard": leaderboard},
}
def build_finish_game_response(session: GameSession) -> dict:
finish_event = build_finish_game_phase_event(session)
return {
"session": {
"code": session.code,
"status": GameSession.Status.FINISHED,
"current_round": session.current_round,
},
"winner": finish_event["payload"]["winner"],
"leaderboard": finish_event["payload"]["leaderboard"],
}

View File

@@ -1,479 +0,0 @@
import random
from datetime import timedelta
from dataclasses import dataclass
from typing import Any
from django.db import transaction
from django.utils import timezone
from .models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
from .payloads import (
build_finish_game_phase_event,
build_finish_game_response,
build_lie_started_payload,
build_question_shown_payload,
build_question_shown_response,
build_reveal_scoreboard_response,
build_scoreboard_phase_event,
build_start_next_round_phase_event,
build_start_next_round_response,
build_start_round_response,
)
@dataclass(frozen=True)
class RoundTransitionResult:
session: GameSession
round_config: RoundConfig
round_question: RoundQuestion
should_broadcast: bool
response_payload: dict[str, Any]
phase_event_name: str | None = None
phase_event_payload: dict[str, Any] | None = None
@dataclass(frozen=True)
class FinishGameResult:
session: GameSession
should_broadcast: bool
response_payload: dict[str, Any]
phase_event_name: str | None = None
phase_event_payload: dict[str, Any] | None = None
@dataclass(frozen=True)
class ScoreboardTransitionResult:
session: GameSession
leaderboard: list[dict]
should_broadcast: bool
response_payload: dict[str, Any] | None = None
phase_event_name: str | None = None
phase_event_payload: dict[str, Any] | None = None
def get_round_question(session: GameSession, round_number: int) -> RoundQuestion | None:
return (
RoundQuestion.objects.filter(session=session, round_number=round_number)
.select_related("question")
.order_by("-id")
.first()
)
def get_current_round_question(session: GameSession) -> RoundQuestion | None:
return get_round_question(session, session.current_round)
def reset_round_question_bootstrap_state(round_question: RoundQuestion) -> RoundQuestion:
Guess.objects.filter(round_question=round_question).delete()
LieAnswer.objects.filter(round_question=round_question).delete()
update_fields: list[str] = []
if round_question.mixed_answers:
round_question.mixed_answers = []
update_fields.append("mixed_answers")
round_question.shown_at = timezone.now()
update_fields.append("shown_at")
round_question.save(update_fields=update_fields)
return round_question
def select_round_question(
session: GameSession,
round_config: RoundConfig,
*,
round_number: int | None = None,
) -> RoundQuestion:
target_round_number = session.current_round if round_number is None else round_number
existing_round_question = get_round_question(session, target_round_number)
if existing_round_question is not None and existing_round_question.question.category_id == round_config.category_id:
return existing_round_question
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
available_questions = Question.objects.filter(
category=round_config.category,
is_active=True,
).exclude(pk__in=used_question_ids)
if not available_questions.exists():
raise ValueError("no_available_questions")
question = random.choice(list(available_questions))
if existing_round_question is not None:
existing_round_question.question = question
existing_round_question.correct_answer = question.correct_answer
existing_round_question.save(update_fields=["question", "correct_answer"])
return existing_round_question
return RoundQuestion.objects.create(
session=session,
round_number=target_round_number,
question=question,
correct_answer=question.correct_answer,
)
def prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
deduped_answers = list(round_question.mixed_answers or [])
if deduped_answers:
return deduped_answers
lie_texts = list(round_question.lies.values_list("text", flat=True))
seen = set()
for text in [round_question.correct_answer, *lie_texts]:
normalized = text.strip().casefold()
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped_answers.append(text.strip())
if len(deduped_answers) < 2:
raise ValueError("not_enough_answers_to_mix")
random.shuffle(deduped_answers)
round_question.mixed_answers = deduped_answers
round_question.save(update_fields=["mixed_answers"])
return deduped_answers
def start_round(session: GameSession, category_slug: str) -> RoundTransitionResult:
try:
category = Category.objects.get(slug=category_slug, is_active=True)
except Category.DoesNotExist:
raise ValueError("category_not_found")
if not Question.objects.filter(category=category, is_active=True).exists():
raise ValueError("category_has_no_questions")
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.LOBBY:
raise ValueError("round_start_invalid_phase")
if RoundConfig.objects.filter(session=locked_session, number=locked_session.current_round).exists():
raise ValueError("round_already_configured")
round_config = RoundConfig(
session=locked_session,
number=locked_session.current_round,
category=category,
)
round_question = select_round_question(locked_session, round_config)
round_config.save()
locked_session.status = GameSession.Status.LIE
locked_session.save(update_fields=["status"])
phase_event = {
"name": "phase.lie_started",
"payload": build_lie_started_payload(locked_session, round_config, round_question),
}
return RoundTransitionResult(
session=locked_session,
round_config=round_config,
round_question=round_question,
should_broadcast=True,
response_payload=build_start_round_response(locked_session, round_config, round_question),
phase_event_name=phase_event["name"],
phase_event_payload=phase_event["payload"],
)
def show_question(session: GameSession) -> RoundTransitionResult:
if session.status != GameSession.Status.LIE:
raise ValueError("show_question_invalid_phase")
try:
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
except RoundConfig.DoesNotExist:
raise ValueError("round_config_missing")
round_question = get_current_round_question(session)
if round_question is None:
round_question = select_round_question(session, round_config)
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
lie_deadline_iso = lie_deadline_at.isoformat()
phase_event = {
"name": "phase.question_shown",
"payload": build_question_shown_payload(round_question, lie_deadline_iso, round_config.lie_seconds),
}
return RoundTransitionResult(
session=session,
round_config=round_config,
round_question=round_question,
should_broadcast=True,
response_payload=build_question_shown_response(round_question, lie_deadline_iso, round_config.lie_seconds),
phase_event_name=phase_event["name"],
phase_event_payload=phase_event["payload"],
)
def start_next_round(session: GameSession) -> RoundTransitionResult:
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
next_round_config = None
round_question = None
should_broadcast = False
phase_event_name = None
phase_event_payload = None
if locked_session.status == GameSession.Status.SCOREBOARD:
previous_round_config = RoundConfig.objects.filter(
session=locked_session,
number=locked_session.current_round,
).select_related("category").first()
if previous_round_config is None:
raise ValueError("round_config_missing")
next_round_number = locked_session.current_round + 1
next_round_config, _created = RoundConfig.objects.get_or_create(
session=locked_session,
number=next_round_number,
defaults={
"category": previous_round_config.category,
"lie_seconds": previous_round_config.lie_seconds,
"guess_seconds": previous_round_config.guess_seconds,
"points_correct": previous_round_config.points_correct,
"points_bluff": previous_round_config.points_bluff,
"started_from_scoreboard": True,
},
)
round_config_update_fields: list[str] = []
if next_round_config.category_id != previous_round_config.category_id:
next_round_config.category = previous_round_config.category
round_config_update_fields.append("category")
if next_round_config.lie_seconds != previous_round_config.lie_seconds:
next_round_config.lie_seconds = previous_round_config.lie_seconds
round_config_update_fields.append("lie_seconds")
if next_round_config.guess_seconds != previous_round_config.guess_seconds:
next_round_config.guess_seconds = previous_round_config.guess_seconds
round_config_update_fields.append("guess_seconds")
if next_round_config.points_correct != previous_round_config.points_correct:
next_round_config.points_correct = previous_round_config.points_correct
round_config_update_fields.append("points_correct")
if next_round_config.points_bluff != previous_round_config.points_bluff:
next_round_config.points_bluff = previous_round_config.points_bluff
round_config_update_fields.append("points_bluff")
if not next_round_config.started_from_scoreboard:
next_round_config.started_from_scoreboard = True
round_config_update_fields.append("started_from_scoreboard")
if round_config_update_fields:
next_round_config.save(update_fields=round_config_update_fields)
locked_session.current_round = next_round_number
round_question = reset_round_question_bootstrap_state(
select_round_question(locked_session, next_round_config, round_number=next_round_number)
)
locked_session.status = GameSession.Status.LIE
locked_session.save(update_fields=["current_round", "status"])
should_broadcast = True
phase_event = build_start_next_round_phase_event(locked_session, next_round_config, round_question)
phase_event_name = phase_event["name"]
phase_event_payload = phase_event["payload"]
elif locked_session.status == GameSession.Status.LIE:
if locked_session.current_round <= 1:
raise ValueError("next_round_invalid_phase")
next_round_config = RoundConfig.objects.filter(
session=locked_session,
number=locked_session.current_round,
).select_related("category").first()
round_question = get_current_round_question(locked_session)
if (
next_round_config is None
or not next_round_config.started_from_scoreboard
or round_question is None
):
raise ValueError("next_round_invalid_phase")
else:
raise ValueError("next_round_invalid_phase")
return RoundTransitionResult(
session=locked_session,
round_config=next_round_config,
round_question=round_question,
should_broadcast=should_broadcast,
response_payload=build_start_next_round_response(
locked_session,
next_round_config,
round_question,
),
phase_event_name=phase_event_name,
phase_event_payload=phase_event_payload,
)
def finish_game(session: GameSession) -> FinishGameResult:
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
should_broadcast = False
phase_event_name = None
phase_event_payload = None
if locked_session.status == GameSession.Status.SCOREBOARD:
locked_session.status = GameSession.Status.FINISHED
locked_session.save(update_fields=["status"])
should_broadcast = True
phase_event = build_finish_game_phase_event(locked_session)
phase_event_name = phase_event["name"]
phase_event_payload = phase_event["payload"]
elif locked_session.status != GameSession.Status.FINISHED:
raise ValueError("finish_game_invalid_phase")
return FinishGameResult(
session=locked_session,
should_broadcast=should_broadcast,
response_payload=build_finish_game_response(locked_session),
phase_event_name=phase_event_name,
phase_event_payload=phase_event_payload,
)
def promote_reveal_to_scoreboard(session: GameSession) -> ScoreboardTransitionResult:
if session.status != GameSession.Status.REVEAL:
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return ScoreboardTransitionResult(
session=session,
leaderboard=leaderboard,
should_broadcast=False,
response_payload=build_reveal_scoreboard_response(session, leaderboard),
)
current_round_question = get_current_round_question(session)
if current_round_question is None:
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return ScoreboardTransitionResult(
session=session,
leaderboard=leaderboard,
should_broadcast=False,
response_payload=build_reveal_scoreboard_response(session, leaderboard),
)
players_count = Player.objects.filter(session=session).count()
guess_count = Guess.objects.filter(round_question=current_round_question).count()
has_score_events = ScoreEvent.objects.filter(
session=session,
meta__round_question_id=current_round_question.id,
).exists()
reveal_is_resolved = has_score_events or (players_count > 0 and guess_count >= players_count)
if not reveal_is_resolved:
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return ScoreboardTransitionResult(
session=session,
leaderboard=leaderboard,
should_broadcast=False,
response_payload=build_reveal_scoreboard_response(session, leaderboard),
)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.REVEAL:
scoreboard_session = locked_session
should_broadcast = False
else:
locked_session.status = GameSession.Status.SCOREBOARD
locked_session.save(update_fields=["status"])
scoreboard_session = locked_session
should_broadcast = True
leaderboard = list(
Player.objects.filter(session=scoreboard_session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
phase_event_name = None
phase_event_payload = None
if should_broadcast:
phase_event = build_scoreboard_phase_event(scoreboard_session, leaderboard)
phase_event_name = phase_event["name"]
phase_event_payload = phase_event["payload"]
return ScoreboardTransitionResult(
session=scoreboard_session,
leaderboard=leaderboard,
should_broadcast=should_broadcast,
response_payload=build_reveal_scoreboard_response(scoreboard_session, leaderboard),
phase_event_name=phase_event_name,
phase_event_payload=phase_event_payload,
)
def resolve_scores(
session: GameSession,
round_question: RoundQuestion,
round_config: RoundConfig,
) -> tuple[list[ScoreEvent], list[dict]]:
guesses = list(round_question.guesses.select_related("player"))
if not guesses:
raise ValueError("no_guesses_submitted")
bluff_counts: dict[int, int] = {}
for guess in guesses:
if guess.fooled_player_id:
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
score_events = []
for guess in guesses:
if guess.is_correct:
guess.player.score += round_config.points_correct
guess.player.save(update_fields=["score"])
score_events.append(
ScoreEvent(
session=session,
player=guess.player,
delta=round_config.points_correct,
reason="guess_correct",
meta={"round_question_id": round_question.id, "guess_id": guess.id},
)
)
for player_id, fooled_count in bluff_counts.items():
delta = fooled_count * round_config.points_bluff
player = Player.objects.get(pk=player_id, session=session)
player.score += delta
player.save(update_fields=["score"])
score_events.append(
ScoreEvent(
session=session,
player=player,
delta=delta,
reason="bluff_success",
meta={"round_question_id": round_question.id, "fooled_count": fooled_count},
)
)
ScoreEvent.objects.bulk_create(score_events)
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return score_events, leaderboard

View File

@@ -1,410 +1,2 @@
from datetime import timedelta
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
from fupogfakta.payloads import (
build_lie_started_payload,
build_phase_view_model,
build_reveal_payload,
build_round_question_payload,
build_session_detail_gameplay_payload,
)
from fupogfakta.services import (
finish_game,
get_current_round_question,
prepare_mixed_answers,
promote_reveal_to_scoreboard,
resolve_scores,
select_round_question,
start_next_round,
)
User = get_user_model()
class FupOgFaktaExtractionSliceTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="ABCD23")
self.category = Category.objects.create(name="Historie", slug="historie", is_active=True)
self.question_one = Question.objects.create(
category=self.category,
prompt="Hvornår faldt muren?",
correct_answer="1989",
is_active=True,
)
self.question_two = Question.objects.create(
category=self.category,
prompt="Hvornår kom euroen?",
correct_answer="1999",
is_active=True,
)
self.round_config = RoundConfig.objects.create(session=self.session, number=1, category=self.category)
self.alice = Player.objects.create(session=self.session, nickname="Alice")
self.bob = Player.objects.create(session=self.session, nickname="Bob")
self.clara = Player.objects.create(session=self.session, nickname="Clara")
def test_select_round_question_skips_already_used_questions_for_session(self):
RoundQuestion.objects.create(
session=self.session,
round_number=99,
question=self.question_one,
correct_answer=self.question_one.correct_answer,
)
round_question = select_round_question(self.session, self.round_config)
self.assertEqual(round_question.question, self.question_two)
self.assertEqual(get_current_round_question(self.session), round_question)
def test_prepare_mixed_answers_dedupes_blank_and_case_variants(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer="1989",
)
LieAnswer.objects.create(round_question=round_question, player=self.alice, text=" 1989 ")
LieAnswer.objects.create(round_question=round_question, player=self.bob, text="Nitten niogfirs")
LieAnswer.objects.create(round_question=round_question, player=self.clara, text=" ")
with patch("fupogfakta.services.random.shuffle", side_effect=lambda answers: None):
answers = prepare_mixed_answers(round_question)
self.assertEqual(answers, ["1989", "Nitten niogfirs"])
round_question.refresh_from_db()
self.assertEqual(round_question.mixed_answers, answers)
def test_start_next_round_moves_scoreboard_transition_into_service(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
result = start_next_round(self.session)
self.session.refresh_from_db()
self.assertTrue(result.should_broadcast)
self.assertEqual(result.session.status, GameSession.Status.LIE)
self.assertEqual(result.session.current_round, 2)
self.assertEqual(result.round_config.number, 2)
self.assertTrue(result.round_config.started_from_scoreboard)
self.assertEqual(result.round_question.round_number, 2)
def test_start_next_round_rejects_plain_lie_without_scoreboard_marker(self):
self.session.status = GameSession.Status.LIE
self.session.current_round = 2
self.session.save(update_fields=["status", "current_round"])
RoundConfig.objects.create(session=self.session, number=2, category=self.category, started_from_scoreboard=False)
RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.question_two,
correct_answer=self.question_two.correct_answer,
)
with self.assertRaisesMessage(ValueError, "next_round_invalid_phase"):
start_next_round(self.session)
def test_start_next_round_refreshes_shown_at_for_reused_bootstrap_question(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
stale_shown_at = timezone.now() - timedelta(minutes=10)
stale_round_question = RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.question_two,
correct_answer=self.question_two.correct_answer,
shown_at=stale_shown_at,
mixed_answers=["Stale truth", "Stale lie"],
)
LieAnswer.objects.create(round_question=stale_round_question, player=self.alice, text="Stale lie")
Guess.objects.create(
round_question=stale_round_question,
player=self.bob,
selected_text="Stale truth",
is_correct=True,
)
before_transition = timezone.now()
result = start_next_round(self.session)
after_transition = timezone.now()
stale_round_question.refresh_from_db()
self.assertEqual(result.round_question.id, stale_round_question.id)
self.assertGreaterEqual(stale_round_question.shown_at, before_transition)
self.assertLessEqual(stale_round_question.shown_at, after_transition)
self.assertNotEqual(stale_round_question.shown_at, stale_shown_at)
self.assertEqual(result.response_payload["round_question"]["shown_at"], stale_round_question.shown_at.isoformat())
expected_deadline = stale_round_question.shown_at + timedelta(seconds=result.round_config.lie_seconds)
self.assertEqual(result.response_payload["round_question"]["lie_deadline_at"], expected_deadline.isoformat())
self.assertGreater(expected_deadline, before_transition)
self.assertEqual(stale_round_question.mixed_answers, [])
self.assertEqual(stale_round_question.lies.count(), 0)
self.assertEqual(stale_round_question.guesses.count(), 0)
def test_start_next_round_reuses_existing_bootstrap_round_config_with_fresh_canonical_values(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
stale_category = Category.objects.create(name="Sport", slug="sport", is_active=True)
stale_round_config = RoundConfig.objects.create(
session=self.session,
number=2,
category=stale_category,
lie_seconds=12,
guess_seconds=18,
points_correct=9,
points_bluff=7,
started_from_scoreboard=False,
)
stale_round_question = RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.question_two,
correct_answer=self.question_two.correct_answer,
shown_at=timezone.now() - timedelta(minutes=10),
mixed_answers=["Stale truth"],
)
result = start_next_round(self.session)
stale_round_config.refresh_from_db()
stale_round_question.refresh_from_db()
self.assertEqual(result.round_config.id, stale_round_config.id)
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1)
self.assertEqual(stale_round_config.category_id, self.round_config.category_id)
self.assertEqual(stale_round_config.lie_seconds, self.round_config.lie_seconds)
self.assertEqual(stale_round_config.guess_seconds, self.round_config.guess_seconds)
self.assertEqual(stale_round_config.points_correct, self.round_config.points_correct)
self.assertEqual(stale_round_config.points_bluff, self.round_config.points_bluff)
self.assertTrue(stale_round_config.started_from_scoreboard)
self.assertEqual(result.round_question.id, stale_round_question.id)
self.assertEqual(stale_round_question.mixed_answers, [])
def test_start_next_round_repairs_reused_bootstrap_question_when_category_drifted(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer=self.question_one.correct_answer,
)
stale_category = Category.objects.create(name="Sport drift", slug="sport-drift", is_active=True)
stale_question = Question.objects.create(
category=stale_category,
prompt="Hvem vandt EM i 1992?",
correct_answer="Danmark",
is_active=True,
)
stale_round_question = RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=stale_question,
correct_answer=stale_question.correct_answer,
shown_at=timezone.now() - timedelta(minutes=10),
mixed_answers=["Stale truth", "Stale lie"],
)
LieAnswer.objects.create(round_question=stale_round_question, player=self.alice, text="Tyskland")
Guess.objects.create(
round_question=stale_round_question,
player=self.bob,
selected_text="Stale truth",
is_correct=True,
)
result = start_next_round(self.session)
stale_round_question.refresh_from_db()
self.assertEqual(result.round_question.id, stale_round_question.id)
self.assertEqual(stale_round_question.question.category_id, self.round_config.category_id)
self.assertEqual(stale_round_question.question_id, self.question_two.id)
self.assertEqual(stale_round_question.correct_answer, self.question_two.correct_answer)
self.assertEqual(stale_round_question.mixed_answers, [])
self.assertEqual(stale_round_question.lies.count(), 0)
self.assertEqual(stale_round_question.guesses.count(), 0)
def test_start_next_round_does_not_reuse_previous_round_question_when_category_matches(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
previous_round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer=self.question_one.correct_answer,
mixed_answers=["1989", "1991"],
)
LieAnswer.objects.create(round_question=previous_round_question, player=self.alice, text="1991")
Guess.objects.create(
round_question=previous_round_question,
player=self.bob,
selected_text="1991",
is_correct=False,
fooled_player=self.alice,
)
result = start_next_round(self.session)
previous_round_question.refresh_from_db()
self.session.refresh_from_db()
self.assertEqual(self.session.current_round, 2)
self.assertEqual(result.round_question.round_number, 2)
self.assertNotEqual(result.round_question.id, previous_round_question.id)
self.assertEqual(result.round_question.question_id, self.question_two.id)
self.assertEqual(previous_round_question.round_number, 1)
self.assertEqual(previous_round_question.question_id, self.question_one.id)
self.assertEqual(previous_round_question.mixed_answers, ["1989", "1991"])
self.assertEqual(previous_round_question.lies.count(), 1)
self.assertEqual(previous_round_question.guesses.count(), 1)
def test_finish_game_moves_scoreboard_transition_into_service(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
result = finish_game(self.session)
self.session.refresh_from_db()
self.assertTrue(result.should_broadcast)
self.assertEqual(result.session.status, GameSession.Status.FINISHED)
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
def test_promote_reveal_to_scoreboard_moves_transition_into_service(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer=self.question_one.correct_answer,
)
self.session.status = GameSession.Status.REVEAL
self.session.save(update_fields=["status"])
LieAnswer.objects.create(round_question=round_question, player=self.alice, text="Elbil")
Guess.objects.create(
round_question=round_question,
player=self.bob,
selected_text="Elbil",
is_correct=False,
fooled_player=self.alice,
)
ScoreEvent.objects.create(
session=self.session,
player=self.alice,
delta=5,
reason="bluff_success",
meta={"round_question_id": round_question.id},
)
self.alice.score = 5
self.alice.save(update_fields=["score"])
result = promote_reveal_to_scoreboard(self.session)
self.session.refresh_from_db()
self.assertTrue(result.should_broadcast)
self.assertEqual(result.session.status, GameSession.Status.SCOREBOARD)
self.assertEqual(result.leaderboard[0]["nickname"], self.alice.nickname)
def test_resolve_scores_applies_correct_and_bluff_points(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer="1989",
)
Guess.objects.create(
round_question=round_question,
player=self.alice,
selected_text="1989",
is_correct=True,
)
Guess.objects.create(
round_question=round_question,
player=self.bob,
selected_text="Berlin",
is_correct=False,
fooled_player=self.clara,
)
Guess.objects.create(
round_question=round_question,
player=self.clara,
selected_text="Berlin",
is_correct=False,
fooled_player=self.clara,
)
score_events, leaderboard = resolve_scores(self.session, round_question, self.round_config)
self.assertEqual(len(score_events), 2)
self.alice.refresh_from_db()
self.clara.refresh_from_db()
self.assertEqual(self.alice.score, self.round_config.points_correct)
self.assertEqual(self.clara.score, self.round_config.points_bluff * 2)
self.assertEqual(ScoreEvent.objects.filter(session=self.session, meta__round_question_id=round_question.id).count(), 2)
self.assertEqual([entry["nickname"] for entry in leaderboard], ["Alice", "Clara", "Bob"])
def test_payload_builders_expose_fupogfakta_round_contract(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer="1989",
)
lie = LieAnswer.objects.create(round_question=round_question, player=self.bob, text="1991")
Guess.objects.create(
round_question=round_question,
player=self.alice,
selected_text="1991",
is_correct=False,
fooled_player=self.bob,
)
round_question_payload = build_round_question_payload(round_question)
lie_payload = build_lie_started_payload(self.session, self.round_config, round_question)
reveal_payload = build_reveal_payload(round_question)
phase_view_model = build_phase_view_model(
self.session,
players_count=3,
has_round_question=True,
)
self.assertEqual(round_question_payload["prompt"], self.question_one.prompt)
self.assertEqual(round_question_payload["answers"], [])
self.assertEqual(lie_payload["category"], {"slug": self.category.slug, "name": self.category.name})
self.assertEqual(lie_payload["round_question_id"], round_question.id)
self.assertEqual(reveal_payload["correct_answer"], "1989")
self.assertEqual(reveal_payload["lies"][0]["player_id"], lie.player_id)
self.assertEqual(reveal_payload["guesses"][0]["fooled_player_nickname"], self.bob.nickname)
self.assertTrue(phase_view_model["host"]["can_start_round"])
self.assertFalse(phase_view_model["host"]["can_finish_game"])
def test_build_session_detail_gameplay_payload_keeps_session_detail_semantics_in_cartridge(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question_one,
correct_answer=self.question_one.correct_answer,
)
lie = LieAnswer.objects.create(round_question=round_question, player=self.bob, text="1991")
Guess.objects.create(
round_question=round_question,
player=self.alice,
selected_text="1991",
is_correct=False,
fooled_player=self.bob,
)
gameplay_payload = build_session_detail_gameplay_payload(
self.session,
current_round_question=round_question,
players_count=3,
)
self.assertEqual(gameplay_payload["round_question"]["id"], round_question.id)
self.assertEqual(gameplay_payload["reveal"]["lies"][0]["player_id"], lie.player_id)
self.assertEqual(gameplay_payload["scoreboard"], [{"id": self.alice.id, "nickname": self.alice.nickname, "score": self.alice.score}, {"id": self.bob.id, "nickname": self.bob.nickname, "score": self.bob.score}, {"id": self.clara.id, "nickname": self.clara.nickname, "score": self.clara.score}])
self.assertEqual(gameplay_payload["phase_view_model"]["status"], GameSession.Status.SCOREBOARD)
self.assertTrue(gameplay_payload["phase_view_model"]["host"]["can_start_next_round"])
self.assertTrue(gameplay_payload["phase_view_model"]["host"]["can_finish_game"])
# Create your tests here.

View File

@@ -1,69 +1,46 @@
import json
import logging
from functools import lru_cache
from pathlib import Path
from django.http import HttpRequest, JsonResponse
from django.utils.translation import get_language_from_request
from partyhub.i18n_bootstrap import locale_config, shared_i18n_catalog
LOGGER = logging.getLogger(__name__)
@lru_cache(maxsize=1)
def lobby_i18n_catalog() -> dict:
return shared_i18n_catalog()
catalog_path = Path(__file__).resolve().parents[1] / "shared" / "i18n" / "lobby.json"
with catalog_path.open(encoding="utf-8") as handle:
return json.load(handle)
@lru_cache(maxsize=1)
def i18n_locale_config() -> tuple[str, tuple[str, ...]]:
return locale_config()
locales = lobby_i18n_catalog().get("locales", {})
default_locale = str(locales.get("default", "en")).strip().lower() or "en"
supported_locales = tuple(
locale.strip().lower() for locale in locales.get("supported", ["en", "da"]) if str(locale).strip()
) or ("en", "da")
return default_locale, supported_locales
def lobby_i18n_errors() -> dict:
return shared_i18n_catalog().get("backend", {}).get("error_codes", {})
return lobby_i18n_catalog().get("backend", {}).get("error_codes", {})
def lobby_i18n_error_messages() -> dict:
return shared_i18n_catalog().get("backend", {}).get("errors", {})
def resolve_error_key(code: str) -> str:
resolved = lobby_i18n_errors().get(code)
if isinstance(resolved, str) and resolved:
return resolved
LOGGER.warning("i18n error code missing in shared catalog", extra={"code": code})
return code
def _quality_value(language_candidate: str) -> float | None:
for parameter in language_candidate.split(";")[1:]:
key, separator, value = parameter.partition("=")
if separator and key.strip().lower() == "q":
try:
return float(value.strip())
except ValueError:
return None
return None
return lobby_i18n_catalog().get("backend", {}).get("errors", {})
def resolve_locale(request: HttpRequest) -> str:
default_locale, supported_locales = i18n_locale_config()
accept_language = request.META.get("HTTP_ACCEPT_LANGUAGE") or ""
ranked_candidates: list[tuple[float, int, str]] = []
for index, candidate in enumerate(accept_language.split(",")):
quality = _quality_value(candidate)
if quality is not None and quality <= 0:
continue
tag = candidate.split(";", 1)[0]
normalized = tag.strip().replace("_", "-").split("-", 1)[0].lower()
if normalized not in supported_locales:
continue
ranked_candidates.append((quality if quality is not None else 1.0, index, normalized))
if ranked_candidates:
ranked_candidates.sort(key=lambda entry: (-entry[0], entry[1]))
return ranked_candidates[0][2]
raw_accept_language = (request.META.get("HTTP_ACCEPT_LANGUAGE") or "").split(",", 1)[0]
raw_requested = raw_accept_language.split(";", 1)[0].strip().replace("_", "-").split("-", 1)[0].lower()
if raw_requested in supported_locales:
return raw_requested
requested = (get_language_from_request(request) or "").replace("_", "-").split("-", 1)[0].lower()
if requested in supported_locales:
@@ -87,13 +64,12 @@ def resolve_error_message(*, key: str, locale: str) -> str:
return key
def api_error(request: HttpRequest, *, code: str, status: int) -> JsonResponse:
def api_error(request: HttpRequest, *, key: str, status: int) -> JsonResponse:
locale = resolve_locale(request)
key = resolve_error_key(code)
return JsonResponse(
{
"error": resolve_error_message(key=key, locale=locale),
"error_code": code,
"error_code": key,
"locale": locale,
},
status=status,

View File

@@ -10,7 +10,7 @@ from fupogfakta.models import Category, GameSession, Player, Question, RoundQues
class Command(BaseCommand):
help = "Run canonical gameplay smoke/regression flow for bluff -> guess -> reveal -> scoreboard"
help = "Run minimal staging smoke flow for lobby gameplay"
def add_arguments(self, parser):
parser.add_argument(
@@ -18,26 +18,6 @@ class Command(BaseCommand):
help="Optional path to write smoke result artifact as JSON",
)
def _fail(self, step: str, detail: str, payload=None):
message = f"{step} failed: {detail}"
if payload is not None:
message += f" | payload={json.dumps(payload, sort_keys=True)}"
raise CommandError(message)
def _expect_status(self, response, expected_status: int, step: str):
if response.status_code != expected_status:
try:
payload = response.json()
except ValueError:
payload = {"raw": response.content.decode("utf-8", errors="replace")}
self._fail(step, f"expected HTTP {expected_status}, got {response.status_code}", payload)
return response.json()
def _expect_session_status(self, payload: dict, expected_status: str, step: str):
actual_status = payload.get("session", {}).get("status")
if actual_status != expected_status:
self._fail(step, f"expected session.status={expected_status}, got {actual_status}", payload)
def handle(self, *args, **options):
GameSession.objects.all().delete()
Player.objects.all().delete()
@@ -50,14 +30,11 @@ class Command(BaseCommand):
category.is_active = True
category.save(update_fields=["is_active"])
question, _ = Question.objects.get_or_create(
Question.objects.get_or_create(
category=category,
prompt="Smoke prompt?",
defaults={"correct_answer": "Correct", "is_active": True},
)
if not question.is_active:
question.is_active = True
question.save(update_fields=["is_active"])
User = get_user_model()
host, _ = User.objects.get_or_create(username="smoke-host")
@@ -65,254 +42,121 @@ class Command(BaseCommand):
host.is_staff = True
host.save()
artifact = {
"ok": True,
"command": "python manage.py smoke_staging --artifact <path>",
"generated_at": datetime.now(timezone.utc).isoformat(),
"question": {
"prompt": question.prompt,
"correct_answer": question.correct_answer,
},
"steps": [],
}
host_client = Client()
host_client.force_login(host)
create_payload = self._expect_status(
host_client.post("/lobby/sessions/create", content_type="application/json"),
201,
"create_session",
)
code = create_payload["session"]["code"]
artifact["session_code"] = code
artifact["steps"].append(
{
"step": "create_session",
"session_status": create_payload["session"]["status"],
}
)
create_res = host_client.post("/lobby/sessions/create", content_type="application/json")
if create_res.status_code != 201:
raise CommandError(f"create_session failed: {create_res.status_code} {create_res.content!r}")
code = create_res.json()["session"]["code"]
players = []
for nickname in ["P1", "P2", "P3"]:
join_payload = self._expect_status(
Client().post(
"/lobby/sessions/join",
data=json.dumps({"code": code, "nickname": nickname}),
content_type="application/json",
),
201,
f"join_session[{nickname}]",
)
players.append(join_payload["player"])
artifact["players"] = [player["nickname"] for player in players]
artifact["steps"].append(
{
"step": "join_players",
"players_count": len(players),
}
)
start_payload = self._expect_status(
host_client.post(
f"/lobby/sessions/{code}/rounds/start",
data=json.dumps({"category_slug": category.slug}),
join_res = Client().post(
"/lobby/sessions/join",
data=json.dumps({"code": code, "nickname": nickname}),
content_type="application/json",
),
201,
"start_round",
)
self._expect_session_status(start_payload, GameSession.Status.LIE, "start_round")
)
if join_res.status_code != 201:
raise CommandError(f"join_session failed for {nickname}: {join_res.status_code}")
players.append(join_res.json()["player"])
round_question_id = start_payload["round_question"]["id"]
artifact["round_question_id"] = round_question_id
artifact["steps"].append(
{
"step": "start_round",
"session_status": start_payload["session"]["status"],
"round_question_id": round_question_id,
}
start_res = host_client.post(
f"/lobby/sessions/{code}/rounds/start",
data=json.dumps({"category_slug": category.slug}),
content_type="application/json",
)
if start_res.status_code != 201:
raise CommandError(f"start_round failed: {start_res.status_code}")
show_res = host_client.post(f"/lobby/sessions/{code}/questions/show", content_type="application/json")
if show_res.status_code != 201:
raise CommandError(f"show_question failed: {show_res.status_code}")
round_question_id = show_res.json()["round_question"]["id"]
answers = []
lie_transition_payload = None
for player in players:
nickname = player["nickname"]
lie_payload = self._expect_status(
Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"text": f"Lie from {nickname}",
}
),
content_type="application/json",
),
201,
f"submit_lie[{nickname}]",
)
if lie_payload.get("answers"):
answers = lie_payload["answers"]
lie_transition_payload = lie_payload
if not answers:
detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_lies")
answers = detail_payload.get("round_question", {}).get("answers", [])
self._expect_session_status(detail_payload, GameSession.Status.GUESS, "session_detail_after_lies")
lie_transition_payload = detail_payload
if not answers:
self._fail("auto_guess_transition", "canonical lie->guess transition returned empty answers")
if not any(answer.get("text") == question.correct_answer for answer in answers):
self._fail("auto_guess_transition", "mixed answers missing correct answer", {"answers": answers})
if len(answers) < len(players) + 1:
self._fail(
"auto_guess_transition",
"mixed answers shorter than expected bluff set",
{"answers": answers, "players_count": len(players)},
)
self._expect_session_status(lie_transition_payload, GameSession.Status.GUESS, "auto_guess_transition")
artifact["steps"].append(
{
"step": "auto_guess_transition",
"session_status": lie_transition_payload["session"]["status"],
"answers": [answer["text"] for answer in answers],
}
)
answer_texts = {answer["text"] for answer in answers}
correct_answer = next((answer["text"] for answer in answers if answer.get("text") == question.correct_answer), None)
if correct_answer is None:
self._fail("submit_guesses", "could not resolve correct answer from mixed answers", {"answers": answers})
guess_plan = {
players[0]["nickname"]: "Lie from P2",
players[1]["nickname"]: correct_answer,
players[2]["nickname"]: "Lie from P1",
}
missing_guess_targets = {text for text in guess_plan.values() if text not in answer_texts}
if missing_guess_targets:
self._fail(
"submit_guesses",
"expected bluff targets missing from mixed answers",
{"answers": answers, "missing_guess_targets": sorted(missing_guess_targets)},
)
artifact["guess_plan"] = guess_plan
guess_payloads = []
for player in players:
nickname = player["nickname"]
guess_payload = self._expect_status(
Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"selected_text": guess_plan[nickname],
}
),
content_type="application/json",
),
201,
f"submit_guess[{nickname}]",
)
guess_payloads.append(guess_payload)
reveal_payload = guess_payloads[-1]
self._expect_session_status(reveal_payload, GameSession.Status.REVEAL, "auto_reveal_transition")
if not reveal_payload.get("phase_transition", {}).get("auto_advanced"):
self._fail("auto_reveal_transition", "expected auto_advanced=true on final guess", reveal_payload)
reveal = reveal_payload.get("reveal")
if not reveal:
self._fail("auto_reveal_transition", "missing canonical reveal payload", reveal_payload)
if reveal.get("correct_answer") != question.correct_answer:
self._fail(
"auto_reveal_transition",
"reveal payload returned wrong correct answer",
{"expected": question.correct_answer, "reveal": reveal},
)
if len(reveal.get("lies", [])) != len(players):
self._fail("auto_reveal_transition", "unexpected lie count in reveal payload", reveal)
if len(reveal.get("guesses", [])) != len(players):
self._fail("auto_reveal_transition", "unexpected guess count in reveal payload", reveal)
fooled_guesses = [guess for guess in reveal["guesses"] if not guess.get("is_correct")]
correct_guesses = [guess for guess in reveal["guesses"] if guess.get("is_correct")]
if len(fooled_guesses) != 2:
self._fail("auto_reveal_transition", "expected exactly two bluff guesses", reveal)
if len(correct_guesses) != 1:
self._fail("auto_reveal_transition", "expected exactly one correct guess", reveal)
if any(guess.get("fooled_player_id") is None for guess in fooled_guesses):
self._fail("auto_reveal_transition", "bluff guesses missing fooled_player_id", reveal)
artifact["steps"].append(
{
"step": "submit_guesses",
"guess_results": [
nick = player["nickname"]
lie_res = Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
data=json.dumps(
{
"player_id": payload["guess"]["player_id"],
"selected_text": payload["guess"]["selected_text"],
"is_correct": payload["guess"]["is_correct"],
"fooled_player_id": payload["guess"].get("fooled_player_id"),
"player_id": player["id"],
"session_token": player["session_token"],
"text": f"Lie from {nick}",
}
for payload in guess_payloads
],
}
)
artifact["steps"].append(
{
"step": "auto_reveal_transition",
"session_status": reveal_payload["session"]["status"],
"reveal": {
"correct_answer": reveal["correct_answer"],
"lies_count": len(reveal["lies"]),
"guesses_count": len(reveal["guesses"]),
"fooled_player_ids": sorted(guess["fooled_player_id"] for guess in fooled_guesses),
"correct_guess_player_ids": sorted(guess["player_id"] for guess in correct_guesses),
},
}
)
),
content_type="application/json",
)
if lie_res.status_code != 201:
raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}")
detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_guesses")
self._expect_session_status(detail_payload, GameSession.Status.SCOREBOARD, "auto_scoreboard_transition")
if detail_payload.get("reveal") != reveal:
self._fail("auto_scoreboard_transition", "scoreboard promotion changed canonical reveal payload", detail_payload)
scoreboard = detail_payload.get("scoreboard")
if not scoreboard:
self._fail("auto_scoreboard_transition", "missing scoreboard payload after promotion", detail_payload)
if len(scoreboard) != len(players):
self._fail("auto_scoreboard_transition", "unexpected scoreboard length", detail_payload)
if not detail_payload.get("phase_view_model", {}).get("readiness", {}).get("scoreboard_ready"):
self._fail("auto_scoreboard_transition", "scoreboard_ready=false after promotion", detail_payload)
mix_res = host_client.post(
f"/lobby/sessions/{code}/questions/{round_question_id}/answers/mix",
content_type="application/json",
)
if mix_res.status_code != 200:
raise CommandError(f"mix_answers failed: {mix_res.status_code}")
artifact["steps"].append(
{
"step": "auto_scoreboard_transition",
"session_status": detail_payload["session"]["status"],
"leaderboard": scoreboard,
}
)
answers = mix_res.json().get("answers", [])
if not answers:
raise CommandError("mix_answers returned empty answers")
finish_payload = self._expect_status(
host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json"),
200,
"finish_game",
)
self._expect_session_status(finish_payload, GameSession.Status.FINISHED, "finish_game")
artifact["steps"].append(
{
"step": "finish_game",
"session_status": finish_payload["session"]["status"],
}
for player in players:
nick = player["nickname"]
selected = next((a for a in answers if a.get("player_id") != player["id"]), answers[0])
guess_res = Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"selected_text": selected["text"],
}
),
content_type="application/json",
)
if guess_res.status_code != 201:
raise CommandError(f"submit_guess failed for {nick}: {guess_res.status_code}")
calc_res = host_client.post(
f"/lobby/sessions/{code}/questions/{round_question_id}/scores/calculate",
content_type="application/json",
)
if calc_res.status_code != 200:
raise CommandError(f"calculate_scores failed: {calc_res.status_code}")
board_res = host_client.get(f"/lobby/sessions/{code}/scoreboard")
if board_res.status_code != 200:
raise CommandError(f"reveal_scoreboard failed: {board_res.status_code}")
finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json")
if finish_res.status_code != 200:
raise CommandError(f"finish_game failed: {finish_res.status_code}")
artifact_path = options.get("artifact")
if artifact_path:
artifact = {
"ok": True,
"command": "smoke_staging",
"generated_at": datetime.now(timezone.utc).isoformat(),
"session_code": code,
"players": [player["nickname"] for player in players],
"round_question_id": round_question_id,
"steps": [
"create_session",
"join_players",
"start_round",
"show_question",
"submit_lies",
"mix_answers",
"submit_guesses",
"calculate_scores",
"reveal_scoreboard",
"finish_game",
],
}
output_path = Path(artifact_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")

View File

@@ -24,10 +24,14 @@
<p id="categoryGuardHint">Kategori er kun redigérbar i lobby-fasen.</p>
<p id="phaseStatus">Fase: ukendt (opdatér session-status).</p>
<p id="roundQuestionStatus">Aktiv round question: afventer session-status.</p>
<p id="roundQuestionGuardHint">Round question-id styres server-side i canonical flow og er kun read-only kontekst for host.</p>
<button id="nextRoundBtn" onclick="nextRound()" disabled>3) Næste runde</button>
<button id="finishGameBtn" onclick="finishGame()" disabled>4) Afslut spil</button>
<button id="showQuestionBtn" onclick="showQuestion()" disabled>3) Vis spørgsmål</button>
<input id="roundQuestionId" placeholder="Round question id">
<p id="roundQuestionGuardHint">Round question-id kan kun redigeres i lie/guess/reveal-faser.</p>
<button id="mixAnswersBtn" onclick="mixAnswers()" disabled>4) Mix svar</button>
<button id="calcScoresBtn" onclick="calcScores()" disabled>5) Beregn score</button>
<button id="showScoreboardBtn" onclick="showScoreboard()" disabled>6) Scoreboard</button>
<button id="nextRoundBtn" onclick="nextRound()" disabled>7) Næste runde</button>
<button id="finishGameBtn" onclick="finishGame()" disabled>8) Afslut spil</button>
<p id="hostActionHint">Angiv sessionkode for at aktivere host-actions.</p>
<p id="hostErrorHint">Ingen fejl.</p>
<button id="sessionDetailBtn" onclick="sessionDetail()">Session-status</button>
@@ -51,12 +55,8 @@
<p id="hostCriticalPlayers">Spillere: afventer</p>
<p id="hostCriticalRound">Aktiv round question: afventer</p>
</section>
<pre id="out">Ready.</pre>
{{ lobby_i18n|json_script:"wppHostI18n" }}
<pre id="out">Klar.</pre>
<script>
var WPP_HOST_LOCALE="{{ shell_locale|default:'en'|escapejs }}";
var WPP_HOST_I18N=JSON.parse(document.getElementById("wppHostI18n").textContent||"{}");
function hostCopy(path,fallback){var node=WPP_HOST_I18N;var parts=(path||"").split(".");for(var i=0;i<parts.length;i++){if(!node||typeof node!=="object"){return fallback||path;}node=node[parts[i]];}if(node&&typeof node==="object"){if(node[WPP_HOST_LOCALE]){return node[WPP_HOST_LOCALE];}if(node.en){return node.en;}}return typeof node==="string"?node:(fallback||path);}
var currentSessionStatus="";
var autoRefreshEnabled=false;
var autoRefreshTimer=null;
@@ -65,15 +65,14 @@ var lastRefreshAtLabel="";
var lastRefreshFailed=false;
var sessionDetailInFlight=false;
var hostShellRouteHint="";
var HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",scoreboard:"scoreboard",finished:"finished"};
var HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",finished:"finished"};
var hostShellFatalError=false;
var hostShellRecoverInFlight=false;
var hostCriticalHydrated=false;
function setHostCriticalLoading(isLoading){var skeleton=document.getElementById("hostCriticalSkeleton");var view=document.getElementById("hostCriticalView");if(!skeleton||!view){return;}skeleton.style.display=isLoading?"block":"none";view.style.display=isLoading?"none":"block";}
function hydrateHostCriticalView(data){var session=(data&&data.session)||{};var phaseEl=document.getElementById("hostCriticalPhase");var playersEl=document.getElementById("hostCriticalPlayers");var roundEl=document.getElementById("hostCriticalRound");var roundStatus=document.getElementById("roundQuestionStatus");var roundQuestionId=(data&&data.round_question&&data.round_question.id)?String(data.round_question.id):"";if(phaseEl){phaseEl.textContent="Fase: "+phaseLabel(currentSessionStatus||session.status||"");}
function hydrateHostCriticalView(data){var session=(data&&data.session)||{};var phaseEl=document.getElementById("hostCriticalPhase");var playersEl=document.getElementById("hostCriticalPlayers");var roundEl=document.getElementById("hostCriticalRound");if(phaseEl){phaseEl.textContent="Fase: "+phaseLabel(currentSessionStatus||session.status||"");}
if(playersEl){playersEl.textContent="Spillere: "+(typeof session.players_count==="number"?session.players_count:"ukendt");}
if(roundEl){roundEl.textContent="Aktiv round question: "+(roundQuestionId||"ikke valgt");}
if(roundStatus){roundStatus.textContent="Aktiv round question: "+(roundQuestionId||"afventer session-status.");}
if(roundEl){roundEl.textContent="Aktiv round question: "+(rq()||"ikke valgt");}
hostCriticalHydrated=true;
setHostCriticalLoading(false);
}
@@ -83,9 +82,10 @@ function setHostShellFatalError(detail){hostShellFatalError=true;var out=documen
function clearHostShellFatalError(){hostShellFatalError=false;hostShellRecoverInFlight=false;updateHostShellErrorBoundary();}
function recoverHostShell(mode){if(hostShellRecoverInFlight){return Promise.resolve({error:"recover_in_flight"});}hostShellRecoverInFlight=true;updateHostShellErrorBoundary();if(mode==="reload"){window.location.reload();return Promise.resolve({ok:true});}if(!code()){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();return Promise.resolve({error:"missing_session_code"});}return sessionDetail().then(function(result){clearHostShellFatalError();return result;}).catch(function(err){hostShellRecoverInFlight=false;updateHostShellErrorBoundary();throw err;});}
function code(){return document.getElementById("code").value.trim().toUpperCase();}
function saveHostContext(){try{localStorage.setItem("wppHostContext",JSON.stringify({code:code(),session_status:currentSessionStatus||"",auto_refresh:autoRefreshEnabled}));}catch(_e){}}
function restoreHostContext(){try{var raw=localStorage.getItem("wppHostContext");if(!raw){return false;}var ctx=JSON.parse(raw);if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.session_status){currentSessionStatus=ctx.session_status;}autoRefreshEnabled=!!ctx.auto_refresh;updateAutoRefreshUi();return !!ctx.code;}catch(_e){return false;}}
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Lie";}if(status==="guess"){return"Guess";}if(status==="reveal"){return"Reveal";}if(status==="scoreboard"){return"Scoreboard";}if(status==="finished"){return"Finished";}return"Unknown";}
function rq(){return document.getElementById("roundQuestionId").value.trim();}
function saveHostContext(){try{localStorage.setItem("wppHostContext",JSON.stringify({code:code(),round_question_id:rq(),session_status:currentSessionStatus||"",auto_refresh:autoRefreshEnabled}));}catch(_e){}}
function restoreHostContext(){try{var raw=localStorage.getItem("wppHostContext");if(!raw){return false;}var ctx=JSON.parse(raw);if(ctx.code){document.getElementById("code").value=(ctx.code||"").toUpperCase();}if(ctx.round_question_id){document.getElementById("roundQuestionId").value=ctx.round_question_id;}if(ctx.session_status){currentSessionStatus=ctx.session_status;}autoRefreshEnabled=!!ctx.auto_refresh;updateAutoRefreshUi();return !!ctx.code;}catch(_e){return false;}}
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
function hostShellRouteFromPath(){var marker="/lobby/ui/host";var path=(window.location.pathname||"").toLowerCase();var idx=path.indexOf(marker);if(idx===-1){return"";}var remainder=path.slice(idx+marker.length).replace(/^\/+|\/+$/g,"");if(!remainder){return"";}var route=remainder.split("/")[0];return HOST_SHELL_ROUTES[route]?route:"";}
function expectedHostShellRoute(){return HOST_SHELL_ROUTES[currentSessionStatus]||"";}
function syncHostShellRoute(){var currentRoute=hostShellRouteFromPath();var expectedRoute=expectedHostShellRoute();if(!currentRoute||!expectedRoute){hostShellRouteHint="";return;}if(currentRoute===expectedRoute){hostShellRouteHint="";return;}var nextPath="/lobby/ui/host/"+expectedRoute;window.history.replaceState(null,"",nextPath);hostShellRouteHint="Deep-link route guard: omdirigeret fra /"+currentRoute+" til /"+expectedRoute+" for fase "+phaseLabel(currentSessionStatus)+".";}
@@ -93,27 +93,31 @@ function updateAutoRefreshUi(){var btn=document.getElementById("autoRefreshToggl
function stopAutoRefresh(reason){autoRefreshEnabled=false;if(autoRefreshTimer){clearInterval(autoRefreshTimer);autoRefreshTimer=null;}if(reason){var hint=document.getElementById("autoRefreshHint");if(hint){hint.textContent=reason;}}updateAutoRefreshUi();saveHostContext();}
function startAutoRefresh(){if(!code()){updateAutoRefreshUi();return;}autoRefreshEnabled=true;if(autoRefreshTimer){clearInterval(autoRefreshTimer);}autoRefreshTimer=setInterval(function(){if(!code()||sessionDetailInFlight){return;}if(currentSessionStatus==="finished"){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");return;}sessionDetail();},10000);updateAutoRefreshUi();saveHostContext();}
function toggleAutoRefresh(){if(hostActionInFlight||sessionDetailInFlight||!code()){updateAutoRefreshUi();return;}if(autoRefreshEnabled){stopAutoRefresh();return;}startAutoRefresh();}
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString(WPP_HOST_LOCALE,{hour12:false});}
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString("da-DK",{hour12:false});}
function markSessionRefresh(status){if(status>=200&&status<300){lastRefreshAtLabel=formatTimeLabel(new Date());lastRefreshFailed=false;}else{lastRefreshFailed=true;}updateLastRefreshStatus();}
function updateLastRefreshStatus(){var el=document.getElementById("lastRefreshStatus");if(!el){return;}if(!lastRefreshAtLabel){el.textContent=lastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(lastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+lastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+lastRefreshAtLabel+".";}
function normalizeApiError(data){if(!data||typeof data!=="object"){return"";}return (data.error_code||data.error||"").toString();}
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(WPP_HOST_I18N&&WPP_HOST_I18N.backend&&WPP_HOST_I18N.backend.errors&&WPP_HOST_I18N.backend.errors[key]){return hostCopy("backend.errors."+key,"Action failed. Refresh state and retry.");}if(key.indexOf("session")!==-1){return hostCopy("backend.errors.session_not_found_or_closed","Session code is invalid, or session no longer exists.");}return hostCopy("backend.errors.generic_action_failed_retry","Action failed. Refresh state and retry.");}
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(key.indexOf("phase")!==-1){return"Ugyldig fase for handlingen. Opdatér session-status og prøv igen.";}if(key.indexOf("token")!==-1||key.indexOf("auth")!==-1){return"Session-token er ugyldig eller udløbet. Rejoin sessionen og prøv igen.";}if(key.indexOf("round")!==-1||key.indexOf("question")!==-1||key.indexOf("state")!==-1){return"Runde-kontekst matcher ikke længere. Opdatér session-status før næste handling.";}if(key.indexOf("session")!==-1){return"Sessionkoden er ugyldig eller sessionen findes ikke længere.";}return"Handling fejlede. Opdatér session-status og prøv igen.";}
function updateErrorHint(status,data){var el=document.getElementById("hostErrorHint");if(!el){return;}if(status>=200&&status<300){el.textContent="Ingen fejl.";return;}var errKey=normalizeApiError(data);el.textContent="Fejl: "+mapUiErrorMessage(errKey)+" ("+(errKey||("http_"+status))+")";}
function updateCreateSessionState(){var btn=document.getElementById("createSessionBtn");var hint=document.getElementById("createSessionHint");if(btn){btn.disabled=hostActionInFlight||sessionDetailInFlight;}if(!hint){return;}if(hostActionInFlight){hint.textContent="Opret session er låst mens en host-handling kører.";return;}if(sessionDetailInFlight){hint.textContent="Opret session er låst mens session-opdatering kører.";return;}hint.textContent="Opret session er klar.";}function updateSessionDetailState(){var btn=document.getElementById("sessionDetailBtn");var hint=document.getElementById("sessionDetailHint");var codeInput=document.getElementById("code");if(btn){btn.disabled=sessionDetailInFlight||hostActionInFlight||!code();}if(codeInput){codeInput.readOnly=sessionDetailInFlight||hostActionInFlight;}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Opdaterer session-status…";return;}if(hostActionInFlight){hint.textContent="Session-opdatering er låst mens en host-handling kører.";return;}if(!code()){hint.textContent="Angiv sessionkode for at opdatere session-status.";updateHostShellErrorBoundary();return;}hint.textContent="Session-opdatering klar.";updateHostShellErrorBoundary();}
function updatePhaseStatus(){var el=document.getElementById("phaseStatus");syncHostShellRoute();if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";}
function syncStartRoundGuard(data){var btn=document.getElementById("startRoundBtn");var hint=document.getElementById("startRoundHint");var status=document.getElementById("playerCountStatus");if(!btn||!hint||!status){return;}var count=(data&&data.session&&typeof data.session.players_count==="number")?data.session.players_count:null;var phase=currentSessionStatus||"";if(phase&&phase!=="lobby"){btn.disabled=true;status.textContent=count===null?"Spillere i session: ukendt":"Spillere i session: "+count;hint.textContent="Start runde er kun tilladt i lobby-fasen.";return;}if(count===null){btn.disabled=true;status.textContent="Spillere i session: ukendt";hint.textContent="Opdatér session-status for at validere 3-5 spillere.";return;}status.textContent="Spillere i session: "+count;if(count<3){btn.disabled=true;hint.textContent="Mangler spillere: kræver mindst 3 for at starte runde.";return;}if(count>5){btn.disabled=true;hint.textContent="For mange spillere: maks 5 i MVP før runde-start.";return;}btn.disabled=false;hint.textContent="Klar: spillerantal er indenfor 3-5 til runde-start.";}
function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var phase=currentSessionStatus||"";var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at se aktiv round question.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-kontekst.";}else{roundQuestionGuardHint.textContent="Round question-id styres server-side i canonical flow og er read-only i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if(phase==="scoreboard"){hint.textContent="Host-actions er klar: vælg næste runde eller afslut spillet.";return;}if(hostShellRouteHint){hint.textContent=hostShellRouteHint;return;}hint.textContent="Mid-round faseskift er server-styrede i canonical flow. Host monitorerer kun fremdrift i fase: "+phaseLabel(phase)+".";}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.session){hydrateHostCriticalView(d);}updateErrorHint(r.status,d);updatePhaseStatus();syncStartRoundGuard(d);updateHostActionState();if(currentSessionStatus==="finished"&&autoRefreshEnabled){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updateAutoRefreshUi();}if(hostShellFatalError){clearHostShellFatalError();}saveHostContext();return d;}
function updateHostActionState(){updateCreateSessionState();var hasCode=!!code();var hasRound=!!rq();var phase=currentSessionStatus||"";var showQuestionBtn=document.getElementById("showQuestionBtn");var mixAnswersBtn=document.getElementById("mixAnswersBtn");var calcScoresBtn=document.getElementById("calcScoresBtn");var showScoreboardBtn=document.getElementById("showScoreboardBtn");var nextRoundBtn=document.getElementById("nextRoundBtn");var finishGameBtn=document.getElementById("finishGameBtn");var roundQuestionInput=document.getElementById("roundQuestionId");var roundQuestionGuardHint=document.getElementById("roundQuestionGuardHint");var categorySelect=document.getElementById("category");var categoryGuardHint=document.getElementById("categoryGuardHint");var hint=document.getElementById("hostActionHint");if(showQuestionBtn){showQuestionBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lie";}if(showScoreboardBtn){showScoreboardBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}if(mixAnswersBtn){mixAnswersBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||(phase!=="lie"&&phase!=="guess");}if(calcScoresBtn){calcScoresBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||!hasRound||phase!=="guess";}var canEditRoundQuestion=!!hasCode&&(phase==="lie"||phase==="guess"||phase==="reveal");if(roundQuestionInput){roundQuestionInput.disabled=hostActionInFlight||sessionDetailInFlight||!canEditRoundQuestion;}if(roundQuestionGuardHint){if(hostActionInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens en handling kører.";}else if(sessionDetailInFlight){roundQuestionGuardHint.textContent="Round question-id er låst mens session-opdatering kører.";}else if(!hasCode){roundQuestionGuardHint.textContent="Angiv sessionkode for at redigere round question-id.";}else if(!phase){roundQuestionGuardHint.textContent="Opdatér session-status for round question-id.";}else if(canEditRoundQuestion){roundQuestionGuardHint.textContent="Round question-id kan redigeres i fase: "+phaseLabel(phase)+".";}else{roundQuestionGuardHint.textContent="Round question-id er låst i fase: "+phaseLabel(phase)+".";}}if(categorySelect){categorySelect.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="lobby";}if(categoryGuardHint){if(hostActionInFlight){categoryGuardHint.textContent="Kategori er midlertidigt låst mens en handling kører.";}else if(sessionDetailInFlight){categoryGuardHint.textContent="Kategori er låst mens session-opdatering kører.";}else if(!hasCode){categoryGuardHint.textContent="Angiv sessionkode for at låse kategori til lobby-fasen.";}else if(phase==="lobby"){categoryGuardHint.textContent="Kategori kan vælges i lobby-fasen.";}else if(!phase){categoryGuardHint.textContent="Opdatér session-status for at validere kategori-lås.";}else{categoryGuardHint.textContent="Kategori er låst udenfor lobby-fasen.";}}if(!hint){return;}if(hostActionInFlight){hint.textContent="Handling kører… afvent svar før næste klik.";return;}if(sessionDetailInFlight){hint.textContent="Host-actions er låst mens session-opdatering kører.";return;}if(!hasCode){hint.textContent="Angiv sessionkode for at aktivere host-actions.";return;}if(!phase){hint.textContent="Opdatér session-status for fasebaserede host-actions.";return;}if(phase==="finished"){hint.textContent="Spillet er afsluttet: gameplay-actions er låst.";return;}if((phase==="lie"||phase==="guess")&&!hasRound){hint.textContent="Round question id mangler: mix/beregn score er låst.";return;}if(hostShellRouteHint){hint.textContent=hostShellRouteHint;return;}hint.textContent="Host-actions er klar for fase: "+phaseLabel(phase)+".";}
async function api(path,method,payload){var o={method:method||"GET",headers:{"Accept":"application/json"}};if(payload!==null){o.headers["Content-Type"]="application/json";o.headers["X-CSRFToken"]=csrf();o.body=JSON.stringify(payload);}var r=await fetch(path,o);var d=await r.json().catch(function(){return {};});var isSessionDetailRead=(method||"GET")==="GET"&&/^\/lobby\/sessions\/[A-Z0-9]+$/.test(path);if(isSessionDetailRead){markSessionRefresh(r.status);}document.getElementById("out").textContent=JSON.stringify({status:r.status,data:d},null,2);if(d.session&&d.session.code){document.getElementById("code").value=d.session.code;}if(d.session&&d.session.status){currentSessionStatus=d.session.status;}if(d.round_question&&d.round_question.id){document.getElementById("roundQuestionId").value=d.round_question.id;}if(d.session){hydrateHostCriticalView(d);}updateErrorHint(r.status,d);updatePhaseStatus();syncStartRoundGuard(d);updateHostActionState();if(currentSessionStatus==="finished"&&autoRefreshEnabled){stopAutoRefresh("Auto-refresh stoppet: spillet er afsluttet.");}else{updateAutoRefreshUi();}if(hostShellFatalError){clearHostShellFatalError();}saveHostContext();return d;}
function withHostActionLock(fn){if(hostActionInFlight){return Promise.resolve({error:"host_action_in_flight"});}hostActionInFlight=true;updateHostActionState();return Promise.resolve().then(fn).finally(function(){hostActionInFlight=false;updateHostActionState();});}
function createSession(){return withHostActionLock(function(){return api("/lobby/sessions/create","POST",{});});}
function sessionDetail(){if(!code()){updateSessionDetailState();return Promise.resolve({error:"missing_session_code"});}if(sessionDetailInFlight){return Promise.resolve({error:"session_detail_in_flight"});}sessionDetailInFlight=true;updateSessionDetailState();return api("/lobby/sessions/"+code(),"GET",null).finally(function(){sessionDetailInFlight=false;updateSessionDetailState();});}
function startRound(){if(document.getElementById("startRoundBtn").disabled){return Promise.resolve({error:"not_enough_players_client_guard"});}return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/rounds/start","POST",{category_slug:document.getElementById("category").value});});}
function showQuestion(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/show","POST",{});});}
function mixAnswers(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/answers/mix","POST",{});});}
function calcScores(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/questions/"+rq()+"/scores/calculate","POST",{});});}
function showScoreboard(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/scoreboard","GET",null);});}
function nextRound(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/rounds/next","POST",{});});}
function finishGame(){return withHostActionLock(function(){return api("/lobby/sessions/"+code()+"/finish","POST",{});});}
["code"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});field.addEventListener("change",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});});
["code","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});field.addEventListener("change",function(){syncStartRoundGuard(null);updateHostActionState();updateSessionDetailState();saveHostContext();});});
window.addEventListener("error",function(event){setHostShellFatalError((event&&event.message)||"Ukendt runtime-fejl");});
window.addEventListener("unhandledrejection",function(event){var reason=event&&event.reason;var detail=(reason&&reason.message)||String(reason||"Unhandled promise rejection");setHostShellFatalError(detail);});

View File

@@ -69,12 +69,8 @@
<p id="playerCriticalRound">Round question: afventer</p>
<p id="playerCriticalJoin">Join-status: afventer</p>
</section>
<pre id="out">Ready.</pre>
{{ lobby_i18n|json_script:"wppPlayerI18n" }}
<pre id="out">Klar.</pre>
<script>
var WPP_PLAYER_LOCALE="{{ shell_locale|default:'en'|escapejs }}";
var WPP_PLAYER_I18N=JSON.parse(document.getElementById("wppPlayerI18n").textContent||"{}");
function playerCopy(path,fallback){var node=WPP_PLAYER_I18N;var parts=(path||"").split(".");for(var i=0;i<parts.length;i++){if(!node||typeof node!=="object"){return fallback||path;}node=node[parts[i]];}if(node&&typeof node==="object"){if(node[WPP_PLAYER_LOCALE]){return node[WPP_PLAYER_LOCALE];}if(node.en){return node.en;}}return typeof node==="string"?node:(fallback||path);}
var availableAnswers=[];
var guessSubmitted=false;
var lieSubmitted=false;
@@ -109,7 +105,7 @@ function clearPlayerShellFatalError(){playerShellFatalError=false;playerShellRec
function recoverPlayerShell(mode){if(playerShellRecoverInFlight){return Promise.resolve({error:"recover_in_flight"});}playerShellRecoverInFlight=true;updatePlayerShellErrorBoundary();if(mode==="reload"){window.location.reload();return Promise.resolve({ok:true});}if(!code()){playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();return Promise.resolve({error:"missing_session_code"});}return sessionDetail().then(function(result){clearPlayerShellFatalError();return result;}).catch(function(err){playerShellRecoverInFlight=false;updatePlayerShellErrorBoundary();throw err;});}
function pid(){return document.getElementById("playerId").value.trim();}
function rq(){return document.getElementById("roundQuestionId").value.trim();}
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Lie";}if(status==="guess"){return"Guess";}if(status==="reveal"){return"Reveal";}if(status==="scoreboard"){return"Scoreboard";}if(status==="finished"){return"Finished";}return"Unknown";}
function phaseLabel(status){if(status==="lobby"){return"Lobby";}if(status==="lie"){return"Løgn";}if(status==="guess"){return"Gæt";}if(status==="reveal"){return"Reveal";}if(status==="finished"){return"Afsluttet";}return"Ukendt";}
function updatePhaseStatus(){var el=document.getElementById("phaseStatus");if(!el){return;}if(!currentSessionStatus){el.textContent="Fase: ukendt (opdatér session-status).";return;}el.textContent="Fase: "+phaseLabel(currentSessionStatus)+" ("+currentSessionStatus+")";}
function savePlayerContext(){try{localStorage.setItem(PLAYER_CONTEXT_KEY,JSON.stringify({code:code(),nickname:document.getElementById("nickname").value.trim(),player_id:pid(),session_token:document.getElementById("sessionToken").value.trim(),round_question_id:rq(),auto_refresh:playerAutoRefreshEnabled}));}catch(_e){}}
function loadPlayerContext(){try{var raw=localStorage.getItem(PLAYER_CONTEXT_KEY);if(!raw){return null;}return JSON.parse(raw);}catch(_e){return null;}}
@@ -122,8 +118,8 @@ function resetRoundContextForManualChange(){document.getElementById("roundQuesti
function updateContextLockState(){var locked=isPlayerContextLocked()||joinInFlight;var codeField=document.getElementById("code");var nicknameField=document.getElementById("nickname");var playerIdField=document.getElementById("playerId");var tokenField=document.getElementById("sessionToken");if(codeField){codeField.readOnly=locked;}if(nicknameField){nicknameField.readOnly=locked;}if(playerIdField){playerIdField.readOnly=locked;}if(tokenField){tokenField.readOnly=true;}var hint=document.getElementById("contextLockHint");if(!hint){return;}if(joinInFlight){hint.textContent="Låser kontekst…";return;}if(locked){hint.textContent="Spillerkontekst er låst efter join.";return;}hint.textContent="Kontekst er ikke låst endnu.";}
function canAttemptJoin(){return !!(code()&&document.getElementById("nickname").value.trim());}
function normalizeApiError(data){if(!data||typeof data!=="object"){return"";}return (data.error_code||data.error||"").toString();}
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(WPP_PLAYER_I18N&&WPP_PLAYER_I18N.backend&&WPP_PLAYER_I18N.backend.errors&&WPP_PLAYER_I18N.backend.errors[key]){return playerCopy("backend.errors."+key,"Action failed. Refresh state and retry.");}if(key.indexOf("session")!==-1){return playerCopy("backend.errors.session_not_found_or_closed","Session code is invalid, or session no longer exists.");}return playerCopy("backend.errors.generic_action_failed_retry","Action failed. Refresh state and retry.");}
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString(WPP_PLAYER_LOCALE,{hour12:false});}
function mapUiErrorMessage(errorKey){if(!errorKey){return"";}var key=errorKey.toLowerCase();if(key.indexOf("phase")!==-1){return"Ugyldig fase for handlingen. Opdatér session-status og prøv igen.";}if(key.indexOf("token")!==-1||key.indexOf("auth")!==-1){return"Session-token er ugyldig eller udløbet. Rejoin sessionen og prøv igen.";}if(key.indexOf("round")!==-1||key.indexOf("question")!==-1||key.indexOf("state")!==-1){return"Runde-kontekst matcher ikke længere. Opdatér session-status før næste handling.";}if(key.indexOf("session")!==-1){return"Sessionkoden er ugyldig eller sessionen findes ikke længere.";}return"Handling fejlede. Opdatér session-status og prøv igen.";}
function formatTimeLabel(dateObj){return dateObj.toLocaleTimeString("da-DK",{hour12:false});}
function markPlayerSessionRefresh(status){if(status>=200&&status<300){playerLastRefreshAtLabel=formatTimeLabel(new Date());playerLastRefreshFailed=false;}else{playerLastRefreshFailed=true;}updatePlayerLastRefreshStatus();}
function updatePlayerLastRefreshStatus(){var el=document.getElementById("playerLastRefreshStatus");if(!el){return;}if(!playerLastRefreshAtLabel){el.textContent=playerLastRefreshFailed?"Session-data kan være forældet (ingen succesfuld opdatering endnu).":"Session-data ikke opdateret endnu.";return;}if(playerLastRefreshFailed){el.textContent="Session-data kan være forældet (seneste succes: "+playerLastRefreshAtLabel+").";return;}el.textContent="Sidst opdateret: "+playerLastRefreshAtLabel+".";}
function updatePlayerAutoRefreshUi(){var btn=document.getElementById("playerAutoRefreshToggleBtn");var hint=document.getElementById("playerAutoRefreshHint");if(btn){btn.textContent="Auto-refresh: "+(playerAutoRefreshEnabled?"ON":"OFF");btn.disabled=sessionDetailInFlight||joinInFlight||!code();}if(!hint){return;}if(sessionDetailInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv session-opdatering.";return;}if(joinInFlight){hint.textContent="Auto-refresh-lås: afvent aktiv join.";return;}if(!code()){hint.textContent="Auto-refresh kræver sessionkode.";return;}if(!playerAutoRefreshEnabled){hint.textContent="Auto-refresh er slået fra.";return;}if(currentSessionStatus==="finished"){hint.textContent="Auto-refresh stoppet: spillet er afsluttet.";return;}hint.textContent="Auto-refresh aktiv (10s) for spillerstatus.";}

File diff suppressed because it is too large Load Diff

View File

@@ -40,7 +40,6 @@ def host_screen(request, spa_path=None):
{
"categories": categories,
"lobby_i18n": lobby_i18n_catalog(),
"shell_locale": resolve_locale(request),
},
)
@@ -49,8 +48,4 @@ def player_screen(request):
if use_spa_ui():
return _render_spa_shell(request, "/player", "player")
return render(
request,
"lobby/player_screen.html",
{"lobby_i18n": lobby_i18n_catalog(), "shell_locale": resolve_locale(request)},
)
return render(request, "lobby/player_screen.html", {"lobby_i18n": lobby_i18n_catalog()})

View File

@@ -1,38 +1,26 @@
from datetime import timedelta
import json
import random
from datetime import timedelta
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError, transaction
from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST
from fupogfakta.models import GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent
from fupogfakta.payloads import (
build_leaderboard as _build_leaderboard,
build_reveal_payload as _build_reveal_payload,
build_scoreboard_phase_event as _build_scoreboard_phase_event,
build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload,
from fupogfakta.models import (
Category,
GameSession,
Guess,
LieAnswer,
Player,
Question,
RoundConfig,
RoundQuestion,
ScoreEvent,
)
from fupogfakta.services import (
finish_game as _finish_game,
get_current_round_question as _get_current_round_question,
prepare_mixed_answers as _prepare_mixed_answers,
promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard,
resolve_scores as _resolve_scores,
select_round_question as _select_round_question,
show_question as _show_question,
start_next_round as _start_next_round,
start_round as _start_round,
)
from realtime.broadcast import sync_broadcast_phase_event
from .i18n import api_error
_GAMEPLAY_SERVICE_OWNERSHIP_EXPORTS = (
_select_round_question,
_build_scoreboard_phase_event,
)
from .i18n import api_error, lobby_i18n_errors
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
SESSION_CODE_LENGTH = 6
@@ -42,10 +30,8 @@ JOINABLE_STATUSES = {
GameSession.Status.LIE,
GameSession.Status.GUESS,
GameSession.Status.REVEAL,
GameSession.Status.SCOREBOARD,
}
ERROR_CODES = lobby_i18n_errors()
def _json_body(request: HttpRequest) -> dict:
@@ -75,17 +61,43 @@ def _create_unique_session_code() -> str:
raise RuntimeError("Could not generate unique session code")
def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
status = session.status
in_lobby = status == GameSession.Status.LOBBY
in_lie = status == GameSession.Status.LIE
in_guess = status == GameSession.Status.GUESS
in_reveal = status == GameSession.Status.REVEAL
in_finished = status == GameSession.Status.FINISHED
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
transition = _promote_reveal_to_scoreboard(session)
if transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
return transition.session
min_players_reached = players_count >= 3
max_players_allowed = players_count <= 5
return {
"status": status,
"round_number": session.current_round,
"players_count": players_count,
"constraints": {
"min_players_to_start": 3,
"max_players_mvp": 5,
"min_players_reached": min_players_reached,
"max_players_allowed": max_players_allowed,
},
"host": {
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
"can_show_question": in_lie and not has_round_question,
"can_mix_answers": in_lie or in_guess,
"can_calculate_scores": in_guess,
"can_reveal_scoreboard": in_reveal,
"can_start_next_round": in_reveal,
"can_finish_game": in_reveal,
},
"player": {
"can_join": status in JOINABLE_STATUSES,
"can_submit_lie": in_lie and has_round_question,
"can_submit_guess": in_guess and has_round_question,
"can_view_final_result": in_finished,
},
}
@require_POST
@@ -117,14 +129,14 @@ def join_session(request: HttpRequest) -> JsonResponse:
if not code:
return api_error(
request,
code="session_code_required",
key=ERROR_CODES.get("session_code_required", "session_code_required"),
status=400,
)
if len(nickname) < 2 or len(nickname) > 40:
return api_error(
request,
code="nickname_invalid",
key=ERROR_CODES.get("nickname_invalid", "nickname_invalid"),
status=400,
)
@@ -133,21 +145,21 @@ def join_session(request: HttpRequest) -> JsonResponse:
except GameSession.DoesNotExist:
return api_error(
request,
code="session_not_found",
key=ERROR_CODES.get("session_not_found", "session_not_found"),
status=404,
)
if session.status not in JOINABLE_STATUSES:
return api_error(
request,
code="session_not_joinable",
key=ERROR_CODES.get("session_not_joinable", "session_not_joinable"),
status=400,
)
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
return api_error(
request,
code="nickname_taken",
key=ERROR_CODES.get("nickname_taken", "nickname_taken"),
status=409,
)
@@ -179,7 +191,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
except GameSession.DoesNotExist:
return api_error(
request,
code="session_not_found",
key=ERROR_CODES.get("session_not_found", "session_not_found"),
status=404,
)
@@ -192,12 +204,27 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
)
)
session = _maybe_promote_reveal_to_scoreboard(session)
current_round_question = _get_current_round_question(session)
gameplay_payload = _build_session_detail_gameplay_payload(
current_round_question = (
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
.select_related("question")
.order_by("-id")
.first()
)
round_question_payload = None
if current_round_question:
round_question_payload = {
"id": current_round_question.id,
"round_number": current_round_question.round_number,
"prompt": current_round_question.question.prompt,
"shown_at": current_round_question.shown_at.isoformat(),
"answers": [{"text": text} for text in (current_round_question.mixed_answers or [])],
}
phase_view_model = _build_phase_view_model(
session,
current_round_question=current_round_question,
players_count=len(players),
has_round_question=bool(current_round_question),
)
return JsonResponse(
@@ -210,7 +237,8 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
"players_count": len(players),
},
"players": players,
**gameplay_payload,
"round_question": round_question_payload,
"phase_view_model": phase_view_model,
}
)
@@ -224,7 +252,7 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
if not category_slug:
return api_error(
request,
code="category_slug_required",
key=ERROR_CODES.get("category_slug_required", "category_slug_required"),
status=400,
)
@@ -235,35 +263,82 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
except GameSession.DoesNotExist:
return api_error(
request,
code="session_not_found",
key=ERROR_CODES.get("session_not_found", "session_not_found"),
status=404,
)
if session.host_id != request.user.id:
return api_error(
request,
code="host_only_start_round",
key=ERROR_CODES.get("host_only_start_round", "host_only_start_round"),
status=403,
)
if session.status != GameSession.Status.LOBBY:
return api_error(
request,
key=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
status=400,
)
try:
transition = _start_round(session, category_slug)
except ValueError as exc:
error_code = str(exc)
error_status = {
"category_not_found": 404,
"round_already_configured": 409,
}.get(error_code, 400)
return api_error(request, code=error_code, status=error_status)
category = Category.objects.get(slug=category_slug, is_active=True)
except Category.DoesNotExist:
return api_error(
request,
key=ERROR_CODES.get("category_not_found", "category_not_found"),
status=404,
)
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
if not Question.objects.filter(category=category, is_active=True).exists():
return api_error(
request,
key=ERROR_CODES.get("category_has_no_questions", "category_has_no_questions"),
status=400,
)
with transaction.atomic():
session = GameSession.objects.select_for_update().get(pk=session.pk)
if session.status != GameSession.Status.LOBBY:
return api_error(
request,
key=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
status=400,
)
round_config, created = RoundConfig.objects.get_or_create(
session=session,
number=session.current_round,
defaults={"category": category},
)
if not created:
return api_error(
request,
key=ERROR_CODES.get("round_already_configured", "round_already_configured"),
status=409,
)
session.status = GameSession.Status.LIE
session.save(update_fields=["status"])
return JsonResponse(
{
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"round": {
"number": round_config.number,
"category": {
"slug": round_config.category.slug,
"name": round_config.category.name,
},
},
},
status=201,
)
return JsonResponse(transition.response_payload, status=201)
@require_POST
@login_required
@@ -275,29 +350,78 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
except GameSession.DoesNotExist:
return api_error(
request,
code="session_not_found",
key=ERROR_CODES.get("session_not_found", "session_not_found"),
status=404,
)
if session.host_id != request.user.id:
return api_error(
request,
code="host_only_show_question",
key=ERROR_CODES.get("host_only_show_question", "host_only_show_question"),
status=403,
)
try:
transition = _show_question(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
if session.status != GameSession.Status.LIE:
return api_error(
request,
key=ERROR_CODES.get("show_question_invalid_phase", "show_question_invalid_phase"),
status=400,
)
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
try:
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
except RoundConfig.DoesNotExist:
return api_error(
request,
key=ERROR_CODES.get("round_config_missing", "round_config_missing"),
status=400,
)
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
return api_error(
request,
key=ERROR_CODES.get("question_already_shown", "question_already_shown"),
status=409,
)
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
available_questions = Question.objects.filter(
category=round_config.category,
is_active=True,
).exclude(pk__in=used_question_ids)
if not available_questions.exists():
return api_error(
request,
key=ERROR_CODES.get("no_available_questions", "no_available_questions"),
status=400,
)
question = random.choice(list(available_questions))
round_question = RoundQuestion.objects.create(
session=session,
round_number=session.current_round,
question=question,
correct_answer=question.correct_answer,
)
return JsonResponse(transition.response_payload, status=201)
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
return JsonResponse(
{
"round_question": {
"id": round_question.id,
"prompt": question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
},
"config": {
"lie_seconds": round_config.lie_seconds,
},
},
status=201,
)
@require_POST
@@ -310,29 +434,29 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
lie_text = str(payload.get("text", "")).strip()
if not player_id:
return api_error(request, code="player_id_required", status=400)
return JsonResponse({"error": "player_id is required"}, status=400)
if not session_token:
return api_error(request, code="session_token_required", status=400)
return JsonResponse({"error": "session_token is required"}, status=400)
if not lie_text or len(lie_text) > 255:
return api_error(request, code="lie_text_invalid", status=400)
return JsonResponse({"error": "text must be between 1 and 255 characters"}, status=400)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code="session_not_found", status=404)
return JsonResponse({"error": "Session not found"}, status=404)
if session.status != GameSession.Status.LIE:
return api_error(request, code="lie_submission_invalid_phase", status=400)
return JsonResponse({"error": "Lie submission is only allowed in lie phase"}, status=400)
try:
player = Player.objects.get(pk=player_id, session=session)
except Player.DoesNotExist:
return api_error(request, code="player_not_found_in_session", status=404)
return JsonResponse({"error": "Player not found in session"}, status=404)
if player.session_token != session_token:
return api_error(request, code="invalid_player_session_token", status=403)
return JsonResponse({"error": "Invalid player session token"}, status=403)
try:
round_question = RoundQuestion.objects.get(
@@ -341,46 +465,21 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return api_error(request, code="round_question_not_found", status=404)
return JsonResponse({"error": "Round question not found"}, status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return api_error(request, code="round_config_missing", status=400)
return JsonResponse({"error": "Round config missing"}, status=400)
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
if timezone.now() > lie_deadline_at:
return api_error(request, code="lie_submission_closed", status=400)
return JsonResponse({"error": "Lie submission window has closed"}, status=400)
try:
lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text)
except IntegrityError:
return api_error(request, code="lie_already_submitted", status=409)
players_count = Player.objects.filter(session=session).count()
lie_count = LieAnswer.objects.filter(round_question=round_question).count()
session_status = session.status
mixed_answers_payload = None
if players_count > 0 and lie_count >= players_count:
try:
mixed_answers = _prepare_mixed_answers(round_question)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
session.status = GameSession.Status.GUESS
session.save(update_fields=["status"])
session_status = session.status
mixed_answers_payload = [{"text": text} for text in mixed_answers]
sync_broadcast_phase_event(
session.code,
"phase.guess_started",
{
"round_question_id": round_question.id,
"answers": mixed_answers_payload,
"guess_seconds": round_config.guess_seconds,
},
)
return JsonResponse({"error": "Lie already submitted for this player"}, status=409)
return JsonResponse(
{
@@ -394,18 +493,6 @@ def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonR
"window": {
"lie_deadline_at": lie_deadline_at.isoformat(),
},
"session": {
"code": session.code,
"status": session_status,
"current_round": session.current_round,
},
"phase_transition": {
"current_phase": session_status,
"lies_submitted": lie_count,
"players_expected": players_count,
"auto_advanced": session_status == GameSession.Status.GUESS,
},
"answers": mixed_answers_payload,
},
status=201,
)
@@ -420,21 +507,21 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
except GameSession.DoesNotExist:
return api_error(
request,
code="session_not_found",
key=ERROR_CODES.get("session_not_found", "session_not_found"),
status=404,
)
if session.host_id != request.user.id:
return api_error(
request,
code="host_only_mix_answers",
key=ERROR_CODES.get("host_only_mix_answers", "host_only_mix_answers"),
status=403,
)
if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return api_error(
request,
code="mix_answers_invalid_phase",
key=ERROR_CODES.get("mix_answers_invalid_phase", "mix_answers_invalid_phase"),
status=400,
)
@@ -447,7 +534,7 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
except RoundQuestion.DoesNotExist:
return api_error(
request,
code="round_question_not_found",
key=ERROR_CODES.get("round_question_not_found", "round_question_not_found"),
status=404,
)
@@ -456,37 +543,38 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return api_error(
request,
code="mix_answers_invalid_phase",
key=ERROR_CODES.get("mix_answers_invalid_phase", "mix_answers_invalid_phase"),
status=400,
)
locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk)
try:
deduped_answers = _prepare_mixed_answers(locked_round_question)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
deduped_answers = list(locked_round_question.mixed_answers or [])
if not deduped_answers:
lie_texts = list(locked_round_question.lies.values_list("text", flat=True))
seen = set()
for text in [locked_round_question.correct_answer, *lie_texts]:
normalized = text.strip().casefold()
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped_answers.append(text.strip())
if len(deduped_answers) < 2:
return api_error(
request,
key=ERROR_CODES.get("not_enough_answers_to_mix", "not_enough_answers_to_mix"),
status=400,
)
random.shuffle(deduped_answers)
locked_round_question.mixed_answers = deduped_answers
locked_round_question.save(update_fields=["mixed_answers"])
if locked_session.status == GameSession.Status.LIE:
locked_session.status = GameSession.Status.GUESS
locked_session.save(update_fields=["status"])
try:
_guess_config = RoundConfig.objects.get(session=session, number=session.current_round)
_guess_seconds = _guess_config.guess_seconds
except RoundConfig.DoesNotExist:
_guess_seconds = None
sync_broadcast_phase_event(
session.code,
"phase.guess_started",
{
"round_question_id": round_question.id,
"answers": [{"text": t} for t in deduped_answers],
"guess_seconds": _guess_seconds,
},
)
return JsonResponse(
{
"session": {
@@ -513,29 +601,29 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
selected_text = str(payload.get("selected_text", "")).strip()
if not player_id:
return api_error(request, code="player_id_required", status=400)
return JsonResponse({"error": "player_id is required"}, status=400)
if not session_token:
return api_error(request, code="session_token_required", status=400)
return JsonResponse({"error": "session_token is required"}, status=400)
if not selected_text or len(selected_text) > 255:
return api_error(request, code="selected_text_invalid", status=400)
return JsonResponse({"error": "selected_text must be between 1 and 255 characters"}, status=400)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code="session_not_found", status=404)
return JsonResponse({"error": "Session not found"}, status=404)
if session.status != GameSession.Status.GUESS:
return api_error(request, code="guess_submission_invalid_phase", status=400)
return JsonResponse({"error": "Guess submission is only allowed in guess phase"}, status=400)
try:
player = Player.objects.get(pk=player_id, session=session)
except Player.DoesNotExist:
return api_error(request, code="player_not_found_in_session", status=404)
return JsonResponse({"error": "Player not found in session"}, status=404)
if player.session_token != session_token:
return api_error(request, code="invalid_player_session_token", status=403)
return JsonResponse({"error": "Invalid player session token"}, status=403)
try:
round_question = RoundQuestion.objects.get(
@@ -544,18 +632,18 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return api_error(request, code="round_question_not_found", status=404)
return JsonResponse({"error": "Round question not found"}, status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return api_error(request, code="round_config_missing", status=400)
return JsonResponse({"error": "Round config missing"}, status=400)
guess_deadline_at = round_question.shown_at + timedelta(
seconds=round_config.lie_seconds + round_config.guess_seconds
)
if timezone.now() > guess_deadline_at:
return api_error(request, code="guess_submission_closed", status=400)
return JsonResponse({"error": "Guess submission window has closed"}, status=400)
allowed_answers = {
round_question.correct_answer.strip().casefold(),
@@ -568,7 +656,7 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
selected_normalized = selected_text.casefold()
if selected_normalized not in allowed_answers:
return api_error(request, code="selected_answer_invalid", status=400)
return JsonResponse({"error": "Selected answer is not part of this round"}, status=400)
correct_normalized = round_question.correct_answer.strip().casefold()
fooled_player_id = None
@@ -586,68 +674,7 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
fooled_player_id=fooled_player_id,
)
except IntegrityError:
return api_error(request, code="guess_already_submitted", status=409)
players_count = Player.objects.filter(session=session).count()
guess_count = Guess.objects.filter(round_question=round_question).count()
session_status = session.status
reveal_payload = None
leaderboard = None
if players_count > 0 and guess_count >= players_count:
score_events = []
should_broadcast_scores = False
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status == GameSession.Status.GUESS:
already_calculated = ScoreEvent.objects.filter(
session=locked_session,
meta__round_question_id=round_question.id,
).exists()
if not already_calculated:
score_events, leaderboard = _resolve_scores(locked_session, round_question, round_config)
should_broadcast_scores = True
else:
score_events = list(
ScoreEvent.objects.filter(
session=locked_session,
meta__round_question_id=round_question.id,
).select_related("player")
)
leaderboard = _build_leaderboard(locked_session)
locked_session.status = GameSession.Status.REVEAL
locked_session.save(update_fields=["status"])
elif locked_session.status == GameSession.Status.REVEAL:
score_events = list(
ScoreEvent.objects.filter(
session=locked_session,
meta__round_question_id=round_question.id,
).select_related("player")
)
leaderboard = _build_leaderboard(locked_session)
session_status = locked_session.status
reveal_payload = _build_reveal_payload(round_question)
if should_broadcast_scores:
score_deltas = [
{"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason}
for ev in score_events
]
sync_broadcast_phase_event(
session.code,
"phase.scores_calculated",
{
"round_question_id": round_question.id,
"score_deltas": score_deltas,
"leaderboard": list(leaderboard),
},
)
return JsonResponse({"error": "Guess already submitted for this player"}, status=409)
return JsonResponse(
{
@@ -663,19 +690,6 @@ def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> Jso
"window": {
"guess_deadline_at": guess_deadline_at.isoformat(),
},
"session": {
"code": session.code,
"status": session_status,
"current_round": session.current_round,
},
"phase_transition": {
"current_phase": session_status,
"guesses_submitted": guess_count,
"players_expected": players_count,
"auto_advanced": session_status == GameSession.Status.REVEAL,
},
"reveal": reveal_payload,
"leaderboard": leaderboard,
},
status=201,
)
@@ -691,23 +705,30 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code="session_not_found", status=404)
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return api_error(request, code="host_only_view_scoreboard", status=403)
return JsonResponse({"error": "Only host can view scoreboard"}, status=403)
transition = _promote_reveal_to_scoreboard(session)
if transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
session = transition.session
if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
return api_error(request, code="scoreboard_invalid_phase", status=400)
if session.status != GameSession.Status.REVEAL:
return JsonResponse({"error": "Scoreboard is only available in reveal phase"}, status=400)
return JsonResponse(transition.response_payload)
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return JsonResponse(
{
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"leaderboard": leaderboard,
}
)
@require_POST
@@ -718,24 +739,29 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code="session_not_found", status=404)
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return api_error(request, code="host_only_start_next_round", status=403)
return JsonResponse({"error": "Only host can start next round"}, status=403)
try:
transition = _start_next_round(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.REVEAL:
return JsonResponse({"error": "Next round can only start from reveal phase"}, status=400)
if transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
locked_session.current_round += 1
locked_session.status = GameSession.Status.LOBBY
locked_session.save(update_fields=["current_round", "status"])
return JsonResponse(transition.response_payload)
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.LOBBY,
"current_round": locked_session.current_round,
}
}
)
@require_POST
@login_required
@@ -745,24 +771,38 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code="session_not_found", status=404)
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return api_error(request, code="host_only_finish_game", status=403)
return JsonResponse({"error": "Only host can finish game"}, status=403)
try:
transition = _finish_game(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.REVEAL:
return JsonResponse({"error": "Game can only be finished from reveal phase"}, status=400)
if transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
locked_session.status = GameSession.Status.FINISHED
locked_session.save(update_fields=["status"])
return JsonResponse(transition.response_payload)
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
winner = leaderboard[0] if leaderboard else None
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.FINISHED,
"current_round": session.current_round,
},
"winner": winner,
"leaderboard": leaderboard,
}
)
@require_POST
@@ -773,20 +813,20 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code="session_not_found", status=404)
return JsonResponse({"error": "Session not found"}, status=404)
if session.host_id != request.user.id:
return api_error(request, code="host_only_calculate_scores", status=403)
return JsonResponse({"error": "Only host can calculate scores"}, status=403)
already_calculated = ScoreEvent.objects.filter(
session=session,
meta__round_question_id=round_question_id,
).exists()
if already_calculated:
return api_error(request, code="scores_already_calculated", status=409)
return JsonResponse({"error": "Scores already calculated for this round question"}, status=409)
if session.status != GameSession.Status.GUESS:
return api_error(request, code="calculate_scores_invalid_phase", status=400)
return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400)
try:
round_question = RoundQuestion.objects.get(
@@ -795,16 +835,16 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return api_error(request, code="round_question_not_found", status=404)
return JsonResponse({"error": "Round question not found"}, status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return api_error(request, code="round_config_missing", status=400)
return JsonResponse({"error": "Round config missing"}, status=400)
guesses = list(round_question.guesses.select_related("player"))
if not guesses:
return api_error(request, code="no_guesses_submitted", status=400)
return JsonResponse({"error": "No guesses submitted for this round question"}, status=400)
bluff_counts = {}
for guess in guesses:
@@ -814,7 +854,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.GUESS:
return api_error(request, code="calculate_scores_invalid_phase", status=400)
return JsonResponse({"error": "Scores can only be calculated in guess phase"}, status=400)
score_events = []
@@ -858,21 +898,6 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
.values("id", "nickname", "score")
)
score_deltas = [
{"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason}
for ev in score_events
]
sync_broadcast_phase_event(
session.code,
"phase.scores_calculated",
{
"round_question_id": round_question.id,
"score_deltas": score_deltas,
"leaderboard": list(leaderboard),
},
)
return JsonResponse(
{
"session": {
@@ -884,7 +909,6 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
"id": round_question.id,
"round_number": round_question.round_number,
},
"reveal": _build_reveal_payload(round_question),
"events_created": len(score_events),
"leaderboard": leaderboard,
}

View File

@@ -1,15 +1,11 @@
import os
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.routing import ProtocolTypeRouter
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'partyhub.settings')
django_asgi_app = get_asgi_application()
from realtime.routing import websocket_urlpatterns # noqa: E402 — must come after env setup
application = ProtocolTypeRouter({
'http': django_asgi_app,
'websocket': URLRouter(websocket_urlpatterns),
})

View File

@@ -1,30 +0,0 @@
import json
from functools import lru_cache
from pathlib import Path
_LOCALE_LABELS = {
"en": "English",
"da": "Danish",
}
@lru_cache(maxsize=1)
def shared_i18n_catalog() -> dict:
catalog_path = Path(__file__).resolve().parents[1] / "shared" / "i18n" / "lobby.json"
with catalog_path.open(encoding="utf-8") as handle:
return json.load(handle)
@lru_cache(maxsize=1)
def locale_config() -> tuple[str, tuple[str, ...]]:
locales = shared_i18n_catalog().get("locales", {})
default_locale = str(locales.get("default", "en")).strip().lower() or "en"
supported_locales = tuple(
locale.strip().lower() for locale in locales.get("supported", ["en", "da"]) if str(locale).strip()
) or ("en", "da")
return default_locale, supported_locales
def django_languages() -> list[tuple[str, str]]:
_default_locale, supported_locales = locale_config()
return [(locale, _LOCALE_LABELS.get(locale, locale.upper())) for locale in supported_locales]

View File

@@ -1,8 +1,6 @@
from pathlib import Path
import os
from partyhub.i18n_bootstrap import django_languages, locale_config
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -92,8 +90,11 @@ AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE, _SUPPORTED_LOCALES = locale_config()
LANGUAGES = django_languages()
LANGUAGE_CODE = 'en'
LANGUAGES = [
('en', 'English'),
('da', 'Danish'),
]
LOCALE_PATHS = [BASE_DIR / 'locale']
TIME_ZONE = 'Europe/Copenhagen'
USE_I18N = True
@@ -116,13 +117,8 @@ WPP_SPA_ASSET_VERSION = env('WPP_SPA_ASSET_VERSION', 'dev')
CHANNEL_REDIS_HOST = env('CHANNEL_REDIS_HOST', '127.0.0.1')
CHANNEL_REDIS_PORT = int(env('CHANNEL_REDIS_PORT', '6379'))
import sys # noqa: E402
_testing = 'test' in sys.argv
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels.layers.InMemoryChannelLayer',
} if _testing else {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {'hosts': [(CHANNEL_REDIS_HOST, CHANNEL_REDIS_PORT)]},
}

View File

@@ -1,28 +0,0 @@
from django.conf import settings
from django.test import TestCase
from partyhub.i18n_bootstrap import django_languages, locale_config, shared_i18n_catalog
class I18nBootstrapTests(TestCase):
def test_shared_catalog_bootstraps_default_and_supported_locales(self):
default_locale, supported_locales = locale_config()
self.assertEqual(default_locale, "en")
self.assertEqual(supported_locales, ("en", "da"))
def test_django_language_settings_bootstrap_from_shared_i18n_catalog(self):
default_locale, supported_locales = locale_config()
self.assertEqual(settings.LANGUAGE_CODE, default_locale)
self.assertEqual(tuple(code for code, _ in settings.LANGUAGES), supported_locales)
self.assertEqual(settings.LANGUAGES, django_languages())
def test_locale_contract_in_catalog_matches_django_language_codes(self):
catalog_locales = shared_i18n_catalog()["locales"]
self.assertEqual(settings.LANGUAGE_CODE, catalog_locales["default"])
self.assertEqual(
[code for code, _ in settings.LANGUAGES],
catalog_locales["supported"],
)

View File

@@ -1,35 +0,0 @@
from asgiref.sync import async_to_sync
from channels.exceptions import InvalidChannelLayerError
from channels.layers import get_channel_layer
try:
from redis.exceptions import ConnectionError as RedisConnectionError
except Exception: # pragma: no cover - optional dependency in local/test runtimes
RedisConnectionError = RuntimeError
async def broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None:
"""Send a phase event to all WebSocket clients connected to a game session."""
try:
channel_layer = get_channel_layer()
if channel_layer is None:
return
group_name = f"game_{session_code.upper()}"
await channel_layer.group_send(
group_name,
{
"type": "phase.event",
"event_type": event_type,
"payload": payload,
},
)
except (InvalidChannelLayerError, RedisConnectionError):
return
def sync_broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None:
"""Sync wrapper for calling broadcast_phase_event from synchronous Django views."""
try:
async_to_sync(broadcast_phase_event)(session_code, event_type, payload)
except (InvalidChannelLayerError, RedisConnectionError):
return

View File

@@ -1,63 +0,0 @@
import json
from urllib.parse import parse_qs
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from fupogfakta.models import Player
class GameConsumer(AsyncJsonWebsocketConsumer):
"""
WebSocket consumer for a game session.
URL: ws/game/<session_code>/
Query params:
- session_token: player session token (players only)
- role=host: skip token check for host in MVP
"""
async def connect(self):
self.session_code = self.scope["url_route"]["kwargs"]["session_code"].upper()
self.group_name = f"game_{self.session_code}"
query_string = self.scope.get("query_string", b"").decode()
params = parse_qs(query_string)
role = params.get("role", [None])[0]
session_token = params.get("session_token", [None])[0]
if role != "host":
if not session_token:
await self.close(code=4001)
return
try:
self.player = await Player.objects.aget(
session_token=session_token,
session__code=self.session_code,
)
except Player.DoesNotExist:
await self.close(code=4003)
return
else:
self.player = None
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
async def disconnect(self, close_code):
if hasattr(self, "group_name"):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
async def receive_json(self, content, **kwargs):
if content.get("type") == "ping":
await self.send_json({"type": "pong"})
# --- Group message handlers ---
async def phase_event(self, event):
"""Forward any phase_event broadcast to the WebSocket client."""
payload = dict(event.get("payload") or {})
payload["type"] = event["event_type"]
await self.send_json(payload)

View File

@@ -1,7 +0,0 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r"^ws/game/(?P<session_code>[A-Z0-9]{4,8})/$", consumers.GameConsumer.as_asgi()),
]

View File

@@ -1,123 +1,3 @@
import unittest
from unittest.mock import AsyncMock, Mock, patch
from django.test import TestCase
from channels.exceptions import InvalidChannelLayerError
from django.contrib.auth import get_user_model
from django.test import SimpleTestCase, TestCase
try:
from channels.testing import WebsocketCommunicator
except Exception: # pragma: no cover - optional test dependency
WebsocketCommunicator = None
from fupogfakta.models import GameSession, Player
from partyhub.asgi import application
from realtime.broadcast import broadcast_phase_event, sync_broadcast_phase_event
from realtime.consumers import GameConsumer
User = get_user_model()
class BroadcastPhaseEventTests(SimpleTestCase):
@patch("realtime.broadcast.get_channel_layer", return_value=None)
async def test_broadcast_phase_event_noops_without_channel_layer(self, _mock_get_channel_layer):
await broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"})
@patch("realtime.broadcast.async_to_sync")
def test_sync_broadcast_phase_event_noops_when_channel_layer_is_unavailable(self, mock_async_to_sync):
mock_async_to_sync.return_value.side_effect = InvalidChannelLayerError("missing channel layer")
sync_broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"})
@patch("realtime.broadcast.async_to_sync")
def test_sync_broadcast_phase_event_still_broadcasts_when_channel_layer_exists(self, mock_async_to_sync):
sender = Mock()
mock_async_to_sync.return_value = sender
sync_broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"})
sender.assert_called_once_with("ABCD", "phase.scoreboard", {"phase": "scoreboard"})
class GameConsumerPhaseEventTests(SimpleTestCase):
async def test_phase_event_restores_external_type_field(self):
consumer = GameConsumer()
consumer.send_json = AsyncMock()
await consumer.phase_event(
{
"event_type": "phase.test_event",
"payload": {"hello": "world"},
}
)
consumer.send_json.assert_awaited_once_with(
{
"type": "phase.test_event",
"hello": "world",
}
)
@unittest.skipIf(WebsocketCommunicator is None, "channels.testing dependencies unavailable")
class GameConsumerConnectTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="host", password="pw")
self.session = GameSession.objects.create(host=self.user, code="AABBCC")
self.player = Player.objects.create(session=self.session, nickname="Tester")
async def test_player_connect_and_ping(self):
token = self.player.session_token
communicator = WebsocketCommunicator(
application,
f"/ws/game/AABBCC/?session_token={token}",
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
await communicator.send_json_to({"type": "ping"})
response = await communicator.receive_json_from()
self.assertEqual(response["type"], "pong")
await communicator.disconnect()
async def test_connect_without_token_rejected(self):
communicator = WebsocketCommunicator(application, "/ws/game/AABBCC/")
connected, code = await communicator.connect()
self.assertFalse(connected)
self.assertEqual(code, 4001)
async def test_connect_invalid_token_rejected(self):
communicator = WebsocketCommunicator(
application,
"/ws/game/AABBCC/?session_token=invalid-token",
)
connected, code = await communicator.connect()
self.assertFalse(connected)
self.assertEqual(code, 4003)
async def test_host_connect_without_token(self):
communicator = WebsocketCommunicator(
application,
"/ws/game/AABBCC/?role=host",
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
await communicator.disconnect()
async def test_broadcast_reaches_connected_client(self):
token = self.player.session_token
communicator = WebsocketCommunicator(
application,
f"/ws/game/AABBCC/?session_token={token}",
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
await broadcast_phase_event("AABBCC", "phase.test_event", {"hello": "world"})
message = await communicator.receive_json_from(timeout=2)
self.assertEqual(message["type"], "phase.test_event")
self.assertEqual(message["hello"], "world")
await communicator.disconnect()
# Create your tests here.

View File

@@ -1,6 +1,5 @@
Django==6.0.2
channels>=4.1,<5
channels-redis>=4.2,<5
daphne>=4.1,<5
mysqlclient>=2.2,<3
python-dotenv>=1.0,<2

View File

@@ -1,214 +0,0 @@
#!/usr/bin/env python3
"""Build the shared i18n parity artifact for MVP-critical Django/Angular keys."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
from collections import defaultdict
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json"
ARTIFACT_PATH = REPO_ROOT / "shared" / "i18n" / "artifacts" / "lobby-mvp-keyspace-parity-report.v1.json"
DJANGO_VIEWS_PATH = REPO_ROOT / "lobby" / "views.py"
FRONTEND_VERTICAL_SLICE_PATH = REPO_ROOT / "frontend" / "src" / "spa" / "vertical-slice.ts"
ANGULAR_I18N_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "lobby-i18n.ts"
ANGULAR_HOST_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "features" / "host" / "host-shell.component.ts"
ANGULAR_PLAYER_PATH = REPO_ROOT / "frontend" / "angular" / "src" / "app" / "features" / "player" / "player-shell.component.ts"
ARTIFACT_NAME = "shared.i18n.lobby.mvp_keyspace_parity_report"
ARTIFACT_VERSION = "v1"
class ParityError(RuntimeError):
pass
def _read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _load_catalog() -> dict[str, Any]:
return json.loads(_read_text(CATALOG_PATH))
def _catalog_hash() -> str:
return hashlib.sha256(CATALOG_PATH.read_bytes()).hexdigest()
def _sorted_unique(values: list[str]) -> list[str]:
return sorted({value for value in values})
def _extract_matches(path: Path, pattern: str) -> list[str]:
return re.findall(pattern, _read_text(path), re.MULTILINE)
def _resolve_frontend_ui_key(key: str) -> str:
if key.startswith("lobby.shell."):
return key.replace("lobby.shell.", "app.", 1)
if key.startswith("game.host."):
return key.replace("game.host.", "host.", 1)
if key.startswith("game.player."):
return key.replace("game.player.", "player.", 1)
return key
def _has_nested_key(root: dict[str, Any], dotted_key: str) -> bool:
current: Any = root
for part in dotted_key.split("."):
if not isinstance(current, dict) or part not in current:
return False
current = current[part]
return isinstance(current, dict)
def build_report() -> dict[str, Any]:
catalog = _load_catalog()
locales = list(catalog["locales"]["supported"])
frontend_errors: dict[str, Any] = catalog["frontend"]["errors"]
frontend_ui: dict[str, Any] = catalog["frontend"]["ui"]
backend_error_codes: dict[str, str] = catalog["backend"]["error_codes"]
backend_errors: dict[str, Any] = catalog["backend"]["errors"]
backend_to_frontend: dict[str, str] = catalog["contract"]["backend_to_frontend_error_keys"]
django_error_codes = _sorted_unique(
_extract_matches(DJANGO_VIEWS_PATH, r'ERROR_CODES\.get\("([a-z0-9_]+)"')
)
frontend_runtime_error_fallbacks = _sorted_unique(
_extract_matches(FRONTEND_VERTICAL_SLICE_PATH, r"lobbyMessageFromApiPayload\([^\n]+?'([a-z0-9_]+)'\)")
+ _extract_matches(FRONTEND_VERTICAL_SLICE_PATH, r"lobbyMessage\('([a-z0-9_]+)'\)")
)
angular_copy_keys = _sorted_unique(
_extract_matches(ANGULAR_HOST_PATH, r"copy\('([A-Za-z0-9_\.]+)'\)")
+ _extract_matches(ANGULAR_PLAYER_PATH, r"copy\('([A-Za-z0-9_\.]+)'\)")
)
angular_catalog_paths = [_resolve_frontend_ui_key(key) for key in angular_copy_keys]
missing_backend_codes = [code for code in django_error_codes if code not in backend_error_codes]
missing_backend_translations = [code for code in django_error_codes if code not in backend_errors]
missing_contract_mappings = [code for code in django_error_codes if code not in backend_to_frontend]
mapped_frontend_error_keys = _sorted_unique(
[backend_to_frontend[code] for code in django_error_codes if code in backend_to_frontend]
)
missing_frontend_error_keys = [key for key in mapped_frontend_error_keys if key not in frontend_errors]
missing_frontend_runtime_fallbacks = [key for key in frontend_runtime_error_fallbacks if key not in frontend_errors]
missing_angular_catalog_paths = [path for path in angular_catalog_paths if not _has_nested_key(frontend_ui, path)]
dead_contract_aliases = _sorted_unique([code for code in backend_to_frontend if code not in backend_error_codes])
many_to_one_mappings: dict[str, list[str]] = defaultdict(list)
for code in django_error_codes:
frontend_key = backend_to_frontend.get(code)
if frontend_key:
many_to_one_mappings[frontend_key].append(code)
many_to_one_mappings = {
frontend_key: sorted(codes)
for frontend_key, codes in sorted(many_to_one_mappings.items())
if len(codes) > 1
}
blocking_issues = {
"missing_backend_codes": missing_backend_codes,
"missing_backend_translations": missing_backend_translations,
"missing_contract_mappings": missing_contract_mappings,
"missing_frontend_error_keys": missing_frontend_error_keys,
"missing_frontend_runtime_fallbacks": missing_frontend_runtime_fallbacks,
"missing_angular_catalog_paths": missing_angular_catalog_paths,
}
status = "pass" if not any(blocking_issues.values()) else "fail"
follow_ups: list[dict[str, str]] = []
if dead_contract_aliases:
follow_ups.append(
{
"priority": "need-to-have",
"item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.",
"evidence": ", ".join(dead_contract_aliases),
}
)
if many_to_one_mappings:
follow_ups.append(
{
"priority": "nice-to-have",
"item": "Decide whether grouped backend codes should keep collapsing into one Angular fallback key or be split into more specific frontend error copy as UX matures.",
"evidence": "; ".join(
f"{frontend_key} <= {', '.join(codes)}" for frontend_key, codes in many_to_one_mappings.items()
),
}
)
return {
"artifact_name": ARTIFACT_NAME,
"artifact_version": ARTIFACT_VERSION,
"naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.",
"source_of_truth": {
"catalog": str(CATALOG_PATH.relative_to(REPO_ROOT)),
"catalog_sha256": _catalog_hash(),
"source_paths": [
str(path.relative_to(REPO_ROOT))
for path in [
DJANGO_VIEWS_PATH,
FRONTEND_VERTICAL_SLICE_PATH,
ANGULAR_I18N_PATH,
ANGULAR_HOST_PATH,
ANGULAR_PLAYER_PATH,
]
],
},
"scope": {
"issue": 277,
"related_epic": 175,
"mvp_locales": locales,
"definition": "MVP-critical keys are the Django error codes emitted by lobby/views.py plus the Angular fallback/UI keys consumed by the host/player MVP shells.",
},
"parity": {
"status": status,
"django_backend_error_codes_used_by_mvp": django_error_codes,
"angular_frontend_error_fallback_keys_used_by_mvp": frontend_runtime_error_fallbacks,
"angular_ui_keys_used_by_mvp": angular_copy_keys,
"angular_ui_catalog_paths": angular_catalog_paths,
"backend_codes_mapped_to_frontend_error_keys": {code: backend_to_frontend[code] for code in django_error_codes if code in backend_to_frontend},
"unique_frontend_error_keys_reached_from_django": mapped_frontend_error_keys,
"blocking_issues": blocking_issues,
"follow_ups": follow_ups,
},
}
def write_report() -> None:
report = build_report()
ARTIFACT_PATH.parent.mkdir(parents=True, exist_ok=True)
ARTIFACT_PATH.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
def check_report() -> None:
report = build_report()
if not ARTIFACT_PATH.exists():
raise ParityError(f"artifact missing: {ARTIFACT_PATH}")
existing = json.loads(_read_text(ARTIFACT_PATH))
if existing != report:
raise ParityError("artifact out of date; run scripts/build_i18n_parity_report.py --write")
if report["parity"]["status"] != "pass":
raise ParityError(json.dumps(report["parity"]["blocking_issues"], indent=2))
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--write", action="store_true")
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
if args.write:
write_report()
if args.check:
check_report()
if not args.write and not args.check:
print(json.dumps(build_report(), indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,36 +0,0 @@
#!/usr/bin/env python3
"""Guard issue #277 parity artifact against non-deterministic regeneration."""
from __future__ import annotations
import hashlib
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
ARTIFACT_PATH = REPO_ROOT / "docs" / "ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md"
REPORT_SCRIPT = REPO_ROOT / "scripts" / "report_i18n_parity.py"
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def main() -> int:
before = sha256(ARTIFACT_PATH)
for run in range(1, 3):
subprocess.run([sys.executable, str(REPORT_SCRIPT)], cwd=REPO_ROOT, check=True)
after = sha256(ARTIFACT_PATH)
if after != before:
raise SystemExit(
f"issue #277 parity artifact is not deterministic after run {run}: {before} != {after}"
)
print(f"issue #277 parity artifact deterministic: {before}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,196 +0,0 @@
#!/usr/bin/env python3
"""Generate issue #277 shared i18n parity artifact.
Read-only report over the shared lobby i18n catalog, with focus on MVP-critical
backend/frontend parity used by Django and Angular.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json"
OUTPUT_PATH = REPO_ROOT / "docs" / "ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md"
ARTIFACT_ID = "issue-277-shared-i18n-parity-report"
ARTIFACT_VERSION = "1.0"
MVP_FRONTEND_UI_KEYS = [
"frontend.ui.host.title",
"frontend.ui.player.title",
"frontend.ui.common.session_code",
"frontend.ui.player.nickname",
"frontend.ui.player.join",
"frontend.ui.host.start_round",
"frontend.ui.host.show_question",
"frontend.ui.player.lie_label",
"frontend.ui.player.submit_lie",
"frontend.ui.player.submit_guess",
"frontend.ui.host.mix_answers",
"frontend.ui.host.calculate_scores",
"frontend.ui.host.load_scoreboard",
"frontend.ui.host.final_leaderboard",
"frontend.ui.player.final_leaderboard",
"frontend.ui.common.points_short",
]
MVP_FRONTEND_ERROR_KEYS = [
"frontend.errors.session_code_required",
"frontend.errors.session_not_found",
"frontend.errors.nickname_invalid",
"frontend.errors.nickname_taken",
"frontend.errors.join_failed",
"frontend.errors.start_round_failed",
"frontend.errors.unknown",
]
MVP_BACKEND_CODES = [
"backend.error_codes.session_code_required",
"backend.error_codes.nickname_invalid",
"backend.error_codes.session_not_found",
"backend.error_codes.session_not_joinable",
"backend.error_codes.nickname_taken",
"backend.error_codes.category_slug_required",
"backend.error_codes.category_not_found",
"backend.error_codes.round_start_invalid_phase",
"backend.error_codes.round_already_configured",
]
@dataclass(frozen=True)
class MappingRow:
backend_code: str
backend_key: str
frontend_key: str
locales_ok: bool
parity_status: str
note: str
def load_catalog() -> dict:
return json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
def get_path(data: dict, dotted: str):
node = data
for part in dotted.split("."):
node = node[part]
return node
def translation_state(data: dict, dotted: str) -> tuple[bool, list[str]]:
translations = get_path(data, dotted)
missing = [locale for locale in ("en", "da") if not isinstance(translations.get(locale), str) or not translations[locale].strip()]
return (not missing, missing)
def build_mapping_rows(catalog: dict) -> list[MappingRow]:
rows: list[MappingRow] = []
mapping = catalog["contract"]["backend_to_frontend_error_keys"]
backend_errors = catalog["backend"]["errors"]
for dotted_code in MVP_BACKEND_CODES:
code = dotted_code.removeprefix("backend.error_codes.")
backend_key = catalog["backend"]["error_codes"][code]
frontend_key = mapping[code]
backend_locales_ok, _ = translation_state(catalog, f"backend.errors.{backend_key}")
frontend_locales_ok, _ = translation_state(catalog, f"frontend.errors.{frontend_key}")
note = "1:1" if code == frontend_key else "many:1 collapse" if frontend_key == "start_round_failed" else "mapped alias"
rows.append(
MappingRow(
backend_code=code,
backend_key=backend_key,
frontend_key=frontend_key,
locales_ok=backend_locales_ok and frontend_locales_ok,
parity_status="aligned" if frontend_key in backend_errors or code == frontend_key else "mapped",
note=note,
)
)
return rows
def render_report(catalog: dict) -> str:
mapping_rows = build_mapping_rows(catalog)
frontend_ui_ok = all(translation_state(catalog, key)[0] for key in MVP_FRONTEND_UI_KEYS)
frontend_error_ok = all(translation_state(catalog, key)[0] for key in MVP_FRONTEND_ERROR_KEYS)
backend_code_ok = all(translation_state(catalog, f"backend.errors.{catalog['backend']['error_codes'][key.removeprefix('backend.error_codes.')]}" )[0] for key in MVP_BACKEND_CODES)
mapped_frontend_keys = sorted({row.frontend_key for row in mapping_rows})
collapsed_codes = [row.backend_code for row in mapping_rows if row.frontend_key == "start_round_failed"]
lines: list[str] = []
lines.append("# ISSUE-277 Artifact — shared i18n registry parity report (Django ↔ Angular MVP)")
lines.append("")
lines.append("Issue: **#277** (`[READY][#175][P3] Shared i18n registry artifact: backend/frontend keyspace parity report`)")
lines.append("")
lines.append("## Artifact metadata")
lines.append("")
lines.append(f"- `artifact_id`: `{ARTIFACT_ID}`")
lines.append(f"- `artifact_version`: `{ARTIFACT_VERSION}`")
lines.append(f"- `catalog_source`: `{CATALOG_PATH.relative_to(REPO_ROOT)}`")
lines.append(f"- `generator`: `scripts/{Path(__file__).name}`")
lines.append("")
lines.append("## Naming/version rules (email-manager-inspired strategy)")
lines.append("")
lines.append("- **Single canonical artifact per issue**: issue-bundne rapporter navngives `docs/ISSUE-<nr>-<slug>-ARTIFACT.md`.")
lines.append("- **Stable artifact identity**: `artifact_id` ændres ikke ved tekstlige opdateringer i samme rapporttype; det er den faste reference i review/ops.")
lines.append("- **Explicit artifact versioning**: `artifact_version` bumpes, når rapportlogik eller scope ændres, så drift/review kan se forskel på format- vs. dataændringer.")
lines.append("- **Shared namespace first**: keys refereres med fulde navnerum (`frontend.ui.*`, `frontend.errors.*`, `backend.error_codes.*`, `backend.errors.*`) i stedet for lokale aliases i artefakter.")
lines.append("- **Source-of-truth before consumers**: rapporten afledes fra `shared/i18n/lobby.json`; Django/Angular beskrives som consumers af samme registry og ikke som parallelle kontrakter.")
lines.append("")
lines.append("## MVP-critical parity summary")
lines.append("")
lines.append(f"- Frontend UI gameplay keys checked: **{len(MVP_FRONTEND_UI_KEYS)}** → `{'OK' if frontend_ui_ok else 'DRIFT'}`")
lines.append(f"- Frontend error keys checked: **{len(MVP_FRONTEND_ERROR_KEYS)}** → `{'OK' if frontend_error_ok else 'DRIFT'}`")
lines.append(f"- Backend gameplay/error codes checked: **{len(MVP_BACKEND_CODES)}** → `{'OK' if backend_code_ok else 'DRIFT'}`")
lines.append(f"- Distinct frontend error keys reached from backend MVP flow: **{len(mapped_frontend_keys)}** (`{', '.join(mapped_frontend_keys)}`)")
lines.append("")
lines.append("Status: **Shared locale matrix is aligned (`en`, `da`) and backend→frontend error handling is contract-complete for MVP-critical flow.**")
lines.append("")
lines.append("## Django ↔ Angular parity matrix (MVP-critical error contract)")
lines.append("")
lines.append("| Backend code (`backend.error_codes.*`) | Django message key (`backend.errors.*`) | Angular key (`frontend.errors.*`) | Locales `en/da` | Parity note |")
lines.append("|---|---|---|---|---|")
for row in mapping_rows:
lines.append(
f"| `{row.backend_code}` | `{row.backend_key}` | `{row.frontend_key}` | `{'OK' if row.locales_ok else 'DRIFT'}` | {row.note} |"
)
lines.append("")
lines.append("## Scope notes")
lines.append("")
lines.append("- **Django** consumes backend codes/messages directly from `shared/i18n/lobby.json` via `lobby/i18n.py`.")
lines.append("- **Angular** consumes the same registry via `frontend/shared/i18n/lobby-loader.ts` and runtime helpers in `frontend/angular/src/app/lobby-i18n.ts`.")
lines.append("- **Parity in MVP** is therefore strongest on the shared error contract and locale matrix; gameplay UI labels are frontend-owned but still live in the same registry.")
lines.append("")
lines.append("## Verified MVP gameplay UI keyspace present in the shared registry")
lines.append("")
for key in MVP_FRONTEND_UI_KEYS:
lines.append(f"- `{key}`")
lines.append("")
lines.append("## Concrete deviations / follow-up items")
lines.append("")
lines.append(f"1. **Error granularity collapse remains intentional**: backend codes `{', '.join(collapsed_codes)}` all map to `frontend.errors.start_round_failed`. Follow-up only if product wants case-specific Angular copy instead of one shared host failure message.")
lines.append("2. **Frontend-only fallback copy is not mirrored in Django**: `frontend.errors.unknown` and `frontend.errors.session_fetch_failed` are Angular-side resilience keys, not backend contract keys. Follow-up if API responses should expose stable backend equivalents for these states.")
lines.append("3. **Gameplay UI labels are registry-shared but not backend-rendered**: `frontend.ui.host.*`, `frontend.ui.player.*`, and `frontend.ui.common.*` are available in the shared artifact, but Django currently consumes only the backend error slice. Follow-up only if server-rendered views must guarantee the same UI label surface as Angular.")
lines.append("")
lines.append("## Re-run")
lines.append("")
lines.append("```bash")
lines.append("python3 scripts/check_i18n_drift.py")
lines.append("python3 scripts/report_i18n_parity.py")
lines.append("python3 scripts/check_i18n_parity_artifact.py")
lines.append("```")
lines.append("")
return "\n".join(lines)
def main() -> int:
catalog = load_catalog()
OUTPUT_PATH.write_text(render_report(catalog), encoding="utf-8")
print(OUTPUT_PATH.relative_to(REPO_ROOT))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,158 +0,0 @@
{
"artifact_name": "shared.i18n.lobby.mvp_keyspace_parity_report",
"artifact_version": "v1",
"naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.",
"source_of_truth": {
"catalog": "shared/i18n/lobby.json",
"catalog_sha256": "d9f7227bddd007f2c56f33dfd0015bcffb3b60c52dc756126a02b7e4de638adb",
"source_paths": [
"lobby/views.py",
"frontend/src/spa/vertical-slice.ts",
"frontend/angular/src/app/lobby-i18n.ts",
"frontend/angular/src/app/features/host/host-shell.component.ts",
"frontend/angular/src/app/features/player/player-shell.component.ts"
]
},
"scope": {
"issue": 277,
"related_epic": 175,
"mvp_locales": [
"en",
"da"
],
"definition": "MVP-critical keys are the Django error codes emitted by lobby/views.py plus the Angular fallback/UI keys consumed by the host/player MVP shells."
},
"parity": {
"status": "pass",
"django_backend_error_codes_used_by_mvp": [],
"angular_frontend_error_fallback_keys_used_by_mvp": [
"join_failed",
"session_code_required",
"session_fetch_failed",
"start_round_failed"
],
"angular_ui_keys_used_by_mvp": [
"common.back_to_join",
"common.points_short",
"common.prompt",
"common.refresh",
"common.retry",
"common.round",
"common.round_question_id",
"common.session_code",
"common.status",
"common.unknown_error",
"host.audio_locale_hint",
"host.calculate_scores",
"host.category",
"host.final_leaderboard",
"host.finish_game",
"host.finish_game_failed",
"host.load_scoreboard",
"host.mix_answers",
"host.next_round_failed",
"host.retry_finish",
"host.retry_next_round",
"host.retry_scoreboard",
"host.scoreboard_failed",
"host.session_code_required",
"host.session_refresh_failed",
"host.show_question",
"host.start_next_round",
"host.start_round",
"host.title",
"host.winner",
"player.audio_policy_notice",
"player.final_leaderboard",
"player.guess_submit_failed",
"player.join",
"player.join_failed",
"player.lie_label",
"player.lie_submit_failed",
"player.loading_join",
"player.loading_refresh",
"player.loading_submit_guess",
"player.loading_submit_lie",
"player.nickname",
"player.offline_text",
"player.reconnecting_text",
"player.retry_guess_submit",
"player.retry_lie_submit",
"player.retry_now",
"player.session_refresh_failed",
"player.submit_guess",
"player.submit_lie",
"player.title"
],
"angular_ui_catalog_paths": [
"common.back_to_join",
"common.points_short",
"common.prompt",
"common.refresh",
"common.retry",
"common.round",
"common.round_question_id",
"common.session_code",
"common.status",
"common.unknown_error",
"host.audio_locale_hint",
"host.calculate_scores",
"host.category",
"host.final_leaderboard",
"host.finish_game",
"host.finish_game_failed",
"host.load_scoreboard",
"host.mix_answers",
"host.next_round_failed",
"host.retry_finish",
"host.retry_next_round",
"host.retry_scoreboard",
"host.scoreboard_failed",
"host.session_code_required",
"host.session_refresh_failed",
"host.show_question",
"host.start_next_round",
"host.start_round",
"host.title",
"host.winner",
"player.audio_policy_notice",
"player.final_leaderboard",
"player.guess_submit_failed",
"player.join",
"player.join_failed",
"player.lie_label",
"player.lie_submit_failed",
"player.loading_join",
"player.loading_refresh",
"player.loading_submit_guess",
"player.loading_submit_lie",
"player.nickname",
"player.offline_text",
"player.reconnecting_text",
"player.retry_guess_submit",
"player.retry_lie_submit",
"player.retry_now",
"player.session_refresh_failed",
"player.submit_guess",
"player.submit_lie",
"player.title"
],
"backend_codes_mapped_to_frontend_error_keys": {},
"unique_frontend_error_keys_reached_from_django": [],
"blocking_issues": {
"missing_backend_codes": [],
"missing_backend_translations": [],
"missing_contract_mappings": [],
"missing_frontend_error_keys": [],
"missing_frontend_runtime_fallbacks": [],
"missing_angular_catalog_paths": []
},
"follow_ups": [
{
"priority": "need-to-have",
"item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.",
"evidence": "host_only_action"
}
]
}
}

View File

@@ -1,8 +1,5 @@
{
"locales": [
"en",
"da"
],
"locales": ["en", "da"],
"frontend_error_keys": [
"join_failed",
"nickname_invalid",
@@ -14,96 +11,50 @@
"unknown"
],
"backend_error_codes": [
"calculate_scores_invalid_phase",
"category_has_no_questions",
"category_not_found",
"category_slug_required",
"finish_game_invalid_phase",
"guess_already_submitted",
"guess_submission_closed",
"guess_submission_invalid_phase",
"host_only_calculate_scores",
"host_only_finish_game",
"host_only_mix_answers",
"host_only_show_question",
"host_only_start_next_round",
"host_only_start_round",
"host_only_view_scoreboard",
"invalid_player_session_token",
"lie_already_submitted",
"lie_submission_closed",
"lie_submission_invalid_phase",
"lie_text_invalid",
"mix_answers_invalid_phase",
"next_round_invalid_phase",
"nickname_invalid",
"nickname_taken",
"no_available_questions",
"no_guesses_submitted",
"not_enough_answers_to_mix",
"player_id_required",
"player_not_found_in_session",
"question_already_shown",
"round_already_configured",
"round_config_missing",
"round_question_not_found",
"round_start_invalid_phase",
"scoreboard_invalid_phase",
"scores_already_calculated",
"selected_answer_invalid",
"selected_text_invalid",
"session_code_required",
"session_not_found",
"session_not_joinable",
"session_token_required",
"show_question_invalid_phase"
],
"allowed_contract_only_backend_codes": [
"host_only_action"
],
"backend_error_keys": [
"calculate_scores_invalid_phase",
"category_has_no_questions",
"category_not_found",
"category_slug_required",
"finish_game_invalid_phase",
"guess_already_submitted",
"guess_submission_closed",
"guess_submission_invalid_phase",
"host_only_calculate_scores",
"host_only_finish_game",
"host_only_mix_answers",
"host_only_show_question",
"host_only_start_next_round",
"host_only_start_round",
"host_only_view_scoreboard",
"invalid_player_session_token",
"lie_already_submitted",
"lie_submission_closed",
"lie_submission_invalid_phase",
"lie_text_invalid",
"mix_answers_invalid_phase",
"next_round_invalid_phase",
"nickname_invalid",
"nickname_taken",
"no_available_questions",
"no_guesses_submitted",
"not_enough_answers_to_mix",
"player_id_required",
"player_not_found_in_session",
"question_already_shown",
"round_already_configured",
"round_config_missing",
"round_question_not_found",
"round_start_invalid_phase",
"scoreboard_invalid_phase",
"scores_already_calculated",
"selected_answer_invalid",
"selected_text_invalid",
"session_code_required",
"session_not_found",
"session_not_joinable",
"session_token_required",
"show_question_invalid_phase"
]
}

View File

@@ -264,10 +264,6 @@
"guess_submit_failed": {
"en": "Guess submit failed",
"da": "Gætte-fejl"
},
"audio_policy_notice": {
"en": "Audio playback is disabled on phone clients. Sound is available on the primary host device.",
"da": "Lydafspilning er slået fra på telefon-klienten. Lyd afspilles kun på den primære værtsenhed."
}
}
},
@@ -277,47 +273,16 @@
},
"backend": {
"error_codes": {
"calculate_scores_invalid_phase": "calculate_scores_invalid_phase",
"category_has_no_questions": "category_has_no_questions",
"category_not_found": "category_not_found",
"category_slug_required": "category_slug_required",
"finish_game_invalid_phase": "finish_game_invalid_phase",
"guess_already_submitted": "guess_already_submitted",
"guess_submission_invalid_phase": "guess_submission_invalid_phase",
"guess_submission_window_closed": "guess_submission_window_closed",
"host_only_calculate_scores": "host_only_calculate_scores",
"host_only_finish_game": "host_only_finish_game",
"host_only_mix_answers": "host_only_mix_answers",
"host_only_show_question": "host_only_show_question",
"host_only_start_next_round": "host_only_start_next_round",
"host_only_start_round": "host_only_start_round",
"host_only_view_scoreboard": "host_only_view_scoreboard",
"invalid_player_session_token": "invalid_player_session_token",
"lie_already_submitted": "lie_already_submitted",
"lie_submission_invalid_phase": "lie_submission_invalid_phase",
"lie_submission_window_closed": "lie_submission_window_closed",
"lie_text_invalid": "lie_text_invalid",
"mix_answers_invalid_phase": "mix_answers_invalid_phase",
"nickname_invalid": "nickname_invalid",
"nickname_taken": "nickname_taken",
"no_available_questions": "no_available_questions",
"no_guesses_submitted": "no_guesses_submitted",
"not_enough_answers_to_mix": "not_enough_answers_to_mix",
"player_id_required": "player_id_required",
"player_not_found_in_session": "player_not_found_in_session",
"question_already_shown": "question_already_shown",
"round_already_configured": "round_already_configured",
"round_config_missing": "round_config_missing",
"round_question_not_found": "round_question_not_found",
"round_start_invalid_phase": "round_start_invalid_phase",
"scoreboard_invalid_phase": "scoreboard_invalid_phase",
"scores_already_calculated": "scores_already_calculated",
"selected_answer_invalid": "selected_answer_invalid",
"selected_text_invalid": "selected_text_invalid",
"session_code_required": "session_code_required",
"nickname_invalid": "nickname_invalid",
"session_not_found": "session_not_found",
"session_not_joinable": "session_not_joinable",
"session_token_required": "session_token_required",
"nickname_taken": "nickname_taken",
"category_slug_required": "category_slug_required",
"category_not_found": "category_not_found",
"round_start_invalid_phase": "round_start_invalid_phase",
"round_already_configured": "round_already_configured",
"category_has_no_questions": "category_has_no_questions",
"show_question_invalid_phase": "show_question_invalid_phase",
"round_config_missing": "round_config_missing",
"question_already_shown": "question_already_shown",
@@ -327,184 +292,17 @@
"not_enough_answers_to_mix": "not_enough_answers_to_mix",
"host_only_start_round": "host_only_start_round",
"host_only_show_question": "host_only_show_question",
"host_only_mix_answers": "host_only_mix_answers",
"player_id_required": "player_id_required",
"session_token_required": "session_token_required",
"lie_text_invalid": "lie_text_invalid",
"player_not_found_in_session": "player_not_found_in_session",
"invalid_player_session_token": "invalid_player_session_token",
"lie_submission_closed": "lie_submission_closed",
"lie_already_submitted": "lie_already_submitted",
"host_only_view_scoreboard": "host_only_view_scoreboard",
"scoreboard_invalid_phase": "scoreboard_invalid_phase",
"host_only_start_next_round": "host_only_start_next_round",
"next_round_invalid_phase": "next_round_invalid_phase",
"host_only_finish_game": "host_only_finish_game",
"finish_game_invalid_phase": "finish_game_invalid_phase",
"host_only_calculate_scores": "host_only_calculate_scores",
"scores_already_calculated": "scores_already_calculated",
"calculate_scores_invalid_phase": "calculate_scores_invalid_phase",
"no_guesses_submitted": "no_guesses_submitted",
"guess_submission_closed": "guess_submission_closed",
"selected_answer_invalid": "selected_answer_invalid",
"guess_already_submitted": "guess_already_submitted",
"lie_submission_invalid_phase": "lie_submission_invalid_phase",
"selected_text_invalid": "selected_text_invalid",
"guess_submission_invalid_phase": "guess_submission_invalid_phase"
"host_only_mix_answers": "host_only_mix_answers"
},
"errors": {
"calculate_scores_invalid_phase": {
"en": "Scores can only be calculated in guess phase.",
"da": "Score kan kun udregnes i gættefasen."
},
"category_has_no_questions": {
"en": "Category has no active questions",
"da": "Kategorien har ingen aktive spørgsmål"
},
"category_not_found": {
"en": "Category not found",
"da": "Kategori blev ikke fundet"
},
"category_slug_required": {
"en": "category_slug is required",
"da": "category_slug er påkrævet"
},
"finish_game_invalid_phase": {
"en": "Game can only be finished from scoreboard phase.",
"da": "Spillet kan kun afsluttes fra scoreboard-fasen."
},
"guess_already_submitted": {
"en": "Guess has already been submitted for this player.",
"da": "Gættet er allerede indsendt for denne spiller."
},
"guess_submission_invalid_phase": {
"en": "Guess submission is only allowed in guess phase.",
"da": "Gæt kan kun sendes i gættefasen."
},
"guess_submission_window_closed": {
"en": "Guess submission window has closed.",
"da": "Vinduet for gætindsendelse er lukket."
},
"host_only_calculate_scores": {
"en": "Only the host can calculate scores.",
"da": "Kun værten kan udregne score."
},
"host_only_finish_game": {
"en": "Only the host can finish the game.",
"da": "Kun værten kan afslutte spillet."
},
"host_only_mix_answers": {
"en": "Only host can mix answers",
"da": "Kun værten kan blande svar"
},
"host_only_show_question": {
"en": "Only host can show question",
"da": "Kun værten kan vise spørgsmålet"
},
"host_only_start_next_round": {
"en": "Only the host can start the next round.",
"da": "Kun værten kan starte næste runde."
},
"host_only_start_round": {
"en": "Only host can start round",
"da": "Kun værten kan starte runden"
},
"host_only_view_scoreboard": {
"en": "Only the host can view the scoreboard.",
"da": "Kun værten kan se scoreboardet."
},
"invalid_player_session_token": {
"en": "Player session token is invalid.",
"da": "Spillerens session-token er ugyldigt."
},
"lie_already_submitted": {
"en": "Lie has already been submitted for this player.",
"da": "Løgnen er allerede indsendt for denne spiller."
},
"lie_submission_invalid_phase": {
"en": "Lie submission is only allowed in lie phase.",
"da": "Løgn kan kun sendes i løgnefasen."
},
"lie_submission_window_closed": {
"en": "Lie submission window has closed.",
"da": "Vinduet for løgnindsendelse er lukket."
},
"lie_text_invalid": {
"en": "Text must be between 1 and 255 characters.",
"da": "Tekst skal være mellem 1 og 255 tegn."
},
"mix_answers_invalid_phase": {
"en": "Answers can only be mixed in lie or guess phase",
"da": "Svar kan kun blandes i løgne- eller gættefasen"
"session_code_required": {
"en": "Session code is required",
"da": "Sessionskode er påkrævet"
},
"nickname_invalid": {
"en": "Nickname must be between 2 and 40 characters",
"da": "Kaldenavn skal være mellem 2 og 40 tegn"
},
"nickname_taken": {
"en": "Nickname already taken",
"da": "Kaldenavnet er allerede taget"
},
"no_available_questions": {
"en": "No available questions in category",
"da": "Ingen tilgængelige spørgsmål i kategorien"
},
"no_guesses_submitted": {
"en": "No guesses have been submitted for this round question.",
"da": "Der er ikke indsendt gæt for dette rundespørgsmål."
},
"not_enough_answers_to_mix": {
"en": "Not enough answers to mix",
"da": "Ikke nok svar at blande"
},
"player_id_required": {
"en": "Player id is required.",
"da": "Spiller-id er påkrævet."
},
"player_not_found_in_session": {
"en": "Player was not found in this session.",
"da": "Spilleren blev ikke fundet i denne session."
},
"question_already_shown": {
"en": "Question already shown for this round",
"da": "Spørgsmålet er allerede vist for denne runde"
},
"round_already_configured": {
"en": "Round already configured",
"da": "Runden er allerede konfigureret"
},
"round_config_missing": {
"en": "Round config missing",
"da": "Rundekonfiguration mangler"
},
"round_question_not_found": {
"en": "Round question not found",
"da": "Rundespørgsmål blev ikke fundet"
},
"round_start_invalid_phase": {
"en": "Round can only be started from lobby",
"da": "Runden kan kun startes fra lobbyen"
},
"scoreboard_invalid_phase": {
"en": "Scoreboard is only available in scoreboard phase.",
"da": "Scoreboard er kun tilgængeligt i scoreboard-fasen."
},
"scores_already_calculated": {
"en": "Scores have already been calculated for this round question.",
"da": "Score er allerede udregnet for dette rundespørgsmål."
},
"selected_answer_invalid": {
"en": "Selected answer is not part of this round.",
"da": "Det valgte svar er ikke en del af denne runde."
},
"selected_text_invalid": {
"en": "Selected text must be between 1 and 255 characters.",
"da": "Valgt tekst skal være mellem 1 og 255 tegn."
},
"session_code_required": {
"en": "Session code is required",
"da": "Sessionskode er påkrævet"
},
"session_not_found": {
"en": "Session not found",
"da": "Session blev ikke fundet"
@@ -513,9 +311,29 @@
"en": "Session is not joinable",
"da": "Sessionen kan ikke joine nu"
},
"session_token_required": {
"en": "Session token is required.",
"da": "Session-token er påkrævet."
"nickname_taken": {
"en": "Nickname already taken",
"da": "Kaldenavnet er allerede taget"
},
"category_slug_required": {
"en": "category_slug is required",
"da": "category_slug er påkrævet"
},
"category_not_found": {
"en": "Category not found",
"da": "Kategori blev ikke fundet"
},
"round_start_invalid_phase": {
"en": "Round can only be started from lobby",
"da": "Runden kan kun startes fra lobbyen"
},
"round_already_configured": {
"en": "Round already configured",
"da": "Runden er allerede konfigureret"
},
"category_has_no_questions": {
"en": "Category has no active questions",
"da": "Kategorien har ingen aktive spørgsmål"
},
"show_question_invalid_phase": {
"en": "Question can only be shown in lie phase",
@@ -556,98 +374,6 @@
"host_only_mix_answers": {
"en": "Only host can mix answers",
"da": "Kun værten kan blande svar"
},
"player_id_required": {
"en": "player_id is required",
"da": "player_id er påkrævet"
},
"session_token_required": {
"en": "session_token is required",
"da": "session_token er påkrævet"
},
"lie_text_invalid": {
"en": "text must be between 1 and 255 characters",
"da": "text skal være mellem 1 og 255 tegn"
},
"player_not_found_in_session": {
"en": "Player not found in session",
"da": "Spiller blev ikke fundet i sessionen"
},
"invalid_player_session_token": {
"en": "Invalid player session token",
"da": "Ugyldigt spiller-session-token"
},
"lie_submission_closed": {
"en": "Lie submission window has closed",
"da": "Vinduet for løgn-indsendelse er lukket"
},
"lie_already_submitted": {
"en": "Lie already submitted for this player",
"da": "Løgnen er allerede indsendt for denne spiller"
},
"host_only_view_scoreboard": {
"en": "Only host can view scoreboard",
"da": "Kun værten kan se scoreboard"
},
"scoreboard_invalid_phase": {
"en": "Scoreboard is only available in reveal/scoreboard phase",
"da": "Scoreboard er kun tilgængeligt i reveal-/scoreboard-fasen"
},
"host_only_start_next_round": {
"en": "Only host can start next round",
"da": "Kun værten kan starte næste runde"
},
"next_round_invalid_phase": {
"en": "Next round can only start from scoreboard phase",
"da": "Næste runde kan kun starte fra scoreboard-fasen"
},
"host_only_finish_game": {
"en": "Only host can finish game",
"da": "Kun værten kan afslutte spillet"
},
"finish_game_invalid_phase": {
"en": "Game can only be finished from scoreboard phase",
"da": "Spillet kan kun afsluttes fra scoreboard-fasen"
},
"host_only_calculate_scores": {
"en": "Only host can calculate scores",
"da": "Kun værten kan udregne score"
},
"scores_already_calculated": {
"en": "Scores already calculated for this round question",
"da": "Score er allerede udregnet for dette rundespørgsmål"
},
"calculate_scores_invalid_phase": {
"en": "Scores can only be calculated in guess phase",
"da": "Score kan kun udregnes i gættefasen"
},
"no_guesses_submitted": {
"en": "No guesses submitted for this round question",
"da": "Ingen gæt er indsendt for dette rundespørgsmål"
},
"guess_submission_closed": {
"en": "Guess submission window has closed",
"da": "Vinduet for gæt-indsendelse er lukket"
},
"selected_answer_invalid": {
"en": "Selected answer is not part of this round",
"da": "Det valgte svar er ikke en del af denne runde"
},
"guess_already_submitted": {
"en": "Guess already submitted for this player",
"da": "Gættet er allerede indsendt for denne spiller"
},
"lie_submission_invalid_phase": {
"en": "Lie submission is only allowed in lie phase",
"da": "Løgn kan kun indsendes i løgnefasen"
},
"selected_text_invalid": {
"en": "selected_text must be between 1 and 255 characters",
"da": "selected_text skal være mellem 1 og 255 tegn"
},
"guess_submission_invalid_phase": {
"en": "Guess submission is only allowed in guess phase",
"da": "Gæt kan kun indsendes i gættefasen"
}
}
},
@@ -666,78 +392,27 @@
"fallback": "Use default locale when requested locale is unsupported or key translation is missing."
},
"backend_to_frontend_error_keys": {
"calculate_scores_invalid_phase": "unknown",
"category_has_no_questions": "start_round_failed",
"category_not_found": "start_round_failed",
"category_slug_required": "start_round_failed",
"finish_game_invalid_phase": "unknown",
"guess_already_submitted": "unknown",
"guess_submission_invalid_phase": "unknown",
"guess_submission_window_closed": "unknown",
"host_only_action": "start_round_failed",
"host_only_calculate_scores": "unknown",
"host_only_finish_game": "unknown",
"host_only_mix_answers": "start_round_failed",
"host_only_show_question": "start_round_failed",
"host_only_start_next_round": "unknown",
"host_only_start_round": "start_round_failed",
"host_only_view_scoreboard": "unknown",
"invalid_player_session_token": "unknown",
"lie_already_submitted": "unknown",
"lie_submission_invalid_phase": "unknown",
"lie_submission_window_closed": "unknown",
"lie_text_invalid": "unknown",
"mix_answers_invalid_phase": "start_round_failed",
"nickname_invalid": "nickname_invalid",
"nickname_taken": "nickname_taken",
"no_available_questions": "start_round_failed",
"no_guesses_submitted": "unknown",
"not_enough_answers_to_mix": "start_round_failed",
"player_id_required": "unknown",
"player_not_found_in_session": "unknown",
"question_already_shown": "start_round_failed",
"round_already_configured": "start_round_failed",
"round_config_missing": "start_round_failed",
"round_question_not_found": "start_round_failed",
"round_start_invalid_phase": "start_round_failed",
"scoreboard_invalid_phase": "unknown",
"scores_already_calculated": "unknown",
"selected_answer_invalid": "unknown",
"selected_text_invalid": "unknown",
"session_code_required": "session_code_required",
"nickname_invalid": "nickname_invalid",
"session_not_found": "session_not_found",
"session_not_joinable": "join_failed",
"session_token_required": "unknown",
"nickname_taken": "nickname_taken",
"category_slug_required": "start_round_failed",
"category_not_found": "start_round_failed",
"round_start_invalid_phase": "start_round_failed",
"round_already_configured": "start_round_failed",
"host_only_start_round": "start_round_failed",
"host_only_show_question": "start_round_failed",
"host_only_mix_answers": "start_round_failed",
"host_only_action": "start_round_failed",
"category_has_no_questions": "start_round_failed",
"show_question_invalid_phase": "start_round_failed",
"round_config_missing": "start_round_failed",
"question_already_shown": "start_round_failed",
"no_available_questions": "start_round_failed",
"mix_answers_invalid_phase": "start_round_failed",
"round_question_not_found": "start_round_failed",
"not_enough_answers_to_mix": "start_round_failed",
"player_id_required": "unknown",
"session_token_required": "unknown",
"lie_text_invalid": "unknown",
"lie_submission_invalid_phase": "unknown",
"player_not_found_in_session": "unknown",
"invalid_player_session_token": "unknown",
"lie_submission_closed": "unknown",
"lie_already_submitted": "unknown",
"selected_text_invalid": "unknown",
"guess_submission_invalid_phase": "unknown",
"guess_submission_closed": "unknown",
"selected_answer_invalid": "unknown",
"guess_already_submitted": "unknown",
"host_only_view_scoreboard": "unknown",
"scoreboard_invalid_phase": "unknown",
"host_only_start_next_round": "unknown",
"next_round_invalid_phase": "unknown",
"host_only_finish_game": "unknown",
"finish_game_invalid_phase": "unknown",
"host_only_calculate_scores": "unknown",
"scores_already_calculated": "unknown",
"calculate_scores_invalid_phase": "unknown",
"no_guesses_submitted": "unknown"
"not_enough_answers_to_mix": "start_round_failed"
}
}
}