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)
+8 -7
View File
@@ -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',
+8 -23
View File
@@ -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(() => {
<n-alert v-if="state.notice" type="info">{{ state.notice }}</n-alert>
</div>
</n-card>
<div v-if="bridge && isReady" class="game-grid">
<n-card title="Игра">
<n-card v-if="bridge && isReady" title="Материалы" data-testid="game-materials">
<template #header-extra>
<n-tag
role="status"
@@ -223,7 +223,7 @@ const gameOptions = computed(() => {
<div class="connection-content">
<div v-if="selectedKey" class="current-game">
<span data-testid="control-game">Текущая игра: {{ selectedName }}</span>
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')"> Сбросить </n-button>
<n-button :disabled="isBusy || state.busy" @click="emit('resetGame')">Сменить игру</n-button>
</div>
<template v-if="state.authenticated">
<div v-if="!selectedKey" class="game-selection">
@@ -390,36 +390,21 @@ const gameOptions = computed(() => {
</n-collapse>
<p v-if="!state.authenticated">Войдите на сервер, чтобы выбрать игру.</p>
</div>
</n-card>
<ScreenConnectionPanel
:state="state"
:is-busy="isBusy"
@action="run"
>
</n-card>
<GameControlPanel v-if="bridge && isReady" :state="state" :is-busy="isBusy" :selected-key="selectedKey" :selected-name="selectedName" @action="run">
<ScreenConnectionPanel :state="state" :is-busy="isBusy" @action="run">
<template v-if="state.authenticated">
<n-button
v-if="state.connection !== 'connected'"
:disabled="!canConnectCurrent || isBusy || state.busy"
@click="run({ action: 'connect' })"
>Подключиться</n-button
>
>Подключиться</n-button>
</template>
</ScreenConnectionPanel>
</div>
</GameControlPanel>
</template>
<style scoped>
.game-grid {
align-items: start;
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
}
.game-grid > * {
min-width: 0;
}
.materials-list {
display: flex;
flex-direction: column;
+93
View File
@@ -0,0 +1,93 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NAlert, NButton, NCard, NSpace, NTag, useDialog } from 'naive-ui'
import type { GameCommand, OnlineIntent, OnlineState } from '../types/online'
import { gameCommands, gameStatuses } from '../utils/game-status'
const props = defineProps<{
state: OnlineState
isBusy: boolean
selectedKey: string | null
selectedName: string | null
}>()
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
const dialog = useDialog()
const game = computed(() => props.state.game?.key === props.selectedKey ? props.state.game : null)
const status = computed(() => game.value ? gameStatuses[game.value.status] : null)
const disabled = computed(() => props.isBusy || props.state.busy || !props.state.authenticated || !!props.state.gameError)
const commands = {
start: { label: 'Начать игру', path: 'm8 5 12 7-12 7z' },
play: { label: 'Продолжить игру', path: 'm8 5 12 7-12 7z' },
pause: { label: 'Поставить игру на паузу', path: 'M8 5v14M16 5v14' },
finish: { label: 'Завершить игру', path: 'M5 21V3m0 1h14l-4 5 4 5H5' },
reset: { label: 'Сбросить игру', path: 'M4 5v6h6M4 11a8 8 0 1 1 2 7' },
}
function run(command: GameCommand) {
if (disabled.value || !game.value || !props.selectedKey) return
if (command !== 'reset') {
emit('action', { action: 'game-command', command, key: props.selectedKey })
return
}
const key = props.selectedKey
const id = game.value.id
dialog.warning({
title: 'Сбросить игру?',
content: `Игра «${game.value.name}» вернётся в черновик. Время начала и завершения будет очищено. Материалы и ответы сохранятся.`,
positiveText: 'Сбросить игру',
negativeText: 'Отмена',
onPositiveClick: () => {
if (disabled.value || props.selectedKey !== key || game.value?.id !== id) return
emit('action', { action: 'game-command', command, key })
},
})
}
</script>
<template>
<n-card title="Игра" data-testid="game-control">
<template #header-extra>
<n-tag role="status" data-testid="game-state" :type="state.gameError ? 'warning' : status?.type ?? 'default'">
{{ !selectedKey ? 'Игра не выбрана' : status?.label ?? (!state.authenticated ? 'Требуется вход' : state.gameError ? 'Нет данных' : 'Загрузка…') }}
</n-tag>
</template>
<div class="game-grid">
<n-space vertical>
<span v-if="selectedKey">{{ game?.name || selectedName }}</span>
<span v-else>Выберите игру в блоке «Материалы».</span>
<n-alert v-if="state.gameError" type="warning" role="status">
{{ state.gameError }}{{ game ? ' Показано последнее полученное состояние.' : '' }}
</n-alert>
<n-space v-if="game && selectedKey" align="center">
<n-button
v-for="command in gameCommands[game.status]"
:key="command"
square
:disabled="disabled"
:type="command === 'finish' || command === 'reset' ? 'error' : 'default'"
:aria-label="commands[command].label"
:title="commands[command].label"
@click="run(command)"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path :d="commands[command].path" />
</svg>
</n-button>
</n-space>
</n-space>
<slot />
</div>
</n-card>
</template>
<style scoped>
.game-grid {
align-items: start;
display: grid;
gap: 24px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
}
.game-grid > * {
min-width: 0;
}
</style>
+22 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { NButton, NCard, NSpace, NTag } from 'naive-ui'
import { NButton, NSpace, NTag } from 'naive-ui'
import type { OnlineIntent, OnlineState } from '../types/online'
defineProps<{ state: OnlineState; isBusy: boolean }>()
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
@@ -8,8 +8,9 @@ function run(intent: OnlineIntent) {
}
</script>
<template>
<n-card title="Экран игры">
<template #header-extra>
<section class="screen-connection" aria-label="Экран игры">
<div class="screen-heading">
<span>Экран игры</span>
<n-tag
role="status"
data-testid="game-connection-status"
@@ -17,7 +18,7 @@ function run(intent: OnlineIntent) {
>
{{ state.connection === 'connected' ? 'Подключено' : 'Не подключено' }}
</n-tag>
</template>
</div>
<slot />
<n-space v-if="state.screenId" align="center">
<span>Экран №{{ state.screenId }}</span>
@@ -49,5 +50,21 @@ function run(intent: OnlineIntent) {
>Назначить звук этому экрану</n-button
>
</n-space>
</n-card>
</section>
</template>
<style scoped>
.screen-connection {
display: flex;
flex-direction: column;
gap: 12px;
}
.screen-heading {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: space-between;
}
</style>
+2
View File
@@ -7,6 +7,8 @@ export function useOnlineConnection() {
const message = useMessage()
const isReady = ref(false)
const state = ref<OnlineState>({
game: null,
gameError: '',
server: DEFAULT_SERVER,
currentGameId: null,
materials: [],
+5
View File
@@ -1,6 +1,8 @@
import type { ScreenStep } from './screen.js'
export const DEFAULT_SERVER = 'https://evening-detective.crabs-games.art'
export type GameStatus = 'draft' | 'run' | 'pause' | 'finish'
export type GameCommand = 'start' | 'pause' | 'play' | 'finish' | 'reset'
export interface MusicPlaylist {
id: number
@@ -17,6 +19,7 @@ export interface GameMaterial {
}
export type OnlineIntent =
| { action: 'game-command'; command: GameCommand; key: string }
| { action: 'download-material'; key: string; assetId: string }
| { action: 'playlists' }
| { action: 'playlist-create'; name: string }
@@ -33,6 +36,8 @@ export type OnlineIntent =
| { action: 'select-game' | 'download' | 'check'; gameId: number }
| { action: 'remove'; key: string }
export interface OnlineState {
game: { key: string; id: number; name: string; status: GameStatus } | null
gameError: string
server: string
currentGameId: number | null
materials: GameMaterial[]
+15
View File
@@ -0,0 +1,15 @@
import type { GameCommand, GameStatus } from '../types/online.js'
export const gameStatuses = {
draft: { label: 'Черновик', type: 'default' },
run: { label: 'Идёт', type: 'success' },
pause: { label: 'Пауза', type: 'warning' },
finish: { label: 'Завершена', type: 'info' },
} as const
export const gameCommands: Record<GameStatus, GameCommand[]> = {
draft: ['start', 'finish'],
run: ['pause', 'finish', 'reset'],
pause: ['play', 'finish', 'reset'],
finish: ['reset'],
}