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/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;