Files
weirsoe-party-protocol/frontend/angular/src/app/features/player/player-shell.component.ts
Asger Geel Weirsoee 5957660802
All checks were successful
CI / test-and-quality (push) Successful in 1m59s
feat(spa): bootstrap host/player shells from route resolver context
2026-03-01 18:02:43 +00:00

391 lines
13 KiB
TypeScript

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: `
<h2>Player SPA gameplay flow</h2>
<div class="panel">
<label>Session code <input [(ngModel)]="sessionCode" /></label>
<label>Nickname <input [(ngModel)]="nickname" /></label>
<button (click)="refreshSession()" [disabled]="loading">Refresh</button>
<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>
<button type="button" (click)="returnToJoin()" [disabled]="loading">Back to join</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>
<button type="button" (click)="returnToJoin()" [disabled]="loading">Back to join</button>
</p>
<p *ngIf="loading" class="hint">{{ loadingMessage }}</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>
<label>Løgn <input [(ngModel)]="lieText" [disabled]="loading || session.session.status !== 'lie'" /></label>
<button (click)="submitLie()" [disabled]="loading || session.session.status !== 'lie'">Submit lie</button>
<button *ngIf="submitError?.kind === 'lie'" (click)="submitLie()" [disabled]="loading">Retry lie submit</button>
<div class="answers" *ngIf="session.round_question?.answers?.length">
<button
type="button"
*ngFor="let answer of session.round_question?.answers"
(click)="selectedGuess = answer.text"
[class.active]="selectedGuess === answer.text"
[disabled]="loading || session.session.status !== 'guess'"
>
{{ answer.text }}
</button>
</div>
<button (click)="submitGuess()" [disabled]="loading || session.session.status !== 'guess' || !selectedGuess">Submit guess</button>
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">Retry guess submit</button>
<div *ngIf="session.session.status === 'finished' && finalLeaderboard.length">
<h3>Final leaderboard</h3>
<ol>
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
</ol>
</div>
</div>
<p *ngIf="error" class="error">{{ error }}</p>
<p *ngIf="submitError" class="error">{{ submitError.message }}</p>
<div class="panel" *ngIf="error || submitError">
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry</button>
<button type="button" (click)="returnToJoin()" [disabled]="loading">Back to join</button>
</div>
`,
})
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<typeof setTimeout> | 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<void> {
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<T>(path: string, method: 'GET' | 'POST', payload?: unknown): Promise<T> {
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<void> {
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<void> {
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<void> {
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<void> {
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;
}
}
}