vimp-engine 0.38.2 → 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 (56) hide show
  1. package/core/Cargo.toml +1 -1
  2. package/package.json +3 -1
  3. package/src/client/boot.js +30 -7
  4. package/src/client/components/controller/ClientReports.js +1 -1
  5. package/src/client/components/controller/Games.js +1 -1
  6. package/src/client/components/model/ClientReports.js +1 -1
  7. package/src/client/components/model/Games.js +24 -0
  8. package/src/client/components/view/ClientReports.js +1 -1
  9. package/src/client/components/view/Games.js +19 -1
  10. package/src/client/components/view/Lobby.js +1 -7
  11. package/src/client/debug.js +1 -1
  12. package/src/client/lib/authApi.js +1 -1
  13. package/src/client/lib/diagnostics.js +19 -4
  14. package/src/client/lib/engineVersion.js +0 -6
  15. package/src/client/lib/footerLink.js +15 -14
  16. package/src/client/lib/retryOnNetworkError.js +60 -0
  17. package/src/client/main.js +35 -18
  18. package/src/client/network/HiddenHostHealthLog.js +40 -3
  19. package/src/client/network/HostController.js +4 -4
  20. package/src/client/network/HostHealthPolicy.js +41 -0
  21. package/src/client/network/InlineHostBridge.js +8 -1
  22. package/src/client/session/HandoffFlow.js +5 -0
  23. package/src/client/session/HostRole.js +5 -2
  24. package/src/client/style.css +5 -6
  25. package/src/client/views/gameShell.js +3 -3
  26. package/src/client/views/includes/auth.pug +4 -4
  27. package/src/client/views/includes/lobby.pug +4 -4
  28. package/src/client/views/includes/reports.pug +1 -1
  29. package/src/config/authClient.js +3 -3
  30. package/src/config/lobby.js +4 -5
  31. package/src/config/opcodes.js +2 -1
  32. package/src/devtools/contract/loadContext.js +2 -2
  33. package/src/devtools/contract/rules/a7-package-repository.js +7 -8
  34. package/src/devtools/contract/rules/index.js +1 -1
  35. package/src/devtools/invariants.js +1 -1
  36. package/src/host/DebugRecorder.js +1 -1
  37. package/src/host/HostGame.js +35 -15
  38. package/src/host/commandFailure.js +140 -0
  39. package/src/host/host.worker.js +8 -2
  40. package/src/host/meta/SocketManager.js +2 -2
  41. package/src/host/meta/core/CommandProcessor.js +37 -7
  42. package/src/lib/applyRoomOverrides.js +1 -1
  43. package/src/lib/capabilities.js +1 -1
  44. package/src/lib/checkpointCodec.js +1 -2
  45. package/src/lib/coreConfig.js +1 -1
  46. package/src/lib/createHostRuntime.js +1 -1
  47. package/src/lib/gameId.js +1 -1
  48. package/src/lib/gamePlugin.js +6 -2
  49. package/src/lib/jwt.js +1 -1
  50. package/src/lib/packageLink.js +18 -61
  51. package/src/lib/validators.js +3 -3
  52. package/src/standalone/index.js +1 -1
  53. package/tests/fixtures/generations/gen-api3/README.md +2 -2
  54. package/tests/fixtures/generations/gen-api4/README.md +2 -2
  55. package/src/devtools/surface/abiParse.js +0 -175
  56. package/src/devtools/surface/collect.js +0 -413
@@ -56,7 +56,7 @@ export default class HostController {
56
56
  * @param {Function} [opts.onLobbyInfoChange] - смена строки карточки
57
57
  * комнаты в лобби (string|null) — для актуализации info у мастера.
58
58
  * @param {Object} [opts.diagnostics] - журнал клиентских ошибок
59
- * (lib/diagnostics.js, plan/client-reports): туда уходят ошибки Worker'а.
59
+ * (lib/diagnostics.js): туда уходят ошибки Worker'а.
60
60
  * @param {Uint8Array} [opts.checkpoint] - сжатая контрольная точка
61
61
  * (host-migration этап 5): Worker поднимает матч из неё на паузе, старт —
62
62
  * startAfterRestore(). Буфер передаётся списком переноса — после
@@ -114,7 +114,7 @@ export default class HostController {
114
114
  // подписчики метрик здоровья хоста (host-migration этап 9a)
115
115
  this._healthListeners = new Set();
116
116
 
117
- // отладочные запросы в Worker (этап 6 плана plan/done/ai-debug):
117
+ // отладочные запросы в Worker:
118
118
  // requestId → { resolve, reject }
119
119
  this._debugRequests = new Map();
120
120
  this._debugRequestId = 0;
@@ -149,7 +149,7 @@ export default class HostController {
149
149
  });
150
150
  }
151
151
 
152
- // журнал клиентских ошибок (plan/client-reports): неперехваченная ошибка
152
+ // журнал клиентских ошибок: неперехваченная ошибка
153
153
  // Worker'а приходит сюда. preventDefault не зовётся — консольный вывод
154
154
  // браузера остаётся как есть
155
155
  _watchWorkerErrors(worker) {
@@ -456,7 +456,7 @@ export default class HostController {
456
456
  }
457
457
 
458
458
  /**
459
- * Отладочный контур (этап 6 плана plan/done/ai-debug): начинает запись живого
459
+ * Отладочный контур: начинает запись живого
460
460
  * матча в формат сценария headless-runner'а.
461
461
  * @returns {Promise<boolean>} false — dev-режим в комнате выключен.
462
462
  */
@@ -56,6 +56,7 @@ export default class HostHealthPolicy {
56
56
  this._successor = false;
57
57
  this._hiddenSince = null;
58
58
  this._hiddenTimer = null;
59
+ this._hiddenOutcome = null;
59
60
  this._lastAutoAt = null;
60
61
  this._cooldownBefore = null;
61
62
  this._samples = [];
@@ -80,6 +81,12 @@ export default class HostHealthPolicy {
80
81
  this._successor = false;
81
82
  this._resetSamples();
82
83
 
84
+ // исход прошлой роли уже прочитан её журналом (flush при потере роли);
85
+ // новой роли он не принадлежит
86
+ if (isHost) {
87
+ this._hiddenOutcome = null;
88
+ }
89
+
83
90
  // роль получена скрытой вкладкой: скрытие отсчитывается от получения
84
91
  if (isHost && this._hiddenSince !== null) {
85
92
  this._hiddenSince = this._hostSince;
@@ -98,6 +105,7 @@ export default class HostHealthPolicy {
98
105
  setHidden(hidden) {
99
106
  if (hidden && this._hiddenSince === null) {
100
107
  this._hiddenSince = this._now();
108
+ this._hiddenOutcome = null;
101
109
  this._armHiddenTimer();
102
110
  } else if (!hidden) {
103
111
  this._hiddenSince = null;
@@ -105,6 +113,19 @@ export default class HostHealthPolicy {
105
113
  }
106
114
  }
107
115
 
116
+ /**
117
+ * Исход передачи для последнего эпизода скрытия (живёт до следующего
118
+ * скрытия или новой роли хоста, переживает возврат видимости и потерю
119
+ * роли — его читает журнал закрываемого эпизода): null — порог
120
+ * hiddenHandoffMs не достигнут; 'started' — передача началась;
121
+ * 'disabled' | 'no-successor' | 'blocked' (срок роли или кулдаун) —
122
+ * ожидаемый отказ; 'unexplained' — всё разрешено, а передача не началась.
123
+ * @returns {string|null}
124
+ */
125
+ hiddenHandoff() {
126
+ return this._hiddenOutcome;
127
+ }
128
+
108
129
  // сэмпл здоровья Worker'а
109
130
  addHealth(health) {
110
131
  if (this._hostSince === null || !health) {
@@ -182,6 +203,8 @@ export default class HostHealthPolicy {
182
203
  }
183
204
 
184
205
  if (this._handOverNow('hidden')) {
206
+ this._hiddenOutcome = 'started';
207
+
185
208
  return true;
186
209
  }
187
210
 
@@ -191,9 +214,27 @@ export default class HostHealthPolicy {
191
214
  this._armHiddenTimer(waitMs);
192
215
  }
193
216
 
217
+ // начатая передача исход не меняет: следующие проверки её уже не стартуют
218
+ if (this._hiddenOutcome !== 'started') {
219
+ this._hiddenOutcome = this._refusal(waitMs);
220
+ }
221
+
194
222
  return false;
195
223
  }
196
224
 
225
+ // почему передача со скрытой вкладки не началась
226
+ _refusal(waitMs) {
227
+ if (!this._enabled()) {
228
+ return 'disabled';
229
+ }
230
+
231
+ if (!this._successor) {
232
+ return 'no-successor';
233
+ }
234
+
235
+ return waitMs > 0 ? 'blocked' : 'unexplained';
236
+ }
237
+
197
238
  // немедленная передача: ждущую границы — ускорить, иначе начать
198
239
  _handOverNow(reason) {
199
240
  if (!this._enabled() || !this._successor) {
@@ -11,7 +11,7 @@ import { createGuestIdentity } from '../../host/identity.js';
11
11
  // Почему inline, а не Worker: HostPlugin принципиально непередаваем в Worker
12
12
  // (postMessage не несёт функции), а standalone SDK получает от репозитория
13
13
  // игры именно живой объект плагина. Прод-путь (хост в Worker'е) не меняется —
14
- // расхождение dev/prod осознанное, см. plan/standalone-sdk/README.md.
14
+ // расхождение dev/prod осознанное.
15
15
  //
16
16
  // Мастера в этом контуре нет: личность гостевая (ник из формы, identity.js),
17
17
  // профиль offline-заглушкой (lib/offlinePlayerData.js).
@@ -102,6 +102,13 @@ export default class InlineHostBridge {
102
102
 
103
103
  this._runtime = runtime;
104
104
 
105
+ // сбой чат-команды игры виден в консоли вкладки вместе с кадрами обработчика
106
+ runtime.host.setDiagnosticSink(report =>
107
+ console.error(
108
+ `[vimp] ${report.message}${report.stack ? `\n${report.stack}` : ''}`,
109
+ ),
110
+ );
111
+
105
112
  this._portMachine = new PortMachine({
106
113
  host: runtime.host,
107
114
  socketManager: runtime.socketManager,
@@ -261,6 +261,11 @@ export default class HandoffFlow {
261
261
  this._policy?.addHealth(health);
262
262
  }
263
263
 
264
+ // исход передачи для последнего эпизода скрытия (HostHealthPolicy)
265
+ hiddenHandoff() {
266
+ return this._policy?.hiddenHandoff() ?? null;
267
+ }
268
+
264
269
  // «Leave server» из меню комнаты (этап 8.3). Хост при других людях отдаёт
265
270
  // роль бете без отката и уходит; один в комнате — закрывает её
266
271
  // (host_leaving: людей нет — мастер закрывает комнату сразу). Гость
@@ -321,11 +321,14 @@ export default class HostRole {
321
321
  onStats: this._debug ? stats => this._debug('standby sent', stats) : null,
322
322
  });
323
323
 
324
- // метрики здоровья Worker'а (этап 9a): скрытая вкладка троттлит цикл —
325
- // насколько, уходит в журнал одной сводкой за эпизод
324
+ // метрики здоровья Worker'а: эпизод скрытия, в котором матч просел,
325
+ // задел удалённых игроков, а автопередача не началась без видимой
326
+ // причины, уходит в журнал одной сводкой (HiddenHostHealthLog)
326
327
  this._hiddenLog = this._factories.hiddenLog({
327
328
  warn: (code, summary) =>
328
329
  this._diagnostics.warn(code, summary, { source: 'client' }),
330
+ minHealthyTickRate: migration.auto.overloadTickRate,
331
+ handoffOutcome: () => this._getHandoff().hiddenHandoff(),
329
332
  hidden: this._ui.isHidden(),
330
333
  });
331
334
  this._healthReporter = this._isLobbyMode
@@ -1,10 +1,9 @@
1
1
  @charset "UTF-8";
2
2
 
3
- /* База страницы: раньше жила инлайном в packages/engine/index.html, но в
4
- репозитории игры этого файла нет — а без правил ниже интерфейс движка
5
- остался бы без своей типографики и раскладки. Standalone SDK импортирует
6
- этот файл целиком, поэтому всё, что здесь есть, применяется и к
7
- встраивающей странице. */
3
+ /* База страницы: типографика и раскладка интерфейса движка живут здесь, а
4
+ не в index.html, потому что в репозитории игры такого файла нет.
5
+ Standalone SDK импортирует этот файл целиком, поэтому всё, что здесь
6
+ есть, применяется и к встраивающей странице. */
8
7
  html,
9
8
  body {
10
9
  width: 100%;
@@ -1329,7 +1328,7 @@ body.hide-cursor {
1329
1328
  color: #d66;
1330
1329
  }
1331
1330
 
1332
- /* журнал клиентских ошибок (plan/client-reports, этап 5): тот же каркас и
1331
+ /* журнал клиентских ошибок: тот же каркас и
1333
1332
  палитра, что у панели реестра игр; графы — .games-filters/.games-filter-btn */
1334
1333
  #reports-panel {
1335
1334
  background-color: #222;
@@ -55,9 +55,9 @@ const SHELL = [
55
55
  tag: 'div',
56
56
  id: 'auth-link',
57
57
  children: [
58
- // ссылка и версия пакета ИГРЫ — заполняет main.js по AUTH_DATA
59
- // из метаданных пакета в манифесте
60
- { tag: 'p', children: [{ tag: 'a', id: 'auth-package-link' }] },
58
+ // версия пакета ИГРЫ (из манифеста) и ссылка на документацию
59
+ // (только в dev-сборке) — заполняет main.js по AUTH_DATA
60
+ { tag: 'p', children: [{ tag: 'a', id: 'auth-docs-link' }] },
61
61
  { tag: 'p', id: 'auth-version' },
62
62
  { tag: 'p', text: `© ${new Date().getFullYear()} VIMP` },
63
63
  ],
@@ -15,10 +15,10 @@ div#auth
15
15
  div#auth-informs
16
16
 
17
17
  div#auth-link
18
- //- ссылка и версия пакета ИГРЫ (мастер кладёт их в манифест, читая
19
- //- package.json пакета) — проставляет main.js по AUTH_DATA; без данных
20
- //- ячейки остаются пустыми
18
+ //- версия пакета ИГРЫ (мастер кладёт её в манифест, читая package.json
19
+ //- пакета) и ссылка на документацию (только в dev-сборке) —
20
+ //- проставляет main.js по AUTH_DATA; без данных ячейки остаются пустыми
21
21
  p
22
- a#auth-package-link
22
+ a#auth-docs-link
23
23
  p#auth-version
24
24
  p &copy; #{new Date().getFullYear()} VIMP
@@ -21,7 +21,7 @@ div#lobby(style='display:none')
21
21
  input#profile-open(type='button', value='Profile')
22
22
  input#games-open-mine(type='button', value='My games')
23
23
  input#games-open-moderation(type='button', value='Moderation', style='display:none')
24
- //- журнал клиентских ошибок (plan/client-reports, этап 5): только админ
24
+ //- журнал клиентских ошибок: только админ
25
25
  input#reports-open(type='button', value='Errors', style='display:none')
26
26
  hr.lobby-divider
27
27
  h3.lobby-section-title= 'HOST A GAME'
@@ -67,8 +67,8 @@ div#lobby(style='display:none')
67
67
  span#leaderboard-total
68
68
  ol#lobby-leaderboard-list
69
69
  p#lobby-my-placement
70
- //- футер: ссылку и версию пакета движка проставляет LobbyView
71
- //- (engineVersion.js + lib/packageLink.js)
70
+ //- футер: ссылка на документацию статична, версию пакета движка
71
+ //- проставляет LobbyView (engineVersion.js)
72
72
  div#lobby-footer
73
73
  p
74
74
  a#lobby-terms-link(target='_blank', rel='noopener') Terms of Service
@@ -76,6 +76,6 @@ div#lobby(style='display:none')
76
76
  |
77
77
  a#lobby-privacy-link(target='_blank', rel='noopener') Privacy Policy
78
78
  p
79
- a#lobby-link
79
+ a#lobby-link(href='https://docs.vimp.lgick.dev', target='_blank', rel='noopener') docs
80
80
  p#lobby-version
81
81
  p &copy; #{new Date().getFullYear()} VIMP
@@ -1,4 +1,4 @@
1
- //- Журнал клиентских ошибок (plan/client-reports, этап 5): только админ.
1
+ //- Журнал клиентских ошибок: только админ.
2
2
  //- Панель скрыта по умолчанию — открывается кнопкой «Errors» бейджа
3
3
  //- пользователя (#lobby-user в lobby.pug); id элементов перечислены в
4
4
  //- config/lobby.js. Подписи — по-английски, как и остальное лобби
@@ -2,8 +2,8 @@
2
2
  // config/lobby.js — auth-сервис живёт на отдельном домене/порте, поэтому
3
3
  // serviceUrl подставляется под конкретный деплой при сборке.
4
4
  export default {
5
- // базовый URL центрального auth-сервиса (packages/auth). Дефолт — dev-порт
6
- // из packages/auth/src/config/auth.js; в проде подставляется Vite'ом из
5
+ // базовый URL центрального auth-сервиса. Дефолт — dev-порт
6
+ // из конфига auth-сервиса; в проде подставляется Vite'ом из
7
7
  // VITE_AUTH_SERVICE_URL (см. Dockerfile — тот же VIMP_AUTH_SERVICE_URL,
8
8
  // что и серверный master/main.js). Этот модуль импортируется и серверным
9
9
  // master/main.js под чистым Node, где import.meta.env не определён —
@@ -14,7 +14,7 @@ export default {
14
14
  'http://localhost:3010',
15
15
 
16
16
  // issuer, которым central auth-сервис подписывает identity-токен; должен
17
- // совпадать с packages/auth/src/config/auth.js: jwt.issuer — хост сверяет
17
+ // совпадать с jwt.issuer из конфига auth-сервиса — хост сверяет
18
18
  // claim iss токена с этим значением при проверке подписи по /jwks
19
19
  issuer: 'vimp-auth',
20
20
 
@@ -300,11 +300,11 @@ export default {
300
300
  joinRetryWindowMs: 45000,
301
301
  },
302
302
 
303
- // приёмник выгрузок отладочного контура (этап 6 плана plan/done/ai-debug):
303
+ // приёмник выгрузок отладочного контура:
304
304
  // маршрут поднимается мастером только в dev, в проде вернёт 404
305
305
  debugReportUrl: '/debug/report',
306
306
 
307
- // журнал клиентских ошибок (plan/client-reports): приём на том же боксе,
307
+ // журнал клиентских ошибок: приём на том же боксе,
308
308
  // что раздал страницу, — лобби-мастер или dedicated
309
309
  clientReportUrl: '/client-reports',
310
310
 
@@ -350,9 +350,8 @@ export default {
350
350
  myPlacementId: 'lobby-my-placement',
351
351
 
352
352
  // футер: версия npm-пакета движка, запечённая в бандл при сборке
353
- // (client/lib/engineVersion.js), и ссылка на его страницу
353
+ // (client/lib/engineVersion.js)
354
354
  versionId: 'lobby-version',
355
- linkId: 'lobby-link',
356
355
  },
357
356
 
358
357
  // реестр игр (master-game-registry, этап 4): заявка разработчика и панель
@@ -489,7 +488,7 @@ export default {
489
488
  },
490
489
  },
491
490
 
492
- // журнал клиентских ошибок (plan/client-reports, этап 5): только админ.
491
+ // журнал клиентских ошибок: только админ.
493
492
  // URL и id элементов — здесь, как у панели реестра игр (reports.pug)
494
493
  clientReports: {
495
494
  urls: {
@@ -1,6 +1,7 @@
1
1
  // Разметка бинарного snapshot-протокола (порт SHOT_DATA).
2
2
  // Единый источник для хоста (упаковка) и клиента (распаковка) — обе стороны
3
- // живут в Rust-ядре: packages/engine/core/src/snapshot.rs (pack) и packages/engine/core/src/client/unpack.rs.
3
+ // живут в Rust-ядре: vimp-engine-core/src/snapshot.rs (pack) и
4
+ // vimp-engine-core/src/client/unpack.rs.
4
5
 
5
6
  // версия контракта движок ↔ игра-плагин (GameManifest, HostPlugin,
6
7
  // ClientPlugin, Wasm Host ABI — docs/{en,ru}/plugin-api.md).
@@ -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,17 +1,16 @@
1
1
  import { WARN, skip, verdict } from '../result.js';
2
2
  import { resolveProjectUrl } from '../../../lib/packageLink.js';
3
3
 
4
- // Игра без объявленного homepage/repository собирается и играется — это не
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:
14
- 'package.json declares homepage or repository (the #auth footer links to it)',
13
+ title: 'package.json declares repository (the games registry links to it)',
15
14
 
16
15
  check(ctx) {
17
16
  if (!ctx.pkg) {
@@ -23,8 +22,8 @@ export default {
23
22
  }
24
23
 
25
24
  return verdict([
26
- 'neither "repository" nor "homepage" resolves to a project URL — ' +
27
- "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",
28
27
  ]);
29
28
  },
30
29
  };
@@ -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'],
@@ -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 }.