vimp-engine 0.33.2 → 0.34.1
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/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/main.js +5 -0
- package/src/devtools/ScenarioRunner.js +62 -8
- package/src/devtools/VirtualClient.js +6 -3
- package/src/devtools/contract/rules/d3-snapshot-interp.js +44 -1
- package/src/devtools/contract/rules/e7-map-game-field.js +58 -0
- package/src/devtools/contract/rules/index.js +2 -1
- package/src/lib/capabilities.js +8 -0
- package/tests/fixtures/miniGame/client/fakeClientCore.js +13 -2
package/core/Cargo.toml
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "vimp-engine-core"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.21.0"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
|
|
6
6
|
license = "MIT"
|
package/package.json
CHANGED
package/src/client/main.js
CHANGED
|
@@ -596,6 +596,8 @@ function applyMapData(data, { notifyHost = true } = {}) {
|
|
|
596
596
|
// значит и предсказание подъёма. Не доехав до клиентской `MapLevels`,
|
|
597
597
|
// она дала бы другой уклон, чем у хоста, — расхождение молчаливое
|
|
598
598
|
levelHeight: data.levelHeight,
|
|
599
|
+
// непрозрачные данные игры: движок их не читает и не масштабирует
|
|
600
|
+
game: data.game,
|
|
599
601
|
}),
|
|
600
602
|
);
|
|
601
603
|
} catch (e) {
|
|
@@ -673,6 +675,9 @@ function applyMapData(data, { notifyHost = true } = {}) {
|
|
|
673
675
|
// прежнее имя оставлено для парта, который его уже читает
|
|
674
676
|
physicsStatic,
|
|
675
677
|
scale,
|
|
678
|
+
// непрозрачные данные игры (`map.game`) как объявлены в карте: без
|
|
679
|
+
// масштабирования. `physicsDynamic[i].game` доезжает до `d{i}` сам
|
|
680
|
+
game: data.game,
|
|
676
681
|
};
|
|
677
682
|
|
|
678
683
|
staticIndex += 1;
|
|
@@ -32,6 +32,10 @@ const RECORDED_PORTS = {
|
|
|
32
32
|
sendTechInform: 'techInform',
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
+
// Сообщения, перед которыми отложенные кадры клиента доставляются сразу:
|
|
36
|
+
// они сбрасывают или заменяют его мир (см. routeFrame).
|
|
37
|
+
const FLUSHING_METHODS = new Set(['sendClear', 'sendMap', 'sendFirstShot']);
|
|
38
|
+
|
|
35
39
|
// В headless-контуре нет ни мастера, ни auth-сервиса — участник стартует с
|
|
36
40
|
// пустым профилем (lib/offlinePlayerData.js, общее со standalone/dedicated).
|
|
37
41
|
const emptyProfileFetch = offlinePlayerData();
|
|
@@ -133,6 +137,12 @@ export async function runScenario(rawScenario, options = {}) {
|
|
|
133
137
|
|
|
134
138
|
async function execute(scenario, plugin, virtualClock, { captureFrames }) {
|
|
135
139
|
const clients = new Map(); // socketId → VirtualClient
|
|
140
|
+
// sendShot не вталкивается в клиента сразу: кадр момента T, доставленный
|
|
141
|
+
// до render(T), сравнивался бы детектором рассинхрона с репликой на
|
|
142
|
+
// T − timeStep — ложный дрейф v · timeStep. Кадры копятся здесь и
|
|
143
|
+
// доставляются после рендер-тика (docs/*/debugging.md, «Prediction
|
|
144
|
+
// divergence detector»)
|
|
145
|
+
const pendingShots = new Map(); // socketId → [bytes]
|
|
136
146
|
const byParticipant = new Map(); // id сценария → { socketId, gameId }
|
|
137
147
|
const participantLog = []; // [{ who, socketId, gameId, joinTick, leaveTick }]
|
|
138
148
|
const mapChanges = [];
|
|
@@ -165,7 +175,8 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
|
|
|
165
175
|
// получить те же порты и ту же игровую параметризацию
|
|
166
176
|
createSocketManager: (ports, gameOpts) => {
|
|
167
177
|
socketManager = new RecordingSocketManager(ports, gameOpts, {
|
|
168
|
-
onFrame: frame =>
|
|
178
|
+
onFrame: frame =>
|
|
179
|
+
routeFrame(frame, clients, pendingShots, virtualClock, host),
|
|
169
180
|
});
|
|
170
181
|
|
|
171
182
|
return socketManager;
|
|
@@ -211,6 +222,7 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
|
|
|
211
222
|
clientCfg,
|
|
212
223
|
plugin,
|
|
213
224
|
clients,
|
|
225
|
+
pendingShots,
|
|
214
226
|
byParticipant,
|
|
215
227
|
participantLog,
|
|
216
228
|
inputSeq,
|
|
@@ -224,8 +236,9 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
|
|
|
224
236
|
await virtualClock.advance(stepMs);
|
|
225
237
|
}
|
|
226
238
|
|
|
227
|
-
for (const client of clients
|
|
239
|
+
for (const [socketId, client] of clients) {
|
|
228
240
|
client.render(virtualClock.monotonic());
|
|
241
|
+
deliverShots(socketId, clients, pendingShots, virtualClock);
|
|
229
242
|
}
|
|
230
243
|
|
|
231
244
|
if (dumpTicks.has(tick)) {
|
|
@@ -240,6 +253,16 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
|
|
|
240
253
|
}
|
|
241
254
|
}
|
|
242
255
|
|
|
256
|
+
// кадры последнего advance доставляются уже после финального дампа (срез
|
|
257
|
+
// сцены не меняется), но ещё раз проходят рендер: без него проверки
|
|
258
|
+
// finiteValues/hotLayout/renderCoverage не видели бы последнего тика, а
|
|
259
|
+
// агрегаты детектора (render вычерпывает их первым делом) — его кадров.
|
|
260
|
+
// Время то же — реплика не шагает повторно
|
|
261
|
+
for (const [socketId, client] of clients) {
|
|
262
|
+
deliverShots(socketId, clients, pendingShots, virtualClock);
|
|
263
|
+
client.render(virtualClock.monotonic());
|
|
264
|
+
}
|
|
265
|
+
|
|
243
266
|
return buildReport({
|
|
244
267
|
scenario,
|
|
245
268
|
plugin,
|
|
@@ -261,13 +284,31 @@ async function execute(scenario, plugin, virtualClock, { captureFrames }) {
|
|
|
261
284
|
|
|
262
285
|
// исходящий кадр хоста → клиент-получатель. sendShot несёт байты, которые в
|
|
263
286
|
// проде ушли бы в data channel — их и скармливаем клиентскому ядру
|
|
264
|
-
function routeFrame(frame, clients, virtualClock, host) {
|
|
287
|
+
function routeFrame(frame, clients, pendingShots, virtualClock, host) {
|
|
265
288
|
const client = clients.get(frame.socketId);
|
|
266
289
|
|
|
267
290
|
if (!client) {
|
|
268
291
|
return;
|
|
269
292
|
}
|
|
270
293
|
|
|
294
|
+
// кадр ждёт рендер-тика своего момента (см. pendingShots в runScenario)
|
|
295
|
+
if (frame.method === 'sendShot') {
|
|
296
|
+
if (!pendingShots.has(frame.socketId)) {
|
|
297
|
+
pendingShots.set(frame.socketId, []);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
pendingShots.get(frame.socketId).push(frame.args[0]);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// отложенные кадры выпускают только сообщения, меняющие мир клиента: кадр
|
|
305
|
+
// старой карты не должен доехать после CLEAR/MAP/первого снапшота. Пинг
|
|
306
|
+
// (каждые rttPingInterval), панель и прочие очередь не трогают — иначе
|
|
307
|
+
// кадр их тика доставлялся бы до рендера, на шаг раньше реплики
|
|
308
|
+
if (FLUSHING_METHODS.has(frame.method)) {
|
|
309
|
+
deliverShots(frame.socketId, clients, pendingShots, virtualClock);
|
|
310
|
+
}
|
|
311
|
+
|
|
271
312
|
// pong отвечается мгновенно: латентность headless-прогона равна нулю, и
|
|
272
313
|
// это единственная честная её модель
|
|
273
314
|
if (frame.method === 'sendPing') {
|
|
@@ -275,11 +316,6 @@ function routeFrame(frame, clients, virtualClock, host) {
|
|
|
275
316
|
return;
|
|
276
317
|
}
|
|
277
318
|
|
|
278
|
-
if (frame.method === 'sendShot') {
|
|
279
|
-
client.pushFrame(frame.args[0], virtualClock.monotonic());
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
319
|
// первый снапшот мира: в браузере он применяется сразу (applyShot), а не
|
|
284
320
|
// едет через буфер интерполяции — сущность, доехавшая только им, иначе не
|
|
285
321
|
// попадёт ни в сцену, ни в проверки
|
|
@@ -307,10 +343,27 @@ function routeFrame(frame, clients, virtualClock, host) {
|
|
|
307
343
|
}
|
|
308
344
|
}
|
|
309
345
|
|
|
346
|
+
function deliverShots(socketId, clients, pendingShots, virtualClock) {
|
|
347
|
+
const queue = pendingShots.get(socketId);
|
|
348
|
+
|
|
349
|
+
if (!queue?.length) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
pendingShots.delete(socketId);
|
|
354
|
+
|
|
355
|
+
const client = clients.get(socketId);
|
|
356
|
+
|
|
357
|
+
for (const bytes of queue) {
|
|
358
|
+
client?.pushFrame(bytes, virtualClock.monotonic());
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
310
362
|
async function applyOp(op, ctx) {
|
|
311
363
|
const {
|
|
312
364
|
host,
|
|
313
365
|
clients,
|
|
366
|
+
pendingShots,
|
|
314
367
|
byParticipant,
|
|
315
368
|
participantLog,
|
|
316
369
|
inputSeq,
|
|
@@ -333,6 +386,7 @@ async function applyOp(op, ctx) {
|
|
|
333
386
|
// текучкой участников растит процесс
|
|
334
387
|
clients.get(entry.socketId)?.destroy();
|
|
335
388
|
clients.delete(entry.socketId);
|
|
389
|
+
pendingShots.delete(entry.socketId);
|
|
336
390
|
byParticipant.delete(op.who);
|
|
337
391
|
|
|
338
392
|
const logged = participantLog.find(
|
|
@@ -206,6 +206,8 @@ class VirtualClient {
|
|
|
206
206
|
// разъедется с авторитетным молча
|
|
207
207
|
levels: data.levels,
|
|
208
208
|
ramps: data.ramps,
|
|
209
|
+
// непрозрачные данные игры — как в main.js
|
|
210
|
+
game: data.game,
|
|
209
211
|
}),
|
|
210
212
|
);
|
|
211
213
|
} catch (e) {
|
|
@@ -220,7 +222,7 @@ class VirtualClient {
|
|
|
220
222
|
render(localNow) {
|
|
221
223
|
// записи копятся в ядре на push_frame, поэтому вычерпываются до sample —
|
|
222
224
|
// иначе тик с пустым hot-буфером (ранний выход ниже) их бы потерял
|
|
223
|
-
this.
|
|
225
|
+
this.drainDivergence();
|
|
224
226
|
|
|
225
227
|
const len = this.core.sample(localNow);
|
|
226
228
|
|
|
@@ -297,8 +299,9 @@ class VirtualClient {
|
|
|
297
299
|
}
|
|
298
300
|
|
|
299
301
|
// Записи детектора рассинхрона предикта из ядра. Агрегаты накопительные,
|
|
300
|
-
// поэтому просто перезаписываются; записи — вычерпываются.
|
|
301
|
-
|
|
302
|
+
// поэтому просто перезаписываются; записи — вычерпываются. Публичный:
|
|
303
|
+
// раннер вычерпывает кадры, доставленные после последнего рендер-тика.
|
|
304
|
+
drainDivergence() {
|
|
302
305
|
if (typeof this.core.take_divergence !== 'function') {
|
|
303
306
|
return;
|
|
304
307
|
}
|
|
@@ -3,11 +3,16 @@ import { ERROR, skip, verdict } from '../result.js';
|
|
|
3
3
|
// Интерполируются только f32 и только в горячих блоках. interp на u8 или
|
|
4
4
|
// в event-блоке не ошибка сборки — он просто не исполняется, и поле
|
|
5
5
|
// дёргается вместо плавного движения.
|
|
6
|
+
//
|
|
7
|
+
// Там же — роль `state` (байт состояния тела карты): u8 сразу за головой
|
|
8
|
+
// строки динамики (позиция 5 у слоёной строки, 3 — у плоской) и до
|
|
9
|
+
// опционального хвоста. Ядро отвергает иное при загрузке карты, то есть
|
|
10
|
+
// уже в запущенном матче (зеркало `BlockSchema::validate_roles`).
|
|
6
11
|
export default {
|
|
7
12
|
id: 'D3',
|
|
8
13
|
name: 'snapshotInterp',
|
|
9
14
|
level: ERROR,
|
|
10
|
-
title: "interp only on f32 fields of class 'hot' blocks",
|
|
15
|
+
title: "interp only on f32 fields of class 'hot' blocks; role 'state' is a u8 right after the row head",
|
|
11
16
|
|
|
12
17
|
check(ctx) {
|
|
13
18
|
const snapshot = ctx.gameConfig?.snapshot;
|
|
@@ -19,6 +24,8 @@ export default {
|
|
|
19
24
|
const violations = [];
|
|
20
25
|
|
|
21
26
|
for (const [key, block] of Object.entries(snapshot)) {
|
|
27
|
+
checkStateRole(key, block, violations);
|
|
28
|
+
|
|
22
29
|
for (const field of block.fields ?? []) {
|
|
23
30
|
if (field.interp === undefined) {
|
|
24
31
|
continue;
|
|
@@ -43,3 +50,39 @@ export default {
|
|
|
43
50
|
return verdict(violations);
|
|
44
51
|
},
|
|
45
52
|
};
|
|
53
|
+
|
|
54
|
+
// позиции зафиксированы движком (`GameMap::dynamic_map_data_with_state`)
|
|
55
|
+
const STATE_INDEX_FLAT = 3;
|
|
56
|
+
const STATE_INDEX_LAYERED = 5;
|
|
57
|
+
|
|
58
|
+
function checkStateRole(key, block, violations) {
|
|
59
|
+
const fields = block.fields ?? [];
|
|
60
|
+
const layered = fields[3]?.role === 'z' && fields[4]?.role === 'level';
|
|
61
|
+
const expected = layered ? STATE_INDEX_LAYERED : STATE_INDEX_FLAT;
|
|
62
|
+
|
|
63
|
+
fields.forEach((field, index) => {
|
|
64
|
+
if (field.role !== 'state') {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const at = `snapshot "${key}".${field.name}`;
|
|
69
|
+
|
|
70
|
+
if (index !== expected) {
|
|
71
|
+
violations.push(
|
|
72
|
+
`${at}: role 'state' at index ${index}, the engine writes it at ` +
|
|
73
|
+
`${expected} (right after the ${layered ? 'layered' : 'flat'} row head)`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (field.ty !== 'u8') {
|
|
78
|
+
violations.push(`${at}: role 'state' must be ty "u8", got "${field.ty}"`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (block.optionalFrom !== undefined && block.optionalFrom <= index) {
|
|
82
|
+
violations.push(
|
|
83
|
+
`${at}: role 'state' at index ${index} lies inside the optional tail ` +
|
|
84
|
+
`(optionalFrom ${block.optionalFrom}) — a resting body would lose it`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { ERROR, skip, verdict } from '../result.js';
|
|
2
|
+
|
|
3
|
+
// Поле карты `game` (и `physicsDynamic[i].game`) — пространство игры:
|
|
4
|
+
// движок его не читает и не масштабирует, но фиксирует форму. Ядро
|
|
5
|
+
// (`MapConfig::validate`) отвергает не-объект при загрузке карты, то есть в
|
|
6
|
+
// уже запущенном матче; здесь то же ловится до сборки.
|
|
7
|
+
export default {
|
|
8
|
+
id: 'E7',
|
|
9
|
+
name: 'mapGameField',
|
|
10
|
+
level: ERROR,
|
|
11
|
+
title: 'map game field is a plain object',
|
|
12
|
+
|
|
13
|
+
check(ctx) {
|
|
14
|
+
const maps = ctx.gameConfig?.maps;
|
|
15
|
+
|
|
16
|
+
if (!maps) {
|
|
17
|
+
return skip('no gameConfig.maps');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const declared = Object.entries(maps).filter(
|
|
21
|
+
([, map]) =>
|
|
22
|
+
map?.game !== undefined ||
|
|
23
|
+
(map?.physicsDynamic ?? []).some(object => object?.game !== undefined),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (!declared.length) {
|
|
27
|
+
return skip('no map declares game');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const violations = [];
|
|
31
|
+
|
|
32
|
+
for (const [name, map] of declared) {
|
|
33
|
+
if (map.game !== undefined && !isPlainObject(map.game)) {
|
|
34
|
+
violations.push(`map "${name}": game is not a plain object`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const [index, object] of (map.physicsDynamic ?? []).entries()) {
|
|
38
|
+
if (object?.game !== undefined && !isPlainObject(object.game)) {
|
|
39
|
+
violations.push(
|
|
40
|
+
`map "${name}": physicsDynamic ${index} game is not a plain object`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return verdict(violations);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// `null` ядро принимает как «не объявлено», но в исходнике карты это почти
|
|
51
|
+
// всегда опечатка — правило требует либо объект, либо отсутствие поля
|
|
52
|
+
function isPlainObject(value) {
|
|
53
|
+
return (
|
|
54
|
+
value !== null &&
|
|
55
|
+
typeof value === 'object' &&
|
|
56
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -35,6 +35,7 @@ import e3 from './e3-sound-registry.js';
|
|
|
35
35
|
import e4 from './e4-map-layers.js';
|
|
36
36
|
import e5 from './e5-map-radar-walls.js';
|
|
37
37
|
import e6 from './e6-sound-spatial.js';
|
|
38
|
+
import e7 from './e7-map-game-field.js';
|
|
38
39
|
|
|
39
40
|
// Порядок групп — порядок отчёта: A пакет и сборка, B host, C client,
|
|
40
41
|
// D снапшот, E ассеты (plan/create-vimp-game/stage_1.md).
|
|
@@ -43,7 +44,7 @@ export const rules = [
|
|
|
43
44
|
b1, b2, b3, b4, b5, b6, b7, b8, b9, b10,
|
|
44
45
|
c1, c2, c3, c4, c5, c6, c7, c8, c9, c10, c11,
|
|
45
46
|
d1, d2, d3,
|
|
46
|
-
e1, e2, e3, e4, e5, e6,
|
|
47
|
+
e1, e2, e3, e4, e5, e6, e7,
|
|
47
48
|
];
|
|
48
49
|
|
|
49
50
|
export default rules;
|
package/src/lib/capabilities.js
CHANGED
|
@@ -35,6 +35,14 @@ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
|
|
|
35
35
|
// уклон рампы в ядре, поле `levelHeight` в контексте статического парта,
|
|
36
36
|
// прогоны рамп уровня (`ramps`) там же, коллайдеры-стражи прогона
|
|
37
37
|
{ value: 'map.levelHeight', since: '0.32.0' },
|
|
38
|
+
// непрозрачное поле карты `game` (и `physicsDynamic[i].game`): до
|
|
39
|
+
// игрового ядра хоста, `ClientCore.set_map` и партов карты; хук ядра
|
|
40
|
+
// `GameSim::on_map_loaded`
|
|
41
|
+
{ value: 'map.gameData', since: '0.34.0' },
|
|
42
|
+
// байт состояния тела карты (роль `state` в строке динамики,
|
|
43
|
+
// `SimCtx::map_body_state`) и доступ игры к телам карты по индексу
|
|
44
|
+
// (хэндл, индекс по хэндлу, отключение тела)
|
|
45
|
+
{ value: 'map.bodyState', since: '0.34.0' },
|
|
38
46
|
]);
|
|
39
47
|
|
|
40
48
|
// плоский список имён — то, чем правило контракта B2 проверяет `requires`
|
|
@@ -40,6 +40,10 @@ export default class FakeClientCore {
|
|
|
40
40
|
this._inputs = []; // [{ time, vy }] в локальном времени
|
|
41
41
|
this._base = null; // { x, y, time, vy } — последнее авторитетное, в серверном
|
|
42
42
|
this._offset = 0;
|
|
43
|
+
// локальное время последнего sample(): реплика Rust-ядра шагает только
|
|
44
|
+
// в нём, и predicted_state() отдаёт состояние на этот момент, а не на
|
|
45
|
+
// момент кадра — фикстура сравнивает так же, чтобы сдвиг был виден
|
|
46
|
+
this._sampledAt = null;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
push_frame(data, localNow = 0) {
|
|
@@ -66,7 +70,9 @@ export default class FakeClientCore {
|
|
|
66
70
|
return 0;
|
|
67
71
|
}
|
|
68
72
|
|
|
69
|
-
sample() {
|
|
73
|
+
sample(localNow = 0) {
|
|
74
|
+
this._sampledAt = localNow;
|
|
75
|
+
|
|
70
76
|
if (!this._frame || !this._hotKey) {
|
|
71
77
|
this._hot = new Float32Array(0);
|
|
72
78
|
|
|
@@ -157,7 +163,11 @@ export default class FakeClientCore {
|
|
|
157
163
|
const time = frame.serverTime;
|
|
158
164
|
|
|
159
165
|
if (this._base) {
|
|
160
|
-
|
|
166
|
+
// до первого sample() момента реплики ещё нет — сравнение на момент
|
|
167
|
+
// кадра, как было до учёта sample (сдвига в этот момент нет)
|
|
168
|
+
const predicted = this._predictAt(
|
|
169
|
+
this._sampledAt === null ? time : this._sampledAt + this._offset,
|
|
170
|
+
);
|
|
161
171
|
|
|
162
172
|
this._compare(predicted, authoritative, frame);
|
|
163
173
|
}
|
|
@@ -273,6 +283,7 @@ export default class FakeClientCore {
|
|
|
273
283
|
this._hot = new Float32Array(0);
|
|
274
284
|
this._base = null;
|
|
275
285
|
this._inputs = [];
|
|
286
|
+
this._sampledAt = null;
|
|
276
287
|
}
|
|
277
288
|
|
|
278
289
|
// зеркало ClientState::debug_json; у фикстуры буфер — один последний
|