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;
14
15/// A simplified client-side world that tracks entity states using `SabSlot`.
16#[derive(Debug)]
17pub struct ClientWorld {
18    /// Map of `NetworkId` to the last known authoritative state.
19    pub entities: BTreeMap<NetworkId, SabSlot>,
20    /// The network ID of the player's own ship, if known.
21    pub player_network_id: Option<NetworkId>,
22    /// The latest tick received from the server.
23    pub latest_tick: u64,
24    /// Manifest of system-wide metadata.
25    pub system_manifest: BTreeMap<String, String>,
26    /// Optional shared world to push room bounds into directly (stored as usize for Send/Sync).
27    pub shared_world_ref: Option<usize>,
28}
29
30impl Default for ClientWorld {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36impl ClientWorld {
37    /// Creates a new, empty `ClientWorld`.
38    #[must_use]
39    pub fn new() -> Self {
40        Self {
41            entities: BTreeMap::new(),
42            player_network_id: None,
43            latest_tick: 0,
44            system_manifest: BTreeMap::new(),
45            shared_world_ref: None,
46        }
47    }
48}
49
50impl WorldState for ClientWorld {
51    fn get_local_id(&self, network_id: NetworkId) -> Option<LocalId> {
52        Some(LocalId(network_id.0))
53    }
54
55    fn get_network_id(&self, local_id: LocalId) -> Option<NetworkId> {
56        Some(NetworkId(local_id.0))
57    }
58
59    fn extract_deltas(&mut self) -> Vec<ReplicationEvent> {
60        Vec::new()
61    }
62
63    fn apply_updates(&mut self, updates: &[(ClientId, ComponentUpdate)]) {
64        if !updates.is_empty() {
65            tracing::debug!(
66                count = updates.len(),
67                player_network_id = ?self.player_network_id,
68                total_entities = self.entities.len(),
69                "[apply_updates] Processing updates batch"
70            );
71        }
72        for (_, update) in updates {
73            if update.tick > self.latest_tick {
74                self.latest_tick = update.tick;
75            }
76
77            let is_new = !self.entities.contains_key(&update.network_id);
78
79            // Ensure entity exists for component updates
80            let entry = self.entities.entry(update.network_id).or_insert_with(|| {
81                tracing::info!(
82                    network_id = update.network_id.0,
83                    kind = update.component_kind.0,
84                    player_network_id = ?self.player_network_id,
85                    "[apply_updates] NEW entity from server"
86                );
87                SabSlot {
88                    network_id: update.network_id.0,
89                    x: 0.0,
90                    y: 0.0,
91                    z: 0.0,
92                    rotation: 0.0,
93                    dx: 0.0,
94                    dy: 0.0,
95                    dz: 0.0,
96                    hp: 100,
97                    shield: 0,
98                    entity_type: 0,
99                    flags: 1,
100                    cargo_ore: 0,
101                    mining_target_id: 0,
102                    mining_active: 0,
103                }
104            });
105
106            // Re-apply possession flag if this is the player ship.
107            // This handles cases where Possession arrived before the entity was spawned.
108            let is_player = Some(update.network_id) == self.player_network_id;
109            if is_player {
110                tracing::info!(
111                    network_id = update.network_id.0,
112                    is_new,
113                    "[apply_updates] Setting 0x04 (LocalPlayer) flag on entity"
114                );
115                entry.flags |= 0x04;
116            } else if is_new {
117                tracing::info!(
118                    network_id = update.network_id.0,
119                    player_network_id = ?self.player_network_id,
120                    flags = entry.flags,
121                    "[apply_updates] New NON-player entity - no possession flag"
122                );
123            }
124
125            self.apply_component_update(update);
126        }
127    }
128
129    fn simulate(&mut self) {
130        let dt = 0.05; // 20Hz
131        let drag_base = 0.05;
132        for slot in self.entities.values_mut() {
133            // In a simple velocity integration without input, we just move by DX/DY.
134            slot.x += slot.dx * dt;
135            slot.y += slot.dy * dt;
136
137            // Apply Drag (Local approximation)
138            slot.dx *= 1.0 - (drag_base * dt);
139            slot.dy *= 1.0 - (drag_base * dt);
140        }
141    }
142
143    fn spawn_networked(&mut self) -> NetworkId {
144        NetworkId(0)
145    }
146
147    fn spawn_networked_for(&mut self, _client_id: ClientId) -> NetworkId {
148        self.spawn_networked()
149    }
150
151    fn despawn_networked(&mut self, network_id: NetworkId) -> Result<(), WorldError> {
152        self.entities
153            .remove(&network_id)
154            .map(|_| ())
155            .ok_or(WorldError::EntityNotFound(network_id))
156    }
157
158    fn stress_test(&mut self, _count: u16, _rotate: bool) {}
159
160    fn spawn_kind(&mut self, _kind: u16, _x: f32, _y: f32, _rot: f32) -> NetworkId {
161        NetworkId(1)
162    }
163
164    fn clear_world(&mut self) {
165        self.entities.clear();
166    }
167}
168
169impl ClientWorld {
170    /// Handles discrete game events from the server.
171    pub fn handle_game_event(&mut self, event: &aetheris_protocol::events::GameEvent) {
172        if let aetheris_protocol::events::GameEvent::Possession { network_id } = event {
173            let prev = self.player_network_id;
174            tracing::info!(
175                ?network_id,
176                ?prev,
177                entity_exists = self.entities.contains_key(network_id),
178                total_entities = self.entities.len(),
179                "[handle_game_event] POSSESSION received"
180            );
181            // Clear the local-player flag from the previous entity if it differs.
182            if let Some(slot) = prev
183                .filter(|&id| id != *network_id)
184                .and_then(|id| self.entities.get_mut(&id))
185            {
186                slot.flags &= !0x04;
187                tracing::info!(
188                    network_id = ?prev,
189                    flags = slot.flags,
190                    "[handle_game_event] 0x04 flag cleared from previous entity"
191                );
192            }
193            self.player_network_id = Some(*network_id);
194            if let Some(slot) = self.entities.get_mut(network_id) {
195                slot.flags |= 0x04;
196                tracing::info!(
197                    ?network_id,
198                    flags = slot.flags,
199                    "[handle_game_event] 0x04 flag applied to entity"
200                );
201            } else {
202                tracing::warn!(
203                    ?network_id,
204                    "[handle_game_event] Possession entity not yet in world - will apply when it arrives"
205                );
206            }
207        }
208    }
209
210    fn apply_component_update(&mut self, update: &ComponentUpdate) {
211        match update.component_kind {
212            // ComponentKind(1) == Transform (Spatial data)
213            ComponentKind(1) => match rmp_serde::from_slice::<Transform>(&update.payload) {
214                Ok(transform) => {
215                    if let Some(entry) = self.entities.get_mut(&update.network_id) {
216                        entry.x = transform.x;
217                        entry.y = transform.y;
218                        entry.z = transform.z;
219                        entry.rotation = transform.rotation;
220                        if transform.entity_type != 0 {
221                            entry.entity_type = transform.entity_type;
222                        }
223                    }
224                }
225                Err(e) => {
226                    tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode Transform");
227                }
228            },
229            // ComponentKind(5) == ShipClass (Drives rendering type)
230            ComponentKind(5) => match rmp_serde::from_slice::<ShipClass>(&update.payload) {
231                Ok(ship_class) => {
232                    if let Some(entry) = self.entities.get_mut(&update.network_id) {
233                        entry.entity_type = match ship_class {
234                            ShipClass::Interceptor => 1,
235                            ShipClass::Dreadnought => 3,
236                            ShipClass::Hauler => 4,
237                        };
238                    }
239                }
240                Err(e) => {
241                    tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode ShipClass");
242                }
243            },
244            // ComponentKind(3) == ShipStats (HP/Shield)
245            ComponentKind(3) => match rmp_serde::from_slice::<ShipStats>(&update.payload) {
246                Ok(stats) => {
247                    if let Some(entry) = self.entities.get_mut(&update.network_id) {
248                        entry.hp = stats.hp;
249                        entry.shield = stats.shield;
250                    }
251                }
252                Err(e) => {
253                    tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode ShipStats");
254                }
255            },
256            // ComponentKind(1024) == MiningBeam
257            aetheris_protocol::types::MINING_BEAM_KIND => {
258                use aetheris_protocol::types::MiningBeam;
259                match rmp_serde::from_slice::<MiningBeam>(&update.payload) {
260                    Ok(beam) => {
261                        if let Some(entry) = self.entities.get_mut(&update.network_id) {
262                            entry.mining_active = u8::from(beam.active);
263                            #[allow(clippy::cast_possible_truncation)]
264                            {
265                                entry.mining_target_id = beam.target.map_or(0, |id| id.0 as u16);
266                            }
267                        }
268                    }
269                    Err(e) => {
270                        tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode MiningBeam");
271                    }
272                }
273            }
274            // ComponentKind(1025) == CargoHold
275            aetheris_protocol::types::CARGO_HOLD_KIND => {
276                use aetheris_protocol::types::CargoHold;
277                match rmp_serde::from_slice::<CargoHold>(&update.payload) {
278                    Ok(cargo) => {
279                        if let Some(entry) = self.entities.get_mut(&update.network_id) {
280                            entry.cargo_ore = cargo.ore_count;
281                            // High-assurance possession: CargoHold is only replicated to owners.
282                            // Flagging this entity as the player for camera tracking and local input handling.
283                            entry.flags |= 0x04;
284                        }
285                    }
286                    Err(e) => {
287                        tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode CargoHold");
288                    }
289                }
290            }
291            // ComponentKind(1026) == Asteroid
292            aetheris_protocol::types::ASTEROID_KIND => {
293                if let Some(entry) = self.entities.get_mut(&update.network_id) {
294                    // Mark as Asteroid type (5) if not already set
295                    if entry.entity_type == 0 {
296                        entry.entity_type = 5;
297                    }
298                }
299            }
300            // ComponentKind(130) == RoomBounds
301            aetheris_protocol::types::ROOM_BOUNDS_KIND => {
302                use aetheris_protocol::types::RoomBounds;
303                if let (Ok(bounds), Some(ptr_val)) = (
304                    rmp_serde::from_slice::<RoomBounds>(&update.payload),
305                    self.shared_world_ref,
306                ) {
307                    let mut sw =
308                        unsafe { crate::shared_world::SharedWorld::from_ptr(ptr_val as *mut u8) };
309                    sw.set_room_bounds(bounds.min_x, bounds.min_y, bounds.max_x, bounds.max_y);
310                }
311            }
312            kind => {
313                tracing::debug!(
314                    network_id = update.network_id.0,
315                    kind = kind.0,
316                    "Unhandled component kind"
317                );
318            }
319        }
320    }
321}
322
323impl ClientWorld {
324    /// Applies playground input to the local player entity.
325    /// Used for Sandbox mode simulation.
326    pub fn playground_apply_input(&mut self, move_x: f32, move_y: f32, actions_mask: u32) {
327        // Physics constants from VS-01
328        const THRUST_ACCEL: f32 = 0.12;
329        const DRAG: f32 = 0.92;
330        const MAX_SPEED: f32 = 3.0;
331
332        // Locate local player entity (flag 0x04) in the world state
333        for slot in self.entities.values_mut() {
334            if (slot.flags & 0x04) != 0 {
335                // Apply thrust
336                slot.dx = (slot.dx + move_x * THRUST_ACCEL) * DRAG;
337                slot.dy = (slot.dy + move_y * THRUST_ACCEL) * DRAG;
338
339                // Clamp speed
340                let speed_sq = slot.dx * slot.dx + slot.dy * slot.dy;
341                if speed_sq > MAX_SPEED * MAX_SPEED {
342                    let speed = speed_sq.sqrt();
343                    slot.dx = (slot.dx / speed) * MAX_SPEED;
344                    slot.dy = (slot.dy / speed) * MAX_SPEED;
345                }
346
347                // Integrate position
348                slot.x += slot.dx;
349                slot.y += slot.dy;
350
351                // Reset mining state if ToggleMining action is triggered in sandbox
352                if (actions_mask & 0x02) != 0 {
353                    slot.mining_active = 0;
354                    slot.mining_target_id = 0;
355                }
356
357                break;
358            }
359        }
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use crate::shared_world::SabSlot;
367    use aetheris_protocol::types::NetworkId;
368    use bytemuck::Zeroable;
369
370    #[test]
371    fn test_playground_movement() {
372        let mut world = ClientWorld::new();
373
374        // Spawn a local player ship (flag 0x04)
375        world.entities.insert(
376            NetworkId(1),
377            SabSlot {
378                network_id: 1,
379                flags: 0x04, // Local player
380                ..SabSlot::zeroed()
381            },
382        );
383
384        // Apply thrust forward (move_x = 1.0)
385        world.playground_apply_input(1.0, 0.0, 0);
386
387        let player = world.entities.get(&NetworkId(1)).unwrap();
388        assert!(player.dx > 0.0);
389        assert!(player.x > 0.0);
390        // dx = (0 + 1 * 0.12) * 0.92 = 0.1104
391        assert!((player.dx - 0.1104).abs() < 0.0001);
392    }
393
394    #[test]
395    fn test_playground_speed_clamp() {
396        let mut world = ClientWorld::new();
397        world.entities.insert(
398            NetworkId(1),
399            SabSlot {
400                network_id: 1,
401                flags: 0x04,
402                dx: 10.0,
403                dy: 10.0,
404                ..SabSlot::zeroed()
405            },
406        );
407
408        // Apply thrust
409        world.playground_apply_input(1.0, 1.0, 0);
410
411        let player = world.entities.get(&NetworkId(1)).unwrap();
412        let speed = (player.dx * player.dx + player.dy * player.dy).sqrt();
413        assert!(speed <= 3.0 + 0.0001);
414    }
415
416    #[test]
417    fn test_playground_drag() {
418        let mut world = ClientWorld::new();
419        world.entities.insert(
420            NetworkId(1),
421            SabSlot {
422                network_id: 1,
423                flags: 0x04,
424                ..SabSlot::zeroed()
425            },
426        );
427
428        // Start moving
429        world.playground_apply_input(1.0, 0.0, 0);
430        let v1 = world.entities.get(&NetworkId(1)).unwrap().dx;
431
432        // Coast (zero input)
433        world.playground_apply_input(0.0, 0.0, 0);
434        let v2 = world.entities.get(&NetworkId(1)).unwrap().dx;
435
436        assert!(v2 < v1);
437        assert!((v2 - v1 * 0.92).abs() < 0.0001);
438    }
439}