Skip to main content

aetheris_client_wasm/
world_state.rs

1//! Client-side world state and interpolation buffer.
2//!
3//! This module implements the `WorldState` trait for the WASM client,
4//! providing the foundation for the two-tick interpolation buffer.
5
6use crate::shared_world::SabSlot;
7use aetheris_protocol::error::WorldError;
8use aetheris_protocol::events::{ComponentUpdate, ReplicationEvent};
9use aetheris_protocol::traits::WorldState;
10use aetheris_protocol::types::{
11    ClientId, ComponentKind, LocalId, NetworkId, ShipClass, ShipStats, Transform,
12};
13use std::collections::{BTreeMap, VecDeque};
14
15#[derive(Clone, Copy, Debug)]
16pub struct InputRecord {
17    pub tick: u64,
18    pub move_x: f32,
19    pub move_y: f32,
20    pub actions_mask: u8,
21}
22
23#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, Default)]
24pub struct Velocity {
25    pub dx: f32,
26    pub dy: f32,
27    pub dz: f32,
28}
29
30/// A simplified client-side world that tracks entity states using `SabSlot`.
31#[derive(Debug)]
32pub struct ClientWorld {
33    /// Map of `NetworkId` to the last known authoritative state.
34    pub entities: BTreeMap<NetworkId, SabSlot>,
35    /// The network ID of the player's own ship, if known.
36    pub player_network_id: Option<NetworkId>,
37    /// The latest tick received from the server.
38    pub latest_tick: u64,
39    /// Manifest of system-wide metadata.
40    pub system_manifest: BTreeMap<String, String>,
41    /// Optional shared world to push room bounds into directly (stored as usize for Send/Sync).
42    pub shared_world_ref: Option<usize>,
43    /// History of inputs applied for Client-Side Prediction reconciliation.
44    pub input_history: VecDeque<InputRecord>,
45    /// The latest server tick that has been reconciled.
46    pub last_reconciled_tick: u64,
47    /// When `true`, the client simulates input locally and replays unacknowledged inputs
48    /// on top of server snapshots (client-side prediction + reconciliation).
49    /// When `false`, the client is pure server-authority: position is only updated from
50    /// server transforms and no local simulation is performed for the local player.
51    pub prediction_enabled: bool,
52    /// Authoritative world boundaries received from the server.
53    /// Used for toroidal wrapping in Sandbox mode.
54    pub room_bounds: Option<aetheris_protocol::types::RoomBounds>,
55}
56
57impl Default for ClientWorld {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63impl ClientWorld {
64    /// Creates a new, empty `ClientWorld`.
65    ///
66    /// `prediction_enabled` controls whether client-side prediction is active.
67    /// Pass `false` for pure server-authority mode (recommended during debugging).
68    /// Pass `true` to enable local simulation + reconciliation for responsive feel.
69    #[must_use]
70    pub fn new() -> Self {
71        Self::with_prediction(false)
72    }
73
74    /// Creates a `ClientWorld` with explicit prediction setting.
75    #[must_use]
76    pub fn with_prediction(prediction_enabled: bool) -> Self {
77        Self {
78            entities: BTreeMap::new(),
79            player_network_id: None,
80            latest_tick: 0,
81            system_manifest: BTreeMap::new(),
82            shared_world_ref: None,
83            input_history: VecDeque::with_capacity(120), // 2 seconds at 60Hz
84            last_reconciled_tick: 0,
85            prediction_enabled,
86            room_bounds: if prediction_enabled {
87                Some(aetheris_protocol::types::RoomBounds {
88                    min_x: -250.0,
89                    min_y: -250.0,
90                    max_x: 250.0,
91                    max_y: 250.0,
92                })
93            } else {
94                None
95            },
96        }
97    }
98}
99
100impl WorldState for ClientWorld {
101    fn get_local_id(&self, network_id: NetworkId) -> Option<LocalId> {
102        Some(LocalId(network_id.0))
103    }
104
105    fn get_network_id(&self, local_id: LocalId) -> Option<NetworkId> {
106        Some(NetworkId(local_id.0))
107    }
108
109    fn extract_deltas(&mut self) -> Vec<ReplicationEvent> {
110        Vec::new()
111    }
112
113    fn apply_updates(&mut self, updates: &[(ClientId, ComponentUpdate)]) {
114        if !updates.is_empty() {
115            tracing::debug!(
116                count = updates.len(),
117                player_network_id = ?self.player_network_id,
118                total_entities = self.entities.len(),
119                "[apply_updates] Processing updates batch"
120            );
121        }
122        for (_, update) in updates {
123            if update.tick > self.latest_tick {
124                self.latest_tick = update.tick;
125            }
126
127            let is_new = !self.entities.contains_key(&update.network_id);
128
129            // Ensure entity exists for component updates
130            let entry = self.entities.entry(update.network_id).or_insert_with(|| {
131                tracing::trace!(
132                    network_id = update.network_id.0,
133                    kind = update.component_kind.0,
134                    player_network_id = ?self.player_network_id,
135                    "[apply_updates] NEW entity from server"
136                );
137                SabSlot {
138                    network_id: update.network_id.0,
139                    x: 0.0,
140                    y: 0.0,
141                    z: 0.0,
142                    rotation: 0.0,
143                    dx: 0.0,
144                    dy: 0.0,
145                    dz: 0.0,
146                    hp: 100,
147                    shield: 0,
148                    entity_type: 0,
149                    flags: 1,
150                    cargo_ore: 0,
151                    mining_target_id: 0,
152                    mining_active: 0,
153                }
154            });
155
156            // Re-apply possession flag if this is the player ship.
157            // This handles cases where Possession arrived before the entity was spawned.
158            let is_player = Some(update.network_id) == self.player_network_id;
159            if is_player {
160                tracing::trace!(
161                    network_id = update.network_id.0,
162                    is_new,
163                    "[apply_updates] Setting 0x04 (LocalPlayer) flag on entity"
164                );
165                entry.flags |= 0x04;
166            } else if is_new {
167                tracing::trace!(
168                    network_id = update.network_id.0,
169                    player_network_id = ?self.player_network_id,
170                    flags = entry.flags,
171                    "[apply_updates] New NON-player entity - no possession flag"
172                );
173            }
174
175            self.apply_component_update(update);
176        }
177    }
178
179    fn simulate(&mut self) {
180        const DRAG: f32 = 1.0;
181        const DT: f32 = 1.0 / 60.0;
182        let drag_factor = 1.0 / (1.0 + DRAG * DT);
183
184        for slot in self.entities.values_mut() {
185            // Semi-implicit Euler integration
186            slot.dx *= drag_factor;
187            slot.dy *= drag_factor;
188            slot.x += slot.dx * DT;
189            slot.y += slot.dy * DT;
190
191            // Toroidal wrapping (Sandbox/Prediction)
192            if let Some(bounds) = self.room_bounds {
193                let width = bounds.max_x - bounds.min_x;
194                let height = bounds.max_y - bounds.min_y;
195                if width > 0.0 {
196                    slot.x = ((slot.x - bounds.min_x).rem_euclid(width)) + bounds.min_x;
197                }
198                if height > 0.0 {
199                    slot.y = ((slot.y - bounds.min_y).rem_euclid(height)) + bounds.min_y;
200                }
201            }
202        }
203    }
204
205    fn spawn_networked(&mut self) -> NetworkId {
206        NetworkId(0)
207    }
208
209    fn spawn_networked_for(&mut self, _client_id: ClientId) -> NetworkId {
210        self.spawn_networked()
211    }
212
213    fn despawn_networked(&mut self, network_id: NetworkId) -> Result<(), WorldError> {
214        self.entities
215            .remove(&network_id)
216            .map(|_| ())
217            .ok_or(WorldError::EntityNotFound(network_id))
218    }
219
220    fn stress_test(&mut self, _count: u16, _rotate: bool) {}
221
222    fn spawn_kind(&mut self, _kind: u16, _x: f32, _y: f32, _rot: f32) -> NetworkId {
223        NetworkId(1)
224    }
225
226    fn clear_world(&mut self) {
227        self.entities.clear();
228    }
229
230    fn state_hash(&self) -> u64 {
231        use std::hash::{Hash, Hasher};
232        use twox_hash::XxHash64;
233
234        // Use a stable, seeded hasher for cross-platform determinism
235        let mut hasher = XxHash64::with_seed(0);
236        self.latest_tick.hash(&mut hasher);
237
238        // BTreeMap iteration is already deterministic (sorted by NetworkId)
239        for (nid, slot) in &self.entities {
240            nid.hash(&mut hasher);
241
242            // SabSlot fields must be hashed individually as the struct is FFI-oriented
243            slot.x.to_bits().hash(&mut hasher);
244            slot.y.to_bits().hash(&mut hasher);
245            slot.z.to_bits().hash(&mut hasher);
246            slot.rotation.to_bits().hash(&mut hasher);
247
248            // Inclusion of physics and mining state for high-fidelity determinism (VS-07 ยง4.2)
249            slot.dx.to_bits().hash(&mut hasher);
250            slot.dy.to_bits().hash(&mut hasher);
251            slot.dz.to_bits().hash(&mut hasher);
252
253            slot.hp.hash(&mut hasher);
254            slot.shield.hash(&mut hasher);
255            slot.entity_type.hash(&mut hasher);
256            slot.flags.hash(&mut hasher);
257
258            slot.mining_active.hash(&mut hasher);
259            slot.cargo_ore.hash(&mut hasher);
260            slot.mining_target_id.hash(&mut hasher);
261        }
262
263        hasher.finish()
264    }
265}
266
267impl ClientWorld {
268    /// Handles discrete game events from the server.
269    pub fn handle_game_event(&mut self, event: &aetheris_protocol::events::GameEvent) {
270        if let aetheris_protocol::events::GameEvent::Possession { network_id } = event {
271            let prev = self.player_network_id;
272            tracing::info!(
273                ?network_id,
274                ?prev,
275                entity_exists = self.entities.contains_key(network_id),
276                total_entities = self.entities.len(),
277                "[handle_game_event] POSSESSION received โ€” updating player_network_id"
278            );
279            // Clear the local-player flag from the previous entity if it differs.
280            if let Some(slot) = prev
281                .filter(|&id| id != *network_id)
282                .and_then(|id| self.entities.get_mut(&id))
283            {
284                slot.flags &= !0x04;
285                tracing::info!(
286                    network_id = ?prev,
287                    flags = slot.flags,
288                    "[handle_game_event] 0x04 flag cleared from previous entity"
289                );
290            }
291            self.player_network_id = Some(*network_id);
292            if let Some(slot) = self.entities.get_mut(network_id) {
293                slot.flags |= 0x04;
294                tracing::info!(
295                    ?network_id,
296                    flags = slot.flags,
297                    "[handle_game_event] 0x04 flag applied to entity"
298                );
299            } else {
300                tracing::warn!(
301                    ?network_id,
302                    "[handle_game_event] Possession entity not yet in world - will apply when it arrives"
303                );
304            }
305        }
306    }
307
308    fn apply_component_update(&mut self, update: &ComponentUpdate) {
309        match update.component_kind {
310            ComponentKind(1) => self.handle_transform_update(update),
311            ComponentKind(2) => self.handle_velocity_update(update),
312            ComponentKind(5) => self.handle_ship_class_update(update),
313            ComponentKind(3) => self.handle_ship_stats_update(update),
314            aetheris_protocol::types::MINING_BEAM_KIND => self.handle_mining_beam_update(update),
315            aetheris_protocol::types::CARGO_HOLD_KIND => self.handle_cargo_hold_update(update),
316            aetheris_protocol::types::ASTEROID_KIND => {
317                if let Some(entry) = self.entities.get_mut(&update.network_id)
318                    && entry.entity_type == 0
319                {
320                    entry.entity_type = 5;
321                }
322            }
323            aetheris_protocol::types::ROOM_BOUNDS_KIND => self.handle_room_bounds_update(update),
324            kind => {
325                tracing::debug!(
326                    network_id = update.network_id.0,
327                    kind = kind.0,
328                    "Unhandled component kind"
329                );
330            }
331        }
332    }
333
334    fn handle_transform_update(&mut self, update: &ComponentUpdate) {
335        match rmp_serde::from_slice::<Transform>(&update.payload) {
336            Ok(transform) => {
337                if let Some(entry) = self.entities.get_mut(&update.network_id) {
338                    if (entry.flags & 0x04) != 0 && self.prediction_enabled {
339                        // M1020: Client-Side Prediction + Reconciliation
340                        let mut authoritative_x = transform.x;
341                        let mut authoritative_y = transform.y;
342
343                        // M1038: Wrap-aware snap
344                        if let Some(bounds) = self.room_bounds {
345                            let width = bounds.max_x - bounds.min_x;
346                            let height = bounds.max_y - bounds.min_y;
347                            if width > 0.0 {
348                                let dx = authoritative_x - entry.x;
349                                if dx.abs() > width * 0.5 {
350                                    if dx > 0.0 {
351                                        authoritative_x -= width;
352                                    } else {
353                                        authoritative_x += width;
354                                    }
355                                }
356                            }
357                            if height > 0.0 {
358                                let dy = authoritative_y - entry.y;
359                                if dy.abs() > height * 0.5 {
360                                    if dy > 0.0 {
361                                        authoritative_y -= height;
362                                    } else {
363                                        authoritative_y += height;
364                                    }
365                                }
366                            }
367                        }
368
369                        entry.x = authoritative_x;
370                        entry.y = authoritative_y;
371                        entry.z = transform.z;
372                        entry.rotation = transform.rotation;
373
374                        let server_tick = update.tick;
375                        for record in self.input_history.iter().filter(|r| r.tick > server_tick) {
376                            Self::simulate_slot_wrapped(
377                                entry,
378                                record.move_x,
379                                record.move_y,
380                                self.room_bounds,
381                            );
382                        }
383                        while self
384                            .input_history
385                            .front()
386                            .is_some_and(|r| r.tick <= server_tick)
387                        {
388                            self.input_history.pop_front();
389                        }
390                    } else {
391                        // Server-authority mode
392                        entry.x = transform.x;
393                        entry.y = transform.y;
394                        entry.z = transform.z;
395                        entry.rotation = transform.rotation;
396                        self.input_history.clear();
397                    }
398
399                    if transform.entity_type != 0 {
400                        entry.entity_type = transform.entity_type;
401                    }
402                }
403            }
404            Err(e) => {
405                tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode Transform");
406            }
407        }
408    }
409
410    fn handle_velocity_update(&mut self, update: &ComponentUpdate) {
411        match rmp_serde::from_slice::<Velocity>(&update.payload) {
412            Ok(velocity) => {
413                if let Some(entry) = self.entities.get_mut(&update.network_id) {
414                    entry.dx = velocity.dx;
415                    entry.dy = velocity.dy;
416                    entry.dz = velocity.dz;
417                }
418            }
419            Err(e) => {
420                tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode Velocity");
421            }
422        }
423    }
424
425    fn handle_ship_class_update(&mut self, update: &ComponentUpdate) {
426        match rmp_serde::from_slice::<ShipClass>(&update.payload) {
427            Ok(ship_class) => {
428                if let Some(entry) = self.entities.get_mut(&update.network_id) {
429                    entry.entity_type = match ship_class {
430                        ShipClass::Interceptor => 1,
431                        ShipClass::Dreadnought => 3,
432                        ShipClass::Hauler => 4,
433                    };
434                }
435            }
436            Err(e) => {
437                tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode ShipClass");
438            }
439        }
440    }
441
442    fn handle_ship_stats_update(&mut self, update: &ComponentUpdate) {
443        match rmp_serde::from_slice::<ShipStats>(&update.payload) {
444            Ok(stats) => {
445                if let Some(entry) = self.entities.get_mut(&update.network_id) {
446                    entry.hp = stats.hp;
447                    entry.shield = stats.shield;
448                }
449            }
450            Err(e) => {
451                tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode ShipStats");
452            }
453        }
454    }
455
456    fn handle_mining_beam_update(&mut self, update: &ComponentUpdate) {
457        use aetheris_protocol::types::MiningBeam;
458        match rmp_serde::from_slice::<MiningBeam>(&update.payload) {
459            Ok(beam) => {
460                if let Some(entry) = self.entities.get_mut(&update.network_id) {
461                    entry.mining_active = u8::from(beam.active);
462                    #[allow(clippy::cast_possible_truncation)]
463                    {
464                        entry.mining_target_id = beam.target.map_or(0, |id| id.0 as u16);
465                    }
466                }
467            }
468            Err(e) => {
469                tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode MiningBeam");
470            }
471        }
472    }
473
474    fn handle_cargo_hold_update(&mut self, update: &ComponentUpdate) {
475        use aetheris_protocol::types::CargoHold;
476        match rmp_serde::from_slice::<CargoHold>(&update.payload) {
477            Ok(cargo) => {
478                if let Some(entry) = self.entities.get_mut(&update.network_id) {
479                    entry.cargo_ore = cargo.ore_count;
480                    entry.flags |= 0x04;
481                }
482            }
483            Err(e) => {
484                tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode CargoHold");
485            }
486        }
487    }
488
489    fn handle_room_bounds_update(&mut self, update: &ComponentUpdate) {
490        use aetheris_protocol::types::RoomBounds;
491        if let (Ok(bounds), Some(ptr_val)) = (
492            rmp_serde::from_slice::<RoomBounds>(&update.payload),
493            self.shared_world_ref,
494        ) {
495            let mut sw = unsafe { crate::shared_world::SharedWorld::from_ptr(ptr_val as *mut u8) };
496            sw.set_room_bounds(bounds.min_x, bounds.min_y, bounds.max_x, bounds.max_y);
497            self.room_bounds = Some(bounds);
498        }
499    }
500}
501
502impl ClientWorld {
503    /// Internal simulation step used for prediction and reconciliation.
504    fn simulate_slot(slot: &mut SabSlot, move_x: f32, move_y: f32) {
505        const THRUST_FORCE: f32 = 8000.0;
506        const BASE_MASS: f32 = 100.0;
507        const MASS_PER_ORE: f32 = 2.0;
508        const DRAG: f32 = 2.0;
509        const MAX_SPEED: f32 = 75.0;
510        const DT: f32 = 1.0 / 60.0;
511
512        // 1.0. Calculate total mass (M1038 Cargo Penalty)
513        let total_mass = BASE_MASS + (f32::from(slot.cargo_ore) * MASS_PER_ORE);
514
515        // 1. Resolve move vector (Normalizing diagonal)
516        let mut mx = move_x;
517        let mut my = move_y;
518        let input_len_sq = mx * mx + my * my;
519        if input_len_sq > 1.0 {
520            let input_len = input_len_sq.sqrt();
521            mx /= input_len;
522            my /= input_len;
523        }
524
525        // 2. Apply thrust acceleration
526        let accel_x = mx * (THRUST_FORCE / total_mass);
527        let accel_y = my * (THRUST_FORCE / total_mass);
528
529        slot.dx += accel_x * DT;
530        slot.dy += accel_y * DT;
531
532        // 3. Apply Drag (Stable semi-implicit model)
533        let drag_factor = 1.0 / (1.0 + DRAG * DT);
534        slot.dx *= drag_factor;
535        slot.dy *= drag_factor;
536
537        // 4. Clamp speed
538        let speed_sq = slot.dx * slot.dx + slot.dy * slot.dy;
539        if speed_sq > MAX_SPEED * MAX_SPEED {
540            let speed = speed_sq.sqrt();
541            slot.dx = (slot.dx / speed) * MAX_SPEED;
542            slot.dy = (slot.dy / speed) * MAX_SPEED;
543        }
544
545        // 5. Update rotation (Smoothing)
546        if speed_sq > 0.01 {
547            const TURN_RATE: f32 = 5.0;
548            let target_rot = slot.dy.atan2(slot.dx);
549            let current_rot = slot.rotation;
550            let diff = (target_rot - current_rot + std::f32::consts::PI)
551                .rem_euclid(std::f32::consts::TAU)
552                - std::f32::consts::PI;
553
554            if diff.abs() > 0.001 {
555                slot.rotation += diff.clamp(-TURN_RATE * DT, TURN_RATE * DT);
556            } else {
557                slot.rotation = target_rot;
558            }
559        }
560
561        // 6. Integrate position
562        slot.x += slot.dx * DT;
563        slot.y += slot.dy * DT;
564        slot.z += slot.dz * DT;
565    }
566
567    /// Internal simulation step with toroidal wrapping.
568    ///
569    /// NOTE: If prediction is enabled, wrapping must be consistent with the server
570    /// to avoid reconciliation snaps. Prediction is currently disabled in the playground.
571    fn simulate_slot_wrapped(
572        slot: &mut SabSlot,
573        move_x: f32,
574        move_y: f32,
575        bounds: Option<aetheris_protocol::types::RoomBounds>,
576    ) {
577        Self::simulate_slot(slot, move_x, move_y);
578
579        if let Some(bounds) = bounds {
580            let width = bounds.max_x - bounds.min_x;
581            let height = bounds.max_y - bounds.min_y;
582            if width > 0.0 {
583                slot.x = ((slot.x - bounds.min_x).rem_euclid(width)) + bounds.min_x;
584            }
585            if height > 0.0 {
586                slot.y = ((slot.y - bounds.min_y).rem_euclid(height)) + bounds.min_y;
587            }
588        }
589    }
590
591    /// Applies playground input to the local player entity.
592    /// Used for Sandbox mode simulation.
593    pub fn playground_apply_input(&mut self, move_x: f32, move_y: f32, actions_mask: u32) -> bool {
594        if self.prediction_enabled {
595            // Record input for reconciliation history only when prediction is active.
596            self.input_history.push_back(InputRecord {
597                tick: self.latest_tick,
598                move_x,
599                move_y,
600                #[allow(clippy::cast_possible_truncation)]
601                actions_mask: actions_mask as u8,
602            });
603
604            // Limit history size to 5 seconds (300 ticks)
605            if self.input_history.len() > 300 {
606                self.input_history.pop_front();
607            }
608        }
609
610        let mut found = false;
611        // Locate local player entity (flag 0x04) in the world state
612        for slot in self.entities.values_mut() {
613            if (slot.flags & 0x04) != 0 {
614                found = true;
615                if self.prediction_enabled {
616                    // Prediction ON: simulate locally for immediate visual feedback.
617                    // The server reconciles this in apply_component_update().
618                    Self::simulate_slot_wrapped(slot, move_x, move_y, self.room_bounds);
619                }
620                // Prediction OFF: input is only sent to the server.
621                // Position is updated exclusively by server transforms when they arrive.
622            }
623        }
624        found
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631    use crate::shared_world::SabSlot;
632    use aetheris_protocol::types::NetworkId;
633    use bytemuck::Zeroable;
634
635    #[test]
636    fn test_playground_movement() {
637        let mut world = ClientWorld::with_prediction(true);
638
639        // Spawn a local player ship (flag 0x04)
640        world.entities.insert(
641            NetworkId(1),
642            SabSlot {
643                network_id: 1,
644                flags: 0x04, // Local player
645                ..SabSlot::zeroed()
646            },
647        );
648
649        // Apply thrust forward (move_x = 1.0)
650        world.playground_apply_input(1.0, 0.0, 0);
651
652        let player = world.entities.get(&NetworkId(1)).unwrap();
653        assert!(player.dx > 0.0);
654        assert!(player.x > 0.0);
655        // accel = 80, DT = 1/60, drag_factor = 1 / (1 + 2.0/60) = 60/62 โ‰ˆ 0.9677
656        // dx = (0 + 80 * 1/60) * 0.9677 = 1.2903225
657        assert!((player.dx - 1.2903225).abs() < 0.0001);
658    }
659
660    #[test]
661    fn test_playground_speed_clamp() {
662        let mut world = ClientWorld::with_prediction(true);
663        world.entities.insert(
664            NetworkId(1),
665            SabSlot {
666                network_id: 1,
667                flags: 0x04,
668                dx: 10.0,
669                dy: 10.0,
670                ..SabSlot::zeroed()
671            },
672        );
673
674        // Apply thrust
675        world.playground_apply_input(1.0, 1.0, 0);
676
677        let player = world.entities.get(&NetworkId(1)).unwrap();
678        let speed = (player.dx * player.dx + player.dy * player.dy).sqrt();
679        assert!(speed <= 30.0 + 0.0001);
680    }
681
682    #[test]
683    fn test_playground_drag() {
684        let mut world = ClientWorld::with_prediction(true);
685        world.entities.insert(
686            NetworkId(1),
687            SabSlot {
688                network_id: 1,
689                flags: 0x04,
690                ..SabSlot::zeroed()
691            },
692        );
693
694        // Start moving
695        world.playground_apply_input(1.0, 0.0, 0);
696        let v1 = world.entities.get(&NetworkId(1)).unwrap().dx;
697
698        // Coast (zero input)
699        world.playground_apply_input(0.0, 0.0, 0);
700        let v2 = world.entities.get(&NetworkId(1)).unwrap().dx;
701
702        assert!(v2 < v1);
703        // drag_factor = 1 / (1 + 2.0/60) = 60/62 โ‰ˆ 0.9677
704        assert!((v2 - v1 * (1.0 / (1.0 + 2.0 / 60.0))).abs() < 0.0001);
705    }
706}