vimp-engine 0.3.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.
Files changed (49) hide show
  1. package/bin/vimp-sim.js +140 -0
  2. package/package.json +10 -3
  3. package/src/config/gameCodes.js +8 -0
  4. package/src/config/lobby.js +23 -1
  5. package/src/config/master.js +8 -0
  6. package/src/devtools/RecordingSocketManager.js +143 -0
  7. package/src/devtools/ScenarioRunner.js +580 -0
  8. package/src/devtools/VirtualClient.js +411 -0
  9. package/src/devtools/VirtualClock.js +169 -0
  10. package/src/devtools/inspectHost.js +31 -0
  11. package/src/devtools/invariants.js +685 -0
  12. package/src/devtools/pluginLoader.js +145 -0
  13. package/src/devtools/report.js +204 -0
  14. package/src/devtools/resetHostSingletons.js +23 -0
  15. package/src/host/DebugRecorder.js +227 -0
  16. package/src/host/GameCoreAdapter.js +17 -0
  17. package/src/host/HostGame.js +129 -7
  18. package/src/host/host.worker.js +46 -47
  19. package/src/host/meta/SocketManager.js +15 -7
  20. package/src/host/meta/modules/Panel.js +7 -0
  21. package/src/host/meta/modules/PlayerDataSync.js +29 -5
  22. package/src/host/meta/modules/RTTManager.js +4 -2
  23. package/src/host/meta/modules/Stat.js +7 -0
  24. package/src/host/meta/modules/TimerManager.js +15 -7
  25. package/src/host/meta/modules/Vote.js +10 -1
  26. package/src/host/meta/modules/chat/Chat.js +7 -0
  27. package/src/host/meta/modules/chat/index.js +1 -1
  28. package/src/host/meta/player/HumanParticipant.js +2 -1
  29. package/src/lib/AbstractTimer.js +16 -6
  30. package/src/lib/applyRoomOverrides.js +6 -0
  31. package/src/lib/clientCoreConfig.js +4 -0
  32. package/src/lib/clock.js +53 -0
  33. package/src/lib/createHostRuntime.js +108 -0
  34. package/src/lib/reconstructHot.js +78 -0
  35. package/src/lib/validators.js +10 -0
  36. package/tests/fixtures/miniGame/client/fakeClientCore.js +304 -0
  37. package/tests/fixtures/miniGame/client/index.js +37 -0
  38. package/tests/fixtures/miniGame/client/parts/Actor.js +18 -0
  39. package/tests/fixtures/miniGame/client/parts/ActorRadar.js +7 -0
  40. package/tests/fixtures/miniGame/config/auth.js +41 -0
  41. package/tests/fixtures/miniGame/config/client.js +139 -0
  42. package/tests/fixtures/miniGame/config/game.js +148 -0
  43. package/tests/fixtures/miniGame/host/ScriptedManager.js +129 -0
  44. package/tests/fixtures/miniGame/host/createModules.js +6 -0
  45. package/tests/fixtures/miniGame/host/fakeCore.js +233 -0
  46. package/tests/fixtures/miniGame/host/index.js +29 -0
  47. package/tests/fixtures/miniGame/host/spawnCommand.js +14 -0
  48. package/tests/fixtures/miniGame/host/systemMessages.js +6 -0
  49. package/tests/fixtures/miniGame.contract.test.js +76 -0
@@ -0,0 +1,580 @@
1
+ import { createHostRuntime } from '../lib/createHostRuntime.js';
2
+ import VirtualClock, { flushMicrotasks } from './VirtualClock.js';
3
+ import RecordingSocketManager from './RecordingSocketManager.js';
4
+ import VirtualClient from './VirtualClient.js';
5
+ import { loadGameForSim } from './pluginLoader.js';
6
+ import { resetHostSingletons } from './resetHostSingletons.js';
7
+ import { checkInvariants, summarize } from './invariants.js';
8
+ import { inspectCore, inspectHost } from './inspectHost.js';
9
+
10
+ // Прогон сценария целиком в одном Node-процессе: авторитетный хост
11
+ // (боевая инициализация из lib/createHostRuntime.js) + по настоящему
12
+ // клиентскому ядру на каждого участника, связанные записывающим транспортом.
13
+ // Время — виртуальное, поэтому десятиминутный матч занимает секунды, а два
14
+ // прогона одного сценария обязаны совпасть.
15
+
16
+ // Не-кадровые порты, которые VirtualClient складывает в received —
17
+ // имя метода SocketManager → имя корзины.
18
+ const RECORDED_PORTS = {
19
+ sendPanel: 'panel',
20
+ sendStat: 'stat',
21
+ sendChat: 'chat',
22
+ sendVote: 'vote',
23
+ sendKeySet: 'keySet',
24
+ sendGameInform: 'gameInform',
25
+ sendSoundCue: 'soundCue',
26
+ sendRoundEnd: 'roundEnd',
27
+ sendTechInform: 'techInform',
28
+ };
29
+
30
+ // В headless-контуре нет ни мастера, ни auth-сервиса — участник стартует с
31
+ // пустым профилем. Это не заглушка ради тишины: пустой профиль и есть
32
+ // корректное состояние прогона, а настоящий fetch по относительному URL в
33
+ // Node просто не разрешается.
34
+ const emptyProfileFetch = async () => ({
35
+ ok: true,
36
+ status: 200,
37
+ json: async () => ({ rank: 0, state: null }),
38
+ });
39
+
40
+ /**
41
+ * Приводит сценарий к полной форме и проверяет обязательные поля.
42
+ * @param {Object} raw - Разобранный JSON сценария.
43
+ * @returns {Object} Нормализованный сценарий.
44
+ */
45
+ export function parseScenario(raw) {
46
+ if (!raw || typeof raw !== 'object') {
47
+ throw new Error('scenario: expected an object');
48
+ }
49
+
50
+ if (raw.version !== 1) {
51
+ throw new Error(`scenario: unsupported version ${raw.version} (expected 1)`);
52
+ }
53
+
54
+ if (!Array.isArray(raw.participants) || !raw.participants.length) {
55
+ throw new Error('scenario: participants must be a non-empty array');
56
+ }
57
+
58
+ const timeline = [...(raw.timeline ?? [])].sort(
59
+ (a, b) => (a.tick ?? 0) - (b.tick ?? 0),
60
+ );
61
+
62
+ return {
63
+ version: 1,
64
+ seed: raw.seed ?? 1,
65
+ game: raw.game ?? null,
66
+ map: raw.map ?? null,
67
+ config: raw.config ?? {},
68
+ participants: raw.participants,
69
+ timeline,
70
+ // ключи схемы, которых в этом сценарии заведомо не будет (инвариант 2):
71
+ // «сущность не спавнится» отличается от «сценарий её не трогает» только
72
+ // этим объявлением
73
+ unusedSnapshotKeys: raw.unusedSnapshotKeys ?? [],
74
+ // пороги детектора рассинхрона предикта (инвариант 9); {} — дефолты
75
+ // ядра, null — детектор выключен
76
+ divergence: raw.divergence === null ? null : (raw.divergence ?? {}),
77
+ ticks: raw.ticks ?? 600,
78
+ dumpTicks: raw.dumpTicks ?? null,
79
+ room: raw.room ?? {},
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Прогоняет сценарий и возвращает отчёт.
85
+ * @param {Object} rawScenario
86
+ * @param {Object} [options]
87
+ * @param {Object} [options.plugin] - Результат loadGameForSim (если игра уже
88
+ * загружена — например, при двух прогонах подряд для самопроверки
89
+ * детерминизма).
90
+ * @param {string} [options.gamePath] - Путь к пакету игры/манифесту.
91
+ * @param {string} [options.corePath] - Путь к node-сборке ядра.
92
+ * @param {boolean} [options.captureFrames] - Считать хеши потока кадров
93
+ * (нужны только самопроверке детерминизма — на длинном матче это лишние
94
+ * мегабайты в отчёте).
95
+ * @returns {Promise<Object>} Отчёт прогона.
96
+ */
97
+ export async function runScenario(rawScenario, options = {}) {
98
+ const scenario = parseScenario(rawScenario);
99
+ const plugin =
100
+ options.plugin ??
101
+ (await loadGameForSim({ game: options.gamePath, core: options.corePath }));
102
+
103
+ // каждый прогон — свой матч: мета-модули хоста синглтонны, и без сброса
104
+ // второй прогон унаследовал бы таймеры первого
105
+ resetHostSingletons();
106
+
107
+ const virtualClock = new VirtualClock({ seed: scenario.seed });
108
+ const restoreClock = virtualClock.install();
109
+
110
+ try {
111
+ return await execute(scenario, plugin, virtualClock, {
112
+ captureFrames: options.captureFrames === true,
113
+ });
114
+ } finally {
115
+ restoreClock();
116
+ }
117
+ }
118
+
119
+ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
120
+ const clients = new Map(); // socketId → VirtualClient
121
+ const byParticipant = new Map(); // id сценария → { socketId, gameId }
122
+ const participantLog = []; // [{ who, socketId, gameId, joinTick, leaveTick }]
123
+ const mapChanges = [];
124
+ const inputSeq = new Map();
125
+
126
+ let currentTick = 0;
127
+
128
+ // ping/pong: без ответа хост честно кикает участника по таймауту RTT, и
129
+ // длинный сценарий тихо теряет игроков посреди прогона. Объявлено до
130
+ // транспорта — замыкание onFrame читает эту переменную.
131
+ let host = null;
132
+ let socketManager = null;
133
+
134
+ const room = {
135
+ ...scenario.room,
136
+ seed: scenario.seed,
137
+ map: scenario.map ?? undefined,
138
+ // wasmUrl — node-сборка ядра: createHostRuntime отдаёт его плагину
139
+ // тем же полем, что и Worker в браузере (у фикстуры его нет — её
140
+ // ядра обычный JS)
141
+ game: {
142
+ version: scenario.game?.version ?? null,
143
+ wasmUrl: plugin.wasmUrl,
144
+ },
145
+ };
146
+
147
+ const runtime = await createHostRuntime(room, {
148
+ loadHostPlugin: () => plugin.hostPlugin,
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
+ },
158
+ overrideGameConfig: game => mergeConfig(game, scenario.config),
159
+ hostOptions: {
160
+ onMapChange: mapName =>
161
+ mapChanges.push({ tick: currentTick, map: mapName }),
162
+ playerDataFetch: emptyProfileFetch,
163
+ },
164
+ });
165
+
166
+ const { clientCfg, game, seed } = runtime;
167
+ const stepMs = game.timers.timeStep;
168
+
169
+ host = runtime.host;
170
+
171
+ // очередь операций сценария, разложенная по номеру тика: ввод хост
172
+ // применяет синхронно при приходе сообщения (HostGame.updateKeys), очереди
173
+ // у него нет — значит сценарий обязан задавать именно границу тика
174
+ const opsByTick = new Map();
175
+
176
+ for (const op of scenario.timeline) {
177
+ const tick = op.tick ?? 0;
178
+
179
+ if (!opsByTick.has(tick)) {
180
+ opsByTick.set(tick, []);
181
+ }
182
+
183
+ opsByTick.get(tick).push(op);
184
+ }
185
+
186
+ const dumpTicks = new Set(scenario.dumpTicks ?? [scenario.ticks]);
187
+ const scenes = [];
188
+
189
+ for (let tick = 0; tick <= scenario.ticks; tick += 1) {
190
+ currentTick = tick;
191
+ socketManager.tick = tick;
192
+
193
+ for (const op of opsByTick.get(tick) ?? []) {
194
+ await applyOp(op, {
195
+ host,
196
+ clientCfg,
197
+ plugin,
198
+ clients,
199
+ byParticipant,
200
+ participantLog,
201
+ inputSeq,
202
+ scenario,
203
+ virtualClock,
204
+ tick,
205
+ });
206
+ }
207
+
208
+ if (tick > 0) {
209
+ await virtualClock.advance(stepMs);
210
+ }
211
+
212
+ for (const client of clients.values()) {
213
+ client.render(virtualClock.monotonic());
214
+ }
215
+
216
+ if (dumpTicks.has(tick)) {
217
+ scenes.push({
218
+ tick,
219
+ // дамп мира ядра рядом со сценой клиентов: расхождение «в ядре тело
220
+ // есть, на холсте пусто» видно только при обеих половинах в одном
221
+ // файле (этап 4)
222
+ core: inspectCore(host),
223
+ clients: [...clients.values()].map(client => client.snapshot()),
224
+ });
225
+ }
226
+ }
227
+
228
+ return buildReport({
229
+ scenario,
230
+ plugin,
231
+ seed,
232
+ stepMs,
233
+ host,
234
+ core: runtime.core,
235
+ game,
236
+ clientCfg,
237
+ clients,
238
+ socketManager,
239
+ scenes,
240
+ mapChanges,
241
+ byParticipant,
242
+ participantLog,
243
+ captureFrames,
244
+ });
245
+ }
246
+
247
+ // исходящий кадр хоста → клиент-получатель. sendShot несёт байты, которые в
248
+ // проде ушли бы в data channel — их и скармливаем клиентскому ядру
249
+ function routeFrame(frame, clients, virtualClock, host) {
250
+ const client = clients.get(frame.socketId);
251
+
252
+ if (!client) {
253
+ return;
254
+ }
255
+
256
+ // pong отвечается мгновенно: латентность headless-прогона равна нулю, и
257
+ // это единственная честная её модель
258
+ if (frame.method === 'sendPing') {
259
+ host?.updateRTT(client.gameId, frame.args[0]);
260
+ return;
261
+ }
262
+
263
+ if (frame.method === 'sendShot') {
264
+ client.pushFrame(frame.args[0], virtualClock.monotonic());
265
+ return;
266
+ }
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
+
283
+ if (frame.method === 'sendMap') {
284
+ client.setMap(frame.args[0]);
285
+ return;
286
+ }
287
+
288
+ const bucket = RECORDED_PORTS[frame.method];
289
+
290
+ if (bucket) {
291
+ client.record(bucket, frame.args[0]);
292
+ }
293
+ }
294
+
295
+ async function applyOp(op, ctx) {
296
+ const {
297
+ host,
298
+ clients,
299
+ byParticipant,
300
+ participantLog,
301
+ inputSeq,
302
+ virtualClock,
303
+ tick,
304
+ } = ctx;
305
+
306
+ switch (op.op) {
307
+ case 'join':
308
+ await joinParticipant(op, ctx);
309
+ break;
310
+
311
+ case 'leave': {
312
+ const entry = byParticipant.get(op.who);
313
+
314
+ if (entry) {
315
+ host.removeUser(entry.gameId);
316
+
317
+ // ядро клиента живёт в WASM-памяти: без free многочасовой реплей с
318
+ // текучкой участников растит процесс
319
+ clients.get(entry.socketId)?.destroy();
320
+ clients.delete(entry.socketId);
321
+ byParticipant.delete(op.who);
322
+
323
+ const logged = participantLog.find(
324
+ item => item.who === op.who && item.leaveTick === null,
325
+ );
326
+
327
+ if (logged) {
328
+ logged.leaveTick = tick;
329
+ }
330
+ }
331
+
332
+ break;
333
+ }
334
+
335
+ case 'key': {
336
+ const entry = requireEntry(byParticipant, op.who);
337
+ const seq = (inputSeq.get(op.who) ?? 0) + 1;
338
+
339
+ inputSeq.set(op.who, seq);
340
+ host.updateKeys(entry.gameId, `${seq}:${op.action}:${op.name}`);
341
+
342
+ // клиент предсказывает тот же ввод — иначе дрейф предикта не с чем
343
+ // сравнивать (этап 5)
344
+ clients
345
+ .get(entry.socketId)
346
+ ?.core.apply_input(op.action, op.name, virtualClock.monotonic());
347
+
348
+ break;
349
+ }
350
+
351
+ case 'chat':
352
+ host.pushMessage(requireEntry(byParticipant, op.who).gameId, op.text);
353
+ break;
354
+
355
+ case 'vote':
356
+ host.parseVote(requireEntry(byParticipant, op.who).gameId, op.data);
357
+ break;
358
+
359
+ default:
360
+ throw new Error(`scenario: unknown op '${op.op}'`);
361
+ }
362
+
363
+ await flushMicrotasks();
364
+ }
365
+
366
+ async function joinParticipant(op, ctx) {
367
+ const {
368
+ host,
369
+ clientCfg,
370
+ plugin,
371
+ clients,
372
+ byParticipant,
373
+ participantLog,
374
+ scenario,
375
+ tick,
376
+ } = ctx;
377
+ const participant = scenario.participants.find(p => p.id === op.who);
378
+
379
+ if (!participant) {
380
+ throw new Error(`scenario: join references unknown participant '${op.who}'`);
381
+ }
382
+
383
+ const socketId = participant.socketId ?? `sock-${participant.id}`;
384
+ let gameId;
385
+
386
+ host.createUser(
387
+ { name: participant.name, model: participant.model },
388
+ socketId,
389
+ id => {
390
+ gameId = id;
391
+ },
392
+ );
393
+
394
+ await flushMicrotasks();
395
+
396
+ if (gameId === undefined) {
397
+ throw new Error(`scenario: host refused participant '${op.who}'`);
398
+ }
399
+
400
+ // клиент создаётся ДО онбординга: в браузере вкладка существует раньше
401
+ // карты, и MAP_DATA/keySet/панель должны дойти до ядра
402
+ const client = await VirtualClient.create({
403
+ clientPlugin: plugin.clientPlugin,
404
+ clientConfig: clientCfg,
405
+ wasmUrl: plugin.wasmUrl,
406
+ socketId,
407
+ gameId,
408
+ auth: { name: participant.name, model: participant.model },
409
+ divergence: scenario.divergence ?? undefined,
410
+ });
411
+
412
+ clients.set(socketId, client);
413
+
414
+ // онбординг клиента: карта → готовность → первый кадр (порт-машина
415
+ // Worker'а в headless-контуре не участвует)
416
+ host.sendMap(gameId);
417
+ host.mapReady(gameId);
418
+ host.firstShotReady(gameId);
419
+ byParticipant.set(op.who, { socketId, gameId });
420
+ participantLog.push({
421
+ who: op.who,
422
+ socketId,
423
+ gameId,
424
+ joinTick: tick,
425
+ leaveTick: null,
426
+ });
427
+
428
+ if (op.team) {
429
+ host.parseVote(gameId, ['teamChange', op.team]);
430
+ }
431
+ }
432
+
433
+ function requireEntry(byParticipant, who) {
434
+ const entry = byParticipant.get(who);
435
+
436
+ if (!entry) {
437
+ throw new Error(`scenario: participant '${who}' has not joined yet`);
438
+ }
439
+
440
+ return entry;
441
+ }
442
+
443
+ // точечное переопределение конфига игры сценарием. Таймеры — только через
444
+ // config.timers: неявный роутинг одноимённого ключа верхнего уровня в
445
+ // game.timers молча правил не то, что просил сценарий.
446
+ function mergeConfig(game, overrides) {
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)) {
453
+ game[key] = { ...game[key], ...value };
454
+ } else {
455
+ game[key] = value;
456
+ }
457
+ }
458
+ }
459
+
460
+ function buildReport(ctx) {
461
+ const {
462
+ scenario,
463
+ plugin,
464
+ seed,
465
+ stepMs,
466
+ host,
467
+ core,
468
+ game,
469
+ clientCfg,
470
+ clients,
471
+ socketManager,
472
+ scenes,
473
+ mapChanges,
474
+ byParticipant,
475
+ participantLog,
476
+ captureFrames,
477
+ } = ctx;
478
+
479
+ const frameCounts = {};
480
+
481
+ for (const frame of socketManager.frames) {
482
+ frameCounts[frame.method] = (frameCounts[frame.method] ?? 0) + 1;
483
+ }
484
+
485
+ const clientList = [...clients.values()];
486
+
487
+ // проверки идут по живым объектам прогона (сцена, реестр хоста, ядро), а
488
+ // не по сериализованному отчёту: половина инвариантов смотрит туда, чего
489
+ // в JSON нет вовсе
490
+ const invariants = checkInvariants({
491
+ scenario,
492
+ game,
493
+ clientConfig: clientCfg,
494
+ clients: clientList,
495
+ socketManager,
496
+ core,
497
+ hostState: inspectHost(host),
498
+ participantLog,
499
+ stepMs,
500
+ });
501
+
502
+ return {
503
+ scenario: {
504
+ seed: scenario.seed,
505
+ ticks: scenario.ticks,
506
+ map: scenario.map,
507
+ participants: scenario.participants.length,
508
+ timeline: scenario.timeline.length,
509
+ },
510
+ game: { id: plugin.id, source: plugin.source },
511
+ seed,
512
+ stepMs,
513
+ durationMs: stepMs * scenario.ticks,
514
+ currentMap: host.currentMap,
515
+ mapChanges,
516
+ participants: [...byParticipant.entries()].map(([id, entry]) => ({
517
+ id,
518
+ ...entry,
519
+ })),
520
+ participantLog,
521
+ invariants,
522
+ invariantSummary: summarize(invariants),
523
+ frameCounts,
524
+ clients: clientList.map(client => ({
525
+ ...client.snapshot(),
526
+ received: Object.fromEntries(
527
+ Object.entries(client.received).map(([kind, list]) => [
528
+ kind,
529
+ list.length,
530
+ ]),
531
+ ),
532
+ decodeErrors: client.decodeErrors,
533
+ // дрейф предикта: агрегаты за прогон + сами записи (этап 5)
534
+ divergence: client.divergenceStats && {
535
+ ...client.divergenceStats,
536
+ records: client.divergence,
537
+ truncated: client.truncated.divergence,
538
+ },
539
+ // сколько строк на ключ схемы доехало за весь прогон, а не к финалу:
540
+ // сущность могла появиться и исчезнуть между дампами
541
+ observedRows: Object.fromEntries(
542
+ Object.entries(client.observed).map(([key, stats]) => [
543
+ key,
544
+ stats.rows,
545
+ ]),
546
+ ),
547
+ })),
548
+ scenes,
549
+ // поток кадров нужен ровно одному потребителю — самопроверке
550
+ // детерминизма (этап 3). Хеша на кадр для сравнения потоков достаточно,
551
+ // а сами байты линейно от длины матча раздували report.json
552
+ shotHashes: captureFrames
553
+ ? socketManager
554
+ .framesOf('sendShot')
555
+ .map(frame => hash32(toBytes(frame.args[0])))
556
+ : null,
557
+ snapshotSchema: game.snapshot,
558
+ };
559
+ }
560
+
561
+ function toBytes(value) {
562
+ if (value instanceof Uint8Array) {
563
+ return value;
564
+ }
565
+
566
+ return new Uint8Array(value);
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
+ }