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.
- package/bin/vimp-contract.js +110 -0
- package/core/Cargo.toml +17 -0
- package/package.json +4 -2
- package/src/client/components/view/Game.js +8 -12
- package/src/client/main.js +5 -0
- package/src/devtools/contract/format.js +65 -0
- package/src/devtools/contract/index.js +69 -0
- package/src/devtools/contract/loadContext.js +250 -0
- package/src/devtools/contract/result.js +55 -0
- package/src/devtools/contract/rules/a1-package-fields.js +81 -0
- package/src/devtools/contract/rules/a2-package-scripts.js +36 -0
- package/src/devtools/contract/rules/a3-entry-paths.js +30 -0
- package/src/devtools/contract/rules/a4-vite-config.js +47 -0
- package/src/devtools/contract/rules/a5-cargo-core.js +194 -0
- package/src/devtools/contract/rules/a6-manifest.js +78 -0
- package/src/devtools/contract/rules/b1-host-shape.js +50 -0
- package/src/devtools/contract/rules/b10-respawns.js +52 -0
- package/src/devtools/contract/rules/b2-engine-api.js +47 -0
- package/src/devtools/contract/rules/b3-game-config-shape.js +27 -0
- package/src/devtools/contract/rules/b4-teams.js +37 -0
- package/src/devtools/contract/rules/b5-room-form.js +43 -0
- package/src/devtools/contract/rules/b6-panel-reserved-key.js +29 -0
- package/src/devtools/contract/rules/b7-chat-commands.js +49 -0
- package/src/devtools/contract/rules/b8-system-messages.js +48 -0
- package/src/devtools/contract/rules/b9-vote-names.js +48 -0
- package/src/devtools/contract/rules/c1-client-shape.js +52 -0
- package/src/devtools/contract/rules/c10-auth-schema.js +55 -0
- package/src/devtools/contract/rules/c2-parts-registered.js +43 -0
- package/src/devtools/contract/rules/c3-game-sets-coverage.js +35 -0
- package/src/devtools/contract/rules/c4-component-dependencies.js +33 -0
- package/src/devtools/contract/rules/c5-panel-time-field.js +42 -0
- package/src/devtools/contract/rules/c6-stat-columns.js +36 -0
- package/src/devtools/contract/rules/c7-key-sets.js +71 -0
- package/src/devtools/contract/rules/c8-baked-assets.js +34 -0
- package/src/devtools/contract/rules/c9-chat-messages.js +41 -0
- package/src/devtools/contract/rules/d1-snapshot-ids.js +36 -0
- package/src/devtools/contract/rules/d2-snapshot-classes.js +42 -0
- package/src/devtools/contract/rules/d3-snapshot-interp.js +45 -0
- package/src/devtools/contract/rules/e1-sound-pairs.js +40 -0
- package/src/devtools/contract/rules/e2-map-images.js +40 -0
- package/src/devtools/contract/rules/e3-sound-registry.js +25 -0
- package/src/devtools/contract/rules/index.js +44 -0
- package/src/host/meta/player/ParticipantManager.js +6 -0
- package/tests/fixtures/miniGame/config/client.js +10 -2
- package/tests/fixtures/miniGame/config/game.js +9 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
checkContract,
|
|
4
|
+
hasBlockingFailure,
|
|
5
|
+
} from '../src/devtools/contract/index.js';
|
|
6
|
+
import { formatContract } from '../src/devtools/contract/format.js';
|
|
7
|
+
|
|
8
|
+
// CLI статической проверки контракта: правка → npm run check:contract →
|
|
9
|
+
// список нарушенных правил, без сборки и без браузера. Дополняет
|
|
10
|
+
// vimp-sim: раннер судит по прогону, этот инструмент — по конфигам.
|
|
11
|
+
|
|
12
|
+
class UsageError extends Error {}
|
|
13
|
+
|
|
14
|
+
const USAGE = `Usage: vimp-contract [options]
|
|
15
|
+
|
|
16
|
+
--game <path> game package directory (default: .)
|
|
17
|
+
--json print the machine-readable report instead of text
|
|
18
|
+
--quiet print only failures and the summary
|
|
19
|
+
--strict treat warnings as errors (exit code 1)
|
|
20
|
+
--help
|
|
21
|
+
`;
|
|
22
|
+
|
|
23
|
+
async function main(argv) {
|
|
24
|
+
const args = parseArgs(argv);
|
|
25
|
+
|
|
26
|
+
if (args.help) {
|
|
27
|
+
process.stdout.write(USAGE);
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const game = args.game ?? '.';
|
|
32
|
+
const report = await checkContract(game);
|
|
33
|
+
|
|
34
|
+
process.stdout.write(
|
|
35
|
+
args.json
|
|
36
|
+
? `${JSON.stringify(report, null, 2)}\n`
|
|
37
|
+
: formatContract(report, { quiet: args.quiet }),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// ни одного вердикта — обычно опечатка в пути. Нулевой код выхода здесь
|
|
41
|
+
// читался бы как «проверено, всё хорошо», хотя проверено ничего
|
|
42
|
+
if (report.summary.passed === 0 && report.summary.failed === 0) {
|
|
43
|
+
process.stderr.write(
|
|
44
|
+
`nothing to check in '${game}' — no rule found its input; ` +
|
|
45
|
+
'is this a game package directory?\n',
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return hasBlockingFailure(report, args.strict === true) ? 1 : 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseArgs(argv) {
|
|
55
|
+
const args = {};
|
|
56
|
+
|
|
57
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
58
|
+
const arg = argv[i];
|
|
59
|
+
|
|
60
|
+
switch (arg) {
|
|
61
|
+
case '--help':
|
|
62
|
+
case '-h':
|
|
63
|
+
args.help = true;
|
|
64
|
+
break;
|
|
65
|
+
|
|
66
|
+
case '--json':
|
|
67
|
+
args.json = true;
|
|
68
|
+
break;
|
|
69
|
+
|
|
70
|
+
case '--quiet':
|
|
71
|
+
args.quiet = true;
|
|
72
|
+
break;
|
|
73
|
+
|
|
74
|
+
case '--strict':
|
|
75
|
+
args.strict = true;
|
|
76
|
+
break;
|
|
77
|
+
|
|
78
|
+
case '--game':
|
|
79
|
+
i += 1;
|
|
80
|
+
|
|
81
|
+
// «--game» без значения тихо проверял бы текущий каталог — то есть
|
|
82
|
+
// сам движок; путь, начинающийся с '--', тем самым запрещён
|
|
83
|
+
if (argv[i] === undefined || argv[i].startsWith('--')) {
|
|
84
|
+
throw new UsageError(`option '${arg}' needs a value\n\n${USAGE}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
args.game = argv[i];
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
default:
|
|
91
|
+
throw new UsageError(`unknown option '${arg}'\n\n${USAGE}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return args;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
main(process.argv.slice(2)).then(
|
|
99
|
+
code => {
|
|
100
|
+
process.exitCode = code;
|
|
101
|
+
},
|
|
102
|
+
error => {
|
|
103
|
+
process.stderr.write(
|
|
104
|
+
error instanceof UsageError
|
|
105
|
+
? `${error.message}\n`
|
|
106
|
+
: `${error.stack ?? error.message}\n`,
|
|
107
|
+
);
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
},
|
|
110
|
+
);
|
package/core/Cargo.toml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[package]
|
|
2
|
+
name = "vimp-engine-core"
|
|
3
|
+
version = "0.3.2"
|
|
4
|
+
edition = "2024"
|
|
5
|
+
description = "VIMP — движковый каркас симуляции (физика, карта, снапшот-фрейминг, интерполяция/предикт/raycast-примитивы, нав-утилиты). Без wasm-bindgen — обёртки для WASM-ABI собирает game-crate."
|
|
6
|
+
license = "MIT"
|
|
7
|
+
repository = "https://github.com/lgick/vimp"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
|
|
10
|
+
[lib]
|
|
11
|
+
crate-type = ["rlib"]
|
|
12
|
+
|
|
13
|
+
[dependencies]
|
|
14
|
+
indexmap = { workspace = true }
|
|
15
|
+
rapier2d = { workspace = true }
|
|
16
|
+
serde = { workspace = true }
|
|
17
|
+
serde_json = { workspace = true }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vimp-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"src/standalone",
|
|
31
31
|
"src/devtools",
|
|
32
32
|
"tests/fixtures",
|
|
33
|
+
"core/Cargo.toml",
|
|
33
34
|
"bin"
|
|
34
35
|
],
|
|
35
36
|
"scripts": {
|
|
@@ -56,6 +57,7 @@
|
|
|
56
57
|
"vite-plugin-pug": "^0.4.1"
|
|
57
58
|
},
|
|
58
59
|
"bin": {
|
|
59
|
-
"vimp-sim": "bin/vimp-sim.js"
|
|
60
|
+
"vimp-sim": "bin/vimp-sim.js",
|
|
61
|
+
"vimp-contract": "bin/vimp-contract.js"
|
|
60
62
|
}
|
|
61
63
|
}
|
|
@@ -20,19 +20,15 @@ export default class GameView {
|
|
|
20
20
|
|
|
21
21
|
// создает экземпляр на полотне
|
|
22
22
|
add(instance) {
|
|
23
|
+
// порядок отрисовки задаёт только zIndex парта. sortChildren() в
|
|
24
|
+
// PixiJS 8 компаратор не принимает (сортирует по zIndex) и выходит на
|
|
25
|
+
// первой строке, пока не поднят sortDirty; сам Pixi поднимает его лишь
|
|
26
|
+
// при записи zIndex уже добавленному в сцену объекту, а парты ставят
|
|
27
|
+
// zIndex в конструкторе — без sortableChildren сортировки не будет
|
|
28
|
+
// вовсе, и слои лягут в порядке добавления
|
|
29
|
+
this._app.stage.sortableChildren = true;
|
|
23
30
|
this._app.stage.addChild(instance);
|
|
24
|
-
|
|
25
|
-
this._app.stage.sortChildren((a, b) => {
|
|
26
|
-
if (a.layer < b.layer) {
|
|
27
|
-
return -1;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (a.layer > b.layer) {
|
|
31
|
-
return 1;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return 0;
|
|
35
|
-
});
|
|
31
|
+
this._app.stage.sortChildren();
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
// создаёт эффект и запускает его
|
package/src/client/main.js
CHANGED
|
@@ -345,6 +345,11 @@ socketMethods[PS_CONFIG_DATA] = async data => {
|
|
|
345
345
|
const availableServices = {
|
|
346
346
|
renderer: app.renderer,
|
|
347
347
|
soundManager,
|
|
348
|
+
// база ассетов игры — тем же каналом, что и путь к звукам (см. выше):
|
|
349
|
+
// картинки карт живут в пакете игры (dist/img/), движок их не раздаёт.
|
|
350
|
+
// part, объявивший сервис в componentDependencies, строит URL сам —
|
|
351
|
+
// движок не знает ни имён файлов, ни их раскладки внутри пакета
|
|
352
|
+
assetsBase: activeGameManifest.assetsBase,
|
|
348
353
|
};
|
|
349
354
|
|
|
350
355
|
// если есть данные для запекания компонентов
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ERROR, FAIL, PASS, SKIP } from './result.js';
|
|
2
|
+
|
|
3
|
+
// Человекочитаемый отчёт — та же разметка, что у секции инвариантов
|
|
4
|
+
// headless-раннера (devtools/report.js): один значок, id, имя правила,
|
|
5
|
+
// заголовок, ниже — нарушения.
|
|
6
|
+
const MARKS = { [PASS]: '✅', [FAIL]: '❌', [SKIP]: '⏭️' };
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {Object} report - Результат checkContract.
|
|
10
|
+
* @param {Object} [options]
|
|
11
|
+
* @param {boolean} [options.quiet] - Только нарушения и сводка.
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
export function formatContract(report, { quiet = false } = {}) {
|
|
15
|
+
const { summary } = report;
|
|
16
|
+
const lines = [`# Contract report — ${report.game.id ?? '(unknown game)'}`, ''];
|
|
17
|
+
|
|
18
|
+
lines.push(`- game: \`${report.game.dir}\``);
|
|
19
|
+
lines.push(`- engine API: v${report.game.engineApi}`);
|
|
20
|
+
lines.push(
|
|
21
|
+
`- host: \`${report.game.hostSource ?? '(not loaded)'}\`, ` +
|
|
22
|
+
`client: \`${report.game.clientSource ?? '(not loaded)'}\``,
|
|
23
|
+
);
|
|
24
|
+
lines.push(`- built: ${report.game.built ? 'yes' : 'no (dist/ is missing)'}`);
|
|
25
|
+
lines.push('');
|
|
26
|
+
|
|
27
|
+
for (const note of report.notes) {
|
|
28
|
+
lines.push(`> ${note}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (report.notes.length) {
|
|
32
|
+
lines.push('');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
lines.push(
|
|
36
|
+
`${summary.passed} passed, ${summary.failed} failed ` +
|
|
37
|
+
`(${summary.errors} error, ${summary.warnings} warning), ` +
|
|
38
|
+
`${summary.skipped} skipped`,
|
|
39
|
+
);
|
|
40
|
+
lines.push('');
|
|
41
|
+
|
|
42
|
+
for (const result of report.results) {
|
|
43
|
+
if (quiet && result.status !== FAIL) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const level = result.level === ERROR ? '' : ` (${result.level})`;
|
|
48
|
+
const note = result.note ? ` — ${result.note}` : '';
|
|
49
|
+
|
|
50
|
+
lines.push(
|
|
51
|
+
`- ${MARKS[result.status]} ${result.id}. \`${result.name}\`${level}: ` +
|
|
52
|
+
`${result.title}${note}`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
for (const violation of result.violations) {
|
|
56
|
+
lines.push(` - ${violation}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
lines.push('');
|
|
61
|
+
|
|
62
|
+
return `${lines.join('\n')}\n`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default formatContract;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { loadContext } from './loadContext.js';
|
|
2
|
+
import { rules } from './rules/index.js';
|
|
3
|
+
import { ERROR, FAIL, summarize } from './result.js';
|
|
4
|
+
|
|
5
|
+
// Статическая проверка контракта «движок ↔ игра»: то же намерение, что у
|
|
6
|
+
// headless-раннера (молчаливое нарушение обязано становиться строкой),
|
|
7
|
+
// только без прогона матча — по конфигам, манифесту и живым объектам
|
|
8
|
+
// плагина. Живёт в движке, а не копируется в каждую игру: правила
|
|
9
|
+
// эволюционируют вместе с ENGINE_API_VERSION и применимы к любой игре.
|
|
10
|
+
|
|
11
|
+
export { loadContext } from './loadContext.js';
|
|
12
|
+
export { rules } from './rules/index.js';
|
|
13
|
+
export * from './result.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} gameDir - Каталог пакета игры.
|
|
17
|
+
* @returns {Promise<Object>} { game, results, summary, notes }.
|
|
18
|
+
*/
|
|
19
|
+
export async function checkContract(gameDir) {
|
|
20
|
+
const ctx = await loadContext(gameDir);
|
|
21
|
+
const results = runRules(ctx);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
game: {
|
|
25
|
+
id: ctx.hostPlugin?.id ?? ctx.manifest?.id ?? null,
|
|
26
|
+
dir: ctx.dir,
|
|
27
|
+
hostSource: ctx.hostSource ?? null,
|
|
28
|
+
clientSource: ctx.clientSource ?? null,
|
|
29
|
+
built: Boolean(ctx.manifest),
|
|
30
|
+
engineApi: ctx.engineApi,
|
|
31
|
+
},
|
|
32
|
+
results,
|
|
33
|
+
summary: summarize(results),
|
|
34
|
+
notes: ctx.notes,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Прогон правил по готовому контексту (тесты собирают его вручную).
|
|
40
|
+
* @param {Object} ctx
|
|
41
|
+
* @returns {Array<Object>} Результаты правил.
|
|
42
|
+
*/
|
|
43
|
+
export function runRules(ctx) {
|
|
44
|
+
return rules.map(rule => {
|
|
45
|
+
const { status, violations, note } = rule.check(ctx);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
id: rule.id,
|
|
49
|
+
name: rule.name,
|
|
50
|
+
title: rule.title,
|
|
51
|
+
level: rule.level,
|
|
52
|
+
status,
|
|
53
|
+
violations,
|
|
54
|
+
note,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {Object} report - Результат checkContract.
|
|
61
|
+
* @param {boolean} [strict] - Считать предупреждения ошибками.
|
|
62
|
+
* @returns {boolean} Есть ли отказ, который обязан валить прогон.
|
|
63
|
+
*/
|
|
64
|
+
export function hasBlockingFailure(report, strict = false) {
|
|
65
|
+
return report.results.some(
|
|
66
|
+
result =>
|
|
67
|
+
result.status === FAIL && (strict || result.level === ERROR),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { access, readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { ENGINE_API_VERSION } from '../../config/opcodes.js';
|
|
5
|
+
|
|
6
|
+
// Сбор контекста статической проверки: по каталогу пакета игры собирается
|
|
7
|
+
// всё, что удалось прочитать, и помечается недостающее. Ни одна неудача не
|
|
8
|
+
// бросает — правило, которому не хватило входа, вернёт skip.
|
|
9
|
+
//
|
|
10
|
+
// Живые объекты плагинов важнее исходников: gameConfig/authSchema/parts —
|
|
11
|
+
// это вычисленные структуры (импорты, спреды, сгенерированные карты), и
|
|
12
|
+
// парсинг текста врал бы. Поэтому половины плагина импортируются как
|
|
13
|
+
// модули; исходники читаются только там, где проверяется буква кода
|
|
14
|
+
// (engineApi литералом или импортом).
|
|
15
|
+
|
|
16
|
+
// каталог движка (packages/engine) — из него берётся версия крейта, с
|
|
17
|
+
// которой сверяется пин игры. core/Cargo.toml числится в "files"
|
|
18
|
+
// package.json именно ради этого: иначе в установке из npm версии крейта
|
|
19
|
+
// нет, и правило A5 проверяло бы пин только внутри монорепозитория
|
|
20
|
+
const ENGINE_DIR = new URL('../../../', import.meta.url);
|
|
21
|
+
|
|
22
|
+
const HOST_ENTRIES = ['src/host/index.js', 'host/index.js'];
|
|
23
|
+
const CLIENT_ENTRIES = ['src/client/index.js', 'client/index.js'];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} gameDir - Каталог пакета игры.
|
|
27
|
+
* @returns {Promise<Object>} Контекст правил.
|
|
28
|
+
*/
|
|
29
|
+
export async function loadContext(gameDir) {
|
|
30
|
+
const dir = path.resolve(gameDir);
|
|
31
|
+
const ctx = {
|
|
32
|
+
dir,
|
|
33
|
+
engineApi: ENGINE_API_VERSION,
|
|
34
|
+
engineCoreVersion: await readEngineCoreVersion(),
|
|
35
|
+
pkg: await readJson(path.join(dir, 'package.json')),
|
|
36
|
+
viteText: await readText(path.join(dir, 'vite.config.js')),
|
|
37
|
+
cargoText: await readText(path.join(dir, 'core', 'Cargo.toml')),
|
|
38
|
+
workspaceCargoText: await readText(path.join(dir, 'Cargo.toml')),
|
|
39
|
+
hostText: await readFirstText(dir, HOST_ENTRIES),
|
|
40
|
+
clientText: await readFirstText(dir, CLIENT_ENTRIES),
|
|
41
|
+
hostEntryExists: await exists(path.join(dir, 'src/host/index.js')),
|
|
42
|
+
clientEntryExists: await exists(path.join(dir, 'src/client/index.js')),
|
|
43
|
+
notes: [],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const distDir = path.join(dir, 'dist');
|
|
47
|
+
|
|
48
|
+
ctx.manifest = await readJson(path.join(distDir, 'manifest.json'));
|
|
49
|
+
ctx.distDir = ctx.manifest ? distDir : null;
|
|
50
|
+
ctx.distFiles = ctx.manifest ? await listFiles(distDir) : null;
|
|
51
|
+
|
|
52
|
+
ctx.hostPlugin = await loadHalf(ctx, 'host', HOST_ENTRIES);
|
|
53
|
+
ctx.clientPlugin = await loadHalf(ctx, 'client', CLIENT_ENTRIES);
|
|
54
|
+
|
|
55
|
+
ctx.gameConfig = ctx.hostPlugin?.gameConfig ?? null;
|
|
56
|
+
ctx.authSchema = ctx.hostPlugin?.authSchema ?? null;
|
|
57
|
+
ctx.clientConfig = buildClientConfig(ctx);
|
|
58
|
+
|
|
59
|
+
return ctx;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Половина плагина: сначала исходник (он свежее сборки), при неудаче —
|
|
63
|
+
// собранный entry из манифеста. Клиентский исходник в Node обычно не
|
|
64
|
+
// импортируется вовсе (`import './game.css'` — расширение Vite, не Node),
|
|
65
|
+
// поэтому падение первого кандидата — норма, а не ошибка.
|
|
66
|
+
async function loadHalf(ctx, half, candidates) {
|
|
67
|
+
const sources = [
|
|
68
|
+
...candidates.map(rel => path.join(ctx.dir, rel)),
|
|
69
|
+
distEntry(ctx, half),
|
|
70
|
+
].filter(Boolean);
|
|
71
|
+
|
|
72
|
+
const failures = [];
|
|
73
|
+
|
|
74
|
+
for (const file of sources) {
|
|
75
|
+
if (!(await exists(file))) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const module = await import(pathToFileURL(file).href);
|
|
81
|
+
const source = path.relative(ctx.dir, file);
|
|
82
|
+
|
|
83
|
+
ctx[`${half}Source`] = source;
|
|
84
|
+
|
|
85
|
+
// модуль импортировался, но плагина не отдал: движок берёт половину
|
|
86
|
+
// ровно из default-экспорта, поэтому это отказ, а не «нечего
|
|
87
|
+
// проверять» — без ноты автор увидел бы десяток немых skip
|
|
88
|
+
if (module.default === undefined || module.default === null) {
|
|
89
|
+
ctx.notes.push(
|
|
90
|
+
`${half} plugin has no default export (${source}) — the engine ` +
|
|
91
|
+
'loads the half by it',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return module.default ?? null;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
failures.push(`${path.relative(ctx.dir, file)}: ${error.message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
ctx.notes.push(
|
|
102
|
+
failures.length
|
|
103
|
+
? `${half} plugin not loaded — ${failures.join('; ')}`
|
|
104
|
+
: `${half} plugin not found (looked for ${candidates.join(', ')})`,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// entries.* манифеста — URL-ы, какими их видит браузер; на диске им
|
|
111
|
+
// соответствует каталог манифеста (та же арифметика, что в
|
|
112
|
+
// lib/loadGamePackage.js)
|
|
113
|
+
function distEntry(ctx, half) {
|
|
114
|
+
const entry = ctx.manifest?.entries?.[half];
|
|
115
|
+
|
|
116
|
+
if (!entry || !ctx.distDir) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { assetsBase } = ctx.manifest;
|
|
121
|
+
const rel =
|
|
122
|
+
assetsBase && entry.startsWith(assetsBase)
|
|
123
|
+
? entry.slice(assetsBase.length)
|
|
124
|
+
: entry.replace(/^\/+/, '');
|
|
125
|
+
|
|
126
|
+
return path.resolve(ctx.distDir, rel);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// клиентский CONFIG_DATA игры собирается хостом (buildClientGameConfig
|
|
130
|
+
// вызывается движком безусловно) — своего файла у него нет
|
|
131
|
+
function buildClientConfig(ctx) {
|
|
132
|
+
if (typeof ctx.hostPlugin?.buildClientGameConfig !== 'function') {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
return ctx.hostPlugin.buildClientGameConfig();
|
|
138
|
+
} catch (error) {
|
|
139
|
+
ctx.notes.push(`buildClientGameConfig() threw: ${error.message}`);
|
|
140
|
+
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readEngineCoreVersion() {
|
|
146
|
+
const text = await readText(new URL('core/Cargo.toml', ENGINE_DIR));
|
|
147
|
+
|
|
148
|
+
return text ? (parseCrateVersion(text) ?? null) : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// версия пакета в секции [package] — первый `version = "…"` до следующей
|
|
152
|
+
// секции; TOML-парсера в зависимостях движка нет, а формат здесь наш.
|
|
153
|
+
//
|
|
154
|
+
// Копия живёт в packages/create-vimp-game/src/versions.js: скаффолдер
|
|
155
|
+
// ставится через `npm create` и обязан работать без движка в зависимостях.
|
|
156
|
+
// Разбор обязан совпадать — правьте обе или ни одной (см. тест
|
|
157
|
+
// tests/scaffold/versions.test.js).
|
|
158
|
+
export function parseCrateVersion(text) {
|
|
159
|
+
const section = text.split(/^\s*\[/m).find(part => part.startsWith('package]'));
|
|
160
|
+
|
|
161
|
+
return section?.match(/^\s*version\s*=\s*"([^"]+)"/m)?.[1] ?? null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function readJson(file) {
|
|
165
|
+
const text = await readText(file);
|
|
166
|
+
|
|
167
|
+
if (text === null) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
return JSON.parse(text);
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function readText(file) {
|
|
179
|
+
try {
|
|
180
|
+
return await readFile(file, 'utf8');
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function readFirstText(dir, candidates) {
|
|
187
|
+
for (const rel of candidates) {
|
|
188
|
+
const text = await readText(path.join(dir, rel));
|
|
189
|
+
|
|
190
|
+
if (text !== null) {
|
|
191
|
+
return text;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function exists(file) {
|
|
199
|
+
try {
|
|
200
|
+
await access(file);
|
|
201
|
+
|
|
202
|
+
return true;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// плоский список путей внутри dist/ (posix-разделители): правила проверяют
|
|
209
|
+
// наличие ассетов и entry-файлов по нему, а не десятком stat()
|
|
210
|
+
async function listFiles(root) {
|
|
211
|
+
const found = new Set();
|
|
212
|
+
|
|
213
|
+
async function walk(dir, prefix) {
|
|
214
|
+
let entries;
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
218
|
+
} catch {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const entry of entries) {
|
|
223
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
224
|
+
const full = path.join(dir, entry.name);
|
|
225
|
+
|
|
226
|
+
// симлинк-каталог (обычная dev-раскладка: dist/img -> ../assets/img)
|
|
227
|
+
// у readdir не isDirectory(); без разыменования он попал бы в список
|
|
228
|
+
// файлом, и правила ассетов дали бы ложный отказ
|
|
229
|
+
if (entry.isSymbolicLink() ? await isDirectory(full) : entry.isDirectory()) {
|
|
230
|
+
await walk(full, rel);
|
|
231
|
+
} else {
|
|
232
|
+
found.add(rel);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await walk(root, '');
|
|
238
|
+
|
|
239
|
+
return found;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function isDirectory(file) {
|
|
243
|
+
try {
|
|
244
|
+
return (await stat(file)).isDirectory();
|
|
245
|
+
} catch {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export default loadContext;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Словарь результатов статической проверки контракта. Он намеренно тот же,
|
|
2
|
+
// что у headless-раннера (devtools/invariants.js): pass / fail / skip —
|
|
3
|
+
// автор плагина читает оба отчёта подряд и не должен держать в голове две
|
|
4
|
+
// системы вердиктов. Отличие одно: у правила есть уровень (error / warn),
|
|
5
|
+
// потому что часть контракта — соглашения (пять колонок статистики), а не
|
|
6
|
+
// жёсткие отказы.
|
|
7
|
+
|
|
8
|
+
export const PASS = 'pass';
|
|
9
|
+
export const FAIL = 'fail';
|
|
10
|
+
export const SKIP = 'skip';
|
|
11
|
+
|
|
12
|
+
export const ERROR = 'error';
|
|
13
|
+
export const WARN = 'warn';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* «Проверять нечего»: входа нет (игра не собрана, нет package.json).
|
|
17
|
+
* skip никогда не маскирует нарушение — он означает отсутствие данных.
|
|
18
|
+
* @param {string} note - Чего именно не хватило.
|
|
19
|
+
* @returns {Object} { status, violations, note }.
|
|
20
|
+
*/
|
|
21
|
+
export function skip(note) {
|
|
22
|
+
return { status: SKIP, violations: [], note };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Вердикт по списку нарушений: пустой список — pass.
|
|
27
|
+
* @param {Array<string>} violations
|
|
28
|
+
* @param {string} [note]
|
|
29
|
+
* @returns {Object} { status, violations, note }.
|
|
30
|
+
*/
|
|
31
|
+
export function verdict(violations, note) {
|
|
32
|
+
return {
|
|
33
|
+
status: violations.length ? FAIL : PASS,
|
|
34
|
+
violations,
|
|
35
|
+
note,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Сводка по результатам правил.
|
|
41
|
+
* @param {Array<Object>} results
|
|
42
|
+
* @returns {Object} { passed, failed, skipped, errors, warnings, violations }.
|
|
43
|
+
*/
|
|
44
|
+
export function summarize(results) {
|
|
45
|
+
const failed = results.filter(r => r.status === FAIL);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
passed: results.filter(r => r.status === PASS).length,
|
|
49
|
+
failed: failed.length,
|
|
50
|
+
skipped: results.filter(r => r.status === SKIP).length,
|
|
51
|
+
errors: failed.filter(r => r.level === ERROR).length,
|
|
52
|
+
warnings: failed.filter(r => r.level === WARN).length,
|
|
53
|
+
violations: results.reduce((sum, r) => sum + r.violations.length, 0),
|
|
54
|
+
};
|
|
55
|
+
}
|