1use serde_json::{Map, Value, json};
23
24use super::divergence::{DivergenceTracker, Observation, Source};
25use super::interpolator::{FrameData, InterpolatedGame, Interpolator};
26use super::unpack::{self, DecodedSnapshot, UnpackError};
27use crate::config::{BlockKind, EngineClientConfig, FieldValue, PLAYER_STATE_LEN, SnapshotConfig};
28
29pub struct RenderOverlay {
32 pub camera: [f32; 2],
33 pub tail: Vec<f32>,
34}
35
36pub struct PredictedRow {
43 pub key_id: u8,
44 pub id: u32,
45 pub fields: Vec<f32>,
46}
47
48pub trait GameClientDef: Sized {
53 type Config: serde::de::DeserializeOwned;
54
55 fn new(cfg: &Self::Config, engine_cfg: &EngineClientConfig) -> Self;
56
57 fn on_server_state(
62 &mut self,
63 state: [f32; PLAYER_STATE_LEN],
64 centering: bool,
65 server_time: f64,
66 offset: f64,
67 local_now: f64,
68 );
69
70 fn update(&mut self, local_now: f64);
72
73 fn begin_reconcile(&mut self, _snapshot: &DecodedSnapshot) {}
78
79 fn finish_reconcile(&mut self) {}
83
84 fn render_rows(&self) -> Vec<PredictedRow> {
87 Vec::new()
88 }
89
90 fn track_frame(&mut self, my_game_id: Option<u32>, frame: &FrameData);
94
95 fn filter_frame_game(&mut self, game: &mut Map<String, Value>, my_game_id: Option<u32>, local_now: f64);
98
99 fn update_world(&mut self, snapshot: &DecodedSnapshot);
102
103 fn update_world_interpolated(&mut self, game: &InterpolatedGame);
105
106 fn render_overlay(&self, my_game_id: Option<u32>) -> Option<RenderOverlay>;
110
111 fn predicted_state(&self) -> Option<[f32; PLAYER_STATE_LEN]> {
118 None
119 }
120
121 fn replayed_inputs(&self) -> Option<(f64, f64, usize)> {
126 None
127 }
128
129 fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64);
130
131 fn apply_aim(&mut self, _x: f32, _y: f32, _flags: u32, _local_now: f64) {}
135 fn set_model(&mut self, model_name: &str);
136 fn set_active(&mut self, active: bool);
137 fn set_map(&mut self, map_json: &str) -> Result<(), String>;
138 fn sync_panel(&mut self, items: &[String]);
139 fn reset(&mut self);
140
141 fn cycle_item(&mut self, back: bool);
144
145 fn try_action(&mut self, my_game_id: Option<u32>, local_now: f64) -> Option<String>;
148}
149
150fn field_as_f32(value: FieldValue) -> f32 {
152 match value {
153 FieldValue::F32(v) => v,
154 FieldValue::U8(v) => v as f32,
155 FieldValue::U16(v) => v as f32,
156 FieldValue::U32(v) => v as f32,
157 }
158}
159
160fn blocks_of_kind<'a>(
163 snapshot_cfg: &'a SnapshotConfig,
164 game: &'a InterpolatedGame,
165 kind: BlockKind,
166) -> impl Iterator<Item = (u8, &'a Vec<super::interpolator::InterpolatedRow>)> {
167 game.blocks.iter().filter_map(move |(key, rows)| {
168 let schema = snapshot_cfg.keys.get(key)?;
169
170 (schema.kind == kind).then_some((schema.id, rows))
171 })
172}
173
174pub struct ClientState<G: GameClientDef> {
179 cfg: EngineClientConfig,
180 interpolator: Interpolator,
181 game: G,
182
183 my_game_id: Option<u32>,
185
186 frames_out: Vec<Value>,
188
189 hot: Vec<f32>,
191
192 divergence: Option<DivergenceTracker>,
195}
196
197impl<G: GameClientDef> ClientState<G> {
198 pub fn new(cfg: EngineClientConfig, game_cfg: &G::Config) -> Self {
199 let interpolator = Interpolator::new(&cfg.interpolation, cfg.snapshot.clone());
200 let game = G::new(game_cfg, &cfg);
201 let divergence = cfg.divergence.clone().map(DivergenceTracker::new);
202
203 Self {
204 cfg,
205 interpolator,
206 game,
207 my_game_id: None,
208 frames_out: Vec::new(),
209 hot: Vec::new(),
210 divergence,
211 }
212 }
213
214 pub fn push_frame(&mut self, data: &[u8], local_now: f64) -> bool {
218 let frame = match unpack::unpack_frame(data, &self.cfg.snapshot) {
219 Ok(frame) => frame,
220 Err(UnpackError::WrongVersion | UnpackError::Truncated) => return false,
221 };
222
223 if frame.port != self.cfg.snapshot.port {
224 return false;
225 }
226
227 if frame.player.is_some() {
231 self.game.begin_reconcile(&frame.snapshot);
232 }
233
234 self.interpolator.push(
235 FrameData {
236 snapshot: frame.snapshot,
237 camera: frame.camera,
238 },
239 frame.server_time,
240 local_now,
241 frame.seq,
242 );
243
244 if let Some(player) = frame.player {
245 self.my_game_id = Some(player.game_id as u32);
246
247 let offset = self.interpolator.offset().unwrap_or(0.0);
249
250 self.observe_divergence(&player, frame.server_time, offset, local_now);
251
252 self.game.on_server_state(
253 player.state,
254 player.centering,
255 frame.server_time,
256 offset,
257 local_now,
258 );
259
260 self.game.finish_reconcile();
262 }
263
264 true
265 }
266
267 pub fn my_game_id(&self) -> Option<u32> {
268 self.my_game_id
269 }
270
271 pub fn offset(&self) -> Option<f64> {
272 self.interpolator.offset()
273 }
274
275 pub fn sample(&mut self, local_now: f64) -> usize {
279 let result = self.interpolator.sample(local_now);
280
281 for frame in result.frames {
283 self.game.track_frame(self.my_game_id, &frame);
284
285 let mut game = unpack::snapshot_to_json(&frame.snapshot);
286
287 self.game
288 .filter_frame_game(&mut game, self.my_game_id, local_now);
289
290 self.frames_out.push(json!({
291 "game": game,
292 "camera": unpack::camera_to_json(frame.camera.as_ref()),
293 }));
294
295 self.game.update_world(&frame.snapshot);
296 }
297
298 if let Some(game) = &result.game {
299 self.game.update_world_interpolated(game);
300 }
301
302 self.game.update(local_now);
303
304 let overlay = self.game.render_overlay(self.my_game_id);
305 let rows = self.game.render_rows();
306
307 self.write_hot(result.game.as_ref(), result.camera, overlay.as_ref(), &rows);
308 self.hot.len()
309 }
310
311 pub fn hot(&self) -> &[f32] {
312 &self.hot
313 }
314
315 pub fn take_frames(&mut self) -> String {
317 let frames = std::mem::take(&mut self.frames_out);
318
319 serde_json::to_string(&frames).unwrap_or_else(|_| "[]".to_string())
320 }
321
322 pub fn apply_input(&mut self, action: &str, key_name: &str, local_now: f64) {
323 self.game.apply_input(action, key_name, local_now);
324 }
325
326 pub fn apply_aim(&mut self, x: f32, y: f32, flags: u32, local_now: f64) {
327 self.game.apply_aim(x, y, flags, local_now);
328 }
329
330 pub fn try_action(&mut self, local_now: f64) -> Option<String> {
331 self.game.try_action(self.my_game_id, local_now)
332 }
333
334 pub fn cycle_item(&mut self, back: bool) {
335 self.game.cycle_item(back);
336 }
337
338 pub fn set_model(&mut self, model_name: &str) {
339 self.game.set_model(model_name);
340 }
341
342 pub fn set_active(&mut self, active: bool) {
344 self.game.set_active(active);
345 }
346
347 pub fn set_map(&mut self, map_json: &str) -> Result<(), String> {
349 self.interpolator.reset();
350 self.frames_out.clear();
351 self.game.set_map(map_json)
352 }
353
354 pub fn sync_panel(&mut self, panel_json: &str) {
355 let Ok(Value::Array(items)) = serde_json::from_str(panel_json) else {
356 return;
357 };
358
359 let items: Vec<String> = items
360 .iter()
361 .map(|item| match item {
362 Value::String(s) => s.clone(),
363 other => other.to_string(),
364 })
365 .collect();
366
367 self.game.sync_panel(&items);
368 }
369
370 pub fn reset(&mut self) {
372 self.interpolator.reset();
373 self.game.reset();
374 self.frames_out.clear();
375 self.my_game_id = None;
379 }
380
381 pub fn resync(&mut self) {
387 self.interpolator.reset();
388 self.frames_out.clear();
389 }
390
391 pub fn debug_json(&self) -> String {
395 json!({
396 "myGameId": self.my_game_id,
397 "offset": self.interpolator.offset(),
398 "interpolator": self.interpolator.debug_json(),
399 "hotLen": self.hot.len(),
400 "framesOut": self.frames_out.len(),
401 })
402 .to_string()
403 }
404
405 pub fn take_divergence(&mut self) -> String {
409 match &mut self.divergence {
410 Some(tracker) => tracker.take_json(),
411 None => "null".to_string(),
412 }
413 }
414
415 fn observe_divergence(
418 &mut self,
419 player: &unpack::DecodedPlayer,
420 server_time: f64,
421 offset: f64,
422 local_now: f64,
423 ) {
424 let Some(tracker) = &mut self.divergence else {
425 return;
426 };
427
428 let (source, predicted) = match self.game.predicted_state() {
430 Some(state) => (Source::State, state.to_vec()),
431 None => match self.game.render_overlay(self.my_game_id) {
432 Some(overlay) => (Source::Camera, overlay.camera.to_vec()),
433 None => return,
434 },
435 };
436
437 tracker.observe(Observation {
438 source,
439 predicted: &predicted,
440 authoritative: &player.state,
441 server_time,
442 local_now,
443 offset,
444 input_seq: player.input_seq,
445 replayed: self.game.replayed_inputs(),
446 });
447 }
448
449 pub fn decode_frame(&self, data: &[u8]) -> String {
451 match unpack::unpack_frame(data, &self.cfg.snapshot) {
452 Ok(frame) => unpack::frame_to_json(&frame).to_string(),
453 Err(_) => "null".to_string(),
454 }
455 }
456
457 fn write_hot(
465 &mut self,
466 game: Option<&InterpolatedGame>,
467 camera: Option<[f32; 2]>,
468 overlay: Option<&RenderOverlay>,
469 rows: &[PredictedRow],
470 ) {
471 self.hot.clear();
472
473 let mut flags = 0u32;
474
475 if game.is_some() {
476 flags |= super::HOT_HAS_GAME;
477 }
478
479 if !self.frames_out.is_empty() {
480 flags |= super::HOT_HAS_FRAMES;
481 }
482
483 if overlay.is_some() {
484 flags |= super::HOT_HAS_PREDICTED;
485 }
486
487 let camera = overlay.map(|o| o.camera).or(camera);
489
490 if camera.is_some() {
491 flags |= super::HOT_HAS_CAMERA;
492 }
493
494 self.hot.push(flags as f32);
495
496 let camera = camera.unwrap_or([0.0, 0.0]);
497
498 self.hot.push(camera[0]);
499 self.hot.push(camera[1]);
500
501 let empty = InterpolatedGame::default();
502 let game = game.unwrap_or(&empty);
503
504 let tank_count: usize = blocks_of_kind(&self.cfg.snapshot, game, BlockKind::Indexed8)
509 .map(|(_, rows)| rows.len())
510 .sum();
511
512 self.hot.push(tank_count as f32);
513
514 for (key_id, rows) in blocks_of_kind(&self.cfg.snapshot, game, BlockKind::Indexed8) {
515 for row in rows {
516 self.hot.push(key_id as f32);
517 self.hot.push(row.id as f32);
518
519 for field in &row.fields {
520 self.hot.push(field_as_f32(*field));
521 }
522 }
523 }
524
525 let dynamic_count: usize =
526 blocks_of_kind(&self.cfg.snapshot, game, BlockKind::IndexedNoNull8)
527 .map(|(_, rows)| rows.len())
528 .sum();
529
530 self.hot.push(dynamic_count as f32);
531
532 for (key_id, rows) in blocks_of_kind(&self.cfg.snapshot, game, BlockKind::IndexedNoNull8) {
533 for row in rows {
534 self.hot.push(key_id as f32);
535 self.hot.push(row.id as f32);
536
537 for field in &row.fields {
538 self.hot.push(field_as_f32(*field));
539 }
540 }
541 }
542
543 if let Some(overlay) = overlay {
544 self.hot.extend_from_slice(&overlay.tail);
545 }
546
547 for row in rows {
548 let Some(width) = self
552 .cfg
553 .snapshot
554 .keys
555 .values()
556 .find(|schema| schema.id == row.key_id)
557 .map(|schema| schema.fields.len())
558 else {
559 continue;
560 };
561
562 self.hot.push(row.key_id as f32);
563 self.hot.push(row.id as f32);
564
565 for index in 0..width {
566 self.hot.push(row.fields.get(index).copied().unwrap_or(0.0));
567 }
568 }
569 }
570}
571
572#[cfg(test)]
578mod fixture {
579 use super::*;
580 use serde::Deserialize;
581
582 #[derive(Deserialize)]
583 pub struct TestConfig {}
584
585 pub struct TestClient {
586 x: f32,
587 y: f32,
588 vx: f32,
589 vy: f32,
590 active: bool,
591 alive: bool,
592 last_update: Option<f64>,
593 report_state: bool,
596 predicted_rows: bool,
598 pub reconcile_log: Vec<&'static str>,
600 }
601
602 impl GameClientDef for TestClient {
603 type Config = TestConfig;
604
605 fn new(_cfg: &Self::Config, _engine_cfg: &EngineClientConfig) -> Self {
606 Self {
607 x: 0.0,
608 y: 0.0,
609 vx: 0.0,
610 vy: 0.0,
611 active: false,
612 alive: true,
613 last_update: None,
614 report_state: false,
615 predicted_rows: false,
616 reconcile_log: Vec::new(),
617 }
618 }
619
620 fn on_server_state(
621 &mut self,
622 state: [f32; PLAYER_STATE_LEN],
623 _centering: bool,
624 _server_time: f64,
625 _offset: f64,
626 _local_now: f64,
627 ) {
628 self.x = state[0];
629 self.y = state[1];
630 self.vx = state[3];
631 self.vy = state[4];
632 self.reconcile_log.push("state");
633 }
634
635 fn begin_reconcile(&mut self, _snapshot: &DecodedSnapshot) {
636 self.reconcile_log.push("begin");
637 }
638
639 fn finish_reconcile(&mut self) {
640 self.reconcile_log.push("finish");
641 }
642
643 fn update(&mut self, local_now: f64) {
644 let dt = self
645 .last_update
646 .map(|last| (local_now - last) / 1000.0)
647 .unwrap_or(0.0) as f32;
648
649 self.x += self.vx * dt;
650 self.y += self.vy * dt;
651 self.last_update = Some(local_now);
652 }
653
654 fn track_frame(&mut self, _my_game_id: Option<u32>, _frame: &FrameData) {}
655
656 fn filter_frame_game(
657 &mut self,
658 _game: &mut Map<String, Value>,
659 _my_game_id: Option<u32>,
660 _local_now: f64,
661 ) {
662 }
663
664 fn update_world(&mut self, _snapshot: &DecodedSnapshot) {}
665
666 fn update_world_interpolated(&mut self, _game: &InterpolatedGame) {}
667
668 fn render_overlay(&self, my_game_id: Option<u32>) -> Option<RenderOverlay> {
669 let game_id = my_game_id?;
670
671 (self.active && self.alive).then(|| RenderOverlay {
672 camera: [self.x, self.y],
673 tail: vec![0.0, game_id as f32, self.x, self.y],
674 })
675 }
676
677 fn render_rows(&self) -> Vec<PredictedRow> {
682 if !self.predicted_rows {
683 return Vec::new();
684 }
685
686 vec![
687 PredictedRow {
688 key_id: 1,
689 id: 2,
690 fields: vec![111.0, 222.0],
691 },
692 PredictedRow {
693 key_id: 1,
694 id: 7,
695 fields: vec![333.0],
696 },
697 PredictedRow {
698 key_id: 200,
699 id: 9,
700 fields: vec![1.0, 2.0],
701 },
702 ]
703 }
704
705 fn predicted_state(&self) -> Option<[f32; PLAYER_STATE_LEN]> {
706 self.report_state
707 .then_some([self.x, self.y, 0.0, self.vx, self.vy, 0.0, 0.0, 0.0])
708 }
709
710 fn replayed_inputs(&self) -> Option<(f64, f64, usize)> {
711 self.last_update.map(|last| (last - 50.0, last, 2))
712 }
713
714 fn apply_input(&mut self, _action: &str, _key_name: &str, _local_now: f64) {}
715
716 fn set_model(&mut self, model_name: &str) {
717 self.report_state = model_name == "predicted";
718 self.predicted_rows = model_name == "rows";
719 }
720
721 fn set_active(&mut self, active: bool) {
722 self.active = active;
723 }
724
725 fn set_map(&mut self, _map_json: &str) -> Result<(), String> {
726 Ok(())
727 }
728
729 fn sync_panel(&mut self, _items: &[String]) {}
730
731 fn reset(&mut self) {
732 self.x = 0.0;
733 self.y = 0.0;
734 self.last_update = None;
735 }
736
737 fn cycle_item(&mut self, _back: bool) {}
738
739 fn try_action(&mut self, _my_game_id: Option<u32>, _local_now: f64) -> Option<String> {
740 None
741 }
742 }
743}
744
745#[cfg(test)]
746mod tests {
747 use super::fixture::{TestClient, TestConfig};
748 use super::*;
749 use crate::client::{HOT_HAS_CAMERA, HOT_HAS_FRAMES, HOT_HAS_GAME, HOT_HAS_PREDICTED};
750 use crate::snapshot::{Block, CameraData, PlayerBlock, SnapshotPacker};
751
752 fn config_json() -> serde_json::Value {
753 serde_json::json!({
754 "timeStepMs": 1000.0 / 120.0,
755 "snapshot": {
756 "version": 3,
757 "port": 5,
758 "keys": {
759 "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
760 { "name": "x", "ty": "f32", "interp": "lerp" },
761 { "name": "y", "ty": "f32", "interp": "lerp" }
762 ] }
763 }
764 },
765 "interpolation": { "delay": 100, "maxFrameAge": 1000 }
766 })
767 }
768
769 fn engine_client_config() -> EngineClientConfig {
770 serde_json::from_value(config_json()).unwrap()
771 }
772
773 fn config_with_divergence(capacity: usize) -> EngineClientConfig {
776 let mut json = config_json();
777
778 json["divergence"] = serde_json::json!({
779 "defaultThreshold": 1.0,
780 "capacity": capacity,
781 });
782
783 serde_json::from_value(json).unwrap()
784 }
785
786 fn make_state() -> ClientState<TestClient> {
787 ClientState::new(engine_client_config(), &TestConfig {})
788 }
789
790 fn frame_bytes(server_time: f64, seq: u32, x: f32, with_player: bool) -> Vec<u8> {
791 let cfg = engine_client_config();
792 let mut packer = SnapshotPacker::new(cfg.snapshot.clone());
793
794 packer
795 .pack_body(&[(
796 "actor".to_string(),
797 Block::Indexed8(vec![(2, Some(vec![FieldValue::F32(x), FieldValue::F32(0.0)]))]),
798 )])
799 .unwrap();
800
801 let camera = CameraData {
802 x,
803 y: 0.0,
804 force_reset: false,
805 shake: None,
806 };
807 let player = PlayerBlock {
808 game_id: 2,
809 input_seq: 0,
810 state: [x, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
811 centering: false,
812 };
813
814 packer
815 .pack_frame(
816 server_time,
817 seq,
818 Some(&camera),
819 with_player.then_some(&player),
820 )
821 .to_vec()
822 }
823
824 #[test]
825 fn push_frame_and_sample_writes_hot_layout() {
826 let mut state = make_state();
827
828 state.push_frame(&frame_bytes(1000.0, 1, 10.0, false), 1000.0);
829 state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
830
831 let len = state.sample(1150.0);
833 let hot = state.hot().to_vec();
834
835 assert_eq!(len, hot.len());
836
837 let flags = hot[0] as u32;
838
839 assert!(flags & HOT_HAS_GAME != 0);
840 assert!(flags & HOT_HAS_CAMERA != 0);
841 assert!(flags & HOT_HAS_FRAMES != 0);
842 assert!(flags & HOT_HAS_PREDICTED == 0);
843
844 assert_eq!(hot[3], 1.0);
846 assert_eq!(hot[4], 1.0);
847 assert_eq!(hot[5], 2.0);
848 assert_eq!(hot[6], 15.0);
849
850 let frames: Vec<serde_json::Value> =
851 serde_json::from_str(&state.take_frames()).unwrap();
852
853 assert_eq!(frames.len(), 1);
854 assert_eq!(state.take_frames(), "[]");
855 }
856
857 #[test]
858 fn render_overlay_appends_opaque_tail_and_sets_flag() {
859 let mut state = make_state();
860
861 state.set_active(true);
862 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
863
864 assert_eq!(state.my_game_id(), Some(2));
865
866 state.sample(1150.0);
867
868 let hot = state.hot().to_vec();
869 let flags = hot[0] as u32;
870
871 assert!(flags & HOT_HAS_PREDICTED != 0);
872
873 let tail = &hot[hot.len() - 4..];
875
876 assert_eq!(tail[1], 2.0); assert_eq!(hot[1], tail[2]); }
879
880 #[test]
881 fn reconcile_hooks_wrap_the_replay() {
882 let mut state = make_state();
883
884 state.set_active(true);
885
886 state.push_frame(&frame_bytes(1000.0, 1, 10.0, false), 1000.0);
888 assert!(state.game.reconcile_log.is_empty());
889
890 state.push_frame(&frame_bytes(1100.0, 2, 20.0, true), 1100.0);
891
892 assert_eq!(state.game.reconcile_log, vec!["begin", "state", "finish"]);
895 }
896
897 #[test]
898 fn render_rows_follow_the_tail_and_keep_schema_width() {
899 let mut state = make_state();
900
901 state.set_active(true);
902 state.set_model("rows");
903 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
904 state.push_frame(&frame_bytes(1100.0, 2, 20.0, true), 1100.0);
905 state.sample(1150.0);
906
907 let hot = state.hot().to_vec();
908
909 let rows = &hot[hot.len() - 8..];
912
913 assert_eq!(rows[0], 1.0); assert_eq!(rows[1], 2.0); assert_eq!(rows[2], 111.0);
916 assert_eq!(rows[3], 222.0);
917
918 assert_eq!(rows[4], 1.0);
920 assert_eq!(rows[5], 7.0);
921 assert_eq!(rows[6], 333.0);
922 assert_eq!(rows[7], 0.0);
923
924 assert_eq!(hot[5], 2.0);
927 assert_eq!(hot[6], 15.0);
928 }
929
930 #[test]
931 fn render_rows_default_to_empty() {
932 let mut state = make_state();
933
934 state.set_active(true);
935 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
936 state.sample(1150.0);
937
938 let hot = state.hot().to_vec();
940
941 assert_eq!(hot.len(), 3 + 1 + 4 + 1 + 4);
942 }
943
944 #[test]
945 fn reset_clears_predictor_and_frame_queue() {
946 let mut state = make_state();
947
948 state.set_active(true);
949 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
950 state.sample(1150.0);
951
952 state.reset();
953
954 assert_eq!(state.take_frames(), "[]");
955 }
956
957 #[test]
958 fn resync_clears_network_half_only() {
959 let mut state = make_state();
960
961 state.set_active(true);
962 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
963 state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
964 state.sample(1150.0);
965
966 state.resync();
967
968 let dump: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
969
970 assert_eq!(dump["interpolator"]["buffered"], 0);
971 assert!(dump["interpolator"]["lastFrame"].is_null());
972 assert!(dump["offset"].is_null());
973 assert_eq!(dump["framesOut"], 0);
974 assert_eq!(state.take_frames(), "[]");
975
976 assert_eq!(state.my_game_id(), Some(2));
978 }
979
980 #[test]
981 fn debug_json_reports_buffer_seq_window_and_offset() {
982 let mut state = make_state();
983
984 let empty: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
985
986 assert!(empty["myGameId"].is_null());
987 assert_eq!(empty["interpolator"]["buffered"], 0);
988 assert!(empty["interpolator"]["lastFrame"].is_null());
989
990 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
991 state.push_frame(&frame_bytes(1100.0, 2, 20.0, false), 1100.0);
992 state.sample(1150.0);
993
994 let dump: serde_json::Value = serde_json::from_str(&state.debug_json()).unwrap();
995
996 assert_eq!(dump["myGameId"], 2);
997 assert_eq!(dump["interpolator"]["buffered"], 2);
998 assert_eq!(dump["interpolator"]["seqWindow"], serde_json::json!([1, 2]));
999 assert_eq!(dump["interpolator"]["lastFrame"]["seq"], 2);
1000 assert_eq!(dump["interpolator"]["lastFrame"]["serverTime"], 1100.0);
1001 assert_eq!(dump["interpolator"]["delay"], 100.0);
1002 assert_eq!(dump["offset"], dump["interpolator"]["offset"]);
1003 assert_eq!(dump["hotLen"], state.hot().len());
1004 assert_eq!(dump["framesOut"], 1);
1005 }
1006
1007 #[test]
1010 fn divergence_is_off_without_config() {
1011 let mut state = make_state();
1012
1013 state.set_active(true);
1014 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1015
1016 assert_eq!(state.take_divergence(), "null");
1017 }
1018
1019 #[test]
1022 fn divergence_falls_back_to_overlay_camera() {
1023 let mut state = ClientState::<TestClient>::new(config_with_divergence(8), &TestConfig {});
1024
1025 state.set_active(true);
1026 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1027
1028 let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1029 let record = &dump["records"][0];
1030
1031 assert_eq!(dump["samples"], 1);
1032 assert_eq!(dump["violations"], 1);
1033 assert_eq!(record["source"], "camera");
1034 assert_eq!(record["predicted"], serde_json::json!([0.0, 0.0]));
1035 assert_eq!(record["authoritative"], serde_json::json!([10.0, 0.0]));
1036 assert_eq!(record["delta"][0], -10.0);
1037 assert_eq!(record["exceeded"], serde_json::json!([0]));
1038 assert_eq!(record["thresholds"][0], 1.0);
1039 assert!(record["replayed"].is_null());
1040 }
1041
1042 #[test]
1046 fn divergence_reports_predicted_state_and_replay_window() {
1047 let mut state = ClientState::<TestClient>::new(config_with_divergence(8), &TestConfig {});
1048
1049 state.set_active(true);
1050 state.set_model("predicted");
1051
1052 state.push_frame(&frame_bytes(1000.0, 1, 0.0, true), 1000.0);
1054 state.sample(1100.0);
1055 state.push_frame(&frame_bytes(1100.0, 2, 50.0, true), 1100.0);
1056
1057 let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1058
1059 assert_eq!(dump["samples"], 2);
1060 assert_eq!(dump["violations"], 1);
1061 assert_eq!(dump["records"].as_array().unwrap().len(), 1);
1062 assert_eq!(dump["maxDelta"][0], 50.0);
1063
1064 let record = &dump["records"][0];
1065
1066 assert_eq!(record["source"], "state");
1067 assert_eq!(record["serverTime"], 1100.0);
1068 assert_eq!(record["localNow"], 1100.0);
1069 assert_eq!(record["inputSeq"], 0);
1070 assert_eq!(record["delta"][0], -50.0);
1071 assert_eq!(record["exceeded"], serde_json::json!([0]));
1072 assert_eq!(record["replayed"]["from"], 1050.0);
1073 assert_eq!(record["replayed"]["to"], 1100.0);
1074 assert_eq!(record["replayed"]["count"], 2);
1075
1076 let drained: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1078
1079 assert_eq!(drained["records"].as_array().unwrap().len(), 0);
1080 assert_eq!(drained["samples"], 2);
1081 assert_eq!(drained["maxDelta"][0], 50.0);
1082 }
1083
1084 #[test]
1085 fn divergence_ring_buffer_evicts_oldest_records() {
1086 let mut state = ClientState::<TestClient>::new(config_with_divergence(1), &TestConfig {});
1087
1088 state.set_active(true);
1089 state.set_model("predicted");
1090 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1091 state.push_frame(&frame_bytes(1100.0, 2, 90.0, true), 1100.0);
1092
1093 let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1094
1095 assert_eq!(dump["violations"], 2);
1096 assert_eq!(dump["dropped"], 1);
1097 assert_eq!(dump["records"].as_array().unwrap().len(), 1);
1098 assert_eq!(dump["records"][0]["serverTime"], 1100.0);
1099 }
1100
1101 #[test]
1104 fn divergence_zero_capacity_counts_evictions_honestly() {
1105 let mut state = ClientState::<TestClient>::new(config_with_divergence(0), &TestConfig {});
1106
1107 state.set_active(true);
1108 state.set_model("predicted");
1109 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1110 state.push_frame(&frame_bytes(1100.0, 2, 90.0, true), 1100.0);
1111
1112 let dump: serde_json::Value = serde_json::from_str(&state.take_divergence()).unwrap();
1113
1114 assert_eq!(dump["violations"], 2);
1115 assert_eq!(dump["dropped"], 1);
1116 assert_eq!(dump["records"].as_array().unwrap().len(), 1);
1117 }
1118
1119 #[test]
1125 fn second_schema_key_of_different_block_kind_flows_into_hot_buffer() {
1126 let config = serde_json::json!({
1127 "timeStepMs": 1000.0 / 120.0,
1128 "snapshot": {
1129 "version": 3,
1130 "port": 5,
1131 "keys": {
1132 "actor": { "id": 1, "kind": "indexed8", "class": "hot", "fields": [
1133 { "name": "x", "ty": "f32", "interp": "lerp" },
1134 { "name": "y", "ty": "f32", "interp": "lerp" }
1135 ] },
1136 "zone": { "id": 2, "kind": "indexedNoNull8", "class": "hot", "fields": [
1137 { "name": "level", "ty": "f32", "interp": "discrete" }
1138 ] }
1139 }
1140 },
1141 "interpolation": { "delay": 100, "maxFrameAge": 1000 }
1142 });
1143 let cfg: EngineClientConfig = serde_json::from_value(config).unwrap();
1144 let mut state = ClientState::<TestClient>::new(cfg.clone(), &TestConfig {});
1145 let mut packer = SnapshotPacker::new(cfg.snapshot.clone());
1146
1147 packer
1148 .pack_body(&[
1149 (
1150 "actor".to_string(),
1151 Block::Indexed8(vec![(2, Some(vec![FieldValue::F32(10.0), FieldValue::F32(0.0)]))]),
1152 ),
1153 (
1154 "zone".to_string(),
1155 Block::IndexedNoNull8(vec![(0, vec![FieldValue::F32(7.0)])]),
1156 ),
1157 ])
1158 .unwrap();
1159
1160 let frame = packer.pack_frame(1000.0, 1, None, None).to_vec();
1161
1162 state.push_frame(&frame, 1000.0);
1163 state.push_frame(&frame, 1100.0);
1164 state.sample(1150.0);
1165
1166 let hot = state.hot().to_vec();
1167
1168 assert_eq!(hot[3], 1.0); assert_eq!(hot[8], 1.0); assert_eq!(hot[9], 2.0); assert_eq!(hot[11], 7.0); }
1174
1175 #[test]
1176 fn reset_clears_my_game_id() {
1177 let mut state = make_state();
1178
1179 state.push_frame(&frame_bytes(1000.0, 1, 10.0, true), 1000.0);
1180 state.push_frame(&frame_bytes(1100.0, 2, 20.0, true), 1100.0);
1181 state.sample(1150.0);
1182 assert_eq!(state.my_game_id(), Some(2));
1183
1184 state.reset();
1187 assert_eq!(state.my_game_id(), None);
1188 }
1189}