Testing Replicon Integration with RNG State Management
This document outlines specific test cases and methodologies for verifying the correct integration of bevy_replicon with our RNG state management system.
Table of Contents
- Introduction
- Test Environment Setup
- Unit Tests
- Integration Tests
- End-to-End Tests
- Performance Tests
- Debugging Failures
- Snapshot System Integration
Introduction
Testing the integration of bevy_replicon with RNG state management presents unique challenges:
- Network conditions are variable and unpredictable
- Randomized operations must be deterministic across network boundaries
- Rollbacks must preserve the exact RNG state
- Any deviations in RNG state can lead to unpredictable game outcomes
Our testing approach focuses on verifying determinism under various network conditions and ensuring proper recovery after disruptions.
Snapshot System Integration
For general information about testing the snapshot system, please refer to the centralized Snapshot System documentation:
The tests in this document specifically focus on the integration between the snapshot system, bevy_replicon, and RNG state management. When running these tests, it's important to also run the general snapshot system tests to ensure complete coverage.
Test Environment Setup
Local Network Testing Harness
#![allow(unused)] fn main() { /// Struct for testing replicon and RNG integration pub struct RepliconRngTestHarness { /// Server application pub server_app: App, /// Client applications (can have multiple) pub client_apps: Vec<App>, /// Network conditions simulator pub network_conditions: NetworkConditionSimulator, /// Test seed for deterministic behavior pub test_seed: u64, } impl RepliconRngTestHarness { /// Create a new test harness with the specified number of clients pub fn new(num_clients: usize) -> Self { let mut server_app = App::new(); let mut client_apps = Vec::with_capacity(num_clients); // Setup server server_app.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin) .add_plugins(RepliconServerPlugin::default()) .add_plugin(RepliconRngRollbackPlugin) .add_plugin(SnapshotPlugin); // Add the snapshot plugin // Setup RNG with specific seed for repeatability let test_seed = 12345u64; server_app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); // Setup clients for _ in 0..num_clients { let mut client_app = App::new(); client_app.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin) .add_plugins(RepliconClientPlugin::default()) .add_plugin(RepliconRngRollbackPlugin) .add_plugin(SnapshotPlugin); // Add the snapshot plugin // Each client gets the same seed client_app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); client_apps.push(client_app); } Self { server_app, client_apps, network_conditions: NetworkConditionSimulator::default(), test_seed, } } /// Connect all clients to the server pub fn connect_all_clients(&mut self) { // Setup server to listen let server_port = 8080; self.server_app.world.resource_mut::<RepliconServer>() .start_endpoint(ServerEndpoint::new(server_port)); // Connect clients for (i, client_app) in self.client_apps.iter_mut().enumerate() { client_app.world.resource_mut::<RepliconClient>() .connect_endpoint(ClientEndpoint::new("127.0.0.1", server_port)); } // Update a few times to establish connections for _ in 0..10 { self.server_app.update(); for client_app in &mut self.client_apps { client_app.update(); } } } /// Simulate network disruption for a specific client pub fn simulate_disruption(&mut self, client_idx: usize, duration_ms: u64) { self.network_conditions.disconnect_client(client_idx, duration_ms); } /// Run a test with network conditions pub fn run_with_conditions<F>(&mut self, update_count: usize, test_fn: F) where F: Fn(&mut Self, usize) { for i in 0..update_count { // Apply network conditions self.network_conditions.update(&mut self.client_apps); // Run server update self.server_app.update(); // Run client updates for client_app in &mut self.client_apps { client_app.update(); } // Call test function test_fn(self, i); } } /// Create a snapshot on the server pub fn create_server_snapshot(&mut self) -> Uuid { // Create a snapshot self.server_app.world.send_event(SnapshotEvent::Take); self.server_app.update(); // Return the snapshot ID self.server_app.world.resource::<SnapshotRegistry>() .most_recent() .unwrap() .id } /// Apply a snapshot on the server pub fn apply_server_snapshot(&mut self, snapshot_id: Uuid) { self.server_app.world.send_event(SnapshotEvent::Apply(snapshot_id)); self.server_app.update(); } } /// Simulates different network conditions pub struct NetworkConditionSimulator { /// Client disconnection timers pub disconnection_timers: HashMap<usize, u64>, /// Packet loss percentages pub packet_loss_rates: HashMap<usize, f32>, /// Latency values pub latencies: HashMap<usize, u64>, } impl NetworkConditionSimulator { /// Disconnect a client for a duration pub fn disconnect_client(&mut self, client_idx: usize, duration_ms: u64) { self.disconnection_timers.insert(client_idx, duration_ms); } /// Apply network conditions to clients pub fn update(&mut self, client_apps: &mut [App]) { // Update disconnection timers and reconnect if needed let mut reconnect = Vec::new(); for (client_idx, timer) in &mut self.disconnection_timers { if *timer <= 16 { reconnect.push(*client_idx); } else { *timer -= 16; // Assuming 60 FPS } } for client_idx in reconnect { self.disconnection_timers.remove(&client_idx); } } } }
Unit Tests
Testing RNG State Serialization and Deserialization
#![allow(unused)] fn main() { #[test] fn test_rng_state_serialization() { // Create a test app let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin) .init_resource::<RngReplicationState>(); // Setup global RNG with specific seed let test_seed = 12345u64; app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); // Generate some random values and store them let original_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.gen::<u32>()).collect() }; // Capture the RNG state let mut rng_state = app.world.resource_mut::<RngReplicationState>(); let global_rng = app.world.resource::<GlobalEntropy<WyRand>>(); rng_state.global_state = global_rng.try_serialize_state().unwrap(); // Create a new app with fresh RNG let mut new_app = App::new(); new_app.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin); // Apply the saved state let mut new_global_rng = new_app.world.resource_mut::<GlobalEntropy<WyRand>>(); new_global_rng.deserialize_state(&rng_state.global_state).unwrap(); // Generate values from the new RNG let new_values: Vec<u32> = { let mut rng = new_app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.gen::<u32>()).collect() }; // Values should be different from the original sequence // because we captured the state after generating the original values assert_ne!(original_values, new_values); // Reset both RNGs to the same seed and generate sequences app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); new_app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); let reset_values1: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.gen::<u32>()).collect() }; let reset_values2: Vec<u32> = { let mut rng = new_app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.gen::<u32>()).collect() }; // Values should now be identical assert_eq!(reset_values1, reset_values2); } }
Testing Snapshot System with RNG State
#![allow(unused)] fn main() { #[test] fn test_snapshot_preserves_rng_state() { // Create a test app with both the RNG and snapshot plugins let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin) .add_plugin(SnapshotPlugin) .add_plugin(RepliconRngRollbackPlugin) .init_resource::<SequenceTracker>(); // Seed RNG let test_seed = 12345u64; app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); // Create an entity with a marker component app.world.spawn(( Snapshotable, RandomizedBehavior::default(), Transform::default(), )); // Generate some initial values and mark the entity as using them let initial_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // Update sequence in the entity let mut entities = app.world.query::<&mut RandomizedBehavior>(); for mut behavior in entities.iter_mut(&mut app.world) { behavior.last_rng_sequence = 1; } // Create a snapshot app.world.send_event(SnapshotEvent::Take); app.update(); // Get the snapshot ID let snapshot_id = app.world.resource::<SnapshotRegistry>() .most_recent() .unwrap() .id; // Generate more random values, changing the RNG state let _more_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.gen::<u32>()).collect() }; // Apply the snapshot to restore the game state including RNG app.world.send_event(SnapshotEvent::Apply(snapshot_id)); app.update(); // Generate new values from the restored RNG state let restored_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // These values should be different from the initial values // since the RNG state has advanced, but they should be deterministic assert_ne!(initial_values, restored_values); // Create a new app and repeat the process to verify determinism let mut app2 = App::new(); app2.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin) .add_plugin(SnapshotPlugin) .add_plugin(RepliconRngRollbackPlugin) .init_resource::<SequenceTracker>(); // Seed RNG the same way app2.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); // Create the same entity app2.world.spawn(( Snapshotable, RandomizedBehavior::default(), Transform::default(), )); // Generate the initial values exactly as before let _initial_values2: Vec<u32> = { let mut rng = app2.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // Update sequence in the entity let mut entities = app2.world.query::<&mut RandomizedBehavior>(); for mut behavior in entities.iter_mut(&mut app2.world) { behavior.last_rng_sequence = 1; } // Create a snapshot app2.world.send_event(SnapshotEvent::Take); app2.update(); // Get the snapshot ID let snapshot_id2 = app2.world.resource::<SnapshotRegistry>() .most_recent() .unwrap() .id; // Generate more random values, changing the RNG state let _more_values2: Vec<u32> = { let mut rng = app2.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..10).map(|_| rng.gen::<u32>()).collect() }; // Apply the snapshot to restore the game state including RNG app2.world.send_event(SnapshotEvent::Apply(snapshot_id2)); app2.update(); // Generate new values from the restored RNG state let restored_values2: Vec<u32> = { let mut rng = app2.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // The deterministically restored values should be identical in both runs assert_eq!(restored_values, restored_values2); } }
Testing Checkpoint Creation and Restoration
#![allow(unused)] fn main() { #[test] fn test_checkpoint_creation_and_restoration() { // Create a test app with the plugin let mut app = App::new(); app.add_plugins(MinimalPlugins) .add_plugins(DefaultRngPlugin) .add_plugin(RepliconRngRollbackPlugin) .init_resource::<SequenceTracker>(); // Seed RNG let test_seed = 12345u64; app.world.resource_mut::<GlobalEntropy<WyRand>>().seed_from_u64(test_seed); // Generate some initial values let initial_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // Create a checkpoint let checkpoint_sequence = 1; let mut checkpoints = app.world.resource_mut::<RollbackCheckpoints>(); let rng_state = app.world.resource::<RngReplicationState>(); let global_rng = app.world.resource::<GlobalEntropy<WyRand>>(); let checkpoint = RollbackCheckpoint { sequence_id: checkpoint_sequence, timestamp: 0.0, global_rng_state: global_rng.try_serialize_state().unwrap(), player_rng_states: HashMap::new(), replicated_entities: Vec::new(), }; checkpoints.checkpoints.insert(checkpoint_sequence, checkpoint); // Generate more values after checkpoint let post_checkpoint_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // Restore from checkpoint let checkpoint = checkpoints.checkpoints.get(&checkpoint_sequence).unwrap(); app.world.resource_mut::<GlobalEntropy<WyRand>>() .deserialize_state(&checkpoint.global_rng_state).unwrap(); // Generate values after restoration let restored_values: Vec<u32> = { let mut rng = app.world.resource_mut::<GlobalEntropy<WyRand>>(); (0..5).map(|_| rng.gen::<u32>()).collect() }; // The restored values should match the post-checkpoint values assert_eq!(post_checkpoint_values, restored_values); } }
Integration Tests
Testing RNG Synchronization Between Server and Client
#![allow(unused)] fn main() { #[test] fn test_server_client_rng_sync() { // Create test harness with 1 client let mut harness = RepliconRngTestHarness::new(1); harness.connect_all_clients(); // Test variables let mut server_values = Vec::new(); let mut client_values = Vec::new(); // Run with updates harness.run_with_conditions(50, |harness, i| { if i == 10 { // Record server RNG values at update 10 let mut rng = harness.server_app.world.resource_mut::<GlobalEntropy<WyRand>>(); server_values = (0..5).map(|_| rng.gen::<u32>()).collect(); } if i == 20 { // Record client RNG values at update 20 // By now, RNG state should have been synced let mut rng = harness.client_apps[0].world.resource_mut::<GlobalEntropy<WyRand>>(); client_values = (0..5).map(|_| rng.gen::<u32>()).collect(); // Server will have advanced, get fresh set of values let mut rng = harness.server_app.world.resource_mut::<GlobalEntropy<WyRand>>(); server_values = (0..5).map(|_| rng.gen::<u32>()).collect(); } }); // Client values should match server values from update 20 assert_eq!(client_values, server_values); } }
Testing Rollback Due to Network Disruption
#![allow(unused)] fn main() { #[test] fn test_rollback_after_disruption() { // Create test harness with 2 clients let mut harness = RepliconRngTestHarness::new(2); harness.connect_all_clients(); // Setup game entities // ... // Run test with network disruption let mut pre_disruption_rng_values = Vec::new(); let mut post_disruption_rng_values = Vec::new(); let mut post_rollback_rng_values = Vec::new(); harness.run_with_conditions(100, |harness, i| { if i == 20 { // Record RNG values before disruption let rng = harness.server_app.world.resource::<GlobalEntropy<WyRand>>(); pre_disruption_rng_values = generate_test_random_values(rng, 10); // Simulate network disruption for client 0 harness.simulate_disruption(0, 500); // 500ms disruption } if i == 40 { // Record RNG values after disruption let rng = harness.server_app.world.resource::<GlobalEntropy<WyRand>>(); post_disruption_rng_values = generate_test_random_values(rng, 10); } if i == 60 { // By now rollback should have happened // Record RNG values after rollback let rng = harness.server_app.world.resource::<GlobalEntropy<WyRand>>(); post_rollback_rng_values = generate_test_random_values(rng, 10); // Check that client 0 and client 1 have the same RNG state let rng0 = harness.client_apps[0].world.resource::<GlobalEntropy<WyRand>>(); let rng1 = harness.client_apps[1].world.resource::<GlobalEntropy<WyRand>>(); let client0_values = generate_test_random_values(rng0, 10); let client1_values = generate_test_random_values(rng1, 10); assert_eq!(client0_values, client1_values, "Clients should have same RNG state after rollback"); } }); // Verify behavior assert_ne!(pre_disruption_rng_values, post_disruption_rng_values, "RNG values should change during normal operation"); assert_eq!(post_rollback_rng_values, post_disruption_rng_values, "After rollback, RNG sequences should match the checkpoint state"); } /// Helper function to generate random values for testing fn generate_test_random_values(rng: &GlobalEntropy<WyRand>, count: usize) -> Vec<u32> { let mut rng_clone = rng.clone(); (0..count).map(|_| rng_clone.gen::<u32>()).collect() } }
End-to-End Tests
Testing Card Shuffling During Network Disruption
#![allow(unused)] fn main() { #[test] fn test_card_shuffle_during_disruption() { // Setup test environment with card library let mut harness = RepliconRngTestHarness::new(2); harness.connect_all_clients(); // Create players and libraries let server_player1 = setup_test_player(&mut harness.server_app.world, 1); let server_player2 = setup_test_player(&mut harness.server_app.world, 2); // Create identical card libraries let cards = (1..53).collect::<Vec<i32>>(); let server_library1 = create_test_library(&mut harness.server_app.world, server_player1, cards.clone()); let server_library2 = create_test_library(&mut harness.server_app.world, server_player2, cards.clone()); // Initialize client players and libraries // ... // Shuffle results let mut server_shuffle_result1 = Vec::new(); let mut server_shuffle_result2 = Vec::new(); let mut client1_shuffle_result = Vec::new(); let mut client2_shuffle_result = Vec::new(); // Run test with network disruption during card shuffle harness.run_with_conditions(200, |harness, i| { if i == 50 { // Player 1 shuffles their library harness.server_app.world.send_event(ShuffleLibraryEvent { library_entity: server_library1 }); } if i == 60 { // Capture shuffle result server_shuffle_result1 = get_library_order(&harness.server_app.world, server_library1); // Cause network disruption harness.simulate_disruption(0, 1000); } if i == 80 { // Player 2 shuffles during disruption harness.server_app.world.send_event(ShuffleLibraryEvent { library_entity: server_library2 }); } if i == 100 { // Capture server-side shuffle results server_shuffle_result2 = get_library_order(&harness.server_app.world, server_library2); } if i == 150 { // By now, rollback and resynchronization should have occurred // Capture client-side shuffle results client1_shuffle_result = get_client_library_order(&harness.client_apps[0].world, 1); client2_shuffle_result = get_client_library_order(&harness.client_apps[1].world, 2); } }); // Verify all libraries have the same shuffle result assert_eq!(server_shuffle_result1, client1_shuffle_result, "Client 1 should have same shuffle result as server"); assert_eq!(server_shuffle_result2, client2_shuffle_result, "Client 2 should have same shuffle result as server"); } /// Helper function to get library card order fn get_library_order(world: &World, library_entity: Entity) -> Vec<i32> { if let Some(library) = world.get::<Library>(library_entity) { library.cards.clone() } else { Vec::new() } } /// Helper function to get client-side library order fn get_client_library_order(client_world: &World, player_id: i32) -> Vec<i32> { // Find player by ID let player_entity = find_player_by_id(client_world, player_id); if player_entity.is_none() { return Vec::new(); } // Find library entity let library_entity = find_library_for_player(client_world, player_entity.unwrap()); if library_entity.is_none() { return Vec::new(); } // Get library cards get_library_order(client_world, library_entity.unwrap()) } }
Performance Tests
Testing RNG State Replication Bandwidth
#![allow(unused)] fn main() { #[test] fn test_rng_replication_bandwidth() { // Create a test harness with multiple clients let mut harness = RepliconRngTestHarness::new(4); harness.connect_all_clients(); // Setup bandwidth tracking let mut bandwidth_tracker = BandwidthTracker::new(); // Run test with bandwidth monitoring harness.run_with_conditions(100, |harness, i| { if i % 10 == 0 { // Record bandwidth usage every 10 updates let server = harness.server_app.world.resource::<RepliconServer>(); bandwidth_tracker.record_bandwidth(server.get_bandwidth_stats()); } }); // Analyze bandwidth results let results = bandwidth_tracker.analyze(); // Ensure RNG state replication is within reasonable bounds assert!(results.avg_bandwidth_per_client < 1024, "Average bandwidth should be less than 1KB per client"); // Print results println!("Bandwidth results:"); println!(" Average per client: {} bytes", results.avg_bandwidth_per_client); println!(" Peak: {} bytes", results.peak_bandwidth); println!(" Total: {} bytes", results.total_bandwidth); } /// Helper struct for tracking bandwidth usage struct BandwidthTracker { samples: Vec<BandwidthSample>, } struct BandwidthSample { timestamp: f32, bytes_sent: usize, client_count: usize, } struct BandwidthResults { avg_bandwidth_per_client: f32, peak_bandwidth: usize, total_bandwidth: usize, } impl BandwidthTracker { fn new() -> Self { Self { samples: Vec::new() } } fn record_bandwidth(&mut self, stats: BandwidthStats) { self.samples.push(BandwidthSample { timestamp: stats.timestamp, bytes_sent: stats.bytes_sent, client_count: stats.client_count, }); } fn analyze(&self) -> BandwidthResults { if self.samples.is_empty() { return BandwidthResults { avg_bandwidth_per_client: 0.0, peak_bandwidth: 0, total_bandwidth: 0, }; } let total_bytes: usize = self.samples.iter().map(|s| s.bytes_sent).sum(); let peak_bytes = self.samples.iter().map(|s| s.bytes_sent).max().unwrap_or(0); let client_samples: usize = self.samples.iter().map(|s| s.client_count).sum(); let avg_per_client = if client_samples > 0 { total_bytes as f32 / client_samples as f32 } else { 0.0 }; BandwidthResults { avg_bandwidth_per_client: avg_per_client, peak_bandwidth: peak_bytes, total_bandwidth: total_bytes, } } } /// Mock struct to represent network bandwidth statistics struct BandwidthStats { timestamp: f32, bytes_sent: usize, client_count: usize, } }
Debugging Failures
When tests fail, collect diagnostic information to aid debugging:
#![allow(unused)] fn main() { fn diagnose_rng_state_mismatch( server_rng: &GlobalEntropy<WyRand>, client_rng: &GlobalEntropy<WyRand>, ) -> String { // Serialize both RNG states let server_state = server_rng.try_serialize_state().unwrap_or_default(); let client_state = client_rng.try_serialize_state().unwrap_or_default(); // Generate test values from both let mut server_rng_clone = server_rng.clone(); let mut client_rng_clone = client_rng.clone(); let server_values: Vec<u32> = (0..5).map(|_| server_rng_clone.gen::<u32>()).collect(); let client_values: Vec<u32> = (0..5).map(|_| client_rng_clone.gen::<u32>()).collect(); let mut report = String::new(); report.push_str("RNG State Mismatch Diagnostic:\n"); report.push_str(&format!("Server state: {:?}\n", server_state)); report.push_str(&format!("Client state: {:?}\n", client_state)); report.push_str(&format!("Server values: {:?}\n", server_values)); report.push_str(&format!("Client values: {:?}\n", client_values)); report } }
These tests validate that our bevy_replicon integration with RNG state management works correctly under various conditions, ensuring deterministic behavior in our networked MTG Commander game.
Remember to run these tests:
- Regularly during development
- After any changes to networking code
- After any changes to RNG-dependent game logic
- As part of the CI/CD pipeline