diff --git a/frontend/angular/src/app/api-contract-smoke.spec.ts b/frontend/angular/src/app/api-contract-smoke.spec.ts
index 166c272..78a5fff 100644
--- a/frontend/angular/src/app/api-contract-smoke.spec.ts
+++ b/frontend/angular/src/app/api-contract-smoke.spec.ts
@@ -19,6 +19,24 @@ describe('SPA Angular API contract smoke (host/player foundation)', () => {
shown_at: '2026-03-01T18:00:00Z',
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: {
status: 'lobby',
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.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.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);
diff --git a/frontend/angular/src/app/features/host/host-shell.component.spec.ts b/frontend/angular/src/app/features/host/host-shell.component.spec.ts
index e29ff47..b6f54b8 100644
--- a/frontend/angular/src/app/features/host/host-shell.component.spec.ts
+++ b/frontend/angular/src/app/features/host/host-shell.component.spec.ts
@@ -12,7 +12,26 @@ function jsonResponse(status: number, body: unknown) {
} 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;
return {
@@ -37,6 +56,23 @@ function sessionDetailPayload(status: string, options?: { roundQuestionId?: numb
{ id: 1, nickname: 'Host', score: 0, 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: {
status,
round_number: 1,
@@ -101,6 +137,48 @@ describe('HostShellComponent gameplay wiring', () => {
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 () => {
const fetchMock: FetchMock = vi.fn().mockResolvedValue(jsonResponse(500, { error: 'Scoreboard unavailable' }));
diff --git a/frontend/angular/src/app/features/host/host-shell.component.ts b/frontend/angular/src/app/features/host/host-shell.component.ts
index 4253051..36b431d 100644
--- a/frontend/angular/src/app/features/host/host-shell.component.ts
+++ b/frontend/angular/src/app/features/host/host-shell.component.ts
@@ -3,15 +3,11 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
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 { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
-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 }>;
-}
+type SessionDetail = SessionDetailResponse;
type LeaderboardEntry = ScoreboardResponse['leaderboard'][number];
type LeaderboardResponse = FinishGameResponse;
@@ -52,6 +48,28 @@ type LeaderboardResponse = FinishGameResponse;
- {{ p.nickname }}: {{ p.score }}
+
+
Reveal
+
Korrekt svar: {{ session.reveal.correct_answer }}
+
Spørgsmål: {{ session.reveal.prompt }}
+
+
Løgne
+
+ - {{ lie.nickname }} løj: {{ lie.text }}
+
+
+
+
Gæt
+
+ -
+ {{ guess.nickname }} valgte {{ guess.selected_text }}
+ · korrekt
+ · narret af {{ guess.fooled_player_nickname }}
+ · forkert
+
+
+
+
{{ scoreboardPayload }}
{{ copy('host.final_leaderboard') }}
diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts
index 5bd1d1d..bbb092e 100644
--- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts
+++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts
@@ -13,7 +13,28 @@ function jsonResponse(status: number, body: unknown) {
} 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 roundQuestionId = options?.roundQuestionId ?? 11;
@@ -39,6 +60,23 @@ function sessionDetailPayload(status: string, options?: { answers?: string[]; pl
...player,
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: {
status,
round_number: 1,
@@ -158,6 +196,63 @@ describe('PlayerShellComponent gameplay wiring', () => {
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 () => {
const fetchMock: FetchMock = vi
.fn()
diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts
index 393723a..d7f485e 100644
--- a/frontend/angular/src/app/features/player/player-shell.component.ts
+++ b/frontend/angular/src/app/features/player/player-shell.component.ts
@@ -3,15 +3,12 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { createApiClient } from '../../../../../src/api/client';
+import type { SessionDetailResponse } from '../../../../../src/api/types';
import { createSessionContextStore } from '../../../../../src/spa/session-context-store';
import { createVerticalSliceController } from '../../../../../src/spa/vertical-slice';
import { clientHasNoAudioOutput, resolvePreferredLocale, subscribeToLocaleChanges, t } from '../../lobby-i18n';
-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 }>;
-}
+type SessionDetail = SessionDetailResponse;
type ConnectionState = 'online' | 'reconnecting' | 'offline';
type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null;
@@ -90,6 +87,29 @@ function resolveLocalStorage(): Storage | undefined {
+
+
Reveal
+
Korrekt svar: {{ session.reveal.correct_answer }}
+
Spørgsmål: {{ session.reveal.prompt }}
+
+
Løgne
+
+ - {{ lie.nickname }} løj: {{ lie.text }}
+
+
+
+
Gæt
+
+ -
+ {{ guess.nickname }} valgte {{ guess.selected_text }}
+ · korrekt
+ · narret af {{ guess.fooled_player_nickname }}
+ · forkert
+
+
+
+
+
{{ copy('player.final_leaderboard') }}
@@ -261,7 +281,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
const activeElements = document.querySelectorAll('audio,video') as
- | NodeListOf
+ | NodeListOf
| GuardableMediaElement[]
| undefined;
diff --git a/frontend/src/api/mappers.ts b/frontend/src/api/mappers.ts
index 538a264..1944ec3 100644
--- a/frontend/src/api/mappers.ts
+++ b/frontend/src/api/mappers.ts
@@ -102,6 +102,56 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
const host = asRecord(phase.host, 'session_detail.phase_view_model.host');
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 {
session: {
code: readString(session, 'code', 'session_detail.session'),
@@ -129,6 +179,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
};
}),
round_question: roundQuestion,
+ reveal,
phase_view_model: {
status: readString(phase, 'status', 'session_detail.phase_view_model'),
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 982928d..5a9a13a 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -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 {
session: SessionSummary;
players: SessionPlayer[];
round_question: SessionRoundQuestion | null;
+ reveal: RevealPayload | null;
phase_view_model: PhaseViewModel;
}