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; +
+

Reveal

+

Korrekt svar: {{ session.reveal.correct_answer }}

+

Spørgsmål: {{ session.reveal.prompt }}

+
+ Løgne + +
+
+ Gæt + +
+
{{ 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",