Merge pull request 'fix(gameplay): gate client actions from canonical phase state (#301)' (#303) from dev/issue-301-client-action-gating into main
All checks were successful
CI / test-and-quality (push) Successful in 2m36s

This commit was merged in pull request #303.
This commit is contained in:
2026-03-16 15:53:44 +01:00
9 changed files with 545 additions and 35 deletions

View File

@@ -4,6 +4,8 @@ import { HostShellComponent } from './host-shell.component';
type FetchMock = ReturnType<typeof vi.fn>; type FetchMock = ReturnType<typeof vi.fn>;
type FetchRouteHandler = (input: RequestInfo | URL, init?: RequestInit) => Response | Promise<Response>;
function jsonResponse(status: number, body: unknown) { function jsonResponse(status: number, body: unknown) {
return { return {
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
@@ -12,6 +14,10 @@ function jsonResponse(status: number, body: unknown) {
} as unknown as Response; } as unknown as Response;
} }
function createFetchRouteMock(handler: FetchRouteHandler): FetchMock {
return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init)));
}
function sessionDetailPayload( function sessionDetailPayload(
status: string, status: string,
options?: { options?: {
@@ -85,12 +91,12 @@ function sessionDetailPayload(
}, },
host: { host: {
can_start_round: status === 'lobby', can_start_round: status === 'lobby',
can_show_question: false, can_show_question: status === 'lie',
can_mix_answers: false, can_mix_answers: status === 'lie' || status === 'guess',
can_calculate_scores: false, can_calculate_scores: status === 'guess',
can_reveal_scoreboard: false, can_reveal_scoreboard: status === 'reveal',
can_start_next_round: status === 'scoreboard', can_start_next_round: status === 'reveal',
can_finish_game: status === 'scoreboard', can_finish_game: status === 'reveal',
}, },
player: { player: {
can_join: status === 'lobby', can_join: status === 'lobby',
@@ -179,18 +185,81 @@ describe('HostShellComponent gameplay wiring', () => {
}); });
}); });
it('runs next-round transition into canonical lie phase and clears prior final leaderboard state', async () => { it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => {
const fetchMock: FetchMock = vi let refreshCount = 0;
.fn() const fetchMock = createFetchRouteMock((input, init) => {
.mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } })) const url = String(input);
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }))); const method = init?.method ?? 'GET';
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/show') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
}
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/99/answers/mix') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'guess', current_round: 2 } });
}
if (method === 'POST' && url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'reveal', current_round: 2 } });
}
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
refreshCount += 1;
if (refreshCount === 1) {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
}
if (refreshCount === 2) {
return jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }));
}
return jsonResponse(200, sessionDetailPayload('reveal', { roundQuestionId: 77 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent(); const component = new HostShellComponent();
component.sessionCode = ' abcd12 '; component.sessionCode = ' abcd12 ';
component.roundQuestionId = ' 77 ';
component.session = sessionDetailPayload('lie', { roundQuestionId: null }) as any;
await component.showQuestion();
expect(component.session?.session.status).toBe('lie');
expect(component.roundQuestionId).toBe('99');
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
await component.mixAnswers();
expect(component.session?.session.status).toBe('guess');
await component.calculateScores();
expect(component.session?.session.status).toBe('reveal');
expect(component.error).toBe('');
expect(component.loading).toBe(false);
expect(fetchMock).toHaveBeenCalledTimes(6);
});
it('runs next-round transition without reload and clears scoreboard payload', async () => {
const fetchMock = createFetchRouteMock((input, init) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (method === 'POST' && url === '/lobby/sessions/ABCD12/rounds/next') {
return jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } });
}
if (method === 'GET' && url === '/lobby/sessions/ABCD12') {
return jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }));
}
throw new Error(`Unhandled fetch in test: ${method} ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = ' abcd12 ';
component.scoreboardPayload = '{"leaderboard":[]}';
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ; component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}' ;
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }]; component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
await component.startNextRound(); await component.startNextRound();
@@ -227,6 +296,7 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent(); const component = new HostShellComponent();
component.sessionCode = 'ABCD12'; component.sessionCode = 'ABCD12';
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
await component.finishGame(); await component.finishGame();
expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout'); expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout');
@@ -250,6 +320,7 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent(); const component = new HostShellComponent();
component.sessionCode = ' '; component.sessionCode = ' ';
component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any;
await component.startNextRound(); await component.startNextRound();
await component.finishGame(); await component.finishGame();
@@ -259,6 +330,47 @@ describe('HostShellComponent gameplay wiring', () => {
expect(component.finishError).toContain('Session code is required'); expect(component.finishError).toContain('Session code is required');
}); });
it('blocks illegal host actions outside canonical phase permissions', async () => {
const fetchMock: FetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
component.roundQuestionId = '77';
for (const status of ['guess', 'reveal', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.showQuestion();
}
for (const status of ['lie', 'reveal', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.calculateScores();
}
for (const status of ['lie', 'guess', 'scoreboard'] as const) {
component.session = sessionDetailPayload(status, { roundQuestionId: 77 }) as any;
await component.loadScoreboard();
await component.startNextRound();
await component.finishGame();
}
component.session = sessionDetailPayload('guess', { roundQuestionId: 77 }) as any;
expect(component.canShowQuestion).toBe(false);
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);
component.session = sessionDetailPayload('scoreboard', { roundQuestionId: 77 }) as any;
expect(component.canLoadScoreboard).toBe(false);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it('syncs host hash-route with latest phase after refresh without page reload', async () => { 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 }))); const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 })));
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
@@ -290,11 +402,18 @@ describe('HostShellComponent gameplay wiring', () => {
component.session = sessionDetailPayload('lie') as any; component.session = sessionDetailPayload('lie') as any;
expect(component.canStartRound).toBe(false); expect(component.canStartRound).toBe(false);
expect(component.canShowQuestion).toBe(true);
expect(component.canStartNextRound).toBe(false); expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false); expect(component.canFinishGame).toBe(false);
component.session = sessionDetailPayload('scoreboard') as any; component.session = sessionDetailPayload('reveal') as any;
expect(component.canLoadScoreboard).toBe(true);
expect(component.canStartNextRound).toBe(true); expect(component.canStartNextRound).toBe(true);
expect(component.canFinishGame).toBe(true); expect(component.canFinishGame).toBe(true);
component.session = sessionDetailPayload('scoreboard') as any;
expect(component.canLoadScoreboard).toBe(false);
expect(component.canStartNextRound).toBe(false);
expect(component.canFinishGame).toBe(false);
}); });
}); });

View File

@@ -3,7 +3,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client'; import { createApiClient } from '../../../../../src/api/client';
import type { FinishGameResponse, SessionDetailResponse } from '../../../../../src/api/types'; import type { FinishGameResponse, ScoreboardResponse, SessionDetailResponse } from '../../../../../src/api/types';
import { deriveGameplayPhase, isHostGameplayActionAllowed } from '../../../../../src/spa/gameplay-phase-machine';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
@@ -23,9 +24,16 @@ type LeaderboardResponse = FinishGameResponse;
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label> <label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>
<label *ngIf="canStartRound">{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label> <label *ngIf="canStartRound">{{ copy('host.category') }} <input [(ngModel)]="categorySlug" /></label>
<button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button> <button (click)="refreshSession()" [disabled]="loading">{{ copy('common.refresh') }}</button>
<button *ngIf="canStartRound" (click)="startRound()" [disabled]="loading">{{ copy('host.start_round') }}</button> <button (click)="startRound()" [disabled]="loading || !canStartRound">{{ copy('host.start_round') }}</button>
<button *ngIf="canStartNextRound || nextRoundError" (click)="startNextRound()" [disabled]="loading">{{ copy(nextRoundError ? 'host.retry_next_round' : 'host.start_next_round') }}</button> <button (click)="showQuestion()" [disabled]="loading || !canShowQuestion">{{ copy('host.show_question') }}</button>
<button *ngIf="canFinishGame || finishError" (click)="finishGame()" [disabled]="loading">{{ copy(finishError ? 'host.retry_finish' : 'host.finish_game') }}</button> <button (click)="mixAnswers()" [disabled]="loading || !canMixAnswers">{{ copy('host.mix_answers') }}</button>
<button (click)="calculateScores()" [disabled]="loading || !canCalculateScores">{{ 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> </div>
<p *ngIf="session" class="hint">{{ copy('host.audio_locale_hint') }}: {{ locale }}</p> <p *ngIf="session" class="hint">{{ copy('host.audio_locale_hint') }}: {{ locale }}</p>
@@ -82,8 +90,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
roundQuestionId = ''; roundQuestionId = '';
loading = false; loading = false;
error = ''; error = '';
scoreboardError = '';
nextRoundError = ''; nextRoundError = '';
finishError = ''; finishError = '';
scoreboardPayload = '';
finalLeaderboardPayload = ''; finalLeaderboardPayload = '';
finalLeaderboard: LeaderboardEntry[] = []; finalLeaderboard: LeaderboardEntry[] = [];
finalWinner: LeaderboardEntry | null = null; finalWinner: LeaderboardEntry | null = null;
@@ -121,16 +131,36 @@ export class HostShellComponent implements OnInit, OnDestroy {
this.unsubscribeLocale = null; this.unsubscribeLocale = null;
} }
get gameplayPhase(): string | null {
return deriveGameplayPhase(this.session as any);
}
get canStartRound(): boolean { get canStartRound(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_start_round ?? !this.session); return isHostGameplayActionAllowed(this.session as any, 'startRound');
}
get canShowQuestion(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'showQuestion');
}
get canMixAnswers(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'mixAnswers');
}
get canCalculateScores(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'calculateScores');
}
get canLoadScoreboard(): boolean {
return isHostGameplayActionAllowed(this.session as any, 'loadScoreboard');
} }
get canStartNextRound(): boolean { get canStartNextRound(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_start_next_round); return isHostGameplayActionAllowed(this.session as any, 'startNextRound');
} }
get canFinishGame(): boolean { get canFinishGame(): boolean {
return Boolean(this.session?.phase_view_model?.host?.can_finish_game); return isHostGameplayActionAllowed(this.session as any, 'finishGame');
} }
copy(key: string): string { copy(key: string): string {
@@ -169,6 +199,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
async refreshSession(): Promise<void> { async refreshSession(): Promise<void> {
this.loading = true; this.loading = true;
this.error = ''; this.error = '';
this.scoreboardError = '';
this.nextRoundError = ''; this.nextRoundError = '';
this.finishError = ''; this.finishError = '';
try { try {
@@ -192,6 +223,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
} }
async startRound(): Promise<void> { async startRound(): Promise<void> {
if (!this.canStartRound) {
return;
}
await this.runAction(async () => { await this.runAction(async () => {
const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim()); const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim());
if (!state.session || state.errorMessage) { if (!state.session || state.errorMessage) {
@@ -206,7 +241,69 @@ export class HostShellComponent implements OnInit, OnDestroy {
}); });
} }
async showQuestion(): Promise<void> {
if (!this.canShowQuestion) {
return;
}
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {});
await this.refreshSession();
});
}
async mixAnswers(): Promise<void> {
if (!this.canMixAnswers) {
return;
}
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
const roundQuestionId = this.roundQuestionId.trim();
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/answers/mix`, 'POST', {});
await this.refreshSession();
});
}
async calculateScores(): Promise<void> {
if (!this.canCalculateScores) {
return;
}
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
const roundQuestionId = this.roundQuestionId.trim();
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/scores/calculate`, 'POST', {});
await this.refreshSession();
});
}
async loadScoreboard(): Promise<void> {
if (!this.canLoadScoreboard) {
return;
}
this.loading = true;
this.scoreboardError = '';
this.error = '';
try {
const code = this.normalizeCode(this.sessionCode);
const payload = await this.request<ScoreboardResponse>(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET');
this.scoreboardPayload = JSON.stringify(payload, null, 2);
await this.refreshSession();
} catch (error) {
this.scoreboardError = `${this.copy('host.scoreboard_failed')}: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
async startNextRound(): Promise<void> { async startNextRound(): Promise<void> {
if (!this.canStartNextRound) {
return;
}
this.loading = true; this.loading = true;
this.nextRoundError = ''; this.nextRoundError = '';
this.error = ''; this.error = '';
@@ -226,6 +323,10 @@ export class HostShellComponent implements OnInit, OnDestroy {
} }
async finishGame(): Promise<void> { async finishGame(): Promise<void> {
if (!this.canFinishGame) {
return;
}
this.loading = true; this.loading = true;
this.finishError = ''; this.finishError = '';
this.error = ''; this.error = '';
@@ -252,6 +353,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
} }
private resetFinalLeaderboard(): void { private resetFinalLeaderboard(): void {
this.scoreboardPayload = '';
this.finalLeaderboardPayload = ''; this.finalLeaderboardPayload = '';
this.finalLeaderboard = []; this.finalLeaderboard = [];
this.finalWinner = null; this.finalWinner = null;

View File

@@ -147,9 +147,8 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.sessionToken = 'token-1'; component.sessionToken = 'token-1';
component.lieText = 'my lie'; component.lieText = 'my lie';
component.session = { component.session = {
session: { code: 'ABCD12', status: 'lie', current_round: 1 }, ...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [] }, round_question: { id: 11, prompt: 'Q?', answers: [] },
players: [],
}; };
await component.submitLie(); await component.submitLie();
@@ -268,9 +267,8 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.sessionToken = 'token-1'; component.sessionToken = 'token-1';
component.selectedGuess = 'B'; component.selectedGuess = 'B';
component.session = { component.session = {
session: { code: 'ABCD12', status: 'guess', current_round: 1 }, ...(sessionDetailPayload('guess', { answers: ['A', 'B'], roundQuestionId: 11 }) as any),
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
players: [],
}; };
await component.submitGuess(); await component.submitGuess();
@@ -294,6 +292,29 @@ describe('PlayerShellComponent gameplay wiring', () => {
expect(fetchMock).toHaveBeenCalledTimes(3); 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 () => { it('auto-refreshes player session to avoid host/player state desync between rounds', async () => {
vi.useFakeTimers(); vi.useFakeTimers();

View File

@@ -4,6 +4,10 @@ import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client'; import { createApiClient } from '../../../../../src/api/client';
import type { SessionDetailResponse } from '../../../../../src/api/types'; import type { SessionDetailResponse } from '../../../../../src/api/types';
import {
deriveGameplayPhase,
isPlayerGameplayActionAllowed,
} from '../../../../../src/spa/gameplay-phase-machine';
import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
@@ -69,9 +73,9 @@ function resolveLocalStorage(): Storage | undefined {
<p *ngIf="session.round_question"><strong>{{ copy('common.prompt') }}:</strong> {{ session.round_question.prompt }}</p> <p *ngIf="session.round_question"><strong>{{ copy('common.prompt') }}:</strong> {{ session.round_question.prompt }}</p>
<ng-container *ngIf="showLieControls"> <ng-container *ngIf="showLieControls">
<label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading" /></label> <label>{{ copy('player.lie_label') }} <input [(ngModel)]="lieText" [disabled]="loading || !canSubmitLie" /></label>
<button (click)="submitLie()" [disabled]="loading">{{ copy('player.submit_lie') }}</button> <button (click)="submitLie()" [disabled]="loading || !canSubmitLie">{{ copy('player.submit_lie') }}</button>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading">{{ copy('player.retry_lie_submit') }}</button> <button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading || !canSubmitLie">{{ copy('player.retry_lie_submit') }}</button>
</ng-container> </ng-container>
<ng-container *ngIf="showGuessControls"> <ng-container *ngIf="showGuessControls">
@@ -81,14 +85,14 @@ function resolveLocalStorage(): Storage | undefined {
*ngFor="let answer of session.round_question?.answers" *ngFor="let answer of session.round_question?.answers"
(click)="selectedGuess = answer.text" (click)="selectedGuess = answer.text"
[class.active]="selectedGuess === answer.text" [class.active]="selectedGuess === answer.text"
[disabled]="loading" [disabled]="loading || !canSubmitGuess"
> >
{{ answer.text }} {{ answer.text }}
</button> </button>
</div> </div>
<button (click)="submitGuess()" [disabled]="loading || !selectedGuess">{{ copy('player.submit_guess') }}</button> <button (click)="submitGuess()" [disabled]="loading || !canSubmitGuess || !selectedGuess">{{ copy('player.submit_guess') }}</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button> <button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading || !canSubmitGuess">{{ copy('player.retry_guess_submit') }}</button>
</ng-container> </ng-container>
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')"> <div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
@@ -205,6 +209,18 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.restoreAudioGuard = null; 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 => { private readonly handleOnline = (): void => {
this.connectionState = 'reconnecting'; this.connectionState = 'reconnecting';
void this.retryReconnect(); void this.retryReconnect();
@@ -543,7 +559,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
} }
async submitLie(): Promise<void> { async submitLie(): Promise<void> {
if (!this.session?.round_question?.id) { if (!this.session?.round_question?.id || !this.canSubmitLie) {
return; return;
} }
this.loading = true; this.loading = true;
@@ -571,7 +587,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
} }
async submitGuess(): Promise<void> { async submitGuess(): Promise<void> {
if (!this.session?.round_question?.id || !this.selectedGuess) { if (!this.session?.round_question?.id || !this.selectedGuess || !this.canSubmitGuess) {
return; return;
} }
this.loading = true; this.loading = true;

View File

@@ -7,12 +7,125 @@
"": { "": {
"name": "wpp-frontend-api-client-baseline", "name": "wpp-frontend-api-client-baseline",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vitest": "^2.1.9" "vitest": "^2.1.9"
} }
}, },
"node_modules/@angular/common": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.20.tgz",
"integrity": "sha512-1M3W3FjUUbVKXDMs+yQpBhnkD/pCe0Jn79rPE5W+EGWWxFoLSyGX+fhnRO5m4c9k66p3nvYrikWQ0ZzMv3M5tw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/core": "19.2.20",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/compiler": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.20.tgz",
"integrity": "sha512-LvjE8W58EACgTFaAoqmNe7FRsbvoQ0GvCB/rmm6AEMWx/0W/JBvWkQTrOQlwpoeYOHcMZRGdmPcZoUDwU3JySQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
}
},
"node_modules/@angular/core": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.20.tgz",
"integrity": "sha512-pxzQh8ouqfE57lJlXjIzXFuRETwkfMVwS+NFCfv2yh01Qtx+vymO8ZClcJMgLPfBYinhBYX+hrRYVSa1nzlkRQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0"
}
},
"node_modules/@angular/forms": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.20.tgz",
"integrity": "sha512-agi7InbMzop1jrud6L7SlNwnZk3iNolORcFIwBQMvKxLkcJ+ttbSYuM0KAw56IundWHf4dL9GP4cSygm4kUeFA==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/common": "19.2.20",
"@angular/core": "19.2.20",
"@angular/platform-browser": "19.2.20",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/platform-browser": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.20.tgz",
"integrity": "sha512-O9ZoQKILPC1T2c64OASS75XlOLBxY81m5AAgsBKhwiFWq+V28RsO0cnwpi1YSh/z4ryH8Fe7IUFz8jGrsJi3hQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/animations": "19.2.20",
"@angular/common": "19.2.20",
"@angular/core": "19.2.20"
},
"peerDependenciesMeta": {
"@angular/animations": {
"optional": true
}
}
},
"node_modules/@angular/router": {
"version": "19.2.20",
"resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.20.tgz",
"integrity": "sha512-y0fyKycxJHr82kxXKE50Vac5hPn5Kx3gw9CfqyEuwJ9VQzEixDljU+chrQK4Wods14jJn9Tt2ncNPGH1rLya3Q==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
},
"peerDependencies": {
"@angular/common": "19.2.20",
"@angular/core": "19.2.20",
"@angular/platform-browser": "19.2.20",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -1188,6 +1301,15 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/siginfo": { "node_modules/siginfo": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@@ -1263,6 +1385,12 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1449,6 +1577,12 @@
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
},
"node_modules/zone.js": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
"license": "MIT"
} }
} }
} }

View File

@@ -7,6 +7,17 @@
"test": "vitest run", "test": "vitest run",
"build": "tsc --noEmit" "build": "tsc --noEmit"
}, },
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.10", "@types/node": "^22.13.10",
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@@ -1,6 +1,15 @@
import type { SessionDetailResponse } from '../api/types'; import type { PhaseViewModel, SessionDetailResponse } from '../api/types';
export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard'; export type GameplayPhase = 'lie' | 'guess' | 'reveal' | 'scoreboard';
export type HostGameplayAction =
| 'startRound'
| 'showQuestion'
| 'mixAnswers'
| 'calculateScores'
| 'loadScoreboard'
| 'startNextRound'
| 'finishGame';
export type PlayerGameplayAction = 'join' | 'submitLie' | 'submitGuess' | 'viewFinalResult';
export type GameplayPhaseEvent = export type GameplayPhaseEvent =
| 'LIES_LOCKED' | 'LIES_LOCKED'
@@ -40,8 +49,7 @@ export function allowedGameplayEvents(phase: GameplayPhase): GameplayPhaseEvent[
return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[]; return Object.keys(TRANSITIONS[phase]) as GameplayPhaseEvent[];
} }
export function deriveGameplayPhase(session: SessionDetailResponse | null): GameplayPhase | null { function derivePhaseFromStatus(status: string | null | undefined): GameplayPhase | null {
const status = session?.session.status;
if (!status) { if (!status) {
return null; return null;
} }
@@ -56,3 +64,59 @@ export function deriveGameplayPhase(session: SessionDetailResponse | null): Game
return null; 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 action === 'startRound';
}
const host = session.phase_view_model?.host;
switch (action) {
case 'startRound':
return Boolean(host?.can_start_round ?? false);
case 'showQuestion':
return Boolean(host?.can_show_question ?? false);
case 'mixAnswers':
return Boolean(host?.can_mix_answers ?? false);
case 'calculateScores':
return Boolean(host?.can_calculate_scores ?? false);
case 'loadScoreboard':
return Boolean(host?.can_reveal_scoreboard ?? false);
case 'startNextRound':
return Boolean(host?.can_start_next_round ?? false);
case 'finishGame':
return Boolean(host?.can_finish_game ?? false);
}
}
export function isPlayerGameplayActionAllowed(session: SessionDetailResponse | null, action: PlayerGameplayAction): boolean {
if (!session) {
return action === 'join';
}
const player = session.phase_view_model?.player;
switch (action) {
case 'join':
return Boolean(player?.can_join ?? false);
case 'submitLie':
return Boolean(player?.can_submit_lie ?? false);
case 'submitGuess':
return Boolean(player?.can_submit_guess ?? false);
case 'viewFinalResult':
return Boolean(player?.can_view_final_result ?? false);
}
}

View File

@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import { import {
allowedGameplayEvents, allowedGameplayEvents,
deriveGameplayPhase, deriveGameplayPhase,
isHostGameplayActionAllowed,
isPlayerGameplayActionAllowed,
transitionGameplayPhase, transitionGameplayPhase,
type GameplayPhase type GameplayPhase
} from '../src/spa/gameplay-phase-machine'; } from '../src/spa/gameplay-phase-machine';
@@ -105,4 +107,44 @@ describe('gameplay phase machine skeleton', () => {
}) })
).toBe('scoreboard'); ).toBe('scoreboard');
}); });
it('gates host and player actions from canonical phase_view_model permissions', () => {
const session = {
session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: { id: 77, prompt: 'Q?', answers: [] },
phase_view_model: {
status: 'reveal',
round_number: 1,
players_count: 3,
constraints: {
min_players_to_start: 3,
max_players_mvp: 5,
min_players_reached: true,
max_players_allowed: true
},
host: {
can_start_round: false,
can_show_question: false,
can_mix_answers: false,
can_calculate_scores: false,
can_reveal_scoreboard: true,
can_start_next_round: true,
can_finish_game: true
},
player: {
can_join: false,
can_submit_lie: false,
can_submit_guess: false,
can_view_final_result: false
}
}
} as const;
expect(deriveGameplayPhase(session as any)).toBe('reveal');
expect(isHostGameplayActionAllowed(session as any, 'loadScoreboard')).toBe(true);
expect(isHostGameplayActionAllowed(session as any, 'startNextRound')).toBe(true);
expect(isHostGameplayActionAllowed(session as any, 'finishGame')).toBe(true);
expect(isPlayerGameplayActionAllowed(session as any, 'submitGuess')).toBe(false);
});
}); });

View File

@@ -2,7 +2,8 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({ export default defineConfig({
test: { test: {
include: ['tests/**/*.test.ts'], include: ['tests/**/*.test.ts', 'angular/src/**/*.spec.ts'],
setupFiles: ['angular/src/test-setup.ts'],
exclude: ['**/node_modules/**'] exclude: ['**/node_modules/**']
} }
}); });