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