vimp-engine 0.38.1 → 0.38.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +12 -6
- package/bin/vimp-sim.js +2 -2
- package/core/Cargo.toml +4 -3
- package/package.json +14 -5
- package/src/client/boot.js +30 -7
- package/src/client/components/controller/ClientReports.js +1 -1
- package/src/client/components/controller/Games.js +1 -1
- package/src/client/components/model/ClientReports.js +1 -1
- package/src/client/components/model/Games.js +24 -0
- package/src/client/components/view/Auth.js +4 -2
- package/src/client/components/view/ClientReports.js +1 -1
- package/src/client/components/view/Games.js +19 -1
- package/src/client/components/view/Lobby.js +1 -7
- package/src/client/debug.js +1 -1
- package/src/client/lib/authApi.js +1 -1
- package/src/client/lib/diagnostics.js +19 -4
- package/src/client/lib/engineVersion.js +0 -6
- package/src/client/lib/footerLink.js +15 -14
- package/src/client/lib/formBuilder.js +9 -8
- package/src/client/lib/retryOnNetworkError.js +60 -0
- package/src/client/main.js +46 -28
- package/src/client/network/HiddenHostHealthLog.js +40 -3
- package/src/client/network/HostController.js +4 -4
- package/src/client/network/HostHealthPolicy.js +41 -0
- package/src/client/network/InlineHostBridge.js +8 -1
- package/src/client/network/WebSocketTransport.js +1 -1
- package/src/client/session/HandoffFlow.js +5 -0
- package/src/client/session/HostRole.js +5 -2
- package/src/client/style.css +5 -6
- package/src/client/views/gameShell.js +3 -3
- package/src/client/views/includes/auth.pug +4 -4
- package/src/client/views/includes/lobby.pug +6 -5
- package/src/client/views/includes/reports.pug +1 -1
- package/src/config/authClient.js +4 -5
- package/src/config/gameCodes.js +2 -2
- package/src/config/lobby.js +4 -5
- package/src/config/opcodes.js +2 -1
- package/src/config/wsports.js +2 -2
- package/src/devtools/builtinScenario.js +3 -3
- package/src/devtools/contract/loadContext.js +2 -2
- package/src/devtools/contract/rules/a2-package-scripts.js +4 -3
- package/src/devtools/contract/rules/a4-vite-config.js +4 -3
- package/src/devtools/contract/rules/a7-package-repository.js +7 -7
- package/src/devtools/contract/rules/c4-component-dependencies.js +5 -5
- package/src/devtools/contract/rules/index.js +1 -1
- package/src/devtools/invariants.js +2 -2
- package/src/devtools/pluginLoader.js +3 -3
- package/src/host/DebugRecorder.js +1 -1
- package/src/host/HostGame.js +35 -15
- package/src/host/PortMachine.js +6 -5
- package/src/host/commandFailure.js +140 -0
- package/src/host/host.worker.js +8 -2
- package/src/host/meta/SocketManager.js +2 -2
- package/src/host/meta/core/CommandProcessor.js +37 -7
- package/src/lib/applyRoomOverrides.js +1 -1
- package/src/lib/capabilities.js +1 -1
- package/src/lib/checkpointCodec.js +1 -2
- package/src/lib/coreConfig.js +1 -1
- package/src/lib/createHostRuntime.js +1 -1
- package/src/lib/gameId.js +1 -1
- package/src/lib/gamePlugin.js +6 -2
- package/src/lib/jwt.js +1 -1
- package/src/lib/loadGamePackage.js +1 -1
- package/src/lib/packageLink.js +21 -63
- package/src/lib/validators.js +3 -3
- package/src/standalone/index.js +6 -6
- package/tests/fixtures/generations/gen-api3/README.md +2 -2
- package/tests/fixtures/generations/gen-api4/README.md +2 -2
- package/bin/vimp-surface.js +0 -88
- package/src/config/env.js +0 -104
- package/src/config/master.js +0 -288
- package/src/devtools/surface/abiParse.js +0 -175
- package/src/devtools/surface/collect.js +0 -413
- package/src/lib/clientIp.js +0 -96
- package/src/lib/rateLimiter.js +0 -40
- package/src/lib/security.js +0 -45
|
@@ -13,8 +13,8 @@ import { isFixture } from './pluginLoader.js';
|
|
|
13
13
|
// Ввод начинается на 40-м тике, а не сразу: кадр спавна с force_reset
|
|
14
14
|
// приходит примерно на interpolation.delay (движковый дефолт — 100 мс, тут
|
|
15
15
|
// ~3× запаса) позже входа и чистит удержанные клавиши предиктора
|
|
16
|
-
// (docs/en/debugging
|
|
17
|
-
// ровно ту ловушку, от которой предостерегает документация
|
|
16
|
+
// (docs.vimp.lgick.dev/en/debugging) — встроенный сценарий не должен
|
|
17
|
+
// демонстрировать ровно ту ловушку, от которой предостерегает документация
|
|
18
18
|
const INPUT_DOWN_TICK = 40;
|
|
19
19
|
const INPUT_UP_TICK = 100;
|
|
20
20
|
const TICKS = 120;
|
|
@@ -26,7 +26,7 @@ const FIXTURE_UNUSED_KEYS = ['e1'];
|
|
|
26
26
|
const firstKey = dict => Object.keys(dict ?? {})[0];
|
|
27
27
|
|
|
28
28
|
// Клавиша для смоука обязана быть удерживаемой: по конвенции playerKeys
|
|
29
|
-
// (docs/en/plugin-api
|
|
29
|
+
// (docs.vimp.lgick.dev/en/plugin-api) `type: 1` — триггер, у которого 'up'
|
|
30
30
|
// игнорируется, и «нажал — отпустил» не даёт ни удержания, ни движения.
|
|
31
31
|
// Порядок объявления в playerKeys контрактом не задан, поэтому берём первую
|
|
32
32
|
// без type; если игра объявила одни триггеры — первую любую, смоук всё равно
|
|
@@ -14,7 +14,7 @@ import { createGameConfigView } from '../../lib/gameConfigView.js';
|
|
|
14
14
|
// модули; исходники читаются только там, где проверяется буква кода
|
|
15
15
|
// (engineApi литералом или импортом).
|
|
16
16
|
|
|
17
|
-
// каталог движка
|
|
17
|
+
// каталог пакета движка — из него берётся версия крейта, с
|
|
18
18
|
// которой сверяется пин игры. core/Cargo.toml числится в "files"
|
|
19
19
|
// package.json именно ради этого: иначе в установке из npm версии крейта
|
|
20
20
|
// нет, и правило A5 проверяло бы пин только внутри монорепозитория
|
|
@@ -170,7 +170,7 @@ async function readEngineCoreVersion() {
|
|
|
170
170
|
// версия пакета в секции [package] — первый `version = "…"` до следующей
|
|
171
171
|
// секции; TOML-парсера в зависимостях движка нет, а формат здесь наш.
|
|
172
172
|
//
|
|
173
|
-
// Копия живёт в
|
|
173
|
+
// Копия живёт в create-vimp-game (src/versions.js): скаффолдер
|
|
174
174
|
// ставится через `npm create` и обязан работать без движка в зависимостях.
|
|
175
175
|
// Разбор обязан совпадать — правьте обе или ни одной (см. тест
|
|
176
176
|
// tests/scaffold/versions.test.js).
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { ERROR, skip, verdict } from '../result.js';
|
|
2
2
|
|
|
3
|
-
// Набор npm-скриптов пакета игры (docs/en/packaging
|
|
4
|
-
// требование: инструкции движка и CI сгенерированной игры зовут их по
|
|
5
|
-
// а отсутствующий core:build:node тихо оставляет headless-прогон без
|
|
3
|
+
// Набор npm-скриптов пакета игры (docs.vimp.lgick.dev/en/packaging). Это не
|
|
4
|
+
// вкусовое требование: инструкции движка и CI сгенерированной игры зовут их по
|
|
5
|
+
// имени, а отсутствующий core:build:node тихо оставляет headless-прогон без
|
|
6
|
+
// ядра.
|
|
6
7
|
const REQUIRED = [
|
|
7
8
|
'build',
|
|
8
9
|
'build:client',
|
|
@@ -2,9 +2,10 @@ import { ERROR, skip, verdict } from '../result.js';
|
|
|
2
2
|
|
|
3
3
|
// vite.config.js проверяется как текст, а не импортом: конфиг —
|
|
4
4
|
// функция от mode и вне Vite не исполняется. Каждый пункт здесь ловит
|
|
5
|
-
// молчаливую поломку сборки, а не стилистику
|
|
6
|
-
// без preserveEntrySignatures
|
|
7
|
-
// tree-shaking, а build.lib всегда инлайнит
|
|
5
|
+
// молчаливую поломку сборки, а не стилистику
|
|
6
|
+
// (docs.vimp.lgick.dev/en/packaging): без preserveEntrySignatures
|
|
7
|
+
// default-экспорт плагина вытряхивает tree-shaking, а build.lib всегда инлайнит
|
|
8
|
+
// ассеты.
|
|
8
9
|
const REQUIRED = [
|
|
9
10
|
[/emptyOutDir\s*:\s*false/, 'emptyOutDir: false (two builds share dist/)'],
|
|
10
11
|
[/assetsInlineLimit\s*:\s*0/, 'assetsInlineLimit: 0'],
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { WARN, skip, verdict } from '../result.js';
|
|
2
2
|
import { resolveProjectUrl } from '../../../lib/packageLink.js';
|
|
3
3
|
|
|
4
|
-
// Игра без объявленного
|
|
5
|
-
// Но
|
|
6
|
-
// только
|
|
7
|
-
//
|
|
4
|
+
// Игра без объявленного repository собирается и играется — это не
|
|
5
|
+
// отказ. Но реестр игр в лобби показывает ссылку на проект, и берёт он её
|
|
6
|
+
// только из него (фолбэка нет, см. lib/packageLink.js): без них строка
|
|
7
|
+
// игры останется без ссылки, а заметить это без подсказки негде.
|
|
8
8
|
// Отсюда warn — под --strict он становится ошибкой.
|
|
9
9
|
export default {
|
|
10
10
|
id: 'A7',
|
|
11
11
|
name: 'packageRepository',
|
|
12
12
|
level: WARN,
|
|
13
|
-
title: 'package.json declares repository (the
|
|
13
|
+
title: 'package.json declares repository (the games registry links to it)',
|
|
14
14
|
|
|
15
15
|
check(ctx) {
|
|
16
16
|
if (!ctx.pkg) {
|
|
@@ -22,8 +22,8 @@ export default {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
return verdict([
|
|
25
|
-
'
|
|
26
|
-
"the game's entry
|
|
25
|
+
'"repository" does not resolve to a project URL — ' +
|
|
26
|
+
"the game's registry entry will show no project link",
|
|
27
27
|
]);
|
|
28
28
|
},
|
|
29
29
|
};
|
|
@@ -4,11 +4,11 @@ import { SERVICES } from '../../../config/clientServices.js';
|
|
|
4
4
|
// Движковый пул сервисов держит реестр (config/clientServices.js), но пул
|
|
5
5
|
// ими не исчерпывается: игра доливает туда свои через
|
|
6
6
|
// ClientPlugin.hooks.services(core) — например геометрию предсказанной
|
|
7
|
-
// динамики карты (docs/en/plugin-api
|
|
8
|
-
// ошибка для движка: part получает undefined и рисует пустоту
|
|
9
|
-
// assetsBase выглядит как чистый холст без единой строки в консоли,
|
|
10
|
-
// этого правило и существует. Правило остаётся ERROR: оно работает на
|
|
11
|
-
// разработки игры, а не в рантайме.
|
|
7
|
+
// динамики карты (docs.vimp.lgick.dev/en/plugin-api «hooks.services»).
|
|
8
|
+
// Незнакомое имя не ошибка для движка: part получает undefined и рисует пустоту
|
|
9
|
+
// — карта без assetsBase выглядит как чистый холст без единой строки в консоли,
|
|
10
|
+
// ради этого правило и существует. Правило остаётся ERROR: оно работает на
|
|
11
|
+
// этапе разработки игры, а не в рантайме.
|
|
12
12
|
//
|
|
13
13
|
// Что именно возвращает `hooks.services(core)`, статически не видно — хук
|
|
14
14
|
// требует живого ядра. Поэтому у плагина есть необязательное поле
|
|
@@ -38,7 +38,7 @@ import e6 from './e6-sound-spatial.js';
|
|
|
38
38
|
import e7 from './e7-map-game-field.js';
|
|
39
39
|
|
|
40
40
|
// Порядок групп — порядок отчёта: A пакет и сборка, B host, C client,
|
|
41
|
-
// D снапшот, E
|
|
41
|
+
// D снапшот, E ассеты.
|
|
42
42
|
export const rules = [
|
|
43
43
|
a1,
|
|
44
44
|
a2,
|
|
@@ -19,7 +19,7 @@ export const SKIP = 'skip';
|
|
|
19
19
|
// не берётся судить покрытие схемы» (встроенный смоук на чужой игре)
|
|
20
20
|
export const ALL_KEYS_UNAUDITED = '*';
|
|
21
21
|
|
|
22
|
-
// список проверок
|
|
22
|
+
// список проверок
|
|
23
23
|
const CHECKS = [
|
|
24
24
|
[1, 'finiteValues', 'no NaN/Infinity in decoded fields and hot buffer'],
|
|
25
25
|
[2, 'snapshotKeysUsed', 'every snapshot key produced at least one row'],
|
|
@@ -484,7 +484,7 @@ function predictionDrift({ clients, scenario }) {
|
|
|
484
484
|
// Компоненты player-блока — игровая раскладка, движок знает только их
|
|
485
485
|
// порядок, поэтому нарушение адресуется индексом. Исключение — уровень 0
|
|
486
486
|
// (source 'camera'): там движок сравнивает ровно мировые x/y, и называть их
|
|
487
|
-
// «#0/#1» значит прятать причину (см. docs/en/debugging
|
|
487
|
+
// «#0/#1» значит прятать причину (см. docs.vimp.lgick.dev/en/debugging).
|
|
488
488
|
const CAMERA_COMPONENTS = ['x', 'y'];
|
|
489
489
|
|
|
490
490
|
function formatDivergence(record) {
|
|
@@ -12,9 +12,9 @@ import { loadGamePackage } from '../lib/loadGamePackage.js';
|
|
|
12
12
|
// 3. ничего — фикстура miniGame из этого репозитория.
|
|
13
13
|
//
|
|
14
14
|
// entries.wasmNode — новое ОПЦИОНАЛЬНОЕ поле манифеста: конвенция
|
|
15
|
-
// core/pkg-node/ уже описана в docs/en/packaging
|
|
16
|
-
// пути в манифесте. Его отсутствие не ошибка — просто игра не
|
|
17
|
-
// headless своим настоящим ядром.
|
|
15
|
+
// core/pkg-node/ уже описана в docs.vimp.lgick.dev/en/packaging, не хватало
|
|
16
|
+
// только пути в манифесте. Его отсутствие не ошибка — просто игра не
|
|
17
|
+
// прогоняется headless своим настоящим ядром.
|
|
18
18
|
|
|
19
19
|
const FIXTURE_DIR = new URL('../../tests/fixtures/miniGame/', import.meta.url);
|
|
20
20
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import clock from '../lib/clock.js';
|
|
2
2
|
|
|
3
|
-
// Рекордер живого
|
|
3
|
+
// Рекордер живого матча: пишет то, что headless
|
|
4
4
|
// не воспроизводит — реальный WebRTC, PixiJS и живой ввод человека — ровно в
|
|
5
5
|
// формат сценария headless-runner'а (devtools/ScenarioRunner.js). Баг,
|
|
6
6
|
// пойманный человеком в браузере, догоняется потом `npm run sim:replay`
|
package/src/host/HostGame.js
CHANGED
|
@@ -16,6 +16,7 @@ import closeCodes from '../config/closeCodes.js';
|
|
|
16
16
|
import clock from '../lib/clock.js';
|
|
17
17
|
import { createResumeKey } from '../lib/resumeKey.js';
|
|
18
18
|
import GameCoreAdapter from './GameCoreAdapter.js';
|
|
19
|
+
import { commandFailureReport } from './commandFailure.js';
|
|
19
20
|
import DebugRecorder from './DebugRecorder.js';
|
|
20
21
|
import enginePkg from '../../package.json' with { type: 'json' };
|
|
21
22
|
|
|
@@ -252,7 +253,7 @@ export default class HostGame {
|
|
|
252
253
|
this._networkSendRate = data.timers.networkSendRate;
|
|
253
254
|
this._snapshotManager = new SnapshotThrottle(this._networkSendRate);
|
|
254
255
|
|
|
255
|
-
// рекордер живого матча
|
|
256
|
+
// рекордер живого матча — только в dev-режиме;
|
|
256
257
|
// в проде null, и все точки записи ниже вырождаются в ?.
|
|
257
258
|
this._recorder = this._isDevMode ? new DebugRecorder() : null;
|
|
258
259
|
|
|
@@ -317,19 +318,22 @@ export default class HostGame {
|
|
|
317
318
|
currentMap: data.currentMap,
|
|
318
319
|
});
|
|
319
320
|
|
|
320
|
-
this._commandProcessor = new CommandProcessor(
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
321
|
+
this._commandProcessor = new CommandProcessor(
|
|
322
|
+
{
|
|
323
|
+
participants: this._participants,
|
|
324
|
+
chat: this._chat,
|
|
325
|
+
scripted: this._scripted,
|
|
326
|
+
roundManager: this._roundManager,
|
|
327
|
+
voteCoordinator: this._voteCoordinator,
|
|
328
|
+
timerManager: this._timerManager,
|
|
329
|
+
playerDataSync: this._playerDataSync,
|
|
330
|
+
teams: this._teams,
|
|
331
|
+
spectatorTeam: this._spectatorTeam,
|
|
332
|
+
spectatorId: this._spectatorId,
|
|
333
|
+
isDevMode: this._isDevMode,
|
|
334
|
+
},
|
|
335
|
+
{ onError: (command, error) => this._reportCommandError(command, error) },
|
|
336
|
+
);
|
|
333
337
|
|
|
334
338
|
// игровые чат-команды и коды системных сообщений из HostPlugin
|
|
335
339
|
for (const command of hostPlugin.chatCommands) {
|
|
@@ -349,6 +353,7 @@ export default class HostGame {
|
|
|
349
353
|
this._checkpointSink = null; // ({ meta, core, final }) => void
|
|
350
354
|
// здоровье хоста (host-migration этап 9a): раз в окно цикла
|
|
351
355
|
this._healthSink = null; // (health) => void
|
|
356
|
+
this._diagnosticSink = null; // ({ kind, message, stack }) => void
|
|
352
357
|
this._checkpointIntervalMs = 0; // 0 — периодических точек нет
|
|
353
358
|
this._lastCheckpointAt = 0;
|
|
354
359
|
this._checkpointRequest = null; // { final } — точка на ближайшем кадре
|
|
@@ -738,7 +743,7 @@ export default class HostGame {
|
|
|
738
743
|
this._mapList.push(...Object.keys(this._maps));
|
|
739
744
|
}
|
|
740
745
|
|
|
741
|
-
// ***** отладочный контур
|
|
746
|
+
// ***** отладочный контур ***** //
|
|
742
747
|
// Всё ниже живёт под флагом isDevMode: в проде рекордера нет, а дамп
|
|
743
748
|
// отдаёт только то, что и так знает мета.
|
|
744
749
|
|
|
@@ -1042,6 +1047,21 @@ export default class HostGame {
|
|
|
1042
1047
|
|
|
1043
1048
|
// ***** здоровье хоста (host-migration этап 9a) ***** //
|
|
1044
1049
|
|
|
1050
|
+
/**
|
|
1051
|
+
* Приёмник сбоев кода игры, пойманных хостом: { kind, message, stack }.
|
|
1052
|
+
* @param {Function|null} sink
|
|
1053
|
+
*/
|
|
1054
|
+
setDiagnosticSink(sink) {
|
|
1055
|
+
this._diagnosticSink = typeof sink === 'function' ? sink : null;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// сбой чат-команды игры: отчёт собирает commandFailure.js (одна строка
|
|
1059
|
+
// сообщения, стек без текста ошибки); бросок приёмника гасит
|
|
1060
|
+
// CommandProcessor._fail
|
|
1061
|
+
_reportCommandError(command, error) {
|
|
1062
|
+
this._diagnosticSink?.(commandFailureReport(command, error));
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1045
1065
|
/**
|
|
1046
1066
|
* Приёмник метрик здоровья: раз в окно игрового цикла (~1 с) —
|
|
1047
1067
|
* { tickRate, maxGapMs, lostMs, windowMs, peerRttMedian, peerCount }.
|
package/src/host/PortMachine.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Хендшейк-машина клиента, вынутая из host.worker.js: автомат по клиентским
|
|
2
|
-
// портам
|
|
3
|
-
// ни DOM: всё, что знает про транспорт, приходит через
|
|
4
|
-
// крутит и Worker браузерного хоста, и inline-хост
|
|
5
|
-
// Node-процесс dedicated-сервера: копий автомата быть не
|
|
6
|
-
// разъедутся ровно так, как разъезжались бы копии
|
|
2
|
+
// портам (таблица портов — docs.vimp.lgick.dev/en/snapshot). Изоморфна — ни
|
|
3
|
+
// self, ни postMessage, ни DOM: всё, что знает про транспорт, приходит через
|
|
4
|
+
// makeSocket. Поэтому её крутит и Worker браузерного хоста, и inline-хост
|
|
5
|
+
// standalone SDK, и Node-процесс dedicated-сервера: копий автомата быть не
|
|
6
|
+
// должно, иначе они разъедутся ровно так, как разъезжались бы копии
|
|
7
|
+
// createHostRuntime.
|
|
7
8
|
|
|
8
9
|
import wsports from '../config/wsports.js';
|
|
9
10
|
import closeCodes from '../config/closeCodes.js';
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Отчёт о сбое чат-команды игры для приёмника диагностики
|
|
2
|
+
// (HostGame.setDiagnosticSink): сообщение в одну строку и стек без текста
|
|
3
|
+
// ошибки. Один отчёт на все среды — Worker (журнал платформы), dedicated
|
|
4
|
+
// (лог процесса), standalone (консоль вкладки). Чистые функции, без DOM и
|
|
5
|
+
// Node API: модуль работает в Worker'е
|
|
6
|
+
|
|
7
|
+
// символы, подделывающие строки лога: управляющие (перевод строки, ESC),
|
|
8
|
+
// невидимые форматные (bidi-переопределения, нулевая ширина, BOM), разделители
|
|
9
|
+
// строк и абзацев (U+2028/2029)
|
|
10
|
+
const LOG_UNSAFE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u180E]/gu;
|
|
11
|
+
const MAX_STACK = 2000;
|
|
12
|
+
|
|
13
|
+
// обрезка до limit единиц UTF-16 без разрыва суррогатной пары; выполняется
|
|
14
|
+
// до очистки, чтобы огромная строка не проходила regex целиком
|
|
15
|
+
const clip = (value, limit) => {
|
|
16
|
+
if (value.length <= limit) {
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const text = value.slice(0, limit);
|
|
21
|
+
|
|
22
|
+
return /[\uD800-\uDBFF]$/.test(text) ? text.slice(0, -1) : text;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// одна строка лога: текст от игрока или из кода игры без LOG_UNSAFE
|
|
26
|
+
const oneLine = (value, limit) =>
|
|
27
|
+
clip(String(value), limit).replace(LOG_UNSAFE, ' ');
|
|
28
|
+
|
|
29
|
+
// стек сохраняет переводы строк, остальное LOG_UNSAFE заменяется; длина
|
|
30
|
+
// ограничена
|
|
31
|
+
const cleanStack = stack =>
|
|
32
|
+
clip(stack, MAX_STACK)
|
|
33
|
+
.split(/\r\n|\r|\n/)
|
|
34
|
+
.map(line => line.replace(LOG_UNSAFE, ' '))
|
|
35
|
+
.join('\n');
|
|
36
|
+
|
|
37
|
+
// кадры стека (V8: « at …»). Заголовок «имя: текст» снимается по числу
|
|
38
|
+
// строк текста — ввод игрока в тексте не отличить от кадра по виду, поиск
|
|
39
|
+
// «at» его бы не отсёк. Не сошлось (текст изменён после создания, цепочка
|
|
40
|
+
// cause, другой движок) — остаётся хвост из подряд идущих кадров; ни кадров
|
|
41
|
+
// V8, ни кадров SpiderMonkey/JSC — стек сводится к одной строке, чтобы текст
|
|
42
|
+
// из него не подделал строки лога
|
|
43
|
+
const V8_FRAME = /^ {4}at /;
|
|
44
|
+
|
|
45
|
+
// кадр SpiderMonkey/JavaScriptCore: «имя@место», текста ошибки в таком стеке
|
|
46
|
+
// нет. Имя — без пробелов, кроме служебных имён JSC (global/eval/module code,
|
|
47
|
+
// «async имя», у анонимной функции — «async »): заголовок V8 «имя: текст» за
|
|
48
|
+
// кадр не сойдёт. Место — URL со схемой, [native code] или кадр Wasm у JSC:
|
|
49
|
+
// wasm-function[N] с именем модуля или без, у WebKit до 2025-08 — [wasm code]
|
|
50
|
+
// (имя тогда «модуль.wasm-function[N]»). Голый [native code] — тоже кадр
|
|
51
|
+
const FRAME_NAME = [
|
|
52
|
+
String.raw`[^\s@]*`,
|
|
53
|
+
'(?:global|eval|module) code',
|
|
54
|
+
String.raw`async [^\s@]*`,
|
|
55
|
+
].join('|');
|
|
56
|
+
const FRAME_PLACE = [
|
|
57
|
+
String.raw`[a-z][a-z0-9+.-]*:\S+`,
|
|
58
|
+
String.raw`\[native code\]`,
|
|
59
|
+
String.raw`\[wasm code\]`,
|
|
60
|
+
String.raw`(?:[^\s@:[\]]+:)?wasm-function\[\d+\]`,
|
|
61
|
+
].join('|');
|
|
62
|
+
const AT_FRAME = new RegExp(
|
|
63
|
+
String.raw`^(?:(?:${FRAME_NAME})@(?:${FRAME_PLACE})|\[native code\])$`,
|
|
64
|
+
'i',
|
|
65
|
+
);
|
|
66
|
+
const STACK_SCAN = 10 * MAX_STACK;
|
|
67
|
+
|
|
68
|
+
const stackFrames = (stack, message) => {
|
|
69
|
+
const lines = clip(stack, STACK_SCAN).split('\n');
|
|
70
|
+
|
|
71
|
+
// обрезка по STACK_SCAN рвёт последнюю строку: неполный кадр не
|
|
72
|
+
// распознала бы ни одна ветка, и весь стек свернулся бы в строку
|
|
73
|
+
if (stack.length > STACK_SCAN && lines.length > 1) {
|
|
74
|
+
lines.pop();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// SpiderMonkey/JavaScriptCore: стек — одни кадры, без заголовка; Firefox
|
|
78
|
+
// заканчивает его переводом строки
|
|
79
|
+
let end = lines.length;
|
|
80
|
+
|
|
81
|
+
while (end > 0 && lines[end - 1] === '') {
|
|
82
|
+
end -= 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const body = lines.slice(0, end);
|
|
86
|
+
|
|
87
|
+
if (body.length > 0 && body.every(line => AT_FRAME.test(line))) {
|
|
88
|
+
return body.join('\n');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const rest = lines.slice(message.split('\n').length);
|
|
92
|
+
|
|
93
|
+
if (rest.length > 0 && rest.every(line => V8_FRAME.test(line))) {
|
|
94
|
+
return rest.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let start = lines.length;
|
|
98
|
+
|
|
99
|
+
while (start > 0 && V8_FRAME.test(lines[start - 1])) {
|
|
100
|
+
start -= 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return start < lines.length
|
|
104
|
+
? lines.slice(start).join('\n')
|
|
105
|
+
: lines.join(' | ');
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// сбой чат-команды → { kind, message, stack }. Не бросает: недоступный
|
|
109
|
+
// message/toString — текст «[unprintable error]», недоступный стек — null.
|
|
110
|
+
// Имя команды приходит из ввода игрока, текст — из кода игры, который вправе
|
|
111
|
+
// вставить в него аргументы игрока: оба приводятся к одной строке и
|
|
112
|
+
// обрезаются. V8 повторяет текст ошибки первой строкой стека — она
|
|
113
|
+
// снимается, остаются кадры
|
|
114
|
+
export function commandFailureReport(command, error) {
|
|
115
|
+
let text;
|
|
116
|
+
let stack = null;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// message читается один раз: геттер мог бы вернуть разное
|
|
120
|
+
const message = error && error.message ? String(error.message) : '';
|
|
121
|
+
|
|
122
|
+
text = message || String(error);
|
|
123
|
+
|
|
124
|
+
if (error && typeof error.stack === 'string') {
|
|
125
|
+
stack = stackFrames(error.stack, message);
|
|
126
|
+
stack = cleanStack(stack) || null;
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
text ??= '[unprintable error]';
|
|
130
|
+
stack = null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const commandText = oneLine(command, 32);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
kind: 'error',
|
|
137
|
+
message: `chat command ${commandText} failed: ${oneLine(text, 400)}`,
|
|
138
|
+
stack,
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/host/host.worker.js
CHANGED
|
@@ -152,6 +152,12 @@ async function onInit(room, handoff = null, checkpointBytes = null, seqFloor) {
|
|
|
152
152
|
// ли отдать роль
|
|
153
153
|
host.setHealthSink(health => self.postMessage({ type: 'health', health }));
|
|
154
154
|
|
|
155
|
+
// сбой кода игры, пойманный хостом (чат-команда): в журнал клиентских
|
|
156
|
+
// ошибок тем же сообщением, что и необработанный reject
|
|
157
|
+
host.setDiagnosticSink(report =>
|
|
158
|
+
self.postMessage({ type: 'diagnostic', ...report }),
|
|
159
|
+
);
|
|
160
|
+
|
|
155
161
|
// в лобби личность игрока — claim identity-токена, проверенного по JWKS
|
|
156
162
|
// мастера (Этап B3); свободного ввода имени в форме игры нет
|
|
157
163
|
portMachine = new PortMachine({
|
|
@@ -238,7 +244,7 @@ function onDebug({ requestId, action }) {
|
|
|
238
244
|
self.postMessage({ type: 'debug_result', requestId, result, error });
|
|
239
245
|
}
|
|
240
246
|
|
|
241
|
-
// журнал клиентских
|
|
247
|
+
// журнал клиентских ошибок: необработанный reject в
|
|
242
248
|
// Worker не всплывает в worker.onerror главного потока — пересылаем сами.
|
|
243
249
|
// Отдельный тип сообщения: 'error' занят сбоем init и запускает откат эстафеты
|
|
244
250
|
self.addEventListener('unhandledrejection', event => {
|
|
@@ -325,7 +331,7 @@ self.onmessage = async event => {
|
|
|
325
331
|
host?.setRoom({ roomId: msg.hostId, roomSecret: msg.hostSecret });
|
|
326
332
|
break;
|
|
327
333
|
|
|
328
|
-
// отладочный
|
|
334
|
+
// отладочный контур: единственный вход в
|
|
329
335
|
// авторитетную половину из главного потока — запрос/ответ по requestId
|
|
330
336
|
case 'debug':
|
|
331
337
|
onDebug(msg);
|
|
@@ -311,8 +311,8 @@ export default class SocketManager {
|
|
|
311
311
|
}
|
|
312
312
|
|
|
313
313
|
/**
|
|
314
|
-
* Отправка отладочного лога хоста в консоль клиента (порт CONSOLE
|
|
315
|
-
*
|
|
314
|
+
* Отправка отладочного лога хоста в консоль клиента (порт CONSOLE).
|
|
315
|
+
* Worker изолирован от DevTools вкладки —
|
|
316
316
|
* без этого канала его события в браузере не видны вовсе.
|
|
317
317
|
* @param {string} socketId
|
|
318
318
|
* @param {*} data
|
|
@@ -4,11 +4,19 @@
|
|
|
4
4
|
// разных играх может делать разное или отсутствовать вовсе; незнакомая
|
|
5
5
|
// команда — COMMANDS_NOT_FOUND.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// Контекст команд: roundManager (новый раунд, текущая карта, смена карты),
|
|
8
|
+
// timerManager (остаток времени карты), playerDataSync (ранг), chat,
|
|
9
|
+
// isDevMode и остальные поля deps.
|
|
10
|
+
//
|
|
11
|
+
// Обработчик — код игры и вызывается под защитой: его сбой (исключение или
|
|
12
|
+
// отклонённый промис) уходит в onError, матч продолжается.
|
|
10
13
|
class CommandProcessor {
|
|
11
|
-
|
|
14
|
+
/**
|
|
15
|
+
* @param {Object} deps - контекст игровых команд.
|
|
16
|
+
* @param {Object} [options]
|
|
17
|
+
* @param {Function} [options.onError] - (command, error) сбой обработчика.
|
|
18
|
+
*/
|
|
19
|
+
constructor(deps, { onError = null } = {}) {
|
|
12
20
|
this._chat = deps.chat;
|
|
13
21
|
|
|
14
22
|
// контекст игровых команд (participants, chat, scripted, roundManager,
|
|
@@ -17,6 +25,7 @@ class CommandProcessor {
|
|
|
17
25
|
this._ctx = deps;
|
|
18
26
|
|
|
19
27
|
this._commands = new Map();
|
|
28
|
+
this._onError = onError;
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
/**
|
|
@@ -36,10 +45,31 @@ class CommandProcessor {
|
|
|
36
45
|
const cmd = arr.shift();
|
|
37
46
|
const handler = this._commands.get(cmd);
|
|
38
47
|
|
|
39
|
-
if (handler) {
|
|
40
|
-
handler(this._ctx, gameId, arr);
|
|
41
|
-
} else {
|
|
48
|
+
if (!handler) {
|
|
42
49
|
this._chat.pushSystemByUser(gameId, 'COMMANDS_NOT_FOUND');
|
|
50
|
+
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// в Worker'е сбой стал бы необработанным reject'ом, в dedicated-процессе —
|
|
55
|
+
// uncaughtException, то есть падением матча всех игроков
|
|
56
|
+
try {
|
|
57
|
+
const result = handler(this._ctx, gameId, arr);
|
|
58
|
+
|
|
59
|
+
if (result && typeof result.then === 'function') {
|
|
60
|
+
result.then(null, error => this._fail(cmd, error));
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
this._fail(cmd, error);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// сбой приёмника не должен породить новый сбой
|
|
68
|
+
_fail(command, error) {
|
|
69
|
+
try {
|
|
70
|
+
this._onError?.(command, error);
|
|
71
|
+
} catch {
|
|
72
|
+
// приёмник молчит о своих сбоях
|
|
43
73
|
}
|
|
44
74
|
}
|
|
45
75
|
}
|
|
@@ -54,7 +54,7 @@ export function applyRoomOverrides(
|
|
|
54
54
|
game.timers.mapTime = clampTime(room.mapTime);
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
// dev-режим вкладки
|
|
57
|
+
// dev-режим вкладки хоста: включает рекордер
|
|
58
58
|
// и хостовый CONSOLE-лог. Прод-бандл его не выставляет — поведение то же
|
|
59
59
|
if (typeof room.isDevMode === 'boolean') {
|
|
60
60
|
game.isDevMode = room.isDevMode;
|
package/src/lib/capabilities.js
CHANGED
|
@@ -43,7 +43,7 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
|
|
|
43
43
|
// `SimCtx::map_body_state`) и доступ игры к телам карты по индексу
|
|
44
44
|
// (хэндл, индекс по хэндлу, отключение тела)
|
|
45
45
|
{ value: 'map.bodyState', since: '0.34.0' },
|
|
46
|
-
// сервис пула `diagnostics` (журнал клиентских
|
|
46
|
+
// сервис пула `diagnostics` (журнал клиентских ошибок);
|
|
47
47
|
// опционален — игра не пишет его в `requires`
|
|
48
48
|
{ value: 'diagnostics', since: '0.35.0' },
|
|
49
49
|
// контрольная точка хоста посреди раунда (host-migration этап 5):
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Контейнер до сжатия: [u32 metaLen LE][meta JSON utf-8][байты дампа ядра];
|
|
6
6
|
// весь буфер — gzip. Дамп ядра сам по себе JSON (serialize_state), и без
|
|
7
|
-
// сжатия точка tanks весит ~420 КБ, с ним ~21
|
|
8
|
-
// spike-results).
|
|
7
|
+
// сжатия точка tanks весит ~420 КБ, с ним ~21 КБ.
|
|
9
8
|
|
|
10
9
|
import hostDefaults from '../config/hostDefaults.js';
|
|
11
10
|
|
package/src/lib/coreConfig.js
CHANGED
|
@@ -2,7 +2,7 @@ import { SNAPSHOT_FORMAT_VERSION } from '../config/opcodes.js';
|
|
|
2
2
|
import hostDefaults from '../config/hostDefaults.js';
|
|
3
3
|
import wsports from '../config/wsports.js';
|
|
4
4
|
|
|
5
|
-
// Сборка JSON-конфига Rust-ядра (
|
|
5
|
+
// Сборка JSON-конфига Rust-ядра (крейт vimp-engine-core + core/ в репозитории
|
|
6
6
|
// игры, например vimp-tanks): движковая половина
|
|
7
7
|
// (timeStep/mapScale/mapSetId/snapshot/seed) + игровая
|
|
8
8
|
// (models/weapons/playerKeys/panel/friendlyFire) — форма {engine, game} из
|
|
@@ -87,7 +87,7 @@ export async function createHostRuntime(room, options = {}) {
|
|
|
87
87
|
// преемник собирает из них тот же конфиг ядра
|
|
88
88
|
roomSettings: pickRoomSettings(room),
|
|
89
89
|
mapsVersion: room?.mapsVersion ?? null,
|
|
90
|
-
// рекордер
|
|
90
|
+
// рекордер кладёт seed в сценарий — без него
|
|
91
91
|
// записанный матч невоспроизводим
|
|
92
92
|
seed,
|
|
93
93
|
...hostOptions,
|
package/src/lib/gameId.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Формат id игры — изоморфно: его проверяет мастер (реестр, раздача, каталог)
|
|
2
2
|
// и клиентский роутер ссылок (client/lib/roomLink.js) до похода в сеть.
|
|
3
|
-
// Значение обязано совпадать с
|
|
3
|
+
// Значение обязано совпадать с games из конфига auth-сервиса
|
|
4
4
|
// (см. master/gameRefs.js — он реэкспортирует отсюда)
|
|
5
5
|
export const GAME_ID_PATTERN = /^[a-z][a-z0-9-]{1,30}$/;
|
|
6
6
|
|
package/src/lib/gamePlugin.js
CHANGED
|
@@ -7,8 +7,12 @@ import { createGameConfigView } from './gameConfigView.js';
|
|
|
7
7
|
// entries.client из манифеста.
|
|
8
8
|
|
|
9
9
|
// каталог всех игр мастера (GameCatalog, см. Этап 6.2) — массив манифестов
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// doFetch подставляет клиент: загрузка страницы повторяет сбой сети
|
|
11
|
+
export async function fetchGamesManifest(
|
|
12
|
+
url = '/games/manifest.json',
|
|
13
|
+
doFetch = fetch,
|
|
14
|
+
) {
|
|
15
|
+
const res = await doFetch(url);
|
|
12
16
|
|
|
13
17
|
if (!res.ok) {
|
|
14
18
|
throw new Error(`games manifest: HTTP ${res.status}`);
|
package/src/lib/jwt.js
CHANGED
|
@@ -41,7 +41,7 @@ export function decodeJwtPayload(token) {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
// Авторитетная проверка identity-токена (Этап B3): подпись RS256 по JWKS
|
|
44
|
-
// central auth
|
|
44
|
+
// central auth-сервиса, issuer и срок годности. Работает через
|
|
45
45
|
// Web Crypto API (crypto.subtle) — доступен в браузере, Worker'е хоста и в
|
|
46
46
|
// Node ≥19 глобально, отдельной JWT-библиотеки не требует. Возвращает
|
|
47
47
|
// проверенный payload ({ sub, nick, iss, exp, ... }) или бросает исключение.
|