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 +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 +7 -11
- 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
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
|
|
package/src/client/style.css
CHANGED
|
@@ -453,15 +453,19 @@ body.hide-cursor {
|
|
|
453
453
|
z-index: 8;
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
-
#auth-error
|
|
456
|
+
/* общий стиль ошибок формы — #lobby-error и #auth-error (formBuilder.js
|
|
457
|
+
renderFormErrors рендерит в них один и тот же список <div>) */
|
|
458
|
+
.form-error {
|
|
457
459
|
color: #e00;
|
|
458
460
|
font-size: 12px;
|
|
459
461
|
line-height: 15px;
|
|
460
462
|
margin: 0 5px;
|
|
461
463
|
}
|
|
462
464
|
|
|
463
|
-
#
|
|
464
|
-
|
|
465
|
+
/* пустой блок ошибок не должен занимать место: #lobby-error — флекс-элемент
|
|
466
|
+
#lobby-create с gap:8px, и одного обнуления margin там мало */
|
|
467
|
+
.form-error:empty {
|
|
468
|
+
display: none;
|
|
465
469
|
}
|
|
466
470
|
|
|
467
471
|
#auth-enter {
|
|
@@ -621,14 +625,6 @@ body.hide-cursor {
|
|
|
621
625
|
margin: 16px auto 0;
|
|
622
626
|
}
|
|
623
627
|
|
|
624
|
-
#lobby-error {
|
|
625
|
-
margin: 8px 0 0;
|
|
626
|
-
color: #d98282;
|
|
627
|
-
font-size: 12px;
|
|
628
|
-
line-height: 16px;
|
|
629
|
-
text-align: center;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
628
|
#lobby-search {
|
|
633
629
|
display: block;
|
|
634
630
|
width: 100%;
|
|
@@ -43,7 +43,7 @@ const SHELL = [
|
|
|
43
43
|
children: [
|
|
44
44
|
// заголовок и help-секции заполняет AuthView из authSchema.texts
|
|
45
45
|
{ tag: 'h2', id: 'auth-title' },
|
|
46
|
-
{ tag: 'div', id: 'auth-error' },
|
|
46
|
+
{ tag: 'div', id: 'auth-error', attrs: { class: 'form-error' } },
|
|
47
47
|
{ tag: 'div', id: 'auth-fields' },
|
|
48
48
|
{
|
|
49
49
|
tag: 'input',
|
|
@@ -26,12 +26,13 @@ div#lobby(style='display:none')
|
|
|
26
26
|
//- поля настроек комнаты генерируются по явной схеме roomForm
|
|
27
27
|
//- манифеста активной игры (docs/en/plugin-api.md "Form schema")
|
|
28
28
|
div#lobby-fields
|
|
29
|
-
|
|
30
|
-
//-
|
|
31
|
-
//- повторный клик), поэтому строкой в форме, а не #tech-informer —
|
|
29
|
+
//- ошибки формы (formBuilder.renderFormErrors) и отказ загрузки
|
|
30
|
+
//- плагина выбранной игры — строкой в форме, а не в #tech-informer:
|
|
32
31
|
//- тот кроет вкладку непрозрачным слоем и рассчитан на терминальные
|
|
33
|
-
//-
|
|
34
|
-
|
|
32
|
+
//- причины, а эти восстановимы (поправить поле, другая игра,
|
|
33
|
+
//- повторный клик). Над кнопкой — рядом с полями, к которым относятся
|
|
34
|
+
div#lobby-error.form-error
|
|
35
|
+
input#lobby-host(type='button', value='Create server')
|
|
35
36
|
//- правая панель: вкладки Active Servers / Leaderboard (lobby-page-plan)
|
|
36
37
|
div#lobby-browser-panel.card
|
|
37
38
|
//- абсолютный слой: высоту .card задаёт форма слева (stretch), а
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadContext } from './loadContext.js';
|
|
2
2
|
import { rules } from './rules/index.js';
|
|
3
|
-
import { ERROR, FAIL, summarize } from './result.js';
|
|
3
|
+
import { ERROR, FAIL, WARN, summarize } from './result.js';
|
|
4
4
|
|
|
5
5
|
// Статическая проверка контракта «движок ↔ игра»: то же намерение, что у
|
|
6
6
|
// headless-раннера (молчаливое нарушение обязано становиться строкой),
|
|
@@ -35,20 +35,43 @@ export async function checkContract(gameDir) {
|
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Вердикт может нести собственный уровень (см. verdict) — но только один из
|
|
39
|
+
// двух известных: неизвестное значение молча перестало бы блокировать прогон
|
|
40
|
+
// (hasBlockingFailure сравнивает с ERROR), то есть опечатка в правиле
|
|
41
|
+
// отключала бы его без единой строки. Поэтому она громкая, а уровень берётся
|
|
42
|
+
// объявленный правилом.
|
|
43
|
+
function resolveLevel(level, rule) {
|
|
44
|
+
if (level === undefined) {
|
|
45
|
+
return rule.level;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (level !== ERROR && level !== WARN) {
|
|
49
|
+
console.error(
|
|
50
|
+
`contract: rule ${rule.id} returned unknown level "${level}" — ` +
|
|
51
|
+
`falling back to "${rule.level}"`,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return rule.level;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return level;
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
/**
|
|
39
61
|
* Прогон правил по готовому контексту (тесты собирают его вручную).
|
|
40
62
|
* @param {Object} ctx
|
|
63
|
+
* @param {Array<Object>} [ruleList] - Набор правил; по умолчанию все.
|
|
41
64
|
* @returns {Array<Object>} Результаты правил.
|
|
42
65
|
*/
|
|
43
|
-
export function runRules(ctx) {
|
|
44
|
-
return
|
|
45
|
-
const { status, violations, note } = rule.check(ctx);
|
|
66
|
+
export function runRules(ctx, ruleList = rules) {
|
|
67
|
+
return ruleList.map(rule => {
|
|
68
|
+
const { status, violations, note, level } = rule.check(ctx);
|
|
46
69
|
|
|
47
70
|
return {
|
|
48
71
|
id: rule.id,
|
|
49
72
|
name: rule.name,
|
|
50
73
|
title: rule.title,
|
|
51
|
-
level:
|
|
74
|
+
level: resolveLevel(level, rule),
|
|
52
75
|
status,
|
|
53
76
|
violations,
|
|
54
77
|
note,
|
|
@@ -24,15 +24,21 @@ export function skip(note) {
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Вердикт по списку нарушений: пустой список — pass.
|
|
27
|
+
* `level` перекрывает уровень правила на этом прогоне: у правила он один на
|
|
28
|
+
* все случаи, а бывает, что нарушение доказуемо только частично (C4 не может
|
|
29
|
+
* перечислить игровые сервисы, не запустив игровой код) — такое обязано
|
|
30
|
+
* попасть в отчёт, но не обязано валить прогон.
|
|
27
31
|
* @param {Array<string>} violations
|
|
28
32
|
* @param {string} [note]
|
|
29
|
-
* @
|
|
33
|
+
* @param {string} [level] - ERROR или WARN; по умолчанию — уровень правила.
|
|
34
|
+
* @returns {Object} { status, violations, note, level }.
|
|
30
35
|
*/
|
|
31
|
-
export function verdict(violations, note) {
|
|
36
|
+
export function verdict(violations, note, level) {
|
|
32
37
|
return {
|
|
33
38
|
status: violations.length ? FAIL : PASS,
|
|
34
39
|
violations,
|
|
35
40
|
note,
|
|
41
|
+
level,
|
|
36
42
|
};
|
|
37
43
|
}
|
|
38
44
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { ERROR, skip, verdict } from '../result.js';
|
|
2
|
+
import { anchorPattern } from '../../../lib/formPattern.js';
|
|
2
3
|
|
|
3
4
|
// roomForm. Поле вне белого списка форма показывает, лобби отправляет, а
|
|
4
5
|
// хост молча выбрасывает — правило игры, построенное на своей настройке
|
|
5
6
|
// комнаты, не работает и ничего об этом не сообщает. Контролов в v3 ровно
|
|
6
|
-
// четыре: неизвестный пропускается с console.error.
|
|
7
|
+
// четыре: неизвестный пропускается с console.error. `regExp` едет в манифесте
|
|
8
|
+
// строкой и компилируется уже у игрока — некомпилируемая ловится здесь.
|
|
7
9
|
const HONOURED = ['maps', 'maxPlayers', 'map', 'roundTime', 'mapTime', 'friendlyFire'];
|
|
8
10
|
const CONTROLS = ['text', 'select', 'checkbox', 'radio'];
|
|
9
11
|
|
|
@@ -36,6 +38,20 @@ export default {
|
|
|
36
38
|
`not exist (${CONTROLS.join(', ')})`,
|
|
37
39
|
);
|
|
38
40
|
}
|
|
41
|
+
|
|
42
|
+
if (field.regExp !== undefined) {
|
|
43
|
+
try {
|
|
44
|
+
// ровно та форма, что компилирует клиент (общий anchorPattern):
|
|
45
|
+
// иначе «компилируется» у чекера и у игрока разъедется молча
|
|
46
|
+
anchorPattern(field.regExp);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
violations.push(
|
|
49
|
+
`roomForm field "${field.name}": regExp "${field.regExp}" does ` +
|
|
50
|
+
`not compile (${e.message}) — the client drops the check with ` +
|
|
51
|
+
'a console.error, so the field ends up with no pattern at all',
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
39
55
|
}
|
|
40
56
|
|
|
41
57
|
return verdict(violations);
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { ERROR, skip, verdict } from '../result.js';
|
|
2
|
+
import { resolveValidator } from '../../../lib/validators.js';
|
|
2
3
|
|
|
3
|
-
// authSchema.
|
|
4
|
+
// authSchema. Четыре ошибки, каждая из которых уже случалась:
|
|
4
5
|
// formId вместо fieldsId (контейнер резолвится в null и экран авторизации
|
|
5
6
|
// умирает TypeError на первом рендере), поле ника (личность приходит из
|
|
6
7
|
// JWT лобби) и поле выбора модели под своим именем — движок читает
|
|
7
|
-
// params.model, всё остальное до Participant не
|
|
8
|
+
// params.model, всё остальное до Participant не доезжает, и имя валидатора,
|
|
9
|
+
// которого нет в authSchema.validators (поле не проверяется никем).
|
|
8
10
|
const NICKNAME = /^(name|nick|nickname|player|playername|login|username)$/i;
|
|
9
11
|
|
|
10
12
|
export default {
|
|
11
13
|
id: 'C10',
|
|
12
14
|
name: 'authSchema',
|
|
13
15
|
level: ERROR,
|
|
14
|
-
title:
|
|
16
|
+
title:
|
|
17
|
+
'authSchema: fieldsId, no nickname field, the model field, resolvable ' +
|
|
18
|
+
'validators',
|
|
15
19
|
|
|
16
20
|
check(ctx) {
|
|
17
21
|
if (!ctx.authSchema) {
|
|
@@ -34,6 +38,8 @@ export default {
|
|
|
34
38
|
);
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
const validators = ctx.authSchema.validators;
|
|
42
|
+
|
|
37
43
|
for (const field of params) {
|
|
38
44
|
if (NICKNAME.test(field.name)) {
|
|
39
45
|
violations.push(
|
|
@@ -41,6 +47,23 @@ export default {
|
|
|
41
47
|
'identity comes from the lobby JWT',
|
|
42
48
|
);
|
|
43
49
|
}
|
|
50
|
+
|
|
51
|
+
const validatorName = field.options?.validator;
|
|
52
|
+
|
|
53
|
+
// опечатка в имени (как и не-функция под верным именем) = поле не
|
|
54
|
+
// проверяется никем: validateAuth пропускает нерезолвнутый валидатор
|
|
55
|
+
// молча. Резолвер — тот же, которым зовёт хост (lib/validators.js),
|
|
56
|
+
// чтобы правило не обещало того, чего не проверяет
|
|
57
|
+
if (
|
|
58
|
+
validatorName !== undefined &&
|
|
59
|
+
!resolveValidator(validatorName, validators)
|
|
60
|
+
) {
|
|
61
|
+
violations.push(
|
|
62
|
+
`authSchema param "${field.name}" names validator ` +
|
|
63
|
+
`"${validatorName}", which authSchema.validators does not ` +
|
|
64
|
+
'provide as a function — the host skips the check silently',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
44
67
|
}
|
|
45
68
|
|
|
46
69
|
if (!params.some(field => field.name === 'model')) {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { ERROR, skip, verdict } from '../result.js';
|
|
1
|
+
import { ERROR, WARN, skip, verdict } from '../result.js';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Движковых сервисов ровно четыре (client/main.js), но пул ими не
|
|
4
|
+
// исчерпывается: игра доливает туда свои через ClientPlugin.hooks.services(core)
|
|
5
|
+
// — например геометрию предсказанной динамики карты
|
|
6
|
+
// (docs/en/plugin-api.md «hooks.services»). Незнакомое имя не ошибка для
|
|
7
|
+
// движка: part получает undefined и рисует пустоту — карта без assetsBase
|
|
8
|
+
// выглядит как чистый холст без единой строки в консоли, ради этого правило
|
|
9
|
+
// и существует.
|
|
6
10
|
const SERVICES = ['renderer', 'soundManager', 'assetsBase', 'localPlayer'];
|
|
7
11
|
|
|
8
12
|
export default {
|
|
@@ -19,15 +23,42 @@ export default {
|
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
// раскладка «сервис → парты, которым он нужен» (client.js игры)
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
26
|
+
const unknown = Object.keys(deps).filter(
|
|
27
|
+
service => !SERVICES.includes(service),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
if (unknown.length === 0) {
|
|
31
|
+
return verdict([]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const pool = SERVICES.join(', ');
|
|
35
|
+
// hooks.services(core) требует живой инстанс ядра, а игровой код чекер не
|
|
36
|
+
// запускает — статически перечислить игровые сервисы нечем. Поэтому при
|
|
37
|
+
// объявленном хуке имя вне движкового пула остаётся строкой отчёта, но
|
|
38
|
+
// прогон не валит: иначе правило краснеет на штатном механизме движка
|
|
39
|
+
if (typeof ctx.clientPlugin?.hooks?.services === 'function') {
|
|
40
|
+
return verdict(
|
|
41
|
+
unknown.map(
|
|
42
|
+
service =>
|
|
43
|
+
`componentDependencies declares service "${service}" — not one of ` +
|
|
44
|
+
`the engine's (${pool}), so ClientPlugin.hooks.services() has to ` +
|
|
45
|
+
'return it; verifying that needs a live core, which this check ' +
|
|
46
|
+
'has no way to build (a service nothing provides is silently ' +
|
|
47
|
+
'undefined in the part)',
|
|
48
|
+
),
|
|
49
|
+
'the plugin declares hooks.services(), so the pool is not statically known',
|
|
50
|
+
WARN,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return verdict(
|
|
55
|
+
unknown.map(
|
|
25
56
|
service =>
|
|
26
57
|
`componentDependencies declares service "${service}" — the engine ` +
|
|
27
|
-
`provides only ${
|
|
58
|
+
`provides only ${pool}, and this plugin declares no ` +
|
|
59
|
+
'hooks.services() to add its own (an unknown one is silently ' +
|
|
28
60
|
'undefined in the part)',
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
return verdict(violations);
|
|
61
|
+
),
|
|
62
|
+
);
|
|
32
63
|
},
|
|
33
64
|
};
|
|
@@ -1,13 +1,77 @@
|
|
|
1
1
|
import { WARN, skip, verdict } from '../result.js';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Сколько в статистике колонок — решение игры: их объявляет её схема, а
|
|
4
|
+
// движковые записи в необъявленную колонку просто игнорируются
|
|
5
|
+
// (host/meta/modules/Stat.js, Д7). Своя у движка только вёрстка — style.css
|
|
6
|
+
// раздаёт ширины пяти колонкам (#stat …:nth-child(1)…(5)), и шестая без
|
|
7
|
+
// собственных стилей схлопывается в ноль. Поэтому правило проверяет не число
|
|
8
|
+
// колонок, а покрыта ли раскладка: игра со своими ClientPlugin.styles вправе
|
|
9
|
+
// объявить сколько угодно. Ширина — соглашение, а не отказ, отсюда warn.
|
|
10
|
+
const ENGINE_COLUMNS = 5;
|
|
11
|
+
|
|
12
|
+
// Правило разбирает CSS плагина эвристикой, а не парсером: «колонку кроет
|
|
13
|
+
// объявление ширины на селекторе с #stat и ячейкой».
|
|
14
|
+
// Пара «селектор + тело»: [^{}] не перешагивает вложенную скобку, поэтому
|
|
15
|
+
// matchAll ловит именно внутреннее правило, в том числе завёрнутое в
|
|
16
|
+
// @media/@supports (разбор по split('}') видел там обёртку и терял селектор).
|
|
17
|
+
const RULE = /([^{}]+)\{([^{}]*)\}/g;
|
|
18
|
+
// ячейка колонки: движковая раскладка адресует их как `#stat … td|span`
|
|
19
|
+
const CELL = /(?:^|[\s.#>+~])(?:td|th|span)(?=[\s.:#>+~,[]|$)/;
|
|
20
|
+
// правило про раскладку, а не про цвет: колонку кроет только объявление
|
|
21
|
+
// ширины или её флекс-эквивалент
|
|
22
|
+
const WIDTH =
|
|
23
|
+
/(?:^|[\s;])(?:width|min-width|max-width|flex|flex-basis|flex-grow)\s*:/;
|
|
24
|
+
// трек-лист грида задаёт ширины всем колонкам разом и стоит на контейнере
|
|
25
|
+
// (`#stat table`, `#stat .stat-head`), который CELL по определению не
|
|
26
|
+
// проходит, — поэтому отдельной веткой, а не пунктом в WIDTH
|
|
27
|
+
const GRID = /(?:^|[\s;])grid-template-columns\s*:/;
|
|
28
|
+
|
|
29
|
+
// Индексы колонок, которым стили плагина задают ширину. Селектор ячеек без
|
|
30
|
+
// nth-child ('#stat table td') правит всю таблицу разом.
|
|
31
|
+
function styledColumns(styles, total) {
|
|
32
|
+
const covered = new Set();
|
|
33
|
+
|
|
34
|
+
for (const [, selector, body] of String(styles || '').matchAll(RULE)) {
|
|
35
|
+
if (!selector.includes('#stat')) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// грид на контейнере перечисляет все треки сразу — колонки покрыты все
|
|
40
|
+
if (GRID.test(body)) {
|
|
41
|
+
for (let index = 1; index <= total; index += 1) {
|
|
42
|
+
covered.add(index);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!CELL.test(selector) || !WIDTH.test(body)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const indexes = [...selector.matchAll(/nth-child\((\d+)\)/g)];
|
|
53
|
+
|
|
54
|
+
if (indexes.length === 0) {
|
|
55
|
+
for (let index = 1; index <= total; index += 1) {
|
|
56
|
+
covered.add(index);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
for (const [, index] of indexes) {
|
|
63
|
+
covered.add(Number(index));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return covered;
|
|
68
|
+
}
|
|
69
|
+
|
|
6
70
|
export default {
|
|
7
71
|
id: 'C6',
|
|
8
72
|
name: 'statColumns',
|
|
9
73
|
level: WARN,
|
|
10
|
-
title: 'stat
|
|
74
|
+
title: 'stat columns past the engine layout are styled by the plugin',
|
|
11
75
|
|
|
12
76
|
check(ctx) {
|
|
13
77
|
const columns = ctx.clientConfig?.modules?.stat?.params?.columns;
|
|
@@ -16,21 +80,32 @@ export default {
|
|
|
16
80
|
return skip('no client stat columns');
|
|
17
81
|
}
|
|
18
82
|
|
|
19
|
-
|
|
83
|
+
// меньше пяти — движковых ширин просто хватает с запасом
|
|
84
|
+
if (columns.length <= ENGINE_COLUMNS) {
|
|
20
85
|
return verdict([]);
|
|
21
86
|
}
|
|
22
87
|
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
88
|
+
const covered = styledColumns(ctx.clientPlugin?.styles, columns.length);
|
|
89
|
+
const missing = [];
|
|
90
|
+
|
|
91
|
+
for (let index = ENGINE_COLUMNS + 1; index <= columns.length; index += 1) {
|
|
92
|
+
if (!covered.has(index)) {
|
|
93
|
+
missing.push(index);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (missing.length === 0) {
|
|
98
|
+
return verdict(
|
|
99
|
+
[],
|
|
100
|
+
`${columns.length} columns, laid out by the plugin's own styles`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
26
103
|
|
|
27
|
-
return verdict(
|
|
28
|
-
|
|
29
|
-
`
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
note,
|
|
34
|
-
);
|
|
104
|
+
return verdict([
|
|
105
|
+
`stat declares ${columns.length} columns, but ClientPlugin.styles ` +
|
|
106
|
+
`gives no width to column(s) ${missing.join(', ')}: the engine CSS ` +
|
|
107
|
+
`lays out ${ENGINE_COLUMNS} (#stat …:nth-child(1)…(${ENGINE_COLUMNS})), ` +
|
|
108
|
+
'so the rest are rendered with no width of their own',
|
|
109
|
+
]);
|
|
35
110
|
},
|
|
36
111
|
};
|
package/src/host/PortMachine.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import wsports from '../config/wsports.js';
|
|
9
9
|
import closeCodes from '../config/closeCodes.js';
|
|
10
|
-
import { validateAuth } from '../lib/validators.js';
|
|
10
|
+
import { resolveValidator, validateAuth } from '../lib/validators.js';
|
|
11
11
|
|
|
12
12
|
// PC (client ports): порты получения данных от клиента
|
|
13
13
|
const PC_CONFIG_READY = wsports.client.CONFIG_READY;
|
|
@@ -49,6 +49,22 @@ export default class PortMachine {
|
|
|
49
49
|
...(authSchema.params ?? []),
|
|
50
50
|
];
|
|
51
51
|
|
|
52
|
+
// Правило C10 говорит это статически, но контракт-чекер запускают не
|
|
53
|
+
// все: нерезолвнутое имя валидатора означает поле, которое не проверяет
|
|
54
|
+
// никто (validateAuth пропускает его молча — для клиента это норма)
|
|
55
|
+
for (const { name, options } of this._authParams) {
|
|
56
|
+
if (
|
|
57
|
+
options?.validator &&
|
|
58
|
+
!resolveValidator(options.validator, authSchema.validators)
|
|
59
|
+
) {
|
|
60
|
+
console.error(
|
|
61
|
+
`PortMachine: authSchema param "${name}" names validator ` +
|
|
62
|
+
`"${options.validator}", which authSchema.validators does not ` +
|
|
63
|
+
'provide — the field is checked by nobody',
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
52
68
|
// состояние подключений: socketId → { gameId, methods, enabled }
|
|
53
69
|
this._clients = new Map();
|
|
54
70
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Разбор списка вариантов дескриптора поля — общий для формы
|
|
2
|
+
// (client/lib/formBuilder.js) и для авторитетной проверки
|
|
3
|
+
// (lib/validators.js): вариант пишут либо строкой, либо { value, label }.
|
|
4
|
+
// Живёт в lib по той же причине, что и formPattern.js: хосту нельзя
|
|
5
|
+
// импортировать слой клиента, а разъехаться эти два разбора не должны —
|
|
6
|
+
// форма отдаёт ровно то, что хост потом сверяет со списком.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {Array} [list] - Список вариантов из дескриптора (options/source).
|
|
10
|
+
* @returns {Array<{value: *, label: string}>} Нормализованные варианты.
|
|
11
|
+
*/
|
|
12
|
+
export function normalizeOptions(list) {
|
|
13
|
+
return (list || []).map(opt =>
|
|
14
|
+
opt !== null && typeof opt === 'object' ? opt : { value: opt, label: String(opt) },
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Якорная форма паттерна поля формы — общая для клиента и контракт-чекера.
|
|
2
|
+
//
|
|
3
|
+
// Браузер применяет атрибут `pattern` ко ВСЕЙ строке, оборачивая паттерн в
|
|
4
|
+
// `^(?:…)$` сам. Движок повторяет это за ним (formBuilder.validateField), а
|
|
5
|
+
// правило B5 обязано компилировать ровно ту же форму: иначе «компилируется»
|
|
6
|
+
// у чекера и у клиента разъедется молча, и правило начнёт обещать то, чего
|
|
7
|
+
// не проверяет.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} regExp - Паттерн из дескриптора поля (строка, не RegExp).
|
|
11
|
+
* @returns {RegExp} Скомпилированный паттерн, привязанный ко всей строке.
|
|
12
|
+
* @throws {SyntaxError} Если паттерн не компилируется.
|
|
13
|
+
*/
|
|
14
|
+
export function anchorPattern(regExp) {
|
|
15
|
+
return new RegExp(`^(?:${regExp})$`);
|
|
16
|
+
}
|