vimp-engine 0.4.0 → 0.5.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
@@ -55,10 +55,13 @@ async function main(argv) {
55
55
  // плагин грузится один раз: второй прогон самопроверки детерминизма
56
56
  // обязан идти на том же ядре, иначе он проверял бы загрузчик, а не мир
57
57
  const plugin = await loadGameForSim({ game: args.game, core: args.core });
58
- const report = await runScenario(scenario, { plugin });
58
+ // хеши потока кадров собираются только под --determinism: их единственный
59
+ // потребитель — сравнение двух прогонов, а объём линеен по длине матча
60
+ const captureFrames = args.determinism === true;
61
+ const report = await runScenario(scenario, { plugin, captureFrames });
59
62
 
60
63
  if (args.determinism) {
61
- const second = await runScenario(scenario, { plugin });
64
+ const second = await runScenario(scenario, { plugin, captureFrames });
62
65
  const check = checkDeterminism(report, second);
63
66
 
64
67
  report.invariants = report.invariants.map(item =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.4.0",
3
+ "version": "0.5.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
  }
@@ -89,6 +89,9 @@ export function parseScenario(raw) {
89
89
  * детерминизма).
90
90
  * @param {string} [options.gamePath] - Путь к пакету игры/манифесту.
91
91
  * @param {string} [options.corePath] - Путь к node-сборке ядра.
92
+ * @param {boolean} [options.captureFrames] - Считать хеши потока кадров
93
+ * (нужны только самопроверке детерминизма — на длинном матче это лишние
94
+ * мегабайты в отчёте).
92
95
  * @returns {Promise<Object>} Отчёт прогона.
93
96
  */
94
97
  export async function runScenario(rawScenario, options = {}) {
@@ -105,13 +108,15 @@ export async function runScenario(rawScenario, options = {}) {
105
108
  const restoreClock = virtualClock.install();
106
109
 
107
110
  try {
108
- return await execute(scenario, plugin, virtualClock);
111
+ return await execute(scenario, plugin, virtualClock, {
112
+ captureFrames: options.captureFrames === true,
113
+ });
109
114
  } finally {
110
115
  restoreClock();
111
116
  }
112
117
  }
113
118
 
114
- async function execute(scenario, plugin, virtualClock) {
119
+ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
115
120
  const clients = new Map(); // socketId → VirtualClient
116
121
  const byParticipant = new Map(); // id сценария → { socketId, gameId }
117
122
  const participantLog = []; // [{ who, socketId, gameId, joinTick, leaveTick }]
@@ -120,13 +125,11 @@ async function execute(scenario, plugin, virtualClock) {
120
125
 
121
126
  let currentTick = 0;
122
127
 
123
- const socketManager = new RecordingSocketManager({
124
- onFrame: frame => routeFrame(frame, clients, virtualClock, host),
125
- });
126
-
127
128
  // ping/pong: без ответа хост честно кикает участника по таймауту RTT, и
128
- // длинный сценарий тихо теряет игроков посреди прогона
129
+ // длинный сценарий тихо теряет игроков посреди прогона. Объявлено до
130
+ // транспорта — замыкание onFrame читает эту переменную.
129
131
  let host = null;
132
+ let socketManager = null;
130
133
 
131
134
  const room = {
132
135
  ...scenario.room,
@@ -143,7 +146,15 @@ async function execute(scenario, plugin, virtualClock) {
143
146
 
144
147
  const runtime = await createHostRuntime(room, {
145
148
  loadHostPlugin: () => plugin.hostPlugin,
146
- createSocketManager: () => socketManager,
149
+ // транспорт собирается той же фабрикой, что и боевой: наследник обязан
150
+ // получить те же порты и ту же игровую параметризацию
151
+ createSocketManager: (ports, gameOpts) => {
152
+ socketManager = new RecordingSocketManager(ports, gameOpts, {
153
+ onFrame: frame => routeFrame(frame, clients, virtualClock, host),
154
+ });
155
+
156
+ return socketManager;
157
+ },
147
158
  overrideGameConfig: game => mergeConfig(game, scenario.config),
148
159
  hostOptions: {
149
160
  onMapChange: mapName =>
@@ -229,6 +240,7 @@ async function execute(scenario, plugin, virtualClock) {
229
240
  mapChanges,
230
241
  byParticipant,
231
242
  participantLog,
243
+ captureFrames,
232
244
  });
233
245
  }
234
246
 
@@ -253,6 +265,21 @@ function routeFrame(frame, clients, virtualClock, host) {
253
265
  return;
254
266
  }
255
267
 
268
+ // первый снапшот мира: в браузере он применяется сразу (applyShot), а не
269
+ // едет через буфер интерполяции — сущность, доехавшая только им, иначе не
270
+ // попадёт ни в сцену, ни в проверки
271
+ if (frame.method === 'sendFirstShot') {
272
+ client.applyFirstShot(frame.sent[0]?.data);
273
+ return;
274
+ }
275
+
276
+ // CLEAR приходит на каждый рестарт раунда и на смену карты; без него
277
+ // headless живёт через границу раунда в состоянии, которого в игре нет
278
+ if (frame.method === 'sendClear') {
279
+ client.clear(frame.args[0]);
280
+ return;
281
+ }
282
+
256
283
  if (frame.method === 'sendMap') {
257
284
  client.setMap(frame.args[0]);
258
285
  return;
@@ -286,6 +313,10 @@ async function applyOp(op, ctx) {
286
313
 
287
314
  if (entry) {
288
315
  host.removeUser(entry.gameId);
316
+
317
+ // ядро клиента живёт в WASM-памяти: без free многочасовой реплей с
318
+ // текучкой участников растит процесс
319
+ clients.get(entry.socketId)?.destroy();
289
320
  clients.delete(entry.socketId);
290
321
  byParticipant.delete(op.who);
291
322
 
@@ -409,13 +440,16 @@ function requireEntry(byParticipant, who) {
409
440
  return entry;
410
441
  }
411
442
 
412
- // точечное переопределение конфига игры сценарием: config.timers.* и
413
- // config.<ключ верхнего уровня>
443
+ // точечное переопределение конфига игры сценарием. Таймеры — только через
444
+ // config.timers: неявный роутинг одноимённого ключа верхнего уровня в
445
+ // game.timers молча правил не то, что просил сценарий.
414
446
  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)) {
447
+ const { timers = {}, ...rest } = overrides;
448
+
449
+ Object.assign(game.timers, timers);
450
+
451
+ for (const [key, value] of Object.entries(rest)) {
452
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
419
453
  game[key] = { ...game[key], ...value };
420
454
  } else {
421
455
  game[key] = value;
@@ -439,6 +473,7 @@ function buildReport(ctx) {
439
473
  mapChanges,
440
474
  byParticipant,
441
475
  participantLog,
476
+ captureFrames,
442
477
  } = ctx;
443
478
 
444
479
  const frameCounts = {};
@@ -511,11 +546,14 @@ function buildReport(ctx) {
511
546
  ),
512
547
  })),
513
548
  scenes,
514
- // сырой поток кадров нужен самопроверке детерминизма (этап 3)
515
- // сравнивается побайтово
516
- shotBytes: socketManager
517
- .framesOf('sendShot')
518
- .map(frame => Buffer.from(toBytes(frame.args[0])).toString('base64')),
549
+ // поток кадров нужен ровно одному потребителю самопроверке
550
+ // детерминизма (этап 3). Хеша на кадр для сравнения потоков достаточно,
551
+ // а сами байты линейно от длины матча раздували report.json
552
+ shotHashes: captureFrames
553
+ ? socketManager
554
+ .framesOf('sendShot')
555
+ .map(frame => hash32(toBytes(frame.args[0])))
556
+ : null,
519
557
  snapshotSchema: game.snapshot,
520
558
  };
521
559
  }
@@ -527,3 +565,16 @@ function toBytes(value) {
527
565
 
528
566
  return new Uint8Array(value);
529
567
  }
568
+
569
+ // FNV-1a: сравниваются потоки одного и того же прогона, криптостойкость не
570
+ // нужна — нужна дешевизна и отсутствие зависимостей
571
+ function hash32(bytes) {
572
+ let hash = 0x811c9dc5;
573
+
574
+ for (let i = 0; i < bytes.length; i += 1) {
575
+ hash ^= bytes[i];
576
+ hash = Math.imul(hash, 0x01000193);
577
+ }
578
+
579
+ return hash >>> 0;
580
+ }
@@ -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() {
@@ -15,7 +15,7 @@ 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
+ // список проверок в порядке plan/done/ai-debug/stage_3.md
19
19
  const CHECKS = [
20
20
  [1, 'finiteValues', 'no NaN/Infinity in decoded fields and hot buffer'],
21
21
  [2, 'snapshotKeysUsed', 'every snapshot key produced at least one row'],
@@ -67,14 +67,19 @@ export function checkInvariants(ctx) {
67
67
  }
68
68
 
69
69
  /**
70
- * Инвариант 12: два прогона одного сценария обязаны совпасть побайтово.
70
+ * Инвариант 12: два прогона одного сценария обязаны совпасть кадр в кадр
71
+ * (сравниваются хеши кадров, собранные при captureFrames).
71
72
  * @param {Object} first - Отчёт первого прогона.
72
73
  * @param {Object} second - Отчёт второго прогона.
73
74
  * @returns {Object} Результат проверки.
74
75
  */
75
76
  export function checkDeterminism(first, second) {
76
- const a = first.shotBytes;
77
- const b = second.shotBytes;
77
+ const a = first.shotHashes;
78
+ const b = second.shotHashes;
79
+
80
+ if (!a || !b) {
81
+ return result('determinism', SKIP, [], 'run with --determinism');
82
+ }
78
83
 
79
84
  if (a.length !== b.length) {
80
85
  return result('determinism', FAIL, [
@@ -442,11 +447,19 @@ function predictionDrift({ clients }) {
442
447
  }
443
448
 
444
449
  // Компоненты player-блока — игровая раскладка, движок знает только их
445
- // порядок, поэтому нарушение адресуется индексом.
450
+ // порядок, поэтому нарушение адресуется индексом. Исключение — уровень 0
451
+ // (source 'camera'): там движок сравнивает ровно мировые x/y, и называть их
452
+ // «#0/#1» значит прятать причину (см. docs/ai/13-debugging.md).
453
+ const CAMERA_COMPONENTS = ['x', 'y'];
454
+
446
455
  function formatDivergence(record) {
456
+ const name =
457
+ record.source === 'camera'
458
+ ? index => CAMERA_COMPONENTS[index] ?? `#${index}`
459
+ : index => `#${index}`;
447
460
  const parts = (record.exceeded ?? []).map(
448
461
  index =>
449
- `#${index} Δ${record.delta[index]} > ${record.thresholds[index]} ` +
462
+ `${name(index)} Δ${record.delta[index]} > ${record.thresholds[index]} ` +
450
463
  `(predicted ${record.predicted[index]}, authoritative ${record.authoritative[index]})`,
451
464
  );
452
465
  const replayed = record.replayed
@@ -1,4 +1,4 @@
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
4
  import { assertEngineApiCompatible } from '../lib/gamePlugin.js';
@@ -43,8 +43,22 @@ export async function loadGameForSim({ game = null, core = null } = {}) {
43
43
 
44
44
  const baseDir = path.dirname(manifestPath);
45
45
  const { assetsBase } = manifest;
46
- const hostPlugin = await importDefault(baseDir, manifest.entries.host, assetsBase);
47
- const clientPlugin = await importDefault(baseDir, manifest.entries.client, assetsBase);
46
+ const hostPlugin = await importDefault(
47
+ baseDir,
48
+ manifest.entries.host,
49
+ assetsBase,
50
+ );
51
+ const clientPlugin = await importDefault(
52
+ baseDir,
53
+ manifest.entries.client,
54
+ assetsBase,
55
+ );
56
+
57
+ assertPluginMatchesManifest(manifest, {
58
+ host: hostPlugin,
59
+ client: clientPlugin,
60
+ });
61
+
48
62
  const nodeCore = core ?? manifest.entries.wasmNode ?? null;
49
63
 
50
64
  if (!nodeCore) {
@@ -54,6 +68,21 @@ export async function loadGameForSim({ game = null, core = null } = {}) {
54
68
  );
55
69
  }
56
70
 
71
+ const corePath = path.resolve(baseDir, nodeCore);
72
+
73
+ // манифест объявляет поле, но опубликованный пакет мог не довезти файл
74
+ // (ignore-правила срезают каталог внутри files) — без этой проверки отказ
75
+ // приходит сырым ERR_MODULE_NOT_FOUND из резолвера
76
+ try {
77
+ await access(corePath);
78
+ } catch {
79
+ throw new Error(
80
+ `${manifestPath}: entries.wasmNode points at '${nodeCore}', but ` +
81
+ `${corePath} does not exist — the game package was published ` +
82
+ `without its node core (npm run core:build:node) or pass --core <path>`,
83
+ );
84
+ }
85
+
57
86
  return {
58
87
  id: manifest.id,
59
88
  manifest,
@@ -82,6 +111,21 @@ async function loadFixture(core) {
82
111
  };
83
112
  }
84
113
 
114
+ // зеркало lib/gamePlugin.js:loadClientPlugin — манифест мог быть пересобран
115
+ // без dist/: прогон на старом плагине даёт зелёный вердикт о том, чего в
116
+ // сборке уже нет
117
+ function assertPluginMatchesManifest(manifest, plugins) {
118
+ for (const [half, plugin] of Object.entries(plugins)) {
119
+ if (plugin.engineApi !== manifest.engineApi) {
120
+ throw new Error(
121
+ `game "${manifest.id}": ${half} plugin engineApi ` +
122
+ `v${plugin.engineApi} does not match manifest engineApi ` +
123
+ `v${manifest.engineApi} — stale dist/`,
124
+ );
125
+ }
126
+ }
127
+ }
128
+
85
129
  function importDefault(baseDir, entry, assetsBase) {
86
130
  const file = path.resolve(baseDir, stripBase(entry, assetsBase));
87
131
 
@@ -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,