up
This commit is contained in:
@@ -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
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user