vimp-engine 0.23.0 → 0.24.1

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.
@@ -44,7 +44,7 @@ async function main(argv) {
44
44
 
45
45
  try {
46
46
  committed = JSON.parse(await readFile(SURFACE_PATH, 'utf8'));
47
- } catch (err) {
47
+ } catch {
48
48
  process.stderr.write(
49
49
  `surface: no snapshot at ${SURFACE_PATH} — run ` +
50
50
  '`npm run surface:update` to create it\n',
package/core/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "vimp-engine-core"
3
- version = "0.9.0"
3
+ version = "0.9.2"
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.23.0",
3
+ "version": "0.24.1",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,16 +1,23 @@
1
1
  import { anchorPattern } from '../../lib/formPattern.js';
2
2
  import { normalizeOptions } from '../../lib/formOptions.js';
3
3
  import { resolveDescriptor } from '../../lib/formControls.js';
4
+ import { toDisplay, toStored, isNumericField } from '../../lib/formUnit.js';
4
5
 
5
6
  // Общий билдер полей форм (room-форма и auth-форма используют один
6
7
  // контракт дескрипторов — docs/en/plugin-api.md, раздел "Form schema").
7
8
  // control: 'select'|'text'|'checkbox'|'radio'; все контролы — нативные
8
- // элементы формы, без визуальной кастомизации. Имя контрола приезжает от
9
- // игры и разрешается через append-only реестр (lib/formControls.js): имена,
10
- // выведенные из эксплуатации в v3 ('range', 'number', 'toggle',
11
- // 'segmented'), продолжают строиться алиасами своих нативных замен (И1). Валидация — не нативная
12
- // (никаких браузерных попапов): collectFormErrors/renderFormErrors ниже
13
- // собирают и рисуют ошибки в разметку (#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).
14
21
  // Проверяются только поля, у которых есть строка в DOM: ошибка на скрытом
15
22
  // поле игроку не видна и исправить её нечем — она просто запирает форму.
16
23
 
@@ -18,12 +25,6 @@ import { resolveDescriptor } from '../../lib/formControls.js';
18
25
  // resolveForcedValue не расходился с builders при добавлении нового
19
26
  const OPTION_CONTROLS = ['select', 'radio'];
20
27
 
21
- // числовое text-поле: unit задан или numeric:true. Правило одно на билдер и
22
- // на валидацию, поэтому живёт в одном месте
23
- function isNumeric(descriptor) {
24
- return descriptor.numeric === true || descriptor.unit !== undefined;
25
- }
26
-
27
28
  // 'source' — спец-источник вариантов из каталога движка (например карты),
28
29
  // прокидывается вызывающей стороной через ctx.sources
29
30
  function resolveOptions(descriptor, ctx) {
@@ -63,15 +64,6 @@ export function resolveForcedValue(descriptor, ctx = {}) {
63
64
  return forced === undefined ? undefined : String(forced);
64
65
  }
65
66
 
66
- // unit:'s' — значение хранится в мс, показывается/редактируется в секундах
67
- function toDisplay(descriptor, value) {
68
- return descriptor.unit === 's' ? value / 1000 : value;
69
- }
70
-
71
- function toStored(descriptor, value) {
72
- return descriptor.unit === 's' ? value * 1000 : value;
73
- }
74
-
75
67
  function buildSelect(descriptor, ctx) {
76
68
  const el = document.createElement('select');
77
69
  const options = resolveOptions(descriptor, ctx);
@@ -116,7 +108,7 @@ function buildSelect(descriptor, ctx) {
116
108
  // вместо превращения его в 0 на сабмите
117
109
  function buildText(descriptor) {
118
110
  const el = document.createElement('input');
119
- const numeric = isNumeric(descriptor);
111
+ const numeric = isNumericField(descriptor);
120
112
 
121
113
  el.type = 'text';
122
114
  // выпадашка прошлых значений/автозаполнения кроет форму, а подставить в
@@ -386,7 +378,7 @@ function validateField(descriptor, field) {
386
378
  const isEmpty = isText
387
379
  ? raw === ''
388
380
  : value === undefined || value === null || value === '';
389
- const numeric = isText && isNumeric(descriptor);
381
+ const numeric = isText && isNumericField(descriptor);
390
382
 
391
383
  // числовое поле всегда несёт число и «необязательным» быть не может:
392
384
  // пустой ввод getValue() подменяет default'ом (чтобы не уехал нолём), и
@@ -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;
@@ -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';
@@ -37,6 +41,9 @@ import { createContextTracker } from './lib/contextTracker.js';
37
41
  import { createLocalPlayer } from './lib/localPlayer.js';
38
42
  import { createAccolades } from './lib/accolades.js';
39
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';
40
47
  import { createDebugApi, debugLog, DEBUG_PREFIX } from './debug.js';
41
48
  import { buildClientCoreConfig } from '../lib/clientCoreConfig.js';
42
49
  import {
@@ -110,13 +117,6 @@ function bindActiveGame(manifest, plugin) {
110
117
  gameStyleNode.textContent = plugin.styles ?? '';
111
118
  }
112
119
 
113
- // поле `compat` появляется у манифеста каталога мастера, только когда игра
114
- // просит возможность, которой в этой сборке движка нет; манифест без него
115
- // (все опубликованные до этапа 5) доступен по определению
116
- function isGameAvailable(manifest) {
117
- return manifest.compat?.ok !== false;
118
- }
119
-
120
120
  // режим загрузки (Этап 2 плана standalone-sdk): lobby — прод с мастером,
121
121
  // solo — хост в этой же вкладке (standalone SDK), dedicated — прямой WS к
122
122
  // Node-серверу. Ветвлений ровно пять: манифест, сигналинг/лобби, транспорт,
@@ -154,13 +154,8 @@ try {
154
154
  throw gamesManifest;
155
155
  }
156
156
 
157
- // недоступная игра (manifest.compat.ok === false, этап 5 плана
158
- // plugin-forward-compat) не годится в активные: её плагин не загрузится,
159
- // и вкладка встала бы на первой же игре каталога. В список лобби она
160
- // при этом попадает — с причиной
161
- activeGameManifest = boot.gameId
162
- ? gamesManifest.find(manifest => manifest.id === boot.gameId)
163
- : (gamesManifest.find(isGameAvailable) ?? gamesManifest[0]);
157
+ // недоступная игра активной быть не может (lib/pickActiveGame.js)
158
+ activeGameManifest = pickActiveGame(gamesManifest, boot.gameId);
164
159
  }
165
160
 
166
161
  if (!activeGameManifest) {
@@ -294,7 +289,7 @@ let wasm = null;
294
289
  // читаются один раз при создании ядра, а не в момент вызова. Ядро старше
295
290
  // самоописания даёт поколение 0 с пустым списком опкодов: это не ошибка,
296
291
  // а игра, собранная до появления механизма (И2 плана plugin-forward-compat)
297
- let clientCoreAbi = { abi: 0, core: null, ops: [] };
292
+ let clientCoreAbi = ABI_UNKNOWN;
298
293
 
299
294
  // сервис пула зависимостей: «эта сущность моя или чужая?». Ядро читается
300
295
  // геттером — оно создаётся позже пула сервисов (см. lib/localPlayer.js)
@@ -330,10 +325,7 @@ socketMethods[PS_CONFIG_DATA] = async data => {
330
325
 
331
326
  clientCore = core;
332
327
  wasm = { memory };
333
- clientCoreAbi =
334
- typeof core.abi_describe === 'function'
335
- ? JSON.parse(core.abi_describe())
336
- : { abi: 0, core: null, ops: [] };
328
+ clientCoreAbi = readCoreAbi(core, 'client core');
337
329
 
338
330
  // инициализация сущностей игры
339
331
  for (const entity of Object.keys(entitiesOnCanvas)) {
@@ -1152,8 +1144,7 @@ const RESYNC_AFTER_HIDDEN_MS = 3000;
1152
1144
 
1153
1145
  // вкладка могла быть скрыта уже в момент навешивания слушателя — события
1154
1146
  // 'hidden' тогда не будет, а пауза всё равно идёт
1155
- let hiddenAt =
1156
- document.visibilityState === 'hidden' ? performance.now() : null;
1147
+ let hiddenAt = document.visibilityState === 'hidden' ? performance.now() : null;
1157
1148
 
1158
1149
  // обработчик видимости вкладки
1159
1150
  function handleVisibilityChange() {
@@ -1199,18 +1190,22 @@ function handleVisibilityChange() {
1199
1190
 
1200
1191
  // дамп клиентского ядра: сначала опкод dispatch, затем замороженный метод.
1201
1192
  // Метод не удаляется никогда (И1), поэтому запасной путь остаётся навсегда:
1202
- // ядро, собранное до появления dispatch, отдаёт дамп по-старому
1193
+ // ядро, собранное до появления dispatch, отдаёт дамп по-старому.
1194
+ // dispatchCoreOp — та же точка вызова, что у хостового GameCoreAdapter._op:
1195
+ // имя опкода читается из реестра, три исхода ответа различимы
1203
1196
  function clientCoreDebug() {
1204
1197
  if (!clientCore) {
1205
1198
  return undefined;
1206
1199
  }
1207
1200
 
1208
- if (clientCoreAbi.ops.includes('debug.json')) {
1209
- const out = clientCore.dispatch('debug.json', new Uint8Array(0));
1201
+ const { handled, bytes } = dispatchCoreOp(
1202
+ clientCore,
1203
+ clientCoreAbi,
1204
+ ABI_OP_DEBUG_JSON,
1205
+ );
1210
1206
 
1211
- if (out.length > 0) {
1212
- return new TextDecoder().decode(out);
1213
- }
1207
+ if (handled && bytes !== null) {
1208
+ return new TextDecoder().decode(bytes);
1214
1209
  }
1215
1210
 
1216
1211
  return clientCore.debug_json?.();
@@ -1413,7 +1408,9 @@ if (isDevBuild) {
1413
1408
  reportUrl: lobbyConfig.debugReportUrl,
1414
1409
  });
1415
1410
 
1416
- debugLog('window.__vimpDebug is available: dump, startRecording, stopRecording, divergence');
1411
+ debugLog(
1412
+ 'window.__vimpDebug is available: dump, startRecording, stopRecording, divergence',
1413
+ );
1417
1414
  }
1418
1415
 
1419
1416
  // WebRTC обязателен для P2P-игры. В Firefox RTCPeerConnection может
@@ -1944,7 +1941,9 @@ function populateRoomForm(manifest) {
1944
1941
 
1945
1942
  // каталог манифестов по id: форма и leaderboard селектора игр, а также
1946
1943
  // активация игры перед созданием комнаты и входом в чужую
1947
- const gamesById = new Map(gamesManifest.map(manifest => [manifest.id, manifest]));
1944
+ const gamesById = new Map(
1945
+ gamesManifest.map(manifest => [manifest.id, manifest]),
1946
+ );
1948
1947
 
1949
1948
  // ClientPlugin выбранной игры грузится в момент клика (создание комнаты /
1950
1949
  // вход в комнату), а не при смене селектора: просмотр каталога не должен
@@ -28,4 +28,10 @@ export const abiOps = createRegistry('abiOps', [
28
28
  // плоский список активных опкодов — то, что вправе позвать движок
29
29
  export const ABI_OPS = abiOps.values();
30
30
 
31
+ // Именованные константы вместо литералов в местах вызова: строка 'debug.json',
32
+ // написанная руками у хоста и у клиента, обходила бы реестр молча — новый
33
+ // опкод уехал бы в прод, не попав ни в слепок поверхности, ни в
34
+ // CHANGELOG. Имя опкода читается ТОЛЬКО отсюда.
35
+ export const ABI_OP_DEBUG_JSON = 'debug.json';
36
+
31
37
  export default abiOps;
@@ -1,5 +1,8 @@
1
- import { ERROR, skip, verdict } from '../result.js';
2
- import { CAPABILITIES } from '../../../lib/capabilities.js';
1
+ import { ERROR, WARN, skip, verdict } from '../result.js';
2
+ import {
3
+ ENGINE_CAPABILITIES,
4
+ CAPABILITIES,
5
+ } from '../../../lib/capabilities.js';
3
6
 
4
7
  // `engineApi` живёт в трёх местах (манифест, HostPlugin, ClientPlugin).
5
8
  // Расхождение между ними — рассинхрон сборки внутри пакета, и это ошибка.
@@ -14,11 +17,16 @@ import { CAPABILITIES } from '../../../lib/capabilities.js';
14
17
  //
15
18
  // Возможности из `manifest.requires` проверяются по реестру установленного
16
19
  // движка: имени, которого нет, движок дать не может — игра просит будущее.
20
+ // `requires` при этом живёт в трёх местах пакета (манифест + обе половины
21
+ // плагина), и их расхождение — такой же рассинхрон сборки, как расхождение
22
+ // `engineApi`.
17
23
  export default {
18
24
  id: 'B2',
19
25
  name: 'engineApiVersion',
20
26
  level: ERROR,
21
- title: 'engineApi is consistent and requires name existing capabilities',
27
+ title:
28
+ 'engineApi is consistent, requires names existing capabilities and ' +
29
+ 'agrees between the manifest and both plugin halves',
22
30
 
23
31
  check(ctx) {
24
32
  // манифест первый: он — то, что о поколении пакета читает движок,
@@ -34,6 +42,7 @@ export default {
34
42
  }
35
43
 
36
44
  const violations = [];
45
+ const retired = [];
37
46
  const [source, reference] = declared[0];
38
47
 
39
48
  for (const [where, value] of declared) {
@@ -56,15 +65,123 @@ export default {
56
65
  }
57
66
  }
58
67
 
59
- for (const name of ctx.manifest?.requires ?? []) {
60
- if (!CAPABILITIES.includes(name)) {
68
+ // форма `requires` то же недоверие, что в checkPluginCompatibility:
69
+ // строка проитерировалась бы здесь посимвольно, объект уронил бы чекер
70
+ // «not iterable»
71
+ const requires = ctx.manifest?.requires;
72
+
73
+ if (requires === undefined || requires === null) {
74
+ violations.push(...halfViolations(ctx, []));
75
+
76
+ return finish(violations, retired);
77
+ }
78
+
79
+ if (
80
+ !Array.isArray(requires) ||
81
+ requires.some(name => typeof name !== 'string')
82
+ ) {
83
+ violations.push(
84
+ 'manifest.requires must be an array of capability names, got ' +
85
+ `${JSON.stringify(requires)} — the engine reads it before it ` +
86
+ 'loads the plugin',
87
+ );
88
+
89
+ return finish(violations, retired);
90
+ }
91
+
92
+ for (const name of requires) {
93
+ // has, а не CAPABILITIES.includes: реестр возможностей append-only,
94
+ // и выведенное алиасом имя движок принимает вечно (ENGINE_CAPABILITIES.
95
+ // has в checkPluginCompatibility). Сверка с одними активными именами
96
+ // отвергала бы игру за то, что движок переименовал возможность — тот
97
+ // самый отказ по возрасту, который план и снимал
98
+ if (!ENGINE_CAPABILITIES.has(name)) {
61
99
  violations.push(
62
100
  `manifest.requires names '${name}', which this engine does not ` +
63
101
  `provide — known capabilities: ${CAPABILITIES.join(', ')}`,
64
102
  );
103
+ } else if (ENGINE_CAPABILITIES.isRetired(name)) {
104
+ retired.push(
105
+ `manifest.requires names '${name}', which was retired — it still ` +
106
+ `works (the engine resolves it to ` +
107
+ `'${ENGINE_CAPABILITIES.resolve(name)}' forever), but a new game ` +
108
+ `should declare '${ENGINE_CAPABILITIES.resolve(name)}' itself`,
109
+ );
65
110
  }
66
111
  }
67
112
 
68
- return verdict(violations);
113
+ violations.push(...halfViolations(ctx, requires));
114
+
115
+ return finish(violations, retired);
69
116
  },
70
117
  };
118
+
119
+ // `requires` пишут три места одного пакета: скрипт сборки манифеста и обе
120
+ // половины плагина (последние — ради standalone SDK, у которого манифеста
121
+ // нет). Разъехавшись, они дают игру, которую лобби честно отвергает, а
122
+ // solo-режим принимает и тихо недоигрывает. Половина без поля из сверки
123
+ // исключена: пакет, собранный до появления поля, обязан оставаться
124
+ // валидным (И1/И2)
125
+ function halfViolations(ctx, manifestRequires) {
126
+ const wanted = new Set(manifestRequires);
127
+ const violations = [];
128
+ const halves = [
129
+ ['HostPlugin', ctx.hostPlugin?.requires],
130
+ ['ClientPlugin', ctx.clientPlugin?.requires],
131
+ ].filter(([, value]) => value !== undefined && value !== null);
132
+
133
+ for (const [where, value] of halves) {
134
+ if (!Array.isArray(value) || value.some(name => typeof name !== 'string')) {
135
+ violations.push(
136
+ `${where}.requires must be an array of capability names, got ` +
137
+ `${JSON.stringify(value)} — the standalone SDK reads it`,
138
+ );
139
+ continue;
140
+ }
141
+
142
+ const extra = value.filter(name => !wanted.has(name));
143
+
144
+ if (extra.length) {
145
+ violations.push(
146
+ `${where}.requires names ${extra.join(', ')}, which ` +
147
+ 'manifest.requires does not list — the lobby master reads the ' +
148
+ 'manifest, so the two must agree',
149
+ );
150
+ }
151
+ }
152
+
153
+ // «поле объявлено» — это наличие ключа, а не непустой список: половина с
154
+ // `requires: []` утверждает, что игре ничего не нужно, и расходится с
155
+ // манифестом, который просит возможность. Половина БЕЗ поля не утверждает
156
+ // ничего — пакет, собранный до его появления
157
+ if (halves.length === 0) {
158
+ return violations;
159
+ }
160
+
161
+ const declared = halves
162
+ .map(([, value]) => value)
163
+ .filter(Array.isArray)
164
+ .flat();
165
+
166
+ const missing = [...wanted].filter(name => !declared.includes(name));
167
+
168
+ if (missing.length) {
169
+ violations.push(
170
+ `manifest.requires names ${missing.join(', ')}, which neither plugin ` +
171
+ 'half declares — the standalone SDK has no manifest and would run ' +
172
+ 'the game on an engine that cannot support it',
173
+ );
174
+ }
175
+
176
+ return violations;
177
+ }
178
+
179
+ // то же, что делают B5 и C10: выведенное имя — не отказ, а предупреждение.
180
+ // Отвергать за него значило бы отвергать игру за возраст (И1)
181
+ function finish(violations, retired) {
182
+ if (violations.length === 0 && retired.length > 0) {
183
+ return verdict(retired, 'retired capability names still resolve', WARN);
184
+ }
185
+
186
+ return verdict(violations);
187
+ }
@@ -1,5 +1,9 @@
1
- import { ERROR, skip, verdict } from '../result.js';
1
+ import { ERROR, WARN, skip, verdict } from '../result.js';
2
2
  import { resolveValidator } from '../../../lib/validators.js';
3
+ import {
4
+ formControls,
5
+ ACTIVE_FORM_CONTROLS,
6
+ } from '../../../lib/formControls.js';
3
7
 
4
8
  // authSchema. Четыре ошибки, каждая из которых уже случалась:
5
9
  // formId вместо fieldsId (контейнер резолвится в null и экран авторизации
@@ -14,8 +18,8 @@ export default {
14
18
  name: 'authSchema',
15
19
  level: ERROR,
16
20
  title:
17
- 'authSchema: fieldsId, no nickname field, the model field, resolvable ' +
18
- 'validators',
21
+ 'authSchema: fieldsId, no nickname field, the model field, inline ' +
22
+ 'options, resolvable validators',
19
23
 
20
24
  check(ctx) {
21
25
  if (!ctx.authSchema) {
@@ -24,6 +28,7 @@ export default {
24
28
 
25
29
  const { elems = {}, params = [] } = ctx.authSchema;
26
30
  const violations = [];
31
+ const retired = [];
27
32
 
28
33
  if (elems.formId !== undefined) {
29
34
  violations.push(
@@ -48,6 +53,39 @@ export default {
48
53
  );
49
54
  }
50
55
 
56
+ const control = field.options?.control;
57
+
58
+ if (control !== undefined && !formControls.has(control)) {
59
+ violations.push(
60
+ `authSchema param "${field.name}": control "${control}" does not ` +
61
+ `exist (${ACTIVE_FORM_CONTROLS.join(', ')}) — the form throws ` +
62
+ "'unknown control' and the auth screen never renders",
63
+ );
64
+ } else if (control !== undefined && formControls.isRetired(control)) {
65
+ retired.push(
66
+ `authSchema param "${field.name}": control "${control}" was ` +
67
+ `retired in plugin API v${formControls.get(control).retiredIn} — ` +
68
+ `it still works (the engine builds and validates it as ` +
69
+ `"${formControls.resolve(control)}" forever), but a new game ` +
70
+ `should declare "${formControls.resolve(control)}" itself`,
71
+ );
72
+ }
73
+
74
+ // `source` резолвится вызывающей стороной через ctx.sources, а
75
+ // auth-форма строится с ПУСТЫМ ctx (client/components/view/Auth.js) —
76
+ // значит список вариантов у такого поля пуст всегда: игрок видит
77
+ // 'no options available' и войти не может, а хост отвергает любое
78
+ // значение как не-вариант. Это только для authSchema; в roomForm
79
+ // (правило B5) source штатный — там движок отдаёт каталог карт
80
+ if (field.options?.source !== undefined) {
81
+ violations.push(
82
+ `authSchema param "${field.name}" declares options.source ` +
83
+ `"${field.options.source}" — the auth form is built without ` +
84
+ 'sources, so the field resolves to an empty list and nobody can ' +
85
+ 'log in; inline the options',
86
+ );
87
+ }
88
+
51
89
  const validatorName = field.options?.validator;
52
90
 
53
91
  // опечатка в имени (как и не-функция под верным именем) = поле не
@@ -73,6 +111,16 @@ export default {
73
111
  );
74
112
  }
75
113
 
114
+ // то же, что делает B5 для roomForm: выведенный контрол — не отказ, а
115
+ // предупреждение, иначе правило отвергало бы игру за возраст (И1)
116
+ if (violations.length === 0 && retired.length > 0) {
117
+ return verdict(
118
+ retired,
119
+ 'retired controls still build and validate, via aliases',
120
+ WARN,
121
+ );
122
+ }
123
+
76
124
  return verdict(violations);
77
125
  },
78
126
  };
@@ -10,8 +10,8 @@
10
10
  // (spawn_scripted_actor/remove_scripted_actor), человек — только танк
11
11
  // (spawn_actor/remove_actor).
12
12
 
13
- // пустая нагрузка опкода: ядро ждёт байты всегда, даже когда их нет
14
- const EMPTY_PAYLOAD = new Uint8Array(0);
13
+ import { ABI_OP_DEBUG_JSON } from '../config/abiOps.js';
14
+ import { readCoreAbi, dispatchCoreOp } from '../lib/coreAbi.js';
15
15
 
16
16
  export default class GameCoreAdapter {
17
17
  /**
@@ -28,14 +28,11 @@ export default class GameCoreAdapter {
28
28
  this._onCoreEvent = onCoreEvent;
29
29
  this._services = {}; // { vimp, panel } — инъекция как у Game.js
30
30
 
31
- // Метода нет — ядро собрано до появления самоописания. Это не ошибка:
32
- // поколение 0, ни одного опционального опкода (И2). Читается один раз
33
- // здесь, а не при вызове: ветку упаковки, поле формы или ответ лобби
34
- // движок выбирает заранее, а не посреди матча.
35
- this._abi =
36
- typeof core.abi_describe === 'function'
37
- ? JSON.parse(core.abi_describe())
38
- : { abi: 0, core: null, ops: [] };
31
+ // Метода нет (или самоописание нечитаемо) — ядро собрано до появления
32
+ // механизма. Это не ошибка: поколение 0, ни одного опционального опкода
33
+ // (И2). Читается один раз здесь, а не при вызове: ветку упаковки, поле
34
+ // формы или ответ лобби движок выбирает заранее, а не посреди матча.
35
+ this._abi = readCoreAbi(core, 'game core');
39
36
  }
40
37
 
41
38
  /**
@@ -237,10 +234,10 @@ export default class GameCoreAdapter {
237
234
  * @returns {Object|null}
238
235
  */
239
236
  debugJson() {
240
- const out = this._op('debug.json');
237
+ const { handled, bytes } = this._op(ABI_OP_DEBUG_JSON);
241
238
 
242
- if (out !== null) {
243
- return JSON.parse(new TextDecoder().decode(out));
239
+ if (handled && bytes !== null) {
240
+ return JSON.parse(new TextDecoder().decode(bytes));
244
241
  }
245
242
 
246
243
  // ядро старше опкода, но с замороженным методом — метод не удаляется
@@ -257,18 +254,22 @@ export default class GameCoreAdapter {
257
254
  * ядра: прямой вызов нового метода на `this._core` запрещён (И2) — у
258
255
  * ядра, собранного год назад, его нет и не будет. ESLint стережёт это
259
256
  * правилом no-restricted-syntax.
257
+ *
258
+ * Исходов ровно три, и они РАЗЛИЧИМЫ (соглашение `abi::dispatch_result`,
259
+ * core/src/abi.rs): пустой ответ — «опкод не понят», маркер `[0x00]` —
260
+ * «понят, ответа нет», иначе — полезные байты. Схлопывать первые два в
261
+ * один `null` нельзя: опкод-команда (ради них механизм и делался) отдавала
262
+ * бы вызывающему сырой `Uint8Array [0]`, который поехал бы в TextDecoder
263
+ * и JSON.parse.
260
264
  * @param {string} op - Опкод из config/abiOps.js.
261
265
  * @param {Uint8Array} [payload] - Полезная нагрузка опкода.
262
- * @returns {Uint8Array|null} null ядро опкод не умеет либо не обработало.
266
+ * @returns {{handled: boolean, bytes: Uint8Array|null}} `handled: false`
267
+ * ядро опкода не знает (вызывающий идёт по запасному пути);
268
+ * `handled: true, bytes: null` — обработано без ответа.
269
+ * @see lib/coreAbi.js — то же для клиентского ядра
263
270
  */
264
- _op(op, payload = EMPTY_PAYLOAD) {
265
- if (!this._abi.ops.includes(op)) {
266
- return null;
267
- }
268
-
269
- const out = this._core.dispatch(op, payload);
270
-
271
- return out.length === 0 ? null : out;
271
+ _op(op, payload) {
272
+ return dispatchCoreOp(this._core, this._abi, op, payload);
272
273
  }
273
274
 
274
275
  // бот ли участник (спавн/удаление в ядре различаются)
@@ -8,7 +8,9 @@ import { createGameConfigView } from './gameConfigView.js';
8
8
  * @param {Object} [room] - Переопределения комнаты.
9
9
  * @param {Object} plugin - HostPlugin игры.
10
10
  * @param {Object} [view] - Готовая gameConfig-view (createHostRuntime строит
11
- * её один раз на прогон); без неё собирается своя.
11
+ * её один раз на прогон). Путь по умолчанию — ТЕСТОВЫЙ: он строит вторую
12
+ * view, и deriveSpectatorTeam предупреждает в консоль дважды за прогон.
13
+ * Прод-вызов всегда передаёт готовую.
12
14
  * @returns {Object} Конфиг матча: движковые дефолты + игровая половина.
13
15
  */
14
16
  export function applyRoomOverrides(
@@ -0,0 +1,149 @@
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
+ /**
67
+ * Зовёт необязательную возможность ядра опкодом. Единственное место, где
68
+ * движок вызывает `dispatch` — и игровой половины, и клиентской: таблица
69
+ * экспортов wasm заморожена (И1/И3), поэтому новая возможность приезжает
70
+ * строкой, а не новым символом.
71
+ *
72
+ * Исходов ровно три, и они РАЗЛИЧИМЫ (соглашение `abi::dispatch_result`,
73
+ * core/src/abi.rs): пустой ответ — «опкод не понят», маркер `[0x00]` —
74
+ * «понят, ответа нет», иначе — полезные байты. Схлопывать первые два в один
75
+ * `null` нельзя: опкод-команда (ради них механизм и делался) отдавала бы
76
+ * вызывающему сырой `Uint8Array [0]`, который поехал бы в TextDecoder и
77
+ * JSON.parse.
78
+ * @param {Object} core - Экземпляр wasm-ядра.
79
+ * @param {Object} abi - Результат readCoreAbi для этого ядра.
80
+ * @param {string} op - Опкод из config/abiOps.js.
81
+ * @param {Uint8Array} [payload] - Полезная нагрузка опкода.
82
+ * @returns {{handled: boolean, bytes: Uint8Array|null}} `handled: false` —
83
+ * ядро опкода не знает (вызывающий идёт по запасному пути);
84
+ * `handled: true, bytes: null` — обработано без ответа.
85
+ */
86
+ export function dispatchCoreOp(core, abi, op, payload = EMPTY_PAYLOAD) {
87
+ // опкод вне реестра — дефект ДВИЖКА, а не старого ядра: имя, которого нет
88
+ // в config/abiOps.js, не попадает ни в слепок поверхности, ни в CHANGELOG.
89
+ // Падаем сразу, а не на игре, которая его однажды поймёт
90
+ if (!abiOps.has(op)) {
91
+ throw new Error(
92
+ `dispatchCoreOp: unknown opcode "${op}" — declare it in ` +
93
+ 'config/abiOps.js (append-only, И1)',
94
+ );
95
+ }
96
+
97
+ // Направление резолва здесь ОБРАТНОЕ остальным реестрам, и это не описка.
98
+ // Имя контрола или сервиса пишет игра, а движок разрешает его вперёд, в
99
+ // активное. Имя опкода пишет ДВИЖОК, а понимать его должно уже
100
+ // опубликованное ядро, которое старше движка: разрешив 'debug.json' в
101
+ // будущее 'debug.dump', движок спросил бы у старого ядра имя, которого в
102
+ // нём нет, и молча потерял бы возможность на ровно тех ядрах, ради
103
+ // которых весь план и писался. Поэтому пробуется вся цепочка: активное
104
+ // имя первым, выведенные — как запасной путь для старого ядра
105
+ const known = abiOps
106
+ .chain(op)
107
+ .map(entry => entry.value)
108
+ .reverse()
109
+ .find(name => abi.ops.includes(name));
110
+
111
+ if (known === undefined) {
112
+ return NOT_HANDLED;
113
+ }
114
+
115
+ // ядро с самоописанием, но без самого dispatch (ручное раскрытие макроса,
116
+ // обрезанная сборка, мок в тестах игры) — не краш движка, а ядро без
117
+ // опциональных возможностей: тот же исход, что «опкод не понят»
118
+ if (typeof core.dispatch !== 'function') {
119
+ return NOT_HANDLED;
120
+ }
121
+
122
+ const out = core.dispatch(known, payload);
123
+
124
+ // ответ ядра нормализуется так же, как самоописание: не-Uint8Array
125
+ // (undefined, строка, null) — это чужая форма, а не полезные байты, и
126
+ // TypeError на .length ушёл бы в конструктор адаптера или в async-обработчик
127
+ // PS_CONFIG_DATA невыловленным промисом
128
+ if (!(out instanceof Uint8Array)) {
129
+ if (out !== undefined && out !== null) {
130
+ console.warn(`dispatchCoreOp: "${known}" answered with a non-Uint8Array`);
131
+ }
132
+
133
+ return NOT_HANDLED;
134
+ }
135
+
136
+ if (out.length === 0) {
137
+ return NOT_HANDLED;
138
+ }
139
+
140
+ // [0x00] — «обработан, ответа нет»: полезных байтов у него нет
141
+ return {
142
+ handled: true,
143
+ bytes: out.length === 1 && out[0] === 0x00 ? null : out,
144
+ };
145
+ }
146
+
147
+ // дефолтный экспорт — чтение самоописания: с него начинается работа с
148
+ // любым ядром, dispatchCoreOp зовётся уже с его результатом
149
+ export default readCoreAbi;
@@ -0,0 +1,25 @@
1
+ // Единица измерения числового поля формы: `unit: 's'` значит, что игра
2
+ // хранит значение в миллисекундах, а игрок видит и вводит секунды.
3
+ //
4
+ // Определение общее на движок (как anchorPattern и normalizeOptions рядом):
5
+ // конвертацию делают и билдер формы (client/lib/formBuilder.js), и
6
+ // авторитетная валидация хоста (lib/validators.js) — min/max дескриптора
7
+ // объявлены в единице ОТОБРАЖЕНИЯ, а по сети едет единица хранения. Две
8
+ // копии этого правила разъехались бы молча: форма пропускала бы то, что
9
+ // отбивает хост, или наоборот.
10
+
11
+ /** Значение хранения → значение, в котором объявлены min/max и default. */
12
+ export function toDisplay(descriptor, value) {
13
+ return descriptor.unit === 's' ? value / 1000 : value;
14
+ }
15
+
16
+ /** Обратное преобразование: то, что вводит игрок → то, что едет на хост. */
17
+ export function toStored(descriptor, value) {
18
+ return descriptor.unit === 's' ? value * 1000 : value;
19
+ }
20
+
21
+ // числовое text-поле: unit задан или numeric:true. Правило одно на билдер и
22
+ // на валидацию — иначе поле строится числовым, а проверяется как текст
23
+ export function isNumericField(descriptor) {
24
+ return descriptor?.numeric === true || descriptor?.unit !== undefined;
25
+ }
@@ -126,7 +126,10 @@ function setPath(target, dottedPath, value) {
126
126
  * Строит представление gameConfig с умолчаниями и проверяет обязательное.
127
127
  * @param {Object} gameConfig - HostPlugin.gameConfig игры как есть.
128
128
  * @param {string} [gameId] - id плагина; попадает в текст ошибок.
129
- * @returns {Object} Замороженный конфиг: поля игры плюс умолчания движка.
129
+ * @returns {Object} Конфиг: поля игры плюс умолчания движка. Заморожен
130
+ * ПОВЕРХНОСТНО — вложенные ветки (parts, roomDefaults) правятся; глубокая
131
+ * заморозка стоила бы обхода всего конфига игры на каждом старте матча, а
132
+ * единственный потребитель (applyRoomOverrides) и так делает structuredClone.
130
133
  * @throws {Error} Если нет поля из REQUIRED_GAME_CONFIG_PATHS или конфиг
131
134
  * внутренне противоречив (spectatorTeam вне teams, noSpectators при двух
132
135
  * командах).
@@ -41,9 +41,52 @@ export async function fetchGameManifest(url) {
41
41
  // мастера, Node-загрузчик, браузерный клиент, standalone SDK) разная
42
42
  // правильная реакция — каталог помечает игру недоступной и продолжает
43
43
  // раздавать остальные, остальные три бросают.
44
+
45
+ /** Объявлено ли `requires` вообще (undefined/null = «ничего сверх базового»). */
46
+ const isDeclared = value => value !== undefined && value !== null;
47
+
48
+ /**
49
+ * Склеивает несколько объявлений `requires` в одно. Спредить их напрямую
50
+ * нельзя: строка разошлась бы посимвольно ('accolades' → 'a','c','c'...) и
51
+ * проехала бы проверку формы ниже, потому что каждый символ — валидная
52
+ * строка. Недоверенное значение возвращается КАК ЕСТЬ, чтобы о его форме
53
+ * говорила одна точка — checkPluginCompatibility, — а не место склейки.
54
+ * @param {...*} sources - Значения `requires` (половины плагина, опция SDK).
55
+ * @returns {Array<string>|*} Объединение объявленных списков либо первое
56
+ * значение неверной формы.
57
+ */
58
+ export function mergeRequires(...sources) {
59
+ const declared = sources.filter(isDeclared);
60
+ const malformed = declared.find(source => !Array.isArray(source));
61
+
62
+ return malformed ?? [...new Set(declared.flat())];
63
+ }
64
+
44
65
  export function checkPluginCompatibility(manifest) {
45
- const wanted = manifest.requires ?? [];
46
- const missing = wanted.filter(name => !ENGINE_CAPABILITIES.has(name));
66
+ const wanted = manifest.requires;
67
+
68
+ // `requires` пишет ЧУЖОЙ репозиторий игры, и его форме нельзя доверять:
69
+ // строка или объект вместо массива давали здесь TypeError, который уходил
70
+ // из конструктора GameCatalog и не давал стартовать мастеру целиком —
71
+ // одна битая игра уносила весь каталог. Битый манифест обязан вести себя
72
+ // как несовместимый (вердикт), а не как краш движка
73
+ if (wanted !== undefined && wanted !== null) {
74
+ if (
75
+ !Array.isArray(wanted) ||
76
+ wanted.some(name => typeof name !== 'string')
77
+ ) {
78
+ return {
79
+ ok: false,
80
+ reason: 'bad-manifest',
81
+ missing: [],
82
+ text:
83
+ `game "${manifest.id}": manifest.requires must be an array of ` +
84
+ 'capability names — rebuild the game package',
85
+ };
86
+ }
87
+ }
88
+
89
+ const missing = (wanted ?? []).filter(name => !ENGINE_CAPABILITIES.has(name));
47
90
 
48
91
  if (missing.length === 0) {
49
92
  return { ok: true };
@@ -106,6 +106,74 @@ function assertPluginMatchesManifest(manifest, plugins) {
106
106
  );
107
107
  }
108
108
  }
109
+
110
+ assertRequiresMatchManifest(manifest, plugins);
111
+ }
112
+
113
+ // `requires` пишут ТРИ независимых места одного пакета игры: скрипт сборки
114
+ // манифеста и обе половины плагина (последние — ради standalone SDK, у
115
+ // которого манифеста нет вовсе). Разъехавшись, они дают игру, которая в
116
+ // лобби честно отвергается, а в solo-режиме тихо недоигрывает — то есть
117
+ // именно тот молчаливый режим, ради отказа от которого возможности и
118
+ // заводились. Проверка — того же класса, что сверка engineApi выше: это
119
+ // рассинхрон сборки ВНУТРИ пакета, а не отказ по возрасту (И4).
120
+ //
121
+ // Половина, вовсе НЕ объявившая поле, из сверки исключена: старый пакет,
122
+ // собранный до его появления, обязан грузиться (И1/И2). Объявленное пустым
123
+ // (`requires: []`) — уже утверждение «игре ничего не нужно», и оно
124
+ // расходится с манифестом наравне с неполным списком.
125
+ function assertRequiresMatchManifest(manifest, plugins) {
126
+ const wanted = new Set(
127
+ Array.isArray(manifest.requires) ? manifest.requires : [],
128
+ );
129
+
130
+ for (const [half, plugin] of Object.entries(plugins)) {
131
+ if (plugin.requires === undefined || plugin.requires === null) {
132
+ continue;
133
+ }
134
+
135
+ if (!Array.isArray(plugin.requires)) {
136
+ throw new Error(
137
+ `game "${manifest.id}": ${half} plugin requires must be an array ` +
138
+ 'of capability names',
139
+ );
140
+ }
141
+
142
+ const extra = plugin.requires.filter(name => !wanted.has(name));
143
+
144
+ if (extra.length) {
145
+ throw new Error(
146
+ `game "${manifest.id}": ${half} plugin requires ` +
147
+ `${extra.join(', ')}, which manifest.requires does not list — ` +
148
+ 'stale dist/ (the manifest is what the lobby master reads)',
149
+ );
150
+ }
151
+ }
152
+
153
+ // обратная сторона: манифест просит возможность, о которой половины не
154
+ // знают. Половина, вовсе не объявившая поле, из сверки исключена — иначе
155
+ // правка ломала бы каждый уже опубликованный пакет. Объявленное пустым
156
+ // (`requires: []`) — это утверждение «ничего не нужно», и оно расходится
157
+ // с манифестом наравне с неполным списком
158
+ const halves = Object.values(plugins).filter(
159
+ plugin => plugin.requires !== undefined && plugin.requires !== null,
160
+ );
161
+
162
+ if (halves.length === 0) {
163
+ return;
164
+ }
165
+
166
+ const declared = halves.flatMap(plugin => plugin.requires);
167
+
168
+ const missing = [...wanted].filter(name => !declared.includes(name));
169
+
170
+ if (missing.length) {
171
+ throw new Error(
172
+ `game "${manifest.id}": manifest.requires names ${missing.join(', ')}, ` +
173
+ 'which neither plugin half declares — stale dist/ (the standalone ' +
174
+ 'SDK reads the halves, there is no manifest in solo mode)',
175
+ );
176
+ }
109
177
  }
110
178
 
111
179
  function importDefault(baseDir, entry, assetsBase) {
@@ -1,5 +1,7 @@
1
1
  import { anchorPattern } from './formPattern.js';
2
2
  import { normalizeOptions } from './formOptions.js';
3
+ import { formControls, resolveDescriptor } from './formControls.js';
4
+ import { toDisplay, isNumericField } from './formUnit.js';
3
5
 
4
6
  const NAME_REGEXP = new RegExp('^[a-zA-Z]([\\w\\s#]{0,13})[\\w]{1}$');
5
7
 
@@ -30,12 +32,49 @@ const OPTION_CONTROLS = ['select', 'radio'];
30
32
  // умолчанию — text (тот же дефолт, что у билдера формы)
31
33
  const isTextControl = control => control === 'text' || control === undefined;
32
34
 
33
- // source-варианты хост не резолвит (их и форма в auth не резолвит: она
34
- // строится с пустым ctx) сверяем только объявленный inline-список.
35
- // String(): и <option>.value, и <input type=radio>.value — DOM-свойства,
36
- // они всегда строки, так что options: [1, 2] форма отдаёт как '1'/'2'.
37
- // Списка нет вовсе (или он не массив — дефект схемы) — валидного значения у
38
- // поля не существует, и форма это говорит прямо ('no options available'):
35
+ // числовое поле (control 'text' + numeric/unit в том числе разрешённые из
36
+ // выведенных 'number' и 'range'). Форма отдаёт его ЧИСЛОМ в единице
37
+ // хранения (formBuilder.buildText: getValue → toStored(Number(...))), а не
38
+ // строкой, поэтому оно идёт отдельной веткой до проверки типа
39
+ const isNumericControl = options =>
40
+ isTextControl(options?.control) && isNumericField(options);
41
+
42
+ // min/max дескриптора объявлены в единице ОТОБРАЖЕНИЯ, а по сети едет
43
+ // единица хранения — сравниваем ровно так же, как validateField на клиенте
44
+ const numericError = (options, value) => {
45
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
46
+ return 'must be a number';
47
+ }
48
+
49
+ const shown = toDisplay(options, value);
50
+
51
+ if (options.min !== undefined && shown < options.min) {
52
+ return `must be >= ${options.min}`;
53
+ }
54
+
55
+ if (options.max !== undefined && shown > options.max) {
56
+ return `must be <= ${options.max}`;
57
+ }
58
+
59
+ // regExp — не дубль диапазона: сгенерированный сборкой паттерн (вроде
60
+ // "^([1-8])$") ловит то, что диапазон пропускает — дробное и ведущий
61
+ // ноль. Клиент проверяет числовое поле и тем, и другим (formBuilder.
62
+ // validateField), и сверяет паттерн с СЫРОЙ строкой поля, то есть со
63
+ // значением в единице отображения — здесь то же самое
64
+ if (options.regExp && !matchesPattern(options.regExp, String(shown))) {
65
+ return 'invalid format';
66
+ }
67
+
68
+ return null;
69
+ };
70
+
71
+ // Сверяется только объявленный inline-список. String(): и <option>.value, и
72
+ // <input type=radio>.value — DOM-свойства, они всегда строки, так что
73
+ // options: [1, 2] форма отдаёт как '1'/'2'.
74
+ //
75
+ // Списка нет вовсе — ни массива, ни `source` (последний в auth-схеме не
76
+ // резолвится: форма строится с пустым ctx). Валидного значения у такого поля
77
+ // не существует, и форма это говорит прямо ('no options available'):
39
78
  // пропустить здесь что угодно значило бы дать обошедшему форму клиенту
40
79
  // больше прав, чем игроку, который войти не может вовсе
41
80
  const isDeclaredOption = (list, value) =>
@@ -76,6 +115,26 @@ export const resolveValidator = (name, validators = {}) => {
76
115
  return typeof fn === 'function' ? fn : undefined;
77
116
  };
78
117
 
118
+ // игровой валидатор поля (authSchema.validators). Нерезолвнутое имя
119
+ // (опечатка) и не-функция ведут себя одинаково — поле проходит. Для клиента
120
+ // это норма (игровые валидаторы к нему не едут, авторитет проверки на
121
+ // хосте), для хоста — дефект схемы, о котором говорят C10 и console.error в
122
+ // PortMachine: звать что попало нельзя, TypeError отсюда уходит прямо в
123
+ // обработчик сообщения
124
+ const gameValidatorError = (options, value, validators) => {
125
+ if (options?.validator === undefined) {
126
+ return null;
127
+ }
128
+
129
+ const validatorFn = resolveValidator(options.validator, validators);
130
+
131
+ return validatorFn && !validatorFn(value) ? 'not valid' : null;
132
+ };
133
+
134
+ // null → ничего не добавляем: errors.push(...withName(...)) читается одной
135
+ // строкой на обеих ветках поля
136
+ const withName = (name, error) => (error === null ? [] : [{ name, error }]);
137
+
79
138
  /**
80
139
  * Валидирует объект с данными для авторизации.
81
140
  * @param {object} data - Объект с данными для проверки.
@@ -87,13 +146,49 @@ export const resolveValidator = (name, validators = {}) => {
87
146
  export const validateAuth = (data, authParams, validators = {}) => {
88
147
  const errors = [];
89
148
 
90
- for (const { name, options } of authParams) {
149
+ for (const { name, options: declared } of authParams) {
91
150
  if (!(name in data)) {
92
151
  return [{ name, error: `Property is missing` }];
93
152
  }
94
153
 
154
+ // Тот же резолв алиаса, что делает билдер формы (client/lib/formBuilder.js:
155
+ // buildField, collectFormErrors, resolveForcedValue). Без него контрол,
156
+ // выведенный из эксплуатации в v3 ('segmented', 'number', 'range',
157
+ // 'toggle'), не совпадает ни с одним именем ниже, и клиент, обошедший
158
+ // форму, получает поле ВООБЩЕ без проверок — ровно то превосходство над
159
+ // заполнившим форму, которого здесь быть не должно. Алиасы стали
160
+ // достижимы вместе с реестром контролов (этап 3 plugin-forward-compat):
161
+ // до него такое поле не строилось у клиента и сюда не доезжало
162
+ const options = resolveDescriptor(declared);
95
163
  const value = data[name];
96
164
 
165
+ // Контрол, которого нет в реестре, — дефект схемы (опечатка или игра из
166
+ // будущего), и билдер выносит ему тот же приговор: поле не строится
167
+ // (`unknown control`), в сабмит не попадает и до хоста не доезжает.
168
+ // Значит доехать оно может только от клиента, обошедшего форму, — и
169
+ // проверить его нечем: ниже не совпадёт ни одна ветка, останется лишь
170
+ // потолок длины. Отвергаем явно; раньше это ловит правило C10
171
+ if (options?.control !== undefined && !formControls.has(options.control)) {
172
+ errors.push({ name, error: 'unknown control' });
173
+ continue;
174
+ }
175
+
176
+ // Числовое поле идёт своей веткой: форма отдаёт его числом, и общий
177
+ // путь ниже (длина, список вариантов, regExp) к нему не применим —
178
+ // проверяется диапазон, ровно как validateField делает на клиенте.
179
+ // Игровой валидатор зовётся для обоих видов поля одинаково
180
+ if (isNumericControl(options)) {
181
+ const error = numericError(options, value);
182
+
183
+ errors.push(
184
+ ...withName(
185
+ name,
186
+ error ?? gameValidatorError(options, value, validators),
187
+ ),
188
+ );
189
+ continue;
190
+ }
191
+
97
192
  if (typeof value !== 'string') {
98
193
  return [{ name, error: `Property must be a string` }];
99
194
  }
@@ -103,10 +198,7 @@ export const validateAuth = (data, authParams, validators = {}) => {
103
198
  // не должен получать больше прав, чем клиент, её заполнивший. Пустое
104
199
  // значение пропускается ровно как на клиенте (required здесь не
105
200
  // проверяется: solo-путь boot.autoAuth отвечает дефолтами схемы, среди
106
- // которых бывает '') — пустота остаётся делом игрового валидатора.
107
- // min/max формы здесь не применяются и не нужны: числовое поле отдаёт из
108
- // формы число, а нестроковое значение отбито выше, то есть числовых
109
- // полей в authSchema не бывает вовсе
201
+ // которых бывает '') — пустота остаётся делом игрового валидатора
110
202
 
111
203
  // длина — первой и безусловно: потолок ограничивает не столько ввод,
112
204
  // сколько работу паттерна ниже (см. MAX_FIELD_LENGTH)
@@ -123,9 +215,16 @@ export const validateAuth = (data, authParams, validators = {}) => {
123
215
  // членство в списке вариантов — самое жёсткое ограничение формы: без
124
216
  // этой проверки поле-select без игрового валидатора принимает от
125
217
  // обошедшего форму клиента любую строку
218
+ // `source` (спец-источник вариантов движка, например карты) в auth-схеме
219
+ // не работает вовсе: auth-форма строится с ПУСТЫМ ctx
220
+ // (client/components/view/Auth.js), поэтому у игрока такое поле
221
+ // резолвится в пустой список и получает 'no options available' — войти
222
+ // с ним нельзя. Пропустив его здесь, хост дал бы обошедшему форму
223
+ // клиенту произвольную строку там, где игрок не может ввести ничего:
224
+ // инвариант модуля наизнанку. Отвергаем как и любой не-вариант; раньше
225
+ // это ловит правило C10
126
226
  if (
127
227
  OPTION_CONTROLS.includes(options?.control) &&
128
- !options.source &&
129
228
  !isDeclaredOption(options.options, value)
130
229
  ) {
131
230
  errors.push({ name, error: 'not an option' });
@@ -142,18 +241,9 @@ export const validateAuth = (data, authParams, validators = {}) => {
142
241
  continue;
143
242
  }
144
243
 
145
- if (options?.validator) {
146
- const validatorFn = resolveValidator(options.validator, validators);
147
-
148
- // нерезолвнутое имя (опечатка) и не-функция ведут себя одинаково —
149
- // поле проходит. Для клиента это норма (игровые валидаторы к нему не
150
- // едут, авторитет проверки на хосте), для хоста — дефект схемы, о
151
- // котором говорят C10 и console.error в PortMachine: звать что попало
152
- // нельзя, TypeError отсюда уходит прямо в обработчик сообщения
153
- if (validatorFn && !validatorFn(value)) {
154
- errors.push({ name, error: 'not valid' });
155
- }
156
- }
244
+ errors.push(
245
+ ...withName(name, gameValidatorError(options, value, validators)),
246
+ );
157
247
  }
158
248
 
159
249
  return errors.length ? errors : undefined;
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  assertGameConfigShape,
3
3
  checkPluginCompatibility,
4
+ mergeRequires,
4
5
  } from '../lib/gamePlugin.js';
5
6
  import { setBootConfig } from '../client/boot.js';
6
7
  import { ensureGameShell } from '../client/views/gameShell.js';
@@ -36,6 +37,10 @@ import { ensureGameShell } from '../client/views/gameShell.js';
36
37
  * голосований, например `['/bot 4']`.
37
38
  * @param {Object} [options.room] - Переопределения комнаты (map, maxPlayers,
38
39
  * roundTime, friendlyFire, seed).
40
+ * @param {Array<string>} [options.requires] - Возможности движка, без
41
+ * которых игра не запустится (аналог `GameManifest.requires`, которого в
42
+ * solo-режиме нет). По умолчанию — объединение `requires` обеих половин
43
+ * плагина.
39
44
  * @param {boolean} [options.devMode] - room.isDevMode: рекордер и хостовый
40
45
  * CONSOLE-лог.
41
46
  * @returns {Promise<Object>} `{ stop() }` — останов матча.
@@ -52,6 +57,7 @@ export async function startStandaloneGame({
52
57
  startupVotes = [],
53
58
  startupCommands = [],
54
59
  room = {},
60
+ requires,
55
61
  devMode = false,
56
62
  } = {}) {
57
63
  requireOption(hostPlugin, 'hostPlugin');
@@ -59,9 +65,21 @@ export async function startStandaloneGame({
59
65
  requireOption(wasmUrl, 'wasmUrl');
60
66
 
61
67
  // возможность, которой в этой сборке движка нет, иначе всплыла бы где-то
62
- // в середине хендшейка непрозрачной ошибкой
63
- requireCompatible(hostPlugin);
64
- requireCompatible(clientPlugin);
68
+ // в середине хендшейка непрозрачной ошибкой. Мастера тут нет, а значит нет
69
+ // и GameManifest с его `requires` — источником служат сами половины
70
+ // плагина (необязательное поле `requires`, docs/en/plugin-api.md) либо
71
+ // опция вызова, если встраивающий знает лучше. Одна проверка вместо двух:
72
+ // сообщение адресовано разработчику встраивания, делить его по половинам
73
+ // незачем
74
+ requireCompatible({
75
+ id: hostPlugin.id,
76
+ // mergeRequires, а не спред: строка вместо массива ('accolades' —
77
+ // естественная опечатка в поле, которого у игры раньше не было)
78
+ // разошлась бы посимвольно и дала отказ со списком букв вместо внятного
79
+ // «должен быть массив», а объект уронил бы SDK «not iterable»
80
+ requires:
81
+ requires ?? mergeRequires(hostPlugin.requires, clientPlugin.requires),
82
+ });
65
83
 
66
84
  // одна view на прогон: умолчания вместо обязательных полей (И2 этапа 2)
67
85
  const configView = assertGameConfigShape(hostPlugin);
@@ -97,8 +115,8 @@ export async function startStandaloneGame({
97
115
  };
98
116
  }
99
117
 
100
- // половина плагина просит возможность, которой в этой сборке движка нет:
101
- // подменить её в SDK нечем — матч не поднимется
118
+ // игра просит возможность, которой в этой сборке движка нет: подменить её в
119
+ // SDK нечем — матч не поднимется
102
120
  function requireCompatible(plugin) {
103
121
  const compat = checkPluginCompatibility(plugin);
104
122