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 626db73..3c15591 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 @@ -245,6 +245,30 @@ describe('HostShellComponent gameplay wiring', () => { expect(component.finishError).toContain('Session code is required'); }); + it('blocks illegal host actions outside canonical reveal/scoreboard boundaries', async () => { + const fetchMock: FetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const component = new HostShellComponent(); + component.sessionCode = 'ABCD12'; + + for (const status of ['lie', 'guess', 'reveal'] as const) { + component.session = sessionDetailPayload(status) as any; + await component.startNextRound(); + await component.finishGame(); + } + + component.session = sessionDetailPayload('reveal') as any; + expect(component.canStartNextRound).toBe(false); + expect(component.canFinishGame).toBe(false); + + component.session = sessionDetailPayload('scoreboard') as any; + await component.loadScoreboard(); + + expect(component.canLoadScoreboard).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('syncs host hash-route with latest phase after refresh without page reload', async () => { const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))); vi.stubGlobal('fetch', fetchMock); 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 4253051..89baba5 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; import type { FinishGameResponse, ScoreboardResponse } 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'; @@ -27,16 +28,16 @@ type LeaderboardResponse = FinishGameResponse; - - - - - - - - - - + + + + + + + + + +

{{ copy('host.audio_locale_hint') }}: {{ locale }}

@@ -114,6 +115,30 @@ export class HostShellComponent implements OnInit, OnDestroy { this.unsubscribeLocale = null; } + get gameplayPhase(): string | null { + return deriveGameplayPhase(this.session as any); + } + + get canStartRound(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'startRound'); + } + + get canStartNextRound(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'startNextRound'); + } + + get canFinishGame(): boolean { + return isHostGameplayActionAllowed(this.session as any, 'finishGame'); + } + + get canUseLegacyMidRoundHostAction(): boolean { + return false; + } + + get canLoadScoreboard(): boolean { + return !this.session || this.gameplayPhase === 'reveal'; + } + copy(key: string): string { return t(key, this.locale); } @@ -174,6 +199,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async startRound(): Promise { + if (!this.canStartRound) { + return; + } + await this.runAction(async () => { const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim()); if (!state.session || state.errorMessage) { @@ -190,6 +219,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async showQuestion(): Promise { + if (!this.canUseLegacyMidRoundHostAction) { + return; + } + await this.runAction(async () => { const code = this.normalizeCode(this.sessionCode); await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {}); @@ -198,6 +231,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async mixAnswers(): Promise { + if (!this.canUseLegacyMidRoundHostAction) { + return; + } + await this.runAction(async () => { const code = this.normalizeCode(this.sessionCode); const roundQuestionId = this.roundQuestionId.trim(); @@ -207,6 +244,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async calculateScores(): Promise { + if (!this.canUseLegacyMidRoundHostAction) { + return; + } + await this.runAction(async () => { const code = this.normalizeCode(this.sessionCode); const roundQuestionId = this.roundQuestionId.trim(); @@ -216,6 +257,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async loadScoreboard(): Promise { + if (!this.canLoadScoreboard) { + return; + } + this.loading = true; this.scoreboardError = ''; this.error = ''; @@ -232,6 +277,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async startNextRound(): Promise { + if (!this.canStartNextRound) { + return; + } + this.loading = true; this.nextRoundError = ''; this.error = ''; @@ -252,6 +301,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async finishGame(): Promise { + if (!this.canFinishGame) { + return; + } + this.loading = true; this.finishError = ''; this.error = ''; 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 5bd1d1d..9ebcf84 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 @@ -199,6 +199,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(); 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 393723a..c1ae1e9 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -3,6 +3,10 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; +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'; @@ -71,9 +75,9 @@ function resolveLocalStorage(): Storage | undefined {

{{ copy('common.status') }}: {{ session.session.status }}

{{ copy('common.prompt') }}: {{ session.round_question.prompt }}

- - - + + +
- - + +

{{ copy('player.final_leaderboard') }}

@@ -181,6 +185,18 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.restoreAudioGuard = null; } + get gameplayPhase(): string | null { + return deriveGameplayPhase(this.session as any); + } + + get canSubmitLie(): boolean { + return isPlayerGameplayActionAllowed(this.session as any, 'submitLie'); + } + + get canSubmitGuess(): boolean { + return isPlayerGameplayActionAllowed(this.session as any, 'submitGuess'); + } + private readonly handleOnline = (): void => { this.connectionState = 'reconnecting'; void this.retryReconnect(); @@ -500,7 +516,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } async submitLie(): Promise { - if (!this.session?.round_question?.id) { + if (!this.session?.round_question?.id || !this.canSubmitLie) { return; } this.loading = true; @@ -528,7 +544,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } async submitGuess(): Promise { - if (!this.session?.round_question?.id || !this.selectedGuess) { + if (!this.session?.round_question?.id || !this.selectedGuess || !this.canSubmitGuess) { return; } this.loading = true; diff --git a/frontend/src/spa/gameplay-phase-machine.ts b/frontend/src/spa/gameplay-phase-machine.ts index f0ba252..c9b059c 100644 --- a/frontend/src/spa/gameplay-phase-machine.ts +++ b/frontend/src/spa/gameplay-phase-machine.ts @@ -1,6 +1,8 @@ -import type { SessionDetailResponse } from '../api/types'; +import type { PhaseViewModel, SessionDetailResponse } from '../api/types'; export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard'; +export type HostGameplayAction = 'startRound' | 'startNextRound' | 'finishGame'; +export type PlayerGameplayAction = 'join' | 'submitLie' | 'submitGuess' | 'viewFinalResult'; export type GameplayPhaseEvent = | 'LIES_LOCKED' @@ -40,8 +42,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 +57,53 @@ 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 true; + } + + const phase = deriveGameplayPhase(session); + const host = session.phase_view_model?.host; + switch (action) { + case 'startRound': + return phase === null || Boolean(host?.can_start_round ?? phase === null); + case 'startNextRound': + return phase === 'scoreboard' && Boolean(host?.can_start_next_round ?? true); + case 'finishGame': + return phase === 'scoreboard' && Boolean(host?.can_finish_game ?? true); + } +} + +export function isPlayerGameplayActionAllowed(session: SessionDetailResponse | null, action: PlayerGameplayAction): boolean { + if (!session) { + return action === 'join'; + } + + const phase = deriveGameplayPhase(session); + const player = session.phase_view_model?.player; + switch (action) { + case 'join': + return Boolean(player?.can_join ?? true); + case 'submitLie': + return phase === 'lie' && Boolean(player?.can_submit_lie ?? true); + case 'submitGuess': + return phase === 'guess' && Boolean(player?.can_submit_guess ?? true); + case 'viewFinalResult': + return session.session.status === 'finished' && Boolean(player?.can_view_final_result ?? true); + } +}