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,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;
@@ -1,3 +1,5 @@
1
+ import { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
2
+ import { readCoreAbi, dispatchCoreOp } from '../lib/coreAbi.js';
1
3
  // Адаптер Rust-ядра (GameCore) под интерфейс, который потребляют мета-модули
2
4
  // хоста (RoundManager, SocketManager) и host-фасад. За поверхностью Game.js
3
5
  // (+ упаковка снапшотов) стоит WASM-ядро; события ядра (take_events) несут
@@ -9,6 +11,7 @@
9
11
  // scripted-участник создаётся как танк + ИИ-контроллер внутри ядра
10
12
  // (spawn_scripted_actor/remove_scripted_actor), человек — только танк
11
13
  // (spawn_actor/remove_actor).
14
+
12
15
  export default class GameCoreAdapter {
13
16
  /**
14
17
  * @param {GameCore} core - экземпляр WASM-ядра.
@@ -23,6 +26,21 @@ export default class GameCoreAdapter {
23
26
  this._participants = participants;
24
27
  this._onCoreEvent = onCoreEvent;
25
28
  this._services = {}; // { vimp, panel } — инъекция как у Game.js
29
+
30
+ // Метода нет (или самоописание нечитаемо) — ядро собрано до появления
31
+ // механизма. Это не ошибка: поколение 0, ни одного опционального опкода
32
+ // (И2). Читается один раз здесь, а не при вызове: ветку упаковки, поле
33
+ // формы или ответ лобби движок выбирает заранее, а не посреди матча.
34
+ this._abi = readCoreAbi(core, 'game core');
35
+ }
36
+
37
+ /**
38
+ * Возможности загруженного ядра: { abi, core, ops }. `abi: 0` — ядро
39
+ * старше самоописания.
40
+ * @returns {Object}
41
+ */
42
+ get abi() {
43
+ return this._abi;
26
44
  }
27
45
 
28
46
  // получает сервисы (аналог Game.injectServices): { vimp, panel }
@@ -215,6 +233,14 @@ export default class GameCoreAdapter {
215
233
  * @returns {Object|null}
216
234
  */
217
235
  debugJson() {
236
+ const { handled, bytes } = this._op(ABI_OP_DEBUG_JSON);
237
+
238
+ if (handled && bytes !== null) {
239
+ return JSON.parse(new TextDecoder().decode(bytes));
240
+ }
241
+
242
+ // ядро старше опкода, но с замороженным методом — метод не удаляется
243
+ // никогда (И1), поэтому запасной путь остаётся навсегда
218
244
  if (typeof this._core.debug_json !== 'function') {
219
245
  return null;
220
246
  }
@@ -222,6 +248,29 @@ export default class GameCoreAdapter {
222
248
  return JSON.parse(this._core.debug_json());
223
249
  }
224
250
 
251
+ /**
252
+ * Единственный разрешённый способ позвать необязательную возможность
253
+ * ядра: прямой вызов нового метода на `this._core` запрещён (И2) — у
254
+ * ядра, собранного год назад, его нет и не будет. ESLint стережёт это
255
+ * правилом no-restricted-syntax.
256
+ *
257
+ * Исходов ровно три, и они РАЗЛИЧИМЫ (соглашение `abi::dispatch_result`,
258
+ * core/src/abi.rs): пустой ответ — «опкод не понят», маркер `[0x00]` —
259
+ * «понят, ответа нет», иначе — полезные байты. Схлопывать первые два в
260
+ * один `null` нельзя: опкод-команда (ради них механизм и делался) отдавала
261
+ * бы вызывающему сырой `Uint8Array [0]`, который поехал бы в TextDecoder
262
+ * и JSON.parse.
263
+ * @param {string} op - Опкод из config/abiOps.js.
264
+ * @param {Uint8Array} [payload] - Полезная нагрузка опкода.
265
+ * @returns {{handled: boolean, bytes: Uint8Array|null}} `handled: false` —
266
+ * ядро опкода не знает (вызывающий идёт по запасному пути);
267
+ * `handled: true, bytes: null` — обработано без ответа.
268
+ * @see lib/coreAbi.js — то же для клиентского ядра
269
+ */
270
+ _op(op, payload) {
271
+ return dispatchCoreOp(this._core, this._abi, op, payload);
272
+ }
273
+
225
274
  // бот ли участник (спавн/удаление в ядре различаются)
226
275
  _isScripted(gameId) {
227
276
  const participant = this._participants.get(gameId);
@@ -2,9 +2,23 @@
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
+ * view, и deriveSpectatorTeam предупреждает в консоль дважды за прогон.
13
+ * Прод-вызов всегда передаёт готовую.
14
+ * @returns {Object} Конфиг матча: движковые дефолты + игровая половина.
15
+ */
16
+ export function applyRoomOverrides(
17
+ room = {},
18
+ plugin,
19
+ view = createGameConfigView(plugin.gameConfig, plugin.id),
20
+ ) {
21
+ const game = structuredClone({ ...hostDefaults, ...view });
8
22
 
9
23
  // Этап 5.1: актуальные карты мастера (фетчит главный поток) вместо бандла
10
24
  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;
@@ -0,0 +1,116 @@
1
+ // Единственная точка чтения самоописания wasm-ядра (`abi_describe`).
2
+ //
3
+ // Фрагмент жил в трёх местах — конструкторе GameCoreAdapter и двух местах
4
+ // client/main.js — и дефолт `{abi: 0, core: null, ops: []}` был написан
5
+ // руками трижды: разъехавшись, они дали бы `ops === undefined` и TypeError
6
+ // в первом же вызове опкода.
7
+ //
8
+ // Плюс `JSON.parse` не был обёрнут. Ядро, отдающее не-JSON, роняло
9
+ // конструктор адаптера и (на клиенте) async-обработчик PS_CONFIG_DATA —
10
+ // невыловленным промисом, после которого конфиг не применяется вовсе. Это
11
+ // тот же режим «получатель падает от того, что отправитель другой», который
12
+ // на JSON-портах уже вылечен веткой по умолчанию
13
+ // (client/lib/socketDispatch.js): битое самоописание обязано читаться как
14
+ // поколение 0, а не как отказ движка.
15
+
16
+ import { abiOps } from '../config/abiOps.js';
17
+
18
+ // пустая нагрузка опкода: ядро ждёт байты всегда, даже когда их нет
19
+ const EMPTY_PAYLOAD = new Uint8Array(0);
20
+
21
+ // «опкод не обработан»: ядро его не знает либо вернуло пустой вектор
22
+ // (соглашение abi::dispatch_result в крейте)
23
+ const NOT_HANDLED = Object.freeze({ handled: false, bytes: null });
24
+
25
+ // ядро старше самоописания: ни одного опционального опкода (И2 плана
26
+ // plugin-forward-compat). Один объект на все такие ядра — он заморожен и
27
+ // никем не правится
28
+ export const ABI_UNKNOWN = Object.freeze({ abi: 0, core: null, ops: [] });
29
+
30
+ /**
31
+ * Читает самоописание ядра: версия формата, версия движкового крейта,
32
+ * список опкодов `dispatch`.
33
+ * @param {Object} core - Экземпляр wasm-ядра (игрового или клиентского).
34
+ * @param {string} [label] - Имя ядра в тексте предупреждения.
35
+ * @returns {{abi: number, core: string|null, ops: Array<string>}} Всегда
36
+ * пригодный к чтению объект: `abi: 0` с пустым `ops` — ядро старше
37
+ * механизма ЛИБО его самоописание нечитаемо.
38
+ */
39
+ export function readCoreAbi(core, label = 'core') {
40
+ if (typeof core?.abi_describe !== 'function') {
41
+ return ABI_UNKNOWN;
42
+ }
43
+
44
+ let described;
45
+
46
+ try {
47
+ described = JSON.parse(core.abi_describe());
48
+ } catch (err) {
49
+ console.warn(`${label}: abi_describe is not JSON (${err.message})`);
50
+
51
+ return ABI_UNKNOWN;
52
+ }
53
+
54
+ // нормализация, а не проверка: движок обязан продолжить работу на любом
55
+ // содержимом. Не-строки из ops выбрасываются — по ним всё равно нечего
56
+ // диспетчеризовать
57
+ return {
58
+ abi: Number.isInteger(described?.abi) ? described.abi : 0,
59
+ core: typeof described?.core === 'string' ? described.core : null,
60
+ ops: Array.isArray(described?.ops)
61
+ ? described.ops.filter(op => typeof op === 'string')
62
+ : [],
63
+ };
64
+ }
65
+
66
+ export default readCoreAbi;
67
+
68
+ /**
69
+ * Зовёт необязательную возможность ядра опкодом. Единственное место, где
70
+ * движок вызывает `dispatch` — и игровой половины, и клиентской: таблица
71
+ * экспортов wasm заморожена (И1/И3), поэтому новая возможность приезжает
72
+ * строкой, а не новым символом.
73
+ *
74
+ * Исходов ровно три, и они РАЗЛИЧИМЫ (соглашение `abi::dispatch_result`,
75
+ * core/src/abi.rs): пустой ответ — «опкод не понят», маркер `[0x00]` —
76
+ * «понят, ответа нет», иначе — полезные байты. Схлопывать первые два в один
77
+ * `null` нельзя: опкод-команда (ради них механизм и делался) отдавала бы
78
+ * вызывающему сырой `Uint8Array [0]`, который поехал бы в TextDecoder и
79
+ * JSON.parse.
80
+ * @param {Object} core - Экземпляр wasm-ядра.
81
+ * @param {Object} abi - Результат readCoreAbi для этого ядра.
82
+ * @param {string} op - Опкод из config/abiOps.js.
83
+ * @param {Uint8Array} [payload] - Полезная нагрузка опкода.
84
+ * @returns {{handled: boolean, bytes: Uint8Array|null}} `handled: false` —
85
+ * ядро опкода не знает (вызывающий идёт по запасному пути);
86
+ * `handled: true, bytes: null` — обработано без ответа.
87
+ */
88
+ export function dispatchCoreOp(core, abi, op, payload = EMPTY_PAYLOAD) {
89
+ // опкод вне реестра — дефект ДВИЖКА, а не старого ядра: имя, которого нет
90
+ // в config/abiOps.js, не попадает ни в слепок поверхности, ни в CHANGELOG.
91
+ // Падаем сразу, а не на игре, которая его однажды поймёт
92
+ if (!abiOps.has(op)) {
93
+ throw new Error(
94
+ `dispatchCoreOp: unknown opcode "${op}" — declare it in ` +
95
+ 'config/abiOps.js (append-only, И1)',
96
+ );
97
+ }
98
+
99
+ const resolved = abiOps.resolve(op);
100
+
101
+ if (!abi.ops.includes(resolved)) {
102
+ return NOT_HANDLED;
103
+ }
104
+
105
+ const out = core.dispatch(resolved, payload);
106
+
107
+ if (out.length === 0) {
108
+ return NOT_HANDLED;
109
+ }
110
+
111
+ // [0x00] — «обработан, ответа нет»: полезных байтов у него нет
112
+ return {
113
+ handled: true,
114
+ bytes: out.length === 1 && out[0] === 0x00 ? null : out,
115
+ };
116
+ }