feat(gameplay): expose canonical reveal payload in frontend contract
Some checks failed
CI / test-and-quality (push) Failing after 2m8s

This commit is contained in:
2026-03-13 17:49:13 +00:00
parent b968ea4430
commit f0ebc25da7
3 changed files with 254 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ import type {
HealthResponse,
JoinSessionResponse,
MixAnswersResponse,
RevealPayload,
ScoreboardResponse,
SessionDetailResponse,
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 {
const root = asRecord(payload, 'session_detail');
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 constraints = asRecord(phase.constraints, 'session_detail.phase_view_model.constraints');
const host = asRecord(phase.host, 'session_detail.phase_view_model.host');
@@ -129,6 +182,7 @@ function mapSessionDetail(payload: unknown): SessionDetailResponse {
};
}),
round_question: roundQuestion,
reveal: revealRaw === null || revealRaw === undefined ? null : mapRevealPayload(revealRaw, 'session_detail.reveal'),
phase_view_model: {
status: readString(phase, 'status', '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')
},
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}]`))
};
}

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 {
session: SessionSummary;
players: SessionPlayer[];
round_question: SessionRoundQuestion | null;
reveal: RevealPayload | null;
phase_view_model: PhaseViewModel;
}
@@ -138,6 +166,7 @@ export interface CalculateScoresResponse {
round_number: number;
};
events_created: number;
reveal: RevealPayload;
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 }
],
round_question: null,
reveal: null,
phase_view_model: {
status: 'lobby',
round_number: 1,
@@ -84,6 +85,7 @@ describe('createAngularApiClient', () => {
if (session.ok) {
expect(session.data.session.code).toBe('ABCD12');
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);
}
@@ -119,6 +121,7 @@ describe('createAngularApiClient', () => {
session: { code: 'ABCD12', status: 'lobby', host_id: 1, current_round: 1, players_count: 2 },
players: [],
round_question: null,
reveal: null,
phase_view_model: {
status: 'lobby',
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 () => {
const http = {
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 },
round_question: { id: 77, round_number: 1 },
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 }]
} as T;
}
@@ -305,6 +452,29 @@ describe('createAngularApiClient', () => {
const calculateScores = await client.calculateScores('abcd12', 77);
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');
expect(scoreboard.ok).toBe(true);