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);
+ }
+}