vimp-engine 0.14.4 → 0.15.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.14.4",
3
+ "version": "0.15.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -31,21 +31,14 @@ export default class AuthModel {
31
31
  }
32
32
 
33
33
  // обновление данных
34
- // если value невалиден, возвращается текущий value
34
+ // формат поля проверяет formBuilder.collectFormErrors перед сабмитом —
35
+ // единственный контур валидации формы (раньше здесь была вторая,
36
+ // параллельная проверка по options.regExp, которая молча зануляла
37
+ // значение и падала на строке из JSON: у неё нет .test)
35
38
  update(data) {
36
- const { name } = data;
37
- const { regExp } = this._options[name] ?? {};
38
- let { value } = data;
39
-
40
- if (regExp) {
41
- if (regExp.test(value)) {
42
- this._data[name] = value;
43
- } else {
44
- this._data[name] = value = '';
45
- }
46
- } else {
47
- this._data[name] = value;
48
- }
39
+ const { name, value } = data;
40
+
41
+ this._data[name] = value;
49
42
 
50
43
  this.publisher.emit('form', { name, value });
51
44
  }
@@ -1,5 +1,5 @@
1
1
  import Publisher from '../../../lib/Publisher.js';
2
- import { buildForm, reportFormValidity } from '../../lib/formBuilder.js';
2
+ import { bindLiveErrors, buildForm, renderFormErrors } from '../../lib/formBuilder.js';
3
3
 
4
4
  // Singleton AuthView
5
5
 
@@ -24,19 +24,29 @@ export default class AuthView {
24
24
  this._enter = document.getElementById(elems.enterId);
25
25
  this._fieldsContainer = document.getElementById(elems.fieldsId);
26
26
  this._fields = new Map();
27
+ this._descriptors = [];
27
28
 
28
29
  this.publisher = new Publisher();
29
30
 
30
31
  this._renderTexts(elems, texts);
31
32
  this._renderFields(elems, params);
32
33
 
33
- // форма заполнена; нативная проверка (pattern/required) сервер всё
34
- // равно валидирует своими validators (renderError), это лишь UX-фильтр
35
- // до отправки
34
+ // ошибки видны по ходу правки (см. bindLiveErrors), а не по клику:
35
+ // до сабмита по тронутым полям, после — по всей форме
36
+ this._liveErrors = bindLiveErrors(this._fieldsContainer, this._error, () => ({
37
+ descriptors: this._descriptors,
38
+ fields: this._fields,
39
+ }));
40
+
41
+ // форма заполнена; клиентская проверка (required/regExp/min/max) —
42
+ // сервер всё равно валидирует своими validators (renderError), это лишь
43
+ // UX-фильтр до отправки
36
44
  this._enter.onclick = () => {
37
- if (reportFormValidity(this._fieldsContainer)) {
38
- authView.publisher.emit('enter');
45
+ if (this._liveErrors.arm().length) {
46
+ return;
39
47
  }
48
+
49
+ authView.publisher.emit('enter');
40
50
  };
41
51
 
42
52
  this._mPublic.on('form', 'renderData', this);
@@ -65,6 +75,11 @@ export default class AuthView {
65
75
  ...descriptorRest,
66
76
  }));
67
77
 
78
+ this._descriptors = descriptors;
79
+ // params сюда приходят чужие (PS_AUTH_DATA, тот же массив main.js
80
+ // отдаёт следом в AuthCtrl.init) и остаются нетронутыми: значение поля
81
+ // с единственным вариантом закрепляет main.js (resolveForcedValue) до
82
+ // разветвления на solo-путь, а не эта форма
68
83
  this._fields = buildForm(descriptors, container, {}, ({ name, value }) => {
69
84
  this.publisher.emit('input', { name, value });
70
85
  });
@@ -146,22 +161,17 @@ export default class AuthView {
146
161
  renderData(data) {
147
162
  const { name, value } = data;
148
163
 
149
- this._error.textContent = '';
164
+ // блок ошибок ведёт bindLiveErrors — он перевалидирует форму по 'input',
165
+ // то есть по ходу правки. Чистка, стоявшая здесь, висела на событии
166
+ // модели, а оно приходит из 'change' (у text-инпута — blur): игрок,
167
+ // перейдя из починенного поля в следующее, терял ошибки полей, которых
168
+ // ещё не касался
150
169
  this._fields.get(name)?.setValue(value);
151
170
  }
152
171
 
153
- // отображает ошибки
172
+ // отображает серверные ошибки (PS_AUTH_DATA/validators) — тот же рендер,
173
+ // что и у клиентской collectFormErrors (formBuilder.renderFormErrors)
154
174
  renderError(data) {
155
- this._error.textContent = '';
156
-
157
- data.forEach(item => {
158
- const name = item.name.toUpperCase();
159
- const err = item.error;
160
- const line = document.createElement('div');
161
-
162
- line.textContent = err ? `${name}: ${err}` : `${name} is not correctly!`;
163
-
164
- this._error.appendChild(line);
165
- });
175
+ renderFormErrors(this._error, data);
166
176
  }
167
177
  }
@@ -0,0 +1,41 @@
1
+ import { resolveForcedValue } from './formBuilder.js';
2
+
3
+ // Значения полей auth-формы до её постройки (PS_AUTH_DATA.params).
4
+ // Вынесено из socketMethods[PS_AUTH_DATA] (main.js), чтобы этот код был
5
+ // накрыт тестом: solo-путь (boot.autoAuth) отвечает хосту вообще без формы и
6
+ // берёт значения ровно отсюда, то есть ошибка здесь не видна ни в одном
7
+ // тесте формы.
8
+
9
+ /**
10
+ * Правит `param.value` на месте: сначала память клиента
11
+ * (`localStorage[storage]`), затем схема. Порядок важен — поле с
12
+ * единственным вариантом форма не показывает и править не даёт, поэтому
13
+ * устаревший `localStorage` от версии игры, где вариантов было больше,
14
+ * обязан быть перекрыт: иначе он уедет на хост и получит отказ от
15
+ * игровых validators, а поправить его игроку нечем.
16
+ *
17
+ * Мутация, а не копия: тот же массив main.js отдаёт следом в
18
+ * `AuthCtrl.init` (оттуда он попадает в `AuthModel._data`) и в
19
+ * `defaultsFrom(params)` на solo-пути.
20
+ *
21
+ * @param {Array<Object>} params - PS_AUTH_DATA.params.
22
+ * @param {Object} [storage] - Хранилище значений, по умолчанию localStorage.
23
+ * @returns {Array<Object>} Тот же массив.
24
+ */
25
+ export function normalizeAuthParams(params, storage = localStorage) {
26
+ params.forEach(param => {
27
+ const descriptor = param.options ?? {};
28
+
29
+ if (descriptor.storage) {
30
+ param.value = storage[descriptor.storage] || param.value || '';
31
+ }
32
+
33
+ const forced = resolveForcedValue(descriptor);
34
+
35
+ if (forced !== undefined) {
36
+ param.value = forced;
37
+ }
38
+ });
39
+
40
+ return params;
41
+ }
@@ -1,12 +1,23 @@
1
+ import { anchorPattern } from '../../lib/formPattern.js';
2
+ import { normalizeOptions } from '../../lib/formOptions.js';
3
+
1
4
  // Общий билдер полей форм (room-форма и auth-форма используют один
2
5
  // контракт дескрипторов — docs/en/plugin-api.md, раздел "Form schema").
3
6
  // control: 'select'|'text'|'checkbox'|'radio'; все контролы — нативные
4
- // элементы формы, без визуальной кастомизации.
5
-
6
- function normalizeOptions(list) {
7
- return (list || []).map(opt =>
8
- opt !== null && typeof opt === 'object' ? opt : { value: opt, label: String(opt) },
9
- );
7
+ // элементы формы, без визуальной кастомизации. Валидация — не нативная
8
+ // (никаких браузерных попапов): collectFormErrors/renderFormErrors ниже
9
+ // собирают и рисуют ошибки в разметку (#lobby-error/#auth-error).
10
+ // Проверяются только поля, у которых есть строка в DOM: ошибка на скрытом
11
+ // поле игроку не видна и исправить её нечем она просто запирает форму.
12
+
13
+ // контролы, у которых есть список вариантов: держим одним списком, чтобы
14
+ // resolveForcedValue не расходился с builders при добавлении нового
15
+ const OPTION_CONTROLS = ['select', 'radio'];
16
+
17
+ // числовое text-поле: unit задан или numeric:true. Правило одно на билдер и
18
+ // на валидацию, поэтому живёт в одном месте
19
+ function isNumeric(descriptor) {
20
+ return descriptor.numeric === true || descriptor.unit !== undefined;
10
21
  }
11
22
 
12
23
  // 'source' — спец-источник вариантов из каталога движка (например карты),
@@ -19,6 +30,32 @@ function resolveOptions(descriptor, ctx) {
19
30
  return normalizeOptions(descriptor.options);
20
31
  }
21
32
 
33
+ // ровно один резолвнутый вариант — единственно возможное значение поля:
34
+ // строка формы для него не рендерится (игроку нечего выбирать и нечем
35
+ // поправить рассинхрон), поэтому ничто извне (устаревший localStorage,
36
+ // default из схемы, серверное PS_AUTH_DATA) не должно иметь возможности
37
+ // его переопределить — native <select>.value на несуществующем option
38
+ // молча схлопывается в '' без единой ошибки (vimp-snakes/model)
39
+ function forcedValue(options) {
40
+ return options.length === 1 ? options[0].value : undefined;
41
+ }
42
+
43
+ // то же правило вне формы: solo-путь (boot.autoAuth) отвечает хосту без
44
+ // всякой формы и должен прийти ровно к тому же значению, что и она
45
+ export function resolveForcedValue(descriptor, ctx = {}) {
46
+ if (!OPTION_CONTROLS.includes(descriptor.control)) {
47
+ return undefined;
48
+ }
49
+
50
+ const forced = forcedValue(resolveOptions(descriptor, ctx));
51
+
52
+ // форма отдала бы строку: и <option>.value, и <input type=radio>.value —
53
+ // DOM-свойства, они всегда строки. Нестроковое значение (options: [1, 2])
54
+ // validateAuth отбивает «Property must be a string» (lib/validators.js), а
55
+ // строки поля в DOM нет — игроку нечем это поправить
56
+ return forced === undefined ? undefined : String(forced);
57
+ }
58
+
22
59
  // unit:'s' — значение хранится в мс, показывается/редактируется в секундах
23
60
  function toDisplay(descriptor, value) {
24
61
  return descriptor.unit === 's' ? value / 1000 : value;
@@ -30,10 +67,12 @@ function toStored(descriptor, value) {
30
67
 
31
68
  function buildSelect(descriptor, ctx) {
32
69
  const el = document.createElement('select');
70
+ const options = resolveOptions(descriptor, ctx);
71
+ const forced = forcedValue(options);
33
72
 
34
73
  el.name = descriptor.name;
35
74
 
36
- resolveOptions(descriptor, ctx).forEach(({ value, label }) => {
75
+ options.forEach(({ value, label }) => {
37
76
  const option = document.createElement('option');
38
77
 
39
78
  option.value = value;
@@ -41,10 +80,20 @@ function buildSelect(descriptor, ctx) {
41
80
  el.appendChild(option);
42
81
  });
43
82
 
83
+ if (forced !== undefined) {
84
+ el.value = forced;
85
+ }
86
+
44
87
  return {
45
88
  el,
89
+ singleOption: forced !== undefined,
90
+ noOptions: options.length === 0,
46
91
  getValue: () => el.value,
47
92
  setValue(value) {
93
+ if (forced !== undefined) {
94
+ return;
95
+ }
96
+
48
97
  el.value = value;
49
98
  },
50
99
  onChange(cb) {
@@ -58,11 +107,15 @@ function buildSelect(descriptor, ctx) {
58
107
  // вместо превращения его в 0 на сабмите
59
108
  function buildText(descriptor) {
60
109
  const el = document.createElement('input');
61
- const numeric = descriptor.numeric || descriptor.unit !== undefined;
110
+ const numeric = isNumeric(descriptor);
62
111
 
63
112
  el.type = 'text';
64
113
  el.name = descriptor.name;
65
114
 
115
+ // pattern/required — семантика контрола (a11y, автозаполнение), НЕ
116
+ // валидация: обе формы — обычные div, не <form>, и reportValidity()
117
+ // движок не зовёт (см. collectFormErrors). maxLength — единственный из
118
+ // трёх с реальным эффектом: он ограничивает сам ввод
66
119
  if (descriptor.regExp) {
67
120
  el.pattern = descriptor.regExp;
68
121
  }
@@ -79,7 +132,11 @@ function buildText(descriptor) {
79
132
 
80
133
  const getValue = () => {
81
134
  if (!numeric) {
82
- return el.value;
135
+ // то же, что лобби уже делает с именем комнаты (main.js: name.trim()):
136
+ // незначащие пробелы — опечатка ввода, а не значение. Валидация
137
+ // смотрит на ту же строку (validateField), иначе она разрешала бы то,
138
+ // что потом отбивает хост (isValidName не терпит крайних пробелов)
139
+ return el.value.trim();
83
140
  }
84
141
 
85
142
  const n = Number(el.value);
@@ -91,6 +148,11 @@ function buildText(descriptor) {
91
148
  return {
92
149
  el,
93
150
  getValue,
151
+ // «сырая» строка поля, до конвертации единицы и отката к default:
152
+ // единственный способ отличить пустой ввод от валидного значения (у
153
+ // числового поля getValue() подменяет пустоту дефолтом). Часть контракта
154
+ // поля — валидации незачем знать, из какого элемента оно построено
155
+ getRaw: () => el.value,
94
156
  setValue(value) {
95
157
  el.value = numeric ? toDisplay(descriptor, value) : (value ?? '');
96
158
  },
@@ -131,8 +193,12 @@ function buildRadio(descriptor, ctx) {
131
193
  const el = document.createElement('div');
132
194
  const groupName = `field-radio-${descriptor.name}-${++idSeq}`;
133
195
  const inputs = [];
196
+ const options = resolveOptions(descriptor, ctx);
197
+ // см. forcedValue: ровно один вариант — принудительное и неизменяемое
198
+ // значение, строка скрыта и поправить рассинхрон нечем
199
+ const forced = forcedValue(options);
134
200
 
135
- resolveOptions(descriptor, ctx).forEach(({ value, label }) => {
201
+ options.forEach(({ value, label }) => {
136
202
  const wrap = document.createElement('span');
137
203
  const input = document.createElement('input');
138
204
 
@@ -151,12 +217,22 @@ function buildRadio(descriptor, ctx) {
151
217
  inputs.push(input);
152
218
  });
153
219
 
220
+ if (forced !== undefined) {
221
+ inputs[0].checked = true;
222
+ }
223
+
154
224
  const getValue = () => inputs.find(input => input.checked)?.value;
155
225
 
156
226
  return {
157
227
  el,
228
+ singleOption: forced !== undefined,
229
+ noOptions: options.length === 0,
158
230
  getValue,
159
231
  setValue(value) {
232
+ if (forced !== undefined) {
233
+ return;
234
+ }
235
+
160
236
  inputs.forEach(input => {
161
237
  input.checked = input.value === String(value);
162
238
  });
@@ -205,30 +281,268 @@ export function mergeRoomDefaults(descriptors, roomDefaults) {
205
281
  }));
206
282
  }
207
283
 
208
- // нативная валидация (pattern/required/maxlength) перед сабмитом room- и
209
- // auth-формы: обеобычные div (lobby.pug/auth.pug), не <form>, поэтому
210
- // reportValidity вызывается на каждом контроле напрямую (Constraint
211
- // Validation API работает и вне <form>). Останавливается на первом
212
- // невалидном контроле иначе браузер фокусирует/подсвечивает последний
213
- // вызванный reportValidity, а не первый по порядку поля
214
- export function reportFormValidity(container) {
284
+ // суффикс подписи поля: диапазон (min/max, если задан хотя бы один из них),
285
+ // иначе просто единица (unit:'s') та же строка, что и раньше, "(s)".
286
+ // Полноценные HTML5 min/max тут не годятся: числовые поля — type=text
287
+ // (unit-конвертация мс<->с сама по себе не native-совместима), поэтому
288
+ // диапазонтолько подсказка + вход в collectFormErrors ниже
289
+ function buildLabelSuffix(descriptor) {
290
+ const { min, max, unit } = descriptor;
291
+
292
+ if (min === undefined && max === undefined) {
293
+ return unit === 's' ? ' (s)' : '';
294
+ }
295
+
296
+ const range = `${min ?? '…'}–${max ?? '…'}`;
297
+
298
+ return unit === 's' ? ` (s, ${range})` : ` (${range})`;
299
+ }
300
+
301
+ // скомпилированные паттерны полей: строка regExp -> RegExp, либо null, если
302
+ // она не компилируется. Кэш на модуль — дескрипторы приезжают из манифеста и
303
+ // живут всю сессию, а сабмит иначе пересобирал бы RegExp на каждое поле
304
+ const patterns = new Map();
305
+
306
+ // regExp приезжает из манифеста игры строкой: невалидный паттерн — дефект
307
+ // схемы, а не повод убить сабмит. Без перехвата SyntaxError уходит из
308
+ // collectFormErrors в обработчик клика, кнопка перестаёт делать что-либо и
309
+ // игрок не видит ни строки (нативный `pattern` вёл себя ровно наоборот:
310
+ // нечитаемый паттерн браузер игнорирует). Контракт-чекер ловит это раньше —
311
+ // правило B5, roomForm; якорение общее с ним (lib/formPattern.js)
312
+ function fieldPattern(regExp) {
313
+ if (!patterns.has(regExp)) {
314
+ let pattern = null;
315
+
316
+ try {
317
+ pattern = anchorPattern(regExp);
318
+ } catch (e) {
319
+ console.error(`formBuilder: invalid regExp "${regExp}" — ${e.message}`);
320
+ }
321
+
322
+ patterns.set(regExp, pattern);
323
+ }
324
+
325
+ return patterns.get(regExp);
326
+ }
327
+
328
+ // ошибка одного поля, либо null — required/regExp/maxlength (не-numeric
329
+ // text)/min/max (numeric text); сообщения не зависят от локали браузера, в
330
+ // отличие от бывшего reportValidity(). regExp сверяется с "сырой" строкой
331
+ // поля (field.getRaw()) — тем же значением, которое раньше матчил нативный
332
+ // pattern: у числового text-поля getValue() отдаёт число в единице
333
+ // хранения (мс для unit:'s'), а не отображаемую строку, так что regExp
334
+ // (сгенерированный build-game-manifest.js диапазон вроде "^([1-8])$" для
335
+ // maxPlayers — единственная граница у манифестов без min/max) проверяется
336
+ // отдельно от min/max, а не вместо них
337
+ function validateField(descriptor, field) {
338
+ // резолв вариантов дал пустой список: допустимого значения у поля нет
339
+ // вовсе — дефект схемы или каталога (buildForm уже написал console.error).
340
+ // Отдельным сообщением, а не через required: заполнить такое поле нечем, и
341
+ // «required» повело бы игрока туда, где ничего нет. Проверяется первой —
342
+ // required у поля может и не стоять (ни одна игра не ставит его на `map`),
343
+ // а пустая строка не должна уезжать на хост молча
344
+ if (field.noOptions) {
345
+ return 'no options available';
346
+ }
347
+
348
+ const value = field.getValue();
349
+ const isText = descriptor.control === 'text';
350
+ // у числового поля getValue() уже откатился к default (чтобы пустая
351
+ // строка не уехала нулём), поэтому пустоту видно только по сырой строке.
352
+ // trim: Number(' ') === 0, то есть без него пробел прошёл бы нулём — и
353
+ // наоборот, ' 8 ' игрок считает валидной восьмёркой. У нечислового поля
354
+ // это ровно то же, что вернёт getValue(): обе стороны обязаны смотреть на
355
+ // одну строку, иначе клиент разрешает то, что отбивает хост
356
+ const raw = isText ? (field.getRaw?.() ?? '').trim() : undefined;
357
+ const isEmpty = isText
358
+ ? raw === ''
359
+ : value === undefined || value === null || value === '';
360
+ const numeric = isText && isNumeric(descriptor);
361
+
362
+ // числовое поле всегда несёт число и «необязательным» быть не может:
363
+ // пустой ввод getValue() подменяет default'ом (чтобы не уехал нолём), и
364
+ // без этой ветки пустой Max players молча создавал бы комнату с дефолтом
365
+ if ((descriptor.required || numeric) && isEmpty) {
366
+ return 'required';
367
+ }
368
+
369
+ if (isEmpty) {
370
+ return null;
371
+ }
372
+
373
+ if (numeric) {
374
+ // без этой проверки нечисловой ввод молча подменялся бы default'ом
375
+ // из getValue() и уезжал на хост как «валидный»
376
+ if (!Number.isFinite(Number(raw))) {
377
+ return 'must be a number';
378
+ }
379
+
380
+ const displayValue = toDisplay(descriptor, value);
381
+
382
+ // min/max раньше regExp: тот же диапазон regExp кодирует точным
383
+ // паттерном (rangeToPattern), но «must be ≤ 32» говорит игроку то же,
384
+ // что подсказка в подписи поля, а «invalid format» — ничего
385
+ if (descriptor.min !== undefined && displayValue < descriptor.min) {
386
+ return `must be ≥ ${descriptor.min}`;
387
+ }
388
+
389
+ if (descriptor.max !== undefined && displayValue > descriptor.max) {
390
+ return `must be ≤ ${descriptor.max}`;
391
+ }
392
+ } else if (
393
+ isText &&
394
+ descriptor.maxlength !== undefined &&
395
+ String(value).length > descriptor.maxlength
396
+ ) {
397
+ return `must be at most ${descriptor.maxlength} characters`;
398
+ }
399
+
400
+ // Проверяется последним: ловит то, что диапазон пропускает (дробное,
401
+ // ведущий ноль). Нечитаемый паттерн — не ограничение (fieldPattern уже
402
+ // назвал дефект в консоли), поле проходит
403
+ if (isText && descriptor.regExp) {
404
+ const pattern = fieldPattern(descriptor.regExp);
405
+
406
+ if (pattern && !pattern.test(raw)) {
407
+ return 'invalid format';
408
+ }
409
+ }
410
+
411
+ return null;
412
+ }
413
+
414
+ // валидирует всю форму перед сабмитом (замена бывшего reportFormValidity/
415
+ // native reportValidity): порядок ошибок — порядок дескрипторов.
416
+ // Поля без строки в DOM (hidden, единственный вариант) пропускаются: их
417
+ // ошибку игрок не видит и исправить не может — она бы заперла форму
418
+ // навсегда (нативный reportValidity обходил только контролы в DOM и до них
419
+ // тоже не добирался). label — подпись из формы, чтобы имя в ошибке
420
+ // совпадало с тем, что игрок видит рядом с полем
421
+ export function collectFormErrors(descriptors, fields) {
422
+ const errors = [];
423
+
424
+ descriptors.forEach(descriptor => {
425
+ const field = fields.get(descriptor.name);
426
+
427
+ if (!field || field.rendered === false) {
428
+ return;
429
+ }
430
+
431
+ const error = validateField(descriptor, field);
432
+
433
+ if (error) {
434
+ errors.push({
435
+ name: descriptor.name,
436
+ label: descriptor.label || descriptor.name,
437
+ error,
438
+ });
439
+ }
440
+ });
441
+
442
+ return errors;
443
+ }
444
+
445
+ // рисует ошибки формы в разметку — общий рендер для #lobby-error и
446
+ // #auth-error (и для клиентской collectFormErrors, и для серверных ошибок
447
+ // PS_AUTH_DATA/validators, чей формат уже {name, error})
448
+ export function renderFormErrors(container, errors) {
215
449
  if (!container) {
216
- return true;
450
+ return;
217
451
  }
218
452
 
219
- for (const el of container.querySelectorAll('input, select')) {
220
- if (!el.reportValidity()) {
221
- return false;
453
+ container.textContent = '';
454
+
455
+ (errors || []).forEach(({ name, label, error }) => {
456
+ const line = document.createElement('div');
457
+ // серверные ошибки (PS_AUTH_DATA/validators) приезжают без label —
458
+ // для них, как и раньше, остаётся имя поля
459
+ const title = String(label || name).toUpperCase();
460
+
461
+ line.textContent = error ? `${title}: ${error}` : `${title} is not correctly!`;
462
+
463
+ container.appendChild(line);
464
+ });
465
+ }
466
+
467
+ // какому полю принадлежит событие ввода: у radio-группы field.el — обёртка
468
+ // вокруг нескольких input'ов, а их собственный name сгенерирован и с именем
469
+ // поля не совпадает, поэтому ищем по вхождению узла, а не по name
470
+ function fieldNameOf(fields, target) {
471
+ for (const [name, field] of fields) {
472
+ if (field.el === target || field.el.contains?.(target)) {
473
+ return name;
222
474
  }
223
475
  }
224
476
 
225
- return true;
477
+ return undefined;
478
+ }
479
+
480
+ // перевалидация формы по ходу правки — вместо «одна ошибка на клик».
481
+ //
482
+ // Показывается не всё подряд: до сабмита — только поля, которых игрок уже
483
+ // касался. Неверное значение он видит сразу, как ввёл (а не после клика по
484
+ // кнопке), но «required» на поле, которое ещё не заполняли, не шумит.
485
+ // Сабмит (arm) снимает фильтр: клик — это ответ за всю форму целиком.
486
+ //
487
+ // Строка уходит из блока, когда починено именно её поле, а не всё разом по
488
+ // первому нажатию клавиши — иначе игрок с тремя ошибками теряет список,
489
+ // начав править первую.
490
+ //
491
+ // 'input' — единственное событие по ходу ввода ('change' у text-инпута
492
+ // приходит на blur, то есть уже после клика по кнопке); слушатель
493
+ // делегированный и вешается один раз: контейнер постоянен, пересобираются
494
+ // только поля. Пустой список тоже рендерится — так из блока уходят и
495
+ // не-формные строки (отказ загрузки плагина в лобби).
496
+ export function bindLiveErrors(container, errorContainer, getForm) {
497
+ let touched = new Set();
498
+ let all = false;
499
+
500
+ const render = () => {
501
+ const { descriptors, fields } = getForm();
502
+ const errors = collectFormErrors(descriptors, fields).filter(
503
+ error => all || touched.has(error.name),
504
+ );
505
+
506
+ renderFormErrors(errorContainer, errors);
507
+
508
+ return errors;
509
+ };
510
+
511
+ container?.addEventListener('input', event => {
512
+ const name = fieldNameOf(getForm().fields, event.target);
513
+
514
+ if (name !== undefined) {
515
+ touched.add(name);
516
+ }
517
+
518
+ render();
519
+ });
520
+
521
+ return {
522
+ // сабмит: показываем и то, чего игрок не трогал. Возвращает отрисованные
523
+ // ошибки, чтобы вызывающему не пришлось собирать их вторым проходом
524
+ arm: () => {
525
+ all = true;
526
+
527
+ return render();
528
+ },
529
+ // форма пересобрана (смена игры) — она снова ничья: ни одного тронутого
530
+ // поля, ни одного сабмита
531
+ disarm: () => {
532
+ all = false;
533
+ touched = new Set();
534
+
535
+ return render();
536
+ },
537
+ };
226
538
  }
227
539
 
228
540
  // собирает форму (упорядоченный массив дескрипторов = порядок полей) в
229
541
  // контейнер: одна .form-row на дескриптор (.form-label + контрол); onChange,
230
- // если передан, подписывается на все поля разом. hidden:true поле строится
231
- // и участвует в сабмите (fields Map), но строка не попадает в DOM
542
+ // если передан, подписывается на все поля разом. hidden:true, а также
543
+ // select/radio с единственным резолвнутым вариантом (singleOption) поле
544
+ // строится и участвует в сабмите (fields Map), но строка не попадает в DOM
545
+ // и помечается field.rendered = false (collectFormErrors такие пропускает)
232
546
  export function buildForm(descriptors, container, ctx = {}, onChange) {
233
547
  const fields = new Map();
234
548
 
@@ -252,10 +566,32 @@ export function buildForm(descriptors, container, ctx = {}, onChange) {
252
566
 
253
567
  fields.set(descriptor.name, field);
254
568
 
255
- if (descriptor.hidden) {
569
+ // пустой список вариантов — дефект схемы или каталога, а не «нечего
570
+ // выбирать»: значения у поля нет вовсе. Такое поле остаётся видимым, а
571
+ // валидация даёт ему собственное 'no options available' (см.
572
+ // validateField) — молча спрятать его значит отправить на хост пустую
573
+ // строку и получить отказ уже от игровых validators
574
+ if (field.noOptions) {
575
+ console.error(
576
+ `formBuilder: field "${descriptor.name}" resolved to no options`,
577
+ );
578
+ }
579
+
580
+ if (descriptor.hidden || field.singleOption) {
581
+ field.rendered = false;
582
+
583
+ // строки нет — ошибку показать некому (её не видно и не исправить), но
584
+ // и пустая строка не значение: пусть ключ вообще не попадёт в сабмит,
585
+ // на хосте останется roomDefaults/значение схемы
586
+ if (field.noOptions) {
587
+ fields.delete(descriptor.name);
588
+ }
589
+
256
590
  return;
257
591
  }
258
592
 
593
+ field.rendered = true;
594
+
259
595
  const row = document.createElement('div');
260
596
 
261
597
  row.className = 'form-row';
@@ -266,7 +602,7 @@ export function buildForm(descriptors, container, ctx = {}, onChange) {
266
602
  const label = document.createElement(field.labelFor ? 'label' : 'span');
267
603
 
268
604
  label.className = 'form-label';
269
- label.textContent = (descriptor.label || descriptor.name) + (descriptor.unit === 's' ? ' (s)' : '');
605
+ label.textContent = (descriptor.label || descriptor.name) + buildLabelSuffix(descriptor);
270
606
 
271
607
  if (field.labelFor) {
272
608
  label.htmlFor = field.labelFor;