vimp-engine 0.22.0 → 0.23.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 (65) 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 +27 -8
  5. package/src/client/lib/socketDispatch.js +34 -0
  6. package/src/client/main.js +55 -7
  7. package/src/config/abiOps.js +31 -0
  8. package/src/config/clientServices.js +27 -0
  9. package/src/config/opcodes.js +11 -2
  10. package/src/config/wsports.js +9 -0
  11. package/src/devtools/contract/loadContext.js +25 -2
  12. package/src/devtools/contract/rules/b10-respawns.js +5 -3
  13. package/src/devtools/contract/rules/b2-engine-api.js +31 -8
  14. package/src/devtools/contract/rules/b3-game-config-shape.js +46 -5
  15. package/src/devtools/contract/rules/b4-teams.js +6 -3
  16. package/src/devtools/contract/rules/b5-room-form.js +40 -8
  17. package/src/devtools/contract/rules/c4-component-dependencies.js +10 -15
  18. package/src/devtools/surface/abiParse.js +175 -0
  19. package/src/devtools/surface/collect.js +413 -0
  20. package/src/host/GameCoreAdapter.js +49 -0
  21. package/src/host/meta/core/RoundManager.js +14 -1
  22. package/src/lib/applyRoomOverrides.js +14 -2
  23. package/src/lib/capabilities.js +32 -0
  24. package/src/lib/coreConfig.js +3 -3
  25. package/src/lib/createHostRuntime.js +5 -3
  26. package/src/lib/formControls.js +89 -0
  27. package/src/lib/gameConfigView.js +211 -0
  28. package/src/lib/gamePlugin.js +65 -87
  29. package/src/lib/loadGamePackage.js +9 -2
  30. package/src/lib/registry.js +94 -0
  31. package/src/standalone/index.js +27 -13
  32. package/tests/fixtures/generations/gen-api3/README.md +7 -0
  33. package/tests/fixtures/generations/gen-api3/client/fakeClientCore.js +307 -0
  34. package/tests/fixtures/generations/gen-api3/client/index.js +38 -0
  35. package/tests/fixtures/generations/gen-api3/client/parts/Actor.js +18 -0
  36. package/tests/fixtures/generations/gen-api3/client/parts/ActorRadar.js +7 -0
  37. package/tests/fixtures/generations/gen-api3/config/auth.js +41 -0
  38. package/tests/fixtures/generations/gen-api3/config/client.js +151 -0
  39. package/tests/fixtures/generations/gen-api3/config/game.js +167 -0
  40. package/tests/fixtures/generations/gen-api3/core/pkg-node/core.js +4 -0
  41. package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +129 -0
  42. package/tests/fixtures/generations/gen-api3/host/createModules.js +6 -0
  43. package/tests/fixtures/generations/gen-api3/host/fakeCore.js +263 -0
  44. package/tests/fixtures/generations/gen-api3/host/index.js +30 -0
  45. package/tests/fixtures/generations/gen-api3/host/spawnCommand.js +14 -0
  46. package/tests/fixtures/generations/gen-api3/host/systemMessages.js +6 -0
  47. package/tests/fixtures/generations/gen-api3/manifest.json +25 -0
  48. package/tests/fixtures/generations/gen-api4/README.md +7 -0
  49. package/tests/fixtures/generations/gen-api4/client/fakeClientCore.js +307 -0
  50. package/tests/fixtures/generations/gen-api4/client/index.js +38 -0
  51. package/tests/fixtures/generations/gen-api4/client/parts/Actor.js +18 -0
  52. package/tests/fixtures/generations/gen-api4/client/parts/ActorRadar.js +7 -0
  53. package/tests/fixtures/generations/gen-api4/config/auth.js +41 -0
  54. package/tests/fixtures/generations/gen-api4/config/client.js +151 -0
  55. package/tests/fixtures/generations/gen-api4/config/game.js +156 -0
  56. package/tests/fixtures/generations/gen-api4/core/pkg-node/core.js +4 -0
  57. package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +129 -0
  58. package/tests/fixtures/generations/gen-api4/host/createModules.js +6 -0
  59. package/tests/fixtures/generations/gen-api4/host/fakeCore.js +263 -0
  60. package/tests/fixtures/generations/gen-api4/host/index.js +30 -0
  61. package/tests/fixtures/generations/gen-api4/host/spawnCommand.js +14 -0
  62. package/tests/fixtures/generations/gen-api4/host/systemMessages.js +6 -0
  63. package/tests/fixtures/generations/gen-api4/manifest.json +17 -0
  64. package/tests/fixtures/miniGame/client/fakeClientCore.js +17 -0
  65. package/tests/fixtures/miniGame/host/fakeCore.js +38 -8
@@ -0,0 +1,413 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { fileURLToPath } from 'node:url';
3
+ import path from 'node:path';
4
+ import wsports from '../../config/wsports.js';
5
+ import {
6
+ KNOWN_GAME_CONFIG_PATHS,
7
+ REQUIRED_GAME_CONFIG_PATHS,
8
+ } from '../../lib/gameConfigView.js';
9
+ import { formControls } from '../../lib/formControls.js';
10
+ import { ENGINE_CAPABILITIES } from '../../lib/capabilities.js';
11
+ import { clientServices } from '../../config/clientServices.js';
12
+ import { abiOps } from '../../config/abiOps.js';
13
+ import { SNAPSHOT_FORMAT_VERSION } from '../../config/opcodes.js';
14
+ import { parseAbi } from './abiParse.js';
15
+
16
+ // Слепок плагинной поверхности (этап 1 плана plugin-forward-compat).
17
+ // Поверхность — всё, что игра может написать или прочитать: поля gameConfig,
18
+ // имена сервисов, контролы форм, номера портов, поля манифеста, члены
19
+ // объектов плагина, методы wasm-ABI. Инвариант И1 говорит, что ни одно из
20
+ // этих имён не исчезает и не переименовывается, И3 — что форма данных
21
+ // (сигнатура ABI, номер порта) не меняется.
22
+ //
23
+ // Слепок ничего не объявляет сам: каждый раздел собирается из существующего
24
+ // модуля движка — импортом там, где имя есть значением, и разбором текста
25
+ // там, где его нет (методы внутри `macro_rules!`, поля манифеста, которые
26
+ // движок читает точечно). Продублированный руками список устарел бы молча.
27
+ //
28
+ // Живёт в devtools/, потому что в бандл приложения devtools не попадает
29
+ // (граница из CLAUDE.md).
30
+
31
+ const SRC_DIR = fileURLToPath(new URL('../../', import.meta.url));
32
+ const ABI_PATH = fileURLToPath(
33
+ new URL('../../../core/src/abi.rs', import.meta.url),
34
+ );
35
+
36
+ // модули, читающие GameManifest: поля манифеста — их точечные обращения
37
+ const MANIFEST_READERS = [
38
+ 'master/GameCatalog.js',
39
+ 'lib/loadGamePackage.js',
40
+ 'lib/gamePlugin.js',
41
+ ];
42
+
43
+ /**
44
+ * Собирает поверхность плагинного контракта.
45
+ * @returns {Promise<Object>} JSON-объект слепка (см. contract/surface.json).
46
+ */
47
+ export async function collectSurface() {
48
+ const abi = parseAbi(await readFile(ABI_PATH, 'utf8'));
49
+ const sources = await readSources();
50
+
51
+ return sortDeep({
52
+ abi,
53
+ // опкоды dispatch: append-only на тех же правилах, что имена (И1) —
54
+ // удаление опкода отнимает возможность, на которую игра уже оперлась
55
+ abiOps: registrySection(abiOps),
56
+ clientPluginMembers: collectMembers(sources, 'clientPlugin'),
57
+ // словари-реестры отдают и активные записи, и выведенные из
58
+ // эксплуатации: вывод алиасом — не удаление (И1), и слепок обязан
59
+ // отличать одно от другого
60
+ clientServices: registrySection(clientServices),
61
+ // возможности движка (этап 5): имя отсюда игра пишет в
62
+ // manifest.requires — исчезнувшее имя отвергает уже опубликованную игру
63
+ // ровно так же, как исчезнувший контрол формы
64
+ engineCapabilities: registrySection(ENGINE_CAPABILITIES),
65
+ formControls: registrySection(formControls),
66
+ // поля gameConfig с умолчаниями (И1): движок читает их через view, а не
67
+ // точечно с плагина, поэтому в hostPluginMembers они уже не видны
68
+ gameConfigFields: [
69
+ ...KNOWN_GAME_CONFIG_PATHS,
70
+ ...REQUIRED_GAME_CONFIG_PATHS,
71
+ ],
72
+ hostPluginMembers: collectMembers(sources, 'hostPlugin'),
73
+ manifestFields: collectManifestFields(sources),
74
+ ports: { server: { ...wsports.server }, client: { ...wsports.client } },
75
+ requiredGameConfig: [...REQUIRED_GAME_CONFIG_PATHS],
76
+ // движковая (не плагинная) величина: плагин её не читает, но смена
77
+ // раскладки кадра разводит хост и клиент разных сборок движка — пусть
78
+ // попадает в diff и меняется осознанно
79
+ snapshotFormatVersion: SNAPSHOT_FORMAT_VERSION,
80
+ });
81
+ }
82
+
83
+ // запись реестра в слепке: имя плюс механика вывода из эксплуатации.
84
+ // `note` не пишем — это пояснение для человека, а не форма данных
85
+ function registrySection(registry) {
86
+ return registry.list().map(({ value, since, alias, retiredIn }) => ({
87
+ name: value,
88
+ since,
89
+ ...(alias === undefined ? {} : { alias, retiredIn }),
90
+ }));
91
+ }
92
+
93
+ // текст всех модулей движка без комментариев и строковых литералов: путь
94
+ // 'manifest.json' в строке — не чтение поля манифеста, и попади он в слепок,
95
+ // раздел жил бы своей жизнью
96
+ async function readSources() {
97
+ const files = await listJsFiles(SRC_DIR);
98
+ const sources = new Map();
99
+
100
+ for (const file of files) {
101
+ const rel = path.relative(SRC_DIR, file).replaceAll(path.sep, '/');
102
+
103
+ sources.set(rel, stripNoise(await readFile(file, 'utf8')));
104
+ }
105
+
106
+ return sources;
107
+ }
108
+
109
+ async function listJsFiles(dir) {
110
+ const entries = await readdir(dir, { withFileTypes: true });
111
+ const files = [];
112
+
113
+ for (const entry of entries) {
114
+ // `_`-префикс — черновики, которые не коммитятся (CLAUDE.md)
115
+ if (entry.name.startsWith('_')) {
116
+ continue;
117
+ }
118
+
119
+ const full = path.join(dir, entry.name);
120
+
121
+ if (entry.isDirectory()) {
122
+ files.push(...(await listJsFiles(full)));
123
+ } else if (entry.name.endsWith('.js')) {
124
+ files.push(full);
125
+ }
126
+ }
127
+
128
+ return files;
129
+ }
130
+
131
+ // комментарии и литералы вырезаются одним проходом, в порядке появления:
132
+ // раздельные проходы спаривают апостроф внутри шаблонной строки с чужим
133
+ // апострофом и молча съедают код между ними
134
+ const NOISE =
135
+ /\/\*[\s\S]*?\*\/|\/\/[^\n]*|'(?:\\.|[^'\\\n])*'|"(?:\\.|[^"\\\n])*"|`(?:\\.|[^`\\])*`/g;
136
+
137
+ function stripNoise(source) {
138
+ return source.replace(NOISE, ' ');
139
+ }
140
+
141
+ // имена, которые движок читает с объекта плагина: точечные обращения
142
+ // (`hostPlugin.gameConfig`, `clientPlugin.hooks.onAuth`) и деструктуризация
143
+ function collectMembers(sources, holder) {
144
+ const names = new Set();
145
+
146
+ for (const source of sources.values()) {
147
+ for (const name of readPaths(source, holder)) {
148
+ names.add(name);
149
+ }
150
+ }
151
+
152
+ if (names.size === 0) {
153
+ throw new Error(
154
+ `collectSurface: no member of "${holder}" is read anywhere in ` +
155
+ 'src/ — the engine was restructured; fix the collector instead of ' +
156
+ 'letting the surface snapshot go silently empty',
157
+ );
158
+ }
159
+
160
+ return [...names];
161
+ }
162
+
163
+ function collectManifestFields(sources) {
164
+ const names = new Set();
165
+
166
+ for (const file of MANIFEST_READERS) {
167
+ const source = sources.get(file);
168
+
169
+ if (source === undefined) {
170
+ throw new Error(
171
+ `collectSurface: manifest reader ${file} is gone — the engine was ` +
172
+ 'restructured; fix the collector',
173
+ );
174
+ }
175
+
176
+ for (const name of readPaths(source, 'manifest')) {
177
+ names.add(name);
178
+ }
179
+ }
180
+
181
+ if (names.size === 0) {
182
+ throw new Error(
183
+ 'collectSurface: no GameManifest field is read anywhere in ' +
184
+ `${MANIFEST_READERS.join(', ')} — fix the collector`,
185
+ );
186
+ }
187
+
188
+ return [...names];
189
+ }
190
+
191
+ // пути, которые модуль читает с объекта `holder`: `holder.a.b` → 'a.b',
192
+ // `const { a, b } = holder` → 'a', 'b'
193
+ function readPaths(source, holder) {
194
+ const names = [];
195
+ const dotted = new RegExp(
196
+ `\\b${holder}\\??\\.([A-Za-z_$][\\w$]*(?:\\??\\.[A-Za-z_$][\\w$]*)*)`,
197
+ 'g',
198
+ );
199
+ let match;
200
+
201
+ while ((match = dotted.exec(source)) !== null) {
202
+ names.push(match[1].replaceAll('?.', '.'));
203
+ }
204
+
205
+ // ровно `= holder`, а не `= holder.gameConfig`: во втором случае имена
206
+ // принадлежат не плагину, а его конфигу — у того свой раздел слепка
207
+ const destructured = new RegExp(
208
+ `\\{([^{}]*)\\}\\s*=\\s*${holder}(?![\\w$.])`,
209
+ 'g',
210
+ );
211
+
212
+ while ((match = destructured.exec(source)) !== null) {
213
+ for (const part of match[1].split(',')) {
214
+ const name = part
215
+ .split(':')[0]
216
+ .trim()
217
+ .replace(/^\.\.\./, '');
218
+
219
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) {
220
+ names.push(name);
221
+ }
222
+ }
223
+ }
224
+
225
+ return names;
226
+ }
227
+
228
+ // списки, порядок которых сам по себе является контрактом: аргументы
229
+ // ABI-метода читаются позиционно, и алфавит здесь тихо переписал бы
230
+ // сигнатуру
231
+ const ORDERED_LISTS = ['args'];
232
+
233
+ // стабильный порядок: ключи объектов и строковые списки — по алфавиту,
234
+ // записи ABI — по имени. Слепок читается диффом, а не глазами
235
+ function sortDeep(value, key = null) {
236
+ if (Array.isArray(value)) {
237
+ const items = value.map(item => sortDeep(item));
238
+
239
+ if (ORDERED_LISTS.includes(key)) {
240
+ return items;
241
+ }
242
+
243
+ return items.every(item => typeof item === 'string')
244
+ ? items.sort()
245
+ : items.sort((a, b) => String(a?.name).localeCompare(String(b?.name)));
246
+ }
247
+
248
+ if (value !== null && typeof value === 'object') {
249
+ return Object.fromEntries(
250
+ Object.keys(value)
251
+ .sort()
252
+ .map(name => [name, sortDeep(value[name], name)]),
253
+ );
254
+ }
255
+
256
+ return value;
257
+ }
258
+
259
+ /**
260
+ * Сериализация слепка для записи в contract/surface.json.
261
+ * @param {Object} surface - Результат collectSurface().
262
+ * @returns {string} JSON с отступом 2 и переводом строки в конце.
263
+ */
264
+ export function formatSurface(surface) {
265
+ return `${JSON.stringify(sortDeep(surface), null, 2)}\n`;
266
+ }
267
+
268
+ const INVARIANT_1 =
269
+ 'Инвариант И1 (plan/plugin-forward-compat/README.md): имя, которое игра\n' +
270
+ 'могла написать, существует вечно. Выведи его из эксплуатации алиасом,\n' +
271
+ 'а не удалением. Если это осознанный security-фикс — удали строку из\n' +
272
+ 'contract/surface.json тем же коммитом и опиши в CHANGELOG под\n' +
273
+ '⚠️ Breaking + Migration.';
274
+
275
+ const INVARIANT_2 =
276
+ 'Инвариант И2 (plan/plugin-forward-compat/README.md): ничто новое не\n' +
277
+ 'обязательно. Новое обязательное поле gameConfig отвергает КАЖДУЮ уже\n' +
278
+ 'опубликованную игру, которая о нём не знает. Заведи поле в FIELDS\n' +
279
+ 'модуля lib/gameConfigView.js с безопасным умолчанием — список REQUIRED\n' +
280
+ 'может только сокращаться.';
281
+
282
+ const INVARIANT_3 =
283
+ 'Инвариант И3 (plan/plugin-forward-compat/README.md): форма данных\n' +
284
+ 'неизменна — рядом добавляется новая. Уже опубликованная сборка игры\n' +
285
+ 'вызывает старую форму и не пересоберётся. Если это осознанный слом —\n' +
286
+ 'поправь строку в contract/surface.json тем же коммитом и опиши в\n' +
287
+ 'CHANGELOG под ⚠️ Breaking + Migration.';
288
+
289
+ /**
290
+ * Сравнение закоммиченного слепка с собранным.
291
+ * @param {Object} committed - Слепок из contract/surface.json.
292
+ * @param {Object} collected - Результат collectSurface().
293
+ * @returns {{violations: string[], additions: string[]}} Нарушения И1/И3 и
294
+ * добавления (добавление поверхности совместимость не ломает).
295
+ */
296
+ export function diffSurface(committed, collected) {
297
+ const violations = [];
298
+ const additions = [];
299
+
300
+ walk(committed, collected, [], violations, additions);
301
+
302
+ return { violations, additions };
303
+ }
304
+
305
+ function walk(before, after, trail, violations, additions) {
306
+ const where = trail.join('.') || 'surface';
307
+
308
+ if (Array.isArray(before)) {
309
+ walkList(before, after ?? [], where, violations, additions);
310
+ return;
311
+ }
312
+
313
+ if (before !== null && typeof before === 'object') {
314
+ for (const key of Object.keys(before)) {
315
+ if (after?.[key] === undefined) {
316
+ violations.push(`surface: '${key}' исчез из ${where}.\n${INVARIANT_1}`);
317
+ continue;
318
+ }
319
+
320
+ walk(before[key], after[key], [...trail, key], violations, additions);
321
+ }
322
+
323
+ for (const key of Object.keys(after ?? {})) {
324
+ if (before[key] === undefined) {
325
+ additions.push(`${where}.${key}`);
326
+ }
327
+ }
328
+
329
+ return;
330
+ }
331
+
332
+ if (before !== after) {
333
+ violations.push(
334
+ `surface: ${where} изменился: ${JSON.stringify(before)} → ` +
335
+ `${JSON.stringify(after)}.\n` +
336
+ (ENGINE_VALUES.has(where) ? ENGINE_VALUE_NOTE : INVARIANT_3),
337
+ );
338
+ }
339
+ }
340
+
341
+ // Движковые величины: плагин их не читает, поэтому И3 к ним не относится —
342
+ // но менять их молча всё равно нельзя.
343
+ const ENGINE_VALUES = new Set(['snapshotFormatVersion']);
344
+
345
+ const ENGINE_VALUE_NOTE =
346
+ 'Это движковая величина, а не плагинная: игра её не читает и от неё не\n' +
347
+ 'зависит. Но раскладка кадра разводит хост и клиент разных сборок\n' +
348
+ 'движка — если смена осознанна, поправь contract/surface.json тем же\n' +
349
+ 'коммитом и убедись, что расхождение ловит codeVersion.';
350
+
351
+ // Разделы с инвертированным правилом: у них расширение — это слом, а
352
+ // сокращение — норма. Пока такой один: список обязательных полей
353
+ // gameConfig (И2).
354
+ const SHRINK_ONLY = new Set(['requiredGameConfig']);
355
+
356
+ // требование, которого раньше не было, отвергает старые игры — а снятое
357
+ // требование не ломает никого: правило зеркально общему
358
+ function walkShrinkOnly(before, after, where, violations, additions) {
359
+ const known = new Set(before);
360
+
361
+ for (const name of after) {
362
+ if (!known.has(name)) {
363
+ violations.push(
364
+ `surface: '${name}' добавлен в ${where}.\n${INVARIANT_2}`,
365
+ );
366
+ }
367
+ }
368
+
369
+ const kept = new Set(after);
370
+
371
+ for (const name of before) {
372
+ if (!kept.has(name)) {
373
+ additions.push(`${where}: снято требование ${name}`);
374
+ }
375
+ }
376
+ }
377
+
378
+ function walkList(before, after, where, violations, additions) {
379
+ if (SHRINK_ONLY.has(where)) {
380
+ walkShrinkOnly(before, after, where, violations, additions);
381
+ return;
382
+ }
383
+
384
+ const key = item => (typeof item === 'string' ? item : item?.name);
385
+ const seen = new Map(after.map(item => [key(item), item]));
386
+
387
+ for (const item of before) {
388
+ const name = key(item);
389
+ const found = seen.get(name);
390
+
391
+ if (found === undefined) {
392
+ violations.push(`surface: '${name}' исчез из ${where}.\n${INVARIANT_1}`);
393
+ continue;
394
+ }
395
+
396
+ if (JSON.stringify(found) !== JSON.stringify(item)) {
397
+ violations.push(
398
+ `surface: сигнатура '${name}' в ${where} изменилась: ` +
399
+ `${JSON.stringify(item)} → ${JSON.stringify(found)}.\n${INVARIANT_3}`,
400
+ );
401
+ }
402
+ }
403
+
404
+ const known = new Set(before.map(key));
405
+
406
+ for (const item of after) {
407
+ if (!known.has(key(item))) {
408
+ additions.push(`${where}: ${key(item)}`);
409
+ }
410
+ }
411
+ }
412
+
413
+ export default collectSurface;
@@ -9,6 +9,10 @@
9
9
  // scripted-участник создаётся как танк + ИИ-контроллер внутри ядра
10
10
  // (spawn_scripted_actor/remove_scripted_actor), человек — только танк
11
11
  // (spawn_actor/remove_actor).
12
+
13
+ // пустая нагрузка опкода: ядро ждёт байты всегда, даже когда их нет
14
+ const EMPTY_PAYLOAD = new Uint8Array(0);
15
+
12
16
  export default class GameCoreAdapter {
13
17
  /**
14
18
  * @param {GameCore} core - экземпляр WASM-ядра.
@@ -23,6 +27,24 @@ export default class GameCoreAdapter {
23
27
  this._participants = participants;
24
28
  this._onCoreEvent = onCoreEvent;
25
29
  this._services = {}; // { vimp, panel } — инъекция как у Game.js
30
+
31
+ // Метода нет — ядро собрано до появления самоописания. Это не ошибка:
32
+ // поколение 0, ни одного опционального опкода (И2). Читается один раз
33
+ // здесь, а не при вызове: ветку упаковки, поле формы или ответ лобби
34
+ // движок выбирает заранее, а не посреди матча.
35
+ this._abi =
36
+ typeof core.abi_describe === 'function'
37
+ ? JSON.parse(core.abi_describe())
38
+ : { abi: 0, core: null, ops: [] };
39
+ }
40
+
41
+ /**
42
+ * Возможности загруженного ядра: { abi, core, ops }. `abi: 0` — ядро
43
+ * старше самоописания.
44
+ * @returns {Object}
45
+ */
46
+ get abi() {
47
+ return this._abi;
26
48
  }
27
49
 
28
50
  // получает сервисы (аналог Game.injectServices): { vimp, panel }
@@ -215,6 +237,14 @@ export default class GameCoreAdapter {
215
237
  * @returns {Object|null}
216
238
  */
217
239
  debugJson() {
240
+ const out = this._op('debug.json');
241
+
242
+ if (out !== null) {
243
+ return JSON.parse(new TextDecoder().decode(out));
244
+ }
245
+
246
+ // ядро старше опкода, но с замороженным методом — метод не удаляется
247
+ // никогда (И1), поэтому запасной путь остаётся навсегда
218
248
  if (typeof this._core.debug_json !== 'function') {
219
249
  return null;
220
250
  }
@@ -222,6 +252,25 @@ export default class GameCoreAdapter {
222
252
  return JSON.parse(this._core.debug_json());
223
253
  }
224
254
 
255
+ /**
256
+ * Единственный разрешённый способ позвать необязательную возможность
257
+ * ядра: прямой вызов нового метода на `this._core` запрещён (И2) — у
258
+ * ядра, собранного год назад, его нет и не будет. ESLint стережёт это
259
+ * правилом no-restricted-syntax.
260
+ * @param {string} op - Опкод из config/abiOps.js.
261
+ * @param {Uint8Array} [payload] - Полезная нагрузка опкода.
262
+ * @returns {Uint8Array|null} null — ядро опкод не умеет либо не обработало.
263
+ */
264
+ _op(op, payload = EMPTY_PAYLOAD) {
265
+ if (!this._abi.ops.includes(op)) {
266
+ return null;
267
+ }
268
+
269
+ const out = this._core.dispatch(op, payload);
270
+
271
+ return out.length === 0 ? null : out;
272
+ }
273
+
225
274
  // бот ли участник (спавн/удаление в ядре различаются)
226
275
  _isScripted(gameId) {
227
276
  const participant = this._participants.get(gameId);
@@ -218,9 +218,22 @@ class RoundManager {
218
218
  // `_startRound` раздавал бы точки респауна карты каталога, то есть исходного
219
219
  // размера, и расставлял всех по геометрии, которой в ядре уже нет
220
220
  // (`@vimp-games/snakes`, `src/host/ArenaScaler.js`).
221
+ //
222
+ // Подменяются ОБЕ копии, и это не удобство, а исправление: `sendMap`
223
+ // отдаёт подключающемуся `_currentMapData`, то есть карту каталога — и
224
+ // клиент рисовал геометрию, которой в ядре уже нет, пока игра сама не
225
+ // пришлёт ему MAP_DATA. Между этими двумя кадрами он видит чужую границу.
226
+ //
227
+ // `mapData` приходит в КЛИЕНТСКОМ виде (несмасштабированные координаты плюс
228
+ // `scale`) — это тот же объект, который игра рассылает через `sendMap`, —
229
+ // поэтому масштабирование делается здесь, ровно как в `_prepareMapData`.
230
+ // Отдать `_scaledMapData` клиенту напрямую нельзя: клиент масштабирует сам
231
+ // (`client/main.js`, applyMapData), и при `scale !== 1` карта была бы
232
+ // умножена дважды.
221
233
  overrideMapData(mapData) {
222
234
  if (mapData) {
223
- this._scaledMapData = mapData;
235
+ this._currentMapData = mapData;
236
+ this._scaledMapData = scaleMapData(mapData);
224
237
  }
225
238
  }
226
239
 
@@ -2,9 +2,21 @@
2
2
  // пользовательские настройки комнаты. Используется host.worker.js; вынесено
3
3
  // в lib для тестируемости (worker вешает self.onmessage при импорте)
4
4
  import hostDefaults from '../config/hostDefaults.js';
5
+ import { createGameConfigView } from './gameConfigView.js';
5
6
 
6
- export function applyRoomOverrides(room = {}, plugin) {
7
- const game = structuredClone({ ...hostDefaults, ...plugin.gameConfig });
7
+ /**
8
+ * @param {Object} [room] - Переопределения комнаты.
9
+ * @param {Object} plugin - HostPlugin игры.
10
+ * @param {Object} [view] - Готовая gameConfig-view (createHostRuntime строит
11
+ * её один раз на прогон); без неё собирается своя.
12
+ * @returns {Object} Конфиг матча: движковые дефолты + игровая половина.
13
+ */
14
+ export function applyRoomOverrides(
15
+ room = {},
16
+ plugin,
17
+ view = createGameConfigView(plugin.gameConfig, plugin.id),
18
+ ) {
19
+ const game = structuredClone({ ...hostDefaults, ...view });
8
20
 
9
21
  // Этап 5.1: актуальные карты мастера (фетчит главный поток) вместо бандла
10
22
  if (room.maps && Object.keys(room.maps).length) {
@@ -0,0 +1,32 @@
1
+ import { createRegistry } from './registry.js';
2
+
3
+ // Реестр возможностей движка (этап 5 плана plugin-forward-compat). После
4
+ // этапов 1-4 плагинная поверхность append-only, и отвергать игру за возраст
5
+ // стало не за что: единственная законная причина отказа — игра просит
6
+ // возможность, которой в этой сборке движка ещё нет, то есть она НОВЕЕ
7
+ // движка. Список запрошенного игра пишет в необязательное поле
8
+ // `GameManifest.requires`; манифест без него (все опубликованные до этапа 5)
9
+ // значит «ничего сверх базового не нужно».
10
+ //
11
+ // Реестр append-only (И1): имя, однажды объявленное, поддерживается вечно —
12
+ // опубликованная игра могла записать его в `requires`, и её dist больше
13
+ // никто не тронет. Вывод из эксплуатации = алиас на новое имя, но не
14
+ // удаление строки.
15
+ //
16
+ // Что сюда попадает: возможность, без которой игра не запустится вовсе, а не
17
+ // каждое движковое улучшение. Игре, которая деградирует штатно (не просит
18
+ // сервис, не подписана на порт), объявлять ничего не нужно — умолчания
19
+ // этапа 2 и словари этапа 3 её и так примут.
20
+ export const ENGINE_CAPABILITIES = createRegistry('engine-capabilities', [
21
+ // срезы рейтинга (day/month/all) в лобби и на хосте
22
+ { value: 'stat.leaderboard', since: '0.20.0' },
23
+ // порт ACCOLADES_DATA (18) + сервис пула `accolades`
24
+ { value: 'accolades', since: '0.21.0' },
25
+ // dispatch/abi_describe в ядре (этап 4): опциональные вызовы ядра
26
+ { value: 'dispatch', since: '0.23.0' },
27
+ ]);
28
+
29
+ // плоский список имён — то, чем правило контракта B2 проверяет `requires`
30
+ export const CAPABILITIES = ENGINE_CAPABILITIES.values();
31
+
32
+ export default ENGINE_CAPABILITIES;
@@ -11,9 +11,9 @@ import wsports from '../config/wsports.js';
11
11
 
12
12
  /**
13
13
  * Собирает объект конфигурации ядра.
14
- * @param {Object} gameConfig - HostPlugin.gameConfig игры, загруженной
15
- * динамически по GameManifest (Этап 6.4) движок больше не знает игру
16
- * статически.
14
+ * @param {Object} gameConfig - Представление HostPlugin.gameConfig
15
+ * (lib/gameConfigView.js): поля игры плюс движковые умолчания. Прямой
16
+ * gameConfig сюда не передаётся — он обходит умолчания (И2).
17
17
  * @param {Object} [overrides] - Переопределения плоским объектом (например,
18
18
  * seed для воспроизводимых прогонов или friendlyFire) — распределяются
19
19
  * по движковой/игровой половине автоматически.
@@ -40,9 +40,11 @@ export async function createHostRuntime(room, options = {}) {
40
40
 
41
41
  const hostPlugin = await loadHostPlugin(room);
42
42
 
43
- assertGameConfigShape(hostPlugin);
43
+ // одна view на прогон: она же валидирует обязательные поля gameConfig и
44
+ // подставляет умолчания для всего остального (lib/gameConfigView.js)
45
+ const configView = assertGameConfigShape(hostPlugin);
44
46
 
45
- const game = applyRoomOverrides(room, hostPlugin);
47
+ const game = applyRoomOverrides(room, hostPlugin, configView);
46
48
 
47
49
  if (overrideGameConfig) {
48
50
  overrideGameConfig(game);
@@ -57,7 +59,7 @@ export async function createHostRuntime(room, options = {}) {
57
59
 
58
60
  const core = await hostPlugin.createCore(
59
61
  JSON.stringify(
60
- buildCoreConfig(hostPlugin.gameConfig, {
62
+ buildCoreConfig(configView, {
61
63
  friendlyFire: game.parts.friendlyFire,
62
64
  seed,
63
65
  }),