vimp-engine 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/vimp-sim.js +137 -0
- package/package.json +10 -3
- package/src/config/gameCodes.js +8 -0
- package/src/config/lobby.js +23 -1
- package/src/config/master.js +8 -0
- package/src/config/opcodes.js +3 -1
- package/src/devtools/RecordingSocketManager.js +118 -0
- package/src/devtools/ScenarioRunner.js +529 -0
- package/src/devtools/VirtualClient.js +363 -0
- package/src/devtools/VirtualClock.js +169 -0
- package/src/devtools/inspectHost.js +31 -0
- package/src/devtools/invariants.js +672 -0
- package/src/devtools/pluginLoader.js +101 -0
- package/src/devtools/report.js +202 -0
- package/src/devtools/resetHostSingletons.js +23 -0
- package/src/host/DebugRecorder.js +224 -0
- package/src/host/GameCoreAdapter.js +17 -0
- package/src/host/HostGame.js +129 -7
- package/src/host/host.worker.js +46 -47
- package/src/host/meta/SocketManager.js +15 -7
- package/src/host/meta/modules/Panel.js +7 -0
- package/src/host/meta/modules/PlayerDataSync.js +29 -5
- package/src/host/meta/modules/RTTManager.js +4 -2
- package/src/host/meta/modules/Stat.js +7 -0
- package/src/host/meta/modules/TimerManager.js +15 -7
- package/src/host/meta/modules/Vote.js +10 -1
- package/src/host/meta/modules/chat/Chat.js +7 -0
- package/src/host/meta/modules/chat/index.js +1 -1
- package/src/host/meta/player/HumanParticipant.js +2 -1
- package/src/lib/AbstractTimer.js +6 -4
- package/src/lib/applyRoomOverrides.js +6 -0
- package/src/lib/clientCoreConfig.js +4 -0
- package/src/lib/clock.js +53 -0
- package/src/lib/createHostRuntime.js +108 -0
- package/src/lib/reconstructHot.js +78 -0
- package/src/lib/validators.js +10 -0
- package/tests/fixtures/miniGame/client/fakeClientCore.js +304 -0
- package/tests/fixtures/miniGame/client/index.js +37 -0
- package/tests/fixtures/miniGame/client/parts/Actor.js +18 -0
- package/tests/fixtures/miniGame/client/parts/ActorRadar.js +7 -0
- package/tests/fixtures/miniGame/config/auth.js +41 -0
- package/tests/fixtures/miniGame/config/client.js +139 -0
- package/tests/fixtures/miniGame/config/game.js +148 -0
- package/tests/fixtures/miniGame/host/ScriptedManager.js +129 -0
- package/tests/fixtures/miniGame/host/createModules.js +6 -0
- package/tests/fixtures/miniGame/host/fakeCore.js +233 -0
- package/tests/fixtures/miniGame/host/index.js +29 -0
- package/tests/fixtures/miniGame/host/spawnCommand.js +14 -0
- package/tests/fixtures/miniGame/host/systemMessages.js +6 -0
- package/tests/fixtures/miniGame.contract.test.js +76 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { buildClientCoreConfig } from '../lib/clientCoreConfig.js';
|
|
2
|
+
import { buildSnapshotKeysById, parseHot } from '../lib/reconstructHot.js';
|
|
3
|
+
import { HOT_FLAGS } from '../config/opcodes.js';
|
|
4
|
+
|
|
5
|
+
// Клиент без браузера: настоящее клиентское ядро игры, которое кормится
|
|
6
|
+
// реальными байтами кадров, отправленными хостом. Замыкает контур
|
|
7
|
+
// «хост → бинарный кадр → ClientCore → hot-буфер → сцена в JSON» в одном
|
|
8
|
+
// процессе — без этого отказы тира 1–2 (сущность не спавнится, поля схемы
|
|
9
|
+
// перепутаны, панель не обновляется) не видны нигде, кроме чёрного холста.
|
|
10
|
+
//
|
|
11
|
+
// Рендер-конвейер повторяет client/main.js:renderTick, но hot читается через
|
|
12
|
+
// hot_values() (копия), а не hot_ptr()+память WASM: в Node нет смысла в
|
|
13
|
+
// zero-copy, а копия не детачится при росте памяти ядра.
|
|
14
|
+
|
|
15
|
+
// предел числа сохраняемых однотипных нарушений (дальше только счётчик)
|
|
16
|
+
const NOTE_LIMIT = 20;
|
|
17
|
+
|
|
18
|
+
class VirtualClient {
|
|
19
|
+
/**
|
|
20
|
+
* @param {Object} params
|
|
21
|
+
* @param {Object} params.clientPlugin - ClientPlugin игры.
|
|
22
|
+
* @param {Object} params.clientConfig - CONFIG_DATA хоста.
|
|
23
|
+
* @param {string} [params.wasmUrl] - Путь к клиентскому ядру (node-сборка).
|
|
24
|
+
* @param {string} params.socketId
|
|
25
|
+
* @param {number} params.gameId
|
|
26
|
+
* @param {Object} [params.auth] - Данные авторизации участника
|
|
27
|
+
* ({ name, model }) — то же, что уходит хосту в createUser.
|
|
28
|
+
* @param {Object} [params.divergence] - Пороги детектора рассинхрона
|
|
29
|
+
* предикта (thresholds/defaultThreshold/capacity); undefined — детектор
|
|
30
|
+
* выключен, как в боевом конфиге.
|
|
31
|
+
* @returns {Promise<VirtualClient>}
|
|
32
|
+
*/
|
|
33
|
+
static async create({
|
|
34
|
+
clientPlugin,
|
|
35
|
+
clientConfig,
|
|
36
|
+
wasmUrl,
|
|
37
|
+
socketId,
|
|
38
|
+
gameId,
|
|
39
|
+
auth,
|
|
40
|
+
divergence,
|
|
41
|
+
}) {
|
|
42
|
+
const { core } = await clientPlugin.createClientCore(
|
|
43
|
+
JSON.stringify(buildClientCoreConfig(clientConfig, { divergence })),
|
|
44
|
+
{ wasmUrl },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const client = new VirtualClient({
|
|
48
|
+
core,
|
|
49
|
+
clientConfig,
|
|
50
|
+
socketId,
|
|
51
|
+
gameId,
|
|
52
|
+
clientPlugin,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// игровой хук авторизации (в браузере — publisher 'socket' формы auth):
|
|
56
|
+
// без него ядро не знает модель своего актора, а значит и предикт
|
|
57
|
+
// никогда не включается
|
|
58
|
+
if (auth) {
|
|
59
|
+
clientPlugin.hooks?.onAuth?.(core, auth);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return client;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
constructor({ core, clientConfig, socketId, gameId, clientPlugin }) {
|
|
66
|
+
this.socketId = socketId;
|
|
67
|
+
this.gameId = gameId;
|
|
68
|
+
this.core = core;
|
|
69
|
+
|
|
70
|
+
this._plugin = clientPlugin;
|
|
71
|
+
|
|
72
|
+
this._snapshotKeysById = buildSnapshotKeysById(clientConfig.snapshot);
|
|
73
|
+
this._gameSets = clientConfig.parts.gameSets;
|
|
74
|
+
this._entitiesOnCanvas = clientConfig.parts.entitiesOnCanvas;
|
|
75
|
+
|
|
76
|
+
// накопленная сцена: ключ схемы → id → поля (то, что в браузере ушло бы
|
|
77
|
+
// в parts через Factory)
|
|
78
|
+
this.scene = {};
|
|
79
|
+
this.camera = null;
|
|
80
|
+
|
|
81
|
+
// всё, что приехало по не-кадровым портам — материал для проверок
|
|
82
|
+
// инвариантов (этап 3) и для отчёта
|
|
83
|
+
this.received = {
|
|
84
|
+
panel: [],
|
|
85
|
+
stat: [],
|
|
86
|
+
chat: [],
|
|
87
|
+
vote: [],
|
|
88
|
+
keySet: [],
|
|
89
|
+
gameInform: [],
|
|
90
|
+
soundCue: [],
|
|
91
|
+
roundEnd: [],
|
|
92
|
+
techInform: [],
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
this.frameCount = 0;
|
|
96
|
+
this.decodeErrors = [];
|
|
97
|
+
|
|
98
|
+
// материал проверок инвариантов (devtools/invariants.js): что реально
|
|
99
|
+
// доехало до сцены за весь прогон, а не только к финальному тику —
|
|
100
|
+
// сущность могла появиться и исчезнуть между дампами
|
|
101
|
+
this.observed = {}; // ключ схемы → { rows, ids: Set, widths: Set }
|
|
102
|
+
this.nonFinite = []; // NaN/Infinity в декодированных полях и hot-буфере
|
|
103
|
+
this.hotLayoutErrors = []; // расхождение len ↔ пройденного куска буфера
|
|
104
|
+
|
|
105
|
+
// расхождение предикта с авторитетным состоянием (этап 5): записи ядра
|
|
106
|
+
// и накопительные агрегаты; null в stats — детектор выключен или ядро
|
|
107
|
+
// игры собрано против движка без него
|
|
108
|
+
this.divergence = [];
|
|
109
|
+
this.divergenceStats = null;
|
|
110
|
+
|
|
111
|
+
// нарушение обычно повторяется каждый тик: в отчёте нужны первые
|
|
112
|
+
// экземпляры и счётчик, а не сотня одинаковых строк
|
|
113
|
+
this.truncated = { nonFinite: 0, hotLayoutErrors: 0, divergence: 0 };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// байты кадра из sendShot — ровно то, что ушло бы в data channel
|
|
117
|
+
pushFrame(bytes, localNow) {
|
|
118
|
+
const data = toUint8(bytes);
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
this.core.push_frame(data, localNow);
|
|
122
|
+
this.frameCount += 1;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
this.decodeErrors.push({ localNow, message: String(e) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// не-кадровые порты (панель, статистика, чат, keyset…). Порты, которые в
|
|
129
|
+
// браузере доходят до ядра (main.js), доводятся и здесь — иначе headless
|
|
130
|
+
// гоняет ядро в режиме, которого в игре не бывает.
|
|
131
|
+
record(kind, data) {
|
|
132
|
+
if (this.received[kind]) {
|
|
133
|
+
this.received[kind].push(data);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (kind === 'panel') {
|
|
137
|
+
this._plugin?.hooks?.onPanel?.(this.core, data);
|
|
138
|
+
} else if (kind === 'keySet') {
|
|
139
|
+
// keySet 1 — играющий, иначе спектатор (предикт выключается)
|
|
140
|
+
this.core.set_active?.(data === 1);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* MAP_DATA: мир raycast в ядре и сброс предикта — зеркало main.js.
|
|
146
|
+
* @param {Object} data - Данные карты, как их шлёт хост.
|
|
147
|
+
*/
|
|
148
|
+
setMap(data) {
|
|
149
|
+
if (typeof this.core.set_map !== 'function') {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
this.core.set_map(
|
|
155
|
+
JSON.stringify({
|
|
156
|
+
map: data.map,
|
|
157
|
+
step: data.step,
|
|
158
|
+
scale: data.scale,
|
|
159
|
+
physicsStatic: data.physicsStatic,
|
|
160
|
+
physicsDynamic: data.physicsDynamic,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
} catch (e) {
|
|
164
|
+
this.decodeErrors.push({ localNow: null, message: `set_map: ${e}` });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Рендер-тик: зеркало client/main.js:renderTick без PixiJS.
|
|
170
|
+
* @param {number} localNow - Локальное время клиента (мс).
|
|
171
|
+
*/
|
|
172
|
+
render(localNow) {
|
|
173
|
+
// записи копятся в ядре на push_frame, поэтому вычерпываются до sample —
|
|
174
|
+
// иначе тик с пустым hot-буфером (ранний выход ниже) их бы потерял
|
|
175
|
+
this._drainDivergence();
|
|
176
|
+
|
|
177
|
+
const len = this.core.sample(localNow);
|
|
178
|
+
|
|
179
|
+
if (!len) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const hot = this.core.hot_values();
|
|
184
|
+
const flags = hot[0];
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < len; i += 1) {
|
|
187
|
+
if (!Number.isFinite(hot[i])) {
|
|
188
|
+
this._note('nonFinite', {
|
|
189
|
+
key: 'hot',
|
|
190
|
+
id: 'buffer',
|
|
191
|
+
index: i,
|
|
192
|
+
value: String(hot[i]),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (flags & HOT_FLAGS.FRAMES) {
|
|
198
|
+
for (const frame of JSON.parse(this.core.take_frames())) {
|
|
199
|
+
this._applyGameData(frame.game);
|
|
200
|
+
|
|
201
|
+
if (frame.camera && frame.camera !== 0) {
|
|
202
|
+
this.camera = [frame.camera[0], frame.camera[1]];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (flags & (HOT_FLAGS.GAME | HOT_FLAGS.PREDICTED)) {
|
|
208
|
+
let parsed;
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
parsed = parseHot(hot, this._snapshotKeysById);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
this._note('hotLayoutErrors', { len, message: String(e) });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// раскладку буфера диктует схема игры: разошлась ширина записи или
|
|
218
|
+
// порядок групп — обход упрётся не в конец буфера
|
|
219
|
+
if (parsed.consumed !== len) {
|
|
220
|
+
this._note('hotLayoutErrors', { len, consumed: parsed.consumed });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
this._applyGameData(parsed.game);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (flags & HOT_FLAGS.CAMERA) {
|
|
227
|
+
this.camera = [hot[1], hot[2]];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Дамп клиентского ядра (сетевой буфер, окно seq, оффсет) — зеркало
|
|
233
|
+
* серверного `debug_json`. null, если ядро игры собрано против движка
|
|
234
|
+
* без этого метода.
|
|
235
|
+
* @returns {Object|null}
|
|
236
|
+
*/
|
|
237
|
+
debug() {
|
|
238
|
+
if (typeof this.core.debug_json !== 'function') {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return JSON.parse(this.core.debug_json());
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Записи детектора рассинхрона предикта из ядра. Агрегаты накопительные,
|
|
246
|
+
// поэтому просто перезаписываются; записи — вычерпываются.
|
|
247
|
+
_drainDivergence() {
|
|
248
|
+
if (typeof this.core.take_divergence !== 'function') {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const dump = JSON.parse(this.core.take_divergence());
|
|
253
|
+
|
|
254
|
+
if (!dump) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const { records = [], ...stats } = dump;
|
|
259
|
+
|
|
260
|
+
this.divergenceStats = stats;
|
|
261
|
+
|
|
262
|
+
for (const record of records) {
|
|
263
|
+
this._note('divergence', record);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Срез сцены для дампа и для дифа между тиками. Копия обязательна: сцена
|
|
268
|
+
// живая, и срез по ссылке «догонял» бы последующие тики — все дампы вышли
|
|
269
|
+
// бы одинаковыми.
|
|
270
|
+
snapshot() {
|
|
271
|
+
return {
|
|
272
|
+
socketId: this.socketId,
|
|
273
|
+
gameId: this.gameId,
|
|
274
|
+
camera: this.camera ? [...this.camera] : null,
|
|
275
|
+
frameCount: this.frameCount,
|
|
276
|
+
entities: structuredClone(this.scene),
|
|
277
|
+
debug: this.debug(),
|
|
278
|
+
// накопительные агрегаты дрейфа предикта на момент среза
|
|
279
|
+
divergence: this.divergenceStats,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// зеркало applyGameData клиента: ключ схемы → parts через gameSets;
|
|
284
|
+
// отсутствие ключа в gameSets — это и есть «чёрный холст», поэтому
|
|
285
|
+
// фиксируется, а не игнорируется
|
|
286
|
+
_applyGameData(game) {
|
|
287
|
+
for (const [key, instances] of Object.entries(game)) {
|
|
288
|
+
const names = this._gameSets[key];
|
|
289
|
+
|
|
290
|
+
if (!names) {
|
|
291
|
+
this.decodeErrors.push({
|
|
292
|
+
message: `snapshot key '${key}' is missing from parts.gameSets`,
|
|
293
|
+
});
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (const name of names) {
|
|
298
|
+
if (!this._entitiesOnCanvas[name]) {
|
|
299
|
+
this.decodeErrors.push({
|
|
300
|
+
message: `part '${name}' is missing from parts.entitiesOnCanvas`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const bucket = (this.scene[key] ??= {});
|
|
306
|
+
const seen = (this.observed[key] ??= {
|
|
307
|
+
rows: 0,
|
|
308
|
+
ids: new Set(),
|
|
309
|
+
widths: new Set(),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
for (const [id, fields] of Object.entries(instances)) {
|
|
313
|
+
if (fields === null) {
|
|
314
|
+
delete bucket[id];
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
bucket[id] = fields;
|
|
319
|
+
seen.rows += 1;
|
|
320
|
+
seen.ids.add(String(id));
|
|
321
|
+
seen.widths.add(Array.isArray(fields) ? fields.length : -1);
|
|
322
|
+
|
|
323
|
+
this._checkFinite(key, id, fields);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// NaN/Infinity в поле — самый тихий из отказов: холст просто перестаёт
|
|
329
|
+
// рисовать сущность, ошибки нет нигде
|
|
330
|
+
_checkFinite(key, id, fields) {
|
|
331
|
+
const values = Array.isArray(fields) ? fields : [fields];
|
|
332
|
+
|
|
333
|
+
values.forEach((value, index) => {
|
|
334
|
+
if (typeof value === 'number' && !Number.isFinite(value)) {
|
|
335
|
+
this._note('nonFinite', {
|
|
336
|
+
key,
|
|
337
|
+
id: String(id),
|
|
338
|
+
index,
|
|
339
|
+
value: String(value),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_note(name, entry) {
|
|
346
|
+
if (this[name].length < NOTE_LIMIT) {
|
|
347
|
+
this[name].push(entry);
|
|
348
|
+
} else {
|
|
349
|
+
this.truncated[name] += 1;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// кадр приходит как ArrayBuffer (боевой транспорт) либо Uint8Array (ядро)
|
|
355
|
+
function toUint8(bytes) {
|
|
356
|
+
if (bytes instanceof Uint8Array) {
|
|
357
|
+
return bytes;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return new Uint8Array(bytes);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export default VirtualClient;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import clock from '../lib/clock.js';
|
|
2
|
+
|
|
3
|
+
// Виртуальное время для headless-прогона: очередь отложенных задач + advance().
|
|
4
|
+
// Без него десятиминутный матч невозможно прогнать за секунды, а два прогона
|
|
5
|
+
// одного сценария не совпадут побайтово.
|
|
6
|
+
//
|
|
7
|
+
// Случайность тоже здесь: mulberry32 — маленький, полностью воспроизводимый
|
|
8
|
+
// PRNG (тот же seed → та же последовательность), в отличие от Math.random.
|
|
9
|
+
|
|
10
|
+
// одна итерация mulberry32
|
|
11
|
+
const mulberry32 = seed => {
|
|
12
|
+
let state = seed >>> 0;
|
|
13
|
+
|
|
14
|
+
return () => {
|
|
15
|
+
state = (state + 0x6d2b79f5) >>> 0;
|
|
16
|
+
|
|
17
|
+
let t = state;
|
|
18
|
+
|
|
19
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
20
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
21
|
+
|
|
22
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
class VirtualClock {
|
|
27
|
+
/**
|
|
28
|
+
* @param {Object} [options]
|
|
29
|
+
* @param {number} [options.startTime] - Стартовая метка эпохи (мс) для now().
|
|
30
|
+
* @param {number} [options.seed] - Seed PRNG для random().
|
|
31
|
+
*/
|
|
32
|
+
constructor({ startTime = 1700000000000, seed = 1 } = {}) {
|
|
33
|
+
this._startTime = startTime;
|
|
34
|
+
// единый виртуальный счётчик: now() и monotonic() не могут разъехаться
|
|
35
|
+
this._elapsed = 0;
|
|
36
|
+
this._nextId = 1;
|
|
37
|
+
this._tasks = new Map(); // id → { time, callback, interval }
|
|
38
|
+
this._random = mulberry32(seed);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ***** реализация clock ***** //
|
|
42
|
+
|
|
43
|
+
now() {
|
|
44
|
+
return this._startTime + this._elapsed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
monotonic() {
|
|
48
|
+
return this._elapsed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
random() {
|
|
52
|
+
return this._random();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setTimeout(callback, delay = 0) {
|
|
56
|
+
return this._schedule(callback, delay, null);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
setInterval(callback, delay = 0) {
|
|
60
|
+
// нулевой интервал зациклил бы advance() навсегда
|
|
61
|
+
return this._schedule(callback, delay, Math.max(1, delay));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
clearTimeout(timerId) {
|
|
65
|
+
this._tasks.delete(timerId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
clearInterval(timerId) {
|
|
69
|
+
this._tasks.delete(timerId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ***** управление ***** //
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Подменяет глобальный clock этим экземпляром.
|
|
76
|
+
* @returns {Function} Функция отката.
|
|
77
|
+
*/
|
|
78
|
+
install() {
|
|
79
|
+
return clock.install({
|
|
80
|
+
now: () => this.now(),
|
|
81
|
+
monotonic: () => this.monotonic(),
|
|
82
|
+
random: () => this.random(),
|
|
83
|
+
setTimeout: (callback, delay) => this.setTimeout(callback, delay),
|
|
84
|
+
clearTimeout: timerId => this.clearTimeout(timerId),
|
|
85
|
+
setInterval: (callback, delay) => this.setInterval(callback, delay),
|
|
86
|
+
clearInterval: timerId => this.clearInterval(timerId),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Прокручивает время на ms, выполняя созревшие задачи в порядке
|
|
92
|
+
* (время, номер регистрации). Между задачами вычерпываются микрозадачи —
|
|
93
|
+
* иначе очередь HostGame (queueMicrotask в createUser) отстаёт от таймеров.
|
|
94
|
+
* @param {number} ms
|
|
95
|
+
*/
|
|
96
|
+
async advance(ms) {
|
|
97
|
+
const target = this._elapsed + ms;
|
|
98
|
+
|
|
99
|
+
for (;;) {
|
|
100
|
+
const next = this._earliest(target);
|
|
101
|
+
|
|
102
|
+
if (!next) {
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
this._elapsed = next.time;
|
|
107
|
+
|
|
108
|
+
if (next.interval === null) {
|
|
109
|
+
this._tasks.delete(next.id);
|
|
110
|
+
} else {
|
|
111
|
+
next.time += next.interval;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
next.callback();
|
|
115
|
+
|
|
116
|
+
await flushMicrotasks();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
this._elapsed = target;
|
|
120
|
+
|
|
121
|
+
await flushMicrotasks();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// число зарегистрированных задач (диагностика утечек таймеров)
|
|
125
|
+
get pendingCount() {
|
|
126
|
+
return this._tasks.size;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
_schedule(callback, delay, interval) {
|
|
130
|
+
const id = this._nextId;
|
|
131
|
+
|
|
132
|
+
this._nextId += 1;
|
|
133
|
+
this._tasks.set(id, {
|
|
134
|
+
id,
|
|
135
|
+
time: this._elapsed + Math.max(0, delay),
|
|
136
|
+
callback,
|
|
137
|
+
interval,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
return id;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ближайшая задача не позже target; при равном времени — та, что
|
|
144
|
+
// зарегистрирована раньше (иначе порядок зависел бы от обхода Map)
|
|
145
|
+
_earliest(target) {
|
|
146
|
+
let found = null;
|
|
147
|
+
|
|
148
|
+
for (const task of this._tasks.values()) {
|
|
149
|
+
if (task.time > target) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (
|
|
154
|
+
!found ||
|
|
155
|
+
task.time < found.time ||
|
|
156
|
+
(task.time === found.time && task.id < found.id)
|
|
157
|
+
) {
|
|
158
|
+
found = task;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return found;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export const flushMicrotasks = () =>
|
|
167
|
+
new Promise(resolve => queueMicrotask(resolve));
|
|
168
|
+
|
|
169
|
+
export default VirtualClock;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Срез меты хоста для проверок инвариантов. Читает приватные поля HostGame
|
|
2
|
+
// намеренно: отладочный контур не должен ради себя расширять боевой API
|
|
3
|
+
// хоста, а держать копию реестра участников в runner'е — значит проверять
|
|
4
|
+
// не то состояние, которым живёт матч. Весь доступ к внутренностям собран
|
|
5
|
+
// здесь, чтобы правка HostGame ломала один файл, а не пол-devtools.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {Object} host - HostGame прогона.
|
|
9
|
+
* @returns {Object} { activeList, humans, scripted, total, currentMap }.
|
|
10
|
+
*/
|
|
11
|
+
export function inspectHost(host) {
|
|
12
|
+
const participants = host._participants;
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
activeList: participants.getActiveList().map(String),
|
|
16
|
+
humans: participants.getHumans().map(user => String(user.gameId)),
|
|
17
|
+
scripted: participants.getScripted().map(user => String(user.gameId)),
|
|
18
|
+
total: participants.totalCount,
|
|
19
|
+
currentMap: host.currentMap,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Курированный дамп мира ядра через адаптер хоста (этап 4). null, если
|
|
25
|
+
* ядро игры собрано против движка без `debug_json`.
|
|
26
|
+
* @param {Object} host - HostGame прогона.
|
|
27
|
+
* @returns {Object|null}
|
|
28
|
+
*/
|
|
29
|
+
export function inspectCore(host) {
|
|
30
|
+
return host._game.debugJson();
|
|
31
|
+
}
|