diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 3bfae34..57797c3 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -90,9 +90,9 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова ## Проведение показа В «План экранов» нажмите «Показать» у нужного шага, используйте «Предыдущий шаг» / «Следующий шаг» -для переходов по плану. В редакторе шагов доступны шесть режимов: «Обложка», +для переходов по плану. В редакторе шагов доступны восемь режимов: «Обложка», «Введение», «Закрывашка», -«Результаты», «Таймер» и «Текст». Переход к шагу или режиму сам не запускает +«Результаты», «Таймер», «Текст», «QR-код» и «Список команд». Переход к шагу или режиму сам не запускает медиа и таймер. Дождитесь готовности; отправленная команда ещё не означает успешного воспроизведения — смотрите статус и фактическое изображение/звук. @@ -159,11 +159,22 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова Используемый игрой или сценарием плейлист и его треки удалить нельзя. Редактирование требует сети; сохранённая музыка продолжает играть без неё. -### Текст, таймер и результаты +### Текст, QR-код, команды, таймер и результаты - В редакторе плана добавьте шаг «Текст», заполните текст и сохраните план. Нажмите «Показать» у этого шага. Переносы строк сохраняются. -- Для шага «Таймер» задайте надпись и длительность в секундах, сохраните план +- Для шага «QR-код» укажите ссылку HTTP/HTTPS (обязательно) и поясняющий текст + (необязательно). Ссылка кодируется в QR; текст показывается рядом. После + сохранения плана этот экран работает без интернета. +- Шаг «Список команд» показывает названия команд текущей игры по алфавиту, + исключая отменённые регистрации. Пока экран открыт, список обновляется каждые + пять секунд. Последний полученный список сохраняется отдельно для каждой игры + и доступен без сети, в том числе после перезапуска приложения. Перед выездом + откройте этот шаг при подключении к серверу, чтобы сохранить актуальный список. +- Для шага «Таймер» выберите тип: «До начала игры», «Перерыв» или «До конца игры». + Надпись подставляется автоматически и доступна для редактирования. Смена типа + сохраняет пользовательскую надпись; стандартную заменяет на надпись нового типа. + Задайте длительность в секундах, сохраните план и покажите шаг. В активном шаге одна кнопка меняется между «Старт», «Пауза» и «Продолжить»; рядом доступно «Сбросить». При смене шага отсчёт продолжается в фоне; на нуле остаётся 00:00 без сигнала и автоматического перехода. diff --git a/electron/api-client.test.ts b/electron/api-client.test.ts index 05f7835..a373f46 100644 --- a/electron/api-client.test.ts +++ b/electron/api-client.test.ts @@ -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), diff --git a/electron/api-values.ts b/electron/api-values.ts index 2c61009..e3cf2e4 100644 --- a/electron/api-values.ts +++ b/electron/api-values.ts @@ -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 { - 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]! } diff --git a/electron/api/generated/crabs/evening_detective_server/index.ts b/electron/api/generated/crabs/evening_detective_server/index.ts index f004ae8..6b0220e 100644 --- a/electron/api/generated/crabs/evening_detective_server/index.ts +++ b/electron/api/generated/crabs/evening_detective_server/index.ts @@ -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; SaveScreenPlan(request: SaveScreenPlanReq): Promise; SetScreenPlanStep(request: SetScreenPlanStepReq): Promise; + GetScreenTeams(request: GetScreenTeamsReq): Promise; GetScreenResults(request: GetScreenResultsReq): Promise; DownloadGamePrintMaterials(request: GetGameReq): Promise; PreviewTeamReceipt(request: TeamReceiptReq): Promise; @@ -3159,6 +3191,23 @@ export function createEveningDetectiveServerClient( method: "SetScreenPlanStep", }) as Promise; }, + 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; + }, GetScreenResults(request) { // eslint-disable-line @typescript-eslint/no-unused-vars const path = `api/screens/results`; // eslint-disable-line quotes const body = JSON.stringify(request); diff --git a/electron/controller.ts b/electron/controller.ts index 0467793..0df0124 100644 --- a/electron/controller.ts +++ b/electron/controller.ts @@ -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') { diff --git a/electron/new-screens.test.ts b/electron/new-screens.test.ts new file mode 100644 index 0000000..f95cf08 --- /dev/null +++ b/electron/new-screens.test.ts @@ -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) +}) diff --git a/electron/online.test.ts b/electron/online.test.ts index 7c21993..04eddf9 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -68,6 +68,8 @@ async function fixture(t: TestContext, secure = true) { refreshResponse: null as (() => Promise) | 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) +}) diff --git a/electron/online.ts b/electron/online.ts index 375f375..20cda16 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -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) diff --git a/electron/presentation.test.ts b/electron/presentation.test.ts index adfba80..95963cf 100644 --- a/electron/presentation.test.ts +++ b/electron/presentation.test.ts @@ -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'] }, diff --git a/electron/remote-state.ts b/electron/remote-state.ts index a50e203..6218516 100644 --- a/electron/remote-state.ts +++ b/electron/remote-state.ts @@ -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) diff --git a/electron/screen-package.ts b/electron/screen-package.ts index e4e9e13..c7d6f5d 100644 --- a/electron/screen-package.ts +++ b/electron/screen-package.ts @@ -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, } }), diff --git a/electron/screen-plan.ts b/electron/screen-plan.ts index 1c1dcfd..7663a00 100644 --- a/electron/screen-plan.ts +++ b/electron/screen-plan.ts @@ -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 +} diff --git a/electron/teams.ts b/electron/teams.ts new file mode 100644 index 0000000..c0bf8af --- /dev/null +++ b/electron/teams.ts @@ -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 } +} diff --git a/scripts/previews-smoke.cjs b/scripts/previews-smoke.cjs index 9b2706c..ea19873 100644 --- a/scripts/previews-smoke.cjs +++ b/scripts/previews-smoke.cjs @@ -8,7 +8,7 @@ module.exports = async function previewsSmoke({ control, output, evaluate, inspe const root = document.querySelector(${JSON.stringify(root)}); if (!root) return null; const bounds = root.getBoundingClientRect(); - return ['.background', '.cover', '.message', '.intro-waveform', '.timer h1', '.timer strong', '.results', '.results h1', '.results .empty', '.results table', ${JSON.stringify(videoSelector)}].flatMap(selector => { + return ['.background', '.cover', '.message', '.intro-waveform', '.timer h1', '.timer strong', '.qr-scene', '.qr-caption', '.teams-scene', '.team-names', '.results', '.results h1', '.results .empty', '.results table', ${JSON.stringify(videoSelector)}].flatMap(selector => { const e = root.querySelector(selector); if (!e) return []; const r = e.getBoundingClientRect(); const s = getComputedStyle(e); if (s.display === 'none') return []; return [{ selector: selector.includes('video') ? 'video' : selector, text: e.textContent.trim(), x: (r.x-bounds.x)/bounds.width, y: (r.y-bounds.y)/bounds.height, width: r.width/bounds.width, height: r.height/bounds.height, font: s.fontSize, fit: s.objectFit, filter: s.filter }]; @@ -79,11 +79,17 @@ module.exports = async function previewsSmoke({ control, output, evaluate, inspe { index: 5, results: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), questions: [], teams: [] } }, { index: 5, results: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), questions: [{ code: 'A', text: 'Вопрос' }], teams: [{ id: 'one', name: 'Детективы', place: 1, totalScore: 5, actionsCount: 1, answers: [{ questionCode: 'A', score: 5 }] }] } }, ] + for (const step of [ + { mode: 9, qrUrl: 'https://example.org/join?game=7', text: 'Присоединяйтесь\nк расследованию' }, + { mode: 9, qrUrl: 'https://example.org/join', text: '' }, + { mode: 10, text: '' }, + ]) cases.push({ index: 7, catalog: { ...original.catalog, steps: original.catalog.steps.map((old, i) => i === 7 ? { ...old, ...step } : old) }, teams: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), teams: Array.from({ length: 32 }, (_, i) => ({ id: String(i), name: `Команда ${i + 1} — Длинное название` })) } }) + cases.push({ index: 7, catalog: { ...original.catalog, steps: original.catalog.steps.map((old, i) => i === 7 ? { ...old, mode: 10 } : old) }, teams: { packageKey: original.catalog.key, capturedAt: new Date().toISOString(), teams: [] } }) for (const item of cases) { const catalog = item.catalog ?? original.catalog const step = catalog.steps[item.index] - const snapshot = { ...original, revision: ++revision, catalog, results: item.results ?? original.results, - scene: { ...original.scene, revision, mode: step.mode, text: step.text, stepIndex: item.index }, + const snapshot = { ...original, revision: ++revision, catalog, results: item.results ?? original.results, teams: item.teams ?? original.teams, + scene: { ...original.scene, revision, mode: step.mode, text: step.text, qrUrl: step.qrUrl, stepIndex: item.index }, channels: { ...original.channels, main: { ...original.channels.main, source: catalog.media.find(m => m.kind === (step.mode === 2 ? 'audio' : 'video')) ?? null } } } control.webContents.send('state', snapshot) output.webContents.send('state', snapshot) @@ -91,6 +97,16 @@ module.exports = async function previewsSmoke({ control, output, evaluate, inspe await poll(() => evaluate(`document.querySelector('${miniature(item.index)} .scene-view').dataset.mode === '${step.mode}'`), 'fixture thumbnail') await new Promise(resolve => setTimeout(resolve, 50)) await compare(item.index) + if (step.mode === 9) { + await poll(() => inspect("!!document.querySelector('.qr-scene canvas')"), 'QR canvas') + assert.ok(await inspect("(() => { const c = document.querySelector('.qr-scene canvas'), r = c.getBoundingClientRect(); return r.width > 200 && r.height > 200 && r.bottom <= innerHeight && r.right <= innerWidth })()")) + if (step.text) writeFileSync('/tmp/ed054-qr-output.png', (await output.webContents.capturePage()).toPNG()) + } + if (step.mode === 10) { + assert.equal(await inspect("document.querySelectorAll('.team-names p').length"), item.teams.teams.length) + assert.ok(await inspect("Array.from(document.querySelectorAll('.team-names p')).every(e => { const r = e.getBoundingClientRect(); return r.top >= 0 && r.left >= 0 && r.bottom <= innerHeight && r.right <= innerWidth })")) + if (item.teams.teams.length) writeFileSync('/tmp/ed054-teams-output.png', (await output.webContents.capturePage()).toPNG()) + } } console.log('SCREEN_PREVIEWS_PASS') } diff --git a/src/components/ControlPage.vue b/src/components/ControlPage.vue index b1d28ad..b6b1c89 100644 --- a/src/components/ControlPage.vue +++ b/src/components/ControlPage.vue @@ -408,6 +408,7 @@ onUnmounted(() => { (null) const video = ref(null) const musicA = ref(null) const musicB = ref(null) +const viewport = ref({ width: window.innerWidth, height: window.innerHeight }) +function updateViewport() { + viewport.value = { width: window.innerWidth, height: window.innerHeight } +} const surface = ref(null) const state = shallowRef(null) const imageError = ref(false) @@ -210,6 +214,7 @@ function apply(value: State) { } } onMounted(async () => { + window.addEventListener('resize', updateViewport) try { await bridge.prepareAudioOutput() } catch { @@ -224,6 +229,7 @@ onMounted(async () => { apply(await bridge.state()) }) onUnmounted(() => { + window.removeEventListener('resize', updateViewport) disposed = true sceneAbort?.abort() clearIntroWaveform() @@ -238,7 +244,10 @@ onUnmounted(() => { v-if="state" :catalog="state.catalog" :mode="state.scene.mode" + :viewport="viewport" :text="state.scene.text" + :qr-url="state.scene.qrUrl" + :team-names="state.teams?.teams.map(team => team.name)" :timer-label="state.timer.label" :timer-display="timerDisplay" :results="state.results" diff --git a/src/components/SceneView.vue b/src/components/SceneView.vue index fbc7917..a1cd5ce 100644 --- a/src/components/SceneView.vue +++ b/src/components/SceneView.vue @@ -1,5 +1,6 @@