diff --git a/docs/issue-180-flow-log.md b/docs/issue-180-flow-log.md new file mode 100644 index 0000000..fae3376 --- /dev/null +++ b/docs/issue-180-flow-log.md @@ -0,0 +1,28 @@ +# Issue #180 – SPA gameplay flow evidence + +## Flow log (host + player, no page reload) + +1. Host opens SPA host shell and loads scoreboard (`GET /lobby/sessions/{code}/scoreboard`). +2. Host starts next round (`POST /lobby/sessions/{code}/rounds/next`). +3. Host shell refreshes session state in-place (`GET /lobby/sessions/{code}`) and clears old scoreboard/final leaderboard payloads. +4. Player shell performs periodic session refresh while online (3s cadence) and transitions from `scoreboard` to `lobby` without page reload. +5. Host finishes game (`POST /lobby/sessions/{code}/finish`) and renders final leaderboard directly in SPA shell. +6. Player shell reads `finished` state and renders final leaderboard in SPA (sorted by score). +7. Error/retry paths available: + - Host: next-round and finish-game retry buttons with explicit error feedback. + - Player: reconnect + submit retry feedback. + +## Test output snapshot + +Command: + +```bash +cd frontend/angular +npm test -- --run src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts +``` + +Result: + +- `host-shell.component.spec.ts`: 6 passed +- `player-shell.component.spec.ts`: 7 passed +- Total: 13 passed, 0 failed 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 363a878..ff3e0c8 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 @@ -198,6 +198,30 @@ describe('PlayerShellComponent gameplay wiring', () => { expect(fetchMock).toHaveBeenCalledTimes(3); }); + it('auto-refreshes player session to avoid host/player state desync between rounds', async () => { + vi.useFakeTimers(); + + const fetchMock: FetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: null }))) + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null }))); + + vi.stubGlobal('fetch', fetchMock); + + const component = new PlayerShellComponent(); + component.sessionCode = 'ABCD12'; + + await component.refreshSession(); + expect(component.session?.session.status).toBe('scoreboard'); + + await vi.advanceTimersByTimeAsync(3100); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(component.session?.session.status).toBe('lobby'); + + component.ngOnDestroy(); + }); + it('enters reconnecting state when network request fails while online', async () => { vi.stubGlobal('navigator', { onLine: true }); 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 d1f2b85..c2338db 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -13,6 +13,7 @@ interface SessionDetail { } type ConnectionState = 'online' | 'reconnecting' | 'offline'; +type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null; @Component({ selector: 'app-player-shell', @@ -31,12 +32,16 @@ type ConnectionState = 'online' | 'reconnecting' | 'offline';

Reconnecting… trying to refresh session state. +

You are offline. Reconnect to continue gameplay. +

+

{{ loadingMessage }}

+

Status: {{ session.session.status }}

Prompt: {{ session.round_question.prompt }}

@@ -70,6 +75,11 @@ type ConnectionState = 'online' | 'reconnecting' | 'offline';

{{ error }}

{{ submitError.message }}

+ +
+ + +
`, }) export class PlayerShellComponent implements OnInit, OnDestroy { @@ -85,10 +95,12 @@ export class PlayerShellComponent implements OnInit, OnDestroy { session: SessionDetail | null = null; finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = []; connectionState: ConnectionState = 'online'; + loadingTransition: LoadingTransition = null; private readonly sessionContextStore = createSessionContextStore(); private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); private reconnectTimer: ReturnType | null = null; + private stateSyncTimer: ReturnType | null = null; constructor() { if (typeof navigator !== 'undefined' && !navigator.onLine) { @@ -127,6 +139,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { window.removeEventListener('offline', this.handleOffline); } this.clearReconnectTimer(); + this.clearStateSyncTimer(); } private readonly handleOnline = (): void => { @@ -137,6 +150,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { private readonly handleOffline = (): void => { this.connectionState = 'offline'; this.clearReconnectTimer(); + this.clearStateSyncTimer(); }; private clearReconnectTimer(): void { @@ -146,6 +160,48 @@ export class PlayerShellComponent implements OnInit, OnDestroy { } } + private clearStateSyncTimer(): void { + if (this.stateSyncTimer) { + clearTimeout(this.stateSyncTimer); + this.stateSyncTimer = null; + } + } + + private scheduleStateSync(): void { + this.clearStateSyncTimer(); + + if (!this.sessionCode.trim() || this.connectionState !== 'online' || !this.session) { + return; + } + + if (this.session.session.status === 'finished') { + return; + } + + this.stateSyncTimer = setTimeout(() => { + this.stateSyncTimer = null; + if (this.loading || this.connectionState !== 'online') { + this.scheduleStateSync(); + return; + } + void this.refreshSession(); + }, 3000); + } + + get loadingMessage(): string { + switch (this.loadingTransition) { + case 'join': + return 'Joining session… restoring your player state.'; + case 'submit-lie': + return 'Submitting lie… waiting for guess phase.'; + case 'submit-guess': + return 'Submitting guess… waiting for reveal.'; + case 'refresh': + default: + return 'Loading latest session state…'; + } + } + private normalizeCode(value: string): string { return value.trim().toUpperCase(); } @@ -160,9 +216,12 @@ export class PlayerShellComponent implements OnInit, OnDestroy { private markOnline(): void { this.connectionState = 'online'; this.clearReconnectTimer(); + this.scheduleStateSync(); } private markConnectionIssue(error: unknown): void { + this.clearStateSyncTimer(); + if (typeof navigator !== 'undefined' && !navigator.onLine) { this.connectionState = 'offline'; return; @@ -200,6 +259,21 @@ export class PlayerShellComponent implements OnInit, OnDestroy { await this.refreshSession(); } + returnToJoin(): void { + this.loadingTransition = null; + this.clearReconnectTimer(); + this.connectionState = typeof navigator !== 'undefined' && !navigator.onLine ? 'offline' : 'online'; + this.session = null; + this.finalLeaderboard = []; + this.selectedGuess = ''; + this.lieText = ''; + this.submitError = null; + this.error = ''; + this.playerId = 0; + this.sessionToken = ''; + this.sessionContextStore.clear(); + } + private syncFinalLeaderboard(): void { if (!this.session || this.session.session.status !== 'finished') { this.finalLeaderboard = []; @@ -235,6 +309,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { async refreshSession(): Promise { this.loading = true; + this.loadingTransition = 'refresh'; this.error = ''; try { const state = await this.controller.hydrateLobby(this.sessionCode); @@ -253,11 +328,13 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.markConnectionIssue(error); } finally { this.loading = false; + this.loadingTransition = null; } } async joinSession(): Promise { this.loading = true; + this.loadingTransition = 'join'; this.error = ''; try { const state = await this.controller.joinLobby(this.sessionCode, this.nickname); @@ -280,6 +357,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy { this.markConnectionIssue(error); } finally { this.loading = false; + this.loadingTransition = null; } }