[SPA][P8] Host final leaderboard summary + reset flow #189

Merged
integrator-bot merged 1 commits from pr-185 into main 2026-03-01 16:35:36 +01:00
3 changed files with 47 additions and 8 deletions

View File

@@ -3,7 +3,7 @@
## Flow log (Angular SPA)
1. Host reaches `reveal` phase and runs `loadScoreboard()` (`GET /lobby/sessions/:code/scoreboard`).
2. Host can start next round directly in SPA via `startNextRound()` (`POST /lobby/sessions/:code/rounds/next`) and then session hydrate (`GET /lobby/sessions/:code`) without page reload.
3. Host can finish game directly in SPA via `finishGame()` (`POST /lobby/sessions/:code/finish`), rendering final leaderboard payload in the same shell.
3. Host can finish game directly in SPA via `finishGame()` (`POST /lobby/sessions/:code/finish`), rendering winner + sorted final leaderboard (plus raw payload for debug) in the same shell.
4. Player SPA renders final leaderboard from refreshed finished-session payload (sorted by score desc, nickname tiebreak) without leaving SPA route.
5. Error/retry paths are implemented and covered:
- scoreboard failure -> `scoreboardError` + retry button

View File

@@ -135,6 +135,8 @@ describe('HostShellComponent gameplay wiring', () => {
const component = new HostShellComponent();
component.sessionCode = ' abcd12 ';
component.scoreboardPayload = '{"leaderboard":[]}';
component.finalLeaderboardPayload = '{"leaderboard":[{"nickname":"Old","score":1}]}';
component.finalLeaderboard = [{ id: 9, nickname: 'Old', score: 1 }];
await component.startNextRound();
@@ -150,6 +152,8 @@ describe('HostShellComponent gameplay wiring', () => {
);
expect(component.session?.session.status).toBe('lobby');
expect(component.scoreboardPayload).toBe('');
expect(component.finalLeaderboardPayload).toBe('');
expect(component.finalLeaderboard).toEqual([]);
expect(component.nextRoundError).toBe('');
});
@@ -161,7 +165,10 @@ describe('HostShellComponent gameplay wiring', () => {
jsonResponse(200, {
session: { code: 'ABCD12', status: 'finished', current_round: 2 },
winner: { id: 1, nickname: 'Luna', score: 320 },
leaderboard: [{ id: 1, nickname: 'Luna', score: 320 }],
leaderboard: [
{ id: 2, nickname: 'Mads', score: 120 },
{ id: 1, nickname: 'Luna', score: 320 },
],
})
)
.mockResolvedValueOnce(
@@ -189,6 +196,8 @@ describe('HostShellComponent gameplay wiring', () => {
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(component.finishError).toBe('');
expect(component.finalLeaderboardPayload).toContain('"status": "finished"');
expect(component.finalLeaderboardPayload).toContain('\"status\": \"finished\"');
expect(component.finalWinner?.nickname).toBe('Luna');
expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']);
});
});

View File

@@ -11,10 +11,16 @@ interface SessionDetail {
players: Array<{ id: number; nickname: string; score: number }>;
}
interface LeaderboardEntry {
id: number;
nickname: string;
score: number;
}
interface LeaderboardResponse {
session: { code: string; status: string; current_round: number };
leaderboard: Array<{ id: number; nickname: string; score: number }>;
winner?: { id: number; nickname: string; score: number } | null;
leaderboard: LeaderboardEntry[];
winner?: LeaderboardEntry | null;
}
@Component({
@@ -53,6 +59,13 @@ interface LeaderboardResponse {
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
</ul>
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
<div *ngIf="finalLeaderboard.length">
<h3>Final leaderboard</h3>
<p *ngIf="finalWinner"><strong>Winner:</strong> {{ finalWinner.nickname }} ({{ finalWinner.score }} pts)</p>
<ol>
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
</ol>
</div>
<pre *ngIf="finalLeaderboardPayload">{{ finalLeaderboardPayload }}</pre>
</div>
`,
@@ -68,6 +81,8 @@ export class HostShellComponent {
finishError = '';
scoreboardPayload = '';
finalLeaderboardPayload = '';
finalLeaderboard: LeaderboardEntry[] = [];
finalWinner: LeaderboardEntry | null = null;
session: SessionDetail | null = null;
private readonly controller = createVerticalSliceController(createApiClient());
@@ -110,7 +125,7 @@ export class HostShellComponent {
this.sessionCode = this.session.session.code;
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
if (this.session.session.status !== 'finished') {
this.finalLeaderboardPayload = '';
this.resetFinalLeaderboard();
}
} catch (error) {
this.error = `Session refresh failed: ${(error as Error).message}`;
@@ -129,7 +144,7 @@ export class HostShellComponent {
this.sessionCode = this.session.session.code;
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
this.scoreboardPayload = '';
this.finalLeaderboardPayload = '';
this.resetFinalLeaderboard();
});
}
@@ -183,7 +198,7 @@ export class HostShellComponent {
const code = this.normalizeCode(this.sessionCode);
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/next`, 'POST', {});
this.scoreboardPayload = '';
this.finalLeaderboardPayload = '';
this.resetFinalLeaderboard();
await this.refreshSession();
} catch (error) {
this.nextRoundError = `Next round failed: ${(error as Error).message}`;
@@ -200,6 +215,13 @@ export class HostShellComponent {
const code = this.normalizeCode(this.sessionCode);
const payload = await this.request<LeaderboardResponse>(`/lobby/sessions/${encodeURIComponent(code)}/finish`, 'POST', {});
this.finalLeaderboardPayload = JSON.stringify(payload, null, 2);
this.finalLeaderboard = [...payload.leaderboard].sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
return a.nickname.localeCompare(b.nickname);
});
this.finalWinner = payload.winner ?? this.finalLeaderboard[0] ?? null;
await this.refreshSession();
} catch (error) {
this.finishError = `Finish game failed: ${(error as Error).message}`;
@@ -208,6 +230,14 @@ export class HostShellComponent {
}
}
private resetFinalLeaderboard(): void {
this.finalLeaderboardPayload = '';
this.finalLeaderboard = [];
this.finalWinner = null;
}
private async runAction(action: () => Promise<void>): Promise<void> {
this.loading = true;
this.error = '';