vimp-engine 0.22.1 → 0.24.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-surface.js +88 -0
- package/core/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/client/lib/formBuilder.js +38 -27
- package/src/client/lib/pickActiveGame.js +59 -0
- package/src/client/lib/socketDispatch.js +34 -0
- package/src/client/main.js +61 -14
- package/src/config/abiOps.js +37 -0
- package/src/config/clientServices.js +27 -0
- package/src/config/opcodes.js +11 -2
- package/src/config/wsports.js +9 -0
- package/src/devtools/contract/loadContext.js +25 -2
- package/src/devtools/contract/rules/b10-respawns.js +5 -3
- package/src/devtools/contract/rules/b2-engine-api.js +81 -10
- package/src/devtools/contract/rules/b3-game-config-shape.js +46 -5
- package/src/devtools/contract/rules/b4-teams.js +6 -3
- package/src/devtools/contract/rules/b5-room-form.js +40 -8
- package/src/devtools/contract/rules/c10-auth-schema.js +34 -1
- package/src/devtools/contract/rules/c4-component-dependencies.js +10 -15
- package/src/devtools/surface/abiParse.js +175 -0
- package/src/devtools/surface/collect.js +413 -0
- package/src/host/GameCoreAdapter.js +49 -0
- package/src/lib/applyRoomOverrides.js +16 -2
- package/src/lib/capabilities.js +32 -0
- package/src/lib/coreAbi.js +116 -0
- package/src/lib/coreConfig.js +3 -3
- package/src/lib/createHostRuntime.js +5 -3
- package/src/lib/formControls.js +89 -0
- package/src/lib/formUnit.js +25 -0
- package/src/lib/gameConfigView.js +214 -0
- package/src/lib/gamePlugin.js +86 -86
- package/src/lib/loadGamePackage.js +9 -2
- package/src/lib/registry.js +94 -0
- package/src/lib/validators.js +79 -17
- package/src/standalone/index.js +42 -13
- package/tests/fixtures/generations/gen-api3/README.md +7 -0
- package/tests/fixtures/generations/gen-api3/client/fakeClientCore.js +307 -0
- package/tests/fixtures/generations/gen-api3/client/index.js +38 -0
- package/tests/fixtures/generations/gen-api3/client/parts/Actor.js +18 -0
- package/tests/fixtures/generations/gen-api3/client/parts/ActorRadar.js +7 -0
- package/tests/fixtures/generations/gen-api3/config/auth.js +41 -0
- package/tests/fixtures/generations/gen-api3/config/client.js +151 -0
- package/tests/fixtures/generations/gen-api3/config/game.js +167 -0
- package/tests/fixtures/generations/gen-api3/core/pkg-node/core.js +4 -0
- package/tests/fixtures/generations/gen-api3/host/ScriptedManager.js +129 -0
- package/tests/fixtures/generations/gen-api3/host/createModules.js +6 -0
- package/tests/fixtures/generations/gen-api3/host/fakeCore.js +263 -0
- package/tests/fixtures/generations/gen-api3/host/index.js +30 -0
- package/tests/fixtures/generations/gen-api3/host/spawnCommand.js +14 -0
- package/tests/fixtures/generations/gen-api3/host/systemMessages.js +6 -0
- package/tests/fixtures/generations/gen-api3/manifest.json +25 -0
- package/tests/fixtures/generations/gen-api4/README.md +7 -0
- package/tests/fixtures/generations/gen-api4/client/fakeClientCore.js +307 -0
- package/tests/fixtures/generations/gen-api4/client/index.js +38 -0
- package/tests/fixtures/generations/gen-api4/client/parts/Actor.js +18 -0
- package/tests/fixtures/generations/gen-api4/client/parts/ActorRadar.js +7 -0
- package/tests/fixtures/generations/gen-api4/config/auth.js +41 -0
- package/tests/fixtures/generations/gen-api4/config/client.js +151 -0
- package/tests/fixtures/generations/gen-api4/config/game.js +156 -0
- package/tests/fixtures/generations/gen-api4/core/pkg-node/core.js +4 -0
- package/tests/fixtures/generations/gen-api4/host/ScriptedManager.js +129 -0
- package/tests/fixtures/generations/gen-api4/host/createModules.js +6 -0
- package/tests/fixtures/generations/gen-api4/host/fakeCore.js +263 -0
- package/tests/fixtures/generations/gen-api4/host/index.js +30 -0
- package/tests/fixtures/generations/gen-api4/host/spawnCommand.js +14 -0
- package/tests/fixtures/generations/gen-api4/host/systemMessages.js +6 -0
- package/tests/fixtures/generations/gen-api4/manifest.json +17 -0
- package/tests/fixtures/miniGame/client/fakeClientCore.js +17 -0
- package/tests/fixtures/miniGame/host/fakeCore.js +38 -8
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Фейковое JS-ядро миниигры-фикстуры (Этап 7 плана отделения движка,
|
|
2
|
+
// PLAN.md §4/Wasm Host ABI §3.4): реализует полную поверхность методов,
|
|
3
|
+
// которую вызывает GameCoreAdapter, без Rust/WASM — доказательство, что
|
|
4
|
+
// движок не завязан на конкретную реализацию ядра. Актёры — плоские
|
|
5
|
+
// объекты { x, y, angle, team, alive }; тик — тривиальная линейная
|
|
6
|
+
// интеграция скорости (та же идея, что у Rust-фикстуры TestClient,
|
|
7
|
+
// core/src/client/game.rs: форма ABI важнее физики).
|
|
8
|
+
export default class FakeGameCore {
|
|
9
|
+
constructor(configJson) {
|
|
10
|
+
this._config = JSON.parse(configJson);
|
|
11
|
+
this._actors = new Map(); // gameId -> { x, y, angle, team, alive, vx, vy, lastInputSeq }
|
|
12
|
+
this._map = null;
|
|
13
|
+
this._events = [];
|
|
14
|
+
this._lastBody = [];
|
|
15
|
+
this._lastFrame = null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ***** карта ***** //
|
|
19
|
+
|
|
20
|
+
load_map(mapJson) {
|
|
21
|
+
this._map = JSON.parse(mapJson);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
map_info() {
|
|
25
|
+
if (!this._map) {
|
|
26
|
+
return 'null';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return JSON.stringify({
|
|
30
|
+
setId: this._map.setId,
|
|
31
|
+
respawns: this._map.respawns || {},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
clear() {
|
|
36
|
+
this._actors.clear();
|
|
37
|
+
this._events.length = 0;
|
|
38
|
+
this._lastBody = [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ***** участники ***** //
|
|
42
|
+
|
|
43
|
+
spawn_actor(gameId, model, teamId, x, y, angle) {
|
|
44
|
+
this._actors.set(gameId, {
|
|
45
|
+
x,
|
|
46
|
+
y,
|
|
47
|
+
angle,
|
|
48
|
+
team: teamId,
|
|
49
|
+
alive: true,
|
|
50
|
+
vx: 0,
|
|
51
|
+
vy: 0,
|
|
52
|
+
lastInputSeq: 0,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
this._events.push({ type: 'panelSet', id: gameId, field: 'energy', value: 100 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
remove_actor(gameId) {
|
|
59
|
+
this._actors.delete(gameId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
reset_actor(gameId, teamId, x, y, angle) {
|
|
63
|
+
const actor = this._actors.get(gameId);
|
|
64
|
+
|
|
65
|
+
if (actor) {
|
|
66
|
+
Object.assign(actor, { x, y, angle, team: teamId, alive: true, vx: 0, vy: 0 });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
reset_all_vitals() {
|
|
71
|
+
for (const actor of this._actors.values()) {
|
|
72
|
+
actor.alive = true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
spawn_scripted_actor(gameId, model, teamId, x, y, angle) {
|
|
77
|
+
this.spawn_actor(gameId, model, teamId, x, y, angle);
|
|
78
|
+
this._actors.get(gameId).scripted = true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
remove_scripted_actor(gameId) {
|
|
82
|
+
this.remove_actor(gameId);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
remove_players_and_shots() {
|
|
86
|
+
const names = [...this._actors.keys()].map(String);
|
|
87
|
+
|
|
88
|
+
this._actors.clear();
|
|
89
|
+
|
|
90
|
+
return JSON.stringify(names);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ***** ввод ***** //
|
|
94
|
+
|
|
95
|
+
apply_input(gameId, seq, action, name) {
|
|
96
|
+
const actor = this._actors.get(gameId);
|
|
97
|
+
|
|
98
|
+
if (!actor) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
actor.lastInputSeq = seq;
|
|
103
|
+
|
|
104
|
+
const magnitude = action === 'down' ? 40 : 0;
|
|
105
|
+
|
|
106
|
+
if (name === 'forward') {
|
|
107
|
+
actor.vy = -magnitude;
|
|
108
|
+
} else if (name === 'back') {
|
|
109
|
+
actor.vy = magnitude;
|
|
110
|
+
} else if (name === 'fire' && action === 'down') {
|
|
111
|
+
this._events.push({ type: 'custom', data: { kind: 'fire', id: gameId } });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ввод указателем: миниигра просто запоминает последнюю точку и биты —
|
|
116
|
+
// движковым тестам важно, что канал доходит до ядра
|
|
117
|
+
apply_aim(gameId, seq, x, y, flags) {
|
|
118
|
+
const actor = this._actors.get(gameId);
|
|
119
|
+
|
|
120
|
+
if (!actor) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
actor.lastInputSeq = seq;
|
|
125
|
+
actor.aim = { x, y, flags };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
aim_of(gameId) {
|
|
129
|
+
return this._actors.get(gameId)?.aim ?? null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
last_input_seq(gameId) {
|
|
133
|
+
return this._actors.get(gameId)?.lastInputSeq ?? 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ***** запросы состояния ***** //
|
|
137
|
+
|
|
138
|
+
is_alive(gameId) {
|
|
139
|
+
return Boolean(this._actors.get(gameId)?.alive);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
position_of(gameId) {
|
|
143
|
+
const actor = this._actors.get(gameId);
|
|
144
|
+
|
|
145
|
+
return actor ? [actor.x, actor.y] : [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// FIRST_SHOT_DATA: полный снапшот игроков в раскладке снапшот-схемы
|
|
149
|
+
// (ключ схемы -> id -> поля), а не плоским списком: ровно её разбирает
|
|
150
|
+
// клиент (applyShot браузера и VirtualClient._applyGameData). Плоский
|
|
151
|
+
// список доезжал до клиента ключами '0', '1' — сущность первого кадра
|
|
152
|
+
// не появлялась на холсте, и видно это только со второго участника
|
|
153
|
+
players_data() {
|
|
154
|
+
const entry = Object.entries(
|
|
155
|
+
this._config.engine?.snapshot?.keys ?? {},
|
|
156
|
+
).find(([, spec]) => spec.class === 'hot' && spec.kind === 'indexed8');
|
|
157
|
+
|
|
158
|
+
if (!entry) {
|
|
159
|
+
return '{}';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const [key, spec] = entry;
|
|
163
|
+
const rows = {};
|
|
164
|
+
|
|
165
|
+
for (const [id, actor] of this._actors) {
|
|
166
|
+
rows[id] = spec.fields.map(field => Number(actor[field.name] ?? 0));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return JSON.stringify({ [key]: rows });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ***** игровой тик ***** //
|
|
173
|
+
|
|
174
|
+
step(dt) {
|
|
175
|
+
for (const actor of this._actors.values()) {
|
|
176
|
+
actor.x += actor.vx * dt;
|
|
177
|
+
actor.y += actor.vy * dt;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
take_events() {
|
|
182
|
+
const events = this._events;
|
|
183
|
+
|
|
184
|
+
this._events = [];
|
|
185
|
+
|
|
186
|
+
return JSON.stringify(events);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ***** упаковка снапшота (фикстурный формат — не бинарный кодек ядра;
|
|
190
|
+
// достаточно для тестов меты, которые не декодируют реальный фрейминг) ***** //
|
|
191
|
+
|
|
192
|
+
// поля записи — по снапшот-схеме фикстуры (config/game.js, ключ a1):
|
|
193
|
+
// x, y, angle, team; клиентская половина фикстуры раскладывает их в
|
|
194
|
+
// hot-буфер той же ширины, что заявлена схемой
|
|
195
|
+
pack_body() {
|
|
196
|
+
this._lastBody = [...this._actors.entries()].map(([id, a]) => ({
|
|
197
|
+
id: Number(id),
|
|
198
|
+
x: a.x,
|
|
199
|
+
y: a.y,
|
|
200
|
+
angle: a.angle,
|
|
201
|
+
team: a.team,
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
body_has_events() {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
pack_frame(serverTime, seq, hasCamera, camX, camY, forceReset, shake, playerId) {
|
|
210
|
+
this._lastFrame = {
|
|
211
|
+
serverTime,
|
|
212
|
+
seq,
|
|
213
|
+
camera: hasCamera ? [camX, camY, Boolean(forceReset), shake ?? null] : null,
|
|
214
|
+
playerId,
|
|
215
|
+
body: this._lastBody,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
frame_bytes() {
|
|
220
|
+
return new TextEncoder().encode(JSON.stringify(this._lastFrame));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ***** отладка ***** //
|
|
224
|
+
|
|
225
|
+
// форма дампа — та же, что у движкового crate::debug (тела/карта/rng),
|
|
226
|
+
// с поправкой на фикстуру без физики: актёры и есть тела
|
|
227
|
+
debug_json() {
|
|
228
|
+
return JSON.stringify({
|
|
229
|
+
bodies: [...this._actors.entries()].map(([id, a]) => ({
|
|
230
|
+
handle: Number(id),
|
|
231
|
+
tag: 2,
|
|
232
|
+
userData: String(id),
|
|
233
|
+
translation: [a.x, a.y],
|
|
234
|
+
rotation: a.angle,
|
|
235
|
+
linvel: [a.vx, a.vy],
|
|
236
|
+
angvel: 0,
|
|
237
|
+
mass: 1,
|
|
238
|
+
bodyType: 'Dynamic',
|
|
239
|
+
ccd: false,
|
|
240
|
+
})),
|
|
241
|
+
colliders: [],
|
|
242
|
+
map: this._map
|
|
243
|
+
? { setId: this._map.setId, step: this._map.step ?? null }
|
|
244
|
+
: null,
|
|
245
|
+
nav: null,
|
|
246
|
+
spatial: { cells: 0, entities: 0, cellCounts: [] },
|
|
247
|
+
rng: { state: '0' },
|
|
248
|
+
step: { timeStep: this._config.timeStep ?? null, accumulator: 0 },
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ***** handoff (задел, PLAN.md §6 «открытые вопросы») ***** //
|
|
253
|
+
|
|
254
|
+
serialize_state() {
|
|
255
|
+
return new TextEncoder().encode(JSON.stringify([...this._actors.entries()]));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
deserialize_state(bytes) {
|
|
259
|
+
const entries = JSON.parse(new TextDecoder().decode(bytes));
|
|
260
|
+
|
|
261
|
+
this._actors = new Map(entries);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// ЗАМОРОЖЕНО. Снимок плагина 2026-08-29 (поколение engineApi: 3).
|
|
2
|
+
// Не править: правка фикстуры маскирует слом совместимости.
|
|
3
|
+
import FakeGameCore from './fakeCore.js';
|
|
4
|
+
import gameConfig from '../config/game.js';
|
|
5
|
+
import authSchema from '../config/auth.js';
|
|
6
|
+
import clientConfig from '../config/client.js';
|
|
7
|
+
import systemMessages from './systemMessages.js';
|
|
8
|
+
import spawnCommand from './spawnCommand.js';
|
|
9
|
+
import createModules from './createModules.js';
|
|
10
|
+
|
|
11
|
+
// HostPlugin миниигры-фикстуры (Этап 7 плана отделения движка, PLAN.md
|
|
12
|
+
// §3.2): доказывает, что HostGame и движковая мета работают с любым
|
|
13
|
+
// HostPlugin, реализующим контракт — не только с @vimp-games/tanks. createCore
|
|
14
|
+
// не грузит WASM (fake-core — обычный JS-класс), поэтому фикстура не
|
|
15
|
+
// требует собранного Rust-ядра игры.
|
|
16
|
+
export default {
|
|
17
|
+
id: 'gen-api3',
|
|
18
|
+
engineApi: 3,
|
|
19
|
+
|
|
20
|
+
async createCore(coreConfigJson) {
|
|
21
|
+
return new FakeGameCore(coreConfigJson);
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
gameConfig,
|
|
25
|
+
authSchema,
|
|
26
|
+
chatCommands: [spawnCommand],
|
|
27
|
+
systemMessages,
|
|
28
|
+
createModules,
|
|
29
|
+
buildClientGameConfig: () => clientConfig,
|
|
30
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Игровая чат-команда фикстуры: '/spawn <count>' — создаёт scripted-
|
|
2
|
+
// участников без голосования (зеркало vimp-tanks/src/host/botCommand.js,
|
|
3
|
+
// упрощённое — доказывает только регистрацию HostPlugin.chatCommands).
|
|
4
|
+
// Регистрируется в движковом CommandProcessor.
|
|
5
|
+
export default {
|
|
6
|
+
name: '/spawn',
|
|
7
|
+
handler(ctx, gameId, args) {
|
|
8
|
+
const count = Number(args[0]) || 1;
|
|
9
|
+
const created = ctx.scripted.createScripted(count);
|
|
10
|
+
|
|
11
|
+
ctx.chat.pushSystem('SCRIPTED_SPAWNED', [created]);
|
|
12
|
+
ctx.roundManager.initiateNewRound();
|
|
13
|
+
},
|
|
14
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Игровые коды системных сообщений фикстуры (группа g:*) — зеркало
|
|
2
|
+
// vimp-tanks/src/host/systemMessages.js (группа b:*). Merge в движковый
|
|
3
|
+
// реестр через registerCodes (HostGame конструктор).
|
|
4
|
+
export default {
|
|
5
|
+
SCRIPTED_SPAWNED: 'g:0', // {0} scripted participant(s) spawned
|
|
6
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "gen-api3",
|
|
3
|
+
"engineApi": 3,
|
|
4
|
+
"assetsBase": "",
|
|
5
|
+
"entries": {
|
|
6
|
+
"host": "host/index.js",
|
|
7
|
+
"client": "client/index.js",
|
|
8
|
+
"wasm": "core/pkg-web/core_bg.wasm",
|
|
9
|
+
"wasmNode": "core/pkg-node/core.js"
|
|
10
|
+
},
|
|
11
|
+
"roomDefaults": {
|
|
12
|
+
"maxPlayers": 4
|
|
13
|
+
},
|
|
14
|
+
"maps": [
|
|
15
|
+
"arena"
|
|
16
|
+
],
|
|
17
|
+
"roomForm": [
|
|
18
|
+
{
|
|
19
|
+
"name": "maxPlayers",
|
|
20
|
+
"control": "range",
|
|
21
|
+
"min": 2,
|
|
22
|
+
"max": 4
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# gen-api4 — ЗАМОРОЖЕНО
|
|
2
|
+
|
|
3
|
+
Снимок плагина 2026-08-29, поколение `engineApi: 4`.
|
|
4
|
+
Не править: правка фикстуры маскирует слом совместимости
|
|
5
|
+
(этап 1 плана `plan/plugin-forward-compat`). Новое поколение
|
|
6
|
+
добавляется копированием текущего `../../miniGame`, а не правкой
|
|
7
|
+
этого каталога.
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// ЗАМОРОЖЕНО. Снимок плагина 2026-08-29 (поколение engineApi: 4).
|
|
2
|
+
// Не править: правка фикстуры маскирует слом совместимости.
|
|
3
|
+
// Фейковое клиентское ядро миниигры-фикстуры: минимальная реализация
|
|
4
|
+
// движкового клиентского ABI (см. export_client_core_abi!, PLAN.md §3.4).
|
|
5
|
+
//
|
|
6
|
+
// Кадры фикстуры — не бинарный кодек ядра, а JSON (см. host/fakeCore.js:
|
|
7
|
+
// frame_bytes), поэтому push_frame их просто разбирает, а интерполяции нет:
|
|
8
|
+
// sample() отдаёт последний принятый кадр. Раскладка hot-буфера при этом
|
|
9
|
+
// настоящая (флаги, две группы, ширина записи = 2 + поля схемы) — именно её
|
|
10
|
+
// читает движковый reconstructHot, и именно она замыкает headless-контур
|
|
11
|
+
// «хост → байты → клиентское ядро → сцена» без WASM.
|
|
12
|
+
|
|
13
|
+
// флаги hot-буфера на момент снимка (движковый src/config/opcodes.js)
|
|
14
|
+
const HOT_FLAGS = { GAME: 1, CAMERA: 2, PREDICTED: 4, FRAMES: 8 };
|
|
15
|
+
|
|
16
|
+
export default class FakeClientCore {
|
|
17
|
+
constructor(configJson) {
|
|
18
|
+
this._config = JSON.parse(configJson);
|
|
19
|
+
this._active = false;
|
|
20
|
+
this._frame = null;
|
|
21
|
+
this._hot = new Float32Array(0);
|
|
22
|
+
|
|
23
|
+
// обратный индекс снапшот-схемы: keyId → { key, kind, fields }
|
|
24
|
+
const keys = this._config.engine?.snapshot?.keys ?? {};
|
|
25
|
+
|
|
26
|
+
this._hotKey = Object.entries(keys).find(
|
|
27
|
+
([, spec]) => spec.class === 'hot' && spec.kind === 'indexed8',
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
// детектор рассинхрона предикта (этап 5): включается секцией divergence
|
|
31
|
+
// движкового конфига, как и в Rust-ядре
|
|
32
|
+
const cfg = this._config.engine?.divergence;
|
|
33
|
+
|
|
34
|
+
this._divergenceCfg = cfg ?? null;
|
|
35
|
+
this._divergence = cfg
|
|
36
|
+
? { samples: 0, violations: 0, dropped: 0, maxDelta: [0, 0], records: [] }
|
|
37
|
+
: null;
|
|
38
|
+
|
|
39
|
+
// предикт фикстуры: та же таблица скоростей, что у host/fakeCore, плюс
|
|
40
|
+
// история её изменений — реконсиляция идёт по времени кадра, поэтому
|
|
41
|
+
// история переигрывается от момента авторитетного состояния, а не от seq
|
|
42
|
+
this._vy = 0;
|
|
43
|
+
this._inputs = []; // [{ time, vy }] в локальном времени
|
|
44
|
+
this._base = null; // { x, y, time, vy } — последнее авторитетное, в серверном
|
|
45
|
+
this._offset = 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
push_frame(data, localNow = 0) {
|
|
49
|
+
let frame;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
frame = JSON.parse(new TextDecoder().decode(data));
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this._offset = frame.serverTime - localNow;
|
|
58
|
+
this._observeDivergence(frame);
|
|
59
|
+
this._frame = frame;
|
|
60
|
+
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
my_game_id() {
|
|
65
|
+
return this._frame?.playerId ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
offset() {
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
sample() {
|
|
73
|
+
if (!this._frame || !this._hotKey) {
|
|
74
|
+
this._hot = new Float32Array(0);
|
|
75
|
+
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const [, spec] = this._hotKey;
|
|
80
|
+
const body = this._frame.body ?? [];
|
|
81
|
+
const camera = this._frame.camera;
|
|
82
|
+
const values = [
|
|
83
|
+
HOT_FLAGS.GAME | (camera ? HOT_FLAGS.CAMERA : 0),
|
|
84
|
+
camera ? camera[0] : 0,
|
|
85
|
+
camera ? camera[1] : 0,
|
|
86
|
+
body.length,
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
for (const record of body) {
|
|
90
|
+
values.push(spec.id, record.id);
|
|
91
|
+
|
|
92
|
+
for (const field of spec.fields) {
|
|
93
|
+
values.push(Number(record[field.name] ?? 0));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// группа IndexedNoNull8 (динамика карты) — у фикстуры пуста
|
|
98
|
+
values.push(0);
|
|
99
|
+
|
|
100
|
+
this._hot = new Float32Array(values);
|
|
101
|
+
|
|
102
|
+
return this._hot.length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
hot_ptr() {
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
hot_values() {
|
|
110
|
+
return this._hot;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
take_frames() {
|
|
114
|
+
return '[]';
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
apply_input(action, name, localNow = 0) {
|
|
118
|
+
const magnitude = action === 'down' ? 40 : 0;
|
|
119
|
+
|
|
120
|
+
if (name === 'forward') {
|
|
121
|
+
this._vy = -magnitude;
|
|
122
|
+
} else if (name === 'back') {
|
|
123
|
+
this._vy = magnitude;
|
|
124
|
+
} else {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this._inputs.push({ time: localNow, vy: this._vy });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// записи расхождения предикта; 'null' — детектор выключен конфигом
|
|
132
|
+
take_divergence() {
|
|
133
|
+
if (!this._divergence) {
|
|
134
|
+
return 'null';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { records, ...stats } = this._divergence;
|
|
138
|
+
|
|
139
|
+
this._divergence.records = [];
|
|
140
|
+
|
|
141
|
+
return JSON.stringify({ ...stats, records });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// сравнение предсказанного своего актора с авторитетным player-блоком —
|
|
145
|
+
// до того, как авторитетное состояние станет новой базой предикта
|
|
146
|
+
_observeDivergence(frame) {
|
|
147
|
+
if (!this._divergence) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// playerId приходит в кадре хоста строкой (gameId меты) — наблюдатель
|
|
152
|
+
// помечен -1 и своего актора не имеет
|
|
153
|
+
const id = Number(frame.playerId);
|
|
154
|
+
const authoritative = (frame.body ?? []).find(row => row.id === id);
|
|
155
|
+
|
|
156
|
+
if (!Number.isInteger(id) || id < 0 || !authoritative) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const time = frame.serverTime;
|
|
161
|
+
|
|
162
|
+
if (this._base) {
|
|
163
|
+
const predicted = this._predictAt(time);
|
|
164
|
+
|
|
165
|
+
this._compare(predicted, authoritative, frame);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// реконсиляция: база — авторитетное состояние на serverTime кадра,
|
|
169
|
+
// история ввода до этого момента больше не переигрывается
|
|
170
|
+
const vy = this._velocityAt(time);
|
|
171
|
+
|
|
172
|
+
this._base = { x: authoritative.x, y: authoritative.y, time, vy };
|
|
173
|
+
this._inputs = this._inputs.filter(
|
|
174
|
+
input => input.time + this._offset > time,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
_compare(predicted, authoritative, frame) {
|
|
179
|
+
const dump = this._divergence;
|
|
180
|
+
const cfg = this._divergenceCfg;
|
|
181
|
+
const pair = [
|
|
182
|
+
[predicted.x, authoritative.x],
|
|
183
|
+
[predicted.y, authoritative.y],
|
|
184
|
+
];
|
|
185
|
+
const delta = pair.map(([a, b]) => a - b);
|
|
186
|
+
const thresholds = pair.map(
|
|
187
|
+
(_, index) => cfg.thresholds?.[index] ?? cfg.defaultThreshold ?? 1,
|
|
188
|
+
);
|
|
189
|
+
const exceeded = delta
|
|
190
|
+
.map((value, index) => (Math.abs(value) > thresholds[index] ? index : -1))
|
|
191
|
+
.filter(index => index !== -1);
|
|
192
|
+
|
|
193
|
+
dump.samples += 1;
|
|
194
|
+
delta.forEach((value, index) => {
|
|
195
|
+
dump.maxDelta[index] = Math.max(dump.maxDelta[index], Math.abs(value));
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (!exceeded.length) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
dump.violations += 1;
|
|
203
|
+
|
|
204
|
+
if (dump.records.length >= (cfg.capacity ?? 64)) {
|
|
205
|
+
dump.records.shift();
|
|
206
|
+
dump.dropped += 1;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
dump.records.push({
|
|
210
|
+
source: 'state',
|
|
211
|
+
serverTime: frame.serverTime,
|
|
212
|
+
localNow: frame.serverTime - this._offset,
|
|
213
|
+
offset: this._offset,
|
|
214
|
+
inputSeq: frame.seq,
|
|
215
|
+
replayed: {
|
|
216
|
+
from: this._base.time,
|
|
217
|
+
to: frame.serverTime,
|
|
218
|
+
count: this._inputs.length,
|
|
219
|
+
},
|
|
220
|
+
predicted: [predicted.x, predicted.y],
|
|
221
|
+
authoritative: [authoritative.x, authoritative.y],
|
|
222
|
+
delta,
|
|
223
|
+
exceeded,
|
|
224
|
+
thresholds,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// интегрирование скорости от базы до момента t (серверное время):
|
|
229
|
+
// кусочно-линейно по истории ввода — предикт переигрывает именно её
|
|
230
|
+
_predictAt(t) {
|
|
231
|
+
let { y, time, vy } = this._base;
|
|
232
|
+
|
|
233
|
+
for (const input of this._inputs) {
|
|
234
|
+
const at = input.time + this._offset;
|
|
235
|
+
const until = Math.min(at, t);
|
|
236
|
+
|
|
237
|
+
if (until > time) {
|
|
238
|
+
y += (vy * (until - time)) / 1000;
|
|
239
|
+
time = until;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (at > t) {
|
|
243
|
+
return { x: this._base.x, y };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
vy = input.vy;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (t > time) {
|
|
250
|
+
y += (vy * (t - time)) / 1000;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { x: this._base.x, y };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
_velocityAt(time) {
|
|
257
|
+
let vy = this._base?.vy ?? 0;
|
|
258
|
+
|
|
259
|
+
for (const input of this._inputs) {
|
|
260
|
+
if (input.time + this._offset <= time) {
|
|
261
|
+
vy = input.vy;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return vy;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
set_active(active) {
|
|
269
|
+
this._active = active;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
set_map() {}
|
|
273
|
+
|
|
274
|
+
reset() {
|
|
275
|
+
this._frame = null;
|
|
276
|
+
this._hot = new Float32Array(0);
|
|
277
|
+
this._base = null;
|
|
278
|
+
this._inputs = [];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// зеркало ClientState::debug_json; у фикстуры буфер — один последний
|
|
282
|
+
// кадр, поэтому окно seq вырождено
|
|
283
|
+
debug_json() {
|
|
284
|
+
return JSON.stringify({
|
|
285
|
+
myGameId: this._frame?.playerId ?? null,
|
|
286
|
+
offset: 0,
|
|
287
|
+
interpolator: {
|
|
288
|
+
buffered: this._frame ? 1 : 0,
|
|
289
|
+
seqWindow: this._frame ? [this._frame.seq, this._frame.seq] : null,
|
|
290
|
+
lastFrame: this._frame
|
|
291
|
+
? { seq: this._frame.seq, serverTime: this._frame.serverTime }
|
|
292
|
+
: null,
|
|
293
|
+
},
|
|
294
|
+
hotLen: this._hot.length,
|
|
295
|
+
framesOut: 0,
|
|
296
|
+
active: this._active,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
decode_frame(data) {
|
|
301
|
+
try {
|
|
302
|
+
return JSON.stringify(JSON.parse(new TextDecoder().decode(data)));
|
|
303
|
+
} catch {
|
|
304
|
+
return 'null';
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// ЗАМОРОЖЕНО. Снимок плагина 2026-08-29 (поколение engineApi: 4).
|
|
2
|
+
// Не править: правка фикстуры маскирует слом совместимости.
|
|
3
|
+
import FakeClientCore from './fakeClientCore.js';
|
|
4
|
+
import Actor from './parts/Actor.js';
|
|
5
|
+
import ActorRadar from './parts/ActorRadar.js';
|
|
6
|
+
|
|
7
|
+
// ClientPlugin миниигры-фикстуры (Этап 7 плана отделения движка, PLAN.md
|
|
8
|
+
// §3.3): минимальные заглушечные parts (без PixiJS) — доказывает, что
|
|
9
|
+
// gamePlugin.js/Factory работают с любым ClientPlugin, а не только с
|
|
10
|
+
// @vimp-games/tanks.
|
|
11
|
+
export default {
|
|
12
|
+
id: 'gen-api4',
|
|
13
|
+
engineApi: 4,
|
|
14
|
+
|
|
15
|
+
async createClientCore(clientConfigJson) {
|
|
16
|
+
return { core: new FakeClientCore(clientConfigJson), memory: null };
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
parts: { Actor, ActorRadar },
|
|
20
|
+
bakers: {},
|
|
21
|
+
styles: '',
|
|
22
|
+
|
|
23
|
+
hooks: {
|
|
24
|
+
onAuth(core, authData) {
|
|
25
|
+
core.set_model?.(authData.model);
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
onPanel() {},
|
|
29
|
+
|
|
30
|
+
onLocalAction(core, action, name, now) {
|
|
31
|
+
if (action === 'down' && name === 'fire') {
|
|
32
|
+
return core.try_fire?.(now) || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|