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,81 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Поля package.json, от которых зависит не сборка, а поведение в бою:
4
+ // вбандленный pixi.js даёт вторую копию PixiJS со своим реестром расширений
5
+ // (объекты одной копии падают в другой), а vimp-engine в dependencies тянет
6
+ // движок внутрь пакета игры — движок загружает плагин, не наоборот.
7
+ export default {
8
+ id: 'A1',
9
+ name: 'packageFields',
10
+ level: ERROR,
11
+ title: 'package.json: type, files, pixi.js, vimp-engine, publishConfig',
12
+
13
+ check(ctx) {
14
+ if (!ctx.pkg) {
15
+ return skip('no package.json');
16
+ }
17
+
18
+ const {
19
+ name,
20
+ type,
21
+ files,
22
+ publishConfig,
23
+ dependencies = {},
24
+ devDependencies = {},
25
+ peerDependencies = {},
26
+ } = ctx.pkg;
27
+ const violations = [];
28
+
29
+ if (type !== 'module') {
30
+ violations.push(`"type" is ${JSON.stringify(type)}, must be "module"`);
31
+ }
32
+
33
+ // npm принимает запись каталога в трёх видах ("dist", "dist/",
34
+ // "./dist") — сравнение строкой отвергало бы две из трёх
35
+ if (!Array.isArray(files) || !files.some(isDistEntry)) {
36
+ violations.push('"files" must list "dist" — only dist/ is published');
37
+ }
38
+
39
+ if (dependencies['pixi.js']) {
40
+ violations.push(
41
+ 'pixi.js is in dependencies — it must be external (peer + dev), ' +
42
+ 'otherwise the bundle ships a second PixiJS instance',
43
+ );
44
+ }
45
+
46
+ if (!peerDependencies['pixi.js']) {
47
+ violations.push('pixi.js is missing from peerDependencies');
48
+ }
49
+
50
+ if (!devDependencies['pixi.js']) {
51
+ violations.push(
52
+ 'pixi.js is missing from devDependencies — the build needs it locally',
53
+ );
54
+ }
55
+
56
+ if (dependencies['vimp-engine']) {
57
+ violations.push(
58
+ 'vimp-engine is in dependencies — it belongs in devDependencies',
59
+ );
60
+ }
61
+
62
+ if (!devDependencies['vimp-engine']) {
63
+ violations.push('vimp-engine is missing from devDependencies');
64
+ }
65
+
66
+ if (name?.startsWith('@') && publishConfig?.access !== 'public') {
67
+ violations.push(
68
+ `scoped package "${name}" needs publishConfig.access: "public"`,
69
+ );
70
+ }
71
+
72
+ return verdict(violations);
73
+ },
74
+ };
75
+
76
+ function isDistEntry(entry) {
77
+ return (
78
+ typeof entry === 'string' &&
79
+ entry.replace(/^\.\//, '').replace(/\/+$/, '') === 'dist'
80
+ );
81
+ }
@@ -0,0 +1,36 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Набор npm-скриптов пакета игры (docs/ai/02-packaging.md). Это не вкусовое
4
+ // требование: инструкции движка и CI сгенерированной игры зовут их по имени,
5
+ // а отсутствующий core:build:node тихо оставляет headless-прогон без ядра.
6
+ const REQUIRED = [
7
+ 'build',
8
+ 'build:client',
9
+ 'build:host',
10
+ 'build:assets',
11
+ 'build:manifest',
12
+ 'core:build:web',
13
+ 'core:build:node',
14
+ 'core:test',
15
+ 'test',
16
+ ];
17
+
18
+ export default {
19
+ id: 'A2',
20
+ name: 'packageScripts',
21
+ level: ERROR,
22
+ title: 'package.json declares the standard build/test scripts',
23
+
24
+ check(ctx) {
25
+ if (!ctx.pkg) {
26
+ return skip('no package.json');
27
+ }
28
+
29
+ const scripts = ctx.pkg.scripts ?? {};
30
+ const missing = REQUIRED.filter(name => !scripts[name]);
31
+
32
+ return verdict(
33
+ missing.map(name => `script "${name}" is missing`),
34
+ );
35
+ },
36
+ };
@@ -0,0 +1,30 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Пути точек входа захардкожены в dev-режиме мастера
4
+ // (GameCatalog._toDevManifest отдаёт браузеру /@fs/<пакет>/src/client/index.js).
5
+ // Игра, разложившая половины иначе, собирается и публикуется нормально, а в
6
+ // dev-режиме просто не грузится.
7
+ export default {
8
+ id: 'A3',
9
+ name: 'entryPaths',
10
+ level: ERROR,
11
+ title: 'entries live at src/client/index.js and src/host/index.js',
12
+
13
+ check(ctx) {
14
+ if (!ctx.pkg) {
15
+ return skip('no package.json');
16
+ }
17
+
18
+ const violations = [];
19
+
20
+ if (!ctx.hostEntryExists) {
21
+ violations.push('src/host/index.js is missing — dev mode hardcodes it');
22
+ }
23
+
24
+ if (!ctx.clientEntryExists) {
25
+ violations.push('src/client/index.js is missing — dev mode hardcodes it');
26
+ }
27
+
28
+ return verdict(violations);
29
+ },
30
+ };
@@ -0,0 +1,47 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // vite.config.js проверяется как текст, а не импортом: конфиг —
4
+ // функция от mode и вне Vite не исполняется. Каждый пункт здесь ловит
5
+ // молчаливую поломку сборки, а не стилистику (docs/ai/02-packaging.md):
6
+ // без preserveEntrySignatures default-экспорт плагина вытряхивает
7
+ // tree-shaking, а build.lib всегда инлайнит ассеты.
8
+ const REQUIRED = [
9
+ [/emptyOutDir\s*:\s*false/, 'emptyOutDir: false (two builds share dist/)'],
10
+ [/assetsInlineLimit\s*:\s*0/, 'assetsInlineLimit: 0'],
11
+ [
12
+ /preserveEntrySignatures\s*:\s*['"]strict['"]/,
13
+ "preserveEntrySignatures: 'strict' (or the default export is tree-shaken)",
14
+ ],
15
+ [/inlineDynamicImports\s*:\s*true/, 'inlineDynamicImports: true'],
16
+ // external записывают и строкой, и регэкспом (/^pixi\.js(\/.*)?$/), а в
17
+ // регэкспе встречается ']' — ограничиваем не символом, а длиной хвоста
18
+ [/external\s*:\s*\[[\s\S]{0,400}?pixi/, 'external with pixi.js'],
19
+ [/entryFileNames[^\n]*\[hash\]/, 'entryFileNames with [hash]'],
20
+ ];
21
+
22
+ export default {
23
+ id: 'A4',
24
+ name: 'viteConfig',
25
+ level: ERROR,
26
+ title: 'vite.config.js carries the required rollup options',
27
+
28
+ check(ctx) {
29
+ if (!ctx.viteText) {
30
+ return skip('no vite.config.js');
31
+ }
32
+
33
+ const violations = REQUIRED.filter(([re]) => !re.test(ctx.viteText)).map(
34
+ ([, what]) => `vite.config.js: missing ${what}`,
35
+ );
36
+
37
+ // именно build.lib: ключ `lib` встречается и в resolve.alias, и в
38
+ // именах путей — без привязки к build это ложный отказ
39
+ if (/build\s*:\s*\{[\s\S]{0,400}?\blib\s*:\s*[{'"]/.test(ctx.viteText)) {
40
+ violations.push(
41
+ 'vite.config.js: build.lib is used — it always inlines assets',
42
+ );
43
+ }
44
+
45
+ return verdict(violations);
46
+ },
47
+ };
@@ -0,0 +1,194 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // core/Cargo.toml. Три пункта, каждый — молчаливый отказ:
4
+ // crate-type без cdylib не даёт .wasm, rapier2d без enhanced-determinism
5
+ // разъезжается между хостом и предиктором на разных машинах, а устаревший
6
+ // пин vimp-engine-core собирает игру с чужим ABI (болезнь vimp-street-fighters:
7
+ // пин 0.1.0 при крейте движка 0.3.2).
8
+ export default {
9
+ id: 'A5',
10
+ name: 'cargoCore',
11
+ level: ERROR,
12
+ title: 'core/Cargo.toml: crate-type, rapier2d determinism, engine pin',
13
+
14
+ check(ctx) {
15
+ if (!ctx.cargoText) {
16
+ return skip('no core/Cargo.toml');
17
+ }
18
+
19
+ const violations = [];
20
+ // именно в [lib]: тот же ключ встречается в комментариях и в чужих
21
+ // секциях, и поиск по всему файлу выдал бы за проверку случайную строку
22
+ const crateType = readSection(ctx.cargoText, 'lib')?.match(
23
+ /^\s*crate-type\s*=\s*\[([^\]]*)\]/m,
24
+ )?.[1];
25
+
26
+ if (!crateType) {
27
+ violations.push('[lib] crate-type is missing (need ["cdylib", "rlib"])');
28
+ } else {
29
+ for (const kind of ['cdylib', 'rlib']) {
30
+ if (!crateType.includes(kind)) {
31
+ violations.push(`[lib] crate-type does not list "${kind}"`);
32
+ }
33
+ }
34
+ }
35
+
36
+ const rapier = resolveDep(ctx, 'rapier2d');
37
+
38
+ if (!rapier.declared) {
39
+ violations.push('rapier2d is not a dependency');
40
+ } else if (rapier.text && !rapier.text.includes('enhanced-determinism')) {
41
+ violations.push(
42
+ 'rapier2d is missing the "enhanced-determinism" feature — physics ' +
43
+ 'diverges between machines',
44
+ );
45
+ }
46
+
47
+ violations.push(...checkEnginePin(ctx));
48
+
49
+ return verdict(violations);
50
+ },
51
+ };
52
+
53
+ // зависимость крейта: `{ workspace = true }` уводит за реальным
54
+ // объявлением в корневой Cargo.toml — там же живут и версия, и фичи.
55
+ // Недостижимый корень — не нарушение, а отсутствие входа: возвращаем
56
+ // null и объявляем зависимость непроверяемой (declared остаётся true)
57
+ function resolveDep(ctx, name) {
58
+ const declaration = readDep(ctx.cargoText, name);
59
+
60
+ if (!declaration) {
61
+ return { declared: false, text: null };
62
+ }
63
+
64
+ if (/workspace\s*=\s*true/.test(declaration)) {
65
+ return {
66
+ declared: true,
67
+ text: ctx.workspaceCargoText
68
+ ? readDep(ctx.workspaceCargoText, name)
69
+ : null,
70
+ };
71
+ }
72
+
73
+ return { declared: true, text: declaration };
74
+ }
75
+
76
+ function checkEnginePin(ctx) {
77
+ const { declared, text } = resolveDep(ctx, 'vimp-engine-core');
78
+
79
+ if (!declared) {
80
+ return ['vimp-engine-core is not a dependency'];
81
+ }
82
+
83
+ // отсутствие входа не должно выглядеть зелёной галочкой: остальные
84
+ // пункты A5 проверены, а пин — нет, и это обязано быть в отчёте
85
+ if (!ctx.engineCoreVersion) {
86
+ ctx.notes?.push(
87
+ 'A5: the engine crate version is unknown — the vimp-engine-core pin ' +
88
+ 'was NOT checked',
89
+ );
90
+
91
+ return [];
92
+ }
93
+
94
+ if (!text) {
95
+ ctx.notes?.push(
96
+ 'A5: the vimp-engine-core declaration was not found in [dependencies] ' +
97
+ '/ [workspace.dependencies] — the pin was NOT checked',
98
+ );
99
+
100
+ return [];
101
+ }
102
+
103
+ const pinned = text.match(/(\d+)\.(\d+)(?:\.(\d+))?/);
104
+
105
+ if (!pinned) {
106
+ ctx.notes?.push(
107
+ `A5: vimp-engine-core is declared without a version (${text.trim()}) — ` +
108
+ 'the pin was NOT checked',
109
+ );
110
+
111
+ return [];
112
+ }
113
+
114
+ const engine = ctx.engineCoreVersion.split('.').map(Number);
115
+ const game = [Number(pinned[1]), Number(pinned[2])];
116
+ const order = game[0] - engine[0] || game[1] - engine[1];
117
+
118
+ if (order < 0) {
119
+ return [
120
+ `vimp-engine-core is pinned to ${game.join('.')}, older than this ` +
121
+ `engine's crate ${ctx.engineCoreVersion}`,
122
+ ];
123
+ }
124
+
125
+ if (order > 0) {
126
+ return [
127
+ `vimp-engine-core is pinned to ${game.join('.')}, ahead of this ` +
128
+ `engine's crate ${ctx.engineCoreVersion}`,
129
+ ];
130
+ }
131
+
132
+ return [];
133
+ }
134
+
135
+ // секции, в которых объявление зависимости считается объявлением. Поиск
136
+ // по всему файлу ловил бы `[patch.crates-io] vimp-engine-core = { path =
137
+ // … }` — оно есть в игре, созданной с --core-path, версии в нём нет, и
138
+ // проверка пина молча проходила бы
139
+ const DEP_SECTIONS = new Set([
140
+ 'dependencies',
141
+ 'workspace.dependencies',
142
+ 'build-dependencies',
143
+ ]);
144
+
145
+ // объявление зависимости в обеих формах: inline (`dep = { … }` или
146
+ // `dep = "x.y"`) и секцией (`[dependencies.dep]` … до следующей секции)
147
+ function readDep(text, name) {
148
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
149
+ const inline = new RegExp(`^\\s*${escaped}\\s*=\\s*(.+)$`, 'm');
150
+
151
+ for (const [header, body] of sections(text)) {
152
+ if (DEP_SECTIONS.has(header)) {
153
+ const found = body.match(inline)?.[1];
154
+
155
+ if (found) {
156
+ return found;
157
+ }
158
+ }
159
+
160
+ if ([...DEP_SECTIONS].some(dep => header === `${dep}.${name}`)) {
161
+ return body;
162
+ }
163
+ }
164
+
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * Секции TOML: заголовок без скобок и тело до следующего заголовка.
170
+ * @param {string} text
171
+ * @returns {Array<[string, string]>}
172
+ */
173
+ function sections(text) {
174
+ const header = /^[ \t]*\[([^[\]\n]+)\][ \t]*$/gm;
175
+ const found = [];
176
+ let match = header.exec(text);
177
+
178
+ while (match !== null) {
179
+ const start = match.index + match[0].length;
180
+ const next = header.exec(text);
181
+
182
+ found.push([
183
+ match[1].trim(),
184
+ text.slice(start, next === null ? text.length : next.index),
185
+ ]);
186
+ match = next;
187
+ }
188
+
189
+ return found;
190
+ }
191
+
192
+ function readSection(text, name) {
193
+ return sections(text).find(([header]) => header === name)?.[1] ?? null;
194
+ }
@@ -0,0 +1,78 @@
1
+ import path from 'node:path';
2
+ import { ERROR, skip, verdict } from '../result.js';
3
+
4
+ // dist/manifest.json — единственное, что мастер читает о собранной игре.
5
+ // Расхождение id или битая ссылка в entries не ломает сборку: игра просто
6
+ // не появляется в лобби (мастер пропускает её с console.warn).
7
+ // entries.wasmNode обязан указывать внутрь dist/ — публикуется только он.
8
+ export default {
9
+ id: 'A6',
10
+ name: 'manifestShape',
11
+ level: ERROR,
12
+ title: 'dist/manifest.json: id, entries on disk, roomDefaults coverage',
13
+
14
+ check(ctx) {
15
+ if (!ctx.manifest) {
16
+ return skip('not built — no dist/manifest.json');
17
+ }
18
+
19
+ const { manifest } = ctx;
20
+ const violations = [];
21
+
22
+ for (const [half, plugin] of [
23
+ ['host', ctx.hostPlugin],
24
+ ['client', ctx.clientPlugin],
25
+ ]) {
26
+ if (plugin && plugin.id !== manifest.id) {
27
+ violations.push(
28
+ `manifest id "${manifest.id}" differs from the ${half} plugin id ` +
29
+ `"${plugin.id}"`,
30
+ );
31
+ }
32
+ }
33
+
34
+ for (const [name, entry] of Object.entries(manifest.entries ?? {})) {
35
+ const rel =
36
+ name === 'wasmNode' ? entry : stripBase(entry, manifest.assetsBase);
37
+ const inside = path
38
+ .normalize(rel)
39
+ .replace(/^\.\//, '')
40
+ .replaceAll(path.sep, '/');
41
+
42
+ if (inside.startsWith('../')) {
43
+ violations.push(
44
+ `entries.${name} points outside dist/ ("${entry}") — only dist/ ` +
45
+ 'is published',
46
+ );
47
+ continue;
48
+ }
49
+
50
+ if (!ctx.distFiles?.has(inside)) {
51
+ violations.push(`entries.${name} ("${entry}") is not in dist/`);
52
+ }
53
+ }
54
+
55
+ const roomForm = manifest.roomForm ?? [];
56
+ const defaults = manifest.roomDefaults ?? {};
57
+
58
+ for (const field of roomForm) {
59
+ // источник значения у поля бывает и вне roomDefaults: select по картам
60
+ // засеивается списком карт манифеста
61
+ if (defaults[field.name] === undefined && field.source !== 'maps') {
62
+ violations.push(
63
+ `roomForm field "${field.name}" has no roomDefaults value`,
64
+ );
65
+ }
66
+ }
67
+
68
+ return verdict(violations);
69
+ },
70
+ };
71
+
72
+ function stripBase(entry, assetsBase) {
73
+ if (assetsBase && entry.startsWith(assetsBase)) {
74
+ return entry.slice(assetsBase.length);
75
+ }
76
+
77
+ return entry.replace(/^\/+/, '');
78
+ }
@@ -0,0 +1,50 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // Поля HostPlugin, которые движок разыменовывает без проверок: отсутствие
4
+ // любого даёт TypeError глубоко в onInit, за десяток кадров от причины.
5
+ // chatCommands проверяется отдельно на «массив»: движок его итерирует.
6
+ const REQUIRED = [
7
+ ['id', 'string'],
8
+ ['engineApi', 'number'],
9
+ ['createCore', 'function'],
10
+ ['gameConfig', 'object'],
11
+ ['authSchema', 'object'],
12
+ ['createModules', 'function'],
13
+ ['buildClientGameConfig', 'function'],
14
+ ];
15
+
16
+ export default {
17
+ id: 'B1',
18
+ name: 'hostPluginShape',
19
+ level: ERROR,
20
+ title: 'HostPlugin exports every field the engine dereferences',
21
+
22
+ check(ctx) {
23
+ if (!ctx.hostPlugin) {
24
+ return skip('host plugin not loaded');
25
+ }
26
+
27
+ const violations = [];
28
+
29
+ for (const [field, type] of REQUIRED) {
30
+ const value = ctx.hostPlugin[field];
31
+
32
+ if (value === undefined || value === null) {
33
+ violations.push(`HostPlugin.${field} is missing`);
34
+ } else if (typeof value !== type) {
35
+ violations.push(
36
+ `HostPlugin.${field} is ${typeof value}, expected ${type}`,
37
+ );
38
+ }
39
+ }
40
+
41
+ if (!Array.isArray(ctx.hostPlugin.chatCommands)) {
42
+ violations.push(
43
+ 'HostPlugin.chatCommands must be an array (use [] for none) — the ' +
44
+ 'engine iterates it unguarded',
45
+ );
46
+ }
47
+
48
+ return verdict(violations);
49
+ },
50
+ };
@@ -0,0 +1,52 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // respawns[team].length — жёсткая вместимость команды на карте
4
+ // (RoundManager отказывает в переходе, когда точек не хватает). Комната,
5
+ // заявившая maxPlayers больше суммы точек, впускает лишних участников
6
+ // наблюдателями и молча меньше, чем обещала.
7
+ export default {
8
+ id: 'B10',
9
+ name: 'respawns',
10
+ level: ERROR,
11
+ title: 'every playing team has respawns and they cover maxPlayers',
12
+
13
+ check(ctx) {
14
+ const maps = ctx.gameConfig?.maps;
15
+ const teams = ctx.gameConfig?.teams;
16
+
17
+ if (!maps || !teams) {
18
+ return skip('no gameConfig.maps or gameConfig.teams');
19
+ }
20
+
21
+ const playing = Object.keys(teams).filter(
22
+ team => team !== ctx.gameConfig.spectatorTeam,
23
+ );
24
+ const maxPlayers = ctx.gameConfig.roomDefaults?.maxPlayers;
25
+ const violations = [];
26
+
27
+ for (const [name, map] of Object.entries(maps)) {
28
+ const respawns = map?.respawns ?? {};
29
+ let capacity = 0;
30
+
31
+ for (const team of playing) {
32
+ const points = respawns[team];
33
+
34
+ if (!Array.isArray(points) || !points.length) {
35
+ violations.push(`map "${name}": team "${team}" has no respawns`);
36
+ continue;
37
+ }
38
+
39
+ capacity += points.length;
40
+ }
41
+
42
+ if (maxPlayers !== undefined && capacity < maxPlayers) {
43
+ violations.push(
44
+ `map "${name}": ${capacity} respawn point(s) for ${maxPlayers} ` +
45
+ 'players — the room is silently capped below roomDefaults.maxPlayers',
46
+ );
47
+ }
48
+ }
49
+
50
+ return verdict(violations);
51
+ },
52
+ };
@@ -0,0 +1,47 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // engineApi живёт в трёх местах (манифест, HostPlugin, ClientPlugin) и во
4
+ // всех трёх обязан быть импортом ENGINE_API_VERSION, а не числом: литерал
5
+ // не расходится со сборкой движка в момент написания и молча расходится
6
+ // через релиз, после чего плагин отклоняется гейтом совместимости.
7
+ export default {
8
+ id: 'B2',
9
+ name: 'engineApiVersion',
10
+ level: ERROR,
11
+ title: 'engineApi matches ENGINE_API_VERSION in all three places',
12
+
13
+ check(ctx) {
14
+ const declared = [
15
+ ['host plugin', ctx.hostPlugin?.engineApi],
16
+ ['client plugin', ctx.clientPlugin?.engineApi],
17
+ ['manifest', ctx.manifest?.engineApi],
18
+ ].filter(([, value]) => value !== undefined);
19
+
20
+ if (!declared.length) {
21
+ return skip('neither plugin half nor manifest is available');
22
+ }
23
+
24
+ const violations = [];
25
+
26
+ for (const [where, value] of declared) {
27
+ if (value !== ctx.engineApi) {
28
+ violations.push(
29
+ `${where} declares engineApi v${value}, this engine is v${ctx.engineApi}`,
30
+ );
31
+ }
32
+ }
33
+
34
+ for (const [where, text] of [
35
+ ['src/host/index.js', ctx.hostText],
36
+ ['src/client/index.js', ctx.clientText],
37
+ ]) {
38
+ if (text && !text.includes('ENGINE_API_VERSION')) {
39
+ violations.push(
40
+ `${where} hardcodes engineApi — import ENGINE_API_VERSION instead`,
41
+ );
42
+ }
43
+ }
44
+
45
+ return verdict(violations);
46
+ },
47
+ };
@@ -0,0 +1,27 @@
1
+ import { assertGameConfigShape } from '../../../lib/gamePlugin.js';
2
+ import { ERROR, skip, verdict } from '../result.js';
3
+
4
+ // Тот же гейт, что стоит на боевом пути загрузки плагина
5
+ // (lib/gamePlugin.js): девять обязательных путей gameConfig плюс связь
6
+ // spectatorTeam ↔ teams. Дублировать его список здесь нельзя — он обязан
7
+ // эволюционировать в одном месте.
8
+ export default {
9
+ id: 'B3',
10
+ name: 'gameConfigShape',
11
+ level: ERROR,
12
+ title: 'gameConfig has the nine paths the engine reads before any logic',
13
+
14
+ check(ctx) {
15
+ if (!ctx.hostPlugin) {
16
+ return skip('host plugin not loaded');
17
+ }
18
+
19
+ try {
20
+ assertGameConfigShape(ctx.hostPlugin);
21
+ } catch (error) {
22
+ return verdict([error.message]);
23
+ }
24
+
25
+ return verdict([]);
26
+ },
27
+ };
@@ -0,0 +1,37 @@
1
+ import { ERROR, skip, verdict } from '../result.js';
2
+
3
+ // teams/spectatorTeam. Опечатка в spectatorTeam ловится гейтом B3; здесь —
4
+ // вторая половина контракта: играющая команда должна быть хотя бы одна,
5
+ // иначе раунд некому начинать, а все участники висят наблюдателями.
6
+ export default {
7
+ id: 'B4',
8
+ name: 'teams',
9
+ level: ERROR,
10
+ title: 'spectatorTeam is a teams key and at least one playing team exists',
11
+
12
+ check(ctx) {
13
+ const teams = ctx.gameConfig?.teams;
14
+
15
+ if (!teams) {
16
+ return skip('no gameConfig.teams');
17
+ }
18
+
19
+ const { spectatorTeam } = ctx.gameConfig;
20
+ const violations = [];
21
+
22
+ if (teams[spectatorTeam] === undefined) {
23
+ violations.push(
24
+ `spectatorTeam "${spectatorTeam}" is not a key of teams ` +
25
+ `(${Object.keys(teams).join(', ')})`,
26
+ );
27
+ }
28
+
29
+ const playing = Object.keys(teams).filter(team => team !== spectatorTeam);
30
+
31
+ if (!playing.length) {
32
+ violations.push('teams declares no playing team besides the spectators');
33
+ }
34
+
35
+ return verdict(violations);
36
+ },
37
+ };