vimp_engine_core/abi.rs
1//! `export_game_core_abi!` — генерирует `#[wasm_bindgen] impl` для игрового
2//! `GameCore`, снимая ~25-метод boilerplate, зафиксированный §3.4 PLAN.md.
3//! Все методы ниже — механические 1:1-делегации в уже generic
4//! `EngineSim<G>`/`SnapshotPacker`, чья сигнатура зафиксирована трейтом
5//! `crate::sim::GameSim<G>` независимо от конкретной игры — не дизайн под
6//! гипотетическую вторую игру, а извлечение уже существующего соответствия
7//! (см. PLAN_4_details.md §5).
8//!
9//! Контракт: тип-цель обязан иметь поля `state: vimp_engine_core::game::EngineSim<G>`
10//! (под именем `EngineSim<...>`, конкретный `G` — забота игрового crate) и
11//! `packer: vimp_engine_core::snapshot::SnapshotPacker`, и раскрываться в
12//! модуле, где уже подключён `wasm-bindgen` как зависимость (пути ниже
13//! квалифицированы через `::wasm_bindgen`, поэтому не зависят от локальных
14//! `use`, но зависят от наличия крейта в `Cargo.toml` вызывающей стороны).
15//! `new` (парсинг игрового конфига) и не-`#[wasm_bindgen]` тестовые
16//! аксессоры в макрос не входят — остаются рукописными в game-crate.
17
18/// Версия формата самоописания (`abi_describe`), не контракта плагина.
19pub const ABI_DESCRIBE_VERSION: u32 = 1;
20
21/// Версия крейта движка, с которым собрано ядро. Внутри макроса `env!`
22/// раскрылся бы в версию крейта ИГРЫ — поэтому константа живёт здесь.
23pub const CORE_VERSION: &str = env!("CARGO_PKG_VERSION");
24
25/// Имя опкода читается ТОЛЬКО отсюда — как на JS-стороне из
26/// `src/config/abiOps.js` (`ABI_OP_DEBUG_JSON`). Литерал, написанный руками
27/// и в списке, и в `match` обработчика, разъезжается молча: ядро объявляет
28/// опкод, которого не обрабатывает, движок видит «не обработан» и уходит на
29/// запасной путь, ничего не сказав.
30pub const OP_DEBUG_JSON: &str = "debug.json";
31
32/// Движковые опкоды `dispatch`, которые умеет это раскрытие игрового
33/// макроса. Список растёт вместе с движком: игра, пересобранная с новым
34/// крейтом, получает их без единой правки своего исходника.
35pub const ENGINE_GAME_OPS: &[&str] = &[OP_DEBUG_JSON];
36
37/// То же для клиентского макроса.
38pub const ENGINE_CLIENT_OPS: &[&str] = &[OP_DEBUG_JSON];
39
40/// JSON самоописания ядра: версия формата, версия движкового крейта и
41/// объединённый список опкодов (движковые + объявленные игрой).
42pub fn describe_json(engine_ops: &[&str], game_ops: &[&str]) -> String {
43 let mut ops: Vec<&str> = engine_ops.to_vec();
44
45 for op in game_ops {
46 if !ops.contains(op) {
47 ops.push(op);
48 }
49 }
50
51 ::serde_json::json!({
52 "abi": ABI_DESCRIBE_VERSION,
53 "core": CORE_VERSION,
54 "ops": ops,
55 })
56 .to_string()
57}
58
59/// Соглашение о возврате `dispatch`: пустой вектор — «опкод не обработан»
60/// (вызывающий идёт по запасному пути), однобайтовый маркер `[0x00]` —
61/// «обработан, ответа нет».
62pub fn dispatch_result(out: Option<Vec<u8>>) -> Vec<u8> {
63 match out {
64 None => Vec::new(),
65 Some(bytes) if bytes.is_empty() => vec![0x00],
66 Some(bytes) => bytes,
67 }
68}
69
70// ЗАМОРОЖЕНО (И3 плана plugin-forward-compat). Имя, арность и типы каждого
71// `pub fn` в макросах ниже неизменны НАВСЕГДА. Glue-код wasm-bindgen лежит в
72// dist уже опубликованных игр: добавленный аргумент он молча выбросит, и
73// старая игра будет тихо врать вместо того чтобы упасть. Нужна другая форма —
74// заведи опкод `dispatch` (см. ниже и src/config/abiOps.js), а не правь
75// сигнатуру. Метод можно удалить (старая игра его всё ещё экспортирует,
76// движок перестаёт звать), но не переименовать и не изменить.
77// Страж: tests/devtools/surface.test.js, раздел abi.
78
79#[macro_export]
80macro_rules! export_game_core_abi {
81 ($GameCoreTy:ty) => {
82 #[::wasm_bindgen::prelude::wasm_bindgen]
83 impl $GameCoreTy {
84 /// Загружает карту из JSON (см. scripts/export-maps.js).
85 pub fn load_map(&mut self, map_json: &str) -> Result<(), ::wasm_bindgen::JsError> {
86 self.state
87 .load_map(map_json)
88 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
89 }
90
91 /// Информация о загруженной карте: setId, масштабированные
92 /// респауны, размеры мира (JSON).
93 pub fn map_info(&self) -> String {
94 self.state.map_info_json()
95 }
96
97 // ***** участники ***** //
98
99 pub fn spawn_actor(
100 &mut self,
101 game_id: u32,
102 model: &str,
103 team_id: u8,
104 x: f32,
105 y: f32,
106 angle_deg: f32,
107 ) -> Result<(), ::wasm_bindgen::JsError> {
108 self.state
109 .spawn_actor(game_id, model, team_id, x, y, angle_deg)
110 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
111 }
112
113 pub fn remove_actor(&mut self, game_id: u32) {
114 self.state.remove_actor(game_id);
115 }
116
117 /// Респаун/смена команды (аналог Game.changePlayerData).
118 pub fn reset_actor(
119 &mut self,
120 game_id: u32,
121 team_id: u8,
122 x: f32,
123 y: f32,
124 angle_deg: f32,
125 ) {
126 self.state.reset_actor(game_id, team_id, x, y, angle_deg);
127 }
128
129 /// Сброс здоровья/боезапаса всех танков (аналог Panel.reset).
130 pub fn reset_all_vitals(&mut self) {
131 self.state.reset_all_vitals();
132 }
133
134 pub fn spawn_scripted_actor(
135 &mut self,
136 game_id: u32,
137 model: &str,
138 team_id: u8,
139 x: f32,
140 y: f32,
141 angle_deg: f32,
142 ) -> Result<(), ::wasm_bindgen::JsError> {
143 self.state
144 .spawn_scripted_actor(game_id, model, team_id, x, y, angle_deg)
145 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
146 }
147
148 pub fn remove_scripted_actor(&mut self, game_id: u32) {
149 self.state.remove_scripted_actor(game_id);
150 }
151
152 // ***** ввод и шаг ***** //
153
154 /// Ввод игрока: seq + action ('down'/'up') + имя клавиши
155 /// (wire-формат 'seq:action:name' разбирает JS-оболочка).
156 pub fn apply_input(&mut self, game_id: u32, seq: u32, action: &str, key_name: &str) {
157 self.state.apply_input(game_id, seq, action, key_name);
158 }
159
160 /// Ввод указателем: seq + мировая точка + биты состояния
161 /// (wire-формат 'seq:aim:x:y:flags' разбирает JS-оболочка).
162 pub fn apply_aim(&mut self, game_id: u32, seq: u32, x: f32, y: f32, flags: u32) {
163 self.state.apply_aim(game_id, seq, x, y, flags);
164 }
165
166 /// Шаг симуляции: фиксированные подшаги физики + ИИ ботов.
167 pub fn step(&mut self, dt: f32) {
168 self.state.step(dt);
169 }
170
171 /// События за тик (kill/health/ammo/weapon/shake) одной
172 /// JSON-строкой; буфер очищается.
173 pub fn take_events(&mut self) -> String {
174 self.state.take_events_json()
175 }
176
177 // ***** запросы состояния ***** //
178
179 pub fn last_input_seq(&self, game_id: u32) -> u32 {
180 self.state.last_input_seq(game_id)
181 }
182
183 pub fn is_alive(&self, game_id: u32) -> bool {
184 self.state.is_alive(game_id)
185 }
186
187 /// Координаты танка [x, y] (скруглены до 2 знаков) или пустой
188 /// массив.
189 pub fn position_of(&self, game_id: u32) -> Vec<f32> {
190 self.state
191 .actor_position(game_id)
192 .map(|p| p.to_vec())
193 .unwrap_or_default()
194 }
195
196 /// Полные данные всех игроков (Game.getPlayersData) одной
197 /// JSON-строкой для первого кадра (FIRST_SHOT_DATA). Не
198 /// дренирует накопители.
199 pub fn players_data(&self) -> String {
200 self.state.players_json()
201 }
202
203 /// Живые игроки плоским массивом [id, teamId, x, y, ...]
204 /// (аналог Game.getAlivePlayers для меты).
205 pub fn alive_players(&self) -> Vec<f32> {
206 self.state.alive_players_flat()
207 }
208
209 // ***** снапшот ***** //
210
211 /// Пакует broadcast-тело кадра, дренируя накопленные события
212 /// снапшота. Вызывать один раз на отправляемый кадр (throttle
213 /// частоты отправки — забота JS-оболочки).
214 pub fn pack_body(&mut self) -> Result<(), ::wasm_bindgen::JsError> {
215 let blocks = self.state.build_snapshot_blocks();
216
217 self.packer
218 .pack_body(&blocks)
219 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
220 }
221
222 /// Собирает per-user кадр v3 во внутренний буфер, возвращает
223 /// длину. Кадр читается zero-copy через frame_ptr() + память
224 /// WASM. player_id < 0 — кадр без player-блока (наблюдатель).
225 #[allow(clippy::too_many_arguments)]
226 pub fn pack_frame(
227 &mut self,
228 server_time: f64,
229 seq: u32,
230 has_camera: bool,
231 camera_x: f32,
232 camera_y: f32,
233 force_reset: bool,
234 shake: Option<String>,
235 player_id: i32,
236 ) -> usize {
237 let camera = has_camera.then_some($crate::snapshot::CameraData {
238 x: camera_x,
239 y: camera_y,
240 force_reset,
241 shake,
242 });
243
244 let player = if player_id >= 0 {
245 let game_id = player_id as u32;
246
247 self.state.prediction_state(game_id).map(|(state, centering)| {
248 $crate::snapshot::PlayerBlock {
249 game_id: game_id as u8,
250 input_seq: self.state.last_input_seq(game_id),
251 state,
252 centering,
253 }
254 })
255 } else {
256 None
257 };
258
259 self.packer
260 .pack_frame(server_time, seq, camera.as_ref(), player.as_ref())
261 .len()
262 }
263
264 /// Содержал ли последний `pack_body()` событийные блоки
265 /// (трассеры/бомбы/взрывы/удаления). JS-Worker вызывает после
266 /// `pack_body()` для выбора канала WebRTC: события → meta
267 /// (reliable), только позиции → state.
268 pub fn body_has_events(&self) -> bool {
269 self.state.body_has_events()
270 }
271
272 /// Указатель на буфер последнего кадра (zero-copy чтение из JS:
273 /// new Uint8Array(wasm.memory.buffer, ptr, len)).
274 pub fn frame_ptr(&self) -> *const u8 {
275 self.packer.frame_bytes().as_ptr()
276 }
277
278 /// Копия последнего кадра (nodejs-таргет не отдаёт память
279 /// наружу; горячий путь браузера использует frame_ptr + память
280 /// WASM).
281 pub fn frame_bytes(&self) -> Vec<u8> {
282 self.packer.frame_bytes().to_vec()
283 }
284
285 // ***** очистка и handoff ***** //
286
287 /// Удаляет игроков и снаряды, возвращает JSON-массив имён для
288 /// очистки полотна клиентов (Game.removePlayersAndShots).
289 pub fn remove_players_and_shots(&mut self) -> String {
290 ::serde_json::to_string(&self.state.remove_players_and_shots())
291 .unwrap_or_else(|_| "[]".to_string())
292 }
293
294 /// Полная очистка мира (смена карты).
295 pub fn clear(&mut self) {
296 self.state.clear();
297 }
298
299 /// Курированный дамп мира для отладки (тела, коллайдеры,
300 /// карта, нав-граф, spatial-сетка, rng, аккумулятор) —
301 /// читаемая альтернатива serialize_state.
302 pub fn debug_json(&self) -> String {
303 self.state.debug_json()
304 }
305
306 /// Дамп состояния симуляции (Worker Handoff, Этап 5.2).
307 pub fn serialize_state(&self) -> Result<Vec<u8>, ::wasm_bindgen::JsError> {
308 self.state
309 .serialize_state()
310 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
311 }
312
313 pub fn deserialize_state(&mut self, data: &[u8]) -> Result<(), ::wasm_bindgen::JsError> {
314 self.state
315 .deserialize_state(data)
316 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
317 }
318
319 // ***** расширение ***** //
320
321 /// Самоописание ядра (JSON: версия формата, версия движкового
322 /// крейта, список опкодов dispatch). Движок читает его один раз
323 /// при загрузке — до первого вызова, а не посреди матча.
324 pub fn abi_describe(&self) -> String {
325 self.state.abi_describe()
326 }
327
328 /// Единая точка роста ABI: новая возможность приезжает опкодом,
329 /// а не новым символом (таблица экспортов заморожена). Пустой
330 /// возврат — «опкод не обработан», `[0x00]` — «обработан, ответа
331 /// нет».
332 pub fn dispatch(&mut self, op: &str, payload: &[u8]) -> Vec<u8> {
333 self.state.dispatch(op, payload)
334 }
335 }
336 };
337}
338
339/// `export_client_core_abi!` — генерирует `#[wasm_bindgen] impl` для
340/// клиентского `ClientCore`, зеркально `export_game_core_abi!` (см. выше).
341/// Методы ниже — движковый минимум (§3.4 PLAN.md): 1:1-делегации в generic
342/// `vimp_engine_core::client::game::ClientState<G>`, чья сигнатура
343/// зафиксирована трейтом `GameClientDef` независимо от конкретной игры.
344///
345/// Контракт: тип-цель обязан иметь поле
346/// `state: vimp_engine_core::client::game::ClientState<G>` (под именем
347/// `ClientState<...>`, конкретный `G: GameClientDef` — забота игрового
348/// crate), и раскрываться в модуле, где уже подключён `wasm-bindgen`.
349/// Игровые методы вне минимума (`set_model`, `try_fire`, `cycle_weapon`,
350/// `sync_panel` и т.п. — сигнатуры которых по форме зависят от игры)
351/// остаются рукописными в game-crate рядом с раскрытием макроса.
352#[macro_export]
353macro_rules! export_client_core_abi {
354 ($ClientCoreTy:ty) => {
355 #[::wasm_bindgen::prelude::wasm_bindgen]
356 impl $ClientCoreTy {
357 // ***** сеть ***** //
358
359 /// Бинарный кадр из транспорта: распаковка, вставка в буфер по
360 /// seq (+дедупликация/опоздавшие), reconciliation предикта по
361 /// player-блоку. false — кадр отброшен (чужой порт/версия/
362 /// повреждён).
363 pub fn push_frame(&mut self, data: &[u8], local_now: f64) -> bool {
364 self.state.push_frame(data, local_now)
365 }
366
367 /// Свой gameId из последнего player-блока; -1, если ещё не
368 /// приходил.
369 pub fn my_game_id(&self) -> i32 {
370 self.state.my_game_id().map(|id| id as i32).unwrap_or(-1)
371 }
372
373 /// EMA-оценка (serverTime − localNow); NaN, если кадров ещё не
374 /// было. Это разница часов (`Date.now` хоста против
375 /// `performance.now` клиента), а **не** латентность: за оценку
376 /// RTT её принимать нельзя.
377 pub fn offset(&self) -> f64 {
378 self.state.offset().unwrap_or(f64::NAN)
379 }
380
381 // ***** рендер-тик ***** //
382
383 /// Весь рендер-тик: выдача пересечённых кадров (фильтр дублей
384 /// своих эффектов → JSON-очередь), интерполяция, шаг предикта,
385 /// запись hot-буфера. Возвращает длину hot-буфера в
386 /// f32-элементах.
387 pub fn sample(&mut self, local_now: f64) -> usize {
388 self.state.sample(local_now)
389 }
390
391 /// Указатель на hot-буфер (zero-copy чтение из JS:
392 /// new Float32Array(wasm.memory.buffer, ptr, len) — view
393 /// пересоздавать каждый тик, рост памяти WASM инвалидирует
394 /// buffer).
395 pub fn hot_ptr(&self) -> *const f32 {
396 self.state.hot().as_ptr()
397 }
398
399 /// Копия hot-буфера (nodejs-таргет; горячий путь браузера —
400 /// hot_ptr).
401 pub fn hot_values(&self) -> Vec<f32> {
402 self.state.hot().to_vec()
403 }
404
405 /// Событийные кадры JSON-строкой [{game, camera}, ...] в
406 /// форме, готовой для applyShot; вызывать при флаге hasFrames
407 /// hot-буфера, очередь очищается.
408 pub fn take_frames(&mut self) -> String {
409 self.state.take_frames()
410 }
411
412 // ***** ввод ***** //
413
414 /// Ввод игрока: action ('down'/'up') + имя клавиши — в историю
415 /// предикта.
416 pub fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64) {
417 self.state.apply_input(action, key_name, local_now);
418 }
419
420 /// Ввод указателем: мировая точка + биты состояния — в историю
421 /// предикта.
422 pub fn apply_aim(&mut self, x: f32, y: f32, flags: u32, local_now: f64) {
423 self.state.apply_aim(x, y, flags, local_now);
424 }
425
426 // ***** жизненный цикл ***** //
427
428 /// Смена режима игрок/спектатор (KEYSET_DATA).
429 pub fn set_active(&mut self, active: bool) {
430 self.state.set_active(active);
431 }
432
433 /// Данные карты (MAP_DATA): мир raycast + сброс буфера и
434 /// предикта.
435 pub fn set_map(&mut self, map_json: &str) -> Result<(), ::wasm_bindgen::JsError> {
436 self.state
437 .set_map(map_json)
438 .map_err(|e| ::wasm_bindgen::JsError::new(&e))
439 }
440
441 /// Полный сброс (порт CLEAR).
442 pub fn reset(&mut self) {
443 self.state.reset();
444 }
445
446 /// Ресинк часов после долгой паузы вкладки: сброс сетевого
447 /// буфера и очереди кадров без обнуления предикта.
448 pub fn resync(&mut self) {
449 self.state.resync();
450 }
451
452 // ***** тесты и харнесс ***** //
453
454 /// Дамп клиентского состояния для отладки: сетевой буфер
455 /// (глубина, окно seq, оффсет, последний кадр), свой gameId,
456 /// размеры hot-буфера и очереди событийных кадров.
457 pub fn debug_json(&self) -> String {
458 self.state.debug_json()
459 }
460
461 /// Записи расхождения предикта с авторитетным состоянием
462 /// (JSON {samples, violations, dropped, maxDelta, records});
463 /// очередь очищается. 'null' — детектор выключен (конфиг без
464 /// секции divergence, боевой путь).
465 pub fn take_divergence(&mut self) -> String {
466 self.state.take_divergence()
467 }
468
469 /// Чистая распаковка кадра v3 → JSON {port, seq, serverTime,
470 /// camera, player, snapshot} (замена unpackFrame в тестах);
471 /// 'null' при несовпадении версии или повреждённом кадре.
472 pub fn decode_frame(&self, data: &[u8]) -> String {
473 self.state.decode_frame(data)
474 }
475
476 // ***** расширение ***** //
477
478 /// Зеркало `abi_describe` игрового ядра (см. игровой макрос).
479 pub fn abi_describe(&self) -> String {
480 self.state.abi_describe()
481 }
482
483 /// Зеркало `dispatch` игрового ядра (см. игровой макрос).
484 pub fn dispatch(&mut self, op: &str, payload: &[u8]) -> Vec<u8> {
485 self.state.dispatch(op, payload)
486 }
487 }
488 };
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[test]
496 fn describe_carries_engine_crate_version_and_ops() {
497 let json: ::serde_json::Value =
498 ::serde_json::from_str(&describe_json(ENGINE_GAME_OPS, &["snakes.grow"])).unwrap();
499
500 assert_eq!(json["abi"], ABI_DESCRIBE_VERSION);
501 assert_eq!(json["core"], env!("CARGO_PKG_VERSION"));
502 assert_eq!(
503 json["ops"],
504 ::serde_json::json!(["debug.json", "snakes.grow"])
505 );
506 }
507
508 // игра, объявившая движковый опкод, не должна удваивать его в списке
509 #[test]
510 fn describe_does_not_repeat_an_engine_op() {
511 let json: ::serde_json::Value =
512 ::serde_json::from_str(&describe_json(ENGINE_GAME_OPS, &["debug.json"])).unwrap();
513
514 assert_eq!(json["ops"], ::serde_json::json!(["debug.json"]));
515 }
516
517 // соглашение возврата: «не обработан» и «обработан, ответа нет» —
518 // разные состояния, иначе движок не отличит отказ от пустого ответа
519 #[test]
520 fn dispatch_result_separates_unhandled_from_empty_answer() {
521 assert_eq!(dispatch_result(None), Vec::<u8>::new());
522 assert_eq!(dispatch_result(Some(Vec::new())), vec![0x00]);
523 assert_eq!(dispatch_result(Some(vec![1, 2])), vec![1, 2]);
524 }
525}