1#![warn(clippy::all, clippy::pedantic)]
7#![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
26pub 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#[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 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 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 js_sys::Date::now()
85 }
86 #[cfg(not(target_arch = "wasm32"))]
87 {
88 0.0
89 }
90}
91
92#[allow(dead_code)]
93pub(crate) fn get_worker_id() -> usize {
94 #[cfg(target_arch = "wasm32")]
95 {
96 WORKER_ID.with(|&id| id)
97 }
98 #[cfg(not(target_arch = "wasm32"))]
99 {
100 0
101 }
102}
103
104#[cfg(target_arch = "wasm32")]
105mod wasm_impl {
106 use crate::assets;
107 use crate::metrics::with_collector;
108 use crate::performance_now;
109 use crate::render::RenderState;
110 use crate::shared_world::{MAX_ENTITIES, SabSlot, SharedWorld};
111 use crate::transport::WebTransportBridge;
112 use crate::world_state::ClientWorld;
113 use aetheris_encoder_serde::SerdeEncoder;
114 use aetheris_protocol::events::{NetworkEvent, ReplicationEvent};
115 use aetheris_protocol::traits::{Encoder, GameTransport};
116 use aetheris_protocol::types::{
117 ClientId, ComponentKind, InputCommand, NetworkId, PlayerInputKind,
118 };
119 use wasm_bindgen::prelude::*;
120
121 #[wasm_bindgen]
122 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
123 pub enum ConnectionState {
124 Disconnected,
125 Connecting,
126 InGame,
127 Reconnecting,
128 Failed,
129 }
130
131 #[derive(Clone)]
133 pub struct SimulationSnapshot {
134 pub tick: u64,
135 pub entities: Vec<SabSlot>,
136 }
137
138 #[wasm_bindgen]
140 pub struct AetherisClient {
141 pub(crate) shared_world: SharedWorld,
142 pub(crate) world_state: ClientWorld,
143 pub(crate) render_state: Option<RenderState>,
144 pub(crate) transport: Option<Box<dyn GameTransport>>,
145 pub(crate) worker_id: usize,
146 pub(crate) session_token: Option<String>,
147
148 pub(crate) snapshots: std::collections::VecDeque<SimulationSnapshot>,
150
151 pub(crate) last_rtt_ms: f64,
153 ping_counter: u64,
154
155 reassembler: aetheris_protocol::Reassembler,
156 connection_state: ConnectionState,
157 reconnect_attempts: u32,
158 playground_rotation_enabled: bool,
159 playground_next_network_id: u64,
160 first_playground_tick: bool,
161
162 pub(crate) render_buffer: Vec<SabSlot>,
164
165 pub(crate) asset_registry: assets::AssetRegistry,
166 }
167
168 #[wasm_bindgen]
169 impl AetherisClient {
170 #[wasm_bindgen(constructor)]
173 pub fn new(shared_world_ptr: Option<u32>) -> Result<AetherisClient, JsValue> {
174 console_error_panic_hook::set_once();
175
176 use std::sync::atomic::{AtomicBool, Ordering};
179 static LOGGER_INITIALIZED: AtomicBool = AtomicBool::new(false);
180 if !LOGGER_INITIALIZED.swap(true, Ordering::SeqCst) {
181 let config = tracing_wasm::WASMLayerConfigBuilder::new()
182 .set_max_level(tracing::Level::INFO)
183 .build();
184 tracing_wasm::set_as_global_default_with_config(config);
185 }
186
187 let shared_world = if let Some(ptr_val) = shared_world_ptr {
188 let ptr = ptr_val as *mut u8;
189
190 if ptr_val == 0 || !ptr_val.is_multiple_of(8) {
192 return Err(JsValue::from_str(
193 "Invalid shared_world_ptr: null or unaligned",
194 ));
195 }
196
197 unsafe { SharedWorld::from_ptr(ptr) }
202 } else {
203 SharedWorld::new()
204 };
205
206 tracing::info!(
207 "AetherisClient initialized on worker {}",
208 crate::get_worker_id()
209 );
210
211 with_collector(|c| {
213 c.push_event(
214 1,
215 "wasm_client",
216 "AetherisClient initialized",
217 "wasm_init",
218 None,
219 );
220 });
221
222 Ok(Self {
223 shared_world,
224 world_state: ClientWorld::new(),
225 render_state: None,
226 transport: None,
227 worker_id: crate::get_worker_id(),
228 session_token: None,
229 snapshots: std::collections::VecDeque::with_capacity(8),
230 last_rtt_ms: 0.0,
231 ping_counter: 0,
232 reassembler: aetheris_protocol::Reassembler::new(),
233 connection_state: ConnectionState::Disconnected,
234 reconnect_attempts: 0,
235 playground_rotation_enabled: false,
236 playground_next_network_id: 1,
237 first_playground_tick: true,
238 render_buffer: Vec::with_capacity(crate::shared_world::MAX_ENTITIES),
239 asset_registry: assets::AssetRegistry::new(),
240 })
241 }
242
243 #[wasm_bindgen]
244 pub async fn request_otp(base_url: String, email: String) -> Result<String, String> {
245 crate::auth::request_otp(base_url, email).await
246 }
247
248 #[wasm_bindgen]
249 pub async fn login_with_otp(
250 base_url: String,
251 request_id: String,
252 code: String,
253 ) -> Result<String, String> {
254 crate::auth::login_with_otp(base_url, request_id, code).await
255 }
256
257 #[wasm_bindgen]
258 pub async fn logout(base_url: String, session_token: String) -> Result<(), String> {
259 crate::auth::logout(base_url, session_token).await
260 }
261
262 #[wasm_bindgen(getter)]
263 pub fn connection_state(&self) -> ConnectionState {
264 self.connection_state
265 }
266
267 fn check_worker(&self) {
268 debug_assert_eq!(
269 self.worker_id,
270 crate::get_worker_id(),
271 "AetherisClient accessed from wrong worker! It is pin-bound to its creating thread."
272 );
273 }
274
275 pub fn shared_world_ptr(&self) -> u32 {
277 self.shared_world.as_ptr() as u32
278 }
279
280 pub async fn connect(
281 &mut self,
282 url: String,
283 cert_hash: Option<Vec<u8>>,
284 ) -> Result<(), JsValue> {
285 self.check_worker();
286
287 if self.connection_state == ConnectionState::Connecting
288 || self.connection_state == ConnectionState::InGame
289 || self.connection_state == ConnectionState::Reconnecting
290 {
291 return Ok(());
292 }
293
294 if self.connection_state == ConnectionState::Failed && self.reconnect_attempts > 0 {
296 with_collector(|c| {
297 c.push_event(
298 2,
299 "transport",
300 "Triggering reconnection",
301 "reconnect_attempt",
302 None,
303 );
304 });
305 }
306
307 self.connection_state = ConnectionState::Connecting;
308 tracing::info!(url = %url, "Connecting to server...");
309
310 let transport_result = WebTransportBridge::connect(&url, cert_hash.as_deref()).await;
311
312 match transport_result {
313 Ok(transport) => {
314 if let Some(token) = &self.session_token {
316 let encoder = SerdeEncoder::new();
317 let auth_event = NetworkEvent::Auth {
318 session_token: token.clone(),
319 };
320
321 match encoder.encode_event(&auth_event) {
322 Ok(data) => {
323 if let Err(e) = transport.send_reliable(ClientId(0), &data).await {
324 self.connection_state = ConnectionState::Failed;
325 tracing::error!(error = ?e, "Handshake failed: could not send auth packet");
326 return Err(JsValue::from_str(&format!(
327 "Failed to send auth packet: {:?}",
328 e
329 )));
330 }
331 tracing::info!("Auth packet sent to server");
332 }
333 Err(e) => {
334 self.connection_state = ConnectionState::Failed;
335 tracing::error!(error = ?e, "Handshake failed: could not encode auth packet");
336 return Err(JsValue::from_str("Failed to encode auth packet"));
337 }
338 }
339 } else {
340 tracing::warn!(
341 "Connecting without session token! Server will likely discard data."
342 );
343 }
344
345 self.transport = Some(Box::new(transport));
346 self.connection_state = ConnectionState::InGame;
347 self.reconnect_attempts = 0;
348 tracing::info!("WebTransport connection established");
349 with_collector(|c| {
351 c.push_event(
352 1,
353 "transport",
354 &format!("WebTransport connected: {url}"),
355 "connect_handshake",
356 None,
357 );
358 });
359 Ok(())
360 }
361 Err(e) => {
362 self.connection_state = ConnectionState::Failed;
363 tracing::error!(error = ?e, "Failed to establish WebTransport connection");
364 with_collector(|c| {
366 c.push_event(
367 3,
368 "transport",
369 &format!("WebTransport failed: {url} — {e:?}"),
370 "connect_handshake_failed",
371 None,
372 );
373 });
374 Err(JsValue::from_str(&format!("failed to connect: {e:?}")))
375 }
376 }
377 }
378
379 #[wasm_bindgen]
380 pub async fn reconnect(
381 &mut self,
382 url: String,
383 cert_hash: Option<Vec<u8>>,
384 ) -> Result<(), JsValue> {
385 self.check_worker();
386 self.connection_state = ConnectionState::Reconnecting;
387 self.reconnect_attempts += 1;
388
389 tracing::info!(
390 "Attempting reconnection... (attempt {})",
391 self.reconnect_attempts
392 );
393
394 self.connect(url, cert_hash).await
395 }
396
397 #[wasm_bindgen]
398 pub async fn wasm_load_asset(
399 &mut self,
400 handle: assets::AssetHandle,
401 url: String,
402 ) -> Result<(), JsValue> {
403 self.asset_registry.load_asset(handle, &url).await
404 }
405
406 pub fn set_session_token(&mut self, token: String) {
408 self.session_token = Some(token);
409 }
410
411 pub async fn init_renderer(&mut self, canvas: JsValue) -> Result<(), JsValue> {
414 self.check_worker();
415 use wasm_bindgen::JsCast;
416
417 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
418 backends: wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL,
419 flags: wgpu::InstanceFlags::default(),
420 ..wgpu::InstanceDescriptor::new_without_display_handle()
421 });
422
423 let (surface_target, width, height) =
425 if let Ok(html_canvas) = canvas.clone().dyn_into::<web_sys::HtmlCanvasElement>() {
426 let width = html_canvas.width();
427 let height = html_canvas.height();
428 tracing::info!(
429 "Initializing renderer on HTMLCanvasElement ({}x{})",
430 width,
431 height
432 );
433 (wgpu::SurfaceTarget::Canvas(html_canvas), width, height)
434 } else if let Ok(offscreen_canvas) =
435 canvas.clone().dyn_into::<web_sys::OffscreenCanvas>()
436 {
437 let width = offscreen_canvas.width();
438 let height = offscreen_canvas.height();
439 tracing::info!(
440 "Initializing renderer on OffscreenCanvas ({}x{})",
441 width,
442 height
443 );
444
445 let _ = offscreen_canvas.get_context("webgpu").map_err(|e| {
448 JsValue::from_str(&format!("Failed to get webgpu context: {:?}", e))
449 })?;
450
451 (
452 wgpu::SurfaceTarget::OffscreenCanvas(offscreen_canvas),
453 width,
454 height,
455 )
456 } else {
457 return Err(JsValue::from_str(
458 "Aetheris: Provided object is not a valid Canvas or OffscreenCanvas",
459 ));
460 };
461
462 let surface = instance
463 .create_surface(surface_target)
464 .map_err(|e| JsValue::from_str(&format!("Failed to create surface: {:?}", e)))?;
465
466 let render_state = RenderState::new(&instance, surface, width, height)
467 .await
468 .map_err(|e| JsValue::from_str(&format!("Failed to init renderer: {:?}", e)))?;
469
470 self.render_state = Some(render_state);
471
472 with_collector(|c| {
474 c.push_event(
475 1,
476 "render_worker",
477 &format!("Renderer initialized ({}x{})", width, height),
478 "render_pipeline_setup",
479 None,
480 );
481 });
482
483 Ok(())
484 }
485
486 #[wasm_bindgen]
487 pub fn resize(&mut self, width: u32, height: u32) {
488 if let Some(state) = &mut self.render_state {
489 state.resize(width, height);
490 }
491 }
492
493 #[cfg(debug_assertions)]
494 #[wasm_bindgen]
495 pub fn set_debug_mode(&mut self, mode: u32) {
496 self.check_worker();
497 if let Some(state) = &mut self.render_state {
498 state.set_debug_mode(match mode {
499 0 => crate::render::DebugRenderMode::Off,
500 1 => crate::render::DebugRenderMode::Wireframe,
501 2 => crate::render::DebugRenderMode::Components,
502 _ => crate::render::DebugRenderMode::Full,
503 });
504 }
505 }
506
507 #[wasm_bindgen]
508 pub fn set_theme_colors(&mut self, bg_base: &str, text_primary: &str) {
509 self.check_worker();
510 let clear = crate::render::parse_css_color(bg_base);
511 let label = crate::render::parse_css_color(text_primary);
512
513 tracing::info!(
514 "Aetheris Client: Applying theme colors [bg: {} -> {:?}, text: {} -> {:?}]",
515 bg_base,
516 clear,
517 text_primary,
518 label
519 );
520
521 if let Some(state) = &mut self.render_state {
522 state.set_clear_color(clear);
523 #[cfg(debug_assertions)]
524 state.set_label_color([
525 label.r as f32,
526 label.g as f32,
527 label.b as f32,
528 label.a as f32,
529 ]);
530 }
531 }
532
533 #[cfg(debug_assertions)]
534 #[wasm_bindgen]
535 pub fn cycle_debug_mode(&mut self) {
536 if let Some(state) = &mut self.render_state {
537 state.cycle_debug_mode();
538 }
539 }
540
541 #[cfg(debug_assertions)]
542 #[wasm_bindgen]
543 pub fn toggle_grid(&mut self) {
544 if let Some(state) = &mut self.render_state {
545 state.toggle_grid();
546 }
547 }
548
549 pub async fn tick(&mut self) {
551 self.check_worker();
552 use aetheris_protocol::traits::{Encoder, WorldState};
553
554 let encoder = SerdeEncoder::new();
555
556 if let Some(_transport) = &self.transport {}
559
560 if let Some(transport) = &mut self.transport {
562 self.ping_counter = self.ping_counter.wrapping_add(1);
563 if self.ping_counter % 60 == 0 {
564 let now = performance_now();
566 let tick_u64 = now as u64;
567
568 if let Ok(data) = encoder.encode_event(&NetworkEvent::Ping {
569 client_id: ClientId(0), tick: tick_u64,
571 }) {
572 tracing::trace!(tick = tick_u64, "Sending Ping");
573 let _ = transport.send_unreliable(ClientId(0), &data).await;
574 }
575 }
576 }
577
578 if let Some(transport) = &mut self.transport {
580 let events = match transport.poll_events().await {
581 Ok(e) => e,
582 Err(e) => {
583 tracing::error!("Transport poll failure: {:?}", e);
584 return;
585 }
586 };
587 let mut updates: Vec<(ClientId, aetheris_protocol::events::ComponentUpdate)> =
588 Vec::new();
589
590 for event in events {
591 match event {
592 NetworkEvent::UnreliableMessage { data, client_id }
593 | NetworkEvent::ReliableMessage { data, client_id } => {
594 match encoder.decode(&data) {
595 Ok(update) => {
596 tracing::debug!(
597 network_id = update.network_id.0,
598 kind = update.component_kind.0,
599 tick = update.tick,
600 "Decoded component update"
601 );
602 updates.push((client_id, update));
603 }
604 Err(_) => {
605 if let Ok(event) = encoder.decode_event(&data) {
607 match event {
608 aetheris_protocol::events::NetworkEvent::GameEvent {
609 event:
610 aetheris_protocol::events::GameEvent::AsteroidDepleted {
611 network_id,
612 },
613 ..
614 } => {
615 tracing::info!(?network_id, "Asteroid depleted");
616 self.world_state.entities.remove(&network_id);
618
619 for slot in self.world_state.entities.values_mut() {
621 if (slot.flags & 0x04) != 0
623 && slot.mining_target_id == (network_id.0 as u16)
624 {
625 slot.mining_active = 0;
626 slot.mining_target_id = 0;
627 tracing::info!("Cleared local mining target due to depletion");
628 }
629 }
630 }
631 _ => {}
632 }
633 } else {
634 tracing::warn!(
635 "Failed to decode server message as update or wire event"
636 );
637 }
638 }
639 }
640 }
641 NetworkEvent::ClientConnected(id) => {
642 tracing::info!(?id, "Server connected");
643 }
644 NetworkEvent::ClientDisconnected(id) => {
645 tracing::warn!(?id, "Server disconnected");
646 }
647 NetworkEvent::Disconnected(_id) => {
648 tracing::error!("Transport disconnected locally");
649 self.connection_state = ConnectionState::Disconnected;
650 }
651 NetworkEvent::Ping { client_id: _, tick } => {
652 let pong = NetworkEvent::Pong { tick };
654 if let Ok(data) = encoder.encode_event(&pong) {
655 let _ = transport.send_reliable(ClientId(0), &data).await;
656 }
657 }
658 NetworkEvent::Pong { tick } => {
659 let now = performance_now();
661 let rtt = now - (tick as f64);
662 self.last_rtt_ms = rtt;
663
664 with_collector(|c| {
665 c.update_rtt(rtt);
666 });
667
668 #[cfg(feature = "metrics")]
669 metrics::gauge!("aetheris_client_rtt_ms").set(rtt);
670
671 tracing::trace!(rtt_ms = rtt, tick, "Received Pong / RTT update");
672 }
673 NetworkEvent::Auth { .. } => {
674 tracing::debug!("Received Auth event from server (unexpected)");
676 }
677 NetworkEvent::SessionClosed(id) => {
678 tracing::warn!(?id, "WebTransport session closed");
679 }
680 NetworkEvent::StreamReset(id) => {
681 tracing::error!(?id, "WebTransport stream reset");
682 }
683 NetworkEvent::Fragment {
684 client_id,
685 fragment,
686 } => {
687 if let Some(data) = self.reassembler.ingest(client_id, fragment) {
688 if let Ok(update) = encoder.decode(&data) {
689 updates.push((client_id, update));
690 }
691 }
692 }
693 NetworkEvent::StressTest { .. } => {
694 }
697 NetworkEvent::Spawn { .. } => {
698 }
700 NetworkEvent::ClearWorld { .. } => {
701 tracing::info!("Server initiated world clear");
702 self.world_state.entities.clear();
703 }
704 NetworkEvent::GameEvent { event, .. } => {
705 match event {
708 aetheris_protocol::events::GameEvent::AsteroidDepleted {
709 network_id,
710 } => {
711 tracing::info!(
712 ?network_id,
713 "Asteroid depleted (via GameEvent)"
714 );
715 self.world_state.entities.remove(&network_id);
717
718 for slot in self.world_state.entities.values_mut() {
720 if (slot.flags & 0x04) != 0
722 && slot.mining_target_id == (network_id.0 as u16)
723 {
724 slot.mining_active = 0;
725 slot.mining_target_id = 0;
726 tracing::info!(
727 "Cleared local mining target due to depletion"
728 );
729 }
730 }
731 }
732 #[allow(unreachable_patterns)]
733 _ => {
734 tracing::debug!("Unhandled inner GameEvent variant");
735 }
736 }
737 }
738 #[allow(unreachable_patterns)]
739 _ => {
740 tracing::debug!("Unhandled outer NetworkEvent variant");
741 }
742 }
743 }
744
745 if !updates.is_empty() {
747 tracing::debug!(count = updates.len(), "Applying server updates to world");
748 }
749 self.world_state.apply_updates(&updates);
750 }
751
752 if self.playground_rotation_enabled {
754 for slot in self.world_state.entities.values_mut() {
755 slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
756 }
757 }
758
759 self.world_state.latest_tick += 1;
762
763 let sim_start = crate::performance_now();
765
766 self.flush_to_shared_world(self.world_state.latest_tick);
768
769 let sim_time_ms = crate::performance_now() - sim_start;
770 let count = self.world_state.entities.len() as u32;
771 with_collector(|c| {
772 c.record_sim(sim_time_ms);
773 c.update_entity_count(count);
774 });
775 }
776
777 fn flush_to_shared_world(&mut self, tick: u64) {
778 let entities = &self.world_state.entities;
779 let write_buffer = self.shared_world.get_write_buffer();
780
781 let mut count = 0;
782 for (i, slot) in entities.values().enumerate() {
783 if i >= MAX_ENTITIES {
784 tracing::warn!("Max entities reached in shared world! Overflow suppressed.");
785 break;
786 }
787 write_buffer[i] = *slot;
788 count += 1;
789 }
790
791 tracing::debug!(entity_count = count, tick, "Flushed world to SAB");
792 self.shared_world.commit_write(count as u32, tick);
793 }
794
795 #[wasm_bindgen]
796 pub fn playground_spawn(&mut self, entity_type: u16, x: f32, y: f32, rotation: f32) {
797 if self.world_state.entities.len() >= MAX_ENTITIES {
799 tracing::warn!("playground_spawn: MAX_ENTITIES reached, spawn ignored.");
800 return;
801 }
802
803 if self.playground_next_network_id == 1 && !self.world_state.entities.is_empty() {
805 self.playground_next_network_id = self
806 .world_state
807 .entities
808 .keys()
809 .map(|k| k.0)
810 .max()
811 .unwrap_or(0)
812 + 1;
813 }
814
815 let id = aetheris_protocol::types::NetworkId(self.playground_next_network_id);
816 self.playground_next_network_id += 1;
817 let slot = SabSlot {
818 network_id: id.0,
819 x,
820 y,
821 z: 0.0,
822 rotation,
823 dx: 0.0,
824 dy: 0.0,
825 dz: 0.0,
826 hp: 100,
827 shield: 100,
828 entity_type,
829 flags: 0x01, mining_active: 0,
831 cargo_ore: 0,
832 mining_target_id: 0,
833 };
834 self.world_state.entities.insert(id, slot);
835 }
836
837 #[wasm_bindgen]
838 pub async fn playground_spawn_net(
839 &mut self,
840 entity_type: u16,
841 x: f32,
842 y: f32,
843 rot: f32,
844 ) -> Result<(), JsValue> {
845 self.check_worker();
846
847 if let Some(transport) = &self.transport {
848 let encoder = SerdeEncoder::new();
849 let event = NetworkEvent::Spawn {
850 client_id: ClientId(0),
851 entity_type,
852 x,
853 y,
854 rot,
855 };
856
857 if let Ok(data) = encoder.encode_event(&event) {
858 transport
859 .send_reliable(ClientId(0), &data)
860 .await
861 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
862 tracing::info!(entity_type, x, y, "Sent Spawn command to server");
863 }
864 } else {
865 self.playground_spawn(entity_type, x, y, rot);
867 }
868 Ok(())
869 }
870
871 #[wasm_bindgen]
872 pub fn playground_clear(&mut self) {
873 self.world_state.entities.clear();
874 }
875
876 #[wasm_bindgen]
881 pub async fn send_input(
882 &mut self,
883 tick: u64,
884 move_x: f32,
885 move_y: f32,
886 actions_mask: u32,
887 target_id: Option<u64>,
888 ) -> Result<(), JsValue> {
889 self.check_worker();
890
891 let transport = self.transport.as_ref().ok_or_else(|| {
892 JsValue::from_str("Cannot send input: transport not initialized or closed")
893 })?;
894
895 let mut actions = Vec::new();
897
898 if move_x.abs() > f32::EPSILON || move_y.abs() > f32::EPSILON {
900 actions.push(PlayerInputKind::Move {
901 x: move_x,
902 y: move_y,
903 });
904 }
905
906 if (actions_mask & 0x01) != 0 {
909 actions.push(PlayerInputKind::FirePrimary);
910 }
911 if (actions_mask & 0x02) != 0 {
913 if let Some(id) = target_id {
914 actions.push(PlayerInputKind::ToggleMining {
915 target: NetworkId(id),
916 });
917 } else {
918 tracing::warn!("ToggleMining requested without target_id; dropping action");
919 }
920 }
921
922 let cmd = InputCommand { tick, actions }.clamped();
923
924 let payload = rmp_serde::to_vec(&cmd)
928 .map_err(|e| JsValue::from_str(&format!("Failed to encode InputCommand: {e:?}")))?;
929
930 let update = ReplicationEvent {
931 network_id: NetworkId(0), component_kind: ComponentKind(128),
933 payload,
934 tick,
935 };
936
937 let mut buffer = [0u8; 1024];
938 let encoder = SerdeEncoder::new();
939 let len = encoder.encode(&update, &mut buffer).map_err(|e| {
940 JsValue::from_str(&format!("Failed to encode input replication event: {e:?}"))
941 })?;
942
943 transport
945 .send_unreliable(ClientId(0), &buffer[..len])
946 .await
947 .map_err(|e| {
948 JsValue::from_str(&format!("Transport error during send_input: {e:?}"))
949 })?;
950
951 Ok(())
952 }
953
954 #[wasm_bindgen]
955 pub async fn playground_clear_server(&mut self) -> Result<(), JsValue> {
956 self.check_worker();
957
958 if let Some(transport) = &self.transport {
959 let encoder = SerdeEncoder::new();
960 let event = NetworkEvent::ClearWorld {
961 client_id: ClientId(0),
962 };
963 if let Ok(data) = encoder.encode_event(&event) {
964 transport
965 .send_reliable(ClientId(0), &data)
966 .await
967 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
968 tracing::info!("Sent ClearWorld command to server");
969 self.world_state.entities.clear();
970 }
971 } else {
972 self.world_state.entities.clear();
974 }
975 Ok(())
976 }
977
978 #[wasm_bindgen]
979 pub fn playground_set_rotation_enabled(&mut self, enabled: bool) {
980 self.playground_rotation_enabled = enabled;
981 }
984
985 #[wasm_bindgen]
986 pub async fn playground_stress_test(
987 &mut self,
988 count: u16,
989 rotate: bool,
990 ) -> Result<(), JsValue> {
991 self.check_worker();
992
993 if let Some(transport) = &self.transport {
994 let encoder = SerdeEncoder::new();
995 let event = NetworkEvent::StressTest {
996 client_id: ClientId(0),
997 count,
998 rotate,
999 };
1000
1001 if let Ok(data) = encoder.encode_event(&event) {
1002 transport
1003 .send_reliable(ClientId(0), &data)
1004 .await
1005 .map_err(|e| JsValue::from_str(&format!("{e:?}")))?;
1006 tracing::info!(count, rotate, "Sent StressTest command to server");
1007 }
1008 self.playground_set_rotation_enabled(rotate);
1009 } else {
1010 self.playground_set_rotation_enabled(rotate);
1012 self.playground_clear();
1013 for _ in 0..count {
1014 self.playground_spawn(1, 0.0, 0.0, 0.0); }
1016 }
1017
1018 Ok(())
1019 }
1020
1021 #[wasm_bindgen]
1022 pub async fn tick_playground(&mut self) {
1023 self.check_worker();
1024
1025 if self.first_playground_tick {
1027 self.first_playground_tick = false;
1028 with_collector(|c| {
1029 c.push_event(
1030 1,
1031 "wasm_client",
1032 "Playground simulation loop started",
1033 "tick_playground_loop_start",
1034 None,
1035 );
1036 });
1037 }
1038
1039 let sim_start = crate::performance_now();
1041 self.world_state.latest_tick += 1;
1042
1043 if self.playground_rotation_enabled {
1044 for slot in self.world_state.entities.values_mut() {
1045 slot.rotation = (slot.rotation + 0.05) % std::f32::consts::TAU;
1046 }
1047 }
1048
1049 let count = self.world_state.entities.len() as u32;
1051 self.flush_to_shared_world(self.world_state.latest_tick);
1052
1053 let sim_time_ms = crate::performance_now() - sim_start;
1054 with_collector(|c| {
1055 c.record_sim(sim_time_ms);
1056 c.update_entity_count(count);
1057 });
1058 }
1059
1060 pub fn render(&mut self) -> f64 {
1062 self.check_worker();
1063
1064 let tick = self.shared_world.tick();
1065 let entities = self.shared_world.get_read_buffer();
1066
1067 thread_local! {
1069 static FRAME_COUNT: core::cell::Cell<u64> = core::cell::Cell::new(0);
1070 }
1071 FRAME_COUNT.with(|count| {
1072 let current = count.get();
1073 if current % 300 == 0 {
1074 tracing::debug!(
1075 "Aetheris Render Stats: Tick={}, Entities={}, Snapshots={}",
1076 tick,
1077 entities.len(),
1078 self.snapshots.len(),
1079 );
1080 }
1081 count.set(current + 1);
1082 });
1083
1084 if tick == 0 {
1085 let mut frame_time_ms = 0.0;
1087 if let Some(state) = &mut self.render_state {
1088 frame_time_ms = state.render_frame_with_compact_slots(&[]);
1089 with_collector(|c| {
1090 c.record_frame(frame_time_ms, 0.0);
1093 });
1094 }
1095 return frame_time_ms;
1096 }
1097
1098 if self.snapshots.is_empty()
1100 || tick > self.snapshots.back().map(|s| s.tick).unwrap_or(0)
1101 {
1102 self.snapshots.push_back(SimulationSnapshot {
1103 tick,
1104 entities: entities.to_vec(),
1105 });
1106 }
1107
1108 let mut frame_time_ms = 0.0;
1112 if !self.snapshots.is_empty() {
1113 let latest_tick = self.snapshots.back().unwrap().tick as f32;
1114 let target_tick = latest_tick - 2.0;
1115 frame_time_ms = self.render_at_tick(target_tick);
1116 }
1117
1118 let snap_count = self.snapshots.len() as u32;
1120 with_collector(|c| {
1121 c.record_frame(frame_time_ms, 0.0);
1123 c.update_snapshot_count(snap_count);
1124 });
1125
1126 frame_time_ms
1127 }
1128
1129 fn render_at_tick(&mut self, target_tick: f32) -> f64 {
1130 if self.snapshots.len() < 2 {
1131 if let Some(state) = &mut self.render_state {
1134 let entities = if !self.snapshots.is_empty() {
1135 self.snapshots[0].entities.clone()
1136 } else {
1137 Vec::new()
1138 };
1139 return state.render_frame_with_compact_slots(&entities);
1140 }
1141 return 0.0;
1142 }
1143
1144 let mut s1_idx = 0;
1146 let mut found = false;
1147
1148 for i in 0..self.snapshots.len() - 1 {
1149 if (self.snapshots[i].tick as f32) <= target_tick
1150 && (self.snapshots[i + 1].tick as f32) > target_tick
1151 {
1152 s1_idx = i;
1153 found = true;
1154 break;
1155 }
1156 }
1157
1158 if !found {
1159 if target_tick < self.snapshots[0].tick as f32 {
1161 s1_idx = 0;
1162 } else {
1163 s1_idx = self.snapshots.len() - 2;
1164 }
1165 }
1166
1167 let s1 = self.snapshots.get(s1_idx).unwrap();
1168 let s2 = self.snapshots.get(s1_idx + 1).unwrap();
1169
1170 let tick_range = (s2.tick - s1.tick) as f32;
1171 let alpha = if tick_range > 0.0 {
1172 (target_tick - s1.tick as f32) / tick_range
1173 } else {
1174 1.0
1175 }
1176 .clamp(0.0, 1.0);
1177
1178 self.render_buffer.clear();
1180 self.render_buffer.extend_from_slice(&s2.entities);
1181
1182 let prev_map: std::collections::HashMap<u64, &SabSlot> =
1184 s1.entities.iter().map(|e| (e.network_id, e)).collect();
1185
1186 for ent in &mut self.render_buffer {
1187 if let Some(prev) = prev_map.get(&ent.network_id).copied() {
1188 ent.x = lerp(prev.x, ent.x, alpha);
1189 ent.y = lerp(prev.y, ent.y, alpha);
1190 ent.z = lerp(prev.z, ent.z, alpha);
1191 ent.rotation = lerp_rotation(prev.rotation, ent.rotation, alpha);
1192 }
1193 }
1194
1195 let mut frame_time = 0.0;
1196 if let Some(state) = &mut self.render_state {
1197 frame_time = state.render_frame_with_compact_slots(&self.render_buffer);
1198 }
1199
1200 while self.snapshots.len() > 2 && (self.snapshots[0].tick as f32) < target_tick - 1.0 {
1204 self.snapshots.pop_front();
1205 }
1206
1207 while self.snapshots.len() > 16 {
1209 self.snapshots.pop_front();
1210 }
1211
1212 frame_time
1213 }
1214 }
1215
1216 fn lerp(a: f32, b: f32, alpha: f32) -> f32 {
1217 a + (b - a) * alpha
1218 }
1219
1220 fn lerp_rotation(a: f32, b: f32, alpha: f32) -> f32 {
1221 let mut diff = b - a;
1224 while diff < -std::f32::consts::PI {
1225 diff += std::f32::consts::TAU;
1226 }
1227 while diff > std::f32::consts::PI {
1228 diff -= std::f32::consts::TAU;
1229 }
1230 a + diff * alpha
1231 }
1232
1233 #[cfg(test)]
1234 mod tests {
1235 }
1254}