feat(spa): keep player in sync across next-round and document issue-180 flow
This commit is contained in:
28
docs/issue-180-flow-log.md
Normal file
28
docs/issue-180-flow-log.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Issue #180 – SPA gameplay flow evidence
|
||||
|
||||
## Flow log (host + player, no page reload)
|
||||
|
||||
1. Host opens SPA host shell and loads scoreboard (`GET /lobby/sessions/{code}/scoreboard`).
|
||||
2. Host starts next round (`POST /lobby/sessions/{code}/rounds/next`).
|
||||
3. Host shell refreshes session state in-place (`GET /lobby/sessions/{code}`) and clears old scoreboard/final leaderboard payloads.
|
||||
4. Player shell performs periodic session refresh while online (3s cadence) and transitions from `scoreboard` to `lobby` without page reload.
|
||||
5. Host finishes game (`POST /lobby/sessions/{code}/finish`) and renders final leaderboard directly in SPA shell.
|
||||
6. Player shell reads `finished` state and renders final leaderboard in SPA (sorted by score).
|
||||
7. Error/retry paths available:
|
||||
- Host: next-round and finish-game retry buttons with explicit error feedback.
|
||||
- Player: reconnect + submit retry feedback.
|
||||
|
||||
## Test output snapshot
|
||||
|
||||
Command:
|
||||
|
||||
```bash
|
||||
cd frontend/angular
|
||||
npm test -- --run src/app/features/host/host-shell.component.spec.ts src/app/features/player/player-shell.component.spec.ts
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- `host-shell.component.spec.ts`: 6 passed
|
||||
- `player-shell.component.spec.ts`: 7 passed
|
||||
- Total: 13 passed, 0 failed
|
||||
@@ -198,6 +198,30 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('auto-refreshes player session to avoid host/player state desync between rounds', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fetchMock: FetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('scoreboard', { roundQuestionId: null })))
|
||||
.mockResolvedValueOnce(jsonResponse(200, sessionDetailPayload('lobby', { roundQuestionId: null })));
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const component = new PlayerShellComponent();
|
||||
component.sessionCode = 'ABCD12';
|
||||
|
||||
await component.refreshSession();
|
||||
expect(component.session?.session.status).toBe('scoreboard');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3100);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(component.session?.session.status).toBe('lobby');
|
||||
|
||||
component.ngOnDestroy();
|
||||
});
|
||||
|
||||
it('enters reconnecting state when network request fails while online', async () => {
|
||||
vi.stubGlobal('navigator', { onLine: true });
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ interface SessionDetail {
|
||||
}
|
||||
|
||||
type ConnectionState = 'online' | 'reconnecting' | 'offline';
|
||||
type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null;
|
||||
|
||||
@Component({
|
||||
selector: 'app-player-shell',
|
||||
@@ -31,12 +32,16 @@ type ConnectionState = 'online' | 'reconnecting' | 'offline';
|
||||
<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>
|
||||
@@ -70,6 +75,11 @@ type ConnectionState = 'online' | 'reconnecting' | 'offline';
|
||||
|
||||
<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 {
|
||||
@@ -85,10 +95,12 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
session: SessionDetail | null = null;
|
||||
finalLeaderboard: Array<{ id: number; nickname: string; score: number }> = [];
|
||||
connectionState: ConnectionState = 'online';
|
||||
loadingTransition: LoadingTransition = null;
|
||||
|
||||
private readonly sessionContextStore = createSessionContextStore();
|
||||
private readonly controller = createVerticalSliceController(createApiClient(), this.sessionContextStore);
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor() {
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||
@@ -127,6 +139,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
window.removeEventListener('offline', this.handleOffline);
|
||||
}
|
||||
this.clearReconnectTimer();
|
||||
this.clearStateSyncTimer();
|
||||
}
|
||||
|
||||
private readonly handleOnline = (): void => {
|
||||
@@ -137,6 +150,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
private readonly handleOffline = (): void => {
|
||||
this.connectionState = 'offline';
|
||||
this.clearReconnectTimer();
|
||||
this.clearStateSyncTimer();
|
||||
};
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
@@ -146,6 +160,48 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private clearStateSyncTimer(): void {
|
||||
if (this.stateSyncTimer) {
|
||||
clearTimeout(this.stateSyncTimer);
|
||||
this.stateSyncTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleStateSync(): void {
|
||||
this.clearStateSyncTimer();
|
||||
|
||||
if (!this.sessionCode.trim() || this.connectionState !== 'online' || !this.session) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.session.session.status === 'finished') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stateSyncTimer = setTimeout(() => {
|
||||
this.stateSyncTimer = null;
|
||||
if (this.loading || this.connectionState !== 'online') {
|
||||
this.scheduleStateSync();
|
||||
return;
|
||||
}
|
||||
void this.refreshSession();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -160,9 +216,12 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
private markOnline(): void {
|
||||
this.connectionState = 'online';
|
||||
this.clearReconnectTimer();
|
||||
this.scheduleStateSync();
|
||||
}
|
||||
|
||||
private markConnectionIssue(error: unknown): void {
|
||||
this.clearStateSyncTimer();
|
||||
|
||||
if (typeof navigator !== 'undefined' && !navigator.onLine) {
|
||||
this.connectionState = 'offline';
|
||||
return;
|
||||
@@ -200,6 +259,21 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
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 = [];
|
||||
@@ -235,6 +309,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
|
||||
async refreshSession(): Promise<void> {
|
||||
this.loading = true;
|
||||
this.loadingTransition = 'refresh';
|
||||
this.error = '';
|
||||
try {
|
||||
const state = await this.controller.hydrateLobby(this.sessionCode);
|
||||
@@ -253,11 +328,13 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
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);
|
||||
@@ -280,6 +357,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
||||
this.markConnectionIssue(error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.loadingTransition = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user