vimp-engine 0.38.1 → 0.38.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +12 -6
- package/bin/vimp-sim.js +2 -2
- package/core/Cargo.toml +4 -3
- package/package.json +14 -5
- package/src/client/boot.js +30 -7
- package/src/client/components/controller/ClientReports.js +1 -1
- package/src/client/components/controller/Games.js +1 -1
- package/src/client/components/model/ClientReports.js +1 -1
- package/src/client/components/model/Games.js +24 -0
- package/src/client/components/view/Auth.js +4 -2
- package/src/client/components/view/ClientReports.js +1 -1
- package/src/client/components/view/Games.js +19 -1
- package/src/client/components/view/Lobby.js +1 -7
- package/src/client/debug.js +1 -1
- package/src/client/lib/authApi.js +1 -1
- package/src/client/lib/diagnostics.js +19 -4
- package/src/client/lib/engineVersion.js +0 -6
- package/src/client/lib/footerLink.js +15 -14
- package/src/client/lib/formBuilder.js +9 -8
- package/src/client/lib/retryOnNetworkError.js +60 -0
- package/src/client/main.js +46 -28
- package/src/client/network/HiddenHostHealthLog.js +40 -3
- package/src/client/network/HostController.js +4 -4
- package/src/client/network/HostHealthPolicy.js +41 -0
- package/src/client/network/InlineHostBridge.js +8 -1
- package/src/client/network/WebSocketTransport.js +1 -1
- package/src/client/session/HandoffFlow.js +5 -0
- package/src/client/session/HostRole.js +5 -2
- package/src/client/style.css +5 -6
- package/src/client/views/gameShell.js +3 -3
- package/src/client/views/includes/auth.pug +4 -4
- package/src/client/views/includes/lobby.pug +6 -5
- package/src/client/views/includes/reports.pug +1 -1
- package/src/config/authClient.js +4 -5
- package/src/config/gameCodes.js +2 -2
- package/src/config/lobby.js +4 -5
- package/src/config/opcodes.js +2 -1
- package/src/config/wsports.js +2 -2
- package/src/devtools/builtinScenario.js +3 -3
- package/src/devtools/contract/loadContext.js +2 -2
- package/src/devtools/contract/rules/a2-package-scripts.js +4 -3
- package/src/devtools/contract/rules/a4-vite-config.js +4 -3
- package/src/devtools/contract/rules/a7-package-repository.js +7 -7
- package/src/devtools/contract/rules/c4-component-dependencies.js +5 -5
- package/src/devtools/contract/rules/index.js +1 -1
- package/src/devtools/invariants.js +2 -2
- package/src/devtools/pluginLoader.js +3 -3
- package/src/host/DebugRecorder.js +1 -1
- package/src/host/HostGame.js +35 -15
- package/src/host/PortMachine.js +6 -5
- package/src/host/commandFailure.js +140 -0
- package/src/host/host.worker.js +8 -2
- package/src/host/meta/SocketManager.js +2 -2
- package/src/host/meta/core/CommandProcessor.js +37 -7
- package/src/lib/applyRoomOverrides.js +1 -1
- package/src/lib/capabilities.js +1 -1
- package/src/lib/checkpointCodec.js +1 -2
- package/src/lib/coreConfig.js +1 -1
- package/src/lib/createHostRuntime.js +1 -1
- package/src/lib/gameId.js +1 -1
- package/src/lib/gamePlugin.js +6 -2
- package/src/lib/jwt.js +1 -1
- package/src/lib/loadGamePackage.js +1 -1
- package/src/lib/packageLink.js +21 -63
- package/src/lib/validators.js +3 -3
- package/src/standalone/index.js +6 -6
- package/tests/fixtures/generations/gen-api3/README.md +2 -2
- package/tests/fixtures/generations/gen-api4/README.md +2 -2
- package/bin/vimp-surface.js +0 -88
- package/src/config/env.js +0 -104
- package/src/config/master.js +0 -288
- package/src/devtools/surface/abiParse.js +0 -175
- package/src/devtools/surface/collect.js +0 -413
- package/src/lib/clientIp.js +0 -96
- package/src/lib/rateLimiter.js +0 -40
- package/src/lib/security.js +0 -45
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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
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.
|
|
3
|
+
version = "0.23.4"
|
|
4
4
|
edition = "2024"
|
|
5
|
-
description = "VIMP
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "VIMP
|
|
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://
|
|
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",
|
package/src/client/boot.js
CHANGED
|
@@ -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
|
-
* @
|
|
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
|
-
|
|
87
|
+
res = await fetchRetrying(doFetch, CONFIG_URL, retry);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// сети нет: режим неизвестен, и лобби здесь — догадка, а не факт
|
|
90
|
+
if (isNetworkFailure(e)) {
|
|
91
|
+
throw e;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
79
94
|
|
|
80
|
-
|
|
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
|
-
// Контроллер журнала клиентских
|
|
5
|
+
// Контроллер журнала клиентских ошибок:
|
|
6
6
|
// связывает view-события с моделью, собственного сетевого I/O не делает
|
|
7
7
|
export default class ClientReportsCtrl {
|
|
8
8
|
constructor(model, view) {
|
|
@@ -4,7 +4,7 @@ import Publisher from '../../../lib/Publisher.js';
|
|
|
4
4
|
|
|
5
5
|
let clientReportsModel;
|
|
6
6
|
|
|
7
|
-
// Модель журнала клиентских ошибок в
|
|
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
|
|
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
|
|
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
|
-
// Представление журнала клиентских
|
|
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 {
|
|
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 = '';
|
package/src/client/debug.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Браузерная половина отладочного
|
|
1
|
+
// Браузерная половина отладочного контура.
|
|
2
2
|
//
|
|
3
3
|
// Всё, что headless-прогон не воспроизводит — реальный WebRTC, PixiJS, живой
|
|
4
4
|
// ввод человека — читается и записывается отсюда: `window.__vimpDebug`
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Журнал клиентских
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
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 {
|
|
10
|
+
* @param {boolean} enabled - true только в dev-сборке
|
|
12
11
|
*/
|
|
13
|
-
export function
|
|
12
|
+
export function renderDocsLink(anchor, enabled) {
|
|
14
13
|
if (!anchor) {
|
|
15
14
|
return;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
|
|
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 =
|
|
28
|
-
anchor.href =
|
|
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
|
|
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
|
|
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
|
|
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
|
+
}
|