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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 lgick
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,9 +1,15 @@
1
1
  # vimp-engine
2
2
 
3
- VIMP — движок-приложение: мастер (лобби, WebRTC-сигналинг), P2P-транспорт,
4
- Worker-хост, мета-модули матча, MVC-каркас клиента. Игровые правила
5
- (например, `@vimp-games/tanks`) подключаются динамически через
6
- [plugin-api](https://github.com/lgick/vimp/blob/main/docs/en/plugin-api.md)
7
- — этот пакет их не содержит.
3
+ The VIMP engine SDK for game plugins: the browser host and client runtime,
4
+ the standalone mode (`vimp-engine/standalone`) and the CLI tools `vimp-sim`
5
+ and `vimp-contract`. Game rules live in separate plugin packages that the
6
+ engine loads at runtime through the
7
+ [plugin contract](https://docs.vimp.lgick.dev/en/plugin-api).
8
8
 
9
- Документация: https://github.com/lgick/vimp/tree/main/docs/en
9
+ Start a game:
10
+
11
+ ```bash
12
+ npm create vimp-game my-game
13
+ ```
14
+
15
+ Documentation: https://docs.vimp.lgick.dev
package/bin/vimp-sim.js CHANGED
@@ -40,8 +40,8 @@ const BUILTIN_NOTICE =
40
40
  'notice: the built-in smoke scenario is running against --game. It only\n' +
41
41
  'notice: proves the loop closes on this plugin: key coverage (invariant 2)\n' +
42
42
  'notice: and prediction drift (invariant 9) are skipped — both need a\n' +
43
- 'notice: scenario written for your game. See § Scenario format in the\n' +
44
- "notice: engine's docs/en/debugging.md (github.com/lgick/vimp), then run\n" +
43
+ 'notice: scenario written for your game. See § Scenario format at\n' +
44
+ 'notice: https://docs.vimp.lgick.dev/en/debugging#scenario-format, then run\n' +
45
45
  'notice: vimp-sim --scenario <file>.\n\n';
46
46
 
47
47
  // типичная опечатка «--core без --game»: фикстурное ядро — обычный JS и
package/core/Cargo.toml CHANGED
@@ -1,10 +1,11 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.23.2"
3
+ version = "0.23.4"
4
4
  edition = "2024"
5
- description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
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"
7
- repository = "https://github.com/lgick/vimp"
7
+ homepage = "https://docs.vimp.lgick.dev/en/core"
8
+ documentation = "https://docs.rs/vimp-engine-core"
8
9
  readme = "README.md"
9
10
 
10
11
  [lib]
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.38.1",
4
- "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
3
+ "version": "0.38.3",
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": {
7
7
  "type": "git",
8
- "url": "git+ssh://git@github.com/lgick/vimp.git"
8
+ "url": "git+ssh://git@github.com/lgick/vimp.git",
9
+ "directory": "packages/engine"
9
10
  },
10
- "homepage": "https://github.com/lgick/vimp#readme",
11
+ "homepage": "https://docs.vimp.lgick.dev",
11
12
  "type": "module",
12
13
  "engines": {
13
14
  "node": ">=20.11.0"
@@ -23,15 +24,23 @@
23
24
  },
24
25
  "files": [
25
26
  "src/lib",
27
+ "!src/lib/clientIp.js",
28
+ "!src/lib/rateLimiter.js",
29
+ "!src/lib/security.js",
26
30
  "src/config",
31
+ "!src/config/env.js",
32
+ "!src/config/master.js",
27
33
  "src/host",
28
34
  "src/client",
29
35
  "!src/client/_*",
30
36
  "src/standalone",
31
37
  "src/devtools",
38
+ "!src/devtools/surface/abiParse.js",
39
+ "!src/devtools/surface/collect.js",
32
40
  "tests/fixtures",
33
41
  "core/Cargo.toml",
34
- "bin"
42
+ "bin",
43
+ "!bin/vimp-surface.js"
35
44
  ],
36
45
  "scripts": {
37
46
  "predev": "node scripts/sync-pixi-vendor.mjs",
@@ -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
  }
@@ -13,7 +13,8 @@ export default class AuthView {
13
13
  // texts — игровые тексты формы (authSchema.texts: title, sections);
14
14
  // сам каркас (auth.pug) нейтрален и текстов игры не содержит.
15
15
  // params — та же дескрипторная схема формы, что и у room-формы
16
- // (docs/en/plugin-api.md "Form schema"), едет по сети в PS_AUTH_DATA
16
+ // (docs.vimp.lgick.dev/en/plugin-api "Form schema"), едет по сети в
17
+ // PS_AUTH_DATA
17
18
  constructor(model, elems, texts = null, params = null) {
18
19
  if (authView) {
19
20
  return authView;
@@ -63,7 +64,8 @@ export default class AuthView {
63
64
  }
64
65
 
65
66
  // строит контролы формы игрока из той же дескрипторной схемы, что и
66
- // room-форма (единый formBuilder, docs/en/plugin-api.md "Form schema")
67
+ // room-форма (единый formBuilder, docs.vimp.lgick.dev/en/plugin-api "Form
68
+ // schema")
67
69
  _renderFields(elems, params) {
68
70
  if (!Array.isArray(params)) {
69
71
  return;
@@ -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
  }
@@ -4,9 +4,9 @@ import { resolveDescriptor } from '../../lib/formControls.js';
4
4
  import { toDisplay, toStored, isNumericField } from '../../lib/formUnit.js';
5
5
 
6
6
  // Общий билдер полей форм (room-форма и auth-форма используют один
7
- // контракт дескрипторов — docs/en/plugin-api.md, раздел "Form schema").
8
- // control: 'select'|'text'|'checkbox'|'radio'; все контролы — нативные
9
- // элементы формы, без визуальной кастомизации.
7
+ // контракт дескрипторов — docs.vimp.lgick.dev/en/plugin-api, раздел "Form
8
+ // schema"). control: 'select'|'text'|'checkbox'|'radio'; все контролы —
9
+ // нативные элементы формы, без визуальной кастомизации.
10
10
  //
11
11
  // Имя контрола приезжает от игры и разрешается через append-only реестр
12
12
  // (lib/formControls.js): имена, выведенные из эксплуатации в v3 ('range',
@@ -270,7 +270,8 @@ const builders = {
270
270
  // control» на имени, которое реестр считает живым
271
271
  export const FORM_CONTROLS = Object.keys(builders);
272
272
 
273
- // строит одно поле формы по дескриптору (docs/en/plugin-api.md, "Form schema")
273
+ // строит одно поле формы по дескриптору (docs.vimp.lgick.dev/en/plugin-api,
274
+ // "Form schema")
274
275
  export function buildField(descriptor, ctx = {}) {
275
276
  // алиас выведенного контрола ('range' → numeric 'text') разрешается
276
277
  // здесь же, где строится поле, и теми же правилами, что в валидации
@@ -291,10 +292,10 @@ export function buildField(descriptor, ctx = {}) {
291
292
  }
292
293
 
293
294
  // засеивает default каждого дескриптора room-формы значением из roomDefaults
294
- // (docs/en/plugin-api.md "Form schema": roomDefaults остаётся единственным
295
- // источником значений по умолчанию); явный descriptor.default, если он есть
296
- // в схеме, побеждает. Вынесено из populateRoomForm (main.js), чтобы этот же
297
- // код был накрыт тестом, а не продублирован в нём
295
+ // (docs.vimp.lgick.dev/en/plugin-api "Form schema": roomDefaults остаётся
296
+ // единственным источником значений по умолчанию); явный descriptor.default,
297
+ // если он есть в схеме, побеждает. Вынесено из populateRoomForm (main.js),
298
+ // чтобы этот же код был накрыт тестом, а не продублирован в нём
298
299
  export function mergeRoomDefaults(descriptors, roomDefaults) {
299
300
  return descriptors.map(descriptor => ({
300
301
  default: roomDefaults[descriptor.name],
@@ -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
+ }