1use crate::shared_world::SabSlot;
7use aetheris_protocol::error::WorldError;
8use aetheris_protocol::events::{ComponentUpdate, ReplicationEvent};
9use aetheris_protocol::traits::WorldState;
10use aetheris_protocol::types::{
11 ClientId, ComponentKind, LocalId, NetworkId, ShipClass, ShipStats, Transform,
12};
13use std::collections::{BTreeMap, VecDeque};
14
15#[derive(Clone, Copy, Debug)]
16pub struct InputRecord {
17 pub tick: u64,
18 pub move_x: f32,
19 pub move_y: f32,
20 pub actions_mask: u8,
21}
22
23#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, Default)]
24pub struct Velocity {
25 pub dx: f32,
26 pub dy: f32,
27 pub dz: f32,
28}
29
30#[derive(Debug)]
32pub struct ClientWorld {
33 pub entities: BTreeMap<NetworkId, SabSlot>,
35 pub player_network_id: Option<NetworkId>,
37 pub latest_tick: u64,
39 pub system_manifest: BTreeMap<String, String>,
41 pub shared_world_ref: Option<usize>,
43 pub input_history: VecDeque<InputRecord>,
45 pub last_reconciled_tick: u64,
47 pub prediction_enabled: bool,
52 pub room_bounds: Option<aetheris_protocol::types::RoomBounds>,
55}
56
57impl Default for ClientWorld {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63impl ClientWorld {
64 #[must_use]
70 pub fn new() -> Self {
71 Self::with_prediction(false)
72 }
73
74 #[must_use]
76 pub fn with_prediction(prediction_enabled: bool) -> Self {
77 Self {
78 entities: BTreeMap::new(),
79 player_network_id: None,
80 latest_tick: 0,
81 system_manifest: BTreeMap::new(),
82 shared_world_ref: None,
83 input_history: VecDeque::with_capacity(120), last_reconciled_tick: 0,
85 prediction_enabled,
86 room_bounds: if prediction_enabled {
87 Some(aetheris_protocol::types::RoomBounds {
88 min_x: -250.0,
89 min_y: -250.0,
90 max_x: 250.0,
91 max_y: 250.0,
92 })
93 } else {
94 None
95 },
96 }
97 }
98}
99
100impl WorldState for ClientWorld {
101 fn get_local_id(&self, network_id: NetworkId) -> Option<LocalId> {
102 Some(LocalId(network_id.0))
103 }
104
105 fn get_network_id(&self, local_id: LocalId) -> Option<NetworkId> {
106 Some(NetworkId(local_id.0))
107 }
108
109 fn extract_deltas(&mut self) -> Vec<ReplicationEvent> {
110 Vec::new()
111 }
112
113 fn apply_updates(&mut self, updates: &[(ClientId, ComponentUpdate)]) {
114 if !updates.is_empty() {
115 tracing::debug!(
116 count = updates.len(),
117 player_network_id = ?self.player_network_id,
118 total_entities = self.entities.len(),
119 "[apply_updates] Processing updates batch"
120 );
121 }
122 for (_, update) in updates {
123 if update.tick > self.latest_tick {
124 self.latest_tick = update.tick;
125 }
126
127 let is_new = !self.entities.contains_key(&update.network_id);
128
129 let entry = self.entities.entry(update.network_id).or_insert_with(|| {
131 tracing::trace!(
132 network_id = update.network_id.0,
133 kind = update.component_kind.0,
134 player_network_id = ?self.player_network_id,
135 "[apply_updates] NEW entity from server"
136 );
137 SabSlot {
138 network_id: update.network_id.0,
139 x: 0.0,
140 y: 0.0,
141 z: 0.0,
142 rotation: 0.0,
143 dx: 0.0,
144 dy: 0.0,
145 dz: 0.0,
146 hp: 100,
147 shield: 0,
148 entity_type: 0,
149 flags: 1,
150 cargo_ore: 0,
151 mining_target_id: 0,
152 mining_active: 0,
153 }
154 });
155
156 let is_player = Some(update.network_id) == self.player_network_id;
159 if is_player {
160 tracing::trace!(
161 network_id = update.network_id.0,
162 is_new,
163 "[apply_updates] Setting 0x04 (LocalPlayer) flag on entity"
164 );
165 entry.flags |= 0x04;
166 } else if is_new {
167 tracing::trace!(
168 network_id = update.network_id.0,
169 player_network_id = ?self.player_network_id,
170 flags = entry.flags,
171 "[apply_updates] New NON-player entity - no possession flag"
172 );
173 }
174
175 self.apply_component_update(update);
176 }
177 }
178
179 fn simulate(&mut self) {
180 const DRAG: f32 = 1.0;
181 const DT: f32 = 1.0 / 60.0;
182 let drag_factor = 1.0 / (1.0 + DRAG * DT);
183
184 for slot in self.entities.values_mut() {
185 slot.dx *= drag_factor;
187 slot.dy *= drag_factor;
188 slot.x += slot.dx * DT;
189 slot.y += slot.dy * DT;
190
191 if let Some(bounds) = self.room_bounds {
193 let width = bounds.max_x - bounds.min_x;
194 let height = bounds.max_y - bounds.min_y;
195 if width > 0.0 {
196 slot.x = ((slot.x - bounds.min_x).rem_euclid(width)) + bounds.min_x;
197 }
198 if height > 0.0 {
199 slot.y = ((slot.y - bounds.min_y).rem_euclid(height)) + bounds.min_y;
200 }
201 }
202 }
203 }
204
205 fn spawn_networked(&mut self) -> NetworkId {
206 NetworkId(0)
207 }
208
209 fn spawn_networked_for(&mut self, _client_id: ClientId) -> NetworkId {
210 self.spawn_networked()
211 }
212
213 fn despawn_networked(&mut self, network_id: NetworkId) -> Result<(), WorldError> {
214 self.entities
215 .remove(&network_id)
216 .map(|_| ())
217 .ok_or(WorldError::EntityNotFound(network_id))
218 }
219
220 fn stress_test(&mut self, _count: u16, _rotate: bool) {}
221
222 fn spawn_kind(&mut self, _kind: u16, _x: f32, _y: f32, _rot: f32) -> NetworkId {
223 NetworkId(1)
224 }
225
226 fn clear_world(&mut self) {
227 self.entities.clear();
228 }
229
230 fn state_hash(&self) -> u64 {
231 use std::hash::{Hash, Hasher};
232 use twox_hash::XxHash64;
233
234 let mut hasher = XxHash64::with_seed(0);
236 self.latest_tick.hash(&mut hasher);
237
238 for (nid, slot) in &self.entities {
240 nid.hash(&mut hasher);
241
242 slot.x.to_bits().hash(&mut hasher);
244 slot.y.to_bits().hash(&mut hasher);
245 slot.z.to_bits().hash(&mut hasher);
246 slot.rotation.to_bits().hash(&mut hasher);
247
248 slot.dx.to_bits().hash(&mut hasher);
250 slot.dy.to_bits().hash(&mut hasher);
251 slot.dz.to_bits().hash(&mut hasher);
252
253 slot.hp.hash(&mut hasher);
254 slot.shield.hash(&mut hasher);
255 slot.entity_type.hash(&mut hasher);
256 slot.flags.hash(&mut hasher);
257
258 slot.mining_active.hash(&mut hasher);
259 slot.cargo_ore.hash(&mut hasher);
260 slot.mining_target_id.hash(&mut hasher);
261 }
262
263 hasher.finish()
264 }
265}
266
267impl ClientWorld {
268 pub fn handle_game_event(&mut self, event: &aetheris_protocol::events::GameEvent) {
270 if let aetheris_protocol::events::GameEvent::Possession { network_id } = event {
271 let prev = self.player_network_id;
272 tracing::info!(
273 ?network_id,
274 ?prev,
275 entity_exists = self.entities.contains_key(network_id),
276 total_entities = self.entities.len(),
277 "[handle_game_event] POSSESSION received โ updating player_network_id"
278 );
279 if let Some(slot) = prev
281 .filter(|&id| id != *network_id)
282 .and_then(|id| self.entities.get_mut(&id))
283 {
284 slot.flags &= !0x04;
285 tracing::info!(
286 network_id = ?prev,
287 flags = slot.flags,
288 "[handle_game_event] 0x04 flag cleared from previous entity"
289 );
290 }
291 self.player_network_id = Some(*network_id);
292 if let Some(slot) = self.entities.get_mut(network_id) {
293 slot.flags |= 0x04;
294 tracing::info!(
295 ?network_id,
296 flags = slot.flags,
297 "[handle_game_event] 0x04 flag applied to entity"
298 );
299 } else {
300 tracing::warn!(
301 ?network_id,
302 "[handle_game_event] Possession entity not yet in world - will apply when it arrives"
303 );
304 }
305 }
306 }
307
308 fn apply_component_update(&mut self, update: &ComponentUpdate) {
309 match update.component_kind {
310 ComponentKind(1) => self.handle_transform_update(update),
311 ComponentKind(2) => self.handle_velocity_update(update),
312 ComponentKind(5) => self.handle_ship_class_update(update),
313 ComponentKind(3) => self.handle_ship_stats_update(update),
314 aetheris_protocol::types::MINING_BEAM_KIND => self.handle_mining_beam_update(update),
315 aetheris_protocol::types::CARGO_HOLD_KIND => self.handle_cargo_hold_update(update),
316 aetheris_protocol::types::ASTEROID_KIND => {
317 if let Some(entry) = self.entities.get_mut(&update.network_id)
318 && entry.entity_type == 0
319 {
320 entry.entity_type = 5;
321 }
322 }
323 aetheris_protocol::types::ROOM_BOUNDS_KIND => self.handle_room_bounds_update(update),
324 kind => {
325 tracing::debug!(
326 network_id = update.network_id.0,
327 kind = kind.0,
328 "Unhandled component kind"
329 );
330 }
331 }
332 }
333
334 fn handle_transform_update(&mut self, update: &ComponentUpdate) {
335 match rmp_serde::from_slice::<Transform>(&update.payload) {
336 Ok(transform) => {
337 if let Some(entry) = self.entities.get_mut(&update.network_id) {
338 if (entry.flags & 0x04) != 0 && self.prediction_enabled {
339 let mut authoritative_x = transform.x;
341 let mut authoritative_y = transform.y;
342
343 if let Some(bounds) = self.room_bounds {
345 let width = bounds.max_x - bounds.min_x;
346 let height = bounds.max_y - bounds.min_y;
347 if width > 0.0 {
348 let dx = authoritative_x - entry.x;
349 if dx.abs() > width * 0.5 {
350 if dx > 0.0 {
351 authoritative_x -= width;
352 } else {
353 authoritative_x += width;
354 }
355 }
356 }
357 if height > 0.0 {
358 let dy = authoritative_y - entry.y;
359 if dy.abs() > height * 0.5 {
360 if dy > 0.0 {
361 authoritative_y -= height;
362 } else {
363 authoritative_y += height;
364 }
365 }
366 }
367 }
368
369 entry.x = authoritative_x;
370 entry.y = authoritative_y;
371 entry.z = transform.z;
372 entry.rotation = transform.rotation;
373
374 let server_tick = update.tick;
375 for record in self.input_history.iter().filter(|r| r.tick > server_tick) {
376 Self::simulate_slot_wrapped(
377 entry,
378 record.move_x,
379 record.move_y,
380 self.room_bounds,
381 );
382 }
383 while self
384 .input_history
385 .front()
386 .is_some_and(|r| r.tick <= server_tick)
387 {
388 self.input_history.pop_front();
389 }
390 } else {
391 entry.x = transform.x;
393 entry.y = transform.y;
394 entry.z = transform.z;
395 entry.rotation = transform.rotation;
396 self.input_history.clear();
397 }
398
399 if transform.entity_type != 0 {
400 entry.entity_type = transform.entity_type;
401 }
402 }
403 }
404 Err(e) => {
405 tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode Transform");
406 }
407 }
408 }
409
410 fn handle_velocity_update(&mut self, update: &ComponentUpdate) {
411 match rmp_serde::from_slice::<Velocity>(&update.payload) {
412 Ok(velocity) => {
413 if let Some(entry) = self.entities.get_mut(&update.network_id) {
414 entry.dx = velocity.dx;
415 entry.dy = velocity.dy;
416 entry.dz = velocity.dz;
417 }
418 }
419 Err(e) => {
420 tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode Velocity");
421 }
422 }
423 }
424
425 fn handle_ship_class_update(&mut self, update: &ComponentUpdate) {
426 match rmp_serde::from_slice::<ShipClass>(&update.payload) {
427 Ok(ship_class) => {
428 if let Some(entry) = self.entities.get_mut(&update.network_id) {
429 entry.entity_type = match ship_class {
430 ShipClass::Interceptor => 1,
431 ShipClass::Dreadnought => 3,
432 ShipClass::Hauler => 4,
433 };
434 }
435 }
436 Err(e) => {
437 tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode ShipClass");
438 }
439 }
440 }
441
442 fn handle_ship_stats_update(&mut self, update: &ComponentUpdate) {
443 match rmp_serde::from_slice::<ShipStats>(&update.payload) {
444 Ok(stats) => {
445 if let Some(entry) = self.entities.get_mut(&update.network_id) {
446 entry.hp = stats.hp;
447 entry.shield = stats.shield;
448 }
449 }
450 Err(e) => {
451 tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode ShipStats");
452 }
453 }
454 }
455
456 fn handle_mining_beam_update(&mut self, update: &ComponentUpdate) {
457 use aetheris_protocol::types::MiningBeam;
458 match rmp_serde::from_slice::<MiningBeam>(&update.payload) {
459 Ok(beam) => {
460 if let Some(entry) = self.entities.get_mut(&update.network_id) {
461 entry.mining_active = u8::from(beam.active);
462 #[allow(clippy::cast_possible_truncation)]
463 {
464 entry.mining_target_id = beam.target.map_or(0, |id| id.0 as u16);
465 }
466 }
467 }
468 Err(e) => {
469 tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode MiningBeam");
470 }
471 }
472 }
473
474 fn handle_cargo_hold_update(&mut self, update: &ComponentUpdate) {
475 use aetheris_protocol::types::CargoHold;
476 match rmp_serde::from_slice::<CargoHold>(&update.payload) {
477 Ok(cargo) => {
478 if let Some(entry) = self.entities.get_mut(&update.network_id) {
479 entry.cargo_ore = cargo.ore_count;
480 entry.flags |= 0x04;
481 }
482 }
483 Err(e) => {
484 tracing::warn!(network_id = update.network_id.0, error = ?e, "Failed to decode CargoHold");
485 }
486 }
487 }
488
489 fn handle_room_bounds_update(&mut self, update: &ComponentUpdate) {
490 use aetheris_protocol::types::RoomBounds;
491 if let (Ok(bounds), Some(ptr_val)) = (
492 rmp_serde::from_slice::<RoomBounds>(&update.payload),
493 self.shared_world_ref,
494 ) {
495 let mut sw = unsafe { crate::shared_world::SharedWorld::from_ptr(ptr_val as *mut u8) };
496 sw.set_room_bounds(bounds.min_x, bounds.min_y, bounds.max_x, bounds.max_y);
497 self.room_bounds = Some(bounds);
498 }
499 }
500}
501
502impl ClientWorld {
503 fn simulate_slot(slot: &mut SabSlot, move_x: f32, move_y: f32) {
505 const THRUST_FORCE: f32 = 8000.0;
506 const BASE_MASS: f32 = 100.0;
507 const MASS_PER_ORE: f32 = 2.0;
508 const DRAG: f32 = 2.0;
509 const MAX_SPEED: f32 = 75.0;
510 const DT: f32 = 1.0 / 60.0;
511
512 let total_mass = BASE_MASS + (f32::from(slot.cargo_ore) * MASS_PER_ORE);
514
515 let mut mx = move_x;
517 let mut my = move_y;
518 let input_len_sq = mx * mx + my * my;
519 if input_len_sq > 1.0 {
520 let input_len = input_len_sq.sqrt();
521 mx /= input_len;
522 my /= input_len;
523 }
524
525 let accel_x = mx * (THRUST_FORCE / total_mass);
527 let accel_y = my * (THRUST_FORCE / total_mass);
528
529 slot.dx += accel_x * DT;
530 slot.dy += accel_y * DT;
531
532 let drag_factor = 1.0 / (1.0 + DRAG * DT);
534 slot.dx *= drag_factor;
535 slot.dy *= drag_factor;
536
537 let speed_sq = slot.dx * slot.dx + slot.dy * slot.dy;
539 if speed_sq > MAX_SPEED * MAX_SPEED {
540 let speed = speed_sq.sqrt();
541 slot.dx = (slot.dx / speed) * MAX_SPEED;
542 slot.dy = (slot.dy / speed) * MAX_SPEED;
543 }
544
545 if speed_sq > 0.01 {
547 const TURN_RATE: f32 = 5.0;
548 let target_rot = slot.dy.atan2(slot.dx);
549 let current_rot = slot.rotation;
550 let diff = (target_rot - current_rot + std::f32::consts::PI)
551 .rem_euclid(std::f32::consts::TAU)
552 - std::f32::consts::PI;
553
554 if diff.abs() > 0.001 {
555 slot.rotation += diff.clamp(-TURN_RATE * DT, TURN_RATE * DT);
556 } else {
557 slot.rotation = target_rot;
558 }
559 }
560
561 slot.x += slot.dx * DT;
563 slot.y += slot.dy * DT;
564 slot.z += slot.dz * DT;
565 }
566
567 fn simulate_slot_wrapped(
572 slot: &mut SabSlot,
573 move_x: f32,
574 move_y: f32,
575 bounds: Option<aetheris_protocol::types::RoomBounds>,
576 ) {
577 Self::simulate_slot(slot, move_x, move_y);
578
579 if let Some(bounds) = bounds {
580 let width = bounds.max_x - bounds.min_x;
581 let height = bounds.max_y - bounds.min_y;
582 if width > 0.0 {
583 slot.x = ((slot.x - bounds.min_x).rem_euclid(width)) + bounds.min_x;
584 }
585 if height > 0.0 {
586 slot.y = ((slot.y - bounds.min_y).rem_euclid(height)) + bounds.min_y;
587 }
588 }
589 }
590
591 pub fn playground_apply_input(&mut self, move_x: f32, move_y: f32, actions_mask: u32) -> bool {
594 if self.prediction_enabled {
595 self.input_history.push_back(InputRecord {
597 tick: self.latest_tick,
598 move_x,
599 move_y,
600 #[allow(clippy::cast_possible_truncation)]
601 actions_mask: actions_mask as u8,
602 });
603
604 if self.input_history.len() > 300 {
606 self.input_history.pop_front();
607 }
608 }
609
610 let mut found = false;
611 for slot in self.entities.values_mut() {
613 if (slot.flags & 0x04) != 0 {
614 found = true;
615 if self.prediction_enabled {
616 Self::simulate_slot_wrapped(slot, move_x, move_y, self.room_bounds);
619 }
620 }
623 }
624 found
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631 use crate::shared_world::SabSlot;
632 use aetheris_protocol::types::NetworkId;
633 use bytemuck::Zeroable;
634
635 #[test]
636 fn test_playground_movement() {
637 let mut world = ClientWorld::with_prediction(true);
638
639 world.entities.insert(
641 NetworkId(1),
642 SabSlot {
643 network_id: 1,
644 flags: 0x04, ..SabSlot::zeroed()
646 },
647 );
648
649 world.playground_apply_input(1.0, 0.0, 0);
651
652 let player = world.entities.get(&NetworkId(1)).unwrap();
653 assert!(player.dx > 0.0);
654 assert!(player.x > 0.0);
655 assert!((player.dx - 1.2903225).abs() < 0.0001);
658 }
659
660 #[test]
661 fn test_playground_speed_clamp() {
662 let mut world = ClientWorld::with_prediction(true);
663 world.entities.insert(
664 NetworkId(1),
665 SabSlot {
666 network_id: 1,
667 flags: 0x04,
668 dx: 10.0,
669 dy: 10.0,
670 ..SabSlot::zeroed()
671 },
672 );
673
674 world.playground_apply_input(1.0, 1.0, 0);
676
677 let player = world.entities.get(&NetworkId(1)).unwrap();
678 let speed = (player.dx * player.dx + player.dy * player.dy).sqrt();
679 assert!(speed <= 30.0 + 0.0001);
680 }
681
682 #[test]
683 fn test_playground_drag() {
684 let mut world = ClientWorld::with_prediction(true);
685 world.entities.insert(
686 NetworkId(1),
687 SabSlot {
688 network_id: 1,
689 flags: 0x04,
690 ..SabSlot::zeroed()
691 },
692 );
693
694 world.playground_apply_input(1.0, 0.0, 0);
696 let v1 = world.entities.get(&NetworkId(1)).unwrap().dx;
697
698 world.playground_apply_input(0.0, 0.0, 0);
700 let v2 = world.entities.get(&NetworkId(1)).unwrap().dx;
701
702 assert!(v2 < v1);
703 assert!((v2 - v1 * (1.0 / (1.0 + 2.0 / 60.0))).abs() < 0.0001);
705 }
706}