diff --git a/Cargo.lock b/Cargo.lock index 2f071bb..533bc78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -278,6 +278,7 @@ dependencies = [ "once_cell", "parking_lot 0.12.1", "rand", + "rand_xoshiro", "serde", "wasm-bindgen", ] @@ -3553,6 +3554,15 @@ dependencies = [ "getrandom", ] +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core", +] + [[package]] name = "range-alloc" version = "0.1.3" diff --git a/Cargo.toml b/Cargo.toml index 69e9df8..2c7785b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] rand = "0.8" +rand_xoshiro = "0.6" itertools = "0.10" bevy_ggrs = { version = "0.14.0" } bytemuck = { version = "1.7", features = ["derive"] } @@ -50,5 +51,6 @@ parking_lot = "0.12" wasm-bindgen = "0.2" [patch.crates-io] +# bevy_ggrs = { path = "../bevy_ggrs" } bevy_ggrs = { git = "https://github.com/aleksa2808/bevy_ggrs" } ggrs = { git = "https://github.com/aleksa2808/ggrs" } diff --git a/src/constants.rs b/src/constants.rs index 50f1d6a..fe224d7 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -55,5 +55,5 @@ pub const BOMB_SHORTENED_FUSE_FRAME_COUNT: usize = 2; pub const MOVING_OBJECT_FRAME_INTERVAL: usize = 1; -// TODO does float precision cause desyncs? -pub const ITEM_SPAWN_CHANCE_PERCENTAGE: usize = 33; +// TODO figure out if floats can be used deterministically +pub const ITEM_SPAWN_CHANCE_PERCENTAGE: u64 = 33; diff --git a/src/lib.rs b/src/lib.rs index a45cbc0..8d2c7b5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -141,6 +141,7 @@ pub fn run() { .rollback_resource_with_copy::() .rollback_resource_with_copy::() .rollback_resource_with_copy::() + // checksums .checksum_component_with_hash::() .checksum_component_with_hash::() .checksum_component_with_hash::() diff --git a/src/resources.rs b/src/resources.rs index 2b20f2f..89943f4 100644 --- a/src/resources.rs +++ b/src/resources.rs @@ -1,6 +1,7 @@ use bevy::{ecs as bevy_ecs, prelude::*, text::Font, utils::HashMap}; use bevy_matchbox::matchbox_socket::PeerId; -use rand::{rngs::StdRng, seq::IteratorRandom, Rng}; +use rand::{Rng, SeedableRng}; +use rand_xoshiro::Xoshiro256StarStar; use crate::{ components::Position, @@ -164,20 +165,20 @@ pub enum WorldType { impl WorldType { pub const LIST: [Self; 3] = [Self::GrassWorld, Self::IceWorld, Self::CloudWorld]; - pub fn random(rng: &mut StdRng) -> Self { - match rng.gen_range(1..=3) { - 1 => Self::GrassWorld, - 2 => Self::IceWorld, - 3 => Self::CloudWorld, + pub fn random(rng: &mut SessionRng) -> Self { + match rng.gen_u64() % 3 { + 0 => Self::GrassWorld, + 1 => Self::IceWorld, + 2 => Self::CloudWorld, _ => unreachable!(), } } - pub fn next_random(&self, rng: &mut StdRng) -> Self { + pub fn next_random(&self, rng: &mut SessionRng) -> Self { Self::LIST .into_iter() .filter(|&w| w != *self) - .choose(rng) + .nth((rng.gen_u64() as usize) % (Self::LIST.len() - 1)) .unwrap() } } @@ -196,8 +197,20 @@ pub struct RngSeeds { pub remote: HashMap>, } +// I could not verify it but I assume that the Xoshiro256StarStar generator is platform-independent. This is necessary for cross-platform deterministic gameplay. #[derive(Resource, Clone)] -pub struct SessionRng(pub StdRng); +pub struct SessionRng(Xoshiro256StarStar); + +impl SessionRng { + pub fn new(seed: u64) -> Self { + Self(Xoshiro256StarStar::seed_from_u64(seed)) + } + + // Allow only `u64` number generation in order to prevent things like generating platform dependent `usize` values. + pub fn gen_u64(&mut self) -> u64 { + self.0.gen() + } +} #[derive(Resource)] pub struct LocalPlayerID(pub usize); diff --git a/src/systems.rs b/src/systems.rs index 98efd0c..32ba4be 100644 --- a/src/systems.rs +++ b/src/systems.rs @@ -13,11 +13,6 @@ use bevy_matchbox::{ MatchboxSocket, }; use itertools::Itertools; -use rand::{ - rngs::StdRng, - seq::{IteratorRandom, SliceRandom}, - Rng, SeedableRng, -}; use crate::{ components::*, @@ -33,7 +28,7 @@ use crate::{ utils::{ burn_item, decode, format_hud_time, generate_item_at_position, get_x, get_y, setup_fullscreen_message_display, setup_get_ready_display, setup_leaderboard_display, - setup_round, setup_tournament_winner_display, + setup_round, setup_tournament_winner_display, shuffle, }, AppState, GgrsConfig, }; @@ -249,7 +244,7 @@ pub fn lobby_system( rng_seeds.local ^ peer_rng_seeds.into_iter().reduce(|acc, e| acc ^ e).unwrap(); info!("Generated the shared RNG seed: {shared_seed}"); commands.remove_resource::(); - commands.insert_resource(SessionRng(StdRng::seed_from_u64(shared_seed))); + commands.insert_resource(SessionRng::new(shared_seed)); // extract final player list let players = socket.players(); @@ -344,7 +339,7 @@ pub fn setup_game( local_player_id: Res, ) { // choose the initial world - let world_type = WorldType::random(&mut session_rng.0); + let world_type = WorldType::random(&mut session_rng); commands.insert_resource(world_type); // setup the tournament leaderboard @@ -454,7 +449,7 @@ pub fn player_move( .sorted_by_cached_key(|q| rollback_ordered.order(*q.0)) .collect_vec(); // shuffle to ensure fairness in situations where two players push the same bomb in the same frame - players.shuffle(&mut session_rng.0); + shuffle(&mut players, &mut session_rng); for (_, player, mut position, mut transform, mut sprite) in players { let input = inputs[player.id.0].0 .0; for (input_mask, moving_direction) in [ @@ -678,7 +673,7 @@ pub fn bomb_drop( .sorted_by_cached_key(|q| rollback_ordered.order(*q.0)) .collect_vec(); // shuffle to ensure fairness in situations where two players try to place a bomb in the same frame - players.shuffle(&mut session_rng.0); + shuffle(&mut players, &mut session_rng); for (_, player, position, mut bomb_satchel) in players { if inputs[player.id.0].0 .0 & INPUT_ACTION != 0 && bomb_satchel.bombs_available > 0 @@ -894,9 +889,9 @@ pub fn crumbling_tick( commands.entity(entity).despawn_recursive(); // drop power-up - let roll = session_rng.0.gen_range(0..100); + let roll = session_rng.gen_u64() % 100; if roll < ITEM_SPAWN_CHANCE_PERCENTAGE { - generate_item_at_position(&mut session_rng.0, &mut commands, &game_textures, *position); + generate_item_at_position(&mut session_rng, &mut commands, &game_textures, *position); } } } @@ -1328,17 +1323,19 @@ pub fn cleanup_dead( // death pinata let invalid_item_positions: HashSet = invalid_item_position_query.iter().copied().collect(); - let valid_positions = (1..map_size.rows - 1) + let mut valid_positions = (1..map_size.rows - 1) .flat_map(|y| { (1..map_size.columns - 1).map(move |x| Position { y: y as isize, x: x as isize, }) }) - .filter(|position| !invalid_item_positions.contains(position)); - for position in valid_positions.choose_multiple(&mut session_rng.0, 3) { + .filter(|position| !invalid_item_positions.contains(position)) + .collect_vec(); + shuffle(&mut valid_positions, &mut session_rng); + for &position in valid_positions.iter().take(3) { generate_item_at_position( - &mut session_rng.0, + &mut session_rng, &mut commands, &game_textures, position, @@ -1435,7 +1432,7 @@ pub fn show_leaderboard( let window = primary_window_query.get_single().unwrap(); setup_leaderboard_display( - &mut session_rng.0, + &mut session_rng, parent, window.height(), window.width(), @@ -1498,7 +1495,7 @@ pub fn show_tournament_winner( } // choose a world for the next tournament - *world_type = world_type.next_random(&mut session_rng.0); + *world_type = world_type.next_random(&mut session_rng); commands.insert_resource(GameFreeze { end_frame: frame_count.frame + TOURNAMENT_WINNER_DISPLAY_FRAME_COUNT, @@ -1534,7 +1531,7 @@ pub fn start_new_round( let round_start_frame = frame_count.frame + GAME_START_FREEZE_FRAME_COUNT; setup_round( - &mut session_rng.0, + &mut session_rng, &mut commands, *map_size, *world_type, diff --git a/src/utils.rs b/src/utils.rs index 0c62b59..33b50c6 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -12,7 +12,6 @@ use bevy::{ }; use bevy_ggrs::AddRollbackCommandExtension; use itertools::Itertools; -use rand::{rngs::StdRng, seq::IteratorRandom, Rng}; use crate::{ components::{ @@ -25,7 +24,8 @@ use crate::{ PLAYER_Z_LAYER, ROUND_DURATION_SECS, TILE_HEIGHT, TILE_WIDTH, WALL_Z_LAYER, }, resources::{ - Fonts, GameEndFrame, GameTextures, HUDColors, Leaderboard, MapSize, WallOfDeath, WorldType, + Fonts, GameEndFrame, GameTextures, HUDColors, Leaderboard, MapSize, SessionRng, + WallOfDeath, WorldType, }, types::{Direction, PlayerID, RoundOutcome}, }; @@ -42,6 +42,12 @@ pub fn decode(input: &str) -> String { String::from_utf8(STANDARD_NO_PAD.decode(input).unwrap()).unwrap() } +pub fn shuffle(elements: &mut [T], rng: &mut SessionRng) { + for i in (1..elements.len()).rev() { + elements.swap(i, rng.gen_u64() as usize % (i + 1)); + } +} + pub fn setup_fullscreen_message_display( commands: &mut Commands, window: &Window, @@ -387,7 +393,7 @@ fn init_hud( } fn spawn_map( - rng: &mut StdRng, + rng: &mut SessionRng, commands: &mut Commands, game_textures: &GameTextures, world_type: WorldType, @@ -503,11 +509,16 @@ fn spawn_map( ); } - let destructible_wall_positions = destructible_wall_potential_positions + let mut destructible_wall_positions = destructible_wall_potential_positions .into_iter() .sorted_by_key(|p| (p.x, p.y)) - .choose_multiple(rng, num_of_destructible_walls_to_place); - for position in destructible_wall_positions.iter().cloned() { + .collect_vec(); + shuffle(&mut destructible_wall_positions, rng); + for position in destructible_wall_positions + .iter() + .take(num_of_destructible_walls_to_place) + .cloned() + { commands .spawn(( SpriteBundle { @@ -536,7 +547,7 @@ fn spawn_map( } pub fn setup_round( - rng: &mut StdRng, + rng: &mut SessionRng, commands: &mut Commands, map_size: MapSize, world_type: WorldType, @@ -646,12 +657,12 @@ pub fn setup_round( } pub fn generate_item_at_position( - rng: &mut StdRng, + rng: &mut SessionRng, commands: &mut Commands, game_textures: &GameTextures, position: Position, ) { - let roll = rng.gen::() % 100; + let roll = rng.gen_u64() % 100; /* "Loot tables" */ let item = match roll { @@ -699,7 +710,7 @@ pub fn burn_item( } pub fn setup_leaderboard_display( - rng: &mut StdRng, + rng: &mut SessionRng, parent: &mut ChildBuilder, window_height: f32, window_width: f32, @@ -845,7 +856,11 @@ pub fn setup_leaderboard_display( height: Val::Px(PIXEL_SCALE as f32), ..Default::default() }, - background_color: (*COLORS.iter().choose(rng).unwrap()).into(), + background_color: (*COLORS + .iter() + .nth(rng.gen_u64() as usize % COLORS.len()) + .unwrap()) + .into(), ..Default::default() }, UIComponent,