Files
evening_detective_screen/electron/online.ts
T
2026-10-18 01:38:02 +07:00

1040 lines
38 KiB
TypeScript

import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { OnlineIntent, OnlineState } from '../src/types/online.js'
import type {
Intent,
PlanStatus,
Report,
ResultsSnapshot,
ScreenPlan,
} from '../src/types/screen.js'
import type { Controller } from './controller.js'
import { PackageStore, packageKey } from './package-store.js'
import { prepareScreenPackage } from './screen-package.js'
import { parseResults } from './results.js'
import { parsePlan } from './screen-plan.js'
import {
applyRemote,
integer,
object,
parseRemote,
send,
text,
type RemoteState,
} from './remote-state.js'
export interface SecretStorage {
available(): boolean
encrypt(value: string): Buffer
decrypt(value: Buffer): string
}
interface ScreenSession {
id: string
token: string
gameId: number
}
interface Credentials {
server: string
access: string
refresh: string
screen: ScreenSession | null
}
class NetworkError extends Error {}
class ApiError extends Error {
constructor(public status: number) {
super(
status === 401
? 'Сессия истекла. Войдите снова'
: status === 403
? 'Нет доступа к действию'
: status === 400
? 'Действие пока недоступно для этой игры'
: `Ошибка сервера: HTTP ${status}`,
)
}
}
function serverURL(value: unknown): string {
const url = new URL(text(value))
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash ||
(url.pathname !== '/' && url.pathname !== '')
)
throw new Error('Укажите адрес HTTP(S) сервера без пути и параметров')
return url.origin
}
function token(value: unknown): string {
const s = text(value)
if (!s || s.length > 16384 || /[\r\n]/.test(s))
throw new Error('Сервер не вернул действующие токены')
return s
}
export class Online {
readonly state: OnlineState = {
server: '',
authenticated: false,
remembered: false,
busy: false,
canCancel: false,
games: [],
screenId: null,
soundEnabled: null,
connection: 'disabled',
error: '',
notice: '',
progress: null,
packagesRevision: 0,
}
private credentials: Credentials | null = null
private screen: ScreenSession | null = null
private generation = 0
private refreshPromise: Promise<void> | null = null
private writes: Promise<void> = Promise.resolve()
private cancellation: AbortController | null = null
private downloadKey: string | null = null
private previous: RemoteState | null = null
private pollBusy = false
private pollSession = ''
private lastSound: boolean | null = null
private lastResults = -Infinity
private resultsBusy = false
private advanceBusy = false
private plan: ScreenPlan = { steps: [], currentStep: 0 }
private planBusy = 0
private planCommands: Promise<boolean> = Promise.resolve(true)
private planPolling: Promise<void> | null = null
private lastPlan = -Infinity
private planVersion = 0
private planWrites: Promise<void> = Promise.resolve()
private cachedPlan = ''
private planNotice = ''
planStatus(): PlanStatus {
const pkg = this.selectedGame()
return {
key: this.controller.state.catalog.key,
editable: !!pkg && !!this.credentials && pkg.package.server === this.credentials.server,
notice: this.planNotice,
}
}
private selectedGame() {
return this.packages.list().find((p) => p.key === this.controller.state.catalog.key)
}
private planGame() {
const pkg = this.selectedGame()
if (!pkg || !this.credentials || pkg.package.server !== this.credentials.server)
throw new Error('Войдите на сервер выбранной подготовленной игры')
return pkg
}
private async acceptPlan(plan: ScreenPlan, key: string, generation: number, version: number) {
if (
generation !== this.generation ||
key !== this.controller.state.catalog.key ||
version !== this.planVersion
)
return
const serialized = JSON.stringify(plan)
const hasChanged = this.cachedPlan !== serialized
this.plan = plan
this.controller.setPlan(plan.steps)
this.planNotice = ''
if (!hasChanged) return
try {
const write = this.planWrites.then(async () => {
if (generation === this.generation && version === this.planVersion)
await this.atomic(`plan-${key}.json`, serialized)
if (generation === this.generation && version === this.planVersion)
this.cachedPlan = serialized
})
this.planWrites = write.catch(() => {})
await write
} catch {
if (generation === this.generation && version === this.planVersion)
this.planNotice = 'План получен с сервера, но локальную копию сохранить не удалось'
}
}
async refreshPlan() {
if (this.planPolling) return this.planPolling
const task = this.loadPlan()
this.planPolling = task
try {
await task
} finally {
this.planPolling = null
}
}
private async loadPlan() {
if (this.planBusy || !this.planStatus().editable) return
const pkg = this.planGame()
const generation = this.generation
const version = this.planVersion
this.lastPlan = Date.now()
try {
const result = await this.api(
`/api/games/${pkg.package.game.id}/screen-plan`,
undefined,
true,
AbortSignal.timeout(5000),
)
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
} catch (error) {
if (generation === this.generation && version === this.planVersion)
this.planNotice = `${error instanceof Error ? error.message : 'План недоступен'}. Используется локальная копия`
}
}
private async savePlan(data: Record<string, unknown>) {
if (this.planBusy) throw new Error('Подождите завершения серверной команды')
if (!Array.isArray(data.steps)) throw new Error('Некорректные шаги плана')
const pkg = this.planGame()
if (data.key !== pkg.key) throw new Error('Выбранная игра изменилась')
const plan = parsePlan({ steps: data.steps, currentStep: 0 })
plan.currentStep = Math.min(this.plan.currentStep, Math.max(0, plan.steps.length - 1))
const generation = this.generation
const version = ++this.planVersion
this.planBusy++
try {
const result = await this.json(
await this.authorized(`/api/games/${pkg.package.game.id}/screen-plan`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(plan),
}),
)
if (generation !== this.generation) throw new Error('Сессия изменилась')
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
} finally {
this.planBusy--
}
}
async planCommand(intent: Intent) {
if (this.busy) throw new Error('Подождите завершения текущего действия')
const pkg = this.selectedGame()
// Локальная команда не ждёт сеть и никогда не откатывается при обрыве связи.
send(this.controller, intent)
const sequence = this.controller.state.sequence
const version = ++this.planVersion
if (!pkg || !this.planStatus().editable) {
this.planNotice = 'Переход выполнен только локально; войдите на сервер игры'
return
}
const generation = this.generation
const session = this.controller.state.session
const hasPendingCommand = this.planBusy > 0
const previousCommand = this.planCommands
let complete!: (available: boolean) => void
this.planCommands = new Promise((resolve) => {
complete = resolve
})
this.planBusy++
let isAvailable = true
let expectedCommand = 0
try {
// В сеть уходит только последняя команда; после обрыва ожидающие действия не повторяются.
const wasAvailable = await previousCommand
isAvailable = !hasPendingCommand || wasAvailable
if (
generation !== this.generation ||
version !== this.planVersion ||
session !== this.controller.state.session
)
return
if (hasPendingCommand && !wasAvailable) {
isAvailable = false
this.previous = null
this.planNotice = 'Команда выполнена локально; сервер недоступен'
return
}
if (intent.action === 'step') {
const result = await this.api(
`/api/games/${pkg.package.game.id}/screen-plan/step`,
{ currentStep: intent.index },
true,
AbortSignal.timeout(5000),
)
const confirmed = parsePlan(result.plan)
const shown = this.controller.state.catalog.steps[intent.index]
await this.acceptPlan(confirmed, pkg.key, generation, version)
if (
generation !== this.generation ||
version !== this.planVersion ||
session !== this.controller.state.session
)
return
if (
this.controller.state.sequence === sequence &&
JSON.stringify(shown) !== JSON.stringify(confirmed.steps[intent.index])
) {
if (confirmed.steps[intent.index]) send(this.controller, intent)
}
} else {
const index = this.controller.state.scene.stepIndex
if (index !== null && index !== this.plan.currentStep) {
const result = await this.api(
`/api/games/${pkg.package.game.id}/screen-plan/step`,
{ currentStep: index },
true,
AbortSignal.timeout(5000),
)
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
if (
generation !== this.generation ||
version !== this.planVersion ||
session !== this.controller.state.session
)
return
}
const commands = {
play: 1,
pause: 2,
resume: 3,
stop: 4,
replay: 5,
seek: 6,
volume: 7,
'timer-start': 8,
'timer-pause': 9,
'timer-resume': 10,
'timer-reset': 11,
}
if (!(intent.action in commands)) throw new Error('Некорректная команда плана')
expectedCommand = commands[intent.action as keyof typeof commands]
await this.api(
`/api/games/${pkg.package.game.id}/screens/command`,
{
command: expectedCommand,
positionMs: 'position' in intent ? Math.round(intent.position * 1000) : 0,
volume: 'volume' in intent ? intent.volume : this.controller.state.channels.main.volume,
},
true,
AbortSignal.timeout(5000),
)
}
if (
generation !== this.generation ||
session !== this.controller.state.session ||
version !== this.planVersion
)
return
this.planNotice = ''
// Ответ следующего опроса становится базой: собственная команда не применяется дважды.
if (this.screen) {
const result = await this.api(
'/api/screens/state',
{ screenID: this.screen.id, accessToken: this.screen.token },
false,
AbortSignal.timeout(5000),
)
if (generation !== this.generation || session !== this.controller.state.session) return
const remote = parseRemote(result.state)
if (remote.gameId !== pkg.package.game.id) throw new Error('Привязка экрана изменилась')
if (version !== this.planVersion) return
const scene = this.controller.state.scene
if (
this.controller.state.sequence === sequence &&
(remote.command !== expectedCommand ||
remote.mode !== scene.mode ||
(remote.mode === 8 && remote.text !== scene.text) ||
(remote.mode === 7 &&
(remote.timer.label !== this.controller.state.timer.label ||
remote.timer.durationMs !== this.controller.state.timer.durationMs)) ||
(intent.action === 'seek' && remote.position !== intent.position) ||
(intent.action === 'volume' && remote.volume !== intent.volume))
) {
applyRemote(this.controller, { ...remote, version: remote.version - 1n }, remote)
if (remote.command === 0) {
const result = await this.api(
`/api/games/${pkg.package.game.id}/screen-plan`,
undefined,
true,
AbortSignal.timeout(5000),
)
await this.acceptPlan(parsePlan(result.plan), pkg.key, generation, version)
if (
generation !== this.generation ||
version !== this.planVersion ||
session !== this.controller.state.session
)
return
this.controller.markStep(this.plan.currentStep)
}
}
if (!this.previous || this.previous.sound !== remote.sound)
send(this.controller, { action: 'mute', enabled: !remote.sound })
this.lastSound = remote.sound
this.state.soundEnabled = remote.sound
this.previous = { ...remote, music: this.previous?.music ?? null }
}
} catch (error) {
isAvailable = !(error instanceof NetworkError)
if (
generation !== this.generation ||
session !== this.controller.state.session ||
version !== this.planVersion
)
return
this.previous = null
this.planNotice = 'Команда выполнена локально; сервер не подтвердил обновление'
if (!(error instanceof NetworkError)) throw error
} finally {
this.planBusy--
complete(isAvailable)
}
}
constructor(
private directory: string,
private packages: PackageStore,
private controller: Controller,
private secrets: SecretStorage,
private fetchAPI: typeof fetch = fetch,
) {
controller.onMusicEnded = (report) => {
void this.musicEnded(report)
}
}
get blocksOutput() {
return this.downloadKey !== null && this.downloadKey === this.controller.state.catalog.key
}
get busy() {
return this.state.busy
}
snapshot(): OnlineState {
return structuredClone(this.state)
}
private async atomic(name: string, data: string | Buffer) {
await mkdir(this.directory, { recursive: true, mode: 0o700 })
const path = join(this.directory, name)
await writeFile(`${path}.tmp`, data, { mode: 0o600 })
await rename(`${path}.tmp`, path)
}
private persistSecrets() {
const data = this.credentials ? { ...this.credentials, screen: this.screen } : null
this.state.remembered = !!data && this.secrets.available()
let encrypted: Buffer | null = null
try {
if (this.state.remembered) encrypted = this.secrets.encrypt(JSON.stringify(data))
} catch {
this.state.remembered = false
this.state.notice =
'Не удалось сохранить вход средствами ОС. Сессия действует до закрытия приложения'
}
const write = this.writes.then(async () => {
if (encrypted) await this.atomic('credentials.bin', encrypted)
else await rm(join(this.directory, 'credentials.bin'), { force: true })
})
this.writes = write.catch(() => {})
return write
}
async initialize() {
try {
const config = object(
JSON.parse(await readFile(join(this.directory, 'selection.json'), 'utf8')),
)
if (config.server) this.state.server = serverURL(config.server)
if (typeof config.key === 'string' && /^[a-f0-9]{64}$/.test(config.key))
await this.select(config.key, false)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
this.state.error = 'Не удалось восстановить выбранные материалы; выберите готовый пакет'
}
if (!this.secrets.available()) return
try {
const raw = object(
JSON.parse(this.secrets.decrypt(await readFile(join(this.directory, 'credentials.bin')))),
)
const server = serverURL(raw.server)
const c: Credentials = {
server,
access: token(raw.access),
refresh: token(raw.refresh),
screen: null,
}
if (raw.screen) {
const s = object(raw.screen)
c.screen = {
id: String(integer(s.id, 1)),
token: token(s.token),
gameId: integer(s.gameId, 1),
}
}
this.credentials = c
this.state.server = server
this.state.authenticated = true
this.state.remembered = true
const pkg = this.packages.list().find((p) => p.key === this.controller.state.catalog.key)
if (c.screen && pkg?.package.server === server && pkg.package.game.id === c.screen.gameId) {
this.screen = c.screen
this.state.screenId = c.screen.id
this.state.connection = 'waiting'
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
this.state.error =
'Сохранённый вход недоступен. Войдите снова; локальные материалы доступны'
}
}
private async saveSelection() {
await this.atomic(
'selection.json',
JSON.stringify({ server: this.state.server, key: this.controller.state.catalog.key }),
)
}
async select(key: string | null, persist = true) {
if (this.busy) throw new Error('Подождите завершения подготовки материалов')
let pkg
try {
pkg = key === null ? null : await this.packages.verify(key)
} catch (error) {
if (key === this.controller.state.catalog.key) {
this.detach()
this.controller.selectPackage(null)
await this.saveSelection()
await this.persistSecrets()
this.state.packagesRevision++
}
throw error
}
this.detach()
this.controller.selectPackage(pkg)
this.plan = { steps: structuredClone(this.controller.state.catalog.steps), currentStep: 0 }
this.planNotice = ''
this.lastPlan = -Infinity
this.cachedPlan = ''
if (key) {
try {
this.plan = parsePlan(
JSON.parse(await readFile(join(this.directory, `plan-${key}.json`), 'utf8')),
)
this.controller.setPlan(this.plan.steps)
this.cachedPlan = JSON.stringify(this.plan)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
this.planNotice = 'Локальная копия плана повреждена; используется план пакета'
}
}
this.lastResults = -Infinity
if (key) await this.restoreResults(key)
if (persist) {
await this.saveSelection()
await this.persistSecrets()
}
}
private detach() {
this.generation++
this.planVersion++
this.screen = null
this.previous = null
this.lastSound = null
this.state.soundEnabled = null
this.state.screenId = null
this.state.connection = 'disabled'
}
private async raw(server: string, path: string, init: RequestInit = {}): Promise<Response> {
try {
return await this.fetchAPI(`${server}${path}`, {
...init,
redirect: 'error',
credentials: 'omit',
signal: AbortSignal.any([
AbortSignal.timeout(30_000),
...(init.signal ? [init.signal] : []),
]),
})
} catch {
throw new NetworkError('Сервер недоступен или превышено время ожидания')
}
}
private async json(response: Response): Promise<Record<string, unknown>> {
if (!response.ok) throw new ApiError(response.status)
try {
return object(await response.json())
} catch {
throw new Error('Сервер вернул некорректный JSON')
}
}
private async refresh(failed: string, generation: number) {
if (this.credentials?.access !== failed) return
if (this.refreshPromise) return this.refreshPromise
const c = this.credentials!
this.refreshPromise = (async () => {
const response = await this.raw(c.server, '/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: c.refresh }),
})
if (generation !== this.generation) throw new Error('Сессия изменилась')
if (response.status === 401) {
this.credentials = null
this.state.authenticated = false
await this.persistSecrets()
throw new ApiError(401)
}
const result = await this.json(response)
if (generation !== this.generation) throw new Error('Сессия изменилась')
this.credentials = {
...c,
access: token(result.accessToken),
refresh: token(result.refreshToken),
}
await this.persistSecrets()
})().finally(() => {
this.refreshPromise = null
})
return this.refreshPromise
}
private async authorized(path: string, init: RequestInit = {}) {
const c = this.credentials
const generation = this.generation
if (!c) throw new ApiError(401)
const request = (access: string) => {
const headers = new Headers(init.headers)
headers.set('Authorization', `Bearer ${access}`)
return this.raw(c.server, path, { ...init, headers })
}
let response = await request(c.access)
if (generation !== this.generation) throw new Error('Сессия изменилась')
if (response.status === 401) {
await response.body?.cancel()
await this.refresh(c.access, generation)
if (generation !== this.generation || !this.credentials) throw new ApiError(401)
response = await request(this.credentials.access)
if (response.status === 401) {
this.credentials = null
this.state.authenticated = false
await this.persistSecrets()
}
}
return response
}
private async api(path: string, body?: object, authorized = true, signal?: AbortSignal) {
const init: RequestInit =
body === undefined
? { signal }
: {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
}
return this.json(
authorized
? await this.authorized(path, init)
: await this.raw(this.state.server, path, init),
)
}
async act(input: unknown): Promise<OnlineState> {
const data = object(input)
if (data.action === 'cancel') {
this.cancellation?.abort()
this.packages.cancel()
return this.snapshot()
}
if (this.busy) throw new Error('Подождите завершения текущего действия')
this.state.busy = true
this.state.error = ''
this.state.notice = ''
try {
await this.perform(data)
} catch (error) {
this.state.error = error instanceof Error ? error.message : 'Действие не выполнено'
throw new Error(this.state.error)
} finally {
this.state.busy = false
this.state.progress = null
}
return this.snapshot()
}
private async perform(data: Record<string, unknown>) {
const action = data.action as OnlineIntent['action']
switch (action) {
case 'plan-save':
return this.savePlan(data)
case 'plan-refresh':
return this.refreshPlan()
case 'login': {
const server = serverURL(data.server)
const email = text(data.email).trim()
const password = text(data.password)
if (!email.includes('@') || email.length > 320 || !password || password.length > 4096)
throw new Error('Укажите email и пароль')
this.detach()
this.credentials = null
this.state.authenticated = false
this.state.games = []
await this.persistSecrets()
this.state.server = server
const response = await this.raw(server, '/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const result = await this.json(response)
this.credentials = {
server,
access: token(result.accessToken),
refresh: token(result.refreshToken),
screen: null,
}
try {
const me = object((await this.api('/api/users/me')).user)
if (
!Array.isArray(me.roles) ||
!me.roles.some((r) => ['admin', 'organizer', 'author'].includes(String(r)))
)
throw new Error(
'Для управления экраном нужны права организатора, автора или администратора',
)
} catch (error) {
this.credentials = null
await this.persistSecrets()
throw error
}
this.state.authenticated = true
await this.persistSecrets()
await this.saveSelection()
await this.loadGames()
return
}
case 'logout':
this.detach()
this.credentials = null
this.state.authenticated = false
this.state.games = []
await this.persistSecrets()
return
case 'games':
return this.loadGames()
case 'download':
case 'check': {
const gameId = integer(data.gameId, 1)
const key = packageKey(this.state.server, gameId)
if (
action === 'download' &&
key === this.controller.state.catalog.key &&
this.controller.state.outputOpen
)
throw new Error('Закройте показ перед обновлением выбранной игры')
if (!this.credentials) throw new ApiError(401)
this.downloadKey = action === 'download' ? key : null
this.cancellation = new AbortController()
this.state.canCancel = true
try {
const description = await prepareScreenPackage(
{
server: this.state.server,
gameId,
token: this.credentials.access,
signal: this.cancellation.signal,
},
(url, init) => {
const parsed = new URL(String(url))
if (parsed.origin !== this.state.server) throw new Error('Недопустимый сервер API')
return this.authorized(parsed.pathname, init)
},
)
if (action === 'check') {
const previous = this.packages.list().find((p) => p.key === key)
this.state.notice = !previous
? 'Игра ещё не загружена'
: previous.package.metadataVersion === description.metadataVersion
? 'Описание не изменилось. Для проверки замены файлов по прежним адресам загрузите материалы повторно'
: 'На сервере есть изменения. Загрузите материалы при закрытом показе'
return
}
const pkg = await this.packages.download(description, {
signal: this.cancellation.signal,
onProgress: (p) => {
this.state.progress = p
},
})
this.state.packagesRevision++
if (key === this.controller.state.catalog.key) {
this.controller.selectPackage(pkg)
this.plan = {
steps: structuredClone(this.controller.state.catalog.steps),
currentStep: 0,
}
this.lastPlan = -Infinity
this.cachedPlan = JSON.stringify(this.plan)
await this.atomic(`plan-${key}.json`, this.cachedPlan)
this.previous = null
await this.restoreResults(key)
}
this.state.notice = 'Материалы проверены и готовы к показу'
} finally {
this.cancellation = null
this.state.canCancel = false
this.downloadKey = null
}
return
}
case 'remove': {
const key = text(data.key)
if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Некорректный пакет')
if (key === this.controller.state.catalog.key)
throw new Error('Сначала выберите другой пакет или тестовый режим')
await this.packages.remove(key)
await rm(join(this.directory, `results-${key}.json`), { force: true })
await rm(join(this.directory, `plan-${key}.json`), { force: true })
this.state.packagesRevision++
return
}
case 'connect': {
const pkg = this.packages.list().find((p) => p.key === this.controller.state.catalog.key)
if (!pkg || pkg.package.server !== this.state.server)
throw new Error('Выберите готовую игру с этого сервера')
if (!this.credentials) throw new ApiError(401)
this.detach()
const result = await this.api('/api/screens', {}, false)
const s: ScreenSession = {
id: String(integer(result.screenID, 1)),
token: token(result.accessToken),
gameId: pkg.package.game.id,
}
await this.api(`/api/screens/${s.id}/bind`, {
screenID: s.id,
accessToken: s.token,
gameID: s.gameId,
})
this.screen = s
this.state.screenId = s.id
this.state.connection = 'waiting'
await this.persistSecrets()
return
}
case 'disconnect':
this.detach()
await this.persistSecrets()
return
case 'sound': {
const generation = this.generation
if (!this.screen) throw new Error('Сначала подключите управление')
await this.api(`/api/screens/${this.screen.id}/sound`, {
screenID: this.screen.id,
soundEnabled: true,
})
if (generation === this.generation) this.state.soundEnabled = true
return
}
case 'results':
return this.refreshResults()
default:
throw new Error('Неизвестное действие')
}
}
private async loadGames() {
const result = await this.api('/api/games')
if (!Array.isArray(result.games ?? [])) throw new Error('Некорректный список игр')
this.state.games = ((result.games ?? []) as unknown[]).map((value) => {
const g = object(value)
return { id: integer(g.ID, 1), name: text(g.name), status: text(g.status ?? '') }
})
}
private async restoreResults(key: string) {
try {
this.controller.setResults(
parseResults(
JSON.parse(await readFile(join(this.directory, `results-${key}.json`), 'utf8')),
key,
),
)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
this.state.error = 'Сохранённые результаты недоступны; обновите снимок'
}
}
async refreshResults() {
if (this.resultsBusy) return
const s = this.screen
const key = this.controller.state.catalog.key
if (!s || !key) throw new Error('Подключите экран к выбранной игре для получения результатов')
const generation = this.generation
this.resultsBusy = true
this.lastResults = Date.now()
try {
const result = await this.api(
'/api/screens/results',
{ screenID: s.id, accessToken: s.token },
false,
)
if (generation !== this.generation || key !== this.controller.state.catalog.key) return
if (!Array.isArray(result.questions ?? []) || !Array.isArray(result.teams ?? []))
throw new Error('Некорректные результаты')
const snapshot: ResultsSnapshot = parseResults(
{
packageKey: key,
capturedAt: new Date().toISOString(),
questions: result.questions ?? [],
teams: ((result.teams ?? []) as unknown[]).map((value) => {
const row = object(value)
const team = object(row.team)
if (!Array.isArray(row.answers ?? [])) throw new Error('Некорректные ответы')
return {
id: text(team.ID),
name: text(team.name),
place: integer(row.place, 1),
totalScore: integer(row.totalScore ?? 0, -2147483648),
actionsCount: integer(row.actionsCount ?? 0),
answers: ((row.answers ?? []) as unknown[]).map((value) => {
const a = object(value)
return {
questionCode: text(a.questionCode),
score: integer(a.score ?? 0, -2147483648),
}
}),
}
}),
},
key,
)
await this.atomic(`results-${key}.json`, JSON.stringify(snapshot))
if (generation === this.generation && key === this.controller.state.catalog.key)
this.controller.setResults(snapshot)
} finally {
this.resultsBusy = false
}
}
async poll() {
if (!this.busy && !this.planBusy && Date.now() - this.lastPlan >= 5000) await this.refreshPlan()
if (this.planBusy) return
const planVersion = this.planVersion
const s = this.screen
const session = this.controller.state.session
if (!s || !this.controller.state.outputOpen) {
this.previous = null
if (s) this.state.connection = 'waiting'
return
}
if (this.pollBusy) return
if (this.pollSession !== session) {
this.previous = null
this.pollSession = session
}
this.pollBusy = true
const generation = this.generation
try {
const result = await this.api(
'/api/screens/state',
{ screenID: s.id, accessToken: s.token },
false,
AbortSignal.timeout(5000),
)
if (
generation !== this.generation ||
session !== this.controller.state.session ||
!this.controller.state.outputOpen ||
planVersion !== this.planVersion
)
return
let next = parseRemote(result.state)
if (next.gameId !== s.gameId) {
this.detach()
await this.persistSecrets()
this.state.error =
'Привязка экрана изменилась на сервере. Локальный показ сохранён; подключите управление заново'
return
}
const previous = this.previous
if (previous && next.version > previous.version && next.command === 0) {
await this.refreshPlan()
if (
generation !== this.generation ||
session !== this.controller.state.session ||
planVersion !== this.planVersion
)
return
}
if (previous && next.version < previous.version)
next = { ...previous, sound: next.sound, music: next.music }
if (previous?.music && next.music && next.music.version < previous.music.version)
next = { ...next, music: previous.music }
this.previous = next
this.state.soundEnabled = next.sound
if (this.state.connection === 'offline') this.state.error = ''
this.state.connection = 'connected'
if (!previous && this.lastSound !== next.sound) {
send(this.controller, { action: 'mute', enabled: !next.sound })
}
this.lastSound = next.sound
if (previous) {
try {
applyRemote(this.controller, previous, next)
if (next.version > previous.version && next.command === 0)
this.controller.markStep(this.plan.currentStep)
} catch (error) {
this.state.error = error instanceof Error ? error.message : 'Команда сервера не выполнена'
}
}
if (this.controller.state.scene.mode === 6 && Date.now() - this.lastResults >= 5000)
void this.refreshResults().catch((error) => {
this.state.error =
error instanceof Error ? error.message : 'Не удалось обновить результаты'
})
} catch (error) {
if (
generation !== this.generation ||
session !== this.controller.state.session ||
planVersion !== this.planVersion
)
return
this.previous = null
if (error instanceof ApiError && error.status === 401) {
this.detach()
this.state.connection = 'expired'
await this.persistSecrets().catch(() => {})
} else this.state.connection = 'offline'
this.state.error =
error instanceof Error ? error.message : 'Не удалось получить состояние экрана'
} finally {
this.pollBusy = false
}
}
async musicEnded(report: Report) {
const m = this.previous?.music
const c = this.controller.state.channels.music
if (
!this.screen ||
!m ||
this.advanceBusy ||
this.state.connection !== 'connected' ||
report.channel !== 'music' ||
report.status !== 'ended' ||
report.session !== this.controller.state.session ||
report.operation !== c.operation ||
c.desired !== 'playing' ||
c.playlistId !== m.playlistId ||
c.source?.id !== `track-${m.playlistId}-${m.trackId}` ||
m.playback !== 2
)
return
this.advanceBusy = true
const generation = this.generation
try {
const result = await this.api(
'/api/screens/music/advance',
{ screenID: this.screen.id, accessToken: this.screen.token },
false,
AbortSignal.timeout(5000),
)
if (generation === this.generation && this.previous?.music?.version === m.version) {
const music = parseRemote({ music: result.state }).music
if (music) this.previous = { ...this.previous, music }
}
} catch {
if (generation === this.generation) {
this.previous = null
this.state.connection = 'offline'
this.state.error = 'Переход музыки выполнен локально; сервер не подтвердил обновление'
}
} finally {
this.advanceBusy = false
}
}
close() {
this.cancellation?.abort()
this.packages.cancel()
this.detach()
}
}