68 Commits

Author SHA1 Message Date
d86941fef8 Merge pull request '[READY][Gameplay] #310 Host transition idempotency and error catalog for scoreboard -> next round / finish' (#320) from dev/issue-310-host-transition-idempotency-v2 into main
All checks were successful
CI / test-and-quality (push) Successful in 3m30s
Reviewed-on: #320
Reviewed-by: reviewer-bot <review-no-reply@weircon.dk>
2026-03-18 06:52:03 +01:00
21e390d200 test: tighten pr320 lobby ownership guard
All checks were successful
CI / test-and-quality (push) Successful in 4m6s
CI / test-and-quality (pull_request) Successful in 4m8s
2026-03-18 06:44:54 +01:00
df9b6d192c chore: refresh i18n parity artifact
All checks were successful
CI / test-and-quality (push) Successful in 4m6s
CI / test-and-quality (pull_request) Successful in 4m6s
2026-03-18 05:19:53 +00:00
702f130de2 test(lobby): lock issue-310 transition ownership boundary
All checks were successful
CI / test-and-quality (push) Successful in 4m14s
CI / test-and-quality (pull_request) Successful in 4m15s
2026-03-18 05:00:48 +00:00
92f2cda83a test(lobby): lock scoreboard next-round bootstrap target
All checks were successful
CI / test-and-quality (push) Successful in 4m3s
CI / test-and-quality (pull_request) Successful in 4m5s
2026-03-18 04:36:20 +00:00
d080f05661 fix(ci): retain lobby payload ownership export
All checks were successful
CI / test-and-quality (push) Successful in 4m0s
CI / test-and-quality (pull_request) Successful in 4m2s
2026-03-18 04:17:17 +00:00
e246bd648f fix(gameplay): scope next-round selection to target round
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 11s
2026-03-18 03:54:25 +00:00
06e4ccac61 fix(lobby): restore scoreboard payload import
Some checks failed
CI / test-and-quality (push) Failing after 13s
CI / test-and-quality (pull_request) Failing after 14s
2026-03-18 02:55:30 +00:00
3c9214178e fix(ci): remove stale scoreboard payload import
Some checks failed
CI / test-and-quality (pull_request) Failing after 3m34s
CI / test-and-quality (push) Failing after 3m35s
2026-03-18 02:33:36 +00:00
feddd910eb fix: restore reveal payload import in submit_guess
Some checks failed
CI / test-and-quality (push) Failing after 12s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-18 02:12:11 +00:00
dd615796f4 refactor(payloads): delegate session detail gameplay payload
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-18 01:33:47 +00:00
d2cdf16322 test(lobby): lock repaired stale next-round replay
All checks were successful
CI / test-and-quality (push) Successful in 4m0s
CI / test-and-quality (pull_request) Successful in 4m1s
2026-03-18 00:46:51 +00:00
101c3f9c26 fix(gameplay): repair stale next-round question drift
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m55s
CI / test-and-quality (push) Successful in 3m56s
2026-03-17 23:25:02 +00:00
65eb5685f7 test(lobby): lock scoreboard ownership boundary
All checks were successful
CI / test-and-quality (push) Successful in 3m57s
CI / test-and-quality (pull_request) Successful in 3m58s
2026-03-17 23:07:22 +00:00
8a70645fda test(lobby): lock session detail payload delegation
All checks were successful
CI / test-and-quality (push) Successful in 3m53s
CI / test-and-quality (pull_request) Successful in 3m54s
2026-03-17 22:25:03 +00:00
2cd8d940f9 test(lobby): lock session detail ownership boundary
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m55s
CI / test-and-quality (push) Successful in 3m57s
2026-03-17 21:22:39 +00:00
72bc5997ff test(gameplay): keep lobby delegation checks in lobby suite
All checks were successful
CI / test-and-quality (push) Successful in 4m3s
CI / test-and-quality (pull_request) Successful in 4m5s
2026-03-17 21:03:50 +00:00
c9e64bc8a8 test(gameplay): lock lobby delegation for host transitions (#310)
All checks were successful
CI / test-and-quality (push) Successful in 4m4s
CI / test-and-quality (pull_request) Successful in 4m4s
2026-03-17 20:48:39 +00:00
1c7f1e7c53 fix(ci): satisfy PR #320 lobby lint contract
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m56s
CI / test-and-quality (push) Successful in 3m56s
2026-03-17 20:06:12 +00:00
03850b5ed5 refactor(gameplay): extract start/show transitions from lobby views
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 12s
2026-03-17 19:44:13 +00:00
16c9cf6b57 refactor(gameplay): extract round start payload builders
All checks were successful
CI / test-and-quality (push) Successful in 3m51s
CI / test-and-quality (pull_request) Successful in 3m53s
2026-03-17 19:15:44 +00:00
c45f04f9f1 refactor(gameplay): extract round question payload builder
All checks were successful
CI / test-and-quality (push) Successful in 3m41s
CI / test-and-quality (pull_request) Successful in 3m42s
2026-03-17 18:55:28 +00:00
319038555a refactor(gameplay): move phase view model into cartridge
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m42s
CI / test-and-quality (push) Successful in 3m44s
2026-03-17 18:29:11 +00:00
e318711148 test(gameplay): lock refreshed next-round deadline contract
All checks were successful
CI / test-and-quality (push) Successful in 3m47s
CI / test-and-quality (pull_request) Successful in 3m48s
2026-03-17 17:44:34 +00:00
a9c6e4fd79 test(lobby): lock host transition ownership boundary
All checks were successful
CI / test-and-quality (push) Successful in 3m44s
CI / test-and-quality (pull_request) Successful in 3m44s
2026-03-17 17:25:22 +00:00
7eb3507934 fix(gameplay): refresh stale next-round bootstrap config
All checks were successful
CI / test-and-quality (push) Successful in 3m39s
CI / test-and-quality (pull_request) Successful in 3m40s
2026-03-17 17:06:59 +00:00
dfa197b33b refactor(gameplay): keep host transition payloads in cartridge
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m37s
CI / test-and-quality (push) Successful in 3m38s
2026-03-17 16:06:46 +00:00
fefc5ecd56 test(lobby): lock refreshed deadline for reused bootstrap round
All checks were successful
CI / test-and-quality (push) Successful in 3m40s
CI / test-and-quality (pull_request) Successful in 3m40s
2026-03-17 15:42:00 +00:00
94f940e5d8 refactor(gameplay): delegate host transition events from service
All checks were successful
CI / test-and-quality (push) Successful in 3m35s
CI / test-and-quality (pull_request) Successful in 3m35s
2026-03-17 13:43:44 +00:00
a102a72a77 fix(gameplay): refresh reused bootstrap lie timer
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m27s
CI / test-and-quality (push) Successful in 3m27s
2026-03-17 13:21:52 +00:00
d272e35a79 refactor(gameplay): keep host transition events in payload layer
All checks were successful
CI / test-and-quality (push) Successful in 3m28s
CI / test-and-quality (pull_request) Successful in 3m28s
2026-03-17 13:04:58 +00:00
8a07433f11 refactor(gameplay): move transition event composition into service
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m36s
CI / test-and-quality (push) Successful in 3m37s
2026-03-17 11:58:39 +00:00
9baade0105 test(gameplay): lock lobby replay side-effect delegation
All checks were successful
CI / test-and-quality (push) Successful in 3m43s
CI / test-and-quality (pull_request) Successful in 3m44s
2026-03-17 11:35:19 +00:00
35e2d09ee3 test(gameplay): lock lobby host-transition delegation
All checks were successful
CI / test-and-quality (push) Successful in 3m34s
CI / test-and-quality (pull_request) Successful in 3m34s
2026-03-17 10:55:41 +00:00
a916da12a7 refactor: move scoreboard promotion out of lobby view
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m26s
CI / test-and-quality (push) Successful in 3m28s
2026-03-17 10:41:09 +00:00
7f20cb3bf9 refactor(gameplay): move scoreboard phase events into cartridge payloads
All checks were successful
CI / test-and-quality (push) Successful in 3m25s
CI / test-and-quality (pull_request) Successful in 3m27s
2026-03-17 10:13:41 +00:00
f736f4f74e refactor(gameplay): move scoreboard transitions into cartridge service
All checks were successful
CI / test-and-quality (push) Successful in 3m27s
CI / test-and-quality (pull_request) Successful in 3m28s
2026-03-17 09:29:02 +00:00
8247787404 refactor(gameplay): move transition payload builders to cartridge
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m30s
CI / test-and-quality (push) Successful in 3m31s
2026-03-17 09:08:14 +00:00
6722be43d4 merge(main): resolve PR #320 scoreboard transition conflict
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m24s
CI / test-and-quality (push) Successful in 3m26s
2026-03-17 08:45:55 +00:00
212549373b fix(gameplay): gate next-round replay on scoreboard exit marker
All checks were successful
CI / test-and-quality (push) Successful in 3m25s
CI / test-and-quality (pull_request) Successful in 3m26s
2026-03-17 08:25:57 +00:00
47659ed673 test(gameplay): guard extracted lobby helper wiring
All checks were successful
CI / test-and-quality (push) Successful in 3m26s
CI / test-and-quality (pull_request) Successful in 3m26s
2026-03-17 07:43:49 +00:00
root
c8750af4d8 fix(gameplay): restore extracted helper imports
All checks were successful
CI / test-and-quality (push) Successful in 3m29s
CI / test-and-quality (pull_request) Successful in 3m29s
2026-03-17 07:24:50 +00:00
44e480931b fix(gameplay): gate next-round replay on prior scoreboard exit
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 11s
2026-03-17 07:05:56 +00:00
8c0a561a64 Merge pull request 'refactor(fupogfakta): extract first lobby gameplay slice (#312)' (#319) from dev/issue-312-extraction-map into main
All checks were successful
CI / test-and-quality (push) Successful in 2m55s
2026-03-17 08:01:26 +01:00
1839b30e0a merge(main): resolve PR #320 gameplay conflicts
Some checks failed
CI / test-and-quality (push) Failing after 13s
CI / test-and-quality (pull_request) Failing after 14s
2026-03-17 06:44:21 +00:00
7de843e44b fix(lobby): use extracted fupogfakta helpers
All checks were successful
CI / test-and-quality (push) Successful in 3m32s
CI / test-and-quality (pull_request) Successful in 3m36s
2026-03-17 06:21:33 +00:00
542d326615 fix(gameplay): gate next-round replay on prior transition
All checks were successful
CI / test-and-quality (push) Successful in 3m24s
CI / test-and-quality (pull_request) Successful in 3m27s
2026-03-17 06:21:00 +00:00
e39605d782 merge(main): resolve PR #319 lobby extraction conflict
Some checks failed
CI / test-and-quality (push) Failing after 11s
CI / test-and-quality (pull_request) Failing after 11s
2026-03-17 05:58:51 +00:00
d36d256daf fix(gameplay): make scoreboard host exits idempotent
All checks were successful
CI / test-and-quality (push) Successful in 3m44s
CI / test-and-quality (pull_request) Successful in 3m45s
2026-03-17 05:41:13 +00:00
2ee235c6c0 refactor(fupogfakta): extract first lobby gameplay slice (#312)
All checks were successful
CI / test-and-quality (push) Successful in 3m8s
CI / test-and-quality (pull_request) Successful in 3m13s
2026-03-17 05:37:31 +00:00
592c265331 docs(architecture): map lobby vs fupogfakta extraction boundary refs #311 #312 2026-03-16 18:57:29 +00:00
251ccfce19 Merge pull request 'fix(frontend): prefer canonical phase for client action gating (#301 follow-up)' (#306) from dev/issue-301-phase-action-gating-followup into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
CI / test-and-quality (pull_request) Successful in 3m36s
2026-03-16 18:09:04 +01:00
d9c4cda966 fix(frontend): prefer canonical phase for client action gating
All checks were successful
CI / test-and-quality (push) Successful in 2m56s
CI / test-and-quality (pull_request) Successful in 3m1s
2026-03-16 17:00:02 +00:00
2437f0e8bd Merge pull request 'test(gameplay): add canonical loop smoke evidence (#302)' (#304) from dev/issue-302-canonical-loop-evidence into main
All checks were successful
CI / test-and-quality (push) Successful in 2m43s
2026-03-16 17:31:23 +01:00
3b4b844126 chore(ci): retrigger canonical loop evidence checks
All checks were successful
CI / test-and-quality (push) Successful in 3m9s
CI / test-and-quality (pull_request) Successful in 3m10s
2026-03-16 15:52:54 +00:00
c8c17654a4 Merge pull request 'fix(gameplay): harden scoreboard -> next round bootstrap invariants (#300)' (#305) from dev/issue-300-round-bootstrap-invariants-v2 into main
All checks were successful
CI / test-and-quality (push) Successful in 2m39s
2026-03-16 16:44:22 +01:00
fd6e3e86e8 ci: repair rollup optional dep on npm ci
Some checks failed
CI / test-and-quality (pull_request) Successful in 3m35s
CI / test-and-quality (push) Failing after 4m8s
2026-03-16 15:35:49 +00:00
7c0332f95f fix(gameplay): harden scoreboard to round bootstrap invariants (#300)
All checks were successful
CI / test-and-quality (push) Successful in 3m20s
CI / test-and-quality (pull_request) Successful in 2m52s
2026-03-16 15:22:03 +00:00
9970257f32 test(gameplay): add canonical loop smoke evidence (#302)
Some checks failed
CI / test-and-quality (push) Failing after 3m42s
CI / test-and-quality (pull_request) Successful in 3m36s
2026-03-16 15:20:06 +00:00
112a85a22d Merge pull request 'fix(gameplay): gate client actions from canonical phase state (#301)' (#303) from dev/issue-301-client-action-gating into main
All checks were successful
CI / test-and-quality (push) Successful in 2m36s
2026-03-16 15:53:44 +01:00
33b428955b test(frontend): install angular spec runtime in root suite
All checks were successful
CI / test-and-quality (push) Successful in 3m8s
CI / test-and-quality (pull_request) Successful in 3m9s
2026-03-16 13:53:00 +00:00
55fc758389 test(gameplay): stabilize canonical host gating specs
All checks were successful
CI / test-and-quality (push) Successful in 3m9s
CI / test-and-quality (pull_request) Successful in 3m9s
2026-03-16 13:33:49 +00:00
f0142f33b6 test(issue-301): align host gating specs with canonical phases
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m3s
CI / test-and-quality (push) Successful in 3m5s
2026-03-16 12:50:33 +00:00
3acaf3e370 test(frontend): include angular specs in vitest suite
Some checks failed
CI / test-and-quality (push) Failing after 3m6s
CI / test-and-quality (pull_request) Failing after 3m6s
2026-03-16 12:06:57 +00:00
1cb36a5943 merge(main): resolve PR #303 conflicts
Some checks failed
CI / test-and-quality (push) Failing after 3m6s
CI / test-and-quality (pull_request) Failing after 3m8s
2026-03-16 11:53:56 +00:00
fc68e30cf4 fix(frontend): restore phase-gating build
All checks were successful
CI / test-and-quality (push) Successful in 2m32s
CI / test-and-quality (pull_request) Successful in 2m32s
2026-03-16 11:29:45 +00:00
57ca237565 fix(issue-301): gate client actions from canonical phase flags
All checks were successful
CI / test-and-quality (push) Successful in 2m20s
CI / test-and-quality (pull_request) Successful in 2m28s
2026-03-16 10:28:12 +00:00
076faf2ff1 feat: gate client actions by canonical phase state 2026-03-16 10:15:35 +00:00
26 changed files with 3314 additions and 718 deletions

View File

@@ -38,7 +38,10 @@ jobs:
node-version: "22"
- name: Install SPA dependencies
run: npm ci --prefix frontend/angular
run: |
npm ci --prefix frontend/angular
node -e "require('./frontend/angular/node_modules/rollup/dist/native.js')" \
|| npm install --prefix frontend/angular
- name: SPA Angular smoke tests
run: npm --prefix frontend/angular test

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

View 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

View File

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

View File

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

View File

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

View File

@@ -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,9 +14,14 @@ function jsonResponse(status: number, body: unknown) {
} as unknown as Response;
}
function createFetchRouteMock(handler: FetchRouteHandler): FetchMock {
return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init)));
}
function sessionDetailPayload(
status: string,
options?: {
currentPhase?: string;
roundQuestionId?: number | null;
reveal?: {
correct_answer: string;
@@ -75,6 +82,7 @@ function sessionDetailPayload(
},
phase_view_model: {
status,
current_phase: options?.currentPhase ?? status,
round_number: 1,
players_count: 2,
constraints: {
@@ -83,14 +91,18 @@ function sessionDetailPayload(
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: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
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',
@@ -179,18 +191,81 @@ describe('HostShellComponent gameplay wiring', () => {
});
});
it('runs next-round transition into canonical lie phase and clears prior final leaderboard state', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } }))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 })));
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);
const component = new HostShellComponent();
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('runs next-round transition without reload and clears scoreboard payload', async () => {
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);
const component = new HostShellComponent();
component.sessionCode = ' abcd12 ';
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();
@@ -227,6 +302,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');
@@ -250,6 +326,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();
@@ -259,6 +336,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);
@@ -290,10 +438,17 @@ describe('HostShellComponent gameplay wiring', () => {
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);
});

View File

@@ -3,7 +3,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client';
import type { FinishGameResponse, SessionDetailResponse } from '../../../../../src/api/types';
import type { FinishGameResponse, ScoreboardResponse, SessionDetailResponse } from '../../../../../src/api/types';
import { deriveGameplayPhase, isHostGameplayActionAllowed } from '../../../../../src/spa/gameplay-phase-machine';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
@@ -23,9 +24,16 @@ type LeaderboardResponse = FinishGameResponse;
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
<label *ngIf="canStartRound">{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button *ngIf="canStartRound" (click)="startRound()" [disabled]="loading">{{ copy('host.start_round') }}</button>
<button *ngIf="canStartNextRound || nextRoundError" (click)="startNextRound()" [disabled]="loading">{{ copy(nextRoundError ? 'host.retry_next_round' : 'host.start_next_round') }}</button>
<button *ngIf="canFinishGame || finishError" (click)="finishGame()" [disabled]="loading">{{ copy(finishError ? 'host.retry_finish' : 'host.finish_game') }}</button>
<button (click)="startRound()" [disabled]="loading || !canStartRound">{{ copy('host.start_round') }}</button>
<button (click)="showQuestion()" [disabled]="loading || !canShowQuestion">{{ copy('host.show_question') }}</button>
<button (click)="mixAnswers()" [disabled]="loading || !canMixAnswers">{{ copy('host.mix_answers') }}</button>
<button (click)="calculateScores()" [disabled]="loading || !canCalculateScores">{{ copy('host.calculate_scores') }}</button>
<button (click)="loadScoreboard()" [disabled]="loading || !canLoadScoreboard">{{ copy('host.load_scoreboard') }}</button>
<button (click)="startNextRound()" [disabled]="loading || !canStartNextRound">{{ copy('host.start_next_round') }}</button>
<button (click)="finishGame()" [disabled]="loading || !canFinishGame">{{ copy('host.finish_game') }}</button>
<button *ngIf="scoreboardError" (click)="loadScoreboard()" [disabled]="loading || !canLoadScoreboard">{{ copy('host.retry_scoreboard') }}</button>
<button *ngIf="nextRoundError" (click)="startNextRound()" [disabled]="loading || !canStartNextRound">{{ copy('host.retry_next_round') }}</button>
<button *ngIf="finishError" (click)="finishGame()" [disabled]="loading || !canFinishGame">{{ copy('host.retry_finish') }}</button>
</div>
<p *ngIf="session" class="hint">{{ copy('host.audio_locale_hint') }}: {{ locale }}</p>
@@ -40,7 +48,7 @@ type LeaderboardResponse = FinishGameResponse;
<ul>
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
</ul>
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
<div class="panel" *ngIf="showRevealPanel">
<h3>Reveal</h3>
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
@@ -82,8 +90,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
roundQuestionId = '';
loading = false;
error = '';
scoreboardError = '';
nextRoundError = '';
finishError = '';
scoreboardPayload = '';
finalLeaderboardPayload = '';
finalLeaderboard: LeaderboardEntry[] = [];
finalWinner: LeaderboardEntry | null = null;
@@ -121,16 +131,40 @@ export class HostShellComponent implements OnInit, OnDestroy {
this.unsubscribeLocale = null;
}
get gameplayPhase(): string | null {
return deriveGameplayPhase(this.session as any);
}
get canStartRound(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_start_round ?? !this.session);
return isHostGameplayActionAllowed(this.session as any, 'startRound');
}
get canShowQuestion(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'showQuestion');
}
get canMixAnswers(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'mixAnswers');
}
get canCalculateScores(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'calculateScores');
}
get canLoadScoreboard(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'loadScoreboard');
}
get canStartNextRound(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_start_next_round);
return isHostGameplayActionAllowed(this.session as any, 'startNextRound');
}
get canFinishGame(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_finish_game);
return isHostGameplayActionAllowed(this.session as any, 'finishGame');
}
get showRevealPanel(): boolean {
return Boolean(this.session?.reveal && (this.gameplayPhase === 'reveal' || this.gameplayPhase === 'scoreboard'));
}
copy(key: string): string {
@@ -169,6 +203,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
async refreshSession(): Promise<void> {
this.loading = true;
this.error = '';
this.scoreboardError = '';
this.nextRoundError = '';
this.finishError = '';
try {
@@ -192,6 +227,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
async startRound(): Promise<void> {
if (!this.canStartRound) {
return;
}
await this.runAction(async () => {
const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim());
if (!state.session || state.errorMessage) {
@@ -206,7 +245,69 @@ export class HostShellComponent implements OnInit, OnDestroy {
});
}
async showQuestion(): Promise<void> {
if (!this.canShowQuestion) {
return;
}
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {});
await this.refreshSession();
});
}
async mixAnswers(): Promise<void> {
if (!this.canMixAnswers) {
return;
}
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
const roundQuestionId = this.roundQuestionId.trim();
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/answers/mix`, 'POST', {});
await this.refreshSession();
});
}
async calculateScores(): Promise<void> {
if (!this.canCalculateScores) {
return;
}
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
const roundQuestionId = this.roundQuestionId.trim();
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/scores/calculate`, 'POST', {});
await this.refreshSession();
});
}
async loadScoreboard(): Promise<void> {
if (!this.canLoadScoreboard) {
return;
}
this.loading = true;
this.scoreboardError = '';
this.error = '';
try {
const code = this.normalizeCode(this.sessionCode);
const payload = await this.request<ScoreboardResponse>(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET');
this.scoreboardPayload = JSON.stringify(payload, null, 2);
await this.refreshSession();
} catch (error) {
this.scoreboardError = `${this.copy('host.scoreboard_failed')}: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
async startNextRound(): Promise<void> {
if (!this.canStartNextRound) {
return;
}
this.loading = true;
this.nextRoundError = '';
this.error = '';
@@ -226,6 +327,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
async finishGame(): Promise<void> {
if (!this.canFinishGame) {
return;
}
this.loading = true;
this.finishError = '';
this.error = '';
@@ -252,6 +357,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
private resetFinalLeaderboard(): void {
this.scoreboardPayload = '';
this.finalLeaderboardPayload = '';
this.finalLeaderboard = [];
this.finalWinner = null;
@@ -262,7 +368,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
return;
}
const phase = this.session.session.status || 'lobby';
const phase = this.gameplayPhase ?? this.session.session.status ?? 'lobby';
const code = this.normalizeCode(this.session.session.code || this.sessionCode);
if (!code) {
return;

View File

@@ -16,6 +16,7 @@ function jsonResponse(status: number, body: unknown) {
function sessionDetailPayload(
status: string,
options?: {
currentPhase?: string;
answers?: string[];
players?: Array<{ id: number; nickname: string; score: number }>;
roundQuestionId?: number | null;
@@ -79,6 +80,7 @@ function sessionDetailPayload(
},
phase_view_model: {
status,
current_phase: options?.currentPhase ?? status,
round_number: 1,
players_count: (options?.players ?? []).length,
constraints: {
@@ -87,6 +89,10 @@ function sessionDetailPayload(
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: false,
can_show_question: false,
@@ -97,10 +103,10 @@ function sessionDetailPayload(
can_finish_game: false,
},
player: {
can_join: status === 'lobby',
can_submit_lie: status === 'lie',
can_submit_guess: status === 'guess',
can_view_final_result: status === 'finished',
can_join: (options?.currentPhase ?? status) === 'lobby',
can_submit_lie: (options?.currentPhase ?? status) === 'lie',
can_submit_guess: (options?.currentPhase ?? status) === 'guess',
can_view_final_result: (options?.currentPhase ?? status) === 'finished',
},
},
};
@@ -147,9 +153,8 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.sessionToken = 'token-1';
component.lieText = 'my lie';
component.session = {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [] },
players: [],
};
await component.submitLie();
@@ -268,9 +273,8 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.sessionToken = 'token-1';
component.selectedGuess = 'B';
component.session = {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
...(sessionDetailPayload('guess', { answers: ['A', 'B'], roundQuestionId: 11 }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
players: [],
};
await component.submitGuess();
@@ -294,6 +298,29 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('blocks illegal player guess submission outside canonical guess phase', async () => {
const fetchMock: FetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'token-1';
component.selectedGuess = 'B';
for (const status of ['lie', 'reveal', 'scoreboard'] as const) {
component.session = {
...(sessionDetailPayload(status, { answers: ['A', 'B'] }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
};
await component.submitGuess();
}
expect(component.canSubmitGuess).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it('auto-refreshes player session to avoid host/player state desync between rounds', async () => {
vi.useFakeTimers();
@@ -416,6 +443,34 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(values.get('wpp.session-context')).toBeUndefined();
});
it('prefers canonical current_phase for player reveal panel and routing when status lags behind', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'scoreboard', roundQuestionId: 11, reveal: { correct_answer: 'A' } }))
);
vi.stubGlobal('fetch', fetchMock);
const replaceState = vi.fn();
const localStorage = { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() };
vi.stubGlobal('window', {
location: { hash: '#/player/reveal/ABCD12' },
history: { state: null, replaceState },
localStorage,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.gameplayPhase).toBe('scoreboard');
expect(component.showRevealPanel).toBe(true);
expect(component.showGuessControls).toBe(false);
expect(replaceState).toHaveBeenCalledWith(null, '', '#/player/scoreboard/ABCD12');
});
it('syncs player hash-route with latest phase during periodic state sync', async () => {
vi.useFakeTimers();

View File

@@ -4,6 +4,10 @@ import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client';
import type { SessionDetailResponse } from '../../../../../src/api/types';
import {
deriveGameplayPhase,
isPlayerGameplayActionAllowed,
} from '../../../../../src/spa/gameplay-phase-machine';
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
@@ -69,9 +73,9 @@ function resolveLocalStorage(): Storage | undefined {
<p *ngIf="session.round_question"><strong>{{ copy('common.prompt') }}:</strong> {{ session.round_question.prompt }}</p>
<ng-container *ngIf="showLieControls">
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading" /></label>
<button (click)="submitLie()" [disabled]="loading">{{ copy('player.submit_lie') }}</button>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading">{{ copy('player.retry_lie_submit') }}</button>
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading || !canSubmitLie" /></label>
<button (click)="submitLie()" [disabled]="loading || !canSubmitLie">{{ copy('player.submit_lie') }}</button>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading || !canSubmitLie">{{ copy('player.retry_lie_submit') }}</button>
</ng-container>
<ng-container *ngIf="showGuessControls">
@@ -81,17 +85,17 @@ function resolveLocalStorage(): Storage | undefined {
*ngFor="let answer of session.round_question?.answers"
(click)="selectedGuess = answer.text"
[class.active]="selectedGuess === answer.text"
[disabled]="loading"
[disabled]="loading || !canSubmitGuess"
>
{{ answer.text }}
</button>
</div>
<button (click)="submitGuess()" [disabled]="loading || !selectedGuess">{{ copy('player.submit_guess') }}</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button>
<button (click)="submitGuess()" [disabled]="loading || !canSubmitGuess || !selectedGuess">{{ copy('player.submit_guess') }}</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading || !canSubmitGuess">{{ copy('player.retry_guess_submit') }}</button>
</ng-container>
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
<div class="panel" *ngIf="showRevealPanel">
<h3>Reveal</h3>
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
@@ -205,6 +209,22 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.restoreAudioGuard = null;
}
get gameplayPhase(): string | null {
return deriveGameplayPhase(this.session as any);
}
get canSubmitLie(): boolean {
return isPlayerGameplayActionAllowed(this.session as any, 'submitLie');
}
get canSubmitGuess(): boolean {
return isPlayerGameplayActionAllowed(this.session as any, 'submitGuess');
}
get showRevealPanel(): boolean {
return Boolean(this.session?.reveal && (this.gameplayPhase === 'reveal' || this.gameplayPhase === 'scoreboard'));
}
private readonly handleOnline = (): void => {
this.connectionState = 'reconnecting';
void this.retryReconnect();
@@ -453,7 +473,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
return;
}
const phase = this.session.session.status || 'lobby';
const phase = this.gameplayPhase ?? this.session.session.status ?? 'lobby';
const code = this.normalizeCode(this.session.session.code || this.sessionCode);
if (!code) {
return;
@@ -543,7 +563,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
async submitLie(): Promise<void> {
if (!this.session?.round_question?.id) {
if (!this.session?.round_question?.id || !this.canSubmitLie) {
return;
}
this.loading = true;
@@ -571,7 +591,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
async submitGuess(): Promise<void> {
if (!this.session?.round_question?.id || !this.selectedGuess) {
if (!this.session?.round_question?.id || !this.selectedGuess || !this.canSubmitGuess) {
return;
}
this.loading = true;

View File

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

View File

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

View File

@@ -195,6 +195,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
reveal,
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: {
@@ -203,6 +204,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'),

View File

@@ -32,6 +32,7 @@ export interface SessionRoundQuestion {
export interface PhaseViewModel {
status: string;
current_phase?: string;
round_number: number;
players_count: number;
constraints: {
@@ -40,6 +41,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;

View File

@@ -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);
}
}

View File

@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import {
allowedGameplayEvents,
deriveGameplayPhase,
isHostGameplayActionAllowed,
isPlayerGameplayActionAllowed,
transitionGameplayPhase,
type GameplayPhase
} from '../src/spa/gameplay-phase-machine';
@@ -105,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);
});
});

View File

@@ -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/**']
}
});

View File

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

View File

@@ -83,6 +83,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"),)

275
fupogfakta/payloads.py Normal file
View File

@@ -0,0 +1,275 @@
from datetime import timedelta
from .models import GameSession, Player, RoundConfig, RoundQuestion
def build_player_ref(player: Player | None) -> dict | None:
if player is None:
return None
return {
"player_id": player.id,
"nickname": player.nickname,
}
def build_round_question_payload(round_question: RoundQuestion | None) -> dict | None:
if round_question is None:
return None
return {
"id": round_question.id,
"round_number": round_question.round_number,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"answers": [{"text": text} for text in (round_question.mixed_answers or [])],
}
def build_reveal_payload(round_question: RoundQuestion | None) -> dict | None:
if round_question is None:
return None
lies = [
{
**build_player_ref(lie.player),
"text": lie.text,
"created_at": lie.created_at.isoformat(),
}
for lie in round_question.lies.select_related("player").order_by("created_at", "id")
]
guesses = []
for guess in round_question.guesses.select_related("player", "fooled_player").order_by("created_at", "id"):
guess_payload = {
**build_player_ref(guess.player),
"selected_text": guess.selected_text,
"is_correct": guess.is_correct,
"created_at": guess.created_at.isoformat(),
"fooled_player_id": guess.fooled_player_id,
}
if guess.fooled_player is not None:
guess_payload["fooled_player_nickname"] = guess.fooled_player.nickname
guesses.append(guess_payload)
return {
"round_question_id": round_question.id,
"round_number": round_question.round_number,
"prompt": round_question.question.prompt,
"correct_answer": round_question.correct_answer,
"lies": lies,
"guesses": guesses,
}
def build_leaderboard(session: GameSession) -> list[dict]:
return list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
def build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict:
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
return {
"round_number": session.current_round,
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
"lie_seconds": round_config.lie_seconds,
}
def build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
status = session.status
in_lobby = status == GameSession.Status.LOBBY
in_lie = status == GameSession.Status.LIE
in_guess = status == GameSession.Status.GUESS
in_scoreboard = status == GameSession.Status.SCOREBOARD
in_finished = status == GameSession.Status.FINISHED
min_players_reached = players_count >= 3
max_players_allowed = players_count <= 5
return {
"status": status,
"current_phase": status,
"round_number": session.current_round,
"players_count": players_count,
"constraints": {
"min_players_to_start": 3,
"max_players_mvp": 5,
"min_players_reached": min_players_reached,
"max_players_allowed": max_players_allowed,
},
"readiness": {
"question_ready": has_round_question,
"scoreboard_ready": status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED},
"can_advance_to_next_round": in_scoreboard,
},
"host": {
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
"can_show_question": False,
"can_mix_answers": False,
"can_calculate_scores": False,
"can_reveal_scoreboard": False,
"can_start_next_round": in_scoreboard,
"can_finish_game": in_scoreboard,
},
"player": {
"can_join": status in {
GameSession.Status.LOBBY,
GameSession.Status.LIE,
GameSession.Status.GUESS,
GameSession.Status.REVEAL,
GameSession.Status.SCOREBOARD,
},
"can_submit_lie": in_lie and has_round_question,
"can_submit_guess": in_guess and has_round_question,
"can_view_final_result": in_finished,
},
}
def build_session_detail_gameplay_payload(
session: GameSession,
*,
current_round_question: RoundQuestion | None,
players_count: int,
) -> dict:
return {
"round_question": build_round_question_payload(current_round_question),
"reveal": build_reveal_payload(current_round_question)
if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question
else None,
"scoreboard": build_scoreboard_phase_event(session)["payload"]["leaderboard"]
if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}
else None,
"phase_view_model": build_phase_view_model(
session,
players_count=players_count,
has_round_question=bool(current_round_question),
),
}
def build_start_round_response(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
lie_started_payload = build_lie_started_payload(session, round_config, round_question)
return {
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"round": {
"number": round_config.number,
"category": {
"slug": round_config.category.slug,
"name": round_config.category.name,
},
},
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_started_payload["lie_deadline_at"],
},
"config": {
"lie_seconds": round_config.lie_seconds,
},
}
def build_question_shown_payload(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict:
return {
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at,
"lie_seconds": lie_seconds,
}
def build_question_shown_response(round_question: RoundQuestion, lie_deadline_at: str, lie_seconds: int) -> dict:
return {
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at,
},
"config": {
"lie_seconds": lie_seconds,
},
}
def build_start_next_round_response(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
return build_start_round_response(session, round_config, round_question)
def build_start_next_round_phase_event(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> dict:
return {
"name": "phase.lie_started",
"payload": build_lie_started_payload(session, round_config, round_question),
}
def build_scoreboard_phase_event(session: GameSession, leaderboard: list[dict] | None = None) -> dict:
return {
"name": "phase.scoreboard",
"payload": {
"leaderboard": leaderboard if leaderboard is not None else build_leaderboard(session),
"current_round": session.current_round,
},
}
def build_reveal_scoreboard_response(session: GameSession, leaderboard: list[dict]) -> dict:
return {
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"leaderboard": leaderboard,
}
def build_finish_game_phase_event(session: GameSession) -> dict:
leaderboard = build_leaderboard(session)
winner = leaderboard[0] if leaderboard else None
return {
"name": "phase.game_over",
"payload": {"winner": winner, "leaderboard": leaderboard},
}
def build_finish_game_response(session: GameSession) -> dict:
finish_event = build_finish_game_phase_event(session)
return {
"session": {
"code": session.code,
"status": GameSession.Status.FINISHED,
"current_round": session.current_round,
},
"winner": finish_event["payload"]["winner"],
"leaderboard": finish_event["payload"]["leaderboard"],
}

479
fupogfakta/services.py Normal file
View File

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

View File

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

View File

@@ -10,7 +10,7 @@ from fupogfakta.models import Category, GameSession, Player, Question, RoundQues
class Command(BaseCommand):
help = "Run minimal staging smoke flow for lobby gameplay"
help = "Run canonical gameplay smoke/regression flow for bluff -> guess -> reveal -> scoreboard"
def add_arguments(self, parser):
parser.add_argument(
@@ -18,6 +18,26 @@ class Command(BaseCommand):
help="Optional path to write smoke result artifact as JSON",
)
def _fail(self, step: str, detail: str, payload=None):
message = f"{step} failed: {detail}"
if payload is not None:
message += f" | payload={json.dumps(payload, sort_keys=True)}"
raise CommandError(message)
def _expect_status(self, response, expected_status: int, step: str):
if response.status_code != expected_status:
try:
payload = response.json()
except ValueError:
payload = {"raw": response.content.decode("utf-8", errors="replace")}
self._fail(step, f"expected HTTP {expected_status}, got {response.status_code}", payload)
return response.json()
def _expect_session_status(self, payload: dict, expected_status: str, step: str):
actual_status = payload.get("session", {}).get("status")
if actual_status != expected_status:
self._fail(step, f"expected session.status={expected_status}, got {actual_status}", payload)
def handle(self, *args, **options):
GameSession.objects.all().delete()
Player.objects.all().delete()
@@ -30,11 +50,14 @@ class Command(BaseCommand):
category.is_active = True
category.save(update_fields=["is_active"])
Question.objects.get_or_create(
question, _ = Question.objects.get_or_create(
category=category,
prompt="Smoke prompt?",
defaults={"correct_answer": "Correct", "is_active": True},
)
if not question.is_active:
question.is_active = True
question.save(update_fields=["is_active"])
User = get_user_model()
host, _ = User.objects.get_or_create(username="smoke-host")
@@ -42,111 +65,254 @@ class Command(BaseCommand):
host.is_staff = True
host.save()
artifact = {
"ok": True,
"command": "python manage.py smoke_staging --artifact <path>",
"generated_at": datetime.now(timezone.utc).isoformat(),
"question": {
"prompt": question.prompt,
"correct_answer": question.correct_answer,
},
"steps": [],
}
host_client = Client()
host_client.force_login(host)
create_res = host_client.post("/lobby/sessions/create", content_type="application/json")
if create_res.status_code != 201:
raise CommandError(f"create_session failed: {create_res.status_code} {create_res.content!r}")
code = create_res.json()["session"]["code"]
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_res = Client().post(
join_payload = self._expect_status(
Client().post(
"/lobby/sessions/join",
data=json.dumps({"code": code, "nickname": nickname}),
content_type="application/json",
),
201,
f"join_session[{nickname}]",
)
players.append(join_payload["player"])
artifact["players"] = [player["nickname"] for player in players]
artifact["steps"].append(
{
"step": "join_players",
"players_count": len(players),
}
)
if join_res.status_code != 201:
raise CommandError(f"join_session failed for {nickname}: {join_res.status_code}")
players.append(join_res.json()["player"])
start_res = host_client.post(
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",
)
if start_res.status_code != 201:
raise CommandError(f"start_round failed: {start_res.status_code}")
self._expect_session_status(start_payload, GameSession.Status.LIE, "start_round")
round_question_id = start_res.json()["round_question"]["id"]
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:
nick = player["nickname"]
lie_res = Client().post(
nickname = player["nickname"]
lie_payload = self._expect_status(
Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"text": f"Lie from {nick}",
"text": f"Lie from {nickname}",
}
),
content_type="application/json",
),
201,
f"submit_lie[{nickname}]",
)
if lie_res.status_code != 201:
raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}")
if lie_res.json().get("answers"):
answers = lie_res.json()["answers"]
if lie_payload.get("answers"):
answers = lie_payload["answers"]
lie_transition_payload = lie_payload
if not answers:
detail_res = host_client.get(f"/lobby/sessions/{code}")
if detail_res.status_code != 200:
raise CommandError(f"session_detail after lies failed: {detail_res.status_code}")
answers = detail_res.json().get("round_question", {}).get("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:
raise CommandError("canonical lie->guess transition returned empty 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:
nick = player["nickname"]
selected = next((a for a in answers if a.get("player_id") != player["id"]), answers[0])
guess_res = Client().post(
nickname = player["nickname"]
guess_payload = self._expect_status(
Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"selected_text": selected["text"],
"selected_text": guess_plan[nickname],
}
),
content_type="application/json",
),
201,
f"submit_guess[{nickname}]",
)
if guess_res.status_code != 201:
raise CommandError(f"submit_guess failed for {nick}: {guess_res.status_code}")
guess_payloads.append(guess_payload)
detail_res = host_client.get(f"/lobby/sessions/{code}")
if detail_res.status_code != 200:
raise CommandError(f"session_detail after guesses failed: {detail_res.status_code}")
if detail_res.json()["session"]["status"] != GameSession.Status.SCOREBOARD:
raise CommandError("canonical guess->reveal->scoreboard transition did not reach scoreboard")
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)
finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json")
if finish_res.status_code != 200:
raise CommandError(f"finish_game failed: {finish_res.status_code}")
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:
artifact = {
"ok": True,
"command": "smoke_staging",
"generated_at": datetime.now(timezone.utc).isoformat(),
"session_code": code,
"players": [player["nickname"] for player in players],
"round_question_id": round_question_id,
"steps": [
"create_session",
"join_players",
"start_round",
"submit_lies",
"auto_guess_transition",
"submit_guesses",
"auto_reveal_to_scoreboard",
"finish_game",
],
}
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")

View File

@@ -1,3 +1,4 @@
import inspect
import json
import tempfile
from datetime import timedelta
@@ -10,6 +11,7 @@ from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from fupogfakta import payloads as gameplay_payloads, services as gameplay_services
from fupogfakta.models import (
Category,
GameSession,
@@ -21,11 +23,343 @@ from fupogfakta.models import (
RoundQuestion,
ScoreEvent,
)
from lobby import views as lobby_views
from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale
User = get_user_model()
class LobbyGameplayExtractionTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="extract_host", password="secret123")
self.client.login(username="extract_host", password="secret123")
self.session = GameSession.objects.create(
host=self.host,
code="EXTR42",
status=GameSession.Status.SCOREBOARD,
)
self.category = Category.objects.create(name="Historie", slug="historie-extract", is_active=True)
self.round_config = RoundConfig.objects.create(
session=self.session,
number=1,
category=self.category,
)
self.question = Question.objects.create(
category=self.category,
prompt="Hvornår faldt muren?",
correct_answer="1989",
is_active=True,
)
def test_lobby_views_use_extracted_gameplay_helpers(self):
self.assertIs(lobby_views._get_current_round_question, gameplay_services.get_current_round_question)
self.assertIs(lobby_views._select_round_question, gameplay_services.select_round_question)
self.assertIs(lobby_views._prepare_mixed_answers, gameplay_services.prepare_mixed_answers)
self.assertIs(lobby_views._resolve_scores, gameplay_services.resolve_scores)
self.assertIs(lobby_views._promote_reveal_to_scoreboard, gameplay_services.promote_reveal_to_scoreboard)
self.assertIs(lobby_views._start_round, gameplay_services.start_round)
self.assertIs(lobby_views._show_question, gameplay_services.show_question)
self.assertIs(lobby_views._start_next_round, gameplay_services.start_next_round)
self.assertIs(lobby_views._finish_game, gameplay_services.finish_game)
self.assertIs(lobby_views._build_session_detail_gameplay_payload, gameplay_payloads.build_session_detail_gameplay_payload)
self.assertIs(lobby_views._build_scoreboard_phase_event, gameplay_payloads.build_scoreboard_phase_event)
def test_start_round_view_source_stays_http_thin(self):
source = inspect.getsource(inspect.unwrap(lobby_views.start_round))
self.assertIn("transition = _start_round(session, category_slug)", source)
self.assertNotIn("RoundConfig", source)
self.assertNotIn("RoundQuestion", source)
self.assertNotIn("build_start_round_response", source)
def test_show_question_view_source_stays_http_thin(self):
source = inspect.getsource(inspect.unwrap(lobby_views.show_question))
self.assertIn("transition = _show_question(session)", source)
self.assertNotIn("RoundConfig", source)
self.assertNotIn("RoundQuestion", source)
self.assertNotIn("build_question_shown_response", source)
def test_start_next_round_view_source_stays_http_thin(self):
source = inspect.getsource(inspect.unwrap(lobby_views.start_next_round))
self.assertIn("transition = _start_next_round(session)", source)
self.assertNotIn("RoundConfig", source)
self.assertNotIn("RoundQuestion", source)
self.assertNotIn("build_start_next_round_response", source)
self.assertNotIn("build_start_next_round_phase_event", source)
def test_finish_game_view_source_stays_http_thin(self):
source = inspect.getsource(inspect.unwrap(lobby_views.finish_game))
self.assertIn("transition = _finish_game(session)", source)
self.assertNotIn("RoundConfig", source)
self.assertNotIn("RoundQuestion", source)
self.assertNotIn("build_finish_game_response", source)
self.assertNotIn("build_finish_game_phase_event", source)
def test_reveal_scoreboard_view_source_stays_http_thin(self):
source = inspect.getsource(inspect.unwrap(lobby_views.reveal_scoreboard))
self.assertIn("transition = _promote_reveal_to_scoreboard(session)", source)
self.assertNotIn("Player.objects.filter(session=session)", source)
self.assertNotIn("ScoreEvent.objects.filter", source)
self.assertNotIn("build_reveal_scoreboard_response", source)
self.assertNotIn("build_scoreboard_phase_event", source)
def test_issue_310_transition_views_keep_gameplay_logic_out_of_lobby(self):
transition_sources = {
"reveal_scoreboard": inspect.getsource(inspect.unwrap(lobby_views.reveal_scoreboard)),
"start_next_round": inspect.getsource(inspect.unwrap(lobby_views.start_next_round)),
"finish_game": inspect.getsource(inspect.unwrap(lobby_views.finish_game)),
}
forbidden_snippets = (
"RoundConfig.objects.get_or_create(",
"RoundConfig.objects.create(",
"RoundQuestion.objects.create(",
"select_round_question(",
"reset_round_question_bootstrap_state(",
"session.current_round =",
"session.status = GameSession.Status.LIE",
"session.status = GameSession.Status.SCOREBOARD",
"session.status = GameSession.Status.FINISHED",
"build_start_next_round_response(",
"build_start_next_round_phase_event(",
"build_finish_game_response(",
"build_finish_game_phase_event(",
"build_reveal_scoreboard_response(",
"build_scoreboard_phase_event(",
"ScoreEvent.objects.filter(",
"Player.objects.filter(",
)
for view_name, source in transition_sources.items():
for snippet in forbidden_snippets:
self.assertNotIn(snippet, source, msg=f"{view_name} leaked gameplay snippet: {snippet}")
def test_session_detail_view_source_stays_http_thin(self):
source = inspect.getsource(inspect.unwrap(lobby_views.session_detail))
self.assertIn("session = _maybe_promote_reveal_to_scoreboard(session)", source)
self.assertIn("current_round_question = _get_current_round_question(session)", source)
self.assertIn("gameplay_payload = _build_session_detail_gameplay_payload(", source)
self.assertIn("**gameplay_payload", source)
self.assertNotIn("build_round_question_payload", source)
self.assertNotIn("build_phase_view_model", source)
self.assertNotIn("build_reveal_payload", source)
self.assertNotIn("build_scoreboard_phase_event(session)[\"payload\"][\"leaderboard\"]", source)
self.assertNotIn("lies.select_related", source)
self.assertNotIn("guesses.select_related", source)
self.assertNotIn("Player.objects.filter(session=session)", source)
self.assertNotIn("leaderboard =", source)
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._start_round")
def test_start_round_view_delegates_transition_to_service(
self,
mock_start_round,
mock_sync_broadcast_phase_event,
):
lobby_session = GameSession.objects.create(host=self.host, code="LOBBY1", status=GameSession.Status.LOBBY)
transition = gameplay_services.RoundTransitionResult(
session=lobby_session,
round_config=self.round_config,
round_question=RoundQuestion.objects.create(
session=lobby_session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
),
should_broadcast=True,
response_payload={"ok": True},
phase_event_name="phase.lie_started",
phase_event_payload={"round_question_id": 123},
)
mock_start_round.return_value = transition
response = self.client.post(
reverse("lobby:start_round", kwargs={"code": lobby_session.code}),
data=json.dumps({"category_slug": self.category.slug}),
content_type="application/json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json(), {"ok": True})
mock_start_round.assert_called_once_with(lobby_session, self.category.slug)
mock_sync_broadcast_phase_event.assert_called_once_with(
lobby_session.code,
"phase.lie_started",
{"round_question_id": 123},
)
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._show_question")
def test_show_question_view_delegates_transition_to_service(
self,
mock_show_question,
mock_sync_broadcast_phase_event,
):
lie_session = GameSession.objects.create(host=self.host, code="LIE123", status=GameSession.Status.LIE)
transition = gameplay_services.RoundTransitionResult(
session=lie_session,
round_config=self.round_config,
round_question=RoundQuestion.objects.create(
session=lie_session,
round_number=1,
question=self.question,
correct_answer=self.question.correct_answer,
),
should_broadcast=True,
response_payload={"ok": True},
phase_event_name="phase.question_shown",
phase_event_payload={"round_question_id": 456},
)
mock_show_question.return_value = transition
response = self.client.post(reverse("lobby:show_question", kwargs={"code": lie_session.code}))
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json(), {"ok": True})
mock_show_question.assert_called_once_with(lie_session)
mock_sync_broadcast_phase_event.assert_called_once_with(
lie_session.code,
"phase.question_shown",
{"round_question_id": 456},
)
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._start_next_round")
def test_start_next_round_view_delegates_transition_to_service(
self,
mock_start_next_round,
mock_sync_broadcast_phase_event,
):
next_round_config = RoundConfig.objects.create(
session=self.session,
number=2,
category=self.category,
started_from_scoreboard=True,
)
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.question,
correct_answer=self.question.correct_answer,
)
transition = gameplay_services.RoundTransitionResult(
session=self.session,
round_config=next_round_config,
round_question=round_question,
should_broadcast=True,
response_payload={"ok": True},
phase_event_name="phase.lie_started",
phase_event_payload={"round_question_id": round_question.id},
)
mock_start_next_round.return_value = transition
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True})
mock_start_next_round.assert_called_once_with(self.session)
mock_sync_broadcast_phase_event.assert_called_once_with(
self.session.code,
"phase.lie_started",
{"round_question_id": round_question.id},
)
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._finish_game")
def test_finish_game_view_delegates_transition_to_service(
self,
mock_finish_game,
mock_sync_broadcast_phase_event,
):
finished_session = GameSession.objects.get(pk=self.session.pk)
finished_session.status = GameSession.Status.FINISHED
transition = gameplay_services.FinishGameResult(
session=finished_session,
should_broadcast=True,
response_payload={"ok": True},
phase_event_name="phase.game_over",
phase_event_payload={"winner": None, "leaderboard": []},
)
mock_finish_game.return_value = transition
response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True})
mock_finish_game.assert_called_once_with(self.session)
mock_sync_broadcast_phase_event.assert_called_once_with(
self.session.code,
"phase.game_over",
{"winner": None, "leaderboard": []},
)
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._start_next_round")
def test_start_next_round_view_skips_broadcast_on_service_replay(
self,
mock_start_next_round,
mock_sync_broadcast_phase_event,
):
replay_round_config = RoundConfig.objects.create(
session=self.session,
number=2,
category=self.category,
started_from_scoreboard=True,
)
round_question = RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.question,
correct_answer=self.question.correct_answer,
)
replay_session = GameSession.objects.get(pk=self.session.pk)
replay_session.status = GameSession.Status.LIE
replay_session.current_round = 2
transition = gameplay_services.RoundTransitionResult(
session=replay_session,
round_config=replay_round_config,
round_question=round_question,
should_broadcast=False,
response_payload={"ok": True},
)
mock_start_next_round.return_value = transition
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True})
mock_start_next_round.assert_called_once_with(self.session)
mock_sync_broadcast_phase_event.assert_not_called()
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._finish_game")
def test_finish_game_view_skips_broadcast_on_service_replay(
self,
mock_finish_game,
mock_sync_broadcast_phase_event,
):
finished_session = GameSession.objects.get(pk=self.session.pk)
finished_session.status = GameSession.Status.FINISHED
transition = gameplay_services.FinishGameResult(
session=finished_session,
should_broadcast=False,
response_payload={"ok": True},
)
mock_finish_game.return_value = transition
response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True})
mock_finish_game.assert_called_once_with(self.session)
mock_sync_broadcast_phase_event.assert_not_called()
class LobbyFlowTests(TestCase):
def setUp(self):
self.host = User.objects.create_user(username="host", password="secret123")
@@ -308,7 +642,7 @@ class StartRoundTests(TestCase):
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Only host can start round")
@patch("lobby.views._select_round_question", side_effect=ValueError("no_available_questions"))
@patch("fupogfakta.services.select_round_question", side_effect=ValueError("no_available_questions"))
def test_start_round_does_not_persist_round_config_when_question_selection_fails(self, _mock_select_round_question):
self.client.login(username="host", password="secret123")
@@ -867,6 +1201,79 @@ class CanonicalRoundFlowTests(TestCase):
},
)
def test_canonical_round_flow_bootstraps_second_round_without_first_round_carry_over(self):
self.client.login(username="host_canonical", password="secret123")
extra_question = Question.objects.create(
category=self.category,
prompt="Hvem malede Mona Lisa?",
correct_answer="Da Vinci",
is_active=True,
)
start_response = self.client.post(
reverse("lobby:start_round", kwargs={"code": self.session.code}),
data={"category_slug": self.category.slug},
content_type="application/json",
)
self.assertEqual(start_response.status_code, 201)
first_round_question_id = start_response.json()["round_question"]["id"]
first_round_prompt = start_response.json()["round_question"]["prompt"]
first_round_correct_answer = RoundQuestion.objects.get(pk=first_round_question_id).correct_answer
second_question = extra_question if first_round_prompt == self.question.prompt else self.question
final_lie_response = None
for index, player in enumerate(self.players, start=1):
lie_response = self.client.post(
reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}),
data={"player_id": player.id, "session_token": player.session_token, "text": f"Første løgn {index}"},
content_type="application/json",
)
self.assertEqual(lie_response.status_code, 201)
final_lie_response = lie_response
self.assertIsNotNone(final_lie_response)
for player, selected_text in zip(
self.players,
[first_round_correct_answer, first_round_correct_answer, first_round_correct_answer],
strict=True,
):
guess_response = self.client.post(
reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}),
data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text},
content_type="application/json",
)
self.assertEqual(guess_response.status_code, 201)
scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(scoreboard_payload["session"]["status"], GameSession.Status.SCOREBOARD)
self.assertEqual(scoreboard_payload["round_question"]["id"], first_round_question_id)
self.assertIsNotNone(scoreboard_payload["reveal"])
self.assertIsNotNone(scoreboard_payload["scoreboard"])
self.assertGreaterEqual(len(scoreboard_payload["reveal"]["guesses"]), 1)
next_round_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(next_round_response.status_code, 200)
self.assertEqual(next_round_response.json()["session"]["status"], GameSession.Status.LIE)
self.assertEqual(next_round_response.json()["session"]["current_round"], 2)
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE)
self.assertEqual(detail_payload["session"]["current_round"], 2)
self.assertEqual(detail_payload["phase_view_model"]["current_phase"], GameSession.Status.LIE)
self.assertIsNone(detail_payload["reveal"])
self.assertIsNone(detail_payload["scoreboard"])
self.assertEqual(detail_payload["round_question"]["round_number"], 2)
self.assertNotEqual(detail_payload["round_question"]["id"], first_round_question_id)
self.assertEqual(detail_payload["round_question"]["prompt"], second_question.prompt)
self.assertEqual(detail_payload["round_question"]["answers"], [])
round_two_question = RoundQuestion.objects.get(session=self.session, round_number=2)
self.assertEqual(round_two_question.question, second_question)
self.assertEqual(round_two_question.lies.count(), 0)
self.assertEqual(round_two_question.guesses.count(), 0)
self.assertEqual(round_two_question.mixed_answers, [])
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._resolve_scores")
@patch("lobby.views.GameSession.objects.get")
@@ -1216,6 +1623,25 @@ class RevealRoundFlowTests(TestCase):
self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
@patch("lobby.views.sync_broadcast_phase_event")
def test_finish_game_is_idempotent_after_transition_to_finished(self, mock_sync_broadcast_phase_event):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
first_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code}))
second_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code}))
self.assertEqual(first_response.status_code, 200)
self.assertEqual(second_response.status_code, 200)
self.assertEqual(first_response.json(), second_response.json())
self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.FINISHED)
self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
self.assertEqual(mock_sync_broadcast_phase_event.call_count, 2)
self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[0].args[1], "phase.scoreboard")
self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[1].args[1], "phase.game_over")
def test_finish_game_requires_host(self):
self.client.login(username="other_reveal", password="secret123")
@@ -1275,7 +1701,12 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(self.session.status, GameSession.Status.LIE)
self.assertEqual(self.session.current_round, 2)
self.assertTrue(
RoundConfig.objects.filter(session=self.session, number=2, category=self.category).exists()
RoundConfig.objects.filter(
session=self.session,
number=2,
category=self.category,
started_from_scoreboard=True,
).exists()
)
self.assertTrue(
RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists()
@@ -1284,6 +1715,242 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[0], self.session.code)
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
@patch("lobby.views.sync_broadcast_phase_event")
def test_start_next_round_bootstraps_new_round_question_instead_of_reusing_current_round(self, mock_sync_broadcast_phase_event):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
mock_sync_broadcast_phase_event.reset_mock()
stale_shown_at = timezone.now() - timedelta(minutes=10)
current_round_question = RoundQuestion.objects.get(session=self.session, round_number=1)
current_round_question.shown_at = stale_shown_at
current_round_question.mixed_answers = ["Stale truth", "Stale lie"]
current_round_question.save(update_fields=["shown_at", "mixed_answers"])
LieAnswer.objects.create(round_question=current_round_question, player=self.player_one, text="Stale lie")
Guess.objects.create(
round_question=current_round_question,
player=self.player_two,
selected_text="Stale truth",
is_correct=True,
)
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
self.session.refresh_from_db()
current_round_question.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LIE)
self.assertEqual(self.session.current_round, 2)
payload = response.json()
self.assertEqual(payload["round_question"]["id"], RoundQuestion.objects.get(session=self.session, round_number=2).id)
self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt)
self.assertEqual(current_round_question.round_number, 1)
self.assertEqual(current_round_question.question_id, self.question.id)
self.assertEqual(current_round_question.shown_at, stale_shown_at)
self.assertEqual(current_round_question.mixed_answers, ["Stale truth", "Stale lie"])
self.assertEqual(current_round_question.lies.count(), 1)
self.assertEqual(current_round_question.guesses.count(), 1)
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(detail_payload["round_question"]["id"], payload["round_question"]["id"])
self.assertEqual(detail_payload["round_question"]["prompt"], self.next_question.prompt)
mock_sync_broadcast_phase_event.assert_called_once()
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
@patch("lobby.views.sync_broadcast_phase_event")
def test_start_next_round_is_idempotent_after_transition_to_lie(self, mock_sync_broadcast_phase_event):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
mock_sync_broadcast_phase_event.reset_mock()
first_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
second_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(first_response.status_code, 200)
self.assertEqual(second_response.status_code, 200)
self.assertEqual(first_response.json(), second_response.json())
self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.LIE)
self.assertEqual(second_response.json()["session"]["current_round"], 2)
self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LIE)
self.assertEqual(self.session.current_round, 2)
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1)
self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=2).count(), 1)
mock_sync_broadcast_phase_event.assert_called_once()
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
def test_start_next_round_rejects_plain_lie_phase_without_prior_scoreboard_transition(self):
self.client.login(username="host_reveal", password="secret123")
ScoreEvent.objects.filter(session=self.session).delete()
self.session.status = GameSession.Status.LIE
self.session.current_round = 2
self.session.save(update_fields=["status", "current_round"])
RoundConfig.objects.create(session=self.session, number=2, category=self.category, started_from_scoreboard=False)
RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.next_question,
correct_answer=self.next_question.correct_answer,
)
response = self.client.post(
reverse(
"lobby:start_next_round",
kwargs={"code": self.session.code},
),
HTTP_ACCEPT_LANGUAGE="en",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "next_round_invalid_phase")
self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LIE)
self.assertEqual(self.session.current_round, 2)
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=1).count(), 1)
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1)
self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=1).count(), 1)
self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=2).count(), 1)
def test_start_next_round_clears_existing_next_round_bootstrap_state(self):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
stale_shown_at = timezone.now() - timedelta(minutes=10)
stale_round_question = RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.next_question,
correct_answer=self.next_question.correct_answer,
shown_at=stale_shown_at,
mixed_answers=["Stale truth", "Stale lie"],
)
LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Stale lie")
Guess.objects.create(
round_question=stale_round_question,
player=self.player_two,
selected_text="Stale truth",
is_correct=True,
)
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
self.session.refresh_from_db()
stale_round_question.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LIE)
self.assertEqual(self.session.current_round, 2)
response_payload = response.json()
self.assertEqual(response_payload["round_question"]["id"], stale_round_question.id)
self.assertEqual(stale_round_question.mixed_answers, [])
self.assertEqual(stale_round_question.lies.count(), 0)
self.assertEqual(stale_round_question.guesses.count(), 0)
self.assertNotEqual(stale_round_question.shown_at, stale_shown_at)
self.assertGreater(stale_round_question.shown_at, stale_shown_at)
self.assertEqual(response_payload["round_question"]["shown_at"], stale_round_question.shown_at.isoformat())
expected_deadline = stale_round_question.shown_at + timedelta(seconds=self.round_config.lie_seconds)
self.assertEqual(response_payload["round_question"]["lie_deadline_at"], expected_deadline.isoformat())
self.assertGreater(expected_deadline, timezone.now())
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE)
def test_start_next_round_reuses_existing_next_round_config_with_refreshed_canonical_values(self):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
stale_category = Category.objects.create(name="Sport reveal", slug="sport-reveal", is_active=True)
stale_round_config = RoundConfig.objects.create(
session=self.session,
number=2,
category=stale_category,
lie_seconds=12,
guess_seconds=18,
points_correct=9,
points_bluff=7,
started_from_scoreboard=False,
)
stale_round_question = RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.next_question,
correct_answer=self.next_question.correct_answer,
shown_at=timezone.now() - timedelta(minutes=10),
mixed_answers=["Stale truth"],
)
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
self.session.refresh_from_db()
stale_round_config.refresh_from_db()
stale_round_question.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LIE)
self.assertEqual(self.session.current_round, 2)
self.assertEqual(RoundConfig.objects.filter(session=self.session, number=2).count(), 1)
self.assertEqual(stale_round_config.category_id, self.round_config.category_id)
self.assertEqual(stale_round_config.lie_seconds, self.round_config.lie_seconds)
self.assertEqual(stale_round_config.guess_seconds, self.round_config.guess_seconds)
self.assertEqual(stale_round_config.points_correct, self.round_config.points_correct)
self.assertEqual(stale_round_config.points_bluff, self.round_config.points_bluff)
self.assertTrue(stale_round_config.started_from_scoreboard)
self.assertEqual(response.json()["round_question"]["id"], stale_round_question.id)
self.assertEqual(response.json()["config"]["lie_seconds"], self.round_config.lie_seconds)
expected_deadline = stale_round_question.shown_at + timedelta(seconds=self.round_config.lie_seconds)
self.assertEqual(response.json()["round_question"]["lie_deadline_at"], expected_deadline.isoformat())
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(detail_payload["session"]["current_round"], 2)
self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id)
self.assertEqual(detail_payload["round_question"]["answers"], [])
self.assertIsNone(detail_payload["reveal"])
self.assertIsNone(detail_payload["scoreboard"])
def test_start_next_round_repairs_reused_bootstrap_question_with_drifted_category(self):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
stale_category = Category.objects.create(name="Drift reveal", slug="drift-reveal", is_active=True)
stale_question = Question.objects.create(
category=stale_category,
prompt="Hvem vandt EM i 1992?",
correct_answer="Danmark",
is_active=True,
)
stale_round_question = RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=stale_question,
correct_answer=stale_question.correct_answer,
shown_at=timezone.now() - timedelta(minutes=10),
mixed_answers=["Stale truth", "Stale lie"],
)
LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Tyskland")
Guess.objects.create(
round_question=stale_round_question,
player=self.player_two,
selected_text="Stale truth",
is_correct=True,
)
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
self.session.refresh_from_db()
stale_round_question.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LIE)
self.assertEqual(self.session.current_round, 2)
self.assertEqual(stale_round_question.question.category_id, self.round_config.category_id)
self.assertEqual(stale_round_question.question_id, self.next_question.id)
self.assertEqual(stale_round_question.correct_answer, self.next_question.correct_answer)
self.assertEqual(stale_round_question.mixed_answers, [])
self.assertEqual(stale_round_question.lies.count(), 0)
self.assertEqual(stale_round_question.guesses.count(), 0)
payload = response.json()
self.assertEqual(payload["round_question"]["id"], stale_round_question.id)
self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt)
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(detail_payload["round_question"]["id"], stale_round_question.id)
self.assertEqual(detail_payload["round_question"]["prompt"], self.next_question.prompt)
def test_start_next_round_requires_host(self):
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
@@ -1905,7 +2572,7 @@ class SmokeStagingCommandTests(TestCase):
self.assertEqual(session.status, GameSession.Status.FINISHED)
self.assertEqual(Player.objects.filter(session=session).count(), 3)
def test_smoke_staging_writes_artifact_when_requested(self):
def test_smoke_staging_writes_phase_evidence_artifact_when_requested(self):
with tempfile.TemporaryDirectory() as tmp_dir:
artifact_path = Path(tmp_dir) / "smoke.json"
call_command("smoke_staging", artifact=str(artifact_path))
@@ -1913,24 +2580,40 @@ class SmokeStagingCommandTests(TestCase):
self.assertTrue(artifact_path.exists())
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
self.assertTrue(payload["ok"])
self.assertEqual(payload["command"], "smoke_staging")
self.assertEqual(payload["command"], "python manage.py smoke_staging --artifact <path>")
self.assertEqual(payload["players"], ["P1", "P2", "P3"])
self.assertIn("generated_at", payload)
self.assertIn("session_code", payload)
self.assertEqual(payload["question"]["correct_answer"], "Correct")
self.assertEqual(payload["guess_plan"]["P2"], "Correct")
step_names = [step["step"] for step in payload["steps"]]
self.assertEqual(
payload["steps"],
step_names,
[
"create_session",
"join_players",
"start_round",
"submit_lies",
"auto_guess_transition",
"submit_guesses",
"auto_reveal_to_scoreboard",
"auto_reveal_transition",
"auto_scoreboard_transition",
"finish_game",
],
)
reveal_step = payload["steps"][5]
self.assertEqual(reveal_step["session_status"], GameSession.Status.REVEAL)
self.assertEqual(reveal_step["reveal"]["correct_answer"], "Correct")
self.assertEqual(reveal_step["reveal"]["lies_count"], 3)
self.assertEqual(reveal_step["reveal"]["guesses_count"], 3)
self.assertEqual(len(reveal_step["reveal"]["fooled_player_ids"]), 2)
self.assertEqual(len(reveal_step["reveal"]["correct_guess_player_ids"]), 1)
scoreboard_step = payload["steps"][6]
self.assertEqual(scoreboard_step["session_status"], GameSession.Status.SCOREBOARD)
self.assertEqual(len(scoreboard_step["leaderboard"]), 3)
class I18nResolverTests(TestCase):
def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self):

View File

@@ -1,28 +1,39 @@
import json
import random
from datetime import timedelta
import json
import random
from django.contrib.auth.decorators import login_required
from django.db import IntegrityError, transaction
from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST
from fupogfakta.models import (
Category,
GameSession,
Guess,
LieAnswer,
Player,
Question,
RoundConfig,
RoundQuestion,
ScoreEvent,
from fupogfakta.models import GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent
from fupogfakta.payloads import (
build_leaderboard as _build_leaderboard,
build_reveal_payload as _build_reveal_payload,
build_scoreboard_phase_event as _build_scoreboard_phase_event,
build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload,
)
from fupogfakta.services import (
finish_game as _finish_game,
get_current_round_question as _get_current_round_question,
prepare_mixed_answers as _prepare_mixed_answers,
promote_reveal_to_scoreboard as _promote_reveal_to_scoreboard,
resolve_scores as _resolve_scores,
select_round_question as _select_round_question,
show_question as _show_question,
start_next_round as _start_next_round,
start_round as _start_round,
)
from realtime.broadcast import sync_broadcast_phase_event
from .i18n import api_error
_GAMEPLAY_SERVICE_OWNERSHIP_EXPORTS = (
_select_round_question,
_build_scoreboard_phase_event,
)
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
SESSION_CODE_LENGTH = 6
MAX_CODE_GENERATION_ATTEMPTS = 20
@@ -64,262 +75,19 @@ def _create_unique_session_code() -> str:
raise RuntimeError("Could not generate unique session code")
def _build_player_ref(player: Player | None) -> dict | None:
if player is None:
return None
return {
"player_id": player.id,
"nickname": player.nickname,
}
def _build_reveal_payload(round_question: RoundQuestion | None) -> dict | None:
if round_question is None:
return None
lies = [
{
**_build_player_ref(lie.player),
"text": lie.text,
"created_at": lie.created_at.isoformat(),
}
for lie in round_question.lies.select_related("player").order_by("created_at", "id")
]
guesses = []
for guess in round_question.guesses.select_related("player", "fooled_player").order_by("created_at", "id"):
guess_payload = {
**_build_player_ref(guess.player),
"selected_text": guess.selected_text,
"is_correct": guess.is_correct,
"created_at": guess.created_at.isoformat(),
"fooled_player_id": guess.fooled_player_id,
}
if guess.fooled_player is not None:
guess_payload["fooled_player_nickname"] = guess.fooled_player.nickname
guesses.append(guess_payload)
return {
"round_question_id": round_question.id,
"round_number": round_question.round_number,
"prompt": round_question.question.prompt,
"correct_answer": round_question.correct_answer,
"lies": lies,
"guesses": guesses,
}
def _build_leaderboard(session: GameSession) -> list[dict]:
return list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
def _get_current_round_question(session: GameSession) -> RoundQuestion | None:
return (
RoundQuestion.objects.filter(session=session, round_number=session.current_round)
.select_related("question")
.order_by("-id")
.first()
)
def _select_round_question(session: GameSession, round_config: RoundConfig) -> RoundQuestion:
existing_round_question = _get_current_round_question(session)
if existing_round_question is not None:
return existing_round_question
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
available_questions = Question.objects.filter(
category=round_config.category,
is_active=True,
).exclude(pk__in=used_question_ids)
if not available_questions.exists():
raise ValueError("no_available_questions")
question = random.choice(list(available_questions))
return RoundQuestion.objects.create(
session=session,
round_number=session.current_round,
question=question,
correct_answer=question.correct_answer,
)
def _build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict:
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
return {
"round_number": session.current_round,
"category": {"slug": round_config.category.slug, "name": round_config.category.name},
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
"lie_seconds": round_config.lie_seconds,
}
def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
deduped_answers = list(round_question.mixed_answers or [])
if deduped_answers:
return deduped_answers
lie_texts = list(round_question.lies.values_list("text", flat=True))
seen = set()
for text in [round_question.correct_answer, *lie_texts]:
normalized = text.strip().casefold()
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped_answers.append(text.strip())
if len(deduped_answers) < 2:
raise ValueError("not_enough_answers_to_mix")
random.shuffle(deduped_answers)
round_question.mixed_answers = deduped_answers
round_question.save(update_fields=["mixed_answers"])
return deduped_answers
def _resolve_scores(session: GameSession, round_question: RoundQuestion, round_config: RoundConfig) -> tuple[list[ScoreEvent], list[dict]]:
guesses = list(round_question.guesses.select_related("player"))
if not guesses:
raise ValueError("no_guesses_submitted")
bluff_counts: dict[int, int] = {}
for guess in guesses:
if guess.fooled_player_id:
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
score_events = []
for guess in guesses:
if guess.is_correct:
guess.player.score += round_config.points_correct
guess.player.save(update_fields=["score"])
score_events.append(
ScoreEvent(
session=session,
player=guess.player,
delta=round_config.points_correct,
reason="guess_correct",
meta={"round_question_id": round_question.id, "guess_id": guess.id},
)
)
for player_id, fooled_count in bluff_counts.items():
delta = fooled_count * round_config.points_bluff
player = Player.objects.get(pk=player_id, session=session)
player.score += delta
player.save(update_fields=["score"])
score_events.append(
ScoreEvent(
session=session,
player=player,
delta=delta,
reason="bluff_success",
meta={"round_question_id": round_question.id, "fooled_count": fooled_count},
)
)
ScoreEvent.objects.bulk_create(score_events)
return score_events, _build_leaderboard(session)
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
if session.status != GameSession.Status.REVEAL:
return session
current_round_question = _get_current_round_question(session)
if current_round_question is None:
return session
players_count = Player.objects.filter(session=session).count()
guess_count = Guess.objects.filter(round_question=current_round_question).count()
has_score_events = ScoreEvent.objects.filter(
session=session,
meta__round_question_id=current_round_question.id,
).exists()
reveal_is_resolved = has_score_events or (players_count > 0 and guess_count >= players_count)
if not reveal_is_resolved:
return session
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.REVEAL:
return locked_session
locked_session.status = GameSession.Status.SCOREBOARD
locked_session.save(update_fields=["status"])
leaderboard = _build_leaderboard(session)
transition = _promote_reveal_to_scoreboard(session)
if transition.should_broadcast:
sync_broadcast_phase_event(
session.code,
"phase.scoreboard",
{"leaderboard": list(leaderboard), "current_round": session.current_round},
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
session.refresh_from_db(fields=["status"])
return session
return transition.session
def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
status = session.status
in_lobby = status == GameSession.Status.LOBBY
in_lie = status == GameSession.Status.LIE
in_guess = status == GameSession.Status.GUESS
in_scoreboard = status == GameSession.Status.SCOREBOARD
in_finished = status == GameSession.Status.FINISHED
min_players_reached = players_count >= 3
max_players_allowed = players_count <= 5
return {
"status": status,
"current_phase": status,
"round_number": session.current_round,
"players_count": players_count,
"constraints": {
"min_players_to_start": 3,
"max_players_mvp": 5,
"min_players_reached": min_players_reached,
"max_players_allowed": max_players_allowed,
},
"readiness": {
"question_ready": has_round_question,
"scoreboard_ready": status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED},
"can_advance_to_next_round": in_scoreboard,
},
"host": {
"can_start_round": in_lobby and min_players_reached and max_players_allowed,
"can_show_question": False,
"can_mix_answers": False,
"can_calculate_scores": False,
"can_reveal_scoreboard": False,
"can_start_next_round": in_scoreboard,
"can_finish_game": in_scoreboard,
},
"player": {
"can_join": status in JOINABLE_STATUSES,
"can_submit_lie": in_lie and has_round_question,
"can_submit_guess": in_guess and has_round_question,
"can_view_final_result": in_finished,
},
}
@require_POST
@login_required
def create_session(request: HttpRequest) -> JsonResponse:
@@ -426,21 +194,10 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
session = _maybe_promote_reveal_to_scoreboard(session)
current_round_question = _get_current_round_question(session)
round_question_payload = None
if current_round_question:
round_question_payload = {
"id": current_round_question.id,
"round_number": current_round_question.round_number,
"prompt": current_round_question.question.prompt,
"shown_at": current_round_question.shown_at.isoformat(),
"answers": [{"text": text} for text in (current_round_question.mixed_answers or [])],
}
phase_view_model = _build_phase_view_model(
gameplay_payload = _build_session_detail_gameplay_payload(
session,
current_round_question=current_round_question,
players_count=len(players),
has_round_question=bool(current_round_question),
)
return JsonResponse(
@@ -453,14 +210,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
"players_count": len(players),
},
"players": players,
"round_question": round_question_payload,
"reveal": _build_reveal_payload(current_round_question)
if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question
else None,
"scoreboard": _build_leaderboard(session)
if session.status in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}
else None,
"phase_view_model": phase_view_model,
**gameplay_payload,
}
)
@@ -496,95 +246,23 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
status=403,
)
if session.status != GameSession.Status.LOBBY:
return api_error(
request,
code="round_start_invalid_phase",
status=400,
)
try:
category = Category.objects.get(slug=category_slug, is_active=True)
except Category.DoesNotExist:
return api_error(
request,
code="category_not_found",
status=404,
)
if not Question.objects.filter(category=category, is_active=True).exists():
return api_error(
request,
code="category_has_no_questions",
status=400,
)
with transaction.atomic():
session = GameSession.objects.select_for_update().get(pk=session.pk)
if session.status != GameSession.Status.LOBBY:
return api_error(
request,
code="round_start_invalid_phase",
status=400,
)
if RoundConfig.objects.filter(session=session, number=session.current_round).exists():
return api_error(
request,
code="round_already_configured",
status=409,
)
round_config = RoundConfig(
session=session,
number=session.current_round,
category=category,
)
try:
round_question = _select_round_question(session, round_config)
transition = _start_round(session, category_slug)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
round_config.save()
session.status = GameSession.Status.LIE
session.save(update_fields=["status"])
lie_started_payload = _build_lie_started_payload(session, round_config, round_question)
error_code = str(exc)
error_status = {
"category_not_found": 404,
"round_already_configured": 409,
}.get(error_code, 400)
return api_error(request, code=error_code, status=error_status)
sync_broadcast_phase_event(
session.code,
"phase.lie_started",
lie_started_payload,
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
return JsonResponse(
{
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"round": {
"number": round_config.number,
"category": {
"slug": round_config.category.slug,
"name": round_config.category.name,
},
},
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_started_payload["lie_deadline_at"],
},
"config": {
"lie_seconds": round_config.lie_seconds,
},
},
status=201,
)
return JsonResponse(transition.response_payload, status=201)
@require_POST
@@ -608,60 +286,18 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
status=403,
)
if session.status != GameSession.Status.LIE:
return api_error(
request,
code="show_question_invalid_phase",
status=400,
)
try:
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
except RoundConfig.DoesNotExist:
return api_error(
request,
code="round_config_missing",
status=400,
)
existing_round_question = _get_current_round_question(session)
if existing_round_question is not None:
round_question = existing_round_question
else:
try:
round_question = _select_round_question(session, round_config)
transition = _show_question(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds)
sync_broadcast_phase_event(
session.code,
"phase.question_shown",
{
"round_question_id": round_question.id,
"prompt": round_question.question.prompt,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
"lie_seconds": round_config.lie_seconds,
},
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
return JsonResponse(
{
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_deadline_at.isoformat(),
},
"config": {
"lie_seconds": round_config.lie_seconds,
},
},
status=201,
)
return JsonResponse(transition.response_payload, status=201)
@require_POST
@@ -1060,22 +696,18 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
if session.host_id != request.user.id:
return api_error(request, code="host_only_view_scoreboard", status=403)
session = _maybe_promote_reveal_to_scoreboard(session)
transition = _promote_reveal_to_scoreboard(session)
if transition.should_broadcast:
sync_broadcast_phase_event(
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
session = transition.session
if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
return api_error(request, code="scoreboard_invalid_phase", status=400)
leaderboard = _build_leaderboard(session)
return JsonResponse(
{
"session": {
"code": session.code,
"status": session.status,
"current_round": session.current_round,
},
"leaderboard": leaderboard,
}
)
return JsonResponse(transition.response_payload)
@require_POST
@@ -1091,72 +723,19 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
if session.host_id != request.user.id:
return api_error(request, code="host_only_start_next_round", status=403)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.SCOREBOARD:
return api_error(request, code="next_round_invalid_phase", status=400)
previous_round_config = RoundConfig.objects.filter(
session=locked_session,
number=locked_session.current_round,
).select_related("category").first()
if previous_round_config is None:
return api_error(request, code="round_config_missing", status=400)
next_round_number = locked_session.current_round + 1
next_round_config = RoundConfig(
session=locked_session,
number=next_round_number,
category=previous_round_config.category,
lie_seconds=previous_round_config.lie_seconds,
guess_seconds=previous_round_config.guess_seconds,
points_correct=previous_round_config.points_correct,
points_bluff=previous_round_config.points_bluff,
)
locked_session.current_round = next_round_number
try:
round_question = _select_round_question(locked_session, next_round_config)
transition = _start_next_round(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
next_round_config.save()
locked_session.status = GameSession.Status.LIE
locked_session.save(update_fields=["current_round", "status"])
lie_started_payload = _build_lie_started_payload(locked_session, next_round_config, round_question)
if transition.should_broadcast:
sync_broadcast_phase_event(
locked_session.code,
"phase.lie_started",
lie_started_payload,
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
return JsonResponse(
{
"session": {
"code": locked_session.code,
"status": locked_session.status,
"current_round": locked_session.current_round,
},
"round": {
"number": next_round_config.number,
"category": {
"slug": next_round_config.category.slug,
"name": next_round_config.category.name,
},
},
"round_question": {
"id": round_question.id,
"prompt": round_question.question.prompt,
"round_number": round_question.round_number,
"shown_at": round_question.shown_at.isoformat(),
"lie_deadline_at": lie_started_payload["lie_deadline_at"],
},
"config": {
"lie_seconds": next_round_config.lie_seconds,
},
}
)
return JsonResponse(transition.response_payload)
@require_POST
@login_required
@@ -1171,40 +750,19 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
if session.host_id != request.user.id:
return api_error(request, code="host_only_finish_game", status=403)
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.SCOREBOARD:
return api_error(request, code="finish_game_invalid_phase", status=400)
locked_session.status = GameSession.Status.FINISHED
locked_session.save(update_fields=["status"])
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
winner = leaderboard[0] if leaderboard else None
try:
transition = _finish_game(session)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
if transition.should_broadcast:
sync_broadcast_phase_event(
session.code,
"phase.game_over",
{"winner": winner, "leaderboard": list(leaderboard)},
transition.session.code,
transition.phase_event_name,
transition.phase_event_payload,
)
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.FINISHED,
"current_round": session.current_round,
},
"winner": winner,
"leaderboard": leaderboard,
}
)
return JsonResponse(transition.response_payload)
@require_POST

View File

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