vimp-engine 0.14.4 → 0.16.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.
@@ -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;
@@ -26,11 +26,8 @@ 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 {
30
- buildForm,
31
- mergeRoomDefaults,
32
- reportFormValidity,
33
- } from './lib/formBuilder.js';
29
+ import { buildForm, mergeRoomDefaults, bindLiveErrors } from './lib/formBuilder.js';
30
+ import { normalizeAuthParams } from './lib/authParams.js';
34
31
  import { createGameActivator } from './lib/gameActivator.js';
35
32
  import createAutostart from './lib/autostart.js';
36
33
  import { getBootConfig, resolveBootConfig } from './boot.js';
@@ -425,13 +422,22 @@ socketMethods[PS_AUTH_DATA] = data => {
425
422
 
426
423
  document.getElementById('logo').textContent = texts?.title || 'VIMP';
427
424
 
428
- params.forEach(param => {
429
- const { storage } = param.options;
425
+ // версия пакета игры в футере формы входа. Здесь, а не в AuthView: его
426
+ // elems приходят из authSchema.elems игрового плагина, и футер каркаса —
427
+ // не его забота. Игра, собранная до появления packageVersion, оставляет
428
+ // строку пустой (#auth-link держит раскладку через space-between)
429
+ const authVersion = document.getElementById('auth-version');
430
430
 
431
- if (storage) {
432
- param.value = localStorage[storage] || param.value || '';
433
- }
434
- });
431
+ if (authVersion) {
432
+ const packageVersion = activeGameManifest?.packageVersion;
433
+
434
+ authVersion.textContent = packageVersion ? `v${packageVersion}` : '';
435
+ }
436
+
437
+ // память клиента (localStorage) + принудительное значение поля с
438
+ // единственным вариантом. Здесь, а не в AuthView: solo-путь ниже отвечает
439
+ // хосту вообще без формы и обязан прийти к тем же значениям
440
+ normalizeAuthParams(params);
435
441
 
436
442
  // solo: формы нет — отвечаем дефолтами схемы, перекрытыми boot.autoAuth
437
443
  if (boot.autoAuth) {
@@ -1822,6 +1828,9 @@ let leaderboardReqId = 0;
1822
1828
 
1823
1829
  // поля формы комнаты, сгенерированные по manifest.roomForm: key -> field
1824
1830
  let roomFormFields = new Map();
1831
+ // дескрипторы той же формы (после mergeRoomDefaults) — нужны отдельно для
1832
+ // валидации (bindLiveErrors по ходу правки и при сабмите)
1833
+ let roomFormDescriptors = [];
1825
1834
 
1826
1835
  // генерирует форму создания комнаты по явной схеме манифеста (roomForm,
1827
1836
  // docs/en/plugin-api.md "Form schema") — движок не выводит контролы из типа
@@ -1834,12 +1843,12 @@ function populateRoomForm(manifest) {
1834
1843
  }
1835
1844
 
1836
1845
  if (Array.isArray(manifest.roomForm)) {
1837
- const descriptors = mergeRoomDefaults(
1846
+ roomFormDescriptors = mergeRoomDefaults(
1838
1847
  manifest.roomForm,
1839
1848
  manifest.roomDefaults,
1840
1849
  );
1841
1850
 
1842
- roomFormFields = buildForm(descriptors, container, {
1851
+ roomFormFields = buildForm(roomFormDescriptors, container, {
1843
1852
  sources: { maps: manifest.maps?.list },
1844
1853
  });
1845
1854
  } else {
@@ -1850,6 +1859,7 @@ function populateRoomForm(manifest) {
1850
1859
  );
1851
1860
  container.textContent = '';
1852
1861
  roomFormFields = new Map();
1862
+ roomFormDescriptors = [];
1853
1863
  }
1854
1864
  }
1855
1865
 
@@ -1870,7 +1880,6 @@ function showLobbyError(text) {
1870
1880
 
1871
1881
  if (elem) {
1872
1882
  elem.textContent = text;
1873
- elem.style.display = '';
1874
1883
  }
1875
1884
  }
1876
1885
 
@@ -1879,7 +1888,6 @@ function clearLobbyError() {
1879
1888
 
1880
1889
  if (elem) {
1881
1890
  elem.textContent = '';
1882
- elem.style.display = 'none';
1883
1891
  }
1884
1892
  }
1885
1893
 
@@ -2005,6 +2013,14 @@ function initLobby() {
2005
2013
  // первого gameChanged/рендера Leaderboard
2006
2014
  lobbyView.setSelfNick(lobbyAuthModel.getNick());
2007
2015
 
2016
+ // ошибки формы видны по ходу правки (formBuilder.bindLiveErrors): до
2017
+ // первого клика по Create server — по тронутым полям, после — по всей форме
2018
+ const liveErrors = bindLiveErrors(
2019
+ document.getElementById(lobbyConfig.elems.fieldsId),
2020
+ document.getElementById(lobbyConfig.elems.errorId),
2021
+ () => ({ descriptors: roomFormDescriptors, fields: roomFormFields }),
2022
+ );
2023
+
2008
2024
  // создание комнаты в этой же вкладке (хост-игрок через loopback)
2009
2025
  populateGameSelect();
2010
2026
  populateRoomForm(activeGameManifest);
@@ -2025,21 +2041,23 @@ function initLobby() {
2025
2041
 
2026
2042
  populateRoomForm(manifest);
2027
2043
  lobby.gameChanged(manifest.id, manifest.title);
2028
- clearLobbyError();
2044
+ // форма пересобрана: она снова ничья, а блок ошибок чистит сам disarm
2045
+ liveErrors.disarm();
2029
2046
  });
2030
2047
 
2031
2048
  const nameInput = document.getElementById(lobbyConfig.elems.nameId);
2032
2049
 
2033
2050
  hostBtn?.addEventListener('click', async () => {
2034
- // нативная валидация (pattern/required) — единственная граница
2051
+ // валидация (pattern/required/min/max) — единственная граница
2035
2052
  // room-формы: она едет клиенту как JSON манифеста, JS-валидаторы
2036
2053
  // (как в auth-форме) туда не сериализуются (docs/en/plugin-api.md
2037
2054
  // "Form schema"); авторитетный клампинг всё равно в applyRoomOverrides.js
2038
2055
  // (вызывается из host.worker.js при создании комнаты). Проверяем до
2039
- // активации игры: неверная форма не должна стоить загрузки плагина
2040
- if (
2041
- !reportFormValidity(document.getElementById(lobbyConfig.elems.fieldsId))
2042
- ) {
2056
+ // активации игры: неверная форма не должна стоить загрузки плагина.
2057
+ // arm() и рисует ошибки, и снимает фильтр «только тронутые поля»: клик —
2058
+ // это ответ за форму целиком, включая поля, которых игрок не касался.
2059
+ // Он же чистит блок, когда ошибок нет
2060
+ if (liveErrors.arm().length) {
2043
2061
  return;
2044
2062
  }
2045
2063