Merge pull request '[SPA][P7] #172 Gameplay MVP-del 2: Lie -> guess -> reveal -> scoreboard wired flow' (#181) from dev/issue-172-spa-gameplay-flow into main
All checks were successful
CI / test-and-quality (push) Successful in 2m1s

This commit was merged in pull request #181.
This commit is contained in:
2026-03-01 15:59:34 +01:00
10 changed files with 1988 additions and 10 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,14 @@
"private": true,
"scripts": {
"start": "ng serve",
"build": "ng build"
"build": "ng build",
"test": "vitest run"
},
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/router": "^19.2.0",
"rxjs": "~7.8.0",
@@ -20,6 +22,7 @@
"@angular/build": "^19.2.0",
"@angular/cli": "^19.2.0",
"@angular/compiler-cli": "^19.2.0",
"typescript": "~5.7.2"
"typescript": "~5.7.2",
"vitest": "^2.1.9"
}
}

View File

@@ -13,7 +13,7 @@ export class AppComponent {
constructor() {
const shellRoute = document.body.dataset['wppShellRoute'];
if (shellRoute === '/host' || shellRoute === '/player') {
if (shellRoute?.startsWith('/host') || shellRoute?.startsWith('/player')) {
void this.router.navigateByUrl(shellRoute);
}
}

View File

@@ -5,10 +5,26 @@ export const routes: Routes = [
path: 'host',
loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent),
},
{
path: 'host/:phase',
loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent),
},
{
path: 'host/:phase/:context',
loadComponent: () => import('./features/host/host-shell.component').then((m) => m.HostShellComponent),
},
{
path: 'player',
loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent),
},
{
path: 'player/:phase',
loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent),
},
{
path: 'player/:phase/:context',
loadComponent: () => import('./features/player/player-shell.component').then((m) => m.PlayerShellComponent),
},
{ path: '', pathMatch: 'full', redirectTo: 'player' },
{ path: '**', redirectTo: 'player' },
];

View File

@@ -0,0 +1,120 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HostShellComponent } from './host-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
describe('HostShellComponent gameplay wiring', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('runs startRound transition and refreshes session details', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(201, { ok: true }))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'lie', current_round: 2 },
round_question: { id: 41, prompt: 'Q?', answers: [] },
players: [],
})
);
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = ' abcd12 ';
component.categorySlug = ' history ';
await component.startRound();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12/rounds/start',
expect.objectContaining({ method: 'POST', body: JSON.stringify({ category_slug: 'history' }) })
);
expect(fetchMock).toHaveBeenNthCalledWith(
2,
'/lobby/sessions/ABCD12',
expect.objectContaining({ method: 'GET' })
);
expect(component.session?.session.status).toBe('lie');
expect(component.roundQuestionId).toBe('41');
expect(component.loading).toBe(false);
});
it('captures scoreboard error for retry path', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(500, { error: 'Scoreboard unavailable' })
);
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = 'ABCD12';
await component.loadScoreboard();
expect(fetchMock).toHaveBeenCalledWith(
'/lobby/sessions/ABCD12/scoreboard',
expect.objectContaining({ method: 'GET' })
);
expect(component.scoreboardError).toContain('Scoreboard failed: Scoreboard unavailable');
expect(component.loading).toBe(false);
});
it('wires showQuestion, mixAnswers and calculateScores with expected request payloads', async () => {
const sessionAfterAction = {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 77, prompt: 'Q?', answers: [] },
players: [],
};
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(jsonResponse(200, sessionAfterAction))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(jsonResponse(200, sessionAfterAction))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(jsonResponse(200, sessionAfterAction));
vi.stubGlobal('fetch', fetchMock);
const component = new HostShellComponent();
component.sessionCode = ' abcd12 ';
component.roundQuestionId = ' 77 ';
await component.showQuestion();
await component.mixAnswers();
await component.calculateScores();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12/questions/show',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'/lobby/sessions/ABCD12/questions/77/answers/mix',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(fetchMock).toHaveBeenNthCalledWith(
5,
'/lobby/sessions/ABCD12/questions/77/scores/calculate',
expect.objectContaining({ method: 'POST', body: JSON.stringify({}) })
);
expect(component.error).toBe('');
expect(component.loading).toBe(false);
});
});

View File

@@ -1,11 +1,156 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
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 }>;
}
@Component({
selector: 'app-host-shell',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<h2>Host route placeholder</h2>
<p>Host flow flyttes hertil i kommende SPA-issues.</p>
<h2>Host SPA gameplay flow</h2>
<div class="panel">
<label>Session code <input [(ngModel)]="sessionCode" /></label>
<label>Category <input [(ngModel)]="categorySlug" /></label>
<button (click)="refreshSession()" [disabled]="loading">Refresh</button>
<button (click)="startRound()" [disabled]="loading">Start round</button>
<button (click)="showQuestion()" [disabled]="loading || !roundQuestionId">Show question</button>
<button (click)="mixAnswers()" [disabled]="loading || !roundQuestionId">Mix answers → guess</button>
<button (click)="calculateScores()" [disabled]="loading || !roundQuestionId">Calculate scores → reveal</button>
<button (click)="loadScoreboard()" [disabled]="loading">Load scoreboard</button>
<button *ngIf="scoreboardError" (click)="loadScoreboard()" [disabled]="loading">Retry scoreboard</button>
</div>
<p *ngIf="error" class="error">{{ error }}</p>
<p *ngIf="scoreboardError" class="error">{{ scoreboardError }}</p>
<div *ngIf="session" class="panel">
<p><strong>Status:</strong> {{ session.session.status }} · round {{ session.session.current_round }}</p>
<p><strong>Round question id:</strong> {{ roundQuestionId || '-' }}</p>
<p *ngIf="session.round_question"><strong>Prompt:</strong> {{ session.round_question.prompt }}</p>
<ul>
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
</ul>
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
</div>
`,
})
export class HostShellComponent {}
export class HostShellComponent {
sessionCode = '';
categorySlug = 'general';
roundQuestionId = '';
loading = false;
error = '';
scoreboardError = '';
scoreboardPayload = '';
session: SessionDetail | null = null;
private normalizeCode(value: string): string {
return value.trim().toUpperCase();
}
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.error = '';
this.scoreboardError = '';
try {
const code = this.normalizeCode(this.sessionCode);
this.session = await this.request<SessionDetail>(`/lobby/sessions/${encodeURIComponent(code)}`, 'GET');
this.sessionCode = this.session.session.code;
this.roundQuestionId = this.session.round_question?.id ? String(this.session.round_question.id) : '';
} catch (error) {
this.error = `Session refresh failed: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
async startRound(): Promise<void> {
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/rounds/start`, 'POST', {
category_slug: this.categorySlug.trim(),
});
await this.refreshSession();
});
}
async showQuestion(): Promise<void> {
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/show`, 'POST', {});
await this.refreshSession();
});
}
async mixAnswers(): Promise<void> {
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
const roundQuestionId = this.roundQuestionId.trim();
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/answers/mix`, 'POST', {});
await this.refreshSession();
});
}
async calculateScores(): Promise<void> {
await this.runAction(async () => {
const code = this.normalizeCode(this.sessionCode);
const roundQuestionId = this.roundQuestionId.trim();
await this.request(`/lobby/sessions/${encodeURIComponent(code)}/questions/${roundQuestionId}/scores/calculate`, 'POST', {});
await this.refreshSession();
});
}
async loadScoreboard(): Promise<void> {
this.loading = true;
this.scoreboardError = '';
this.error = '';
try {
const code = this.normalizeCode(this.sessionCode);
const payload = await this.request<unknown>(`/lobby/sessions/${encodeURIComponent(code)}/scoreboard`, 'GET');
this.scoreboardPayload = JSON.stringify(payload, null, 2);
await this.refreshSession();
} catch (error) {
this.scoreboardError = `Scoreboard failed: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
private async runAction(action: () => Promise<void>): Promise<void> {
this.loading = true;
this.error = '';
try {
await action();
} catch (error) {
this.error = (error as Error).message;
} finally {
this.loading = false;
}
}
}

View File

@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PlayerShellComponent } from './player-shell.component';
type FetchMock = ReturnType<typeof vi.fn>;
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: vi.fn().mockResolvedValue(body),
} as unknown as Response;
}
describe('PlayerShellComponent gameplay wiring', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('clears selected guess when refreshed status is no longer guess', async () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }] },
})
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'abcd12';
component.selectedGuess = 'A';
await component.refreshSession();
expect(fetchMock).toHaveBeenCalledWith(
'/lobby/sessions/ABCD12',
expect.objectContaining({ method: 'GET' })
);
expect(component.selectedGuess).toBe('');
});
it('surfaces lie submit error and allows retry success flow', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(500, { error: 'Temporary submit outage' }))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
})
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = 'ABCD12';
component.playerId = 9;
component.sessionToken = 'token-1';
component.lieText = 'my lie';
component.session = {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [] },
};
await component.submitLie();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12/questions/11/lies/submit',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ player_id: 9, session_token: 'token-1', text: 'my lie' }),
})
);
expect(component.submitError?.kind).toBe('lie');
expect(component.submitError?.message).toContain('Lie submit failed: Temporary submit outage');
await component.submitLie();
expect(component.submitError).toBeNull();
expect(component.session?.session.status).toBe('guess');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('surfaces guess submit error and retries with selected answer payload', async () => {
const fetchMock: FetchMock = vi
.fn()
.mockResolvedValueOnce(jsonResponse(503, { error: 'Guess queue busy' }))
.mockResolvedValueOnce(jsonResponse(200, { ok: true }))
.mockResolvedValueOnce(
jsonResponse(200, {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
})
);
vi.stubGlobal('fetch', fetchMock);
const component = new PlayerShellComponent();
component.sessionCode = ' abcd12 ';
component.playerId = 9;
component.sessionToken = 'token-1';
component.selectedGuess = 'B';
component.session = {
session: { code: 'ABCD12', status: 'guess', current_round: 1 },
round_question: { id: 11, prompt: 'Q?', answers: [{ text: 'A' }, { text: 'B' }] },
};
await component.submitGuess();
expect(fetchMock).toHaveBeenNthCalledWith(
1,
'/lobby/sessions/ABCD12/questions/11/guesses/submit',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ player_id: 9, session_token: 'token-1', selected_text: 'B' }),
})
);
expect(component.submitError?.kind).toBe('guess');
expect(component.submitError?.message).toContain('Guess submit failed: Guess queue busy');
await component.submitGuess();
expect(component.submitError).toBeNull();
expect(component.session?.session.status).toBe('reveal');
expect(component.selectedGuess).toBe('');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});

View File

@@ -1,11 +1,173 @@
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
interface SessionDetail {
session: { code: string; status: string; current_round: number };
round_question: { id: number; prompt: string; answers: Array<{ text: string }> } | null;
}
@Component({
selector: 'app-player-shell',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<h2>Player route placeholder</h2>
<p>Player flow flyttes hertil i kommende SPA-issues.</p>
<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>
<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>
<p *ngIf="error" class="error">{{ error }}</p>
<p *ngIf="submitError" class="error">{{ submitError.message }}</p>
`,
})
export class PlayerShellComponent {}
export class PlayerShellComponent {
sessionCode = '';
nickname = '';
playerId = 0;
sessionToken = '';
lieText = '';
selectedGuess = '';
loading = false;
error = '';
submitError: { kind: 'lie' | 'guess'; message: string } | null = null;
session: SessionDetail | null = null;
private normalizeCode(value: string): string {
return value.trim().toUpperCase();
}
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.error = '';
try {
const code = this.normalizeCode(this.sessionCode);
this.session = await this.request<SessionDetail>(`/lobby/sessions/${encodeURIComponent(code)}`, 'GET');
this.sessionCode = this.session.session.code;
if (this.session.session.status !== 'guess') {
this.selectedGuess = '';
}
} catch (error) {
this.error = `Session refresh failed: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
async joinSession(): Promise<void> {
this.loading = true;
this.error = '';
try {
const payload = await this.request<{
player: { id: number; session_token: string };
session: { code: string };
}>('/lobby/sessions/join', 'POST', {
code: this.normalizeCode(this.sessionCode),
nickname: this.nickname.trim(),
});
this.playerId = payload.player.id;
this.sessionToken = payload.player.session_token;
this.sessionCode = payload.session.code;
await this.refreshSession();
} catch (error) {
this.error = `Join failed: ${(error as Error).message}`;
} finally {
this.loading = false;
}
}
async submitLie(): Promise<void> {
if (!this.session?.round_question?.id) {
return;
}
this.loading = true;
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();
} catch (error) {
this.submitError = { kind: 'lie', message: `Lie submit failed: ${(error as Error).message}` };
} finally {
this.loading = false;
}
}
async submitGuess(): Promise<void> {
if (!this.session?.round_question?.id || !this.selectedGuess) {
return;
}
this.loading = true;
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();
} catch (error) {
this.submitError = { kind: 'guess', message: `Guess submit failed: ${(error as Error).message}` };
} finally {
this.loading = false;
}
}
}

View File

@@ -0,0 +1 @@
import '@angular/compiler';

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.spec.ts'],
setupFiles: ['src/test-setup.ts'],
},
});