vimp-engine 0.24.1 → 0.24.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.24.1",
3
+ "version": "0.24.2",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -123,7 +123,7 @@ export default {
123
123
  // исключена: пакет, собранный до появления поля, обязан оставаться
124
124
  // валидным (И1/И2)
125
125
  function halfViolations(ctx, manifestRequires) {
126
- const wanted = new Set(manifestRequires);
126
+ const wanted = new Set(resolveNames(manifestRequires));
127
127
  const violations = [];
128
128
  const halves = [
129
129
  ['HostPlugin', ctx.hostPlugin?.requires],
@@ -139,7 +139,7 @@ function halfViolations(ctx, manifestRequires) {
139
139
  continue;
140
140
  }
141
141
 
142
- const extra = value.filter(name => !wanted.has(name));
142
+ const extra = resolveNames(value).filter(name => !wanted.has(name));
143
143
 
144
144
  if (extra.length) {
145
145
  violations.push(
@@ -158,10 +158,12 @@ function halfViolations(ctx, manifestRequires) {
158
158
  return violations;
159
159
  }
160
160
 
161
- const declared = halves
162
- .map(([, value]) => value)
163
- .filter(Array.isArray)
164
- .flat();
161
+ const declared = resolveNames(
162
+ halves
163
+ .map(([, value]) => value)
164
+ .filter(Array.isArray)
165
+ .flat(),
166
+ );
165
167
 
166
168
  const missing = [...wanted].filter(name => !declared.includes(name));
167
169
 
@@ -176,6 +178,19 @@ function halfViolations(ctx, manifestRequires) {
176
178
  return violations;
177
179
  }
178
180
 
181
+ // Имена сравниваются РЕЗОЛВНУТЫМИ: реестр возможностей append-only, и
182
+ // выведенное алиасом имя движок принимает вечно наравне с активным.
183
+ // Манифест с новым именем и половина, оставшаяся на выведенном, просят одно
184
+ // и то же — считать это расхождением значило бы ругаться на игру за то, что
185
+ // движок переименовал возможность (тот самый отказ по возрасту, который план
186
+ // снимал). Неизвестное имя резолва не имеет и сравнивается как есть: о нём
187
+ // говорит проверка по реестру выше
188
+ function resolveNames(names) {
189
+ return (Array.isArray(names) ? names : []).map(
190
+ name => ENGINE_CAPABILITIES.resolve(name) ?? name,
191
+ );
192
+ }
193
+
179
194
  // то же, что делают B5 и C10: выведенное имя — не отказ, а предупреждение.
180
195
  // Отвергать за него значило бы отвергать игру за возраст (И1)
181
196
  function finish(violations, retired) {
@@ -2,6 +2,7 @@ import { ERROR, WARN, skip, verdict } from '../result.js';
2
2
  import { resolveValidator } from '../../../lib/validators.js';
3
3
  import {
4
4
  formControls,
5
+ resolveDescriptor,
5
6
  ACTIVE_FORM_CONTROLS,
6
7
  } from '../../../lib/formControls.js';
7
8
 
@@ -13,6 +14,10 @@ import {
13
14
  // которого нет в authSchema.validators (поле не проверяется никем).
14
15
  const NICKNAME = /^(name|nick|nickname|player|playername|login|username)$/i;
15
16
 
17
+ // те же имена, что у validateAuth и билдера формы: список вариантов есть
18
+ // только у них, и только им движок резолвит `source`
19
+ const OPTION_CONTROLS = ['select', 'radio'];
20
+
16
21
  export default {
17
22
  id: 'C10',
18
23
  name: 'authSchema',
@@ -76,8 +81,18 @@ export default {
76
81
  // значит список вариантов у такого поля пуст всегда: игрок видит
77
82
  // 'no options available' и войти не может, а хост отвергает любое
78
83
  // значение как не-вариант. Это только для authSchema; в roomForm
79
- // (правило B5) source штатный — там движок отдаёт каталог карт
80
- if (field.options?.source !== undefined) {
84
+ // (правило B5) source штатный — там движок отдаёт каталог карт.
85
+ //
86
+ // Только для контролов со списком: у text/checkbox `source` не читает
87
+ // никто (formBuilder.resolveOptions зовут лишь buildSelect/buildRadio),
88
+ // и такое поле работает — отвергать за лишний ключ значило бы отвергать
89
+ // игру, которая логинит игроков как ни в чём не бывало
90
+ // по резолвнутому контролу, а не по написанному: 'segmented' — та же
91
+ // группа вариантов, что radio, и source в ней так же не резолвится
92
+ if (
93
+ OPTION_CONTROLS.includes(resolveDescriptor(field.options)?.control) &&
94
+ field.options?.source !== undefined
95
+ ) {
81
96
  violations.push(
82
97
  `authSchema param "${field.name}" declares options.source ` +
83
98
  `"${field.options.source}" — the auth form is built without ` +
@@ -2,6 +2,7 @@ import { access, readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { checkPluginCompatibility } from './gamePlugin.js';
5
+ import { ENGINE_CAPABILITIES } from './capabilities.js';
5
6
 
6
7
  // Загрузка пакета игры в Node по его собранному dist/ (Этап 4 плана
7
8
  // standalone-sdk). В браузере плагин грузится по URL из GameManifest мастера;
@@ -107,25 +108,28 @@ function assertPluginMatchesManifest(manifest, plugins) {
107
108
  }
108
109
  }
109
110
 
110
- assertRequiresMatchManifest(manifest, plugins);
111
+ warnOnRequiresMismatch(manifest, plugins);
111
112
  }
112
113
 
113
114
  // `requires` пишут ТРИ независимых места одного пакета игры: скрипт сборки
114
115
  // манифеста и обе половины плагина (последние — ради standalone SDK, у
115
116
  // которого манифеста нет вовсе). Разъехавшись, они дают игру, которая в
116
- // лобби честно отвергается, а в solo-режиме тихо недоигрывает — то есть
117
- // именно тот молчаливый режим, ради отказа от которого возможности и
118
- // заводились. Проверка того же класса, что сверка engineApi выше: это
119
- // рассинхрон сборки ВНУТРИ пакета, а не отказ по возрасту (И4).
117
+ // лобби честно отвергается, а в solo-режиме тихо недоигрывает.
118
+ //
119
+ // Здесь это ПРЕДУПРЕЖДЕНИЕ, а не отказ, и разница принципиальная. Node-путь
120
+ // (dedicated, vimp-sim, inline host) читает авторитетный манифест, и его
121
+ // совместимость уже проверена выше (checkPluginCompatibility): рассинхрон с
122
+ // половинами ничего здесь не ломает. Бросить значило бы отвергнуть пакет,
123
+ // который грузился раньше, — прямое нарушение И4 плана
124
+ // plugin-forward-compat ради дефекта упаковки, который движок переживает.
125
+ // Место отказа — правило контракта B2: чекер запускает автор игры, и он
126
+ // узнаёт о расхождении до публикации, а не игрок вместо матча.
120
127
  //
121
128
  // Половина, вовсе НЕ объявившая поле, из сверки исключена: старый пакет,
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
+ // собранный до его появления, ничего не утверждает (И1/И2). Объявленное
130
+ // пустым (`requires: []`) — уже утверждение «игре ничего не нужно».
131
+ function warnOnRequiresMismatch(manifest, plugins) {
132
+ const wanted = new Set(resolveNames(manifest.requires));
129
133
 
130
134
  for (const [half, plugin] of Object.entries(plugins)) {
131
135
  if (plugin.requires === undefined || plugin.requires === null) {
@@ -133,16 +137,19 @@ function assertRequiresMatchManifest(manifest, plugins) {
133
137
  }
134
138
 
135
139
  if (!Array.isArray(plugin.requires)) {
136
- throw new Error(
140
+ console.warn(
137
141
  `game "${manifest.id}": ${half} plugin requires must be an array ` +
138
- 'of capability names',
142
+ 'of capability names — the standalone SDK reads it',
139
143
  );
144
+ continue;
140
145
  }
141
146
 
142
- const extra = plugin.requires.filter(name => !wanted.has(name));
147
+ const extra = resolveNames(plugin.requires).filter(
148
+ name => !wanted.has(name),
149
+ );
143
150
 
144
151
  if (extra.length) {
145
- throw new Error(
152
+ console.warn(
146
153
  `game "${manifest.id}": ${half} plugin requires ` +
147
154
  `${extra.join(', ')}, which manifest.requires does not list — ` +
148
155
  'stale dist/ (the manifest is what the lobby master reads)',
@@ -156,19 +163,18 @@ function assertRequiresMatchManifest(manifest, plugins) {
156
163
  // (`requires: []`) — это утверждение «ничего не нужно», и оно расходится
157
164
  // с манифестом наравне с неполным списком
158
165
  const halves = Object.values(plugins).filter(
159
- plugin => plugin.requires !== undefined && plugin.requires !== null,
166
+ plugin => Array.isArray(plugin.requires),
160
167
  );
161
168
 
162
169
  if (halves.length === 0) {
163
170
  return;
164
171
  }
165
172
 
166
- const declared = halves.flatMap(plugin => plugin.requires);
167
-
173
+ const declared = resolveNames(halves.flatMap(plugin => plugin.requires));
168
174
  const missing = [...wanted].filter(name => !declared.includes(name));
169
175
 
170
176
  if (missing.length) {
171
- throw new Error(
177
+ console.warn(
172
178
  `game "${manifest.id}": manifest.requires names ${missing.join(', ')}, ` +
173
179
  'which neither plugin half declares — stale dist/ (the standalone ' +
174
180
  'SDK reads the halves, there is no manifest in solo mode)',
@@ -176,6 +182,19 @@ function assertRequiresMatchManifest(manifest, plugins) {
176
182
  }
177
183
  }
178
184
 
185
+ // Сравнивать имена возможностей буквально нельзя: реестр append-only, и
186
+ // выведенное алиасом имя движок принимает вечно наравне с активным
187
+ // (ENGINE_CAPABILITIES.has в checkPluginCompatibility). Манифест, назвавший
188
+ // новое имя, и половина, оставшаяся на выведенном, просят ОДНО И ТО ЖЕ —
189
+ // объявить это расхождением значило бы ругаться на игру за то, что движок
190
+ // переименовал возможность. Неизвестное имя резолва не имеет и сравнивается
191
+ // как есть: о нём говорит checkPluginCompatibility, а не эта сверка
192
+ function resolveNames(names) {
193
+ return (Array.isArray(names) ? names : []).map(
194
+ name => ENGINE_CAPABILITIES.resolve(name) ?? name,
195
+ );
196
+ }
197
+
179
198
  function importDefault(baseDir, entry, assetsBase) {
180
199
  const file = path.resolve(baseDir, stripBase(entry, assetsBase));
181
200
 
@@ -56,15 +56,16 @@ const numericError = (options, value) => {
56
56
  return `must be <= ${options.max}`;
57
57
  }
58
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
-
59
+ // regExp числового поля хост НЕ проверяет, и это осознанно. Клиент
60
+ // сверяет паттерн с сырой строкой, которую набрал игрок
61
+ // (formBuilder.validateField: field.getRaw()); до хоста доезжает уже
62
+ // число, и восстановить ту строку нечем `String(значение)` даёт
63
+ // каноническую запись, а не набранную. Паттерн, принимающий
64
+ // неканоническую («^\d+\.\d{2}$» и ввод «1.50»), совпал бы с формой и
65
+ // разошёлся бы с хостом: игрок видит поле валидным, а вход не пускает, и
66
+ // набрать строку, годную обеим сторонам, нельзя вовсе. Блокировка входа
67
+ // хуже той щели, которую паттерн закрывает: диапазон уже проверен, а
68
+ // ведущий ноль до хоста и не доезжает (Number('08') === 8)
68
69
  return null;
69
70
  };
70
71