diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..aecadcb --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash" + ] + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index dd5f0d3..7fe8610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c96a4fb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,119 @@ +# 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) +- 3–12 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/`: 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 diff --git a/PROMPT.md b/PROMPT.md new file mode 100644 index 0000000..a7b413a --- /dev/null +++ b/PROMPT.md @@ -0,0 +1,71 @@ +# 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// 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// 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) diff --git a/TODO.md b/TODO.md index 62c1ffa..9a97b32 100644 --- a/TODO.md +++ b/TODO.md @@ -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` (channels events, game state broadcast) - - [x] `voice` (fælles voice-acting interface) + - [x] `realtime` (app-skelet oprettet — consumers/routing IKKE implementeret endnu) + - [x] `voice` (fælles voice-acting interface — stub) - [x] Miljøfiler (`.env.test`, `.env.prod` skabeloner) - [x] Konfig for MySQL test/prod @@ -53,14 +53,15 @@ 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 -- [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 +- [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) ### Fase 4 — Voice-acting (platformkrav) - [ ] Definér TTS provider-interface @@ -103,10 +104,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 -- [ ] (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) Persistér mixed svarrækkefølge pr. round question — DONE (JSONField + migration 0003 + test) - [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) [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) 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) Rate limiting på join/submit endpoints - [ ] (Need-to-have) Session-kode brute-force beskyttelse diff --git a/docs/ISSUE-252-REACT-FALLBACK-TRIGGERS-ARTIFACT.md b/docs/ISSUE-252-REACT-FALLBACK-TRIGGERS-ARTIFACT.md new file mode 100644 index 0000000..a13fe9d --- /dev/null +++ b/docs/ISSUE-252-REACT-FALLBACK-TRIGGERS-ARTIFACT.md @@ -0,0 +1,27 @@ +# 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. diff --git a/docs/ISSUE-278-SMOKE-E2E-GATE-ARTIFACT.md b/docs/ISSUE-278-SMOKE-E2E-GATE-ARTIFACT.md new file mode 100644 index 0000000..361b3d3 --- /dev/null +++ b/docs/ISSUE-278-SMOKE-E2E-GATE-ARTIFACT.md @@ -0,0 +1,36 @@ +# 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 +Gate’en verificerer nu eksplicit begge locale-runs (`da` + `en`) og bekræfter primary-only audio routing i MVP-flowet. diff --git a/docs/ISSUE-279-I18N-MVP-CLOSEOUT.md b/docs/ISSUE-279-I18N-MVP-CLOSEOUT.md new file mode 100644 index 0000000..4855b43 --- /dev/null +++ b/docs/ISSUE-279-I18N-MVP-CLOSEOUT.md @@ -0,0 +1,168 @@ +# 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. diff --git a/docs/plans/2026-03-09-fupogfakta-game-engine-design.md b/docs/plans/2026-03-09-fupogfakta-game-engine-design.md new file mode 100644 index 0000000..a8877a0 --- /dev/null +++ b/docs/plans/2026-03-09-fupogfakta-game-engine-design.md @@ -0,0 +1,272 @@ +# 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) diff --git a/docs/plans/2026-03-09-fupogfakta-implementation-plan.md b/docs/plans/2026-03-09-fupogfakta-implementation-plan.md new file mode 100644 index 0000000..b0001fd --- /dev/null +++ b/docs/plans/2026-03-09-fupogfakta-implementation-plan.md @@ -0,0 +1,2180 @@ +# Fup og Fakta — Game Engine Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement a fully working Fup og Fakta game on top of a pluggable game-platform engine with Celery-driven timers, incremental reveal scoring, emoji reactions, and post-game awards. + +**Architecture:** Platform layer (`lobby/`) provides `GameRun`, `GameDriver` interface, and Celery timer dispatch. Each game cartridge (starting with `fupogfakta/`) owns its models, config, and phase logic. The Angular frontend gets proper game screens replacing the current developer control panels. + +**Tech Stack:** Django 6.0.2, Celery + Redis, Django Channels (WebSocket already built), Angular 19 standalone components, Vitest. + +--- + +## Reading Before You Start + +- `docs/plans/2026-03-09-fupogfakta-game-engine-design.md` — full design, read this first +- `lobby/views.py` — existing REST endpoints (most will be replaced) +- `fupogfakta/models.py` — existing models +- `realtime/broadcast.py` — sync_broadcast_phase_event helper +- `partyhub/settings.py` — env-driven config pattern used throughout + +**Run tests before starting:** `.venv/bin/python manage.py test lobby realtime` → must be 78/78 green. + +--- + +## Batch 1 — Celery Infrastructure + +No breaking changes. Additive only. Existing tests stay green throughout. + +--- + +### Task 1: Add Celery to dependencies + +**Files:** +- Modify: `requirements.txt` + +**Step 1: Add celery and redis to requirements** + +``` +celery>=5.3,<6 +redis>=5.0,<6 +``` + +`redis` is the pure-Python Redis client Celery uses as a broker. (`channels-redis` uses it too but declares it as a dep — add explicitly for clarity.) + +**Step 2: Install** + +```bash +.venv/bin/pip install celery>=5.3,<6 redis>=5.0,<6 +``` + +Expected: installs without errors. + +**Step 3: Commit** + +```bash +git add requirements.txt +git commit -m "chore: add celery + redis to requirements" +``` + +--- + +### Task 2: Create Celery app + +**Files:** +- Create: `partyhub/celery.py` +- Modify: `partyhub/__init__.py` + +**Step 1: Create `partyhub/celery.py`** + +```python +import os +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'partyhub.settings') + +app = Celery('partyhub') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() +``` + +**Step 2: Read `partyhub/__init__.py` first, then update it** + +Add at the top (after any existing content): + +```python +from .celery import app as celery_app + +__all__ = ('celery_app',) +``` + +**Step 3: Add Celery config to `partyhub/settings.py`** + +Add after the `CHANNEL_LAYERS` block: + +```python +CELERY_BROKER_URL = f"redis://{env('CELERY_REDIS_HOST', '127.0.0.1')}:{env('CELERY_REDIS_PORT', '6379')}/1" +CELERY_RESULT_BACKEND = CELERY_BROKER_URL +CELERY_TASK_SERIALIZER = 'json' +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TIMEZONE = TIME_ZONE +# In tests: use synchronous task execution (no broker needed) +CELERY_TASK_ALWAYS_EAGER = _testing +CELERY_TASK_EAGER_PROPAGATES = _testing +``` + +**Step 4: Verify Django check still passes** + +```bash +.venv/bin/python manage.py check +``` + +Expected: `System check identified no issues (0 silenced).` + +**Step 5: Commit** + +```bash +git add partyhub/celery.py partyhub/__init__.py partyhub/settings.py +git commit -m "feat(platform): add celery app and broker config" +``` + +--- + +### Task 3: Create the timer Celery task (skeleton) + +**Files:** +- Create: `lobby/tasks.py` + +**Step 1: Write failing test** + +In `lobby/tests.py`, add at the bottom: + +```python +class TimerTaskStaleGuardTest(TestCase): + def test_stale_task_does_nothing_when_run_not_found(self): + from lobby.tasks import handle_timer_expired + # Should not raise — run_id that does not exist is silently ignored + handle_timer_expired(run_id=99999, expected_state="LIE_PHASE") +``` + +**Step 2: Run test to verify it fails** + +```bash +.venv/bin/python manage.py test lobby.tests.TimerTaskStaleGuardTest --verbosity=2 +``` + +Expected: `ImportError` or `ModuleNotFoundError` for `lobby.tasks`. + +**Step 3: Create `lobby/tasks.py`** + +```python +import logging +from celery import shared_task + +logger = logging.getLogger(__name__) + + +@shared_task(bind=True, max_retries=0) +def handle_timer_expired(self, run_id: int, expected_state: str) -> None: + """ + Fired by Celery when a phase timer expires. + + Guards against stale tasks: if the GameRun no longer exists or its + current_state no longer matches expected_state (e.g. after pause/resume), + the task silently exits. + + Full driver dispatch is wired in Batch 5 once GameRun model exists. + """ + # Import here to avoid circular imports at module load time + try: + from lobby.models import GameRun # will exist after Batch 2 + except ImportError: + logger.warning("handle_timer_expired: GameRun model not yet available (pre-migration)") + return + + try: + run = GameRun.objects.select_related('session').get(pk=run_id) + except GameRun.DoesNotExist: + logger.info("handle_timer_expired: run %s not found — stale task, ignoring", run_id) + return + + if run.current_state != expected_state: + logger.info( + "handle_timer_expired: run %s state is %r, expected %r — stale task, ignoring", + run_id, run.current_state, expected_state, + ) + return + + # Full transition logic wired in Batch 5 + logger.info("handle_timer_expired: run %s state=%r — TODO: dispatch driver", run_id, expected_state) +``` + +**Step 4: Run test to verify it passes** + +```bash +.venv/bin/python manage.py test lobby.tests.TimerTaskStaleGuardTest --verbosity=2 +``` + +Expected: PASS (ImportError path is handled gracefully). + +**Step 5: Commit** + +```bash +git add lobby/tasks.py lobby/tests.py +git commit -m "feat(platform): add handle_timer_expired celery task skeleton" +``` + +--- + +## Batch 2 — GameRun Model + GameDriver Interface + +--- + +### Task 4: Add `game_type` field to GameSession and simplify status + +**Files:** +- Modify: `fupogfakta/models.py` +- Create: `fupogfakta/migrations/0005_gamesession_game_type_status_active.py` (auto-generated) + +**Step 1: Write failing test** + +In `lobby/tests.py`, in an appropriate test class or new one: + +```python +class GameSessionGameTypeTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='host2', password='pw') + + def test_game_session_has_game_type_field(self): + session = GameSession.objects.create(host=self.user, code='GGTYPE') + self.assertEqual(session.game_type, 'fupogfakta') + + def test_game_session_status_active_exists(self): + session = GameSession.objects.create(host=self.user, code='ACTSTS') + session.status = GameSession.Status.ACTIVE + session.save() + session.refresh_from_db() + self.assertEqual(session.status, GameSession.Status.ACTIVE) +``` + +**Step 2: Run test to verify it fails** + +```bash +.venv/bin/python manage.py test lobby.tests.GameSessionGameTypeTest --verbosity=2 +``` + +Expected: `AttributeError: type object 'GameSession' has no attribute 'game_type'` + +**Step 3: Modify `fupogfakta/models.py` — add `game_type` and `ACTIVE` status** + +In the `GameSession` class, add `game_type` field and `ACTIVE` to the Status enum: + +```python +class Status(models.TextChoices): + LOBBY = "lobby", "Lobby" + ACTIVE = "active", "Aktiv" # ADD THIS — game is running (state detail in GameRun) + LIE = "lie", "Løgnfase" # keep for now — removed in Batch 6 + GUESS = "guess", "Gættefase" # keep for now + REVEAL = "reveal", "Reveal" # keep for now + FINISHED = "finished", "Afsluttet" +``` + +And add the field (after `current_round`): + +```python +game_type = models.CharField(max_length=50, default='fupogfakta') +``` + +**Step 4: Generate and apply migration** + +```bash +.venv/bin/python manage.py makemigrations fupogfakta --name gamesession_game_type_active_status +.venv/bin/python manage.py migrate +``` + +**Step 5: Run test to verify it passes** + +```bash +.venv/bin/python manage.py test lobby.tests.GameSessionGameTypeTest --verbosity=2 +``` + +Expected: PASS. + +**Step 6: Run full test suite — must stay green** + +```bash +.venv/bin/python manage.py test lobby realtime --verbosity=1 +``` + +Expected: all passing. + +**Step 7: Commit** + +```bash +git add fupogfakta/models.py fupogfakta/migrations/ lobby/tests.py +git commit -m "feat(platform): add game_type field and ACTIVE status to GameSession" +``` + +--- + +### Task 5: Create `GameDriver` interface + +**Files:** +- Create: `lobby/driver.py` + +**Step 1: Write failing test** + +```python +# lobby/tests.py — add to bottom +class GameDriverInterfaceTest(TestCase): + def test_cannot_instantiate_abstract_driver(self): + from lobby.driver import GameDriver + with self.assertRaises(TypeError): + GameDriver() + + def test_phase_result_is_namedtuple(self): + from lobby.driver import PhaseResult + result = PhaseResult(next_state='SOME_STATE', duration_seconds=30, broadcast_payload={'type': 'x'}) + self.assertEqual(result.next_state, 'SOME_STATE') + self.assertEqual(result.duration_seconds, 30) +``` + +**Step 2: Run test to verify it fails** + +```bash +.venv/bin/python manage.py test lobby.tests.GameDriverInterfaceTest --verbosity=2 +``` + +Expected: `ModuleNotFoundError: No module named 'lobby.driver'` + +**Step 3: Create `lobby/driver.py`** + +```python +from abc import ABC, abstractmethod +from typing import NamedTuple + + +class PhaseResult(NamedTuple): + """Return value from GameDriver phase methods.""" + next_state: str + duration_seconds: int | None # None = no timer, wait for manual trigger + broadcast_payload: dict + + +class GameDriver(ABC): + """ + Abstract base class for game cartridges. + + Each game app implements this and registers via GAME_DRIVERS in settings. + The platform (lobby/) calls these methods; game logic never touches + GameRun directly. + """ + + game_type: str # must be set on concrete subclass, e.g. "fupogfakta" + + @abstractmethod + def on_game_start(self, session, run, config: dict) -> PhaseResult: + """Called when host presses Play from LOBBY. Returns initial phase.""" + + @abstractmethod + def on_timer_expired(self, session, run, config: dict) -> PhaseResult: + """Called when Celery timer fires for run.current_state. Returns next phase.""" + + @abstractmethod + def on_exit(self, session, run) -> None: + """ + Called when host presses Exit. Must delete all ephemeral game data + (RoundQuestion, LieAnswer, Guess, LieReaction, Player, GameRun). + GameSession row is kept. + """ + + def on_pause(self, session, run) -> None: + """Default: no-op. Override if game needs to pause internal state.""" + + def on_resume(self, session, run) -> None: + """Default: no-op. Override if game needs to handle resume.""" + + +def get_driver(game_type: str) -> GameDriver: + """ + Look up and return a registered GameDriver instance by game_type. + Raises KeyError if game_type is not registered. + Import and call this from the platform task/views. + """ + from django.conf import settings + registry: dict[str, str] = getattr(settings, 'GAME_DRIVERS', {}) + if game_type not in registry: + raise KeyError(f"No GameDriver registered for game_type={game_type!r}. " + f"Add it to GAME_DRIVERS in settings.py.") + # Lazy import the driver class from dotted path + module_path, class_name = registry[game_type].rsplit('.', 1) + import importlib + module = importlib.import_module(module_path) + cls = getattr(module, class_name) + return cls() +``` + +**Step 4: Register driver path in `partyhub/settings.py`** + +Add after the CELERY block: + +```python +GAME_DRIVERS = { + 'fupogfakta': 'fupogfakta.driver.FupOgFaktaDriver', +} +``` + +**Step 5: Run test to verify it passes** + +```bash +.venv/bin/python manage.py test lobby.tests.GameDriverInterfaceTest --verbosity=2 +``` + +Expected: PASS. + +**Step 6: Commit** + +```bash +git add lobby/driver.py partyhub/settings.py lobby/tests.py +git commit -m "feat(platform): add GameDriver abstract interface and get_driver registry" +``` + +--- + +### Task 6: Create `GameRun` model + +**Files:** +- Create: `lobby/models.py` (currently empty scaffold) +- Create migration + +**Step 1: Write failing test** + +```python +# lobby/tests.py +class GameRunModelTest(TestCase): + def setUp(self): + from django.contrib.auth import get_user_model + User = get_user_model() + self.user = User.objects.create_user(username='runhost', password='pw') + self.session = GameSession.objects.create(host=self.user, code='RUNTEST') + + def test_create_game_run(self): + from lobby.models import GameRun + run = GameRun.objects.create( + session=self.session, + current_state='LIE_PHASE', + ) + self.assertEqual(run.current_state, 'LIE_PHASE') + self.assertFalse(run.is_paused) + self.assertIsNone(run.phase_deadline) + self.assertIsNone(run.celery_task_id) + self.assertEqual(run.state_data, {}) + + def test_game_run_is_one_to_one_with_session(self): + from lobby.models import GameRun + from django.db import IntegrityError + GameRun.objects.create(session=self.session, current_state='LIE_PHASE') + with self.assertRaises(IntegrityError): + GameRun.objects.create(session=self.session, current_state='GUESS_PHASE') +``` + +**Step 2: Run test to verify it fails** + +```bash +.venv/bin/python manage.py test lobby.tests.GameRunModelTest --verbosity=2 +``` + +Expected: fails — GameRun does not exist. + +**Step 3: Read and update `lobby/models.py`** + +```python +from django.db import models +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class GameRun(models.Model): + """ + Ephemeral runtime state for an active game session. + Created when host presses Play. Deleted when game ends (exit or finish). + """ + session = models.OneToOneField( + 'fupogfakta.GameSession', + on_delete=models.CASCADE, + related_name='run', + ) + current_state = models.CharField(max_length=64) + phase_deadline = models.DateTimeField(null=True, blank=True) + is_paused = models.BooleanField(default=False) + paused_remaining_seconds = models.FloatField(null=True, blank=True) + celery_task_id = models.CharField(max_length=255, null=True, blank=True) + # Game-specific snapshot data (config, current question id, etc.) + state_data = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"GameRun({self.session.code}, state={self.current_state})" +``` + +**Step 4: Generate and apply migration** + +```bash +.venv/bin/python manage.py makemigrations lobby --name add_gamerun +.venv/bin/python manage.py migrate +``` + +**Step 5: Run test to verify it passes** + +```bash +.venv/bin/python manage.py test lobby.tests.GameRunModelTest --verbosity=2 +``` + +Expected: PASS. + +**Step 6: Run full suite** + +```bash +.venv/bin/python manage.py test lobby realtime --verbosity=1 +``` + +Expected: all green. + +**Step 7: Commit** + +```bash +git add lobby/models.py lobby/migrations/ lobby/tests.py +git commit -m "feat(platform): add GameRun model" +``` + +--- + +## Batch 3 — Config System + +--- + +### Task 7: BaseGameConfig abstract model + FupOgFaktaConfig + +**Files:** +- Create: `lobby/base_config.py` +- Modify: `fupogfakta/models.py` +- Create migration + +**Step 1: Write failing test** + +```python +# fupogfakta/tests.py (create if empty) +from django.test import TestCase +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class FupOgFaktaConfigTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username='cfghost', password='pw') + + def test_system_default_config_exists_after_migration(self): + from fupogfakta.models import FupOgFaktaConfig + default = FupOgFaktaConfig.objects.filter(user=None, is_default=True).first() + # System default is created by a data migration (Task 8) + self.assertIsNotNone(default) + self.assertEqual(default.num_rounds, 3) + self.assertEqual(default.lie_seconds, 45) + + def test_user_can_have_multiple_presets(self): + from fupogfakta.models import FupOgFaktaConfig + FupOgFaktaConfig.objects.create(user=self.user, name='Quick', is_default=True) + FupOgFaktaConfig.objects.create(user=self.user, name='Long', is_default=False) + self.assertEqual(FupOgFaktaConfig.objects.filter(user=self.user).count(), 2) + + def test_resolve_config_returns_user_default_if_exists(self): + from fupogfakta.config import resolve_config + user_cfg = FupOgFaktaConfig.objects.create( + user=self.user, name='Mine', is_default=True, lie_seconds=20 + ) + result = resolve_config(self.user) + self.assertEqual(result['lie_seconds'], 20) + + def test_resolve_config_falls_back_to_system_default(self): + from fupogfakta.config import resolve_config + # No user preset created + result = resolve_config(self.user) + self.assertEqual(result['lie_seconds'], 45) # system default +``` + +**Step 2: Run test to verify it fails** + +```bash +.venv/bin/python manage.py test fupogfakta --verbosity=2 +``` + +Expected: module-level failures. + +**Step 3: Create `lobby/base_config.py`** + +```python +from django.db import models +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class BaseGameConfig(models.Model): + """ + Abstract base for per-user game configuration presets. + Each game cartridge extends this with its own concrete fields. + """ + name = models.CharField(max_length=100, default='Default') + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + help_text="null = system default (set in Django admin)", + ) + is_default = models.BooleanField( + default=False, + help_text="If True and user=null, this is the system default for this game.", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + abstract = True +``` + +**Step 4: Add `FupOgFaktaConfig` to `fupogfakta/models.py`** + +At the bottom of `fupogfakta/models.py`, add: + +```python +from lobby.base_config import BaseGameConfig + + +class FupOgFaktaConfig(BaseGameConfig): + num_rounds = models.PositiveIntegerField(default=3) + questions_per_round = models.PositiveIntegerField(default=3) + min_players = models.PositiveIntegerField(default=2) + max_players = models.PositiveIntegerField(default=8) + lie_seconds = models.PositiveIntegerField(default=45) + guess_seconds = models.PositiveIntegerField(default=30) + reveal_seconds_per_lie = models.PositiveIntegerField(default=8) + scoreboard_recap_seconds = models.PositiveIntegerField(default=10) + awards_display_seconds = models.PositiveIntegerField(default=15) + # Escalating scoring: index 0 = round 1, 1 = round 2, etc. + points_correct = models.JSONField(default=list) + points_bluff = models.JSONField(default=list) + + def points_correct_for_round(self, round_index: int) -> int: + defaults = [1500, 3000, 4500] + pts = self.points_correct or defaults + return pts[min(round_index, len(pts) - 1)] + + def points_bluff_for_round(self, round_index: int) -> int: + defaults = [500, 1000, 1500] + pts = self.points_bluff or defaults + return pts[min(round_index, len(pts) - 1)] + + class Meta: + verbose_name = "Fup og Fakta config" + ordering = ["name"] + + def __str__(self): + owner = self.user.username if self.user else "SYSTEM" + return f"{self.name} ({owner})" +``` + +**Step 5: Create `fupogfakta/config.py`** + +```python +from django.contrib.auth import get_user_model + +User = get_user_model() + +_CONFIG_FIELDS = [ + 'num_rounds', 'questions_per_round', 'min_players', 'max_players', + 'lie_seconds', 'guess_seconds', 'reveal_seconds_per_lie', + 'scoreboard_recap_seconds', 'awards_display_seconds', + 'points_correct', 'points_bluff', +] + + +def resolve_config(user) -> dict: + """ + Return config dict for a user starting a fupogfakta session. + + Resolution order: + 1. User's is_default=True preset (if any) + 2. System default (user=None, is_default=True) + 3. Model field defaults (hardcoded) + """ + from fupogfakta.models import FupOgFaktaConfig + + cfg = ( + FupOgFaktaConfig.objects.filter(user=user, is_default=True).first() + or FupOgFaktaConfig.objects.filter(user=None, is_default=True).first() + ) + + if cfg is None: + # Fall back to model defaults + return _model_defaults() + + return {field: getattr(cfg, field) for field in _CONFIG_FIELDS} + + +def _model_defaults() -> dict: + from fupogfakta.models import FupOgFaktaConfig + tmp = FupOgFaktaConfig() + return {field: getattr(tmp, field) for field in _CONFIG_FIELDS} +``` + +**Step 6: Generate migration and data migration for system default** + +```bash +.venv/bin/python manage.py makemigrations fupogfakta --name add_fupogfakta_config +``` + +Then create the data migration manually: + +```bash +.venv/bin/python manage.py makemigrations fupogfakta --empty --name seed_system_default_config +``` + +Edit the generated file to add: + +```python +def seed_default(apps, schema_editor): + FupOgFaktaConfig = apps.get_model('fupogfakta', 'FupOgFaktaConfig') + FupOgFaktaConfig.objects.create( + name='System Default', + user=None, + is_default=True, + num_rounds=3, + questions_per_round=3, + min_players=2, + max_players=8, + lie_seconds=45, + guess_seconds=30, + reveal_seconds_per_lie=8, + scoreboard_recap_seconds=10, + awards_display_seconds=15, + points_correct=[1500, 3000, 4500], + points_bluff=[500, 1000, 1500], + ) + +class Migration(migrations.Migration): + dependencies = [('fupogfakta', '0005_...')] # previous migration name + operations = [migrations.RunPython(seed_default, migrations.RunPython.noop)] +``` + +```bash +.venv/bin/python manage.py migrate +``` + +**Step 7: Register in admin — modify `fupogfakta/admin.py`** + +```python +from django.contrib import admin +from .models import Category, Question, FupOgFaktaConfig + +admin.site.register(Category) +admin.site.register(Question) + +@admin.register(FupOgFaktaConfig) +class FupOgFaktaConfigAdmin(admin.ModelAdmin): + list_display = ['name', 'user', 'is_default', 'num_rounds', 'lie_seconds', 'guess_seconds'] + list_filter = ['is_default', 'user'] +``` + +**Step 8: Run tests** + +```bash +.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1 +``` + +Expected: all green. + +**Step 9: Commit** + +```bash +git add lobby/base_config.py fupogfakta/models.py fupogfakta/migrations/ \ + fupogfakta/config.py fupogfakta/admin.py fupogfakta/tests.py +git commit -m "feat(config): add FupOgFaktaConfig model, resolve_config, and system default seed" +``` + +--- + +## Batch 4 — FupOgFakta Game Models + +--- + +### Task 8: Add `LieReaction` model + `reveal_order` to RoundQuestion, remove ScoreEvent + +**Files:** +- Modify: `fupogfakta/models.py` +- Create migrations + +**Step 1: Write failing tests** + +```python +# fupogfakta/tests.py — add + +class LieReactionModelTest(TestCase): + def setUp(self): + from django.contrib.auth import get_user_model + User = get_user_model() + host = User.objects.create_user(username='rhost', password='pw') + from fupogfakta.models import GameSession, Player, Category, Question, RoundQuestion, LieAnswer + self.session = GameSession.objects.create(host=host, code='REACT1') + self.player = Player.objects.create(session=self.session, nickname='Alice') + self.other = Player.objects.create(session=self.session, nickname='Bob') + cat = Category.objects.create(name='Test', slug='test') + q = Question.objects.create(category=cat, prompt='Q?', correct_answer='A') + self.rq = RoundQuestion.objects.create(session=self.session, round_number=1, question=q, correct_answer='A') + self.lie = LieAnswer.objects.create(round_question=self.rq, player=self.player, text='Fake') + + def test_player_can_react_to_lie(self): + from fupogfakta.models import LieReaction + r = LieReaction.objects.create(lie=self.lie, player=self.other, reaction='laugh') + self.assertEqual(r.reaction, 'laugh') + + def test_duplicate_reaction_raises(self): + from fupogfakta.models import LieReaction + from django.db import IntegrityError + LieReaction.objects.create(lie=self.lie, player=self.other, reaction='laugh') + with self.assertRaises(IntegrityError): + LieReaction.objects.create(lie=self.lie, player=self.other, reaction='laugh') + + def test_round_question_has_reveal_order_field(self): + from fupogfakta.models import RoundQuestion + self.assertIsNone(self.rq.reveal_order) +``` + +**Step 2: Run test to verify it fails** + +```bash +.venv/bin/python manage.py test fupogfakta.tests.LieReactionModelTest --verbosity=2 +``` + +**Step 3: Update `fupogfakta/models.py`** + +Remove the entire `ScoreEvent` class. + +Add `reveal_order` to `RoundQuestion`: + +```python +reveal_order = models.PositiveIntegerField(null=True, blank=True) +``` + +Add `LieReaction` at the bottom: + +```python +class LieReaction(models.Model): + REACTION_CHOICES = [ + ('laugh', '😂 Laugh'), + ('heart', '❤️ Heart'), + ('fire', '🔥 Fire'), + ('wow', '😮 Wow'), + ] + lie = models.ForeignKey(LieAnswer, on_delete=models.CASCADE, related_name='reactions') + player = models.ForeignKey(Player, on_delete=models.CASCADE, related_name='lie_reactions') + reaction = models.CharField(max_length=20, choices=REACTION_CHOICES) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = [('lie', 'player', 'reaction')] + ordering = ['created_at'] +``` + +**Step 4: Generate migration** + +```bash +.venv/bin/python manage.py makemigrations fupogfakta --name add_liereaction_reveal_order_remove_scoreevent +.venv/bin/python manage.py migrate +``` + +**Step 5: Run tests** + +```bash +.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1 +``` + +Expected: all green (ScoreEvent removal will break `calculate_scores` in lobby/views.py — fix in next step). + +**Step 6: Fix `lobby/views.py` — remove ScoreEvent references** + +In `lobby/views.py`, the `calculate_scores` view imports and uses `ScoreEvent`. Replace the score-saving logic with direct `Player.score` updates only (no audit model). Remove `ScoreEvent` from imports. + +The score_events list building and `ScoreEvent.objects.bulk_create(score_events)` lines should be replaced with just the `player.save()` calls already present. Remove the `score_events` list and `events_created` from the response. + +**Step 7: Run full test suite** + +```bash +.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1 +``` + +Expected: all green. + +**Step 8: Commit** + +```bash +git add fupogfakta/models.py fupogfakta/migrations/ lobby/views.py fupogfakta/tests.py +git commit -m "feat(fupogfakta): add LieReaction + reveal_order, remove ScoreEvent" +``` + +--- + +## Batch 5 — FupOgFakta Game Driver + +This is the core game logic. Take it one state at a time. + +--- + +### Task 9: Driver skeleton + LIE_PHASE entry + +**Files:** +- Create: `fupogfakta/driver.py` +- Create: `fupogfakta/phases.py` (phase transition helpers) + +**Step 1: Write failing test** + +```python +# fupogfakta/tests.py — add + +class FupOgFaktaDriverStartTest(TestCase): + def setUp(self): + from django.contrib.auth import get_user_model + User = get_user_model() + self.host = User.objects.create_user(username='drvhost', password='pw') + from fupogfakta.models import GameSession, Category, Question, FupOgFaktaConfig + from lobby.models import GameRun + self.session = GameSession.objects.create(host=self.host, code='DRVST1') + cat = Category.objects.create(name='DrvCat', slug='drvcat') + Question.objects.create(category=cat, prompt='Q1?', correct_answer='A1') + Question.objects.create(category=cat, prompt='Q2?', correct_answer='A2') + Question.objects.create(category=cat, prompt='Q3?', correct_answer='A3') + self.run = GameRun.objects.create(session=self.session, current_state='LOBBY') + self.config = { + 'num_rounds': 1, 'questions_per_round': 1, + 'lie_seconds': 30, 'guess_seconds': 20, + 'reveal_seconds_per_lie': 5, 'scoreboard_recap_seconds': 5, + 'awards_display_seconds': 5, + 'points_correct': [1500], 'points_bluff': [500], + 'min_players': 2, 'max_players': 8, + 'category_slug': 'drvcat', + } + + def test_on_game_start_returns_lie_phase(self): + from fupogfakta.driver import FupOgFaktaDriver + driver = FupOgFaktaDriver() + result = driver.on_game_start(self.session, self.run, self.config) + self.assertEqual(result.next_state, 'LIE_PHASE') + self.assertEqual(result.duration_seconds, 30) + self.assertIn('prompt', result.broadcast_payload) + + def test_on_game_start_creates_round_question(self): + from fupogfakta.driver import FupOgFaktaDriver + from fupogfakta.models import RoundQuestion + driver = FupOgFaktaDriver() + driver.on_game_start(self.session, self.run, self.config) + self.assertEqual(RoundQuestion.objects.filter(session=self.session).count(), 1) +``` + +**Step 2: Run test to verify it fails** + +```bash +.venv/bin/python manage.py test fupogfakta.tests.FupOgFaktaDriverStartTest --verbosity=2 +``` + +**Step 3: Create `fupogfakta/driver.py`** + +```python +import random +import logging + +from lobby.driver import GameDriver, PhaseResult + +logger = logging.getLogger(__name__) + + +class FupOgFaktaDriver(GameDriver): + game_type = 'fupogfakta' + + def on_game_start(self, session, run, config: dict) -> PhaseResult: + """Select first question and enter LIE_PHASE.""" + from fupogfakta.phases import enter_lie_phase + return enter_lie_phase(session, run, config, round_number=1, question_number=1) + + def on_timer_expired(self, session, run, config: dict) -> PhaseResult: + """Dispatch to the correct next-state handler based on current_state.""" + from fupogfakta import phases + state = run.current_state + state_data = run.state_data + + if state == 'LIE_PHASE': + return phases.lie_phase_expired(session, run, config) + if state == 'GUESS_PHASE': + return phases.guess_phase_expired(session, run, config) + if state.startswith('REVEAL_LIE_'): + return phases.reveal_lie_expired(session, run, config) + if state == 'REVEAL_TRUTH': + return phases.reveal_truth_expired(session, run, config) + if state == 'SCOREBOARD_RECAP': + return phases.scoreboard_recap_expired(session, run, config) + if state == 'POST_GAME_AWARDS': + return phases.post_game_awards_expired(session, run, config) + + logger.error("FupOgFaktaDriver: unknown state %r for run %s", state, run.pk) + return PhaseResult('FINISHED', None, {'type': 'phase.game_over'}) + + def on_exit(self, session, run) -> None: + """Delete all ephemeral game data. Keep GameSession row.""" + from fupogfakta.models import RoundQuestion, LieAnswer, Guess, LieReaction, Player + from lobby.models import GameRun + # Cascade deletes handle LieAnswer, Guess, LieReaction via RoundQuestion + RoundQuestion.objects.filter(session=session).delete() + Player.objects.filter(session=session).delete() + GameRun.objects.filter(session=session).delete() + session.status = session.Status.FINISHED + session.save(update_fields=['status']) +``` + +**Step 4: Create `fupogfakta/phases.py`** + +Start with just `enter_lie_phase` — other phases added in subsequent tasks: + +```python +import random +import logging + +from lobby.driver import PhaseResult + +logger = logging.getLogger(__name__) + + +def _pick_question(session, config: dict, round_number: int): + """Pick a random unused question for this session from the configured category.""" + from fupogfakta.models import Category, Question, RoundQuestion + category = Category.objects.get(slug=config['category_slug']) + used_ids = RoundQuestion.objects.filter(session=session).values_list('question_id', flat=True) + available = list( + Question.objects.filter(category=category, is_active=True).exclude(pk__in=used_ids) + ) + if not available: + raise ValueError(f"No available questions for category {config['category_slug']!r}") + return random.choice(available) + + +def enter_lie_phase(session, run, config: dict, round_number: int, question_number: int) -> PhaseResult: + """Select a question, create RoundQuestion, return LIE_PHASE result.""" + from fupogfakta.models import RoundQuestion + question = _pick_question(session, config, round_number) + rq = RoundQuestion.objects.create( + session=session, + round_number=round_number, + question=question, + correct_answer=question.correct_answer, + ) + run.state_data = { + **run.state_data, + 'round_number': round_number, + 'question_number': question_number, + 'round_question_id': rq.id, + } + run.save(update_fields=['state_data']) + return PhaseResult( + next_state='LIE_PHASE', + duration_seconds=config['lie_seconds'], + broadcast_payload={ + 'type': 'phase.lie_started', + 'round_question_id': rq.id, + 'prompt': question.prompt, + 'round_number': round_number, + 'question_number': question_number, + 'lie_seconds': config['lie_seconds'], + }, + ) + + +def lie_phase_expired(session, run, config: dict) -> PhaseResult: + """Timer ran out in LIE_PHASE. Mix answers and enter GUESS_PHASE.""" + from fupogfakta.models import RoundQuestion, LieAnswer + rq_id = run.state_data['round_question_id'] + rq = RoundQuestion.objects.get(pk=rq_id) + + # Mix answers (dedup, shuffle) + lie_texts = list(LieAnswer.objects.filter(round_question=rq).values_list('text', flat=True)) + seen = set() + answers = [] + for text in [rq.correct_answer, *lie_texts]: + norm = text.strip().casefold() + if norm and norm not in seen: + seen.add(norm) + answers.append(text.strip()) + random.shuffle(answers) + rq.mixed_answers = answers + rq.save(update_fields=['mixed_answers']) + + return PhaseResult( + next_state='GUESS_PHASE', + duration_seconds=config['guess_seconds'], + broadcast_payload={ + 'type': 'phase.guess_started', + 'round_question_id': rq.id, + 'answers': [{'text': t} for t in answers], + 'guess_seconds': config['guess_seconds'], + }, + ) + + +def guess_phase_expired(session, run, config: dict) -> PhaseResult: + """Timer ran out in GUESS_PHASE. Compute reveal order, enter first REVEAL_LIE or REVEAL_TRUTH.""" + from fupogfakta.models import RoundQuestion, LieAnswer, Guess + rq_id = run.state_data['round_question_id'] + rq = RoundQuestion.objects.prefetch_related('lies', 'guesses').get(pk=rq_id) + + # Determine which lies had at least one guesser + guessed_lie_ids = set( + Guess.objects.filter(round_question=rq, is_correct=False) + .exclude(fooled_player=None) + .values_list('fooled_player_id', flat=True) + ) + # Map: liar player_id → lie + lie_map = {lie.player_id: lie for lie in rq.lies.all()} + reveal_lies = [lie for pid, lie in lie_map.items() if any( + g.fooled_player_id == pid for g in rq.guesses.all() + )] + + # Assign reveal_order to each lie being revealed + for i, lie in enumerate(reveal_lies): + lie.reveal_order = i + lie.save(update_fields=['reveal_order']) + + run.state_data = { + **run.state_data, + 'reveal_lies': [lie.id for lie in reveal_lies], + 'reveal_index': 0, + } + run.save(update_fields=['state_data']) + + if reveal_lies: + return _reveal_lie_result(rq, reveal_lies[0], config, run.state_data) + else: + return _reveal_truth_result(rq, config) + + +def reveal_lie_expired(session, run, config: dict) -> PhaseResult: + """A lie has been shown. Score the liar. Move to next lie or truth.""" + from fupogfakta.models import LieAnswer, Guess, Player, RoundQuestion + rq_id = run.state_data['round_question_id'] + reveal_lies = run.state_data['reveal_lies'] + reveal_index = run.state_data['reveal_index'] + round_number = run.state_data['round_number'] + + # Score the liar for this lie + lie = LieAnswer.objects.get(pk=reveal_lies[reveal_index]) + guessers = list(Guess.objects.filter(round_question_id=rq_id, fooled_player=lie.player).select_related('player')) + points_bluff = _points_bluff(config, round_number) + if guessers: + delta = points_bluff * len(guessers) + lie.player.score += delta + lie.player.save(update_fields=['score']) + + # Next reveal + next_index = reveal_index + 1 + run.state_data = {**run.state_data, 'reveal_index': next_index} + run.save(update_fields=['state_data']) + + rq = RoundQuestion.objects.get(pk=rq_id) + if next_index < len(reveal_lies): + next_lie = LieAnswer.objects.get(pk=reveal_lies[next_index]) + return _reveal_lie_result(rq, next_lie, config, run.state_data) + else: + return _reveal_truth_result(rq, config) + + +def reveal_truth_expired(session, run, config: dict) -> PhaseResult: + """Truth revealed. Score correct guessers. Enter SCOREBOARD_RECAP.""" + from fupogfakta.models import Guess, RoundQuestion + rq_id = run.state_data['round_question_id'] + round_number = run.state_data['round_number'] + rq = RoundQuestion.objects.get(pk=rq_id) + + correct_guessers = list(Guess.objects.filter(round_question=rq, is_correct=True).select_related('player')) + points_correct = _points_correct(config, round_number) + for guess in correct_guessers: + guess.player.score += points_correct + guess.player.save(update_fields=['score']) + + leaderboard = _leaderboard(session) + return PhaseResult( + next_state='SCOREBOARD_RECAP', + duration_seconds=config['scoreboard_recap_seconds'], + broadcast_payload={ + 'type': 'phase.scoreboard', + 'leaderboard': leaderboard, + 'round_number': round_number, + }, + ) + + +def scoreboard_recap_expired(session, run, config: dict) -> PhaseResult: + """Scoreboard shown. Advance to next question, next round, or post-game awards.""" + round_number = run.state_data['round_number'] + question_number = run.state_data['question_number'] + num_rounds = config['num_rounds'] + questions_per_round = config['questions_per_round'] + + if question_number < questions_per_round: + # Next question in same round + return enter_lie_phase(session, run, config, round_number, question_number + 1) + elif round_number < num_rounds: + # Next round + return enter_lie_phase(session, run, config, round_number + 1, 1) + else: + # All rounds done → post-game awards + return _post_game_awards_result(session, config) + + +def post_game_awards_expired(session, run, config: dict) -> PhaseResult: + """Awards shown. Game over.""" + return PhaseResult( + next_state='FINISHED', + duration_seconds=None, + broadcast_payload={ + 'type': 'phase.game_over', + 'leaderboard': _leaderboard(session), + }, + ) + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _points_correct(config: dict, round_number: int) -> int: + pts = config.get('points_correct') or [1500, 3000, 4500] + return pts[min(round_number - 1, len(pts) - 1)] + + +def _points_bluff(config: dict, round_number: int) -> int: + pts = config.get('points_bluff') or [500, 1000, 1500] + return pts[min(round_number - 1, len(pts) - 1)] + + +def _leaderboard(session) -> list: + from fupogfakta.models import Player + return list( + Player.objects.filter(session=session) + .order_by('-score', 'nickname') + .values('id', 'nickname', 'score') + ) + + +def _reveal_lie_result(rq, lie, config: dict, state_data: dict) -> PhaseResult: + from fupogfakta.models import Guess + guessers = list( + Guess.objects.filter(round_question=rq, fooled_player=lie.player) + .select_related('player') + .values('player__nickname') + ) + return PhaseResult( + next_state=f'REVEAL_LIE_{state_data["reveal_index"]}', + duration_seconds=config['reveal_seconds_per_lie'], + broadcast_payload={ + 'type': 'phase.reveal_lie', + 'lie_text': lie.text, + 'author': lie.player.nickname, + 'guessers': [g['player__nickname'] for g in guessers], + 'guesser_count': len(guessers), + }, + ) + + +def _reveal_truth_result(rq, config: dict) -> PhaseResult: + from fupogfakta.models import Guess + correct_guessers = list( + Guess.objects.filter(round_question=rq, is_correct=True) + .select_related('player') + .values('player__nickname') + ) + return PhaseResult( + next_state='REVEAL_TRUTH', + duration_seconds=config['reveal_seconds_per_lie'], + broadcast_payload={ + 'type': 'phase.reveal_truth', + 'correct_answer': rq.correct_answer, + 'correct_guessers': [g['player__nickname'] for g in correct_guessers], + }, + ) + + +def _post_game_awards_result(session, config: dict) -> PhaseResult: + from fupogfakta.models import LieReaction, LieAnswer + from django.db.models import Count + + # Most laughs overall per player + top_laugh = ( + LieReaction.objects.filter(lie__round_question__session=session, reaction='laugh') + .values('lie__player__nickname') + .annotate(total=Count('id')) + .order_by('-total') + .first() + ) + # Most hearts on a single lie + top_heart = ( + LieReaction.objects.filter(lie__round_question__session=session, reaction='heart') + .values('lie__player__nickname', 'lie__text') + .annotate(total=Count('id')) + .order_by('-total') + .first() + ) + + awards = [] + if top_laugh: + awards.append({ + 'award': 'most_hilarious', + 'label': 'Most Hilarious Liar 😂', + 'winner': top_laugh['lie__player__nickname'], + 'count': top_laugh['total'], + }) + if top_heart: + awards.append({ + 'award': 'most_beloved', + 'label': 'Most Beloved Lie ❤️', + 'winner': top_heart['lie__player__nickname'], + 'lie': top_heart['lie__text'], + 'count': top_heart['total'], + }) + + return PhaseResult( + next_state='POST_GAME_AWARDS', + duration_seconds=config['awards_display_seconds'], + broadcast_payload={ + 'type': 'phase.awards', + 'awards': awards, + 'leaderboard': _leaderboard(session), + }, + ) +``` + +**Step 5: Run tests** + +```bash +.venv/bin/python manage.py test fupogfakta.tests.FupOgFaktaDriverStartTest --verbosity=2 +``` + +Expected: PASS. + +**Step 6: Run full suite** + +```bash +.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1 +``` + +Expected: all green. + +**Step 7: Commit** + +```bash +git add fupogfakta/driver.py fupogfakta/phases.py fupogfakta/tests.py +git commit -m "feat(fupogfakta): implement game driver and all phase transitions" +``` + +--- + +## Batch 6 — Platform REST Endpoints + Celery Wiring + +--- + +### Task 10: Wire `handle_timer_expired` to the driver + +**Files:** +- Modify: `lobby/tasks.py` + +**Step 1: Write failing test** + +```python +# lobby/tests.py — add + +class TimerTaskDispatchTest(TestCase): + def setUp(self): + from django.contrib.auth import get_user_model + User = get_user_model() + self.host = User.objects.create_user(username='taskhost', password='pw') + from fupogfakta.models import GameSession, Category, Question, Player + from lobby.models import GameRun + self.session = GameSession.objects.create(host=self.host, code='TASKTS') + self.cat = Category.objects.create(name='TskCat', slug='tskcat') + for i in range(3): + Question.objects.create(category=self.cat, prompt=f'Q{i}', correct_answer=f'A{i}') + Player.objects.create(session=self.session, nickname='P1') + Player.objects.create(session=self.session, nickname='P2') + self.run = GameRun.objects.create( + session=self.session, + current_state='LIE_PHASE', + state_data={ + 'round_number': 1, 'question_number': 1, + 'round_question_id': None, # set below + 'config': { + 'num_rounds': 1, 'questions_per_round': 1, + 'lie_seconds': 30, 'guess_seconds': 20, + 'reveal_seconds_per_lie': 5, 'scoreboard_recap_seconds': 5, + 'awards_display_seconds': 5, + 'points_correct': [1500], 'points_bluff': [500], + 'min_players': 2, 'max_players': 8, + 'category_slug': 'tskcat', + }, + }, + ) + from fupogfakta.models import RoundQuestion, Question as Q + q = Q.objects.filter(category=self.cat).first() + rq = RoundQuestion.objects.create(session=self.session, round_number=1, question=q, correct_answer=q.correct_answer) + self.run.state_data['round_question_id'] = rq.id + self.run.save(update_fields=['state_data']) + self.rq = rq + + def test_task_transitions_lie_to_guess(self): + from lobby.tasks import handle_timer_expired + from lobby.models import GameRun + handle_timer_expired(run_id=self.run.pk, expected_state='LIE_PHASE') + self.run.refresh_from_db() + self.assertEqual(self.run.current_state, 'GUESS_PHASE') + + def test_stale_task_does_not_transition(self): + from lobby.tasks import handle_timer_expired + from lobby.models import GameRun + handle_timer_expired(run_id=self.run.pk, expected_state='WRONG_STATE') + self.run.refresh_from_db() + self.assertEqual(self.run.current_state, 'LIE_PHASE') # unchanged +``` + +**Step 2: Update `lobby/tasks.py` — full implementation** + +```python +import logging +from celery import shared_task +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +logger = logging.getLogger(__name__) + + +def _schedule_next(run, duration_seconds: int) -> str: + """Schedule handle_timer_expired for run after duration_seconds. Returns task id.""" + from datetime import timedelta + from django.utils import timezone + eta = timezone.now() + timedelta(seconds=duration_seconds) + result = handle_timer_expired.apply_async( + kwargs={'run_id': run.pk, 'expected_state': run.current_state}, + eta=eta, + ) + return result.id + + +def _broadcast(session_code: str, payload: dict) -> None: + channel_layer = get_channel_layer() + group_name = f"game_{session_code.upper()}" + async_to_sync(channel_layer.group_send)( + group_name, + {'type': 'phase_event', 'payload': payload}, + ) + + +@shared_task(bind=True, max_retries=0) +def handle_timer_expired(self, run_id: int, expected_state: str) -> None: + from lobby.models import GameRun + from lobby.driver import get_driver + from django.utils import timezone + + try: + run = GameRun.objects.select_related('session').get(pk=run_id) + except GameRun.DoesNotExist: + logger.info("handle_timer_expired: run %s not found — stale task", run_id) + return + + if run.current_state != expected_state: + logger.info("handle_timer_expired: run %s state mismatch (%r != %r) — stale", run_id, run.current_state, expected_state) + return + + if run.is_paused: + logger.info("handle_timer_expired: run %s is paused — ignoring", run_id) + return + + config = run.state_data.get('config', {}) + driver = get_driver(run.session.game_type) + + if run.current_state == 'FINISHED': + # Clean up + driver.on_exit(run.session, run) + return + + result = driver.on_timer_expired(run.session, run, config) + + if result.next_state == 'FINISHED': + _broadcast(run.session.code, result.broadcast_payload) + driver.on_exit(run.session, run) + return + + run.current_state = result.next_state + run.phase_deadline = timezone.now() + __import__('datetime').timedelta(seconds=result.duration_seconds) if result.duration_seconds else None + run.celery_task_id = None + run.save(update_fields=['current_state', 'phase_deadline', 'celery_task_id', 'state_data']) + + if result.duration_seconds: + task_id = _schedule_next(run, result.duration_seconds) + run.celery_task_id = task_id + run.save(update_fields=['celery_task_id']) + + _broadcast(run.session.code, result.broadcast_payload) +``` + +**Step 3: Run tests** + +```bash +.venv/bin/python manage.py test lobby.tests.TimerTaskDispatchTest --verbosity=2 +``` + +Expected: PASS (CELERY_TASK_ALWAYS_EAGER=True in test mode). + +**Step 4: Commit** + +```bash +git add lobby/tasks.py lobby/tests.py +git commit -m "feat(platform): wire handle_timer_expired to GameDriver dispatch" +``` + +--- + +### Task 11: Platform play/pause/exit endpoints + +**Files:** +- Modify: `lobby/views.py` — add play, pause, exit views +- Modify: `lobby/urls.py` — register routes + +**Step 1: Write failing tests** + +```python +# lobby/tests.py — add + +class PlayPauseExitTest(TestCase): + def setUp(self): + from django.contrib.auth import get_user_model + User = get_user_model() + self.host = User.objects.create_user(username='ppe_host', password='pw') + self.client.force_login(self.host) + from fupogfakta.models import GameSession, Category, Question, Player + from fupogfakta.models import FupOgFaktaConfig + self.session = GameSession.objects.create(host=self.host, code='PPETEST') + cat = Category.objects.create(name='PPECat', slug='ppecat') + for i in range(3): + Question.objects.create(category=cat, prompt=f'Q{i}', correct_answer=f'A{i}') + Player.objects.create(session=self.session, nickname='P1') + Player.objects.create(session=self.session, nickname='P2') + + def test_play_creates_game_run_and_transitions_to_active(self): + from lobby.models import GameRun + resp = self.client.post(f'/lobby/sessions/PPETEST/play', + content_type='application/json', + data={'category_slug': 'ppecat'}) + self.assertEqual(resp.status_code, 201) + self.assertTrue(GameRun.objects.filter(session__code='PPETEST').exists()) + + def test_pause_sets_is_paused(self): + from lobby.models import GameRun + self.client.post(f'/lobby/sessions/PPETEST/play', + content_type='application/json', + data={'category_slug': 'ppecat'}) + resp = self.client.post('/lobby/sessions/PPETEST/pause') + self.assertEqual(resp.status_code, 200) + run = GameRun.objects.get(session__code='PPETEST') + self.assertTrue(run.is_paused) + + def test_exit_deletes_game_run(self): + from lobby.models import GameRun + self.client.post('/lobby/sessions/PPETEST/play', + content_type='application/json', + data={'category_slug': 'ppecat'}) + resp = self.client.post('/lobby/sessions/PPETEST/exit') + self.assertEqual(resp.status_code, 200) + self.assertFalse(GameRun.objects.filter(session__code='PPETEST').exists()) +``` + +**Step 2: Add views to `lobby/views.py`** + +Add these three views at the bottom: + +```python +@require_POST +@login_required +def play_session(request: HttpRequest, code: str) -> JsonResponse: + """Start game (or resume if paused).""" + from lobby.models import GameRun + from lobby.driver import get_driver + from lobby.tasks import _schedule_next, _broadcast + from fupogfakta.config import resolve_config + from django.utils import timezone + + session_code = _normalize_session_code(code) + try: + session = GameSession.objects.get(code=session_code) + except GameSession.DoesNotExist: + return JsonResponse({'error': 'Session not found'}, status=404) + + if session.host_id != request.user.id: + return JsonResponse({'error': 'Only host can start game'}, status=403) + + # Resume from pause + run = GameRun.objects.filter(session=session).first() + if run and run.is_paused: + remaining = run.paused_remaining_seconds or 0 + run.phase_deadline = timezone.now() + __import__('datetime').timedelta(seconds=remaining) + run.is_paused = False + run.paused_remaining_seconds = None + run.save(update_fields=['phase_deadline', 'is_paused', 'paused_remaining_seconds']) + task_id = _schedule_next(run, int(remaining)) + run.celery_task_id = task_id + run.save(update_fields=['celery_task_id']) + _broadcast(session.code, {'type': 'phase.resumed', 'phase_deadline': run.phase_deadline.isoformat()}) + return JsonResponse({'status': 'resumed'}) + + if run: + return JsonResponse({'error': 'Game already running'}, status=409) + + # Fresh start + payload = _json_body(request) + category_slug = str(payload.get('category_slug', '')).strip() + if not category_slug: + return JsonResponse({'error': 'category_slug required'}, status=400) + + config = resolve_config(request.user) + config['category_slug'] = category_slug + + run = GameRun.objects.create( + session=session, + current_state='LOBBY', + state_data={'config': config}, + ) + + driver = get_driver(session.game_type) + result = driver.on_game_start(session, run, config) + + run.current_state = result.next_state + run.state_data['config'] = config + run.save(update_fields=['current_state', 'state_data']) + + if result.duration_seconds: + run.phase_deadline = timezone.now() + __import__('datetime').timedelta(seconds=result.duration_seconds) + task_id = _schedule_next(run, result.duration_seconds) + run.celery_task_id = task_id + run.save(update_fields=['phase_deadline', 'celery_task_id']) + + session.status = GameSession.Status.ACTIVE + session.save(update_fields=['status']) + + _broadcast(session.code, result.broadcast_payload) + + return JsonResponse({'status': 'started', 'state': run.current_state}, status=201) + + +@require_POST +@login_required +def pause_session(request: HttpRequest, code: str) -> JsonResponse: + from lobby.models import GameRun + from lobby.tasks import _broadcast + from django.utils import timezone + from celery.app.control import Control + + session_code = _normalize_session_code(code) + try: + session = GameSession.objects.get(code=session_code) + except GameSession.DoesNotExist: + return JsonResponse({'error': 'Session not found'}, status=404) + + if session.host_id != request.user.id: + return JsonResponse({'error': 'Only host can pause'}, status=403) + + try: + run = GameRun.objects.get(session=session) + except GameRun.DoesNotExist: + return JsonResponse({'error': 'Game not running'}, status=400) + + if run.is_paused: + return JsonResponse({'error': 'Already paused'}, status=400) + + # Revoke Celery task + if run.celery_task_id: + from partyhub.celery import app as celery_app + celery_app.control.revoke(run.celery_task_id, terminate=True) + + remaining = (run.phase_deadline - timezone.now()).total_seconds() if run.phase_deadline else 0 + run.is_paused = True + run.paused_remaining_seconds = max(remaining, 0) + run.celery_task_id = None + run.save(update_fields=['is_paused', 'paused_remaining_seconds', 'celery_task_id']) + + _broadcast(session.code, {'type': 'phase.paused', 'remaining_seconds': run.paused_remaining_seconds}) + return JsonResponse({'status': 'paused', 'remaining_seconds': run.paused_remaining_seconds}) + + +@require_POST +@login_required +def exit_session(request: HttpRequest, code: str) -> JsonResponse: + from lobby.models import GameRun + from lobby.driver import get_driver + from celery.app.control import Control + + session_code = _normalize_session_code(code) + try: + session = GameSession.objects.get(code=session_code) + except GameSession.DoesNotExist: + return JsonResponse({'error': 'Session not found'}, status=404) + + if session.host_id != request.user.id: + return JsonResponse({'error': 'Only host can exit'}, status=403) + + run = GameRun.objects.filter(session=session).first() + if run: + if run.celery_task_id: + from partyhub.celery import app as celery_app + celery_app.control.revoke(run.celery_task_id, terminate=True) + driver = get_driver(session.game_type) + driver.on_exit(session, run) + + return JsonResponse({'status': 'exited'}) +``` + +**Step 3: Add routes to `lobby/urls.py`** + +```python +path("sessions//play", views.play_session, name="play_session"), +path("sessions//pause", views.pause_session, name="pause_session"), +path("sessions//exit", views.exit_session, name="exit_session"), +``` + +**Step 4: Run tests** + +```bash +.venv/bin/python manage.py test lobby.tests.PlayPauseExitTest --verbosity=2 +``` + +Expected: PASS. + +**Step 5: Run full suite** + +```bash +.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1 +``` + +**Step 6: Commit** + +```bash +git add lobby/views.py lobby/urls.py lobby/tests.py +git commit -m "feat(platform): add play/pause/exit session endpoints" +``` + +--- + +### Task 12: FupOgFakta game-specific endpoints (lie, guess, react) + +**Files:** +- Create: `fupogfakta/views.py` +- Create: `fupogfakta/urls.py` +- Modify: `partyhub/urls.py` + +**Step 1: Write failing tests** + +```python +# fupogfakta/tests.py — add + +class SubmitLieViewTest(TestCase): + def setUp(self): + # Create session in LIE_PHASE with a RoundQuestion active + from django.contrib.auth import get_user_model + User = get_user_model() + self.host = User.objects.create_user(username='liehost', password='pw') + from fupogfakta.models import GameSession, Player, Category, Question, RoundQuestion + from lobby.models import GameRun + self.session = GameSession.objects.create(host=self.host, code='LIETEST', status='active') + cat = Category.objects.create(name='LieCat', slug='liecat') + q = Question.objects.create(category=cat, prompt='What is?', correct_answer='Real Answer') + self.rq = RoundQuestion.objects.create(session=self.session, round_number=1, question=q, correct_answer='Real Answer') + self.player = Player.objects.create(session=self.session, nickname='Liar') + self.run = GameRun.objects.create( + session=self.session, current_state='LIE_PHASE', + state_data={'round_question_id': self.rq.id, 'config': {'lie_seconds': 45}} + ) + + def test_submit_lie_succeeds(self): + resp = self.client.post( + '/fupogfakta/LIETEST/lie', + content_type='application/json', + data={'session_token': self.player.session_token, 'text': 'My fake answer'}, + ) + self.assertEqual(resp.status_code, 201) + + def test_submit_correct_answer_as_lie_rejected(self): + resp = self.client.post( + '/fupogfakta/LIETEST/lie', + content_type='application/json', + data={'session_token': self.player.session_token, 'text': 'real answer'}, + ) + self.assertEqual(resp.status_code, 422) + self.assertIn('lie_matches_correct_answer', resp.json().get('error_code', '')) +``` + +**Step 2: Create `fupogfakta/views.py`** + +```python +import json +from django.http import HttpRequest, JsonResponse +from django.views.decorators.http import require_POST + +from fupogfakta.models import GameSession, Player, RoundQuestion, LieAnswer, Guess, LieReaction +from lobby.models import GameRun + + +def _json_body(request: HttpRequest) -> dict: + try: + return json.loads(request.body) if request.body else {} + except json.JSONDecodeError: + return {} + + +def _get_active_run_and_player(code: str, session_token: str): + """Returns (session, run, player) or raises.""" + try: + session = GameSession.objects.get(code=code.upper()) + except GameSession.DoesNotExist: + return None, None, None + try: + run = GameRun.objects.get(session=session) + except GameRun.DoesNotExist: + return session, None, None + try: + player = Player.objects.get(session=session, session_token=session_token) + except Player.DoesNotExist: + return session, run, None + return session, run, player + + +@require_POST +def submit_lie(request: HttpRequest, code: str) -> JsonResponse: + payload = _json_body(request) + session_token = str(payload.get('session_token', '')).strip() + lie_text = str(payload.get('text', '')).strip() + + if not session_token: + return JsonResponse({'error': 'session_token required'}, status=400) + if not lie_text or len(lie_text) > 255: + return JsonResponse({'error': 'text must be 1-255 characters'}, status=400) + + session, run, player = _get_active_run_and_player(code, session_token) + if not session: + return JsonResponse({'error': 'Session not found'}, status=404) + if not run: + return JsonResponse({'error': 'Game not running'}, status=400) + if not player: + return JsonResponse({'error': 'Invalid session_token'}, status=403) + if run.current_state != 'LIE_PHASE': + return JsonResponse({'error': 'Not in lie phase'}, status=400) + + rq_id = run.state_data.get('round_question_id') + try: + rq = RoundQuestion.objects.get(pk=rq_id, session=session) + except RoundQuestion.DoesNotExist: + return JsonResponse({'error': 'No active question'}, status=400) + + # Reject if lie matches correct answer (case-insensitive) + if lie_text.strip().casefold() == rq.correct_answer.strip().casefold(): + return JsonResponse({ + 'error': 'Your lie matches the correct answer — try something else!', + 'error_code': 'lie_matches_correct_answer', + }, status=422) + + from django.db import IntegrityError + try: + lie = LieAnswer.objects.create(round_question=rq, player=player, text=lie_text) + except IntegrityError: + return JsonResponse({'error': 'Lie already submitted'}, status=409) + + # Broadcast progress (anonymous count only) + from realtime.broadcast import sync_broadcast_phase_event + total_players = Player.objects.filter(session=session).count() + submitted = LieAnswer.objects.filter(round_question=rq).count() + sync_broadcast_phase_event(session.code, 'phase.lie_progress', { + 'submitted': submitted, + 'total': total_players, + }) + + return JsonResponse({'lie_id': lie.id}, status=201) + + +@require_POST +def submit_guess(request: HttpRequest, code: str) -> JsonResponse: + payload = _json_body(request) + session_token = str(payload.get('session_token', '')).strip() + selected_text = str(payload.get('selected_text', '')).strip() + + if not session_token: + return JsonResponse({'error': 'session_token required'}, status=400) + if not selected_text: + return JsonResponse({'error': 'selected_text required'}, status=400) + + session, run, player = _get_active_run_and_player(code, session_token) + if not session: + return JsonResponse({'error': 'Session not found'}, status=404) + if not run: + return JsonResponse({'error': 'Game not running'}, status=400) + if not player: + return JsonResponse({'error': 'Invalid session_token'}, status=403) + if run.current_state != 'GUESS_PHASE': + return JsonResponse({'error': 'Not in guess phase'}, status=400) + + rq_id = run.state_data.get('round_question_id') + rq = RoundQuestion.objects.get(pk=rq_id, session=session) + + allowed = {t.strip().casefold() for t in rq.mixed_answers} + if selected_text.casefold() not in allowed: + return JsonResponse({'error': 'Answer not in this round'}, status=400) + + correct_normalized = rq.correct_answer.strip().casefold() + is_correct = selected_text.casefold() == correct_normalized + + fooled_player_id = None + if not is_correct: + fooled_player_id = ( + LieAnswer.objects.filter(round_question=rq, text__iexact=selected_text) + .values_list('player_id', flat=True) + .first() + ) + + from django.db import IntegrityError + try: + guess = Guess.objects.create( + round_question=rq, + player=player, + selected_text=selected_text, + is_correct=is_correct, + fooled_player_id=fooled_player_id, + ) + except IntegrityError: + return JsonResponse({'error': 'Guess already submitted'}, status=409) + + return JsonResponse({'guess_id': guess.id, 'is_correct': is_correct}, status=201) + + +@require_POST +def submit_reaction(request: HttpRequest, code: str) -> JsonResponse: + payload = _json_body(request) + session_token = str(payload.get('session_token', '')).strip() + lie_id = payload.get('lie_id') + reaction = str(payload.get('reaction', '')).strip() + + VALID_REACTIONS = {'laugh', 'heart', 'fire', 'wow'} + if not session_token: + return JsonResponse({'error': 'session_token required'}, status=400) + if reaction not in VALID_REACTIONS: + return JsonResponse({'error': f'reaction must be one of {sorted(VALID_REACTIONS)}'}, status=400) + + session, run, player = _get_active_run_and_player(code, session_token) + if not session: + return JsonResponse({'error': 'Session not found'}, status=404) + if not run or run.current_state != 'GUESS_PHASE': + return JsonResponse({'error': 'Reactions only allowed during guess phase'}, status=400) + if not player: + return JsonResponse({'error': 'Invalid session_token'}, status=403) + + # Cannot react to own lie + try: + lie = LieAnswer.objects.get(pk=lie_id, round_question__session=session) + except LieAnswer.DoesNotExist: + return JsonResponse({'error': 'Lie not found'}, status=404) + if lie.player_id == player.id: + return JsonResponse({'error': 'Cannot react to your own lie'}, status=400) + + from django.db import IntegrityError + try: + LieReaction.objects.create(lie=lie, player=player, reaction=reaction) + except IntegrityError: + # Already reacted with this emoji — idempotent, return ok + pass + + return JsonResponse({'status': 'ok'}) +``` + +**Step 3: Create `fupogfakta/urls.py`** + +```python +from django.urls import path +from . import views + +app_name = 'fupogfakta' + +urlpatterns = [ + path('/lie', views.submit_lie, name='submit_lie'), + path('/guess', views.submit_guess, name='submit_guess'), + path('/react', views.submit_reaction, name='submit_reaction'), +] +``` + +**Step 4: Register in `partyhub/urls.py`** + +Read the file first, then add: + +```python +path('fupogfakta/', include('fupogfakta.urls')), +``` + +**Step 5: Run tests** + +```bash +.venv/bin/python manage.py test fupogfakta.tests.SubmitLieViewTest --verbosity=2 +``` + +Expected: PASS. + +**Step 6: Commit** + +```bash +git add fupogfakta/views.py fupogfakta/urls.py partyhub/urls.py fupogfakta/tests.py +git commit -m "feat(fupogfakta): add lie/guess/react endpoints" +``` + +--- + +## Batch 7 — Frontend Game Screens + +The Angular host-shell and player-shell currently work as developer control panels. Replace them with proper game screens that react to WebSocket events. + +--- + +### Task 13: WebSocket state machine in Angular + +**Files:** +- Create: `frontend/angular/src/app/game-state.service.ts` + +The service opens a WebSocket to `ws/game/{sessionCode}/` and maintains reactive state from `phase.*` events. Both host and player shells subscribe to this service. + +```typescript +// game-state.service.ts +import { Injectable, signal } from '@angular/core'; + +export type GamePhase = + | 'LOBBY' | 'LIE_PHASE' | 'GUESS_PHASE' + | `REVEAL_LIE_${number}` | 'REVEAL_TRUTH' + | 'SCOREBOARD_RECAP' | 'POST_GAME_AWARDS' | 'FINISHED'; + +export interface GameState { + phase: GamePhase; + sessionCode: string; + payload: Record; +} + +@Injectable({ providedIn: 'root' }) +export class GameStateService { + readonly state = signal({ phase: 'LOBBY', sessionCode: '', payload: {} }); + private ws: WebSocket | null = null; + + connect(sessionCode: string, token: string, role: 'player' | 'host') { + const query = role === 'host' ? 'role=host' : `session_token=${token}`; + this.ws = new WebSocket(`ws://${location.host}/ws/game/${sessionCode}/?${query}`); + this.ws.onmessage = (ev) => { + const msg = JSON.parse(ev.data); + this.state.set({ phase: msg.type.replace('phase.', '').toUpperCase() as GamePhase, sessionCode, payload: msg }); + }; + } + + disconnect() { this.ws?.close(); } + + ping() { this.ws?.send(JSON.stringify({ type: 'ping' })); } +} +``` + +**Step 1: Write Vitest test** + +```typescript +// frontend/angular/src/app/game-state.service.spec.ts +import { TestBed } from '@angular/core/testing'; +import { GameStateService } from './game-state.service'; + +describe('GameStateService', () => { + it('initialises in LOBBY phase', () => { + const svc = TestBed.inject(GameStateService); + expect(svc.state().phase).toBe('LOBBY'); + }); +}); +``` + +**Step 2: Run test** + +```bash +cd frontend/angular && npm test -- --run 2>&1 | grep -E 'PASS|FAIL|GameState' +``` + +**Step 3: Rebuild host-shell for real gameplay** + +Replace `host-shell.component.ts` content. The host screen shows: +- **LOBBY**: session code large, player list, "Play" button with category select +- **LIE_PHASE**: question prompt, lie-submission progress bar (N/total), countdown timer, Pause button +- **GUESS_PHASE**: answers displayed (no correct answer highlighted), countdown timer, Pause button +- **REVEAL_LIE_\***: lie text, author revealed, guessers listed, score delta animation +- **REVEAL_TRUTH**: correct answer, who guessed right, score deltas +- **SCOREBOARD_RECAP**: full leaderboard +- **POST_GAME_AWARDS**: award cards +- **FINISHED**: final leaderboard, "New Game" button + +Host makes REST calls only for: POST `/lobby/sessions/{code}/play`, POST `pause`, POST `exit`. + +**Step 4: Rebuild player-shell for real gameplay** + +Player screen shows: +- **LOBBY**: "Waiting for host to start…" with players list +- **LIE_PHASE**: text input for lie, submit button, countdown — replaced by "Submitted ✓" once sent +- **GUESS_PHASE**: answer buttons, once selected show emoji reaction buttons for other lies (cannot react to own lie), countdown +- **REVEAL_\*** / **SCOREBOARD** / **AWARDS**: display-only, shows own score delta highlighted +- **FINISHED**: final leaderboard + +Player makes REST calls only for: POST `/fupogfakta/{code}/lie`, POST `/fupogfakta/{code}/guess`, POST `/fupogfakta/{code}/react`. + +**Step 5: Run Angular tests** + +```bash +cd frontend/angular && npm test -- --run 2>&1 | tail -10 +``` + +**Step 6: Commit** + +```bash +git add frontend/angular/src/ +git commit -m "feat(frontend): rebuild host and player screens as real game UI" +``` + +--- + +## Batch 8 — Cleanup + +### Task 14: Remove obsolete lobby/views.py endpoints + +The old manual-advance endpoints (`start_round`, `show_question`, `submit_lie`, `mix_answers`, `submit_guess`, `calculate_scores`, `reveal_scoreboard`, `finish_game`, `start_next_round`) are now replaced by the driver + platform endpoints. Remove them from `lobby/views.py` and `lobby/urls.py`, then remove or update the tests that covered them. + +Run full suite after: + +```bash +.venv/bin/python manage.py test fupogfakta lobby realtime --verbosity=1 +``` + +Commit: + +```bash +git commit -m "chore: remove obsolete manual-advance lobby endpoints" +``` + +--- + +### Task 15: Update TODO.md + +Mark completed items, add new backlog items discovered during implementation: +- [ ] Rate limiting on fupogfakta/lie and /guess endpoints +- [ ] Session-code brute-force protection on /join +- [ ] TTS / read-aloud integration (Fase 4) + +```bash +git add TODO.md && git commit -m "docs: update TODO after game engine implementation" +``` + +--- + +## Running Order Summary + +``` +Batch 1 — Celery infra (Tasks 1-3) no breaking changes +Batch 2 — GameRun + Driver (Tasks 4-6) additive +Batch 3 — Config system (Task 7) additive +Batch 4 — Game models (Task 8) remove ScoreEvent +Batch 5 — FupOgFakta driver (Task 9) new game logic +Batch 6 — Platform endpoints (Tasks 10-12) new REST API +Batch 7 — Frontend (Tasks 13) replace UI +Batch 8 — Cleanup (Tasks 14-15) remove old code +``` + +Each batch is independently mergeable. Run `.venv/bin/python manage.py test fupogfakta lobby realtime` before every commit. diff --git a/docs/spa-cutover-flag.md b/docs/spa-cutover-flag.md index edbf900..80c61fd 100644 --- a/docs/spa-cutover-flag.md +++ b/docs/spa-cutover-flag.md @@ -59,6 +59,31 @@ Trin-for-trin: Target: rollback + sanity-verifikation inden for 10 minutter. +## React fallback trigger-kriterier (kun delivery-blocking) +Formål: React fallback må kun bruges som kortvarig leverings-sikring, når release ellers er blokeret. + +### Hvornår fallback er tilladt +Alle punkter skal være opfyldt: +1. **Delivery-blocking fejl i Angular SPA** + - Host/player kerneflow kan ikke leveres i release-vinduet (fx login/join/start/round/scoreboard stopper). +2. **Ingen hurtig Angular-fix inden for release-vinduet** + - Teamet har vurderet at patch + verificering ikke kan nås sikkert i tide. +3. **Rollback alene løser ikke leveringsbehovet** + - `USE_SPA_UI=false` (legacy) er enten utilstrækkelig for den konkrete leverance eller allerede verificeret som ikke tilstrækkelig. +4. **Beslutning er eksplicit logget** + - Trigger, impact, UTC-tid, ansvarlig, issue/incident-reference og plan for tilbagevenden til Angular er dokumenteret i release/smoke artifact. + +### Scope-limits for fallback +- Fallback omfatter kun **delivery-blocking host/player-paths**. +- Ingen nye features, UX-forbedringer eller ikke-kritiske ændringer må bundtes ind i fallback. +- Fallback er **midlertidig** og gælder kun for aktiv incident/release-vindue. +- Når blocker er fjernet, skal miljøet tilbage på standard cutover-spor (Angular + `USE_SPA_UI` styring). + +### Ikke tilladt +- Proaktiv fallback "for en sikkerheds skyld" uden aktiv blocker. +- Brug af fallback til at omgå normale kvalitetsgates eller testkrav. +- Langvarig drift i fallback-mode uden dokumenteret blocker og opfølgningsplan. + ## Verifikation - Flag OFF: `UiScreenTests.test_legacy_templates_are_used_when_spa_flag_is_off` - Flag ON (host): `UiScreenTests.test_host_screen_can_render_angular_shell_when_feature_flag_enabled` diff --git a/frontend/angular/src/app/api-contract-smoke.spec.ts b/frontend/angular/src/app/api-contract-smoke.spec.ts index 78a5fff..63b72b5 100644 --- a/frontend/angular/src/app/api-contract-smoke.spec.ts +++ b/frontend/angular/src/app/api-contract-smoke.spec.ts @@ -49,17 +49,17 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { }, host: { can_start_round: true, - can_show_question: true, - can_mix_answers: true, - can_calculate_scores: true, - can_reveal_scoreboard: true, - can_start_next_round: true, - can_finish_game: true + 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: true, - can_submit_guess: true, + can_submit_lie: false, + can_submit_guess: false, can_view_final_result: false } } @@ -122,9 +122,15 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { 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: 2, + reveal: { + round_question_id: 77, + correct_answer: 'A', + lies: [], + guesses: [] + }, leaderboard: [ { id: 9, nickname: 'Maja', score: 200 }, { id: 10, nickname: 'Bo', score: 150 } @@ -134,7 +140,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { 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') { @@ -188,8 +194,8 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => { expect(session.ok).toBe(true); if (session.ok) { expect(session.data.session.code).toBe('ABCD12'); - expect(session.data.phase_view_model.host.can_start_next_round).toBe(true); - expect(session.data.phase_view_model.player.can_submit_guess).toBe(true); + 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'); } diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index b6f54b8..82fb35f 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -220,13 +220,13 @@ describe('HostShellComponent gameplay wiring', () => { .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))) .mockResolvedValueOnce( jsonResponse(200, { - 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: 2, leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }], }) ) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: 77 }))); + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }))); vi.stubGlobal('fetch', fetchMock); @@ -340,5 +340,26 @@ describe('HostShellComponent gameplay wiring', () => { await component.refreshSession(); expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12'); + expect(component.canStartRound).toBe(false); + expect(component.canShowQuestion).toBe(false); + expect(component.canMixAnswers).toBe(false); + expect(component.canCalculateScores).toBe(true); + }); + + it('uses phase_view_model to keep host action surface phase-specific', async () => { + const component = new HostShellComponent(); + + expect(component.canStartRound).toBe(true); + expect(component.canShowQuestion).toBe(false); + + component.session = sessionDetailPayload('lie') as any; + expect(component.canStartRound).toBe(false); + expect(component.canShowQuestion).toBe(true); + expect(component.canMixAnswers).toBe(true); + + component.session = sessionDetailPayload('reveal') as any; + expect(component.canRevealScoreboard).toBe(true); + expect(component.canStartNextRound).toBe(false); + expect(component.canFinishGame).toBe(false); }); }); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 36b431d..89c6ac9 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -21,18 +21,15 @@ type LeaderboardResponse = FinishGameResponse;
- + - - - - - - - - - - + + + + + + +

{{ copy('host.audio_locale_hint') }}: {{ locale }}

@@ -132,6 +129,34 @@ export class HostShellComponent implements OnInit, OnDestroy { this.unsubscribeLocale = null; } + get canStartRound(): boolean { + return Boolean(this.session?.phase_view_model?.host?.can_start_round ?? !this.session); + } + + get canShowQuestion(): boolean { + return Boolean(this.session?.phase_view_model?.host?.can_show_question); + } + + get canMixAnswers(): boolean { + return Boolean(this.session?.phase_view_model?.host?.can_mix_answers); + } + + get canCalculateScores(): boolean { + return Boolean(this.session?.phase_view_model?.host?.can_calculate_scores); + } + + get canRevealScoreboard(): boolean { + return Boolean(this.session?.phase_view_model?.host?.can_reveal_scoreboard); + } + + get canStartNextRound(): boolean { + return Boolean(this.session?.phase_view_model?.host?.can_start_next_round); + } + + get canFinishGame(): boolean { + return Boolean(this.session?.phase_view_model?.host?.can_finish_game); + } + copy(key: string): string { return t(key, this.locale); } diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index bbb092e..94d7aeb 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -613,4 +613,28 @@ describe('PlayerShellComponent gameplay wiring', () => { 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); + }); + }); diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index d7f485e..bd33a58 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -46,9 +46,9 @@ function resolveLocalStorage(): Storage | undefined {
- + - +

@@ -68,24 +68,28 @@ function resolveLocalStorage(): Storage | undefined {

{{ copy('common.status') }}: {{ session.session.status }}

{{ copy('common.prompt') }}: {{ session.round_question.prompt }}

- - - + + + + + -
- -
+ +
+ +
- - + + +

Reveal

@@ -110,7 +114,7 @@ function resolveLocalStorage(): Storage | undefined {
-
+

{{ copy('player.final_leaderboard') }}

  1. {{ entry.nickname }}: {{ entry.score }}
  2. @@ -320,6 +324,25 @@ export class PlayerShellComponent implements OnInit, OnDestroy { }, 3000); } + get showJoinControls(): boolean { + if (!this.session) { + return true; + } + return Boolean(this.session?.phase_view_model?.player?.can_join && !this.playerId && !this.sessionToken); + } + + get showLieControls(): boolean { + return Boolean(this.session?.phase_view_model?.player?.can_submit_lie); + } + + get showGuessControls(): boolean { + return Boolean(this.session?.phase_view_model?.player?.can_submit_guess); + } + + get showFinalLeaderboard(): boolean { + return Boolean(this.session?.phase_view_model?.player?.can_view_final_result); + } + get loadingMessage(): string { switch (this.loadingTransition) { case 'join': diff --git a/frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts b/frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts index 26adc77..84b8fd0 100644 --- a/frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts +++ b/frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts @@ -4,42 +4,56 @@ 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('resolves host/player copy in en and da from shared catalog', () => { - vi.stubGlobal('window', { - location: { hash: '', search: '' }, - history: { state: null, replaceState: vi.fn() }, - localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() }, - sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() }, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - }); - vi.stubGlobal('navigator', { language: 'en-US', onLine: true }); + 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('game.host.start_round')).toBe('Start round'); - expect(player.copy('game.player.submit_guess')).toBe('Submit guess'); - - setPreferredLocale('da'); - - expect(host.copy('game.host.start_round')).toBe('Start runde'); - expect(player.copy('game.player.submit_guess')).toBe('Send gæt'); + 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 policy primary-only (client has no audio output)', async () => { - const originalPlay = vi.fn().mockRejectedValue(new Error('original play')); + 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', { @@ -57,7 +71,7 @@ describe('i18n MVP flow smoke (host/player + audio policy)', () => { const host = new HostShellComponent(); host.ngOnInit(); - await expect(mediaPrototype.play()).rejects.toThrow('original play'); + await expect(mediaPrototype.play()).rejects.toThrow('primary host playback'); const player = new PlayerShellComponent(); player.ngOnInit(); @@ -66,7 +80,7 @@ describe('i18n MVP flow smoke (host/player + audio policy)', () => { player.ngOnDestroy(); - await expect(mediaPrototype.play()).rejects.toThrow('original play'); + await expect(mediaPrototype.play()).rejects.toThrow('primary host playback'); host.ngOnDestroy(); }); diff --git a/frontend/tests/angular-api-client.test.ts b/frontend/tests/angular-api-client.test.ts index c0b9eb5..d0f2aef 100644 --- a/frontend/tests/angular-api-client.test.ts +++ b/frontend/tests/angular-api-client.test.ts @@ -206,6 +206,83 @@ describe('createAngularApiClient', () => { } }); + it('keeps canonical reveal payload stable when session detail is already in scoreboard phase', async () => { + const get = vi.fn(async (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('maps host/player gameplay endpoints through typed response mappers', async () => { const get = vi.fn(async (url: string) => { if (url === '/lobby/sessions/ABCD12/scoreboard') { diff --git a/fupogfakta/migrations/0005_alter_gamesession_status.py b/fupogfakta/migrations/0005_alter_gamesession_status.py new file mode 100644 index 0000000..202f7be --- /dev/null +++ b/fupogfakta/migrations/0005_alter_gamesession_status.py @@ -0,0 +1,18 @@ +# 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), + ), + ] diff --git a/fupogfakta/migrations/0006_merge_20260315_1249.py b/fupogfakta/migrations/0006_merge_20260315_1249.py new file mode 100644 index 0000000..863c15a --- /dev/null +++ b/fupogfakta/migrations/0006_merge_20260315_1249.py @@ -0,0 +1,10 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("fupogfakta", "0005_alter_gamesession_status"), + ("fupogfakta", "0005_gamesession_scoreboard_status"), + ] + + operations = [] diff --git a/lobby/i18n.py b/lobby/i18n.py index b5808c8..a40d8dd 100644 --- a/lobby/i18n.py +++ b/lobby/i18n.py @@ -24,6 +24,15 @@ 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("=") @@ -78,12 +87,13 @@ def resolve_error_message(*, key: str, locale: str) -> str: return key -def api_error(request: HttpRequest, *, key: str, status: int) -> JsonResponse: +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": key, + "error_code": code, "locale": locale, }, status=status, diff --git a/lobby/templates/lobby/host_screen.html b/lobby/templates/lobby/host_screen.html index 49f9d01..3c66382 100644 --- a/lobby/templates/lobby/host_screen.html +++ b/lobby/templates/lobby/host_screen.html @@ -55,8 +55,12 @@

    Spillere: afventer

    Aktiv round question: afventer

    -
    Klar.
    +
    Ready.
    +{{ lobby_i18n|json_script:"wppHostI18n" }}