vimp-engine 0.22.1 → 0.24.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 (69) hide show
  1. package/bin/vimp-surface.js +88 -0
  2. package/core/Cargo.toml +1 -1
  3. package/package.json +1 -1
  4. package/src/client/lib/formBuilder.js +38 -27
  5. package/src/client/lib/pickActiveGame.js +59 -0
  6. package/src/client/lib/socketDispatch.js +34 -0
  7. package/src/client/main.js +61 -14
  8. package/src/config/abiOps.js +37 -0
  9. package/src/config/clientServices.js +27 -0
  10. package/src/config/opcodes.js +11 -2
  11. package/src/config/wsports.js +9 -0
  12. package/src/devtools/contract/loadContext.js +25 -2
  13. package/src/devtools/contract/rules/b10-respawns.js +5 -3
  14. package/src/devtools/contract/rules/b2-engine-api.js +81 -10
  15. package/src/devtools/contract/rules/b3-game-config-shape.js +46 -5
  16. package/src/devtools/contract/rules/b4-teams.js +6 -3
  17. package/src/devtools/contract/rules/b5-room-form.js +40 -8
  18. package/src/devtools/contract/rules/c10-auth-schema.js +34 -1
  19. package/src/devtools/contract/rules/c4-component-dependencies.js +10 -15
  20. package/src/devtools/surface/abiParse.js +175 -0
  21. package/src/devtools/surface/collect.js +413 -0
  22. package/src/host/GameCoreAdapter.js +49 -0
  23. package/src/lib/applyRoomOverrides.js +16 -2
  24. package/src/lib/capabilities.js +32 -0
  25. package/src/lib/coreAbi.js +116 -0
  26. package/src/lib/coreConfig.js +3 -3
  27. package/src/lib/createHostRuntime.js +5 -3
  28. package/src/lib/formControls.js +89 -0
  29. package/src/lib/formUnit.js +25 -0
  30. package/src/lib/gameConfigView.js +214 -0
  31. package/src/lib/gamePlugin.js +86 -86
  32. package/src/lib/loadGamePackage.js +9 -2
  33. package/src/lib/registry.js +94 -0
  34. package/src/lib/validators.js +79 -17
  35. package/src/standalone/index.js +42 -13
  36. package/tests/fixtures/generations/gen-api3/README.md +7 -0
  37. package/tests/fixtures/generations/gen-api3/client/fakeClientCore.js +307 -0
  38. package/tests/fixtures/generations/gen-api3/client/index.js +38 -0
  39. package/tests/fixtures/generations/gen-api3/client/parts/Actor.js +18 -0
  40. package/tests/fixtures/generations/gen-api3/client/parts/ActorRadar.js +7 -0
  41. package/tests/fixtures/generations/gen-api3/config/auth.js +41 -0
  42. package/tests/fixtures/generations/gen-api3/config/client.js +151 -0
  43. package/tests/fixtures/generations/gen-api3/config/game.js +167 -0
  44. package/tests/fixtures/generations/gen-api3/core/pkg-node/core.js +4 -0
  45. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +129 -0
  46. package/tests/fixtures/generations/gen-api3/host/createModules.js +6 -0
  47. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +263 -0
  48. package/tests/fixtures/generations/gen-api3/host/index.js +30 -0
  49. package/tests/fixtures/generations/gen-api3/host/spawnCommand.js +14 -0
  50. package/tests/fixtures/generations/gen-api3/host/systemMessages.js +6 -0
  51. package/tests/fixtures/generations/gen-api3/manifest.json +25 -0
  52. package/tests/fixtures/generations/gen-api4/README.md +7 -0
  53. package/tests/fixtures/generations/gen-api4/client/fakeClientCore.js +307 -0
  54. package/tests/fixtures/generations/gen-api4/client/index.js +38 -0
  55. package/tests/fixtures/generations/gen-api4/client/parts/Actor.js +18 -0
  56. package/tests/fixtures/generations/gen-api4/client/parts/ActorRadar.js +7 -0
  57. package/tests/fixtures/generations/gen-api4/config/auth.js +41 -0
  58. package/tests/fixtures/generations/gen-api4/config/client.js +151 -0
  59. package/tests/fixtures/generations/gen-api4/config/game.js +156 -0
  60. package/tests/fixtures/generations/gen-api4/core/pkg-node/core.js +4 -0
  61. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +129 -0
  62. package/tests/fixtures/generations/gen-api4/host/createModules.js +6 -0
  63. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +263 -0
  64. package/tests/fixtures/generations/gen-api4/host/index.js +30 -0
  65. package/tests/fixtures/generations/gen-api4/host/spawnCommand.js +14 -0
  66. package/tests/fixtures/generations/gen-api4/host/systemMessages.js +6 -0
  67. package/tests/fixtures/generations/gen-api4/manifest.json +17 -0
  68. package/tests/fixtures/miniGame/client/fakeClientCore.js +17 -0
  69. package/tests/fixtures/miniGame/host/fakeCore.js +38 -8
@@ -0,0 +1,94 @@
1
+ // Append-only реестр (И1 плана plugin-forward-compat, этап 3). Движок держит
2
+ // закрытые словари, из которых игра выбирает значения: контролы формы, имена
3
+ // клиентских сервисов. Запись НИКОГДА не удаляется: игра, собранная два года
4
+ // назад, могла её написать, и её dist больше никто не тронет — сокращение
5
+ // словаря отвергает такую игру молча. Вывод из эксплуатации =
6
+ // { alias: 'новое-имя' } + запись в CHANGELOG, но не удаление строки.
7
+ //
8
+ // Именно так сломался v2 → v3: набор `control` урезали до четырёх нативных
9
+ // элементов, и `range`/`number`/`toggle`/`segmented` перестали строиться.
10
+
11
+ /**
12
+ * Создаёт append-only реестр имён плагинной поверхности.
13
+ * @param {string} name - Имя реестра (в тексте ошибок).
14
+ * @param {Array<Object>} entries - Записи `{ value, since, alias?,
15
+ * retiredIn?, note?, ...полезная нагрузка }`. Запись с `alias` — выведенное
16
+ * из эксплуатации имя: оно продолжает работать, разрешаясь в указанное.
17
+ * @returns {Object} Реестр: `has`, `get`, `resolve`, `chain`, `isRetired`,
18
+ * `list`, `values`.
19
+ */
20
+ export function createRegistry(name, entries) {
21
+ const byValue = new Map();
22
+
23
+ for (const entry of entries) {
24
+ if (byValue.has(entry.value)) {
25
+ throw new Error(`registry ${name}: duplicate entry "${entry.value}"`);
26
+ }
27
+
28
+ byValue.set(entry.value, Object.freeze({ ...entry }));
29
+ }
30
+
31
+ // цепочка алиасов от имени к активной записи; пустая, если имени нет
32
+ const chain = value => {
33
+ const trail = [];
34
+ const seen = new Set();
35
+ let current = byValue.get(value);
36
+
37
+ while (current !== undefined) {
38
+ if (seen.has(current.value)) {
39
+ throw new Error(
40
+ `registry ${name}: alias cycle at "${current.value}" — ` +
41
+ 'a retired name must resolve to an active one',
42
+ );
43
+ }
44
+
45
+ seen.add(current.value);
46
+ trail.push(current);
47
+
48
+ if (current.alias === undefined) {
49
+ return trail;
50
+ }
51
+
52
+ const next = byValue.get(current.alias);
53
+
54
+ if (next === undefined) {
55
+ throw new Error(
56
+ `registry ${name}: "${current.value}" is aliased to unknown ` +
57
+ `"${current.alias}"`,
58
+ );
59
+ }
60
+
61
+ current = next;
62
+ }
63
+
64
+ return trail;
65
+ };
66
+
67
+ // битый реестр — дефект движка, а не плагина: пусть падает на загрузке
68
+ // модуля, а не на первой игре, которая напишет выведенное имя
69
+ for (const value of byValue.keys()) {
70
+ chain(value);
71
+ }
72
+
73
+ return Object.freeze({
74
+ name,
75
+ has: value => byValue.has(value),
76
+ get: value => byValue.get(value),
77
+ // разрешает имя в активное (проходит цепочку алиасов). undefined — имя
78
+ // неизвестно; это ВСЕГДА ошибка плагина (он попросил будущее), никогда
79
+ // не ошибка движка за то, что у него список длиннее
80
+ resolve: value => chain(value).at(-1)?.value,
81
+ chain,
82
+ isRetired: value => byValue.get(value)?.alias !== undefined,
83
+ // все записи, включая выведенные: слепок поверхности (этап 1) не считает
84
+ // вывод из эксплуатации удалением
85
+ list: () => [...byValue.values()],
86
+ // только активные имена — то, что вправе написать НОВАЯ игра
87
+ values: () =>
88
+ [...byValue.values()]
89
+ .filter(e => e.alias === undefined)
90
+ .map(e => e.value),
91
+ });
92
+ }
93
+
94
+ export default createRegistry;
@@ -1,5 +1,7 @@
1
1
  import { anchorPattern } from './formPattern.js';
2
2
  import { normalizeOptions } from './formOptions.js';
3
+ import { resolveDescriptor } from './formControls.js';
4
+ import { toDisplay, isNumericField } from './formUnit.js';
3
5
 
4
6
  const NAME_REGEXP = new RegExp('^[a-zA-Z]([\\w\\s#]{0,13})[\\w]{1}$');
5
7
 
@@ -30,6 +32,33 @@ const OPTION_CONTROLS = ['select', 'radio'];
30
32
  // умолчанию — text (тот же дефолт, что у билдера формы)
31
33
  const isTextControl = control => control === 'text' || control === undefined;
32
34
 
35
+ // числовое поле (control 'text' + numeric/unit — в том числе разрешённые из
36
+ // выведенных 'number' и 'range'). Форма отдаёт его ЧИСЛОМ в единице
37
+ // хранения (formBuilder.buildText: getValue → toStored(Number(...))), а не
38
+ // строкой, поэтому оно идёт отдельной веткой до проверки типа
39
+ const isNumericControl = options =>
40
+ isTextControl(options?.control) && isNumericField(options);
41
+
42
+ // min/max дескриптора объявлены в единице ОТОБРАЖЕНИЯ, а по сети едет
43
+ // единица хранения — сравниваем ровно так же, как validateField на клиенте
44
+ const numericError = (options, value) => {
45
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
46
+ return 'must be a number';
47
+ }
48
+
49
+ const shown = toDisplay(options, value);
50
+
51
+ if (options.min !== undefined && shown < options.min) {
52
+ return `must be >= ${options.min}`;
53
+ }
54
+
55
+ if (options.max !== undefined && shown > options.max) {
56
+ return `must be <= ${options.max}`;
57
+ }
58
+
59
+ return null;
60
+ };
61
+
33
62
  // source-варианты хост не резолвит (их и форма в auth не резолвит: она
34
63
  // строится с пустым ctx) — сверяем только объявленный inline-список.
35
64
  // String(): и <option>.value, и <input type=radio>.value — DOM-свойства,
@@ -76,6 +105,26 @@ export const resolveValidator = (name, validators = {}) => {
76
105
  return typeof fn === 'function' ? fn : undefined;
77
106
  };
78
107
 
108
+ // игровой валидатор поля (authSchema.validators). Нерезолвнутое имя
109
+ // (опечатка) и не-функция ведут себя одинаково — поле проходит. Для клиента
110
+ // это норма (игровые валидаторы к нему не едут, авторитет проверки на
111
+ // хосте), для хоста — дефект схемы, о котором говорят C10 и console.error в
112
+ // PortMachine: звать что попало нельзя, TypeError отсюда уходит прямо в
113
+ // обработчик сообщения
114
+ const gameValidatorError = (options, value, validators) => {
115
+ if (options?.validator === undefined) {
116
+ return null;
117
+ }
118
+
119
+ const validatorFn = resolveValidator(options.validator, validators);
120
+
121
+ return validatorFn && !validatorFn(value) ? 'not valid' : null;
122
+ };
123
+
124
+ // null → ничего не добавляем: errors.push(...withName(...)) читается одной
125
+ // строкой на обеих ветках поля
126
+ const withName = (name, error) => (error === null ? [] : [{ name, error }]);
127
+
79
128
  /**
80
129
  * Валидирует объект с данными для авторизации.
81
130
  * @param {object} data - Объект с данными для проверки.
@@ -87,13 +136,38 @@ export const resolveValidator = (name, validators = {}) => {
87
136
  export const validateAuth = (data, authParams, validators = {}) => {
88
137
  const errors = [];
89
138
 
90
- for (const { name, options } of authParams) {
139
+ for (const { name, options: declared } of authParams) {
91
140
  if (!(name in data)) {
92
141
  return [{ name, error: `Property is missing` }];
93
142
  }
94
143
 
144
+ // Тот же резолв алиаса, что делает билдер формы (client/lib/formBuilder.js:
145
+ // buildField, collectFormErrors, resolveForcedValue). Без него контрол,
146
+ // выведенный из эксплуатации в v3 ('segmented', 'number', 'range',
147
+ // 'toggle'), не совпадает ни с одним именем ниже, и клиент, обошедший
148
+ // форму, получает поле ВООБЩЕ без проверок — ровно то превосходство над
149
+ // заполнившим форму, которого здесь быть не должно. Алиасы стали
150
+ // достижимы вместе с реестром контролов (этап 3 plugin-forward-compat):
151
+ // до него такое поле не строилось у клиента и сюда не доезжало
152
+ const options = resolveDescriptor(declared);
95
153
  const value = data[name];
96
154
 
155
+ // Числовое поле идёт своей веткой: форма отдаёт его числом, и общий
156
+ // путь ниже (длина, список вариантов, regExp) к нему не применим —
157
+ // проверяется диапазон, ровно как validateField делает на клиенте.
158
+ // Игровой валидатор зовётся для обоих видов поля одинаково
159
+ if (isNumericControl(options)) {
160
+ const error = numericError(options, value);
161
+
162
+ errors.push(
163
+ ...withName(
164
+ name,
165
+ error ?? gameValidatorError(options, value, validators),
166
+ ),
167
+ );
168
+ continue;
169
+ }
170
+
97
171
  if (typeof value !== 'string') {
98
172
  return [{ name, error: `Property must be a string` }];
99
173
  }
@@ -103,10 +177,7 @@ export const validateAuth = (data, authParams, validators = {}) => {
103
177
  // не должен получать больше прав, чем клиент, её заполнивший. Пустое
104
178
  // значение пропускается ровно как на клиенте (required здесь не
105
179
  // проверяется: solo-путь boot.autoAuth отвечает дефолтами схемы, среди
106
- // которых бывает '') — пустота остаётся делом игрового валидатора.
107
- // min/max формы здесь не применяются и не нужны: числовое поле отдаёт из
108
- // формы число, а нестроковое значение отбито выше, то есть числовых
109
- // полей в authSchema не бывает вовсе
180
+ // которых бывает '') — пустота остаётся делом игрового валидатора
110
181
 
111
182
  // длина — первой и безусловно: потолок ограничивает не столько ввод,
112
183
  // сколько работу паттерна ниже (см. MAX_FIELD_LENGTH)
@@ -142,18 +213,9 @@ export const validateAuth = (data, authParams, validators = {}) => {
142
213
  continue;
143
214
  }
144
215
 
145
- if (options?.validator) {
146
- const validatorFn = resolveValidator(options.validator, validators);
147
-
148
- // нерезолвнутое имя (опечатка) и не-функция ведут себя одинаково —
149
- // поле проходит. Для клиента это норма (игровые валидаторы к нему не
150
- // едут, авторитет проверки на хосте), для хоста — дефект схемы, о
151
- // котором говорят C10 и console.error в PortMachine: звать что попало
152
- // нельзя, TypeError отсюда уходит прямо в обработчик сообщения
153
- if (validatorFn && !validatorFn(value)) {
154
- errors.push({ name, error: 'not valid' });
155
- }
156
- }
216
+ errors.push(
217
+ ...withName(name, gameValidatorError(options, value, validators)),
218
+ );
157
219
  }
158
220
 
159
221
  return errors.length ? errors : undefined;
@@ -1,4 +1,7 @@
1
- import { assertEngineApiCompatible } from '../lib/gamePlugin.js';
1
+ import {
2
+ assertGameConfigShape,
3
+ checkPluginCompatibility,
4
+ } from '../lib/gamePlugin.js';
2
5
  import { setBootConfig } from '../client/boot.js';
3
6
  import { ensureGameShell } from '../client/views/gameShell.js';
4
7
 
@@ -33,6 +36,10 @@ import { ensureGameShell } from '../client/views/gameShell.js';
33
36
  * голосований, например `['/bot 4']`.
34
37
  * @param {Object} [options.room] - Переопределения комнаты (map, maxPlayers,
35
38
  * roundTime, friendlyFire, seed).
39
+ * @param {Array<string>} [options.requires] - Возможности движка, без
40
+ * которых игра не запустится (аналог `GameManifest.requires`, которого в
41
+ * solo-режиме нет). По умолчанию — объединение `requires` обеих половин
42
+ * плагина.
36
43
  * @param {boolean} [options.devMode] - room.isDevMode: рекордер и хостовый
37
44
  * CONSOLE-лог.
38
45
  * @returns {Promise<Object>} `{ stop() }` — останов матча.
@@ -49,27 +56,41 @@ export async function startStandaloneGame({
49
56
  startupVotes = [],
50
57
  startupCommands = [],
51
58
  room = {},
59
+ requires,
52
60
  devMode = false,
53
61
  } = {}) {
54
62
  requireOption(hostPlugin, 'hostPlugin');
55
63
  requireOption(clientPlugin, 'clientPlugin');
56
64
  requireOption(wasmUrl, 'wasmUrl');
57
65
 
58
- // несовпадение ENGINE_API_VERSION иначе всплыло бы где-то в середине
59
- // хендшейка непрозрачной ошибкой
60
- assertEngineApiCompatible(hostPlugin);
61
- assertEngineApiCompatible(clientPlugin);
66
+ // возможность, которой в этой сборке движка нет, иначе всплыла бы где-то
67
+ // в середине хендшейка непрозрачной ошибкой. Мастера тут нет, а значит нет
68
+ // и GameManifest с его `requires` — источником служат сами половины
69
+ // плагина (необязательное поле `requires`, docs/en/plugin-api.md) либо
70
+ // опция вызова, если встраивающий знает лучше. Одна проверка вместо двух:
71
+ // сообщение адресовано разработчику встраивания, делить его по половинам
72
+ // незачем
73
+ requireCompatible({
74
+ id: hostPlugin.id,
75
+ requires: requires ?? [
76
+ ...(hostPlugin.requires ?? []),
77
+ ...(clientPlugin.requires ?? []),
78
+ ],
79
+ });
80
+
81
+ // одна view на прогон: умолчания вместо обязательных полей (И2 этапа 2)
82
+ const configView = assertGameConfigShape(hostPlugin);
62
83
 
63
84
  ensureGameShell(container);
64
85
 
65
86
  setBootConfig({
66
87
  mode: 'solo',
67
88
  container,
68
- manifest: buildManifest(hostPlugin, { wasmUrl, assetsBase }),
89
+ manifest: buildManifest(hostPlugin, configView, { wasmUrl, assetsBase }),
69
90
  clientPlugin,
70
91
  hostPlugin,
71
92
  room: {
72
- ...hostPlugin.gameConfig.roomDefaults,
93
+ ...configView.roomDefaults,
73
94
  ...room,
74
95
  isDevMode: devMode,
75
96
  },
@@ -91,6 +112,16 @@ export async function startStandaloneGame({
91
112
  };
92
113
  }
93
114
 
115
+ // игра просит возможность, которой в этой сборке движка нет: подменить её в
116
+ // SDK нечем — матч не поднимется
117
+ function requireCompatible(plugin) {
118
+ const compat = checkPluginCompatibility(plugin);
119
+
120
+ if (!compat.ok) {
121
+ throw new Error(compat.text);
122
+ }
123
+ }
124
+
94
125
  // незаданное поле — это «взять дефолт схемы», а не «отправить undefined»:
95
126
  // JSON.stringify ключ с undefined выбрасывает, и хост отвечает
96
127
  // 'Property is missing' (main.js накрывает autoAuth поверх дефолтов схемы)
@@ -103,18 +134,16 @@ function pruneUndefined(source) {
103
134
  // манифест-подобный объект в памяти: мастера нет, а клиент читает из
104
135
  // манифеста id/version, entries.wasm и assetsBase. entries.client/host не
105
136
  // нужны — оба плагина уже живые объекты
106
- function buildManifest(hostPlugin, { wasmUrl, assetsBase }) {
107
- const { gameConfig } = hostPlugin;
108
-
137
+ function buildManifest(hostPlugin, configView, { wasmUrl, assetsBase }) {
109
138
  return {
110
139
  id: hostPlugin.id,
111
140
  engineApi: hostPlugin.engineApi,
112
141
  version: 'dev',
113
- title: gameConfig.title ?? hostPlugin.id,
142
+ title: configView.title ?? hostPlugin.id,
114
143
  entries: { wasm: wasmUrl },
115
144
  assetsBase,
116
- maps: { version: 'dev', list: Object.keys(gameConfig.maps) },
117
- roomDefaults: gameConfig.roomDefaults,
145
+ maps: { version: 'dev', list: Object.keys(configView.maps) },
146
+ roomDefaults: configView.roomDefaults,
118
147
  };
119
148
  }
120
149
 
@@ -0,0 +1,7 @@
1
+ # gen-api3 — ЗАМОРОЖЕНО
2
+
3
+ Снимок плагина 2026-08-29, поколение `engineApi: 3`.
4
+ Не править: правка фикстуры маскирует слом совместимости
5
+ (этап 1 плана `plan/plugin-forward-compat`). Новое поколение
6
+ добавляется копированием текущего `../../miniGame`, а не правкой
7
+ этого каталога.
@@ -0,0 +1,307 @@
1
+ // ЗАМОРОЖЕНО. Снимок плагина 2026-08-29 (поколение engineApi: 3).
2
+ // Не править: правка фикстуры маскирует слом совместимости.
3
+ // Фейковое клиентское ядро миниигры-фикстуры: минимальная реализация
4
+ // движкового клиентского ABI (см. export_client_core_abi!, PLAN.md §3.4).
5
+ //
6
+ // Кадры фикстуры — не бинарный кодек ядра, а JSON (см. host/fakeCore.js:
7
+ // frame_bytes), поэтому push_frame их просто разбирает, а интерполяции нет:
8
+ // sample() отдаёт последний принятый кадр. Раскладка hot-буфера при этом
9
+ // настоящая (флаги, две группы, ширина записи = 2 + поля схемы) — именно её
10
+ // читает движковый reconstructHot, и именно она замыкает headless-контур
11
+ // «хост → байты → клиентское ядро → сцена» без WASM.
12
+
13
+ // флаги hot-буфера на момент снимка (движковый src/config/opcodes.js)
14
+ const HOT_FLAGS = { GAME: 1, CAMERA: 2, PREDICTED: 4, FRAMES: 8 };
15
+
16
+ export default class FakeClientCore {
17
+ constructor(configJson) {
18
+ this._config = JSON.parse(configJson);
19
+ this._active = false;
20
+ this._frame = null;
21
+ this._hot = new Float32Array(0);
22
+
23
+ // обратный индекс снапшот-схемы: keyId → { key, kind, fields }
24
+ const keys = this._config.engine?.snapshot?.keys ?? {};
25
+
26
+ this._hotKey = Object.entries(keys).find(
27
+ ([, spec]) => spec.class === 'hot' && spec.kind === 'indexed8',
28
+ );
29
+
30
+ // детектор рассинхрона предикта (этап 5): включается секцией divergence
31
+ // движкового конфига, как и в Rust-ядре
32
+ const cfg = this._config.engine?.divergence;
33
+
34
+ this._divergenceCfg = cfg ?? null;
35
+ this._divergence = cfg
36
+ ? { samples: 0, violations: 0, dropped: 0, maxDelta: [0, 0], records: [] }
37
+ : null;
38
+
39
+ // предикт фикстуры: та же таблица скоростей, что у host/fakeCore, плюс
40
+ // история её изменений — реконсиляция идёт по времени кадра, поэтому
41
+ // история переигрывается от момента авторитетного состояния, а не от seq
42
+ this._vy = 0;
43
+ this._inputs = []; // [{ time, vy }] в локальном времени
44
+ this._base = null; // { x, y, time, vy } — последнее авторитетное, в серверном
45
+ this._offset = 0;
46
+ }
47
+
48
+ push_frame(data, localNow = 0) {
49
+ let frame;
50
+
51
+ try {
52
+ frame = JSON.parse(new TextDecoder().decode(data));
53
+ } catch {
54
+ return false;
55
+ }
56
+
57
+ this._offset = frame.serverTime - localNow;
58
+ this._observeDivergence(frame);
59
+ this._frame = frame;
60
+
61
+ return true;
62
+ }
63
+
64
+ my_game_id() {
65
+ return this._frame?.playerId ?? null;
66
+ }
67
+
68
+ offset() {
69
+ return 0;
70
+ }
71
+
72
+ sample() {
73
+ if (!this._frame || !this._hotKey) {
74
+ this._hot = new Float32Array(0);
75
+
76
+ return 0;
77
+ }
78
+
79
+ const [, spec] = this._hotKey;
80
+ const body = this._frame.body ?? [];
81
+ const camera = this._frame.camera;
82
+ const values = [
83
+ HOT_FLAGS.GAME | (camera ? HOT_FLAGS.CAMERA : 0),
84
+ camera ? camera[0] : 0,
85
+ camera ? camera[1] : 0,
86
+ body.length,
87
+ ];
88
+
89
+ for (const record of body) {
90
+ values.push(spec.id, record.id);
91
+
92
+ for (const field of spec.fields) {
93
+ values.push(Number(record[field.name] ?? 0));
94
+ }
95
+ }
96
+
97
+ // группа IndexedNoNull8 (динамика карты) — у фикстуры пуста
98
+ values.push(0);
99
+
100
+ this._hot = new Float32Array(values);
101
+
102
+ return this._hot.length;
103
+ }
104
+
105
+ hot_ptr() {
106
+ return 0;
107
+ }
108
+
109
+ hot_values() {
110
+ return this._hot;
111
+ }
112
+
113
+ take_frames() {
114
+ return '[]';
115
+ }
116
+
117
+ apply_input(action, name, localNow = 0) {
118
+ const magnitude = action === 'down' ? 40 : 0;
119
+
120
+ if (name === 'forward') {
121
+ this._vy = -magnitude;
122
+ } else if (name === 'back') {
123
+ this._vy = magnitude;
124
+ } else {
125
+ return;
126
+ }
127
+
128
+ this._inputs.push({ time: localNow, vy: this._vy });
129
+ }
130
+
131
+ // записи расхождения предикта; 'null' — детектор выключен конфигом
132
+ take_divergence() {
133
+ if (!this._divergence) {
134
+ return 'null';
135
+ }
136
+
137
+ const { records, ...stats } = this._divergence;
138
+
139
+ this._divergence.records = [];
140
+
141
+ return JSON.stringify({ ...stats, records });
142
+ }
143
+
144
+ // сравнение предсказанного своего актора с авторитетным player-блоком —
145
+ // до того, как авторитетное состояние станет новой базой предикта
146
+ _observeDivergence(frame) {
147
+ if (!this._divergence) {
148
+ return;
149
+ }
150
+
151
+ // playerId приходит в кадре хоста строкой (gameId меты) — наблюдатель
152
+ // помечен -1 и своего актора не имеет
153
+ const id = Number(frame.playerId);
154
+ const authoritative = (frame.body ?? []).find(row => row.id === id);
155
+
156
+ if (!Number.isInteger(id) || id < 0 || !authoritative) {
157
+ return;
158
+ }
159
+
160
+ const time = frame.serverTime;
161
+
162
+ if (this._base) {
163
+ const predicted = this._predictAt(time);
164
+
165
+ this._compare(predicted, authoritative, frame);
166
+ }
167
+
168
+ // реконсиляция: база — авторитетное состояние на serverTime кадра,
169
+ // история ввода до этого момента больше не переигрывается
170
+ const vy = this._velocityAt(time);
171
+
172
+ this._base = { x: authoritative.x, y: authoritative.y, time, vy };
173
+ this._inputs = this._inputs.filter(
174
+ input => input.time + this._offset > time,
175
+ );
176
+ }
177
+
178
+ _compare(predicted, authoritative, frame) {
179
+ const dump = this._divergence;
180
+ const cfg = this._divergenceCfg;
181
+ const pair = [
182
+ [predicted.x, authoritative.x],
183
+ [predicted.y, authoritative.y],
184
+ ];
185
+ const delta = pair.map(([a, b]) => a - b);
186
+ const thresholds = pair.map(
187
+ (_, index) => cfg.thresholds?.[index] ?? cfg.defaultThreshold ?? 1,
188
+ );
189
+ const exceeded = delta
190
+ .map((value, index) => (Math.abs(value) > thresholds[index] ? index : -1))
191
+ .filter(index => index !== -1);
192
+
193
+ dump.samples += 1;
194
+ delta.forEach((value, index) => {
195
+ dump.maxDelta[index] = Math.max(dump.maxDelta[index], Math.abs(value));
196
+ });
197
+
198
+ if (!exceeded.length) {
199
+ return;
200
+ }
201
+
202
+ dump.violations += 1;
203
+
204
+ if (dump.records.length >= (cfg.capacity ?? 64)) {
205
+ dump.records.shift();
206
+ dump.dropped += 1;
207
+ }
208
+
209
+ dump.records.push({
210
+ source: 'state',
211
+ serverTime: frame.serverTime,
212
+ localNow: frame.serverTime - this._offset,
213
+ offset: this._offset,
214
+ inputSeq: frame.seq,
215
+ replayed: {
216
+ from: this._base.time,
217
+ to: frame.serverTime,
218
+ count: this._inputs.length,
219
+ },
220
+ predicted: [predicted.x, predicted.y],
221
+ authoritative: [authoritative.x, authoritative.y],
222
+ delta,
223
+ exceeded,
224
+ thresholds,
225
+ });
226
+ }
227
+
228
+ // интегрирование скорости от базы до момента t (серверное время):
229
+ // кусочно-линейно по истории ввода — предикт переигрывает именно её
230
+ _predictAt(t) {
231
+ let { y, time, vy } = this._base;
232
+
233
+ for (const input of this._inputs) {
234
+ const at = input.time + this._offset;
235
+ const until = Math.min(at, t);
236
+
237
+ if (until > time) {
238
+ y += (vy * (until - time)) / 1000;
239
+ time = until;
240
+ }
241
+
242
+ if (at > t) {
243
+ return { x: this._base.x, y };
244
+ }
245
+
246
+ vy = input.vy;
247
+ }
248
+
249
+ if (t > time) {
250
+ y += (vy * (t - time)) / 1000;
251
+ }
252
+
253
+ return { x: this._base.x, y };
254
+ }
255
+
256
+ _velocityAt(time) {
257
+ let vy = this._base?.vy ?? 0;
258
+
259
+ for (const input of this._inputs) {
260
+ if (input.time + this._offset <= time) {
261
+ vy = input.vy;
262
+ }
263
+ }
264
+
265
+ return vy;
266
+ }
267
+
268
+ set_active(active) {
269
+ this._active = active;
270
+ }
271
+
272
+ set_map() {}
273
+
274
+ reset() {
275
+ this._frame = null;
276
+ this._hot = new Float32Array(0);
277
+ this._base = null;
278
+ this._inputs = [];
279
+ }
280
+
281
+ // зеркало ClientState::debug_json; у фикстуры буфер — один последний
282
+ // кадр, поэтому окно seq вырождено
283
+ debug_json() {
284
+ return JSON.stringify({
285
+ myGameId: this._frame?.playerId ?? null,
286
+ offset: 0,
287
+ interpolator: {
288
+ buffered: this._frame ? 1 : 0,
289
+ seqWindow: this._frame ? [this._frame.seq, this._frame.seq] : null,
290
+ lastFrame: this._frame
291
+ ? { seq: this._frame.seq, serverTime: this._frame.serverTime }
292
+ : null,
293
+ },
294
+ hotLen: this._hot.length,
295
+ framesOut: 0,
296
+ active: this._active,
297
+ });
298
+ }
299
+
300
+ decode_frame(data) {
301
+ try {
302
+ return JSON.stringify(JSON.parse(new TextDecoder().decode(data)));
303
+ } catch {
304
+ return 'null';
305
+ }
306
+ }
307
+ }