From f3ea19fcd7399bac387983a70434fbe92938ca1e Mon Sep 17 00:00:00 2001 From: Asger Geel Weirsoee Date: Sun, 1 Mar 2026 16:00:53 +0000 Subject: [PATCH] feat(player): add reconnect/offline states in angular gameplay flow --- .../player/player-shell.component.spec.ts | 128 ++++++++++++---- .../features/player/player-shell.component.ts | 139 +++++++++++++++++- 2 files changed, 234 insertions(+), 33 deletions(-) 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 ba0647c..708b5b9 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 @@ -12,18 +12,70 @@ function jsonResponse(status: number, body: unknown) { } as unknown as Response; } +function sessionDetailPayload(status: string, options?: { answers?: string[]; players?: Array<{ id: number; nickname: string; score: number }>; roundQuestionId?: number | null }) { + const answers = options?.answers ?? []; + const roundQuestionId = options?.roundQuestionId ?? 11; + + return { + session: { + code: 'ABCD12', + status, + host_id: null, + current_round: 1, + players_count: (options?.players ?? []).length, + }, + round_question: + roundQuestionId === null + ? null + : { + id: roundQuestionId, + round_number: 1, + prompt: 'Q?', + shown_at: '2026-01-01T00:00:00Z', + answers: answers.map((text) => ({ text })), + }, + players: (options?.players ?? []).map((player) => ({ + ...player, + is_connected: true, + })), + phase_view_model: { + status, + round_number: 1, + players_count: (options?.players ?? []).length, + constraints: { + min_players_to_start: 2, + max_players_mvp: 8, + 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: false, + can_start_next_round: false, + can_finish_game: false, + }, + player: { + can_join: status === 'lobby', + can_submit_lie: status === 'lie', + can_submit_guess: status === 'guess', + can_view_final_result: status === 'finished', + }, + }, + }; +} + describe('PlayerShellComponent gameplay wiring', () => { afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); it('clears selected guess when refreshed status is no longer guess', async () => { const fetchMock: FetchMock = vi.fn().mockResolvedValue( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, - round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }] }, - players: [], - }) + jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A'] })) ); vi.stubGlobal('fetch', fetchMock); @@ -46,13 +98,7 @@ describe('PlayerShellComponent gameplay wiring', () => { .fn() .mockResolvedValueOnce(jsonResponse(500, { error: 'Temporary submit outage' })) .mockResolvedValueOnce(jsonResponse(200, { ok: true })) - .mockResolvedValueOnce( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'guess', current_round: 1 }, - round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, - players: [], - }) - ); + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('guess', { answers: ['A', 'B'] }))); vi.stubGlobal('fetch', fetchMock); @@ -64,6 +110,7 @@ describe('PlayerShellComponent gameplay wiring', () => { component.session = { session: { code: 'ABCD12', status: 'lie', current_round: 1 }, round_question: { id: 11, prompt: 'Q?', answers: [] }, + players: [], }; await component.submitLie(); @@ -88,14 +135,16 @@ describe('PlayerShellComponent gameplay wiring', () => { it('builds final leaderboard in finished status without legacy page hop', async () => { const fetchMock: FetchMock = vi.fn().mockResolvedValue( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'finished', current_round: 2 }, - round_question: null, - players: [ - { id: 2, nickname: 'Mads', score: 150 }, - { id: 1, nickname: 'Luna', score: 320 }, - ], - }) + jsonResponse( + 200, + sessionDetailPayload('finished', { + roundQuestionId: null, + players: [ + { id: 2, nickname: 'Mads', score: 150 }, + { id: 1, nickname: 'Luna', score: 320 }, + ], + }) + ) ); vi.stubGlobal('fetch', fetchMock); @@ -113,13 +162,7 @@ describe('PlayerShellComponent gameplay wiring', () => { .fn() .mockResolvedValueOnce(jsonResponse(503, { error: 'Guess queue busy' })) .mockResolvedValueOnce(jsonResponse(200, { ok: true })) - .mockResolvedValueOnce( - jsonResponse(200, { - session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, - round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, - players: [], - }) - ); + .mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('reveal', { answers: ['A', 'B'] }))); vi.stubGlobal('fetch', fetchMock); @@ -131,6 +174,7 @@ describe('PlayerShellComponent gameplay wiring', () => { component.session = { session: { code: 'ABCD12', status: 'guess', current_round: 1 }, round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] }, + players: [], }; await component.submitGuess(); @@ -153,4 +197,34 @@ describe('PlayerShellComponent gameplay wiring', () => { expect(component.selectedGuess).toBe(''); expect(fetchMock).toHaveBeenCalledTimes(3); }); + + it('enters reconnecting state when network request fails while online', async () => { + vi.stubGlobal('navigator', { onLine: true }); + + const fetchMock: FetchMock = vi.fn().mockRejectedValueOnce(new TypeError('Failed to fetch')); + vi.stubGlobal('fetch', fetchMock); + + const component = new PlayerShellComponent(); + component.sessionCode = 'ABCD12'; + + await component.refreshSession(); + + expect(component.connectionState).toBe('reconnecting'); + expect(component.error).toContain('Session refresh failed: Could not load lobby status.'); + }); + + it('uses offline state when browser reports disconnected network', async () => { + vi.stubGlobal('navigator', { onLine: false }); + + const fetchMock: FetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); + vi.stubGlobal('fetch', fetchMock); + + const component = new PlayerShellComponent(); + component.sessionCode = 'ABCD12'; + + await component.refreshSession(); + + expect(component.connectionState).toBe('offline'); + expect(component.error).toContain('Session refresh failed'); + }); }); 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 5e09248..d1f2b85 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; @@ -12,6 +12,8 @@ interface SessionDetail { players: Array<{ id: number; nickname: string; score: number }>; } +type ConnectionState = 'online' | 'reconnecting' | 'offline'; + @Component({ selector: 'app-player-shell', standalone: true, @@ -26,6 +28,15 @@ interface SessionDetail { +

+ Reconnecting… trying to refresh session state. + +

+

+ You are offline. Reconnect to continue gameplay. + +

+

Status: {{ session.session.status }}

Prompt: {{ session.round_question.prompt }}

@@ -61,7 +72,7 @@ interface SessionDetail {

{{ submitError.message }}

`, }) -export class PlayerShellComponent { +export class PlayerShellComponent implements OnInit, OnDestroy { sessionCode = ''; nickname = ''; playerId = 0; @@ -73,14 +84,122 @@ export class PlayerShellComponent { submitError: { kind: 'lie' | 'guess'; message: string } | null = null; session: SessionDetail | null = null; finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = []; + connectionState: ConnectionState = 'online'; private readonly sessionContextStore = createSessionContextStore(); private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); + private reconnectTimer: ReturnType | null = null; + + constructor() { + if (typeof navigator !== 'undefined' && !navigator.onLine) { + this.connectionState = 'offline'; + } + + if (typeof window !== 'undefined') { + window.addEventListener('online', this.handleOnline); + window.addEventListener('offline', this.handleOffline); + } + } + + ngOnInit(): void { + const hashRoute = window.location.hash.replace(/^#\/?/, ''); + const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i); + const codeFromRoute = match?.[1] ?? ''; + + const persistedContext = this.sessionContextStore.get(); + if (persistedContext) { + this.playerId = persistedContext.playerId; + this.sessionToken = persistedContext.token; + } + + const candidate = codeFromRoute || persistedContext?.sessionCode || ''; + if (!candidate) { + return; + } + + this.sessionCode = this.normalizeCode(candidate); + void this.refreshSession(); + } + + ngOnDestroy(): void { + if (typeof window !== 'undefined') { + window.removeEventListener('online', this.handleOnline); + window.removeEventListener('offline', this.handleOffline); + } + this.clearReconnectTimer(); + } + + private readonly handleOnline = (): void => { + this.connectionState = 'reconnecting'; + this.retryReconnect(); + }; + + private readonly handleOffline = (): void => { + this.connectionState = 'offline'; + this.clearReconnectTimer(); + }; + + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } private normalizeCode(value: string): string { return value.trim().toUpperCase(); } + private toMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + return 'Unknown error'; + } + + private markOnline(): void { + this.connectionState = 'online'; + this.clearReconnectTimer(); + } + + private markConnectionIssue(error: unknown): void { + if (typeof navigator !== 'undefined' && !navigator.onLine) { + this.connectionState = 'offline'; + return; + } + + const message = this.toMessage(error).toLowerCase(); + if ( + message.includes('fetch') || + message.includes('network') || + message.includes('failed to') || + message.includes('could not load lobby status') || + message.includes('session refresh failed') + ) { + this.connectionState = 'reconnecting'; + this.scheduleReconnect(); + } + } + + private scheduleReconnect(): void { + if (this.reconnectTimer || !this.sessionCode.trim()) { + return; + } + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + void this.retryReconnect(); + }, 2000); + } + + async retryReconnect(): Promise { + if (!this.sessionCode.trim() || this.loading) { + return; + } + + await this.refreshSession(); + } + private syncFinalLeaderboard(): void { if (!this.session || this.session.session.status !== 'finished') { this.finalLeaderboard = []; @@ -128,8 +247,10 @@ export class PlayerShellComponent { this.selectedGuess = ''; } this.syncFinalLeaderboard(); + this.markOnline(); } catch (error) { - this.error = `Session refresh failed: ${(error as Error).message}`; + this.error = `Session refresh failed: ${this.toMessage(error)}`; + this.markConnectionIssue(error); } finally { this.loading = false; } @@ -153,8 +274,10 @@ export class PlayerShellComponent { this.selectedGuess = ''; } this.syncFinalLeaderboard(); + this.markOnline(); } catch (error) { - this.error = `Join failed: ${(error as Error).message}`; + this.error = `Join failed: ${this.toMessage(error)}`; + this.markConnectionIssue(error); } finally { this.loading = false; } @@ -177,8 +300,10 @@ export class PlayerShellComponent { } ); await this.refreshSession(); + this.markOnline(); } catch (error) { - this.submitError = { kind: 'lie', message: `Lie submit failed: ${(error as Error).message}` }; + this.submitError = { kind: 'lie', message: `Lie submit failed: ${this.toMessage(error)}` }; + this.markConnectionIssue(error); } finally { this.loading = false; } @@ -201,8 +326,10 @@ export class PlayerShellComponent { } ); await this.refreshSession(); + this.markOnline(); } catch (error) { - this.submitError = { kind: 'guess', message: `Guess submit failed: ${(error as Error).message}` }; + this.submitError = { kind: 'guess', message: `Guess submit failed: ${this.toMessage(error)}` }; + this.markConnectionIssue(error); } finally { this.loading = false; }