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.
- 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/components/view/Lobby.js +9 -0
- package/src/client/lib/authParams.js +41 -0
- package/src/client/lib/engineVersion.js +11 -0
- package/src/client/lib/formBuilder.js +362 -26
- package/src/client/main.js +39 -21
- package/src/client/style.css +44 -13
- package/src/client/views/gameShell.js +4 -1
- package/src/client/views/includes/auth.pug +4 -1
- package/src/client/views/includes/lobby.pug +57 -50
- package/src/config/lobby.js +4 -0
- 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,22 @@ socketMethods[PS_AUTH_DATA] = data => {
|
|
|
425
422
|
|
|
426
423
|
document.getElementById('logo').textContent = texts?.title || 'VIMP';
|
|
427
424
|
|
|
428
|
-
|
|
429
|
-
|
|
425
|
+
// версия пакета игры в футере формы входа. Здесь, а не в AuthView: его
|
|
426
|
+
// elems приходят из authSchema.elems игрового плагина, и футер каркаса —
|
|
427
|
+
// не его забота. Игра, собранная до появления packageVersion, оставляет
|
|
428
|
+
// строку пустой (#auth-link держит раскладку через space-between)
|
|
429
|
+
const authVersion = document.getElementById('auth-version');
|
|
430
430
|
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
1846
|
+
roomFormDescriptors = mergeRoomDefaults(
|
|
1838
1847
|
manifest.roomForm,
|
|
1839
1848
|
manifest.roomDefaults,
|
|
1840
1849
|
);
|
|
1841
1850
|
|
|
1842
|
-
roomFormFields = buildForm(
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2056
|
+
// активации игры: неверная форма не должна стоить загрузки плагина.
|
|
2057
|
+
// arm() и рисует ошибки, и снимает фильтр «только тронутые поля»: клик —
|
|
2058
|
+
// это ответ за форму целиком, включая поля, которых игрок не касался.
|
|
2059
|
+
// Он же чистит блок, когда ошибок нет
|
|
2060
|
+
if (liveErrors.arm().length) {
|
|
2043
2061
|
return;
|
|
2044
2062
|
}
|
|
2045
2063
|
|