@@ -19,6 +19,24 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
|
|||||||
shown_at: '2026-03-01T18:00:00Z',
|
shown_at: '2026-03-01T18:00:00Z',
|
||||||
answers: [{ text: 'A' }, { text: 'B' }]
|
answers: [{ text: 'A' }, { text: 'B' }]
|
||||||
},
|
},
|
||||||
|
reveal: {
|
||||||
|
round_question_id: 77,
|
||||||
|
round_number: 1,
|
||||||
|
prompt: 'Q?',
|
||||||
|
correct_answer: 'A',
|
||||||
|
lies: [{ player_id: 2, nickname: 'Maja', text: 'B', created_at: '2026-03-01T18:00:05Z' }],
|
||||||
|
guesses: [
|
||||||
|
{
|
||||||
|
player_id: 3,
|
||||||
|
nickname: 'Bo',
|
||||||
|
selected_text: 'B',
|
||||||
|
is_correct: false,
|
||||||
|
fooled_player_id: 2,
|
||||||
|
fooled_player_nickname: 'Maja',
|
||||||
|
created_at: '2026-03-01T18:00:15Z'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
phase_view_model: {
|
phase_view_model: {
|
||||||
status: 'lobby',
|
status: 'lobby',
|
||||||
round_number: 1,
|
round_number: 1,
|
||||||
@@ -172,6 +190,8 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
|
|||||||
expect(session.data.session.code).toBe('ABCD12');
|
expect(session.data.session.code).toBe('ABCD12');
|
||||||
expect(session.data.phase_view_model.host.can_start_next_round).toBe(true);
|
expect(session.data.phase_view_model.host.can_start_next_round).toBe(true);
|
||||||
expect(session.data.phase_view_model.player.can_submit_guess).toBe(true);
|
expect(session.data.phase_view_model.player.can_submit_guess).toBe(true);
|
||||||
|
expect(session.data.reveal?.correct_answer).toBe('A');
|
||||||
|
expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja');
|
||||||
}
|
}
|
||||||
|
|
||||||
expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true);
|
expect((await client.joinSession({ code: ' abcd12 ', nickname: ' Maja ' })).ok).toBe(true);
|
||||||
|
|||||||
@@ -12,7 +12,26 @@ function jsonResponse(status: number, body: unknown) {
|
|||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionDetailPayload(status: string, options?: { roundQuestionId?: number | null }) {
|
function sessionDetailPayload(
|
||||||
|
status: string,
|
||||||
|
options?: {
|
||||||
|
roundQuestionId?: number | null;
|
||||||
|
reveal?: {
|
||||||
|
correct_answer: string;
|
||||||
|
prompt?: string;
|
||||||
|
lies?: Array<{ player_id: number; nickname: string; text: string; created_at?: string }>;
|
||||||
|
guesses?: Array<{
|
||||||
|
player_id: number;
|
||||||
|
nickname: string;
|
||||||
|
selected_text: string;
|
||||||
|
is_correct: boolean;
|
||||||
|
fooled_player_id: number | null;
|
||||||
|
fooled_player_nickname?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
const roundQuestionId = options?.roundQuestionId ?? 41;
|
const roundQuestionId = options?.roundQuestionId ?? 41;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -37,6 +56,23 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
|
|||||||
{ id: 1, nickname: 'Host', score: 0, is_connected: true },
|
{ id: 1, nickname: 'Host', score: 0, is_connected: true },
|
||||||
{ id: 2, nickname: 'Mads', score: 120, is_connected: true },
|
{ id: 2, nickname: 'Mads', score: 120, is_connected: true },
|
||||||
],
|
],
|
||||||
|
reveal:
|
||||||
|
options?.reveal === undefined || options?.reveal === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
round_question_id: roundQuestionId,
|
||||||
|
round_number: 1,
|
||||||
|
prompt: options.reveal.prompt ?? 'Q?',
|
||||||
|
correct_answer: options.reveal.correct_answer,
|
||||||
|
lies: (options.reveal.lies ?? []).map((lie) => ({
|
||||||
|
...lie,
|
||||||
|
created_at: lie.created_at ?? '2026-01-01T00:00:05Z',
|
||||||
|
})),
|
||||||
|
guesses: (options.reveal.guesses ?? []).map((guess) => ({
|
||||||
|
...guess,
|
||||||
|
created_at: guess.created_at ?? '2026-01-01T00:00:10Z',
|
||||||
|
})),
|
||||||
|
},
|
||||||
phase_view_model: {
|
phase_view_model: {
|
||||||
status,
|
status,
|
||||||
round_number: 1,
|
round_number: 1,
|
||||||
@@ -101,6 +137,48 @@ describe('HostShellComponent gameplay wiring', () => {
|
|||||||
expect(component.loading).toBe(false);
|
expect(component.loading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hydrates canonical reveal payload in reveal phase', async () => {
|
||||||
|
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
|
||||||
|
jsonResponse(
|
||||||
|
200,
|
||||||
|
sessionDetailPayload('reveal', {
|
||||||
|
roundQuestionId: 77,
|
||||||
|
reveal: {
|
||||||
|
correct_answer: 'Mercury',
|
||||||
|
lies: [{ player_id: 2, nickname: 'Mads', text: 'Venus' }],
|
||||||
|
guesses: [
|
||||||
|
{
|
||||||
|
player_id: 3,
|
||||||
|
nickname: 'Luna',
|
||||||
|
selected_text: 'Venus',
|
||||||
|
is_correct: false,
|
||||||
|
fooled_player_id: 2,
|
||||||
|
fooled_player_nickname: 'Mads',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const component = new HostShellComponent();
|
||||||
|
component.sessionCode = 'ABCD12';
|
||||||
|
|
||||||
|
await component.refreshSession();
|
||||||
|
|
||||||
|
expect(component.session?.reveal?.correct_answer).toBe('Mercury');
|
||||||
|
expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 2, nickname: 'Mads', text: 'Venus' });
|
||||||
|
expect(component.session?.reveal?.guesses[0]).toMatchObject({
|
||||||
|
player_id: 3,
|
||||||
|
nickname: 'Luna',
|
||||||
|
selected_text: 'Venus',
|
||||||
|
fooled_player_id: 2,
|
||||||
|
fooled_player_nickname: 'Mads',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('captures scoreboard error for retry path', async () => {
|
it('captures scoreboard error for retry path', async () => {
|
||||||
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' }));
|
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' }));
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,11 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
import { createApiClient } from '../../../../../src/api/client';
|
import { createApiClient } from '../../../../../src/api/client';
|
||||||
import type { FinishGameResponse, ScoreboardResponse } from '../../../../../src/api/types';
|
import type { FinishGameResponse, ScoreboardResponse, SessionDetailResponse } from '../../../../../src/api/types';
|
||||||
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
||||||
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
|
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
|
||||||
|
|
||||||
interface SessionDetail {
|
type SessionDetail = SessionDetailResponse;
|
||||||
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 }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LeaderboardEntry = ScoreboardResponse['leaderboard'][number];
|
type LeaderboardEntry = ScoreboardResponse['leaderboard'][number];
|
||||||
type LeaderboardResponse = FinishGameResponse;
|
type LeaderboardResponse = FinishGameResponse;
|
||||||
@@ -52,6 +48,28 @@ type LeaderboardResponse = FinishGameResponse;
|
|||||||
<ul>
|
<ul>
|
||||||
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
|
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
|
||||||
|
<h3>Reveal</h3>
|
||||||
|
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
|
||||||
|
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
|
||||||
|
<div *ngIf="session.reveal.lies.length">
|
||||||
|
<strong>Løgne</strong>
|
||||||
|
<ul>
|
||||||
|
<li *ngFor="let lie of session.reveal.lies">{{ lie.nickname }} løj: {{ lie.text }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="session.reveal.guesses.length">
|
||||||
|
<strong>Gæt</strong>
|
||||||
|
<ul>
|
||||||
|
<li *ngFor="let guess of session.reveal.guesses">
|
||||||
|
{{ guess.nickname }} valgte {{ guess.selected_text }}
|
||||||
|
<span *ngIf="guess.is_correct">· korrekt</span>
|
||||||
|
<span *ngIf="!guess.is_correct && guess.fooled_player_nickname">· narret af {{ guess.fooled_player_nickname }}</span>
|
||||||
|
<span *ngIf="!guess.is_correct && !guess.fooled_player_nickname">· forkert</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
|
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
|
||||||
<div *ngIf="finalLeaderboard.length">
|
<div *ngIf="finalLeaderboard.length">
|
||||||
<h3>{{ copy('host.final_leaderboard') }}</h3>
|
<h3>{{ copy('host.final_leaderboard') }}</h3>
|
||||||
|
|||||||
@@ -13,7 +13,28 @@ function jsonResponse(status: number, body: unknown) {
|
|||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionDetailPayload(status: string, options?: { answers?: string[]; players?: Array<{ id: number; nickname: string; score: number }>; roundQuestionId?: number | null }) {
|
function sessionDetailPayload(
|
||||||
|
status: string,
|
||||||
|
options?: {
|
||||||
|
answers?: string[];
|
||||||
|
players?: Array<{ id: number; nickname: string; score: number }>;
|
||||||
|
roundQuestionId?: number | null;
|
||||||
|
reveal?: {
|
||||||
|
correct_answer: string;
|
||||||
|
prompt?: string;
|
||||||
|
lies?: Array<{ player_id: number; nickname: string; text: string; created_at?: string }>;
|
||||||
|
guesses?: Array<{
|
||||||
|
player_id: number;
|
||||||
|
nickname: string;
|
||||||
|
selected_text: string;
|
||||||
|
is_correct: boolean;
|
||||||
|
fooled_player_id: number | null;
|
||||||
|
fooled_player_nickname?: string;
|
||||||
|
created_at?: string;
|
||||||
|
}>;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
) {
|
||||||
const answers = options?.answers ?? [];
|
const answers = options?.answers ?? [];
|
||||||
const roundQuestionId = options?.roundQuestionId ?? 11;
|
const roundQuestionId = options?.roundQuestionId ?? 11;
|
||||||
|
|
||||||
@@ -39,6 +60,23 @@ function sessionDetailPayload(status: string, options?: { answers?: string[]; pl
|
|||||||
...player,
|
...player,
|
||||||
is_connected: true,
|
is_connected: true,
|
||||||
})),
|
})),
|
||||||
|
reveal:
|
||||||
|
options?.reveal === undefined || options?.reveal === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
round_question_id: roundQuestionId,
|
||||||
|
round_number: 1,
|
||||||
|
prompt: options.reveal.prompt ?? 'Q?',
|
||||||
|
correct_answer: options.reveal.correct_answer,
|
||||||
|
lies: (options.reveal.lies ?? []).map((lie) => ({
|
||||||
|
...lie,
|
||||||
|
created_at: lie.created_at ?? '2026-01-01T00:00:05Z',
|
||||||
|
})),
|
||||||
|
guesses: (options.reveal.guesses ?? []).map((guess) => ({
|
||||||
|
...guess,
|
||||||
|
created_at: guess.created_at ?? '2026-01-01T00:00:10Z',
|
||||||
|
})),
|
||||||
|
},
|
||||||
phase_view_model: {
|
phase_view_model: {
|
||||||
status,
|
status,
|
||||||
round_number: 1,
|
round_number: 1,
|
||||||
@@ -158,6 +196,63 @@ describe('PlayerShellComponent gameplay wiring', () => {
|
|||||||
expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']);
|
expect(component.finalLeaderboard.map((entry) => entry.nickname)).toEqual(['Luna', 'Mads']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hydrates canonical reveal payload after guess -> reveal', async () => {
|
||||||
|
const fetchMock: FetchMock = vi.fn().mockResolvedValue(
|
||||||
|
jsonResponse(
|
||||||
|
200,
|
||||||
|
sessionDetailPayload('reveal', {
|
||||||
|
answers: ['A', 'B'],
|
||||||
|
reveal: {
|
||||||
|
correct_answer: 'A',
|
||||||
|
lies: [{ player_id: 3, nickname: 'Løgnhals', text: 'B' }],
|
||||||
|
guesses: [
|
||||||
|
{
|
||||||
|
player_id: 9,
|
||||||
|
nickname: 'Detektiv',
|
||||||
|
selected_text: 'B',
|
||||||
|
is_correct: false,
|
||||||
|
fooled_player_id: 3,
|
||||||
|
fooled_player_nickname: 'Løgnhals',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
player_id: 10,
|
||||||
|
nickname: 'Sandhed',
|
||||||
|
selected_text: 'A',
|
||||||
|
is_correct: true,
|
||||||
|
fooled_player_id: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const component = new PlayerShellComponent();
|
||||||
|
component.sessionCode = 'ABCD12';
|
||||||
|
|
||||||
|
await component.refreshSession();
|
||||||
|
|
||||||
|
expect(component.session?.reveal?.correct_answer).toBe('A');
|
||||||
|
expect(component.session?.reveal?.lies[0]).toMatchObject({ player_id: 3, nickname: 'Løgnhals', text: 'B' });
|
||||||
|
expect(component.session?.reveal?.guesses[0]).toMatchObject({
|
||||||
|
player_id: 9,
|
||||||
|
nickname: 'Detektiv',
|
||||||
|
selected_text: 'B',
|
||||||
|
is_correct: false,
|
||||||
|
fooled_player_id: 3,
|
||||||
|
fooled_player_nickname: 'Løgnhals',
|
||||||
|
});
|
||||||
|
expect(component.session?.reveal?.guesses[1]).toMatchObject({
|
||||||
|
player_id: 10,
|
||||||
|
nickname: 'Sandhed',
|
||||||
|
selected_text: 'A',
|
||||||
|
is_correct: true,
|
||||||
|
fooled_player_id: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('surfaces guess submit error and retries with selected answer payload', async () => {
|
it('surfaces guess submit error and retries with selected answer payload', async () => {
|
||||||
const fetchMock: FetchMock = vi
|
const fetchMock: FetchMock = vi
|
||||||
.fn()
|
.fn()
|
||||||
|
|||||||
@@ -3,15 +3,12 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
import { createApiClient } from '../../../../../src/api/client';
|
import { createApiClient } from '../../../../../src/api/client';
|
||||||
|
import type { SessionDetailResponse } from '../../../../../src/api/types';
|
||||||
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
|
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
|
||||||
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
|
||||||
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
|
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
|
||||||
|
|
||||||
interface SessionDetail {
|
type SessionDetail = SessionDetailResponse;
|
||||||
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 }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConnectionState = 'online' | 'reconnecting' | 'offline';
|
type ConnectionState = 'online' | 'reconnecting' | 'offline';
|
||||||
type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null;
|
type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null;
|
||||||
@@ -90,6 +87,29 @@ function resolveLocalStorage(): Storage | undefined {
|
|||||||
<button (click)="submitGuess()" [disabled]="loading || session.session.status !== 'guess' || !selectedGuess">{{ copy('player.submit_guess') }}</button>
|
<button (click)="submitGuess()" [disabled]="loading || session.session.status !== 'guess' || !selectedGuess">{{ copy('player.submit_guess') }}</button>
|
||||||
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button>
|
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button>
|
||||||
|
|
||||||
|
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
|
||||||
|
<h3>Reveal</h3>
|
||||||
|
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
|
||||||
|
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
|
||||||
|
<div *ngIf="session.reveal.lies.length">
|
||||||
|
<strong>Løgne</strong>
|
||||||
|
<ul>
|
||||||
|
<li *ngFor="let lie of session.reveal.lies">{{ lie.nickname }} løj: {{ lie.text }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div *ngIf="session.reveal.guesses.length">
|
||||||
|
<strong>Gæt</strong>
|
||||||
|
<ul>
|
||||||
|
<li *ngFor="let guess of session.reveal.guesses">
|
||||||
|
{{ guess.nickname }} valgte {{ guess.selected_text }}
|
||||||
|
<span *ngIf="guess.is_correct">· korrekt</span>
|
||||||
|
<span *ngIf="!guess.is_correct && guess.fooled_player_nickname">· narret af {{ guess.fooled_player_nickname }}</span>
|
||||||
|
<span *ngIf="!guess.is_correct && !guess.fooled_player_nickname">· forkert</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div *ngIf="session.session.status === 'finished' && finalLeaderboard.length">
|
<div *ngIf="session.session.status === 'finished' && finalLeaderboard.length">
|
||||||
<h3>{{ copy('player.final_leaderboard') }}</h3>
|
<h3>{{ copy('player.final_leaderboard') }}</h3>
|
||||||
<ol>
|
<ol>
|
||||||
@@ -261,7 +281,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeElements = document.querySelectorAll('audio,video') as
|
const activeElements = document.querySelectorAll('audio,video') as
|
||||||
| NodeListOf<GuardableMediaElement>
|
| NodeListOf<HTMLMediaElement>
|
||||||
| GuardableMediaElement[]
|
| GuardableMediaElement[]
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,56 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
|
|||||||
const host = asRecord(phase.host, 'session_detail.phase_view_model.host');
|
const host = asRecord(phase.host, 'session_detail.phase_view_model.host');
|
||||||
const player = asRecord(phase.player, 'session_detail.phase_view_model.player');
|
const player = asRecord(phase.player, 'session_detail.phase_view_model.player');
|
||||||
|
|
||||||
|
const revealRaw = root.reveal;
|
||||||
|
let reveal: SessionDetailResponse['reveal'] = null;
|
||||||
|
if (revealRaw !== null && revealRaw !== undefined) {
|
||||||
|
const revealRecord = asRecord(revealRaw, 'session_detail.reveal');
|
||||||
|
const liesRaw = revealRecord.lies;
|
||||||
|
const guessesRaw = revealRecord.guesses;
|
||||||
|
if (!Array.isArray(liesRaw)) {
|
||||||
|
throw new Error('Invalid API contract: expected array at session_detail.reveal.lies');
|
||||||
|
}
|
||||||
|
if (!Array.isArray(guessesRaw)) {
|
||||||
|
throw new Error('Invalid API contract: expected array at session_detail.reveal.guesses');
|
||||||
|
}
|
||||||
|
|
||||||
|
reveal = {
|
||||||
|
round_question_id: readNumber(revealRecord, 'round_question_id', 'session_detail.reveal'),
|
||||||
|
round_number: readNumber(revealRecord, 'round_number', 'session_detail.reveal'),
|
||||||
|
prompt: readString(revealRecord, 'prompt', 'session_detail.reveal'),
|
||||||
|
correct_answer: readString(revealRecord, 'correct_answer', 'session_detail.reveal'),
|
||||||
|
lies: liesRaw.map((lie, index) => {
|
||||||
|
const record = asRecord(lie, `session_detail.reveal.lies[${index}]`);
|
||||||
|
return {
|
||||||
|
player_id: readNumber(record, 'player_id', `session_detail.reveal.lies[${index}]`),
|
||||||
|
nickname: readString(record, 'nickname', `session_detail.reveal.lies[${index}]`),
|
||||||
|
text: readString(record, 'text', `session_detail.reveal.lies[${index}]`),
|
||||||
|
created_at: readString(record, 'created_at', `session_detail.reveal.lies[${index}]`)
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
guesses: guessesRaw.map((guess, index) => {
|
||||||
|
const record = asRecord(guess, `session_detail.reveal.guesses[${index}]`);
|
||||||
|
const fooledPlayerId = record.fooled_player_id;
|
||||||
|
if (fooledPlayerId !== null && !isNumber(fooledPlayerId)) {
|
||||||
|
throw new Error(`Invalid API contract: expected number|null at session_detail.reveal.guesses[${index}].fooled_player_id`);
|
||||||
|
}
|
||||||
|
const fooledPlayerNickname = record.fooled_player_nickname;
|
||||||
|
if (fooledPlayerNickname !== undefined && !isString(fooledPlayerNickname)) {
|
||||||
|
throw new Error(`Invalid API contract: expected string at session_detail.reveal.guesses[${index}].fooled_player_nickname`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
player_id: readNumber(record, 'player_id', `session_detail.reveal.guesses[${index}]`),
|
||||||
|
nickname: readString(record, 'nickname', `session_detail.reveal.guesses[${index}]`),
|
||||||
|
selected_text: readString(record, 'selected_text', `session_detail.reveal.guesses[${index}]`),
|
||||||
|
is_correct: readBoolean(record, 'is_correct', `session_detail.reveal.guesses[${index}]`),
|
||||||
|
fooled_player_id: fooledPlayerId,
|
||||||
|
...(fooledPlayerNickname === undefined ? {} : { fooled_player_nickname: fooledPlayerNickname }),
|
||||||
|
created_at: readString(record, 'created_at', `session_detail.reveal.guesses[${index}]`)
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session: {
|
session: {
|
||||||
code: readString(session, 'code', 'session_detail.session'),
|
code: readString(session, 'code', 'session_detail.session'),
|
||||||
@@ -129,6 +179,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
round_question: roundQuestion,
|
round_question: roundQuestion,
|
||||||
|
reveal,
|
||||||
phase_view_model: {
|
phase_view_model: {
|
||||||
status: readString(phase, 'status', 'session_detail.phase_view_model'),
|
status: readString(phase, 'status', 'session_detail.phase_view_model'),
|
||||||
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
|
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
|
||||||
|
|||||||
@@ -57,10 +57,37 @@ export interface PhaseViewModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RevealLie {
|
||||||
|
player_id: number;
|
||||||
|
nickname: string;
|
||||||
|
text: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevealGuess {
|
||||||
|
player_id: number;
|
||||||
|
nickname: string;
|
||||||
|
selected_text: string;
|
||||||
|
is_correct: boolean;
|
||||||
|
fooled_player_id: number | null;
|
||||||
|
fooled_player_nickname?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevealPayload {
|
||||||
|
round_question_id: number;
|
||||||
|
round_number: number;
|
||||||
|
prompt: string;
|
||||||
|
correct_answer: string;
|
||||||
|
lies: RevealLie[];
|
||||||
|
guesses: RevealGuess[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionDetailResponse {
|
export interface SessionDetailResponse {
|
||||||
session: SessionSummary;
|
session: SessionSummary;
|
||||||
players: SessionPlayer[];
|
players: SessionPlayer[];
|
||||||
round_question: SessionRoundQuestion | null;
|
round_question: SessionRoundQuestion | null;
|
||||||
|
reveal: RevealPayload | null;
|
||||||
phase_view_model: PhaseViewModel;
|
phase_view_model: PhaseViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user