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,88 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import {
6
+ collectSurface,
7
+ formatSurface,
8
+ diffSurface,
9
+ } from '../src/devtools/surface/collect.js';
10
+
11
+ // CLI слепка плагинной поверхности (этап 1 плана plugin-forward-compat):
12
+ // без флага печатает расхождение с закоммиченным contract/surface.json и
13
+ // возвращает 1 при нарушении И1/И3, с --write перезаписывает слепок.
14
+ // Внутренний инструмент репозитория движка: в поле `bin` публикуемого
15
+ // пакета он не значится.
16
+
17
+ const SURFACE_PATH = fileURLToPath(
18
+ new URL('../contract/surface.json', import.meta.url),
19
+ );
20
+
21
+ const USAGE = `Usage: vimp-surface [options]
22
+
23
+ --write rewrite contract/surface.json with the collected surface
24
+ --help
25
+ `;
26
+
27
+ async function main(argv) {
28
+ if (argv.includes('--help')) {
29
+ process.stdout.write(USAGE);
30
+ return 0;
31
+ }
32
+
33
+ const surface = await collectSurface();
34
+
35
+ if (argv.includes('--write')) {
36
+ await mkdir(path.dirname(SURFACE_PATH), { recursive: true });
37
+ await writeFile(SURFACE_PATH, formatSurface(surface));
38
+ process.stdout.write(`surface: written ${SURFACE_PATH}\n`);
39
+
40
+ return 0;
41
+ }
42
+
43
+ let committed;
44
+
45
+ try {
46
+ committed = JSON.parse(await readFile(SURFACE_PATH, 'utf8'));
47
+ } catch (err) {
48
+ process.stderr.write(
49
+ `surface: no snapshot at ${SURFACE_PATH} — run ` +
50
+ '`npm run surface:update` to create it\n',
51
+ );
52
+
53
+ return 1;
54
+ }
55
+
56
+ const { violations, additions } = diffSurface(committed, surface);
57
+
58
+ for (const addition of additions) {
59
+ process.stdout.write(`surface: добавлено ${addition}\n`);
60
+ }
61
+
62
+ if (additions.length > 0 && violations.length === 0) {
63
+ process.stdout.write(
64
+ 'слепок устарел, запусти `npm run surface:update` (добавление ' +
65
+ 'поверхности совместимость не ломает)\n',
66
+ );
67
+ }
68
+
69
+ for (const violation of violations) {
70
+ process.stderr.write(`${violation}\n\n`);
71
+ }
72
+
73
+ if (violations.length === 0 && additions.length === 0) {
74
+ process.stdout.write('surface: слепок совпадает\n');
75
+ }
76
+
77
+ return violations.length > 0 ? 1 : 0;
78
+ }
79
+
80
+ main(process.argv.slice(2)).then(
81
+ code => {
82
+ process.exitCode = code;
83
+ },
84
+ err => {
85
+ process.stderr.write(`${err.message}\n`);
86
+ process.exitCode = 1;
87
+ },
88
+ );
package/core/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.8.4"
3
+ version = "0.9.0"
4
4
  edition = "2024"
5
5
  description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
6
6
  license = "MIT"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,10 +1,14 @@
1
1
  import { anchorPattern } from '../../lib/formPattern.js';
2
2
  import { normalizeOptions } from '../../lib/formOptions.js';
3
+ import { resolveDescriptor } from '../../lib/formControls.js';
3
4
 
4
5
  // Общий билдер полей форм (room-форма и auth-форма используют один
5
6
  // контракт дескрипторов — docs/en/plugin-api.md, раздел "Form schema").
6
7
  // control: 'select'|'text'|'checkbox'|'radio'; все контролы — нативные
7
- // элементы формы, без визуальной кастомизации. Валидация не нативная
8
+ // элементы формы, без визуальной кастомизации. Имя контрола приезжает от
9
+ // игры и разрешается через append-only реестр (lib/formControls.js): имена,
10
+ // выведенные из эксплуатации в v3 ('range', 'number', 'toggle',
11
+ // 'segmented'), продолжают строиться алиасами своих нативных замен (И1). Валидация — не нативная
8
12
  // (никаких браузерных попапов): collectFormErrors/renderFormErrors ниже
9
13
  // собирают и рисуют ошибки в разметку (#lobby-error/#auth-error).
10
14
  // Проверяются только поля, у которых есть строка в DOM: ошибка на скрытом
@@ -43,11 +47,14 @@ function forcedValue(options) {
43
47
  // то же правило вне формы: solo-путь (boot.autoAuth) отвечает хосту без
44
48
  // всякой формы и должен прийти ровно к тому же значению, что и она
45
49
  export function resolveForcedValue(descriptor, ctx = {}) {
46
- if (!OPTION_CONTROLS.includes(descriptor.control)) {
50
+ // после разрешения алиаса: 'segmented' — та же группа вариантов, что radio
51
+ const resolved = resolveDescriptor(descriptor);
52
+
53
+ if (!OPTION_CONTROLS.includes(resolved.control)) {
47
54
  return undefined;
48
55
  }
49
56
 
50
- const forced = forcedValue(resolveOptions(descriptor, ctx));
57
+ const forced = forcedValue(resolveOptions(resolved, ctx));
51
58
 
52
59
  // форма отдала бы строку: и <option>.value, и <input type=radio>.value —
53
60
  // DOM-свойства, они всегда строки. Нестроковое значение (options: [1, 2])
@@ -265,18 +272,27 @@ const builders = {
265
272
  radio: buildRadio,
266
273
  };
267
274
 
275
+ // имена control, которые билдер умеет строить. Активный набор имён диктует
276
+ // реестр (lib/formControls.js), и совпадение этих двух списков проверяет
277
+ // tests/client/formControls.test.js: разъехавшись, они дали бы «unknown
278
+ // control» на имени, которое реестр считает живым
279
+ export const FORM_CONTROLS = Object.keys(builders);
280
+
268
281
  // строит одно поле формы по дескриптору (docs/en/plugin-api.md, "Form schema")
269
282
  export function buildField(descriptor, ctx = {}) {
270
- const build = builders[descriptor.control];
283
+ // алиас выведенного контрола ('range' → numeric 'text') разрешается
284
+ // здесь же, где строится поле, и теми же правилами, что в валидации
285
+ const patched = resolveDescriptor(descriptor);
286
+ const build = builders[patched.control];
271
287
 
272
288
  if (!build) {
273
289
  throw new Error(`formBuilder: unknown control "${descriptor.control}"`);
274
290
  }
275
291
 
276
- const field = build(descriptor, ctx);
292
+ const field = build(patched, ctx);
277
293
 
278
- if (descriptor.default !== undefined) {
279
- field.setValue(descriptor.default);
294
+ if (patched.default !== undefined) {
295
+ field.setValue(patched.default);
280
296
  }
281
297
 
282
298
  return field;
@@ -441,7 +457,10 @@ export function collectFormErrors(descriptors, fields) {
441
457
  return;
442
458
  }
443
459
 
444
- const error = validateField(descriptor, field);
460
+ // тот же резолв алиаса, что и в buildField: валидация обязана смотреть
461
+ // на контрол, которым поле построено, иначе бывшее range-поле уезжает
462
+ // на хост без проверки диапазона
463
+ const error = validateField(resolveDescriptor(descriptor), field);
445
464
 
446
465
  if (error) {
447
466
  errors.push({
@@ -0,0 +1,34 @@
1
+ // Диспетчеризация входящего JSON-сообщения [portId, payload] по разрежённому
2
+ // массиву обработчиков (client/main.js: socketMethods).
3
+ //
4
+ // Порт без обработчика — не исключение, а тишина (этап 3 плана
5
+ // plugin-forward-compat). До этого `socketMethods[msg[0]](msg[1])` бросал
6
+ // `TypeError: ... is not a function` и ронял обработку сообщения целиком:
7
+ // получатель падал от того, что отправитель знает больше. Это не плагинная
8
+ // ось (хост и клиент комнаты — один бандл движка, их расхождение ловится
9
+ // codeVersion), но хрупкость ровно та же, и лечится она веткой по умолчанию.
10
+
11
+ /**
12
+ * Отдаёт сообщение обработчику своего порта.
13
+ * @param {Array<Function>} methods - Обработчики по номеру порта.
14
+ * @param {Array} msg - Сообщение [portId, payload].
15
+ * @returns {boolean} Было ли сообщение обработано.
16
+ */
17
+ export function dispatchSocketMessage(methods, msg) {
18
+ const port = msg?.[0];
19
+ const method = methods?.[port];
20
+
21
+ if (typeof method !== 'function') {
22
+ // console.debug, а не error: неизвестный порт — штатная встреча со
23
+ // «стороной, которая знает больше», а не дефект
24
+ console.debug(`client: no handler for port ${port} — message ignored`);
25
+
26
+ return false;
27
+ }
28
+
29
+ method(msg[1]);
30
+
31
+ return true;
32
+ }
33
+
34
+ export default dispatchSocketMessage;
@@ -36,6 +36,7 @@ import { ensureGameShell, ensureCanvas } from './views/gameShell.js';
36
36
  import { createContextTracker } from './lib/contextTracker.js';
37
37
  import { createLocalPlayer } from './lib/localPlayer.js';
38
38
  import { createAccolades } from './lib/accolades.js';
39
+ import { dispatchSocketMessage } from './lib/socketDispatch.js';
39
40
  import { createDebugApi, debugLog, DEBUG_PREFIX } from './debug.js';
40
41
  import { buildClientCoreConfig } from '../lib/clientCoreConfig.js';
41
42
  import {
@@ -109,6 +110,13 @@ function bindActiveGame(manifest, plugin) {
109
110
  gameStyleNode.textContent = plugin.styles ?? '';
110
111
  }
111
112
 
113
+ // поле `compat` появляется у манифеста каталога мастера, только когда игра
114
+ // просит возможность, которой в этой сборке движка нет; манифест без него
115
+ // (все опубликованные до этапа 5) доступен по определению
116
+ function isGameAvailable(manifest) {
117
+ return manifest.compat?.ok !== false;
118
+ }
119
+
112
120
  // режим загрузки (Этап 2 плана standalone-sdk): lobby — прод с мастером,
113
121
  // solo — хост в этой же вкладке (standalone SDK), dedicated — прямой WS к
114
122
  // Node-серверу. Ветвлений ровно пять: манифест, сигналинг/лобби, транспорт,
@@ -146,9 +154,13 @@ try {
146
154
  throw gamesManifest;
147
155
  }
148
156
 
157
+ // недоступная игра (manifest.compat.ok === false, этап 5 плана
158
+ // plugin-forward-compat) не годится в активные: её плагин не загрузится,
159
+ // и вкладка встала бы на первой же игре каталога. В список лобби она
160
+ // при этом попадает — с причиной
149
161
  activeGameManifest = boot.gameId
150
162
  ? gamesManifest.find(manifest => manifest.id === boot.gameId)
151
- : gamesManifest[0];
163
+ : (gamesManifest.find(isGameAvailable) ?? gamesManifest[0]);
152
164
  }
153
165
 
154
166
  if (!activeGameManifest) {
@@ -278,6 +290,12 @@ const socketMethods = []; // методы для обработки сокет-
278
290
  let clientCore = null;
279
291
  let wasm = null;
280
292
 
293
+ // возможности загруженного клиентского ядра ({ abi, core, ops }) —
294
+ // читаются один раз при создании ядра, а не в момент вызова. Ядро старше
295
+ // самоописания даёт поколение 0 с пустым списком опкодов: это не ошибка,
296
+ // а игра, собранная до появления механизма (И2 плана plugin-forward-compat)
297
+ let clientCoreAbi = { abi: 0, core: null, ops: [] };
298
+
281
299
  // сервис пула зависимостей: «эта сущность моя или чужая?». Ядро читается
282
300
  // геттером — оно создаётся позже пула сервисов (см. lib/localPlayer.js)
283
301
  const localPlayer = createLocalPlayer(() => clientCore);
@@ -312,6 +330,10 @@ socketMethods[PS_CONFIG_DATA] = async data => {
312
330
 
313
331
  clientCore = core;
314
332
  wasm = { memory };
333
+ clientCoreAbi =
334
+ typeof core.abi_describe === 'function'
335
+ ? JSON.parse(core.abi_describe())
336
+ : { abi: 0, core: null, ops: [] };
315
337
 
316
338
  // инициализация сущностей игры
317
339
  for (const entity of Object.keys(entitiesOnCanvas)) {
@@ -1170,11 +1192,30 @@ function handleVisibilityChange() {
1170
1192
  }
1171
1193
  }
1172
1194
 
1173
- debugLog('clientCore', clientCore?.debug_json?.());
1195
+ debugLog('clientCore', clientCoreDebug());
1174
1196
  }
1175
1197
  }
1176
1198
  }
1177
1199
 
1200
+ // дамп клиентского ядра: сначала опкод dispatch, затем замороженный метод.
1201
+ // Метод не удаляется никогда (И1), поэтому запасной путь остаётся навсегда:
1202
+ // ядро, собранное до появления dispatch, отдаёт дамп по-старому
1203
+ function clientCoreDebug() {
1204
+ if (!clientCore) {
1205
+ return undefined;
1206
+ }
1207
+
1208
+ if (clientCoreAbi.ops.includes('debug.json')) {
1209
+ const out = clientCore.dispatch('debug.json', new Uint8Array(0));
1210
+
1211
+ if (out.length > 0) {
1212
+ return new TextDecoder().decode(out);
1213
+ }
1214
+ }
1215
+
1216
+ return clientCore.debug_json?.();
1217
+ }
1218
+
1178
1219
  // единая точка управления рендер-циклом: Ticker.add дубликаты не отсеивает,
1179
1220
  // а добавить renderTick могут и runModules, и восстановление контекста
1180
1221
  function startRenderLoop() {
@@ -1270,10 +1311,9 @@ function handleMessage(data) {
1270
1311
  return;
1271
1312
  }
1272
1313
 
1273
- // JSON-сообщение [portId, payload]
1274
- const msg = unpacking(data);
1275
-
1276
- socketMethods[msg[0]](msg[1]);
1314
+ // JSON-сообщение [portId, payload]; порт без обработчика игнорируется, а
1315
+ // не роняет обработку (lib/socketDispatch.js)
1316
+ dispatchSocketMessage(socketMethods, unpacking(data));
1277
1317
  }
1278
1318
 
1279
1319
  // разрыв P2P: выход хоста = смерть комнаты (host-migration нет). Останавливаем
@@ -1965,9 +2005,17 @@ function populateGameSelect() {
1965
2005
 
1966
2006
  gamesManifest.forEach(manifest => {
1967
2007
  const option = document.createElement('option');
2008
+ const available = isGameAvailable(manifest);
1968
2009
 
1969
2010
  option.value = manifest.id;
1970
- option.textContent = manifest.title;
2011
+ // недоступная игра остаётся видимой (раньше мастер выкидывал её из
2012
+ // каталога, и игрок видел пустое лобби без причины), но выбрать её
2013
+ // нельзя: комнату по ней всё равно не поднять
2014
+ option.textContent = available
2015
+ ? manifest.title
2016
+ : `${manifest.title} — unavailable`;
2017
+ option.disabled = !available;
2018
+ option.title = available ? '' : manifest.compat.text;
1971
2019
  gameSelect.appendChild(option);
1972
2020
  });
1973
2021
 
@@ -0,0 +1,31 @@
1
+ import { createRegistry } from '../lib/registry.js';
2
+
3
+ // Реестр опкодов `dispatch` (этап 4 плана plugin-forward-compat). Таблица
4
+ // экспортов wasm-ядра заморожена: символ, которого нет в уже опубликованном
5
+ // `.wasm`, там не появится никогда, и никакой адаптер его не синтезирует.
6
+ // Поэтому КАЖДАЯ будущая возможность ядра приезжает опкодом — строкой,
7
+ // которую ядро либо понимает, либо нет, — а не новым методом.
8
+ //
9
+ // Реестр append-only (И1): опкод, который могла начать понимать игра,
10
+ // существует вечно. Удаление опкода = удаление возможности, на которую
11
+ // игра уже могла опереться.
12
+ //
13
+ // Форма записи: `payload`/`response` фиксируют кодировку байтов конкретного
14
+ // опкода (сам `dispatch` их не знает — он возит сырые `Uint8Array`).
15
+ // Пустой ответ ядра — «опкод не обработан»; `[0x00]` — «обработан, ответа
16
+ // нет» (соглашение `abi::dispatch_result` в крейте).
17
+ export const abiOps = createRegistry('abiOps', [
18
+ // курированный дамп мира ядра; дублирует замороженный метод debug_json,
19
+ // с которого началась проверка механизма (этап 4.6)
20
+ {
21
+ value: 'debug.json',
22
+ since: 4,
23
+ payload: 'none',
24
+ response: 'json',
25
+ },
26
+ ]);
27
+
28
+ // плоский список активных опкодов — то, что вправе позвать движок
29
+ export const ABI_OPS = abiOps.values();
30
+
31
+ export default abiOps;
@@ -0,0 +1,27 @@
1
+ import { createRegistry } from '../lib/registry.js';
2
+
3
+ // Реестр движковых сервисов клиентского пула (этап 3 плана
4
+ // plugin-forward-compat). Пул ими не исчерпывается: игра доливает туда свои
5
+ // через ClientPlugin.hooks.services(core) — движок о них не знает ничего.
6
+ //
7
+ // Реестр append-only (И1): имя, которое игра написала в
8
+ // componentDependencies, существует вечно. Ключевое поведение, на которое
9
+ // опирается совместимость: пул отдаёт ЗАПРОШЕННОЕ, а не всё подряд
10
+ // (client/providers/DependencyProvider.js), поэтому шестой сервис ничего не
11
+ // требует от старых игр — они его не просят и не получают. Незнакомое имя —
12
+ // не отказ загрузки, а undefined в парте; ловит его правило контракта C4.
13
+ export const clientServices = createRegistry('clientServices', [
14
+ { value: 'renderer', since: 1 },
15
+ { value: 'soundManager', since: 1 },
16
+ // база ассетов пакета игры (`${assetsBase}img/...`)
17
+ { value: 'assetsBase', since: 3 },
18
+ // «свой ли это персонаж» (lib/localPlayer.js)
19
+ { value: 'localPlayer', since: 3 },
20
+ // места в глобальном топе (lib/accolades.js)
21
+ { value: 'accolades', since: 4 },
22
+ ]);
23
+
24
+ // плоский список имён — то, чем правило C4 проверяет componentDependencies
25
+ export const SERVICES = clientServices.values();
26
+
27
+ export default clientServices;
@@ -3,8 +3,17 @@
3
3
  // живут в Rust-ядре: packages/engine/core/src/snapshot.rs (pack) и packages/engine/core/src/client/unpack.rs.
4
4
 
5
5
  // версия контракта движок ↔ игра-плагин (GameManifest, HostPlugin,
6
- // ClientPlugin, Wasm Host ABI — docs/{en,ru}/plugin-api.md);
7
- // проверяется при загрузке плагинов; ломающие изменения контрактов → +1.
6
+ // ClientPlugin, Wasm Host ABI — docs/{en,ru}/plugin-api.md).
7
+ //
8
+ // ЗАМОРОЖЕНО на 4. Больше не гейт совместимости и больше не бампается:
9
+ // после этапов 1-4 плана plugin-forward-compat плагинная поверхность
10
+ // append-only, и отвергать плагин за возраст стало не за что. Остаётся как
11
+ // метка поколения контракта в манифестах и диагностике; удалять нельзя —
12
+ // константу импортируют скрипты сборки всех уже существующих игр
13
+ // (build-game-manifest.js) и правило контракта B2. Совместимость решается
14
+ // переговорами о возможностях, см. lib/capabilities.js.
15
+ //
16
+ // История поколений (для чтения манифестов постарше):
8
17
  // v2: явная схема форм (roomForm/authSchema.params[].options — Form schema
9
18
  // в plugin-api.md), движок больше не выводит контролы из типа значения
10
19
  // v3: набор control сокращён до нативных элементов — 'select'|'text'|
@@ -1,3 +1,12 @@
1
+ // Номера портов JSON-канала (docs/en/network.md). Реестр append-only, как и
2
+ // остальные словари плагинной поверхности (этап 3 плана
3
+ // plugin-forward-compat): номер НИКОГДА не переиспользуется и не
4
+ // перенумеровывается — уже опубликованная сборка игры шлёт и слушает те
5
+ // номера, что видела при сборке, и её dist больше никто не тронет.
6
+ // Выведенный из эксплуатации порт остаётся в этом файле со своим номером и
7
+ // пометкой `// retired in vN`, чтобы номер не был выдан повторно. За
8
+ // механикой следит раздел `ports` слепка поверхности
9
+ // (contract/surface.json): удаление имени или смена номера валит тест.
1
10
  export default {
2
11
  // порты получения данные от сервера
3
12
  server: {
@@ -2,6 +2,7 @@ import { access, readFile, readdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { ENGINE_API_VERSION } from '../../config/opcodes.js';
5
+ import { createGameConfigView } from '../../lib/gameConfigView.js';
5
6
 
6
7
  // Сбор контекста статической проверки: по каталогу пакета игры собирается
7
8
  // всё, что удалось прочитать, и помечается недостающее. Ни одна неудача не
@@ -53,12 +54,30 @@ export async function loadContext(gameDir) {
53
54
  ctx.clientPlugin = await loadHalf(ctx, 'client', CLIENT_ENTRIES);
54
55
 
55
56
  ctx.gameConfig = ctx.hostPlugin?.gameConfig ?? null;
57
+ // то же представление, что построит движок при загрузке (этап 2 плана
58
+ // plugin-forward-compat): правило, которое спрашивает «что получится»,
59
+ // а не «что объявлено», обязано смотреть сюда — иначе оно краснеет на
60
+ // конфиге, который движок принимает. Невалидный конфиг о себе скажет
61
+ // правилом B3, поэтому здесь падение вырождается в исходный объект
62
+ ctx.gameConfigView = resolveConfigView(ctx);
56
63
  ctx.authSchema = ctx.hostPlugin?.authSchema ?? null;
57
64
  ctx.clientConfig = buildClientConfig(ctx);
58
65
 
59
66
  return ctx;
60
67
  }
61
68
 
69
+ function resolveConfigView(ctx) {
70
+ if (!ctx.gameConfig) {
71
+ return null;
72
+ }
73
+
74
+ try {
75
+ return createGameConfigView(ctx.gameConfig, ctx.hostPlugin?.id);
76
+ } catch {
77
+ return ctx.gameConfig;
78
+ }
79
+ }
80
+
62
81
  // Половина плагина: сначала исходник (он свежее сборки), при неудаче —
63
82
  // собранный entry из манифеста. Клиентский исходник в Node обычно не
64
83
  // импортируется вовсе (`import './game.css'` — расширение Vite, не Node),
@@ -156,7 +175,9 @@ async function readEngineCoreVersion() {
156
175
  // Разбор обязан совпадать — правьте обе или ни одной (см. тест
157
176
  // tests/scaffold/versions.test.js).
158
177
  export function parseCrateVersion(text) {
159
- const section = text.split(/^\s*\[/m).find(part => part.startsWith('package]'));
178
+ const section = text
179
+ .split(/^\s*\[/m)
180
+ .find(part => part.startsWith('package]'));
160
181
 
161
182
  return section?.match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1] ?? null;
162
183
  }
@@ -226,7 +247,9 @@ async function listFiles(root) {
226
247
  // симлинк-каталог (обычная dev-раскладка: dist/img -> ../assets/img)
227
248
  // у readdir не isDirectory(); без разыменования он попал бы в список
228
249
  // файлом, и правила ассетов дали бы ложный отказ
229
- if (entry.isSymbolicLink() ? await isDirectory(full) : entry.isDirectory()) {
250
+ if (
251
+ entry.isSymbolicLink() ? await isDirectory(full) : entry.isDirectory()
252
+ ) {
230
253
  await walk(full, rel);
231
254
  } else {
232
255
  found.add(rel);
@@ -18,9 +18,11 @@ export default {
18
18
  return skip('no gameConfig.maps or gameConfig.teams');
19
19
  }
20
20
 
21
- const playing = Object.keys(teams).filter(
22
- team => team !== ctx.gameConfig.spectatorTeam,
23
- );
21
+ // эффективное значение: игра вправе не объявлять spectatorTeam (И2), а
22
+ // по объявленному команда наблюдателей попала бы в играющие и правило
23
+ // потребовало бы для неё респауны
24
+ const spectatorTeam = ctx.gameConfigView?.spectatorTeam ?? null;
25
+ const playing = Object.keys(teams).filter(team => team !== spectatorTeam);
24
26
  const maxPlayers = ctx.gameConfig.roomDefaults?.maxPlayers;
25
27
  const violations = [];
26
28
 
@@ -1,20 +1,32 @@
1
1
  import { ERROR, skip, verdict } from '../result.js';
2
+ import { CAPABILITIES } from '../../../lib/capabilities.js';
2
3
 
3
- // engineApi живёт в трёх местах (манифест, HostPlugin, ClientPlugin) и во
4
- // всех трёх обязан быть импортом ENGINE_API_VERSION, а не числом: литерал
5
- // не расходится со сборкой движка в момент написания и молча расходится
6
- // через релиз, после чего плагин отклоняется гейтом совместимости.
4
+ // `engineApi` живёт в трёх местах (манифест, HostPlugin, ClientPlugin).
5
+ // Расхождение между ними рассинхрон сборки внутри пакета, и это ошибка.
6
+ // Расхождение с установленным движком ошибкой БОЛЬШЕ НЕ является (этап 5
7
+ // плана plugin-forward-compat): `ENGINE_API_VERSION` заморожен на 4 и не
8
+ // гейт, а игра, собранная год назад против движка не последней версии, —
9
+ // нормальное состояние, ради которого весь план и делался.
10
+ //
11
+ // Импортом, а не литералом, значение остаётся по-прежнему: литерал не
12
+ // расходится со сборкой движка в момент написания и молча расходится через
13
+ // релиз, после чего манифест игры врёт о своём поколении.
14
+ //
15
+ // Возможности из `manifest.requires` проверяются по реестру установленного
16
+ // движка: имени, которого нет, движок дать не может — игра просит будущее.
7
17
  export default {
8
18
  id: 'B2',
9
19
  name: 'engineApiVersion',
10
20
  level: ERROR,
11
- title: 'engineApi matches ENGINE_API_VERSION in all three places',
21
+ title: 'engineApi is consistent and requires name existing capabilities',
12
22
 
13
23
  check(ctx) {
24
+ // манифест первый: он — то, что о поколении пакета читает движок,
25
+ // и с ним сверяются половины плагина
14
26
  const declared = [
27
+ ['manifest', ctx.manifest?.engineApi],
15
28
  ['host plugin', ctx.hostPlugin?.engineApi],
16
29
  ['client plugin', ctx.clientPlugin?.engineApi],
17
- ['manifest', ctx.manifest?.engineApi],
18
30
  ].filter(([, value]) => value !== undefined);
19
31
 
20
32
  if (!declared.length) {
@@ -22,11 +34,13 @@ export default {
22
34
  }
23
35
 
24
36
  const violations = [];
37
+ const [source, reference] = declared[0];
25
38
 
26
39
  for (const [where, value] of declared) {
27
- if (value !== ctx.engineApi) {
40
+ if (value !== reference) {
28
41
  violations.push(
29
- `${where} declares engineApi v${value}, this engine is v${ctx.engineApi}`,
42
+ `${where} declares engineApi v${value}, but ${source} declares ` +
43
+ `v${reference} — rebuild the package`,
30
44
  );
31
45
  }
32
46
  }
@@ -42,6 +56,15 @@ export default {
42
56
  }
43
57
  }
44
58
 
59
+ for (const name of ctx.manifest?.requires ?? []) {
60
+ if (!CAPABILITIES.includes(name)) {
61
+ violations.push(
62
+ `manifest.requires names '${name}', which this engine does not ` +
63
+ `provide — known capabilities: ${CAPABILITIES.join(', ')}`,
64
+ );
65
+ }
66
+ }
67
+
45
68
  return verdict(violations);
46
69
  },
47
70
  };