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.
Files changed (50) hide show
  1. package/bin/vimp-sim.js +137 -0
  2. package/package.json +10 -3
  3. package/src/config/gameCodes.js +8 -0
  4. package/src/config/lobby.js +23 -1
  5. package/src/config/master.js +8 -0
  6. package/src/config/opcodes.js +3 -1
  7. package/src/devtools/RecordingSocketManager.js +118 -0
  8. package/src/devtools/ScenarioRunner.js +529 -0
  9. package/src/devtools/VirtualClient.js +363 -0
  10. package/src/devtools/VirtualClock.js +169 -0
  11. package/src/devtools/inspectHost.js +31 -0
  12. package/src/devtools/invariants.js +672 -0
  13. package/src/devtools/pluginLoader.js +101 -0
  14. package/src/devtools/report.js +202 -0
  15. package/src/devtools/resetHostSingletons.js +23 -0
  16. package/src/host/DebugRecorder.js +224 -0
  17. package/src/host/GameCoreAdapter.js +17 -0
  18. package/src/host/HostGame.js +129 -7
  19. package/src/host/host.worker.js +46 -47
  20. package/src/host/meta/SocketManager.js +15 -7
  21. package/src/host/meta/modules/Panel.js +7 -0
  22. package/src/host/meta/modules/PlayerDataSync.js +29 -5
  23. package/src/host/meta/modules/RTTManager.js +4 -2
  24. package/src/host/meta/modules/Stat.js +7 -0
  25. package/src/host/meta/modules/TimerManager.js +15 -7
  26. package/src/host/meta/modules/Vote.js +10 -1
  27. package/src/host/meta/modules/chat/Chat.js +7 -0
  28. package/src/host/meta/modules/chat/index.js +1 -1
  29. package/src/host/meta/player/HumanParticipant.js +2 -1
  30. package/src/lib/AbstractTimer.js +6 -4
  31. package/src/lib/applyRoomOverrides.js +6 -0
  32. package/src/lib/clientCoreConfig.js +4 -0
  33. package/src/lib/clock.js +53 -0
  34. package/src/lib/createHostRuntime.js +108 -0
  35. package/src/lib/reconstructHot.js +78 -0
  36. package/src/lib/validators.js +10 -0
  37. package/tests/fixtures/miniGame/client/fakeClientCore.js +304 -0
  38. package/tests/fixtures/miniGame/client/index.js +37 -0
  39. package/tests/fixtures/miniGame/client/parts/Actor.js +18 -0
  40. package/tests/fixtures/miniGame/client/parts/ActorRadar.js +7 -0
  41. package/tests/fixtures/miniGame/config/auth.js +41 -0
  42. package/tests/fixtures/miniGame/config/client.js +139 -0
  43. package/tests/fixtures/miniGame/config/game.js +148 -0
  44. package/tests/fixtures/miniGame/host/ScriptedManager.js +129 -0
  45. package/tests/fixtures/miniGame/host/createModules.js +6 -0
  46. package/tests/fixtures/miniGame/host/fakeCore.js +233 -0
  47. package/tests/fixtures/miniGame/host/index.js +29 -0
  48. package/tests/fixtures/miniGame/host/spawnCommand.js +14 -0
  49. package/tests/fixtures/miniGame/host/systemMessages.js +6 -0
  50. package/tests/fixtures/miniGame.contract.test.js +76 -0
@@ -1,4 +1,5 @@
1
1
  import Participant from './Participant.js';
2
+ import clock from '../../../lib/clock.js';
2
3
 
3
4
  // Участник-человек: поля и поведение, специфичные для реального игрока
4
5
  class HumanParticipant extends Participant {
@@ -24,7 +25,7 @@ class HumanParticipant extends Participant {
24
25
  this.watchedGameId = watchedGameId ?? null;
25
26
  this.forceCameraReset = true;
26
27
  this.pendingShake = null;
27
- this.lastActionTime = Date.now();
28
+ this.lastActionTime = clock.now();
28
29
  this.lastInputSeq = 0; // номер последнего обработанного ввода (предикшен)
29
30
  }
30
31
 
@@ -1,3 +1,5 @@
1
+ import clock from './clock.js';
2
+
1
3
  /**
2
4
  * @class AbstractTimer
3
5
  * @description Базовый класс для управления таймерами (setTimeout, setInterval)
@@ -25,7 +27,7 @@ class AbstractTimer {
25
27
 
26
28
  if (isInterval) {
27
29
  // setInterval живёт, пока его не остановят
28
- const timerId = setInterval(callback, duration);
30
+ const timerId = clock.setInterval(callback, duration);
29
31
  this._timers.set(key, { timerId, isInterval });
30
32
  } else {
31
33
  // setTimeout удаляется сразу после выполнения
@@ -34,7 +36,7 @@ class AbstractTimer {
34
36
  callback();
35
37
  };
36
38
 
37
- const timerId = setTimeout(wrappedCallback, duration);
39
+ const timerId = clock.setTimeout(wrappedCallback, duration);
38
40
  this._timers.set(key, { timerId, isInterval });
39
41
  }
40
42
  }
@@ -47,7 +49,7 @@ class AbstractTimer {
47
49
  _stopTimer(key) {
48
50
  if (this._timers.has(key)) {
49
51
  const { timerId, isInterval } = this._timers.get(key);
50
- const handler = isInterval ? clearInterval : clearTimeout;
52
+ const handler = isInterval ? clock.clearInterval : clock.clearTimeout;
51
53
 
52
54
  handler(timerId);
53
55
 
@@ -72,7 +74,7 @@ class AbstractTimer {
72
74
  _clearAllTimers() {
73
75
  for (const timerData of this._timers.values()) {
74
76
  const { timerId, isInterval } = timerData;
75
- const handler = isInterval ? clearInterval : clearTimeout;
77
+ const handler = isInterval ? clock.clearInterval : clock.clearTimeout;
76
78
 
77
79
  handler(timerId);
78
80
  }
@@ -40,6 +40,12 @@ export function applyRoomOverrides(room = {}, plugin) {
40
40
  game.timers.mapTime = clampTime(room.mapTime);
41
41
  }
42
42
 
43
+ // dev-режим вкладки хоста (этап 6 плана plan/ai-debug): включает рекордер
44
+ // и хостовый CONSOLE-лог. Прод-бандл его не выставляет — поведение то же
45
+ if (typeof room.isDevMode === 'boolean') {
46
+ game.isDevMode = room.isDevMode;
47
+ }
48
+
43
49
  if (typeof room.friendlyFire === 'boolean') {
44
50
  game.parts.friendlyFire = room.friendlyFire;
45
51
  }
@@ -40,6 +40,9 @@ export const buildClientCoreConfig = (
40
40
  },
41
41
  interpolation,
42
42
  seed: undefined,
43
+ // детектор рассинхрона предикта: в боевом конфиге секции нет (undefined
44
+ // не попадает в JSON) — ядро тогда не делает на пути кадра ничего лишнего
45
+ divergence: undefined,
43
46
  ...overrides,
44
47
  };
45
48
 
@@ -48,6 +51,7 @@ export const buildClientCoreConfig = (
48
51
  timeStepMs: flat.timeStepMs,
49
52
  snapshot: flat.snapshot,
50
53
  interpolation: flat.interpolation,
54
+ divergence: flat.divergence,
51
55
  },
52
56
  game: {
53
57
  playerKeys: flat.playerKeys,
@@ -0,0 +1,53 @@
1
+ // Единая точка доступа к времени, случайности и таймерам.
2
+ //
3
+ // Прод работает на реализации по умолчанию (глобалы) — поведение не меняется.
4
+ // Headless-runner подменяет её виртуальной через install(): без этого
5
+ // десятиминутный матч невозможно прогнать за секунды, а прогон со случайным
6
+ // seed невозможно воспроизвести.
7
+ //
8
+ // now() и monotonic() разделены намеренно: игровой цикл считает dt по
9
+ // performance.now(), разрешение которого сильно выше миллисекунды Date.now().
10
+ // Виртуальная реализация выводит оба из одного счётчика.
11
+
12
+ const defaults = {
13
+ now: () => Date.now(),
14
+ monotonic: () => performance.now(),
15
+ random: () => Math.random(),
16
+ setTimeout: (callback, delay) => setTimeout(callback, delay),
17
+ clearTimeout: timerId => clearTimeout(timerId),
18
+ setInterval: (callback, delay) => setInterval(callback, delay),
19
+ clearInterval: timerId => clearInterval(timerId),
20
+ };
21
+
22
+ let impl = defaults;
23
+
24
+ // подменяет реализацию; недостающие методы берутся из дефолтных
25
+ // возвращает функцию отката к предыдущей реализации
26
+ function install(custom = {}) {
27
+ const previous = impl;
28
+
29
+ impl = { ...defaults, ...custom };
30
+
31
+ return () => {
32
+ impl = previous;
33
+ };
34
+ }
35
+
36
+ // возврат к глобалам
37
+ function reset() {
38
+ impl = defaults;
39
+ }
40
+
41
+ // делегирование позднее (impl читается в момент вызова), чтобы install()
42
+ // действовал на уже импортировавшие clock модули
43
+ export default {
44
+ install,
45
+ reset,
46
+ now: () => impl.now(),
47
+ monotonic: () => impl.monotonic(),
48
+ random: () => impl.random(),
49
+ setTimeout: (callback, delay) => impl.setTimeout(callback, delay),
50
+ clearTimeout: timerId => impl.clearTimeout(timerId),
51
+ setInterval: (callback, delay) => impl.setInterval(callback, delay),
52
+ clearInterval: timerId => impl.clearInterval(timerId),
53
+ };
@@ -0,0 +1,108 @@
1
+ import clientDefaults from '../config/clientDefaults.js';
2
+ import wsports from '../config/wsports.js';
3
+ import { applyRoomOverrides } from './applyRoomOverrides.js';
4
+ import { buildClientConfig } from './buildClientConfig.js';
5
+ import { buildCoreConfig } from './coreConfig.js';
6
+ import { assertGameConfigShape } from './gamePlugin.js';
7
+ import clock from './clock.js';
8
+ import SocketManager from '../host/meta/SocketManager.js';
9
+ import HostGame from '../host/HostGame.js';
10
+
11
+ // Боевая инициализация авторитетного матча, вынутая из host.worker.js:onInit.
12
+ // Вызывается и Worker'ом, и headless-runner'ом (devtools/ScenarioRunner.js):
13
+ // собственная копия у runner'а неизбежно разъехалась бы с Worker'ом, а
14
+ // расхождение здесь означало бы, что отладочный прогон проверяет не тот код,
15
+ // который крутится в проде.
16
+
17
+ /**
18
+ * Поднимает HostPlugin игры, ядро, SocketManager и HostGame.
19
+ * @param {Object} room - Описание комнаты (game.hostEntryUrl, game.wasmUrl,
20
+ * game.version, hostSocketId, hostId/hostSecret, seed, переопределения).
21
+ * @param {Object} [options]
22
+ * @param {Function} [options.loadHostPlugin] - Загрузчик плагина; по умолчанию
23
+ * динамический import по room.game.hostEntryUrl.
24
+ * @param {Function} [options.createSocketManager] - Фабрика транспорта; по
25
+ * умолчанию боевой SocketManager (runner подставляет записывающий).
26
+ * @param {Object} [options.hostOptions] - Опции HostGame (onMapChange, handoff).
27
+ * @param {Function} [options.overrideGameConfig] - Правка собранного конфига
28
+ * игры перед созданием ядра; в проде не задаётся, нужна отладочному
29
+ * прогону (сценарий подкручивает networkSendRate и таймеры).
30
+ * @returns {Promise<Object>} { hostPlugin, game, seed, core, clientCfg,
31
+ * socketManager, host }.
32
+ */
33
+ export async function createHostRuntime(room, options = {}) {
34
+ const {
35
+ loadHostPlugin = defaultLoadHostPlugin,
36
+ createSocketManager = defaultCreateSocketManager,
37
+ hostOptions = {},
38
+ overrideGameConfig = null,
39
+ } = options;
40
+
41
+ const hostPlugin = await loadHostPlugin(room);
42
+
43
+ assertGameConfigShape(hostPlugin);
44
+
45
+ const game = applyRoomOverrides(room, hostPlugin);
46
+
47
+ if (overrideGameConfig) {
48
+ overrideGameConfig(game);
49
+ }
50
+
51
+ // seed уезжает наружу вызывающему: без него прогон матча невоспроизводим
52
+ // (реплей задаёт room.seed и получает тот же мир)
53
+ const seed =
54
+ room?.seed !== undefined && room.seed !== null
55
+ ? room.seed >>> 0
56
+ : (clock.random() * 2 ** 32) >>> 0;
57
+
58
+ const core = await hostPlugin.createCore(
59
+ JSON.stringify(
60
+ buildCoreConfig(hostPlugin.gameConfig, {
61
+ friendlyFire: game.parts.friendlyFire,
62
+ seed,
63
+ }),
64
+ ),
65
+ { wasmUrl: room.game?.wasmUrl },
66
+ );
67
+
68
+ const clientCfg = buildClientConfig(
69
+ game,
70
+ clientDefaults,
71
+ hostPlugin.buildClientGameConfig(),
72
+ );
73
+
74
+ const socketManager = createSocketManager(wsports.server, {
75
+ soundCues: game.soundCues,
76
+ initialVote: game.initialVote,
77
+ });
78
+
79
+ const host = new HostGame(game, socketManager, core, hostPlugin, {
80
+ hostSocketId: room?.hostSocketId ?? null,
81
+ gameVersion: room.game?.version ?? null,
82
+ // рекордер (этап 6 плана plan/ai-debug) кладёт seed в сценарий — без него
83
+ // записанный матч невоспроизводим
84
+ seed,
85
+ ...hostOptions,
86
+ });
87
+
88
+ // эстафета Worker'ов несёт уже известные hostId+секрет в room — новый
89
+ // Worker не должен ждать повторного register_host, чтобы возобновить
90
+ // атрибуцию rank/state-flush; при холодном старте они придут позже
91
+ if (room?.hostId) {
92
+ host.setHostId(room.hostId, room.hostSecret);
93
+ }
94
+
95
+ return { hostPlugin, game, seed, core, clientCfg, socketManager, host };
96
+ }
97
+
98
+ // до onInit движок игру не знает вовсе — только URL её host-бандла из
99
+ // GameManifest
100
+ function defaultLoadHostPlugin(room) {
101
+ return import(/* @vite-ignore */ room.game.hostEntryUrl).then(
102
+ module => module.default,
103
+ );
104
+ }
105
+
106
+ function defaultCreateSocketManager(ports, opts) {
107
+ return new SocketManager(ports, opts);
108
+ }
@@ -0,0 +1,78 @@
1
+ import { HOT_FLAGS } from '../config/opcodes.js';
2
+
3
+ // Разбор плоского hot-буфера клиентского ядра. Вынесено из client/main.js:
4
+ // раскладку читают двое — браузерный рендер-тик и headless-runner
5
+ // (devtools/VirtualClient.js), а расходиться им нельзя.
6
+
7
+ /**
8
+ * Строит обратный индекс снапшот-схемы игры: keyId → { key, kind, width }.
9
+ * Раскладку hot-буфера диктует схема игры (CONFIG_DATA.snapshot), движковый
10
+ * бандл её не знает.
11
+ * @param {Object} snapshot - Секция snapshot CONFIG_DATA.
12
+ * @returns {Object} Индекс по числовому id ключа.
13
+ */
14
+ export const buildSnapshotKeysById = snapshot =>
15
+ Object.fromEntries(
16
+ Object.entries(snapshot).map(([key, { id, kind, fields }]) => [
17
+ id,
18
+ { key, kind, width: 2 + fields.length },
19
+ ]),
20
+ );
21
+
22
+ /**
23
+ * Разбирает hot-буфер и сообщает, сколько float'ов ушло на разбор: длина
24
+ * пройденного куска обязана совпасть с длиной буфера, иначе раскладка ядра
25
+ * разъехалась со схемой игры (проверка инвариантов, devtools/invariants.js).
26
+ * @param {Float32Array} hot - Буфер рендер-тика (hot_ptr/hot_values).
27
+ * @param {Object} snapshotKeysById - Результат buildSnapshotKeysById.
28
+ * @returns {Object} { game, consumed }.
29
+ */
30
+ export function parseHot(hot, snapshotKeysById) {
31
+ const game = {};
32
+ let i = 3;
33
+
34
+ const readRecord = () => {
35
+ const spec = snapshotKeysById[hot[i]];
36
+
37
+ if (!spec) {
38
+ throw new Error(`hot buffer: unknown snapshot key id ${hot[i]}`);
39
+ }
40
+
41
+ const { key, kind, width } = spec;
42
+ const id = kind === 'indexedNoNull8' ? `d${hot[i + 1]}` : hot[i + 1];
43
+
44
+ (game[key] ??= {})[id] = Array.from(hot.subarray(i + 2, i + width));
45
+ i += width;
46
+ };
47
+
48
+ // две группы: Indexed8 (акторы), затем IndexedNoNull8 (динамика карты)
49
+ for (let g = 0; g < 2; g += 1) {
50
+ const count = hot[i];
51
+
52
+ i += 1;
53
+
54
+ for (let n = 0; n < count; n += 1) {
55
+ readRecord();
56
+ }
57
+ }
58
+
59
+ if (hot[0] & HOT_FLAGS.PREDICTED) {
60
+ readRecord();
61
+ }
62
+
63
+ return { game, consumed: i };
64
+ }
65
+
66
+ /**
67
+ * Восстанавливает объект игровых данных из плоского hot-буфера ядра:
68
+ * [3] N записей Indexed8-группы, затем M записей IndexedNoNull8-группы;
69
+ * каждая запись — keyId, id, поля по схеме игры (ширина = 2 + fields);
70
+ * у IndexedNoNull8 id получает префикс 'd' (динамика карты, зеркало
71
+ * snapshot_to_json ядра). Predicted-запись (последняя) перекрывает свою —
72
+ * предикт поверх интерполяции тем же parse-конвейером.
73
+ * @param {Float32Array} hot - Буфер рендер-тика (hot_ptr/hot_values).
74
+ * @param {Object} snapshotKeysById - Результат buildSnapshotKeysById.
75
+ * @returns {Object} { [ключ схемы]: { [id]: [поля] } }.
76
+ */
77
+ export const reconstructHot = (hot, snapshotKeysById) =>
78
+ parseHot(hot, snapshotKeysById).game;
@@ -53,3 +53,13 @@ export const validateAuth = (data, authParams, validators = {}) => {
53
53
 
54
54
  return errors.length ? errors : undefined;
55
55
  };
56
+
57
+ // клампит query-параметр в целое [1, max]; невалидное значение — fallback
58
+ // (lobby-page-plan: GET /auth/leaderboard?limit=, мастер клампит ещё раз
59
+ // перед проксированием). Вынесено из master/main.js (code review L3), чтобы
60
+ // клампинг был покрыт юнит-тестом отдельно от роута
61
+ export const clampLimit = (value, fallback, max) => {
62
+ const num = Number(value);
63
+
64
+ return Number.isInteger(num) ? Math.min(Math.max(num, 1), max) : fallback;
65
+ };
@@ -0,0 +1,304 @@
1
+ // Фейковое клиентское ядро миниигры-фикстуры: минимальная реализация
2
+ // движкового клиентского ABI (см. export_client_core_abi!, PLAN.md §3.4).
3
+ //
4
+ // Кадры фикстуры — не бинарный кодек ядра, а JSON (см. host/fakeCore.js:
5
+ // frame_bytes), поэтому push_frame их просто разбирает, а интерполяции нет:
6
+ // sample() отдаёт последний принятый кадр. Раскладка hot-буфера при этом
7
+ // настоящая (флаги, две группы, ширина записи = 2 + поля схемы) — именно её
8
+ // читает движковый reconstructHot, и именно она замыкает headless-контур
9
+ // «хост → байты → клиентское ядро → сцена» без WASM.
10
+
11
+ import { HOT_FLAGS } from '../../../../src/config/opcodes.js';
12
+
13
+ export default class FakeClientCore {
14
+ constructor(configJson) {
15
+ this._config = JSON.parse(configJson);
16
+ this._active = false;
17
+ this._frame = null;
18
+ this._hot = new Float32Array(0);
19
+
20
+ // обратный индекс снапшот-схемы: keyId → { key, kind, fields }
21
+ const keys = this._config.engine?.snapshot?.keys ?? {};
22
+
23
+ this._hotKey = Object.entries(keys).find(
24
+ ([, spec]) => spec.class === 'hot' && spec.kind === 'indexed8',
25
+ );
26
+
27
+ // детектор рассинхрона предикта (этап 5): включается секцией divergence
28
+ // движкового конфига, как и в Rust-ядре
29
+ const cfg = this._config.engine?.divergence;
30
+
31
+ this._divergenceCfg = cfg ?? null;
32
+ this._divergence = cfg
33
+ ? { samples: 0, violations: 0, dropped: 0, maxDelta: [0, 0], records: [] }
34
+ : null;
35
+
36
+ // предикт фикстуры: та же таблица скоростей, что у host/fakeCore, плюс
37
+ // история её изменений — реконсиляция идёт по времени кадра, поэтому
38
+ // история переигрывается от момента авторитетного состояния, а не от seq
39
+ this._vy = 0;
40
+ this._inputs = []; // [{ time, vy }] в локальном времени
41
+ this._base = null; // { x, y, time, vy } — последнее авторитетное, в серверном
42
+ this._offset = 0;
43
+ }
44
+
45
+ push_frame(data, localNow = 0) {
46
+ let frame;
47
+
48
+ try {
49
+ frame = JSON.parse(new TextDecoder().decode(data));
50
+ } catch {
51
+ return false;
52
+ }
53
+
54
+ this._offset = frame.serverTime - localNow;
55
+ this._observeDivergence(frame);
56
+ this._frame = frame;
57
+
58
+ return true;
59
+ }
60
+
61
+ my_game_id() {
62
+ return this._frame?.playerId ?? null;
63
+ }
64
+
65
+ offset() {
66
+ return 0;
67
+ }
68
+
69
+ sample() {
70
+ if (!this._frame || !this._hotKey) {
71
+ this._hot = new Float32Array(0);
72
+
73
+ return 0;
74
+ }
75
+
76
+ const [, spec] = this._hotKey;
77
+ const body = this._frame.body ?? [];
78
+ const camera = this._frame.camera;
79
+ const values = [
80
+ HOT_FLAGS.GAME | (camera ? HOT_FLAGS.CAMERA : 0),
81
+ camera ? camera[0] : 0,
82
+ camera ? camera[1] : 0,
83
+ body.length,
84
+ ];
85
+
86
+ for (const record of body) {
87
+ values.push(spec.id, record.id);
88
+
89
+ for (const field of spec.fields) {
90
+ values.push(Number(record[field.name] ?? 0));
91
+ }
92
+ }
93
+
94
+ // группа IndexedNoNull8 (динамика карты) — у фикстуры пуста
95
+ values.push(0);
96
+
97
+ this._hot = new Float32Array(values);
98
+
99
+ return this._hot.length;
100
+ }
101
+
102
+ hot_ptr() {
103
+ return 0;
104
+ }
105
+
106
+ hot_values() {
107
+ return this._hot;
108
+ }
109
+
110
+ take_frames() {
111
+ return '[]';
112
+ }
113
+
114
+ apply_input(action, name, localNow = 0) {
115
+ const magnitude = action === 'down' ? 40 : 0;
116
+
117
+ if (name === 'forward') {
118
+ this._vy = -magnitude;
119
+ } else if (name === 'back') {
120
+ this._vy = magnitude;
121
+ } else {
122
+ return;
123
+ }
124
+
125
+ this._inputs.push({ time: localNow, vy: this._vy });
126
+ }
127
+
128
+ // записи расхождения предикта; 'null' — детектор выключен конфигом
129
+ take_divergence() {
130
+ if (!this._divergence) {
131
+ return 'null';
132
+ }
133
+
134
+ const { records, ...stats } = this._divergence;
135
+
136
+ this._divergence.records = [];
137
+
138
+ return JSON.stringify({ ...stats, records });
139
+ }
140
+
141
+ // сравнение предсказанного своего актора с авторитетным player-блоком —
142
+ // до того, как авторитетное состояние станет новой базой предикта
143
+ _observeDivergence(frame) {
144
+ if (!this._divergence) {
145
+ return;
146
+ }
147
+
148
+ // playerId приходит в кадре хоста строкой (gameId меты) — наблюдатель
149
+ // помечен -1 и своего актора не имеет
150
+ const id = Number(frame.playerId);
151
+ const authoritative = (frame.body ?? []).find(row => row.id === id);
152
+
153
+ if (!Number.isInteger(id) || id < 0 || !authoritative) {
154
+ return;
155
+ }
156
+
157
+ const time = frame.serverTime;
158
+
159
+ if (this._base) {
160
+ const predicted = this._predictAt(time);
161
+
162
+ this._compare(predicted, authoritative, frame);
163
+ }
164
+
165
+ // реконсиляция: база — авторитетное состояние на serverTime кадра,
166
+ // история ввода до этого момента больше не переигрывается
167
+ const vy = this._velocityAt(time);
168
+
169
+ this._base = { x: authoritative.x, y: authoritative.y, time, vy };
170
+ this._inputs = this._inputs.filter(
171
+ input => input.time + this._offset > time,
172
+ );
173
+ }
174
+
175
+ _compare(predicted, authoritative, frame) {
176
+ const dump = this._divergence;
177
+ const cfg = this._divergenceCfg;
178
+ const pair = [
179
+ [predicted.x, authoritative.x],
180
+ [predicted.y, authoritative.y],
181
+ ];
182
+ const delta = pair.map(([a, b]) => a - b);
183
+ const thresholds = pair.map(
184
+ (_, index) => cfg.thresholds?.[index] ?? cfg.defaultThreshold ?? 1,
185
+ );
186
+ const exceeded = delta
187
+ .map((value, index) => (Math.abs(value) > thresholds[index] ? index : -1))
188
+ .filter(index => index !== -1);
189
+
190
+ dump.samples += 1;
191
+ delta.forEach((value, index) => {
192
+ dump.maxDelta[index] = Math.max(dump.maxDelta[index], Math.abs(value));
193
+ });
194
+
195
+ if (!exceeded.length) {
196
+ return;
197
+ }
198
+
199
+ dump.violations += 1;
200
+
201
+ if (dump.records.length >= (cfg.capacity ?? 64)) {
202
+ dump.records.shift();
203
+ dump.dropped += 1;
204
+ }
205
+
206
+ dump.records.push({
207
+ source: 'state',
208
+ serverTime: frame.serverTime,
209
+ localNow: frame.serverTime - this._offset,
210
+ offset: this._offset,
211
+ inputSeq: frame.seq,
212
+ replayed: {
213
+ from: this._base.time,
214
+ to: frame.serverTime,
215
+ count: this._inputs.length,
216
+ },
217
+ predicted: [predicted.x, predicted.y],
218
+ authoritative: [authoritative.x, authoritative.y],
219
+ delta,
220
+ exceeded,
221
+ thresholds,
222
+ });
223
+ }
224
+
225
+ // интегрирование скорости от базы до момента t (серверное время):
226
+ // кусочно-линейно по истории ввода — предикт переигрывает именно её
227
+ _predictAt(t) {
228
+ let { y, time, vy } = this._base;
229
+
230
+ for (const input of this._inputs) {
231
+ const at = input.time + this._offset;
232
+ const until = Math.min(at, t);
233
+
234
+ if (until > time) {
235
+ y += (vy * (until - time)) / 1000;
236
+ time = until;
237
+ }
238
+
239
+ if (at > t) {
240
+ return { x: this._base.x, y };
241
+ }
242
+
243
+ vy = input.vy;
244
+ }
245
+
246
+ if (t > time) {
247
+ y += (vy * (t - time)) / 1000;
248
+ }
249
+
250
+ return { x: this._base.x, y };
251
+ }
252
+
253
+ _velocityAt(time) {
254
+ let vy = this._base?.vy ?? 0;
255
+
256
+ for (const input of this._inputs) {
257
+ if (input.time + this._offset <= time) {
258
+ vy = input.vy;
259
+ }
260
+ }
261
+
262
+ return vy;
263
+ }
264
+
265
+ set_active(active) {
266
+ this._active = active;
267
+ }
268
+
269
+ set_map() {}
270
+
271
+ reset() {
272
+ this._frame = null;
273
+ this._hot = new Float32Array(0);
274
+ this._base = null;
275
+ this._inputs = [];
276
+ }
277
+
278
+ // зеркало ClientState::debug_json; у фикстуры буфер — один последний
279
+ // кадр, поэтому окно seq вырождено
280
+ debug_json() {
281
+ return JSON.stringify({
282
+ myGameId: this._frame?.playerId ?? null,
283
+ offset: 0,
284
+ interpolator: {
285
+ buffered: this._frame ? 1 : 0,
286
+ seqWindow: this._frame ? [this._frame.seq, this._frame.seq] : null,
287
+ lastFrame: this._frame
288
+ ? { seq: this._frame.seq, serverTime: this._frame.serverTime }
289
+ : null,
290
+ },
291
+ hotLen: this._hot.length,
292
+ framesOut: 0,
293
+ active: this._active,
294
+ });
295
+ }
296
+
297
+ decode_frame(data) {
298
+ try {
299
+ return JSON.stringify(JSON.parse(new TextDecoder().decode(data)));
300
+ } catch {
301
+ return 'null';
302
+ }
303
+ }
304
+ }
@@ -0,0 +1,37 @@
1
+ import { ENGINE_API_VERSION } from '../../../../src/config/opcodes.js';
2
+ import FakeClientCore from './fakeClientCore.js';
3
+ import Actor from './parts/Actor.js';
4
+ import ActorRadar from './parts/ActorRadar.js';
5
+
6
+ // ClientPlugin миниигры-фикстуры (Этап 7 плана отделения движка, PLAN.md
7
+ // §3.3): минимальные заглушечные parts (без PixiJS) — доказывает, что
8
+ // gamePlugin.js/Factory работают с любым ClientPlugin, а не только с
9
+ // @vimp-games/tanks.
10
+ export default {
11
+ id: 'miniGame',
12
+ engineApi: ENGINE_API_VERSION,
13
+
14
+ async createClientCore(clientConfigJson) {
15
+ return { core: new FakeClientCore(clientConfigJson), memory: null };
16
+ },
17
+
18
+ parts: { Actor, ActorRadar },
19
+ bakers: {},
20
+ styles: '',
21
+
22
+ hooks: {
23
+ onAuth(core, authData) {
24
+ core.set_model?.(authData.model);
25
+ },
26
+
27
+ onPanel() {},
28
+
29
+ onLocalAction(core, action, name, now) {
30
+ if (action === 'down' && name === 'fire') {
31
+ return core.try_fire?.(now) || null;
32
+ }
33
+
34
+ return null;
35
+ },
36
+ },
37
+ };