vimp-engine 0.3.0 → 0.4.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 (49) hide show
  1. package/bin/vimp-sim.js +137 -0
  2. package/package.json +10 -3
  3. package/src/config/gameCodes.js +8 -0
  4. package/src/config/lobby.js +23 -1
  5. package/src/config/master.js +8 -0
  6. package/src/devtools/RecordingSocketManager.js +118 -0
  7. package/src/devtools/ScenarioRunner.js +529 -0
  8. package/src/devtools/VirtualClient.js +363 -0
  9. package/src/devtools/VirtualClock.js +169 -0
  10. package/src/devtools/inspectHost.js +31 -0
  11. package/src/devtools/invariants.js +672 -0
  12. package/src/devtools/pluginLoader.js +101 -0
  13. package/src/devtools/report.js +202 -0
  14. package/src/devtools/resetHostSingletons.js +23 -0
  15. package/src/host/DebugRecorder.js +224 -0
  16. package/src/host/GameCoreAdapter.js +17 -0
  17. package/src/host/HostGame.js +129 -7
  18. package/src/host/host.worker.js +46 -47
  19. package/src/host/meta/SocketManager.js +15 -7
  20. package/src/host/meta/modules/Panel.js +7 -0
  21. package/src/host/meta/modules/PlayerDataSync.js +29 -5
  22. package/src/host/meta/modules/RTTManager.js +4 -2
  23. package/src/host/meta/modules/Stat.js +7 -0
  24. package/src/host/meta/modules/TimerManager.js +15 -7
  25. package/src/host/meta/modules/Vote.js +10 -1
  26. package/src/host/meta/modules/chat/Chat.js +7 -0
  27. package/src/host/meta/modules/chat/index.js +1 -1
  28. package/src/host/meta/player/HumanParticipant.js +2 -1
  29. package/src/lib/AbstractTimer.js +6 -4
  30. package/src/lib/applyRoomOverrides.js +6 -0
  31. package/src/lib/clientCoreConfig.js +4 -0
  32. package/src/lib/clock.js +53 -0
  33. package/src/lib/createHostRuntime.js +108 -0
  34. package/src/lib/reconstructHot.js +78 -0
  35. package/src/lib/validators.js +10 -0
  36. package/tests/fixtures/miniGame/client/fakeClientCore.js +304 -0
  37. package/tests/fixtures/miniGame/client/index.js +37 -0
  38. package/tests/fixtures/miniGame/client/parts/Actor.js +18 -0
  39. package/tests/fixtures/miniGame/client/parts/ActorRadar.js +7 -0
  40. package/tests/fixtures/miniGame/config/auth.js +41 -0
  41. package/tests/fixtures/miniGame/config/client.js +139 -0
  42. package/tests/fixtures/miniGame/config/game.js +148 -0
  43. package/tests/fixtures/miniGame/host/ScriptedManager.js +129 -0
  44. package/tests/fixtures/miniGame/host/createModules.js +6 -0
  45. package/tests/fixtures/miniGame/host/fakeCore.js +233 -0
  46. package/tests/fixtures/miniGame/host/index.js +29 -0
  47. package/tests/fixtures/miniGame/host/spawnCommand.js +14 -0
  48. package/tests/fixtures/miniGame/host/systemMessages.js +6 -0
  49. package/tests/fixtures/miniGame.contract.test.js +76 -0
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from 'node:fs/promises';
3
+ import { runScenario } from '../src/devtools/ScenarioRunner.js';
4
+ import { loadGameForSim } from '../src/devtools/pluginLoader.js';
5
+ import {
6
+ checkDeterminism,
7
+ summarize,
8
+ FAIL,
9
+ } from '../src/devtools/invariants.js';
10
+ import { writeReport, formatMarkdown } from '../src/devtools/report.js';
11
+
12
+ // CLI headless-прогона: правка → npm run sim → текстовый вердикт, без
13
+ // браузера и без человека.
14
+
15
+ const USAGE = `Usage: vimp-sim [options]
16
+
17
+ --scenario <path> scenario JSON (default: built-in smoke scenario)
18
+ --game <path> game package directory or dist/manifest.json
19
+ --core <path> node build of the game core (overrides entries.wasmNode)
20
+ --out <dir> report root (default: .debug)
21
+ --no-write print the report to stdout instead of writing files
22
+ --determinism run the scenario twice and compare the frame streams
23
+ --help
24
+ `;
25
+
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
+ };
42
+
43
+ async function main(argv) {
44
+ const args = parseArgs(argv);
45
+
46
+ if (args.help) {
47
+ process.stdout.write(USAGE);
48
+ return 0;
49
+ }
50
+
51
+ const scenario = args.scenario
52
+ ? JSON.parse(await readFile(args.scenario, 'utf8'))
53
+ : DEFAULT_SCENARIO;
54
+
55
+ // плагин грузится один раз: второй прогон самопроверки детерминизма
56
+ // обязан идти на том же ядре, иначе он проверял бы загрузчик, а не мир
57
+ const plugin = await loadGameForSim({ game: args.game, core: args.core });
58
+ const report = await runScenario(scenario, { plugin });
59
+
60
+ if (args.determinism) {
61
+ const second = await runScenario(scenario, { plugin });
62
+ const check = checkDeterminism(report, second);
63
+
64
+ report.invariants = report.invariants.map(item =>
65
+ item.name === check.name ? check : item,
66
+ );
67
+ report.invariantSummary = summarize(report.invariants);
68
+ }
69
+
70
+ if (args.write === false) {
71
+ process.stdout.write(formatMarkdown(report));
72
+ } else {
73
+ const runDir = await writeReport(report, { outDir: args.out });
74
+
75
+ process.stdout.write(formatMarkdown(report));
76
+ process.stdout.write(`\nReport written to ${runDir}\n`);
77
+ }
78
+
79
+ const failed = report.invariants.filter(check => check.status === FAIL);
80
+
81
+ if (failed.length) {
82
+ process.stderr.write(
83
+ `\n${failed.length} broken contract(s): ` +
84
+ `${failed.map(check => check.name).join(', ')}\n`,
85
+ );
86
+
87
+ return 1;
88
+ }
89
+
90
+ return 0;
91
+ }
92
+
93
+ function parseArgs(argv) {
94
+ const args = { write: true };
95
+
96
+ for (let i = 0; i < argv.length; i += 1) {
97
+ const arg = argv[i];
98
+
99
+ switch (arg) {
100
+ case '--help':
101
+ case '-h':
102
+ args.help = true;
103
+ break;
104
+
105
+ case '--no-write':
106
+ args.write = false;
107
+ break;
108
+
109
+ case '--determinism':
110
+ args.determinism = true;
111
+ break;
112
+
113
+ case '--scenario':
114
+ case '--game':
115
+ case '--core':
116
+ case '--out':
117
+ i += 1;
118
+ args[arg.slice(2)] = argv[i];
119
+ break;
120
+
121
+ default:
122
+ throw new Error(`unknown option '${arg}'\n\n${USAGE}`);
123
+ }
124
+ }
125
+
126
+ return args;
127
+ }
128
+
129
+ main(process.argv.slice(2)).then(
130
+ code => {
131
+ process.exitCode = code;
132
+ },
133
+ error => {
134
+ process.stderr.write(`${error.stack ?? error.message}\n`);
135
+ process.exitCode = 1;
136
+ },
137
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vimp-engine",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "VIMP — движок-приложение (мастер, P2P-транспорт, Worker-хост, мета, MVC-каркас клиента)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -15,12 +15,16 @@
15
15
  "exports": {
16
16
  "./lib/*": "./src/lib/*",
17
17
  "./config/*": "./src/config/*",
18
- "./host/*": "./src/host/*"
18
+ "./host/*": "./src/host/*",
19
+ "./devtools/*": "./src/devtools/*"
19
20
  },
20
21
  "files": [
21
22
  "src/lib",
22
23
  "src/config",
23
- "src/host"
24
+ "src/host",
25
+ "src/devtools",
26
+ "tests/fixtures",
27
+ "bin"
24
28
  ],
25
29
  "scripts": {
26
30
  "predev": "node scripts/sync-pixi-vendor.mjs",
@@ -43,5 +47,8 @@
43
47
  "nodemon": "^3.1.10",
44
48
  "vite": "^7.1.11",
45
49
  "vite-plugin-pug": "^0.4.1"
50
+ },
51
+ "bin": {
52
+ "vimp-sim": "bin/vimp-sim.js"
46
53
  }
47
54
  }
@@ -0,0 +1,8 @@
1
+ // коды игровых информ-сообщений (порт GAME_INFORM_DATA, см.
2
+ // docs/en/network.md) — общий контракт хоста (SocketManager) и клиента
3
+ // (main.js): значение не должно расходиться между ними молча
4
+ export default {
5
+ winnerTeam: [0],
6
+ roundStart: [1],
7
+ gameOver: [2],
8
+ };
@@ -44,6 +44,12 @@ export default {
44
44
  stateUrl: '/auth/state',
45
45
  },
46
46
 
47
+ // рейтинг игры (lobby-page-plan): публичный топ-N и позиция вызывающего,
48
+ // проксируемые мастером под тем же origin — правки CSP не нужны
49
+ leaderboardUrl: '/auth/leaderboard',
50
+ placementUrl: '/auth/placement',
51
+ leaderboardLimit: 10,
52
+
47
53
  // переподключение сигнального WS хоста (комната без него выпадает из
48
54
  // выдачи мастера): экспоненциальный бэкофф от baseDelay до maxDelay (мс)
49
55
  reconnect: {
@@ -51,6 +57,10 @@ export default {
51
57
  maxDelay: 30000,
52
58
  },
53
59
 
60
+ // приёмник выгрузок отладочного контура (этап 6 плана plan/ai-debug):
61
+ // маршрут поднимается мастером только в dev, в проде вернёт 404
62
+ debugReportUrl: '/debug/report',
63
+
54
64
  // размер страницы для «Загрузить ещё» (offset/limit к мастеру)
55
65
  pageSize: 10,
56
66
 
@@ -67,11 +77,23 @@ export default {
67
77
  emptyId: 'lobby-empty',
68
78
  nameId: 'lobby-name',
69
79
  hostBtnId: 'lobby-host',
70
- // селектор игры: скрыт, пока в каталоге мастера одна игра (§6, PLAN.md)
80
+ // селектор игры (lobby-page-plan): заполняется всем каталогом мастера,
81
+ // выбор меняет активную форму/leaderboard, но не активную игру для host
82
+ // (граница задачи — см. plan/lobby-page-plan.md)
71
83
  gameId: 'lobby-game',
72
84
  // контейнер полей комнаты: генерируются по ключам roomDefaults
73
85
  // манифеста активной игры (Д7) — движок не знает игровых полей
74
86
  fieldsId: 'lobby-fields',
87
+
88
+ // вкладки правой панели (lobby-page-plan)
89
+ tabServersBtnId: 'btn-show-servers',
90
+ tabLeaderboardBtnId: 'btn-show-leaderboard',
91
+ serversContentId: 'lobby-servers-content',
92
+ leaderboardContentId: 'lobby-leaderboard-content',
93
+ leaderboardListId: 'lobby-leaderboard-list',
94
+ leaderboardTitleId: 'leaderboard-title',
95
+ leaderboardTotalId: 'leaderboard-total',
96
+ myPlacementId: 'lobby-my-placement',
75
97
  },
76
98
 
77
99
  // создание комнаты (хост в этой же вкладке); лимит игроков/время
@@ -45,6 +45,14 @@ export default {
45
45
  sweepInterval: 10000, // период проверки протухших комнат
46
46
  },
47
47
 
48
+ // GET /auth/leaderboard (code review L2): TTL кэша на мастере (мс) и
49
+ // верхняя граница ?limit= — публичный анонимный эндпоинт, самый частый
50
+ // запрос лобби, выборка меняется медленно
51
+ leaderboard: {
52
+ cacheTtl: 15000,
53
+ maxLimit: 100,
54
+ },
55
+
48
56
  // рейтинг хостера комнаты (server-rating этап 2, plan/server-rating/
49
57
  // stage_2.md): /like·/unlike гостей комнаты вместо соц-модерации /ban.
50
58
  // Дефолт движка для всех игр; auth-сервис хранит и клампит рейтинг
@@ -0,0 +1,118 @@
1
+ // Транспорт хоста, который вместо сети пишет исходящие кадры в память.
2
+ // Обобщение FakeSocketManager из tests/host/fixtureHarness.js: одну и ту же
3
+ // подмену используют и движковые тесты меты, и headless-runner — иначе
4
+ // пришлось бы держать две копии перечня отправителей SocketManager.
5
+
6
+ // Все отправители SocketManager, которые дёргает host-фасад.
7
+ export const SENDER_METHODS = [
8
+ 'sendConfig',
9
+ 'sendAuthData',
10
+ 'sendAuthResult',
11
+ 'sendPing',
12
+ 'sendClear',
13
+ 'sendTechInform',
14
+ 'sendMap',
15
+ 'sendFirstShot',
16
+ 'sendFirstVote',
17
+ 'sendShot',
18
+ 'sendPanel',
19
+ 'sendStat',
20
+ 'sendChat',
21
+ 'sendVote',
22
+ 'sendKeySet',
23
+ 'sendPlayerDefaultShot',
24
+ 'sendSpectatorDefaultShot',
25
+ 'sendConsole',
26
+ 'sendGameInform',
27
+ 'sendRoundEnd',
28
+ 'sendSoundCue',
29
+ 'sendName',
30
+ ];
31
+
32
+ class RecordingSocketManager {
33
+ /**
34
+ * @param {Object} [options]
35
+ * @param {Function} [options.onFrame] - Вызывается на каждый исходящий кадр
36
+ * ({ method, socketId, args }) — runner раздаёт их VirtualClient'ам, не
37
+ * дожидаясь конца прогона.
38
+ */
39
+ constructor({ onFrame = null } = {}) {
40
+ this.frames = []; // [{ method, socketId, args, tick }]
41
+
42
+ // номер тика прогона: runner двигает его перед каждым шагом, кадры
43
+ // получают метку — без неё проверки жизненного цикла раунда не могут
44
+ // сказать «кому именно не доехал roundEnd на этом тике»
45
+ this.tick = 0;
46
+ this._onFrame = onFrame;
47
+ this._game = null;
48
+ this._panel = null;
49
+ this._stat = null;
50
+
51
+ for (const method of SENDER_METHODS) {
52
+ this[method] = (socketId, ...args) => {
53
+ this._record({ method, socketId, args });
54
+ };
55
+ }
56
+
57
+ // Составные отправители боевого SocketManager сами дёргают панель,
58
+ // статистику и keySet. Плоская запись их не раскрывает — тогда клиент
59
+ // headless-прогона живёт с пустой панелью и без keySet, то есть предикт
60
+ // у него не включается никогда. Развёртка обязана повторять
61
+ // host/meta/SocketManager.js.
62
+ this.sendFirstShot = socketId => {
63
+ this._record({ method: 'sendFirstShot', socketId, args: [] });
64
+ this.sendStat(socketId, this._stat?.getFull());
65
+ this.sendPanel(socketId, this._panel?.getEmptyPanel());
66
+ this.sendKeySet(socketId, 0); // наблюдатель
67
+ };
68
+
69
+ this.sendPlayerDefaultShot = (socketId, gameId) => {
70
+ this._record({
71
+ method: 'sendPlayerDefaultShot',
72
+ socketId,
73
+ args: [gameId],
74
+ });
75
+ this.sendPanel(socketId, this._panel?.getFullPanel(gameId));
76
+ this.sendKeySet(socketId, 1); // играющий
77
+ };
78
+
79
+ this.sendSpectatorDefaultShot = socketId => {
80
+ this._record({ method: 'sendSpectatorDefaultShot', socketId, args: [] });
81
+ this.sendPanel(socketId, this._panel?.getEmptyPanel());
82
+ this.sendKeySet(socketId, 0);
83
+ };
84
+ }
85
+
86
+ injectServices(game, panel, stat) {
87
+ this._game = game;
88
+ this._panel = panel;
89
+ this._stat = stat;
90
+ }
91
+
92
+ addUser() {}
93
+ removeUser() {}
94
+
95
+ close(socketId, code, key, arr) {
96
+ this._record({ method: 'close', socketId, args: [code, key, arr] });
97
+ }
98
+
99
+ // все кадры указанного метода
100
+ framesOf(method) {
101
+ return this.frames.filter(frame => frame.method === method);
102
+ }
103
+
104
+ clear() {
105
+ this.frames.length = 0;
106
+ }
107
+
108
+ _record(frame) {
109
+ frame.tick = this.tick;
110
+ this.frames.push(frame);
111
+
112
+ if (this._onFrame) {
113
+ this._onFrame(frame);
114
+ }
115
+ }
116
+ }
117
+
118
+ export default RecordingSocketManager;