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