feat(spa): wire lie-guess-reveal-scoreboard gameplay flow (#172)
This commit is contained in:
19
frontend/angular/package-lock.json
generated
19
frontend/angular/package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@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",
|
||||
@@ -372,6 +373,24 @@
|
||||
"zone.js": "~0.15.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/forms": {
|
||||
"version": "19.2.19",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.19.tgz",
|
||||
"integrity": "sha512-J09++utTVaPs962y/adeDjIgqyhzNpnzAS7Nex+HNy/LnWPcTNW781cOh1EGS1X/+CmgnI8HWs5z4KGeBeU1aA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.1 || ^20.11.1 || >=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "19.2.19",
|
||||
"@angular/core": "19.2.19",
|
||||
"@angular/platform-browser": "19.2.19",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "19.2.19",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.19.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user