1use serde_json::{Map, Value, json};
17
18use super::divergence::{DivergenceTracker, Observation, Source};
19use super::interpolator::{FrameData, InterpolatedGame, Interpolator};
20use super::unpack::{self, DecodedSnapshot, UnpackError};
21use crate::config::{BlockKind, EngineClientConfig, FieldValue, PLAYER_STATE_LEN, SnapshotConfig};
22
23pub struct RenderOverlay {
26 pub camera: [f32; 2],
27 pub tail: Vec<f32>,
28}
29
30pub trait GameClientDef: Sized {
35 type Config: serde::de::DeserializeOwned;
36
37 fn new(cfg: &Self::Config, engine_cfg: &EngineClientConfig) -> Self;
38
39 fn on_server_state(
44 &mut self,
45 state: [f32; PLAYER_STATE_LEN],
46 centering: bool,
47 server_time: f64,
48 offset: f64,
49 local_now: f64,
50 );
51
52 fn update(&mut self, local_now: f64);
54
55 fn track_frame(&mut self, my_game_id: Option<u32>, frame: &FrameData);
59
60 fn filter_frame_game(&mut self, game: &mut Map<String, Value>, my_game_id: Option<u32>, local_now: f64);
63
64 fn update_world(&mut self, snapshot: &DecodedSnapshot);
67
68 fn update_world_interpolated(&mut self, game: &InterpolatedGame);
70
71 fn render_overlay(&self, my_game_id: Option<u32>) -> Option<RenderOverlay>;
75
76 fn predicted_state(&self) -> Option<[f32; PLAYER_STATE_LEN]> {
83 None
84 }
85
86 fn replayed_inputs(&self) -> Option<(f64, f64, usize)> {
91 None
92 }
93
94 fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64);
95 fn set_model(&mut self, model_name: &str);
96 fn set_active(&mut self, active: bool);
97 fn set_map(&mut self, map_json: &str) -> Result<(), String>;
98 fn sync_panel(&mut self, items: &[String]);
99 fn reset(&mut self);
100
101 fn cycle_item(&mut self, back: bool);
104
105 fn try_action(&mut self, my_game_id: Option<u32>, local_now: f64) -> Option<String>;
108}
109
110fn field_as_f32(value: FieldValue) -> f32 {
112 match value {
113 FieldValue::F32(v) => v,
114 FieldValue::U8(v) => v as f32,
115 FieldValue::U16(v) => v as f32,
116 FieldValue::U32(v) => v as f32,
117 }
118}
119
120fn blocks_of_kind<'a>(
123 snapshot_cfg: &'a SnapshotConfig,
124 game: &'a InterpolatedGame,
125 kind: BlockKind,
126) -> impl Iterator<Item = (u8, &'a Vec<super::interpolator::InterpolatedRow>)> {
127 game.blocks.iter().filter_map(move |(key, rows)| {
128 let schema = snapshot_cfg.keys.get(key)?;
129
130 (schema.kind == kind).then_some((schema.id, rows))
131 })
132}
133
134pub struct ClientState<G: GameClientDef> {
139 cfg: EngineClientConfig,
140 interpolator: Interpolator,
141 game: G,
142
143 my_game_id: Option<u32>,
145
146 frames_out: Vec<Value>,
148
149 hot: Vec<f32>,
151
152 divergence: Option<DivergenceTracker>,
155}
156
157impl<G: GameClientDef> ClientState<G> {
158 pub fn new(cfg: EngineClientConfig, game_cfg: &G::Config) -> Self {
159 let interpolator = Interpolator::new(&cfg.interpolation, cfg.snapshot.clone());
160 let game = G::new(game_cfg, &cfg);
161 let divergence = cfg.divergence.clone().map(DivergenceTracker::new);
162
163 Self {
164 cfg,
165 interpolator,
166 game,
167 my_game_id: None,
168 frames_out: Vec::new(),
169 hot: Vec::new(),
170 divergence,
171 }
172 }
173
174 pub fn push_frame(&mut self, data: &[u8], local_now: f64) -> bool {
178 let frame = match unpack::unpack_frame(data, &self.cfg.snapshot) {
179 Ok(frame) => frame,
180 Err(UnpackError::WrongVersion | UnpackError::Truncated) => return false,
181 };
182
183 if frame.port != self.cfg.snapshot.port {
184 return false;
185 }
186
187 self.interpolator.push(
188 FrameData {
189 snapshot: frame.snapshot,
190 camera: frame.camera,
191 },
192 frame.server_time,
193 local_now,
194 frame.seq,
195 );
196
197 if let Some(player) = frame.player {
198 self.my_game_id = Some(player.game_id as u32);
199
200 let offset = self.interpolator.offset().unwrap_or(0.0);
202
203 self.observe_divergence(&player, frame.server_time, offset, local_now);
204
205 self.game.on_server_state(
206 player.state,
207 player.centering,
208 frame.server_time,
209 offset,
210 local_now,
211 );
212 }
213
214 true
215 }
216
217 pub fn my_game_id(&self) -> Option<u32> {
218 self.my_game_id
219 }
220
221 pub fn offset(&self) -> Option<f64> {
222 self.interpolator.offset()
223 }
224
225 pub fn sample(&mut self, local_now: f64) -> usize {
229 let result = self.interpolator.sample(local_now);
230
231 for frame in result.frames {
233 self.game.track_frame(self.my_game_id, &frame);
234
235 let mut game = unpack::snapshot_to_json(&frame.snapshot);
236
237 self.game
238 .filter_frame_game(&mut game, self.my_game_id, local_now);
239
240 self.frames_out.push(json!({
241 "game": game,
242 "camera": unpack::camera_to_json(frame.camera.as_ref()),
243 }));
244
245 self.game.update_world(&frame.snapshot);
246 }
247
248 if let Some(game) = &result.game {
249 self.game.update_world_interpolated(game);
250 }
251
252 self.game.update(local_now);
253
254 let overlay = self.game.render_overlay(self.my_game_id);
255
256 self.write_hot(result.game.as_ref(), result.camera, overlay.as_ref());
257 self.hot.len()
258 }
259
260 pub fn hot(&self) -> &[f32] {
261 &self.hot
262 }
263
264 pub fn take_frames(&mut self) -> String {
266 let frames = std::mem::take(&mut self.frames_out);
267
268 serde_json::to_string(&frames).unwrap_or_else(|_| "[]".to_string())
269 }
270
271 pub fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64) {
272 self.game.apply_input(action, key_name, local_now);
273 }
274
275 pub fn try_action(&mut self, local_now: f64) -> Option<String> {
276 self.game.try_action(self.my_game_id, local_now)
277 }
278
279 pub fn cycle_item(&mut self, back: bool) {
280 self.game.cycle_item(back);
281 }
282
283 pub fn set_model(&mut self, model_name: &str) {
284 self.game.set_model(model_name);
285 }
286
287 pub fn set_active(&mut self, active: bool) {
289 self.game.set_active(active);
290 }
291
292 pub fn set_map(&mut self, map_json: &str) -> Result<(), String> {
294 self.interpolator.reset();
295 self.frames_out.clear();
296 self.game.set_map(map_json)
297 }
298
299 pub fn sync_panel(&mut self, panel_json: &str) {
300 let Ok(Value::Array(items)) = serde_json::from_str(panel_json) else {
301 return;
302 };
303
304 let items: Vec<String> = items
305 .iter()
306 .map(|item| match item {
307 Value::String(s) => s.clone(),
308 other => other.to_string(),
309 })
310 .collect();
311
312 self.game.sync_panel(&items);
313 }
314
315 pub fn reset(&mut self) {
317 self.interpolator.reset();
318 self.game.reset();
319 self.frames_out.clear();
320 self.my_game_id = None;
324 }
325
326 pub fn resync(&mut self) {
332 self.interpolator.reset();
333 self.frames_out.clear();
334 }
335
336 pub fn debug_json(&self) -> String {
340 json!({
341 "myGameId": self.my_game_id,
342 "offset": self.interpolator.offset(),
343 "interpolator": self.interpolator.debug_json(),
344 "hotLen": self.hot.len(),
345 "framesOut": self.frames_out.len(),
346 })
347 .to_string()
348 }
349
350 pub fn take_divergence(&mut self) -> String {
354 match &mut self.divergence {
355 Some(tracker) => tracker.take_json(),
356 None => "null".to_string(),
357 }
358 }
359
360 fn observe_divergence(
363 &mut self,
364 player: &unpack::DecodedPlayer,
365 server_time: f64,
366 offset: f64,
367 local_now: f64,
368 ) {
369 let Some(tracker) = &mut self.divergence else {
370 return;
371 };
372
373 let (source, predicted) = match self.game.predicted_state() {
375 Some(state) => (Source::State, state.to_vec()),
376 None => match self.game.render_overlay(self.my_game_id) {
377 Some(overlay) => (Source::Camera, overlay.camera.to_vec()),
378 None => return,
379 },
380 };
381
382 tracker.observe(Observation {
383 source,
384 predicted: &predicted,
385 authoritative: &player.state,
386 server_time,
387 local_now,
388 offset,
389 input_seq: player.input_seq,
390 replayed: self.game.replayed_inputs(),
391 });
392 }
393
394 pub fn decode_frame(&self, data: &[u8]) -> String {
396 match unpack::unpack_frame(data, &self.cfg.snapshot) {
397 Ok(frame) => unpack::frame_to_json(&frame).to_string(),
398 Err(_) => "null".to_string(),
399 }
400 }
401
402 fn write_hot(
407 &mut self,
408 game: Option<&InterpolatedGame>,
409 camera: Option<[f32; 2]>,
410 overlay: Option<&RenderOverlay>,
411 ) {
412 self.hot.clear();
413
414 let mut flags = 0u32;
415
416 if game.is_some() {
417 flags |= super::HOT_HAS_GAME;
418 }
419
420 if !self.frames_out.is_empty() {
421 flags |= super::HOT_HAS_FRAMES;
422 }
423
424 if overlay.is_some() {
425 flags |= super::HOT_HAS_PREDICTED;
426 }
427
428 let camera = overlay.map(|o| o.camera).or(camera);
430
431 if camera.is_some() {
432 flags |= super::HOT_HAS_CAMERA;
433 }
434
435 self.hot.push(flags as f32);
436
437 let camera = camera.unwrap_or([0.0, 0.0]);
438
439 self.hot.push(camera[0]);
440 self.hot.push(camera[1]);
441
442 let empty = InterpolatedGame::default();
443 let game = game.unwrap_or(&empty);
444
445 let tank_count: usize = blocks_of_kind(&self.cfg.snapshot, game, BlockKind::Indexed8)
450 .map(|(_, rows)| rows.len())
451 .sum();
452
453 self.hot.push(tank_count as f32);
454
455 for (key_id, rows) in blocks_of_kind(&self.cfg.snapshot, game, BlockKind::Indexed8) {
456 for row in rows {
457 self.hot.push(key_id as f32);
458 self.hot.push(row.id as f32);
459
460 for field in &row.fields {
461 self.hot.push(field_as_f32(*field));
462 }
463 }
464 }
465
466 let dynamic_count: usize =
467 blocks_of_kind(&self.cfg.snapshot, game, BlockKind::IndexedNoNull8)
468 .map(|(_, rows)| rows.len())
469 .sum();
470
471 self.hot.push(dynamic_count as f32);
472
473 for (key_id, rows) in blocks_of_kind(&self.cfg.snapshot, game, BlockKind::IndexedNoNull8) {
474 for row in rows {
475 self.hot.push(key_id as f32);
476 self.hot.push(row.id as f32);
477
478 for field in &row.fields {
479 self.hot.push(field_as_f32(*field));
480 }
481 }
482 }
483
484 if let Some(overlay) = overlay {
485 self.hot.extend_from_slice(&overlay.tail);
486 }
487 }
488}
489
490#[cfg(test)]
496mod fixture {
497 use super::*;
498 use serde::Deserialize;
499
500 #[derive(Deserialize)]
501 pub struct TestConfig {}
502
503 pub struct TestClient {
504 x: f32,
505 y: f32,
506 vx: f32,
507 vy: f32,
508 active: bool,
509 alive: bool,
510 last_update: Option<f64>,
511 report_state: bool,
514 }
515
516 impl GameClientDef for TestClient {
517 type Config = TestConfig;
518
519 fn new(_cfg: &Self::Config, _engine_cfg: &EngineClientConfig) -> Self {
520 Self {
521 x: 0.0,
522 y: 0.0,
523 vx: 0.0,
524 vy: 0.0,
525 active: false,
526 alive: true,
527 last_update: None,
528 report_state: false,
529 }
530 }
531
532 fn on_server_state(
533 &mut self,
534 state: [f32; PLAYER_STATE_LEN],
535 _centering: bool,
536 _server_time: f64,
537 _offset: f64,
538 _local_now: f64,
539 ) {
540 self.x = state[0];
541 self.y = state[1];
542 self.vx = state[3];
543 self.vy = state[4];
544 }
545
546 fn update(&mut self, local_now: f64) {
547 let dt = self
548 .last_update
549 .map(|last| (local_now - last) / 1000.0)
550 .unwrap_or(0.0) as f32;
551
552 self.x += self.vx * dt;
553 self.y += self.vy * dt;
554 self.last_update = Some(local_now);
555 }
556
557 fn track_frame(&mut self, _my_game_id: Option<u32>, _frame: &FrameData) {}
558
559 fn filter_frame_game(
560 &mut self,
561 _game: &mut Map<String, Value>,
562 _my_game_id: Option<u32>,
563 _local_now: f64,
564 ) {
565 }
566
567 fn update_world(&mut self, _snapshot: &DecodedSnapshot) {}
568
569 fn update_world_interpolated(&mut self, _game: &InterpolatedGame) {}
570
571 fn render_overlay(&self, my_game_id: Option<u32>) -> Option<RenderOverlay> {
572 let game_id = my_game_id?;
573
574 (self.active && self.alive).then(|| RenderOverlay {
575 camera: [self.x, self.y],
576 tail: vec![0.0, game_id as f32, self.x, self.y],
577 })
578 }
579
580 fn predicted_state(&self) -> Option<[f32; PLAYER_STATE_LEN]> {
581 self.report_state
582 .then_some([self.x, self.y, 0.0, self.vx, self.vy, 0.0, 0.0, 0.0])
583 }
584
585 fn replayed_inputs(&self) -> Option<(f64, f64, usize)> {
586 self.last_update.map(|last| (last - 50.0, last, 2))
587 }
588
589 fn apply_input(&mut self, _action: &str, _key_name: &str, _local_now: f64) {}
590
591 fn set_model(&mut self, model_name: &str) {
592 self.report_state = model_name == "predicted";
593 }
594
595 fn set_active(&mut self, active: bool) {
596 self.active = active;
597 }
598
599 fn set_map(&mut self, _map_json: &str) -> Result<(), String> {
600 Ok(())
601 }
602
603 fn sync_panel(&mut self, _items: &[String]) {}
604
605 fn reset(&mut self) {
606 self.x = 0.0;
607 self.y = 0.0;
608 self.last_update = None;
609 }
610
611 fn cycle_item(&mut self, _back: bool) {}
612
613 fn try_action(&mut self, _my_game_id: Option<u32>, _local_now: f64) -> Option<String> {
614 None
615 }
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::fixture::{TestClient, TestConfig};
622 use super::*;
623 use crate::client::{HOT_HAS_CAMERA, HOT_HAS_FRAMES, HOT_HAS_GAME, HOT_HAS_PREDICTED};
624 use crate::snapshot::{Block, CameraData, PlayerBlock, SnapshotPacker};
625
626 fn config_json() -> serde_json::Value {
627 serde_json::json!({
628 "timeStepMs": 1000.0 / 120.0,
629 "snapshot": {
630 "version": 3,
631 "port": 5,
632 "keys": {
633 "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
634 { "name": "x", "ty": "f32", "interp": "lerp" },
635 { "name": "y", "ty": "f32", "interp": "lerp" }
636 ] }
637 }
638 },
639 "interpolation": { "delay": 100, "maxFrameAge": 1000 }
640 })
641 }
642
643 fn engine_client_config() -> EngineClientConfig {
644 serde_json::from_value(config_json()).unwrap()
645 }
646
647 fn config_with_divergence(capacity: usize) -> EngineClientConfig {
650 let mut json = config_json();
651
652 json["divergence"] = serde_json::json!({
653 "defaultThreshold": 1.0,
654 "capacity": capacity,
655 });
656
657 serde_json::from_value(json).unwrap()
658 }
659
660 fn make_state() -> ClientState<TestClient> {
661 ClientState::new(engine_client_config(), &TestConfig {})
662 }
663
664 fn frame_bytes(server_time: f64, seq: u32, x: f32, with_player: bool) -> Vec<u8> {
665 let cfg = engine_client_config();
666 let mut packer = SnapshotPacker::new(cfg.snapshot.clone());
667
668 packer
669 .pack_body(&[(
670 "actor".to_string(),
671 Block::Indexed8(vec![(2, Some(vec![FieldValue::F32(x), FieldValue::F32(0.0)]))]),
672 )])
673 .unwrap();
674
675 let camera = CameraData {
676 x,
677 y: 0.0,
678 force_reset: false,
679 shake: None,
680 };
681 let player = PlayerBlock {
682 game_id: 2,
683 input_seq: 0,
684 state: [x, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
685 centering: false,
686 };
687
688 packer
689 .pack_frame(
690 server_time,
691 seq,
692 Some(&camera),
693 with_player.then_some(&player),
694 )
695 .to_vec()
696 }
697
698 #[test]
699 fn push_frame_and_sample_writes_hot_layout() {
700 let mut state = make_state();
701
702 state.push_frame(&frame_bytes(1000.0, 1, 10.0, false), 1000.0);
703 state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
704
705 let len = state.sample(1150.0);
707 let hot = state.hot().to_vec();
708
709 assert_eq!(len, hot.len());
710
711 let flags = hot[0] as u32;
712
713 assert!(flags & HOT_HAS_GAME != 0);
714 assert!(flags & HOT_HAS_CAMERA != 0);
715 assert!(flags & HOT_HAS_FRAMES != 0);
716 assert!(flags & HOT_HAS_PREDICTED == 0);
717
718 assert_eq!(hot[3], 1.0);
720 assert_eq!(hot[4], 1.0);
721 assert_eq!(hot[5], 2.0);
722 assert_eq!(hot[6], 15.0);
723
724 let frames: Vec<serde_json::Value> =
725 serde_json::from_str(&state.take_frames()).unwrap();
726
727 assert_eq!(frames.len(), 1);
728 assert_eq!(state.take_frames(), "[]");
729 }
730
731 #[test]
732 fn render_overlay_appends_opaque_tail_and_sets_flag() {
733 let mut state = make_state();
734
735 state.set_active(true);
736 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
737
738 assert_eq!(state.my_game_id(), Some(2));
739
740 state.sample(1150.0);
741
742 let hot = state.hot().to_vec();
743 let flags = hot[0] as u32;
744
745 assert!(flags & HOT_HAS_PREDICTED != 0);
746
747 let tail = &hot[hot.len() - 4..];
749
750 assert_eq!(tail[1], 2.0); assert_eq!(hot[1], tail[2]); }
753
754 #[test]
755 fn reset_clears_predictor_and_frame_queue() {
756 let mut state = make_state();
757
758 state.set_active(true);
759 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
760 state.sample(1150.0);
761
762 state.reset();
763
764 assert_eq!(state.take_frames(), "[]");
765 }
766
767 #[test]
768 fn resync_clears_network_half_only() {
769 let mut state = make_state();
770
771 state.set_active(true);
772 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
773 state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
774 state.sample(1150.0);
775
776 state.resync();
777
778 let dump: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
779
780 assert_eq!(dump["interpolator"]["buffered"], 0);
781 assert!(dump["interpolator"]["lastFrame"].is_null());
782 assert!(dump["offset"].is_null());
783 assert_eq!(dump["framesOut"], 0);
784 assert_eq!(state.take_frames(), "[]");
785
786 assert_eq!(state.my_game_id(), Some(2));
788 }
789
790 #[test]
791 fn debug_json_reports_buffer_seq_window_and_offset() {
792 let mut state = make_state();
793
794 let empty: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
795
796 assert!(empty["myGameId"].is_null());
797 assert_eq!(empty["interpolator"]["buffered"], 0);
798 assert!(empty["interpolator"]["lastFrame"].is_null());
799
800 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
801 state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
802 state.sample(1150.0);
803
804 let dump: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
805
806 assert_eq!(dump["myGameId"], 2);
807 assert_eq!(dump["interpolator"]["buffered"], 2);
808 assert_eq!(dump["interpolator"]["seqWindow"], serde_json::json!([1, 2]));
809 assert_eq!(dump["interpolator"]["lastFrame"]["seq"], 2);
810 assert_eq!(dump["interpolator"]["lastFrame"]["serverTime"], 1100.0);
811 assert_eq!(dump["interpolator"]["delay"], 100.0);
812 assert_eq!(dump["offset"], dump["interpolator"]["offset"]);
813 assert_eq!(dump["hotLen"], state.hot().len());
814 assert_eq!(dump["framesOut"], 1);
815 }
816
817 #[test]
820 fn divergence_is_off_without_config() {
821 let mut state = make_state();
822
823 state.set_active(true);
824 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
825
826 assert_eq!(state.take_divergence(), "null");
827 }
828
829 #[test]
832 fn divergence_falls_back_to_overlay_camera() {
833 let mut state = ClientState::<TestClient>::new(config_with_divergence(8), &TestConfig {});
834
835 state.set_active(true);
836 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
837
838 let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
839 let record = &dump["records"][0];
840
841 assert_eq!(dump["samples"], 1);
842 assert_eq!(dump["violations"], 1);
843 assert_eq!(record["source"], "camera");
844 assert_eq!(record["predicted"], serde_json::json!([0.0, 0.0]));
845 assert_eq!(record["authoritative"], serde_json::json!([10.0, 0.0]));
846 assert_eq!(record["delta"][0], -10.0);
847 assert_eq!(record["exceeded"], serde_json::json!([0]));
848 assert_eq!(record["thresholds"][0], 1.0);
849 assert!(record["replayed"].is_null());
850 }
851
852 #[test]
856 fn divergence_reports_predicted_state_and_replay_window() {
857 let mut state = ClientState::<TestClient>::new(config_with_divergence(8), &TestConfig {});
858
859 state.set_active(true);
860 state.set_model("predicted");
861
862 state.push_frame(&frame_bytes(1000.0, 1, 0.0, true), 1000.0);
864 state.sample(1100.0);
865 state.push_frame(&frame_bytes(1100.0, 2, 50.0, true), 1100.0);
866
867 let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
868
869 assert_eq!(dump["samples"], 2);
870 assert_eq!(dump["violations"], 1);
871 assert_eq!(dump["records"].as_array().unwrap().len(), 1);
872 assert_eq!(dump["maxDelta"][0], 50.0);
873
874 let record = &dump["records"][0];
875
876 assert_eq!(record["source"], "state");
877 assert_eq!(record["serverTime"], 1100.0);
878 assert_eq!(record["localNow"], 1100.0);
879 assert_eq!(record["inputSeq"], 0);
880 assert_eq!(record["delta"][0], -50.0);
881 assert_eq!(record["exceeded"], serde_json::json!([0]));
882 assert_eq!(record["replayed"]["from"], 1050.0);
883 assert_eq!(record["replayed"]["to"], 1100.0);
884 assert_eq!(record["replayed"]["count"], 2);
885
886 let drained: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
888
889 assert_eq!(drained["records"].as_array().unwrap().len(), 0);
890 assert_eq!(drained["samples"], 2);
891 assert_eq!(drained["maxDelta"][0], 50.0);
892 }
893
894 #[test]
895 fn divergence_ring_buffer_evicts_oldest_records() {
896 let mut state = ClientState::<TestClient>::new(config_with_divergence(1), &TestConfig {});
897
898 state.set_active(true);
899 state.set_model("predicted");
900 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
901 state.push_frame(&frame_bytes(1100.0, 2, 90.0, true), 1100.0);
902
903 let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
904
905 assert_eq!(dump["violations"], 2);
906 assert_eq!(dump["dropped"], 1);
907 assert_eq!(dump["records"].as_array().unwrap().len(), 1);
908 assert_eq!(dump["records"][0]["serverTime"], 1100.0);
909 }
910
911 #[test]
914 fn divergence_zero_capacity_counts_evictions_honestly() {
915 let mut state = ClientState::<TestClient>::new(config_with_divergence(0), &TestConfig {});
916
917 state.set_active(true);
918 state.set_model("predicted");
919 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
920 state.push_frame(&frame_bytes(1100.0, 2, 90.0, true), 1100.0);
921
922 let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
923
924 assert_eq!(dump["violations"], 2);
925 assert_eq!(dump["dropped"], 1);
926 assert_eq!(dump["records"].as_array().unwrap().len(), 1);
927 }
928
929 #[test]
935 fn second_schema_key_of_different_block_kind_flows_into_hot_buffer() {
936 let config = serde_json::json!({
937 "timeStepMs": 1000.0 / 120.0,
938 "snapshot": {
939 "version": 3,
940 "port": 5,
941 "keys": {
942 "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
943 { "name": "x", "ty": "f32", "interp": "lerp" },
944 { "name": "y", "ty": "f32", "interp": "lerp" }
945 ] },
946 "zone": { "id": 2, "kind": "indexedNoNull8", "class": "hot", "fields": [
947 { "name": "level", "ty": "f32", "interp": "discrete" }
948 ] }
949 }
950 },
951 "interpolation": { "delay": 100, "maxFrameAge": 1000 }
952 });
953 let cfg: EngineClientConfig = serde_json::from_value(config).unwrap();
954 let mut state = ClientState::<TestClient>::new(cfg.clone(), &TestConfig {});
955 let mut packer = SnapshotPacker::new(cfg.snapshot.clone());
956
957 packer
958 .pack_body(&[
959 (
960 "actor".to_string(),
961 Block::Indexed8(vec![(2, Some(vec![FieldValue::F32(10.0), FieldValue::F32(0.0)]))]),
962 ),
963 (
964 "zone".to_string(),
965 Block::IndexedNoNull8(vec![(0, vec![FieldValue::F32(7.0)])]),
966 ),
967 ])
968 .unwrap();
969
970 let frame = packer.pack_frame(1000.0, 1, None, None).to_vec();
971
972 state.push_frame(&frame, 1000.0);
973 state.push_frame(&frame, 1100.0);
974 state.sample(1150.0);
975
976 let hot = state.hot().to_vec();
977
978 assert_eq!(hot[3], 1.0); assert_eq!(hot[8], 1.0); assert_eq!(hot[9], 2.0); assert_eq!(hot[11], 7.0); }
984
985 #[test]
986 fn reset_clears_my_game_id() {
987 let mut state = make_state();
988
989 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
990 state.push_frame(&frame_bytes(1100.0, 2, 20.0, true), 1100.0);
991 state.sample(1150.0);
992 assert_eq!(state.my_game_id(), Some(2));
993
994 state.reset();
997 assert_eq!(state.my_game_id(), None);
998 }
999}