From 5635b8b5b16235c7b82157a9fdfa95cb96786d10 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Mon, 19 Oct 2026 00:48:46 +0700 Subject: [PATCH] fix --- docs/USER_GUIDE.md | 9 ++- electron/diagnostics.test.ts | 3 +- electron/diagnostics.ts | 2 +- electron/online.test.ts | 37 +++++++++++++ electron/online.ts | 20 ++++++- electron/request-error.ts | 1 + electron/screen-package.test.ts | 76 ++++++++++++++++++++++++++ electron/screen-package.ts | 42 +++++++++++++- scripts/materials-prepare-smoke.cjs | 63 +++++++++++++++++++++ scripts/smoke.cjs | 5 ++ src/components/ConnectionPanel.vue | 3 + src/composables/useOnlineConnection.ts | 1 + src/types/online.ts | 1 + 13 files changed, 252 insertions(+), 11 deletions(-) create mode 100644 scripts/materials-prepare-smoke.cjs diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 85a64c3..d9c914d 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -275,8 +275,11 @@ Windows 11, macOS 13+, Ubuntu 24.04 LTS или Debian 13. Эти требова `Описание игры · GET /api/games/7: Некорректный ответ API: поле game.scenario.story — ожидалось объект, получено массив`. Содержимое ответа, значения полей, токены и stack trace не отображаются. -Отсутствие истории сценария в ответе игры не мешает подключению. После подключения -ссылки на обложку, фон, вступление и видео берутся из состояния экрана и автоматически -сохраняются локально. До получения состояния используются доступные ссылки игры. +При подготовке игры до подключения экрана приложение отдельно запрашивает полный +сценарий, чтобы скачать фон, вступление и остальные материалы. Если API не отдаёт +полный сценарий, появляется предупреждение о неполном списке вместо сообщения +о полной готовности. При повторной загрузке уже сохранённый пакет остаётся без +изменений, пока полный список недоступен. После подключения ссылки на обложку, +фон, вступление и видео берутся из состояния экрана и сохраняются локально. Если медиа не заданы, текст, таймер и музыка остаются доступны; экран показывает «Вступление не задано» или «Видео закрывашки не задано» для соответствующего шага. diff --git a/electron/diagnostics.test.ts b/electron/diagnostics.test.ts index 9201614..af9d216 100644 --- a/electron/diagnostics.test.ts +++ b/electron/diagnostics.test.ts @@ -27,13 +27,14 @@ test('журнал сохраняет только разрешённые пол }) log.record('request-error', { path: '/api/games/7?token=SECRET', reason: 'ENOTFOUND' }) log.record('request-error', { path: '/api/games/{id}', reason: 'SECRET' }) + log.record('request-error', { path: '/api/scenarios/{id}', reason: 'AUTH', token: 'SECRET' }) const content = await readFile(join(dir, 'diagnostic.jsonl'), 'utf8') assert.equal(content.includes('SECRET'), false) const lines = content .trim() .split('\n') .map((s) => JSON.parse(s)) - assert.equal(lines.length, 4) + assert.equal(lines.length, 5) assert.equal(lines[0].version, '0.2.0-rc.1') assert.deepEqual(Object.keys(lines[1]).sort(), ['event', 'reason', 'time']) }) diff --git a/electron/diagnostics.ts b/electron/diagnostics.ts index 08f6dfe..fdf9584 100644 --- a/electron/diagnostics.ts +++ b/electron/diagnostics.ts @@ -28,7 +28,7 @@ const fields: Partial>> = { ], }, 'request-error': { - path: ['/api/games/{id}', '/api/games/{id}/screen-plan', '/api/games/{id}/music'], + path: ['/api/games/{id}', '/api/games/{id}/screen-plan', '/api/games/{id}/music', '/api/scenarios/{id}'], reason: [...NETWORK_ERROR_CODES, 'AUTH', 'HTTP', 'INVALID_JSON', 'SESSION_CHANGED'], }, 'media-error': { channel: ['main', 'music', 'scene'] }, diff --git a/electron/online.test.ts b/electron/online.test.ts index 57f7400..083d87b 100644 --- a/electron/online.test.ts +++ b/electron/online.test.ts @@ -55,6 +55,8 @@ async function fixture(t: TestContext, secure = true) { introURL: 'https://media.test/intro.wav', omitScenario: false, omitStory: false, + scenarioId: null as number | null, + scenarioError: 0, musicOverride: null as Record | null, mediaHandler: null as ((url: string) => Promise) | null, failMedia: false, @@ -161,6 +163,7 @@ async function fixture(t: TestContext, secure = true) { return json({}, 401) if (model.refreshExpired) return json({}, 401) if (path === '/api/users/me') return json({ user: { roles: model.roles } }) + if (path === '/api/scenarios/23') return model.scenarioError ? json({}, model.scenarioError) : json({ scenario: { ID: 23, image: 'https://media.test/cover.png', story: { background: 'https://media.test/background.png', introduction: { audio: model.introURL }, questions: [{ answer: 'PRIVATE-ANSWER' }] } } }) if (path === '/api/games') return json({ games: [ @@ -187,6 +190,7 @@ async function fixture(t: TestContext, secure = true) { scenario: model.omitScenario ? null : { + ...(model.scenarioId ? { ID: model.scenarioId } : {}), story: model.omitStory ? null : { @@ -389,6 +393,39 @@ test('чистый профиль предлагает адрес сайта п assert.equal(f.online.snapshot().server, 'https://evening-detective.crabs-games.art') }) +test('полный сценарий скачивает фон и введение до подключения и сохраняет их без сети', async (t) => { + const f = await fixture(t) + f.model.scenarioId = 23 + f.model.omitStory = true + await f.login() + await f.online.act({ action: 'select-game', gameId: 7 }) + const state = f.online.snapshot() + assert.equal(state.screenId, null) + assert.equal(state.materialsWarning, '') + assert.ok(state.materials.some((m) => m.usages.some((u) => u.name === 'Фон') && m.status === 'ready')) + assert.ok(state.materials.some((m) => m.usages.some((u) => u.name === 'Вступление') && m.status === 'ready')) + assert.ok(state.materials.every((m) => m.status === 'ready')) + assert.equal(JSON.stringify(f.store.list()).includes('PRIVATE-ANSWER'), false) + assert.equal(f.model.bindings.length, 0) + await f.online.close() + f.model.offline = true + await f.store.initialize() + await f.online.initialize() + assert.ok(f.online.snapshot().materials.every((m) => m.status === 'ready')) + assert.ok(f.online.snapshot().materials.some((m) => m.usages.some((u) => u.name === 'Вступление'))) +}) + +test('недоступный полный сценарий не выдаётся за готовность всех материалов', async (t) => { + const f = await fixture(t) + f.model.scenarioId = 23 + f.model.omitStory = true + f.model.scenarioError = 403 + await f.login() + await f.online.act({ action: 'select-game', gameId: 7 }) + assert.match(f.online.snapshot().materialsWarning, /неполный список/) + assert.equal(f.online.snapshot().notice, '') +}) + test('список материалов показывает отдельную загрузку, ошибку, повтор и кеш после перезапуска', async (t) => { const f = await fixture(t) await f.login() diff --git a/electron/online.ts b/electron/online.ts index debad16..90fbd5d 100644 --- a/electron/online.ts +++ b/electron/online.ts @@ -23,7 +23,7 @@ import type { } from '../src/types/screen.js' import type { Controller } from './controller.js' import { PackageStore, packageKey } from './package-store.js' -import { type ScreenPackage, prepareScreenPackage, resolveMediaURL } from './screen-package.js' +import { INCOMPLETE_MATERIALS_WARNING, type ScreenPackage, prepareScreenPackage, resolveMediaURL } from './screen-package.js' import { parseResults } from './results.js' import { parsePlan } from './screen-plan.js' import { @@ -80,6 +80,7 @@ export class Online { server: DEFAULT_SERVER, currentGameId: null, materials: [], + materialsWarning: '', authenticated: false, remembered: false, busy: false, @@ -198,7 +199,9 @@ export class Online { } private async description(gameId: number): Promise { if (!this.credentials) throw new ApiError(401) - return prepareScreenPackage( + const generation = this.generation + let incomplete = false + const description = await prepareScreenPackage( { server: this.credentials.server, gameId, @@ -206,6 +209,7 @@ export class Online { token: this.credentials.access, signal: this.materialAbort.signal, onRequestFailure: this.onRequestFailure, + onIncomplete: () => { incomplete = true }, }, (url, init) => { const parsed = new URL(String(url)) @@ -213,6 +217,9 @@ export class Online { return this.authorized(parsed.pathname, init) }, ) + if (generation === this.generation) + this.state.materialsWarning = incomplete ? INCOMPLETE_MATERIALS_WARNING : '' + return description } private async refreshMaterials() { if (this.materialRefresh) return this.materialRefresh @@ -1098,6 +1105,7 @@ export class Online { } } private detach() { + this.state.materialsWarning = '' this.materialAbort.abort() this.materialAbort = new AbortController() this.received = null @@ -1405,6 +1413,7 @@ export class Online { this.downloadKey = action === 'check' ? null : key this.cancellation = new AbortController() this.state.canCancel = true + let incomplete = false try { const description = await prepareScreenPackage( { @@ -1414,6 +1423,7 @@ export class Online { token: this.credentials.access, signal: this.cancellation.signal, onRequestFailure: this.onRequestFailure, + onIncomplete: () => { incomplete = true }, }, (url, init) => { const parsed = new URL(String(url)) @@ -1421,6 +1431,9 @@ export class Online { return this.authorized(parsed.pathname, init) }, ) + this.state.materialsWarning = incomplete ? INCOMPLETE_MATERIALS_WARNING : '' + if (incomplete && action !== 'check' && this.packages.list().some((pkg) => pkg.key === key)) + throw new Error(`${INCOMPLETE_MATERIALS_WARNING}. Сохранённые материалы оставлены без изменений`) if (action === 'check') { const previous = this.packages.list().find((p) => p.key === key) this.state.notice = !previous @@ -1454,7 +1467,8 @@ export class Online { await this.persistSecrets() } } - this.state.notice = 'Материалы проверены и готовы к показу' + this.state.materialsWarning = incomplete ? INCOMPLETE_MATERIALS_WARNING : '' + this.state.notice = incomplete ? '' : 'Материалы проверены и готовы к показу' } finally { this.cancellation = null this.state.canCancel = false diff --git a/electron/request-error.ts b/electron/request-error.ts index cae5d93..e8aefb7 100644 --- a/electron/request-error.ts +++ b/electron/request-error.ts @@ -136,6 +136,7 @@ export function requestErrorContext( if (contextualErrors.has(result)) return result const names: Record = { GetGame: 'Описание игры', + GetScenario: 'Сценарий игры', GetGameMusic: 'Музыка игры', GetScreenPlan: 'План экранов', SaveScreenPlan: 'Сохранение плана', diff --git a/electron/screen-package.test.ts b/electron/screen-package.test.ts index 4132707..2b821e5 100644 --- a/electron/screen-package.test.ts +++ b/electron/screen-package.test.ts @@ -180,6 +180,82 @@ test('получает данные через три существующих A assert.ok(!JSON.stringify(result).includes('SECRET')) }) +test('полный сценарий добавляет фон и введение до подключения: 14 файлов становятся 16', async () => { + const game = { game: { ID: 7, name: 'Игра', scenario: { ID: 23, image: 'cover.png' } } } + const full = { scenario: { ID: 23, image: 'cover.png', story: { + background: 'background.png', introduction: { audio: 'intro.wav' }, + questions: [{ answer: 'PRIVATE-ANSWER' }], + } } } + const music = { gamePlaylistId: 1, playlists: [{ ID: 1, name: 'Музыка', tracks: + Array.from({ length: 13 }, (_, index) => ({ ID: index + 1, name: `Трек ${index}`, filename: `track-${index}.wav` })), + }] } + assert.equal(buildScreenPackage(server, 7, { game, plan: {}, music }).assets.length, 14) + const calls: string[] = [] + let incomplete = false + const pkg = await prepareScreenPackage({ server, gameId: 7, token: 'SECRET', onIncomplete: () => { incomplete = true } }, async (input) => { + const url = String(input) + calls.push(url) + return Response.json(url.endsWith('/scenarios/23') ? full : url.endsWith('/music') ? music : url.endsWith('/screen-plan') ? {} : game) + }) + assert.equal(pkg.assets.length, 16) + assert.ok(pkg.media.background && pkg.media.intro) + assert.ok(calls.includes(`${server}/api/scenarios/23`)) + assert.equal(incomplete, false) + assert.equal(JSON.stringify(pkg).includes('PRIVATE-ANSWER'), false) + assert.deepEqual(parseScreenPackage(pkg), pkg) +}) + +test('недоступный или неполный сценарий предупреждает о неполном списке, чужой ID отклоняется', async () => { + const game = { game: { ID: 7, scenario: { ID: 23, image: 'cover.png' } } } + for (const status of [403, 404, 500, 200]) { + let incomplete = false + const failures: unknown[] = [] + const pkg = await prepareScreenPackage({ server, gameId: 7, token: 'SECRET', onIncomplete: () => { incomplete = true }, onRequestFailure: (failure) => failures.push(failure) }, async (input) => { + const url = String(input) + return url.endsWith('/scenarios/23') ? Response.json({ scenario: { ID: 23 } }, { status }) : Response.json(url.endsWith('/games/7') ? game : {}) + }) + assert.equal(incomplete, true) + assert.equal(pkg.assets.length, 1) + if (status !== 200) assert.deepEqual(failures, [{ path: '/api/scenarios/{id}', reason: status === 403 ? 'AUTH' : 'HTTP' }]) + } + await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) => + Response.json(String(input).endsWith('/scenarios/23') ? { scenario: { ID: 24 } } : String(input).endsWith('/games/7') ? game : {})), + /Сценарий игры.*GET \/api\/scenarios\/23.*другой сценарий/) + await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async (input) => + Response.json(String(input).endsWith('/scenarios/23') ? null : String(input).endsWith('/games/7') ? game : {})), + /Сценарий игры.*scenarioResponse/) +}) + +test('состояние подключённого экрана имеет приоритет и не требует сценария', async () => { + const data = fixture() + Object.assign(data.game.game.scenario, { ID: 23 }) + let calls = 0 + const pkg = await prepareScreenPackage({ server, gameId: 7, token: 'SECRET', resources: { cover: 'current.png', background: '', intro: '', conclusion: '' } }, async (input) => { + const url = String(input) + assert.ok(!url.includes('/scenarios/')) + calls++ + return Response.json(url.endsWith('/music') ? data.music : url.endsWith('/screen-plan') ? data.plan : data.game) + }) + assert.equal(calls, 3) + assert.equal(pkg.media.intro, null) + assert.ok(pkg.assets.some((asset) => asset.url.endsWith('/current.png'))) +}) + +test('отмена запроса полного сценария не выдаёт неполный пакет', async () => { + const abort = new AbortController() + const timer = setTimeout(() => abort.abort(), 20) + let incomplete = false + try { + await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: 'SECRET', signal: abort.signal, onIncomplete: () => { incomplete = true } }, async (input, init) => { + if (String(input).includes('/scenarios/')) return new Promise((_resolve, reject) => { + init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true }) + }) + return Response.json(String(input).endsWith('/games/7') ? { game: { ID: 7, scenario: { ID: 23 } } } : {}) + }), /Сценарий игры.*отмен/) + assert.equal(incomplete, false) + } finally { clearTimeout(timer) } +}) + test('отказы API и некорректный JSON не выдают частичный пакет или ответ с секретом', async () => { for (const status of [401, 403, 404, 500]) { await assert.rejects( diff --git a/electron/screen-package.ts b/electron/screen-package.ts index 2e3b423..1aa3222 100644 --- a/electron/screen-package.ts +++ b/electron/screen-package.ts @@ -8,6 +8,8 @@ import { } from './request-error.js' import { createHash } from 'node:crypto' +export const INCOMPLETE_MATERIALS_WARNING = 'Получен неполный список материалов. Подключите экран к игре для уточнения' + export interface ScreenPackage { formatVersion: 1 metadataVersion: string @@ -39,6 +41,7 @@ export interface PackageRequest { token: string resources?: { cover: string; background: string; intro: string; conclusion: string } signal?: AbortSignal + onIncomplete?: () => void onRequestFailure?: (failure: { path: string reason: NetworkErrorCode | 'SESSION_CHANGED' | 'AUTH' | 'HTTP' | 'INVALID_JSON' @@ -326,6 +329,7 @@ export async function prepareScreenPackage( : meta.method === 'GetScreenPlan' ? '/screen-plan' : '' + const safePath = meta.method === 'GetScenario' ? '/api/scenarios/{id}' : `/api/games/{id}${suffix}` let response: Response try { response = await fetchAPI(`${server}/${requestData.path}`, { @@ -337,7 +341,7 @@ export async function prepareScreenPackage( signal, }) } catch (error) { - const path = `/api/games/{id}${suffix}` + const path = safePath if (error instanceof ApiError || error instanceof SessionChangedError) { request.onRequestFailure?.({ path, @@ -356,7 +360,7 @@ export async function prepareScreenPackage( } if (!response.ok) { request.onRequestFailure?.({ - path: `/api/games/{id}${suffix}`, + path: safePath, reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP', }) throw requestErrorContext( @@ -369,7 +373,7 @@ export async function prepareScreenPackage( try { return await response.json() } catch { - request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' }) + request.onRequestFailure?.({ path: safePath, reason: 'INVALID_JSON' }) throw requestErrorContext( new Error('Некорректный JSON в ответе API'), meta.method, @@ -383,10 +387,42 @@ export async function prepareScreenPackage( client.GetScreenPlan({ gameID: request.gameId }), client.GetGameMusic({ gameID: request.gameId }), ]) + let fullScenarioId: number | null = null try { + const gameData = object(object(game, 'gameResponse').game, 'game') + const scenario = optionalObject(gameData.scenario, 'game.scenario') + if (!request.resources && gameData.scenario != null) { + if (scenario.ID != null) { + const scenarioId = id(scenario.ID, 'game.scenario.ID') + let response + let received = false + try { + response = await client.GetScenario({ ID: scenarioId }) + received = true + } catch (error) { + if (signal.aborted || error instanceof SessionChangedError || (error instanceof ApiError && error.status === 401)) throw error + request.onIncomplete?.() + } + if (received) { + try { + const full = object(object(response, 'scenarioResponse').scenario, 'scenario') + if (id(full.ID, 'scenario.ID') !== scenarioId) + throw new ResponseFieldError('scenario.ID', 'идентификатор сценария выбранной игры', full.ID, 'API вернул другой сценарий') + optionalObject(full.story, 'scenario.story') + fullScenarioId = scenarioId + gameData.scenario = { ...scenario, ...full, story: full.story } + if (full.story == null) request.onIncomplete?.() + } catch (error) { + throw requestErrorContext(error, 'GetScenario', 'GET', `/api/scenarios/${scenarioId}`) + } + } + } else if (scenario.story == null) request.onIncomplete?.() + } return buildScreenPackage(server, request.gameId, { game, plan, music }, request.resources) } catch (error) { const field = error instanceof ResponseFieldError ? error.field : 'game' + if (fullScenarioId !== null && field.startsWith('game.scenario.')) + throw requestErrorContext(error, 'GetScenario', 'GET', `/api/scenarios/${fullScenarioId}`) const state = field.startsWith('state.') const suffix = field.startsWith('plan') ? '/screen-plan' diff --git a/scripts/materials-prepare-smoke.cjs b/scripts/materials-prepare-smoke.cjs new file mode 100644 index 0000000..3c94d60 --- /dev/null +++ b/scripts/materials-prepare-smoke.cjs @@ -0,0 +1,63 @@ +const assert = require('node:assert/strict') +const { createServer } = require('node:http') +const { nativeImage } = require('electron') +const { soundCheckWav } = require('../dist-electron/electron/sound-check.js') + +module.exports = async ({ evaluate, poll }) => { + const requests = [] + let scenarioError = false + const image = nativeImage.createFromBitmap(Buffer.alloc(4), { width: 1, height: 1 }).toPNG() + const server = createServer((request, response) => { + requests.push(request.url) + request.resume() + const send = (value, status = 200) => { + response.writeHead(status, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify(value)) + } + const game = { ID: 7, name: 'Materials smoke', status: 'finish', scenario: { ID: 23, image: 'cover.png' } } + if (request.url === '/api/auth/login') return send({ accessToken: 'TEST', refreshToken: 'REFRESH' }) + if (request.url === '/api/users/me') return send({ user: { roles: ['organizer'] } }) + if (request.url === '/api/playlists') return send({ playlists: [] }) + if (request.url === '/api/games') return send({ games: [game] }) + if (request.url === '/api/games/7') return send({ game }) + if (request.url === '/api/games/7/screen-plan') return send({ plan: { steps: [{ mode: 2 }] } }) + if (request.url === '/api/games/7/music') return send({ gamePlaylistId: 1, playlists: [{ ID: 1, name: 'Музыка', tracks: + Array.from({ length: 13 }, (_, index) => ({ ID: index + 1, name: `Трек ${index}`, filename: `track-${index}.wav` })), + }] }) + if (request.url === '/api/scenarios/23') return send({ scenario: { ID: 23, image: 'cover.png', story: { background: 'background.png', introduction: { audio: 'intro.wav' } } } }, scenarioError ? 403 : 200) + if (request.url.startsWith('/api/files/')) { + response.writeHead(200, { 'Content-Type': request.url.endsWith('.png') ? 'image/png' : 'audio/wav' }) + response.end(request.url.endsWith('.png') ? image : soundCheckWav) + return + } + send({}, 404) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + try { + const url = `http://127.0.0.1:${server.address().port}` + await evaluate(`window.screenBridge.onlineAction(${JSON.stringify({ action: 'login', server: url, email: 'test@example.test', password: 'test-password' })})`) + await evaluate('window.screenBridge.onlineAction({ action: "select-game", gameId: 7 })') + const result = await evaluate('window.screenBridge.onlineState()') + assert.equal(result.materials.length, 16) + assert.ok(result.materials.every((m) => m.status === 'ready')) + assert.ok(result.materials.some((m) => m.usages.some((u) => u.name === 'Фон'))) + assert.ok(result.materials.some((m) => m.usages.some((u) => u.name === 'Вступление'))) + assert.equal(result.screenId, null) + assert.equal(result.materialsWarning, '') + assert.ok(!requests.some((path) => path.startsWith('/api/screens'))) + await poll(() => evaluate('document.body.textContent.includes("Материалы — загружено 16 из 16")'), 'all materials before screen connection') + scenarioError = true + await assert.rejects(evaluate('window.screenBridge.onlineAction({ action: "download", gameId: 7 })'), /Сохранённые материалы/) + const incomplete = await evaluate('window.screenBridge.onlineState()') + assert.equal(incomplete.materials.length, 16) + assert.equal(incomplete.notice, '') + await poll(() => evaluate('document.querySelector("[data-testid=materials-warning]")?.textContent.includes("неполный список")'), 'incomplete materials warning') + console.log('SCREEN_MATERIALS_PREPARE_PASS') + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(resolve)) + } +} diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index d82270f..2aee316 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -219,6 +219,11 @@ app.whenReady().then(async () => { (await evaluate('!!window.screenBridge && !!document.querySelector("main.control")')), 'control UI', ) + if (process.argv.includes('--materials-prepare')) { + await require('./materials-prepare-smoke.cjs')({ evaluate, poll }) + app.exit(0) + return + } if (process.argv.includes('--clear-games')) { await evaluate(`window.screenBridge.selectPackage(${JSON.stringify(offlinePackage.key)})`) await poll(() => evaluate('!!document.querySelector("[data-testid=clear-games]:not([disabled])")'), 'clear games button') diff --git a/src/components/ConnectionPanel.vue b/src/components/ConnectionPanel.vue index 24b6152..56f2d43 100644 --- a/src/components/ConnectionPanel.vue +++ b/src/components/ConnectionPanel.vue @@ -331,6 +331,9 @@ const gameOptions = computed(() => { > + + {{ state.materialsWarning }} +

Материалы: {{ state.preparation.completed }}/{{ state.preparation.total }} · {{ (state.preparation.downloadedBytes / 1048576).toFixed(1) }} МиБ diff --git a/src/composables/useOnlineConnection.ts b/src/composables/useOnlineConnection.ts index 2d7faee..8d6effd 100644 --- a/src/composables/useOnlineConnection.ts +++ b/src/composables/useOnlineConnection.ts @@ -12,6 +12,7 @@ export function useOnlineConnection() { server: DEFAULT_SERVER, currentGameId: null, materials: [], + materialsWarning: '', authenticated: false, remembered: false, busy: false, diff --git a/src/types/online.ts b/src/types/online.ts index f5f0a4d..ae5483b 100644 --- a/src/types/online.ts +++ b/src/types/online.ts @@ -41,6 +41,7 @@ export interface OnlineState { server: string currentGameId: number | null materials: GameMaterial[] + materialsWarning: string authenticated: boolean remembered: boolean busy: boolean