diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts index 8fe8674..d57ff4d 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts @@ -4,6 +4,8 @@ import { HostShellComponent } from './host-shell.component'; type FetchMock = ReturnType; +type FetchRouteHandler = (input: RequestInfo | URL, init?: RequestInit) => Response | Promise; + function jsonResponse(status: number, body: unknown) { return { ok: status >= 200 && status < 300, @@ -12,6 +14,10 @@ function jsonResponse(status: number, body: unknown) { } as unknown as Response; } +function createFetchRouteMock(handler: FetchRouteHandler): FetchMock { + return vi.fn((input: RequestInfo | URL, init?: RequestInit) => Promise.resolve(handler(input, init))); +} + function sessionDetailPayload( status: string, options?: { @@ -85,12 +91,12 @@ function sessionDetailPayload( }, host: { can_start_round: status === 'lobby', - can_show_question: false, - can_mix_answers: false, - can_calculate_scores: false, - can_reveal_scoreboard: false, - can_start_next_round: status === 'scoreboard', - can_finish_game: status === 'scoreboard', + can_show_question: status === 'lie', + can_mix_answers: status === 'lie' || status === 'guess', + can_calculate_scores: status === 'guess', + can_reveal_scoreboard: status === 'reveal', + can_start_next_round: status === 'reveal', + can_finish_game: status === 'reveal', }, player: { 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 () => { - const fetchMock: FetchMock = vi - .fn() - .mockResolvedValueOnce(jsonResponse(200, { session: { code: 'ABCD12', status: 'lie', current_round: 2 } })) - .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lie', { roundQuestionId: 99 }))); + it('wires showQuestion, mixAnswers and calculateScores with canonical phase gating', async () => { + let refreshCount = 0; + const fetchMock = createFetchRouteMock((input, init) => { + const url = String(input); + 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); const component = new HostShellComponent(); 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.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }]; + component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any; await component.startNextRound(); @@ -227,6 +296,7 @@ describe('HostShellComponent gameplay wiring', () => { const component = new HostShellComponent(); component.sessionCode = 'ABCD12'; + component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any; await component.finishGame(); expect(component.finishError).toContain('Finish game failed: Final leaderboard timeout'); @@ -250,6 +320,7 @@ describe('HostShellComponent gameplay wiring', () => { const component = new HostShellComponent(); component.sessionCode = ' '; + component.session = sessionDetailPayload('reveal', { roundQuestionId: 77 }) as any; await component.startNextRound(); await component.finishGame(); @@ -259,6 +330,47 @@ describe('HostShellComponent gameplay wiring', () => { 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 () => { const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(200, sessionDetailPayload('guess', { roundQuestionId: 77 }))); vi.stubGlobal('fetch', fetchMock); @@ -290,11 +402,18 @@ describe('HostShellComponent gameplay wiring', () => { component.session = sessionDetailPayload('lie') as any; expect(component.canStartRound).toBe(false); + expect(component.canShowQuestion).toBe(true); expect(component.canStartNextRound).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.canFinishGame).toBe(true); + + component.session = sessionDetailPayload('scoreboard') as any; + expect(component.canLoadScoreboard).toBe(false); + expect(component.canStartNextRound).toBe(false); + expect(component.canFinishGame).toBe(false); }); }); diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts index 83d9165..9eb219a 100644 --- a/frontend/angular/src/app/features/host/host-shell.component.ts +++ b/frontend/angular/src/app/features/host/host-shell.component.ts @@ -3,7 +3,8 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; 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 { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; @@ -23,9 +24,16 @@ type LeaderboardResponse = FinishGameResponse; - - - + + + + + + + + + +

{{ copy('host.audio_locale_hint') }}: {{ locale }}

@@ -82,8 +90,10 @@ export class HostShellComponent implements OnInit, OnDestroy { roundQuestionId = ''; loading = false; error = ''; + scoreboardError = ''; nextRoundError = ''; finishError = ''; + scoreboardPayload = ''; finalLeaderboardPayload = ''; finalLeaderboard: LeaderboardEntry[] = []; finalWinner: LeaderboardEntry | null = null; @@ -121,16 +131,36 @@ export class HostShellComponent implements OnInit, OnDestroy { this.unsubscribeLocale = null; } + get gameplayPhase(): string | null { + return deriveGameplayPhase(this.session as any); + } + 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 { - return Boolean(this.session?.phase_view_model?.host?.can_start_next_round); + return isHostGameplayActionAllowed(this.session as any, 'startNextRound'); } 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 { @@ -169,6 +199,7 @@ export class HostShellComponent implements OnInit, OnDestroy { async refreshSession(): Promise { this.loading = true; this.error = ''; + this.scoreboardError = ''; this.nextRoundError = ''; this.finishError = ''; try { @@ -192,6 +223,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async startRound(): Promise { + if (!this.canStartRound) { + return; + } + await this.runAction(async () => { const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim()); if (!state.session || state.errorMessage) { @@ -206,7 +241,69 @@ export class HostShellComponent implements OnInit, OnDestroy { }); } + async showQuestion(): Promise { + 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 { + 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 { + 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 { + if (!this.canLoadScoreboard) { + return; + } + + this.loading = true; + this.scoreboardError = ''; + this.error = ''; + try { + const code = this.normalizeCode(this.sessionCode); + const payload = await this.request(`/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 { + if (!this.canStartNextRound) { + return; + } + this.loading = true; this.nextRoundError = ''; this.error = ''; @@ -226,6 +323,10 @@ export class HostShellComponent implements OnInit, OnDestroy { } async finishGame(): Promise { + if (!this.canFinishGame) { + return; + } + this.loading = true; this.finishError = ''; this.error = ''; @@ -252,6 +353,7 @@ export class HostShellComponent implements OnInit, OnDestroy { } private resetFinalLeaderboard(): void { + this.scoreboardPayload = ''; this.finalLeaderboardPayload = ''; this.finalLeaderboard = []; this.finalWinner = null; diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index 94d7aeb..389d682 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -147,9 +147,8 @@ describe('PlayerShellComponent gameplay wiring', () => { component.sessionToken = 'token-1'; component.lieText = 'my lie'; component.session = { - session: { code: 'ABCD12', status: 'lie', current_round: 1 }, + ...(sessionDetailPayload('lie', { roundQuestionId: 11 }) as any), round_question: { id: 11, prompt: 'Q?', answers: [] }, - players: [], }; await component.submitLie(); @@ -268,9 +267,8 @@ describe('PlayerShellComponent gameplay wiring', () => { component.sessionToken = 'token-1'; component.selectedGuess = 'B'; 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' }] }, - players: [], }; await component.submitGuess(); @@ -294,6 +292,29 @@ describe('PlayerShellComponent gameplay wiring', () => { expect(fetchMock).toHaveBeenCalledTimes(3); }); + it('blocks illegal player guess submission outside canonical guess phase', async () => { + const fetchMock: FetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const component = new PlayerShellComponent(); + component.sessionCode = 'ABCD12'; + component.playerId = 9; + component.sessionToken = 'token-1'; + component.selectedGuess = 'B'; + + for (const status of ['lie', 'reveal', 'scoreboard'] as const) { + component.session = { + ...(sessionDetailPayload(status, { answers: ['A', 'B'] }) as any), + round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, + }; + + await component.submitGuess(); + } + + expect(component.canSubmitGuess).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('auto-refreshes player session to avoid host/player state desync between rounds', async () => { vi.useFakeTimers(); diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index bd33a58..2e4e59f 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -4,6 +4,10 @@ import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; 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 { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n'; @@ -69,9 +73,9 @@ function resolveLocalStorage(): Storage | undefined {

{{ copy('common.prompt') }}: {{ session.round_question.prompt }}

- - - + + + @@ -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" + [disabled]="loading || !canSubmitGuess" > {{ answer.text }} - - + +
@@ -205,6 +209,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(); @@ -543,7 +559,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } async submitLie(): Promise { - if (!this.session?.round_question?.id) { + if (!this.session?.round_question?.id || !this.canSubmitLie) { return; } this.loading = true; @@ -571,7 +587,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } async submitGuess(): Promise { - if (!this.session?.round_question?.id || !this.selectedGuess) { + if (!this.session?.round_question?.id || !this.selectedGuess || !this.canSubmitGuess) { return; } this.loading = true; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7cecda4..80e5174 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,12 +7,125 @@ "": { "name": "wpp-frontend-api-client-baseline", "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": { "@types/node": "^22.13.10", "typescript": "^5.7.3", "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": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -1188,6 +1301,15 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -1263,6 +1385,12 @@ "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": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1449,6 +1577,12 @@ "engines": { "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" } } } diff --git a/frontend/package.json b/frontend/package.json index 772a3c5..ba1dfc7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,17 @@ "test": "vitest run", "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": { "@types/node": "^22.13.10", "typescript": "^5.7.3", diff --git a/frontend/src/spa/gameplay-phase-machine.ts b/frontend/src/spa/gameplay-phase-machine.ts index f0ba252..17d57db 100644 --- a/frontend/src/spa/gameplay-phase-machine.ts +++ b/frontend/src/spa/gameplay-phase-machine.ts @@ -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 HostGameplayAction = + | 'startRound' + | 'showQuestion' + | 'mixAnswers' + | 'calculateScores' + | 'loadScoreboard' + | 'startNextRound' + | 'finishGame'; +export type PlayerGameplayAction = 'join' | 'submitLie' | 'submitGuess' | 'viewFinalResult'; export type GameplayPhaseEvent = | 'LIES_LOCKED' @@ -40,8 +49,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 +64,59 @@ 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 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); + } +} diff --git a/frontend/tests/gameplay-phase-machine.test.ts b/frontend/tests/gameplay-phase-machine.test.ts index 420d822..f67f26d 100644 --- a/frontend/tests/gameplay-phase-machine.test.ts +++ b/frontend/tests/gameplay-phase-machine.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { allowedGameplayEvents, deriveGameplayPhase, + isHostGameplayActionAllowed, + isPlayerGameplayActionAllowed, transitionGameplayPhase, type GameplayPhase } from '../src/spa/gameplay-phase-machine'; @@ -105,4 +107,44 @@ describe('gameplay phase machine skeleton', () => { }) ).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); + }); }); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 353adbd..1ca74b0 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -2,7 +2,8 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['tests/**/*.test.ts'], + include: ['tests/**/*.test.ts', 'angular/src/**/*.spec.ts'], + setupFiles: ['angular/src/test-setup.ts'], exclude: ['**/node_modules/**'] } });