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.
@@ -46,7 +46,9 @@ body.hide-cursor {
46
46
  background-color: #fff;
47
47
  text-align: center;
48
48
  font-size: 14px;
49
- line-height: 12px;
49
+ /* межстрочный не меньше кегля: при 12px глифы 14px вылезали за строку,
50
+ и ячейки панели держались только на её height/overflow */
51
+ line-height: 16px;
50
52
  position: absolute;
51
53
  top: 0;
52
54
  left: 0;
@@ -451,15 +453,19 @@ body.hide-cursor {
451
453
  z-index: 8;
452
454
  }
453
455
 
454
- #auth-error {
456
+ /* общий стиль ошибок формы — #lobby-error и #auth-error (formBuilder.js
457
+ renderFormErrors рендерит в них один и тот же список <div>) */
458
+ .form-error {
455
459
  color: #e00;
456
460
  font-size: 12px;
457
461
  line-height: 15px;
458
462
  margin: 0 5px;
459
463
  }
460
464
 
461
- #auth-error:empty {
462
- margin: 0;
465
+ /* пустой блок ошибок не должен занимать место: #lobby-error — флекс-элемент
466
+ #lobby-create с gap:8px, и одного обнуления margin там мало */
467
+ .form-error:empty {
468
+ display: none;
463
469
  }
464
470
 
465
471
  #auth-enter {
@@ -619,14 +625,6 @@ body.hide-cursor {
619
625
  margin: 16px auto 0;
620
626
  }
621
627
 
622
- #lobby-error {
623
- margin: 8px 0 0;
624
- color: #d98282;
625
- font-size: 12px;
626
- line-height: 16px;
627
- text-align: center;
628
- }
629
-
630
628
  #lobby-search {
631
629
  display: block;
632
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',
@@ -6,7 +6,7 @@ div#auth
6
6
 
7
7
  h2#auth-title
8
8
 
9
- div#auth-error
9
+ div#auth-error.form-error
10
10
 
11
11
  div#auth-fields
12
12
 
@@ -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
- input#lobby-host(type='button', value='Create server')
30
- //- отказ загрузки плагина выбранной игры: восстановим (другая игра,
31
- //- повторный клик), поэтому строкой в форме, а не #tech-informer —
29
+ //- ошибки формы (formBuilder.renderFormErrors) и отказ загрузки
30
+ //- плагина выбранной игры строкой в форме, а не в #tech-informer:
32
31
  //- тот кроет вкладку непрозрачным слоем и рассчитан на терминальные
33
- //- причины
34
- p#lobby-error(style='display:none')
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 rules.map(rule => {
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: rule.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
- * @returns {Object} { status, violations, note }.
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: 'authSchema: fieldsId, no nickname field, the model field',
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
- // Пул сервисов клиента ровно из четырёх имён (client/main.js). Незнакомое имя
4
- // не ошибка для движка: part получает undefined и рисует пустоту — карта
5
- // без assetsBase выглядит как чистый холст без единой строки в консоли.
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 violations = Object.keys(deps)
23
- .filter(service => !SERVICES.includes(service))
24
- .map(
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 ${SERVICES.join(', ')} (an unknown one is silently ` +
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
- // Движок пишет ровно пять имён (name, status, score, deaths, latency), и
4
- // его CSS свёрстана под пять колонок. Другое число — не отказ: игра вправе
5
- // привезти свои стили в ClientPlugin.styles. Поэтому warn, а не error.
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 declares five columns (the engine CSS assumes five)',
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
- if (columns.length === 5) {
83
+ // меньше пяти движковых ширин просто хватает с запасом
84
+ if (columns.length <= ENGINE_COLUMNS) {
20
85
  return verdict([]);
21
86
  }
22
87
 
23
- const note = ctx.clientPlugin?.styles
24
- ? 'the plugin ships its own styles'
25
- : undefined;
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
- `stat declares ${columns.length} column(s): the engine populates ` +
30
- 'exactly name, status, score, deaths, latency and its CSS is laid ' +
31
- 'out for five',
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
  };
@@ -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
+ }