vimp-engine 0.8.0 → 0.10.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.
Files changed (45) hide show
  1. package/bin/vimp-contract.js +110 -0
  2. package/core/Cargo.toml +17 -0
  3. package/package.json +4 -2
  4. package/src/client/components/view/Game.js +8 -12
  5. package/src/client/main.js +5 -0
  6. package/src/devtools/contract/format.js +65 -0
  7. package/src/devtools/contract/index.js +69 -0
  8. package/src/devtools/contract/loadContext.js +250 -0
  9. package/src/devtools/contract/result.js +55 -0
  10. package/src/devtools/contract/rules/a1-package-fields.js +81 -0
  11. package/src/devtools/contract/rules/a2-package-scripts.js +36 -0
  12. package/src/devtools/contract/rules/a3-entry-paths.js +30 -0
  13. package/src/devtools/contract/rules/a4-vite-config.js +47 -0
  14. package/src/devtools/contract/rules/a5-cargo-core.js +194 -0
  15. package/src/devtools/contract/rules/a6-manifest.js +78 -0
  16. package/src/devtools/contract/rules/b1-host-shape.js +50 -0
  17. package/src/devtools/contract/rules/b10-respawns.js +52 -0
  18. package/src/devtools/contract/rules/b2-engine-api.js +47 -0
  19. package/src/devtools/contract/rules/b3-game-config-shape.js +27 -0
  20. package/src/devtools/contract/rules/b4-teams.js +37 -0
  21. package/src/devtools/contract/rules/b5-room-form.js +43 -0
  22. package/src/devtools/contract/rules/b6-panel-reserved-key.js +29 -0
  23. package/src/devtools/contract/rules/b7-chat-commands.js +49 -0
  24. package/src/devtools/contract/rules/b8-system-messages.js +48 -0
  25. package/src/devtools/contract/rules/b9-vote-names.js +48 -0
  26. package/src/devtools/contract/rules/c1-client-shape.js +52 -0
  27. package/src/devtools/contract/rules/c10-auth-schema.js +55 -0
  28. package/src/devtools/contract/rules/c2-parts-registered.js +43 -0
  29. package/src/devtools/contract/rules/c3-game-sets-coverage.js +35 -0
  30. package/src/devtools/contract/rules/c4-component-dependencies.js +33 -0
  31. package/src/devtools/contract/rules/c5-panel-time-field.js +42 -0
  32. package/src/devtools/contract/rules/c6-stat-columns.js +36 -0
  33. package/src/devtools/contract/rules/c7-key-sets.js +71 -0
  34. package/src/devtools/contract/rules/c8-baked-assets.js +34 -0
  35. package/src/devtools/contract/rules/c9-chat-messages.js +41 -0
  36. package/src/devtools/contract/rules/d1-snapshot-ids.js +36 -0
  37. package/src/devtools/contract/rules/d2-snapshot-classes.js +42 -0
  38. package/src/devtools/contract/rules/d3-snapshot-interp.js +45 -0
  39. package/src/devtools/contract/rules/e1-sound-pairs.js +40 -0
  40. package/src/devtools/contract/rules/e2-map-images.js +40 -0
  41. package/src/devtools/contract/rules/e3-sound-registry.js +25 -0
  42. package/src/devtools/contract/rules/index.js +44 -0
  43. package/src/host/meta/player/ParticipantManager.js +6 -0
  44. package/tests/fixtures/miniGame/config/client.js +10 -2
  45. package/tests/fixtures/miniGame/config/game.js +9 -1
@@ -0,0 +1,43 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // roomForm. Поле вне белого списка форма показывает, лобби отправляет, а
4
+ // хост молча выбрасывает — правило игры, построенное на своей настройке
5
+ // комнаты, не работает и ничего об этом не сообщает. Контролов в v3 ровно
6
+ // четыре: неизвестный пропускается с console.error.
7
+ const HONOURED = ['maps', 'maxPlayers', 'map', 'roundTime', 'mapTime', 'friendlyFire'];
8
+ const CONTROLS = ['text', 'select', 'checkbox', 'radio'];
9
+
10
+ export default {
11
+ id: 'B5',
12
+ name: 'roomForm',
13
+ level: ERROR,
14
+ title: 'roomForm uses honoured field names and existing controls',
15
+
16
+ check(ctx) {
17
+ const roomForm = ctx.gameConfig?.roomForm ?? ctx.manifest?.roomForm;
18
+
19
+ if (!Array.isArray(roomForm)) {
20
+ return skip('no roomForm');
21
+ }
22
+
23
+ const violations = [];
24
+
25
+ for (const field of roomForm) {
26
+ if (!HONOURED.includes(field.name)) {
27
+ violations.push(
28
+ `roomForm field "${field.name}" is not read by the host ` +
29
+ `(honoured: ${HONOURED.join(', ')}) — it is silently dropped`,
30
+ );
31
+ }
32
+
33
+ if (!CONTROLS.includes(field.control)) {
34
+ violations.push(
35
+ `roomForm field "${field.name}": control "${field.control}" does ` +
36
+ `not exist (${CONTROLS.join(', ')})`,
37
+ );
38
+ }
39
+ }
40
+
41
+ return verdict(violations);
42
+ },
43
+ };
@@ -0,0 +1,29 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Ключ панели 't' зарезервирован движком под время раунда (Panel шлёт его
4
+ // сама). Игра, занявшая 't' своим полем, получает ячейку, которую движок
5
+ // перезаписывает секундами — без единого предупреждения.
6
+ export default {
7
+ id: 'B6',
8
+ name: 'panelReservedKey',
9
+ level: ERROR,
10
+ title: "host panel.fields does not use the engine key 't'",
11
+
12
+ check(ctx) {
13
+ const fields = ctx.gameConfig?.panel?.fields;
14
+
15
+ if (!fields) {
16
+ return skip('no gameConfig.panel.fields');
17
+ }
18
+
19
+ const violations = Object.entries(fields)
20
+ .filter(([, field]) => field?.key === 't')
21
+ .map(
22
+ ([name]) =>
23
+ `panel field "${name}" uses key 't' — reserved by the engine for ` +
24
+ 'the round time',
25
+ );
26
+
27
+ return verdict(violations);
28
+ },
29
+ };
@@ -0,0 +1,49 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Движковые команды разбираются switch'ем раньше реестра игровых
4
+ // (CommandProcessor.parseCommand): одноимённая команда плагина
5
+ // регистрируется, но не вызывается никогда.
6
+ const RESERVED = ['/name', '/nr', '/timeleft', '/mapname', '/rank'];
7
+
8
+ export default {
9
+ id: 'B7',
10
+ name: 'chatCommands',
11
+ level: ERROR,
12
+ title: 'chat commands do not shadow the engine commands',
13
+
14
+ check(ctx) {
15
+ const commands = ctx.hostPlugin?.chatCommands;
16
+
17
+ if (!Array.isArray(commands)) {
18
+ return skip('no chatCommands array');
19
+ }
20
+
21
+ const violations = [];
22
+ const seen = new Set();
23
+
24
+ for (const command of commands) {
25
+ const { name } = command ?? {};
26
+
27
+ if (typeof name !== 'string' || !name.startsWith('/')) {
28
+ violations.push(
29
+ `chat command ${JSON.stringify(name)} has no leading-slash name`,
30
+ );
31
+ continue;
32
+ }
33
+
34
+ if (RESERVED.includes(name)) {
35
+ violations.push(
36
+ `chat command "${name}" is an engine command — it will never fire`,
37
+ );
38
+ }
39
+
40
+ if (seen.has(name)) {
41
+ violations.push(`chat command "${name}" is registered twice`);
42
+ }
43
+
44
+ seen.add(name);
45
+ }
46
+
47
+ return verdict(violations);
48
+ },
49
+ };
@@ -0,0 +1,48 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Регистрация игровых кодов — слепой Object.assign поверх движкового
4
+ // реестра: код в зарезервированном диапазоне затирает движковое сообщение
5
+ // без предупреждения, и вместо «Vote passed» игрок видит игровой текст.
6
+ // Диапазоны — длины движковых групп (client/config/chat messages).
7
+ const RESERVED = { s: 6, v: 5, m: 1, c: 1, n: 1 };
8
+
9
+ export default {
10
+ id: 'B8',
11
+ name: 'systemMessages',
12
+ level: ERROR,
13
+ title: 'system message codes stay out of the engine ranges',
14
+
15
+ check(ctx) {
16
+ const messages = ctx.hostPlugin?.systemMessages;
17
+
18
+ if (!messages) {
19
+ return skip('no HostPlugin.systemMessages');
20
+ }
21
+
22
+ const violations = [];
23
+
24
+ for (const [name, code] of Object.entries(messages)) {
25
+ const parsed = /^([a-z]):(\d+)$/.exec(String(code));
26
+
27
+ if (!parsed) {
28
+ violations.push(
29
+ `systemMessages.${name} is "${code}" — expected "<group>:<index>"`,
30
+ );
31
+ continue;
32
+ }
33
+
34
+ const group = parsed[1];
35
+ const index = Number(parsed[2]);
36
+ const last = RESERVED[group];
37
+
38
+ if (last !== undefined && index <= last) {
39
+ violations.push(
40
+ `systemMessages.${name} ("${code}") overwrites the engine message ` +
41
+ `${group}:${index} (group "${group}" is reserved up to index ${last})`,
42
+ );
43
+ }
44
+ }
45
+
46
+ return verdict(violations);
47
+ },
48
+ };
@@ -0,0 +1,48 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Имена голосований mapChange/teamChange принадлежат движку. Своё
4
+ // голосование под этим именем не «переопределяет» движковое, а тонет:
5
+ // категорию разбирает HostGame раньше плагина. Обратная сторона той же
6
+ // ошибки — шаблон с именем категории (mapChange): движок рисует
7
+ // mapChangeBySystem/mapChangeByUser, и такой шаблон не показывается никогда.
8
+ const ENGINE_MENU = ['teamChange', 'mapChange'];
9
+ const ENGINE_TEMPLATES = ['teamChange', 'mapChangeBySystem', 'mapChangeByUser'];
10
+
11
+ export default {
12
+ id: 'B9',
13
+ name: 'voteNames',
14
+ level: ERROR,
15
+ title: 'custom votes do not reuse the reserved vote names',
16
+
17
+ check(ctx) {
18
+ const vote = ctx.clientConfig?.modules?.vote?.params;
19
+
20
+ if (!vote) {
21
+ return skip('no client vote config');
22
+ }
23
+
24
+ const templates = Object.keys(vote.templates ?? {});
25
+ const violations = [];
26
+
27
+ for (const name of templates) {
28
+ if (ENGINE_MENU.includes(name) && !ENGINE_TEMPLATES.includes(name)) {
29
+ violations.push(
30
+ `vote template "${name}" reuses a reserved vote name — the engine ` +
31
+ `renders ${ENGINE_TEMPLATES.join(' / ')}`,
32
+ );
33
+ }
34
+ }
35
+
36
+ for (const entry of vote.menu ?? []) {
37
+ const name = Array.isArray(entry) ? entry[0] : entry;
38
+
39
+ if (!ENGINE_MENU.includes(name) && !templates.includes(name)) {
40
+ violations.push(
41
+ `vote menu entry "${name}" has no template — the vote renders empty`,
42
+ );
43
+ }
44
+ }
45
+
46
+ return verdict(violations);
47
+ },
48
+ };
@@ -0,0 +1,52 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Поля ClientPlugin и все три хука. Хук с пустым телом — норма,
4
+ // отсутствующий — краш на первом же вызове (движок зовёт их безусловно).
5
+ const REQUIRED = [
6
+ ['id', 'string'],
7
+ ['engineApi', 'number'],
8
+ ['createClientCore', 'function'],
9
+ ['parts', 'object'],
10
+ ['bakers', 'object'],
11
+ ['styles', 'string'],
12
+ ];
13
+
14
+ const HOOKS = ['onAuth', 'onPanel', 'onLocalAction'];
15
+
16
+ export default {
17
+ id: 'C1',
18
+ name: 'clientPluginShape',
19
+ level: ERROR,
20
+ title: 'ClientPlugin exports parts, bakers, styles and all three hooks',
21
+
22
+ check(ctx) {
23
+ if (!ctx.clientPlugin) {
24
+ return skip('client plugin not loaded');
25
+ }
26
+
27
+ const violations = [];
28
+
29
+ for (const [field, type] of REQUIRED) {
30
+ const value = ctx.clientPlugin[field];
31
+
32
+ if (value === undefined || value === null) {
33
+ violations.push(`ClientPlugin.${field} is missing`);
34
+ } else if (typeof value !== type) {
35
+ violations.push(
36
+ `ClientPlugin.${field} is ${typeof value}, expected ${type}`,
37
+ );
38
+ }
39
+ }
40
+
41
+ for (const hook of HOOKS) {
42
+ if (typeof ctx.clientPlugin.hooks?.[hook] !== 'function') {
43
+ violations.push(
44
+ `ClientPlugin.hooks.${hook} is missing — an empty body is fine, ` +
45
+ 'a missing hook crashes',
46
+ );
47
+ }
48
+ }
49
+
50
+ return verdict(violations);
51
+ },
52
+ };
@@ -0,0 +1,55 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // authSchema. Три ошибки, каждая из которых уже случалась:
4
+ // formId вместо fieldsId (контейнер резолвится в null и экран авторизации
5
+ // умирает TypeError на первом рендере), поле ника (личность приходит из
6
+ // JWT лобби) и поле выбора модели под своим именем — движок читает
7
+ // params.model, всё остальное до Participant не доезжает.
8
+ const NICKNAME = /^(name|nick|nickname|player|playername|login|username)$/i;
9
+
10
+ export default {
11
+ id: 'C10',
12
+ name: 'authSchema',
13
+ level: ERROR,
14
+ title: 'authSchema: fieldsId, no nickname field, the model field',
15
+
16
+ check(ctx) {
17
+ if (!ctx.authSchema) {
18
+ return skip('no HostPlugin.authSchema');
19
+ }
20
+
21
+ const { elems = {}, params = [] } = ctx.authSchema;
22
+ const violations = [];
23
+
24
+ if (elems.formId !== undefined) {
25
+ violations.push(
26
+ "authSchema.elems.formId does not exist — the key is 'fieldsId'",
27
+ );
28
+ }
29
+
30
+ if (elems.fieldsId === undefined) {
31
+ violations.push(
32
+ 'authSchema.elems.fieldsId is missing — the engine fills that ' +
33
+ 'container with the form fields',
34
+ );
35
+ }
36
+
37
+ for (const field of params) {
38
+ if (NICKNAME.test(field.name)) {
39
+ violations.push(
40
+ `authSchema param "${field.name}" looks like a nickname field — ` +
41
+ 'identity comes from the lobby JWT',
42
+ );
43
+ }
44
+ }
45
+
46
+ if (!params.some(field => field.name === 'model')) {
47
+ violations.push(
48
+ 'authSchema has no param named exactly "model" — the engine reads ' +
49
+ 'params.model when it creates the participant',
50
+ );
51
+ }
52
+
53
+ return verdict(violations);
54
+ },
55
+ };
@@ -0,0 +1,43 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Класс парта регистрируется во Factory только через entitiesOnCanvas.
4
+ // Перечисления в parts и gameSets недостаточно: движок отвечает
5
+ // «Constructor for X not found.» на первом кадре, где ключ ожил.
6
+ export default {
7
+ id: 'C2',
8
+ name: 'partsRegistered',
9
+ level: ERROR,
10
+ title: 'every gameSets class is in entitiesOnCanvas and exported in parts',
11
+
12
+ check(ctx) {
13
+ const parts = ctx.clientConfig?.parts;
14
+
15
+ if (!parts?.gameSets) {
16
+ return skip('no client parts.gameSets');
17
+ }
18
+
19
+ const onCanvas = parts.entitiesOnCanvas ?? {};
20
+ const exported = ctx.clientPlugin?.parts ?? null;
21
+ const violations = [];
22
+
23
+ for (const [key, names] of Object.entries(parts.gameSets)) {
24
+ for (const name of names ?? []) {
25
+ if (onCanvas[name] === undefined) {
26
+ violations.push(
27
+ `gameSets["${key}"] uses part "${name}", missing from ` +
28
+ 'entitiesOnCanvas — it is never registered',
29
+ );
30
+ }
31
+
32
+ if (exported && exported[name] === undefined) {
33
+ violations.push(
34
+ `gameSets["${key}"] uses part "${name}", not exported in ` +
35
+ 'ClientPlugin.parts',
36
+ );
37
+ }
38
+ }
39
+ }
40
+
41
+ return verdict(violations);
42
+ },
43
+ };
@@ -0,0 +1,35 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Ключ снапшот-схемы без записи в gameSets — чёрный холст: клиент получает
4
+ // строки кадра и не знает, чем их рисовать. То же для setId карты: без него
5
+ // статика полотна не создаётся. Раннер ловит это только для ключей, ожив-
6
+ // ших в конкретном прогоне, — статически видно все.
7
+ export default {
8
+ id: 'C3',
9
+ name: 'gameSetsCoverage',
10
+ level: ERROR,
11
+ title: 'every snapshot key and map setId has a gameSets entry',
12
+
13
+ check(ctx) {
14
+ const gameSets = ctx.clientConfig?.parts?.gameSets;
15
+ const snapshot = ctx.gameConfig?.snapshot;
16
+
17
+ if (!gameSets || !snapshot) {
18
+ return skip('no client parts.gameSets or gameConfig.snapshot');
19
+ }
20
+
21
+ const violations = Object.keys(snapshot)
22
+ .filter(key => !gameSets[key])
23
+ .map(key => `snapshot key "${key}" has no parts.gameSets entry`);
24
+
25
+ for (const [name, map] of Object.entries(ctx.gameConfig.maps ?? {})) {
26
+ if (map?.setId !== undefined && !gameSets[map.setId]) {
27
+ violations.push(
28
+ `map "${name}": setId "${map.setId}" has no parts.gameSets entry`,
29
+ );
30
+ }
31
+ }
32
+
33
+ return verdict(violations);
34
+ },
35
+ };
@@ -0,0 +1,33 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Пул сервисов клиента ровно из трёх имён (client/main.js). Незнакомое имя
4
+ // не ошибка для движка: part получает undefined и рисует пустоту — карта
5
+ // без assetsBase выглядит как чистый холст без единой строки в консоли.
6
+ const SERVICES = ['renderer', 'soundManager', 'assetsBase'];
7
+
8
+ export default {
9
+ id: 'C4',
10
+ name: 'componentDependencies',
11
+ level: ERROR,
12
+ title: 'componentDependencies name only existing services',
13
+
14
+ check(ctx) {
15
+ const deps = ctx.clientConfig?.parts?.componentDependencies;
16
+
17
+ if (!deps) {
18
+ return skip('no client parts.componentDependencies');
19
+ }
20
+
21
+ // раскладка «сервис → парты, которым он нужен» (client.js игры)
22
+ const violations = Object.keys(deps)
23
+ .filter(service => !SERVICES.includes(service))
24
+ .map(
25
+ service =>
26
+ `componentDependencies declares service "${service}" — the engine ` +
27
+ `provides only ${SERVICES.join(', ')} (an unknown one is silently ` +
28
+ 'undefined in the part)',
29
+ );
30
+
31
+ return verdict(violations);
32
+ },
33
+ };
@@ -0,0 +1,42 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Панель шлёт время раунда под ключом 't' безусловно. Клиент, не
4
+ // объявивший для него поле type: 'time', получает значение с именем
5
+ // undefined — время просто не появляется на HUD.
6
+ export default {
7
+ id: 'C5',
8
+ name: 'panelTimeField',
9
+ level: ERROR,
10
+ title: "the client panel maps key 't' to a type: 'time' field",
11
+
12
+ check(ctx) {
13
+ const panel = ctx.clientConfig?.modules?.panel;
14
+
15
+ if (!panel) {
16
+ return skip('no client panel config');
17
+ }
18
+
19
+ const name = panel.keys?.t;
20
+
21
+ if (name === undefined) {
22
+ return verdict([
23
+ "panel.keys has no 't' — the engine sends the round time under it",
24
+ ]);
25
+ }
26
+
27
+ const field = (panel.fields ?? []).find(item => item.name === name);
28
+
29
+ if (!field) {
30
+ return verdict([`panel.keys.t maps to "${name}", which has no field`]);
31
+ }
32
+
33
+ if (field.type !== 'time') {
34
+ return verdict([
35
+ `panel field "${name}" (key 't') has type "${field.type}", ` +
36
+ "expected 'time'",
37
+ ]);
38
+ }
39
+
40
+ return verdict([]);
41
+ },
42
+ };
@@ -0,0 +1,36 @@
1
+ import { WARN, skip, verdict } from '../result.js';
2
+
3
+ // Движок пишет ровно пять имён (name, status, score, deaths, latency), и
4
+ // его CSS свёрстана под пять колонок. Другое число — не отказ: игра вправе
5
+ // привезти свои стили в ClientPlugin.styles. Поэтому warn, а не error.
6
+ export default {
7
+ id: 'C6',
8
+ name: 'statColumns',
9
+ level: WARN,
10
+ title: 'stat declares five columns (the engine CSS assumes five)',
11
+
12
+ check(ctx) {
13
+ const columns = ctx.clientConfig?.modules?.stat?.params?.columns;
14
+
15
+ if (!columns) {
16
+ return skip('no client stat columns');
17
+ }
18
+
19
+ if (columns.length === 5) {
20
+ return verdict([]);
21
+ }
22
+
23
+ const note = ctx.clientPlugin?.styles
24
+ ? 'the plugin ships its own styles'
25
+ : undefined;
26
+
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
+ );
35
+ },
36
+ };
@@ -0,0 +1,71 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // keySetList: [0] — набор наблюдателя, [1] — игрока. Без nextPlayer/
4
+ // prevPlayer наблюдатель заперт на одной камере; занятый движковый код
5
+ // (чат, голосование, статистика, escape, enter) до игры не доходит;
6
+ // расхождение имён с playerKeys даёт клавишу, которая ничего не шлёт.
7
+ const ENGINE_CODES = { 9: 'stat', 13: 'enter', 27: 'escape', 67: 'chat', 77: 'vote' };
8
+ const SPECTATOR_ACTIONS = ['nextPlayer', 'prevPlayer'];
9
+
10
+ export default {
11
+ id: 'C7',
12
+ name: 'keySets',
13
+ level: ERROR,
14
+ title: 'keySetList: spectator actions, engine codes, playerKeys parity',
15
+
16
+ check(ctx) {
17
+ const keySetList = ctx.clientConfig?.modules?.controls?.keySetList;
18
+
19
+ if (!Array.isArray(keySetList)) {
20
+ return skip('no client controls.keySetList');
21
+ }
22
+
23
+ const violations = [];
24
+ const spectator = Object.values(keySetList[0] ?? {});
25
+
26
+ for (const action of SPECTATOR_ACTIONS) {
27
+ if (!spectator.includes(action)) {
28
+ violations.push(
29
+ `keySetList[0] (spectator) has no "${action}" — the spectator ` +
30
+ 'cannot switch camera',
31
+ );
32
+ }
33
+ }
34
+
35
+ keySetList.forEach((set, index) => {
36
+ for (const code of Object.keys(set ?? {})) {
37
+ if (ENGINE_CODES[code]) {
38
+ violations.push(
39
+ `keySetList[${index}] binds code ${code} — the engine owns it ` +
40
+ `(${ENGINE_CODES[code]})`,
41
+ );
42
+ }
43
+ }
44
+ });
45
+
46
+ const playerKeys = ctx.gameConfig?.playerKeys;
47
+
48
+ if (playerKeys) {
49
+ const bound = new Set(Object.values(keySetList[1] ?? {}));
50
+ const declared = new Set(Object.keys(playerKeys));
51
+
52
+ for (const action of declared) {
53
+ if (!bound.has(action)) {
54
+ violations.push(
55
+ `playerKeys."${action}" has no key in keySetList[1]`,
56
+ );
57
+ }
58
+ }
59
+
60
+ for (const action of bound) {
61
+ if (!declared.has(action)) {
62
+ violations.push(
63
+ `keySetList[1] binds "${action}", missing from gameConfig.playerKeys`,
64
+ );
65
+ }
66
+ }
67
+ }
68
+
69
+ return verdict(violations);
70
+ },
71
+ };
@@ -0,0 +1,34 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Неизвестное имя запекаемого ассета пропускается молча: part получает
4
+ // пустой assets и рисует ничего.
5
+ export default {
6
+ id: 'C8',
7
+ name: 'bakedAssets',
8
+ level: ERROR,
9
+ title: 'bakedAssets names exist in ClientPlugin.bakers',
10
+
11
+ check(ctx) {
12
+ const baked = ctx.clientConfig?.parts?.bakedAssets;
13
+ const bakers = ctx.clientPlugin?.bakers;
14
+
15
+ if (!baked || !bakers) {
16
+ return skip('no bakedAssets or client plugin not loaded');
17
+ }
18
+
19
+ const violations = [];
20
+
21
+ for (const [canvas, entries] of Object.entries(baked)) {
22
+ for (const entry of entries ?? []) {
23
+ if (bakers[entry.name] === undefined) {
24
+ violations.push(
25
+ `bakedAssets["${canvas}"]: baker "${entry.name}" is not in ` +
26
+ 'ClientPlugin.bakers — the entry is skipped silently',
27
+ );
28
+ }
29
+ }
30
+ }
31
+
32
+ return verdict(violations);
33
+ },
34
+ };
@@ -0,0 +1,41 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Каждому зарегистрированному коду нужен текст на том же индексе в
4
+ // клиентском реестре. Недостающий текст рендерится пустотой: событие
5
+ // произошло, в чате ничего.
6
+ export default {
7
+ id: 'C9',
8
+ name: 'chatMessages',
9
+ level: ERROR,
10
+ title: 'every system message code has a client text',
11
+
12
+ check(ctx) {
13
+ const messages = ctx.hostPlugin?.systemMessages;
14
+ const texts = ctx.clientConfig?.modules?.chat?.params?.messages;
15
+
16
+ if (!messages || !texts) {
17
+ return skip('no systemMessages or client chat messages');
18
+ }
19
+
20
+ const violations = [];
21
+
22
+ for (const [name, code] of Object.entries(messages)) {
23
+ const parsed = /^([a-z]):(\d+)$/.exec(String(code));
24
+
25
+ if (!parsed) {
26
+ continue;
27
+ }
28
+
29
+ const group = texts[parsed[1]];
30
+
31
+ if (group?.[Number(parsed[2])] === undefined) {
32
+ violations.push(
33
+ `systemMessages.${name} ("${code}") has no text in ` +
34
+ `chat.params.messages.${parsed[1]}[${parsed[2]}]`,
35
+ );
36
+ }
37
+ }
38
+
39
+ return verdict(violations);
40
+ },
41
+ };