52 Commits

Author SHA1 Message Date
Asger Geel Weirsøe
a81bc1250c Big visual overhaul docker compsoe file etc
Some checks failed
CI / test-and-quality (push) Failing after 4m4s
2026-03-23 14:11:30 +01:00
d86941fef8 Merge pull request '[READY][Gameplay] #310 Host transition idempotency and error catalog for scoreboard -> next round / finish' (#320) from dev/issue-310-host-transition-idempotency-v2 into main
All checks were successful
CI / test-and-quality (push) Successful in 3m30s
Reviewed-on: #320
Reviewed-by: reviewer-bot <review-no-reply@weircon.dk>
2026-03-18 06:52:03 +01:00
21e390d200 test: tighten pr320 lobby ownership guard
All checks were successful
CI / test-and-quality (push) Successful in 4m6s
CI / test-and-quality (pull_request) Successful in 4m8s
2026-03-18 06:44:54 +01:00
df9b6d192c chore: refresh i18n parity artifact
All checks were successful
CI / test-and-quality (push) Successful in 4m6s
CI / test-and-quality (pull_request) Successful in 4m6s
2026-03-18 05:19:53 +00:00
702f130de2 test(lobby): lock issue-310 transition ownership boundary
All checks were successful
CI / test-and-quality (push) Successful in 4m14s
CI / test-and-quality (pull_request) Successful in 4m15s
2026-03-18 05:00:48 +00:00
92f2cda83a test(lobby): lock scoreboard next-round bootstrap target
All checks were successful
CI / test-and-quality (push) Successful in 4m3s
CI / test-and-quality (pull_request) Successful in 4m5s
2026-03-18 04:36:20 +00:00
d080f05661 fix(ci): retain lobby payload ownership export
All checks were successful
CI / test-and-quality (push) Successful in 4m0s
CI / test-and-quality (pull_request) Successful in 4m2s
2026-03-18 04:17:17 +00:00
e246bd648f fix(gameplay): scope next-round selection to target round
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 11s
2026-03-18 03:54:25 +00:00
06e4ccac61 fix(lobby): restore scoreboard payload import
Some checks failed
CI / test-and-quality (push) Failing after 13s
CI / test-and-quality (pull_request) Failing after 14s
2026-03-18 02:55:30 +00:00
3c9214178e fix(ci): remove stale scoreboard payload import
Some checks failed
CI / test-and-quality (pull_request) Failing after 3m34s
CI / test-and-quality (push) Failing after 3m35s
2026-03-18 02:33:36 +00:00
feddd910eb fix: restore reveal payload import in submit_guess
Some checks failed
CI / test-and-quality (push) Failing after 12s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-18 02:12:11 +00:00
dd615796f4 refactor(payloads): delegate session detail gameplay payload
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-18 01:33:47 +00:00
d2cdf16322 test(lobby): lock repaired stale next-round replay
All checks were successful
CI / test-and-quality (push) Successful in 4m0s
CI / test-and-quality (pull_request) Successful in 4m1s
2026-03-18 00:46:51 +00:00
101c3f9c26 fix(gameplay): repair stale next-round question drift
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m55s
CI / test-and-quality (push) Successful in 3m56s
2026-03-17 23:25:02 +00:00
65eb5685f7 test(lobby): lock scoreboard ownership boundary
All checks were successful
CI / test-and-quality (push) Successful in 3m57s
CI / test-and-quality (pull_request) Successful in 3m58s
2026-03-17 23:07:22 +00:00
8a70645fda test(lobby): lock session detail payload delegation
All checks were successful
CI / test-and-quality (push) Successful in 3m53s
CI / test-and-quality (pull_request) Successful in 3m54s
2026-03-17 22:25:03 +00:00
2cd8d940f9 test(lobby): lock session detail ownership boundary
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m55s
CI / test-and-quality (push) Successful in 3m57s
2026-03-17 21:22:39 +00:00
72bc5997ff test(gameplay): keep lobby delegation checks in lobby suite
All checks were successful
CI / test-and-quality (push) Successful in 4m3s
CI / test-and-quality (pull_request) Successful in 4m5s
2026-03-17 21:03:50 +00:00
c9e64bc8a8 test(gameplay): lock lobby delegation for host transitions (#310)
All checks were successful
CI / test-and-quality (push) Successful in 4m4s
CI / test-and-quality (pull_request) Successful in 4m4s
2026-03-17 20:48:39 +00:00
1c7f1e7c53 fix(ci): satisfy PR #320 lobby lint contract
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m56s
CI / test-and-quality (push) Successful in 3m56s
2026-03-17 20:06:12 +00:00
03850b5ed5 refactor(gameplay): extract start/show transitions from lobby views
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-17 19:44:13 +00:00
16c9cf6b57 refactor(gameplay): extract round start payload builders
All checks were successful
CI / test-and-quality (push) Successful in 3m51s
CI / test-and-quality (pull_request) Successful in 3m53s
2026-03-17 19:15:44 +00:00
c45f04f9f1 refactor(gameplay): extract round question payload builder
All checks were successful
CI / test-and-quality (push) Successful in 3m41s
CI / test-and-quality (pull_request) Successful in 3m42s
2026-03-17 18:55:28 +00:00
319038555a refactor(gameplay): move phase view model into cartridge
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m42s
CI / test-and-quality (push) Successful in 3m44s
2026-03-17 18:29:11 +00:00
e318711148 test(gameplay): lock refreshed next-round deadline contract
All checks were successful
CI / test-and-quality (push) Successful in 3m47s
CI / test-and-quality (pull_request) Successful in 3m48s
2026-03-17 17:44:34 +00:00
a9c6e4fd79 test(lobby): lock host transition ownership boundary
All checks were successful
CI / test-and-quality (push) Successful in 3m44s
CI / test-and-quality (pull_request) Successful in 3m44s
2026-03-17 17:25:22 +00:00
7eb3507934 fix(gameplay): refresh stale next-round bootstrap config
All checks were successful
CI / test-and-quality (push) Successful in 3m39s
CI / test-and-quality (pull_request) Successful in 3m40s
2026-03-17 17:06:59 +00:00
dfa197b33b refactor(gameplay): keep host transition payloads in cartridge
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m37s
CI / test-and-quality (push) Successful in 3m38s
2026-03-17 16:06:46 +00:00
fefc5ecd56 test(lobby): lock refreshed deadline for reused bootstrap round
All checks were successful
CI / test-and-quality (push) Successful in 3m40s
CI / test-and-quality (pull_request) Successful in 3m40s
2026-03-17 15:42:00 +00:00
94f940e5d8 refactor(gameplay): delegate host transition events from service
All checks were successful
CI / test-and-quality (push) Successful in 3m35s
CI / test-and-quality (pull_request) Successful in 3m35s
2026-03-17 13:43:44 +00:00
a102a72a77 fix(gameplay): refresh reused bootstrap lie timer
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m27s
CI / test-and-quality (push) Successful in 3m27s
2026-03-17 13:21:52 +00:00
d272e35a79 refactor(gameplay): keep host transition events in payload layer
All checks were successful
CI / test-and-quality (push) Successful in 3m28s
CI / test-and-quality (pull_request) Successful in 3m28s
2026-03-17 13:04:58 +00:00
8a07433f11 refactor(gameplay): move transition event composition into service
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m36s
CI / test-and-quality (push) Successful in 3m37s
2026-03-17 11:58:39 +00:00
9baade0105 test(gameplay): lock lobby replay side-effect delegation
All checks were successful
CI / test-and-quality (push) Successful in 3m43s
CI / test-and-quality (pull_request) Successful in 3m44s
2026-03-17 11:35:19 +00:00
35e2d09ee3 test(gameplay): lock lobby host-transition delegation
All checks were successful
CI / test-and-quality (push) Successful in 3m34s
CI / test-and-quality (pull_request) Successful in 3m34s
2026-03-17 10:55:41 +00:00
a916da12a7 refactor: move scoreboard promotion out of lobby view
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m26s
CI / test-and-quality (push) Successful in 3m28s
2026-03-17 10:41:09 +00:00
7f20cb3bf9 refactor(gameplay): move scoreboard phase events into cartridge payloads
All checks were successful
CI / test-and-quality (push) Successful in 3m25s
CI / test-and-quality (pull_request) Successful in 3m27s
2026-03-17 10:13:41 +00:00
f736f4f74e refactor(gameplay): move scoreboard transitions into cartridge service
All checks were successful
CI / test-and-quality (push) Successful in 3m27s
CI / test-and-quality (pull_request) Successful in 3m28s
2026-03-17 09:29:02 +00:00
8247787404 refactor(gameplay): move transition payload builders to cartridge
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m30s
CI / test-and-quality (push) Successful in 3m31s
2026-03-17 09:08:14 +00:00
6722be43d4 merge(main): resolve PR #320 scoreboard transition conflict
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m24s
CI / test-and-quality (push) Successful in 3m26s
2026-03-17 08:45:55 +00:00
212549373b fix(gameplay): gate next-round replay on scoreboard exit marker
All checks were successful
CI / test-and-quality (push) Successful in 3m25s
CI / test-and-quality (pull_request) Successful in 3m26s
2026-03-17 08:25:57 +00:00
47659ed673 test(gameplay): guard extracted lobby helper wiring
All checks were successful
CI / test-and-quality (push) Successful in 3m26s
CI / test-and-quality (pull_request) Successful in 3m26s
2026-03-17 07:43:49 +00:00
root
c8750af4d8 fix(gameplay): restore extracted helper imports
All checks were successful
CI / test-and-quality (push) Successful in 3m29s
CI / test-and-quality (pull_request) Successful in 3m29s
2026-03-17 07:24:50 +00:00
44e480931b fix(gameplay): gate next-round replay on prior scoreboard exit
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 11s
2026-03-17 07:05:56 +00:00
8c0a561a64 Merge pull request 'refactor(fupogfakta): extract first lobby gameplay slice (#312)' (#319) from dev/issue-312-extraction-map into main
All checks were successful
CI / test-and-quality (push) Successful in 2m55s
2026-03-17 08:01:26 +01:00
1839b30e0a merge(main): resolve PR #320 gameplay conflicts
Some checks failed
CI / test-and-quality (push) Failing after 13s
CI / test-and-quality (pull_request) Failing after 14s
2026-03-17 06:44:21 +00:00
7de843e44b fix(lobby): use extracted fupogfakta helpers
All checks were successful
CI / test-and-quality (push) Successful in 3m32s
CI / test-and-quality (pull_request) Successful in 3m36s
2026-03-17 06:21:33 +00:00
542d326615 fix(gameplay): gate next-round replay on prior transition
All checks were successful
CI / test-and-quality (push) Successful in 3m24s
CI / test-and-quality (pull_request) Successful in 3m27s
2026-03-17 06:21:00 +00:00
e39605d782 merge(main): resolve PR #319 lobby extraction conflict
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 11s
2026-03-17 05:58:51 +00:00
d36d256daf fix(gameplay): make scoreboard host exits idempotent
All checks were successful
CI / test-and-quality (push) Successful in 3m44s
CI / test-and-quality (pull_request) Successful in 3m45s
2026-03-17 05:41:13 +00:00
2ee235c6c0 refactor(fupogfakta): extract first lobby gameplay slice (#312)
All checks were successful
CI / test-and-quality (push) Successful in 3m8s
CI / test-and-quality (pull_request) Successful in 3m13s
2026-03-17 05:37:31 +00:00
592c265331 docs(architecture): map lobby vs fupogfakta extraction boundary refs #311 #312 2026-03-16 18:57:29 +00:00
96 changed files with 13516 additions and 2155 deletions

16
.dockerignore Normal file
View File

@@ -0,0 +1,16 @@
.git
.venv
venv
__pycache__
*.py[cod]
*.egg-info
.pytest_cache
.mypy_cache
.ruff_cache
node_modules
frontend/node_modules
frontend/angular/node_modules
frontend/angular/dist
db.sqlite3
staticfiles
media

View File

@@ -27,21 +27,34 @@ jobs:
pip install ruff
- name: Lint
run: ruff check lobby
run: ruff check .
- name: Tests
run: python manage.py test lobby -v 1
- name: Django checks
run: |
python manage.py check
python scripts/check_i18n_drift.py
python manage.py test lobby fupogfakta -v 1
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install shared frontend dependencies
run: npm ci --prefix frontend
- name: Shared frontend checks
run: |
npm --prefix frontend test
npm --prefix frontend run build
- name: Install SPA dependencies
run: |
npm ci --prefix frontend/angular
node -e "require('./frontend/angular/node_modules/rollup/dist/native.js')" \
|| npm install --prefix frontend/angular
- name: SPA Angular smoke tests
run: npm --prefix frontend/angular test
- name: SPA Angular checks
run: |
npm --prefix frontend/angular test
npm --prefix frontend/angular run build

2
.gitignore vendored
View File

@@ -17,6 +17,7 @@ venv/
db.sqlite3
staticfiles/
media/
artifacts/
# Env/secrets
.env
@@ -24,6 +25,7 @@ media/
!.env.test.example
!.env.staging.example
!.env.prod.example
!.env.dev.example
# Editors/OS
.vscode/

30
AGENTS.md Normal file
View File

@@ -0,0 +1,30 @@
# Repository Guidelines
## Project Structure & Module Organization
`partyhub/` is the Django project entrypoint (`settings.py`, `urls.py`, `asgi.py`). Backend apps live at the repo root: `lobby/` handles session and player flows, `fupogfakta/` owns game rules and scoring, `realtime/` holds websocket/broadcast code, and `core_admin/` plus `voice/` cover admin and future audio integration. Shared locale data lives in `shared/i18n/`, helper scripts in `scripts/`, deployment assets in `infra/`, and release or smoke evidence in `docs/`.
Frontend code is split in two layers: `frontend/src/` contains the framework-agnostic TypeScript API client and SPA state helpers, while `frontend/angular/src/` contains the Angular 19 shell for host and player screens.
## Build, Test, and Development Commands
Install backend dependencies with `.venv/bin/pip install -r requirements.txt`.
- `.venv/bin/python manage.py runserver` starts the Django dev server.
- `.venv/bin/python manage.py migrate` applies schema changes.
- `.venv/bin/python manage.py check` runs Django configuration checks.
- `.venv/bin/python manage.py test lobby` runs the backend suite currently enforced in CI.
- `npm --prefix frontend test` runs Vitest for the shared TypeScript client.
- `npm --prefix frontend run build` performs the TypeScript compile check.
- `npm --prefix frontend/angular start` serves the Angular shell locally.
- `npm --prefix frontend/angular test` runs Angular-side Vitest smoke tests.
- `.venv/bin/python scripts/check_i18n_drift.py` validates shared locale keys.
## Coding Style & Naming Conventions
Use 4-space indentation in Python and follow Django conventions: snake_case for functions, PascalCase for models, explicit `on_delete` on `ForeignKey`s, and committed migrations in each apps `migrations/` package. Keep business rules server-authoritative.
Use 2-space indentation in TypeScript and Angular. Match the existing style: single quotes, semicolons, PascalCase classes, camelCase functions, and kebab-case filenames such as `gameplay-phase-machine.ts`. Keep API types in `frontend/src/api/types.ts` aligned with backend JSON payloads.
## Testing Guidelines
Backend tests live in `<app>/tests.py`; frontend tests live in `frontend/tests/*.test.ts` and `frontend/angular/src/**/*.spec.ts`. No numeric coverage gate is committed, so add targeted tests for every gameplay, i18n, or payload-contract change. Before opening a PR, run `manage.py check`, `manage.py test lobby`, and `npm --prefix frontend/angular test` at minimum.
## Commit & Pull Request Guidelines
Recent history follows short, imperative subjects with optional scopes, for example `fix(gameplay): ...`, `test(lobby): ...`, and `chore: ...`. Keep commits small and reference issue numbers when relevant. Open PRs from `feature/<name>` branches with a clear problem statement, linked issue, test evidence, and screenshots for host/player UI changes. If you touch `USE_SPA_UI`, staging flow, or i18n artifacts, include the related smoke or parity document in `docs/`.

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential default-libmysqlclient-dev pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt /app/requirements.txt
RUN pip install --upgrade pip \
&& pip install -r /app/requirements.txt
COPY . /app
EXPOSE 8000
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

View File

@@ -1,3 +1 @@
from django.contrib import admin
# Register your models here.
"""Admin registrations for the core_admin app."""

View File

@@ -1,3 +1 @@
from django.db import models
# Create your models here.
"""Database models for the core_admin app."""

View File

@@ -1,3 +1 @@
from django.test import TestCase
# Create your tests here.
"""Test module placeholder for the core_admin app."""

View File

@@ -1,3 +1 @@
from django.shortcuts import render
# Create your views here.
"""HTTP views for the core_admin app."""

82
docker-compose.yml Normal file
View File

@@ -0,0 +1,82 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
command: sh /app/scripts/docker_dev_entrypoint.sh
env_file:
- infra/env/.env.dev.example
environment:
DB_HOST: db
DB_PORT: "3306"
CHANNEL_REDIS_HOST: redis
CHANNEL_REDIS_PORT: "6379"
USE_SPA_UI: ${USE_SPA_UI:-false}
WPP_SPA_ASSET_BASE: ${WPP_SPA_ASSET_BASE:-http://localhost:4200/browser}
WPP_SPA_ASSET_VERSION: ${WPP_SPA_ASSET_VERSION:-dev}
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
healthcheck:
test:
- CMD
- python
- -c
- import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz').status == 200 else 1)
interval: 5s
timeout: 5s
retries: 20
start_period: 15s
ports:
- "${APP_PORT:-8000}:8000"
volumes:
- .:/app
stdin_open: true
tty: true
db:
image: mysql:8.4
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_DATABASE: wpp_dev
MYSQL_USER: wpp_dev
MYSQL_PASSWORD: wpp_dev
MYSQL_ROOT_PASSWORD: wpp_root
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
interval: 5s
timeout: 5s
retries: 20
start_period: 10s
ports:
- "${DB_FORWARD_PORT:-3307}:3306"
volumes:
- mysql_data:/var/lib/mysql
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
ports:
- "${REDIS_FORWARD_PORT:-6380}:6379"
volumes:
- redis_data:/data
spa-assets:
profiles: ["spa"]
image: node:22-alpine
working_dir: /workspace/frontend/angular
command: sh -c "npm ci && npm run build && node /workspace/scripts/serve_static_dir.mjs dist/wpp-angular-shell 4200 http://app:8000"
ports:
- "${SPA_PORT:-4200}:4200"
volumes:
- .:/workspace
- spa_node_modules:/workspace/frontend/angular/node_modules
volumes:
mysql_data:
redis_data:
spa_node_modules:

89
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,89 @@
# Development Setup
## MVP Runtime Path
The current MVP runtime path is the legacy Django host/player UI with `USE_SPA_UI=false`.
## Docker Compose
The fastest MVP path is the legacy UI with MySQL and Redis behind Django:
```bash
docker compose up --build
```
App URLs:
- `http://localhost:8000/admin/login/`
- `http://localhost:8000/lobby/ui/host`
- `http://localhost:8000/lobby/ui/player`
Compose uses `infra/env/.env.dev.example` and overrides `DB_HOST`/`CHANNEL_REDIS_HOST` inside containers so the same file also works for host-side commands.
If port `8000` is already in use, run with `APP_PORT=18000 docker compose up --build` and use `http://localhost:18000/...` instead.
The app container now waits for the database and Redis endpoints before running migrations, so transient Docker DNS startup races do not kill the local stack.
## Bootstrap
Create deterministic demo credentials and sample questions with:
```bash
docker compose exec app python manage.py bootstrap_mvp
```
Default output:
- host username: `demo-host`
- host password: `demo-pass`
- category slug: `general`
- questions: `3`
You can override the host/category names with `--username`, `--password`, `--category-slug`, and `--category-name`.
For a quick seeded regression flow, run:
```bash
docker compose exec app python manage.py smoke_staging --artifact /tmp/wpp-smoke.json
```
That creates `smoke-host` / `smoke-pass`, ensures one active smoke question exists, and exercises one full round. Use `bootstrap_mvp` for the reusable local try-out account.
## Local MVP Smoke
For a one-command local MVP proof, run:
```bash
./scripts/run_local_mvp_smoke.sh
```
That starts the compose stack, waits for `/healthz`, runs `bootstrap_mvp`, executes `smoke_staging`, and writes a JSON artifact under `artifacts/local/`.
If port `8000` is busy on your machine, use `APP_PORT=18000 ./scripts/run_local_mvp_smoke.sh`.
By default the stack stays up after the smoke so you can continue in the browser. Use `KEEP_STACK_RUNNING=0` if you want the script to shut the stack down on exit.
## Release Gate
Run the full local MVP release gate with:
```bash
./scripts/verify_mvp_release.sh
```
That runs repo lint, shared i18n drift checks, Django checks/tests, both frontend test/build pipelines, and a `docker compose config` sanity pass.
## Optional SPA Shell
To serve the Angular shell as the UI path instead of the legacy templates:
```bash
USE_SPA_UI=true docker compose --profile spa up --build
```
Use these entry points:
- `http://localhost:4200/` for the SPA landing page with host login, host session creation, and player join
- `http://localhost:4200/host?session=ABC123` for the host shell after a session exists
- `http://localhost:4200/player?session=ABC123` for the player shell after join
- `http://localhost:8000/lobby/ui/host` and `http://localhost:8000/lobby/ui/player` if you want Django to render the SPA shell with `USE_SPA_UI=true`
The raw SPA container serves the compiled Angular app at `/` and also proxies `/accounts/*`, `/lobby/*`, and other Django endpoints back to `http://localhost:8000`.
`WPP_SPA_ASSET_BASE` still points at `http://localhost:4200/browser` because Django-rendered SPA pages load their static bundles from the compiled Angular `browser/` directory.

View File

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

View File

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

View File

@@ -10,11 +10,13 @@ Sikre at release-tags altid repræsenterer faktisk deployet software.
## Release-flow
1. Bekræft architect-gate (`issue #17`) er release-approved.
2. Bekræft tester ikke er aktiv.
3. Deploy kandidat til staging (`infra/staging/deploy_staging.sh`).
4. Verificér `/healthz` + smoke-resultat.
5. Tilføj changelog-entry i `CHANGELOG.md`.
6. Opret release-tag i Gitea (annotated), og referér changelog-sektion i release-notes.
2. Kør den lokale MVP gate: `./scripts/verify_mvp_release.sh`.
3. Bekræft tester ikke er aktiv.
4. Kør helst `infra/staging/deploy_and_smoke_staging.sh [ref] [artifact-path]`.
5. Hvis wrapper ikke bruges: deploy med `infra/staging/deploy_staging.sh` og kør derefter `infra/staging/run_mvp_smoke.sh`.
6. Verificér `/healthz` + smoke-resultat.
7. Tilføj changelog-entry i `CHANGELOG.md`.
8. Opret release-tag i Gitea (annotated), og referér changelog-sektion i release-notes.
## Minimum release-notes template
```markdown

View File

@@ -0,0 +1,69 @@
# SPA visual + realtime smoke artifact
## Purpose
This is the Batch 6 manual evidence lane for the presenter-host and player-phone overhaul. Use it when `USE_SPA_UI=true` and you need reviewable proof that the Angular host/player shells behave correctly across realtime reconnects, role-based visibility, and multi-device presentation.
The automated companion lane for this checklist is:
```bash
npm --prefix frontend/angular test -- src/app/realtime-visual-smoke.spec.ts
```
## When to capture it
- Staging or local smoke after a host/player visual or realtime change.
- Before asking for SPA cutover confidence beyond unit-level component coverage.
- When reconnect recovery or developer-state safety changed and reviewers need concrete device evidence.
## Evidence template
```markdown
### SPA visual + realtime smoke evidence
- Timestamp (UTC): <YYYY-MM-DD HH:MM>
- Environment: <local/staging>
- Commit/Head SHA: <sha>
- `USE_SPA_UI`: `true`
- Locale: <en/da>
- Devices: projected host + <N> player phones/tabs
#### Setup
- Host route: `/lobby/ui/host`
- Player route: `/lobby/ui/player`
- Session code: <code>
- Participants joined: <list or count>
- Developer-state left OFF by default before evidence capture: <yes/no>
#### Checks (PASS/FAIL)
1. Presenter-only question visibility
- Host lie/presenter scene shows the active prompt: <pass/fail>
- Player phones stay prompt-hidden until the allowed phase payload reveals it: <pass/fail>
2. Reconnect recovery
- Disconnect one player device or throttle network during an active lie/guess input: <pass/fail>
- Reconnect badge/card appears without clearing the local draft/selection: <pass/fail>
- Recovered websocket push resumes before the 3s polling fallback becomes the steady-state transport: <pass/fail>
3. Multi-device reveal + scoreboard
- At least 3 player devices reach reveal and scoreboard together: <pass/fail>
- Host projected scene remains presenter-grade through reveal and final standings: <pass/fail>
- Shared player identity tokens/colors/icons stay consistent between the projected host roster and player-phone developer-state snapshots: <pass/fail>
4. Developer-state safety
- Host developer-state screenshot or recording captured separately from the default presenter screen: <pass/fail>
- Player developer-state screenshot or recording captured separately from the default phone UI: <pass/fail>
- Host/player developer-state captures show the current `phase_display.theme` and `phase_display.ornament` tokens plus each player `identity.token` and `identity.icon` so contract-driven scene art/copy and roster styling can be traced back to the payload: <pass/fail>
- At least one lie/guess/reveal capture shows an authored question ornament slug from admin/bootstrap content instead of only the deterministic fallback set: <pass/fail>
5. Optional host voice cue check
- Host-only voice playback still routes on the primary device when enabled: <pass/fail/not-run>
#### Artifact pointers
- Automated smoke command: `npm --prefix frontend/angular test -- src/app/realtime-visual-smoke.spec.ts`
- Screenshot/video refs:
- host projected scene: <ref>
- reconnect recovery: <ref>
- reveal/scoreboard multi-device: <ref>
- host/player developer-state: <ref>
- Result: <PASS/FAIL>
- If FAIL: blocker link + shortest repro
```
## Minimum acceptable artifact
- One projected host screenshot during lie, reveal, or scoreboard.
- One player-device capture showing reconnect recovery or input preservation.
- One reveal or scoreboard capture with 3+ player devices.
- Separate host and player developer-state captures so diagnostics stay out of the default presentation.

View File

@@ -5,6 +5,7 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
## Guardrails (MVP)
- Hold scope inden for #16 (execution board) og #17 (scope guardrail).
- Kun verifikation af eksisterende flow; ingen nye features/polish.
- Primær MVP release-gate bruger legacy UI med `USE_SPA_UI=false`.
## Hvornår bruges artifacten
- Efter staging-smoke af gameplay-flowet: lobby -> join -> start -> runde -> scoreboard -> next/final.
@@ -22,18 +23,13 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
- Host authenticated in Django admin: <yes/no>
- Active category/questions present: <yes/no>
- Participants: host + <N> players
- `USE_SPA_UI`: <on/off>
- `USE_SPA_UI`: `false`
- `WPP_SPA_ASSET_VERSION`: <release-token/sha>
- UI route used:
- OFF (legacy): `/lobby/ui/host` + `/lobby/ui/player`
- ON (SPA shell): `/lobby/ui/host/<spa-path>` + `/lobby/ui/player`
- UI routes used: `/lobby/ui/host` + `/lobby/ui/player`
#### Checks (PASS/FAIL)
0. Same release-window verification
- OFF + ON smoke kørt i samme release-vindue: <pass/fail>
1. Cutover route sanity
- Flag OFF serves legacy UI templates: <pass/fail>
- Flag ON serves SPA shell on expected path(s): <pass/fail>
1. Legacy route sanity
- Host/player legacy templates svarer korrekt: <pass/fail>
2. Lobby -> join -> start
- Mixed-case + whitespace session code accepted: <pass/fail>
3. One full round to scoreboard
@@ -42,10 +38,10 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
- next round transitions: <pass/fail>
- final leaderboard visible: <pass/fail>
#### Smoke-gate decision (før `USE_SPA_UI=true`)
#### MVP smoke-gate decision
- Gate status: <GREEN/RED>
- Gate criteria met:
- [ ] Cutover route sanity = PASS (OFF + ON)
- [ ] Legacy route sanity = PASS
- [ ] Full gameplay round = PASS
- [ ] Next-round/final leaderboard sanity = PASS
- [ ] Ingen nye blocker-regressioner i host/player flow
@@ -53,10 +49,10 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
#### Rollback checkpoint
- Rollback required: <yes/no>
- Trigger reason (if yes): <kort trigger>
- Rollback done (`USE_SPA_UI=false`) verified: <yes/no>
- Rollback done (`USE_SPA_UI=false`) verified: <yes/no/not-needed>
#### Evidence pointers
- Command(s): `<exact command(s)>`
- Command(s): `./infra/staging/deploy_and_smoke_staging.sh [ref] [artifact-path]` or `./infra/staging/run_mvp_smoke.sh [artifact-path]`
- UI notes/screenshots/log refs: <short refs>
- Result: <PASS/FAIL>
- If FAIL: blocker issue link + shortest repro
@@ -64,3 +60,6 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
## Anti-stall minimum
Hvis der ikke er ny kode at ændre, er denne artifact-skabelon den mindste gyldige leverance for at sikre ensartet, reviewbar smoke-evidens i staging.
## SPA cutover note
Hvis der køres separat SPA-cutover, dokumenteres det i et særskilt artifact med henvisning til `docs/spa-cutover-flag.md`. Brug i så fald `ALLOW_SPA_CUTOVER=1` eksplicit ved staging smoke.

View File

@@ -1,48 +1,35 @@
# UI smoke (MVP)
## Forudsætning
- Host er logget ind i Django.
- Mindst én aktiv kategori med spørgsmål findes.
## MVP path
- Current MVP path: `USE_SPA_UI=false`
- Canonical routes: `/lobby/ui/host` + `/lobby/ui/player`
- SPA shell verification is follow-up cutover work; keep it out of the primary MVP smoke.
## Cutover-forudsætning (`USE_SPA_UI`)
- `USE_SPA_UI=false` (default): brug legacy routes `/lobby/ui/host` + `/lobby/ui/player`.
- `USE_SPA_UI=true`: host må gerne testes på SPA deep-link route `/lobby/ui/host/<spa-path>` (fx `/lobby/ui/host/guess`), player på `/lobby/ui/player`.
## Preconditions
- Host can log in through Django.
- At least one active category with questions exists.
- Recommended local bootstrap: `python manage.py bootstrap_mvp`
- Fastest local setup: `./scripts/run_local_mvp_smoke.sh` and then keep the stack running for browser follow-up.
## Flow
1. Verificér cutover-route matcher valgt flag (legacy vs SPA shell).
2. Åbn host-siden og tryk Opret session.
3. Åbn player-siden i 3 faner/enheder.
4. Join alle spillere med sessionkode og nickname.
5. Host: vælg kategori, Start runde, Vis spørgsmål.
6. Spillere: brug round_question_id og submit løgn.
7. Host: Mix svar.
8. Spillere: submit gæt.
9. Host: Beregn score og Vis scoreboard.
10. Host: Næste runde eller Afslut spil.
1. Confirm `USE_SPA_UI=false`.
2. Open `/lobby/ui/host` and create a session.
3. Open `/lobby/ui/player` in 3 tabs or devices.
4. Join all players with the session code and nicknames.
5. Host selects a category, starts the round, and shows the question.
6. Players submit lies.
7. Host mixes answers.
8. Players submit guesses.
9. Host calculates scores and opens the scoreboard.
10. Host starts the next round or finishes the game.
## Smoke-gate (staging cutover)
`USE_SPA_UI` må kun aktiveres i staging når følgende er opfyldt:
- Cutover route sanity er PASS for både OFF (legacy) og ON (SPA shell).
- Én fuld gameplay-runde til scoreboard er PASS.
- Next-round/final leaderboard sanity er PASS.
- Ingen nye blocker-regressioner i host/player kerneflow.
## Pass criteria
- One full round reaches scoreboard without raw API calls.
- Error banners are absent in the host/player core flow.
- Session detail reflects the same phase on both screens.
- Finish-game path shows the final leaderboard.
## Samme release-vindue: SPA OFF + ON verifikation
Kør begge checks i samme release-vindue (samme deploy/artifact version):
1. **OFF-pass (legacy)**
- `USE_SPA_UI=false`
- Verificér legacy routes + fuld runde.
2. **ON-pass (SPA)**
- `USE_SPA_UI=true`
- Behold samme release artifact og kun toggl flag/version-token ved behov.
- Verificér SPA shell routes + fuld runde.
3. Dokumentér begge pass i samme smoke-artifact med UTC timestamps og `WPP_SPA_ASSET_VERSION`.
## Rollback check points
Skift straks tilbage til `USE_SPA_UI=false` hvis en gate fejler:
1. Verificér legacy routes (`/lobby/ui/host` + `/lobby/ui/player`) fungerer igen.
2. Log rollback trigger + kort repro i smoke artifact.
3. Opret/link blocker issue før nyt cutover-forsøg.
Resultat: En fuld runde kan køres uden rå API-kald fra terminal.
## Cutover note
If SPA shell validation is needed, use `docs/spa-cutover-flag.md` and `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md`. Those checks are not the primary MVP smoke gate.
For the presenter/player visual lane specifically, capture the manual evidence in `docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md`.
For local SPA-only checks with the compose `spa` profile, start at `http://localhost:4200/`.

View File

@@ -0,0 +1,193 @@
# Host + Player Visual Overhaul Plan
**Date:** 2026-03-18
**Updated:** 2026-03-23
**Status:** Active
## Goal
- make the SPA host shell usable as the projected primary game screen
- make the SPA player shell a minimal mobile action surface instead of a secondary dashboard
- make phase changes feel shared across devices, without manual refresh in the normal path
- keep explicit developer-state available for host and player without contaminating the default presentation
## Product Decisions From Playtesting
- the authenticated host may still create the session and choose the game, but the first player to join becomes the lobby captain on a phone
- the lobby captain is the default pre-game operator on the player side:
- can start the game
- can confirm readiness
- can choose or rotate a player icon from a curated set when that is supported by the cartridge
- non-captain player devices should stay intentionally simple before game start:
- icon selection if the game supports it
- otherwise a passive "wait for the game to start" state
- once the game has started, player devices should only show the current player action:
- text input
- buttons
- draw area
- hidden private information when a cartridge needs it
- player devices should not show roster, session metadata, or broad game-state detail in the default UX
- pushed updates need to land across clients in the same phase-change window; perfect instant sync is not required, but manual update should not be part of the happy path
## Current State
- role-aware session-detail payloads already exist for host/player/public viewers
- websocket transport and reconnect fallback already exist
- host/player developer-state toggles already exist
- presenter/player visual styling, question ornaments, and host voice playback already exist
- the remaining gaps are mostly product-shape gaps:
- lobby captain flow is not yet the default
- the default player shell still exposes too much context
- synchronized multi-device updates are not yet strong enough for confidence
- authored player identity assets and presenter-copy content are still incomplete
## Non-Goals
- solving every future cartridge in this lane
- custom user-uploaded avatars
- full spectator mode
- native mobile apps
- replacing the existing REST contract wholesale
## Known Issues To Solve
- a player phone cannot yet reliably act as the pre-game "start game" controller
- some clients still feel stale until a manual update path is used
- the default player UI still behaves too much like a diagnostic shell
- the plan file itself previously drifted into websocket-only scope; this document is now the authoritative visual-overhaul plan again
## Verification
- `.venv/bin/python manage.py check`
- `.venv/bin/python manage.py test realtime.tests lobby.tests.SessionDetailPhaseViewModelTests fupogfakta.tests.FupOgFaktaExtractionSliceTests`
- `npm --prefix frontend test -- angular-api-client.test.ts`
- `npm --prefix frontend/angular test -- src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts src/app/realtime-visual-smoke.spec.ts`
- `npm --prefix frontend/angular run build`
- local or staging multi-device smoke captured with `docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md`
## Batch 1 — Lobby Captain Flow
- add an explicit lobby-captain concept to the session contract
- the first player to join becomes the default captain
- expose captain capability to the player shell:
- start game
- ready/continue controls where appropriate
- pre-game icon selection when supported
- keep a clear fallback/override story for dev and staging:
- authenticated host can still inspect or recover the flow
- developer-state shows who the current captain is and why
- update lobby and gameplay rules so "who can start" is deterministic and testable
**Acceptance:** in the normal couch + TV flow, the first joined player can start the game from a phone without needing the projected host screen as an operator console.
## Batch 2 — Realtime Coherence
- treat websocket push as the primary phase-change transport
- make every significant phase transition emit enough information for all connected clients to converge without user action
- add or tighten revision/phase markers so stale refreshes do not leave some devices behind
- keep polling only as fallback and recovery
- if realtime recovery takes longer than a short threshold, show a clear reconnect notice instead of silently leaving stale UI on screen
- extend tests to cover one host plus three player clients moving through the same phase transition window
**Acceptance:** phase changes propagate across host and player clients together closely enough that manual update is not required in the happy path.
## Batch 3 — Simplified Player Phone UX
- reduce the default player shell to one main action surface per phase
- pre-game states:
- captain device: start game + identity/icon choice if supported
- non-captain devices: icon choice or passive wait state
- in-game states:
- input-only or choice-only when action is required
- simple waiting state when no action is required
- private hidden information panel when a cartridge needs it
- remove roster/session/debug context from the default player presentation
- keep developer-state behind the existing explicit toggle/query override
- preserve draft text, selections, and focus during background sync and reconnect
**Acceptance:** a player can glance at the phone and immediately know what to do, without seeing unnecessary room/session detail.
## Batch 4 — Presenter Host Experience
- keep the host screen presenter-first:
- lobby scene
- question/lie scene
- guess scene
- reveal scene
- scoreboard scene
- final result scene
- show the question prominently on the projected host even when players only get input controls
- display player icons/colors consistently across all presenter beats
- keep operational controls in a secondary backstage layer instead of the default projected surface
- integrate voice cues and uploaded audio into the presenter rhythm
- make the default lobby scene support the new player-captain model:
- projected host shows readiness and roster
- captain phone owns the start action
**Acceptance:** the host screen can stay on the TV as the main visual surface without asking the projected operator to click through the game.
## Batch 5 — Content, Assets, and Cartridge Hooks
- add content/admin support for curated player icon sets or avatar-like identity options
- keep authored question ornaments and extend them where useful
- add optional phase-specific presenter copy that can be authored instead of only inferred from shared i18n keys
- define one generic private-info contract for cartridges that need hidden player instructions or roles
- keep the fallback story explicit:
- deterministic icons and copy still work when authored assets are absent
**Acceptance:** the visual lane is not blocked on hardcoded placeholder assets, and future cartridges have a place to attach hidden player info without bloating the default phone UX.
## Batch 6 — Smoke Coverage and Sign-Off
- extend automated smoke for:
- first-player captain start
- websocket phase propagation across host + 3 player clients
- no-input-loss during reconnect
- presenter-only question visibility
- hidden player info routing where applicable
- developer-state visibility and safety gates
- extend the manual artifact checklist so it proves:
- projected host during lobby, reveal, and scoreboard
- captain phone start flow
- at least 3 player devices
- reconnect recovery without manual refresh
- separate host/player developer-state captures
**Acceptance:** the overhaul is demonstrable in a realistic living-room flow, not just in isolated component tests.
## Definition of Done
This lane is complete when:
- the first joined player can start the game from a phone in the normal flow
- non-captain phones stay intentionally minimal before game start
- player phones show only the current action or private hidden info by default
- websocket state sync is the primary update path and manual update is not required in the happy path
- the projected host screen is usable as a real presenter surface
- backend contracts remain role-correct
- host/player visuals share one coherent visual system
- regression tests cover realtime, visibility, and input preservation
- host and player both provide an explicit developer-state that is useful in dev/staging and hidden by default
- the multi-device artifact in `docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md` has been captured for the current flow
## Recommended Order
1. Batch 1: lobby captain flow.
2. Batch 2: realtime coherence.
3. Batch 3: simplified player phone UX.
4. Batch 4: presenter host flow adjustments for the captain model.
5. Batch 5: authored assets and cartridge hooks.
6. Batch 6: smoke coverage and sign-off.
## Ralph Loop Exit Rule
- this plan should only be marked `Completed` when the Definition of Done is satisfied
- narrower websocket or sub-feature slices should be tracked as check-ins under this plan or in separate plan files, not by rewriting this file into a different plan
## Explicitly Deferred
- custom uploaded user avatars
- advanced moderation/admin tooling
- spectator mode
- native mobile clients
- multi-cartridge theming beyond the shared shell and contract hooks above

View File

@@ -0,0 +1,137 @@
# MVP Plan — Deployable and Testable Repository
**Date:** 2026-03-18
**Status:** In progress
## Goal
Get the repository into a state where one person can deploy it to staging or a local VM, run one documented setup flow, and verify one full playable round through the intended UI without ad hoc fixes.
For this plan, **deployable and testable** means:
- backend boots with documented env vars
- frontend assets build successfully
- one UI path is designated as the MVP path
- a development `docker compose` setup exists for the app and its required services
- CI covers the checks needed to trust a release candidate
- a smoke flow proves host + 3 players can complete one full round
## Current Baseline
Verified on 2026-03-18:
- `manage.py check` passes
- `manage.py test lobby fupogfakta` passes
- `npm --prefix frontend test` passes
- `npm --prefix frontend run build` passes
- `npm --prefix frontend/angular test` passes
- `npm --prefix frontend/angular run build` **fails**
Main blockers observed:
1. Angular production build fails on strict template nullability in host/player reveal panels.
2. `shared/i18n/lobby.json` contains duplicate keys and produces build warnings.
3. The repo still has two UI modes (`USE_SPA_UI` ON/OFF), so the MVP “real path” is ambiguous.
4. CI does not yet enforce the full MVP gate.
## Progress Snapshot
Implemented on 2026-03-18:
- legacy UI (`USE_SPA_UI=false`) is the explicit MVP path
- Angular build and test pipeline passes
- duplicate shared i18n keys were removed and drift is checked
- development `docker compose` exists for Django + MySQL + Redis
- local deterministic bootstrap is available via `python manage.py bootstrap_mvp`
- local release verification is available via `./scripts/verify_mvp_release.sh`
- staging deploy + smoke wrappers exist via `infra/staging/deploy_and_smoke_staging.sh`
Remaining MVP sign-off item:
- run the real staging deploy + smoke flow and record the resulting artifact
## Plan
### Batch 1 — Make the chosen UI path buildable
- Decide the MVP runtime path:
- Current choice: legacy UI (`USE_SPA_UI=false`)
- SPA remains a follow-up cutover path after the MVP release gate is stable
- Fix Angular template errors in `frontend/angular/src/app/features/host/host-shell.component.ts` and `frontend/angular/src/app/features/player/player-shell.component.ts`.
- Remove duplicate i18n keys in `shared/i18n/lobby.json`.
- Require these checks to pass locally:
- `npm --prefix frontend/angular test`
- `npm --prefix frontend/angular run build`
### Batch 2 — Make local/staging setup deterministic
- Add one documented bootstrap path for:
- backend venv install
- database migrate
- host user creation
- sample category/question seed
- Create a development `docker compose` file that can start the services needed for local work:
- Django app
- MySQL or the chosen dev database
- Redis for Channels
- optional frontend dev server if the team wants one-command startup
- Ensure the compose setup matches the documented env var contract and supports the chosen MVP UI path.
- Keep SQLite acceptable for local try-out; keep MySQL/Redis for staging.
- Document the exact env vars and `USE_SPA_UI` setting required for the MVP path.
### Batch 3 — Make smoke verification release-grade
- Treat one canonical smoke as required:
- create session
- 3 players join
- start round
- submit lies
- submit guesses
- reveal
- scoreboard
- next round or finish
- Keep `python manage.py smoke_staging --artifact <path>` as the canonical backend smoke entrypoint.
- Provide one staging wrapper command that chains deploy + MVP smoke for release candidates.
- Add one short manual UI smoke checklist for the chosen MVP path only.
### Batch 4 — Align CI with MVP readiness
- Expand CI to run:
- `python manage.py check`
- `python manage.py test lobby fupogfakta`
- `npm --prefix frontend test`
- `npm --prefix frontend run build`
- `npm --prefix frontend/angular test`
- `npm --prefix frontend/angular run build`
- Update lint scope so it no longer only checks `lobby/`.
- Provide one local wrapper command for the same MVP gate before staging deploy.
### Batch 5 — Freeze the MVP boundary
- Declare which items are required for MVP and which are explicitly deferred.
- Defer non-blockers:
- broader game-driver redesign
- extra cartridges
- polished host presentation UX
- voice integration
- post-game awards or richer reactions
## Definition of Done
The repo is MVP-deployable and testable when all of the following are true:
- one UI path is explicitly marked as the MVP path
- Angular production build passes for that path
- a development `docker compose` setup can bring up the required local dependencies
- staging deploy docs match the code and env flags
- CI enforces the same checks used for release sign-off
- one full smoke round passes through host/player UI and is captured as evidence
## Recommended Order
1. Fix Angular build blockers and i18n duplication.
2. Choose SPA ON or legacy as the single MVP runtime path.
3. Add the development `docker compose` setup.
4. Write the bootstrap/setup doc and seed flow.
5. Expand CI to the full MVP gate.
6. Re-run staging smoke and record the artifact.

View File

@@ -3,6 +3,8 @@
## Formål
`USE_SPA_UI` styrer om host/player UI routes serverer Angular SPA shell eller legacy Django templates.
Aktuel MVP release-gate bruger `USE_SPA_UI=false`. Denne note beskriver separat cutover-arbejde, ikke den primære MVP deploy-path.
## Miljø-toggle (uden kodeændring)
Sæt env var pr. miljø:

View File

@@ -66,6 +66,58 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
} as T;
}
if (url === '/lobby/sessions/ABCD12?session_token=session-token-1') {
return {
session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 2 },
viewer_role: 'player',
players: [
{ id: 2, nickname: 'Maja', score: 0, is_connected: true },
{ id: 3, nickname: 'Bo', score: 0, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: null,
shown_at: '2026-03-01T18:00:00Z',
answers: []
},
reveal: null,
voice_cues: {
default_locale: 'en',
intro: null,
phase: null,
question_prompt: null,
question_reveal: null
},
phase_view_model: {
status: 'lie',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: false,
can_show_question: false,
can_mix_answers: true,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: true,
can_submit_guess: false,
can_view_final_result: false
}
}
} as T;
}
if (url === '/lobby/sessions/ABCD12/scoreboard') {
return {
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
@@ -88,6 +140,13 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
} as T;
}
if (url === '/lobby/sessions/create') {
expect(body).toEqual({});
return {
session: { code: 'H0ST42', status: 'lobby', host_id: 1, current_round: 1 }
} as T;
}
if (url === '/lobby/sessions/ABCD12/rounds/start') {
expect(body).toEqual({ category_slug: 'history' });
return {
@@ -191,7 +250,9 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
const client = createAngularApiClient({ get, post } as AngularHttpClientLike);
const session = await client.getSession(' abcd12 ');
const playerSession = await client.getSession(' abcd12 ', { session_token: 'session-token-1' });
expect(session.ok).toBe(true);
expect(playerSession.ok).toBe(true);
if (session.ok) {
expect(session.data.session.code).toBe('ABCD12');
expect(session.data.phase_view_model.host.can_start_next_round).toBe(false);
@@ -199,7 +260,13 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
expect(session.data.reveal?.correct_answer).toBe('A');
expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja');
}
if (playerSession.ok) {
expect(playerSession.data.viewer_role).toBe('player');
expect(playerSession.data.round_question?.prompt).toBeNull();
expect(playerSession.data.voice_cues?.question_prompt).toBeNull();
}
expect((await client.createSession()).ok).toBe(true);
expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true);
expect((await client.startRound(' abcd12 ', { category_slug: 'history' })).ok).toBe(true);
expect((await client.showQuestion(' abcd12 ')).ok).toBe(true);
@@ -228,43 +295,50 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
).toBe(true);
expect(get).toHaveBeenNthCalledWith(1, '/lobby/sessions/ABCD12', { withCredentials: true });
expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12/scoreboard', { withCredentials: true });
expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12?session_token=session-token-1', { withCredentials: true });
expect(get).toHaveBeenNthCalledWith(3, '/lobby/sessions/ABCD12/scoreboard', { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/create',
{},
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/join',
{ code: 'ABCD12', nickname: 'Maja' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
2,
3,
'/lobby/sessions/ABCD12/rounds/start',
{ category_slug: 'history' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(3, '/lobby/sessions/ABCD12/questions/show', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(4, '/lobby/sessions/ABCD12/questions/show', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
4,
5,
'/lobby/sessions/ABCD12/questions/77/answers/mix',
{},
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
5,
6,
'/lobby/sessions/ABCD12/questions/77/scores/calculate',
{},
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(6, '/lobby/sessions/ABCD12/rounds/next', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(7, '/lobby/sessions/ABCD12/finish', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(7, '/lobby/sessions/ABCD12/rounds/next', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(8, '/lobby/sessions/ABCD12/finish', {}, { withCredentials: true });
expect(post).toHaveBeenNthCalledWith(
8,
9,
'/lobby/sessions/ABCD12/questions/77/lies/submit',
{ player_id: 9, session_token: 'session-token-1', text: 'my lie' },
{ withCredentials: true }
);
expect(post).toHaveBeenNthCalledWith(
9,
10,
'/lobby/sessions/ABCD12/questions/77/guesses/submit',
{ player_id: 9, session_token: 'session-token-1', selected_text: 'B' },
{ withCredentials: true }

View File

@@ -1,5 +1,86 @@
.shell { font-family: Arial, sans-serif; margin: 1rem; }
.shell__header { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: space-between; align-items: center; border-bottom: 1px solid #ddd; padding-bottom: 0.75rem; }
.shell__header nav { display: flex; gap: 0.75rem; }
.shell__content { margin-top: 1rem; }
.locale-picker { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.95rem; }
.shell {
margin: 0 auto;
max-width: 84rem;
min-height: 100vh;
padding: 1.25rem;
}
.shell__header {
display: grid;
gap: 1rem;
padding: 1rem 1.1rem;
border: 1px solid var(--wpp-border);
border-radius: var(--wpp-radius-xl);
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.5), transparent 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(245, 249, 250, 0.94));
box-shadow: var(--wpp-shadow-soft);
backdrop-filter: blur(10px);
}
.shell__brand {
display: grid;
gap: 0.3rem;
}
.shell__brand h1 {
margin: 0;
font-family: var(--wpp-font-display);
font-size: clamp(1.8rem, 3vw, 2.6rem);
line-height: 0.95;
}
.shell__brand p {
margin: 0;
color: var(--wpp-ink-muted);
}
.shell__nav-row {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.9rem;
align-items: center;
}
.shell__header nav {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
}
.shell__header nav a {
display: inline-flex;
align-items: center;
padding: 0.58rem 0.86rem;
border-radius: var(--wpp-radius-pill);
color: var(--wpp-accent);
text-decoration: none;
font-weight: 800;
background: var(--wpp-accent-soft);
}
.shell__content {
margin-top: 1.1rem;
}
.locale-picker {
display: inline-flex;
align-items: center;
gap: 0.55rem;
font-size: 0.95rem;
color: var(--wpp-ink);
}
.locale-picker select {
border: 1px solid var(--wpp-border);
border-radius: var(--wpp-radius-pill);
padding: 0.46rem 0.8rem;
background: rgba(255, 255, 255, 0.78);
}
@media (max-width: 720px) {
.shell {
padding: 0.85rem;
}
}

View File

@@ -1,17 +1,24 @@
<main class="shell">
<header class="shell__header">
<h1>{{ copy('app.title') }}</h1>
<nav>
<a routerLink="/host">{{ copy('app.host_nav') }}</a>
<a routerLink="/player">{{ copy('app.player_nav') }}</a>
</nav>
<label class="locale-picker">
{{ copy('app.language_label') }}
<select [ngModel]="locale" (ngModelChange)="setLocale($event)">
<option value="en">English</option>
<option value="da">Dansk</option>
</select>
</label>
<div class="shell__brand">
<p class="wpp-eyebrow">{{ copy('app.home_badge') }}</p>
<h1>{{ copy('app.title') }}</h1>
<p>{{ copy('app.home_intro') }}</p>
</div>
<div class="shell__nav-row">
<nav>
<a routerLink="/">{{ copy('app.home_nav') }}</a>
<a routerLink="/host">{{ copy('app.host_nav') }}</a>
<a routerLink="/player">{{ copy('app.player_nav') }}</a>
</nav>
<label class="locale-picker">
{{ copy('app.language_label') }}
<select [ngModel]="locale" (ngModelChange)="setLocale($event)">
<option value="en">English</option>
<option value="da">Dansk</option>
</select>
</label>
</div>
</header>
<section class="shell__content" [attr.data-wpp-locale]="locale">

View File

@@ -8,6 +8,11 @@ import {
} from './session-route-context';
export const routes: Routes = [
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./features/home/home-shell.component').then((m) => m.HomeShellComponent),
},
{
path: 'host',
resolve: { routeContext: hostRouteContextResolver },
@@ -44,6 +49,5 @@ export const routes: Routes = [
canActivate: [playerRouteGuard],
loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent),
},
{ path: '', pathMatch: 'full', redirectTo: 'player' },
{ path: '**', redirectTo: 'player' },
{ path: '**', redirectTo: '' },
];

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from 'vitest';
import { resolveDeveloperState, toggleDeveloperState } from './developer-state';
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
function storageMock(initial: Record<string, string> = {}): StorageLike {
const data = new Map<string, string>(Object.entries(initial));
return {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
data.set(key, value);
}),
};
}
describe('developer-state helpers', () => {
it('reads persisted developer state when no query override is present', () => {
const storage = storageMock({ 'wpp.host.developer-mode': 'true' });
expect(
resolveDeveloperState('wpp.host.developer-mode', {
storage,
location: { search: '', hash: '#/host/lobby/ABCD12' },
}),
).toBe(true);
});
it('lets query params override persisted state and persists the override', () => {
const storage = storageMock({ 'wpp.host.developer-mode': 'false' });
expect(
resolveDeveloperState('wpp.host.developer-mode', {
storage,
location: { search: '?dev=1', hash: '#/host/lobby/ABCD12' },
}),
).toBe(true);
expect(storage.setItem).toHaveBeenCalledWith('wpp.host.developer-mode', 'true');
});
it('reads hash query overrides for hash-routed SPA paths', () => {
const storage = storageMock();
expect(
resolveDeveloperState('wpp.player.developer-mode', {
storage,
location: { search: '', hash: '#/player/guess/ABCD12?session=ABCD12&dev=1' },
}),
).toBe(true);
});
it('toggles and persists the next developer state value', () => {
const storage = storageMock();
expect(toggleDeveloperState('wpp.player.developer-mode', false, storage)).toBe(true);
expect(storage.setItem).toHaveBeenCalledWith('wpp.player.developer-mode', 'true');
});
});

View File

@@ -0,0 +1,68 @@
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
type LocationLike = Pick<Location, 'search' | 'hash'>;
function readFlag(value: string | null): boolean | null {
if (value === null) {
return null;
}
const normalized = value.trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
return true;
}
if (['0', 'false', 'no', 'off'].includes(normalized)) {
return false;
}
return null;
}
function readHashQuery(hash: string): URLSearchParams {
const queryIndex = hash.indexOf('?');
if (queryIndex === -1) {
return new URLSearchParams();
}
return new URLSearchParams(hash.slice(queryIndex + 1));
}
function resolveFlagFromLocation(locationLike: LocationLike | null | undefined): boolean | null {
if (!locationLike) {
return null;
}
const searchValue = readFlag(new URLSearchParams(locationLike.search || '').get('dev'));
if (searchValue !== null) {
return searchValue;
}
return readFlag(readHashQuery(locationLike.hash || '').get('dev'));
}
export function resolveDeveloperState(
storageKey: string,
options: {
storage?: StorageLike | null;
location?: LocationLike | null;
} = {},
): boolean {
const storage = options.storage ?? (typeof window !== 'undefined' ? window.localStorage : null);
const locationLike = options.location ?? (typeof window !== 'undefined' ? window.location : null);
const locationValue = resolveFlagFromLocation(locationLike);
if (locationValue !== null) {
storage?.setItem(storageKey, String(locationValue));
return locationValue;
}
return storage?.getItem(storageKey) === 'true';
}
export function toggleDeveloperState(
storageKey: string,
currentValue: boolean,
storage?: StorageLike | null,
): boolean {
const nextValue = !currentValue;
const targetStorage = storage ?? (typeof window !== 'undefined' ? window.localStorage : null);
targetStorage?.setItem(storageKey, String(nextValue));
return nextValue;
}

View File

@@ -0,0 +1,118 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AngularApiClient } from '../../../../../src/api/angular-client';
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
import { HomeShellComponent } from './home-shell.component';
type StorageLike = {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
removeItem: (key: string) => void;
};
function storageMock(initial: Record<string, string> = {}): StorageLike {
const data = new Map<string, string>(Object.entries(initial));
return {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
data.set(key, value);
}),
removeItem: vi.fn((key: string) => {
data.delete(key);
}),
};
}
function apiMock(overrides: Partial<AngularApiClient> = {}): AngularApiClient {
return {
health: vi.fn(),
createSession: vi.fn(),
getSession: vi.fn(),
joinSession: vi.fn(),
startRound: vi.fn(),
showQuestion: vi.fn(),
mixAnswers: vi.fn(),
calculateScores: vi.fn(),
getScoreboard: vi.fn(),
startNextRound: vi.fn(),
finishGame: vi.fn(),
submitLie: vi.fn(),
submitGuess: vi.fn(),
...overrides,
} as unknown as AngularApiClient;
}
describe('HomeShellComponent', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('creates a host session and routes to the host shell', async () => {
const sessionStorage = storageMock();
vi.stubGlobal('window', {
sessionStorage,
localStorage: storageMock(),
location: { assign: vi.fn() },
});
const router = { navigate: vi.fn().mockResolvedValue(true) };
const api = apiMock({
createSession: vi.fn().mockResolvedValue({
ok: true,
status: 201,
data: { session: { code: 'ABCD12', status: 'lobby', host_id: 4, current_round: 1 } },
}),
});
const component = new HomeShellComponent().withTestingDependencies({
router,
api,
sessionContextStore: createSessionContextStore(storageMock() as Storage),
location: { assign: vi.fn() },
});
await component.createSession();
expect(sessionStorage.setItem).toHaveBeenCalledWith('wpp.host-session-code', 'ABCD12');
expect(router.navigate).toHaveBeenCalledWith(['/host'], { queryParams: { session: 'ABCD12' } });
expect(component.hostError).toBe('');
});
it('joins a player session and persists player context before routing', async () => {
const localStorage = storageMock();
vi.stubGlobal('window', {
sessionStorage: storageMock(),
localStorage,
location: { assign: vi.fn() },
});
const router = { navigate: vi.fn().mockResolvedValue(true) };
const api = apiMock({
joinSession: vi.fn().mockResolvedValue({
ok: true,
status: 201,
data: {
player: { id: 9, nickname: 'Luna', session_token: 'tok-9', score: 0 },
session: { code: 'ABCD12', status: 'lobby' },
},
}),
});
const store = createSessionContextStore(localStorage as Storage);
const component = new HomeShellComponent().withTestingDependencies({
router,
api,
sessionContextStore: store,
location: { assign: vi.fn() },
});
component.sessionCode = ' abcd12 ';
component.nickname = ' Luna ';
await component.joinSession();
expect(store.get()).toEqual({ sessionCode: 'ABCD12', playerId: 9, token: 'tok-9' });
expect(router.navigate).toHaveBeenCalledWith(['/player'], { queryParams: { session: 'ABCD12' } });
expect(component.playerError).toBe('');
});
});

View File

@@ -0,0 +1,219 @@
import { CommonModule } from '@angular/common';
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import type { AngularApiClient } from '../../../../../src/api/angular-client';
import { createSessionContextStore, type SessionContextStore } from '../../../../../src/spa/session-context-store';
import { subscribeToLocaleChanges, resolvePreferredLocale, t } from '../../lobby-i18n';
import { WPP_API_CLIENT } from '../../wpp-api-client';
type RouterLike = Pick<Router, 'navigate'>;
type LocationLike = Pick<Location, 'assign'>;
type HomeShellDependencies = {
router: RouterLike;
api: AngularApiClient;
sessionContextStore: SessionContextStore;
location: LocationLike | null;
};
function resolveLocalStorage(): Storage | undefined {
if (typeof window === 'undefined') {
return undefined;
}
return window.localStorage;
}
function fallbackRouter(): RouterLike {
return { navigate: async () => false };
}
function fallbackApi(): AngularApiClient {
return {
health: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
createSession: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
getSession: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
joinSession: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
startRound: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
showQuestion: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
mixAnswers: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
calculateScores: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
getScoreboard: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
startNextRound: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
finishGame: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
submitLie: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
submitGuess: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
};
}
function tryInject<T>(factory: () => T, fallback: T): T {
try {
return factory();
} catch {
return fallback;
}
}
@Component({
selector: 'app-home-shell',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<section class="wpp-page landing">
<div class="wpp-hero-card hero">
<div class="wpp-stack">
<p class="wpp-eyebrow">{{ copy('app.home_badge') }}</p>
<h2 class="wpp-title">{{ copy('app.home_title') }}</h2>
<p class="wpp-subtitle hero__intro">{{ copy('app.home_intro') }}</p>
</div>
</div>
<div class="wpp-grid wpp-grid--two">
<article class="wpp-card wpp-stack">
<h3 class="wpp-section-title">{{ copy('app.host_card_title') }}</h3>
<p class="wpp-section-copy">{{ copy('app.host_card_body') }}</p>
<p class="wpp-inline-note">{{ copy('app.host_login_hint') }}</p>
<div class="wpp-action-row">
<button type="button" class="wpp-button wpp-button--secondary" (click)="loginAsHost()">
{{ copy('app.host_login') }}
</button>
<button type="button" class="wpp-button" (click)="createSession()" [disabled]="hostBusy">
{{ hostBusy ? copy('app.creating_session') : copy('app.create_session') }}
</button>
</div>
<p *ngIf="hostError" class="wpp-error">{{ hostError }}</p>
</article>
<article class="wpp-card wpp-stack">
<h3 class="wpp-section-title">{{ copy('app.player_card_title') }}</h3>
<p class="wpp-section-copy">{{ copy('app.player_card_body') }}</p>
<label class="wpp-field">
<span class="wpp-field-label">{{ copy('common.session_code') }}</span>
<input [(ngModel)]="sessionCode" autocomplete="one-time-code" />
</label>
<label class="wpp-field">
<span class="wpp-field-label">{{ copy('player.nickname') }}</span>
<input [(ngModel)]="nickname" autocomplete="nickname" />
</label>
<div class="wpp-action-row">
<button type="button" class="wpp-button" (click)="joinSession()" [disabled]="playerBusy">
{{ playerBusy ? copy('app.joining_session') : copy('app.join_session') }}
</button>
</div>
<p *ngIf="playerError" class="wpp-error">{{ playerError }}</p>
</article>
</div>
</section>
`,
styles: [`
.landing { gap: 1.4rem; }
.hero { max-width: 54rem; }
.hero__intro { max-width: 42rem; }
`],
})
export class HomeShellComponent implements OnInit, OnDestroy {
locale = resolvePreferredLocale();
sessionCode = '';
nickname = '';
hostBusy = false;
playerBusy = false;
hostError = '';
playerError = '';
private router: RouterLike;
private api: AngularApiClient;
private sessionContextStore: SessionContextStore;
private location: LocationLike | null;
private unsubscribeLocale: (() => void) | null = null;
constructor() {
this.router = tryInject(() => inject(Router), fallbackRouter());
this.api = tryInject(() => inject(WPP_API_CLIENT), fallbackApi());
this.sessionContextStore = createSessionContextStore(resolveLocalStorage());
this.location = typeof window !== 'undefined' ? window.location : null;
}
withTestingDependencies(deps: Partial<HomeShellDependencies>): this {
if (deps.router) {
this.router = deps.router;
}
if (deps.api) {
this.api = deps.api;
}
if (deps.sessionContextStore) {
this.sessionContextStore = deps.sessionContextStore;
}
if ('location' in deps) {
this.location = deps.location ?? null;
}
return this;
}
ngOnInit(): void {
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
this.locale = locale;
});
}
ngOnDestroy(): void {
this.unsubscribeLocale?.();
this.unsubscribeLocale = null;
}
copy(key: string): string {
return t(key, this.locale);
}
loginAsHost(): void {
this.location?.assign(`/accounts/login/?next=${encodeURIComponent('/')}`);
}
async createSession(): Promise<void> {
this.hostBusy = true;
this.hostError = '';
const result = await this.api.createSession();
if (!result.ok) {
this.hostBusy = false;
this.hostError = `${this.copy('app.create_session_failed')}: ${result.error.message}`;
return;
}
const code = result.data.session.code;
if (typeof window !== 'undefined') {
window.sessionStorage.setItem('wpp.host-session-code', code);
}
this.hostBusy = false;
await this.router.navigate(['/host'], { queryParams: { session: code } });
}
async joinSession(): Promise<void> {
this.playerBusy = true;
this.playerError = '';
const normalizedCode = this.sessionCode.trim().toUpperCase();
this.sessionCode = normalizedCode;
const result = await this.api.joinSession({
code: normalizedCode,
nickname: this.nickname,
});
if (!result.ok) {
this.playerBusy = false;
this.playerError = `${this.copy('app.join_session_failed')}: ${result.error.message}`;
return;
}
this.sessionContextStore.set({
sessionCode: result.data.session.code,
playerId: result.data.player.id,
token: result.data.player.session_token,
});
this.playerBusy = false;
await this.router.navigate(['/player'], { queryParams: { session: result.data.session.code } });
}
}

View File

@@ -18,11 +18,49 @@ function createFetchRouteMock(handler: FetchRouteHandler): FetchMock {
return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init)));
}
class HostRealtimeSocketMock {
static instances: HostRealtimeSocketMock[] = [];
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly close = vi.fn();
constructor(readonly url: string) {
HostRealtimeSocketMock.instances.push(this);
}
emitClose(event: { code?: number; reason?: string; wasClean?: boolean } = {}): void {
this.onclose?.(event);
}
emitMessage(payload: unknown): void {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitOpen(): void {
this.onopen?.();
}
}
function sessionDetailPayload(
status: string,
options?: {
currentPhase?: string;
roundQuestionId?: number | null;
roundQuestionPrompt?: string | null;
answers?: string[];
players?: Array<{
id: number;
nickname: string;
score: number;
is_connected?: boolean;
identity?: { token: string; tone: string; icon?: string };
}>;
phaseDisplay?: Record<string, string> | null;
voiceCues?: Record<string, unknown> | null;
reveal?: {
correct_answer: string;
prompt?: string;
@@ -40,6 +78,14 @@ function sessionDetailPayload(
}
) {
const roundQuestionId = options?.roundQuestionId ?? 41;
const roundQuestionPrompt = options?.roundQuestionPrompt === undefined ? 'Q?' : options.roundQuestionPrompt;
const players = (options?.players ?? [
{ id: 1, nickname: 'Host', score: 0, is_connected: true },
{ id: 2, nickname: 'Mads', score: 120, is_connected: true },
]).map((player) => ({
...player,
is_connected: player.is_connected ?? true,
}));
return {
session: {
@@ -47,7 +93,7 @@ function sessionDetailPayload(
status,
host_id: 1,
current_round: status === 'lobby' ? 2 : 1,
players_count: 2,
players_count: players.length,
},
round_question:
roundQuestionId === null
@@ -55,14 +101,11 @@ function sessionDetailPayload(
: {
id: roundQuestionId,
round_number: 1,
prompt: 'Q?',
prompt: roundQuestionPrompt,
shown_at: '2026-01-01T00:00:00Z',
answers: [],
answers: (options?.answers ?? []).map((text) => ({ text })),
},
players: [
{ id: 1, nickname: 'Host', score: 0, is_connected: true },
{ id: 2, nickname: 'Mads', score: 120, is_connected: true },
],
players,
reveal:
options?.reveal === undefined || options?.reveal === null
? null
@@ -80,6 +123,43 @@ function sessionDetailPayload(
created_at: guess.created_at ?? '2026-01-01T00:00:10Z',
})),
},
voice_cues:
options?.voiceCues === undefined
? {
default_locale: 'en',
intro: {
cue: 'intro',
translations: { en: 'Welcome to the round.', da: 'Velkommen til runden.' },
audio_urls: {},
source: 'default',
},
phase: {
cue: options?.currentPhase ?? status,
translations: { en: 'Phase cue.', da: 'Fase-tekst.' },
audio_urls: {},
source: 'default',
},
question_prompt:
roundQuestionId === null
? null
: {
cue: 'question_prompt',
translations: { en: 'The question is: Q?', da: 'Sporgsmalet er: Q?' },
audio_urls: {},
source: 'default',
},
question_reveal:
options?.reveal === undefined || options?.reveal === null
? null
: {
cue: 'question_reveal',
translations: { en: 'The correct answer is Mercury.', da: 'Det rigtige svar er Mercury.' },
audio_urls: {},
source: 'default',
},
}
: options.voiceCues,
phase_display: options?.phaseDisplay ?? null,
phase_view_model: {
status,
current_phase: options?.currentPhase ?? status,
@@ -116,6 +196,8 @@ function sessionDetailPayload(
describe('HostShellComponent gameplay wiring', () => {
afterEach(() => {
HostRealtimeSocketMock.instances.length = 0;
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -191,6 +273,401 @@ describe('HostShellComponent gameplay wiring', () => {
});
});
it('builds a presenter-focused lie scene with deterministic player tones', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('lie', { roundQuestionId: 77, roundQuestionPrompt: 'Who built the first telescope?' }) as any;
expect(component.showLiePresenterScene).toBe(true);
expect(component.showPresenterScene).toBe(true);
expect(component.heroTitle).toBe('Backstage control');
expect(component.presenterCueLabel).toBe('Move into the answer mix');
expect(component.presenterPlayers.map((player) => player.tone)).toEqual(['ember', 'lagoon']);
expect(component.presenterPlayers[0]).toMatchObject({
nickname: 'Host',
badge: 'H',
scoreLabel: '0 pts',
tone: 'ember',
});
expect(component.livePlayersCount).toBe(2);
});
it('builds a presenter-focused lobby scene and keeps controls in backstage mode', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('lobby', { roundQuestionId: null }) as any;
expect(component.showLobbyPresenterScene).toBe(true);
expect(component.showPresenterScene).toBe(true);
expect(component.showPresenterRoster).toBe(true);
expect(component.heroTitle).toBe('Backstage control');
expect(component.presenterSceneTitle).toBe('Room open');
expect(component.presenterSceneHeadline).toBe('ABCD12');
expect(component.presenterCueLabel).toBe('Open the next round');
expect(component.presenterLobbyStats).toEqual([
{ label: 'Players', value: '2' },
{ label: 'Live', value: '2' },
{ label: 'Start ready', value: '2/2' },
]);
});
it('prefers contract-driven presenter copy and theme when phase_display is present', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('guess', {
roundQuestionId: 77,
phaseDisplay: {
theme: 'host-verdict',
ornament: 'verdict-wave',
title_key: 'host.presenter_scene_title_lobby',
body_key: 'host.presenter_scene_body_reveal',
cue_label_key: 'host.presenter_scene_cue_scoreboard_label',
cue_body_key: 'host.presenter_scene_cue_scoreboard_body',
},
}) as any;
expect(component.presenterSceneTheme).toBe('host-verdict');
expect(component.presenterSceneOrnament).toBe('verdict-wave');
expect(component.presenterSceneTitle).toBe(component.copy('host.presenter_scene_title_lobby'));
expect(component.presenterSceneBody).toBe(component.copy('host.presenter_scene_body_reveal'));
expect(component.presenterCueLabel).toBe(component.copy('host.presenter_scene_cue_scoreboard_label'));
expect(component.presenterCueBody).toBe(component.copy('host.presenter_scene_cue_scoreboard_body'));
});
it('prefers contract-driven player identity tokens when the session payload includes them', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('scoreboard', {
players: [
{ id: 1, nickname: 'Host', score: 0, identity: { token: 'H1', tone: 'ember', icon: 'spark' } },
{ id: 2, nickname: 'Mads', score: 120, identity: { token: 'M2', tone: 'lagoon', icon: 'wave' } },
],
}) as any;
component.finalLeaderboard = [
{ id: 2, nickname: 'Mads', score: 120 },
{ id: 1, nickname: 'Host', score: 0 },
];
expect(component.presenterPlayers[0]).toMatchObject({
badge: 'H1',
tone: 'ember',
icon: 'spark',
});
expect(component.presenterPlayers[1]).toMatchObject({
badge: 'M2',
tone: 'lagoon',
icon: 'wave',
});
expect(component.playerIcon(2, 'Mads', 1)).toBe('wave');
expect(component.presenterLeaderboard[0]).toMatchObject({
id: 2,
tone: 'lagoon',
icon: 'wave',
});
});
it('builds a presenter-focused guess scene with projected answer cards', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('guess', {
roundQuestionId: 77,
roundQuestionPrompt: 'Who built the first telescope?',
answers: ['Galileo Galilei', 'Isaac Newton', 'Christiaan Huygens'],
}) as any;
expect(component.showGuessPresenterScene).toBe(true);
expect(component.showPresenterRoster).toBe(true);
expect(component.heroTitle).toBe('Backstage control');
expect(component.presenterSceneTitle).toBe('Answer mix in play');
expect(component.presenterCueLabel).toBe('Trigger the reveal');
expect(component.presenterAnswerCards).toEqual([
{ badge: 'A', text: 'Galileo Galilei' },
{ badge: 'B', text: 'Isaac Newton' },
{ badge: 'C', text: 'Christiaan Huygens' },
]);
});
it('summarizes the reveal phase for the presenter screen', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('reveal', {
roundQuestionId: 77,
reveal: {
correct_answer: 'Mercury',
prompt: 'Which planet is closest to the sun?',
lies: [{ player_id: 2, nickname: 'Mads', text: 'Venus' }],
guesses: [
{
player_id: 3,
nickname: 'Luna',
selected_text: 'Venus',
is_correct: false,
fooled_player_id: 2,
fooled_player_nickname: 'Mads',
},
{
player_id: 1,
nickname: 'Host',
selected_text: 'Mercury',
is_correct: true,
fooled_player_id: null,
},
],
},
}) as any;
expect(component.showRevealPresenterScene).toBe(true);
expect(component.presenterSceneHeadline).toBe('Mercury');
expect(component.presenterCueLabel).toBe('Land the scoreboard');
expect(component.presenterRevealStats).toEqual([
{ label: 'Lies in play', value: '1' },
{ label: 'Correct guesses', value: '1' },
{ label: 'Players fooled', value: '1' },
]);
});
it('sorts the presenter leaderboard for scoreboard and finished scenes', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
component.session.players = [
{ id: 1, nickname: 'Host', score: 40, is_connected: true },
{ id: 2, nickname: 'Mads', score: 120, is_connected: true },
{ id: 3, nickname: 'Luna', score: 120, is_connected: false },
];
expect(component.showScoreboardPresenterScene).toBe(true);
expect(component.presenterCueLabel).toBe('Choose the next beat');
expect(component.presenterLeaderboard.map((entry) => [entry.rank, entry.nickname, entry.scoreLabel])).toEqual([
[1, 'Luna', '120 pts'],
[2, 'Mads', '120 pts'],
[3, 'Host', '40 pts'],
]);
component.finalLeaderboard = [
{ id: 2, nickname: 'Mads', score: 180 },
{ id: 3, nickname: 'Luna', score: 240 },
];
component.session = sessionDetailPayload('finished', { roundQuestionId: null }) as any;
expect(component.showFinishedPresenterScene).toBe(true);
expect(component.presenterLeader?.nickname).toBe('Luna');
expect(component.presenterCueLabel).toBe('Hold the closing frame');
});
it('suppresses the lie presenter scene when the prompt is unavailable', () => {
const component = new HostShellComponent();
component.session = sessionDetailPayload('lie', { roundQuestionId: 77, roundQuestionPrompt: null }) as any;
expect(component.showLiePresenterScene).toBe(false);
expect(component.heroTitle).toBe('Waiting for the next round to begin.');
});
it('speaks resolved voice cues on host refresh and can replay them', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(
200,
sessionDetailPayload('lie', {
roundQuestionId: 77,
voiceCues: {
default_locale: 'en',
intro: {
cue: 'intro',
translations: { en: 'Welcome intro.', da: 'Velkomst intro.' },
audio_urls: {},
source: 'custom',
},
phase: {
cue: 'lie',
translations: { en: 'Write a believable lie.', da: 'Skriv en trovardig logn.' },
audio_urls: {},
source: 'custom',
},
question_prompt: {
cue: 'question_prompt',
translations: { en: 'The question is: Q?', da: 'Sporgsmalet er: Q?' },
audio_urls: {},
source: 'custom',
},
question_reveal: null,
},
})
)
);
const speak = vi.fn();
const cancel = vi.fn();
class FakeSpeechSynthesisUtterance {
text: string;
lang = '';
constructor(text: string) {
this.text = text;
}
}
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('window', {
location: { hash: '' },
history: { state: null, replaceState: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
speechSynthesis: { speak, cancel },
});
vi.stubGlobal('SpeechSynthesisUtterance', FakeSpeechSynthesisUtterance as unknown as typeof SpeechSynthesisUtterance);
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(speak).toHaveBeenCalledTimes(1);
const spoken = speak.mock.calls[0][0] as FakeSpeechSynthesisUtterance;
expect(spoken.text).toContain('Welcome intro.');
expect(spoken.text).toContain('Write a believable lie.');
expect(spoken.lang).toBe('en-US');
component.replayVoiceCue();
expect(cancel).toHaveBeenCalledTimes(2);
expect(speak).toHaveBeenCalledTimes(2);
});
it('prefers uploaded audio assets over speech synthesis when available', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(
200,
sessionDetailPayload('lobby', {
roundQuestionId: null,
voiceCues: {
default_locale: 'en',
intro: {
cue: 'intro',
translations: { en: 'Welcome intro.', da: 'Velkomst intro.' },
audio_urls: { en: '/media/voice/phase/intro-en.mp3' },
source: 'custom',
},
phase: null,
question_prompt: null,
question_reveal: null,
},
})
)
);
const speak = vi.fn();
const cancel = vi.fn();
const audioPlay = vi.fn(function (this: { onended?: (() => void) | null }) {
this.onended?.();
return Promise.resolve();
});
class FakeAudio {
src?: string;
currentTime = 0;
onended: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor(src?: string) {
this.src = src;
}
addEventListener(type: 'ended' | 'error', listener: () => void): void {
if (type === 'ended') {
this.onended = listener;
return;
}
this.onerror = listener;
}
removeEventListener(type: 'ended' | 'error', listener: () => void): void {
if (type === 'ended' && this.onended === listener) {
this.onended = null;
return;
}
if (type === 'error' && this.onerror === listener) {
this.onerror = null;
}
}
pause = vi.fn();
play = audioPlay;
}
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('Audio', FakeAudio as unknown as typeof Audio);
vi.stubGlobal('window', {
location: { hash: '' },
history: { state: null, replaceState: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
speechSynthesis: { speak, cancel },
});
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(audioPlay).toHaveBeenCalledTimes(1);
expect((audioPlay.mock.instances[0] as FakeAudio).src).toBe('/media/voice/phase/intro-en.mp3');
expect(speak).not.toHaveBeenCalled();
});
it('bootstraps csrf before host transition posts when shell uses direct fetch', async () => {
let cookieValue = '';
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'GET' && url === '/lobby/csrf') {
cookieValue = 'csrftoken=csrf-token-1';
return jsonResponse(200, { csrf_token: 'csrf-token-1' });
}
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/show') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 1 } });
}
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 41 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('window', {
location: { hash: '' },
history: { state: null, replaceState: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
});
vi.stubGlobal('document', {});
Object.defineProperty(document, 'cookie', {
configurable: true,
get: () => cookieValue,
set: (value: string) => {
cookieValue = value;
},
});
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
component.session = sessionDetailPayload('lie', { roundQuestionId: 41 }) as any;
await component.showQuestion();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/csrf',
expect.objectContaining({
method: 'GET',
credentials: 'same-origin',
})
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12/questions/show',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
headers: expect.objectContaining({
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRFToken': 'csrf-token-1',
}),
})
);
});
it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => {
let refreshCount = 0;
const fetchMock = createFetchRouteMock((input, init) => {
@@ -243,6 +720,100 @@ describe('HostShellComponent gameplay wiring', () => {
expect(fetchMock).toHaveBeenCalledTimes(6);
});
it('switches host sync to websocket when realtime is available and refreshes on pushed phase events', async () => {
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
if (fetchMock.mock.calls.length === 1) {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 41 }));
}
return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 41 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', HostRealtimeSocketMock as unknown as typeof WebSocket);
vi.stubGlobal('window', {
location: { hash: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
speechSynthesis: { speak: vi.fn(), cancel: vi.fn() },
});
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
HostRealtimeSocketMock.instances[0]?.emitOpen();
HostRealtimeSocketMock.instances[0]?.emitMessage({ type: 'phase.guess_started' });
await vi.waitFor(() => {
expect(component.session?.session.status).toBe('guess');
});
expect(HostRealtimeSocketMock.instances[0]?.url).toBe('ws://localhost:4200/ws/game/ABCD12/?role=host');
expect(component.syncTransport).toBe('websocket');
expect(component.lastRealtimeEventType).toBe('phase.guess_started');
expect(component.loading).toBe(false);
});
it('recovers host realtime subscriptions after disconnects before polling fallback fires', async () => {
vi.useFakeTimers();
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
if (fetchMock.mock.calls.length === 1) {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 41 }));
}
return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 41 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', HostRealtimeSocketMock as unknown as typeof WebSocket);
vi.stubGlobal('window', {
location: { hash: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
speechSynthesis: { speak: vi.fn(), cancel: vi.fn() },
});
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
HostRealtimeSocketMock.instances[0]?.emitOpen();
HostRealtimeSocketMock.instances[0]?.emitClose({ code: 1006, wasClean: false });
expect(component.syncTransport).toBe('polling');
await vi.advanceTimersByTimeAsync(1500);
expect(HostRealtimeSocketMock.instances).toHaveLength(2);
HostRealtimeSocketMock.instances[1]?.emitOpen();
expect(component.syncTransport).toBe('websocket');
await vi.advanceTimersByTimeAsync(3000);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(component.loading).toBe(false);
HostRealtimeSocketMock.instances[1]?.emitMessage({ type: 'phase.guess_started' });
await vi.waitFor(() => {
expect(component.session?.session.status).toBe('guess');
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(component.lastRealtimeEventType).toBe('phase.guess_started');
});
it('runs next-round transition without reload and clears scoreboard payload', async () => {
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,33 @@ import { PlayerShellComponent } from './player-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
class RealtimeSocketMock {
static instances: RealtimeSocketMock[] = [];
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly close = vi.fn();
constructor(readonly url: string) {
RealtimeSocketMock.instances.push(this);
}
emitClose(event: { code?: number; reason?: string; wasClean?: boolean } = {}): void {
this.onclose?.(event);
}
emitMessage(payload: unknown): void {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitOpen(): void {
this.onopen?.();
}
}
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
@@ -18,7 +45,19 @@ function sessionDetailPayload(
options?: {
currentPhase?: string;
answers?: string[];
players?: Array<{ id: number; nickname: string; score: number }>;
phaseDisplay?: Record<string, string> | null;
players?: Array<{
id: number;
nickname: string;
score: number;
identity?: { token: string; tone: string; icon?: string };
}>;
playerPermissions?: Partial<{
can_join: boolean;
can_submit_lie: boolean;
can_submit_guess: boolean;
can_view_final_result: boolean;
}>;
roundQuestionId?: number | null;
reveal?: {
correct_answer: string;
@@ -78,6 +117,7 @@ function sessionDetailPayload(
created_at: guess.created_at ?? '2026-01-01T00:00:10Z',
})),
},
phase_display: options?.phaseDisplay ?? null,
phase_view_model: {
status,
current_phase: options?.currentPhase ?? status,
@@ -103,10 +143,10 @@ function sessionDetailPayload(
can_finish_game: false,
},
player: {
can_join: (options?.currentPhase ?? status) === 'lobby',
can_submit_lie: (options?.currentPhase ?? status) === 'lie',
can_submit_guess: (options?.currentPhase ?? status) === 'guess',
can_view_final_result: (options?.currentPhase ?? status) === 'finished',
can_join: options?.playerPermissions?.can_join ?? (options?.currentPhase ?? status) === 'lobby',
can_submit_lie: options?.playerPermissions?.can_submit_lie ?? (options?.currentPhase ?? status) === 'lie',
can_submit_guess: options?.playerPermissions?.can_submit_guess ?? (options?.currentPhase ?? status) === 'guess',
can_view_final_result: options?.playerPermissions?.can_view_final_result ?? (options?.currentPhase ?? status) === 'finished',
},
},
};
@@ -114,6 +154,7 @@ function sessionDetailPayload(
describe('PlayerShellComponent gameplay wiring', () => {
afterEach(() => {
RealtimeSocketMock.instances.length = 0;
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -138,6 +179,176 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(component.selectedGuess).toBe('');
});
it('builds a player join scene with room stats before the device is claimed', () => {
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.session = sessionDetailPayload('lobby', {
roundQuestionId: null,
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
],
}) as any;
expect(component.showPlayerScene).toBe(true);
expect(component.showJoinControls).toBe(true);
expect(component.showRoomRoster).toBe(true);
expect(component.playerSceneTitle).toBe('Player station');
expect(component.playerSceneHeadline).toBe('ABCD12');
expect(component.playerSceneCueLabel).toBe('Claim this screen');
expect(component.playerSceneStats).toEqual([
{ label: 'Players', value: '2' },
{ label: 'Live', value: '2' },
]);
});
it('builds a joined lobby waiting scene with score-aware room stats', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('lobby', {
roundQuestionId: null,
playerPermissions: { can_join: false },
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
],
}) as any;
expect(component.showPlayerLobbyScene).toBe(true);
expect(component.showPlayerScene).toBe(true);
expect(component.playerSceneTitle).toBe('Seat reserved');
expect(component.playerSceneHeadline).toBe('Luna');
expect(component.playerSceneCueLabel).toBe('Wait for the host');
expect(component.playerSceneStats).toEqual([
{ label: 'Players', value: '2' },
{ label: 'Live', value: '2' },
{ label: 'Your score', value: '35 pts' },
]);
});
it('prefers contract-driven player copy and theme when phase_display is present', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('guess', {
answers: ['A', 'B'],
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
],
phaseDisplay: {
theme: 'player-ripple',
ornament: 'ripple-flare',
title_key: 'player.reveal_title',
body_key: 'player.phase_summary_reveal',
cue_label_key: 'player.active_scene_cue_reveal_label',
cue_body_key: 'player.active_scene_cue_reveal_body',
},
}) as any;
expect(component.activeSceneTheme).toBe('player-ripple');
expect(component.activeSceneOrnament).toBe('ripple-flare');
expect(component.activeSceneTitle).toBe(component.copy('player.reveal_title'));
expect(component.activeSceneBody).toBe(component.copy('player.phase_summary_reveal'));
expect(component.activeSceneCueLabel).toBe(component.copy('player.active_scene_cue_reveal_label'));
expect(component.activeSceneCueBody).toBe(component.copy('player.active_scene_cue_reveal_body'));
});
it('prefers contract-driven player identity tokens when the session payload includes them', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('scoreboard', {
roundQuestionId: null,
playerPermissions: { can_join: false, can_view_final_result: false },
players: [
{ id: 2, nickname: 'Mads', score: 20, identity: { token: 'M1', tone: 'ember', icon: 'spark' } },
{ id: 3, nickname: 'Luna', score: 35, identity: { token: 'L2', tone: 'lagoon', icon: 'wave' } },
],
}) as any;
expect(component.playerIdentityToken(3, 'Luna', 1)).toBe('L2');
expect(component.playerTone(3, 'Luna', 1)).toBe('lagoon');
expect(component.playerIcon(3, 'Luna', 1)).toBe('wave');
expect(component.resultLeaderboard[0].identityToken).toBe('L2');
expect(component.resultLeaderboard[0].identityTone).toBe('lagoon');
expect(component.resultLeaderboard[0].identityIcon).toBe('wave');
});
it('treats post-submit lie state as a waiting-room scene instead of a blank task area', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('lie', {
roundQuestionId: 11,
playerPermissions: { can_submit_lie: false },
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
],
}) as any;
expect(component.showLieControls).toBe(false);
expect(component.showWaitingState).toBe(true);
expect(component.showPlayerWaitingScene).toBe(true);
expect(component.playerSceneTitle).toBe('Lie locked in');
expect(component.playerSceneHeadline).toBe('Luna');
expect(component.playerSceneCueLabel).toBe('Hold your place');
expect(component.showRoomRoster).toBe(true);
});
it('builds a lie-phase active scene with a prompt-first composer and room stats', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('lie', {
roundQuestionId: 11,
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
],
}) as any;
expect(component.showActivePlayerScene).toBe(true);
expect(component.showLieControls).toBe(true);
expect(component.showRoomRoster).toBe(true);
expect(component.activeSceneTitle).toBe('Submit lie');
expect(component.activeSceneHeadline).toBe('Q?');
expect(component.activeSceneCueLabel).toBe('Sell the bluff');
expect(component.activeSceneStats).toEqual([
{ label: 'Players', value: '2' },
{ label: 'Live', value: '2' },
{ label: 'Your score', value: '35 pts' },
]);
expect(component.activeSupportBody).toBe('Type a believable lie and send it when you are ready.');
});
it('builds a guess-phase active scene with answer counts and local selection state', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('guess', {
answers: ['A', 'B', 'C'],
roundQuestionId: 11,
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
],
}) as any;
expect(component.showActivePlayerScene).toBe(true);
expect(component.showGuessControls).toBe(true);
expect(component.showRoomRoster).toBe(true);
expect(component.activeSceneTitle).toBe('Submit guess');
expect(component.activeSceneCueLabel).toBe('Pick the truth');
expect(component.activeSceneStats).toEqual([
{ label: 'Answers', value: '3' },
{ label: 'Selected answer', value: 'Not picked yet' },
{ label: 'Your score', value: '35 pts' },
]);
});
it('surfaces lie submit error and allows retry success flow', async () => {
const fetchMock: FetchMock = vi
.fn()
@@ -177,6 +388,88 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('bootstraps csrf before lie submit when player shell posts directly', async () => {
let cookieValue = '';
const fetchMock: FetchMock = vi.fn().mockImplementation(async (input: string, init?: RequestInit) => {
if (input === '/lobby/csrf') {
cookieValue = 'csrftoken=csrf-token-1';
return jsonResponse(200, { csrf_token: 'csrf-token-1' });
}
if (input === '/lobby/sessions/ABCD12/questions/11/lies/submit') {
return jsonResponse(201, {
lie: {
id: 1,
player_id: 9,
round_question_id: 11,
text: 'my lie',
created_at: '2026-01-01T00:00:01Z',
},
window: { lie_deadline_at: '2026-01-01T00:00:45Z' },
});
}
if (input === '/lobby/sessions/ABCD12?session_token=token-1') {
return jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] }));
}
throw new Error(`Unexpected fetch: ${input}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('window', {
location: { hash: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('document', {});
Object.defineProperty(document, 'cookie', {
configurable: true,
get: () => cookieValue,
set: (value: string) => {
cookieValue = value;
},
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'token-1';
component.lieText = 'my lie';
component.session = {
...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [] },
};
await component.submitLie();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/csrf',
expect.objectContaining({
method: 'GET',
credentials: 'same-origin',
})
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12/questions/11/lies/submit',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
headers: expect.objectContaining({
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRFToken': 'csrf-token-1',
}),
})
);
expect(component.submitError).toBeNull();
expect(component.session?.session.status).toBe('guess');
});
it('builds final leaderboard in finished status without legacy page hop', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(
@@ -199,6 +492,9 @@ describe('PlayerShellComponent gameplay wiring', () => {
await component.refreshSession();
expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']);
expect(component.showResultScene).toBe(true);
expect(component.activeSceneTitle).toBe('Final leaderboard');
expect(component.phaseSummary).toBe('The game is over. The final standings are ready below.');
});
it('hydrates canonical reveal payload after guess -> reveal', async () => {
@@ -258,6 +554,82 @@ describe('PlayerShellComponent gameplay wiring', () => {
});
});
it('builds a reveal-phase active scene with personal outcome stats and recap copy', () => {
const component = new PlayerShellComponent();
component.playerId = 9;
component.sessionToken = 'tok-9';
component.session = sessionDetailPayload('reveal', {
answers: ['A', 'B'],
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 9, nickname: 'Luna', score: 35 },
{ id: 10, nickname: 'Omar', score: 28 },
],
reveal: {
correct_answer: 'A',
lies: [{ player_id: 9, nickname: 'Luna', text: 'B' }],
guesses: [
{
player_id: 9,
nickname: 'Luna',
selected_text: 'B',
is_correct: false,
fooled_player_id: 2,
fooled_player_nickname: 'Mads',
},
{
player_id: 10,
nickname: 'Omar',
selected_text: 'B',
is_correct: false,
fooled_player_id: 9,
fooled_player_nickname: 'Luna',
},
],
},
}) as any;
expect(component.showActivePlayerScene).toBe(true);
expect(component.showRevealScene).toBe(true);
expect(component.showRoomRoster).toBe(false);
expect(component.activeSceneTitle).toBe('Reveal');
expect(component.activeSceneHeadline).toBe('A');
expect(component.revealGuessResultText).toBe('fooled by Mads');
expect(component.playersFooledCount).toBe(1);
expect(component.activeSceneStats).toEqual([
{ label: 'Your guess', value: 'fooled by Mads' },
{ label: 'Players fooled', value: '1' },
{ label: 'Your score', value: '35 pts' },
]);
});
it('builds a scoreboard result scene with current placement and lead score', () => {
const component = new PlayerShellComponent();
component.playerId = 3;
component.sessionToken = 'tok-3';
component.session = sessionDetailPayload('scoreboard', {
roundQuestionId: null,
players: [
{ id: 2, nickname: 'Mads', score: 20 },
{ id: 3, nickname: 'Luna', score: 35 },
{ id: 5, nickname: 'Omar', score: 32 },
],
reveal: {
correct_answer: 'A',
},
}) as any;
expect(component.showResultScene).toBe(true);
expect(component.playerHeadline).toBe('Scoreboard');
expect(component.activeSceneHeadline).toBe('#1');
expect(component.activeSceneStats).toEqual([
{ label: 'Your place', value: '#1' },
{ label: 'Lead score', value: '35 pts' },
{ label: 'Your score', value: '35 pts' },
]);
expect(component.playerActionSummary).toBe('Check the current standings and stay ready for the next transition.');
});
it('surfaces guess submit error and retries with selected answer payload', async () => {
const fetchMock: FetchMock = vi
.fn()
@@ -345,6 +717,218 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.ngOnDestroy();
});
it('prefers websocket sync for connected players and refreshes on pushed phase events', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] })))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A', 'B'], reveal: { correct_answer: 'A' } })));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', RealtimeSocketMock as unknown as typeof WebSocket);
vi.stubGlobal('window', {
location: { hash: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'tok-1';
await component.refreshSession();
RealtimeSocketMock.instances[0]?.emitOpen();
RealtimeSocketMock.instances[0]?.emitMessage({ type: 'phase.reveal_started' });
await vi.waitFor(() => {
expect(component.session?.session.status).toBe('reveal');
});
expect(RealtimeSocketMock.instances[0]?.url).toBe('ws://localhost:4200/ws/game/ABCD12/?session_token=tok-1');
expect(component.syncTransport).toBe('websocket');
expect(component.lastRealtimeEventType).toBe('phase.reveal_started');
});
it('keeps the selected guess through websocket refresh when the canonical phase is still guess', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] })))
.mockResolvedValueOnce(
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'guess', answers: ['A', 'B', 'C'] }))
);
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', RealtimeSocketMock as unknown as typeof WebSocket);
vi.stubGlobal('window', {
location: { hash: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'tok-1';
await component.refreshSession();
component.selectedGuess = 'B';
RealtimeSocketMock.instances[0]?.emitOpen();
RealtimeSocketMock.instances[0]?.emitMessage({ type: 'phase.guess_snapshot' });
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
expect(component.gameplayPhase).toBe('guess');
expect(component.selectedGuess).toBe('B');
expect(component.lastRealtimeEventType).toBe('phase.guess_snapshot');
});
it('recovers player websocket sync before polling fallback and keeps the selected guess intact', async () => {
vi.useFakeTimers();
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] })))
.mockResolvedValueOnce(
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'guess', answers: ['A', 'B', 'C'] }))
);
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', RealtimeSocketMock as unknown as typeof WebSocket);
vi.stubGlobal('window', {
location: { hash: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'tok-1';
await component.refreshSession();
component.selectedGuess = 'B';
RealtimeSocketMock.instances[0]?.emitOpen();
RealtimeSocketMock.instances[0]?.emitClose({ code: 1006, wasClean: false });
expect(component.syncTransport).toBe('polling');
expect(component.showSyncStatusCard).toBe(true);
await vi.advanceTimersByTimeAsync(1500);
expect(RealtimeSocketMock.instances).toHaveLength(2);
RealtimeSocketMock.instances[1]?.emitOpen();
expect(component.syncTransport).toBe('websocket');
expect(component.showSyncStatusCard).toBe(false);
await vi.advanceTimersByTimeAsync(3000);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(component.selectedGuess).toBe('B');
RealtimeSocketMock.instances[1]?.emitMessage({ type: 'phase.guess_snapshot' });
await vi.waitFor(() => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
expect(component.gameplayPhase).toBe('guess');
expect(component.selectedGuess).toBe('B');
expect(component.lastRealtimeEventType).toBe('phase.guess_snapshot');
});
it('keeps polling in the background without toggling loading or clearing active input focus state', async () => {
vi.useFakeTimers();
let resolveBackgroundRefresh: ((value: Response) => void) | null = null;
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: 11 })))
.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveBackgroundRefresh = resolve;
})
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.lieText = 'typed lie';
await component.refreshSession();
await vi.advanceTimersByTimeAsync(3100);
expect(component.loading).toBe(false);
expect(component.loadingTransition).toBeNull();
expect(component.lieText).toBe('typed lie');
expect(component.backgroundRefreshNotice).toBe('');
expect(component.showSyncStatusCard).toBe(false);
await vi.advanceTimersByTimeAsync(5000);
expect(component.backgroundRefreshNotice).toBe('Refreshing in the background is taking longer than expected…');
expect(component.showSyncStatusCard).toBe(true);
expect(component.syncStatusTitle).toBe('Background refresh is slow');
expect(component.syncStatusBody).toContain('Keep your input here');
resolveBackgroundRefresh?.(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null })));
await vi.advanceTimersByTimeAsync(0);
expect(component.backgroundRefreshNotice).toBe('');
expect(component.session?.session.status).toBe('lobby');
component.ngOnDestroy();
});
it('shows reconnect recovery state while fallback polling keeps a lie draft intact', async () => {
vi.useFakeTimers();
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 11 })))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 11 })));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', RealtimeSocketMock as unknown as typeof WebSocket);
vi.stubGlobal('window', {
location: { hash: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'tok-1';
await component.refreshSession();
RealtimeSocketMock.instances[0]?.emitOpen();
component.lieText = 'typed lie';
RealtimeSocketMock.instances[0]?.emitClose({ code: 1006, wasClean: false });
expect(component.syncTransport).toBe('polling');
expect(component.showSyncStatusCard).toBe(true);
expect(component.syncStatusTitle).toBe('Live sync is reconnecting');
await vi.advanceTimersByTimeAsync(5000);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(component.lieText).toBe('typed lie');
expect(component.showSyncStatusCard).toBe(true);
expect(component.syncStatusChips).toContainEqual({ label: 'Draft length', value: '9' });
component.ngOnDestroy();
});
it('enters reconnecting state when network request fails while online', async () => {
vi.stubGlobal('navigator', { onLine: true });
@@ -377,26 +961,46 @@ describe('PlayerShellComponent gameplay wiring', () => {
it('tracks loading transition message for join action', async () => {
let resolveJoin: ((value: Response) => void) | null = null;
const fetchMock: FetchMock = vi.fn().mockImplementation(
() =>
new Promise<Response>((resolve) => {
resolveJoin = resolve;
})
);
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { csrf_token: 'csrf-token-1' }))
.mockImplementationOnce(
() =>
new Promise<Response>((resolve) => {
resolveJoin = resolve;
})
)
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null })));
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
vi.spyOn(component as never, 'scheduleStateSync').mockImplementation(() => {});
component.sessionCode = 'ABCD12';
component.nickname = 'Luna';
const joinPromise = component.joinSession();
await Promise.resolve();
expect(component.loading).toBe(true);
expect(component.loadingMessage).toBe('Joining session… restoring your player state.');
resolveJoin?.(jsonResponse(201, sessionDetailPayload('lobby', { roundQuestionId: null })));
resolveJoin?.(
jsonResponse(201, {
player: { id: 9, nickname: 'Luna', session_token: 'tok-9', score: 0 },
session: { code: 'ABCD12', status: 'lobby' },
})
);
await joinPromise;
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/join',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({ code: 'ABCD12', nickname: 'Luna' }),
})
);
expect(component.loading).toBe(false);
expect(component.loadingTransition).toBeNull();
});

View File

@@ -104,9 +104,11 @@ describe('lobby i18n locale propagation', () => {
const baselineKeys = [
'lobby.shell.title',
'lobby.shell.home_nav',
'lobby.shell.host_nav',
'lobby.shell.player_nav',
'lobby.shell.language_label',
'lobby.shell.home_title',
'common.refresh',
'common.session_code',
'game.host.title',

View File

@@ -0,0 +1,363 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { SessionDetailResponse } from '../../../src/api/types';
import { HostShellComponent } from './features/host/host-shell.component';
import { PlayerShellComponent } from './features/player/player-shell.component';
import { setPreferredLocale } from './lobby-i18n';
type ViewerRole = 'host' | 'player';
type SessionSeedPlayer = Pick<SessionDetailResponse['players'][number], 'id' | 'nickname' | 'score'> & {
is_connected?: boolean;
identity?: SessionDetailResponse['players'][number]['identity'];
};
type SessionSeed = {
viewerRole: ViewerRole;
status: string;
currentPhase?: string;
prompt?: string | null;
answers?: string[];
phaseDisplay?: SessionDetailResponse['phase_display'];
players?: SessionSeedPlayer[];
playerPermissions?: Partial<SessionDetailResponse['phase_view_model']['player']>;
roundQuestionId?: number | null;
};
class SharedRealtimeSocketMock {
static instances: SharedRealtimeSocketMock[] = [];
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly close = vi.fn();
constructor(readonly url: string) {
SharedRealtimeSocketMock.instances.push(this);
}
emitClose(event: { code?: number; reason?: string; wasClean?: boolean } = {}): void {
this.onclose?.(event);
}
emitMessage(payload: unknown): void {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitOpen(): void {
this.onopen?.();
}
}
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
function buildSessionDetail(seed: SessionSeed): SessionDetailResponse {
const phase = seed.currentPhase ?? seed.status;
const players = (seed.players ?? [
{ id: 1, nickname: 'Host', score: 0, is_connected: true },
{ id: 9, nickname: 'Luna', score: 120, is_connected: true },
{ id: 10, nickname: 'Mads', score: 80, is_connected: true },
]).map((player) => ({
...player,
is_connected: player.is_connected ?? true,
}));
const roundQuestionId = seed.roundQuestionId ?? 41;
return {
session: {
code: 'ABCD12',
status: seed.status,
host_id: seed.viewerRole === 'host' ? 1 : null,
current_round: 1,
players_count: players.length,
},
viewer_role: seed.viewerRole,
players,
round_question:
roundQuestionId === null
? null
: {
id: roundQuestionId,
round_number: 1,
prompt: seed.prompt === undefined ? 'Which planet is closest to the sun?' : seed.prompt,
shown_at: '2026-03-23T11:24:02Z',
answers: (seed.answers ?? []).map((text) => ({ text })),
},
reveal: null,
voice_cues: null,
phase_display: seed.phaseDisplay ?? null,
phase_view_model: {
status: seed.status,
current_phase: phase,
round_number: 1,
players_count: players.length,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true,
},
readiness: {
question_ready: phase !== 'lobby',
scoreboard_ready: phase === 'reveal' || phase === 'scoreboard',
},
host: {
can_start_round: phase === 'lobby',
can_show_question: phase === 'lie',
can_mix_answers: phase === 'lie' || phase === 'guess',
can_calculate_scores: phase === 'guess',
can_reveal_scoreboard: phase === 'reveal',
can_start_next_round: phase === 'scoreboard',
can_finish_game: phase === 'scoreboard',
},
player: {
can_join: seed.playerPermissions?.can_join ?? phase === 'lobby',
can_submit_lie: seed.playerPermissions?.can_submit_lie ?? phase === 'lie',
can_submit_guess: seed.playerPermissions?.can_submit_guess ?? phase === 'guess',
can_view_final_result: seed.playerPermissions?.can_view_final_result ?? phase === 'finished',
},
},
};
}
function stubShellGlobals(): void {
vi.stubGlobal('window', {
location: { hash: '', search: '', host: 'localhost:4200', protocol: 'http:' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
speechSynthesis: { speak: vi.fn(), cancel: vi.fn() },
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
}
function latestSocket(search: string): SharedRealtimeSocketMock {
const socket = [...SharedRealtimeSocketMock.instances].reverse().find((candidate) => candidate.url.includes(search));
expect(socket).toBeDefined();
return socket as SharedRealtimeSocketMock;
}
describe('realtime visual smoke (host/player resilience + visibility)', () => {
afterEach(() => {
SharedRealtimeSocketMock.instances.length = 0;
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('keeps lie prompts presenter-only while host and player hydrate the same session', async () => {
stubShellGlobals();
setPreferredLocale('en');
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === '/lobby/sessions/ABCD12') {
return Promise.resolve(
jsonResponse(
200,
buildSessionDetail({
viewerRole: 'host',
status: 'lie',
prompt: 'Which planet is closest to the sun?',
players: [
{ id: 1, nickname: 'Host', score: 0, identity: { token: 'H1', tone: 'ember', icon: 'spark' } },
{ id: 9, nickname: 'Luna', score: 120, identity: { token: 'L2', tone: 'lagoon', icon: 'wave' } },
{ id: 10, nickname: 'Mads', score: 80, identity: { token: 'M3', tone: 'gold', icon: 'comet' } },
],
phaseDisplay: {
theme: 'host-spotlight',
ornament: 'harbor-flare',
title_key: 'host.presenter_scene_title',
body_key: 'host.presenter_scene_body_lie',
cue_label_key: 'host.presenter_scene_cue_mix_label',
cue_body_key: 'host.presenter_scene_cue_mix_body',
},
playerPermissions: { can_join: false, can_submit_lie: true },
}),
),
);
}
if (url === '/lobby/sessions/ABCD12?session_token=tok-9') {
return Promise.resolve(
jsonResponse(
200,
buildSessionDetail({
viewerRole: 'player',
status: 'lie',
prompt: null,
players: [
{ id: 1, nickname: 'Host', score: 0, identity: { token: 'H1', tone: 'ember', icon: 'spark' } },
{ id: 9, nickname: 'Luna', score: 120, identity: { token: 'L2', tone: 'lagoon', icon: 'wave' } },
{ id: 10, nickname: 'Mads', score: 80, identity: { token: 'M3', tone: 'gold', icon: 'comet' } },
],
phaseDisplay: {
theme: 'player-ink',
ornament: 'harbor-flare',
title_key: 'player.submit_lie',
body_key: 'player.phase_summary_lie',
cue_label_key: 'player.active_scene_cue_lie_label',
cue_body_key: 'player.active_scene_cue_lie_body',
},
playerPermissions: { can_join: false, can_submit_lie: true },
}),
),
);
}
throw new Error(`Unhandled fetch in realtime visual smoke: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', SharedRealtimeSocketMock as unknown as typeof WebSocket);
const host = new HostShellComponent();
host.sessionCode = 'ABCD12';
await host.refreshSession();
latestSocket('?role=host').emitOpen();
const player = new PlayerShellComponent();
player.sessionCode = 'ABCD12';
player.playerId = 9;
player.sessionToken = 'tok-9';
await player.refreshSession();
latestSocket('session_token=tok-9').emitOpen();
expect(host.showLiePresenterScene).toBe(true);
expect(host.presenterSceneHeadline).toBe('Which planet is closest to the sun?');
expect(host.presenterSceneTheme).toBe('host-spotlight');
expect(host.presenterSceneOrnament).toBe('harbor-flare');
expect(host.syncTransport).toBe('websocket');
expect(host.presenterPlayers[1].badge).toBe('L2');
expect(host.presenterPlayers[1].tone).toBe('lagoon');
expect(host.presenterPlayers[1].icon).toBe('wave');
expect(player.showLieControls).toBe(true);
expect(player.currentPrompt).toBe('');
expect(player.activeSceneHeadline).toBe(player.copy('player.round_prompt_waiting'));
expect(player.activeSceneTheme).toBe('player-ink');
expect(player.activeSceneOrnament).toBe('harbor-flare');
expect(player.syncTransport).toBe('websocket');
expect(player.playerIdentityToken(9, 'Luna', 1)).toBe('L2');
expect(player.playerTone(9, 'Luna', 1)).toBe('lagoon');
expect(player.playerIcon(9, 'Luna', 1)).toBe('wave');
expect(player.activeSceneOrnament).toBe(host.presenterSceneOrnament);
player.ngOnDestroy();
host.ngOnDestroy();
});
it('recovers shared host/player realtime sync before polling fallback fires', async () => {
vi.useFakeTimers();
stubShellGlobals();
setPreferredLocale('en');
let hostFetchCount = 0;
let playerFetchCount = 0;
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url === '/lobby/sessions/ABCD12') {
hostFetchCount += 1;
return Promise.resolve(
jsonResponse(
200,
buildSessionDetail({
viewerRole: 'host',
status: 'guess',
prompt: 'Which planet is closest to the sun?',
answers: ['Mercury', 'Venus', 'Mars'],
playerPermissions: { can_join: false, can_submit_guess: true },
}),
),
);
}
if (url === '/lobby/sessions/ABCD12?session_token=tok-9') {
playerFetchCount += 1;
return Promise.resolve(
jsonResponse(
200,
buildSessionDetail({
viewerRole: 'player',
status: playerFetchCount === 1 ? 'guess' : 'reveal',
currentPhase: 'guess',
prompt: 'Which planet is closest to the sun?',
answers: ['Mercury', 'Venus', 'Mars', 'Earth'],
playerPermissions: { can_join: false, can_submit_guess: true },
}),
),
);
}
throw new Error(`Unhandled fetch in realtime visual smoke: ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('WebSocket', SharedRealtimeSocketMock as unknown as typeof WebSocket);
const host = new HostShellComponent();
host.sessionCode = 'ABCD12';
await host.refreshSession();
const firstHostSocket = latestSocket('?role=host');
firstHostSocket.emitOpen();
const player = new PlayerShellComponent();
player.sessionCode = 'ABCD12';
player.playerId = 9;
player.sessionToken = 'tok-9';
await player.refreshSession();
player.selectedGuess = 'Venus';
const firstPlayerSocket = latestSocket('session_token=tok-9');
firstPlayerSocket.emitOpen();
firstHostSocket.emitClose({ code: 1006, wasClean: false });
firstPlayerSocket.emitClose({ code: 1006, wasClean: false });
expect(host.syncTransport).toBe('polling');
expect(player.syncTransport).toBe('polling');
expect(player.showSyncStatusCard).toBe(true);
await vi.advanceTimersByTimeAsync(1500);
const recoveredHostSocket = latestSocket('?role=host');
const recoveredPlayerSocket = latestSocket('session_token=tok-9');
expect(recoveredHostSocket).not.toBe(firstHostSocket);
expect(recoveredPlayerSocket).not.toBe(firstPlayerSocket);
recoveredHostSocket.emitOpen();
recoveredPlayerSocket.emitOpen();
expect(host.syncTransport).toBe('websocket');
expect(player.syncTransport).toBe('websocket');
expect(player.showSyncStatusCard).toBe(false);
await vi.advanceTimersByTimeAsync(3000);
expect(hostFetchCount).toBe(1);
expect(playerFetchCount).toBe(1);
expect(player.selectedGuess).toBe('Venus');
recoveredHostSocket.emitMessage({ type: 'phase.guess_snapshot' });
recoveredPlayerSocket.emitMessage({ type: 'phase.guess_snapshot' });
await vi.waitFor(() => {
expect(hostFetchCount).toBe(2);
expect(playerFetchCount).toBe(2);
});
expect(host.lastRealtimeEventType).toBe('phase.guess_snapshot');
expect(player.lastRealtimeEventType).toBe('phase.guess_snapshot');
expect(player.gameplayPhase).toBe('guess');
expect(player.selectedGuess).toBe('Venus');
player.ngOnDestroy();
host.ngOnDestroy();
});
});

View File

@@ -0,0 +1,107 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createSessionRealtimeClient, resolveSessionRealtimeUrl } from './session-realtime';
class FakeWebSocket {
static instances: FakeWebSocket[] = [];
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null;
onerror: ((event: unknown) => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onopen: (() => void) | null = null;
readonly close = vi.fn();
constructor(readonly url: string) {
FakeWebSocket.instances.push(this);
}
emitOpen(): void {
this.onopen?.();
}
emitClose(event: { code?: number; reason?: string; wasClean?: boolean } = {}): void {
this.onclose?.(event);
}
emitError(event: unknown = new Event('error')): void {
this.onerror?.(event);
}
emitMessage(payload: unknown): void {
this.onmessage?.({ data: JSON.stringify(payload) });
}
}
describe('session realtime client', () => {
afterEach(() => {
FakeWebSocket.instances.length = 0;
vi.useRealTimers();
vi.restoreAllMocks();
});
it('builds host and player websocket URLs from the current location', () => {
expect(
resolveSessionRealtimeUrl(
{ protocol: 'http:', host: 'localhost:4200' },
{ sessionCode: 'abcd12', role: { mode: 'host' } },
),
).toBe('ws://localhost:4200/ws/game/ABCD12/?role=host');
expect(
resolveSessionRealtimeUrl(
{ protocol: 'https:', host: 'party.example' },
{ sessionCode: 'abcd12', role: { mode: 'player', sessionToken: 'tok-1' } },
),
).toBe('wss://party.example/ws/game/ABCD12/?session_token=tok-1');
});
it('publishes websocket events and reconnects after unexpected disconnects', async () => {
vi.useFakeTimers();
const events: string[] = [];
const states: string[] = [];
const client = createSessionRealtimeClient({
onEvent: (event) => {
events.push(String(event.type));
},
onStatusChange: (status) => {
states.push(status.connectionState);
},
webSocketFactory: (url) => new FakeWebSocket(url),
windowLike: { location: { protocol: 'http:', host: 'localhost:4200' } as Location },
});
client.updateTarget({ sessionCode: 'ABCD12', role: { mode: 'host' } });
expect(FakeWebSocket.instances).toHaveLength(1);
expect(FakeWebSocket.instances[0]?.url).toBe('ws://localhost:4200/ws/game/ABCD12/?role=host');
FakeWebSocket.instances[0]?.emitOpen();
FakeWebSocket.instances[0]?.emitMessage({ type: 'phase.guess_started' });
FakeWebSocket.instances[0]?.emitClose();
expect(events).toEqual(['phase.guess_started']);
expect(states).toEqual(['connecting', 'connected', 'connected', 'reconnecting']);
await vi.advanceTimersByTimeAsync(1500);
expect(FakeWebSocket.instances).toHaveLength(2);
FakeWebSocket.instances[1]?.emitOpen();
expect(client.getStatus().connectionState).toBe('connected');
});
it('reconfigures the socket when the player session token changes', () => {
const client = createSessionRealtimeClient({
onEvent: vi.fn(),
webSocketFactory: (url) => new FakeWebSocket(url),
windowLike: { location: { protocol: 'http:', host: 'localhost:4200' } as Location },
});
client.updateTarget({ sessionCode: 'ABCD12', role: { mode: 'player', sessionToken: 'tok-1' } });
const firstSocket = FakeWebSocket.instances[0];
client.updateTarget({ sessionCode: 'ABCD12', role: { mode: 'player', sessionToken: 'tok-2' } });
expect(firstSocket?.close).toHaveBeenCalledWith(1000, 'reconfigure');
expect(FakeWebSocket.instances[1]?.url).toBe('ws://localhost:4200/ws/game/ABCD12/?session_token=tok-2');
});
});

View File

@@ -0,0 +1,264 @@
export type SessionRealtimeRole =
| { mode: 'host' }
| { mode: 'player'; sessionToken: string };
export type SessionRealtimeEvent = {
type?: string;
[key: string]: unknown;
};
export type SessionRealtimeConnectionState = 'idle' | 'connecting' | 'connected' | 'reconnecting';
export type SessionRealtimeStatus = {
connectionState: SessionRealtimeConnectionState;
lastEventAt: number | null;
lastEventType: string | null;
reconnectAttempt: number;
};
type TimeoutHandle = ReturnType<typeof setTimeout>;
type WebSocketLike = {
close: (code?: number, reason?: string) => void;
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null;
onerror: ((event: unknown) => void) | null;
onmessage: ((event: { data: string }) => void) | null;
onopen: (() => void) | null;
};
type SessionRealtimeTarget = {
role: SessionRealtimeRole;
sessionCode: string;
};
type SessionRealtimeOptions = {
clearTimeoutImpl?: (handle: TimeoutHandle) => void;
onEvent: (event: SessionRealtimeEvent) => void;
onStatusChange?: (status: SessionRealtimeStatus) => void;
setTimeoutImpl?: (callback: () => void, delayMs: number) => TimeoutHandle;
webSocketFactory?: ((url: string) => WebSocketLike) | null;
windowLike?: Pick<Window, 'location'>;
};
const DEFAULT_RECONNECT_DELAY_MS = 1500;
const MAX_RECONNECT_DELAY_MS = 5000;
function resolveWindowLike(windowLike?: Pick<Window, 'location'>): Pick<Window, 'location'> | null {
if (windowLike) {
return windowLike;
}
if (typeof window === 'undefined') {
return null;
}
return window;
}
function normalizeSessionCode(value: string): string {
return value.trim().toUpperCase();
}
export function resolveSessionRealtimeUrl(
location: Pick<Location, 'host' | 'protocol'>,
target: SessionRealtimeTarget,
): string {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = new URL(`${protocol}//${location.host}/ws/game/${encodeURIComponent(normalizeSessionCode(target.sessionCode))}/`);
if (target.role.mode === 'host') {
url.searchParams.set('role', 'host');
} else {
url.searchParams.set('session_token', target.role.sessionToken);
}
return url.toString();
}
function sameTarget(left: SessionRealtimeTarget | null, right: SessionRealtimeTarget | null): boolean {
if (!left || !right) {
return left === right;
}
if (normalizeSessionCode(left.sessionCode) !== normalizeSessionCode(right.sessionCode)) {
return false;
}
if (left.role.mode !== right.role.mode) {
return false;
}
if (left.role.mode === 'host' && right.role.mode === 'host') {
return true;
}
if (left.role.mode === 'player' && right.role.mode === 'player') {
return left.role.sessionToken === right.role.sessionToken;
}
return false;
}
export function createSessionRealtimeClient(options: SessionRealtimeOptions) {
const setTimeoutImpl = options.setTimeoutImpl ?? ((callback, delayMs) => setTimeout(callback, delayMs));
const clearTimeoutImpl = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle));
const webSocketFactory =
options.webSocketFactory ??
(typeof WebSocket === 'function' ? ((url: string) => new WebSocket(url) as unknown as WebSocketLike) : null);
let reconnectTimer: TimeoutHandle | null = null;
let reconnectAttempt = 0;
let socket: WebSocketLike | null = null;
let target: SessionRealtimeTarget | null = null;
let status: SessionRealtimeStatus = {
connectionState: 'idle',
lastEventAt: null,
lastEventType: null,
reconnectAttempt: 0,
};
function publishStatus(next: Partial<SessionRealtimeStatus>): void {
status = {
...status,
...next,
reconnectAttempt,
};
options.onStatusChange?.(status);
}
function clearReconnectTimer(): void {
if (!reconnectTimer) {
return;
}
clearTimeoutImpl(reconnectTimer);
reconnectTimer = null;
}
function clearSocket(closeCode?: number, reason?: string): void {
if (!socket) {
return;
}
const currentSocket = socket;
socket = null;
currentSocket.onopen = null;
currentSocket.onmessage = null;
currentSocket.onerror = null;
currentSocket.onclose = null;
currentSocket.close(closeCode, reason);
}
function scheduleReconnect(): void {
if (!target || reconnectTimer) {
return;
}
publishStatus({ connectionState: 'reconnecting' });
const delayMs = Math.min(DEFAULT_RECONNECT_DELAY_MS * Math.max(1, reconnectAttempt + 1), MAX_RECONNECT_DELAY_MS);
reconnectTimer = setTimeoutImpl(() => {
reconnectTimer = null;
reconnectAttempt += 1;
connect();
}, delayMs);
}
function connect(): void {
if (!target) {
publishStatus({ connectionState: 'idle' });
return;
}
const windowLike = resolveWindowLike(options.windowLike);
if (
!windowLike ||
typeof windowLike.location?.protocol !== 'string' ||
typeof windowLike.location?.host !== 'string' ||
!windowLike.location.host ||
!webSocketFactory
) {
publishStatus({ connectionState: 'idle' });
return;
}
clearReconnectTimer();
clearSocket(1000, 'reconnect');
publishStatus({ connectionState: reconnectAttempt > 0 ? 'reconnecting' : 'connecting' });
const nextSocket = webSocketFactory(resolveSessionRealtimeUrl(windowLike.location, target));
socket = nextSocket;
nextSocket.onopen = () => {
if (socket !== nextSocket) {
return;
}
reconnectAttempt = 0;
publishStatus({ connectionState: 'connected' });
};
nextSocket.onmessage = (event) => {
if (socket !== nextSocket) {
return;
}
try {
const payload = JSON.parse(event.data) as SessionRealtimeEvent;
publishStatus({
lastEventAt: Date.now(),
lastEventType: typeof payload.type === 'string' ? payload.type : null,
});
options.onEvent(payload);
} catch {
// Ignore malformed websocket frames; the HTTP refresh path remains authoritative.
}
};
nextSocket.onerror = () => {
if (socket !== nextSocket) {
return;
}
publishStatus({ connectionState: 'reconnecting' });
};
nextSocket.onclose = () => {
if (socket !== nextSocket) {
return;
}
socket = null;
scheduleReconnect();
};
}
return {
disconnect(): void {
target = null;
reconnectAttempt = 0;
clearReconnectTimer();
clearSocket(1000, 'disconnect');
publishStatus({ connectionState: 'idle' });
},
getStatus(): SessionRealtimeStatus {
return status;
},
updateTarget(nextTarget: SessionRealtimeTarget | null): void {
if (sameTarget(target, nextTarget)) {
if (!nextTarget) {
this.disconnect();
}
return;
}
target = nextTarget;
reconnectAttempt = 0;
clearReconnectTimer();
clearSocket(1000, 'reconfigure');
if (!target) {
publishStatus({ connectionState: 'idle' });
return;
}
connect();
},
};
}

View File

@@ -15,15 +15,25 @@ describe('WPP Angular API client skeleton', () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 1 }, players: [], round_question: null, phase_view_model: { status: 'lobby', round_number: 1, players_count: 1, constraints: { min_players_to_start: 2, max_players_mvp: 8, min_players_reached: false, max_players_allowed: true }, host: { can_start_round: false, can_show_question: false, can_mix_answers: false, can_calculate_scores: false, can_reveal_scoreboard: false, can_start_next_round: false, can_finish_game: false }, player: { can_join: true, can_submit_lie: false, can_submit_guess: false, can_view_final_result: false } } }))
.mockResolvedValueOnce(jsonResponse(201, { player: { id: 1, nickname: 'Luna', session_token: 'tok', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } }));
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 1 }, viewer_role: 'player', players: [], round_question: { id: 77, round_number: 1, prompt: null, shown_at: '2026-03-18T12:00:00Z', answers: [] }, phase_view_model: { status: 'lie', round_number: 1, players_count: 1, constraints: { min_players_to_start: 2, max_players_mvp: 8, min_players_reached: false, max_players_allowed: true }, host: { can_start_round: false, can_show_question: false, can_mix_answers: false, can_calculate_scores: false, can_reveal_scoreboard: false, can_start_next_round: false, can_finish_game: false }, player: { can_join: true, can_submit_lie: true, can_submit_guess: false, can_view_final_result: false } } }))
.mockResolvedValueOnce(jsonResponse(201, { player: { id: 1, nickname: 'Luna', session_token: 'tok', score: 0 }, session: { code: 'ABCD12', status: 'lobby' } }))
.mockResolvedValueOnce(jsonResponse(201, { session: { code: 'ZXCV12', status: 'lobby', host_id: 1, current_round: 1 } }));
const client = createWppApiClient(fetchMock);
const session = await client.getSession(' abcd12 ');
const playerSession = await client.getSession(' abcd12 ', { session_token: 'tok-1' });
const joined = await client.joinSession({ code: ' abcd12 ', nickname: ' Luna ' });
const created = await client.createSession();
expect(session.ok).toBe(true);
expect(playerSession.ok).toBe(true);
expect(joined.ok).toBe(true);
expect(created.ok).toBe(true);
if (playerSession.ok) {
expect(playerSession.data.viewer_role).toBe('player');
expect(playerSession.data.round_question?.prompt).toBeNull();
}
expect(fetchMock).toHaveBeenNthCalledWith(
1,
@@ -32,6 +42,11 @@ describe('WPP Angular API client skeleton', () => {
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12?session_token=tok-1',
expect.objectContaining({ method: 'GET', credentials: 'same-origin' })
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'/lobby/sessions/join',
expect.objectContaining({
method: 'POST',
@@ -39,5 +54,14 @@ describe('WPP Angular API client skeleton', () => {
body: JSON.stringify({ code: 'ABCD12', nickname: 'Luna' }),
})
);
expect(fetchMock).toHaveBeenNthCalledWith(
4,
'/lobby/sessions/create',
expect.objectContaining({
method: 'POST',
credentials: 'same-origin',
body: JSON.stringify({}),
})
);
});
});

View File

@@ -13,6 +13,53 @@ export interface FetchLike {
}
export function createFetchHttpClient(fetchImpl: FetchLike): AngularHttpClientLike {
function readCookie(name: string): string {
if (typeof document === 'undefined' || typeof document.cookie !== 'string') {
return '';
}
const prefix = `${name}=`;
for (const part of document.cookie.split(';')) {
const trimmed = part.trim();
if (trimmed.startsWith(prefix)) {
return decodeURIComponent(trimmed.slice(prefix.length));
}
}
return '';
}
async function ensureCsrfToken(): Promise<string> {
const existing = readCookie('csrftoken');
if (existing || typeof document === 'undefined' || typeof window === 'undefined') {
return existing;
}
try {
await fetchImpl('/lobby/csrf', {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
} catch {
return '';
}
return readCookie('csrftoken');
}
async function parsePayload(response: Response): Promise<unknown> {
if (response.redirected && response.url.includes('/accounts/login')) {
throw {
status: 401,
message: 'Login required',
error: { redirect: response.url },
};
}
return response.json().catch(() => ({}));
}
return {
async get<T>(url: string): Promise<T> {
const response = await fetchImpl(url, {
@@ -20,7 +67,7 @@ export function createFetchHttpClient(fetchImpl: FetchLike): AngularHttpClientLi
headers: { Accept: 'application/json' },
credentials: 'same-origin',
});
const payload = await response.json().catch(() => ({}));
const payload = await parsePayload(response);
if (!response.ok) {
throw {
status: response.status,
@@ -31,16 +78,18 @@ export function createFetchHttpClient(fetchImpl: FetchLike): AngularHttpClientLi
return payload as T;
},
async post<T>(url: string, body: unknown): Promise<T> {
const csrfToken = await ensureCsrfToken();
const response = await fetchImpl(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
},
body: JSON.stringify(body),
credentials: 'same-origin',
});
const payload = await response.json().catch(() => ({}));
const payload = await parsePayload(response);
if (!response.ok) {
throw {
status: response.status,

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import {
mapCalculateScoresResponse,
mapCreateSessionResponse,
mapFinishGameResponse,
mapHealthResponse,
mapJoinSessionResponse,
@@ -16,12 +17,14 @@ import type {
ApiFailure,
ApiResult,
CalculateScoresResponse,
CreateSessionResponse,
FinishGameResponse,
HealthResponse,
JoinSessionRequest,
JoinSessionResponse,
MixAnswersResponse,
ScoreboardResponse,
SessionDetailRequestOptions,
SessionDetailResponse,
ShowQuestionResponse,
StartNextRoundResponse,
@@ -46,7 +49,8 @@ export interface AngularHttpClientLike {
export interface AngularApiClient {
health(): Promise<ApiResult<HealthResponse>>;
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
createSession(): Promise<ApiResult<CreateSessionResponse>>;
getSession(code: string, options?: SessionDetailRequestOptions): Promise<ApiResult<SessionDetailResponse>>;
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
showQuestion(code: string): Promise<ApiResult<ShowQuestionResponse>>;
@@ -96,6 +100,17 @@ function buildUrl(baseUrl: string, path: string): string {
return `${normalizeBaseUrl(baseUrl)}${path}`;
}
function buildSessionDetailPath(code: string, options?: SessionDetailRequestOptions): string {
const path = `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`;
const sessionToken = options?.session_token?.trim();
if (!sessionToken) {
return path;
}
const params = new URLSearchParams({ session_token: sessionToken });
return `${path}?${params.toString()}`;
}
async function wrap<T>(call: () => Promise<unknown>, mapper: (payload: unknown) => T): Promise<ApiResult<T>> {
let payload: unknown;
try {
@@ -128,12 +143,14 @@ export function createAngularApiClient(http: AngularHttpClientLike, baseUrl = ''
return {
health: () =>
wrap(() => http.get<HealthResponse>(buildUrl(baseUrl, '/healthz'), { withCredentials: true }), mapHealthResponse),
getSession: (code: string) =>
createSession: () =>
wrap(
() =>
http.get<SessionDetailResponse>(buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`), {
withCredentials: true
}),
() => http.post<CreateSessionResponse>(buildUrl(baseUrl, '/lobby/sessions/create'), {}, { withCredentials: true }),
mapCreateSessionResponse
),
getSession: (code: string, options?: SessionDetailRequestOptions) =>
wrap(
() => http.get<SessionDetailResponse>(buildUrl(baseUrl, buildSessionDetailPath(code, options)), { withCredentials: true }),
mapSessionDetailResponse
),
joinSession: (payload: JoinSessionRequest) =>

View File

@@ -1,5 +1,6 @@
import {
mapCalculateScoresResponse,
mapCreateSessionResponse,
mapFinishGameResponse,
mapHealthResponse,
mapJoinSessionResponse,
@@ -15,12 +16,14 @@ import {
import type {
ApiResult,
CalculateScoresResponse,
CreateSessionResponse,
FinishGameResponse,
HealthResponse,
JoinSessionRequest,
JoinSessionResponse,
MixAnswersResponse,
ScoreboardResponse,
SessionDetailRequestOptions,
SessionDetailResponse,
ShowQuestionResponse,
StartNextRoundResponse,
@@ -34,7 +37,8 @@ import type {
export interface ApiClient {
health(): Promise<ApiResult<HealthResponse>>;
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
createSession(): Promise<ApiResult<CreateSessionResponse>>;
getSession(code: string, options?: SessionDetailRequestOptions): Promise<ApiResult<SessionDetailResponse>>;
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
showQuestion(code: string): Promise<ApiResult<ShowQuestionResponse>>;
@@ -48,21 +52,59 @@ export interface ApiClient {
}
export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient {
function readCookie(name: string): string {
if (typeof document === 'undefined' || typeof document.cookie !== 'string') {
return '';
}
const prefix = `${name}=`;
for (const part of document.cookie.split(';')) {
const trimmed = part.trim();
if (trimmed.startsWith(prefix)) {
return decodeURIComponent(trimmed.slice(prefix.length));
}
}
return '';
}
async function ensureCsrfToken(): Promise<string> {
const existing = readCookie('csrftoken');
if (existing || typeof document === 'undefined' || typeof window === 'undefined') {
return existing;
}
try {
await fetchImpl(`${baseUrl}/lobby/csrf`, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'same-origin'
});
} catch {
return '';
}
return readCookie('csrftoken');
}
async function request<T>(
path: string,
method: 'GET' | 'POST',
mapper: (payload: unknown) => T,
payload?: unknown
): Promise<ApiResult<T>> {
const csrfToken = method === 'POST' ? await ensureCsrfToken() : '';
let response: Response;
try {
response = await fetchImpl(`${baseUrl}${path}`, {
method,
headers: {
Accept: 'application/json',
...(payload === undefined ? {} : { 'Content-Type': 'application/json' })
...(payload === undefined ? {} : { 'Content-Type': 'application/json' }),
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {})
},
...(payload === undefined ? {} : { body: JSON.stringify(payload) })
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
credentials: 'same-origin'
});
} catch {
return {
@@ -72,6 +114,19 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
};
}
if (response.redirected && response.url.includes('/accounts/login')) {
return {
ok: false,
status: 401,
error: {
kind: 'http',
status: 401,
message: 'Login required',
payload: { redirect: response.url }
}
};
}
let responsePayload: unknown;
try {
responsePayload = await response.json();
@@ -114,11 +169,29 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
const normalizeCode = (value: string): string => value.trim().toUpperCase();
function buildSessionDetailPath(code: string, options?: SessionDetailRequestOptions): string {
const path = `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`;
const sessionToken = options?.session_token?.trim();
if (!sessionToken) {
return path;
}
const params = new URLSearchParams({ session_token: sessionToken });
return `${path}?${params.toString()}`;
}
return {
health: () => request<HealthResponse>('/healthz', 'GET', mapHealthResponse),
getSession: (code: string) =>
createSession: () =>
request<CreateSessionResponse>(
'/lobby/sessions/create',
'POST',
mapCreateSessionResponse,
{}
),
getSession: (code: string, options?: SessionDetailRequestOptions) =>
request<SessionDetailResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`,
buildSessionDetailPath(code, options),
'GET',
mapSessionDetailResponse
),

View File

@@ -1,5 +1,6 @@
import type {
CalculateScoresResponse,
CreateSessionResponse,
FinishGameResponse,
HealthResponse,
JoinSessionResponse,
@@ -10,7 +11,8 @@ import type {
StartNextRoundResponse,
StartRoundResponse,
SubmitGuessResponse,
SubmitLieResponse
SubmitLieResponse,
VoiceCue,
} from './types';
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -52,6 +54,17 @@ function readNumber(record: Record<string, unknown>, key: string, path: string):
return value;
}
function readNullableString(record: Record<string, unknown>, key: string, path: string): string | null {
const value = record[key];
if (value === undefined || value === null) {
return null;
}
if (!isString(value)) {
throw new Error(`Invalid API contract: expected string|null at ${path}.${key}`);
}
return value;
}
function readBoolean(record: Record<string, unknown>, key: string, path: string): boolean {
const value = record[key];
if (!isBoolean(value)) {
@@ -71,6 +84,63 @@ function readNullableNumber(record: Record<string, unknown>, key: string, path:
return value;
}
function mapVoiceCue(payload: unknown, path: string): VoiceCue | null {
if (payload === null || payload === undefined) {
return null;
}
const record = asRecord(payload, path);
const translations = record.translations;
if (!isRecord(translations)) {
throw new Error(`Invalid API contract: expected object at ${path}.translations`);
}
const audioUrls = record.audio_urls;
if (audioUrls !== undefined && audioUrls !== null && !isRecord(audioUrls)) {
throw new Error(`Invalid API contract: expected object at ${path}.audio_urls`);
}
const textByLocale: Record<string, string> = {};
for (const [locale, value] of Object.entries(translations)) {
if (!isString(value)) {
throw new Error(`Invalid API contract: expected string at ${path}.translations.${locale}`);
}
textByLocale[locale] = value;
}
const audioByLocale: Record<string, string> = {};
for (const [locale, value] of Object.entries(audioUrls ?? {})) {
if (!isString(value)) {
throw new Error(`Invalid API contract: expected string at ${path}.audio_urls.${locale}`);
}
audioByLocale[locale] = value;
}
return {
cue: readString(record, 'cue', path),
translations: textByLocale,
audio_urls: audioByLocale,
source: readString(record, 'source', path),
};
}
function mapSessionPlayerIdentity(payload: unknown, path: string): { token: string; tone: string; icon?: string } | undefined {
if (payload === undefined || payload === null) {
return undefined;
}
const record = asRecord(payload, path);
const icon = record.icon;
if (icon !== undefined && icon !== null && !isString(icon)) {
throw new Error(`Invalid API contract: expected string at ${path}.icon`);
}
return {
token: readString(record, 'token', path),
tone: readString(record, 'tone', path),
...(icon === undefined || icon === null ? {} : { icon }),
};
}
export function mapHealthResponse(payload: unknown): HealthResponse {
const root = asRecord(payload, 'health');
return {
@@ -79,6 +149,20 @@ export function mapHealthResponse(payload: unknown): HealthResponse {
};
}
export function mapCreateSessionResponse(payload: unknown): CreateSessionResponse {
const root = asRecord(payload, 'create_session');
const session = asRecord(root.session, 'create_session.session');
return {
session: {
code: readString(session, 'code', 'create_session.session'),
status: readString(session, 'status', 'create_session.session'),
host_id: readNullableNumber(session, 'host_id', 'create_session.session'),
current_round: readNumber(session, 'current_round', 'create_session.session')
}
};
}
function mapSessionDetail(payload: unknown): SessionDetailResponse {
const root = asRecord(payload, 'session_detail');
const session = asRecord(root.session, 'session_detail.session');
@@ -99,7 +183,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
roundQuestion = {
id: readNumber(roundQuestionRecord, 'id', 'session_detail.round_question'),
round_number: readNumber(roundQuestionRecord, 'round_number', 'session_detail.round_question'),
prompt: readString(roundQuestionRecord, 'prompt', 'session_detail.round_question'),
prompt: readNullableString(roundQuestionRecord, 'prompt', 'session_detail.round_question'),
shown_at: readString(roundQuestionRecord, 'shown_at', 'session_detail.round_question'),
answers: answersRaw.map((answer, index) => {
const answerRecord = asRecord(answer, `session_detail.round_question.answers[${index}]`);
@@ -165,6 +249,34 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
};
}
const voiceCuesRaw = root.voice_cues;
let voiceCues: SessionDetailResponse['voice_cues'] = null;
if (voiceCuesRaw !== null && voiceCuesRaw !== undefined) {
const record = asRecord(voiceCuesRaw, 'session_detail.voice_cues');
voiceCues = {
default_locale: readString(record, 'default_locale', 'session_detail.voice_cues'),
intro: mapVoiceCue(record.intro, 'session_detail.voice_cues.intro'),
phase: mapVoiceCue(record.phase, 'session_detail.voice_cues.phase'),
question_prompt: mapVoiceCue(record.question_prompt, 'session_detail.voice_cues.question_prompt'),
question_reveal: mapVoiceCue(record.question_reveal, 'session_detail.voice_cues.question_reveal'),
};
}
const phaseDisplayRaw = root.phase_display;
let phaseDisplay: SessionDetailResponse['phase_display'] = null;
if (phaseDisplayRaw !== null && phaseDisplayRaw !== undefined) {
const record = asRecord(phaseDisplayRaw, 'session_detail.phase_display');
const ornament = readNullableString(record, 'ornament', 'session_detail.phase_display');
phaseDisplay = {
theme: readString(record, 'theme', 'session_detail.phase_display'),
...(ornament === null ? {} : { ornament }),
title_key: readString(record, 'title_key', 'session_detail.phase_display'),
body_key: readString(record, 'body_key', 'session_detail.phase_display'),
cue_label_key: readString(record, 'cue_label_key', 'session_detail.phase_display'),
cue_body_key: readString(record, 'cue_body_key', 'session_detail.phase_display'),
};
}
return {
session: {
code: readString(session, 'code', 'session_detail.session'),
@@ -182,17 +294,24 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
current_round: readNumber(session, 'current_round', 'session_detail.session'),
players_count: readNumber(session, 'players_count', 'session_detail.session')
},
viewer_role:
root.viewer_role === 'host' || root.viewer_role === 'player' || root.viewer_role === 'public'
? root.viewer_role
: undefined,
players: players.map((item, index) => {
const record = asRecord(item, `session_detail.players[${index}]`);
return {
id: readNumber(record, 'id', `session_detail.players[${index}]`),
nickname: readString(record, 'nickname', `session_detail.players[${index}]`),
score: readNumber(record, 'score', `session_detail.players[${index}]`),
is_connected: readBoolean(record, 'is_connected', `session_detail.players[${index}]`)
is_connected: readBoolean(record, 'is_connected', `session_detail.players[${index}]`),
identity: mapSessionPlayerIdentity(record.identity, `session_detail.players[${index}].identity`),
};
}),
round_question: roundQuestion,
reveal,
voice_cues: voiceCues,
phase_display: phaseDisplay,
phase_view_model: {
status: readString(phase, 'status', 'session_detail.phase_view_model'),
current_phase: typeof phase.current_phase === 'string' ? phase.current_phase : undefined,

View File

@@ -3,6 +3,15 @@ export interface HealthResponse {
service: string;
}
export interface CreateSessionResponse {
session: {
code: string;
status: string;
host_id: number | null;
current_round: number;
};
}
export interface SessionSummary {
code: string;
status: string;
@@ -16,6 +25,11 @@ export interface SessionPlayer {
nickname: string;
score: number;
is_connected: boolean;
identity?: {
token: string;
tone: string;
icon?: string;
};
}
export interface SessionAnswer {
@@ -25,7 +39,7 @@ export interface SessionAnswer {
export interface SessionRoundQuestion {
id: number;
round_number: number;
prompt: string;
prompt: string | null;
shown_at: string;
answers: SessionAnswer[];
}
@@ -88,14 +102,45 @@ export interface RevealPayload {
guesses: RevealGuess[];
}
export interface VoiceCue {
cue: string;
translations: Record<string, string>;
audio_urls: Record<string, string>;
source: string;
}
export interface SessionVoiceCues {
default_locale: string;
intro: VoiceCue | null;
phase: VoiceCue | null;
question_prompt: VoiceCue | null;
question_reveal: VoiceCue | null;
}
export interface SessionPhaseDisplay {
theme: string;
ornament?: string;
title_key: string;
body_key: string;
cue_label_key: string;
cue_body_key: string;
}
export interface SessionDetailResponse {
session: SessionSummary;
viewer_role?: 'host' | 'player' | 'public';
players: SessionPlayer[];
round_question: SessionRoundQuestion | null;
reveal: RevealPayload | null;
voice_cues?: SessionVoiceCues | null;
phase_display?: SessionPhaseDisplay | null;
phase_view_model: PhaseViewModel;
}
export interface SessionDetailRequestOptions {
session_token?: string;
}
export interface JoinSessionRequest {
code: string;
nickname: string;

View File

@@ -1,5 +1,5 @@
import type { ApiClient } from '../api/client';
import type { SessionDetailResponse } from '../api/types';
import type { SessionDetailRequestOptions, SessionDetailResponse } from '../api/types';
import {
createSessionContextStore,
type SessionContext,
@@ -25,7 +25,7 @@ export interface VerticalSliceState {
export interface VerticalSliceController {
getState(): VerticalSliceState;
hydrateLobby(sessionCode: string): Promise<VerticalSliceState>;
hydrateLobby(sessionCode: string, options?: SessionDetailRequestOptions): Promise<VerticalSliceState>;
joinLobby(sessionCode: string, nickname: string): Promise<VerticalSliceState>;
startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState>;
}
@@ -48,7 +48,7 @@ export function createVerticalSliceController(
const normalizeCode = (value: string): string => value.trim().toUpperCase();
async function hydrateLobby(sessionCode: string): Promise<VerticalSliceState> {
async function hydrateLobby(sessionCode: string, options?: SessionDetailRequestOptions): Promise<VerticalSliceState> {
state.loadingSession = true;
state.errorMessage = null;
@@ -62,7 +62,7 @@ export function createVerticalSliceController(
return { ...state };
}
const result = await api.getSession(state.sessionCode);
const result = await api.getSession(state.sessionCode, options);
state.loadingSession = false;
if (!result.ok) {
@@ -107,7 +107,7 @@ export function createVerticalSliceController(
};
sessionContextStore.set(nextContext);
return hydrateLobby(state.sessionCode);
return hydrateLobby(state.sessionCode, { session_token: nextContext.token });
}
async function startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState> {

View File

@@ -463,6 +463,102 @@ describe('createAngularApiClient', () => {
expect(mapped.reveal?.guesses[0]).not.toHaveProperty('fooled_player_nickname');
});
it('maps optional phase_display metadata for contract-driven scene copy and themes', () => {
const mapped = mapSessionDetailResponse({
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true }
],
round_question: null,
reveal: null,
phase_display: {
theme: 'host-atrium',
ornament: 'atrium-banner',
title_key: 'host.presenter_scene_title_lobby',
body_key: 'host.presenter_scene_body_lobby',
cue_label_key: 'host.presenter_scene_cue_start_label',
cue_body_key: 'host.presenter_scene_cue_start_body'
},
phase_view_model: {
status: 'lobby',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: true,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
});
expect(mapped.phase_display).toEqual({
theme: 'host-atrium',
ornament: 'atrium-banner',
title_key: 'host.presenter_scene_title_lobby',
body_key: 'host.presenter_scene_body_lobby',
cue_label_key: 'host.presenter_scene_cue_start_label',
cue_body_key: 'host.presenter_scene_cue_start_body'
});
});
it('maps optional player identity metadata for contract-driven roster styling', () => {
const mapped = mapSessionDetailResponse({
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
players: [
{ id: 2, nickname: 'Maja', score: 10, is_connected: true, identity: { token: 'M1', tone: 'ember', icon: 'spark' } },
{ id: 3, nickname: 'Bo', score: 7, is_connected: true, identity: { token: 'B2', tone: 'lagoon', icon: 'wave' } }
],
round_question: null,
reveal: null,
phase_view_model: {
status: 'lobby',
round_number: 1,
players_count: 2,
constraints: {
min_players_to_start: 2,
max_players_mvp: 8,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: true,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: false,
can_finish_game: false
},
player: {
can_join: true,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
});
expect(mapped.players[0].identity).toEqual({ token: 'M1', tone: 'ember', icon: 'spark' });
expect(mapped.players[1].identity).toEqual({ token: 'B2', tone: 'lagoon', icon: 'wave' });
});
it('rejects canonical reveal payloads that include fooled_player_nickname without fooled_player_id', () => {
expect(() =>
mapSessionDetailResponse({

View File

@@ -9,6 +9,7 @@ import type { ApiClient } from '../src/api/client';
function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
const base: ApiClient = {
health: vi.fn(),
createSession: vi.fn(),
getSession: vi.fn().mockResolvedValue({
ok: true,
status: 200,

View File

@@ -1,5 +1,6 @@
from django.contrib import admin
from .models import Category, Question, GameSession, Player, RoundConfig, RoundQuestion, LieAnswer, Guess, ScoreEvent
from .models import Category, Question, QuestionLie, GameSession, Player, RoundConfig, RoundQuestion, LieAnswer, Guess, ScoreEvent
from voice.models import QuestionVoiceLine
@admin.register(Category)
@@ -9,11 +10,29 @@ class CategoryAdmin(admin.ModelAdmin):
search_fields = ("name", "slug")
class QuestionLieInline(admin.TabularInline):
model = QuestionLie
extra = 1
class QuestionVoiceLineInline(admin.TabularInline):
model = QuestionVoiceLine
extra = 1
@admin.register(Question)
class QuestionAdmin(admin.ModelAdmin):
list_display = ("id", "category", "is_active")
list_display = ("id", "category", "scene_ornament", "is_active")
list_filter = ("category", "is_active")
search_fields = ("prompt", "correct_answer")
inlines = [QuestionLieInline, QuestionVoiceLineInline]
@admin.register(QuestionLie)
class QuestionLieAdmin(admin.ModelAdmin):
list_display = ("question", "text", "is_active", "sort_order")
list_filter = ("is_active", "question__category")
search_fields = ("question__prompt", "text")
class PlayerInline(admin.TabularInline):

180
fupogfakta/bootstrap.py Normal file
View File

@@ -0,0 +1,180 @@
from __future__ import annotations
from dataclasses import dataclass
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
from .models import Category, Question, QuestionLie
DEFAULT_MVP_HOST_USERNAME = "demo-host"
DEFAULT_MVP_HOST_PASSWORD = "demo-pass"
DEFAULT_MVP_CATEGORY_SLUG = "general"
DEFAULT_MVP_CATEGORY_NAME = "General"
DEFAULT_MVP_QUESTIONS: tuple[tuple[str, str], ...] = (
("What is the capital of Denmark?", "Copenhagen"),
("Which planet is known as the Red Planet?", "Mars"),
("How many players are required before the host can start a round?", "3"),
)
DEFAULT_MVP_FALLBACK_LIES_BY_PROMPT: dict[str, tuple[str, ...]] = {
"What is the capital of Denmark?": ("Aarhus", "Odense", "Aalborg", "Roskilde", "Esbjerg"),
"Which planet is known as the Red Planet?": ("Venus", "Jupiter", "Saturn", "Mercury", "Neptune"),
"How many players are required before the host can start a round?": ("2", "4", "5", "6", "8"),
}
DEFAULT_MVP_SCENE_ORNAMENT_BY_PROMPT: dict[str, str] = {
"What is the capital of Denmark?": Question.SceneOrnament.HARBOR_FLARE,
"Which planet is known as the Red Planet?": Question.SceneOrnament.AURORA_ARC,
"How many players are required before the host can start a round?": Question.SceneOrnament.SIGNAL_BLOOM,
}
@dataclass(frozen=True)
class SeedSummary:
created: int
updated: int
@dataclass(frozen=True)
class MvpBootstrapResult:
host: AbstractBaseUser
category: Category
questions: tuple[Question, ...]
host_changes: SeedSummary
category_changes: SeedSummary
question_changes: SeedSummary
def ensure_host_user(*, username: str, password: str, is_staff: bool = True) -> tuple[AbstractBaseUser, SeedSummary]:
user_model = get_user_model()
host, created = user_model.objects.get_or_create(username=username)
updates: list[str] = []
if not host.is_active:
host.is_active = True
updates.append("is_active")
if host.is_staff != is_staff:
host.is_staff = is_staff
updates.append("is_staff")
host.set_password(password)
updates.append("password")
host.save(update_fields=updates)
return host, SeedSummary(created=int(created), updated=int(bool(updates and not created)))
def ensure_category_with_questions(
*,
slug: str,
name: str,
prompts_and_answers: tuple[tuple[str, str], ...],
fallback_lies_by_prompt: dict[str, tuple[str, ...]] | None = None,
scene_ornament_by_prompt: dict[str, str] | None = None,
) -> tuple[Category, tuple[Question, ...], SeedSummary, SeedSummary]:
category, created = Category.objects.get_or_create(
slug=slug,
defaults={"name": name, "is_active": True},
)
category_updates: list[str] = []
if category.name != name:
category.name = name
category_updates.append("name")
if not category.is_active:
category.is_active = True
category_updates.append("is_active")
if category_updates:
category.save(update_fields=category_updates)
questions: list[Question] = []
created_count = 0
updated_count = 0
for prompt, correct_answer in prompts_and_answers:
scene_ornament = ""
if scene_ornament_by_prompt:
scene_ornament = scene_ornament_by_prompt.get(prompt, "")
question, question_created = Question.objects.get_or_create(
category=category,
prompt=prompt,
defaults={
"correct_answer": correct_answer,
"scene_ornament": scene_ornament,
"is_active": True,
},
)
question_updates: list[str] = []
if question.correct_answer != correct_answer:
question.correct_answer = correct_answer
question_updates.append("correct_answer")
if question.scene_ornament != scene_ornament:
question.scene_ornament = scene_ornament
question_updates.append("scene_ornament")
if not question.is_active:
question.is_active = True
question_updates.append("is_active")
if question_updates:
question.save(update_fields=question_updates)
if fallback_lies_by_prompt:
ensure_question_fallback_lies(
question=question,
lies=fallback_lies_by_prompt.get(prompt, ()),
)
created_count += int(question_created)
updated_count += int(bool(question_updates and not question_created))
questions.append(question)
return (
category,
tuple(questions),
SeedSummary(created=int(created), updated=int(bool(category_updates and not created))),
SeedSummary(created=created_count, updated=updated_count),
)
def ensure_question_fallback_lies(*, question: Question, lies: tuple[str, ...]) -> SeedSummary:
created_count = 0
updated_count = 0
for index, lie_text in enumerate(lies):
lie, created = QuestionLie.objects.get_or_create(
question=question,
text=lie_text,
defaults={"is_active": True, "sort_order": index},
)
updates: list[str] = []
if not lie.is_active:
lie.is_active = True
updates.append("is_active")
if lie.sort_order != index:
lie.sort_order = index
updates.append("sort_order")
if updates:
lie.save(update_fields=updates)
created_count += int(created)
updated_count += int(bool(updates and not created))
return SeedSummary(created=created_count, updated=updated_count)
def ensure_mvp_bootstrap(
*,
username: str = DEFAULT_MVP_HOST_USERNAME,
password: str = DEFAULT_MVP_HOST_PASSWORD,
category_slug: str = DEFAULT_MVP_CATEGORY_SLUG,
category_name: str = DEFAULT_MVP_CATEGORY_NAME,
prompts_and_answers: tuple[tuple[str, str], ...] = DEFAULT_MVP_QUESTIONS,
) -> MvpBootstrapResult:
host, host_changes = ensure_host_user(username=username, password=password)
category, questions, category_changes, question_changes = ensure_category_with_questions(
slug=category_slug,
name=category_name,
prompts_and_answers=prompts_and_answers,
fallback_lies_by_prompt=DEFAULT_MVP_FALLBACK_LIES_BY_PROMPT,
scene_ornament_by_prompt=DEFAULT_MVP_SCENE_ORNAMENT_BY_PROMPT,
)
return MvpBootstrapResult(
host=host,
category=category,
questions=questions,
host_changes=host_changes,
category_changes=category_changes,
question_changes=question_changes,
)

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,41 @@
from django.core.management.base import BaseCommand
from fupogfakta.bootstrap import (
DEFAULT_MVP_CATEGORY_NAME,
DEFAULT_MVP_CATEGORY_SLUG,
DEFAULT_MVP_HOST_PASSWORD,
DEFAULT_MVP_HOST_USERNAME,
ensure_mvp_bootstrap,
)
class Command(BaseCommand):
help = "Create deterministic host credentials and sample FupOgFakta content for MVP try-out"
def add_arguments(self, parser):
parser.add_argument("--username", default=DEFAULT_MVP_HOST_USERNAME)
parser.add_argument("--password", default=DEFAULT_MVP_HOST_PASSWORD)
parser.add_argument("--category-slug", default=DEFAULT_MVP_CATEGORY_SLUG)
parser.add_argument("--category-name", default=DEFAULT_MVP_CATEGORY_NAME)
def handle(self, *args, **options):
result = ensure_mvp_bootstrap(
username=options["username"],
password=options["password"],
category_slug=options["category_slug"],
category_name=options["category_name"],
)
self.stdout.write(
self.style.SUCCESS(
"\n".join(
[
"MVP bootstrap ready",
f"host_username={result.host.username}",
f"host_password={options['password']}",
f"category_slug={result.category.slug}",
f"questions={len(result.questions)}",
]
)
)
)

View File

@@ -0,0 +1,312 @@
import json
from datetime import datetime, timezone
from pathlib import Path
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.test import Client
from fupogfakta.bootstrap import ensure_category_with_questions, ensure_host_user
from fupogfakta.models import GameSession, Player, RoundQuestion
class Command(BaseCommand):
help = 'Run canonical gameplay smoke/regression flow for bluff -> guess -> reveal -> scoreboard'
def add_arguments(self, parser):
parser.add_argument(
'--artifact',
help='Optional path to write smoke result artifact as JSON',
)
def _fail(self, step: str, detail: str, payload=None):
message = f'{step} failed: {detail}'
if payload is not None:
message += f' | payload={json.dumps(payload, sort_keys=True)}'
raise CommandError(message)
def _expect_status(self, response, expected_status: int, step: str):
if response.status_code != expected_status:
try:
payload = response.json()
except ValueError:
payload = {'raw': response.content.decode('utf-8', errors='replace')}
self._fail(step, f'expected HTTP {expected_status}, got {response.status_code}', payload)
return response.json()
def _expect_session_status(self, payload: dict, expected_status: str, step: str):
actual_status = payload.get('session', {}).get('status')
if actual_status != expected_status:
self._fail(step, f'expected session.status={expected_status}, got {actual_status}', payload)
def _client(self) -> Client:
host = next((candidate for candidate in settings.ALLOWED_HOSTS if candidate and candidate != '*'), 'localhost')
return Client(HTTP_HOST=host)
def handle(self, *args, **options):
GameSession.objects.all().delete()
Player.objects.all().delete()
RoundQuestion.objects.all().delete()
category, questions, _category_changes, _question_changes = ensure_category_with_questions(
slug='smoke',
name='Smoke',
prompts_and_answers=(('Smoke prompt?', 'Correct'),),
)
question = questions[0]
host, _host_changes = ensure_host_user(username='smoke-host', password='smoke-pass')
artifact = {
'ok': True,
'command': 'python manage.py smoke_staging --artifact <path>',
'generated_at': datetime.now(timezone.utc).isoformat(),
'question': {
'prompt': question.prompt,
'correct_answer': question.correct_answer,
},
'steps': [],
}
host_client = self._client()
host_client.force_login(host)
create_payload = self._expect_status(
host_client.post('/lobby/sessions/create', content_type='application/json'),
201,
'create_session',
)
code = create_payload['session']['code']
artifact['session_code'] = code
artifact['steps'].append(
{
'step': 'create_session',
'session_status': create_payload['session']['status'],
}
)
players = []
for nickname in ['P1', 'P2', 'P3']:
join_payload = self._expect_status(
self._client().post(
'/lobby/sessions/join',
data=json.dumps({'code': code, 'nickname': nickname}),
content_type='application/json',
),
201,
f'join_session[{nickname}]',
)
players.append(join_payload['player'])
artifact['players'] = [player['nickname'] for player in players]
artifact['steps'].append(
{
'step': 'join_players',
'players_count': len(players),
}
)
start_payload = self._expect_status(
host_client.post(
f'/lobby/sessions/{code}/rounds/start',
data=json.dumps({'category_slug': category.slug}),
content_type='application/json',
),
201,
'start_round',
)
self._expect_session_status(start_payload, GameSession.Status.LIE, 'start_round')
round_question_id = start_payload['round_question']['id']
artifact['round_question_id'] = round_question_id
artifact['steps'].append(
{
'step': 'start_round',
'session_status': start_payload['session']['status'],
'round_question_id': round_question_id,
}
)
answers = []
lie_transition_payload = None
for player in players:
nickname = player['nickname']
lie_payload = self._expect_status(
self._client().post(
f'/lobby/sessions/{code}/questions/{round_question_id}/lies/submit',
data=json.dumps(
{
'player_id': player['id'],
'session_token': player['session_token'],
'text': f'Lie from {nickname}',
}
),
content_type='application/json',
),
201,
f'submit_lie[{nickname}]',
)
if lie_payload.get('answers'):
answers = lie_payload['answers']
lie_transition_payload = lie_payload
if not answers:
detail_payload = self._expect_status(host_client.get(f'/lobby/sessions/{code}'), 200, 'session_detail_after_lies')
answers = detail_payload.get('round_question', {}).get('answers', [])
self._expect_session_status(detail_payload, GameSession.Status.GUESS, 'session_detail_after_lies')
lie_transition_payload = detail_payload
if not answers:
self._fail('auto_guess_transition', 'canonical lie->guess transition returned empty answers')
if not any(answer.get('text') == question.correct_answer for answer in answers):
self._fail('auto_guess_transition', 'mixed answers missing correct answer', {'answers': answers})
if len(answers) < len(players) + 1:
self._fail(
'auto_guess_transition',
'mixed answers shorter than expected bluff set',
{'answers': answers, 'players_count': len(players)},
)
self._expect_session_status(lie_transition_payload, GameSession.Status.GUESS, 'auto_guess_transition')
artifact['steps'].append(
{
'step': 'auto_guess_transition',
'session_status': lie_transition_payload['session']['status'],
'answers': [answer['text'] for answer in answers],
}
)
answer_texts = {answer['text'] for answer in answers}
correct_answer = next((answer['text'] for answer in answers if answer.get('text') == question.correct_answer), None)
if correct_answer is None:
self._fail('submit_guesses', 'could not resolve correct answer from mixed answers', {'answers': answers})
guess_plan = {
players[0]['nickname']: 'Lie from P2',
players[1]['nickname']: correct_answer,
players[2]['nickname']: 'Lie from P1',
}
missing_guess_targets = {text for text in guess_plan.values() if text not in answer_texts}
if missing_guess_targets:
self._fail(
'submit_guesses',
'expected bluff targets missing from mixed answers',
{'answers': answers, 'missing_guess_targets': sorted(missing_guess_targets)},
)
artifact['guess_plan'] = guess_plan
guess_payloads = []
for player in players:
nickname = player['nickname']
guess_payload = self._expect_status(
self._client().post(
f'/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit',
data=json.dumps(
{
'player_id': player['id'],
'session_token': player['session_token'],
'selected_text': guess_plan[nickname],
}
),
content_type='application/json',
),
201,
f'submit_guess[{nickname}]',
)
guess_payloads.append(guess_payload)
reveal_payload = guess_payloads[-1]
self._expect_session_status(reveal_payload, GameSession.Status.REVEAL, 'auto_reveal_transition')
if not reveal_payload.get('phase_transition', {}).get('auto_advanced'):
self._fail('auto_reveal_transition', 'expected auto_advanced=true on final guess', reveal_payload)
reveal = reveal_payload.get('reveal')
if not reveal:
self._fail('auto_reveal_transition', 'missing canonical reveal payload', reveal_payload)
if reveal.get('correct_answer') != question.correct_answer:
self._fail(
'auto_reveal_transition',
'reveal payload returned wrong correct answer',
{'expected': question.correct_answer, 'reveal': reveal},
)
if len(reveal.get('lies', [])) != len(players):
self._fail('auto_reveal_transition', 'unexpected lie count in reveal payload', reveal)
if len(reveal.get('guesses', [])) != len(players):
self._fail('auto_reveal_transition', 'unexpected guess count in reveal payload', reveal)
fooled_guesses = [guess for guess in reveal['guesses'] if not guess.get('is_correct')]
correct_guesses = [guess for guess in reveal['guesses'] if guess.get('is_correct')]
if len(fooled_guesses) != 2:
self._fail('auto_reveal_transition', 'expected exactly two bluff guesses', reveal)
if len(correct_guesses) != 1:
self._fail('auto_reveal_transition', 'expected exactly one correct guess', reveal)
if any(guess.get('fooled_player_id') is None for guess in fooled_guesses):
self._fail('auto_reveal_transition', 'bluff guesses missing fooled_player_id', reveal)
artifact['steps'].append(
{
'step': 'submit_guesses',
'guess_results': [
{
'player_id': payload['guess']['player_id'],
'selected_text': payload['guess']['selected_text'],
'is_correct': payload['guess']['is_correct'],
'fooled_player_id': payload['guess'].get('fooled_player_id'),
}
for payload in guess_payloads
],
}
)
artifact['steps'].append(
{
'step': 'auto_reveal_transition',
'session_status': reveal_payload['session']['status'],
'reveal': {
'correct_answer': reveal['correct_answer'],
'lies_count': len(reveal['lies']),
'guesses_count': len(reveal['guesses']),
'fooled_player_ids': sorted(guess['fooled_player_id'] for guess in fooled_guesses),
'correct_guess_player_ids': sorted(guess['player_id'] for guess in correct_guesses),
},
}
)
detail_payload = self._expect_status(host_client.get(f'/lobby/sessions/{code}'), 200, 'session_detail_after_guesses')
self._expect_session_status(detail_payload, GameSession.Status.SCOREBOARD, 'auto_scoreboard_transition')
if detail_payload.get('reveal') != reveal:
self._fail('auto_scoreboard_transition', 'scoreboard promotion changed canonical reveal payload', detail_payload)
scoreboard = detail_payload.get('scoreboard')
if not scoreboard:
self._fail('auto_scoreboard_transition', 'missing scoreboard payload after promotion', detail_payload)
if len(scoreboard) != len(players):
self._fail('auto_scoreboard_transition', 'unexpected scoreboard length', detail_payload)
if not detail_payload.get('phase_view_model', {}).get('readiness', {}).get('scoreboard_ready'):
self._fail('auto_scoreboard_transition', 'scoreboard_ready=false after promotion', detail_payload)
artifact['steps'].append(
{
'step': 'auto_scoreboard_transition',
'session_status': detail_payload['session']['status'],
'leaderboard': scoreboard,
}
)
finish_payload = self._expect_status(
host_client.post(f'/lobby/sessions/{code}/finish', content_type='application/json'),
200,
'finish_game',
)
self._expect_session_status(finish_payload, GameSession.Status.FINISHED, 'finish_game')
artifact['steps'].append(
{
'step': 'finish_game',
'session_status': finish_payload['session']['status'],
}
)
artifact_path = options.get('artifact')
if artifact_path:
output_path = Path(artifact_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(artifact, indent=2) + '\n', encoding='utf-8')
self.stdout.write(self.style.SUCCESS(f'Smoke flow OK for session {code}'))

View File

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

View File

@@ -0,0 +1,29 @@
# Generated by Django 6.0.2 on 2026-03-18 13:00
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('fupogfakta', '0007_roundconfig_started_from_scoreboard'),
]
operations = [
migrations.CreateModel(
name='QuestionLie',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.CharField(max_length=255)),
('is_active', models.BooleanField(default=True)),
('sort_order', models.PositiveIntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fallback_lies', to='fupogfakta.question')),
],
options={
'ordering': ['sort_order', 'id'],
'unique_together': {('question', 'text')},
},
),
]

View File

@@ -0,0 +1,27 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("fupogfakta", "0008_questionlie"),
]
operations = [
migrations.AddField(
model_name="question",
name="scene_ornament",
field=models.CharField(
blank=True,
choices=[
("aurora-arc", "Aurora Arc"),
("constellation-dust", "Constellation Dust"),
("harbor-flare", "Harbor Flare"),
("signal-bloom", "Signal Bloom"),
("sunburst-ribbon", "Sunburst Ribbon"),
],
default="",
max_length=64,
),
),
]

View File

@@ -24,9 +24,22 @@ class Category(models.Model):
class Question(models.Model):
class SceneOrnament(models.TextChoices):
AURORA_ARC = "aurora-arc", "Aurora Arc"
CONSTELLATION_DUST = "constellation-dust", "Constellation Dust"
HARBOR_FLARE = "harbor-flare", "Harbor Flare"
SIGNAL_BLOOM = "signal-bloom", "Signal Bloom"
SUNBURST_RIBBON = "sunburst-ribbon", "Sunburst Ribbon"
category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="questions")
prompt = models.TextField()
correct_answer = models.CharField(max_length=255)
scene_ornament = models.CharField(
max_length=64,
choices=SceneOrnament.choices,
blank=True,
default="",
)
is_active = models.BooleanField(default=True)
class Meta:
@@ -36,6 +49,21 @@ class Question(models.Model):
return f"{self.category.name}: {self.prompt[:60]}"
class QuestionLie(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name="fallback_lies")
text = models.CharField(max_length=255)
is_active = models.BooleanField(default=True)
sort_order = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["sort_order", "id"]
unique_together = (("question", "text"),)
def __str__(self):
return f"{self.question.prompt[:40]} -> {self.text}"
class GameSession(models.Model):
class Status(models.TextChoices):
LOBBY = "lobby", "Lobby"
@@ -83,6 +111,7 @@ class RoundConfig(models.Model):
points_bluff = models.IntegerField(default=2)
lie_seconds = models.PositiveIntegerField(default=45)
guess_seconds = models.PositiveIntegerField(default=30)
started_from_scoreboard = models.BooleanField(default=False)
class Meta:
unique_together = (("session", "number"),)

618
fupogfakta/payloads.py Normal file
View File

@@ -0,0 +1,618 @@
from datetime import timedelta
from typing import Literal
from .models import GameSession, Player, Question, RoundConfig, RoundQuestion
SessionViewerRole = Literal["host", "player", "public"]
NON_HOST_PROMPT_PHASES = {
GameSession.Status.REVEAL,
GameSession.Status.SCOREBOARD,
GameSession.Status.FINISHED,
}
HOST_PHASE_THEMES = {
GameSession.Status.LOBBY: "host-atrium",
GameSession.Status.LIE: "host-spotlight",
GameSession.Status.GUESS: "host-signal",
GameSession.Status.REVEAL: "host-verdict",
GameSession.Status.SCOREBOARD: "host-podium",
GameSession.Status.FINISHED: "host-finale",
}
HOST_PHASE_ORNAMENTS = {
GameSession.Status.LOBBY: "atrium-banner",
GameSession.Status.LIE: "spotlight-beam",
GameSession.Status.GUESS: "signal-grid",
GameSession.Status.REVEAL: "verdict-wave",
GameSession.Status.SCOREBOARD: "podium-ribbon",
GameSession.Status.FINISHED: "finale-burst",
}
PLAYER_IDENTITY_TONES = (
"ember",
"lagoon",
"gold",
"sage",
"coral",
)
PLAYER_IDENTITY_ICONS = (
"spark",
"wave",
"comet",
"leaf",
"crown",
)
PLAYER_PHASE_THEMES = {
"join": "player-boarding",
"lobby": "player-ready",
"waiting": "player-holding",
"lie": "player-ink",
"guess": "player-choices",
"reveal": "player-ripple",
"result": "player-pennant",
}
PLAYER_PHASE_ORNAMENTS = {
"join": "boarding-pass",
"lobby": "ready-lantern",
"waiting": "holding-ring",
"lie": "ink-trace",
"guess": "choice-grid",
"reveal": "ripple-flare",
"result": "pennant-stack",
}
def build_player_ref(player: Player | None) -> dict | None:
if player is None:
return None
return {
"player_id": player.id,
"nickname": player.nickname,
}
def _player_identity_token(nickname: str, join_order: int) -> str:
initial = nickname.strip()[:1].upper() or "P"
return f"{initial}{join_order}"
def build_player_identity_payload(player: Player, *, join_order: int) -> dict:
return {
"token": _player_identity_token(player.nickname, join_order),
"tone": PLAYER_IDENTITY_TONES[(join_order - 1) % len(PLAYER_IDENTITY_TONES)],
"icon": PLAYER_IDENTITY_ICONS[(join_order - 1) % len(PLAYER_IDENTITY_ICONS)],
}
def build_session_players_payload(session: GameSession) -> list[dict]:
joined_players = list(session.players.order_by("created_at", "id"))
identities_by_id = {
player.id: build_player_identity_payload(player, join_order=index)
for index, player in enumerate(joined_players, start=1)
}
return [
{
"id": player.id,
"nickname": player.nickname,
"score": player.score,
"is_connected": player.is_connected,
"identity": identities_by_id[player.id],
}
for player in sorted(joined_players, key=lambda entry: (entry.nickname.casefold(), entry.id))
]
def _can_view_round_prompt(session: GameSession, viewer_role: SessionViewerRole) -> bool:
return viewer_role == "host" or session.status in NON_HOST_PROMPT_PHASES
def build_round_question_payload(
round_question: RoundQuestion | None,
*,
session: GameSession,
viewer_role: SessionViewerRole,
) -> dict | None:
if round_question is None:
return None
return {
"id": round_question.id,
"round_number": round_question.round_number,
"prompt": round_question.question.prompt if _can_view_round_prompt(session, viewer_role) else None,
"shown_at": round_question.shown_at.isoformat(),
"answers": [{"text": text} for text in (round_question.mixed_answers or [])],
}
def build_reveal_payload(
round_question: RoundQuestion | None,
*,
session: GameSession,
viewer_role: SessionViewerRole,
) -> dict | None:
if round_question is None:
return None
lies = [
{
**build_player_ref(lie.player),
"text": lie.text,
"created_at": lie.created_at.isoformat(),
}
for lie in round_question.lies.select_related("player").order_by("created_at", "id")
]
guesses = []
for guess in round_question.guesses.select_related("player", "fooled_player").order_by("created_at", "id"):
guess_payload = {
**build_player_ref(guess.player),
"selected_text": guess.selected_text,
"is_correct": guess.is_correct,
"created_at": guess.created_at.isoformat(),
"fooled_player_id": guess.fooled_player_id,
}
if guess.fooled_player is not None:
guess_payload["fooled_player_nickname"] = guess.fooled_player.nickname
guesses.append(guess_payload)
return {
"round_question_id": round_question.id,
"round_number": round_question.round_number,
"prompt": round_question.question.prompt if _can_view_round_prompt(session, viewer_role) else None,
"correct_answer": round_question.correct_answer,
"lies": lies,
"guesses": guesses,
}
def build_voice_cues_payload(
voice_cues: dict | None,
*,
session: GameSession,
viewer_role: SessionViewerRole,
) -> dict | None:
if voice_cues is None:
return None
if viewer_role == "host":
return voice_cues
# Keep non-host payloads role-correct: players can still receive generic intro/phase
# metadata later if needed, but prompt-bearing cues stay presenter-only until reveal.
return {
**voice_cues,
"question_prompt": None,
"question_reveal": voice_cues.get("question_reveal") if session.status in NON_HOST_PROMPT_PHASES else None,
}
def build_leaderboard(session: GameSession) -> list[dict]:
return list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
def build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict:
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
return {
"round_number": session.current_round,
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
"lie_seconds": round_config.lie_seconds,
}
def _wait_cue_keys() -> tuple[str, str]:
return "host.presenter_scene_cue_wait_label", "host.presenter_scene_cue_wait_body"
def _resolve_authored_scene_ornament(
session: GameSession,
current_round_question: RoundQuestion | None,
) -> str | None:
if session.status not in {
GameSession.Status.LIE,
GameSession.Status.GUESS,
GameSession.Status.REVEAL,
}:
return None
if current_round_question is None:
return None
authored_ornament = current_round_question.question.scene_ornament
if authored_ornament in Question.SceneOrnament.values:
return authored_ornament
return None
def _build_host_phase_display_payload(
session: GameSession,
phase_view_model: dict,
*,
current_round_question: RoundQuestion | None = None,
) -> dict:
phase = session.status
host = phase_view_model["host"]
cue_label_key, cue_body_key = _wait_cue_keys()
if phase == GameSession.Status.FINISHED:
cue_label_key = "host.presenter_scene_cue_finished_label"
cue_body_key = "host.presenter_scene_cue_finished_body"
title_key = "host.presenter_scene_title_finished"
body_key = "host.presenter_scene_body_finished"
elif phase == GameSession.Status.LOBBY:
if host["can_start_round"]:
cue_label_key = "host.presenter_scene_cue_start_label"
cue_body_key = "host.presenter_scene_cue_start_body"
title_key = "host.presenter_scene_title_lobby"
body_key = "host.presenter_scene_body_lobby"
elif phase == GameSession.Status.GUESS:
if host["can_calculate_scores"]:
cue_label_key = "host.presenter_scene_cue_reveal_label"
cue_body_key = "host.presenter_scene_cue_reveal_body"
title_key = "host.presenter_scene_title_guess"
body_key = "host.presenter_scene_body_guess"
elif phase == GameSession.Status.REVEAL:
if host["can_reveal_scoreboard"]:
cue_label_key = "host.presenter_scene_cue_scoreboard_label"
cue_body_key = "host.presenter_scene_cue_scoreboard_body"
title_key = "host.presenter_scene_title_reveal"
body_key = "host.presenter_scene_body_reveal"
elif phase == GameSession.Status.SCOREBOARD:
if host["can_start_next_round"] or host["can_finish_game"]:
cue_label_key = "host.presenter_scene_cue_close_label"
cue_body_key = "host.presenter_scene_cue_close_body"
title_key = "host.presenter_scene_title_scoreboard"
body_key = "host.presenter_scene_body_scoreboard"
else:
if host["can_mix_answers"]:
cue_label_key = "host.presenter_scene_cue_mix_label"
cue_body_key = "host.presenter_scene_cue_mix_body"
elif host["can_show_question"]:
cue_label_key = "host.presenter_scene_cue_show_label"
cue_body_key = "host.presenter_scene_cue_show_body"
title_key = "host.presenter_scene_title"
body_key = "host.presenter_scene_body_lie"
return {
"theme": HOST_PHASE_THEMES.get(phase, HOST_PHASE_THEMES[GameSession.Status.LIE]),
"ornament": _resolve_authored_scene_ornament(session, current_round_question)
or HOST_PHASE_ORNAMENTS.get(phase, HOST_PHASE_ORNAMENTS[GameSession.Status.LIE]),
"title_key": title_key,
"body_key": body_key,
"cue_label_key": cue_label_key,
"cue_body_key": cue_body_key,
}
def _build_player_phase_display_payload(
session: GameSession,
phase_view_model: dict,
viewer_role: SessionViewerRole,
*,
current_round_question: RoundQuestion | None = None,
) -> dict:
phase = session.status
player = phase_view_model["player"]
authored_ornament = _resolve_authored_scene_ornament(session, current_round_question)
if viewer_role != "player" and player["can_join"]:
return {
"theme": PLAYER_PHASE_THEMES["join"],
"ornament": PLAYER_PHASE_ORNAMENTS["join"],
"title_key": "player.player_scene_title_join",
"body_key": "player.player_scene_body_join",
"cue_label_key": "player.player_scene_cue_join_label",
"cue_body_key": "player.player_scene_cue_join_body",
}
if player["can_submit_lie"]:
return {
"theme": PLAYER_PHASE_THEMES["lie"],
"ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["lie"],
"title_key": "player.submit_lie",
"body_key": "player.phase_summary_lie",
"cue_label_key": "player.active_scene_cue_lie_label",
"cue_body_key": "player.active_scene_cue_lie_body",
}
if player["can_submit_guess"]:
return {
"theme": PLAYER_PHASE_THEMES["guess"],
"ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["guess"],
"title_key": "player.submit_guess",
"body_key": "player.phase_summary_guess",
"cue_label_key": "player.active_scene_cue_guess_label",
"cue_body_key": "player.active_scene_cue_guess_body",
}
if phase == GameSession.Status.REVEAL:
return {
"theme": PLAYER_PHASE_THEMES["reveal"],
"ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["reveal"],
"title_key": "player.reveal_title",
"body_key": "player.phase_summary_reveal",
"cue_label_key": "player.active_scene_cue_reveal_label",
"cue_body_key": "player.active_scene_cue_reveal_body",
}
if phase in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED} or player["can_view_final_result"]:
is_finished = phase == GameSession.Status.FINISHED or player["can_view_final_result"]
return {
"theme": PLAYER_PHASE_THEMES["result"],
"ornament": PLAYER_PHASE_ORNAMENTS["result"],
"title_key": "player.final_leaderboard" if is_finished else "player.scoreboard_title",
"body_key": "player.phase_summary_finished" if is_finished else "player.phase_summary_scoreboard",
"cue_label_key": "player.active_scene_cue_result_label",
"cue_body_key": "player.active_scene_cue_result_body",
}
if phase == GameSession.Status.LOBBY:
return {
"theme": PLAYER_PHASE_THEMES["lobby"],
"ornament": PLAYER_PHASE_ORNAMENTS["lobby"],
"title_key": "player.player_scene_title_lobby",
"body_key": "player.player_scene_body_lobby",
"cue_label_key": "player.player_scene_cue_lobby_label",
"cue_body_key": "player.player_scene_cue_lobby_body",
}
waiting_title_key = {
GameSession.Status.LIE: "player.player_scene_title_waiting_lie",
GameSession.Status.GUESS: "player.player_scene_title_waiting_guess",
}.get(phase, "player.player_scene_title_waiting")
return {
"theme": PLAYER_PHASE_THEMES["waiting"],
"ornament": authored_ornament or PLAYER_PHASE_ORNAMENTS["waiting"],
"title_key": waiting_title_key,
"body_key": "player.player_scene_body_waiting",
"cue_label_key": "player.player_scene_cue_waiting_label",
"cue_body_key": "player.player_scene_cue_waiting_body",
}
def build_phase_display_payload(
session: GameSession,
*,
viewer_role: SessionViewerRole,
phase_view_model: dict,
current_round_question: RoundQuestion | None = None,
) -> dict:
if viewer_role == "host":
return _build_host_phase_display_payload(
session,
phase_view_model,
current_round_question=current_round_question,
)
return _build_player_phase_display_payload(
session,
phase_view_model,
viewer_role,
current_round_question=current_round_question,
)
def build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
status = session.status
in_lobby = status == GameSession.Status.LOBBY
in_lie = status == GameSession.Status.LIE
in_guess = status == GameSession.Status.GUESS
in_scoreboard = status == GameSession.Status.SCOREBOARD
in_finished = status == GameSession.Status.FINISHED
min_players_reached = players_count >= 3
max_players_allowed = players_count <= 5
return {
"status": status,
"current_phase": status,
"round_number": session.current_round,
"players_count": players_count,
"constraints": {
"min_players_to_start": 3,
"max_players_mvp": 5,
"min_players_reached": min_players_reached,
"max_players_allowed": max_players_allowed,
},
"readiness": {
"question_ready": has_round_question,
"scoreboard_ready": status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED},
"can_advance_to_next_round": in_scoreboard,
},
"host": {
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
"can_show_question": in_lie and has_round_question,
"can_mix_answers": (in_lie or in_guess) and has_round_question,
"can_calculate_scores": in_guess and has_round_question,
"can_reveal_scoreboard": status == GameSession.Status.REVEAL,
"can_start_next_round": in_scoreboard,
"can_finish_game": in_scoreboard,
},
"player": {
"can_join": status in {
GameSession.Status.LOBBY,
GameSession.Status.LIE,
GameSession.Status.GUESS,
GameSession.Status.REVEAL,
GameSession.Status.SCOREBOARD,
},
"can_submit_lie": in_lie and has_round_question,
"can_submit_guess": in_guess and has_round_question,
"can_view_final_result": in_finished,
},
}
def build_session_detail_gameplay_payload(
session: GameSession,
*,
current_round_question: RoundQuestion | None,
players_count: int,
viewer_role: SessionViewerRole,
voice_cues: dict | None = None,
) -> dict:
phase_view_model = build_phase_view_model(
session,
players_count=players_count,
has_round_question=bool(current_round_question),
)
return {
"round_question": build_round_question_payload(
current_round_question,
session=session,
viewer_role=viewer_role,
),
"reveal": build_reveal_payload(
current_round_question,
session=session,
viewer_role=viewer_role,
)
if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question
else None,
"scoreboard": build_scoreboard_phase_event(session)["payload"]["leaderboard"]
if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}
else None,
"voice_cues": build_voice_cues_payload(
voice_cues,
session=session,
viewer_role=viewer_role,
),
"phase_view_model": phase_view_model,
"phase_display": build_phase_display_payload(
session,
viewer_role=viewer_role,
phase_view_model=phase_view_model,
current_round_question=current_round_question,
),
}
def build_start_round_response(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
lie_started_payload = build_lie_started_payload(session, round_config, round_question)
return {
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"round": {
"number": round_config.number,
"category": {
"slug": round_config.category.slug,
"name": round_config.category.name,
},
},
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_started_payload["lie_deadline_at"],
},
"config": {
"lie_seconds": round_config.lie_seconds,
},
}
def build_question_shown_payload(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict:
return {
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at,
"lie_seconds": lie_seconds,
}
def build_question_shown_response(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict:
return {
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at,
},
"config": {
"lie_seconds": lie_seconds,
},
}
def build_start_next_round_response(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
return build_start_round_response(session, round_config, round_question)
def build_start_next_round_phase_event(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
return {
"name": "phase.lie_started",
"payload": build_lie_started_payload(session, round_config, round_question),
}
def build_scoreboard_phase_event(session: GameSession, leaderboard: list[dict] | None = None) -> dict:
return {
"name": "phase.scoreboard",
"payload": {
"leaderboard": leaderboard if leaderboard is not None else build_leaderboard(session),
"current_round": session.current_round,
},
}
def build_reveal_scoreboard_response(session: GameSession, leaderboard: list[dict]) -> dict:
return {
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"leaderboard": leaderboard,
}
def build_finish_game_phase_event(session: GameSession) -> dict:
leaderboard = build_leaderboard(session)
winner = leaderboard[0] if leaderboard else None
return {
"name": "phase.game_over",
"payload": {"winner": winner, "leaderboard": leaderboard},
}
def build_finish_game_response(session: GameSession) -> dict:
finish_event = build_finish_game_phase_event(session)
return {
"session": {
"code": session.code,
"status": GameSession.Status.FINISHED,
"current_round": session.current_round,
},
"winner": finish_event["payload"]["winner"],
"leaderboard": finish_event["payload"]["leaderboard"],
}

494
fupogfakta/services.py Normal file
View File

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

View File

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

View File

@@ -1,2 +1,651 @@
from datetime import timedelta
# Create your views here.
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError, transaction
from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST
from lobby.http import json_body, normalize_session_code
from lobby.i18n import api_error
from realtime.broadcast import sync_broadcast_phase_event
from .models import GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent
from .payloads import (
build_leaderboard as _build_leaderboard,
build_reveal_payload as _build_reveal_payload,
)
from .services import (
finish_game as _finish_game,
prepare_mixed_answers as _prepare_mixed_answers,
promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard,
resolve_scores as _resolve_scores,
show_question as _show_question,
start_next_round as _start_next_round,
start_round as _start_round,
)
def _broadcast_transition(transition) -> None:
if transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
def maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
transition = _promote_reveal_to_scoreboard(session)
_broadcast_transition(transition)
return transition.session
@require_POST
@login_required
def start_round(request: HttpRequest, code: str) -> JsonResponse:
payload = json_body(request)
category_slug = str(payload.get('category_slug', '')).strip()
if not category_slug:
return api_error(
request,
code='category_slug_required',
status=400,
)
session_code = normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(
request,
code='session_not_found',
status=404,
)
if session.host_id != request.user.id:
return api_error(
request,
code='host_only_start_round',
status=403,
)
try:
transition = _start_round(session, category_slug)
except ValueError as exc:
error_code = str(exc)
error_status = {
'category_not_found': 404,
'round_already_configured': 409,
}.get(error_code, 400)
return api_error(request, code=error_code, status=error_status)
_broadcast_transition(transition)
return JsonResponse(transition.response_payload, status=201)
@require_POST
@login_required
def show_question(request: HttpRequest, code: str) -> JsonResponse:
session_code = normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(
request,
code='session_not_found',
status=404,
)
if session.host_id != request.user.id:
return api_error(
request,
code='host_only_show_question',
status=403,
)
try:
transition = _show_question(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
_broadcast_transition(transition)
return JsonResponse(transition.response_payload, status=201)
@require_POST
def submit_lie(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
payload = json_body(request)
session_code = normalize_session_code(code)
player_id = payload.get('player_id')
session_token = str(payload.get('session_token', '')).strip()
lie_text = str(payload.get('text', '')).strip()
if not player_id:
return api_error(request, code='player_id_required', status=400)
if not session_token:
return api_error(request, code='session_token_required', status=400)
if not lie_text or len(lie_text) > 255:
return api_error(request, code='lie_text_invalid', status=400)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code='session_not_found', status=404)
if session.status != GameSession.Status.LIE:
return api_error(request, code='lie_submission_invalid_phase', status=400)
try:
player = Player.objects.get(pk=player_id, session=session)
except Player.DoesNotExist:
return api_error(request, code='player_not_found_in_session', status=404)
if player.session_token != session_token:
return api_error(request, code='invalid_player_session_token', status=403)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return api_error(request, code='round_question_not_found', status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return api_error(request, code='round_config_missing', status=400)
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
if timezone.now() > lie_deadline_at:
return api_error(request, code='lie_submission_closed', status=400)
try:
lie = LieAnswer.objects.create(round_question=round_question, player=player, text=lie_text)
except IntegrityError:
return api_error(request, code='lie_already_submitted', status=409)
players_count = Player.objects.filter(session=session).count()
lie_count = LieAnswer.objects.filter(round_question=round_question).count()
session_status = session.status
mixed_answers_payload = None
if players_count > 0 and lie_count >= players_count:
try:
mixed_answers = _prepare_mixed_answers(round_question)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
session.status = GameSession.Status.GUESS
session.save(update_fields=['status'])
session_status = session.status
mixed_answers_payload = [{'text': text} for text in mixed_answers]
sync_broadcast_phase_event(
session.code,
'phase.guess_started',
{
'round_question_id': round_question.id,
'answers': mixed_answers_payload,
'guess_seconds': round_config.guess_seconds,
},
)
return JsonResponse(
{
'lie': {
'id': lie.id,
'player_id': player.id,
'round_question_id': round_question.id,
'text': lie.text,
'created_at': lie.created_at.isoformat(),
},
'window': {
'lie_deadline_at': lie_deadline_at.isoformat(),
},
'session': {
'code': session.code,
'status': session_status,
'current_round': session.current_round,
},
'phase_transition': {
'current_phase': session_status,
'lies_submitted': lie_count,
'players_expected': players_count,
'auto_advanced': session_status == GameSession.Status.GUESS,
},
'answers': mixed_answers_payload,
},
status=201,
)
@require_POST
@login_required
def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
session_code = normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(
request,
code='session_not_found',
status=404,
)
if session.host_id != request.user.id:
return api_error(
request,
code='host_only_mix_answers',
status=403,
)
if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return api_error(
request,
code='mix_answers_invalid_phase',
status=400,
)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return api_error(
request,
code='round_question_not_found',
status=404,
)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return api_error(
request,
code='mix_answers_invalid_phase',
status=400,
)
locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk)
try:
deduped_answers = _prepare_mixed_answers(locked_round_question)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
if locked_session.status == GameSession.Status.LIE:
locked_session.status = GameSession.Status.GUESS
locked_session.save(update_fields=['status'])
try:
guess_config = RoundConfig.objects.get(session=session, number=session.current_round)
guess_seconds = guess_config.guess_seconds
except RoundConfig.DoesNotExist:
guess_seconds = None
sync_broadcast_phase_event(
session.code,
'phase.guess_started',
{
'round_question_id': round_question.id,
'answers': [{'text': text} for text in deduped_answers],
'guess_seconds': guess_seconds,
},
)
return JsonResponse(
{
'session': {
'code': session.code,
'status': GameSession.Status.GUESS,
'current_round': session.current_round,
},
'round_question': {
'id': round_question.id,
'round_number': round_question.round_number,
},
'answers': [{'text': text} for text in deduped_answers],
}
)
@require_POST
def submit_guess(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
payload = json_body(request)
session_code = normalize_session_code(code)
player_id = payload.get('player_id')
session_token = str(payload.get('session_token', '')).strip()
selected_text = str(payload.get('selected_text', '')).strip()
if not player_id:
return api_error(request, code='player_id_required', status=400)
if not session_token:
return api_error(request, code='session_token_required', status=400)
if not selected_text or len(selected_text) > 255:
return api_error(request, code='selected_text_invalid', status=400)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code='session_not_found', status=404)
if session.status != GameSession.Status.GUESS:
return api_error(request, code='guess_submission_invalid_phase', status=400)
try:
player = Player.objects.get(pk=player_id, session=session)
except Player.DoesNotExist:
return api_error(request, code='player_not_found_in_session', status=404)
if player.session_token != session_token:
return api_error(request, code='invalid_player_session_token', status=403)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return api_error(request, code='round_question_not_found', status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return api_error(request, code='round_config_missing', status=400)
guess_deadline_at = round_question.shown_at + timedelta(
seconds=round_config.lie_seconds + round_config.guess_seconds
)
if timezone.now() > guess_deadline_at:
return api_error(request, code='guess_submission_closed', status=400)
mixed_answers = round_question.mixed_answers or _prepare_mixed_answers(round_question)
allowed_answers = {
text.strip().casefold()
for text in mixed_answers
if isinstance(text, str) and text.strip()
}
selected_normalized = selected_text.casefold()
if selected_normalized not in allowed_answers:
return api_error(request, code='selected_answer_invalid', status=400)
correct_normalized = round_question.correct_answer.strip().casefold()
fooled_player_id = None
if selected_normalized != correct_normalized:
fooled_player_id = (
round_question.lies.filter(text__iexact=selected_text).values_list('player_id', flat=True).first()
)
try:
guess = Guess.objects.create(
round_question=round_question,
player=player,
selected_text=selected_text,
is_correct=selected_normalized == correct_normalized,
fooled_player_id=fooled_player_id,
)
except IntegrityError:
return api_error(request, code='guess_already_submitted', status=409)
players_count = Player.objects.filter(session=session).count()
guess_count = Guess.objects.filter(round_question=round_question).count()
session_status = session.status
reveal_payload = None
leaderboard = None
if players_count > 0 and guess_count >= players_count:
score_events = []
should_broadcast_scores = False
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status == GameSession.Status.GUESS:
already_calculated = ScoreEvent.objects.filter(
session=locked_session,
meta__round_question_id=round_question.id,
).exists()
if not already_calculated:
score_events, leaderboard = _resolve_scores(locked_session, round_question, round_config)
should_broadcast_scores = True
else:
score_events = list(
ScoreEvent.objects.filter(
session=locked_session,
meta__round_question_id=round_question.id,
).select_related('player')
)
leaderboard = _build_leaderboard(locked_session)
locked_session.status = GameSession.Status.REVEAL
locked_session.save(update_fields=['status'])
elif locked_session.status == GameSession.Status.REVEAL:
score_events = list(
ScoreEvent.objects.filter(
session=locked_session,
meta__round_question_id=round_question.id,
).select_related('player')
)
leaderboard = _build_leaderboard(locked_session)
session_status = locked_session.status
reveal_payload = _build_reveal_payload(
round_question,
session=locked_session,
viewer_role='player',
)
if should_broadcast_scores:
score_deltas = [
{'player_id': ev.player_id, 'delta': ev.delta, 'reason': ev.reason}
for ev in score_events
]
sync_broadcast_phase_event(
session.code,
'phase.scores_calculated',
{
'round_question_id': round_question.id,
'score_deltas': score_deltas,
'leaderboard': list(leaderboard),
},
)
return JsonResponse(
{
'guess': {
'id': guess.id,
'player_id': player.id,
'round_question_id': round_question.id,
'selected_text': guess.selected_text,
'is_correct': guess.is_correct,
'fooled_player_id': guess.fooled_player_id,
'created_at': guess.created_at.isoformat(),
},
'window': {
'guess_deadline_at': guess_deadline_at.isoformat(),
},
'session': {
'code': session.code,
'status': session_status,
'current_round': session.current_round,
},
'phase_transition': {
'current_phase': session_status,
'guesses_submitted': guess_count,
'players_expected': players_count,
'auto_advanced': session_status == GameSession.Status.REVEAL,
},
'reveal': reveal_payload,
'leaderboard': leaderboard,
},
status=201,
)
@require_GET
@login_required
def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
session_code = normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code='session_not_found', status=404)
if session.host_id != request.user.id:
return api_error(request, code='host_only_view_scoreboard', status=403)
transition = _promote_reveal_to_scoreboard(session)
_broadcast_transition(transition)
session = transition.session
if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
return api_error(request, code='scoreboard_invalid_phase', status=400)
return JsonResponse(transition.response_payload)
@require_POST
@login_required
def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
session_code = normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code='session_not_found', status=404)
if session.host_id != request.user.id:
return api_error(request, code='host_only_start_next_round', status=403)
try:
transition = _start_next_round(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
_broadcast_transition(transition)
return JsonResponse(transition.response_payload)
@require_POST
@login_required
def finish_game(request: HttpRequest, code: str) -> JsonResponse:
session_code = normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code='session_not_found', status=404)
if session.host_id != request.user.id:
return api_error(request, code='host_only_finish_game', status=403)
try:
transition = _finish_game(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
_broadcast_transition(transition)
return JsonResponse(transition.response_payload)
@require_POST
@login_required
def calculate_scores(request: HttpRequest, code: str, round_question_id: int) -> JsonResponse:
session_code = normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist:
return api_error(request, code='session_not_found', status=404)
if session.host_id != request.user.id:
return api_error(request, code='host_only_calculate_scores', status=403)
already_calculated = ScoreEvent.objects.filter(
session=session,
meta__round_question_id=round_question_id,
).exists()
if already_calculated:
return api_error(request, code='scores_already_calculated', status=409)
if session.status != GameSession.Status.GUESS:
return api_error(request, code='calculate_scores_invalid_phase', status=400)
try:
round_question = RoundQuestion.objects.get(
pk=round_question_id,
session=session,
round_number=session.current_round,
)
except RoundQuestion.DoesNotExist:
return api_error(request, code='round_question_not_found', status=404)
try:
round_config = RoundConfig.objects.get(session=session, number=round_question.round_number)
except RoundConfig.DoesNotExist:
return api_error(request, code='round_config_missing', status=400)
try:
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.GUESS:
return api_error(request, code='calculate_scores_invalid_phase', status=400)
score_events, leaderboard = _resolve_scores(locked_session, round_question, round_config)
locked_session.status = GameSession.Status.REVEAL
locked_session.save(update_fields=['status'])
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
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': {
'code': session.code,
'status': GameSession.Status.REVEAL,
'current_round': session.current_round,
},
'round_question': {
'id': round_question.id,
'round_number': round_question.round_number,
},
'reveal': _build_reveal_payload(
round_question,
session=session,
viewer_role='host',
),
'events_created': len(score_events),
'leaderboard': leaderboard,
}
)

15
infra/env/.env.dev.example vendored Normal file
View File

@@ -0,0 +1,15 @@
DJANGO_SECRET_KEY=change-me-dev
DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DB_ENGINE=django.db.backends.mysql
DB_NAME=wpp_dev
DB_USER=wpp_dev
DB_PASSWORD=wpp_dev
DB_HOST=127.0.0.1
DB_PORT=3307
TEST_DB_NAME=wpp_test
CHANNEL_REDIS_HOST=127.0.0.1
CHANNEL_REDIS_PORT=6380
USE_SPA_UI=false
WPP_SPA_ASSET_BASE=http://localhost:4200/browser
WPP_SPA_ASSET_VERSION=dev

View File

@@ -10,3 +10,6 @@ DB_PORT=3306
TEST_DB_NAME=
CHANNEL_REDIS_HOST=127.0.0.1
CHANNEL_REDIS_PORT=6379
USE_SPA_UI=false
WPP_SPA_ASSET_BASE=/static/frontend/angular/browser
WPP_SPA_ASSET_VERSION=prod-dev

View File

@@ -10,3 +10,6 @@ DB_PORT=3306
TEST_DB_NAME=
CHANNEL_REDIS_HOST=127.0.0.1
CHANNEL_REDIS_PORT=6379
USE_SPA_UI=false
WPP_SPA_ASSET_BASE=/static/frontend/angular/browser
WPP_SPA_ASSET_VERSION=staging-dev

View File

@@ -10,3 +10,6 @@ DB_PORT=3306
TEST_DB_NAME=wpp_test
CHANNEL_REDIS_HOST=127.0.0.1
CHANNEL_REDIS_PORT=6379
USE_SPA_UI=false
WPP_SPA_ASSET_BASE=/static/frontend/angular/browser
WPP_SPA_ASSET_VERSION=test-dev

View File

@@ -9,6 +9,18 @@ Staging-miljø for WPP i Proxmox LXC, så release-klar kode kan deployes og smok
- Service: wpp-staging.service
- Health endpoint: GET /healthz
- Database: MySQL (staging må ikke bruge SQLite, issue #133)
- Aktuel MVP UI-path: legacy host/player UI (`USE_SPA_UI=false`)
## MVP env-kontrakt
Staging skal mindst have følgende release-relevante env vars sat:
- `DB_ENGINE=django.db.backends.mysql`
- `CHANNEL_REDIS_HOST` + `CHANNEL_REDIS_PORT`
- `USE_SPA_UI=false`
- `WPP_SPA_ASSET_BASE=/static/frontend/angular/browser`
- `WPP_SPA_ASSET_VERSION=<release-tag eller sha>`
`USE_SPA_UI=true` er ikke del af den primære MVP release-gate. Det hører til separat cutover-verifikation.
## Verifikation
Kør fra devops-shell med Proxmox-adgang:
@@ -24,6 +36,23 @@ Forventet:
Smoke-suite skriver nu et gameplay-artifact som JSON under `/opt/wpp-staging/app/artifacts/smoke/` (kan overrides via `ARTIFACT_DIR`/`ARTIFACT_FILE`).
Før manuel UI-smoke anbefales følgende bootstrap på staging-app'en:
python manage.py bootstrap_mvp
Det sikrer en host-bruger og aktiv demo-kategori/spørgsmål uden ad hoc admin-oprettelse.
For den automatiske MVP bootstrap + smoke artifact flow bruges den kanoniske kommando:
./infra/staging/run_mvp_smoke.sh
Kommandoen kører i staging-CT via Proxmox, loader staging-env, kører `bootstrap_mvp`, og derefter `smoke_staging --artifact ...`.
Som default håndhæver den MVP-pathen `USE_SPA_UI=false`. Brug kun `ALLOW_SPA_CUTOVER=1` ved separat SPA-cutover.
For release-lignende "én kommando" execution bruges wrapperen:
./infra/staging/deploy_and_smoke_staging.sh [ref] [artifact-path]
Efter deploy validerer scriptet, at `DB_ENGINE` ikke er `django.db.backends.sqlite3` før migrations køres.
Deploy-scriptet bruger en release-candidate mappe og promoverer først til `/opt/wpp-staging/app` efter succesfuld `migrate`. Det reducerer schema/code drift ved afbrudte deploys (issue #130) og understøtter release-readiness gate (issue #90).
@@ -35,6 +64,10 @@ Officiel kommando:
./infra/staging/deploy_staging.sh [ref]
Anbefalet release-wrapper:
./infra/staging/deploy_and_smoke_staging.sh [ref] [artifact-path]
Scriptet bruger default PROXMOX_HOST=proxmox-lan og kører sudo -n pct exec på hosten.
Eksempler:
@@ -42,9 +75,25 @@ Eksempler:
./infra/staging/deploy_staging.sh
./infra/staging/deploy_staging.sh v0.3.0
PROXMOX_HOST=proxmox-prod ./infra/staging/deploy_staging.sh main
./infra/staging/deploy_and_smoke_staging.sh main
./infra/staging/deploy_and_smoke_staging.sh v0.3.0 /opt/wpp-staging/app/artifacts/smoke/release-smoke.json
## Smoke (canonical execution context)
MVP smoke skal køres via Proxmox host over SSH, ligesom deploy:
./infra/staging/run_mvp_smoke.sh
Eksempler:
./infra/staging/run_mvp_smoke.sh
./infra/staging/run_mvp_smoke.sh /opt/wpp-staging/app/artifacts/smoke/manual-smoke.json
PROXMOX_HOST=proxmox-prod CT_ID=222 ./infra/staging/run_mvp_smoke.sh
ALLOW_SPA_CUTOVER=1 ./infra/staging/run_mvp_smoke.sh
## Policy-kobling
Før deploy:
1. Bekræft at tester ikke er aktiv (ingen aktiv smoke-run).
2. Deploy til staging skal lykkes.
3. Først derefter må release-tag oprettes (se docs/RELEASE_POLICY.md).
2. Kør helst `./infra/staging/deploy_and_smoke_staging.sh` for release-kandidater.
3. Hvis wrapper ikke bruges: deploy til staging og kør derefter `./infra/staging/run_mvp_smoke.sh`.
4. Bekræft MVP UI-smoke på legacy UI (`/lobby/ui/host` + `/lobby/ui/player`).
5. Først derefter må release-tag oprettes (se docs/RELEASE_POLICY.md).

View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REF_NAME="${1:-main}"
ARTIFACT_FILE="${2:-}"
echo "[release] deploy + smoke start REF=${REF_NAME}"
"${SCRIPT_DIR}/deploy_staging.sh" "${REF_NAME}"
if [[ -n "${ARTIFACT_FILE}" ]]; then
"${SCRIPT_DIR}/run_mvp_smoke.sh" "${ARTIFACT_FILE}"
else
"${SCRIPT_DIR}/run_mvp_smoke.sh"
fi
echo "[release] deploy + smoke OK REF=${REF_NAME}"

97
infra/staging/run_mvp_smoke.sh Executable file
View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
CT_ID="${CT_ID:-143}"
PROXMOX_HOST="${PROXMOX_HOST:-proxmox-lan}"
APP_DIR="${APP_DIR:-/opt/wpp-staging/app}"
ARTIFACT_FILE="${1:-${ARTIFACT_FILE:-}}"
ARTIFACT_DIR_ARG="${ARTIFACT_DIR:-}"
BASE_URL_ARG="${BASE_URL:-}"
ISSUE_ON_FAIL="${ISSUE_ON_FAIL:-1}"
RUN_BOOTSTRAP_MVP="${RUN_BOOTSTRAP_MVP:-1}"
BOOTSTRAP_MVP_ARGS="${BOOTSTRAP_MVP_ARGS:-}"
ALLOW_SPA_CUTOVER="${ALLOW_SPA_CUTOVER:-0}"
ENV_FILE_ARG="${ENV_FILE:-}"
GITEA_BASE_ARG="${GITEA_BASE:-}"
GITEA_REPO_ARG="${GITEA_REPO:-}"
GITEA_USER_ARG="${GITEA_USER:-}"
GITEA_TOKEN_ARG="${GITEA_TOKEN:-}"
echo "[smoke] host=${PROXMOX_HOST} CT_ID=${CT_ID} APP_DIR=${APP_DIR}"
if [[ -n "${ARTIFACT_FILE}" ]]; then
echo "[smoke] artifact=${ARTIFACT_FILE}"
fi
ssh "${PROXMOX_HOST}" sudo -n /usr/sbin/pct exec "${CT_ID}" -- bash -s -- \
"${APP_DIR}" \
"${ARTIFACT_FILE}" \
"${ARTIFACT_DIR_ARG}" \
"${BASE_URL_ARG}" \
"${ISSUE_ON_FAIL}" \
"${RUN_BOOTSTRAP_MVP}" \
"${BOOTSTRAP_MVP_ARGS}" \
"${ALLOW_SPA_CUTOVER}" \
"${ENV_FILE_ARG}" \
"${GITEA_BASE_ARG}" \
"${GITEA_REPO_ARG}" \
"${GITEA_USER_ARG}" \
"${GITEA_TOKEN_ARG}" <<'REMOTE'
set -euo pipefail
APP_DIR="$1"
ARTIFACT_FILE="$2"
ARTIFACT_DIR_ARG="$3"
BASE_URL_ARG="$4"
ISSUE_ON_FAIL="$5"
RUN_BOOTSTRAP_MVP="$6"
BOOTSTRAP_MVP_ARGS="$7"
ALLOW_SPA_CUTOVER="$8"
ENV_FILE_ARG="$9"
GITEA_BASE_ARG="${10}"
GITEA_REPO_ARG="${11}"
GITEA_USER_ARG="${12}"
GITEA_TOKEN_ARG="${13}"
SCRIPT_PATH="${APP_DIR}/infra/staging/smoke_suite.sh"
if [[ ! -f "${SCRIPT_PATH}" ]]; then
echo "[smoke] ERROR: missing script ${SCRIPT_PATH}" >&2
exit 1
fi
ENV_VARS=(
"APP_DIR=${APP_DIR}"
"ISSUE_ON_FAIL=${ISSUE_ON_FAIL}"
"RUN_BOOTSTRAP_MVP=${RUN_BOOTSTRAP_MVP}"
"BOOTSTRAP_MVP_ARGS=${BOOTSTRAP_MVP_ARGS}"
"ALLOW_SPA_CUTOVER=${ALLOW_SPA_CUTOVER}"
)
if [[ -n "${ARTIFACT_FILE}" ]]; then
ENV_VARS+=("ARTIFACT_FILE=${ARTIFACT_FILE}")
fi
if [[ -n "${ARTIFACT_DIR_ARG}" ]]; then
ENV_VARS+=("ARTIFACT_DIR=${ARTIFACT_DIR_ARG}")
fi
if [[ -n "${BASE_URL_ARG}" ]]; then
ENV_VARS+=("BASE_URL=${BASE_URL_ARG}")
fi
if [[ -n "${ENV_FILE_ARG}" ]]; then
ENV_VARS+=("ENV_FILE=${ENV_FILE_ARG}")
fi
if [[ -n "${GITEA_BASE_ARG}" ]]; then
ENV_VARS+=("GITEA_BASE=${GITEA_BASE_ARG}")
fi
if [[ -n "${GITEA_REPO_ARG}" ]]; then
ENV_VARS+=("GITEA_REPO=${GITEA_REPO_ARG}")
fi
if [[ -n "${GITEA_USER_ARG}" ]]; then
ENV_VARS+=("GITEA_USER=${GITEA_USER_ARG}")
fi
if [[ -n "${GITEA_TOKEN_ARG}" ]]; then
ENV_VARS+=("GITEA_TOKEN=${GITEA_TOKEN_ARG}")
fi
runuser -u wpp -- env "${ENV_VARS[@]}" bash "${SCRIPT_PATH}"
REMOTE
echo "[smoke] OK: staging MVP smoke complete"

View File

@@ -4,6 +4,9 @@ set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8000}"
APP_DIR="${APP_DIR:-/opt/wpp-staging/app}"
ISSUE_ON_FAIL="${ISSUE_ON_FAIL:-1}"
RUN_BOOTSTRAP_MVP="${RUN_BOOTSTRAP_MVP:-1}"
BOOTSTRAP_MVP_ARGS="${BOOTSTRAP_MVP_ARGS:-}"
ALLOW_SPA_CUTOVER="${ALLOW_SPA_CUTOVER:-0}"
fail() {
local message="$1"
@@ -50,10 +53,36 @@ PY
echo "[smoke] healthz check: ${BASE_URL}/healthz"
curl -fsS "${BASE_URL}/healthz" >/dev/null || { SMOKE_FAIL_MESSAGE="healthz check failed" fail "healthz check failed"; }
ENV_FILE="${ENV_FILE:-/etc/wpp/staging.env}"
resolve_env_file() {
if [[ -n "${ENV_FILE:-}" ]]; then
if [[ -f "${ENV_FILE}" ]]; then
printf '%s\n' "${ENV_FILE}"
return 0
fi
return 1
fi
local candidate
for candidate in \
/opt/wpp-staging/.env.staging \
/opt/wpp-staging/.env \
/opt/wpp-staging/env/wpp_staging.env \
/opt/wpp-staging/secrets/wpp_staging.env \
/etc/wpp/staging.env
do
if [[ -f "${candidate}" ]]; then
printf '%s\n' "${candidate}"
return 0
fi
done
return 1
}
ENV_FILE="$(resolve_env_file)" || { SMOKE_FAIL_MESSAGE="staging env file not found" fail "staging env file not found"; }
echo "[smoke] env file: ${ENV_FILE}"
run_manage() {
local cmd="$1"
(
cd "${APP_DIR}"
if [[ -f "${ENV_FILE}" ]]; then
@@ -62,18 +91,37 @@ run_manage() {
source "${ENV_FILE}"
set +a
fi
.venv/bin/python manage.py ${cmd}
.venv/bin/python manage.py "$@"
)
}
echo "[smoke] migration consistency check"
run_manage "migrate --check --noinput" || { SMOKE_FAIL_MESSAGE="schema drift: unapplied migrations in staging" fail "schema drift: unapplied migrations in staging"; }
run_manage migrate --check --noinput || { SMOKE_FAIL_MESSAGE="schema drift: unapplied migrations in staging" fail "schema drift: unapplied migrations in staging"; }
if [[ "${ALLOW_SPA_CUTOVER}" != "1" ]]; then
echo "[smoke] MVP UI mode check (expect USE_SPA_UI=false)"
run_manage shell -c "from django.conf import settings; import sys; print('USE_SPA_UI=' + ('true' if settings.USE_SPA_UI else 'false')); sys.exit(0 if not settings.USE_SPA_UI else 1)" \
|| { SMOKE_FAIL_MESSAGE="USE_SPA_UI=true is outside the canonical MVP smoke path" fail "USE_SPA_UI=true is outside the canonical MVP smoke path"; }
else
echo "[smoke] SPA cutover override enabled (ALLOW_SPA_CUTOVER=1)"
fi
if [[ "${RUN_BOOTSTRAP_MVP}" == "1" ]]; then
echo "[smoke] bootstrap MVP host + demo questions"
if [[ -n "${BOOTSTRAP_MVP_ARGS}" ]]; then
# shellcheck disable=SC2206
bootstrap_args=(${BOOTSTRAP_MVP_ARGS})
run_manage bootstrap_mvp "${bootstrap_args[@]}" || { SMOKE_FAIL_MESSAGE="manage.py bootstrap_mvp failed" fail "manage.py bootstrap_mvp failed"; }
else
run_manage bootstrap_mvp || { SMOKE_FAIL_MESSAGE="manage.py bootstrap_mvp failed" fail "manage.py bootstrap_mvp failed"; }
fi
fi
ARTIFACT_DIR="${ARTIFACT_DIR:-${APP_DIR}/artifacts/smoke}"
ARTIFACT_FILE="${ARTIFACT_FILE:-${ARTIFACT_DIR}/smoke-$(date -u +%Y%m%dT%H%M%SZ).json}"
echo "[smoke] gameplay flow via management command"
run_manage "smoke_staging --artifact ${ARTIFACT_FILE}" || { SMOKE_FAIL_MESSAGE="manage.py smoke_staging failed" fail "manage.py smoke_staging failed"; }
run_manage smoke_staging --artifact "${ARTIFACT_FILE}" || { SMOKE_FAIL_MESSAGE="manage.py smoke_staging failed" fail "manage.py smoke_staging failed"; }
echo "[smoke] artifact: ${ARTIFACT_FILE}"
echo "[smoke] OK"

17
lobby/http.py Normal file
View File

@@ -0,0 +1,17 @@
import json
from django.http import HttpRequest
def json_body(request: HttpRequest) -> dict:
if not request.body:
return {}
try:
return json.loads(request.body)
except json.JSONDecodeError:
return {}
def normalize_session_code(code: str) -> str:
return code.strip().upper()

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,42 @@
from django.urls import path
from fupogfakta import views as gameplay_views
from . import ui_views, views
app_name = "lobby"
urlpatterns = [
path("csrf", views.csrf_token, name="csrf_token"),
path("ui/host", ui_views.host_screen, name="host_screen"),
path("ui/host/<path:spa_path>", ui_views.host_screen, name="host_screen_deeplink"),
path("ui/player", ui_views.player_screen, name="player_screen"),
path("sessions/create", views.create_session, name="create_session"),
path("sessions/join", views.join_session, name="join_session"),
path("sessions/<str:code>", views.session_detail, name="session_detail"),
path("sessions/<str:code>/rounds/start", views.start_round, name="start_round"),
path("sessions/<str:code>/questions/show", views.show_question, name="show_question"),
path("sessions/<str:code>/rounds/start", gameplay_views.start_round, name="start_round"),
path("sessions/<str:code>/questions/show", gameplay_views.show_question, name="show_question"),
path(
"sessions/<str:code>/questions/<int:round_question_id>/lies/submit",
views.submit_lie,
gameplay_views.submit_lie,
name="submit_lie",
),
path(
"sessions/<str:code>/questions/<int:round_question_id>/answers/mix",
views.mix_answers,
gameplay_views.mix_answers,
name="mix_answers",
),
path(
"sessions/<str:code>/questions/<int:round_question_id>/guesses/submit",
views.submit_guess,
gameplay_views.submit_guess,
name="submit_guess",
),
path(
"sessions/<str:code>/questions/<int:round_question_id>/scores/calculate",
views.calculate_scores,
gameplay_views.calculate_scores,
name="calculate_scores",
),
path("sessions/<str:code>/scoreboard", views.reveal_scoreboard, name="reveal_scoreboard"),
path("sessions/<str:code>/finish", views.finish_game, name="finish_game"),
path("sessions/<str:code>/rounds/next", views.start_next_round, name="start_next_round"),
path("sessions/<str:code>/scoreboard", gameplay_views.reveal_scoreboard, name="reveal_scoreboard"),
path("sessions/<str:code>/finish", gameplay_views.finish_game, name="finish_game"),
path("sessions/<str:code>/rounds/next", gameplay_views.start_next_round, name="start_next_round"),
]

File diff suppressed because it is too large Load Diff

View File

@@ -101,6 +101,8 @@ USE_TZ = True
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View File

@@ -1,3 +1,5 @@
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.http import JsonResponse
from django.urls import include, path
@@ -9,6 +11,9 @@ def health(_request):
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("django.contrib.auth.urls")),
path("healthz", health, name="healthz"),
path("lobby/", include("lobby.urls")),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -1,3 +1 @@
from django.contrib import admin
# Register your models here.
"""Admin registrations for the realtime app."""

View File

@@ -1,9 +1,8 @@
import json
from urllib.parse import parse_qs
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from fupogfakta.models import Player
from fupogfakta.models import GameSession, Player
class GameConsumer(AsyncJsonWebsocketConsumer):
@@ -27,7 +26,12 @@ class GameConsumer(AsyncJsonWebsocketConsumer):
role = params.get("role", [None])[0]
session_token = params.get("session_token", [None])[0]
if role != "host":
if role == "host":
if not await GameSession.objects.filter(code=self.session_code).aexists():
await self.close(code=4004)
return
self.player = None
else:
if not session_token:
await self.close(code=4001)
return
@@ -40,9 +44,6 @@ class GameConsumer(AsyncJsonWebsocketConsumer):
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()

View File

@@ -1,3 +1 @@
from django.db import models
# Create your models here.
"""Database models for the realtime app."""

View File

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

View File

@@ -58,6 +58,16 @@ class GameConsumerPhaseEventTests(SimpleTestCase):
}
)
async def test_disconnect_discards_group_membership(self):
consumer = GameConsumer()
consumer.group_name = "game_AABBCC"
consumer.channel_name = "test-channel"
consumer.channel_layer = Mock(group_discard=AsyncMock())
await consumer.disconnect(1000)
consumer.channel_layer.group_discard.assert_awaited_once_with("game_AABBCC", "test-channel")
@unittest.skipIf(WebsocketCommunicator is None, "channels.testing dependencies unavailable")
class GameConsumerConnectTest(TestCase):
@@ -105,6 +115,15 @@ class GameConsumerConnectTest(TestCase):
self.assertTrue(connected)
await communicator.disconnect()
async def test_host_connect_unknown_session_rejected(self):
communicator = WebsocketCommunicator(
application,
"/ws/game/ZZZZZZ/?role=host",
)
connected, code = await communicator.connect()
self.assertFalse(connected)
self.assertEqual(code, 4004)
async def test_broadcast_reaches_connected_client(self):
token = self.player.session_token
communicator = WebsocketCommunicator(

View File

@@ -1,3 +1 @@
from django.shortcuts import render
# Create your views here.
"""HTTP views for the realtime app."""

View File

@@ -9,7 +9,6 @@ from __future__ import annotations
import json
from pathlib import Path
import sys
from typing import Any

View File

@@ -0,0 +1,41 @@
#!/bin/sh
set -eu
WAIT_RETRIES="${WAIT_RETRIES:-60}"
WAIT_INTERVAL_SECONDS="${WAIT_INTERVAL_SECONDS:-2}"
wait_for_service() {
label="$1"
host="$2"
port="$3"
python - "$label" "$host" "$port" "$WAIT_RETRIES" "$WAIT_INTERVAL_SECONDS" <<'PY'
import socket
import sys
import time
label, host, port, retries, interval = sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]), float(sys.argv[5])
for attempt in range(1, retries + 1):
try:
socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
with socket.create_connection((host, port), timeout=2):
print(f"[docker-entrypoint] {label} ready at {host}:{port}")
raise SystemExit(0)
except OSError as exc:
print(
f"[docker-entrypoint] waiting for {label} ({attempt}/{retries}) at {host}:{port}: {exc}",
file=sys.stderr,
)
time.sleep(interval)
print(f"[docker-entrypoint] {label} never became reachable at {host}:{port}", file=sys.stderr)
raise SystemExit(1)
PY
}
wait_for_service "database" "${DB_HOST:-127.0.0.1}" "${DB_PORT:-3306}"
wait_for_service "redis" "${CHANNEL_REDIS_HOST:-127.0.0.1}" "${CHANNEL_REDIS_PORT:-6379}"
python manage.py migrate --noinput
exec python manage.py runserver 0.0.0.0:8000

123
scripts/run_local_mvp_smoke.sh Executable file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_PORT="${APP_PORT:-8000}"
BASE_URL="${BASE_URL:-http://127.0.0.1:${APP_PORT}}"
USE_SPA_UI="${USE_SPA_UI:-false}"
ALLOW_SPA_CUTOVER="${ALLOW_SPA_CUTOVER:-0}"
KEEP_STACK_RUNNING="${KEEP_STACK_RUNNING:-1}"
BOOTSTRAP_MVP_ARGS="${BOOTSTRAP_MVP_ARGS:-}"
ARTIFACT_DIR="${ARTIFACT_DIR:-${ROOT_DIR}/artifacts/local}"
ARTIFACT_FILE="${ARTIFACT_FILE:-${ARTIFACT_DIR}/smoke-$(date -u +%Y%m%dT%H%M%SZ).json}"
HEALTH_RETRIES="${HEALTH_RETRIES:-60}"
HEALTH_SLEEP_SECONDS="${HEALTH_SLEEP_SECONDS:-2}"
require_command() {
local command_name="$1"
local hint="$2"
if ! command -v "${command_name}" >/dev/null 2>&1; then
echo "[local-smoke] missing command: ${command_name}" >&2
echo "[local-smoke] ${hint}" >&2
exit 1
fi
}
if [[ "${USE_SPA_UI}" == "true" ]] && [[ "${ALLOW_SPA_CUTOVER}" != "1" ]]; then
echo "[local-smoke] USE_SPA_UI=true is outside the canonical MVP smoke path" >&2
echo "[local-smoke] set ALLOW_SPA_CUTOVER=1 only for separate SPA cutover verification" >&2
exit 1
fi
case "${ARTIFACT_FILE}" in
"${ROOT_DIR}"/*) ;;
*)
echo "[local-smoke] ARTIFACT_FILE must live inside ${ROOT_DIR}" >&2
exit 1
;;
esac
require_command "docker" "install Docker Desktop or Docker Engine before running the local smoke flow"
require_command "curl" "install curl so the script can poll the local /healthz endpoint"
mkdir -p "$(dirname "${ARTIFACT_FILE}")"
COMPOSE_CMD=(docker compose)
if [[ "${USE_SPA_UI}" == "true" ]]; then
COMPOSE_CMD+=(--profile spa)
fi
print_failure_context() {
echo "[local-smoke] docker compose state after failure" >&2
(
cd "${ROOT_DIR}"
"${COMPOSE_CMD[@]}" ps >&2 || true
"${COMPOSE_CMD[@]}" logs --tail=80 app db redis >&2 || true
)
}
cleanup() {
local exit_code="$1"
if [[ "${exit_code}" -ne 0 ]]; then
print_failure_context
fi
if [[ "${KEEP_STACK_RUNNING}" != "1" ]]; then
echo "[local-smoke] shutting down docker compose stack"
(
cd "${ROOT_DIR}"
"${COMPOSE_CMD[@]}" down --remove-orphans
)
fi
}
trap 'cleanup "$?"' EXIT
wait_for_healthz() {
local attempt
for ((attempt = 1; attempt <= HEALTH_RETRIES; attempt += 1)); do
if curl -fsS "${BASE_URL}/healthz" >/dev/null; then
echo "[local-smoke] healthz OK"
return 0
fi
echo "[local-smoke] waiting for healthz (${attempt}/${HEALTH_RETRIES})"
sleep "${HEALTH_SLEEP_SECONDS}"
done
echo "[local-smoke] healthz did not become ready: ${BASE_URL}/healthz" >&2
return 1
}
run_compose_exec() {
(
cd "${ROOT_DIR}"
"${COMPOSE_CMD[@]}" exec -T app "$@"
)
}
echo "[local-smoke] starting docker compose stack"
(
cd "${ROOT_DIR}"
export USE_SPA_UI APP_PORT
"${COMPOSE_CMD[@]}" up -d --build
)
wait_for_healthz
echo "[local-smoke] bootstrap MVP host + demo questions"
if [[ -n "${BOOTSTRAP_MVP_ARGS}" ]]; then
# shellcheck disable=SC2206
bootstrap_args=(${BOOTSTRAP_MVP_ARGS})
run_compose_exec python manage.py bootstrap_mvp "${bootstrap_args[@]}"
else
run_compose_exec python manage.py bootstrap_mvp
fi
container_artifact="/app/${ARTIFACT_FILE#${ROOT_DIR}/}"
echo "[local-smoke] gameplay smoke -> ${ARTIFACT_FILE}"
run_compose_exec python manage.py smoke_staging --artifact "${container_artifact}"
echo "[local-smoke] artifact: ${ARTIFACT_FILE}"
if [[ "${KEEP_STACK_RUNNING}" == "1" ]]; then
echo "[local-smoke] stack left running for manual UI follow-up at ${BASE_URL}"
else
echo "[local-smoke] stack will be stopped on exit"
fi

View File

@@ -0,0 +1,265 @@
import { createReadStream, existsSync } from "node:fs";
import { stat } from "node:fs/promises";
import { createServer, request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import { extname, join, normalize, resolve } from "node:path";
const [, , rootArg = ".", portArg = "4200", backendArg = "http://app:8000"] = process.argv;
const rootDir = resolve(rootArg);
const port = Number.parseInt(portArg, 10);
const backendOrigin = backendArg.replace(/\/$/, "");
const backendUrl = new URL(backendOrigin);
if (!Number.isInteger(port) || port <= 0) {
console.error(`[static-server] invalid port: ${portArg}`);
process.exit(1);
}
const contentTypes = new Map([
[".css", "text/css; charset=utf-8"],
[".html", "text/html; charset=utf-8"],
[".ico", "image/x-icon"],
[".jpg", "image/jpeg"],
[".js", "text/javascript; charset=utf-8"],
[".json", "application/json; charset=utf-8"],
[".map", "application/json; charset=utf-8"],
[".png", "image/png"],
[".svg", "image/svg+xml"],
[".txt", "text/plain; charset=utf-8"],
[".woff2", "font/woff2"],
]);
const proxyPrefixes = ["/accounts", "/admin", "/healthz", "/lobby", "/media", "/static", "/ws"];
function safePathname(urlPath) {
const decoded = decodeURIComponent(urlPath.split("?")[0] || "/");
const normalized = normalize(decoded).replace(/^(\.\.(\/|\\|$))+/, "");
return normalized === "/" ? "/" : normalized;
}
function shouldProxy(urlPath) {
const pathname = safePathname(urlPath);
return proxyPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
}
async function resolveFile(urlPath) {
const pathname = safePathname(urlPath);
const candidatePaths =
pathname === "/"
? [resolve(join(rootDir, "browser", "index.html"))]
: [
resolve(join(rootDir, `.${pathname}`)),
resolve(join(rootDir, "browser", `.${pathname}`)),
];
for (const candidatePath of candidatePaths) {
if (!candidatePath.startsWith(rootDir) || !existsSync(candidatePath)) {
continue;
}
const candidateStat = await stat(candidatePath);
if (candidateStat.isDirectory()) {
const indexPath = join(candidatePath, "index.html");
if (existsSync(indexPath)) {
return indexPath;
}
continue;
}
return candidatePath;
}
if (!extname(pathname) && !shouldProxy(pathname)) {
const spaIndex = resolve(join(rootDir, "browser", "index.html"));
if (spaIndex.startsWith(rootDir) && existsSync(spaIndex)) {
return spaIndex;
}
}
return null;
}
async function readRequestBody(request) {
const chunks = [];
for await (const chunk of request) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
async function proxyRequest(request, response) {
const upstreamUrl = new URL(request.url || "/", `${backendOrigin}/`);
const body =
request.method && request.method !== "GET" && request.method !== "HEAD" ? await readRequestBody(request) : undefined;
const headers = {};
for (const [key, value] of Object.entries(request.headers)) {
if (value === undefined) {
continue;
}
const loweredKey = key.toLowerCase();
if (loweredKey === "host" || loweredKey === "connection") {
continue;
}
headers[key] = value;
}
const forwardedHost = request.headers.host || "localhost";
headers.host = forwardedHost;
headers["x-forwarded-host"] = forwardedHost;
headers["x-forwarded-proto"] = "http";
if (request.socket.remoteAddress) {
headers["x-forwarded-for"] = request.socket.remoteAddress;
}
const transport = upstreamUrl.protocol === "https:" ? httpsRequest : httpRequest;
await new Promise((resolvePromise, rejectPromise) => {
const upstream = transport(
{
protocol: upstreamUrl.protocol,
hostname: backendUrl.hostname,
port: backendUrl.port || (upstreamUrl.protocol === "https:" ? 443 : 80),
method: request.method,
path: `${upstreamUrl.pathname}${upstreamUrl.search}`,
headers,
},
(upstreamResponse) => {
response.writeHead(upstreamResponse.statusCode || 502, upstreamResponse.headers);
upstreamResponse.pipe(response);
upstreamResponse.on("end", resolvePromise);
},
);
upstream.on("error", rejectPromise);
if (body) {
upstream.end(body);
return;
}
upstream.end();
});
}
function shouldProxyUpgrade(urlPath) {
const pathname = safePathname(urlPath);
return pathname === "/ws" || pathname.startsWith("/ws/");
}
function writeUpgradeFailure(socket, message = "Bad Gateway") {
try {
socket.write(`HTTP/1.1 502 ${message}\r\nConnection: close\r\n\r\n`);
} finally {
socket.destroy();
}
}
function proxyUpgrade(request, socket, head) {
const upstreamUrl = new URL(request.url || "/", `${backendOrigin}/`);
const headers = {};
for (const [key, value] of Object.entries(request.headers)) {
if (value === undefined) {
continue;
}
headers[key] = value;
}
const forwardedHost = request.headers.host || "localhost";
headers.host = forwardedHost;
headers["x-forwarded-host"] = forwardedHost;
headers["x-forwarded-proto"] = "http";
if (request.socket.remoteAddress) {
headers["x-forwarded-for"] = request.socket.remoteAddress;
}
const transport = upstreamUrl.protocol === "https:" ? httpsRequest : httpRequest;
const upstream = transport({
protocol: upstreamUrl.protocol,
hostname: backendUrl.hostname,
port: backendUrl.port || (upstreamUrl.protocol === "https:" ? 443 : 80),
method: request.method,
path: `${upstreamUrl.pathname}${upstreamUrl.search}`,
headers,
});
upstream.on("upgrade", (upstreamResponse, upstreamSocket, upstreamHead) => {
const lines = [`HTTP/1.1 ${upstreamResponse.statusCode || 101} ${upstreamResponse.statusMessage || "Switching Protocols"}`];
for (const [key, value] of Object.entries(upstreamResponse.headers)) {
if (value === undefined) {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
lines.push(`${key}: ${item}`);
}
continue;
}
lines.push(`${key}: ${value}`);
}
lines.push("", "");
socket.write(lines.join("\r\n"));
if (head?.length) {
upstreamSocket.write(head);
}
if (upstreamHead?.length) {
socket.write(upstreamHead);
}
// After the HTTP upgrade handshake, both sockets become a raw websocket tunnel.
upstreamSocket.pipe(socket);
socket.pipe(upstreamSocket);
});
upstream.on("response", (upstreamResponse) => {
upstreamResponse.resume();
writeUpgradeFailure(socket, upstreamResponse.statusMessage || "Bad Gateway");
});
upstream.on("error", () => {
writeUpgradeFailure(socket);
});
socket.on("error", () => {
upstream.destroy();
});
upstream.end();
}
const server = createServer(async (request, response) => {
try {
if (shouldProxy(request.url || "/")) {
await proxyRequest(request, response);
return;
}
const filePath = await resolveFile(request.url || "/");
if (!filePath) {
response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
response.end("Not found\n");
return;
}
response.writeHead(200, {
"Content-Type": contentTypes.get(extname(filePath)) || "application/octet-stream",
});
createReadStream(filePath).pipe(response);
} catch (error) {
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
response.end(`Server error: ${error instanceof Error ? error.message : "unknown"}\n`);
}
});
server.on("upgrade", (request, socket, head) => {
if (!shouldProxyUpgrade(request.url || "/")) {
writeUpgradeFailure(socket, "Not Found");
return;
}
proxyUpgrade(request, socket, head);
});
server.listen(port, "0.0.0.0", () => {
console.log(`[static-server] serving ${rootDir} on 0.0.0.0:${port} with backend proxy ${backendOrigin}`);
});

54
scripts/verify_mvp_release.sh Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHON_BIN="${PYTHON_BIN:-${ROOT_DIR}/.venv/bin/python}"
RUFF_BIN="${RUFF_BIN:-${ROOT_DIR}/.venv/bin/ruff}"
NPM_BIN="${NPM_BIN:-npm}"
require_command() {
local command_name="$1"
local hint="$2"
if ! command -v "${command_name}" >/dev/null 2>&1; then
echo "[verify] missing command: ${command_name}" >&2
echo "[verify] ${hint}" >&2
exit 1
fi
}
if [[ ! -x "${PYTHON_BIN}" ]]; then
echo "[verify] missing python interpreter: ${PYTHON_BIN}" >&2
echo "[verify] create the virtualenv first, for example: python3 -m venv .venv && .venv/bin/pip install -r requirements.txt" >&2
exit 1
fi
if [[ ! -x "${RUFF_BIN}" ]]; then
echo "[verify] missing ruff binary: ${RUFF_BIN}" >&2
echo "[verify] install repo tooling into .venv before running this gate" >&2
exit 1
fi
require_command "${NPM_BIN}" "install Node.js and npm before running frontend release checks"
require_command "docker" "install Docker if you want the compose config sanity check to run"
run_step() {
local label="$1"
shift
echo "[verify] ${label}"
(
cd "${ROOT_DIR}"
"$@"
)
}
run_step "ruff" "${RUFF_BIN}" check .
run_step "i18n drift" "${PYTHON_BIN}" scripts/check_i18n_drift.py
run_step "django check" "${PYTHON_BIN}" manage.py check
run_step "django tests" "${PYTHON_BIN}" manage.py test lobby fupogfakta --verbosity=1
run_step "shared frontend tests" "${NPM_BIN}" --prefix frontend test
run_step "shared frontend build" "${NPM_BIN}" --prefix frontend run build
run_step "angular tests" "${NPM_BIN}" --prefix frontend/angular test
run_step "angular build" "${NPM_BIN}" --prefix frontend/angular run build
run_step "docker compose config" bash -lc "cd \"${ROOT_DIR}\" && docker compose config >/dev/null"
echo "[verify] MVP release gate passed"

View File

@@ -4,7 +4,7 @@
"naming_version_rule": "Keep a stable artifact_name and append only explicit schema-major suffixes to the filename/version (v1, v2, ...). Update artifact_version only when the report shape changes; refresh content in-place for catalog/keyspace changes.",
"source_of_truth": {
"catalog": "shared/i18n/lobby.json",
"catalog_sha256": "e3ed39f2fa25622c01b450bd14fd4da5fc7f96c0d9635bb819f73cae14203beb",
"catalog_sha256": "d9f7227bddd007f2c56f33dfd0015bcffb3b60c52dc756126a02b7e4de638adb",
"source_paths": [
"lobby/views.py",
"frontend/src/spa/vertical-slice.ts",
@@ -24,28 +24,7 @@
},
"parity": {
"status": "pass",
"django_backend_error_codes_used_by_mvp": [
"category_has_no_questions",
"category_not_found",
"category_slug_required",
"host_only_mix_answers",
"host_only_show_question",
"host_only_start_round",
"mix_answers_invalid_phase",
"nickname_invalid",
"nickname_taken",
"no_available_questions",
"not_enough_answers_to_mix",
"question_already_shown",
"round_already_configured",
"round_config_missing",
"round_question_not_found",
"round_start_invalid_phase",
"session_code_required",
"session_not_found",
"session_not_joinable",
"show_question_invalid_phase"
],
"django_backend_error_codes_used_by_mvp": [],
"angular_frontend_error_fallback_keys_used_by_mvp": [
"join_failed",
"session_code_required",
@@ -158,36 +137,8 @@
"player.submit_lie",
"player.title"
],
"backend_codes_mapped_to_frontend_error_keys": {
"category_has_no_questions": "start_round_failed",
"category_not_found": "start_round_failed",
"category_slug_required": "start_round_failed",
"host_only_mix_answers": "start_round_failed",
"host_only_show_question": "start_round_failed",
"host_only_start_round": "start_round_failed",
"mix_answers_invalid_phase": "start_round_failed",
"nickname_invalid": "nickname_invalid",
"nickname_taken": "nickname_taken",
"no_available_questions": "start_round_failed",
"not_enough_answers_to_mix": "start_round_failed",
"question_already_shown": "start_round_failed",
"round_already_configured": "start_round_failed",
"round_config_missing": "start_round_failed",
"round_question_not_found": "start_round_failed",
"round_start_invalid_phase": "start_round_failed",
"session_code_required": "session_code_required",
"session_not_found": "session_not_found",
"session_not_joinable": "join_failed",
"show_question_invalid_phase": "start_round_failed"
},
"unique_frontend_error_keys_reached_from_django": [
"join_failed",
"nickname_invalid",
"nickname_taken",
"session_code_required",
"session_not_found",
"start_round_failed"
],
"backend_codes_mapped_to_frontend_error_keys": {},
"unique_frontend_error_keys_reached_from_django": [],
"blocking_issues": {
"missing_backend_codes": [],
"missing_backend_translations": [],
@@ -201,11 +152,6 @@
"priority": "need-to-have",
"item": "Either add missing backend/error_codes + backend/errors entries for dead contract aliases or remove them from contract.backend_to_frontend_error_keys.",
"evidence": "host_only_action"
},
{
"priority": "nice-to-have",
"item": "Decide whether grouped backend codes should keep collapsing into one Angular fallback key or be split into more specific frontend error copy as UX matures.",
"evidence": "start_round_failed <= category_has_no_questions, category_not_found, category_slug_required, host_only_mix_answers, host_only_show_question, host_only_start_round, mix_answers_invalid_phase, no_available_questions, not_enough_answers_to_mix, question_already_shown, round_already_configured, round_config_missing, round_question_not_found, round_start_invalid_phase, show_question_invalid_phase"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Host Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
color-scheme: light;
font-family: "Segoe UI", sans-serif;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.18), transparent 28rem),
linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%);
color: #0f172a;
}
main {
width: min(100%, 28rem);
padding: 1.5rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.12);
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.9rem;
}
p {
margin: 0 0 1rem;
color: #334155;
}
form {
display: grid;
gap: 0.9rem;
}
label {
display: grid;
gap: 0.35rem;
font-weight: 600;
}
input {
padding: 0.8rem 0.9rem;
border: 1px solid #cbd5e1;
border-radius: 0.8rem;
font: inherit;
}
button {
border: 0;
border-radius: 999px;
padding: 0.8rem 1rem;
background: #0f766e;
color: #fff;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.errors {
padding: 0.75rem 0.9rem;
border-radius: 0.8rem;
background: #fef2f2;
color: #b91c1c;
}
.hint {
margin-top: 1rem;
font-size: 0.95rem;
color: #475569;
}
code {
font-family: "SFMono-Regular", Consolas, monospace;
}
</style>
</head>
<body>
<main>
<h1>Host login</h1>
<p>Use the demo host account for local MVP testing, then return to the SPA home page to create a session.</p>
{% if form.errors %}
<div class="errors">The username or password did not match.</div>
{% endif %}
<form method="post">
{% csrf_token %}
<label for="id_username">
Username
<input id="id_username" name="username" type="text" autocomplete="username" required autofocus>
</label>
<label for="id_password">
Password
<input id="id_password" name="password" type="password" autocomplete="current-password" required>
</label>
{% if next %}
<input type="hidden" name="next" value="{{ next }}">
{% endif %}
<button type="submit">Log in</button>
</form>
<p class="hint">Local demo account: <code>demo-host</code> / <code>demo-pass</code></p>
</main>
</body>
</html>

View File

@@ -1,3 +1,25 @@
from django.contrib import admin
# Register your models here.
from .models import PhaseVoiceLine, QuestionVoiceLine
@admin.register(PhaseVoiceLine)
class PhaseVoiceLineAdmin(admin.ModelAdmin):
list_display = ("game_key", "cue_key", "locale", "has_audio", "is_active")
list_filter = ("game_key", "cue_key", "locale", "is_active")
search_fields = ("text",)
@admin.display(boolean=True, description="Audio")
def has_audio(self, obj: PhaseVoiceLine) -> bool:
return bool(obj.audio_file)
@admin.register(QuestionVoiceLine)
class QuestionVoiceLineAdmin(admin.ModelAdmin):
list_display = ("question", "cue_key", "locale", "has_audio", "is_active")
list_filter = ("cue_key", "locale", "is_active", "question__category")
search_fields = ("question__prompt", "text")
@admin.display(boolean=True, description="Audio")
def has_audio(self, obj: QuestionVoiceLine) -> bool:
return bool(obj.audio_file)

View File

@@ -0,0 +1,48 @@
# Generated by Django 6.0.2 on 2026-03-18 13:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('fupogfakta', '0008_questionlie'),
]
operations = [
migrations.CreateModel(
name='PhaseVoiceLine',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('game_key', models.CharField(default='fupogfakta', max_length=64)),
('cue_key', models.CharField(choices=[('intro', 'Intro'), ('lobby', 'Lobby'), ('lie', 'Lie'), ('guess', 'Guess'), ('reveal', 'Reveal'), ('scoreboard', 'Scoreboard'), ('finished', 'Finished')], max_length=32)),
('locale', models.CharField(default='en', max_length=12)),
('text', models.TextField()),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'ordering': ['game_key', 'cue_key', 'locale'],
'unique_together': {('game_key', 'cue_key', 'locale')},
},
),
migrations.CreateModel(
name='QuestionVoiceLine',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('cue_key', models.CharField(choices=[('question_prompt', 'Question prompt'), ('question_reveal', 'Question reveal')], max_length=32)),
('locale', models.CharField(default='en', max_length=12)),
('text', models.TextField()),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='voice_lines', to='fupogfakta.question')),
],
options={
'ordering': ['question_id', 'cue_key', 'locale'],
'unique_together': {('question', 'cue_key', 'locale')},
},
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 6.0.2 on 2026-03-18 13:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('voice', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='phasevoiceline',
name='audio_file',
field=models.FileField(blank=True, null=True, upload_to='voice/phase/'),
),
migrations.AddField(
model_name='questionvoiceline',
name='audio_file',
field=models.FileField(blank=True, null=True, upload_to='voice/question/'),
),
]

View File

@@ -1,3 +1,48 @@
from django.db import models
# Create your models here.
class PhaseVoiceLine(models.Model):
class CueKey(models.TextChoices):
INTRO = "intro", "Intro"
LOBBY = "lobby", "Lobby"
LIE = "lie", "Lie"
GUESS = "guess", "Guess"
REVEAL = "reveal", "Reveal"
SCOREBOARD = "scoreboard", "Scoreboard"
FINISHED = "finished", "Finished"
game_key = models.CharField(max_length=64, default="fupogfakta")
cue_key = models.CharField(max_length=32, choices=CueKey.choices)
locale = models.CharField(max_length=12, default="en")
text = models.TextField()
audio_file = models.FileField(upload_to="voice/phase/", blank=True, null=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["game_key", "cue_key", "locale"]
unique_together = (("game_key", "cue_key", "locale"),)
def __str__(self):
return f"{self.game_key}:{self.cue_key}:{self.locale}"
class QuestionVoiceLine(models.Model):
class CueKey(models.TextChoices):
QUESTION_PROMPT = "question_prompt", "Question prompt"
QUESTION_REVEAL = "question_reveal", "Question reveal"
question = models.ForeignKey("fupogfakta.Question", on_delete=models.CASCADE, related_name="voice_lines")
cue_key = models.CharField(max_length=32, choices=CueKey.choices)
locale = models.CharField(max_length=12, default="en")
text = models.TextField()
audio_file = models.FileField(upload_to="voice/question/", blank=True, null=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["question_id", "cue_key", "locale"]
unique_together = (("question", "cue_key", "locale"),)
def __str__(self):
return f"{self.question_id}:{self.cue_key}:{self.locale}"

193
voice/services.py Normal file
View File

@@ -0,0 +1,193 @@
from __future__ import annotations
from typing import Any
from fupogfakta.models import GameSession, RoundQuestion
from lobby.i18n import i18n_locale_config
from .models import PhaseVoiceLine, QuestionVoiceLine
VOICE_GAME_KEY = "fupogfakta"
DEFAULT_PHASE_LINES: dict[str, dict[str, str]] = {
"en": {
"intro": (
"Welcome to Fup og Fakta. Invent a believable lie, spot the real answer, "
"and score points when other players believe your bluff."
),
"lobby": "Players are joining the session. Get ready to start the round.",
"lie": "The question is live. Players, write one believable lie before time runs out.",
"guess": "The answers are mixed. Pick the answer you believe is true.",
"reveal": "Time to reveal the lies, the guesses, and the correct answer.",
"scoreboard": "Here comes the scoreboard for this round.",
"finished": "The game is finished. Here is the final result.",
},
"da": {
"intro": (
"Velkommen til Fup og Fakta. Find på en troværdig løgn, gennemsku det rigtige svar, "
"og få point når andre hopper på dit bluff."
),
"lobby": "Spillerne er ved at joine sessionen. Gør klar til at starte runden.",
"lie": "Spørgsmålet er live. Spillere, skriv en troværdig løgn før tiden løber ud.",
"guess": "Svarene er blandet. Vælg det svar du tror er rigtigt.",
"reveal": "Nu afslører vi løgnene, gættene og det rigtige svar.",
"scoreboard": "Her kommer scoreboardet for denne runde.",
"finished": "Spillet er slut. Her er det endelige resultat.",
},
}
def _default_question_prompt(locale: str, prompt: str) -> str:
if locale == "da":
return f"Spørgsmålet lyder: {prompt}"
return f"The question is: {prompt}"
def _default_question_reveal(locale: str, correct_answer: str) -> str:
if locale == "da":
return f"Det rigtige svar er: {correct_answer}"
return f"The correct answer is: {correct_answer}"
def _supported_locales() -> tuple[str, tuple[str, ...]]:
return i18n_locale_config()
def _default_phase_line(cue_key: str, locale: str) -> str:
default_locale, _locales = _supported_locales()
localized_lines = DEFAULT_PHASE_LINES.get(locale) or DEFAULT_PHASE_LINES.get(default_locale) or {}
return localized_lines.get(cue_key, cue_key)
def _resolve_audio_url(audio_field: Any) -> str | None:
if not audio_field:
return None
try:
return str(audio_field.url)
except ValueError:
return None
def _resolve_phase_content(*, cue_key: str, locale: str) -> tuple[str, str | None, str]:
custom = (
PhaseVoiceLine.objects.filter(
game_key=VOICE_GAME_KEY,
cue_key=cue_key,
locale=locale,
is_active=True,
)
.first()
)
if custom:
return custom.text, _resolve_audio_url(custom.audio_file), "custom"
return _default_phase_line(cue_key, locale), None, "default"
def _resolve_question_content(
*,
cue_key: str,
locale: str,
round_question: RoundQuestion,
) -> tuple[str, str | None, str]:
custom = (
QuestionVoiceLine.objects.filter(
question=round_question.question,
cue_key=cue_key,
locale=locale,
is_active=True,
)
.first()
)
if custom:
return custom.text, _resolve_audio_url(custom.audio_file), "custom"
if cue_key == QuestionVoiceLine.CueKey.QUESTION_REVEAL:
return _default_question_reveal(locale, round_question.correct_answer), None, "default"
return _default_question_prompt(locale, round_question.question.prompt), None, "default"
def _build_cue_payload(
*,
cue_key: str,
text_by_locale: dict[str, str],
audio_urls: dict[str, str],
source: str,
) -> dict[str, Any]:
return {
"cue": cue_key,
"translations": text_by_locale,
"audio_urls": audio_urls,
"source": source,
}
def resolve_session_voice_cues(
session: GameSession,
*,
current_round_question: RoundQuestion | None,
) -> dict[str, Any]:
default_locale, supported_locales = _supported_locales()
def collect_phase(cue_key: str) -> dict[str, Any]:
translations: dict[str, str] = {}
audio_urls: dict[str, str] = {}
source = "default"
for locale in supported_locales:
text, audio_url, line_source = _resolve_phase_content(cue_key=cue_key, locale=locale)
translations[locale] = text
if audio_url:
audio_urls[locale] = audio_url
if line_source == "custom":
source = "custom"
return _build_cue_payload(
cue_key=cue_key,
text_by_locale=translations,
audio_urls=audio_urls,
source=source,
)
def collect_question(cue_key: str) -> dict[str, Any] | None:
if current_round_question is None:
return None
translations: dict[str, str] = {}
audio_urls: dict[str, str] = {}
source = "default"
for locale in supported_locales:
text, audio_url, line_source = _resolve_question_content(
cue_key=cue_key,
locale=locale,
round_question=current_round_question,
)
translations[locale] = text
if audio_url:
audio_urls[locale] = audio_url
if line_source == "custom":
source = "custom"
return _build_cue_payload(
cue_key=cue_key,
text_by_locale=translations,
audio_urls=audio_urls,
source=source,
)
phase_cue_key = session.status if session.status in {
GameSession.Status.LOBBY,
GameSession.Status.LIE,
GameSession.Status.GUESS,
GameSession.Status.REVEAL,
GameSession.Status.SCOREBOARD,
GameSession.Status.FINISHED,
} else GameSession.Status.LOBBY
return {
"default_locale": default_locale,
"intro": collect_phase(PhaseVoiceLine.CueKey.INTRO),
"phase": collect_phase(phase_cue_key),
"question_prompt": collect_question(QuestionVoiceLine.CueKey.QUESTION_PROMPT)
if session.status in {GameSession.Status.LIE, GameSession.Status.GUESS}
else None,
"question_reveal": collect_question(QuestionVoiceLine.CueKey.QUESTION_REVEAL)
if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}
else None,
}

View File

@@ -1,3 +1,90 @@
from django.test import TestCase
import tempfile
# Create your tests here.
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from fupogfakta.models import Category, GameSession, Question, RoundQuestion
from voice.models import PhaseVoiceLine, QuestionVoiceLine
from voice.services import resolve_session_voice_cues
User = get_user_model()
class VoiceCueResolutionTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="voice_host", password="secret123")
self.session = GameSession.objects.create(host=self.host, code="VOICE1", status=GameSession.Status.LIE)
self.category = Category.objects.create(name="Voice", slug="voice", is_active=True)
self.question = Question.objects.create(
category=self.category,
prompt="Which city is the capital of Denmark?",
correct_answer="Copenhagen",
is_active=True,
)
self.round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
def test_resolve_session_voice_cues_builds_default_multilocale_payload(self):
payload = resolve_session_voice_cues(self.session, current_round_question=self.round_question)
self.assertEqual(payload["default_locale"], "en")
self.assertEqual(payload["intro"]["cue"], "intro")
self.assertIn("Welcome to Fup og Fakta", payload["intro"]["translations"]["en"])
self.assertIn("Velkommen til Fup og Fakta", payload["intro"]["translations"]["da"])
self.assertEqual(payload["intro"]["audio_urls"], {})
self.assertEqual(payload["phase"]["cue"], GameSession.Status.LIE)
self.assertIn(self.question.prompt, payload["question_prompt"]["translations"]["en"])
self.assertIsNone(payload["question_reveal"])
def test_custom_phase_and_question_voice_lines_override_defaults(self):
PhaseVoiceLine.objects.create(
game_key="fupogfakta",
cue_key=PhaseVoiceLine.CueKey.INTRO,
locale="da",
text="Special dansk intro.",
)
QuestionVoiceLine.objects.create(
question=self.question,
cue_key=QuestionVoiceLine.CueKey.QUESTION_PROMPT,
locale="en",
text="Custom English prompt line.",
)
payload = resolve_session_voice_cues(self.session, current_round_question=self.round_question)
self.assertEqual(payload["intro"]["source"], "custom")
self.assertEqual(payload["intro"]["translations"]["da"], "Special dansk intro.")
self.assertEqual(payload["question_prompt"]["source"], "custom")
self.assertEqual(payload["question_prompt"]["translations"]["en"], "Custom English prompt line.")
def test_custom_audio_files_are_exposed_per_locale(self):
with tempfile.TemporaryDirectory() as media_root:
with override_settings(MEDIA_ROOT=media_root):
PhaseVoiceLine.objects.create(
game_key="fupogfakta",
cue_key=PhaseVoiceLine.CueKey.INTRO,
locale="en",
text="English intro with audio.",
audio_file=SimpleUploadedFile("intro-en.mp3", b"fake-mp3-content", content_type="audio/mpeg"),
)
QuestionVoiceLine.objects.create(
question=self.question,
cue_key=QuestionVoiceLine.CueKey.QUESTION_PROMPT,
locale="da",
text="Dansk sporgsmal med lyd.",
audio_file=SimpleUploadedFile("question-da.mp3", b"fake-mp3-content", content_type="audio/mpeg"),
)
payload = resolve_session_voice_cues(self.session, current_round_question=self.round_question)
self.assertEqual(payload["intro"]["source"], "custom")
self.assertIn("/media/voice/phase/", payload["intro"]["audio_urls"]["en"])
self.assertTrue(payload["intro"]["audio_urls"]["en"].endswith("intro-en.mp3"))
self.assertEqual(payload["question_prompt"]["source"], "custom")
self.assertIn("/media/voice/question/", payload["question_prompt"]["audio_urls"]["da"])
self.assertTrue(payload["question_prompt"]["audio_urls"]["da"].endswith("question-da.mp3"))

View File

@@ -1,3 +1 @@
from django.shortcuts import render
# Create your views here.
"""HTTP views for the voice app."""