vimp-engine 0.2.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.
- package/bin/vimp-sim.js +137 -0
- package/package.json +10 -3
- package/src/config/gameCodes.js +8 -0
- package/src/config/lobby.js +23 -1
- package/src/config/master.js +8 -0
- package/src/config/opcodes.js +3 -1
- package/src/devtools/RecordingSocketManager.js +118 -0
- package/src/devtools/ScenarioRunner.js +529 -0
- package/src/devtools/VirtualClient.js +363 -0
- package/src/devtools/VirtualClock.js +169 -0
- package/src/devtools/inspectHost.js +31 -0
- package/src/devtools/invariants.js +672 -0
- package/src/devtools/pluginLoader.js +101 -0
- package/src/devtools/report.js +202 -0
- package/src/devtools/resetHostSingletons.js +23 -0
- package/src/host/DebugRecorder.js +224 -0
- package/src/host/GameCoreAdapter.js +17 -0
- package/src/host/HostGame.js +129 -7
- package/src/host/host.worker.js +46 -47
- package/src/host/meta/SocketManager.js +15 -7
- package/src/host/meta/modules/Panel.js +7 -0
- package/src/host/meta/modules/PlayerDataSync.js +29 -5
- package/src/host/meta/modules/RTTManager.js +4 -2
- package/src/host/meta/modules/Stat.js +7 -0
- package/src/host/meta/modules/TimerManager.js +15 -7
- package/src/host/meta/modules/Vote.js +10 -1
- package/src/host/meta/modules/chat/Chat.js +7 -0
- package/src/host/meta/modules/chat/index.js +1 -1
- package/src/host/meta/player/HumanParticipant.js +2 -1
- package/src/lib/AbstractTimer.js +6 -4
- package/src/lib/applyRoomOverrides.js +6 -0
- package/src/lib/clientCoreConfig.js +4 -0
- package/src/lib/clock.js +53 -0
- package/src/lib/createHostRuntime.js +108 -0
- package/src/lib/reconstructHot.js +78 -0
- package/src/lib/validators.js +10 -0
- package/tests/fixtures/miniGame/client/fakeClientCore.js +304 -0
- package/tests/fixtures/miniGame/client/index.js +37 -0
- package/tests/fixtures/miniGame/client/parts/Actor.js +18 -0
- package/tests/fixtures/miniGame/client/parts/ActorRadar.js +7 -0
- package/tests/fixtures/miniGame/config/auth.js +41 -0
- package/tests/fixtures/miniGame/config/client.js +139 -0
- package/tests/fixtures/miniGame/config/game.js +148 -0
- package/tests/fixtures/miniGame/host/ScriptedManager.js +129 -0
- package/tests/fixtures/miniGame/host/createModules.js +6 -0
- package/tests/fixtures/miniGame/host/fakeCore.js +233 -0
- package/tests/fixtures/miniGame/host/index.js +29 -0
- package/tests/fixtures/miniGame/host/spawnCommand.js +14 -0
- package/tests/fixtures/miniGame/host/systemMessages.js +6 -0
- package/tests/fixtures/miniGame.contract.test.js +76 -0
package/bin/vimp-sim.js
ADDED
|
@@ -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
|
+
"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
|
+
};
|
package/src/config/lobby.js
CHANGED
|
@@ -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
|
-
// селектор
|
|
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
|
// создание комнаты (хост в этой же вкладке); лимит игроков/время
|
package/src/config/master.js
CHANGED
|
@@ -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-сервис хранит и клампит рейтинг
|
package/src/config/opcodes.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
// проверяется при загрузке плагинов; ломающие изменения контрактов → +1.
|
|
8
8
|
// v2: явная схема форм (roomForm/authSchema.params[].options — Form schema
|
|
9
9
|
// в plugin-api.md), движок больше не выводит контролы из типа значения
|
|
10
|
-
|
|
10
|
+
// v3: набор control сокращён до нативных элементов — 'select'|'text'|
|
|
11
|
+
// 'checkbox'|'radio' (были ещё 'range'/'number'/'toggle'/'segmented')
|
|
12
|
+
export const ENGINE_API_VERSION = 3;
|
|
11
13
|
|
|
12
14
|
// версия формата кадра: первый байт после порта;
|
|
13
15
|
// увеличивать при любом изменении байтовой раскладки в ядре
|
|
@@ -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;
|