This commit is contained in:
2026-10-17 19:52:24 +07:00
parent a386f3d9e5
commit f5dc9c1e5b
17 changed files with 1652 additions and 59 deletions
+217 -3
View File
@@ -8,7 +8,10 @@ import type {
Intent,
Report,
State,
ScreenMode,
TimerState,
} from '../src/types/screen.js'
import { parseResults } from './results.js'
import type { LocalPackage } from './package-store.js'
function number(value: unknown, min: number, max = Number.MAX_SAFE_INTEGER): value is number {
@@ -27,6 +30,34 @@ export function parseCommand(value: unknown): Command {
let intent: Intent
if (c.action === 'mute' && typeof c.enabled === 'boolean')
intent = { action: 'mute', enabled: c.enabled }
else if (c.action === 'mode' && Number.isInteger(c.mode) && number(c.mode, 1, 8))
intent = { action: 'mode', mode: c.mode as ScreenMode }
else if (c.action === 'step' && Number.isSafeInteger(c.index) && number(c.index, 0))
intent = { action: 'step', index: c.index }
else if (
c.action === 'text' &&
typeof c.text === 'string' &&
c.text.trim() &&
c.text.length <= 10000
)
intent = { action: 'text', text: c.text.trim() }
else if (
c.action === 'timer-set' &&
typeof c.label === 'string' &&
c.label.length <= 200 &&
Number.isInteger(c.durationSeconds) &&
number(c.durationSeconds, 1, 86400)
)
intent = {
action: 'timer-set',
label: c.label.trim() || 'Перерыв',
durationSeconds: c.durationSeconds,
}
else if (
typeof c.action === 'string' &&
['timer-start', 'timer-pause', 'timer-resume', 'timer-reset'].includes(c.action)
)
intent = { action: c.action as 'timer-start' | 'timer-pause' | 'timer-resume' | 'timer-reset' }
else {
if (c.channel !== 'main' && c.channel !== 'music') throw new Error('Некорректный канал')
const channel = c.channel
@@ -84,6 +115,9 @@ const fixtureCatalog = (): Catalog => ({
key: null,
name: 'Тестовый режим SCREEN-001',
playlists: [],
cover: null,
background: null,
steps: [],
media: [
{
id: 'fixture-audio',
@@ -115,6 +149,15 @@ function initialChannel(): ChannelState {
loop: false,
}
}
function initialTimer(): TimerState {
return {
revision: 0,
label: 'Перерыв',
durationMs: 300000,
remainingMs: 300000,
status: 'stopped',
}
}
export class Controller {
state: State = {
session: randomUUID(),
@@ -123,11 +166,18 @@ export class Controller {
outputOpen: false,
catalog: fixtureCatalog(),
muted: false,
scene: { revision: 0, mode: 1, stepIndex: null, text: '', status: 'idle', error: '' },
timer: initialTimer(),
results: null,
channels: { main: initialChannel(), music: initialChannel() },
samples: [],
}
private measured = new Map<Channel, { operation: number; startedAt: number }>()
constructor(private publish: (state: State) => void) {
private timerDeadline: number | null = null
constructor(
private publish: (state: State) => void,
private now: () => number = () => performance.now(),
) {
this.state.channels.main.source = this.state.catalog.media[0]!
}
private broadcast() {
@@ -146,8 +196,10 @@ export class Controller {
this.measured.delete(channel)
}
private newSession() {
this.updateTimer()
this.state.session = randomUUID()
this.state.sequence = 0
this.invalidateScene()
for (const channel of ['main', 'music'] as const)
this.change(channel, {
desired: 'stopped',
@@ -158,6 +210,7 @@ export class Controller {
output(open: boolean) {
this.newSession()
this.state.outputOpen = open
this.state.scene.status = open ? 'pending' : 'idle'
if (!open)
for (const c of Object.values(this.state.channels)) c.status = c.source ? 'stopped' : 'idle'
this.broadcast()
@@ -182,6 +235,13 @@ export class Controller {
key: pkg.key,
name: pkg.package.game.name,
media,
cover: pkg.package.media.cover
? `screen-app://app/packages/${pkg.key}/${pkg.package.media.cover}`
: null,
background: pkg.package.media.background
? `screen-app://app/packages/${pkg.key}/${pkg.package.media.background}`
: null,
steps: pkg.package.steps.map((step) => ({ ...step, mode: step.mode as ScreenMode })),
playlists: pkg.package.playlists.map((p) => ({
id: p.id,
name: p.name,
@@ -194,6 +254,17 @@ export class Controller {
}
}
this.state.catalog = catalog
this.state.scene = {
revision: this.state.scene.revision,
mode: 1,
stepIndex: null,
text: '',
status: 'idle',
error: '',
}
this.state.timer = initialTimer()
this.timerDeadline = null
this.state.results = null
this.state.channels = {
main: { ...initialChannel(), source: catalog.media[0] ?? null },
music: initialChannel(),
@@ -214,6 +285,134 @@ export class Controller {
this.newSession()
this.broadcast()
}
private invalidateScene() {
this.state.scene.revision++
this.state.scene.status = this.state.outputOpen ? 'pending' : 'idle'
this.state.scene.error = ''
}
private mode(mode: ScreenMode, stepIndex: number | null = null, text = this.state.scene.text) {
this.state.scene = { ...this.state.scene, mode, stepIndex, text }
const c = this.state.channels.main
const kind = mode === 2 ? 'audio' : mode === 4 ? 'video' : null
const source = kind ? (this.state.catalog.media.find((s) => s.kind === kind) ?? null) : c.source
this.change('main', {
source,
desired: 'stopped',
position: 0,
duration: source?.id === c.source?.id ? c.duration : null,
status: source ? 'pending' : 'idle',
})
this.invalidateScene()
}
private configureTimer(label: string, seconds: number) {
this.timerDeadline = null
this.state.timer = {
revision: this.state.timer.revision + 1,
label,
durationMs: seconds * 1000,
remainingMs: seconds * 1000,
status: 'stopped',
}
}
private updateTimer() {
if (this.timerDeadline === null) return false
const t = this.state.timer
const remaining = Math.max(0, this.timerDeadline - this.now())
if (remaining === t.remainingMs) return false
t.remainingMs = remaining
t.revision++
if (!remaining) {
t.status = 'finished'
this.timerDeadline = null
}
return true
}
tick() {
if (this.updateTimer()) this.broadcast()
}
setResults(value: unknown) {
const key = this.state.catalog.key
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
const snapshot = value === null ? null : parseResults(value, key!)
this.state.results = snapshot
if (this.state.scene.mode === 6) this.invalidateScene()
this.broadcast()
}
reportScene(value: unknown) {
if (!value || typeof value !== 'object') throw new Error('Некорректное подтверждение сцены')
const r = value as Record<string, unknown>
if (
typeof r.session !== 'string' ||
!Number.isSafeInteger(r.revision) ||
(r.status !== 'ready' && r.status !== 'error') ||
(r.error !== undefined && typeof r.error !== 'string')
)
throw new Error('Некорректное подтверждение сцены')
if (
!this.state.outputOpen ||
r.session !== this.state.session ||
r.revision !== this.state.scene.revision
)
return
const mediaError = [2, 4].includes(this.state.scene.mode) ? this.state.channels.main.error : ''
const status = mediaError ? 'error' : r.status
const error = String(mediaError || r.error || '').slice(0, 500)
if (this.state.scene.status === status && this.state.scene.error === error) return
this.state.scene.status = status
this.state.scene.error = error
this.broadcast()
}
private presentation(command: Command): boolean {
switch (command.action) {
case 'mode':
this.mode(command.mode)
return true
case 'text':
this.mode(8, null, command.text)
return true
case 'step': {
const step = this.state.catalog.steps[command.index]
if (!step) throw new Error('Шаг плана не найден')
if (step.mode === 7)
this.configureTimer(step.timerLabel || 'Перерыв', step.timerDurationSeconds)
this.mode(step.mode, command.index, step.text)
return true
}
case 'timer-set':
this.configureTimer(command.label, command.durationSeconds)
this.mode(7)
return true
case 'timer-start':
case 'timer-pause':
case 'timer-resume':
case 'timer-reset': {
this.updateTimer()
const t = this.state.timer
if (command.action === 'timer-reset') {
this.configureTimer(t.label, t.durationMs / 1000)
return true
}
if (command.action === 'timer-pause') {
if (t.status === 'running') {
this.timerDeadline = null
t.status = 'paused'
t.revision++
}
} else if (
t.status !== 'running' &&
(command.action === 'timer-start' || t.status === 'paused')
) {
if (t.remainingMs <= 0) t.remainingMs = t.durationMs
t.status = 'running'
t.revision++
this.timerDeadline = this.now() + t.remainingMs
}
return true
}
default:
return false
}
}
private track(index: number) {
const c = this.state.channels.music
const track = this.state.catalog.playlists.find((p) => p.id === c.playlistId)?.tracks[index]
@@ -243,13 +442,16 @@ export class Controller {
const command = parseCommand(input)
if (command.session !== this.state.session || command.sequence <= this.state.sequence) return
if (command.action === 'mute') this.state.muted = command.enabled
else {
else if (this.presentation(command)) {
/* Режимы и таймер не зависят от транспортных каналов. */
} else if ('channel' in command) {
const channel = command.channel
const c = this.state.channels[channel]
let c = this.state.channels[channel]
switch (command.action) {
case 'select': {
const source = this.state.catalog.media.find((s) => s.id === command.sourceId)
if (!source) throw new Error('Ресурс не найден в выбранном пакете')
this.mode(source.kind === 'audio' ? 2 : 4)
this.change(channel, {
source,
desired: 'stopped',
@@ -287,6 +489,14 @@ export class Controller {
break
default: {
if (!c.source) throw new Error('Для канала не выбран ресурс')
if (channel === 'main' && ['play', 'resume', 'replay'].includes(command.action)) {
const mode = c.source.kind === 'audio' ? 2 : 4
if (this.state.scene.status === 'error') this.invalidateScene()
if (this.state.scene.mode !== mode) {
this.mode(mode)
c = this.state.channels[channel]
}
}
const patch: Partial<ChannelState> = { status: 'pending' }
if (command.action === 'play' || command.action === 'replay') {
patch.position = 0
@@ -361,6 +571,10 @@ export class Controller {
}
if (report.status === 'error') {
c.desired = 'paused'
if (report.channel === 'main' && [2, 4].includes(this.state.scene.mode)) {
this.state.scene.status = 'error'
this.state.scene.error = c.error
}
this.measured.delete(report.channel)
}
if (report.status === 'ended') {
+61 -2
View File
@@ -98,7 +98,11 @@ app
.then(async () => {
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
await packages.initialize()
app.on('before-quit', () => packages.cancel())
const timerTick = setInterval(() => controller.tick(), 250)
app.on('before-quit', () => {
clearInterval(timerTick)
packages.cancel()
})
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) =>
callback(false),
)
@@ -107,7 +111,7 @@ app
const url = new URL(request.url)
const headers = {
'Content-Security-Policy':
"default-src 'self' screen-app:; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src screen-app:; connect-src 'self'; frame-ancestors 'none'",
"default-src 'self' screen-app:; script-src 'self'; style-src 'self' 'unsafe-inline'; media-src screen-app:; img-src 'self' screen-app: data:; connect-src 'self'; frame-ancestors 'none'",
'X-Content-Type-Options': 'nosniff',
}
if (url.host !== 'app' || request.method !== 'GET') return new Response(null, { status: 404 })
@@ -220,6 +224,61 @@ app
throw new Error('Сначала откройте окно показа')
controller.command(parsed)
})
let capturing = false
let lastCapture = -Infinity
ipcMain.handle('preview', async (event) => {
requireControl(event)
const window = output
const state = controller.state
if (
!window ||
window.isDestroyed() ||
window.webContents.isLoading() ||
!control?.isVisible() ||
control.isMinimized() ||
state.scene.status !== 'ready' ||
capturing ||
performance.now() - lastCapture < 500
)
return null
const session = state.session
const revision = state.scene.revision
capturing = true
lastCapture = performance.now()
try {
const image = await window.webContents.capturePage()
if (
window !== output ||
window.isDestroyed() ||
session !== controller.state.session ||
revision !== controller.state.scene.revision ||
controller.state.scene.status !== 'ready' ||
image.isEmpty()
)
return null
return {
session,
revision,
dataURL: image.resize({ width: Math.min(960, image.getSize().width) }).toDataURL(),
}
} catch {
return null
} finally {
capturing = false
}
})
ipcMain.on('scene-report', (event, report: unknown) => {
if (
event.sender !== output?.webContents ||
event.senderFrame !== output.webContents.mainFrame
)
return
try {
controller.reportScene(report)
} catch (error) {
console.error('Ошибка подтверждения сцены', error)
}
})
ipcMain.on('report', (event, report: unknown) => {
if (
event.sender !== output?.webContents ||
+3 -1
View File
@@ -1,9 +1,11 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { Command, Report, State } from '../src/types/screen.js'
import type { Command, Report, SceneReport, State } from '../src/types/screen.js'
contextBridge.exposeInMainWorld('screenBridge', {
command: (command: Command) => ipcRenderer.invoke('command', command),
report: (report: Report) => ipcRenderer.send('report', report),
reportScene: (report: SceneReport) => ipcRenderer.send('scene-report', report),
preview: () => ipcRenderer.invoke('preview'),
state: () => ipcRenderer.invoke('state'),
subscribe: (listener: (state: State) => void) => {
const handler = (_event: Electron.IpcRendererEvent, state: State) => listener(state)
+284
View File
@@ -0,0 +1,284 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { Controller, parseCommand } from './controller.js'
import { buildScreenPackage } from './screen-package.js'
import { packageKey, type LocalPackage } from './package-store.js'
import { parseResults } from './results.js'
import { formatTimer, remainingTime } from '../src/utils/presentation.js'
import type { Intent, ScreenMode } from '../src/types/screen.js'
function fixture() {
let now = 0
const c = new Controller(
() => {},
() => now,
)
const pkg = buildScreenPackage('https://example.test', 7, {
game: {
game: {
ID: 7,
name: 'Дело №7',
scenario: {
image: 'cover.png',
story: {
background: 'background.png',
introduction: { audio: 'intro.wav' },
conclusion: { video: 'ending.mp4' },
},
},
},
},
plan: {
plan: {
steps: Array.from({ length: 8 }, (_, i) => ({
mode: i + 1,
text: i === 7 ? 'Текст шага\nКириллица' : '',
timerLabel: 'Перерыв',
timerDurationSeconds: i === 6 ? '10' : '0',
})),
},
},
music: {
gamePlaylistId: 1,
playlists: [
{
ID: 1,
name: 'Музыка',
tracks: [{ ID: 1, name: 'Трек', filename: 'one.wav', position: 0 }],
},
],
},
})
const local: LocalPackage = {
formatVersion: 1,
key: packageKey(pkg.server, 7),
version: 'a'.repeat(64),
package: pkg,
files: [],
}
c.selectPackage(local)
c.output(true)
const send = (intent: Intent) =>
c.command({ session: c.state.session, sequence: c.state.sequence + 1, ...intent })
return {
c,
send,
key: local.key,
advance: (ms: number) => {
now += ms
},
}
}
test('проходит восемь шагов, подставляет локальные изображения, не запускает звук/таймер', () => {
const f = fixture()
assert.match(
f.c.state.catalog.cover!,
/^screen-app:\/\/app\/packages\/[a-f0-9]{64}\/[a-f0-9]{64}$/,
)
assert.notEqual(f.c.state.catalog.cover, f.c.state.catalog.background)
for (let i = 0; i < 8; i++) {
f.send({ action: 'step', index: i })
assert.equal(f.c.state.scene.mode, i + 1)
assert.equal(f.c.state.scene.stepIndex, i)
assert.equal(f.c.state.channels.main.desired, 'stopped')
assert.equal(f.c.state.timer.status, 'stopped')
}
assert.equal(f.c.state.scene.text, 'Текст шага\nКириллица')
assert.equal(f.c.state.timer.remainingMs, 10000)
assert.throws(() => f.send({ action: 'step', index: 8 }), /Шаг/)
})
test('смена сцены останавливает основное медиа, музыка независима, автоматического шага нет', () => {
const f = fixture()
f.send({ action: 'play', channel: 'music' })
f.send({ action: 'step', index: 1 })
f.send({ action: 'play', channel: 'main' })
const c = f.c.state.channels.main
f.c.report({
session: f.c.state.session,
channel: 'main',
operation: c.operation,
status: 'ended',
position: 60,
duration: 60,
})
assert.equal(f.c.state.scene.stepIndex, 1)
assert.equal(f.c.state.scene.mode, 2)
f.send({ action: 'mode', mode: 3 })
assert.equal(f.c.state.channels.main.desired, 'stopped')
assert.equal(f.c.state.channels.music.desired, 'playing')
assert.equal(f.c.state.scene.stepIndex, null)
})
test('таймер не накапливает дрейф при редких tick, переживает смену сцены и окна', () => {
const f = fixture()
f.send({ action: 'timer-set', label: '', durationSeconds: 10 })
assert.equal(f.c.state.timer.label, 'Перерыв')
assert.equal(f.c.state.timer.status, 'stopped')
f.send({ action: 'timer-start' })
f.advance(3250)
f.c.tick()
assert.equal(f.c.state.timer.remainingMs, 6750)
f.send({ action: 'mode', mode: 8 })
f.advance(1500)
f.c.output(false)
f.c.output(true)
assert.equal(f.c.state.timer.remainingMs, 5250)
f.advance(5250)
f.c.tick()
assert.equal(f.c.state.timer.status, 'finished')
assert.equal(f.c.state.timer.remainingMs, 0)
assert.equal(f.c.state.scene.mode, 8)
assert.equal(f.c.state.channels.main.desired, 'stopped')
f.advance(99999)
f.c.tick()
assert.equal(f.c.state.timer.remainingMs, 0)
})
test('пауза/продолжение/сброс таймера используют точный остаток, шаг таймера сбрасывает его', () => {
const f = fixture()
f.send({ action: 'step', index: 6 })
f.send({ action: 'timer-start' })
f.advance(1234)
f.send({ action: 'timer-pause' })
assert.equal(f.c.state.timer.remainingMs, 8766)
f.advance(5000)
f.c.tick()
assert.equal(f.c.state.timer.remainingMs, 8766)
f.send({ action: 'timer-resume' })
f.advance(1000)
f.c.tick()
assert.equal(f.c.state.timer.remainingMs, 7766)
f.send({ action: 'timer-reset' })
assert.equal(f.c.state.timer.remainingMs, 10000)
assert.equal(f.c.state.timer.status, 'stopped')
f.send({ action: 'timer-start' })
f.advance(100)
f.send({ action: 'step', index: 6 })
assert.equal(f.c.state.timer.remainingMs, 10000)
assert.equal(f.c.state.timer.status, 'stopped')
f.c.selectPackage(null)
assert.equal(f.c.state.timer.status, 'stopped')
assert.equal(f.c.state.timer.remainingMs, 300000)
})
test('формат таймера и интерполяция не зависят от числа обновлений', () => {
assert.equal(formatTimer(60000), '01:00')
assert.equal(formatTimer(1), '00:01')
assert.equal(formatTimer(-5), '00:00')
const f = fixture()
f.send({ action: 'timer-set', label: 'Перерыв', durationSeconds: 10 })
f.send({ action: 'timer-start' })
assert.equal(remainingTime(f.c.state.timer, 11000), 0)
assert.equal(remainingTime(f.c.state.timer, 1234), 8766)
f.send({ action: 'timer-pause' })
assert.equal(remainingTime(f.c.state.timer, 1234), 10000)
})
test('подтверждение сцены отклоняет старую ревизию и сессию, ошибка не попадает в текст показа', () => {
const f = fixture()
const old = { session: f.c.state.session, revision: f.c.state.scene.revision, status: 'ready' }
f.send({ action: 'text', text: ' Привет\n<script>не HTML</script> ' })
f.c.reportScene(old)
assert.equal(f.c.state.scene.status, 'pending')
assert.equal(f.c.state.scene.text, 'Привет\n<script>не HTML</script>')
const report = {
session: f.c.state.session,
revision: f.c.state.scene.revision,
status: 'error',
error: 'Нет изображения',
}
f.c.reportScene(report)
assert.equal(f.c.state.scene.error, 'Нет изображения')
assert.ok(!f.c.state.scene.text.includes('Нет изображения'))
f.c.output(false)
f.c.output(true)
f.c.reportScene(report)
assert.equal(f.c.state.scene.status, 'pending')
f.c.reportScene({
session: f.c.state.session,
revision: f.c.state.scene.revision,
status: 'ready',
})
assert.equal(f.c.state.scene.status, 'ready')
assert.throws(() => f.c.reportScene({ ...old, status: 'fake' }))
})
test('не допускает пустой текст и некорректные параметры таймера/режима на IPC', () => {
const f = fixture()
const base = { session: f.c.state.session, sequence: 1 }
for (const intent of [
{ action: 'text', text: '' },
{ action: 'text', text: ' '.repeat(3) },
{ action: 'text', text: 'x'.repeat(10001) },
{ action: 'timer-set', label: '', durationSeconds: 0 },
{ action: 'timer-set', label: '', durationSeconds: 1.5 },
{ action: 'timer-set', label: 'x'.repeat(201), durationSeconds: 10 },
{ action: 'mode', mode: 9 },
{ action: 'mode', mode: NaN },
{ action: 'step', index: -1 },
{ action: ['timer-start'] },
])
assert.throws(() => parseCommand({ ...base, ...intent }))
for (const mode of [1, 2, 3, 4, 5, 6, 7, 8] as ScreenMode[])
assert.equal(parseCommand({ ...base, action: 'mode', mode }).action, 'mode')
})
function snapshot(key: string) {
return {
packageKey: key,
capturedAt: '2026-10-17T10:00:00Z',
questions: [{ code: 'К1', text: 'Вопрос' }],
teams: [
{
id: 'team',
name: 'Детективы',
place: 1,
totalScore: -2,
actionsCount: 4,
answers: [{ questionCode: 'К1', score: -2 }],
password: 'SECRET',
participants: ['SECRET'],
},
],
token: 'SECRET',
}
}
test('снимок результатов ограничен текущей игрой, содержит только публичные поля и время', () => {
const f = fixture()
const data = snapshot(f.key)
f.send({ action: 'mode', mode: 6 })
const revision = f.c.state.scene.revision
f.c.setResults(data)
assert.equal(f.c.state.scene.revision, revision + 1)
assert.equal(f.c.state.results!.teams[0]!.totalScore, -2)
assert.equal(f.c.state.results!.capturedAt, '2026-10-17T10:00:00.000Z')
assert.ok(!JSON.stringify(f.c.state.results).includes('SECRET'))
assert.throws(() => f.c.setResults(snapshot('a'.repeat(64))), /другой игре/)
f.c.selectPackage(null)
assert.equal(f.c.state.results, null)
})
test('не принимает повреждённые снимки, повторные команды и неизвестные коды ответов', () => {
const f = fixture()
for (const mutate of [
(d: ReturnType<typeof snapshot>) => {
d.capturedAt = 'invalid'
},
(d: ReturnType<typeof snapshot>) => {
d.teams[0]!.totalScore = NaN
},
(d: ReturnType<typeof snapshot>) => {
d.teams[0]!.answers[0]!.questionCode = 'unknown'
},
(d: ReturnType<typeof snapshot>) => {
d.teams.push(d.teams[0]!)
},
(d: ReturnType<typeof snapshot>) => {
d.questions.push(d.questions[0]!)
},
]) {
const data = snapshot(f.key)
mutate(data)
assert.throws(() => parseResults(data, f.key))
}
const command = { session: f.c.state.session, sequence: 1, action: 'timer-start' }
f.c.command(command)
f.advance(500)
f.c.command(command)
f.c.tick()
assert.equal(f.c.state.timer.remainingMs, 299500)
})
+58
View File
@@ -0,0 +1,58 @@
import type { ResultsSnapshot } from '../src/types/screen.js'
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new Error('Некорректный снимок результатов')
return value as Record<string, unknown>
}
function list(value: unknown): unknown[] {
if (!Array.isArray(value)) throw new Error('Некорректный список результатов')
return value
}
function text(value: unknown): string {
if (typeof value !== 'string') throw new Error('Некорректный текст результатов')
return value
}
function integer(value: unknown, minimum = -2147483648): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum)
throw new Error('Некорректное число результатов')
return value
}
// Сохраняем только поля публичной таблицы, без участников, паролей и токенов.
export function parseResults(value: unknown, packageKey: string): ResultsSnapshot {
const data = object(value)
if (data.packageKey !== packageKey || !/^[a-f0-9]{64}$/.test(packageKey))
throw new Error('Результаты относятся к другой игре')
const captured = Date.parse(text(data.capturedAt))
if (!Number.isFinite(captured)) throw new Error('Некорректное время снимка результатов')
const questions = list(data.questions).map((value) => {
const q = object(value)
return { code: text(q.code), text: text(q.text) }
})
const codes = new Set(questions.map((q) => q.code))
if (codes.size !== questions.length || codes.has(''))
throw new Error('Повтор или отсутствие кода вопроса')
const teams = list(data.teams).map((value) => {
const team = object(value)
const answers = list(team.answers).map((value) => {
const a = object(value)
return { questionCode: text(a.questionCode), score: integer(a.score) }
})
if (
new Set(answers.map((a) => a.questionCode)).size !== answers.length ||
answers.some((a) => !codes.has(a.questionCode))
)
throw new Error('Некорректные ответы результатов')
return {
id: text(team.id),
name: text(team.name),
place: integer(team.place, 1),
totalScore: integer(team.totalScore),
actionsCount: integer(team.actionsCount, 0),
answers,
}
})
if (teams.some((team) => !team.id) || new Set(teams.map((t) => t.id)).size !== teams.length)
throw new Error('Повтор или отсутствие команды')
return { packageKey, capturedAt: new Date(captured).toISOString(), questions, teams }
}