Compare commits
219 Commits
dev/issue-
...
feature/vi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a81bc1250c | ||
| d86941fef8 | |||
| 21e390d200 | |||
| df9b6d192c | |||
| 702f130de2 | |||
| 92f2cda83a | |||
| d080f05661 | |||
| e246bd648f | |||
| 06e4ccac61 | |||
| 3c9214178e | |||
| feddd910eb | |||
| dd615796f4 | |||
| d2cdf16322 | |||
| 101c3f9c26 | |||
| 65eb5685f7 | |||
| 8a70645fda | |||
| 2cd8d940f9 | |||
| 72bc5997ff | |||
| c9e64bc8a8 | |||
| 1c7f1e7c53 | |||
| 03850b5ed5 | |||
| 16c9cf6b57 | |||
| c45f04f9f1 | |||
| 319038555a | |||
| e318711148 | |||
| a9c6e4fd79 | |||
| 7eb3507934 | |||
| dfa197b33b | |||
| fefc5ecd56 | |||
| 94f940e5d8 | |||
| a102a72a77 | |||
| d272e35a79 | |||
| 8a07433f11 | |||
| 9baade0105 | |||
| 35e2d09ee3 | |||
| a916da12a7 | |||
| 7f20cb3bf9 | |||
| f736f4f74e | |||
| 8247787404 | |||
| 6722be43d4 | |||
| 212549373b | |||
| 47659ed673 | |||
|
|
c8750af4d8 | ||
| 44e480931b | |||
| 8c0a561a64 | |||
| 1839b30e0a | |||
| 7de843e44b | |||
| 542d326615 | |||
| e39605d782 | |||
| d36d256daf | |||
| 2ee235c6c0 | |||
| 592c265331 | |||
| 251ccfce19 | |||
| d9c4cda966 | |||
| 2437f0e8bd | |||
| 3b4b844126 | |||
| c8c17654a4 | |||
| fd6e3e86e8 | |||
| 7c0332f95f | |||
| 9970257f32 | |||
| 112a85a22d | |||
| 33b428955b | |||
| 55fc758389 | |||
| f0142f33b6 | |||
| 3acaf3e370 | |||
| 1cb36a5943 | |||
| fc68e30cf4 | |||
| 57ca237565 | |||
| 076faf2ff1 | |||
| f58e852246 | |||
| 242aeaacd6 | |||
| 624bcd602b | |||
| bfa4ab859c | |||
| 3706bc3b1c | |||
| a6e09e2bea | |||
| 5bb035deec | |||
| ab08dc2b6d | |||
| a2c60749f8 | |||
| 89c7070e02 | |||
| c43975a1c8 | |||
| 2cc2a08ccb | |||
| 0d91531b90 | |||
| e566e0967d | |||
| 0b0e3c325c | |||
| f44dd92543 | |||
| c363ec92da | |||
| 2472b70d45 | |||
| 7a6eb0b88e | |||
| 1cbec3b70e | |||
| 49257af0b0 | |||
| e8883e803b | |||
| 076ca4ebbb | |||
| 207c934b48 | |||
|
|
dffb3f49ff | ||
|
|
6dcd5e5f03 | ||
| f0e87eb988 | |||
| a80b1ee354 | |||
| 3f20f25902 | |||
| 1a6869643f | |||
| 5c9d29a3a7 | |||
| 62174135b8 | |||
| 17234de5d1 | |||
| be38fe6ac2 | |||
| 8fa39adc2b | |||
| 97b366d1e9 | |||
| 558f8fe245 | |||
| dc0c203f7f | |||
| 173cc8f2d9 | |||
| 638c9452d8 | |||
| a0277fd8be | |||
| 8503e18e57 | |||
| 3747081eb4 | |||
| 4a12cee6ee | |||
| 1bc4c27273 | |||
| 6ad5430302 | |||
|
|
d6f4b5c0fb | ||
| ceb71aff6e | |||
| 864984273a | |||
| b2e66389c3 | |||
| 8ff552aeae | |||
| b968ea4430 | |||
| e6ca18ff30 | |||
| 575f4782b5 | |||
| e5b8081c10 | |||
| 5a580964c4 | |||
| db7be0dfc6 | |||
| 80520bad51 | |||
| e0aba3fdf6 | |||
| c0c3ecd90c | |||
| b8a9fbf6d1 | |||
| 903c63ce17 | |||
| 58874c0d78 | |||
| fb657cb76c | |||
|
|
d15abf9d78 | ||
|
|
d2dbd8c802 | ||
| f1699841e6 | |||
| 7841fb7651 | |||
| a500056843 | |||
| ad841dfe9f | |||
| 6d6fd44662 | |||
| 022ba24fd0 | |||
| b63b0ccf7e | |||
| 9594a8fcb0 | |||
| e4841afbaa | |||
| ee2a202f34 | |||
| f73b99b637 | |||
| 6d99741305 | |||
| cf58ba8067 | |||
| f7ed3d9407 | |||
| 951e24b57d | |||
| 63fce7760a | |||
| 8899bf547c | |||
| 0dad635311 | |||
| 7c7a6b6a08 | |||
| f87e0b60cf | |||
| aa2d636e90 | |||
| 9219648231 | |||
| 377fb712e1 | |||
| bb90295d26 | |||
| f9e1999e74 | |||
| 0c515ed2b7 | |||
| 0bb15f749b | |||
| 361f78b1c8 | |||
| 4d46611910 | |||
| ed72f9a824 | |||
| 3474d68c57 | |||
| 0bc4e6f066 | |||
| b18b05cc70 | |||
| ed57efb1b3 | |||
| 1faadbea4d | |||
| dc6af7547c | |||
| 1f98f01283 | |||
| a278934960 | |||
| c1391e8dc5 | |||
| 8d3df1f850 | |||
| 6838cc0efc | |||
| 9deae85a56 | |||
| 4b2b21fe57 | |||
| 5538a91800 | |||
| 79b694c590 | |||
| 87c1a0ee6c | |||
| c34a52e83e | |||
| edf9460ceb | |||
| a0a1424e90 | |||
| 60e58f6214 | |||
| a658ef5f80 | |||
| 258025ac4e | |||
| f28a390f95 | |||
| a1bb1ccbed | |||
| ee025e8deb | |||
| b977016ef4 | |||
| 1b899a30a2 | |||
| 187b26e561 | |||
| 0b4ddaf43f | |||
| 7a3d649e11 | |||
| f50f6a08ae | |||
| 000a486db1 | |||
| 845e94b726 | |||
| 5fe8f92ee4 | |||
| e2f184d1bc | |||
| ab41798220 | |||
| c7ff3d96de | |||
| 3398aead7f | |||
| 97945ede92 | |||
| 3655bad847 | |||
| fdef33f44a | |||
| bc78f79f78 | |||
| 784622058a | |||
| 257732e2ab | |||
| 64fe273691 | |||
| cd6fb06343 | |||
| 508d462bb6 | |||
| e435a41660 | |||
| b9bfe55f93 | |||
| 8e21ca8e5e | |||
| ddf8e874e2 | |||
| 21a25a063c | |||
| 4e300e4631 | |||
| 5fe9939057 |
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash"
|
||||
]
|
||||
}
|
||||
}
|
||||
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@@ -0,0 +1,16 @@
|
||||
.git
|
||||
.venv
|
||||
venv
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
*.egg-info
|
||||
.pytest_cache
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
frontend/angular/node_modules
|
||||
frontend/angular/dist
|
||||
db.sqlite3
|
||||
staticfiles
|
||||
media
|
||||
@@ -27,18 +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 SPA dependencies
|
||||
run: npm ci --prefix frontend/angular
|
||||
- name: Install shared frontend dependencies
|
||||
run: npm ci --prefix frontend
|
||||
|
||||
- name: SPA Angular smoke tests
|
||||
run: npm --prefix frontend/angular test
|
||||
- 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 checks
|
||||
run: |
|
||||
npm --prefix frontend/angular test
|
||||
npm --prefix frontend/angular run build
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -17,6 +17,7 @@ venv/
|
||||
db.sqlite3
|
||||
staticfiles/
|
||||
media/
|
||||
artifacts/
|
||||
|
||||
# Env/secrets
|
||||
.env
|
||||
@@ -24,6 +25,7 @@ media/
|
||||
!.env.test.example
|
||||
!.env.staging.example
|
||||
!.env.prod.example
|
||||
!.env.dev.example
|
||||
|
||||
# Editors/OS
|
||||
.vscode/
|
||||
|
||||
30
AGENTS.md
Normal file
30
AGENTS.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
`partyhub/` is the Django project entrypoint (`settings.py`, `urls.py`, `asgi.py`). Backend apps live at the repo root: `lobby/` handles session and player flows, `fupogfakta/` owns game rules and scoring, `realtime/` holds websocket/broadcast code, and `core_admin/` plus `voice/` cover admin and future audio integration. Shared locale data lives in `shared/i18n/`, helper scripts in `scripts/`, deployment assets in `infra/`, and release or smoke evidence in `docs/`.
|
||||
|
||||
Frontend code is split in two layers: `frontend/src/` contains the framework-agnostic TypeScript API client and SPA state helpers, while `frontend/angular/src/` contains the Angular 19 shell for host and player screens.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Install backend dependencies with `.venv/bin/pip install -r requirements.txt`.
|
||||
|
||||
- `.venv/bin/python manage.py runserver` starts the Django dev server.
|
||||
- `.venv/bin/python manage.py migrate` applies schema changes.
|
||||
- `.venv/bin/python manage.py check` runs Django configuration checks.
|
||||
- `.venv/bin/python manage.py test lobby` runs the backend suite currently enforced in CI.
|
||||
- `npm --prefix frontend test` runs Vitest for the shared TypeScript client.
|
||||
- `npm --prefix frontend run build` performs the TypeScript compile check.
|
||||
- `npm --prefix frontend/angular start` serves the Angular shell locally.
|
||||
- `npm --prefix frontend/angular test` runs Angular-side Vitest smoke tests.
|
||||
- `.venv/bin/python scripts/check_i18n_drift.py` validates shared locale keys.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Use 4-space indentation in Python and follow Django conventions: snake_case for functions, PascalCase for models, explicit `on_delete` on `ForeignKey`s, and committed migrations in each app’s `migrations/` package. Keep business rules server-authoritative.
|
||||
|
||||
Use 2-space indentation in TypeScript and Angular. Match the existing style: single quotes, semicolons, PascalCase classes, camelCase functions, and kebab-case filenames such as `gameplay-phase-machine.ts`. Keep API types in `frontend/src/api/types.ts` aligned with backend JSON payloads.
|
||||
|
||||
## Testing Guidelines
|
||||
Backend tests live in `<app>/tests.py`; frontend tests live in `frontend/tests/*.test.ts` and `frontend/angular/src/**/*.spec.ts`. No numeric coverage gate is committed, so add targeted tests for every gameplay, i18n, or payload-contract change. Before opening a PR, run `manage.py check`, `manage.py test lobby`, and `npm --prefix frontend/angular test` at minimum.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Recent history follows short, imperative subjects with optional scopes, for example `fix(gameplay): ...`, `test(lobby): ...`, and `chore: ...`. Keep commits small and reference issue numbers when relevant. Open PRs from `feature/<name>` branches with a clear problem statement, linked issue, test evidence, and screenshots for host/player UI changes. If you touch `USE_SPA_UI`, staging flow, or i18n artifacts, include the related smoke or parity document in `docs/`.
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
### Docs
|
||||
- Added `docs/ISSUE-279-I18N-MVP-CLOSEOUT.md` with the issue #279 i18n MVP close-out note, including migration impact, reusable release-note text, and a release-readiness checklist refreshed against `main@1bc4c27` after PR #282/#283 landed on 2026-03-13 UTC.
|
||||
- Clarified that the close-out note supersedes earlier PR snapshot assumptions and now treats PR #282 (`6ad5430`) and PR #283 (`1bc4c27`) as already merged on `main`.
|
||||
|
||||
## [0.1.0] - 2026-02-27
|
||||
### Added
|
||||
- Projekt scaffold for Weirsøe Party Protocol (Django 6.0.2)
|
||||
|
||||
119
CLAUDE.md
Normal file
119
CLAUDE.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Weirsøe Party Protocol** is a Danish party game web platform (Jackbox-style) where games display on a primary screen and players participate via mobile. The MVP game is "Fup og Fakta" (a Fibbage-style lie-and-guess game).
|
||||
|
||||
- Backend: Django 6.0.2 + Django Channels (WebSockets) + Redis
|
||||
- Frontend: Angular 19 shell + shared TypeScript API client library
|
||||
- Database: MySQL (SQLite fallback for dev)
|
||||
- Deployment: Proxmox LXC containers (not Docker)
|
||||
|
||||
## Commands
|
||||
|
||||
### Backend (Django)
|
||||
```bash
|
||||
python manage.py runserver # Dev server
|
||||
python manage.py migrate # Apply migrations
|
||||
python manage.py test # Run all backend tests
|
||||
python manage.py test lobby # Run tests for a single app
|
||||
python manage.py shell # Django shell
|
||||
```
|
||||
|
||||
### Frontend — API client (`/frontend`)
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm test # Vitest unit tests
|
||||
npm run build # TypeScript compile check (--noEmit)
|
||||
```
|
||||
|
||||
### Frontend — Angular shell (`/frontend/angular`)
|
||||
```bash
|
||||
cd frontend/angular
|
||||
npm install
|
||||
npm start # Dev server (ng serve)
|
||||
npm run build # Production build
|
||||
npm run test # Vitest unit tests
|
||||
```
|
||||
|
||||
### i18n validation
|
||||
```bash
|
||||
python scripts/check_i18n_drift.py # Check for key drift between locales
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend apps
|
||||
|
||||
| App | Purpose |
|
||||
|-----|---------|
|
||||
| `partyhub/` | Main Django project — settings, root URLs, ASGI/WSGI, i18n bootstrap |
|
||||
| `lobby/` | Session & player management — create/join session, locale-aware error responses |
|
||||
| `fupogfakta/` | Game logic — all domain models, score calculation (server-authoritative) |
|
||||
| `realtime/` | WebSocket event layer (stub) |
|
||||
| `voice/` | Voice/TTS interface (stub, Phase 2) |
|
||||
| `core_admin/` | Health endpoint (`/healthz`), global admin |
|
||||
|
||||
**Key domain models** (all in `fupogfakta/models.py`): `GameSession`, `Player`, `Category`, `Question`, `RoundConfig`, `RoundQuestion`, `LieAnswer`, `Guess`, `ScoreEvent`.
|
||||
|
||||
Score calculation is server-side only. `ScoreEvent` provides an auditable trail of all point changes.
|
||||
|
||||
### Frontend layers
|
||||
|
||||
1. **Shared API client** (`frontend/src/`) — pure TypeScript, framework-agnostic. Defines all API types (`api/types.ts`) and HTTP client abstraction (`api/client.ts`).
|
||||
2. **Angular shell** (`frontend/angular/`) — Angular 19 standalone components (no NgModules), hash-based routing. `host-shell.component` for the presenter screen; `player-shell.component` for mobile players.
|
||||
|
||||
The Angular shell consumes the shared client via `frontend/src/api/angular-client.ts`.
|
||||
|
||||
### Real-time flow
|
||||
|
||||
`LOBBY → LIE → GUESS → REVEAL → FINISHED` — phase transitions broadcast a `PhaseViewModel` to all connected clients via WebSocket. Clients are read-only; only the server is authoritative for state.
|
||||
|
||||
### i18n
|
||||
|
||||
- **Single source of truth**: `shared/i18n/lobby.json` (keys in both `en` and `da`)
|
||||
- Loaded once at startup with LRU cache (`partyhub/i18n_bootstrap.py`)
|
||||
- Key naming: domain-first — `frontend.ui.host.*`, `frontend.ui.player.*`, `backend.errors.*`, `backend.error_codes.*`
|
||||
- Locale resolved from `Accept-Language` header; missing key returns key + logs warning; missing translation falls back to `en`
|
||||
|
||||
## Key Conventions
|
||||
|
||||
### Errors
|
||||
Backend error responses use stable machine-readable codes (`backend.error_codes.*`) with separately localized messages. Never couple error code strings to locale.
|
||||
|
||||
### Game constraints (MVP)
|
||||
- 3–12 players per session
|
||||
- Session codes: 6-char alphanumeric (no 0/O/1/I/L)
|
||||
- Anti-cheat: no duplicate lies, lies cannot match the correct answer, answer order randomized
|
||||
|
||||
### Git workflow
|
||||
- `main`: stable baseline
|
||||
- `feature/<name>`: development branches
|
||||
- `release/vX.Y.Z`: release preparation
|
||||
- Release: merge → create release branch → update `VERSION` + `CHANGELOG.md` → tag → push
|
||||
|
||||
### TypeScript
|
||||
Strict mode required. Target ES2022. API response interfaces in `frontend/src/api/types.ts` must match backend responses exactly.
|
||||
|
||||
### Database
|
||||
Use `ForeignKey` with explicit `on_delete` (`PROTECT`/`CASCADE`/`SET_NULL`). Add `db_index=True` on frequently queried fields. Migrations are auto-generated by Django and versioned in `migrations/`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```
|
||||
DJANGO_SECRET_KEY, DJANGO_DEBUG, DJANGO_ALLOWED_HOSTS
|
||||
DB_ENGINE, DB_NAME, DB_USER, DB_PASSWORD, DB_HOST, DB_PORT
|
||||
CHANNEL_REDIS_HOST, CHANNEL_REDIS_PORT
|
||||
USE_SPA_UI (fallback: WPP_SPA_ENABLED)
|
||||
WPP_SPA_ASSET_BASE, WPP_SPA_ASSET_VERSION
|
||||
```
|
||||
|
||||
## Test Files of Note
|
||||
|
||||
- `lobby/tests.py` — comprehensive Django TestCase coverage for session/player/i18n/error flows
|
||||
- `frontend/angular/src/app/api-contract-smoke.spec.ts` — API contract smoke tests
|
||||
- `frontend/angular/src/app/lobby-i18n.spec.ts` — i18n parity checks
|
||||
- `frontend/tests/lobby-loader.parity.test.ts` — shared i18n loader parity
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM python:3.14-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential default-libmysqlclient-dev pkg-config \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt /app/requirements.txt
|
||||
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install -r /app/requirements.txt
|
||||
|
||||
COPY . /app
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
71
PROMPT.md
Normal file
71
PROMPT.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Ralph Loop: Implement WebSocket push for Weirsøe Party Protocol
|
||||
|
||||
## Context
|
||||
- Project: /home/agw/projects/weirsoe-party-protocol
|
||||
- Backend: Django 6.0.2 + Django Channels + Redis
|
||||
- The full game REST flow is already implemented in lobby/views.py
|
||||
(create_session, join_session, start_round, show_question, submit_lie,
|
||||
mix_answers, submit_guess, calculate_scores, reveal_scoreboard, finish_game)
|
||||
- realtime/ app exists but is a stub (no consumers.py, no routing)
|
||||
- partyhub/settings.py has channels in INSTALLED_APPS but no CHANNEL_LAYERS or routing
|
||||
- PO hard requirement: WebSocket push is mandatory in MVP (no polling)
|
||||
|
||||
## What to build
|
||||
|
||||
### 1. realtime/consumers.py — GameConsumer
|
||||
- AsyncJsonWebsocketConsumer
|
||||
- Connects to group game_{session_code} on connect (session_code from URL)
|
||||
- Player auth: session_token query param validated against Player model
|
||||
- Host auth: query param role=host, no token required for MVP
|
||||
- On disconnect: clean leave from group
|
||||
- Handles incoming message type "ping" -> replies with {"type": "pong"}
|
||||
- Forwards broadcast group events to WebSocket client
|
||||
|
||||
### 2. partyhub/settings.py — CHANNEL_LAYERS
|
||||
Add CHANNEL_LAYERS using channels_redis.core.RedisChannelLayer.
|
||||
Read CHANNEL_REDIS_HOST (default 127.0.0.1) and CHANNEL_REDIS_PORT (default 6379) from env.
|
||||
|
||||
### 3. partyhub/asgi.py — ASGI routing
|
||||
Wire URLRouter so ws/game/<session_code>/ routes to GameConsumer.
|
||||
Keep existing HTTP routing intact.
|
||||
|
||||
### 4. realtime/routing.py
|
||||
Define websocket_urlpatterns list.
|
||||
|
||||
### 5. realtime/broadcast.py — broadcast helper
|
||||
- async def broadcast_phase_event(session_code, event_type, payload)
|
||||
Sends to group game_{session_code} via channel layer.
|
||||
- def sync_broadcast_phase_event(session_code, event_type, payload)
|
||||
Sync wrapper using async_to_sync for calling from sync REST views.
|
||||
|
||||
### 6. lobby/views.py — hook broadcasts into phase transitions
|
||||
After each phase transition, call sync_broadcast_phase_event:
|
||||
- start_round -> phase.lie_started (question prompt + time limit)
|
||||
- show_question -> phase.question_shown (question text)
|
||||
- mix_answers -> phase.guess_started (shuffled answers + time limit)
|
||||
- calculate_scores -> phase.scores_calculated (per-player score delta)
|
||||
- reveal_scoreboard -> phase.scoreboard (ranked player list)
|
||||
- finish_game -> phase.game_over (final rankings)
|
||||
|
||||
### 7. realtime/tests.py — basic tests
|
||||
- Connect/disconnect test using channels.testing.WebsocketCommunicator
|
||||
- Verify a broadcast reaches a connected client
|
||||
|
||||
## Constraints
|
||||
- Keep auth simple: session_token query param for players, unauthenticated host in MVP
|
||||
- Use async_to_sync wrapper for sync REST views calling async broadcast
|
||||
- Do not break existing REST tests (python manage.py test lobby must still pass)
|
||||
- After each file written, run: python manage.py check
|
||||
- Follow existing code style in lobby/views.py
|
||||
|
||||
## Completion criteria
|
||||
Output the exact text: WEBSOCKET COMPLETE
|
||||
|
||||
...when ALL of the following are true:
|
||||
- realtime/consumers.py exists and handles connect/disconnect/ping
|
||||
- realtime/broadcast.py exists with sync_broadcast_phase_event
|
||||
- partyhub/settings.py has CHANNEL_LAYERS configured
|
||||
- partyhub/asgi.py routes ws/game/<code>/ to GameConsumer
|
||||
- All 6 phase transitions in lobby/views.py call sync_broadcast_phase_event
|
||||
- python manage.py check passes with no errors
|
||||
- python manage.py test lobby passes (existing tests not broken)
|
||||
26
TODO.md
26
TODO.md
@@ -37,8 +37,8 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
|
||||
- [x] `core_admin` (global administration)
|
||||
- [x] `fupogfakta` (Spil 1)
|
||||
- [x] `lobby` (room/session/player join flow)
|
||||
- [x] `realtime` (channels events, game state broadcast)
|
||||
- [x] `voice` (fælles voice-acting interface)
|
||||
- [x] `realtime` (app-skelet oprettet — consumers/routing IKKE implementeret endnu)
|
||||
- [x] `voice` (fælles voice-acting interface — stub)
|
||||
- [x] Miljøfiler (`.env.test`, `.env.prod` skabeloner)
|
||||
- [x] Konfig for MySQL test/prod
|
||||
|
||||
@@ -53,14 +53,15 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
|
||||
- [x] `ScoreEvent` (auditérbar pointslog)
|
||||
|
||||
### Fase 3 — Spilflow `Fup og Fakta`
|
||||
- [x] Lobby: host opretter session, spillere joiner via kode
|
||||
- [x] Runde starter med kategori
|
||||
- [x] Spørgsmål vises -> alle skriver løgn inden X sek
|
||||
- [x] System blander korrekt svar + løgne
|
||||
- [x] Guessfase: alle gætter inden Z sek
|
||||
- [x] Pointudregning (konfigurerbar pr. runde)
|
||||
- [x] Scoreboard + næste spørgsmål/runde
|
||||
- [x] Slutresultat
|
||||
- [x] Lobby: host opretter session, spillere joiner via kode (REST)
|
||||
- [x] Runde starter med kategori (REST)
|
||||
- [x] Spørgsmål vises -> alle skriver løgn inden X sek (REST)
|
||||
- [x] System blander korrekt svar + løgne (persisted i JSONField, anti-cheat dedup)
|
||||
- [x] Guessfase: alle gætter inden Z sek (REST)
|
||||
- [x] Pointudregning (konfigurerbar pr. runde, ScoreEvent audit trail)
|
||||
- [x] Scoreboard + næste spørgsmål/runde (REST)
|
||||
- [x] Slutresultat (REST)
|
||||
- [x] **WebSocket push af phase-events til host + spillere** (GameConsumer + broadcast.py, InMemoryChannelLayer i tests)
|
||||
|
||||
### Fase 4 — Voice-acting (platformkrav)
|
||||
- [ ] Definér TTS provider-interface
|
||||
@@ -103,10 +104,11 @@ Byg **Weirsøe Party Protocol**: en dansk party-webapp platform ala Jackbox, hvo
|
||||
- [ ] Migrations + static + health checks
|
||||
|
||||
### Backlog — Need-to-have / Nice-to-have
|
||||
- [ ] (Need-to-have) Persistér mixed svarrækkefølge pr. round question, så alle spillere ser samme rækkefølge ved reconnect/refresh
|
||||
- [x] (Need-to-have) Persistér mixed svarrækkefølge pr. round question — DONE (JSONField + migration 0003 + test)
|
||||
- [x] (Need-to-have) Tilføj spiller-auth/session-token for submit_lie (pt. baseret på player_id i payload)
|
||||
- [ ] (Nice-to-have) Endpoint til status/progress i løgnfasen (antal indsendt ud af total)
|
||||
- [ ] (Need-to-have) [Fejltype: CI/lint F401] [Fil/område: core_admin/*, fupogfakta/tests.py+views.py, lobby/admin.py+models.py, realtime/*, voice/*] [Branch/PR: feature/f3-lobby-create-join, feature/fase0-mvp-fup-og-fakta, feature/lobby-mvp (ingen åbne PRs fundet)] Fjern ubrugte scaffold-imports (eller kør ruff check --fix) så quality gate kan blive grøn før merge.
|
||||
- [ ] (Need-to-have) Fjern ubrugte scaffold-imports i core_admin/*, realtime/*, voice/*, fupogfakta/views.py (kør `ruff check --fix`) så CI quality gate er grøn
|
||||
- [x] (Need-to-have) [Issue #251] Release-often lane: SPA MVP opdelt i 3 merge-klare micro-PR batches (plan + acceptance criteria dokumenteret i `docs/ISSUE-251-RELEASE-OFTEN-SPA-MVP-BATCH-PLAN.md`).
|
||||
- [ ] (Need-to-have) Rate limiting på join/submit endpoints
|
||||
- [ ] (Need-to-have) Session-kode brute-force beskyttelse
|
||||
- [ ] (Need-to-have) Audit-log for host-handlinger (start/stop/skip)
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
"""Admin registrations for the core_admin app."""
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
"""Database models for the core_admin app."""
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
"""Test module placeholder for the core_admin app."""
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
"""HTTP views for the core_admin app."""
|
||||
|
||||
82
docker-compose.yml
Normal file
82
docker-compose.yml
Normal file
@@ -0,0 +1,82 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
command: sh /app/scripts/docker_dev_entrypoint.sh
|
||||
env_file:
|
||||
- infra/env/.env.dev.example
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: "3306"
|
||||
CHANNEL_REDIS_HOST: redis
|
||||
CHANNEL_REDIS_PORT: "6379"
|
||||
USE_SPA_UI: ${USE_SPA_UI:-false}
|
||||
WPP_SPA_ASSET_BASE: ${WPP_SPA_ASSET_BASE:-http://localhost:4200/browser}
|
||||
WPP_SPA_ASSET_VERSION: ${WPP_SPA_ASSET_VERSION:-dev}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- python
|
||||
- -c
|
||||
- import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz').status == 200 else 1)
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 15s
|
||||
ports:
|
||||
- "${APP_PORT:-8000}:8000"
|
||||
volumes:
|
||||
- .:/app
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
db:
|
||||
image: mysql:8.4
|
||||
command:
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_unicode_ci
|
||||
environment:
|
||||
MYSQL_DATABASE: wpp_dev
|
||||
MYSQL_USER: wpp_dev
|
||||
MYSQL_PASSWORD: wpp_dev
|
||||
MYSQL_ROOT_PASSWORD: wpp_root
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 10s
|
||||
ports:
|
||||
- "${DB_FORWARD_PORT:-3307}:3306"
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
ports:
|
||||
- "${REDIS_FORWARD_PORT:-6380}:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
spa-assets:
|
||||
profiles: ["spa"]
|
||||
image: node:22-alpine
|
||||
working_dir: /workspace/frontend/angular
|
||||
command: sh -c "npm ci && npm run build && node /workspace/scripts/serve_static_dir.mjs dist/wpp-angular-shell 4200 http://app:8000"
|
||||
ports:
|
||||
- "${SPA_PORT:-4200}:4200"
|
||||
volumes:
|
||||
- .:/workspace
|
||||
- spa_node_modules:/workspace/frontend/angular/node_modules
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
redis_data:
|
||||
spa_node_modules:
|
||||
89
docs/DEVELOPMENT.md
Normal file
89
docs/DEVELOPMENT.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Development Setup
|
||||
|
||||
## MVP Runtime Path
|
||||
|
||||
The current MVP runtime path is the legacy Django host/player UI with `USE_SPA_UI=false`.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
The fastest MVP path is the legacy UI with MySQL and Redis behind Django:
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
App URLs:
|
||||
|
||||
- `http://localhost:8000/admin/login/`
|
||||
- `http://localhost:8000/lobby/ui/host`
|
||||
- `http://localhost:8000/lobby/ui/player`
|
||||
|
||||
Compose uses `infra/env/.env.dev.example` and overrides `DB_HOST`/`CHANNEL_REDIS_HOST` inside containers so the same file also works for host-side commands.
|
||||
If port `8000` is already in use, run with `APP_PORT=18000 docker compose up --build` and use `http://localhost:18000/...` instead.
|
||||
The app container now waits for the database and Redis endpoints before running migrations, so transient Docker DNS startup races do not kill the local stack.
|
||||
|
||||
## Bootstrap
|
||||
|
||||
Create deterministic demo credentials and sample questions with:
|
||||
|
||||
```bash
|
||||
docker compose exec app python manage.py bootstrap_mvp
|
||||
```
|
||||
|
||||
Default output:
|
||||
|
||||
- host username: `demo-host`
|
||||
- host password: `demo-pass`
|
||||
- category slug: `general`
|
||||
- questions: `3`
|
||||
|
||||
You can override the host/category names with `--username`, `--password`, `--category-slug`, and `--category-name`.
|
||||
|
||||
For a quick seeded regression flow, run:
|
||||
|
||||
```bash
|
||||
docker compose exec app python manage.py smoke_staging --artifact /tmp/wpp-smoke.json
|
||||
```
|
||||
|
||||
That creates `smoke-host` / `smoke-pass`, ensures one active smoke question exists, and exercises one full round. Use `bootstrap_mvp` for the reusable local try-out account.
|
||||
|
||||
## Local MVP Smoke
|
||||
|
||||
For a one-command local MVP proof, run:
|
||||
|
||||
```bash
|
||||
./scripts/run_local_mvp_smoke.sh
|
||||
```
|
||||
|
||||
That starts the compose stack, waits for `/healthz`, runs `bootstrap_mvp`, executes `smoke_staging`, and writes a JSON artifact under `artifacts/local/`.
|
||||
If port `8000` is busy on your machine, use `APP_PORT=18000 ./scripts/run_local_mvp_smoke.sh`.
|
||||
|
||||
By default the stack stays up after the smoke so you can continue in the browser. Use `KEEP_STACK_RUNNING=0` if you want the script to shut the stack down on exit.
|
||||
|
||||
## Release Gate
|
||||
|
||||
Run the full local MVP release gate with:
|
||||
|
||||
```bash
|
||||
./scripts/verify_mvp_release.sh
|
||||
```
|
||||
|
||||
That runs repo lint, shared i18n drift checks, Django checks/tests, both frontend test/build pipelines, and a `docker compose config` sanity pass.
|
||||
|
||||
## Optional SPA Shell
|
||||
|
||||
To serve the Angular shell as the UI path instead of the legacy templates:
|
||||
|
||||
```bash
|
||||
USE_SPA_UI=true docker compose --profile spa up --build
|
||||
```
|
||||
|
||||
Use these entry points:
|
||||
|
||||
- `http://localhost:4200/` for the SPA landing page with host login, host session creation, and player join
|
||||
- `http://localhost:4200/host?session=ABC123` for the host shell after a session exists
|
||||
- `http://localhost:4200/player?session=ABC123` for the player shell after join
|
||||
- `http://localhost:8000/lobby/ui/host` and `http://localhost:8000/lobby/ui/player` if you want Django to render the SPA shell with `USE_SPA_UI=true`
|
||||
|
||||
The raw SPA container serves the compiled Angular app at `/` and also proxies `/accounts/*`, `/lobby/*`, and other Django endpoints back to `http://localhost:8000`.
|
||||
`WPP_SPA_ASSET_BASE` still points at `http://localhost:4200/browser` because Django-rendered SPA pages load their static bundles from the compiled Angular `browser/` directory.
|
||||
@@ -9,7 +9,7 @@ Issue #175 requires one shared i18n contract for MVP host/player flows across fr
|
||||
- supported: `en`, `da`
|
||||
- default/fallback: `en`
|
||||
|
||||
Both Angular (`frontend/angular/src/app/lobby-i18n.ts`) and Django (`lobby/i18n.py`) read from this catalog.
|
||||
Django (`lobby/i18n.py`) reads directly from the catalog. Frontend runtimes are Angular-first and use a shared loader (`frontend/shared/i18n/lobby-loader.ts`) so Angular shell and SPA fallback consume the same keyspace + locale normalization.
|
||||
|
||||
## Key naming convention
|
||||
- Domain-first namespaces:
|
||||
@@ -46,3 +46,4 @@ Both Angular (`frontend/angular/src/app/lobby-i18n.ts`) and Django (`lobby/i18n.
|
||||
- `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts`
|
||||
- `frontend/angular/src/app/features/host/host-shell.component.spec.ts`
|
||||
- `frontend/angular/src/app/features/player/player-shell.component.spec.ts`
|
||||
- `frontend/tests/lobby-loader.parity.test.ts` (minimal da/en key parity guard for shared keyspace)
|
||||
|
||||
20
docs/ISSUE-223-PLAYER-AUDIO-GUARD-ARTIFACT.md
Normal file
20
docs/ISSUE-223-PLAYER-AUDIO-GUARD-ARTIFACT.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Issue #223 — Telefon-klient audio guard (artifact)
|
||||
|
||||
## Scope leveret
|
||||
- Telefon-/player-klient installerer en eksplicit audio guard ved mount (`installSecondaryDeviceAudioGuard`).
|
||||
- Guard overskriver `HTMLMediaElement.prototype.play` til en no-op på secondary device (client policy: `client_has_no_audio_output=true`).
|
||||
- Guard fjernes igen ved unmount (`ngOnDestroy`) så øvrige flows/enheder ikke påvirkes.
|
||||
- Ingen audio-controls er eksponeret i player-shell UI.
|
||||
|
||||
## Acceptance mapping
|
||||
1. **Telefon-klient trigger ikke audio playback i kerneflow**
|
||||
- Verificeret af test: `player-shell.component.spec.ts` (`installs secondary-device audio guard while player shell is mounted`).
|
||||
2. **Primær enhed påvirkes ikke negativt**
|
||||
- Guard er scoped til player-shell lifecycle og restore'r original `play` ved destroy.
|
||||
3. **Enkel test/verifikation dokumenteret**
|
||||
- Dokumenteret her + testkørsel nedenfor.
|
||||
|
||||
## Testkørsel
|
||||
- Kommando:
|
||||
- `cd frontend/angular && npm test -- src/app/features/player/player-shell.component.spec.ts`
|
||||
- Resultat: bestået (inkl. audio-guard test).
|
||||
55
docs/ISSUE-224-TRUNK-SEQUENCE-175.md
Normal file
55
docs/ISSUE-224-TRUNK-SEQUENCE-175.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Issue #224 — Trunk-sekvens for #175 (A/B/C)
|
||||
|
||||
Formål: gøre #175 scheduler-klar som tre små, uafhængige og mergeklare bidder.
|
||||
|
||||
## Sekvens
|
||||
|
||||
### A) Backend i18n baseline
|
||||
- Tracking issue: #225
|
||||
- Scope:
|
||||
- Backend resolver til locale (da/en)
|
||||
- Fallback til `en` ved unsupported locale
|
||||
- Stabil fejlkontrakt i payload (`error_code`, `error`, `locale`)
|
||||
- Mergebarhed: Kan merges uden frontend-ændringer.
|
||||
- Acceptance:
|
||||
- Backend tests dækker `da` + fallback `en`
|
||||
- Kontraktfelter er stabile i response
|
||||
|
||||
### B) Shared key-map + locale-kontrakt
|
||||
- Tracking issue: #226
|
||||
- Scope:
|
||||
- Én shared key-map for lobby/kerneflow
|
||||
- Locale-kontrakt (tilladte locales, default locale, fallback-regler)
|
||||
- Dokumentation af naming + ownership
|
||||
- Mergebarhed: Kan merges uden host/player UI-migrering.
|
||||
- Acceptance:
|
||||
- Shared kontrakt findes ét sted
|
||||
- Begge sider kan importere den
|
||||
- Docs opdateret med da/en eksempler
|
||||
|
||||
### C) Angular host/player integration + hardcoded-text cleanup
|
||||
- Tracking issue: #227
|
||||
- Scope:
|
||||
- Angular host/player kerneflow bruger shared keys
|
||||
- Hardcoded tekster fjernes i aftalte kernekomponenter
|
||||
- Sprogskift verificeres i kritiske states
|
||||
- Mergebarhed: Kan merges selvstændigt når frontend-tests er grønne.
|
||||
- Acceptance:
|
||||
- Korrekt i18n-copy i da/en i kerneflow
|
||||
- Ingen hardcoded kerneflow-tekster tilbage
|
||||
- Frontend tests/smoke grønne
|
||||
|
||||
## PR-grænser (per bid)
|
||||
- 1 PR pr. bid (A/B/C) mod `main`
|
||||
- Mål: ~200–300 net LOC per PR (ekskl. generated artefakter)
|
||||
- Undgå cross-layer scope creep
|
||||
- Review-tid <30 min
|
||||
|
||||
## Overordnet acceptance for #224
|
||||
- A/B/C-sekvens er tydelig med links
|
||||
- Hver bid er mergebar isoleret
|
||||
- Scheduler kan assign’e direkte uden ekstra afklaring
|
||||
|
||||
## Verification (docs-only)
|
||||
- Verificeret at dokumentet kun beskriver trunk-sekvensen for issue #224 og linker til #225/#226/#227.
|
||||
- Ingen runtime-kode ændret; der er derfor ikke kørt kode/tests i denne PR.
|
||||
44
docs/ISSUE-225-BACKEND-I18N-BASELINE-ARTIFACT.md
Normal file
44
docs/ISSUE-225-BACKEND-I18N-BASELINE-ARTIFACT.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# ISSUE-225 Artifact — Backend i18n baseline (resolver + fallback)
|
||||
|
||||
Issue: **#225** (`[MVP][READY] #175-A: Backend i18n baseline (resolver + fallback)`)
|
||||
|
||||
## Scope verified
|
||||
|
||||
- Backend locale resolver supports `da` + `en` and normalizes language tags.
|
||||
- Unsupported locale requests fall back to default locale (`en`).
|
||||
- Error payload contract is stable for API errors:
|
||||
- `error_code`
|
||||
- `error`
|
||||
- `locale`
|
||||
|
||||
## Implementation references
|
||||
|
||||
- Locale resolution + fallback chain:
|
||||
- `lobby/i18n.py`
|
||||
- `resolve_locale()`
|
||||
- `resolve_error_message()`
|
||||
- `api_error()`
|
||||
- Shared locale contract source:
|
||||
- `shared/i18n/lobby.json` (`locales.default=en`, supported includes `en`, `da`)
|
||||
|
||||
## Acceptance checks run
|
||||
|
||||
Command:
|
||||
|
||||
```bash
|
||||
.venv/bin/python manage.py test \
|
||||
lobby.tests.I18nResolverTests \
|
||||
lobby.tests.LobbyFlowTests \
|
||||
lobby.tests.StartRoundTests
|
||||
```
|
||||
|
||||
Result (2026-03-02):
|
||||
|
||||
- `Ran 28 tests in 24.781s — OK`
|
||||
- `System check identified no issues (0 silenced).`
|
||||
- Confirms resolver behavior for locale normalization + fallback and stable error payload fields across flow endpoints.
|
||||
|
||||
## Notes
|
||||
|
||||
- Existing merged follow-ups in `main` include Accept-Language parsing fixes for q-values and locale tag normalization.
|
||||
- This artifact documents the final baseline state and verification evidence for #225.
|
||||
30
docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md
Normal file
30
docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# ISSUE-226 — Shared key-map + locale-kontrakt (backend/frontend)
|
||||
|
||||
## Source of truth
|
||||
- Single shared artifact: `shared/i18n/lobby.json`
|
||||
- Ownership is documented under `contract.ownership` in the same artifact.
|
||||
|
||||
## Locale contract
|
||||
Defined under `contract.locale`:
|
||||
- default locale: `en`
|
||||
- supported locales: `en`, `da`
|
||||
- fallback rule: use default locale when requested locale is unsupported or a key translation is missing.
|
||||
|
||||
## Shared backend→frontend key-map
|
||||
Defined under `contract.backend_to_frontend_error_keys`.
|
||||
|
||||
Examples:
|
||||
- `session_not_found -> session_not_found`
|
||||
- `session_not_joinable -> join_failed`
|
||||
- `round_start_invalid_phase -> start_round_failed`
|
||||
|
||||
This allows backend error codes to remain stable while frontend copy keys stay UX-oriented.
|
||||
|
||||
## da/en example values
|
||||
From `shared/i18n/lobby.json`:
|
||||
- `frontend.errors.session_code_required.en = "Session code is required."`
|
||||
- `frontend.errors.session_code_required.da = "Sessionskoden er påkrævet."`
|
||||
|
||||
## Verification
|
||||
- Backend: `python manage.py test lobby.tests.I18nResolverTests`
|
||||
- Frontend: `npm test -- --run tests/lobby-i18n.contract.test.ts`
|
||||
16
docs/ISSUE-250-PRIMARY-DEVICE-AUDIO-POLICY-ARTIFACT.md
Normal file
16
docs/ISSUE-250-PRIMARY-DEVICE-AUDIO-POLICY-ARTIFACT.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Issue #250 Artifact — MVP guardrail (telefon-klient uden lydafspilning)
|
||||
|
||||
## Scope
|
||||
Implementeret guardrail for `primary-device only` audio policy i SPA player-flow.
|
||||
|
||||
## Acceptance mapping
|
||||
1. **Telefon-klient flow indeholder ingen audio-play init-path**
|
||||
- Test: `player-shell.component.spec.ts` → `does not trigger original media play during player-shell init path`.
|
||||
2. **Primær enhed policy er dokumenteret og testbar**
|
||||
- Policy i contract: `shared/i18n/lobby.json` → `frontend.capabilities.client_has_no_audio_output=true`.
|
||||
- Testbar via eksisterende guard-tests + init-path test i player-shell specs.
|
||||
3. **Krav er refereret i SPA-plan/cutover-noter**
|
||||
- Dokumenteret i `docs/spa-cutover-flag.md` under *MVP audio policy guardrail (telefon-klient)*.
|
||||
|
||||
## UX/i18n note
|
||||
- Player shell viser advarselstekst via i18n key: `frontend.ui.player.audio_policy_notice`.
|
||||
124
docs/ISSUE-251-RELEASE-OFTEN-SPA-MVP-BATCH-PLAN.md
Normal file
124
docs/ISSUE-251-RELEASE-OFTEN-SPA-MVP-BATCH-PLAN.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Issue #251 — Release-often lane for SPA MVP (3 micro-PR batches)
|
||||
|
||||
## Formål
|
||||
Bryde SPA MVP-arbejdet op i **3 merge-klare micro-PRs** med tydelige acceptance criteria,
|
||||
så vi kan levere værdi oftere, reducere review-risiko og holde `main` grøn.
|
||||
|
||||
## Scope (issue-bound)
|
||||
Denne plan dækker kun planlægning/acceptance for den kommende SPA MVP-lane.
|
||||
Implementering af de konkrete features sker i efterfølgende PRs.
|
||||
|
||||
## Hard acceptance criteria for issue #251
|
||||
- [x] Der findes en dokumenteret plan med præcis **3 batches**.
|
||||
- [x] Hver batch har:
|
||||
- [x] mål og afgrænsning
|
||||
- [x] konkrete leverancer (kodeområder)
|
||||
- [x] test/checks før merge
|
||||
- [x] rollback-note
|
||||
- [x] "ikke med i denne batch" for at undgå scope creep
|
||||
- [x] Batch-rækkefølgen er dependencies-sikker (batch B bygger på batch A, batch C på batch B).
|
||||
- [x] Hver batch kan merges/releaseres uafhængigt uden at blokere drift på `main`.
|
||||
- [x] Planen linker til konkrete dev-opgaver for lane-kørsel.
|
||||
|
||||
## Batch-plan (merge-klare micro-PRs)
|
||||
|
||||
### Batch A — SPA shell + routing baseline
|
||||
**Mål:** Et stabilt SPA-skelet med route-struktur og guard-basics.
|
||||
|
||||
**Leverancer (kodeområder)**
|
||||
- `frontend/angular/src/app/app.routes.ts` (host/player entry routes + fallback)
|
||||
- `frontend/angular/src/app/session-route-context.ts` (baseline route guards)
|
||||
- `frontend/angular/src/app/app.component.*` (shell-nav + route outlet wiring)
|
||||
- `lobby/templates/lobby/spa_shell.html` (kompatibel shell-entry ved SPA cutover)
|
||||
|
||||
**Done-kriterier**
|
||||
- Host- og player-entry routes kan åbnes uden runtime-fejl i samme SPA-shell.
|
||||
- Route guards afviser ugyldige parametre deterministisk (ingen hard crash).
|
||||
- `USE_SPA_UI=false` fortsætter med legacy-flow uden regression.
|
||||
|
||||
**Checks før merge**
|
||||
- `cd frontend/angular && npm test -- --run src/app/app.routes.spec.ts src/app/session-route-context.spec.ts`
|
||||
- `cd frontend/angular && npm run build`
|
||||
- Manual smoke: `/lobby/ui/host` + `/lobby/ui/player` (både `USE_SPA_UI=false` og `true`).
|
||||
|
||||
**Rollback-note**
|
||||
- Sæt `USE_SPA_UI=false` og redeploy; verificér legacy routes svarer 200.
|
||||
|
||||
**Ikke med i batch A**
|
||||
- Fuld gameplay-state synkronisering.
|
||||
- Audio/polish og i18n finpudsning ud over baseline wiring.
|
||||
|
||||
---
|
||||
|
||||
### Batch B — Session-state + host/player sync MVP
|
||||
**Mål:** Korrekt synkronisering af session-state mellem host og spillerklient.
|
||||
|
||||
**Leverancer (kodeområder)**
|
||||
- `frontend/angular/src/app/features/host/host-shell.component.ts`
|
||||
- `frontend/angular/src/app/features/player/player-shell.component.ts`
|
||||
- `frontend/src/api/angular-client.ts` (MVP-kald for status/phase-overgange)
|
||||
|
||||
**Done-kriterier**
|
||||
- Host handlinger (`start`, `show`, `mix`, `score`, `next`, `finish`) afspejles hos player uden side-reload.
|
||||
- Session-phase transitions er deterministiske i happy-path (`lobby -> question -> score -> next/finish`).
|
||||
- Guardrails reducerer race-condition regressions ved hurtige phase-skift.
|
||||
|
||||
**Checks før merge**
|
||||
- `cd frontend/angular && npm test -- --run src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts`
|
||||
- `cd frontend && npm test -- --run tests/angular-api-client.test.ts`
|
||||
- Manual smoke: host action -> player phase sync indenfor forventet latenstid.
|
||||
|
||||
**Rollback-note**
|
||||
- Slå `USE_SPA_UI=false`, redeploy, og kør hurtig gameplay-smoke i legacy flow.
|
||||
|
||||
**Ikke med i batch B**
|
||||
- Avanceret UX-polish/animation.
|
||||
- Udvidet observability udenfor MVP-kritiske logs.
|
||||
|
||||
---
|
||||
|
||||
### Batch C — Lobby/join/start minimal flow + release readiness
|
||||
**Mål:** Gøre SPA MVP release-klar med fokus på stabilitet og driftssikkerhed omkring det minimale flow.
|
||||
|
||||
**Leverancer (kodeområder)**
|
||||
- `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts` + relevante shell-tests
|
||||
- `docs/UI_SMOKE.md` + `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md`
|
||||
- `CHANGELOG.md` release-input for SPA MVP lane
|
||||
|
||||
**Done-kriterier**
|
||||
- End-to-end minimal flow (`lobby -> join -> start`) er dokumenteret PASS i SPA.
|
||||
- Fejl-/empty-/loading states for flowets kritiske skærme er verificeret.
|
||||
- Driftsteam kan udføre cutover + rollback uden tvetydighed.
|
||||
|
||||
**Checks før merge**
|
||||
- `cd frontend/angular && npm test -- --run src/app/i18n-mvp-flow-smoke.spec.ts`
|
||||
- `python manage.py test lobby.tests.LobbyFlowTests`
|
||||
- Opdateret staging-smoke artifact med UTC tidsstempler og gate-resultat.
|
||||
|
||||
**Rollback-note**
|
||||
- Brug eksisterende playbook i `docs/spa-cutover-flag.md` (`USE_SPA_UI=false` + asset-version rollback).
|
||||
|
||||
**Ikke med i batch C**
|
||||
- Post-MVP featureudvidelser.
|
||||
- Større refactors uden direkte release-værdi.
|
||||
|
||||
## Rækkefølge og parallel-kørsel
|
||||
- **Dependency-rækkefølge:** A -> B -> C.
|
||||
- **Kan køres parallelt uden konflikt:**
|
||||
- Test-/doc-forberedelse til C kan startes parallelt med B (ingen blokering af runtime-kode), men merges først efter B.
|
||||
- Drift-smoke templates kan opdateres tidligt, så længe de ikke ændrer runtime-adfærd.
|
||||
- **Må ikke køre parallelt:**
|
||||
- Runtime routing/guard ændringer i A og session-sync logik i B på samme filer uden feature-flag koordinering.
|
||||
|
||||
## Konkret lane-opgavebinding (dev-opgaver)
|
||||
- Batch A PR: `feat/issue-251-batch-1-spa-shell-routing`
|
||||
- Batch B PR: `feat/issue-251-batch-2-session-sync`
|
||||
- Batch C PR: `feat/issue-251-batch-3-lobby-join-start-release-readiness`
|
||||
|
||||
Hver PR skal linke tilbage til issue #251 og inkludere test-evidence + rollback-check.
|
||||
|
||||
## Merge-gate for alle 3 batches
|
||||
- Små PRs (mål: reviewbar størrelse, helst < ~300 netto-linjer når muligt).
|
||||
- Grøn CI/checks før review-request.
|
||||
- Tydelig PR-beskrivelse med: scope, test evidence, out-of-scope.
|
||||
- Ingen skjulte sideeffekter på tværs af apps/domæner.
|
||||
27
docs/ISSUE-252-REACT-FALLBACK-TRIGGERS-ARTIFACT.md
Normal file
27
docs/ISSUE-252-REACT-FALLBACK-TRIGGERS-ARTIFACT.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# ISSUE-252 Artifact — React fallback trigger criteria (delivery-blocking only)
|
||||
|
||||
Issue: **#252**
|
||||
|
||||
## Leveret ændring
|
||||
Dokumentationen i `docs/spa-cutover-flag.md` er opdateret med en dedikeret sektion:
|
||||
- **React fallback trigger-kriterier (kun delivery-blocking)**
|
||||
- klare **tilladelses-kriterier** (alle skal være opfyldt)
|
||||
- tydelige **scope-limits**
|
||||
- eksplicitte **ikke-tilladte** anvendelser
|
||||
|
||||
## Acceptance mapping
|
||||
1. **Clear trigger criteria**
|
||||
- Definerer præcist hvornår fallback er tilladt:
|
||||
- aktiv delivery-blocking fejl i Angular SPA
|
||||
- ingen sikker Angular-fix inden release-vinduet
|
||||
- rollback alene er utilstrækkelig for leveringsbehovet
|
||||
- beslutning + evidens logges eksplicit (inkl. issue/incident-reference)
|
||||
2. **Scope limits**
|
||||
- Begrænset til delivery-blocking host/player-paths.
|
||||
- Ingen feature-bundling eller ikke-kritiske ændringer.
|
||||
- Midlertidig anvendelse kun i aktiv incident/release-vindue.
|
||||
3. **When fallback is allowed**
|
||||
- Kun når alle trigger-kriterier er opfyldt og dokumenteret.
|
||||
|
||||
## Resultat
|
||||
Issue #252 er dokumenteret med operationelle guardrails, så React fallback kun bruges i kontrollerede, leveringsblokerende situationer.
|
||||
@@ -0,0 +1,32 @@
|
||||
# ISSUE-257 Artifact — Shared i18n keyspace + frontend loader (da/en, Angular-first)
|
||||
|
||||
Issue: **#257** (`[MVP][READY] #175-B: Shared i18n keyspace + frontend loader`)
|
||||
|
||||
## Acceptance checklist
|
||||
|
||||
- [x] **Delt key-strategi dokumenteret (frontend/backend)**
|
||||
- Arkitektur/deling beskrevet i `docs/I18N_ARCHITECTURE.md`.
|
||||
- Shared contract + keyspace source of truth: `shared/i18n/lobby.json`.
|
||||
|
||||
- [x] **Frontend loader kan indlæse da+en med samme keyspace**
|
||||
- Shared loader: `frontend/shared/i18n/lobby-loader.ts`.
|
||||
- Angular-first integration via `frontend/angular/src/app/lobby-i18n.ts` (samme loader/samme keyspace).
|
||||
- Locale-normalisering inkluderer underscore/hyphen variants (`da_DK` → `da`).
|
||||
|
||||
- [x] **Minimal check for key-paritet da/en**
|
||||
- `collectLocaleParityIssues(...)` i shared loader.
|
||||
- Testet i `frontend/tests/lobby-loader.parity.test.ts`.
|
||||
|
||||
- [x] **Ingen API-kontraktbrud**
|
||||
- Contract-test: `frontend/tests/lobby-i18n.contract.test.ts`.
|
||||
- Drift-check mellem manifest og katalog: `scripts/check_i18n_drift.py`.
|
||||
|
||||
## Kørte checks
|
||||
|
||||
```bash
|
||||
python3 scripts/check_i18n_drift.py
|
||||
cd frontend && npm test -- --run tests/lobby-loader.parity.test.ts tests/lobby-i18n.contract.test.ts
|
||||
cd frontend/angular && npm test -- --run src/app/lobby-i18n.spec.ts src/app/i18n-mvp-flow-smoke.spec.ts
|
||||
```
|
||||
|
||||
Resultat: alle checks grønne.
|
||||
37
docs/ISSUE-257-SHARED-I18N-LOADER-ARTIFACT.md
Normal file
37
docs/ISSUE-257-SHARED-I18N-LOADER-ARTIFACT.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# ISSUE-257 Artifact — shared i18n keyspace + frontend loader (Angular-first)
|
||||
|
||||
Issue: **#257** (`[MVP][READY] #175-B: Shared i18n keyspace + frontend loader (da/en, Angular-first)`)
|
||||
|
||||
## Acceptance mapping
|
||||
|
||||
### 1) Delt key-strategi dokumenteret (frontend/backend)
|
||||
- Shared contract source: `shared/i18n/lobby.json`
|
||||
- Architecture doc: `docs/I18N_ARCHITECTURE.md`
|
||||
- Key-map/contract doc: `docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md`
|
||||
|
||||
### 2) Frontend loader kan indlæse da+en med samme keyspace
|
||||
- Shared loader: `frontend/shared/i18n/lobby-loader.ts`
|
||||
- Angular-first consumer path:
|
||||
- `frontend/src/spa/lobby-i18n.ts`
|
||||
- Angular shell/tests continue to consume same shared catalog through shared loader contract.
|
||||
|
||||
### 3) Minimal check for key-paritet da/en
|
||||
- Guard test: `frontend/tests/lobby-loader.parity.test.ts`
|
||||
- Contract test: `frontend/tests/lobby-i18n.contract.test.ts`
|
||||
|
||||
### 4) Ingen API-kontraktbrud
|
||||
- Frontend API contract smoke:
|
||||
- `frontend/angular/src/app/api-contract-smoke.spec.ts`
|
||||
- `frontend/tests/angular-api-client.test.ts`
|
||||
|
||||
## Verification run (this lane)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm test -- --run tests/lobby-loader.parity.test.ts tests/lobby-i18n.contract.test.ts tests/angular-api-client.test.ts
|
||||
|
||||
cd ../frontend/angular
|
||||
npm test -- --run src/app/api-contract-smoke.spec.ts
|
||||
```
|
||||
|
||||
Result: PASS (all selected suites green).
|
||||
18
docs/ISSUE-260-PHONE-CLIENT-NO-AUDIO-GUARD-ARTIFACT.md
Normal file
18
docs/ISSUE-260-PHONE-CLIENT-NO-AUDIO-GUARD-ARTIFACT.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Issue #260 Artifact — Phone/client no-audio guard (primary-device only playback)
|
||||
|
||||
## Scope
|
||||
Added regression coverage for MVP audio policy to ensure phone/client flows never claim playback ownership, while primary-device playback stays unaffected.
|
||||
|
||||
## Acceptance mapping
|
||||
1. **client/phone triggers no playback**
|
||||
- Existing test coverage retained in `player-shell.component.spec.ts`:
|
||||
- `does not trigger original media play during player-shell init path`
|
||||
- `installs secondary-device audio guard while player shell is mounted`
|
||||
2. **primary device playback unaffected**
|
||||
- New negative test in `player-shell.component.spec.ts`:
|
||||
- `keeps primary-device playback untouched when no-audio capability is disabled`
|
||||
3. **one negative test for phone audio**
|
||||
- Existing negative path preserved:
|
||||
- `does not trigger original media play during player-shell init path`
|
||||
4. **no backend contract changes**
|
||||
- Frontend test/docs-only scope; no backend contract files changed.
|
||||
80
docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md
Normal file
80
docs/ISSUE-277-SHARED-I18N-PARITY-ARTIFACT.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# ISSUE-277 Artifact — shared i18n registry parity report (Django ↔ Angular MVP)
|
||||
|
||||
Issue: **#277** (`[READY][#175][P3] Shared i18n registry artifact: backend/frontend keyspace parity report`)
|
||||
|
||||
## Artifact metadata
|
||||
|
||||
- `artifact_id`: `issue-277-shared-i18n-parity-report`
|
||||
- `artifact_version`: `1.0`
|
||||
- `catalog_source`: `shared/i18n/lobby.json`
|
||||
- `generator`: `scripts/report_i18n_parity.py`
|
||||
|
||||
## Naming/version rules (email-manager-inspired strategy)
|
||||
|
||||
- **Single canonical artifact per issue**: issue-bundne rapporter navngives `docs/ISSUE-<nr>-<slug>-ARTIFACT.md`.
|
||||
- **Stable artifact identity**: `artifact_id` ændres ikke ved tekstlige opdateringer i samme rapporttype; det er den faste reference i review/ops.
|
||||
- **Explicit artifact versioning**: `artifact_version` bumpes, når rapportlogik eller scope ændres, så drift/review kan se forskel på format- vs. dataændringer.
|
||||
- **Shared namespace first**: keys refereres med fulde navnerum (`frontend.ui.*`, `frontend.errors.*`, `backend.error_codes.*`, `backend.errors.*`) i stedet for lokale aliases i artefakter.
|
||||
- **Source-of-truth before consumers**: rapporten afledes fra `shared/i18n/lobby.json`; Django/Angular beskrives som consumers af samme registry og ikke som parallelle kontrakter.
|
||||
|
||||
## MVP-critical parity summary
|
||||
|
||||
- Frontend UI gameplay keys checked: **16** → `OK`
|
||||
- Frontend error keys checked: **7** → `OK`
|
||||
- Backend gameplay/error codes checked: **9** → `OK`
|
||||
- Distinct frontend error keys reached from backend MVP flow: **6** (`join_failed, nickname_invalid, nickname_taken, session_code_required, session_not_found, start_round_failed`)
|
||||
|
||||
Status: **Shared locale matrix is aligned (`en`, `da`) and backend→frontend error handling is contract-complete for MVP-critical flow.**
|
||||
|
||||
## Django ↔ Angular parity matrix (MVP-critical error contract)
|
||||
|
||||
| Backend code (`backend.error_codes.*`) | Django message key (`backend.errors.*`) | Angular key (`frontend.errors.*`) | Locales `en/da` | Parity note |
|
||||
|---|---|---|---|---|
|
||||
| `session_code_required` | `session_code_required` | `session_code_required` | `OK` | 1:1 |
|
||||
| `nickname_invalid` | `nickname_invalid` | `nickname_invalid` | `OK` | 1:1 |
|
||||
| `session_not_found` | `session_not_found` | `session_not_found` | `OK` | 1:1 |
|
||||
| `session_not_joinable` | `session_not_joinable` | `join_failed` | `OK` | mapped alias |
|
||||
| `nickname_taken` | `nickname_taken` | `nickname_taken` | `OK` | 1:1 |
|
||||
| `category_slug_required` | `category_slug_required` | `start_round_failed` | `OK` | many:1 collapse |
|
||||
| `category_not_found` | `category_not_found` | `start_round_failed` | `OK` | many:1 collapse |
|
||||
| `round_start_invalid_phase` | `round_start_invalid_phase` | `start_round_failed` | `OK` | many:1 collapse |
|
||||
| `round_already_configured` | `round_already_configured` | `start_round_failed` | `OK` | many:1 collapse |
|
||||
|
||||
## Scope notes
|
||||
|
||||
- **Django** consumes backend codes/messages directly from `shared/i18n/lobby.json` via `lobby/i18n.py`.
|
||||
- **Angular** consumes the same registry via `frontend/shared/i18n/lobby-loader.ts` and runtime helpers in `frontend/angular/src/app/lobby-i18n.ts`.
|
||||
- **Parity in MVP** is therefore strongest on the shared error contract and locale matrix; gameplay UI labels are frontend-owned but still live in the same registry.
|
||||
|
||||
## Verified MVP gameplay UI keyspace present in the shared registry
|
||||
|
||||
- `frontend.ui.host.title`
|
||||
- `frontend.ui.player.title`
|
||||
- `frontend.ui.common.session_code`
|
||||
- `frontend.ui.player.nickname`
|
||||
- `frontend.ui.player.join`
|
||||
- `frontend.ui.host.start_round`
|
||||
- `frontend.ui.host.show_question`
|
||||
- `frontend.ui.player.lie_label`
|
||||
- `frontend.ui.player.submit_lie`
|
||||
- `frontend.ui.player.submit_guess`
|
||||
- `frontend.ui.host.mix_answers`
|
||||
- `frontend.ui.host.calculate_scores`
|
||||
- `frontend.ui.host.load_scoreboard`
|
||||
- `frontend.ui.host.final_leaderboard`
|
||||
- `frontend.ui.player.final_leaderboard`
|
||||
- `frontend.ui.common.points_short`
|
||||
|
||||
## Concrete deviations / follow-up items
|
||||
|
||||
1. **Error granularity collapse remains intentional**: backend codes `category_slug_required, category_not_found, round_start_invalid_phase, round_already_configured` all map to `frontend.errors.start_round_failed`. Follow-up only if product wants case-specific Angular copy instead of one shared host failure message.
|
||||
2. **Frontend-only fallback copy is not mirrored in Django**: `frontend.errors.unknown` and `frontend.errors.session_fetch_failed` are Angular-side resilience keys, not backend contract keys. Follow-up if API responses should expose stable backend equivalents for these states.
|
||||
3. **Gameplay UI labels are registry-shared but not backend-rendered**: `frontend.ui.host.*`, `frontend.ui.player.*`, and `frontend.ui.common.*` are available in the shared artifact, but Django currently consumes only the backend error slice. Follow-up only if server-rendered views must guarantee the same UI label surface as Angular.
|
||||
|
||||
## Re-run
|
||||
|
||||
```bash
|
||||
python3 scripts/check_i18n_drift.py
|
||||
python3 scripts/report_i18n_parity.py
|
||||
python3 scripts/check_i18n_parity_artifact.py
|
||||
```
|
||||
36
docs/ISSUE-278-SMOKE-E2E-GATE-ARTIFACT.md
Normal file
36
docs/ISSUE-278-SMOKE-E2E-GATE-ARTIFACT.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Issue #278 Artifact — smoke/e2e gate for da+en locale flow and primary-only audio
|
||||
|
||||
## Scope
|
||||
Acceptance for `[READY][#175][P4]`:
|
||||
1. Verify one MVP host+player smoke run in `en`.
|
||||
2. Verify one MVP host+player smoke run in `da`.
|
||||
3. Verify audio routing remains `primary-device only` so phone/player clients never take playback ownership.
|
||||
|
||||
Dette er en gate-/evidensleverance. Ingen ny produktfunktion ud over test/verifikation.
|
||||
|
||||
## Implemented smoke gate
|
||||
Angular smoke spec: `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts`
|
||||
|
||||
The gate now runs two explicit locale scenarios:
|
||||
- `en`: host refresh/start-round copy + player submit-guess copy
|
||||
- `da`: samme flow med dansk copy
|
||||
|
||||
Audio-policy delen af samme smoke-spec verificerer:
|
||||
- host/primary playback path er uændret før player mount
|
||||
- player mount installerer no-audio guard på secondary device
|
||||
- guard fjernes igen ved unmount, så primary path fortsat er eneste aktive output
|
||||
|
||||
## Recommended verification command
|
||||
Køres fra `frontend/angular`:
|
||||
|
||||
```bash
|
||||
npm test -- --run src/app/i18n-mvp-flow-smoke.spec.ts src/app/lobby-i18n.spec.ts src/app/features/player/player-shell.component.spec.ts
|
||||
```
|
||||
|
||||
## Why this is the gate
|
||||
- `i18n-mvp-flow-smoke.spec.ts` giver en lille, samlet smoke/e2e-lignende verifikation af host+player i begge locale-kontekster.
|
||||
- `lobby-i18n.spec.ts` holder shared locale propagation + contract fallback grøn.
|
||||
- `player-shell.component.spec.ts` dækker den dybere regressionflade for audio-guard på secondary device.
|
||||
|
||||
## Conclusion
|
||||
Gate’en verificerer nu eksplicit begge locale-runs (`da` + `en`) og bekræfter primary-only audio routing i MVP-flowet.
|
||||
168
docs/ISSUE-279-I18N-MVP-CLOSEOUT.md
Normal file
168
docs/ISSUE-279-I18N-MVP-CLOSEOUT.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# ISSUE-279 — i18n MVP close-out note
|
||||
|
||||
Issue: **#279** (`[READY][#175][P5] MVP close-out note: migration/changelog + release-readiness checklist for i18n`)
|
||||
|
||||
## Scope
|
||||
|
||||
Dette dokument lukker MVP-sporet for issue #175 med tre konkrete ting:
|
||||
|
||||
1. en migrationsnote for release/deploy,
|
||||
2. changelog-indhold der kan genbruges i næste release-note,
|
||||
3. en release-readiness checkliste for i18n, forankret i et verificeret snapshot af `main` ved reviewtidspunktet.
|
||||
|
||||
Repo-state ved review-opdatering:
|
||||
- `main` peger nu på merge commit `1bc4c27` (PR #283), og inkluderer også PR #282 via merge commit `6ad5430`.
|
||||
- Denne note er opdateret mod repo-tilstanden verificeret 2026-03-13 UTC, ikke en løbende garanti for senere `main`-ændringer.
|
||||
- Denne revision afløser de tidligere snapshot-antagelser fra PR-historikken, hvor #282/#283 endnu ikke var landet.
|
||||
- Der er ingen åbne release-afklaringer tilbage for PR #282/#283; begge er allerede landet på `main`.
|
||||
|
||||
## Current i18n MVP state on `main`
|
||||
|
||||
Følgende er allerede til stede på `main`:
|
||||
|
||||
- **Shared contract** i `shared/i18n/lobby.json`
|
||||
- default locale: `en`
|
||||
- supported locales: `en`, `da`
|
||||
- fælles frontend/backend keyspace + fallback-regler
|
||||
- **Django bootstrap** via `partyhub/i18n_bootstrap.py` og `partyhub/settings.py`
|
||||
- `LocaleMiddleware` aktiv
|
||||
- `LANGUAGE_CODE` + `LANGUAGES` bootstrappes fra shared catalog
|
||||
- **Backend locale/error flow** via `lobby/i18n.py`
|
||||
- normalisering af locale-tags
|
||||
- locale-aware fejlpayload med `error_code`, `error`, `locale`
|
||||
- fallback til `en` når locale eller oversættelse mangler
|
||||
- **Angular MVP wiring** via
|
||||
- `frontend/shared/i18n/lobby-loader.ts`
|
||||
- `frontend/angular/src/app/lobby-i18n.ts`
|
||||
- host/player shells med locale switch og shared copy-opslag
|
||||
- **Drift/parity guardrails**
|
||||
- `shared/i18n/key-manifest.json`
|
||||
- `scripts/check_i18n_drift.py`
|
||||
- frontend parity/contract tests
|
||||
- **Existing documentation/artifacts**
|
||||
- `docs/I18N_ARCHITECTURE.md`
|
||||
- `docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md`
|
||||
- `docs/ISSUE-225-BACKEND-I18N-BASELINE-ARTIFACT.md`
|
||||
- `docs/ISSUE-257-SHARED-I18N-KEYSPACE-FRONTEND-LOADER-ARTIFACT.md`
|
||||
- `docs/ISSUE-207-I18N-AUDIO-SMOKE-ARTIFACT.md`
|
||||
- `docs/i18n-drift-check.md`
|
||||
|
||||
## Migration note for release
|
||||
|
||||
### Schema impact
|
||||
|
||||
**Der er ingen nye Django-migrations i selve i18n-MVP-sporet på `main`.**
|
||||
|
||||
Den i18n-relaterede leverance ligger i shared catalog, locale-bootstrap, error-payload-kontrakt, Angular wiring og test/documentation. Den kræver derfor ikke en særskilt i18n-database-migration for at gå i release.
|
||||
|
||||
### Release/deploy expectation
|
||||
|
||||
Selv om issue #279 ikke introducerer schemaændringer, skal release-flow stadig følge repoets generelle migreringsgate:
|
||||
|
||||
```bash
|
||||
python manage.py makemigrations --check --dry-run
|
||||
python manage.py migrate --check --noinput
|
||||
```
|
||||
|
||||
Hvorfor: release-policyen kræver, at vi undgår code/schema drift, og staging-smoke-suiten forventer eksplicit migration consistency check.
|
||||
|
||||
### Praktisk migrationskonklusion
|
||||
|
||||
Til release-notes/deploy-runbook kan i18n-sporet beskrives sådan her:
|
||||
|
||||
- **Migration impact:** none for i18n MVP itself
|
||||
- **Deploy requirement:** run standard Django migration consistency checks anyway
|
||||
- **Rollback note:** rollback er primært kode-/asset-baseret (shared catalog, frontend bundles, backend locale resolver), ikke schema-baseret
|
||||
|
||||
## Suggested changelog content
|
||||
|
||||
Følgende tekst kan bruges direkte i næste unreleased/release-sektion:
|
||||
|
||||
```markdown
|
||||
### i18n
|
||||
- Shared da/en lobby i18n contract is now wired across Django and Angular MVP flows via `shared/i18n/lobby.json`.
|
||||
- Backend error payloads expose stable locale-aware fields (`error_code`, `error`, `locale`) with fallback to English for unsupported locales.
|
||||
- Angular host/player shells now consume shared i18n copy, persist preferred locale, and keep audio-policy messaging aligned with the shared catalog.
|
||||
- Added repo guardrails for i18n drift/parity through the shared key manifest, drift checker, and focused frontend/backend contract tests.
|
||||
- Release migration impact for the i18n MVP is **none** beyond the standard Django migration consistency checks.
|
||||
```
|
||||
|
||||
Kort version til annoterede release-notes:
|
||||
|
||||
```markdown
|
||||
## i18n MVP close-out
|
||||
- Shared da/en contract is active across backend + Angular MVP shell.
|
||||
- Locale fallback remains `en` for unsupported requests and missing translations.
|
||||
- No i18n-specific schema migration is required; keep standard `migrate --check --noinput` in release verification.
|
||||
```
|
||||
|
||||
## Release-readiness checklist for i18n
|
||||
|
||||
Status er vurderet mod verificeret snapshot `main@1bc4c27` (reviewet 2026-03-13 UTC, inkl. PR #282/#283).
|
||||
|
||||
### 1) Shared contract and locale behavior
|
||||
|
||||
- [x] Shared catalog findes i `shared/i18n/lobby.json`.
|
||||
- [x] Default/supported locales er dokumenteret og implementeret som `en` + `da`.
|
||||
- [x] Backend bruger shared contract til locale-aware fejlbeskeder.
|
||||
- [x] Frontend/Angular bruger shared loader + shared keyspace.
|
||||
- [x] Fallback-regel til `en` er dokumenteret og testet.
|
||||
|
||||
### 2) Verification artifacts and local checks
|
||||
|
||||
- [x] Arkitektur-note findes: `docs/I18N_ARCHITECTURE.md`.
|
||||
- [x] Baseline artifact for issue #175 findes.
|
||||
- [x] Backend artifact for issue #225 findes.
|
||||
- [x] Frontend/shared loader artifact for issue #257 findes.
|
||||
- [x] Drift-check dokumentation findes i `docs/i18n-drift-check.md`.
|
||||
- [x] Parity artifact fra issue #277 er på `main` via PR #282 (merge commit `6ad5430`).
|
||||
|
||||
### 3) Code readiness on current branch topology
|
||||
|
||||
- [x] Angular MVP host/player i18n flow er på `main` (PR #281).
|
||||
- [x] Shared locale/bootstrap wiring er på `main`.
|
||||
- [x] Django i18n hardening fra issue #275 er på `main` via PR #283 (merge commit `1bc4c27`).
|
||||
- [x] PR #283 er ikke længere en separat release-afklaring; hardeningen er allerede indarbejdet på `main`.
|
||||
|
||||
### 4) Release gate before shipping i18n as “done”
|
||||
|
||||
- [x] PR #282 er allerede merged, så parity-artifact-status er afklaret på `main`.
|
||||
- [x] PR #283 er allerede merged, så backend hardening-status er afklaret på `main`.
|
||||
- [ ] Kør drift-check fra repo root:
|
||||
```bash
|
||||
python3 scripts/check_i18n_drift.py
|
||||
```
|
||||
- [ ] Kør backend i18n regressions:
|
||||
```bash
|
||||
. .venv/bin/activate && python manage.py test \
|
||||
partyhub.tests_i18n_bootstrap \
|
||||
lobby.tests.I18nResolverTests
|
||||
```
|
||||
- [ ] Kør frontend shared-contract/parity checks:
|
||||
```bash
|
||||
cd frontend && npm test -- --run \
|
||||
tests/lobby-loader.parity.test.ts \
|
||||
tests/lobby-i18n.contract.test.ts
|
||||
```
|
||||
- [ ] Kør Angular MVP locale smoke:
|
||||
```bash
|
||||
cd frontend/angular && npm test -- --run \
|
||||
src/app/lobby-i18n.spec.ts \
|
||||
src/app/i18n-mvp-flow-smoke.spec.ts \
|
||||
src/app/features/host/host-shell.component.spec.ts \
|
||||
src/app/features/player/player-shell.component.spec.ts
|
||||
```
|
||||
- [ ] Bekræft standard migration consistency gate:
|
||||
```bash
|
||||
. .venv/bin/activate && python manage.py makemigrations --check --dry-run
|
||||
. .venv/bin/activate && python manage.py migrate --check --noinput
|
||||
```
|
||||
- [ ] Følg `docs/RELEASE_POLICY.md`: staging deploy, `/healthz`, smoke-resultat og changelog-reference før tag.
|
||||
|
||||
## Close-out conclusion
|
||||
|
||||
**Konklusion:** i18n-MVP'en er implementeret på `main`, og issue #279 leverer den manglende release-/migration-closeout dokumentation uden nye kodeændringer i app-logikken.
|
||||
|
||||
PR #282 (parity artifact) og PR #283 (Django i18n hardening) er nu begge merged på `main`, så close-out-noten, changelog-teksten og release-readiness-checklisten kan behandles som indbyrdes konsistente for det verificerede snapshot.
|
||||
|
||||
Det betyder, at de resterende release-gates for i18n nu er de almindelige verificeringstrin (drift-check, backend/frontend-smoke, migrations-konsistens, staging deploy og changelog-reference) — ikke længere afklaring af om #282/#283 skal lande.
|
||||
22
docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md
Normal file
22
docs/ISSUE-287-CANONICAL-ROUND-FLOW-BACKEND-ARTIFACT.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Issue #287 — Canonical round-flow backend artifact
|
||||
|
||||
## State-transition matrix
|
||||
|
||||
| Trigger | From | To | Server-owned effect |
|
||||
|---|---|---|---|
|
||||
| `POST /lobby/sessions/{code}/rounds/start` | `lobby` | `lie` | Opretter `RoundConfig`, vælger/låser konkret `RoundQuestion`, eksponerer prompt + lie-deadline i samme svar |
|
||||
| Sidste gyldige `submit_lie` for aktivt spørgsmål | `lie` | `guess` | Dedupe/shuffle `correct_answer + lies`, persisterer `mixed_answers`, broadcaster `phase.guess_started` |
|
||||
| Sidste gyldige `submit_guess` for aktivt spørgsmål | `guess` | `reveal` | Beregner score deterministisk, persisterer `ScoreEvent` + opdaterede `Player.score`, returnerer canonical reveal payload |
|
||||
| Første canonical state-read efter resolved reveal (`session_detail`, og idempotent `GET /scoreboard` hvis state allerede er resolved) | `reveal` | `scoreboard` | Promoverer scoreboard som state, broadcaster `phase.scoreboard`, eksponerer leaderboard + readiness |
|
||||
| `POST /lobby/sessions/{code}/rounds/next` | `scoreboard` | `lie` | Increment round counter, kopierer seneste `RoundConfig`, vælger/låser næste spørgsmål i samme kategori og broadcaster `phase.lie_started` |
|
||||
| `POST /lobby/sessions/{code}/finish` | `scoreboard` | `finished` | Fryser slutresultat og returnerer final leaderboard |
|
||||
|
||||
## Flow-log (happy path)
|
||||
|
||||
1. Host starter runde med kategori.
|
||||
2. Server vælger straks spørgsmål og går i `lie`.
|
||||
3. Spillere sender løgne; sidste submission auto-advancer til `guess`.
|
||||
4. Spillere sender gæt; sidste submission auto-advancer til `reveal` og scorer runden.
|
||||
5. Næste canonical state-read promoverer resolved reveal til `scoreboard`; state findes uden separat debug-knap.
|
||||
6. Host kan nu kun vælge `next round` eller `finish game`.
|
||||
7. `next round` starter næste runde direkte i `lie` med nyt konkret spørgsmål; ingen mellem-hop tilbage til `lobby`.
|
||||
52
docs/ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md
Normal file
52
docs/ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# Issue #301 Artifact — Client action gating from canonical phase state
|
||||
|
||||
Refs: #287, #301
|
||||
|
||||
## What changed
|
||||
|
||||
Frontend host/player shells now prefer the canonical phase exposed by `phase_view_model.current_phase` when deciding:
|
||||
|
||||
- which gameplay actions are enabled
|
||||
- whether reveal data should still be shown
|
||||
- which SPA hash-route should represent the active game state
|
||||
|
||||
This tightens the #301 slice so the client stays aligned with backend canonicalisation even when `session.status` lags during reveal/scoreboard promotion.
|
||||
|
||||
## Gated UI actions by phase
|
||||
|
||||
### Lobby
|
||||
- **Host:** `startRound`
|
||||
- **Player:** `join`
|
||||
|
||||
### Bluff / lie
|
||||
- **Host:** `showQuestion`
|
||||
- **Player:** `submitLie`
|
||||
- **Blocked:** guess submission, scoreboard load, next round, finish game
|
||||
|
||||
### Guess
|
||||
- **Host:** `mixAnswers`, `calculateScores`
|
||||
- **Player:** `submitGuess`
|
||||
- **Blocked:** lie submission, scoreboard load, next round, finish game
|
||||
|
||||
### Reveal
|
||||
- **Host:** `loadScoreboard`
|
||||
- **Player:** display-only reveal state
|
||||
- **Blocked:** start next round, finish game, guess/lie submission
|
||||
|
||||
### Scoreboard
|
||||
- **Host:** `startNextRound`, `finishGame`
|
||||
- **Player:** display-only reveal/scoreboard state
|
||||
- **Blocked:** scoreboard reload, guess/lie submission
|
||||
|
||||
## Test evidence
|
||||
|
||||
Targeted tests added/updated for:
|
||||
|
||||
- host shell canonical gating and route sync when `current_phase` differs from `session.status`
|
||||
- player shell canonical gating and route sync when `current_phase` differs from `session.status`
|
||||
- shared gameplay phase machine gating from canonical permissions
|
||||
- shared API mapper contract coverage, including reveal/scoreboard payload stability
|
||||
|
||||
## Contract note
|
||||
|
||||
No backend protocol redesign was introduced. This follow-up only preserves and consumes the existing canonical phase/action contract more strictly on the client side.
|
||||
55
docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md
Normal file
55
docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Issue #302 Evidence — canonical bluff → guess → reveal → scoreboard regression
|
||||
|
||||
## Runnable command
|
||||
|
||||
```bash
|
||||
python manage.py migrate --noinput
|
||||
python manage.py smoke_staging --artifact docs/artifacts/issue-302-canonical-loop-smoke.json
|
||||
```
|
||||
|
||||
`migrate` is the normal local bootstrap precondition when the database has not been initialized yet; the regression evidence itself is produced by `smoke_staging`.
|
||||
|
||||
## What the regression proves
|
||||
|
||||
`smoke_staging` now exercises one full canonical round and fails fast with step-specific diagnostics if any of these break:
|
||||
|
||||
1. `start_round` lands the session in `lie` and returns a concrete `round_question_id`.
|
||||
2. Final `submit_lie` auto-advances the session to `guess` and exposes mixed answers containing both the correct answer and player bluffs.
|
||||
3. Final `submit_guess` auto-advances the session to `reveal` and returns the canonical reveal payload.
|
||||
4. The reveal payload includes:
|
||||
- correct answer
|
||||
- all lies
|
||||
- all guesses
|
||||
- fooled-player references for bluff hits
|
||||
5. The first canonical state read after reveal promotes the session to `scoreboard`.
|
||||
6. Scoreboard promotion preserves the same reveal payload and exposes a leaderboard with `scoreboard_ready=true`.
|
||||
|
||||
## Artifact shape
|
||||
|
||||
When `--artifact` is provided, the JSON file records:
|
||||
|
||||
- the exact smoke command
|
||||
- session code and round question id
|
||||
- deterministic guess plan used to produce both bluff hits and one correct guess
|
||||
- per-step evidence for:
|
||||
- `create_session`
|
||||
- `join_players`
|
||||
- `start_round`
|
||||
- `auto_guess_transition`
|
||||
- `submit_guesses`
|
||||
- `auto_reveal_transition`
|
||||
- `auto_scoreboard_transition`
|
||||
- `finish_game`
|
||||
- reveal summary (`correct_answer`, lie/guess counts, fooled-player ids, correct guess player ids)
|
||||
- promoted scoreboard leaderboard payload
|
||||
|
||||
## Targeted test coverage
|
||||
|
||||
Backend regression coverage lives in `lobby/tests.py`:
|
||||
|
||||
- `test_smoke_staging_command_runs_full_flow`
|
||||
- `test_smoke_staging_writes_phase_evidence_artifact_when_requested`
|
||||
|
||||
Together they ensure the command stays runnable in normal workflow and that the evidence artifact contains phase-by-phase proof instead of only a generic pass/fail.
|
||||
|
||||
Refs #287 #302
|
||||
33
docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md
Normal file
33
docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Issue #310 — Host transition idempotency and error catalog
|
||||
|
||||
## Scope
|
||||
|
||||
This artifact hardens the two host-owned scoreboard exits in the canonical gameplay flow:
|
||||
|
||||
- `POST /lobby/sessions/{code}/rounds/next`
|
||||
- `POST /lobby/sessions/{code}/finish`
|
||||
|
||||
The goal is retry-safe host behavior when the scoreboard transition already succeeded server-side but the client retries because of a duplicate click, timeout, or lost response.
|
||||
|
||||
## Transition contract
|
||||
|
||||
| Endpoint | First valid transition | Idempotent replay state | Replay result | Broadcast behavior | Still-invalid states |
|
||||
|---|---|---|---|---|---|
|
||||
| `POST /lobby/sessions/{code}/rounds/next` | `scoreboard -> lie` | `lie` with persisted current-round bootstrap (`RoundConfig` + `RoundQuestion`) | `200 OK` with the same canonical next-round payload shape | `phase.lie_started` fires only on the first transition | `lobby`, `guess`, `reveal`, `finished` → `next_round_invalid_phase` |
|
||||
| `POST /lobby/sessions/{code}/finish` | `scoreboard -> finished` | `finished` | `200 OK` with the same final leaderboard payload shape | `phase.game_over` fires only on the first transition | `lobby`, `lie`, `guess`, `reveal` → `finish_game_invalid_phase` |
|
||||
|
||||
## Error catalog notes
|
||||
|
||||
No new backend error codes were introduced for this slice.
|
||||
|
||||
The contract change is behavioral:
|
||||
|
||||
- `next_round_invalid_phase` now means the session is in a phase where the scoreboard → next-round transition has **not** already been completed, or the expected bootstrap artifact for the already-started round is missing.
|
||||
- `finish_game_invalid_phase` now means the session is in a phase where the scoreboard → finish transition has **not** already been completed.
|
||||
- Successful replays are returned as normal `200 OK` canonical responses instead of phase errors.
|
||||
|
||||
## Acceptance evidence
|
||||
|
||||
- Repeated `rounds/next` calls after a successful scoreboard exit return the same canonical lie/bootstrap payload without incrementing the round twice.
|
||||
- Repeated `finish` calls after a successful scoreboard exit return the same finished leaderboard payload without rebroadcasting game-over.
|
||||
- Wrong-phase calls outside those replay states still return the existing shared error codes.
|
||||
202
docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md
Normal file
202
docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Issue #312 — FupOgFakta extraction map for logic currently living in `lobby/`
|
||||
|
||||
Parent: #311
|
||||
Issue: #312
|
||||
|
||||
## Purpose
|
||||
|
||||
This artifact documents the concrete FupOgFakta-specific logic that still lives in `lobby/`, separates it from true platform/session concerns, and names the intended destination ownership before any larger code move happens.
|
||||
|
||||
It is intentionally an inventory + extraction plan only. It does **not** perform the full move.
|
||||
|
||||
## Architectural boundary this map is enforcing
|
||||
|
||||
The target boundary is already described in:
|
||||
|
||||
- `docs/plans/2026-03-09-fupogfakta-game-engine-design.md`
|
||||
- `docs/plans/2026-03-09-fupogfakta-implementation-plan.md`
|
||||
- `docs/ARCHITECTURE.md`
|
||||
|
||||
Those docs consistently describe:
|
||||
|
||||
- `lobby/` as the **platform layer** for session lifecycle, player presence, host ownership, generic game-run orchestration, and transport-facing platform concerns.
|
||||
- `fupogfakta/` as the **game cartridge** that owns question selection rules, round config semantics, lie/guess/reveal/scoreboard flow, answer mixing, scoring, and game-specific response/event payloads.
|
||||
|
||||
In other words:
|
||||
|
||||
- **Platform (`lobby/`)** should know that a session exists and that a game can be started/observed.
|
||||
- **Cartridge (`fupogfakta/`)** should know what a lie is, what a guess is, how answers are mixed, when phases advance, and what payload shape those game phases expose.
|
||||
|
||||
## Summary split
|
||||
|
||||
### Generic platform/session concerns that belong in `lobby/`
|
||||
|
||||
These are not FupOgFakta-specific and should remain platform-owned:
|
||||
|
||||
- Session code parsing/generation:
|
||||
- `lobby/views.py::_generate_session_code`
|
||||
- `lobby/views.py::_normalize_session_code`
|
||||
- `lobby/views.py::_create_unique_session_code`
|
||||
- Generic request parsing:
|
||||
- `lobby/views.py::_json_body`
|
||||
- Session lifecycle and player presence endpoints:
|
||||
- `lobby/views.py::create_session`
|
||||
- `lobby/views.py::join_session`
|
||||
- `lobby/views.py::session_detail` **only for the generic session/player shell part**
|
||||
- Generic ownership / host authorization checks
|
||||
- Generic session detail payload fields:
|
||||
- `session.code`
|
||||
- `session.status`
|
||||
- `session.host_id`
|
||||
- `session.current_round`
|
||||
- `session.players_count`
|
||||
- `players[].id|nickname|score|is_connected`
|
||||
- Generic i18n/error transport helper usage:
|
||||
- `lobby/i18n.py`
|
||||
- `api_error(...)`
|
||||
- Route mounting / namespace ownership in `lobby/urls.py` for platform routes only
|
||||
|
||||
### FupOgFakta-specific logic currently misplaced in `lobby/`
|
||||
|
||||
These items are game-cartridge logic and should move behind `fupogfakta/` ownership:
|
||||
|
||||
- Round question selection by category and previously-used questions
|
||||
- Lie-phase payload construction and lie timer semantics
|
||||
- Mixed-answer preparation for bluff gameplay
|
||||
- Guess correctness / fooled-player detection
|
||||
- Bluff/correct-answer score resolution
|
||||
- Reveal payload construction
|
||||
- Reveal → scoreboard promotion rules
|
||||
- Start round / mix answers / submit lie / submit guess / calculate scores / reveal scoreboard / next round / finish game gameplay endpoints
|
||||
- Phase view-model booleans that encode FupOgFakta rules rather than generic platform readiness
|
||||
|
||||
## Extraction map
|
||||
|
||||
| Source file | Current function / concern | Why it is FupOgFakta-specific | Intended destination / owner |
|
||||
| --- | --- | --- | --- |
|
||||
| `lobby/views.py` | `_build_player_ref(player)` | Helper is only used to shape FupOgFakta reveal payloads; not a generic platform concern today. | `fupogfakta/serializers.py` or `fupogfakta/payloads.py` owned by cartridge. |
|
||||
| `lobby/views.py` | `_build_reveal_payload(round_question)` | Encodes FupOgFakta reveal contract: lies, guesses, fooled-player refs, correct answer, prompt. | `fupogfakta/payloads.py::build_reveal_payload` or equivalent cartridge response builder. |
|
||||
| `lobby/views.py` | `_build_leaderboard(session)` | Current implementation is generic-ish, but used exclusively by FupOgFakta scoreboard/finish flow and coupled to that response shape. | Short term: keep shared helper if multiple games will consume same contract; otherwise move to `fupogfakta/payloads.py` until a true shared scoreboard contract exists. |
|
||||
| `lobby/views.py` | `_get_current_round_question(session)` | Depends on FupOgFakta `RoundQuestion` model and current-round semantics. | `fupogfakta/services/rounds.py` or `fupogfakta/queries.py`. |
|
||||
| `lobby/views.py` | `_select_round_question(session, round_config)` | Implements FupOgFakta question selection rules by category, active questions, and not-yet-used question set. | `fupogfakta/services/rounds.py::select_round_question`. |
|
||||
| `lobby/views.py` | `_build_lie_started_payload(session, round_config, round_question)` | Builds a FupOgFakta event/response contract for lie phase, including category, prompt, lie deadline, round question id. | `fupogfakta/payloads.py::build_lie_started_payload`. |
|
||||
| `lobby/views.py` | `_prepare_mixed_answers(round_question)` | Bluff-answer dedupe and shuffle is core FupOgFakta gameplay logic. | `fupogfakta/services/answers.py::prepare_mixed_answers`. |
|
||||
| `lobby/views.py` | `_resolve_scores(session, round_question, round_config)` | Applies FupOgFakta scoring rules for correct guesses and successful bluffs; depends on `Guess`, `LieAnswer`, `ScoreEvent`, `points_correct`, `points_bluff`. | `fupogfakta/services/scoring.py::resolve_scores`. |
|
||||
| `lobby/views.py` | `_maybe_promote_reveal_to_scoreboard(session)` | Encodes FupOgFakta reveal completion semantics and scoreboard transition trigger. | `fupogfakta/services/phases.py::maybe_promote_reveal_to_scoreboard`. |
|
||||
| `lobby/views.py` | `_build_phase_view_model(session, players_count, has_round_question)` | Most booleans are not platform-generic; they encode FupOgFakta phase names (`lie`, `guess`, `scoreboard`) and MVP constraints (`3-5 players`, round-question readiness, next-round/finish gating). | Split: keep platform-shell fields in `lobby/`; move game-specific readiness/action flags to `fupogfakta/payloads.py::build_phase_view_model` or cartridge driver payload builder. |
|
||||
| `lobby/views.py` | `start_round(request, code)` | Starts FupOgFakta round, binds category, creates `RoundConfig`, selects `RoundQuestion`, transitions to `LIE`, broadcasts `phase.lie_started`. | `fupogfakta/views.py` or cartridge command handler behind a future `GameDriver.on_game_start` / round bootstrap service. |
|
||||
| `lobby/views.py` | `show_question(request, code)` | Emits lie-phase question payload using FupOgFakta `RoundQuestion` and `RoundConfig`. | `fupogfakta/views.py` or remove entirely once canonical driver flow owns the transition. |
|
||||
| `lobby/views.py` | `submit_lie(request, code, round_question_id)` | Pure FupOgFakta gameplay endpoint: lie validation, deadline semantics, auto-advance to guess phase, `phase.guess_started` payload. | `fupogfakta/views.py::submit_lie` (or cartridge intent handler). |
|
||||
| `lobby/views.py` | `mix_answers(request, code, round_question_id)` | Manual FupOgFakta host action for lie→guess transition and answer mixing. | `fupogfakta/views.py` short term; long term likely deleted in favor of cartridge-driven automatic transition. |
|
||||
| `lobby/views.py` | `submit_guess(request, code, round_question_id)` | Pure FupOgFakta gameplay endpoint: validates answer choice, resolves correctness/bluff source, auto-calculates scores, transitions to reveal. | `fupogfakta/views.py::submit_guess` plus `fupogfakta/services/scoring.py` and `fupogfakta/services/phases.py`. |
|
||||
| `lobby/views.py` | `reveal_scoreboard(request, code)` | FupOgFakta reveal/scoreboard progression, not a generic platform capability. | `fupogfakta/views.py::reveal_scoreboard` or cartridge phase service. |
|
||||
| `lobby/views.py` | `start_next_round(request, code)` | FupOgFakta next-round bootstrap: copies prior `RoundConfig`, increments round, picks next question, re-enters lie phase. | `fupogfakta/services/rounds.py::start_next_round` plus cartridge-owned endpoint/driver integration. |
|
||||
| `lobby/views.py` | `finish_game(request, code)` | Current finish path is tied to FupOgFakta scoreboard semantics and winner payload. | `fupogfakta/views.py::finish_game` until a truly generic platform finish contract exists. |
|
||||
| `lobby/views.py` | `calculate_scores(request, code, round_question_id)` | Explicit FupOgFakta score resolution endpoint. | `fupogfakta/services/scoring.py` and/or remove when fully absorbed by cartridge phase driver. |
|
||||
| `lobby/urls.py` | Gameplay routes for rounds, lies, guesses, scoreboard, finish | These route names expose FupOgFakta-specific phase/actions from the platform namespace. | Re-home under `fupogfakta/urls.py` or leave mounted under `/lobby/sessions/...` only as a temporary façade delegating to cartridge-owned code. |
|
||||
| `lobby/tests.py` | `StartRoundTests`, `LieSubmissionTests`, `MixAnswersTests`, `GuessSubmissionTests`, `CanonicalRoundFlowTests`, `ScoreCalculationTests`, `RevealRoundFlowTests`, `SessionDetailRoundQuestionTests`, `SessionDetailPhaseViewModelTests`, `SmokeStagingCommandTests` | These test classes verify FupOgFakta game flow rather than platform mechanics. | Move/split into `fupogfakta/tests/` with only session creation/join/platform transport tests left in `lobby/tests.py`. |
|
||||
| `lobby/management/commands/smoke_staging.py` | End-to-end gameplay smoke through lies/guesses/finish | Script executes one concrete game flow and should be cartridge-aware, not platform-owned. | `fupogfakta/management/commands/` or a shared smoke harness that delegates into cartridge-specific scenario runners. |
|
||||
|
||||
## Recommended ownership split by module
|
||||
|
||||
### Keep in `lobby/`
|
||||
|
||||
- Session creation/join and session-code lifecycle
|
||||
- Generic player membership/presence reads
|
||||
- Generic auth/host checks helpers (if extracted from views)
|
||||
- Generic API error/i18n plumbing
|
||||
- Future `GameRun` / driver orchestration, timers, and cartridge dispatch
|
||||
- A slim generic `session_detail` envelope that can embed cartridge payloads under a dedicated game key
|
||||
|
||||
### Move to `fupogfakta/`
|
||||
|
||||
- Round state queries
|
||||
- Question selection
|
||||
- Lie/guess/reveal/scoreboard/finish transition rules
|
||||
- Score calculation
|
||||
- Answer mixing
|
||||
- Gameplay payload/response builders
|
||||
- Gameplay endpoints and tests
|
||||
- Gameplay smoke command
|
||||
|
||||
## Explicit boundary for `session_detail`
|
||||
|
||||
`session_detail` is currently mixed.
|
||||
|
||||
### Generic part that should remain platform-owned
|
||||
|
||||
- Session identity/status metadata
|
||||
- Player list / presence list
|
||||
- Generic host/player capability envelope if it is game-agnostic
|
||||
|
||||
### FupOgFakta part that should move or be delegated
|
||||
|
||||
- `round_question` payload
|
||||
- `reveal` payload
|
||||
- `scoreboard` payload
|
||||
- `phase_view_model` fields keyed to `lie`, `guess`, `scoreboard`, `finished`, `question_ready`, and 3–5-player MVP rules
|
||||
|
||||
A clean future shape would be:
|
||||
|
||||
```json
|
||||
{
|
||||
"session": {"code": "ABC123", "status": "active", "game_type": "fupogfakta"},
|
||||
"players": [...],
|
||||
"game": {
|
||||
"phase": "lie",
|
||||
"payload": {"round_question": {...}, "reveal": null, "scoreboard": null}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That makes `lobby/` the shell and `fupogfakta/` the authority for game-state payloads.
|
||||
|
||||
## Concrete extraction sequence
|
||||
|
||||
1. **Move pure helpers first**
|
||||
- `_get_current_round_question`
|
||||
- `_select_round_question`
|
||||
- `_prepare_mixed_answers`
|
||||
- `_resolve_scores`
|
||||
- `_build_lie_started_payload`
|
||||
- `_build_reveal_payload`
|
||||
2. **Move gameplay endpoints behind cartridge-owned service functions**
|
||||
- `submit_lie`
|
||||
- `submit_guess`
|
||||
- `start_round`
|
||||
- `start_next_round`
|
||||
- `finish_game`
|
||||
- `reveal_scoreboard`
|
||||
- `calculate_scores`
|
||||
3. **Slim `session_detail` into platform envelope + delegated cartridge payload**
|
||||
4. **Move gameplay tests out of `lobby/tests.py`**
|
||||
5. **Optionally leave compatibility routes in `lobby/urls.py` as a façade** until clients are rewired
|
||||
|
||||
## Risks this map is explicitly preventing
|
||||
|
||||
- Moving only models but leaving hidden phase-transition rules in `lobby/views.py`
|
||||
- Treating `session_detail` as platform-generic while it still leaks cartridge payload semantics
|
||||
- Leaving scoreboard/reveal transition logic behind as an undocumented coupling
|
||||
- Splitting tests incorrectly so regressions stay "green" in `lobby/` while FupOgFakta behavior silently drifts
|
||||
|
||||
## Decision
|
||||
|
||||
For #311 / #312, the repository should treat the following as **game-specific and extraction candidates**:
|
||||
|
||||
- round-question selection
|
||||
- lie/guess/reveal/scoreboard/finish transitions
|
||||
- answer mixing
|
||||
- score resolution
|
||||
- reveal/scoreboard payload builders
|
||||
- FupOgFakta-specific session-detail subpayloads
|
||||
- gameplay flow tests and smoke command
|
||||
|
||||
And it should treat the following as **platform-generic**:
|
||||
|
||||
- session identity/lifecycle
|
||||
- player presence/membership
|
||||
- host authorization shell
|
||||
- generic error transport
|
||||
- future game-driver dispatch/orchestration
|
||||
|
||||
That is the explicit `lobby` vs `fupogfakta` boundary this issue needs before code extraction proceeds.
|
||||
@@ -10,11 +10,13 @@ Sikre at release-tags altid repræsenterer faktisk deployet software.
|
||||
|
||||
## Release-flow
|
||||
1. Bekræft architect-gate (`issue #17`) er release-approved.
|
||||
2. Bekræft tester ikke er aktiv.
|
||||
3. Deploy kandidat til staging (`infra/staging/deploy_staging.sh`).
|
||||
4. Verificér `/healthz` + smoke-resultat.
|
||||
5. Tilføj changelog-entry i `CHANGELOG.md`.
|
||||
6. Opret release-tag i Gitea (annotated), og referér changelog-sektion i release-notes.
|
||||
2. Kør den lokale MVP gate: `./scripts/verify_mvp_release.sh`.
|
||||
3. Bekræft tester ikke er aktiv.
|
||||
4. Kør helst `infra/staging/deploy_and_smoke_staging.sh [ref] [artifact-path]`.
|
||||
5. Hvis wrapper ikke bruges: deploy med `infra/staging/deploy_staging.sh` og kør derefter `infra/staging/run_mvp_smoke.sh`.
|
||||
6. Verificér `/healthz` + smoke-resultat.
|
||||
7. Tilføj changelog-entry i `CHANGELOG.md`.
|
||||
8. Opret release-tag i Gitea (annotated), og referér changelog-sektion i release-notes.
|
||||
|
||||
## Minimum release-notes template
|
||||
```markdown
|
||||
|
||||
69
docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md
Normal file
69
docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# SPA visual + realtime smoke artifact
|
||||
|
||||
## Purpose
|
||||
This is the Batch 6 manual evidence lane for the presenter-host and player-phone overhaul. Use it when `USE_SPA_UI=true` and you need reviewable proof that the Angular host/player shells behave correctly across realtime reconnects, role-based visibility, and multi-device presentation.
|
||||
|
||||
The automated companion lane for this checklist is:
|
||||
|
||||
```bash
|
||||
npm --prefix frontend/angular test -- src/app/realtime-visual-smoke.spec.ts
|
||||
```
|
||||
|
||||
## When to capture it
|
||||
- Staging or local smoke after a host/player visual or realtime change.
|
||||
- Before asking for SPA cutover confidence beyond unit-level component coverage.
|
||||
- When reconnect recovery or developer-state safety changed and reviewers need concrete device evidence.
|
||||
|
||||
## Evidence template
|
||||
```markdown
|
||||
### SPA visual + realtime smoke evidence
|
||||
- Timestamp (UTC): <YYYY-MM-DD HH:MM>
|
||||
- Environment: <local/staging>
|
||||
- Commit/Head SHA: <sha>
|
||||
- `USE_SPA_UI`: `true`
|
||||
- Locale: <en/da>
|
||||
- Devices: projected host + <N> player phones/tabs
|
||||
|
||||
#### Setup
|
||||
- Host route: `/lobby/ui/host`
|
||||
- Player route: `/lobby/ui/player`
|
||||
- Session code: <code>
|
||||
- Participants joined: <list or count>
|
||||
- Developer-state left OFF by default before evidence capture: <yes/no>
|
||||
|
||||
#### Checks (PASS/FAIL)
|
||||
1. Presenter-only question visibility
|
||||
- Host lie/presenter scene shows the active prompt: <pass/fail>
|
||||
- Player phones stay prompt-hidden until the allowed phase payload reveals it: <pass/fail>
|
||||
2. Reconnect recovery
|
||||
- Disconnect one player device or throttle network during an active lie/guess input: <pass/fail>
|
||||
- Reconnect badge/card appears without clearing the local draft/selection: <pass/fail>
|
||||
- Recovered websocket push resumes before the 3s polling fallback becomes the steady-state transport: <pass/fail>
|
||||
3. Multi-device reveal + scoreboard
|
||||
- At least 3 player devices reach reveal and scoreboard together: <pass/fail>
|
||||
- Host projected scene remains presenter-grade through reveal and final standings: <pass/fail>
|
||||
- Shared player identity tokens/colors/icons stay consistent between the projected host roster and player-phone developer-state snapshots: <pass/fail>
|
||||
4. Developer-state safety
|
||||
- Host developer-state screenshot or recording captured separately from the default presenter screen: <pass/fail>
|
||||
- Player developer-state screenshot or recording captured separately from the default phone UI: <pass/fail>
|
||||
- Host/player developer-state captures show the current `phase_display.theme` and `phase_display.ornament` tokens plus each player `identity.token` and `identity.icon` so contract-driven scene art/copy and roster styling can be traced back to the payload: <pass/fail>
|
||||
- At least one lie/guess/reveal capture shows an authored question ornament slug from admin/bootstrap content instead of only the deterministic fallback set: <pass/fail>
|
||||
5. Optional host voice cue check
|
||||
- Host-only voice playback still routes on the primary device when enabled: <pass/fail/not-run>
|
||||
|
||||
#### Artifact pointers
|
||||
- Automated smoke command: `npm --prefix frontend/angular test -- src/app/realtime-visual-smoke.spec.ts`
|
||||
- Screenshot/video refs:
|
||||
- host projected scene: <ref>
|
||||
- reconnect recovery: <ref>
|
||||
- reveal/scoreboard multi-device: <ref>
|
||||
- host/player developer-state: <ref>
|
||||
- Result: <PASS/FAIL>
|
||||
- If FAIL: blocker link + shortest repro
|
||||
```
|
||||
|
||||
## Minimum acceptable artifact
|
||||
- One projected host screenshot during lie, reveal, or scoreboard.
|
||||
- One player-device capture showing reconnect recovery or input preservation.
|
||||
- One reveal or scoreboard capture with 3+ player devices.
|
||||
- Separate host and player developer-state captures so diagnostics stay out of the default presentation.
|
||||
@@ -5,6 +5,7 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
|
||||
## Guardrails (MVP)
|
||||
- Hold scope inden for #16 (execution board) og #17 (scope guardrail).
|
||||
- Kun verifikation af eksisterende flow; ingen nye features/polish.
|
||||
- Primær MVP release-gate bruger legacy UI med `USE_SPA_UI=false`.
|
||||
|
||||
## Hvornår bruges artifacten
|
||||
- Efter staging-smoke af gameplay-flowet: lobby -> join -> start -> runde -> scoreboard -> next/final.
|
||||
@@ -22,18 +23,13 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
|
||||
- Host authenticated in Django admin: <yes/no>
|
||||
- Active category/questions present: <yes/no>
|
||||
- Participants: host + <N> players
|
||||
- `USE_SPA_UI`: <on/off>
|
||||
- `USE_SPA_UI`: `false`
|
||||
- `WPP_SPA_ASSET_VERSION`: <release-token/sha>
|
||||
- UI route used:
|
||||
- OFF (legacy): `/lobby/ui/host` + `/lobby/ui/player`
|
||||
- ON (SPA shell): `/lobby/ui/host/<spa-path>` + `/lobby/ui/player`
|
||||
- UI routes used: `/lobby/ui/host` + `/lobby/ui/player`
|
||||
|
||||
#### Checks (PASS/FAIL)
|
||||
0. Same release-window verification
|
||||
- OFF + ON smoke kørt i samme release-vindue: <pass/fail>
|
||||
1. Cutover route sanity
|
||||
- Flag OFF serves legacy UI templates: <pass/fail>
|
||||
- Flag ON serves SPA shell on expected path(s): <pass/fail>
|
||||
1. Legacy route sanity
|
||||
- Host/player legacy templates svarer korrekt: <pass/fail>
|
||||
2. Lobby -> join -> start
|
||||
- Mixed-case + whitespace session code accepted: <pass/fail>
|
||||
3. One full round to scoreboard
|
||||
@@ -42,10 +38,10 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
|
||||
- next round transitions: <pass/fail>
|
||||
- final leaderboard visible: <pass/fail>
|
||||
|
||||
#### Smoke-gate decision (før `USE_SPA_UI=true`)
|
||||
#### MVP smoke-gate decision
|
||||
- Gate status: <GREEN/RED>
|
||||
- Gate criteria met:
|
||||
- [ ] Cutover route sanity = PASS (OFF + ON)
|
||||
- [ ] Legacy route sanity = PASS
|
||||
- [ ] Full gameplay round = PASS
|
||||
- [ ] Next-round/final leaderboard sanity = PASS
|
||||
- [ ] Ingen nye blocker-regressioner i host/player flow
|
||||
@@ -53,10 +49,10 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
|
||||
#### Rollback checkpoint
|
||||
- Rollback required: <yes/no>
|
||||
- Trigger reason (if yes): <kort trigger>
|
||||
- Rollback done (`USE_SPA_UI=false`) verified: <yes/no>
|
||||
- Rollback done (`USE_SPA_UI=false`) verified: <yes/no/not-needed>
|
||||
|
||||
#### Evidence pointers
|
||||
- Command(s): `<exact command(s)>`
|
||||
- Command(s): `./infra/staging/deploy_and_smoke_staging.sh [ref] [artifact-path]` or `./infra/staging/run_mvp_smoke.sh [artifact-path]`
|
||||
- UI notes/screenshots/log refs: <short refs>
|
||||
- Result: <PASS/FAIL>
|
||||
- If FAIL: blocker issue link + shortest repro
|
||||
@@ -64,3 +60,6 @@ Formål: levere et lille, ensartet evidensformat for release-nær gameplay-smoke
|
||||
|
||||
## Anti-stall minimum
|
||||
Hvis der ikke er ny kode at ændre, er denne artifact-skabelon den mindste gyldige leverance for at sikre ensartet, reviewbar smoke-evidens i staging.
|
||||
|
||||
## SPA cutover note
|
||||
Hvis der køres separat SPA-cutover, dokumenteres det i et særskilt artifact med henvisning til `docs/spa-cutover-flag.md`. Brug i så fald `ALLOW_SPA_CUTOVER=1` eksplicit ved staging smoke.
|
||||
|
||||
@@ -1,48 +1,35 @@
|
||||
# UI smoke (MVP)
|
||||
|
||||
## Forudsætning
|
||||
- Host er logget ind i Django.
|
||||
- Mindst én aktiv kategori med spørgsmål findes.
|
||||
## MVP path
|
||||
- Current MVP path: `USE_SPA_UI=false`
|
||||
- Canonical routes: `/lobby/ui/host` + `/lobby/ui/player`
|
||||
- SPA shell verification is follow-up cutover work; keep it out of the primary MVP smoke.
|
||||
|
||||
## Cutover-forudsætning (`USE_SPA_UI`)
|
||||
- `USE_SPA_UI=false` (default): brug legacy routes `/lobby/ui/host` + `/lobby/ui/player`.
|
||||
- `USE_SPA_UI=true`: host må gerne testes på SPA deep-link route `/lobby/ui/host/<spa-path>` (fx `/lobby/ui/host/guess`), player på `/lobby/ui/player`.
|
||||
## Preconditions
|
||||
- Host can log in through Django.
|
||||
- At least one active category with questions exists.
|
||||
- Recommended local bootstrap: `python manage.py bootstrap_mvp`
|
||||
- Fastest local setup: `./scripts/run_local_mvp_smoke.sh` and then keep the stack running for browser follow-up.
|
||||
|
||||
## Flow
|
||||
1. Verificér cutover-route matcher valgt flag (legacy vs SPA shell).
|
||||
2. Åbn host-siden og tryk Opret session.
|
||||
3. Åbn player-siden i 3 faner/enheder.
|
||||
4. Join alle spillere med sessionkode og nickname.
|
||||
5. Host: vælg kategori, Start runde, Vis spørgsmål.
|
||||
6. Spillere: brug round_question_id og submit løgn.
|
||||
7. Host: Mix svar.
|
||||
8. Spillere: submit gæt.
|
||||
9. Host: Beregn score og Vis scoreboard.
|
||||
10. Host: Næste runde eller Afslut spil.
|
||||
1. Confirm `USE_SPA_UI=false`.
|
||||
2. Open `/lobby/ui/host` and create a session.
|
||||
3. Open `/lobby/ui/player` in 3 tabs or devices.
|
||||
4. Join all players with the session code and nicknames.
|
||||
5. Host selects a category, starts the round, and shows the question.
|
||||
6. Players submit lies.
|
||||
7. Host mixes answers.
|
||||
8. Players submit guesses.
|
||||
9. Host calculates scores and opens the scoreboard.
|
||||
10. Host starts the next round or finishes the game.
|
||||
|
||||
## Smoke-gate (staging cutover)
|
||||
`USE_SPA_UI` må kun aktiveres i staging når følgende er opfyldt:
|
||||
- Cutover route sanity er PASS for både OFF (legacy) og ON (SPA shell).
|
||||
- Én fuld gameplay-runde til scoreboard er PASS.
|
||||
- Next-round/final leaderboard sanity er PASS.
|
||||
- Ingen nye blocker-regressioner i host/player kerneflow.
|
||||
## Pass criteria
|
||||
- One full round reaches scoreboard without raw API calls.
|
||||
- Error banners are absent in the host/player core flow.
|
||||
- Session detail reflects the same phase on both screens.
|
||||
- Finish-game path shows the final leaderboard.
|
||||
|
||||
## Samme release-vindue: SPA OFF + ON verifikation
|
||||
Kør begge checks i samme release-vindue (samme deploy/artifact version):
|
||||
|
||||
1. **OFF-pass (legacy)**
|
||||
- `USE_SPA_UI=false`
|
||||
- Verificér legacy routes + fuld runde.
|
||||
2. **ON-pass (SPA)**
|
||||
- `USE_SPA_UI=true`
|
||||
- Behold samme release artifact og kun toggl flag/version-token ved behov.
|
||||
- Verificér SPA shell routes + fuld runde.
|
||||
3. Dokumentér begge pass i samme smoke-artifact med UTC timestamps og `WPP_SPA_ASSET_VERSION`.
|
||||
|
||||
## Rollback check points
|
||||
Skift straks tilbage til `USE_SPA_UI=false` hvis en gate fejler:
|
||||
1. Verificér legacy routes (`/lobby/ui/host` + `/lobby/ui/player`) fungerer igen.
|
||||
2. Log rollback trigger + kort repro i smoke artifact.
|
||||
3. Opret/link blocker issue før nyt cutover-forsøg.
|
||||
|
||||
Resultat: En fuld runde kan køres uden rå API-kald fra terminal.
|
||||
## Cutover note
|
||||
If SPA shell validation is needed, use `docs/spa-cutover-flag.md` and `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md`. Those checks are not the primary MVP smoke gate.
|
||||
For the presenter/player visual lane specifically, capture the manual evidence in `docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md`.
|
||||
For local SPA-only checks with the compose `spa` profile, start at `http://localhost:4200/`.
|
||||
|
||||
110
docs/artifacts/issue-302-canonical-loop-smoke.json
Normal file
110
docs/artifacts/issue-302-canonical-loop-smoke.json
Normal file
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"ok": true,
|
||||
"command": "python manage.py smoke_staging --artifact <path>",
|
||||
"generated_at": "2026-03-16T15:19:30.105231+00:00",
|
||||
"question": {
|
||||
"prompt": "Smoke prompt?",
|
||||
"correct_answer": "Correct"
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"step": "create_session",
|
||||
"session_status": "lobby"
|
||||
},
|
||||
{
|
||||
"step": "join_players",
|
||||
"players_count": 3
|
||||
},
|
||||
{
|
||||
"step": "start_round",
|
||||
"session_status": "lie",
|
||||
"round_question_id": 1
|
||||
},
|
||||
{
|
||||
"step": "auto_guess_transition",
|
||||
"session_status": "guess",
|
||||
"answers": [
|
||||
"Lie from P3",
|
||||
"Lie from P1",
|
||||
"Lie from P2",
|
||||
"Correct"
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "submit_guesses",
|
||||
"guess_results": [
|
||||
{
|
||||
"player_id": 1,
|
||||
"selected_text": "Lie from P2",
|
||||
"is_correct": false,
|
||||
"fooled_player_id": 2
|
||||
},
|
||||
{
|
||||
"player_id": 2,
|
||||
"selected_text": "Correct",
|
||||
"is_correct": true,
|
||||
"fooled_player_id": null
|
||||
},
|
||||
{
|
||||
"player_id": 3,
|
||||
"selected_text": "Lie from P1",
|
||||
"is_correct": false,
|
||||
"fooled_player_id": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "auto_reveal_transition",
|
||||
"session_status": "reveal",
|
||||
"reveal": {
|
||||
"correct_answer": "Correct",
|
||||
"lies_count": 3,
|
||||
"guesses_count": 3,
|
||||
"fooled_player_ids": [
|
||||
1,
|
||||
2
|
||||
],
|
||||
"correct_guess_player_ids": [
|
||||
2
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"step": "auto_scoreboard_transition",
|
||||
"session_status": "scoreboard",
|
||||
"leaderboard": [
|
||||
{
|
||||
"id": 2,
|
||||
"nickname": "P2",
|
||||
"score": 7
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"nickname": "P1",
|
||||
"score": 2
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"nickname": "P3",
|
||||
"score": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"step": "finish_game",
|
||||
"session_status": "finished"
|
||||
}
|
||||
],
|
||||
"session_code": "7YV59E",
|
||||
"players": [
|
||||
"P1",
|
||||
"P2",
|
||||
"P3"
|
||||
],
|
||||
"round_question_id": 1,
|
||||
"guess_plan": {
|
||||
"P1": "Lie from P2",
|
||||
"P2": "Correct",
|
||||
"P3": "Lie from P1"
|
||||
}
|
||||
}
|
||||
34
docs/i18n-drift-check.md
Normal file
34
docs/i18n-drift-check.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# i18n key manifest + drift check
|
||||
|
||||
Issue: #240
|
||||
|
||||
This repo keeps shared lobby keyspaces in two files:
|
||||
|
||||
- Contract source: `shared/i18n/lobby.json`
|
||||
- Key manifest: `shared/i18n/key-manifest.json`
|
||||
|
||||
The manifest is intentionally small and explicit. It lists:
|
||||
|
||||
- Supported locales (`locales`)
|
||||
- Frontend error key set (`frontend_error_keys`)
|
||||
- Backend error code set (`backend_error_codes`)
|
||||
- Backend translation key set (`backend_error_keys`)
|
||||
- Optional contract-only aliases (`allowed_contract_only_backend_codes`)
|
||||
|
||||
## Local check
|
||||
|
||||
Run the read-only drift checker from repo root:
|
||||
|
||||
```bash
|
||||
python3 scripts/check_i18n_drift.py
|
||||
```
|
||||
|
||||
The script returns non-zero when it detects drift, including:
|
||||
|
||||
- key set mismatch between manifest and shared catalog
|
||||
- missing backend→frontend mapping coverage
|
||||
- mapping to unknown frontend keys
|
||||
- mappings for unknown backend codes
|
||||
- missing/empty locale translations (`en`/`da`)
|
||||
|
||||
No CI gating changes are included in this task; this is a local guardrail.
|
||||
69
docs/i18n-keymap.md
Normal file
69
docs/i18n-keymap.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# i18n key-map bootstrap (Angular host/player MVP)
|
||||
|
||||
Issue: #220
|
||||
Scope: Lobby → Join → Start round → Round → Reveal → Scoreboard
|
||||
Locales: `en`, `da`
|
||||
|
||||
This document is the gameplay key-namespace map for Angular host/player MVP.
|
||||
It maps existing text keys only (no feature expansion) and stays aligned with `shared/i18n/lobby.json`.
|
||||
|
||||
## Key families
|
||||
|
||||
- `host` — `frontend.ui.host.*` host-facing gameplay actions and status text.
|
||||
- `player` — `frontend.ui.player.*` player-facing gameplay actions and status text.
|
||||
- `system` — shared UI labels used across host/player views (implemented under `frontend.ui.common.*` in `shared/i18n/lobby.json`).
|
||||
- `errors` — user-facing error keys shown by frontend (`frontend.errors.*`) plus backend code → frontend key bridge via `backend.error_codes.*` / `contract.backend_to_frontend_error_keys.*`.
|
||||
|
||||
## Gameplay flow key map
|
||||
|
||||
| Flow step | Family | Key | en | da |
|
||||
|---|---|---|---|---|
|
||||
| Lobby | `host` | `host.title` | Host gameplay flow | Vært gameplay-flow |
|
||||
| Lobby | `player` | `player.title` | Player gameplay flow | Spiller gameplay-flow |
|
||||
| Lobby | `system` (`frontend.ui.common`) | `common.session_code` | Session code | Sessionskode |
|
||||
| Lobby | `player` | `player.nickname` | Nickname | Kaldenavn |
|
||||
| Join | `player` | `player.join` | Join | Join |
|
||||
| Start round | `host` | `host.start_round` | Start round | Start runde |
|
||||
| Round | `host` | `host.show_question` | Show question | Vis spørgsmål |
|
||||
| Round | `player` | `player.lie_label` | Lie | Løgn |
|
||||
| Round | `player` | `player.submit_lie` | Submit lie | Send løgn |
|
||||
| Round | `player` | `player.submit_guess` | Submit guess | Send gæt |
|
||||
| Reveal | `host` | `host.mix_answers` | Mix answers → guess | Bland svar → gæt |
|
||||
| Reveal | `host` | `host.calculate_scores` | Calculate scores → reveal | Udregn score → afslør |
|
||||
| Scoreboard | `host` | `host.load_scoreboard` | Load scoreboard | Hent scoreboard |
|
||||
| Scoreboard | `host` | `host.final_leaderboard` | Final leaderboard | Finale leaderboard |
|
||||
| Scoreboard | `player` | `player.final_leaderboard` | Final leaderboard | Finale leaderboard |
|
||||
| Scoreboard | `system` (`frontend.ui.common`) | `common.points_short` | pts | point |
|
||||
|
||||
## Frontend error keys used in flow scope
|
||||
|
||||
| Error family | Key | en | da |
|
||||
|---|---|---|---|
|
||||
| Join | `frontend.errors.session_code_required` | Session code is required. | Sessionskoden er påkrævet. |
|
||||
| Join | `frontend.errors.session_not_found` | Session code is invalid or the session no longer exists. | Sessionskoden er ugyldig, eller sessionen findes ikke længere. |
|
||||
| Join | `frontend.errors.nickname_invalid` | Nickname must be between 2 and 40 characters. | Kaldenavn skal være mellem 2 og 40 tegn. |
|
||||
| Join | `frontend.errors.nickname_taken` | Nickname is already taken. | Kaldenavnet er allerede taget. |
|
||||
| Join | `frontend.errors.join_failed` | Join failed. Check code or nickname and try again. | Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen. |
|
||||
| Start round | `frontend.errors.start_round_failed` | Could not start round. Refresh the lobby and try again. | Kunne ikke starte runden. Opdater lobbyen og prøv igen. |
|
||||
| Any | `frontend.errors.unknown` | Action failed. Refresh status and try again. | Handlingen fejlede. Opdater status og prøv igen. |
|
||||
|
||||
## Backend→frontend mapping for gameplay errors
|
||||
|
||||
Mapped in `contract.backend_to_frontend_error_keys` (source: `shared/i18n/lobby.json`):
|
||||
|
||||
> Note: `host_only_action` is **not** part of the current shared contract mapping and is intentionally not listed here.
|
||||
|
||||
- `session_code_required` → `session_code_required`
|
||||
- `nickname_invalid` → `nickname_invalid`
|
||||
- `session_not_found` → `session_not_found`
|
||||
- `session_not_joinable` → `join_failed`
|
||||
- `nickname_taken` → `nickname_taken`
|
||||
- `category_slug_required` → `start_round_failed`
|
||||
- `category_not_found` → `start_round_failed`
|
||||
- `round_start_invalid_phase` → `start_round_failed`
|
||||
- `round_already_configured` → `start_round_failed`
|
||||
|
||||
## Notes
|
||||
|
||||
- This is a bootstrap key-map doc for MVP mergeability.
|
||||
- The key/value source of truth remains `shared/i18n/lobby.json`.
|
||||
272
docs/plans/2026-03-09-fupogfakta-game-engine-design.md
Normal file
272
docs/plans/2026-03-09-fupogfakta-game-engine-design.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# Design: Fup og Fakta — Game Engine & Platform Architecture
|
||||
|
||||
**Date:** 2026-03-09
|
||||
**Status:** Approved
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Build a working Fup og Fakta game (Fibbage-style) on top of a **pluggable game platform**. The platform handles sessions, players, WebSocket push, and Celery-driven timers. Each game is a self-contained **cartridge** that implements a shared driver interface and owns its own models, config, and phase logic.
|
||||
|
||||
---
|
||||
|
||||
## Platform Architecture
|
||||
|
||||
```
|
||||
partyhub/ Django project — settings, Celery app, ASGI
|
||||
lobby/ Platform layer — sessions, players, GameRun, timer dispatch
|
||||
realtime/ WebSocket consumers (already built)
|
||||
fupogfakta/ Game cartridge #1
|
||||
future_game/ Game cartridge #N (same interface)
|
||||
```
|
||||
|
||||
### Platform provides (`lobby/`)
|
||||
|
||||
#### Models
|
||||
|
||||
**`GameSession`** (exists, minor additions)
|
||||
- `game_type` (CharField) — e.g. `"fupogfakta"`
|
||||
- `host` (FK → User)
|
||||
- `code` (6-char session code)
|
||||
- `status` (LOBBY / ACTIVE / FINISHED)
|
||||
- `config_id` / `config_snapshot` — see Config section
|
||||
|
||||
**`GameRun`** (new — ephemeral, deleted on game exit)
|
||||
- `session` (OneToOne → GameSession)
|
||||
- `current_state` (CharField — game-defined state string)
|
||||
- `phase_deadline` (DateTimeField, nullable)
|
||||
- `is_paused` (BooleanField, default False)
|
||||
- `paused_remaining_seconds` (FloatField, nullable)
|
||||
- `celery_task_id` (CharField, nullable)
|
||||
- `state_data` (JSONField) — game-specific snapshot for current phase
|
||||
|
||||
**`Player`** (exists)
|
||||
- `session`, `nickname`, `score`, `session_token`, `is_connected`
|
||||
|
||||
#### GameDriver interface
|
||||
|
||||
Each cartridge implements:
|
||||
|
||||
```python
|
||||
class GameDriver:
|
||||
game_type: str # e.g. "fupogfakta"
|
||||
|
||||
def on_game_start(session, run, config) -> PhaseResult
|
||||
def on_timer_expired(session, run, config) -> PhaseResult
|
||||
def on_pause(session, run) -> None
|
||||
def on_resume(session, run) -> None
|
||||
def on_exit(session, run) -> None # must clean up all game data
|
||||
def get_ws_payload(state, state_data) -> dict
|
||||
```
|
||||
|
||||
`PhaseResult` = `(next_state: str, duration_seconds: int | None, broadcast_payload: dict)`
|
||||
|
||||
#### Celery task
|
||||
|
||||
```python
|
||||
@app.task
|
||||
def handle_timer_expired(run_id: int, expected_state: str):
|
||||
# If run no longer exists or state has changed → stale task, ignore
|
||||
# Call driver.on_timer_expired(session, run, config)
|
||||
# Apply PhaseResult: update run, broadcast via channel layer, schedule next task
|
||||
```
|
||||
|
||||
`expected_state` prevents stale tasks from firing after pause/resume or manual state changes.
|
||||
|
||||
#### REST endpoints (platform-level)
|
||||
|
||||
- `POST /sessions/{code}/play` — start or resume
|
||||
- `POST /sessions/{code}/pause` — pause current phase timer
|
||||
- `POST /sessions/{code}/exit` — end game, delete GameRun + all game data
|
||||
|
||||
---
|
||||
|
||||
## Configuration System
|
||||
|
||||
### Base config model (`partyhub/`)
|
||||
|
||||
```python
|
||||
class BaseGameConfig(models.Model):
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
name = models.CharField(max_length=100) # "Quick game", "Full evening"
|
||||
user = models.ForeignKey(User, null=True, ...) # null = system default
|
||||
is_default = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
```
|
||||
|
||||
### Game-specific config (`fupogfakta/`)
|
||||
|
||||
```python
|
||||
class FupOgFaktaConfig(BaseGameConfig):
|
||||
num_rounds = PositiveIntegerField(default=3)
|
||||
questions_per_round = PositiveIntegerField(default=3)
|
||||
min_players = PositiveIntegerField(default=2)
|
||||
max_players = PositiveIntegerField(default=8)
|
||||
lie_seconds = PositiveIntegerField(default=45)
|
||||
guess_seconds = PositiveIntegerField(default=30)
|
||||
reveal_seconds_per_lie = PositiveIntegerField(default=8)
|
||||
scoreboard_recap_seconds = PositiveIntegerField(default=10)
|
||||
# Escalating scoring per round (stored as arrays or separate fields)
|
||||
points_correct = JSONField(default=[1500, 3000, 4500])
|
||||
points_bluff = JSONField(default=[500, 1000, 1500])
|
||||
# Reaction bonus (static, feeds post-game awards only)
|
||||
reaction_bonus = IntegerField(default=5)
|
||||
```
|
||||
|
||||
### Default resolution at session start
|
||||
|
||||
1. User has `is_default=True` row for this game type → use that
|
||||
2. System default (`user=null, is_default=True`) — set in Django admin
|
||||
3. Model field `default=` values (hardcoded)
|
||||
|
||||
User can have **multiple named presets** (one-to-many). When starting a session they choose which to use (or it auto-selects their default). The chosen config's values are **snapshotted into `GameRun.state_data`** at game start — immutable for the life of the session.
|
||||
|
||||
---
|
||||
|
||||
## Fup og Fakta — Game States
|
||||
|
||||
```
|
||||
LOBBY
|
||||
│ (host presses Play)
|
||||
▼
|
||||
LIE_PHASE timer: lie_seconds
|
||||
│ (all submitted OR timer expires)
|
||||
▼
|
||||
GUESS_PHASE timer: guess_seconds
|
||||
│ (timer expires — no mercy)
|
||||
▼
|
||||
REVEAL_LIE_{n} timer: reveal_seconds_per_lie (one per lie with ≥1 guess)
|
||||
│ → score liar incrementally as each is shown
|
||||
▼
|
||||
REVEAL_TRUTH timer: reveal_seconds_per_lie
|
||||
│ → score correct guessers
|
||||
▼
|
||||
SCOREBOARD_RECAP timer: scoreboard_recap_seconds
|
||||
│
|
||||
├─ more questions in round → back to LIE_PHASE (next question)
|
||||
├─ round done, more rounds → back to LIE_PHASE (next round, next category)
|
||||
└─ all rounds done → POST_GAME_AWARDS
|
||||
timer: configurable
|
||||
→ FINISHED (GameRun deleted, GameSession status = FINISHED)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fup og Fakta — Phase Details
|
||||
|
||||
### LIE_PHASE
|
||||
- Question shown to all clients via WebSocket (`phase.lie_started` event)
|
||||
- Players submit lie via `POST /fupogfakta/{code}/lie`
|
||||
- **If lie matches correct answer (case-insensitive):** return `error_code: lie_matches_correct_answer` — player prompted again, does not consume their submission
|
||||
- Anonymous to other players during this phase
|
||||
- `state_data` tracks: question id, round number, how many have submitted (for progress display on host screen)
|
||||
- Timer expires → transition to GUESS_PHASE regardless of how many submitted
|
||||
|
||||
### GUESS_PHASE
|
||||
- Answers mixed (lies + truth, deduped) broadcast to all clients (`phase.guess_started`)
|
||||
- Players guess via `POST /fupogfakta/{code}/guess`
|
||||
- **After selecting:** player can react to other lies with 👍 😂 ❤️ etc. until timer expires. Cannot change guess.
|
||||
- Reactions stored in `LieReaction` model (player, lie, reaction_type)
|
||||
- Timer expires → transition to first REVEAL_LIE (or REVEAL_TRUTH if no lies had guesses)
|
||||
|
||||
### REVEAL_LIE_{n}
|
||||
- One Celery task per lie to reveal (only lies with ≥1 guesser)
|
||||
- Broadcast: which lie, who wrote it, who guessed it (`phase.reveal_lie`)
|
||||
- Score awarded to liar: `points_bluff[round_index] × guesser_count`
|
||||
- Score broadcast immediately (`phase.score_delta`)
|
||||
- Skipped lies (0 guesses): not shown at all
|
||||
|
||||
### REVEAL_TRUTH
|
||||
- Broadcast: correct answer, who guessed correctly (`phase.reveal_truth`)
|
||||
- Score awarded: `points_correct[round_index]` per correct guesser
|
||||
- Also show reaction totals on each lie during this phase
|
||||
|
||||
### SCOREBOARD_RECAP
|
||||
- Full leaderboard broadcast (`phase.scoreboard`)
|
||||
- Auto-advances to next question, next round, or post-game
|
||||
|
||||
### POST_GAME_AWARDS
|
||||
- Computed from `LieReaction` aggregate:
|
||||
- "Most Hilarious Liar" — most 😂 reactions total
|
||||
- "Most Beloved Lie" — most ❤️ reactions on a single lie
|
||||
- etc. (extensible)
|
||||
- Broadcast as `phase.awards`
|
||||
- Then FINISHED → GameRun deleted, all session game data wiped
|
||||
|
||||
---
|
||||
|
||||
## Fup og Fakta — Models
|
||||
|
||||
**Existing (keep):** `Category`, `Question`, `RoundQuestion`, `LieAnswer`, `Guess`
|
||||
|
||||
**Remove:** `ScoreEvent` (no audit trail needed — game state is ephemeral)
|
||||
|
||||
**New:**
|
||||
```python
|
||||
class LieReaction(models.Model):
|
||||
lie = ForeignKey(LieAnswer, on_delete=CASCADE)
|
||||
player = ForeignKey(Player, on_delete=CASCADE)
|
||||
reaction = CharField(max_length=20) # "laugh", "heart", "fire", etc.
|
||||
created_at = auto_now_add
|
||||
class Meta:
|
||||
unique_together = [("lie", "player", "reaction")]
|
||||
```
|
||||
|
||||
**Modify `RoundQuestion`:**
|
||||
- Add `reveal_order` (PositiveIntegerField, nullable) — set when GUESS_PHASE ends, determines reveal sequence
|
||||
|
||||
---
|
||||
|
||||
## Pause / Resume
|
||||
|
||||
- **Pause:** compute `remaining = phase_deadline - now`, store in `paused_remaining_seconds`, set `is_paused=True`, revoke Celery task by `celery_task_id`
|
||||
- **Resume:** set `phase_deadline = now + paused_remaining_seconds`, schedule new Celery task, clear pause fields
|
||||
- Stale task guard: every Celery task checks `expected_state == run.current_state` before firing
|
||||
|
||||
---
|
||||
|
||||
## Host Controls (Session Owner Only)
|
||||
|
||||
| Action | Effect |
|
||||
|--------|--------|
|
||||
| Play | Starts game from LOBBY, or resumes from paused |
|
||||
| Pause | Freezes current phase timer, broadcasts `phase.paused` |
|
||||
| Exit | Ends game immediately, deletes GameRun + all game data |
|
||||
|
||||
Cannot skip. Cannot manually advance phases.
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Event Reference
|
||||
|
||||
| Event | Triggered by | Payload |
|
||||
|-------|-------------|---------|
|
||||
| `phase.lie_started` | LIE_PHASE start | question prompt, deadline, round info |
|
||||
| `phase.lie_progress` | Each lie submitted | n_submitted / n_players (no names) |
|
||||
| `phase.guess_started` | GUESS_PHASE start | mixed answers, deadline |
|
||||
| `phase.reveal_lie` | REVEAL_LIE_{n} | lie text, author, guessers, score delta |
|
||||
| `phase.reveal_truth` | REVEAL_TRUTH | correct answer, correct guessers, score delta |
|
||||
| `phase.scoreboard` | SCOREBOARD_RECAP | full leaderboard |
|
||||
| `phase.awards` | POST_GAME_AWARDS | award winners |
|
||||
| `phase.paused` | Pause | remaining_seconds |
|
||||
| `phase.resumed` | Resume | new deadline |
|
||||
| `phase.game_over` | FINISHED | final leaderboard |
|
||||
|
||||
---
|
||||
|
||||
## Data Lifecycle
|
||||
|
||||
All game session data (`GameRun`, `RoundQuestion`, `LieAnswer`, `Guess`, `LieReaction`, `Player`) is **deleted when host exits or game reaches FINISHED**. `GameSession` row is kept (with status=FINISHED) for the session code uniqueness constraint. `Category` and `Question` content is permanent.
|
||||
|
||||
---
|
||||
|
||||
## Not In Scope (This Implementation)
|
||||
|
||||
- TTS / read-aloud (Fase 4, deferred)
|
||||
- Reconnect recovery after server restart (game is gone if server dies)
|
||||
- Spectator/viewer mode (post-MVP)
|
||||
- Rate limiting on endpoints (backlog)
|
||||
- Bulk question import (Fase 5)
|
||||
2180
docs/plans/2026-03-09-fupogfakta-implementation-plan.md
Normal file
2180
docs/plans/2026-03-09-fupogfakta-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
193
docs/plans/2026-03-18-host-player-visual-overhaul-plan.md
Normal file
193
docs/plans/2026-03-18-host-player-visual-overhaul-plan.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Host + Player Visual Overhaul Plan
|
||||
|
||||
**Date:** 2026-03-18
|
||||
**Updated:** 2026-03-23
|
||||
**Status:** Active
|
||||
|
||||
## Goal
|
||||
|
||||
- make the SPA host shell usable as the projected primary game screen
|
||||
- make the SPA player shell a minimal mobile action surface instead of a secondary dashboard
|
||||
- make phase changes feel shared across devices, without manual refresh in the normal path
|
||||
- keep explicit developer-state available for host and player without contaminating the default presentation
|
||||
|
||||
## Product Decisions From Playtesting
|
||||
|
||||
- the authenticated host may still create the session and choose the game, but the first player to join becomes the lobby captain on a phone
|
||||
- the lobby captain is the default pre-game operator on the player side:
|
||||
- can start the game
|
||||
- can confirm readiness
|
||||
- can choose or rotate a player icon from a curated set when that is supported by the cartridge
|
||||
- non-captain player devices should stay intentionally simple before game start:
|
||||
- icon selection if the game supports it
|
||||
- otherwise a passive "wait for the game to start" state
|
||||
- once the game has started, player devices should only show the current player action:
|
||||
- text input
|
||||
- buttons
|
||||
- draw area
|
||||
- hidden private information when a cartridge needs it
|
||||
- player devices should not show roster, session metadata, or broad game-state detail in the default UX
|
||||
- pushed updates need to land across clients in the same phase-change window; perfect instant sync is not required, but manual update should not be part of the happy path
|
||||
|
||||
## Current State
|
||||
|
||||
- role-aware session-detail payloads already exist for host/player/public viewers
|
||||
- websocket transport and reconnect fallback already exist
|
||||
- host/player developer-state toggles already exist
|
||||
- presenter/player visual styling, question ornaments, and host voice playback already exist
|
||||
- the remaining gaps are mostly product-shape gaps:
|
||||
- lobby captain flow is not yet the default
|
||||
- the default player shell still exposes too much context
|
||||
- synchronized multi-device updates are not yet strong enough for confidence
|
||||
- authored player identity assets and presenter-copy content are still incomplete
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- solving every future cartridge in this lane
|
||||
- custom user-uploaded avatars
|
||||
- full spectator mode
|
||||
- native mobile apps
|
||||
- replacing the existing REST contract wholesale
|
||||
|
||||
## Known Issues To Solve
|
||||
|
||||
- a player phone cannot yet reliably act as the pre-game "start game" controller
|
||||
- some clients still feel stale until a manual update path is used
|
||||
- the default player UI still behaves too much like a diagnostic shell
|
||||
- the plan file itself previously drifted into websocket-only scope; this document is now the authoritative visual-overhaul plan again
|
||||
|
||||
## Verification
|
||||
|
||||
- `.venv/bin/python manage.py check`
|
||||
- `.venv/bin/python manage.py test realtime.tests lobby.tests.SessionDetailPhaseViewModelTests fupogfakta.tests.FupOgFaktaExtractionSliceTests`
|
||||
- `npm --prefix frontend test -- angular-api-client.test.ts`
|
||||
- `npm --prefix frontend/angular test -- src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts src/app/realtime-visual-smoke.spec.ts`
|
||||
- `npm --prefix frontend/angular run build`
|
||||
- local or staging multi-device smoke captured with `docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md`
|
||||
|
||||
## Batch 1 — Lobby Captain Flow
|
||||
|
||||
- add an explicit lobby-captain concept to the session contract
|
||||
- the first player to join becomes the default captain
|
||||
- expose captain capability to the player shell:
|
||||
- start game
|
||||
- ready/continue controls where appropriate
|
||||
- pre-game icon selection when supported
|
||||
- keep a clear fallback/override story for dev and staging:
|
||||
- authenticated host can still inspect or recover the flow
|
||||
- developer-state shows who the current captain is and why
|
||||
- update lobby and gameplay rules so "who can start" is deterministic and testable
|
||||
|
||||
**Acceptance:** in the normal couch + TV flow, the first joined player can start the game from a phone without needing the projected host screen as an operator console.
|
||||
|
||||
## Batch 2 — Realtime Coherence
|
||||
|
||||
- treat websocket push as the primary phase-change transport
|
||||
- make every significant phase transition emit enough information for all connected clients to converge without user action
|
||||
- add or tighten revision/phase markers so stale refreshes do not leave some devices behind
|
||||
- keep polling only as fallback and recovery
|
||||
- if realtime recovery takes longer than a short threshold, show a clear reconnect notice instead of silently leaving stale UI on screen
|
||||
- extend tests to cover one host plus three player clients moving through the same phase transition window
|
||||
|
||||
**Acceptance:** phase changes propagate across host and player clients together closely enough that manual update is not required in the happy path.
|
||||
|
||||
## Batch 3 — Simplified Player Phone UX
|
||||
|
||||
- reduce the default player shell to one main action surface per phase
|
||||
- pre-game states:
|
||||
- captain device: start game + identity/icon choice if supported
|
||||
- non-captain devices: icon choice or passive wait state
|
||||
- in-game states:
|
||||
- input-only or choice-only when action is required
|
||||
- simple waiting state when no action is required
|
||||
- private hidden information panel when a cartridge needs it
|
||||
- remove roster/session/debug context from the default player presentation
|
||||
- keep developer-state behind the existing explicit toggle/query override
|
||||
- preserve draft text, selections, and focus during background sync and reconnect
|
||||
|
||||
**Acceptance:** a player can glance at the phone and immediately know what to do, without seeing unnecessary room/session detail.
|
||||
|
||||
## Batch 4 — Presenter Host Experience
|
||||
|
||||
- keep the host screen presenter-first:
|
||||
- lobby scene
|
||||
- question/lie scene
|
||||
- guess scene
|
||||
- reveal scene
|
||||
- scoreboard scene
|
||||
- final result scene
|
||||
- show the question prominently on the projected host even when players only get input controls
|
||||
- display player icons/colors consistently across all presenter beats
|
||||
- keep operational controls in a secondary backstage layer instead of the default projected surface
|
||||
- integrate voice cues and uploaded audio into the presenter rhythm
|
||||
- make the default lobby scene support the new player-captain model:
|
||||
- projected host shows readiness and roster
|
||||
- captain phone owns the start action
|
||||
|
||||
**Acceptance:** the host screen can stay on the TV as the main visual surface without asking the projected operator to click through the game.
|
||||
|
||||
## Batch 5 — Content, Assets, and Cartridge Hooks
|
||||
|
||||
- add content/admin support for curated player icon sets or avatar-like identity options
|
||||
- keep authored question ornaments and extend them where useful
|
||||
- add optional phase-specific presenter copy that can be authored instead of only inferred from shared i18n keys
|
||||
- define one generic private-info contract for cartridges that need hidden player instructions or roles
|
||||
- keep the fallback story explicit:
|
||||
- deterministic icons and copy still work when authored assets are absent
|
||||
|
||||
**Acceptance:** the visual lane is not blocked on hardcoded placeholder assets, and future cartridges have a place to attach hidden player info without bloating the default phone UX.
|
||||
|
||||
## Batch 6 — Smoke Coverage and Sign-Off
|
||||
|
||||
- extend automated smoke for:
|
||||
- first-player captain start
|
||||
- websocket phase propagation across host + 3 player clients
|
||||
- no-input-loss during reconnect
|
||||
- presenter-only question visibility
|
||||
- hidden player info routing where applicable
|
||||
- developer-state visibility and safety gates
|
||||
- extend the manual artifact checklist so it proves:
|
||||
- projected host during lobby, reveal, and scoreboard
|
||||
- captain phone start flow
|
||||
- at least 3 player devices
|
||||
- reconnect recovery without manual refresh
|
||||
- separate host/player developer-state captures
|
||||
|
||||
**Acceptance:** the overhaul is demonstrable in a realistic living-room flow, not just in isolated component tests.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
This lane is complete when:
|
||||
|
||||
- the first joined player can start the game from a phone in the normal flow
|
||||
- non-captain phones stay intentionally minimal before game start
|
||||
- player phones show only the current action or private hidden info by default
|
||||
- websocket state sync is the primary update path and manual update is not required in the happy path
|
||||
- the projected host screen is usable as a real presenter surface
|
||||
- backend contracts remain role-correct
|
||||
- host/player visuals share one coherent visual system
|
||||
- regression tests cover realtime, visibility, and input preservation
|
||||
- host and player both provide an explicit developer-state that is useful in dev/staging and hidden by default
|
||||
- the multi-device artifact in `docs/SPA_VISUAL_REALTIME_SMOKE_ARTIFACT.md` has been captured for the current flow
|
||||
|
||||
## Recommended Order
|
||||
|
||||
1. Batch 1: lobby captain flow.
|
||||
2. Batch 2: realtime coherence.
|
||||
3. Batch 3: simplified player phone UX.
|
||||
4. Batch 4: presenter host flow adjustments for the captain model.
|
||||
5. Batch 5: authored assets and cartridge hooks.
|
||||
6. Batch 6: smoke coverage and sign-off.
|
||||
|
||||
## Ralph Loop Exit Rule
|
||||
|
||||
- this plan should only be marked `Completed` when the Definition of Done is satisfied
|
||||
- narrower websocket or sub-feature slices should be tracked as check-ins under this plan or in separate plan files, not by rewriting this file into a different plan
|
||||
|
||||
## Explicitly Deferred
|
||||
|
||||
- custom uploaded user avatars
|
||||
- advanced moderation/admin tooling
|
||||
- spectator mode
|
||||
- native mobile clients
|
||||
- multi-cartridge theming beyond the shared shell and contract hooks above
|
||||
137
docs/plans/2026-03-18-mvp-deployable-testable-plan.md
Normal file
137
docs/plans/2026-03-18-mvp-deployable-testable-plan.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# MVP Plan — Deployable and Testable Repository
|
||||
|
||||
**Date:** 2026-03-18
|
||||
**Status:** In progress
|
||||
|
||||
## Goal
|
||||
|
||||
Get the repository into a state where one person can deploy it to staging or a local VM, run one documented setup flow, and verify one full playable round through the intended UI without ad hoc fixes.
|
||||
|
||||
For this plan, **deployable and testable** means:
|
||||
|
||||
- backend boots with documented env vars
|
||||
- frontend assets build successfully
|
||||
- one UI path is designated as the MVP path
|
||||
- a development `docker compose` setup exists for the app and its required services
|
||||
- CI covers the checks needed to trust a release candidate
|
||||
- a smoke flow proves host + 3 players can complete one full round
|
||||
|
||||
## Current Baseline
|
||||
|
||||
Verified on 2026-03-18:
|
||||
|
||||
- `manage.py check` passes
|
||||
- `manage.py test lobby fupogfakta` passes
|
||||
- `npm --prefix frontend test` passes
|
||||
- `npm --prefix frontend run build` passes
|
||||
- `npm --prefix frontend/angular test` passes
|
||||
- `npm --prefix frontend/angular run build` **fails**
|
||||
|
||||
Main blockers observed:
|
||||
|
||||
1. Angular production build fails on strict template nullability in host/player reveal panels.
|
||||
2. `shared/i18n/lobby.json` contains duplicate keys and produces build warnings.
|
||||
3. The repo still has two UI modes (`USE_SPA_UI` ON/OFF), so the MVP “real path” is ambiguous.
|
||||
4. CI does not yet enforce the full MVP gate.
|
||||
|
||||
## Progress Snapshot
|
||||
|
||||
Implemented on 2026-03-18:
|
||||
|
||||
- legacy UI (`USE_SPA_UI=false`) is the explicit MVP path
|
||||
- Angular build and test pipeline passes
|
||||
- duplicate shared i18n keys were removed and drift is checked
|
||||
- development `docker compose` exists for Django + MySQL + Redis
|
||||
- local deterministic bootstrap is available via `python manage.py bootstrap_mvp`
|
||||
- local release verification is available via `./scripts/verify_mvp_release.sh`
|
||||
- staging deploy + smoke wrappers exist via `infra/staging/deploy_and_smoke_staging.sh`
|
||||
|
||||
Remaining MVP sign-off item:
|
||||
|
||||
- run the real staging deploy + smoke flow and record the resulting artifact
|
||||
|
||||
## Plan
|
||||
|
||||
### Batch 1 — Make the chosen UI path buildable
|
||||
|
||||
- Decide the MVP runtime path:
|
||||
- Current choice: legacy UI (`USE_SPA_UI=false`)
|
||||
- SPA remains a follow-up cutover path after the MVP release gate is stable
|
||||
- Fix Angular template errors in `frontend/angular/src/app/features/host/host-shell.component.ts` and `frontend/angular/src/app/features/player/player-shell.component.ts`.
|
||||
- Remove duplicate i18n keys in `shared/i18n/lobby.json`.
|
||||
- Require these checks to pass locally:
|
||||
- `npm --prefix frontend/angular test`
|
||||
- `npm --prefix frontend/angular run build`
|
||||
|
||||
### Batch 2 — Make local/staging setup deterministic
|
||||
|
||||
- Add one documented bootstrap path for:
|
||||
- backend venv install
|
||||
- database migrate
|
||||
- host user creation
|
||||
- sample category/question seed
|
||||
- Create a development `docker compose` file that can start the services needed for local work:
|
||||
- Django app
|
||||
- MySQL or the chosen dev database
|
||||
- Redis for Channels
|
||||
- optional frontend dev server if the team wants one-command startup
|
||||
- Ensure the compose setup matches the documented env var contract and supports the chosen MVP UI path.
|
||||
- Keep SQLite acceptable for local try-out; keep MySQL/Redis for staging.
|
||||
- Document the exact env vars and `USE_SPA_UI` setting required for the MVP path.
|
||||
|
||||
### Batch 3 — Make smoke verification release-grade
|
||||
|
||||
- Treat one canonical smoke as required:
|
||||
- create session
|
||||
- 3 players join
|
||||
- start round
|
||||
- submit lies
|
||||
- submit guesses
|
||||
- reveal
|
||||
- scoreboard
|
||||
- next round or finish
|
||||
- Keep `python manage.py smoke_staging --artifact <path>` as the canonical backend smoke entrypoint.
|
||||
- Provide one staging wrapper command that chains deploy + MVP smoke for release candidates.
|
||||
- Add one short manual UI smoke checklist for the chosen MVP path only.
|
||||
|
||||
### Batch 4 — Align CI with MVP readiness
|
||||
|
||||
- Expand CI to run:
|
||||
- `python manage.py check`
|
||||
- `python manage.py test lobby fupogfakta`
|
||||
- `npm --prefix frontend test`
|
||||
- `npm --prefix frontend run build`
|
||||
- `npm --prefix frontend/angular test`
|
||||
- `npm --prefix frontend/angular run build`
|
||||
- Update lint scope so it no longer only checks `lobby/`.
|
||||
- Provide one local wrapper command for the same MVP gate before staging deploy.
|
||||
|
||||
### Batch 5 — Freeze the MVP boundary
|
||||
|
||||
- Declare which items are required for MVP and which are explicitly deferred.
|
||||
- Defer non-blockers:
|
||||
- broader game-driver redesign
|
||||
- extra cartridges
|
||||
- polished host presentation UX
|
||||
- voice integration
|
||||
- post-game awards or richer reactions
|
||||
|
||||
## Definition of Done
|
||||
|
||||
The repo is MVP-deployable and testable when all of the following are true:
|
||||
|
||||
- one UI path is explicitly marked as the MVP path
|
||||
- Angular production build passes for that path
|
||||
- a development `docker compose` setup can bring up the required local dependencies
|
||||
- staging deploy docs match the code and env flags
|
||||
- CI enforces the same checks used for release sign-off
|
||||
- one full smoke round passes through host/player UI and is captured as evidence
|
||||
|
||||
## Recommended Order
|
||||
|
||||
1. Fix Angular build blockers and i18n duplication.
|
||||
2. Choose SPA ON or legacy as the single MVP runtime path.
|
||||
3. Add the development `docker compose` setup.
|
||||
4. Write the bootstrap/setup doc and seed flow.
|
||||
5. Expand CI to the full MVP gate.
|
||||
6. Re-run staging smoke and record the artifact.
|
||||
@@ -3,6 +3,8 @@
|
||||
## Formål
|
||||
`USE_SPA_UI` styrer om host/player UI routes serverer Angular SPA shell eller legacy Django templates.
|
||||
|
||||
Aktuel MVP release-gate bruger `USE_SPA_UI=false`. Denne note beskriver separat cutover-arbejde, ikke den primære MVP deploy-path.
|
||||
|
||||
## Miljø-toggle (uden kodeændring)
|
||||
Sæt env var pr. miljø:
|
||||
|
||||
@@ -59,9 +61,41 @@ Trin-for-trin:
|
||||
|
||||
Target: rollback + sanity-verifikation inden for 10 minutter.
|
||||
|
||||
## React fallback trigger-kriterier (kun delivery-blocking)
|
||||
Formål: React fallback må kun bruges som kortvarig leverings-sikring, når release ellers er blokeret.
|
||||
|
||||
### Hvornår fallback er tilladt
|
||||
Alle punkter skal være opfyldt:
|
||||
1. **Delivery-blocking fejl i Angular SPA**
|
||||
- Host/player kerneflow kan ikke leveres i release-vinduet (fx login/join/start/round/scoreboard stopper).
|
||||
2. **Ingen hurtig Angular-fix inden for release-vinduet**
|
||||
- Teamet har vurderet at patch + verificering ikke kan nås sikkert i tide.
|
||||
3. **Rollback alene løser ikke leveringsbehovet**
|
||||
- `USE_SPA_UI=false` (legacy) er enten utilstrækkelig for den konkrete leverance eller allerede verificeret som ikke tilstrækkelig.
|
||||
4. **Beslutning er eksplicit logget**
|
||||
- Trigger, impact, UTC-tid, ansvarlig, issue/incident-reference og plan for tilbagevenden til Angular er dokumenteret i release/smoke artifact.
|
||||
|
||||
### Scope-limits for fallback
|
||||
- Fallback omfatter kun **delivery-blocking host/player-paths**.
|
||||
- Ingen nye features, UX-forbedringer eller ikke-kritiske ændringer må bundtes ind i fallback.
|
||||
- Fallback er **midlertidig** og gælder kun for aktiv incident/release-vindue.
|
||||
- Når blocker er fjernet, skal miljøet tilbage på standard cutover-spor (Angular + `USE_SPA_UI` styring).
|
||||
|
||||
### Ikke tilladt
|
||||
- Proaktiv fallback "for en sikkerheds skyld" uden aktiv blocker.
|
||||
- Brug af fallback til at omgå normale kvalitetsgates eller testkrav.
|
||||
- Langvarig drift i fallback-mode uden dokumenteret blocker og opfølgningsplan.
|
||||
|
||||
## Verifikation
|
||||
- Flag OFF: `UiScreenTests.test_legacy_templates_are_used_when_spa_flag_is_off`
|
||||
- Flag ON (host): `UiScreenTests.test_host_screen_can_render_angular_shell_when_feature_flag_enabled`
|
||||
- Flag ON (host deep-link): `UiScreenTests.test_host_screen_deeplink_preserves_spa_path_when_feature_flag_enabled`
|
||||
- Flag ON (player): `UiScreenTests.test_player_screen_can_render_angular_shell_when_feature_flag_enabled`
|
||||
- Smoke-checkliste for cutover paths: `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md` + `docs/UI_SMOKE.md`
|
||||
|
||||
|
||||
## MVP audio policy guardrail (telefon-klient)
|
||||
- Telefon-/player-klienten må ikke starte lydafspilning lokalt i MVP (`primary-device only`).
|
||||
- Policy er bundet til capability-flaget `frontend.capabilities.client_has_no_audio_output=true` i `shared/i18n/lobby.json`.
|
||||
- Brugeradvarsel i player UI leveres via i18n key: `frontend.ui.player.audio_policy_notice`.
|
||||
- Acceptance-spec er dækket i Angular tests (`player-shell.component.spec.ts`), inkl. at init-path ikke kalder original media `play`.
|
||||
|
||||
@@ -19,6 +19,24 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
|
||||
shown_at: '2026-03-01T18:00:00Z',
|
||||
answers: [{ text: 'A' }, { text: 'B' }]
|
||||
},
|
||||
reveal: {
|
||||
round_question_id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Q?',
|
||||
correct_answer: 'A',
|
||||
lies: [{ player_id: 2, nickname: 'Maja', text: 'B', created_at: '2026-03-01T18:00:05Z' }],
|
||||
guesses: [
|
||||
{
|
||||
player_id: 3,
|
||||
nickname: 'Bo',
|
||||
selected_text: 'B',
|
||||
is_correct: false,
|
||||
fooled_player_id: 2,
|
||||
fooled_player_nickname: 'Maja',
|
||||
created_at: '2026-03-01T18:00:15Z'
|
||||
}
|
||||
]
|
||||
},
|
||||
phase_view_model: {
|
||||
status: 'lobby',
|
||||
round_number: 1,
|
||||
@@ -31,17 +49,69 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
|
||||
},
|
||||
host: {
|
||||
can_start_round: true,
|
||||
can_show_question: 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
|
||||
}
|
||||
}
|
||||
} 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: true,
|
||||
can_reveal_scoreboard: true,
|
||||
can_start_next_round: true,
|
||||
can_finish_game: 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: true,
|
||||
can_submit_guess: false,
|
||||
can_view_final_result: false
|
||||
}
|
||||
}
|
||||
@@ -50,7 +120,7 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
|
||||
|
||||
if (url === '/lobby/sessions/ABCD12/scoreboard') {
|
||||
return {
|
||||
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
|
||||
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
|
||||
leaderboard: [
|
||||
{ id: 9, nickname: 'Maja', score: 200 },
|
||||
{ id: 10, nickname: 'Bo', score: 150 }
|
||||
@@ -70,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 {
|
||||
@@ -107,6 +184,12 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
|
||||
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
|
||||
round_question: { id: 77, round_number: 1 },
|
||||
events_created: 2,
|
||||
reveal: {
|
||||
round_question_id: 77,
|
||||
correct_answer: 'A',
|
||||
lies: [],
|
||||
guesses: []
|
||||
},
|
||||
leaderboard: [
|
||||
{ id: 9, nickname: 'Maja', score: 200 },
|
||||
{ id: 10, nickname: 'Bo', score: 150 }
|
||||
@@ -167,13 +250,23 @@ 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(true);
|
||||
expect(session.data.phase_view_model.player.can_submit_guess).toBe(true);
|
||||
expect(session.data.phase_view_model.host.can_start_next_round).toBe(false);
|
||||
expect(session.data.phase_view_model.player.can_submit_guess).toBe(false);
|
||||
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);
|
||||
@@ -202,43 +295,50 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
|
||||
).toBe(true);
|
||||
|
||||
expect(get).toHaveBeenNthCalledWith(1, '/lobby/sessions/ABCD12', { withCredentials: true });
|
||||
expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12/scoreboard', { withCredentials: true });
|
||||
expect(get).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12?session_token=session-token-1', { withCredentials: true });
|
||||
expect(get).toHaveBeenNthCalledWith(3, '/lobby/sessions/ABCD12/scoreboard', { withCredentials: true });
|
||||
|
||||
expect(post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/lobby/sessions/create',
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
expect(post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/lobby/sessions/join',
|
||||
{ code: 'ABCD12', nickname: 'Maja' },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
expect(post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
3,
|
||||
'/lobby/sessions/ABCD12/rounds/start',
|
||||
{ category_slug: 'history' },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
expect(post).toHaveBeenNthCalledWith(3, '/lobby/sessions/ABCD12/questions/show', {}, { withCredentials: true });
|
||||
expect(post).toHaveBeenNthCalledWith(4, '/lobby/sessions/ABCD12/questions/show', {}, { withCredentials: true });
|
||||
expect(post).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
5,
|
||||
'/lobby/sessions/ABCD12/questions/77/answers/mix',
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
expect(post).toHaveBeenNthCalledWith(
|
||||
5,
|
||||
6,
|
||||
'/lobby/sessions/ABCD12/questions/77/scores/calculate',
|
||||
{},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
expect(post).toHaveBeenNthCalledWith(6, '/lobby/sessions/ABCD12/rounds/next', {}, { withCredentials: true });
|
||||
expect(post).toHaveBeenNthCalledWith(7, '/lobby/sessions/ABCD12/finish', {}, { withCredentials: true });
|
||||
expect(post).toHaveBeenNthCalledWith(7, '/lobby/sessions/ABCD12/rounds/next', {}, { withCredentials: true });
|
||||
expect(post).toHaveBeenNthCalledWith(8, '/lobby/sessions/ABCD12/finish', {}, { withCredentials: true });
|
||||
expect(post).toHaveBeenNthCalledWith(
|
||||
8,
|
||||
9,
|
||||
'/lobby/sessions/ABCD12/questions/77/lies/submit',
|
||||
{ player_id: 9, session_token: 'session-token-1', text: 'my lie' },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
expect(post).toHaveBeenNthCalledWith(
|
||||
9,
|
||||
10,
|
||||
'/lobby/sessions/ABCD12/questions/77/guesses/submit',
|
||||
{ player_id: 9, session_token: 'session-token-1', selected_text: 'B' },
|
||||
{ withCredentials: true }
|
||||
|
||||
@@ -1,5 +1,86 @@
|
||||
.shell { font-family: Arial, sans-serif; margin: 1rem; }
|
||||
.shell__header { display: flex; flex-wrap: wrap; gap: 0.75rem; justify-content: space-between; align-items: center; border-bottom: 1px solid #ddd; padding-bottom: 0.75rem; }
|
||||
.shell__header nav { display: flex; gap: 0.75rem; }
|
||||
.shell__content { margin-top: 1rem; }
|
||||
.locale-picker { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.95rem; }
|
||||
.shell {
|
||||
margin: 0 auto;
|
||||
max-width: 84rem;
|
||||
min-height: 100vh;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.shell__header {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid var(--wpp-border);
|
||||
border-radius: var(--wpp-radius-xl);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(255, 255, 255, 0.5), transparent 38%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(245, 249, 250, 0.94));
|
||||
box-shadow: var(--wpp-shadow-soft);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.shell__brand {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.shell__brand h1 {
|
||||
margin: 0;
|
||||
font-family: var(--wpp-font-display);
|
||||
font-size: clamp(1.8rem, 3vw, 2.6rem);
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.shell__brand p {
|
||||
margin: 0;
|
||||
color: var(--wpp-ink-muted);
|
||||
}
|
||||
|
||||
.shell__nav-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 0.9rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shell__header nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.shell__header nav a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.58rem 0.86rem;
|
||||
border-radius: var(--wpp-radius-pill);
|
||||
color: var(--wpp-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 800;
|
||||
background: var(--wpp-accent-soft);
|
||||
}
|
||||
|
||||
.shell__content {
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.locale-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--wpp-ink);
|
||||
}
|
||||
|
||||
.locale-picker select {
|
||||
border: 1px solid var(--wpp-border);
|
||||
border-radius: var(--wpp-radius-pill);
|
||||
padding: 0.46rem 0.8rem;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.shell {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<main class="shell">
|
||||
<header class="shell__header">
|
||||
<div class="shell__brand">
|
||||
<p class="wpp-eyebrow">{{ copy('app.home_badge') }}</p>
|
||||
<h1>{{ copy('app.title') }}</h1>
|
||||
<p>{{ copy('app.home_intro') }}</p>
|
||||
</div>
|
||||
<div class="shell__nav-row">
|
||||
<nav>
|
||||
<a routerLink="/">{{ copy('app.home_nav') }}</a>
|
||||
<a routerLink="/host">{{ copy('app.host_nav') }}</a>
|
||||
<a routerLink="/player">{{ copy('app.player_nav') }}</a>
|
||||
</nav>
|
||||
@@ -12,6 +18,7 @@
|
||||
<option value="da">Dansk</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="shell__content" [attr.data-wpp-locale]="locale">
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { ApplicationConfig } from '@angular/core';
|
||||
import { provideRouter, withHashLocation } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { createWppApiClient, WPP_API_CLIENT } from './wpp-api-client';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [provideRouter(routes, withHashLocation())],
|
||||
providers: [
|
||||
provideRouter(routes, withHashLocation()),
|
||||
{
|
||||
provide: WPP_API_CLIENT,
|
||||
useFactory: () => createWppApiClient(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
} from './session-route-context';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
loadComponent: () => import('./features/home/home-shell.component').then((m) => m.HomeShellComponent),
|
||||
},
|
||||
{
|
||||
path: 'host',
|
||||
resolve: { routeContext: hostRouteContextResolver },
|
||||
@@ -44,6 +49,5 @@ export const routes: Routes = [
|
||||
canActivate: [playerRouteGuard],
|
||||
loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent),
|
||||
},
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'player' },
|
||||
{ path: '**', redirectTo: 'player' },
|
||||
{ path: '**', redirectTo: '' },
|
||||
];
|
||||
|
||||
58
frontend/angular/src/app/developer-state.spec.ts
Normal file
58
frontend/angular/src/app/developer-state.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { resolveDeveloperState, toggleDeveloperState } from './developer-state';
|
||||
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
|
||||
|
||||
function storageMock(initial: Record<string, string> = {}): StorageLike {
|
||||
const data = new Map<string, string>(Object.entries(initial));
|
||||
return {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
data.set(key, value);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('developer-state helpers', () => {
|
||||
it('reads persisted developer state when no query override is present', () => {
|
||||
const storage = storageMock({ 'wpp.host.developer-mode': 'true' });
|
||||
|
||||
expect(
|
||||
resolveDeveloperState('wpp.host.developer-mode', {
|
||||
storage,
|
||||
location: { search: '', hash: '#/host/lobby/ABCD12' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('lets query params override persisted state and persists the override', () => {
|
||||
const storage = storageMock({ 'wpp.host.developer-mode': 'false' });
|
||||
|
||||
expect(
|
||||
resolveDeveloperState('wpp.host.developer-mode', {
|
||||
storage,
|
||||
location: { search: '?dev=1', hash: '#/host/lobby/ABCD12' },
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(storage.setItem).toHaveBeenCalledWith('wpp.host.developer-mode', 'true');
|
||||
});
|
||||
|
||||
it('reads hash query overrides for hash-routed SPA paths', () => {
|
||||
const storage = storageMock();
|
||||
|
||||
expect(
|
||||
resolveDeveloperState('wpp.player.developer-mode', {
|
||||
storage,
|
||||
location: { search: '', hash: '#/player/guess/ABCD12?session=ABCD12&dev=1' },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles and persists the next developer state value', () => {
|
||||
const storage = storageMock();
|
||||
|
||||
expect(toggleDeveloperState('wpp.player.developer-mode', false, storage)).toBe(true);
|
||||
expect(storage.setItem).toHaveBeenCalledWith('wpp.player.developer-mode', 'true');
|
||||
});
|
||||
});
|
||||
68
frontend/angular/src/app/developer-state.ts
Normal file
68
frontend/angular/src/app/developer-state.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
|
||||
type LocationLike = Pick<Location, 'search' | 'hash'>;
|
||||
|
||||
function readFlag(value: string | null): boolean | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (['0', 'false', 'no', 'off'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readHashQuery(hash: string): URLSearchParams {
|
||||
const queryIndex = hash.indexOf('?');
|
||||
if (queryIndex === -1) {
|
||||
return new URLSearchParams();
|
||||
}
|
||||
return new URLSearchParams(hash.slice(queryIndex + 1));
|
||||
}
|
||||
|
||||
function resolveFlagFromLocation(locationLike: LocationLike | null | undefined): boolean | null {
|
||||
if (!locationLike) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchValue = readFlag(new URLSearchParams(locationLike.search || '').get('dev'));
|
||||
if (searchValue !== null) {
|
||||
return searchValue;
|
||||
}
|
||||
|
||||
return readFlag(readHashQuery(locationLike.hash || '').get('dev'));
|
||||
}
|
||||
|
||||
export function resolveDeveloperState(
|
||||
storageKey: string,
|
||||
options: {
|
||||
storage?: StorageLike | null;
|
||||
location?: LocationLike | null;
|
||||
} = {},
|
||||
): boolean {
|
||||
const storage = options.storage ?? (typeof window !== 'undefined' ? window.localStorage : null);
|
||||
const locationLike = options.location ?? (typeof window !== 'undefined' ? window.location : null);
|
||||
|
||||
const locationValue = resolveFlagFromLocation(locationLike);
|
||||
if (locationValue !== null) {
|
||||
storage?.setItem(storageKey, String(locationValue));
|
||||
return locationValue;
|
||||
}
|
||||
|
||||
return storage?.getItem(storageKey) === 'true';
|
||||
}
|
||||
|
||||
export function toggleDeveloperState(
|
||||
storageKey: string,
|
||||
currentValue: boolean,
|
||||
storage?: StorageLike | null,
|
||||
): boolean {
|
||||
const nextValue = !currentValue;
|
||||
const targetStorage = storage ?? (typeof window !== 'undefined' ? window.localStorage : null);
|
||||
targetStorage?.setItem(storageKey, String(nextValue));
|
||||
return nextValue;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { AngularApiClient } from '../../../../../src/api/angular-client';
|
||||
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
|
||||
import { HomeShellComponent } from './home-shell.component';
|
||||
|
||||
type StorageLike = {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
};
|
||||
|
||||
function storageMock(initial: Record<string, string> = {}): StorageLike {
|
||||
const data = new Map<string, string>(Object.entries(initial));
|
||||
return {
|
||||
getItem: vi.fn((key: string) => data.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
data.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
data.delete(key);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function apiMock(overrides: Partial<AngularApiClient> = {}): AngularApiClient {
|
||||
return {
|
||||
health: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
joinSession: vi.fn(),
|
||||
startRound: vi.fn(),
|
||||
showQuestion: vi.fn(),
|
||||
mixAnswers: vi.fn(),
|
||||
calculateScores: vi.fn(),
|
||||
getScoreboard: vi.fn(),
|
||||
startNextRound: vi.fn(),
|
||||
finishGame: vi.fn(),
|
||||
submitLie: vi.fn(),
|
||||
submitGuess: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as AngularApiClient;
|
||||
}
|
||||
|
||||
describe('HomeShellComponent', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('creates a host session and routes to the host shell', async () => {
|
||||
const sessionStorage = storageMock();
|
||||
vi.stubGlobal('window', {
|
||||
sessionStorage,
|
||||
localStorage: storageMock(),
|
||||
location: { assign: vi.fn() },
|
||||
});
|
||||
|
||||
const router = { navigate: vi.fn().mockResolvedValue(true) };
|
||||
const api = apiMock({
|
||||
createSession: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 201,
|
||||
data: { session: { code: 'ABCD12', status: 'lobby', host_id: 4, current_round: 1 } },
|
||||
}),
|
||||
});
|
||||
|
||||
const component = new HomeShellComponent().withTestingDependencies({
|
||||
router,
|
||||
api,
|
||||
sessionContextStore: createSessionContextStore(storageMock() as Storage),
|
||||
location: { assign: vi.fn() },
|
||||
});
|
||||
|
||||
await component.createSession();
|
||||
|
||||
expect(sessionStorage.setItem).toHaveBeenCalledWith('wpp.host-session-code', 'ABCD12');
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/host'], { queryParams: { session: 'ABCD12' } });
|
||||
expect(component.hostError).toBe('');
|
||||
});
|
||||
|
||||
it('joins a player session and persists player context before routing', async () => {
|
||||
const localStorage = storageMock();
|
||||
vi.stubGlobal('window', {
|
||||
sessionStorage: storageMock(),
|
||||
localStorage,
|
||||
location: { assign: vi.fn() },
|
||||
});
|
||||
|
||||
const router = { navigate: vi.fn().mockResolvedValue(true) };
|
||||
const api = apiMock({
|
||||
joinSession: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 201,
|
||||
data: {
|
||||
player: { id: 9, nickname: 'Luna', session_token: 'tok-9', score: 0 },
|
||||
session: { code: 'ABCD12', status: 'lobby' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const store = createSessionContextStore(localStorage as Storage);
|
||||
|
||||
const component = new HomeShellComponent().withTestingDependencies({
|
||||
router,
|
||||
api,
|
||||
sessionContextStore: store,
|
||||
location: { assign: vi.fn() },
|
||||
});
|
||||
component.sessionCode = ' abcd12 ';
|
||||
component.nickname = ' Luna ';
|
||||
|
||||
await component.joinSession();
|
||||
|
||||
expect(store.get()).toEqual({ sessionCode: 'ABCD12', playerId: 9, token: 'tok-9' });
|
||||
expect(router.navigate).toHaveBeenCalledWith(['/player'], { queryParams: { session: 'ABCD12' } });
|
||||
expect(component.playerError).toBe('');
|
||||
});
|
||||
});
|
||||
219
frontend/angular/src/app/features/home/home-shell.component.ts
Normal file
219
frontend/angular/src/app/features/home/home-shell.component.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import type { AngularApiClient } from '../../../../../src/api/angular-client';
|
||||
import { createSessionContextStore, type SessionContextStore } from '../../../../../src/spa/session-context-store';
|
||||
import { subscribeToLocaleChanges, resolvePreferredLocale, t } from '../../lobby-i18n';
|
||||
import { WPP_API_CLIENT } from '../../wpp-api-client';
|
||||
|
||||
type RouterLike = Pick<Router, 'navigate'>;
|
||||
type LocationLike = Pick<Location, 'assign'>;
|
||||
|
||||
type HomeShellDependencies = {
|
||||
router: RouterLike;
|
||||
api: AngularApiClient;
|
||||
sessionContextStore: SessionContextStore;
|
||||
location: LocationLike | null;
|
||||
};
|
||||
|
||||
function resolveLocalStorage(): Storage | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
return window.localStorage;
|
||||
}
|
||||
|
||||
function fallbackRouter(): RouterLike {
|
||||
return { navigate: async () => false };
|
||||
}
|
||||
|
||||
function fallbackApi(): AngularApiClient {
|
||||
return {
|
||||
health: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
|
||||
createSession: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
|
||||
getSession: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
|
||||
joinSession: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
|
||||
startRound: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
|
||||
showQuestion: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
|
||||
mixAnswers: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
|
||||
calculateScores: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
|
||||
getScoreboard: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
|
||||
startNextRound: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
|
||||
finishGame: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
|
||||
submitLie: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
|
||||
submitGuess: async () => ({ ok: false, status: 0, error: { kind: 'network', status: 0, message: 'Unavailable' } }),
|
||||
};
|
||||
}
|
||||
|
||||
function tryInject<T>(factory: () => T, fallback: T): T {
|
||||
try {
|
||||
return factory();
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-home-shell',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
template: `
|
||||
<section class="wpp-page landing">
|
||||
<div class="wpp-hero-card hero">
|
||||
<div class="wpp-stack">
|
||||
<p class="wpp-eyebrow">{{ copy('app.home_badge') }}</p>
|
||||
<h2 class="wpp-title">{{ copy('app.home_title') }}</h2>
|
||||
<p class="wpp-subtitle hero__intro">{{ copy('app.home_intro') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wpp-grid wpp-grid--two">
|
||||
<article class="wpp-card wpp-stack">
|
||||
<h3 class="wpp-section-title">{{ copy('app.host_card_title') }}</h3>
|
||||
<p class="wpp-section-copy">{{ copy('app.host_card_body') }}</p>
|
||||
<p class="wpp-inline-note">{{ copy('app.host_login_hint') }}</p>
|
||||
<div class="wpp-action-row">
|
||||
<button type="button" class="wpp-button wpp-button--secondary" (click)="loginAsHost()">
|
||||
{{ copy('app.host_login') }}
|
||||
</button>
|
||||
<button type="button" class="wpp-button" (click)="createSession()" [disabled]="hostBusy">
|
||||
{{ hostBusy ? copy('app.creating_session') : copy('app.create_session') }}
|
||||
</button>
|
||||
</div>
|
||||
<p *ngIf="hostError" class="wpp-error">{{ hostError }}</p>
|
||||
</article>
|
||||
|
||||
<article class="wpp-card wpp-stack">
|
||||
<h3 class="wpp-section-title">{{ copy('app.player_card_title') }}</h3>
|
||||
<p class="wpp-section-copy">{{ copy('app.player_card_body') }}</p>
|
||||
<label class="wpp-field">
|
||||
<span class="wpp-field-label">{{ copy('common.session_code') }}</span>
|
||||
<input [(ngModel)]="sessionCode" autocomplete="one-time-code" />
|
||||
</label>
|
||||
<label class="wpp-field">
|
||||
<span class="wpp-field-label">{{ copy('player.nickname') }}</span>
|
||||
<input [(ngModel)]="nickname" autocomplete="nickname" />
|
||||
</label>
|
||||
<div class="wpp-action-row">
|
||||
<button type="button" class="wpp-button" (click)="joinSession()" [disabled]="playerBusy">
|
||||
{{ playerBusy ? copy('app.joining_session') : copy('app.join_session') }}
|
||||
</button>
|
||||
</div>
|
||||
<p *ngIf="playerError" class="wpp-error">{{ playerError }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.landing { gap: 1.4rem; }
|
||||
.hero { max-width: 54rem; }
|
||||
.hero__intro { max-width: 42rem; }
|
||||
`],
|
||||
})
|
||||
export class HomeShellComponent implements OnInit, OnDestroy {
|
||||
locale = resolvePreferredLocale();
|
||||
sessionCode = '';
|
||||
nickname = '';
|
||||
hostBusy = false;
|
||||
playerBusy = false;
|
||||
hostError = '';
|
||||
playerError = '';
|
||||
|
||||
private router: RouterLike;
|
||||
private api: AngularApiClient;
|
||||
private sessionContextStore: SessionContextStore;
|
||||
private location: LocationLike | null;
|
||||
private unsubscribeLocale: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
this.router = tryInject(() => inject(Router), fallbackRouter());
|
||||
this.api = tryInject(() => inject(WPP_API_CLIENT), fallbackApi());
|
||||
this.sessionContextStore = createSessionContextStore(resolveLocalStorage());
|
||||
this.location = typeof window !== 'undefined' ? window.location : null;
|
||||
}
|
||||
|
||||
withTestingDependencies(deps: Partial<HomeShellDependencies>): this {
|
||||
if (deps.router) {
|
||||
this.router = deps.router;
|
||||
}
|
||||
if (deps.api) {
|
||||
this.api = deps.api;
|
||||
}
|
||||
if (deps.sessionContextStore) {
|
||||
this.sessionContextStore = deps.sessionContextStore;
|
||||
}
|
||||
if ('location' in deps) {
|
||||
this.location = deps.location ?? null;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
|
||||
this.locale = locale;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeLocale?.();
|
||||
this.unsubscribeLocale = null;
|
||||
}
|
||||
|
||||
copy(key: string): string {
|
||||
return t(key, this.locale);
|
||||
}
|
||||
|
||||
loginAsHost(): void {
|
||||
this.location?.assign(`/accounts/login/?next=${encodeURIComponent('/')}`);
|
||||
}
|
||||
|
||||
async createSession(): Promise<void> {
|
||||
this.hostBusy = true;
|
||||
this.hostError = '';
|
||||
|
||||
const result = await this.api.createSession();
|
||||
if (!result.ok) {
|
||||
this.hostBusy = false;
|
||||
this.hostError = `${this.copy('app.create_session_failed')}: ${result.error.message}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const code = result.data.session.code;
|
||||
if (typeof window !== 'undefined') {
|
||||
window.sessionStorage.setItem('wpp.host-session-code', code);
|
||||
}
|
||||
|
||||
this.hostBusy = false;
|
||||
await this.router.navigate(['/host'], { queryParams: { session: code } });
|
||||
}
|
||||
|
||||
async joinSession(): Promise<void> {
|
||||
this.playerBusy = true;
|
||||
this.playerError = '';
|
||||
|
||||
const normalizedCode = this.sessionCode.trim().toUpperCase();
|
||||
this.sessionCode = normalizedCode;
|
||||
|
||||
const result = await this.api.joinSession({
|
||||
code: normalizedCode,
|
||||
nickname: this.nickname,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
this.playerBusy = false;
|
||||
this.playerError = `${this.copy('app.join_session_failed')}: ${result.error.message}`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.sessionContextStore.set({
|
||||
sessionCode: result.data.session.code,
|
||||
playerId: result.data.player.id,
|
||||
token: result.data.player.session_token,
|
||||
});
|
||||
|
||||
this.playerBusy = false;
|
||||
await this.router.navigate(['/player'], { queryParams: { session: result.data.session.code } });
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { HostShellComponent } from './host-shell.component';
|
||||
|
||||
type FetchMock = ReturnType<typeof vi.fn>;
|
||||
|
||||
type FetchRouteHandler = (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>;
|
||||
|
||||
function jsonResponse(status: number, body: unknown) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
@@ -12,8 +14,78 @@ function jsonResponse(status: number, body: unknown) {
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function sessionDetailPayload(status: string, options?: { roundQuestionId?: number | null }) {
|
||||
function createFetchRouteMock(handler: FetchRouteHandler): FetchMock {
|
||||
return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init)));
|
||||
}
|
||||
|
||||
class HostRealtimeSocketMock {
|
||||
static instances: HostRealtimeSocketMock[] = [];
|
||||
|
||||
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null;
|
||||
onerror: ((event: unknown) => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
|
||||
readonly close = vi.fn();
|
||||
|
||||
constructor(readonly url: string) {
|
||||
HostRealtimeSocketMock.instances.push(this);
|
||||
}
|
||||
|
||||
emitClose(event: { code?: number; reason?: string; wasClean?: boolean } = {}): void {
|
||||
this.onclose?.(event);
|
||||
}
|
||||
|
||||
emitMessage(payload: unknown): void {
|
||||
this.onmessage?.({ data: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
emitOpen(): void {
|
||||
this.onopen?.();
|
||||
}
|
||||
}
|
||||
|
||||
function sessionDetailPayload(
|
||||
status: string,
|
||||
options?: {
|
||||
currentPhase?: string;
|
||||
roundQuestionId?: number | null;
|
||||
roundQuestionPrompt?: string | null;
|
||||
answers?: string[];
|
||||
players?: Array<{
|
||||
id: number;
|
||||
nickname: string;
|
||||
score: number;
|
||||
is_connected?: boolean;
|
||||
identity?: { token: string; tone: string; icon?: string };
|
||||
}>;
|
||||
phaseDisplay?: Record<string, string> | null;
|
||||
voiceCues?: Record<string, unknown> | null;
|
||||
reveal?: {
|
||||
correct_answer: string;
|
||||
prompt?: string;
|
||||
lies?: Array<{ player_id: number; nickname: string; text: string; created_at?: string }>;
|
||||
guesses?: Array<{
|
||||
player_id: number;
|
||||
nickname: string;
|
||||
selected_text: string;
|
||||
is_correct: boolean;
|
||||
fooled_player_id: number | null;
|
||||
fooled_player_nickname?: string;
|
||||
created_at?: string;
|
||||
}>;
|
||||
} | null;
|
||||
}
|
||||
) {
|
||||
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: {
|
||||
@@ -21,7 +93,7 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
|
||||
status,
|
||||
host_id: 1,
|
||||
current_round: status === 'lobby' ? 2 : 1,
|
||||
players_count: 2,
|
||||
players_count: players.length,
|
||||
},
|
||||
round_question:
|
||||
roundQuestionId === null
|
||||
@@ -29,16 +101,68 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
|
||||
: {
|
||||
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
|
||||
: {
|
||||
round_question_id: roundQuestionId,
|
||||
round_number: 1,
|
||||
prompt: options.reveal.prompt ?? 'Q?',
|
||||
correct_answer: options.reveal.correct_answer,
|
||||
lies: (options.reveal.lies ?? []).map((lie) => ({
|
||||
...lie,
|
||||
created_at: lie.created_at ?? '2026-01-01T00:00:05Z',
|
||||
})),
|
||||
guesses: (options.reveal.guesses ?? []).map((guess) => ({
|
||||
...guess,
|
||||
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,
|
||||
round_number: 1,
|
||||
players_count: 2,
|
||||
constraints: {
|
||||
@@ -47,14 +171,18 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
|
||||
min_players_reached: true,
|
||||
max_players_allowed: true,
|
||||
},
|
||||
readiness: {
|
||||
question_ready: (options?.currentPhase ?? status) !== 'lobby',
|
||||
scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard',
|
||||
},
|
||||
host: {
|
||||
can_start_round: status === 'lobby',
|
||||
can_show_question: status === 'lie',
|
||||
can_mix_answers: status === 'lie',
|
||||
can_calculate_scores: status === 'guess',
|
||||
can_reveal_scoreboard: status === 'reveal',
|
||||
can_start_next_round: status === 'scoreboard',
|
||||
can_finish_game: status === 'scoreboard',
|
||||
can_start_round: (options?.currentPhase ?? status) === 'lobby',
|
||||
can_show_question: (options?.currentPhase ?? status) === 'lie',
|
||||
can_mix_answers: (options?.currentPhase ?? status) === 'lie' || (options?.currentPhase ?? status) === 'guess',
|
||||
can_calculate_scores: (options?.currentPhase ?? status) === 'guess',
|
||||
can_reveal_scoreboard: (options?.currentPhase ?? status) === 'reveal',
|
||||
can_start_next_round: (options?.currentPhase ?? status) === 'scoreboard',
|
||||
can_finish_game: (options?.currentPhase ?? status) === 'scoreboard',
|
||||
},
|
||||
player: {
|
||||
can_join: status === 'lobby',
|
||||
@@ -68,6 +196,8 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
|
||||
|
||||
describe('HostShellComponent gameplay wiring', () => {
|
||||
afterEach(() => {
|
||||
HostRealtimeSocketMock.instances.length = 0;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -101,54 +231,471 @@ describe('HostShellComponent gameplay wiring', () => {
|
||||
expect(component.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('captures scoreboard error for retry path', async () => {
|
||||
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' }));
|
||||
it('hydrates canonical reveal payload in reveal phase', async () => {
|
||||
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse(
|
||||
200,
|
||||
sessionDetailPayload('reveal', {
|
||||
roundQuestionId: 77,
|
||||
reveal: {
|
||||
correct_answer: 'Mercury',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const component = new HostShellComponent();
|
||||
component.sessionCode = 'ABCD12';
|
||||
|
||||
await component.loadScoreboard();
|
||||
await component.refreshSession();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/lobby/sessions/ABCD12/scoreboard', expect.objectContaining({ method: 'GET' }));
|
||||
expect(component.scoreboardError).toContain('Scoreboard failed: Scoreboard unavailable');
|
||||
expect(component.loading).toBe(false);
|
||||
expect(component.session?.reveal?.correct_answer).toBe('Mercury');
|
||||
expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 2, nickname: 'Mads', text: 'Venus' });
|
||||
expect(component.session?.reveal?.guesses[0]).toMatchObject({
|
||||
player_id: 3,
|
||||
nickname: 'Luna',
|
||||
selected_text: 'Venus',
|
||||
fooled_player_id: 2,
|
||||
fooled_player_nickname: 'Mads',
|
||||
});
|
||||
});
|
||||
|
||||
it('wires showQuestion, mixAnswers and calculateScores with expected request payloads', async () => {
|
||||
const fetchMock: FetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
round_question: {
|
||||
id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Q?',
|
||||
shown_at: '2026-01-01T00:00:00Z',
|
||||
lie_deadline_at: '2026-01-01T00:00:45Z',
|
||||
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,
|
||||
},
|
||||
config: { lie_seconds: 45 },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 77 })))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
|
||||
round_question: { id: 77, round_number: 1 },
|
||||
answers: [{ text: 'A' }],
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(200, {
|
||||
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
|
||||
round_question: { id: 77, round_number: 1 },
|
||||
events_created: 2,
|
||||
leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }],
|
||||
);
|
||||
|
||||
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',
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 })));
|
||||
);
|
||||
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) => {
|
||||
const url = String(input);
|
||||
const method = init?.method ?? 'GET';
|
||||
|
||||
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/show') {
|
||||
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
|
||||
}
|
||||
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/99/answers/mix') {
|
||||
return jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 2 } });
|
||||
}
|
||||
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
|
||||
return jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 2 } });
|
||||
}
|
||||
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
|
||||
refreshCount += 1;
|
||||
if (refreshCount === 1) {
|
||||
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
|
||||
}
|
||||
if (refreshCount === 2) {
|
||||
return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }));
|
||||
}
|
||||
return jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }));
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
@@ -156,19 +703,131 @@ describe('HostShellComponent gameplay wiring', () => {
|
||||
component.sessionCode = ' abcd12 ';
|
||||
component.roundQuestionId = ' 77 ';
|
||||
|
||||
component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any;
|
||||
await component.showQuestion();
|
||||
expect(component.session?.session.status).toBe('lie');
|
||||
expect(component.roundQuestionId).toBe('99');
|
||||
|
||||
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
|
||||
await component.mixAnswers();
|
||||
expect(component.session?.session.status).toBe('guess');
|
||||
|
||||
await component.calculateScores();
|
||||
|
||||
expect(component.session?.session.status).toBe('reveal');
|
||||
expect(component.error).toBe('');
|
||||
expect(component.loading).toBe(false);
|
||||
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: FetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } }))
|
||||
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null })));
|
||||
const fetchMock = createFetchRouteMock((input, init) => {
|
||||
const url = String(input);
|
||||
const method = init?.method ?? 'GET';
|
||||
|
||||
if (method === 'POST' && url === '/lobby/sessions/ABCD12/rounds/next') {
|
||||
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
|
||||
}
|
||||
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
|
||||
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
@@ -177,6 +836,7 @@ describe('HostShellComponent gameplay wiring', () => {
|
||||
component.scoreboardPayload = '{"leaderboard":[]}';
|
||||
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ;
|
||||
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
|
||||
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
||||
|
||||
await component.startNextRound();
|
||||
|
||||
@@ -186,8 +846,8 @@ describe('HostShellComponent gameplay wiring', () => {
|
||||
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/lobby/sessions/ABCD12', expect.objectContaining({ method: 'GET' }));
|
||||
expect(component.session?.session.status).toBe('lobby');
|
||||
expect(component.scoreboardPayload).toBe('');
|
||||
expect(component.session?.session.status).toBe('lie');
|
||||
expect(component.roundQuestionId).toBe('99');
|
||||
expect(component.finalLeaderboardPayload).toBe('');
|
||||
expect(component.finalLeaderboard).toEqual([]);
|
||||
expect(component.nextRoundError).toBe('');
|
||||
@@ -213,6 +873,7 @@ describe('HostShellComponent gameplay wiring', () => {
|
||||
|
||||
const component = new HostShellComponent();
|
||||
component.sessionCode = 'ABCD12';
|
||||
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
||||
|
||||
await component.finishGame();
|
||||
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
|
||||
@@ -236,6 +897,7 @@ describe('HostShellComponent gameplay wiring', () => {
|
||||
|
||||
const component = new HostShellComponent();
|
||||
component.sessionCode = ' ';
|
||||
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
||||
|
||||
await component.startNextRound();
|
||||
await component.finishGame();
|
||||
@@ -245,6 +907,77 @@ describe('HostShellComponent gameplay wiring', () => {
|
||||
expect(component.finishError).toContain('Session code is required');
|
||||
});
|
||||
|
||||
it('blocks illegal host actions outside canonical phase permissions', async () => {
|
||||
const fetchMock: FetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const component = new HostShellComponent();
|
||||
component.sessionCode = 'ABCD12';
|
||||
component.roundQuestionId = '77';
|
||||
|
||||
for (const status of ['guess', 'reveal', 'scoreboard'] as const) {
|
||||
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
|
||||
await component.showQuestion();
|
||||
}
|
||||
|
||||
for (const status of ['lie', 'reveal', 'scoreboard'] as const) {
|
||||
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
|
||||
await component.calculateScores();
|
||||
}
|
||||
|
||||
for (const status of ['lie', 'guess', 'scoreboard'] as const) {
|
||||
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
|
||||
await component.loadScoreboard();
|
||||
}
|
||||
|
||||
for (const status of ['lie', 'guess', 'reveal'] as const) {
|
||||
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
|
||||
await component.startNextRound();
|
||||
await component.finishGame();
|
||||
}
|
||||
|
||||
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
|
||||
expect(component.canShowQuestion).toBe(false);
|
||||
|
||||
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
|
||||
expect(component.canCalculateScores).toBe(false);
|
||||
expect(component.canLoadScoreboard).toBe(true);
|
||||
expect(component.canStartNextRound).toBe(false);
|
||||
expect(component.canFinishGame).toBe(false);
|
||||
|
||||
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
||||
expect(component.canLoadScoreboard).toBe(false);
|
||||
expect(component.canStartNextRound).toBe(true);
|
||||
expect(component.canFinishGame).toBe(true);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prefers canonical current_phase for reveal panel and host routing when status lags behind', async () => {
|
||||
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
|
||||
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'scoreboard', roundQuestionId: 77, reveal: { correct_answer: 'Mercury' } }))
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const replaceState = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
location: { hash: '#/host/reveal/ABCD12' },
|
||||
history: { state: null, replaceState },
|
||||
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn() },
|
||||
});
|
||||
|
||||
const component = new HostShellComponent();
|
||||
component.sessionCode = 'ABCD12';
|
||||
|
||||
await component.refreshSession();
|
||||
|
||||
expect(component.gameplayPhase).toBe('scoreboard');
|
||||
expect(component.showRevealPanel).toBe(true);
|
||||
expect(component.canLoadScoreboard).toBe(false);
|
||||
expect(component.canStartNextRound).toBe(true);
|
||||
expect(component.canFinishGame).toBe(true);
|
||||
expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/scoreboard/ABCD12');
|
||||
});
|
||||
|
||||
it('syncs host hash-route with latest phase after refresh without page reload', async () => {
|
||||
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
@@ -262,5 +995,32 @@ describe('HostShellComponent gameplay wiring', () => {
|
||||
await component.refreshSession();
|
||||
|
||||
expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/guess/ABCD12');
|
||||
expect(component.canStartRound).toBe(false);
|
||||
expect(component.canStartNextRound).toBe(false);
|
||||
expect(component.canFinishGame).toBe(false);
|
||||
});
|
||||
|
||||
it('uses phase_view_model to keep host action surface bound to round boundaries only', async () => {
|
||||
const component = new HostShellComponent();
|
||||
|
||||
expect(component.canStartRound).toBe(true);
|
||||
expect(component.canStartNextRound).toBe(false);
|
||||
expect(component.canFinishGame).toBe(false);
|
||||
|
||||
component.session = sessionDetailPayload('lie') as any;
|
||||
expect(component.canStartRound).toBe(false);
|
||||
expect(component.canShowQuestion).toBe(true);
|
||||
expect(component.canStartNextRound).toBe(false);
|
||||
expect(component.canFinishGame).toBe(false);
|
||||
|
||||
component.session = sessionDetailPayload('reveal') as any;
|
||||
expect(component.canLoadScoreboard).toBe(true);
|
||||
expect(component.canStartNextRound).toBe(false);
|
||||
expect(component.canFinishGame).toBe(false);
|
||||
|
||||
component.session = sessionDetailPayload('scoreboard') as any;
|
||||
expect(component.canLoadScoreboard).toBe(false);
|
||||
expect(component.canStartNextRound).toBe(true);
|
||||
expect(component.canFinishGame).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,41 +4,58 @@ import { HostShellComponent } from './features/host/host-shell.component';
|
||||
import { PlayerShellComponent } from './features/player/player-shell.component';
|
||||
import { setPreferredLocale } from './lobby-i18n';
|
||||
|
||||
function stubShellGlobals(initialLocale: string) {
|
||||
vi.stubGlobal('window', {
|
||||
location: { hash: '', search: '' },
|
||||
history: { state: null, replaceState: vi.fn() },
|
||||
localStorage: { getItem: vi.fn().mockReturnValue(initialLocale), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('navigator', { language: `${initialLocale}-US`, onLine: true });
|
||||
}
|
||||
|
||||
describe('i18n MVP flow smoke (host/player + audio policy)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('resolves host/player copy in en and da from shared catalog', () => {
|
||||
vi.stubGlobal('window', {
|
||||
location: { hash: '', search: '' },
|
||||
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(),
|
||||
});
|
||||
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
|
||||
it.each([
|
||||
{
|
||||
locale: 'en',
|
||||
hostRefresh: 'Refresh',
|
||||
hostStartRound: 'Start round',
|
||||
playerSubmitGuess: 'Submit guess',
|
||||
},
|
||||
{
|
||||
locale: 'da',
|
||||
hostRefresh: 'Opdatér',
|
||||
hostStartRound: 'Start runde',
|
||||
playerSubmitGuess: 'Send gæt',
|
||||
},
|
||||
])('resolves one host/player locale run for $locale', ({ locale, hostRefresh, hostStartRound, playerSubmitGuess }) => {
|
||||
stubShellGlobals(locale);
|
||||
|
||||
const host = new HostShellComponent();
|
||||
const player = new PlayerShellComponent();
|
||||
host.ngOnInit();
|
||||
player.ngOnInit();
|
||||
setPreferredLocale(locale);
|
||||
|
||||
expect(host.copy('host.start_round')).toBe('Start round');
|
||||
expect(player.copy('player.submit_guess')).toBe('Submit guess');
|
||||
|
||||
setPreferredLocale('da');
|
||||
|
||||
expect(host.copy('host.start_round')).toBe('Start runde');
|
||||
expect(player.copy('player.submit_guess')).toBe('Send gæt');
|
||||
expect(host.copy('common.refresh')).toBe(hostRefresh);
|
||||
expect(host.copy('game.host.start_round')).toBe(hostStartRound);
|
||||
expect(player.copy('game.player.submit_guess')).toBe(playerSubmitGuess);
|
||||
|
||||
player.ngOnDestroy();
|
||||
host.ngOnDestroy();
|
||||
});
|
||||
|
||||
it('keeps audio routing policy primary-only (client has no audio output)', () => {
|
||||
it('keeps audio routing primary-only by guarding player playback without muting the host path', async () => {
|
||||
const originalPlay = vi.fn().mockRejectedValue(new Error('primary host playback'));
|
||||
const mediaPrototype = { play: originalPlay };
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
location: { hash: '', search: '' },
|
||||
history: { state: null, replaceState: vi.fn() },
|
||||
@@ -46,16 +63,25 @@ describe('i18n MVP flow smoke (host/player + audio policy)', () => {
|
||||
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
HTMLMediaElement: { prototype: mediaPrototype },
|
||||
});
|
||||
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
|
||||
vi.stubGlobal('document', { querySelectorAll: vi.fn().mockReturnValue([]) });
|
||||
|
||||
const host = new HostShellComponent();
|
||||
const player = new PlayerShellComponent();
|
||||
host.ngOnInit();
|
||||
|
||||
expect(host.clientHasNoAudioOutput).toBe(true);
|
||||
expect(player.clientHasNoAudioOutput).toBe(true);
|
||||
await expect(mediaPrototype.play()).rejects.toThrow('primary host playback');
|
||||
|
||||
const player = new PlayerShellComponent();
|
||||
player.ngOnInit();
|
||||
|
||||
await expect(mediaPrototype.play()).resolves.toBeUndefined();
|
||||
|
||||
player.ngOnDestroy();
|
||||
|
||||
await expect(mediaPrototype.play()).rejects.toThrow('primary host playback');
|
||||
|
||||
host.ngOnDestroy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,22 @@ describe('lobby i18n locale propagation', () => {
|
||||
expect(updates).toEqual(['en', 'da']);
|
||||
});
|
||||
|
||||
it('prefers backend-provided shell locale over browser defaults', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
location: { search: '' },
|
||||
localStorage: storageMock(),
|
||||
});
|
||||
vi.stubGlobal('document', {
|
||||
body: { dataset: { wppLocale: 'da' } },
|
||||
querySelector: vi.fn(() => null),
|
||||
});
|
||||
vi.stubGlobal('navigator', { language: 'en-US' });
|
||||
|
||||
const i18n = await import('./lobby-i18n');
|
||||
|
||||
expect(i18n.resolvePreferredLocale()).toBe('da');
|
||||
});
|
||||
|
||||
it('falls back to default en translation when da key is intentionally missing', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
location: { search: '' },
|
||||
@@ -77,6 +93,38 @@ describe('lobby i18n locale propagation', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves baseline shell/game keys from shared namespaces', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
location: { search: '' },
|
||||
localStorage: storageMock({ 'wpp.locale': 'da' }),
|
||||
});
|
||||
vi.stubGlobal('navigator', { language: 'da-DK' });
|
||||
|
||||
const i18n = await import('./lobby-i18n');
|
||||
|
||||
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',
|
||||
'game.host.start_round',
|
||||
'game.player.title',
|
||||
'game.player.submit_guess',
|
||||
] as const;
|
||||
|
||||
for (const key of baselineKeys) {
|
||||
const value = i18n.t(key, 'da');
|
||||
expect(value).toBeTypeOf('string');
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
expect(value).not.toBe(key);
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes primary-only audio routing policy to clients', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
location: { search: '' },
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import lobbyCatalog from '../../../../shared/i18n/lobby.json';
|
||||
|
||||
type SupportedLocale = (typeof lobbyCatalog.locales.supported)[number];
|
||||
|
||||
const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale;
|
||||
const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[];
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LOBBY_I18N_CATALOG,
|
||||
normalizeLocale,
|
||||
type SupportedLocale,
|
||||
translateCatalogPath,
|
||||
} from '../../../shared/i18n/lobby-loader';
|
||||
|
||||
let activeLocale: SupportedLocale | null = null;
|
||||
const localeSubscribers = new Set<(locale: SupportedLocale) => void>();
|
||||
|
||||
export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
|
||||
const locale = (rawLocale ?? '').trim().toLowerCase();
|
||||
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
|
||||
return locale as SupportedLocale;
|
||||
}
|
||||
|
||||
const shortLocale = locale.split('-')[0] ?? '';
|
||||
if ((SUPPORTED_LOCALES as readonly string[]).includes(shortLocale)) {
|
||||
return shortLocale as SupportedLocale;
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
export { normalizeLocale };
|
||||
|
||||
export function resolvePreferredLocale(): SupportedLocale {
|
||||
if (activeLocale) {
|
||||
@@ -32,11 +21,14 @@ export function resolvePreferredLocale(): SupportedLocale {
|
||||
return activeLocale;
|
||||
}
|
||||
|
||||
const rootLocale =
|
||||
typeof document !== 'undefined' ? document.querySelector<HTMLElement>('app-root')?.dataset?.['wppLocale'] : null;
|
||||
const shellLocale = typeof document !== 'undefined' ? document.body?.dataset?.['wppLocale'] : null;
|
||||
const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang');
|
||||
const storedLocale = window.localStorage?.getItem?.('wpp.locale');
|
||||
const browserLocale = typeof navigator !== 'undefined' ? navigator.language : '';
|
||||
|
||||
activeLocale = normalizeLocale(queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE);
|
||||
activeLocale = normalizeLocale(rootLocale || shellLocale || queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE);
|
||||
return activeLocale;
|
||||
}
|
||||
|
||||
@@ -62,25 +54,21 @@ export function subscribeToLocaleChanges(callback: (locale: SupportedLocale) =>
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCatalogPath(key: string): string {
|
||||
if (key.startsWith('lobby.shell.')) {
|
||||
return key.replace(/^lobby\.shell\./, 'app.');
|
||||
}
|
||||
if (key.startsWith('game.host.')) {
|
||||
return key.replace(/^game\.host\./, 'host.');
|
||||
}
|
||||
if (key.startsWith('game.player.')) {
|
||||
return key.replace(/^game\.player\./, 'player.');
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export function t(key: string, locale: string): string {
|
||||
const normalizedLocale = normalizeLocale(locale);
|
||||
const fallbackLocale = DEFAULT_LOCALE;
|
||||
const segments = key.split('.');
|
||||
|
||||
let cursor: unknown = lobbyCatalog.frontend.ui;
|
||||
for (const segment of segments) {
|
||||
if (!cursor || typeof cursor !== 'object' || !(segment in (cursor as Record<string, unknown>))) {
|
||||
return key;
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[segment];
|
||||
return translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, resolveCatalogPath(key), locale);
|
||||
}
|
||||
|
||||
if (!cursor || typeof cursor !== 'object') {
|
||||
return key;
|
||||
}
|
||||
|
||||
const translations = cursor as Record<string, string>;
|
||||
return translations[normalizedLocale] ?? translations[fallbackLocale] ?? key;
|
||||
}
|
||||
|
||||
export const clientHasNoAudioOutput = Boolean(lobbyCatalog.frontend.capabilities.client_has_no_audio_output);
|
||||
export const clientHasNoAudioOutput = Boolean(LOBBY_I18N_CATALOG.frontend.capabilities.client_has_no_audio_output);
|
||||
|
||||
363
frontend/angular/src/app/realtime-visual-smoke.spec.ts
Normal file
363
frontend/angular/src/app/realtime-visual-smoke.spec.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SessionDetailResponse } from '../../../src/api/types';
|
||||
import { HostShellComponent } from './features/host/host-shell.component';
|
||||
import { PlayerShellComponent } from './features/player/player-shell.component';
|
||||
import { setPreferredLocale } from './lobby-i18n';
|
||||
|
||||
type ViewerRole = 'host' | 'player';
|
||||
type SessionSeedPlayer = Pick<SessionDetailResponse['players'][number], 'id' | 'nickname' | 'score'> & {
|
||||
is_connected?: boolean;
|
||||
identity?: SessionDetailResponse['players'][number]['identity'];
|
||||
};
|
||||
type SessionSeed = {
|
||||
viewerRole: ViewerRole;
|
||||
status: string;
|
||||
currentPhase?: string;
|
||||
prompt?: string | null;
|
||||
answers?: string[];
|
||||
phaseDisplay?: SessionDetailResponse['phase_display'];
|
||||
players?: SessionSeedPlayer[];
|
||||
playerPermissions?: Partial<SessionDetailResponse['phase_view_model']['player']>;
|
||||
roundQuestionId?: number | null;
|
||||
};
|
||||
|
||||
class SharedRealtimeSocketMock {
|
||||
static instances: SharedRealtimeSocketMock[] = [];
|
||||
|
||||
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null;
|
||||
onerror: ((event: unknown) => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
|
||||
readonly close = vi.fn();
|
||||
|
||||
constructor(readonly url: string) {
|
||||
SharedRealtimeSocketMock.instances.push(this);
|
||||
}
|
||||
|
||||
emitClose(event: { code?: number; reason?: string; wasClean?: boolean } = {}): void {
|
||||
this.onclose?.(event);
|
||||
}
|
||||
|
||||
emitMessage(payload: unknown): void {
|
||||
this.onmessage?.({ data: JSON.stringify(payload) });
|
||||
}
|
||||
|
||||
emitOpen(): void {
|
||||
this.onopen?.();
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function buildSessionDetail(seed: SessionSeed): SessionDetailResponse {
|
||||
const phase = seed.currentPhase ?? seed.status;
|
||||
const players = (seed.players ?? [
|
||||
{ id: 1, nickname: 'Host', score: 0, is_connected: true },
|
||||
{ id: 9, nickname: 'Luna', score: 120, is_connected: true },
|
||||
{ id: 10, nickname: 'Mads', score: 80, is_connected: true },
|
||||
]).map((player) => ({
|
||||
...player,
|
||||
is_connected: player.is_connected ?? true,
|
||||
}));
|
||||
const roundQuestionId = seed.roundQuestionId ?? 41;
|
||||
|
||||
return {
|
||||
session: {
|
||||
code: 'ABCD12',
|
||||
status: seed.status,
|
||||
host_id: seed.viewerRole === 'host' ? 1 : null,
|
||||
current_round: 1,
|
||||
players_count: players.length,
|
||||
},
|
||||
viewer_role: seed.viewerRole,
|
||||
players,
|
||||
round_question:
|
||||
roundQuestionId === null
|
||||
? null
|
||||
: {
|
||||
id: roundQuestionId,
|
||||
round_number: 1,
|
||||
prompt: seed.prompt === undefined ? 'Which planet is closest to the sun?' : seed.prompt,
|
||||
shown_at: '2026-03-23T11:24:02Z',
|
||||
answers: (seed.answers ?? []).map((text) => ({ text })),
|
||||
},
|
||||
reveal: null,
|
||||
voice_cues: null,
|
||||
phase_display: seed.phaseDisplay ?? null,
|
||||
phase_view_model: {
|
||||
status: seed.status,
|
||||
current_phase: phase,
|
||||
round_number: 1,
|
||||
players_count: players.length,
|
||||
constraints: {
|
||||
min_players_to_start: 2,
|
||||
max_players_mvp: 8,
|
||||
min_players_reached: true,
|
||||
max_players_allowed: true,
|
||||
},
|
||||
readiness: {
|
||||
question_ready: phase !== 'lobby',
|
||||
scoreboard_ready: phase === 'reveal' || phase === 'scoreboard',
|
||||
},
|
||||
host: {
|
||||
can_start_round: phase === 'lobby',
|
||||
can_show_question: phase === 'lie',
|
||||
can_mix_answers: phase === 'lie' || phase === 'guess',
|
||||
can_calculate_scores: phase === 'guess',
|
||||
can_reveal_scoreboard: phase === 'reveal',
|
||||
can_start_next_round: phase === 'scoreboard',
|
||||
can_finish_game: phase === 'scoreboard',
|
||||
},
|
||||
player: {
|
||||
can_join: seed.playerPermissions?.can_join ?? phase === 'lobby',
|
||||
can_submit_lie: seed.playerPermissions?.can_submit_lie ?? phase === 'lie',
|
||||
can_submit_guess: seed.playerPermissions?.can_submit_guess ?? phase === 'guess',
|
||||
can_view_final_result: seed.playerPermissions?.can_view_final_result ?? phase === 'finished',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stubShellGlobals(): void {
|
||||
vi.stubGlobal('window', {
|
||||
location: { hash: '', search: '', host: 'localhost:4200', protocol: 'http:' },
|
||||
history: { state: null, replaceState: vi.fn() },
|
||||
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
speechSynthesis: { speak: vi.fn(), cancel: vi.fn() },
|
||||
});
|
||||
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
|
||||
}
|
||||
|
||||
function latestSocket(search: string): SharedRealtimeSocketMock {
|
||||
const socket = [...SharedRealtimeSocketMock.instances].reverse().find((candidate) => candidate.url.includes(search));
|
||||
expect(socket).toBeDefined();
|
||||
return socket as SharedRealtimeSocketMock;
|
||||
}
|
||||
|
||||
describe('realtime visual smoke (host/player resilience + visibility)', () => {
|
||||
afterEach(() => {
|
||||
SharedRealtimeSocketMock.instances.length = 0;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps lie prompts presenter-only while host and player hydrate the same session', async () => {
|
||||
stubShellGlobals();
|
||||
setPreferredLocale('en');
|
||||
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/lobby/sessions/ABCD12') {
|
||||
return Promise.resolve(
|
||||
jsonResponse(
|
||||
200,
|
||||
buildSessionDetail({
|
||||
viewerRole: 'host',
|
||||
status: 'lie',
|
||||
prompt: 'Which planet is closest to the sun?',
|
||||
players: [
|
||||
{ id: 1, nickname: 'Host', score: 0, identity: { token: 'H1', tone: 'ember', icon: 'spark' } },
|
||||
{ id: 9, nickname: 'Luna', score: 120, identity: { token: 'L2', tone: 'lagoon', icon: 'wave' } },
|
||||
{ id: 10, nickname: 'Mads', score: 80, identity: { token: 'M3', tone: 'gold', icon: 'comet' } },
|
||||
],
|
||||
phaseDisplay: {
|
||||
theme: 'host-spotlight',
|
||||
ornament: 'harbor-flare',
|
||||
title_key: 'host.presenter_scene_title',
|
||||
body_key: 'host.presenter_scene_body_lie',
|
||||
cue_label_key: 'host.presenter_scene_cue_mix_label',
|
||||
cue_body_key: 'host.presenter_scene_cue_mix_body',
|
||||
},
|
||||
playerPermissions: { can_join: false, can_submit_lie: true },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (url === '/lobby/sessions/ABCD12?session_token=tok-9') {
|
||||
return Promise.resolve(
|
||||
jsonResponse(
|
||||
200,
|
||||
buildSessionDetail({
|
||||
viewerRole: 'player',
|
||||
status: 'lie',
|
||||
prompt: null,
|
||||
players: [
|
||||
{ id: 1, nickname: 'Host', score: 0, identity: { token: 'H1', tone: 'ember', icon: 'spark' } },
|
||||
{ id: 9, nickname: 'Luna', score: 120, identity: { token: 'L2', tone: 'lagoon', icon: 'wave' } },
|
||||
{ id: 10, nickname: 'Mads', score: 80, identity: { token: 'M3', tone: 'gold', icon: 'comet' } },
|
||||
],
|
||||
phaseDisplay: {
|
||||
theme: 'player-ink',
|
||||
ornament: 'harbor-flare',
|
||||
title_key: 'player.submit_lie',
|
||||
body_key: 'player.phase_summary_lie',
|
||||
cue_label_key: 'player.active_scene_cue_lie_label',
|
||||
cue_body_key: 'player.active_scene_cue_lie_body',
|
||||
},
|
||||
playerPermissions: { can_join: false, can_submit_lie: true },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
throw new Error(`Unhandled fetch in realtime visual smoke: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('WebSocket', SharedRealtimeSocketMock as unknown as typeof WebSocket);
|
||||
|
||||
const host = new HostShellComponent();
|
||||
host.sessionCode = 'ABCD12';
|
||||
await host.refreshSession();
|
||||
latestSocket('?role=host').emitOpen();
|
||||
|
||||
const player = new PlayerShellComponent();
|
||||
player.sessionCode = 'ABCD12';
|
||||
player.playerId = 9;
|
||||
player.sessionToken = 'tok-9';
|
||||
await player.refreshSession();
|
||||
latestSocket('session_token=tok-9').emitOpen();
|
||||
|
||||
expect(host.showLiePresenterScene).toBe(true);
|
||||
expect(host.presenterSceneHeadline).toBe('Which planet is closest to the sun?');
|
||||
expect(host.presenterSceneTheme).toBe('host-spotlight');
|
||||
expect(host.presenterSceneOrnament).toBe('harbor-flare');
|
||||
expect(host.syncTransport).toBe('websocket');
|
||||
expect(host.presenterPlayers[1].badge).toBe('L2');
|
||||
expect(host.presenterPlayers[1].tone).toBe('lagoon');
|
||||
expect(host.presenterPlayers[1].icon).toBe('wave');
|
||||
|
||||
expect(player.showLieControls).toBe(true);
|
||||
expect(player.currentPrompt).toBe('');
|
||||
expect(player.activeSceneHeadline).toBe(player.copy('player.round_prompt_waiting'));
|
||||
expect(player.activeSceneTheme).toBe('player-ink');
|
||||
expect(player.activeSceneOrnament).toBe('harbor-flare');
|
||||
expect(player.syncTransport).toBe('websocket');
|
||||
expect(player.playerIdentityToken(9, 'Luna', 1)).toBe('L2');
|
||||
expect(player.playerTone(9, 'Luna', 1)).toBe('lagoon');
|
||||
expect(player.playerIcon(9, 'Luna', 1)).toBe('wave');
|
||||
expect(player.activeSceneOrnament).toBe(host.presenterSceneOrnament);
|
||||
|
||||
player.ngOnDestroy();
|
||||
host.ngOnDestroy();
|
||||
});
|
||||
|
||||
it('recovers shared host/player realtime sync before polling fallback fires', async () => {
|
||||
vi.useFakeTimers();
|
||||
stubShellGlobals();
|
||||
setPreferredLocale('en');
|
||||
|
||||
let hostFetchCount = 0;
|
||||
let playerFetchCount = 0;
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
if (url === '/lobby/sessions/ABCD12') {
|
||||
hostFetchCount += 1;
|
||||
return Promise.resolve(
|
||||
jsonResponse(
|
||||
200,
|
||||
buildSessionDetail({
|
||||
viewerRole: 'host',
|
||||
status: 'guess',
|
||||
prompt: 'Which planet is closest to the sun?',
|
||||
answers: ['Mercury', 'Venus', 'Mars'],
|
||||
playerPermissions: { can_join: false, can_submit_guess: true },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (url === '/lobby/sessions/ABCD12?session_token=tok-9') {
|
||||
playerFetchCount += 1;
|
||||
return Promise.resolve(
|
||||
jsonResponse(
|
||||
200,
|
||||
buildSessionDetail({
|
||||
viewerRole: 'player',
|
||||
status: playerFetchCount === 1 ? 'guess' : 'reveal',
|
||||
currentPhase: 'guess',
|
||||
prompt: 'Which planet is closest to the sun?',
|
||||
answers: ['Mercury', 'Venus', 'Mars', 'Earth'],
|
||||
playerPermissions: { can_join: false, can_submit_guess: true },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled fetch in realtime visual smoke: ${url}`);
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.stubGlobal('WebSocket', SharedRealtimeSocketMock as unknown as typeof WebSocket);
|
||||
|
||||
const host = new HostShellComponent();
|
||||
host.sessionCode = 'ABCD12';
|
||||
await host.refreshSession();
|
||||
const firstHostSocket = latestSocket('?role=host');
|
||||
firstHostSocket.emitOpen();
|
||||
|
||||
const player = new PlayerShellComponent();
|
||||
player.sessionCode = 'ABCD12';
|
||||
player.playerId = 9;
|
||||
player.sessionToken = 'tok-9';
|
||||
await player.refreshSession();
|
||||
player.selectedGuess = 'Venus';
|
||||
const firstPlayerSocket = latestSocket('session_token=tok-9');
|
||||
firstPlayerSocket.emitOpen();
|
||||
|
||||
firstHostSocket.emitClose({ code: 1006, wasClean: false });
|
||||
firstPlayerSocket.emitClose({ code: 1006, wasClean: false });
|
||||
|
||||
expect(host.syncTransport).toBe('polling');
|
||||
expect(player.syncTransport).toBe('polling');
|
||||
expect(player.showSyncStatusCard).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
|
||||
const recoveredHostSocket = latestSocket('?role=host');
|
||||
const recoveredPlayerSocket = latestSocket('session_token=tok-9');
|
||||
expect(recoveredHostSocket).not.toBe(firstHostSocket);
|
||||
expect(recoveredPlayerSocket).not.toBe(firstPlayerSocket);
|
||||
|
||||
recoveredHostSocket.emitOpen();
|
||||
recoveredPlayerSocket.emitOpen();
|
||||
|
||||
expect(host.syncTransport).toBe('websocket');
|
||||
expect(player.syncTransport).toBe('websocket');
|
||||
expect(player.showSyncStatusCard).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
expect(hostFetchCount).toBe(1);
|
||||
expect(playerFetchCount).toBe(1);
|
||||
expect(player.selectedGuess).toBe('Venus');
|
||||
|
||||
recoveredHostSocket.emitMessage({ type: 'phase.guess_snapshot' });
|
||||
recoveredPlayerSocket.emitMessage({ type: 'phase.guess_snapshot' });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(hostFetchCount).toBe(2);
|
||||
expect(playerFetchCount).toBe(2);
|
||||
});
|
||||
|
||||
expect(host.lastRealtimeEventType).toBe('phase.guess_snapshot');
|
||||
expect(player.lastRealtimeEventType).toBe('phase.guess_snapshot');
|
||||
expect(player.gameplayPhase).toBe('guess');
|
||||
expect(player.selectedGuess).toBe('Venus');
|
||||
|
||||
player.ngOnDestroy();
|
||||
host.ngOnDestroy();
|
||||
});
|
||||
});
|
||||
107
frontend/angular/src/app/session-realtime.spec.ts
Normal file
107
frontend/angular/src/app/session-realtime.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createSessionRealtimeClient, resolveSessionRealtimeUrl } from './session-realtime';
|
||||
|
||||
class FakeWebSocket {
|
||||
static instances: FakeWebSocket[] = [];
|
||||
|
||||
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null = null;
|
||||
onerror: ((event: unknown) => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onopen: (() => void) | null = null;
|
||||
|
||||
readonly close = vi.fn();
|
||||
|
||||
constructor(readonly url: string) {
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
emitOpen(): void {
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
emitClose(event: { code?: number; reason?: string; wasClean?: boolean } = {}): void {
|
||||
this.onclose?.(event);
|
||||
}
|
||||
|
||||
emitError(event: unknown = new Event('error')): void {
|
||||
this.onerror?.(event);
|
||||
}
|
||||
|
||||
emitMessage(payload: unknown): void {
|
||||
this.onmessage?.({ data: JSON.stringify(payload) });
|
||||
}
|
||||
}
|
||||
|
||||
describe('session realtime client', () => {
|
||||
afterEach(() => {
|
||||
FakeWebSocket.instances.length = 0;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('builds host and player websocket URLs from the current location', () => {
|
||||
expect(
|
||||
resolveSessionRealtimeUrl(
|
||||
{ protocol: 'http:', host: 'localhost:4200' },
|
||||
{ sessionCode: 'abcd12', role: { mode: 'host' } },
|
||||
),
|
||||
).toBe('ws://localhost:4200/ws/game/ABCD12/?role=host');
|
||||
|
||||
expect(
|
||||
resolveSessionRealtimeUrl(
|
||||
{ protocol: 'https:', host: 'party.example' },
|
||||
{ sessionCode: 'abcd12', role: { mode: 'player', sessionToken: 'tok-1' } },
|
||||
),
|
||||
).toBe('wss://party.example/ws/game/ABCD12/?session_token=tok-1');
|
||||
});
|
||||
|
||||
it('publishes websocket events and reconnects after unexpected disconnects', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const events: string[] = [];
|
||||
const states: string[] = [];
|
||||
const client = createSessionRealtimeClient({
|
||||
onEvent: (event) => {
|
||||
events.push(String(event.type));
|
||||
},
|
||||
onStatusChange: (status) => {
|
||||
states.push(status.connectionState);
|
||||
},
|
||||
webSocketFactory: (url) => new FakeWebSocket(url),
|
||||
windowLike: { location: { protocol: 'http:', host: 'localhost:4200' } as Location },
|
||||
});
|
||||
|
||||
client.updateTarget({ sessionCode: 'ABCD12', role: { mode: 'host' } });
|
||||
expect(FakeWebSocket.instances).toHaveLength(1);
|
||||
expect(FakeWebSocket.instances[0]?.url).toBe('ws://localhost:4200/ws/game/ABCD12/?role=host');
|
||||
|
||||
FakeWebSocket.instances[0]?.emitOpen();
|
||||
FakeWebSocket.instances[0]?.emitMessage({ type: 'phase.guess_started' });
|
||||
FakeWebSocket.instances[0]?.emitClose();
|
||||
|
||||
expect(events).toEqual(['phase.guess_started']);
|
||||
expect(states).toEqual(['connecting', 'connected', 'connected', 'reconnecting']);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1500);
|
||||
|
||||
expect(FakeWebSocket.instances).toHaveLength(2);
|
||||
FakeWebSocket.instances[1]?.emitOpen();
|
||||
expect(client.getStatus().connectionState).toBe('connected');
|
||||
});
|
||||
|
||||
it('reconfigures the socket when the player session token changes', () => {
|
||||
const client = createSessionRealtimeClient({
|
||||
onEvent: vi.fn(),
|
||||
webSocketFactory: (url) => new FakeWebSocket(url),
|
||||
windowLike: { location: { protocol: 'http:', host: 'localhost:4200' } as Location },
|
||||
});
|
||||
|
||||
client.updateTarget({ sessionCode: 'ABCD12', role: { mode: 'player', sessionToken: 'tok-1' } });
|
||||
const firstSocket = FakeWebSocket.instances[0];
|
||||
client.updateTarget({ sessionCode: 'ABCD12', role: { mode: 'player', sessionToken: 'tok-2' } });
|
||||
|
||||
expect(firstSocket?.close).toHaveBeenCalledWith(1000, 'reconfigure');
|
||||
expect(FakeWebSocket.instances[1]?.url).toBe('ws://localhost:4200/ws/game/ABCD12/?session_token=tok-2');
|
||||
});
|
||||
});
|
||||
264
frontend/angular/src/app/session-realtime.ts
Normal file
264
frontend/angular/src/app/session-realtime.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
export type SessionRealtimeRole =
|
||||
| { mode: 'host' }
|
||||
| { mode: 'player'; sessionToken: string };
|
||||
|
||||
export type SessionRealtimeEvent = {
|
||||
type?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type SessionRealtimeConnectionState = 'idle' | 'connecting' | 'connected' | 'reconnecting';
|
||||
|
||||
export type SessionRealtimeStatus = {
|
||||
connectionState: SessionRealtimeConnectionState;
|
||||
lastEventAt: number | null;
|
||||
lastEventType: string | null;
|
||||
reconnectAttempt: number;
|
||||
};
|
||||
|
||||
type TimeoutHandle = ReturnType<typeof setTimeout>;
|
||||
|
||||
type WebSocketLike = {
|
||||
close: (code?: number, reason?: string) => void;
|
||||
onclose: ((event: { code?: number; reason?: string; wasClean?: boolean }) => void) | null;
|
||||
onerror: ((event: unknown) => void) | null;
|
||||
onmessage: ((event: { data: string }) => void) | null;
|
||||
onopen: (() => void) | null;
|
||||
};
|
||||
|
||||
type SessionRealtimeTarget = {
|
||||
role: SessionRealtimeRole;
|
||||
sessionCode: string;
|
||||
};
|
||||
|
||||
type SessionRealtimeOptions = {
|
||||
clearTimeoutImpl?: (handle: TimeoutHandle) => void;
|
||||
onEvent: (event: SessionRealtimeEvent) => void;
|
||||
onStatusChange?: (status: SessionRealtimeStatus) => void;
|
||||
setTimeoutImpl?: (callback: () => void, delayMs: number) => TimeoutHandle;
|
||||
webSocketFactory?: ((url: string) => WebSocketLike) | null;
|
||||
windowLike?: Pick<Window, 'location'>;
|
||||
};
|
||||
|
||||
const DEFAULT_RECONNECT_DELAY_MS = 1500;
|
||||
const MAX_RECONNECT_DELAY_MS = 5000;
|
||||
|
||||
function resolveWindowLike(windowLike?: Pick<Window, 'location'>): Pick<Window, 'location'> | null {
|
||||
if (windowLike) {
|
||||
return windowLike;
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
function normalizeSessionCode(value: string): string {
|
||||
return value.trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function resolveSessionRealtimeUrl(
|
||||
location: Pick<Location, 'host' | 'protocol'>,
|
||||
target: SessionRealtimeTarget,
|
||||
): string {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = new URL(`${protocol}//${location.host}/ws/game/${encodeURIComponent(normalizeSessionCode(target.sessionCode))}/`);
|
||||
|
||||
if (target.role.mode === 'host') {
|
||||
url.searchParams.set('role', 'host');
|
||||
} else {
|
||||
url.searchParams.set('session_token', target.role.sessionToken);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function sameTarget(left: SessionRealtimeTarget | null, right: SessionRealtimeTarget | null): boolean {
|
||||
if (!left || !right) {
|
||||
return left === right;
|
||||
}
|
||||
|
||||
if (normalizeSessionCode(left.sessionCode) !== normalizeSessionCode(right.sessionCode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (left.role.mode !== right.role.mode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (left.role.mode === 'host' && right.role.mode === 'host') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (left.role.mode === 'player' && right.role.mode === 'player') {
|
||||
return left.role.sessionToken === right.role.sessionToken;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createSessionRealtimeClient(options: SessionRealtimeOptions) {
|
||||
const setTimeoutImpl = options.setTimeoutImpl ?? ((callback, delayMs) => setTimeout(callback, delayMs));
|
||||
const clearTimeoutImpl = options.clearTimeoutImpl ?? ((handle) => clearTimeout(handle));
|
||||
const webSocketFactory =
|
||||
options.webSocketFactory ??
|
||||
(typeof WebSocket === 'function' ? ((url: string) => new WebSocket(url) as unknown as WebSocketLike) : null);
|
||||
|
||||
let reconnectTimer: TimeoutHandle | null = null;
|
||||
let reconnectAttempt = 0;
|
||||
let socket: WebSocketLike | null = null;
|
||||
let target: SessionRealtimeTarget | null = null;
|
||||
let status: SessionRealtimeStatus = {
|
||||
connectionState: 'idle',
|
||||
lastEventAt: null,
|
||||
lastEventType: null,
|
||||
reconnectAttempt: 0,
|
||||
};
|
||||
|
||||
function publishStatus(next: Partial<SessionRealtimeStatus>): void {
|
||||
status = {
|
||||
...status,
|
||||
...next,
|
||||
reconnectAttempt,
|
||||
};
|
||||
options.onStatusChange?.(status);
|
||||
}
|
||||
|
||||
function clearReconnectTimer(): void {
|
||||
if (!reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeoutImpl(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
|
||||
function clearSocket(closeCode?: number, reason?: string): void {
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSocket = socket;
|
||||
socket = null;
|
||||
currentSocket.onopen = null;
|
||||
currentSocket.onmessage = null;
|
||||
currentSocket.onerror = null;
|
||||
currentSocket.onclose = null;
|
||||
currentSocket.close(closeCode, reason);
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (!target || reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
publishStatus({ connectionState: 'reconnecting' });
|
||||
const delayMs = Math.min(DEFAULT_RECONNECT_DELAY_MS * Math.max(1, reconnectAttempt + 1), MAX_RECONNECT_DELAY_MS);
|
||||
reconnectTimer = setTimeoutImpl(() => {
|
||||
reconnectTimer = null;
|
||||
reconnectAttempt += 1;
|
||||
connect();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function connect(): void {
|
||||
if (!target) {
|
||||
publishStatus({ connectionState: 'idle' });
|
||||
return;
|
||||
}
|
||||
|
||||
const windowLike = resolveWindowLike(options.windowLike);
|
||||
if (
|
||||
!windowLike ||
|
||||
typeof windowLike.location?.protocol !== 'string' ||
|
||||
typeof windowLike.location?.host !== 'string' ||
|
||||
!windowLike.location.host ||
|
||||
!webSocketFactory
|
||||
) {
|
||||
publishStatus({ connectionState: 'idle' });
|
||||
return;
|
||||
}
|
||||
|
||||
clearReconnectTimer();
|
||||
clearSocket(1000, 'reconnect');
|
||||
publishStatus({ connectionState: reconnectAttempt > 0 ? 'reconnecting' : 'connecting' });
|
||||
|
||||
const nextSocket = webSocketFactory(resolveSessionRealtimeUrl(windowLike.location, target));
|
||||
socket = nextSocket;
|
||||
|
||||
nextSocket.onopen = () => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectAttempt = 0;
|
||||
publishStatus({ connectionState: 'connected' });
|
||||
};
|
||||
|
||||
nextSocket.onmessage = (event) => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(event.data) as SessionRealtimeEvent;
|
||||
publishStatus({
|
||||
lastEventAt: Date.now(),
|
||||
lastEventType: typeof payload.type === 'string' ? payload.type : null,
|
||||
});
|
||||
options.onEvent(payload);
|
||||
} catch {
|
||||
// Ignore malformed websocket frames; the HTTP refresh path remains authoritative.
|
||||
}
|
||||
};
|
||||
|
||||
nextSocket.onerror = () => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
publishStatus({ connectionState: 'reconnecting' });
|
||||
};
|
||||
|
||||
nextSocket.onclose = () => {
|
||||
if (socket !== nextSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
disconnect(): void {
|
||||
target = null;
|
||||
reconnectAttempt = 0;
|
||||
clearReconnectTimer();
|
||||
clearSocket(1000, 'disconnect');
|
||||
publishStatus({ connectionState: 'idle' });
|
||||
},
|
||||
getStatus(): SessionRealtimeStatus {
|
||||
return status;
|
||||
},
|
||||
updateTarget(nextTarget: SessionRealtimeTarget | null): void {
|
||||
if (sameTarget(target, nextTarget)) {
|
||||
if (!nextTarget) {
|
||||
this.disconnect();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
target = nextTarget;
|
||||
reconnectAttempt = 0;
|
||||
clearReconnectTimer();
|
||||
clearSocket(1000, 'reconfigure');
|
||||
|
||||
if (!target) {
|
||||
publishStatus({ connectionState: 'idle' });
|
||||
return;
|
||||
}
|
||||
|
||||
connect();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -69,6 +69,7 @@ describe('session route context', () => {
|
||||
sessionCode: 'AB12',
|
||||
playerId: 5,
|
||||
token: 'tok-5',
|
||||
locale: 'en',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,7 +81,25 @@ describe('session route context', () => {
|
||||
sessionCode: 'AB12',
|
||||
playerId: null,
|
||||
token: null,
|
||||
locale: 'en',
|
||||
});
|
||||
expect(sessionStorage.setItem).toHaveBeenCalledWith('wpp.host-session-code', 'AB12');
|
||||
});
|
||||
|
||||
it('resolvers normalize and expose locale from lang query param', () => {
|
||||
setWindow(storageMock(), storageMock());
|
||||
|
||||
expect(hostRouteContextResolver(route({}, { lang: 'da-DK' }) as never, {} as never).locale).toBe('da');
|
||||
expect(playerRouteContextResolver(route({}, { lang: 'EN' }) as never, {} as never).locale).toBe('en');
|
||||
});
|
||||
|
||||
it('does not reset persisted preferred locale when lang query param is absent', () => {
|
||||
const localStorage = storageMock({ 'wpp.locale': 'da' });
|
||||
setWindow(localStorage, storageMock());
|
||||
|
||||
expect(hostRouteContextResolver(route({}, { lang: 'da' }) as never, {} as never).locale).toBe('da');
|
||||
expect(hostRouteContextResolver(route({}, {}) as never, {} as never).locale).toBe('da');
|
||||
expect(localStorage.setItem).toHaveBeenCalledTimes(1);
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith('wpp.locale', 'da');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,13 @@ import { inject } from '@angular/core';
|
||||
import { type ActivatedRouteSnapshot, type CanActivateFn, type ResolveFn, Router, type UrlTree } from '@angular/router';
|
||||
|
||||
import { createSessionContextStore } from '../../../src/spa/session-context-store';
|
||||
import { normalizeLocale, resolvePreferredLocale, setPreferredLocale } from './lobby-i18n';
|
||||
|
||||
export interface RouteSessionContext {
|
||||
sessionCode: string | null;
|
||||
playerId: number | null;
|
||||
token: string | null;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
const HOST_STORAGE_KEY = 'wpp.host-session-code';
|
||||
@@ -61,6 +63,17 @@ export function resolveSessionCode(route: ActivatedRouteSnapshot, mode: 'host' |
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveRouteLocale(route: ActivatedRouteSnapshot): string {
|
||||
const langParam = route.queryParamMap.get('lang');
|
||||
if (langParam !== null) {
|
||||
const locale = normalizeLocale(langParam);
|
||||
setPreferredLocale(locale);
|
||||
return locale;
|
||||
}
|
||||
|
||||
return resolvePreferredLocale();
|
||||
}
|
||||
|
||||
async function sessionExists(code: string): Promise<boolean> {
|
||||
const response = await fetch(`/lobby/sessions/${encodeURIComponent(code)}`, {
|
||||
method: 'GET',
|
||||
@@ -118,23 +131,26 @@ export const playerRouteGuard: CanActivateFn = (route) => guard('player', route)
|
||||
|
||||
export const hostRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => {
|
||||
const code = resolveSessionCode(route, 'host');
|
||||
const locale = resolveRouteLocale(route);
|
||||
if (code) {
|
||||
window.sessionStorage.setItem(HOST_STORAGE_KEY, code);
|
||||
}
|
||||
return { sessionCode: code, playerId: null, token: null };
|
||||
return { sessionCode: code, playerId: null, token: null, locale };
|
||||
};
|
||||
|
||||
export const playerRouteContextResolver: ResolveFn<RouteSessionContext> = (route) => {
|
||||
const code = resolveSessionCode(route, 'player');
|
||||
const locale = resolveRouteLocale(route);
|
||||
const context = createSessionContextStore(window.localStorage).get();
|
||||
|
||||
if (!code || !context || normalizeCode(context.sessionCode) !== code) {
|
||||
return { sessionCode: code, playerId: null, token: null };
|
||||
return { sessionCode: code, playerId: null, token: null, locale };
|
||||
}
|
||||
|
||||
return {
|
||||
sessionCode: code,
|
||||
playerId: Number.isInteger(context.playerId) && context.playerId > 0 ? context.playerId : null,
|
||||
token: context.token.trim() || null,
|
||||
locale,
|
||||
};
|
||||
};
|
||||
|
||||
67
frontend/angular/src/app/wpp-api-client.spec.ts
Normal file
67
frontend/angular/src/app/wpp-api-client.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createWppApiClient } from './wpp-api-client';
|
||||
|
||||
function jsonResponse(status: number, body: unknown) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
describe('WPP Angular API client skeleton', () => {
|
||||
it('normalizes host/player API calls through fetch transport', async () => {
|
||||
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(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,
|
||||
'/lobby/sessions/ABCD12',
|
||||
expect.objectContaining({ method: 'GET', credentials: 'same-origin' })
|
||||
);
|
||||
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',
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ code: 'ABCD12', nickname: 'Luna' }),
|
||||
})
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'/lobby/sessions/create',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
107
frontend/angular/src/app/wpp-api-client.ts
Normal file
107
frontend/angular/src/app/wpp-api-client.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
import {
|
||||
createAngularApiClient,
|
||||
type AngularApiClient,
|
||||
type AngularHttpClientLike,
|
||||
} from '../../../src/api/angular-client';
|
||||
|
||||
export const WPP_API_CLIENT = new InjectionToken<AngularApiClient>('WPP_API_CLIENT');
|
||||
|
||||
export interface FetchLike {
|
||||
(input: string, init?: RequestInit): Promise<Response>;
|
||||
}
|
||||
|
||||
export function createFetchHttpClient(fetchImpl: FetchLike): AngularHttpClientLike {
|
||||
function readCookie(name: string): string {
|
||||
if (typeof document === 'undefined' || typeof document.cookie !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const prefix = `${name}=`;
|
||||
for (const part of document.cookie.split(';')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return decodeURIComponent(trimmed.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function ensureCsrfToken(): Promise<string> {
|
||||
const existing = readCookie('csrftoken');
|
||||
if (existing || typeof document === 'undefined' || typeof window === 'undefined') {
|
||||
return existing;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchImpl('/lobby/csrf', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
||||
return readCookie('csrftoken');
|
||||
}
|
||||
|
||||
async function parsePayload(response: Response): Promise<unknown> {
|
||||
if (response.redirected && response.url.includes('/accounts/login')) {
|
||||
throw {
|
||||
status: 401,
|
||||
message: 'Login required',
|
||||
error: { redirect: response.url },
|
||||
};
|
||||
}
|
||||
|
||||
return response.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
return {
|
||||
async get<T>(url: string): Promise<T> {
|
||||
const response = await fetchImpl(url, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const payload = await parsePayload(response);
|
||||
if (!response.ok) {
|
||||
throw {
|
||||
status: response.status,
|
||||
message: (payload as { error?: string }).error ?? `HTTP ${response.status}`,
|
||||
error: payload,
|
||||
};
|
||||
}
|
||||
return payload as T;
|
||||
},
|
||||
async post<T>(url: string, body: unknown): Promise<T> {
|
||||
const csrfToken = await ensureCsrfToken();
|
||||
const response = await fetchImpl(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
const payload = await parsePayload(response);
|
||||
if (!response.ok) {
|
||||
throw {
|
||||
status: response.status,
|
||||
message: (payload as { error?: string }).error ?? `HTTP ${response.status}`,
|
||||
error: payload,
|
||||
};
|
||||
}
|
||||
return payload as T;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createWppApiClient(fetchImpl: FetchLike = fetch.bind(globalThis)): AngularApiClient {
|
||||
return createAngularApiClient(createFetchHttpClient(fetchImpl));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
134
frontend/package-lock.json
generated
134
frontend/package-lock.json
generated
@@ -7,12 +7,125 @@
|
||||
"": {
|
||||
"name": "wpp-frontend-api-client-baseline",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/compiler": "^19.2.0",
|
||||
"@angular/core": "^19.2.0",
|
||||
"@angular/forms": "^19.2.0",
|
||||
"@angular/platform-browser": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/common": {
|
||||
"version": "19.2.20",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.20.tgz",
|
||||
"integrity": "sha512-1M3W3FjUUbVKXDMs+yQpBhnkD/pCe0Jn79rPE5W+EGWWxFoLSyGX+fhnRO5m4c9k66p3nvYrikWQ0ZzMv3M5tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "19.2.20",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler": {
|
||||
"version": "19.2.20",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.20.tgz",
|
||||
"integrity": "sha512-LvjE8W58EACgTFaAoqmNe7FRsbvoQ0GvCB/rmm6AEMWx/0W/JBvWkQTrOQlwpoeYOHcMZRGdmPcZoUDwU3JySQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/core": {
|
||||
"version": "19.2.20",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.20.tgz",
|
||||
"integrity": "sha512-pxzQh8ouqfE57lJlXjIzXFuRETwkfMVwS+NFCfv2yh01Qtx+vymO8ZClcJMgLPfBYinhBYX+hrRYVSa1nzlkRQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rxjs": "^6.5.3 || ^7.4.0",
|
||||
"zone.js": "~0.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/forms": {
|
||||
"version": "19.2.20",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.20.tgz",
|
||||
"integrity": "sha512-agi7InbMzop1jrud6L7SlNwnZk3iNolORcFIwBQMvKxLkcJ+ttbSYuM0KAw56IundWHf4dL9GP4cSygm4kUeFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "19.2.20",
|
||||
"@angular/core": "19.2.20",
|
||||
"@angular/platform-browser": "19.2.20",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "19.2.20",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.20.tgz",
|
||||
"integrity": "sha512-O9ZoQKILPC1T2c64OASS75XlOLBxY81m5AAgsBKhwiFWq+V28RsO0cnwpi1YSh/z4ryH8Fe7IUFz8jGrsJi3hQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": "19.2.20",
|
||||
"@angular/common": "19.2.20",
|
||||
"@angular/core": "19.2.20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/animations": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/router": {
|
||||
"version": "19.2.20",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.20.tgz",
|
||||
"integrity": "sha512-y0fyKycxJHr82kxXKE50Vac5hPn5Kx3gw9CfqyEuwJ9VQzEixDljU+chrQK4Wods14jJn9Tt2ncNPGH1rLya3Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "19.2.20",
|
||||
"@angular/core": "19.2.20",
|
||||
"@angular/platform-browser": "19.2.20",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -1188,6 +1301,15 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
@@ -1263,6 +1385,12 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -1449,6 +1577,12 @@
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/zone.js": {
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
|
||||
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@
|
||||
"test": "vitest run",
|
||||
"build": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/compiler": "^19.2.0",
|
||||
"@angular/core": "^19.2.0",
|
||||
"@angular/forms": "^19.2.0",
|
||||
"@angular/platform-browser": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
"typescript": "^5.7.3",
|
||||
|
||||
74
frontend/shared/i18n/lobby-loader.ts
Normal file
74
frontend/shared/i18n/lobby-loader.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import lobbyCatalog from '../../../shared/i18n/lobby.json';
|
||||
|
||||
export type LobbyCatalog = typeof lobbyCatalog;
|
||||
export type SupportedLocale = LobbyCatalog['locales']['supported'][number];
|
||||
|
||||
export const LOBBY_I18N_CATALOG = lobbyCatalog;
|
||||
export const DEFAULT_LOCALE = lobbyCatalog.locales.default as SupportedLocale;
|
||||
export const SUPPORTED_LOCALES = lobbyCatalog.locales.supported as readonly SupportedLocale[];
|
||||
|
||||
export function normalizeLocale(rawLocale?: string | null): SupportedLocale {
|
||||
const locale = (rawLocale ?? '').trim().toLowerCase().replace(/_/g, '-');
|
||||
if ((SUPPORTED_LOCALES as readonly string[]).includes(locale)) {
|
||||
return locale as SupportedLocale;
|
||||
}
|
||||
|
||||
const shortLocale = locale.split('-')[0] ?? '';
|
||||
if ((SUPPORTED_LOCALES as readonly string[]).includes(shortLocale)) {
|
||||
return shortLocale as SupportedLocale;
|
||||
}
|
||||
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function translateCatalogPath(
|
||||
root: Record<string, unknown>,
|
||||
keyPath: string,
|
||||
locale: string,
|
||||
fallback = DEFAULT_LOCALE,
|
||||
): string {
|
||||
const normalizedLocale = normalizeLocale(locale);
|
||||
const segments = keyPath.split('.');
|
||||
|
||||
let cursor: unknown = root;
|
||||
for (const segment of segments) {
|
||||
if (!cursor || typeof cursor !== 'object' || !(segment in (cursor as Record<string, unknown>))) {
|
||||
return keyPath;
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[segment];
|
||||
}
|
||||
|
||||
if (!cursor || typeof cursor !== 'object') {
|
||||
return keyPath;
|
||||
}
|
||||
|
||||
const translations = cursor as Record<string, string>;
|
||||
return translations[normalizedLocale] ?? translations[fallback] ?? keyPath;
|
||||
}
|
||||
|
||||
export function collectLocaleParityIssues(
|
||||
node: unknown,
|
||||
locales: readonly string[] = SUPPORTED_LOCALES,
|
||||
path = '',
|
||||
): string[] {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const record = node as Record<string, unknown>;
|
||||
const keys = Object.keys(record);
|
||||
const isTranslationLeaf = keys.length > 0 && locales.every((locale) => locale in record);
|
||||
|
||||
if (isTranslationLeaf) {
|
||||
const issues: string[] = [];
|
||||
for (const locale of locales) {
|
||||
const value = record[locale];
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
issues.push(`${path || '<root>'} missing non-empty '${locale}' translation`);
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
return keys.flatMap((key) => collectLocaleParityIssues(record[key], locales, path ? `${path}.${key}` : key));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
mapCalculateScoresResponse,
|
||||
mapCreateSessionResponse,
|
||||
mapFinishGameResponse,
|
||||
mapHealthResponse,
|
||||
mapJoinSessionResponse,
|
||||
@@ -16,12 +17,14 @@ import type {
|
||||
ApiFailure,
|
||||
ApiResult,
|
||||
CalculateScoresResponse,
|
||||
CreateSessionResponse,
|
||||
FinishGameResponse,
|
||||
HealthResponse,
|
||||
JoinSessionRequest,
|
||||
JoinSessionResponse,
|
||||
MixAnswersResponse,
|
||||
ScoreboardResponse,
|
||||
SessionDetailRequestOptions,
|
||||
SessionDetailResponse,
|
||||
ShowQuestionResponse,
|
||||
StartNextRoundResponse,
|
||||
@@ -46,7 +49,8 @@ export interface AngularHttpClientLike {
|
||||
|
||||
export interface AngularApiClient {
|
||||
health(): Promise<ApiResult<HealthResponse>>;
|
||||
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
|
||||
createSession(): Promise<ApiResult<CreateSessionResponse>>;
|
||||
getSession(code: string, options?: SessionDetailRequestOptions): Promise<ApiResult<SessionDetailResponse>>;
|
||||
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
|
||||
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
|
||||
showQuestion(code: string): Promise<ApiResult<ShowQuestionResponse>>;
|
||||
@@ -96,6 +100,17 @@ function buildUrl(baseUrl: string, path: string): string {
|
||||
return `${normalizeBaseUrl(baseUrl)}${path}`;
|
||||
}
|
||||
|
||||
function buildSessionDetailPath(code: string, options?: SessionDetailRequestOptions): string {
|
||||
const path = `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`;
|
||||
const sessionToken = options?.session_token?.trim();
|
||||
if (!sessionToken) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ session_token: sessionToken });
|
||||
return `${path}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function wrap<T>(call: () => Promise<unknown>, mapper: (payload: unknown) => T): Promise<ApiResult<T>> {
|
||||
let payload: unknown;
|
||||
try {
|
||||
@@ -128,12 +143,14 @@ export function createAngularApiClient(http: AngularHttpClientLike, baseUrl = ''
|
||||
return {
|
||||
health: () =>
|
||||
wrap(() => http.get<HealthResponse>(buildUrl(baseUrl, '/healthz'), { withCredentials: true }), mapHealthResponse),
|
||||
getSession: (code: string) =>
|
||||
createSession: () =>
|
||||
wrap(
|
||||
() =>
|
||||
http.get<SessionDetailResponse>(buildUrl(baseUrl, `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`), {
|
||||
withCredentials: true
|
||||
}),
|
||||
() => http.post<CreateSessionResponse>(buildUrl(baseUrl, '/lobby/sessions/create'), {}, { withCredentials: true }),
|
||||
mapCreateSessionResponse
|
||||
),
|
||||
getSession: (code: string, options?: SessionDetailRequestOptions) =>
|
||||
wrap(
|
||||
() => http.get<SessionDetailResponse>(buildUrl(baseUrl, buildSessionDetailPath(code, options)), { withCredentials: true }),
|
||||
mapSessionDetailResponse
|
||||
),
|
||||
joinSession: (payload: JoinSessionRequest) =>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import {
|
||||
mapCalculateScoresResponse,
|
||||
mapCreateSessionResponse,
|
||||
mapFinishGameResponse,
|
||||
mapHealthResponse,
|
||||
mapJoinSessionResponse,
|
||||
mapMixAnswersResponse,
|
||||
mapNextRoundResponse,
|
||||
mapScoreboardResponse,
|
||||
mapSessionDetailResponse,
|
||||
mapShowQuestionResponse,
|
||||
mapStartNextRoundResponse,
|
||||
mapStartRoundResponse,
|
||||
mapSubmitGuessResponse,
|
||||
mapSubmitLieResponse
|
||||
@@ -15,15 +16,17 @@ import {
|
||||
import type {
|
||||
ApiResult,
|
||||
CalculateScoresResponse,
|
||||
CreateSessionResponse,
|
||||
FinishGameResponse,
|
||||
HealthResponse,
|
||||
JoinSessionRequest,
|
||||
JoinSessionResponse,
|
||||
MixAnswersResponse,
|
||||
NextRoundResponse,
|
||||
ScoreboardResponse,
|
||||
SessionDetailRequestOptions,
|
||||
SessionDetailResponse,
|
||||
ShowQuestionResponse,
|
||||
StartNextRoundResponse,
|
||||
StartRoundRequest,
|
||||
StartRoundResponse,
|
||||
SubmitGuessRequest,
|
||||
@@ -34,35 +37,74 @@ import type {
|
||||
|
||||
export interface ApiClient {
|
||||
health(): Promise<ApiResult<HealthResponse>>;
|
||||
getSession(code: string): Promise<ApiResult<SessionDetailResponse>>;
|
||||
createSession(): Promise<ApiResult<CreateSessionResponse>>;
|
||||
getSession(code: string, options?: SessionDetailRequestOptions): Promise<ApiResult<SessionDetailResponse>>;
|
||||
joinSession(payload: JoinSessionRequest): Promise<ApiResult<JoinSessionResponse>>;
|
||||
startRound(code: string, payload: StartRoundRequest): Promise<ApiResult<StartRoundResponse>>;
|
||||
showQuestion(code: string): Promise<ApiResult<ShowQuestionResponse>>;
|
||||
mixAnswers(code: string, roundQuestionId: number): Promise<ApiResult<MixAnswersResponse>>;
|
||||
calculateScores(code: string, roundQuestionId: number): Promise<ApiResult<CalculateScoresResponse>>;
|
||||
getScoreboard(code: string): Promise<ApiResult<ScoreboardResponse>>;
|
||||
startNextRound(code: string): Promise<ApiResult<NextRoundResponse>>;
|
||||
startNextRound(code: string): Promise<ApiResult<StartNextRoundResponse>>;
|
||||
finishGame(code: string): Promise<ApiResult<FinishGameResponse>>;
|
||||
submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise<ApiResult<SubmitLieResponse>>;
|
||||
submitGuess(code: string, roundQuestionId: number, payload: SubmitGuessRequest): Promise<ApiResult<SubmitGuessResponse>>;
|
||||
}
|
||||
|
||||
export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch): ApiClient {
|
||||
function readCookie(name: string): string {
|
||||
if (typeof document === 'undefined' || typeof document.cookie !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const prefix = `${name}=`;
|
||||
for (const part of document.cookie.split(';')) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return decodeURIComponent(trimmed.slice(prefix.length));
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function ensureCsrfToken(): Promise<string> {
|
||||
const existing = readCookie('csrftoken');
|
||||
if (existing || typeof document === 'undefined' || typeof window === 'undefined') {
|
||||
return existing;
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchImpl(`${baseUrl}/lobby/csrf`, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
||||
return readCookie('csrftoken');
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
method: 'GET' | 'POST',
|
||||
mapper: (payload: unknown) => T,
|
||||
payload?: unknown
|
||||
): Promise<ApiResult<T>> {
|
||||
const csrfToken = method === 'POST' ? await ensureCsrfToken() : '';
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetchImpl(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(payload === undefined ? {} : { 'Content-Type': 'application/json' })
|
||||
...(payload === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {})
|
||||
},
|
||||
...(payload === undefined ? {} : { body: JSON.stringify(payload) })
|
||||
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
@@ -72,6 +114,19 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
|
||||
};
|
||||
}
|
||||
|
||||
if (response.redirected && response.url.includes('/accounts/login')) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 401,
|
||||
error: {
|
||||
kind: 'http',
|
||||
status: 401,
|
||||
message: 'Login required',
|
||||
payload: { redirect: response.url }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let responsePayload: unknown;
|
||||
try {
|
||||
responsePayload = await response.json();
|
||||
@@ -114,11 +169,29 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
|
||||
|
||||
const normalizeCode = (value: string): string => value.trim().toUpperCase();
|
||||
|
||||
function buildSessionDetailPath(code: string, options?: SessionDetailRequestOptions): string {
|
||||
const path = `/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`;
|
||||
const sessionToken = options?.session_token?.trim();
|
||||
if (!sessionToken) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({ session_token: sessionToken });
|
||||
return `${path}?${params.toString()}`;
|
||||
}
|
||||
|
||||
return {
|
||||
health: () => request<HealthResponse>('/healthz', 'GET', mapHealthResponse),
|
||||
getSession: (code: string) =>
|
||||
createSession: () =>
|
||||
request<CreateSessionResponse>(
|
||||
'/lobby/sessions/create',
|
||||
'POST',
|
||||
mapCreateSessionResponse,
|
||||
{}
|
||||
),
|
||||
getSession: (code: string, options?: SessionDetailRequestOptions) =>
|
||||
request<SessionDetailResponse>(
|
||||
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}`,
|
||||
buildSessionDetailPath(code, options),
|
||||
'GET',
|
||||
mapSessionDetailResponse
|
||||
),
|
||||
@@ -167,10 +240,10 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
|
||||
mapScoreboardResponse
|
||||
),
|
||||
startNextRound: (code: string) =>
|
||||
request<NextRoundResponse>(
|
||||
request<StartNextRoundResponse>(
|
||||
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`,
|
||||
'POST',
|
||||
mapNextRoundResponse,
|
||||
mapStartNextRoundResponse,
|
||||
{}
|
||||
),
|
||||
finishGame: (code: string) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
CalculateScoresResponse,
|
||||
CreateSessionResponse,
|
||||
FinishGameResponse,
|
||||
HealthResponse,
|
||||
JoinSessionResponse,
|
||||
@@ -10,7 +11,8 @@ import type {
|
||||
StartNextRoundResponse,
|
||||
StartRoundResponse,
|
||||
SubmitGuessResponse,
|
||||
SubmitLieResponse
|
||||
SubmitLieResponse,
|
||||
VoiceCue,
|
||||
} from './types';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -52,6 +54,17 @@ function readNumber(record: Record<string, unknown>, key: string, path: string):
|
||||
return value;
|
||||
}
|
||||
|
||||
function readNullableString(record: Record<string, unknown>, key: string, path: string): string | null {
|
||||
const value = record[key];
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
if (!isString(value)) {
|
||||
throw new Error(`Invalid API contract: expected string|null at ${path}.${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readBoolean(record: Record<string, unknown>, key: string, path: string): boolean {
|
||||
const value = record[key];
|
||||
if (!isBoolean(value)) {
|
||||
@@ -60,6 +73,74 @@ function readBoolean(record: Record<string, unknown>, key: string, path: string)
|
||||
return value;
|
||||
}
|
||||
|
||||
function readNullableNumber(record: Record<string, unknown>, key: string, path: string): number | null {
|
||||
const value = record[key];
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
if (!isNumber(value)) {
|
||||
throw new Error(`Invalid API contract: expected number|null at ${path}.${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function mapVoiceCue(payload: unknown, path: string): VoiceCue | null {
|
||||
if (payload === null || payload === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = asRecord(payload, path);
|
||||
const translations = record.translations;
|
||||
if (!isRecord(translations)) {
|
||||
throw new Error(`Invalid API contract: expected object at ${path}.translations`);
|
||||
}
|
||||
const audioUrls = record.audio_urls;
|
||||
if (audioUrls !== undefined && audioUrls !== null && !isRecord(audioUrls)) {
|
||||
throw new Error(`Invalid API contract: expected object at ${path}.audio_urls`);
|
||||
}
|
||||
|
||||
const textByLocale: Record<string, string> = {};
|
||||
for (const [locale, value] of Object.entries(translations)) {
|
||||
if (!isString(value)) {
|
||||
throw new Error(`Invalid API contract: expected string at ${path}.translations.${locale}`);
|
||||
}
|
||||
textByLocale[locale] = value;
|
||||
}
|
||||
|
||||
const audioByLocale: Record<string, string> = {};
|
||||
for (const [locale, value] of Object.entries(audioUrls ?? {})) {
|
||||
if (!isString(value)) {
|
||||
throw new Error(`Invalid API contract: expected string at ${path}.audio_urls.${locale}`);
|
||||
}
|
||||
audioByLocale[locale] = value;
|
||||
}
|
||||
|
||||
return {
|
||||
cue: readString(record, 'cue', path),
|
||||
translations: textByLocale,
|
||||
audio_urls: audioByLocale,
|
||||
source: readString(record, 'source', path),
|
||||
};
|
||||
}
|
||||
|
||||
function mapSessionPlayerIdentity(payload: unknown, path: string): { token: string; tone: string; icon?: string } | undefined {
|
||||
if (payload === undefined || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = asRecord(payload, path);
|
||||
const icon = record.icon;
|
||||
if (icon !== undefined && icon !== null && !isString(icon)) {
|
||||
throw new Error(`Invalid API contract: expected string at ${path}.icon`);
|
||||
}
|
||||
|
||||
return {
|
||||
token: readString(record, 'token', path),
|
||||
tone: readString(record, 'tone', path),
|
||||
...(icon === undefined || icon === null ? {} : { icon }),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapHealthResponse(payload: unknown): HealthResponse {
|
||||
const root = asRecord(payload, 'health');
|
||||
return {
|
||||
@@ -68,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');
|
||||
@@ -88,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}]`);
|
||||
@@ -102,6 +197,86 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
|
||||
const host = asRecord(phase.host, 'session_detail.phase_view_model.host');
|
||||
const player = asRecord(phase.player, 'session_detail.phase_view_model.player');
|
||||
|
||||
const revealRaw = root.reveal;
|
||||
let reveal: SessionDetailResponse['reveal'] = null;
|
||||
if (revealRaw !== null && revealRaw !== undefined) {
|
||||
const revealRecord = asRecord(revealRaw, 'session_detail.reveal');
|
||||
const liesRaw = revealRecord.lies;
|
||||
const guessesRaw = revealRecord.guesses;
|
||||
if (!Array.isArray(liesRaw)) {
|
||||
throw new Error('Invalid API contract: expected array at session_detail.reveal.lies');
|
||||
}
|
||||
if (!Array.isArray(guessesRaw)) {
|
||||
throw new Error('Invalid API contract: expected array at session_detail.reveal.guesses');
|
||||
}
|
||||
|
||||
reveal = {
|
||||
round_question_id: readNumber(revealRecord, 'round_question_id', 'session_detail.reveal'),
|
||||
round_number: readNumber(revealRecord, 'round_number', 'session_detail.reveal'),
|
||||
prompt: readString(revealRecord, 'prompt', 'session_detail.reveal'),
|
||||
correct_answer: readString(revealRecord, 'correct_answer', 'session_detail.reveal'),
|
||||
lies: liesRaw.map((lie, index) => {
|
||||
const record = asRecord(lie, `session_detail.reveal.lies[${index}]`);
|
||||
return {
|
||||
player_id: readNumber(record, 'player_id', `session_detail.reveal.lies[${index}]`),
|
||||
nickname: readString(record, 'nickname', `session_detail.reveal.lies[${index}]`),
|
||||
text: readString(record, 'text', `session_detail.reveal.lies[${index}]`),
|
||||
created_at: readString(record, 'created_at', `session_detail.reveal.lies[${index}]`)
|
||||
};
|
||||
}),
|
||||
guesses: guessesRaw.map((guess, index) => {
|
||||
const path = `session_detail.reveal.guesses[${index}]`;
|
||||
const record = asRecord(guess, path);
|
||||
const fooledPlayerId = readNullableNumber(record, 'fooled_player_id', path);
|
||||
const fooledPlayerNickname = record.fooled_player_nickname;
|
||||
if (fooledPlayerId === null) {
|
||||
if (fooledPlayerNickname !== undefined) {
|
||||
throw new Error(`Invalid API contract: expected ${path}.fooled_player_nickname to be omitted when fooled_player_id is null`);
|
||||
}
|
||||
} else if (!isString(fooledPlayerNickname)) {
|
||||
throw new Error(`Invalid API contract: expected string at ${path}.fooled_player_nickname when fooled_player_id is set`);
|
||||
}
|
||||
return {
|
||||
player_id: readNumber(record, 'player_id', path),
|
||||
nickname: readString(record, 'nickname', path),
|
||||
selected_text: readString(record, 'selected_text', path),
|
||||
is_correct: readBoolean(record, 'is_correct', path),
|
||||
fooled_player_id: fooledPlayerId,
|
||||
...(fooledPlayerNickname === undefined ? {} : { fooled_player_nickname: fooledPlayerNickname }),
|
||||
created_at: readString(record, 'created_at', path)
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
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'),
|
||||
@@ -119,18 +294,27 @@ 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,
|
||||
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
|
||||
players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'),
|
||||
constraints: {
|
||||
@@ -139,6 +323,19 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
|
||||
min_players_reached: readBoolean(constraints, 'min_players_reached', 'session_detail.phase_view_model.constraints'),
|
||||
max_players_allowed: readBoolean(constraints, 'max_players_allowed', 'session_detail.phase_view_model.constraints')
|
||||
},
|
||||
readiness:
|
||||
phase.readiness && typeof phase.readiness === 'object'
|
||||
? {
|
||||
question_ready:
|
||||
typeof (phase.readiness as Record<string, unknown>).question_ready === 'boolean'
|
||||
? ((phase.readiness as Record<string, unknown>).question_ready as boolean)
|
||||
: undefined,
|
||||
scoreboard_ready:
|
||||
typeof (phase.readiness as Record<string, unknown>).scoreboard_ready === 'boolean'
|
||||
? ((phase.readiness as Record<string, unknown>).scoreboard_ready as boolean)
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
host: {
|
||||
can_start_round: readBoolean(host, 'can_start_round', 'session_detail.phase_view_model.host'),
|
||||
can_show_question: readBoolean(host, 'can_show_question', 'session_detail.phase_view_model.host'),
|
||||
@@ -337,10 +534,7 @@ export function mapSubmitGuessResponse(payload: unknown): SubmitGuessResponse {
|
||||
const root = asRecord(payload, 'submit_guess');
|
||||
const guess = asRecord(root.guess, 'submit_guess.guess');
|
||||
const window = asRecord(root.window, 'submit_guess.window');
|
||||
const fooledPlayerId = guess.fooled_player_id;
|
||||
if (fooledPlayerId !== null && !isNumber(fooledPlayerId)) {
|
||||
throw new Error('Invalid API contract: expected number|null at submit_guess.guess.fooled_player_id');
|
||||
}
|
||||
const fooledPlayerId = readNullableNumber(guess, 'fooled_player_id', 'submit_guess.guess');
|
||||
|
||||
return {
|
||||
guess: {
|
||||
|
||||
@@ -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,13 +39,14 @@ export interface SessionAnswer {
|
||||
export interface SessionRoundQuestion {
|
||||
id: number;
|
||||
round_number: number;
|
||||
prompt: string;
|
||||
prompt: string | null;
|
||||
shown_at: string;
|
||||
answers: SessionAnswer[];
|
||||
}
|
||||
|
||||
export interface PhaseViewModel {
|
||||
status: string;
|
||||
current_phase?: string;
|
||||
round_number: number;
|
||||
players_count: number;
|
||||
constraints: {
|
||||
@@ -40,6 +55,10 @@ export interface PhaseViewModel {
|
||||
min_players_reached: boolean;
|
||||
max_players_allowed: boolean;
|
||||
};
|
||||
readiness?: {
|
||||
question_ready?: boolean;
|
||||
scoreboard_ready?: boolean;
|
||||
};
|
||||
host: {
|
||||
can_start_round: boolean;
|
||||
can_show_question: boolean;
|
||||
@@ -57,13 +76,71 @@ export interface PhaseViewModel {
|
||||
};
|
||||
}
|
||||
|
||||
export interface RevealLie {
|
||||
player_id: number;
|
||||
nickname: string;
|
||||
text: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RevealGuess {
|
||||
player_id: number;
|
||||
nickname: string;
|
||||
selected_text: string;
|
||||
is_correct: boolean;
|
||||
fooled_player_id: number | null;
|
||||
fooled_player_nickname?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RevealPayload {
|
||||
round_question_id: number;
|
||||
round_number: number;
|
||||
prompt: string;
|
||||
correct_answer: string;
|
||||
lies: RevealLie[];
|
||||
guesses: RevealGuess[];
|
||||
}
|
||||
|
||||
export interface VoiceCue {
|
||||
cue: string;
|
||||
translations: Record<string, string>;
|
||||
audio_urls: Record<string, string>;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface SessionVoiceCues {
|
||||
default_locale: string;
|
||||
intro: VoiceCue | null;
|
||||
phase: VoiceCue | null;
|
||||
question_prompt: VoiceCue | null;
|
||||
question_reveal: VoiceCue | null;
|
||||
}
|
||||
|
||||
export interface SessionPhaseDisplay {
|
||||
theme: string;
|
||||
ornament?: string;
|
||||
title_key: string;
|
||||
body_key: string;
|
||||
cue_label_key: string;
|
||||
cue_body_key: string;
|
||||
}
|
||||
|
||||
export interface SessionDetailResponse {
|
||||
session: SessionSummary;
|
||||
viewer_role?: 'host' | 'player' | 'public';
|
||||
players: SessionPlayer[];
|
||||
round_question: SessionRoundQuestion | null;
|
||||
reveal: RevealPayload | null;
|
||||
voice_cues?: SessionVoiceCues | null;
|
||||
phase_display?: SessionPhaseDisplay | null;
|
||||
phase_view_model: PhaseViewModel;
|
||||
}
|
||||
|
||||
export interface SessionDetailRequestOptions {
|
||||
session_token?: string;
|
||||
}
|
||||
|
||||
export interface JoinSessionRequest {
|
||||
code: string;
|
||||
nickname: string;
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { SessionDetailResponse } from '../api/types';
|
||||
import type { PhaseViewModel, SessionDetailResponse } from '../api/types';
|
||||
|
||||
export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard';
|
||||
export type HostGameplayAction =
|
||||
| 'startRound'
|
||||
| 'showQuestion'
|
||||
| 'mixAnswers'
|
||||
| 'calculateScores'
|
||||
| 'loadScoreboard'
|
||||
| 'startNextRound'
|
||||
| 'finishGame';
|
||||
export type PlayerGameplayAction = 'join' | 'submitLie' | 'submitGuess' | 'viewFinalResult';
|
||||
|
||||
export type GameplayPhaseEvent =
|
||||
| 'LIES_LOCKED'
|
||||
@@ -40,8 +49,7 @@ export function allowedGameplayEvents(phase: GameplayPhase): GameplayPhaseEvent[
|
||||
return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[];
|
||||
}
|
||||
|
||||
export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null {
|
||||
const status = session?.session.status;
|
||||
function derivePhaseFromStatus(status: string | null | undefined): GameplayPhase | null {
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
@@ -56,3 +64,59 @@ export function deriveGameplayPhase(session: SessionDetailResponse | null): Game
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveCanonicalPhaseStatus(phaseViewModel: PhaseViewModel | null | undefined): string | null {
|
||||
if (!phaseViewModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentPhase = (phaseViewModel as PhaseViewModel & { current_phase?: string }).current_phase;
|
||||
return currentPhase ?? phaseViewModel.status ?? null;
|
||||
}
|
||||
|
||||
export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null {
|
||||
const canonicalStatus = deriveCanonicalPhaseStatus(session?.phase_view_model);
|
||||
return derivePhaseFromStatus(canonicalStatus ?? session?.session.status);
|
||||
}
|
||||
|
||||
export function isHostGameplayActionAllowed(session: SessionDetailResponse | null, action: HostGameplayAction): boolean {
|
||||
if (!session) {
|
||||
return action === 'startRound';
|
||||
}
|
||||
|
||||
const host = session.phase_view_model?.host;
|
||||
switch (action) {
|
||||
case 'startRound':
|
||||
return Boolean(host?.can_start_round ?? false);
|
||||
case 'showQuestion':
|
||||
return Boolean(host?.can_show_question ?? false);
|
||||
case 'mixAnswers':
|
||||
return Boolean(host?.can_mix_answers ?? false);
|
||||
case 'calculateScores':
|
||||
return Boolean(host?.can_calculate_scores ?? false);
|
||||
case 'loadScoreboard':
|
||||
return Boolean(host?.can_reveal_scoreboard ?? false);
|
||||
case 'startNextRound':
|
||||
return Boolean(host?.can_start_next_round ?? false);
|
||||
case 'finishGame':
|
||||
return Boolean(host?.can_finish_game ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
export function isPlayerGameplayActionAllowed(session: SessionDetailResponse | null, action: PlayerGameplayAction): boolean {
|
||||
if (!session) {
|
||||
return action === 'join';
|
||||
}
|
||||
|
||||
const player = session.phase_view_model?.player;
|
||||
switch (action) {
|
||||
case 'join':
|
||||
return Boolean(player?.can_join ?? false);
|
||||
case 'submitLie':
|
||||
return Boolean(player?.can_submit_lie ?? false);
|
||||
case 'submitGuess':
|
||||
return Boolean(player?.can_submit_guess ?? false);
|
||||
case 'viewFinalResult':
|
||||
return Boolean(player?.can_view_final_result ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,17 @@
|
||||
import lobbyCatalog from '../../../shared/i18n/lobby.json';
|
||||
import { DEFAULT_LOCALE, LOBBY_I18N_CATALOG, normalizeLocale } from '../../shared/i18n/lobby-loader';
|
||||
|
||||
const frontendErrors = lobbyCatalog.frontend.errors;
|
||||
const localeConfig = lobbyCatalog.locales;
|
||||
const frontendErrors = LOBBY_I18N_CATALOG.frontend.errors;
|
||||
const backendToFrontendErrorKeys = LOBBY_I18N_CATALOG.contract.backend_to_frontend_error_keys as Record<
|
||||
string,
|
||||
keyof typeof frontendErrors
|
||||
>;
|
||||
|
||||
type FrontendErrorKey = keyof typeof frontendErrors;
|
||||
type SupportedLocale = (typeof localeConfig.supported)[number];
|
||||
|
||||
function isFrontendErrorKey(value: string): value is FrontendErrorKey {
|
||||
return value in frontendErrors;
|
||||
}
|
||||
|
||||
function normalizeLocale(rawLocale?: string): SupportedLocale {
|
||||
const requested = (rawLocale ?? '').trim().toLowerCase();
|
||||
if (localeConfig.supported.includes(requested as SupportedLocale)) {
|
||||
return requested as SupportedLocale;
|
||||
}
|
||||
return localeConfig.default;
|
||||
}
|
||||
|
||||
export function lobbyMessage(key: FrontendErrorKey, locale?: string): string {
|
||||
const resolvedLocale = normalizeLocale(locale);
|
||||
const translations = frontendErrors[key] as Record<string, string>;
|
||||
@@ -25,8 +19,8 @@ export function lobbyMessage(key: FrontendErrorKey, locale?: string): string {
|
||||
if (translations[resolvedLocale]) {
|
||||
return translations[resolvedLocale];
|
||||
}
|
||||
if (translations[localeConfig.default]) {
|
||||
return translations[localeConfig.default];
|
||||
if (translations[DEFAULT_LOCALE]) {
|
||||
return translations[DEFAULT_LOCALE];
|
||||
}
|
||||
|
||||
return key;
|
||||
@@ -40,9 +34,15 @@ export function lobbyMessageFromApiPayload(payload: unknown, fallbackKey: Fronte
|
||||
const record = payload as Record<string, unknown>;
|
||||
const code = typeof record.error_code === 'string' ? record.error_code : '';
|
||||
const payloadLocale = typeof record.locale === 'string' ? record.locale : locale;
|
||||
if (!isFrontendErrorKey(code)) {
|
||||
return lobbyMessage(fallbackKey, payloadLocale);
|
||||
const mappedKey = code ? backendToFrontendErrorKeys[code] : undefined;
|
||||
|
||||
if (mappedKey && isFrontendErrorKey(mappedKey)) {
|
||||
return lobbyMessage(mappedKey, payloadLocale);
|
||||
}
|
||||
|
||||
if (isFrontendErrorKey(code)) {
|
||||
return lobbyMessage(code, payloadLocale);
|
||||
}
|
||||
|
||||
return lobbyMessage(fallbackKey, payloadLocale);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ApiClient } from '../api/client';
|
||||
import type { SessionDetailResponse } from '../api/types';
|
||||
import type { SessionDetailRequestOptions, SessionDetailResponse } from '../api/types';
|
||||
import {
|
||||
createSessionContextStore,
|
||||
type SessionContext,
|
||||
@@ -25,7 +25,7 @@ export interface VerticalSliceState {
|
||||
|
||||
export interface VerticalSliceController {
|
||||
getState(): VerticalSliceState;
|
||||
hydrateLobby(sessionCode: string): Promise<VerticalSliceState>;
|
||||
hydrateLobby(sessionCode: string, options?: SessionDetailRequestOptions): Promise<VerticalSliceState>;
|
||||
joinLobby(sessionCode: string, nickname: string): Promise<VerticalSliceState>;
|
||||
startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState>;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export function createVerticalSliceController(
|
||||
|
||||
const normalizeCode = (value: string): string => value.trim().toUpperCase();
|
||||
|
||||
async function hydrateLobby(sessionCode: string): Promise<VerticalSliceState> {
|
||||
async function hydrateLobby(sessionCode: string, options?: SessionDetailRequestOptions): Promise<VerticalSliceState> {
|
||||
state.loadingSession = true;
|
||||
state.errorMessage = null;
|
||||
|
||||
@@ -62,7 +62,7 @@ export function createVerticalSliceController(
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
const result = await api.getSession(state.sessionCode);
|
||||
const result = await api.getSession(state.sessionCode, options);
|
||||
state.loadingSession = false;
|
||||
|
||||
if (!result.ok) {
|
||||
@@ -107,7 +107,7 @@ export function createVerticalSliceController(
|
||||
};
|
||||
sessionContextStore.set(nextContext);
|
||||
|
||||
return hydrateLobby(state.sessionCode);
|
||||
return hydrateLobby(state.sessionCode, { session_token: nextContext.token });
|
||||
}
|
||||
|
||||
async function startRound(sessionCode: string, categorySlug: string): Promise<VerticalSliceState> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createAngularApiClient, type AngularHttpClientLike } from '../src/api/angular-client';
|
||||
import { mapSessionDetailResponse, mapSubmitGuessResponse } from '../src/api/mappers';
|
||||
|
||||
describe('createAngularApiClient', () => {
|
||||
it('reads health and session detail using Django-compatible endpoints', async () => {
|
||||
@@ -206,11 +207,487 @@ describe('createAngularApiClient', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps canonical reveal payload stable when session detail is already in scoreboard phase', async () => {
|
||||
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
|
||||
if (url === '/lobby/sessions/ABCD12') {
|
||||
return {
|
||||
session: { code: 'ABCD12', status: 'scoreboard', 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: {
|
||||
id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Q?',
|
||||
shown_at: '2026-03-01T18:00:00Z',
|
||||
answers: [{ text: 'A' }, { text: 'B' }]
|
||||
},
|
||||
reveal: {
|
||||
round_question_id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Q?',
|
||||
correct_answer: 'A',
|
||||
lies: [{ player_id: 2, nickname: 'Maja', text: 'B', created_at: '2026-03-01T18:00:05Z' }],
|
||||
guesses: [
|
||||
{
|
||||
player_id: 3,
|
||||
nickname: 'Bo',
|
||||
selected_text: 'B',
|
||||
is_correct: false,
|
||||
fooled_player_id: 2,
|
||||
fooled_player_nickname: 'Maja',
|
||||
created_at: '2026-03-01T18:00:15Z'
|
||||
}
|
||||
]
|
||||
},
|
||||
phase_view_model: {
|
||||
status: 'scoreboard',
|
||||
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: false,
|
||||
can_calculate_scores: false,
|
||||
can_reveal_scoreboard: false,
|
||||
can_start_next_round: true,
|
||||
can_finish_game: true
|
||||
},
|
||||
player: {
|
||||
can_join: true,
|
||||
can_submit_lie: false,
|
||||
can_submit_guess: false,
|
||||
can_view_final_result: false
|
||||
}
|
||||
}
|
||||
} as T;
|
||||
}
|
||||
throw { status: 404, error: { error: 'Not found' } };
|
||||
});
|
||||
|
||||
const client = createAngularApiClient({ get, post: vi.fn() } as unknown as AngularHttpClientLike);
|
||||
const session = await client.getSession('abcd12');
|
||||
|
||||
expect(session.ok).toBe(true);
|
||||
if (session.ok) {
|
||||
expect(session.data.session.status).toBe('scoreboard');
|
||||
expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja');
|
||||
expect(session.data.phase_view_model.host.can_start_next_round).toBe(true);
|
||||
expect(session.data.phase_view_model.host.can_finish_game).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('normalizes omitted fooled_player_id to null in canonical reveal payloads', async () => {
|
||||
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
|
||||
if (url === '/lobby/sessions/ABCD12') {
|
||||
return {
|
||||
session: { code: 'ABCD12', status: 'reveal', 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: {
|
||||
id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Q?',
|
||||
shown_at: '2026-03-01T18:00:00Z',
|
||||
answers: [{ text: 'A' }, { text: 'B' }]
|
||||
},
|
||||
reveal: {
|
||||
round_question_id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Q?',
|
||||
correct_answer: 'A',
|
||||
lies: [],
|
||||
guesses: [
|
||||
{
|
||||
player_id: 3,
|
||||
nickname: 'Bo',
|
||||
selected_text: 'A',
|
||||
is_correct: true,
|
||||
created_at: '2026-03-01T18:00:15Z'
|
||||
}
|
||||
]
|
||||
},
|
||||
phase_view_model: {
|
||||
status: 'reveal',
|
||||
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: false,
|
||||
can_calculate_scores: false,
|
||||
can_reveal_scoreboard: true,
|
||||
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
|
||||
}
|
||||
}
|
||||
} as T;
|
||||
}
|
||||
throw { status: 404, error: { error: 'Not found' } };
|
||||
});
|
||||
|
||||
const post = vi.fn<AngularHttpClientLike['post']>(async <T>(url: string, body: unknown) => {
|
||||
if (url === '/lobby/sessions/ABCD12/questions/77/guesses/submit') {
|
||||
expect(body).toEqual({ player_id: 9, session_token: 'tok', selected_text: 'A' });
|
||||
return {
|
||||
guess: {
|
||||
id: 200,
|
||||
player_id: 9,
|
||||
round_question_id: 77,
|
||||
selected_text: 'A',
|
||||
is_correct: false,
|
||||
created_at: '2026-03-01T16:01:00Z'
|
||||
},
|
||||
window: { guess_deadline_at: '2026-03-01T16:01:30Z' }
|
||||
} as T;
|
||||
}
|
||||
throw { status: 404, error: { error: 'Not found' } };
|
||||
});
|
||||
|
||||
const client = createAngularApiClient({ get, post } as AngularHttpClientLike);
|
||||
const session = await client.getSession('abcd12');
|
||||
|
||||
expect(session.ok).toBe(true);
|
||||
if (session.ok) {
|
||||
expect(session.data.reveal?.guesses[0].fooled_player_id).toBeNull();
|
||||
expect(session.data.reveal?.guesses[0]).not.toHaveProperty('fooled_player_nickname');
|
||||
}
|
||||
|
||||
const submitGuess = await client.submitGuess('ABCD12', 77, {
|
||||
player_id: 9,
|
||||
session_token: 'tok',
|
||||
selected_text: 'A'
|
||||
});
|
||||
expect(submitGuess.ok).toBe(true);
|
||||
if (submitGuess.ok) {
|
||||
expect(submitGuess.data.guess.fooled_player_id).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('maps omitted fooled_player_id to null in submit guess mapper payloads', () => {
|
||||
const mapped = mapSubmitGuessResponse({
|
||||
guess: {
|
||||
id: 200,
|
||||
player_id: 9,
|
||||
round_question_id: 77,
|
||||
selected_text: 'A',
|
||||
is_correct: false,
|
||||
created_at: '2026-03-01T16:01:00Z'
|
||||
},
|
||||
window: { guess_deadline_at: '2026-03-01T16:01:30Z' }
|
||||
});
|
||||
|
||||
expect(mapped.guess.fooled_player_id).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps fooled_player_nickname omitted when canonical reveal payload omits fooled player refs', () => {
|
||||
const mapped = mapSessionDetailResponse({
|
||||
session: { code: 'ABCD12', status: 'reveal', 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: {
|
||||
id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Q?',
|
||||
shown_at: '2026-03-01T18:00:00Z',
|
||||
answers: [{ text: 'A' }, { text: 'B' }]
|
||||
},
|
||||
reveal: {
|
||||
round_question_id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Q?',
|
||||
correct_answer: 'A',
|
||||
lies: [],
|
||||
guesses: [
|
||||
{
|
||||
player_id: 3,
|
||||
nickname: 'Bo',
|
||||
selected_text: 'A',
|
||||
is_correct: true,
|
||||
created_at: '2026-03-01T18:00:15Z'
|
||||
}
|
||||
]
|
||||
},
|
||||
phase_view_model: {
|
||||
status: 'reveal',
|
||||
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: false,
|
||||
can_calculate_scores: false,
|
||||
can_reveal_scoreboard: true,
|
||||
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.reveal?.guesses[0].fooled_player_id).toBeNull();
|
||||
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({
|
||||
session: { code: 'ABCD12', status: 'reveal', 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: {
|
||||
id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Q?',
|
||||
shown_at: '2026-03-01T18:00:00Z',
|
||||
answers: [{ text: 'A' }, { text: 'B' }]
|
||||
},
|
||||
reveal: {
|
||||
round_question_id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Q?',
|
||||
correct_answer: 'A',
|
||||
lies: [],
|
||||
guesses: [
|
||||
{
|
||||
player_id: 3,
|
||||
nickname: 'Bo',
|
||||
selected_text: 'A',
|
||||
is_correct: true,
|
||||
fooled_player_nickname: 'Maja',
|
||||
created_at: '2026-03-01T18:00:15Z'
|
||||
}
|
||||
]
|
||||
},
|
||||
phase_view_model: {
|
||||
status: 'reveal',
|
||||
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: false,
|
||||
can_calculate_scores: false,
|
||||
can_reveal_scoreboard: true,
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
).toThrow('fooled_player_nickname to be omitted when fooled_player_id is null');
|
||||
});
|
||||
|
||||
it('rejects canonical reveal payloads that omit fooled_player_nickname when fooled_player_id is set', () => {
|
||||
expect(() =>
|
||||
mapSessionDetailResponse({
|
||||
session: { code: 'ABCD12', status: 'reveal', 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: {
|
||||
id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Q?',
|
||||
shown_at: '2026-03-01T18:00:00Z',
|
||||
answers: [{ text: 'A' }, { text: 'B' }]
|
||||
},
|
||||
reveal: {
|
||||
round_question_id: 77,
|
||||
round_number: 1,
|
||||
prompt: 'Q?',
|
||||
correct_answer: 'A',
|
||||
lies: [],
|
||||
guesses: [
|
||||
{
|
||||
player_id: 3,
|
||||
nickname: 'Bo',
|
||||
selected_text: 'B',
|
||||
is_correct: false,
|
||||
fooled_player_id: 2,
|
||||
created_at: '2026-03-01T18:00:15Z'
|
||||
}
|
||||
]
|
||||
},
|
||||
phase_view_model: {
|
||||
status: 'reveal',
|
||||
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: false,
|
||||
can_calculate_scores: false,
|
||||
can_reveal_scoreboard: true,
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
).toThrow('fooled_player_nickname when fooled_player_id is set');
|
||||
});
|
||||
|
||||
it('maps host/player gameplay endpoints through typed response mappers', async () => {
|
||||
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
|
||||
if (url === '/lobby/sessions/ABCD12/scoreboard') {
|
||||
return {
|
||||
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
|
||||
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
|
||||
leaderboard: [
|
||||
{ id: 2, nickname: 'Maja', score: 11 },
|
||||
{ id: 3, nickname: 'Bo', score: 7 }
|
||||
@@ -245,7 +722,7 @@ describe('createAngularApiClient', () => {
|
||||
if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
|
||||
expect(body).toEqual({});
|
||||
return {
|
||||
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
|
||||
session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
|
||||
round_question: { id: 77, round_number: 1 },
|
||||
events_created: 3,
|
||||
leaderboard: [{ id: 2, nickname: 'Maja', score: 11 }]
|
||||
@@ -253,7 +730,7 @@ describe('createAngularApiClient', () => {
|
||||
}
|
||||
if (url === '/lobby/sessions/ABCD12/rounds/next') {
|
||||
expect(body).toEqual({});
|
||||
return { session: { code: 'ABCD12', status: 'lobby', current_round: 2 } } as T;
|
||||
return { session: { code: 'ABCD12', status: 'lie', current_round: 2 } } as T;
|
||||
}
|
||||
if (url === '/lobby/sessions/ABCD12/finish') {
|
||||
expect(body).toEqual({});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
allowedGameplayEvents,
|
||||
deriveGameplayPhase,
|
||||
isHostGameplayActionAllowed,
|
||||
isPlayerGameplayActionAllowed,
|
||||
transitionGameplayPhase,
|
||||
type GameplayPhase
|
||||
} from '../src/spa/gameplay-phase-machine';
|
||||
@@ -40,6 +42,7 @@ describe('gameplay phase machine skeleton', () => {
|
||||
session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 3 },
|
||||
players: [],
|
||||
round_question: null,
|
||||
reveal: null,
|
||||
phase_view_model: {
|
||||
status: 'lie',
|
||||
round_number: 1,
|
||||
@@ -74,6 +77,7 @@ describe('gameplay phase machine skeleton', () => {
|
||||
session: { code: 'ABCD12', status: 'finished', host_id: 1, current_round: 1, players_count: 3 },
|
||||
players: [],
|
||||
round_question: null,
|
||||
reveal: null,
|
||||
phase_view_model: {
|
||||
status: 'finished',
|
||||
round_number: 1,
|
||||
@@ -103,4 +107,44 @@ describe('gameplay phase machine skeleton', () => {
|
||||
})
|
||||
).toBe('scoreboard');
|
||||
});
|
||||
|
||||
it('gates host and player actions from canonical phase_view_model permissions', () => {
|
||||
const session = {
|
||||
session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 3 },
|
||||
players: [],
|
||||
round_question: { id: 77, prompt: 'Q?', answers: [] },
|
||||
phase_view_model: {
|
||||
status: 'reveal',
|
||||
round_number: 1,
|
||||
players_count: 3,
|
||||
constraints: {
|
||||
min_players_to_start: 3,
|
||||
max_players_mvp: 5,
|
||||
min_players_reached: true,
|
||||
max_players_allowed: true
|
||||
},
|
||||
host: {
|
||||
can_start_round: false,
|
||||
can_show_question: false,
|
||||
can_mix_answers: false,
|
||||
can_calculate_scores: false,
|
||||
can_reveal_scoreboard: true,
|
||||
can_start_next_round: true,
|
||||
can_finish_game: true
|
||||
},
|
||||
player: {
|
||||
can_join: false,
|
||||
can_submit_lie: false,
|
||||
can_submit_guess: false,
|
||||
can_view_final_result: false
|
||||
}
|
||||
}
|
||||
} as const;
|
||||
|
||||
expect(deriveGameplayPhase(session as any)).toBe('reveal');
|
||||
expect(isHostGameplayActionAllowed(session as any, 'loadScoreboard')).toBe(true);
|
||||
expect(isHostGameplayActionAllowed(session as any, 'startNextRound')).toBe(true);
|
||||
expect(isHostGameplayActionAllowed(session as any, 'finishGame')).toBe(true);
|
||||
expect(isPlayerGameplayActionAllowed(session as any, 'submitGuess')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,10 +14,16 @@ describe('shared i18n keyspace contract', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps backend error-code keyspace aligned with backend translations', () => {
|
||||
for (const [code, key] of Object.entries(lobbyCatalog.backend.error_codes)) {
|
||||
expect(code).toBe(key);
|
||||
expect(lobbyCatalog.backend.errors[key as keyof typeof lobbyCatalog.backend.errors]).toBeDefined();
|
||||
it('keeps backend error-code keyspace aligned with shared backend→frontend map and backend translations', () => {
|
||||
for (const [code, backendKey] of Object.entries(lobbyCatalog.backend.error_codes)) {
|
||||
const frontendKey =
|
||||
lobbyCatalog.contract.backend_to_frontend_error_keys[
|
||||
code as keyof typeof lobbyCatalog.contract.backend_to_frontend_error_keys
|
||||
];
|
||||
|
||||
expect(lobbyCatalog.backend.errors[backendKey as keyof typeof lobbyCatalog.backend.errors]).toBeDefined();
|
||||
expect(frontendKey, `missing frontend mapping for ${code}`).toBeTruthy();
|
||||
expect(lobbyCatalog.frontend.errors[frontendKey as keyof typeof lobbyCatalog.frontend.errors]).toBeDefined();
|
||||
}
|
||||
|
||||
for (const [key, translations] of Object.entries(lobbyCatalog.backend.errors)) {
|
||||
@@ -42,10 +48,19 @@ describe('lobbyMessage locale handling', () => {
|
||||
).toBe('Sessionskoden er ugyldig, eller sessionen findes ikke længere.');
|
||||
});
|
||||
|
||||
it('falls back when backend error code has no frontend translation key', () => {
|
||||
it('uses shared backend→frontend key-map at runtime even when fallback key differs', () => {
|
||||
expect(
|
||||
lobbyMessageFromApiPayload(
|
||||
{ error_code: 'session_not_joinable', locale: 'da' },
|
||||
'start_round_failed',
|
||||
),
|
||||
).toBe('Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen.');
|
||||
});
|
||||
|
||||
it('falls back to caller-provided fallback key for unknown backend error codes', () => {
|
||||
expect(
|
||||
lobbyMessageFromApiPayload(
|
||||
{ error_code: 'unknown_backend_key', locale: 'da' },
|
||||
'join_failed',
|
||||
),
|
||||
).toBe('Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen.');
|
||||
|
||||
38
frontend/tests/lobby-loader.parity.test.ts
Normal file
38
frontend/tests/lobby-loader.parity.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
collectLocaleParityIssues,
|
||||
LOBBY_I18N_CATALOG,
|
||||
normalizeLocale,
|
||||
SUPPORTED_LOCALES,
|
||||
translateCatalogPath,
|
||||
} from '../shared/i18n/lobby-loader';
|
||||
|
||||
describe('shared lobby i18n loader parity', () => {
|
||||
it('keeps da/en translation parity in shared keyspace', () => {
|
||||
const issues = collectLocaleParityIssues(LOBBY_I18N_CATALOG, SUPPORTED_LOCALES);
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes browser-style locale tags to supported keyspace locales', () => {
|
||||
expect(normalizeLocale('da-DK')).toBe('da');
|
||||
expect(normalizeLocale('da_DK')).toBe('da');
|
||||
expect(normalizeLocale('en-US')).toBe('en');
|
||||
expect(normalizeLocale('en_US')).toBe('en');
|
||||
expect(normalizeLocale('fr-FR')).toBe('en');
|
||||
});
|
||||
|
||||
it('resolves shared frontend ui keys with fallback-safe behavior', () => {
|
||||
expect(
|
||||
translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, 'host.start_round', 'da-DK'),
|
||||
).toBe('Start runde');
|
||||
|
||||
expect(
|
||||
translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, 'app.language_label', 'en-US'),
|
||||
).toBe('Language');
|
||||
|
||||
expect(
|
||||
translateCatalogPath(LOBBY_I18N_CATALOG.frontend.ui as Record<string, unknown>, 'host.non_existing_key', 'da'),
|
||||
).toBe('host.non_existing_key');
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import type { ApiClient } from '../src/api/client';
|
||||
function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
|
||||
const base: ApiClient = {
|
||||
health: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
getSession: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
@@ -16,6 +17,7 @@ function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
|
||||
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 },
|
||||
players: [],
|
||||
round_question: null,
|
||||
reveal: null,
|
||||
phase_view_model: {
|
||||
status: 'lobby',
|
||||
round_number: 1,
|
||||
@@ -56,7 +58,15 @@ function makeApiMock(overrides?: Partial<ApiClient>): ApiClient {
|
||||
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
|
||||
round: { number: 1, category: { slug: 'history', name: 'History' } }
|
||||
}
|
||||
})
|
||||
}),
|
||||
showQuestion: vi.fn(),
|
||||
mixAnswers: vi.fn(),
|
||||
calculateScores: vi.fn(),
|
||||
getScoreboard: vi.fn(),
|
||||
startNextRound: vi.fn(),
|
||||
finishGame: vi.fn(),
|
||||
submitLie: vi.fn(),
|
||||
submitGuess: vi.fn()
|
||||
};
|
||||
|
||||
return { ...base, ...overrides };
|
||||
|
||||
@@ -2,7 +2,8 @@ import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
include: ['tests/**/*.test.ts', 'angular/src/**/*.spec.ts'],
|
||||
setupFiles: ['angular/src/test-setup.ts'],
|
||||
exclude: ['**/node_modules/**']
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from django.contrib import admin
|
||||
from .models import Category, Question, GameSession, Player, RoundConfig, RoundQuestion, LieAnswer, Guess, ScoreEvent
|
||||
from .models import Category, Question, QuestionLie, GameSession, Player, RoundConfig, RoundQuestion, LieAnswer, Guess, ScoreEvent
|
||||
from voice.models import QuestionVoiceLine
|
||||
|
||||
|
||||
@admin.register(Category)
|
||||
@@ -9,11 +10,29 @@ class CategoryAdmin(admin.ModelAdmin):
|
||||
search_fields = ("name", "slug")
|
||||
|
||||
|
||||
class QuestionLieInline(admin.TabularInline):
|
||||
model = QuestionLie
|
||||
extra = 1
|
||||
|
||||
|
||||
class QuestionVoiceLineInline(admin.TabularInline):
|
||||
model = QuestionVoiceLine
|
||||
extra = 1
|
||||
|
||||
|
||||
@admin.register(Question)
|
||||
class QuestionAdmin(admin.ModelAdmin):
|
||||
list_display = ("id", "category", "is_active")
|
||||
list_display = ("id", "category", "scene_ornament", "is_active")
|
||||
list_filter = ("category", "is_active")
|
||||
search_fields = ("prompt", "correct_answer")
|
||||
inlines = [QuestionLieInline, QuestionVoiceLineInline]
|
||||
|
||||
|
||||
@admin.register(QuestionLie)
|
||||
class QuestionLieAdmin(admin.ModelAdmin):
|
||||
list_display = ("question", "text", "is_active", "sort_order")
|
||||
list_filter = ("is_active", "question__category")
|
||||
search_fields = ("question__prompt", "text")
|
||||
|
||||
|
||||
class PlayerInline(admin.TabularInline):
|
||||
|
||||
180
fupogfakta/bootstrap.py
Normal file
180
fupogfakta/bootstrap.py
Normal file
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AbstractBaseUser
|
||||
|
||||
from .models import Category, Question, QuestionLie
|
||||
|
||||
DEFAULT_MVP_HOST_USERNAME = "demo-host"
|
||||
DEFAULT_MVP_HOST_PASSWORD = "demo-pass"
|
||||
DEFAULT_MVP_CATEGORY_SLUG = "general"
|
||||
DEFAULT_MVP_CATEGORY_NAME = "General"
|
||||
DEFAULT_MVP_QUESTIONS: tuple[tuple[str, str], ...] = (
|
||||
("What is the capital of Denmark?", "Copenhagen"),
|
||||
("Which planet is known as the Red Planet?", "Mars"),
|
||||
("How many players are required before the host can start a round?", "3"),
|
||||
)
|
||||
DEFAULT_MVP_FALLBACK_LIES_BY_PROMPT: dict[str, tuple[str, ...]] = {
|
||||
"What is the capital of Denmark?": ("Aarhus", "Odense", "Aalborg", "Roskilde", "Esbjerg"),
|
||||
"Which planet is known as the Red Planet?": ("Venus", "Jupiter", "Saturn", "Mercury", "Neptune"),
|
||||
"How many players are required before the host can start a round?": ("2", "4", "5", "6", "8"),
|
||||
}
|
||||
DEFAULT_MVP_SCENE_ORNAMENT_BY_PROMPT: dict[str, str] = {
|
||||
"What is the capital of Denmark?": Question.SceneOrnament.HARBOR_FLARE,
|
||||
"Which planet is known as the Red Planet?": Question.SceneOrnament.AURORA_ARC,
|
||||
"How many players are required before the host can start a round?": Question.SceneOrnament.SIGNAL_BLOOM,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SeedSummary:
|
||||
created: int
|
||||
updated: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MvpBootstrapResult:
|
||||
host: AbstractBaseUser
|
||||
category: Category
|
||||
questions: tuple[Question, ...]
|
||||
host_changes: SeedSummary
|
||||
category_changes: SeedSummary
|
||||
question_changes: SeedSummary
|
||||
|
||||
|
||||
def ensure_host_user(*, username: str, password: str, is_staff: bool = True) -> tuple[AbstractBaseUser, SeedSummary]:
|
||||
user_model = get_user_model()
|
||||
host, created = user_model.objects.get_or_create(username=username)
|
||||
|
||||
updates: list[str] = []
|
||||
if not host.is_active:
|
||||
host.is_active = True
|
||||
updates.append("is_active")
|
||||
if host.is_staff != is_staff:
|
||||
host.is_staff = is_staff
|
||||
updates.append("is_staff")
|
||||
|
||||
host.set_password(password)
|
||||
updates.append("password")
|
||||
host.save(update_fields=updates)
|
||||
return host, SeedSummary(created=int(created), updated=int(bool(updates and not created)))
|
||||
|
||||
|
||||
def ensure_category_with_questions(
|
||||
*,
|
||||
slug: str,
|
||||
name: str,
|
||||
prompts_and_answers: tuple[tuple[str, str], ...],
|
||||
fallback_lies_by_prompt: dict[str, tuple[str, ...]] | None = None,
|
||||
scene_ornament_by_prompt: dict[str, str] | None = None,
|
||||
) -> tuple[Category, tuple[Question, ...], SeedSummary, SeedSummary]:
|
||||
category, created = Category.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={"name": name, "is_active": True},
|
||||
)
|
||||
|
||||
category_updates: list[str] = []
|
||||
if category.name != name:
|
||||
category.name = name
|
||||
category_updates.append("name")
|
||||
if not category.is_active:
|
||||
category.is_active = True
|
||||
category_updates.append("is_active")
|
||||
if category_updates:
|
||||
category.save(update_fields=category_updates)
|
||||
|
||||
questions: list[Question] = []
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
for prompt, correct_answer in prompts_and_answers:
|
||||
scene_ornament = ""
|
||||
if scene_ornament_by_prompt:
|
||||
scene_ornament = scene_ornament_by_prompt.get(prompt, "")
|
||||
question, question_created = Question.objects.get_or_create(
|
||||
category=category,
|
||||
prompt=prompt,
|
||||
defaults={
|
||||
"correct_answer": correct_answer,
|
||||
"scene_ornament": scene_ornament,
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
question_updates: list[str] = []
|
||||
if question.correct_answer != correct_answer:
|
||||
question.correct_answer = correct_answer
|
||||
question_updates.append("correct_answer")
|
||||
if question.scene_ornament != scene_ornament:
|
||||
question.scene_ornament = scene_ornament
|
||||
question_updates.append("scene_ornament")
|
||||
if not question.is_active:
|
||||
question.is_active = True
|
||||
question_updates.append("is_active")
|
||||
if question_updates:
|
||||
question.save(update_fields=question_updates)
|
||||
if fallback_lies_by_prompt:
|
||||
ensure_question_fallback_lies(
|
||||
question=question,
|
||||
lies=fallback_lies_by_prompt.get(prompt, ()),
|
||||
)
|
||||
created_count += int(question_created)
|
||||
updated_count += int(bool(question_updates and not question_created))
|
||||
questions.append(question)
|
||||
|
||||
return (
|
||||
category,
|
||||
tuple(questions),
|
||||
SeedSummary(created=int(created), updated=int(bool(category_updates and not created))),
|
||||
SeedSummary(created=created_count, updated=updated_count),
|
||||
)
|
||||
|
||||
|
||||
def ensure_question_fallback_lies(*, question: Question, lies: tuple[str, ...]) -> SeedSummary:
|
||||
created_count = 0
|
||||
updated_count = 0
|
||||
for index, lie_text in enumerate(lies):
|
||||
lie, created = QuestionLie.objects.get_or_create(
|
||||
question=question,
|
||||
text=lie_text,
|
||||
defaults={"is_active": True, "sort_order": index},
|
||||
)
|
||||
updates: list[str] = []
|
||||
if not lie.is_active:
|
||||
lie.is_active = True
|
||||
updates.append("is_active")
|
||||
if lie.sort_order != index:
|
||||
lie.sort_order = index
|
||||
updates.append("sort_order")
|
||||
if updates:
|
||||
lie.save(update_fields=updates)
|
||||
created_count += int(created)
|
||||
updated_count += int(bool(updates and not created))
|
||||
|
||||
return SeedSummary(created=created_count, updated=updated_count)
|
||||
|
||||
|
||||
def ensure_mvp_bootstrap(
|
||||
*,
|
||||
username: str = DEFAULT_MVP_HOST_USERNAME,
|
||||
password: str = DEFAULT_MVP_HOST_PASSWORD,
|
||||
category_slug: str = DEFAULT_MVP_CATEGORY_SLUG,
|
||||
category_name: str = DEFAULT_MVP_CATEGORY_NAME,
|
||||
prompts_and_answers: tuple[tuple[str, str], ...] = DEFAULT_MVP_QUESTIONS,
|
||||
) -> MvpBootstrapResult:
|
||||
host, host_changes = ensure_host_user(username=username, password=password)
|
||||
category, questions, category_changes, question_changes = ensure_category_with_questions(
|
||||
slug=category_slug,
|
||||
name=category_name,
|
||||
prompts_and_answers=prompts_and_answers,
|
||||
fallback_lies_by_prompt=DEFAULT_MVP_FALLBACK_LIES_BY_PROMPT,
|
||||
scene_ornament_by_prompt=DEFAULT_MVP_SCENE_ORNAMENT_BY_PROMPT,
|
||||
)
|
||||
return MvpBootstrapResult(
|
||||
host=host,
|
||||
category=category,
|
||||
questions=questions,
|
||||
host_changes=host_changes,
|
||||
category_changes=category_changes,
|
||||
question_changes=question_changes,
|
||||
)
|
||||
1
fupogfakta/management/__init__.py
Normal file
1
fupogfakta/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
fupogfakta/management/commands/__init__.py
Normal file
1
fupogfakta/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
41
fupogfakta/management/commands/bootstrap_mvp.py
Normal file
41
fupogfakta/management/commands/bootstrap_mvp.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from fupogfakta.bootstrap import (
|
||||
DEFAULT_MVP_CATEGORY_NAME,
|
||||
DEFAULT_MVP_CATEGORY_SLUG,
|
||||
DEFAULT_MVP_HOST_PASSWORD,
|
||||
DEFAULT_MVP_HOST_USERNAME,
|
||||
ensure_mvp_bootstrap,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Create deterministic host credentials and sample FupOgFakta content for MVP try-out"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--username", default=DEFAULT_MVP_HOST_USERNAME)
|
||||
parser.add_argument("--password", default=DEFAULT_MVP_HOST_PASSWORD)
|
||||
parser.add_argument("--category-slug", default=DEFAULT_MVP_CATEGORY_SLUG)
|
||||
parser.add_argument("--category-name", default=DEFAULT_MVP_CATEGORY_NAME)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
result = ensure_mvp_bootstrap(
|
||||
username=options["username"],
|
||||
password=options["password"],
|
||||
category_slug=options["category_slug"],
|
||||
category_name=options["category_name"],
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"\n".join(
|
||||
[
|
||||
"MVP bootstrap ready",
|
||||
f"host_username={result.host.username}",
|
||||
f"host_password={options['password']}",
|
||||
f"category_slug={result.category.slug}",
|
||||
f"questions={len(result.questions)}",
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
312
fupogfakta/management/commands/smoke_staging.py
Normal file
312
fupogfakta/management/commands/smoke_staging.py
Normal file
@@ -0,0 +1,312 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.test import Client
|
||||
|
||||
from fupogfakta.bootstrap import ensure_category_with_questions, ensure_host_user
|
||||
from fupogfakta.models import GameSession, Player, RoundQuestion
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Run canonical gameplay smoke/regression flow for bluff -> guess -> reveal -> scoreboard'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--artifact',
|
||||
help='Optional path to write smoke result artifact as JSON',
|
||||
)
|
||||
|
||||
def _fail(self, step: str, detail: str, payload=None):
|
||||
message = f'{step} failed: {detail}'
|
||||
if payload is not None:
|
||||
message += f' | payload={json.dumps(payload, sort_keys=True)}'
|
||||
raise CommandError(message)
|
||||
|
||||
def _expect_status(self, response, expected_status: int, step: str):
|
||||
if response.status_code != expected_status:
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = {'raw': response.content.decode('utf-8', errors='replace')}
|
||||
self._fail(step, f'expected HTTP {expected_status}, got {response.status_code}', payload)
|
||||
return response.json()
|
||||
|
||||
def _expect_session_status(self, payload: dict, expected_status: str, step: str):
|
||||
actual_status = payload.get('session', {}).get('status')
|
||||
if actual_status != expected_status:
|
||||
self._fail(step, f'expected session.status={expected_status}, got {actual_status}', payload)
|
||||
|
||||
def _client(self) -> Client:
|
||||
host = next((candidate for candidate in settings.ALLOWED_HOSTS if candidate and candidate != '*'), 'localhost')
|
||||
return Client(HTTP_HOST=host)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
GameSession.objects.all().delete()
|
||||
Player.objects.all().delete()
|
||||
RoundQuestion.objects.all().delete()
|
||||
|
||||
category, questions, _category_changes, _question_changes = ensure_category_with_questions(
|
||||
slug='smoke',
|
||||
name='Smoke',
|
||||
prompts_and_answers=(('Smoke prompt?', 'Correct'),),
|
||||
)
|
||||
question = questions[0]
|
||||
|
||||
host, _host_changes = ensure_host_user(username='smoke-host', password='smoke-pass')
|
||||
|
||||
artifact = {
|
||||
'ok': True,
|
||||
'command': 'python manage.py smoke_staging --artifact <path>',
|
||||
'generated_at': datetime.now(timezone.utc).isoformat(),
|
||||
'question': {
|
||||
'prompt': question.prompt,
|
||||
'correct_answer': question.correct_answer,
|
||||
},
|
||||
'steps': [],
|
||||
}
|
||||
|
||||
host_client = self._client()
|
||||
host_client.force_login(host)
|
||||
|
||||
create_payload = self._expect_status(
|
||||
host_client.post('/lobby/sessions/create', content_type='application/json'),
|
||||
201,
|
||||
'create_session',
|
||||
)
|
||||
code = create_payload['session']['code']
|
||||
artifact['session_code'] = code
|
||||
artifact['steps'].append(
|
||||
{
|
||||
'step': 'create_session',
|
||||
'session_status': create_payload['session']['status'],
|
||||
}
|
||||
)
|
||||
|
||||
players = []
|
||||
for nickname in ['P1', 'P2', 'P3']:
|
||||
join_payload = self._expect_status(
|
||||
self._client().post(
|
||||
'/lobby/sessions/join',
|
||||
data=json.dumps({'code': code, 'nickname': nickname}),
|
||||
content_type='application/json',
|
||||
),
|
||||
201,
|
||||
f'join_session[{nickname}]',
|
||||
)
|
||||
players.append(join_payload['player'])
|
||||
artifact['players'] = [player['nickname'] for player in players]
|
||||
artifact['steps'].append(
|
||||
{
|
||||
'step': 'join_players',
|
||||
'players_count': len(players),
|
||||
}
|
||||
)
|
||||
|
||||
start_payload = self._expect_status(
|
||||
host_client.post(
|
||||
f'/lobby/sessions/{code}/rounds/start',
|
||||
data=json.dumps({'category_slug': category.slug}),
|
||||
content_type='application/json',
|
||||
),
|
||||
201,
|
||||
'start_round',
|
||||
)
|
||||
self._expect_session_status(start_payload, GameSession.Status.LIE, 'start_round')
|
||||
|
||||
round_question_id = start_payload['round_question']['id']
|
||||
artifact['round_question_id'] = round_question_id
|
||||
artifact['steps'].append(
|
||||
{
|
||||
'step': 'start_round',
|
||||
'session_status': start_payload['session']['status'],
|
||||
'round_question_id': round_question_id,
|
||||
}
|
||||
)
|
||||
|
||||
answers = []
|
||||
lie_transition_payload = None
|
||||
for player in players:
|
||||
nickname = player['nickname']
|
||||
lie_payload = self._expect_status(
|
||||
self._client().post(
|
||||
f'/lobby/sessions/{code}/questions/{round_question_id}/lies/submit',
|
||||
data=json.dumps(
|
||||
{
|
||||
'player_id': player['id'],
|
||||
'session_token': player['session_token'],
|
||||
'text': f'Lie from {nickname}',
|
||||
}
|
||||
),
|
||||
content_type='application/json',
|
||||
),
|
||||
201,
|
||||
f'submit_lie[{nickname}]',
|
||||
)
|
||||
if lie_payload.get('answers'):
|
||||
answers = lie_payload['answers']
|
||||
lie_transition_payload = lie_payload
|
||||
|
||||
if not answers:
|
||||
detail_payload = self._expect_status(host_client.get(f'/lobby/sessions/{code}'), 200, 'session_detail_after_lies')
|
||||
answers = detail_payload.get('round_question', {}).get('answers', [])
|
||||
self._expect_session_status(detail_payload, GameSession.Status.GUESS, 'session_detail_after_lies')
|
||||
lie_transition_payload = detail_payload
|
||||
|
||||
if not answers:
|
||||
self._fail('auto_guess_transition', 'canonical lie->guess transition returned empty answers')
|
||||
|
||||
if not any(answer.get('text') == question.correct_answer for answer in answers):
|
||||
self._fail('auto_guess_transition', 'mixed answers missing correct answer', {'answers': answers})
|
||||
if len(answers) < len(players) + 1:
|
||||
self._fail(
|
||||
'auto_guess_transition',
|
||||
'mixed answers shorter than expected bluff set',
|
||||
{'answers': answers, 'players_count': len(players)},
|
||||
)
|
||||
|
||||
self._expect_session_status(lie_transition_payload, GameSession.Status.GUESS, 'auto_guess_transition')
|
||||
artifact['steps'].append(
|
||||
{
|
||||
'step': 'auto_guess_transition',
|
||||
'session_status': lie_transition_payload['session']['status'],
|
||||
'answers': [answer['text'] for answer in answers],
|
||||
}
|
||||
)
|
||||
|
||||
answer_texts = {answer['text'] for answer in answers}
|
||||
correct_answer = next((answer['text'] for answer in answers if answer.get('text') == question.correct_answer), None)
|
||||
if correct_answer is None:
|
||||
self._fail('submit_guesses', 'could not resolve correct answer from mixed answers', {'answers': answers})
|
||||
|
||||
guess_plan = {
|
||||
players[0]['nickname']: 'Lie from P2',
|
||||
players[1]['nickname']: correct_answer,
|
||||
players[2]['nickname']: 'Lie from P1',
|
||||
}
|
||||
missing_guess_targets = {text for text in guess_plan.values() if text not in answer_texts}
|
||||
if missing_guess_targets:
|
||||
self._fail(
|
||||
'submit_guesses',
|
||||
'expected bluff targets missing from mixed answers',
|
||||
{'answers': answers, 'missing_guess_targets': sorted(missing_guess_targets)},
|
||||
)
|
||||
artifact['guess_plan'] = guess_plan
|
||||
|
||||
guess_payloads = []
|
||||
for player in players:
|
||||
nickname = player['nickname']
|
||||
guess_payload = self._expect_status(
|
||||
self._client().post(
|
||||
f'/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit',
|
||||
data=json.dumps(
|
||||
{
|
||||
'player_id': player['id'],
|
||||
'session_token': player['session_token'],
|
||||
'selected_text': guess_plan[nickname],
|
||||
}
|
||||
),
|
||||
content_type='application/json',
|
||||
),
|
||||
201,
|
||||
f'submit_guess[{nickname}]',
|
||||
)
|
||||
guess_payloads.append(guess_payload)
|
||||
|
||||
reveal_payload = guess_payloads[-1]
|
||||
self._expect_session_status(reveal_payload, GameSession.Status.REVEAL, 'auto_reveal_transition')
|
||||
if not reveal_payload.get('phase_transition', {}).get('auto_advanced'):
|
||||
self._fail('auto_reveal_transition', 'expected auto_advanced=true on final guess', reveal_payload)
|
||||
reveal = reveal_payload.get('reveal')
|
||||
if not reveal:
|
||||
self._fail('auto_reveal_transition', 'missing canonical reveal payload', reveal_payload)
|
||||
if reveal.get('correct_answer') != question.correct_answer:
|
||||
self._fail(
|
||||
'auto_reveal_transition',
|
||||
'reveal payload returned wrong correct answer',
|
||||
{'expected': question.correct_answer, 'reveal': reveal},
|
||||
)
|
||||
if len(reveal.get('lies', [])) != len(players):
|
||||
self._fail('auto_reveal_transition', 'unexpected lie count in reveal payload', reveal)
|
||||
if len(reveal.get('guesses', [])) != len(players):
|
||||
self._fail('auto_reveal_transition', 'unexpected guess count in reveal payload', reveal)
|
||||
|
||||
fooled_guesses = [guess for guess in reveal['guesses'] if not guess.get('is_correct')]
|
||||
correct_guesses = [guess for guess in reveal['guesses'] if guess.get('is_correct')]
|
||||
if len(fooled_guesses) != 2:
|
||||
self._fail('auto_reveal_transition', 'expected exactly two bluff guesses', reveal)
|
||||
if len(correct_guesses) != 1:
|
||||
self._fail('auto_reveal_transition', 'expected exactly one correct guess', reveal)
|
||||
if any(guess.get('fooled_player_id') is None for guess in fooled_guesses):
|
||||
self._fail('auto_reveal_transition', 'bluff guesses missing fooled_player_id', reveal)
|
||||
|
||||
artifact['steps'].append(
|
||||
{
|
||||
'step': 'submit_guesses',
|
||||
'guess_results': [
|
||||
{
|
||||
'player_id': payload['guess']['player_id'],
|
||||
'selected_text': payload['guess']['selected_text'],
|
||||
'is_correct': payload['guess']['is_correct'],
|
||||
'fooled_player_id': payload['guess'].get('fooled_player_id'),
|
||||
}
|
||||
for payload in guess_payloads
|
||||
],
|
||||
}
|
||||
)
|
||||
artifact['steps'].append(
|
||||
{
|
||||
'step': 'auto_reveal_transition',
|
||||
'session_status': reveal_payload['session']['status'],
|
||||
'reveal': {
|
||||
'correct_answer': reveal['correct_answer'],
|
||||
'lies_count': len(reveal['lies']),
|
||||
'guesses_count': len(reveal['guesses']),
|
||||
'fooled_player_ids': sorted(guess['fooled_player_id'] for guess in fooled_guesses),
|
||||
'correct_guess_player_ids': sorted(guess['player_id'] for guess in correct_guesses),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
detail_payload = self._expect_status(host_client.get(f'/lobby/sessions/{code}'), 200, 'session_detail_after_guesses')
|
||||
self._expect_session_status(detail_payload, GameSession.Status.SCOREBOARD, 'auto_scoreboard_transition')
|
||||
if detail_payload.get('reveal') != reveal:
|
||||
self._fail('auto_scoreboard_transition', 'scoreboard promotion changed canonical reveal payload', detail_payload)
|
||||
scoreboard = detail_payload.get('scoreboard')
|
||||
if not scoreboard:
|
||||
self._fail('auto_scoreboard_transition', 'missing scoreboard payload after promotion', detail_payload)
|
||||
if len(scoreboard) != len(players):
|
||||
self._fail('auto_scoreboard_transition', 'unexpected scoreboard length', detail_payload)
|
||||
if not detail_payload.get('phase_view_model', {}).get('readiness', {}).get('scoreboard_ready'):
|
||||
self._fail('auto_scoreboard_transition', 'scoreboard_ready=false after promotion', detail_payload)
|
||||
|
||||
artifact['steps'].append(
|
||||
{
|
||||
'step': 'auto_scoreboard_transition',
|
||||
'session_status': detail_payload['session']['status'],
|
||||
'leaderboard': scoreboard,
|
||||
}
|
||||
)
|
||||
|
||||
finish_payload = self._expect_status(
|
||||
host_client.post(f'/lobby/sessions/{code}/finish', content_type='application/json'),
|
||||
200,
|
||||
'finish_game',
|
||||
)
|
||||
self._expect_session_status(finish_payload, GameSession.Status.FINISHED, 'finish_game')
|
||||
artifact['steps'].append(
|
||||
{
|
||||
'step': 'finish_game',
|
||||
'session_status': finish_payload['session']['status'],
|
||||
}
|
||||
)
|
||||
|
||||
artifact_path = options.get('artifact')
|
||||
if artifact_path:
|
||||
output_path = Path(artifact_path)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(artifact, indent=2) + '\n', encoding='utf-8')
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'Smoke flow OK for session {code}'))
|
||||
18
fupogfakta/migrations/0005_alter_gamesession_status.py
Normal file
18
fupogfakta/migrations/0005_alter_gamesession_status.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.2 on 2026-03-13 16:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fupogfakta', '0004_player_session_token'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='gamesession',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('lobby', 'Lobby'), ('lie', 'Løgnfase'), ('guess', 'Gættefase'), ('reveal', 'Reveal'), ('scoreboard', 'Scoreboard'), ('finished', 'Afsluttet')], default='lobby', max_length=16),
|
||||
),
|
||||
]
|
||||
26
fupogfakta/migrations/0005_gamesession_scoreboard_status.py
Normal file
26
fupogfakta/migrations/0005_gamesession_scoreboard_status.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("fupogfakta", "0004_player_session_token"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="gamesession",
|
||||
name="status",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("lobby", "Lobby"),
|
||||
("lie", "Løgnfase"),
|
||||
("guess", "Gættefase"),
|
||||
("reveal", "Reveal"),
|
||||
("scoreboard", "Scoreboard"),
|
||||
("finished", "Afsluttet"),
|
||||
],
|
||||
default="lobby",
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
]
|
||||
10
fupogfakta/migrations/0006_merge_20260315_1249.py
Normal file
10
fupogfakta/migrations/0006_merge_20260315_1249.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("fupogfakta", "0005_alter_gamesession_status"),
|
||||
("fupogfakta", "0005_gamesession_scoreboard_status"),
|
||||
]
|
||||
|
||||
operations = []
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.2 on 2026-03-17 08:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fupogfakta', '0006_merge_20260315_1249'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='roundconfig',
|
||||
name='started_from_scoreboard',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
29
fupogfakta/migrations/0008_questionlie.py
Normal file
29
fupogfakta/migrations/0008_questionlie.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 6.0.2 on 2026-03-18 13:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('fupogfakta', '0007_roundconfig_started_from_scoreboard'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='QuestionLie',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('text', models.CharField(max_length=255)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('sort_order', models.PositiveIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='fallback_lies', to='fupogfakta.question')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['sort_order', 'id'],
|
||||
'unique_together': {('question', 'text')},
|
||||
},
|
||||
),
|
||||
]
|
||||
27
fupogfakta/migrations/0009_question_scene_ornament.py
Normal file
27
fupogfakta/migrations/0009_question_scene_ornament.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("fupogfakta", "0008_questionlie"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="question",
|
||||
name="scene_ornament",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
choices=[
|
||||
("aurora-arc", "Aurora Arc"),
|
||||
("constellation-dust", "Constellation Dust"),
|
||||
("harbor-flare", "Harbor Flare"),
|
||||
("signal-bloom", "Signal Bloom"),
|
||||
("sunburst-ribbon", "Sunburst Ribbon"),
|
||||
],
|
||||
default="",
|
||||
max_length=64,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -24,9 +24,22 @@ class Category(models.Model):
|
||||
|
||||
|
||||
class Question(models.Model):
|
||||
class SceneOrnament(models.TextChoices):
|
||||
AURORA_ARC = "aurora-arc", "Aurora Arc"
|
||||
CONSTELLATION_DUST = "constellation-dust", "Constellation Dust"
|
||||
HARBOR_FLARE = "harbor-flare", "Harbor Flare"
|
||||
SIGNAL_BLOOM = "signal-bloom", "Signal Bloom"
|
||||
SUNBURST_RIBBON = "sunburst-ribbon", "Sunburst Ribbon"
|
||||
|
||||
category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="questions")
|
||||
prompt = models.TextField()
|
||||
correct_answer = models.CharField(max_length=255)
|
||||
scene_ornament = models.CharField(
|
||||
max_length=64,
|
||||
choices=SceneOrnament.choices,
|
||||
blank=True,
|
||||
default="",
|
||||
)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
@@ -36,12 +49,28 @@ 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"
|
||||
LIE = "lie", "Løgnfase"
|
||||
GUESS = "guess", "Gættefase"
|
||||
REVEAL = "reveal", "Reveal"
|
||||
SCOREBOARD = "scoreboard", "Scoreboard"
|
||||
FINISHED = "finished", "Afsluttet"
|
||||
|
||||
host = models.ForeignKey(User, on_delete=models.PROTECT, related_name="hosted_sessions")
|
||||
@@ -82,6 +111,7 @@ class RoundConfig(models.Model):
|
||||
points_bluff = models.IntegerField(default=2)
|
||||
lie_seconds = models.PositiveIntegerField(default=45)
|
||||
guess_seconds = models.PositiveIntegerField(default=30)
|
||||
started_from_scoreboard = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
unique_together = (("session", "number"),)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user