vimp-engine 0.4.0 → 0.6.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 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 { loadGameForSim } from '../src/devtools/pluginLoader.js';
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 DEFAULT_SCENARIO = {
29
- version: 1,
30
- seed: 3812,
31
- participants: [{ id: 'p1', name: 'P1', model: 'm1' }],
32
- timeline: [
33
- { tick: 0, op: 'join', who: 'p1', team: 'team1' },
34
- { tick: 10, op: 'key', who: 'p1', action: 'down', name: 'forward' },
35
- { tick: 60, op: 'key', who: 'p1', action: 'up', name: 'forward' },
36
- ],
37
- // событийный ключ фикстуры в этом сценарии не стреляет объявлено явно,
38
- // иначе инвариант 2 честно посчитает это «сущность не спавнится»
39
- unusedSnapshotKeys: ['e1'],
40
- ticks: 120,
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,17 +53,30 @@ async function main(argv) {
48
53
  return 0;
49
54
  }
50
55
 
51
- const scenario = args.scenario
52
- ? JSON.parse(await readFile(args.scenario, 'utf8'))
53
- : DEFAULT_SCENARIO;
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 });
58
- const report = await runScenario(scenario, { plugin });
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);
73
+ // хеши потока кадров собираются только под --determinism: их единственный
74
+ // потребитель — сравнение двух прогонов, а объём линеен по длине матча
75
+ const captureFrames = args.determinism === true;
76
+ const report = await runScenario(scenario, { plugin, captureFrames });
59
77
 
60
78
  if (args.determinism) {
61
- const second = await runScenario(scenario, { plugin });
79
+ const second = await runScenario(scenario, { plugin, captureFrames });
62
80
  const check = checkDeterminism(report, second);
63
81
 
64
82
  report.invariants = report.invariants.map(item =>
@@ -115,11 +133,20 @@ function parseArgs(argv) {
115
133
  case '--core':
116
134
  case '--out':
117
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
+
118
145
  args[arg.slice(2)] = argv[i];
119
146
  break;
120
147
 
121
148
  default:
122
- throw new Error(`unknown option '${arg}'\n\n${USAGE}`);
149
+ throw new UsageError(`unknown option '${arg}'\n\n${USAGE}`);
123
150
  }
124
151
  }
125
152
 
@@ -131,7 +158,13 @@ main(process.argv.slice(2)).then(
131
158
  process.exitCode = code;
132
159
  },
133
160
  error => {
134
- process.stderr.write(`${error.stack ?? error.message}\n`);
161
+ // опечатка в флаге — не краш инструмента: стек тут лишний шум поверх
162
+ // USAGE, который пользователь и должен прочитать
163
+ process.stderr.write(
164
+ error instanceof UsageError
165
+ ? `${error.message}\n`
166
+ : `${error.stack ?? error.message}\n`,
167
+ );
135
168
  process.exitCode = 1;
136
169
  },
137
170
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -57,7 +57,7 @@ export default {
57
57
  maxDelay: 30000,
58
58
  },
59
59
 
60
- // приёмник выгрузок отладочного контура (этап 6 плана plan/ai-debug):
60
+ // приёмник выгрузок отладочного контура (этап 6 плана plan/done/ai-debug):
61
61
  // маршрут поднимается мастером только в dev, в проде вернёт 404
62
62
  debugReportUrl: '/debug/report',
63
63
 
@@ -1,99 +1,62 @@
1
- // Транспорт хоста, который вместо сети пишет исходящие кадры в память.
2
- // Обобщение FakeSocketManager из tests/host/fixtureHarness.js: одну и ту же
3
- // подмену используют и движковые тесты меты, и headless-runner — иначе
4
- // пришлось бы держать две копии перечня отправителей SocketManager.
5
-
6
- // Все отправители SocketManager, которые дёргает host-фасад.
7
- export const SENDER_METHODS = [
8
- 'sendConfig',
9
- 'sendAuthData',
10
- 'sendAuthResult',
11
- 'sendPing',
12
- 'sendClear',
13
- 'sendTechInform',
14
- 'sendMap',
15
- 'sendFirstShot',
16
- 'sendFirstVote',
17
- 'sendShot',
18
- 'sendPanel',
19
- 'sendStat',
20
- 'sendChat',
21
- 'sendVote',
22
- 'sendKeySet',
23
- 'sendPlayerDefaultShot',
24
- 'sendSpectatorDefaultShot',
25
- 'sendConsole',
26
- 'sendGameInform',
27
- 'sendRoundEnd',
28
- 'sendSoundCue',
29
- 'sendName',
30
- ];
31
-
32
- class RecordingSocketManager {
1
+ import wsports from '../config/wsports.js';
2
+ import SocketManager from '../host/meta/SocketManager.js';
3
+
4
+ // Транспорт хоста, который вместо сети пишет исходящие вызовы в память.
5
+ // Наследует боевой SocketManager, а не копирует его отправителей: составные
6
+ // отправители (sendFirstShot и компания), нагрузка портов и их номера
7
+ // остаются одни на прод и на headless — ручная копия уже расходилась с
8
+ // оригиналом и стоила прогону keySet, панели и первого снапшота мира.
9
+ //
10
+ // Записывается сам вызов (метод + аргументы, как их видит хост), а нагрузка
11
+ // транспорта — тем, что настоящий код успел передать в _send/_sendBinary:
12
+ // проверки инвариантов рассуждают о семантике («roundEnd объявил победителя»),
13
+ // а VirtualClient — о том, что реально уехало бы в data channel.
14
+
15
+ // имена отправителей берутся из прототипа: ручной список неизбежно отстаёт
16
+ // от боевого класса, что уже случалось
17
+ const SENDER_METHODS = Object.getOwnPropertyNames(SocketManager.prototype)
18
+ .filter(name => name.startsWith('send'));
19
+
20
+ class RecordingSocketManager extends SocketManager {
33
21
  /**
22
+ * @param {Object} [ports] - Карта портов host→client; по умолчанию боевая
23
+ * (движковые тесты меты создают транспорт без аргументов).
24
+ * @param {Object} [gameOpts] - Игровая параметризация ({ soundCues,
25
+ * initialVote }) — та же, что получает боевой SocketManager.
34
26
  * @param {Object} [options]
35
27
  * @param {Function} [options.onFrame] - Вызывается на каждый исходящий кадр
36
- * ({ method, socketId, args }) — runner раздаёт их VirtualClient'ам, не
37
- * дожидаясь конца прогона.
28
+ * ({ method, socketId, args, tick, sent }) — runner раздаёт их
29
+ * VirtualClient'ам, не дожидаясь конца прогона.
38
30
  */
39
- constructor({ onFrame = null } = {}) {
40
- this.frames = []; // [{ method, socketId, args, tick }]
31
+ constructor(ports = wsports.server, gameOpts = {}, { onFrame = null } = {}) {
32
+ super(ports, gameOpts);
33
+
34
+ this.frames = []; // [{ method, socketId, args, tick, sent }]
41
35
 
42
36
  // номер тика прогона: runner двигает его перед каждым шагом, кадры
43
37
  // получают метку — без неё проверки жизненного цикла раунда не могут
44
38
  // сказать «кому именно не доехал roundEnd на этом тике»
45
39
  this.tick = 0;
46
40
  this._onFrame = onFrame;
47
- this._game = null;
48
- this._panel = null;
49
- this._stat = null;
41
+
42
+ // кадр, чьё тело сейчас исполняется: в него уходит нагрузка _send;
43
+ // составные отправители вкладываются друг в друга, поэтому это стек
44
+ // через локальную переменную в _record
45
+ this._current = null;
50
46
 
51
47
  for (const method of SENDER_METHODS) {
52
48
  this[method] = (socketId, ...args) => {
53
- this._record({ method, socketId, args });
49
+ this._record(method, socketId, args, () =>
50
+ SocketManager.prototype[method].call(this, socketId, ...args),
51
+ );
54
52
  };
55
53
  }
56
-
57
- // Составные отправители боевого SocketManager сами дёргают панель,
58
- // статистику и keySet. Плоская запись их не раскрывает — тогда клиент
59
- // headless-прогона живёт с пустой панелью и без keySet, то есть предикт
60
- // у него не включается никогда. Развёртка обязана повторять
61
- // host/meta/SocketManager.js.
62
- this.sendFirstShot = socketId => {
63
- this._record({ method: 'sendFirstShot', socketId, args: [] });
64
- this.sendStat(socketId, this._stat?.getFull());
65
- this.sendPanel(socketId, this._panel?.getEmptyPanel());
66
- this.sendKeySet(socketId, 0); // наблюдатель
67
- };
68
-
69
- this.sendPlayerDefaultShot = (socketId, gameId) => {
70
- this._record({
71
- method: 'sendPlayerDefaultShot',
72
- socketId,
73
- args: [gameId],
74
- });
75
- this.sendPanel(socketId, this._panel?.getFullPanel(gameId));
76
- this.sendKeySet(socketId, 1); // играющий
77
- };
78
-
79
- this.sendSpectatorDefaultShot = socketId => {
80
- this._record({ method: 'sendSpectatorDefaultShot', socketId, args: [] });
81
- this.sendPanel(socketId, this._panel?.getEmptyPanel());
82
- this.sendKeySet(socketId, 0);
83
- };
84
- }
85
-
86
- injectServices(game, panel, stat) {
87
- this._game = game;
88
- this._panel = panel;
89
- this._stat = stat;
90
54
  }
91
55
 
92
- addUser() {}
93
- removeUser() {}
94
-
95
56
  close(socketId, code, key, arr) {
96
- this._record({ method: 'close', socketId, args: [code, key, arr] });
57
+ this._record('close', socketId, [code, key, arr], () =>
58
+ super.close(socketId, code, key, arr),
59
+ );
97
60
  }
98
61
 
99
62
  // все кадры указанного метода
@@ -101,14 +64,76 @@ class RecordingSocketManager {
101
64
  return this.frames.filter(frame => frame.method === method);
102
65
  }
103
66
 
104
- clear() {
67
+ clearFrames() {
105
68
  this.frames.length = 0;
106
69
  }
107
70
 
108
- _record(frame) {
109
- frame.tick = this.tick;
71
+ // сети нет: всё, что боевой код отдал бы сокету, оседает в текущем кадре
72
+ _send(socketId, port, data, reliable = true) {
73
+ this._payload({ port, data, reliable });
74
+ }
75
+
76
+ _sendBinary(socketId, buffer, reliable) {
77
+ this._payload({ port: this._PORT_SHOT_DATA, data: buffer, reliable });
78
+ }
79
+
80
+ _close(socketId, code, data) {
81
+ this._payload({ port: null, data: { code, data } });
82
+ }
83
+
84
+ // Кадр публикуется в момент первой отправки — это и есть порядок провода:
85
+ // составной отправитель отдаёт свою нагрузку раньше вложенных вызовов
86
+ // (FIRST_SHOT_DATA → STAT → PANEL → KEYSET), и клиент прогона видит ту же
87
+ // последовательность, что и браузер. Публикация после invoke() отдавала бы
88
+ // родителя последним.
89
+ //
90
+ // Отсюда несущее допущение: у отправителя не больше ОДНОЙ собственной
91
+ // нагрузки (составные раскладываются во вложенные вызовы, а два _send в
92
+ // sendClear/sendTechInform — это ветки if/else). Нарушь его — и подписчик
93
+ // получит кадр, собранный наполовину, уже после решения о маршрутизации.
94
+ // Поэтому не молчим, а называем контракт.
95
+ _payload(entry) {
96
+ const frame = this._current;
97
+
98
+ if (!frame) {
99
+ return;
100
+ }
101
+
102
+ if (frame.sent.length) {
103
+ throw new Error(
104
+ `RecordingSocketManager: '${frame.method}' sent a second payload ` +
105
+ `(port ${entry.port}) — a sender may hand the socket at most one, ` +
106
+ 'otherwise the subscriber already routed a half-built frame; ' +
107
+ 'split it into nested senders like sendFirstShot does',
108
+ );
109
+ }
110
+
111
+ frame.sent.push(entry);
112
+ this._emit(frame);
113
+ }
114
+
115
+ _record(method, socketId, args, invoke) {
116
+ const frame = { method, socketId, args, tick: this.tick, sent: [] };
117
+ const parent = this._current;
118
+
110
119
  this.frames.push(frame);
120
+ this._current = frame;
121
+
122
+ try {
123
+ invoke();
124
+ } finally {
125
+ this._current = parent;
126
+ }
127
+
128
+ // отправитель без собственной нагрузки (составной вроде
129
+ // sendPlayerDefaultShot либо отфильтрованный прод-кодом незамапленный
130
+ // soundCue) всё равно доезжает до подписчика — но ровно один раз
131
+ if (!frame.sent.length) {
132
+ this._emit(frame);
133
+ }
134
+ }
111
135
 
136
+ _emit(frame) {
112
137
  if (this._onFrame) {
113
138
  this._onFrame(frame);
114
139
  }
@@ -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 { checkInvariants, summarize } from './invariants.js';
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
- unusedSnapshotKeys: raw.unusedSnapshotKeys ?? [],
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,
@@ -89,6 +109,9 @@ export function parseScenario(raw) {
89
109
  * детерминизма).
90
110
  * @param {string} [options.gamePath] - Путь к пакету игры/манифесту.
91
111
  * @param {string} [options.corePath] - Путь к node-сборке ядра.
112
+ * @param {boolean} [options.captureFrames] - Считать хеши потока кадров
113
+ * (нужны только самопроверке детерминизма — на длинном матче это лишние
114
+ * мегабайты в отчёте).
92
115
  * @returns {Promise<Object>} Отчёт прогона.
93
116
  */
94
117
  export async function runScenario(rawScenario, options = {}) {
@@ -105,13 +128,15 @@ export async function runScenario(rawScenario, options = {}) {
105
128
  const restoreClock = virtualClock.install();
106
129
 
107
130
  try {
108
- return await execute(scenario, plugin, virtualClock);
131
+ return await execute(scenario, plugin, virtualClock, {
132
+ captureFrames: options.captureFrames === true,
133
+ });
109
134
  } finally {
110
135
  restoreClock();
111
136
  }
112
137
  }
113
138
 
114
- async function execute(scenario, plugin, virtualClock) {
139
+ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
115
140
  const clients = new Map(); // socketId → VirtualClient
116
141
  const byParticipant = new Map(); // id сценария → { socketId, gameId }
117
142
  const participantLog = []; // [{ who, socketId, gameId, joinTick, leaveTick }]
@@ -120,13 +145,11 @@ async function execute(scenario, plugin, virtualClock) {
120
145
 
121
146
  let currentTick = 0;
122
147
 
123
- const socketManager = new RecordingSocketManager({
124
- onFrame: frame => routeFrame(frame, clients, virtualClock, host),
125
- });
126
-
127
148
  // ping/pong: без ответа хост честно кикает участника по таймауту RTT, и
128
- // длинный сценарий тихо теряет игроков посреди прогона
149
+ // длинный сценарий тихо теряет игроков посреди прогона. Объявлено до
150
+ // транспорта — замыкание onFrame читает эту переменную.
129
151
  let host = null;
152
+ let socketManager = null;
130
153
 
131
154
  const room = {
132
155
  ...scenario.room,
@@ -143,7 +166,15 @@ async function execute(scenario, plugin, virtualClock) {
143
166
 
144
167
  const runtime = await createHostRuntime(room, {
145
168
  loadHostPlugin: () => plugin.hostPlugin,
146
- createSocketManager: () => socketManager,
169
+ // транспорт собирается той же фабрикой, что и боевой: наследник обязан
170
+ // получить те же порты и ту же игровую параметризацию
171
+ createSocketManager: (ports, gameOpts) => {
172
+ socketManager = new RecordingSocketManager(ports, gameOpts, {
173
+ onFrame: frame => routeFrame(frame, clients, virtualClock, host),
174
+ });
175
+
176
+ return socketManager;
177
+ },
147
178
  overrideGameConfig: game => mergeConfig(game, scenario.config),
148
179
  hostOptions: {
149
180
  onMapChange: mapName =>
@@ -229,6 +260,7 @@ async function execute(scenario, plugin, virtualClock) {
229
260
  mapChanges,
230
261
  byParticipant,
231
262
  participantLog,
263
+ captureFrames,
232
264
  });
233
265
  }
234
266
 
@@ -253,6 +285,21 @@ function routeFrame(frame, clients, virtualClock, host) {
253
285
  return;
254
286
  }
255
287
 
288
+ // первый снапшот мира: в браузере он применяется сразу (applyShot), а не
289
+ // едет через буфер интерполяции — сущность, доехавшая только им, иначе не
290
+ // попадёт ни в сцену, ни в проверки
291
+ if (frame.method === 'sendFirstShot') {
292
+ client.applyFirstShot(frame.sent[0]?.data);
293
+ return;
294
+ }
295
+
296
+ // CLEAR приходит на каждый рестарт раунда и на смену карты; без него
297
+ // headless живёт через границу раунда в состоянии, которого в игре нет
298
+ if (frame.method === 'sendClear') {
299
+ client.clear(frame.args[0]);
300
+ return;
301
+ }
302
+
256
303
  if (frame.method === 'sendMap') {
257
304
  client.setMap(frame.args[0]);
258
305
  return;
@@ -286,6 +333,10 @@ async function applyOp(op, ctx) {
286
333
 
287
334
  if (entry) {
288
335
  host.removeUser(entry.gameId);
336
+
337
+ // ядро клиента живёт в WASM-памяти: без free многочасовой реплей с
338
+ // текучкой участников растит процесс
339
+ clients.get(entry.socketId)?.destroy();
289
340
  clients.delete(entry.socketId);
290
341
  byParticipant.delete(op.who);
291
342
 
@@ -409,13 +460,16 @@ function requireEntry(byParticipant, who) {
409
460
  return entry;
410
461
  }
411
462
 
412
- // точечное переопределение конфига игры сценарием: config.timers.* и
413
- // config.<ключ верхнего уровня>
463
+ // точечное переопределение конфига игры сценарием. Таймеры — только через
464
+ // config.timers: неявный роутинг одноимённого ключа верхнего уровня в
465
+ // game.timers молча правил не то, что просил сценарий.
414
466
  function mergeConfig(game, overrides) {
415
- for (const [key, value] of Object.entries(overrides)) {
416
- if (key in game.timers) {
417
- game.timers[key] = value;
418
- } else if (value && typeof value === 'object' && !Array.isArray(value)) {
467
+ const { timers = {}, ...rest } = overrides;
468
+
469
+ Object.assign(game.timers, timers);
470
+
471
+ for (const [key, value] of Object.entries(rest)) {
472
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
419
473
  game[key] = { ...game[key], ...value };
420
474
  } else {
421
475
  game[key] = value;
@@ -439,6 +493,7 @@ function buildReport(ctx) {
439
493
  mapChanges,
440
494
  byParticipant,
441
495
  participantLog,
496
+ captureFrames,
442
497
  } = ctx;
443
498
 
444
499
  const frameCounts = {};
@@ -511,11 +566,14 @@ function buildReport(ctx) {
511
566
  ),
512
567
  })),
513
568
  scenes,
514
- // сырой поток кадров нужен самопроверке детерминизма (этап 3)
515
- // сравнивается побайтово
516
- shotBytes: socketManager
517
- .framesOf('sendShot')
518
- .map(frame => Buffer.from(toBytes(frame.args[0])).toString('base64')),
569
+ // поток кадров нужен ровно одному потребителю самопроверке
570
+ // детерминизма (этап 3). Хеша на кадр для сравнения потоков достаточно,
571
+ // а сами байты линейно от длины матча раздували report.json
572
+ shotHashes: captureFrames
573
+ ? socketManager
574
+ .framesOf('sendShot')
575
+ .map(frame => hash32(toBytes(frame.args[0])))
576
+ : null,
519
577
  snapshotSchema: game.snapshot,
520
578
  };
521
579
  }
@@ -527,3 +585,16 @@ function toBytes(value) {
527
585
 
528
586
  return new Uint8Array(value);
529
587
  }
588
+
589
+ // FNV-1a: сравниваются потоки одного и того же прогона, криптостойкость не
590
+ // нужна — нужна дешевизна и отсутствие зависимостей
591
+ function hash32(bytes) {
592
+ let hash = 0x811c9dc5;
593
+
594
+ for (let i = 0; i < bytes.length; i += 1) {
595
+ hash ^= bytes[i];
596
+ hash = Math.imul(hash, 0x01000193);
597
+ }
598
+
599
+ return hash >>> 0;
600
+ }
@@ -141,6 +141,48 @@ class VirtualClient {
141
141
  }
142
142
  }
143
143
 
144
+ /**
145
+ * FIRST_SHOT_DATA: первый снапшот мира. В браузере применяется немедленно
146
+ * (applyShot), в буфер интерполяции не попадает — сущность, доехавшая
147
+ * только им, иначе не появится в сцене вовсе.
148
+ * @param {Array} [data] - [gameSnapshot, camera, serverTime, seq].
149
+ */
150
+ applyFirstShot(data) {
151
+ if (!Array.isArray(data)) {
152
+ return;
153
+ }
154
+
155
+ const [game, camera] = data;
156
+
157
+ if (game) {
158
+ this._applyGameData(game);
159
+ }
160
+
161
+ if (camera && camera !== 0) {
162
+ this.camera = [camera[0], camera[1]];
163
+ }
164
+ }
165
+
166
+ /**
167
+ * CLEAR: зеркало client/main.js — снятие сущностей с «холста» и сброс ядра
168
+ * (буфер интерполяции + предикт). Хост шлёт его на каждый рестарт раунда
169
+ * и на смену карты; без него headless живёт через границу раунда в
170
+ * состоянии, которого в браузере не бывает.
171
+ * @param {Array} [setIdList] - Ключи схемы к снятию; без него — всё.
172
+ */
173
+ clear(setIdList) {
174
+ if (Array.isArray(setIdList)) {
175
+ for (const setId of setIdList) {
176
+ delete this.scene[setId];
177
+ }
178
+ } else {
179
+ this.scene = {};
180
+ this.camera = null;
181
+ }
182
+
183
+ this.core.reset?.();
184
+ }
185
+
144
186
  /**
145
187
  * MAP_DATA: мир raycast в ядре и сброс предикта — зеркало main.js.
146
188
  * @param {Object} data - Данные карты, как их шлёт хост.
@@ -242,6 +284,12 @@ class VirtualClient {
242
284
  return JSON.parse(this.core.debug_json());
243
285
  }
244
286
 
287
+ // Ядро живёт в памяти WASM: вышедший участник обязан её отдать, иначе
288
+ // длинный реплей с текучкой игроков растит процесс
289
+ destroy() {
290
+ this.core.free?.();
291
+ }
292
+
245
293
  // Записи детектора рассинхрона предикта из ядра. Агрегаты накопительные,
246
294
  // поэтому просто перезаписываются; записи — вычерпываются.
247
295
  _drainDivergence() {
@@ -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,7 +15,11 @@ export const PASS = 'pass';
15
15
  export const FAIL = 'fail';
16
16
  export const SKIP = 'skip';
17
17
 
18
- // список проверок в порядке plan/ai-debug/stage_3.md
18
+ // scenario.unusedSnapshotKeys: вместо списка ключей — «этот сценарий вообще
19
+ // не берётся судить покрытие схемы» (встроенный смоук на чужой игре)
20
+ export const ALL_KEYS_UNAUDITED = '*';
21
+
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'],
21
25
  [2, 'snapshotKeysUsed', 'every snapshot key produced at least one row'],
@@ -67,14 +71,19 @@ export function checkInvariants(ctx) {
67
71
  }
68
72
 
69
73
  /**
70
- * Инвариант 12: два прогона одного сценария обязаны совпасть побайтово.
74
+ * Инвариант 12: два прогона одного сценария обязаны совпасть кадр в кадр
75
+ * (сравниваются хеши кадров, собранные при captureFrames).
71
76
  * @param {Object} first - Отчёт первого прогона.
72
77
  * @param {Object} second - Отчёт второго прогона.
73
78
  * @returns {Object} Результат проверки.
74
79
  */
75
80
  export function checkDeterminism(first, second) {
76
- const a = first.shotBytes;
77
- const b = second.shotBytes;
81
+ const a = first.shotHashes;
82
+ const b = second.shotHashes;
83
+
84
+ if (!a || !b) {
85
+ return result('determinism', SKIP, [], 'run with --determinism');
86
+ }
78
87
 
79
88
  if (a.length !== b.length) {
80
89
  return result('determinism', FAIL, [
@@ -132,6 +141,19 @@ function finiteValues({ clients }) {
132
141
 
133
142
  // 2
134
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
+
135
157
  const declared = Object.keys(game.snapshot);
136
158
  const unused = new Set(scenario.unusedSnapshotKeys);
137
159
  const seen = new Set();
@@ -392,7 +414,19 @@ function keyBindings({ game, clientConfig, scenario }) {
392
414
  // player-блоком кадра. Сопоставление идёт по времени кадра, а не по seq —
393
415
  // предиктор переигрывает историю ввода от момента авторитетного состояния,
394
416
  // и «тот же seq» на клиенте и на хосте означает разные моменты.
395
- 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
+
396
430
  const violations = [];
397
431
  const tracked = clients.filter(client => client.divergenceStats);
398
432
 
@@ -442,11 +476,19 @@ function predictionDrift({ clients }) {
442
476
  }
443
477
 
444
478
  // Компоненты player-блока — игровая раскладка, движок знает только их
445
- // порядок, поэтому нарушение адресуется индексом.
479
+ // порядок, поэтому нарушение адресуется индексом. Исключение — уровень 0
480
+ // (source 'camera'): там движок сравнивает ровно мировые x/y, и называть их
481
+ // «#0/#1» значит прятать причину (см. docs/ai/13-debugging.md).
482
+ const CAMERA_COMPONENTS = ['x', 'y'];
483
+
446
484
  function formatDivergence(record) {
485
+ const name =
486
+ record.source === 'camera'
487
+ ? index => CAMERA_COMPONENTS[index] ?? `#${index}`
488
+ : index => `#${index}`;
447
489
  const parts = (record.exceeded ?? []).map(
448
490
  index =>
449
- `#${index} Δ${record.delta[index]} > ${record.thresholds[index]} ` +
491
+ `${name(index)} Δ${record.delta[index]} > ${record.thresholds[index]} ` +
450
492
  `(predicted ${record.predicted[index]}, authoritative ${record.authoritative[index]})`,
451
493
  );
452
494
  const replayed = record.replayed
@@ -1,7 +1,10 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { access, readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
- import { assertEngineApiCompatible } from '../lib/gamePlugin.js';
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
- if (!game) {
34
- return loadFixture(core);
35
- }
46
+ const plugin = game
47
+ ? await loadFromManifest(game, core)
48
+ : await loadFixture(core);
36
49
 
50
+ // контракт gameConfig проверяется здесь, а не только в createHostRuntime:
51
+ // встроенный сценарий собирается из gameConfig раньше, чем стартует прогон
52
+ // (builtinScenario.js), и плагин без конфига иначе отвечал бы сырым
53
+ // TypeError вместо перечисления недостающих полей
54
+ assertGameConfigShape(plugin.hostPlugin);
55
+
56
+ return plugin;
57
+ }
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');
@@ -43,8 +66,22 @@ export async function loadGameForSim({ game = null, core = null } = {}) {
43
66
 
44
67
  const baseDir = path.dirname(manifestPath);
45
68
  const { assetsBase } = manifest;
46
- const hostPlugin = await importDefault(baseDir, manifest.entries.host, assetsBase);
47
- const clientPlugin = await importDefault(baseDir, manifest.entries.client, assetsBase);
69
+ const hostPlugin = await importDefault(
70
+ baseDir,
71
+ manifest.entries.host,
72
+ assetsBase,
73
+ );
74
+ const clientPlugin = await importDefault(
75
+ baseDir,
76
+ manifest.entries.client,
77
+ assetsBase,
78
+ );
79
+
80
+ assertPluginMatchesManifest(manifest, {
81
+ host: hostPlugin,
82
+ client: clientPlugin,
83
+ });
84
+
48
85
  const nodeCore = core ?? manifest.entries.wasmNode ?? null;
49
86
 
50
87
  if (!nodeCore) {
@@ -54,6 +91,21 @@ export async function loadGameForSim({ game = null, core = null } = {}) {
54
91
  );
55
92
  }
56
93
 
94
+ const corePath = path.resolve(baseDir, nodeCore);
95
+
96
+ // манифест объявляет поле, но опубликованный пакет мог не довезти файл
97
+ // (ignore-правила срезают каталог внутри files) — без этой проверки отказ
98
+ // приходит сырым ERR_MODULE_NOT_FOUND из резолвера
99
+ try {
100
+ await access(corePath);
101
+ } catch {
102
+ throw new Error(
103
+ `${manifestPath}: entries.wasmNode points at '${nodeCore}', but ` +
104
+ `${corePath} does not exist — the game package was published ` +
105
+ `without its node core (npm run core:build:node) or pass --core <path>`,
106
+ );
107
+ }
108
+
57
109
  return {
58
110
  id: manifest.id,
59
111
  manifest,
@@ -78,10 +130,25 @@ async function loadFixture(core) {
78
130
  hostPlugin,
79
131
  clientPlugin,
80
132
  wasmUrl: core ? pathToFileURL(path.resolve(core)).href : undefined,
81
- source: 'fixture:miniGame',
133
+ source: FIXTURE_SOURCE,
82
134
  };
83
135
  }
84
136
 
137
+ // зеркало lib/gamePlugin.js:loadClientPlugin — манифест мог быть пересобран
138
+ // без dist/: прогон на старом плагине даёт зелёный вердикт о том, чего в
139
+ // сборке уже нет
140
+ function assertPluginMatchesManifest(manifest, plugins) {
141
+ for (const [half, plugin] of Object.entries(plugins)) {
142
+ if (plugin.engineApi !== manifest.engineApi) {
143
+ throw new Error(
144
+ `game "${manifest.id}": ${half} plugin engineApi ` +
145
+ `v${plugin.engineApi} does not match manifest engineApi ` +
146
+ `v${manifest.engineApi} — stale dist/`,
147
+ );
148
+ }
149
+ }
150
+ }
151
+
85
152
  function importDefault(baseDir, entry, assetsBase) {
86
153
  const file = path.resolve(baseDir, stripBase(entry, assetsBase));
87
154
 
@@ -18,8 +18,10 @@ export async function writeReport(report, { outDir = '.debug', stamp } = {}) {
18
18
 
19
19
  await mkdir(runDir, { recursive: true });
20
20
 
21
- // срезы сцены — отдельными файлами: они самые объёмные и читаются точечно
22
- const { scenes = [], ...rest } = report;
21
+ // срезы сцены — отдельными файлами: они самые объёмные и читаются точечно.
22
+ // shotHashes служебный материал самопроверки детерминизма (длина = число
23
+ // кадров матча), в читаемом отчёте ему делать нечего
24
+ const { scenes = [], shotHashes, ...rest } = report;
23
25
 
24
26
  await Promise.all(
25
27
  scenes.map(scene =>
@@ -1,6 +1,6 @@
1
1
  import clock from '../lib/clock.js';
2
2
 
3
- // Рекордер живого матча (этап 6 плана plan/ai-debug): пишет то, что headless
3
+ // Рекордер живого матча (этап 6 плана plan/done/ai-debug): пишет то, что headless
4
4
  // не воспроизводит — реальный WebRTC, PixiJS и живой ввод человека — ровно в
5
5
  // формат сценария headless-runner'а (devtools/ScenarioRunner.js). Баг,
6
6
  // пойманный человеком в браузере, догоняется потом `npm run sim:replay`
@@ -134,8 +134,11 @@ export default class DebugRecorder {
134
134
  version: 1,
135
135
  seed: this._seed,
136
136
  map: this._map,
137
+ // таймеры сценария живут только в config.timers: верхний уровень
138
+ // config — это ключи конфига игры, и раньше запись подсовывала туда
139
+ // таймер в расчёте на неявный роутинг
137
140
  config: this._networkSendRate
138
- ? { networkSendRate: this._networkSendRate }
141
+ ? { timers: { networkSendRate: this._networkSendRate } }
139
142
  : {},
140
143
  participants: this._participants,
141
144
  timeline: this._timeline,
@@ -145,7 +145,7 @@ export default class HostGame {
145
145
  this._networkSendRate = data.timers.networkSendRate;
146
146
  this._snapshotManager = new SnapshotThrottle(this._networkSendRate);
147
147
 
148
- // рекордер живого матча (этап 6 плана plan/ai-debug) — только в dev-режиме;
148
+ // рекордер живого матча (этап 6 плана plan/done/ai-debug) — только в dev-режиме;
149
149
  // в проде null, и все точки записи ниже вырождаются в ?.
150
150
  this._recorder = this._isDevMode ? new DebugRecorder() : null;
151
151
 
@@ -511,7 +511,7 @@ export default class HostGame {
511
511
  this._mapList.push(...Object.keys(this._maps));
512
512
  }
513
513
 
514
- // ***** отладочный контур (этап 6 плана plan/ai-debug) ***** //
514
+ // ***** отладочный контур (этап 6 плана plan/done/ai-debug) ***** //
515
515
  // Всё ниже живёт под флагом isDevMode: в проде рекордера нет, а дамп
516
516
  // отдаёт только то, что и так знает мета.
517
517
 
@@ -407,7 +407,7 @@ self.onmessage = async event => {
407
407
  host?.setHostId(msg.hostId, msg.hostSecret);
408
408
  break;
409
409
 
410
- // отладочный контур (этап 6 плана plan/ai-debug): единственный вход в
410
+ // отладочный контур (этап 6 плана plan/done/ai-debug): единственный вход в
411
411
  // авторитетную половину из главного потока — запрос/ответ по requestId
412
412
  case 'debug':
413
413
  onDebug(msg);
@@ -310,7 +310,7 @@ export default class SocketManager {
310
310
 
311
311
  /**
312
312
  * Отправка отладочного лога хоста в консоль клиента (порт CONSOLE,
313
- * этап 6 плана plan/ai-debug). Worker изолирован от DevTools вкладки —
313
+ * этап 6 плана plan/done/ai-debug). Worker изолирован от DevTools вкладки —
314
314
  * без этого канала его события в браузере не видны вовсе.
315
315
  * @param {string} socketId
316
316
  * @param {*} data
@@ -49,9 +49,14 @@ class AbstractTimer {
49
49
  _stopTimer(key) {
50
50
  if (this._timers.has(key)) {
51
51
  const { timerId, isInterval } = this._timers.get(key);
52
- const handler = isInterval ? clock.clearInterval : clock.clearTimeout;
53
52
 
54
- handler(timerId);
53
+ // вызов через clock, а не отрыв метода от объекта: сейчас это
54
+ // замыкания, но привязка к модулю не должна зависеть от этого
55
+ if (isInterval) {
56
+ clock.clearInterval(timerId);
57
+ } else {
58
+ clock.clearTimeout(timerId);
59
+ }
55
60
 
56
61
  this._timers.delete(key);
57
62
  }
@@ -74,9 +79,12 @@ class AbstractTimer {
74
79
  _clearAllTimers() {
75
80
  for (const timerData of this._timers.values()) {
76
81
  const { timerId, isInterval } = timerData;
77
- const handler = isInterval ? clock.clearInterval : clock.clearTimeout;
78
82
 
79
- handler(timerId);
83
+ if (isInterval) {
84
+ clock.clearInterval(timerId);
85
+ } else {
86
+ clock.clearTimeout(timerId);
87
+ }
80
88
  }
81
89
 
82
90
  this._timers.clear();
@@ -40,7 +40,7 @@ export function applyRoomOverrides(room = {}, plugin) {
40
40
  game.timers.mapTime = clampTime(room.mapTime);
41
41
  }
42
42
 
43
- // dev-режим вкладки хоста (этап 6 плана plan/ai-debug): включает рекордер
43
+ // dev-режим вкладки хоста (этап 6 плана plan/done/ai-debug): включает рекордер
44
44
  // и хостовый CONSOLE-лог. Прод-бандл его не выставляет — поведение то же
45
45
  if (typeof room.isDevMode === 'boolean') {
46
46
  game.isDevMode = room.isDevMode;
@@ -79,7 +79,7 @@ export async function createHostRuntime(room, options = {}) {
79
79
  const host = new HostGame(game, socketManager, core, hostPlugin, {
80
80
  hostSocketId: room?.hostSocketId ?? null,
81
81
  gameVersion: room.game?.version ?? null,
82
- // рекордер (этап 6 плана plan/ai-debug) кладёт seed в сценарий — без него
82
+ // рекордер (этап 6 плана plan/done/ai-debug) кладёт seed в сценарий — без него
83
83
  // записанный матч невоспроизводим
84
84
  seed,
85
85
  ...hostOptions,
@@ -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
- const missing = REQUIRED_GAME_CONFIG_PATHS.filter(
65
- p => getPath(hostPlugin.gameConfig, p) === undefined,
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 её сборки). Манифест и