This commit is contained in:
2026-10-18 19:47:31 +07:00
parent 7b8badd140
commit 0152b662a7
10 changed files with 375 additions and 37 deletions
+1
View File
@@ -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', () => {
+132 -2
View File
@@ -65,6 +65,10 @@ async function fixture(t: TestContext, secure = true) {
advanceCalls: 0,
resultError: false,
title: 'Дело',
gameStatus: 'finish',
gameError: 0,
gameResponse: null as (() => Promise<Response>) | 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')
})
+89
View File
@@ -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<void> } | 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<void> {
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<ScreenPackage> {
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<string, string[]>()
const usages = new Map<string, GameMaterial['usages']>()
@@ -1157,6 +1241,7 @@ export class Online {
const isBackgroundRefresh = data.action === 'playlists'
this.state.busy = true
const messages: Record<OnlineIntent['action'], string> = {
'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<string, unknown>) {
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)