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