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;
115    use aetheris_protocol::traits::GameTransport;
116    use aetheris_protocol::types::ClientId;
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        shared_world: SharedWorld,
140        world_state: ClientWorld,
141        render_state: Option<RenderState>,
142        transport: Option<Box<dyn GameTransport>>,
143        worker_id: usize,
144        session_token: Option<String>,
145
146        // Interpolation state (Render Worker only)
147        snapshots: std::collections::VecDeque<SimulationSnapshot>,
148
149        // Network metrics
150        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        render_buffer: Vec<SabSlot>,
162
163        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                        web_sys::console::debug_1(
571                            &format!("[Aetheris] Sending Ping: tick={tick_u64}").into(),
572                        );
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                            if let Ok(update) = encoder.decode(&data) {
595                                updates.push((client_id, update));
596                            }
597                        }
598                        NetworkEvent::ClientConnected(id) => {
599                            tracing::info!(?id, "Server connected");
600                        }
601                        NetworkEvent::ClientDisconnected(id) => {
602                            tracing::warn!(?id, "Server disconnected");
603                        }
604                        NetworkEvent::Disconnected(_id) => {
605                            tracing::error!("Transport disconnected locally");
606                            self.connection_state = ConnectionState::Disconnected;
607                        }
608                        NetworkEvent::Ping { client_id: _, tick } => {
609                            // Immediately reflect the ping as a pong with same tick
610                            let pong = NetworkEvent::Pong { tick };
611                            if let Ok(data) = encoder.encode_event(&pong) {
612                                let _ = transport.send_reliable(ClientId(0), &data).await;
613                            }
614                        }
615                        NetworkEvent::Pong { tick } => {
616                            // Calculate RTT from our own outgoing pings
617                            let now = performance_now();
618                            let rtt = now - (tick as f64);
619                            self.last_rtt_ms = rtt;
620
621                            with_collector(|c| {
622                                c.update_rtt(rtt);
623                            });
624
625                            #[cfg(feature = "metrics")]
626                            metrics::gauge!("aetheris_client_rtt_ms").set(rtt);
627
628                            web_sys::console::debug_1(
629                                &format!("[Aetheris] Received Pong: tick={tick} RTT={rtt:.1}ms")
630                                    .into(),
631                            );
632                            tracing::debug!(rtt_ms = rtt, "RTT Update");
633                        }
634                        NetworkEvent::Auth { .. } => {
635                            // Client initiated event usually, ignore if received from server
636                            tracing::debug!("Received Auth event from server (unexpected)");
637                        }
638                        NetworkEvent::SessionClosed(id) => {
639                            tracing::warn!(?id, "WebTransport session closed");
640                        }
641                        NetworkEvent::StreamReset(id) => {
642                            tracing::error!(?id, "WebTransport stream reset");
643                        }
644                        NetworkEvent::Fragment {
645                            client_id,
646                            fragment,
647                        } => {
648                            if let Some(data) = self.reassembler.ingest(client_id, fragment) {
649                                if let Ok(update) = encoder.decode(&data) {
650                                    updates.push((client_id, update));
651                                }
652                            }
653                        }
654                        NetworkEvent::StressTest { .. } => {
655                            // Client-side, we don't handle incoming stress test events usually,
656                            // they are processed by the server.
657                        }
658                        NetworkEvent::Spawn { .. } => {
659                            // Handled by GameWorker via p_spawn
660                        }
661                        NetworkEvent::ClearWorld { .. } => {
662                            tracing::info!("Server initiated world clear");
663                            self.world_state.entities.clear();
664                        }
665                    }
666                }
667
668                // 2. Apply updates to the Simulation World
669                self.world_state.apply_updates(&updates);
670            }
671
672            // 2.5. Client-side rotation animation (local, not replicated by server)
673            if self.playground_rotation_enabled {
674                for slot in self.world_state.entities.values_mut() {
675                    slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
676                }
677            }
678
679            // Advance local tick every frame so the render worker always sees a new snapshot,
680            // even when the server sends no updates (static entities, client-side animation).
681            self.world_state.latest_tick += 1;
682
683            // 3. Write Authoritative Snapshot to Shared World for the Render Worker
684            self.flush_to_shared_world(self.world_state.latest_tick);
685        }
686
687        fn flush_to_shared_world(&mut self, tick: u64) {
688            let entities = &self.world_state.entities;
689            let write_buffer = self.shared_world.get_write_buffer();
690
691            let mut count = 0;
692            for (i, slot) in entities.values().enumerate() {
693                if i >= MAX_ENTITIES {
694                    tracing::warn!("Max entities reached in shared world! Overflow suppressed.");
695                    break;
696                }
697                write_buffer[i] = *slot;
698                count += 1;
699            }
700
701            self.shared_world.commit_write(count as u32, tick);
702        }
703
704        #[wasm_bindgen]
705        pub fn playground_spawn(&mut self, entity_type: u16, x: f32, y: f32, rotation: f32) {
706            // Security: Prevent overflow in playground mode
707            if self.world_state.entities.len() >= MAX_ENTITIES {
708                tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
709                return;
710            }
711
712            // Sync ID generator if it's currently at default but world is seeded
713            if self.playground_next_network_id == 1 && !self.world_state.entities.is_empty() {
714                self.playground_next_network_id = self
715                    .world_state
716                    .entities
717                    .keys()
718                    .map(|k| k.0)
719                    .max()
720                    .unwrap_or(0)
721                    + 1;
722            }
723
724            let id = aetheris_protocol::types::NetworkId(self.playground_next_network_id);
725            self.playground_next_network_id += 1;
726            let slot = SabSlot {
727                network_id: id.0,
728                x,
729                y,
730                z: 0.0,
731                rotation,
732                dx: 0.0,
733                dy: 0.0,
734                dz: 0.0,
735                hp: 100,
736                shield: 100,
737                entity_type,
738                flags: 0x01, // ALIVE
739                padding: [0; 5],
740            };
741            self.world_state.entities.insert(id, slot);
742        }
743
744        #[wasm_bindgen]
745        pub async fn playground_spawn_net(
746            &mut self,
747            entity_type: u16,
748            x: f32,
749            y: f32,
750            rot: f32,
751        ) -> Result<(), JsValue> {
752            self.check_worker();
753
754            if let Some(transport) = &self.transport {
755                let encoder = SerdeEncoder::new();
756                let event = NetworkEvent::Spawn {
757                    client_id: ClientId(0),
758                    entity_type,
759                    x,
760                    y,
761                    rot,
762                };
763
764                if let Ok(data) = encoder.encode_event(&event) {
765                    transport
766                        .send_reliable(ClientId(0), &data)
767                        .await
768                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
769                    tracing::info!(entity_type, x, y, "Sent Spawn command to server");
770                }
771            } else {
772                // Local fallback
773                self.playground_spawn(entity_type, x, y, rot);
774            }
775            Ok(())
776        }
777
778        #[wasm_bindgen]
779        pub fn playground_clear(&mut self) {
780            self.world_state.entities.clear();
781        }
782
783        #[wasm_bindgen]
784        pub async fn playground_clear_server(&mut self) -> Result<(), JsValue> {
785            self.check_worker();
786
787            if let Some(transport) = &self.transport {
788                let encoder = SerdeEncoder::new();
789                let event = NetworkEvent::ClearWorld {
790                    client_id: ClientId(0),
791                };
792                if let Ok(data) = encoder.encode_event(&event) {
793                    transport
794                        .send_reliable(ClientId(0), &data)
795                        .await
796                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
797                    tracing::info!("Sent ClearWorld command to server");
798                    self.world_state.entities.clear();
799                }
800            } else {
801                // No transport, clear immediately
802                self.world_state.entities.clear();
803            }
804            Ok(())
805        }
806
807        #[wasm_bindgen]
808        pub fn playground_set_rotation_enabled(&mut self, enabled: bool) {
809            self.playground_rotation_enabled = enabled;
810            // Note: Rotation toggle is currently local-authoritative in playground_tick,
811            // but for server-side replication it would need a network event.
812        }
813
814        #[wasm_bindgen]
815        pub async fn playground_stress_test(
816            &mut self,
817            count: u16,
818            rotate: bool,
819        ) -> Result<(), JsValue> {
820            self.check_worker();
821
822            if let Some(transport) = &self.transport {
823                let encoder = SerdeEncoder::new();
824                let event = NetworkEvent::StressTest {
825                    client_id: ClientId(0),
826                    count,
827                    rotate,
828                };
829
830                if let Ok(data) = encoder.encode_event(&event) {
831                    transport
832                        .send_reliable(ClientId(0), &data)
833                        .await
834                        .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
835                    tracing::info!(count, rotate, "Sent StressTest command to server");
836                }
837                self.playground_set_rotation_enabled(rotate);
838            } else {
839                // Fallback to local behavior if not connected
840                self.playground_set_rotation_enabled(rotate);
841                self.playground_clear();
842                for _ in 0..count {
843                    self.playground_spawn(1, 0.0, 0.0, 0.0); // Simple spawn
844                }
845            }
846
847            Ok(())
848        }
849
850        #[wasm_bindgen]
851        pub async fn tick_playground(&mut self) {
852            self.check_worker();
853
854            // M10105 — emit tick_playground_loop_start lifecycle span
855            if self.first_playground_tick {
856                self.first_playground_tick = false;
857                with_collector(|c| {
858                    c.push_event(
859                        1,
860                        "wasm_client",
861                        "Playground simulation loop started",
862                        "tick_playground_loop_start",
863                        None,
864                    );
865                });
866            }
867
868            // M10105 — measure simulation time
869            let sim_start = crate::performance_now();
870            self.world_state.latest_tick += 1;
871
872            if self.playground_rotation_enabled {
873                for slot in self.world_state.entities.values_mut() {
874                    slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
875                }
876            }
877
878            // Sync to shared world
879            let count = self.world_state.entities.len() as u32;
880            self.flush_to_shared_world(self.world_state.latest_tick);
881
882            let sim_time_ms = crate::performance_now() - sim_start;
883            with_collector(|c| {
884                c.record_sim(sim_time_ms);
885                c.update_entity_count(count);
886            });
887        }
888
889        /// Render frame called by the Render Worker.
890        pub fn render(&mut self) -> f64 {
891            self.check_worker();
892
893            let tick = self.shared_world.tick();
894            let entities = self.shared_world.get_read_buffer();
895
896            // Periodic diagnostic log for render worker
897            thread_local! {
898                static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
899            }
900            FRAME_COUNT.with(|count| {
901                let current = count.get();
902                if current % 300 == 0 {
903                    tracing::debug!(
904                        "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
905                        tick,
906                        entities.len(),
907                        self.snapshots.len(),
908                    );
909                }
910                count.set(current + 1);
911            });
912
913            if tick == 0 {
914                // Background only or placeholder if no simulation is running yet
915                let mut frame_time_ms = 0.0;
916                if let Some(state) = &mut self.render_state {
917                    frame_time_ms = state.render_frame_with_compact_slots(&[]);
918                    with_collector(|c| {
919                        // FPS is computed in the worker; we only report duration here.
920                        // FPS=0 here as it is not authoritative.
921                        c.record_frame(frame_time_ms, 0.0);
922                    });
923                }
924                return frame_time_ms;
925            }
926
927            // 1. Buffer new snapshots — only push when tick advances
928            if self.snapshots.is_empty()
929                || tick > self.snapshots.back().map(|s| s.tick).unwrap_or(0)
930            {
931                self.snapshots.push_back(SimulationSnapshot {
932                    tick,
933                    entities: entities.to_vec(),
934                });
935            }
936
937            // 2. Calculate target playback tick.
938            // Stay 2 ticks behind latest so we always have an (s1, s2) interpolation pair.
939            // This is rate-independent: works for both the 20 Hz server and the 60 Hz playground.
940            let mut frame_time_ms = 0.0;
941            if !self.snapshots.is_empty() {
942                let latest_tick = self.snapshots.back().unwrap().tick as f32;
943                let target_tick = latest_tick - 2.0;
944                frame_time_ms = self.render_at_tick(target_tick);
945            }
946
947            // M10105 — record accurate frame time (from WGPU) + snapshot depth.
948            let snap_count = self.snapshots.len() as u32;
949            with_collector(|c| {
950                // FPS is computed in the worker; we only report duration here.
951                c.record_frame(frame_time_ms, 0.0);
952                c.update_snapshot_count(snap_count);
953            });
954
955            frame_time_ms
956        }
957
958        fn render_at_tick(&mut self, target_tick: f32) -> f64 {
959            if self.snapshots.len() < 2 {
960                // If we don't have enough snapshots for interpolation,
961                // we still want to render the background or at least one frame.
962                if let Some(state) = &mut self.render_state {
963                    let entities = if !self.snapshots.is_empty() {
964                        self.snapshots[0].entities.clone()
965                    } else {
966                        Vec::new()
967                    };
968                    return state.render_frame_with_compact_slots(&entities);
969                }
970                return 0.0;
971            }
972
973            // Find snapshots S1, S2 such that S1.tick <= target_tick < S2.tick
974            let mut s1_idx = 0;
975            let mut found = false;
976
977            for i in 0..self.snapshots.len() - 1 {
978                if (self.snapshots[i].tick as f32) <= target_tick
979                    && (self.snapshots[i + 1].tick as f32) > target_tick
980                {
981                    s1_idx = i;
982                    found = true;
983                    break;
984                }
985            }
986
987            if !found {
988                // If we are outside the buffer range, clamp to the nearest edge
989                if target_tick < self.snapshots[0].tick as f32 {
990                    s1_idx = 0;
991                } else {
992                    s1_idx = self.snapshots.len() - 2;
993                }
994            }
995
996            let s1 = self.snapshots.get(s1_idx).unwrap();
997            let s2 = self.snapshots.get(s1_idx + 1).unwrap();
998
999            let tick_range = (s2.tick - s1.tick) as f32;
1000            let alpha = if tick_range > 0.0 {
1001                (target_tick - s1.tick as f32) / tick_range
1002            } else {
1003                1.0
1004            }
1005            .clamp(0.0, 1.0);
1006
1007            // Interpolate entities into a reusable buffer to avoid per-frame heap allocations
1008            self.render_buffer.clear();
1009            self.render_buffer.extend_from_slice(&s2.entities);
1010
1011            // Build a lookup map from the previous snapshot for O(1) access per entity.
1012            let prev_map: std::collections::HashMap<u64, &SabSlot> =
1013                s1.entities.iter().map(|e| (e.network_id, e)).collect();
1014
1015            for ent in &mut self.render_buffer {
1016                if let Some(prev) = prev_map.get(&ent.network_id).copied() {
1017                    ent.x = lerp(prev.x, ent.x, alpha);
1018                    ent.y = lerp(prev.y, ent.y, alpha);
1019                    ent.z = lerp(prev.z, ent.z, alpha);
1020                    ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
1021                }
1022            }
1023
1024            let mut frame_time = 0.0;
1025            if let Some(state) = &mut self.render_state {
1026                frame_time = state.render_frame_with_compact_slots(&self.render_buffer);
1027            }
1028
1029            // 3. Prune old snapshots.
1030            // We keep the oldest one that is still relevant for interpolation (index 0)
1031            // and everything newer. We prune snapshots that are entirely behind our window.
1032            while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
1033                self.snapshots.pop_front();
1034            }
1035
1036            // Safety cap: prevent unbounded growth if simulation stops but render continues
1037            while self.snapshots.len() > 16 {
1038                self.snapshots.pop_front();
1039            }
1040
1041            frame_time
1042        }
1043    }
1044
1045    fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
1046        a + (b - a) * alpha
1047    }
1048
1049    fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1050        // Simple rotation lerp for Phase 1.
1051        // Handles 2pi wraparound for smooth visuals.
1052        let mut diff = b - a;
1053        while diff < -std::f32::consts::PI {
1054            diff += std::f32::consts::TAU;
1055        }
1056        while diff > std::f32::consts::PI {
1057            diff -= std::f32::consts::TAU;
1058        }
1059        a + diff * alpha
1060    }
1061
1062    #[cfg(test)]
1063    mod tests {
1064        use super::*;
1065        use crate::transport_mock::MockTransport;
1066
1067        #[tokio::test]
1068        async fn test_transport_disconnection() {
1069            let mut client = AetherisClient::new(None).unwrap();
1070            let mock = MockTransport::new();
1071            client.transport = Some(Box::new(mock.clone()));
1072            client.connection_state = ConnectionState::InGame;
1073
1074            // Simulate disconnection
1075            mock.set_closed(true);
1076
1077            // One tick to detect
1078            client.tick().await;
1079
1080            assert_eq!(client.connection_state, ConnectionState::Disconnected);
1081        }
1082    }
1083}