vimp-engine 0.14.3 → 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/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/components/model/Auth.js +7 -14
- package/src/client/components/view/Auth.js +29 -19
- package/src/client/lib/authParams.js +41 -0
- package/src/client/lib/formBuilder.js +362 -26
- package/src/client/main.js +28 -22
- package/src/client/style.css +10 -12
- package/src/client/views/gameShell.js +1 -1
- package/src/client/views/includes/auth.pug +1 -1
- package/src/client/views/includes/lobby.pug +6 -5
- package/src/devtools/contract/index.js +28 -5
- package/src/devtools/contract/result.js +8 -2
- package/src/devtools/contract/rules/b5-room-form.js +17 -1
- package/src/devtools/contract/rules/c10-auth-schema.js +26 -3
- package/src/devtools/contract/rules/c4-component-dependencies.js +42 -11
- package/src/devtools/contract/rules/c6-stat-columns.js +91 -16
- package/src/host/PortMachine.js +17 -1
- package/src/lib/formOptions.js +16 -0
- package/src/lib/formPattern.js +16 -0
- package/src/lib/validators.js +112 -7
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
450
|
+
return;
|
|
217
451
|
}
|
|
218
452
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
605
|
+
label.textContent = (descriptor.label || descriptor.name) + buildLabelSuffix(descriptor);
|
|
270
606
|
|
|
271
607
|
if (field.labelFor) {
|
|
272
608
|
label.htmlFor = field.labelFor;
|
package/src/client/main.js
CHANGED
|
@@ -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
|
-
|
|
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,10 @@ socketMethods[PS_AUTH_DATA] = data => {
|
|
|
425
422
|
|
|
426
423
|
document.getElementById('logo').textContent = texts?.title || 'VIMP';
|
|
427
424
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
param.value = localStorage[storage] || param.value || '';
|
|
433
|
-
}
|
|
434
|
-
});
|
|
425
|
+
// память клиента (localStorage) + принудительное значение поля с
|
|
426
|
+
// единственным вариантом. Здесь, а не в AuthView: solo-путь ниже отвечает
|
|
427
|
+
// хосту вообще без формы и обязан прийти к тем же значениям
|
|
428
|
+
normalizeAuthParams(params);
|
|
435
429
|
|
|
436
430
|
// solo: формы нет — отвечаем дефолтами схемы, перекрытыми boot.autoAuth
|
|
437
431
|
if (boot.autoAuth) {
|
|
@@ -1822,6 +1816,9 @@ let leaderboardReqId = 0;
|
|
|
1822
1816
|
|
|
1823
1817
|
// поля формы комнаты, сгенерированные по manifest.roomForm: key -> field
|
|
1824
1818
|
let roomFormFields = new Map();
|
|
1819
|
+
// дескрипторы той же формы (после mergeRoomDefaults) — нужны отдельно для
|
|
1820
|
+
// валидации (bindLiveErrors по ходу правки и при сабмите)
|
|
1821
|
+
let roomFormDescriptors = [];
|
|
1825
1822
|
|
|
1826
1823
|
// генерирует форму создания комнаты по явной схеме манифеста (roomForm,
|
|
1827
1824
|
// docs/en/plugin-api.md "Form schema") — движок не выводит контролы из типа
|
|
@@ -1834,12 +1831,12 @@ function populateRoomForm(manifest) {
|
|
|
1834
1831
|
}
|
|
1835
1832
|
|
|
1836
1833
|
if (Array.isArray(manifest.roomForm)) {
|
|
1837
|
-
|
|
1834
|
+
roomFormDescriptors = mergeRoomDefaults(
|
|
1838
1835
|
manifest.roomForm,
|
|
1839
1836
|
manifest.roomDefaults,
|
|
1840
1837
|
);
|
|
1841
1838
|
|
|
1842
|
-
roomFormFields = buildForm(
|
|
1839
|
+
roomFormFields = buildForm(roomFormDescriptors, container, {
|
|
1843
1840
|
sources: { maps: manifest.maps?.list },
|
|
1844
1841
|
});
|
|
1845
1842
|
} else {
|
|
@@ -1850,6 +1847,7 @@ function populateRoomForm(manifest) {
|
|
|
1850
1847
|
);
|
|
1851
1848
|
container.textContent = '';
|
|
1852
1849
|
roomFormFields = new Map();
|
|
1850
|
+
roomFormDescriptors = [];
|
|
1853
1851
|
}
|
|
1854
1852
|
}
|
|
1855
1853
|
|
|
@@ -1870,7 +1868,6 @@ function showLobbyError(text) {
|
|
|
1870
1868
|
|
|
1871
1869
|
if (elem) {
|
|
1872
1870
|
elem.textContent = text;
|
|
1873
|
-
elem.style.display = '';
|
|
1874
1871
|
}
|
|
1875
1872
|
}
|
|
1876
1873
|
|
|
@@ -1879,7 +1876,6 @@ function clearLobbyError() {
|
|
|
1879
1876
|
|
|
1880
1877
|
if (elem) {
|
|
1881
1878
|
elem.textContent = '';
|
|
1882
|
-
elem.style.display = 'none';
|
|
1883
1879
|
}
|
|
1884
1880
|
}
|
|
1885
1881
|
|
|
@@ -2005,6 +2001,14 @@ function initLobby() {
|
|
|
2005
2001
|
// первого gameChanged/рендера Leaderboard
|
|
2006
2002
|
lobbyView.setSelfNick(lobbyAuthModel.getNick());
|
|
2007
2003
|
|
|
2004
|
+
// ошибки формы видны по ходу правки (formBuilder.bindLiveErrors): до
|
|
2005
|
+
// первого клика по Create server — по тронутым полям, после — по всей форме
|
|
2006
|
+
const liveErrors = bindLiveErrors(
|
|
2007
|
+
document.getElementById(lobbyConfig.elems.fieldsId),
|
|
2008
|
+
document.getElementById(lobbyConfig.elems.errorId),
|
|
2009
|
+
() => ({ descriptors: roomFormDescriptors, fields: roomFormFields }),
|
|
2010
|
+
);
|
|
2011
|
+
|
|
2008
2012
|
// создание комнаты в этой же вкладке (хост-игрок через loopback)
|
|
2009
2013
|
populateGameSelect();
|
|
2010
2014
|
populateRoomForm(activeGameManifest);
|
|
@@ -2025,21 +2029,23 @@ function initLobby() {
|
|
|
2025
2029
|
|
|
2026
2030
|
populateRoomForm(manifest);
|
|
2027
2031
|
lobby.gameChanged(manifest.id, manifest.title);
|
|
2028
|
-
|
|
2032
|
+
// форма пересобрана: она снова ничья, а блок ошибок чистит сам disarm
|
|
2033
|
+
liveErrors.disarm();
|
|
2029
2034
|
});
|
|
2030
2035
|
|
|
2031
2036
|
const nameInput = document.getElementById(lobbyConfig.elems.nameId);
|
|
2032
2037
|
|
|
2033
2038
|
hostBtn?.addEventListener('click', async () => {
|
|
2034
|
-
//
|
|
2039
|
+
// валидация (pattern/required/min/max) — единственная граница
|
|
2035
2040
|
// room-формы: она едет клиенту как JSON манифеста, JS-валидаторы
|
|
2036
2041
|
// (как в auth-форме) туда не сериализуются (docs/en/plugin-api.md
|
|
2037
2042
|
// "Form schema"); авторитетный клампинг всё равно в applyRoomOverrides.js
|
|
2038
2043
|
// (вызывается из host.worker.js при создании комнаты). Проверяем до
|
|
2039
|
-
// активации игры: неверная форма не должна стоить загрузки
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2044
|
+
// активации игры: неверная форма не должна стоить загрузки плагина.
|
|
2045
|
+
// arm() и рисует ошибки, и снимает фильтр «только тронутые поля»: клик —
|
|
2046
|
+
// это ответ за форму целиком, включая поля, которых игрок не касался.
|
|
2047
|
+
// Он же чистит блок, когда ошибок нет
|
|
2048
|
+
if (liveErrors.arm().length) {
|
|
2043
2049
|
return;
|
|
2044
2050
|
}
|
|
2045
2051
|
|