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