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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
Reference in New Issue
Block a user