17 Commits

Author SHA1 Message Date
251ccfce19 Merge pull request 'fix(frontend): prefer canonical phase for client action gating (#301 follow-up)' (#306) from dev/issue-301-phase-action-gating-followup into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
CI / test-and-quality (pull_request) Successful in 3m36s
2026-03-16 18:09:04 +01:00
d9c4cda966 fix(frontend): prefer canonical phase for client action gating
All checks were successful
CI / test-and-quality (push) Successful in 2m56s
CI / test-and-quality (pull_request) Successful in 3m1s
2026-03-16 17:00:02 +00:00
2437f0e8bd Merge pull request 'test(gameplay): add canonical loop smoke evidence (#302)' (#304) from dev/issue-302-canonical-loop-evidence into main
All checks were successful
CI / test-and-quality (push) Successful in 2m43s
2026-03-16 17:31:23 +01:00
3b4b844126 chore(ci): retrigger canonical loop evidence checks
All checks were successful
CI / test-and-quality (push) Successful in 3m9s
CI / test-and-quality (pull_request) Successful in 3m10s
2026-03-16 15:52:54 +00:00
c8c17654a4 Merge pull request 'fix(gameplay): harden scoreboard -> next round bootstrap invariants (#300)' (#305) from dev/issue-300-round-bootstrap-invariants-v2 into main
All checks were successful
CI / test-and-quality (push) Successful in 2m39s
2026-03-16 16:44:22 +01:00
fd6e3e86e8 ci: repair rollup optional dep on npm ci
Some checks failed
CI / test-and-quality (pull_request) Successful in 3m35s
CI / test-and-quality (push) Failing after 4m8s
2026-03-16 15:35:49 +00:00
7c0332f95f fix(gameplay): harden scoreboard to round bootstrap invariants (#300)
All checks were successful
CI / test-and-quality (push) Successful in 3m20s
CI / test-and-quality (pull_request) Successful in 2m52s
2026-03-16 15:22:03 +00:00
9970257f32 test(gameplay): add canonical loop smoke evidence (#302)
Some checks failed
CI / test-and-quality (push) Failing after 3m42s
CI / test-and-quality (pull_request) Successful in 3m36s
2026-03-16 15:20:06 +00:00
112a85a22d Merge pull request 'fix(gameplay): gate client actions from canonical phase state (#301)' (#303) from dev/issue-301-client-action-gating into main
All checks were successful
CI / test-and-quality (push) Successful in 2m36s
2026-03-16 15:53:44 +01:00
33b428955b test(frontend): install angular spec runtime in root suite
All checks were successful
CI / test-and-quality (push) Successful in 3m8s
CI / test-and-quality (pull_request) Successful in 3m9s
2026-03-16 13:53:00 +00:00
55fc758389 test(gameplay): stabilize canonical host gating specs
All checks were successful
CI / test-and-quality (push) Successful in 3m9s
CI / test-and-quality (pull_request) Successful in 3m9s
2026-03-16 13:33:49 +00:00
f0142f33b6 test(issue-301): align host gating specs with canonical phases
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m3s
CI / test-and-quality (push) Successful in 3m5s
2026-03-16 12:50:33 +00:00
3acaf3e370 test(frontend): include angular specs in vitest suite
Some checks failed
CI / test-and-quality (push) Failing after 3m6s
CI / test-and-quality (pull_request) Failing after 3m6s
2026-03-16 12:06:57 +00:00
1cb36a5943 merge(main): resolve PR #303 conflicts
Some checks failed
CI / test-and-quality (push) Failing after 3m6s
CI / test-and-quality (pull_request) Failing after 3m8s
2026-03-16 11:53:56 +00:00
fc68e30cf4 fix(frontend): restore phase-gating build
All checks were successful
CI / test-and-quality (push) Successful in 2m32s
CI / test-and-quality (pull_request) Successful in 2m32s
2026-03-16 11:29:45 +00:00
57ca237565 fix(issue-301): gate client actions from canonical phase flags
All checks were successful
CI / test-and-quality (push) Successful in 2m20s
CI / test-and-quality (pull_request) Successful in 2m28s
2026-03-16 10:28:12 +00:00
076faf2ff1 feat: gate client actions by canonical phase state 2026-03-16 10:15:35 +00:00
19 changed files with 1247 additions and 283 deletions

View File

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

View File

@@ -1,25 +0,0 @@
# 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

@@ -0,0 +1,52 @@
# Issue #301 Artifact — Client action gating from canonical phase state
Refs: #287, #301
## What changed
Frontend host/player shells now prefer the canonical phase exposed by `phase_view_model.current_phase` when deciding:
- which gameplay actions are enabled
- whether reveal data should still be shown
- which SPA hash-route should represent the active game state
This tightens the #301 slice so the client stays aligned with backend canonicalisation even when `session.status` lags during reveal/scoreboard promotion.
## Gated UI actions by phase
### Lobby
- **Host:** `startRound`
- **Player:** `join`
### Bluff / lie
- **Host:** `showQuestion`
- **Player:** `submitLie`
- **Blocked:** guess submission, scoreboard load, next round, finish game
### Guess
- **Host:** `mixAnswers`, `calculateScores`
- **Player:** `submitGuess`
- **Blocked:** lie submission, scoreboard load, next round, finish game
### Reveal
- **Host:** `loadScoreboard`
- **Player:** display-only reveal state
- **Blocked:** start next round, finish game, guess/lie submission
### Scoreboard
- **Host:** `startNextRound`, `finishGame`
- **Player:** display-only reveal/scoreboard state
- **Blocked:** scoreboard reload, guess/lie submission
## Test evidence
Targeted tests added/updated for:
- host shell canonical gating and route sync when `current_phase` differs from `session.status`
- player shell canonical gating and route sync when `current_phase` differs from `session.status`
- shared gameplay phase machine gating from canonical permissions
- shared API mapper contract coverage, including reveal/scoreboard payload stability
## Contract note
No backend protocol redesign was introduced. This follow-up only preserves and consumes the existing canonical phase/action contract more strictly on the client side.

View File

@@ -0,0 +1,55 @@
# Issue #302 Evidence — canonical bluff → guess → reveal → scoreboard regression
## Runnable command
```bash
python manage.py migrate --noinput
python manage.py smoke_staging --artifact docs/artifacts/issue-302-canonical-loop-smoke.json
```
`migrate` is the normal local bootstrap precondition when the database has not been initialized yet; the regression evidence itself is produced by `smoke_staging`.
## What the regression proves
`smoke_staging` now exercises one full canonical round and fails fast with step-specific diagnostics if any of these break:
1. `start_round` lands the session in `lie` and returns a concrete `round_question_id`.
2. Final `submit_lie` auto-advances the session to `guess` and exposes mixed answers containing both the correct answer and player bluffs.
3. Final `submit_guess` auto-advances the session to `reveal` and returns the canonical reveal payload.
4. The reveal payload includes:
- correct answer
- all lies
- all guesses
- fooled-player references for bluff hits
5. The first canonical state read after reveal promotes the session to `scoreboard`.
6. Scoreboard promotion preserves the same reveal payload and exposes a leaderboard with `scoreboard_ready=true`.
## Artifact shape
When `--artifact` is provided, the JSON file records:
- the exact smoke command
- session code and round question id
- deterministic guess plan used to produce both bluff hits and one correct guess
- per-step evidence for:
- `create_session`
- `join_players`
- `start_round`
- `auto_guess_transition`
- `submit_guesses`
- `auto_reveal_transition`
- `auto_scoreboard_transition`
- `finish_game`
- reveal summary (`correct_answer`, lie/guess counts, fooled-player ids, correct guess player ids)
- promoted scoreboard leaderboard payload
## Targeted test coverage
Backend regression coverage lives in `lobby/tests.py`:
- `test_smoke_staging_command_runs_full_flow`
- `test_smoke_staging_writes_phase_evidence_artifact_when_requested`
Together they ensure the command stays runnable in normal workflow and that the evidence artifact contains phase-by-phase proof instead of only a generic pass/fail.
Refs #287 #302

View File

@@ -0,0 +1,110 @@
{
"ok": true,
"command": "python manage.py smoke_staging --artifact <path>",
"generated_at": "2026-03-16T15:19:30.105231+00:00",
"question": {
"prompt": "Smoke prompt?",
"correct_answer": "Correct"
},
"steps": [
{
"step": "create_session",
"session_status": "lobby"
},
{
"step": "join_players",
"players_count": 3
},
{
"step": "start_round",
"session_status": "lie",
"round_question_id": 1
},
{
"step": "auto_guess_transition",
"session_status": "guess",
"answers": [
"Lie from P3",
"Lie from P1",
"Lie from P2",
"Correct"
]
},
{
"step": "submit_guesses",
"guess_results": [
{
"player_id": 1,
"selected_text": "Lie from P2",
"is_correct": false,
"fooled_player_id": 2
},
{
"player_id": 2,
"selected_text": "Correct",
"is_correct": true,
"fooled_player_id": null
},
{
"player_id": 3,
"selected_text": "Lie from P1",
"is_correct": false,
"fooled_player_id": 1
}
]
},
{
"step": "auto_reveal_transition",
"session_status": "reveal",
"reveal": {
"correct_answer": "Correct",
"lies_count": 3,
"guesses_count": 3,
"fooled_player_ids": [
1,
2
],
"correct_guess_player_ids": [
2
]
}
},
{
"step": "auto_scoreboard_transition",
"session_status": "scoreboard",
"leaderboard": [
{
"id": 2,
"nickname": "P2",
"score": 7
},
{
"id": 1,
"nickname": "P1",
"score": 2
},
{
"id": 3,
"nickname": "P3",
"score": 0
}
]
},
{
"step": "finish_game",
"session_status": "finished"
}
],
"session_code": "7YV59E",
"players": [
"P1",
"P2",
"P3"
],
"round_question_id": 1,
"guess_plan": {
"P1": "Lie from P2",
"P2": "Correct",
"P3": "Lie from P1"
}
}

View File

@@ -4,6 +4,8 @@ import { HostShellComponent } from './host-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
type FetchRouteHandler = (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>;
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
@@ -12,9 +14,14 @@ function jsonResponse(status: number, body: unknown) {
} as unknown as Response;
}
function createFetchRouteMock(handler: FetchRouteHandler): FetchMock {
return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init)));
}
function sessionDetailPayload(
status: string,
options?: {
currentPhase?: string;
roundQuestionId?: number | null;
reveal?: {
correct_answer: string;
@@ -75,6 +82,7 @@ function sessionDetailPayload(
},
phase_view_model: {
status,
current_phase: options?.currentPhase ?? status,
round_number: 1,
players_count: 2,
constraints: {
@@ -83,14 +91,18 @@ function sessionDetailPayload(
min_players_reached: true,
max_players_allowed: true,
},
readiness: {
question_ready: (options?.currentPhase ?? status) !== 'lobby',
scoreboard_ready: (options?.currentPhase ?? status) === 'reveal' || (options?.currentPhase ?? status) === 'scoreboard',
},
host: {
can_start_round: status === 'lobby',
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: false,
can_start_next_round: status === 'scoreboard',
can_finish_game: status === 'scoreboard',
can_start_round: (options?.currentPhase ?? status) === 'lobby',
can_show_question: (options?.currentPhase ?? status) === 'lie',
can_mix_answers: (options?.currentPhase ?? status) === 'lie' || (options?.currentPhase ?? status) === 'guess',
can_calculate_scores: (options?.currentPhase ?? status) === 'guess',
can_reveal_scoreboard: (options?.currentPhase ?? status) === 'reveal',
can_start_next_round: (options?.currentPhase ?? status) === 'scoreboard',
can_finish_game: (options?.currentPhase ?? status) === 'scoreboard',
},
player: {
can_join: status === 'lobby',
@@ -179,18 +191,81 @@ describe('HostShellComponent gameplay wiring', () => {
});
});
it('runs next-round transition into canonical lie phase and clears prior final leaderboard state', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } }))
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 })));
it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => {
let refreshCount = 0;
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/show') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
}
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/99/answers/mix') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 2 } });
}
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 2 } });
}
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
refreshCount += 1;
if (refreshCount === 1) {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
}
if (refreshCount === 2) {
return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }));
}
return jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = ' abcd12 ';
component.roundQuestionId = ' 77 ';
component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any;
await component.showQuestion();
expect(component.session?.session.status).toBe('lie');
expect(component.roundQuestionId).toBe('99');
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
await component.mixAnswers();
expect(component.session?.session.status).toBe('guess');
await component.calculateScores();
expect(component.session?.session.status).toBe('reveal');
expect(component.error).toBe('');
expect(component.loading).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(6);
});
it('runs next-round transition without reload and clears scoreboard payload', async () => {
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'POST' && url === '/lobby/sessions/ABCD12/rounds/next') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
}
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = ' abcd12 ';
component.scoreboardPayload = '{"leaderboard":[]}';
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ;
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.startNextRound();
@@ -227,6 +302,7 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.finishGame();
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
@@ -250,6 +326,7 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent();
component.sessionCode = ' ';
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
await component.startNextRound();
await component.finishGame();
@@ -259,6 +336,77 @@ describe('HostShellComponent gameplay wiring', () => {
expect(component.finishError).toContain('Session code is required');
});
it('blocks illegal host actions outside canonical phase permissions', async () => {
const fetchMock: FetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
component.roundQuestionId = '77';
for (const status of ['guess', 'reveal', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.showQuestion();
}
for (const status of ['lie', 'reveal', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.calculateScores();
}
for (const status of ['lie', 'guess', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.loadScoreboard();
}
for (const status of ['lie', 'guess', 'reveal'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.startNextRound();
await component.finishGame();
}
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
expect(component.canShowQuestion).toBe(false);
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
expect(component.canCalculateScores).toBe(false);
expect(component.canLoadScoreboard).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
expect(component.canLoadScoreboard).toBe(false);
expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
});
it('prefers canonical current_phase for reveal panel and host routing when status lags behind', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, sessionDetailPayload('reveal', { currentPhase: 'scoreboard', roundQuestionId: 77, reveal: { correct_answer: 'Mercury' } }))
);
vi.stubGlobal('fetch', fetchMock);
const replaceState = vi.fn();
vi.stubGlobal('window', {
location: { hash: '#/host/reveal/ABCD12' },
history: { state: null, replaceState },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn() },
});
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.refreshSession();
expect(component.gameplayPhase).toBe('scoreboard');
expect(component.showRevealPanel).toBe(true);
expect(component.canLoadScoreboard).toBe(false);
expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).toBe(true);
expect(replaceState).toHaveBeenCalledWith(null, '', '#/host/scoreboard/ABCD12');
});
it('syncs host hash-route with latest phase after refresh without page reload', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })));
vi.stubGlobal('fetch', fetchMock);
@@ -290,10 +438,17 @@ describe('HostShellComponent gameplay wiring', () => {
component.session = sessionDetailPayload('lie') as any;
expect(component.canStartRound).toBe(false);
expect(component.canShowQuestion).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
component.session = sessionDetailPayload('reveal') as any;
expect(component.canLoadScoreboard).toBe(true);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
component.session = sessionDetailPayload('scoreboard') as any;
expect(component.canLoadScoreboard).toBe(false);
expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).toBe(true);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -195,6 +195,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
reveal,
phase_view_model: {
status: readString(phase, 'status', 'session_detail.phase_view_model'),
current_phase: typeof phase.current_phase === 'string' ? phase.current_phase : undefined,
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'),
constraints: {
@@ -203,6 +204,19 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
min_players_reached: readBoolean(constraints, 'min_players_reached', 'session_detail.phase_view_model.constraints'),
max_players_allowed: readBoolean(constraints, 'max_players_allowed', 'session_detail.phase_view_model.constraints')
},
readiness:
phase.readiness && typeof phase.readiness === 'object'
? {
question_ready:
typeof (phase.readiness as Record<string, unknown>).question_ready === 'boolean'
? ((phase.readiness as Record<string, unknown>).question_ready as boolean)
: undefined,
scoreboard_ready:
typeof (phase.readiness as Record<string, unknown>).scoreboard_ready === 'boolean'
? ((phase.readiness as Record<string, unknown>).scoreboard_ready as boolean)
: undefined,
}
: undefined,
host: {
can_start_round: readBoolean(host, 'can_start_round', 'session_detail.phase_view_model.host'),
can_show_question: readBoolean(host, 'can_show_question', 'session_detail.phase_view_model.host'),

View File

@@ -32,6 +32,7 @@ export interface SessionRoundQuestion {
export interface PhaseViewModel {
status: string;
current_phase?: string;
round_number: number;
players_count: number;
constraints: {
@@ -40,6 +41,10 @@ export interface PhaseViewModel {
min_players_reached: boolean;
max_players_allowed: boolean;
};
readiness?: {
question_ready?: boolean;
scoreboard_ready?: boolean;
};
host: {
can_start_round: boolean;
can_show_question: boolean;

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ from fupogfakta.models import Category, GameSession, Player, Question, RoundQues
class Command(BaseCommand):
help = "Run minimal staging smoke flow for lobby gameplay"
help = "Run canonical gameplay smoke/regression flow for bluff -> guess -> reveal -> scoreboard"
def add_arguments(self, parser):
parser.add_argument(
@@ -18,6 +18,26 @@ class Command(BaseCommand):
help="Optional path to write smoke result artifact as JSON",
)
def _fail(self, step: str, detail: str, payload=None):
message = f"{step} failed: {detail}"
if payload is not None:
message += f" | payload={json.dumps(payload, sort_keys=True)}"
raise CommandError(message)
def _expect_status(self, response, expected_status: int, step: str):
if response.status_code != expected_status:
try:
payload = response.json()
except ValueError:
payload = {"raw": response.content.decode("utf-8", errors="replace")}
self._fail(step, f"expected HTTP {expected_status}, got {response.status_code}", payload)
return response.json()
def _expect_session_status(self, payload: dict, expected_status: str, step: str):
actual_status = payload.get("session", {}).get("status")
if actual_status != expected_status:
self._fail(step, f"expected session.status={expected_status}, got {actual_status}", payload)
def handle(self, *args, **options):
GameSession.objects.all().delete()
Player.objects.all().delete()
@@ -30,11 +50,14 @@ class Command(BaseCommand):
category.is_active = True
category.save(update_fields=["is_active"])
Question.objects.get_or_create(
question, _ = Question.objects.get_or_create(
category=category,
prompt="Smoke prompt?",
defaults={"correct_answer": "Correct", "is_active": True},
)
if not question.is_active:
question.is_active = True
question.save(update_fields=["is_active"])
User = get_user_model()
host, _ = User.objects.get_or_create(username="smoke-host")
@@ -42,111 +65,254 @@ class Command(BaseCommand):
host.is_staff = True
host.save()
artifact = {
"ok": True,
"command": "python manage.py smoke_staging --artifact <path>",
"generated_at": datetime.now(timezone.utc).isoformat(),
"question": {
"prompt": question.prompt,
"correct_answer": question.correct_answer,
},
"steps": [],
}
host_client = Client()
host_client.force_login(host)
create_res = host_client.post("/lobby/sessions/create", content_type="application/json")
if create_res.status_code != 201:
raise CommandError(f"create_session failed: {create_res.status_code} {create_res.content!r}")
code = create_res.json()["session"]["code"]
create_payload = self._expect_status(
host_client.post("/lobby/sessions/create", content_type="application/json"),
201,
"create_session",
)
code = create_payload["session"]["code"]
artifact["session_code"] = code
artifact["steps"].append(
{
"step": "create_session",
"session_status": create_payload["session"]["status"],
}
)
players = []
for nickname in ["P1", "P2", "P3"]:
join_res = Client().post(
"/lobby/sessions/join",
data=json.dumps({"code": code, "nickname": nickname}),
content_type="application/json",
join_payload = self._expect_status(
Client().post(
"/lobby/sessions/join",
data=json.dumps({"code": code, "nickname": nickname}),
content_type="application/json",
),
201,
f"join_session[{nickname}]",
)
if join_res.status_code != 201:
raise CommandError(f"join_session failed for {nickname}: {join_res.status_code}")
players.append(join_res.json()["player"])
start_res = host_client.post(
f"/lobby/sessions/{code}/rounds/start",
data=json.dumps({"category_slug": category.slug}),
content_type="application/json",
players.append(join_payload["player"])
artifact["players"] = [player["nickname"] for player in players]
artifact["steps"].append(
{
"step": "join_players",
"players_count": len(players),
}
)
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 = []
lie_transition_payload = None
for player in players:
nick = player["nickname"]
lie_res = Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"text": f"Lie from {nick}",
}
nickname = player["nickname"]
lie_payload = self._expect_status(
Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/lies/submit",
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"text": f"Lie from {nickname}",
}
),
content_type="application/json",
),
content_type="application/json",
201,
f"submit_lie[{nickname}]",
)
if lie_res.status_code != 201:
raise CommandError(f"submit_lie failed for {nick}: {lie_res.status_code}")
if lie_res.json().get("answers"):
answers = lie_res.json()["answers"]
if lie_payload.get("answers"):
answers = lie_payload["answers"]
lie_transition_payload = lie_payload
if not answers:
detail_res = host_client.get(f"/lobby/sessions/{code}")
if detail_res.status_code != 200:
raise CommandError(f"session_detail after lies failed: {detail_res.status_code}")
answers = detail_res.json().get("round_question", {}).get("answers", [])
detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_lies")
answers = detail_payload.get("round_question", {}).get("answers", [])
self._expect_session_status(detail_payload, GameSession.Status.GUESS, "session_detail_after_lies")
lie_transition_payload = detail_payload
if not answers:
raise CommandError("canonical lie->guess transition returned empty answers")
self._fail("auto_guess_transition", "canonical lie->guess transition returned empty answers")
for player in players:
nick = player["nickname"]
selected = next((a for a in answers if a.get("player_id") != player["id"]), answers[0])
guess_res = Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"selected_text": selected["text"],
}
),
content_type="application/json",
if not any(answer.get("text") == question.correct_answer for answer in answers):
self._fail("auto_guess_transition", "mixed answers missing correct answer", {"answers": answers})
if len(answers) < len(players) + 1:
self._fail(
"auto_guess_transition",
"mixed answers shorter than expected bluff set",
{"answers": answers, "players_count": len(players)},
)
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}")
if detail_res.status_code != 200:
raise CommandError(f"session_detail after guesses failed: {detail_res.status_code}")
if detail_res.json()["session"]["status"] != GameSession.Status.SCOREBOARD:
raise CommandError("canonical guess->reveal->scoreboard transition did not reach scoreboard")
self._expect_session_status(lie_transition_payload, GameSession.Status.GUESS, "auto_guess_transition")
artifact["steps"].append(
{
"step": "auto_guess_transition",
"session_status": lie_transition_payload["session"]["status"],
"answers": [answer["text"] for answer in answers],
}
)
finish_res = host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json")
if finish_res.status_code != 200:
raise CommandError(f"finish_game failed: {finish_res.status_code}")
answer_texts = {answer["text"] for answer in answers}
correct_answer = next((answer["text"] for answer in answers if answer.get("text") == question.correct_answer), None)
if correct_answer is None:
self._fail("submit_guesses", "could not resolve correct answer from mixed answers", {"answers": answers})
guess_plan = {
players[0]["nickname"]: "Lie from P2",
players[1]["nickname"]: correct_answer,
players[2]["nickname"]: "Lie from P1",
}
missing_guess_targets = {text for text in guess_plan.values() if text not in answer_texts}
if missing_guess_targets:
self._fail(
"submit_guesses",
"expected bluff targets missing from mixed answers",
{"answers": answers, "missing_guess_targets": sorted(missing_guess_targets)},
)
artifact["guess_plan"] = guess_plan
guess_payloads = []
for player in players:
nickname = player["nickname"]
guess_payload = self._expect_status(
Client().post(
f"/lobby/sessions/{code}/questions/{round_question_id}/guesses/submit",
data=json.dumps(
{
"player_id": player["id"],
"session_token": player["session_token"],
"selected_text": guess_plan[nickname],
}
),
content_type="application/json",
),
201,
f"submit_guess[{nickname}]",
)
guess_payloads.append(guess_payload)
reveal_payload = guess_payloads[-1]
self._expect_session_status(reveal_payload, GameSession.Status.REVEAL, "auto_reveal_transition")
if not reveal_payload.get("phase_transition", {}).get("auto_advanced"):
self._fail("auto_reveal_transition", "expected auto_advanced=true on final guess", reveal_payload)
reveal = reveal_payload.get("reveal")
if not reveal:
self._fail("auto_reveal_transition", "missing canonical reveal payload", reveal_payload)
if reveal.get("correct_answer") != question.correct_answer:
self._fail(
"auto_reveal_transition",
"reveal payload returned wrong correct answer",
{"expected": question.correct_answer, "reveal": reveal},
)
if len(reveal.get("lies", [])) != len(players):
self._fail("auto_reveal_transition", "unexpected lie count in reveal payload", reveal)
if len(reveal.get("guesses", [])) != len(players):
self._fail("auto_reveal_transition", "unexpected guess count in reveal payload", reveal)
fooled_guesses = [guess for guess in reveal["guesses"] if not guess.get("is_correct")]
correct_guesses = [guess for guess in reveal["guesses"] if guess.get("is_correct")]
if len(fooled_guesses) != 2:
self._fail("auto_reveal_transition", "expected exactly two bluff guesses", reveal)
if len(correct_guesses) != 1:
self._fail("auto_reveal_transition", "expected exactly one correct guess", reveal)
if any(guess.get("fooled_player_id") is None for guess in fooled_guesses):
self._fail("auto_reveal_transition", "bluff guesses missing fooled_player_id", reveal)
artifact["steps"].append(
{
"step": "submit_guesses",
"guess_results": [
{
"player_id": payload["guess"]["player_id"],
"selected_text": payload["guess"]["selected_text"],
"is_correct": payload["guess"]["is_correct"],
"fooled_player_id": payload["guess"].get("fooled_player_id"),
}
for payload in guess_payloads
],
}
)
artifact["steps"].append(
{
"step": "auto_reveal_transition",
"session_status": reveal_payload["session"]["status"],
"reveal": {
"correct_answer": reveal["correct_answer"],
"lies_count": len(reveal["lies"]),
"guesses_count": len(reveal["guesses"]),
"fooled_player_ids": sorted(guess["fooled_player_id"] for guess in fooled_guesses),
"correct_guess_player_ids": sorted(guess["player_id"] for guess in correct_guesses),
},
}
)
detail_payload = self._expect_status(host_client.get(f"/lobby/sessions/{code}"), 200, "session_detail_after_guesses")
self._expect_session_status(detail_payload, GameSession.Status.SCOREBOARD, "auto_scoreboard_transition")
if detail_payload.get("reveal") != reveal:
self._fail("auto_scoreboard_transition", "scoreboard promotion changed canonical reveal payload", detail_payload)
scoreboard = detail_payload.get("scoreboard")
if not scoreboard:
self._fail("auto_scoreboard_transition", "missing scoreboard payload after promotion", detail_payload)
if len(scoreboard) != len(players):
self._fail("auto_scoreboard_transition", "unexpected scoreboard length", detail_payload)
if not detail_payload.get("phase_view_model", {}).get("readiness", {}).get("scoreboard_ready"):
self._fail("auto_scoreboard_transition", "scoreboard_ready=false after promotion", detail_payload)
artifact["steps"].append(
{
"step": "auto_scoreboard_transition",
"session_status": detail_payload["session"]["status"],
"leaderboard": scoreboard,
}
)
finish_payload = self._expect_status(
host_client.post(f"/lobby/sessions/{code}/finish", content_type="application/json"),
200,
"finish_game",
)
self._expect_session_status(finish_payload, GameSession.Status.FINISHED, "finish_game")
artifact["steps"].append(
{
"step": "finish_game",
"session_status": finish_payload["session"]["status"],
}
)
artifact_path = options.get("artifact")
if artifact_path:
artifact = {
"ok": True,
"command": "smoke_staging",
"generated_at": datetime.now(timezone.utc).isoformat(),
"session_code": code,
"players": [player["nickname"] for player in players],
"round_question_id": round_question_id,
"steps": [
"create_session",
"join_players",
"start_round",
"submit_lies",
"auto_guess_transition",
"submit_guesses",
"auto_reveal_to_scoreboard",
"finish_game",
],
}
output_path = Path(artifact_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(artifact, indent=2) + "\n", encoding="utf-8")

View File

@@ -867,6 +867,79 @@ class CanonicalRoundFlowTests(TestCase):
},
)
def test_canonical_round_flow_bootstraps_second_round_without_first_round_carry_over(self):
self.client.login(username="host_canonical", password="secret123")
extra_question = Question.objects.create(
category=self.category,
prompt="Hvem malede Mona Lisa?",
correct_answer="Da Vinci",
is_active=True,
)
start_response = self.client.post(
reverse("lobby:start_round", kwargs={"code": self.session.code}),
data={"category_slug": self.category.slug},
content_type="application/json",
)
self.assertEqual(start_response.status_code, 201)
first_round_question_id = start_response.json()["round_question"]["id"]
first_round_prompt = start_response.json()["round_question"]["prompt"]
first_round_correct_answer = RoundQuestion.objects.get(pk=first_round_question_id).correct_answer
second_question = extra_question if first_round_prompt == self.question.prompt else self.question
final_lie_response = None
for index, player in enumerate(self.players, start=1):
lie_response = self.client.post(
reverse("lobby:submit_lie", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}),
data={"player_id": player.id, "session_token": player.session_token, "text": f"Første løgn {index}"},
content_type="application/json",
)
self.assertEqual(lie_response.status_code, 201)
final_lie_response = lie_response
self.assertIsNotNone(final_lie_response)
for player, selected_text in zip(
self.players,
[first_round_correct_answer, first_round_correct_answer, first_round_correct_answer],
strict=True,
):
guess_response = self.client.post(
reverse("lobby:submit_guess", kwargs={"code": self.session.code, "round_question_id": first_round_question_id}),
data={"player_id": player.id, "session_token": player.session_token, "selected_text": selected_text},
content_type="application/json",
)
self.assertEqual(guess_response.status_code, 201)
scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(scoreboard_payload["session"]["status"], GameSession.Status.SCOREBOARD)
self.assertEqual(scoreboard_payload["round_question"]["id"], first_round_question_id)
self.assertIsNotNone(scoreboard_payload["reveal"])
self.assertIsNotNone(scoreboard_payload["scoreboard"])
self.assertGreaterEqual(len(scoreboard_payload["reveal"]["guesses"]), 1)
next_round_response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
self.assertEqual(next_round_response.status_code, 200)
self.assertEqual(next_round_response.json()["session"]["status"], GameSession.Status.LIE)
self.assertEqual(next_round_response.json()["session"]["current_round"], 2)
detail_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
self.assertEqual(detail_payload["session"]["status"], GameSession.Status.LIE)
self.assertEqual(detail_payload["session"]["current_round"], 2)
self.assertEqual(detail_payload["phase_view_model"]["current_phase"], GameSession.Status.LIE)
self.assertIsNone(detail_payload["reveal"])
self.assertIsNone(detail_payload["scoreboard"])
self.assertEqual(detail_payload["round_question"]["round_number"], 2)
self.assertNotEqual(detail_payload["round_question"]["id"], first_round_question_id)
self.assertEqual(detail_payload["round_question"]["prompt"], second_question.prompt)
self.assertEqual(detail_payload["round_question"]["answers"], [])
round_two_question = RoundQuestion.objects.get(session=self.session, round_number=2)
self.assertEqual(round_two_question.question, second_question)
self.assertEqual(round_two_question.lies.count(), 0)
self.assertEqual(round_two_question.guesses.count(), 0)
self.assertEqual(round_two_question.mixed_answers, [])
@patch("lobby.views.sync_broadcast_phase_event")
@patch("lobby.views._resolve_scores")
@patch("lobby.views.GameSession.objects.get")
@@ -1122,12 +1195,6 @@ 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,
@@ -1274,22 +1341,7 @@ 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_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["round_question"]["prompt"], self.next_question.prompt)
self.assertEqual(payload["config"]["lie_seconds"], self.round_config.lie_seconds)
self.session.refresh_from_db()
@@ -1298,108 +1350,51 @@ class RevealRoundFlowTests(TestCase):
self.assertTrue(
RoundConfig.objects.filter(session=self.session, number=2, category=self.category).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},
self.assertTrue(
RoundQuestion.objects.filter(session=self.session, round_number=2, question=self.next_question).exists()
)
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")
def test_start_next_round_clears_stale_future_round_artifacts(self):
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_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"],
mixed_answers=["Stale truth", "Stale lie"],
)
LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Göteborg")
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="Göteborg",
fooled_player=self.player_one,
is_correct=False,
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.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)
stale_round_question.refresh_from_db()
self.assertEqual(self.session.status, GameSession.Status.LIE)
self.assertEqual(self.session.current_round, 2)
self.assertEqual(response.json()["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)
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()
second_transition = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code}))
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")
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["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_requires_host(self):
self.session.status = GameSession.Status.SCOREBOARD
@@ -1467,7 +1462,6 @@ 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(
@@ -2023,7 +2017,7 @@ class SmokeStagingCommandTests(TestCase):
self.assertEqual(session.status, GameSession.Status.FINISHED)
self.assertEqual(Player.objects.filter(session=session).count(), 3)
def test_smoke_staging_writes_artifact_when_requested(self):
def test_smoke_staging_writes_phase_evidence_artifact_when_requested(self):
with tempfile.TemporaryDirectory() as tmp_dir:
artifact_path = Path(tmp_dir) / "smoke.json"
call_command("smoke_staging", artifact=str(artifact_path))
@@ -2031,24 +2025,40 @@ class SmokeStagingCommandTests(TestCase):
self.assertTrue(artifact_path.exists())
payload = json.loads(artifact_path.read_text(encoding="utf-8"))
self.assertTrue(payload["ok"])
self.assertEqual(payload["command"], "smoke_staging")
self.assertEqual(payload["command"], "python manage.py smoke_staging --artifact <path>")
self.assertEqual(payload["players"], ["P1", "P2", "P3"])
self.assertIn("generated_at", payload)
self.assertIn("session_code", payload)
self.assertEqual(payload["question"]["correct_answer"], "Correct")
self.assertEqual(payload["guess_plan"]["P2"], "Correct")
step_names = [step["step"] for step in payload["steps"]]
self.assertEqual(
payload["steps"],
step_names,
[
"create_session",
"join_players",
"start_round",
"submit_lies",
"auto_guess_transition",
"submit_guesses",
"auto_reveal_to_scoreboard",
"auto_reveal_transition",
"auto_scoreboard_transition",
"finish_game",
],
)
reveal_step = payload["steps"][5]
self.assertEqual(reveal_step["session_status"], GameSession.Status.REVEAL)
self.assertEqual(reveal_step["reveal"]["correct_answer"], "Correct")
self.assertEqual(reveal_step["reveal"]["lies_count"], 3)
self.assertEqual(reveal_step["reveal"]["guesses_count"], 3)
self.assertEqual(len(reveal_step["reveal"]["fooled_player_ids"]), 2)
self.assertEqual(len(reveal_step["reveal"]["correct_guess_player_ids"]), 1)
scoreboard_step = payload["steps"][6]
self.assertEqual(scoreboard_step["session_status"], GameSession.Status.SCOREBOARD)
self.assertEqual(len(scoreboard_step["leaderboard"]), 3)
class I18nResolverTests(TestCase):
def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self):

View File

@@ -169,22 +169,6 @@ def _build_lie_started_payload(session: GameSession, round_config: RoundConfig,
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:
@@ -209,6 +193,16 @@ def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]:
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()
if round_question.mixed_answers:
round_question.mixed_answers = []
round_question.save(update_fields=["mixed_answers"])
return round_question
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:
@@ -1120,7 +1114,6 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
return api_error(request, code="round_config_missing", status=400)
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,
@@ -1133,7 +1126,9 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
locked_session.current_round = next_round_number
try:
round_question = _select_round_question(locked_session, next_round_config)
round_question = _reset_round_question_bootstrap_state(
_select_round_question(locked_session, next_round_config)
)
except ValueError as exc:
return api_error(request, code=str(exc), status=400)
@@ -1162,22 +1157,13 @@ def start_next_round(request: HttpRequest, code: str) -> JsonResponse:
"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,
},