up
This commit is contained in:
+15
-4
@@ -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 без сигнала и автоматического перехода.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -408,6 +408,7 @@ onUnmounted(() => {
|
||||
<ScreenPlanPanel
|
||||
:catalog="state.catalog"
|
||||
:results="state.results"
|
||||
:team-names="state.teams?.teams.map(team => team.name)"
|
||||
:viewport="previewViewport"
|
||||
:steps="state.catalog.steps"
|
||||
:current-index="state.scene.stepIndex"
|
||||
|
||||
@@ -10,6 +10,10 @@ const audio = ref<HTMLAudioElement | null>(null)
|
||||
const video = ref<HTMLVideoElement | null>(null)
|
||||
const musicA = ref<HTMLAudioElement | null>(null)
|
||||
const musicB = ref<HTMLAudioElement | null>(null)
|
||||
const viewport = ref({ width: window.innerWidth, height: window.innerHeight })
|
||||
function updateViewport() {
|
||||
viewport.value = { width: window.innerWidth, height: window.innerHeight }
|
||||
}
|
||||
const surface = ref<HTMLElement | null>(null)
|
||||
const state = shallowRef<State | null>(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"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import { NQrCode } from 'naive-ui'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
import type { Catalog, ResultsSnapshot, ScreenMode } from '../types/screen'
|
||||
import ResultsTable from './ResultsTable.vue'
|
||||
|
||||
@@ -7,6 +8,8 @@ const props = withDefaults(defineProps<{
|
||||
catalog: Catalog
|
||||
mode: ScreenMode
|
||||
text: string
|
||||
qrUrl?: string
|
||||
teamNames?: string[]
|
||||
timerLabel: string
|
||||
timerDisplay: string
|
||||
results: ResultsSnapshot | null
|
||||
@@ -20,6 +23,7 @@ const props = withDefaults(defineProps<{
|
||||
animatedResults?: boolean
|
||||
viewport?: { width: number; height: number }
|
||||
}>(), {
|
||||
qrUrl: '',
|
||||
revision: 0,
|
||||
imageVersion: '',
|
||||
imageError: false,
|
||||
@@ -28,6 +32,8 @@ const props = withDefaults(defineProps<{
|
||||
introTime: '',
|
||||
animatedResults: false,
|
||||
})
|
||||
const teamColumns = computed(() => Math.max(1, Math.ceil(Math.sqrt((props.teamNames?.length ?? 0) / 4))))
|
||||
const teamRows = computed(() => Math.max(1, Math.ceil((props.teamNames?.length ?? 0) / teamColumns.value)))
|
||||
const failedImages = reactive(new Set<string>())
|
||||
watch(() => [props.imageVersion, props.catalog.cover, props.catalog.background], () => failedImages.clear(), { flush: 'sync' })
|
||||
function imageURL(url: string, version: string) {
|
||||
@@ -93,6 +99,17 @@ function imageURL(url: string, version: string) {
|
||||
<h1>{{ timerLabel }}</h1>
|
||||
<strong>{{ timerDisplay }}</strong>
|
||||
</section>
|
||||
<section v-else-if="mode === 9" class="qr-scene" aria-label="QR-код">
|
||||
<NQrCode v-if="qrUrl" :value="qrUrl" :size="Math.max(64, Math.round((viewport?.height ?? 720) * 0.6))" :padding="24" />
|
||||
<p v-if="text" class="qr-caption">{{ text }}</p>
|
||||
</section>
|
||||
<section v-else-if="mode === 10" class="teams-scene" aria-label="Список команд">
|
||||
<p v-if="!teamNames" class="message">Список команд ещё не загружен</p>
|
||||
<div v-else-if="teamNames.length" class="team-names" :style="{ gridTemplateColumns: `repeat(${teamColumns}, minmax(0, 1fr))`, gridTemplateRows: `repeat(${teamRows}, minmax(0, 1fr))`, fontSize: `min(calc(var(--scene-vh) * ${40 / teamRows}), calc(var(--scene-vw) * ${5 / teamColumns}))` }">
|
||||
<p v-for="(name, index) in teamNames" :key="index">{{ name }}</p>
|
||||
</div>
|
||||
<p v-else class="message">Команды пока не зарегистрированы</p>
|
||||
</section>
|
||||
<p v-else-if="mode === 8" class="message text">{{ text }}</p>
|
||||
</div>
|
||||
<slot />
|
||||
@@ -198,4 +215,39 @@ function imageURL(url: string, version: string) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.qr-scene {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: calc(var(--scene-vw) * 4);
|
||||
justify-content: center;
|
||||
max-width: 92%;
|
||||
}
|
||||
|
||||
.qr-caption {
|
||||
font-size: calc(var(--scene-vw) * 3.5);
|
||||
max-height: calc(var(--scene-vh) * 85);
|
||||
max-width: calc(var(--scene-vw) * 40);
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.teams-scene {
|
||||
height: 90%;
|
||||
width: 92%;
|
||||
}
|
||||
|
||||
.team-names {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: calc(var(--scene-vw) * 1.5);
|
||||
grid-auto-flow: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.team-names p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,12 +3,13 @@ import { NAlert, NButton, NInput, NInputNumber, NSelect, NSpace } from 'naive-ui
|
||||
import { computed } from 'vue'
|
||||
import { playbackControl } from '../utils/playbackControl'
|
||||
import type { Catalog, ChannelState, Intent, ResultsSnapshot, ScreenMode, ScreenStep, TimerState } from '../types/screen'
|
||||
import { modeLabels } from '../utils/presentation'
|
||||
import { changeTimerType, timerLabels, modeLabels } from '../utils/presentation'
|
||||
import ScreenStepPreview from './ScreenStepPreview.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
catalog: Catalog
|
||||
results: ResultsSnapshot | null
|
||||
teamNames?: string[]
|
||||
viewport: { width: number; height: number }
|
||||
steps: ScreenStep[]
|
||||
currentIndex: number | null
|
||||
@@ -33,6 +34,14 @@ const emit = defineEmits<{
|
||||
const mainControl = computed(() =>
|
||||
playbackControl({ desired: props.mainDesired, status: props.mainStatus }),
|
||||
)
|
||||
const timerTypeOptions = timerLabels.map((label, value) => ({ label, value }))
|
||||
function updateTimerType(index: number, value: 0 | 1 | 2) {
|
||||
const step = props.draft[index]!
|
||||
emit('updateStep', index, {
|
||||
timerType: value,
|
||||
timerLabel: changeTimerType(step.timerLabel, step.timerType, value),
|
||||
})
|
||||
}
|
||||
const modeOptions = Object.entries(modeLabels).map(([mode, label]) => ({
|
||||
value: Number(mode) as ScreenMode,
|
||||
label,
|
||||
@@ -56,6 +65,7 @@ function timerLabel(index: number) {
|
||||
:step="step"
|
||||
:catalog="catalog"
|
||||
:results="results"
|
||||
:team-names="teamNames"
|
||||
:viewport="viewport"
|
||||
/>
|
||||
</div>
|
||||
@@ -85,7 +95,7 @@ function timerLabel(index: number) {
|
||||
</div>
|
||||
</div>
|
||||
<n-input
|
||||
v-if="step.mode === 8"
|
||||
v-if="step.mode === 8 || step.mode === 9"
|
||||
:value="step.text"
|
||||
type="textarea"
|
||||
:disabled="isBusy"
|
||||
@@ -93,7 +103,15 @@ function timerLabel(index: number) {
|
||||
:input-props="{ 'aria-label': `Текст шага ${index + 1}` }"
|
||||
@update:value="emit('updateStep', index, { text: $event })"
|
||||
/>
|
||||
<template v-if="step.mode === 7">
|
||||
<div v-if="step.mode === 7">
|
||||
<label :for="`plan-type-${index}`">Тип таймера</label>
|
||||
<n-select
|
||||
:value="step.timerType ?? 0"
|
||||
:options="timerTypeOptions"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ id: `plan-type-${index}` }"
|
||||
@update:value="updateTimerType(index, $event)"
|
||||
/>
|
||||
<label :for="`plan-label-${index}`">Надпись таймера</label>
|
||||
<n-input
|
||||
:value="step.timerLabel"
|
||||
@@ -110,7 +128,17 @@ function timerLabel(index: number) {
|
||||
:input-props="{ id: `plan-seconds-${index}` }"
|
||||
@update:value="emit('updateStep', index, { timerDurationSeconds: $event ?? 0 })"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="step.mode === 9">
|
||||
<label :for="`plan-qr-${index}`">Ссылка для QR-кода (обязательно)</label>
|
||||
<n-input
|
||||
:value="step.qrUrl ?? ''"
|
||||
:disabled="isBusy"
|
||||
:input-props="{ id: `plan-qr-${index}` }"
|
||||
placeholder="https://example.com"
|
||||
@update:value="emit('updateStep', index, { qrUrl: $event })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<n-space>
|
||||
<n-button size="small" :disabled="isBusy" @click="emit('add')">Добавить шаг</n-button>
|
||||
@@ -133,6 +161,7 @@ function timerLabel(index: number) {
|
||||
:step="step"
|
||||
:catalog="catalog"
|
||||
:results="results"
|
||||
:team-names="teamNames"
|
||||
:viewport="viewport"
|
||||
/>
|
||||
</div>
|
||||
@@ -140,7 +169,7 @@ function timerLabel(index: number) {
|
||||
<div class="plan-step-title">
|
||||
<span
|
||||
>{{ modeLabels[step.mode]
|
||||
}}<template v-if="step.mode === 8">: {{ step.text }}</template
|
||||
}}<template v-if="step.mode === 8 || step.mode === 9">: {{ step.text }}</template
|
||||
><template v-if="step.mode === 7"
|
||||
>: {{ step.timerLabel }} ({{ step.timerDurationSeconds }} с)</template
|
||||
></span
|
||||
|
||||
@@ -8,6 +8,7 @@ const props = defineProps<{
|
||||
step: ScreenStep
|
||||
catalog: Catalog
|
||||
results: ResultsSnapshot | null
|
||||
teamNames?: string[]
|
||||
viewport: { width: number; height: number }
|
||||
}>()
|
||||
const source = computed(() => props.catalog.media.find(media =>
|
||||
@@ -22,6 +23,8 @@ const source = computed(() => props.catalog.media.find(media =>
|
||||
:catalog="catalog"
|
||||
:mode="step.mode"
|
||||
:text="step.text"
|
||||
:qr-url="step.qrUrl"
|
||||
:team-names="teamNames"
|
||||
:timer-label="step.timerLabel"
|
||||
:timer-display="formatTimer(step.timerDurationSeconds * 1000)"
|
||||
:results="results"
|
||||
|
||||
+14
-2
@@ -15,7 +15,8 @@ export type Intent =
|
||||
| { action: 'mode'; mode: ScreenMode }
|
||||
| { action: 'step'; index: number }
|
||||
| { action: 'text'; text: string }
|
||||
| { action: 'timer-set'; label: string; durationSeconds: number }
|
||||
| { action: 'qr'; qrUrl: string; text: string }
|
||||
| { action: 'timer-set'; label: string; durationSeconds: number; timerType?: TimerType }
|
||||
| { action: 'timer-start' | 'timer-pause' | 'timer-resume' | 'timer-reset' }
|
||||
export type Command = Intent & { session: string; sequence: number }
|
||||
export interface MediaSource {
|
||||
@@ -30,14 +31,18 @@ export interface Playlist {
|
||||
name: string
|
||||
tracks: { id: number; name: string; source: MediaSource }[]
|
||||
}
|
||||
export type ScreenMode = 1 | 2 | 4 | 6 | 7 | 8
|
||||
export type TimerType = 0 | 1 | 2
|
||||
export type ScreenMode = 1 | 2 | 4 | 6 | 7 | 8 | 9 | 10
|
||||
export interface ScreenStep {
|
||||
qrUrl?: string
|
||||
timerType?: TimerType
|
||||
mode: ScreenMode
|
||||
text: string
|
||||
timerLabel: string
|
||||
timerDurationSeconds: number
|
||||
}
|
||||
export interface SceneState {
|
||||
qrUrl?: string
|
||||
revision: number
|
||||
mode: ScreenMode
|
||||
stepIndex: number | null
|
||||
@@ -46,6 +51,7 @@ export interface SceneState {
|
||||
error: string
|
||||
}
|
||||
export interface TimerState {
|
||||
timerType?: TimerType
|
||||
revision: number
|
||||
label: string
|
||||
durationMs: number
|
||||
@@ -99,7 +105,13 @@ export interface ChannelState {
|
||||
trackIndex: number
|
||||
loop: boolean
|
||||
}
|
||||
export interface TeamsSnapshot {
|
||||
packageKey: string
|
||||
capturedAt: string
|
||||
teams: { id: string; name: string }[]
|
||||
}
|
||||
export interface State {
|
||||
teams: TeamsSnapshot | null
|
||||
session: string
|
||||
sequence: number
|
||||
revision: number
|
||||
|
||||
@@ -7,6 +7,8 @@ export const modeLabels: Record<ScreenMode, string> = {
|
||||
6: 'Результаты',
|
||||
7: 'Таймер',
|
||||
8: 'Текст',
|
||||
9: 'QR-код',
|
||||
10: 'Список команд',
|
||||
}
|
||||
export function remainingTime(timer: TimerState, elapsedMs: number): number {
|
||||
return Math.max(0, timer.remainingMs - (timer.status === 'running' ? Math.max(0, elapsedMs) : 0))
|
||||
@@ -15,3 +17,25 @@ export function formatTimer(milliseconds: number): string {
|
||||
const seconds = Math.ceil(Math.max(0, milliseconds) / 1000)
|
||||
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export const timerLabels = ['Перерыв', 'До начала игры', 'До конца игры'] as const
|
||||
export function changeTimerType(label: string, previous = 0, next = 0): string {
|
||||
return !label.trim() || label === timerLabels[previous] ? timerLabels[next]! : label
|
||||
}
|
||||
export function validQR(link: string, caption = ''): boolean {
|
||||
try {
|
||||
const url = new URL(link)
|
||||
return (
|
||||
/^https?:\/\/[^/\\\s?#]/.test(link) &&
|
||||
['http:', 'https:'].includes(url.protocol) &&
|
||||
!!url.hostname &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
link === link.trim() &&
|
||||
new TextEncoder().encode(link).length <= 2048 &&
|
||||
new TextEncoder().encode(caption).length <= 10000
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user