diff --git a/frontend/angular/src/app/api-contract-smoke.spec.ts b/frontend/angular/src/app/api-contract-smoke.spec.ts
index b630734..63b72b5 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,
@@ -178,6 +196,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(false);
expect(session.data.phase_view_model.player.can_submit_guess).toBe(false);
+ 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 fe0bc2e..82fb35f 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 fa3f5e9..89c6ac9 100644
--- a/frontend/angular/src/app/features/host/host-shell.component.ts
+++ b/frontend/angular/src/app/features/host/host-shell.component.ts
@@ -3,26 +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 }>;
- phase_view_model?: {
- host?: {
- can_start_round?: boolean;
- can_show_question?: boolean;
- can_mix_answers?: boolean;
- can_calculate_scores?: boolean;
- can_reveal_scoreboard?: boolean;
- can_start_next_round?: boolean;
- can_finish_game?: boolean;
- };
- };
-}
+type SessionDetail = SessionDetailResponse;
type LeaderboardEntry = ScoreboardResponse['leaderboard'][number];
type LeaderboardResponse = FinishGameResponse;
@@ -60,6 +45,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 c256895..94d7aeb 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 ed73fc5..bd33a58 100644
--- a/frontend/angular/src/app/features/player/player-shell.component.ts
+++ b/frontend/angular/src/app/features/player/player-shell.component.ts
@@ -3,23 +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 }>;
- phase_view_model?: {
- player?: {
- can_join?: boolean;
- can_submit_lie?: boolean;
- can_submit_guess?: boolean;
- can_view_final_result?: boolean;
- };
- };
-}
+type SessionDetail = SessionDetailResponse;
type ConnectionState = 'online' | 'reconnecting' | 'offline';
type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null;
@@ -102,6 +91,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') }}
@@ -273,7 +285,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
const activeElements = document.querySelectorAll('audio,video') as
- | NodeListOf
+ | NodeListOf
| GuardableMediaElement[]
| undefined;
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 397365c..65ef550 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -4,8 +4,8 @@ import {
mapHealthResponse,
mapJoinSessionResponse,
mapMixAnswersResponse,
- mapNextRoundResponse,
mapScoreboardResponse,
+ mapStartNextRoundResponse,
mapSessionDetailResponse,
mapShowQuestionResponse,
mapStartRoundResponse,
@@ -20,8 +20,8 @@ import type {
JoinSessionRequest,
JoinSessionResponse,
MixAnswersResponse,
- NextRoundResponse,
ScoreboardResponse,
+ StartNextRoundResponse,
SessionDetailResponse,
ShowQuestionResponse,
StartRoundRequest,
@@ -41,7 +41,7 @@ export interface ApiClient {
mixAnswers(code: string, roundQuestionId: number): Promise>;
calculateScores(code: string, roundQuestionId: number): Promise>;
getScoreboard(code: string): Promise>;
- startNextRound(code: string): Promise>;
+ startNextRound(code: string): Promise>;
finishGame(code: string): Promise>;
submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise>;
submitGuess(code: string, roundQuestionId: number, payload: SubmitGuessRequest): Promise>;
@@ -167,10 +167,10 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
mapScoreboardResponse
),
startNextRound: (code: string) =>
- request(
+ request(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`,
'POST',
- mapNextRoundResponse,
+ mapStartNextRoundResponse,
{}
),
finishGame: (code: string) =>
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;
}
diff --git a/frontend/tests/angular-api-client.test.ts b/frontend/tests/angular-api-client.test.ts
index 13a574b..d0f2aef 100644
--- a/frontend/tests/angular-api-client.test.ts
+++ b/frontend/tests/angular-api-client.test.ts
@@ -206,6 +206,83 @@ describe('createAngularApiClient', () => {
}
});
+ it('keeps canonical reveal payload stable when session detail is already in scoreboard phase', async () => {
+ const get = vi.fn(async (url: string) => {
+ if (url === '/lobby/sessions/ABCD12') {
+ return {
+ session: { code: 'ABCD12', status: 'scoreboard', host_id: 1, current_round: 1, players_count: 2 },
+ players: [
+ { id: 2, nickname: 'Maja', score: 10, is_connected: true },
+ { id: 3, nickname: 'Bo', score: 7, is_connected: true }
+ ],
+ round_question: {
+ id: 77,
+ round_number: 1,
+ prompt: 'Q?',
+ 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: 'scoreboard',
+ round_number: 1,
+ players_count: 2,
+ constraints: {
+ min_players_to_start: 2,
+ max_players_mvp: 8,
+ min_players_reached: true,
+ max_players_allowed: true
+ },
+ host: {
+ can_start_round: false,
+ can_show_question: false,
+ can_mix_answers: false,
+ can_calculate_scores: false,
+ can_reveal_scoreboard: false,
+ can_start_next_round: true,
+ can_finish_game: true
+ },
+ player: {
+ can_join: true,
+ can_submit_lie: false,
+ can_submit_guess: false,
+ can_view_final_result: false
+ }
+ }
+ } as T;
+ }
+ throw { status: 404, error: { error: 'Not found' } };
+ });
+
+ const client = createAngularApiClient({ get, post: vi.fn() } as unknown as AngularHttpClientLike);
+ const session = await client.getSession('abcd12');
+
+ expect(session.ok).toBe(true);
+ if (session.ok) {
+ expect(session.data.session.status).toBe('scoreboard');
+ expect(session.data.reveal?.guesses[0].fooled_player_nickname).toBe('Maja');
+ expect(session.data.phase_view_model.host.can_start_next_round).toBe(true);
+ expect(session.data.phase_view_model.host.can_finish_game).toBe(true);
+ }
+ });
+
it('maps host/player gameplay endpoints through typed response mappers', async () => {
const get = vi.fn(async (url: string) => {
if (url === '/lobby/sessions/ABCD12/scoreboard') {
@@ -245,7 +322,7 @@ describe('createAngularApiClient', () => {
if (url === '/lobby/sessions/ABCD12/questions/77/scores/calculate') {
expect(body).toEqual({});
return {
- session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
+ session: { code: 'ABCD12', status: 'scoreboard', current_round: 1 },
round_question: { id: 77, round_number: 1 },
events_created: 3,
leaderboard: [{ id: 2, nickname: 'Maja', score: 11 }]
diff --git a/frontend/tests/gameplay-phase-machine.test.ts b/frontend/tests/gameplay-phase-machine.test.ts
index 2d71a7b..420d822 100644
--- a/frontend/tests/gameplay-phase-machine.test.ts
+++ b/frontend/tests/gameplay-phase-machine.test.ts
@@ -40,6 +40,7 @@ describe('gameplay phase machine skeleton', () => {
session: { code: 'ABCD12', status: 'lie', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
+ reveal: null,
phase_view_model: {
status: 'lie',
round_number: 1,
@@ -74,6 +75,7 @@ describe('gameplay phase machine skeleton', () => {
session: { code: 'ABCD12', status: 'finished', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
+ reveal: null,
phase_view_model: {
status: 'finished',
round_number: 1,
diff --git a/frontend/tests/vertical-slice.test.ts b/frontend/tests/vertical-slice.test.ts
index 81544ee..f1c94f8 100644
--- a/frontend/tests/vertical-slice.test.ts
+++ b/frontend/tests/vertical-slice.test.ts
@@ -16,6 +16,7 @@ function makeApiMock(overrides?: Partial): ApiClient {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 3 },
players: [],
round_question: null,
+ reveal: null,
phase_view_model: {
status: 'lobby',
round_number: 1,
@@ -56,7 +57,15 @@ function makeApiMock(overrides?: Partial): ApiClient {
session: { code: 'ABCD12', status: 'lie', current_round: 1 },
round: { number: 1, category: { slug: 'history', name: 'History' } }
}
- })
+ }),
+ showQuestion: vi.fn(),
+ mixAnswers: vi.fn(),
+ calculateScores: vi.fn(),
+ getScoreboard: vi.fn(),
+ startNextRound: vi.fn(),
+ finishGame: vi.fn(),
+ submitLie: vi.fn(),
+ submitGuess: vi.fn()
};
return { ...base, ...overrides };
diff --git a/fupogfakta/migrations/0005_gamesession_scoreboard_status.py b/fupogfakta/migrations/0005_gamesession_scoreboard_status.py
new file mode 100644
index 0000000..d49f650
--- /dev/null
+++ b/fupogfakta/migrations/0005_gamesession_scoreboard_status.py
@@ -0,0 +1,26 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("fupogfakta", "0004_player_session_token"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="gamesession",
+ name="status",
+ field=models.CharField(
+ choices=[
+ ("lobby", "Lobby"),
+ ("lie", "Løgnfase"),
+ ("guess", "Gættefase"),
+ ("reveal", "Reveal"),
+ ("scoreboard", "Scoreboard"),
+ ("finished", "Afsluttet"),
+ ],
+ default="lobby",
+ max_length=16,
+ ),
+ ),
+ ]
diff --git a/fupogfakta/migrations/0006_merge_20260315_1249.py b/fupogfakta/migrations/0006_merge_20260315_1249.py
new file mode 100644
index 0000000..863c15a
--- /dev/null
+++ b/fupogfakta/migrations/0006_merge_20260315_1249.py
@@ -0,0 +1,10 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("fupogfakta", "0005_alter_gamesession_status"),
+ ("fupogfakta", "0005_gamesession_scoreboard_status"),
+ ]
+
+ operations = []
diff --git a/lobby/tests.py b/lobby/tests.py
index ac3afbf..4322a32 100644
--- a/lobby/tests.py
+++ b/lobby/tests.py
@@ -745,6 +745,8 @@ class ScoreCalculationTests(TestCase):
self.player_three = Player.objects.create(session=self.session, nickname="Nora")
def test_host_can_calculate_scores_and_transition_to_reveal(self):
+ LieAnswer.objects.create(round_question=self.round_question, player=self.player_three, text="Padel")
+
Guess.objects.create(round_question=self.round_question, player=self.player_one, selected_text="Tennis", is_correct=True)
Guess.objects.create(
round_question=self.round_question,
@@ -773,6 +775,50 @@ class ScoreCalculationTests(TestCase):
payload = response.json()
self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL)
self.assertEqual(payload["events_created"], 2)
+ self.assertEqual(payload["reveal"]["round_question_id"], self.round_question.id)
+ self.assertEqual(payload["reveal"]["correct_answer"], "Tennis")
+ self.assertEqual(
+ payload["reveal"]["lies"],
+ [
+ {
+ "player_id": self.player_three.id,
+ "nickname": "Nora",
+ "text": "Padel",
+ "created_at": payload["reveal"]["lies"][0]["created_at"],
+ }
+ ],
+ )
+ self.assertEqual(
+ payload["reveal"]["guesses"],
+ [
+ {
+ "player_id": self.player_one.id,
+ "nickname": "Luna",
+ "selected_text": "Tennis",
+ "is_correct": True,
+ "created_at": payload["reveal"]["guesses"][0]["created_at"],
+ "fooled_player_id": None,
+ },
+ {
+ "player_id": self.player_two.id,
+ "nickname": "Mads",
+ "selected_text": "Padel",
+ "is_correct": False,
+ "created_at": payload["reveal"]["guesses"][1]["created_at"],
+ "fooled_player_id": self.player_three.id,
+ "fooled_player_nickname": "Nora",
+ },
+ {
+ "player_id": self.player_three.id,
+ "nickname": "Nora",
+ "selected_text": "Padel",
+ "is_correct": False,
+ "created_at": payload["reveal"]["guesses"][2]["created_at"],
+ "fooled_player_id": self.player_three.id,
+ "fooled_player_nickname": "Nora",
+ },
+ ],
+ )
self.player_one.refresh_from_db()
self.player_three.refresh_from_db()
@@ -1266,6 +1312,17 @@ class UiScreenTests(TestCase):
'var HOST_SHELL_ROUTES={lobby:"lobby",lie:"lie",guess:"guess",reveal:"reveal",scoreboard:"scoreboard",finished:"finished"};',
)
+ def test_host_screen_template_gates_next_round_and_finish_on_scoreboard_phase(self):
+ self.client.login(username="host_ui", password="secret123")
+
+ response = self.client.get(reverse("lobby:host_screen"))
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, 'if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}')
+ self.assertContains(response, 'if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="scoreboard";}')
+ self.assertNotContains(response, 'if(nextRoundBtn){nextRoundBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}')
+ self.assertNotContains(response, 'if(finishGameBtn){finishGameBtn.disabled=hostActionInFlight||sessionDetailInFlight||!hasCode||phase!=="reveal";}')
+
@override_settings(USE_SPA_UI=True)
def test_host_screen_deeplink_normalizes_redundant_slashes_when_feature_flag_enabled(self):
self.client.login(username="host_ui", password="secret123")
@@ -1325,7 +1382,129 @@ class SessionDetailRoundQuestionTests(TestCase):
self.assertEqual(payload["round_question"]["id"], round_question.id)
self.assertEqual(payload["round_question"]["prompt"], self.question.prompt)
+ def test_session_detail_includes_canonical_reveal_payload_in_reveal_phase(self):
+ self.session.status = GameSession.Status.REVEAL
+ self.session.save(update_fields=["status"])
+ round_question = RoundQuestion.objects.create(
+ session=self.session,
+ round_number=1,
+ question=self.question,
+ correct_answer=self.question.correct_answer,
+ )
+ liar = Player.objects.create(session=self.session, nickname="Løgnhals")
+ guesser = Player.objects.create(session=self.session, nickname="Detektiv")
+ correct_player = Player.objects.create(session=self.session, nickname="Sandhed")
+ LieAnswer.objects.create(round_question=round_question, player=liar, text="Tesla")
+ Guess.objects.create(
+ round_question=round_question,
+ player=guesser,
+ selected_text="Tesla",
+ is_correct=False,
+ fooled_player=liar,
+ )
+ Guess.objects.create(
+ round_question=round_question,
+ player=correct_player,
+ selected_text="Edison",
+ is_correct=True,
+ )
+ response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
+
+ self.assertEqual(response.status_code, 200)
+ payload = response.json()
+ self.assertEqual(payload["reveal"]["round_question_id"], round_question.id)
+ self.assertEqual(payload["reveal"]["correct_answer"], "Edison")
+ self.assertEqual(payload["reveal"]["lies"][0]["player_id"], liar.id)
+ self.assertEqual(payload["reveal"]["lies"][0]["nickname"], "Løgnhals")
+ self.assertEqual(payload["reveal"]["lies"][0]["text"], "Tesla")
+ self.assertEqual(payload["reveal"]["guesses"][0]["player_id"], guesser.id)
+ self.assertEqual(payload["reveal"]["guesses"][0]["selected_text"], "Tesla")
+ self.assertFalse(payload["reveal"]["guesses"][0]["is_correct"])
+ self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_id"], liar.id)
+ self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_nickname"], "Løgnhals")
+ self.assertEqual(payload["reveal"]["guesses"][1]["player_id"], correct_player.id)
+ self.assertEqual(payload["reveal"]["guesses"][1]["selected_text"], "Edison")
+ self.assertTrue(payload["reveal"]["guesses"][1]["is_correct"])
+ self.assertIsNone(payload["reveal"]["guesses"][1]["fooled_player_id"])
+
+ def test_session_detail_includes_canonical_reveal_payload_in_scoreboard_phase(self):
+ self.session.status = GameSession.Status.SCOREBOARD
+ self.session.save(update_fields=["status"])
+ round_question = RoundQuestion.objects.create(
+ session=self.session,
+ round_number=1,
+ question=self.question,
+ correct_answer=self.question.correct_answer,
+ )
+ liar = Player.objects.create(session=self.session, nickname="Løgnhals")
+ guesser = Player.objects.create(session=self.session, nickname="Detektiv")
+ correct_player = Player.objects.create(session=self.session, nickname="Sandhed")
+ LieAnswer.objects.create(round_question=round_question, player=liar, text="Tesla")
+ Guess.objects.create(
+ round_question=round_question,
+ player=guesser,
+ selected_text="Tesla",
+ is_correct=False,
+ fooled_player=liar,
+ )
+ Guess.objects.create(
+ round_question=round_question,
+ player=correct_player,
+ selected_text="Edison",
+ is_correct=True,
+ )
+
+ response = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code}))
+
+ self.assertEqual(response.status_code, 200)
+ payload = response.json()
+ self.assertEqual(payload["session"]["status"], GameSession.Status.SCOREBOARD)
+ self.assertEqual(payload["reveal"]["round_question_id"], round_question.id)
+ self.assertEqual(payload["reveal"]["correct_answer"], "Edison")
+ self.assertEqual(payload["reveal"]["lies"][0]["player_id"], liar.id)
+ self.assertEqual(payload["reveal"]["lies"][0]["nickname"], "Løgnhals")
+ self.assertEqual(payload["reveal"]["lies"][0]["text"], "Tesla")
+ self.assertEqual(payload["reveal"]["guesses"][0]["player_id"], guesser.id)
+ self.assertEqual(payload["reveal"]["guesses"][0]["selected_text"], "Tesla")
+ self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_id"], liar.id)
+ self.assertEqual(payload["reveal"]["guesses"][0]["fooled_player_nickname"], "Løgnhals")
+ self.assertTrue(payload["reveal"]["guesses"][1]["is_correct"])
+ self.assertEqual(payload["reveal"]["guesses"][1]["selected_text"], "Edison")
+ self.assertIsNone(payload["reveal"]["guesses"][1]["fooled_player_id"])
+ self.assertIsNone(payload["reveal"]["guesses"][1].get("fooled_player_nickname"))
+
+ def test_session_detail_preserves_canonical_reveal_payload_across_reveal_and_scoreboard(self):
+ round_question = RoundQuestion.objects.create(
+ session=self.session,
+ round_number=1,
+ question=self.question,
+ correct_answer=self.question.correct_answer,
+ )
+ liar = Player.objects.create(session=self.session, nickname="Løgnhals")
+ guesser = Player.objects.create(session=self.session, nickname="Detektiv")
+ LieAnswer.objects.create(round_question=round_question, player=liar, text="Tesla")
+ Guess.objects.create(
+ round_question=round_question,
+ player=guesser,
+ selected_text="Tesla",
+ is_correct=False,
+ fooled_player=liar,
+ )
+
+ self.session.status = GameSession.Status.REVEAL
+ self.session.save(update_fields=["status"])
+ reveal_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
+
+ self.session.status = GameSession.Status.SCOREBOARD
+ self.session.save(update_fields=["status"])
+ scoreboard_payload = self.client.get(reverse("lobby:session_detail", kwargs={"code": self.session.code})).json()
+
+ self.assertEqual(reveal_payload["reveal"], scoreboard_payload["reveal"])
+ self.assertTrue(reveal_payload["phase_view_model"]["host"]["can_reveal_scoreboard"])
+ self.assertFalse(scoreboard_payload["phase_view_model"]["host"]["can_reveal_scoreboard"])
+ self.assertFalse(reveal_payload["phase_view_model"]["host"]["can_start_next_round"])
+ self.assertTrue(scoreboard_payload["phase_view_model"]["host"]["can_start_next_round"])
class SessionDetailPhaseViewModelTests(TestCase):
diff --git a/lobby/views.py b/lobby/views.py
index dbfb20a..7899c85 100644
--- a/lobby/views.py
+++ b/lobby/views.py
@@ -36,6 +36,7 @@ JOINABLE_STATUSES = {
+
def _json_body(request: HttpRequest) -> dict:
if not request.body:
return {}
@@ -63,6 +64,54 @@ def _create_unique_session_code() -> str:
raise RuntimeError("Could not generate unique session code")
+def _build_player_ref(player: Player | None) -> dict | None:
+ if player is None:
+ return None
+
+ return {
+ "player_id": player.id,
+ "nickname": player.nickname,
+ }
+
+
+
+def _build_reveal_payload(round_question: RoundQuestion | None) -> dict | None:
+ if round_question is None:
+ return None
+
+ lies = [
+ {
+ **_build_player_ref(lie.player),
+ "text": lie.text,
+ "created_at": lie.created_at.isoformat(),
+ }
+ for lie in round_question.lies.select_related("player").order_by("created_at", "id")
+ ]
+
+ guesses = []
+ for guess in round_question.guesses.select_related("player", "fooled_player").order_by("created_at", "id"):
+ guess_payload = {
+ **_build_player_ref(guess.player),
+ "selected_text": guess.selected_text,
+ "is_correct": guess.is_correct,
+ "created_at": guess.created_at.isoformat(),
+ "fooled_player_id": guess.fooled_player_id,
+ }
+ if guess.fooled_player is not None:
+ guess_payload["fooled_player_nickname"] = guess.fooled_player.nickname
+ guesses.append(guess_payload)
+
+ return {
+ "round_question_id": round_question.id,
+ "round_number": round_question.round_number,
+ "prompt": round_question.question.prompt,
+ "correct_answer": round_question.correct_answer,
+ "lies": lies,
+ "guesses": guesses,
+ }
+
+
+
def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
status = session.status
in_lobby = status == GameSession.Status.LOBBY
@@ -241,6 +290,9 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
},
"players": players,
"round_question": round_question_payload,
+ "reveal": _build_reveal_payload(current_round_question)
+ if session.status in {GameSession.Status.REVEAL, GameSession.Status.SCOREBOARD} and current_round_question
+ else None,
"phase_view_model": phase_view_model,
}
)
@@ -989,6 +1041,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
"id": round_question.id,
"round_number": round_question.round_number,
},
+ "reveal": _build_reveal_payload(round_question),
"events_created": len(score_events),
"leaderboard": leaderboard,
}
diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json
index 17b0b3f..2b5d19a 100644
--- a/shared/i18n/lobby.json
+++ b/shared/i18n/lobby.json
@@ -277,16 +277,47 @@
},
"backend": {
"error_codes": {
- "session_code_required": "session_code_required",
+ "calculate_scores_invalid_phase": "calculate_scores_invalid_phase",
+ "category_has_no_questions": "category_has_no_questions",
+ "category_not_found": "category_not_found",
+ "category_slug_required": "category_slug_required",
+ "finish_game_invalid_phase": "finish_game_invalid_phase",
+ "guess_already_submitted": "guess_already_submitted",
+ "guess_submission_invalid_phase": "guess_submission_invalid_phase",
+ "guess_submission_window_closed": "guess_submission_window_closed",
+ "host_only_calculate_scores": "host_only_calculate_scores",
+ "host_only_finish_game": "host_only_finish_game",
+ "host_only_mix_answers": "host_only_mix_answers",
+ "host_only_show_question": "host_only_show_question",
+ "host_only_start_next_round": "host_only_start_next_round",
+ "host_only_start_round": "host_only_start_round",
+ "host_only_view_scoreboard": "host_only_view_scoreboard",
+ "invalid_player_session_token": "invalid_player_session_token",
+ "lie_already_submitted": "lie_already_submitted",
+ "lie_submission_invalid_phase": "lie_submission_invalid_phase",
+ "lie_submission_window_closed": "lie_submission_window_closed",
+ "lie_text_invalid": "lie_text_invalid",
+ "mix_answers_invalid_phase": "mix_answers_invalid_phase",
"nickname_invalid": "nickname_invalid",
+ "nickname_taken": "nickname_taken",
+ "no_available_questions": "no_available_questions",
+ "no_guesses_submitted": "no_guesses_submitted",
+ "not_enough_answers_to_mix": "not_enough_answers_to_mix",
+ "player_id_required": "player_id_required",
+ "player_not_found_in_session": "player_not_found_in_session",
+ "question_already_shown": "question_already_shown",
+ "round_already_configured": "round_already_configured",
+ "round_config_missing": "round_config_missing",
+ "round_question_not_found": "round_question_not_found",
+ "round_start_invalid_phase": "round_start_invalid_phase",
+ "scoreboard_invalid_phase": "scoreboard_invalid_phase",
+ "scores_already_calculated": "scores_already_calculated",
+ "selected_answer_invalid": "selected_answer_invalid",
+ "selected_text_invalid": "selected_text_invalid",
+ "session_code_required": "session_code_required",
"session_not_found": "session_not_found",
"session_not_joinable": "session_not_joinable",
- "nickname_taken": "nickname_taken",
- "category_slug_required": "category_slug_required",
- "category_not_found": "category_not_found",
- "round_start_invalid_phase": "round_start_invalid_phase",
- "round_already_configured": "round_already_configured",
- "category_has_no_questions": "category_has_no_questions",
+ "session_token_required": "session_token_required",
"show_question_invalid_phase": "show_question_invalid_phase",
"round_config_missing": "round_config_missing",
"question_already_shown": "question_already_shown",
@@ -322,14 +353,158 @@
"guess_submission_invalid_phase": "guess_submission_invalid_phase"
},
"errors": {
- "session_code_required": {
- "en": "Session code is required",
- "da": "Sessionskode er påkrævet"
+ "calculate_scores_invalid_phase": {
+ "en": "Scores can only be calculated in guess phase.",
+ "da": "Score kan kun udregnes i gættefasen."
+ },
+ "category_has_no_questions": {
+ "en": "Category has no active questions",
+ "da": "Kategorien har ingen aktive spørgsmål"
+ },
+ "category_not_found": {
+ "en": "Category not found",
+ "da": "Kategori blev ikke fundet"
+ },
+ "category_slug_required": {
+ "en": "category_slug is required",
+ "da": "category_slug er påkrævet"
+ },
+ "finish_game_invalid_phase": {
+ "en": "Game can only be finished from scoreboard phase.",
+ "da": "Spillet kan kun afsluttes fra scoreboard-fasen."
+ },
+ "guess_already_submitted": {
+ "en": "Guess has already been submitted for this player.",
+ "da": "Gættet er allerede indsendt for denne spiller."
+ },
+ "guess_submission_invalid_phase": {
+ "en": "Guess submission is only allowed in guess phase.",
+ "da": "Gæt kan kun sendes i gættefasen."
+ },
+ "guess_submission_window_closed": {
+ "en": "Guess submission window has closed.",
+ "da": "Vinduet for gætindsendelse er lukket."
+ },
+ "host_only_calculate_scores": {
+ "en": "Only the host can calculate scores.",
+ "da": "Kun værten kan udregne score."
+ },
+ "host_only_finish_game": {
+ "en": "Only the host can finish the game.",
+ "da": "Kun værten kan afslutte spillet."
+ },
+ "host_only_mix_answers": {
+ "en": "Only host can mix answers",
+ "da": "Kun værten kan blande svar"
+ },
+ "host_only_show_question": {
+ "en": "Only host can show question",
+ "da": "Kun værten kan vise spørgsmålet"
+ },
+ "host_only_start_next_round": {
+ "en": "Only the host can start the next round.",
+ "da": "Kun værten kan starte næste runde."
+ },
+ "host_only_start_round": {
+ "en": "Only host can start round",
+ "da": "Kun værten kan starte runden"
+ },
+ "host_only_view_scoreboard": {
+ "en": "Only the host can view the scoreboard.",
+ "da": "Kun værten kan se scoreboardet."
+ },
+ "invalid_player_session_token": {
+ "en": "Player session token is invalid.",
+ "da": "Spillerens session-token er ugyldigt."
+ },
+ "lie_already_submitted": {
+ "en": "Lie has already been submitted for this player.",
+ "da": "Løgnen er allerede indsendt for denne spiller."
+ },
+ "lie_submission_invalid_phase": {
+ "en": "Lie submission is only allowed in lie phase.",
+ "da": "Løgn kan kun sendes i løgnefasen."
+ },
+ "lie_submission_window_closed": {
+ "en": "Lie submission window has closed.",
+ "da": "Vinduet for løgnindsendelse er lukket."
+ },
+ "lie_text_invalid": {
+ "en": "Text must be between 1 and 255 characters.",
+ "da": "Tekst skal være mellem 1 og 255 tegn."
+ },
+ "mix_answers_invalid_phase": {
+ "en": "Answers can only be mixed in lie or guess phase",
+ "da": "Svar kan kun blandes i løgne- eller gættefasen"
},
"nickname_invalid": {
"en": "Nickname must be between 2 and 40 characters",
"da": "Kaldenavn skal være mellem 2 og 40 tegn"
},
+ "nickname_taken": {
+ "en": "Nickname already taken",
+ "da": "Kaldenavnet er allerede taget"
+ },
+ "no_available_questions": {
+ "en": "No available questions in category",
+ "da": "Ingen tilgængelige spørgsmål i kategorien"
+ },
+ "no_guesses_submitted": {
+ "en": "No guesses have been submitted for this round question.",
+ "da": "Der er ikke indsendt gæt for dette rundespørgsmål."
+ },
+ "not_enough_answers_to_mix": {
+ "en": "Not enough answers to mix",
+ "da": "Ikke nok svar at blande"
+ },
+ "player_id_required": {
+ "en": "Player id is required.",
+ "da": "Spiller-id er påkrævet."
+ },
+ "player_not_found_in_session": {
+ "en": "Player was not found in this session.",
+ "da": "Spilleren blev ikke fundet i denne session."
+ },
+ "question_already_shown": {
+ "en": "Question already shown for this round",
+ "da": "Spørgsmålet er allerede vist for denne runde"
+ },
+ "round_already_configured": {
+ "en": "Round already configured",
+ "da": "Runden er allerede konfigureret"
+ },
+ "round_config_missing": {
+ "en": "Round config missing",
+ "da": "Rundekonfiguration mangler"
+ },
+ "round_question_not_found": {
+ "en": "Round question not found",
+ "da": "Rundespørgsmål blev ikke fundet"
+ },
+ "round_start_invalid_phase": {
+ "en": "Round can only be started from lobby",
+ "da": "Runden kan kun startes fra lobbyen"
+ },
+ "scoreboard_invalid_phase": {
+ "en": "Scoreboard is only available in scoreboard phase.",
+ "da": "Scoreboard er kun tilgængeligt i scoreboard-fasen."
+ },
+ "scores_already_calculated": {
+ "en": "Scores have already been calculated for this round question.",
+ "da": "Score er allerede udregnet for dette rundespørgsmål."
+ },
+ "selected_answer_invalid": {
+ "en": "Selected answer is not part of this round.",
+ "da": "Det valgte svar er ikke en del af denne runde."
+ },
+ "selected_text_invalid": {
+ "en": "Selected text must be between 1 and 255 characters.",
+ "da": "Valgt tekst skal være mellem 1 og 255 tegn."
+ },
+ "session_code_required": {
+ "en": "Session code is required",
+ "da": "Sessionskode er påkrævet"
+ },
"session_not_found": {
"en": "Session not found",
"da": "Session blev ikke fundet"
@@ -338,29 +513,9 @@
"en": "Session is not joinable",
"da": "Sessionen kan ikke joine nu"
},
- "nickname_taken": {
- "en": "Nickname already taken",
- "da": "Kaldenavnet er allerede taget"
- },
- "category_slug_required": {
- "en": "category_slug is required",
- "da": "category_slug er påkrævet"
- },
- "category_not_found": {
- "en": "Category not found",
- "da": "Kategori blev ikke fundet"
- },
- "round_start_invalid_phase": {
- "en": "Round can only be started from lobby",
- "da": "Runden kan kun startes fra lobbyen"
- },
- "round_already_configured": {
- "en": "Round already configured",
- "da": "Runden er allerede konfigureret"
- },
- "category_has_no_questions": {
- "en": "Category has no active questions",
- "da": "Kategorien har ingen aktive spørgsmål"
+ "session_token_required": {
+ "en": "Session token is required.",
+ "da": "Session-token er påkrævet."
},
"show_question_invalid_phase": {
"en": "Question can only be shown in lie phase",
@@ -511,20 +666,48 @@
"fallback": "Use default locale when requested locale is unsupported or key translation is missing."
},
"backend_to_frontend_error_keys": {
- "session_code_required": "session_code_required",
+ "calculate_scores_invalid_phase": "unknown",
+ "category_has_no_questions": "start_round_failed",
+ "category_not_found": "start_round_failed",
+ "category_slug_required": "start_round_failed",
+ "finish_game_invalid_phase": "unknown",
+ "guess_already_submitted": "unknown",
+ "guess_submission_invalid_phase": "unknown",
+ "guess_submission_window_closed": "unknown",
+ "host_only_action": "start_round_failed",
+ "host_only_calculate_scores": "unknown",
+ "host_only_finish_game": "unknown",
+ "host_only_mix_answers": "start_round_failed",
+ "host_only_show_question": "start_round_failed",
+ "host_only_start_next_round": "unknown",
+ "host_only_start_round": "start_round_failed",
+ "host_only_view_scoreboard": "unknown",
+ "invalid_player_session_token": "unknown",
+ "lie_already_submitted": "unknown",
+ "lie_submission_invalid_phase": "unknown",
+ "lie_submission_window_closed": "unknown",
+ "lie_text_invalid": "unknown",
+ "mix_answers_invalid_phase": "start_round_failed",
"nickname_invalid": "nickname_invalid",
+ "nickname_taken": "nickname_taken",
+ "no_available_questions": "start_round_failed",
+ "no_guesses_submitted": "unknown",
+ "not_enough_answers_to_mix": "start_round_failed",
+ "player_id_required": "unknown",
+ "player_not_found_in_session": "unknown",
+ "question_already_shown": "start_round_failed",
+ "round_already_configured": "start_round_failed",
+ "round_config_missing": "start_round_failed",
+ "round_question_not_found": "start_round_failed",
+ "round_start_invalid_phase": "start_round_failed",
+ "scoreboard_invalid_phase": "unknown",
+ "scores_already_calculated": "unknown",
+ "selected_answer_invalid": "unknown",
+ "selected_text_invalid": "unknown",
+ "session_code_required": "session_code_required",
"session_not_found": "session_not_found",
"session_not_joinable": "join_failed",
- "nickname_taken": "nickname_taken",
- "category_slug_required": "start_round_failed",
- "category_not_found": "start_round_failed",
- "round_start_invalid_phase": "start_round_failed",
- "round_already_configured": "start_round_failed",
- "host_only_start_round": "start_round_failed",
- "host_only_show_question": "start_round_failed",
- "host_only_mix_answers": "start_round_failed",
- "host_only_action": "start_round_failed",
- "category_has_no_questions": "start_round_failed",
+ "session_token_required": "unknown",
"show_question_invalid_phase": "start_round_failed",
"round_config_missing": "start_round_failed",
"question_already_shown": "start_round_failed",