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,101 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { assertEngineApiCompatible } from '../lib/gamePlugin.js';
5
+
6
+ // Поиск игры для headless-прогона. В браузере плагин грузится по URL из
7
+ // GameManifest мастера; в Node URL-ов нет, поэтому источников три, по
8
+ // убыванию точности:
9
+ //
10
+ // 1. --game <путь> — каталог пакета игры или прямо dist/manifest.json;
11
+ // 2. --core <путь> — переопределение node-сборки ядра (entries.wasmNode);
12
+ // 3. ничего — фикстура miniGame из этого репозитория.
13
+ //
14
+ // entries.wasmNode — новое ОПЦИОНАЛЬНОЕ поле манифеста: конвенция
15
+ // core/pkg-node/ уже описана в docs/ai/02-packaging.md, не хватало только
16
+ // пути в манифесте. Его отсутствие не ошибка — просто игра не прогоняется
17
+ // headless своим настоящим ядром.
18
+
19
+ const FIXTURE_DIR = new URL(
20
+ '../../tests/fixtures/miniGame/',
21
+ import.meta.url,
22
+ );
23
+
24
+ /**
25
+ * @param {Object} [options]
26
+ * @param {string} [options.game] - Путь к пакету игры или к манифесту.
27
+ * @param {string} [options.core] - Путь к node-сборке ядра (перекрывает
28
+ * entries.wasmNode манифеста).
29
+ * @returns {Promise<Object>} { id, hostPlugin, clientPlugin, wasmUrl, manifest,
30
+ * source }.
31
+ */
32
+ export async function loadGameForSim({ game = null, core = null } = {}) {
33
+ if (!game) {
34
+ return loadFixture(core);
35
+ }
36
+
37
+ const manifestPath = game.endsWith('.json')
38
+ ? path.resolve(game)
39
+ : path.resolve(game, 'dist/manifest.json');
40
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
41
+
42
+ assertEngineApiCompatible(manifest);
43
+
44
+ const baseDir = path.dirname(manifestPath);
45
+ const { assetsBase } = manifest;
46
+ const hostPlugin = await importDefault(baseDir, manifest.entries.host, assetsBase);
47
+ const clientPlugin = await importDefault(baseDir, manifest.entries.client, assetsBase);
48
+ const nodeCore = core ?? manifest.entries.wasmNode ?? null;
49
+
50
+ if (!nodeCore) {
51
+ throw new Error(
52
+ `${manifestPath}: no node build of the core — add entries.wasmNode ` +
53
+ `to the manifest (core/pkg-node) or pass --core <path>`,
54
+ );
55
+ }
56
+
57
+ return {
58
+ id: manifest.id,
59
+ manifest,
60
+ hostPlugin,
61
+ clientPlugin,
62
+ wasmUrl: pathToFileURL(path.resolve(baseDir, nodeCore)).href,
63
+ source: manifestPath,
64
+ };
65
+ }
66
+
67
+ // фикстура даёт рабочий контур сразу, ещё до того как игра соберёт pkg-node:
68
+ // её ядра — обычный JS, поэтому wasmUrl не нужен
69
+ async function loadFixture(core) {
70
+ const [hostPlugin, clientPlugin] = await Promise.all([
71
+ import(new URL('host/index.js', FIXTURE_DIR).href).then(m => m.default),
72
+ import(new URL('client/index.js', FIXTURE_DIR).href).then(m => m.default),
73
+ ]);
74
+
75
+ return {
76
+ id: hostPlugin.id,
77
+ manifest: null,
78
+ hostPlugin,
79
+ clientPlugin,
80
+ wasmUrl: core ? pathToFileURL(path.resolve(core)).href : undefined,
81
+ source: 'fixture:miniGame',
82
+ };
83
+ }
84
+
85
+ function importDefault(baseDir, entry, assetsBase) {
86
+ const file = path.resolve(baseDir, stripBase(entry, assetsBase));
87
+
88
+ return import(pathToFileURL(file).href).then(module => module.default);
89
+ }
90
+
91
+ // entries.host/client — URL-ы, какими их видит браузер (`assetsBase` + путь
92
+ // внутри пакета). На диске этой базе соответствует каталог манифеста,
93
+ // поэтому абсолютный URL сначала обрезается до пути внутри пакета — иначе
94
+ // path.resolve увёл бы в корень файловой системы.
95
+ function stripBase(entry, assetsBase) {
96
+ if (assetsBase && entry.startsWith(assetsBase)) {
97
+ return entry.slice(assetsBase.length);
98
+ }
99
+
100
+ return entry.replace(/^\/+/, '');
101
+ }
@@ -0,0 +1,202 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ // Отчёт прогона на диск: report.json (машинный), report.md (то, что читает
5
+ // нейросеть) и по файлу на каждый снятый срез сцены. Текстовый вердикт —
6
+ // главный смысл всей затеи: молчаливый отказ должен становиться строкой.
7
+
8
+ /**
9
+ * @param {Object} report - Результат runScenario (плюс invariants этапа 3).
10
+ * @param {Object} [options]
11
+ * @param {string} [options.outDir] - Корень для .debug/run-<ts>.
12
+ * @param {string} [options.stamp] - Метка прогона (по умолчанию — ISO-время).
13
+ * @returns {Promise<string>} Каталог прогона.
14
+ */
15
+ export async function writeReport(report, { outDir = '.debug', stamp } = {}) {
16
+ const runStamp = stamp ?? new Date().toISOString().replace(/[:.]/g, '-');
17
+ const runDir = path.resolve(outDir, `run-${runStamp}`);
18
+
19
+ await mkdir(runDir, { recursive: true });
20
+
21
+ // срезы сцены — отдельными файлами: они самые объёмные и читаются точечно
22
+ const { scenes = [], ...rest } = report;
23
+
24
+ await Promise.all(
25
+ scenes.map(scene =>
26
+ writeFile(
27
+ path.join(runDir, `scene-${scene.tick}.json`),
28
+ `${JSON.stringify(scene, null, 2)}\n`,
29
+ ),
30
+ ),
31
+ );
32
+
33
+ await writeFile(
34
+ path.join(runDir, 'report.json'),
35
+ `${JSON.stringify({ ...rest, sceneTicks: scenes.map(s => s.tick) }, null, 2)}\n`,
36
+ );
37
+
38
+ await writeFile(path.join(runDir, 'report.md'), formatMarkdown(report));
39
+
40
+ return runDir;
41
+ }
42
+
43
+ /**
44
+ * Человекочитаемая сводка прогона.
45
+ * @param {Object} report
46
+ * @returns {string}
47
+ */
48
+ export function formatMarkdown(report) {
49
+ const lines = [];
50
+
51
+ lines.push(`# Simulation report — ${report.game.id}`);
52
+ lines.push('');
53
+ lines.push(`- game source: \`${report.game.source}\``);
54
+ lines.push(`- seed: \`${report.seed}\``);
55
+ lines.push(
56
+ `- ticks: ${report.scenario.ticks} × ${report.stepMs} ms = ${report.durationMs} ms`,
57
+ );
58
+ lines.push(`- map: \`${report.currentMap}\``);
59
+ lines.push(`- participants: ${report.participants.length}`);
60
+ lines.push('');
61
+
62
+ lines.push(...invariantSection(report));
63
+ lines.push(...worldSection(report));
64
+ lines.push(...driftSection(report));
65
+
66
+ lines.push('## Outgoing frames');
67
+ lines.push('');
68
+ lines.push('| method | count |');
69
+ lines.push('| --- | --- |');
70
+
71
+ for (const [method, count] of Object.entries(report.frameCounts).sort()) {
72
+ lines.push(`| ${method} | ${count} |`);
73
+ }
74
+
75
+ lines.push('');
76
+ lines.push('## Clients');
77
+ lines.push('');
78
+
79
+ for (const client of report.clients) {
80
+ const entities = Object.entries(client.entities)
81
+ .map(([key, ids]) => `${key}×${Object.keys(ids).length}`)
82
+ .join(', ');
83
+
84
+ lines.push(
85
+ `- \`${client.socketId}\` (gameId ${client.gameId}): ${client.frameCount} frames, ` +
86
+ `entities: ${entities || '(none)'}, camera: ${JSON.stringify(client.camera)}`,
87
+ );
88
+
89
+ for (const error of client.decodeErrors) {
90
+ lines.push(` - ⚠️ ${error.message}`);
91
+ }
92
+ }
93
+
94
+ if (report.mapChanges.length) {
95
+ lines.push('');
96
+ lines.push('## Map changes');
97
+ lines.push('');
98
+
99
+ for (const change of report.mapChanges) {
100
+ lines.push(`- tick ${change.tick}: \`${change.map}\``);
101
+ }
102
+ }
103
+
104
+ lines.push('');
105
+
106
+ return `${lines.join('\n')}\n`;
107
+ }
108
+
109
+ // Сводка дампа мира ядра последнего среза (этап 4): счётчики отвечают на
110
+ // «есть ли в мире то, чего нет на холсте» без открывания scene-*.json.
111
+ function worldSection(report) {
112
+ const scenes = report.scenes ?? [];
113
+ const last = [...scenes].reverse().find(scene => scene.core);
114
+
115
+ if (!last) {
116
+ return [];
117
+ }
118
+
119
+ const { core } = last;
120
+ const lines = ['## World (core dump)', ''];
121
+
122
+ lines.push(`Tick ${last.tick} — \`scene-${last.tick}.json\``);
123
+ lines.push('');
124
+ lines.push(`- bodies: ${core.bodies?.length ?? 0}`);
125
+ lines.push(`- colliders: ${core.colliders?.length ?? 0}`);
126
+ lines.push(`- map: ${core.map ? `\`${core.map.setId}\`` : '(none)'}`);
127
+ lines.push(
128
+ `- nav nodes: ${core.nav ? core.nav.nodes : '(no nav graph)'}` +
129
+ `, spatial entities: ${core.spatial?.entities ?? 0}`,
130
+ );
131
+ lines.push(
132
+ `- rng state: \`${core.rng?.state}\`, accumulator: ${core.step?.accumulator}`,
133
+ );
134
+ lines.push('');
135
+
136
+ return lines;
137
+ }
138
+
139
+ // Дрейф предикта (этап 5): максимум расхождения по компонентам player-блока
140
+ // за прогон и первые записи. Сопоставление — по времени кадра, а не по seq:
141
+ // предиктор переигрывает историю ввода от момента авторитетного состояния.
142
+ function driftSection(report) {
143
+ const tracked = report.clients.filter(client => client.divergence);
144
+
145
+ if (!tracked.length) {
146
+ return [];
147
+ }
148
+
149
+ const lines = ['## Prediction drift', ''];
150
+
151
+ for (const client of tracked) {
152
+ const drift = client.divergence;
153
+
154
+ lines.push(
155
+ `- \`${client.socketId}\`: ${drift.samples} reconciliation(s), ` +
156
+ `${drift.violations} over threshold, max |Δ| per component: ` +
157
+ `[${(drift.maxDelta ?? []).join(', ')}]`,
158
+ );
159
+
160
+ for (const record of drift.records ?? []) {
161
+ lines.push(
162
+ ` - serverTime ${record.serverTime} (source \`${record.source}\`): ` +
163
+ `Δ [${(record.delta ?? []).join(', ')}]`,
164
+ );
165
+ }
166
+ }
167
+
168
+ lines.push('');
169
+
170
+ return lines;
171
+ }
172
+
173
+ // Ради этой секции всё и затевалось: нарушенный контракт — строка с именем
174
+ // проверки, а не тишина.
175
+ function invariantSection(report) {
176
+ const results = report.invariants ?? [];
177
+
178
+ if (!results.length) {
179
+ return [];
180
+ }
181
+
182
+ const { passed, failed, skipped } = report.invariantSummary ?? {};
183
+ const lines = ['## Invariants', ''];
184
+
185
+ lines.push(`${passed} passed, ${failed} failed, ${skipped} skipped`);
186
+ lines.push('');
187
+
188
+ for (const check of results) {
189
+ const mark = { pass: '✅', fail: '❌', skip: '⏭️' }[check.status];
190
+ const note = check.note ? ` — ${check.note}` : '';
191
+
192
+ lines.push(`- ${mark} ${check.id}. \`${check.name}\`: ${check.title}${note}`);
193
+
194
+ for (const violation of check.violations) {
195
+ lines.push(` - ${violation}`);
196
+ }
197
+ }
198
+
199
+ lines.push('');
200
+
201
+ return lines;
202
+ }
@@ -0,0 +1,23 @@
1
+ import { resetTimerManager } from '../host/meta/modules/TimerManager.js';
2
+ import { resetPanel } from '../host/meta/modules/Panel.js';
3
+ import { resetStat } from '../host/meta/modules/Stat.js';
4
+ import { resetVote } from '../host/meta/modules/Vote.js';
5
+ import { resetChat } from '../host/meta/modules/chat/index.js';
6
+
7
+ // Мета-модули хоста — синглтоны на модуль: в браузерной вкладке матч всегда
8
+ // один, и второй `new TimerManager(...)` молча возвращает первый. Для
9
+ // headless-прогона это ловушка: второй прогон в том же процессе получил бы
10
+ // таймеры первого (со ссылками на его HostGame и на уже выброшенные
11
+ // виртуальные часы) и не сделал бы ни одного тика — молча.
12
+ //
13
+ // Самопроверка детерминизма прогоняет сценарий дважды подряд, поэтому
14
+ // изоляция обязана быть явной.
15
+ export function resetHostSingletons() {
16
+ resetTimerManager();
17
+ resetPanel();
18
+ resetStat();
19
+ resetVote();
20
+ resetChat();
21
+ }
22
+
23
+ export default resetHostSingletons;
@@ -0,0 +1,224 @@
1
+ import clock from '../lib/clock.js';
2
+
3
+ // Рекордер живого матча (этап 6 плана plan/ai-debug): пишет то, что headless
4
+ // не воспроизводит — реальный WebRTC, PixiJS и живой ввод человека — ровно в
5
+ // формат сценария headless-runner'а (devtools/ScenarioRunner.js). Баг,
6
+ // пойманный человеком в браузере, догоняется потом `npm run sim:replay`
7
+ // без браузера и без человека.
8
+ //
9
+ // Worker-safe: только clock (Date/performance), никаких Node-глобалов.
10
+ // Включается флагом gameConfig.isDevMode — в проде HostGame его не создаёт.
11
+
12
+ // Кап записи: живой матч на 120 Гц за десять минут — 72k тиков, и без
13
+ // границы вкладка хоста платила бы памятью за бесконечную ленту. Переполнение
14
+ // не молчит: счётчики уезжают в meta сценария.
15
+ const DEFAULT_LIMITS = {
16
+ maxOps: 20000,
17
+ maxDtSamples: 4000,
18
+ };
19
+
20
+ export default class DebugRecorder {
21
+ /**
22
+ * @param {Object} [limits]
23
+ * @param {number} [limits.maxOps] - Предел операций таймлайна.
24
+ * @param {number} [limits.maxDtSamples] - Предел записанных шагов цикла.
25
+ */
26
+ constructor(limits = {}) {
27
+ this._limits = { ...DEFAULT_LIMITS, ...limits };
28
+ this._reset();
29
+ }
30
+
31
+ get isRecording() {
32
+ return this._recording;
33
+ }
34
+
35
+ // номер тика с начала записи: сценарий адресует ввод именно границей тика
36
+ // (очереди у HostGame нет, updateKeys применяется синхронно)
37
+ get tickCount() {
38
+ return this._tick;
39
+ }
40
+
41
+ /**
42
+ * Начинает запись. Уже вошедшие участники превращаются в join-операции
43
+ * нулевого тика — иначе реплей стартовал бы с пустой комнатой.
44
+ * @param {Object} params
45
+ * @param {number|null} params.seed - Seed мира (room.seed / 'ready').
46
+ * @param {string} params.map - Текущая карта.
47
+ * @param {number} [params.networkSendRate] - Частота отправки кадров.
48
+ * @param {Array} [params.participants] - [{ gameId, name, model, socketId,
49
+ * team }] — люди, уже находящиеся в комнате.
50
+ */
51
+ start({ seed = null, map = null, networkSendRate = null, participants = [] }) {
52
+ this._reset();
53
+
54
+ this._recording = true;
55
+ this._seed = seed;
56
+ this._map = map;
57
+ this._networkSendRate = networkSendRate;
58
+ this._startedAt = clock.now();
59
+
60
+ for (const participant of participants) {
61
+ this.noteJoin(participant);
62
+ }
63
+ }
64
+
65
+ // шаг игрового цикла: номер тика — единственная временная координата
66
+ // сценария, dt пишется справочно (runner крутит фиксированный timeStep)
67
+ tick(dt) {
68
+ if (!this._recording) {
69
+ return;
70
+ }
71
+
72
+ this._tick += 1;
73
+
74
+ if (this._dt.length < this._limits.maxDtSamples) {
75
+ this._dt.push(dt);
76
+ } else {
77
+ this._droppedDt += 1;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Вход участника. Идентификатор сценария выдаётся свой (p1, p2, …):
83
+ * gameId переиспользуется после выхода, и два разных человека получили бы
84
+ * в реплее одну запись.
85
+ * @param {Object} participant - { gameId, name, model, socketId, team }.
86
+ */
87
+ noteJoin({ gameId, name, model, socketId, team = null }) {
88
+ if (!this._recording) {
89
+ return;
90
+ }
91
+
92
+ const who = `p${this._participants.length + 1}`;
93
+
94
+ this._participants.push({ id: who, name, model, socketId });
95
+ this._byGameId.set(gameId, who);
96
+
97
+ this._push({ op: 'join', who, ...(team ? { team } : {}) });
98
+ }
99
+
100
+ noteLeave(gameId) {
101
+ const who = this._byGameId.get(gameId);
102
+
103
+ if (who === undefined) {
104
+ return;
105
+ }
106
+
107
+ this._byGameId.delete(gameId);
108
+ this._push({ op: 'leave', who });
109
+ }
110
+
111
+ noteKey(gameId, action, name) {
112
+ this._pushFor(gameId, who => ({ op: 'key', who, action, name }));
113
+ }
114
+
115
+ noteChat(gameId, text) {
116
+ this._pushFor(gameId, who => ({ op: 'chat', who, text }));
117
+ }
118
+
119
+ noteVote(gameId, data) {
120
+ this._pushFor(gameId, who => ({ op: 'vote', who, data }));
121
+ }
122
+
123
+ /**
124
+ * Останавливает запись и отдаёт готовый сценарий (parseScenario его
125
+ * принимает как есть).
126
+ * @returns {Object|null} Сценарий или null, если записи не было.
127
+ */
128
+ stop() {
129
+ if (!this._recording) {
130
+ return null;
131
+ }
132
+
133
+ const scenario = {
134
+ version: 1,
135
+ seed: this._seed,
136
+ map: this._map,
137
+ config: this._networkSendRate
138
+ ? { networkSendRate: this._networkSendRate }
139
+ : {},
140
+ participants: this._participants,
141
+ timeline: this._timeline,
142
+ ticks: this._tick,
143
+ dumpTicks: [this._tick],
144
+ // не поле формата: parseScenario его игнорирует, а человеку/нейросети
145
+ // нужно понимать, откуда взялся файл и не обрезан ли он
146
+ meta: {
147
+ source: 'browser',
148
+ recordedAt: this._startedAt,
149
+ stoppedAt: clock.now(),
150
+ droppedOps: this._droppedOps,
151
+ dt: this._dtStats(),
152
+ },
153
+ };
154
+
155
+ this._reset();
156
+
157
+ return scenario;
158
+ }
159
+
160
+ _reset() {
161
+ this._recording = false;
162
+ this._tick = 0;
163
+ this._seed = null;
164
+ this._map = null;
165
+ this._networkSendRate = null;
166
+ this._startedAt = null;
167
+ this._participants = [];
168
+ this._byGameId = new Map();
169
+ this._timeline = [];
170
+ this._dt = [];
171
+ this._droppedOps = 0;
172
+ this._droppedDt = 0;
173
+ }
174
+
175
+ _push(op) {
176
+ if (this._timeline.length >= this._limits.maxOps) {
177
+ this._droppedOps += 1;
178
+ return;
179
+ }
180
+
181
+ this._timeline.push({ tick: this._tick, ...op });
182
+ }
183
+
184
+ _pushFor(gameId, build) {
185
+ if (!this._recording) {
186
+ return;
187
+ }
188
+
189
+ const who = this._byGameId.get(gameId);
190
+
191
+ // участник вошёл до старта записи и не попал в participants — операция
192
+ // без адресата сломала бы реплей, поэтому не пишется вовсе
193
+ if (who !== undefined) {
194
+ this._push(build(who));
195
+ }
196
+ }
197
+
198
+ _dtStats() {
199
+ const samples = this._dt;
200
+
201
+ if (!samples.length) {
202
+ return { count: 0, dropped: this._droppedDt };
203
+ }
204
+
205
+ let min = samples[0];
206
+ let max = samples[0];
207
+ let sum = 0;
208
+
209
+ for (const value of samples) {
210
+ min = Math.min(min, value);
211
+ max = Math.max(max, value);
212
+ sum += value;
213
+ }
214
+
215
+ return {
216
+ count: samples.length,
217
+ dropped: this._droppedDt,
218
+ min,
219
+ max,
220
+ mean: sum / samples.length,
221
+ samples,
222
+ };
223
+ }
224
+ }
@@ -197,6 +197,23 @@ export default class GameCoreAdapter {
197
197
  );
198
198
  }
199
199
 
200
+ // ***** отладка ***** //
201
+
202
+ /**
203
+ * Курированный дамп мира ядра (тела, коллайдеры, карта, нав-граф,
204
+ * spatial-сетка, rng). Метод приезжает из движкового ABI-макроса, но
205
+ * ядро игры могло быть собрано против более старого движка — тогда
206
+ * null, а не исключение посреди прогона.
207
+ * @returns {Object|null}
208
+ */
209
+ debugJson() {
210
+ if (typeof this._core.debug_json !== 'function') {
211
+ return null;
212
+ }
213
+
214
+ return JSON.parse(this._core.debug_json());
215
+ }
216
+
200
217
  // бот ли участник (спавн/удаление в ядре различаются)
201
218
  _isScripted(gameId) {
202
219
  const participant = this._participants.get(gameId);