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