1 Commits

Author SHA1 Message Date
62090d7e64 feat(spa): sync host/player hash routes with gameplay phase
All checks were successful
CI / test-and-quality (push) Successful in 1m53s
2026-03-01 17:59:07 +00:00
104 changed files with 843 additions and 12738 deletions

View File

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

View File

@@ -31,17 +31,3 @@ jobs:
- name: Tests
run: python manage.py test lobby -v 1
- name: Setup Node
uses: actions/setup-node@v4
with:
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
- 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

@@ -1,49 +0,0 @@
# Shared i18n architecture (frontend + backend)
## Scope
Issue #175 requires one shared i18n contract for MVP host/player flows across frontend and backend.
## Source of truth
- Catalog: `shared/i18n/lobby.json`
- Locales:
- 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.
## Key naming convention
- Domain-first namespaces:
- `frontend.ui.common.*`
- `frontend.ui.host.*`
- `frontend.ui.player.*`
- `frontend.errors.*`
- `backend.errors.*`
- `backend.error_codes.*`
- UI lookup keys in Angular are shortened aliases mapped under `frontend.ui`, e.g.:
- `host.start_round`
- `player.submit_guess`
- `common.session_code`
- Backend API errors return stable code + localized message:
- `error_code` = machine-stable key from `backend.error_codes`
- `error` = localized message from `backend.errors`
- `locale` = resolved request locale
## Fallback model (robust)
1. Resolve requested locale (`Accept-Language` on backend, user/browser preference on frontend).
2. If locale unsupported -> use default `en`.
3. If key missing -> return key and log warning.
4. If locale translation missing for key -> fallback to `en` translation.
## Audio-routing policy
- Catalog capability: `frontend.capabilities.client_has_no_audio_output = true`
- Host/player clients expose this as a read-only capability flag.
- Policy: phone clients must not play audio directly; only primary/host output is allowed.
## Verification
- Backend tests: `lobby/tests.py` i18n coverage for locale selection + fallback + error-code/message matrix.
- Frontend smoke/e2e-level unit coverage:
- `frontend/angular/src/app/lobby-i18n.spec.ts`
- `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,35 +0,0 @@
# ISSUE-175 Artifact — shared i18n contract + MVP host/player flow
## Delivered
- Shared i18n contract is centralized in `shared/i18n/lobby.json` (da/en, default `en`, robust fallback).
- Frontend host/player MVP copy is read via shared keys (no hardcoded Danish strings in Angular MVP flow components).
- Backend error messages resolve from shared keyspace with locale-aware API payload (`error_code`, `error`, `locale`).
- Audio-routing policy is explicit and shared: `frontend.capabilities.client_has_no_audio_output=true`.
- Architecture + key naming documented in `docs/I18N_ARCHITECTURE.md`.
## Smoke / e2e evidence
### Backend locale + fallback
Command:
```bash
. .venv/bin/activate && python manage.py test \
lobby.tests.LobbyFlowTests.test_join_error_localizes_to_danish_with_accept_language_header \
lobby.tests.LobbyFlowTests.test_join_error_falls_back_to_english_for_unsupported_locale \
lobby.tests.I18nResolverTests
```
Result:
- PASS (7 tests)
- Confirms `da` localization and fallback to `en` for unsupported locale.
### Frontend host/player + language switch + audio policy
Command:
```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
```
Result:
- PASS (22 tests)
- Includes smoke for host/player copy in **both en and da** and verifies primary-only audio policy flag (`clientHasNoAudioOutput=true`).

View File

@@ -1,47 +0,0 @@
# Issue #188 Artifact — SPA cutover hardening (asset versioning + rollback)
## Scope
Acceptance for `[READY][SPA][P11] Cutover hardening`:
1. Dokumenteret strategi for cache-busting/versionering af SPA assets i Django staticfiles/reverse proxy setup.
2. Konfigurerbar rollback-procedure for `USE_SPA_UI` (trin-for-trin, target <10 min).
3. Smoke-artefakt for både SPA on/off i samme release-vindue.
4. Ingen gameplay-ændringer.
## 1) Asset versioning/cache-busting strategi
Implementeret i SPA shell render-path:
- Konfiguration i `partyhub/settings.py`:
- `WPP_SPA_ASSET_BASE` (eksisterende)
- `WPP_SPA_ASSET_VERSION` (ny)
- `lobby/ui_views.py` injicerer `spa_asset_version` til template-context.
- `lobby/templates/lobby/spa_shell.html` appender `?v={{ spa_asset_version }}` på:
- `styles.css`
- `main.js`
Effekt:
- Ny release-version (fx SHA/tag) kan tvinge cache-miss i browser/proxy uden ændring af route.
- Rollback kan pege på tidligere stabil version-token med samme mekanisme.
## 2) Rollback playbook (`USE_SPA_UI`) — target <10 min
Dokumenteret i `docs/spa-cutover-flag.md`:
1. Sæt `USE_SPA_UI=false`.
2. Sæt `WPP_SPA_ASSET_VERSION` til sidste stabile release-token.
3. Deploy/reload app-processer.
4. Verificér legacy routes (`/lobby/ui/host` + `/lobby/ui/player`).
5. Kør hurtig smoke sanity.
6. Log trigger/timestamp/resultat i smoke artifact.
## 3) Smoke artifact for SPA OFF/ON i samme release-vindue
Dokumenteret i:
- `docs/UI_SMOKE.md` (sektion: "Samme release-vindue: SPA OFF + ON verifikation")
- `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md` (template udvidet med release-window check + `WPP_SPA_ASSET_VERSION`)
Krav:
- OFF-pass (legacy) og ON-pass (SPA) køres i samme deploy/release-vindue.
- Begge passes logges i samme artifact med UTC timestamps og version-token.
## Non-goals bekræftet
- Ingen gameplay-regler ændret.
- Ingen API-kontrakter ændret.
- Ingen UX-redesign; kun drift/cutover-hardening.

View File

@@ -1,57 +0,0 @@
# Issue #200 Artifact — SPA host→player phase sync (no reload)
## Scope
Acceptance for `[READY][SPA][P14] Gameplay MVP-del 5`:
1. Host handlinger (`start round` / `næste fase`) propagere korrekt til player-ruter i SPA.
2. Happy-path artefakt for én fuld faseovergang uden page reload.
3. Sync-fejl giver kontrolleret fallback/error state.
Afgrænsning overholdt: ingen nye spilregler og ingen redesign af backend event-model.
## Happy-path faseovergang (uden page reload)
Reference-flow i Angular shell:
1. **Host starter runde/fase**
- Host action kalder backend endpoint (`POST /lobby/sessions/:code/start` eller fase-endpoint)
- Host shell hydrerer session igen (`GET /lobby/sessions/:code`)
- Host route synkes via hash-rewrite i samme SPA shell:
- `#/host/<phase>/<CODE>`
- Implementeret i `HostShellComponent.syncRouteFromSession()`
2. **Player modtager ny fase via state sync**
- Player shell kører periodisk session-refresh (3s), uden hard reload
- Når `session.status` ændrer sig, synkes hash-route i samme SPA shell:
- `#/player/<phase>/<CODE>`
- Implementeret i `PlayerShellComponent.syncRouteFromSession()`
3. **Ingen page reload**
- Routing sker med `window.history.replaceState(...)`
- URL opdateres i eksisterende SPA instans (ingen template-hop, ingen full refresh)
## Kontrolleret fallback ved sync-fejl
Når player-sync fejler (netværk/fetch/session-refresh):
- UI går i kontrolleret connection-state:
- `reconnecting` ved online netværksfejl
- `offline` når browser rapporterer offline
- Fejl vises med retry/back-to-join handlinger
- Reconnect forsøges igen via timer (`scheduleReconnect`) uden at crashe shell
## Verifikation (tests)
Kørt i `frontend/angular` med Vitest:
- `src/app/features/host/host-shell.component.spec.ts`
- Verificerer host-faseflow og hash-route sync uden reload
- `src/app/features/player/player-shell.component.spec.ts`
- Verificerer periodisk player state sync + hash-route sync
- Verificerer reconnect/offline fallback ved sync-fejl
- `src/app/api-contract-smoke.spec.ts`
- `src/app/session-route-context.spec.ts`
Kommando:
```bash
npm test -- --reporter=dot
```
Resultat: **4/4 testfiler grønne, 22/22 tests bestået**.

View File

@@ -1,36 +0,0 @@
# Issue #205 — Django i18n foundation (da/en)
## Implemented acceptance checks
- **Django i18n setup for `en` + `da` with `en` fallback**
- `LANGUAGE_CODE` set to `en`.
- `LANGUAGES = [('en', 'English'), ('da', 'Danish')]`.
- `LocaleMiddleware` enabled in middleware chain.
- **Shared-key resolver/adapter (no ad hoc backend mapping)**
- Backend error responses now resolve from shared catalog keys in `shared/i18n/lobby.json`.
- `lobby.i18n.api_error()` accepts a shared key and resolves locale-specific text.
- **Representative API flow documented with key/locale behavior**
- `POST /lobby/join` with empty code returns:
- `error_code: "session_code_required"`
- localized `error`
- resolved `locale`
- **Missing key handling deterministic and loggable**
- `resolve_error_message()` returns key string when key/translation is missing.
- Warning is logged (`lobby.i18n` logger) for missing key/translation.
## Example response behavior
### Request
`POST /lobby/join` with empty code and header `Accept-Language: da`
### Response (400)
```json
{
"error": "Sessionskode er påkrævet",
"error_code": "session_code_required",
"locale": "da"
}
```
If locale is unsupported (e.g. `fr`), response uses `locale: "en"` and English message.

View File

@@ -1,35 +0,0 @@
# Issue #207 Artifact — da/en locale switch + audio routing (primary-only)
## Scope
Acceptance for `[READY][i18n][P19]`:
1. Verify one MVP host+player flow in both `en` and `da` locale context.
2. Verify controlled fallback to `en` when a locale key is missing.
3. Verify audio-routing policy is primary-only (client/player has no audio output).
No new feature behavior added — this is verification-only evidence.
## Smoke/e2e evidence (Angular shell test run)
Executed from `frontend/angular`:
```bash
npm test -- --reporter=dot src/app/lobby-i18n.spec.ts src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts
```
Evidence covered by the run:
- `lobby-i18n.spec.ts`
- subscriber locale switch updates (`en -> da -> en`)
- controlled fallback where `da` key is removed during test and `en` copy is returned
- `clientHasNoAudioOutput === true` (primary-only audio routing)
- `host-shell.component.spec.ts`
- host actions and route sync in SPA (no reload)
- `player-shell.component.spec.ts`
- player session refresh/sync path and retry behavior
## Policy evidence
- Shared catalog capability: `shared/i18n/lobby.json`
- `frontend.capabilities.client_has_no_audio_output = true`
- Angular host/player shells bind this capability:
- `HostShellComponent.clientHasNoAudioOutput`
- `PlayerShellComponent.clientHasNoAudioOutput`
Conclusion: locale switching works in `da`/`en`, fallback resolves to `en` when locale key is intentionally missing, and client audio remains disabled by policy (`primary-only`).

View File

@@ -1,20 +0,0 @@
# Issue #223 — Telefon-klient audio guard (artifact)
## Scope leveret
- Telefon-/player-klient installerer en eksplicit audio guard ved mount (`installSecondaryDeviceAudioGuard`).
- Guard overskriver `HTMLMediaElement.prototype.play` til en no-op på secondary device (client policy: `client_has_no_audio_output=true`).
- Guard fjernes igen ved unmount (`ngOnDestroy`) så øvrige flows/enheder ikke påvirkes.
- Ingen audio-controls er eksponeret i player-shell UI.
## Acceptance mapping
1. **Telefon-klient trigger ikke audio playback i kerneflow**
- Verificeret af test: `player-shell.component.spec.ts` (`installs secondary-device audio guard while player shell is mounted`).
2. **Primær enhed påvirkes ikke negativt**
- Guard er scoped til player-shell lifecycle og restore'r original `play` ved destroy.
3. **Enkel test/verifikation dokumenteret**
- Dokumenteret her + testkørsel nedenfor.
## Testkørsel
- Kommando:
- `cd frontend/angular && npm test -- src/app/features/player/player-shell.component.spec.ts`
- Resultat: bestået (inkl. audio-guard test).

View File

@@ -1,55 +0,0 @@
# Issue #224 — Trunk-sekvens for #175 (A/B/C)
Formål: gøre #175 scheduler-klar som tre små, uafhængige og mergeklare bidder.
## Sekvens
### A) Backend i18n baseline
- Tracking issue: #225
- Scope:
- Backend resolver til locale (da/en)
- Fallback til `en` ved unsupported locale
- Stabil fejlkontrakt i payload (`error_code`, `error`, `locale`)
- Mergebarhed: Kan merges uden frontend-ændringer.
- Acceptance:
- Backend tests dækker `da` + fallback `en`
- Kontraktfelter er stabile i response
### B) Shared key-map + locale-kontrakt
- Tracking issue: #226
- Scope:
- Én shared key-map for lobby/kerneflow
- Locale-kontrakt (tilladte locales, default locale, fallback-regler)
- Dokumentation af naming + ownership
- Mergebarhed: Kan merges uden host/player UI-migrering.
- Acceptance:
- Shared kontrakt findes ét sted
- Begge sider kan importere den
- Docs opdateret med da/en eksempler
### C) Angular host/player integration + hardcoded-text cleanup
- Tracking issue: #227
- Scope:
- Angular host/player kerneflow bruger shared keys
- Hardcoded tekster fjernes i aftalte kernekomponenter
- Sprogskift verificeres i kritiske states
- Mergebarhed: Kan merges selvstændigt når frontend-tests er grønne.
- Acceptance:
- Korrekt i18n-copy i da/en i kerneflow
- Ingen hardcoded kerneflow-tekster tilbage
- Frontend tests/smoke grønne
## PR-grænser (per bid)
- 1 PR pr. bid (A/B/C) mod `main`
- Mål: ~200300 net LOC per PR (ekskl. generated artefakter)
- Undgå cross-layer scope creep
- Review-tid <30 min
## Overordnet acceptance for #224
- A/B/C-sekvens er tydelig med links
- Hver bid er mergebar isoleret
- Scheduler kan assigne direkte uden ekstra afklaring
## Verification (docs-only)
- Verificeret at dokumentet kun beskriver trunk-sekvensen for issue #224 og linker til #225/#226/#227.
- Ingen runtime-kode ændret; der er derfor ikke kørt kode/tests i denne PR.

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,30 +0,0 @@
# ISSUE-226 — Shared key-map + locale-kontrakt (backend/frontend)
## Source of truth
- Single shared artifact: `shared/i18n/lobby.json`
- Ownership is documented under `contract.ownership` in the same artifact.
## Locale contract
Defined under `contract.locale`:
- default locale: `en`
- supported locales: `en`, `da`
- fallback rule: use default locale when requested locale is unsupported or a key translation is missing.
## Shared backend→frontend key-map
Defined under `contract.backend_to_frontend_error_keys`.
Examples:
- `session_not_found -> session_not_found`
- `session_not_joinable -> join_failed`
- `round_start_invalid_phase -> start_round_failed`
This allows backend error codes to remain stable while frontend copy keys stay UX-oriented.
## da/en example values
From `shared/i18n/lobby.json`:
- `frontend.errors.session_code_required.en = "Session code is required."`
- `frontend.errors.session_code_required.da = "Sessionskoden er påkrævet."`
## Verification
- Backend: `python manage.py test lobby.tests.I18nResolverTests`
- Frontend: `npm test -- --run tests/lobby-i18n.contract.test.ts`

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

@@ -23,14 +23,11 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
- Active category/questions present: <yes/no>
- Participants: host + <N> players
- `USE_SPA_UI`: <on/off>
- `WPP_SPA_ASSET_VERSION`: <release-token/sha>
- UI route used:
- OFF (legacy): `/lobby/ui/host` + `/lobby/ui/player`
- ON (SPA shell): `/lobby/ui/host/<spa-path>` + `/lobby/ui/player`
#### Checks (PASS/FAIL)
0. Same release-window verification
- OFF + ON smoke kørt i samme release-vindue: <pass/fail>
1. Cutover route sanity
- Flag OFF serves legacy UI templates: <pass/fail>
- Flag ON serves SPA shell on expected path(s): <pass/fail>
@@ -42,19 +39,6 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
- next round transitions: <pass/fail>
- final leaderboard visible: <pass/fail>
#### Smoke-gate decision (før `USE_SPA_UI=true`)
- Gate status: <GREEN/RED>
- Gate criteria met:
- [ ] Cutover route sanity = PASS (OFF + ON)
- [ ] Full gameplay round = PASS
- [ ] Next-round/final leaderboard sanity = PASS
- [ ] Ingen nye blocker-regressioner i host/player flow
#### Rollback checkpoint
- Rollback required: <yes/no>
- Trigger reason (if yes): <kort trigger>
- Rollback done (`USE_SPA_UI=false`) verified: <yes/no>
#### Evidence pointers
- Command(s): `<exact command(s)>`
- UI notes/screenshots/log refs: <short refs>

View File

@@ -20,29 +20,4 @@
9. Host: Beregn score og Vis scoreboard.
10. Host: Næste runde eller Afslut spil.
## Smoke-gate (staging cutover)
`USE_SPA_UI` må kun aktiveres i staging når følgende er opfyldt:
- Cutover route sanity er PASS for både OFF (legacy) og ON (SPA shell).
- Én fuld gameplay-runde til scoreboard er PASS.
- Next-round/final leaderboard sanity er PASS.
- Ingen nye blocker-regressioner i host/player kerneflow.
## Samme release-vindue: SPA OFF + ON verifikation
Kør begge checks i samme release-vindue (samme deploy/artifact version):
1. **OFF-pass (legacy)**
- `USE_SPA_UI=false`
- Verificér legacy routes + fuld runde.
2. **ON-pass (SPA)**
- `USE_SPA_UI=true`
- Behold samme release artifact og kun toggl flag/version-token ved behov.
- Verificér SPA shell routes + fuld runde.
3. Dokumentér begge pass i samme smoke-artifact med UTC timestamps og `WPP_SPA_ASSET_VERSION`.
## Rollback check points
Skift straks tilbage til `USE_SPA_UI=false` hvis en gate fejler:
1. Verificér legacy routes (`/lobby/ui/host` + `/lobby/ui/player`) fungerer igen.
2. Log rollback trigger + kort repro i smoke artifact.
3. Opret/link blocker issue før nyt cutover-forsøg.
Resultat: En fuld runde kan køres uden rå API-kald fra terminal.

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,34 +0,0 @@
# i18n key manifest + drift check
Issue: #240
This repo keeps shared lobby keyspaces in two files:
- Contract source: `shared/i18n/lobby.json`
- Key manifest: `shared/i18n/key-manifest.json`
The manifest is intentionally small and explicit. It lists:
- Supported locales (`locales`)
- Frontend error key set (`frontend_error_keys`)
- Backend error code set (`backend_error_codes`)
- Backend translation key set (`backend_error_keys`)
- Optional contract-only aliases (`allowed_contract_only_backend_codes`)
## Local check
Run the read-only drift checker from repo root:
```bash
python3 scripts/check_i18n_drift.py
```
The script returns non-zero when it detects drift, including:
- key set mismatch between manifest and shared catalog
- missing backend→frontend mapping coverage
- mapping to unknown frontend keys
- mappings for unknown backend codes
- missing/empty locale translations (`en`/`da`)
No CI gating changes are included in this task; this is a local guardrail.

View File

@@ -1,69 +0,0 @@
# i18n key-map bootstrap (Angular host/player MVP)
Issue: #220
Scope: Lobby → Join → Start round → Round → Reveal → Scoreboard
Locales: `en`, `da`
This document is the gameplay key-namespace map for Angular host/player MVP.
It maps existing text keys only (no feature expansion) and stays aligned with `shared/i18n/lobby.json`.
## Key families
- `host``frontend.ui.host.*` host-facing gameplay actions and status text.
- `player``frontend.ui.player.*` player-facing gameplay actions and status text.
- `system` — shared UI labels used across host/player views (implemented under `frontend.ui.common.*` in `shared/i18n/lobby.json`).
- `errors` — user-facing error keys shown by frontend (`frontend.errors.*`) plus backend code → frontend key bridge via `backend.error_codes.*` / `contract.backend_to_frontend_error_keys.*`.
## Gameplay flow key map
| Flow step | Family | Key | en | da |
|---|---|---|---|---|
| Lobby | `host` | `host.title` | Host gameplay flow | Vært gameplay-flow |
| Lobby | `player` | `player.title` | Player gameplay flow | Spiller gameplay-flow |
| Lobby | `system` (`frontend.ui.common`) | `common.session_code` | Session code | Sessionskode |
| Lobby | `player` | `player.nickname` | Nickname | Kaldenavn |
| Join | `player` | `player.join` | Join | Join |
| Start round | `host` | `host.start_round` | Start round | Start runde |
| Round | `host` | `host.show_question` | Show question | Vis spørgsmål |
| Round | `player` | `player.lie_label` | Lie | Løgn |
| Round | `player` | `player.submit_lie` | Submit lie | Send løgn |
| Round | `player` | `player.submit_guess` | Submit guess | Send gæt |
| Reveal | `host` | `host.mix_answers` | Mix answers → guess | Bland svar → gæt |
| Reveal | `host` | `host.calculate_scores` | Calculate scores → reveal | Udregn score → afslør |
| Scoreboard | `host` | `host.load_scoreboard` | Load scoreboard | Hent scoreboard |
| Scoreboard | `host` | `host.final_leaderboard` | Final leaderboard | Finale leaderboard |
| Scoreboard | `player` | `player.final_leaderboard` | Final leaderboard | Finale leaderboard |
| Scoreboard | `system` (`frontend.ui.common`) | `common.points_short` | pts | point |
## Frontend error keys used in flow scope
| Error family | Key | en | da |
|---|---|---|---|
| Join | `frontend.errors.session_code_required` | Session code is required. | Sessionskoden er påkrævet. |
| Join | `frontend.errors.session_not_found` | Session code is invalid or the session no longer exists. | Sessionskoden er ugyldig, eller sessionen findes ikke længere. |
| Join | `frontend.errors.nickname_invalid` | Nickname must be between 2 and 40 characters. | Kaldenavn skal være mellem 2 og 40 tegn. |
| Join | `frontend.errors.nickname_taken` | Nickname is already taken. | Kaldenavnet er allerede taget. |
| Join | `frontend.errors.join_failed` | Join failed. Check code or nickname and try again. | Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen. |
| Start round | `frontend.errors.start_round_failed` | Could not start round. Refresh the lobby and try again. | Kunne ikke starte runden. Opdater lobbyen og prøv igen. |
| Any | `frontend.errors.unknown` | Action failed. Refresh status and try again. | Handlingen fejlede. Opdater status og prøv igen. |
## Backend→frontend mapping for gameplay errors
Mapped in `contract.backend_to_frontend_error_keys` (source: `shared/i18n/lobby.json`):
> Note: `host_only_action` is **not** part of the current shared contract mapping and is intentionally not listed here.
- `session_code_required``session_code_required`
- `nickname_invalid``nickname_invalid`
- `session_not_found``session_not_found`
- `session_not_joinable``join_failed`
- `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`
## Notes
- This is a bootstrap key-map doc for MVP mergeability.
- The key/value source of truth remains `shared/i18n/lobby.json`.

View File

@@ -1,28 +0,0 @@
# Issue #180 SPA gameplay flow evidence
## Flow log (host + player, no page reload)
1. Host opens SPA host shell and loads scoreboard (`GET /lobby/sessions/{code}/scoreboard`).
2. Host starts next round (`POST /lobby/sessions/{code}/rounds/next`).
3. Host shell refreshes session state in-place (`GET /lobby/sessions/{code}`) and clears old scoreboard/final leaderboard payloads.
4. Player shell performs periodic session refresh while online (3s cadence) and transitions from `scoreboard` to `lobby` without page reload.
5. Host finishes game (`POST /lobby/sessions/{code}/finish`) and renders final leaderboard directly in SPA shell.
6. Player shell reads `finished` state and renders final leaderboard in SPA (sorted by score).
7. Error/retry paths available:
- Host: next-round and finish-game retry buttons with explicit error feedback.
- Player: reconnect + submit retry feedback.
## Test output snapshot
Command:
```bash
cd frontend/angular
npm test -- --run src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts
```
Result:
- `host-shell.component.spec.ts`: 6 passed
- `player-shell.component.spec.ts`: 7 passed
- Total: 13 passed, 0 failed

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

@@ -12,88 +12,9 @@ Sæt env var pr. miljø:
Backward compatibility under cutover:
- Hvis `USE_SPA_UI` ikke er sat, bruges `WPP_SPA_ENABLED` som fallback.
## Static asset versioning/cache-busting (hardening)
Formål: sikre at browser/proxy/CDN hurtigt henter ny SPA bundle i release-vinduet uden at kræve hard refresh.
- `WPP_SPA_ASSET_BASE` peger fortsat på build-output (`/static/frontend/angular/browser`).
- `WPP_SPA_ASSET_VERSION` injiceres i SPA shell URLs som query-param (`?v=<version>`).
- Anbefalet værdi for `WPP_SPA_ASSET_VERSION`: release-tag eller kort commit SHA.
- Ved rollback sættes `WPP_SPA_ASSET_VERSION` til den tidligere kendte stabile release-værdi.
Eksempel (staging/prod env):
```env
USE_SPA_UI=true
WPP_SPA_ASSET_BASE=/static/frontend/angular/browser
WPP_SPA_ASSET_VERSION=rel-2026-03-01-bb82357
```
## Staging rollout-checkliste (`USE_SPA_UI`)
1. **Baseline (flag OFF)**
- Bekræft at staging kører med `USE_SPA_UI=false`.
- Kør gameplay smoke på legacy routes (`/lobby/ui/host` + `/lobby/ui/player`).
2. **Smoke-gate før aktivering (skal være grøn)**
- Cutover route sanity = PASS for både OFF og ON checks.
- Full gameplay round (join/start/round/scoreboard) = PASS.
- Next-round/final leaderboard sanity = PASS.
- Ingen nye blocker-regressioner i host/player kerneflow.
3. **Kontrolleret aktivering i staging**
- Sæt `USE_SPA_UI=true` i staging miljøet.
- Kør smoke-flow igen med SPA-route-verifikation.
4. **Post-cutover dokumentation**
- Log evidens med commit/head SHA, UTC timestamp og gate-status.
## Rollback playbook (`USE_SPA_UI`) — mål: <10 min
Rollback til legacy (`USE_SPA_UI=false`) udføres straks hvis et checkpoint fejler:
- Forkert route/shell for valgt flag i cutover route sanity.
- Gameplay smoke kan ikke gennemføres til scoreboard/final leaderboard.
- Kritisk regression i host/player flow under smoke.
Trin-for-trin:
1. Sæt `USE_SPA_UI=false` i deploy-env.
2. Sæt `WPP_SPA_ASSET_VERSION` til sidste stabile release-token.
3. Deploy/reload app-processer.
4. Verificér legacy routes: `/lobby/ui/host` + `/lobby/ui/player`.
5. Kør hurtig smoke sanity (join/start/scoreboard path).
6. Log UTC tid, trigger, release-token og resultat i smoke artifact.
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

@@ -1,273 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { createAngularApiClient, type AngularHttpClientLike } from '../../../src/api/angular-client';
describe('SPA Angular API contract smoke (host/player foundation)', () => {
it('smoke-checks canonical host/player endpoint contracts', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/lobby/sessions/ABCD12') {
return {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 0, is_connected: true },
{ id: 3, nickname: 'Bo', score: 0, 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: 'lobby',
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: 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
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
} as T;
}
if (url === '/lobby/sessions/ABCD12/scoreboard') {
return {
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
leaderboard: [
{ id: 9, nickname: 'Maja', score: 200 },
{ id: 10, nickname: 'Bo', score: 150 }
]
} 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/join') {
expect(body).toEqual({ code: 'ABCD12', nickname: 'Maja' });
return {
player: { id: 9, nickname: 'Maja', session_token: 'session-token-1', score: 0 },
session: { code: 'ABCD12', status: 'lobby' }
} as T;
}
if (url === '/lobby/sessions/ABCD12/rounds/start') {
expect(body).toEqual({ category_slug: 'history' });
return {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round: { number: 1, category: { slug: 'history', name: 'History' } }
} as T;
}
if (url === '/lobby/sessions/ABCD12/questions/show') {
expect(body).toEqual({});
return {
round_question: {
id: 77,
prompt: 'Q?',
round_number: 1,
shown_at: '2026-03-01T18:00:00Z',
lie_deadline_at: '2026-03-01T18:00:30Z'
},
config: { lie_seconds: 30 }
} as T;
}
if (url === '/lobby/sessions/ABCD12/questions/77/answers/mix') {
expect(body).toEqual({});
return {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 77, round_number: 1 },
answers: [{ text: 'A' }, { text: 'B' }]
} as T;
}
if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
expect(body).toEqual({});
return {
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 }
]
} as T;
}
if (url === '/lobby/sessions/ABCD12/rounds/next') {
expect(body).toEqual({});
return { session: { code: 'ABCD12', status: 'lie', current_round: 2 } } as T;
}
if (url === '/lobby/sessions/ABCD12/finish') {
expect(body).toEqual({});
return {
session: { code: 'ABCD12', status: 'finished', current_round: 2 },
winner: { id: 9, nickname: 'Maja', score: 250 },
leaderboard: [
{ id: 9, nickname: 'Maja', score: 250 },
{ id: 10, nickname: 'Bo', score: 150 }
]
} as T;
}
if (url === '/lobby/sessions/ABCD12/questions/77/lies/submit') {
expect(body).toEqual({ player_id: 9, session_token: 'session-token-1', text: 'my lie' });
return {
lie: {
id: 501,
player_id: 9,
round_question_id: 77,
text: 'my lie',
created_at: '2026-03-01T18:00:05Z'
},
window: { lie_deadline_at: '2026-03-01T18:00:30Z' }
} as T;
}
if (url === '/lobby/sessions/ABCD12/questions/77/guesses/submit') {
expect(body).toEqual({ player_id: 9, session_token: 'session-token-1', selected_text: 'B' });
return {
guess: {
id: 601,
player_id: 9,
round_question_id: 77,
selected_text: 'B',
is_correct: false,
fooled_player_id: null,
created_at: '2026-03-01T18:00:15Z'
},
window: { guess_deadline_at: '2026-03-01T18:01:00Z' }
} 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.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((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true);
expect((await client.startRound(' abcd12 ', { category_slug: 'history' })).ok).toBe(true);
expect((await client.showQuestion(' abcd12 ')).ok).toBe(true);
expect((await client.mixAnswers(' abcd12 ', 77)).ok).toBe(true);
expect((await client.calculateScores(' abcd12 ', 77)).ok).toBe(true);
expect((await client.getScoreboard(' abcd12 ')).ok).toBe(true);
expect((await client.startNextRound(' abcd12 ')).ok).toBe(true);
expect((await client.finishGame(' abcd12 ')).ok).toBe(true);
expect(
(
await client.submitLie(' abcd12 ', 77, {
player_id: 9,
session_token: 'session-token-1',
text: 'my lie'
})
).ok
).toBe(true);
expect(
(
await client.submitGuess(' abcd12 ', 77, {
player_id: 9,
session_token: 'session-token-1',
selected_text: 'B'
})
).ok
).toBe(true);
expect(get).toHaveBeenNthCalledWith(1, '/lobby/sessions/ABCD12', { withCredentials: true });
expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12/scoreboard', { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/join',
{ code: 'ABCD12', nickname: 'Maja' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12/rounds/start',
{ category_slug: 'history' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(3, '/lobby/sessions/ABCD12/questions/show', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
4,
'/lobby/sessions/ABCD12/questions/77/answers/mix',
{},
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
5,
'/lobby/sessions/ABCD12/questions/77/scores/calculate',
{},
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(6, '/lobby/sessions/ABCD12/rounds/next', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(7, '/lobby/sessions/ABCD12/finish', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
8,
'/lobby/sessions/ABCD12/questions/77/lies/submit',
{ player_id: 9, session_token: 'session-token-1', text: 'my lie' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
9,
'/lobby/sessions/ABCD12/questions/77/guesses/submit',
{ player_id: 9, session_token: 'session-token-1', selected_text: 'B' },
{ withCredentials: true }
);
});
});

View File

@@ -1,5 +1,4 @@
.shell { font-family: Arial, sans-serif; margin: 1rem; }
.shell__header { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: space-between; align-items: center; border-bottom: 1px solid #ddd; padding-bottom: 0.75rem; }
.shell__header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #ddd; padding-bottom: 0.75rem; }
.shell__header nav { display: flex; gap: 0.75rem; }
.shell__content { margin-top: 1rem; }
.locale-picker { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.95rem; }

View File

@@ -1,20 +1,13 @@
<main class="shell">
<header class="shell__header">
<h1>{{ copy('app.title') }}</h1>
<h1>WPP Angular Shell</h1>
<nav>
<a routerLink="/host">{{ copy('app.host_nav') }}</a>
<a routerLink="/player">{{ copy('app.player_nav') }}</a>
<a routerLink="/host">Host</a>
<a routerLink="/player">Player</a>
</nav>
<label class="locale-picker">
{{ copy('app.language_label') }}
<select [ngModel]="locale" (ngModelChange)="setLocale($event)">
<option value="en">English</option>
<option value="da">Dansk</option>
</select>
</label>
</header>
<section class="shell__content" [attr.data-wpp-locale]="locale">
<section class="shell__content">
<router-outlet></router-outlet>
</section>
</main>

View File

@@ -1,33 +1,20 @@
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink, RouterOutlet } from '@angular/router';
import { resolvePreferredLocale, setPreferredLocale, t } from './lobby-i18n';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, FormsModule],
imports: [RouterOutlet, RouterLink],
templateUrl: './app.component.html',
styleUrl: './app.component.css',
})
export class AppComponent {
private readonly router = inject(Router);
locale = resolvePreferredLocale();
constructor() {
const shellRoute = document.body.dataset['wppShellRoute'];
if (shellRoute?.startsWith('/host') || shellRoute?.startsWith('/player')) {
void this.router.navigateByUrl(shellRoute);
}
}
copy(key: string): string {
return t(key, this.locale);
}
setLocale(locale: string): void {
this.locale = setPreferredLocale(locale);
}
}

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();
@@ -335,121 +244,4 @@ describe('HostShellComponent gameplay wiring', () => {
expect(component.nextRoundError).toContain('Session code is required');
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);
const replaceState = vi.fn();
vi.stubGlobal('window', {
location: { hash: '#/host/lobby/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(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

@@ -1,16 +1,21 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit } from '@angular/core';
import type { ActivatedRoute } from '@angular/router';
import { Component, 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';
import { syncHashRoute } from '../../gameplay-route-sync';
import type { RouteSessionContext } from '../../session-route-context';
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({
@@ -18,61 +23,40 @@ type LeaderboardResponse = FinishGameResponse;
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<h2>{{ copy('host.title') }}</h2>
<h2>Host SPA gameplay flow</h2>
<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>
<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>
<div class="panel">
<label>Session code <input [(ngModel)]="sessionCode" /></label>
<label>Category <input [(ngModel)]="categorySlug" /></label>
<button (click)="refreshSession()" [disabled]="loading">Refresh</button>
<button (click)="startRound()" [disabled]="loading">Start round</button>
<button (click)="showQuestion()" [disabled]="loading || !roundQuestionId">Show question</button>
<button (click)="mixAnswers()" [disabled]="loading || !roundQuestionId">Mix answers → guess</button>
<button (click)="calculateScores()" [disabled]="loading || !roundQuestionId">Calculate scores → reveal</button>
<button (click)="loadScoreboard()" [disabled]="loading">Load scoreboard</button>
<button (click)="startNextRound()" [disabled]="loading">Start next round</button>
<button (click)="finishGame()" [disabled]="loading">Finish game</button>
<button *ngIf="scoreboardError" (click)="loadScoreboard()" [disabled]="loading">Retry scoreboard</button>
<button *ngIf="nextRoundError" (click)="startNextRound()" [disabled]="loading">Retry next round</button>
<button *ngIf="finishError" (click)="finishGame()" [disabled]="loading">Retry finish game</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>
<div *ngIf="session" class="panel">
<p><strong>{{ copy('common.status') }}:</strong> {{ session.session.status }} · {{ copy('common.round') }} {{ session.session.current_round }}</p>
<p><strong>{{ copy('common.round_question_id') }}:</strong> {{ roundQuestionId || '-' }}</p>
<p *ngIf="session.round_question"><strong>{{ copy('common.prompt') }}:</strong> {{ session.round_question.prompt }}</p>
<p><strong>Status:</strong> {{ session.session.status }} · round {{ session.session.current_round }}</p>
<p><strong>Round question id:</strong> {{ roundQuestionId || '-' }}</p>
<p *ngIf="session.round_question"><strong>Prompt:</strong> {{ session.round_question.prompt }}</p>
<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>
<h3>Final leaderboard</h3>
<p *ngIf="finalWinner"><strong>Winner:</strong> {{ finalWinner.nickname }} ({{ finalWinner.score }} pts)</p>
<ol>
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
</ol>
@@ -81,10 +65,7 @@ type LeaderboardResponse = FinishGameResponse;
</div>
`,
})
export class HostShellComponent implements OnInit, OnDestroy {
locale = resolvePreferredLocale();
readonly clientHasNoAudioOutput = clientHasNoAudioOutput;
export class HostShellComponent implements OnInit {
sessionCode = '';
categorySlug = 'general';
roundQuestionId = '';
@@ -101,19 +82,16 @@ export class HostShellComponent implements OnInit, OnDestroy {
private readonly api = createApiClient();
private readonly controller = createVerticalSliceController(this.api);
private unsubscribeLocale: (() => void) | null = null;
constructor(private readonly route: Pick<ActivatedRoute, 'snapshot'> = { snapshot: { data: {} } as never }) {}
ngOnInit(): void {
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
this.locale = locale;
});
if (typeof window === 'undefined') {
return;
}
const hashRoute = window.location.hash.replace(/^#\/?/, '');
const match = hashRoute.match(/^host(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
const codeFromRoute = match?.[1] ?? '';
const routeContext = this.route.snapshot.data['routeContext'] as RouteSessionContext | undefined;
const codeFromRoute = routeContext?.sessionCode ?? '';
const storedCode = window.sessionStorage.getItem('wpp.host-session-code') ?? '';
const candidate = codeFromRoute || storedCode;
@@ -126,51 +104,6 @@ export class HostShellComponent implements OnInit, OnDestroy {
void this.refreshSession();
}
ngOnDestroy(): void {
this.unsubscribeLocale?.();
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);
}
private normalizeCode(value: string): string {
return value.trim().toUpperCase();
}
@@ -209,47 +142,44 @@ export class HostShellComponent implements OnInit, OnDestroy {
try {
const state = await this.controller.hydrateLobby(this.sessionCode);
if (!state.session || state.errorMessage) {
throw new Error(state.errorMessage ?? this.copy('common.unknown_error'));
throw new Error(state.errorMessage ?? 'Unknown error');
}
this.session = state.session as SessionDetail;
this.sessionCode = this.session.session.code;
this.persistSessionCode(this.sessionCode);
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
if (!syncHashRoute('host', this.session)) {
throw new Error(`Unsupported host phase status: ${this.session.session.status}`);
}
if (this.session.session.status !== 'finished') {
this.resetFinalLeaderboard();
}
this.syncRouteFromSession();
} catch (error) {
this.error = `${this.copy('host.session_refresh_failed')}: ${(error as Error).message}`;
this.error = `Session refresh failed: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
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) {
throw new Error(state.errorMessage ?? this.copy('common.unknown_error'));
throw new Error(state.errorMessage ?? 'Unknown error');
}
this.session = state.session as SessionDetail;
this.sessionCode = this.session.session.code;
this.persistSessionCode(this.sessionCode);
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
if (!syncHashRoute('host', this.session)) {
throw new Error(`Unsupported host phase status: ${this.session.session.status}`);
}
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 +188,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 +197,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,60 +206,49 @@ 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) {
this.scoreboardError = `${this.copy('host.scoreboard_failed')}: ${(error as Error).message}`;
this.scoreboardError = `Scoreboard failed: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
async startNextRound(): Promise<void> {
if (!this.canStartNextRound) {
return;
}
this.loading = true;
this.nextRoundError = '';
this.error = '';
try {
const code = this.normalizeCode(this.sessionCode);
if (!code) {
throw new Error(this.copy('host.session_code_required'));
throw new Error('Session code is required');
}
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {});
this.scoreboardPayload = '';
this.resetFinalLeaderboard();
await this.refreshSession();
} catch (error) {
this.nextRoundError = `${this.copy('host.next_round_failed')}: ${(error as Error).message}`;
this.nextRoundError = `Next round failed: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
async finishGame(): Promise<void> {
if (!this.canFinishGame) {
return;
}
this.loading = true;
this.finishError = '';
this.error = '';
try {
const code = this.normalizeCode(this.sessionCode);
if (!code) {
throw new Error(this.copy('host.session_code_required'));
throw new Error('Session code is required');
}
const payload = await this.request<LeaderboardResponse>(`/lobby/sessions/${encodeURIComponent(code)}/finish`, 'POST', {});
this.finalLeaderboardPayload = JSON.stringify(payload, null, 2);
@@ -350,38 +261,18 @@ export class HostShellComponent implements OnInit, OnDestroy {
this.finalWinner = payload.winner ?? this.finalLeaderboard[0] ?? null;
await this.refreshSession();
} catch (error) {
this.finishError = `${this.copy('host.finish_game_failed')}: ${(error as Error).message}`;
this.finishError = `Finish game failed: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
private resetFinalLeaderboard(): void {
this.scoreboardPayload = '';
this.finalLeaderboardPayload = '';
this.finalLeaderboard = [];
this.finalWinner = null;
}
private syncRouteFromSession(): void {
if (!this.session) {
return;
}
const phase = this.gameplayPhase ?? this.session.session.status ?? 'lobby';
const code = this.normalizeCode(this.session.session.code || this.sessionCode);
if (!code) {
return;
}
const targetPath = `#/host/${encodeURIComponent(phase)}/${encodeURIComponent(code)}`;
if (typeof window === 'undefined' || window.location.hash === targetPath) {
return;
}
window.history.replaceState(window.history.state, '', targetPath);
}
private async runAction(action: () => Promise<void>): Promise<void> {
this.loading = true;
this.error = '';

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,53 +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();
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: null })))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null })));
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.session?.session.status).toBe('scoreboard');
await vi.advanceTimersByTimeAsync(3100);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(component.session?.session.status).toBe('lobby');
component.ngOnDestroy();
});
it('enters reconnecting state when network request fails while online', async () => {
vi.stubGlobal('navigator', { onLine: true });
@@ -356,8 +209,8 @@ describe('PlayerShellComponent gameplay wiring', () => {
await component.refreshSession();
expect(component.connectionState === 'reconnecting' || component.connectionState === 'online').toBe(true);
expect(component.error).toContain('Session refresh failed:');
expect(component.connectionState).toBe('reconnecting');
expect(component.error).toContain('Session refresh failed: Could not load lobby status.');
});
it('uses offline state when browser reports disconnected network', async () => {
@@ -442,254 +295,4 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(component.submitError).toBeNull();
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();
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: null })))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null })));
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/scoreboard/ABCD12' },
history: { state: null, replaceState },
localStorage,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
await vi.advanceTimersByTimeAsync(3100);
expect(replaceState).toHaveBeenCalledWith(null, '', '#/player/lobby/ABCD12');
component.ngOnDestroy();
});
it('silences active media elements when secondary-device audio guard is installed', () => {
const pauseAudio = vi.fn();
const pauseVideo = vi.fn();
const audioElement = { muted: false, pause: pauseAudio };
const videoElement = { muted: false, pause: pauseVideo };
const querySelectorAll = vi.fn().mockReturnValue([audioElement, videoElement]);
vi.stubGlobal('document', { querySelectorAll });
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: { play: vi.fn().mockResolvedValue(undefined) } },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const component = new PlayerShellComponent();
component.ngOnInit();
expect(querySelectorAll).toHaveBeenCalledWith('audio,video');
expect(audioElement.muted).toBe(true);
expect(videoElement.muted).toBe(true);
expect(pauseAudio).toHaveBeenCalledTimes(1);
expect(pauseVideo).toHaveBeenCalledTimes(1);
component.ngOnDestroy();
});
it('installs secondary-device audio guard while player shell is mounted', async () => {
const originalPlay = vi.fn().mockRejectedValue(new Error('original play'));
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();
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);
component.ngOnDestroy();
await expect(mediaPrototype.play()).rejects.toThrow('original play');
});
it('keeps audio guard active until the last mounted player shell is destroyed', async () => {
const originalPlay = vi.fn().mockRejectedValue(new Error('original play'));
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 firstComponent = new PlayerShellComponent();
const secondComponent = new PlayerShellComponent();
firstComponent.ngOnInit();
secondComponent.ngOnInit();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
firstComponent.ngOnDestroy();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
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

@@ -1,38 +1,23 @@
import { CommonModule } from '@angular/common';
import type { ActivatedRoute } from '@angular/router';
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';
import { syncHashRoute } from '../../gameplay-route-sync';
import type { RouteSessionContext } from '../../session-route-context';
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>;
__wppSecondaryDeviceAudioGuard__?: {
originalPlay: (this: GuardableMediaElement) => Promise<void>;
installs: number;
};
};
function resolveLocalStorage(): Storage | undefined {
if (typeof window === 'undefined') {
return undefined;
@@ -45,81 +30,53 @@ function resolveLocalStorage(): Storage | undefined {
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<h2>{{ copy('player.title') }}</h2>
<p *ngIf="clientHasNoAudioOutput" class="hint">{{ copy('player.audio_policy_notice') }}</p>
<h2>Player SPA gameplay flow</h2>
<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>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button *ngIf="showJoinControls" (click)="joinSession()" [disabled]="loading">{{ copy('player.join') }}</button>
<div class="panel">
<label>Session code <input [(ngModel)]="sessionCode" /></label>
<label>Nickname <input [(ngModel)]="nickname" /></label>
<button (click)="refreshSession()" [disabled]="loading">Refresh</button>
<button (click)="joinSession()" [disabled]="loading">Join</button>
</div>
<p *ngIf="connectionState === 'reconnecting'" class="error">
{{ copy('player.reconnecting_text') }}
<button type="button" (click)="retryReconnect()" [disabled]="loading">{{ copy('player.retry_now') }}</button>
<button type="button" (click)="returnToJoin()" [disabled]="loading">{{ copy('common.back_to_join') }}</button>
Reconnecting… trying to refresh session state.
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry now</button>
<button type="button" (click)="returnToJoin()" [disabled]="loading">Back to join</button>
</p>
<p *ngIf="connectionState === 'offline'" class="error">
{{ copy('player.offline_text') }}
<button type="button" (click)="retryReconnect()" [disabled]="loading">{{ copy('player.retry_now') }}</button>
<button type="button" (click)="returnToJoin()" [disabled]="loading">{{ copy('common.back_to_join') }}</button>
You are offline. Reconnect to continue gameplay.
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry now</button>
<button type="button" (click)="returnToJoin()" [disabled]="loading">Back to join</button>
</p>
<p *ngIf="loading" class="hint">{{ loadingMessage }}</p>
<div class="panel" *ngIf="session">
<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>
<p><strong>Status:</strong> {{ session.session.status }}</p>
<p *ngIf="session.round_question"><strong>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>Løgn <input [(ngModel)]="lieText" [disabled]="loading || session.session.status !== 'lie'" /></label>
<button (click)="submitLie()" [disabled]="loading || session.session.status !== 'lie'">Submit lie</button>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading">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">
<h3>{{ copy('player.final_leaderboard') }}</h3>
<button (click)="submitGuess()" [disabled]="loading || session.session.status !== 'guess' || !selectedGuess">Submit guess</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">Retry guess submit</button>
<div *ngIf="session.session.status === 'finished' && finalLeaderboard.length">
<h3>Final leaderboard</h3>
<ol>
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
</ol>
@@ -130,15 +87,12 @@ function resolveLocalStorage(): Storage | undefined {
<p *ngIf="submitError" class="error">{{ submitError.message }}</p>
<div class="panel" *ngIf="error || submitError">
<button type="button" (click)="retryReconnect()" [disabled]="loading">{{ copy('common.retry') }}</button>
<button type="button" (click)="returnToJoin()" [disabled]="loading">{{ copy('common.back_to_join') }}</button>
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry</button>
<button type="button" (click)="returnToJoin()" [disabled]="loading">Back to join</button>
</div>
`,
})
export class PlayerShellComponent implements OnInit, OnDestroy {
locale = resolvePreferredLocale();
readonly clientHasNoAudioOutput = clientHasNoAudioOutput;
sessionCode = '';
nickname = '';
playerId = 0;
@@ -156,11 +110,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
private readonly sessionContextStore = createSessionContextStore(resolveLocalStorage());
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeLocale: (() => void) | null = null;
private restoreAudioGuard: (() => void) | null = null;
constructor() {
constructor(private readonly route: Pick<ActivatedRoute, 'snapshot'> = { snapshot: { data: {} } as never }) {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
this.connectionState = 'offline';
}
@@ -172,22 +123,13 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
this.locale = locale;
});
this.installSecondaryDeviceAudioGuard();
const hashRoute = window.location.hash.replace(/^#\/?/, '');
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
const codeFromRoute = match?.[1] ?? '';
const routeContext = this.route.snapshot.data['routeContext'] as RouteSessionContext | undefined;
const persistedContext = this.sessionContextStore.get();
if (persistedContext) {
this.playerId = persistedContext.playerId;
this.sessionToken = persistedContext.token;
}
const candidate = codeFromRoute || persistedContext?.sessionCode || '';
this.playerId = routeContext?.playerId ?? persistedContext?.playerId ?? 0;
this.sessionToken = routeContext?.token ?? persistedContext?.token ?? '';
const candidate = routeContext?.sessionCode || persistedContext?.sessionCode || '';
if (!candidate) {
return;
}
@@ -202,27 +144,6 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
window.removeEventListener('offline', this.handleOffline);
}
this.clearReconnectTimer();
this.clearStateSyncTimer();
this.unsubscribeLocale?.();
this.unsubscribeLocale = null;
this.restoreAudioGuard?.();
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 => {
@@ -233,7 +154,6 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
private readonly handleOffline = (): void => {
this.connectionState = 'offline';
this.clearReconnectTimer();
this.clearStateSyncTimer();
};
private clearReconnectTimer(): void {
@@ -243,144 +163,20 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
}
private clearStateSyncTimer(): void {
if (this.stateSyncTimer) {
clearTimeout(this.stateSyncTimer);
this.stateSyncTimer = null;
}
}
private installSecondaryDeviceAudioGuard(): void {
if (!this.clientHasNoAudioOutput || typeof window === 'undefined') {
return;
}
this.silenceExistingMediaElements();
const mediaPrototype = (window as Window & { HTMLMediaElement?: { prototype?: MediaPrototypeWithGuardState } }).HTMLMediaElement
?.prototype;
if (!mediaPrototype || typeof mediaPrototype.play !== 'function') {
return;
}
const guardState = mediaPrototype.__wppSecondaryDeviceAudioGuard__;
if (guardState) {
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.__wppSecondaryDeviceAudioGuard__ = {
originalPlay,
installs: 1,
};
}
this.restoreAudioGuard = () => {
const currentState = mediaPrototype.__wppSecondaryDeviceAudioGuard__;
if (!currentState) {
return;
}
currentState.installs -= 1;
if (currentState.installs <= 0) {
mediaPrototype.play = currentState.originalPlay;
delete mediaPrototype.__wppSecondaryDeviceAudioGuard__;
}
};
}
private silenceExistingMediaElements(): void {
if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') {
return;
}
const activeElements = document.querySelectorAll('audio,video') as
| NodeListOf<HTMLMediaElement>
| GuardableMediaElement[]
| undefined;
if (!activeElements || typeof (activeElements as { forEach?: unknown }).forEach !== 'function') {
return;
}
activeElements.forEach((element) => {
element.muted = true;
element.defaultMuted = true;
if (typeof element.volume === 'number') {
element.volume = 0;
}
element.pause?.();
});
}
private scheduleStateSync(): void {
this.clearStateSyncTimer();
if (!this.sessionCode.trim() || this.connectionState !== 'online' || !this.session) {
return;
}
if (this.session.session.status === 'finished') {
return;
}
this.stateSyncTimer = setTimeout(() => {
this.stateSyncTimer = null;
if (this.loading || this.connectionState !== 'online') {
this.scheduleStateSync();
return;
}
void this.refreshSession();
}, 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':
return this.copy('player.loading_join');
return 'Joining session… restoring your player state.';
case 'submit-lie':
return this.copy('player.loading_submit_lie');
return 'Submitting lie… waiting for guess phase.';
case 'submit-guess':
return this.copy('player.loading_submit_guess');
return 'Submitting guess… waiting for reveal.';
case 'refresh':
default:
return this.copy('player.loading_refresh');
return 'Loading latest session state…';
}
}
copy(key: string): string {
return t(key, this.locale);
}
private normalizeCode(value: string): string {
return value.trim().toUpperCase();
}
@@ -389,18 +185,15 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
if (error instanceof Error && error.message) {
return error.message;
}
return this.copy('common.unknown_error');
return 'Unknown error';
}
private markOnline(): void {
this.connectionState = 'online';
this.clearReconnectTimer();
this.scheduleStateSync();
}
private markConnectionIssue(error: unknown): void {
this.clearStateSyncTimer();
if (typeof navigator !== 'undefined' && !navigator.onLine) {
this.connectionState = 'offline';
return;
@@ -441,7 +234,6 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
returnToJoin(): void {
this.loadingTransition = null;
this.clearReconnectTimer();
this.clearStateSyncTimer();
this.connectionState = typeof navigator !== 'undefined' && !navigator.onLine ? 'offline' : 'online';
this.session = null;
this.finalLeaderboard = [];
@@ -468,25 +260,6 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
});
}
private syncRouteFromSession(): void {
if (!this.session) {
return;
}
const phase = this.gameplayPhase ?? this.session.session.status ?? 'lobby';
const code = this.normalizeCode(this.session.session.code || this.sessionCode);
if (!code) {
return;
}
const targetPath = `#/player/${encodeURIComponent(phase)}/${encodeURIComponent(code)}`;
if (typeof window === 'undefined' || window.location.hash === targetPath) {
return;
}
window.history.replaceState(window.history.state, '', targetPath);
}
private async request<T>(path: string, method: 'GET' | 'POST', payload?: unknown): Promise<T> {
const response = await fetch(path, {
method,
@@ -513,18 +286,20 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
try {
const state = await this.controller.hydrateLobby(this.sessionCode);
if (!state.session || state.errorMessage) {
throw new Error(state.errorMessage ?? this.copy('common.unknown_error'));
throw new Error(state.errorMessage ?? 'Unknown error');
}
this.session = state.session as SessionDetail;
this.sessionCode = this.session.session.code;
if (!syncHashRoute('player', this.session)) {
throw new Error(`Unsupported player phase status: ${this.session.session.status}`);
}
if (this.session.session.status !== 'guess') {
this.selectedGuess = '';
}
this.syncFinalLeaderboard();
this.syncRouteFromSession();
this.markOnline();
} catch (error) {
this.error = `${this.copy('player.session_refresh_failed')}: ${this.toMessage(error)}`;
this.error = `Session refresh failed: ${this.toMessage(error)}`;
this.markConnectionIssue(error);
} finally {
this.loading = false;
@@ -539,10 +314,13 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
try {
const state = await this.controller.joinLobby(this.sessionCode, this.nickname);
if (!state.session || state.errorMessage) {
throw new Error(state.errorMessage ?? this.copy('common.unknown_error'));
throw new Error(state.errorMessage ?? 'Unknown error');
}
this.session = state.session as SessionDetail;
this.sessionCode = this.session.session.code;
if (!syncHashRoute('player', this.session)) {
throw new Error(`Unsupported player phase status: ${this.session.session.status}`);
}
const sessionContext = this.sessionContextStore.get();
this.playerId = sessionContext?.playerId ?? 0;
@@ -551,10 +329,9 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.selectedGuess = '';
}
this.syncFinalLeaderboard();
this.syncRouteFromSession();
this.markOnline();
} catch (error) {
this.error = `${this.copy('player.join_failed')}: ${this.toMessage(error)}`;
this.error = `Join failed: ${this.toMessage(error)}`;
this.markConnectionIssue(error);
} finally {
this.loading = false;
@@ -563,7 +340,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;
@@ -582,7 +359,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
await this.refreshSession();
this.markOnline();
} catch (error) {
this.submitError = { kind: 'lie', message: `${this.copy('player.lie_submit_failed')}: ${this.toMessage(error)}` };
this.submitError = { kind: 'lie', message: `Lie submit failed: ${this.toMessage(error)}` };
this.markConnectionIssue(error);
} finally {
this.loading = false;
@@ -591,7 +368,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;
@@ -610,7 +387,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
await this.refreshSession();
this.markOnline();
} catch (error) {
this.submitError = { kind: 'guess', message: `${this.copy('player.guess_submit_failed')}: ${this.toMessage(error)}` };
this.submitError = { kind: 'guess', message: `Guess submit failed: ${this.toMessage(error)}` };
this.markConnectionIssue(error);
} finally {
this.loading = false;

View File

@@ -1,96 +0,0 @@
import { describe, expect, it } from 'vitest';
import { deriveGameplayPhase, transitionGameplayPhase } from '../../../src/spa/gameplay-phase-machine';
describe('gameplay phase machine sync guards', () => {
it('keeps explicit scoreboard status as scoreboard phase', () => {
const phase = deriveGameplayPhase({
session: {
code: 'ABCD12',
status: 'scoreboard',
host_id: 1,
current_round: 1,
players_count: 2,
},
round_question: null,
players: [],
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: false,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false,
},
},
});
expect(phase).toBe('scoreboard');
});
it('maps finished status to scoreboard phase fallback', () => {
const phase = deriveGameplayPhase({
session: {
code: 'ABCD12',
status: 'finished',
host_id: 1,
current_round: 1,
players_count: 2,
},
round_question: null,
players: [],
phase_view_model: {
status: 'finished',
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: false,
can_finish_game: false,
},
player: {
can_join: false,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: true,
},
},
});
expect(phase).toBe('scoreboard');
});
it('transitions reveal -> scoreboard on SCOREBOARD_READY', () => {
expect(transitionGameplayPhase('reveal', 'SCOREBOARD_READY')).toEqual({
phase: 'scoreboard',
changed: true,
});
});
});

View File

@@ -0,0 +1,29 @@
import { describe, expect, it, vi } from 'vitest';
import { syncHashRoute } from './gameplay-route-sync';
describe('gameplay route sync', () => {
it('maps host status to hash phase route with session context', () => {
vi.stubGlobal('window', {
location: { hash: '#/host/lobby/ABCD12' },
history: { replaceState: vi.fn() },
});
const ok = syncHashRoute('host', { session: { code: 'abcd12', status: 'guess' } });
expect(ok).toBe(true);
expect(window.history.replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12');
});
it('returns false for unsupported status to allow controlled fallback', () => {
vi.stubGlobal('window', {
location: { hash: '#/player/lobby/ABCD12' },
history: { replaceState: vi.fn() },
});
const ok = syncHashRoute('player', { session: { code: 'ABCD12', status: 'waiting' } });
expect(ok).toBe(false);
expect(window.history.replaceState).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,73 @@
import { deriveGameplayPhase } from '../../../src/spa/gameplay-phase-machine';
type ShellMode = 'host' | 'player';
interface SessionLike {
session: {
code: string;
status: string;
};
}
function normalizeCode(value: string): string {
return value.trim().toUpperCase();
}
function toRoutePhase(status: string): string | null {
if (status === 'lobby') {
return 'lobby';
}
const phase = deriveGameplayPhase({
session: { code: 'route-sync', status, host_id: null, current_round: 1, players_count: 0 },
players: [],
round_question: null,
phase_view_model: {
status,
round_number: 1,
players_count: 0,
constraints: {
min_players_to_start: 0,
max_players_mvp: 0,
min_players_reached: false,
max_players_allowed: false,
},
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: false,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false,
},
},
});
return phase;
}
export function syncHashRoute(mode: ShellMode, session: SessionLike): boolean {
if (typeof window === 'undefined') {
return true;
}
const code = normalizeCode(session.session.code);
const phase = toRoutePhase(session.session.status);
if (!code || !phase) {
return false;
}
const nextHash = `#/${mode}/${phase}/${encodeURIComponent(code)}`;
if (window.location.hash !== nextHash) {
window.history.replaceState(null, '', nextHash);
}
return true;
}

View File

@@ -1,87 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
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 };
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(),
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();
player.ngOnInit();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
player.ngOnDestroy();
await expect(mediaPrototype.play()).rejects.toThrow('primary host playback');
host.ngOnDestroy();
});
});

View File

@@ -1,137 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
type StorageLike = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
};
function storageMock(initial: Record<string, string> = {}): StorageLike {
const data = new Map<string, string>(Object.entries(initial));
return {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
data.set(key, value);
}),
};
}
describe('lobby i18n locale propagation', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.resetModules();
});
it('notifies subscribers immediately and on locale changes', async () => {
const localStorage = storageMock({ 'wpp.locale': 'en' });
vi.stubGlobal('window', {
location: { search: '' },
localStorage,
});
vi.stubGlobal('navigator', { language: 'en-US' });
const i18n = await import('./lobby-i18n');
const updates: string[] = [];
const unsubscribe = i18n.subscribeToLocaleChanges((locale) => updates.push(locale));
expect(updates).toEqual(['en']);
i18n.setPreferredLocale('da');
expect(updates).toEqual(['en', 'da']);
unsubscribe();
i18n.setPreferredLocale('en');
expect(updates).toEqual(['en', 'da']);
});
it('prefers backend-provided shell locale over browser defaults', async () => {
vi.stubGlobal('window', {
location: { search: '' },
localStorage: storageMock(),
});
vi.stubGlobal('document', {
body: { dataset: { wppLocale: 'da' } },
querySelector: vi.fn(() => null),
});
vi.stubGlobal('navigator', { language: 'en-US' });
const i18n = await import('./lobby-i18n');
expect(i18n.resolvePreferredLocale()).toBe('da');
});
it('falls back to default en translation when da key is intentionally missing', async () => {
vi.stubGlobal('window', {
location: { search: '' },
localStorage: storageMock({ 'wpp.locale': 'da' }),
});
vi.stubGlobal('navigator', { language: 'da-DK' });
const i18n = await import('./lobby-i18n');
const catalogModule = await import('../../../../shared/i18n/lobby.json');
const catalog = catalogModule.default as {
frontend: {
ui: {
common: {
refresh: {
en?: string;
da?: string;
};
};
};
};
};
const originalDa = catalog.frontend.ui.common.refresh.da;
catalog.frontend.ui.common.refresh.da = undefined;
try {
expect(i18n.t('common.refresh', 'da')).toBe(catalog.frontend.ui.common.refresh.en);
} finally {
catalog.frontend.ui.common.refresh.da = originalDa;
}
});
it('resolves baseline shell/game keys from shared namespaces', async () => {
vi.stubGlobal('window', {
location: { search: '' },
localStorage: storageMock({ 'wpp.locale': 'da' }),
});
vi.stubGlobal('navigator', { language: 'da-DK' });
const i18n = await import('./lobby-i18n');
const baselineKeys = [
'lobby.shell.title',
'lobby.shell.host_nav',
'lobby.shell.player_nav',
'lobby.shell.language_label',
'common.refresh',
'common.session_code',
'game.host.title',
'game.host.start_round',
'game.player.title',
'game.player.submit_guess',
] as const;
for (const key of baselineKeys) {
const value = i18n.t(key, 'da');
expect(value).toBeTypeOf('string');
expect(value.length).toBeGreaterThan(0);
expect(value).not.toBe(key);
}
});
it('exposes primary-only audio routing policy to clients', async () => {
vi.stubGlobal('window', {
location: { search: '' },
localStorage: storageMock({ 'wpp.locale': 'en' }),
});
vi.stubGlobal('navigator', { language: 'en-US' });
const i18n = await import('./lobby-i18n');
expect(i18n.clientHasNoAudioOutput).toBe(true);
});
});

View File

@@ -1,74 +0,0 @@
import {
DEFAULT_LOCALE,
LOBBY_I18N_CATALOG,
normalizeLocale,
type SupportedLocale,
translateCatalogPath,
} from '../../../shared/i18n/lobby-loader';
let activeLocale: SupportedLocale | null = null;
const localeSubscribers = new Set<(locale: SupportedLocale) => void>();
export { normalizeLocale };
export function resolvePreferredLocale(): SupportedLocale {
if (activeLocale) {
return activeLocale;
}
if (typeof window === 'undefined') {
activeLocale = DEFAULT_LOCALE;
return activeLocale;
}
const rootLocale =
typeof document !== 'undefined' ? document.querySelector<HTMLElement>('app-root')?.dataset?.['wppLocale'] : null;
const shellLocale = typeof document !== 'undefined' ? document.body?.dataset?.['wppLocale'] : null;
const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang');
const storedLocale = window.localStorage?.getItem?.('wpp.locale');
const browserLocale = typeof navigator !== 'undefined' ? navigator.language : '';
activeLocale = normalizeLocale(rootLocale || shellLocale || queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE);
return activeLocale;
}
export function setPreferredLocale(locale: string): SupportedLocale {
const normalized = normalizeLocale(locale);
activeLocale = normalized;
if (typeof window !== 'undefined') {
window.localStorage?.setItem?.('wpp.locale', normalized);
}
for (const subscriber of localeSubscribers) {
subscriber(normalized);
}
return normalized;
}
export function subscribeToLocaleChanges(callback: (locale: SupportedLocale) => void): () => void {
localeSubscribers.add(callback);
callback(resolvePreferredLocale());
return () => {
localeSubscribers.delete(callback);
};
}
function resolveCatalogPath(key: string): string {
if (key.startsWith('lobby.shell.')) {
return key.replace(/^lobby\.shell\./, 'app.');
}
if (key.startsWith('game.host.')) {
return key.replace(/^game\.host\./, 'host.');
}
if (key.startsWith('game.player.')) {
return key.replace(/^game\.player\./, 'player.');
}
return key;
}
export function t(key: string, locale: string): string {
return translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, resolveCatalogPath(key), locale);
}
export const clientHasNoAudioOutput = Boolean(LOBBY_I18N_CATALOG.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,12 +40,13 @@ 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;
}
if (status === 'lie' || status === 'guess' || status === 'reveal' || status === 'scoreboard') {
if (status === 'lie' || status === 'guess' || status === 'reveal') {
return status;
}
@@ -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,48 +1,31 @@
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
>;
type FrontendErrorKey = keyof typeof lobbyCatalog.frontend.errors;
type FrontendErrorKey = keyof typeof frontendErrors;
const frontendErrors = lobbyCatalog.frontend.errors;
function isFrontendErrorKey(value: string): value is FrontendErrorKey {
return value in frontendErrors;
const apiErrorMap: Record<string, FrontendErrorKey> = {
session_code_required: 'session_code_required',
session_not_found: 'session_not_found',
nickname_invalid: 'nickname_invalid',
nickname_taken: 'nickname_taken'
};
export function lobbyMessage(key: FrontendErrorKey): string {
return frontendErrors[key] ?? frontendErrors.unknown;
}
export function lobbyMessage(key: FrontendErrorKey, locale?: string): string {
const resolvedLocale = normalizeLocale(locale);
const translations = frontendErrors[key] as Record<string, string>;
if (translations[resolvedLocale]) {
return translations[resolvedLocale];
}
if (translations[DEFAULT_LOCALE]) {
return translations[DEFAULT_LOCALE];
}
return key;
}
export function lobbyMessageFromApiPayload(payload: unknown, fallbackKey: FrontendErrorKey, locale?: string): string {
export function lobbyMessageFromApiPayload(payload: unknown, fallbackKey: FrontendErrorKey): string {
if (!payload || typeof payload !== 'object') {
return lobbyMessage(fallbackKey, locale);
return lobbyMessage(fallbackKey);
}
const record = payload as Record<string, unknown>;
const code = typeof record.error_code === 'string' ? record.error_code : '';
const payloadLocale = typeof record.locale === 'string' ? record.locale : locale;
const mappedKey = code ? backendToFrontendErrorKeys[code] : undefined;
if (mappedKey && isFrontendErrorKey(mappedKey)) {
return lobbyMessage(mappedKey, payloadLocale);
const mappedKey = apiErrorMap[code];
if (!mappedKey) {
return lobbyMessage(fallbackKey);
}
if (isFrontendErrorKey(code)) {
return lobbyMessage(code, payloadLocale);
}
return lobbyMessage(fallbackKey, payloadLocale);
return lobbyMessage(mappedKey);
}

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,68 +0,0 @@
import { describe, expect, it } from 'vitest';
import lobbyCatalog from '../../shared/i18n/lobby.json';
import { lobbyMessage, lobbyMessageFromApiPayload } from '../src/spa/lobby-i18n';
describe('shared i18n keyspace contract', () => {
it('keeps en as default and da/en matrix for frontend error keys', () => {
expect(lobbyCatalog.locales.default).toBe('en');
expect(lobbyCatalog.locales.supported).toEqual(expect.arrayContaining(['en', 'da']));
for (const [key, translations] of Object.entries(lobbyCatalog.frontend.errors)) {
expect(translations.en, `${key} missing en`).toBeTruthy();
expect(translations.da, `${key} missing da`).toBeTruthy();
}
});
it('keeps backend error-code keyspace aligned with shared backend→frontend map and backend translations', () => {
for (const [code, backendKey] of Object.entries(lobbyCatalog.backend.error_codes)) {
const frontendKey =
lobbyCatalog.contract.backend_to_frontend_error_keys[
code as keyof typeof lobbyCatalog.contract.backend_to_frontend_error_keys
];
expect(lobbyCatalog.backend.errors[backendKey as keyof typeof lobbyCatalog.backend.errors]).toBeDefined();
expect(frontendKey, `missing frontend mapping for ${code}`).toBeTruthy();
expect(lobbyCatalog.frontend.errors[frontendKey as keyof typeof lobbyCatalog.frontend.errors]).toBeDefined();
}
for (const [key, translations] of Object.entries(lobbyCatalog.backend.errors)) {
expect(translations.en, `${key} missing en`).toBeTruthy();
expect(translations.da, `${key} missing da`).toBeTruthy();
}
});
});
describe('lobbyMessage locale handling', () => {
it('uses english by default and falls back to default for unsupported locale', () => {
expect(lobbyMessage('session_code_required')).toBe('Session code is required.');
expect(lobbyMessage('session_code_required', 'fr')).toBe('Session code is required.');
});
it('resolves locale from api payload and maps known backend error codes directly', () => {
expect(
lobbyMessageFromApiPayload(
{ error_code: 'session_not_found', locale: 'da' },
'join_failed',
),
).toBe('Sessionskoden er ugyldig, eller sessionen findes ikke længere.');
});
it('uses shared backend→frontend key-map at runtime even when fallback key differs', () => {
expect(
lobbyMessageFromApiPayload(
{ error_code: 'session_not_joinable', locale: 'da' },
'start_round_failed',
),
).toBe('Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen.');
});
it('falls back to caller-provided fallback key for unknown backend error codes', () => {
expect(
lobbyMessageFromApiPayload(
{ error_code: 'unknown_backend_key', locale: 'da' },
'join_failed',
),
).toBe('Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen.');
});
});

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,100 +1,20 @@
import logging
import json
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__)
from django.http import JsonResponse
@lru_cache(maxsize=1)
def lobby_i18n_catalog() -> dict:
return shared_i18n_catalog()
def i18n_locale_config() -> tuple[str, tuple[str, ...]]:
return locale_config()
catalog_path = Path(__file__).resolve().parents[1] / "shared" / "i18n" / "lobby.json"
with catalog_path.open(encoding="utf-8") as handle:
return json.load(handle)
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
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]
requested = (get_language_from_request(request) or "").replace("_", "-").split("-", 1)[0].lower()
if requested in supported_locales:
return requested
return default_locale
def resolve_error_message(*, key: str, locale: str) -> str:
default_locale, _supported_locales = i18n_locale_config()
translations = lobby_i18n_error_messages().get(key)
if not isinstance(translations, dict):
LOGGER.warning("i18n key missing in shared catalog", extra={"key": key, "locale": locale})
return key
if locale in translations and translations[locale]:
return translations[locale]
if default_locale in translations and translations[default_locale]:
return translations[default_locale]
LOGGER.warning("i18n translation missing for key", extra={"key": key, "locale": locale})
return key
def api_error(request: HttpRequest, *, code: 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,
"locale": locale,
},
status=status,
)
def api_error(*, code: str, message: str, status: int) -> JsonResponse:
return JsonResponse({"error": message, "error_code": code}, 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;
@@ -93,8 +89,6 @@ var connectionRetryInFlight=false;
var playerShellFatalError=false;
var playerShellRecoverInFlight=false;
var playerCriticalHydrated=false;
function silencePlayerMediaElements(){if(typeof document==="undefined"||typeof document.querySelectorAll!=="function"){return;}var elements=document.querySelectorAll("audio,video");if(!elements||typeof elements.forEach!=="function"){return;}elements.forEach(function(element){if(!element){return;}element.muted=true;if(typeof element.pause==="function"){element.pause();}});}
function installSecondaryDeviceAudioGuard(){if(typeof window==="undefined"){return;}silencePlayerMediaElements();var mediaProto=window.HTMLMediaElement&&window.HTMLMediaElement.prototype;if(!mediaProto||typeof mediaProto.play!=="function"){return;}if(mediaProto.__wppSecondaryDeviceAudioGuardInstalled){return;}mediaProto.__wppSecondaryDeviceAudioGuardInstalled=true;mediaProto.__wppSecondaryDeviceAudioGuardOriginalPlay=mediaProto.play;mediaProto.play=function(){return Promise.resolve();};}
function setPlayerCriticalLoading(isLoading){var skeleton=document.getElementById("playerCriticalSkeleton");var view=document.getElementById("playerCriticalView");if(!skeleton||!view){return;}skeleton.style.display=isLoading?"block":"none";view.style.display=isLoading?"none":"block";}
function hydratePlayerCriticalView(data){var phaseEl=document.getElementById("playerCriticalPhase");var roundEl=document.getElementById("playerCriticalRound");var joinEl=document.getElementById("playerCriticalJoin");if(phaseEl){phaseEl.textContent="Fase: "+phaseLabel(currentSessionStatus||((data&&data.session&&data.session.status)||""));}
if(roundEl){roundEl.textContent="Round question: "+(rq()||"afventer");}
@@ -109,7 +103,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 +116,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.";}
@@ -159,7 +153,6 @@ function submitGuess(){if(guessSubmitted){return Promise.resolve({error:"guess_a
["code","nickname","playerId","sessionToken","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});field.addEventListener("change",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});});
window.addEventListener("error",function(event){setPlayerShellFatalError((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");setPlayerShellFatalError(detail);});
installSecondaryDeviceAudioGuard();
setPlayerCriticalLoading(true);
updatePhaseStatus();
updateGuessSubmitState();

View File

@@ -1,13 +1,13 @@
<!doctype html>
<html lang="{{ shell_locale|default:'en' }}">
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WPP SPA Shell</title>
<link rel="stylesheet" href="{{ spa_asset_base }}/styles.css?v={{ spa_asset_version|urlencode }}">
<link rel="stylesheet" href="{{ spa_asset_base }}/styles.css">
</head>
<body data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}" data-wpp-locale="{{ shell_locale|default:'en' }}">
<app-root data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}" data-wpp-locale="{{ shell_locale|default:'en' }}">Indlæser Angular app-shell…</app-root>
<script type="module" src="{{ spa_asset_base }}/main.js?v={{ spa_asset_version|urlencode }}"></script>
<body data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}">
<app-root data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}">Indlæser Angular app-shell…</app-root>
<script type="module" src="{{ spa_asset_base }}/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ from django.shortcuts import render
from fupogfakta.models import Category
from .feature_flags import use_spa_ui
from .i18n import lobby_i18n_catalog, resolve_locale
from .i18n import lobby_i18n_catalog
def _render_spa_shell(request, shell_route: str, shell_kind: str):
@@ -16,9 +16,7 @@ def _render_spa_shell(request, shell_route: str, shell_kind: str):
"shell_route": shell_route,
"shell_kind": shell_kind,
"spa_asset_base": settings.WPP_SPA_ASSET_BASE,
"spa_asset_version": getattr(settings, "WPP_SPA_ASSET_VERSION", "dev"),
"lobby_i18n": lobby_i18n_catalog(),
"shell_locale": resolve_locale(request),
},
)
@@ -40,7 +38,6 @@ def host_screen(request, spa_path=None):
{
"categories": categories,
"lobby_i18n": lobby_i18n_catalog(),
"shell_locale": resolve_locale(request),
},
)
@@ -49,8 +46,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
@@ -116,15 +128,15 @@ def join_session(request: HttpRequest) -> JsonResponse:
if not code:
return api_error(
request,
code="session_code_required",
code=ERROR_CODES.get("session_code_required", "session_code_required"),
message="Session code is required",
status=400,
)
if len(nickname) < 2 or len(nickname) > 40:
return api_error(
request,
code="nickname_invalid",
code=ERROR_CODES.get("nickname_invalid", "nickname_invalid"),
message="Nickname must be between 2 and 40 characters",
status=400,
)
@@ -132,22 +144,22 @@ def join_session(request: HttpRequest) -> JsonResponse:
session = GameSession.objects.get(code=code)
except GameSession.DoesNotExist:
return api_error(
request,
code="session_not_found",
code=ERROR_CODES.get("session_not_found", "session_not_found"),
message="Session not found",
status=404,
)
if session.status not in JOINABLE_STATUSES:
return api_error(
request,
code="session_not_joinable",
code=ERROR_CODES.get("session_not_joinable", "session_not_joinable"),
message="Session is not joinable",
status=400,
)
if Player.objects.filter(session=session, nickname__iexact=nickname).exists():
return api_error(
request,
code="nickname_taken",
code=ERROR_CODES.get("nickname_taken", "nickname_taken"),
message="Nickname already taken",
status=409,
)
@@ -178,8 +190,8 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(
request,
code="session_not_found",
code=ERROR_CODES.get("session_not_found", "session_not_found"),
message="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,
}
)
@@ -223,8 +251,8 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
if not category_slug:
return api_error(
request,
code="category_slug_required",
code=ERROR_CODES.get("category_slug_required", "category_slug_required"),
message="category_slug is required",
status=400,
)
@@ -234,36 +262,75 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(
request,
code="session_not_found",
code=ERROR_CODES.get("session_not_found", "session_not_found"),
message="Session not found",
status=404,
)
if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can start round"}, status=403)
if session.status != GameSession.Status.LOBBY:
return api_error(
request,
code="host_only_start_round",
status=403,
code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
message="Round can only be started from lobby",
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(
code=ERROR_CODES.get("category_not_found", "category_not_found"),
message="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 JsonResponse({"error": "Category has no active 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(
code=ERROR_CODES.get("round_start_invalid_phase", "round_start_invalid_phase"),
message="Round can only be started from lobby",
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(
code=ERROR_CODES.get("round_already_configured", "round_already_configured"),
message="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
@@ -273,31 +340,56 @@ def show_question(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_show_question",
status=403,
)
return JsonResponse({"error": "Only host can show question"}, status=403)
if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Question can only be shown in lie phase"}, status=400)
try:
transition = _show_question(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400)
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
return JsonResponse({"error": "Question already shown for this round"}, 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 JsonResponse({"error": "No available questions in category"}, 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 +402,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 +433,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 +461,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,
)
@@ -418,25 +473,13 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
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_mix_answers",
status=403,
)
return JsonResponse({"error": "Only host can mix answers"}, status=403)
if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return api_error(
request,
code="mix_answers_invalid_phase",
status=400,
)
return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400)
try:
round_question = RoundQuestion.objects.get(
@@ -445,48 +488,37 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
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)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return api_error(
request,
code="mix_answers_invalid_phase",
status=400,
)
return JsonResponse({"error": "Answers can only be mixed in lie or guess 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 JsonResponse({"error": "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 +545,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 +576,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 +600,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 +618,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 +634,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 +649,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 +683,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 +715,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 +757,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 +779,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 +798,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 +842,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 +853,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
@@ -32,7 +30,6 @@ INSTALLED_APPS = [
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
@@ -92,9 +89,7 @@ AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
LANGUAGE_CODE, _SUPPORTED_LOCALES = locale_config()
LANGUAGES = django_languages()
LOCALE_PATHS = [BASE_DIR / 'locale']
LANGUAGE_CODE = 'da'
TIME_ZONE = 'Europe/Copenhagen'
USE_I18N = True
USE_TZ = True
@@ -110,19 +105,11 @@ if USE_SPA_UI_RAW is None:
USE_SPA_UI_RAW = env('WPP_SPA_ENABLED', 'false')
USE_SPA_UI = USE_SPA_UI_RAW.lower() == 'true'
WPP_SPA_ASSET_BASE = env('WPP_SPA_ASSET_BASE', '/static/frontend/angular/browser').rstrip('/')
# Cache-busting token for SPA shell static asset URLs (querystring versioning).
# Set to release id / commit SHA in deploy env for deterministic invalidation.
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,132 +0,0 @@
#!/usr/bin/env python3
"""Read-only drift check for shared i18n key coverage.
Compares `shared/i18n/key-manifest.json` against `shared/i18n/lobby.json` and
fails fast when keyspaces drift between frontend/backend contract sections.
"""
from __future__ import annotations
import json
from pathlib import Path
import sys
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json"
MANIFEST_PATH = REPO_ROOT / "shared" / "i18n" / "key-manifest.json"
def _load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def _as_set(value: Any) -> set[str]:
if not isinstance(value, list):
return set()
return {str(item) for item in value}
def _require_translations(
errors: dict[str, Any],
locales: set[str],
label: str,
failures: list[str],
) -> None:
for key, translations in errors.items():
if not isinstance(translations, dict):
failures.append(f"{label}.{key} must be an object of locale->message")
continue
for locale in sorted(locales):
value = translations.get(locale)
if not isinstance(value, str) or not value.strip():
failures.append(f"{label}.{key} missing non-empty '{locale}' translation")
def main() -> int:
catalog = _load_json(CATALOG_PATH)
manifest = _load_json(MANIFEST_PATH)
failures: list[str] = []
manifest_locales = _as_set(manifest.get("locales"))
catalog_locales = _as_set(catalog.get("locales", {}).get("supported"))
if manifest_locales != catalog_locales:
failures.append(
"locale set mismatch: "
f"manifest={sorted(manifest_locales)} catalog={sorted(catalog_locales)}"
)
frontend_manifest = _as_set(manifest.get("frontend_error_keys"))
frontend_catalog = set(catalog.get("frontend", {}).get("errors", {}).keys())
if frontend_manifest != frontend_catalog:
failures.append(
"frontend error key mismatch: "
f"manifest-only={sorted(frontend_manifest - frontend_catalog)} "
f"catalog-only={sorted(frontend_catalog - frontend_manifest)}"
)
backend_code_manifest = _as_set(manifest.get("backend_error_codes"))
backend_code_catalog = set(catalog.get("backend", {}).get("error_codes", {}).keys())
if backend_code_manifest != backend_code_catalog:
failures.append(
"backend error code mismatch: "
f"manifest-only={sorted(backend_code_manifest - backend_code_catalog)} "
f"catalog-only={sorted(backend_code_catalog - backend_code_manifest)}"
)
backend_key_manifest = _as_set(manifest.get("backend_error_keys"))
backend_key_catalog = set(catalog.get("backend", {}).get("errors", {}).keys())
if backend_key_manifest != backend_key_catalog:
failures.append(
"backend error key mismatch: "
f"manifest-only={sorted(backend_key_manifest - backend_key_catalog)} "
f"catalog-only={sorted(backend_key_catalog - backend_key_manifest)}"
)
backend_to_frontend = catalog.get("contract", {}).get("backend_to_frontend_error_keys", {})
if not isinstance(backend_to_frontend, dict):
failures.append("contract.backend_to_frontend_error_keys must be an object")
backend_to_frontend = {}
for code in sorted(backend_code_catalog):
frontend_key = backend_to_frontend.get(code)
if frontend_key is None:
failures.append(f"missing contract mapping for backend code '{code}'")
continue
if frontend_key not in frontend_catalog:
failures.append(
f"mapping for backend code '{code}' points to unknown frontend key '{frontend_key}'"
)
allowed_contract_only_codes = _as_set(manifest.get("allowed_contract_only_backend_codes"))
unknown_mapping_codes = set(backend_to_frontend.keys()) - backend_code_catalog
disallowed_unknown_mapping_codes = unknown_mapping_codes - allowed_contract_only_codes
if disallowed_unknown_mapping_codes:
failures.append(
"contract contains mappings for unknown backend codes: "
f"{sorted(disallowed_unknown_mapping_codes)}"
)
_require_translations(catalog.get("frontend", {}).get("errors", {}), manifest_locales, "frontend.errors", failures)
_require_translations(catalog.get("backend", {}).get("errors", {}), manifest_locales, "backend.errors", failures)
if failures:
print("i18n drift check FAILED")
for failure in failures:
print(f" - {failure}")
return 1
print("i18n drift check OK")
print(f" - locales: {sorted(manifest_locales)}")
print(f" - frontend error keys: {len(frontend_catalog)}")
print(f" - backend error codes: {len(backend_code_catalog)}")
print(f" - backend error keys: {len(backend_key_catalog)}")
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())

Some files were not shown because too many files have changed in this diff Show More