up
This commit is contained in:
@@ -11,6 +11,7 @@ import type {
|
||||
State,
|
||||
ScreenMode,
|
||||
TimerState,
|
||||
ScreenStep,
|
||||
} from '../src/types/screen.js'
|
||||
import { parseResults } from './results.js'
|
||||
import type { LocalPackage } from './package-store.js'
|
||||
@@ -345,6 +346,28 @@ export class Controller {
|
||||
this.timerDeadline = timer.status === 'running' ? this.now() + timer.remainingMs : null
|
||||
this.broadcast()
|
||||
}
|
||||
setPlan(steps: ScreenStep[]) {
|
||||
if (isDeepStrictEqual(steps, this.state.catalog.steps)) return
|
||||
const index = this.state.scene.stepIndex
|
||||
const old = index === null ? null : this.state.catalog.steps[index]
|
||||
this.state.catalog.steps = structuredClone(steps)
|
||||
if (index !== null && !isDeepStrictEqual(old, steps[index])) this.state.scene.stepIndex = null
|
||||
this.broadcast()
|
||||
}
|
||||
markStep(index: number) {
|
||||
const step = this.state.catalog.steps[index]
|
||||
const scene = this.state.scene
|
||||
if (!step || step.mode !== scene.mode || (step.mode === 8 && step.text !== scene.text)) return
|
||||
if (
|
||||
step.mode === 7 &&
|
||||
(step.timerLabel !== this.state.timer.label ||
|
||||
step.timerDurationSeconds * 1000 !== this.state.timer.durationMs)
|
||||
)
|
||||
return
|
||||
if (scene.stepIndex === index) return
|
||||
scene.stepIndex = index
|
||||
this.broadcast()
|
||||
}
|
||||
setResults(value: unknown) {
|
||||
const key = this.state.catalog.key
|
||||
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
|
||||
|
||||
+23
-3
@@ -419,6 +419,10 @@ app
|
||||
throw new Error('Недоступно')
|
||||
return controller.state
|
||||
})
|
||||
ipcMain.handle('plan-status', (event) => {
|
||||
requireControl(event)
|
||||
return online!.planStatus()
|
||||
})
|
||||
ipcMain.handle('online-state', (event): OnlineState => {
|
||||
requireControl(event)
|
||||
return online!.snapshot()
|
||||
@@ -476,7 +480,7 @@ app
|
||||
})
|
||||
let localSession = ''
|
||||
let localSequence = 0
|
||||
ipcMain.handle('command', (event, command: unknown) => {
|
||||
ipcMain.handle('command', async (event, command: unknown) => {
|
||||
requireControl(event)
|
||||
if (selectingPackage) throw new Error('Подождите проверки пакета')
|
||||
const parsed = parseCommand(command)
|
||||
@@ -491,8 +495,24 @@ app
|
||||
localSequence = 0
|
||||
}
|
||||
if (parsed.sequence <= localSequence) return
|
||||
controller.command({ ...parsed, sequence: controller.state.sequence + 1 })
|
||||
localSequence = parsed.sequence
|
||||
if (
|
||||
parsed.action === 'step' ||
|
||||
(controller.state.scene.stepIndex !== null &&
|
||||
((parsed.action.startsWith('timer-') && parsed.action !== 'timer-set') ||
|
||||
('channel' in parsed &&
|
||||
parsed.channel === 'main' &&
|
||||
['play', 'pause', 'resume', 'stop', 'replay', 'seek', 'volume'].includes(
|
||||
parsed.action,
|
||||
))))
|
||||
) {
|
||||
const before = controller.state.sequence
|
||||
const operation = online!.planCommand(parsed)
|
||||
if (controller.state.sequence !== before) localSequence = parsed.sequence
|
||||
await operation
|
||||
} else {
|
||||
controller.command({ ...parsed, sequence: controller.state.sequence + 1 })
|
||||
localSequence = parsed.sequence
|
||||
}
|
||||
})
|
||||
let capturing = false
|
||||
let lastCapture = -Infinity
|
||||
|
||||
+322
-2
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os'
|
||||
import { Controller } from './controller.js'
|
||||
import { Online, type SecretStorage } from './online.js'
|
||||
import { PackageStore } from './package-store.js'
|
||||
import { parsePlan } from './screen-plan.js'
|
||||
import { applyRemote, parseRemote, send } from './remote-state.js'
|
||||
|
||||
async function fixture(t: TestContext, secure = true) {
|
||||
@@ -49,6 +50,12 @@ async function fixture(t: TestContext, secure = true) {
|
||||
positionMs: '0',
|
||||
volume: 80,
|
||||
} as Record<string, unknown>,
|
||||
plan: parsePlan({ steps: [{ mode: 2 }] }),
|
||||
planError: 0,
|
||||
planSaves: 0,
|
||||
planTransitions: 0,
|
||||
planResponse: null as (() => Promise<Response>) | null,
|
||||
stepResponse: null as (() => Promise<Response>) | null,
|
||||
stateResponse: null as (() => Promise<Response>) | null,
|
||||
}
|
||||
const json = (value: unknown, status = 200) => Response.json(value, { status })
|
||||
@@ -131,7 +138,43 @@ async function fixture(t: TestContext, secure = true) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if (path === '/api/games/7/screen-plan') return json({ plan: { steps: [{ mode: 2 }] } })
|
||||
if (path === '/api/games/7/screen-plan') {
|
||||
if (model.planResponse) return model.planResponse()
|
||||
if (model.planError) return json({}, model.planError)
|
||||
if (init?.method === 'PUT') {
|
||||
model.planSaves++
|
||||
model.plan = parsePlan(JSON.parse(String(init.body)))
|
||||
}
|
||||
return json({ plan: model.plan.steps.length ? model.plan : null })
|
||||
}
|
||||
if (path === '/api/games/7/screen-plan/step') {
|
||||
if (model.stepResponse) return model.stepResponse()
|
||||
model.planTransitions++
|
||||
if (model.planError) return json({}, model.planError)
|
||||
const data = JSON.parse(String(init?.body)) as { currentStep: number }
|
||||
const step = model.plan.steps[data.currentStep]
|
||||
if (!step) return json({}, 400)
|
||||
model.plan.currentStep = data.currentStep
|
||||
model.remote = {
|
||||
...model.remote,
|
||||
mode: step.mode,
|
||||
text: step.text,
|
||||
command: 0,
|
||||
timerLabel: step.timerLabel,
|
||||
timerDurationSeconds: step.timerDurationSeconds || 600,
|
||||
timerState: 1,
|
||||
timerRemainingSeconds: step.timerDurationSeconds,
|
||||
stateVersion: String(BigInt(String(model.remote.stateVersion)) + 1n),
|
||||
}
|
||||
return json({ plan: model.plan })
|
||||
}
|
||||
if (path === '/api/games/7/screens/command') {
|
||||
if (model.planError) return json({}, model.planError)
|
||||
const data = JSON.parse(String(init?.body)) as { command: number }
|
||||
model.remote.command = data.command
|
||||
model.remote.stateVersion = String(BigInt(String(model.remote.stateVersion)) + 1n)
|
||||
return json({})
|
||||
}
|
||||
if (path === '/api/games/7/music')
|
||||
return json({
|
||||
gamePlaylistId: 1,
|
||||
@@ -426,7 +469,10 @@ test('перестановка прежних треков останавлив
|
||||
})
|
||||
assert.throws(() => applyRemote(f.controller, previous, next), /Музыка изменилась/)
|
||||
assert.equal(f.controller.state.channels.music.desired, 'stopped')
|
||||
assert.deepEqual(f.controller.state.catalog.playlists[0]!.tracks.map((track) => track.id), [1, 2])
|
||||
assert.deepEqual(
|
||||
f.controller.state.catalog.playlists[0]!.tracks.map((track) => track.id),
|
||||
[1, 2],
|
||||
)
|
||||
})
|
||||
|
||||
test('назначение звука восстанавливается после обрыва и открытия окна без повтора команд', async (t) => {
|
||||
@@ -625,3 +671,277 @@ test('назначение звука подтверждается сервер
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('план: сохранение и удаление на сервере, восстановление копии офлайн, ошибки валидации и доступа', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const key = f.controller.state.catalog.key!
|
||||
assert.equal(f.online.planStatus().editable, true)
|
||||
const steps = parsePlan({
|
||||
steps: [
|
||||
{ mode: 'SCREEN_MODE_TEXT', text: ' Новый текст ' },
|
||||
{ mode: 7, timerDurationSeconds: '60' },
|
||||
],
|
||||
}).steps
|
||||
const sceneRevision = f.controller.state.scene.revision
|
||||
await f.online.act({ action: 'plan-save', key, steps })
|
||||
assert.deepEqual(f.model.plan.steps, steps)
|
||||
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
||||
assert.equal(f.controller.state.scene.revision, sceneRevision)
|
||||
assert.equal(f.model.planSaves, 1)
|
||||
await assert.rejects(
|
||||
f.online.act({ action: 'plan-save', key, steps: [{ mode: 8, text: ' ' }] }),
|
||||
/Текст шага/,
|
||||
)
|
||||
await assert.rejects(
|
||||
f.online.act({ action: 'plan-save', key: 'wrong', steps }),
|
||||
/игра изменилась/,
|
||||
)
|
||||
assert.equal(f.model.planSaves, 1)
|
||||
f.model.planError = 403
|
||||
await assert.rejects(f.online.act({ action: 'plan-save', key, steps: [] }), /Нет доступа/)
|
||||
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
||||
f.model.planError = 0
|
||||
f.model.offline = true
|
||||
await assert.rejects(f.online.act({ action: 'plan-save', key, steps: [] }), /Сервер недоступен/)
|
||||
const restoredController = new Controller(() => {})
|
||||
const restored = new Online(
|
||||
join(f.directory, 'connection'),
|
||||
f.store,
|
||||
restoredController,
|
||||
f.secrets,
|
||||
f.fetchAPI,
|
||||
)
|
||||
await restored.initialize()
|
||||
assert.deepEqual(restoredController.state.catalog.steps, steps)
|
||||
assert.equal(restored.planStatus().key, key)
|
||||
await restored.select(key)
|
||||
f.model.offline = false
|
||||
await f.online.act({ action: 'plan-save', key, steps: [] })
|
||||
assert.deepEqual(f.controller.state.catalog.steps, [])
|
||||
assert.deepEqual(
|
||||
JSON.parse(await readFile(join(f.directory, 'connection', `plan-${key}.json`), 'utf8')).steps,
|
||||
[],
|
||||
)
|
||||
})
|
||||
|
||||
test('план: онлайн-переход общий, без автозапуска и повторного применения; офлайн-переход не отправляется после возврата сети', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const steps = parsePlan({
|
||||
steps: [
|
||||
{ mode: 7, timerLabel: 'Отдых', timerDurationSeconds: 60 },
|
||||
{ mode: 8, text: 'Локальный шаг' },
|
||||
],
|
||||
}).steps
|
||||
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
await f.online.planCommand({ action: 'step', index: 0 })
|
||||
assert.equal(f.model.planTransitions, 1)
|
||||
assert.equal(f.model.plan.currentStep, 0)
|
||||
assert.equal(f.controller.state.scene.stepIndex, 0)
|
||||
assert.equal(f.controller.state.timer.status, 'stopped')
|
||||
assert.equal(f.controller.state.channels.main.desired, 'stopped')
|
||||
await f.online.planCommand({ action: 'timer-start' })
|
||||
const timerRevision = f.controller.state.timer.revision
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.timer.revision, timerRevision)
|
||||
assert.equal(f.controller.state.timer.status, 'running')
|
||||
f.model.offline = true
|
||||
await f.online.planCommand({ action: 'step', index: 1 })
|
||||
assert.equal(f.controller.state.scene.text, 'Локальный шаг')
|
||||
assert.match(f.online.planStatus().notice, /локально/)
|
||||
f.model.offline = false
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.scene.text, 'Локальный шаг')
|
||||
assert.equal(f.controller.state.scene.stepIndex, 1)
|
||||
assert.equal(f.model.planTransitions, 1)
|
||||
await f.online.planCommand({ action: 'step', index: 0 })
|
||||
assert.equal(f.model.planTransitions, 2)
|
||||
})
|
||||
|
||||
test('план: изменение на сайте между опросом и переходом применяет подтверждённое содержимое', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
await f.online.refreshPlan()
|
||||
f.model.plan = parsePlan({ steps: [{ mode: 8, text: 'Изменено на сайте' }] })
|
||||
await f.online.planCommand({ action: 'step', index: 0 })
|
||||
assert.equal(f.controller.state.scene.mode, 8)
|
||||
assert.equal(f.controller.state.scene.text, 'Изменено на сайте')
|
||||
assert.equal(f.controller.state.scene.stepIndex, 0)
|
||||
})
|
||||
|
||||
test('план: поздний ответ старой игры и опрос до сохранения не заменяют новый план', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const key = f.controller.state.catalog.key!
|
||||
let resolve!: (response: Response) => void
|
||||
f.model.planResponse = () =>
|
||||
new Promise((done) => {
|
||||
resolve = done
|
||||
})
|
||||
const pending = f.online.refreshPlan()
|
||||
await new Promise((done) => setImmediate(done))
|
||||
f.model.planResponse = null
|
||||
const steps = parsePlan({ steps: [{ mode: 8, text: 'Сохранено позже' }] }).steps
|
||||
await f.online.act({ action: 'plan-save', key, steps })
|
||||
resolve(Response.json({ plan: { steps: [{ mode: 2 }] } }))
|
||||
await pending
|
||||
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
||||
f.model.planResponse = () =>
|
||||
new Promise((done) => {
|
||||
resolve = done
|
||||
})
|
||||
const oldGame = f.online.refreshPlan()
|
||||
await new Promise((done) => setImmediate(done))
|
||||
await f.online.select(null)
|
||||
resolve(Response.json({ plan: { steps: [{ mode: 8, text: 'Старая игра' }] } }))
|
||||
await oldGame
|
||||
assert.deepEqual(f.controller.state.catalog.steps, [])
|
||||
assert.equal(f.online.planStatus().editable, false)
|
||||
})
|
||||
|
||||
test('план: локальные переходы не ждут незавершённый запрос и поздний ответ не меняет показ', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const steps = parsePlan({
|
||||
steps: [
|
||||
{ mode: 8, text: 'Первый' },
|
||||
{ mode: 8, text: 'Второй' },
|
||||
],
|
||||
}).steps
|
||||
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||
let finish!: (response: Response) => void
|
||||
f.model.stepResponse = () =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const pending = f.online.planCommand({ action: 'step', index: 0 })
|
||||
await new Promise((done) => setImmediate(done))
|
||||
f.model.stepResponse = null
|
||||
f.model.offline = true
|
||||
const second = f.online.planCommand({ action: 'step', index: 1 })
|
||||
assert.equal(f.controller.state.scene.text, 'Второй')
|
||||
finish(Response.json({ plan: { steps: [{ mode: 8, text: 'Устаревший' }], currentStep: 0 } }))
|
||||
await Promise.all([pending, second])
|
||||
assert.equal(f.controller.state.scene.text, 'Второй')
|
||||
assert.deepEqual(f.controller.state.catalog.steps, steps)
|
||||
})
|
||||
|
||||
test('план: изменения с сайта загружаются при закрытом показе; удалённый переход выделяет актуальный шаг', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.model.plan = parsePlan({
|
||||
steps: [
|
||||
{ mode: 8, text: 'Один' },
|
||||
{ mode: 8, text: 'Два' },
|
||||
],
|
||||
})
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.outputOpen, false)
|
||||
assert.deepEqual(f.controller.state.catalog.steps, f.model.plan.steps)
|
||||
f.controller.output(true)
|
||||
await f.online.poll()
|
||||
f.model.plan.currentStep = 1
|
||||
f.model.remote = {
|
||||
...f.model.remote,
|
||||
mode: 8,
|
||||
text: 'Два',
|
||||
command: 0,
|
||||
stateVersion: String(BigInt(String(f.model.remote.stateVersion)) + 1n),
|
||||
}
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.scene.text, 'Два')
|
||||
assert.equal(f.controller.state.scene.stepIndex, 1)
|
||||
})
|
||||
|
||||
test('план: быстрые переходы сериализованы, неотправленные промежуточные шаги пропускаются', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const steps = parsePlan({ steps: [0, 1, 2].map((i) => ({ mode: 8, text: `Шаг ${i}` })) }).steps
|
||||
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||
let finish!: (response: Response) => void
|
||||
f.model.stepResponse = () =>
|
||||
new Promise((resolve) => {
|
||||
finish = resolve
|
||||
})
|
||||
const first = f.online.planCommand({ action: 'step', index: 0 })
|
||||
await new Promise((done) => setImmediate(done))
|
||||
const second = f.online.planCommand({ action: 'step', index: 1 })
|
||||
const third = f.online.planCommand({ action: 'step', index: 2 })
|
||||
assert.equal(f.controller.state.scene.text, 'Шаг 2')
|
||||
assert.equal(f.model.planTransitions, 0)
|
||||
f.model.stepResponse = null
|
||||
finish(Response.json({ plan: { steps, currentStep: 0 } }))
|
||||
await Promise.all([first, second, third])
|
||||
assert.equal(f.model.planTransitions, 1)
|
||||
assert.equal(f.model.plan.currentStep, 2)
|
||||
assert.equal(f.controller.state.scene.stepIndex, 2)
|
||||
})
|
||||
|
||||
test('план: конкурентный серверный таймер не теряется при подтверждении собственного перехода', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const steps = parsePlan({
|
||||
steps: [
|
||||
{ mode: 7, timerLabel: 'Первый', timerDurationSeconds: 60 },
|
||||
{ mode: 7, timerLabel: 'Второй', timerDurationSeconds: 120 },
|
||||
],
|
||||
}).steps
|
||||
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||
f.controller.output(true)
|
||||
f.model.stateResponse = async () => {
|
||||
f.model.plan.currentStep = 1
|
||||
return Response.json({
|
||||
state: {
|
||||
gameID: 7,
|
||||
mode: 7,
|
||||
command: 0,
|
||||
stateVersion: '99',
|
||||
timerLabel: 'Второй',
|
||||
timerDurationSeconds: 120,
|
||||
timerRemainingSeconds: 120,
|
||||
},
|
||||
})
|
||||
}
|
||||
await f.online.planCommand({ action: 'step', index: 0 })
|
||||
assert.equal(f.controller.state.timer.label, 'Второй')
|
||||
assert.equal(f.controller.state.timer.durationMs, 120000)
|
||||
assert.equal(f.controller.state.scene.stepIndex, 1)
|
||||
})
|
||||
|
||||
test('план: сетевой сбой отбрасывает все ожидающие переходы, включая устаревшие промежуточные', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
const steps = parsePlan({ steps: [0, 1, 2].map((i) => ({ mode: 8, text: `Шаг ${i}` })) }).steps
|
||||
await f.online.act({ action: 'plan-save', key: f.controller.state.catalog.key, steps })
|
||||
let fail!: (error: Error) => void
|
||||
f.model.stepResponse = () =>
|
||||
new Promise((_resolve, reject) => {
|
||||
fail = reject
|
||||
})
|
||||
const first = f.online.planCommand({ action: 'step', index: 0 })
|
||||
await new Promise((done) => setImmediate(done))
|
||||
const second = f.online.planCommand({ action: 'step', index: 1 })
|
||||
const third = f.online.planCommand({ action: 'step', index: 2 })
|
||||
f.model.stepResponse = null
|
||||
fail(new Error('network'))
|
||||
await Promise.all([first, second, third])
|
||||
assert.equal(f.model.planTransitions, 0)
|
||||
assert.equal(f.controller.state.scene.text, 'Шаг 2')
|
||||
await f.online.planCommand({ action: 'step', index: 0 })
|
||||
assert.equal(f.model.planTransitions, 1)
|
||||
})
|
||||
|
||||
test('план: первое подтверждение собственной команды учитывает назначение звука', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await f.prepare()
|
||||
f.controller.output(true)
|
||||
f.model.remote.soundEnabled = false
|
||||
await f.online.planCommand({ action: 'step', index: 0 })
|
||||
assert.equal(f.controller.state.muted, true)
|
||||
assert.equal(f.online.state.soundEnabled, false)
|
||||
await f.online.poll()
|
||||
assert.equal(f.controller.state.muted, true)
|
||||
})
|
||||
|
||||
+341
-4
@@ -1,11 +1,18 @@
|
||||
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 { Report, ResultsSnapshot } from '../src/types/screen.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,
|
||||
@@ -32,6 +39,7 @@ interface Credentials {
|
||||
refresh: string
|
||||
screen: ScreenSession | null
|
||||
}
|
||||
class NetworkError extends Error {}
|
||||
class ApiError extends Error {
|
||||
constructor(public status: number) {
|
||||
super(
|
||||
@@ -94,6 +102,286 @@ export class Online {
|
||||
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,
|
||||
@@ -209,6 +497,22 @@ export class Online {
|
||||
}
|
||||
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) {
|
||||
@@ -218,6 +522,7 @@ export class Online {
|
||||
}
|
||||
private detach() {
|
||||
this.generation++
|
||||
this.planVersion++
|
||||
this.screen = null
|
||||
this.previous = null
|
||||
this.lastSound = null
|
||||
@@ -237,7 +542,7 @@ export class Online {
|
||||
]),
|
||||
})
|
||||
} catch {
|
||||
throw new Error('Сервер недоступен или превышено время ожидания')
|
||||
throw new NetworkError('Сервер недоступен или превышено время ожидания')
|
||||
}
|
||||
}
|
||||
private async json(response: Response): Promise<Record<string, unknown>> {
|
||||
@@ -343,6 +648,10 @@ export class Online {
|
||||
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()
|
||||
@@ -442,6 +751,13 @@ export class Online {
|
||||
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)
|
||||
}
|
||||
@@ -460,6 +776,7 @@ export class Online {
|
||||
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
|
||||
}
|
||||
@@ -579,6 +896,9 @@ export class Online {
|
||||
}
|
||||
}
|
||||
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) {
|
||||
@@ -603,7 +923,8 @@ export class Online {
|
||||
if (
|
||||
generation !== this.generation ||
|
||||
session !== this.controller.state.session ||
|
||||
!this.controller.state.outputOpen
|
||||
!this.controller.state.outputOpen ||
|
||||
planVersion !== this.planVersion
|
||||
)
|
||||
return
|
||||
let next = parseRemote(result.state)
|
||||
@@ -615,6 +936,15 @@ export class Online {
|
||||
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)
|
||||
@@ -630,6 +960,8 @@ export class Online {
|
||||
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 : 'Команда сервера не выполнена'
|
||||
}
|
||||
@@ -640,7 +972,12 @@ export class Online {
|
||||
error instanceof Error ? error.message : 'Не удалось обновить результаты'
|
||||
})
|
||||
} catch (error) {
|
||||
if (generation !== this.generation || session !== this.controller.state.session) return
|
||||
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()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { contextBridge, ipcRenderer } from 'electron'
|
||||
import type { Command, Report, SceneReport, State } from '../src/types/screen.js'
|
||||
|
||||
contextBridge.exposeInMainWorld('screenBridge', {
|
||||
planStatus: () => ipcRenderer.invoke('plan-status'),
|
||||
onlineState: () => ipcRenderer.invoke('online-state'),
|
||||
onlineAction: (intent: OnlineIntent) => ipcRenderer.invoke('online-action', intent),
|
||||
closeOutput: () => ipcRenderer.invoke('close-output'),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { parsePlan } from './screen-plan.js'
|
||||
|
||||
test('план: режимы protobuf, значения по умолчанию и UTF-8 ограничения сервера', () => {
|
||||
assert.deepEqual(parsePlan(null), { steps: [], currentStep: 0 })
|
||||
assert.equal(
|
||||
parsePlan({ steps: [{ mode: 'SCREEN_MODE_TIMER', timerDurationSeconds: '60' }] }).steps[0]
|
||||
?.timerLabel,
|
||||
'Перерыв',
|
||||
)
|
||||
for (const value of [
|
||||
{ steps: {} },
|
||||
{ steps: [{ mode: 0 }] },
|
||||
{ steps: [{ mode: 9 }] },
|
||||
{ steps: [{ mode: 8, text: ' ' }] },
|
||||
{ steps: [{ mode: 8, text: 'Я'.repeat(5001) }] },
|
||||
{ steps: [{ mode: 7, timerDurationSeconds: 0 }] },
|
||||
{ steps: [{ mode: 7, timerDurationSeconds: 86401 }] },
|
||||
{ steps: [{ mode: 7, timerDurationSeconds: 1, timerLabel: 'Я'.repeat(101) }] },
|
||||
{ steps: [{ mode: 1 }], currentStep: 1 },
|
||||
])
|
||||
assert.throws(() => parsePlan(value))
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ScreenMode, ScreenPlan } from '../src/types/screen.js'
|
||||
import { integer, object, text } from './remote-state.js'
|
||||
|
||||
const modes = [
|
||||
'UNSPECIFIED',
|
||||
'COVER',
|
||||
'INTRO',
|
||||
'INTRO_DONE',
|
||||
'CONCLUSION',
|
||||
'CONCLUSION_DONE',
|
||||
'RESULTS',
|
||||
'TIMER',
|
||||
'TEXT',
|
||||
]
|
||||
|
||||
export function parsePlan(value: unknown): ScreenPlan {
|
||||
const plan = value == null ? {} : object(value)
|
||||
const list = plan.steps ?? []
|
||||
if (!Array.isArray(list)) throw new Error('Некорректные шаги плана')
|
||||
const steps = list.map((value) => {
|
||||
const step = object(value)
|
||||
const mode = integer(
|
||||
typeof step.mode === 'string' && step.mode.startsWith('SCREEN_MODE_')
|
||||
? modes.indexOf(step.mode.slice(12))
|
||||
: step.mode,
|
||||
1,
|
||||
8,
|
||||
) as ScreenMode
|
||||
const content = text(step.text ?? '').trim()
|
||||
const label = text(step.timerLabel ?? '').trim()
|
||||
const seconds = integer(step.timerDurationSeconds ?? 0, 0, 86400)
|
||||
if (mode === 8 && (!content || Buffer.byteLength(content) > 10000))
|
||||
throw new Error('Текст шага должен содержать от 1 до 10000 байт')
|
||||
if (mode === 7 && (!seconds || Buffer.byteLength(label) > 200))
|
||||
throw new Error('Укажите таймер от 1 до 86400 секунд и подпись до 200 байт')
|
||||
return {
|
||||
mode,
|
||||
text: content,
|
||||
timerLabel: mode === 7 ? label || 'Перерыв' : label,
|
||||
timerDurationSeconds: seconds,
|
||||
}
|
||||
})
|
||||
return { steps, currentStep: integer(plan.currentStep ?? 0, 0, Math.max(0, steps.length - 1)) }
|
||||
}
|
||||
Reference in New Issue
Block a user