vimp-engine 0.4.0 → 0.6.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.
- package/bin/vimp-sim.js +59 -26
- package/package.json +1 -1
- package/src/config/lobby.js +1 -1
- package/src/devtools/RecordingSocketManager.js +106 -81
- package/src/devtools/ScenarioRunner.js +94 -23
- package/src/devtools/VirtualClient.js +48 -0
- package/src/devtools/builtinScenario.js +95 -0
- package/src/devtools/invariants.js +49 -7
- package/src/devtools/pluginLoader.js +75 -8
- package/src/devtools/report.js +4 -2
- package/src/host/DebugRecorder.js +5 -2
- package/src/host/HostGame.js +2 -2
- package/src/host/host.worker.js +1 -1
- package/src/host/meta/SocketManager.js +1 -1
- package/src/lib/AbstractTimer.js +12 -4
- package/src/lib/applyRoomOverrides.js +1 -1
- package/src/lib/createHostRuntime.js +1 -1
- package/src/lib/gamePlugin.js +25 -3
package/bin/vimp-sim.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { runScenario } from '../src/devtools/ScenarioRunner.js';
|
|
4
|
-
import {
|
|
4
|
+
import { builtinScenario } from '../src/devtools/builtinScenario.js';
|
|
5
|
+
import { loadGameForSim, isFixture } from '../src/devtools/pluginLoader.js';
|
|
5
6
|
import {
|
|
6
7
|
checkDeterminism,
|
|
7
8
|
summarize,
|
|
@@ -12,33 +13,37 @@ import { writeReport, formatMarkdown } from '../src/devtools/report.js';
|
|
|
12
13
|
// CLI headless-прогона: правка → npm run sim → текстовый вердикт, без
|
|
13
14
|
// браузера и без человека.
|
|
14
15
|
|
|
16
|
+
// ошибка разбора флагов печатается без стека — см. обработчик внизу файла
|
|
17
|
+
class UsageError extends Error {}
|
|
18
|
+
|
|
15
19
|
const USAGE = `Usage: vimp-sim [options]
|
|
16
20
|
|
|
17
21
|
--scenario <path> scenario JSON (default: built-in smoke scenario)
|
|
18
22
|
--game <path> game package directory or dist/manifest.json
|
|
19
|
-
--core <path> node build of the game core (overrides entries.wasmNode)
|
|
23
|
+
--core <path> node build of the game core (overrides entries.wasmNode);
|
|
24
|
+
only meaningful with --game — the built-in fixture has
|
|
25
|
+
no WASM core to override
|
|
20
26
|
--out <dir> report root (default: .debug)
|
|
21
27
|
--no-write print the report to stdout instead of writing files
|
|
22
28
|
--determinism run the scenario twice and compare the frame streams
|
|
23
29
|
--help
|
|
24
30
|
`;
|
|
25
31
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
};
|
|
32
|
+
// каждая строка с префиксом: дальше в stderr уходит список падений, и
|
|
33
|
+
// грепалки CI не должны путать предупреждение с ними
|
|
34
|
+
const BUILTIN_NOTICE =
|
|
35
|
+
'notice: the built-in smoke scenario is running against --game. It only\n' +
|
|
36
|
+
'notice: proves the loop closes on this plugin: key coverage (invariant 2)\n' +
|
|
37
|
+
'notice: and prediction drift (invariant 9) are skipped — both need a\n' +
|
|
38
|
+
'notice: scenario written for your game. See § Scenario format in the\n' +
|
|
39
|
+
"notice: engine's docs/en/debugging.md (github.com/lgick/vimp), then run\n" +
|
|
40
|
+
'notice: vimp-sim --scenario <file>.\n\n';
|
|
41
|
+
|
|
42
|
+
// типичная опечатка «--core без --game»: фикстурное ядро — обычный JS и
|
|
43
|
+
// wasmUrl не смотрит, поэтому прогон был бы зелёным, не тронув ядро игры
|
|
44
|
+
const STRAY_CORE_NOTICE =
|
|
45
|
+
'notice: --core without --game does nothing: the run falls back to the\n' +
|
|
46
|
+
"notice: built-in fixture, whose core is plain JS. Add --game <path>.\n\n";
|
|
42
47
|
|
|
43
48
|
async function main(argv) {
|
|
44
49
|
const args = parseArgs(argv);
|
|
@@ -48,17 +53,30 @@ async function main(argv) {
|
|
|
48
53
|
return 0;
|
|
49
54
|
}
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
if (args.core && !args.game) {
|
|
57
|
+
process.stderr.write(STRAY_CORE_NOTICE);
|
|
58
|
+
}
|
|
54
59
|
|
|
55
60
|
// плагин грузится один раз: второй прогон самопроверки детерминизма
|
|
56
|
-
// обязан идти на том же ядре, иначе он проверял бы загрузчик, а не
|
|
61
|
+
// обязан идти на том же ядре, иначе он проверял бы загрузчик, а не мир.
|
|
62
|
+
// Загрузка идёт до сборки сценария: встроенный берёт имена модели, команды
|
|
63
|
+
// и клавиши из gameConfig игры
|
|
57
64
|
const plugin = await loadGameForSim({ game: args.game, core: args.core });
|
|
58
|
-
|
|
65
|
+
|
|
66
|
+
if (!args.scenario && !isFixture(plugin)) {
|
|
67
|
+
process.stderr.write(BUILTIN_NOTICE);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const scenario = args.scenario
|
|
71
|
+
? JSON.parse(await readFile(args.scenario, 'utf8'))
|
|
72
|
+
: builtinScenario(plugin);
|
|
73
|
+
// хеши потока кадров собираются только под --determinism: их единственный
|
|
74
|
+
// потребитель — сравнение двух прогонов, а объём линеен по длине матча
|
|
75
|
+
const captureFrames = args.determinism === true;
|
|
76
|
+
const report = await runScenario(scenario, { plugin, captureFrames });
|
|
59
77
|
|
|
60
78
|
if (args.determinism) {
|
|
61
|
-
const second = await runScenario(scenario, { plugin });
|
|
79
|
+
const second = await runScenario(scenario, { plugin, captureFrames });
|
|
62
80
|
const check = checkDeterminism(report, second);
|
|
63
81
|
|
|
64
82
|
report.invariants = report.invariants.map(item =>
|
|
@@ -115,11 +133,20 @@ function parseArgs(argv) {
|
|
|
115
133
|
case '--core':
|
|
116
134
|
case '--out':
|
|
117
135
|
i += 1;
|
|
136
|
+
|
|
137
|
+
// «--game» последним аргументом (или перед следующим флагом) тихо
|
|
138
|
+
// уводил на фикстуру: прогон зелёный, игра не тронута. Путь,
|
|
139
|
+
// начинающийся с '--', тем самым запрещён — таких не бывает, а
|
|
140
|
+
// явный отказ лучше ENOENT про каталог '--determinism'
|
|
141
|
+
if (argv[i] === undefined || argv[i].startsWith('--')) {
|
|
142
|
+
throw new UsageError(`option '${arg}' needs a value\n\n${USAGE}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
118
145
|
args[arg.slice(2)] = argv[i];
|
|
119
146
|
break;
|
|
120
147
|
|
|
121
148
|
default:
|
|
122
|
-
throw new
|
|
149
|
+
throw new UsageError(`unknown option '${arg}'\n\n${USAGE}`);
|
|
123
150
|
}
|
|
124
151
|
}
|
|
125
152
|
|
|
@@ -131,7 +158,13 @@ main(process.argv.slice(2)).then(
|
|
|
131
158
|
process.exitCode = code;
|
|
132
159
|
},
|
|
133
160
|
error => {
|
|
134
|
-
|
|
161
|
+
// опечатка в флаге — не краш инструмента: стек тут лишний шум поверх
|
|
162
|
+
// USAGE, который пользователь и должен прочитать
|
|
163
|
+
process.stderr.write(
|
|
164
|
+
error instanceof UsageError
|
|
165
|
+
? `${error.message}\n`
|
|
166
|
+
: `${error.stack ?? error.message}\n`,
|
|
167
|
+
);
|
|
135
168
|
process.exitCode = 1;
|
|
136
169
|
},
|
|
137
170
|
);
|
package/package.json
CHANGED
package/src/config/lobby.js
CHANGED
|
@@ -57,7 +57,7 @@ export default {
|
|
|
57
57
|
maxDelay: 30000,
|
|
58
58
|
},
|
|
59
59
|
|
|
60
|
-
// приёмник выгрузок отладочного контура (этап 6 плана plan/ai-debug):
|
|
60
|
+
// приёмник выгрузок отладочного контура (этап 6 плана plan/done/ai-debug):
|
|
61
61
|
// маршрут поднимается мастером только в dev, в проде вернёт 404
|
|
62
62
|
debugReportUrl: '/debug/report',
|
|
63
63
|
|
|
@@ -1,99 +1,62 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
'
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
'sendVote',
|
|
22
|
-
'sendKeySet',
|
|
23
|
-
'sendPlayerDefaultShot',
|
|
24
|
-
'sendSpectatorDefaultShot',
|
|
25
|
-
'sendConsole',
|
|
26
|
-
'sendGameInform',
|
|
27
|
-
'sendRoundEnd',
|
|
28
|
-
'sendSoundCue',
|
|
29
|
-
'sendName',
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
class RecordingSocketManager {
|
|
1
|
+
import wsports from '../config/wsports.js';
|
|
2
|
+
import SocketManager from '../host/meta/SocketManager.js';
|
|
3
|
+
|
|
4
|
+
// Транспорт хоста, который вместо сети пишет исходящие вызовы в память.
|
|
5
|
+
// Наследует боевой SocketManager, а не копирует его отправителей: составные
|
|
6
|
+
// отправители (sendFirstShot и компания), нагрузка портов и их номера
|
|
7
|
+
// остаются одни на прод и на headless — ручная копия уже расходилась с
|
|
8
|
+
// оригиналом и стоила прогону keySet, панели и первого снапшота мира.
|
|
9
|
+
//
|
|
10
|
+
// Записывается сам вызов (метод + аргументы, как их видит хост), а нагрузка
|
|
11
|
+
// транспорта — тем, что настоящий код успел передать в _send/_sendBinary:
|
|
12
|
+
// проверки инвариантов рассуждают о семантике («roundEnd объявил победителя»),
|
|
13
|
+
// а VirtualClient — о том, что реально уехало бы в data channel.
|
|
14
|
+
|
|
15
|
+
// имена отправителей берутся из прототипа: ручной список неизбежно отстаёт
|
|
16
|
+
// от боевого класса, что уже случалось
|
|
17
|
+
const SENDER_METHODS = Object.getOwnPropertyNames(SocketManager.prototype)
|
|
18
|
+
.filter(name => name.startsWith('send'));
|
|
19
|
+
|
|
20
|
+
class RecordingSocketManager extends SocketManager {
|
|
33
21
|
/**
|
|
22
|
+
* @param {Object} [ports] - Карта портов host→client; по умолчанию боевая
|
|
23
|
+
* (движковые тесты меты создают транспорт без аргументов).
|
|
24
|
+
* @param {Object} [gameOpts] - Игровая параметризация ({ soundCues,
|
|
25
|
+
* initialVote }) — та же, что получает боевой SocketManager.
|
|
34
26
|
* @param {Object} [options]
|
|
35
27
|
* @param {Function} [options.onFrame] - Вызывается на каждый исходящий кадр
|
|
36
|
-
* ({ method, socketId, args }) — runner раздаёт их
|
|
37
|
-
* дожидаясь конца прогона.
|
|
28
|
+
* ({ method, socketId, args, tick, sent }) — runner раздаёт их
|
|
29
|
+
* VirtualClient'ам, не дожидаясь конца прогона.
|
|
38
30
|
*/
|
|
39
|
-
constructor({ onFrame = null } = {}) {
|
|
40
|
-
|
|
31
|
+
constructor(ports = wsports.server, gameOpts = {}, { onFrame = null } = {}) {
|
|
32
|
+
super(ports, gameOpts);
|
|
33
|
+
|
|
34
|
+
this.frames = []; // [{ method, socketId, args, tick, sent }]
|
|
41
35
|
|
|
42
36
|
// номер тика прогона: runner двигает его перед каждым шагом, кадры
|
|
43
37
|
// получают метку — без неё проверки жизненного цикла раунда не могут
|
|
44
38
|
// сказать «кому именно не доехал roundEnd на этом тике»
|
|
45
39
|
this.tick = 0;
|
|
46
40
|
this._onFrame = onFrame;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
41
|
+
|
|
42
|
+
// кадр, чьё тело сейчас исполняется: в него уходит нагрузка _send;
|
|
43
|
+
// составные отправители вкладываются друг в друга, поэтому это стек
|
|
44
|
+
// через локальную переменную в _record
|
|
45
|
+
this._current = null;
|
|
50
46
|
|
|
51
47
|
for (const method of SENDER_METHODS) {
|
|
52
48
|
this[method] = (socketId, ...args) => {
|
|
53
|
-
this._record(
|
|
49
|
+
this._record(method, socketId, args, () =>
|
|
50
|
+
SocketManager.prototype[method].call(this, socketId, ...args),
|
|
51
|
+
);
|
|
54
52
|
};
|
|
55
53
|
}
|
|
56
|
-
|
|
57
|
-
// Составные отправители боевого SocketManager сами дёргают панель,
|
|
58
|
-
// статистику и keySet. Плоская запись их не раскрывает — тогда клиент
|
|
59
|
-
// headless-прогона живёт с пустой панелью и без keySet, то есть предикт
|
|
60
|
-
// у него не включается никогда. Развёртка обязана повторять
|
|
61
|
-
// host/meta/SocketManager.js.
|
|
62
|
-
this.sendFirstShot = socketId => {
|
|
63
|
-
this._record({ method: 'sendFirstShot', socketId, args: [] });
|
|
64
|
-
this.sendStat(socketId, this._stat?.getFull());
|
|
65
|
-
this.sendPanel(socketId, this._panel?.getEmptyPanel());
|
|
66
|
-
this.sendKeySet(socketId, 0); // наблюдатель
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
this.sendPlayerDefaultShot = (socketId, gameId) => {
|
|
70
|
-
this._record({
|
|
71
|
-
method: 'sendPlayerDefaultShot',
|
|
72
|
-
socketId,
|
|
73
|
-
args: [gameId],
|
|
74
|
-
});
|
|
75
|
-
this.sendPanel(socketId, this._panel?.getFullPanel(gameId));
|
|
76
|
-
this.sendKeySet(socketId, 1); // играющий
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
this.sendSpectatorDefaultShot = socketId => {
|
|
80
|
-
this._record({ method: 'sendSpectatorDefaultShot', socketId, args: [] });
|
|
81
|
-
this.sendPanel(socketId, this._panel?.getEmptyPanel());
|
|
82
|
-
this.sendKeySet(socketId, 0);
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
injectServices(game, panel, stat) {
|
|
87
|
-
this._game = game;
|
|
88
|
-
this._panel = panel;
|
|
89
|
-
this._stat = stat;
|
|
90
54
|
}
|
|
91
55
|
|
|
92
|
-
addUser() {}
|
|
93
|
-
removeUser() {}
|
|
94
|
-
|
|
95
56
|
close(socketId, code, key, arr) {
|
|
96
|
-
this._record(
|
|
57
|
+
this._record('close', socketId, [code, key, arr], () =>
|
|
58
|
+
super.close(socketId, code, key, arr),
|
|
59
|
+
);
|
|
97
60
|
}
|
|
98
61
|
|
|
99
62
|
// все кадры указанного метода
|
|
@@ -101,14 +64,76 @@ class RecordingSocketManager {
|
|
|
101
64
|
return this.frames.filter(frame => frame.method === method);
|
|
102
65
|
}
|
|
103
66
|
|
|
104
|
-
|
|
67
|
+
clearFrames() {
|
|
105
68
|
this.frames.length = 0;
|
|
106
69
|
}
|
|
107
70
|
|
|
108
|
-
|
|
109
|
-
|
|
71
|
+
// сети нет: всё, что боевой код отдал бы сокету, оседает в текущем кадре
|
|
72
|
+
_send(socketId, port, data, reliable = true) {
|
|
73
|
+
this._payload({ port, data, reliable });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
_sendBinary(socketId, buffer, reliable) {
|
|
77
|
+
this._payload({ port: this._PORT_SHOT_DATA, data: buffer, reliable });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
_close(socketId, code, data) {
|
|
81
|
+
this._payload({ port: null, data: { code, data } });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Кадр публикуется в момент первой отправки — это и есть порядок провода:
|
|
85
|
+
// составной отправитель отдаёт свою нагрузку раньше вложенных вызовов
|
|
86
|
+
// (FIRST_SHOT_DATA → STAT → PANEL → KEYSET), и клиент прогона видит ту же
|
|
87
|
+
// последовательность, что и браузер. Публикация после invoke() отдавала бы
|
|
88
|
+
// родителя последним.
|
|
89
|
+
//
|
|
90
|
+
// Отсюда несущее допущение: у отправителя не больше ОДНОЙ собственной
|
|
91
|
+
// нагрузки (составные раскладываются во вложенные вызовы, а два _send в
|
|
92
|
+
// sendClear/sendTechInform — это ветки if/else). Нарушь его — и подписчик
|
|
93
|
+
// получит кадр, собранный наполовину, уже после решения о маршрутизации.
|
|
94
|
+
// Поэтому не молчим, а называем контракт.
|
|
95
|
+
_payload(entry) {
|
|
96
|
+
const frame = this._current;
|
|
97
|
+
|
|
98
|
+
if (!frame) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (frame.sent.length) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`RecordingSocketManager: '${frame.method}' sent a second payload ` +
|
|
105
|
+
`(port ${entry.port}) — a sender may hand the socket at most one, ` +
|
|
106
|
+
'otherwise the subscriber already routed a half-built frame; ' +
|
|
107
|
+
'split it into nested senders like sendFirstShot does',
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
frame.sent.push(entry);
|
|
112
|
+
this._emit(frame);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_record(method, socketId, args, invoke) {
|
|
116
|
+
const frame = { method, socketId, args, tick: this.tick, sent: [] };
|
|
117
|
+
const parent = this._current;
|
|
118
|
+
|
|
110
119
|
this.frames.push(frame);
|
|
120
|
+
this._current = frame;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
invoke();
|
|
124
|
+
} finally {
|
|
125
|
+
this._current = parent;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// отправитель без собственной нагрузки (составной вроде
|
|
129
|
+
// sendPlayerDefaultShot либо отфильтрованный прод-кодом незамапленный
|
|
130
|
+
// soundCue) всё равно доезжает до подписчика — но ровно один раз
|
|
131
|
+
if (!frame.sent.length) {
|
|
132
|
+
this._emit(frame);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
111
135
|
|
|
136
|
+
_emit(frame) {
|
|
112
137
|
if (this._onFrame) {
|
|
113
138
|
this._onFrame(frame);
|
|
114
139
|
}
|
|
@@ -4,7 +4,11 @@ import RecordingSocketManager from './RecordingSocketManager.js';
|
|
|
4
4
|
import VirtualClient from './VirtualClient.js';
|
|
5
5
|
import { loadGameForSim } from './pluginLoader.js';
|
|
6
6
|
import { resetHostSingletons } from './resetHostSingletons.js';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
checkInvariants,
|
|
9
|
+
summarize,
|
|
10
|
+
ALL_KEYS_UNAUDITED,
|
|
11
|
+
} from './invariants.js';
|
|
8
12
|
import { inspectCore, inspectHost } from './inspectHost.js';
|
|
9
13
|
|
|
10
14
|
// Прогон сценария целиком в одном Node-процессе: авторитетный хост
|
|
@@ -58,6 +62,21 @@ export function parseScenario(raw) {
|
|
|
58
62
|
const timeline = [...(raw.timeline ?? [])].sort(
|
|
59
63
|
(a, b) => (a.tick ?? 0) - (b.tick ?? 0),
|
|
60
64
|
);
|
|
65
|
+
const unusedSnapshotKeys = raw.unusedSnapshotKeys ?? [];
|
|
66
|
+
|
|
67
|
+
// строка тут легальна ровно одна: без проверки забытые скобки
|
|
68
|
+
// ("unusedSnapshotKeys": "w1") молча разложились бы в набор символов, и
|
|
69
|
+
// объявленный ключ всё равно попал бы в нарушения инварианта 2
|
|
70
|
+
if (
|
|
71
|
+
unusedSnapshotKeys !== ALL_KEYS_UNAUDITED &&
|
|
72
|
+
(!Array.isArray(unusedSnapshotKeys) ||
|
|
73
|
+
unusedSnapshotKeys.some(key => typeof key !== 'string'))
|
|
74
|
+
) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
'scenario: unusedSnapshotKeys must be an array of snapshot keys or ' +
|
|
77
|
+
`"${ALL_KEYS_UNAUDITED}"`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
61
80
|
|
|
62
81
|
return {
|
|
63
82
|
version: 1,
|
|
@@ -69,10 +88,11 @@ export function parseScenario(raw) {
|
|
|
69
88
|
timeline,
|
|
70
89
|
// ключи схемы, которых в этом сценарии заведомо не будет (инвариант 2):
|
|
71
90
|
// «сущность не спавнится» отличается от «сценарий её не трогает» только
|
|
72
|
-
// этим
|
|
73
|
-
|
|
91
|
+
// этим объявлением. '*' — сценарий не берётся судить покрытие ключей
|
|
92
|
+
// вовсе (встроенный смоук на чужой игре), и инвариант честно пропускается
|
|
93
|
+
unusedSnapshotKeys,
|
|
74
94
|
// пороги детектора рассинхрона предикта (инвариант 9); {} — дефолты
|
|
75
|
-
// ядра, null — детектор
|
|
95
|
+
// ядра, null — детектор выключен, и инвариант тоже пропускается
|
|
76
96
|
divergence: raw.divergence === null ? null : (raw.divergence ?? {}),
|
|
77
97
|
ticks: raw.ticks ?? 600,
|
|
78
98
|
dumpTicks: raw.dumpTicks ?? null,
|
|
@@ -89,6 +109,9 @@ export function parseScenario(raw) {
|
|
|
89
109
|
* детерминизма).
|
|
90
110
|
* @param {string} [options.gamePath] - Путь к пакету игры/манифесту.
|
|
91
111
|
* @param {string} [options.corePath] - Путь к node-сборке ядра.
|
|
112
|
+
* @param {boolean} [options.captureFrames] - Считать хеши потока кадров
|
|
113
|
+
* (нужны только самопроверке детерминизма — на длинном матче это лишние
|
|
114
|
+
* мегабайты в отчёте).
|
|
92
115
|
* @returns {Promise<Object>} Отчёт прогона.
|
|
93
116
|
*/
|
|
94
117
|
export async function runScenario(rawScenario, options = {}) {
|
|
@@ -105,13 +128,15 @@ export async function runScenario(rawScenario, options = {}) {
|
|
|
105
128
|
const restoreClock = virtualClock.install();
|
|
106
129
|
|
|
107
130
|
try {
|
|
108
|
-
return await execute(scenario, plugin, virtualClock
|
|
131
|
+
return await execute(scenario, plugin, virtualClock, {
|
|
132
|
+
captureFrames: options.captureFrames === true,
|
|
133
|
+
});
|
|
109
134
|
} finally {
|
|
110
135
|
restoreClock();
|
|
111
136
|
}
|
|
112
137
|
}
|
|
113
138
|
|
|
114
|
-
async function execute(scenario, plugin, virtualClock) {
|
|
139
|
+
async function execute(scenario, plugin, virtualClock, { captureFrames }) {
|
|
115
140
|
const clients = new Map(); // socketId → VirtualClient
|
|
116
141
|
const byParticipant = new Map(); // id сценария → { socketId, gameId }
|
|
117
142
|
const participantLog = []; // [{ who, socketId, gameId, joinTick, leaveTick }]
|
|
@@ -120,13 +145,11 @@ async function execute(scenario, plugin, virtualClock) {
|
|
|
120
145
|
|
|
121
146
|
let currentTick = 0;
|
|
122
147
|
|
|
123
|
-
const socketManager = new RecordingSocketManager({
|
|
124
|
-
onFrame: frame => routeFrame(frame, clients, virtualClock, host),
|
|
125
|
-
});
|
|
126
|
-
|
|
127
148
|
// ping/pong: без ответа хост честно кикает участника по таймауту RTT, и
|
|
128
|
-
// длинный сценарий тихо теряет игроков посреди
|
|
149
|
+
// длинный сценарий тихо теряет игроков посреди прогона. Объявлено до
|
|
150
|
+
// транспорта — замыкание onFrame читает эту переменную.
|
|
129
151
|
let host = null;
|
|
152
|
+
let socketManager = null;
|
|
130
153
|
|
|
131
154
|
const room = {
|
|
132
155
|
...scenario.room,
|
|
@@ -143,7 +166,15 @@ async function execute(scenario, plugin, virtualClock) {
|
|
|
143
166
|
|
|
144
167
|
const runtime = await createHostRuntime(room, {
|
|
145
168
|
loadHostPlugin: () => plugin.hostPlugin,
|
|
146
|
-
|
|
169
|
+
// транспорт собирается той же фабрикой, что и боевой: наследник обязан
|
|
170
|
+
// получить те же порты и ту же игровую параметризацию
|
|
171
|
+
createSocketManager: (ports, gameOpts) => {
|
|
172
|
+
socketManager = new RecordingSocketManager(ports, gameOpts, {
|
|
173
|
+
onFrame: frame => routeFrame(frame, clients, virtualClock, host),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
return socketManager;
|
|
177
|
+
},
|
|
147
178
|
overrideGameConfig: game => mergeConfig(game, scenario.config),
|
|
148
179
|
hostOptions: {
|
|
149
180
|
onMapChange: mapName =>
|
|
@@ -229,6 +260,7 @@ async function execute(scenario, plugin, virtualClock) {
|
|
|
229
260
|
mapChanges,
|
|
230
261
|
byParticipant,
|
|
231
262
|
participantLog,
|
|
263
|
+
captureFrames,
|
|
232
264
|
});
|
|
233
265
|
}
|
|
234
266
|
|
|
@@ -253,6 +285,21 @@ function routeFrame(frame, clients, virtualClock, host) {
|
|
|
253
285
|
return;
|
|
254
286
|
}
|
|
255
287
|
|
|
288
|
+
// первый снапшот мира: в браузере он применяется сразу (applyShot), а не
|
|
289
|
+
// едет через буфер интерполяции — сущность, доехавшая только им, иначе не
|
|
290
|
+
// попадёт ни в сцену, ни в проверки
|
|
291
|
+
if (frame.method === 'sendFirstShot') {
|
|
292
|
+
client.applyFirstShot(frame.sent[0]?.data);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// CLEAR приходит на каждый рестарт раунда и на смену карты; без него
|
|
297
|
+
// headless живёт через границу раунда в состоянии, которого в игре нет
|
|
298
|
+
if (frame.method === 'sendClear') {
|
|
299
|
+
client.clear(frame.args[0]);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
256
303
|
if (frame.method === 'sendMap') {
|
|
257
304
|
client.setMap(frame.args[0]);
|
|
258
305
|
return;
|
|
@@ -286,6 +333,10 @@ async function applyOp(op, ctx) {
|
|
|
286
333
|
|
|
287
334
|
if (entry) {
|
|
288
335
|
host.removeUser(entry.gameId);
|
|
336
|
+
|
|
337
|
+
// ядро клиента живёт в WASM-памяти: без free многочасовой реплей с
|
|
338
|
+
// текучкой участников растит процесс
|
|
339
|
+
clients.get(entry.socketId)?.destroy();
|
|
289
340
|
clients.delete(entry.socketId);
|
|
290
341
|
byParticipant.delete(op.who);
|
|
291
342
|
|
|
@@ -409,13 +460,16 @@ function requireEntry(byParticipant, who) {
|
|
|
409
460
|
return entry;
|
|
410
461
|
}
|
|
411
462
|
|
|
412
|
-
// точечное переопределение конфига игры
|
|
413
|
-
// config
|
|
463
|
+
// точечное переопределение конфига игры сценарием. Таймеры — только через
|
|
464
|
+
// config.timers: неявный роутинг одноимённого ключа верхнего уровня в
|
|
465
|
+
// game.timers молча правил не то, что просил сценарий.
|
|
414
466
|
function mergeConfig(game, overrides) {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
467
|
+
const { timers = {}, ...rest } = overrides;
|
|
468
|
+
|
|
469
|
+
Object.assign(game.timers, timers);
|
|
470
|
+
|
|
471
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
472
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
419
473
|
game[key] = { ...game[key], ...value };
|
|
420
474
|
} else {
|
|
421
475
|
game[key] = value;
|
|
@@ -439,6 +493,7 @@ function buildReport(ctx) {
|
|
|
439
493
|
mapChanges,
|
|
440
494
|
byParticipant,
|
|
441
495
|
participantLog,
|
|
496
|
+
captureFrames,
|
|
442
497
|
} = ctx;
|
|
443
498
|
|
|
444
499
|
const frameCounts = {};
|
|
@@ -511,11 +566,14 @@ function buildReport(ctx) {
|
|
|
511
566
|
),
|
|
512
567
|
})),
|
|
513
568
|
scenes,
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
569
|
+
// поток кадров нужен ровно одному потребителю — самопроверке
|
|
570
|
+
// детерминизма (этап 3). Хеша на кадр для сравнения потоков достаточно,
|
|
571
|
+
// а сами байты линейно от длины матча раздували report.json
|
|
572
|
+
shotHashes: captureFrames
|
|
573
|
+
? socketManager
|
|
574
|
+
.framesOf('sendShot')
|
|
575
|
+
.map(frame => hash32(toBytes(frame.args[0])))
|
|
576
|
+
: null,
|
|
519
577
|
snapshotSchema: game.snapshot,
|
|
520
578
|
};
|
|
521
579
|
}
|
|
@@ -527,3 +585,16 @@ function toBytes(value) {
|
|
|
527
585
|
|
|
528
586
|
return new Uint8Array(value);
|
|
529
587
|
}
|
|
588
|
+
|
|
589
|
+
// FNV-1a: сравниваются потоки одного и того же прогона, криптостойкость не
|
|
590
|
+
// нужна — нужна дешевизна и отсутствие зависимостей
|
|
591
|
+
function hash32(bytes) {
|
|
592
|
+
let hash = 0x811c9dc5;
|
|
593
|
+
|
|
594
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
595
|
+
hash ^= bytes[i];
|
|
596
|
+
hash = Math.imul(hash, 0x01000193);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return hash >>> 0;
|
|
600
|
+
}
|
|
@@ -141,6 +141,48 @@ class VirtualClient {
|
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* FIRST_SHOT_DATA: первый снапшот мира. В браузере применяется немедленно
|
|
146
|
+
* (applyShot), в буфер интерполяции не попадает — сущность, доехавшая
|
|
147
|
+
* только им, иначе не появится в сцене вовсе.
|
|
148
|
+
* @param {Array} [data] - [gameSnapshot, camera, serverTime, seq].
|
|
149
|
+
*/
|
|
150
|
+
applyFirstShot(data) {
|
|
151
|
+
if (!Array.isArray(data)) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const [game, camera] = data;
|
|
156
|
+
|
|
157
|
+
if (game) {
|
|
158
|
+
this._applyGameData(game);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (camera && camera !== 0) {
|
|
162
|
+
this.camera = [camera[0], camera[1]];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* CLEAR: зеркало client/main.js — снятие сущностей с «холста» и сброс ядра
|
|
168
|
+
* (буфер интерполяции + предикт). Хост шлёт его на каждый рестарт раунда
|
|
169
|
+
* и на смену карты; без него headless живёт через границу раунда в
|
|
170
|
+
* состоянии, которого в браузере не бывает.
|
|
171
|
+
* @param {Array} [setIdList] - Ключи схемы к снятию; без него — всё.
|
|
172
|
+
*/
|
|
173
|
+
clear(setIdList) {
|
|
174
|
+
if (Array.isArray(setIdList)) {
|
|
175
|
+
for (const setId of setIdList) {
|
|
176
|
+
delete this.scene[setId];
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
this.scene = {};
|
|
180
|
+
this.camera = null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this.core.reset?.();
|
|
184
|
+
}
|
|
185
|
+
|
|
144
186
|
/**
|
|
145
187
|
* MAP_DATA: мир raycast в ядре и сброс предикта — зеркало main.js.
|
|
146
188
|
* @param {Object} data - Данные карты, как их шлёт хост.
|
|
@@ -242,6 +284,12 @@ class VirtualClient {
|
|
|
242
284
|
return JSON.parse(this.core.debug_json());
|
|
243
285
|
}
|
|
244
286
|
|
|
287
|
+
// Ядро живёт в памяти WASM: вышедший участник обязан её отдать, иначе
|
|
288
|
+
// длинный реплей с текучкой игроков растит процесс
|
|
289
|
+
destroy() {
|
|
290
|
+
this.core.free?.();
|
|
291
|
+
}
|
|
292
|
+
|
|
245
293
|
// Записи детектора рассинхрона предикта из ядра. Агрегаты накопительные,
|
|
246
294
|
// поэтому просто перезаписываются; записи — вычерпываются.
|
|
247
295
|
_drainDivergence() {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { ALL_KEYS_UNAUDITED } from './invariants.js';
|
|
2
|
+
import { isFixture } from './pluginLoader.js';
|
|
3
|
+
|
|
4
|
+
// Встроенный сценарий headless-прогона: один участник заходит, едет вперёд,
|
|
5
|
+
// отпускает клавишу. Этого хватает, чтобы контур доказал, что он замкнут
|
|
6
|
+
// (хост → кадр → клиентское ядро → сцена), и не хватает ни на что больше.
|
|
7
|
+
//
|
|
8
|
+
// Идентификаторы берутся из gameConfig самой игры: имя модели, команды и
|
|
9
|
+
// клавиши — часть игры, а не движка. Захардкоженные фикстурные 'm1' /
|
|
10
|
+
// 'team1' / 'forward' на чужом плагине означают падение в ядре («unknown
|
|
11
|
+
// model») либо красный инвариант 8 у исправного плагина.
|
|
12
|
+
|
|
13
|
+
// Ввод начинается на 40-м тике, а не сразу: кадр спавна с force_reset
|
|
14
|
+
// приходит примерно на interpolation.delay (движковый дефолт — 100 мс, тут
|
|
15
|
+
// ~3× запаса) позже входа и чистит удержанные клавиши предиктора
|
|
16
|
+
// (docs/en/debugging.md) — встроенный сценарий не должен демонстрировать
|
|
17
|
+
// ровно ту ловушку, от которой предостерегает документация
|
|
18
|
+
const INPUT_DOWN_TICK = 40;
|
|
19
|
+
const INPUT_UP_TICK = 100;
|
|
20
|
+
const TICKS = 120;
|
|
21
|
+
|
|
22
|
+
// событийный ключ фикстуры в этом сценарии не стреляет — объявлено явно,
|
|
23
|
+
// иначе инвариант 2 честно посчитает это «сущность не спавнится»
|
|
24
|
+
const FIXTURE_UNUSED_KEYS = ['e1'];
|
|
25
|
+
|
|
26
|
+
const firstKey = dict => Object.keys(dict ?? {})[0];
|
|
27
|
+
|
|
28
|
+
// Клавиша для смоука обязана быть удерживаемой: по конвенции playerKeys
|
|
29
|
+
// (docs/ai/04-client-plugin.md) `type: 1` — триггер, у которого 'up'
|
|
30
|
+
// игнорируется, и «нажал — отпустил» не даёт ни удержания, ни движения.
|
|
31
|
+
// Порядок объявления в playerKeys контрактом не задан, поэтому берём первую
|
|
32
|
+
// без type; если игра объявила одни триггеры — первую любую, смоук всё равно
|
|
33
|
+
// должен запуститься. Движок сам type не интерпретирует (это дело ядра
|
|
34
|
+
// игры) — здесь это эвристика выбора, а не поведение движка.
|
|
35
|
+
const heldKey = playerKeys => {
|
|
36
|
+
const keys = Object.entries(playerKeys ?? {});
|
|
37
|
+
|
|
38
|
+
return (keys.find(([, spec]) => !spec?.type) ?? keys[0])?.[0];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Собирает встроенный смоук-сценарий под конкретную игру.
|
|
43
|
+
* @param {Object} plugin - Результат loadGameForSim.
|
|
44
|
+
* @returns {Object} Сценарий в формате runScenario.
|
|
45
|
+
*/
|
|
46
|
+
export function builtinScenario(plugin) {
|
|
47
|
+
const config = plugin.hostPlugin.gameConfig;
|
|
48
|
+
const model = firstKey(config.parts?.models);
|
|
49
|
+
const key = heldKey(config.playerKeys);
|
|
50
|
+
const team = Object.keys(config.teams ?? {}).find(
|
|
51
|
+
name => name !== config.spectatorTeam,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (!model || !key || !team) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`game "${plugin.id}": the built-in scenario has nothing to drive ` +
|
|
57
|
+
`(model: ${model ?? '—'}, playable team: ${team ?? '—'}, ` +
|
|
58
|
+
`player key: ${key ?? '—'}) — write a scenario for this game and ` +
|
|
59
|
+
`pass --scenario <file>`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const scenario = {
|
|
64
|
+
version: 1,
|
|
65
|
+
seed: 3812,
|
|
66
|
+
participants: [{ id: 'p1', name: 'P1', model }],
|
|
67
|
+
timeline: [
|
|
68
|
+
{ tick: 0, op: 'join', who: 'p1', team },
|
|
69
|
+
{
|
|
70
|
+
tick: INPUT_DOWN_TICK,
|
|
71
|
+
op: 'key',
|
|
72
|
+
who: 'p1',
|
|
73
|
+
action: 'down',
|
|
74
|
+
name: key,
|
|
75
|
+
},
|
|
76
|
+
{ tick: INPUT_UP_TICK, op: 'key', who: 'p1', action: 'up', name: key },
|
|
77
|
+
],
|
|
78
|
+
ticks: TICKS,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (isFixture(plugin)) {
|
|
82
|
+
return { ...scenario, unusedSnapshotKeys: FIXTURE_UNUSED_KEYS };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// На чужой игре это смоук контура, а не аудит контракта: сценарий не знает
|
|
86
|
+
// ни ключей её схемы, ни её порогов дрейфа (у каждой игры своя раскладка
|
|
87
|
+
// player-блока и свои единицы). Судить исправный плагин по фикстурным
|
|
88
|
+
// значениям значит выдавать ему красный вердикт, поэтому проверки 2 и 9
|
|
89
|
+
// честно пропускаются, а не притворяются пройденными.
|
|
90
|
+
return {
|
|
91
|
+
...scenario,
|
|
92
|
+
unusedSnapshotKeys: ALL_KEYS_UNAUDITED,
|
|
93
|
+
divergence: null,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -15,7 +15,11 @@ export const PASS = 'pass';
|
|
|
15
15
|
export const FAIL = 'fail';
|
|
16
16
|
export const SKIP = 'skip';
|
|
17
17
|
|
|
18
|
-
//
|
|
18
|
+
// scenario.unusedSnapshotKeys: вместо списка ключей — «этот сценарий вообще
|
|
19
|
+
// не берётся судить покрытие схемы» (встроенный смоук на чужой игре)
|
|
20
|
+
export const ALL_KEYS_UNAUDITED = '*';
|
|
21
|
+
|
|
22
|
+
// список проверок в порядке plan/done/ai-debug/stage_3.md
|
|
19
23
|
const CHECKS = [
|
|
20
24
|
[1, 'finiteValues', 'no NaN/Infinity in decoded fields and hot buffer'],
|
|
21
25
|
[2, 'snapshotKeysUsed', 'every snapshot key produced at least one row'],
|
|
@@ -67,14 +71,19 @@ export function checkInvariants(ctx) {
|
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
/**
|
|
70
|
-
* Инвариант 12: два прогона одного сценария обязаны совпасть
|
|
74
|
+
* Инвариант 12: два прогона одного сценария обязаны совпасть кадр в кадр
|
|
75
|
+
* (сравниваются хеши кадров, собранные при captureFrames).
|
|
71
76
|
* @param {Object} first - Отчёт первого прогона.
|
|
72
77
|
* @param {Object} second - Отчёт второго прогона.
|
|
73
78
|
* @returns {Object} Результат проверки.
|
|
74
79
|
*/
|
|
75
80
|
export function checkDeterminism(first, second) {
|
|
76
|
-
const a = first.
|
|
77
|
-
const b = second.
|
|
81
|
+
const a = first.shotHashes;
|
|
82
|
+
const b = second.shotHashes;
|
|
83
|
+
|
|
84
|
+
if (!a || !b) {
|
|
85
|
+
return result('determinism', SKIP, [], 'run with --determinism');
|
|
86
|
+
}
|
|
78
87
|
|
|
79
88
|
if (a.length !== b.length) {
|
|
80
89
|
return result('determinism', FAIL, [
|
|
@@ -132,6 +141,19 @@ function finiteValues({ clients }) {
|
|
|
132
141
|
|
|
133
142
|
// 2
|
|
134
143
|
function snapshotKeysUsed({ game, clients, scenario }) {
|
|
144
|
+
// сценарий, не знающий схемы этой игры (встроенный смоук на чужом плагине),
|
|
145
|
+
// не должен объявлять её ключи «не спавнящимися» — это красный вердикт
|
|
146
|
+
// исправной игре
|
|
147
|
+
if (scenario.unusedSnapshotKeys === ALL_KEYS_UNAUDITED) {
|
|
148
|
+
return result(
|
|
149
|
+
'snapshotKeysUsed',
|
|
150
|
+
SKIP,
|
|
151
|
+
[],
|
|
152
|
+
`the scenario declares unusedSnapshotKeys: "${ALL_KEYS_UNAUDITED}" — ` +
|
|
153
|
+
'key coverage is not audited',
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
135
157
|
const declared = Object.keys(game.snapshot);
|
|
136
158
|
const unused = new Set(scenario.unusedSnapshotKeys);
|
|
137
159
|
const seen = new Set();
|
|
@@ -392,7 +414,19 @@ function keyBindings({ game, clientConfig, scenario }) {
|
|
|
392
414
|
// player-блоком кадра. Сопоставление идёт по времени кадра, а не по seq —
|
|
393
415
|
// предиктор переигрывает историю ввода от момента авторитетного состояния,
|
|
394
416
|
// и «тот же seq» на клиенте и на хосте означает разные моменты.
|
|
395
|
-
function predictionDrift({ clients }) {
|
|
417
|
+
function predictionDrift({ clients, scenario }) {
|
|
418
|
+
// детектор выключен самим сценарием — молчит не ядро, и автор плагина не
|
|
419
|
+
// должен идти искать несуществующую проблему в take_divergence
|
|
420
|
+
if (scenario.divergence === null) {
|
|
421
|
+
return result(
|
|
422
|
+
'predictionDrift',
|
|
423
|
+
SKIP,
|
|
424
|
+
[],
|
|
425
|
+
'the scenario disables the drift detector (divergence: null) — ' +
|
|
426
|
+
'thresholds are per-game, write a scenario for yours',
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
396
430
|
const violations = [];
|
|
397
431
|
const tracked = clients.filter(client => client.divergenceStats);
|
|
398
432
|
|
|
@@ -442,11 +476,19 @@ function predictionDrift({ clients }) {
|
|
|
442
476
|
}
|
|
443
477
|
|
|
444
478
|
// Компоненты player-блока — игровая раскладка, движок знает только их
|
|
445
|
-
// порядок, поэтому нарушение адресуется индексом.
|
|
479
|
+
// порядок, поэтому нарушение адресуется индексом. Исключение — уровень 0
|
|
480
|
+
// (source 'camera'): там движок сравнивает ровно мировые x/y, и называть их
|
|
481
|
+
// «#0/#1» значит прятать причину (см. docs/ai/13-debugging.md).
|
|
482
|
+
const CAMERA_COMPONENTS = ['x', 'y'];
|
|
483
|
+
|
|
446
484
|
function formatDivergence(record) {
|
|
485
|
+
const name =
|
|
486
|
+
record.source === 'camera'
|
|
487
|
+
? index => CAMERA_COMPONENTS[index] ?? `#${index}`
|
|
488
|
+
: index => `#${index}`;
|
|
447
489
|
const parts = (record.exceeded ?? []).map(
|
|
448
490
|
index =>
|
|
449
|
-
|
|
491
|
+
`${name(index)} Δ${record.delta[index]} > ${record.thresholds[index]} ` +
|
|
450
492
|
`(predicted ${record.predicted[index]}, authoritative ${record.authoritative[index]})`,
|
|
451
493
|
);
|
|
452
494
|
const replayed = record.replayed
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
1
|
+
import { access, readFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
assertEngineApiCompatible,
|
|
6
|
+
assertGameConfigShape,
|
|
7
|
+
} from '../lib/gamePlugin.js';
|
|
5
8
|
|
|
6
9
|
// Поиск игры для headless-прогона. В браузере плагин грузится по URL из
|
|
7
10
|
// GameManifest мастера; в Node URL-ов нет, поэтому источников три, по
|
|
@@ -21,6 +24,16 @@ const FIXTURE_DIR = new URL(
|
|
|
21
24
|
import.meta.url,
|
|
22
25
|
);
|
|
23
26
|
|
|
27
|
+
// значение поля source у фикстуры: по нему отличается «своя игра, схему
|
|
28
|
+
// которой инструмент знает» от чужого плагина (см. builtinScenario.js)
|
|
29
|
+
export const FIXTURE_SOURCE = 'fixture:miniGame';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {Object} plugin - Результат loadGameForSim.
|
|
33
|
+
* @returns {boolean} Прогон идёт на встроенной фикстуре, а не на чужой игре.
|
|
34
|
+
*/
|
|
35
|
+
export const isFixture = plugin => plugin.source === FIXTURE_SOURCE;
|
|
36
|
+
|
|
24
37
|
/**
|
|
25
38
|
* @param {Object} [options]
|
|
26
39
|
* @param {string} [options.game] - Путь к пакету игры или к манифесту.
|
|
@@ -30,10 +43,20 @@ const FIXTURE_DIR = new URL(
|
|
|
30
43
|
* source }.
|
|
31
44
|
*/
|
|
32
45
|
export async function loadGameForSim({ game = null, core = null } = {}) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
const plugin = game
|
|
47
|
+
? await loadFromManifest(game, core)
|
|
48
|
+
: await loadFixture(core);
|
|
36
49
|
|
|
50
|
+
// контракт gameConfig проверяется здесь, а не только в createHostRuntime:
|
|
51
|
+
// встроенный сценарий собирается из gameConfig раньше, чем стартует прогон
|
|
52
|
+
// (builtinScenario.js), и плагин без конфига иначе отвечал бы сырым
|
|
53
|
+
// TypeError вместо перечисления недостающих полей
|
|
54
|
+
assertGameConfigShape(plugin.hostPlugin);
|
|
55
|
+
|
|
56
|
+
return plugin;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function loadFromManifest(game, core) {
|
|
37
60
|
const manifestPath = game.endsWith('.json')
|
|
38
61
|
? path.resolve(game)
|
|
39
62
|
: path.resolve(game, 'dist/manifest.json');
|
|
@@ -43,8 +66,22 @@ export async function loadGameForSim({ game = null, core = null } = {}) {
|
|
|
43
66
|
|
|
44
67
|
const baseDir = path.dirname(manifestPath);
|
|
45
68
|
const { assetsBase } = manifest;
|
|
46
|
-
const hostPlugin = await importDefault(
|
|
47
|
-
|
|
69
|
+
const hostPlugin = await importDefault(
|
|
70
|
+
baseDir,
|
|
71
|
+
manifest.entries.host,
|
|
72
|
+
assetsBase,
|
|
73
|
+
);
|
|
74
|
+
const clientPlugin = await importDefault(
|
|
75
|
+
baseDir,
|
|
76
|
+
manifest.entries.client,
|
|
77
|
+
assetsBase,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
assertPluginMatchesManifest(manifest, {
|
|
81
|
+
host: hostPlugin,
|
|
82
|
+
client: clientPlugin,
|
|
83
|
+
});
|
|
84
|
+
|
|
48
85
|
const nodeCore = core ?? manifest.entries.wasmNode ?? null;
|
|
49
86
|
|
|
50
87
|
if (!nodeCore) {
|
|
@@ -54,6 +91,21 @@ export async function loadGameForSim({ game = null, core = null } = {}) {
|
|
|
54
91
|
);
|
|
55
92
|
}
|
|
56
93
|
|
|
94
|
+
const corePath = path.resolve(baseDir, nodeCore);
|
|
95
|
+
|
|
96
|
+
// манифест объявляет поле, но опубликованный пакет мог не довезти файл
|
|
97
|
+
// (ignore-правила срезают каталог внутри files) — без этой проверки отказ
|
|
98
|
+
// приходит сырым ERR_MODULE_NOT_FOUND из резолвера
|
|
99
|
+
try {
|
|
100
|
+
await access(corePath);
|
|
101
|
+
} catch {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`${manifestPath}: entries.wasmNode points at '${nodeCore}', but ` +
|
|
104
|
+
`${corePath} does not exist — the game package was published ` +
|
|
105
|
+
`without its node core (npm run core:build:node) or pass --core <path>`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
57
109
|
return {
|
|
58
110
|
id: manifest.id,
|
|
59
111
|
manifest,
|
|
@@ -78,10 +130,25 @@ async function loadFixture(core) {
|
|
|
78
130
|
hostPlugin,
|
|
79
131
|
clientPlugin,
|
|
80
132
|
wasmUrl: core ? pathToFileURL(path.resolve(core)).href : undefined,
|
|
81
|
-
source:
|
|
133
|
+
source: FIXTURE_SOURCE,
|
|
82
134
|
};
|
|
83
135
|
}
|
|
84
136
|
|
|
137
|
+
// зеркало lib/gamePlugin.js:loadClientPlugin — манифест мог быть пересобран
|
|
138
|
+
// без dist/: прогон на старом плагине даёт зелёный вердикт о том, чего в
|
|
139
|
+
// сборке уже нет
|
|
140
|
+
function assertPluginMatchesManifest(manifest, plugins) {
|
|
141
|
+
for (const [half, plugin] of Object.entries(plugins)) {
|
|
142
|
+
if (plugin.engineApi !== manifest.engineApi) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`game "${manifest.id}": ${half} plugin engineApi ` +
|
|
145
|
+
`v${plugin.engineApi} does not match manifest engineApi ` +
|
|
146
|
+
`v${manifest.engineApi} — stale dist/`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
85
152
|
function importDefault(baseDir, entry, assetsBase) {
|
|
86
153
|
const file = path.resolve(baseDir, stripBase(entry, assetsBase));
|
|
87
154
|
|
package/src/devtools/report.js
CHANGED
|
@@ -18,8 +18,10 @@ export async function writeReport(report, { outDir = '.debug', stamp } = {}) {
|
|
|
18
18
|
|
|
19
19
|
await mkdir(runDir, { recursive: true });
|
|
20
20
|
|
|
21
|
-
// срезы сцены — отдельными файлами: они самые объёмные и читаются
|
|
22
|
-
|
|
21
|
+
// срезы сцены — отдельными файлами: они самые объёмные и читаются точечно.
|
|
22
|
+
// shotHashes — служебный материал самопроверки детерминизма (длина = число
|
|
23
|
+
// кадров матча), в читаемом отчёте ему делать нечего
|
|
24
|
+
const { scenes = [], shotHashes, ...rest } = report;
|
|
23
25
|
|
|
24
26
|
await Promise.all(
|
|
25
27
|
scenes.map(scene =>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import clock from '../lib/clock.js';
|
|
2
2
|
|
|
3
|
-
// Рекордер живого матча (этап 6 плана plan/ai-debug): пишет то, что headless
|
|
3
|
+
// Рекордер живого матча (этап 6 плана plan/done/ai-debug): пишет то, что headless
|
|
4
4
|
// не воспроизводит — реальный WebRTC, PixiJS и живой ввод человека — ровно в
|
|
5
5
|
// формат сценария headless-runner'а (devtools/ScenarioRunner.js). Баг,
|
|
6
6
|
// пойманный человеком в браузере, догоняется потом `npm run sim:replay`
|
|
@@ -134,8 +134,11 @@ export default class DebugRecorder {
|
|
|
134
134
|
version: 1,
|
|
135
135
|
seed: this._seed,
|
|
136
136
|
map: this._map,
|
|
137
|
+
// таймеры сценария живут только в config.timers: верхний уровень
|
|
138
|
+
// config — это ключи конфига игры, и раньше запись подсовывала туда
|
|
139
|
+
// таймер в расчёте на неявный роутинг
|
|
137
140
|
config: this._networkSendRate
|
|
138
|
-
? { networkSendRate: this._networkSendRate }
|
|
141
|
+
? { timers: { networkSendRate: this._networkSendRate } }
|
|
139
142
|
: {},
|
|
140
143
|
participants: this._participants,
|
|
141
144
|
timeline: this._timeline,
|
package/src/host/HostGame.js
CHANGED
|
@@ -145,7 +145,7 @@ export default class HostGame {
|
|
|
145
145
|
this._networkSendRate = data.timers.networkSendRate;
|
|
146
146
|
this._snapshotManager = new SnapshotThrottle(this._networkSendRate);
|
|
147
147
|
|
|
148
|
-
// рекордер живого матча (этап 6 плана plan/ai-debug) — только в dev-режиме;
|
|
148
|
+
// рекордер живого матча (этап 6 плана plan/done/ai-debug) — только в dev-режиме;
|
|
149
149
|
// в проде null, и все точки записи ниже вырождаются в ?.
|
|
150
150
|
this._recorder = this._isDevMode ? new DebugRecorder() : null;
|
|
151
151
|
|
|
@@ -511,7 +511,7 @@ export default class HostGame {
|
|
|
511
511
|
this._mapList.push(...Object.keys(this._maps));
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
-
// ***** отладочный контур (этап 6 плана plan/ai-debug) ***** //
|
|
514
|
+
// ***** отладочный контур (этап 6 плана plan/done/ai-debug) ***** //
|
|
515
515
|
// Всё ниже живёт под флагом isDevMode: в проде рекордера нет, а дамп
|
|
516
516
|
// отдаёт только то, что и так знает мета.
|
|
517
517
|
|
package/src/host/host.worker.js
CHANGED
|
@@ -407,7 +407,7 @@ self.onmessage = async event => {
|
|
|
407
407
|
host?.setHostId(msg.hostId, msg.hostSecret);
|
|
408
408
|
break;
|
|
409
409
|
|
|
410
|
-
// отладочный контур (этап 6 плана plan/ai-debug): единственный вход в
|
|
410
|
+
// отладочный контур (этап 6 плана plan/done/ai-debug): единственный вход в
|
|
411
411
|
// авторитетную половину из главного потока — запрос/ответ по requestId
|
|
412
412
|
case 'debug':
|
|
413
413
|
onDebug(msg);
|
|
@@ -310,7 +310,7 @@ export default class SocketManager {
|
|
|
310
310
|
|
|
311
311
|
/**
|
|
312
312
|
* Отправка отладочного лога хоста в консоль клиента (порт CONSOLE,
|
|
313
|
-
* этап 6 плана plan/ai-debug). Worker изолирован от DevTools вкладки —
|
|
313
|
+
* этап 6 плана plan/done/ai-debug). Worker изолирован от DevTools вкладки —
|
|
314
314
|
* без этого канала его события в браузере не видны вовсе.
|
|
315
315
|
* @param {string} socketId
|
|
316
316
|
* @param {*} data
|
package/src/lib/AbstractTimer.js
CHANGED
|
@@ -49,9 +49,14 @@ class AbstractTimer {
|
|
|
49
49
|
_stopTimer(key) {
|
|
50
50
|
if (this._timers.has(key)) {
|
|
51
51
|
const { timerId, isInterval } = this._timers.get(key);
|
|
52
|
-
const handler = isInterval ? clock.clearInterval : clock.clearTimeout;
|
|
53
52
|
|
|
54
|
-
|
|
53
|
+
// вызов через clock, а не отрыв метода от объекта: сейчас это
|
|
54
|
+
// замыкания, но привязка к модулю не должна зависеть от этого
|
|
55
|
+
if (isInterval) {
|
|
56
|
+
clock.clearInterval(timerId);
|
|
57
|
+
} else {
|
|
58
|
+
clock.clearTimeout(timerId);
|
|
59
|
+
}
|
|
55
60
|
|
|
56
61
|
this._timers.delete(key);
|
|
57
62
|
}
|
|
@@ -74,9 +79,12 @@ class AbstractTimer {
|
|
|
74
79
|
_clearAllTimers() {
|
|
75
80
|
for (const timerData of this._timers.values()) {
|
|
76
81
|
const { timerId, isInterval } = timerData;
|
|
77
|
-
const handler = isInterval ? clock.clearInterval : clock.clearTimeout;
|
|
78
82
|
|
|
79
|
-
|
|
83
|
+
if (isInterval) {
|
|
84
|
+
clock.clearInterval(timerId);
|
|
85
|
+
} else {
|
|
86
|
+
clock.clearTimeout(timerId);
|
|
87
|
+
}
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
this._timers.clear();
|
|
@@ -40,7 +40,7 @@ export function applyRoomOverrides(room = {}, plugin) {
|
|
|
40
40
|
game.timers.mapTime = clampTime(room.mapTime);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
// dev-режим вкладки хоста (этап 6 плана plan/ai-debug): включает рекордер
|
|
43
|
+
// dev-режим вкладки хоста (этап 6 плана plan/done/ai-debug): включает рекордер
|
|
44
44
|
// и хостовый CONSOLE-лог. Прод-бандл его не выставляет — поведение то же
|
|
45
45
|
if (typeof room.isDevMode === 'boolean') {
|
|
46
46
|
game.isDevMode = room.isDevMode;
|
|
@@ -79,7 +79,7 @@ export async function createHostRuntime(room, options = {}) {
|
|
|
79
79
|
const host = new HostGame(game, socketManager, core, hostPlugin, {
|
|
80
80
|
hostSocketId: room?.hostSocketId ?? null,
|
|
81
81
|
gameVersion: room.game?.version ?? null,
|
|
82
|
-
// рекордер (этап 6 плана plan/ai-debug) кладёт seed в сценарий — без него
|
|
82
|
+
// рекордер (этап 6 плана plan/done/ai-debug) кладёт seed в сценарий — без него
|
|
83
83
|
// записанный матч невоспроизводим
|
|
84
84
|
seed,
|
|
85
85
|
...hostOptions,
|
package/src/lib/gamePlugin.js
CHANGED
|
@@ -51,6 +51,10 @@ const REQUIRED_GAME_CONFIG_PATHS = [
|
|
|
51
51
|
'parts.friendlyFire',
|
|
52
52
|
'panel.fields',
|
|
53
53
|
'playerKeys',
|
|
54
|
+
// без них HostGame разыменовывает undefined (this._teams[spectatorTeam])
|
|
55
|
+
// и игра умирает тремя разными сообщениями вместо одного контрактного
|
|
56
|
+
'teams',
|
|
57
|
+
'spectatorTeam',
|
|
54
58
|
];
|
|
55
59
|
|
|
56
60
|
function getPath(obj, dottedPath) {
|
|
@@ -61,9 +65,14 @@ function getPath(obj, dottedPath) {
|
|
|
61
65
|
|
|
62
66
|
// бросает при отсутствии обязательных полей HostPlugin.gameConfig
|
|
63
67
|
export function assertGameConfigShape(hostPlugin) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
// null проходил бы проверку присутствия, хотя ни одно из этих полей не
|
|
69
|
+
// бывает пустым по контракту: движок разыменовывает их сразу, и гейт,
|
|
70
|
+
// заведённый ради текста вместо TypeError, сам отвечал бы TypeError
|
|
71
|
+
const missing = REQUIRED_GAME_CONFIG_PATHS.filter(p => {
|
|
72
|
+
const value = getPath(hostPlugin.gameConfig, p);
|
|
73
|
+
|
|
74
|
+
return value === undefined || value === null;
|
|
75
|
+
});
|
|
67
76
|
|
|
68
77
|
if (missing.length > 0) {
|
|
69
78
|
throw new Error(
|
|
@@ -71,6 +80,19 @@ export function assertGameConfigShape(hostPlugin) {
|
|
|
71
80
|
missing.join(', '),
|
|
72
81
|
);
|
|
73
82
|
}
|
|
83
|
+
|
|
84
|
+
// единственная связь между полями, которую стоит проверять здесь:
|
|
85
|
+
// spectatorTeam — имя ключа внутри teams, и опечатка даёт spectatorId ===
|
|
86
|
+
// undefined, после чего участник заходит в несуществующую команду
|
|
87
|
+
// (ParticipantManager.createHuman валится на её счётчике)
|
|
88
|
+
const { teams, spectatorTeam } = hostPlugin.gameConfig;
|
|
89
|
+
|
|
90
|
+
if (teams[spectatorTeam] === undefined) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`game "${hostPlugin.id}": spectatorTeam '${spectatorTeam}' is not a ` +
|
|
93
|
+
`key of teams (${Object.keys(teams).join(', ')})`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
74
96
|
}
|
|
75
97
|
|
|
76
98
|
// динамический import ClientPlugin игры (client-entry её сборки). Манифест и
|