Merge pull request '[Gameplay] Canonical reveal payload for round question incl. who-fooled-whom' (#295) from dev/issue-289-canonical-reveal into main
All checks were successful
CI / test-and-quality (push) Successful in 2m25s

This commit was merged in pull request #295.
This commit is contained in:
2026-03-15 16:46:24 +01:00
16 changed files with 913 additions and 84 deletions

View File

@@ -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);

View File

@@ -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' }));

View File

@@ -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;
<ul>
<li *ngFor="let p of session.players">{{ p.nickname }}: {{ p.score }}</li>
</ul>
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
<h3>Reveal</h3>
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
<div *ngIf="session.reveal.lies.length">
<strong>Løgne</strong>
<ul>
<li *ngFor="let lie of session.reveal.lies">{{ lie.nickname }} løj: {{ lie.text }}</li>
</ul>
</div>
<div *ngIf="session.reveal.guesses.length">
<strong>Gæt</strong>
<ul>
<li *ngFor="let guess of session.reveal.guesses">
{{ guess.nickname }} valgte {{ guess.selected_text }}
<span *ngIf="guess.is_correct">· korrekt</span>
<span *ngIf="!guess.is_correct && guess.fooled_player_nickname">· narret af {{ guess.fooled_player_nickname }}</span>
<span *ngIf="!guess.is_correct && !guess.fooled_player_nickname">· forkert</span>
</li>
</ul>
</div>
</div>
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
<div *ngIf="finalLeaderboard.length">
<h3>{{ copy('host.final_leaderboard') }}</h3>

View File

@@ -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()

View File

@@ -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 {
<button *ngIf="submitError?.kind === 'guess'" (click)="submitGuess()" [disabled]="loading">{{ copy('player.retry_guess_submit') }}</button>
</ng-container>
<div class="panel" *ngIf="session.reveal && (session.session.status === 'reveal' || session.session.status === 'scoreboard')">
<h3>Reveal</h3>
<p><strong>Korrekt svar:</strong> {{ session.reveal.correct_answer }}</p>
<p><strong>Spørgsmål:</strong> {{ session.reveal.prompt }}</p>
<div *ngIf="session.reveal.lies.length">
<strong>Løgne</strong>
<ul>
<li *ngFor="let lie of session.reveal.lies">{{ lie.nickname }} løj: {{ lie.text }}</li>
</ul>
</div>
<div *ngIf="session.reveal.guesses.length">
<strong>Gæt</strong>
<ul>
<li *ngFor="let guess of session.reveal.guesses">
{{ guess.nickname }} valgte {{ guess.selected_text }}
<span *ngIf="guess.is_correct">· korrekt</span>
<span *ngIf="!guess.is_correct && guess.fooled_player_nickname">· narret af {{ guess.fooled_player_nickname }}</span>
<span *ngIf="!guess.is_correct && !guess.fooled_player_nickname">· forkert</span>
</li>
</ul>
</div>
</div>
<div *ngIf="showFinalLeaderboard && finalLeaderboard.length">
<h3>{{ copy('player.final_leaderboard') }}</h3>
<ol>
@@ -273,7 +285,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
const activeElements = document.querySelectorAll('audio,video') as
| NodeListOf<GuardableMediaElement>
| NodeListOf<HTMLMediaElement>
| GuardableMediaElement[]
| undefined;

View File

@@ -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<ApiResult<MixAnswersResponse>>;
calculateScores(code: string, roundQuestionId: number): Promise<ApiResult<CalculateScoresResponse>>;
getScoreboard(code: string): Promise<ApiResult<ScoreboardResponse>>;
startNextRound(code: string): Promise<ApiResult<NextRoundResponse>>;
startNextRound(code: string): Promise<ApiResult<StartNextRoundResponse>>;
finishGame(code: string): Promise<ApiResult<FinishGameResponse>>;
submitLie(code: string, roundQuestionId: number, payload: SubmitLieRequest): Promise<ApiResult<SubmitLieResponse>>;
submitGuess(code: string, roundQuestionId: number, payload: SubmitGuessRequest): Promise<ApiResult<SubmitGuessResponse>>;
@@ -167,10 +167,10 @@ export function createApiClient(baseUrl = '', fetchImpl: typeof fetch = fetch):
mapScoreboardResponse
),
startNextRound: (code: string) =>
request<NextRoundResponse>(
request<StartNextRoundResponse>(
`/lobby/sessions/${encodeURIComponent(normalizeCode(code))}/rounds/next`,
'POST',
mapNextRoundResponse,
mapStartNextRoundResponse,
{}
),
finishGame: (code: string) =>

View File

@@ -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'),

View File

@@ -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;
}

View File

@@ -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<AngularHttpClientLike['get']>(async <T>(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<AngularHttpClientLike['get']>(async <T>(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 }]

View File

@@ -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,

View File

@@ -16,6 +16,7 @@ function makeApiMock(overrides?: Partial<ApiClient>): 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>): 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 };

View File

@@ -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,
),
),
]

View File

@@ -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 = []

View File

@@ -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):

View File

@@ -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,
}

View File

@@ -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",