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
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 {
|
|
@@ -487,11 +491,18 @@ body.hide-cursor {
|
|
|
487
491
|
color: #ff0;
|
|
488
492
|
}
|
|
489
493
|
|
|
490
|
-
|
|
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-
|
|
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 © #{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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
//-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
div
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
div
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
package/src/config/lobby.js
CHANGED
|
@@ -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
|
|
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
|
};
|