Compare commits
68 Commits
dev/issue-
...
d86941fef8
| Author | SHA1 | Date | |
|---|---|---|---|
| d86941fef8 | |||
| 21e390d200 | |||
| df9b6d192c | |||
| 702f130de2 | |||
| 92f2cda83a | |||
| d080f05661 | |||
| e246bd648f | |||
| 06e4ccac61 | |||
| 3c9214178e | |||
| feddd910eb | |||
| dd615796f4 | |||
| d2cdf16322 | |||
| 101c3f9c26 | |||
| 65eb5685f7 | |||
| 8a70645fda | |||
| 2cd8d940f9 | |||
| 72bc5997ff | |||
| c9e64bc8a8 | |||
| 1c7f1e7c53 | |||
| 03850b5ed5 | |||
| 16c9cf6b57 | |||
| c45f04f9f1 | |||
| 319038555a | |||
| e318711148 | |||
| a9c6e4fd79 | |||
| 7eb3507934 | |||
| dfa197b33b | |||
| fefc5ecd56 | |||
| 94f940e5d8 | |||
| a102a72a77 | |||
| d272e35a79 | |||
| 8a07433f11 | |||
| 9baade0105 | |||
| 35e2d09ee3 | |||
| a916da12a7 | |||
| 7f20cb3bf9 | |||
| f736f4f74e | |||
| 8247787404 | |||
| 6722be43d4 | |||
| 212549373b | |||
| 47659ed673 | |||
|
|
c8750af4d8 | ||
| 44e480931b | |||
| 8c0a561a64 | |||
| 1839b30e0a | |||
| 7de843e44b | |||
| 542d326615 | |||
| e39605d782 | |||
| d36d256daf | |||
| 2ee235c6c0 | |||
| 592c265331 | |||
| 251ccfce19 | |||
| d9c4cda966 | |||
| 2437f0e8bd | |||
| 3b4b844126 | |||
| c8c17654a4 | |||
| fd6e3e86e8 | |||
| 7c0332f95f | |||
| 9970257f32 | |||
| 112a85a22d | |||
| 33b428955b | |||
| 55fc758389 | |||
| f0142f33b6 | |||
| 3acaf3e370 | |||
| 1cb36a5943 | |||
| fc68e30cf4 | |||
| 57ca237565 | |||
| 076faf2ff1 |
@@ -38,7 +38,10 @@ jobs:
|
|||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Install SPA dependencies
|
- 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
|
- name: SPA Angular smoke tests
|
||||||
run: npm --prefix frontend/angular test
|
run: npm --prefix frontend/angular test
|
||||||
|
|||||||
52
docs/ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md
Normal file
52
docs/ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Issue #301 Artifact — Client action gating from canonical phase state
|
||||||
|
|
||||||
|
Refs: #287, #301
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Frontend host/player shells now prefer the canonical phase exposed by `phase_view_model.current_phase` when deciding:
|
||||||
|
|
||||||
|
- which gameplay actions are enabled
|
||||||
|
- whether reveal data should still be shown
|
||||||
|
- which SPA hash-route should represent the active game state
|
||||||
|
|
||||||
|
This tightens the #301 slice so the client stays aligned with backend canonicalisation even when `session.status` lags during reveal/scoreboard promotion.
|
||||||
|
|
||||||
|
## Gated UI actions by phase
|
||||||
|
|
||||||
|
### Lobby
|
||||||
|
- **Host:** `startRound`
|
||||||
|
- **Player:** `join`
|
||||||
|
|
||||||
|
### Bluff / lie
|
||||||
|
- **Host:** `showQuestion`
|
||||||
|
- **Player:** `submitLie`
|
||||||
|
- **Blocked:** guess submission, scoreboard load, next round, finish game
|
||||||
|
|
||||||
|
### Guess
|
||||||
|
- **Host:** `mixAnswers`, `calculateScores`
|
||||||
|
- **Player:** `submitGuess`
|
||||||
|
- **Blocked:** lie submission, scoreboard load, next round, finish game
|
||||||
|
|
||||||
|
### Reveal
|
||||||
|
- **Host:** `loadScoreboard`
|
||||||
|
- **Player:** display-only reveal state
|
||||||
|
- **Blocked:** start next round, finish game, guess/lie submission
|
||||||
|
|
||||||
|
### Scoreboard
|
||||||
|
- **Host:** `startNextRound`, `finishGame`
|
||||||
|
- **Player:** display-only reveal/scoreboard state
|
||||||
|
- **Blocked:** scoreboard reload, guess/lie submission
|
||||||
|
|
||||||
|
## Test evidence
|
||||||
|
|
||||||
|
Targeted tests added/updated for:
|
||||||
|
|
||||||
|
- host shell canonical gating and route sync when `current_phase` differs from `session.status`
|
||||||
|
- player shell canonical gating and route sync when `current_phase` differs from `session.status`
|
||||||
|
- shared gameplay phase machine gating from canonical permissions
|
||||||
|
- shared API mapper contract coverage, including reveal/scoreboard payload stability
|
||||||
|
|
||||||
|
## Contract note
|
||||||
|
|
||||||
|
No backend protocol redesign was introduced. This follow-up only preserves and consumes the existing canonical phase/action contract more strictly on the client side.
|
||||||
55
docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md
Normal file
55
docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Issue #302 Evidence — canonical bluff → guess → reveal → scoreboard regression
|
||||||
|
|
||||||
|
## Runnable command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py migrate --noinput
|
||||||
|
python manage.py smoke_staging --artifact docs/artifacts/issue-302-canonical-loop-smoke.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`migrate` is the normal local bootstrap precondition when the database has not been initialized yet; the regression evidence itself is produced by `smoke_staging`.
|
||||||
|
|
||||||
|
## What the regression proves
|
||||||
|
|
||||||
|
`smoke_staging` now exercises one full canonical round and fails fast with step-specific diagnostics if any of these break:
|
||||||
|
|
||||||
|
1. `start_round` lands the session in `lie` and returns a concrete `round_question_id`.
|
||||||
|
2. Final `submit_lie` auto-advances the session to `guess` and exposes mixed answers containing both the correct answer and player bluffs.
|
||||||
|
3. Final `submit_guess` auto-advances the session to `reveal` and returns the canonical reveal payload.
|
||||||
|
4. The reveal payload includes:
|
||||||
|
- correct answer
|
||||||
|
- all lies
|
||||||
|
- all guesses
|
||||||
|
- fooled-player references for bluff hits
|
||||||
|
5. The first canonical state read after reveal promotes the session to `scoreboard`.
|
||||||
|
6. Scoreboard promotion preserves the same reveal payload and exposes a leaderboard with `scoreboard_ready=true`.
|
||||||
|
|
||||||
|
## Artifact shape
|
||||||
|
|
||||||
|
When `--artifact` is provided, the JSON file records:
|
||||||
|
|
||||||
|
- the exact smoke command
|
||||||
|
- session code and round question id
|
||||||
|
- deterministic guess plan used to produce both bluff hits and one correct guess
|
||||||
|
- per-step evidence for:
|
||||||
|
- `create_session`
|
||||||
|
- `join_players`
|
||||||
|
- `start_round`
|
||||||
|
- `auto_guess_transition`
|
||||||
|
- `submit_guesses`
|
||||||
|
- `auto_reveal_transition`
|
||||||
|
- `auto_scoreboard_transition`
|
||||||
|
- `finish_game`
|
||||||
|
- reveal summary (`correct_answer`, lie/guess counts, fooled-player ids, correct guess player ids)
|
||||||
|
- promoted scoreboard leaderboard payload
|
||||||
|
|
||||||
|
## Targeted test coverage
|
||||||
|
|
||||||
|
Backend regression coverage lives in `lobby/tests.py`:
|
||||||
|
|
||||||
|
- `test_smoke_staging_command_runs_full_flow`
|
||||||
|
- `test_smoke_staging_writes_phase_evidence_artifact_when_requested`
|
||||||
|
|
||||||
|
Together they ensure the command stays runnable in normal workflow and that the evidence artifact contains phase-by-phase proof instead of only a generic pass/fail.
|
||||||
|
|
||||||
|
Refs #287 #302
|
||||||
33
docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md
Normal file
33
docs/ISSUE-310-HOST-TRANSITION-IDEMPOTENCY-ARTIFACT.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Issue #310 — Host transition idempotency and error catalog
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This artifact hardens the two host-owned scoreboard exits in the canonical gameplay flow:
|
||||||
|
|
||||||
|
- `POST /lobby/sessions/{code}/rounds/next`
|
||||||
|
- `POST /lobby/sessions/{code}/finish`
|
||||||
|
|
||||||
|
The goal is retry-safe host behavior when the scoreboard transition already succeeded server-side but the client retries because of a duplicate click, timeout, or lost response.
|
||||||
|
|
||||||
|
## Transition contract
|
||||||
|
|
||||||
|
| Endpoint | First valid transition | Idempotent replay state | Replay result | Broadcast behavior | Still-invalid states |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `POST /lobby/sessions/{code}/rounds/next` | `scoreboard -> lie` | `lie` with persisted current-round bootstrap (`RoundConfig` + `RoundQuestion`) | `200 OK` with the same canonical next-round payload shape | `phase.lie_started` fires only on the first transition | `lobby`, `guess`, `reveal`, `finished` → `next_round_invalid_phase` |
|
||||||
|
| `POST /lobby/sessions/{code}/finish` | `scoreboard -> finished` | `finished` | `200 OK` with the same final leaderboard payload shape | `phase.game_over` fires only on the first transition | `lobby`, `lie`, `guess`, `reveal` → `finish_game_invalid_phase` |
|
||||||
|
|
||||||
|
## Error catalog notes
|
||||||
|
|
||||||
|
No new backend error codes were introduced for this slice.
|
||||||
|
|
||||||
|
The contract change is behavioral:
|
||||||
|
|
||||||
|
- `next_round_invalid_phase` now means the session is in a phase where the scoreboard → next-round transition has **not** already been completed, or the expected bootstrap artifact for the already-started round is missing.
|
||||||
|
- `finish_game_invalid_phase` now means the session is in a phase where the scoreboard → finish transition has **not** already been completed.
|
||||||
|
- Successful replays are returned as normal `200 OK` canonical responses instead of phase errors.
|
||||||
|
|
||||||
|
## Acceptance evidence
|
||||||
|
|
||||||
|
- Repeated `rounds/next` calls after a successful scoreboard exit return the same canonical lie/bootstrap payload without incrementing the round twice.
|
||||||
|
- Repeated `finish` calls after a successful scoreboard exit return the same finished leaderboard payload without rebroadcasting game-over.
|
||||||
|
- Wrong-phase calls outside those replay states still return the existing shared error codes.
|
||||||
202
docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md
Normal file
202
docs/ISSUE-312-LOBBY-FUPOGFAKTA-EXTRACTION-MAP.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# Issue #312 — FupOgFakta extraction map for logic currently living in `lobby/`
|
||||||
|
|
||||||
|
Parent: #311
|
||||||
|
Issue: #312
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This artifact documents the concrete FupOgFakta-specific logic that still lives in `lobby/`, separates it from true platform/session concerns, and names the intended destination ownership before any larger code move happens.
|
||||||
|
|
||||||
|
It is intentionally an inventory + extraction plan only. It does **not** perform the full move.
|
||||||
|
|
||||||
|
## Architectural boundary this map is enforcing
|
||||||
|
|
||||||
|
The target boundary is already described in:
|
||||||
|
|
||||||
|
- `docs/plans/2026-03-09-fupogfakta-game-engine-design.md`
|
||||||
|
- `docs/plans/2026-03-09-fupogfakta-implementation-plan.md`
|
||||||
|
- `docs/ARCHITECTURE.md`
|
||||||
|
|
||||||
|
Those docs consistently describe:
|
||||||
|
|
||||||
|
- `lobby/` as the **platform layer** for session lifecycle, player presence, host ownership, generic game-run orchestration, and transport-facing platform concerns.
|
||||||
|
- `fupogfakta/` as the **game cartridge** that owns question selection rules, round config semantics, lie/guess/reveal/scoreboard flow, answer mixing, scoring, and game-specific response/event payloads.
|
||||||
|
|
||||||
|
In other words:
|
||||||
|
|
||||||
|
- **Platform (`lobby/`)** should know that a session exists and that a game can be started/observed.
|
||||||
|
- **Cartridge (`fupogfakta/`)** should know what a lie is, what a guess is, how answers are mixed, when phases advance, and what payload shape those game phases expose.
|
||||||
|
|
||||||
|
## Summary split
|
||||||
|
|
||||||
|
### Generic platform/session concerns that belong in `lobby/`
|
||||||
|
|
||||||
|
These are not FupOgFakta-specific and should remain platform-owned:
|
||||||
|
|
||||||
|
- Session code parsing/generation:
|
||||||
|
- `lobby/views.py::_generate_session_code`
|
||||||
|
- `lobby/views.py::_normalize_session_code`
|
||||||
|
- `lobby/views.py::_create_unique_session_code`
|
||||||
|
- Generic request parsing:
|
||||||
|
- `lobby/views.py::_json_body`
|
||||||
|
- Session lifecycle and player presence endpoints:
|
||||||
|
- `lobby/views.py::create_session`
|
||||||
|
- `lobby/views.py::join_session`
|
||||||
|
- `lobby/views.py::session_detail` **only for the generic session/player shell part**
|
||||||
|
- Generic ownership / host authorization checks
|
||||||
|
- Generic session detail payload fields:
|
||||||
|
- `session.code`
|
||||||
|
- `session.status`
|
||||||
|
- `session.host_id`
|
||||||
|
- `session.current_round`
|
||||||
|
- `session.players_count`
|
||||||
|
- `players[].id|nickname|score|is_connected`
|
||||||
|
- Generic i18n/error transport helper usage:
|
||||||
|
- `lobby/i18n.py`
|
||||||
|
- `api_error(...)`
|
||||||
|
- Route mounting / namespace ownership in `lobby/urls.py` for platform routes only
|
||||||
|
|
||||||
|
### FupOgFakta-specific logic currently misplaced in `lobby/`
|
||||||
|
|
||||||
|
These items are game-cartridge logic and should move behind `fupogfakta/` ownership:
|
||||||
|
|
||||||
|
- Round question selection by category and previously-used questions
|
||||||
|
- Lie-phase payload construction and lie timer semantics
|
||||||
|
- Mixed-answer preparation for bluff gameplay
|
||||||
|
- Guess correctness / fooled-player detection
|
||||||
|
- Bluff/correct-answer score resolution
|
||||||
|
- Reveal payload construction
|
||||||
|
- Reveal → scoreboard promotion rules
|
||||||
|
- Start round / mix answers / submit lie / submit guess / calculate scores / reveal scoreboard / next round / finish game gameplay endpoints
|
||||||
|
- Phase view-model booleans that encode FupOgFakta rules rather than generic platform readiness
|
||||||
|
|
||||||
|
## Extraction map
|
||||||
|
|
||||||
|
| Source file | Current function / concern | Why it is FupOgFakta-specific | Intended destination / owner |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `lobby/views.py` | `_build_player_ref(player)` | Helper is only used to shape FupOgFakta reveal payloads; not a generic platform concern today. | `fupogfakta/serializers.py` or `fupogfakta/payloads.py` owned by cartridge. |
|
||||||
|
| `lobby/views.py` | `_build_reveal_payload(round_question)` | Encodes FupOgFakta reveal contract: lies, guesses, fooled-player refs, correct answer, prompt. | `fupogfakta/payloads.py::build_reveal_payload` or equivalent cartridge response builder. |
|
||||||
|
| `lobby/views.py` | `_build_leaderboard(session)` | Current implementation is generic-ish, but used exclusively by FupOgFakta scoreboard/finish flow and coupled to that response shape. | Short term: keep shared helper if multiple games will consume same contract; otherwise move to `fupogfakta/payloads.py` until a true shared scoreboard contract exists. |
|
||||||
|
| `lobby/views.py` | `_get_current_round_question(session)` | Depends on FupOgFakta `RoundQuestion` model and current-round semantics. | `fupogfakta/services/rounds.py` or `fupogfakta/queries.py`. |
|
||||||
|
| `lobby/views.py` | `_select_round_question(session, round_config)` | Implements FupOgFakta question selection rules by category, active questions, and not-yet-used question set. | `fupogfakta/services/rounds.py::select_round_question`. |
|
||||||
|
| `lobby/views.py` | `_build_lie_started_payload(session, round_config, round_question)` | Builds a FupOgFakta event/response contract for lie phase, including category, prompt, lie deadline, round question id. | `fupogfakta/payloads.py::build_lie_started_payload`. |
|
||||||
|
| `lobby/views.py` | `_prepare_mixed_answers(round_question)` | Bluff-answer dedupe and shuffle is core FupOgFakta gameplay logic. | `fupogfakta/services/answers.py::prepare_mixed_answers`. |
|
||||||
|
| `lobby/views.py` | `_resolve_scores(session, round_question, round_config)` | Applies FupOgFakta scoring rules for correct guesses and successful bluffs; depends on `Guess`, `LieAnswer`, `ScoreEvent`, `points_correct`, `points_bluff`. | `fupogfakta/services/scoring.py::resolve_scores`. |
|
||||||
|
| `lobby/views.py` | `_maybe_promote_reveal_to_scoreboard(session)` | Encodes FupOgFakta reveal completion semantics and scoreboard transition trigger. | `fupogfakta/services/phases.py::maybe_promote_reveal_to_scoreboard`. |
|
||||||
|
| `lobby/views.py` | `_build_phase_view_model(session, players_count, has_round_question)` | Most booleans are not platform-generic; they encode FupOgFakta phase names (`lie`, `guess`, `scoreboard`) and MVP constraints (`3-5 players`, round-question readiness, next-round/finish gating). | Split: keep platform-shell fields in `lobby/`; move game-specific readiness/action flags to `fupogfakta/payloads.py::build_phase_view_model` or cartridge driver payload builder. |
|
||||||
|
| `lobby/views.py` | `start_round(request, code)` | Starts FupOgFakta round, binds category, creates `RoundConfig`, selects `RoundQuestion`, transitions to `LIE`, broadcasts `phase.lie_started`. | `fupogfakta/views.py` or cartridge command handler behind a future `GameDriver.on_game_start` / round bootstrap service. |
|
||||||
|
| `lobby/views.py` | `show_question(request, code)` | Emits lie-phase question payload using FupOgFakta `RoundQuestion` and `RoundConfig`. | `fupogfakta/views.py` or remove entirely once canonical driver flow owns the transition. |
|
||||||
|
| `lobby/views.py` | `submit_lie(request, code, round_question_id)` | Pure FupOgFakta gameplay endpoint: lie validation, deadline semantics, auto-advance to guess phase, `phase.guess_started` payload. | `fupogfakta/views.py::submit_lie` (or cartridge intent handler). |
|
||||||
|
| `lobby/views.py` | `mix_answers(request, code, round_question_id)` | Manual FupOgFakta host action for lie→guess transition and answer mixing. | `fupogfakta/views.py` short term; long term likely deleted in favor of cartridge-driven automatic transition. |
|
||||||
|
| `lobby/views.py` | `submit_guess(request, code, round_question_id)` | Pure FupOgFakta gameplay endpoint: validates answer choice, resolves correctness/bluff source, auto-calculates scores, transitions to reveal. | `fupogfakta/views.py::submit_guess` plus `fupogfakta/services/scoring.py` and `fupogfakta/services/phases.py`. |
|
||||||
|
| `lobby/views.py` | `reveal_scoreboard(request, code)` | FupOgFakta reveal/scoreboard progression, not a generic platform capability. | `fupogfakta/views.py::reveal_scoreboard` or cartridge phase service. |
|
||||||
|
| `lobby/views.py` | `start_next_round(request, code)` | FupOgFakta next-round bootstrap: copies prior `RoundConfig`, increments round, picks next question, re-enters lie phase. | `fupogfakta/services/rounds.py::start_next_round` plus cartridge-owned endpoint/driver integration. |
|
||||||
|
| `lobby/views.py` | `finish_game(request, code)` | Current finish path is tied to FupOgFakta scoreboard semantics and winner payload. | `fupogfakta/views.py::finish_game` until a truly generic platform finish contract exists. |
|
||||||
|
| `lobby/views.py` | `calculate_scores(request, code, round_question_id)` | Explicit FupOgFakta score resolution endpoint. | `fupogfakta/services/scoring.py` and/or remove when fully absorbed by cartridge phase driver. |
|
||||||
|
| `lobby/urls.py` | Gameplay routes for rounds, lies, guesses, scoreboard, finish | These route names expose FupOgFakta-specific phase/actions from the platform namespace. | Re-home under `fupogfakta/urls.py` or leave mounted under `/lobby/sessions/...` only as a temporary façade delegating to cartridge-owned code. |
|
||||||
|
| `lobby/tests.py` | `StartRoundTests`, `LieSubmissionTests`, `MixAnswersTests`, `GuessSubmissionTests`, `CanonicalRoundFlowTests`, `ScoreCalculationTests`, `RevealRoundFlowTests`, `SessionDetailRoundQuestionTests`, `SessionDetailPhaseViewModelTests`, `SmokeStagingCommandTests` | These test classes verify FupOgFakta game flow rather than platform mechanics. | Move/split into `fupogfakta/tests/` with only session creation/join/platform transport tests left in `lobby/tests.py`. |
|
||||||
|
| `lobby/management/commands/smoke_staging.py` | End-to-end gameplay smoke through lies/guesses/finish | Script executes one concrete game flow and should be cartridge-aware, not platform-owned. | `fupogfakta/management/commands/` or a shared smoke harness that delegates into cartridge-specific scenario runners. |
|
||||||
|
|
||||||
|
## Recommended ownership split by module
|
||||||
|
|
||||||
|
### Keep in `lobby/`
|
||||||
|
|
||||||
|
- Session creation/join and session-code lifecycle
|
||||||
|
- Generic player membership/presence reads
|
||||||
|
- Generic auth/host checks helpers (if extracted from views)
|
||||||
|
- Generic API error/i18n plumbing
|
||||||
|
- Future `GameRun` / driver orchestration, timers, and cartridge dispatch
|
||||||
|
- A slim generic `session_detail` envelope that can embed cartridge payloads under a dedicated game key
|
||||||
|
|
||||||
|
### Move to `fupogfakta/`
|
||||||
|
|
||||||
|
- Round state queries
|
||||||
|
- Question selection
|
||||||
|
- Lie/guess/reveal/scoreboard/finish transition rules
|
||||||
|
- Score calculation
|
||||||
|
- Answer mixing
|
||||||
|
- Gameplay payload/response builders
|
||||||
|
- Gameplay endpoints and tests
|
||||||
|
- Gameplay smoke command
|
||||||
|
|
||||||
|
## Explicit boundary for `session_detail`
|
||||||
|
|
||||||
|
`session_detail` is currently mixed.
|
||||||
|
|
||||||
|
### Generic part that should remain platform-owned
|
||||||
|
|
||||||
|
- Session identity/status metadata
|
||||||
|
- Player list / presence list
|
||||||
|
- Generic host/player capability envelope if it is game-agnostic
|
||||||
|
|
||||||
|
### FupOgFakta part that should move or be delegated
|
||||||
|
|
||||||
|
- `round_question` payload
|
||||||
|
- `reveal` payload
|
||||||
|
- `scoreboard` payload
|
||||||
|
- `phase_view_model` fields keyed to `lie`, `guess`, `scoreboard`, `finished`, `question_ready`, and 3–5-player MVP rules
|
||||||
|
|
||||||
|
A clean future shape would be:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"session": {"code": "ABC123", "status": "active", "game_type": "fupogfakta"},
|
||||||
|
"players": [...],
|
||||||
|
"game": {
|
||||||
|
"phase": "lie",
|
||||||
|
"payload": {"round_question": {...}, "reveal": null, "scoreboard": null}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That makes `lobby/` the shell and `fupogfakta/` the authority for game-state payloads.
|
||||||
|
|
||||||
|
## Concrete extraction sequence
|
||||||
|
|
||||||
|
1. **Move pure helpers first**
|
||||||
|
- `_get_current_round_question`
|
||||||
|
- `_select_round_question`
|
||||||
|
- `_prepare_mixed_answers`
|
||||||
|
- `_resolve_scores`
|
||||||
|
- `_build_lie_started_payload`
|
||||||
|
- `_build_reveal_payload`
|
||||||
|
2. **Move gameplay endpoints behind cartridge-owned service functions**
|
||||||
|
- `submit_lie`
|
||||||
|
- `submit_guess`
|
||||||
|
- `start_round`
|
||||||
|
- `start_next_round`
|
||||||
|
- `finish_game`
|
||||||
|
- `reveal_scoreboard`
|
||||||
|
- `calculate_scores`
|
||||||
|
3. **Slim `session_detail` into platform envelope + delegated cartridge payload**
|
||||||
|
4. **Move gameplay tests out of `lobby/tests.py`**
|
||||||
|
5. **Optionally leave compatibility routes in `lobby/urls.py` as a façade** until clients are rewired
|
||||||
|
|
||||||
|
## Risks this map is explicitly preventing
|
||||||
|
|
||||||
|
- Moving only models but leaving hidden phase-transition rules in `lobby/views.py`
|
||||||
|
- Treating `session_detail` as platform-generic while it still leaks cartridge payload semantics
|
||||||
|
- Leaving scoreboard/reveal transition logic behind as an undocumented coupling
|
||||||
|
- Splitting tests incorrectly so regressions stay "green" in `lobby/` while FupOgFakta behavior silently drifts
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
For #311 / #312, the repository should treat the following as **game-specific and extraction candidates**:
|
||||||
|
|
||||||
|
- round-question selection
|
||||||
|
- lie/guess/reveal/scoreboard/finish transitions
|
||||||
|
- answer mixing
|
||||||
|
- score resolution
|
||||||
|
- reveal/scoreboard payload builders
|
||||||
|
- FupOgFakta-specific session-detail subpayloads
|
||||||
|
- gameplay flow tests and smoke command
|
||||||
|
|
||||||
|
And it should treat the following as **platform-generic**:
|
||||||
|
|
||||||
|
- session identity/lifecycle
|
||||||
|
- player presence/membership
|
||||||
|
- host authorization shell
|
||||||
|
- generic error transport
|
||||||
|
- future game-driver dispatch/orchestration
|
||||||
|
|
||||||
|
That is the explicit `lobby` vs `fupogfakta` boundary this issue needs before code extraction proceeds.
|
||||||
110
docs/artifacts/issue-302-canonical-loop-smoke.json
Normal file
110
docs/artifacts/issue-302-canonical-loop-smoke.json
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"command": "python manage.py smoke_staging --artifact <path>",
|
||||||
|
"generated_at": "2026-03-16T15:19:30.105231+00:00",
|
||||||
|
"question": {
|
||||||
|
"prompt": "Smoke prompt?",
|
||||||
|
"correct_answer": "Correct"
|
||||||
|
},
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"step": "create_session",
|
||||||
|
"session_status": "lobby"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "join_players",
|
||||||
|
"players_count": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "start_round",
|
||||||
|
"session_status": "lie",
|
||||||
|
"round_question_id": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "auto_guess_transition",
|
||||||
|
"session_status": "guess",
|
||||||
|
"answers": [
|
||||||
|
"Lie from P3",
|
||||||
|
"Lie from P1",
|
||||||
|
"Lie from P2",
|
||||||
|
"Correct"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "submit_guesses",
|
||||||
|
"guess_results": [
|
||||||
|
{
|
||||||
|
"player_id": 1,
|
||||||
|
"selected_text": "Lie from P2",
|
||||||
|
"is_correct": false,
|
||||||
|
"fooled_player_id": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"player_id": 2,
|
||||||
|
"selected_text": "Correct",
|
||||||
|
"is_correct": true,
|
||||||
|
"fooled_player_id": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"player_id": 3,
|
||||||
|
"selected_text": "Lie from P1",
|
||||||
|
"is_correct": false,
|
||||||
|
"fooled_player_id": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "auto_reveal_transition",
|
||||||
|
"session_status": "reveal",
|
||||||
|
"reveal": {
|
||||||
|
"correct_answer": "Correct",
|
||||||
|
"lies_count": 3,
|
||||||
|
"guesses_count": 3,
|
||||||
|
"fooled_player_ids": [
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"correct_guess_player_ids": [
|
||||||
|
2
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "auto_scoreboard_transition",
|
||||||
|
"session_status": "scoreboard",
|
||||||
|
"leaderboard": [
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"nickname": "P2",
|
||||||
|
"score": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"nickname": "P1",
|
||||||
|
"score": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"nickname": "P3",
|
||||||
|
"score": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"step": "finish_game",
|
||||||
|
"session_status": "finished"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"session_code": "7YV59E",
|
||||||
|
"players": [
|
||||||
|
"P1",
|
||||||
|
"P2",
|
||||||
|
"P3"
|
||||||
|
],
|
||||||
|
"round_question_id": 1,
|
||||||
|
"guess_plan": {
|
||||||
|
"P1": "Lie from P2",
|
||||||
|
"P2": "Correct",
|
||||||
|
"P3": "Lie from P1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { HostShellComponent } from './host-shell.component';
|
|||||||
|
|
||||||
type FetchMock = ReturnType<typeof vi.fn>;
|
type FetchMock = ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
type FetchRouteHandler = (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>;
|
||||||
|
|
||||||
function jsonResponse(status: number, body: unknown) {
|
function jsonResponse(status: number, body: unknown) {
|
||||||
return {
|
return {
|
||||||
ok: status >= 200 && status < 300,
|
ok: status >= 200 && status < 300,
|
||||||
@@ -12,9 +14,14 @@ function jsonResponse(status: number, body: unknown) {
|
|||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFetchRouteMock(handler: FetchRouteHandler): FetchMock {
|
||||||
|
return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init)));
|
||||||
|
}
|
||||||
|
|
||||||
function sessionDetailPayload(
|
function sessionDetailPayload(
|
||||||
status: string,
|
status: string,
|
||||||
options?: {
|
options?: {
|
||||||
|
currentPhase?: string;
|
||||||
roundQuestionId?: number | null;
|
roundQuestionId?: number | null;
|
||||||
reveal?: {
|
reveal?: {
|
||||||
correct_answer: string;
|
correct_answer: string;
|
||||||
@@ -75,6 +82,7 @@ function sessionDetailPayload(
|
|||||||
},
|
},
|
||||||
phase_view_model: {
|
phase_view_model: {
|
||||||
status,
|
status,
|
||||||
|
current_phase: options?.currentPhase ?? status,
|
||||||
round_number: 1,
|
round_number: 1,
|
||||||
players_count: 2,
|
players_count: 2,
|
||||||
constraints: {
|
constraints: {
|
||||||
@@ -83,14 +91,18 @@ function sessionDetailPayload(
|
|||||||
min_players_reached: true,
|
min_players_reached: true,
|
||||||
max_players_allowed: true,
|
max_players_allowed: true,
|
||||||
},
|
},
|
||||||
|
readiness: {
|
||||||
|
question_ready: (options?.currentPhase ?? status) !== 'lobby',
|
||||||
|
scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard',
|
||||||
|
},
|
||||||
host: {
|
host: {
|
||||||
can_start_round: status === 'lobby',
|
can_start_round: (options?.currentPhase ?? status) === 'lobby',
|
||||||
can_show_question: false,
|
can_show_question: (options?.currentPhase ?? status) === 'lie',
|
||||||
can_mix_answers: false,
|
can_mix_answers: (options?.currentPhase ?? status) === 'lie' || (options?.currentPhase ?? status) === 'guess',
|
||||||
can_calculate_scores: false,
|
can_calculate_scores: (options?.currentPhase ?? status) === 'guess',
|
||||||
can_reveal_scoreboard: false,
|
can_reveal_scoreboard: (options?.currentPhase ?? status) === 'reveal',
|
||||||
can_start_next_round: status === 'scoreboard',
|
can_start_next_round: (options?.currentPhase ?? status) === 'scoreboard',
|
||||||
can_finish_game: status === 'scoreboard',
|
can_finish_game: (options?.currentPhase ?? status) === 'scoreboard',
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
can_join: status === 'lobby',
|
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 () => {
|
it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => {
|
||||||
const fetchMock: FetchMock = vi
|
let refreshCount = 0;
|
||||||
.fn()
|
const fetchMock = createFetchRouteMock((input, init) => {
|
||||||
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } }))
|
const url = String(input);
|
||||||
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 })));
|
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);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
const component = new HostShellComponent();
|
const component = new HostShellComponent();
|
||||||
component.sessionCode = ' abcd12 ';
|
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.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ;
|
||||||
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
|
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
|
||||||
|
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
||||||
|
|
||||||
await component.startNextRound();
|
await component.startNextRound();
|
||||||
|
|
||||||
@@ -227,6 +302,7 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
|
|
||||||
const component = new HostShellComponent();
|
const component = new HostShellComponent();
|
||||||
component.sessionCode = 'ABCD12';
|
component.sessionCode = 'ABCD12';
|
||||||
|
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
||||||
|
|
||||||
await component.finishGame();
|
await component.finishGame();
|
||||||
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
|
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
|
||||||
@@ -250,6 +326,7 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
|
|
||||||
const component = new HostShellComponent();
|
const component = new HostShellComponent();
|
||||||
component.sessionCode = ' ';
|
component.sessionCode = ' ';
|
||||||
|
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
|
||||||
|
|
||||||
await component.startNextRound();
|
await component.startNextRound();
|
||||||
await component.finishGame();
|
await component.finishGame();
|
||||||
@@ -259,6 +336,77 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
expect(component.finishError).toContain('Session code is required');
|
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 () => {
|
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 })));
|
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })));
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
@@ -290,10 +438,17 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
|
|
||||||
component.session = sessionDetailPayload('lie') as any;
|
component.session = sessionDetailPayload('lie') as any;
|
||||||
expect(component.canStartRound).toBe(false);
|
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.canStartNextRound).toBe(false);
|
||||||
expect(component.canFinishGame).toBe(false);
|
expect(component.canFinishGame).toBe(false);
|
||||||
|
|
||||||
component.session = sessionDetailPayload('scoreboard') as any;
|
component.session = sessionDetailPayload('scoreboard') as any;
|
||||||
|
expect(component.canLoadScoreboard).toBe(false);
|
||||||
expect(component.canStartNextRound).toBe(true);
|
expect(component.canStartNextRound).toBe(true);
|
||||||
expect(component.canFinishGame).toBe(true);
|
expect(component.canFinishGame).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
import { createApiClient } from '../../../../../src/api/client';
|
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 { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
||||||
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
|
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>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
|
||||||
<label *ngIf="canStartRound">{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
|
<label *ngIf="canStartRound">{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
|
||||||
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
|
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
|
||||||
<button *ngIf="canStartRound" (click)="startRound()" [disabled]="loading">{{ copy('host.start_round') }}</button>
|
<button (click)="startRound()" [disabled]="loading || !canStartRound">{{ 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 (click)="showQuestion()" [disabled]="loading || !canShowQuestion">{{ copy('host.show_question') }}</button>
|
||||||
<button *ngIf="canFinishGame || finishError" (click)="finishGame()" [disabled]="loading">{{ copy(finishError ? 'host.retry_finish' : 'host.finish_game') }}</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>
|
</div>
|
||||||
|
|
||||||
<p *ngIf="session" class="hint">{{ copy('host.audio_locale_hint') }}: {{ locale }}</p>
|
<p *ngIf="session" class="hint">{{ copy('host.audio_locale_hint') }}: {{ locale }}</p>
|
||||||
@@ -40,7 +48,7 @@ type LeaderboardResponse = FinishGameResponse;
|
|||||||
<ul>
|
<ul>
|
||||||
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
|
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
|
<div class="panel" *ngIf="showRevealPanel">
|
||||||
<h3>Reveal</h3>
|
<h3>Reveal</h3>
|
||||||
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
|
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
|
||||||
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
|
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
|
||||||
@@ -82,8 +90,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
roundQuestionId = '';
|
roundQuestionId = '';
|
||||||
loading = false;
|
loading = false;
|
||||||
error = '';
|
error = '';
|
||||||
|
scoreboardError = '';
|
||||||
nextRoundError = '';
|
nextRoundError = '';
|
||||||
finishError = '';
|
finishError = '';
|
||||||
|
scoreboardPayload = '';
|
||||||
finalLeaderboardPayload = '';
|
finalLeaderboardPayload = '';
|
||||||
finalLeaderboard: LeaderboardEntry[] = [];
|
finalLeaderboard: LeaderboardEntry[] = [];
|
||||||
finalWinner: LeaderboardEntry | null = null;
|
finalWinner: LeaderboardEntry | null = null;
|
||||||
@@ -121,16 +131,40 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
this.unsubscribeLocale = null;
|
this.unsubscribeLocale = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get gameplayPhase(): string | null {
|
||||||
|
return deriveGameplayPhase(this.session as any);
|
||||||
|
}
|
||||||
|
|
||||||
get canStartRound(): boolean {
|
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 {
|
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 {
|
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 {
|
copy(key: string): string {
|
||||||
@@ -169,6 +203,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
async refreshSession(): Promise<void> {
|
async refreshSession(): Promise<void> {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
|
this.scoreboardError = '';
|
||||||
this.nextRoundError = '';
|
this.nextRoundError = '';
|
||||||
this.finishError = '';
|
this.finishError = '';
|
||||||
try {
|
try {
|
||||||
@@ -192,6 +227,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async startRound(): Promise<void> {
|
async startRound(): Promise<void> {
|
||||||
|
if (!this.canStartRound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await this.runAction(async () => {
|
await this.runAction(async () => {
|
||||||
const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim());
|
const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim());
|
||||||
if (!state.session || state.errorMessage) {
|
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> {
|
async startNextRound(): Promise<void> {
|
||||||
|
if (!this.canStartNextRound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.nextRoundError = '';
|
this.nextRoundError = '';
|
||||||
this.error = '';
|
this.error = '';
|
||||||
@@ -226,6 +327,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async finishGame(): Promise<void> {
|
async finishGame(): Promise<void> {
|
||||||
|
if (!this.canFinishGame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.finishError = '';
|
this.finishError = '';
|
||||||
this.error = '';
|
this.error = '';
|
||||||
@@ -252,6 +357,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resetFinalLeaderboard(): void {
|
private resetFinalLeaderboard(): void {
|
||||||
|
this.scoreboardPayload = '';
|
||||||
this.finalLeaderboardPayload = '';
|
this.finalLeaderboardPayload = '';
|
||||||
this.finalLeaderboard = [];
|
this.finalLeaderboard = [];
|
||||||
this.finalWinner = null;
|
this.finalWinner = null;
|
||||||
@@ -262,7 +368,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
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);
|
const code = this.normalizeCode(this.session.session.code || this.sessionCode);
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function jsonResponse(status: number, body: unknown) {
|
|||||||
function sessionDetailPayload(
|
function sessionDetailPayload(
|
||||||
status: string,
|
status: string,
|
||||||
options?: {
|
options?: {
|
||||||
|
currentPhase?: string;
|
||||||
answers?: string[];
|
answers?: string[];
|
||||||
players?: Array<{ id: number; nickname: string; score: number }>;
|
players?: Array<{ id: number; nickname: string; score: number }>;
|
||||||
roundQuestionId?: number | null;
|
roundQuestionId?: number | null;
|
||||||
@@ -79,6 +80,7 @@ function sessionDetailPayload(
|
|||||||
},
|
},
|
||||||
phase_view_model: {
|
phase_view_model: {
|
||||||
status,
|
status,
|
||||||
|
current_phase: options?.currentPhase ?? status,
|
||||||
round_number: 1,
|
round_number: 1,
|
||||||
players_count: (options?.players ?? []).length,
|
players_count: (options?.players ?? []).length,
|
||||||
constraints: {
|
constraints: {
|
||||||
@@ -87,6 +89,10 @@ function sessionDetailPayload(
|
|||||||
min_players_reached: true,
|
min_players_reached: true,
|
||||||
max_players_allowed: true,
|
max_players_allowed: true,
|
||||||
},
|
},
|
||||||
|
readiness: {
|
||||||
|
question_ready: (options?.currentPhase ?? status) !== 'lobby',
|
||||||
|
scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard',
|
||||||
|
},
|
||||||
host: {
|
host: {
|
||||||
can_start_round: false,
|
can_start_round: false,
|
||||||
can_show_question: false,
|
can_show_question: false,
|
||||||
@@ -97,10 +103,10 @@ function sessionDetailPayload(
|
|||||||
can_finish_game: false,
|
can_finish_game: false,
|
||||||
},
|
},
|
||||||
player: {
|
player: {
|
||||||
can_join: status === 'lobby',
|
can_join: (options?.currentPhase ?? status) === 'lobby',
|
||||||
can_submit_lie: status === 'lie',
|
can_submit_lie: (options?.currentPhase ?? status) === 'lie',
|
||||||
can_submit_guess: status === 'guess',
|
can_submit_guess: (options?.currentPhase ?? status) === 'guess',
|
||||||
can_view_final_result: status === 'finished',
|
can_view_final_result: (options?.currentPhase ?? status) === 'finished',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -147,9 +153,8 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
|||||||
component.sessionToken = 'token-1';
|
component.sessionToken = 'token-1';
|
||||||
component.lieText = 'my lie';
|
component.lieText = 'my lie';
|
||||||
component.session = {
|
component.session = {
|
||||||
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
|
...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any),
|
||||||
round_question: { id: 11, prompt: 'Q?', answers: [] },
|
round_question: { id: 11, prompt: 'Q?', answers: [] },
|
||||||
players: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await component.submitLie();
|
await component.submitLie();
|
||||||
@@ -268,9 +273,8 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
|||||||
component.sessionToken = 'token-1';
|
component.sessionToken = 'token-1';
|
||||||
component.selectedGuess = 'B';
|
component.selectedGuess = 'B';
|
||||||
component.session = {
|
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' }] },
|
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
|
||||||
players: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await component.submitGuess();
|
await component.submitGuess();
|
||||||
@@ -294,6 +298,29 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
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 () => {
|
it('auto-refreshes player session to avoid host/player state desync between rounds', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
@@ -416,6 +443,34 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
|||||||
expect(values.get('wpp.session-context')).toBeUndefined();
|
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 () => {
|
it('syncs player hash-route with latest phase during periodic state sync', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { FormsModule } from '@angular/forms';
|
|||||||
|
|
||||||
import { createApiClient } from '../../../../../src/api/client';
|
import { createApiClient } from '../../../../../src/api/client';
|
||||||
import type { SessionDetailResponse } from '../../../../../src/api/types';
|
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 { createSessionContextStore } from '../../../../../src/spa/session-context-store';
|
||||||
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
||||||
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
|
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>
|
<p *ngIf="session.round_question"><strong>{{ copy('common.prompt') }}:</strong> {{ session.round_question.prompt }}</p>
|
||||||
|
|
||||||
<ng-container *ngIf="showLieControls">
|
<ng-container *ngIf="showLieControls">
|
||||||
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading" /></label>
|
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading || !canSubmitLie" /></label>
|
||||||
<button (click)="submitLie()" [disabled]="loading">{{ copy('player.submit_lie') }}</button>
|
<button (click)="submitLie()" [disabled]="loading || !canSubmitLie">{{ copy('player.submit_lie') }}</button>
|
||||||
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading">{{ copy('player.retry_lie_submit') }}</button>
|
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading || !canSubmitLie">{{ copy('player.retry_lie_submit') }}</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container *ngIf="showGuessControls">
|
<ng-container *ngIf="showGuessControls">
|
||||||
@@ -81,17 +85,17 @@ function resolveLocalStorage(): Storage | undefined {
|
|||||||
*ngFor="let answer of session.round_question?.answers"
|
*ngFor="let answer of session.round_question?.answers"
|
||||||
(click)="selectedGuess = answer.text"
|
(click)="selectedGuess = answer.text"
|
||||||
[class.active]="selectedGuess === answer.text"
|
[class.active]="selectedGuess === answer.text"
|
||||||
[disabled]="loading"
|
[disabled]="loading || !canSubmitGuess"
|
||||||
>
|
>
|
||||||
{{ answer.text }}
|
{{ answer.text }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button (click)="submitGuess()" [disabled]="loading || !selectedGuess">{{ copy('player.submit_guess') }}</button>
|
<button (click)="submitGuess()" [disabled]="loading || !canSubmitGuess || !selectedGuess">{{ copy('player.submit_guess') }}</button>
|
||||||
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button>
|
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading || !canSubmitGuess">{{ copy('player.retry_guess_submit') }}</button>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
|
<div class="panel" *ngIf="showRevealPanel">
|
||||||
<h3>Reveal</h3>
|
<h3>Reveal</h3>
|
||||||
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
|
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
|
||||||
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
|
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
|
||||||
@@ -205,6 +209,22 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
this.restoreAudioGuard = null;
|
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 => {
|
private readonly handleOnline = (): void => {
|
||||||
this.connectionState = 'reconnecting';
|
this.connectionState = 'reconnecting';
|
||||||
void this.retryReconnect();
|
void this.retryReconnect();
|
||||||
@@ -453,7 +473,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
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);
|
const code = this.normalizeCode(this.session.session.code || this.sessionCode);
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return;
|
return;
|
||||||
@@ -543,7 +563,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async submitLie(): Promise<void> {
|
async submitLie(): Promise<void> {
|
||||||
if (!this.session?.round_question?.id) {
|
if (!this.session?.round_question?.id || !this.canSubmitLie) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
@@ -571,7 +591,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async submitGuess(): Promise<void> {
|
async submitGuess(): Promise<void> {
|
||||||
if (!this.session?.round_question?.id || !this.selectedGuess) {
|
if (!this.session?.round_question?.id || !this.selectedGuess || !this.canSubmitGuess) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|||||||
134
frontend/package-lock.json
generated
134
frontend/package-lock.json
generated
@@ -7,12 +7,125 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "wpp-frontend-api-client-baseline",
|
"name": "wpp-frontend-api-client-baseline",
|
||||||
"version": "0.1.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vitest": "^2.1.9"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||||
@@ -1188,6 +1301,15 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
@@ -1263,6 +1385,12 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -1449,6 +1577,12 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/zone.js": {
|
||||||
|
"version": "0.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
|
||||||
|
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"build": "tsc --noEmit"
|
"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": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.13.10",
|
"@types/node": "^22.13.10",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
|
|||||||
reveal,
|
reveal,
|
||||||
phase_view_model: {
|
phase_view_model: {
|
||||||
status: readString(phase, 'status', 'session_detail.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'),
|
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
|
||||||
players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'),
|
players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'),
|
||||||
constraints: {
|
constraints: {
|
||||||
@@ -203,6 +204,19 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
|
|||||||
min_players_reached: readBoolean(constraints, 'min_players_reached', 'session_detail.phase_view_model.constraints'),
|
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')
|
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: {
|
host: {
|
||||||
can_start_round: readBoolean(host, 'can_start_round', 'session_detail.phase_view_model.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'),
|
can_show_question: readBoolean(host, 'can_show_question', 'session_detail.phase_view_model.host'),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface SessionRoundQuestion {
|
|||||||
|
|
||||||
export interface PhaseViewModel {
|
export interface PhaseViewModel {
|
||||||
status: string;
|
status: string;
|
||||||
|
current_phase?: string;
|
||||||
round_number: number;
|
round_number: number;
|
||||||
players_count: number;
|
players_count: number;
|
||||||
constraints: {
|
constraints: {
|
||||||
@@ -40,6 +41,10 @@ export interface PhaseViewModel {
|
|||||||
min_players_reached: boolean;
|
min_players_reached: boolean;
|
||||||
max_players_allowed: boolean;
|
max_players_allowed: boolean;
|
||||||
};
|
};
|
||||||
|
readiness?: {
|
||||||
|
question_ready?: boolean;
|
||||||
|
scoreboard_ready?: boolean;
|
||||||
|
};
|
||||||
host: {
|
host: {
|
||||||
can_start_round: boolean;
|
can_start_round: boolean;
|
||||||
can_show_question: boolean;
|
can_show_question: boolean;
|
||||||
|
|||||||
@@ -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 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 =
|
export type GameplayPhaseEvent =
|
||||||
| 'LIES_LOCKED'
|
| 'LIES_LOCKED'
|
||||||
@@ -40,8 +49,7 @@ export function allowedGameplayEvents(phase: GameplayPhase): GameplayPhaseEvent[
|
|||||||
return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[];
|
return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null {
|
function derivePhaseFromStatus(status: string | null | undefined): GameplayPhase | null {
|
||||||
const status = session?.session.status;
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -56,3 +64,59 @@ export function deriveGameplayPhase(session: SessionDetailResponse | null): Game
|
|||||||
|
|
||||||
return null;
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
allowedGameplayEvents,
|
allowedGameplayEvents,
|
||||||
deriveGameplayPhase,
|
deriveGameplayPhase,
|
||||||
|
isHostGameplayActionAllowed,
|
||||||
|
isPlayerGameplayActionAllowed,
|
||||||
transitionGameplayPhase,
|
transitionGameplayPhase,
|
||||||
type GameplayPhase
|
type GameplayPhase
|
||||||
} from '../src/spa/gameplay-phase-machine';
|
} from '../src/spa/gameplay-phase-machine';
|
||||||
@@ -105,4 +107,44 @@ describe('gameplay phase machine skeleton', () => {
|
|||||||
})
|
})
|
||||||
).toBe('scoreboard');
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { defineConfig } from 'vitest/config';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
include: ['tests/**/*.test.ts'],
|
include: ['tests/**/*.test.ts', 'angular/src/**/*.spec.ts'],
|
||||||
|
setupFiles: ['angular/src/test-setup.ts'],
|
||||||
exclude: ['**/node_modules/**']
|
exclude: ['**/node_modules/**']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -83,6 +83,7 @@ class RoundConfig(models.Model):
|
|||||||
points_bluff = models.IntegerField(default=2)
|
points_bluff = models.IntegerField(default=2)
|
||||||
lie_seconds = models.PositiveIntegerField(default=45)
|
lie_seconds = models.PositiveIntegerField(default=45)
|
||||||
guess_seconds = models.PositiveIntegerField(default=30)
|
guess_seconds = models.PositiveIntegerField(default=30)
|
||||||
|
started_from_scoreboard = models.BooleanField(default=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = (("session", "number"),)
|
unique_together = (("session", "number"),)
|
||||||
|
|||||||
275
fupogfakta/payloads.py
Normal file
275
fupogfakta/payloads.py
Normal 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
479
fupogfakta/services.py
Normal 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
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from fupogfakta.models import Category, GameSession, Player, Question, RoundQues
|
|||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -18,6 +18,26 @@ class Command(BaseCommand):
|
|||||||
help="Optional path to write smoke result artifact as JSON",
|
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):
|
def handle(self, *args, **options):
|
||||||
GameSession.objects.all().delete()
|
GameSession.objects.all().delete()
|
||||||
Player.objects.all().delete()
|
Player.objects.all().delete()
|
||||||
@@ -30,11 +50,14 @@ class Command(BaseCommand):
|
|||||||
category.is_active = True
|
category.is_active = True
|
||||||
category.save(update_fields=["is_active"])
|
category.save(update_fields=["is_active"])
|
||||||
|
|
||||||
Question.objects.get_or_create(
|
question, _ = Question.objects.get_or_create(
|
||||||
category=category,
|
category=category,
|
||||||
prompt="Smoke prompt?",
|
prompt="Smoke prompt?",
|
||||||
defaults={"correct_answer": "Correct", "is_active": True},
|
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()
|
User = get_user_model()
|
||||||
host, _ = User.objects.get_or_create(username="smoke-host")
|
host, _ = User.objects.get_or_create(username="smoke-host")
|
||||||
@@ -42,111 +65,254 @@ class Command(BaseCommand):
|
|||||||
host.is_staff = True
|
host.is_staff = True
|
||||||
host.save()
|
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 = Client()
|
||||||
host_client.force_login(host)
|
host_client.force_login(host)
|
||||||
|
|
||||||
create_res = host_client.post("/lobby/sessions/create", content_type="application/json")
|
create_payload = self._expect_status(
|
||||||
if create_res.status_code != 201:
|
host_client.post("/lobby/sessions/create", content_type="application/json"),
|
||||||
raise CommandError(f"create_session failed: {create_res.status_code} {create_res.content!r}")
|
201,
|
||||||
|
"create_session",
|
||||||
code = create_res.json()["session"]["code"]
|
)
|
||||||
|
code = create_payload["session"]["code"]
|
||||||
|
artifact["session_code"] = code
|
||||||
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
|
"step": "create_session",
|
||||||
|
"session_status": create_payload["session"]["status"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
players = []
|
players = []
|
||||||
for nickname in ["P1", "P2", "P3"]:
|
for nickname in ["P1", "P2", "P3"]:
|
||||||
join_res = Client().post(
|
join_payload = self._expect_status(
|
||||||
"/lobby/sessions/join",
|
Client().post(
|
||||||
data=json.dumps({"code": code, "nickname": nickname}),
|
"/lobby/sessions/join",
|
||||||
content_type="application/json",
|
data=json.dumps({"code": code, "nickname": nickname}),
|
||||||
|
content_type="application/json",
|
||||||
|
),
|
||||||
|
201,
|
||||||
|
f"join_session[{nickname}]",
|
||||||
)
|
)
|
||||||
if join_res.status_code != 201:
|
players.append(join_payload["player"])
|
||||||
raise CommandError(f"join_session failed for {nickname}: {join_res.status_code}")
|
artifact["players"] = [player["nickname"] for player in players]
|
||||||
players.append(join_res.json()["player"])
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
start_res = host_client.post(
|
"step": "join_players",
|
||||||
f"/lobby/sessions/{code}/rounds/start",
|
"players_count": len(players),
|
||||||
data=json.dumps({"category_slug": category.slug}),
|
}
|
||||||
content_type="application/json",
|
|
||||||
)
|
)
|
||||||
if start_res.status_code != 201:
|
|
||||||
raise CommandError(f"start_round failed: {start_res.status_code}")
|
|
||||||
|
|
||||||
round_question_id = start_res.json()["round_question"]["id"]
|
start_payload = self._expect_status(
|
||||||
|
host_client.post(
|
||||||
|
f"/lobby/sessions/{code}/rounds/start",
|
||||||
|
data=json.dumps({"category_slug": category.slug}),
|
||||||
|
content_type="application/json",
|
||||||
|
),
|
||||||
|
201,
|
||||||
|
"start_round",
|
||||||
|
)
|
||||||
|
self._expect_session_status(start_payload, GameSession.Status.LIE, "start_round")
|
||||||
|
|
||||||
|
round_question_id = start_payload["round_question"]["id"]
|
||||||
|
artifact["round_question_id"] = round_question_id
|
||||||
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
|
"step": "start_round",
|
||||||
|
"session_status": start_payload["session"]["status"],
|
||||||
|
"round_question_id": round_question_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
answers = []
|
answers = []
|
||||||
|
lie_transition_payload = None
|
||||||
for player in players:
|
for player in players:
|
||||||
nick = player["nickname"]
|
nickname = player["nickname"]
|
||||||
lie_res = Client().post(
|
lie_payload = self._expect_status(
|
||||||
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
|
Client().post(
|
||||||
data=json.dumps(
|
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
|
||||||
{
|
data=json.dumps(
|
||||||
"player_id": player["id"],
|
{
|
||||||
"session_token": player["session_token"],
|
"player_id": player["id"],
|
||||||
"text": f"Lie from {nick}",
|
"session_token": player["session_token"],
|
||||||
}
|
"text": f"Lie from {nickname}",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
),
|
),
|
||||||
content_type="application/json",
|
201,
|
||||||
|
f"submit_lie[{nickname}]",
|
||||||
)
|
)
|
||||||
if lie_res.status_code != 201:
|
if lie_payload.get("answers"):
|
||||||
raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}")
|
answers = lie_payload["answers"]
|
||||||
if lie_res.json().get("answers"):
|
lie_transition_payload = lie_payload
|
||||||
answers = lie_res.json()["answers"]
|
|
||||||
|
|
||||||
if not answers:
|
if not answers:
|
||||||
detail_res = host_client.get(f"/lobby/sessions/{code}")
|
detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_lies")
|
||||||
if detail_res.status_code != 200:
|
answers = detail_payload.get("round_question", {}).get("answers", [])
|
||||||
raise CommandError(f"session_detail after lies failed: {detail_res.status_code}")
|
self._expect_session_status(detail_payload, GameSession.Status.GUESS, "session_detail_after_lies")
|
||||||
answers = detail_res.json().get("round_question", {}).get("answers", [])
|
lie_transition_payload = detail_payload
|
||||||
|
|
||||||
if not answers:
|
if not answers:
|
||||||
raise CommandError("canonical lie->guess transition returned empty answers")
|
self._fail("auto_guess_transition", "canonical lie->guess transition returned empty answers")
|
||||||
|
|
||||||
for player in players:
|
if not any(answer.get("text") == question.correct_answer for answer in answers):
|
||||||
nick = player["nickname"]
|
self._fail("auto_guess_transition", "mixed answers missing correct answer", {"answers": answers})
|
||||||
selected = next((a for a in answers if a.get("player_id") != player["id"]), answers[0])
|
if len(answers) < len(players) + 1:
|
||||||
guess_res = Client().post(
|
self._fail(
|
||||||
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
|
"auto_guess_transition",
|
||||||
data=json.dumps(
|
"mixed answers shorter than expected bluff set",
|
||||||
{
|
{"answers": answers, "players_count": len(players)},
|
||||||
"player_id": player["id"],
|
|
||||||
"session_token": player["session_token"],
|
|
||||||
"selected_text": selected["text"],
|
|
||||||
}
|
|
||||||
),
|
|
||||||
content_type="application/json",
|
|
||||||
)
|
)
|
||||||
if guess_res.status_code != 201:
|
|
||||||
raise CommandError(f"submit_guess failed for {nick}: {guess_res.status_code}")
|
|
||||||
|
|
||||||
detail_res = host_client.get(f"/lobby/sessions/{code}")
|
self._expect_session_status(lie_transition_payload, GameSession.Status.GUESS, "auto_guess_transition")
|
||||||
if detail_res.status_code != 200:
|
artifact["steps"].append(
|
||||||
raise CommandError(f"session_detail after guesses failed: {detail_res.status_code}")
|
{
|
||||||
if detail_res.json()["session"]["status"] != GameSession.Status.SCOREBOARD:
|
"step": "auto_guess_transition",
|
||||||
raise CommandError("canonical guess->reveal->scoreboard transition did not reach scoreboard")
|
"session_status": lie_transition_payload["session"]["status"],
|
||||||
|
"answers": [answer["text"] for answer in answers],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json")
|
answer_texts = {answer["text"] for answer in answers}
|
||||||
if finish_res.status_code != 200:
|
correct_answer = next((answer["text"] for answer in answers if answer.get("text") == question.correct_answer), None)
|
||||||
raise CommandError(f"finish_game failed: {finish_res.status_code}")
|
if correct_answer is None:
|
||||||
|
self._fail("submit_guesses", "could not resolve correct answer from mixed answers", {"answers": answers})
|
||||||
|
|
||||||
|
guess_plan = {
|
||||||
|
players[0]["nickname"]: "Lie from P2",
|
||||||
|
players[1]["nickname"]: correct_answer,
|
||||||
|
players[2]["nickname"]: "Lie from P1",
|
||||||
|
}
|
||||||
|
missing_guess_targets = {text for text in guess_plan.values() if text not in answer_texts}
|
||||||
|
if missing_guess_targets:
|
||||||
|
self._fail(
|
||||||
|
"submit_guesses",
|
||||||
|
"expected bluff targets missing from mixed answers",
|
||||||
|
{"answers": answers, "missing_guess_targets": sorted(missing_guess_targets)},
|
||||||
|
)
|
||||||
|
artifact["guess_plan"] = guess_plan
|
||||||
|
|
||||||
|
guess_payloads = []
|
||||||
|
for player in players:
|
||||||
|
nickname = player["nickname"]
|
||||||
|
guess_payload = self._expect_status(
|
||||||
|
Client().post(
|
||||||
|
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"player_id": player["id"],
|
||||||
|
"session_token": player["session_token"],
|
||||||
|
"selected_text": guess_plan[nickname],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
),
|
||||||
|
201,
|
||||||
|
f"submit_guess[{nickname}]",
|
||||||
|
)
|
||||||
|
guess_payloads.append(guess_payload)
|
||||||
|
|
||||||
|
reveal_payload = guess_payloads[-1]
|
||||||
|
self._expect_session_status(reveal_payload, GameSession.Status.REVEAL, "auto_reveal_transition")
|
||||||
|
if not reveal_payload.get("phase_transition", {}).get("auto_advanced"):
|
||||||
|
self._fail("auto_reveal_transition", "expected auto_advanced=true on final guess", reveal_payload)
|
||||||
|
reveal = reveal_payload.get("reveal")
|
||||||
|
if not reveal:
|
||||||
|
self._fail("auto_reveal_transition", "missing canonical reveal payload", reveal_payload)
|
||||||
|
if reveal.get("correct_answer") != question.correct_answer:
|
||||||
|
self._fail(
|
||||||
|
"auto_reveal_transition",
|
||||||
|
"reveal payload returned wrong correct answer",
|
||||||
|
{"expected": question.correct_answer, "reveal": reveal},
|
||||||
|
)
|
||||||
|
if len(reveal.get("lies", [])) != len(players):
|
||||||
|
self._fail("auto_reveal_transition", "unexpected lie count in reveal payload", reveal)
|
||||||
|
if len(reveal.get("guesses", [])) != len(players):
|
||||||
|
self._fail("auto_reveal_transition", "unexpected guess count in reveal payload", reveal)
|
||||||
|
|
||||||
|
fooled_guesses = [guess for guess in reveal["guesses"] if not guess.get("is_correct")]
|
||||||
|
correct_guesses = [guess for guess in reveal["guesses"] if guess.get("is_correct")]
|
||||||
|
if len(fooled_guesses) != 2:
|
||||||
|
self._fail("auto_reveal_transition", "expected exactly two bluff guesses", reveal)
|
||||||
|
if len(correct_guesses) != 1:
|
||||||
|
self._fail("auto_reveal_transition", "expected exactly one correct guess", reveal)
|
||||||
|
if any(guess.get("fooled_player_id") is None for guess in fooled_guesses):
|
||||||
|
self._fail("auto_reveal_transition", "bluff guesses missing fooled_player_id", reveal)
|
||||||
|
|
||||||
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
|
"step": "submit_guesses",
|
||||||
|
"guess_results": [
|
||||||
|
{
|
||||||
|
"player_id": payload["guess"]["player_id"],
|
||||||
|
"selected_text": payload["guess"]["selected_text"],
|
||||||
|
"is_correct": payload["guess"]["is_correct"],
|
||||||
|
"fooled_player_id": payload["guess"].get("fooled_player_id"),
|
||||||
|
}
|
||||||
|
for payload in guess_payloads
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
|
"step": "auto_reveal_transition",
|
||||||
|
"session_status": reveal_payload["session"]["status"],
|
||||||
|
"reveal": {
|
||||||
|
"correct_answer": reveal["correct_answer"],
|
||||||
|
"lies_count": len(reveal["lies"]),
|
||||||
|
"guesses_count": len(reveal["guesses"]),
|
||||||
|
"fooled_player_ids": sorted(guess["fooled_player_id"] for guess in fooled_guesses),
|
||||||
|
"correct_guess_player_ids": sorted(guess["player_id"] for guess in correct_guesses),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_guesses")
|
||||||
|
self._expect_session_status(detail_payload, GameSession.Status.SCOREBOARD, "auto_scoreboard_transition")
|
||||||
|
if detail_payload.get("reveal") != reveal:
|
||||||
|
self._fail("auto_scoreboard_transition", "scoreboard promotion changed canonical reveal payload", detail_payload)
|
||||||
|
scoreboard = detail_payload.get("scoreboard")
|
||||||
|
if not scoreboard:
|
||||||
|
self._fail("auto_scoreboard_transition", "missing scoreboard payload after promotion", detail_payload)
|
||||||
|
if len(scoreboard) != len(players):
|
||||||
|
self._fail("auto_scoreboard_transition", "unexpected scoreboard length", detail_payload)
|
||||||
|
if not detail_payload.get("phase_view_model", {}).get("readiness", {}).get("scoreboard_ready"):
|
||||||
|
self._fail("auto_scoreboard_transition", "scoreboard_ready=false after promotion", detail_payload)
|
||||||
|
|
||||||
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
|
"step": "auto_scoreboard_transition",
|
||||||
|
"session_status": detail_payload["session"]["status"],
|
||||||
|
"leaderboard": scoreboard,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
finish_payload = self._expect_status(
|
||||||
|
host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json"),
|
||||||
|
200,
|
||||||
|
"finish_game",
|
||||||
|
)
|
||||||
|
self._expect_session_status(finish_payload, GameSession.Status.FINISHED, "finish_game")
|
||||||
|
artifact["steps"].append(
|
||||||
|
{
|
||||||
|
"step": "finish_game",
|
||||||
|
"session_status": finish_payload["session"]["status"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
artifact_path = options.get("artifact")
|
artifact_path = options.get("artifact")
|
||||||
if artifact_path:
|
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 = Path(artifact_path)
|
||||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")
|
output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")
|
||||||
|
|||||||
697
lobby/tests.py
697
lobby/tests.py
@@ -1,3 +1,4 @@
|
|||||||
|
import inspect
|
||||||
import json
|
import json
|
||||||
import tempfile
|
import tempfile
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -10,6 +11,7 @@ from django.test import TestCase, override_settings
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from fupogfakta import payloads as gameplay_payloads, services as gameplay_services
|
||||||
from fupogfakta.models import (
|
from fupogfakta.models import (
|
||||||
Category,
|
Category,
|
||||||
GameSession,
|
GameSession,
|
||||||
@@ -21,11 +23,343 @@ from fupogfakta.models import (
|
|||||||
RoundQuestion,
|
RoundQuestion,
|
||||||
ScoreEvent,
|
ScoreEvent,
|
||||||
)
|
)
|
||||||
|
from lobby import views as lobby_views
|
||||||
from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale
|
from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale
|
||||||
|
|
||||||
User = get_user_model()
|
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):
|
class LobbyFlowTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.host = User.objects.create_user(username="host", password="secret123")
|
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()["locale"], "en")
|
||||||
self.assertEqual(response.json()["error"], "Only host can start round")
|
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):
|
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")
|
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.sync_broadcast_phase_event")
|
||||||
@patch("lobby.views._resolve_scores")
|
@patch("lobby.views._resolve_scores")
|
||||||
@patch("lobby.views.GameSession.objects.get")
|
@patch("lobby.views.GameSession.objects.get")
|
||||||
@@ -1216,6 +1623,25 @@ class RevealRoundFlowTests(TestCase):
|
|||||||
self.session.refresh_from_db()
|
self.session.refresh_from_db()
|
||||||
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
|
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):
|
def test_finish_game_requires_host(self):
|
||||||
self.client.login(username="other_reveal", password="secret123")
|
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.status, GameSession.Status.LIE)
|
||||||
self.assertEqual(self.session.current_round, 2)
|
self.assertEqual(self.session.current_round, 2)
|
||||||
self.assertTrue(
|
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(
|
self.assertTrue(
|
||||||
RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists()
|
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[0], self.session.code)
|
||||||
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
|
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):
|
def test_start_next_round_requires_host(self):
|
||||||
self.session.status = GameSession.Status.SCOREBOARD
|
self.session.status = GameSession.Status.SCOREBOARD
|
||||||
self.session.save(update_fields=["status"])
|
self.session.save(update_fields=["status"])
|
||||||
@@ -1905,7 +2572,7 @@ class SmokeStagingCommandTests(TestCase):
|
|||||||
self.assertEqual(session.status, GameSession.Status.FINISHED)
|
self.assertEqual(session.status, GameSession.Status.FINISHED)
|
||||||
self.assertEqual(Player.objects.filter(session=session).count(), 3)
|
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:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
artifact_path = Path(tmp_dir) / "smoke.json"
|
artifact_path = Path(tmp_dir) / "smoke.json"
|
||||||
call_command("smoke_staging", artifact=str(artifact_path))
|
call_command("smoke_staging", artifact=str(artifact_path))
|
||||||
@@ -1913,24 +2580,40 @@ class SmokeStagingCommandTests(TestCase):
|
|||||||
self.assertTrue(artifact_path.exists())
|
self.assertTrue(artifact_path.exists())
|
||||||
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
|
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
|
||||||
self.assertTrue(payload["ok"])
|
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.assertEqual(payload["players"], ["P1", "P2", "P3"])
|
||||||
self.assertIn("generated_at", payload)
|
self.assertIn("generated_at", payload)
|
||||||
self.assertIn("session_code", 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(
|
self.assertEqual(
|
||||||
payload["steps"],
|
step_names,
|
||||||
[
|
[
|
||||||
"create_session",
|
"create_session",
|
||||||
"join_players",
|
"join_players",
|
||||||
"start_round",
|
"start_round",
|
||||||
"submit_lies",
|
|
||||||
"auto_guess_transition",
|
"auto_guess_transition",
|
||||||
"submit_guesses",
|
"submit_guesses",
|
||||||
"auto_reveal_to_scoreboard",
|
"auto_reveal_transition",
|
||||||
|
"auto_scoreboard_transition",
|
||||||
"finish_game",
|
"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):
|
class I18nResolverTests(TestCase):
|
||||||
def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self):
|
def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self):
|
||||||
|
|||||||
610
lobby/views.py
610
lobby/views.py
@@ -1,28 +1,39 @@
|
|||||||
import json
|
|
||||||
import random
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.http import HttpRequest, JsonResponse
|
from django.http import HttpRequest, JsonResponse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.http import require_GET, require_POST
|
from django.views.decorators.http import require_GET, require_POST
|
||||||
|
|
||||||
from fupogfakta.models import (
|
from fupogfakta.models import GameSession, Guess, LieAnswer, Player, RoundConfig, RoundQuestion, ScoreEvent
|
||||||
Category,
|
from fupogfakta.payloads import (
|
||||||
GameSession,
|
build_leaderboard as _build_leaderboard,
|
||||||
Guess,
|
build_reveal_payload as _build_reveal_payload,
|
||||||
LieAnswer,
|
build_scoreboard_phase_event as _build_scoreboard_phase_event,
|
||||||
Player,
|
build_session_detail_gameplay_payload as _build_session_detail_gameplay_payload,
|
||||||
Question,
|
)
|
||||||
RoundConfig,
|
from fupogfakta.services import (
|
||||||
RoundQuestion,
|
finish_game as _finish_game,
|
||||||
ScoreEvent,
|
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 realtime.broadcast import sync_broadcast_phase_event
|
||||||
|
|
||||||
from .i18n import api_error
|
from .i18n import api_error
|
||||||
|
|
||||||
|
_GAMEPLAY_SERVICE_OWNERSHIP_EXPORTS = (
|
||||||
|
_select_round_question,
|
||||||
|
_build_scoreboard_phase_event,
|
||||||
|
)
|
||||||
|
|
||||||
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
SESSION_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
SESSION_CODE_LENGTH = 6
|
SESSION_CODE_LENGTH = 6
|
||||||
MAX_CODE_GENERATION_ATTEMPTS = 20
|
MAX_CODE_GENERATION_ATTEMPTS = 20
|
||||||
@@ -64,260 +75,17 @@ def _create_unique_session_code() -> str:
|
|||||||
raise RuntimeError("Could not generate unique session code")
|
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:
|
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
|
||||||
if session.status != GameSession.Status.REVEAL:
|
transition = _promote_reveal_to_scoreboard(session)
|
||||||
return session
|
if transition.should_broadcast:
|
||||||
|
sync_broadcast_phase_event(
|
||||||
|
transition.session.code,
|
||||||
|
transition.phase_event_name,
|
||||||
|
transition.phase_event_payload,
|
||||||
|
)
|
||||||
|
return transition.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)
|
|
||||||
sync_broadcast_phase_event(
|
|
||||||
session.code,
|
|
||||||
"phase.scoreboard",
|
|
||||||
{"leaderboard": list(leaderboard), "current_round": session.current_round},
|
|
||||||
)
|
|
||||||
session.refresh_from_db(fields=["status"])
|
|
||||||
return 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
|
@require_POST
|
||||||
@@ -426,21 +194,10 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
|
|
||||||
session = _maybe_promote_reveal_to_scoreboard(session)
|
session = _maybe_promote_reveal_to_scoreboard(session)
|
||||||
current_round_question = _get_current_round_question(session)
|
current_round_question = _get_current_round_question(session)
|
||||||
|
gameplay_payload = _build_session_detail_gameplay_payload(
|
||||||
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(
|
|
||||||
session,
|
session,
|
||||||
|
current_round_question=current_round_question,
|
||||||
players_count=len(players),
|
players_count=len(players),
|
||||||
has_round_question=bool(current_round_question),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(
|
||||||
@@ -453,14 +210,7 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
"players_count": len(players),
|
"players_count": len(players),
|
||||||
},
|
},
|
||||||
"players": players,
|
"players": players,
|
||||||
"round_question": round_question_payload,
|
**gameplay_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,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -496,95 +246,23 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
status=403,
|
status=403,
|
||||||
)
|
)
|
||||||
|
|
||||||
if session.status != GameSession.Status.LOBBY:
|
|
||||||
return api_error(
|
|
||||||
request,
|
|
||||||
code="round_start_invalid_phase",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
category = Category.objects.get(slug=category_slug, is_active=True)
|
transition = _start_round(session, category_slug)
|
||||||
except Category.DoesNotExist:
|
except ValueError as exc:
|
||||||
return api_error(
|
error_code = str(exc)
|
||||||
request,
|
error_status = {
|
||||||
code="category_not_found",
|
"category_not_found": 404,
|
||||||
status=404,
|
"round_already_configured": 409,
|
||||||
)
|
}.get(error_code, 400)
|
||||||
|
return api_error(request, code=error_code, status=error_status)
|
||||||
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)
|
|
||||||
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)
|
|
||||||
|
|
||||||
sync_broadcast_phase_event(
|
sync_broadcast_phase_event(
|
||||||
session.code,
|
transition.session.code,
|
||||||
"phase.lie_started",
|
transition.phase_event_name,
|
||||||
lie_started_payload,
|
transition.phase_event_payload,
|
||||||
)
|
)
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(transition.response_payload, status=201)
|
||||||
{
|
|
||||||
"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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@@ -608,60 +286,18 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
status=403,
|
status=403,
|
||||||
)
|
)
|
||||||
|
|
||||||
if session.status != GameSession.Status.LIE:
|
|
||||||
return api_error(
|
|
||||||
request,
|
|
||||||
code="show_question_invalid_phase",
|
|
||||||
status=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
round_config = RoundConfig.objects.get(session=session, number=session.current_round)
|
transition = _show_question(session)
|
||||||
except RoundConfig.DoesNotExist:
|
except ValueError as exc:
|
||||||
return api_error(
|
return api_error(request, code=str(exc), status=400)
|
||||||
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)
|
|
||||||
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(
|
sync_broadcast_phase_event(
|
||||||
session.code,
|
transition.session.code,
|
||||||
"phase.question_shown",
|
transition.phase_event_name,
|
||||||
{
|
transition.phase_event_payload,
|
||||||
"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,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return JsonResponse(
|
return JsonResponse(transition.response_payload, status=201)
|
||||||
{
|
|
||||||
"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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@@ -1060,22 +696,18 @@ def reveal_scoreboard(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
if session.host_id != request.user.id:
|
if session.host_id != request.user.id:
|
||||||
return api_error(request, code="host_only_view_scoreboard", status=403)
|
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}:
|
if session.status not in {GameSession.Status.SCOREBOARD, GameSession.Status.FINISHED}:
|
||||||
return api_error(request, code="scoreboard_invalid_phase", status=400)
|
return api_error(request, code="scoreboard_invalid_phase", status=400)
|
||||||
|
|
||||||
leaderboard = _build_leaderboard(session)
|
return JsonResponse(transition.response_payload)
|
||||||
|
|
||||||
return JsonResponse(
|
|
||||||
{
|
|
||||||
"session": {
|
|
||||||
"code": session.code,
|
|
||||||
"status": session.status,
|
|
||||||
"current_round": session.current_round,
|
|
||||||
},
|
|
||||||
"leaderboard": leaderboard,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@@ -1091,72 +723,19 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
if session.host_id != request.user.id:
|
if session.host_id != request.user.id:
|
||||||
return api_error(request, code="host_only_start_next_round", status=403)
|
return api_error(request, code="host_only_start_next_round", status=403)
|
||||||
|
|
||||||
with transaction.atomic():
|
try:
|
||||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
transition = _start_next_round(session)
|
||||||
if locked_session.status != GameSession.Status.SCOREBOARD:
|
except ValueError as exc:
|
||||||
return api_error(request, code="next_round_invalid_phase", status=400)
|
return api_error(request, code=str(exc), status=400)
|
||||||
|
|
||||||
previous_round_config = RoundConfig.objects.filter(
|
if transition.should_broadcast:
|
||||||
session=locked_session,
|
sync_broadcast_phase_event(
|
||||||
number=locked_session.current_round,
|
transition.session.code,
|
||||||
).select_related("category").first()
|
transition.phase_event_name,
|
||||||
if previous_round_config is None:
|
transition.phase_event_payload,
|
||||||
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:
|
return JsonResponse(transition.response_payload)
|
||||||
round_question = _select_round_question(locked_session, next_round_config)
|
|
||||||
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)
|
|
||||||
sync_broadcast_phase_event(
|
|
||||||
locked_session.code,
|
|
||||||
"phase.lie_started",
|
|
||||||
lie_started_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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
@login_required
|
@login_required
|
||||||
@@ -1171,40 +750,19 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
|
|||||||
if session.host_id != request.user.id:
|
if session.host_id != request.user.id:
|
||||||
return api_error(request, code="host_only_finish_game", status=403)
|
return api_error(request, code="host_only_finish_game", status=403)
|
||||||
|
|
||||||
with transaction.atomic():
|
try:
|
||||||
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
|
transition = _finish_game(session)
|
||||||
if locked_session.status != GameSession.Status.SCOREBOARD:
|
except ValueError as exc:
|
||||||
return api_error(request, code="finish_game_invalid_phase", status=400)
|
return api_error(request, code=str(exc), status=400)
|
||||||
|
|
||||||
|
if transition.should_broadcast:
|
||||||
|
sync_broadcast_phase_event(
|
||||||
|
transition.session.code,
|
||||||
|
transition.phase_event_name,
|
||||||
|
transition.phase_event_payload,
|
||||||
|
)
|
||||||
|
|
||||||
locked_session.status = GameSession.Status.FINISHED
|
return JsonResponse(transition.response_payload)
|
||||||
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
|
|
||||||
|
|
||||||
sync_broadcast_phase_event(
|
|
||||||
session.code,
|
|
||||||
"phase.game_over",
|
|
||||||
{"winner": winner, "leaderboard": list(leaderboard)},
|
|
||||||
)
|
|
||||||
|
|
||||||
return JsonResponse(
|
|
||||||
{
|
|
||||||
"session": {
|
|
||||||
"code": session.code,
|
|
||||||
"status": GameSession.Status.FINISHED,
|
|
||||||
"current_round": session.current_round,
|
|
||||||
},
|
|
||||||
"winner": winner,
|
|
||||||
"leaderboard": leaderboard,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@require_POST
|
@require_POST
|
||||||
|
|||||||
@@ -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.",
|
"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": {
|
"source_of_truth": {
|
||||||
"catalog": "shared/i18n/lobby.json",
|
"catalog": "shared/i18n/lobby.json",
|
||||||
"catalog_sha256": "e3ed39f2fa25622c01b450bd14fd4da5fc7f96c0d9635bb819f73cae14203beb",
|
"catalog_sha256": "d9f7227bddd007f2c56f33dfd0015bcffb3b60c52dc756126a02b7e4de638adb",
|
||||||
"source_paths": [
|
"source_paths": [
|
||||||
"lobby/views.py",
|
"lobby/views.py",
|
||||||
"frontend/src/spa/vertical-slice.ts",
|
"frontend/src/spa/vertical-slice.ts",
|
||||||
@@ -24,28 +24,7 @@
|
|||||||
},
|
},
|
||||||
"parity": {
|
"parity": {
|
||||||
"status": "pass",
|
"status": "pass",
|
||||||
"django_backend_error_codes_used_by_mvp": [
|
"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"
|
|
||||||
],
|
|
||||||
"angular_frontend_error_fallback_keys_used_by_mvp": [
|
"angular_frontend_error_fallback_keys_used_by_mvp": [
|
||||||
"join_failed",
|
"join_failed",
|
||||||
"session_code_required",
|
"session_code_required",
|
||||||
@@ -158,36 +137,8 @@
|
|||||||
"player.submit_lie",
|
"player.submit_lie",
|
||||||
"player.title"
|
"player.title"
|
||||||
],
|
],
|
||||||
"backend_codes_mapped_to_frontend_error_keys": {
|
"backend_codes_mapped_to_frontend_error_keys": {},
|
||||||
"category_has_no_questions": "start_round_failed",
|
"unique_frontend_error_keys_reached_from_django": [],
|
||||||
"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"
|
|
||||||
],
|
|
||||||
"blocking_issues": {
|
"blocking_issues": {
|
||||||
"missing_backend_codes": [],
|
"missing_backend_codes": [],
|
||||||
"missing_backend_translations": [],
|
"missing_backend_translations": [],
|
||||||
@@ -201,11 +152,6 @@
|
|||||||
"priority": "need-to-have",
|
"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.",
|
"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"
|
"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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user