Skip to main content

aetheris_client_wasm/
lib.rs

1//! Aetheris WASM client logic.
2//!
3//! This crate implements the browser-based client for the Aetheris Engine,
4//! using `WebWorkers` for multi-threaded execution and `WebGPU` for rendering.
5
6#![warn(clippy::all, clippy::pedantic)]
7// Required to declare `#[thread_local]` statics on nightly (wasm32 target).
8// This feature gate is only active when compiling for WASM — see the
9// `_TLS_ANCHOR` declaration below.
10#![cfg_attr(
11    all(target_arch = "wasm32", feature = "nightly"),
12    feature(thread_local)
13)]
14
15pub mod auth;
16pub mod shared_world;
17pub mod world_state;
18
19#[cfg(target_arch = "wasm32")]
20pub mod metrics;
21
22#[cfg(test)]
23#[cfg(target_arch = "wasm32")]
24pub mod smoke_test;
25
26/// Protobuf message types generated from `auth.proto` (prost only — no service stubs).
27/// Used by `auth.rs` so it doesn't need the `aetheris-protocol` `grpc` feature,
28/// which would pull in `tonic::transport` and hence `mio` (incompatible with wasm32).
29pub mod auth_proto {
30    #![allow(clippy::must_use_candidate, clippy::doc_markdown)]
31    tonic::include_proto!("aetheris.auth.v1");
32}
33
34#[cfg(target_arch = "wasm32")]
35pub mod transport;
36
37#[cfg(test)]
38pub mod transport_mock;
39
40#[cfg(target_arch = "wasm32")]
41pub mod render;
42
43#[cfg(any(target_arch = "wasm32", test))]
44pub mod render_primitives;
45
46#[cfg(any(target_arch = "wasm32", test))]
47pub mod assets;
48
49#[cfg(target_arch = "wasm32")]
50#[cfg_attr(feature = "nightly", thread_local)]
51static _TLS_ANCHOR: u8 = 0;
52
53use std::sync::atomic::AtomicUsize;
54#[cfg(target_arch = "wasm32")]
55use std::sync::atomic::Ordering;
56
57#[allow(dead_code)]
58static NEXT_WORKER_ID: AtomicUsize = AtomicUsize::new(1);
59
60#[cfg(target_arch = "wasm32")]
61thread_local! {
62    static WORKER_ID: usize = NEXT_WORKER_ID.fetch_add(1, Ordering::Relaxed);
63}
64
65/// Helper to get `performance.now()` in both Window and Worker contexts.
66#[must_use]
67pub fn performance_now() -> f64 {
68    #[cfg(target_arch = "wasm32")]
69    {
70        use wasm_bindgen::JsCast;
71        let global = js_sys::global();
72
73        // Try WorkerGlobalScope first
74        if let Ok(worker) = global.clone().dyn_into::<web_sys::WorkerGlobalScope>() {
75            return worker.performance().map(|p| p.now()).unwrap_or(0.0);
76        }
77
78        // Try Window
79        if let Ok(window) = global.dyn_into::<web_sys::Window>() {
80            return window.performance().map(|p| p.now()).unwrap_or(0.0);
81        }
82
83        // Fallback to Date
84        js_sys::Date::now()
85    }
86    #[cfg(not(target_arch = "wasm32"))]
87    {
88        0.0
89    }
90}
91
92#[allow(dead_code)]
93pub(crate) fn get_worker_id() -> usize {
94    #[cfg(target_arch = "wasm32")]
95    {
96        WORKER_ID.with(|&id| id)
97    }
98    #[cfg(not(target_arch = "wasm32"))]
99    {
100        0
101    }
102}
103
104#[cfg(target_arch = "wasm32")]
105mod wasm_impl {
106    use crate::assets;
107    use crate::metrics::with_collector;
108    use crate::performance_now;
109    use crate::render::RenderState;
110    use crate::shared_world::{MAX_ENTITIES, SabSlot, SharedWorld};
111    use crate::transport::WebTransportBridge;
112    use crate::world_state::ClientWorld;
113    use aetheris_encoder_serde::SerdeEncoder;
114    use aetheris_protocol::events::{NetworkEvent, ReplicationEvent};
115    use aetheris_protocol::traits::{Encoder, GameTransport, WorldState};
116    use aetheris_protocol::types::{
117        ClientId, ComponentKind, InputCommand, NetworkId, PlayerInputKind,
118    };
119    use wasm_bindgen::prelude::*;
120
121    #[wasm_bindgen]
122    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
123    pub enum ConnectionState {
124        Disconnected,
125        Connecting,
126        InGame,
127        Reconnecting,
128        Failed,
129    }
130
131    /// A snapshot of the world for interpolation.
132    #[derive(Clone)]
133    pub struct SimulationSnapshot {
134        pub tick: u64,
135        pub entities: Vec<SabSlot>,
136    }
137
138    /// Global state held by the WASM instance.
139    #[wasm_bindgen]
140    pub struct AetherisClient {
141        pub(crate) shared_world: SharedWorld,
142        pub(crate) world_state: ClientWorld,
143        pub(crate) render_state: Option<RenderState>,
144        pub(crate) transport: Option<Box<dyn GameTransport>>,
145        pub(crate) worker_id: usize,
146        pub(crate) session_token: Option<String>,
147
148        // Interpolation state (Render Worker only)
149        pub(crate) snapshots: std::collections::VecDeque<SimulationSnapshot>,
150
151        // Network metrics
152        pub(crate) last_rtt_ms: f64,
153        ping_counter: u64,
154
155        reassembler: aetheris_protocol::Reassembler,
156        connection_state: ConnectionState,
157        reconnect_attempts: u32,
158        playground_rotation_enabled: bool,
159        playground_next_network_id: u64,
160        first_playground_tick: bool,
161
162        // Reusable buffer for zero-allocation rendering (Render Worker only)
163        pub(crate) render_buffer: Vec<SabSlot>,
164        pub(crate) asset_registry: assets::AssetRegistry,
165
166        // Input noise reduction
167        last_input_target: Option<NetworkId>,
168        last_input_actions: Vec<PlayerInputKind>,
169
170        pending_clear: bool,
171        last_clear_tick: u64,
172
173        // Fixed-Timestep Simulation (M10105/M1020)
174        last_process_time: f64,
175        tick_accumulator: f64,
176
177        // Playground Input Buffering
178        playground_move_x: f32,
179        playground_move_y: f32,
180        playground_actions: u32,
181        last_fraction: f32,
182        last_actions_mask: u32,
183    }
184
185    #[wasm_bindgen]
186    impl AetherisClient {
187        /// Creates a new AetherisClient instance.
188        /// If a pointer is provided, it will use it as the backing storage (shared memory).
189        #[wasm_bindgen(constructor)]
190        pub fn new(shared_world_ptr: Option<u32>) -> Result<AetherisClient, JsValue> {
191            console_error_panic_hook::set_once();
192
193            // tracing_wasm doesn't have a clean try_init for global default.
194            // We use a static atomic to ensure only the first worker sets the global default.
195            use std::sync::atomic::{AtomicBool, Ordering};
196            static LOGGER_INITIALIZED: AtomicBool = AtomicBool::new(false);
197            if !LOGGER_INITIALIZED.swap(true, Ordering::SeqCst) {
198                let config = tracing_wasm::WASMLayerConfigBuilder::new()
199                    .set_max_level(tracing::Level::INFO)
200                    .build();
201                tracing_wasm::set_as_global_default_with_config(config);
202            }
203
204            let shared_world = if let Some(ptr_val) = shared_world_ptr {
205                let ptr = ptr_val as *mut u8;
206
207                // Security: Validate the incoming pointer before use
208                if ptr_val == 0 || !ptr_val.is_multiple_of(8) {
209                    return Err(JsValue::from_str(
210                        "Invalid shared_world_ptr: null or unaligned",
211                    ));
212                }
213
214                // JS-allocated SharedArrayBuffer pointers are not in the Rust registry
215                // (only Rust-owned allocations are registered). The null/alignment checks
216                // above are the only feasible boundary validation for externally-provided
217                // pointers; trusting the caller is required by the SAB contract.
218                unsafe { SharedWorld::from_ptr(ptr) }
219            } else {
220                SharedWorld::new()
221            };
222
223            let global = js_sys::global();
224            let (ua, lang) =
225                if let Ok(worker) = global.clone().dyn_into::<web_sys::WorkerGlobalScope>() {
226                    let n = worker.navigator();
227                    (n.user_agent().ok(), n.language())
228                } else if let Ok(window) = global.dyn_into::<web_sys::Window>() {
229                    let n = window.navigator();
230                    (n.user_agent().ok(), n.language())
231                } else {
232                    (None, None)
233                };
234
235            tracing::info!(
236                "Aetheris Client: Environment [UA: {}, Lang: {}]",
237                ua.as_deref().unwrap_or("Unknown"),
238                lang.as_deref().unwrap_or("Unknown")
239            );
240
241            tracing::info!(
242                "AetherisClient initialized on worker {}",
243                crate::get_worker_id()
244            );
245
246            // M10105 — emit wasm_init lifecycle span
247            with_collector(|c| {
248                c.push_event(
249                    1,
250                    "wasm_client",
251                    "AetherisClient initialized",
252                    "wasm_init",
253                    None,
254                );
255            });
256
257            let mut world_state = ClientWorld::new();
258            world_state.shared_world_ref = Some(shared_world.as_ptr() as usize);
259
260            Ok(Self {
261                shared_world,
262                world_state,
263                render_state: None,
264                transport: None,
265                worker_id: crate::get_worker_id(),
266                session_token: None,
267                snapshots: std::collections::VecDeque::with_capacity(8),
268                last_rtt_ms: 0.0,
269                ping_counter: 0,
270                reassembler: aetheris_protocol::Reassembler::new(),
271                connection_state: ConnectionState::Disconnected,
272                reconnect_attempts: 0,
273                playground_rotation_enabled: false,
274                playground_next_network_id: 1,
275                first_playground_tick: true,
276                render_buffer: Vec::with_capacity(crate::shared_world::MAX_ENTITIES),
277                asset_registry: assets::AssetRegistry::new(),
278                last_input_target: None,
279                last_input_actions: Vec::new(),
280                pending_clear: false,
281                last_clear_tick: 0,
282                last_process_time: crate::performance_now(),
283                tick_accumulator: 0.0,
284                playground_move_x: 0.0,
285                playground_move_y: 0.0,
286                playground_actions: 0,
287                last_fraction: 0.0,
288                last_actions_mask: 0,
289            })
290        }
291
292        #[wasm_bindgen]
293        pub fn set_view_state(&mut self, state: u32) {
294            use crate::render::ViewState;
295            let state = match state {
296                0 => ViewState::Logo,
297                1 => ViewState::Roaming,
298                2 => ViewState::Entering,
299                3 => ViewState::Playing,
300                _ => return,
301            };
302
303            if let Some(rs) = &mut self.render_state {
304                rs.set_view_state(state);
305            } else {
306                tracing::warn!("set_view_state called but render_state is None");
307            }
308        }
309
310        #[wasm_bindgen]
311        pub async fn request_otp(base_url: String, email: String) -> Result<String, String> {
312            crate::auth::request_otp(base_url, email).await
313        }
314
315        #[wasm_bindgen]
316        pub async fn login_with_otp(
317            base_url: String,
318            request_id: String,
319            code: String,
320        ) -> Result<String, String> {
321            crate::auth::login_with_otp(base_url, request_id, code).await
322        }
323
324        #[wasm_bindgen]
325        pub async fn logout(base_url: String, session_token: String) -> Result<(), String> {
326            crate::auth::logout(base_url, session_token).await
327        }
328
329        #[wasm_bindgen(getter)]
330        pub fn connection_state(&self) -> ConnectionState {
331            self.connection_state
332        }
333
334        fn check_worker(&self) {
335            debug_assert_eq!(
336                self.worker_id,
337                crate::get_worker_id(),
338                "AetherisClient accessed from wrong worker! It is pin-bound to its creating thread."
339            );
340        }
341
342        /// Returns the raw pointer to the shared world buffer.
343        pub fn shared_world_ptr(&self) -> u32 {
344            self.shared_world.as_ptr() as u32
345        }
346
347        pub async fn connect(
348            &mut self,
349            url: String,
350            cert_hash: Option<Vec<u8>>,
351        ) -> Result<(), JsValue> {
352            self.check_worker();
353
354            if self.connection_state == ConnectionState::Connecting
355                || self.connection_state == ConnectionState::InGame
356                || self.connection_state == ConnectionState::Reconnecting
357            {
358                return Ok(());
359            }
360
361            // M10105 — emit reconnect_attempt if it looks like one
362            if self.connection_state == ConnectionState::Failed && self.reconnect_attempts > 0 {
363                with_collector(|c| {
364                    c.push_event(
365                        2,
366                        "transport",
367                        "Triggering reconnection",
368                        "reconnect_attempt",
369                        None,
370                    );
371                });
372            }
373
374            self.connection_state = ConnectionState::Connecting;
375            tracing::info!(url = %url, "Connecting to server...");
376
377            let transport_result = WebTransportBridge::connect(&url, cert_hash.as_deref()).await;
378
379            match transport_result {
380                Ok(transport) => {
381                    // Security: Send Auth message immediately after connection
382                    if let Some(token) = &self.session_token {
383                        let encoder = SerdeEncoder::new();
384                        let auth_event = NetworkEvent::Auth {
385                            session_token: token.clone(),
386                        };
387
388                        match encoder.encode_event(&auth_event) {
389                            Ok(data) => {
390                                if let Err(e) = transport.send_reliable(ClientId(0), &data).await {
391                                    self.connection_state = ConnectionState::Failed;
392                                    tracing::error!(error = ?e, "Handshake failed: could not send auth packet");
393                                    return Err(JsValue::from_str(&format!(
394                                        "Failed to send auth packet: {:?}",
395                                        e
396                                    )));
397                                }
398                                tracing::info!("Auth packet sent to server");
399                            }
400                            Err(e) => {
401                                self.connection_state = ConnectionState::Failed;
402                                tracing::error!(error = ?e, "Handshake failed: could not encode auth packet");
403                                return Err(JsValue::from_str("Failed to encode auth packet"));
404                            }
405                        }
406                    } else {
407                        tracing::warn!(
408                            "Connecting without session token! Server will likely discard data."
409                        );
410                    }
411
412                    self.transport = Some(Box::new(transport));
413                    self.connection_state = ConnectionState::InGame;
414                    self.reconnect_attempts = 0;
415                    tracing::info!("WebTransport connection established");
416                    // M10105 — connect_handshake lifecycle span
417                    with_collector(|c| {
418                        c.push_event(
419                            1,
420                            "transport",
421                            &format!("WebTransport connected: {url}"),
422                            "connect_handshake",
423                            None,
424                        );
425                    });
426                    Ok(())
427                }
428                Err(e) => {
429                    self.connection_state = ConnectionState::Failed;
430                    tracing::error!(error = ?e, "Failed to establish WebTransport connection");
431                    // M10105 — connect_handshake_failed lifecycle span (ERROR level)
432                    with_collector(|c| {
433                        c.push_event(
434                            3,
435                            "transport",
436                            &format!("WebTransport failed: {url} — {e:?}"),
437                            "connect_handshake_failed",
438                            None,
439                        );
440                    });
441                    Err(JsValue::from_str(&format!("failed to connect: {e:?}")))
442                }
443            }
444        }
445
446        #[wasm_bindgen]
447        pub async fn reconnect(
448            &mut self,
449            url: String,
450            cert_hash: Option<Vec<u8>>,
451        ) -> Result<(), JsValue> {
452            self.check_worker();
453            self.connection_state = ConnectionState::Reconnecting;
454            self.reconnect_attempts += 1;
455
456            tracing::info!(
457                "Attempting reconnection... (attempt {})",
458                self.reconnect_attempts
459            );
460
461            self.connect(url, cert_hash).await
462        }
463
464        #[wasm_bindgen]
465        pub async fn wasm_load_asset(
466            &mut self,
467            handle: assets::AssetHandle,
468            url: String,
469        ) -> Result<(), JsValue> {
470            self.asset_registry.load_asset(handle, &url).await
471        }
472
473        /// Sets the session token to be used for authentication upon connection.
474        pub fn set_session_token(&mut self, token: String) {
475            self.session_token = Some(token);
476        }
477
478        /// Initializes rendering with a canvas element.
479        /// Accepts either web_sys::HtmlCanvasElement or web_sys::OffscreenCanvas.
480        pub async fn init_renderer(&mut self, canvas: JsValue) -> Result<(), JsValue> {
481            self.check_worker();
482            use wasm_bindgen::JsCast;
483
484            let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
485                backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
486                flags: wgpu::InstanceFlags::default(),
487                ..wgpu::InstanceDescriptor::new_without_display_handle()
488            });
489
490            // Handle both HtmlCanvasElement and OffscreenCanvas for Worker support
491            let (surface_target, width, height) =
492                if let Ok(html_canvas) = canvas.clone().dyn_into::<web_sys::HtmlCanvasElement>() {
493                    let width = html_canvas.width();
494                    let height = html_canvas.height();
495                    tracing::info!(
496                        "Initializing renderer on HTMLCanvasElement ({}x{})",
497                        width,
498                        height
499                    );
500                    (wgpu::SurfaceTarget::Canvas(html_canvas), width, height)
501                } else if let Ok(offscreen_canvas) =
502                    canvas.clone().dyn_into::<web_sys::OffscreenCanvas>()
503                {
504                    let width = offscreen_canvas.width();
505                    let height = offscreen_canvas.height();
506                    tracing::info!(
507                        "Initializing renderer on OffscreenCanvas ({}x{})",
508                        width,
509                        height
510                    );
511
512                    // Critical fix for wgpu 0.20+ on WASM workers:
513                    // Ensure the context is initialized with the 'webgpu' id before creating the surface.
514                    let _ = offscreen_canvas.get_context("webgpu").map_err(|e| {
515                        JsValue::from_str(&format!("Failed to get webgpu context: {:?}", e))
516                    })?;
517
518                    (
519                        wgpu::SurfaceTarget::OffscreenCanvas(offscreen_canvas),
520                        width,
521                        height,
522                    )
523                } else {
524                    return Err(JsValue::from_str(
525                        "Aetheris: Provided object is not a valid Canvas or OffscreenCanvas",
526                    ));
527                };
528
529            let surface = instance
530                .create_surface(surface_target)
531                .map_err(|e| JsValue::from_str(&format!("Failed to create surface: {:?}", e)))?;
532
533            let render_state = RenderState::new(&instance, surface, width, height)
534                .await
535                .map_err(|e| JsValue::from_str(&format!("Failed to init renderer: {:?}", e)))?;
536
537            self.render_state = Some(render_state);
538
539            // M10105 — emit render_pipeline_setup lifecycle span
540            with_collector(|c| {
541                c.push_event(
542                    1,
543                    "render_worker",
544                    &format!("Renderer initialized ({}x{})", width, height),
545                    "render_pipeline_setup",
546                    None,
547                );
548            });
549
550            Ok(())
551        }
552
553        #[wasm_bindgen]
554        pub fn resize(&mut self, width: u32, height: u32) {
555            if let Some(state) = &mut self.render_state {
556                state.resize(width, height);
557            }
558        }
559
560        #[cfg(debug_assertions)]
561        #[wasm_bindgen]
562        pub fn set_debug_mode(&mut self, mode: u32) {
563            self.check_worker();
564            if let Some(state) = &mut self.render_state {
565                state.set_debug_mode(match mode {
566                    0 => crate::render::DebugRenderMode::Off,
567                    1 => crate::render::DebugRenderMode::Wireframe,
568                    2 => crate::render::DebugRenderMode::Components,
569                    _ => crate::render::DebugRenderMode::Full,
570                });
571            }
572        }
573
574        #[wasm_bindgen]
575        pub fn set_theme_colors(&mut self, bg_base: &str, text_primary: &str) {
576            self.check_worker();
577            let clear = crate::render::parse_css_color(bg_base);
578            let label = crate::render::parse_css_color(text_primary);
579
580            tracing::info!(
581                "Aetheris Client: Applying theme colors [bg: {} -> {:?}, text: {} -> {:?}]",
582                bg_base,
583                clear,
584                text_primary,
585                label
586            );
587
588            if let Some(state) = &mut self.render_state {
589                state.set_clear_color(clear);
590                #[cfg(debug_assertions)]
591                state.set_label_color([
592                    label.r as f32,
593                    label.g as f32,
594                    label.b as f32,
595                    label.a as f32,
596                ]);
597            }
598        }
599
600        #[cfg(debug_assertions)]
601        #[wasm_bindgen]
602        pub fn cycle_debug_mode(&mut self) {
603            if let Some(state) = &mut self.render_state {
604                state.cycle_debug_mode();
605            }
606        }
607
608        #[cfg(debug_assertions)]
609        #[wasm_bindgen]
610        pub fn toggle_grid(&mut self) {
611            if let Some(state) = &mut self.render_state {
612                state.toggle_grid();
613            }
614        }
615
616        #[wasm_bindgen]
617        pub fn latest_tick(&self) -> u64 {
618            self.world_state.latest_tick
619        }
620
621        #[wasm_bindgen]
622        pub fn playground_apply_input(&mut self, move_x: f32, move_y: f32, actions_mask: u32) {
623            self.check_worker();
624            self.playground_move_x = move_x;
625            self.playground_move_y = move_y;
626            self.playground_actions = actions_mask;
627        }
628
629        /// Simulation tick called by the Network Worker at a fixed rate (e.g. 20Hz).
630        pub async fn tick(&mut self) {
631            self.check_worker();
632            use aetheris_protocol::traits::{Encoder, WorldState};
633
634            let encoder = SerdeEncoder::new();
635
636            // 0. Reconnection Logic
637            // TODO: poll transport.closed() promise and trigger reconnection state machine
638            if let Some(_transport) = &self.transport {}
639
640            // 0.1 Periodic Ping (approx. every 1 second at 60Hz)
641            if let Some(transport) = &mut self.transport {
642                self.ping_counter = self.ping_counter.wrapping_add(1);
643                if self.ping_counter % 60 == 0 {
644                    // Use current time as timestamp (ms)
645                    let now = performance_now();
646                    let tick_u64 = now as u64;
647
648                    if let Ok(data) = encoder.encode_event(&NetworkEvent::Ping {
649                        client_id: ClientId(0), // Client doesn't know its ID yet usually
650                        tick: tick_u64,
651                    }) {
652                        tracing::trace!(tick = tick_u64, "Sending Ping");
653                        let _ = transport.send_unreliable(ClientId(0), &data).await;
654                    }
655                }
656            }
657
658            // 1. Poll Network
659            if let Some(transport) = &mut self.transport {
660                let events = match transport.poll_events().await {
661                    Ok(e) => e,
662                    Err(e) => {
663                        tracing::error!("Transport poll failure: {:?}", e);
664                        return;
665                    }
666                };
667                let mut updates: Vec<(ClientId, aetheris_protocol::events::ComponentUpdate)> =
668                    Vec::new();
669
670                for event in events {
671                    match event {
672                        NetworkEvent::UnreliableMessage { data, client_id }
673                        | NetworkEvent::ReliableMessage { data, client_id } => {
674                            match encoder.decode(&data) {
675                                Ok(update) => {
676                                    // M10105 — Always filter by epoch tick so stale datagrams are
677                                    // rejected even after the reliable ClearWorld ack has lowered
678                                    // pending_clear (unreliable datagrams may overtake the ack).
679                                    if self.last_clear_tick == 0
680                                        || update.tick > self.last_clear_tick
681                                    {
682                                        updates.push((client_id, update));
683                                    } else {
684                                        tracing::debug!(
685                                            network_id = update.network_id.0,
686                                            tick = update.tick,
687                                            last_clear_tick = self.last_clear_tick,
688                                            "Discarding stale update (tick <= last_clear_tick)"
689                                        );
690                                    }
691                                }
692                                Err(_) => {
693                                    // Try to decode as a protocol/wire event instead
694                                    if let Ok(event) = encoder.decode_event(&data) {
695                                        match event {
696                                            aetheris_protocol::events::NetworkEvent::GameEvent {
697                                                event: game_event,
698                                                ..
699                                            } => match &game_event {
700                                                aetheris_protocol::events::GameEvent::AsteroidDepleted {
701                                                    network_id,
702                                                } => {
703                                                    tracing::info!(?network_id, "Asteroid depleted");
704                                                    self.world_state.entities.remove(&network_id);
705
706                                                    for slot in self.world_state.entities.values_mut() {
707                                                        if (slot.flags & 0x04) != 0
708                                                            && slot.mining_target_id == (network_id.0 as u16)
709                                                        {
710                                                            slot.mining_active = 0;
711                                                            slot.mining_target_id = 0;
712                                                            tracing::info!("Cleared local mining target due to depletion");
713                                                        }
714                                                    }
715                                                }
716                                                aetheris_protocol::events::GameEvent::Possession {
717                                                    network_id: _,
718                                                }
719                                                | aetheris_protocol::events::GameEvent::DamageEvent { .. }
720                                                | aetheris_protocol::events::GameEvent::DeathEvent { .. }
721                                                | aetheris_protocol::events::GameEvent::RespawnEvent { .. }
722                                                | aetheris_protocol::events::GameEvent::CargoCollected { .. } => {
723                                                    self.world_state.handle_game_event(&game_event);
724                                                }
725                                                aetheris_protocol::events::GameEvent::SystemManifest {
726                                                    manifest,
727                                                } => {
728                                                    tracing::debug!(
729                                                        count = manifest.len(),
730                                                        "Received SystemManifest from server"
731                                                    );
732                                                    self.world_state.system_manifest = manifest.clone();
733                                                }
734                                            },
735                                            aetheris_protocol::events::NetworkEvent::ClearWorld {
736                                                ..
737                                            } => {
738                                                tracing::info!(
739                                                    "Server ClearWorld ack received (via ReliableMessage) — gate lowered"
740                                                );
741                                                self.pending_clear = false;
742                                            }
743                                            _ => {}
744                                        }
745                                    } else {
746                                        tracing::warn!(
747                                            "Failed to decode server message as update or wire event"
748                                        );
749                                    }
750                                }
751                            }
752                        }
753                        NetworkEvent::ClientConnected(id) => {
754                            tracing::info!(?id, "Server connected");
755                        }
756                        NetworkEvent::ClientDisconnected(id) => {
757                            tracing::warn!(?id, "Server disconnected");
758                        }
759                        NetworkEvent::Disconnected(_id) => {
760                            tracing::error!("Transport disconnected locally");
761                            self.connection_state = ConnectionState::Disconnected;
762                        }
763                        NetworkEvent::Ping { client_id: _, tick } => {
764                            // Immediately reflect the ping as a pong with same tick
765                            let pong = NetworkEvent::Pong { tick };
766                            if let Ok(data) = encoder.encode_event(&pong) {
767                                let _ = transport.send_reliable(ClientId(0), &data).await;
768                            }
769                        }
770                        NetworkEvent::Pong { tick } => {
771                            // Calculate RTT from our own outgoing pings
772                            let now = performance_now();
773                            let rtt = now - (tick as f64);
774                            self.last_rtt_ms = rtt;
775
776                            with_collector(|c| {
777                                c.update_rtt(rtt);
778                            });
779
780                            #[cfg(feature = "metrics")]
781                            metrics::gauge!("aetheris_client_rtt_ms").set(rtt);
782
783                            tracing::trace!(rtt_ms = rtt, tick, "Received Pong / RTT update");
784                        }
785                        NetworkEvent::Auth { .. } => {
786                            // Client initiated event usually, ignore if received from server
787                            tracing::debug!("Received Auth event from server (unexpected)");
788                        }
789                        NetworkEvent::SessionClosed(id) => {
790                            tracing::warn!(?id, "WebTransport session closed");
791                        }
792                        NetworkEvent::StreamReset(id) => {
793                            tracing::error!(?id, "WebTransport stream reset");
794                        }
795                        NetworkEvent::ReplicationBatch { events, client_id } => {
796                            for event in events {
797                                if self.last_clear_tick == 0 || event.tick > self.last_clear_tick {
798                                    updates.push((
799                                        client_id,
800                                        aetheris_protocol::events::ComponentUpdate {
801                                            network_id: event.network_id,
802                                            component_kind: event.component_kind,
803                                            payload: event.payload,
804                                            tick: event.tick,
805                                        },
806                                    ));
807                                }
808                            }
809                        }
810                        NetworkEvent::Fragment {
811                            client_id,
812                            fragment,
813                        } => {
814                            if let Some(data) = self.reassembler.ingest(client_id, fragment) {
815                                if let Ok(update) = encoder.decode(&data) {
816                                    if self.last_clear_tick == 0
817                                        || update.tick > self.last_clear_tick
818                                    {
819                                        updates.push((client_id, update));
820                                    }
821                                }
822                            }
823                        }
824                        NetworkEvent::StressTest { .. } => {
825                            // Client-side, we don't handle incoming stress test events usually,
826                            // they are processed by the server.
827                        }
828                        NetworkEvent::Spawn { .. } => {
829                            // Handled by GameWorker via p_spawn
830                        }
831                        NetworkEvent::ClearWorld { .. } => {
832                            // Reliable ack: guaranteed to arrive after all unreliable
833                            // datagrams sent before it (QUIC ordering).  Lower the gate
834                            // and do a final flush — from this point forward, no stale
835                            // entity updates can arrive.
836                            tracing::info!("Server ClearWorld ack received — gate lowered");
837                            self.pending_clear = false;
838                        }
839                        NetworkEvent::GameEvent {
840                            event: game_event, ..
841                        } => {
842                            // Forward to inner GameEvent logic if needed,
843                            // or just handle the depletion here if it's the only one.
844                            match &game_event {
845                                aetheris_protocol::events::GameEvent::AsteroidDepleted {
846                                    network_id,
847                                } => {
848                                    tracing::info!(
849                                        ?network_id,
850                                        "Asteroid depleted (via GameEvent)"
851                                    );
852                                    // Instant local despawn to hide latency
853                                    self.world_state.entities.remove(&network_id);
854
855                                    // Clear local mining target if it matches the depleted asteroid
856                                    for slot in self.world_state.entities.values_mut() {
857                                        // flags & 0x04 is local player
858                                        if (slot.flags & 0x04) != 0
859                                            && slot.mining_target_id == (network_id.0 as u16)
860                                        {
861                                            slot.mining_active = 0;
862                                            slot.mining_target_id = 0;
863                                            tracing::info!(
864                                                "Cleared local mining target due to depletion"
865                                            );
866                                        }
867                                    }
868                                }
869                                aetheris_protocol::events::GameEvent::SystemManifest {
870                                    manifest,
871                                } => {
872                                    tracing::info!(
873                                        count = manifest.len(),
874                                        "Received SystemManifest from server (via GameEvent)"
875                                    );
876                                    self.world_state.system_manifest = manifest.clone();
877                                }
878                                aetheris_protocol::events::GameEvent::Possession {
879                                    network_id: _,
880                                }
881                                | aetheris_protocol::events::GameEvent::DamageEvent { .. }
882                                | aetheris_protocol::events::GameEvent::DeathEvent { .. }
883                                | aetheris_protocol::events::GameEvent::RespawnEvent { .. }
884                                | aetheris_protocol::events::GameEvent::CargoCollected { .. } => {
885                                    self.world_state.handle_game_event(&game_event);
886                                }
887                            }
888                        }
889                        #[allow(unreachable_patterns)]
890                        _ => {
891                            tracing::debug!("Unhandled outer NetworkEvent variant");
892                        }
893                    }
894                }
895
896                // 2. Apply updates to the Simulation World
897                // Skip while a ClearWorld ack is pending: any updates arriving
898                // now are stale datagrams from before the clear command.
899                if self.pending_clear {
900                    if !updates.is_empty() {
901                        tracing::debug!(
902                            count = updates.len(),
903                            "Discarding updates — pending_clear gate is raised"
904                        );
905                    }
906                } else {
907                    if !updates.is_empty() {
908                        let max_tick = updates.iter().map(|(_, u)| u.tick).max().unwrap_or(0);
909
910                        // M1020 — Continuous Clock Sync to prevent 1-2s rubber-banding
911                        if max_tick > 0 {
912                            let drift =
913                                (self.world_state.latest_tick as i32 - max_tick as i32).abs();
914                            if self.first_playground_tick || drift > 20 {
915                                tracing::info!(
916                                    latest = self.world_state.latest_tick,
917                                    server = max_tick,
918                                    drift,
919                                    "Syncing client latest_tick to server heartbeat"
920                                );
921                                self.world_state.latest_tick = max_tick;
922                                self.first_playground_tick = false;
923                            }
924                        }
925
926                        tracing::debug!(count = updates.len(), "Applying server updates to world");
927                        self.world_state.apply_updates(&updates);
928                    }
929                }
930            }
931
932            // 2.5. Fixed-Timestep Simulation Loop (M1020)
933            // The JS side (game.worker.ts) already calls tick() at ~60Hz via setTimeout.
934            // Using `if` instead of `while` prevents double-stepping when the JS timer fires
935            // slightly late (e.g. 17ms instead of 16.67ms): accumulated drift of ~0.33ms/frame
936            // would otherwise cause a second physics step every ~50 frames, causing ship lurches.
937            let now = crate::performance_now();
938            let delta_ms = now - self.last_process_time;
939            self.last_process_time = now;
940
941            // Limit delta to prevent "spiral of death" after long freezes (max 5 frames)
942            let delta_ms = delta_ms.min(100.0);
943            self.tick_accumulator += delta_ms;
944
945            const DT_MS: f64 = 1000.0 / 60.0;
946            while self.tick_accumulator >= DT_MS {
947                // 1. Apply buffered playground input locally (Prediction)
948                // We keep this enabled in the playground to maintain responsiveness (M1020).
949                // Reconciliation jitter is now mitigated by wrap-aware interpolation.
950                let applied = self.world_state.playground_apply_input(
951                    self.playground_move_x,
952                    self.playground_move_y,
953                    self.playground_actions,
954                );
955
956                if !applied && self.world_state.latest_tick % 120 == 0 {
957                    tracing::warn!(
958                        tick = self.world_state.latest_tick,
959                        "Simulation loop running but no LocalPlayer (0x04) entity found to apply input to"
960                    );
961                }
962
963                // 2. Advance global tick
964                self.world_state.latest_tick += 1;
965                self.world_state.simulate();
966                self.tick_accumulator -= DT_MS;
967            }
968
969            // 2.5.5. Publish sub-tick fraction for smooth rendering (M10105)
970            // Use EMA smoothing to prevent jitter from browser timer noise
971            let fraction = (self.tick_accumulator as f32 / DT_MS as f32).clamp(0.0, 1.0);
972            let alpha = 0.8;
973            self.last_fraction = self.last_fraction * (1.0 - alpha) + fraction * alpha;
974            self.shared_world.set_sub_tick_fraction(self.last_fraction);
975            tracing::trace!(fraction, "Updated sub-tick fraction");
976
977            // 2.6. Client-side rotation animation (local, not replicated by server)
978
979            // M10105 — measure simulation time
980            let sim_start = crate::performance_now();
981
982            // 3. Write Authoritative Snapshot to Shared World for the Render Worker
983            self.flush_to_shared_world(self.world_state.latest_tick);
984
985            let sim_time_ms = crate::performance_now() - sim_start;
986            let count = self.world_state.entities.len() as u32;
987
988            // VS-02 — Update real-time cargo metrics for UI
989            let (cargo_ore, cargo_cap) = self
990                .world_state
991                .player_network_id
992                .and_then(|id| self.world_state.entities.get(&id))
993                .map_or((0, 0), |s| (s.cargo_ore as u32, s.cargo_capacity as u32));
994
995            with_collector(|c| {
996                c.record_sim(sim_time_ms);
997                c.update_entity_count(count);
998                c.update_cargo(cargo_ore, cargo_cap);
999            });
1000        }
1001
1002        fn flush_to_shared_world(&mut self, tick: u64) {
1003            let entities = &self.world_state.entities;
1004            let write_buffer = self.shared_world.get_write_buffer();
1005
1006            let mut count = 0;
1007            for (i, slot) in entities.values().enumerate() {
1008                if i >= MAX_ENTITIES {
1009                    tracing::warn!("Max entities reached in shared world! Overflow suppressed.");
1010                    break;
1011                }
1012                write_buffer[i] = *slot;
1013                count += 1;
1014            }
1015
1016            tracing::debug!(entity_count = count, tick, "Flushed world to SAB");
1017            self.shared_world.commit_write(count as u32, tick);
1018        }
1019
1020        #[wasm_bindgen]
1021        pub async fn request_system_manifest(&mut self) -> Result<(), JsValue> {
1022            self.check_worker();
1023
1024            if let Some(transport) = &self.transport {
1025                let encoder = SerdeEncoder::new();
1026                let event = NetworkEvent::RequestSystemManifest {
1027                    client_id: ClientId(0),
1028                };
1029
1030                if let Ok(data) = encoder.encode_event(&event) {
1031                    transport
1032                        .send_reliable(ClientId(0), &data)
1033                        .await
1034                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1035                    tracing::info!("Sent RequestSystemManifest command to server");
1036                }
1037            }
1038            Ok(())
1039        }
1040
1041        #[wasm_bindgen]
1042        pub fn get_system_info(&self) -> Result<JsValue, JsValue> {
1043            serde_wasm_bindgen::to_value(&self.world_state.system_manifest)
1044                .map_err(|e| JsValue::from_str(&e.to_string()))
1045        }
1046
1047        #[wasm_bindgen]
1048        pub fn wasm_get_entity_statuses(&self) -> JsValue {
1049            #[derive(serde::Serialize)]
1050            struct EntityStatus {
1051                network_id: String,
1052                hp: u16,
1053                shield: u16,
1054                entity_type: u16,
1055                is_player: bool,
1056            }
1057
1058            let mut entities: Vec<&SabSlot> = self.world_state.entities.values().collect();
1059            entities.sort_by_key(|slot| slot.network_id);
1060
1061            let statuses: Vec<EntityStatus> = entities
1062                .into_iter()
1063                .map(|slot| EntityStatus {
1064                    network_id: slot.network_id.to_string(),
1065                    hp: slot.hp,
1066                    shield: slot.shield,
1067                    entity_type: slot.entity_type,
1068                    is_player: (slot.flags & 0x04) != 0,
1069                })
1070                .collect();
1071
1072            match serde_wasm_bindgen::to_value(&statuses) {
1073                Ok(val) => val,
1074                Err(e) => {
1075                    web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(&format!(
1076                        "wasm_get_entity_statuses: serde_wasm_bindgen::to_value failed: {e}"
1077                    )));
1078                    wasm_bindgen::JsValue::NULL
1079                }
1080            }
1081        }
1082
1083        #[wasm_bindgen]
1084        pub fn playground_spawn(&mut self, entity_type: u16, x: f32, y: f32, rotation: f32) {
1085            // Security: Prevent overflow in playground mode
1086            if self.world_state.entities.len() >= MAX_ENTITIES {
1087                tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
1088                return;
1089            }
1090
1091            // Sync ID generator if it's currently at default but world is seeded
1092            if self.playground_next_network_id == 1 && !self.world_state.entities.is_empty() {
1093                self.playground_next_network_id = self
1094                    .world_state
1095                    .entities
1096                    .keys()
1097                    .map(|k| k.0)
1098                    .max()
1099                    .unwrap_or(0)
1100                    + 1;
1101            }
1102
1103            let id = aetheris_protocol::types::NetworkId(self.playground_next_network_id);
1104            self.playground_next_network_id += 1;
1105            let slot = SabSlot {
1106                network_id: id.0,
1107                x,
1108                y,
1109                z: 0.0,
1110                rotation,
1111                dx: 0.0,
1112                dy: 0.0,
1113                dz: 0.0,
1114                hp: 100,
1115                shield: 100,
1116                entity_type,
1117                flags: 0x01, // ALIVE
1118                mining_active: 0,
1119                cargo_ore: 0,
1120                cargo_capacity: 0,
1121                mining_target_id: 0,
1122                combat_target_id: 0,
1123                combat_flash_ticks: 0,
1124                padding: [0; 3],
1125            };
1126            self.world_state.entities.insert(id, slot);
1127        }
1128
1129        #[wasm_bindgen]
1130        pub async fn playground_spawn_net(
1131            &mut self,
1132            entity_type: u16,
1133            x: f32,
1134            y: f32,
1135            rot: f32,
1136        ) -> Result<(), JsValue> {
1137            self.check_worker();
1138
1139            if let Some(transport) = &self.transport {
1140                let encoder = SerdeEncoder::new();
1141                let event = NetworkEvent::Spawn {
1142                    client_id: ClientId(0),
1143                    entity_type,
1144                    x,
1145                    y,
1146                    rot,
1147                };
1148
1149                if let Ok(data) = encoder.encode_event(&event) {
1150                    transport
1151                        .send_reliable(ClientId(0), &data)
1152                        .await
1153                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1154                    tracing::info!(entity_type, x, y, "Sent Spawn command to server");
1155                }
1156            } else {
1157                // Local fallback
1158                self.playground_spawn(entity_type, x, y, rot);
1159            }
1160            Ok(())
1161        }
1162
1163        #[wasm_bindgen]
1164        pub fn playground_clear(&mut self) {
1165            self.world_state.entities.clear();
1166        }
1167
1168        /// Sends a StartSession command to the server.
1169        /// The server will spawn the session Interceptor and send back a Possession event.
1170        /// Only valid when connected; does nothing in local sandbox mode.
1171        #[wasm_bindgen]
1172        pub async fn start_session_net(&mut self) -> Result<(), JsValue> {
1173            self.check_worker();
1174
1175            if let Some(transport) = &self.transport {
1176                let encoder = SerdeEncoder::new();
1177                let event = NetworkEvent::StartSession {
1178                    client_id: ClientId(0),
1179                };
1180                if let Ok(data) = encoder.encode_event(&event) {
1181                    transport
1182                        .send_reliable(ClientId(0), &data)
1183                        .await
1184                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1185                    tracing::info!("Sent StartSession command to server");
1186                }
1187            }
1188            Ok(())
1189        }
1190
1191        /// Sends a movement/action input command to the server.
1192        ///
1193        /// The input is encoded as an unreliable component update (Kind 128)
1194        /// and sent to the server for processing in the next tick.
1195        #[wasm_bindgen]
1196        pub async fn send_input(
1197            &mut self,
1198            tick: u64,
1199            move_x: f32,
1200            move_y: f32,
1201            actions_mask: u32,
1202            target_id_arg: Option<u64>,
1203        ) -> Result<(), JsValue> {
1204            self.check_worker();
1205
1206            // 1. Identify the controlled player entity to target the command correctly
1207            let target_id = if let Some(owned_id) = self.world_state.player_network_id {
1208                // If we have an explicit possession ID from the server, use it.
1209                // This is the most reliable method (M1038).
1210                tracing::trace!(
1211                    network_id = owned_id.0,
1212                    "[send_input] Using player_network_id for input target"
1213                );
1214                Some(owned_id)
1215            } else {
1216                // Fallback: Identify via replication flags if possession event hasn't arrived yet
1217                let fallback = self
1218                    .world_state
1219                    .entities
1220                    .iter()
1221                    .find(|(_, slot)| (slot.flags & 0x04) != 0)
1222                    .map(|(id, _)| *id);
1223                tracing::trace!(
1224                    fallback_id = ?fallback,
1225                    "[send_input] No player_network_id set - using 0x04 flag fallback"
1226                );
1227                fallback
1228            };
1229
1230            let Some(target_id) = target_id else {
1231                tracing::trace!("[send_input] Input dropped: no controlled entity found");
1232                return Ok(());
1233            };
1234
1235            tracing::trace!(
1236                target_id = target_id.0,
1237                move_x,
1238                move_y,
1239                "[send_input] Sending input for entity"
1240            );
1241
1242            // 2. Prepare actions vector
1243            let mut actions = Vec::new();
1244
1245            // Movement action
1246            if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON {
1247                actions.push(PlayerInputKind::Move {
1248                    x: move_x,
1249                    y: move_y,
1250                });
1251            }
1252
1253            // Bitmask actions (M1020 mapping)
1254            // Bit 2: FirePrimary (Space) - ACTION_FIRE_WEAPON
1255            if (actions_mask & 0x04) != 0 {
1256                actions.push(PlayerInputKind::FirePrimary);
1257            }
1258            // Bit 1: ToggleMining (Edge-triggered)
1259            if (actions_mask & 0x02) != 0 && (self.last_actions_mask & 0x02) == 0 {
1260                let target = if let Some(id) = target_id_arg {
1261                    Some(NetworkId(id))
1262                } else {
1263                    // VS-02 Auto-target: find nearest asteroid
1264                    if let Some(player_slot) = self.world_state.entities.get(&target_id) {
1265                        let player_pos = (player_slot.x, player_slot.y);
1266                        self.world_state
1267                            .entities
1268                            .iter()
1269                            .filter(|(_, slot)| slot.entity_type == 5) // Asteroid (kind 5)
1270                            .map(|(id, slot)| {
1271                                let dx = slot.x - player_pos.0;
1272                                let dy = slot.y - player_pos.1;
1273                                (id, dx * dx + dy * dy)
1274                            })
1275                            .min_by(|a, b| {
1276                                a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
1277                            })
1278                            .map(|(id, _)| *id)
1279                    } else {
1280                        None
1281                    }
1282                };
1283
1284                if let Some(id) = target {
1285                    actions.push(PlayerInputKind::ToggleMining { target: id });
1286                } else {
1287                    tracing::warn!(
1288                        "ToggleMining requested without target_id and no asteroid nearby; dropping action"
1289                    );
1290                }
1291            }
1292
1293            self.last_actions_mask = actions_mask;
1294
1295            // 3. Noise reduction check
1296            let is_repeated = self.last_input_actions.len() == actions.len()
1297                && self
1298                    .last_input_actions
1299                    .iter()
1300                    .zip(actions.iter())
1301                    .all(|(a, b)| a == b)
1302                && self.last_input_target == Some(target_id);
1303
1304            if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON || actions_mask != 0 {
1305                if is_repeated {
1306                    tracing::trace!(
1307                        tick,
1308                        move_x,
1309                        move_y,
1310                        actions_mask,
1311                        "Client sending input (repeated)"
1312                    );
1313                } else {
1314                    tracing::trace!(tick, move_x, move_y, actions_mask, "Client sending input");
1315                }
1316            }
1317
1318            let transport = self.transport.as_ref().ok_or_else(|| {
1319                JsValue::from_str("Cannot send input: transport not initialized or closed")
1320            })?;
1321
1322            if is_repeated {
1323                tracing::trace!(
1324                    ?target_id,
1325                    x = move_x,
1326                    y = move_y,
1327                    "Sending InputCommand (repeated)"
1328                );
1329            } else {
1330                tracing::trace!(?target_id, x = move_x, y = move_y, "Sending InputCommand");
1331            }
1332
1333            // Update last input state
1334            self.last_input_target = Some(target_id);
1335            self.last_input_actions = actions.clone();
1336
1337            let cmd = InputCommand {
1338                tick,
1339                actions,
1340                actions_mask,
1341                last_seen_input_tick: None,
1342            }
1343            .clamped();
1344
1345            // 4. Encode as a ComponentUpdate-compatible packet
1346            // We use ComponentKind(128) as the convention for InputCommands.
1347            // The server's TickScheduler will decode this as a standard game update.
1348            let payload = rmp_serde::to_vec(&cmd)
1349                .map_err(|e| JsValue::from_str(&format!("Failed to encode InputCommand: {e:?}")))?;
1350
1351            let update = ReplicationEvent {
1352                network_id: target_id,
1353                component_kind: ComponentKind(128),
1354                payload,
1355                tick,
1356            };
1357
1358            let mut buffer = [0u8; 1024];
1359            let encoder = SerdeEncoder::new();
1360            let len = encoder.encode(&update, &mut buffer).map_err(|e| {
1361                JsValue::from_str(&format!("Failed to encode input replication event: {e:?}"))
1362            })?;
1363
1364            // 3. Send via unreliable datagram
1365            transport
1366                .send_unreliable(ClientId(0), &buffer[..len])
1367                .await
1368                .map_err(|e| {
1369                    JsValue::from_str(&format!("Transport error during send_input: {e:?}"))
1370                })?;
1371
1372            Ok(())
1373        }
1374
1375        #[wasm_bindgen]
1376        pub async fn playground_clear_server(&mut self) -> Result<(), JsValue> {
1377            self.check_worker();
1378
1379            if let Some(transport) = &self.transport {
1380                let encoder = SerdeEncoder::new();
1381                let event = NetworkEvent::ClearWorld {
1382                    client_id: ClientId(0),
1383                };
1384                if let Ok(data) = encoder.encode_event(&event) {
1385                    transport
1386                        .send_reliable(ClientId(0), &data)
1387                        .await
1388                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1389                    tracing::info!(
1390                        "Sent ClearWorld command to server — suppressing updates until ack"
1391                    );
1392                    // Immediately clear local state and raise the gate.  All incoming
1393                    // entity updates are suppressed until the server's reliable ClearWorld
1394                    // ack arrives, preventing stale in-flight datagrams from re-adding
1395                    // entities that were just despawned on the server.
1396                    self.world_state.entities.clear();
1397                    self.world_state.player_network_id = None;
1398                    self.pending_clear = true;
1399                    self.last_clear_tick = self.world_state.latest_tick;
1400                }
1401            } else {
1402                // No transport, clear immediately (no in-flight datagrams to worry about)
1403                self.world_state.entities.clear();
1404                self.world_state.player_network_id = None;
1405            }
1406            Ok(())
1407        }
1408
1409        #[wasm_bindgen]
1410        pub fn playground_set_rotation_enabled(&mut self, enabled: bool) {
1411            self.playground_rotation_enabled = enabled;
1412            // Note: Rotation toggle is currently local-authoritative in playground_tick,
1413            // but for server-side replication it would need a network event.
1414        }
1415
1416        #[wasm_bindgen]
1417        pub async fn playground_stress_test(
1418            &mut self,
1419            count: u16,
1420            rotate: bool,
1421        ) -> Result<(), JsValue> {
1422            self.check_worker();
1423
1424            if let Some(transport) = &self.transport {
1425                let encoder = SerdeEncoder::new();
1426                let event = NetworkEvent::StressTest {
1427                    client_id: ClientId(0),
1428                    count,
1429                    rotate,
1430                };
1431
1432                if let Ok(data) = encoder.encode_event(&event) {
1433                    transport
1434                        .send_reliable(ClientId(0), &data)
1435                        .await
1436                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1437                    tracing::info!(count, rotate, "Sent StressTest command to server");
1438                }
1439                self.playground_set_rotation_enabled(rotate);
1440            } else {
1441                // Fallback to local behavior if not connected
1442                self.playground_set_rotation_enabled(rotate);
1443                self.playground_clear();
1444                for _ in 0..count {
1445                    self.playground_spawn(1, 0.0, 0.0, 0.0); // Simple spawn
1446                }
1447            }
1448
1449            Ok(())
1450        }
1451
1452        #[wasm_bindgen]
1453        pub fn tick_playground(&mut self) {
1454            self.check_worker();
1455
1456            // M10105 — measure simulation time
1457            let sim_start = crate::performance_now();
1458
1459            let now = crate::performance_now();
1460            let delta_ms = now - self.last_process_time;
1461            self.last_process_time = now;
1462
1463            // Limit delta to prevent "spiral of death" (max 5 frames)
1464            let delta_ms = delta_ms.min(100.0);
1465            self.tick_accumulator += delta_ms;
1466
1467            const DT_MS: f64 = 1000.0 / 60.0;
1468            let mut steps = 0;
1469            while self.tick_accumulator >= DT_MS {
1470                self.world_state.latest_tick += 1;
1471
1472                // 1. Apply playground input (respected by prediction_enabled flag internally)
1473                self.world_state.playground_apply_input(
1474                    self.playground_move_x,
1475                    self.playground_move_y,
1476                    self.playground_actions,
1477                );
1478
1479                // 2. Local physics simulation
1480                self.world_state.simulate();
1481
1482                self.tick_accumulator -= DT_MS;
1483                steps += 1;
1484            }
1485
1486            // Sync to shared world if we simulated at least one step
1487            if steps > 0 {
1488                // Publish sub-tick fraction for smooth rendering (M10105)
1489                let fraction = (self.tick_accumulator as f32 / DT_MS as f32).clamp(0.0, 1.0);
1490                let alpha = 0.8;
1491                self.last_fraction = self.last_fraction * (1.0 - alpha) + fraction * alpha;
1492                self.shared_world.set_sub_tick_fraction(self.last_fraction);
1493
1494                let count = self.world_state.entities.len() as u32;
1495                self.flush_to_shared_world(self.world_state.latest_tick);
1496
1497                let sim_time_ms = crate::performance_now() - sim_start;
1498                with_collector(|c| {
1499                    c.record_sim(sim_time_ms);
1500                    c.update_entity_count(count);
1501                });
1502            }
1503        }
1504
1505        /// Render frame called by the Render Worker.
1506        pub fn render(&mut self) -> f64 {
1507            self.check_worker();
1508
1509            let tick = self.shared_world.tick();
1510            let entities = self.shared_world.get_read_buffer();
1511            let bounds = self.shared_world.get_room_bounds();
1512            if let Some(state) = &mut self.render_state {
1513                state.set_room_bounds(bounds);
1514            }
1515
1516            // Periodic diagnostic log for render worker
1517            thread_local! {
1518                static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
1519            }
1520            FRAME_COUNT.with(|count| {
1521                let current = count.get();
1522                if current % 300 == 0 {
1523                    tracing::debug!(
1524                        "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
1525                        tick,
1526                        entities.len(),
1527                        self.snapshots.len(),
1528                    );
1529                }
1530                count.set(current + 1);
1531            });
1532
1533            // 1. Buffer new snapshots — only push when tick advances
1534            let back_tick = self.snapshots.back().map(|s| s.tick).unwrap_or(0);
1535            if tick < back_tick && tick != 0 {
1536                tracing::warn!(
1537                    tick,
1538                    back_tick,
1539                    "Simulation time went backwards! Clearing snapshot buffer."
1540                );
1541                self.snapshots.clear();
1542            }
1543
1544            if self.snapshots.is_empty() || tick > back_tick {
1545                tracing::trace!(tick, "Pushing new snapshot to buffer");
1546                self.snapshots.push_back(SimulationSnapshot {
1547                    tick,
1548                    entities: entities.to_vec(),
1549                });
1550            } else if tick == back_tick && tick != 0 {
1551                // Diagnostic for stagnant tick
1552                thread_local! {
1553                    static STAGNANT_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
1554                }
1555                STAGNANT_COUNT.with(|count| {
1556                    let cur = count.get() + 1;
1557                    if cur % 300 == 0 {
1558                        tracing::warn!(tick, "Render loop stalled on same tick for 300 frames");
1559                    }
1560                    count.set(cur);
1561                });
1562            }
1563
1564            // 2. Calculate target playback tick with high-precision sub-tick fraction.
1565            // Stay 2 ticks behind latest so we always have an (s1, s2) interpolation pair.
1566            // We use the shared_world's sub_tick_fraction to smoothly transition between
1567            // simulation steps at the monitor's full refresh rate (e.g. 144Hz).
1568            let latest_tick = self.snapshots.back().map(|s| s.tick as f32).unwrap_or(0.0);
1569            let fraction = self.shared_world.sub_tick_fraction();
1570            let mut target_tick = latest_tick - 1.0 + fraction;
1571
1572            // Ensure target_tick is within available snapshot range if possible
1573            if !self.snapshots.is_empty() {
1574                let oldest_tick = self.snapshots[0].tick as f32;
1575                if target_tick < oldest_tick {
1576                    target_tick = oldest_tick;
1577                }
1578            }
1579
1580            let ent_count = entities.len();
1581            let frame_time_ms = self.render_at_tick(target_tick);
1582
1583            // M10105 — record accurate frame time (from WGPU) + snapshot depth.
1584            let snap_count = self.snapshots.len() as u32;
1585            if tick % 60 == 0 {
1586                tracing::trace!(
1587                    tick,
1588                    ent_count,
1589                    snap_count,
1590                    target_tick,
1591                    "Render Loop Active"
1592                );
1593            }
1594            with_collector(|c| {
1595                // FPS is computed in the worker; we only report duration here.
1596                c.record_frame(frame_time_ms, 0.0);
1597                c.update_snapshot_count(snap_count);
1598            });
1599
1600            frame_time_ms
1601        }
1602
1603        fn render_at_tick(&mut self, target_tick: f32) -> f64 {
1604            if self.snapshots.len() < 2 {
1605                // If we don't have enough snapshots for interpolation,
1606                // we still want to render the background or at least one frame.
1607                if let Some(state) = &mut self.render_state {
1608                    let entities = if !self.snapshots.is_empty() {
1609                        self.snapshots[0].entities.clone()
1610                    } else {
1611                        Vec::new()
1612                    };
1613                    return state.render_frame_with_compact_slots(&entities);
1614                }
1615                return 0.0;
1616            }
1617
1618            // Find snapshots S1, S2 such that S1.tick <= target_tick < S2.tick
1619            let mut s1_idx = 0;
1620            let mut found = false;
1621
1622            for i in 0..self.snapshots.len() - 1 {
1623                if (self.snapshots[i].tick as f32) <= target_tick
1624                    && (self.snapshots[i + 1].tick as f32) > target_tick
1625                {
1626                    s1_idx = i;
1627                    found = true;
1628                    break;
1629                }
1630            }
1631
1632            if !found {
1633                // If we are outside the buffer range, clamp to the nearest edge
1634                if target_tick < self.snapshots[0].tick as f32 {
1635                    s1_idx = 0;
1636                } else {
1637                    s1_idx = self.snapshots.len() - 2;
1638                }
1639            }
1640
1641            let s1 = self.snapshots.get(s1_idx).unwrap();
1642            let s2 = self.snapshots.get(s1_idx + 1).unwrap();
1643
1644            let tick_range = (s2.tick - s1.tick) as f32;
1645            let alpha = if tick_range > 0.0 {
1646                (target_tick - s1.tick as f32) / tick_range
1647            } else {
1648                1.0
1649            }
1650            .clamp(0.0, 1.0);
1651
1652            // Interpolate entities into a reusable buffer to avoid per-frame heap allocations
1653            self.render_buffer.clear();
1654            self.render_buffer.extend_from_slice(&s2.entities);
1655
1656            // Build a lookup map from the previous snapshot for O(1) access per entity.
1657            let prev_map: std::collections::HashMap<u64, &SabSlot> =
1658                s1.entities.iter().map(|e| (e.network_id, e)).collect();
1659
1660            for ent in &mut self.render_buffer {
1661                if let Some(prev) = prev_map.get(&ent.network_id).copied() {
1662                    if let Some(bounds) = &self.world_state.room_bounds {
1663                        ent.x = lerp_wrapped(prev.x, ent.x, alpha, bounds.min_x, bounds.max_x);
1664                        ent.y = lerp_wrapped(prev.y, ent.y, alpha, bounds.min_y, bounds.max_y);
1665                    } else {
1666                        ent.x = lerp(prev.x, ent.x, alpha);
1667                        ent.y = lerp(prev.y, ent.y, alpha);
1668                    }
1669                    ent.z = lerp(prev.z, ent.z, alpha);
1670                    ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
1671                } else {
1672                    // M1013/M1020 — Extrapolate backwards for newly spawned entities.
1673                    // This prevents the "blink" where an entity appears to stand still for
1674                    // one tick before starting to move.
1675                    let dt = 1.0 / 60.0;
1676                    let remaining = 1.0 - alpha;
1677
1678                    if let Some(bounds) = &self.world_state.room_bounds {
1679                        // Use wrapped logic for backward extrapolation to handle spawns near bounds
1680                        ent.x = lerp_wrapped(
1681                            ent.x,
1682                            ent.x - ent.dx * dt * remaining,
1683                            1.0,
1684                            bounds.min_x,
1685                            bounds.max_x,
1686                        );
1687                        ent.y = lerp_wrapped(
1688                            ent.y,
1689                            ent.y - ent.dy * dt * remaining,
1690                            1.0,
1691                            bounds.min_y,
1692                            bounds.max_y,
1693                        );
1694                    } else {
1695                        ent.x -= ent.dx * dt * remaining;
1696                        ent.y -= ent.dy * dt * remaining;
1697                    }
1698                    ent.z -= ent.dz * dt * remaining;
1699                }
1700            }
1701
1702            let mut frame_time = 0.0;
1703            if let Some(state) = &mut self.render_state {
1704                frame_time = state.render_frame_with_compact_slots(&self.render_buffer);
1705            }
1706
1707            // 3. Prune old snapshots.
1708            // We keep the oldest one that is still relevant for interpolation (index 0)
1709            // and everything newer. We prune snapshots that are entirely behind our window.
1710            while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
1711                self.snapshots.pop_front();
1712            }
1713
1714            // Safety cap: prevent unbounded growth if simulation stops but render continues
1715            while self.snapshots.len() > 16 {
1716                self.snapshots.pop_front();
1717            }
1718
1719            frame_time
1720        }
1721    }
1722
1723    fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
1724        a + (b - a) * alpha
1725    }
1726
1727    fn lerp_wrapped(a: f32, b: f32, alpha: f32, min: f32, max: f32) -> f32 {
1728        let size = max - min;
1729        if size <= 0.0 {
1730            return a + (b - a) * alpha;
1731        }
1732
1733        // Ensure inputs are within [min, max) before calculating diff
1734        let a_norm = (a - min).rem_euclid(size) + min;
1735        let b_norm = (b - min).rem_euclid(size) + min;
1736
1737        let mut diff = b_norm - a_norm;
1738        if diff.abs() > size * 0.5 {
1739            if diff > 0.0 {
1740                diff -= size;
1741            } else {
1742                diff += size;
1743            }
1744        }
1745        let res = a_norm + diff * alpha;
1746        // Final wrap to keep result strictly in [min, max)
1747        (res - min).rem_euclid(size) + min
1748    }
1749
1750    fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1751        // Simple rotation lerp for Phase 1.
1752        // Handles 2pi wraparound for smooth visuals.
1753        let mut diff = b - a;
1754        while diff < -std::f32::consts::PI {
1755            diff += std::f32::consts::TAU;
1756        }
1757        while diff > std::f32::consts::PI {
1758            diff -= std::f32::consts::TAU;
1759        }
1760        a + diff * alpha
1761    }
1762}