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.
- package/core/Cargo.toml +1 -1
- package/package.json +3 -1
- 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/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/retryOnNetworkError.js +60 -0
- package/src/client/main.js +35 -18
- 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/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 +4 -4
- package/src/client/views/includes/reports.pug +1 -1
- package/src/config/authClient.js +3 -3
- package/src/config/lobby.js +4 -5
- package/src/config/opcodes.js +2 -1
- package/src/devtools/contract/loadContext.js +2 -2
- package/src/devtools/contract/rules/a7-package-repository.js +7 -8
- package/src/devtools/contract/rules/index.js +1 -1
- package/src/devtools/invariants.js +1 -1
- package/src/host/DebugRecorder.js +1 -1
- package/src/host/HostGame.js +35 -15
- 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/packageLink.js +18 -61
- package/src/lib/validators.js +3 -3
- package/src/standalone/index.js +1 -1
- package/tests/fixtures/generations/gen-api3/README.md +2 -2
- package/tests/fixtures/generations/gen-api4/README.md +2 -2
- package/src/devtools/surface/abiParse.js +0 -175
- 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
|
+
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.
|
|
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",
|
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
|
}
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
package/src/client/main.js
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
bindLiveErrors,
|
|
33
33
|
} from './lib/formBuilder.js';
|
|
34
34
|
import { normalizeAuthParams } from './lib/authParams.js';
|
|
35
|
-
import {
|
|
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)
|
|
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
|
-
// журнал клиентских
|
|
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 подставляет константу): включает отладочный контур
|
|
352
|
-
//
|
|
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
|
-
// журнал клиентских ошибок для партов
|
|
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
|
-
// футер формы входа: версия
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
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
|
-
|
|
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
|
-
// иначе его события в браузере не видны
|
|
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
|
-
// журнал клиентских
|
|
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
|
-
//
|
|
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({
|
|
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
|
}
|