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
package/core/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.23.3"
3
+ version = "0.23.4"
4
4
  edition = "2024"
5
5
  description = "VIMP engine simulation core: physics, map, snapshot framing, interpolation/prediction/raycast primitives, navigation utilities. No wasm-bindgen — the game crate builds the WASM ABI wrappers."
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.38.2",
3
+ "version": "0.38.3",
4
4
  "description": "VIMP engine SDK: browser host and client runtime, standalone mode and CLI tools (vimp-sim, vimp-contract) for VIMP game plugins",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,6 +35,8 @@
35
35
  "!src/client/_*",
36
36
  "src/standalone",
37
37
  "src/devtools",
38
+ "!src/devtools/surface/abiParse.js",
39
+ "!src/devtools/surface/collect.js",
38
40
  "tests/fixtures",
39
41
  "core/Cargo.toml",
40
42
  "bin",
@@ -11,8 +11,14 @@
11
11
  // window не вводим: они переживают перезагрузку бандла и ломают вторую
12
12
  // вкладку SDK в том же документе.
13
13
 
14
+ import {
15
+ bodyReadFailure,
16
+ fetchRetrying,
17
+ isNetworkFailure,
18
+ } from './lib/retryOnNetworkError.js';
19
+
14
20
  // эндпоинт режима: его отдаёт dedicated-сервер, у мастера его нет —
15
- // 404/сеть/мусор означают прод-лобби (обратная совместимость)
21
+ // 404 и мусор означают прод-лобби; сбой сети — отказ загрузки
16
22
  const CONFIG_URL = '/config';
17
23
 
18
24
  // умолчание игрового WS dedicated-сервера: тот же хост, путь /game
@@ -65,23 +71,40 @@ export function resetBootConfig() {
65
71
  /**
66
72
  * Разрешает режим загрузки: инъекция SDK → GET /config → лобби.
67
73
  * @param {Function} [fetchImpl] - Реализация fetch (тесты подставляют свою).
68
- * @returns {Promise<Object>} Конфиг загрузки.
74
+ * @param {Object} [retry] - Параметры fetchRetrying (тесты отключают паузы).
75
+ * @returns {Promise<Object>} Конфиг загрузки; отклоняется NetworkFailure, если
76
+ * сеть не ответила после повторов.
69
77
  */
70
- export async function resolveBootConfig(fetchImpl) {
78
+ export async function resolveBootConfig(fetchImpl, retry) {
71
79
  if (bootConfig) {
72
80
  return bootConfig;
73
81
  }
74
82
 
75
83
  const doFetch = fetchImpl ?? ((...args) => fetch(...args));
84
+ let res = null;
76
85
 
77
86
  try {
78
- const res = await doFetch(CONFIG_URL);
87
+ res = await fetchRetrying(doFetch, CONFIG_URL, retry);
88
+ } catch (e) {
89
+ // сети нет: режим неизвестен, и лобби здесь — догадка, а не факт
90
+ if (isNetworkFailure(e)) {
91
+ throw e;
92
+ }
93
+ }
79
94
 
80
- if (res.ok) {
95
+ if (res?.ok) {
96
+ try {
81
97
  bootConfig = normalize(await res.json());
98
+ } catch (e) {
99
+ // мусор в JSON — лобби; обрыв при чтении тела — сбой сети
100
+ const failure = bodyReadFailure(e);
101
+
102
+ if (isNetworkFailure(failure)) {
103
+ throw failure;
104
+ }
105
+
106
+ bootConfig = null;
82
107
  }
83
- } catch (e) {
84
- // сети/эндпоинта нет — это и есть прод-лобби, шуметь не о чем
85
108
  }
86
109
 
87
110
  bootConfig = bootConfig ?? { mode: 'lobby' };
@@ -2,7 +2,7 @@
2
2
 
3
3
  let clientReportsCtrl;
4
4
 
5
- // Контроллер журнала клиентских ошибок (plan/client-reports, этап 5):
5
+ // Контроллер журнала клиентских ошибок:
6
6
  // связывает view-события с моделью, собственного сетевого I/O не делает
7
7
  export default class ClientReportsCtrl {
8
8
  constructor(model, view) {
@@ -95,7 +95,7 @@ export default class GamesCtrl {
95
95
  }
96
96
 
97
97
  approve({ id }) {
98
- this._model.moderate(id, { status: 'approved' });
98
+ this._model.approve(id);
99
99
  }
100
100
 
101
101
  reject({ id, note }) {
@@ -4,7 +4,7 @@ import Publisher from '../../../lib/Publisher.js';
4
4
 
5
5
  let clientReportsModel;
6
6
 
7
- // Модель журнала клиентских ошибок в лобби (plan/client-reports, этап 5):
7
+ // Модель журнала клиентских ошибок в лобби:
8
8
  // страницы журнала, фильтры и решение админа по строке. Как GamesModel,
9
9
  // сетевые запросы делает сама (простой REST к мастеру) и DOM не трогает.
10
10
  // Наружу — одно событие 'changed' с полным состоянием: список, счётчик,
@@ -222,6 +222,24 @@ export default class GamesModel {
222
222
  await this.loadAdmin();
223
223
  }
224
224
 
225
+ // одобрение называет заявку, которую модератор видит в карточке
226
+ // (this._all — то, что нарисовано): переподанная после загрузки очереди
227
+ // заявка вернёт 409 moderationStale
228
+ async approve(id) {
229
+ const game = this._all.find(item => item.id === id);
230
+
231
+ if (!game) {
232
+ this._fail('admin', { error: 'unknownGame' });
233
+ return;
234
+ }
235
+
236
+ await this.moderate(id, {
237
+ status: 'approved',
238
+ expectedPendingVersion: game.pendingVersion ?? null,
239
+ expectedPendingRepoUrl: game.pendingRepoUrl ?? null,
240
+ });
241
+ }
242
+
225
243
  async moderate(id, patch) {
226
244
  const { ok, json } = await this._request(this._config.urls.moderate(id), {
227
245
  method: 'PATCH',
@@ -229,6 +247,12 @@ export default class GamesModel {
229
247
  });
230
248
 
231
249
  if (!ok) {
250
+ // карточка устарела: показать свежую и только потом отказ — иначе
251
+ // перерисовка сотрёт его тем же тиком
252
+ if (json?.error === 'moderationStale') {
253
+ await this.loadAdmin();
254
+ }
255
+
232
256
  this._fail('admin', json);
233
257
  return;
234
258
  }
@@ -57,7 +57,7 @@ function truncate(text, max) {
57
57
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
58
58
  }
59
59
 
60
- // Представление журнала клиентских ошибок (plan/client-reports, этап 5).
60
+ // Представление журнала клиентских ошибок.
61
61
  //
62
62
  // ***** БЕЗОПАСНОСТЬ *****
63
63
  //
@@ -19,6 +19,9 @@ const ERROR_MESSAGES = {
19
19
  gameExists: 'A game with this id already exists',
20
20
  tooManyGames: 'Too many submissions from one author',
21
21
  unknownGame: 'Game is not in the registry',
22
+ // автор переподал заявку, пока модератор смотрел карточку
23
+ moderationStale:
24
+ 'The request changed while you were reviewing it — check the refreshed card',
22
25
  gamePublished: 'Published game — ask an admin to disable it first',
23
26
  unknownUser: 'No player with this nick',
24
27
  invalidGameId: 'Invalid game id',
@@ -270,6 +273,7 @@ export default class GamesView {
270
273
  );
271
274
  item.appendChild(this._line(this._statusLine(game)));
272
275
  this._appendRepo(item, game.repoUrl);
276
+ this._appendPendingRepo(item, game);
273
277
 
274
278
  if (game.moderatorNote) {
275
279
  item.appendChild(this._line(`Note: ${game.moderatorNote}`));
@@ -390,6 +394,7 @@ export default class GamesView {
390
394
  ),
391
395
  );
392
396
  this._appendRepo(item, game.repoUrl);
397
+ this._appendPendingRepo(item, game);
393
398
  item.appendChild(
394
399
  this._line(
395
400
  `Served: ${game.version ?? '—'}; requested: ${game.pendingVersion ?? '—'}` +
@@ -460,7 +465,7 @@ export default class GamesView {
460
465
  // принимает только http(s): href — единственное место представления, где
461
466
  // содержимое поля становится исполняемым (javascript:), и полагаться на
462
467
  // одну проверку на другой стороне сети тут не стоит
463
- _appendRepo(item, url) {
468
+ _appendRepo(item, url, prefix = '') {
464
469
  if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
465
470
  return;
466
471
  }
@@ -472,10 +477,23 @@ export default class GamesView {
472
477
  link.textContent = url;
473
478
  link.target = '_blank';
474
479
  link.rel = 'noopener noreferrer';
480
+
481
+ if (prefix) {
482
+ line.append(prefix);
483
+ }
484
+
475
485
  line.appendChild(link);
476
486
  item.appendChild(line);
477
487
  }
478
488
 
489
+ // адрес, который версия на модерации принесёт при одобрении: строка
490
+ // появляется, только если он отличается от текущего
491
+ _appendPendingRepo(item, game) {
492
+ if (game.pendingRepoUrl && game.pendingRepoUrl !== game.repoUrl) {
493
+ this._appendRepo(item, game.pendingRepoUrl, 'After approval: ');
494
+ }
495
+ }
496
+
479
497
  _statusLine(game) {
480
498
  const status = STATUS_TITLES[game.status] ?? game.status;
481
499
  const date = game.createdAt
@@ -1,6 +1,5 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
- import { ENGINE_PROJECT_URL, ENGINE_VERSION } from '../../lib/engineVersion.js';
3
- import { renderProjectLink } from '../../lib/footerLink.js';
2
+ import { ENGINE_VERSION } from '../../lib/engineVersion.js';
4
3
  import { absoluteLink, formatRoomLink } from '../../lib/roomLink.js';
5
4
 
6
5
  // Singleton LobbyView
@@ -65,11 +64,6 @@ export default class LobbyView {
65
64
  version.textContent = ENGINE_VERSION;
66
65
  }
67
66
 
68
- renderProjectLink(
69
- document.getElementById(elems.linkId),
70
- ENGINE_PROJECT_URL,
71
- );
72
-
73
67
  // заголовок игры для "<TITLE> TOP-N" (SVG-ориентир) — задаётся controller'ом
74
68
  // при выборе игры (сама модель не хранит title манифеста)
75
69
  this._gameTitle = '';
@@ -1,4 +1,4 @@
1
- // Браузерная половина отладочного контура (этап 6 плана plan/done/ai-debug).
1
+ // Браузерная половина отладочного контура.
2
2
  //
3
3
  // Всё, что headless-прогон не воспроизводит — реальный WebRTC, PixiJS, живой
4
4
  // ввод человека — читается и записывается отсюда: `window.__vimpDebug`
@@ -1,4 +1,4 @@
1
- // REST-клиент auth-сервиса (packages/auth): регистрация, сессия, OAuth,
1
+ // REST-клиент auth-сервиса: регистрация, сессия, OAuth,
2
2
  // управление профилем. Без DOM; fetch инъектируется (тесты).
3
3
  //
4
4
  // Каждый метод возвращает { ok: true, status, data } или
@@ -1,4 +1,4 @@
1
- // Журнал клиентских ошибок (plan/client-reports, этап 3): неперехваченные
1
+ // Журнал клиентских ошибок: неперехваченные
2
2
  // ошибки и отклонения страницы, ошибки Worker'а хоста, нарушения CSP и явные
3
3
  // warn/capture плагинов уходят на свой же бокс (POST /client-reports), а тот
4
4
  // пересылает их в центральный журнал.
@@ -175,7 +175,7 @@ function noop() {}
175
175
  * @param {number} [opts.flushDelayMs=2000] - задержка первой отправки
176
176
  * @param {number} [opts.minIntervalMs=10000] - интервал между отправками по
177
177
  * таймеру; прямой flush() его не ждёт
178
- * @returns {{ capture, warn, flush, setContext, install }}
178
+ * @returns {{ capture, ignore, warn, flush, setContext, install }}
179
179
  */
180
180
  export function createDiagnostics({
181
181
  url,
@@ -189,6 +189,7 @@ export function createDiagnostics({
189
189
  if (!url) {
190
190
  return {
191
191
  capture: noop,
192
+ ignore: noop,
192
193
  warn: noop,
193
194
  flush: noop,
194
195
  setContext: noop,
@@ -211,7 +212,7 @@ export function createDiagnostics({
211
212
  let busy = false;
212
213
 
213
214
  function record(item) {
214
- const key = `${item.source}|${item.kind}|${item.code ?? item.message}|${topFrame(item.stack)}`;
215
+ const key = `${item.source}|${item.kind}|${item.code ?? item.message}|${item.code ? '' : topFrame(item.stack)}`;
215
216
  const at = now();
216
217
  const known = records.get(key);
217
218
 
@@ -276,8 +277,22 @@ export function createDiagnostics({
276
277
  }
277
278
  }
278
279
 
280
+ // ошибки, о которых вызывающий уже позаботился сам (сбой сети при
281
+ // загрузке): перехватчик окна их не записывает
282
+ const ignored = new WeakSet();
283
+
284
+ function ignore(error) {
285
+ if (error !== null && typeof error === 'object') {
286
+ ignored.add(error);
287
+ }
288
+ }
289
+
279
290
  function capture(error, { source = 'client', kind = 'error' } = {}) {
280
291
  guarded(() => {
292
+ if (error !== null && typeof error === 'object' && ignored.has(error)) {
293
+ return;
294
+ }
295
+
281
296
  const raw =
282
297
  error !== null && error !== undefined && error.message !== undefined
283
298
  ? error.message
@@ -485,5 +500,5 @@ export function createDiagnostics({
485
500
  };
486
501
  }
487
502
 
488
- return { capture, warn, flush, setContext, install };
503
+ return { capture, ignore, warn, flush, setContext, install };
489
504
  }
@@ -7,11 +7,5 @@
7
7
  // бандлу (в npm-тарболле vimp-engine package.json есть всегда, поэтому
8
8
  // standalone SDK собирается у игры тем же импортом).
9
9
  import pkg from '../../../package.json' with { type: 'json' };
10
- import { resolveProjectUrl } from '../../lib/packageLink.js';
11
10
 
12
11
  export const ENGINE_VERSION = pkg.version;
13
-
14
- // адрес проекта для ссылки в футере лобби — то же поле, что мастер отдаёт
15
- // про пакет игры (GameCatalog.packageUrl), чтобы оба футера строил один
16
- // projectLink
17
- export const ENGINE_PROJECT_URL = resolveProjectUrl(pkg);
@@ -1,29 +1,30 @@
1
- import { projectLink } from '../../lib/packageLink.js';
2
-
3
- // Ссылка в футере лобби и формы входа. Один рендер на оба экрана: лобби
4
- // подставляет адрес проекта движка, форма входа — адрес игры из манифеста.
5
- //
6
- // Адреса нет (пакет не объявил repository/homepage) — якорь гасится, а не
7
- // прячется: ячейка футера остаётся пустой, и раскладка space-between не едет.
1
+ // Ссылка на документацию в футере формы входа игры. Показывается только в
2
+ // dev-сборке: разработчик игры при локальном запуске видит, где читать
3
+ // документацию, а игрок на проде — нет. Футер лобби несёт ту же ссылку
4
+ // статичной разметкой (lobby.pug). Тот же адрес — литералом в lobby.pug и в
5
+ // `homepage` пакета; совпадение сторожит tests/client/footerLink.test.js.
6
+ export const DOCS_URL = 'https://docs.vimp.lgick.dev';
8
7
 
9
8
  /**
10
9
  * @param {HTMLAnchorElement|null} anchor - якорь футера
11
- * @param {string|null} url - адрес проекта (resolveProjectUrl)
10
+ * @param {boolean} enabled - true только в dev-сборке
12
11
  */
13
- export function renderProjectLink(anchor, url) {
12
+ export function renderDocsLink(anchor, enabled) {
14
13
  if (!anchor) {
15
14
  return;
16
15
  }
17
16
 
18
- const link = projectLink(url);
19
-
20
- if (!link) {
17
+ if (!enabled) {
21
18
  anchor.textContent = '';
22
19
  anchor.removeAttribute('href');
20
+ anchor.removeAttribute('target');
21
+ anchor.removeAttribute('rel');
23
22
 
24
23
  return;
25
24
  }
26
25
 
27
- anchor.textContent = link.label;
28
- anchor.href = link.url;
26
+ anchor.textContent = 'docs';
27
+ anchor.href = DOCS_URL;
28
+ anchor.target = '_blank';
29
+ anchor.rel = 'noopener';
29
30
  }
@@ -0,0 +1,60 @@
1
+ // Повтор запроса загрузки при сбое сети. fetch отклоняется TypeError'ом,
2
+ // только когда ответа нет вовсе (обрыв, смена сети, вкладка из фона);
3
+ // HTTP-статус и разбор ответа сбоем сети не являются и не повторяются.
4
+ // Повторяется только сам вызов fetch: TypeError из разбора ответа — ошибка
5
+ // кода, а не сети.
6
+
7
+ // паузы перед повторами: три запроса, отказ не позже ~2 с сверх самих запросов
8
+ export const BOOT_RETRY_DELAYS_MS = [500, 1500];
9
+
10
+ const defaultSleep = ms => new Promise(resolve => setTimeout(resolve, ms));
11
+
12
+ /** Сбой сети, оставшийся после повторов; отличает его от ошибок кода. */
13
+ export class NetworkFailure extends Error {
14
+ constructor(cause) {
15
+ super(cause?.message ?? 'Network failure', { cause });
16
+ this.name = 'NetworkFailure';
17
+ }
18
+ }
19
+
20
+ export const isNetworkFailure = error => error instanceof NetworkFailure;
21
+
22
+ /**
23
+ * Оборачивает отказ чтения тела ответа: SyntaxError — это мусор в JSON,
24
+ * любой другой отказ (TypeError обрыва) — сбой сети.
25
+ * @param {Error} error
26
+ * @returns {Error}
27
+ */
28
+ export const bodyReadFailure = error =>
29
+ error instanceof SyntaxError ? error : new NetworkFailure(error);
30
+
31
+ /**
32
+ * @param {Function} doFetch - (...args) => Promise<Response>: сам fetch.
33
+ * @param {*} url
34
+ * @param {Object} [options]
35
+ * @param {number[]} [options.delaysMs] - паузы перед повторами, мс.
36
+ * @param {Function} [options.sleep] - (ms) => Promise (тесты подставляют свою).
37
+ * @returns {Promise<Response>} ответ первой удавшейся попытки; отклоняется
38
+ * NetworkFailure, когда сеть не ответила во всех попытках.
39
+ */
40
+ export async function fetchRetrying(
41
+ doFetch,
42
+ url,
43
+ { delaysMs = BOOT_RETRY_DELAYS_MS, sleep = defaultSleep } = {},
44
+ ) {
45
+ for (let attempt = 0; ; attempt += 1) {
46
+ try {
47
+ return await doFetch(url);
48
+ } catch (e) {
49
+ if (!(e instanceof TypeError)) {
50
+ throw e;
51
+ }
52
+
53
+ if (attempt >= delaysMs.length) {
54
+ throw new NetworkFailure(e);
55
+ }
56
+
57
+ await sleep(delaysMs[attempt]);
58
+ }
59
+ }
60
+ }
@@ -32,7 +32,7 @@ import {
32
32
  bindLiveErrors,
33
33
  } from './lib/formBuilder.js';
34
34
  import { normalizeAuthParams } from './lib/authParams.js';
35
- import { renderProjectLink } from './lib/footerLink.js';
35
+ import { renderDocsLink } from './lib/footerLink.js';
36
36
  import { createGameActivator } from './lib/gameActivator.js';
37
37
  import createAutostart from './lib/autostart.js';
38
38
  import { applyCatalogState } from './lib/catalogState.js';
@@ -46,6 +46,7 @@ import { createContextTracker } from './lib/contextTracker.js';
46
46
  import { createLocalPlayer } from './lib/localPlayer.js';
47
47
  import { createAccolades } from './lib/accolades.js';
48
48
  import { createDiagnostics } from './lib/diagnostics.js';
49
+ import { fetchRetrying, isNetworkFailure } from './lib/retryOnNetworkError.js';
49
50
  import { dispatchSocketMessage } from './lib/socketDispatch.js';
50
51
  import { pickActiveGame, isGameAvailable } from './lib/pickActiveGame.js';
51
52
  import { readCoreAbi, dispatchCoreOp, ABI_UNKNOWN } from '../lib/coreAbi.js';
@@ -183,13 +184,25 @@ function bindActiveGame(manifest, plugin) {
183
184
  const injectedBoot = getBootConfig();
184
185
  const manifestPromise = injectedBoot
185
186
  ? null
186
- : fetchGamesManifest(lobbyConfig.gamesManifestUrl).catch(err => err);
187
+ : fetchGamesManifest(lobbyConfig.gamesManifestUrl, (...args) =>
188
+ fetchRetrying(fetch, ...args),
189
+ ).catch(err => err);
190
+
191
+ let boot;
192
+
193
+ try {
194
+ boot = injectedBoot ?? (await resolveBootConfig());
195
+ } catch (e) {
196
+ // сети нет и режим неизвестен: репортёр ещё не создан (ему нужен режим),
197
+ // игроку остаётся надпись отказа
198
+ showBootFailure(`Failed to load the game: ${e.message}`);
199
+ throw e;
200
+ }
187
201
 
188
- const boot = injectedBoot ?? (await resolveBootConfig());
189
202
  const bootMode = boot.mode;
190
203
  const isLobbyMode = bootMode === 'lobby';
191
204
 
192
- // журнал клиентских ошибок (plan/client-reports): ставится как можно
205
+ // журнал клиентских ошибок: ставится как можно
193
206
  // раньше, чтобы ловить и сбои самого старта. Лобби и dedicated шлют на
194
207
  // свой бокс; SDK (solo) — только если встраивающий передал reportUrl
195
208
  const diagnostics = createDiagnostics({
@@ -273,6 +286,12 @@ try {
273
286
  }
274
287
  } catch (e) {
275
288
  showBootFailure(`Failed to load the game: ${e.message}`, gameContainer);
289
+
290
+ // сбой сети — не ошибка кода: в журнал он не уходит
291
+ if (isNetworkFailure(e)) {
292
+ diagnostics.ignore(e);
293
+ }
294
+
276
295
  throw e;
277
296
  }
278
297
 
@@ -348,8 +367,8 @@ const renderContexts = {};
348
367
  // контекста (хост её повторно не пришлёт)
349
368
  let lastMapData = null;
350
369
 
351
- // dev-сборка (Vite подставляет константу): включает отладочный контур этапа 6
352
- // плана plan/done/ai-debug — рекордер в комнате и window.__vimpDebug. В прод-бандле
370
+ // dev-сборка (Vite подставляет константу): включает отладочный контур —
371
+ // рекордер в комнате и window.__vimpDebug. В прод-бандле
353
372
  // ветка вырезается сборкой, поведение не меняется
354
373
  const isDevBuild =
355
374
  typeof import.meta.env !== 'undefined' && import.meta.env.DEV === true;
@@ -527,7 +546,7 @@ socketMethods[PS_CONFIG_DATA] = async data => {
527
546
  // part, объявивший сервис в componentDependencies, строит URL сам —
528
547
  // движок не знает ни имён файлов, ни их раскладки внутри пакета
529
548
  assetsBase: activeGameManifest.assetsBase,
530
- // журнал клиентских ошибок для партов игры (plan/client-reports):
549
+ // журнал клиентских ошибок для партов игры:
531
550
  // warn(code, details) — своё предупреждение, capture(error) — пойманная
532
551
  // ошибка. Сервис опционален: игра не пишет его в requires
533
552
  diagnostics: {
@@ -577,21 +596,19 @@ socketMethods[PS_AUTH_DATA] = data => {
577
596
 
578
597
  document.getElementById('logo').textContent = texts?.title || 'VIMP';
579
598
 
580
- // футер формы входа: версия и ссылка пакета ИГРЫ. Здесь, а не в AuthView:
581
- // его elems приходят из authSchema.elems игрового плагина, и футер каркаса
582
- // — не его забота. Метаданные кладёт в манифест мастер (GameCatalog читает
583
- // package.json пакета); в standalone-манифесте их может не быть — тогда
584
- // ячейки пустые, раскладку space-between это не ломает
599
+ // футер формы входа: версия пакета ИГРЫ и (в dev-сборке) ссылка на
600
+ // документацию. Здесь, а не в AuthView: его elems приходят из
601
+ // authSchema.elems игрового плагина, и футер каркаса — не его забота.
602
+ // Версию кладёт в манифест мастер (GameCatalog читает package.json
603
+ // пакета); в standalone-манифесте её может не быть — тогда ячейка пустая,
604
+ // раскладку space-between это не ломает
585
605
  const authVersion = document.getElementById('auth-version');
586
606
 
587
607
  if (authVersion) {
588
608
  authVersion.textContent = activeGameManifest?.packageVersion ?? '';
589
609
  }
590
610
 
591
- renderProjectLink(
592
- document.getElementById('auth-package-link'),
593
- activeGameManifest?.packageUrl,
594
- );
611
+ renderDocsLink(document.getElementById('auth-docs-link'), isDevBuild);
595
612
 
596
613
  // память клиента (localStorage) + принудительное значение поля с
597
614
  // единственным вариантом. Здесь, а не в AuthView: solo-путь ниже отвечает
@@ -1034,7 +1051,7 @@ socketMethods[PS_RESUME_RESULT] = data => {
1034
1051
  };
1035
1052
 
1036
1053
  // console: логи авторитетной половины (Worker изолирован от DevTools вкладки —
1037
- // иначе его события в браузере не видны вовсе, этап 6 плана plan/done/ai-debug)
1054
+ // иначе его события в браузере не видны вовсе)
1038
1055
  socketMethods[PS_CONSOLE] = data => {
1039
1056
  console.log(`${DEBUG_PREFIX}[host]`, data);
1040
1057
  };
@@ -2478,7 +2495,7 @@ function initLobby() {
2478
2495
  // сам, по нику из VIMP_ADMIN_NICKS
2479
2496
  games.setAdmin(lobbyAuthModel.getRole() === 'admin');
2480
2497
 
2481
- // журнал клиентских ошибок (plan/client-reports, этап 5): кнопку «Errors»
2498
+ // журнал клиентских ошибок: кнопку «Errors»
2482
2499
  // видит только админ; фильтр по игре — каталог вкладки
2483
2500
  const clientReportsModel = new ClientReportsModel(
2484
2501
  lobbyConfig.clientReports,
@@ -7,8 +7,14 @@
7
7
  // Эпизод закрывается возвратом видимости, потерей роли хоста или
8
8
  // выгрузкой страницы (flush). Следующие эпизоды той же сессии журнал
9
9
  // склеивает с первым: растёт счётчик, details остаются первого — осознанно.
10
- // Эпизод без единой метрики (матч стоял, скрытие короче окна цикла) не
11
- // пишется. Без DOM: видимость сообщает владелец.
10
+ // Пишется только эпизод, в котором матч просел (потеряно время или частота
11
+ // ниже порога), это задело удалённых игроков (peers > 0), а передача хоста
12
+ // не началась без видимой причины (handoff 'unexplained'): просадка без
13
+ // игроков, ожидаемый отказ (нет преемника, срок роли, кулдаун, выключено) и
14
+ // начатая передача — штатный троттлинг, а сводка здорового эпизода заняла бы
15
+ // место details первой записи. Эпизод без единой метрики (матч стоял,
16
+ // скрытие короче окна цикла) не пишется. Без DOM: видимость и исход
17
+ // передачи сообщает владелец.
12
18
 
13
19
  export const HIDDEN_HEALTH_CODE = 'engine.host.hiddenHealth';
14
20
 
@@ -16,11 +22,22 @@ export default class HiddenHostHealthLog {
16
22
  /**
17
23
  * @param {Object} options
18
24
  * @param {Function} options.warn - (code, summary) запись в журнал.
25
+ * @param {number} options.minHealthyTickRate - ниже — эпизод просел.
26
+ * @param {Function} options.handoffOutcome - () => исход автопередачи
27
+ * (HostHealthPolicy.hiddenHandoff).
19
28
  * @param {boolean} [options.hidden] - вкладка скрыта в момент создания.
20
29
  * @param {Function} [options.now] - монотонные часы, мс.
21
30
  */
22
- constructor({ warn, hidden = false, now = () => performance.now() }) {
31
+ constructor({
32
+ warn,
33
+ minHealthyTickRate,
34
+ handoffOutcome,
35
+ hidden = false,
36
+ now = () => performance.now(),
37
+ }) {
23
38
  this._warn = warn;
39
+ this._minHealthyTickRate = minHealthyTickRate;
40
+ this._handoffOutcome = handoffOutcome;
24
41
  this._now = now;
25
42
  this._episode = null;
26
43
 
@@ -35,6 +52,7 @@ export default class HiddenHostHealthLog {
35
52
  minTickRate: Infinity,
36
53
  maxGapMs: 0,
37
54
  lostMs: 0,
55
+ peers: 0,
38
56
  };
39
57
  } else if (!hidden) {
40
58
  this.flush();
@@ -53,6 +71,11 @@ export default class HiddenHostHealthLog {
53
71
  episode.minTickRate = Math.min(episode.minTickRate, health.tickRate);
54
72
  episode.maxGapMs = Math.max(episode.maxGapMs, health.maxGapMs);
55
73
  episode.lostMs += health.lostMs;
74
+
75
+ // peers — по просевшим метрикам: сколько игроков задела просадка
76
+ if (health.lostMs > 0 || health.tickRate < this._minHealthyTickRate) {
77
+ episode.peers = Math.max(episode.peers, health.peerCount ?? 0);
78
+ }
56
79
  }
57
80
 
58
81
  // закрыть эпизод (если идёт) и записать сводку
@@ -65,12 +88,26 @@ export default class HiddenHostHealthLog {
65
88
  return;
66
89
  }
67
90
 
91
+ // peers считается по просевшим метрикам: 0 — здоровый эпизод либо
92
+ // просадка без удалённых игроков
93
+ if (episode.peers === 0) {
94
+ return;
95
+ }
96
+
97
+ const handoff = this._handoffOutcome();
98
+
99
+ if (handoff !== 'unexplained') {
100
+ return;
101
+ }
102
+
68
103
  this._warn(HIDDEN_HEALTH_CODE, {
69
104
  hiddenMs: Math.round(this._now() - episode.startedAt),
70
105
  samples: episode.samples,
71
106
  minTickRate: episode.minTickRate,
72
107
  maxGapMs: episode.maxGapMs,
73
108
  lostMs: episode.lostMs,
109
+ peers: episode.peers,
110
+ handoff,
74
111
  });
75
112
  }
76
113
  }