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