From d2dbd8c802fd805bf7e2a345ada07393ae40c0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Geel=20Weirs=C3=B8e?= Date: Mon, 9 Mar 2026 07:32:45 +0100 Subject: [PATCH 1/2] docs: design doc for fup og fakta game engine + platform architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures all brainstormed decisions: - Pluggable game cartridge platform (GameDriver interface) - Celery + Redis timer-driven phase transitions - Session owner play/pause/exit controls (no skip) - Escalating scoring per round, incremental reveal scoring - Emoji reactions during guess phase → post-game awards - Relational per-user config presets with game-specific models - Ephemeral game state (no persistence after exit/finish) - Full WebSocket event reference and data lifecycle Also: updated TODO.md (WebSocket done, persisted answers done), created CLAUDE.md, and PROMPT.md for ralph-loop. Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.json | 7 + CLAUDE.md | 119 ++++++++ PROMPT.md | 71 +++++ TODO.md | 25 +- ...026-03-09-fupogfakta-game-engine-design.md | 272 ++++++++++++++++++ lobby/views.py | 67 +++++ partyhub/asgi.py | 6 +- partyhub/settings.py | 5 + realtime/broadcast.py | 20 ++ realtime/consumers.py | 61 ++++ realtime/routing.py | 7 + realtime/tests.py | 71 ++++- requirements.txt | 1 + 13 files changed, 718 insertions(+), 14 deletions(-) create mode 100644 .claude/settings.json create mode 100644 CLAUDE.md create mode 100644 PROMPT.md create mode 100644 docs/plans/2026-03-09-fupogfakta-game-engine-design.md create mode 100644 realtime/broadcast.py create mode 100644 realtime/consumers.py create mode 100644 realtime/routing.py 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/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/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/lobby/views.py b/lobby/views.py index 0282fb2..b8af249 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -20,6 +20,8 @@ from fupogfakta.models import ( ScoreEvent, ) +from realtime.broadcast import sync_broadcast_phase_event + from .i18n import api_error, lobby_i18n_errors SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" @@ -321,6 +323,16 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse: session.status = GameSession.Status.LIE session.save(update_fields=["status"]) + sync_broadcast_phase_event( + session.code, + "phase.lie_started", + { + "round_number": session.current_round, + "category": {"slug": round_config.category.slug, "name": round_config.category.name}, + "lie_seconds": round_config.lie_seconds, + }, + ) + return JsonResponse( { "session": { @@ -407,6 +419,18 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse: lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) + sync_broadcast_phase_event( + session.code, + "phase.question_shown", + { + "round_question_id": round_question.id, + "prompt": question.prompt, + "shown_at": round_question.shown_at.isoformat(), + "lie_deadline_at": lie_deadline_at.isoformat(), + "lie_seconds": round_config.lie_seconds, + }, + ) + return JsonResponse( { "round_question": { @@ -575,6 +599,22 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json locked_session.status = GameSession.Status.GUESS locked_session.save(update_fields=["status"]) + try: + _guess_config = RoundConfig.objects.get(session=session, number=session.current_round) + _guess_seconds = _guess_config.guess_seconds + except RoundConfig.DoesNotExist: + _guess_seconds = None + + sync_broadcast_phase_event( + session.code, + "phase.guess_started", + { + "round_question_id": round_question.id, + "answers": [{"text": t} for t in deduped_answers], + "guess_seconds": _guess_seconds, + }, + ) + return JsonResponse( { "session": { @@ -719,6 +759,12 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse: .values("id", "nickname", "score") ) + sync_broadcast_phase_event( + session.code, + "phase.scoreboard", + {"leaderboard": list(leaderboard), "current_round": session.current_round}, + ) + return JsonResponse( { "session": { @@ -792,6 +838,12 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse: winner = leaderboard[0] if leaderboard else None + sync_broadcast_phase_event( + session.code, + "phase.game_over", + {"winner": winner, "leaderboard": list(leaderboard)}, + ) + return JsonResponse( { "session": { @@ -898,6 +950,21 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> .values("id", "nickname", "score") ) + score_deltas = [ + {"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason} + for ev in score_events + ] + + sync_broadcast_phase_event( + session.code, + "phase.scores_calculated", + { + "round_question_id": round_question.id, + "score_deltas": score_deltas, + "leaderboard": list(leaderboard), + }, + ) + return JsonResponse( { "session": { diff --git a/partyhub/asgi.py b/partyhub/asgi.py index 5eec677..54ca5e3 100644 --- a/partyhub/asgi.py +++ b/partyhub/asgi.py @@ -1,11 +1,15 @@ import os -from channels.routing import ProtocolTypeRouter + +from channels.routing import ProtocolTypeRouter, URLRouter from django.core.asgi import get_asgi_application os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'partyhub.settings') django_asgi_app = get_asgi_application() +from realtime.routing import websocket_urlpatterns # noqa: E402 — must come after env setup + application = ProtocolTypeRouter({ 'http': django_asgi_app, + 'websocket': URLRouter(websocket_urlpatterns), }) diff --git a/partyhub/settings.py b/partyhub/settings.py index 70ae90c..9ea5b48 100644 --- a/partyhub/settings.py +++ b/partyhub/settings.py @@ -116,8 +116,13 @@ WPP_SPA_ASSET_VERSION = env('WPP_SPA_ASSET_VERSION', 'dev') CHANNEL_REDIS_HOST = env('CHANNEL_REDIS_HOST', '127.0.0.1') CHANNEL_REDIS_PORT = int(env('CHANNEL_REDIS_PORT', '6379')) + +import sys # noqa: E402 +_testing = 'test' in sys.argv CHANNEL_LAYERS = { 'default': { + 'BACKEND': 'channels.layers.InMemoryChannelLayer', + } if _testing else { 'BACKEND': 'channels_redis.core.RedisChannelLayer', 'CONFIG': {'hosts': [(CHANNEL_REDIS_HOST, CHANNEL_REDIS_PORT)]}, } diff --git a/realtime/broadcast.py b/realtime/broadcast.py new file mode 100644 index 0000000..95d1a5d --- /dev/null +++ b/realtime/broadcast.py @@ -0,0 +1,20 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + + +async def broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None: + """Send a phase event to all WebSocket clients connected to a game session.""" + channel_layer = get_channel_layer() + group_name = f"game_{session_code.upper()}" + await channel_layer.group_send( + group_name, + { + "type": "phase_event", + "payload": {"type": event_type, **payload}, + }, + ) + + +def sync_broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None: + """Sync wrapper for calling broadcast_phase_event from synchronous Django views.""" + async_to_sync(broadcast_phase_event)(session_code, event_type, payload) diff --git a/realtime/consumers.py b/realtime/consumers.py new file mode 100644 index 0000000..b52c088 --- /dev/null +++ b/realtime/consumers.py @@ -0,0 +1,61 @@ +import json +from urllib.parse import parse_qs + +from channels.generic.websocket import AsyncJsonWebsocketConsumer + +from fupogfakta.models import Player + + +class GameConsumer(AsyncJsonWebsocketConsumer): + """ + WebSocket consumer for a game session. + + URL: ws/game// + + Query params: + - session_token: player session token (players only) + - role=host: skip token check for host in MVP + """ + + async def connect(self): + self.session_code = self.scope["url_route"]["kwargs"]["session_code"].upper() + self.group_name = f"game_{self.session_code}" + + query_string = self.scope.get("query_string", b"").decode() + params = parse_qs(query_string) + + role = params.get("role", [None])[0] + session_token = params.get("session_token", [None])[0] + + if role != "host": + if not session_token: + await self.close(code=4001) + return + + try: + self.player = await Player.objects.aget( + session_token=session_token, + session__code=self.session_code, + ) + except Player.DoesNotExist: + await self.close(code=4003) + return + else: + self.player = None + + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + + async def disconnect(self, close_code): + if hasattr(self, "group_name"): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + + async def receive_json(self, content, **kwargs): + if content.get("type") == "ping": + await self.send_json({"type": "pong"}) + + # --- Group message handlers --- + + async def phase_event(self, event): + """Forward any phase_event broadcast to the WebSocket client.""" + await self.send_json(event["payload"]) diff --git a/realtime/routing.py b/realtime/routing.py new file mode 100644 index 0000000..3137721 --- /dev/null +++ b/realtime/routing.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from . import consumers + +websocket_urlpatterns = [ + re_path(r"^ws/game/(?P[A-Z0-9]{4,8})/$", consumers.GameConsumer.as_asgi()), +] diff --git a/realtime/tests.py b/realtime/tests.py index 7ce503c..10aa72a 100644 --- a/realtime/tests.py +++ b/realtime/tests.py @@ -1,3 +1,72 @@ +from channels.testing import WebsocketCommunicator +from django.contrib.auth import get_user_model from django.test import TestCase -# Create your tests here. +from fupogfakta.models import GameSession, Player +from partyhub.asgi import application + +User = get_user_model() + + +class GameConsumerConnectTest(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="host", password="pw") + self.session = GameSession.objects.create(host=self.user, code="AABBCC") + self.player = Player.objects.create(session=self.session, nickname="Tester") + + async def test_player_connect_and_ping(self): + token = self.player.session_token + communicator = WebsocketCommunicator( + application, + f"/ws/game/AABBCC/?session_token={token}", + ) + connected, _ = await communicator.connect() + self.assertTrue(connected) + + await communicator.send_json_to({"type": "ping"}) + response = await communicator.receive_json_from() + self.assertEqual(response["type"], "pong") + + await communicator.disconnect() + + async def test_connect_without_token_rejected(self): + communicator = WebsocketCommunicator(application, "/ws/game/AABBCC/") + connected, code = await communicator.connect() + self.assertFalse(connected) + self.assertEqual(code, 4001) + + async def test_connect_invalid_token_rejected(self): + communicator = WebsocketCommunicator( + application, + "/ws/game/AABBCC/?session_token=invalid-token", + ) + connected, code = await communicator.connect() + self.assertFalse(connected) + self.assertEqual(code, 4003) + + async def test_host_connect_without_token(self): + communicator = WebsocketCommunicator( + application, + "/ws/game/AABBCC/?role=host", + ) + connected, _ = await communicator.connect() + self.assertTrue(connected) + await communicator.disconnect() + + async def test_broadcast_reaches_connected_client(self): + token = self.player.session_token + communicator = WebsocketCommunicator( + application, + f"/ws/game/AABBCC/?session_token={token}", + ) + connected, _ = await communicator.connect() + self.assertTrue(connected) + + from realtime.broadcast import broadcast_phase_event + await broadcast_phase_event("AABBCC", "phase.test_event", {"hello": "world"}) + + message = await communicator.receive_json_from(timeout=2) + self.assertEqual(message["type"], "phase.test_event") + self.assertEqual(message["hello"], "world") + + await communicator.disconnect() diff --git a/requirements.txt b/requirements.txt index 6bdfa66..ee62f92 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ Django==6.0.2 channels>=4.1,<5 channels-redis>=4.2,<5 +daphne>=4.1,<5 mysqlclient>=2.2,<3 python-dotenv>=1.0,<2 From d15abf9d78490a56ea0e98c6ac5f1b5940162fbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Geel=20Weirs=C3=B8e?= Date: Mon, 9 Mar 2026 07:38:04 +0100 Subject: [PATCH 2/2] docs: add fupogfakta game engine implementation plan 15 tasks across 8 batches covering: - Celery infrastructure - GameRun model + GameDriver interface - FupOgFaktaConfig relational presets - LieReaction model, reveal_order, ScoreEvent removal - Full FupOgFaktaDriver with all phase transitions - Platform play/pause/exit endpoints - Fupogfakta lie/guess/react endpoints - Angular frontend game screens rebuild - Cleanup of obsolete manual-advance endpoints Co-Authored-By: Claude Sonnet 4.6 --- ...26-03-09-fupogfakta-implementation-plan.md | 2180 +++++++++++++++++ 1 file changed, 2180 insertions(+) create mode 100644 docs/plans/2026-03-09-fupogfakta-implementation-plan.md 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.