fix(gameplay): gate client actions from canonical phase state (#301) #303

Merged
integrator-bot merged 8 commits from dev/issue-301-client-action-gating into main 2026-03-16 15:53:44 +01:00
5 changed files with 188 additions and 21 deletions
Showing only changes of commit 076faf2ff1 - Show all commits

View File

@@ -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);

View File

@@ -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;
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
<label>{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button (click)="startRound()" [disabled]="loading">{{ copy('host.start_round') }}</button>
<button (click)="showQuestion()" [disabled]="loading || !roundQuestionId">{{ copy('host.show_question') }}</button>
<button (click)="mixAnswers()" [disabled]="loading || !roundQuestionId">{{ copy('host.mix_answers') }}</button>
<button (click)="calculateScores()" [disabled]="loading || !roundQuestionId">{{ copy('host.calculate_scores') }}</button>
<button (click)="loadScoreboard()" [disabled]="loading">{{ copy('host.load_scoreboard') }}</button>
<button (click)="startNextRound()" [disabled]="loading">{{ copy('host.start_next_round') }}</button>
<button (click)="finishGame()" [disabled]="loading">{{ copy('host.finish_game') }}</button>
<button *ngIf="scoreboardError" (click)="loadScoreboard()" [disabled]="loading">{{ copy('host.retry_scoreboard') }}</button>
<button *ngIf="nextRoundError" (click)="startNextRound()" [disabled]="loading">{{ copy('host.retry_next_round') }}</button>
<button *ngIf="finishError" (click)="finishGame()" [disabled]="loading">{{ copy('host.retry_finish') }}</button>
<button (click)="startRound()" [disabled]="loading || !canStartRound">{{ copy('host.start_round') }}</button>
<button (click)="showQuestion()" [disabled]="loading || !canUseLegacyMidRoundHostAction">{{ copy('host.show_question') }}</button>
<button (click)="mixAnswers()" [disabled]="loading || !canUseLegacyMidRoundHostAction">{{ copy('host.mix_answers') }}</button>
<button (click)="calculateScores()" [disabled]="loading || !canUseLegacyMidRoundHostAction">{{ 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>
@@ -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<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) {
@@ -190,6 +219,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
async showQuestion(): Promise<void> {
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<void> {
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<void> {
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<void> {
if (!this.canLoadScoreboard) {
return;
}
this.loading = true;
this.scoreboardError = '';
this.error = '';
@@ -232,6 +277,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
async startNextRound(): Promise<void> {
if (!this.canStartNextRound) {
return;
}
this.loading = true;
this.nextRoundError = '';
this.error = '';
@@ -252,6 +301,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
}
async finishGame(): Promise<void> {
if (!this.canFinishGame) {
return;
}
this.loading = true;
this.finishError = '';
this.error = '';

View File

@@ -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();

View File

@@ -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 {
<p><strong>{{ copy('common.status') }}:</strong> {{ session.session.status }}</p>
<p *ngIf="session.round_question"><strong>{{ copy('common.prompt') }}:</strong> {{ session.round_question.prompt }}</p>
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading || session.session.status !== 'lie'" /></label>
<button (click)="submitLie()" [disabled]="loading || session.session.status !== 'lie'">{{ 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>
<div class="answers" *ngIf="session.round_question?.answers?.length">
<button
@@ -81,14 +85,14 @@ function resolveLocalStorage(): Storage | undefined {
*ngFor="let answer of session.round_question?.answers"
(click)="selectedGuess = answer.text"
[class.active]="selectedGuess === answer.text"
[disabled]="loading || session.session.status !== 'guess'"
[disabled]="loading || !canSubmitGuess"
>
{{ answer.text }}
</button>
</div>
<button (click)="submitGuess()" [disabled]="loading || session.session.status !== 'guess' || !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>
<div *ngIf="session.session.status === 'finished' && finalLeaderboard.length">
<h3>{{ copy('player.final_leaderboard') }}</h3>
@@ -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<void> {
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<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

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