1 Commits

Author SHA1 Message Date
8a8ac54a73 fix(gameplay): harden scoreboard next-round bootstrap refs #300
All checks were successful
CI / test-and-quality (push) Successful in 2m45s
2026-03-16 12:56:58 +00:00
17 changed files with 468 additions and 1248 deletions

View File

@@ -0,0 +1,25 @@
# Issue #300 — scoreboard -> next-round bootstrap invariant
Refs: #300, parent #287
## Hardened invariant
`POST /lobby/sessions/{code}/rounds/next` now treats `scoreboard -> next round` as a deterministic backend-owned bootstrap boundary:
- stale pre-created `RoundConfig` / `RoundQuestion` rows for the next round are deleted before bootstrap
- dependent lies / guesses for that stale next-round question are cleared with it
- `current_round` increments exactly once when a fresh next-round question can be selected
- the response makes reset semantics explicit with:
- `round_bootstrap.active_submissions = { lies: 0, guesses: 0 }`
- `round_question.answers = []`
- `submission_progress = { lies_submitted: 0, guesses_submitted: 0, players_expected: N }`
- `reveal = null`
- `leaderboard = null`
## Regression coverage
Targeted Django tests lock:
1. scoreboard -> next-round bootstrap returns explicit reset semantics
2. stale future round artifacts are removed before the next round is exposed
3. two consecutive round bootstraps do not leak prior-round state into the next round

View File

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

View File

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

View File

@@ -4,8 +4,6 @@ import { HostShellComponent } from './host-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
type FetchRouteHandler = (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>;
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
@@ -14,10 +12,6 @@ function jsonResponse(status: number, body: unknown) {
} as unknown as Response;
}
function createFetchRouteMock(handler: FetchRouteHandler): FetchMock {
return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init)));
}
function sessionDetailPayload(
status: string,
options?: {
@@ -91,12 +85,12 @@ function sessionDetailPayload(
},
host: {
can_start_round: status === 'lobby',
can_show_question: status === 'lie',
can_mix_answers: status === 'lie' || status === 'guess',
can_calculate_scores: status === 'guess',
can_reveal_scoreboard: status === 'reveal',
can_start_next_round: status === 'reveal',
can_finish_game: status === 'reveal',
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: status === 'scoreboard',
can_finish_game: status === 'scoreboard',
},
player: {
can_join: status === 'lobby',
@@ -185,81 +179,18 @@ describe('HostShellComponent gameplay wiring', () => {
});
});
it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => {
let refreshCount = 0;
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/show') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
}
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/99/answers/mix') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 2 } });
}
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 2 } });
}
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
refreshCount += 1;
if (refreshCount === 1) {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
}
if (refreshCount === 2) {
return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }));
}
return jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
it('runs next-round transition into canonical lie phase and clears prior final leaderboard state', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } }))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 })));
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = ' abcd12 ';
component.roundQuestionId = ' 77 ';
component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any;
await component.showQuestion();
expect(component.session?.session.status).toBe('lie');
expect(component.roundQuestionId).toBe('99');
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
await component.mixAnswers();
expect(component.session?.session.status).toBe('guess');
await component.calculateScores();
expect(component.session?.session.status).toBe('reveal');
expect(component.error).toBe('');
expect(component.loading).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(6);
});
it('runs next-round transition without reload and clears scoreboard payload', async () => {
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'POST' && url === '/lobby/sessions/ABCD12/rounds/next') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
}
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = ' abcd12 ';
component.scoreboardPayload = '{"leaderboard":[]}';
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ;
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
await component.startNextRound();
@@ -296,7 +227,6 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
await component.finishGame();
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
@@ -320,7 +250,6 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent();
component.sessionCode = ' ';
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
await component.startNextRound();
await component.finishGame();
@@ -330,47 +259,6 @@ describe('HostShellComponent gameplay wiring', () => {
expect(component.finishError).toContain('Session code is required');
});
it('blocks illegal host actions outside canonical phase permissions', async () => {
const fetchMock: FetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
component.roundQuestionId = '77';
for (const status of ['guess', 'reveal', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.showQuestion();
}
for (const status of ['lie', 'reveal', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.calculateScores();
}
for (const status of ['lie', 'guess', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.loadScoreboard();
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(true);
expect(component.canFinishGame).toBe(true);
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
expect(component.canLoadScoreboard).toBe(false);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it('syncs host hash-route with latest phase after refresh without page reload', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })));
vi.stubGlobal('fetch', fetchMock);
@@ -402,18 +290,11 @@ describe('HostShellComponent gameplay wiring', () => {
component.session = sessionDetailPayload('lie') as any;
expect(component.canStartRound).toBe(false);
expect(component.canShowQuestion).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
component.session = sessionDetailPayload('reveal') as any;
expect(component.canLoadScoreboard).toBe(true);
expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).toBe(true);
component.session = sessionDetailPayload('scoreboard') as any;
expect(component.canLoadScoreboard).toBe(false);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).toBe(true);
});
});

View File

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

View File

@@ -147,8 +147,9 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.sessionToken = 'token-1';
component.lieText = 'my lie';
component.session = {
...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any),
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [] },
players: [],
};
await component.submitLie();
@@ -267,8 +268,9 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.sessionToken = 'token-1';
component.selectedGuess = 'B';
component.session = {
...(sessionDetailPayload('guess', { answers: ['A', 'B'], roundQuestionId: 11 }) as any),
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
players: [],
};
await component.submitGuess();
@@ -292,29 +294,6 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('blocks illegal player guess submission outside canonical guess phase', async () => {
const fetchMock: FetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'token-1';
component.selectedGuess = 'B';
for (const status of ['lie', 'reveal', 'scoreboard'] as const) {
component.session = {
...(sessionDetailPayload(status, { answers: ['A', 'B'] }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
};
await component.submitGuess();
}
expect(component.canSubmitGuess).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it('auto-refreshes player session to avoid host/player state desync between rounds', async () => {
vi.useFakeTimers();

View File

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

View File

@@ -7,125 +7,12 @@
"": {
"name": "wpp-frontend-api-client-baseline",
"version": "0.1.0",
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.7.3",
"vitest": "^2.1.9"
}
},
"node_modules/@angular/common": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.20.tgz",
"integrity": "sha512-1M3W3FjUUbVKXDMs+yQpBhnkD/pCe0Jn79rPE5W+EGWWxFoLSyGX+fhnRO5m4c9k66p3nvYrikWQ0ZzMv3M5tw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/core": "19.2.20",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/compiler": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.20.tgz",
"integrity": "sha512-LvjE8W58EACgTFaAoqmNe7FRsbvoQ0GvCB/rmm6AEMWx/0W/JBvWkQTrOQlwpoeYOHcMZRGdmPcZoUDwU3JySQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
}
},
"node_modules/@angular/core": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.20.tgz",
"integrity": "sha512-pxzQh8ouqfE57lJlXjIzXFuRETwkfMVwS+NFCfv2yh01Qtx+vymO8ZClcJMgLPfBYinhBYX+hrRYVSa1nzlkRQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0"
}
},
"node_modules/@angular/forms": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.20.tgz",
"integrity": "sha512-agi7InbMzop1jrud6L7SlNwnZk3iNolORcFIwBQMvKxLkcJ+ttbSYuM0KAw56IundWHf4dL9GP4cSygm4kUeFA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/common": "19.2.20",
"@angular/core": "19.2.20",
"@angular/platform-browser": "19.2.20",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/platform-browser": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.20.tgz",
"integrity": "sha512-O9ZoQKILPC1T2c64OASS75XlOLBxY81m5AAgsBKhwiFWq+V28RsO0cnwpi1YSh/z4ryH8Fe7IUFz8jGrsJi3hQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/animations": "19.2.20",
"@angular/common": "19.2.20",
"@angular/core": "19.2.20"
},
"peerDependenciesMeta": {
"@angular/animations": {
"optional": true
}
}
},
"node_modules/@angular/router": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.20.tgz",
"integrity": "sha512-y0fyKycxJHr82kxXKE50Vac5hPn5Kx3gw9CfqyEuwJ9VQzEixDljU+chrQK4Wods14jJn9Tt2ncNPGH1rLya3Q==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/common": "19.2.20",
"@angular/core": "19.2.20",
"@angular/platform-browser": "19.2.20",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -1301,15 +1188,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1385,12 +1263,6 @@
"node": ">=14.0.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1577,12 +1449,6 @@
"engines": {
"node": ">=8"
}
},
"node_modules/zone.js": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
"license": "MIT"
}
}
}

View File

@@ -7,17 +7,6 @@
"test": "vitest run",
"build": "tsc --noEmit"
},
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.7.3",

View File

@@ -1,15 +1,6 @@
import type { PhaseViewModel, SessionDetailResponse } from '../api/types';
import type { SessionDetailResponse } from '../api/types';
export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard';
export type HostGameplayAction =
| 'startRound'
| 'showQuestion'
| 'mixAnswers'
| 'calculateScores'
| 'loadScoreboard'
| 'startNextRound'
| 'finishGame';
export type PlayerGameplayAction = 'join' | 'submitLie' | 'submitGuess' | 'viewFinalResult';
export type GameplayPhaseEvent =
| 'LIES_LOCKED'
@@ -49,7 +40,8 @@ export function allowedGameplayEvents(phase: GameplayPhase): GameplayPhaseEvent[
return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[];
}
function derivePhaseFromStatus(status: string | null | undefined): GameplayPhase | null {
export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null {
const status = session?.session.status;
if (!status) {
return null;
}
@@ -64,59 +56,3 @@ function derivePhaseFromStatus(status: string | null | undefined): GameplayPhase
return null;
}
function deriveCanonicalPhaseStatus(phaseViewModel: PhaseViewModel | null | undefined): string | null {
if (!phaseViewModel) {
return null;
}
const currentPhase = (phaseViewModel as PhaseViewModel & { current_phase?: string }).current_phase;
return currentPhase ?? phaseViewModel.status ?? null;
}
export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null {
const canonicalStatus = deriveCanonicalPhaseStatus(session?.phase_view_model);
return derivePhaseFromStatus(canonicalStatus ?? session?.session.status);
}
export function isHostGameplayActionAllowed(session: SessionDetailResponse | null, action: HostGameplayAction): boolean {
if (!session) {
return action === 'startRound';
}
const host = session.phase_view_model?.host;
switch (action) {
case 'startRound':
return Boolean(host?.can_start_round ?? false);
case 'showQuestion':
return Boolean(host?.can_show_question ?? false);
case 'mixAnswers':
return Boolean(host?.can_mix_answers ?? false);
case 'calculateScores':
return Boolean(host?.can_calculate_scores ?? false);
case 'loadScoreboard':
return Boolean(host?.can_reveal_scoreboard ?? false);
case 'startNextRound':
return Boolean(host?.can_start_next_round ?? false);
case 'finishGame':
return Boolean(host?.can_finish_game ?? false);
}
}
export function isPlayerGameplayActionAllowed(session: SessionDetailResponse | null, action: PlayerGameplayAction): boolean {
if (!session) {
return action === 'join';
}
const player = session.phase_view_model?.player;
switch (action) {
case 'join':
return Boolean(player?.can_join ?? false);
case 'submitLie':
return Boolean(player?.can_submit_lie ?? false);
case 'submitGuess':
return Boolean(player?.can_submit_guess ?? false);
case 'viewFinalResult':
return Boolean(player?.can_view_final_result ?? false);
}
}

View File

@@ -2,8 +2,6 @@ import { describe, expect, it } from 'vitest';
import {
allowedGameplayEvents,
deriveGameplayPhase,
isHostGameplayActionAllowed,
isPlayerGameplayActionAllowed,
transitionGameplayPhase,
type GameplayPhase
} from '../src/spa/gameplay-phase-machine';
@@ -107,44 +105,4 @@ describe('gameplay phase machine skeleton', () => {
})
).toBe('scoreboard');
});
it('gates host and player actions from canonical phase_view_model permissions', () => {
const session = {
session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: { id: 77, prompt: 'Q?', answers: [] },
phase_view_model: {
status: 'reveal',
round_number: 1,
players_count: 3,
constraints: {
min_players_to_start: 3,
max_players_mvp: 5,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: true,
can_start_next_round: true,
can_finish_game: true
},
player: {
can_join: false,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
} as const;
expect(deriveGameplayPhase(session as any)).toBe('reveal');
expect(isHostGameplayActionAllowed(session as any, 'loadScoreboard')).toBe(true);
expect(isHostGameplayActionAllowed(session as any, 'startNextRound')).toBe(true);
expect(isHostGameplayActionAllowed(session as any, 'finishGame')).toBe(true);
expect(isPlayerGameplayActionAllowed(session as any, 'submitGuess')).toBe(false);
});
});

View File

@@ -2,8 +2,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts', 'angular/src/**/*.spec.ts'],
setupFiles: ['angular/src/test-setup.ts'],
include: ['tests/**/*.test.ts'],
exclude: ['**/node_modules/**']
}
});

View File

@@ -1,70 +0,0 @@
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_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,
}

View File

@@ -1,115 +0,0 @@
import random
from .models import GameSession, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
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 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)
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
return score_events, leaderboard

View File

@@ -1,127 +1,2 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
from fupogfakta.payloads import build_lie_started_payload, build_reveal_payload
from fupogfakta.services import get_current_round_question, prepare_mixed_answers, resolve_scores, select_round_question
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_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,
)
lie_payload = build_lie_started_payload(self.session, self.round_config, round_question)
reveal_payload = build_reveal_payload(round_question)
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)
# Create your tests here.

View File

@@ -1122,6 +1122,12 @@ class RevealRoundFlowTests(TestCase):
correct_answer="Stockholm",
is_active=True,
)
self.followup_question = Question.objects.create(
category=self.category,
prompt="Hvad er Norges hovedstad?",
correct_answer="Oslo",
is_active=True,
)
self.round_config = RoundConfig.objects.create(session=self.session, number=1, category=self.category)
self.round_question = RoundQuestion.objects.create(
session=self.session,
@@ -1216,25 +1222,6 @@ class RevealRoundFlowTests(TestCase):
self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
@patch("lobby.views.sync_broadcast_phase_event")
def test_finish_game_is_idempotent_after_transition_to_finished(self, mock_sync_broadcast_phase_event):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
first_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code}))
second_response = self.client.post(reverse("lobby:finish_game", kwargs={"code": self.session.code}))
self.assertEqual(first_response.status_code, 200)
self.assertEqual(second_response.status_code, 200)
self.assertEqual(first_response.json(), second_response.json())
self.assertEqual(second_response.json()["session"]["status"], GameSession.Status.FINISHED)
self.session.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.FINISHED)
self.assertEqual(mock_sync_broadcast_phase_event.call_count, 2)
self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[0].args[1], "phase.scoreboard")
self.assertEqual(mock_sync_broadcast_phase_event.call_args_list[1].args[1], "phase.game_over")
def test_finish_game_requires_host(self):
self.client.login(username="other_reveal", password="secret123")
@@ -1287,7 +1274,22 @@ class RevealRoundFlowTests(TestCase):
self.assertEqual(payload["session"]["status"], GameSession.Status.LIE)
self.assertEqual(payload["session"]["current_round"], 2)
self.assertEqual(payload["round"]["category"]["slug"], self.category.slug)
self.assertEqual(payload["round_question"]["prompt"], self.next_question.prompt)
self.assertEqual(payload["round_bootstrap"], {
"round_number": 2,
"round_question": None,
"active_submissions": {"lies": 0, "guesses": 0},
"reveal": None,
"leaderboard": None,
})
self.assertIn(payload["round_question"]["prompt"], {self.next_question.prompt, self.followup_question.prompt})
self.assertEqual(payload["round_question"]["answers"], [])
self.assertEqual(payload["submission_progress"], {
"lies_submitted": 0,
"guesses_submitted": 0,
"players_expected": 2,
})
self.assertIsNone(payload["reveal"])
self.assertIsNone(payload["leaderboard"])
self.assertEqual(payload["config"]["lie_seconds"], self.round_config.lie_seconds)
self.session.refresh_from_db()
@@ -1296,33 +1298,106 @@ class RevealRoundFlowTests(TestCase):
self.assertTrue(
RoundConfig.objects.filter(session=self.session, number=2, category=self.category).exists()
)
self.assertTrue(
RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists()
self.assertEqual(
RoundQuestion.objects.filter(session=self.session, round_number=2).count(),
1,
)
self.assertIn(
RoundQuestion.objects.get(session=self.session, round_number=2).question_id,
{self.next_question.id, self.followup_question.id},
)
mock_sync_broadcast_phase_event.assert_called_once()
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[0], self.session.code)
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
@patch("lobby.views.sync_broadcast_phase_event")
def test_start_next_round_is_idempotent_after_transition_to_lie(self, mock_sync_broadcast_phase_event):
def test_start_next_round_clears_stale_future_round_artifacts(self):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
stale_round_config = RoundConfig.objects.create(
session=self.session,
number=2,
category=self.category,
)
stale_round_question = RoundQuestion.objects.create(
session=self.session,
round_number=2,
question=self.next_question,
correct_answer=self.next_question.correct_answer,
mixed_answers=["Stockholm", "Göteborg"],
)
LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Göteborg")
Guess.objects.create(
round_question=stale_round_question,
player=self.player_two,
selected_text="Göteborg",
fooled_player=self.player_one,
is_correct=False,
)
response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(response.status_code, 200)
self.assertFalse(RoundConfig.objects.filter(pk=stale_round_config.pk).exists())
self.assertFalse(RoundQuestion.objects.filter(pk=stale_round_question.pk).exists())
self.assertEqual(LieAnswer.objects.filter(round_question=stale_round_question).count(), 0)
self.assertEqual(Guess.objects.filter(round_question=stale_round_question).count(), 0)
payload = response.json()
self.assertEqual(payload["session"]["current_round"], 2)
self.assertIn(payload["round_question"]["prompt"], {self.next_question.prompt, self.followup_question.prompt})
self.assertEqual(payload["submission_progress"], {
"lies_submitted": 0,
"guesses_submitted": 0,
"players_expected": 2,
})
self.assertIsNone(payload["reveal"])
self.assertIsNone(payload["leaderboard"])
@patch("lobby.views.sync_broadcast_phase_event")
def test_two_consecutive_round_bootstraps_do_not_leak_prior_round_artifacts(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_transition = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(first_transition.status_code, 200)
self.session.refresh_from_db()
self.assertEqual(self.session.current_round, 2)
self.assertEqual(self.session.status, GameSession.Status.LIE)
round_two_question = RoundQuestion.objects.get(session=self.session, round_number=2)
round_two_question.mixed_answers = [self.next_question.correct_answer, "Bergen"]
round_two_question.save(update_fields=["mixed_answers"])
LieAnswer.objects.create(round_question=round_two_question, player=self.player_one, text="Bergen")
Guess.objects.create(
round_question=round_two_question,
player=self.player_two,
selected_text="Bergen",
fooled_player=self.player_one,
is_correct=False,
)
self.session.status = GameSession.Status.SCOREBOARD
self.session.save(update_fields=["status"])
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}))
second_transition = 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)
self.assertEqual(second_transition.status_code, 200)
payload = second_transition.json()
self.assertEqual(payload["session"]["current_round"], 3)
self.assertEqual(payload["session"]["status"], GameSession.Status.LIE)
self.assertIn(payload["round_question"]["prompt"], {self.next_question.prompt, self.followup_question.prompt})
self.assertNotEqual(payload["round_question"]["prompt"], round_two_question.question.prompt)
self.assertEqual(payload["round_question"]["answers"], [])
self.assertEqual(payload["submission_progress"], {
"lies_submitted": 0,
"guesses_submitted": 0,
"players_expected": 2,
})
self.assertIsNone(payload["reveal"])
self.assertIsNone(payload["leaderboard"])
self.assertEqual(LieAnswer.objects.filter(round_question__round_number=3).count(), 0)
self.assertEqual(Guess.objects.filter(round_question__round_number=3).count(), 0)
mock_sync_broadcast_phase_event.assert_called_once()
self.assertEqual(mock_sync_broadcast_phase_event.call_args.args[1], "phase.lie_started")
@@ -1392,6 +1467,7 @@ class RevealRoundFlowTests(TestCase):
self.client.login(username="host_reveal", password="secret123")
self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code}))
self.next_question.delete()
self.followup_question.delete()
response = self.client.post(
reverse(

View File

@@ -8,17 +8,16 @@ from django.http import HttpRequest, JsonResponse
from django.utils import timezone
from django.views.decorators.http import require_GET, require_POST
from fupogfakta.models import Category, GameSession, Guess, LieAnswer, Player, Question, RoundConfig, RoundQuestion, ScoreEvent
from fupogfakta.payloads import (
build_leaderboard as _build_leaderboard,
build_lie_started_payload as _build_lie_started_payload,
build_reveal_payload as _build_reveal_payload,
)
from fupogfakta.services import (
get_current_round_question as _get_current_round_question,
prepare_mixed_answers as _prepare_mixed_answers,
resolve_scores as _resolve_scores,
select_round_question as _select_round_question,
from fupogfakta.models import (
Category,
GameSession,
Guess,
LieAnswer,
Player,
Question,
RoundConfig,
RoundQuestion,
ScoreEvent,
)
from realtime.broadcast import sync_broadcast_phase_event
@@ -65,57 +64,197 @@ def _create_unique_session_code() -> str:
raise RuntimeError("Could not generate unique session code")
def _build_start_next_round_response(
session: GameSession,
round_config: RoundConfig,
round_question: RoundQuestion,
) -> JsonResponse:
lie_started_payload = _build_lie_started_payload(session, round_config, round_question)
return JsonResponse(
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 = [
{
"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,
},
**_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_finish_game_response(session: GameSession) -> JsonResponse:
leaderboard = _build_leaderboard(session)
winner = leaderboard[0] if leaderboard else None
return JsonResponse(
{
"session": {
"code": session.code,
"status": GameSession.Status.FINISHED,
"current_round": session.current_round,
},
"winner": winner,
"leaderboard": leaderboard,
}
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 _reset_next_round_bootstrap(session: GameSession, round_number: int) -> dict:
RoundQuestion.objects.filter(session=session, round_number=round_number).delete()
RoundConfig.objects.filter(session=session, number=round_number).delete()
return {
"round_number": round_number,
"round_question": None,
"active_submissions": {
"lies": 0,
"guesses": 0,
},
"reveal": None,
"leaderboard": None,
}
def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
deduped_answers = list(round_question.mixed_answers or [])
if deduped_answers:
return deduped_answers
lie_texts = list(round_question.lies.values_list("text", flat=True))
seen = set()
for text in [round_question.correct_answer, *lie_texts]:
normalized = text.strip().casefold()
if not normalized or normalized in seen:
continue
seen.add(normalized)
deduped_answers.append(text.strip())
if len(deduped_answers) < 2:
raise ValueError("not_enough_answers_to_mix")
random.shuffle(deduped_answers)
round_question.mixed_answers = deduped_answers
round_question.save(update_fields=["mixed_answers"])
return deduped_answers
def _resolve_scores(session: GameSession, round_question: RoundQuestion, round_config: RoundConfig) -> tuple[list[ScoreEvent], list[dict]]:
guesses = list(round_question.guesses.select_related("player"))
if not guesses:
raise ValueError("no_guesses_submitted")
bluff_counts: dict[int, int] = {}
for guess in guesses:
if guess.fooled_player_id:
bluff_counts[guess.fooled_player_id] = bluff_counts.get(guess.fooled_player_id, 0) + 1
score_events = []
for guess in guesses:
if guess.is_correct:
guess.player.score += round_config.points_correct
guess.player.save(update_fields=["score"])
score_events.append(
ScoreEvent(
session=session,
player=guess.player,
delta=round_config.points_correct,
reason="guess_correct",
meta={"round_question_id": round_question.id, "guess_id": guess.id},
)
)
for player_id, fooled_count in bluff_counts.items():
delta = fooled_count * round_config.points_bluff
player = Player.objects.get(pk=player_id, session=session)
player.score += delta
player.save(update_fields=["score"])
score_events.append(
ScoreEvent(
session=session,
player=player,
delta=delta,
reason="bluff_success",
meta={"round_question_id": round_question.id, "fooled_count": fooled_count},
)
)
ScoreEvent.objects.bulk_create(score_events)
return score_events, _build_leaderboard(session)
def _maybe_promote_reveal_to_scoreboard(session: GameSession) -> GameSession:
if session.status != GameSession.Status.REVEAL:
return session
@@ -968,61 +1107,82 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
if session.host_id != request.user.id:
return api_error(request, code="host_only_start_next_round", status=403)
should_broadcast = False
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().select_related("host").get(pk=session.pk)
next_round_config = None
round_question = 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:
return api_error(request, code="round_config_missing", status=400)
next_round_number = locked_session.current_round + 1
next_round_config = RoundConfig(
session=locked_session,
number=next_round_number,
category=previous_round_config.category,
lie_seconds=previous_round_config.lie_seconds,
guess_seconds=previous_round_config.guess_seconds,
points_correct=previous_round_config.points_correct,
points_bluff=previous_round_config.points_bluff,
)
locked_session.current_round = next_round_number
try:
round_question = _select_round_question(locked_session, next_round_config)
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"])
should_broadcast = True
elif locked_session.status == GameSession.Status.LIE:
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 round_question is None:
return api_error(request, code="next_round_invalid_phase", status=400)
else:
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status != GameSession.Status.SCOREBOARD:
return api_error(request, code="next_round_invalid_phase", status=400)
if should_broadcast:
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,
)
previous_round_config = RoundConfig.objects.filter(
session=locked_session,
number=locked_session.current_round,
).select_related("category").first()
if previous_round_config is None:
return api_error(request, code="round_config_missing", status=400)
return _build_start_next_round_response(locked_session, next_round_config, round_question)
next_round_number = locked_session.current_round + 1
round_bootstrap = _reset_next_round_bootstrap(locked_session, next_round_number)
next_round_config = RoundConfig(
session=locked_session,
number=next_round_number,
category=previous_round_config.category,
lie_seconds=previous_round_config.lie_seconds,
guess_seconds=previous_round_config.guess_seconds,
points_correct=previous_round_config.points_correct,
points_bluff=previous_round_config.points_bluff,
)
locked_session.current_round = next_round_number
try:
round_question = _select_round_question(locked_session, next_round_config)
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_bootstrap": round_bootstrap,
"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"],
"answers": [],
},
"submission_progress": {
"lies_submitted": 0,
"guesses_submitted": 0,
"players_expected": Player.objects.filter(session=locked_session).count(),
},
"reveal": None,
"leaderboard": None,
"config": {
"lie_seconds": next_round_config.lie_seconds,
},
}
)
@require_POST
@login_required
@@ -1037,26 +1197,40 @@ def finish_game(request: HttpRequest, code: str) -> JsonResponse:
if session.host_id != request.user.id:
return api_error(request, code="host_only_finish_game", status=403)
should_broadcast = False
with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status == GameSession.Status.SCOREBOARD:
locked_session.status = GameSession.Status.FINISHED
locked_session.save(update_fields=["status"])
should_broadcast = True
elif locked_session.status != GameSession.Status.FINISHED:
if locked_session.status != GameSession.Status.SCOREBOARD:
return api_error(request, code="finish_game_invalid_phase", status=400)
if should_broadcast:
leaderboard = _build_leaderboard(locked_session)
winner = leaderboard[0] if leaderboard else None
sync_broadcast_phase_event(
locked_session.code,
"phase.game_over",
{"winner": winner, "leaderboard": list(leaderboard)},
)
return _build_finish_game_response(locked_session)
locked_session.status = GameSession.Status.FINISHED
locked_session.save(update_fields=["status"])
leaderboard = list(
Player.objects.filter(session=session)
.order_by("-score", "nickname")
.values("id", "nickname", "score")
)
winner = leaderboard[0] if leaderboard else None
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