vimp-engine 0.3.0 → 0.4.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 +137 -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 +118 -0
  7. package/src/devtools/ScenarioRunner.js +529 -0
  8. package/src/devtools/VirtualClient.js +363 -0
  9. package/src/devtools/VirtualClock.js +169 -0
  10. package/src/devtools/inspectHost.js +31 -0
  11. package/src/devtools/invariants.js +672 -0
  12. package/src/devtools/pluginLoader.js +101 -0
  13. package/src/devtools/report.js +202 -0
  14. package/src/devtools/resetHostSingletons.js +23 -0
  15. package/src/host/DebugRecorder.js +224 -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 +6 -4
  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,529 @@
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
+ * @returns {Promise<Object>} Отчёт прогона.
93
+ */
94
+ export async function runScenario(rawScenario, options = {}) {
95
+ const scenario = parseScenario(rawScenario);
96
+ const plugin =
97
+ options.plugin ??
98
+ (await loadGameForSim({ game: options.gamePath, core: options.corePath }));
99
+
100
+ // каждый прогон — свой матч: мета-модули хоста синглтонны, и без сброса
101
+ // второй прогон унаследовал бы таймеры первого
102
+ resetHostSingletons();
103
+
104
+ const virtualClock = new VirtualClock({ seed: scenario.seed });
105
+ const restoreClock = virtualClock.install();
106
+
107
+ try {
108
+ return await execute(scenario, plugin, virtualClock);
109
+ } finally {
110
+ restoreClock();
111
+ }
112
+ }
113
+
114
+ async function execute(scenario, plugin, virtualClock) {
115
+ const clients = new Map(); // socketId → VirtualClient
116
+ const byParticipant = new Map(); // id сценария → { socketId, gameId }
117
+ const participantLog = []; // [{ who, socketId, gameId, joinTick, leaveTick }]
118
+ const mapChanges = [];
119
+ const inputSeq = new Map();
120
+
121
+ let currentTick = 0;
122
+
123
+ const socketManager = new RecordingSocketManager({
124
+ onFrame: frame => routeFrame(frame, clients, virtualClock, host),
125
+ });
126
+
127
+ // ping/pong: без ответа хост честно кикает участника по таймауту RTT, и
128
+ // длинный сценарий тихо теряет игроков посреди прогона
129
+ let host = null;
130
+
131
+ const room = {
132
+ ...scenario.room,
133
+ seed: scenario.seed,
134
+ map: scenario.map ?? undefined,
135
+ // wasmUrl — node-сборка ядра: createHostRuntime отдаёт его плагину
136
+ // тем же полем, что и Worker в браузере (у фикстуры его нет — её
137
+ // ядра обычный JS)
138
+ game: {
139
+ version: scenario.game?.version ?? null,
140
+ wasmUrl: plugin.wasmUrl,
141
+ },
142
+ };
143
+
144
+ const runtime = await createHostRuntime(room, {
145
+ loadHostPlugin: () => plugin.hostPlugin,
146
+ createSocketManager: () => socketManager,
147
+ overrideGameConfig: game => mergeConfig(game, scenario.config),
148
+ hostOptions: {
149
+ onMapChange: mapName =>
150
+ mapChanges.push({ tick: currentTick, map: mapName }),
151
+ playerDataFetch: emptyProfileFetch,
152
+ },
153
+ });
154
+
155
+ const { clientCfg, game, seed } = runtime;
156
+ const stepMs = game.timers.timeStep;
157
+
158
+ host = runtime.host;
159
+
160
+ // очередь операций сценария, разложенная по номеру тика: ввод хост
161
+ // применяет синхронно при приходе сообщения (HostGame.updateKeys), очереди
162
+ // у него нет — значит сценарий обязан задавать именно границу тика
163
+ const opsByTick = new Map();
164
+
165
+ for (const op of scenario.timeline) {
166
+ const tick = op.tick ?? 0;
167
+
168
+ if (!opsByTick.has(tick)) {
169
+ opsByTick.set(tick, []);
170
+ }
171
+
172
+ opsByTick.get(tick).push(op);
173
+ }
174
+
175
+ const dumpTicks = new Set(scenario.dumpTicks ?? [scenario.ticks]);
176
+ const scenes = [];
177
+
178
+ for (let tick = 0; tick <= scenario.ticks; tick += 1) {
179
+ currentTick = tick;
180
+ socketManager.tick = tick;
181
+
182
+ for (const op of opsByTick.get(tick) ?? []) {
183
+ await applyOp(op, {
184
+ host,
185
+ clientCfg,
186
+ plugin,
187
+ clients,
188
+ byParticipant,
189
+ participantLog,
190
+ inputSeq,
191
+ scenario,
192
+ virtualClock,
193
+ tick,
194
+ });
195
+ }
196
+
197
+ if (tick > 0) {
198
+ await virtualClock.advance(stepMs);
199
+ }
200
+
201
+ for (const client of clients.values()) {
202
+ client.render(virtualClock.monotonic());
203
+ }
204
+
205
+ if (dumpTicks.has(tick)) {
206
+ scenes.push({
207
+ tick,
208
+ // дамп мира ядра рядом со сценой клиентов: расхождение «в ядре тело
209
+ // есть, на холсте пусто» видно только при обеих половинах в одном
210
+ // файле (этап 4)
211
+ core: inspectCore(host),
212
+ clients: [...clients.values()].map(client => client.snapshot()),
213
+ });
214
+ }
215
+ }
216
+
217
+ return buildReport({
218
+ scenario,
219
+ plugin,
220
+ seed,
221
+ stepMs,
222
+ host,
223
+ core: runtime.core,
224
+ game,
225
+ clientCfg,
226
+ clients,
227
+ socketManager,
228
+ scenes,
229
+ mapChanges,
230
+ byParticipant,
231
+ participantLog,
232
+ });
233
+ }
234
+
235
+ // исходящий кадр хоста → клиент-получатель. sendShot несёт байты, которые в
236
+ // проде ушли бы в data channel — их и скармливаем клиентскому ядру
237
+ function routeFrame(frame, clients, virtualClock, host) {
238
+ const client = clients.get(frame.socketId);
239
+
240
+ if (!client) {
241
+ return;
242
+ }
243
+
244
+ // pong отвечается мгновенно: латентность headless-прогона равна нулю, и
245
+ // это единственная честная её модель
246
+ if (frame.method === 'sendPing') {
247
+ host?.updateRTT(client.gameId, frame.args[0]);
248
+ return;
249
+ }
250
+
251
+ if (frame.method === 'sendShot') {
252
+ client.pushFrame(frame.args[0], virtualClock.monotonic());
253
+ return;
254
+ }
255
+
256
+ if (frame.method === 'sendMap') {
257
+ client.setMap(frame.args[0]);
258
+ return;
259
+ }
260
+
261
+ const bucket = RECORDED_PORTS[frame.method];
262
+
263
+ if (bucket) {
264
+ client.record(bucket, frame.args[0]);
265
+ }
266
+ }
267
+
268
+ async function applyOp(op, ctx) {
269
+ const {
270
+ host,
271
+ clients,
272
+ byParticipant,
273
+ participantLog,
274
+ inputSeq,
275
+ virtualClock,
276
+ tick,
277
+ } = ctx;
278
+
279
+ switch (op.op) {
280
+ case 'join':
281
+ await joinParticipant(op, ctx);
282
+ break;
283
+
284
+ case 'leave': {
285
+ const entry = byParticipant.get(op.who);
286
+
287
+ if (entry) {
288
+ host.removeUser(entry.gameId);
289
+ clients.delete(entry.socketId);
290
+ byParticipant.delete(op.who);
291
+
292
+ const logged = participantLog.find(
293
+ item => item.who === op.who && item.leaveTick === null,
294
+ );
295
+
296
+ if (logged) {
297
+ logged.leaveTick = tick;
298
+ }
299
+ }
300
+
301
+ break;
302
+ }
303
+
304
+ case 'key': {
305
+ const entry = requireEntry(byParticipant, op.who);
306
+ const seq = (inputSeq.get(op.who) ?? 0) + 1;
307
+
308
+ inputSeq.set(op.who, seq);
309
+ host.updateKeys(entry.gameId, `${seq}:${op.action}:${op.name}`);
310
+
311
+ // клиент предсказывает тот же ввод — иначе дрейф предикта не с чем
312
+ // сравнивать (этап 5)
313
+ clients
314
+ .get(entry.socketId)
315
+ ?.core.apply_input(op.action, op.name, virtualClock.monotonic());
316
+
317
+ break;
318
+ }
319
+
320
+ case 'chat':
321
+ host.pushMessage(requireEntry(byParticipant, op.who).gameId, op.text);
322
+ break;
323
+
324
+ case 'vote':
325
+ host.parseVote(requireEntry(byParticipant, op.who).gameId, op.data);
326
+ break;
327
+
328
+ default:
329
+ throw new Error(`scenario: unknown op '${op.op}'`);
330
+ }
331
+
332
+ await flushMicrotasks();
333
+ }
334
+
335
+ async function joinParticipant(op, ctx) {
336
+ const {
337
+ host,
338
+ clientCfg,
339
+ plugin,
340
+ clients,
341
+ byParticipant,
342
+ participantLog,
343
+ scenario,
344
+ tick,
345
+ } = ctx;
346
+ const participant = scenario.participants.find(p => p.id === op.who);
347
+
348
+ if (!participant) {
349
+ throw new Error(`scenario: join references unknown participant '${op.who}'`);
350
+ }
351
+
352
+ const socketId = participant.socketId ?? `sock-${participant.id}`;
353
+ let gameId;
354
+
355
+ host.createUser(
356
+ { name: participant.name, model: participant.model },
357
+ socketId,
358
+ id => {
359
+ gameId = id;
360
+ },
361
+ );
362
+
363
+ await flushMicrotasks();
364
+
365
+ if (gameId === undefined) {
366
+ throw new Error(`scenario: host refused participant '${op.who}'`);
367
+ }
368
+
369
+ // клиент создаётся ДО онбординга: в браузере вкладка существует раньше
370
+ // карты, и MAP_DATA/keySet/панель должны дойти до ядра
371
+ const client = await VirtualClient.create({
372
+ clientPlugin: plugin.clientPlugin,
373
+ clientConfig: clientCfg,
374
+ wasmUrl: plugin.wasmUrl,
375
+ socketId,
376
+ gameId,
377
+ auth: { name: participant.name, model: participant.model },
378
+ divergence: scenario.divergence ?? undefined,
379
+ });
380
+
381
+ clients.set(socketId, client);
382
+
383
+ // онбординг клиента: карта → готовность → первый кадр (порт-машина
384
+ // Worker'а в headless-контуре не участвует)
385
+ host.sendMap(gameId);
386
+ host.mapReady(gameId);
387
+ host.firstShotReady(gameId);
388
+ byParticipant.set(op.who, { socketId, gameId });
389
+ participantLog.push({
390
+ who: op.who,
391
+ socketId,
392
+ gameId,
393
+ joinTick: tick,
394
+ leaveTick: null,
395
+ });
396
+
397
+ if (op.team) {
398
+ host.parseVote(gameId, ['teamChange', op.team]);
399
+ }
400
+ }
401
+
402
+ function requireEntry(byParticipant, who) {
403
+ const entry = byParticipant.get(who);
404
+
405
+ if (!entry) {
406
+ throw new Error(`scenario: participant '${who}' has not joined yet`);
407
+ }
408
+
409
+ return entry;
410
+ }
411
+
412
+ // точечное переопределение конфига игры сценарием: config.timers.* и
413
+ // config.<ключ верхнего уровня>
414
+ 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)) {
419
+ game[key] = { ...game[key], ...value };
420
+ } else {
421
+ game[key] = value;
422
+ }
423
+ }
424
+ }
425
+
426
+ function buildReport(ctx) {
427
+ const {
428
+ scenario,
429
+ plugin,
430
+ seed,
431
+ stepMs,
432
+ host,
433
+ core,
434
+ game,
435
+ clientCfg,
436
+ clients,
437
+ socketManager,
438
+ scenes,
439
+ mapChanges,
440
+ byParticipant,
441
+ participantLog,
442
+ } = ctx;
443
+
444
+ const frameCounts = {};
445
+
446
+ for (const frame of socketManager.frames) {
447
+ frameCounts[frame.method] = (frameCounts[frame.method] ?? 0) + 1;
448
+ }
449
+
450
+ const clientList = [...clients.values()];
451
+
452
+ // проверки идут по живым объектам прогона (сцена, реестр хоста, ядро), а
453
+ // не по сериализованному отчёту: половина инвариантов смотрит туда, чего
454
+ // в JSON нет вовсе
455
+ const invariants = checkInvariants({
456
+ scenario,
457
+ game,
458
+ clientConfig: clientCfg,
459
+ clients: clientList,
460
+ socketManager,
461
+ core,
462
+ hostState: inspectHost(host),
463
+ participantLog,
464
+ stepMs,
465
+ });
466
+
467
+ return {
468
+ scenario: {
469
+ seed: scenario.seed,
470
+ ticks: scenario.ticks,
471
+ map: scenario.map,
472
+ participants: scenario.participants.length,
473
+ timeline: scenario.timeline.length,
474
+ },
475
+ game: { id: plugin.id, source: plugin.source },
476
+ seed,
477
+ stepMs,
478
+ durationMs: stepMs * scenario.ticks,
479
+ currentMap: host.currentMap,
480
+ mapChanges,
481
+ participants: [...byParticipant.entries()].map(([id, entry]) => ({
482
+ id,
483
+ ...entry,
484
+ })),
485
+ participantLog,
486
+ invariants,
487
+ invariantSummary: summarize(invariants),
488
+ frameCounts,
489
+ clients: clientList.map(client => ({
490
+ ...client.snapshot(),
491
+ received: Object.fromEntries(
492
+ Object.entries(client.received).map(([kind, list]) => [
493
+ kind,
494
+ list.length,
495
+ ]),
496
+ ),
497
+ decodeErrors: client.decodeErrors,
498
+ // дрейф предикта: агрегаты за прогон + сами записи (этап 5)
499
+ divergence: client.divergenceStats && {
500
+ ...client.divergenceStats,
501
+ records: client.divergence,
502
+ truncated: client.truncated.divergence,
503
+ },
504
+ // сколько строк на ключ схемы доехало за весь прогон, а не к финалу:
505
+ // сущность могла появиться и исчезнуть между дампами
506
+ observedRows: Object.fromEntries(
507
+ Object.entries(client.observed).map(([key, stats]) => [
508
+ key,
509
+ stats.rows,
510
+ ]),
511
+ ),
512
+ })),
513
+ scenes,
514
+ // сырой поток кадров нужен самопроверке детерминизма (этап 3) —
515
+ // сравнивается побайтово
516
+ shotBytes: socketManager
517
+ .framesOf('sendShot')
518
+ .map(frame => Buffer.from(toBytes(frame.args[0])).toString('base64')),
519
+ snapshotSchema: game.snapshot,
520
+ };
521
+ }
522
+
523
+ function toBytes(value) {
524
+ if (value instanceof Uint8Array) {
525
+ return value;
526
+ }
527
+
528
+ return new Uint8Array(value);
529
+ }