vimp-engine 0.5.0 → 0.7.0
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/bin/vimp-sim.js +54 -24
- package/package.json +1 -1
- package/src/devtools/ScenarioRunner.js +24 -4
- package/src/devtools/builtinScenario.js +95 -0
- package/src/devtools/invariants.js +30 -1
- package/src/devtools/pluginLoader.js +28 -5
- package/src/host/meta/core/RoundManager.js +4 -0
- package/src/lib/gamePlugin.js +25 -3
- package/tests/fixtures/miniGame/config/auth.js +1 -1
package/bin/vimp-sim.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { runScenario } from '../src/devtools/ScenarioRunner.js';
|
|
4
|
-
import {
|
|
4
|
+
import { builtinScenario } from '../src/devtools/builtinScenario.js';
|
|
5
|
+
import { loadGameForSim, isFixture } from '../src/devtools/pluginLoader.js';
|
|
5
6
|
import {
|
|
6
7
|
checkDeterminism,
|
|
7
8
|
summarize,
|
|
@@ -12,33 +13,37 @@ import { writeReport, formatMarkdown } from '../src/devtools/report.js';
|
|
|
12
13
|
// CLI headless-прогона: правка → npm run sim → текстовый вердикт, без
|
|
13
14
|
// браузера и без человека.
|
|
14
15
|
|
|
16
|
+
// ошибка разбора флагов печатается без стека — см. обработчик внизу файла
|
|
17
|
+
class UsageError extends Error {}
|
|
18
|
+
|
|
15
19
|
const USAGE = `Usage: vimp-sim [options]
|
|
16
20
|
|
|
17
21
|
--scenario <path> scenario JSON (default: built-in smoke scenario)
|
|
18
22
|
--game <path> game package directory or dist/manifest.json
|
|
19
|
-
--core <path> node build of the game core (overrides entries.wasmNode)
|
|
23
|
+
--core <path> node build of the game core (overrides entries.wasmNode);
|
|
24
|
+
only meaningful with --game — the built-in fixture has
|
|
25
|
+
no WASM core to override
|
|
20
26
|
--out <dir> report root (default: .debug)
|
|
21
27
|
--no-write print the report to stdout instead of writing files
|
|
22
28
|
--determinism run the scenario twice and compare the frame streams
|
|
23
29
|
--help
|
|
24
30
|
`;
|
|
25
31
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
};
|
|
32
|
+
// каждая строка с префиксом: дальше в stderr уходит список падений, и
|
|
33
|
+
// грепалки CI не должны путать предупреждение с ними
|
|
34
|
+
const BUILTIN_NOTICE =
|
|
35
|
+
'notice: the built-in smoke scenario is running against --game. It only\n' +
|
|
36
|
+
'notice: proves the loop closes on this plugin: key coverage (invariant 2)\n' +
|
|
37
|
+
'notice: and prediction drift (invariant 9) are skipped — both need a\n' +
|
|
38
|
+
'notice: scenario written for your game. See § Scenario format in the\n' +
|
|
39
|
+
"notice: engine's docs/en/debugging.md (github.com/lgick/vimp), then run\n" +
|
|
40
|
+
'notice: vimp-sim --scenario <file>.\n\n';
|
|
41
|
+
|
|
42
|
+
// типичная опечатка «--core без --game»: фикстурное ядро — обычный JS и
|
|
43
|
+
// wasmUrl не смотрит, поэтому прогон был бы зелёным, не тронув ядро игры
|
|
44
|
+
const STRAY_CORE_NOTICE =
|
|
45
|
+
'notice: --core without --game does nothing: the run falls back to the\n' +
|
|
46
|
+
"notice: built-in fixture, whose core is plain JS. Add --game <path>.\n\n";
|
|
42
47
|
|
|
43
48
|
async function main(argv) {
|
|
44
49
|
const args = parseArgs(argv);
|
|
@@ -48,13 +53,23 @@ async function main(argv) {
|
|
|
48
53
|
return 0;
|
|
49
54
|
}
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
if (args.core && !args.game) {
|
|
57
|
+
process.stderr.write(STRAY_CORE_NOTICE);
|
|
58
|
+
}
|
|
54
59
|
|
|
55
60
|
// плагин грузится один раз: второй прогон самопроверки детерминизма
|
|
56
|
-
// обязан идти на том же ядре, иначе он проверял бы загрузчик, а не
|
|
61
|
+
// обязан идти на том же ядре, иначе он проверял бы загрузчик, а не мир.
|
|
62
|
+
// Загрузка идёт до сборки сценария: встроенный берёт имена модели, команды
|
|
63
|
+
// и клавиши из gameConfig игры
|
|
57
64
|
const plugin = await loadGameForSim({ game: args.game, core: args.core });
|
|
65
|
+
|
|
66
|
+
if (!args.scenario && !isFixture(plugin)) {
|
|
67
|
+
process.stderr.write(BUILTIN_NOTICE);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const scenario = args.scenario
|
|
71
|
+
? JSON.parse(await readFile(args.scenario, 'utf8'))
|
|
72
|
+
: builtinScenario(plugin);
|
|
58
73
|
// хеши потока кадров собираются только под --determinism: их единственный
|
|
59
74
|
// потребитель — сравнение двух прогонов, а объём линеен по длине матча
|
|
60
75
|
const captureFrames = args.determinism === true;
|
|
@@ -118,11 +133,20 @@ function parseArgs(argv) {
|
|
|
118
133
|
case '--core':
|
|
119
134
|
case '--out':
|
|
120
135
|
i += 1;
|
|
136
|
+
|
|
137
|
+
// «--game» последним аргументом (или перед следующим флагом) тихо
|
|
138
|
+
// уводил на фикстуру: прогон зелёный, игра не тронута. Путь,
|
|
139
|
+
// начинающийся с '--', тем самым запрещён — таких не бывает, а
|
|
140
|
+
// явный отказ лучше ENOENT про каталог '--determinism'
|
|
141
|
+
if (argv[i] === undefined || argv[i].startsWith('--')) {
|
|
142
|
+
throw new UsageError(`option '${arg}' needs a value\n\n${USAGE}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
121
145
|
args[arg.slice(2)] = argv[i];
|
|
122
146
|
break;
|
|
123
147
|
|
|
124
148
|
default:
|
|
125
|
-
throw new
|
|
149
|
+
throw new UsageError(`unknown option '${arg}'\n\n${USAGE}`);
|
|
126
150
|
}
|
|
127
151
|
}
|
|
128
152
|
|
|
@@ -134,7 +158,13 @@ main(process.argv.slice(2)).then(
|
|
|
134
158
|
process.exitCode = code;
|
|
135
159
|
},
|
|
136
160
|
error => {
|
|
137
|
-
|
|
161
|
+
// опечатка в флаге — не краш инструмента: стек тут лишний шум поверх
|
|
162
|
+
// USAGE, который пользователь и должен прочитать
|
|
163
|
+
process.stderr.write(
|
|
164
|
+
error instanceof UsageError
|
|
165
|
+
? `${error.message}\n`
|
|
166
|
+
: `${error.stack ?? error.message}\n`,
|
|
167
|
+
);
|
|
138
168
|
process.exitCode = 1;
|
|
139
169
|
},
|
|
140
170
|
);
|
package/package.json
CHANGED
|
@@ -4,7 +4,11 @@ import RecordingSocketManager from './RecordingSocketManager.js';
|
|
|
4
4
|
import VirtualClient from './VirtualClient.js';
|
|
5
5
|
import { loadGameForSim } from './pluginLoader.js';
|
|
6
6
|
import { resetHostSingletons } from './resetHostSingletons.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
checkInvariants,
|
|
9
|
+
summarize,
|
|
10
|
+
ALL_KEYS_UNAUDITED,
|
|
11
|
+
} from './invariants.js';
|
|
8
12
|
import { inspectCore, inspectHost } from './inspectHost.js';
|
|
9
13
|
|
|
10
14
|
// Прогон сценария целиком в одном Node-процессе: авторитетный хост
|
|
@@ -58,6 +62,21 @@ export function parseScenario(raw) {
|
|
|
58
62
|
const timeline = [...(raw.timeline ?? [])].sort(
|
|
59
63
|
(a, b) => (a.tick ?? 0) - (b.tick ?? 0),
|
|
60
64
|
);
|
|
65
|
+
const unusedSnapshotKeys = raw.unusedSnapshotKeys ?? [];
|
|
66
|
+
|
|
67
|
+
// строка тут легальна ровно одна: без проверки забытые скобки
|
|
68
|
+
// ("unusedSnapshotKeys": "w1") молча разложились бы в набор символов, и
|
|
69
|
+
// объявленный ключ всё равно попал бы в нарушения инварианта 2
|
|
70
|
+
if (
|
|
71
|
+
unusedSnapshotKeys !== ALL_KEYS_UNAUDITED &&
|
|
72
|
+
(!Array.isArray(unusedSnapshotKeys) ||
|
|
73
|
+
unusedSnapshotKeys.some(key => typeof key !== 'string'))
|
|
74
|
+
) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'scenario: unusedSnapshotKeys must be an array of snapshot keys or ' +
|
|
77
|
+
`"${ALL_KEYS_UNAUDITED}"`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
61
80
|
|
|
62
81
|
return {
|
|
63
82
|
version: 1,
|
|
@@ -69,10 +88,11 @@ export function parseScenario(raw) {
|
|
|
69
88
|
timeline,
|
|
70
89
|
// ключи схемы, которых в этом сценарии заведомо не будет (инвариант 2):
|
|
71
90
|
// «сущность не спавнится» отличается от «сценарий её не трогает» только
|
|
72
|
-
// этим
|
|
73
|
-
|
|
91
|
+
// этим объявлением. '*' — сценарий не берётся судить покрытие ключей
|
|
92
|
+
// вовсе (встроенный смоук на чужой игре), и инвариант честно пропускается
|
|
93
|
+
unusedSnapshotKeys,
|
|
74
94
|
// пороги детектора рассинхрона предикта (инвариант 9); {} — дефолты
|
|
75
|
-
// ядра, null — детектор
|
|
95
|
+
// ядра, null — детектор выключен, и инвариант тоже пропускается
|
|
76
96
|
divergence: raw.divergence === null ? null : (raw.divergence ?? {}),
|
|
77
97
|
ticks: raw.ticks ?? 600,
|
|
78
98
|
dumpTicks: raw.dumpTicks ?? null,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { ALL_KEYS_UNAUDITED } from './invariants.js';
|
|
2
|
+
import { isFixture } from './pluginLoader.js';
|
|
3
|
+
|
|
4
|
+
// Встроенный сценарий headless-прогона: один участник заходит, едет вперёд,
|
|
5
|
+
// отпускает клавишу. Этого хватает, чтобы контур доказал, что он замкнут
|
|
6
|
+
// (хост → кадр → клиентское ядро → сцена), и не хватает ни на что больше.
|
|
7
|
+
//
|
|
8
|
+
// Идентификаторы берутся из gameConfig самой игры: имя модели, команды и
|
|
9
|
+
// клавиши — часть игры, а не движка. Захардкоженные фикстурные 'm1' /
|
|
10
|
+
// 'team1' / 'forward' на чужом плагине означают падение в ядре («unknown
|
|
11
|
+
// model») либо красный инвариант 8 у исправного плагина.
|
|
12
|
+
|
|
13
|
+
// Ввод начинается на 40-м тике, а не сразу: кадр спавна с force_reset
|
|
14
|
+
// приходит примерно на interpolation.delay (движковый дефолт — 100 мс, тут
|
|
15
|
+
// ~3× запаса) позже входа и чистит удержанные клавиши предиктора
|
|
16
|
+
// (docs/en/debugging.md) — встроенный сценарий не должен демонстрировать
|
|
17
|
+
// ровно ту ловушку, от которой предостерегает документация
|
|
18
|
+
const INPUT_DOWN_TICK = 40;
|
|
19
|
+
const INPUT_UP_TICK = 100;
|
|
20
|
+
const TICKS = 120;
|
|
21
|
+
|
|
22
|
+
// событийный ключ фикстуры в этом сценарии не стреляет — объявлено явно,
|
|
23
|
+
// иначе инвариант 2 честно посчитает это «сущность не спавнится»
|
|
24
|
+
const FIXTURE_UNUSED_KEYS = ['e1'];
|
|
25
|
+
|
|
26
|
+
const firstKey = dict => Object.keys(dict ?? {})[0];
|
|
27
|
+
|
|
28
|
+
// Клавиша для смоука обязана быть удерживаемой: по конвенции playerKeys
|
|
29
|
+
// (docs/ai/04-client-plugin.md) `type: 1` — триггер, у которого 'up'
|
|
30
|
+
// игнорируется, и «нажал — отпустил» не даёт ни удержания, ни движения.
|
|
31
|
+
// Порядок объявления в playerKeys контрактом не задан, поэтому берём первую
|
|
32
|
+
// без type; если игра объявила одни триггеры — первую любую, смоук всё равно
|
|
33
|
+
// должен запуститься. Движок сам type не интерпретирует (это дело ядра
|
|
34
|
+
// игры) — здесь это эвристика выбора, а не поведение движка.
|
|
35
|
+
const heldKey = playerKeys => {
|
|
36
|
+
const keys = Object.entries(playerKeys ?? {});
|
|
37
|
+
|
|
38
|
+
return (keys.find(([, spec]) => !spec?.type) ?? keys[0])?.[0];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Собирает встроенный смоук-сценарий под конкретную игру.
|
|
43
|
+
* @param {Object} plugin - Результат loadGameForSim.
|
|
44
|
+
* @returns {Object} Сценарий в формате runScenario.
|
|
45
|
+
*/
|
|
46
|
+
export function builtinScenario(plugin) {
|
|
47
|
+
const config = plugin.hostPlugin.gameConfig;
|
|
48
|
+
const model = firstKey(config.parts?.models);
|
|
49
|
+
const key = heldKey(config.playerKeys);
|
|
50
|
+
const team = Object.keys(config.teams ?? {}).find(
|
|
51
|
+
name => name !== config.spectatorTeam,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (!model || !key || !team) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`game "${plugin.id}": the built-in scenario has nothing to drive ` +
|
|
57
|
+
`(model: ${model ?? '—'}, playable team: ${team ?? '—'}, ` +
|
|
58
|
+
`player key: ${key ?? '—'}) — write a scenario for this game and ` +
|
|
59
|
+
`pass --scenario <file>`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const scenario = {
|
|
64
|
+
version: 1,
|
|
65
|
+
seed: 3812,
|
|
66
|
+
participants: [{ id: 'p1', name: 'P1', model }],
|
|
67
|
+
timeline: [
|
|
68
|
+
{ tick: 0, op: 'join', who: 'p1', team },
|
|
69
|
+
{
|
|
70
|
+
tick: INPUT_DOWN_TICK,
|
|
71
|
+
op: 'key',
|
|
72
|
+
who: 'p1',
|
|
73
|
+
action: 'down',
|
|
74
|
+
name: key,
|
|
75
|
+
},
|
|
76
|
+
{ tick: INPUT_UP_TICK, op: 'key', who: 'p1', action: 'up', name: key },
|
|
77
|
+
],
|
|
78
|
+
ticks: TICKS,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (isFixture(plugin)) {
|
|
82
|
+
return { ...scenario, unusedSnapshotKeys: FIXTURE_UNUSED_KEYS };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// На чужой игре это смоук контура, а не аудит контракта: сценарий не знает
|
|
86
|
+
// ни ключей её схемы, ни её порогов дрейфа (у каждой игры своя раскладка
|
|
87
|
+
// player-блока и свои единицы). Судить исправный плагин по фикстурным
|
|
88
|
+
// значениям значит выдавать ему красный вердикт, поэтому проверки 2 и 9
|
|
89
|
+
// честно пропускаются, а не притворяются пройденными.
|
|
90
|
+
return {
|
|
91
|
+
...scenario,
|
|
92
|
+
unusedSnapshotKeys: ALL_KEYS_UNAUDITED,
|
|
93
|
+
divergence: null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -15,6 +15,10 @@ export const PASS = 'pass';
|
|
|
15
15
|
export const FAIL = 'fail';
|
|
16
16
|
export const SKIP = 'skip';
|
|
17
17
|
|
|
18
|
+
// scenario.unusedSnapshotKeys: вместо списка ключей — «этот сценарий вообще
|
|
19
|
+
// не берётся судить покрытие схемы» (встроенный смоук на чужой игре)
|
|
20
|
+
export const ALL_KEYS_UNAUDITED = '*';
|
|
21
|
+
|
|
18
22
|
// список проверок в порядке plan/done/ai-debug/stage_3.md
|
|
19
23
|
const CHECKS = [
|
|
20
24
|
[1, 'finiteValues', 'no NaN/Infinity in decoded fields and hot buffer'],
|
|
@@ -137,6 +141,19 @@ function finiteValues({ clients }) {
|
|
|
137
141
|
|
|
138
142
|
// 2
|
|
139
143
|
function snapshotKeysUsed({ game, clients, scenario }) {
|
|
144
|
+
// сценарий, не знающий схемы этой игры (встроенный смоук на чужом плагине),
|
|
145
|
+
// не должен объявлять её ключи «не спавнящимися» — это красный вердикт
|
|
146
|
+
// исправной игре
|
|
147
|
+
if (scenario.unusedSnapshotKeys === ALL_KEYS_UNAUDITED) {
|
|
148
|
+
return result(
|
|
149
|
+
'snapshotKeysUsed',
|
|
150
|
+
SKIP,
|
|
151
|
+
[],
|
|
152
|
+
`the scenario declares unusedSnapshotKeys: "${ALL_KEYS_UNAUDITED}" — ` +
|
|
153
|
+
'key coverage is not audited',
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
140
157
|
const declared = Object.keys(game.snapshot);
|
|
141
158
|
const unused = new Set(scenario.unusedSnapshotKeys);
|
|
142
159
|
const seen = new Set();
|
|
@@ -397,7 +414,19 @@ function keyBindings({ game, clientConfig, scenario }) {
|
|
|
397
414
|
// player-блоком кадра. Сопоставление идёт по времени кадра, а не по seq —
|
|
398
415
|
// предиктор переигрывает историю ввода от момента авторитетного состояния,
|
|
399
416
|
// и «тот же seq» на клиенте и на хосте означает разные моменты.
|
|
400
|
-
function predictionDrift({ clients }) {
|
|
417
|
+
function predictionDrift({ clients, scenario }) {
|
|
418
|
+
// детектор выключен самим сценарием — молчит не ядро, и автор плагина не
|
|
419
|
+
// должен идти искать несуществующую проблему в take_divergence
|
|
420
|
+
if (scenario.divergence === null) {
|
|
421
|
+
return result(
|
|
422
|
+
'predictionDrift',
|
|
423
|
+
SKIP,
|
|
424
|
+
[],
|
|
425
|
+
'the scenario disables the drift detector (divergence: null) — ' +
|
|
426
|
+
'thresholds are per-game, write a scenario for yours',
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
401
430
|
const violations = [];
|
|
402
431
|
const tracked = clients.filter(client => client.divergenceStats);
|
|
403
432
|
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { access, readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
assertEngineApiCompatible,
|
|
6
|
+
assertGameConfigShape,
|
|
7
|
+
} from '../lib/gamePlugin.js';
|
|
5
8
|
|
|
6
9
|
// Поиск игры для headless-прогона. В браузере плагин грузится по URL из
|
|
7
10
|
// GameManifest мастера; в Node URL-ов нет, поэтому источников три, по
|
|
@@ -21,6 +24,16 @@ const FIXTURE_DIR = new URL(
|
|
|
21
24
|
import.meta.url,
|
|
22
25
|
);
|
|
23
26
|
|
|
27
|
+
// значение поля source у фикстуры: по нему отличается «своя игра, схему
|
|
28
|
+
// которой инструмент знает» от чужого плагина (см. builtinScenario.js)
|
|
29
|
+
export const FIXTURE_SOURCE = 'fixture:miniGame';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {Object} plugin - Результат loadGameForSim.
|
|
33
|
+
* @returns {boolean} Прогон идёт на встроенной фикстуре, а не на чужой игре.
|
|
34
|
+
*/
|
|
35
|
+
export const isFixture = plugin => plugin.source === FIXTURE_SOURCE;
|
|
36
|
+
|
|
24
37
|
/**
|
|
25
38
|
* @param {Object} [options]
|
|
26
39
|
* @param {string} [options.game] - Путь к пакету игры или к манифесту.
|
|
@@ -30,10 +43,20 @@ const FIXTURE_DIR = new URL(
|
|
|
30
43
|
* source }.
|
|
31
44
|
*/
|
|
32
45
|
export async function loadGameForSim({ game = null, core = null } = {}) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
const plugin = game
|
|
47
|
+
? await loadFromManifest(game, core)
|
|
48
|
+
: await loadFixture(core);
|
|
49
|
+
|
|
50
|
+
// контракт gameConfig проверяется здесь, а не только в createHostRuntime:
|
|
51
|
+
// встроенный сценарий собирается из gameConfig раньше, чем стартует прогон
|
|
52
|
+
// (builtinScenario.js), и плагин без конфига иначе отвечал бы сырым
|
|
53
|
+
// TypeError вместо перечисления недостающих полей
|
|
54
|
+
assertGameConfigShape(plugin.hostPlugin);
|
|
55
|
+
|
|
56
|
+
return plugin;
|
|
57
|
+
}
|
|
36
58
|
|
|
59
|
+
async function loadFromManifest(game, core) {
|
|
37
60
|
const manifestPath = game.endsWith('.json')
|
|
38
61
|
? path.resolve(game)
|
|
39
62
|
: path.resolve(game, 'dist/manifest.json');
|
|
@@ -107,7 +130,7 @@ async function loadFixture(core) {
|
|
|
107
130
|
hostPlugin,
|
|
108
131
|
clientPlugin,
|
|
109
132
|
wasmUrl: core ? pathToFileURL(path.resolve(core)).href : undefined,
|
|
110
|
-
source:
|
|
133
|
+
source: FIXTURE_SOURCE,
|
|
111
134
|
};
|
|
112
135
|
}
|
|
113
136
|
|
|
@@ -141,6 +141,10 @@ class RoundManager {
|
|
|
141
141
|
for (const user of this._participants.getHumans()) {
|
|
142
142
|
const gameId = user.gameId;
|
|
143
143
|
|
|
144
|
+
// keyset наблюдателя уходит до очистки полотна: он выключает
|
|
145
|
+
// клиентский предикт, иначе тот успевает пересоздать сущность уже
|
|
146
|
+
// после CLEAR и она остаётся призраком до конца сессии
|
|
147
|
+
this._socketManager.sendSpectatorDefaultShot(user.socketId);
|
|
144
148
|
this._socketManager.sendClear(user.socketId);
|
|
145
149
|
|
|
146
150
|
// перемещение пользователя в наблюдатели
|
package/src/lib/gamePlugin.js
CHANGED
|
@@ -51,6 +51,10 @@ const REQUIRED_GAME_CONFIG_PATHS = [
|
|
|
51
51
|
'parts.friendlyFire',
|
|
52
52
|
'panel.fields',
|
|
53
53
|
'playerKeys',
|
|
54
|
+
// без них HostGame разыменовывает undefined (this._teams[spectatorTeam])
|
|
55
|
+
// и игра умирает тремя разными сообщениями вместо одного контрактного
|
|
56
|
+
'teams',
|
|
57
|
+
'spectatorTeam',
|
|
54
58
|
];
|
|
55
59
|
|
|
56
60
|
function getPath(obj, dottedPath) {
|
|
@@ -61,9 +65,14 @@ function getPath(obj, dottedPath) {
|
|
|
61
65
|
|
|
62
66
|
// бросает при отсутствии обязательных полей HostPlugin.gameConfig
|
|
63
67
|
export function assertGameConfigShape(hostPlugin) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
// null проходил бы проверку присутствия, хотя ни одно из этих полей не
|
|
69
|
+
// бывает пустым по контракту: движок разыменовывает их сразу, и гейт,
|
|
70
|
+
// заведённый ради текста вместо TypeError, сам отвечал бы TypeError
|
|
71
|
+
const missing = REQUIRED_GAME_CONFIG_PATHS.filter(p => {
|
|
72
|
+
const value = getPath(hostPlugin.gameConfig, p);
|
|
73
|
+
|
|
74
|
+
return value === undefined || value === null;
|
|
75
|
+
});
|
|
67
76
|
|
|
68
77
|
if (missing.length > 0) {
|
|
69
78
|
throw new Error(
|
|
@@ -71,6 +80,19 @@ export function assertGameConfigShape(hostPlugin) {
|
|
|
71
80
|
missing.join(', '),
|
|
72
81
|
);
|
|
73
82
|
}
|
|
83
|
+
|
|
84
|
+
// единственная связь между полями, которую стоит проверять здесь:
|
|
85
|
+
// spectatorTeam — имя ключа внутри teams, и опечатка даёт spectatorId ===
|
|
86
|
+
// undefined, после чего участник заходит в несуществующую команду
|
|
87
|
+
// (ParticipantManager.createHuman валится на её счётчике)
|
|
88
|
+
const { teams, spectatorTeam } = hostPlugin.gameConfig;
|
|
89
|
+
|
|
90
|
+
if (teams[spectatorTeam] === undefined) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`game "${hostPlugin.id}": spectatorTeam '${spectatorTeam}' is not a ` +
|
|
93
|
+
`key of teams (${Object.keys(teams).join(', ')})`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
74
96
|
}
|
|
75
97
|
|
|
76
98
|
// динамический import ClientPlugin игры (client-entry её сборки). Манифест и
|