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,145 @@
1
+ import { access, 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(
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
+
62
+ const nodeCore = core ?? manifest.entries.wasmNode ?? null;
63
+
64
+ if (!nodeCore) {
65
+ throw new Error(
66
+ `${manifestPath}: no node build of the core — add entries.wasmNode ` +
67
+ `to the manifest (core/pkg-node) or pass --core <path>`,
68
+ );
69
+ }
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
+
86
+ return {
87
+ id: manifest.id,
88
+ manifest,
89
+ hostPlugin,
90
+ clientPlugin,
91
+ wasmUrl: pathToFileURL(path.resolve(baseDir, nodeCore)).href,
92
+ source: manifestPath,
93
+ };
94
+ }
95
+
96
+ // фикстура даёт рабочий контур сразу, ещё до того как игра соберёт pkg-node:
97
+ // её ядра — обычный JS, поэтому wasmUrl не нужен
98
+ async function loadFixture(core) {
99
+ const [hostPlugin, clientPlugin] = await Promise.all([
100
+ import(new URL('host/index.js', FIXTURE_DIR).href).then(m => m.default),
101
+ import(new URL('client/index.js', FIXTURE_DIR).href).then(m => m.default),
102
+ ]);
103
+
104
+ return {
105
+ id: hostPlugin.id,
106
+ manifest: null,
107
+ hostPlugin,
108
+ clientPlugin,
109
+ wasmUrl: core ? pathToFileURL(path.resolve(core)).href : undefined,
110
+ source: 'fixture:miniGame',
111
+ };
112
+ }
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
+
129
+ function importDefault(baseDir, entry, assetsBase) {
130
+ const file = path.resolve(baseDir, stripBase(entry, assetsBase));
131
+
132
+ return import(pathToFileURL(file).href).then(module => module.default);
133
+ }
134
+
135
+ // entries.host/client — URL-ы, какими их видит браузер (`assetsBase` + путь
136
+ // внутри пакета). На диске этой базе соответствует каталог манифеста,
137
+ // поэтому абсолютный URL сначала обрезается до пути внутри пакета — иначе
138
+ // path.resolve увёл бы в корень файловой системы.
139
+ function stripBase(entry, assetsBase) {
140
+ if (assetsBase && entry.startsWith(assetsBase)) {
141
+ return entry.slice(assetsBase.length);
142
+ }
143
+
144
+ return entry.replace(/^\/+/, '');
145
+ }
@@ -0,0 +1,204 @@
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
+ // shotHashes — служебный материал самопроверки детерминизма (длина = число
23
+ // кадров матча), в читаемом отчёте ему делать нечего
24
+ const { scenes = [], shotHashes, ...rest } = report;
25
+
26
+ await Promise.all(
27
+ scenes.map(scene =>
28
+ writeFile(
29
+ path.join(runDir, `scene-${scene.tick}.json`),
30
+ `${JSON.stringify(scene, null, 2)}\n`,
31
+ ),
32
+ ),
33
+ );
34
+
35
+ await writeFile(
36
+ path.join(runDir, 'report.json'),
37
+ `${JSON.stringify({ ...rest, sceneTicks: scenes.map(s => s.tick) }, null, 2)}\n`,
38
+ );
39
+
40
+ await writeFile(path.join(runDir, 'report.md'), formatMarkdown(report));
41
+
42
+ return runDir;
43
+ }
44
+
45
+ /**
46
+ * Человекочитаемая сводка прогона.
47
+ * @param {Object} report
48
+ * @returns {string}
49
+ */
50
+ export function formatMarkdown(report) {
51
+ const lines = [];
52
+
53
+ lines.push(`# Simulation report — ${report.game.id}`);
54
+ lines.push('');
55
+ lines.push(`- game source: \`${report.game.source}\``);
56
+ lines.push(`- seed: \`${report.seed}\``);
57
+ lines.push(
58
+ `- ticks: ${report.scenario.ticks} × ${report.stepMs} ms = ${report.durationMs} ms`,
59
+ );
60
+ lines.push(`- map: \`${report.currentMap}\``);
61
+ lines.push(`- participants: ${report.participants.length}`);
62
+ lines.push('');
63
+
64
+ lines.push(...invariantSection(report));
65
+ lines.push(...worldSection(report));
66
+ lines.push(...driftSection(report));
67
+
68
+ lines.push('## Outgoing frames');
69
+ lines.push('');
70
+ lines.push('| method | count |');
71
+ lines.push('| --- | --- |');
72
+
73
+ for (const [method, count] of Object.entries(report.frameCounts).sort()) {
74
+ lines.push(`| ${method} | ${count} |`);
75
+ }
76
+
77
+ lines.push('');
78
+ lines.push('## Clients');
79
+ lines.push('');
80
+
81
+ for (const client of report.clients) {
82
+ const entities = Object.entries(client.entities)
83
+ .map(([key, ids]) => `${key}×${Object.keys(ids).length}`)
84
+ .join(', ');
85
+
86
+ lines.push(
87
+ `- \`${client.socketId}\` (gameId ${client.gameId}): ${client.frameCount} frames, ` +
88
+ `entities: ${entities || '(none)'}, camera: ${JSON.stringify(client.camera)}`,
89
+ );
90
+
91
+ for (const error of client.decodeErrors) {
92
+ lines.push(` - ⚠️ ${error.message}`);
93
+ }
94
+ }
95
+
96
+ if (report.mapChanges.length) {
97
+ lines.push('');
98
+ lines.push('## Map changes');
99
+ lines.push('');
100
+
101
+ for (const change of report.mapChanges) {
102
+ lines.push(`- tick ${change.tick}: \`${change.map}\``);
103
+ }
104
+ }
105
+
106
+ lines.push('');
107
+
108
+ return `${lines.join('\n')}\n`;
109
+ }
110
+
111
+ // Сводка дампа мира ядра последнего среза (этап 4): счётчики отвечают на
112
+ // «есть ли в мире то, чего нет на холсте» без открывания scene-*.json.
113
+ function worldSection(report) {
114
+ const scenes = report.scenes ?? [];
115
+ const last = [...scenes].reverse().find(scene => scene.core);
116
+
117
+ if (!last) {
118
+ return [];
119
+ }
120
+
121
+ const { core } = last;
122
+ const lines = ['## World (core dump)', ''];
123
+
124
+ lines.push(`Tick ${last.tick} — \`scene-${last.tick}.json\``);
125
+ lines.push('');
126
+ lines.push(`- bodies: ${core.bodies?.length ?? 0}`);
127
+ lines.push(`- colliders: ${core.colliders?.length ?? 0}`);
128
+ lines.push(`- map: ${core.map ? `\`${core.map.setId}\`` : '(none)'}`);
129
+ lines.push(
130
+ `- nav nodes: ${core.nav ? core.nav.nodes : '(no nav graph)'}` +
131
+ `, spatial entities: ${core.spatial?.entities ?? 0}`,
132
+ );
133
+ lines.push(
134
+ `- rng state: \`${core.rng?.state}\`, accumulator: ${core.step?.accumulator}`,
135
+ );
136
+ lines.push('');
137
+
138
+ return lines;
139
+ }
140
+
141
+ // Дрейф предикта (этап 5): максимум расхождения по компонентам player-блока
142
+ // за прогон и первые записи. Сопоставление — по времени кадра, а не по seq:
143
+ // предиктор переигрывает историю ввода от момента авторитетного состояния.
144
+ function driftSection(report) {
145
+ const tracked = report.clients.filter(client => client.divergence);
146
+
147
+ if (!tracked.length) {
148
+ return [];
149
+ }
150
+
151
+ const lines = ['## Prediction drift', ''];
152
+
153
+ for (const client of tracked) {
154
+ const drift = client.divergence;
155
+
156
+ lines.push(
157
+ `- \`${client.socketId}\`: ${drift.samples} reconciliation(s), ` +
158
+ `${drift.violations} over threshold, max |Δ| per component: ` +
159
+ `[${(drift.maxDelta ?? []).join(', ')}]`,
160
+ );
161
+
162
+ for (const record of drift.records ?? []) {
163
+ lines.push(
164
+ ` - serverTime ${record.serverTime} (source \`${record.source}\`): ` +
165
+ `Δ [${(record.delta ?? []).join(', ')}]`,
166
+ );
167
+ }
168
+ }
169
+
170
+ lines.push('');
171
+
172
+ return lines;
173
+ }
174
+
175
+ // Ради этой секции всё и затевалось: нарушенный контракт — строка с именем
176
+ // проверки, а не тишина.
177
+ function invariantSection(report) {
178
+ const results = report.invariants ?? [];
179
+
180
+ if (!results.length) {
181
+ return [];
182
+ }
183
+
184
+ const { passed, failed, skipped } = report.invariantSummary ?? {};
185
+ const lines = ['## Invariants', ''];
186
+
187
+ lines.push(`${passed} passed, ${failed} failed, ${skipped} skipped`);
188
+ lines.push('');
189
+
190
+ for (const check of results) {
191
+ const mark = { pass: '✅', fail: '❌', skip: '⏭️' }[check.status];
192
+ const note = check.note ? ` — ${check.note}` : '';
193
+
194
+ lines.push(`- ${mark} ${check.id}. \`${check.name}\`: ${check.title}${note}`);
195
+
196
+ for (const violation of check.violations) {
197
+ lines.push(` - ${violation}`);
198
+ }
199
+ }
200
+
201
+ lines.push('');
202
+
203
+ return lines;
204
+ }
@@ -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,227 @@
1
+ import clock from '../lib/clock.js';
2
+
3
+ // Рекордер живого матча (этап 6 плана plan/done/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.timers: верхний уровень
138
+ // config — это ключи конфига игры, и раньше запись подсовывала туда
139
+ // таймер в расчёте на неявный роутинг
140
+ config: this._networkSendRate
141
+ ? { timers: { networkSendRate: this._networkSendRate } }
142
+ : {},
143
+ participants: this._participants,
144
+ timeline: this._timeline,
145
+ ticks: this._tick,
146
+ dumpTicks: [this._tick],
147
+ // не поле формата: parseScenario его игнорирует, а человеку/нейросети
148
+ // нужно понимать, откуда взялся файл и не обрезан ли он
149
+ meta: {
150
+ source: 'browser',
151
+ recordedAt: this._startedAt,
152
+ stoppedAt: clock.now(),
153
+ droppedOps: this._droppedOps,
154
+ dt: this._dtStats(),
155
+ },
156
+ };
157
+
158
+ this._reset();
159
+
160
+ return scenario;
161
+ }
162
+
163
+ _reset() {
164
+ this._recording = false;
165
+ this._tick = 0;
166
+ this._seed = null;
167
+ this._map = null;
168
+ this._networkSendRate = null;
169
+ this._startedAt = null;
170
+ this._participants = [];
171
+ this._byGameId = new Map();
172
+ this._timeline = [];
173
+ this._dt = [];
174
+ this._droppedOps = 0;
175
+ this._droppedDt = 0;
176
+ }
177
+
178
+ _push(op) {
179
+ if (this._timeline.length >= this._limits.maxOps) {
180
+ this._droppedOps += 1;
181
+ return;
182
+ }
183
+
184
+ this._timeline.push({ tick: this._tick, ...op });
185
+ }
186
+
187
+ _pushFor(gameId, build) {
188
+ if (!this._recording) {
189
+ return;
190
+ }
191
+
192
+ const who = this._byGameId.get(gameId);
193
+
194
+ // участник вошёл до старта записи и не попал в participants — операция
195
+ // без адресата сломала бы реплей, поэтому не пишется вовсе
196
+ if (who !== undefined) {
197
+ this._push(build(who));
198
+ }
199
+ }
200
+
201
+ _dtStats() {
202
+ const samples = this._dt;
203
+
204
+ if (!samples.length) {
205
+ return { count: 0, dropped: this._droppedDt };
206
+ }
207
+
208
+ let min = samples[0];
209
+ let max = samples[0];
210
+ let sum = 0;
211
+
212
+ for (const value of samples) {
213
+ min = Math.min(min, value);
214
+ max = Math.max(max, value);
215
+ sum += value;
216
+ }
217
+
218
+ return {
219
+ count: samples.length,
220
+ dropped: this._droppedDt,
221
+ min,
222
+ max,
223
+ mean: sum / samples.length,
224
+ samples,
225
+ };
226
+ }
227
+ }
@@ -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);