-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsync_test.rs
More file actions
337 lines (298 loc) · 12.4 KB
/
sync_test.rs
File metadata and controls
337 lines (298 loc) · 12.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
//! # SyncTest Example
//!
//! Demonstrates using `SyncTestSession` to verify determinism in your game logic.
//!
//! `SyncTestSession` is a testing tool that simulates rollbacks every frame and
//! verifies that your game state produces identical checksums when resimulated.
//! This is essential for catching determinism bugs early in development.
//!
//! ## How It Works
//!
//! 1. Every frame, the session saves the current state with a checksum
//! 2. After advancing past `check_distance` frames, it rolls back and resimulates
//! 3. If the resimulated state produces a different checksum, it reports an error
//!
//! ## When to Use
//!
//! - During development to catch non-deterministic code
//! - In CI/CD pipelines to prevent determinism regressions
//! - When debugging desync issues in multiplayer games
//!
//! Run with: `cargo run --example sync_test`
// Allow example-specific patterns
#![allow(
clippy::print_stdout,
clippy::print_stderr,
clippy::disallowed_macros,
clippy::panic,
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing
)]
use fortress_rollback::prelude::*;
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
// ============================================================================
// Game State - A simple counter that increments based on input
// ============================================================================
/// A minimal game state for determinism testing.
///
/// This is intentionally simple to demonstrate the sync test workflow.
/// In a real game, this would contain all game entities, physics state, etc.
#[derive(Clone, Default, Debug, PartialEq, Eq)]
struct CounterState {
/// The current frame number (for verification)
frame: i32,
/// Accumulated value from all player inputs
total: u64,
/// Per-player counters
player_values: [u64; 2],
}
impl CounterState {
/// Advances the state by one frame using the provided inputs.
///
/// This is where your actual game logic would go. For determinism,
/// this function must produce identical results given identical inputs.
fn advance(&mut self, inputs: &[(CounterInput, InputStatus)]) {
self.frame += 1;
for (player_idx, (input, status)) in inputs.iter().enumerate() {
// Skip disconnected players
if *status == InputStatus::Disconnected {
continue;
}
// Apply input
if input.increment {
let increment = u64::from(input.amount);
self.player_values[player_idx] =
self.player_values[player_idx].wrapping_add(increment);
self.total = self.total.wrapping_add(increment);
}
}
}
/// Computes a deterministic checksum of the game state.
///
/// This is critical for desync detection. The checksum must:
/// - Include ALL game state that affects gameplay
/// - Be computed the same way on all machines
/// - Use deterministic hashing (no HashMap iteration order, etc.)
fn checksum(&self) -> u128 {
// Simple checksum using XOR and bit shifting
// In production, consider using a proper hash like FNV-1a or xxHash
let mut hash: u128 = 0;
hash ^= self.frame as u128;
hash ^= (self.total as u128) << 32;
hash ^= (self.player_values[0] as u128) << 64;
hash ^= (self.player_values[1] as u128) << 96;
hash
}
}
// ============================================================================
// Input Type - What each player sends each frame
// ============================================================================
/// Player input for the counter game.
///
/// Input types must be:
/// - `Copy` + `Clone` - for efficient handling
/// - `PartialEq` - for prediction comparison
/// - `Default` - for "no input" / disconnected state
/// - `Serialize` + `Deserialize` - for network transmission
#[derive(Copy, Clone, PartialEq, Eq, Default, Debug, Serialize, Deserialize)]
struct CounterInput {
/// Whether to add to the counter this frame
increment: bool,
/// How much to add (if incrementing)
amount: u8,
}
// ============================================================================
// Config Type - Ties everything together
// ============================================================================
/// Configuration marker struct for the session.
///
/// This associates your Input, State, and Address types together.
struct CounterConfig;
impl Config for CounterConfig {
type Input = CounterInput;
type State = CounterState;
type Address = SocketAddr;
}
// ============================================================================
// Main Example
// ============================================================================
fn main() -> Result<(), FortressError> {
println!("=== Fortress Rollback Sync Test Example ===\n");
// Run a basic sync test
basic_sync_test()?;
// Demonstrate what happens with non-determinism (commented out by default)
// This would fail and demonstrate error handling:
// non_deterministic_test()?;
println!("\n=== All sync tests passed! ===");
Ok(())
}
/// Runs a basic sync test to verify deterministic game logic.
fn basic_sync_test() -> Result<(), FortressError> {
println!("--- Basic Sync Test ---\n");
// Step 1: Create a SyncTestSession using SessionBuilder
//
// Key configuration options:
// - num_players: How many players in the game
// - check_distance: How many frames back to rollback and verify (must be < max_prediction)
// - input_delay: Simulated input delay (usually 0 for sync tests)
// - max_prediction: Maximum frames that can be predicted ahead
let num_players = 2;
let check_distance = 2; // Roll back 2 frames and resimulate
let input_delay = 0;
let max_prediction = 8;
let mut session = SessionBuilder::<CounterConfig>::new()
.with_num_players(num_players)?
.with_check_distance(check_distance)
.with_input_delay(input_delay)?
.with_max_prediction_window(max_prediction)
.start_synctest_session()?;
println!("Created SyncTestSession:");
println!(" - Players: {}", session.num_players());
println!(" - Check distance: {}", session.check_distance());
println!(" - Max prediction: {}", session.max_prediction());
println!();
// Step 2: Initialize game state
let mut game_state = CounterState::default();
// Step 3: Run the simulation for several frames
let total_frames = 20;
println!("Running {} frames of simulation...\n", total_frames);
for frame in 0..total_frames {
// Step 3a: Add input for ALL players
// Use local_player_handles() to get all player handles in a sync test.
// In a sync test, all players are treated as local.
for (idx, handle) in session.local_player_handles().into_iter().enumerate() {
let input = CounterInput {
increment: frame % 3 != 0, // Increment 2 out of every 3 frames
amount: ((idx + 1) * 10) as u8,
};
session.add_local_input(handle, input)?;
}
// Step 3b: Advance the frame and get requests
// If checksums don't match, this returns MismatchedChecksum error
let requests = session.advance_frame()?;
// Step 3c: Process ALL requests in order
// This is critical - requests must be handled in the exact order given
for request in requests {
match request {
FortressRequest::SaveGameState { cell, frame } => {
// Save the current state with its checksum
let checksum = game_state.checksum();
cell.save(frame, Some(game_state.clone()), Some(checksum));
},
FortressRequest::LoadGameState { cell, .. } => {
// Load a previously saved state (during rollback)
if let Some(loaded_state) = cell.load() {
game_state = loaded_state;
}
},
FortressRequest::AdvanceFrame { inputs } => {
// Advance game state with the provided inputs
game_state.advance(&inputs);
},
}
}
// Progress indication
if (frame + 1) % 5 == 0 || frame == 0 {
println!(
"Frame {:>2}: total = {:>4}, players = {:?}",
session.current_frame().as_i32(),
game_state.total,
game_state.player_values
);
}
}
println!();
println!("Simulation complete!");
println!(" - Final frame: {}", session.current_frame().as_i32());
println!(" - Final total: {}", game_state.total);
println!(" - Final checksum: {:032x}", game_state.checksum());
println!();
println!("✓ No checksum mismatches detected - game logic is deterministic!");
Ok(())
}
/// Example of what happens when game logic is non-deterministic.
///
/// This is commented out in main() because it intentionally fails.
/// Uncomment the call in main() to see the error handling in action.
#[allow(dead_code)]
fn non_deterministic_test() -> Result<(), FortressError> {
println!("--- Non-Deterministic Test (Expected to Fail) ---\n");
let mut session = SessionBuilder::<CounterConfig>::new()
.with_num_players(1)?
.with_check_distance(2)
.with_input_delay(0)?
.with_max_prediction_window(8)
.start_synctest_session()?;
// State that intentionally produces different checksums on resimulation
#[derive(Clone, Default)]
struct BadState {
value: u64,
// Using a counter that changes behavior based on call count
// This is non-deterministic and will cause checksum mismatches
save_count: u64,
}
let mut game_state = CounterState::default();
let mut save_count: u64 = 0;
for frame in 0..20 {
session.add_local_input(
PlayerHandle::new(0),
CounterInput {
increment: true,
amount: 1,
},
)?;
match session.advance_frame() {
Ok(requests) => {
for request in requests {
match request {
FortressRequest::SaveGameState { cell, frame } => {
save_count += 1;
// BUG: Using save_count in checksum makes it non-deterministic!
// The first save will have a different checksum than resimulation.
let bad_checksum = game_state.checksum() ^ save_count as u128;
cell.save(frame, Some(game_state.clone()), Some(bad_checksum));
},
FortressRequest::LoadGameState { cell, .. } => {
if let Some(loaded) = cell.load() {
game_state = loaded;
}
},
FortressRequest::AdvanceFrame { inputs } => {
game_state.advance(&inputs);
},
}
}
},
Err(FortressError::MismatchedChecksum {
current_frame,
mismatched_frames,
}) => {
println!("✗ Detected checksum mismatch!");
println!(" - Current frame: {}", current_frame.as_i32());
println!(
" - Mismatched frames: {:?}",
mismatched_frames
.iter()
.map(|f| f.as_i32())
.collect::<Vec<_>>()
);
println!();
println!("This indicates non-deterministic game logic.");
println!("Common causes:");
println!(" - Using HashMap (iteration order is random)");
println!(" - Using system time or random without seeded RNG");
println!(" - Floating-point operations with different precision");
println!(" - Reading external state (files, network, etc.)");
return Ok(()); // Return success since we expected this failure
},
Err(e) => return Err(e),
}
if frame % 5 == 0 {
println!("Frame {}: still running...", frame);
}
}
println!("Unexpected: no mismatch detected!");
Ok(())
}