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,89 @@
1
+ import { createRegistry } from './registry.js';
2
+
3
+ // Реестр значений `control` дескриптора формы (этап 3 плана
4
+ // plugin-forward-compat). Активных контролов четыре — все нативные элементы
5
+ // формы, их строит client/lib/formBuilder.js. Остальные четыре выведены из
6
+ // эксплуатации в v3, но продолжают работать вечно (И1): игра, собранная под
7
+ // v2, написала их в манифесте, и её dist больше никто не тронет.
8
+ //
9
+ // `patch` — то, чем алиас доливает дескриптор: без `numeric: true` бывшее
10
+ // числовое поле стало бы свободным текстом, и валидация пропустила бы в
11
+ // комнату строку вместо числа.
12
+ export const formControls = createRegistry('formControls', [
13
+ { value: 'select', since: 1 },
14
+ { value: 'text', since: 1 },
15
+ { value: 'checkbox', since: 1 },
16
+ { value: 'radio', since: 1 },
17
+ {
18
+ value: 'range',
19
+ since: 1,
20
+ alias: 'text',
21
+ retiredIn: 3,
22
+ patch: { numeric: true },
23
+ note: 'нативного range нет; рисуется как numeric text',
24
+ },
25
+ {
26
+ value: 'number',
27
+ since: 1,
28
+ alias: 'text',
29
+ retiredIn: 3,
30
+ patch: { numeric: true },
31
+ note: 'нативного number нет; рисуется как numeric text',
32
+ },
33
+ {
34
+ value: 'toggle',
35
+ since: 1,
36
+ alias: 'checkbox',
37
+ retiredIn: 3,
38
+ note: 'то же поведение, нативный checkbox',
39
+ },
40
+ {
41
+ value: 'segmented',
42
+ since: 1,
43
+ alias: 'radio',
44
+ retiredIn: 3,
45
+ note: 'то же поведение, группа нативных radio',
46
+ },
47
+ ]);
48
+
49
+ /**
50
+ * Разрешает `descriptor.control` в контрол, который умеет строить билдер.
51
+ * @param {string} control - Значение `control` из дескриптора.
52
+ * @returns {{control: string, patch: Object}|undefined} Активный контрол и
53
+ * накладка на дескриптор, либо undefined для неизвестного имени.
54
+ */
55
+ export function resolveControl(control) {
56
+ const chain = formControls.chain(control);
57
+
58
+ if (chain.length === 0) {
59
+ return undefined;
60
+ }
61
+
62
+ // накладки копятся по всей цепочке: алиас на алиас обязан донести обе
63
+ return {
64
+ control: chain.at(-1).value,
65
+ patch: Object.assign({}, ...chain.map(entry => entry.patch ?? {})),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Приводит дескриптор поля к активному контролу (разрешает алиас).
71
+ * @param {Object} descriptor - Дескриптор поля формы.
72
+ * @returns {Object} Тот же объект, если контрол активен или неизвестен
73
+ * (последнее скажет билдер), иначе копия с активным `control` и накладкой
74
+ * алиаса под явными полями игры.
75
+ */
76
+ export function resolveDescriptor(descriptor) {
77
+ const resolved = resolveControl(descriptor?.control);
78
+
79
+ if (!resolved || resolved.control === descriptor.control) {
80
+ return descriptor;
81
+ }
82
+
83
+ // накладка идёт ПОД дескриптором: она уточняет контрол, а не переписывает
84
+ // схему игры (явный numeric:false остаётся за игрой)
85
+ return { ...resolved.patch, ...descriptor, control: resolved.control };
86
+ }
87
+
88
+ // имена, которые вправе написать новая игра (контракт-чекер, документация)
89
+ export const ACTIVE_FORM_CONTROLS = formControls.values();
@@ -0,0 +1,211 @@
1
+ import hostDefaults from '../config/hostDefaults.js';
2
+
3
+ // Единственная точка чтения HostPlugin.gameConfig движком (этап 2 плана
4
+ // plugin-forward-compat). До неё движок разыменовывал конфиг игры россыпью
5
+ // по коду, и каждое новое поле немедленно становилось обязательным —
6
+ // прямое нарушение И2 («ничто новое не обязательно»).
7
+ //
8
+ // View — обычный объект (не класс): applyRoomOverrides делает по нему
9
+ // structuredClone и spread, а те не переживают прототипы и геттеры-ловушки.
10
+ // Поля игры, о которых движок не знает, копируются как есть — игра вправе
11
+ // держать в gameConfig что угодно сверх контракта.
12
+
13
+ // Каждая строка ниже — обещание совместимости: поле, которого нет в конфиге
14
+ // старой игры, отдаёт умолчание, а не роняет загрузку (И2). Умолчание
15
+ // обязано быть безопасным для игры, которая о поле не знает.
16
+ //
17
+ // Ключ — путь через точку, значение — { default } либо { derive(config) }.
18
+ // Список может только РАСТИ: перенос поля отсюда в REQUIRED отверг бы уже
19
+ // опубликованные игры.
20
+ const FIELDS = {
21
+ // название в лобби; null → вызывающий берёт plugin.id
22
+ title: { default: null },
23
+
24
+ // карты везёт мастер (room.maps), у игры их может не быть вовсе
25
+ maps: { default: {} },
26
+ currentMap: { default: null },
27
+ // сколько карт попадает в голосование; 1 — минимальный осмысленный набор
28
+ mapsInVote: { default: 1 },
29
+ mapScale: { default: 1 },
30
+ mapSetId: { default: null },
31
+
32
+ // лимит комнаты: без него берётся движковый (hostDefaults.maxPlayers)
33
+ 'roomDefaults.maxPlayers': { default: hostDefaults.maxPlayers },
34
+
35
+ // игра без оружия — не экзотика: @vimp-games/snakes уже такая
36
+ 'parts.weapons': { default: {} },
37
+ // огонь по своим — опция игры; «нет опции» значит «выключено»
38
+ 'parts.friendlyFire': { default: false },
39
+
40
+ // пустая панель рисуется корректно: Panel читает её через Object.keys.
41
+ // fields — словарь «имя → { key, value }», поэтому пусто здесь это {}
42
+ 'panel.fields': { default: {} },
43
+ 'panel.activeKey': { default: null },
44
+
45
+ // таблица статистики и стартовые данные участника — целиком игровые
46
+ stat: { default: {} },
47
+ scripted: { default: {} },
48
+ playerState: { default: {} },
49
+
50
+ soundCues: { default: {} },
51
+ // голосование на входе (обычно 'teamChange'); null — входим молча
52
+ initialVote: { default: null },
53
+
54
+ // чем игра занимает экран по Tab: движковая таблица или свой лидерборд
55
+ statMode: { default: 'table' },
56
+ // opt-in-флаги: их отсутствие всегда означало «выключено»
57
+ noSpectators: { default: false },
58
+ endlessRound: { default: false },
59
+
60
+ spectatorTeam: { derive: deriveSpectatorTeam },
61
+ };
62
+
63
+ // Все пути gameConfig, которые движок читает: объявленные игрой поля плюс
64
+ // те, за которые он подставляет умолчание. Раздел слепка поверхности
65
+ // (contract/surface.json → gameConfigFields): исчезнувший отсюда путь —
66
+ // нарушение И1, потому что игра могла его написать.
67
+ export const KNOWN_GAME_CONFIG_PATHS = Object.keys(FIELDS);
68
+
69
+ // ЗАМОРОЖЕНО (И2). Этот список может только СОКРАЩАТЬСЯ.
70
+ // Добавление сюда отвергнет все ранее опубликованные игры — вместо этого
71
+ // заведи поле в FIELDS с умолчанием. Страж: tests/devtools/surface.test.js.
72
+ export const REQUIRED_GAME_CONFIG_PATHS = [
73
+ 'parts.models', // из чего состоит участник; синтезировать нечем
74
+ 'playerKeys', // без них ядро не знает ввода
75
+ 'snapshot', // раскладка кадра; движок её не придумывает
76
+ 'teams', // ParticipantManager выбирает команду входа
77
+ ];
78
+
79
+ // Единственный признак «команда наблюдателей», который есть в данных, —
80
+ // её имя: teams это словарь «имя → id», флагов в нём нет. Игра, объявившая
81
+ // наблюдателей как-то иначе, обязана назвать spectatorTeam явно.
82
+ const SPECTATOR_TEAM_NAME = 'spectators';
83
+
84
+ function deriveSpectatorTeam(config, gameId) {
85
+ // наблюдателей нет как концепции — связывать нечего
86
+ if (config.noSpectators === true) {
87
+ return null;
88
+ }
89
+
90
+ if (Object.hasOwn(config.teams ?? {}, SPECTATOR_TEAM_NAME)) {
91
+ return SPECTATOR_TEAM_NAME;
92
+ }
93
+
94
+ // null — рабочее значение (ParticipantManager заводит участника в первую
95
+ // команду), но почти наверняка не то, чего хотела игра: предупреждаем
96
+ console.warn(
97
+ `game "${gameId}": gameConfig.spectatorTeam is not set and teams has ` +
98
+ `no "${SPECTATOR_TEAM_NAME}" key — everyone joins the first team ` +
99
+ `(${Object.keys(config.teams ?? {})[0] ?? '—'}); declare ` +
100
+ 'spectatorTeam or noSpectators to say what you meant',
101
+ );
102
+
103
+ return null;
104
+ }
105
+
106
+ function getPath(source, dottedPath) {
107
+ return dottedPath.split('.').reduce((value, key) => value?.[key], source);
108
+ }
109
+
110
+ // copy-on-write: умолчание вкладывается в копию ветки, объект игры не
111
+ // правится — gameConfig принадлежит плагину и переживает перезапуск матча
112
+ function setPath(target, dottedPath, value) {
113
+ const keys = dottedPath.split('.');
114
+ let node = target;
115
+
116
+ for (const key of keys.slice(0, -1)) {
117
+ node[key] =
118
+ node[key] === undefined || node[key] === null ? {} : { ...node[key] };
119
+ node = node[key];
120
+ }
121
+
122
+ node[keys.at(-1)] = value;
123
+ }
124
+
125
+ /**
126
+ * Строит представление gameConfig с умолчаниями и проверяет обязательное.
127
+ * @param {Object} gameConfig - HostPlugin.gameConfig игры как есть.
128
+ * @param {string} [gameId] - id плагина; попадает в текст ошибок.
129
+ * @returns {Object} Замороженный конфиг: поля игры плюс умолчания движка.
130
+ * @throws {Error} Если нет поля из REQUIRED_GAME_CONFIG_PATHS или конфиг
131
+ * внутренне противоречив (spectatorTeam вне teams, noSpectators при двух
132
+ * командах).
133
+ */
134
+ export function createGameConfigView(gameConfig, gameId = 'unknown') {
135
+ const source = gameConfig ?? {};
136
+
137
+ // null проходил бы проверку присутствия, хотя ни одно из этих полей не
138
+ // бывает пустым по контракту: движок разыменовывает их сразу, и гейт,
139
+ // заведённый ради текста вместо TypeError, сам отвечал бы TypeError
140
+ const missing = REQUIRED_GAME_CONFIG_PATHS.filter(path => {
141
+ const value = getPath(source, path);
142
+
143
+ return value === undefined || value === null;
144
+ });
145
+
146
+ if (missing.length > 0) {
147
+ throw new Error(
148
+ `game "${gameId}": gameConfig is missing required field(s): ` +
149
+ missing.join(', '),
150
+ );
151
+ }
152
+
153
+ const view = { ...source };
154
+
155
+ for (const [path, spec] of Object.entries(FIELDS)) {
156
+ const value = getPath(source, path);
157
+
158
+ if (value !== undefined && value !== null) {
159
+ continue;
160
+ }
161
+
162
+ setPath(
163
+ view,
164
+ path,
165
+ spec.derive ? spec.derive(source, gameId) : spec.default,
166
+ );
167
+ }
168
+
169
+ assertConsistent(view, source, gameId);
170
+
171
+ return Object.freeze(view);
172
+ }
173
+
174
+ // Проверки внутренней согласованности того, что игра УЖЕ прислала. Это не
175
+ // новые требования (И2): поле, которого нет, здесь не проверяется вовсе.
176
+ function assertConsistent(view, source, gameId) {
177
+ const { teams } = view;
178
+
179
+ // noSpectators: связывать нечего — зато команда обязана быть ровно одна.
180
+ // Вторая играющая команда без наблюдателей означала бы вход «куда-нибудь»,
181
+ // а ParticipantManager выбирает команду входа однозначно
182
+ if (view.noSpectators === true) {
183
+ if (Object.keys(teams).length !== 1) {
184
+ throw new Error(
185
+ `game "${gameId}": noSpectators requires exactly one team, ` +
186
+ `got ${Object.keys(teams).length} (${Object.keys(teams).join(', ')})`,
187
+ );
188
+ }
189
+
190
+ return;
191
+ }
192
+
193
+ // spectatorTeam — имя ключа внутри teams, и опечатка даёт spectatorId ===
194
+ // undefined, после чего участник заходит в несуществующую команду
195
+ // (ParticipantManager.createHuman валится на её счётчике). Проверяем
196
+ // только объявленное игрой: выведенное значение уже согласовано
197
+ const declared = source.spectatorTeam;
198
+
199
+ if (
200
+ declared !== undefined &&
201
+ declared !== null &&
202
+ teams[declared] === undefined
203
+ ) {
204
+ throw new Error(
205
+ `game "${gameId}": spectatorTeam '${declared}' is not a ` +
206
+ `key of teams (${Object.keys(teams).join(', ')})`,
207
+ );
208
+ }
209
+ }
210
+
211
+ export default createGameConfigView;
@@ -1,4 +1,5 @@
1
- import { ENGINE_API_VERSION } from '../config/opcodes.js';
1
+ import { ENGINE_CAPABILITIES } from './capabilities.js';
2
+ import { createGameConfigView } from './gameConfigView.js';
2
3
 
3
4
  // Динамическая загрузка игры по GameManifest мастера (Этап 6.3): клиент
4
5
  // больше не импортирует игру статически (gameRegistry.static.js) — вместо
@@ -28,102 +29,79 @@ export async function fetchGameManifest(url) {
28
29
  return res.json();
29
30
  }
30
31
 
31
- // несовпадение engineApi плагин собран под другую версию контрактов
32
- // движка (§3.7 PLAN.md); загружать его небезопасно
33
- export function assertEngineApiCompatible(manifest) {
34
- if (manifest.engineApi !== ENGINE_API_VERSION) {
35
- throw new Error(
36
- `game "${manifest.id}" requires engine API v${manifest.engineApi}, ` +
37
- `this engine build is v${ENGINE_API_VERSION}`,
38
- );
32
+ // Совместимость плагина с этой сборкой движка (этап 5 плана
33
+ // plugin-forward-compat). Числа больше не сравниваются: `engineApi` заморожен
34
+ // на 4 и остался меткой поколения контракта, а не гейтом. Плагин отвергается,
35
+ // только если просит возможность, которой в этой сборке нет (то есть он
36
+ // НОВЕЕ движка) — движок не может выдать того, чего в нём не существует.
37
+ // Плагин любого возраста принимается: поверхность append-only (И1), и имя,
38
+ // которое он написал, работает вечно.
39
+ //
40
+ // Функция возвращает вердикт, а не бросает: у четырёх входов (каталог
41
+ // мастера, Node-загрузчик, браузерный клиент, standalone SDK) разная
42
+ // правильная реакция — каталог помечает игру недоступной и продолжает
43
+ // раздавать остальные, остальные три бросают.
44
+ export function checkPluginCompatibility(manifest) {
45
+ const wanted = manifest.requires ?? [];
46
+ const missing = wanted.filter(name => !ENGINE_CAPABILITIES.has(name));
47
+
48
+ if (missing.length === 0) {
49
+ return { ok: true };
39
50
  }
40
- }
41
51
 
42
- // поля gameConfig, которые движок читает до какой-либо игровой логики
43
- // (applyRoomOverrides/coreConfig/buildClientConfig) — недостающее валится
44
- // непрозрачной ошибкой глубоко в onInit; проверяем контракт §HostPlugin API
45
- // (docs/en/plugin-api.md) сразу после import, рядом с engineApi-гейтом
46
- const REQUIRED_GAME_CONFIG_PATHS = [
47
- 'roomDefaults.maxPlayers',
48
- 'snapshot',
49
- 'parts.models',
50
- 'parts.weapons',
51
- 'parts.friendlyFire',
52
- 'panel.fields',
53
- 'playerKeys',
54
- // без них HostGame разыменовывает undefined (this._teams[spectatorTeam])
55
- // и игра умирает тремя разными сообщениями вместо одного контрактного
56
- 'teams',
57
- ];
58
-
59
- // spectatorTeam обязателен ровно до тех пор, пока игра не объявила
60
- // noSpectators: там наблюдателей нет как концепции и ключа тоже нет
61
- const SPECTATOR_CONFIG_PATH = 'spectatorTeam';
62
-
63
- function getPath(obj, dottedPath) {
64
- return dottedPath
65
- .split('.')
66
- .reduce((value, key) => value?.[key], obj);
52
+ return {
53
+ ok: false,
54
+ reason: 'engine-too-old',
55
+ missing,
56
+ // текст обязан называть сторону, которую надо обновить: это единственный
57
+ // оставшийся режим отказа, и он должен быть однозначным
58
+ text:
59
+ `game "${manifest.id}" needs engine capabilities this build does ` +
60
+ `not have: ${missing.join(', ')} — update the engine`,
61
+ };
67
62
  }
68
63
 
69
- // бросает при отсутствии обязательных полей HostPlugin.gameConfig
70
- export function assertGameConfigShape(hostPlugin) {
71
- // null проходил бы проверку присутствия, хотя ни одно из этих полей не
72
- // бывает пустым по контракту: движок разыменовывает их сразу, и гейт,
73
- // заведённый ради текста вместо TypeError, сам отвечал бы TypeError
74
- const noSpectators = hostPlugin.gameConfig?.noSpectators === true;
75
- const required = noSpectators
76
- ? REQUIRED_GAME_CONFIG_PATHS
77
- : [...REQUIRED_GAME_CONFIG_PATHS, SPECTATOR_CONFIG_PATH];
78
-
79
- const missing = required.filter(p => {
80
- const value = getPath(hostPlugin.gameConfig, p);
81
-
82
- return value === undefined || value === null;
83
- });
84
-
85
- if (missing.length > 0) {
86
- throw new Error(
87
- `game "${hostPlugin.id}": gameConfig is missing required field(s): ` +
88
- missing.join(', '),
89
- );
90
- }
64
+ // Имя, на которое ссылается существующий код и тесты (И1 действует и на
65
+ // экспорты движка): та же проверка, но бросающая.
66
+ export function assertEngineApiCompatible(manifest) {
67
+ const compat = checkPluginCompatibility(manifest);
91
68
 
92
- // единственная связь между полями, которую стоит проверять здесь:
93
- // spectatorTeam — имя ключа внутри teams, и опечатка даёт spectatorId ===
94
- // undefined, после чего участник заходит в несуществующую команду
95
- // (ParticipantManager.createHuman валится на её счётчике)
96
- const { teams, spectatorTeam } = hostPlugin.gameConfig;
97
-
98
- // noSpectators: связывать нечего — зато команда обязана быть ровно одна.
99
- // Вторая играющая команда без наблюдателей означала бы вход «куда-нибудь»,
100
- // а ParticipantManager выбирает команду входа однозначно
101
- if (noSpectators) {
102
- if (Object.keys(teams).length !== 1) {
103
- throw new Error(
104
- `game "${hostPlugin.id}": noSpectators requires exactly one team, ` +
105
- `got ${Object.keys(teams).length} (${Object.keys(teams).join(', ')})`,
106
- );
107
- }
108
-
109
- return;
69
+ if (!compat.ok) {
70
+ throw new Error(compat.text);
110
71
  }
72
+ }
111
73
 
112
- if (teams[spectatorTeam] === undefined) {
113
- throw new Error(
114
- `game "${hostPlugin.id}": spectatorTeam '${spectatorTeam}' is not a ` +
115
- `key of teams (${Object.keys(teams).join(', ')})`,
116
- );
117
- }
74
+ // Обязательные поля gameConfig и умолчания для всего остального живут в
75
+ // lib/gameConfigView.js (этап 2 плана plugin-forward-compat) — здесь только
76
+ // имена, на которые мог сослаться чужой код, и тонкая обёртка над view.
77
+ export { REQUIRED_GAME_CONFIG_PATHS } from './gameConfigView.js';
78
+
79
+ // spectatorTeam перестал быть обязательным (у него есть умолчание) —
80
+ // константа остаётся именем пути, а не требованием
81
+ export const SPECTATOR_CONFIG_PATH = 'spectatorTeam';
82
+
83
+ /**
84
+ * Проверяет gameConfig плагина и возвращает представление с умолчаниями.
85
+ * Гейт стоит сразу после import — рядом с engineApi-гейтом: недостающее
86
+ * обязательное поле иначе валится непрозрачной ошибкой глубоко в onInit.
87
+ * @param {Object} hostPlugin - Загруженный HostPlugin игры.
88
+ * @returns {Object} Результат createGameConfigView (одна view на прогон).
89
+ */
90
+ export function assertGameConfigShape(hostPlugin) {
91
+ return createGameConfigView(hostPlugin.gameConfig, hostPlugin.id);
118
92
  }
119
93
 
120
- // динамический import ClientPlugin игры (client-entry её сборки). Манифест и
121
- // плагин собираются одной сборкой (build-game-manifest.js читает то же
122
- // entries.client) и их engineApi всегда совпадает проверяем только
123
- // манифест (дешевле: до сетевого import), плагин сверяем после загрузки как
124
- // защиту от рассинхрона сборки, а не как отдельный путь отказа
94
+ // динамический import ClientPlugin игры (client-entry её сборки). Сначала
95
+ // вердикт совместимости по манифесту (дешевле: до сетевого import): игра,
96
+ // требующая возможности, которой в этой сборке нет, не заработает и после
97
+ // загрузки бандла. Сверка engineApi манифеста с плагином ниже про
98
+ // рассинхрон сборки внутри пакета, а не про версию движка
125
99
  export async function loadClientPlugin(manifest) {
126
- assertEngineApiCompatible(manifest);
100
+ const compat = checkPluginCompatibility(manifest);
101
+
102
+ if (!compat.ok) {
103
+ throw new Error(compat.text);
104
+ }
127
105
 
128
106
  const module = await import(/* @vite-ignore */ manifest.entries.client);
129
107
  const plugin = module.default;
@@ -1,7 +1,7 @@
1
1
  import { access, readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
- import { assertEngineApiCompatible } from './gamePlugin.js';
4
+ import { checkPluginCompatibility } from './gamePlugin.js';
5
5
 
6
6
  // Загрузка пакета игры в Node по его собранному dist/ (Этап 4 плана
7
7
  // standalone-sdk). В браузере плагин грузится по URL из GameManifest мастера;
@@ -32,7 +32,14 @@ export async function loadGamePackage(distDir, { core = null } = {}) {
32
32
  const baseDir = path.dirname(manifestPath);
33
33
  const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
34
34
 
35
- assertEngineApiCompatible(manifest);
35
+ // игра одна и подменить её нечем — вердикт несовместимости здесь
36
+ // терминальный (в отличие от каталога мастера, который помечает игру
37
+ // недоступной и продолжает раздавать остальные)
38
+ const compat = checkPluginCompatibility(manifest);
39
+
40
+ if (!compat.ok) {
41
+ throw new Error(`${manifestPath}: ${compat.text}`);
42
+ }
36
43
 
37
44
  const { assetsBase } = manifest;
38
45
  const hostPlugin = await importDefault(
@@ -0,0 +1,94 @@
1
+ // Append-only реестр (И1 плана plugin-forward-compat, этап 3). Движок держит
2
+ // закрытые словари, из которых игра выбирает значения: контролы формы, имена
3
+ // клиентских сервисов. Запись НИКОГДА не удаляется: игра, собранная два года
4
+ // назад, могла её написать, и её dist больше никто не тронет — сокращение
5
+ // словаря отвергает такую игру молча. Вывод из эксплуатации =
6
+ // { alias: 'новое-имя' } + запись в CHANGELOG, но не удаление строки.
7
+ //
8
+ // Именно так сломался v2 → v3: набор `control` урезали до четырёх нативных
9
+ // элементов, и `range`/`number`/`toggle`/`segmented` перестали строиться.
10
+
11
+ /**
12
+ * Создаёт append-only реестр имён плагинной поверхности.
13
+ * @param {string} name - Имя реестра (в тексте ошибок).
14
+ * @param {Array<Object>} entries - Записи `{ value, since, alias?,
15
+ * retiredIn?, note?, ...полезная нагрузка }`. Запись с `alias` — выведенное
16
+ * из эксплуатации имя: оно продолжает работать, разрешаясь в указанное.
17
+ * @returns {Object} Реестр: `has`, `get`, `resolve`, `chain`, `isRetired`,
18
+ * `list`, `values`.
19
+ */
20
+ export function createRegistry(name, entries) {
21
+ const byValue = new Map();
22
+
23
+ for (const entry of entries) {
24
+ if (byValue.has(entry.value)) {
25
+ throw new Error(`registry ${name}: duplicate entry "${entry.value}"`);
26
+ }
27
+
28
+ byValue.set(entry.value, Object.freeze({ ...entry }));
29
+ }
30
+
31
+ // цепочка алиасов от имени к активной записи; пустая, если имени нет
32
+ const chain = value => {
33
+ const trail = [];
34
+ const seen = new Set();
35
+ let current = byValue.get(value);
36
+
37
+ while (current !== undefined) {
38
+ if (seen.has(current.value)) {
39
+ throw new Error(
40
+ `registry ${name}: alias cycle at "${current.value}" — ` +
41
+ 'a retired name must resolve to an active one',
42
+ );
43
+ }
44
+
45
+ seen.add(current.value);
46
+ trail.push(current);
47
+
48
+ if (current.alias === undefined) {
49
+ return trail;
50
+ }
51
+
52
+ const next = byValue.get(current.alias);
53
+
54
+ if (next === undefined) {
55
+ throw new Error(
56
+ `registry ${name}: "${current.value}" is aliased to unknown ` +
57
+ `"${current.alias}"`,
58
+ );
59
+ }
60
+
61
+ current = next;
62
+ }
63
+
64
+ return trail;
65
+ };
66
+
67
+ // битый реестр — дефект движка, а не плагина: пусть падает на загрузке
68
+ // модуля, а не на первой игре, которая напишет выведенное имя
69
+ for (const value of byValue.keys()) {
70
+ chain(value);
71
+ }
72
+
73
+ return Object.freeze({
74
+ name,
75
+ has: value => byValue.has(value),
76
+ get: value => byValue.get(value),
77
+ // разрешает имя в активное (проходит цепочку алиасов). undefined — имя
78
+ // неизвестно; это ВСЕГДА ошибка плагина (он попросил будущее), никогда
79
+ // не ошибка движка за то, что у него список длиннее
80
+ resolve: value => chain(value).at(-1)?.value,
81
+ chain,
82
+ isRetired: value => byValue.get(value)?.alias !== undefined,
83
+ // все записи, включая выведенные: слепок поверхности (этап 1) не считает
84
+ // вывод из эксплуатации удалением
85
+ list: () => [...byValue.values()],
86
+ // только активные имена — то, что вправе написать НОВАЯ игра
87
+ values: () =>
88
+ [...byValue.values()]
89
+ .filter(e => e.alias === undefined)
90
+ .map(e => e.value),
91
+ });
92
+ }
93
+
94
+ export default createRegistry;