Big visual overhaul docker compsoe file etc
Some checks failed
CI / test-and-quality (push) Failing after 4m4s
Some checks failed
CI / test-and-quality (push) Failing after 4m4s
This commit is contained in:
16
.dockerignore
Normal file
16
.dockerignore
Normal 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
|
||||
@@ -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
2
.gitignore
vendored
@@ -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
30
AGENTS.md
Normal 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 app’s `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
22
Dockerfile
Normal 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"]
|
||||
@@ -1,3 +1 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
"""Admin registrations for the core_admin app."""
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
"""Database models for the core_admin app."""
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
"""Test module placeholder for the core_admin app."""
|
||||
|
||||
@@ -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
82
docker-compose.yml
Normal 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
89
docs/DEVELOPMENT.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
69
docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md
Normal file
69
docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
193
docs/plans/2026-03-18-host-player-visual-overhaul-plan.md
Normal file
193
docs/plans/2026-03-18-host-player-visual-overhaul-plan.md
Normal 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
|
||||
137
docs/plans/2026-03-18-mvp-deployable-testable-plan.md
Normal file
137
docs/plans/2026-03-18-mvp-deployable-testable-plan.md
Normal 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.
|
||||
@@ -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ø:
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: '' },
|
||||
];
|
||||
|
||||
58
frontend/angular/src/app/developer-state.spec.ts
Normal file
58
frontend/angular/src/app/developer-state.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
68
frontend/angular/src/app/developer-state.ts
Normal file
68
frontend/angular/src/app/developer-state.ts
Normal 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;
|
||||
}
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
219
frontend/angular/src/app/features/home/home-shell.component.ts
Normal file
219
frontend/angular/src/app/features/home/home-shell.component.ts
Normal 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 } });
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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',
|
||||
|
||||
363
frontend/angular/src/app/realtime-visual-smoke.spec.ts
Normal file
363
frontend/angular/src/app/realtime-visual-smoke.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
107
frontend/angular/src/app/session-realtime.spec.ts
Normal file
107
frontend/angular/src/app/session-realtime.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
264
frontend/angular/src/app/session-realtime.ts
Normal file
264
frontend/angular/src/app/session-realtime.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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({}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
180
fupogfakta/bootstrap.py
Normal 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,
|
||||
)
|
||||
1
fupogfakta/management/__init__.py
Normal file
1
fupogfakta/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
fupogfakta/management/commands/__init__.py
Normal file
1
fupogfakta/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
41
fupogfakta/management/commands/bootstrap_mvp.py
Normal file
41
fupogfakta/management/commands/bootstrap_mvp.py
Normal 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)}",
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
312
fupogfakta/management/commands/smoke_staging.py
Normal file
312
fupogfakta/management/commands/smoke_staging.py
Normal 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}'))
|
||||
29
fupogfakta/migrations/0008_questionlie.py
Normal file
29
fupogfakta/migrations/0008_questionlie.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
27
fupogfakta/migrations/0009_question_scene_ornament.py
Normal file
27
fupogfakta/migrations/0009_question_scene_ornament.py
Normal 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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
15
infra/env/.env.dev.example
vendored
Normal 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
|
||||
3
infra/env/.env.prod.example
vendored
3
infra/env/.env.prod.example
vendored
@@ -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
|
||||
|
||||
3
infra/env/.env.staging.example
vendored
3
infra/env/.env.staging.example
vendored
@@ -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
|
||||
|
||||
3
infra/env/.env.test.example
vendored
3
infra/env/.env.test.example
vendored
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
18
infra/staging/deploy_and_smoke_staging.sh
Executable file
18
infra/staging/deploy_and_smoke_staging.sh
Executable 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
97
infra/staging/run_mvp_smoke.sh
Executable 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"
|
||||
@@ -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
17
lobby/http.py
Normal 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()
|
||||
@@ -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"]
|
||||
|
||||
514
lobby/tests.py
514
lobby/tests.py
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
771
lobby/views.py
771
lobby/views.py
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
"""Admin registrations for the realtime app."""
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
"""Database models for the realtime app."""
|
||||
|
||||
@@ -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()),
|
||||
]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
"""HTTP views for the realtime app."""
|
||||
|
||||
@@ -9,7 +9,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
|
||||
41
scripts/docker_dev_entrypoint.sh
Normal file
41
scripts/docker_dev_entrypoint.sh
Normal 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
123
scripts/run_local_mvp_smoke.sh
Executable 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
|
||||
265
scripts/serve_static_dir.mjs
Normal file
265
scripts/serve_static_dir.mjs
Normal 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
54
scripts/verify_mvp_release.sh
Executable 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
121
templates/registration/login.html
Normal file
121
templates/registration/login.html
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
48
voice/migrations/0001_initial.py
Normal file
48
voice/migrations/0001_initial.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
23
voice/migrations/0002_phasevoiceline_audio_file_and_more.py
Normal file
23
voice/migrations/0002_phasevoiceline_audio_file_and_more.py
Normal 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/'),
|
||||
),
|
||||
]
|
||||
@@ -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
193
voice/services.py
Normal 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,
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
"""HTTP views for the voice app."""
|
||||
|
||||
Reference in New Issue
Block a user