2 Commits

Author SHA1 Message Date
2c524d7d2d feat: add canonical reveal payload for issue 289
Some checks failed
CI / test-and-quality (push) Failing after 2m14s
2026-03-15 11:13:57 +00:00
f0ebc25da7 feat(gameplay): expose canonical reveal payload in frontend contract
Some checks failed
CI / test-and-quality (push) Failing after 2m8s
2026-03-13 17:49:13 +00:00
5 changed files with 411 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ import type {
HealthResponse, HealthResponse,
JoinSessionResponse, JoinSessionResponse,
MixAnswersResponse, MixAnswersResponse,
RevealPayload,
ScoreboardResponse, ScoreboardResponse,
SessionDetailResponse, SessionDetailResponse,
ShowQuestionResponse, ShowQuestionResponse,
@@ -68,6 +69,57 @@ export function mapHealthResponse(payload: unknown): HealthResponse {
}; };
} }
function mapRevealPayload(payload: unknown, path: string): RevealPayload {
const reveal = asRecord(payload, path);
const liesRaw = reveal.lies;
const guessesRaw = reveal.guesses;
if (!Array.isArray(liesRaw)) {
throw new Error(`Invalid API contract: expected array at ${path}.lies`);
}
if (!Array.isArray(guessesRaw)) {
throw new Error(`Invalid API contract: expected array at ${path}.guesses`);
}
return {
round_question_id: readNumber(reveal, 'round_question_id', path),
round_number: readNumber(reveal, 'round_number', path),
prompt: readString(reveal, 'prompt', path),
correct_answer: readString(reveal, 'correct_answer', path),
lies: liesRaw.map((lie, index) => {
const liePath = `${path}.lies[${index}]`;
const record = asRecord(lie, liePath);
return {
player_id: readNumber(record, 'player_id', liePath),
nickname: readString(record, 'nickname', liePath),
text: readString(record, 'text', liePath),
created_at: readString(record, 'created_at', liePath)
};
}),
guesses: guessesRaw.map((guess, index) => {
const guessPath = `${path}.guesses[${index}]`;
const record = asRecord(guess, guessPath);
const fooledPlayerId = record.fooled_player_id;
if (fooledPlayerId !== null && !isNumber(fooledPlayerId)) {
throw new Error(`Invalid API contract: expected number|null at ${guessPath}.fooled_player_id`);
}
const fooledPlayerNickname = record.fooled_player_nickname;
if (fooledPlayerNickname !== undefined && !isString(fooledPlayerNickname)) {
throw new Error(`Invalid API contract: expected string|undefined at ${guessPath}.fooled_player_nickname`);
}
return {
player_id: readNumber(record, 'player_id', guessPath),
nickname: readString(record, 'nickname', guessPath),
selected_text: readString(record, 'selected_text', guessPath),
is_correct: readBoolean(record, 'is_correct', guessPath),
created_at: readString(record, 'created_at', guessPath),
fooled_player_id: fooledPlayerId,
...(fooledPlayerNickname === undefined ? {} : { fooled_player_nickname: fooledPlayerNickname })
};
})
};
}
function mapSessionDetail(payload: unknown): SessionDetailResponse { function mapSessionDetail(payload: unknown): SessionDetailResponse {
const root = asRecord(payload, 'session_detail'); const root = asRecord(payload, 'session_detail');
const session = asRecord(root.session, 'session_detail.session'); const session = asRecord(root.session, 'session_detail.session');
@@ -97,6 +149,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
}; };
} }
const revealRaw = root.reveal;
const phase = asRecord(root.phase_view_model, 'session_detail.phase_view_model'); const phase = asRecord(root.phase_view_model, 'session_detail.phase_view_model');
const constraints = asRecord(phase.constraints, 'session_detail.phase_view_model.constraints'); const constraints = asRecord(phase.constraints, 'session_detail.phase_view_model.constraints');
const host = asRecord(phase.host, 'session_detail.phase_view_model.host'); const host = asRecord(phase.host, 'session_detail.phase_view_model.host');
@@ -129,6 +182,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
}; };
}), }),
round_question: roundQuestion, round_question: roundQuestion,
reveal: revealRaw === null || revealRaw === undefined ? null : mapRevealPayload(revealRaw, 'session_detail.reveal'),
phase_view_model: { phase_view_model: {
status: readString(phase, 'status', 'session_detail.phase_view_model'), status: readString(phase, 'status', 'session_detail.phase_view_model'),
round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'), round_number: readNumber(phase, 'round_number', 'session_detail.phase_view_model'),
@@ -276,6 +330,7 @@ export function mapCalculateScoresResponse(payload: unknown): CalculateScoresRes
round_number: readNumber(roundQuestion, 'round_number', 'calculate_scores.round_question') round_number: readNumber(roundQuestion, 'round_number', 'calculate_scores.round_question')
}, },
events_created: readNumber(root, 'events_created', 'calculate_scores'), events_created: readNumber(root, 'events_created', 'calculate_scores'),
reveal: mapRevealPayload(root.reveal, 'calculate_scores.reveal'),
leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `calculate_scores.leaderboard[${index}]`)) leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `calculate_scores.leaderboard[${index}]`))
}; };
} }

View File

@@ -57,10 +57,38 @@ export interface PhaseViewModel {
}; };
} }
export interface RevealPlayerRef {
player_id: number;
nickname: string;
}
export interface RevealLie extends RevealPlayerRef {
text: string;
created_at: string;
}
export interface RevealGuess extends RevealPlayerRef {
selected_text: string;
is_correct: boolean;
created_at: string;
fooled_player_id: number | null;
fooled_player_nickname?: string;
}
export interface RevealPayload {
round_question_id: number;
round_number: number;
prompt: string;
correct_answer: string;
lies: RevealLie[];
guesses: RevealGuess[];
}
export interface SessionDetailResponse { export interface SessionDetailResponse {
session: SessionSummary; session: SessionSummary;
players: SessionPlayer[]; players: SessionPlayer[];
round_question: SessionRoundQuestion | null; round_question: SessionRoundQuestion | null;
reveal: RevealPayload | null;
phase_view_model: PhaseViewModel; phase_view_model: PhaseViewModel;
} }
@@ -138,6 +166,7 @@ export interface CalculateScoresResponse {
round_number: number; round_number: number;
}; };
events_created: number; events_created: number;
reveal: RevealPayload;
leaderboard: Array<{ id: number; nickname: string; score: number }>; leaderboard: Array<{ id: number; nickname: string; score: number }>;
} }

View File

@@ -17,6 +17,7 @@ describe('createAngularApiClient', () => {
{ id: 3, nickname: 'Bo', score: 0, is_connected: false } { id: 3, nickname: 'Bo', score: 0, is_connected: false }
], ],
round_question: null, round_question: null,
reveal: null,
phase_view_model: { phase_view_model: {
status: 'lobby', status: 'lobby',
round_number: 1, round_number: 1,
@@ -84,6 +85,7 @@ describe('createAngularApiClient', () => {
if (session.ok) { if (session.ok) {
expect(session.data.session.code).toBe('ABCD12'); expect(session.data.session.code).toBe('ABCD12');
expect(session.data.session.host_id).toBe(1); expect(session.data.session.host_id).toBe(1);
expect(session.data.reveal).toBeNull();
expect(session.data.phase_view_model.host.can_start_round).toBe(true); expect(session.data.phase_view_model.host.can_start_round).toBe(true);
} }
@@ -119,6 +121,7 @@ describe('createAngularApiClient', () => {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 }, session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
players: [], players: [],
round_question: null, round_question: null,
reveal: null,
phase_view_model: { phase_view_model: {
status: 'lobby', status: 'lobby',
round_number: 1, round_number: 1,
@@ -189,6 +192,117 @@ describe('createAngularApiClient', () => {
); );
}); });
it('maps canonical reveal payload from session detail in reveal phase', async () => {
const get = vi.fn<AngularHttpClientLike['get']>(async <T>(url: string) => {
if (url === '/lobby/sessions/ABCD12') {
return {
session: { code: 'ABCD12', status: 'reveal', host_id: 1, current_round: 1, players_count: 3 },
players: [
{ id: 2, nickname: 'Maja', score: 4, is_connected: true },
{ id: 3, nickname: 'Bo', score: 2, is_connected: true },
{ id: 4, nickname: 'Ida', score: 5, is_connected: true }
],
round_question: {
id: 77,
round_number: 1,
prompt: 'Hvem opfandt pæren?',
shown_at: '2026-03-01T16:00:00Z',
answers: [{ text: 'Edison' }, { text: 'Tesla' }]
},
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Hvem opfandt pæren?',
correct_answer: 'Edison',
lies: [
{
player_id: 3,
nickname: 'Bo',
text: 'Tesla',
created_at: '2026-03-01T16:00:20Z'
}
],
guesses: [
{
player_id: 2,
nickname: 'Maja',
selected_text: 'Tesla',
is_correct: false,
fooled_player_id: 3,
fooled_player_nickname: 'Bo',
created_at: '2026-03-01T16:01:00Z'
},
{
player_id: 4,
nickname: 'Ida',
selected_text: 'Edison',
is_correct: true,
fooled_player_id: null,
created_at: '2026-03-01T16:01:02Z'
}
]
},
phase_view_model: {
status: 'reveal',
round_number: 1,
players_count: 3,
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: true,
can_start_next_round: true,
can_finish_game: true
},
player: {
can_join: false,
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 AngularHttpClientLike);
const session = await client.getSession('abcd12');
expect(session.ok).toBe(true);
if (session.ok) {
expect(session.data.reveal?.correct_answer).toBe('Edison');
expect(session.data.reveal?.lies[0]).toMatchObject({
player_id: 3,
nickname: 'Bo',
text: 'Tesla'
});
expect(session.data.reveal?.guesses[0]).toMatchObject({
player_id: 2,
nickname: 'Maja',
selected_text: 'Tesla',
is_correct: false,
fooled_player_id: 3,
fooled_player_nickname: 'Bo'
});
expect(session.data.reveal?.guesses[1]).toMatchObject({
player_id: 4,
nickname: 'Ida',
selected_text: 'Edison',
is_correct: true,
fooled_player_id: null
});
}
});
it('returns parse error when successful payload breaks typed contract', async () => { it('returns parse error when successful payload breaks typed contract', async () => {
const http = { const http = {
get: vi.fn<AngularHttpClientLike['get']>(async <T>() => ({ ok: true } as T)), get: vi.fn<AngularHttpClientLike['get']>(async <T>() => ({ ok: true } as T)),
@@ -248,6 +362,39 @@ describe('createAngularApiClient', () => {
session: { code: 'ABCD12', status: 'reveal', current_round: 1 }, session: { code: 'ABCD12', status: 'reveal', current_round: 1 },
round_question: { id: 77, round_number: 1 }, round_question: { id: 77, round_number: 1 },
events_created: 3, events_created: 3,
reveal: {
round_question_id: 77,
round_number: 1,
prompt: 'Hvem opfandt pæren?',
correct_answer: 'Edison',
lies: [
{
player_id: 3,
nickname: 'Bo',
text: 'Tesla',
created_at: '2026-03-01T16:00:20Z'
}
],
guesses: [
{
player_id: 2,
nickname: 'Maja',
selected_text: 'Tesla',
is_correct: false,
fooled_player_id: 3,
fooled_player_nickname: 'Bo',
created_at: '2026-03-01T16:01:00Z'
},
{
player_id: 4,
nickname: 'Ida',
selected_text: 'Edison',
is_correct: true,
fooled_player_id: null,
created_at: '2026-03-01T16:01:02Z'
}
]
},
leaderboard: [{ id: 2, nickname: 'Maja', score: 11 }] leaderboard: [{ id: 2, nickname: 'Maja', score: 11 }]
} as T; } as T;
} }
@@ -305,6 +452,29 @@ describe('createAngularApiClient', () => {
const calculateScores = await client.calculateScores('abcd12', 77); const calculateScores = await client.calculateScores('abcd12', 77);
expect(calculateScores.ok).toBe(true); expect(calculateScores.ok).toBe(true);
if (calculateScores.ok) {
expect(calculateScores.data.reveal.correct_answer).toBe('Edison');
expect(calculateScores.data.reveal.lies[0]).toMatchObject({
player_id: 3,
nickname: 'Bo',
text: 'Tesla'
});
expect(calculateScores.data.reveal.guesses[0]).toMatchObject({
player_id: 2,
nickname: 'Maja',
selected_text: 'Tesla',
is_correct: false,
fooled_player_id: 3,
fooled_player_nickname: 'Bo'
});
expect(calculateScores.data.reveal.guesses[1]).toMatchObject({
player_id: 4,
nickname: 'Ida',
selected_text: 'Edison',
is_correct: true,
fooled_player_id: null
});
}
const scoreboard = await client.getScoreboard('abcd12'); const scoreboard = await client.getScoreboard('abcd12');
expect(scoreboard.ok).toBe(true); expect(scoreboard.ok).toBe(true);

View File

@@ -687,6 +687,7 @@ class ScoreCalculationTests(TestCase):
self.player_three = Player.objects.create(session=self.session, nickname="Nora") self.player_three = Player.objects.create(session=self.session, nickname="Nora")
def test_host_can_calculate_scores_and_transition_to_reveal(self): 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, player=self.player_one, selected_text="Tennis", is_correct=True)
Guess.objects.create( Guess.objects.create(
round_question=self.round_question, round_question=self.round_question,
@@ -715,6 +716,57 @@ class ScoreCalculationTests(TestCase):
payload = response.json() payload = response.json()
self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL) self.assertEqual(payload["session"]["status"], GameSession.Status.REVEAL)
self.assertEqual(payload["events_created"], 2) self.assertEqual(payload["events_created"], 2)
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(
[
{
"player_id": item["player_id"],
"nickname": item["nickname"],
"selected_text": item["selected_text"],
"is_correct": item["is_correct"],
"fooled_player_id": item["fooled_player_id"],
"fooled_player_nickname": item.get("fooled_player_nickname"),
}
for item in payload["reveal"]["guesses"]
],
[
{
"player_id": self.player_one.id,
"nickname": "Luna",
"selected_text": "Tennis",
"is_correct": True,
"fooled_player_id": None,
"fooled_player_nickname": None,
},
{
"player_id": self.player_two.id,
"nickname": "Mads",
"selected_text": "Padel",
"is_correct": False,
"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,
"fooled_player_id": self.player_three.id,
"fooled_player_nickname": "Nora",
},
],
)
self.player_one.refresh_from_db() self.player_one.refresh_from_db()
self.player_three.refresh_from_db() self.player_three.refresh_from_db()
@@ -1149,7 +1201,62 @@ class SessionDetailRoundQuestionTests(TestCase):
self.assertEqual(payload["round_question"]["id"], round_question.id) self.assertEqual(payload["round_question"]["id"], round_question.id)
self.assertEqual(payload["round_question"]["prompt"], self.question.prompt) self.assertEqual(payload["round_question"]["prompt"], self.question.prompt)
def test_session_detail_includes_reveal_payload_for_active_round_question(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="Bluffer")
guesser = Player.objects.create(session=self.session, nickname="Guesser")
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,
)
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(
[
{"player_id": lie["player_id"], "nickname": lie["nickname"], "text": lie["text"]}
for lie in payload["reveal"]["lies"]
],
[{"player_id": liar.id, "nickname": "Bluffer", "text": "Tesla"}],
)
self.assertEqual(
[
{
"player_id": guess["player_id"],
"nickname": guess["nickname"],
"selected_text": guess["selected_text"],
"is_correct": guess["is_correct"],
"fooled_player_id": guess["fooled_player_id"],
"fooled_player_nickname": guess.get("fooled_player_nickname"),
}
for guess in payload["reveal"]["guesses"]
],
[
{
"player_id": guesser.id,
"nickname": "Guesser",
"selected_text": "Tesla",
"is_correct": False,
"fooled_player_id": liar.id,
"fooled_player_nickname": "Bluffer",
}
],
)
class SessionDetailPhaseViewModelTests(TestCase): class SessionDetailPhaseViewModelTests(TestCase):

View File

@@ -61,6 +61,52 @@ def _create_unique_session_code() -> str:
raise RuntimeError("Could not generate unique session code") 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: def _build_phase_view_model(session: GameSession, *, players_count: int, has_round_question: bool) -> dict:
status = session.status status = session.status
in_lobby = status == GameSession.Status.LOBBY in_lobby = status == GameSession.Status.LOBBY
@@ -238,6 +284,9 @@ def session_detail(request: HttpRequest, code: str) -> JsonResponse:
}, },
"players": players, "players": players,
"round_question": round_question_payload, "round_question": round_question_payload,
"reveal": _build_reveal_payload(current_round_question)
if session.status == GameSession.Status.REVEAL and current_round_question
else None,
"phase_view_model": phase_view_model, "phase_view_model": phase_view_model,
} }
) )
@@ -909,6 +958,7 @@ def calculate_scores(request: HttpRequest, code: str, round_question_id: int) ->
"id": round_question.id, "id": round_question.id,
"round_number": round_question.round_number, "round_number": round_question.round_number,
}, },
"reveal": _build_reveal_payload(round_question),
"events_created": len(score_events), "events_created": len(score_events),
"leaderboard": leaderboard, "leaderboard": leaderboard,
} }