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};
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
165        pub(crate) asset_registry: assets::AssetRegistry,
166    }
167
168    #[wasm_bindgen]
169    impl AetherisClient {
170        /// Creates a new AetherisClient instance.
171        /// If a pointer is provided, it will use it as the backing storage (shared memory).
172        #[wasm_bindgen(constructor)]
173        pub fn new(shared_world_ptr: Option<u32>) -> Result<AetherisClient, JsValue> {
174            console_error_panic_hook::set_once();
175
176            // tracing_wasm doesn't have a clean try_init for global default.
177            // We use a static atomic to ensure only the first worker sets the global default.
178            use std::sync::atomic::{AtomicBool, Ordering};
179            static LOGGER_INITIALIZED: AtomicBool = AtomicBool::new(false);
180            if !LOGGER_INITIALIZED.swap(true, Ordering::SeqCst) {
181                let config = tracing_wasm::WASMLayerConfigBuilder::new()
182                    .set_max_level(tracing::Level::INFO)
183                    .build();
184                tracing_wasm::set_as_global_default_with_config(config);
185            }
186
187            let shared_world = if let Some(ptr_val) = shared_world_ptr {
188                let ptr = ptr_val as *mut u8;
189
190                // Security: Validate the incoming pointer before use
191                if ptr_val == 0 || !ptr_val.is_multiple_of(8) {
192                    return Err(JsValue::from_str(
193                        "Invalid shared_world_ptr: null or unaligned",
194                    ));
195                }
196
197                // JS-allocated SharedArrayBuffer pointers are not in the Rust registry
198                // (only Rust-owned allocations are registered). The null/alignment checks
199                // above are the only feasible boundary validation for externally-provided
200                // pointers; trusting the caller is required by the SAB contract.
201                unsafe { SharedWorld::from_ptr(ptr) }
202            } else {
203                SharedWorld::new()
204            };
205
206            tracing::info!(
207                "AetherisClient initialized on worker {}",
208                crate::get_worker_id()
209            );
210
211            // M10105 — emit wasm_init lifecycle span
212            with_collector(|c| {
213                c.push_event(
214                    1,
215                    "wasm_client",
216                    "AetherisClient initialized",
217                    "wasm_init",
218                    None,
219                );
220            });
221
222            Ok(Self {
223                shared_world,
224                world_state: ClientWorld::new(),
225                render_state: None,
226                transport: None,
227                worker_id: crate::get_worker_id(),
228                session_token: None,
229                snapshots: std::collections::VecDeque::with_capacity(8),
230                last_rtt_ms: 0.0,
231                ping_counter: 0,
232                reassembler: aetheris_protocol::Reassembler::new(),
233                connection_state: ConnectionState::Disconnected,
234                reconnect_attempts: 0,
235                playground_rotation_enabled: false,
236                playground_next_network_id: 1,
237                first_playground_tick: true,
238                render_buffer: Vec::with_capacity(crate::shared_world::MAX_ENTITIES),
239                asset_registry: assets::AssetRegistry::new(),
240            })
241        }
242
243        #[wasm_bindgen]
244        pub async fn request_otp(base_url: String, email: String) -> Result<String, String> {
245            crate::auth::request_otp(base_url, email).await
246        }
247
248        #[wasm_bindgen]
249        pub async fn login_with_otp(
250            base_url: String,
251            request_id: String,
252            code: String,
253        ) -> Result<String, String> {
254            crate::auth::login_with_otp(base_url, request_id, code).await
255        }
256
257        #[wasm_bindgen]
258        pub async fn logout(base_url: String, session_token: String) -> Result<(), String> {
259            crate::auth::logout(base_url, session_token).await
260        }
261
262        #[wasm_bindgen(getter)]
263        pub fn connection_state(&self) -> ConnectionState {
264            self.connection_state
265        }
266
267        fn check_worker(&self) {
268            debug_assert_eq!(
269                self.worker_id,
270                crate::get_worker_id(),
271                "AetherisClient accessed from wrong worker! It is pin-bound to its creating thread."
272            );
273        }
274
275        /// Returns the raw pointer to the shared world buffer.
276        pub fn shared_world_ptr(&self) -> u32 {
277            self.shared_world.as_ptr() as u32
278        }
279
280        pub async fn connect(
281            &mut self,
282            url: String,
283            cert_hash: Option<Vec<u8>>,
284        ) -> Result<(), JsValue> {
285            self.check_worker();
286
287            if self.connection_state == ConnectionState::Connecting
288                || self.connection_state == ConnectionState::InGame
289                || self.connection_state == ConnectionState::Reconnecting
290            {
291                return Ok(());
292            }
293
294            // M10105 — emit reconnect_attempt if it looks like one
295            if self.connection_state == ConnectionState::Failed && self.reconnect_attempts > 0 {
296                with_collector(|c| {
297                    c.push_event(
298                        2,
299                        "transport",
300                        "Triggering reconnection",
301                        "reconnect_attempt",
302                        None,
303                    );
304                });
305            }
306
307            self.connection_state = ConnectionState::Connecting;
308            tracing::info!(url = %url, "Connecting to server...");
309
310            let transport_result = WebTransportBridge::connect(&url, cert_hash.as_deref()).await;
311
312            match transport_result {
313                Ok(transport) => {
314                    // Security: Send Auth message immediately after connection
315                    if let Some(token) = &self.session_token {
316                        let encoder = SerdeEncoder::new();
317                        let auth_event = NetworkEvent::Auth {
318                            session_token: token.clone(),
319                        };
320
321                        match encoder.encode_event(&auth_event) {
322                            Ok(data) => {
323                                if let Err(e) = transport.send_reliable(ClientId(0), &data).await {
324                                    self.connection_state = ConnectionState::Failed;
325                                    tracing::error!(error = ?e, "Handshake failed: could not send auth packet");
326                                    return Err(JsValue::from_str(&format!(
327                                        "Failed to send auth packet: {:?}",
328                                        e
329                                    )));
330                                }
331                                tracing::info!("Auth packet sent to server");
332                            }
333                            Err(e) => {
334                                self.connection_state = ConnectionState::Failed;
335                                tracing::error!(error = ?e, "Handshake failed: could not encode auth packet");
336                                return Err(JsValue::from_str("Failed to encode auth packet"));
337                            }
338                        }
339                    } else {
340                        tracing::warn!(
341                            "Connecting without session token! Server will likely discard data."
342                        );
343                    }
344
345                    self.transport = Some(Box::new(transport));
346                    self.connection_state = ConnectionState::InGame;
347                    self.reconnect_attempts = 0;
348                    tracing::info!("WebTransport connection established");
349                    // M10105 — connect_handshake lifecycle span
350                    with_collector(|c| {
351                        c.push_event(
352                            1,
353                            "transport",
354                            &format!("WebTransport connected: {url}"),
355                            "connect_handshake",
356                            None,
357                        );
358                    });
359                    Ok(())
360                }
361                Err(e) => {
362                    self.connection_state = ConnectionState::Failed;
363                    tracing::error!(error = ?e, "Failed to establish WebTransport connection");
364                    // M10105 — connect_handshake_failed lifecycle span (ERROR level)
365                    with_collector(|c| {
366                        c.push_event(
367                            3,
368                            "transport",
369                            &format!("WebTransport failed: {url} — {e:?}"),
370                            "connect_handshake_failed",
371                            None,
372                        );
373                    });
374                    Err(JsValue::from_str(&format!("failed to connect: {e:?}")))
375                }
376            }
377        }
378
379        #[wasm_bindgen]
380        pub async fn reconnect(
381            &mut self,
382            url: String,
383            cert_hash: Option<Vec<u8>>,
384        ) -> Result<(), JsValue> {
385            self.check_worker();
386            self.connection_state = ConnectionState::Reconnecting;
387            self.reconnect_attempts += 1;
388
389            tracing::info!(
390                "Attempting reconnection... (attempt {})",
391                self.reconnect_attempts
392            );
393
394            self.connect(url, cert_hash).await
395        }
396
397        #[wasm_bindgen]
398        pub async fn wasm_load_asset(
399            &mut self,
400            handle: assets::AssetHandle,
401            url: String,
402        ) -> Result<(), JsValue> {
403            self.asset_registry.load_asset(handle, &url).await
404        }
405
406        /// Sets the session token to be used for authentication upon connection.
407        pub fn set_session_token(&mut self, token: String) {
408            self.session_token = Some(token);
409        }
410
411        /// Initializes rendering with a canvas element.
412        /// Accepts either web_sys::HtmlCanvasElement or web_sys::OffscreenCanvas.
413        pub async fn init_renderer(&mut self, canvas: JsValue) -> Result<(), JsValue> {
414            self.check_worker();
415            use wasm_bindgen::JsCast;
416
417            let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
418                backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
419                flags: wgpu::InstanceFlags::default(),
420                ..wgpu::InstanceDescriptor::new_without_display_handle()
421            });
422
423            // Handle both HtmlCanvasElement and OffscreenCanvas for Worker support
424            let (surface_target, width, height) =
425                if let Ok(html_canvas) = canvas.clone().dyn_into::<web_sys::HtmlCanvasElement>() {
426                    let width = html_canvas.width();
427                    let height = html_canvas.height();
428                    tracing::info!(
429                        "Initializing renderer on HTMLCanvasElement ({}x{})",
430                        width,
431                        height
432                    );
433                    (wgpu::SurfaceTarget::Canvas(html_canvas), width, height)
434                } else if let Ok(offscreen_canvas) =
435                    canvas.clone().dyn_into::<web_sys::OffscreenCanvas>()
436                {
437                    let width = offscreen_canvas.width();
438                    let height = offscreen_canvas.height();
439                    tracing::info!(
440                        "Initializing renderer on OffscreenCanvas ({}x{})",
441                        width,
442                        height
443                    );
444
445                    // Critical fix for wgpu 0.20+ on WASM workers:
446                    // Ensure the context is initialized with the 'webgpu' id before creating the surface.
447                    let _ = offscreen_canvas.get_context("webgpu").map_err(|e| {
448                        JsValue::from_str(&format!("Failed to get webgpu context: {:?}", e))
449                    })?;
450
451                    (
452                        wgpu::SurfaceTarget::OffscreenCanvas(offscreen_canvas),
453                        width,
454                        height,
455                    )
456                } else {
457                    return Err(JsValue::from_str(
458                        "Aetheris: Provided object is not a valid Canvas or OffscreenCanvas",
459                    ));
460                };
461
462            let surface = instance
463                .create_surface(surface_target)
464                .map_err(|e| JsValue::from_str(&format!("Failed to create surface: {:?}", e)))?;
465
466            let render_state = RenderState::new(&instance, surface, width, height)
467                .await
468                .map_err(|e| JsValue::from_str(&format!("Failed to init renderer: {:?}", e)))?;
469
470            self.render_state = Some(render_state);
471
472            // M10105 — emit render_pipeline_setup lifecycle span
473            with_collector(|c| {
474                c.push_event(
475                    1,
476                    "render_worker",
477                    &format!("Renderer initialized ({}x{})", width, height),
478                    "render_pipeline_setup",
479                    None,
480                );
481            });
482
483            Ok(())
484        }
485
486        #[wasm_bindgen]
487        pub fn resize(&mut self, width: u32, height: u32) {
488            if let Some(state) = &mut self.render_state {
489                state.resize(width, height);
490            }
491        }
492
493        #[cfg(debug_assertions)]
494        #[wasm_bindgen]
495        pub fn set_debug_mode(&mut self, mode: u32) {
496            self.check_worker();
497            if let Some(state) = &mut self.render_state {
498                state.set_debug_mode(match mode {
499                    0 => crate::render::DebugRenderMode::Off,
500                    1 => crate::render::DebugRenderMode::Wireframe,
501                    2 => crate::render::DebugRenderMode::Components,
502                    _ => crate::render::DebugRenderMode::Full,
503                });
504            }
505        }
506
507        #[wasm_bindgen]
508        pub fn set_theme_colors(&mut self, bg_base: &str, text_primary: &str) {
509            self.check_worker();
510            let clear = crate::render::parse_css_color(bg_base);
511            let label = crate::render::parse_css_color(text_primary);
512
513            tracing::info!(
514                "Aetheris Client: Applying theme colors [bg: {} -> {:?}, text: {} -> {:?}]",
515                bg_base,
516                clear,
517                text_primary,
518                label
519            );
520
521            if let Some(state) = &mut self.render_state {
522                state.set_clear_color(clear);
523                #[cfg(debug_assertions)]
524                state.set_label_color([
525                    label.r as f32,
526                    label.g as f32,
527                    label.b as f32,
528                    label.a as f32,
529                ]);
530            }
531        }
532
533        #[cfg(debug_assertions)]
534        #[wasm_bindgen]
535        pub fn cycle_debug_mode(&mut self) {
536            if let Some(state) = &mut self.render_state {
537                state.cycle_debug_mode();
538            }
539        }
540
541        #[cfg(debug_assertions)]
542        #[wasm_bindgen]
543        pub fn toggle_grid(&mut self) {
544            if let Some(state) = &mut self.render_state {
545                state.toggle_grid();
546            }
547        }
548
549        /// Simulation tick called by the Network Worker at a fixed rate (e.g. 20Hz).
550        pub async fn tick(&mut self) {
551            self.check_worker();
552            use aetheris_protocol::traits::{Encoder, WorldState};
553
554            let encoder = SerdeEncoder::new();
555
556            // 0. Reconnection Logic
557            // TODO: poll transport.closed() promise and trigger reconnection state machine
558            if let Some(_transport) = &self.transport {}
559
560            // 0.1 Periodic Ping (approx. every 1 second at 60Hz)
561            if let Some(transport) = &mut self.transport {
562                self.ping_counter = self.ping_counter.wrapping_add(1);
563                if self.ping_counter % 60 == 0 {
564                    // Use current time as timestamp (ms)
565                    let now = performance_now();
566                    let tick_u64 = now as u64;
567
568                    if let Ok(data) = encoder.encode_event(&NetworkEvent::Ping {
569                        client_id: ClientId(0), // Client doesn't know its ID yet usually
570                        tick: tick_u64,
571                    }) {
572                        tracing::trace!(tick = tick_u64, "Sending Ping");
573                        let _ = transport.send_unreliable(ClientId(0), &data).await;
574                    }
575                }
576            }
577
578            // 1. Poll Network
579            if let Some(transport) = &mut self.transport {
580                let events = match transport.poll_events().await {
581                    Ok(e) => e,
582                    Err(e) => {
583                        tracing::error!("Transport poll failure: {:?}", e);
584                        return;
585                    }
586                };
587                let mut updates: Vec<(ClientId, aetheris_protocol::events::ComponentUpdate)> =
588                    Vec::new();
589
590                for event in events {
591                    match event {
592                        NetworkEvent::UnreliableMessage { data, client_id }
593                        | NetworkEvent::ReliableMessage { data, client_id } => {
594                            match encoder.decode(&data) {
595                                Ok(update) => {
596                                    tracing::debug!(
597                                        network_id = update.network_id.0,
598                                        kind = update.component_kind.0,
599                                        tick = update.tick,
600                                        "Decoded component update"
601                                    );
602                                    updates.push((client_id, update));
603                                }
604                                Err(_) => {
605                                    // Try to decode as a protocol/wire event instead
606                                    if let Ok(event) = encoder.decode_event(&data) {
607                                        match event {
608                                            aetheris_protocol::events::NetworkEvent::GameEvent {
609                                                event:
610                                                    aetheris_protocol::events::GameEvent::AsteroidDepleted {
611                                                        network_id,
612                                                    },
613                                                ..
614                                            } => {
615                                                tracing::info!(?network_id, "Asteroid depleted");
616                                                // Instant local despawn to hide latency
617                                                self.world_state.entities.remove(&network_id);
618
619                                                // Clear local mining target if it matches the depleted asteroid
620                                                for slot in self.world_state.entities.values_mut() {
621                                                    // flags & 0x04 is local player
622                                                    if (slot.flags & 0x04) != 0
623                                                        && slot.mining_target_id == (network_id.0 as u16)
624                                                    {
625                                                        slot.mining_active = 0;
626                                                        slot.mining_target_id = 0;
627                                                        tracing::info!("Cleared local mining target due to depletion");
628                                                    }
629                                                }
630                                            }
631                                            _ => {}
632                                        }
633                                    } else {
634                                        tracing::warn!(
635                                            "Failed to decode server message as update or wire event"
636                                        );
637                                    }
638                                }
639                            }
640                        }
641                        NetworkEvent::ClientConnected(id) => {
642                            tracing::info!(?id, "Server connected");
643                        }
644                        NetworkEvent::ClientDisconnected(id) => {
645                            tracing::warn!(?id, "Server disconnected");
646                        }
647                        NetworkEvent::Disconnected(_id) => {
648                            tracing::error!("Transport disconnected locally");
649                            self.connection_state = ConnectionState::Disconnected;
650                        }
651                        NetworkEvent::Ping { client_id: _, tick } => {
652                            // Immediately reflect the ping as a pong with same tick
653                            let pong = NetworkEvent::Pong { tick };
654                            if let Ok(data) = encoder.encode_event(&pong) {
655                                let _ = transport.send_reliable(ClientId(0), &data).await;
656                            }
657                        }
658                        NetworkEvent::Pong { tick } => {
659                            // Calculate RTT from our own outgoing pings
660                            let now = performance_now();
661                            let rtt = now - (tick as f64);
662                            self.last_rtt_ms = rtt;
663
664                            with_collector(|c| {
665                                c.update_rtt(rtt);
666                            });
667
668                            #[cfg(feature = "metrics")]
669                            metrics::gauge!("aetheris_client_rtt_ms").set(rtt);
670
671                            tracing::trace!(rtt_ms = rtt, tick, "Received Pong / RTT update");
672                        }
673                        NetworkEvent::Auth { .. } => {
674                            // Client initiated event usually, ignore if received from server
675                            tracing::debug!("Received Auth event from server (unexpected)");
676                        }
677                        NetworkEvent::SessionClosed(id) => {
678                            tracing::warn!(?id, "WebTransport session closed");
679                        }
680                        NetworkEvent::StreamReset(id) => {
681                            tracing::error!(?id, "WebTransport stream reset");
682                        }
683                        NetworkEvent::Fragment {
684                            client_id,
685                            fragment,
686                        } => {
687                            if let Some(data) = self.reassembler.ingest(client_id, fragment) {
688                                if let Ok(update) = encoder.decode(&data) {
689                                    updates.push((client_id, update));
690                                }
691                            }
692                        }
693                        NetworkEvent::StressTest { .. } => {
694                            // Client-side, we don't handle incoming stress test events usually,
695                            // they are processed by the server.
696                        }
697                        NetworkEvent::Spawn { .. } => {
698                            // Handled by GameWorker via p_spawn
699                        }
700                        NetworkEvent::ClearWorld { .. } => {
701                            tracing::info!("Server initiated world clear");
702                            self.world_state.entities.clear();
703                        }
704                        NetworkEvent::GameEvent { event, .. } => {
705                            // Forward to inner GameEvent logic if needed,
706                            // or just handle the depletion here if it's the only one.
707                            match event {
708                                aetheris_protocol::events::GameEvent::AsteroidDepleted {
709                                    network_id,
710                                } => {
711                                    tracing::info!(
712                                        ?network_id,
713                                        "Asteroid depleted (via GameEvent)"
714                                    );
715                                    // Instant local despawn to hide latency
716                                    self.world_state.entities.remove(&network_id);
717
718                                    // Clear local mining target if it matches the depleted asteroid
719                                    for slot in self.world_state.entities.values_mut() {
720                                        // flags & 0x04 is local player
721                                        if (slot.flags & 0x04) != 0
722                                            && slot.mining_target_id == (network_id.0 as u16)
723                                        {
724                                            slot.mining_active = 0;
725                                            slot.mining_target_id = 0;
726                                            tracing::info!(
727                                                "Cleared local mining target due to depletion"
728                                            );
729                                        }
730                                    }
731                                }
732                                #[allow(unreachable_patterns)]
733                                _ => {
734                                    tracing::debug!("Unhandled inner GameEvent variant");
735                                }
736                            }
737                        }
738                        #[allow(unreachable_patterns)]
739                        _ => {
740                            tracing::debug!("Unhandled outer NetworkEvent variant");
741                        }
742                    }
743                }
744
745                // 2. Apply updates to the Simulation World
746                if !updates.is_empty() {
747                    tracing::debug!(count = updates.len(), "Applying server updates to world");
748                }
749                self.world_state.apply_updates(&updates);
750            }
751
752            // 2.5. Client-side rotation animation (local, not replicated by server)
753            if self.playground_rotation_enabled {
754                for slot in self.world_state.entities.values_mut() {
755                    slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
756                }
757            }
758
759            // Advance local tick every frame so the render worker always sees a new snapshot,
760            // even when the server sends no updates (static entities, client-side animation).
761            self.world_state.latest_tick += 1;
762
763            // M10105 — measure simulation time
764            let sim_start = crate::performance_now();
765
766            // 3. Write Authoritative Snapshot to Shared World for the Render Worker
767            self.flush_to_shared_world(self.world_state.latest_tick);
768
769            let sim_time_ms = crate::performance_now() - sim_start;
770            let count = self.world_state.entities.len() as u32;
771            with_collector(|c| {
772                c.record_sim(sim_time_ms);
773                c.update_entity_count(count);
774            });
775        }
776
777        fn flush_to_shared_world(&mut self, tick: u64) {
778            let entities = &self.world_state.entities;
779            let write_buffer = self.shared_world.get_write_buffer();
780
781            let mut count = 0;
782            for (i, slot) in entities.values().enumerate() {
783                if i >= MAX_ENTITIES {
784                    tracing::warn!("Max entities reached in shared world! Overflow suppressed.");
785                    break;
786                }
787                write_buffer[i] = *slot;
788                count += 1;
789            }
790
791            tracing::debug!(entity_count = count, tick, "Flushed world to SAB");
792            self.shared_world.commit_write(count as u32, tick);
793        }
794
795        #[wasm_bindgen]
796        pub fn playground_spawn(&mut self, entity_type: u16, x: f32, y: f32, rotation: f32) {
797            // Security: Prevent overflow in playground mode
798            if self.world_state.entities.len() >= MAX_ENTITIES {
799                tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
800                return;
801            }
802
803            // Sync ID generator if it's currently at default but world is seeded
804            if self.playground_next_network_id == 1 && !self.world_state.entities.is_empty() {
805                self.playground_next_network_id = self
806                    .world_state
807                    .entities
808                    .keys()
809                    .map(|k| k.0)
810                    .max()
811                    .unwrap_or(0)
812                    + 1;
813            }
814
815            let id = aetheris_protocol::types::NetworkId(self.playground_next_network_id);
816            self.playground_next_network_id += 1;
817            let slot = SabSlot {
818                network_id: id.0,
819                x,
820                y,
821                z: 0.0,
822                rotation,
823                dx: 0.0,
824                dy: 0.0,
825                dz: 0.0,
826                hp: 100,
827                shield: 100,
828                entity_type,
829                flags: 0x01, // ALIVE
830                mining_active: 0,
831                cargo_ore: 0,
832                mining_target_id: 0,
833            };
834            self.world_state.entities.insert(id, slot);
835        }
836
837        #[wasm_bindgen]
838        pub async fn playground_spawn_net(
839            &mut self,
840            entity_type: u16,
841            x: f32,
842            y: f32,
843            rot: f32,
844        ) -> Result<(), JsValue> {
845            self.check_worker();
846
847            if let Some(transport) = &self.transport {
848                let encoder = SerdeEncoder::new();
849                let event = NetworkEvent::Spawn {
850                    client_id: ClientId(0),
851                    entity_type,
852                    x,
853                    y,
854                    rot,
855                };
856
857                if let Ok(data) = encoder.encode_event(&event) {
858                    transport
859                        .send_reliable(ClientId(0), &data)
860                        .await
861                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
862                    tracing::info!(entity_type, x, y, "Sent Spawn command to server");
863                }
864            } else {
865                // Local fallback
866                self.playground_spawn(entity_type, x, y, rot);
867            }
868            Ok(())
869        }
870
871        #[wasm_bindgen]
872        pub fn playground_clear(&mut self) {
873            self.world_state.entities.clear();
874        }
875
876        /// Sends a movement/action input command to the server.
877        ///
878        /// The input is encoded as an unreliable component update (Kind 128)
879        /// and sent to the server for processing in the next tick.
880        #[wasm_bindgen]
881        pub async fn send_input(
882            &mut self,
883            tick: u64,
884            move_x: f32,
885            move_y: f32,
886            actions_mask: u32,
887            target_id: Option<u64>,
888        ) -> Result<(), JsValue> {
889            self.check_worker();
890
891            let transport = self.transport.as_ref().ok_or_else(|| {
892                JsValue::from_str("Cannot send input: transport not initialized or closed")
893            })?;
894
895            // 1. Prepare actions vector
896            let mut actions = Vec::new();
897
898            // Movement action
899            if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON {
900                actions.push(PlayerInputKind::Move {
901                    x: move_x,
902                    y: move_y,
903                });
904            }
905
906            // Bitmask actions (M1020 mapping)
907            // Bit 0: FirePrimary
908            if (actions_mask & 0x01) != 0 {
909                actions.push(PlayerInputKind::FirePrimary);
910            }
911            // Bit 1: ToggleMining
912            if (actions_mask & 0x02) != 0 {
913                if let Some(id) = target_id {
914                    actions.push(PlayerInputKind::ToggleMining {
915                        target: NetworkId(id),
916                    });
917                } else {
918                    tracing::warn!("ToggleMining requested without target_id; dropping action");
919                }
920            }
921
922            let cmd = InputCommand { tick, actions }.clamped();
923
924            // 2. Encode as a ComponentUpdate-compatible packet
925            // We use ComponentKind(128) as the convention for InputCommands.
926            // The server's TickScheduler will decode this as a standard game update.
927            let payload = rmp_serde::to_vec(&cmd)
928                .map_err(|e| JsValue::from_str(&format!("Failed to encode InputCommand: {e:?}")))?;
929
930            let update = ReplicationEvent {
931                network_id: NetworkId(0), // Client sends its own ID or 0 if unknown
932                component_kind: ComponentKind(128),
933                payload,
934                tick,
935            };
936
937            let mut buffer = [0u8; 1024];
938            let encoder = SerdeEncoder::new();
939            let len = encoder.encode(&update, &mut buffer).map_err(|e| {
940                JsValue::from_str(&format!("Failed to encode input replication event: {e:?}"))
941            })?;
942
943            // 3. Send via unreliable datagram
944            transport
945                .send_unreliable(ClientId(0), &buffer[..len])
946                .await
947                .map_err(|e| {
948                    JsValue::from_str(&format!("Transport error during send_input: {e:?}"))
949                })?;
950
951            Ok(())
952        }
953
954        #[wasm_bindgen]
955        pub async fn playground_clear_server(&mut self) -> Result<(), JsValue> {
956            self.check_worker();
957
958            if let Some(transport) = &self.transport {
959                let encoder = SerdeEncoder::new();
960                let event = NetworkEvent::ClearWorld {
961                    client_id: ClientId(0),
962                };
963                if let Ok(data) = encoder.encode_event(&event) {
964                    transport
965                        .send_reliable(ClientId(0), &data)
966                        .await
967                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
968                    tracing::info!("Sent ClearWorld command to server");
969                    self.world_state.entities.clear();
970                }
971            } else {
972                // No transport, clear immediately
973                self.world_state.entities.clear();
974            }
975            Ok(())
976        }
977
978        #[wasm_bindgen]
979        pub fn playground_set_rotation_enabled(&mut self, enabled: bool) {
980            self.playground_rotation_enabled = enabled;
981            // Note: Rotation toggle is currently local-authoritative in playground_tick,
982            // but for server-side replication it would need a network event.
983        }
984
985        #[wasm_bindgen]
986        pub async fn playground_stress_test(
987            &mut self,
988            count: u16,
989            rotate: bool,
990        ) -> Result<(), JsValue> {
991            self.check_worker();
992
993            if let Some(transport) = &self.transport {
994                let encoder = SerdeEncoder::new();
995                let event = NetworkEvent::StressTest {
996                    client_id: ClientId(0),
997                    count,
998                    rotate,
999                };
1000
1001                if let Ok(data) = encoder.encode_event(&event) {
1002                    transport
1003                        .send_reliable(ClientId(0), &data)
1004                        .await
1005                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1006                    tracing::info!(count, rotate, "Sent StressTest command to server");
1007                }
1008                self.playground_set_rotation_enabled(rotate);
1009            } else {
1010                // Fallback to local behavior if not connected
1011                self.playground_set_rotation_enabled(rotate);
1012                self.playground_clear();
1013                for _ in 0..count {
1014                    self.playground_spawn(1, 0.0, 0.0, 0.0); // Simple spawn
1015                }
1016            }
1017
1018            Ok(())
1019        }
1020
1021        #[wasm_bindgen]
1022        pub async fn tick_playground(&mut self) {
1023            self.check_worker();
1024
1025            // M10105 — emit tick_playground_loop_start lifecycle span
1026            if self.first_playground_tick {
1027                self.first_playground_tick = false;
1028                with_collector(|c| {
1029                    c.push_event(
1030                        1,
1031                        "wasm_client",
1032                        "Playground simulation loop started",
1033                        "tick_playground_loop_start",
1034                        None,
1035                    );
1036                });
1037            }
1038
1039            // M10105 — measure simulation time
1040            let sim_start = crate::performance_now();
1041            self.world_state.latest_tick += 1;
1042
1043            if self.playground_rotation_enabled {
1044                for slot in self.world_state.entities.values_mut() {
1045                    slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
1046                }
1047            }
1048
1049            // Sync to shared world
1050            let count = self.world_state.entities.len() as u32;
1051            self.flush_to_shared_world(self.world_state.latest_tick);
1052
1053            let sim_time_ms = crate::performance_now() - sim_start;
1054            with_collector(|c| {
1055                c.record_sim(sim_time_ms);
1056                c.update_entity_count(count);
1057            });
1058        }
1059
1060        /// Render frame called by the Render Worker.
1061        pub fn render(&mut self) -> f64 {
1062            self.check_worker();
1063
1064            let tick = self.shared_world.tick();
1065            let entities = self.shared_world.get_read_buffer();
1066
1067            // Periodic diagnostic log for render worker
1068            thread_local! {
1069                static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
1070            }
1071            FRAME_COUNT.with(|count| {
1072                let current = count.get();
1073                if current % 300 == 0 {
1074                    tracing::debug!(
1075                        "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
1076                        tick,
1077                        entities.len(),
1078                        self.snapshots.len(),
1079                    );
1080                }
1081                count.set(current + 1);
1082            });
1083
1084            if tick == 0 {
1085                // Background only or placeholder if no simulation is running yet
1086                let mut frame_time_ms = 0.0;
1087                if let Some(state) = &mut self.render_state {
1088                    frame_time_ms = state.render_frame_with_compact_slots(&[]);
1089                    with_collector(|c| {
1090                        // FPS is computed in the worker; we only report duration here.
1091                        // FPS=0 here as it is not authoritative.
1092                        c.record_frame(frame_time_ms, 0.0);
1093                    });
1094                }
1095                return frame_time_ms;
1096            }
1097
1098            // 1. Buffer new snapshots — only push when tick advances
1099            if self.snapshots.is_empty()
1100                || tick > self.snapshots.back().map(|s| s.tick).unwrap_or(0)
1101            {
1102                self.snapshots.push_back(SimulationSnapshot {
1103                    tick,
1104                    entities: entities.to_vec(),
1105                });
1106            }
1107
1108            // 2. Calculate target playback tick.
1109            // Stay 2 ticks behind latest so we always have an (s1, s2) interpolation pair.
1110            // This is rate-independent: works for both the 20 Hz server and the 60 Hz playground.
1111            let mut frame_time_ms = 0.0;
1112            if !self.snapshots.is_empty() {
1113                let latest_tick = self.snapshots.back().unwrap().tick as f32;
1114                let target_tick = latest_tick - 2.0;
1115                frame_time_ms = self.render_at_tick(target_tick);
1116            }
1117
1118            // M10105 — record accurate frame time (from WGPU) + snapshot depth.
1119            let snap_count = self.snapshots.len() as u32;
1120            with_collector(|c| {
1121                // FPS is computed in the worker; we only report duration here.
1122                c.record_frame(frame_time_ms, 0.0);
1123                c.update_snapshot_count(snap_count);
1124            });
1125
1126            frame_time_ms
1127        }
1128
1129        fn render_at_tick(&mut self, target_tick: f32) -> f64 {
1130            if self.snapshots.len() < 2 {
1131                // If we don't have enough snapshots for interpolation,
1132                // we still want to render the background or at least one frame.
1133                if let Some(state) = &mut self.render_state {
1134                    let entities = if !self.snapshots.is_empty() {
1135                        self.snapshots[0].entities.clone()
1136                    } else {
1137                        Vec::new()
1138                    };
1139                    return state.render_frame_with_compact_slots(&entities);
1140                }
1141                return 0.0;
1142            }
1143
1144            // Find snapshots S1, S2 such that S1.tick <= target_tick < S2.tick
1145            let mut s1_idx = 0;
1146            let mut found = false;
1147
1148            for i in 0..self.snapshots.len() - 1 {
1149                if (self.snapshots[i].tick as f32) <= target_tick
1150                    && (self.snapshots[i + 1].tick as f32) > target_tick
1151                {
1152                    s1_idx = i;
1153                    found = true;
1154                    break;
1155                }
1156            }
1157
1158            if !found {
1159                // If we are outside the buffer range, clamp to the nearest edge
1160                if target_tick < self.snapshots[0].tick as f32 {
1161                    s1_idx = 0;
1162                } else {
1163                    s1_idx = self.snapshots.len() - 2;
1164                }
1165            }
1166
1167            let s1 = self.snapshots.get(s1_idx).unwrap();
1168            let s2 = self.snapshots.get(s1_idx + 1).unwrap();
1169
1170            let tick_range = (s2.tick - s1.tick) as f32;
1171            let alpha = if tick_range > 0.0 {
1172                (target_tick - s1.tick as f32) / tick_range
1173            } else {
1174                1.0
1175            }
1176            .clamp(0.0, 1.0);
1177
1178            // Interpolate entities into a reusable buffer to avoid per-frame heap allocations
1179            self.render_buffer.clear();
1180            self.render_buffer.extend_from_slice(&s2.entities);
1181
1182            // Build a lookup map from the previous snapshot for O(1) access per entity.
1183            let prev_map: std::collections::HashMap<u64, &SabSlot> =
1184                s1.entities.iter().map(|e| (e.network_id, e)).collect();
1185
1186            for ent in &mut self.render_buffer {
1187                if let Some(prev) = prev_map.get(&ent.network_id).copied() {
1188                    ent.x = lerp(prev.x, ent.x, alpha);
1189                    ent.y = lerp(prev.y, ent.y, alpha);
1190                    ent.z = lerp(prev.z, ent.z, alpha);
1191                    ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
1192                }
1193            }
1194
1195            let mut frame_time = 0.0;
1196            if let Some(state) = &mut self.render_state {
1197                frame_time = state.render_frame_with_compact_slots(&self.render_buffer);
1198            }
1199
1200            // 3. Prune old snapshots.
1201            // We keep the oldest one that is still relevant for interpolation (index 0)
1202            // and everything newer. We prune snapshots that are entirely behind our window.
1203            while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
1204                self.snapshots.pop_front();
1205            }
1206
1207            // Safety cap: prevent unbounded growth if simulation stops but render continues
1208            while self.snapshots.len() > 16 {
1209                self.snapshots.pop_front();
1210            }
1211
1212            frame_time
1213        }
1214    }
1215
1216    fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
1217        a + (b - a) * alpha
1218    }
1219
1220    fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1221        // Simple rotation lerp for Phase 1.
1222        // Handles 2pi wraparound for smooth visuals.
1223        let mut diff = b - a;
1224        while diff < -std::f32::consts::PI {
1225            diff += std::f32::consts::TAU;
1226        }
1227        while diff > std::f32::consts::PI {
1228            diff -= std::f32::consts::TAU;
1229        }
1230        a + diff * alpha
1231    }
1232
1233    #[cfg(test)]
1234    mod tests {
1235        // use super::*;
1236        // use crate::transport_mock::MockTransport;
1237
1238        // #[tokio::test]
1239        // async fn test_transport_disconnection() {
1240        //     let mut client = AetherisClient::new(None).unwrap();
1241        //     let mock = MockTransport::new();
1242        //     client.transport = Some(Box::new(mock.clone()));
1243        //     client.connection_state = ConnectionState::InGame;
1244        //
1245        //     // Simulate disconnection
1246        //     mock.set_closed(true);
1247        //
1248        //     // One tick to detect
1249        //     client.tick().await;
1250
1251        //     assert_eq!(client.connection_state, ConnectionState::Disconnected);
1252        // }
1253    }
1254}