Skip to main content

vimp_engine_core/
game.rs

1use std::sync::mpsc::channel;
2
3use rapier2d::prelude::*;
4use serde::{Deserialize, Serialize};
5
6use crate::nav::navigation::NavigationSystem;
7use crate::nav::spatial::SpatialGrid;
8use crate::config::{EngineConfig, PLAYER_STATE_LEN};
9use crate::events::CoreEvent;
10use crate::map::{GameMap, MapConfig};
11use crate::rng::Rng;
12use crate::sim::{GameDef, GameSim, SimCtx};
13use crate::snapshot::Block;
14
15/// защита от «спирали смерти» (Game._maxAccumulatedTime)
16const MAX_ACCUMULATED_TIME: f32 = 0.1;
17
18/// Движковый каркас симуляции: физический мир, карта, нав-граф/сетка
19/// ботов, PRNG, аккумулятор фиксированного шага, очередь удаления тел,
20/// события. Игровая логика (участники/оружие/снапшот-блоки) вынесена в
21/// `G::Sim` (см. core/src/sim.rs) — движок зовёт её только через
22/// `on_fixed_step`/`on_contacts`/`on_before_destroy`/`on_ai_tick`, не зная
23/// деталей конкретной игры. Конкретная игра (тип `G`, дефолт для
24/// `GameState`-алиасов — забота game-crate) не импортируется движком.
25pub struct EngineSim<G: GameDef> {
26    pub cfg: EngineConfig,
27    pub world: PhysicsWorld,
28    pub map: Option<GameMap>,
29    pub nav: Option<NavigationSystem>,
30    pub spatial: SpatialGrid,
31    pub rng: Rng,
32
33    time_step: f32,
34    accumulator: f32,
35
36    // события для меты, дренируются в take_events
37    pub events: Vec<CoreEvent>,
38
39    // очередь тел на удаление после обработки контактов
40    bodies_to_destroy: Vec<RigidBodyHandle>,
41
42    // содержал ли последний собранный body событийные блоки — для
43    // классификации канала WebRTC (meta reliable / state unreliable)
44    last_body_has_events: bool,
45
46    pub sim: G::Sim,
47}
48
49impl<G: GameDef> EngineSim<G> {
50    pub fn new(cfg: EngineConfig, game_cfg: &G::Config) -> Self {
51        let time_step = cfg.time_step;
52
53        let mut world = PhysicsWorld::new();
54
55        world.gravity = Vector::ZERO;
56        world.integration_parameters.dt = time_step;
57
58        let seed = cfg.seed;
59        let sim = G::Sim::new(game_cfg, &cfg);
60
61        Self {
62            world,
63            map: None,
64            nav: None,
65            spatial: SpatialGrid::new(600.0),
66            rng: Rng::new(seed),
67            time_step,
68            accumulator: 0.0,
69            events: Vec::new(),
70            bodies_to_destroy: Vec::new(),
71            last_body_has_events: false,
72            sim,
73            cfg,
74        }
75    }
76
77    // ***** карта ***** //
78
79    /// Создаёт карту из JSON (масштабирование внутри) и нав-граф ботов.
80    pub fn load_map(&mut self, json: &str) -> Result<(), String> {
81        let map_cfg: MapConfig = serde_json::from_str(json).map_err(|e| format!("bad map json: {e}"))?;
82
83        if let Some(mut old) = self.map.take() {
84            old.destroy(&mut self.world);
85        }
86
87        let map = GameMap::create(&mut self.world, &map_cfg, self.cfg.map_scale, &self.cfg.map_set_id);
88
89        self.nav = Some(NavigationSystem::generate(&map.grid, &map.physics_static, map.step));
90        self.map = Some(map);
91
92        Ok(())
93    }
94
95    /// Информация о загруженной карте: setId, масштабированные респауны,
96    /// размеры мира (JSON) — 'null', если карта не загружена.
97    pub fn map_info_json(&self) -> String {
98        let Some(map) = &self.map else {
99            return "null".to_string();
100        };
101
102        let width = map.grid.first().map(|row| row.len()).unwrap_or(0) as f32 * map.step;
103        let height = map.grid.len() as f32 * map.step;
104
105        serde_json::json!({
106            "setId": map.set_id,
107            "step": map.step,
108            "width": width,
109            "height": height,
110            "respawns": map.respawns,
111        })
112        .to_string()
113    }
114
115    // ***** участники ***** //
116
117    pub fn spawn_actor(&mut self, game_id: u32, model_name: &str, team_id: u8, x: f32, y: f32, angle_deg: f32) -> Result<(), String> {
118        self.sim
119            .spawn_actor(&mut self.world, &mut self.events, game_id, model_name, team_id, x, y, angle_deg)
120    }
121
122    pub fn remove_actor(&mut self, game_id: u32) {
123        self.sim.remove_actor(&mut self.world, game_id);
124    }
125
126    pub fn reset_actor(&mut self, game_id: u32, team_id: u8, x: f32, y: f32, angle_deg: f32) {
127        self.sim.reset_actor(&mut self.world, game_id, team_id, x, y, angle_deg);
128    }
129
130    pub fn reset_all_vitals(&mut self) {
131        self.sim.reset_all_vitals(&mut self.events);
132    }
133
134    pub fn spawn_scripted_actor(&mut self, game_id: u32, model_name: &str, team_id: u8, x: f32, y: f32, angle_deg: f32) -> Result<(), String> {
135        self.sim.spawn_scripted_actor(
136            &mut self.world,
137            &mut self.rng,
138            &mut self.events,
139            game_id,
140            model_name,
141            team_id,
142            x,
143            y,
144            angle_deg,
145        )
146    }
147
148    pub fn remove_scripted_actor(&mut self, game_id: u32) {
149        self.sim.remove_scripted_actor(&mut self.world, game_id);
150    }
151
152    // ***** ввод ***** //
153
154    pub fn apply_input(&mut self, game_id: u32, seq: u32, action: &str, key_name: &str) {
155        self.sim.apply_input(game_id, seq, action, key_name);
156    }
157
158    /// Ввод указателем: мировая точка + биты состояния (см. `GameSim::apply_aim`).
159    pub fn apply_aim(&mut self, game_id: u32, seq: u32, x: f32, y: f32, flags: u32) {
160        self.sim.apply_aim(game_id, seq, x, y, flags);
161    }
162
163    pub fn last_input_seq(&self, game_id: u32) -> u32 {
164        self.sim.last_input_seq(game_id)
165    }
166
167    /// События за тик (kill/health/ammo/weapon/shake) одной JSON-строкой;
168    /// буфер очищается.
169    pub fn take_events_json(&mut self) -> String {
170        let events: Vec<CoreEvent> = self.events.drain(..).collect();
171
172        serde_json::to_string(&events).unwrap_or_else(|_| "[]".to_string())
173    }
174
175    // ***** запросы состояния ***** //
176
177    pub fn is_alive(&self, game_id: u32) -> bool {
178        self.sim.is_alive(game_id)
179    }
180
181    pub fn actor_position(&self, game_id: u32) -> Option<[f32; 2]> {
182        self.sim.actor_position(&self.world, game_id)
183    }
184
185    pub fn prediction_state(&self, game_id: u32) -> Option<([f32; PLAYER_STATE_LEN], bool)> {
186        self.sim.prediction_state(&self.world, game_id)
187    }
188
189    pub fn alive_players_flat(&self) -> Vec<f32> {
190        self.sim.alive_players_flat(&self.world)
191    }
192
193    /// Полный снапшот игроков (Game.getPlayersData), не дренируя
194    /// накопители событий — для первого кадра (FIRST_SHOT_DATA).
195    pub fn players_json(&self) -> String {
196        self.sim.players_json()
197    }
198
199    // ***** игровой тик ***** //
200
201    /// Обновляет физику фиксированными шагами (Game.updateData),
202    /// затем ИИ скриптовых участников (VIMP._onShotTick).
203    pub fn step(&mut self, dt: f32) {
204        self.accumulator = (self.accumulator + dt).min(MAX_ACCUMULATED_TIME);
205
206        while self.accumulator >= self.time_step {
207            self.step_fixed();
208            self.accumulator -= self.time_step;
209        }
210
211        self.sim.refresh_cached(&self.world);
212
213        let mut ctx = SimCtx {
214            world: &mut self.world,
215            cfg: &self.cfg,
216            map: &self.map,
217            nav: &self.nav,
218            spatial: &mut self.spatial,
219            rng: &mut self.rng,
220            events: &mut self.events,
221            bodies_to_destroy: &mut self.bodies_to_destroy,
222        };
223
224        self.sim.on_ai_tick(&mut ctx, dt);
225    }
226
227    /// Один фиксированный шаг физики.
228    fn step_fixed(&mut self) {
229        let mut ctx = SimCtx {
230            world: &mut self.world,
231            cfg: &self.cfg,
232            map: &self.map,
233            nav: &self.nav,
234            spatial: &mut self.spatial,
235            rng: &mut self.rng,
236            events: &mut self.events,
237            bodies_to_destroy: &mut self.bodies_to_destroy,
238        };
239
240        self.sim.on_fixed_step(&mut ctx, self.time_step);
241
242        // шаг физического мира со сбором начавшихся контактов
243        let (collision_send, collision_recv) = channel();
244        let (force_send, _force_recv) = channel();
245        let collector = ChannelEventCollector::new(collision_send, force_send);
246
247        self.world.step_with_events(&(), &collector);
248
249        let mut contacts = Vec::new();
250
251        while let Ok(event) = collision_recv.try_recv() {
252            if let CollisionEvent::Started(h1, h2, _) = event {
253                contacts.push((h1, h2));
254            }
255        }
256
257        let mut ctx = SimCtx {
258            world: &mut self.world,
259            cfg: &self.cfg,
260            map: &self.map,
261            nav: &self.nav,
262            spatial: &mut self.spatial,
263            rng: &mut self.rng,
264            events: &mut self.events,
265            bodies_to_destroy: &mut self.bodies_to_destroy,
266        };
267
268        self.sim.on_contacts(&mut ctx, &contacts);
269        self.destroy_queued_bodies();
270    }
271
272    /// Уничтожает тела из очереди (Game._destroyQueuedBodies).
273    fn destroy_queued_bodies(&mut self) {
274        if self.bodies_to_destroy.is_empty() {
275            return;
276        }
277
278        let handles: Vec<RigidBodyHandle> = self.bodies_to_destroy.drain(..).collect();
279
280        for handle in handles {
281            self.sim.on_before_destroy(&self.world, handle);
282            self.world.remove_body(handle);
283        }
284    }
285
286    // ***** снапшот ***** //
287
288    /// Собирает блоки тела снапшота, дренируя накопители событий.
289    /// Вызывается на каждый отправляемый кадр (throttle — забота JS).
290    pub fn build_snapshot_blocks(&mut self) -> Vec<(String, Block)> {
291        let (mut blocks, has_events) = self.sim.build_snapshot_blocks();
292
293        // динамические элементы карты — каждый отправляемый кадр.
294        // Хвост со скоростями пишется, только если схема набора карт его
295        // объявила (`optionalFrom`): движок не навязывает игре ширину строки
296        if let Some(map) = &self.map {
297            let with_velocities = self
298                .cfg
299                .snapshot
300                .keys
301                .get(&map.set_id)
302                .is_some_and(|schema| schema.optional_from.is_some());
303
304            blocks.push((
305                map.set_id.clone(),
306                Block::IndexedNoNull8(map.dynamic_map_data(&self.world, with_velocities)),
307            ));
308        }
309
310        self.last_body_has_events = has_events;
311
312        blocks
313    }
314
315    /// Содержал ли последний собранный body событийные блоки — для
316    /// классификации канала WebRTC (meta reliable / state unreliable).
317    pub fn body_has_events(&self) -> bool {
318        self.last_body_has_events
319    }
320
321    // ***** очистка ***** //
322
323    /// Удаляет всех игроков и снаряды, возвращает имена для очистки
324    /// полотна клиентов (Game.removePlayersAndShots).
325    pub fn remove_players_and_shots(&mut self) -> Vec<String> {
326        self.sim.remove_players_and_shots(&mut self.world)
327    }
328
329    /// Полная очистка игрового мира (Game.clear + сброс ботов).
330    pub fn clear(&mut self) {
331        // карта удаляет свои тела первой: повторное удаление уже
332        // удалённого тела в Rapier недопустимо
333        if let Some(mut map) = self.map.take() {
334            map.destroy(&mut self.world);
335        }
336
337        let handles: Vec<RigidBodyHandle> = self.world.rigid_bodies().map(|(handle, _)| handle).collect();
338
339        for handle in handles {
340            self.world.remove_body(handle);
341        }
342
343        self.sim.clear();
344        self.nav = None;
345        self.spatial.clear();
346        self.bodies_to_destroy.clear();
347
348        self.accumulator = 0.0;
349    }
350
351    // ***** отладка ***** //
352
353    /// Курированный дамп мира (тела, коллайдеры, карта, нав-граф,
354    /// spatial-сетка, rng, аккумулятор фикс-шага) — читаемая
355    /// альтернатива сырому `serialize_state` (см. `crate::debug`).
356    pub fn debug_json(&self) -> String {
357        crate::debug::engine_json(
358            &self.world,
359            &self.map,
360            &self.nav,
361            &self.spatial,
362            &self.rng,
363            self.accumulator,
364            self.time_step,
365        )
366        .to_string()
367    }
368
369    /// Самоописание игрового ядра: версия формата, версия движкового
370    /// крейта, опкоды `dispatch` (движковые + объявленные игрой).
371    pub fn abi_describe(&self) -> String {
372        crate::abi::describe_json(crate::abi::ENGINE_GAME_OPS, self.sim.dispatch_ops())
373    }
374
375    /// Опкод движка, иначе игровой опкод, иначе «не обработан» (пустой
376    /// вектор). Единая точка роста ABI: новая возможность приезжает
377    /// опкодом, а не новым символом — в dist уже опубликованной игры
378    /// символ не появится никогда.
379    pub fn dispatch(&mut self, op: &str, payload: &[u8]) -> Vec<u8> {
380        let out = match op {
381            crate::abi::OP_DEBUG_JSON => Some(self.debug_json().into_bytes()),
382            _ => self.sim.dispatch_op(op, payload),
383        };
384
385        crate::abi::dispatch_result(out)
386    }
387
388    // ***** handoff (Spike B / Этап 5.2) ***** //
389
390    /// Сериализует состояние симуляции для эстафетной передачи между
391    /// инстансами WASM. Накопители снапшота должны быть дренированы
392    /// (pack_body) перед вызовом.
393    pub fn serialize_state(&self) -> Result<Vec<u8>, String> {
394        let dump = EngineDump {
395            world: WorldDump {
396                gravity: [self.world.gravity.x, self.world.gravity.y],
397                integration_parameters: self.world.integration_parameters,
398                islands: self.world.islands.clone(),
399                broad_phase: self.world.broad_phase.clone(),
400                narrow_phase: self.world.narrow_phase.clone(),
401                bodies: self.world.bodies.clone(),
402                colliders: self.world.colliders.clone(),
403                impulse_joints: self.world.impulse_joints.clone(),
404                multibody_joints: self.world.multibody_joints.clone(),
405            },
406            map: &self.map,
407            rng: &self.rng,
408            accumulator: self.accumulator,
409            sim: self.sim.serialize(),
410        };
411
412        serde_json::to_vec(&dump).map_err(|e| e.to_string())
413    }
414
415    /// Восстанавливает состояние из дампа. Конфиг ядра должен совпадать
416    /// с конфигом инстанса, создавшего дамп.
417    pub fn deserialize_state(&mut self, data: &[u8]) -> Result<(), String> {
418        let dump: EngineDumpOwned = serde_json::from_slice(data).map_err(|e| e.to_string())?;
419
420        let mut world = PhysicsWorld::new();
421
422        world.gravity = Vector::new(dump.world.gravity[0], dump.world.gravity[1]);
423        world.integration_parameters = dump.world.integration_parameters;
424        world.islands = dump.world.islands;
425        world.broad_phase = dump.world.broad_phase;
426        world.narrow_phase = dump.world.narrow_phase;
427        world.bodies = dump.world.bodies;
428        world.colliders = dump.world.colliders;
429        world.impulse_joints = dump.world.impulse_joints;
430        world.multibody_joints = dump.world.multibody_joints;
431
432        self.world = world;
433        self.map = dump.map;
434        self.rng = dump.rng;
435        self.accumulator = dump.accumulator;
436        self.sim.deserialize(dump.sim)?;
437
438        self.bodies_to_destroy.clear();
439        self.events.clear();
440
441        // производные структуры не входят в дамп: нав-граф регенерируется
442        // из карты, пространственная сетка — из живых участников
443        self.nav = self
444            .map
445            .as_ref()
446            .map(|map| NavigationSystem::generate(&map.grid, &map.physics_static, map.step));
447
448        self.sim.rebuild_spatial_grid(&self.world, &mut self.spatial);
449        self.sim.refresh_cached(&self.world);
450
451        Ok(())
452    }
453}
454
455#[derive(Serialize)]
456struct WorldDump {
457    gravity: [f32; 2],
458    integration_parameters: IntegrationParameters,
459    islands: IslandManager,
460    broad_phase: BroadPhaseBvh,
461    narrow_phase: NarrowPhase,
462    bodies: RigidBodySet,
463    colliders: ColliderSet,
464    impulse_joints: ImpulseJointSet,
465    multibody_joints: MultibodyJointSet,
466}
467
468#[derive(Deserialize)]
469struct WorldDumpOwned {
470    gravity: [f32; 2],
471    integration_parameters: IntegrationParameters,
472    islands: IslandManager,
473    broad_phase: BroadPhaseBvh,
474    narrow_phase: NarrowPhase,
475    bodies: RigidBodySet,
476    colliders: ColliderSet,
477    impulse_joints: ImpulseJointSet,
478    multibody_joints: MultibodyJointSet,
479}
480
481#[derive(Serialize)]
482struct EngineDump<'a> {
483    world: WorldDump,
484    map: &'a Option<GameMap>,
485    rng: &'a Rng,
486    accumulator: f32,
487    sim: serde_json::Value,
488}
489
490#[derive(Deserialize)]
491struct EngineDumpOwned {
492    world: WorldDumpOwned,
493    map: Option<GameMap>,
494    rng: Rng,
495    accumulator: f32,
496    sim: serde_json::Value,
497}
498
499/// Фикстурная вторая игра (Этап 7 плана отделения движка, PLAN.md):
500/// доказывает форму `GameDef`/`GameSim<G>` без физических тел (актёры —
501/// плоские данные, не rapier-тела — `spawn_actor` и т.п. игнорируют `world`,
502/// трейт этого не требует). Тот же принцип, что у клиентской фикстуры
503/// `TestClient` (`crate::client::game`): форма ABI важнее физики. Используется
504/// только тестами этого модуля, не влияет на `TanksGame`.
505#[cfg(test)]
506mod fixture {
507    use super::*;
508    use crate::config::FieldValue;
509    use serde::Deserialize;
510    use std::collections::{BTreeMap, BTreeSet};
511
512    #[derive(Deserialize)]
513    pub struct TestConfig {}
514
515    pub struct TestGame;
516
517    impl GameDef for TestGame {
518        type Config = TestConfig;
519        type Sim = TestSim;
520    }
521
522    #[derive(Clone, Copy)]
523    struct TestActor {
524        x: f32,
525        y: f32,
526        vx: f32,
527        vy: f32,
528        team: u8,
529        alive: bool,
530    }
531
532    pub struct TestSim {
533        actors: BTreeMap<u32, TestActor>,
534        scripted: BTreeSet<u32>,
535    }
536
537    impl GameSim<TestGame> for TestSim {
538        fn new(_cfg: &TestConfig, _engine_cfg: &EngineConfig) -> Self {
539            Self {
540                actors: BTreeMap::new(),
541                scripted: BTreeSet::new(),
542            }
543        }
544
545        fn spawn_actor(
546            &mut self,
547            _world: &mut PhysicsWorld,
548            _events: &mut Vec<CoreEvent>,
549            game_id: u32,
550            _model_name: &str,
551            team_id: u8,
552            x: f32,
553            y: f32,
554            _angle_deg: f32,
555        ) -> Result<(), String> {
556            self.actors.insert(
557                game_id,
558                TestActor { x, y, vx: 0.0, vy: 0.0, team: team_id, alive: true },
559            );
560
561            Ok(())
562        }
563
564        fn remove_actor(&mut self, _world: &mut PhysicsWorld, game_id: u32) {
565            self.actors.remove(&game_id);
566            self.scripted.remove(&game_id);
567        }
568
569        fn reset_actor(&mut self, _world: &mut PhysicsWorld, game_id: u32, team_id: u8, x: f32, y: f32, _angle_deg: f32) {
570            if let Some(actor) = self.actors.get_mut(&game_id) {
571                actor.x = x;
572                actor.y = y;
573                actor.team = team_id;
574                actor.alive = true;
575            }
576        }
577
578        fn reset_all_vitals(&mut self, _events: &mut Vec<CoreEvent>) {
579            for actor in self.actors.values_mut() {
580                actor.alive = true;
581            }
582        }
583
584        fn spawn_scripted_actor(
585            &mut self,
586            world: &mut PhysicsWorld,
587            _rng: &mut Rng,
588            events: &mut Vec<CoreEvent>,
589            game_id: u32,
590            model_name: &str,
591            team_id: u8,
592            x: f32,
593            y: f32,
594            angle_deg: f32,
595        ) -> Result<(), String> {
596            self.spawn_actor(world, events, game_id, model_name, team_id, x, y, angle_deg)?;
597            self.scripted.insert(game_id);
598
599            Ok(())
600        }
601
602        fn remove_scripted_actor(&mut self, world: &mut PhysicsWorld, game_id: u32) {
603            self.remove_actor(world, game_id);
604        }
605
606        fn apply_input(&mut self, game_id: u32, _seq: u32, action: &str, key_name: &str) {
607            let Some(actor) = self.actors.get_mut(&game_id) else {
608                return;
609            };
610
611            let magnitude = if action == "down" { 40.0 } else { 0.0 };
612
613            match key_name {
614                "forward" => actor.vy = -magnitude,
615                "back" => actor.vy = magnitude,
616                _ => {}
617            }
618        }
619
620        fn last_input_seq(&self, _game_id: u32) -> u32 {
621            0
622        }
623
624        fn is_alive(&self, game_id: u32) -> bool {
625            self.actors.get(&game_id).is_some_and(|a| a.alive)
626        }
627
628        fn actor_position(&self, _world: &PhysicsWorld, game_id: u32) -> Option<[f32; 2]> {
629            self.actors.get(&game_id).map(|a| [a.x, a.y])
630        }
631
632        fn prediction_state(&self, _world: &PhysicsWorld, game_id: u32) -> Option<([f32; PLAYER_STATE_LEN], bool)> {
633            self.actors
634                .get(&game_id)
635                .map(|a| ([a.x, a.y, 0.0, a.vx, a.vy, 0.0, 0.0, 0.0], false))
636        }
637
638        fn alive_players_flat(&self, _world: &PhysicsWorld) -> Vec<f32> {
639            self.actors
640                .iter()
641                .filter(|(_, a)| a.alive)
642                .flat_map(|(id, a)| [*id as f32, a.x, a.y])
643                .collect()
644        }
645
646        fn players_json(&self) -> String {
647            let rows: Vec<serde_json::Value> = self
648                .actors
649                .iter()
650                .map(|(id, a)| serde_json::json!({ "id": id, "x": a.x, "y": a.y, "team": a.team }))
651                .collect();
652
653            serde_json::to_string(&rows).unwrap()
654        }
655
656        fn on_fixed_step(&mut self, _ctx: &mut SimCtx, dt: f32) {
657            for actor in self.actors.values_mut() {
658                actor.x += actor.vx * dt;
659                actor.y += actor.vy * dt;
660            }
661        }
662
663        fn on_contacts(&mut self, _ctx: &mut SimCtx, _pairs: &[(ColliderHandle, ColliderHandle)]) {}
664
665        fn on_before_destroy(&mut self, _world: &PhysicsWorld, _handle: RigidBodyHandle) {}
666
667        fn on_ai_tick(&mut self, _ctx: &mut SimCtx, _dt: f32) {
668            // тривиальный ИИ фикстуры: скриптовый актёр всегда едет вправо
669            for &id in &self.scripted {
670                if let Some(actor) = self.actors.get_mut(&id) {
671                    actor.vx = 1.0;
672                }
673            }
674        }
675
676        fn refresh_cached(&mut self, _world: &PhysicsWorld) {}
677
678        fn build_snapshot_blocks(&mut self) -> (Vec<(String, Block)>, bool) {
679            let rows: Vec<(u8, Option<Vec<FieldValue>>)> = self
680                .actors
681                .iter()
682                .map(|(id, a)| (*id as u8, Some(vec![FieldValue::F32(a.x), FieldValue::F32(a.y)])))
683                .collect();
684
685            (vec![("actor".to_string(), Block::Indexed8(rows))], false)
686        }
687
688        fn remove_players_and_shots(&mut self, _world: &mut PhysicsWorld) -> Vec<String> {
689            let names: Vec<String> = self.actors.keys().map(|id| id.to_string()).collect();
690
691            self.actors.clear();
692            self.scripted.clear();
693
694            names
695        }
696
697        fn clear(&mut self) {
698            self.actors.clear();
699            self.scripted.clear();
700        }
701
702        fn serialize(&self) -> serde_json::Value {
703            let rows: Vec<serde_json::Value> = self
704                .actors
705                .iter()
706                .map(|(id, a)| serde_json::json!({ "id": id, "x": a.x, "y": a.y, "team": a.team }))
707                .collect();
708
709            serde_json::json!({ "actors": rows })
710        }
711
712        fn deserialize(&mut self, value: serde_json::Value) -> Result<(), String> {
713            let rows = value["actors"].as_array().ok_or("missing actors")?;
714
715            self.actors.clear();
716
717            for row in rows {
718                let id = row["id"].as_u64().ok_or("bad id")? as u32;
719                let x = row["x"].as_f64().ok_or("bad x")? as f32;
720                let y = row["y"].as_f64().ok_or("bad y")? as f32;
721                let team = row["team"].as_u64().ok_or("bad team")? as u8;
722
723                self.actors.insert(id, TestActor { x, y, vx: 0.0, vy: 0.0, team, alive: true });
724            }
725
726            Ok(())
727        }
728
729        fn rebuild_spatial_grid(&self, _world: &PhysicsWorld, _spatial: &mut SpatialGrid) {}
730    }
731}
732
733#[cfg(test)]
734mod tests {
735    use super::fixture::{TestConfig, TestGame};
736    use super::*;
737    use crate::snapshot::SnapshotPacker;
738
739    fn engine_config() -> EngineConfig {
740        serde_json::from_value(serde_json::json!({
741            "timeStep": 1.0 / 120.0,
742            "snapshot": {
743                "version": 3,
744                "port": 5,
745                "keys": {
746                    "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
747                        { "name": "x", "ty": "f32", "interp": "lerp" },
748                        { "name": "y", "ty": "f32", "interp": "lerp" }
749                    ] }
750                }
751            },
752            "seed": 42
753        }))
754        .unwrap()
755    }
756
757    fn make_sim() -> EngineSim<TestGame> {
758        EngineSim::new(engine_config(), &TestConfig {})
759    }
760
761    #[test]
762    fn spawn_actor_and_fixed_step_moves_position() {
763        let mut sim = make_sim();
764
765        sim.spawn_actor(1, "m", 1, 0.0, 0.0, 0.0).unwrap();
766        sim.apply_input(1, 1, "down", "forward");
767
768        for _ in 0..60 {
769            sim.step(1.0 / 120.0);
770        }
771
772        let pos = sim.actor_position(1).unwrap();
773
774        assert!(pos[1] < 0.0); // forward = -vy
775    }
776
777    #[test]
778    fn remove_actor_clears_state() {
779        let mut sim = make_sim();
780
781        sim.spawn_actor(1, "m", 1, 5.0, 5.0, 0.0).unwrap();
782        assert!(sim.is_alive(1));
783
784        sim.remove_actor(1);
785        assert!(!sim.is_alive(1));
786        assert!(sim.actor_position(1).is_none());
787    }
788
789    #[test]
790    fn scripted_actor_runs_ai_tick() {
791        let mut sim = make_sim();
792
793        sim.spawn_scripted_actor(9, "m", 1, 0.0, 0.0, 0.0).unwrap();
794
795        // on_ai_tick — после фикс-шага в том же step(); vx применяется к
796        // позиции только следующим вызовом
797        sim.step(1.0 / 120.0);
798        sim.step(1.0 / 120.0);
799
800        let after = sim.actor_position(9).unwrap();
801
802        assert!(after[0] > 0.0); // фикстурный ИИ едет вправо (vx = 1.0)
803    }
804
805    #[test]
806    fn build_snapshot_blocks_packs_through_generic_schema() {
807        let mut sim = make_sim();
808
809        sim.spawn_actor(1, "m", 1, 3.0, 4.0, 0.0).unwrap();
810        sim.spawn_actor(2, "m", 1, 7.0, 8.0, 0.0).unwrap();
811
812        let blocks = sim.build_snapshot_blocks();
813        let mut packer = SnapshotPacker::new(engine_config().snapshot);
814
815        assert!(packer.pack_body(&blocks).is_ok());
816    }
817
818    #[test]
819    fn clear_removes_all_actors() {
820        let mut sim = make_sim();
821
822        sim.spawn_actor(1, "m", 1, 0.0, 0.0, 0.0).unwrap();
823        sim.spawn_scripted_actor(2, "m", 1, 0.0, 0.0, 0.0).unwrap();
824
825        sim.clear();
826
827        assert!(!sim.is_alive(1));
828        assert!(!sim.is_alive(2));
829        assert_eq!(sim.alive_players_flat().len(), 0);
830    }
831
832    // карта 2×2 с одной статичной стеной: хватает, чтобы в дампе появились
833    // тело, коллайдер и нав-граф
834    fn tiny_map_json() -> &'static str {
835        r#"{
836            "setId": "tiny",
837            "scale": 1,
838            "step": 10,
839            "map": [[1, 0], [0, 0]],
840            "physicsStatic": [1],
841            "respawns": { "team1": [[5, 5, 0]] }
842        }"#
843    }
844
845    #[test]
846    fn debug_json_dumps_world_map_and_rng() {
847        let mut sim = make_sim();
848
849        sim.load_map(tiny_map_json()).unwrap();
850
851        let dump: serde_json::Value = serde_json::from_str(&sim.debug_json()).unwrap();
852
853        let bodies = dump["bodies"].as_array().unwrap();
854        let colliders = dump["colliders"].as_array().unwrap();
855
856        assert_eq!(bodies.len(), 1);
857        assert_eq!(colliders.len(), 1);
858        assert_eq!(bodies[0]["bodyType"], "Fixed");
859        assert_eq!(bodies[0]["translation"], serde_json::json!([5.0, 5.0]));
860        assert_eq!(colliders[0]["shape"], "cuboid");
861        assert_eq!(colliders[0]["halfExtents"], serde_json::json!([5.0, 5.0]));
862        assert_eq!(colliders[0]["isSensor"], false);
863        assert_eq!(colliders[0]["parent"], bodies[0]["handle"]);
864
865        assert_eq!(dump["map"]["setId"], "tiny");
866        assert_eq!(dump["map"]["staticBodies"], 1);
867        assert_eq!(dump["map"]["grid"]["rows"], 2);
868        assert_eq!(dump["map"]["respawns"]["team1"], 1);
869
870        assert!(dump["nav"]["nodes"].as_u64().unwrap() > 0);
871        assert_eq!(dump["spatial"]["cells"], 0);
872        assert_eq!(dump["rng"]["state"], "42");
873        assert_eq!(dump["step"]["accumulator"], 0.0);
874    }
875
876    #[test]
877    fn debug_json_is_null_for_map_and_nav_without_map() {
878        let dump: serde_json::Value = serde_json::from_str(&make_sim().debug_json()).unwrap();
879
880        assert!(dump["map"].is_null());
881        assert!(dump["nav"].is_null());
882        assert_eq!(dump["bodies"].as_array().unwrap().len(), 0);
883    }
884
885    #[test]
886    fn debug_json_is_stable_between_identical_runs() {
887        let mut a = make_sim();
888        let mut b = make_sim();
889
890        for sim in [&mut a, &mut b] {
891            sim.load_map(tiny_map_json()).unwrap();
892            sim.spawn_actor(1, "m", 1, 3.0, 4.0, 0.0).unwrap();
893            sim.step(1.0 / 120.0);
894        }
895
896        assert_eq!(a.debug_json(), b.debug_json());
897    }
898
899    #[test]
900    fn serialize_deserialize_round_trips_actors() {
901        let mut sim = make_sim();
902
903        sim.spawn_actor(1, "m", 3, 11.0, 22.0, 0.0).unwrap();
904
905        let dump = sim.sim.serialize();
906        let mut restored = make_sim();
907
908        restored.sim.deserialize(dump).unwrap();
909
910        assert_eq!(restored.actor_position(1), Some([11.0, 22.0]));
911    }
912}