From a81bc1250c735cd525a32f78d993a9dfcb75e6b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Geel=20Weirs=C3=B8e?= Date: Mon, 23 Mar 2026 14:11:30 +0100 Subject: [PATCH] Big visual overhaul docker compsoe file etc --- .dockerignore | 16 + .gitea/workflows/ci.yml | 23 +- .gitignore | 2 + AGENTS.md | 30 + Dockerfile | 22 + core_admin/admin.py | 4 +- core_admin/models.py | 4 +- core_admin/tests.py | 4 +- core_admin/views.py | 4 +- docker-compose.yml | 82 + docs/DEVELOPMENT.md | 89 ++ docs/RELEASE_POLICY.md | 12 +- docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md | 69 + docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md | 25 +- docs/UI_SMOKE.md | 69 +- ...-03-18-host-player-visual-overhaul-plan.md | 193 +++ ...2026-03-18-mvp-deployable-testable-plan.md | 137 ++ docs/spa-cutover-flag.md | 2 + .../src/app/api-contract-smoke.spec.ts | 92 +- frontend/angular/src/app/app.component.css | 91 +- frontend/angular/src/app/app.component.html | 31 +- frontend/angular/src/app/app.routes.ts | 8 +- .../angular/src/app/developer-state.spec.ts | 58 + frontend/angular/src/app/developer-state.ts | 68 + .../home/home-shell.component.spec.ts | 118 ++ .../app/features/home/home-shell.component.ts | 219 +++ .../host/host-shell.component.spec.ts | 585 ++++++- .../app/features/host/host-shell.component.ts | 1330 +++++++++++++++- .../player/player-shell.component.spec.ts | 628 +++++++- .../features/player/player-shell.component.ts | 1378 +++++++++++++++-- frontend/angular/src/app/lobby-i18n.spec.ts | 2 + .../src/app/realtime-visual-smoke.spec.ts | 363 +++++ .../angular/src/app/session-realtime.spec.ts | 107 ++ frontend/angular/src/app/session-realtime.ts | 264 ++++ .../angular/src/app/wpp-api-client.spec.ts | 26 +- frontend/angular/src/app/wpp-api-client.ts | 53 +- frontend/angular/src/styles.css | 1012 +++++++++++- frontend/src/api/angular-client.ts | 29 +- frontend/src/api/client.ts | 83 +- frontend/src/api/mappers.ts | 125 +- frontend/src/api/types.ts | 47 +- frontend/src/spa/vertical-slice.ts | 10 +- frontend/tests/angular-api-client.test.ts | 96 ++ frontend/tests/vertical-slice.test.ts | 1 + fupogfakta/admin.py | 23 +- fupogfakta/bootstrap.py | 180 +++ fupogfakta/management/__init__.py | 1 + fupogfakta/management/commands/__init__.py | 1 + .../management/commands/bootstrap_mvp.py | 41 + .../management/commands/smoke_staging.py | 312 ++++ fupogfakta/migrations/0008_questionlie.py | 29 + .../0009_question_scene_ornament.py | 27 + fupogfakta/models.py | 28 + fupogfakta/payloads.py | 371 ++++- fupogfakta/services.py | 15 + fupogfakta/tests.py | 95 +- fupogfakta/views.py | 651 +++++++- infra/env/.env.dev.example | 15 + infra/env/.env.prod.example | 3 + infra/env/.env.staging.example | 3 + infra/env/.env.test.example | 3 + infra/staging/README.md | 53 +- infra/staging/deploy_and_smoke_staging.sh | 18 + infra/staging/run_mvp_smoke.sh | 97 ++ infra/staging/smoke_suite.sh | 58 +- lobby/http.py | 17 + lobby/management/commands/smoke_staging.py | 321 +--- lobby/tests.py | 514 +++++- lobby/urls.py | 21 +- lobby/views.py | 771 +-------- partyhub/settings.py | 2 + partyhub/urls.py | 5 + realtime/admin.py | 4 +- realtime/consumers.py | 13 +- realtime/models.py | 4 +- realtime/routing.py | 2 +- realtime/tests.py | 19 + realtime/views.py | 4 +- scripts/check_i18n_drift.py | 1 - scripts/docker_dev_entrypoint.sh | 41 + scripts/run_local_mvp_smoke.sh | 123 ++ scripts/serve_static_dir.mjs | 265 ++++ scripts/verify_mvp_release.sh | 54 + shared/i18n/lobby.json | 903 ++++++++--- templates/registration/login.html | 121 ++ voice/admin.py | 24 +- voice/migrations/0001_initial.py | 48 + ...0002_phasevoiceline_audio_file_and_more.py | 23 + voice/models.py | 47 +- voice/services.py | 193 +++ voice/tests.py | 91 +- voice/views.py | 4 +- 92 files changed, 11584 insertions(+), 1686 deletions(-) create mode 100644 .dockerignore create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md create mode 100644 docs/plans/2026-03-18-host-player-visual-overhaul-plan.md create mode 100644 docs/plans/2026-03-18-mvp-deployable-testable-plan.md create mode 100644 frontend/angular/src/app/developer-state.spec.ts create mode 100644 frontend/angular/src/app/developer-state.ts create mode 100644 frontend/angular/src/app/features/home/home-shell.component.spec.ts create mode 100644 frontend/angular/src/app/features/home/home-shell.component.ts create mode 100644 frontend/angular/src/app/realtime-visual-smoke.spec.ts create mode 100644 frontend/angular/src/app/session-realtime.spec.ts create mode 100644 frontend/angular/src/app/session-realtime.ts create mode 100644 fupogfakta/bootstrap.py create mode 100644 fupogfakta/management/__init__.py create mode 100644 fupogfakta/management/commands/__init__.py create mode 100644 fupogfakta/management/commands/bootstrap_mvp.py create mode 100644 fupogfakta/management/commands/smoke_staging.py create mode 100644 fupogfakta/migrations/0008_questionlie.py create mode 100644 fupogfakta/migrations/0009_question_scene_ornament.py create mode 100644 infra/env/.env.dev.example create mode 100755 infra/staging/deploy_and_smoke_staging.sh create mode 100755 infra/staging/run_mvp_smoke.sh create mode 100644 lobby/http.py create mode 100644 scripts/docker_dev_entrypoint.sh create mode 100755 scripts/run_local_mvp_smoke.sh create mode 100644 scripts/serve_static_dir.mjs create mode 100755 scripts/verify_mvp_release.sh create mode 100644 templates/registration/login.html create mode 100644 voice/migrations/0001_initial.py create mode 100644 voice/migrations/0002_phasevoiceline_audio_file_and_more.py create mode 100644 voice/services.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7353c81 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9fc93e0..bfbd970 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index a06f3ff..847e290 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2ff6207 --- /dev/null +++ b/AGENTS.md @@ -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 `/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/` 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/`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c04f3c9 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/core_admin/admin.py b/core_admin/admin.py index 8c38f3f..a338ee1 100644 --- a/core_admin/admin.py +++ b/core_admin/admin.py @@ -1,3 +1 @@ -from django.contrib import admin - -# Register your models here. +"""Admin registrations for the core_admin app.""" diff --git a/core_admin/models.py b/core_admin/models.py index 71a8362..64c5c80 100644 --- a/core_admin/models.py +++ b/core_admin/models.py @@ -1,3 +1 @@ -from django.db import models - -# Create your models here. +"""Database models for the core_admin app.""" diff --git a/core_admin/tests.py b/core_admin/tests.py index 7ce503c..fb1e14c 100644 --- a/core_admin/tests.py +++ b/core_admin/tests.py @@ -1,3 +1 @@ -from django.test import TestCase - -# Create your tests here. +"""Test module placeholder for the core_admin app.""" diff --git a/core_admin/views.py b/core_admin/views.py index 91ea44a..8e93fc8 100644 --- a/core_admin/views.py +++ b/core_admin/views.py @@ -1,3 +1 @@ -from django.shortcuts import render - -# Create your views here. +"""HTTP views for the core_admin app.""" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ffb88c0 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..6aec554 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -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. diff --git a/docs/RELEASE_POLICY.md b/docs/RELEASE_POLICY.md index 47261cb..ce113af 100644 --- a/docs/RELEASE_POLICY.md +++ b/docs/RELEASE_POLICY.md @@ -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 diff --git a/docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md b/docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md new file mode 100644 index 0000000..03a6e55 --- /dev/null +++ b/docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md @@ -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): +- Environment: +- Commit/Head SHA: +- `USE_SPA_UI`: `true` +- Locale: +- Devices: projected host + player phones/tabs + +#### Setup +- Host route: `/lobby/ui/host` +- Player route: `/lobby/ui/player` +- Session code: +- Participants joined: +- Developer-state left OFF by default before evidence capture: + +#### Checks (PASS/FAIL) +1. Presenter-only question visibility + - Host lie/presenter scene shows the active prompt: + - Player phones stay prompt-hidden until the allowed phase payload reveals it: +2. Reconnect recovery + - Disconnect one player device or throttle network during an active lie/guess input: + - Reconnect badge/card appears without clearing the local draft/selection: + - Recovered websocket push resumes before the 3s polling fallback becomes the steady-state transport: +3. Multi-device reveal + scoreboard + - At least 3 player devices reach reveal and scoreboard together: + - Host projected scene remains presenter-grade through reveal and final standings: + - Shared player identity tokens/colors/icons stay consistent between the projected host roster and player-phone developer-state snapshots: +4. Developer-state safety + - Host developer-state screenshot or recording captured separately from the default presenter screen: + - Player developer-state screenshot or recording captured separately from the default phone UI: + - 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: + - At least one lie/guess/reveal capture shows an authored question ornament slug from admin/bootstrap content instead of only the deterministic fallback set: +5. Optional host voice cue check + - Host-only voice playback still routes on the primary device when enabled: + +#### Artifact pointers +- Automated smoke command: `npm --prefix frontend/angular test -- src/app/realtime-visual-smoke.spec.ts` +- Screenshot/video refs: + - host projected scene: + - reconnect recovery: + - reveal/scoreboard multi-device: + - host/player developer-state: +- Result: +- 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. diff --git a/docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md b/docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md index 5be9b5e..c8f5bdf 100644 --- a/docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md +++ b/docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md @@ -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: - Active category/questions present: - Participants: host + players -- `USE_SPA_UI`: +- `USE_SPA_UI`: `false` - `WPP_SPA_ASSET_VERSION`: -- UI route used: - - OFF (legacy): `/lobby/ui/host` + `/lobby/ui/player` - - ON (SPA shell): `/lobby/ui/host/` + `/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: -1. Cutover route sanity - - Flag OFF serves legacy UI templates: - - Flag ON serves SPA shell on expected path(s): +1. Legacy route sanity + - Host/player legacy templates svarer korrekt: 2. Lobby -> join -> start - Mixed-case + whitespace session code accepted: 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: - final leaderboard visible: -#### Smoke-gate decision (før `USE_SPA_UI=true`) +#### MVP smoke-gate decision - Gate status: - 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: - Trigger reason (if yes): -- Rollback done (`USE_SPA_UI=false`) verified: +- Rollback done (`USE_SPA_UI=false`) verified: #### Evidence pointers -- 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: - Result: - 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. diff --git a/docs/UI_SMOKE.md b/docs/UI_SMOKE.md index d95a135..4bc8fa1 100644 --- a/docs/UI_SMOKE.md +++ b/docs/UI_SMOKE.md @@ -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/` (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/`. diff --git a/docs/plans/2026-03-18-host-player-visual-overhaul-plan.md b/docs/plans/2026-03-18-host-player-visual-overhaul-plan.md new file mode 100644 index 0000000..dc542c2 --- /dev/null +++ b/docs/plans/2026-03-18-host-player-visual-overhaul-plan.md @@ -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 diff --git a/docs/plans/2026-03-18-mvp-deployable-testable-plan.md b/docs/plans/2026-03-18-mvp-deployable-testable-plan.md new file mode 100644 index 0000000..7f198dc --- /dev/null +++ b/docs/plans/2026-03-18-mvp-deployable-testable-plan.md @@ -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 ` 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. diff --git a/docs/spa-cutover-flag.md b/docs/spa-cutover-flag.md index 80c61fd..af7a755 100644 --- a/docs/spa-cutover-flag.md +++ b/docs/spa-cutover-flag.md @@ -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ø: diff --git a/frontend/angular/src/app/api-contract-smoke.spec.ts b/frontend/angular/src/app/api-contract-smoke.spec.ts index c496c94..ba49b90 100644 --- a/frontend/angular/src/app/api-contract-smoke.spec.ts +++ b/frontend/angular/src/app/api-contract-smoke.spec.ts @@ -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 } diff --git a/frontend/angular/src/app/app.component.css b/frontend/angular/src/app/app.component.css index 1e80afb..eba7ee3 100644 --- a/frontend/angular/src/app/app.component.css +++ b/frontend/angular/src/app/app.component.css @@ -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; + } +} diff --git a/frontend/angular/src/app/app.component.html b/frontend/angular/src/app/app.component.html index b628c8b..5157e54 100644 --- a/frontend/angular/src/app/app.component.html +++ b/frontend/angular/src/app/app.component.html @@ -1,17 +1,24 @@
-

{{ copy('app.title') }}

- - +
+

{{ copy('app.home_badge') }}

+

{{ copy('app.title') }}

+

{{ copy('app.home_intro') }}

+
+
+ + +
diff --git a/frontend/angular/src/app/app.routes.ts b/frontend/angular/src/app/app.routes.ts index b28cb1c..3d8110b 100644 --- a/frontend/angular/src/app/app.routes.ts +++ b/frontend/angular/src/app/app.routes.ts @@ -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: '' }, ]; diff --git a/frontend/angular/src/app/developer-state.spec.ts b/frontend/angular/src/app/developer-state.spec.ts new file mode 100644 index 0000000..d49a2e1 --- /dev/null +++ b/frontend/angular/src/app/developer-state.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { resolveDeveloperState, toggleDeveloperState } from './developer-state'; + +type StorageLike = Pick; + +function storageMock(initial: Record = {}): StorageLike { + const data = new Map(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'); + }); +}); diff --git a/frontend/angular/src/app/developer-state.ts b/frontend/angular/src/app/developer-state.ts new file mode 100644 index 0000000..a953513 --- /dev/null +++ b/frontend/angular/src/app/developer-state.ts @@ -0,0 +1,68 @@ +type StorageLike = Pick; +type LocationLike = Pick; + +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; +} diff --git a/frontend/angular/src/app/features/home/home-shell.component.spec.ts b/frontend/angular/src/app/features/home/home-shell.component.spec.ts new file mode 100644 index 0000000..0b8a6b7 --- /dev/null +++ b/frontend/angular/src/app/features/home/home-shell.component.spec.ts @@ -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 = {}): StorageLike { + const data = new Map(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 { + 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(''); + }); +}); diff --git a/frontend/angular/src/app/features/home/home-shell.component.ts b/frontend/angular/src/app/features/home/home-shell.component.ts new file mode 100644 index 0000000..0019de0 --- /dev/null +++ b/frontend/angular/src/app/features/home/home-shell.component.ts @@ -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; +type LocationLike = Pick; + +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(factory: () => T, fallback: T): T { + try { + return factory(); + } catch { + return fallback; + } +} + +@Component({ + selector: 'app-home-shell', + standalone: true, + imports: [CommonModule, FormsModule], + template: ` +
+
+
+

{{ copy('app.home_badge') }}

+

{{ copy('app.home_title') }}

+

{{ copy('app.home_intro') }}

+
+
+ +
+
+

{{ copy('app.host_card_title') }}

+

{{ copy('app.host_card_body') }}

+

{{ copy('app.host_login_hint') }}

+
+ + +
+

{{ hostError }}

+
+ +
+

{{ copy('app.player_card_title') }}

+

{{ copy('app.player_card_body') }}

+ + +
+ +
+

{{ playerError }}

+
+
+
+ `, + 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): 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 { + 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 { + 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 } }); + } +} diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index 0d18e05..fd58af6 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -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 | null; + voiceCues?: Record | 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); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 5c2826a..604288a 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -3,82 +3,449 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; -import type { FinishGameResponse, ScoreboardResponse, SessionDetailResponse } from '../../../../../src/api/types'; +import type { FinishGameResponse, ScoreboardResponse, SessionDetailResponse, VoiceCue } from '../../../../../src/api/types'; import { deriveGameplayPhase, isHostGameplayActionAllowed } from '../../../../../src/spa/gameplay-phase-machine'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; +import { resolveDeveloperState, toggleDeveloperState } from '../../developer-state'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; +import { createSessionRealtimeClient, type SessionRealtimeStatus } from '../../session-realtime'; type SessionDetail = SessionDetailResponse; type LeaderboardEntry = FinishGameResponse['leaderboard'][number]; type LeaderboardResponse = FinishGameResponse; +type RevealPanel = NonNullable; +type PhaseDisplay = NonNullable; +type RefreshMode = 'foreground' | 'background'; +type SyncTransport = 'idle' | 'polling' | 'websocket'; +type PresenterPlayerTone = 'ember' | 'lagoon' | 'gold' | 'sage' | 'coral'; +type SessionPlayerIdentity = { + token: string; + tone: PresenterPlayerTone; + icon: string; +}; +type PresenterAnswerCard = { + badge: string; + text: string; +}; +type PresenterPlayerCard = SessionDetail['players'][number] & { + badge: string; + scoreLabel: string; + tone: PresenterPlayerTone; + icon: string; +}; +type PresenterLeaderboardCard = { + id: number; + nickname: string; + rank: number; + score: number; + scoreLabel: string; + tone: PresenterPlayerTone; + icon: string; +}; +type PresenterStatCard = { + label: string; + value: string; +}; +type VoicePlaybackSegment = { + audioUrl: string; + text: string; +}; +type SpeechSynthesisLike = { + speak: (utterance: SpeechSynthesisUtterance) => void; + cancel?: () => void; +}; +type SpeechUtteranceCtor = new (text: string) => SpeechSynthesisUtterance; +type AudioLike = { + addEventListener?: (type: 'ended' | 'error', listener: () => void, options?: { once?: boolean }) => void; + removeEventListener?: (type: 'ended' | 'error', listener: () => void) => void; + pause?: () => void; + play: () => Promise | unknown; + currentTime?: number; +}; +type AudioCtor = new (src?: string) => AudioLike; + +const PRESENTER_PLAYER_TONES: PresenterPlayerTone[] = ['ember', 'lagoon', 'gold', 'sage', 'coral']; +const PRESENTER_PLAYER_ICONS = ['spark', 'wave', 'comet', 'leaf', 'crown']; + +function isPresenterPlayerTone(value: string): value is PresenterPlayerTone { + return PRESENTER_PLAYER_TONES.includes(value as PresenterPlayerTone); +} @Component({ selector: 'app-host-shell', standalone: true, imports: [CommonModule, FormsModule], template: ` -

{{ copy('host.title') }}

+
+
+
+
+

{{ copy('host.title') }}

+

{{ heroTitle }}

+

{{ phaseSummary }}

+
+
+ + +
+
-
- - - - - - - - - - - - - -
+
+ {{ phaseToken.toUpperCase() }} + {{ copy('common.session_code') }}: {{ sessionCode || '—' }} + {{ copy('common.round') }}: {{ session?.session?.current_round ?? 0 }} + {{ copy('host.players_label') }}: {{ players.length }} + {{ copy('common.round_question_id') }}: {{ roundQuestionId }} +
+
-

{{ copy('host.audio_locale_hint') }}: {{ locale }}

-

{{ error }}

-

{{ nextRoundError }}

-

{{ finishError }}

+

{{ error }}

+

{{ nextRoundError }}

+

{{ finishError }}

+

{{ scoreboardError }}

-
-

{{ copy('common.status') }}: {{ session.session.status }} · {{ copy('common.round') }} {{ session.session.current_round }}

-

{{ copy('common.round_question_id') }}: {{ roundQuestionId || '-' }}

-

{{ copy('common.prompt') }}: {{ session.round_question.prompt }}

-
    -
  • {{ p.nickname }}: {{ p.score }}
  • -
-
-

Reveal

-

Korrekt svar: {{ session.reveal.correct_answer }}

-

Spørgsmål: {{ session.reveal.prompt }}

-
- Løgne -
    -
  • {{ lie.nickname }} løj: {{ lie.text }}
  • +
    +
    +

    {{ presenterSceneOrnament }}

    +

    {{ presenterSceneTitle }}

    +

    {{ presenterSceneHeadline }}

    +

    {{ presenterSceneBody }}

    +
    + {{ phaseToken.toUpperCase() }} + {{ copy('common.round') }}: {{ session?.session?.current_round ?? 0 }} + {{ copy('host.players_label') }}: {{ players.length }} + {{ copy('host.player_live') }}: {{ livePlayersCount }} +
    +
    + + +
    + +
    +
    +
    +

    {{ copy('host.presenter_scene_roster_title') }}

    +

    {{ copy('host.player_roster') }}

    +

    {{ copy('host.presenter_scene_roster_body') }}

    +
    +
    + +
    +
    +
    +
    + {{ player.badge }} + {{ player.icon }} +
    + + {{ player.is_connected ? copy('host.player_live') : copy('host.player_away') }} + +
    +
    +

    {{ player.nickname }}

    +

    {{ player.scoreLabel }}

    +
    +
    +
    + +

    {{ copy('host.no_players') }}

    +
    +
    + +
    +
    +
    +

    {{ copy('host.presenter_scene_answers_title') }}

    +

    {{ copy('host.presenter_scene_title_guess') }}

    +

    {{ copy('host.presenter_scene_answers_body') }}

    +
    +
    + +
    +
    + {{ answer.badge }} +

    {{ answer.text }}

    +
    +
    +
    + + +
    +
    +
    +

    {{ copy('host.presenter_scene_title_reveal') }}

    +

    {{ copy('host.reveal_title') }}

    +

    {{ copy('host.presenter_scene_body_reveal') }}

    +
    +
    + +
    +
    +

    {{ stat.label }}

    +

    {{ stat.value }}

    +
    +
    + +
    +
    +

    {{ copy('host.lies_title') }}

    +
      +
    • {{ lie.nickname }} {{ copy('host.lied_label') }}: {{ lie.text }}
    • +
    + +

    {{ copy('host.no_players') }}

    +
    +
    + +
    +

    {{ copy('host.guesses_title') }}

    +
      +
    • + {{ guess.nickname }} {{ copy('host.picked_label') }} {{ guess.selected_text }} + · {{ copy('host.correct_label') }} + · {{ copy('host.fooled_by_label') }} {{ guess.fooled_player_nickname }} + · {{ copy('host.incorrect_label') }} +
    • +
    + +

    {{ copy('host.no_players') }}

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    {{ showFinishedPresenterScene ? copy('host.presenter_scene_title_finished') : copy('host.presenter_scene_title_scoreboard') }}

    +

    {{ copy('host.final_leaderboard') }}

    +

    {{ showFinishedPresenterScene ? copy('host.presenter_scene_body_finished') : copy('host.presenter_scene_body_scoreboard') }}

    +
    +
    + +
    +
    +
    +
    + #{{ player.rank }} + {{ player.icon }} +
    + {{ player.scoreLabel }} +
    +
    +

    {{ player.nickname }}

    +
    +
    +
    +
    + +
    +
    +
    +

    {{ copy('host.presenter_control_title') }}

    +

    {{ copy('host.round_actions_title') }}

    +

    {{ copy('host.round_actions_body') }}

    +
    + + + +
    +
    + + + + + + + +
    + +

    {{ copy('host.no_host_action') }}

    +
    + +
    +
    + + +
    + +

    {{ copy('host.audio_locale_hint') }}: {{ locale }}

    +

    {{ copy('host.voice_preview') }}: {{ currentVoiceText }}

    +
    +
    + +
    +

    {{ copy('host.player_roster') }}

    +

    {{ copy('host.player_roster_body') }}

    +
    +
    +
    +
    + {{ playerIdentityToken(player.id, player.nickname, index) }} + {{ playerIcon(player.id, player.nickname, index) }} +
    +
    +
    {{ player.nickname }}
    +
    Score {{ player.score }}
    +
    +
    + + {{ player.is_connected ? copy('host.player_live') : copy('host.player_away') }} + +
    +
    + +

    {{ copy('host.no_players') }}

    +
    +
    +
    + +
    +

    {{ copy('host.reveal_title') }}

    +

    {{ copy('common.prompt') }}: {{ reveal.prompt }}

    +

    {{ copy('host.correct_answer') }}: {{ reveal.correct_answer }}

    + +
    +

    {{ copy('host.lies_title') }}

    +
      +
    • {{ lie.nickname }} {{ copy('host.lied_label') }}: {{ lie.text }}
    -
    - Gæt -
      -
    • - {{ guess.nickname }} valgte {{ guess.selected_text }} - · korrekt - · narret af {{ guess.fooled_player_nickname }} - · forkert + +
      +

      {{ copy('host.guesses_title') }}

      +
        +
      • + {{ guess.nickname }} {{ copy('host.picked_label') }} {{ guess.selected_text }} + · {{ copy('host.correct_label') }} + · {{ copy('host.fooled_by_label') }} {{ guess.fooled_player_nickname }} + · {{ copy('host.incorrect_label') }}
      -
    -
    -

    {{ copy('host.final_leaderboard') }}

    -

    {{ copy('host.winner') }}: {{ finalWinner.nickname }} ({{ finalWinner.score }} {{ copy('common.points_short') }})

    -
      -
    1. {{ entry.nickname }}: {{ entry.score }}
    2. +
    + +
    +

    {{ copy('host.final_leaderboard') }}

    +

    + {{ copy('host.winner') }}: {{ finalWinner.nickname }} ({{ finalWinner.score }} {{ copy('common.points_short') }}) +

    +
      +
    1. +
      +
      + {{ playerIdentityToken(entry.id, entry.nickname, index) }} + {{ playerIcon(entry.id, entry.nickname, index) }} +
      + {{ entry.nickname }} +
      + {{ entry.score }} {{ copy('common.points_short') }} +
    -
-
{{ finalLeaderboardPayload }}
-
+
+ +
+
+
+

{{ copy('host.developer_state_title') }}

+

{{ copy('host.developer_state_body') }}

+
+
+ +
+ + +
+ +
+ {{ copy('common.status') }}: {{ session?.session?.status ?? 'idle' }} + {{ copy('common.round_question_id') }}: {{ roundQuestionId || '—' }} + {{ copy('host.sync_transport_label') }}: {{ syncTransport }} + {{ copy('host.realtime_status_label') }}: {{ realtimeConnectionState }} + {{ copy('host.last_event_label') }}: {{ lastRealtimeEventType || '—' }} +
+ +
{{ developerSnapshot }}
+
{{ scoreboardPayload }}
+
{{ finalLeaderboardPayload }}
+
+
`, }) export class HostShellComponent implements OnInit, OnDestroy { @@ -98,10 +465,30 @@ export class HostShellComponent implements OnInit, OnDestroy { finalLeaderboard: LeaderboardEntry[] = []; finalWinner: LeaderboardEntry | null = null; session: SessionDetail | null = null; + autoVoiceEnabled = true; + developerMode = false; + syncTransport: SyncTransport = 'idle'; + realtimeConnectionState: SessionRealtimeStatus['connectionState'] = 'idle'; + lastRealtimeEventType = ''; + lastRealtimeEventAt: number | null = null; private readonly api = createApiClient(); private readonly controller = createVerticalSliceController(this.api); + private readonly realtime = createSessionRealtimeClient({ + onEvent: () => { + void this.refreshSession('background'); + }, + onStatusChange: (status) => { + this.handleRealtimeStatusChange(status); + }, + }); private unsubscribeLocale: (() => void) | null = null; + private spokenIntroSessions = new Set(); + private lastSpokenSignature = ''; + private activeAudio: AudioLike | null = null; + private playbackSequence = 0; + private backgroundRefreshInFlight = false; + private fallbackSyncTimer: ReturnType | null = null; ngOnInit(): void { this.unsubscribeLocale = subscribeToLocaleChanges((locale) => { @@ -111,6 +498,8 @@ export class HostShellComponent implements OnInit, OnDestroy { return; } + this.developerMode = resolveDeveloperState('wpp.host.developer-mode'); + const hashRoute = window.location.hash.replace(/^#\/?/, ''); const match = hashRoute.match(/^host(?:\/[^/]+)?(?:\/([^/?#]+))?/i); const codeFromRoute = match?.[1] ?? ''; @@ -127,6 +516,11 @@ export class HostShellComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + if (typeof window !== 'undefined') { + this.cancelVoicePlayback(); + } + this.clearFallbackSyncTimer(); + this.realtime.disconnect(); this.unsubscribeLocale?.(); this.unsubscribeLocale = null; } @@ -167,10 +561,421 @@ export class HostShellComponent implements OnInit, OnDestroy { return Boolean(this.session?.reveal && (this.gameplayPhase === 'reveal' || this.gameplayPhase === 'scoreboard')); } + get revealPanel(): RevealPanel | null { + return this.showRevealPanel ? this.session?.reveal ?? null : null; + } + + get currentVoiceText(): string { + return this.buildVoicePlaybackText({ includeIntro: false }); + } + + get phaseToken(): string { + return this.gameplayPhase ?? this.session?.session.status ?? 'lobby'; + } + + get isFinishedSession(): boolean { + return this.session?.session.status === 'finished'; + } + + get showLiePresenterScene(): boolean { + return this.phaseToken === 'lie' && Boolean(this.session?.round_question?.prompt); + } + + get showLobbyPresenterScene(): boolean { + return this.phaseToken === 'lobby' && Boolean(this.session); + } + + get showGuessPresenterScene(): boolean { + return this.phaseToken === 'guess' && Boolean(this.session?.round_question?.prompt); + } + + get showRevealPresenterScene(): boolean { + return this.phaseToken === 'reveal' && Boolean(this.revealPanel); + } + + get showScoreboardPresenterScene(): boolean { + return !this.isFinishedSession && this.phaseToken === 'scoreboard' && this.presenterLeaderboard.length > 0; + } + + get showFinishedPresenterScene(): boolean { + return this.isFinishedSession && this.presenterLeaderboard.length > 0; + } + + get showPresenterScene(): boolean { + return ( + this.showLobbyPresenterScene || + this.showLiePresenterScene || + this.showGuessPresenterScene || + this.showRevealPresenterScene || + this.showScoreboardPresenterScene || + this.showFinishedPresenterScene + ); + } + + get showPresenterRoster(): boolean { + return this.showLobbyPresenterScene || this.showLiePresenterScene || this.showGuessPresenterScene; + } + + get heroTitle(): string { + return this.showPresenterScene ? this.copy('host.presenter_control_title') : this.presenterPrompt; + } + + get phaseDisplay(): PhaseDisplay | null { + return this.session?.phase_display ?? null; + } + + get presenterPrompt(): string { + return this.session?.round_question?.prompt ?? this.revealPanel?.prompt ?? this.copy('host.presenter_waiting'); + } + + get presenterSceneTheme(): string { + return this.phaseDisplay?.theme ?? this.defaultPresenterSceneTheme; + } + + get presenterSceneOrnament(): string { + return this.phaseDisplay?.ornament ?? this.defaultPresenterSceneOrnament; + } + + get presenterSceneTitle(): string { + if (this.isFinishedSession) { + return this.resolvePhaseDisplayCopy('title_key', 'host.presenter_scene_title_finished'); + } + + switch (this.phaseToken) { + case 'lobby': + return this.resolvePhaseDisplayCopy('title_key', 'host.presenter_scene_title_lobby'); + case 'guess': + return this.resolvePhaseDisplayCopy('title_key', 'host.presenter_scene_title_guess'); + case 'reveal': + return this.resolvePhaseDisplayCopy('title_key', 'host.presenter_scene_title_reveal'); + case 'scoreboard': + return this.resolvePhaseDisplayCopy('title_key', 'host.presenter_scene_title_scoreboard'); + case 'lie': + default: + return this.resolvePhaseDisplayCopy('title_key', 'host.presenter_scene_title'); + } + } + + get presenterSceneHeadline(): string { + if (this.isFinishedSession) { + return this.presenterLeader?.nickname ?? this.copy('host.presenter_waiting'); + } + + switch (this.phaseToken) { + case 'lobby': + return this.session?.session.code ?? this.copy('host.presenter_waiting'); + case 'reveal': + return this.revealPanel?.correct_answer ?? this.presenterPrompt; + case 'scoreboard': + return this.presenterLeader?.nickname ?? this.copy('host.presenter_waiting'); + case 'lie': + case 'guess': + default: + return this.presenterPrompt; + } + } + + get presenterSceneBody(): string { + if (this.isFinishedSession) { + return this.resolvePhaseDisplayCopy('body_key', 'host.presenter_scene_body_finished'); + } + + switch (this.phaseToken) { + case 'lobby': + return this.resolvePhaseDisplayCopy('body_key', 'host.presenter_scene_body_lobby'); + case 'guess': + return this.resolvePhaseDisplayCopy('body_key', 'host.presenter_scene_body_guess'); + case 'reveal': + return this.resolvePhaseDisplayCopy('body_key', 'host.presenter_scene_body_reveal'); + case 'scoreboard': + return this.resolvePhaseDisplayCopy('body_key', 'host.presenter_scene_body_scoreboard'); + case 'lie': + default: + return this.resolvePhaseDisplayCopy('body_key', 'host.presenter_scene_body_lie'); + } + } + + get phaseSummary(): string { + if (this.isFinishedSession) { + return this.copy('host.phase_summary_finished'); + } + + switch (this.phaseToken) { + case 'lie': + return this.copy('host.phase_summary_lie'); + case 'guess': + return this.copy('host.phase_summary_guess'); + case 'reveal': + return this.copy('host.phase_summary_reveal'); + case 'scoreboard': + return this.copy('host.phase_summary_scoreboard'); + case 'lobby': + default: + return this.copy('host.phase_summary_lobby'); + } + } + + get players(): SessionDetail['players'] { + return this.session?.players ?? []; + } + + get livePlayersCount(): number { + return this.players.filter((player) => player.is_connected).length; + } + + get minPlayersToStart(): number { + return this.session?.phase_view_model?.constraints?.min_players_to_start ?? 2; + } + + get presenterPlayers(): PresenterPlayerCard[] { + return this.players.map((player, index) => ({ + ...player, + badge: this.playerIdentityToken(player.id, player.nickname, index), + scoreLabel: `${player.score} ${this.copy('common.points_short')}`, + tone: this.playerTone(player.id, player.nickname, index), + icon: this.playerIcon(player.id, player.nickname, index), + })); + } + + get presenterAnswerCards(): PresenterAnswerCard[] { + return (this.session?.round_question?.answers ?? []).map((answer, index) => ({ + badge: String.fromCharCode(65 + (index % 26)), + text: answer.text, + })); + } + + get presenterLobbyStats(): PresenterStatCard[] { + return [ + { label: this.copy('host.players_label'), value: String(this.players.length) }, + { label: this.copy('host.player_live'), value: String(this.livePlayersCount) }, + { label: this.copy('host.presenter_scene_stat_start_ready'), value: `${this.players.length}/${this.minPlayersToStart}` }, + ]; + } + + get presenterRevealStats(): PresenterStatCard[] { + if (!this.revealPanel) { + return []; + } + + const correctGuesses = this.revealPanel.guesses.filter((guess) => guess.is_correct).length; + const fooledPlayers = this.revealPanel.guesses.filter((guess) => !guess.is_correct && guess.fooled_player_id !== null).length; + + return [ + { label: this.copy('host.presenter_scene_stat_lies'), value: String(this.revealPanel.lies.length) }, + { label: this.copy('host.presenter_scene_stat_correct_guesses'), value: String(correctGuesses) }, + { label: this.copy('host.presenter_scene_stat_fooled_players'), value: String(fooledPlayers) }, + ]; + } + + get presenterLeaderboard(): PresenterLeaderboardCard[] { + const source = + this.isFinishedSession && this.finalLeaderboard.length + ? this.finalLeaderboard + : this.players.map((player) => ({ + id: player.id, + nickname: player.nickname, + score: player.score, + })); + + return [...source] + .sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + return left.nickname.localeCompare(right.nickname); + }) + .map((entry, index) => ({ + ...entry, + rank: index + 1, + scoreLabel: `${entry.score} ${this.copy('common.points_short')}`, + tone: this.playerTone(entry.id, entry.nickname, index), + icon: this.playerIcon(entry.id, entry.nickname, index), + })); + } + + get presenterLeader(): PresenterLeaderboardCard | null { + return this.presenterLeaderboard[0] ?? null; + } + + get presenterCueLabel(): string { + if (this.isFinishedSession) { + return this.resolvePhaseDisplayCopy('cue_label_key', 'host.presenter_scene_cue_finished_label'); + } + + switch (this.phaseToken) { + case 'lobby': + return this.resolvePhaseDisplayCopy( + 'cue_label_key', + this.canStartRound ? 'host.presenter_scene_cue_start_label' : 'host.presenter_scene_cue_wait_label', + ); + case 'lie': + if (this.canMixAnswers) { + return this.resolvePhaseDisplayCopy('cue_label_key', 'host.presenter_scene_cue_mix_label'); + } + if (this.canShowQuestion) { + return this.resolvePhaseDisplayCopy('cue_label_key', 'host.presenter_scene_cue_show_label'); + } + return this.resolvePhaseDisplayCopy('cue_label_key', 'host.presenter_scene_cue_wait_label'); + case 'guess': + return this.resolvePhaseDisplayCopy( + 'cue_label_key', + this.canCalculateScores ? 'host.presenter_scene_cue_reveal_label' : 'host.presenter_scene_cue_wait_label', + ); + case 'reveal': + return this.resolvePhaseDisplayCopy( + 'cue_label_key', + this.canLoadScoreboard ? 'host.presenter_scene_cue_scoreboard_label' : 'host.presenter_scene_cue_wait_label', + ); + case 'scoreboard': + return this.resolvePhaseDisplayCopy( + 'cue_label_key', + this.canStartNextRound || this.canFinishGame + ? 'host.presenter_scene_cue_close_label' + : 'host.presenter_scene_cue_wait_label', + ); + default: + return this.resolvePhaseDisplayCopy('cue_label_key', 'host.presenter_scene_cue_wait_label'); + } + } + + get presenterCueBody(): string { + if (this.isFinishedSession) { + return this.resolvePhaseDisplayCopy('cue_body_key', 'host.presenter_scene_cue_finished_body'); + } + + switch (this.phaseToken) { + case 'lobby': + return this.resolvePhaseDisplayCopy( + 'cue_body_key', + this.canStartRound ? 'host.presenter_scene_cue_start_body' : 'host.presenter_scene_cue_wait_body', + ); + case 'lie': + if (this.canMixAnswers) { + return this.resolvePhaseDisplayCopy('cue_body_key', 'host.presenter_scene_cue_mix_body'); + } + if (this.canShowQuestion) { + return this.resolvePhaseDisplayCopy('cue_body_key', 'host.presenter_scene_cue_show_body'); + } + return this.resolvePhaseDisplayCopy('cue_body_key', 'host.presenter_scene_cue_wait_body'); + case 'guess': + return this.resolvePhaseDisplayCopy( + 'cue_body_key', + this.canCalculateScores ? 'host.presenter_scene_cue_reveal_body' : 'host.presenter_scene_cue_wait_body', + ); + case 'reveal': + return this.resolvePhaseDisplayCopy( + 'cue_body_key', + this.canLoadScoreboard ? 'host.presenter_scene_cue_scoreboard_body' : 'host.presenter_scene_cue_wait_body', + ); + case 'scoreboard': + return this.resolvePhaseDisplayCopy( + 'cue_body_key', + this.canStartNextRound || this.canFinishGame + ? 'host.presenter_scene_cue_close_body' + : 'host.presenter_scene_cue_wait_body', + ); + default: + return this.resolvePhaseDisplayCopy('cue_body_key', 'host.presenter_scene_cue_wait_body'); + } + } + + get hasVisibleActions(): boolean { + return ( + this.canStartRound || + this.canShowQuestion || + this.canMixAnswers || + this.canCalculateScores || + this.canLoadScoreboard || + this.canStartNextRound || + this.canFinishGame + ); + } + + get developerSnapshot(): string { + return JSON.stringify( + { + phase: this.phaseToken, + session: this.session?.session ?? null, + roundQuestion: this.session?.round_question + ? { + id: this.session.round_question.id, + prompt: this.session.round_question.prompt, + answers: this.session.round_question.answers, + } + : null, + sync: { + transport: this.syncTransport, + realtimeConnectionState: this.realtimeConnectionState, + lastRealtimeEventType: this.lastRealtimeEventType || null, + lastRealtimeEventAt: this.lastRealtimeEventAt, + }, + players: this.players.map((player, index) => ({ + id: player.id, + nickname: player.nickname, + identity: this.resolvePlayerIdentity(player.id, player.nickname, index), + })), + errors: { + error: this.error, + scoreboardError: this.scoreboardError, + nextRoundError: this.nextRoundError, + finishError: this.finishError, + }, + phaseDisplay: this.phaseDisplay, + }, + null, + 2, + ); + } + copy(key: string): string { return t(key, this.locale); } + private get defaultPresenterSceneTheme(): string { + if (this.isFinishedSession) { + return 'host-finale'; + } + + switch (this.phaseToken) { + case 'lobby': + return 'host-atrium'; + case 'guess': + return 'host-signal'; + case 'reveal': + return 'host-verdict'; + case 'scoreboard': + return 'host-podium'; + case 'lie': + default: + return 'host-spotlight'; + } + } + + private get defaultPresenterSceneOrnament(): string { + if (this.isFinishedSession) { + return 'finale-burst'; + } + + switch (this.phaseToken) { + case 'lobby': + return 'atrium-banner'; + case 'guess': + return 'signal-grid'; + case 'reveal': + return 'verdict-wave'; + case 'scoreboard': + return 'podium-ribbon'; + case 'lie': + default: + return 'spotlight-beam'; + } + } + + private resolvePhaseDisplayCopy(field: keyof PhaseDisplay, fallbackKey: string): string { + const key = this.phaseDisplay?.[field]; + return typeof key === 'string' && key ? this.copy(key) : this.copy(fallbackKey); + } + private normalizeCode(value: string): string { return value.trim().toUpperCase(); } @@ -181,12 +986,168 @@ export class HostShellComponent implements OnInit, OnDestroy { } } + toggleAutoVoice(): void { + this.autoVoiceEnabled = !this.autoVoiceEnabled; + } + + toggleDeveloperMode(): void { + this.developerMode = toggleDeveloperState('wpp.host.developer-mode', this.developerMode); + } + + // Keep the default host route presentation-first while still giving each player + // a stable, recognizable token in diagnostics and leaderboard views. + playerInitial(nickname: string, index = 0): string { + const first = nickname.trim().charAt(0).toUpperCase(); + if (first) { + return first; + } + return String((index % 9) + 1); + } + + playerIdentityToken(playerId: number, nickname: string, index = 0): string { + return this.resolvePlayerIdentity(playerId, nickname, index).token; + } + + playerTone(playerId: number, nickname: string, index = 0): PresenterPlayerTone { + return this.resolvePlayerIdentity(playerId, nickname, index).tone; + } + + playerIcon(playerId: number, nickname: string, index = 0): string { + return this.resolvePlayerIdentity(playerId, nickname, index).icon; + } + + replayVoiceCue(): void { + this.speakCurrentVoiceCue({ force: true }); + } + + private clearFallbackSyncTimer(): void { + if (!this.fallbackSyncTimer) { + return; + } + + clearTimeout(this.fallbackSyncTimer); + this.fallbackSyncTimer = null; + } + + private resolvePlayerIdentity(playerId: number, nickname: string, index = 0): SessionPlayerIdentity { + const sessionIdentity = this.players.find((player) => player.id === playerId)?.identity; + const fallbackIcon = PRESENTER_PLAYER_ICONS[index % PRESENTER_PLAYER_ICONS.length]; + if (sessionIdentity?.token && sessionIdentity?.tone && isPresenterPlayerTone(sessionIdentity.tone)) { + return { + token: sessionIdentity.token, + tone: sessionIdentity.tone, + icon: typeof sessionIdentity.icon === 'string' && sessionIdentity.icon ? sessionIdentity.icon : fallbackIcon, + }; + } + + return { + token: this.playerInitial(nickname, index), + tone: PRESENTER_PLAYER_TONES[index % PRESENTER_PLAYER_TONES.length], + icon: fallbackIcon, + }; + } + + private updateSyncTransport(): void { + const hasSessionCode = Boolean(this.normalizeCode(this.session?.session.code || this.sessionCode)); + if (!hasSessionCode) { + this.syncTransport = 'idle'; + this.clearFallbackSyncTimer(); + return; + } + + this.syncTransport = this.realtimeConnectionState === 'connected' ? 'websocket' : 'polling'; + if (this.syncTransport === 'websocket') { + this.clearFallbackSyncTimer(); + return; + } + + this.scheduleFallbackSync(); + } + + private handleRealtimeStatusChange(status: SessionRealtimeStatus): void { + this.realtimeConnectionState = status.connectionState; + this.lastRealtimeEventType = status.lastEventType ?? ''; + this.lastRealtimeEventAt = status.lastEventAt; + this.updateSyncTransport(); + } + + private syncRealtimeSubscription(): void { + const code = this.normalizeCode(this.session?.session.code || this.sessionCode); + if (!code) { + this.realtime.disconnect(); + this.realtimeConnectionState = 'idle'; + this.updateSyncTransport(); + return; + } + + this.realtime.updateTarget({ + sessionCode: code, + role: { mode: 'host' }, + }); + } + + private scheduleFallbackSync(): void { + this.clearFallbackSyncTimer(); + + if (!this.sessionCode.trim() || this.syncTransport !== 'polling' || this.loading || this.backgroundRefreshInFlight) { + return; + } + + this.fallbackSyncTimer = setTimeout(() => { + this.fallbackSyncTimer = null; + if (this.loading || this.backgroundRefreshInFlight || this.syncTransport !== 'polling') { + this.scheduleFallbackSync(); + return; + } + void this.refreshSession('background'); + }, 3000); + } + + private 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 ''; + } + + private async ensureCsrfToken(): Promise { + const existing = this.readCookie('csrftoken'); + if (existing || typeof document === 'undefined' || typeof window === 'undefined') { + return existing; + } + + try { + await fetch('/lobby/csrf', { + method: 'GET', + headers: { + Accept: 'application/json', + }, + credentials: 'same-origin', + }); + } catch { + return ''; + } + + return this.readCookie('csrftoken'); + } + private async request(path: string, method: 'GET' | 'POST', payload?: unknown): Promise { + const csrfToken = method === 'POST' ? await this.ensureCsrfToken() : ''; const response = await fetch(path, { method, headers: { Accept: 'application/json', ...(payload === undefined ? {} : { 'Content-Type': 'application/json' }), + ...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}), }, ...(payload === undefined ? {} : { body: JSON.stringify(payload) }), credentials: 'same-origin', @@ -200,12 +1161,233 @@ export class HostShellComponent implements OnInit, OnDestroy { return body as T; } - async refreshSession(): Promise { - this.loading = true; - this.error = ''; - this.scoreboardError = ''; - this.nextRoundError = ''; - this.finishError = ''; + private resolveVoiceText(cue: VoiceCue | null | undefined): string { + if (!cue) { + return ''; + } + const translations = cue.translations || {}; + return translations[this.locale] ?? translations.en ?? Object.values(translations)[0] ?? ''; + } + + private resolveVoiceAudioUrl(cue: VoiceCue | null | undefined): string { + if (!cue) { + return ''; + } + const audioUrls = cue.audio_urls || {}; + return audioUrls[this.locale] ?? audioUrls.en ?? Object.values(audioUrls)[0] ?? ''; + } + + private buildVoiceSegments(options: { includeIntro: boolean }): VoicePlaybackSegment[] { + if (!this.session?.voice_cues) { + return []; + } + + const cues: Array = []; + if (options.includeIntro) { + cues.push(this.session.voice_cues.intro); + } + cues.push( + this.session.voice_cues.phase, + this.session.voice_cues.question_prompt, + this.session.voice_cues.question_reveal, + ); + + return cues + .map((cue) => ({ + audioUrl: this.resolveVoiceAudioUrl(cue), + text: this.resolveVoiceText(cue), + })) + .filter((segment) => Boolean(segment.audioUrl || segment.text)); + } + + private buildVoicePlaybackText(options: { includeIntro: boolean }): string { + return this.buildVoiceSegments(options) + .map((segment) => segment.text) + .filter(Boolean) + .join(' '); + } + + private currentVoiceSignature(includeIntro: boolean): string { + if (!this.session) { + return ''; + } + + return JSON.stringify({ + code: this.session.session.code, + phase: this.gameplayPhase ?? this.session.session.status, + roundQuestionId: this.session.round_question?.id ?? null, + locale: this.locale, + includeIntro, + segments: this.buildVoiceSegments({ includeIntro }), + }); + } + + private cancelVoicePlayback(): void { + this.playbackSequence += 1; + this.activeAudio?.pause?.(); + if (this.activeAudio) { + try { + this.activeAudio.currentTime = 0; + } catch { + // Ignore browsers that do not allow currentTime rewinds before playback starts. + } + } + this.activeAudio = null; + + const synth = (window as Window & { speechSynthesis?: SpeechSynthesisLike }).speechSynthesis; + synth?.cancel?.(); + } + + private async playAudioSegment(audioUrl: string): Promise { + const Audio = (globalThis as typeof globalThis & { Audio?: AudioCtor }).Audio; + if (typeof Audio !== 'function') { + return false; + } + + const audio = new Audio(audioUrl); + this.activeAudio = audio; + + await new Promise((resolve, reject) => { + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(); + }; + const fail = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + reject(new Error('Audio playback failed')); + }; + const cleanup = () => { + audio.removeEventListener?.('ended', finish); + audio.removeEventListener?.('error', fail); + }; + + audio.addEventListener?.('ended', finish, { once: true }); + audio.addEventListener?.('error', fail, { once: true }); + + try { + const playback = audio.play(); + if (playback && typeof (playback as Promise).then === 'function') { + void (playback as Promise).catch(() => fail()); + } else if (!audio.addEventListener) { + finish(); + } + } catch { + fail(); + } + }); + + if (this.activeAudio === audio) { + this.activeAudio = null; + } + return true; + } + + private async playSpeechSegment(text: string): Promise { + const synth = (window as Window & { speechSynthesis?: SpeechSynthesisLike }).speechSynthesis; + const Utterance = (globalThis as typeof globalThis & { SpeechSynthesisUtterance?: SpeechUtteranceCtor }) + .SpeechSynthesisUtterance; + if (!synth || typeof Utterance !== 'function' || !text) { + return false; + } + + await new Promise((resolve) => { + const utterance = new Utterance(text); + utterance.lang = this.locale === 'da' ? 'da-DK' : 'en-US'; + utterance.onend = () => resolve(); + utterance.onerror = () => resolve(); + synth.speak(utterance); + }); + + return true; + } + + private async playVoiceSegments(segments: VoicePlaybackSegment[], sequence: number): Promise { + const bufferedText: string[] = []; + const flushBufferedText = async (): Promise => { + const text = bufferedText.join(' ').trim(); + bufferedText.length = 0; + if (text) { + await this.playSpeechSegment(text); + } + }; + + for (const segment of segments) { + if (sequence !== this.playbackSequence) { + return; + } + + if (segment.audioUrl) { + await flushBufferedText(); + try { + const playedAudio = await this.playAudioSegment(segment.audioUrl); + if (playedAudio) { + continue; + } + } catch { + // Fall back to synthesized speech if the uploaded asset cannot be played. + } + } + + if (segment.text) { + bufferedText.push(segment.text); + } + } + + await flushBufferedText(); + } + + private speakCurrentVoiceCue(options: { force?: boolean } = {}): void { + if (!this.autoVoiceEnabled && !options.force) { + return; + } + if (!this.session || typeof window === 'undefined') { + return; + } + + const includeIntro = !this.spokenIntroSessions.has(this.session.session.code); + const segments = this.buildVoiceSegments({ includeIntro }); + if (!segments.length) { + return; + } + + const signature = this.currentVoiceSignature(includeIntro); + if (!options.force && signature === this.lastSpokenSignature) { + return; + } + + this.cancelVoicePlayback(); + const sequence = this.playbackSequence; + void this.playVoiceSegments(segments, sequence); + this.lastSpokenSignature = signature; + if (includeIntro) { + this.spokenIntroSessions.add(this.session.session.code); + } + } + + async refreshSession(mode: RefreshMode = 'foreground'): Promise { + const isBackground = mode === 'background'; + if (isBackground) { + if (this.backgroundRefreshInFlight) { + return; + } + this.backgroundRefreshInFlight = true; + } else { + this.loading = true; + this.error = ''; + this.scoreboardError = ''; + this.nextRoundError = ''; + this.finishError = ''; + } + try { const state = await this.controller.hydrateLobby(this.sessionCode); if (!state.session || state.errorMessage) { @@ -219,10 +1401,20 @@ export class HostShellComponent implements OnInit, OnDestroy { this.resetFinalLeaderboard(); } this.syncRouteFromSession(); + this.syncRealtimeSubscription(); + this.updateSyncTransport(); + this.speakCurrentVoiceCue(); } catch (error) { - this.error = `${this.copy('host.session_refresh_failed')}: ${(error as Error).message}`; + if (!isBackground) { + this.error = `${this.copy('host.session_refresh_failed')}: ${(error as Error).message}`; + } } finally { - this.loading = false; + if (isBackground) { + this.backgroundRefreshInFlight = false; + } else { + this.loading = false; + } + this.updateSyncTransport(); } } @@ -242,6 +1434,9 @@ export class HostShellComponent implements OnInit, OnDestroy { this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : ''; this.resetFinalLeaderboard(); this.syncRouteFromSession(); + this.syncRealtimeSubscription(); + this.updateSyncTransport(); + this.speakCurrentVoiceCue(); }); } @@ -253,7 +1448,7 @@ export class HostShellComponent implements OnInit, OnDestroy { await this.runAction(async () => { const code = this.normalizeCode(this.sessionCode); await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {}); - await this.refreshSession(); + await this.refreshSession('background'); }); } @@ -266,7 +1461,7 @@ export class HostShellComponent implements OnInit, OnDestroy { const code = this.normalizeCode(this.sessionCode); const roundQuestionId = this.roundQuestionId.trim(); await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/answers/mix`, 'POST', {}); - await this.refreshSession(); + await this.refreshSession('background'); }); } @@ -279,7 +1474,7 @@ export class HostShellComponent implements OnInit, OnDestroy { const code = this.normalizeCode(this.sessionCode); const roundQuestionId = this.roundQuestionId.trim(); await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/scores/calculate`, 'POST', {}); - await this.refreshSession(); + await this.refreshSession('background'); }); } @@ -295,7 +1490,7 @@ export class HostShellComponent implements OnInit, OnDestroy { const code = this.normalizeCode(this.sessionCode); const payload = await this.request(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET'); this.scoreboardPayload = JSON.stringify(payload, null, 2); - await this.refreshSession(); + await this.refreshSession('background'); } catch (error) { this.scoreboardError = `${this.copy('host.scoreboard_failed')}: ${(error as Error).message}`; } finally { @@ -318,7 +1513,7 @@ export class HostShellComponent implements OnInit, OnDestroy { } await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {}); this.resetFinalLeaderboard(); - await this.refreshSession(); + await this.refreshSession('background'); } catch (error) { this.nextRoundError = `${this.copy('host.next_round_failed')}: ${(error as Error).message}`; } finally { @@ -348,7 +1543,7 @@ export class HostShellComponent implements OnInit, OnDestroy { return a.nickname.localeCompare(b.nickname); }); this.finalWinner = payload.winner ?? this.finalLeaderboard[0] ?? null; - await this.refreshSession(); + await this.refreshSession('background'); } catch (error) { this.finishError = `${this.copy('host.finish_game_failed')}: ${(error as Error).message}`; } finally { @@ -391,6 +1586,7 @@ export class HostShellComponent implements OnInit, OnDestroy { this.error = (error as Error).message; } finally { this.loading = false; + this.updateSyncTransport(); } } } diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index 15c908e..d13cb86 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -5,6 +5,33 @@ import { PlayerShellComponent } from './player-shell.component'; type FetchMock = ReturnType; +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 | 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((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((resolve) => { - resolveJoin = resolve; - }) - ); + const fetchMock: FetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(200, { csrf_token: 'csrf-token-1' })) + .mockImplementationOnce( + () => + new Promise((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(); }); diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index a514f7a..ad731de 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -10,12 +10,43 @@ import { } from '../../../../../src/spa/gameplay-phase-machine'; import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; +import { resolveDeveloperState, toggleDeveloperState } from '../../developer-state'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; +import { createSessionRealtimeClient, type SessionRealtimeStatus } from '../../session-realtime'; type SessionDetail = SessionDetailResponse; +type RevealPanel = NonNullable; +type PhaseDisplay = NonNullable; +type PlayerIdentityTone = 'ember' | 'lagoon' | 'gold' | 'sage' | 'coral'; +type SessionPlayerIdentity = { + token: string; + tone: PlayerIdentityTone; + icon: string; +}; +type PlayerSceneStatCard = { + label: string; + value: string; +}; +type PlayerSyncChip = { + label: string; + value: string; +}; +type PlayerResultEntry = { + id: number; + nickname: string; + score: number; + isCurrent: boolean; + rank: number; + scoreLabel: string; + identityToken: string; + identityTone: PlayerIdentityTone; + identityIcon: string; +}; type ConnectionState = 'online' | 'reconnecting' | 'offline'; type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null; +type RefreshMode = 'foreground' | 'background'; +type SyncTransport = 'idle' | 'polling' | 'websocket'; type GuardableMediaElement = { muted?: boolean; @@ -32,6 +63,13 @@ type MediaPrototypeWithGuardState = { }; }; +const PLAYER_IDENTITY_TONES: PlayerIdentityTone[] = ['ember', 'lagoon', 'gold', 'sage', 'coral']; +const PLAYER_IDENTITY_ICONS = ['spark', 'wave', 'comet', 'leaf', 'crown']; + +function isPlayerIdentityTone(value: string): value is PlayerIdentityTone { + return PLAYER_IDENTITY_TONES.includes(value as PlayerIdentityTone); +} + function resolveLocalStorage(): Storage | undefined { if (typeof window === 'undefined') { @@ -45,94 +83,420 @@ function resolveLocalStorage(): Storage | undefined { standalone: true, imports: [CommonModule, FormsModule], template: ` -

{{ copy('player.title') }}

-

{{ copy('player.audio_policy_notice') }}

+
+
+
+
+

{{ copy('player.title') }}

+

{{ playerHeadline }}

+

{{ phaseSummary }}

+
+
+ + +
+
-
- - - - -
+
+ {{ phaseToken.toUpperCase() }} + {{ copy('common.session_code') }}: {{ sessionCode || '—' }} + {{ copy('common.status') }}: {{ session?.session?.status ?? 'idle' }} + ID: {{ playerId }} + Selected: {{ selectedGuess }} +
-

- {{ copy('player.reconnecting_text') }} - - -

-

- {{ copy('player.offline_text') }} - - -

+

{{ copy('player.audio_policy_notice') }}

+

{{ loadingMessage }}

+

{{ backgroundRefreshNotice }}

+
-

{{ loadingMessage }}

+
+
+

{{ copy('player.sync_status_eyebrow') }}

+

{{ syncStatusTitle }}

+

{{ syncStatusBody }}

+
-
-

{{ copy('common.status') }}: {{ session.session.status }}

-

{{ copy('common.prompt') }}: {{ session.round_question.prompt }}

+
+ + {{ chip.label }}: {{ chip.value }} + +
- - - - - - - -
- +
+
- - - +
+
+

{{ playerSceneOrnament }}

+

{{ playerSceneTitle }}

+

{{ playerSceneHeadline }}

+

{{ playerSceneBody }}

+
+ {{ playerSceneChipLabel }} + {{ copy('common.session_code') }}: {{ session?.session?.code || sessionCode || '—' }} + {{ copy('player.player_id_label') }}: {{ joinedPlayer.id }} +
+
-
-

Reveal

-

Korrekt svar: {{ session.reveal.correct_answer }}

-

Spørgsmål: {{ session.reveal.prompt }}

-
- Løgne -
    -
  • {{ lie.nickname }} løj: {{ lie.text }}
  • -
-
-
- Gæt -
    -
  • - {{ guess.nickname }} valgte {{ guess.selected_text }} - · korrekt - · narret af {{ guess.fooled_player_nickname }} - · forkert -
  • -
+ +
+ +
+
+

{{ activeSceneOrnament }}

+

{{ activeSceneTitle }}

+

{{ activeSceneHeadline }}

+

{{ activeSceneBody }}

+
+ {{ phaseToken.toUpperCase() }} + {{ copy('common.round') }}: {{ session?.session?.current_round ?? 0 }} + {{ copy('player.player_scene_stat_score') }}: {{ joinedPlayer.score }} {{ copy('common.points_short') }} + {{ copy('player.result_rank_label') }}: #{{ joinedPlayerRank }} +
+ +
+ +
+ + +
+
+ +
+
+ +
+ +

{{ copy('player.answers_not_ready') }}

+
+ +
+ + +
+
+ + +
+
+

{{ copy('player.correct_answer') }}

+

{{ reveal.correct_answer }}

+

{{ copy('common.prompt') }}: {{ reveal.prompt }}

+
+
+
+ +
+
+

{{ copy('player.result_rank_label') }}

+

{{ joinedPlayerRank ? '#' + joinedPlayerRank : '—' }}

+

{{ joinedPlayer?.nickname ?? copy('player.headline_waiting') }}

+
+
+ + +
+ +
+
+

{{ showJoinControls ? copy('player.join_section_title') : copy('player.current_task_title') }}

+

+ {{ showJoinControls ? copy('player.join_section_body') : playerActionSummary }} +

+ + + + + + + +
+ + +
+
+ + +
+ +
+ +

{{ copy('player.answers_not_ready') }}

+
+ +
+ + +
+
+ +
+ +
+ +

{{ waitingMessage }}

+
+ + +
+

{{ activeSupportTitle }}

+

{{ activeSupportBody }}

+ +

+ {{ copy('common.prompt') }}: {{ currentPrompt }} +

+

{{ copy('player.draft_length_label') }}: {{ lieText.length }}

+

+ {{ copy('player.selected_answer_label') }}: {{ selectedGuess || copy('player.selection_pending_label') }} +

+

{{ copy('player.guess_result_label') }}: {{ revealGuessResultText }}

+

{{ copy('player.players_fooled_label') }}: {{ playersFooledCount }}

+

+ {{ copy('player.result_rank_label') }}: {{ joinedPlayerRank ? '#' + joinedPlayerRank : '—' }} +

+

+ {{ copy('player.gap_to_lead_label') }}: {{ scoreGapToLeader }} {{ copy('common.points_short') }} +

+
+
+ +
+ +

{{ copy('player.room_roster_title') }}

+

{{ copy('player.room_roster_body') }}

+ +
+
+
+
+ {{ playerIdentityToken(player.id, player.nickname, index) }} + {{ playerIcon(player.id, player.nickname, index) }} +
+
+ {{ player.nickname }} + {{ player.score }} {{ copy('common.points_short') }} +
+
+ + + {{ + player.id === joinedPlayer?.id + ? copy('player.you_label') + : (player.is_connected ? copy('player.room_live_label') : copy('player.room_away_label')) + }} + +
+
+ + +

{{ copy('player.round_prompt_waiting') }}

+
+
+ + +

{{ roundPanelTitle }}

+

{{ roundPanelBody }}

+

+ {{ copy('common.prompt') }}: {{ currentPrompt }} +

+

{{ copy('player.round_prompt_waiting') }}

+ +

{{ copy('player.selected_answer_label') }}: {{ selectedGuess }}

+ +
+

{{ copy('player.reveal_title') }}

+

{{ copy('player.correct_answer') }}: {{ reveal.correct_answer }}

+
    +
  • {{ lie.nickname }} {{ copy('player.lied_label') }}: {{ lie.text }}
  • +
+
    +
  • + {{ guess.nickname }} {{ copy('player.picked_label') }} {{ guess.selected_text }} + · {{ copy('player.correct_label') }} + · {{ copy('player.fooled_by_label') }} {{ guess.fooled_player_nickname }} + · {{ copy('player.incorrect_label') }} +
  • +
+
+ +
+

{{ isFinishedSession ? copy('player.final_leaderboard') : copy('player.scoreboard_title') }}

+
    +
  1. +
    +
    + {{ entry.identityToken }} + {{ entry.identityIcon }} +
    +
    + {{ entry.nickname }} + #{{ entry.rank }} +
    +
    + {{ entry.scoreLabel }} +
  2. +
+
+
+
-
-

{{ copy('player.final_leaderboard') }}

-
    -
  1. {{ entry.nickname }}: {{ entry.score }}
  2. -
-
- +

{{ error }}

+

{{ submitError.message }}

-

{{ error }}

-

{{ submitError.message }}

- -
- - -
+
+

{{ copy('player.developer_state_title') }}

+

{{ copy('player.developer_state_body') }}

+
+ {{ copy('player.connection_label') }}: {{ connectionState }} + {{ copy('player.sync_transport_label') }}: {{ syncTransport }} + {{ copy('player.realtime_status_label') }}: {{ realtimeConnectionState }} + {{ copy('player.player_id_label') }}: {{ playerId || '—' }} + {{ copy('player.token_label') }}: {{ sessionToken ? copy('player.token_present') : copy('player.token_missing') }} + {{ copy('player.last_event_label') }}: {{ lastRealtimeEventType || '—' }} +
+
{{ developerSnapshot }}
+
+
`, }) export class PlayerShellComponent implements OnInit, OnDestroy { @@ -152,13 +516,29 @@ export class PlayerShellComponent implements OnInit, OnDestroy { finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = []; connectionState: ConnectionState = 'online'; loadingTransition: LoadingTransition = null; + backgroundRefreshNotice = ''; + developerMode = false; + syncTransport: SyncTransport = 'idle'; + realtimeConnectionState: SessionRealtimeStatus['connectionState'] = 'idle'; + lastRealtimeEventType = ''; + lastRealtimeEventAt: number | null = null; private readonly sessionContextStore = createSessionContextStore(resolveLocalStorage()); private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); + private readonly realtime = createSessionRealtimeClient({ + onEvent: () => { + void this.refreshSession('background'); + }, + onStatusChange: (status) => { + this.handleRealtimeStatusChange(status); + }, + }); private reconnectTimer: ReturnType | null = null; private stateSyncTimer: ReturnType | null = null; + private backgroundRefreshDelayTimer: ReturnType | null = null; private unsubscribeLocale: (() => void) | null = null; private restoreAudioGuard: (() => void) | null = null; + private backgroundRefreshInFlight = false; constructor() { if (typeof navigator !== 'undefined' && !navigator.onLine) { @@ -176,6 +556,9 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.locale = locale; }); this.installSecondaryDeviceAudioGuard(); + if (typeof window !== 'undefined') { + this.developerMode = resolveDeveloperState('wpp.player.developer-mode'); + } const hashRoute = window.location.hash.replace(/^#\/?/, ''); const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i); @@ -193,6 +576,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } this.sessionCode = this.normalizeCode(candidate); + this.syncRealtimeSubscription(); void this.refreshSession(); } @@ -203,6 +587,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } this.clearReconnectTimer(); this.clearStateSyncTimer(); + this.clearBackgroundRefreshDelayTimer(); + this.realtime.disconnect(); this.unsubscribeLocale?.(); this.unsubscribeLocale = null; this.restoreAudioGuard?.(); @@ -213,6 +599,553 @@ export class PlayerShellComponent implements OnInit, OnDestroy { return deriveGameplayPhase(this.session as any); } + get phaseToken(): string { + return this.gameplayPhase ?? this.session?.session.status ?? 'lobby'; + } + + get isFinishedSession(): boolean { + return this.session?.session.status === 'finished'; + } + + get playerHeadline(): string { + if (this.showJoinControls) { + return this.copy('player.headline_join'); + } + if (this.showResultScene) { + return this.isFinishedSession ? this.copy('player.final_leaderboard') : this.copy('player.scoreboard_title'); + } + if (this.session?.round_question?.prompt) { + return this.session.round_question.prompt; + } + if (this.showFinalLeaderboard) { + return this.copy('player.final_leaderboard'); + } + return this.copy('player.headline_waiting'); + } + + get players(): SessionDetail['players'] { + return this.session?.players ?? []; + } + + get joinedPlayer(): SessionDetail['players'][number] | null { + return this.players.find((player) => player.id === this.playerId) ?? null; + } + + get livePlayersCount(): number { + return this.players.filter((player) => player.is_connected).length; + } + + get showPlayerLobbyScene(): boolean { + return Boolean(this.session) && this.phaseToken === 'lobby' && !this.showJoinControls; + } + + get showPlayerWaitingScene(): boolean { + return Boolean(this.session) && !this.showJoinControls && this.showWaitingState; + } + + get showPlayerScene(): boolean { + return this.showJoinControls || this.showPlayerWaitingScene; + } + + get showActivePlayerScene(): boolean { + return this.showLieControls || this.showGuessControls || this.showRevealScene || this.showResultScene; + } + + get showRoomRoster(): boolean { + return Boolean(this.session) && (this.showJoinControls || this.showPlayerWaitingScene || this.showLieControls || this.showGuessControls); + } + + get phaseDisplay(): PhaseDisplay | null { + return this.session?.phase_display ?? null; + } + + get playerSceneTheme(): string { + return this.phaseDisplay?.theme ?? this.defaultPlayerSceneTheme; + } + + get playerSceneOrnament(): string { + return this.phaseDisplay?.ornament ?? this.defaultPlayerSceneOrnament; + } + + get activeSceneTheme(): string { + return this.phaseDisplay?.theme ?? this.defaultActiveSceneTheme; + } + + get activeSceneOrnament(): string { + return this.phaseDisplay?.ornament ?? this.defaultActiveSceneOrnament; + } + + get playerSceneTitle(): string { + if (this.showJoinControls) { + return this.resolvePhaseDisplayCopy('title_key', 'player.player_scene_title_join'); + } + + switch (this.phaseToken) { + case 'lobby': + return this.resolvePhaseDisplayCopy('title_key', 'player.player_scene_title_lobby'); + case 'lie': + return this.resolvePhaseDisplayCopy('title_key', 'player.player_scene_title_waiting_lie'); + case 'guess': + return this.resolvePhaseDisplayCopy('title_key', 'player.player_scene_title_waiting_guess'); + default: + return this.resolvePhaseDisplayCopy('title_key', 'player.player_scene_title_waiting'); + } + } + + get playerSceneHeadline(): string { + if (this.showJoinControls) { + return this.session?.session?.code || this.normalizeCode(this.sessionCode) || this.copy('player.player_scene_join_headline'); + } + + return this.joinedPlayer?.nickname ?? this.session?.session?.code ?? this.copy('player.headline_waiting'); + } + + get playerSceneBody(): string { + if (this.showJoinControls) { + return this.resolvePhaseDisplayCopy('body_key', 'player.player_scene_body_join'); + } + + if (this.phaseToken === 'lobby') { + return this.resolvePhaseDisplayCopy('body_key', 'player.player_scene_body_lobby'); + } + + return this.resolvePhaseDisplayCopy('body_key', 'player.player_scene_body_waiting'); + } + + get playerSceneChipLabel(): string { + return this.showJoinControls ? this.copy('player.join').toUpperCase() : this.phaseToken.toUpperCase(); + } + + get playerSceneCueLabel(): string { + if (this.showJoinControls) { + return this.resolvePhaseDisplayCopy('cue_label_key', 'player.player_scene_cue_join_label'); + } + + if (this.phaseToken === 'lobby') { + return this.resolvePhaseDisplayCopy('cue_label_key', 'player.player_scene_cue_lobby_label'); + } + + return this.resolvePhaseDisplayCopy('cue_label_key', 'player.player_scene_cue_waiting_label'); + } + + get playerSceneCueBody(): string { + if (this.showJoinControls) { + return this.resolvePhaseDisplayCopy('cue_body_key', 'player.player_scene_cue_join_body'); + } + + if (this.phaseToken === 'lobby') { + return this.resolvePhaseDisplayCopy('cue_body_key', 'player.player_scene_cue_lobby_body'); + } + + return this.resolvePhaseDisplayCopy('cue_body_key', 'player.player_scene_cue_waiting_body'); + } + + get playerSceneStats(): PlayerSceneStatCard[] { + if (!this.session) { + return []; + } + + const stats: PlayerSceneStatCard[] = [ + { label: this.copy('player.room_players_label'), value: String(this.players.length) }, + { label: this.copy('player.room_live_label'), value: String(this.livePlayersCount) }, + ]; + + if (this.joinedPlayer) { + stats.push({ + label: this.copy('player.player_scene_stat_score'), + value: `${this.joinedPlayer.score} ${this.copy('common.points_short')}`, + }); + } + + return stats; + } + + get activeSceneTitle(): string { + if (this.showLieControls) { + return this.resolvePhaseDisplayCopy('title_key', 'player.submit_lie'); + } + if (this.showGuessControls) { + return this.resolvePhaseDisplayCopy('title_key', 'player.submit_guess'); + } + if (this.showRevealScene) { + return this.resolvePhaseDisplayCopy('title_key', 'player.reveal_title'); + } + if (this.showResultScene) { + return this.resolvePhaseDisplayCopy( + 'title_key', + this.isFinishedSession ? 'player.final_leaderboard' : 'player.scoreboard_title', + ); + } + return this.copy('player.current_task_title'); + } + + get activeSceneHeadline(): string { + if (this.showLieControls || this.showGuessControls) { + return this.currentPrompt || this.copy('player.round_prompt_waiting'); + } + if (this.showRevealScene) { + return this.revealPanel?.correct_answer ?? this.copy('player.reveal_title'); + } + if (this.showResultScene) { + return this.joinedPlayerRank ? `#${this.joinedPlayerRank}` : this.copy('player.scoreboard_title'); + } + return this.copy('player.headline_waiting'); + } + + get activeSceneBody(): string { + if (this.showLieControls) { + return this.resolvePhaseDisplayCopy('body_key', 'player.phase_summary_lie'); + } + if (this.showGuessControls) { + return this.resolvePhaseDisplayCopy('body_key', 'player.phase_summary_guess'); + } + if (this.showRevealScene) { + return this.resolvePhaseDisplayCopy('body_key', 'player.phase_summary_reveal'); + } + if (this.showResultScene) { + return this.resolvePhaseDisplayCopy( + 'body_key', + this.isFinishedSession ? 'player.phase_summary_finished' : 'player.phase_summary_scoreboard', + ); + } + return this.copy('player.phase_summary_lobby'); + } + + get activeSceneCueLabel(): string { + if (this.showLieControls) { + return this.resolvePhaseDisplayCopy('cue_label_key', 'player.active_scene_cue_lie_label'); + } + if (this.showGuessControls) { + return this.resolvePhaseDisplayCopy('cue_label_key', 'player.active_scene_cue_guess_label'); + } + if (this.showRevealScene) { + return this.resolvePhaseDisplayCopy('cue_label_key', 'player.active_scene_cue_reveal_label'); + } + return this.resolvePhaseDisplayCopy('cue_label_key', 'player.active_scene_cue_result_label'); + } + + get activeSceneCueBody(): string { + if (this.showLieControls) { + return this.resolvePhaseDisplayCopy('cue_body_key', 'player.active_scene_cue_lie_body'); + } + if (this.showGuessControls) { + return this.resolvePhaseDisplayCopy('cue_body_key', 'player.active_scene_cue_guess_body'); + } + if (this.showRevealScene) { + return this.resolvePhaseDisplayCopy('cue_body_key', 'player.active_scene_cue_reveal_body'); + } + return this.resolvePhaseDisplayCopy('cue_body_key', 'player.active_scene_cue_result_body'); + } + + get activeSceneStats(): PlayerSceneStatCard[] { + if (!this.session) { + return []; + } + + if (this.showLieControls) { + return [ + { label: this.copy('player.room_players_label'), value: String(this.players.length) }, + { label: this.copy('player.room_live_label'), value: String(this.livePlayersCount) }, + { label: this.copy('player.player_scene_stat_score'), value: this.formatPoints(this.joinedPlayer?.score) }, + ]; + } + + if (this.showGuessControls) { + return [ + { label: this.copy('player.answers_available_label'), value: String(this.roundAnswers.length) }, + { label: this.copy('player.selected_answer_label'), value: this.selectedGuess || this.copy('player.selection_pending_label') }, + { label: this.copy('player.player_scene_stat_score'), value: this.formatPoints(this.joinedPlayer?.score) }, + ]; + } + + if (this.showRevealScene) { + return [ + { label: this.copy('player.guess_result_label'), value: this.revealGuessResultText }, + { label: this.copy('player.players_fooled_label'), value: String(this.playersFooledCount) }, + { label: this.copy('player.player_scene_stat_score'), value: this.formatPoints(this.joinedPlayer?.score) }, + ]; + } + + if (this.showResultScene) { + return [ + { label: this.copy('player.result_rank_label'), value: this.joinedPlayerRank ? `#${this.joinedPlayerRank}` : '—' }, + { label: this.copy('player.leader_score_label'), value: this.resultLeader?.scoreLabel ?? '—' }, + { label: this.copy('player.player_scene_stat_score'), value: this.formatPoints(this.joinedPlayer?.score) }, + ]; + } + + return []; + } + + get activeSupportTitle(): string { + if (this.showRevealScene) { + return this.copy('player.reveal_title'); + } + if (this.showResultScene) { + return this.isFinishedSession ? this.copy('player.final_leaderboard') : this.copy('player.scoreboard_title'); + } + return this.copy('player.current_task_title'); + } + + get activeSupportBody(): string { + if (this.showLieControls) { + return this.copy('player.action_summary_lie'); + } + if (this.showGuessControls) { + return this.copy('player.action_summary_guess'); + } + if (this.showResultScene) { + return this.isFinishedSession ? this.copy('player.action_summary_finished') : this.copy('player.action_summary_scoreboard'); + } + if (this.showRevealScene) { + return this.copy('player.phase_summary_reveal'); + } + return this.copy('player.action_summary_waiting'); + } + + get roundPanelTitle(): string { + if (this.showResultScene) { + return this.isFinishedSession ? this.copy('player.final_leaderboard') : this.copy('player.scoreboard_title'); + } + if (this.showRevealScene) { + return this.copy('player.reveal_title'); + } + return this.copy('player.round_view_title'); + } + + get roundPanelBody(): string { + if (this.showResultScene) { + return this.isFinishedSession ? this.copy('player.phase_summary_finished') : this.copy('player.phase_summary_scoreboard'); + } + if (this.showRevealScene) { + return this.copy('player.phase_summary_reveal'); + } + return this.copy('player.round_prompt_waiting'); + } + + get phaseSummary(): string { + if (this.isFinishedSession) { + return this.copy('player.phase_summary_finished'); + } + + switch (this.phaseToken) { + case 'lie': + return this.copy('player.phase_summary_lie'); + case 'guess': + return this.copy('player.phase_summary_guess'); + case 'reveal': + return this.copy('player.phase_summary_reveal'); + case 'scoreboard': + return this.copy('player.phase_summary_scoreboard'); + case 'finished': + return this.copy('player.phase_summary_finished'); + case 'lobby': + default: + return this.copy('player.phase_summary_lobby'); + } + } + + get playerActionSummary(): string { + if (this.showLieControls) { + return this.copy('player.action_summary_lie'); + } + if (this.showGuessControls) { + return this.copy('player.action_summary_guess'); + } + if (this.showResultScene && !this.isFinishedSession) { + return this.copy('player.action_summary_scoreboard'); + } + if (this.showFinalLeaderboard) { + return this.copy('player.action_summary_finished'); + } + return this.copy('player.action_summary_waiting'); + } + + get showWaitingState(): boolean { + return !this.showJoinControls && !this.showLieControls && !this.showGuessControls && !this.revealPanel && !this.showResultScene && !this.showFinalLeaderboard; + } + + get currentPrompt(): string { + return this.session?.round_question?.prompt ?? this.session?.reveal?.prompt ?? ''; + } + + get roundAnswers(): NonNullable['answers']> { + return this.session?.round_question?.answers ?? []; + } + + get showRevealScene(): boolean { + return this.phaseToken === 'reveal' && Boolean(this.revealPanel); + } + + get resultLeaderboard(): PlayerResultEntry[] { + const source = (this.isFinishedSession && this.finalLeaderboard.length ? this.finalLeaderboard : this.players).map((entry) => ({ + id: entry.id, + nickname: entry.nickname, + score: entry.score, + })); + + return source + .sort((left, right) => { + if (right.score !== left.score) { + return right.score - left.score; + } + return left.nickname.localeCompare(right.nickname); + }) + .map((entry, index) => ({ + ...entry, + isCurrent: entry.id === this.playerId, + rank: index + 1, + scoreLabel: this.formatPoints(entry.score), + identityToken: this.playerIdentityToken(entry.id, entry.nickname, index), + identityTone: this.playerTone(entry.id, entry.nickname, index), + identityIcon: this.playerIcon(entry.id, entry.nickname, index), + })); + } + + get resultLeader(): PlayerResultEntry | null { + return this.resultLeaderboard[0] ?? null; + } + + get joinedPlayerRank(): number | null { + return this.resultLeaderboard.find((entry) => entry.id === this.playerId)?.rank ?? null; + } + + get scoreGapToLeader(): number | null { + if (!this.joinedPlayer || !this.resultLeader) { + return null; + } + + return Math.max(this.resultLeader.score - this.joinedPlayer.score, 0); + } + + get revealGuess(): RevealPanel['guesses'][number] | null { + return this.revealPanel?.guesses.find((guess) => guess.player_id === this.playerId) ?? null; + } + + get revealGuessResultText(): string { + const guess = this.revealGuess; + if (!guess) { + return this.copy('player.selection_pending_label'); + } + if (guess.is_correct) { + return this.copy('player.correct_label'); + } + if (guess.fooled_player_nickname) { + return `${this.copy('player.fooled_by_label')} ${guess.fooled_player_nickname}`; + } + return this.copy('player.incorrect_label'); + } + + get playersFooledCount(): number { + return this.revealPanel?.guesses.filter((guess) => guess.fooled_player_id === this.playerId).length ?? 0; + } + + get showResultScene(): boolean { + return (this.phaseToken === 'scoreboard' || this.isFinishedSession) && this.resultLeaderboard.length > 0; + } + + get waitingMessage(): string { + if (this.connectionState === 'reconnecting' || this.realtimeConnectionState === 'reconnecting') { + return this.copy('player.reconnecting_text'); + } + if (this.connectionState === 'offline') { + return this.copy('player.offline_text'); + } + return this.copy('player.waiting_connected'); + } + + get showSyncStatusCard(): boolean { + const hasSessionTarget = Boolean(this.normalizeCode(this.session?.session.code || this.sessionCode)); + if (!hasSessionTarget) { + return false; + } + + return Boolean( + this.connectionState !== 'online' || + this.backgroundRefreshNotice || + this.realtimeConnectionState === 'reconnecting' + ); + } + + get syncStatusTone(): 'warning' | 'danger' { + return this.connectionState === 'offline' ? 'danger' : 'warning'; + } + + get syncStatusTitle(): string { + if (this.connectionState === 'offline') { + return this.copy('player.sync_status_title_offline'); + } + if (this.backgroundRefreshNotice) { + return this.copy('player.sync_status_title_delayed'); + } + return this.copy('player.sync_status_title_reconnecting'); + } + + get syncStatusBody(): string { + if (this.connectionState === 'offline') { + return this.copy('player.sync_status_body_offline'); + } + if (this.backgroundRefreshNotice) { + return this.copy('player.sync_status_body_delayed'); + } + return this.copy('player.sync_status_body_reconnecting'); + } + + get syncStatusChips(): PlayerSyncChip[] { + const chips: PlayerSyncChip[] = [ + { label: this.copy('player.sync_transport_label'), value: this.syncTransport }, + { label: this.copy('player.realtime_status_label'), value: this.realtimeConnectionState }, + ]; + + if (this.lastRealtimeEventType) { + chips.push({ label: this.copy('player.last_event_label'), value: this.lastRealtimeEventType }); + } + + if (this.showLieControls) { + chips.push({ label: this.copy('player.draft_length_label'), value: String(this.lieText.length) }); + } + + if (this.showGuessControls) { + chips.push({ + label: this.copy('player.selected_answer_label'), + value: this.selectedGuess || this.copy('player.selection_pending_label'), + }); + } + + return chips; + } + + get developerSnapshot(): string { + return JSON.stringify( + { + phase: this.phaseToken, + connectionState: this.connectionState, + playerId: this.playerId || null, + hasSessionToken: Boolean(this.sessionToken), + sync: { + transport: this.syncTransport, + realtimeConnectionState: this.realtimeConnectionState, + lastRealtimeEventType: this.lastRealtimeEventType || null, + lastRealtimeEventAt: this.lastRealtimeEventAt, + }, + players: this.players.map((player, index) => ({ + id: player.id, + nickname: player.nickname, + identity: this.resolvePlayerIdentity(player.id, player.nickname, index), + })), + session: this.session?.session ?? null, + roundQuestion: this.session?.round_question ?? null, + phaseDisplay: this.phaseDisplay, + selectedGuess: this.selectedGuess || null, + lieTextDraftLength: this.lieText.length, + error: this.error, + submitError: this.submitError, + }, + null, + 2, + ); + } + get canSubmitLie(): boolean { return isPlayerGameplayActionAllowed(this.session as any, 'submitLie'); } @@ -225,8 +1158,13 @@ export class PlayerShellComponent implements OnInit, OnDestroy { return Boolean(this.session?.reveal && (this.gameplayPhase === 'reveal' || this.gameplayPhase === 'scoreboard')); } + get revealPanel(): RevealPanel | null { + return this.showRevealPanel ? this.session?.reveal ?? null : null; + } + private readonly handleOnline = (): void => { this.connectionState = 'reconnecting'; + this.syncRealtimeSubscription(); void this.retryReconnect(); }; @@ -234,6 +1172,9 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.connectionState = 'offline'; this.clearReconnectTimer(); this.clearStateSyncTimer(); + this.realtime.disconnect(); + this.realtimeConnectionState = 'idle'; + this.updateSyncTransport(); }; private clearReconnectTimer(): void { @@ -250,6 +1191,13 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } } + private clearBackgroundRefreshDelayTimer(): void { + if (this.backgroundRefreshDelayTimer) { + clearTimeout(this.backgroundRefreshDelayTimer); + this.backgroundRefreshDelayTimer = null; + } + } + private installSecondaryDeviceAudioGuard(): void { if (!this.clientHasNoAudioOutput || typeof window === 'undefined') { return; @@ -326,7 +1274,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { private scheduleStateSync(): void { this.clearStateSyncTimer(); - if (!this.sessionCode.trim() || this.connectionState !== 'online' || !this.session) { + if (!this.sessionCode.trim() || this.syncTransport !== 'polling' || !this.session || this.backgroundRefreshInFlight) { return; } @@ -336,11 +1284,11 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.stateSyncTimer = setTimeout(() => { this.stateSyncTimer = null; - if (this.loading || this.connectionState !== 'online') { + if (this.loading || this.syncTransport !== 'polling' || this.backgroundRefreshInFlight) { this.scheduleStateSync(); return; } - void this.refreshSession(); + void this.refreshSession('background'); }, 3000); } @@ -381,10 +1329,115 @@ export class PlayerShellComponent implements OnInit, OnDestroy { return t(key, this.locale); } + private get defaultPlayerSceneTheme(): string { + if (this.showJoinControls) { + return 'player-boarding'; + } + if (this.phaseToken === 'lobby') { + return 'player-ready'; + } + return 'player-holding'; + } + + private get defaultPlayerSceneOrnament(): string { + if (this.showJoinControls) { + return 'boarding-pass'; + } + + if (this.phaseToken === 'lobby') { + return 'ready-lantern'; + } + + return 'holding-ring'; + } + + private get defaultActiveSceneTheme(): string { + if (this.showLieControls) { + return 'player-ink'; + } + if (this.showGuessControls) { + return 'player-choices'; + } + if (this.showRevealScene) { + return 'player-ripple'; + } + return 'player-pennant'; + } + + private get defaultActiveSceneOrnament(): string { + if (this.showLieControls) { + return 'ink-trace'; + } + if (this.showGuessControls) { + return 'choice-grid'; + } + if (this.showRevealScene) { + return 'ripple-flare'; + } + + return 'pennant-stack'; + } + + private resolvePhaseDisplayCopy(field: keyof PhaseDisplay, fallbackKey: string): string { + const key = this.phaseDisplay?.[field]; + return typeof key === 'string' && key ? this.copy(key) : this.copy(fallbackKey); + } + + toggleDeveloperMode(): void { + this.developerMode = toggleDeveloperState('wpp.player.developer-mode', this.developerMode); + } + + // Reuse the same lightweight identity token pattern as the host shell so + // diagnostics and leaderboard views stay consistent across devices. + playerInitial(nickname: string): string { + return nickname.trim().charAt(0).toUpperCase() || '?'; + } + + playerIdentityToken(playerId: number, nickname: string, index = 0): string { + return this.resolvePlayerIdentity(playerId, nickname, index).token; + } + + playerTone(playerId: number, nickname: string, index = 0): PlayerIdentityTone { + return this.resolvePlayerIdentity(playerId, nickname, index).tone; + } + + playerIcon(playerId: number, nickname: string, index = 0): string { + return this.resolvePlayerIdentity(playerId, nickname, index).icon; + } + + private formatPoints(value: number | null | undefined): string { + if (typeof value !== 'number') { + return '—'; + } + return `${value} ${this.copy('common.points_short')}`; + } + private normalizeCode(value: string): string { return value.trim().toUpperCase(); } + private resolvePlayerIdentity(playerId: number, nickname: string, index = 0): SessionPlayerIdentity { + const sessionIdentity = this.players.find((player) => player.id === playerId)?.identity; + const fallbackIcon = PLAYER_IDENTITY_ICONS[index % PLAYER_IDENTITY_ICONS.length]; + if (sessionIdentity?.token && sessionIdentity?.tone && isPlayerIdentityTone(sessionIdentity.tone)) { + return { + token: sessionIdentity.token, + tone: sessionIdentity.tone, + icon: typeof sessionIdentity.icon === 'string' && sessionIdentity.icon ? sessionIdentity.icon : fallbackIcon, + }; + } + + return { + token: this.playerInitial(nickname), + tone: PLAYER_IDENTITY_TONES[index % PLAYER_IDENTITY_TONES.length], + icon: fallbackIcon, + }; + } + + private resolveSessionPhase(session: SessionDetail | null): string { + return deriveGameplayPhase(session as SessionDetail) ?? session?.session.status ?? 'lobby'; + } + private toMessage(error: unknown): string { if (error instanceof Error && error.message) { return error.message; @@ -392,10 +1445,74 @@ export class PlayerShellComponent implements OnInit, OnDestroy { return this.copy('common.unknown_error'); } + private handleRealtimeStatusChange(status: SessionRealtimeStatus): void { + this.realtimeConnectionState = status.connectionState; + this.lastRealtimeEventType = status.lastEventType ?? ''; + this.lastRealtimeEventAt = status.lastEventAt; + this.updateSyncTransport(); + } + + private updateSyncTransport(): void { + const hasSession = Boolean(this.normalizeCode(this.session?.session.code || this.sessionCode)); + if (!hasSession) { + this.syncTransport = 'idle'; + this.clearStateSyncTimer(); + return; + } + + if (this.realtimeConnectionState === 'connected') { + this.syncTransport = 'websocket'; + this.clearStateSyncTimer(); + return; + } + + this.syncTransport = 'polling'; + this.scheduleStateSync(); + } + + private syncRealtimeSubscription(): void { + const code = this.normalizeCode(this.session?.session.code || this.sessionCode); + const sessionToken = this.sessionToken.trim(); + if (!code || !sessionToken) { + this.realtime.disconnect(); + this.realtimeConnectionState = 'idle'; + this.updateSyncTransport(); + return; + } + + this.realtime.updateTarget({ + sessionCode: code, + role: { mode: 'player', sessionToken }, + }); + } + private markOnline(): void { this.connectionState = 'online'; + this.backgroundRefreshNotice = ''; + this.clearBackgroundRefreshDelayTimer(); this.clearReconnectTimer(); - this.scheduleStateSync(); + this.updateSyncTransport(); + } + + private applySessionDetail(session: SessionDetail): void { + const nextPhase = this.resolveSessionPhase(session); + + this.session = session; + this.sessionCode = session.session.code; + + if (nextPhase !== 'guess') { + this.selectedGuess = ''; + } else if ( + this.selectedGuess && + session.round_question?.answers?.length && + !session.round_question.answers.some((answer) => answer.text === this.selectedGuess) + ) { + this.selectedGuess = ''; + } + + this.syncFinalLeaderboard(); + this.syncRouteFromSession(); + this.syncRealtimeSubscription(); } private markConnectionIssue(error: unknown): void { @@ -435,6 +1552,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { return; } + this.syncRealtimeSubscription(); await this.refreshSession(); } @@ -449,8 +1567,14 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.lieText = ''; this.submitError = null; this.error = ''; + this.backgroundRefreshNotice = ''; this.playerId = 0; this.sessionToken = ''; + this.realtime.disconnect(); + this.realtimeConnectionState = 'idle'; + this.syncTransport = 'idle'; + this.lastRealtimeEventType = ''; + this.lastRealtimeEventAt = null; this.sessionContextStore.clear(); } @@ -460,7 +1584,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { return; } - this.finalLeaderboard = [...this.session.players].sort((a, b) => { + this.finalLeaderboard = [...this.players].sort((a, b) => { if (b.score !== a.score) { return b.score - a.score; } @@ -487,12 +1611,51 @@ export class PlayerShellComponent implements OnInit, OnDestroy { window.history.replaceState(window.history.state, '', targetPath); } + private 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 ''; + } + + private async ensureCsrfToken(): Promise { + const existing = this.readCookie('csrftoken'); + if (existing || typeof document === 'undefined' || typeof window === 'undefined') { + return existing; + } + + try { + await fetch('/lobby/csrf', { + method: 'GET', + headers: { + Accept: 'application/json', + }, + credentials: 'same-origin', + }); + } catch { + return ''; + } + + return this.readCookie('csrftoken'); + } + private async request(path: string, method: 'GET' | 'POST', payload?: unknown): Promise { + const csrfToken = method === 'POST' ? await this.ensureCsrfToken() : ''; const response = await fetch(path, { method, headers: { Accept: 'application/json', ...(payload === undefined ? {} : { 'Content-Type': 'application/json' }), + ...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}), }, ...(payload === undefined ? {} : { body: JSON.stringify(payload) }), credentials: 'same-origin', @@ -506,29 +1669,45 @@ export class PlayerShellComponent implements OnInit, OnDestroy { return body as T; } - async refreshSession(): Promise { - this.loading = true; - this.loadingTransition = 'refresh'; - this.error = ''; + async refreshSession(mode: RefreshMode = 'foreground'): Promise { + const isBackground = mode === 'background'; + if (isBackground) { + if (this.backgroundRefreshInFlight) { + return; + } + this.backgroundRefreshInFlight = true; + this.clearBackgroundRefreshDelayTimer(); + this.backgroundRefreshDelayTimer = setTimeout(() => { + this.backgroundRefreshNotice = this.copy('player.sync_delayed_text'); + }, 5000); + } else { + this.loading = true; + this.loadingTransition = 'refresh'; + this.error = ''; + this.backgroundRefreshNotice = ''; + } try { - const state = await this.controller.hydrateLobby(this.sessionCode); + const state = await this.controller.hydrateLobby(this.sessionCode, { + ...(this.sessionToken.trim() ? { session_token: this.sessionToken.trim() } : {}), + }); if (!state.session || state.errorMessage) { throw new Error(state.errorMessage ?? this.copy('common.unknown_error')); } - this.session = state.session as SessionDetail; - this.sessionCode = this.session.session.code; - if (this.session.session.status !== 'guess') { - this.selectedGuess = ''; - } - this.syncFinalLeaderboard(); - this.syncRouteFromSession(); + this.applySessionDetail(state.session as SessionDetail); this.markOnline(); } catch (error) { - this.error = `${this.copy('player.session_refresh_failed')}: ${this.toMessage(error)}`; + if (!isBackground) { + this.error = `${this.copy('player.session_refresh_failed')}: ${this.toMessage(error)}`; + } this.markConnectionIssue(error); } finally { - this.loading = false; - this.loadingTransition = null; + if (isBackground) { + this.backgroundRefreshInFlight = false; + this.clearBackgroundRefreshDelayTimer(); + } else { + this.loading = false; + this.loadingTransition = null; + } } } @@ -541,17 +1720,10 @@ export class PlayerShellComponent implements OnInit, OnDestroy { if (!state.session || state.errorMessage) { throw new Error(state.errorMessage ?? this.copy('common.unknown_error')); } - this.session = state.session as SessionDetail; - this.sessionCode = this.session.session.code; - const sessionContext = this.sessionContextStore.get(); this.playerId = sessionContext?.playerId ?? 0; this.sessionToken = sessionContext?.token ?? ''; - if (this.session.session.status !== 'guess') { - this.selectedGuess = ''; - } - this.syncFinalLeaderboard(); - this.syncRouteFromSession(); + this.applySessionDetail(state.session as SessionDetail); this.markOnline(); } catch (error) { this.error = `${this.copy('player.join_failed')}: ${this.toMessage(error)}`; diff --git a/frontend/angular/src/app/lobby-i18n.spec.ts b/frontend/angular/src/app/lobby-i18n.spec.ts index e86d3d5..953db70 100644 --- a/frontend/angular/src/app/lobby-i18n.spec.ts +++ b/frontend/angular/src/app/lobby-i18n.spec.ts @@ -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', diff --git a/frontend/angular/src/app/realtime-visual-smoke.spec.ts b/frontend/angular/src/app/realtime-visual-smoke.spec.ts new file mode 100644 index 0000000..0f25024 --- /dev/null +++ b/frontend/angular/src/app/realtime-visual-smoke.spec.ts @@ -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 & { + 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; + 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(); + }); +}); diff --git a/frontend/angular/src/app/session-realtime.spec.ts b/frontend/angular/src/app/session-realtime.spec.ts new file mode 100644 index 0000000..e00a71b --- /dev/null +++ b/frontend/angular/src/app/session-realtime.spec.ts @@ -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'); + }); +}); diff --git a/frontend/angular/src/app/session-realtime.ts b/frontend/angular/src/app/session-realtime.ts new file mode 100644 index 0000000..f30cbfe --- /dev/null +++ b/frontend/angular/src/app/session-realtime.ts @@ -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; + +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; +}; + +const DEFAULT_RECONNECT_DELAY_MS = 1500; +const MAX_RECONNECT_DELAY_MS = 5000; + +function resolveWindowLike(windowLike?: Pick): Pick | 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, + 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): 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(); + }, + }; +} diff --git a/frontend/angular/src/app/wpp-api-client.spec.ts b/frontend/angular/src/app/wpp-api-client.spec.ts index 45f21fa..be06801 100644 --- a/frontend/angular/src/app/wpp-api-client.spec.ts +++ b/frontend/angular/src/app/wpp-api-client.spec.ts @@ -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({}), + }) + ); }); }); diff --git a/frontend/angular/src/app/wpp-api-client.ts b/frontend/angular/src/app/wpp-api-client.ts index 973f10d..8c1e69c 100644 --- a/frontend/angular/src/app/wpp-api-client.ts +++ b/frontend/angular/src/app/wpp-api-client.ts @@ -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 { + 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 { + 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(url: string): Promise { 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(url: string, body: unknown): Promise { + 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, diff --git a/frontend/angular/src/styles.css b/frontend/angular/src/styles.css index 57c14ee..aa6f878 100644 --- a/frontend/angular/src/styles.css +++ b/frontend/angular/src/styles.css @@ -1,4 +1,1014 @@ -html, body { +:root { + color-scheme: light; + --wpp-ink-strong: #11222a; + --wpp-ink: #22404b; + --wpp-ink-muted: #5d7781; + --wpp-surface: rgba(255, 250, 244, 0.92); + --wpp-surface-strong: rgba(255, 255, 255, 0.96); + --wpp-surface-glass: rgba(255, 255, 255, 0.72); + --wpp-border: rgba(17, 34, 42, 0.12); + --wpp-border-strong: rgba(17, 34, 42, 0.2); + --wpp-shadow: 0 22px 50px rgba(39, 62, 71, 0.14); + --wpp-shadow-soft: 0 14px 28px rgba(39, 62, 71, 0.08); + --wpp-accent: #0f766e; + --wpp-accent-soft: rgba(15, 118, 110, 0.16); + --wpp-accent-warm: #c96a32; + --wpp-accent-gold: #d3a127; + --wpp-danger: #b42318; + --wpp-danger-soft: rgba(180, 35, 24, 0.12); + --wpp-success: #176448; + --wpp-radius-xl: 28px; + --wpp-radius-lg: 20px; + --wpp-radius-md: 14px; + --wpp-radius-pill: 999px; + --wpp-font-display: "Avenir Next Condensed", "Trebuchet MS", "Segoe UI Variable Display", sans-serif; + --wpp-font-body: "Avenir Next", "Segoe UI Variable", "Trebuchet MS", sans-serif; +} + +html, +body { margin: 0; padding: 0; + min-height: 100%; +} + +body { + font-family: var(--wpp-font-body); + color: var(--wpp-ink-strong); + background: + radial-gradient(circle at top left, rgba(243, 167, 86, 0.3), transparent 34%), + radial-gradient(circle at top right, rgba(94, 197, 180, 0.22), transparent 30%), + linear-gradient(180deg, #fff8ec 0%, #eef6f8 48%, #f6fbfc 100%); +} + +* { + box-sizing: border-box; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button { + font-family: inherit; +} + +.wpp-page { + display: grid; + gap: 1.25rem; + animation: wpp-fade-in 220ms ease-out; +} + +.wpp-page[data-phase="lie"] { + --wpp-accent: #a54e2e; + --wpp-accent-soft: rgba(165, 78, 46, 0.14); +} + +.wpp-page[data-phase="guess"] { + --wpp-accent: #8a5f10; + --wpp-accent-soft: rgba(138, 95, 16, 0.14); +} + +.wpp-page[data-phase="reveal"], +.wpp-page[data-phase="scoreboard"], +.wpp-page[data-phase="finished"] { + --wpp-accent: #155e75; + --wpp-accent-soft: rgba(21, 94, 117, 0.14); +} + +.wpp-hero-card, +.wpp-card, +.wpp-debug-panel { + position: relative; + overflow: hidden; + border: 1px solid var(--wpp-border); + border-radius: var(--wpp-radius-xl); + background: var(--wpp-surface); + box-shadow: var(--wpp-shadow-soft); + backdrop-filter: blur(14px); +} + +.wpp-hero-card::before, +.wpp-card::before, +.wpp-debug-panel::before { + content: ""; + position: absolute; + inset: 0 auto auto 0; + width: 100%; + height: 4px; + background: linear-gradient(90deg, var(--wpp-accent) 0%, var(--wpp-accent-warm) 48%, var(--wpp-accent-gold) 100%); + opacity: 0.9; +} + +.wpp-hero-card { + padding: 1.4rem; + background: + radial-gradient(circle at top right, rgba(255, 255, 255, 0.55), transparent 42%), + linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(255, 248, 236, 0.88)); +} + +.wpp-card, +.wpp-debug-panel { + padding: 1.15rem; +} + +.wpp-debug-panel { + background: linear-gradient(180deg, rgba(244, 248, 249, 0.96), rgba(232, 240, 242, 0.96)); +} + +.wpp-stack { + display: grid; + gap: 0.85rem; +} + +.wpp-stack--tight { + gap: 0.55rem; +} + +.wpp-grid { + display: grid; + gap: 1rem; +} + +.wpp-grid--two { + grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); +} + +.wpp-grid--three { + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); +} + +.wpp-header-row, +.wpp-action-row, +.wpp-meta-row, +.wpp-chip-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; +} + +.wpp-header-row { + justify-content: space-between; +} + +.wpp-chip-row { + gap: 0.5rem; +} + +.wpp-eyebrow { + margin: 0; + color: var(--wpp-accent); + font-size: 0.78rem; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.wpp-title { + margin: 0; + font-family: var(--wpp-font-display); + font-size: clamp(2rem, 4vw, 3.4rem); + line-height: 0.96; + letter-spacing: 0.01em; +} + +.wpp-subtitle, +.wpp-copy, +.wpp-muted { + margin: 0; + color: var(--wpp-ink-muted); +} + +.wpp-subtitle { + max-width: 52rem; + font-size: 1rem; + line-height: 1.55; +} + +.wpp-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.5rem 0.8rem; + border: 1px solid var(--wpp-border); + border-radius: var(--wpp-radius-pill); + background: var(--wpp-surface-glass); + color: var(--wpp-ink); + font-size: 0.9rem; + font-weight: 700; +} + +.wpp-chip strong { + color: var(--wpp-ink-strong); +} + +.wpp-chip--accent { + border-color: transparent; + background: var(--wpp-accent-soft); + color: var(--wpp-accent); +} + +.wpp-button { + appearance: none; + border: 0; + border-radius: var(--wpp-radius-pill); + padding: 0.82rem 1.18rem; + cursor: pointer; + font-weight: 800; + letter-spacing: 0.01em; + color: #fffdf8; + background: linear-gradient(135deg, var(--wpp-accent) 0%, var(--wpp-accent-warm) 100%); + box-shadow: 0 14px 24px rgba(21, 35, 41, 0.15); + transition: transform 140ms ease, box-shadow 140ms ease, opacity 140ms ease; +} + +.wpp-button:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 16px 30px rgba(21, 35, 41, 0.2); +} + +.wpp-button:disabled { + cursor: default; + opacity: 0.56; + box-shadow: none; +} + +.wpp-button--secondary { + color: var(--wpp-ink-strong); + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(234, 240, 242, 0.98)); + border: 1px solid var(--wpp-border); +} + +.wpp-button--ghost { + color: var(--wpp-accent); + background: transparent; + border: 1px dashed var(--wpp-border-strong); + box-shadow: none; +} + +.wpp-field { + display: grid; + gap: 0.38rem; + color: var(--wpp-ink); + font-size: 0.96rem; +} + +.wpp-field-label { + font-size: 0.86rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--wpp-ink-muted); +} + +.wpp-field input, +.wpp-field textarea, +.wpp-field select { + width: 100%; + border: 1px solid var(--wpp-border); + border-radius: var(--wpp-radius-md); + padding: 0.82rem 0.95rem; + color: var(--wpp-ink-strong); + background: rgba(255, 255, 255, 0.78); + outline: none; + transition: border-color 120ms ease, box-shadow 120ms ease, background-color 120ms ease; +} + +.wpp-field input:focus, +.wpp-field textarea:focus, +.wpp-field select:focus { + border-color: var(--wpp-accent); + box-shadow: 0 0 0 4px var(--wpp-accent-soft); + background: rgba(255, 255, 255, 0.96); +} + +.wpp-field textarea { + min-height: 7.2rem; + resize: vertical; +} + +.wpp-section-title { + margin: 0; + font-size: 1.08rem; + font-weight: 800; +} + +.wpp-section-copy { + margin: 0; + color: var(--wpp-ink-muted); + line-height: 1.5; +} + +.wpp-player-list, +.wpp-answer-grid, +.wpp-leaderboard { + display: grid; + gap: 0.75rem; +} + +.wpp-player-pill, +.wpp-answer-pill { + display: flex; + position: relative; + overflow: hidden; + justify-content: space-between; + gap: 1rem; + align-items: center; + padding: 0.86rem 1rem; + border: 1px solid var(--wpp-border); + border-radius: var(--wpp-radius-md); + background: rgba(255, 255, 255, 0.62); +} + +.wpp-player-pill::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 5px; + background: var(--wpp-player-tone, transparent); +} + +.wpp-player-pill__identity { + display: flex; + align-items: center; + gap: 0.7rem; + min-width: 0; +} + +.wpp-player-pill__badge-stack, +.wpp-presenter-player__badge-stack { + display: grid; + justify-items: center; + gap: 0.35rem; +} + +.wpp-player-pill__badge { + display: grid; + place-items: center; + width: 2.2rem; + height: 2.2rem; + border-radius: 50%; + color: #fffdf8; + font-weight: 900; + background: linear-gradient(135deg, var(--wpp-player-tone, var(--wpp-accent)) 0%, rgba(17, 34, 42, 0.82) 100%); +} + +.wpp-player-pill__icon, +.wpp-presenter-player__icon { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.4rem; + padding: 0.18rem 0.45rem; + border: 1px solid rgba(17, 34, 42, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.78); + color: var(--wpp-player-tone, var(--wpp-accent)); + font-size: 0.68rem; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.wpp-player-pill__name { + font-weight: 800; +} + +.wpp-player-pill__meta { + color: var(--wpp-ink-muted); + font-size: 0.92rem; +} + +.wpp-player-pill--active { + border-color: var(--wpp-accent); + background: linear-gradient(135deg, var(--wpp-accent-soft) 0%, rgba(255, 255, 255, 0.9) 100%); +} + +.wpp-player-pill[data-tone="ember"] { + --wpp-player-tone: #c46345; +} + +.wpp-player-pill[data-tone="lagoon"] { + --wpp-player-tone: #1e7b82; +} + +.wpp-player-pill[data-tone="gold"] { + --wpp-player-tone: #b88412; +} + +.wpp-player-pill[data-tone="sage"] { + --wpp-player-tone: #4f8b65; +} + +.wpp-player-pill[data-tone="coral"] { + --wpp-player-tone: #cc6a5e; +} + +.wpp-player-scene { + display: grid; + gap: 1rem; + padding: 1.25rem; + background: + radial-gradient(circle at top left, rgba(220, 241, 236, 0.9), transparent 40%), + radial-gradient(circle at bottom right, rgba(255, 224, 195, 0.82), transparent 44%), + linear-gradient(160deg, rgba(249, 252, 250, 0.98), rgba(255, 245, 236, 0.94)); +} + +.wpp-player-scene--join { + background: + radial-gradient(circle at top left, rgba(255, 236, 210, 0.92), transparent 38%), + radial-gradient(circle at right center, rgba(212, 240, 234, 0.8), transparent 42%), + linear-gradient(160deg, rgba(255, 251, 245, 0.98), rgba(242, 249, 248, 0.96)); +} + +.wpp-player-scene--lobby { + background: + radial-gradient(circle at top left, rgba(214, 240, 231, 0.92), transparent 40%), + radial-gradient(circle at bottom right, rgba(246, 206, 168, 0.76), transparent 42%), + linear-gradient(160deg, rgba(246, 252, 248, 0.98), rgba(255, 246, 239, 0.96)); +} + +.wpp-player-scene[data-scene-theme="player-boarding"] { + background: + radial-gradient(circle at top left, rgba(255, 236, 210, 0.92), transparent 38%), + radial-gradient(circle at right center, rgba(212, 240, 234, 0.8), transparent 42%), + linear-gradient(160deg, rgba(255, 251, 245, 0.98), rgba(242, 249, 248, 0.96)); +} + +.wpp-player-scene[data-scene-theme="player-ready"] { + background: + radial-gradient(circle at top left, rgba(214, 240, 231, 0.92), transparent 40%), + radial-gradient(circle at bottom right, rgba(246, 206, 168, 0.76), transparent 42%), + linear-gradient(160deg, rgba(246, 252, 248, 0.98), rgba(255, 246, 239, 0.96)); +} + +.wpp-player-scene[data-scene-theme="player-holding"] { + background: + radial-gradient(circle at top left, rgba(255, 229, 213, 0.9), transparent 38%), + radial-gradient(circle at bottom right, rgba(215, 237, 230, 0.8), transparent 44%), + linear-gradient(160deg, rgba(252, 248, 244, 0.98), rgba(242, 248, 247, 0.95)); +} + +.wpp-player-scene__copy, +.wpp-player-scene__rail { + display: grid; + gap: 0.9rem; +} + +.wpp-player-scene__headline { + margin: 0; + font-family: var(--wpp-font-display); + font-size: clamp(2.4rem, 9vw, 4.2rem); + line-height: 0.95; + text-wrap: balance; +} + +.wpp-scene-ornament { + display: inline-flex; + width: fit-content; + margin: 0; + padding: 0.35rem 0.7rem; + border: 1px solid rgba(17, 34, 42, 0.1); + border-radius: 999px; + background: rgba(255, 255, 255, 0.68); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6); + color: var(--wpp-ink-muted); + font-size: 0.68rem; + font-weight: 800; + letter-spacing: 0.18em; + line-height: 1; + text-transform: uppercase; +} + +.wpp-player-scene > *, +.wpp-player-active-scene > *, +.wpp-presenter-scene > * { + position: relative; + z-index: 1; +} + +.wpp-player-scene::after, +.wpp-player-active-scene::after, +.wpp-presenter-scene::after { + content: ""; + position: absolute; + inset: 0.8rem; + border-radius: calc(var(--wpp-radius-xl) - 0.45rem); + opacity: var(--wpp-scene-ornament-opacity, 0); + background: var(--wpp-scene-ornament-overlay, none); + mix-blend-mode: screen; + pointer-events: none; + z-index: 0; +} + +.wpp-player-scene[data-scene-ornament="aurora-arc"], +.wpp-player-active-scene[data-scene-ornament="aurora-arc"], +.wpp-presenter-scene[data-scene-ornament="aurora-arc"] { + --wpp-scene-ornament-opacity: 0.58; + --wpp-scene-ornament-overlay: + radial-gradient(circle at 12% 18%, rgba(170, 230, 223, 0.5), transparent 24%), + radial-gradient(circle at 82% 22%, rgba(255, 219, 175, 0.42), transparent 28%), + linear-gradient(145deg, rgba(255, 255, 255, 0) 36%, rgba(214, 244, 239, 0.5) 48%, rgba(255, 255, 255, 0) 62%); +} + +.wpp-player-scene[data-scene-ornament="constellation-dust"], +.wpp-player-active-scene[data-scene-ornament="constellation-dust"], +.wpp-presenter-scene[data-scene-ornament="constellation-dust"] { + --wpp-scene-ornament-opacity: 0.52; + --wpp-scene-ornament-overlay: + radial-gradient(circle at 18% 28%, rgba(255, 255, 255, 0.7), transparent 5%), + radial-gradient(circle at 76% 18%, rgba(255, 239, 203, 0.66), transparent 6%), + radial-gradient(circle at 58% 68%, rgba(212, 241, 236, 0.6), transparent 8%), + linear-gradient(125deg, rgba(255, 255, 255, 0) 40%, rgba(225, 239, 242, 0.42) 52%, rgba(255, 255, 255, 0) 65%); +} + +.wpp-player-scene[data-scene-ornament="harbor-flare"], +.wpp-player-active-scene[data-scene-ornament="harbor-flare"], +.wpp-presenter-scene[data-scene-ornament="harbor-flare"] { + --wpp-scene-ornament-opacity: 0.62; + --wpp-scene-ornament-overlay: + radial-gradient(circle at 86% 18%, rgba(255, 206, 148, 0.48), transparent 24%), + radial-gradient(circle at 20% 78%, rgba(111, 198, 194, 0.28), transparent 26%), + linear-gradient(135deg, rgba(255, 255, 255, 0) 30%, rgba(255, 228, 204, 0.5) 45%, rgba(255, 255, 255, 0) 58%); +} + +.wpp-player-scene[data-scene-ornament="signal-bloom"], +.wpp-player-active-scene[data-scene-ornament="signal-bloom"], +.wpp-presenter-scene[data-scene-ornament="signal-bloom"] { + --wpp-scene-ornament-opacity: 0.54; + --wpp-scene-ornament-overlay: + radial-gradient(circle at 50% 34%, rgba(255, 240, 216, 0.56), transparent 20%), + linear-gradient(90deg, rgba(255, 255, 255, 0) 46%, rgba(246, 207, 152, 0.42) 50%, rgba(255, 255, 255, 0) 54%), + linear-gradient(180deg, rgba(255, 255, 255, 0) 42%, rgba(170, 228, 218, 0.34) 50%, rgba(255, 255, 255, 0) 58%); +} + +.wpp-player-scene[data-scene-ornament="sunburst-ribbon"], +.wpp-player-active-scene[data-scene-ornament="sunburst-ribbon"], +.wpp-presenter-scene[data-scene-ornament="sunburst-ribbon"] { + --wpp-scene-ornament-opacity: 0.56; + --wpp-scene-ornament-overlay: + radial-gradient(circle at 84% 20%, rgba(255, 217, 153, 0.5), transparent 26%), + linear-gradient(122deg, rgba(255, 255, 255, 0) 38%, rgba(255, 224, 183, 0.48) 50%, rgba(255, 255, 255, 0) 60%), + linear-gradient(18deg, rgba(255, 255, 255, 0) 36%, rgba(207, 239, 229, 0.34) 48%, rgba(255, 255, 255, 0) 58%); +} + +.wpp-player-active-scene { + display: grid; + gap: 1rem; + padding: 1.25rem; + background: + radial-gradient(circle at top left, rgba(255, 231, 214, 0.92), transparent 38%), + radial-gradient(circle at bottom right, rgba(205, 235, 229, 0.82), transparent 42%), + linear-gradient(160deg, rgba(255, 252, 246, 0.98), rgba(243, 248, 247, 0.95)); +} + +.wpp-player-active-scene--lie { + background: + radial-gradient(circle at top left, rgba(255, 223, 198, 0.94), transparent 36%), + radial-gradient(circle at right center, rgba(212, 239, 231, 0.82), transparent 40%), + linear-gradient(160deg, rgba(255, 249, 242, 0.98), rgba(240, 248, 245, 0.95)); +} + +.wpp-player-active-scene--guess { + background: + radial-gradient(circle at top left, rgba(214, 238, 230, 0.94), transparent 38%), + radial-gradient(circle at bottom right, rgba(255, 226, 206, 0.82), transparent 42%), + linear-gradient(160deg, rgba(246, 252, 249, 0.98), rgba(255, 246, 239, 0.95)); +} + +.wpp-player-active-scene--reveal { + background: + radial-gradient(circle at top left, rgba(205, 234, 229, 0.94), transparent 36%), + radial-gradient(circle at bottom right, rgba(249, 215, 184, 0.78), transparent 44%), + linear-gradient(160deg, rgba(243, 250, 249, 0.98), rgba(255, 244, 236, 0.94)); +} + +.wpp-player-active-scene--result { + background: + radial-gradient(circle at top left, rgba(255, 236, 205, 0.94), transparent 38%), + radial-gradient(circle at bottom right, rgba(216, 238, 228, 0.82), transparent 42%), + linear-gradient(160deg, rgba(255, 251, 244, 0.98), rgba(241, 248, 246, 0.95)); +} + +.wpp-player-active-scene[data-scene-theme="player-ink"] { + background: + radial-gradient(circle at top left, rgba(255, 223, 198, 0.94), transparent 36%), + radial-gradient(circle at right center, rgba(212, 239, 231, 0.82), transparent 40%), + linear-gradient(160deg, rgba(255, 249, 242, 0.98), rgba(240, 248, 245, 0.95)); +} + +.wpp-player-active-scene[data-scene-theme="player-choices"] { + background: + radial-gradient(circle at top left, rgba(214, 238, 230, 0.94), transparent 38%), + radial-gradient(circle at bottom right, rgba(255, 226, 206, 0.82), transparent 42%), + linear-gradient(160deg, rgba(246, 252, 249, 0.98), rgba(255, 246, 239, 0.95)); +} + +.wpp-player-active-scene[data-scene-theme="player-ripple"] { + background: + radial-gradient(circle at top left, rgba(205, 234, 229, 0.94), transparent 36%), + radial-gradient(circle at bottom right, rgba(249, 215, 184, 0.78), transparent 44%), + linear-gradient(160deg, rgba(243, 250, 249, 0.98), rgba(255, 244, 236, 0.94)); +} + +.wpp-player-active-scene[data-scene-theme="player-pennant"] { + background: + radial-gradient(circle at top left, rgba(255, 236, 205, 0.94), transparent 38%), + radial-gradient(circle at bottom right, rgba(216, 238, 228, 0.82), transparent 42%), + linear-gradient(160deg, rgba(255, 251, 244, 0.98), rgba(241, 248, 246, 0.95)); +} + +.wpp-player-active-scene__copy, +.wpp-player-active-scene__rail, +.wpp-player-active-scene__composer { + display: grid; + gap: 0.9rem; +} + +.wpp-player-active-scene__headline { + margin: 0; + font-family: var(--wpp-font-display); + font-size: clamp(2.5rem, 9vw, 4.6rem); + line-height: 0.94; + text-wrap: balance; +} + +.wpp-player-active-scene__composer { + padding: 1rem; + border: 1px solid rgba(17, 34, 42, 0.08); + border-radius: var(--wpp-radius-lg); + background: rgba(255, 255, 255, 0.76); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.62); +} + +.wpp-sync-status-card { + display: grid; + gap: 0.9rem; + padding: 1.1rem 1.15rem; + border-color: rgba(138, 95, 16, 0.28); + background: + radial-gradient(circle at top left, rgba(255, 236, 205, 0.88), transparent 38%), + linear-gradient(160deg, rgba(255, 250, 240, 0.98), rgba(246, 249, 250, 0.96)); +} + +.wpp-sync-status-card--danger { + border-color: rgba(180, 35, 24, 0.28); + background: + radial-gradient(circle at top left, rgba(252, 224, 220, 0.92), transparent 40%), + linear-gradient(160deg, rgba(255, 246, 244, 0.98), rgba(248, 242, 241, 0.96)); +} + +.wpp-sync-status-card__copy { + display: grid; + gap: 0.45rem; +} + +.wpp-player-support-panel { + background: linear-gradient(180deg, rgba(247, 250, 251, 0.98), rgba(237, 243, 245, 0.96)); +} + +.wpp-presenter-scene { + display: grid; + gap: 1.1rem; + grid-template-columns: minmax(0, 1.7fr) minmax(16rem, 0.9fr); + padding: 1.45rem; + background: + radial-gradient(circle at top left, rgba(255, 237, 227, 0.9), transparent 38%), + radial-gradient(circle at bottom right, rgba(208, 238, 232, 0.75), transparent 40%), + linear-gradient(145deg, rgba(255, 252, 246, 0.98), rgba(248, 242, 236, 0.94)); +} + +.wpp-presenter-scene--lobby { + background: + radial-gradient(circle at top left, rgba(218, 241, 234, 0.92), transparent 38%), + radial-gradient(circle at right center, rgba(255, 223, 194, 0.84), transparent 42%), + linear-gradient(145deg, rgba(247, 252, 249, 0.98), rgba(255, 246, 238, 0.96)); +} + +.wpp-presenter-scene[data-scene-theme="host-atrium"] { + background: + radial-gradient(circle at top left, rgba(218, 241, 234, 0.92), transparent 38%), + radial-gradient(circle at right center, rgba(255, 223, 194, 0.84), transparent 42%), + linear-gradient(145deg, rgba(247, 252, 249, 0.98), rgba(255, 246, 238, 0.96)); +} + +.wpp-presenter-scene[data-scene-theme="host-spotlight"] { + background: + radial-gradient(circle at top left, rgba(255, 237, 227, 0.9), transparent 38%), + radial-gradient(circle at bottom right, rgba(208, 238, 232, 0.75), transparent 40%), + linear-gradient(145deg, rgba(255, 252, 246, 0.98), rgba(248, 242, 236, 0.94)); +} + +.wpp-presenter-scene[data-scene-theme="host-signal"] { + background: + radial-gradient(circle at top left, rgba(244, 237, 202, 0.9), transparent 38%), + radial-gradient(circle at bottom right, rgba(213, 236, 228, 0.78), transparent 42%), + linear-gradient(145deg, rgba(253, 251, 241, 0.98), rgba(246, 240, 230, 0.95)); +} + +.wpp-presenter-scene[data-scene-theme="host-verdict"] { + background: + radial-gradient(circle at top left, rgba(206, 236, 231, 0.92), transparent 38%), + radial-gradient(circle at bottom right, rgba(248, 219, 193, 0.8), transparent 42%), + linear-gradient(145deg, rgba(244, 250, 249, 0.98), rgba(255, 245, 237, 0.95)); +} + +.wpp-presenter-scene[data-scene-theme="host-podium"] { + background: + radial-gradient(circle at top left, rgba(255, 234, 204, 0.92), transparent 38%), + radial-gradient(circle at bottom right, rgba(214, 238, 231, 0.8), transparent 42%), + linear-gradient(145deg, rgba(255, 251, 244, 0.98), rgba(243, 248, 246, 0.95)); +} + +.wpp-presenter-scene[data-scene-theme="host-finale"] { + background: + radial-gradient(circle at top left, rgba(255, 226, 204, 0.94), transparent 34%), + radial-gradient(circle at right center, rgba(209, 233, 228, 0.82), transparent 42%), + radial-gradient(circle at bottom right, rgba(250, 214, 176, 0.74), transparent 38%), + linear-gradient(145deg, rgba(255, 249, 241, 0.98), rgba(242, 248, 246, 0.95)); +} + +.wpp-presenter-scene__copy, +.wpp-presenter-scene__rail, +.wpp-presenter-player-grid { + display: grid; + gap: 0.9rem; +} + +.wpp-presenter-scene--lobby .wpp-presenter-scene__question { + font-size: clamp(3.2rem, 10vw, 6rem); + letter-spacing: 0.08em; +} + +.wpp-presenter-scene__question { + margin: 0; + font-family: var(--wpp-font-display); + font-size: clamp(2.6rem, 6vw, 5rem); + line-height: 0.94; + letter-spacing: 0.01em; + text-wrap: balance; +} + +.wpp-presenter-callout { + display: grid; + gap: 0.55rem; + padding: 1rem 1.05rem; + border: 1px solid rgba(17, 34, 42, 0.08); + border-radius: var(--wpp-radius-lg); + background: rgba(255, 255, 255, 0.7); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.56); +} + +.wpp-presenter-callout--soft { + background: rgba(245, 248, 249, 0.82); +} + +.wpp-presenter-player-grid { + grid-template-columns: repeat(auto-fit, minmax(12.8rem, 1fr)); +} + +.wpp-presenter-player { + position: relative; + overflow: hidden; + display: grid; + gap: 1rem; + padding: 1rem; + border: 1px solid var(--wpp-border); + border-radius: var(--wpp-radius-lg); + background: rgba(255, 255, 255, 0.72); + box-shadow: var(--wpp-shadow-soft); +} + +.wpp-presenter-player::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 7px; + background: var(--wpp-player-tone, var(--wpp-accent)); +} + +.wpp-presenter-player[data-tone="ember"] { + --wpp-player-tone: #c46345; +} + +.wpp-presenter-player[data-tone="lagoon"] { + --wpp-player-tone: #1e7b82; +} + +.wpp-presenter-player[data-tone="gold"] { + --wpp-player-tone: #b88412; +} + +.wpp-presenter-player[data-tone="sage"] { + --wpp-player-tone: #4f8b65; +} + +.wpp-presenter-player[data-tone="coral"] { + --wpp-player-tone: #cc6a5e; +} + +.wpp-presenter-player__header { + display: flex; + justify-content: space-between; + gap: 0.75rem; + align-items: center; +} + +.wpp-presenter-player__badge { + display: grid; + place-items: center; + width: 3rem; + height: 3rem; + border-radius: 50%; + font-weight: 900; + color: #fffdf8; + background: linear-gradient(135deg, var(--wpp-player-tone, var(--wpp-accent)) 0%, rgba(17, 34, 42, 0.82) 100%); +} + +.wpp-presenter-player__icon { + min-width: 3rem; + background: rgba(255, 255, 255, 0.84); +} + +.wpp-presenter-player__name { + margin: 0; + font-size: 1.05rem; + font-weight: 900; +} + +.wpp-presenter-player__score { + margin: 0; + color: var(--wpp-ink-muted); + font-size: 0.95rem; +} + +.wpp-presenter-answer-grid, +.wpp-presenter-stat-grid { + display: grid; + gap: 0.9rem; +} + +.wpp-presenter-answer-grid { + grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); +} + +.wpp-presenter-stat-grid { + grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); +} + +.wpp-presenter-answer-card, +.wpp-presenter-stat-card { + display: grid; + gap: 0.65rem; + padding: 1rem; + border: 1px solid var(--wpp-border); + border-radius: var(--wpp-radius-lg); + background: rgba(255, 255, 255, 0.72); + box-shadow: var(--wpp-shadow-soft); +} + +.wpp-presenter-answer-card__badge { + display: inline-grid; + place-items: center; + width: 2.35rem; + height: 2.35rem; + border-radius: 50%; + font-weight: 900; + color: #fffdf8; + background: linear-gradient(135deg, var(--wpp-accent) 0%, var(--wpp-accent-warm) 100%); +} + +.wpp-presenter-stat-card__value { + margin: 0; + font-family: var(--wpp-font-display); + font-size: clamp(2rem, 4vw, 2.7rem); + line-height: 0.96; +} + +.wpp-backstage-panel { + background: linear-gradient(180deg, rgba(246, 249, 250, 0.98), rgba(236, 242, 244, 0.96)); +} + +.wpp-backstage-panel__section { + display: grid; + gap: 0.8rem; + padding-top: 0.8rem; + border-top: 1px dashed var(--wpp-border-strong); +} + +.wpp-answer-choice { + appearance: none; + width: 100%; + text-align: left; + border: 1px solid var(--wpp-border); + border-radius: var(--wpp-radius-md); + padding: 0.95rem 1rem; + background: rgba(255, 255, 255, 0.76); + color: var(--wpp-ink-strong); + cursor: pointer; + transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease; +} + +.wpp-answer-choice:hover:not(:disabled) { + transform: translateY(-1px); + border-color: var(--wpp-accent); +} + +.wpp-answer-choice.is-active { + border-color: var(--wpp-accent); + background: var(--wpp-accent-soft); +} + +.wpp-empty-state, +.wpp-inline-note { + margin: 0; + padding: 0.85rem 0.95rem; + border-radius: var(--wpp-radius-md); + background: rgba(255, 255, 255, 0.58); + color: var(--wpp-ink-muted); +} + +.wpp-error { + margin: 0; + padding: 0.85rem 0.95rem; + border: 1px solid rgba(180, 35, 24, 0.16); + border-radius: var(--wpp-radius-md); + background: var(--wpp-danger-soft); + color: var(--wpp-danger); + font-weight: 700; +} + +.wpp-code-block { + margin: 0; + overflow: auto; + border-radius: var(--wpp-radius-md); + padding: 0.9rem; + background: #142328; + color: #ecf8f8; + font-size: 0.88rem; +} + +.wpp-reveal-list { + display: grid; + gap: 0.65rem; + margin: 0; + padding: 0; + list-style: none; +} + +.wpp-reveal-list li { + padding: 0.78rem 0.92rem; + border-radius: var(--wpp-radius-md); + background: rgba(255, 255, 255, 0.58); +} + +.wpp-fine-print { + margin: 0; + font-size: 0.86rem; + color: var(--wpp-ink-muted); +} + +@keyframes wpp-fade-in { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (min-width: 720px) { + .wpp-player-scene { + grid-template-columns: minmax(0, 1.2fr) minmax(15rem, 0.85fr); + } + + .wpp-player-active-scene { + grid-template-columns: minmax(0, 1.35fr) minmax(16rem, 0.88fr); + } +} + +@media (max-width: 720px) { + .wpp-presenter-scene { + grid-template-columns: 1fr; + } + + .wpp-presenter-scene__question { + font-size: clamp(2.1rem, 12vw, 3rem); + } + + .wpp-hero-card, + .wpp-card, + .wpp-debug-panel { + padding: 1rem; + } + + .wpp-title { + font-size: 2rem; + } } diff --git a/frontend/src/api/angular-client.ts b/frontend/src/api/angular-client.ts index 84686b1..caedead 100644 --- a/frontend/src/api/angular-client.ts +++ b/frontend/src/api/angular-client.ts @@ -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>; - getSession(code: string): Promise>; + createSession(): Promise>; + getSession(code: string, options?: SessionDetailRequestOptions): Promise>; joinSession(payload: JoinSessionRequest): Promise>; startRound(code: string, payload: StartRoundRequest): Promise>; showQuestion(code: string): Promise>; @@ -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(call: () => Promise, mapper: (payload: unknown) => T): Promise> { let payload: unknown; try { @@ -128,12 +143,14 @@ export function createAngularApiClient(http: AngularHttpClientLike, baseUrl = '' return { health: () => wrap(() => http.get(buildUrl(baseUrl, '/healthz'), { withCredentials: true }), mapHealthResponse), - getSession: (code: string) => + createSession: () => wrap( - () => - http.get(buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`), { - withCredentials: true - }), + () => http.post(buildUrl(baseUrl, '/lobby/sessions/create'), {}, { withCredentials: true }), + mapCreateSessionResponse + ), + getSession: (code: string, options?: SessionDetailRequestOptions) => + wrap( + () => http.get(buildUrl(baseUrl, buildSessionDetailPath(code, options)), { withCredentials: true }), mapSessionDetailResponse ), joinSession: (payload: JoinSessionRequest) => diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 02b3775..e97ecca 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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>; - getSession(code: string): Promise>; + createSession(): Promise>; + getSession(code: string, options?: SessionDetailRequestOptions): Promise>; joinSession(payload: JoinSessionRequest): Promise>; startRound(code: string, payload: StartRoundRequest): Promise>; showQuestion(code: string): Promise>; @@ -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 { + 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( path: string, method: 'GET' | 'POST', mapper: (payload: unknown) => T, payload?: unknown ): Promise> { + 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('/healthz', 'GET', mapHealthResponse), - getSession: (code: string) => + createSession: () => + request( + '/lobby/sessions/create', + 'POST', + mapCreateSessionResponse, + {} + ), + getSession: (code: string, options?: SessionDetailRequestOptions) => request( - `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`, + buildSessionDetailPath(code, options), 'GET', mapSessionDetailResponse ), diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index fe7c89d..aca900a 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -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 { @@ -52,6 +54,17 @@ function readNumber(record: Record, key: string, path: string): return value; } +function readNullableString(record: Record, 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, key: string, path: string): boolean { const value = record[key]; if (!isBoolean(value)) { @@ -71,6 +84,63 @@ function readNullableNumber(record: Record, 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 = {}; + 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 = {}; + 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, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 926f28a..ffe4188 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; + audio_urls: Record; + 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; diff --git a/frontend/src/spa/vertical-slice.ts b/frontend/src/spa/vertical-slice.ts index a6bdac4..29c3da4 100644 --- a/frontend/src/spa/vertical-slice.ts +++ b/frontend/src/spa/vertical-slice.ts @@ -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; + hydrateLobby(sessionCode: string, options?: SessionDetailRequestOptions): Promise; joinLobby(sessionCode: string, nickname: string): Promise; startRound(sessionCode: string, categorySlug: string): Promise; } @@ -48,7 +48,7 @@ export function createVerticalSliceController( const normalizeCode = (value: string): string => value.trim().toUpperCase(); - async function hydrateLobby(sessionCode: string): Promise { + async function hydrateLobby(sessionCode: string, options?: SessionDetailRequestOptions): Promise { 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 { diff --git a/frontend/tests/angular-api-client.test.ts b/frontend/tests/angular-api-client.test.ts index 328d4f8..7ac482e 100644 --- a/frontend/tests/angular-api-client.test.ts +++ b/frontend/tests/angular-api-client.test.ts @@ -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({ diff --git a/frontend/tests/vertical-slice.test.ts b/frontend/tests/vertical-slice.test.ts index f1c94f8..3b3d540 100644 --- a/frontend/tests/vertical-slice.test.ts +++ b/frontend/tests/vertical-slice.test.ts @@ -9,6 +9,7 @@ import type { ApiClient } from '../src/api/client'; function makeApiMock(overrides?: Partial): ApiClient { const base: ApiClient = { health: vi.fn(), + createSession: vi.fn(), getSession: vi.fn().mockResolvedValue({ ok: true, status: 200, diff --git a/fupogfakta/admin.py b/fupogfakta/admin.py index e60566c..13638d1 100644 --- a/fupogfakta/admin.py +++ b/fupogfakta/admin.py @@ -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): diff --git a/fupogfakta/bootstrap.py b/fupogfakta/bootstrap.py new file mode 100644 index 0000000..b5839f9 --- /dev/null +++ b/fupogfakta/bootstrap.py @@ -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, + ) diff --git a/fupogfakta/management/__init__.py b/fupogfakta/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fupogfakta/management/__init__.py @@ -0,0 +1 @@ + diff --git a/fupogfakta/management/commands/__init__.py b/fupogfakta/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/fupogfakta/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/fupogfakta/management/commands/bootstrap_mvp.py b/fupogfakta/management/commands/bootstrap_mvp.py new file mode 100644 index 0000000..b004dbf --- /dev/null +++ b/fupogfakta/management/commands/bootstrap_mvp.py @@ -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)}", + ] + ) + ) + ) diff --git a/fupogfakta/management/commands/smoke_staging.py b/fupogfakta/management/commands/smoke_staging.py new file mode 100644 index 0000000..625a44b --- /dev/null +++ b/fupogfakta/management/commands/smoke_staging.py @@ -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 ', + '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}')) diff --git a/fupogfakta/migrations/0008_questionlie.py b/fupogfakta/migrations/0008_questionlie.py new file mode 100644 index 0000000..1cded8f --- /dev/null +++ b/fupogfakta/migrations/0008_questionlie.py @@ -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')}, + }, + ), + ] diff --git a/fupogfakta/migrations/0009_question_scene_ornament.py b/fupogfakta/migrations/0009_question_scene_ornament.py new file mode 100644 index 0000000..ca1c48b --- /dev/null +++ b/fupogfakta/migrations/0009_question_scene_ornament.py @@ -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, + ), + ), + ] diff --git a/fupogfakta/models.py b/fupogfakta/models.py index 3668e21..5adb3cc 100644 --- a/fupogfakta/models.py +++ b/fupogfakta/models.py @@ -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" diff --git a/fupogfakta/payloads.py b/fupogfakta/payloads.py index 15fce65..cc2cd3a 100644 --- a/fupogfakta/payloads.py +++ b/fupogfakta/payloads.py @@ -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, ), } diff --git a/fupogfakta/services.py b/fupogfakta/services.py index 7ef9a6f..e002aff 100644 --- a/fupogfakta/services.py +++ b/fupogfakta/services.py @@ -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") diff --git a/fupogfakta/tests.py b/fupogfakta/tests.py index 6978ad5..55e2eaa 100644 --- a/fupogfakta/tests.py +++ b/fupogfakta/tests.py @@ -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") diff --git a/fupogfakta/views.py b/fupogfakta/views.py index b8e4ee0..5d52bbe 100644 --- a/fupogfakta/views.py +++ b/fupogfakta/views.py @@ -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, + } + ) diff --git a/infra/env/.env.dev.example b/infra/env/.env.dev.example new file mode 100644 index 0000000..783b75c --- /dev/null +++ b/infra/env/.env.dev.example @@ -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 diff --git a/infra/env/.env.prod.example b/infra/env/.env.prod.example index cd1f4d6..ed645d4 100644 --- a/infra/env/.env.prod.example +++ b/infra/env/.env.prod.example @@ -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 diff --git a/infra/env/.env.staging.example b/infra/env/.env.staging.example index 0f02781..55f1aa6 100644 --- a/infra/env/.env.staging.example +++ b/infra/env/.env.staging.example @@ -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 diff --git a/infra/env/.env.test.example b/infra/env/.env.test.example index 5a15b3b..dba6b9c 100644 --- a/infra/env/.env.test.example +++ b/infra/env/.env.test.example @@ -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 diff --git a/infra/staging/README.md b/infra/staging/README.md index d3b7c91..58d52fa 100644 --- a/infra/staging/README.md +++ b/infra/staging/README.md @@ -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=` + +`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). diff --git a/infra/staging/deploy_and_smoke_staging.sh b/infra/staging/deploy_and_smoke_staging.sh new file mode 100755 index 0000000..61f5853 --- /dev/null +++ b/infra/staging/deploy_and_smoke_staging.sh @@ -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}" diff --git a/infra/staging/run_mvp_smoke.sh b/infra/staging/run_mvp_smoke.sh new file mode 100755 index 0000000..b4143bf --- /dev/null +++ b/infra/staging/run_mvp_smoke.sh @@ -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" diff --git a/infra/staging/smoke_suite.sh b/infra/staging/smoke_suite.sh index 34bd546..119dfc0 100755 --- a/infra/staging/smoke_suite.sh +++ b/infra/staging/smoke_suite.sh @@ -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" diff --git a/lobby/http.py b/lobby/http.py new file mode 100644 index 0000000..ffb2984 --- /dev/null +++ b/lobby/http.py @@ -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() diff --git a/lobby/management/commands/smoke_staging.py b/lobby/management/commands/smoke_staging.py index 8de8782..5dcdff3 100644 --- a/lobby/management/commands/smoke_staging.py +++ b/lobby/management/commands/smoke_staging.py @@ -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 ", - "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"] diff --git a/lobby/tests.py b/lobby/tests.py index 6e5140b..6dd83f9 100644 --- a/lobby/tests.py +++ b/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): diff --git a/lobby/urls.py b/lobby/urls.py index 7a44918..f3a6f27 100644 --- a/lobby/urls.py +++ b/lobby/urls.py @@ -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/", 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/", views.session_detail, name="session_detail"), - path("sessions//rounds/start", views.start_round, name="start_round"), - path("sessions//questions/show", views.show_question, name="show_question"), + path("sessions//rounds/start", gameplay_views.start_round, name="start_round"), + path("sessions//questions/show", gameplay_views.show_question, name="show_question"), path( "sessions//questions//lies/submit", - views.submit_lie, + gameplay_views.submit_lie, name="submit_lie", ), path( "sessions//questions//answers/mix", - views.mix_answers, + gameplay_views.mix_answers, name="mix_answers", ), path( "sessions//questions//guesses/submit", - views.submit_guess, + gameplay_views.submit_guess, name="submit_guess", ), path( "sessions//questions//scores/calculate", - views.calculate_scores, + gameplay_views.calculate_scores, name="calculate_scores", ), - path("sessions//scoreboard", views.reveal_scoreboard, name="reveal_scoreboard"), - path("sessions//finish", views.finish_game, name="finish_game"), - path("sessions//rounds/next", views.start_next_round, name="start_next_round"), + path("sessions//scoreboard", gameplay_views.reveal_scoreboard, name="reveal_scoreboard"), + path("sessions//finish", gameplay_views.finish_game, name="finish_game"), + path("sessions//rounds/next", gameplay_views.start_next_round, name="start_next_round"), ] diff --git a/lobby/views.py b/lobby/views.py index 251888d..c187c72 100644 --- a/lobby/views.py +++ b/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, - } - ) diff --git a/partyhub/settings.py b/partyhub/settings.py index 9ea5b48..75e4d33 100644 --- a/partyhub/settings.py +++ b/partyhub/settings.py @@ -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' diff --git a/partyhub/urls.py b/partyhub/urls.py index dbc31a6..dc6c186 100644 --- a/partyhub/urls.py +++ b/partyhub/urls.py @@ -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) diff --git a/realtime/admin.py b/realtime/admin.py index 8c38f3f..0460f65 100644 --- a/realtime/admin.py +++ b/realtime/admin.py @@ -1,3 +1 @@ -from django.contrib import admin - -# Register your models here. +"""Admin registrations for the realtime app.""" diff --git a/realtime/consumers.py b/realtime/consumers.py index a3149c7..786ce66 100644 --- a/realtime/consumers.py +++ b/realtime/consumers.py @@ -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() diff --git a/realtime/models.py b/realtime/models.py index 71a8362..23e67cf 100644 --- a/realtime/models.py +++ b/realtime/models.py @@ -1,3 +1 @@ -from django.db import models - -# Create your models here. +"""Database models for the realtime app.""" diff --git a/realtime/routing.py b/realtime/routing.py index 3137721..346cfd4 100644 --- a/realtime/routing.py +++ b/realtime/routing.py @@ -3,5 +3,5 @@ from django.urls import re_path from . import consumers websocket_urlpatterns = [ - re_path(r"^ws/game/(?P[A-Z0-9]{4,8})/$", consumers.GameConsumer.as_asgi()), + re_path(r"ws/game/(?P[A-Za-z0-9]{4,8})/$", consumers.GameConsumer.as_asgi()), ] diff --git a/realtime/tests.py b/realtime/tests.py index e87677e..af0bc8c 100644 --- a/realtime/tests.py +++ b/realtime/tests.py @@ -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( diff --git a/realtime/views.py b/realtime/views.py index 91ea44a..45245fb 100644 --- a/realtime/views.py +++ b/realtime/views.py @@ -1,3 +1 @@ -from django.shortcuts import render - -# Create your views here. +"""HTTP views for the realtime app.""" diff --git a/scripts/check_i18n_drift.py b/scripts/check_i18n_drift.py index a8bd92d..9c5599a 100755 --- a/scripts/check_i18n_drift.py +++ b/scripts/check_i18n_drift.py @@ -9,7 +9,6 @@ from __future__ import annotations import json from pathlib import Path -import sys from typing import Any diff --git a/scripts/docker_dev_entrypoint.sh b/scripts/docker_dev_entrypoint.sh new file mode 100644 index 0000000..8e55fdb --- /dev/null +++ b/scripts/docker_dev_entrypoint.sh @@ -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 diff --git a/scripts/run_local_mvp_smoke.sh b/scripts/run_local_mvp_smoke.sh new file mode 100755 index 0000000..897a368 --- /dev/null +++ b/scripts/run_local_mvp_smoke.sh @@ -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 diff --git a/scripts/serve_static_dir.mjs b/scripts/serve_static_dir.mjs new file mode 100644 index 0000000..21feb1f --- /dev/null +++ b/scripts/serve_static_dir.mjs @@ -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}`); +}); diff --git a/scripts/verify_mvp_release.sh b/scripts/verify_mvp_release.sh new file mode 100755 index 0000000..dc69c1e --- /dev/null +++ b/scripts/verify_mvp_release.sh @@ -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" diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json index 2b5d19a..56fed08 100644 --- a/shared/i18n/lobby.json +++ b/shared/i18n/lobby.json @@ -89,6 +89,10 @@ "en": "WPP Angular Shell", "da": "WPP Angular Shell" }, + "home_nav": { + "en": "Home", + "da": "Hjem" + }, "host_nav": { "en": "Host", "da": "Vært" @@ -100,6 +104,66 @@ "language_label": { "en": "Language", "da": "Sprog" + }, + "home_badge": { + "en": "Party Protocol", + "da": "Party Protocol" + }, + "home_title": { + "en": "Start or join a FupOgFakta session", + "da": "Start eller join en FupOgFakta-session" + }, + "home_intro": { + "en": "Hosts log in and create a new session. Players join with the session code from their phone or laptop.", + "da": "Værter logger ind og opretter en ny session. Spillere joiner med sessionskoden fra telefon eller computer." + }, + "host_card_title": { + "en": "Host a session", + "da": "Vær vært for en session" + }, + "host_card_body": { + "en": "Create a fresh session, control the round flow, and drive the game from the host screen.", + "da": "Opret en ny session, styr runde-flowet, og kør spillet fra værtsskærmen." + }, + "host_login_hint": { + "en": "Hosting requires a Django login. Use login first if create session says login required.", + "da": "Værtsrollen kræver Django-login. Brug login først, hvis opret session siger login kræves." + }, + "host_login": { + "en": "Login as host", + "da": "Log ind som vært" + }, + "create_session": { + "en": "Create session", + "da": "Opret session" + }, + "creating_session": { + "en": "Creating session…", + "da": "Opretter session…" + }, + "create_session_failed": { + "en": "Create session failed", + "da": "Kunne ikke oprette session" + }, + "player_card_title": { + "en": "Join as player", + "da": "Join som spiller" + }, + "player_card_body": { + "en": "Enter the session code and your nickname to join the active game from this device.", + "da": "Indtast sessionskoden og dit kaldenavn for at joine det aktive spil fra denne enhed." + }, + "join_session": { + "en": "Join session", + "da": "Join session" + }, + "joining_session": { + "en": "Joining session…", + "da": "Joiner session…" + }, + "join_session_failed": { + "en": "Join session failed", + "da": "Kunne ikke joine session" } }, "host": { @@ -182,6 +246,306 @@ "audio_locale_hint": { "en": "Host locale for audio references", "da": "Værtens locale for lydreferencer" + }, + "voice_on": { + "en": "Voice on", + "da": "Stemme til" + }, + "voice_off": { + "en": "Voice off", + "da": "Stemme fra" + }, + "replay_voice": { + "en": "Replay voice", + "da": "Afspil stemme igen" + }, + "voice_preview": { + "en": "Current voice cue", + "da": "Aktuel stemmetekst" + }, + "show_developer_state": { + "en": "Show developer state", + "da": "Vis udviklerstatus" + }, + "hide_developer_state": { + "en": "Hide developer state", + "da": "Skjul udviklerstatus" + }, + "players_label": { + "en": "Players", + "da": "Spillere" + }, + "round_actions_title": { + "en": "Backstage actions", + "da": "Backstage-handlinger" + }, + "round_actions_body": { + "en": "Keep the operational controls here so the projected presenter surface can stay clean.", + "da": "Hold de operationelle kontroller her, så den projicerede presenter-flade kan forblive ren." + }, + "no_host_action": { + "en": "No host action is available for this phase yet.", + "da": "Der er endnu ingen værtshandling til denne fase." + }, + "player_roster": { + "en": "Player roster", + "da": "Spilleroversigt" + }, + "player_roster_body": { + "en": "The presenter view keeps players visible even when the control surface is hidden.", + "da": "Presenter-visningen holder spillerne synlige, selv når kontrolfladen er skjult." + }, + "player_live": { + "en": "Live", + "da": "Live" + }, + "player_away": { + "en": "Away", + "da": "Væk" + }, + "no_players": { + "en": "No players have joined this session yet.", + "da": "Ingen spillere har joined denne session endnu." + }, + "reveal_title": { + "en": "Reveal", + "da": "Afsløring" + }, + "correct_answer": { + "en": "Correct answer", + "da": "Rigtigt svar" + }, + "lies_title": { + "en": "Lies", + "da": "Løgne" + }, + "guesses_title": { + "en": "Guesses", + "da": "Gæt" + }, + "lied_label": { + "en": "lied", + "da": "løj" + }, + "picked_label": { + "en": "picked", + "da": "valgte" + }, + "correct_label": { + "en": "correct", + "da": "korrekt" + }, + "fooled_by_label": { + "en": "fooled by", + "da": "narret af" + }, + "incorrect_label": { + "en": "incorrect", + "da": "forkert" + }, + "developer_state_title": { + "en": "Developer state", + "da": "Udviklerstatus" + }, + "developer_state_body": { + "en": "Raw diagnostics, manual state input, and phase artifacts stay here instead of dominating the presenter view.", + "da": "Rå diagnostik, manuel state-input og faseartefakter ligger her i stedet for at dominere presenter-visningen." + }, + "sync_transport_label": { + "en": "Sync transport", + "da": "Synk transport" + }, + "realtime_status_label": { + "en": "Realtime status", + "da": "Realtime-status" + }, + "last_event_label": { + "en": "Last event", + "da": "Seneste event" + }, + "presenter_control_title": { + "en": "Backstage control", + "da": "Backstage-kontrol" + }, + "presenter_scene_title_lobby": { + "en": "Room open", + "da": "Rummet er åbent" + }, + "presenter_scene_title": { + "en": "Question in play", + "da": "Spørgsmålet er i spil" + }, + "presenter_scene_title_guess": { + "en": "Answer mix in play", + "da": "Svarmixet er i spil" + }, + "presenter_scene_title_reveal": { + "en": "Round reveal", + "da": "Rundens afsløring" + }, + "presenter_scene_title_scoreboard": { + "en": "Scores on the board", + "da": "Scoren er på tavlen" + }, + "presenter_scene_title_finished": { + "en": "Final standings", + "da": "Endelige placeringer" + }, + "presenter_scene_body_lobby": { + "en": "Let the room gather around the join code while the roster fills in and the next round gets ready.", + "da": "Lad rummet samle sig om join-koden, mens spilleroversigten fyldes op, og næste runde bliver klar." + }, + "presenter_scene_body_lie": { + "en": "Keep the prompt on the main screen while players invent believable lies on their own devices.", + "da": "Hold spørgsmålet på hovedskærmen, mens spillerne finder på troværdige løgne på deres egne enheder." + }, + "presenter_scene_body_guess": { + "en": "Keep every option readable from the main screen while the room locks in a final guess.", + "da": "Hold alle svar læsbare på hovedskærmen, mens rummet låser sit endelige gæt fast." + }, + "presenter_scene_body_reveal": { + "en": "Show the truth first, then let the room track which bluffs landed and who got fooled.", + "da": "Vis sandheden først, og lad derefter rummet følge hvilke bluffs der landede, og hvem der blev narret." + }, + "presenter_scene_body_scoreboard": { + "en": "Let the scores breathe on the main screen before deciding whether to move on or close the game.", + "da": "Lad scoren få luft på hovedskærmen, før I beslutter om spillet skal videre eller lukkes." + }, + "presenter_scene_body_finished": { + "en": "Hold the closing standings on screen long enough for the room to take in the result.", + "da": "Hold slutstillingen på skærmen længe nok til, at rummet kan tage resultatet ind." + }, + "presenter_scene_next_step": { + "en": "Next host beat", + "da": "Næste værtsslag" + }, + "presenter_scene_answers_title": { + "en": "Answer cards", + "da": "Svarkort" + }, + "presenter_scene_answers_body": { + "en": "These are the options currently projected for the room.", + "da": "Det er de svarmuligheder, der lige nu projiceres til rummet." + }, + "presenter_scene_cue_start_label": { + "en": "Open the next round", + "da": "Åbn næste runde" + }, + "presenter_scene_cue_start_body": { + "en": "When the room is settled, launch the first prompt and move the presenter surface into the active round.", + "da": "Når rummet har sat sig, så start det første spørgsmål og flyt presenter-fladen ind i den aktive runde." + }, + "presenter_scene_cue_show_label": { + "en": "Bring the question into focus", + "da": "Bring spørgsmålet i fokus" + }, + "presenter_scene_cue_show_body": { + "en": "Use the prompt as the room anchor, then advance when the round is visually settled.", + "da": "Brug spørgsmålet som rummets anker, og gå videre når runden visuelt har sat sig." + }, + "presenter_scene_cue_mix_label": { + "en": "Move into the answer mix", + "da": "Gå videre til svarmixet" + }, + "presenter_scene_cue_mix_body": { + "en": "Once the room is ready, mix the house lies and player lies into the next guessing moment.", + "da": "Når rummet er klar, så mix husets løgne og spillernes løgne ind i næste gæt-øjeblik." + }, + "presenter_scene_cue_reveal_label": { + "en": "Trigger the reveal", + "da": "Start afsløringen" + }, + "presenter_scene_cue_reveal_body": { + "en": "When enough guesses are in, calculate the round and move the room into the truth moment.", + "da": "Når nok gæt er inde, så beregn runden og flyt rummet ind i sandhedsøjeblikket." + }, + "presenter_scene_cue_scoreboard_label": { + "en": "Land the scoreboard", + "da": "Land scoreboardet" + }, + "presenter_scene_cue_scoreboard_body": { + "en": "Push the reveal into the score beat once the room has had time to read the round outcome.", + "da": "Skub afsløringen videre til score-øjeblikket, når rummet har haft tid til at læse rundens udfald." + }, + "presenter_scene_cue_close_label": { + "en": "Choose the next beat", + "da": "Vælg næste beat" + }, + "presenter_scene_cue_close_body": { + "en": "Use the scoreboard moment to decide whether the room wants another round or the final result.", + "da": "Brug scoreboard-øjeblikket til at afgøre, om rummet vil have en runde mere eller det endelige resultat." + }, + "presenter_scene_cue_finished_label": { + "en": "Hold the closing frame", + "da": "Hold afslutningsbilledet" + }, + "presenter_scene_cue_finished_body": { + "en": "Let the final standings sit on screen as the last beat of the game.", + "da": "Lad slutstillingen stå på skærmen som spillets sidste beat." + }, + "presenter_scene_cue_wait_label": { + "en": "Hold the room here", + "da": "Hold rummet her" + }, + "presenter_scene_cue_wait_body": { + "en": "Let the main screen breathe while players are still writing on their phones.", + "da": "Lad hovedskærmen få luft, mens spillerne stadig skriver på deres telefoner." + }, + "presenter_scene_roster_title": { + "en": "On the floor", + "da": "På gulvet" + }, + "presenter_scene_roster_body": { + "en": "Each player keeps a stable identity token on the presenter screen until real avatars arrive.", + "da": "Hver spiller beholder et stabilt identitetstoken på presenterskærmen, indtil rigtige avatarer kommer." + }, + "presenter_scene_lead_title": { + "en": "Current lead", + "da": "Nuværende føring" + }, + "presenter_scene_stat_start_ready": { + "en": "Start ready", + "da": "Klar til start" + }, + "presenter_scene_stat_lies": { + "en": "Lies in play", + "da": "Løgne i spil" + }, + "presenter_scene_stat_correct_guesses": { + "en": "Correct guesses", + "da": "Rigtige gæt" + }, + "presenter_scene_stat_fooled_players": { + "en": "Players fooled", + "da": "Spillere narret" + }, + "presenter_waiting": { + "en": "Waiting for the next round to begin.", + "da": "Venter på at næste runde starter." + }, + "phase_summary_lobby": { + "en": "Use the presenter surface for atmosphere and player roster, then move into the next round when ready.", + "da": "Brug presenter-fladen til stemning og spilleroversigt, og gå videre til næste runde når I er klar." + }, + "phase_summary_lie": { + "en": "Players are writing believable lies while the host screen carries the prompt and pacing.", + "da": "Spillerne skriver troværdige løgne, mens værtsskærmen bærer spørgsmålet og rytmen." + }, + "phase_summary_guess": { + "en": "The game is collecting guesses. Keep the presenter view focused while players decide on their answers.", + "da": "Spillet samler gæt ind. Hold presenter-visningen fokuseret, mens spillerne vælger deres svar." + }, + "phase_summary_reveal": { + "en": "The reveal phase is ready. Show the truth, the bluffs, and who fooled whom.", + "da": "Afsløringsfasen er klar. Vis sandheden, bluffene og hvem der narrede hvem." + }, + "phase_summary_scoreboard": { + "en": "Scores are ready to land. Use this moment for pacing before the next round starts.", + "da": "Scoren er klar til at lande. Brug øjeblikket til pacing før næste runde starter." + }, + "phase_summary_finished": { + "en": "The final result is ready. This screen should now carry the closing moment of the game.", + "da": "Det endelige resultat er klar. Denne skærm skal nu bære spillets afslutning." } }, "player": { @@ -237,6 +601,10 @@ "en": "Loading latest session state…", "da": "Indlæser seneste session-status…" }, + "sync_delayed_text": { + "en": "Refreshing in the background is taking longer than expected…", + "da": "Baggrundsopdatering tager længere tid end forventet…" + }, "loading_join": { "en": "Joining session… restoring your player state.", "da": "Joiner session… gendanner spillerstatus." @@ -268,6 +636,354 @@ "audio_policy_notice": { "en": "Audio playback is disabled on phone clients. Sound is available on the primary host device.", "da": "Lydafspilning er slået fra på telefon-klienten. Lyd afspilles kun på den primære værtsenhed." + }, + "show_developer_state": { + "en": "Show developer state", + "da": "Vis udviklerstatus" + }, + "hide_developer_state": { + "en": "Hide developer state", + "da": "Skjul udviklerstatus" + }, + "player_scene_title_join": { + "en": "Player station", + "da": "Spillerstation" + }, + "player_scene_title_lobby": { + "en": "Seat reserved", + "da": "Plads reserveret" + }, + "player_scene_title_waiting_lie": { + "en": "Lie locked in", + "da": "Løgnen er låst" + }, + "player_scene_title_waiting_guess": { + "en": "Guess locked in", + "da": "Gættet er låst" + }, + "player_scene_title_waiting": { + "en": "Stay in the room", + "da": "Bliv i rummet" + }, + "player_scene_join_headline": { + "en": "Bring this phone into the game", + "da": "Tag denne telefon med ind i spillet" + }, + "player_scene_body_join": { + "en": "Use the shared session code and your nickname to claim this device before the round starts.", + "da": "Brug den delte sessionskode og dit kaldenavn til at tage denne enhed i brug, før runden starter." + }, + "player_scene_body_lobby": { + "en": "You are checked in. Keep this phone ready while the host opens the next prompt on the main screen.", + "da": "Du er tjekket ind. Hold telefonen klar, mens værten åbner næste spørgsmål på hovedskærmen." + }, + "player_scene_body_waiting": { + "en": "Your action is already locked in. Keep this device nearby while the round advances on the shared screen.", + "da": "Din handling er allerede låst. Hold enheden i nærheden, mens runden går videre på fællesskærmen." + }, + "player_scene_next_step": { + "en": "Next phone beat", + "da": "Næste telefon-beat" + }, + "player_scene_cue_join_label": { + "en": "Claim this screen", + "da": "Tag denne skærm i brug" + }, + "player_scene_cue_join_body": { + "en": "Enter the session code, choose a nickname, and join before the host starts the round.", + "da": "Indtast sessionskoden, vælg et kaldenavn, og join før værten starter runden." + }, + "player_scene_cue_lobby_label": { + "en": "Wait for the host", + "da": "Vent på værten" + }, + "player_scene_cue_lobby_body": { + "en": "Once the room is settled, the shared screen will move into the round and this phone will switch to the active task.", + "da": "Når rummet har sat sig, går fællesskærmen videre til runden, og denne telefon skifter til den aktive opgave." + }, + "player_scene_cue_waiting_label": { + "en": "Hold your place", + "da": "Hold din plads" + }, + "player_scene_cue_waiting_body": { + "en": "The game is still syncing around the room. This device will update in place when the next playable step opens.", + "da": "Spillet synkroniserer stadig rundt i rummet. Denne enhed opdaterer på stedet, når næste spilbare trin åbner." + }, + "active_scene_cue_lie_label": { + "en": "Sell the bluff", + "da": "Sælg bluffet" + }, + "active_scene_cue_lie_body": { + "en": "Write one answer that could pass for the truth, then lock it in before the host flips the room.", + "da": "Skriv ét svar der kan lyde sandt, og lås det før værten vender rummet." + }, + "active_scene_cue_guess_label": { + "en": "Pick the truth", + "da": "Vælg sandheden" + }, + "active_scene_cue_guess_body": { + "en": "Read the mixed answers, trust your instinct, and send one guess when it feels right.", + "da": "Læs de blandede svar, stol på din mavefornemmelse, og send ét gæt når det føles rigtigt." + }, + "active_scene_cue_reveal_label": { + "en": "Read the round", + "da": "Læs runden" + }, + "active_scene_cue_reveal_body": { + "en": "The answer is out. Check who got fooled and how your choice landed before the scoreboard arrives.", + "da": "Svaret er ude. Se hvem der blev narret, og hvordan dit valg landede, før scoretavlen kommer." + }, + "active_scene_cue_result_label": { + "en": "Clock the standings", + "da": "Læs stillingen" + }, + "active_scene_cue_result_body": { + "en": "The room score is live. Track your place here while the host decides on the next round or the finish.", + "da": "Rummets score er live. Følg din placering her, mens værten vælger næste runde eller afslutningen." + }, + "headline_join": { + "en": "Join the session", + "da": "Join sessionen" + }, + "headline_waiting": { + "en": "Stay ready for the next phase", + "da": "Vær klar til næste fase" + }, + "join_section_title": { + "en": "Join this session", + "da": "Join denne session" + }, + "current_task_title": { + "en": "Current task", + "da": "Nuværende opgave" + }, + "join_section_body": { + "en": "Use your session code and nickname to attach this device to the game.", + "da": "Brug sessionskoden og dit kaldenavn til at koble denne enhed til spillet." + }, + "answers_not_ready": { + "en": "Answers have not been mixed yet.", + "da": "Svarene er ikke blevet blandet endnu." + }, + "round_view_title": { + "en": "Round view", + "da": "Rundevisning" + }, + "scoreboard_title": { + "en": "Scoreboard", + "da": "Scoretavle" + }, + "round_prompt_waiting": { + "en": "The round prompt will appear here when the game phase exposes it.", + "da": "Rundens spørgsmål vises her, når spilfasen tillader det." + }, + "selected_answer_label": { + "en": "Selected answer", + "da": "Valgt svar" + }, + "selection_pending_label": { + "en": "Not picked yet", + "da": "Ikke valgt endnu" + }, + "draft_length_label": { + "en": "Draft length", + "da": "Længde på udkast" + }, + "answers_available_label": { + "en": "Answers", + "da": "Svar" + }, + "reveal_title": { + "en": "Reveal", + "da": "Afsløring" + }, + "correct_answer": { + "en": "Correct answer", + "da": "Rigtigt svar" + }, + "guess_result_label": { + "en": "Your guess", + "da": "Dit gæt" + }, + "players_fooled_label": { + "en": "Players fooled", + "da": "Narrede spillere" + }, + "lied_label": { + "en": "lied", + "da": "løj" + }, + "picked_label": { + "en": "picked", + "da": "valgte" + }, + "correct_label": { + "en": "correct", + "da": "korrekt" + }, + "fooled_by_label": { + "en": "fooled by", + "da": "narret af" + }, + "incorrect_label": { + "en": "incorrect", + "da": "forkert" + }, + "developer_state_title": { + "en": "Developer state", + "da": "Udviklerstatus" + }, + "developer_state_body": { + "en": "Player diagnostics live here so the default mobile surface can stay focused on one action per phase.", + "da": "Spillerdiagnostik ligger her, så standardvisningen på mobilen kan forblive fokuseret på én handling per fase." + }, + "room_roster_title": { + "en": "In the room", + "da": "I rummet" + }, + "room_roster_body": { + "en": "Keep track of who is already connected without pushing diagnostics into the main player flow.", + "da": "Hold styr på hvem der allerede er forbundet uden at skubbe diagnostik ind i den normale spillerflow." + }, + "room_players_label": { + "en": "Players", + "da": "Spillere" + }, + "room_live_label": { + "en": "Live", + "da": "Live" + }, + "room_away_label": { + "en": "Away", + "da": "Væk" + }, + "player_scene_stat_score": { + "en": "Your score", + "da": "Din score" + }, + "result_rank_label": { + "en": "Your place", + "da": "Din placering" + }, + "leader_score_label": { + "en": "Lead score", + "da": "Førende score" + }, + "gap_to_lead_label": { + "en": "Gap to lead", + "da": "Afstand til føring" + }, + "connection_label": { + "en": "Connection", + "da": "Forbindelse" + }, + "player_id_label": { + "en": "Player ID", + "da": "Spiller-id" + }, + "token_label": { + "en": "Token", + "da": "Token" + }, + "sync_transport_label": { + "en": "Sync transport", + "da": "Synk transport" + }, + "realtime_status_label": { + "en": "Realtime status", + "da": "Realtime-status" + }, + "last_event_label": { + "en": "Last event", + "da": "Seneste event" + }, + "token_present": { + "en": "present", + "da": "til stede" + }, + "token_missing": { + "en": "missing", + "da": "mangler" + }, + "you_label": { + "en": "You", + "da": "Dig" + }, + "phase_summary_lobby": { + "en": "Connect this device to the session and stay ready for the next prompt.", + "da": "Forbind denne enhed til sessionen og vær klar til næste spørgsmål." + }, + "phase_summary_lie": { + "en": "Write one believable answer. The game will keep syncing in the background while you type.", + "da": "Skriv ét troværdigt svar. Spillet synkroniserer videre i baggrunden, mens du skriver." + }, + "phase_summary_guess": { + "en": "Choose the answer you think is correct. Your selection stays local until you submit it.", + "da": "Vælg det svar du tror er rigtigt. Dit valg bliver lokalt, indtil du sender det." + }, + "phase_summary_reveal": { + "en": "The reveal phase is active. Watch how the lies and guesses landed this round.", + "da": "Afsløringsfasen er aktiv. Se hvordan løgne og gæt landede i denne runde." + }, + "phase_summary_scoreboard": { + "en": "Scores are being shown. Wait here for the next round or the final result.", + "da": "Scoren bliver vist. Vent her på næste runde eller det endelige resultat." + }, + "phase_summary_finished": { + "en": "The game is over. The final standings are ready below.", + "da": "Spillet er slut. De endelige placeringer er klar nedenfor." + }, + "action_summary_lie": { + "en": "Type a believable lie and send it when you are ready.", + "da": "Skriv en troværdig løgn og send den, når du er klar." + }, + "action_summary_guess": { + "en": "Pick one answer and submit your guess.", + "da": "Vælg ét svar og send dit gæt." + }, + "action_summary_scoreboard": { + "en": "Check the current standings and stay ready for the next transition.", + "da": "Tjek den aktuelle stilling og vær klar til næste overgang." + }, + "action_summary_finished": { + "en": "Review the final standings for this game.", + "da": "Gennemgå de endelige placeringer for dette spil." + }, + "action_summary_waiting": { + "en": "This device is connected. Waiting for the next playable step.", + "da": "Denne enhed er forbundet. Venter på næste spilbare trin." + }, + "waiting_connected": { + "en": "You are connected. Waiting for the next phase from the host.", + "da": "Du er forbundet. Venter på næste fase fra værten." + }, + "sync_status_eyebrow": { + "en": "Sync status", + "da": "Synkstatus" + }, + "sync_status_title_reconnecting": { + "en": "Live sync is reconnecting", + "da": "Livesynk forbinder igen" + }, + "sync_status_body_reconnecting": { + "en": "Realtime push dropped back to recovery mode. Keep typing or keep your pick here while the phone refreshes in the background.", + "da": "Realtime-push er faldet tilbage til gendannelsestilstand. Bliv ved med at skrive eller behold dit valg her, mens telefonen opdaterer i baggrunden." + }, + "sync_status_title_offline": { + "en": "This phone is offline", + "da": "Denne telefon er offline" + }, + "sync_status_body_offline": { + "en": "Your draft stays on this device, but the game cannot advance here until the network comes back.", + "da": "Dit udkast bliver på denne enhed, men spillet kan ikke fortsætte her, før netværket er tilbage." + }, + "sync_status_title_delayed": { + "en": "Background refresh is slow", + "da": "Baggrundsopdateringen er langsom" + }, + "sync_status_body_delayed": { + "en": "Updates are taking longer than expected. Keep your input here and the client will keep retrying in the background.", + "da": "Opdateringer tager længere tid end forventet. Behold dit input her, og klienten fortsætter med at prøve igen i baggrunden." } } }, @@ -284,7 +1000,6 @@ "finish_game_invalid_phase": "finish_game_invalid_phase", "guess_already_submitted": "guess_already_submitted", "guess_submission_invalid_phase": "guess_submission_invalid_phase", - "guess_submission_window_closed": "guess_submission_window_closed", "host_only_calculate_scores": "host_only_calculate_scores", "host_only_finish_game": "host_only_finish_game", "host_only_mix_answers": "host_only_mix_answers", @@ -295,7 +1010,6 @@ "invalid_player_session_token": "invalid_player_session_token", "lie_already_submitted": "lie_already_submitted", "lie_submission_invalid_phase": "lie_submission_invalid_phase", - "lie_submission_window_closed": "lie_submission_window_closed", "lie_text_invalid": "lie_text_invalid", "mix_answers_invalid_phase": "mix_answers_invalid_phase", "nickname_invalid": "nickname_invalid", @@ -319,38 +1033,9 @@ "session_not_joinable": "session_not_joinable", "session_token_required": "session_token_required", "show_question_invalid_phase": "show_question_invalid_phase", - "round_config_missing": "round_config_missing", - "question_already_shown": "question_already_shown", - "no_available_questions": "no_available_questions", - "mix_answers_invalid_phase": "mix_answers_invalid_phase", - "round_question_not_found": "round_question_not_found", - "not_enough_answers_to_mix": "not_enough_answers_to_mix", - "host_only_start_round": "host_only_start_round", - "host_only_show_question": "host_only_show_question", - "host_only_mix_answers": "host_only_mix_answers", - "player_id_required": "player_id_required", - "session_token_required": "session_token_required", - "lie_text_invalid": "lie_text_invalid", - "player_not_found_in_session": "player_not_found_in_session", - "invalid_player_session_token": "invalid_player_session_token", "lie_submission_closed": "lie_submission_closed", - "lie_already_submitted": "lie_already_submitted", - "host_only_view_scoreboard": "host_only_view_scoreboard", - "scoreboard_invalid_phase": "scoreboard_invalid_phase", - "host_only_start_next_round": "host_only_start_next_round", "next_round_invalid_phase": "next_round_invalid_phase", - "host_only_finish_game": "host_only_finish_game", - "finish_game_invalid_phase": "finish_game_invalid_phase", - "host_only_calculate_scores": "host_only_calculate_scores", - "scores_already_calculated": "scores_already_calculated", - "calculate_scores_invalid_phase": "calculate_scores_invalid_phase", - "no_guesses_submitted": "no_guesses_submitted", - "guess_submission_closed": "guess_submission_closed", - "selected_answer_invalid": "selected_answer_invalid", - "guess_already_submitted": "guess_already_submitted", - "lie_submission_invalid_phase": "lie_submission_invalid_phase", - "selected_text_invalid": "selected_text_invalid", - "guess_submission_invalid_phase": "guess_submission_invalid_phase" + "guess_submission_closed": "guess_submission_closed" }, "errors": { "calculate_scores_invalid_phase": { @@ -381,10 +1066,6 @@ "en": "Guess submission is only allowed in guess phase.", "da": "Gæt kan kun sendes i gættefasen." }, - "guess_submission_window_closed": { - "en": "Guess submission window has closed.", - "da": "Vinduet for gætindsendelse er lukket." - }, "host_only_calculate_scores": { "en": "Only the host can calculate scores.", "da": "Kun værten kan udregne score." @@ -425,10 +1106,6 @@ "en": "Lie submission is only allowed in lie phase.", "da": "Løgn kan kun sendes i løgnefasen." }, - "lie_submission_window_closed": { - "en": "Lie submission window has closed.", - "da": "Vinduet for løgnindsendelse er lukket." - }, "lie_text_invalid": { "en": "Text must be between 1 and 255 characters.", "da": "Tekst skal være mellem 1 og 255 tegn." @@ -521,133 +1198,17 @@ "en": "Question can only be shown in lie phase", "da": "Spørgsmålet kan kun vises i løgnefasen" }, - "round_config_missing": { - "en": "Round config missing", - "da": "Rundekonfiguration mangler" - }, - "question_already_shown": { - "en": "Question already shown for this round", - "da": "Spørgsmålet er allerede vist for denne runde" - }, - "no_available_questions": { - "en": "No available questions in category", - "da": "Ingen tilgængelige spørgsmål i kategorien" - }, - "mix_answers_invalid_phase": { - "en": "Answers can only be mixed in lie or guess phase", - "da": "Svar kan kun blandes i løgne- eller gættefasen" - }, - "round_question_not_found": { - "en": "Round question not found", - "da": "Rundespørgsmål blev ikke fundet" - }, - "not_enough_answers_to_mix": { - "en": "Not enough answers to mix", - "da": "Ikke nok svar at blande" - }, - "host_only_start_round": { - "en": "Only host can start round", - "da": "Kun værten kan starte runden" - }, - "host_only_show_question": { - "en": "Only host can show question", - "da": "Kun værten kan vise spørgsmålet" - }, - "host_only_mix_answers": { - "en": "Only host can mix answers", - "da": "Kun værten kan blande svar" - }, - "player_id_required": { - "en": "player_id is required", - "da": "player_id er påkrævet" - }, - "session_token_required": { - "en": "session_token is required", - "da": "session_token er påkrævet" - }, - "lie_text_invalid": { - "en": "text must be between 1 and 255 characters", - "da": "text skal være mellem 1 og 255 tegn" - }, - "player_not_found_in_session": { - "en": "Player not found in session", - "da": "Spiller blev ikke fundet i sessionen" - }, - "invalid_player_session_token": { - "en": "Invalid player session token", - "da": "Ugyldigt spiller-session-token" - }, "lie_submission_closed": { "en": "Lie submission window has closed", "da": "Vinduet for løgn-indsendelse er lukket" }, - "lie_already_submitted": { - "en": "Lie already submitted for this player", - "da": "Løgnen er allerede indsendt for denne spiller" - }, - "host_only_view_scoreboard": { - "en": "Only host can view scoreboard", - "da": "Kun værten kan se scoreboard" - }, - "scoreboard_invalid_phase": { - "en": "Scoreboard is only available in reveal/scoreboard phase", - "da": "Scoreboard er kun tilgængeligt i reveal-/scoreboard-fasen" - }, - "host_only_start_next_round": { - "en": "Only host can start next round", - "da": "Kun værten kan starte næste runde" - }, "next_round_invalid_phase": { "en": "Next round can only start from scoreboard phase", "da": "Næste runde kan kun starte fra scoreboard-fasen" }, - "host_only_finish_game": { - "en": "Only host can finish game", - "da": "Kun værten kan afslutte spillet" - }, - "finish_game_invalid_phase": { - "en": "Game can only be finished from scoreboard phase", - "da": "Spillet kan kun afsluttes fra scoreboard-fasen" - }, - "host_only_calculate_scores": { - "en": "Only host can calculate scores", - "da": "Kun værten kan udregne score" - }, - "scores_already_calculated": { - "en": "Scores already calculated for this round question", - "da": "Score er allerede udregnet for dette rundespørgsmål" - }, - "calculate_scores_invalid_phase": { - "en": "Scores can only be calculated in guess phase", - "da": "Score kan kun udregnes i gættefasen" - }, - "no_guesses_submitted": { - "en": "No guesses submitted for this round question", - "da": "Ingen gæt er indsendt for dette rundespørgsmål" - }, "guess_submission_closed": { "en": "Guess submission window has closed", "da": "Vinduet for gæt-indsendelse er lukket" - }, - "selected_answer_invalid": { - "en": "Selected answer is not part of this round", - "da": "Det valgte svar er ikke en del af denne runde" - }, - "guess_already_submitted": { - "en": "Guess already submitted for this player", - "da": "Gættet er allerede indsendt for denne spiller" - }, - "lie_submission_invalid_phase": { - "en": "Lie submission is only allowed in lie phase", - "da": "Løgn kan kun indsendes i løgnefasen" - }, - "selected_text_invalid": { - "en": "selected_text must be between 1 and 255 characters", - "da": "selected_text skal være mellem 1 og 255 tegn" - }, - "guess_submission_invalid_phase": { - "en": "Guess submission is only allowed in guess phase", - "da": "Gæt kan kun indsendes i gættefasen" } } }, @@ -673,7 +1234,6 @@ "finish_game_invalid_phase": "unknown", "guess_already_submitted": "unknown", "guess_submission_invalid_phase": "unknown", - "guess_submission_window_closed": "unknown", "host_only_action": "start_round_failed", "host_only_calculate_scores": "unknown", "host_only_finish_game": "unknown", @@ -685,7 +1245,6 @@ "invalid_player_session_token": "unknown", "lie_already_submitted": "unknown", "lie_submission_invalid_phase": "unknown", - "lie_submission_window_closed": "unknown", "lie_text_invalid": "unknown", "mix_answers_invalid_phase": "start_round_failed", "nickname_invalid": "nickname_invalid", @@ -709,35 +1268,9 @@ "session_not_joinable": "join_failed", "session_token_required": "unknown", "show_question_invalid_phase": "start_round_failed", - "round_config_missing": "start_round_failed", - "question_already_shown": "start_round_failed", - "no_available_questions": "start_round_failed", - "mix_answers_invalid_phase": "start_round_failed", - "round_question_not_found": "start_round_failed", - "not_enough_answers_to_mix": "start_round_failed", - "player_id_required": "unknown", - "session_token_required": "unknown", - "lie_text_invalid": "unknown", - "lie_submission_invalid_phase": "unknown", - "player_not_found_in_session": "unknown", - "invalid_player_session_token": "unknown", "lie_submission_closed": "unknown", - "lie_already_submitted": "unknown", - "selected_text_invalid": "unknown", - "guess_submission_invalid_phase": "unknown", "guess_submission_closed": "unknown", - "selected_answer_invalid": "unknown", - "guess_already_submitted": "unknown", - "host_only_view_scoreboard": "unknown", - "scoreboard_invalid_phase": "unknown", - "host_only_start_next_round": "unknown", - "next_round_invalid_phase": "unknown", - "host_only_finish_game": "unknown", - "finish_game_invalid_phase": "unknown", - "host_only_calculate_scores": "unknown", - "scores_already_calculated": "unknown", - "calculate_scores_invalid_phase": "unknown", - "no_guesses_submitted": "unknown" + "next_round_invalid_phase": "unknown" } } } diff --git a/templates/registration/login.html b/templates/registration/login.html new file mode 100644 index 0000000..c4be19d --- /dev/null +++ b/templates/registration/login.html @@ -0,0 +1,121 @@ + + + + + Host Login + + + + +
+

Host login

+

Use the demo host account for local MVP testing, then return to the SPA home page to create a session.

+ + {% if form.errors %} +
The username or password did not match.
+ {% endif %} + +
+ {% csrf_token %} + + + + + {% if next %} + + {% endif %} + + +
+ +

Local demo account: demo-host / demo-pass

+
+ + diff --git a/voice/admin.py b/voice/admin.py index 8c38f3f..af76afc 100644 --- a/voice/admin.py +++ b/voice/admin.py @@ -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) diff --git a/voice/migrations/0001_initial.py b/voice/migrations/0001_initial.py new file mode 100644 index 0000000..2ba8756 --- /dev/null +++ b/voice/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/voice/migrations/0002_phasevoiceline_audio_file_and_more.py b/voice/migrations/0002_phasevoiceline_audio_file_and_more.py new file mode 100644 index 0000000..f8814eb --- /dev/null +++ b/voice/migrations/0002_phasevoiceline_audio_file_and_more.py @@ -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/'), + ), + ] diff --git a/voice/models.py b/voice/models.py index 71a8362..358b0ca 100644 --- a/voice/models.py +++ b/voice/models.py @@ -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}" diff --git a/voice/services.py b/voice/services.py new file mode 100644 index 0000000..f2ec4b6 --- /dev/null +++ b/voice/services.py @@ -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, + } diff --git a/voice/tests.py b/voice/tests.py index 7ce503c..3069510 100644 --- a/voice/tests.py +++ b/voice/tests.py @@ -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")) diff --git a/voice/views.py b/voice/views.py index 91ea44a..c9ea4fe 100644 --- a/voice/views.py +++ b/voice/views.py @@ -1,3 +1 @@ -from django.shortcuts import render - -# Create your views here. +"""HTTP views for the voice app."""