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,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 {
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.1"
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.1",
3
+ "version": "0.24.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,12 +1,23 @@
1
1
  import { anchorPattern } from '../../lib/formPattern.js';
2
2
  import { normalizeOptions } from '../../lib/formOptions.js';
3
+ import { resolveDescriptor } from '../../lib/formControls.js';
4
+ import { toDisplay, toStored, isNumericField } from '../../lib/formUnit.js';
3
5
 
4
6
  // Общий билдер полей форм (room-форма и auth-форма используют один
5
7
  // контракт дескрипторов — docs/en/plugin-api.md, раздел "Form schema").
6
8
  // control: 'select'|'text'|'checkbox'|'radio'; все контролы — нативные
7
- // элементы формы, без визуальной кастомизации. Валидация — не нативная
8
- // (никаких браузерных попапов): collectFormErrors/renderFormErrors ниже
9
- // собирают и рисуют ошибки в разметку (#lobby-error/#auth-error).
9
+ // элементы формы, без визуальной кастомизации.
10
+ //
11
+ // Имя контрола приезжает от игры и разрешается через append-only реестр
12
+ // (lib/formControls.js): имена, выведенные из эксплуатации в v3 ('range',
13
+ // 'number', 'toggle', 'segmented'), продолжают строиться алиасами своих
14
+ // нативных замен (И1). Тот же резолв обязана делать авторитетная валидация
15
+ // хоста (lib/validators.js) — иначе поле строится одним контролом, а
16
+ // проверяется как другой.
17
+ //
18
+ // Валидация — не нативная (никаких браузерных попапов):
19
+ // collectFormErrors/renderFormErrors ниже собирают и рисуют ошибки в
20
+ // разметку (#lobby-error/#auth-error).
10
21
  // Проверяются только поля, у которых есть строка в DOM: ошибка на скрытом
11
22
  // поле игроку не видна и исправить её нечем — она просто запирает форму.
12
23
 
@@ -14,12 +25,6 @@ import { normalizeOptions } from '../../lib/formOptions.js';
14
25
  // resolveForcedValue не расходился с builders при добавлении нового
15
26
  const OPTION_CONTROLS = ['select', 'radio'];
16
27
 
17
- // числовое text-поле: unit задан или numeric:true. Правило одно на билдер и
18
- // на валидацию, поэтому живёт в одном месте
19
- function isNumeric(descriptor) {
20
- return descriptor.numeric === true || descriptor.unit !== undefined;
21
- }
22
-
23
28
  // 'source' — спец-источник вариантов из каталога движка (например карты),
24
29
  // прокидывается вызывающей стороной через ctx.sources
25
30
  function resolveOptions(descriptor, ctx) {
@@ -43,11 +48,14 @@ function forcedValue(options) {
43
48
  // то же правило вне формы: solo-путь (boot.autoAuth) отвечает хосту без
44
49
  // всякой формы и должен прийти ровно к тому же значению, что и она
45
50
  export function resolveForcedValue(descriptor, ctx = {}) {
46
- if (!OPTION_CONTROLS.includes(descriptor.control)) {
51
+ // после разрешения алиаса: 'segmented' — та же группа вариантов, что radio
52
+ const resolved = resolveDescriptor(descriptor);
53
+
54
+ if (!OPTION_CONTROLS.includes(resolved.control)) {
47
55
  return undefined;
48
56
  }
49
57
 
50
- const forced = forcedValue(resolveOptions(descriptor, ctx));
58
+ const forced = forcedValue(resolveOptions(resolved, ctx));
51
59
 
52
60
  // форма отдала бы строку: и <option>.value, и <input type=radio>.value —
53
61
  // DOM-свойства, они всегда строки. Нестроковое значение (options: [1, 2])
@@ -56,15 +64,6 @@ export function resolveForcedValue(descriptor, ctx = {}) {
56
64
  return forced === undefined ? undefined : String(forced);
57
65
  }
58
66
 
59
- // unit:'s' — значение хранится в мс, показывается/редактируется в секундах
60
- function toDisplay(descriptor, value) {
61
- return descriptor.unit === 's' ? value / 1000 : value;
62
- }
63
-
64
- function toStored(descriptor, value) {
65
- return descriptor.unit === 's' ? value * 1000 : value;
66
- }
67
-
68
67
  function buildSelect(descriptor, ctx) {
69
68
  const el = document.createElement('select');
70
69
  const options = resolveOptions(descriptor, ctx);
@@ -109,7 +108,7 @@ function buildSelect(descriptor, ctx) {
109
108
  // вместо превращения его в 0 на сабмите
110
109
  function buildText(descriptor) {
111
110
  const el = document.createElement('input');
112
- const numeric = isNumeric(descriptor);
111
+ const numeric = isNumericField(descriptor);
113
112
 
114
113
  el.type = 'text';
115
114
  // выпадашка прошлых значений/автозаполнения кроет форму, а подставить в
@@ -265,18 +264,27 @@ const builders = {
265
264
  radio: buildRadio,
266
265
  };
267
266
 
267
+ // имена control, которые билдер умеет строить. Активный набор имён диктует
268
+ // реестр (lib/formControls.js), и совпадение этих двух списков проверяет
269
+ // tests/client/formControls.test.js: разъехавшись, они дали бы «unknown
270
+ // control» на имени, которое реестр считает живым
271
+ export const FORM_CONTROLS = Object.keys(builders);
272
+
268
273
  // строит одно поле формы по дескриптору (docs/en/plugin-api.md, "Form schema")
269
274
  export function buildField(descriptor, ctx = {}) {
270
- const build = builders[descriptor.control];
275
+ // алиас выведенного контрола ('range' → numeric 'text') разрешается
276
+ // здесь же, где строится поле, и теми же правилами, что в валидации
277
+ const patched = resolveDescriptor(descriptor);
278
+ const build = builders[patched.control];
271
279
 
272
280
  if (!build) {
273
281
  throw new Error(`formBuilder: unknown control "${descriptor.control}"`);
274
282
  }
275
283
 
276
- const field = build(descriptor, ctx);
284
+ const field = build(patched, ctx);
277
285
 
278
- if (descriptor.default !== undefined) {
279
- field.setValue(descriptor.default);
286
+ if (patched.default !== undefined) {
287
+ field.setValue(patched.default);
280
288
  }
281
289
 
282
290
  return field;
@@ -370,7 +378,7 @@ function validateField(descriptor, field) {
370
378
  const isEmpty = isText
371
379
  ? raw === ''
372
380
  : value === undefined || value === null || value === '';
373
- const numeric = isText && isNumeric(descriptor);
381
+ const numeric = isText && isNumericField(descriptor);
374
382
 
375
383
  // числовое поле всегда несёт число и «необязательным» быть не может:
376
384
  // пустой ввод getValue() подменяет default'ом (чтобы не уехал нолём), и
@@ -441,7 +449,10 @@ export function collectFormErrors(descriptors, fields) {
441
449
  return;
442
450
  }
443
451
 
444
- const error = validateField(descriptor, field);
452
+ // тот же резолв алиаса, что и в buildField: валидация обязана смотреть
453
+ // на контрол, которым поле построено, иначе бывшее range-поле уезжает
454
+ // на хост без проверки диапазона
455
+ const error = validateField(resolveDescriptor(descriptor), field);
445
456
 
446
457
  if (error) {
447
458
  errors.push({
@@ -0,0 +1,59 @@
1
+ // Выбор активной игры из каталога мастера. Вынесено из client/main.js по той
2
+ // же причине, что и socketDispatch.js: main.js исполняется при импорте, и
3
+ // проверить ветвление внутри него нечем.
4
+ //
5
+ // Недоступная игра (manifest.compat.ok === false, этап 5 плана
6
+ // plugin-forward-compat) в СПИСОК лобби попадает — с причиной, — но активной
7
+ // быть не может: её плагин не загрузится, и вкладка встала бы на первой же
8
+ // игре каталога.
9
+
10
+ /**
11
+ * Доступна ли игра. Поле `compat` появляется у манифеста каталога мастера,
12
+ * только когда игра просит возможность, которой в этой сборке движка нет;
13
+ * манифест без него (все опубликованные до этапа 5) доступен по определению.
14
+ * @param {Object} manifest - GameManifest из каталога мастера.
15
+ * @returns {boolean}
16
+ */
17
+ export function isGameAvailable(manifest) {
18
+ return manifest.compat?.ok !== false;
19
+ }
20
+
21
+ /**
22
+ * Активная игра вкладки.
23
+ *
24
+ * Обе ветки фильтруются одинаково: сохранённый выбор игры (`gameId`) тоже мог
25
+ * указывать на игру, ставшую недоступной после обновления движка, и брать её
26
+ * «раз уж попросили» значило бы падать там, где можно объяснить.
27
+ * @param {Array<Object>} manifests - Каталог мастера.
28
+ * @param {string} [gameId] - Сохранённый выбор игры (boot.gameId).
29
+ * @returns {Object|undefined} Манифест активной игры.
30
+ * @throws {Error} Если каталог непустой, но играбельной игры в нём нет —
31
+ * с причиной по каждой строке.
32
+ */
33
+ export function pickActiveGame(manifests, gameId) {
34
+ const active = gameId
35
+ ? manifests.find(
36
+ manifest => manifest.id === gameId && isGameAvailable(manifest),
37
+ )
38
+ : manifests.find(isGameAvailable);
39
+
40
+ if (active) {
41
+ return active;
42
+ }
43
+
44
+ // непустой каталог без единой играбельной строки — сказать это одной
45
+ // понятной фразой, а не отдать наверх первую попавшуюся и упасть в
46
+ // loadClientPlugin непрозрачной ошибкой
47
+ if (manifests.length) {
48
+ throw new Error(
49
+ 'no playable game in the lobby catalog: ' +
50
+ manifests
51
+ .map(manifest => manifest.compat?.text ?? `"${manifest.id}"`)
52
+ .join('; '),
53
+ );
54
+ }
55
+
56
+ return undefined;
57
+ }
58
+
59
+ export default pickActiveGame;
@@ -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;
@@ -26,7 +26,11 @@ import StatCtrl from './components/controller/Stat.js';
26
26
  import VoteModel from './components/model/Vote.js';
27
27
  import VoteView from './components/view/Vote.js';
28
28
  import VoteCtrl from './components/controller/Vote.js';
29
- import { buildForm, mergeRoomDefaults, bindLiveErrors } from './lib/formBuilder.js';
29
+ import {
30
+ buildForm,
31
+ mergeRoomDefaults,
32
+ bindLiveErrors,
33
+ } from './lib/formBuilder.js';
30
34
  import { normalizeAuthParams } from './lib/authParams.js';
31
35
  import { renderProjectLink } from './lib/footerLink.js';
32
36
  import { createGameActivator } from './lib/gameActivator.js';
@@ -36,6 +40,10 @@ import { ensureGameShell, ensureCanvas } from './views/gameShell.js';
36
40
  import { createContextTracker } from './lib/contextTracker.js';
37
41
  import { createLocalPlayer } from './lib/localPlayer.js';
38
42
  import { createAccolades } from './lib/accolades.js';
43
+ import { dispatchSocketMessage } from './lib/socketDispatch.js';
44
+ import { pickActiveGame, isGameAvailable } from './lib/pickActiveGame.js';
45
+ import { readCoreAbi, dispatchCoreOp, ABI_UNKNOWN } from '../lib/coreAbi.js';
46
+ import { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
39
47
  import { createDebugApi, debugLog, DEBUG_PREFIX } from './debug.js';
40
48
  import { buildClientCoreConfig } from '../lib/clientCoreConfig.js';
41
49
  import {
@@ -146,9 +154,8 @@ try {
146
154
  throw gamesManifest;
147
155
  }
148
156
 
149
- activeGameManifest = boot.gameId
150
- ? gamesManifest.find(manifest => manifest.id === boot.gameId)
151
- : gamesManifest[0];
157
+ // недоступная игра активной быть не может (lib/pickActiveGame.js)
158
+ activeGameManifest = pickActiveGame(gamesManifest, boot.gameId);
152
159
  }
153
160
 
154
161
  if (!activeGameManifest) {
@@ -278,6 +285,12 @@ const socketMethods = []; // методы для обработки сокет-
278
285
  let clientCore = null;
279
286
  let wasm = null;
280
287
 
288
+ // возможности загруженного клиентского ядра ({ abi, core, ops }) —
289
+ // читаются один раз при создании ядра, а не в момент вызова. Ядро старше
290
+ // самоописания даёт поколение 0 с пустым списком опкодов: это не ошибка,
291
+ // а игра, собранная до появления механизма (И2 плана plugin-forward-compat)
292
+ let clientCoreAbi = ABI_UNKNOWN;
293
+
281
294
  // сервис пула зависимостей: «эта сущность моя или чужая?». Ядро читается
282
295
  // геттером — оно создаётся позже пула сервисов (см. lib/localPlayer.js)
283
296
  const localPlayer = createLocalPlayer(() => clientCore);
@@ -312,6 +325,7 @@ socketMethods[PS_CONFIG_DATA] = async data => {
312
325
 
313
326
  clientCore = core;
314
327
  wasm = { memory };
328
+ clientCoreAbi = readCoreAbi(core, 'client core');
315
329
 
316
330
  // инициализация сущностей игры
317
331
  for (const entity of Object.keys(entitiesOnCanvas)) {
@@ -1130,8 +1144,7 @@ const RESYNC_AFTER_HIDDEN_MS = 3000;
1130
1144
 
1131
1145
  // вкладка могла быть скрыта уже в момент навешивания слушателя — события
1132
1146
  // 'hidden' тогда не будет, а пауза всё равно идёт
1133
- let hiddenAt =
1134
- document.visibilityState === 'hidden' ? performance.now() : null;
1147
+ let hiddenAt = document.visibilityState === 'hidden' ? performance.now() : null;
1135
1148
 
1136
1149
  // обработчик видимости вкладки
1137
1150
  function handleVisibilityChange() {
@@ -1170,11 +1183,34 @@ function handleVisibilityChange() {
1170
1183
  }
1171
1184
  }
1172
1185
 
1173
- debugLog('clientCore', clientCore?.debug_json?.());
1186
+ debugLog('clientCore', clientCoreDebug());
1174
1187
  }
1175
1188
  }
1176
1189
  }
1177
1190
 
1191
+ // дамп клиентского ядра: сначала опкод dispatch, затем замороженный метод.
1192
+ // Метод не удаляется никогда (И1), поэтому запасной путь остаётся навсегда:
1193
+ // ядро, собранное до появления dispatch, отдаёт дамп по-старому.
1194
+ // dispatchCoreOp — та же точка вызова, что у хостового GameCoreAdapter._op:
1195
+ // имя опкода читается из реестра, три исхода ответа различимы
1196
+ function clientCoreDebug() {
1197
+ if (!clientCore) {
1198
+ return undefined;
1199
+ }
1200
+
1201
+ const { handled, bytes } = dispatchCoreOp(
1202
+ clientCore,
1203
+ clientCoreAbi,
1204
+ ABI_OP_DEBUG_JSON,
1205
+ );
1206
+
1207
+ if (handled && bytes !== null) {
1208
+ return new TextDecoder().decode(bytes);
1209
+ }
1210
+
1211
+ return clientCore.debug_json?.();
1212
+ }
1213
+
1178
1214
  // единая точка управления рендер-циклом: Ticker.add дубликаты не отсеивает,
1179
1215
  // а добавить renderTick могут и runModules, и восстановление контекста
1180
1216
  function startRenderLoop() {
@@ -1270,10 +1306,9 @@ function handleMessage(data) {
1270
1306
  return;
1271
1307
  }
1272
1308
 
1273
- // JSON-сообщение [portId, payload]
1274
- const msg = unpacking(data);
1275
-
1276
- socketMethods[msg[0]](msg[1]);
1309
+ // JSON-сообщение [portId, payload]; порт без обработчика игнорируется, а
1310
+ // не роняет обработку (lib/socketDispatch.js)
1311
+ dispatchSocketMessage(socketMethods, unpacking(data));
1277
1312
  }
1278
1313
 
1279
1314
  // разрыв P2P: выход хоста = смерть комнаты (host-migration нет). Останавливаем
@@ -1373,7 +1408,9 @@ if (isDevBuild) {
1373
1408
  reportUrl: lobbyConfig.debugReportUrl,
1374
1409
  });
1375
1410
 
1376
- debugLog('window.__vimpDebug is available: dump, startRecording, stopRecording, divergence');
1411
+ debugLog(
1412
+ 'window.__vimpDebug is available: dump, startRecording, stopRecording, divergence',
1413
+ );
1377
1414
  }
1378
1415
 
1379
1416
  // WebRTC обязателен для P2P-игры. В Firefox RTCPeerConnection может
@@ -1904,7 +1941,9 @@ function populateRoomForm(manifest) {
1904
1941
 
1905
1942
  // каталог манифестов по id: форма и leaderboard селектора игр, а также
1906
1943
  // активация игры перед созданием комнаты и входом в чужую
1907
- const gamesById = new Map(gamesManifest.map(manifest => [manifest.id, manifest]));
1944
+ const gamesById = new Map(
1945
+ gamesManifest.map(manifest => [manifest.id, manifest]),
1946
+ );
1908
1947
 
1909
1948
  // ClientPlugin выбранной игры грузится в момент клика (создание комнаты /
1910
1949
  // вход в комнату), а не при смене селектора: просмотр каталога не должен
@@ -1965,9 +2004,17 @@ function populateGameSelect() {
1965
2004
 
1966
2005
  gamesManifest.forEach(manifest => {
1967
2006
  const option = document.createElement('option');
2007
+ const available = isGameAvailable(manifest);
1968
2008
 
1969
2009
  option.value = manifest.id;
1970
- option.textContent = manifest.title;
2010
+ // недоступная игра остаётся видимой (раньше мастер выкидывал её из
2011
+ // каталога, и игрок видел пустое лобби без причины), но выбрать её
2012
+ // нельзя: комнату по ней всё равно не поднять
2013
+ option.textContent = available
2014
+ ? manifest.title
2015
+ : `${manifest.title} — unavailable`;
2016
+ option.disabled = !available;
2017
+ option.title = available ? '' : manifest.compat.text;
1971
2018
  gameSelect.appendChild(option);
1972
2019
  });
1973
2020
 
@@ -0,0 +1,37 @@
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
+ // Именованные константы вместо литералов в местах вызова: строка 'debug.json',
32
+ // написанная руками у хоста и у клиента, обходила бы реестр молча — новый
33
+ // опкод уехал бы в прод, не попав ни в слепок поверхности, ни в
34
+ // CHANGELOG. Имя опкода читается ТОЛЬКО отсюда.
35
+ export const ABI_OP_DEBUG_JSON = 'debug.json';
36
+
37
+ 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);