1 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
92 changed files with 11584 additions and 1686 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

@@ -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,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"

View File

@@ -1,6 +1,69 @@
from datetime import timedelta
from typing import Literal
from .models import GameSession, Player, RoundConfig, RoundQuestion
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:
@@ -13,20 +76,66 @@ def build_player_ref(player: Player | None) -> dict | None:
}
def build_round_question_payload(round_question: RoundQuestion | None) -> dict | None:
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,
"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) -> dict | None:
def build_reveal_payload(
round_question: RoundQuestion | None,
*,
session: GameSession,
viewer_role: SessionViewerRole,
) -> dict | None:
if round_question is None:
return None
@@ -55,13 +164,34 @@ def build_reveal_payload(round_question: RoundQuestion | None) -> dict | None:
return {
"round_question_id": round_question.id,
"round_number": round_question.round_number,
"prompt": round_question.question.prompt,
"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)
@@ -83,6 +213,197 @@ def build_lie_started_payload(session: GameSession, round_config: RoundConfig, r
}
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
@@ -112,10 +433,10 @@ def build_phase_view_model(session: GameSession, *, players_count: int, has_roun
},
"host": {
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
"can_show_question": False,
"can_mix_answers": False,
"can_calculate_scores": False,
"can_reveal_scoreboard": False,
"can_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,
},
@@ -139,19 +460,41 @@ def build_session_detail_gameplay_payload(
*,
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),
"reveal": build_reveal_payload(current_round_question)
"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,
"phase_view_model": build_phase_view_model(
"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,
players_count=players_count,
has_round_question=bool(current_round_question),
viewer_role=viewer_role,
phase_view_model=phase_view_model,
current_round_question=current_round_question,
),
}

View File

@@ -133,6 +133,21 @@ def prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
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")

View File

@@ -5,13 +5,15 @@ from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
from fupogfakta.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,
@@ -79,6 +81,25 @@ class FupOgFaktaExtractionSliceTests(TestCase):
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"])
@@ -359,14 +380,27 @@ class FupOgFaktaExtractionSliceTests(TestCase):
fooled_player=self.bob,
)
round_question_payload = build_round_question_payload(round_question)
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)
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"], [])
@@ -376,7 +410,58 @@ class FupOgFaktaExtractionSliceTests(TestCase):
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
@@ -400,6 +485,7 @@ class FupOgFaktaExtractionSliceTests(TestCase):
self.session,
current_round_question=round_question,
players_count=3,
viewer_role="host",
)
self.assertEqual(gameplay_payload["round_question"]["id"], round_question.id)
@@ -408,3 +494,6 @@ class FupOgFaktaExtractionSliceTests(TestCase):
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"]

View File

@@ -7,11 +7,12 @@ from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from django.urls import resolve, reverse
from django.utils import timezone
from fupogfakta import payloads as gameplay_payloads, services as gameplay_services
from fupogfakta import payloads as gameplay_payloads, services as gameplay_services, views as gameplay_views
from fupogfakta.models import (
Category,
GameSession,
@@ -19,16 +20,146 @@ from fupogfakta.models import (
LieAnswer,
Player,
Question,
QuestionLie,
RoundConfig,
RoundQuestion,
ScoreEvent,
)
from lobby import views as lobby_views
from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale
from voice.models import PhaseVoiceLine, QuestionVoiceLine
User = get_user_model()
def expected_error_message(code: str, locale: str = "en") -> str:
return resolve_error_message(key=code, locale=locale)
class LobbyCsrfTokenTests(TestCase):
def test_csrf_endpoint_returns_token_and_sets_cookie(self):
response = self.client.get(reverse("lobby:csrf_token"))
self.assertEqual(response.status_code, 200)
self.assertIn("csrf_token", response.json())
self.assertIn("csrftoken", response.cookies)
class LobbyVoiceCueTests(TestCase):
def test_session_detail_exposes_multilocale_voice_cues(self):
host = User.objects.create_user(username="voice_detail_host", password="secret123")
session = GameSession.objects.create(host=host, code="VOICE2", status=GameSession.Status.LIE)
category = Category.objects.create(name="Voice detail", slug="voice-detail", is_active=True)
question = Question.objects.create(
category=category,
prompt="Which city is the capital of Denmark?",
correct_answer="Copenhagen",
is_active=True,
)
round_question = RoundQuestion.objects.create(
session=session,
round_number=1,
question=question,
correct_answer=question.correct_answer,
)
PhaseVoiceLine.objects.create(game_key="fupogfakta", cue_key="intro", locale="en", text="Custom intro")
QuestionVoiceLine.objects.create(
question=question,
cue_key="question_prompt",
locale="da",
text="Brugerdefineret sporgsmalslinje",
)
self.client.login(username="voice_detail_host", password="secret123")
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": session.code}))
self.assertEqual(response.status_code, 200)
payload = response.json()["voice_cues"]
self.assertEqual(payload["intro"]["translations"]["en"], "Custom intro")
self.assertIn("Velkommen til Fup og Fakta", payload["intro"]["translations"]["da"])
self.assertEqual(payload["phase"]["cue"], GameSession.Status.LIE)
self.assertEqual(payload["question_prompt"]["translations"]["da"], "Brugerdefineret sporgsmalslinje")
self.assertIsNone(payload["question_reveal"])
self.assertEqual(round_question.question_id, question.id)
def test_session_detail_exposes_audio_urls_for_custom_voice_lines(self):
host = User.objects.create_user(username="voice_audio_host", password="secret123")
session = GameSession.objects.create(host=host, code="VOICE3", status=GameSession.Status.LIE)
category = Category.objects.create(name="Voice audio", slug="voice-audio", is_active=True)
question = Question.objects.create(
category=category,
prompt="Which city is the capital of Denmark?",
correct_answer="Copenhagen",
is_active=True,
)
RoundQuestion.objects.create(
session=session,
round_number=1,
question=question,
correct_answer=question.correct_answer,
)
self.client.login(username="voice_audio_host", password="secret123")
with tempfile.TemporaryDirectory() as media_root:
with override_settings(MEDIA_ROOT=media_root):
PhaseVoiceLine.objects.create(
game_key="fupogfakta",
cue_key="intro",
locale="en",
text="Custom intro with audio",
audio_file=SimpleUploadedFile("intro-en.mp3", b"fake-mp3-content", content_type="audio/mpeg"),
)
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": session.code}))
self.assertEqual(response.status_code, 200)
payload = response.json()["voice_cues"]
self.assertIn("/media/voice/phase/", payload["intro"]["audio_urls"]["en"])
self.assertTrue(payload["intro"]["audio_urls"]["en"].endswith("intro-en.mp3"))
def test_player_session_detail_hides_prompt_bearing_voice_cues(self):
host = User.objects.create_user(username="voice_safe_host", password="secret123")
session = GameSession.objects.create(host=host, code="VOICE4", status=GameSession.Status.LIE)
category = Category.objects.create(name="Voice safe", slug="voice-safe", is_active=True)
question = Question.objects.create(
category=category,
prompt="Which city is the capital of Denmark?",
correct_answer="Copenhagen",
is_active=True,
)
RoundQuestion.objects.create(
session=session,
round_number=1,
question=question,
correct_answer=question.correct_answer,
)
player = Player.objects.create(session=session, nickname="Player one")
QuestionVoiceLine.objects.create(
question=question,
cue_key="question_prompt",
locale="en",
text="Prompt cue that should stay on the host",
)
response = self.client.get(
reverse("lobby:session_detail", kwargs={"code": session.code}),
data={"session_token": player.session_token},
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["viewer_role"], "player")
self.assertIsNone(payload["voice_cues"]["question_prompt"])
class AuthRoutesTests(TestCase):
def test_login_route_renders_form(self):
response = self.client.get("/accounts/login/")
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'name="username"', html=False)
self.assertContains(response, 'name="password"', html=False)
class LobbyGameplayExtractionTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="extract_host", password="secret123")
@@ -51,21 +182,30 @@ class LobbyGameplayExtractionTests(TestCase):
is_active=True,
)
def test_lobby_views_use_extracted_gameplay_helpers(self):
def test_lobby_session_detail_uses_extracted_gameplay_helpers(self):
self.assertIs(lobby_views._get_current_round_question, gameplay_services.get_current_round_question)
self.assertIs(lobby_views._select_round_question, gameplay_services.select_round_question)
self.assertIs(lobby_views._prepare_mixed_answers, gameplay_services.prepare_mixed_answers)
self.assertIs(lobby_views._resolve_scores, gameplay_services.resolve_scores)
self.assertIs(lobby_views._promote_reveal_to_scoreboard, gameplay_services.promote_reveal_to_scoreboard)
self.assertIs(lobby_views._start_round, gameplay_services.start_round)
self.assertIs(lobby_views._show_question, gameplay_services.show_question)
self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round)
self.assertIs(lobby_views._finish_game, gameplay_services.finish_game)
self.assertIs(lobby_views._maybe_promote_reveal_to_scoreboard, gameplay_views.maybe_promote_reveal_to_scoreboard)
self.assertIs(lobby_views._build_session_detail_gameplay_payload, gameplay_payloads.build_session_detail_gameplay_payload)
self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event)
def test_public_lobby_gameplay_routes_resolve_to_cartridge_views(self):
route_map = {
'lobby:start_round': ({'code': self.session.code}, gameplay_views.start_round),
'lobby:show_question': ({'code': self.session.code}, gameplay_views.show_question),
'lobby:submit_lie': ({'code': self.session.code, 'round_question_id': 1}, gameplay_views.submit_lie),
'lobby:mix_answers': ({'code': self.session.code, 'round_question_id': 1}, gameplay_views.mix_answers),
'lobby:submit_guess': ({'code': self.session.code, 'round_question_id': 1}, gameplay_views.submit_guess),
'lobby:calculate_scores': ({'code': self.session.code, 'round_question_id': 1}, gameplay_views.calculate_scores),
'lobby:reveal_scoreboard': ({'code': self.session.code}, gameplay_views.reveal_scoreboard),
'lobby:start_next_round': ({'code': self.session.code}, gameplay_views.start_next_round),
'lobby:finish_game': ({'code': self.session.code}, gameplay_views.finish_game),
}
for route_name, (kwargs, expected_view) in route_map.items():
match = resolve(reverse(route_name, kwargs=kwargs))
self.assertIs(inspect.unwrap(match.func), inspect.unwrap(expected_view), msg=f'{route_name} did not resolve to cartridge view')
def test_start_round_view_source_stays_http_thin(self):
source = inspect.getsource(inspect.unwrap(lobby_views.start_round))
source = inspect.getsource(inspect.unwrap(gameplay_views.start_round))
self.assertIn("transition = _start_round(session, category_slug)", source)
self.assertNotIn("RoundConfig", source)
@@ -73,7 +213,7 @@ class LobbyGameplayExtractionTests(TestCase):
self.assertNotIn("build_start_round_response", source)
def test_show_question_view_source_stays_http_thin(self):
source = inspect.getsource(inspect.unwrap(lobby_views.show_question))
source = inspect.getsource(inspect.unwrap(gameplay_views.show_question))
self.assertIn("transition = _show_question(session)", source)
self.assertNotIn("RoundConfig", source)
@@ -81,7 +221,7 @@ class LobbyGameplayExtractionTests(TestCase):
self.assertNotIn("build_question_shown_response", source)
def test_start_next_round_view_source_stays_http_thin(self):
source = inspect.getsource(inspect.unwrap(lobby_views.start_next_round))
source = inspect.getsource(inspect.unwrap(gameplay_views.start_next_round))
self.assertIn("transition = _start_next_round(session)", source)
self.assertNotIn("RoundConfig", source)
@@ -90,7 +230,7 @@ class LobbyGameplayExtractionTests(TestCase):
self.assertNotIn("build_start_next_round_phase_event", source)
def test_finish_game_view_source_stays_http_thin(self):
source = inspect.getsource(inspect.unwrap(lobby_views.finish_game))
source = inspect.getsource(inspect.unwrap(gameplay_views.finish_game))
self.assertIn("transition = _finish_game(session)", source)
self.assertNotIn("RoundConfig", source)
@@ -99,7 +239,7 @@ class LobbyGameplayExtractionTests(TestCase):
self.assertNotIn("build_finish_game_phase_event", source)
def test_reveal_scoreboard_view_source_stays_http_thin(self):
source = inspect.getsource(inspect.unwrap(lobby_views.reveal_scoreboard))
source = inspect.getsource(inspect.unwrap(gameplay_views.reveal_scoreboard))
self.assertIn("transition = _promote_reveal_to_scoreboard(session)", source)
self.assertNotIn("Player.objects.filter(session=session)", source)
@@ -109,9 +249,9 @@ class LobbyGameplayExtractionTests(TestCase):
def test_issue_310_transition_views_keep_gameplay_logic_out_of_lobby(self):
transition_sources = {
"reveal_scoreboard": inspect.getsource(inspect.unwrap(lobby_views.reveal_scoreboard)),
"start_next_round": inspect.getsource(inspect.unwrap(lobby_views.start_next_round)),
"finish_game": inspect.getsource(inspect.unwrap(lobby_views.finish_game)),
"reveal_scoreboard": inspect.getsource(inspect.unwrap(gameplay_views.reveal_scoreboard)),
"start_next_round": inspect.getsource(inspect.unwrap(gameplay_views.start_next_round)),
"finish_game": inspect.getsource(inspect.unwrap(gameplay_views.finish_game)),
}
forbidden_snippets = (
@@ -155,8 +295,8 @@ class LobbyGameplayExtractionTests(TestCase):
self.assertNotIn("leaderboard =", source)
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._start_round")
@patch("fupogfakta.views.sync_broadcast_phase_event")
@patch("fupogfakta.views._start_round")
def test_start_round_view_delegates_transition_to_service(
self,
mock_start_round,
@@ -194,8 +334,8 @@ class LobbyGameplayExtractionTests(TestCase):
{"round_question_id": 123},
)
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._show_question")
@patch("fupogfakta.views.sync_broadcast_phase_event")
@patch("fupogfakta.views._show_question")
def test_show_question_view_delegates_transition_to_service(
self,
mock_show_question,
@@ -229,8 +369,8 @@ class LobbyGameplayExtractionTests(TestCase):
{"round_question_id": 456},
)
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._start_next_round")
@patch("fupogfakta.views.sync_broadcast_phase_event")
@patch("fupogfakta.views._start_next_round")
def test_start_next_round_view_delegates_transition_to_service(
self,
mock_start_next_round,
@@ -270,8 +410,8 @@ class LobbyGameplayExtractionTests(TestCase):
{"round_question_id": round_question.id},
)
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._finish_game")
@patch("fupogfakta.views.sync_broadcast_phase_event")
@patch("fupogfakta.views._finish_game")
def test_finish_game_view_delegates_transition_to_service(
self,
mock_finish_game,
@@ -299,8 +439,8 @@ class LobbyGameplayExtractionTests(TestCase):
{"winner": None, "leaderboard": []},
)
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._start_next_round")
@patch("fupogfakta.views.sync_broadcast_phase_event")
@patch("fupogfakta.views._start_next_round")
def test_start_next_round_view_skips_broadcast_on_service_replay(
self,
mock_start_next_round,
@@ -337,8 +477,8 @@ class LobbyGameplayExtractionTests(TestCase):
mock_start_next_round.assert_called_once_with(self.session)
mock_sync_broadcast_phase_event.assert_not_called()
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._finish_game")
@patch("fupogfakta.views.sync_broadcast_phase_event")
@patch("fupogfakta.views._finish_game")
def test_finish_game_view_skips_broadcast_on_service_replay(
self,
mock_finish_game,
@@ -755,7 +895,7 @@ class LieSubmissionTests(TestCase):
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error_code"], "lie_already_submitted")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Lie already submitted for this player")
self.assertEqual(response.json()["error"], expected_error_message("lie_already_submitted"))
def test_submit_lie_requires_session_token(self):
round_question = RoundQuestion.objects.create(
@@ -777,7 +917,7 @@ class LieSubmissionTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "session_token_required")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "session_token is required")
self.assertEqual(response.json()["error"], expected_error_message("session_token_required"))
def test_submit_lie_rejects_invalid_session_token(self):
round_question = RoundQuestion.objects.create(
@@ -799,7 +939,7 @@ class LieSubmissionTests(TestCase):
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error_code"], "invalid_player_session_token")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Invalid player session token")
self.assertEqual(response.json()["error"], expected_error_message("invalid_player_session_token"))
def test_submit_lie_uses_danish_locale_payload_from_accept_language(self):
round_question = RoundQuestion.objects.create(
@@ -822,7 +962,7 @@ class LieSubmissionTests(TestCase):
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error_code"], "invalid_player_session_token")
self.assertEqual(response.json()["locale"], "da")
self.assertEqual(response.json()["error"], "Ugyldigt spiller-session-token")
self.assertEqual(response.json()["error"], expected_error_message("invalid_player_session_token", locale="da"))
class MixAnswersTests(TestCase):
def setUp(self):
@@ -869,6 +1009,24 @@ class MixAnswersTests(TestCase):
self.assertEqual(self.session.status, GameSession.Status.GUESS)
self.assertEqual(self.round_question.mixed_answers, answer_texts)
def test_host_can_mix_answers_with_question_fallback_lies_when_players_skip(self):
QuestionLie.objects.create(question=self.question, text="Aarhus", sort_order=0)
QuestionLie.objects.create(question=self.question, text="Odense", sort_order=1)
self.client.login(username="host", password="secret123")
response = self.client.post(
reverse(
"lobby:mix_answers",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
)
)
self.assertEqual(response.status_code, 200)
payload = response.json()
answer_texts = [entry["text"] for entry in payload["answers"]]
self.assertEqual(set(answer_texts), {"København", "Aarhus", "Odense"})
self.assertEqual(payload["session"]["status"], GameSession.Status.GUESS)
def test_mix_answers_requires_host(self):
self.client.login(username="other", password="secret123")
@@ -985,7 +1143,7 @@ class GuessSubmissionTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "guess_submission_invalid_phase")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Guess submission is only allowed in guess phase")
self.assertEqual(response.json()["error"], expected_error_message("guess_submission_invalid_phase"))
def test_submit_guess_rejects_unknown_answer(self):
response = self.client.post(
@@ -1000,7 +1158,26 @@ class GuessSubmissionTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "selected_answer_invalid")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Selected answer is not part of this round")
self.assertEqual(response.json()["error"], expected_error_message("selected_answer_invalid"))
def test_submit_guess_accepts_question_fallback_lie_from_mixed_answers(self):
QuestionLie.objects.create(question=self.question, text="Saturn", sort_order=0)
self.round_question.mixed_answers = ["Mars", "Jupiter", "Saturn"]
self.round_question.save(update_fields=["mixed_answers"])
response = self.client.post(
reverse(
"lobby:submit_guess",
kwargs={"code": self.session.code, "round_question_id": self.round_question.id},
),
data={"player_id": self.player.id, "session_token": self.player.session_token, "selected_text": "Saturn"},
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
payload = response.json()
self.assertFalse(payload["guess"]["is_correct"])
self.assertIsNone(payload["guess"]["fooled_player_id"])
def test_submit_guess_rejects_duplicate_submission(self):
Guess.objects.create(round_question=self.round_question, player=self.player, selected_text="Mars", is_correct=True)
@@ -1017,7 +1194,7 @@ class GuessSubmissionTests(TestCase):
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error_code"], "guess_already_submitted")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Guess already submitted for this player")
self.assertEqual(response.json()["error"], expected_error_message("guess_already_submitted"))
def test_submit_guess_rejects_after_deadline(self):
self.round_question.shown_at = timezone.now() - timedelta(seconds=76)
@@ -1052,7 +1229,7 @@ class GuessSubmissionTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "session_token_required")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "session_token is required")
self.assertEqual(response.json()["error"], expected_error_message("session_token_required"))
def test_submit_guess_rejects_invalid_session_token(self):
response = self.client.post(
@@ -1067,7 +1244,7 @@ class GuessSubmissionTests(TestCase):
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error_code"], "invalid_player_session_token")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Invalid player session token")
self.assertEqual(response.json()["error"], expected_error_message("invalid_player_session_token"))
class CanonicalRoundFlowTests(TestCase):
@@ -1139,8 +1316,8 @@ class CanonicalRoundFlowTests(TestCase):
self.assertEqual([entry["nickname"] for entry in payload["scoreboard"]], ["Luna", "Nora", "Mads"])
self.assertEqual(payload["reveal"]["correct_answer"], "Shakespeare")
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._resolve_scores")
@patch("fupogfakta.views.sync_broadcast_phase_event")
@patch("fupogfakta.views._resolve_scores")
def test_session_detail_promotes_zero_score_event_reveal_to_scoreboard(self, mock_resolve_scores, mock_sync_broadcast):
self.client.login(username="host_canonical", password="secret123")
self.session.status = GameSession.Status.GUESS
@@ -1274,9 +1451,9 @@ class CanonicalRoundFlowTests(TestCase):
self.assertEqual(round_two_question.guesses.count(), 0)
self.assertEqual(round_two_question.mixed_answers, [])
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._resolve_scores")
@patch("lobby.views.GameSession.objects.get")
@patch("fupogfakta.views.sync_broadcast_phase_event")
@patch("fupogfakta.views._resolve_scores")
@patch("fupogfakta.views.GameSession.objects.get")
def test_submit_guess_skips_rescore_when_locked_session_is_already_revealing(
self,
mock_session_get,
@@ -1483,7 +1660,7 @@ class ScoreCalculationTests(TestCase):
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error_code"], "host_only_calculate_scores")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Only host can calculate scores")
self.assertEqual(response.json()["error"], expected_error_message("host_only_calculate_scores"))
def test_calculate_scores_rejects_duplicate_calculation(self):
Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True)
@@ -1506,7 +1683,7 @@ class ScoreCalculationTests(TestCase):
self.assertEqual(second.status_code, 409)
self.assertEqual(second.json()["error_code"], "scores_already_calculated")
self.assertEqual(second.json()["locale"], "en")
self.assertEqual(second.json()["error"], "Scores already calculated for this round question")
self.assertEqual(second.json()["error"], expected_error_message("scores_already_calculated"))
class RevealRoundFlowTests(TestCase):
@@ -1544,7 +1721,7 @@ class RevealRoundFlowTests(TestCase):
meta={"round_question_id": self.round_question.id},
)
@patch("lobby.views.sync_broadcast_phase_event")
@patch("fupogfakta.views.sync_broadcast_phase_event")
def test_host_can_get_reveal_scoreboard(self, mock_sync_broadcast_phase_event):
self.client.login(username="host_reveal", password="secret123")
@@ -1588,7 +1765,7 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error_code"], "host_only_view_scoreboard")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Only host can view scoreboard")
self.assertEqual(response.json()["error"], expected_error_message("host_only_view_scoreboard"))
def test_reveal_scoreboard_is_idempotent_in_scoreboard_phase(self):
self.session.status = GameSession.Status.SCOREBOARD
@@ -1602,7 +1779,7 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD)
self.assertEqual([item["nickname"] for item in payload["leaderboard"]], ["Luna", "Mads"])
@patch("lobby.views.sync_broadcast_phase_event")
@patch("fupogfakta.views.sync_broadcast_phase_event")
def test_host_can_finish_game_from_scoreboard(self, _mock_sync_broadcast_phase_event):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
@@ -1623,7 +1800,7 @@ class RevealRoundFlowTests(TestCase):
self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
@patch("lobby.views.sync_broadcast_phase_event")
@patch("fupogfakta.views.sync_broadcast_phase_event")
def test_finish_game_is_idempotent_after_transition_to_finished(self, mock_sync_broadcast_phase_event):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
@@ -1656,7 +1833,7 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error_code"], "host_only_finish_game")
self.assertEqual(response.json()["locale"], "da")
self.assertEqual(response.json()["error"], "Kun værten kan afslutte spillet")
self.assertEqual(response.json()["error"], expected_error_message("host_only_finish_game", locale="da"))
def test_finish_game_rejects_wrong_phase(self):
self.client.login(username="host_reveal", password="secret123")
@@ -1674,9 +1851,9 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "finish_game_invalid_phase")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Game can only be finished from scoreboard phase")
self.assertEqual(response.json()["error"], expected_error_message("finish_game_invalid_phase"))
@patch("lobby.views.sync_broadcast_phase_event")
@patch("fupogfakta.views.sync_broadcast_phase_event")
def test_host_can_start_next_round_from_scoreboard(self, mock_sync_broadcast_phase_event):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
@@ -1715,7 +1892,7 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[0], self.session.code)
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
@patch("lobby.views.sync_broadcast_phase_event")
@patch("fupogfakta.views.sync_broadcast_phase_event")
def test_start_next_round_bootstraps_new_round_question_instead_of_reusing_current_round(self, mock_sync_broadcast_phase_event):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
@@ -1756,7 +1933,7 @@ class RevealRoundFlowTests(TestCase):
mock_sync_broadcast_phase_event.assert_called_once()
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
@patch("lobby.views.sync_broadcast_phase_event")
@patch("fupogfakta.views.sync_broadcast_phase_event")
def test_start_next_round_is_idempotent_after_transition_to_lie(self, mock_sync_broadcast_phase_event):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
@@ -1966,12 +2143,12 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json(), {
"error": "Only host can start next round",
"error": expected_error_message("host_only_start_next_round"),
"error_code": "host_only_start_next_round",
"locale": "en",
})
@patch("lobby.views.sync_broadcast_phase_event")
@patch("fupogfakta.views.sync_broadcast_phase_event")
def test_reveal_scoreboard_allows_repeated_reads_after_promotion(self, mock_sync_broadcast_phase_event):
self.client.login(username="host_reveal", password="secret123")
@@ -2047,7 +2224,7 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error_code"], "host_only_view_scoreboard")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Only host can view scoreboard")
self.assertEqual(response.json()["error"], expected_error_message("host_only_view_scoreboard"))
class UiScreenTests(TestCase):
def setUp(self):
@@ -2337,14 +2514,51 @@ class SessionDetailRoundQuestionTests(TestCase):
question=self.question,
correct_answer=self.question.correct_answer,
)
self.client.login(username="host_detail", password="secret123")
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["viewer_role"], "host")
self.assertEqual(payload["round_question"]["id"], round_question.id)
self.assertEqual(payload["round_question"]["prompt"], self.question.prompt)
def test_player_session_detail_hides_round_prompt_during_active_play(self):
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
player = Player.objects.create(session=self.session, nickname="Player one")
response = self.client.get(
reverse("lobby:session_detail", kwargs={"code": self.session.code}),
data={"session_token": player.session_token},
)
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["viewer_role"], "player")
self.assertEqual(payload["round_question"]["id"], round_question.id)
self.assertIsNone(payload["round_question"]["prompt"])
def test_public_session_detail_hides_round_prompt_during_active_play(self):
RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
)
response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["viewer_role"], "public")
self.assertIsNone(payload["round_question"]["prompt"])
def test_session_detail_includes_canonical_reveal_payload_in_reveal_phase(self):
self.session.status = GameSession.Status.REVEAL
self.session.save(update_fields=["status"])
@@ -2495,6 +2709,9 @@ class SessionDetailPhaseViewModelTests(TestCase):
self.assertTrue(phase["host"]["can_start_round"])
self.assertEqual(phase["current_phase"], GameSession.Status.LOBBY)
self.assertFalse(phase["host"]["can_show_question"])
self.assertFalse(phase["host"]["can_mix_answers"])
self.assertFalse(phase["host"]["can_calculate_scores"])
self.assertFalse(phase["host"]["can_reveal_scoreboard"])
self.assertFalse(phase["readiness"]["question_ready"])
self.assertFalse(phase["readiness"]["scoreboard_ready"])
self.assertTrue(phase["player"]["can_join"])
@@ -2520,8 +2737,9 @@ class SessionDetailPhaseViewModelTests(TestCase):
self.session.save(update_fields=["status"])
lie_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
lie_phase = lie_payload["phase_view_model"]
self.assertFalse(lie_phase["host"]["can_show_question"])
self.assertFalse(lie_phase["host"]["can_mix_answers"])
self.assertTrue(lie_phase["host"]["can_show_question"])
self.assertTrue(lie_phase["host"]["can_mix_answers"])
self.assertFalse(lie_phase["host"]["can_calculate_scores"])
self.assertTrue(lie_phase["readiness"]["question_ready"])
self.assertTrue(lie_phase["player"]["can_submit_lie"])
self.assertFalse(lie_phase["player"]["can_submit_guess"])
@@ -2530,8 +2748,9 @@ class SessionDetailPhaseViewModelTests(TestCase):
self.session.save(update_fields=["status"])
guess_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
guess_phase = guess_payload["phase_view_model"]
self.assertFalse(guess_phase["host"]["can_mix_answers"])
self.assertFalse(guess_phase["host"]["can_calculate_scores"])
self.assertFalse(guess_phase["host"]["can_show_question"])
self.assertTrue(guess_phase["host"]["can_mix_answers"])
self.assertTrue(guess_phase["host"]["can_calculate_scores"])
self.assertFalse(guess_phase["readiness"]["scoreboard_ready"])
self.assertFalse(guess_phase["player"]["can_submit_lie"])
self.assertTrue(guess_phase["player"]["can_submit_guess"])
@@ -2541,7 +2760,7 @@ class SessionDetailPhaseViewModelTests(TestCase):
self.session.save(update_fields=["status"])
reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
reveal_phase = reveal_payload["phase_view_model"]
self.assertFalse(reveal_phase["host"]["can_reveal_scoreboard"])
self.assertTrue(reveal_phase["host"]["can_reveal_scoreboard"])
self.assertTrue(reveal_phase["readiness"]["scoreboard_ready"])
self.assertFalse(reveal_phase["host"]["can_start_next_round"])
self.assertFalse(reveal_phase["host"]["can_finish_game"])
@@ -2564,6 +2783,103 @@ class SessionDetailPhaseViewModelTests(TestCase):
self.assertFalse(finished_phase["player"]["can_join"])
self.assertTrue(finished_phase["player"]["can_view_final_result"])
def test_session_detail_includes_role_aware_phase_display_contract(self):
Player.objects.create(session=self.session, nickname="P1")
Player.objects.create(session=self.session, nickname="P2")
player = Player.objects.create(session=self.session, nickname="P3")
public_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(public_payload["phase_display"]["theme"], "player-boarding")
self.assertEqual(public_payload["phase_display"]["ornament"], "boarding-pass")
self.assertEqual(public_payload["phase_display"]["title_key"], "player.player_scene_title_join")
self.assertEqual(public_payload["phase_display"]["cue_label_key"], "player.player_scene_cue_join_label")
self.client.force_login(self.host)
host_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(host_payload["phase_display"]["theme"], "host-atrium")
self.assertEqual(host_payload["phase_display"]["ornament"], "atrium-banner")
self.assertEqual(host_payload["phase_display"]["title_key"], "host.presenter_scene_title_lobby")
self.assertEqual(host_payload["phase_display"]["cue_label_key"], "host.presenter_scene_cue_start_label")
self.client.logout()
player_payload = self.client.get(
reverse("lobby:session_detail", kwargs={"code": self.session.code}),
{"session_token": player.session_token},
).json()
self.assertEqual(player_payload["phase_display"]["theme"], "player-ready")
self.assertEqual(player_payload["phase_display"]["ornament"], "ready-lantern")
self.assertEqual(player_payload["phase_display"]["title_key"], "player.player_scene_title_lobby")
self.assertEqual(player_payload["phase_display"]["cue_label_key"], "player.player_scene_cue_lobby_label")
def test_session_detail_prefers_authored_question_ornament_for_active_host_and_player_views(self):
Player.objects.create(session=self.session, nickname="P1")
Player.objects.create(session=self.session, nickname="P2")
player = Player.objects.create(session=self.session, nickname="P3")
category = Category.objects.create(name="Scene art", slug="scene-art", is_active=True)
question = Question.objects.create(
category=category,
prompt="Which harbor city is the capital of Denmark?",
correct_answer="Copenhagen",
scene_ornament=Question.SceneOrnament.HARBOR_FLARE,
is_active=True,
)
self.session.status = GameSession.Status.LIE
self.session.save(update_fields=["status"])
RoundQuestion.objects.create(
session=self.session,
round_number=1,
question=question,
correct_answer=question.correct_answer,
)
public_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(public_payload["phase_display"]["ornament"], "boarding-pass")
self.client.force_login(self.host)
host_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(host_payload["phase_display"]["ornament"], Question.SceneOrnament.HARBOR_FLARE)
self.client.logout()
player_payload = self.client.get(
reverse("lobby:session_detail", kwargs={"code": self.session.code}),
{"session_token": player.session_token},
).json()
self.assertEqual(player_payload["phase_display"]["ornament"], Question.SceneOrnament.HARBOR_FLARE)
def test_session_detail_includes_stable_player_identity_tokens(self):
Player.objects.create(session=self.session, nickname="Zoe")
Player.objects.create(session=self.session, nickname="Alice")
Player.objects.create(session=self.session, nickname="Mads")
payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(
payload["players"],
[
{
"id": payload["players"][0]["id"],
"nickname": "Alice",
"score": 0,
"is_connected": True,
"identity": {"token": "A2", "tone": "lagoon", "icon": "wave"},
},
{
"id": payload["players"][1]["id"],
"nickname": "Mads",
"score": 0,
"is_connected": True,
"identity": {"token": "M3", "tone": "gold", "icon": "comet"},
},
{
"id": payload["players"][2]["id"],
"nickname": "Zoe",
"score": 0,
"is_connected": True,
"identity": {"token": "Z1", "tone": "ember", "icon": "spark"},
},
],
)
class SmokeStagingCommandTests(TestCase):
def test_smoke_staging_command_runs_full_flow(self):
call_command("smoke_staging")
@@ -2614,6 +2930,74 @@ class SmokeStagingCommandTests(TestCase):
self.assertEqual(scoreboard_step["session_status"], GameSession.Status.SCOREBOARD)
self.assertEqual(len(scoreboard_step["leaderboard"]), 3)
@override_settings(ALLOWED_HOSTS=["localhost"])
def test_smoke_staging_uses_an_allowed_host_for_internal_clients(self):
call_command("smoke_staging")
session = GameSession.objects.latest("created_at")
self.assertEqual(session.status, GameSession.Status.FINISHED)
class BootstrapMvpCommandTests(TestCase):
def test_bootstrap_mvp_creates_demo_host_and_seed_questions(self):
call_command("bootstrap_mvp")
host = User.objects.get(username="demo-host")
category = Category.objects.get(slug="general")
capital_question = Question.objects.get(category=category, prompt="What is the capital of Denmark?")
self.assertTrue(host.is_staff)
self.assertTrue(host.check_password("demo-pass"))
self.assertTrue(category.is_active)
self.assertEqual(category.questions.filter(is_active=True).count(), 3)
self.assertEqual(capital_question.scene_ornament, Question.SceneOrnament.HARBOR_FLARE)
self.assertEqual(capital_question.fallback_lies.filter(is_active=True).count(), 5)
def test_bootstrap_mvp_is_idempotent_and_repairs_drift(self):
category = Category.objects.create(name="Old General", slug="general", is_active=False)
Question.objects.create(
category=category,
prompt="What is the capital of Denmark?",
correct_answer="Aarhus",
scene_ornament=Question.SceneOrnament.AURORA_ARC,
is_active=False,
)
call_command(
"bootstrap_mvp",
username="demo-host",
password="demo-pass",
category_name="General",
)
question = Question.objects.get(category=category, prompt="What is the capital of Denmark?")
fallback_lie = QuestionLie.objects.get(question=question, text="Aarhus")
fallback_lie.is_active = False
fallback_lie.sort_order = 99
fallback_lie.save(update_fields=["is_active", "sort_order"])
call_command(
"bootstrap_mvp",
username="demo-host",
password="demo-pass",
category_name="General",
)
host = User.objects.get(username="demo-host")
category.refresh_from_db()
question = Question.objects.get(category=category, prompt="What is the capital of Denmark?")
fallback_lie.refresh_from_db()
self.assertTrue(host.check_password("demo-pass"))
self.assertEqual(User.objects.filter(username="demo-host").count(), 1)
self.assertEqual(Category.objects.filter(slug="general").count(), 1)
self.assertEqual(category.name, "General")
self.assertEqual(question.scene_ornament, Question.SceneOrnament.HARBOR_FLARE)
self.assertTrue(category.is_active)
self.assertEqual(question.correct_answer, "Copenhagen")
self.assertTrue(question.is_active)
self.assertTrue(fallback_lie.is_active)
self.assertEqual(fallback_lie.sort_order, 0)
self.assertEqual(category.questions.count(), 3)
class I18nResolverTests(TestCase):
def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self):

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"),
]

View File

@@ -1,38 +1,21 @@
from datetime import timedelta
import json
import random
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError, transaction
from django.middleware.csrf import get_token
from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST
from fupogfakta.models import GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent
from fupogfakta.models import GameSession, Player
from fupogfakta.payloads import (
build_leaderboard as _build_leaderboard,
build_reveal_payload as _build_reveal_payload,
build_scoreboard_phase_event as _build_scoreboard_phase_event,
SessionViewerRole,
build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload,
build_session_players_payload as _build_session_players_payload,
)
from fupogfakta.services import (
finish_game as _finish_game,
get_current_round_question as _get_current_round_question,
prepare_mixed_answers as _prepare_mixed_answers,
promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard,
resolve_scores as _resolve_scores,
select_round_question as _select_round_question,
show_question as _show_question,
start_next_round as _start_next_round,
start_round as _start_round,
)
from realtime.broadcast import sync_broadcast_phase_event
from .i18n import api_error
from fupogfakta.services import get_current_round_question as _get_current_round_question
from fupogfakta.views import maybe_promote_reveal_to_scoreboard as _maybe_promote_reveal_to_scoreboard
from voice.services import resolve_session_voice_cues
_GAMEPLAY_SERVICE_OWNERSHIP_EXPORTS = (
_select_round_question,
_build_scoreboard_phase_event,
)
from .http import json_body, normalize_session_code
from .i18n import api_error
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
SESSION_CODE_LENGTH = 6
@@ -46,26 +29,9 @@ JOINABLE_STATUSES = {
}
def _json_body(request: HttpRequest) -> dict:
if not request.body:
return {}
try:
return json.loads(request.body)
except json.JSONDecodeError:
return {}
def _generate_session_code() -> str:
return "".join(random.choices(SESSION_CODE_ALPHABET, k=SESSION_CODE_LENGTH))
def _normalize_session_code(code: str) -> str:
return code.strip().upper()
def _create_unique_session_code() -> str:
for _ in range(MAX_CODE_GENERATION_ATTEMPTS):
code = _generate_session_code()
@@ -75,16 +41,9 @@ def _create_unique_session_code() -> str:
raise RuntimeError("Could not generate unique session code")
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
transition = _promote_reveal_to_scoreboard(session)
if transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
return transition.session
@require_GET
def csrf_token(request: HttpRequest) -> JsonResponse:
return JsonResponse({"csrf_token": get_token(request)})
@@ -109,9 +68,9 @@ def create_session(request: HttpRequest) -> JsonResponse:
@require_POST
def join_session(request: HttpRequest) -> JsonResponse:
payload = _json_body(request)
payload = json_body(request)
code = _normalize_session_code(str(payload.get("code", "")))
code = normalize_session_code(str(payload.get("code", "")))
nickname = str(payload.get("nickname", "")).strip()
if not code:
@@ -170,9 +129,20 @@ def join_session(request: HttpRequest) -> JsonResponse:
)
def _resolve_session_detail_viewer_role(request: HttpRequest, session: GameSession) -> SessionViewerRole:
if request.user.is_authenticated and request.user.id == session.host_id:
return "host"
session_token = str(request.GET.get("session_token", "")).strip()
if session_token and Player.objects.filter(session=session, session_token=session_token).exists():
return "player"
return "public"
@require_GET
def session_detail(request: HttpRequest, code: str) -> JsonResponse:
session_code = _normalize_session_code(code)
session_code = normalize_session_code(code)
try:
session = GameSession.objects.get(code=session_code)
@@ -183,21 +153,17 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
status=404,
)
players = list(
session.players.order_by("nickname").values(
"id",
"nickname",
"score",
"is_connected",
)
)
players = _build_session_players_payload(session)
session = _maybe_promote_reveal_to_scoreboard(session)
current_round_question = _get_current_round_question(session)
viewer_role = _resolve_session_detail_viewer_role(request, session)
gameplay_payload = _build_session_detail_gameplay_payload(
session,
current_round_question=current_round_question,
players_count=len(players),
viewer_role=viewer_role,
voice_cues=resolve_session_voice_cues(session, current_round_question=current_round_question),
)
return JsonResponse(
@@ -209,683 +175,8 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
"current_round": session.current_round,
"players_count": len(players),
},
"viewer_role": viewer_role,
"players": players,
**gameplay_payload,
}
)
@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)
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
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)
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
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": t} for t 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)
allowed_answers = {
round_question.correct_answer.strip().casefold(),
*(
text.strip().casefold()
for text in round_question.lies.values_list("text", flat=True)
if 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)
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)
if transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
session = transition.session
if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
return api_error(request, code="scoreboard_invalid_phase", status=400)
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)
if transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
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)
if transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
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)
guesses = list(round_question.guesses.select_related("player"))
if not guesses:
return api_error(request, code="no_guesses_submitted", status=400)
bluff_counts = {}
for guess in guesses:
if guess.fooled_player_id:
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
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 = []
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)
locked_session.status = GameSession.Status.REVEAL
locked_session.save(update_fields=["status"])
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
score_deltas = [
{"player_id": ev.player_id, "delta": ev.delta, "reason": ev.reason}
for ev in score_events
]
sync_broadcast_phase_event(
session.code,
"phase.scores_calculated",
{
"round_question_id": round_question.id,
"score_deltas": score_deltas,
"leaderboard": list(leaderboard),
},
)
return JsonResponse(
{
"session": {
"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),
"events_created": len(score_events),
"leaderboard": leaderboard,
}
)

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"

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."""