vimp-engine 0.2.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 (50) 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/config/opcodes.js +3 -1
  7. package/src/devtools/RecordingSocketManager.js +118 -0
  8. package/src/devtools/ScenarioRunner.js +529 -0
  9. package/src/devtools/VirtualClient.js +363 -0
  10. package/src/devtools/VirtualClock.js +169 -0
  11. package/src/devtools/inspectHost.js +31 -0
  12. package/src/devtools/invariants.js +672 -0
  13. package/src/devtools/pluginLoader.js +101 -0
  14. package/src/devtools/report.js +202 -0
  15. package/src/devtools/resetHostSingletons.js +23 -0
  16. package/src/host/DebugRecorder.js +224 -0
  17. package/src/host/GameCoreAdapter.js +17 -0
  18. package/src/host/HostGame.js +129 -7
  19. package/src/host/host.worker.js +46 -47
  20. package/src/host/meta/SocketManager.js +15 -7
  21. package/src/host/meta/modules/Panel.js +7 -0
  22. package/src/host/meta/modules/PlayerDataSync.js +29 -5
  23. package/src/host/meta/modules/RTTManager.js +4 -2
  24. package/src/host/meta/modules/Stat.js +7 -0
  25. package/src/host/meta/modules/TimerManager.js +15 -7
  26. package/src/host/meta/modules/Vote.js +10 -1
  27. package/src/host/meta/modules/chat/Chat.js +7 -0
  28. package/src/host/meta/modules/chat/index.js +1 -1
  29. package/src/host/meta/player/HumanParticipant.js +2 -1
  30. package/src/lib/AbstractTimer.js +6 -4
  31. package/src/lib/applyRoomOverrides.js +6 -0
  32. package/src/lib/clientCoreConfig.js +4 -0
  33. package/src/lib/clock.js +53 -0
  34. package/src/lib/createHostRuntime.js +108 -0
  35. package/src/lib/reconstructHot.js +78 -0
  36. package/src/lib/validators.js +10 -0
  37. package/tests/fixtures/miniGame/client/fakeClientCore.js +304 -0
  38. package/tests/fixtures/miniGame/client/index.js +37 -0
  39. package/tests/fixtures/miniGame/client/parts/Actor.js +18 -0
  40. package/tests/fixtures/miniGame/client/parts/ActorRadar.js +7 -0
  41. package/tests/fixtures/miniGame/config/auth.js +41 -0
  42. package/tests/fixtures/miniGame/config/client.js +139 -0
  43. package/tests/fixtures/miniGame/config/game.js +148 -0
  44. package/tests/fixtures/miniGame/host/ScriptedManager.js +129 -0
  45. package/tests/fixtures/miniGame/host/createModules.js +6 -0
  46. package/tests/fixtures/miniGame/host/fakeCore.js +233 -0
  47. package/tests/fixtures/miniGame/host/index.js +29 -0
  48. package/tests/fixtures/miniGame/host/spawnCommand.js +14 -0
  49. package/tests/fixtures/miniGame/host/systemMessages.js +6 -0
  50. package/tests/fixtures/miniGame.contract.test.js +76 -0
@@ -0,0 +1,672 @@
1
+ import wsports from '../config/wsports.js';
2
+ import { SNAPSHOT_FORMAT_VERSION } from '../config/opcodes.js';
3
+
4
+ // Проверки контракта «движок ↔ игра» по итогам headless-прогона. Смысл
5
+ // ровно один: молчаливый отказ (чёрный холст, пустая панель, сущность,
6
+ // которая не спавнится) обязан становиться строкой текста с именем
7
+ // нарушенного контракта — иначе автору плагина (человеку или нейросети)
8
+ // не за что зацепиться.
9
+ //
10
+ // Проверка либо pass, либо fail, либо skip — skip означает «в этом прогоне
11
+ // нечего проверять» (например, детерминизм требует второго прогона) и
12
+ // никогда не маскирует нарушение.
13
+
14
+ export const PASS = 'pass';
15
+ export const FAIL = 'fail';
16
+ export const SKIP = 'skip';
17
+
18
+ // список проверок в порядке plan/ai-debug/stage_3.md
19
+ const CHECKS = [
20
+ [1, 'finiteValues', 'no NaN/Infinity in decoded fields and hot buffer'],
21
+ [2, 'snapshotKeysUsed', 'every snapshot key produced at least one row'],
22
+ [3, 'fieldWidths', 'decoded field count matches the schema'],
23
+ [4, 'frameFormat', 'frame version byte and decode_frame'],
24
+ [5, 'hotLayout', 'hot buffer traversal consumes exactly len floats'],
25
+ [6, 'panelContract', 'panel fields reach the client config'],
26
+ [7, 'renderCoverage', 'gameSets/entitiesOnCanvas cover every live key'],
27
+ [8, 'keyBindings', 'playerKeys ↔ client keysets ↔ scenario input'],
28
+ [9, 'predictionDrift', 'client prediction drift below the threshold'],
29
+ [10, 'roundLifecycle', 'round ends, winner announced, respawns happened'],
30
+ [11, 'actorLeak', 'players_data() matches the active participants'],
31
+ [12, 'determinism', 'two runs produce a byte-identical frame stream'],
32
+ ];
33
+
34
+ const TITLES = new Map(CHECKS.map(([id, name, title]) => [name, { id, title }]));
35
+
36
+ /**
37
+ * Выполняет проверки 1–11 по итогам прогона (12 — только по требованию,
38
+ * см. checkDeterminism).
39
+ * @param {Object} ctx
40
+ * @param {Object} ctx.scenario - Нормализованный сценарий.
41
+ * @param {Object} ctx.game - Конфиг игры хоста (после room-переопределений).
42
+ * @param {Object} ctx.clientConfig - CONFIG_DATA клиента.
43
+ * @param {Array} ctx.clients - VirtualClient'ы прогона.
44
+ * @param {Object} ctx.socketManager - RecordingSocketManager.
45
+ * @param {Object} ctx.core - Ядро игры хоста.
46
+ * @param {Object} ctx.hostState - Срез меты хоста (inspectHost).
47
+ * @param {Array} ctx.participantLog - [{ who, socketId, gameId, joinTick,
48
+ * leaveTick }].
49
+ * @param {number} ctx.stepMs
50
+ * @returns {Array<Object>} Результаты проверок.
51
+ */
52
+ export function checkInvariants(ctx) {
53
+ return [
54
+ finiteValues(ctx),
55
+ snapshotKeysUsed(ctx),
56
+ fieldWidths(ctx),
57
+ frameFormat(ctx),
58
+ hotLayout(ctx),
59
+ panelContract(ctx),
60
+ renderCoverage(ctx),
61
+ keyBindings(ctx),
62
+ predictionDrift(ctx),
63
+ roundLifecycle(ctx),
64
+ actorLeak(ctx),
65
+ result('determinism', SKIP, [], 'run with --determinism'),
66
+ ];
67
+ }
68
+
69
+ /**
70
+ * Инвариант 12: два прогона одного сценария обязаны совпасть побайтово.
71
+ * @param {Object} first - Отчёт первого прогона.
72
+ * @param {Object} second - Отчёт второго прогона.
73
+ * @returns {Object} Результат проверки.
74
+ */
75
+ export function checkDeterminism(first, second) {
76
+ const a = first.shotBytes;
77
+ const b = second.shotBytes;
78
+
79
+ if (a.length !== b.length) {
80
+ return result('determinism', FAIL, [
81
+ `frame count differs: ${a.length} vs ${b.length}`,
82
+ ]);
83
+ }
84
+
85
+ const index = a.findIndex((frame, i) => frame !== b[i]);
86
+
87
+ if (index !== -1) {
88
+ return result('determinism', FAIL, [
89
+ `frame #${index} differs between the two runs`,
90
+ ]);
91
+ }
92
+
93
+ return result('determinism', PASS, []);
94
+ }
95
+
96
+ /**
97
+ * Сводка по результатам проверок.
98
+ * @param {Array<Object>} results
99
+ * @returns {Object} { passed, failed, skipped, violations }.
100
+ */
101
+ export function summarize(results) {
102
+ return {
103
+ passed: results.filter(r => r.status === PASS).length,
104
+ failed: results.filter(r => r.status === FAIL).length,
105
+ skipped: results.filter(r => r.status === SKIP).length,
106
+ violations: results.reduce((sum, r) => sum + r.violations.length, 0),
107
+ };
108
+ }
109
+
110
+ // ***** проверки ***** //
111
+
112
+ // 1
113
+ function finiteValues({ clients }) {
114
+ const violations = [];
115
+
116
+ for (const client of clients) {
117
+ for (const item of client.nonFinite) {
118
+ violations.push(
119
+ `${client.socketId}: '${item.key}' id ${item.id} field #${item.index} is ${item.value}`,
120
+ );
121
+ }
122
+
123
+ if (client.truncated.nonFinite) {
124
+ violations.push(
125
+ `${client.socketId}: +${client.truncated.nonFinite} more non-finite value(s)`,
126
+ );
127
+ }
128
+ }
129
+
130
+ return verdict('finiteValues', violations);
131
+ }
132
+
133
+ // 2
134
+ function snapshotKeysUsed({ game, clients, scenario }) {
135
+ const declared = Object.keys(game.snapshot);
136
+ const unused = new Set(scenario.unusedSnapshotKeys);
137
+ const seen = new Set();
138
+
139
+ for (const client of clients) {
140
+ for (const [key, stats] of Object.entries(client.observed)) {
141
+ if (stats.rows) {
142
+ seen.add(key);
143
+ }
144
+ }
145
+ }
146
+
147
+ const violations = declared
148
+ .filter(key => !seen.has(key) && !unused.has(key))
149
+ .map(
150
+ key =>
151
+ `snapshot key '${key}' never produced a row — entity does not spawn, ` +
152
+ `key id mismatch, or declare it in scenario.unusedSnapshotKeys`,
153
+ );
154
+
155
+ for (const key of unused) {
156
+ if (seen.has(key)) {
157
+ violations.push(
158
+ `snapshot key '${key}' is declared unused but did produce rows`,
159
+ );
160
+ }
161
+ }
162
+
163
+ return verdict('snapshotKeysUsed', violations);
164
+ }
165
+
166
+ // 3
167
+ function fieldWidths({ game, clients }) {
168
+ const violations = [];
169
+
170
+ for (const client of clients) {
171
+ for (const [key, stats] of Object.entries(client.observed)) {
172
+ const schema = game.snapshot[key];
173
+
174
+ if (!schema) {
175
+ continue; // ключ вне схемы — это инвариант 7
176
+ }
177
+
178
+ for (const width of stats.widths) {
179
+ if (width !== schema.fields.length) {
180
+ violations.push(
181
+ `${client.socketId}: '${key}' decoded ${width} field(s), ` +
182
+ `schema declares ${schema.fields.length} ` +
183
+ `(${schema.fields.map(f => f.name).join(', ')})`,
184
+ );
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ return verdict('fieldWidths', violations);
191
+ }
192
+
193
+ // 4
194
+ function frameFormat({ clients, socketManager }) {
195
+ const violations = [];
196
+ const frames = socketManager.framesOf('sendShot');
197
+
198
+ if (!frames.length) {
199
+ return result('frameFormat', FAIL, ['the host sent no frames at all']);
200
+ }
201
+
202
+ for (const client of clients) {
203
+ for (const error of client.decodeErrors) {
204
+ violations.push(`${client.socketId}: push_frame threw — ${error.message}`);
205
+ }
206
+ }
207
+
208
+ const bytes = toBytes(frames[0].args[0]);
209
+
210
+ // фикстуры и экспериментальные ядра вправе не использовать движковый
211
+ // фрейминг — тогда проверять версию не по чему, но и молчать нельзя
212
+ if (bytes[0] !== wsports.server.SHOT_DATA) {
213
+ return result(
214
+ 'frameFormat',
215
+ violations.length ? FAIL : SKIP,
216
+ violations,
217
+ `frames do not start with the SHOT_DATA port byte ` +
218
+ `(${bytes[0]}) — the core uses its own framing`,
219
+ );
220
+ }
221
+
222
+ const versions = new Set(frames.map(frame => toBytes(frame.args[0])[1]));
223
+
224
+ for (const version of versions) {
225
+ if (version !== SNAPSHOT_FORMAT_VERSION) {
226
+ violations.push(
227
+ `frame version byte ${version} != SNAPSHOT_FORMAT_VERSION ` +
228
+ `${SNAPSHOT_FORMAT_VERSION} — the client drops such frames`,
229
+ );
230
+ }
231
+ }
232
+
233
+ // decode_frame — чистая распаковка того же кадра: 'null' означает, что
234
+ // кадр повреждён или собран не по формату
235
+ const client = clients[0];
236
+
237
+ if (client && typeof client.core.decode_frame === 'function') {
238
+ const decoded = client.core.decode_frame(bytes);
239
+
240
+ if (!decoded || decoded === 'null') {
241
+ violations.push('decode_frame() rejected the first frame');
242
+ }
243
+ }
244
+
245
+ return verdict('frameFormat', violations);
246
+ }
247
+
248
+ // 5
249
+ function hotLayout({ clients }) {
250
+ const violations = [];
251
+
252
+ for (const client of clients) {
253
+ for (const item of client.hotLayoutErrors) {
254
+ violations.push(
255
+ item.message
256
+ ? `${client.socketId}: ${item.message} (len ${item.len})`
257
+ : `${client.socketId}: hot buffer len ${item.len}, traversal consumed ` +
258
+ `${item.consumed} — record width or group order drifted`,
259
+ );
260
+ }
261
+
262
+ if (client.truncated.hotLayoutErrors) {
263
+ violations.push(
264
+ `${client.socketId}: +${client.truncated.hotLayoutErrors} more layout mismatch(es)`,
265
+ );
266
+ }
267
+ }
268
+
269
+ return verdict('hotLayout', violations);
270
+ }
271
+
272
+ // 6
273
+ function panelContract({ game, clientConfig, clients }) {
274
+ const fields = game.panel?.fields ?? {};
275
+ const clientPanel = clientConfig.modules?.panel ?? {};
276
+ const clientKeys = clientPanel.keys ?? {};
277
+ const clientNames = new Set((clientPanel.fields ?? []).map(f => f.name));
278
+ const violations = [];
279
+ const seenKeys = new Set();
280
+
281
+ for (const client of clients) {
282
+ for (const payload of client.received.panel) {
283
+ for (const entry of payload) {
284
+ seenKeys.add(String(entry).split(':')[0]);
285
+ }
286
+ }
287
+ }
288
+
289
+ for (const [name, spec] of Object.entries(fields)) {
290
+ if (!seenKeys.has(spec.key)) {
291
+ violations.push(
292
+ `panel field '${name}' (key '${spec.key}') never reached a client — no panelSet`,
293
+ );
294
+ }
295
+
296
+ // имя поля на клиенте своё (modules.panel.keys — это словарь
297
+ // «ключ провода → имя ячейки»), совпадать с именем поля хоста оно не
298
+ // обязано; контракт в том, что ключ вообще разложен в ячейку панели
299
+ const clientName = clientKeys[spec.key];
300
+
301
+ if (!clientName) {
302
+ violations.push(
303
+ `panel field '${name}' (key '${spec.key}') is missing from ` +
304
+ 'modules.panel.keys of the client config',
305
+ );
306
+ } else if (!clientNames.has(clientName)) {
307
+ violations.push(
308
+ `panel key '${spec.key}' maps to '${clientName}', which is missing ` +
309
+ 'from modules.panel.fields of the client config',
310
+ );
311
+ }
312
+ }
313
+
314
+ for (const key of seenKeys) {
315
+ if (!clientKeys[key]) {
316
+ violations.push(
317
+ `panel key '${key}' was sent but is missing from modules.panel.keys ` +
318
+ `of the client config — the client cannot render it`,
319
+ );
320
+ }
321
+ }
322
+
323
+ return verdict('panelContract', violations);
324
+ }
325
+
326
+ // 7
327
+ function renderCoverage({ clientConfig, clients }) {
328
+ const gameSets = clientConfig.parts.gameSets;
329
+ const entities = clientConfig.parts.entitiesOnCanvas;
330
+ const violations = [];
331
+
332
+ for (const key of liveKeys(clients)) {
333
+ const names = gameSets[key];
334
+
335
+ if (!names) {
336
+ violations.push(
337
+ `snapshot key '${key}' is live in frames but missing from parts.gameSets — black canvas`,
338
+ );
339
+ continue;
340
+ }
341
+
342
+ for (const name of names) {
343
+ if (!entities[name]) {
344
+ violations.push(
345
+ `part '${name}' (key '${key}') is missing from parts.entitiesOnCanvas`,
346
+ );
347
+ }
348
+ }
349
+ }
350
+
351
+ return verdict('renderCoverage', violations);
352
+ }
353
+
354
+ // 8
355
+ function keyBindings({ game, clientConfig, scenario }) {
356
+ const playerKeys = Object.keys(game.playerKeys ?? {});
357
+ const spectatorKeys = Object.values(game.spectatorKeys ?? {});
358
+ const known = new Set([...playerKeys, ...spectatorKeys]);
359
+ const keySets = clientConfig.modules?.controls?.keySetList ?? [];
360
+ const bound = new Set(keySets.flatMap(set => Object.values(set)));
361
+ const violations = [];
362
+
363
+ for (const name of bound) {
364
+ if (!known.has(name)) {
365
+ violations.push(
366
+ `client keyset binds '${name}', which is neither in playerKeys nor in spectatorKeys`,
367
+ );
368
+ }
369
+ }
370
+
371
+ for (const name of playerKeys) {
372
+ if (!bound.has(name)) {
373
+ violations.push(
374
+ `playerKeys declares '${name}', but no client keyset binds it — unreachable in the browser`,
375
+ );
376
+ }
377
+ }
378
+
379
+ for (const op of scenario.timeline) {
380
+ if (op.op === 'key' && !known.has(op.name)) {
381
+ violations.push(
382
+ `scenario sends key '${op.name}' at tick ${op.tick ?? 0}, which the host does not know`,
383
+ );
384
+ }
385
+ }
386
+
387
+ return verdict('keyBindings', violations);
388
+ }
389
+
390
+ // 9. Расхождение считает само ядро (client::divergence): предсказанное
391
+ // состояние своего актора снимается перед реконсиляцией и сравнивается с
392
+ // player-блоком кадра. Сопоставление идёт по времени кадра, а не по seq —
393
+ // предиктор переигрывает историю ввода от момента авторитетного состояния,
394
+ // и «тот же seq» на клиенте и на хосте означает разные моменты.
395
+ function predictionDrift({ clients }) {
396
+ const violations = [];
397
+ const tracked = clients.filter(client => client.divergenceStats);
398
+
399
+ if (!tracked.length) {
400
+ return result(
401
+ 'predictionDrift',
402
+ SKIP,
403
+ [],
404
+ 'the client core reports no divergence data',
405
+ );
406
+ }
407
+
408
+ const samples = tracked.reduce(
409
+ (sum, client) => sum + (client.divergenceStats.samples ?? 0),
410
+ 0,
411
+ );
412
+
413
+ if (!samples) {
414
+ return result(
415
+ 'predictionDrift',
416
+ SKIP,
417
+ [],
418
+ 'no authoritative player block reached a client in this run',
419
+ );
420
+ }
421
+
422
+ for (const client of tracked) {
423
+ for (const record of client.divergence) {
424
+ violations.push(`${client.socketId}: ${formatDivergence(record)}`);
425
+ }
426
+
427
+ const hidden =
428
+ (client.truncated.divergence ?? 0) +
429
+ (client.divergenceStats.dropped ?? 0);
430
+
431
+ if (hidden) {
432
+ violations.push(`${client.socketId}: +${hidden} more divergence record(s)`);
433
+ }
434
+ }
435
+
436
+ return result(
437
+ 'predictionDrift',
438
+ violations.length ? FAIL : PASS,
439
+ violations,
440
+ `${samples} reconciliation(s) compared by frame time, not by seq`,
441
+ );
442
+ }
443
+
444
+ // Компоненты player-блока — игровая раскладка, движок знает только их
445
+ // порядок, поэтому нарушение адресуется индексом.
446
+ function formatDivergence(record) {
447
+ const parts = (record.exceeded ?? []).map(
448
+ index =>
449
+ `#${index} Δ${record.delta[index]} > ${record.thresholds[index]} ` +
450
+ `(predicted ${record.predicted[index]}, authoritative ${record.authoritative[index]})`,
451
+ );
452
+ const replayed = record.replayed
453
+ ? `, replayed ${record.replayed.count} input(s) in ` +
454
+ `[${record.replayed.from}, ${record.replayed.to}]`
455
+ : '';
456
+
457
+ return (
458
+ `serverTime ${record.serverTime}, offset ${record.offset}, ` +
459
+ `source '${record.source}': ${parts.join('; ')}${replayed}`
460
+ );
461
+ }
462
+
463
+ // 10
464
+ function roundLifecycle({
465
+ game,
466
+ scenario,
467
+ socketManager,
468
+ participantLog,
469
+ hostState,
470
+ stepMs,
471
+ }) {
472
+ const teams = Object.keys(game.teams ?? {});
473
+ const roundEnds = socketManager.framesOf('sendRoundEnd');
474
+ const violations = [];
475
+
476
+ // участники не утекли: реестр хоста == тем, кто зашёл и не вышел
477
+ const stillIn = new Set(
478
+ participantLog.filter(p => p.leaveTick === null).map(p => String(p.gameId)),
479
+ );
480
+ const registered = new Set(hostState.humans.map(String));
481
+
482
+ for (const gameId of registered) {
483
+ if (!stillIn.has(gameId)) {
484
+ violations.push(
485
+ `participant ${gameId} left the scenario but is still in the host registry — leak`,
486
+ );
487
+ }
488
+ }
489
+
490
+ for (const gameId of stillIn) {
491
+ if (!registered.has(gameId)) {
492
+ violations.push(
493
+ `participant ${gameId} is in the scenario but vanished from the host registry`,
494
+ );
495
+ }
496
+ }
497
+
498
+ if (!roundEnds.length) {
499
+ return result(
500
+ 'roundLifecycle',
501
+ violations.length ? FAIL : SKIP,
502
+ violations,
503
+ 'the scenario contains no round end',
504
+ );
505
+ }
506
+
507
+ const restartTicks = Math.ceil((game.timers.roundRestartDelay ?? 0) / stepMs);
508
+ const roundStarts = socketManager
509
+ .framesOf('sendGameInform')
510
+ .filter(frame => frame.args[0] === 'roundStart');
511
+
512
+ for (const [tick, group] of groupByTick(roundEnds)) {
513
+ for (const frame of group) {
514
+ const winner = frame.args[0];
515
+
516
+ if (winner !== undefined && winner !== null && !teams.includes(winner)) {
517
+ violations.push(
518
+ `round end at tick ${tick} announces unknown winner '${winner}'`,
519
+ );
520
+ }
521
+ }
522
+
523
+ const notified = new Set(group.map(frame => frame.socketId));
524
+ const present = participantLog.filter(
525
+ p => p.joinTick <= tick && (p.leaveTick === null || p.leaveTick > tick),
526
+ );
527
+
528
+ for (const participant of present) {
529
+ if (!notified.has(participant.socketId)) {
530
+ violations.push(
531
+ `round end at tick ${tick} never reached '${participant.who}'`,
532
+ );
533
+ }
534
+ }
535
+
536
+ // респаун приезжает отложенно (roundRestartDelay) — требовать его можно
537
+ // только если прогон дожил до этого момента
538
+ if (tick + restartTicks <= scenario.ticks) {
539
+ const restarted = roundStarts.some(frame => frame.tick > tick);
540
+
541
+ if (!restarted) {
542
+ violations.push(
543
+ `no round start after the round end at tick ${tick} — respawns never happened`,
544
+ );
545
+ }
546
+ }
547
+ }
548
+
549
+ return verdict('roundLifecycle', violations);
550
+ }
551
+
552
+ // 11
553
+ function actorLeak({ core, hostState }) {
554
+ const violations = [];
555
+ let actors;
556
+
557
+ try {
558
+ actors = actorIds(core.players_data());
559
+ } catch (e) {
560
+ return result('actorLeak', FAIL, [`players_data() failed: ${e.message}`]);
561
+ }
562
+
563
+ const active = new Set(hostState.activeList.map(String));
564
+
565
+ for (const id of actors) {
566
+ if (!active.has(id)) {
567
+ violations.push(
568
+ `actor ${id} lives in the core but is not an active participant — leak`,
569
+ );
570
+ }
571
+ }
572
+
573
+ for (const id of active) {
574
+ if (!actors.has(id)) {
575
+ violations.push(
576
+ `participant ${id} is active but has no actor in the core — never spawned`,
577
+ );
578
+ }
579
+ }
580
+
581
+ return verdict('actorLeak', violations);
582
+ }
583
+
584
+ // ***** вспомогательное ***** //
585
+
586
+ function result(name, status, violations, note) {
587
+ const { id, title } = TITLES.get(name);
588
+
589
+ return { id, name, title, status, violations, ...(note ? { note } : {}) };
590
+ }
591
+
592
+ function verdict(name, violations) {
593
+ return result(name, violations.length ? FAIL : PASS, violations);
594
+ }
595
+
596
+ function liveKeys(clients) {
597
+ const keys = new Set();
598
+
599
+ for (const client of clients) {
600
+ for (const [key, stats] of Object.entries(client.observed)) {
601
+ if (stats.rows) {
602
+ keys.add(key);
603
+ }
604
+ }
605
+ }
606
+
607
+ return keys;
608
+ }
609
+
610
+ function groupByTick(frames) {
611
+ const groups = new Map();
612
+
613
+ for (const frame of frames) {
614
+ const tick = frame.tick ?? 0;
615
+
616
+ if (!groups.has(tick)) {
617
+ groups.set(tick, []);
618
+ }
619
+
620
+ groups.get(tick).push(frame);
621
+ }
622
+
623
+ return groups;
624
+ }
625
+
626
+ // players_data() — данные первого кадра (FIRST_SHOT_DATA), их клиент
627
+ // применяет как снапшот: { ключ схемы: { id: [поля] } }. Приняты и плоские
628
+ // формы (массив id, массив объектов с id, объект по id) — движок нигде не
629
+ // требует именно снапшот-формы.
630
+ function actorIds(json) {
631
+ const data = JSON.parse(json);
632
+ const ids = new Set();
633
+
634
+ if (Array.isArray(data)) {
635
+ for (const item of data) {
636
+ ids.add(String(item && typeof item === 'object' ? item.id : item));
637
+ }
638
+
639
+ return ids;
640
+ }
641
+
642
+ if (!data || typeof data !== 'object') {
643
+ return ids;
644
+ }
645
+
646
+ for (const [key, value] of Object.entries(data)) {
647
+ if (isSnapshotBlock(value)) {
648
+ for (const id of Object.keys(value)) {
649
+ ids.add(String(id));
650
+ }
651
+ } else {
652
+ ids.add(String(key));
653
+ }
654
+ }
655
+
656
+ return ids;
657
+ }
658
+
659
+ // блок снапшот-формы: словарь id → строка полей (массив) либо null-маркер.
660
+ // Отличает { "m1": { "3": [...] } } от плоского { "3": { x, y } }
661
+ function isSnapshotBlock(value) {
662
+ return (
663
+ value !== null &&
664
+ typeof value === 'object' &&
665
+ !Array.isArray(value) &&
666
+ Object.values(value).every(row => row === null || Array.isArray(row))
667
+ );
668
+ }
669
+
670
+ function toBytes(value) {
671
+ return value instanceof Uint8Array ? value : new Uint8Array(value);
672
+ }