feat(player): add reconnect loading and fallback join state (#187)
This commit is contained in:
@@ -227,4 +227,72 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
|||||||
expect(component.connectionState).toBe('offline');
|
expect(component.connectionState).toBe('offline');
|
||||||
expect(component.error).toContain('Session refresh failed');
|
expect(component.error).toContain('Session refresh failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('tracks loading transition message for join action', async () => {
|
||||||
|
let resolveJoin: ((value: Response) => void) | null = null;
|
||||||
|
const fetchMock: FetchMock = vi.fn().mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<Response>((resolve) => {
|
||||||
|
resolveJoin = resolve;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const component = new PlayerShellComponent();
|
||||||
|
component.sessionCode = 'ABCD12';
|
||||||
|
component.nickname = 'Luna';
|
||||||
|
|
||||||
|
const joinPromise = component.joinSession();
|
||||||
|
|
||||||
|
expect(component.loading).toBe(true);
|
||||||
|
expect(component.loadingMessage).toBe('Joining session… restoring your player state.');
|
||||||
|
|
||||||
|
resolveJoin?.(jsonResponse(201, sessionDetailPayload('lobby', { roundQuestionId: null })));
|
||||||
|
await joinPromise;
|
||||||
|
|
||||||
|
expect(component.loading).toBe(false);
|
||||||
|
expect(component.loadingTransition).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returnToJoin clears persisted session context and transient state', () => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const localStorage = {
|
||||||
|
getItem: vi.fn((key: string) => values.get(key) ?? null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => {
|
||||||
|
values.set(key, value);
|
||||||
|
}),
|
||||||
|
removeItem: vi.fn((key: string) => {
|
||||||
|
values.delete(key);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
localStorage,
|
||||||
|
});
|
||||||
|
|
||||||
|
values.set('wpp.session-context', JSON.stringify({ sessionCode: 'ABCD12', playerId: 9, token: 'tok-1' }));
|
||||||
|
|
||||||
|
const component = new PlayerShellComponent();
|
||||||
|
component.sessionCode = 'ABCD12';
|
||||||
|
component.playerId = 9;
|
||||||
|
component.sessionToken = 'tok-1';
|
||||||
|
component.error = 'Session refresh failed';
|
||||||
|
component.submitError = { kind: 'guess', message: 'Guess submit failed' };
|
||||||
|
component.session = {
|
||||||
|
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
|
||||||
|
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }] },
|
||||||
|
players: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
component.returnToJoin();
|
||||||
|
|
||||||
|
expect(component.playerId).toBe(0);
|
||||||
|
expect(component.sessionToken).toBe('');
|
||||||
|
expect(component.session).toBeNull();
|
||||||
|
expect(component.error).toBe('');
|
||||||
|
expect(component.submitError).toBeNull();
|
||||||
|
expect(values.get('wpp.session-context')).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ interface SessionDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ConnectionState = 'online' | 'reconnecting' | 'offline';
|
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({
|
@Component({
|
||||||
selector: 'app-player-shell',
|
selector: 'app-player-shell',
|
||||||
@@ -31,12 +39,16 @@ type ConnectionState = 'online' | 'reconnecting' | 'offline';
|
|||||||
<p *ngIf="connectionState === 'reconnecting'" class="error">
|
<p *ngIf="connectionState === 'reconnecting'" class="error">
|
||||||
Reconnecting… trying to refresh session state.
|
Reconnecting… trying to refresh session state.
|
||||||
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry now</button>
|
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry now</button>
|
||||||
|
<button type="button" (click)="returnToJoin()" [disabled]="loading">Back to join</button>
|
||||||
</p>
|
</p>
|
||||||
<p *ngIf="connectionState === 'offline'" class="error">
|
<p *ngIf="connectionState === 'offline'" class="error">
|
||||||
You are offline. Reconnect to continue gameplay.
|
You are offline. Reconnect to continue gameplay.
|
||||||
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry now</button>
|
<button type="button" (click)="retryReconnect()" [disabled]="loading">Retry now</button>
|
||||||
|
<button type="button" (click)="returnToJoin()" [disabled]="loading">Back to join</button>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p *ngIf="loading" class="hint">{{ loadingMessage }}</p>
|
||||||
|
|
||||||
<div class="panel" *ngIf="session">
|
<div class="panel" *ngIf="session">
|
||||||
<p><strong>Status:</strong> {{ session.session.status }}</p>
|
<p><strong>Status:</strong> {{ session.session.status }}</p>
|
||||||
<p *ngIf="session.round_question"><strong>Prompt:</strong> {{ session.round_question.prompt }}</p>
|
<p *ngIf="session.round_question"><strong>Prompt:</strong> {{ session.round_question.prompt }}</p>
|
||||||
@@ -70,6 +82,11 @@ type ConnectionState = 'online' | 'reconnecting' | 'offline';
|
|||||||
|
|
||||||
<p *ngIf="error" class="error">{{ error }}</p>
|
<p *ngIf="error" class="error">{{ error }}</p>
|
||||||
<p *ngIf="submitError" class="error">{{ submitError.message }}</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 {
|
export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||||
@@ -85,8 +102,9 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
session: SessionDetail | null = null;
|
session: SessionDetail | null = null;
|
||||||
finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = [];
|
finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = [];
|
||||||
connectionState: ConnectionState = 'online';
|
connectionState: ConnectionState = 'online';
|
||||||
|
loadingTransition: LoadingTransition = null;
|
||||||
|
|
||||||
private readonly sessionContextStore = createSessionContextStore();
|
private readonly sessionContextStore = createSessionContextStore(resolveLocalStorage());
|
||||||
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
|
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
|
||||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
@@ -131,7 +149,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
private readonly handleOnline = (): void => {
|
private readonly handleOnline = (): void => {
|
||||||
this.connectionState = 'reconnecting';
|
this.connectionState = 'reconnecting';
|
||||||
this.retryReconnect();
|
void this.retryReconnect();
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly handleOffline = (): void => {
|
private readonly handleOffline = (): void => {
|
||||||
@@ -146,6 +164,20 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
private normalizeCode(value: string): string {
|
||||||
return value.trim().toUpperCase();
|
return value.trim().toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -200,6 +232,21 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
await this.refreshSession();
|
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 {
|
private syncFinalLeaderboard(): void {
|
||||||
if (!this.session || this.session.session.status !== 'finished') {
|
if (!this.session || this.session.session.status !== 'finished') {
|
||||||
this.finalLeaderboard = [];
|
this.finalLeaderboard = [];
|
||||||
@@ -235,6 +282,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
async refreshSession(): Promise<void> {
|
async refreshSession(): Promise<void> {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
this.loadingTransition = 'refresh';
|
||||||
this.error = '';
|
this.error = '';
|
||||||
try {
|
try {
|
||||||
const state = await this.controller.hydrateLobby(this.sessionCode);
|
const state = await this.controller.hydrateLobby(this.sessionCode);
|
||||||
@@ -253,11 +301,13 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
this.markConnectionIssue(error);
|
this.markConnectionIssue(error);
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
this.loadingTransition = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async joinSession(): Promise<void> {
|
async joinSession(): Promise<void> {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
this.loadingTransition = 'join';
|
||||||
this.error = '';
|
this.error = '';
|
||||||
try {
|
try {
|
||||||
const state = await this.controller.joinLobby(this.sessionCode, this.nickname);
|
const state = await this.controller.joinLobby(this.sessionCode, this.nickname);
|
||||||
@@ -280,6 +330,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
this.markConnectionIssue(error);
|
this.markConnectionIssue(error);
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
this.loadingTransition = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +339,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
this.loadingTransition = 'submit-lie';
|
||||||
this.submitError = null;
|
this.submitError = null;
|
||||||
try {
|
try {
|
||||||
await this.request(
|
await this.request(
|
||||||
@@ -306,6 +358,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
this.markConnectionIssue(error);
|
this.markConnectionIssue(error);
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
this.loadingTransition = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,6 +367,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
this.loadingTransition = 'submit-guess';
|
||||||
this.submitError = null;
|
this.submitError = null;
|
||||||
try {
|
try {
|
||||||
await this.request(
|
await this.request(
|
||||||
@@ -332,6 +386,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
this.markConnectionIssue(error);
|
this.markConnectionIssue(error);
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
|
this.loadingTransition = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user