feat(player): add reconnect/offline states in angular gameplay flow
All checks were successful
CI / test-and-quality (push) Successful in 2m18s
CI / test-and-quality (pull_request) Successful in 2m29s

This commit is contained in:
2026-03-01 16:00:53 +00:00
parent 386ac5b7c1
commit f3ea19fcd7
2 changed files with 234 additions and 33 deletions

View File

@@ -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');
});
});

View File

@@ -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 {
<button (click)="joinSession()" [disabled]="loading">Join</button>
</div>
<p *ngIf="connectionState === 'reconnecting'" class="error">
Reconnecting… trying to refresh session state.
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry now</button>
</p>
<p *ngIf="connectionState === 'offline'" class="error">
You are offline. Reconnect to continue gameplay.
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry now</button>
</p>
<div class="panel" *ngIf="session">
<p><strong>Status:</strong> {{ session.session.status }}</p>
<p *ngIf="session.round_question"><strong>Prompt:</strong> {{ session.round_question.prompt }}</p>
@@ -61,7 +72,7 @@ interface SessionDetail {
<p *ngIf="submitError" class="error">{{ submitError.message }}</p>
`,
})
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<typeof setTimeout> | 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<void> {
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;
}