vimp-engine 0.4.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.
- package/bin/vimp-sim.js +5 -2
- package/package.json +1 -1
- package/src/config/lobby.js +1 -1
- package/src/devtools/RecordingSocketManager.js +106 -81
- package/src/devtools/ScenarioRunner.js +70 -19
- package/src/devtools/VirtualClient.js +48 -0
- package/src/devtools/invariants.js +19 -6
- package/src/devtools/pluginLoader.js +47 -3
- 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/bin/vimp-sim.js
CHANGED
|
@@ -55,10 +55,13 @@ async function main(argv) {
|
|
|
55
55
|
// плагин грузится один раз: второй прогон самопроверки детерминизма
|
|
56
56
|
// обязан идти на том же ядре, иначе он проверял бы загрузчик, а не мир
|
|
57
57
|
const plugin = await loadGameForSim({ game: args.game, core: args.core });
|
|
58
|
-
|
|
58
|
+
// хеши потока кадров собираются только под --determinism: их единственный
|
|
59
|
+
// потребитель — сравнение двух прогонов, а объём линеен по длине матча
|
|
60
|
+
const captureFrames = args.determinism === true;
|
|
61
|
+
const report = await runScenario(scenario, { plugin, captureFrames });
|
|
59
62
|
|
|
60
63
|
if (args.determinism) {
|
|
61
|
-
const second = await runScenario(scenario, { plugin });
|
|
64
|
+
const second = await runScenario(scenario, { plugin, captureFrames });
|
|
62
65
|
const check = checkDeterminism(report, second);
|
|
63
66
|
|
|
64
67
|
report.invariants = report.invariants.map(item =>
|
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
|
}
|
|
@@ -89,6 +89,9 @@ export function parseScenario(raw) {
|
|
|
89
89
|
* детерминизма).
|
|
90
90
|
* @param {string} [options.gamePath] - Путь к пакету игры/манифесту.
|
|
91
91
|
* @param {string} [options.corePath] - Путь к node-сборке ядра.
|
|
92
|
+
* @param {boolean} [options.captureFrames] - Считать хеши потока кадров
|
|
93
|
+
* (нужны только самопроверке детерминизма — на длинном матче это лишние
|
|
94
|
+
* мегабайты в отчёте).
|
|
92
95
|
* @returns {Promise<Object>} Отчёт прогона.
|
|
93
96
|
*/
|
|
94
97
|
export async function runScenario(rawScenario, options = {}) {
|
|
@@ -105,13 +108,15 @@ export async function runScenario(rawScenario, options = {}) {
|
|
|
105
108
|
const restoreClock = virtualClock.install();
|
|
106
109
|
|
|
107
110
|
try {
|
|
108
|
-
return await execute(scenario, plugin, virtualClock
|
|
111
|
+
return await execute(scenario, plugin, virtualClock, {
|
|
112
|
+
captureFrames: options.captureFrames === true,
|
|
113
|
+
});
|
|
109
114
|
} finally {
|
|
110
115
|
restoreClock();
|
|
111
116
|
}
|
|
112
117
|
}
|
|
113
118
|
|
|
114
|
-
async function execute(scenario, plugin, virtualClock) {
|
|
119
|
+
async function execute(scenario, plugin, virtualClock, { captureFrames }) {
|
|
115
120
|
const clients = new Map(); // socketId → VirtualClient
|
|
116
121
|
const byParticipant = new Map(); // id сценария → { socketId, gameId }
|
|
117
122
|
const participantLog = []; // [{ who, socketId, gameId, joinTick, leaveTick }]
|
|
@@ -120,13 +125,11 @@ async function execute(scenario, plugin, virtualClock) {
|
|
|
120
125
|
|
|
121
126
|
let currentTick = 0;
|
|
122
127
|
|
|
123
|
-
const socketManager = new RecordingSocketManager({
|
|
124
|
-
onFrame: frame => routeFrame(frame, clients, virtualClock, host),
|
|
125
|
-
});
|
|
126
|
-
|
|
127
128
|
// ping/pong: без ответа хост честно кикает участника по таймауту RTT, и
|
|
128
|
-
// длинный сценарий тихо теряет игроков посреди
|
|
129
|
+
// длинный сценарий тихо теряет игроков посреди прогона. Объявлено до
|
|
130
|
+
// транспорта — замыкание onFrame читает эту переменную.
|
|
129
131
|
let host = null;
|
|
132
|
+
let socketManager = null;
|
|
130
133
|
|
|
131
134
|
const room = {
|
|
132
135
|
...scenario.room,
|
|
@@ -143,7 +146,15 @@ async function execute(scenario, plugin, virtualClock) {
|
|
|
143
146
|
|
|
144
147
|
const runtime = await createHostRuntime(room, {
|
|
145
148
|
loadHostPlugin: () => plugin.hostPlugin,
|
|
146
|
-
|
|
149
|
+
// транспорт собирается той же фабрикой, что и боевой: наследник обязан
|
|
150
|
+
// получить те же порты и ту же игровую параметризацию
|
|
151
|
+
createSocketManager: (ports, gameOpts) => {
|
|
152
|
+
socketManager = new RecordingSocketManager(ports, gameOpts, {
|
|
153
|
+
onFrame: frame => routeFrame(frame, clients, virtualClock, host),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
return socketManager;
|
|
157
|
+
},
|
|
147
158
|
overrideGameConfig: game => mergeConfig(game, scenario.config),
|
|
148
159
|
hostOptions: {
|
|
149
160
|
onMapChange: mapName =>
|
|
@@ -229,6 +240,7 @@ async function execute(scenario, plugin, virtualClock) {
|
|
|
229
240
|
mapChanges,
|
|
230
241
|
byParticipant,
|
|
231
242
|
participantLog,
|
|
243
|
+
captureFrames,
|
|
232
244
|
});
|
|
233
245
|
}
|
|
234
246
|
|
|
@@ -253,6 +265,21 @@ function routeFrame(frame, clients, virtualClock, host) {
|
|
|
253
265
|
return;
|
|
254
266
|
}
|
|
255
267
|
|
|
268
|
+
// первый снапшот мира: в браузере он применяется сразу (applyShot), а не
|
|
269
|
+
// едет через буфер интерполяции — сущность, доехавшая только им, иначе не
|
|
270
|
+
// попадёт ни в сцену, ни в проверки
|
|
271
|
+
if (frame.method === 'sendFirstShot') {
|
|
272
|
+
client.applyFirstShot(frame.sent[0]?.data);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// CLEAR приходит на каждый рестарт раунда и на смену карты; без него
|
|
277
|
+
// headless живёт через границу раунда в состоянии, которого в игре нет
|
|
278
|
+
if (frame.method === 'sendClear') {
|
|
279
|
+
client.clear(frame.args[0]);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
256
283
|
if (frame.method === 'sendMap') {
|
|
257
284
|
client.setMap(frame.args[0]);
|
|
258
285
|
return;
|
|
@@ -286,6 +313,10 @@ async function applyOp(op, ctx) {
|
|
|
286
313
|
|
|
287
314
|
if (entry) {
|
|
288
315
|
host.removeUser(entry.gameId);
|
|
316
|
+
|
|
317
|
+
// ядро клиента живёт в WASM-памяти: без free многочасовой реплей с
|
|
318
|
+
// текучкой участников растит процесс
|
|
319
|
+
clients.get(entry.socketId)?.destroy();
|
|
289
320
|
clients.delete(entry.socketId);
|
|
290
321
|
byParticipant.delete(op.who);
|
|
291
322
|
|
|
@@ -409,13 +440,16 @@ function requireEntry(byParticipant, who) {
|
|
|
409
440
|
return entry;
|
|
410
441
|
}
|
|
411
442
|
|
|
412
|
-
// точечное переопределение конфига игры
|
|
413
|
-
// config
|
|
443
|
+
// точечное переопределение конфига игры сценарием. Таймеры — только через
|
|
444
|
+
// config.timers: неявный роутинг одноимённого ключа верхнего уровня в
|
|
445
|
+
// game.timers молча правил не то, что просил сценарий.
|
|
414
446
|
function mergeConfig(game, overrides) {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
447
|
+
const { timers = {}, ...rest } = overrides;
|
|
448
|
+
|
|
449
|
+
Object.assign(game.timers, timers);
|
|
450
|
+
|
|
451
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
452
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
419
453
|
game[key] = { ...game[key], ...value };
|
|
420
454
|
} else {
|
|
421
455
|
game[key] = value;
|
|
@@ -439,6 +473,7 @@ function buildReport(ctx) {
|
|
|
439
473
|
mapChanges,
|
|
440
474
|
byParticipant,
|
|
441
475
|
participantLog,
|
|
476
|
+
captureFrames,
|
|
442
477
|
} = ctx;
|
|
443
478
|
|
|
444
479
|
const frameCounts = {};
|
|
@@ -511,11 +546,14 @@ function buildReport(ctx) {
|
|
|
511
546
|
),
|
|
512
547
|
})),
|
|
513
548
|
scenes,
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
549
|
+
// поток кадров нужен ровно одному потребителю — самопроверке
|
|
550
|
+
// детерминизма (этап 3). Хеша на кадр для сравнения потоков достаточно,
|
|
551
|
+
// а сами байты линейно от длины матча раздували report.json
|
|
552
|
+
shotHashes: captureFrames
|
|
553
|
+
? socketManager
|
|
554
|
+
.framesOf('sendShot')
|
|
555
|
+
.map(frame => hash32(toBytes(frame.args[0])))
|
|
556
|
+
: null,
|
|
519
557
|
snapshotSchema: game.snapshot,
|
|
520
558
|
};
|
|
521
559
|
}
|
|
@@ -527,3 +565,16 @@ function toBytes(value) {
|
|
|
527
565
|
|
|
528
566
|
return new Uint8Array(value);
|
|
529
567
|
}
|
|
568
|
+
|
|
569
|
+
// FNV-1a: сравниваются потоки одного и того же прогона, криптостойкость не
|
|
570
|
+
// нужна — нужна дешевизна и отсутствие зависимостей
|
|
571
|
+
function hash32(bytes) {
|
|
572
|
+
let hash = 0x811c9dc5;
|
|
573
|
+
|
|
574
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
575
|
+
hash ^= bytes[i];
|
|
576
|
+
hash = Math.imul(hash, 0x01000193);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return hash >>> 0;
|
|
580
|
+
}
|
|
@@ -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() {
|
|
@@ -15,7 +15,7 @@ export const PASS = 'pass';
|
|
|
15
15
|
export const FAIL = 'fail';
|
|
16
16
|
export const SKIP = 'skip';
|
|
17
17
|
|
|
18
|
-
// список проверок в порядке plan/ai-debug/stage_3.md
|
|
18
|
+
// список проверок в порядке plan/done/ai-debug/stage_3.md
|
|
19
19
|
const CHECKS = [
|
|
20
20
|
[1, 'finiteValues', 'no NaN/Infinity in decoded fields and hot buffer'],
|
|
21
21
|
[2, 'snapshotKeysUsed', 'every snapshot key produced at least one row'],
|
|
@@ -67,14 +67,19 @@ export function checkInvariants(ctx) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/**
|
|
70
|
-
* Инвариант 12: два прогона одного сценария обязаны совпасть
|
|
70
|
+
* Инвариант 12: два прогона одного сценария обязаны совпасть кадр в кадр
|
|
71
|
+
* (сравниваются хеши кадров, собранные при captureFrames).
|
|
71
72
|
* @param {Object} first - Отчёт первого прогона.
|
|
72
73
|
* @param {Object} second - Отчёт второго прогона.
|
|
73
74
|
* @returns {Object} Результат проверки.
|
|
74
75
|
*/
|
|
75
76
|
export function checkDeterminism(first, second) {
|
|
76
|
-
const a = first.
|
|
77
|
-
const b = second.
|
|
77
|
+
const a = first.shotHashes;
|
|
78
|
+
const b = second.shotHashes;
|
|
79
|
+
|
|
80
|
+
if (!a || !b) {
|
|
81
|
+
return result('determinism', SKIP, [], 'run with --determinism');
|
|
82
|
+
}
|
|
78
83
|
|
|
79
84
|
if (a.length !== b.length) {
|
|
80
85
|
return result('determinism', FAIL, [
|
|
@@ -442,11 +447,19 @@ function predictionDrift({ clients }) {
|
|
|
442
447
|
}
|
|
443
448
|
|
|
444
449
|
// Компоненты player-блока — игровая раскладка, движок знает только их
|
|
445
|
-
// порядок, поэтому нарушение адресуется индексом.
|
|
450
|
+
// порядок, поэтому нарушение адресуется индексом. Исключение — уровень 0
|
|
451
|
+
// (source 'camera'): там движок сравнивает ровно мировые x/y, и называть их
|
|
452
|
+
// «#0/#1» значит прятать причину (см. docs/ai/13-debugging.md).
|
|
453
|
+
const CAMERA_COMPONENTS = ['x', 'y'];
|
|
454
|
+
|
|
446
455
|
function formatDivergence(record) {
|
|
456
|
+
const name =
|
|
457
|
+
record.source === 'camera'
|
|
458
|
+
? index => CAMERA_COMPONENTS[index] ?? `#${index}`
|
|
459
|
+
: index => `#${index}`;
|
|
447
460
|
const parts = (record.exceeded ?? []).map(
|
|
448
461
|
index =>
|
|
449
|
-
|
|
462
|
+
`${name(index)} Δ${record.delta[index]} > ${record.thresholds[index]} ` +
|
|
450
463
|
`(predicted ${record.predicted[index]}, authoritative ${record.authoritative[index]})`,
|
|
451
464
|
);
|
|
452
465
|
const replayed = record.replayed
|
|
@@ -1,4 +1,4 @@
|
|
|
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
4
|
import { assertEngineApiCompatible } from '../lib/gamePlugin.js';
|
|
@@ -43,8 +43,22 @@ export async function loadGameForSim({ game = null, core = null } = {}) {
|
|
|
43
43
|
|
|
44
44
|
const baseDir = path.dirname(manifestPath);
|
|
45
45
|
const { assetsBase } = manifest;
|
|
46
|
-
const hostPlugin = await importDefault(
|
|
47
|
-
|
|
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
|
+
|
|
48
62
|
const nodeCore = core ?? manifest.entries.wasmNode ?? null;
|
|
49
63
|
|
|
50
64
|
if (!nodeCore) {
|
|
@@ -54,6 +68,21 @@ export async function loadGameForSim({ game = null, core = null } = {}) {
|
|
|
54
68
|
);
|
|
55
69
|
}
|
|
56
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
|
+
|
|
57
86
|
return {
|
|
58
87
|
id: manifest.id,
|
|
59
88
|
manifest,
|
|
@@ -82,6 +111,21 @@ async function loadFixture(core) {
|
|
|
82
111
|
};
|
|
83
112
|
}
|
|
84
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
|
+
|
|
85
129
|
function importDefault(baseDir, entry, assetsBase) {
|
|
86
130
|
const file = path.resolve(baseDir, stripBase(entry, assetsBase));
|
|
87
131
|
|
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,
|