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