diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ae3f59e..9fc93e0 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/docs/ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md b/docs/ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md new file mode 100644 index 0000000..51cc364 --- /dev/null +++ b/docs/ISSUE-301-CLIENT-ACTION-GATING-ARTIFACT.md @@ -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. diff --git a/docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md b/docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md new file mode 100644 index 0000000..aa30eac --- /dev/null +++ b/docs/ISSUE-302-CANONICAL-LOOP-EVIDENCE.md @@ -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 diff --git a/docs/artifacts/issue-302-canonical-loop-smoke.json b/docs/artifacts/issue-302-canonical-loop-smoke.json new file mode 100644 index 0000000..19682ca --- /dev/null +++ b/docs/artifacts/issue-302-canonical-loop-smoke.json @@ -0,0 +1,110 @@ +{ + "ok": true, + "command": "python manage.py smoke_staging --artifact ", + "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" + } +} diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index d57ff4d..0d18e05 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -21,6 +21,7 @@ function createFetchRouteMock(handler: FetchRouteHandler): FetchMock { function sessionDetailPayload( status: string, options?: { + currentPhase?: string; roundQuestionId?: number | null; reveal?: { correct_answer: string; @@ -81,6 +82,7 @@ function sessionDetailPayload( }, phase_view_model: { status, + current_phase: options?.currentPhase ?? status, round_number: 1, players_count: 2, constraints: { @@ -89,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: 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_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', @@ -259,7 +265,7 @@ describe('HostShellComponent gameplay wiring', () => { component.scoreboardPayload = '{"leaderboard":[]}'; component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ; component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }]; - component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any; + component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any; await component.startNextRound(); @@ -296,7 +302,7 @@ describe('HostShellComponent gameplay wiring', () => { const component = new HostShellComponent(); component.sessionCode = 'ABCD12'; - component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any; + component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any; await component.finishGame(); expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout'); @@ -320,7 +326,7 @@ describe('HostShellComponent gameplay wiring', () => { const component = new HostShellComponent(); component.sessionCode = ' '; - component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any; + component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any; await component.startNextRound(); await component.finishGame(); @@ -351,6 +357,10 @@ describe('HostShellComponent gameplay wiring', () => { 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(); } @@ -361,16 +371,42 @@ describe('HostShellComponent gameplay wiring', () => { 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); + 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(false); - expect(component.canFinishGame).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); @@ -408,12 +444,12 @@ describe('HostShellComponent gameplay wiring', () => { component.session = sessionDetailPayload('reveal') as any; expect(component.canLoadScoreboard).toBe(true); - expect(component.canStartNextRound).toBe(true); - expect(component.canFinishGame).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(false); - expect(component.canFinishGame).toBe(false); + expect(component.canStartNextRound).toBe(true); + expect(component.canFinishGame).toBe(true); }); }); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 9eb219a..5c2826a 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -48,7 +48,7 @@ type LeaderboardResponse = FinishGameResponse; -
+

Reveal

Korrekt svar: {{ session.reveal.correct_answer }}

Spørgsmål: {{ session.reveal.prompt }}

@@ -163,6 +163,10 @@ export class HostShellComponent implements OnInit, OnDestroy { 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 { return t(key, this.locale); } @@ -364,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; diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index 389d682..15c908e 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -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', }, }, }; @@ -437,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(); diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index 2e4e59f..a514f7a 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -95,7 +95,7 @@ function resolveLocalStorage(): Storage | undefined { -
+

Reveal

Korrekt svar: {{ session.reveal.correct_answer }}

Spørgsmål: {{ session.reveal.prompt }}

@@ -221,6 +221,10 @@ export class PlayerShellComponent implements OnInit, OnDestroy { 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(); @@ -469,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; diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts index 0cab015..fe7c89d 100644 --- a/frontend/src/api/mappers.ts +++ b/frontend/src/api/mappers.ts @@ -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).question_ready === 'boolean' + ? ((phase.readiness as Record).question_ready as boolean) + : undefined, + scoreboard_ready: + typeof (phase.readiness as Record).scoreboard_ready === 'boolean' + ? ((phase.readiness as Record).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'), diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 5a9a13a..926f28a 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -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; diff --git a/lobby/management/commands/smoke_staging.py b/lobby/management/commands/smoke_staging.py index c388f9e..8de8782 100644 --- a/lobby/management/commands/smoke_staging.py +++ b/lobby/management/commands/smoke_staging.py @@ -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 ", + "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") diff --git a/lobby/tests.py b/lobby/tests.py index 0a72835..6722cf5 100644 --- a/lobby/tests.py +++ b/lobby/tests.py @@ -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") @@ -1347,6 +1420,45 @@ class RevealRoundFlowTests(TestCase): self.assertEqual(RoundConfig.objects.filter(session=self.session, number=1).count(), 1) self.assertEqual(RoundQuestion.objects.filter(session=self.session, round_number=1).count(), 1) + def test_start_next_round_clears_existing_next_round_bootstrap_state(self): + self.client.login(username="host_reveal", password="secret123") + self.client.get(reverse("lobby:reveal_scoreboard", kwargs={"code": self.session.code})) + + stale_round_question = RoundQuestion.objects.create( + session=self.session, + round_number=2, + question=self.next_question, + correct_answer=self.next_question.correct_answer, + mixed_answers=["Stale truth", "Stale lie"], + ) + LieAnswer.objects.create(round_question=stale_round_question, player=self.player_one, text="Stale lie") + Guess.objects.create( + round_question=stale_round_question, + player=self.player_two, + selected_text="Stale truth", + is_correct=True, + ) + + response = self.client.post(reverse("lobby:start_next_round", kwargs={"code": self.session.code})) + + self.assertEqual(response.status_code, 200) + self.session.refresh_from_db() + stale_round_question.refresh_from_db() + self.assertEqual(self.session.status, GameSession.Status.LIE) + self.assertEqual(self.session.current_round, 2) + 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) + + 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 self.session.save(update_fields=["status"]) @@ -1968,7 +2080,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)) @@ -1976,24 +2088,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 ") 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): diff --git a/lobby/views.py b/lobby/views.py index 2c0112d..64aa0b0 100644 --- a/lobby/views.py +++ b/lobby/views.py @@ -116,6 +116,132 @@ def _build_finish_game_response(session: GameSession) -> JsonResponse: ) +def _get_current_round_question(session: GameSession) -> RoundQuestion | None: + return ( + RoundQuestion.objects.filter(session=session, round_number=session.current_round) + .select_related("question") + .order_by("-id") + .first() + ) + + + +def _select_round_question(session: GameSession, round_config: RoundConfig) -> RoundQuestion: + existing_round_question = _get_current_round_question(session) + if existing_round_question is not None: + return existing_round_question + + used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True) + available_questions = Question.objects.filter( + category=round_config.category, + is_active=True, + ).exclude(pk__in=used_question_ids) + + if not available_questions.exists(): + raise ValueError("no_available_questions") + + question = random.choice(list(available_questions)) + return RoundQuestion.objects.create( + session=session, + round_number=session.current_round, + question=question, + correct_answer=question.correct_answer, + ) + + + +def _build_lie_started_payload(session: GameSession, round_config: RoundConfig, round_question: RoundQuestion) -> dict: + lie_deadline_at = round_question.shown_at + timedelta(seconds=round_config.lie_seconds) + return { + "round_number": session.current_round, + "category": {"slug": round_config.category.slug, "name": round_config.category.name}, + "round_question_id": round_question.id, + "prompt": round_question.question.prompt, + "shown_at": round_question.shown_at.isoformat(), + "lie_deadline_at": lie_deadline_at.isoformat(), + "lie_seconds": round_config.lie_seconds, + } + + + +def _prepare_mixed_answers(round_question: RoundQuestion) -> list[str]: + deduped_answers = list(round_question.mixed_answers or []) + if deduped_answers: + return deduped_answers + + lie_texts = list(round_question.lies.values_list("text", flat=True)) + seen = set() + for text in [round_question.correct_answer, *lie_texts]: + normalized = text.strip().casefold() + if not normalized or normalized in seen: + continue + seen.add(normalized) + deduped_answers.append(text.strip()) + + if len(deduped_answers) < 2: + raise ValueError("not_enough_answers_to_mix") + + random.shuffle(deduped_answers) + round_question.mixed_answers = deduped_answers + round_question.save(update_fields=["mixed_answers"]) + return deduped_answers + + + +def _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: + 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 @@ -995,7 +1121,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)