diff --git a/electron/main.ts b/electron/main.ts index 1212ad2..070501a 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -399,6 +399,7 @@ app } const onlineTick = setInterval(() => { void online?.poll().then(logConnection) + void online?.refreshGameState() }, 1000) const timerTick = setInterval(() => controller.tick(), 250) app.on('before-quit', () => { diff --git a/electron/online.test.ts b/electron/online.test.ts index 438da8d..3a6823c 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -65,6 +65,10 @@ async function fixture(t: TestContext, secure = true) { advanceCalls: 0, resultError: false, title: 'Дело', + gameStatus: 'finish', + gameError: 0, + gameResponse: null as (() => Promise) | null, + gameCommands: [] as string[], roles: ['organizer'], remote: { gameID: 7, @@ -156,14 +160,26 @@ async function fixture(t: TestContext, secure = true) { if (path === '/api/games') return json({ games: [ - { ID: 7, name: model.title, status: 'finish', teams: [{ password: 'TEAM-SECRET' }] }, + { ID: 7, name: model.title, status: model.gameStatus, teams: [{ password: 'TEAM-SECRET' }] }, ], }) - if (path === '/api/games/7' || path === '/api/games/8') + if (/^\/api\/games\/7\/(start|pause|play|finish|reset)$/.test(path)) { + assert.equal(init?.method, 'PUT') + assert.equal(JSON.parse(String(init?.body)).ID, 7) + if (model.gameError) return json({}, model.gameError) + const action = path.split('/').at(-1)! + model.gameCommands.push(action) + model.gameStatus = action === 'reset' ? 'draft' : action === 'finish' ? 'finish' : action === 'pause' ? 'pause' : 'run' + return json({}) + } + if (path === '/api/games/7' || path === '/api/games/8') { + if (model.gameResponse) return model.gameResponse() + if (model.gameError) return json({}, model.gameError) return json({ game: { ID: Number(path.split('/').at(-1)), name: model.title, + status: model.gameStatus, scenario: model.omitScenario ? null : { @@ -176,6 +192,7 @@ async function fixture(t: TestContext, secure = true) { }, }, }) + } if (path === '/api/games/7/screen-plan') { if (model.planResponse) return model.planResponse() if (model.planError) return json({}, model.planError) @@ -1733,3 +1750,116 @@ test('редактор музыки: CRUD, native MP3, порядок, вали f.model.playlistError = 403 await assert.rejects(f.online.act({ action: 'playlist-create', name: 'Нет прав' }), /Нет доступа/) }) + +test('управление игрой работает без привязанного экрана и подтверждается серверным состоянием', async (t) => { + const f = await fixture(t) + await f.login() + await f.online.act({ action: 'select-game', gameId: 7 }) + assert.equal(f.online.snapshot().screenId, null) + assert.equal(f.online.snapshot().game?.status, 'finish') + const key = f.controller.state.catalog.key! + for (const [command, status] of [ + ['reset', 'draft'], ['start', 'run'], ['pause', 'pause'], + ['play', 'run'], ['finish', 'finish'], + ] as const) { + await f.online.act({ action: 'game-command', command, key }) + assert.equal(f.online.snapshot().game?.status, status) + assert.equal(f.online.snapshot().games.find(g => g.id === 7)?.status, status) + assert.equal(f.controller.state.outputOpen, false) + assert.equal(f.online.snapshot().screenId, null) + } + assert.deepEqual(f.model.gameCommands, ['reset', 'start', 'pause', 'play', 'finish']) + assert.equal(f.requests.some(r => r.path === '/api/screens'), false) +}) + +test('статус игры обновляется с сайта; неизвестный статус и ошибки сохраняют последнее состояние', async (t) => { + const f = await fixture(t) + await f.login() + await f.online.act({ action: 'select-game', gameId: 7 }) + f.model.gameStatus = 'pause' + await f.online.refreshGameState(true) + assert.equal(f.online.snapshot().game?.status, 'pause') + const key = f.controller.state.catalog.key! + f.model.gameStatus = 'unexpected' + await f.online.refreshGameState(true) + assert.equal(f.online.snapshot().game?.status, 'pause') + assert.match(f.online.snapshot().gameError, /неизвестное состояние/) + for (const status of [403, 404, 500]) { + f.model.gameError = status + await f.online.refreshGameState(true) + assert.equal(f.online.snapshot().game?.status, 'pause') + assert.ok(f.online.snapshot().gameError) + await assert.rejects(f.online.act({ action: 'game-command', command: 'play', key })) + } + f.model.gameError = 0 + f.model.gameStatus = 'run' + f.model.offline = true + await f.online.refreshGameState(true) + assert.equal(f.online.snapshot().game?.status, 'pause') + assert.ok(f.online.snapshot().gameError) + f.model.offline = false + await f.online.refreshGameState(true) + assert.equal(f.online.snapshot().game?.status, 'run') + assert.equal(f.online.snapshot().gameError, '') + assert.deepEqual(f.model.gameCommands, []) +}) + +test('команда проверяет актуальный статус и ключ игры; двойные команды не выполняются', async (t) => { + const f = await fixture(t) + await f.login() + await f.online.act({ action: 'select-game', gameId: 7 }) + const key = f.controller.state.catalog.key! + await assert.rejects(f.online.act({ action: 'game-command', command: 'pause', key }), /недоступна/) + await assert.rejects(f.online.act({ action: 'game-command', command: 'reset', key: 'other' }), /изменилась/) + await assert.rejects(f.online.act({ action: 'game-command', command: 'other', key }), /Неизвестная/) + let release!: (response: Response) => void + f.model.gameResponse = () => new Promise(resolve => { release = resolve }) + const pending = f.online.act({ action: 'game-command', command: 'reset', key }) + await eventually(() => !!release, 'preflight game state') + await assert.rejects(f.online.act({ action: 'game-command', command: 'reset', key }), /Подождите/) + f.model.gameResponse = null + release(Response.json({ game: { ID: 7, name: 'Дело', status: 'finish' } })) + await pending + assert.deepEqual(f.model.gameCommands, ['reset']) +}) + +test('поздний статус и подтверждение не применяются после смены выбора или выхода', async (t) => { + const f = await fixture(t) + await f.login() + await f.online.act({ action: 'select-game', gameId: 7 }) + const key = f.controller.state.catalog.key! + for (const change of ['selection', 'logout']) { + if (change === 'logout') await f.online.act({ action: 'select-game', gameId: 7 }) + let release!: (response: Response) => void + f.model.gameResponse = () => new Promise(resolve => { release = resolve }) + const pending = f.online.refreshGameState(true) + await eventually(() => !!release, 'pending game state') + if (change === 'selection') await f.online.select(null) + else await f.online.act({ action: 'logout' }) + assert.equal(f.online.snapshot().game, null) + f.model.gameResponse = null + release(Response.json({ game: { ID: 7, name: 'Old', status: 'run' } })) + await pending + assert.equal(f.online.snapshot().game, null) + await assert.rejects(f.online.act({ action: 'game-command', command: 'reset', key })) + } + assert.deepEqual(f.model.gameCommands, []) +}) + +test('статус опрашивается раз в две секунды и во время подготовки материалов', async (t) => { + const f = await fixture(t) + await f.login() + await f.online.act({ action: 'select-game', gameId: 7 }) + const count = () => f.requests.filter(r => r.path === '/api/games/7').length + const before = count() + t.mock.timers.enable({ apis: ['Date'], now: Date.now() }) + await f.online.refreshGameState() + assert.equal(count(), before) + t.mock.timers.tick(2001) + f.online.state.busy = true + f.model.gameStatus = 'run' + await f.online.refreshGameState() + f.online.state.busy = false + assert.equal(count(), before + 1) + assert.equal(f.online.snapshot().game?.status, 'run') +}) diff --git a/electron/online.ts b/electron/online.ts index ca956f1..8898c17 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -9,7 +9,10 @@ import { type GameMaterial, type OnlineIntent, type OnlineState, + type GameStatus, + type GameCommand, } from '../src/types/online.js' +import { gameCommands, gameStatuses } from '../src/utils/game-status.js' import type { Intent, PlanStatus, @@ -71,6 +74,8 @@ function token(value: unknown): string { } export class Online { readonly state: OnlineState = { + game: null, + gameError: '', server: DEFAULT_SERVER, currentGameId: null, materials: [], @@ -112,6 +117,84 @@ export class Online { private receivedSignature = '' private localCommandVersion = 0 private materialSignature = '' + private gameContext = '' + private lastGameRefresh = -Infinity + private gameRevision = 0 + private gameRequest: { context: string; promise: Promise } | null = null + private syncGameContext() { + const pkg = this.selectedGame() + const context = pkg && this.state.authenticated && this.credentials?.server === pkg.package.server + ? `${this.generation}:${pkg.key}:${pkg.package.server}` : '' + if (context !== this.gameContext) { + this.gameContext = context + this.gameRevision++ + this.lastGameRefresh = -Infinity + this.state.game = null + this.state.gameError = '' + } + return context + } + async refreshGameState(force = false): Promise { + const context = this.syncGameContext() + if (!context) return + if (!force && this.gameRequest?.context === context) return this.gameRequest.promise + if (!force && Date.now() - this.lastGameRefresh < 2000) return + const pkg = this.selectedGame()! + const revision = force ? ++this.gameRevision : this.gameRevision + this.lastGameRefresh = Date.now() + const current = () => this.syncGameContext() === context && this.gameRevision === revision + const promise = (async () => { + try { + const result = await this.client(true, AbortSignal.timeout(5000)).GetGame({ ID: pkg.package.game.id }) + if (!current()) return + const game = object(result.game) + const id = integer(game.ID, 1) + const status = text(game.status) + if (id !== pkg.package.game.id || !Object.hasOwn(gameStatuses, status)) + throw new Error('Сервер вернул неизвестное состояние игры') + this.state.game = { key: pkg.key, id, name: text(game.name), status: status as GameStatus } + this.state.gameError = '' + const listed = this.state.games.find(g => g.id === id) + if (listed) listed.status = status + } catch (error) { + if (current()) this.state.gameError = error instanceof Error ? error.message : 'Состояние игры недоступно' + } + })() + const request = { context, promise } + this.gameRequest = request + try { + await promise + } finally { + if (this.gameRequest === request) this.gameRequest = null + } + } + private async gameCommand(value: unknown, key: unknown) { + const context = this.syncGameContext() + if (!context) throw new Error('Выберите игру и войдите на её сервер') + if (key !== this.controller.state.catalog.key) throw new Error('Выбранная игра изменилась') + const command = text(value) as GameCommand + if (!['start', 'pause', 'play', 'finish', 'reset'].includes(command)) + throw new Error('Неизвестная команда игры') + await this.refreshGameState(true) + if (this.syncGameContext() !== context) throw new SessionChangedError() + const game = this.state.game + if (!game || this.state.gameError) throw new Error(this.state.gameError || 'Состояние игры недоступно') + if (!gameCommands[game.status].includes(command)) throw new Error('Команда недоступна в текущем состоянии игры') + const client = this.client(true, AbortSignal.timeout(5000)) + const actions = { + start: () => client.StartGame({ ID: game.id }), + pause: () => client.PauseGame({ ID: game.id }), + play: () => client.PlayGame({ ID: game.id }), + finish: () => client.FinishGame({ ID: game.id }), + reset: () => client.ResetGame({ ID: game.id }), + } + this.gameRevision++ + try { + await actions[command]() + } finally { + if (this.syncGameContext() === context) await this.refreshGameState(true) + } + } private async description(gameId: number): Promise { if (!this.credentials) throw new ApiError(401) return prepareScreenPackage( @@ -840,6 +923,7 @@ export class Online { return this.state.busy } snapshot(): OnlineState { + this.syncGameContext() const pkg = this.selectedGame()?.package const names = new Map() const usages = new Map() @@ -1157,6 +1241,7 @@ export class Online { const isBackgroundRefresh = data.action === 'playlists' this.state.busy = true const messages: Record = { + 'game-command': 'Изменение состояния игры…', playlists: 'Загрузка списка плейлистов…', 'download-material': 'Загрузка файла игры…', 'playlist-create': 'Создание плейлиста…', @@ -1198,12 +1283,16 @@ export class Online { this.state.busy = false this.state.busyMessage = '' this.state.progress = null + if (['login', 'select-game', 'connect'].includes(String(data.action))) + await this.refreshGameState(true) } return this.snapshot() } private async perform(data: Record) { const action = data.action as OnlineIntent['action'] switch (action) { + case 'game-command': + return this.gameCommand(data.command, data.key) case 'download-material': { const pkg = this.selectedGame() const assetId = text(data.assetId) diff --git a/scripts/online-smoke.cjs b/scripts/online-smoke.cjs index d34ee4f..6025275 100644 --- a/scripts/online-smoke.cjs +++ b/scripts/online-smoke.cjs @@ -83,6 +83,7 @@ module.exports = async function onlineSmoke({ game: { ID: 6, name: 'HTTP smoke', + status: 'finish', scenario: { story: invalidStory ? [] : null }, }, }) @@ -250,7 +251,7 @@ module.exports = async function onlineSmoke({ poll( () => evaluate(`(() => { const auth = document.querySelectorAll('.n-card-header [data-testid="auth-status"]') - const game = document.querySelectorAll('.n-card-header [data-testid="game-connection-status"]') + const game = document.querySelectorAll('[data-testid="game-control"] [data-testid="game-connection-status"]') return auth.length === 1 && game.length === 1 && auth[0].textContent.trim() === '${authenticated ? 'Вход выполнен' : 'Не выполнен'}' && game[0].textContent.trim() === '${connected ? 'Подключено' : 'Не подключено'}' @@ -280,7 +281,7 @@ module.exports = async function onlineSmoke({ 'login complete', ) await evaluate(`Array.from(document.querySelectorAll('button')) - .find(b => b.textContent.trim() === 'Сбросить')?.click()`) + .find(b => b.textContent.trim() === 'Сменить игру')?.click()`) await poll( () => evaluate( @@ -291,15 +292,15 @@ module.exports = async function onlineSmoke({ await poll( () => evaluate( - `document.querySelectorAll('main.control > .n-card').length === 3 && document.querySelectorAll('main.control > .game-grid > .n-card').length === 2 && document.querySelectorAll('.n-card-header__main').length === 6`, + `document.querySelectorAll('main.control > .n-card').length === 5 && document.querySelectorAll('[data-testid="game-control"] .n-card').length === 0 && document.querySelectorAll('.n-card-header__main').length === 6`, ), - 'six control cards', + 'six control cards with merged game controls', ) assert.deepEqual( await evaluate( `Array.from(document.querySelectorAll('.n-card-header__main')).map(e => (e.querySelector('.server-heading > span:first-child') ?? e).textContent.trim())`, ), - ['Сервер', 'Игра', 'Экран игры', 'Экран', 'План экранов', 'План музыки'], + ['Сервер', 'Материалы', 'Игра', 'Экран', 'План экранов', 'План музыки'], ) assert.equal( await evaluate(`document.querySelectorAll('details').length`), @@ -474,7 +475,7 @@ module.exports = async function onlineSmoke({ await evaluate(`(() => { const assignment = document.querySelector('[data-testid="sound-assignment"]') const card = assignment?.closest('.n-card') - return card?.querySelector('.n-card-header__main')?.textContent.trim() === 'Экран игры' + return card?.querySelector('.n-card-header__main')?.textContent.trim() === 'Игра' && ['Назначить звук этому экрану', 'Отключиться'].every(label => !Array.from(document.querySelectorAll('.n-card')).find(c => c.querySelector('.n-card-header__main')?.textContent.trim() === 'Экран')?.textContent.includes(label)) })()`), @@ -902,7 +903,7 @@ module.exports = async function onlineSmoke({ 'current local game stays visible after logout', ) const packageKeys = (await evaluate('window.screenBridge.packages()')).map((pkg) => pkg.key) - await evaluate(`Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Сбросить').click()`) + await evaluate(`Array.from(document.querySelectorAll('button')).find(b => b.textContent.trim() === 'Сменить игру').click()`) await poll( async () => (await state()).catalog.key === null, 'reset clears current game', diff --git a/src/components/ConnectionPanel.vue b/src/components/ConnectionPanel.vue index 2159153..b257157 100644 --- a/src/components/ConnectionPanel.vue +++ b/src/components/ConnectionPanel.vue @@ -14,6 +14,7 @@ import { } from 'naive-ui' import { computed, ref, watch } from 'vue' import ScreenConnectionPanel from './ScreenConnectionPanel.vue' +import GameControlPanel from './GameControlPanel.vue' import { DEFAULT_SERVER, type GameMaterial, @@ -209,8 +210,7 @@ const gameOptions = computed(() => { {{ state.notice }} -
- +