vimp-engine 0.5.0 → 0.6.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-sim.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { runScenario } from '../src/devtools/ScenarioRunner.js';
4
- import { loadGameForSim } from '../src/devtools/pluginLoader.js';
4
+ import { builtinScenario } from '../src/devtools/builtinScenario.js';
5
+ import { loadGameForSim, isFixture } from '../src/devtools/pluginLoader.js';
5
6
  import {
6
7
  checkDeterminism,
7
8
  summarize,
@@ -12,33 +13,37 @@ import { writeReport, formatMarkdown } from '../src/devtools/report.js';
12
13
  // CLI headless-прогона: правка → npm run sim → текстовый вердикт, без
13
14
  // браузера и без человека.
14
15
 
16
+ // ошибка разбора флагов печатается без стека — см. обработчик внизу файла
17
+ class UsageError extends Error {}
18
+
15
19
  const USAGE = `Usage: vimp-sim [options]
16
20
 
17
21
  --scenario <path> scenario JSON (default: built-in smoke scenario)
18
22
  --game <path> game package directory or dist/manifest.json
19
- --core <path> node build of the game core (overrides entries.wasmNode)
23
+ --core <path> node build of the game core (overrides entries.wasmNode);
24
+ only meaningful with --game — the built-in fixture has
25
+ no WASM core to override
20
26
  --out <dir> report root (default: .debug)
21
27
  --no-write print the report to stdout instead of writing files
22
28
  --determinism run the scenario twice and compare the frame streams
23
29
  --help
24
30
  `;
25
31
 
26
- // минимальный сценарий на фикстуре: один игрок заходит, едет вперёд,
27
- // отпускает клавишу этого хватает, чтобы контур доказал, что он замкнут
28
- const DEFAULT_SCENARIO = {
29
- version: 1,
30
- seed: 3812,
31
- participants: [{ id: 'p1', name: 'P1', model: 'm1' }],
32
- timeline: [
33
- { tick: 0, op: 'join', who: 'p1', team: 'team1' },
34
- { tick: 10, op: 'key', who: 'p1', action: 'down', name: 'forward' },
35
- { tick: 60, op: 'key', who: 'p1', action: 'up', name: 'forward' },
36
- ],
37
- // событийный ключ фикстуры в этом сценарии не стреляет объявлено явно,
38
- // иначе инвариант 2 честно посчитает это «сущность не спавнится»
39
- unusedSnapshotKeys: ['e1'],
40
- ticks: 120,
41
- };
32
+ // каждая строка с префиксом: дальше в stderr уходит список падений, и
33
+ // грепалки CI не должны путать предупреждение с ними
34
+ const BUILTIN_NOTICE =
35
+ 'notice: the built-in smoke scenario is running against --game. It only\n' +
36
+ 'notice: proves the loop closes on this plugin: key coverage (invariant 2)\n' +
37
+ 'notice: and prediction drift (invariant 9) are skipped — both need a\n' +
38
+ 'notice: scenario written for your game. See § Scenario format in the\n' +
39
+ "notice: engine's docs/en/debugging.md (github.com/lgick/vimp), then run\n" +
40
+ 'notice: vimp-sim --scenario <file>.\n\n';
41
+
42
+ // типичная опечатка «--core без --game»: фикстурное ядро — обычный JS и
43
+ // wasmUrl не смотрит, поэтому прогон был бы зелёным, не тронув ядро игры
44
+ const STRAY_CORE_NOTICE =
45
+ 'notice: --core without --game does nothing: the run falls back to the\n' +
46
+ "notice: built-in fixture, whose core is plain JS. Add --game <path>.\n\n";
42
47
 
43
48
  async function main(argv) {
44
49
  const args = parseArgs(argv);
@@ -48,13 +53,23 @@ async function main(argv) {
48
53
  return 0;
49
54
  }
50
55
 
51
- const scenario = args.scenario
52
- ? JSON.parse(await readFile(args.scenario, 'utf8'))
53
- : DEFAULT_SCENARIO;
56
+ if (args.core && !args.game) {
57
+ process.stderr.write(STRAY_CORE_NOTICE);
58
+ }
54
59
 
55
60
  // плагин грузится один раз: второй прогон самопроверки детерминизма
56
- // обязан идти на том же ядре, иначе он проверял бы загрузчик, а не мир
61
+ // обязан идти на том же ядре, иначе он проверял бы загрузчик, а не мир.
62
+ // Загрузка идёт до сборки сценария: встроенный берёт имена модели, команды
63
+ // и клавиши из gameConfig игры
57
64
  const plugin = await loadGameForSim({ game: args.game, core: args.core });
65
+
66
+ if (!args.scenario && !isFixture(plugin)) {
67
+ process.stderr.write(BUILTIN_NOTICE);
68
+ }
69
+
70
+ const scenario = args.scenario
71
+ ? JSON.parse(await readFile(args.scenario, 'utf8'))
72
+ : builtinScenario(plugin);
58
73
  // хеши потока кадров собираются только под --determinism: их единственный
59
74
  // потребитель — сравнение двух прогонов, а объём линеен по длине матча
60
75
  const captureFrames = args.determinism === true;
@@ -118,11 +133,20 @@ function parseArgs(argv) {
118
133
  case '--core':
119
134
  case '--out':
120
135
  i += 1;
136
+
137
+ // «--game» последним аргументом (или перед следующим флагом) тихо
138
+ // уводил на фикстуру: прогон зелёный, игра не тронута. Путь,
139
+ // начинающийся с '--', тем самым запрещён — таких не бывает, а
140
+ // явный отказ лучше ENOENT про каталог '--determinism'
141
+ if (argv[i] === undefined || argv[i].startsWith('--')) {
142
+ throw new UsageError(`option '${arg}' needs a value\n\n${USAGE}`);
143
+ }
144
+
121
145
  args[arg.slice(2)] = argv[i];
122
146
  break;
123
147
 
124
148
  default:
125
- throw new Error(`unknown option '${arg}'\n\n${USAGE}`);
149
+ throw new UsageError(`unknown option '${arg}'\n\n${USAGE}`);
126
150
  }
127
151
  }
128
152
 
@@ -134,7 +158,13 @@ main(process.argv.slice(2)).then(
134
158
  process.exitCode = code;
135
159
  },
136
160
  error => {
137
- process.stderr.write(`${error.stack ?? error.message}\n`);
161
+ // опечатка в флаге — не краш инструмента: стек тут лишний шум поверх
162
+ // USAGE, который пользователь и должен прочитать
163
+ process.stderr.write(
164
+ error instanceof UsageError
165
+ ? `${error.message}\n`
166
+ : `${error.stack ?? error.message}\n`,
167
+ );
138
168
  process.exitCode = 1;
139
169
  },
140
170
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -4,7 +4,11 @@ import RecordingSocketManager from './RecordingSocketManager.js';
4
4
  import VirtualClient from './VirtualClient.js';
5
5
  import { loadGameForSim } from './pluginLoader.js';
6
6
  import { resetHostSingletons } from './resetHostSingletons.js';
7
- import { checkInvariants, summarize } from './invariants.js';
7
+ import {
8
+ checkInvariants,
9
+ summarize,
10
+ ALL_KEYS_UNAUDITED,
11
+ } from './invariants.js';
8
12
  import { inspectCore, inspectHost } from './inspectHost.js';
9
13
 
10
14
  // Прогон сценария целиком в одном Node-процессе: авторитетный хост
@@ -58,6 +62,21 @@ export function parseScenario(raw) {
58
62
  const timeline = [...(raw.timeline ?? [])].sort(
59
63
  (a, b) => (a.tick ?? 0) - (b.tick ?? 0),
60
64
  );
65
+ const unusedSnapshotKeys = raw.unusedSnapshotKeys ?? [];
66
+
67
+ // строка тут легальна ровно одна: без проверки забытые скобки
68
+ // ("unusedSnapshotKeys": "w1") молча разложились бы в набор символов, и
69
+ // объявленный ключ всё равно попал бы в нарушения инварианта 2
70
+ if (
71
+ unusedSnapshotKeys !== ALL_KEYS_UNAUDITED &&
72
+ (!Array.isArray(unusedSnapshotKeys) ||
73
+ unusedSnapshotKeys.some(key => typeof key !== 'string'))
74
+ ) {
75
+ throw new Error(
76
+ 'scenario: unusedSnapshotKeys must be an array of snapshot keys or ' +
77
+ `"${ALL_KEYS_UNAUDITED}"`,
78
+ );
79
+ }
61
80
 
62
81
  return {
63
82
  version: 1,
@@ -69,10 +88,11 @@ export function parseScenario(raw) {
69
88
  timeline,
70
89
  // ключи схемы, которых в этом сценарии заведомо не будет (инвариант 2):
71
90
  // «сущность не спавнится» отличается от «сценарий её не трогает» только
72
- // этим объявлением
73
- unusedSnapshotKeys: raw.unusedSnapshotKeys ?? [],
91
+ // этим объявлением. '*' — сценарий не берётся судить покрытие ключей
92
+ // вовсе (встроенный смоук на чужой игре), и инвариант честно пропускается
93
+ unusedSnapshotKeys,
74
94
  // пороги детектора рассинхрона предикта (инвариант 9); {} — дефолты
75
- // ядра, null — детектор выключен
95
+ // ядра, null — детектор выключен, и инвариант тоже пропускается
76
96
  divergence: raw.divergence === null ? null : (raw.divergence ?? {}),
77
97
  ticks: raw.ticks ?? 600,
78
98
  dumpTicks: raw.dumpTicks ?? null,
@@ -0,0 +1,95 @@
1
+ import { ALL_KEYS_UNAUDITED } from './invariants.js';
2
+ import { isFixture } from './pluginLoader.js';
3
+
4
+ // Встроенный сценарий headless-прогона: один участник заходит, едет вперёд,
5
+ // отпускает клавишу. Этого хватает, чтобы контур доказал, что он замкнут
6
+ // (хост → кадр → клиентское ядро → сцена), и не хватает ни на что больше.
7
+ //
8
+ // Идентификаторы берутся из gameConfig самой игры: имя модели, команды и
9
+ // клавиши — часть игры, а не движка. Захардкоженные фикстурные 'm1' /
10
+ // 'team1' / 'forward' на чужом плагине означают падение в ядре («unknown
11
+ // model») либо красный инвариант 8 у исправного плагина.
12
+
13
+ // Ввод начинается на 40-м тике, а не сразу: кадр спавна с force_reset
14
+ // приходит примерно на interpolation.delay (движковый дефолт — 100 мс, тут
15
+ // ~3× запаса) позже входа и чистит удержанные клавиши предиктора
16
+ // (docs/en/debugging.md) — встроенный сценарий не должен демонстрировать
17
+ // ровно ту ловушку, от которой предостерегает документация
18
+ const INPUT_DOWN_TICK = 40;
19
+ const INPUT_UP_TICK = 100;
20
+ const TICKS = 120;
21
+
22
+ // событийный ключ фикстуры в этом сценарии не стреляет — объявлено явно,
23
+ // иначе инвариант 2 честно посчитает это «сущность не спавнится»
24
+ const FIXTURE_UNUSED_KEYS = ['e1'];
25
+
26
+ const firstKey = dict => Object.keys(dict ?? {})[0];
27
+
28
+ // Клавиша для смоука обязана быть удерживаемой: по конвенции playerKeys
29
+ // (docs/ai/04-client-plugin.md) `type: 1` — триггер, у которого 'up'
30
+ // игнорируется, и «нажал — отпустил» не даёт ни удержания, ни движения.
31
+ // Порядок объявления в playerKeys контрактом не задан, поэтому берём первую
32
+ // без type; если игра объявила одни триггеры — первую любую, смоук всё равно
33
+ // должен запуститься. Движок сам type не интерпретирует (это дело ядра
34
+ // игры) — здесь это эвристика выбора, а не поведение движка.
35
+ const heldKey = playerKeys => {
36
+ const keys = Object.entries(playerKeys ?? {});
37
+
38
+ return (keys.find(([, spec]) => !spec?.type) ?? keys[0])?.[0];
39
+ };
40
+
41
+ /**
42
+ * Собирает встроенный смоук-сценарий под конкретную игру.
43
+ * @param {Object} plugin - Результат loadGameForSim.
44
+ * @returns {Object} Сценарий в формате runScenario.
45
+ */
46
+ export function builtinScenario(plugin) {
47
+ const config = plugin.hostPlugin.gameConfig;
48
+ const model = firstKey(config.parts?.models);
49
+ const key = heldKey(config.playerKeys);
50
+ const team = Object.keys(config.teams ?? {}).find(
51
+ name => name !== config.spectatorTeam,
52
+ );
53
+
54
+ if (!model || !key || !team) {
55
+ throw new Error(
56
+ `game "${plugin.id}": the built-in scenario has nothing to drive ` +
57
+ `(model: ${model ?? '—'}, playable team: ${team ?? '—'}, ` +
58
+ `player key: ${key ?? '—'}) — write a scenario for this game and ` +
59
+ `pass --scenario <file>`,
60
+ );
61
+ }
62
+
63
+ const scenario = {
64
+ version: 1,
65
+ seed: 3812,
66
+ participants: [{ id: 'p1', name: 'P1', model }],
67
+ timeline: [
68
+ { tick: 0, op: 'join', who: 'p1', team },
69
+ {
70
+ tick: INPUT_DOWN_TICK,
71
+ op: 'key',
72
+ who: 'p1',
73
+ action: 'down',
74
+ name: key,
75
+ },
76
+ { tick: INPUT_UP_TICK, op: 'key', who: 'p1', action: 'up', name: key },
77
+ ],
78
+ ticks: TICKS,
79
+ };
80
+
81
+ if (isFixture(plugin)) {
82
+ return { ...scenario, unusedSnapshotKeys: FIXTURE_UNUSED_KEYS };
83
+ }
84
+
85
+ // На чужой игре это смоук контура, а не аудит контракта: сценарий не знает
86
+ // ни ключей её схемы, ни её порогов дрейфа (у каждой игры своя раскладка
87
+ // player-блока и свои единицы). Судить исправный плагин по фикстурным
88
+ // значениям значит выдавать ему красный вердикт, поэтому проверки 2 и 9
89
+ // честно пропускаются, а не притворяются пройденными.
90
+ return {
91
+ ...scenario,
92
+ unusedSnapshotKeys: ALL_KEYS_UNAUDITED,
93
+ divergence: null,
94
+ };
95
+ }
@@ -15,6 +15,10 @@ export const PASS = 'pass';
15
15
  export const FAIL = 'fail';
16
16
  export const SKIP = 'skip';
17
17
 
18
+ // scenario.unusedSnapshotKeys: вместо списка ключей — «этот сценарий вообще
19
+ // не берётся судить покрытие схемы» (встроенный смоук на чужой игре)
20
+ export const ALL_KEYS_UNAUDITED = '*';
21
+
18
22
  // список проверок в порядке plan/done/ai-debug/stage_3.md
19
23
  const CHECKS = [
20
24
  [1, 'finiteValues', 'no NaN/Infinity in decoded fields and hot buffer'],
@@ -137,6 +141,19 @@ function finiteValues({ clients }) {
137
141
 
138
142
  // 2
139
143
  function snapshotKeysUsed({ game, clients, scenario }) {
144
+ // сценарий, не знающий схемы этой игры (встроенный смоук на чужом плагине),
145
+ // не должен объявлять её ключи «не спавнящимися» — это красный вердикт
146
+ // исправной игре
147
+ if (scenario.unusedSnapshotKeys === ALL_KEYS_UNAUDITED) {
148
+ return result(
149
+ 'snapshotKeysUsed',
150
+ SKIP,
151
+ [],
152
+ `the scenario declares unusedSnapshotKeys: "${ALL_KEYS_UNAUDITED}" — ` +
153
+ 'key coverage is not audited',
154
+ );
155
+ }
156
+
140
157
  const declared = Object.keys(game.snapshot);
141
158
  const unused = new Set(scenario.unusedSnapshotKeys);
142
159
  const seen = new Set();
@@ -397,7 +414,19 @@ function keyBindings({ game, clientConfig, scenario }) {
397
414
  // player-блоком кадра. Сопоставление идёт по времени кадра, а не по seq —
398
415
  // предиктор переигрывает историю ввода от момента авторитетного состояния,
399
416
  // и «тот же seq» на клиенте и на хосте означает разные моменты.
400
- function predictionDrift({ clients }) {
417
+ function predictionDrift({ clients, scenario }) {
418
+ // детектор выключен самим сценарием — молчит не ядро, и автор плагина не
419
+ // должен идти искать несуществующую проблему в take_divergence
420
+ if (scenario.divergence === null) {
421
+ return result(
422
+ 'predictionDrift',
423
+ SKIP,
424
+ [],
425
+ 'the scenario disables the drift detector (divergence: null) — ' +
426
+ 'thresholds are per-game, write a scenario for yours',
427
+ );
428
+ }
429
+
401
430
  const violations = [];
402
431
  const tracked = clients.filter(client => client.divergenceStats);
403
432
 
@@ -1,7 +1,10 @@
1
1
  import { access, readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
- import { assertEngineApiCompatible } from '../lib/gamePlugin.js';
4
+ import {
5
+ assertEngineApiCompatible,
6
+ assertGameConfigShape,
7
+ } from '../lib/gamePlugin.js';
5
8
 
6
9
  // Поиск игры для headless-прогона. В браузере плагин грузится по URL из
7
10
  // GameManifest мастера; в Node URL-ов нет, поэтому источников три, по
@@ -21,6 +24,16 @@ const FIXTURE_DIR = new URL(
21
24
  import.meta.url,
22
25
  );
23
26
 
27
+ // значение поля source у фикстуры: по нему отличается «своя игра, схему
28
+ // которой инструмент знает» от чужого плагина (см. builtinScenario.js)
29
+ export const FIXTURE_SOURCE = 'fixture:miniGame';
30
+
31
+ /**
32
+ * @param {Object} plugin - Результат loadGameForSim.
33
+ * @returns {boolean} Прогон идёт на встроенной фикстуре, а не на чужой игре.
34
+ */
35
+ export const isFixture = plugin => plugin.source === FIXTURE_SOURCE;
36
+
24
37
  /**
25
38
  * @param {Object} [options]
26
39
  * @param {string} [options.game] - Путь к пакету игры или к манифесту.
@@ -30,10 +43,20 @@ const FIXTURE_DIR = new URL(
30
43
  * source }.
31
44
  */
32
45
  export async function loadGameForSim({ game = null, core = null } = {}) {
33
- if (!game) {
34
- return loadFixture(core);
35
- }
46
+ const plugin = game
47
+ ? await loadFromManifest(game, core)
48
+ : await loadFixture(core);
49
+
50
+ // контракт gameConfig проверяется здесь, а не только в createHostRuntime:
51
+ // встроенный сценарий собирается из gameConfig раньше, чем стартует прогон
52
+ // (builtinScenario.js), и плагин без конфига иначе отвечал бы сырым
53
+ // TypeError вместо перечисления недостающих полей
54
+ assertGameConfigShape(plugin.hostPlugin);
55
+
56
+ return plugin;
57
+ }
36
58
 
59
+ async function loadFromManifest(game, core) {
37
60
  const manifestPath = game.endsWith('.json')
38
61
  ? path.resolve(game)
39
62
  : path.resolve(game, 'dist/manifest.json');
@@ -107,7 +130,7 @@ async function loadFixture(core) {
107
130
  hostPlugin,
108
131
  clientPlugin,
109
132
  wasmUrl: core ? pathToFileURL(path.resolve(core)).href : undefined,
110
- source: 'fixture:miniGame',
133
+ source: FIXTURE_SOURCE,
111
134
  };
112
135
  }
113
136
 
@@ -51,6 +51,10 @@ const REQUIRED_GAME_CONFIG_PATHS = [
51
51
  'parts.friendlyFire',
52
52
  'panel.fields',
53
53
  'playerKeys',
54
+ // без них HostGame разыменовывает undefined (this._teams[spectatorTeam])
55
+ // и игра умирает тремя разными сообщениями вместо одного контрактного
56
+ 'teams',
57
+ 'spectatorTeam',
54
58
  ];
55
59
 
56
60
  function getPath(obj, dottedPath) {
@@ -61,9 +65,14 @@ function getPath(obj, dottedPath) {
61
65
 
62
66
  // бросает при отсутствии обязательных полей HostPlugin.gameConfig
63
67
  export function assertGameConfigShape(hostPlugin) {
64
- const missing = REQUIRED_GAME_CONFIG_PATHS.filter(
65
- p => getPath(hostPlugin.gameConfig, p) === undefined,
66
- );
68
+ // null проходил бы проверку присутствия, хотя ни одно из этих полей не
69
+ // бывает пустым по контракту: движок разыменовывает их сразу, и гейт,
70
+ // заведённый ради текста вместо TypeError, сам отвечал бы TypeError
71
+ const missing = REQUIRED_GAME_CONFIG_PATHS.filter(p => {
72
+ const value = getPath(hostPlugin.gameConfig, p);
73
+
74
+ return value === undefined || value === null;
75
+ });
67
76
 
68
77
  if (missing.length > 0) {
69
78
  throw new Error(
@@ -71,6 +80,19 @@ export function assertGameConfigShape(hostPlugin) {
71
80
  missing.join(', '),
72
81
  );
73
82
  }
83
+
84
+ // единственная связь между полями, которую стоит проверять здесь:
85
+ // spectatorTeam — имя ключа внутри teams, и опечатка даёт spectatorId ===
86
+ // undefined, после чего участник заходит в несуществующую команду
87
+ // (ParticipantManager.createHuman валится на её счётчике)
88
+ const { teams, spectatorTeam } = hostPlugin.gameConfig;
89
+
90
+ if (teams[spectatorTeam] === undefined) {
91
+ throw new Error(
92
+ `game "${hostPlugin.id}": spectatorTeam '${spectatorTeam}' is not a ` +
93
+ `key of teams (${Object.keys(teams).join(', ')})`,
94
+ );
95
+ }
74
96
  }
75
97
 
76
98
  // динамический import ClientPlugin игры (client-entry её сборки). Манифест и