This commit is contained in:
2026-10-23 03:02:55 +07:00
parent e2f8a3302e
commit 1ce8efeef5
21 changed files with 561 additions and 46 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ test('клиент proto задаёт маршруты, методы и JSON enu
],
})
await client.SaveScreenPlan({ gameID: 7, ...apiPlan(plan) })
await client.SetScreenMode({ gameID: 7, mode: apiScreenMode(2) })
await client.SetScreenMode({ gameID: 7, mode: apiScreenMode(2), qrUrl: undefined, text: undefined })
await client.SendScreenCommand({
gameID: 7,
command: apiScreenCommand(6),
+21 -1
View File
@@ -14,6 +14,8 @@ const modes = [
'SCREEN_MODE_RESULTS',
'SCREEN_MODE_TIMER',
'SCREEN_MODE_TEXT',
'SCREEN_MODE_QR',
'SCREEN_MODE_TEAMS',
] as const satisfies readonly (ScreenMode | undefined)[]
const commands = [
'SCREEN_COMMAND_UNSPECIFIED',
@@ -40,5 +42,23 @@ export function apiScreenCommand(command: number): ScreenCommand {
return value
}
export function apiPlan(plan: ScreenPlan): Pick<SaveScreenPlanReq, 'steps' | 'currentStep'> {
return { ...plan, steps: plan.steps.map((step) => ({ ...step, mode: apiScreenMode(step.mode) })) }
return {
...plan,
steps: plan.steps.map((step) => ({
...step,
mode: apiScreenMode(step.mode),
qrUrl: step.qrUrl,
timerType: apiTimerType(step.timerType),
})),
}
}
export function apiTimerType(value = 0) {
return (
[
'SCREEN_TIMER_TYPE_BREAK',
'SCREEN_TIMER_TYPE_BEFORE_GAME',
'SCREEN_TIMER_TYPE_UNTIL_END',
] as const
)[value]!
}
@@ -9,7 +9,13 @@ export type ScreenMode =
| "SCREEN_MODE_CONCLUSION"
| "SCREEN_MODE_RESULTS"
| "SCREEN_MODE_TIMER"
| "SCREEN_MODE_TEXT";
| "SCREEN_MODE_TEXT"
| "SCREEN_MODE_QR"
| "SCREEN_MODE_TEAMS";
export type ScreenTimerType =
| "SCREEN_TIMER_TYPE_BREAK"
| "SCREEN_TIMER_TYPE_BEFORE_GAME"
| "SCREEN_TIMER_TYPE_UNTIL_END";
export type ScreenTimerState =
| "SCREEN_TIMER_STATE_UNSPECIFIED"
| "SCREEN_TIMER_STATE_STOPPED"
@@ -568,6 +574,8 @@ export type Game = {
format: string | undefined;
location: string | undefined;
export: GameExport | undefined;
wifiName: string | undefined;
wifiPassword: string | undefined;
};
// Download ready ZIP through the authenticated streaming HTTP route:
@@ -629,6 +637,8 @@ export type ScreenState = {
timerState: ScreenTimerState | undefined;
text: string | undefined;
music: MusicState | undefined;
qrUrl: string | undefined;
timerType: ScreenTimerType | undefined;
};
export type MusicState = {
@@ -760,6 +770,8 @@ export type UpdateGameMapItemRsp = {
export type SetScreenModeReq = {
gameID: number | undefined;
mode: ScreenMode | undefined;
qrUrl: string | undefined;
text: string | undefined;
};
export type SetScreenModeRsp = {
@@ -769,6 +781,7 @@ export type SetScreenTimerReq = {
gameID: number | undefined;
durationSeconds: number | undefined;
label: string | undefined;
timerType: ScreenTimerType | undefined;
};
export type SetScreenTimerRsp = {
@@ -789,6 +802,8 @@ export type ScreenPlanStep = {
text: string | undefined;
timerLabel: string | undefined;
timerDurationSeconds: number | undefined;
qrUrl: string | undefined;
timerType: ScreenTimerType | undefined;
};
export type ScreenPlan = {
@@ -823,6 +838,20 @@ export type SetScreenPlanStepRsp = {
plan: ScreenPlan | undefined;
};
export type ScreenTeam = {
ID: string | undefined;
name: string | undefined;
};
export type GetScreenTeamsReq = {
screenID: string | undefined;
accessToken: string | undefined;
};
export type GetScreenTeamsRsp = {
teams: ScreenTeam[] | undefined;
};
export type GetScreenResultsReq = {
screenID: string | undefined;
accessToken: string | undefined;
@@ -968,6 +997,8 @@ export type UpdateGameReq = {
scenarioID: number | undefined;
format: string | undefined;
location: string | undefined;
wifiName?: string;
wifiPassword?: string;
};
export type UpdateGameRsp = {
@@ -1467,6 +1498,7 @@ export interface EveningDetectiveServer {
GetScreenPlan(request: GetScreenPlanReq): Promise<GetScreenPlanRsp>;
SaveScreenPlan(request: SaveScreenPlanReq): Promise<SaveScreenPlanRsp>;
SetScreenPlanStep(request: SetScreenPlanStepReq): Promise<SetScreenPlanStepRsp>;
GetScreenTeams(request: GetScreenTeamsReq): Promise<GetScreenTeamsRsp>;
GetScreenResults(request: GetScreenResultsReq): Promise<GetScreenResultsRsp>;
DownloadGamePrintMaterials(request: GetGameReq): Promise<googleapi_HttpBody>;
PreviewTeamReceipt(request: TeamReceiptReq): Promise<googleapi_HttpBody>;
@@ -3159,6 +3191,23 @@ export function createEveningDetectiveServerClient(
method: "SetScreenPlanStep",
}) as Promise<SetScreenPlanStepRsp>;
},
GetScreenTeams(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/screens/teams`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "POST",
body,
}, {
service: "EveningDetectiveServer",
method: "GetScreenTeams",
}) as Promise<GetScreenTeamsRsp>;
},
GetScreenResults(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/screens/results`; // eslint-disable-line quotes
const body = JSON.stringify(request);
+39 -10
View File
@@ -13,7 +13,9 @@ import type {
TimerState,
ScreenStep,
} from '../src/types/screen.js'
import { parsePlan } from './screen-plan.js'
import { timerLabels, validQR } from '../src/utils/presentation.js'
import { parseTeams } from './teams.js'
import { parsePlan, parseTimerType } from './screen-plan.js'
import { parseResults } from './results.js'
import type { LocalPackage } from './package-store.js'
@@ -36,10 +38,12 @@ export function parseCommand(value: unknown): Command {
else if (
c.action === 'mode' &&
Number.isInteger(c.mode) &&
number(c.mode, 1, 8) &&
[1, 2, 4, 6, 7, 8].includes(c.mode)
number(c.mode, 1, 10) &&
[1, 2, 4, 6, 7, 8, 9, 10].includes(c.mode)
)
intent = { action: 'mode', mode: c.mode as ScreenMode }
else if (c.action === 'qr' && typeof c.qrUrl === 'string' && typeof c.text === 'string' && validQR(c.qrUrl, c.text))
intent = { action: 'qr', qrUrl: c.qrUrl, text: c.text }
else if (c.action === 'step' && Number.isSafeInteger(c.index) && number(c.index, 0))
intent = { action: 'step', index: c.index }
else if (
@@ -58,8 +62,9 @@ export function parseCommand(value: unknown): Command {
)
intent = {
action: 'timer-set',
label: c.label.trim() || 'Перерыв',
label: c.label.trim() || timerLabels[parseTimerType(c.timerType)],
durationSeconds: c.durationSeconds,
timerType: parseTimerType(c.timerType),
}
else if (
typeof c.action === 'string' &&
@@ -178,6 +183,7 @@ export class Controller {
scene: { revision: 0, mode: 1, stepIndex: null, text: '', status: 'idle', error: '' },
timer: initialTimer(),
results: null,
teams: null,
channels: { main: initialChannel(), music: initialChannel() },
samples: [],
}
@@ -301,6 +307,7 @@ export class Controller {
this.state.timer = initialTimer()
this.timerDeadline = null
this.state.results = null
this.state.teams = null
this.state.channels = {
main: { ...initialChannel(), source: catalog.media[0] ?? null },
music: initialChannel(),
@@ -372,11 +379,12 @@ export class Controller {
this.broadcast()
}
}
private configureTimer(label: string, seconds: number) {
private configureTimer(label: string, seconds: number, timerType = 0) {
this.timerDeadline = null
this.state.timer = {
revision: this.state.timer.revision + 1,
label,
timerType: parseTimerType(timerType),
durationMs: seconds * 1000,
remainingMs: seconds * 1000,
status: 'stopped',
@@ -436,10 +444,17 @@ export class Controller {
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 ||
step.mode !== scene.mode ||
([8, 9].includes(step.mode) && step.text !== scene.text) ||
(step.mode === 9 && step.qrUrl !== scene.qrUrl)
)
return
if (
step.mode === 7 &&
(step.timerLabel !== this.state.timer.label ||
((step.timerType ?? 0) !== (this.state.timer.timerType ?? 0) ||
step.timerLabel !== this.state.timer.label ||
step.timerDurationSeconds * 1000 !== this.state.timer.durationMs)
)
return
@@ -447,6 +462,15 @@ export class Controller {
scene.stepIndex = index
this.broadcast()
}
setTeams(value: unknown) {
const key = this.state.catalog.key
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
const snapshot = value === null ? null : parseTeams(value, key!)
const changed = !isDeepStrictEqual(this.state.teams?.teams, snapshot?.teams)
this.state.teams = snapshot
if (changed && this.state.scene.mode === 10) this.invalidateScene()
this.broadcast()
}
setResults(value: unknown) {
const key = this.state.catalog.key
if (value !== null && !key) throw new Error('Сначала выберите пакет игры')
@@ -487,6 +511,10 @@ export class Controller {
case 'mode':
this.mode(command.mode)
return true
case 'qr':
this.state.scene.qrUrl = command.qrUrl
this.mode(9, null, command.text)
return true
case 'text':
this.mode(8, null, command.text)
return true
@@ -494,12 +522,13 @@ export class Controller {
const step = this.state.catalog.steps[command.index]
if (!step) throw new Error('Шаг плана не найден')
if (step.mode === 7)
this.configureTimer(step.timerLabel || 'Перерыв', step.timerDurationSeconds)
this.configureTimer(step.timerLabel || timerLabels[step.timerType ?? 0], step.timerDurationSeconds, step.timerType)
this.state.scene.qrUrl = step.qrUrl ?? ''
this.mode(step.mode, command.index, step.text)
return true
}
case 'timer-set':
this.configureTimer(command.label, command.durationSeconds)
this.configureTimer(command.label, command.durationSeconds, command.timerType)
this.mode(7)
return true
case 'timer-start':
@@ -509,7 +538,7 @@ export class Controller {
this.updateTimer()
const t = this.state.timer
if (command.action === 'timer-reset') {
this.configureTimer(t.label, t.durationMs / 1000)
this.configureTimer(t.label, t.durationMs / 1000, t.timerType)
return true
}
if (command.action === 'timer-pause') {
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { Controller } from './controller.js'
import { parsePlan } from './screen-plan.js'
import { parseRemote, applyRemote } from './remote-state.js'
import { parseTeams } from './teams.js'
import { buildScreenPackage, parseScreenPackage } from './screen-package.js'
import { changeTimerType, validQR } from '../src/utils/presentation.js'
test('QR validation and timer captions preserve custom text', () => {
for (const url of ['', 'javascript:alert(1)', 'https:///missing', 'https://u:p@example.org'])
assert.equal(validQR(url), false)
assert.equal(validQR('https://example.org/?q=1#join'), true)
assert.equal(validQR('https://example.org', 'я'.repeat(5001)), false)
assert.equal(changeTimerType('Перерыв', 0, 1), 'До начала игры')
assert.equal(changeTimerType('До начала игры', 1, 2), 'До конца игры')
assert.equal(changeTimerType('Обсуждение', 0, 2), 'Обсуждение')
assert.equal(changeTimerType('', 2, 0), 'Перерыв')
const c = new Controller(() => {})
c.command({ session: c.state.session, sequence: 1, action: 'timer-set', timerType: 1, label: '', durationSeconds: 60 })
assert.equal(c.state.timer.label, 'До начала игры')
})
test('new screens survive plan/package round trip and local playback', () => {
const plan = parsePlan({
steps: [
{ mode: 'SCREEN_MODE_QR', qrUrl: 'https://example.org/join', text: 'Вступайте' },
{ mode: 'SCREEN_MODE_TEAMS' },
{ mode: 7, timerType: 'SCREEN_TIMER_TYPE_UNTIL_END', timerDurationSeconds: 60 },
],
})
assert.equal(plan.steps[2]!.timerLabel, 'До конца игры')
const pkg = buildScreenPackage('https://example.org', 1, {
game: { game: { ID: 1, name: 'Игра' } },
plan: { plan },
music: {},
})
assert.deepEqual(parseScreenPackage(pkg).steps, pkg.steps)
const c = new Controller(() => {})
c.setPlan(plan.steps)
const command = (index: number) =>
c.command({ session: c.state.session, sequence: c.state.sequence + 1, action: 'step', index })
command(0)
assert.equal(c.state.scene.qrUrl, 'https://example.org/join')
assert.equal(c.state.scene.text, 'Вступайте')
command(1)
assert.equal(c.state.scene.mode, 10)
command(2)
assert.equal(c.state.timer.timerType, 2)
assert.equal(c.state.timer.status, 'stopped')
assert.throws(() => parsePlan({ steps: [{ mode: 9, qrUrl: 'javascript:alert(1)' }] }))
assert.throws(() => parsePlan({ steps: [{ mode: 7, timerDurationSeconds: 60, timerType: 3 }] }))
const legacy = parsePlan({
steps: [{ mode: 7, timerLabel: 'Мой текст', timerDurationSeconds: 60 }],
})
assert.equal(legacy.steps[0]!.timerType ?? 0, 0)
assert.equal(legacy.steps[0]!.timerLabel, 'Мой текст')
})
test('remote QR changes and timer types reach the output controller', () => {
const c = new Controller(() => {})
const previous = parseRemote({ mode: 1, stateVersion: '1' })
const qr = parseRemote({
mode: 9,
stateVersion: '2',
qrUrl: 'https://example.org',
text: 'Текст',
})
applyRemote(c, previous, qr)
assert.equal(c.state.scene.qrUrl, qr.qrUrl)
const timer = parseRemote({
mode: 7,
stateVersion: '3',
timerType: 'SCREEN_TIMER_TYPE_BEFORE_GAME',
})
applyRemote(c, qr, timer)
assert.equal(c.state.timer.timerType, 1)
})
test('team snapshots sort names, strip private fields and reject another game', () => {
const packageKey = 'a'.repeat(64)
const snapshot = {
packageKey,
capturedAt: '2026-10-23T12:00:00Z',
teams: [
{ id: '2', name: 'Ястребы', password: 'SECRET' },
{ id: '1', name: 'Агенты' },
],
}
const parsed = parseTeams(snapshot, packageKey)
assert.deepEqual(
parsed.teams.map((team) => team.name),
['Агенты', 'Ястребы'],
)
assert.equal(JSON.stringify(parsed).includes('SECRET'), false)
assert.throws(() => parseTeams(snapshot, 'b'.repeat(64)))
assert.throws(() =>
parseTeams({ ...snapshot, teams: [snapshot.teams[0], snapshot.teams[0]] }, packageKey),
)
assert.equal(parseTeams({ ...snapshot, teams: [] }, packageKey).teams.length, 0)
})
+26
View File
@@ -68,6 +68,8 @@ async function fixture(t: TestContext, secure = true) {
refreshResponse: null as (() => Promise<Response>) | null,
advanceCalls: 0,
resultError: false,
teamsError: false,
screenTeams: [{ ID: '2', name: 'Ястребы' }, { ID: '1', name: 'Агенты' }],
title: 'Дело',
gameStatus: 'finish',
gameError: 0,
@@ -138,6 +140,7 @@ async function fixture(t: TestContext, secure = true) {
model.advanceCalls++
return json({ state: objectMusic(3, 1) })
}
if (path === '/api/screens/teams') return model.teamsError ? json({}, 500) : json({ teams: model.screenTeams })
if (path === '/api/screens/results') {
if (model.resultError) return json({}, 400)
return json({
@@ -224,6 +227,8 @@ async function fixture(t: TestContext, secure = true) {
...model.remote,
mode: step.mode,
text: step.text,
qrUrl: step.qrUrl,
timerType: step.timerType,
command: 0,
timerLabel: step.timerLabel,
timerDurationSeconds: step.timerDurationSeconds || 600,
@@ -2187,3 +2192,24 @@ test('статус опрашивается раз в две секунды и
assert.equal(count(), before + 1)
assert.equal(f.online.snapshot().game?.status, 'run')
})
test('список команд обновляется, сохраняется для офлайна и не переносится в другую игру', async (t) => {
const f = await fixture(t)
await f.prepare()
await f.online.refreshTeams()
const key = f.controller.state.catalog.key!
assert.deepEqual(f.controller.state.teams?.teams.map(team => team.name), ['Агенты', 'Ястребы'])
f.model.teamsError = true
await assert.rejects(f.online.refreshTeams())
assert.equal(f.controller.state.teams?.teams.length, 2)
await f.online.close()
const c = new Controller(() => {})
const restored = new Online(join(f.directory, 'connection'), f.store, c, f.secrets, f.fetchAPI)
t.after(() => restored.close())
f.model.offline = true
await restored.initialize()
assert.equal(c.state.catalog.key, key)
assert.deepEqual(c.state.teams?.teams.map(team => team.name), ['Агенты', 'Ястребы'])
await restored.select(null)
assert.equal(c.state.teams, null)
})
+67 -7
View File
@@ -1,6 +1,6 @@
import { requestErrorContext } from './request-error.js'
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
import { apiScreenMode, apiScreenCommand, apiPlan } from './api-values.js'
import { apiScreenMode, apiScreenCommand, apiPlan, apiTimerType } from './api-values.js'
import { ApiError, NetworkError, SessionChangedError, networkFailure } from './request-error.js'
import { mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
@@ -24,6 +24,7 @@ import type {
import type { Controller } from './controller.js'
import { PackageStore, packageKey } from './package-store.js'
import { INCOMPLETE_MATERIALS_WARNING, type ScreenPackage, prepareScreenPackage, resolveMediaURL } from './screen-package.js'
import { parseTeams } from './teams.js'
import { parseResults } from './results.js'
import { parsePlan } from './screen-plan.js'
import {
@@ -482,6 +483,8 @@ export class Online {
private pollBusy = false
private pollSession = ''
private lastSound: boolean | null = null
private lastTeams = -Infinity
private teamsBusy = false
private lastResults = -Infinity
private resultsBusy = false
private advanceBusy = false
@@ -607,6 +610,7 @@ export class Online {
if (intent.action === 'step')
mode = this.controller.state.catalog.steps[intent.index]?.mode ?? mode
if (intent.action === 'text') mode = 8
if (intent.action === 'qr') mode = 9
if (intent.action === 'timer-set') mode = 7
if (intent.action === 'select') mode = intent.sourceId === 'intro' ? 2 : 4
return [
@@ -664,7 +668,7 @@ export class Online {
if (generation !== this.generation || session !== this.controller.state.session) return
if (
version !== this.planVersion &&
['step', 'mode', 'text', 'select', 'timer-set', 'playlist', 'track', 'next'].includes(
['step', 'mode', 'text', 'qr', 'select', 'timer-set', 'playlist', 'track', 'next'].includes(
intent.action,
)
)
@@ -711,10 +715,12 @@ export class Online {
? 'MUSIC_PLAYBACK_PAUSED'
: 'MUSIC_PLAYBACK_STOPPED',
})
} else if (intent.action === 'mode' || intent.action === 'select') {
} else if (intent.action === 'mode' || intent.action === 'select' || intent.action === 'qr') {
await this.client(true, AbortSignal.timeout(5000)).SetScreenMode({
gameID: pkg.package.game.id,
mode: apiScreenMode(localState.scene.mode),
qrUrl: localState.scene.qrUrl,
text: localState.scene.text,
})
} else if (intent.action === 'text') {
const current = parsePlan(
@@ -760,6 +766,7 @@ export class Online {
gameID: pkg.package.game.id,
label: intent.label,
durationSeconds: intent.durationSeconds,
timerType: apiTimerType(intent.timerType),
})
} else {
const index = this.controller.state.scene.stepIndex
@@ -821,9 +828,10 @@ export class Online {
!isMusicCommand &&
(remote.command !== expectedCommand ||
remote.mode !== scene.mode ||
(remote.mode === 8 && remote.text !== scene.text) ||
([8, 9].includes(remote.mode) && remote.text !== scene.text) ||
(remote.mode === 9 && remote.qrUrl !== scene.qrUrl) ||
(remote.mode === 7 &&
(remote.timer.label !== this.controller.state.timer.label ||
(remote.timer.timerType !== (this.controller.state.timer.timerType ?? 0) || 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) ||
@@ -1098,7 +1106,11 @@ export class Online {
}
}
this.lastResults = -Infinity
if (key) await this.restoreResults(key)
this.lastTeams = -Infinity
if (key) {
await this.restoreResults(key)
await this.restoreTeams(key)
}
if (persist) {
await this.saveSelection()
await this.persistSecrets()
@@ -1462,6 +1474,7 @@ export class Online {
await this.atomic(`plan-${key}.json`, this.cachedPlan)
this.previous = null
await this.restoreResults(key)
await this.restoreTeams(key)
if (action === 'select-game') {
await this.saveSelection()
await this.persistSecrets()
@@ -1483,6 +1496,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, `teams-${key}.json`), { force: true })
await rm(join(this.directory, `plan-${key}.json`), { force: true })
this.state.packagesRevision++
return
@@ -1504,7 +1518,7 @@ export class Online {
await this.persistSecrets()
await this.packages.clear()
for (const name of await readdir(this.directory)) {
if (/^(plan|results)-[a-f0-9]{64}\.json(?:\.tmp)?$/.test(name))
if (/^(plan|results|teams)-[a-f0-9]{64}\.json(?:\.tmp)?$/.test(name))
await rm(join(this.directory, name), { force: true })
}
this.state.notice = 'Все сохранённые игры удалены'
@@ -1688,6 +1702,48 @@ export class Online {
return { id: integer(g.ID, 1), name: text(g.name), status: text(g.status ?? '') }
})
}
private async restoreTeams(key: string) {
try {
const snapshot = parseTeams(
JSON.parse(await readFile(join(this.directory, `teams-${key}.json`), 'utf8')),
key,
)
if (this.controller.state.catalog.key === key) this.controller.setTeams(snapshot)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT')
this.state.error = 'Сохранённый список команд недоступен; подключитесь к игре'
}
}
async refreshTeams() {
if (this.teamsBusy) return
const screen = this.screen
const key = this.controller.state.catalog.key
if (!screen || !key) return
const generation = this.generation
this.teamsBusy = true
this.lastTeams = Date.now()
try {
const result = await this.client(false, AbortSignal.timeout(5000)).GetScreenTeams({
screenID: screen.id,
accessToken: screen.token,
})
if (generation !== this.generation || key !== this.controller.state.catalog.key) return
if (!Array.isArray(result.teams ?? [])) throw new Error('Некорректный список команд')
const snapshot = parseTeams(
{
packageKey: key,
capturedAt: new Date().toISOString(),
teams: (result.teams ?? []).map((team) => ({ id: team.ID, name: team.name })),
},
key,
)
await this.atomic(`teams-${key}.json`, JSON.stringify(snapshot))
if (generation === this.generation && key === this.controller.state.catalog.key)
this.controller.setTeams(snapshot)
} finally {
this.teamsBusy = false
}
}
private async restoreResults(key: string) {
try {
this.controller.setResults(
@@ -1799,6 +1855,10 @@ export class Online {
Date.now() - this.lastMaterials >= 5000
)
void this.refreshMaterials()
if ((next.mode === 10 || this.controller.state.scene.mode === 10) && Date.now() - this.lastTeams >= 5000)
void this.refreshTeams().catch(error => {
if (generation === this.generation) this.state.error = error instanceof Error ? error.message : 'Список команд недоступен'
})
if (next.mode === 6 && Date.now() - this.lastResults >= 5000)
void this.refreshResults().catch((error) => {
if (generation === this.generation)
+1 -1
View File
@@ -213,7 +213,7 @@ test('не допускает пустой текст и некорректны
{ action: 'timer-set', label: 'x'.repeat(201), durationSeconds: 10 },
{ action: 'mode', mode: 3 },
{ action: 'mode', mode: 5 },
{ action: 'mode', mode: 9 },
{ action: 'mode', mode: 11 },
{ action: 'mode', mode: NaN },
{ action: 'step', index: -1 },
{ action: ['timer-start'] },
+17 -4
View File
@@ -1,3 +1,6 @@
import { parseTimerType } from './screen-plan.js'
import { timerLabels, validQR } from '../src/utils/presentation.js'
import type { TimerType } from '../src/types/screen.js'
import { fieldError, ResponseFieldError } from './request-error.js'
import type { Controller } from './controller.js'
import type { Intent, ScreenMode } from '../src/types/screen.js'
@@ -64,8 +67,10 @@ export interface RemoteState {
volume: number
sound: boolean
text: string
qrUrl: string
timer: {
label: string
timerType?: TimerType
durationMs: number
remainingMs: number
status: 'stopped' | 'running' | 'paused' | 'finished'
@@ -99,11 +104,15 @@ export function parseRemote(value: unknown): RemoteState {
'RESULTS',
'TIMER',
'TEXT',
'QR',
'TEAMS',
],
'state.mode',
) || 1
if (![1, 2, 4, 6, 7, 8].includes(mode))
if (![1, 2, 4, 6, 7, 8, 9, 10].includes(mode))
throw fieldError('state.mode', 'актуальный режим экрана', s.mode, 'Некорректный режим экрана')
const qrUrl = text(s.qrUrl ?? '', 'state.qrUrl')
if (mode === 9 && !validQR(qrUrl, text(s.text ?? ''))) throw new Error('Некорректные параметры QR-кода')
const duration = integer(s.timerDurationSeconds ?? 600, 1, 86400, 'state.timerDurationSeconds')
const remaining = integer(
s.timerRemainingSeconds ?? 0,
@@ -192,8 +201,10 @@ export function parseRemote(value: unknown): RemoteState {
volume: integer(s.volume ?? 0, 0, 100, 'state.volume'),
sound: s.soundEnabled === true,
text: text(s.text ?? '', 'state.text').slice(0, 10000),
qrUrl,
timer: {
label: text(s.timerLabel ?? 'Перерыв', 'state.timerLabel').slice(0, 200),
timerType: parseTimerType(s.timerType),
label: text(s.timerLabel ?? timerLabels[parseTimerType(s.timerType)], 'state.timerLabel').slice(0, 200),
durationMs: duration * 1000,
remainingMs: remaining * 1000,
status: (['stopped', 'stopped', 'running', 'paused', 'finished'] as const)[timerState]!,
@@ -237,11 +248,13 @@ function applyRemoteState(controller: Controller, previous: RemoteState, next: R
(next.command >= 8 && controller.state.scene.mode !== 7) ||
next.mode !== previous.mode ||
next.text !== previous.text ||
next.qrUrl !== previous.qrUrl ||
(next.mode === 7 &&
(next.timer.label !== previous.timer.label ||
(next.timer.timerType !== previous.timer.timerType || next.timer.label !== previous.timer.label ||
next.timer.durationMs !== previous.timer.durationMs))
) {
if (next.mode === 8 && next.text.trim()) send(controller, { action: 'text', text: next.text })
if (next.mode === 9) send(controller, { action: 'qr', qrUrl: next.qrUrl, text: next.text })
else if (next.mode === 8 && next.text.trim()) send(controller, { action: 'text', text: next.text })
else send(controller, { action: 'mode', mode: next.mode })
}
if (next.mode === 7) controller.synchronizeTimer(next.timer)
+15 -3
View File
@@ -6,6 +6,9 @@ import {
networkFailure,
type NetworkErrorCode,
} from './request-error.js'
import { parseTimerType } from './screen-plan.js'
import { validQR } from '../src/utils/presentation.js'
import type { TimerType } from '../src/types/screen.js'
import { createHash } from 'node:crypto'
export const INCOMPLETE_MATERIALS_WARNING = 'Получен неполный список материалов. Подключите экран к игре для уточнения'
@@ -21,7 +24,7 @@ export interface ScreenPackage {
intro: string | null
conclusion: string | null
}
steps: { mode: number; text: string; timerLabel: string; timerDurationSeconds: number }[]
steps: { mode: number; text: string; timerLabel: string; timerDurationSeconds: number; qrUrl?: string; timerType?: TimerType }[]
music: {
scenarioPlaylistId: number | null
gamePlaylistId: number | null
@@ -118,16 +121,18 @@ const modes = [
'RESULTS',
'TIMER',
'TEXT',
'QR',
'TEAMS',
]
function mode(value: unknown, field?: string): number {
const result =
typeof value === 'string' && value.startsWith('SCREEN_MODE_')
? modes.indexOf(value.slice('SCREEN_MODE_'.length))
: integer(value, 0, field)
if (result < 1 || result > 8)
if (result < 1 || result > 10)
throw fieldError(
field,
'режим экрана 1–8 или enum ScreenMode',
'режим экрана 1–10 или enum ScreenMode',
value,
'Неизвестный режим показа',
)
@@ -207,12 +212,17 @@ export function buildScreenPackage(
const steps = list(plan.steps, 'plan.steps').map((value, index) => {
const field = `plan.steps[${index}]`
const step = object(value, field)
const timerType = parseTimerType(step.timerType)
const qrUrl = text(step.qrUrl, `${field}.qrUrl`)
const result = {
...(qrUrl ? { qrUrl } : {}),
...(timerType ? { timerType } : {}),
mode: mode(step.mode, `${field}.mode`),
text: text(step.text, `${field}.text`),
timerLabel: text(step.timerLabel, `${field}.timerLabel`),
timerDurationSeconds: integer(step.timerDurationSeconds, 0, `${field}.timerDurationSeconds`),
}
if (result.mode === 9 && !validQR(qrUrl, result.text)) throw new Error('Некорректные параметры QR-кода')
if (result.mode === 8 && !result.text.trim())
throw new ResponseFieldError(
`${field}.text`,
@@ -510,6 +520,8 @@ export function parseScreenPackage(value: unknown): ScreenPackage {
mode: step.mode,
text: step.text,
timerLabel: step.timerLabel,
qrUrl: step.qrUrl,
timerType: step.timerType,
timerDurationSeconds: step.timerDurationSeconds,
}
}),
+28 -3
View File
@@ -1,3 +1,5 @@
import { timerLabels, validQR } from '../src/utils/presentation.js'
import type { TimerType } from '../src/types/screen.js'
import { ResponseFieldError } from './request-error.js'
import type { ScreenMode, ScreenPlan } from '../src/types/screen.js'
import { integer, object, text } from './remote-state.js'
@@ -12,6 +14,8 @@ const modes = [
'RESULTS',
'TIMER',
'TEXT',
'QR',
'TEAMS',
]
export function parsePlan(value: unknown): ScreenPlan {
@@ -29,13 +33,13 @@ export function parsePlan(value: unknown): ScreenPlan {
? modes.indexOf(step.mode.slice(12))
: step.mode,
1,
8,
10,
`${field}.mode`,
)
} catch {
throw new ResponseFieldError(
`${field}.mode`,
'режим экрана 1–8 или enum ScreenMode',
'режим экрана 1–10 или enum ScreenMode',
step.mode,
'Некорректный режим экрана',
)
@@ -45,6 +49,9 @@ export function parsePlan(value: unknown): ScreenPlan {
content = mode === 3 ? 'Успехов' : 'Спасибо за игру!'
mode = 8
}
const timerType = parseTimerType(step.timerType)
const qrUrl = text(step.qrUrl ?? '', `${field}.qrUrl`)
if (mode === 9 && !validQR(qrUrl, content)) throw new Error('Укажите ссылку HTTP/HTTPS до 2048 байт и текст до 10000 байт')
const label = text(step.timerLabel ?? '', `${field}.timerLabel`).trim()
const seconds = integer(
step.timerDurationSeconds ?? 0,
@@ -69,8 +76,10 @@ export function parsePlan(value: unknown): ScreenPlan {
return {
mode: mode as ScreenMode,
text: content,
timerLabel: mode === 7 ? label || 'Перерыв' : label,
timerLabel: mode === 7 ? label || timerLabels[timerType] : label,
timerDurationSeconds: seconds,
...(qrUrl ? { qrUrl } : {}),
...(timerType ? { timerType } : {}),
}
})
return {
@@ -83,3 +92,19 @@ export function parsePlan(value: unknown): ScreenPlan {
),
}
}
export function parseTimerType(value: unknown): TimerType {
const names = [
'SCREEN_TIMER_TYPE_BREAK',
'SCREEN_TIMER_TYPE_BEFORE_GAME',
'SCREEN_TIMER_TYPE_UNTIL_END',
]
return integer(
typeof value === 'string' && value.startsWith('SCREEN_TIMER_TYPE_')
? names.indexOf(value)
: (value ?? 0),
0,
2,
'timerType',
) as TimerType
}
+23
View File
@@ -0,0 +1,23 @@
import type { TeamsSnapshot } from '../src/types/screen.js'
import { object, text } from './remote-state.js'
export function parseTeams(value: unknown, packageKey: string): TeamsSnapshot {
const data = object(value)
if (
!/^[a-f0-9]{64}$/.test(packageKey) ||
data.packageKey !== packageKey ||
!Array.isArray(data.teams)
)
throw new Error('Некорректный список команд игры')
const captured = Date.parse(text(data.capturedAt))
if (!Number.isFinite(captured)) throw new Error('Некорректная дата списка команд')
const teams = data.teams
.map((value) => {
const team = object(value)
return { id: text(team.id), name: text(team.name) }
})
.sort((a, b) => a.name.localeCompare(b.name, 'ru') || a.id.localeCompare(b.id))
if (teams.some((team) => !team.id) || new Set(teams.map((team) => team.id)).size !== teams.length)
throw new Error('Повтор или отсутствие команды')
return { packageKey, capturedAt: new Date(captured).toISOString(), teams }
}