421 lines
18 KiB
TypeScript
421 lines
18 KiB
TypeScript
import type {
|
|
CalculateScoresResponse,
|
|
FinishGameResponse,
|
|
HealthResponse,
|
|
JoinSessionResponse,
|
|
MixAnswersResponse,
|
|
ScoreboardResponse,
|
|
SessionDetailResponse,
|
|
ShowQuestionResponse,
|
|
StartNextRoundResponse,
|
|
StartRoundResponse,
|
|
SubmitGuessResponse,
|
|
SubmitLieResponse
|
|
} from './types';
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null;
|
|
}
|
|
|
|
function isBoolean(value: unknown): value is boolean {
|
|
return typeof value === 'boolean';
|
|
}
|
|
|
|
function isNumber(value: unknown): value is number {
|
|
return typeof value === 'number' && Number.isFinite(value);
|
|
}
|
|
|
|
function isString(value: unknown): value is string {
|
|
return typeof value === 'string';
|
|
}
|
|
|
|
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
|
if (!isRecord(value)) {
|
|
throw new Error(`Invalid API contract: expected object at ${path}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readString(record: Record<string, unknown>, key: string, path: string): string {
|
|
const value = record[key];
|
|
if (!isString(value)) {
|
|
throw new Error(`Invalid API contract: expected string at ${path}.${key}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readNumber(record: Record<string, unknown>, key: string, path: string): number {
|
|
const value = record[key];
|
|
if (!isNumber(value)) {
|
|
throw new Error(`Invalid API contract: expected number at ${path}.${key}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readBoolean(record: Record<string, unknown>, key: string, path: string): boolean {
|
|
const value = record[key];
|
|
if (!isBoolean(value)) {
|
|
throw new Error(`Invalid API contract: expected boolean at ${path}.${key}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readNullableNumber(record: Record<string, unknown>, key: string, path: string): number | null {
|
|
const value = record[key];
|
|
if (value === undefined || value === null) {
|
|
return null;
|
|
}
|
|
if (!isNumber(value)) {
|
|
throw new Error(`Invalid API contract: expected number|null at ${path}.${key}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function mapHealthResponse(payload: unknown): HealthResponse {
|
|
const root = asRecord(payload, 'health');
|
|
return {
|
|
ok: readBoolean(root, 'ok', 'health'),
|
|
service: readString(root, 'service', 'health')
|
|
};
|
|
}
|
|
|
|
function mapSessionDetail(payload: unknown): SessionDetailResponse {
|
|
const root = asRecord(payload, 'session_detail');
|
|
const session = asRecord(root.session, 'session_detail.session');
|
|
const players = root.players;
|
|
if (!Array.isArray(players)) {
|
|
throw new Error('Invalid API contract: expected array at session_detail.players');
|
|
}
|
|
|
|
const roundQuestionRaw = root.round_question;
|
|
let roundQuestion: SessionDetailResponse['round_question'] = null;
|
|
if (roundQuestionRaw !== null) {
|
|
const roundQuestionRecord = asRecord(roundQuestionRaw, 'session_detail.round_question');
|
|
const answersRaw = roundQuestionRecord.answers;
|
|
if (!Array.isArray(answersRaw)) {
|
|
throw new Error('Invalid API contract: expected array at session_detail.round_question.answers');
|
|
}
|
|
|
|
roundQuestion = {
|
|
id: readNumber(roundQuestionRecord, 'id', 'session_detail.round_question'),
|
|
round_number: readNumber(roundQuestionRecord, 'round_number', 'session_detail.round_question'),
|
|
prompt: readString(roundQuestionRecord, 'prompt', 'session_detail.round_question'),
|
|
shown_at: readString(roundQuestionRecord, 'shown_at', 'session_detail.round_question'),
|
|
answers: answersRaw.map((answer, index) => {
|
|
const answerRecord = asRecord(answer, `session_detail.round_question.answers[${index}]`);
|
|
return { text: readString(answerRecord, 'text', `session_detail.round_question.answers[${index}]`) };
|
|
})
|
|
};
|
|
}
|
|
|
|
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');
|
|
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 path = `session_detail.reveal.guesses[${index}]`;
|
|
const record = asRecord(guess, path);
|
|
const fooledPlayerId = readNullableNumber(record, 'fooled_player_id', path);
|
|
const fooledPlayerNickname = record.fooled_player_nickname;
|
|
if (fooledPlayerId === null) {
|
|
if (fooledPlayerNickname !== undefined) {
|
|
throw new Error(`Invalid API contract: expected ${path}.fooled_player_nickname to be omitted when fooled_player_id is null`);
|
|
}
|
|
} else if (!isString(fooledPlayerNickname)) {
|
|
throw new Error(`Invalid API contract: expected string at ${path}.fooled_player_nickname when fooled_player_id is set`);
|
|
}
|
|
return {
|
|
player_id: readNumber(record, 'player_id', path),
|
|
nickname: readString(record, 'nickname', path),
|
|
selected_text: readString(record, 'selected_text', path),
|
|
is_correct: readBoolean(record, 'is_correct', path),
|
|
fooled_player_id: fooledPlayerId,
|
|
...(fooledPlayerNickname === undefined ? {} : { fooled_player_nickname: fooledPlayerNickname }),
|
|
created_at: readString(record, 'created_at', path)
|
|
};
|
|
})
|
|
};
|
|
}
|
|
|
|
return {
|
|
session: {
|
|
code: readString(session, 'code', 'session_detail.session'),
|
|
status: readString(session, 'status', 'session_detail.session'),
|
|
host_id: (() => {
|
|
const hostId = session.host_id;
|
|
if (hostId === null) {
|
|
return null;
|
|
}
|
|
if (!isNumber(hostId)) {
|
|
throw new Error('Invalid API contract: expected number|null at session_detail.session.host_id');
|
|
}
|
|
return hostId;
|
|
})(),
|
|
current_round: readNumber(session, 'current_round', 'session_detail.session'),
|
|
players_count: readNumber(session, 'players_count', 'session_detail.session')
|
|
},
|
|
players: players.map((item, index) => {
|
|
const record = asRecord(item, `session_detail.players[${index}]`);
|
|
return {
|
|
id: readNumber(record, 'id', `session_detail.players[${index}]`),
|
|
nickname: readString(record, 'nickname', `session_detail.players[${index}]`),
|
|
score: readNumber(record, 'score', `session_detail.players[${index}]`),
|
|
is_connected: readBoolean(record, 'is_connected', `session_detail.players[${index}]`)
|
|
};
|
|
}),
|
|
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'),
|
|
players_count: readNumber(phase, 'players_count', 'session_detail.phase_view_model'),
|
|
constraints: {
|
|
min_players_to_start: readNumber(constraints, 'min_players_to_start', 'session_detail.phase_view_model.constraints'),
|
|
max_players_mvp: readNumber(constraints, 'max_players_mvp', 'session_detail.phase_view_model.constraints'),
|
|
min_players_reached: readBoolean(constraints, 'min_players_reached', 'session_detail.phase_view_model.constraints'),
|
|
max_players_allowed: readBoolean(constraints, 'max_players_allowed', 'session_detail.phase_view_model.constraints')
|
|
},
|
|
host: {
|
|
can_start_round: readBoolean(host, 'can_start_round', 'session_detail.phase_view_model.host'),
|
|
can_show_question: readBoolean(host, 'can_show_question', 'session_detail.phase_view_model.host'),
|
|
can_mix_answers: readBoolean(host, 'can_mix_answers', 'session_detail.phase_view_model.host'),
|
|
can_calculate_scores: readBoolean(host, 'can_calculate_scores', 'session_detail.phase_view_model.host'),
|
|
can_reveal_scoreboard: readBoolean(host, 'can_reveal_scoreboard', 'session_detail.phase_view_model.host'),
|
|
can_start_next_round: readBoolean(host, 'can_start_next_round', 'session_detail.phase_view_model.host'),
|
|
can_finish_game: readBoolean(host, 'can_finish_game', 'session_detail.phase_view_model.host')
|
|
},
|
|
player: {
|
|
can_join: readBoolean(player, 'can_join', 'session_detail.phase_view_model.player'),
|
|
can_submit_lie: readBoolean(player, 'can_submit_lie', 'session_detail.phase_view_model.player'),
|
|
can_submit_guess: readBoolean(player, 'can_submit_guess', 'session_detail.phase_view_model.player'),
|
|
can_view_final_result: readBoolean(player, 'can_view_final_result', 'session_detail.phase_view_model.player')
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
export function mapSessionDetailResponse(payload: unknown): SessionDetailResponse {
|
|
return mapSessionDetail(payload);
|
|
}
|
|
|
|
export function mapJoinSessionResponse(payload: unknown): JoinSessionResponse {
|
|
const root = asRecord(payload, 'join_session');
|
|
const player = asRecord(root.player, 'join_session.player');
|
|
const session = asRecord(root.session, 'join_session.session');
|
|
|
|
return {
|
|
player: {
|
|
id: readNumber(player, 'id', 'join_session.player'),
|
|
nickname: readString(player, 'nickname', 'join_session.player'),
|
|
session_token: readString(player, 'session_token', 'join_session.player'),
|
|
score: readNumber(player, 'score', 'join_session.player')
|
|
},
|
|
session: {
|
|
code: readString(session, 'code', 'join_session.session'),
|
|
status: readString(session, 'status', 'join_session.session')
|
|
}
|
|
};
|
|
}
|
|
|
|
export function mapStartRoundResponse(payload: unknown): StartRoundResponse {
|
|
const root = asRecord(payload, 'start_round');
|
|
const session = asRecord(root.session, 'start_round.session');
|
|
const round = asRecord(root.round, 'start_round.round');
|
|
const category = asRecord(round.category, 'start_round.round.category');
|
|
|
|
return {
|
|
session: {
|
|
code: readString(session, 'code', 'start_round.session'),
|
|
status: readString(session, 'status', 'start_round.session'),
|
|
current_round: readNumber(session, 'current_round', 'start_round.session')
|
|
},
|
|
round: {
|
|
number: readNumber(round, 'number', 'start_round.round'),
|
|
category: {
|
|
slug: readString(category, 'slug', 'start_round.round.category'),
|
|
name: readString(category, 'name', 'start_round.round.category')
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
function mapLeaderboardEntry(payload: unknown, path: string): { id: number; nickname: string; score: number } {
|
|
const record = asRecord(payload, path);
|
|
return {
|
|
id: readNumber(record, 'id', path),
|
|
nickname: readString(record, 'nickname', path),
|
|
score: readNumber(record, 'score', path)
|
|
};
|
|
}
|
|
|
|
function mapSessionState(payload: unknown, path: string): { code: string; status: string; current_round: number } {
|
|
const session = asRecord(payload, path);
|
|
return {
|
|
code: readString(session, 'code', path),
|
|
status: readString(session, 'status', path),
|
|
current_round: readNumber(session, 'current_round', path)
|
|
};
|
|
}
|
|
|
|
export function mapShowQuestionResponse(payload: unknown): ShowQuestionResponse {
|
|
const root = asRecord(payload, 'show_question');
|
|
const roundQuestion = asRecord(root.round_question, 'show_question.round_question');
|
|
const config = asRecord(root.config, 'show_question.config');
|
|
|
|
return {
|
|
round_question: {
|
|
id: readNumber(roundQuestion, 'id', 'show_question.round_question'),
|
|
prompt: readString(roundQuestion, 'prompt', 'show_question.round_question'),
|
|
round_number: readNumber(roundQuestion, 'round_number', 'show_question.round_question'),
|
|
shown_at: readString(roundQuestion, 'shown_at', 'show_question.round_question'),
|
|
lie_deadline_at: readString(roundQuestion, 'lie_deadline_at', 'show_question.round_question')
|
|
},
|
|
config: {
|
|
lie_seconds: readNumber(config, 'lie_seconds', 'show_question.config')
|
|
}
|
|
};
|
|
}
|
|
|
|
export function mapMixAnswersResponse(payload: unknown): MixAnswersResponse {
|
|
const root = asRecord(payload, 'mix_answers');
|
|
const roundQuestion = asRecord(root.round_question, 'mix_answers.round_question');
|
|
const answersRaw = root.answers;
|
|
if (!Array.isArray(answersRaw)) {
|
|
throw new Error('Invalid API contract: expected array at mix_answers.answers');
|
|
}
|
|
|
|
return {
|
|
session: mapSessionState(root.session, 'mix_answers.session'),
|
|
round_question: {
|
|
id: readNumber(roundQuestion, 'id', 'mix_answers.round_question'),
|
|
round_number: readNumber(roundQuestion, 'round_number', 'mix_answers.round_question')
|
|
},
|
|
answers: answersRaw.map((answer, index) => {
|
|
const record = asRecord(answer, `mix_answers.answers[${index}]`);
|
|
return { text: readString(record, 'text', `mix_answers.answers[${index}]`) };
|
|
})
|
|
};
|
|
}
|
|
|
|
export function mapCalculateScoresResponse(payload: unknown): CalculateScoresResponse {
|
|
const root = asRecord(payload, 'calculate_scores');
|
|
const roundQuestion = asRecord(root.round_question, 'calculate_scores.round_question');
|
|
const leaderboardRaw = root.leaderboard;
|
|
if (!Array.isArray(leaderboardRaw)) {
|
|
throw new Error('Invalid API contract: expected array at calculate_scores.leaderboard');
|
|
}
|
|
|
|
return {
|
|
session: mapSessionState(root.session, 'calculate_scores.session'),
|
|
round_question: {
|
|
id: readNumber(roundQuestion, 'id', 'calculate_scores.round_question'),
|
|
round_number: readNumber(roundQuestion, 'round_number', 'calculate_scores.round_question')
|
|
},
|
|
events_created: readNumber(root, 'events_created', 'calculate_scores'),
|
|
leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `calculate_scores.leaderboard[${index}]`))
|
|
};
|
|
}
|
|
|
|
export function mapScoreboardResponse(payload: unknown): ScoreboardResponse {
|
|
const root = asRecord(payload, 'scoreboard');
|
|
const leaderboardRaw = root.leaderboard;
|
|
if (!Array.isArray(leaderboardRaw)) {
|
|
throw new Error('Invalid API contract: expected array at scoreboard.leaderboard');
|
|
}
|
|
|
|
return {
|
|
session: mapSessionState(root.session, 'scoreboard.session'),
|
|
leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `scoreboard.leaderboard[${index}]`))
|
|
};
|
|
}
|
|
|
|
export function mapStartNextRoundResponse(payload: unknown): StartNextRoundResponse {
|
|
const root = asRecord(payload, 'start_next_round');
|
|
return { session: mapSessionState(root.session, 'start_next_round.session') };
|
|
}
|
|
|
|
export function mapFinishGameResponse(payload: unknown): FinishGameResponse {
|
|
const root = asRecord(payload, 'finish_game');
|
|
const leaderboardRaw = root.leaderboard;
|
|
if (!Array.isArray(leaderboardRaw)) {
|
|
throw new Error('Invalid API contract: expected array at finish_game.leaderboard');
|
|
}
|
|
|
|
const winnerRaw = root.winner;
|
|
|
|
return {
|
|
session: mapSessionState(root.session, 'finish_game.session'),
|
|
winner: winnerRaw === null ? null : mapLeaderboardEntry(winnerRaw, 'finish_game.winner'),
|
|
leaderboard: leaderboardRaw.map((entry, index) => mapLeaderboardEntry(entry, `finish_game.leaderboard[${index}]`))
|
|
};
|
|
}
|
|
|
|
export function mapSubmitLieResponse(payload: unknown): SubmitLieResponse {
|
|
const root = asRecord(payload, 'submit_lie');
|
|
const lie = asRecord(root.lie, 'submit_lie.lie');
|
|
const window = asRecord(root.window, 'submit_lie.window');
|
|
|
|
return {
|
|
lie: {
|
|
id: readNumber(lie, 'id', 'submit_lie.lie'),
|
|
player_id: readNumber(lie, 'player_id', 'submit_lie.lie'),
|
|
round_question_id: readNumber(lie, 'round_question_id', 'submit_lie.lie'),
|
|
text: readString(lie, 'text', 'submit_lie.lie'),
|
|
created_at: readString(lie, 'created_at', 'submit_lie.lie')
|
|
},
|
|
window: {
|
|
lie_deadline_at: readString(window, 'lie_deadline_at', 'submit_lie.window')
|
|
}
|
|
};
|
|
}
|
|
|
|
export function mapSubmitGuessResponse(payload: unknown): SubmitGuessResponse {
|
|
const root = asRecord(payload, 'submit_guess');
|
|
const guess = asRecord(root.guess, 'submit_guess.guess');
|
|
const window = asRecord(root.window, 'submit_guess.window');
|
|
const fooledPlayerId = readNullableNumber(guess, 'fooled_player_id', 'submit_guess.guess');
|
|
|
|
return {
|
|
guess: {
|
|
id: readNumber(guess, 'id', 'submit_guess.guess'),
|
|
player_id: readNumber(guess, 'player_id', 'submit_guess.guess'),
|
|
round_question_id: readNumber(guess, 'round_question_id', 'submit_guess.guess'),
|
|
selected_text: readString(guess, 'selected_text', 'submit_guess.guess'),
|
|
is_correct: readBoolean(guess, 'is_correct', 'submit_guess.guess'),
|
|
fooled_player_id: fooledPlayerId,
|
|
created_at: readString(guess, 'created_at', 'submit_guess.guess')
|
|
},
|
|
window: {
|
|
guess_deadline_at: readString(window, 'guess_deadline_at', 'submit_guess.window')
|
|
}
|
|
};
|
|
}
|