feat(player): add reconnect/offline states in angular gameplay flow
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user