Skip to main content

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            /// Явный уровень участника на слоёной карте (`respawns[i][3]`).
153            /// Движок зовёт сразу после `spawn_actor`/`reset_actor`, когда
154            /// точка респауна назвала уровень; игра без слоёв метод
155            /// игнорирует (дефолт трейта — no-op).
156            pub fn set_actor_level(&mut self, game_id: u32, level: u8) {
157                self.state.set_actor_level(game_id, level);
158            }
159
160            // ***** ввод и шаг ***** //
161
162            /// Ввод игрока: seq + action ('down'/'up') + имя клавиши
163            /// (wire-формат 'seq:action:name' разбирает JS-оболочка).
164            pub fn apply_input(&mut self, game_id: u32, seq: u32, action: &str, key_name: &str) {
165                self.state.apply_input(game_id, seq, action, key_name);
166            }
167
168            /// Ввод указателем: seq + мировая точка + биты состояния
169            /// (wire-формат 'seq:aim:x:y:flags' разбирает JS-оболочка).
170            pub fn apply_aim(&mut self, game_id: u32, seq: u32, x: f32, y: f32, flags: u32) {
171                self.state.apply_aim(game_id, seq, x, y, flags);
172            }
173
174            /// Шаг симуляции: фиксированные подшаги физики + ИИ ботов.
175            pub fn step(&mut self, dt: f32) {
176                self.state.step(dt);
177            }
178
179            /// События за тик (kill/health/ammo/weapon/shake) одной
180            /// JSON-строкой; буфер очищается.
181            pub fn take_events(&mut self) -> String {
182                self.state.take_events_json()
183            }
184
185            // ***** запросы состояния ***** //
186
187            pub fn last_input_seq(&self, game_id: u32) -> u32 {
188                self.state.last_input_seq(game_id)
189            }
190
191            pub fn is_alive(&self, game_id: u32) -> bool {
192                self.state.is_alive(game_id)
193            }
194
195            /// Координаты танка [x, y] (скруглены до 2 знаков) или пустой
196            /// массив.
197            pub fn position_of(&self, game_id: u32) -> Vec<f32> {
198                self.state
199                    .actor_position(game_id)
200                    .map(|p| p.to_vec())
201                    .unwrap_or_default()
202            }
203
204            /// Полные данные всех игроков (Game.getPlayersData) одной
205            /// JSON-строкой для первого кадра (FIRST_SHOT_DATA). Не
206            /// дренирует накопители.
207            pub fn players_data(&self) -> String {
208                self.state.players_json()
209            }
210
211            /// Живые игроки плоским массивом [id, teamId, x, y, ...]
212            /// (аналог Game.getAlivePlayers для меты).
213            pub fn alive_players(&self) -> Vec<f32> {
214                self.state.alive_players_flat()
215            }
216
217            // ***** снапшот ***** //
218
219            /// Пакует broadcast-тело кадра, дренируя накопленные события
220            /// снапшота. Вызывать один раз на отправляемый кадр (throttle
221            /// частоты отправки — забота JS-оболочки).
222            pub fn pack_body(&mut self) -> Result<(), ::wasm_bindgen::JsError> {
223                let blocks = self.state.build_snapshot_blocks();
224
225                self.packer
226                    .pack_body(&blocks)
227                    .map_err(|e| ::wasm_bindgen::JsError::new(&e))
228            }
229
230            /// Собирает per-user кадр v3 во внутренний буфер, возвращает
231            /// длину. Кадр читается zero-copy через frame_ptr() + память
232            /// WASM. player_id < 0 — кадр без player-блока (наблюдатель).
233            #[allow(clippy::too_many_arguments)]
234            pub fn pack_frame(
235                &mut self,
236                server_time: f64,
237                seq: u32,
238                has_camera: bool,
239                camera_x: f32,
240                camera_y: f32,
241                force_reset: bool,
242                shake: Option<String>,
243                player_id: i32,
244            ) -> usize {
245                let camera = has_camera.then_some($crate::snapshot::CameraData {
246                    x: camera_x,
247                    y: camera_y,
248                    force_reset,
249                    shake,
250                });
251
252                let player = if player_id >= 0 {
253                    let game_id = player_id as u32;
254
255                    self.state.prediction_state(game_id).map(|(state, centering)| {
256                        $crate::snapshot::PlayerBlock {
257                            game_id: game_id as u8,
258                            input_seq: self.state.last_input_seq(game_id),
259                            state,
260                            centering,
261                        }
262                    })
263                } else {
264                    None
265                };
266
267                self.packer
268                    .pack_frame(server_time, seq, camera.as_ref(), player.as_ref())
269                    .len()
270            }
271
272            /// Содержал ли последний `pack_body()` событийные блоки
273            /// (трассеры/бомбы/взрывы/удаления). JS-Worker вызывает после
274            /// `pack_body()` для выбора канала WebRTC: события → meta
275            /// (reliable), только позиции → state.
276            pub fn body_has_events(&self) -> bool {
277                self.state.body_has_events()
278            }
279
280            /// Указатель на буфер последнего кадра (zero-copy чтение из JS:
281            /// new Uint8Array(wasm.memory.buffer, ptr, len)).
282            pub fn frame_ptr(&self) -> *const u8 {
283                self.packer.frame_bytes().as_ptr()
284            }
285
286            /// Копия последнего кадра (nodejs-таргет не отдаёт память
287            /// наружу; горячий путь браузера использует frame_ptr + память
288            /// WASM).
289            pub fn frame_bytes(&self) -> Vec<u8> {
290                self.packer.frame_bytes().to_vec()
291            }
292
293            // ***** очистка и handoff ***** //
294
295            /// Удаляет игроков и снаряды, возвращает JSON-массив имён для
296            /// очистки полотна клиентов (Game.removePlayersAndShots).
297            pub fn remove_players_and_shots(&mut self) -> String {
298                ::serde_json::to_string(&self.state.remove_players_and_shots())
299                    .unwrap_or_else(|_| "[]".to_string())
300            }
301
302            /// Полная очистка мира (смена карты).
303            pub fn clear(&mut self) {
304                self.state.clear();
305            }
306
307            /// Курированный дамп мира для отладки (тела, коллайдеры,
308            /// карта, нав-граф, spatial-сетка, rng, аккумулятор) —
309            /// читаемая альтернатива serialize_state.
310            pub fn debug_json(&self) -> String {
311                self.state.debug_json()
312            }
313
314            /// Дамп состояния симуляции (Worker Handoff, Этап 5.2).
315            pub fn serialize_state(&self) -> Result<Vec<u8>, ::wasm_bindgen::JsError> {
316                self.state
317                    .serialize_state()
318                    .map_err(|e| ::wasm_bindgen::JsError::new(&e))
319            }
320
321            pub fn deserialize_state(&mut self, data: &[u8]) -> Result<(), ::wasm_bindgen::JsError> {
322                self.state
323                    .deserialize_state(data)
324                    .map_err(|e| ::wasm_bindgen::JsError::new(&e))
325            }
326
327            // ***** расширение ***** //
328
329            /// Самоописание ядра (JSON: версия формата, версия движкового
330            /// крейта, список опкодов dispatch). Движок читает его один раз
331            /// при загрузке — до первого вызова, а не посреди матча.
332            pub fn abi_describe(&self) -> String {
333                self.state.abi_describe()
334            }
335
336            /// Единая точка роста ABI: новая возможность приезжает опкодом,
337            /// а не новым символом (таблица экспортов заморожена). Пустой
338            /// возврат — «опкод не обработан», `[0x00]` — «обработан, ответа
339            /// нет».
340            pub fn dispatch(&mut self, op: &str, payload: &[u8]) -> Vec<u8> {
341                self.state.dispatch(op, payload)
342            }
343        }
344    };
345}
346
347/// `export_client_core_abi!` — генерирует `#[wasm_bindgen] impl` для
348/// клиентского `ClientCore`, зеркально `export_game_core_abi!` (см. выше).
349/// Методы ниже — движковый минимум (§3.4 PLAN.md): 1:1-делегации в generic
350/// `vimp_engine_core::client::game::ClientState<G>`, чья сигнатура
351/// зафиксирована трейтом `GameClientDef` независимо от конкретной игры.
352///
353/// Контракт: тип-цель обязан иметь поле
354/// `state: vimp_engine_core::client::game::ClientState<G>` (под именем
355/// `ClientState<...>`, конкретный `G: GameClientDef` — забота игрового
356/// crate), и раскрываться в модуле, где уже подключён `wasm-bindgen`.
357/// Игровые методы вне минимума (`set_model`, `try_fire`, `cycle_weapon`,
358/// `sync_panel` и т.п. — сигнатуры которых по форме зависят от игры)
359/// остаются рукописными в game-crate рядом с раскрытием макроса.
360#[macro_export]
361macro_rules! export_client_core_abi {
362    ($ClientCoreTy:ty) => {
363        #[::wasm_bindgen::prelude::wasm_bindgen]
364        impl $ClientCoreTy {
365            // ***** сеть ***** //
366
367            /// Бинарный кадр из транспорта: распаковка, вставка в буфер по
368            /// seq (+дедупликация/опоздавшие), reconciliation предикта по
369            /// player-блоку. false — кадр отброшен (чужой порт/версия/
370            /// повреждён).
371            pub fn push_frame(&mut self, data: &[u8], local_now: f64) -> bool {
372                self.state.push_frame(data, local_now)
373            }
374
375            /// Свой gameId из последнего player-блока; -1, если ещё не
376            /// приходил.
377            pub fn my_game_id(&self) -> i32 {
378                self.state.my_game_id().map(|id| id as i32).unwrap_or(-1)
379            }
380
381            /// EMA-оценка (serverTime − localNow); NaN, если кадров ещё не
382            /// было. Это разница часов (`Date.now` хоста против
383            /// `performance.now` клиента), а **не** латентность: за оценку
384            /// RTT её принимать нельзя.
385            pub fn offset(&self) -> f64 {
386                self.state.offset().unwrap_or(f64::NAN)
387            }
388
389            // ***** рендер-тик ***** //
390
391            /// Весь рендер-тик: выдача пересечённых кадров (фильтр дублей
392            /// своих эффектов → JSON-очередь), интерполяция, шаг предикта,
393            /// запись hot-буфера. Возвращает длину hot-буфера в
394            /// f32-элементах.
395            pub fn sample(&mut self, local_now: f64) -> usize {
396                self.state.sample(local_now)
397            }
398
399            /// Указатель на hot-буфер (zero-copy чтение из JS:
400            /// new Float32Array(wasm.memory.buffer, ptr, len) — view
401            /// пересоздавать каждый тик, рост памяти WASM инвалидирует
402            /// buffer).
403            pub fn hot_ptr(&self) -> *const f32 {
404                self.state.hot().as_ptr()
405            }
406
407            /// Копия hot-буфера (nodejs-таргет; горячий путь браузера —
408            /// hot_ptr).
409            pub fn hot_values(&self) -> Vec<f32> {
410                self.state.hot().to_vec()
411            }
412
413            /// Событийные кадры JSON-строкой [{game, camera}, ...] в
414            /// форме, готовой для applyShot; вызывать при флаге hasFrames
415            /// hot-буфера, очередь очищается.
416            pub fn take_frames(&mut self) -> String {
417                self.state.take_frames()
418            }
419
420            // ***** ввод ***** //
421
422            /// Ввод игрока: action ('down'/'up') + имя клавиши — в историю
423            /// предикта.
424            pub fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64) {
425                self.state.apply_input(action, key_name, local_now);
426            }
427
428            /// Ввод указателем: мировая точка + биты состояния — в историю
429            /// предикта.
430            pub fn apply_aim(&mut self, x: f32, y: f32, flags: u32, local_now: f64) {
431                self.state.apply_aim(x, y, flags, local_now);
432            }
433
434            // ***** жизненный цикл ***** //
435
436            /// Смена режима игрок/спектатор (KEYSET_DATA).
437            pub fn set_active(&mut self, active: bool) {
438                self.state.set_active(active);
439            }
440
441            /// Данные карты (MAP_DATA): мир raycast + сброс буфера и
442            /// предикта.
443            pub fn set_map(&mut self, map_json: &str) -> Result<(), ::wasm_bindgen::JsError> {
444                self.state
445                    .set_map(map_json)
446                    .map_err(|e| ::wasm_bindgen::JsError::new(&e))
447            }
448
449            /// Полный сброс (порт CLEAR).
450            pub fn reset(&mut self) {
451                self.state.reset();
452            }
453
454            /// Ресинк часов после долгой паузы вкладки: сброс сетевого
455            /// буфера и очереди кадров без обнуления предикта.
456            pub fn resync(&mut self) {
457                self.state.resync();
458            }
459
460            // ***** тесты и харнесс ***** //
461
462            /// Дамп клиентского состояния для отладки: сетевой буфер
463            /// (глубина, окно seq, оффсет, последний кадр), свой gameId,
464            /// размеры hot-буфера и очереди событийных кадров.
465            pub fn debug_json(&self) -> String {
466                self.state.debug_json()
467            }
468
469            /// Записи расхождения предикта с авторитетным состоянием
470            /// (JSON {samples, violations, dropped, maxDelta, records});
471            /// очередь очищается. 'null' — детектор выключен (конфиг без
472            /// секции divergence, боевой путь).
473            pub fn take_divergence(&mut self) -> String {
474                self.state.take_divergence()
475            }
476
477            /// Чистая распаковка кадра v3 → JSON {port, seq, serverTime,
478            /// camera, player, snapshot} (замена unpackFrame в тестах);
479            /// 'null' при несовпадении версии или повреждённом кадре.
480            pub fn decode_frame(&self, data: &[u8]) -> String {
481                self.state.decode_frame(data)
482            }
483
484            // ***** расширение ***** //
485
486            /// Зеркало `abi_describe` игрового ядра (см. игровой макрос).
487            pub fn abi_describe(&self) -> String {
488                self.state.abi_describe()
489            }
490
491            /// Зеркало `dispatch` игрового ядра (см. игровой макрос).
492            pub fn dispatch(&mut self, op: &str, payload: &[u8]) -> Vec<u8> {
493                self.state.dispatch(op, payload)
494            }
495        }
496    };
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn describe_carries_engine_crate_version_and_ops() {
505        let json: ::serde_json::Value =
506            ::serde_json::from_str(&describe_json(ENGINE_GAME_OPS, &["snakes.grow"])).unwrap();
507
508        assert_eq!(json["abi"], ABI_DESCRIBE_VERSION);
509        assert_eq!(json["core"], env!("CARGO_PKG_VERSION"));
510        assert_eq!(
511            json["ops"],
512            ::serde_json::json!(["debug.json", "snakes.grow"])
513        );
514    }
515
516    // игра, объявившая движковый опкод, не должна удваивать его в списке
517    #[test]
518    fn describe_does_not_repeat_an_engine_op() {
519        let json: ::serde_json::Value =
520            ::serde_json::from_str(&describe_json(ENGINE_GAME_OPS, &["debug.json"])).unwrap();
521
522        assert_eq!(json["ops"], ::serde_json::json!(["debug.json"]));
523    }
524
525    // соглашение возврата: «не обработан» и «обработан, ответа нет» —
526    // разные состояния, иначе движок не отличит отказ от пустого ответа
527    #[test]
528    fn dispatch_result_separates_unhandled_from_empty_answer() {
529        assert_eq!(dispatch_result(None), Vec::<u8>::new());
530        assert_eq!(dispatch_result(Some(Vec::new())), vec![0x00]);
531        assert_eq!(dispatch_result(Some(vec![1, 2])), vec![1, 2]);
532    }
533}