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.
- package/bin/vimp-surface.js +88 -0
- package/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/lib/formBuilder.js +38 -27
- package/src/client/lib/pickActiveGame.js +59 -0
- package/src/client/lib/socketDispatch.js +34 -0
- package/src/client/main.js +61 -14
- package/src/config/abiOps.js +37 -0
- package/src/config/clientServices.js +27 -0
- package/src/config/opcodes.js +11 -2
- package/src/config/wsports.js +9 -0
- package/src/devtools/contract/loadContext.js +25 -2
- package/src/devtools/contract/rules/b10-respawns.js +5 -3
- package/src/devtools/contract/rules/b2-engine-api.js +81 -10
- package/src/devtools/contract/rules/b3-game-config-shape.js +46 -5
- package/src/devtools/contract/rules/b4-teams.js +6 -3
- package/src/devtools/contract/rules/b5-room-form.js +40 -8
- package/src/devtools/contract/rules/c10-auth-schema.js +34 -1
- package/src/devtools/contract/rules/c4-component-dependencies.js +10 -15
- package/src/devtools/surface/abiParse.js +175 -0
- package/src/devtools/surface/collect.js +413 -0
- package/src/host/GameCoreAdapter.js +49 -0
- package/src/lib/applyRoomOverrides.js +16 -2
- package/src/lib/capabilities.js +32 -0
- package/src/lib/coreAbi.js +116 -0
- package/src/lib/coreConfig.js +3 -3
- package/src/lib/createHostRuntime.js +5 -3
- package/src/lib/formControls.js +89 -0
- package/src/lib/formUnit.js +25 -0
- package/src/lib/gameConfigView.js +214 -0
- package/src/lib/gamePlugin.js +86 -86
- package/src/lib/loadGamePackage.js +9 -2
- package/src/lib/registry.js +94 -0
- package/src/lib/validators.js +79 -17
- package/src/standalone/index.js +42 -13
- package/tests/fixtures/generations/gen-api3/README.md +7 -0
- package/tests/fixtures/generations/gen-api3/client/fakeClientCore.js +307 -0
- package/tests/fixtures/generations/gen-api3/client/index.js +38 -0
- package/tests/fixtures/generations/gen-api3/client/parts/Actor.js +18 -0
- package/tests/fixtures/generations/gen-api3/client/parts/ActorRadar.js +7 -0
- package/tests/fixtures/generations/gen-api3/config/auth.js +41 -0
- package/tests/fixtures/generations/gen-api3/config/client.js +151 -0
- package/tests/fixtures/generations/gen-api3/config/game.js +167 -0
- package/tests/fixtures/generations/gen-api3/core/pkg-node/core.js +4 -0
- package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +129 -0
- package/tests/fixtures/generations/gen-api3/host/createModules.js +6 -0
- package/tests/fixtures/generations/gen-api3/host/fakeCore.js +263 -0
- package/tests/fixtures/generations/gen-api3/host/index.js +30 -0
- package/tests/fixtures/generations/gen-api3/host/spawnCommand.js +14 -0
- package/tests/fixtures/generations/gen-api3/host/systemMessages.js +6 -0
- package/tests/fixtures/generations/gen-api3/manifest.json +25 -0
- package/tests/fixtures/generations/gen-api4/README.md +7 -0
- package/tests/fixtures/generations/gen-api4/client/fakeClientCore.js +307 -0
- package/tests/fixtures/generations/gen-api4/client/index.js +38 -0
- package/tests/fixtures/generations/gen-api4/client/parts/Actor.js +18 -0
- package/tests/fixtures/generations/gen-api4/client/parts/ActorRadar.js +7 -0
- package/tests/fixtures/generations/gen-api4/config/auth.js +41 -0
- package/tests/fixtures/generations/gen-api4/config/client.js +151 -0
- package/tests/fixtures/generations/gen-api4/config/game.js +156 -0
- package/tests/fixtures/generations/gen-api4/core/pkg-node/core.js +4 -0
- package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +129 -0
- package/tests/fixtures/generations/gen-api4/host/createModules.js +6 -0
- package/tests/fixtures/generations/gen-api4/host/fakeCore.js +263 -0
- package/tests/fixtures/generations/gen-api4/host/index.js +30 -0
- package/tests/fixtures/generations/gen-api4/host/spawnCommand.js +14 -0
- package/tests/fixtures/generations/gen-api4/host/systemMessages.js +6 -0
- package/tests/fixtures/generations/gen-api4/manifest.json +17 -0
- package/tests/fixtures/miniGame/client/fakeClientCore.js +17 -0
- 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;
|
package/src/lib/validators.js
CHANGED
|
@@ -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
|
-
|
|
146
|
-
|
|
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;
|
package/src/standalone/index.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
59
|
-
// хендшейка непрозрачной
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
...
|
|
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:
|
|
142
|
+
title: configView.title ?? hostPlugin.id,
|
|
114
143
|
entries: { wasm: wasmUrl },
|
|
115
144
|
assetsBase,
|
|
116
|
-
maps: { version: 'dev', list: Object.keys(
|
|
117
|
-
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
|
+
}
|