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.
@@ -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
- #auth-error:empty {
464
- margin: 0;
465
+ /* пустой блок ошибок не должен занимать место: #lobby-error — флекс-элемент
466
+ #lobby-create с gap:8px, и одного обнуления margin там мало */
467
+ .form-error:empty {
468
+ display: none;
465
469
  }
466
470
 
467
471
  #auth-enter {
@@ -487,11 +491,18 @@ body.hide-cursor {
487
491
  color: #ff0;
488
492
  }
489
493
 
490
- #auth-link p:nth-child(1) {
494
+ /* три колонки: GitHub — версия игры — копирайт. Позициями заведует
495
+ space-between, а не nth-child: средний элемент бывает пустым (игра
496
+ собрана без packageVersion), и порядковые правила тогда съезжали бы */
497
+ #auth-link p:first-child {
491
498
  text-align: left;
492
499
  }
493
500
 
494
- #auth-link p:nth-child(2) {
501
+ #auth-version {
502
+ text-align: center;
503
+ }
504
+
505
+ #auth-link p:last-child {
495
506
  text-align: right;
496
507
  }
497
508
 
@@ -621,14 +632,6 @@ body.hide-cursor {
621
632
  margin: 16px auto 0;
622
633
  }
623
634
 
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
635
  #lobby-search {
633
636
  display: block;
634
637
  width: 100%;
@@ -716,6 +719,14 @@ body.hide-cursor {
716
719
  color: #aaa;
717
720
  }
718
721
 
722
+ /* колоночная обёртка панелей и футера: #lobby центрирует содержимое как
723
+ флекс-строка, поэтому футер обязан лежать в одном столбце с .lobby-grid,
724
+ а не быть её соседом */
725
+ .lobby-column {
726
+ display: flex;
727
+ flex-direction: column;
728
+ }
729
+
719
730
  /* два столбца лобби (lobby-page-plan): настройки/создание слева, вкладки
720
731
  Active Servers / Leaderboard справа; собственная ширина панелей
721
732
  переопределяет фиксированный .card { width:256px } (id сильнее класса);
@@ -772,6 +783,20 @@ body.hide-cursor {
772
783
  flex-shrink: 0;
773
784
  }
774
785
 
786
+ /* версия пакета движка под панелями — тем же мелким кеглем, что и футер
787
+ формы входа (#auth-link) */
788
+ #lobby-footer {
789
+ font-size: 9px;
790
+ padding: 0 5px;
791
+ margin: 6px 0 10px;
792
+ text-align: center;
793
+ text-shadow: none;
794
+ }
795
+
796
+ #lobby-footer p {
797
+ margin: 0;
798
+ }
799
+
775
800
  @media (max-width: 800px) {
776
801
  .lobby-grid {
777
802
  flex-direction: column;
@@ -779,6 +804,12 @@ body.hide-cursor {
779
804
  gap: unset;
780
805
  }
781
806
 
807
+ /* в столбик обёртка тянется на всю ширину: иначе она сжалась бы по
808
+ содержимому и увела бы панели из центра */
809
+ .lobby-column {
810
+ width: 100%;
811
+ }
812
+
782
813
  /* в столбик высоту строкой никто не задаёт — возвращаем слой в поток,
783
814
  иначе absolute-слой схлопнулся бы в 0 */
784
815
  .lobby-browser-inner {
@@ -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',
@@ -65,6 +65,9 @@ const SHELL = [
65
65
  },
66
66
  ],
67
67
  },
68
+ // версия пакета игры (manifest.packageVersion) — текст пишет
69
+ // main.js по AUTH_DATA
70
+ { tag: 'p', id: 'auth-version' },
68
71
  { tag: 'p', text: `© ${new Date().getFullYear()} VIMP` },
69
72
  ],
70
73
  },
@@ -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
 
@@ -17,4 +17,7 @@ div#auth
17
17
  div#auth-link
18
18
  p
19
19
  a(href='https://github.com/lgick/vimp') GitHub
20
+ //- версия пакета игры (manifest.packageVersion): проставляет main.js,
21
+ //- у игры, собранной до появления поля, строка остаётся пустой
22
+ p#auth-version
20
23
  p &copy; #{new Date().getFullYear()} VIMP
@@ -1,53 +1,60 @@
1
1
  //- скрыт по умолчанию: открывается из main.js только после успешного логина
2
2
  //- в #lobby-auth (Этап B2, LobbyAuthCtrl 'authenticated')
3
3
  div#lobby(style='display:none')
4
- div.lobby-grid
5
- //- левая панель: профиль + форма создания комнаты (lobby-page-plan)
6
- div#lobby-setup-panel.card
7
- h2= 'VIMP'
8
- //- бейдж входа (Этап B2): управляется LobbyAuthView, показывается
9
- //- вместе с #lobby после успешной авторизации
10
- div#lobby-user
11
- div
12
- span= 'Signed in as '
13
- b#lobby-user-nick
14
- input#lobby-user-logout(type='button', value='Sign out')
15
- hr.lobby-divider
16
- h3.lobby-section-title= 'HOST A GAME'
17
- div#lobby-create
18
- //- селектор игры (lobby-page-plan): рабочий с первой же игрой в
19
- //- каталоге мастера, а не только когда их несколько
20
- div.form-row
21
- label.form-label(for='lobby-game')= 'Select Game'
22
- select#lobby-game.field-select
23
- div.form-row
24
- label.form-label(for='lobby-name')= 'Server name'
25
- input#lobby-name.field-text(type='text', maxlength='30')
26
- //- поля настроек комнаты генерируются по явной схеме roomForm
27
- //- манифеста активной игры (docs/en/plugin-api.md "Form schema")
28
- div#lobby-fields
29
- input#lobby-host(type='button', value='Create server')
30
- //- отказ загрузки плагина выбранной игры: восстановим (другая игра,
31
- //- повторный клик), поэтому строкой в форме, а не #tech-informer —
32
- //- тот кроет вкладку непрозрачным слоем и рассчитан на терминальные
33
- //- причины
34
- p#lobby-error(style='display:none')
35
- //- правая панель: вкладки Active Servers / Leaderboard (lobby-page-plan)
36
- div#lobby-browser-panel.card
37
- //- абсолютный слой: высоту .card задаёт форма слева (stretch), а
38
- //- список внутри скроллится, не раздувая карточку (lobby-page-plan)
39
- div.lobby-browser-inner
40
- div.lobby-tabs-header
41
- input#btn-show-servers.lobby-tab-btn.active(type='button', value='Active Servers')
42
- input#btn-show-leaderboard.lobby-tab-btn(type='button', value='Leaderboard')
43
- div#lobby-servers-content
44
- input#lobby-search.field-text(type='text', placeholder='Search servers…')
45
- ul#lobby-list
46
- p#lobby-empty= 'No servers available'
47
- input#lobby-more(type='button', value='Load more')
48
- div#lobby-leaderboard-content(style='display:none')
49
- div.lobby-leaderboard-header
50
- span#leaderboard-title
51
- span#leaderboard-total
52
- ol#lobby-leaderboard-list
53
- p#lobby-my-placement
4
+ //- колоночная обёртка: сам #lobby — флекс-строка, центрирующая содержимое,
5
+ //- и футер под панелями иначе встал бы третьей колонкой рядом с ними
6
+ div.lobby-column
7
+ div.lobby-grid
8
+ //- левая панель: профиль + форма создания комнаты (lobby-page-plan)
9
+ div#lobby-setup-panel.card
10
+ h2= 'VIMP'
11
+ //- бейдж входа (Этап B2): управляется LobbyAuthView, показывается
12
+ //- вместе с #lobby после успешной авторизации
13
+ div#lobby-user
14
+ div
15
+ span= 'Signed in as '
16
+ b#lobby-user-nick
17
+ input#lobby-user-logout(type='button', value='Sign out')
18
+ hr.lobby-divider
19
+ h3.lobby-section-title= 'HOST A GAME'
20
+ div#lobby-create
21
+ //- селектор игры (lobby-page-plan): рабочий с первой же игрой в
22
+ //- каталоге мастера, а не только когда их несколько
23
+ div.form-row
24
+ label.form-label(for='lobby-game')= 'Select Game'
25
+ select#lobby-game.field-select
26
+ div.form-row
27
+ label.form-label(for='lobby-name')= 'Server name'
28
+ input#lobby-name.field-text(type='text', maxlength='30')
29
+ //- поля настроек комнаты генерируются по явной схеме roomForm
30
+ //- манифеста активной игры (docs/en/plugin-api.md "Form schema")
31
+ div#lobby-fields
32
+ //- ошибки формы (formBuilder.renderFormErrors) и отказ загрузки
33
+ //- плагина выбранной игры — строкой в форме, а не в #tech-informer:
34
+ //- тот кроет вкладку непрозрачным слоем и рассчитан на терминальные
35
+ //- причины, а эти восстановимы (поправить поле, другая игра,
36
+ //- повторный клик). Над кнопкой — рядом с полями, к которым относятся
37
+ div#lobby-error.form-error
38
+ input#lobby-host(type='button', value='Create server')
39
+ //- правая панель: вкладки Active Servers / Leaderboard (lobby-page-plan)
40
+ div#lobby-browser-panel.card
41
+ //- абсолютный слой: высоту .card задаёт форма слева (stretch), а
42
+ //- список внутри скроллится, не раздувая карточку (lobby-page-plan)
43
+ div.lobby-browser-inner
44
+ div.lobby-tabs-header
45
+ input#btn-show-servers.lobby-tab-btn.active(type='button', value='Active Servers')
46
+ input#btn-show-leaderboard.lobby-tab-btn(type='button', value='Leaderboard')
47
+ div#lobby-servers-content
48
+ input#lobby-search.field-text(type='text', placeholder='Search servers…')
49
+ ul#lobby-list
50
+ p#lobby-empty= 'No servers available'
51
+ input#lobby-more(type='button', value='Load more')
52
+ div#lobby-leaderboard-content(style='display:none')
53
+ div.lobby-leaderboard-header
54
+ span#leaderboard-title
55
+ span#leaderboard-total
56
+ ol#lobby-leaderboard-list
57
+ p#lobby-my-placement
58
+ //- футер: версию пакета движка проставляет LobbyView (engineVersion.js)
59
+ div#lobby-footer
60
+ p#lobby-version
@@ -96,6 +96,10 @@ export default {
96
96
  leaderboardTitleId: 'leaderboard-title',
97
97
  leaderboardTotalId: 'leaderboard-total',
98
98
  myPlacementId: 'lobby-my-placement',
99
+
100
+ // футер: версия npm-пакета движка, запечённая в бандл при сборке
101
+ // (client/lib/engineVersion.js)
102
+ versionId: 'lobby-version',
99
103
  },
100
104
 
101
105
  // создание комнаты (хост в этой же вкладке); лимит игроков/время
@@ -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
  };