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(prev_id) = prev {
183                if prev_id != *network_id {
184                    if let Some(slot) = self.entities.get_mut(&prev_id) {
185                        slot.flags &= !0x04;
186                        tracing::info!(
187                            ?prev_id,
188                            flags = slot.flags,
189                            "[handle_game_event] 0x04 flag cleared from previous entity"
190                        );
191                    }
192                }
193            }
194            self.player_network_id = Some(*network_id);
195            if let Some(slot) = self.entities.get_mut(network_id) {
196                slot.flags |= 0x04;
197                tracing::info!(
198                    ?network_id,
199                    flags = slot.flags,
200                    "[handle_game_event] 0x04 flag applied to entity"
201                );
202            } else {
203                tracing::warn!(
204                    ?network_id,
205                    "[handle_game_event] Possession entity not yet in world - will apply when it arrives"
206                );
207            }
208        }
209    }
210
211    fn apply_component_update(&mut self, update: &ComponentUpdate) {
212        match update.component_kind {
213            // ComponentKind(1) == Transform (Spatial data)
214            ComponentKind(1) => match rmp_serde::from_slice::<Transform>(&update.payload) {
215                Ok(transform) => {
216                    if let Some(entry) = self.entities.get_mut(&update.network_id) {
217                        entry.x = transform.x;
218                        entry.y = transform.y;
219                        entry.z = transform.z;
220                        entry.rotation = transform.rotation;
221                        if transform.entity_type != 0 {
222                            entry.entity_type = transform.entity_type;
223                        }
224                    }
225                }
226                Err(e) => {
227                    tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode Transform");
228                }
229            },
230            // ComponentKind(5) == ShipClass (Drives rendering type)
231            ComponentKind(5) => match rmp_serde::from_slice::<ShipClass>(&update.payload) {
232                Ok(ship_class) => {
233                    if let Some(entry) = self.entities.get_mut(&update.network_id) {
234                        entry.entity_type = match ship_class {
235                            ShipClass::Interceptor => 1,
236                            ShipClass::Dreadnought => 3,
237                            ShipClass::Hauler => 4,
238                        };
239                    }
240                }
241                Err(e) => {
242                    tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode ShipClass");
243                }
244            },
245            // ComponentKind(3) == ShipStats (HP/Shield)
246            ComponentKind(3) => match rmp_serde::from_slice::<ShipStats>(&update.payload) {
247                Ok(stats) => {
248                    if let Some(entry) = self.entities.get_mut(&update.network_id) {
249                        entry.hp = stats.hp;
250                        entry.shield = stats.shield;
251                    }
252                }
253                Err(e) => {
254                    tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode ShipStats");
255                }
256            },
257            // ComponentKind(1024) == MiningBeam
258            aetheris_protocol::types::MINING_BEAM_KIND => {
259                use aetheris_protocol::types::MiningBeam;
260                match rmp_serde::from_slice::<MiningBeam>(&update.payload) {
261                    Ok(beam) => {
262                        if let Some(entry) = self.entities.get_mut(&update.network_id) {
263                            entry.mining_active = u8::from(beam.active);
264                            #[allow(clippy::cast_possible_truncation)]
265                            {
266                                entry.mining_target_id = beam.target.map_or(0, |id| id.0 as u16);
267                            }
268                        }
269                    }
270                    Err(e) => {
271                        tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode MiningBeam");
272                    }
273                }
274            }
275            // ComponentKind(1025) == CargoHold
276            aetheris_protocol::types::CARGO_HOLD_KIND => {
277                use aetheris_protocol::types::CargoHold;
278                match rmp_serde::from_slice::<CargoHold>(&update.payload) {
279                    Ok(cargo) => {
280                        if let Some(entry) = self.entities.get_mut(&update.network_id) {
281                            entry.cargo_ore = cargo.ore_count;
282                            // High-assurance possession: CargoHold is only replicated to owners.
283                            // Flagging this entity as the player for camera tracking and local input handling.
284                            entry.flags |= 0x04;
285                        }
286                    }
287                    Err(e) => {
288                        tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode CargoHold");
289                    }
290                }
291            }
292            // ComponentKind(1026) == Asteroid
293            aetheris_protocol::types::ASTEROID_KIND => {
294                if let Some(entry) = self.entities.get_mut(&update.network_id) {
295                    // Mark as Asteroid type (5) if not already set
296                    if entry.entity_type == 0 {
297                        entry.entity_type = 5;
298                    }
299                }
300            }
301            // ComponentKind(130) == RoomBounds
302            aetheris_protocol::types::ROOM_BOUNDS_KIND => {
303                use aetheris_protocol::types::RoomBounds;
304                if let (Ok(bounds), Some(ptr_val)) = (
305                    rmp_serde::from_slice::<RoomBounds>(&update.payload),
306                    self.shared_world_ref,
307                ) {
308                    let mut sw =
309                        unsafe { crate::shared_world::SharedWorld::from_ptr(ptr_val as *mut u8) };
310                    sw.set_room_bounds(bounds.min_x, bounds.min_y, bounds.max_x, bounds.max_y);
311                }
312            }
313            kind => {
314                tracing::debug!(
315                    network_id = update.network_id.0,
316                    kind = kind.0,
317                    "Unhandled component kind"
318                );
319            }
320        }
321    }
322}
323
324impl ClientWorld {
325    /// Applies playground input to the local player entity.
326    /// Used for Sandbox mode simulation.
327    pub fn playground_apply_input(&mut self, move_x: f32, move_y: f32, actions_mask: u32) {
328        // Physics constants from VS-01
329        const THRUST_ACCEL: f32 = 0.12;
330        const DRAG: f32 = 0.92;
331        const MAX_SPEED: f32 = 3.0;
332
333        // Locate local player entity (flag 0x04) in the world state
334        for slot in self.entities.values_mut() {
335            if (slot.flags & 0x04) != 0 {
336                // Apply thrust
337                slot.dx = (slot.dx + move_x * THRUST_ACCEL) * DRAG;
338                slot.dy = (slot.dy + move_y * THRUST_ACCEL) * DRAG;
339
340                // Clamp speed
341                let speed_sq = slot.dx * slot.dx + slot.dy * slot.dy;
342                if speed_sq > MAX_SPEED * MAX_SPEED {
343                    let speed = speed_sq.sqrt();
344                    slot.dx = (slot.dx / speed) * MAX_SPEED;
345                    slot.dy = (slot.dy / speed) * MAX_SPEED;
346                }
347
348                // Integrate position
349                slot.x += slot.dx;
350                slot.y += slot.dy;
351
352                // Reset mining state if ToggleMining action is triggered in sandbox
353                if (actions_mask & 0x02) != 0 {
354                    slot.mining_active = 0;
355                    slot.mining_target_id = 0;
356                }
357
358                break;
359            }
360        }
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367    use crate::shared_world::SabSlot;
368    use aetheris_protocol::types::NetworkId;
369    use bytemuck::Zeroable;
370
371    #[test]
372    fn test_playground_movement() {
373        let mut world = ClientWorld::new();
374
375        // Spawn a local player ship (flag 0x04)
376        world.entities.insert(
377            NetworkId(1),
378            SabSlot {
379                network_id: 1,
380                flags: 0x04, // Local player
381                ..SabSlot::zeroed()
382            },
383        );
384
385        // Apply thrust forward (move_x = 1.0)
386        world.playground_apply_input(1.0, 0.0, 0);
387
388        let player = world.entities.get(&NetworkId(1)).unwrap();
389        assert!(player.dx > 0.0);
390        assert!(player.x > 0.0);
391        // dx = (0 + 1 * 0.12) * 0.92 = 0.1104
392        assert!((player.dx - 0.1104).abs() < 0.0001);
393    }
394
395    #[test]
396    fn test_playground_speed_clamp() {
397        let mut world = ClientWorld::new();
398        world.entities.insert(
399            NetworkId(1),
400            SabSlot {
401                network_id: 1,
402                flags: 0x04,
403                dx: 10.0,
404                dy: 10.0,
405                ..SabSlot::zeroed()
406            },
407        );
408
409        // Apply thrust
410        world.playground_apply_input(1.0, 1.0, 0);
411
412        let player = world.entities.get(&NetworkId(1)).unwrap();
413        let speed = (player.dx * player.dx + player.dy * player.dy).sqrt();
414        assert!(speed <= 3.0 + 0.0001);
415    }
416
417    #[test]
418    fn test_playground_drag() {
419        let mut world = ClientWorld::new();
420        world.entities.insert(
421            NetworkId(1),
422            SabSlot {
423                network_id: 1,
424                flags: 0x04,
425                ..SabSlot::zeroed()
426            },
427        );
428
429        // Start moving
430        world.playground_apply_input(1.0, 0.0, 0);
431        let v1 = world.entities.get(&NetworkId(1)).unwrap().dx;
432
433        // Coast (zero input)
434        world.playground_apply_input(0.0, 0.0, 0);
435        let v2 = world.entities.get(&NetworkId(1)).unwrap().dx;
436
437        assert!(v2 < v1);
438        assert!((v2 - v1 * 0.92).abs() < 0.0001);
439    }
440}