Skip to main content

fortress_rollback/sessions/
sync_test_session.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::sync::Arc;
4
5use crate::error::{FortressError, InternalErrorKind, InvalidRequestKind};
6use crate::frame_info::PlayerInput;
7use crate::network::messages::ConnectionStatus;
8use crate::report_violation;
9use crate::sessions::config::SaveMode;
10use crate::sync_layer::SyncLayer;
11use crate::telemetry::{ViolationKind, ViolationObserver, ViolationSeverity};
12use crate::{Config, FortressRequest, Frame, PlayerHandle};
13
14/// During a [`SyncTestSession`], Fortress Rollback will simulate a rollback every frame and resimulate the last n states, where n is the given check distance.
15///
16/// The resimulated checksums will be compared with the original checksums and report if there was a mismatch.
17pub struct SyncTestSession<T>
18where
19    T: Config,
20{
21    num_players: usize,
22    max_prediction: usize,
23    check_distance: usize,
24    sync_layer: SyncLayer<T>,
25    dummy_connect_status: Vec<ConnectionStatus>,
26    checksum_history: BTreeMap<Frame, Option<u128>>,
27    local_inputs: BTreeMap<PlayerHandle, PlayerInput<T::Input>>,
28    /// Optional observer for specification violations.
29    violation_observer: Option<Arc<dyn ViolationObserver>>,
30}
31
32impl<T: Config> SyncTestSession<T> {
33    /// Creates a new sync test session with the default queue length.
34    ///
35    /// Note: This function exists for backward compatibility.
36    /// The main construction path uses `with_queue_length` via `SessionBuilder`.
37    #[allow(dead_code)]
38    pub(crate) fn new(
39        num_players: usize,
40        max_prediction: usize,
41        check_distance: usize,
42        input_delay: usize,
43        violation_observer: Option<Arc<dyn ViolationObserver>>,
44    ) -> Self {
45        Self::with_queue_length(
46            num_players,
47            max_prediction,
48            check_distance,
49            input_delay,
50            violation_observer,
51            crate::input_queue::INPUT_QUEUE_LENGTH,
52        )
53    }
54
55    pub(crate) fn with_queue_length(
56        num_players: usize,
57        max_prediction: usize,
58        check_distance: usize,
59        input_delay: usize,
60        violation_observer: Option<Arc<dyn ViolationObserver>>,
61        queue_length: usize,
62    ) -> Self {
63        let dummy_connect_status = vec![ConnectionStatus::default(); num_players];
64
65        let mut sync_layer =
66            SyncLayer::with_queue_length(num_players, max_prediction, queue_length);
67        for i in 0..num_players {
68            // This should never fail during construction as player handles are sequential and valid
69            if let Err(e) = sync_layer.set_frame_delay(PlayerHandle::new(i), input_delay) {
70                report_violation!(
71                    ViolationSeverity::Critical,
72                    ViolationKind::InternalError,
73                    "Failed to set frame delay for player {} during session construction: {}",
74                    i,
75                    e
76                );
77            }
78        }
79
80        Self {
81            num_players,
82            max_prediction,
83            check_distance,
84            sync_layer,
85            dummy_connect_status,
86            checksum_history: BTreeMap::new(),
87            local_inputs: BTreeMap::new(),
88            violation_observer,
89        }
90    }
91
92    /// Registers local input for a player for the current frame. This should be successfully called for every local player before calling [`advance_frame()`].
93    /// If this is called multiple times for the same player before advancing the frame, older given inputs will be overwritten.
94    /// In a sync test, all players are considered to be local, so you need to add input for all of them.
95    ///
96    /// # Errors
97    /// - Returns a [`FortressError`] when the given handle is not valid (i.e. not between 0 and num_players).
98    ///
99    /// [`advance_frame()`]: Self#method.advance_frame
100    pub fn add_local_input(
101        &mut self,
102        player_handle: PlayerHandle,
103        input: T::Input,
104    ) -> Result<(), FortressError> {
105        if !player_handle.is_valid_player_for(self.num_players) {
106            return Err(InvalidRequestKind::InvalidLocalPlayerHandle {
107                handle: player_handle,
108                num_players: self.num_players,
109            }
110            .into());
111        }
112        let player_input = PlayerInput::<T::Input>::new(self.sync_layer.current_frame(), input);
113        self.local_inputs.insert(player_handle, player_input);
114        Ok(())
115    }
116
117    /// In a sync test, this will advance the state by a single frame and afterwards rollback `check_distance` amount of frames,
118    /// resimulate and compare checksums with the original states. Returns an order-sensitive [`Vec<FortressRequest>`].
119    /// You should fulfill all requests in the exact order they are provided. Failure to do so will cause panics later.
120    ///
121    /// # Errors
122    /// - Returns [`MismatchedChecksum`] if checksums don't match after resimulation.
123    ///
124    /// [`Vec<FortressRequest>`]: FortressRequest
125    /// [`MismatchedChecksum`]: FortressError::MismatchedChecksum
126    #[must_use = "FortressRequests must be processed to advance the game state"]
127    pub fn advance_frame(&mut self) -> Result<Vec<FortressRequest<T>>, FortressError> {
128        // Pre-allocate with capacity for typical case: 1 save + 1 advance = 2 requests.
129        // During rollback testing, more requests will be added as the Vec grows.
130        let mut requests = Vec::with_capacity(2);
131
132        // if we advanced far enough into the game do comparisons and rollbacks
133        let current_frame = self.sync_layer.current_frame();
134        if self.check_distance > 0 && current_frame.as_i32() > self.check_distance as i32 {
135            // compare checksums of older frames to our checksum history (where only the first version of any checksum is recorded)
136            let oldest_frame_to_check = current_frame.as_i32() - self.check_distance as i32;
137            let mismatched_frames: Vec<_> = (oldest_frame_to_check..=current_frame.as_i32())
138                .filter(|&frame_to_check| !self.checksums_consistent(Frame::new(frame_to_check)))
139                .map(Frame::new)
140                .collect();
141
142            if !mismatched_frames.is_empty() {
143                return Err(FortressError::MismatchedChecksum {
144                    current_frame,
145                    mismatched_frames,
146                });
147            }
148
149            // simulate rollbacks according to the check_distance
150            let frame_to = self.sync_layer.current_frame() - self.check_distance as i32;
151            self.adjust_gamestate(frame_to, &mut requests)?;
152        }
153
154        // we require inputs for all players
155        if self.num_players != self.local_inputs.len() {
156            return Err(InvalidRequestKind::MissingLocalInput.into());
157        }
158        // pass all inputs into the sync layer
159        for (&handle, &input) in self.local_inputs.iter() {
160            // send the input into the sync layer
161            self.sync_layer.add_local_input(handle, input);
162        }
163        // clear local inputs after using them
164        self.local_inputs.clear();
165
166        // save the current frame in the synchronization layer
167        // we can skip all the saving if the check_distance is 0
168        if self.check_distance > 0 {
169            requests.push(self.sync_layer.save_current_state());
170        }
171
172        // get the correct inputs for all players from the sync layer
173        let inputs = match self
174            .sync_layer
175            .synchronized_inputs(&self.dummy_connect_status)
176        {
177            Some(inputs) => inputs,
178            None => {
179                report_violation!(
180                    ViolationSeverity::Critical,
181                    ViolationKind::InternalError,
182                    "Failed to get synchronized inputs for frame {}",
183                    self.sync_layer.current_frame()
184                );
185                return Err(FortressError::InternalErrorStructured {
186                    kind: InternalErrorKind::SynchronizedInputsFailed {
187                        frame: self.sync_layer.current_frame(),
188                    },
189                });
190            },
191        };
192
193        // advance the frame
194        requests.push(FortressRequest::AdvanceFrame { inputs });
195        self.sync_layer.advance_frame();
196
197        // since this is a sync test, we "cheat" by setting the last confirmed state to the (current state - check_distance), so the sync layer won't complain about missing
198        // inputs from other players
199        let safe_frame = self.sync_layer.current_frame() - self.check_distance as i32;
200
201        self.sync_layer
202            .set_last_confirmed_frame(safe_frame, SaveMode::EveryFrame);
203
204        // also, we update the dummy connect status to pretend that we received inputs from all players
205        for con_stat in &mut self.dummy_connect_status {
206            con_stat.last_frame = self.sync_layer.current_frame();
207        }
208
209        Ok(requests)
210    }
211
212    /// Returns the current frame of a session.
213    #[must_use]
214    pub fn current_frame(&self) -> Frame {
215        self.sync_layer.current_frame()
216    }
217
218    /// Returns the number of players this session was constructed with.
219    #[must_use]
220    pub fn num_players(&self) -> usize {
221        self.num_players
222    }
223
224    /// Returns the maximum prediction window of a session.
225    #[must_use]
226    pub fn max_prediction(&self) -> usize {
227        self.max_prediction
228    }
229
230    /// Returns the check distance set on creation, i.e. the length of the simulated rollbacks
231    #[must_use]
232    pub fn check_distance(&self) -> usize {
233        self.check_distance
234    }
235
236    /// Returns a reference to the violation observer, if one was configured.
237    ///
238    /// This allows checking for violations that occurred during session operations
239    /// when using a [`CollectingObserver`] or similar.
240    ///
241    /// [`CollectingObserver`]: crate::telemetry::CollectingObserver
242    #[must_use]
243    pub fn violation_observer(&self) -> Option<&Arc<dyn ViolationObserver>> {
244        self.violation_observer.as_ref()
245    }
246
247    /// Updates the `checksum_history` and checks if the checksum is identical if it already has been recorded once
248    fn checksums_consistent(&mut self, frame_to_check: Frame) -> bool {
249        // remove entries older than the `check_distance`
250        let oldest_allowed_frame = self.sync_layer.current_frame() - self.check_distance as i32;
251        self.checksum_history
252            .retain(|&k, _| k >= oldest_allowed_frame);
253
254        match self.sync_layer.saved_state_by_frame(frame_to_check) {
255            Some(latest_cell) => match self.checksum_history.get(&latest_cell.frame()) {
256                Some(&cs) => cs == latest_cell.checksum(),
257                None => {
258                    self.checksum_history
259                        .insert(latest_cell.frame(), latest_cell.checksum());
260                    true
261                },
262            },
263            None => true,
264        }
265    }
266
267    fn adjust_gamestate(
268        &mut self,
269        frame_to: Frame,
270        requests: &mut Vec<FortressRequest<T>>,
271    ) -> Result<(), FortressError> {
272        let start_frame = self.sync_layer.current_frame();
273        let count = start_frame - frame_to;
274
275        // rollback to the first incorrect state
276        requests.push(self.sync_layer.load_frame(frame_to)?);
277        self.sync_layer.reset_prediction();
278        let actual_frame = self.sync_layer.current_frame();
279        if actual_frame != frame_to {
280            report_violation!(
281                ViolationSeverity::Error,
282                ViolationKind::FrameSync,
283                "current frame mismatch after load: expected={}, actual={}",
284                frame_to,
285                actual_frame
286            );
287        }
288
289        // step forward to the previous current state
290        for i in 0..count {
291            let inputs = match self
292                .sync_layer
293                .synchronized_inputs(&self.dummy_connect_status)
294            {
295                Some(inputs) => inputs,
296                None => {
297                    report_violation!(
298                        ViolationSeverity::Critical,
299                        ViolationKind::InternalError,
300                        "Failed to get synchronized inputs during resimulation at frame {}",
301                        self.sync_layer.current_frame()
302                    );
303                    return Err(FortressError::InternalErrorStructured {
304                        kind: InternalErrorKind::SynchronizedInputsFailed {
305                            frame: self.sync_layer.current_frame(),
306                        },
307                    });
308                },
309            };
310
311            // first save (except in the first step, because we just loaded that state)
312            if i > 0 {
313                requests.push(self.sync_layer.save_current_state());
314            }
315            // then advance
316            self.sync_layer.advance_frame();
317
318            requests.push(FortressRequest::AdvanceFrame { inputs });
319        }
320        let final_frame = self.sync_layer.current_frame();
321        if final_frame != start_frame {
322            report_violation!(
323                ViolationSeverity::Error,
324                ViolationKind::FrameSync,
325                "current frame mismatch after resimulation: expected={}, actual={}",
326                start_frame,
327                final_frame
328            );
329        }
330        Ok(())
331    }
332}
333
334impl<T: Config> fmt::Debug for SyncTestSession<T> {
335    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336        f.debug_struct("SyncTestSession")
337            .field("num_players", &self.num_players)
338            .field("max_prediction", &self.max_prediction)
339            .field("check_distance", &self.check_distance)
340            .field("current_frame", &self.sync_layer.current_frame())
341            .finish_non_exhaustive()
342    }
343}
344
345#[cfg(test)]
346#[allow(
347    clippy::panic,
348    clippy::unwrap_used,
349    clippy::expect_used,
350    clippy::indexing_slicing
351)]
352mod tests {
353    use super::*;
354    use crate::telemetry::CollectingObserver;
355    use std::net::SocketAddr;
356
357    /// A minimal test configuration for unit testing.
358    struct TestConfig;
359
360    impl Config for TestConfig {
361        type Input = u32;
362        type State = Vec<u8>;
363        type Address = SocketAddr;
364    }
365
366    // ==========================================
367    // Constructor Tests
368    // ==========================================
369
370    #[test]
371    fn sync_test_session_new_creates_valid_session() {
372        let session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 2, 2, None);
373
374        assert_eq!(session.num_players(), 2);
375        assert_eq!(session.max_prediction(), 8);
376        assert_eq!(session.check_distance(), 2);
377        assert_eq!(session.current_frame(), Frame::new(0));
378        assert!(session.violation_observer().is_none());
379    }
380
381    #[test]
382    fn sync_test_session_with_queue_length_creates_valid_session() {
383        let session: SyncTestSession<TestConfig> =
384            SyncTestSession::with_queue_length(4, 16, 3, 1, None, 64);
385
386        assert_eq!(session.num_players(), 4);
387        assert_eq!(session.max_prediction(), 16);
388        assert_eq!(session.check_distance(), 3);
389        assert_eq!(session.current_frame(), Frame::new(0));
390    }
391
392    #[test]
393    fn sync_test_session_with_violation_observer() {
394        let observer = Arc::new(CollectingObserver::new());
395        let session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 2, 2, Some(observer));
396
397        assert!(session.violation_observer().is_some());
398    }
399
400    #[test]
401    fn sync_test_session_single_player() {
402        let session: SyncTestSession<TestConfig> = SyncTestSession::new(1, 8, 2, 0, None);
403
404        assert_eq!(session.num_players(), 1);
405    }
406
407    #[test]
408    fn sync_test_session_zero_check_distance() {
409        let session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 0, 2, None);
410
411        assert_eq!(session.check_distance(), 0);
412    }
413
414    #[test]
415    fn sync_test_session_zero_input_delay() {
416        let session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 2, 0, None);
417
418        // Just ensure construction succeeds
419        assert_eq!(session.current_frame(), Frame::new(0));
420    }
421
422    // ==========================================
423    // add_local_input Tests
424    // ==========================================
425
426    #[test]
427    fn add_local_input_valid_handle_succeeds() {
428        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 0, 0, None);
429
430        session.add_local_input(PlayerHandle::new(0), 42).unwrap();
431        session.add_local_input(PlayerHandle::new(1), 100).unwrap();
432    }
433
434    #[test]
435    fn add_local_input_invalid_handle_fails() {
436        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 0, 0, None);
437
438        let result = session.add_local_input(PlayerHandle::new(2), 42);
439        assert!(result.is_err());
440
441        assert!(matches!(
442            result,
443            Err(FortressError::InvalidRequestStructured {
444                kind: InvalidRequestKind::InvalidLocalPlayerHandle { .. }
445            })
446        ));
447    }
448
449    #[test]
450    fn add_local_input_overwrites_previous_input() {
451        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(1, 8, 0, 0, None);
452
453        // Add first input
454        session
455            .add_local_input(PlayerHandle::new(0), 42)
456            .expect("should succeed");
457
458        // Overwrite with second input
459        session
460            .add_local_input(PlayerHandle::new(0), 100)
461            .expect("should succeed");
462
463        // Advance frame to verify the latest input is used
464        let requests = session.advance_frame().expect("should advance");
465
466        // Find the AdvanceFrame request
467        let advance_request = requests
468            .iter()
469            .find(|r| matches!(r, FortressRequest::AdvanceFrame { .. }));
470        assert!(advance_request.is_some());
471
472        if let Some(FortressRequest::AdvanceFrame { inputs }) = advance_request {
473            assert_eq!(inputs[0].0, 100); // Second input should be used
474        }
475    }
476
477    // ==========================================
478    // advance_frame Tests
479    // ==========================================
480
481    #[test]
482    fn advance_frame_requires_all_inputs() {
483        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 0, 0, None);
484
485        // Only add input for player 0
486        session
487            .add_local_input(PlayerHandle::new(0), 42)
488            .expect("should succeed");
489
490        let result = session.advance_frame();
491        assert!(result.is_err());
492
493        assert!(matches!(
494            result,
495            Err(FortressError::InvalidRequestStructured {
496                kind: InvalidRequestKind::MissingLocalInput
497            })
498        ));
499    }
500
501    #[test]
502    fn advance_frame_with_all_inputs_succeeds() {
503        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 0, 0, None);
504
505        session
506            .add_local_input(PlayerHandle::new(0), 42)
507            .expect("should succeed");
508        session
509            .add_local_input(PlayerHandle::new(1), 100)
510            .expect("should succeed");
511
512        let requests = session.advance_frame().unwrap();
513        // With check_distance 0, we should only get AdvanceFrame
514        assert!(requests
515            .iter()
516            .any(|r| matches!(r, FortressRequest::AdvanceFrame { .. })));
517    }
518
519    #[test]
520    fn advance_frame_increments_current_frame() {
521        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(1, 8, 0, 0, None);
522
523        assert_eq!(session.current_frame(), Frame::new(0));
524
525        session
526            .add_local_input(PlayerHandle::new(0), 42)
527            .expect("should succeed");
528        session.advance_frame().expect("should advance");
529
530        assert_eq!(session.current_frame(), Frame::new(1));
531    }
532
533    #[test]
534    fn advance_frame_clears_inputs() {
535        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(1, 8, 0, 0, None);
536
537        session
538            .add_local_input(PlayerHandle::new(0), 42)
539            .expect("should succeed");
540        session.advance_frame().expect("should advance");
541
542        // Next advance should fail because inputs are cleared
543        let result = session.advance_frame();
544        assert!(result.is_err());
545    }
546
547    #[test]
548    fn advance_frame_with_check_distance_produces_save_request() {
549        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(1, 8, 2, 0, None);
550
551        session
552            .add_local_input(PlayerHandle::new(0), 42)
553            .expect("should succeed");
554
555        let requests = session.advance_frame().expect("should advance");
556
557        // With check_distance > 0, we should get a SaveGameState request
558        assert!(requests
559            .iter()
560            .any(|r| matches!(r, FortressRequest::SaveGameState { .. })));
561    }
562
563    #[test]
564    fn advance_frame_multiple_times() {
565        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(1, 8, 0, 0, None);
566
567        for frame in 1..=10 {
568            session
569                .add_local_input(PlayerHandle::new(0), frame as u32)
570                .expect("should succeed");
571            session.advance_frame().expect("should advance");
572            assert_eq!(session.current_frame(), Frame::new(frame));
573        }
574    }
575
576    #[test]
577    fn advance_frame_no_input_for_any_player() {
578        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 0, 0, None);
579
580        // Don't add any inputs
581        let result = session.advance_frame();
582        assert!(result.is_err());
583
584        assert!(matches!(
585            result,
586            Err(FortressError::InvalidRequestStructured {
587                kind: InvalidRequestKind::MissingLocalInput
588            })
589        ));
590    }
591
592    // ==========================================
593    // Getter Tests
594    // ==========================================
595
596    #[test]
597    fn current_frame_starts_at_zero() {
598        let session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 2, 2, None);
599        assert_eq!(session.current_frame(), Frame::new(0));
600    }
601
602    #[test]
603    fn num_players_returns_correct_value() {
604        for num_players in 1..=4 {
605            let session: SyncTestSession<TestConfig> =
606                SyncTestSession::new(num_players, 8, 2, 2, None);
607            assert_eq!(session.num_players(), num_players);
608        }
609    }
610
611    #[test]
612    fn max_prediction_returns_correct_value() {
613        for max_prediction in [4, 8, 16, 32] {
614            let session: SyncTestSession<TestConfig> =
615                SyncTestSession::new(2, max_prediction, 2, 2, None);
616            assert_eq!(session.max_prediction(), max_prediction);
617        }
618    }
619
620    #[test]
621    fn check_distance_returns_correct_value() {
622        for check_distance in 0..=10 {
623            let session: SyncTestSession<TestConfig> =
624                SyncTestSession::new(2, 8, check_distance, 2, None);
625            assert_eq!(session.check_distance(), check_distance);
626        }
627    }
628
629    #[test]
630    fn violation_observer_none_when_not_set() {
631        let session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 2, 2, None);
632        assert!(session.violation_observer().is_none());
633    }
634
635    #[test]
636    fn violation_observer_some_when_set() {
637        let observer = Arc::new(CollectingObserver::new());
638        let session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 2, 2, Some(observer));
639
640        let stored_observer = session.violation_observer();
641        assert!(stored_observer.is_some());
642    }
643
644    // ==========================================
645    // Edge Case Tests
646    // ==========================================
647
648    #[test]
649    fn many_players_construction() {
650        // Test with a larger number of players
651        let session: SyncTestSession<TestConfig> = SyncTestSession::new(8, 16, 4, 2, None);
652
653        assert_eq!(session.num_players(), 8);
654        assert_eq!(session.max_prediction(), 16);
655    }
656
657    #[test]
658    fn large_check_distance() {
659        // Test with a check distance larger than typical
660        let session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 64, 32, 2, None);
661
662        assert_eq!(session.check_distance(), 32);
663        assert_eq!(session.max_prediction(), 64);
664    }
665
666    #[test]
667    fn small_queue_length() {
668        let session: SyncTestSession<TestConfig> =
669            SyncTestSession::with_queue_length(2, 8, 2, 2, None, 16);
670
671        assert_eq!(session.num_players(), 2);
672    }
673
674    // ==========================================
675    // Checksum Validation Tests
676    // ==========================================
677
678    /// Helper to run a sync test session for a specified number of frames
679    /// while simulating game state saves with consistent checksums.
680    fn run_session_with_checksums(
681        session: &mut SyncTestSession<TestConfig>,
682        num_frames: usize,
683        checksum_fn: impl Fn(Frame) -> Option<u128>,
684    ) {
685        let mut game_state: Vec<u8> = Vec::new();
686
687        for frame_num in 0..num_frames {
688            // Add inputs for all players
689            for player_id in 0..session.num_players() {
690                session
691                    .add_local_input(PlayerHandle::new(player_id), frame_num as u32)
692                    .expect("should succeed");
693            }
694
695            // Advance the frame and handle requests
696            let requests = session.advance_frame().expect("should advance");
697
698            for request in requests {
699                match request {
700                    FortressRequest::SaveGameState { cell, frame } => {
701                        let checksum = checksum_fn(frame);
702                        cell.save(frame, Some(game_state.clone()), checksum);
703                    },
704                    FortressRequest::LoadGameState { cell, .. } => {
705                        if let Some(loaded) = cell.load() {
706                            game_state = loaded;
707                        }
708                    },
709                    FortressRequest::AdvanceFrame { .. } => {
710                        // Simulate game advancement - append frame number to state
711                        game_state.push(frame_num as u8);
712                    },
713                }
714            }
715        }
716    }
717
718    #[test]
719    fn sync_test_with_consistent_checksums_succeeds() {
720        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 3, 0, None);
721
722        // Use consistent checksums - should succeed
723        run_session_with_checksums(&mut session, 20, |frame| Some(frame.as_i32() as u128));
724
725        // Should have advanced to frame 20
726        assert_eq!(session.current_frame(), Frame::new(20));
727    }
728
729    #[test]
730    fn sync_test_with_no_checksums_succeeds() {
731        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 3, 0, None);
732
733        // Use no checksums (None) - should still succeed since None == None
734        run_session_with_checksums(&mut session, 15, |_| None);
735
736        assert_eq!(session.current_frame(), Frame::new(15));
737    }
738
739    #[test]
740    fn sync_test_detects_mismatched_checksum() {
741        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(1, 8, 2, 0, None);
742
743        let mut game_state: Vec<u8> = Vec::new();
744        let mut call_count = 0;
745
746        // Simulate frames until we detect a mismatch
747        let mut detected_mismatch = false;
748        for frame_num in 0..20 {
749            session
750                .add_local_input(PlayerHandle::new(0), frame_num as u32)
751                .expect("should succeed");
752
753            match session.advance_frame() {
754                Ok(requests) => {
755                    for request in requests {
756                        match request {
757                            FortressRequest::SaveGameState { cell, frame } => {
758                                call_count += 1;
759                                // Return different checksum after many saves to trigger mismatch
760                                // The check_distance is 2, so checksums are compared after 2 frames
761                                let checksum = if call_count > 5 {
762                                    // Different checksum for resimulated states
763                                    Some(9999)
764                                } else {
765                                    Some(frame.as_i32() as u128)
766                                };
767                                cell.save(frame, Some(game_state.clone()), checksum);
768                            },
769                            FortressRequest::LoadGameState { cell, .. } => {
770                                if let Some(loaded) = cell.load() {
771                                    game_state = loaded;
772                                }
773                            },
774                            FortressRequest::AdvanceFrame { .. } => {
775                                game_state.push(frame_num as u8);
776                            },
777                        }
778                    }
779                },
780                Err(FortressError::MismatchedChecksum { .. }) => {
781                    detected_mismatch = true;
782                    break;
783                },
784                Err(e) => panic!("Unexpected error: {:?}", e),
785            }
786        }
787
788        // With different checksums during resimulation, we should detect a mismatch
789        assert!(
790            detected_mismatch,
791            "Should have detected mismatched checksums"
792        );
793    }
794
795    #[test]
796    fn sync_test_zero_check_distance_skips_rollback() {
797        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(1, 8, 0, 0, None);
798
799        // With check_distance 0, no SaveGameState or LoadGameState should be issued
800        session
801            .add_local_input(PlayerHandle::new(0), 42)
802            .expect("should succeed");
803
804        let requests = session.advance_frame().expect("should advance");
805
806        // Only AdvanceFrame, no SaveGameState
807        assert!(!requests
808            .iter()
809            .any(|r| matches!(r, FortressRequest::SaveGameState { .. })));
810        assert!(!requests
811            .iter()
812            .any(|r| matches!(r, FortressRequest::LoadGameState { .. })));
813        assert!(requests
814            .iter()
815            .any(|r| matches!(r, FortressRequest::AdvanceFrame { .. })));
816    }
817
818    #[test]
819    fn sync_test_rollback_happens_after_check_distance_frames() {
820        let check_distance = 3;
821        let mut session: SyncTestSession<TestConfig> =
822            SyncTestSession::new(1, 8, check_distance, 0, None);
823
824        let mut game_state: Vec<u8> = Vec::new();
825        let mut saw_load_request = false;
826
827        // Run enough frames to trigger rollback (> check_distance)
828        for frame_num in 0..=(check_distance + 2) {
829            session
830                .add_local_input(PlayerHandle::new(0), frame_num as u32)
831                .expect("should succeed");
832
833            let requests = session.advance_frame().expect("should advance");
834
835            for request in requests {
836                match request {
837                    FortressRequest::SaveGameState { cell, frame } => {
838                        cell.save(
839                            frame,
840                            Some(game_state.clone()),
841                            Some(frame.as_i32() as u128),
842                        );
843                    },
844                    FortressRequest::LoadGameState { cell, .. } => {
845                        saw_load_request = true;
846                        if let Some(loaded) = cell.load() {
847                            game_state = loaded;
848                        }
849                    },
850                    FortressRequest::AdvanceFrame { .. } => {
851                        game_state.push(frame_num as u8);
852                    },
853                }
854            }
855        }
856
857        // After passing check_distance, we should see load requests from rollback simulation
858        assert!(
859            saw_load_request,
860            "Should have seen LoadGameState after passing check_distance"
861        );
862    }
863
864    #[test]
865    fn sync_test_many_players_with_checksums() {
866        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(4, 8, 2, 0, None);
867
868        // Should work with multiple players
869        run_session_with_checksums(&mut session, 10, |frame| Some(frame.as_i32() as u128));
870
871        assert_eq!(session.current_frame(), Frame::new(10));
872        assert_eq!(session.num_players(), 4);
873    }
874
875    #[test]
876    fn sync_test_large_check_distance() {
877        let check_distance = 10;
878        let mut session: SyncTestSession<TestConfig> =
879            SyncTestSession::new(1, 32, check_distance, 0, None);
880
881        run_session_with_checksums(&mut session, 30, |frame| Some(frame.as_i32() as u128));
882
883        assert_eq!(session.current_frame(), Frame::new(30));
884        assert_eq!(session.check_distance(), check_distance);
885    }
886
887    // ==========================================
888    // Request Order Tests
889    // ==========================================
890
891    #[test]
892    fn requests_contain_advance_frame_with_inputs() {
893        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(2, 8, 0, 0, None);
894
895        session
896            .add_local_input(PlayerHandle::new(0), 111)
897            .expect("should succeed");
898        session
899            .add_local_input(PlayerHandle::new(1), 222)
900            .expect("should succeed");
901
902        let requests = session.advance_frame().expect("should advance");
903
904        let advance_request = requests
905            .iter()
906            .find(|r| matches!(r, FortressRequest::AdvanceFrame { .. }));
907        assert!(
908            advance_request.is_some(),
909            "Should have AdvanceFrame request"
910        );
911
912        if let Some(FortressRequest::AdvanceFrame { inputs }) = advance_request {
913            assert_eq!(inputs.len(), 2);
914            assert_eq!(inputs[0].0, 111);
915            assert_eq!(inputs[1].0, 222);
916        }
917    }
918
919    #[test]
920    fn requests_order_save_before_advance() {
921        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(1, 8, 2, 0, None);
922
923        session
924            .add_local_input(PlayerHandle::new(0), 42)
925            .expect("should succeed");
926
927        let requests = session.advance_frame().expect("should advance");
928
929        // Find positions of SaveGameState and AdvanceFrame
930        let save_pos = requests
931            .iter()
932            .position(|r| matches!(r, FortressRequest::SaveGameState { .. }));
933        let advance_pos = requests
934            .iter()
935            .position(|r| matches!(r, FortressRequest::AdvanceFrame { .. }));
936
937        // SaveGameState should come before AdvanceFrame (but after any LoadGameState from rollback)
938        assert!(
939            save_pos.is_some(),
940            "Should have SaveGameState with check_distance > 0"
941        );
942        assert!(advance_pos.is_some(), "Should have AdvanceFrame");
943
944        // The last AdvanceFrame should be after the last SaveGameState
945        // (The save is for the current frame, advance uses those inputs)
946        assert!(
947            save_pos < advance_pos,
948            "SaveGameState should come before the final AdvanceFrame"
949        );
950    }
951
952    // ==========================================
953    // SessionBuilder Integration Tests
954    // ==========================================
955
956    #[test]
957    fn sync_test_via_session_builder() {
958        use crate::SessionBuilder;
959
960        let session: SyncTestSession<TestConfig> = SessionBuilder::new()
961            .with_num_players(2)
962            .unwrap()
963            .with_max_prediction_window(8)
964            .with_check_distance(3)
965            .start_synctest_session()
966            .expect("should create session");
967
968        assert_eq!(session.num_players(), 2);
969        assert_eq!(session.max_prediction(), 8);
970        assert_eq!(session.check_distance(), 3);
971    }
972
973    #[test]
974    fn sync_test_builder_with_input_delay() {
975        use crate::SessionBuilder;
976
977        let session: SyncTestSession<TestConfig> = SessionBuilder::new()
978            .with_num_players(2)
979            .unwrap()
980            .with_input_delay(3)
981            .unwrap()
982            .with_check_distance(2)
983            .start_synctest_session()
984            .expect("should create session");
985
986        assert_eq!(session.num_players(), 2);
987    }
988
989    #[test]
990    fn sync_test_builder_with_observer() {
991        use crate::SessionBuilder;
992
993        let observer = Arc::new(CollectingObserver::new());
994        let session: SyncTestSession<TestConfig> = SessionBuilder::new()
995            .with_num_players(2)
996            .unwrap()
997            .with_violation_observer(observer)
998            .start_synctest_session()
999            .expect("should create session");
1000
1001        assert!(session.violation_observer().is_some());
1002    }
1003
1004    // ==========================================
1005    // Checksum History Retention Tests
1006    // ==========================================
1007
1008    #[test]
1009    fn checksum_history_is_pruned_over_time() {
1010        // This test verifies that old checksums are removed from history
1011        // to prevent unbounded memory growth
1012        let check_distance = 2;
1013        let mut session: SyncTestSession<TestConfig> =
1014            SyncTestSession::new(1, 8, check_distance, 0, None);
1015
1016        let mut game_state: Vec<u8> = Vec::new();
1017
1018        // Run for many frames
1019        for frame_num in 0..50 {
1020            session
1021                .add_local_input(PlayerHandle::new(0), frame_num as u32)
1022                .expect("should succeed");
1023
1024            let requests = session.advance_frame().expect("should advance");
1025
1026            for request in requests {
1027                match request {
1028                    FortressRequest::SaveGameState { cell, frame } => {
1029                        cell.save(
1030                            frame,
1031                            Some(game_state.clone()),
1032                            Some(frame.as_i32() as u128),
1033                        );
1034                    },
1035                    FortressRequest::LoadGameState { cell, .. } => {
1036                        if let Some(loaded) = cell.load() {
1037                            game_state = loaded;
1038                        }
1039                    },
1040                    FortressRequest::AdvanceFrame { .. } => {
1041                        game_state.push(frame_num as u8);
1042                    },
1043                }
1044            }
1045        }
1046
1047        // The session should complete successfully even with many frames
1048        // because old checksums are pruned
1049        assert_eq!(session.current_frame(), Frame::new(50));
1050    }
1051
1052    // ==========================================
1053    // Input Status Tests
1054    // ==========================================
1055
1056    #[test]
1057    fn advance_frame_returns_confirmed_input_status() {
1058        let mut session: SyncTestSession<TestConfig> = SyncTestSession::new(1, 8, 0, 0, None);
1059
1060        session
1061            .add_local_input(PlayerHandle::new(0), 42)
1062            .expect("should succeed");
1063
1064        let requests = session.advance_frame().expect("should advance");
1065
1066        if let Some(FortressRequest::AdvanceFrame { inputs }) = requests
1067            .iter()
1068            .find(|r| matches!(r, FortressRequest::AdvanceFrame { .. }))
1069        {
1070            // In sync test, all inputs should be confirmed
1071            assert_eq!(inputs[0].1, crate::InputStatus::Confirmed);
1072        } else {
1073            panic!("Should have AdvanceFrame request");
1074        }
1075    }
1076}