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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +12 -6
  3. package/bin/vimp-sim.js +2 -2
  4. package/core/Cargo.toml +4 -3
  5. package/package.json +14 -5
  6. package/src/client/boot.js +30 -7
  7. package/src/client/components/controller/ClientReports.js +1 -1
  8. package/src/client/components/controller/Games.js +1 -1
  9. package/src/client/components/model/ClientReports.js +1 -1
  10. package/src/client/components/model/Games.js +24 -0
  11. package/src/client/components/view/Auth.js +4 -2
  12. package/src/client/components/view/ClientReports.js +1 -1
  13. package/src/client/components/view/Games.js +19 -1
  14. package/src/client/components/view/Lobby.js +1 -7
  15. package/src/client/debug.js +1 -1
  16. package/src/client/lib/authApi.js +1 -1
  17. package/src/client/lib/diagnostics.js +19 -4
  18. package/src/client/lib/engineVersion.js +0 -6
  19. package/src/client/lib/footerLink.js +15 -14
  20. package/src/client/lib/formBuilder.js +9 -8
  21. package/src/client/lib/retryOnNetworkError.js +60 -0
  22. package/src/client/main.js +46 -28
  23. package/src/client/network/HiddenHostHealthLog.js +40 -3
  24. package/src/client/network/HostController.js +4 -4
  25. package/src/client/network/HostHealthPolicy.js +41 -0
  26. package/src/client/network/InlineHostBridge.js +8 -1
  27. package/src/client/network/WebSocketTransport.js +1 -1
  28. package/src/client/session/HandoffFlow.js +5 -0
  29. package/src/client/session/HostRole.js +5 -2
  30. package/src/client/style.css +5 -6
  31. package/src/client/views/gameShell.js +3 -3
  32. package/src/client/views/includes/auth.pug +4 -4
  33. package/src/client/views/includes/lobby.pug +6 -5
  34. package/src/client/views/includes/reports.pug +1 -1
  35. package/src/config/authClient.js +4 -5
  36. package/src/config/gameCodes.js +2 -2
  37. package/src/config/lobby.js +4 -5
  38. package/src/config/opcodes.js +2 -1
  39. package/src/config/wsports.js +2 -2
  40. package/src/devtools/builtinScenario.js +3 -3
  41. package/src/devtools/contract/loadContext.js +2 -2
  42. package/src/devtools/contract/rules/a2-package-scripts.js +4 -3
  43. package/src/devtools/contract/rules/a4-vite-config.js +4 -3
  44. package/src/devtools/contract/rules/a7-package-repository.js +7 -7
  45. package/src/devtools/contract/rules/c4-component-dependencies.js +5 -5
  46. package/src/devtools/contract/rules/index.js +1 -1
  47. package/src/devtools/invariants.js +2 -2
  48. package/src/devtools/pluginLoader.js +3 -3
  49. package/src/host/DebugRecorder.js +1 -1
  50. package/src/host/HostGame.js +35 -15
  51. package/src/host/PortMachine.js +6 -5
  52. package/src/host/commandFailure.js +140 -0
  53. package/src/host/host.worker.js +8 -2
  54. package/src/host/meta/SocketManager.js +2 -2
  55. package/src/host/meta/core/CommandProcessor.js +37 -7
  56. package/src/lib/applyRoomOverrides.js +1 -1
  57. package/src/lib/capabilities.js +1 -1
  58. package/src/lib/checkpointCodec.js +1 -2
  59. package/src/lib/coreConfig.js +1 -1
  60. package/src/lib/createHostRuntime.js +1 -1
  61. package/src/lib/gameId.js +1 -1
  62. package/src/lib/gamePlugin.js +6 -2
  63. package/src/lib/jwt.js +1 -1
  64. package/src/lib/loadGamePackage.js +1 -1
  65. package/src/lib/packageLink.js +21 -63
  66. package/src/lib/validators.js +3 -3
  67. package/src/standalone/index.js +6 -6
  68. package/tests/fixtures/generations/gen-api3/README.md +2 -2
  69. package/tests/fixtures/generations/gen-api4/README.md +2 -2
  70. package/bin/vimp-surface.js +0 -88
  71. package/src/config/env.js +0 -104
  72. package/src/config/master.js +0 -288
  73. package/src/devtools/surface/abiParse.js +0 -175
  74. package/src/devtools/surface/collect.js +0 -413
  75. package/src/lib/clientIp.js +0 -96
  76. package/src/lib/rateLimiter.js +0 -40
  77. 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.md) — встроенный сценарий не должен демонстрировать
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.md) `type: 1` — триггер, у которого 'up'
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
- // каталог движка (packages/engine) — из него берётся версия крейта, с
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
- // Копия живёт в packages/create-vimp-game/src/versions.js: скаффолдер
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.md). Это не вкусовое
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
- // молчаливую поломку сборки, а не стилистику (docs/en/packaging.md):
6
- // без preserveEntrySignatures default-экспорт плагина вытряхивает
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
- // только отсюда (фолбэка нет, см. lib/packageLink.js): без поля ячейка
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 #auth footer links to it)',
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
- 'neither "repository" nor "homepage" resolves to a project URL — ' +
26
- "the game's entry form will show no project link",
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.md «hooks.services»). Незнакомое имя не
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 ассеты (plan/create-vimp-game/stage_1.md).
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
- // список проверок в порядке plan/done/ai-debug/stage_3.md
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.md).
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.md, не хватало только
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
- // Рекордер живого матча (этап 6 плана plan/done/ai-debug): пишет то, что headless
3
+ // Рекордер живого матча: пишет то, что headless
4
4
  // не воспроизводит — реальный WebRTC, PixiJS и живой ввод человека — ровно в
5
5
  // формат сценария headless-runner'а (devtools/ScenarioRunner.js). Баг,
6
6
  // пойманный человеком в браузере, догоняется потом `npm run sim:replay`
@@ -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
- // рекордер живого матча (этап 6 плана plan/done/ai-debug) — только в dev-режиме;
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
- participants: this._participants,
322
- chat: this._chat,
323
- scripted: this._scripted,
324
- roundManager: this._roundManager,
325
- voteCoordinator: this._voteCoordinator,
326
- timerManager: this._timerManager,
327
- playerDataSync: this._playerDataSync,
328
- teams: this._teams,
329
- spectatorTeam: this._spectatorTeam,
330
- spectatorId: this._spectatorId,
331
- isDevMode: this._isDevMode,
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
- // ***** отладочный контур (этап 6 плана plan/done/ai-debug) ***** //
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 }.
@@ -1,9 +1,10 @@
1
1
  // Хендшейк-машина клиента, вынутая из host.worker.js: автомат по клиентским
2
- // портам 0–10 (см. docs/en/network.md). Изоморфна — ни self, ни postMessage,
3
- // ни DOM: всё, что знает про транспорт, приходит через makeSocket. Поэтому её
4
- // крутит и Worker браузерного хоста, и inline-хост standalone SDK, и
5
- // Node-процесс dedicated-сервера: копий автомата быть не должно, иначе они
6
- // разъедутся ровно так, как разъезжались бы копии createHostRuntime.
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
+ }
@@ -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
- // журнал клиентских ошибок (plan/client-reports): необработанный reject в
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
- // отладочный контур (этап 6 плана plan/done/ai-debug): единственный вход в
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
- * этап 6 плана plan/done/ai-debug). Worker изолирован от DevTools вкладки —
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
- // Всё, что раньше разбирал движковый switch, доступно играм через контекст:
8
- // roundManager (смена ника, новый раунд, текущая карта), timerManager
9
- // (остаток времени карты), playerDataSync (ранг), chat, isDevMode.
7
+ // Контекст команд: roundManager (новый раунд, текущая карта, смена карты),
8
+ // timerManager (остаток времени карты), playerDataSync (ранг), chat,
9
+ // isDevMode и остальные поля deps.
10
+ //
11
+ // Обработчик — код игры и вызывается под защитой: его сбой (исключение или
12
+ // отклонённый промис) уходит в onError, матч продолжается.
10
13
  class CommandProcessor {
11
- constructor(deps) {
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-режим вкладки хоста (этап 6 плана plan/done/ai-debug): включает рекордер
57
+ // dev-режим вкладки хоста: включает рекордер
58
58
  // и хостовый CONSOLE-лог. Прод-бандл его не выставляет — поведение то же
59
59
  if (typeof room.isDevMode === 'boolean') {
60
60
  game.isDevMode = room.isDevMode;
@@ -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` (журнал клиентских ошибок, plan/client-reports);
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 КБ (plan/host-migration,
8
- // spike-results).
7
+ // сжатия точка tanks весит ~420 КБ, с ним ~21 КБ.
9
8
 
10
9
  import hostDefaults from '../config/hostDefaults.js';
11
10
 
@@ -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-ядра (packages/engine/core + core/ в репозитории
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
- // рекордер (этап 6 плана plan/done/ai-debug) кладёт seed в сценарий — без него
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
- // Значение обязано совпадать с packages/auth/src/config/auth.js:games
3
+ // Значение обязано совпадать с games из конфига auth-сервиса
4
4
  // (см. master/gameRefs.js — он реэкспортирует отсюда)
5
5
  export const GAME_ID_PATTERN = /^[a-z][a-z0-9-]{1,30}$/;
6
6
 
@@ -7,8 +7,12 @@ import { createGameConfigView } from './gameConfigView.js';
7
7
  // entries.client из манифеста.
8
8
 
9
9
  // каталог всех игр мастера (GameCatalog, см. Этап 6.2) — массив манифестов
10
- export async function fetchGamesManifest(url = '/games/manifest.json') {
11
- const res = await fetch(url);
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-сервиса (packages/auth), issuer и срок годности. Работает через
44
+ // central auth-сервиса, issuer и срок годности. Работает через
45
45
  // Web Crypto API (crypto.subtle) — доступен в браузере, Worker'е хоста и в
46
46
  // Node ≥19 глобально, отдельной JWT-библиотеки не требует. Возвращает
47
47
  // проверенный payload ({ sub, nick, iss, exp, ... }) или бросает исключение.