fix(gameplay): gate client actions from canonical phase state (#301) #303
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
134
frontend/package-lock.json
generated
134
frontend/package-lock.json
generated
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/**']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user