import { CommonModule } from '@angular/common'; import { Component, OnDestroy, OnInit, Optional } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { createApiClient } from '../../../../../src/api/client'; import { createSessionContextStore } from '../../../../../src/spa/session-context-store'; import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice'; import type { RouteSessionContext } from '../../session-route-context'; interface SessionDetail { session: { code: string; status: string; current_round: number }; round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null; players: Array<{ id: number; nickname: string; score: number }>; } type ConnectionState = 'online' | 'reconnecting' | 'offline'; type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null; function resolveLocalStorage(): Storage | undefined { if (typeof window === 'undefined') { return undefined; } return window.localStorage; } @Component({ selector: 'app-player-shell', standalone: true, imports: [CommonModule, FormsModule], template: `

Player SPA gameplay flow

Reconnecting… trying to refresh session state.

You are offline. Reconnect to continue gameplay.

{{ loadingMessage }}

Status: {{ session.session.status }}

Prompt: {{ session.round_question.prompt }}

Final leaderboard

  1. {{ entry.nickname }}: {{ entry.score }}

{{ error }}

{{ submitError.message }}

`, }) export class PlayerShellComponent implements OnInit, OnDestroy { sessionCode = ''; nickname = ''; playerId = 0; sessionToken = ''; lieText = ''; selectedGuess = ''; loading = false; error = ''; submitError: { kind: 'lie' | 'guess'; message: string } | null = null; session: SessionDetail | null = null; finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = []; connectionState: ConnectionState = 'online'; loadingTransition: LoadingTransition = null; private readonly sessionContextStore = createSessionContextStore(resolveLocalStorage()); private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore); private reconnectTimer: ReturnType | null = null; constructor(@Optional() private readonly route: ActivatedRoute | null = null) { 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 routeContext = this.route?.snapshot.data['routeContext'] as RouteSessionContext | undefined; const persistedContext = this.sessionContextStore.get(); this.playerId = routeContext?.playerId ?? persistedContext?.playerId ?? 0; this.sessionToken = routeContext?.token ?? persistedContext?.token ?? ''; const candidate = routeContext?.sessionCode || 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'; void this.retryReconnect(); }; private readonly handleOffline = (): void => { this.connectionState = 'offline'; this.clearReconnectTimer(); }; private clearReconnectTimer(): void { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } 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(); } 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(); } 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 = []; return; } this.finalLeaderboard = [...this.session.players].sort((a, b) => { if (b.score !== a.score) { return b.score - a.score; } return a.nickname.localeCompare(b.nickname); }); } private async request(path: string, method: 'GET' | 'POST', payload?: unknown): Promise { const response = await fetch(path, { method, headers: { Accept: 'application/json', ...(payload === undefined ? {} : { 'Content-Type': 'application/json' }), }, ...(payload === undefined ? {} : { body: JSON.stringify(payload) }), credentials: 'same-origin', }); const body = await response.json().catch(() => ({})); if (!response.ok) { throw new Error((body as { error?: string }).error ?? `HTTP ${response.status}`); } return body as T; } async refreshSession(): Promise { this.loading = true; this.loadingTransition = 'refresh'; this.error = ''; try { const state = await this.controller.hydrateLobby(this.sessionCode); if (!state.session || state.errorMessage) { throw new Error(state.errorMessage ?? 'Unknown error'); } this.session = state.session as SessionDetail; this.sessionCode = this.session.session.code; if (this.session.session.status !== 'guess') { this.selectedGuess = ''; } this.syncFinalLeaderboard(); this.markOnline(); } catch (error) { this.error = `Session refresh failed: ${this.toMessage(error)}`; 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); if (!state.session || state.errorMessage) { throw new Error(state.errorMessage ?? 'Unknown error'); } this.session = state.session as SessionDetail; this.sessionCode = this.session.session.code; const sessionContext = this.sessionContextStore.get(); this.playerId = sessionContext?.playerId ?? 0; this.sessionToken = sessionContext?.token ?? ''; if (this.session.session.status !== 'guess') { this.selectedGuess = ''; } this.syncFinalLeaderboard(); this.markOnline(); } catch (error) { this.error = `Join failed: ${this.toMessage(error)}`; this.markConnectionIssue(error); } finally { this.loading = false; this.loadingTransition = null; } } async submitLie(): Promise { if (!this.session?.round_question?.id) { return; } this.loading = true; this.loadingTransition = 'submit-lie'; this.submitError = null; try { await this.request( `/lobby/sessions/${encodeURIComponent(this.normalizeCode(this.sessionCode))}/questions/${this.session.round_question.id}/lies/submit`, 'POST', { player_id: this.playerId, session_token: this.sessionToken, text: this.lieText, } ); await this.refreshSession(); this.markOnline(); } catch (error) { this.submitError = { kind: 'lie', message: `Lie submit failed: ${this.toMessage(error)}` }; this.markConnectionIssue(error); } finally { this.loading = false; this.loadingTransition = null; } } async submitGuess(): Promise { if (!this.session?.round_question?.id || !this.selectedGuess) { return; } this.loading = true; this.loadingTransition = 'submit-guess'; this.submitError = null; try { await this.request( `/lobby/sessions/${encodeURIComponent(this.normalizeCode(this.sessionCode))}/questions/${this.session.round_question.id}/guesses/submit`, 'POST', { player_id: this.playerId, session_token: this.sessionToken, selected_text: this.selectedGuess, } ); await this.refreshSession(); this.markOnline(); } catch (error) { this.submitError = { kind: 'guess', message: `Guess submit failed: ${this.toMessage(error)}` }; this.markConnectionIssue(error); } finally { this.loading = false; this.loadingTransition = null; } } }