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    pub fn last_input_seq(&self, game_id: u32) -> u32 {
159        self.sim.last_input_seq(game_id)
160    }
161
162    /// События за тик (kill/health/ammo/weapon/shake) одной JSON-строкой;
163    /// буфер очищается.
164    pub fn take_events_json(&mut self) -> String {
165        let events: Vec<CoreEvent> = self.events.drain(..).collect();
166
167        serde_json::to_string(&events).unwrap_or_else(|_| "[]".to_string())
168    }
169
170    // ***** запросы состояния ***** //
171
172    pub fn is_alive(&self, game_id: u32) -> bool {
173        self.sim.is_alive(game_id)
174    }
175
176    pub fn actor_position(&self, game_id: u32) -> Option<[f32; 2]> {
177        self.sim.actor_position(&self.world, game_id)
178    }
179
180    pub fn prediction_state(&self, game_id: u32) -> Option<([f32; PLAYER_STATE_LEN], bool)> {
181        self.sim.prediction_state(&self.world, game_id)
182    }
183
184    pub fn alive_players_flat(&self) -> Vec<f32> {
185        self.sim.alive_players_flat(&self.world)
186    }
187
188    /// Полный снапшот игроков (Game.getPlayersData), не дренируя
189    /// накопители событий — для первого кадра (FIRST_SHOT_DATA).
190    pub fn players_json(&self) -> String {
191        self.sim.players_json()
192    }
193
194    // ***** игровой тик ***** //
195
196    /// Обновляет физику фиксированными шагами (Game.updateData),
197    /// затем ИИ скриптовых участников (VIMP._onShotTick).
198    pub fn step(&mut self, dt: f32) {
199        self.accumulator = (self.accumulator + dt).min(MAX_ACCUMULATED_TIME);
200
201        while self.accumulator >= self.time_step {
202            self.step_fixed();
203            self.accumulator -= self.time_step;
204        }
205
206        self.sim.refresh_cached(&self.world);
207
208        let mut ctx = SimCtx {
209            world: &mut self.world,
210            cfg: &self.cfg,
211            map: &self.map,
212            nav: &self.nav,
213            spatial: &mut self.spatial,
214            rng: &mut self.rng,
215            events: &mut self.events,
216            bodies_to_destroy: &mut self.bodies_to_destroy,
217        };
218
219        self.sim.on_ai_tick(&mut ctx, dt);
220    }
221
222    /// Один фиксированный шаг физики.
223    fn step_fixed(&mut self) {
224        let mut ctx = SimCtx {
225            world: &mut self.world,
226            cfg: &self.cfg,
227            map: &self.map,
228            nav: &self.nav,
229            spatial: &mut self.spatial,
230            rng: &mut self.rng,
231            events: &mut self.events,
232            bodies_to_destroy: &mut self.bodies_to_destroy,
233        };
234
235        self.sim.on_fixed_step(&mut ctx, self.time_step);
236
237        // шаг физического мира со сбором начавшихся контактов
238        let (collision_send, collision_recv) = channel();
239        let (force_send, _force_recv) = channel();
240        let collector = ChannelEventCollector::new(collision_send, force_send);
241
242        self.world.step_with_events(&(), &collector);
243
244        let mut contacts = Vec::new();
245
246        while let Ok(event) = collision_recv.try_recv() {
247            if let CollisionEvent::Started(h1, h2, _) = event {
248                contacts.push((h1, h2));
249            }
250        }
251
252        let mut ctx = SimCtx {
253            world: &mut self.world,
254            cfg: &self.cfg,
255            map: &self.map,
256            nav: &self.nav,
257            spatial: &mut self.spatial,
258            rng: &mut self.rng,
259            events: &mut self.events,
260            bodies_to_destroy: &mut self.bodies_to_destroy,
261        };
262
263        self.sim.on_contacts(&mut ctx, &contacts);
264        self.destroy_queued_bodies();
265    }
266
267    /// Уничтожает тела из очереди (Game._destroyQueuedBodies).
268    fn destroy_queued_bodies(&mut self) {
269        if self.bodies_to_destroy.is_empty() {
270            return;
271        }
272
273        let handles: Vec<RigidBodyHandle> = self.bodies_to_destroy.drain(..).collect();
274
275        for handle in handles {
276            self.sim.on_before_destroy(&self.world, handle);
277            self.world.remove_body(handle);
278        }
279    }
280
281    // ***** снапшот ***** //
282
283    /// Собирает блоки тела снапшота, дренируя накопители событий.
284    /// Вызывается на каждый отправляемый кадр (throttle — забота JS).
285    pub fn build_snapshot_blocks(&mut self) -> Vec<(String, Block)> {
286        let (mut blocks, has_events) = self.sim.build_snapshot_blocks();
287
288        // динамические элементы карты — каждый отправляемый кадр
289        if let Some(map) = &self.map {
290            blocks.push((
291                map.set_id.clone(),
292                Block::IndexedNoNull8(map.dynamic_map_data(&self.world)),
293            ));
294        }
295
296        self.last_body_has_events = has_events;
297
298        blocks
299    }
300
301    /// Содержал ли последний собранный body событийные блоки — для
302    /// классификации канала WebRTC (meta reliable / state unreliable).
303    pub fn body_has_events(&self) -> bool {
304        self.last_body_has_events
305    }
306
307    // ***** очистка ***** //
308
309    /// Удаляет всех игроков и снаряды, возвращает имена для очистки
310    /// полотна клиентов (Game.removePlayersAndShots).
311    pub fn remove_players_and_shots(&mut self) -> Vec<String> {
312        self.sim.remove_players_and_shots(&mut self.world)
313    }
314
315    /// Полная очистка игрового мира (Game.clear + сброс ботов).
316    pub fn clear(&mut self) {
317        // карта удаляет свои тела первой: повторное удаление уже
318        // удалённого тела в Rapier недопустимо
319        if let Some(mut map) = self.map.take() {
320            map.destroy(&mut self.world);
321        }
322
323        let handles: Vec<RigidBodyHandle> = self.world.rigid_bodies().map(|(handle, _)| handle).collect();
324
325        for handle in handles {
326            self.world.remove_body(handle);
327        }
328
329        self.sim.clear();
330        self.nav = None;
331        self.spatial.clear();
332        self.bodies_to_destroy.clear();
333
334        self.accumulator = 0.0;
335    }
336
337    // ***** handoff (Spike B / Этап 5.2) ***** //
338
339    /// Сериализует состояние симуляции для эстафетной передачи между
340    /// инстансами WASM. Накопители снапшота должны быть дренированы
341    /// (pack_body) перед вызовом.
342    pub fn serialize_state(&self) -> Result<Vec<u8>, String> {
343        let dump = EngineDump {
344            world: WorldDump {
345                gravity: [self.world.gravity.x, self.world.gravity.y],
346                integration_parameters: self.world.integration_parameters,
347                islands: self.world.islands.clone(),
348                broad_phase: self.world.broad_phase.clone(),
349                narrow_phase: self.world.narrow_phase.clone(),
350                bodies: self.world.bodies.clone(),
351                colliders: self.world.colliders.clone(),
352                impulse_joints: self.world.impulse_joints.clone(),
353                multibody_joints: self.world.multibody_joints.clone(),
354            },
355            map: &self.map,
356            rng: &self.rng,
357            accumulator: self.accumulator,
358            sim: self.sim.serialize(),
359        };
360
361        serde_json::to_vec(&dump).map_err(|e| e.to_string())
362    }
363
364    /// Восстанавливает состояние из дампа. Конфиг ядра должен совпадать
365    /// с конфигом инстанса, создавшего дамп.
366    pub fn deserialize_state(&mut self, data: &[u8]) -> Result<(), String> {
367        let dump: EngineDumpOwned = serde_json::from_slice(data).map_err(|e| e.to_string())?;
368
369        let mut world = PhysicsWorld::new();
370
371        world.gravity = Vector::new(dump.world.gravity[0], dump.world.gravity[1]);
372        world.integration_parameters = dump.world.integration_parameters;
373        world.islands = dump.world.islands;
374        world.broad_phase = dump.world.broad_phase;
375        world.narrow_phase = dump.world.narrow_phase;
376        world.bodies = dump.world.bodies;
377        world.colliders = dump.world.colliders;
378        world.impulse_joints = dump.world.impulse_joints;
379        world.multibody_joints = dump.world.multibody_joints;
380
381        self.world = world;
382        self.map = dump.map;
383        self.rng = dump.rng;
384        self.accumulator = dump.accumulator;
385        self.sim.deserialize(dump.sim)?;
386
387        self.bodies_to_destroy.clear();
388        self.events.clear();
389
390        // производные структуры не входят в дамп: нав-граф регенерируется
391        // из карты, пространственная сетка — из живых участников
392        self.nav = self
393            .map
394            .as_ref()
395            .map(|map| NavigationSystem::generate(&map.grid, &map.physics_static, map.step));
396
397        self.sim.rebuild_spatial_grid(&self.world, &mut self.spatial);
398        self.sim.refresh_cached(&self.world);
399
400        Ok(())
401    }
402}
403
404#[derive(Serialize)]
405struct WorldDump {
406    gravity: [f32; 2],
407    integration_parameters: IntegrationParameters,
408    islands: IslandManager,
409    broad_phase: BroadPhaseBvh,
410    narrow_phase: NarrowPhase,
411    bodies: RigidBodySet,
412    colliders: ColliderSet,
413    impulse_joints: ImpulseJointSet,
414    multibody_joints: MultibodyJointSet,
415}
416
417#[derive(Deserialize)]
418struct WorldDumpOwned {
419    gravity: [f32; 2],
420    integration_parameters: IntegrationParameters,
421    islands: IslandManager,
422    broad_phase: BroadPhaseBvh,
423    narrow_phase: NarrowPhase,
424    bodies: RigidBodySet,
425    colliders: ColliderSet,
426    impulse_joints: ImpulseJointSet,
427    multibody_joints: MultibodyJointSet,
428}
429
430#[derive(Serialize)]
431struct EngineDump<'a> {
432    world: WorldDump,
433    map: &'a Option<GameMap>,
434    rng: &'a Rng,
435    accumulator: f32,
436    sim: serde_json::Value,
437}
438
439#[derive(Deserialize)]
440struct EngineDumpOwned {
441    world: WorldDumpOwned,
442    map: Option<GameMap>,
443    rng: Rng,
444    accumulator: f32,
445    sim: serde_json::Value,
446}
447
448/// Фикстурная вторая игра (Этап 7 плана отделения движка, PLAN.md):
449/// доказывает форму `GameDef`/`GameSim<G>` без физических тел (актёры —
450/// плоские данные, не rapier-тела — `spawn_actor` и т.п. игнорируют `world`,
451/// трейт этого не требует). Тот же принцип, что у клиентской фикстуры
452/// `TestClient` (`crate::client::game`): форма ABI важнее физики. Используется
453/// только тестами этого модуля, не влияет на `TanksGame`.
454#[cfg(test)]
455mod fixture {
456    use super::*;
457    use crate::config::FieldValue;
458    use serde::Deserialize;
459    use std::collections::{BTreeMap, BTreeSet};
460
461    #[derive(Deserialize)]
462    pub struct TestConfig {}
463
464    pub struct TestGame;
465
466    impl GameDef for TestGame {
467        type Config = TestConfig;
468        type Sim = TestSim;
469    }
470
471    #[derive(Clone, Copy)]
472    struct TestActor {
473        x: f32,
474        y: f32,
475        vx: f32,
476        vy: f32,
477        team: u8,
478        alive: bool,
479    }
480
481    pub struct TestSim {
482        actors: BTreeMap<u32, TestActor>,
483        scripted: BTreeSet<u32>,
484    }
485
486    impl GameSim<TestGame> for TestSim {
487        fn new(_cfg: &TestConfig, _engine_cfg: &EngineConfig) -> Self {
488            Self {
489                actors: BTreeMap::new(),
490                scripted: BTreeSet::new(),
491            }
492        }
493
494        fn spawn_actor(
495            &mut self,
496            _world: &mut PhysicsWorld,
497            _events: &mut Vec<CoreEvent>,
498            game_id: u32,
499            _model_name: &str,
500            team_id: u8,
501            x: f32,
502            y: f32,
503            _angle_deg: f32,
504        ) -> Result<(), String> {
505            self.actors.insert(
506                game_id,
507                TestActor { x, y, vx: 0.0, vy: 0.0, team: team_id, alive: true },
508            );
509
510            Ok(())
511        }
512
513        fn remove_actor(&mut self, _world: &mut PhysicsWorld, game_id: u32) {
514            self.actors.remove(&game_id);
515            self.scripted.remove(&game_id);
516        }
517
518        fn reset_actor(&mut self, _world: &mut PhysicsWorld, game_id: u32, team_id: u8, x: f32, y: f32, _angle_deg: f32) {
519            if let Some(actor) = self.actors.get_mut(&game_id) {
520                actor.x = x;
521                actor.y = y;
522                actor.team = team_id;
523                actor.alive = true;
524            }
525        }
526
527        fn reset_all_vitals(&mut self, _events: &mut Vec<CoreEvent>) {
528            for actor in self.actors.values_mut() {
529                actor.alive = true;
530            }
531        }
532
533        fn spawn_scripted_actor(
534            &mut self,
535            world: &mut PhysicsWorld,
536            _rng: &mut Rng,
537            events: &mut Vec<CoreEvent>,
538            game_id: u32,
539            model_name: &str,
540            team_id: u8,
541            x: f32,
542            y: f32,
543            angle_deg: f32,
544        ) -> Result<(), String> {
545            self.spawn_actor(world, events, game_id, model_name, team_id, x, y, angle_deg)?;
546            self.scripted.insert(game_id);
547
548            Ok(())
549        }
550
551        fn remove_scripted_actor(&mut self, world: &mut PhysicsWorld, game_id: u32) {
552            self.remove_actor(world, game_id);
553        }
554
555        fn apply_input(&mut self, game_id: u32, _seq: u32, action: &str, key_name: &str) {
556            let Some(actor) = self.actors.get_mut(&game_id) else {
557                return;
558            };
559
560            let magnitude = if action == "down" { 40.0 } else { 0.0 };
561
562            match key_name {
563                "forward" => actor.vy = -magnitude,
564                "back" => actor.vy = magnitude,
565                _ => {}
566            }
567        }
568
569        fn last_input_seq(&self, _game_id: u32) -> u32 {
570            0
571        }
572
573        fn is_alive(&self, game_id: u32) -> bool {
574            self.actors.get(&game_id).is_some_and(|a| a.alive)
575        }
576
577        fn actor_position(&self, _world: &PhysicsWorld, game_id: u32) -> Option<[f32; 2]> {
578            self.actors.get(&game_id).map(|a| [a.x, a.y])
579        }
580
581        fn prediction_state(&self, _world: &PhysicsWorld, game_id: u32) -> Option<([f32; PLAYER_STATE_LEN], bool)> {
582            self.actors
583                .get(&game_id)
584                .map(|a| ([a.x, a.y, 0.0, a.vx, a.vy, 0.0, 0.0, 0.0], false))
585        }
586
587        fn alive_players_flat(&self, _world: &PhysicsWorld) -> Vec<f32> {
588            self.actors
589                .iter()
590                .filter(|(_, a)| a.alive)
591                .flat_map(|(id, a)| [*id as f32, a.x, a.y])
592                .collect()
593        }
594
595        fn players_json(&self) -> String {
596            let rows: Vec<serde_json::Value> = self
597                .actors
598                .iter()
599                .map(|(id, a)| serde_json::json!({ "id": id, "x": a.x, "y": a.y, "team": a.team }))
600                .collect();
601
602            serde_json::to_string(&rows).unwrap()
603        }
604
605        fn on_fixed_step(&mut self, _ctx: &mut SimCtx, dt: f32) {
606            for actor in self.actors.values_mut() {
607                actor.x += actor.vx * dt;
608                actor.y += actor.vy * dt;
609            }
610        }
611
612        fn on_contacts(&mut self, _ctx: &mut SimCtx, _pairs: &[(ColliderHandle, ColliderHandle)]) {}
613
614        fn on_before_destroy(&mut self, _world: &PhysicsWorld, _handle: RigidBodyHandle) {}
615
616        fn on_ai_tick(&mut self, _ctx: &mut SimCtx, _dt: f32) {
617            // тривиальный ИИ фикстуры: скриптовый актёр всегда едет вправо
618            for &id in &self.scripted {
619                if let Some(actor) = self.actors.get_mut(&id) {
620                    actor.vx = 1.0;
621                }
622            }
623        }
624
625        fn refresh_cached(&mut self, _world: &PhysicsWorld) {}
626
627        fn build_snapshot_blocks(&mut self) -> (Vec<(String, Block)>, bool) {
628            let rows: Vec<(u8, Option<Vec<FieldValue>>)> = self
629                .actors
630                .iter()
631                .map(|(id, a)| (*id as u8, Some(vec![FieldValue::F32(a.x), FieldValue::F32(a.y)])))
632                .collect();
633
634            (vec![("actor".to_string(), Block::Indexed8(rows))], false)
635        }
636
637        fn remove_players_and_shots(&mut self, _world: &mut PhysicsWorld) -> Vec<String> {
638            let names: Vec<String> = self.actors.keys().map(|id| id.to_string()).collect();
639
640            self.actors.clear();
641            self.scripted.clear();
642
643            names
644        }
645
646        fn clear(&mut self) {
647            self.actors.clear();
648            self.scripted.clear();
649        }
650
651        fn serialize(&self) -> serde_json::Value {
652            let rows: Vec<serde_json::Value> = self
653                .actors
654                .iter()
655                .map(|(id, a)| serde_json::json!({ "id": id, "x": a.x, "y": a.y, "team": a.team }))
656                .collect();
657
658            serde_json::json!({ "actors": rows })
659        }
660
661        fn deserialize(&mut self, value: serde_json::Value) -> Result<(), String> {
662            let rows = value["actors"].as_array().ok_or("missing actors")?;
663
664            self.actors.clear();
665
666            for row in rows {
667                let id = row["id"].as_u64().ok_or("bad id")? as u32;
668                let x = row["x"].as_f64().ok_or("bad x")? as f32;
669                let y = row["y"].as_f64().ok_or("bad y")? as f32;
670                let team = row["team"].as_u64().ok_or("bad team")? as u8;
671
672                self.actors.insert(id, TestActor { x, y, vx: 0.0, vy: 0.0, team, alive: true });
673            }
674
675            Ok(())
676        }
677
678        fn rebuild_spatial_grid(&self, _world: &PhysicsWorld, _spatial: &mut SpatialGrid) {}
679    }
680}
681
682#[cfg(test)]
683mod tests {
684    use super::fixture::{TestConfig, TestGame};
685    use super::*;
686    use crate::snapshot::SnapshotPacker;
687
688    fn engine_config() -> EngineConfig {
689        serde_json::from_value(serde_json::json!({
690            "timeStep": 1.0 / 120.0,
691            "snapshot": {
692                "version": 3,
693                "port": 5,
694                "keys": {
695                    "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
696                        { "name": "x", "ty": "f32", "interp": "lerp" },
697                        { "name": "y", "ty": "f32", "interp": "lerp" }
698                    ] }
699                }
700            },
701            "seed": 42
702        }))
703        .unwrap()
704    }
705
706    fn make_sim() -> EngineSim<TestGame> {
707        EngineSim::new(engine_config(), &TestConfig {})
708    }
709
710    #[test]
711    fn spawn_actor_and_fixed_step_moves_position() {
712        let mut sim = make_sim();
713
714        sim.spawn_actor(1, "m", 1, 0.0, 0.0, 0.0).unwrap();
715        sim.apply_input(1, 1, "down", "forward");
716
717        for _ in 0..60 {
718            sim.step(1.0 / 120.0);
719        }
720
721        let pos = sim.actor_position(1).unwrap();
722
723        assert!(pos[1] < 0.0); // forward = -vy
724    }
725
726    #[test]
727    fn remove_actor_clears_state() {
728        let mut sim = make_sim();
729
730        sim.spawn_actor(1, "m", 1, 5.0, 5.0, 0.0).unwrap();
731        assert!(sim.is_alive(1));
732
733        sim.remove_actor(1);
734        assert!(!sim.is_alive(1));
735        assert!(sim.actor_position(1).is_none());
736    }
737
738    #[test]
739    fn scripted_actor_runs_ai_tick() {
740        let mut sim = make_sim();
741
742        sim.spawn_scripted_actor(9, "m", 1, 0.0, 0.0, 0.0).unwrap();
743
744        // on_ai_tick — после фикс-шага в том же step(); vx применяется к
745        // позиции только следующим вызовом
746        sim.step(1.0 / 120.0);
747        sim.step(1.0 / 120.0);
748
749        let after = sim.actor_position(9).unwrap();
750
751        assert!(after[0] > 0.0); // фикстурный ИИ едет вправо (vx = 1.0)
752    }
753
754    #[test]
755    fn build_snapshot_blocks_packs_through_generic_schema() {
756        let mut sim = make_sim();
757
758        sim.spawn_actor(1, "m", 1, 3.0, 4.0, 0.0).unwrap();
759        sim.spawn_actor(2, "m", 1, 7.0, 8.0, 0.0).unwrap();
760
761        let blocks = sim.build_snapshot_blocks();
762        let mut packer = SnapshotPacker::new(engine_config().snapshot);
763
764        assert!(packer.pack_body(&blocks).is_ok());
765    }
766
767    #[test]
768    fn clear_removes_all_actors() {
769        let mut sim = make_sim();
770
771        sim.spawn_actor(1, "m", 1, 0.0, 0.0, 0.0).unwrap();
772        sim.spawn_scripted_actor(2, "m", 1, 0.0, 0.0, 0.0).unwrap();
773
774        sim.clear();
775
776        assert!(!sim.is_alive(1));
777        assert!(!sim.is_alive(2));
778        assert_eq!(sim.alive_players_flat().len(), 0);
779    }
780
781    #[test]
782    fn serialize_deserialize_round_trips_actors() {
783        let mut sim = make_sim();
784
785        sim.spawn_actor(1, "m", 3, 11.0, 22.0, 0.0).unwrap();
786
787        let dump = sim.sim.serialize();
788        let mut restored = make_sim();
789
790        restored.sim.deserialize(dump).unwrap();
791
792        assert_eq!(restored.actor_position(1), Some([11.0, 22.0]));
793    }
794}