feat: gate client actions by canonical phase state
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user