up
This commit is contained in:
+159
-78
@@ -1,7 +1,7 @@
|
||||
import { fieldError, ResponseFieldError, requestErrorContext } from './request-error.js'
|
||||
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
|
||||
import {
|
||||
ApiError,
|
||||
NetworkError,
|
||||
SessionChangedError,
|
||||
networkFailure,
|
||||
type NetworkErrorCode,
|
||||
@@ -44,34 +44,42 @@ export interface PackageRequest {
|
||||
}) => void
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
function object(value: unknown, field?: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new Error('Некорректный ответ API')
|
||||
throw fieldError(field, 'объект', value, 'Некорректный ответ API')
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
function optionalObject(value: unknown) {
|
||||
return value == null ? {} : object(value)
|
||||
function optionalObject(value: unknown, field?: string) {
|
||||
return value == null ? {} : object(value, field)
|
||||
}
|
||||
function list(value: unknown): unknown[] {
|
||||
function list(value: unknown, field?: string): unknown[] {
|
||||
if (value == null) return []
|
||||
if (!Array.isArray(value)) throw new Error('Некорректный список в ответе API')
|
||||
if (!Array.isArray(value))
|
||||
throw fieldError(field, 'массив', value, 'Некорректный список в ответе API')
|
||||
return value
|
||||
}
|
||||
function text(value: unknown): string {
|
||||
function text(value: unknown, field?: string): string {
|
||||
if (value == null) return ''
|
||||
if (typeof value !== 'string') throw new Error('Некорректный текст в ответе API')
|
||||
if (typeof value !== 'string')
|
||||
throw fieldError(field, 'строка', value, 'Некорректный текст в ответе API')
|
||||
return value
|
||||
}
|
||||
function integer(value: unknown, fallback = 0): number {
|
||||
function integer(value: unknown, fallback = 0, field?: string): number {
|
||||
if (value == null) return fallback
|
||||
if (typeof value === 'string' && /^\d+$/.test(value)) value = Number(value)
|
||||
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
|
||||
throw new Error('Некорректное число в ответе API')
|
||||
throw fieldError(field, 'неотрицательное целое число', value, 'Некорректное число в ответе API')
|
||||
return value
|
||||
}
|
||||
function id(value: unknown): number {
|
||||
const result = integer(value)
|
||||
if (result === 0) throw new Error('Отсутствует идентификатор в ответе API')
|
||||
function id(value: unknown, field?: string): number {
|
||||
const result = integer(value, 0, field)
|
||||
if (result === 0)
|
||||
throw fieldError(
|
||||
field,
|
||||
'положительный идентификатор',
|
||||
value,
|
||||
'Отсутствует идентификатор в ответе API',
|
||||
)
|
||||
return result
|
||||
}
|
||||
function hash(value: string) {
|
||||
@@ -107,12 +115,18 @@ const modes = [
|
||||
'TIMER',
|
||||
'TEXT',
|
||||
]
|
||||
function mode(value: unknown): number {
|
||||
function mode(value: unknown, field?: string): number {
|
||||
const result =
|
||||
typeof value === 'string' && value.startsWith('SCREEN_MODE_')
|
||||
? modes.indexOf(value.slice('SCREEN_MODE_'.length))
|
||||
: integer(value)
|
||||
if (result < 1 || result > 8) throw new Error('Неизвестный режим показа')
|
||||
: integer(value, 0, field)
|
||||
if (result < 1 || result > 8)
|
||||
throw fieldError(
|
||||
field,
|
||||
'режим экрана 1–8 или enum ScreenMode',
|
||||
value,
|
||||
'Неизвестный режим показа',
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -138,86 +152,140 @@ export function buildScreenPackage(
|
||||
): ScreenPackage {
|
||||
server = serverURL(server)
|
||||
id(gameId)
|
||||
const game = object(object(responses.game).game)
|
||||
if (id(game.ID) !== gameId) throw new Error('API вернул другую игру')
|
||||
const scenario = object(game.scenario)
|
||||
const story = object(scenario.story)
|
||||
const intro = optionalObject(story.introduction)
|
||||
const conclusion = optionalObject(story.conclusion)
|
||||
const game = object(object(responses.game, 'gameResponse').game, 'game')
|
||||
if (id(game.ID, 'game.ID') !== gameId)
|
||||
throw new ResponseFieldError(
|
||||
'game.ID',
|
||||
'идентификатор выбранной игры',
|
||||
game.ID,
|
||||
'API вернул другую игру',
|
||||
)
|
||||
const scenario = object(game.scenario, 'game.scenario')
|
||||
const story = object(scenario.story, 'game.scenario.story')
|
||||
const intro = optionalObject(story.introduction, 'game.scenario.story.introduction')
|
||||
const conclusion = optionalObject(story.conclusion, 'game.scenario.story.conclusion')
|
||||
const assets = new Map<string, { id: string; url: string }>()
|
||||
function asset(value: unknown): string | null {
|
||||
const ref = text(value).trim()
|
||||
function asset(value: unknown, field: string): string | null {
|
||||
const ref = text(value, field).trim()
|
||||
if (!ref) return null
|
||||
const url = resolveMediaURL(server, ref)
|
||||
let url: string
|
||||
try {
|
||||
url = resolveMediaURL(server, ref)
|
||||
} catch {
|
||||
throw new ResponseFieldError(
|
||||
field,
|
||||
'адрес HTTP(S) без секретов',
|
||||
value,
|
||||
'Недопустимый адрес файла или ссылка содержит секрет',
|
||||
)
|
||||
}
|
||||
const assetId = hash(url)
|
||||
assets.set(assetId, { id: assetId, url })
|
||||
return assetId
|
||||
}
|
||||
const cover = asset(scenario.image)
|
||||
const cover = asset(scenario.image, 'game.scenario.image')
|
||||
const media = {
|
||||
cover,
|
||||
background: asset(story.background) ?? cover,
|
||||
intro: asset(intro.audio),
|
||||
conclusion: asset(conclusion.video),
|
||||
background: asset(story.background, 'game.scenario.story.background') ?? cover,
|
||||
intro: asset(intro.audio, 'game.scenario.story.introduction.audio'),
|
||||
conclusion: asset(conclusion.video, 'game.scenario.story.conclusion.video'),
|
||||
}
|
||||
const plan = optionalObject(object(responses.plan).plan)
|
||||
const steps = list(plan.steps).map((value) => {
|
||||
const step = object(value)
|
||||
const plan = optionalObject(object(responses.plan, 'planResponse').plan, 'plan')
|
||||
const steps = list(plan.steps, 'plan.steps').map((value, index) => {
|
||||
const field = `plan.steps[${index}]`
|
||||
const step = object(value, field)
|
||||
const result = {
|
||||
mode: mode(step.mode),
|
||||
text: text(step.text),
|
||||
timerLabel: text(step.timerLabel),
|
||||
timerDurationSeconds: integer(step.timerDurationSeconds),
|
||||
mode: mode(step.mode, `${field}.mode`),
|
||||
text: text(step.text, `${field}.text`),
|
||||
timerLabel: text(step.timerLabel, `${field}.timerLabel`),
|
||||
timerDurationSeconds: integer(step.timerDurationSeconds, 0, `${field}.timerDurationSeconds`),
|
||||
}
|
||||
if (result.mode === 8 && !result.text.trim()) throw new Error('Пустой текст шага показа')
|
||||
if (result.mode === 8 && !result.text.trim())
|
||||
throw new ResponseFieldError(
|
||||
`${field}.text`,
|
||||
'непустая строка',
|
||||
step.text,
|
||||
'Пустой текст шага показа',
|
||||
)
|
||||
if (
|
||||
result.mode === 7 &&
|
||||
(result.timerDurationSeconds < 1 || result.timerDurationSeconds > 86400)
|
||||
)
|
||||
throw new Error('Некорректная длительность таймера')
|
||||
throw new ResponseFieldError(
|
||||
`${field}.timerDurationSeconds`,
|
||||
'целое число от 1 до 86400',
|
||||
step.timerDurationSeconds,
|
||||
'Некорректная длительность таймера',
|
||||
)
|
||||
return result
|
||||
})
|
||||
const musicResponse = object(responses.music)
|
||||
const state = optionalObject(musicResponse.state)
|
||||
const selected = state.playlist == null ? null : object(state.playlist)
|
||||
const musicResponse = object(responses.music, 'musicResponse')
|
||||
const state = optionalObject(musicResponse.state, 'music.state')
|
||||
const selected = state.playlist == null ? null : object(state.playlist, 'music.state.playlist')
|
||||
const music = {
|
||||
scenarioPlaylistId:
|
||||
integer(musicResponse.scenarioPlaylistId ?? scenario.music_playlist_id) || null,
|
||||
gamePlaylistId: integer(musicResponse.gamePlaylistId) || null,
|
||||
selectedPlaylistId: selected ? id(selected.ID) : null,
|
||||
integer(
|
||||
musicResponse.scenarioPlaylistId ?? scenario.music_playlist_id,
|
||||
0,
|
||||
musicResponse.scenarioPlaylistId == null
|
||||
? 'game.scenario.music_playlist_id'
|
||||
: 'music.scenarioPlaylistId',
|
||||
) || null,
|
||||
gamePlaylistId: integer(musicResponse.gamePlaylistId, 0, 'music.gamePlaylistId') || null,
|
||||
selectedPlaylistId: selected ? id(selected.ID, 'music.state.playlist.ID') : null,
|
||||
}
|
||||
const available = new Map<number, Record<string, unknown>>()
|
||||
for (const value of list(musicResponse.playlists)) {
|
||||
const playlist = object(value)
|
||||
available.set(id(playlist.ID), playlist)
|
||||
const available = new Map<number, { value: Record<string, unknown>; field: string }>()
|
||||
for (const [index, value] of list(musicResponse.playlists, 'music.playlists').entries()) {
|
||||
const field = `music.playlists[${index}]`
|
||||
const playlist = object(value, field)
|
||||
available.set(id(playlist.ID, `${field}.ID`), { value: playlist, field })
|
||||
}
|
||||
// Текущий плейлист берём из того же ответа, даже если его нет в библиотеке.
|
||||
if (selected) available.set(id(selected.ID), selected)
|
||||
if (selected)
|
||||
available.set(id(selected.ID, 'music.state.playlist.ID'), {
|
||||
value: selected,
|
||||
field: 'music.state.playlist',
|
||||
})
|
||||
const selectedIds = [
|
||||
...new Set(Object.values(music).filter((value): value is number => value !== null)),
|
||||
].sort((a, b) => a - b)
|
||||
const playlists = selectedIds.map((playlistId) => {
|
||||
const playlist = available.get(playlistId)
|
||||
if (!playlist) throw new Error('Не найден связанный плейлист')
|
||||
const tracks = list(playlist.tracks)
|
||||
.map((value) => {
|
||||
const track = object(value)
|
||||
const assetId = asset(track.filename)
|
||||
if (!assetId) throw new Error('Не указан файл трека')
|
||||
const entry = available.get(playlistId)
|
||||
if (!entry)
|
||||
throw new ResponseFieldError(
|
||||
'music.playlists',
|
||||
'связанный плейлист',
|
||||
undefined,
|
||||
'Не найден связанный плейлист',
|
||||
)
|
||||
const playlist = entry.value
|
||||
const field = entry.field
|
||||
const tracks = list(playlist.tracks, `${field}.tracks`)
|
||||
.map((value, index) => {
|
||||
const trackField = `${field}.tracks[${index}]`
|
||||
const track = object(value, trackField)
|
||||
const assetId = asset(track.filename, `${trackField}.filename`)
|
||||
if (!assetId)
|
||||
throw new ResponseFieldError(
|
||||
`${trackField}.filename`,
|
||||
'непустая строка',
|
||||
track.filename,
|
||||
'Не указан файл трека',
|
||||
)
|
||||
return {
|
||||
id: id(track.ID),
|
||||
name: text(track.name),
|
||||
position: integer(track.position),
|
||||
id: id(track.ID, `${trackField}.ID`),
|
||||
name: text(track.name, `${trackField}.name`),
|
||||
position: integer(track.position, 0, `${trackField}.position`),
|
||||
assetId,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.position - b.position || a.id - b.id)
|
||||
return { id: playlistId, name: text(playlist.name), tracks }
|
||||
return { id: playlistId, name: text(playlist.name, `${field}.name`), tracks }
|
||||
})
|
||||
const data = {
|
||||
formatVersion: 1 as const,
|
||||
server,
|
||||
game: { id: gameId, name: text(game.name) },
|
||||
game: { id: gameId, name: text(game.name, 'game.name') },
|
||||
media,
|
||||
steps,
|
||||
music,
|
||||
@@ -270,36 +338,34 @@ export async function prepareScreenPackage(
|
||||
: 'HTTP'
|
||||
: 'SESSION_CHANGED',
|
||||
})
|
||||
throw error
|
||||
throw requestErrorContext(error, meta.method, requestData.method, `/${requestData.path}`)
|
||||
}
|
||||
const failure = networkFailure(error, signal)
|
||||
request.onRequestFailure?.({ path, reason: failure.code })
|
||||
const name =
|
||||
suffix === '/music'
|
||||
? 'Музыка игры'
|
||||
: suffix === '/screen-plan'
|
||||
? 'План экранов'
|
||||
: 'Описание игры'
|
||||
throw new NetworkError(`${name}: ${failure.message}`, failure.code)
|
||||
throw requestErrorContext(failure, meta.method, requestData.method, `/${requestData.path}`)
|
||||
}
|
||||
if (!response.ok) {
|
||||
request.onRequestFailure?.({
|
||||
path: `/api/games/{id}${suffix}`,
|
||||
reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP',
|
||||
})
|
||||
throw new Error(
|
||||
response.status === 401
|
||||
? 'Требуется авторизация'
|
||||
: response.status === 403
|
||||
? 'Нет доступа к игре'
|
||||
: `Ошибка API: HTTP ${response.status}`,
|
||||
throw requestErrorContext(
|
||||
new ApiError(response.status),
|
||||
meta.method,
|
||||
requestData.method,
|
||||
`/${requestData.path}`,
|
||||
)
|
||||
}
|
||||
try {
|
||||
return await response.json()
|
||||
} catch {
|
||||
request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' })
|
||||
throw new Error('Некорректный JSON в ответе API')
|
||||
throw requestErrorContext(
|
||||
new Error('Некорректный JSON в ответе API'),
|
||||
meta.method,
|
||||
requestData.method,
|
||||
`/${requestData.path}`,
|
||||
)
|
||||
}
|
||||
})
|
||||
const [game, plan, music] = await Promise.all([
|
||||
@@ -307,7 +373,22 @@ export async function prepareScreenPackage(
|
||||
client.GetScreenPlan({ gameID: request.gameId }),
|
||||
client.GetGameMusic({ gameID: request.gameId }),
|
||||
])
|
||||
return buildScreenPackage(server, request.gameId, { game, plan, music })
|
||||
try {
|
||||
return buildScreenPackage(server, request.gameId, { game, plan, music })
|
||||
} catch (error) {
|
||||
const field = error instanceof ResponseFieldError ? error.field : 'game'
|
||||
const suffix = field.startsWith('plan')
|
||||
? '/screen-plan'
|
||||
: field.startsWith('music')
|
||||
? '/music'
|
||||
: ''
|
||||
throw requestErrorContext(
|
||||
error,
|
||||
suffix === '/music' ? 'GetGameMusic' : suffix ? 'GetScreenPlan' : 'GetGame',
|
||||
'GET',
|
||||
`/api/games/${request.gameId}${suffix}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Восстанавливаем только разрешённые поля и проверяем отпечаток перед работой с диском.
|
||||
|
||||
Reference in New Issue
Block a user