diff --git a/Cargo.toml b/Cargo.toml index aa1f35e..98672ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,14 +37,15 @@ bevy = { version = "0.15.3", features = [ "dynamic_linking", # REMOVE IN RELEASE "trace", ] } # Basic game engine stuff (windows, inputs, etc.) -rand = "0.9.0" +rand = { version = "0.9.0", features = ["small_rng"] } strum = "0.27" strum_macros = "0.27" bitflags = "2.9.0" # I manually set this version because it won't work with Bevy otherwise. uuid = "1.12.1" -num_cpus = "1.16.0" bevy-inspector-egui = "0.29.1" rayon = "1.10.0" -dashmap = "6.1.0" + +[dev-dependencies] +insta = "1" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f51b2e4 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,6 @@ +pub mod particle; +pub mod player; +pub mod render; +pub mod simulation; +pub mod utils; +pub mod world; diff --git a/src/main.rs b/src/main.rs index 780a2e1..ef87dae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,21 +2,16 @@ use bevy::app::AppExit; use bevy::input::keyboard::KeyCode; use bevy::input::ButtonInput; use bevy::prelude::*; -use utils::debug; -use world::camera; -mod particle; -mod player; -mod render; -mod simulation; -mod utils; -mod world; +use cavernborn::player; +use cavernborn::utils::debug; +use cavernborn::world::camera; -use crate::world::MapPlugin; use camera::{CameraPlugin, GameCamera}; +use cavernborn::world::MapPlugin; use debug::DebugPlugin; use player::PlayerPlugin; -use render::map_renderer::MapRendererPlugin; +use cavernborn::render::map_renderer::MapRendererPlugin; fn main() { App::new() diff --git a/src/particle/interaction.rs b/src/particle/interaction.rs index c9fd86d..6754a1e 100644 --- a/src/particle/interaction.rs +++ b/src/particle/interaction.rs @@ -24,7 +24,10 @@ pub static INTERACTION_RULES: LazyLock }, InteractionRule { interaction_type: InteractionType::Preserve, - result: Particle::Liquid(Liquid::Water(Direction::random())), + // Note: this is evaluated once at startup, so the result must be + // a fixed particle. Randomizing state here would freeze a single + // random value for the entire run. + result: Particle::Liquid(Liquid::Water(Direction::default())), }, ); @@ -78,3 +81,45 @@ pub struct InteractionRule { pub interaction_type: InteractionType, pub result: Particle, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::particle::Direction; + + #[test] + fn interaction_lookup_is_commutative() { + let water = Particle::Liquid(Liquid::Water(Direction::Still)); + let lava = Particle::Liquid(Liquid::Lava(Direction::Still)); + + assert!(INTERACTION_RULES.contains_key(&InteractionPair { + source: water, + target: lava + })); + assert!(INTERACTION_RULES.contains_key(&InteractionPair { + source: lava, + target: water + })); + } + + #[test] + fn interaction_lookup_ignores_liquid_direction() { + let water = Particle::Liquid(Liquid::Water(Direction::Left)); + let lava = Particle::Liquid(Liquid::Lava(Direction::Right)); + + assert!(INTERACTION_RULES.contains_key(&InteractionPair { + source: water, + target: lava + })); + } + + #[test] + fn unrelated_particles_have_no_rule() { + let water = Particle::Liquid(Liquid::Water(Direction::Still)); + + assert!(!INTERACTION_RULES.contains_key(&InteractionPair { + source: water, + target: water + })); + } +} diff --git a/src/particle/liquid.rs b/src/particle/liquid.rs index 0a1c3a7..4ac974e 100644 --- a/src/particle/liquid.rs +++ b/src/particle/liquid.rs @@ -100,3 +100,22 @@ impl WorldGenType for Liquid { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Equality (and hashing) intentionally ignore flow direction so that rule + /// lookups treat all waters alike. Pattern matching still sees direction. + #[test] + fn liquids_compare_ignoring_direction() { + assert_eq!( + Liquid::Water(Direction::Left), + Liquid::Water(Direction::Right) + ); + assert_ne!( + Liquid::Water(Direction::Still), + Liquid::Lava(Direction::Still) + ); + } +} diff --git a/src/particle/mod.rs b/src/particle/mod.rs index 05139d2..31e7282 100644 --- a/src/particle/mod.rs +++ b/src/particle/mod.rs @@ -159,10 +159,6 @@ impl Special { Special::Gem(gem) => gem.spawn_chance(), } } - - pub fn all_variants() -> Vec { - Special::iter().collect() - } } impl From for Particle { @@ -200,13 +196,4 @@ impl Direction { pub fn as_int(self) -> i32 { self as i32 } - - /// Returns a random direction. - pub fn random() -> Direction { - if rand::random() { - Direction::Left - } else { - Direction::Right - } - } } diff --git a/src/simulation/fluid.rs b/src/simulation/fluid.rs index 2d95f06..ba426c9 100644 --- a/src/simulation/fluid.rs +++ b/src/simulation/fluid.rs @@ -1,86 +1,41 @@ use bevy::math::UVec2; +use rand::{rngs::SmallRng, Rng}; -use crate::{ - particle::Liquid, - utils::coords::chunk_local_to_world, - world::chunk::ParticleMove, -}; +use crate::particle::{Liquid, Particle}; -use super::{handle_particle_movement, try_move, MoveResult, SimulationContext, Simulator}; +use super::{try_move, Simulator, StepAction, StepContext}; pub struct FluidSimulator; impl Simulator for FluidSimulator { - /// Calculates the new position for a fluid particle, reading old positions from the map and writing to new_cells. - fn simulate( - &mut self, - context: SimulationContext, - fluid: Liquid, - x: u32, - y: u32, - ) -> Option { - let particle_world_pos = - chunk_local_to_world(context.original_chunk.position, UVec2::new(x, y)); - let step = - self.calculate_step(&context, fluid, particle_world_pos.x, particle_world_pos.y); - - match step { - MoveResult::Move(new_pos, new_particle) => { - // Source moves to new position (empty cell or Replace interaction) - handle_particle_movement( - context.original_chunk, - context.new_cells, - particle_world_pos, - new_pos, - new_particle, - false, - ) - } - MoveResult::Preserve { - source_particle, - target_pos, - result, - } => { - // Source stays at its original local position - context.new_cells[x as usize][y as usize] = Some(source_particle); - // Place the interaction result at the target position - handle_particle_movement( - context.original_chunk, - context.new_cells, - particle_world_pos, - target_pos, - result, - true, - ) - } - } - } -} - -impl FluidSimulator { - /// Calculates the new position of a fluid particle in world coordinates. - /// It will either move to a new position, or interact with a neighboring particle if possible. - pub fn calculate_step( + /// Decides the next action for a fluid particle: fall, flow diagonally, + /// flow horizontally, or interact with a neighboring particle. + /// + /// Pure with respect to the simulation state — the engine applies the + /// returned action. + fn calculate_step( &self, - context: &SimulationContext, + context: &StepContext, fluid: Liquid, - x: u32, - y: u32, - ) -> MoveResult { - let particle = fluid.into(); + world_pos: UVec2, + rng: &mut SmallRng, + ) -> StepAction { + let particle: Particle = fluid.into(); + let (x, y) = (world_pos.x, world_pos.y); let buoyancy = Liquid::BUOYANCY; let viscosity = fluid.get_viscosity(); - // Try vertical movement first - for offset in (0..viscosity).rev() { + // Try vertical movement, farthest reach first. + // (Offset 0 would be the particle's own cell, so start at 1.) + for offset in (1..viscosity).rev() { let new_pos = UVec2::new(x, (y as i32 + buoyancy * offset).max(0) as u32); - if let Some(result) = try_move(context, new_pos, particle) { - return result; + if let Some(action) = try_move(context, new_pos, particle) { + return action; } } - // Try diagonal movement - for offset in (0..viscosity).rev() { + // Try diagonal movement, farthest reach first. + for offset in (1..viscosity).rev() { let new_y = (y as i32 + buoyancy).max(0) as u32; let new_x_right = (x as i32 + offset * buoyancy).max(0) as u32; let new_x_left = (x as i32 - offset * buoyancy).max(0) as u32; @@ -90,21 +45,21 @@ impl FluidSimulator { match (move_right, move_left) { // If both are possible, choose one randomly. - (Some(right), Some(left)) => return if rand::random() { right } else { left }, - // If one is possible, return that. - (Some(result), None) | (None, Some(result)) => return result, - // If neither are possible, do nothing. + (Some(right), Some(left)) => return if rng.random() { right } else { left }, + // If one is possible, take that one. + (Some(action), None) | (None, Some(action)) => return action, + // If neither is possible, try a shorter reach. (None, None) => {} } } - // Try moving horizontally + // Try moving horizontally along the current flow direction. let new_x = (x as i32 + fluid.get_direction().as_int()).max(0) as u32; - if let Some(result) = try_move(context, UVec2::new(new_x, y), particle) { - return result; + if let Some(action) = try_move(context, UVec2::new(new_x, y), particle) { + return action; } - // If no movement is possible, flip direction - MoveResult::Move(UVec2::new(x, y), fluid.get_flipped_direction().into()) + // Nowhere to go: stay put and flip direction for next tick. + StepAction::Stay(fluid.get_flipped_direction().into()) } } diff --git a/src/simulation/mod.rs b/src/simulation/mod.rs index d8ee7db..ab05c8b 100644 --- a/src/simulation/mod.rs +++ b/src/simulation/mod.rs @@ -1,5 +1,5 @@ use bevy::math::UVec2; -use dashmap::DashMap; +use rand::rngs::SmallRng; use crate::{ particle::{ @@ -8,172 +8,180 @@ use crate::{ }, utils::coords::world_to_chunk_local, world::{ - chunk::{Chunk, ParticleMove, CHUNK_SIZE}, + chunk::{Chunk, ChunkCells}, Map, }, }; pub mod fluid; -/// A trait for types that can simulate particles. +/// A trait for types that decide how a particle moves. +/// +/// Implementations are pure: they read the simulation state and return a +/// [`StepAction`], but never write. The engine (chunk and map) owns all +/// mutation, so every particle type shares one application path. pub trait Simulator { - fn simulate( - &mut self, - context: SimulationContext, + fn calculate_step( + &self, + context: &StepContext, particle: P, - x: u32, - y: u32, - ) -> Option; + world_pos: UVec2, + rng: &mut SmallRng, + ) -> StepAction; } -/// The result of attempting to move a particle. -pub enum MoveResult { - /// Source particle moves to the new position, becoming the given particle. - /// Used for empty-cell moves and Replace interactions. - Move(UVec2, Particle), - /// Source particle stays at its current position. The result particle - /// should be placed at the target position (Preserve interaction). - Preserve { - source_particle: Particle, - target_pos: UVec2, - result: Particle, - }, +/// Read-only view of the state a simulator may consult when deciding a step. +pub struct StepContext<'a> { + /// The whole map as it was at the start of this tick. + pub map: &'a Map, + /// The chunk being simulated. + pub chunk: &'a Chunk, + /// The chunk's next state, filled in as its cells are processed this tick. + pub new_cells: &'a ChunkCells, } -/// A context for particle simulation. -/// Contains references to the map, original chunk, chunk queue, and new cells. -pub struct SimulationContext<'a> { - pub map: &'a Map, - pub original_chunk: &'a Chunk, - pub chunk_queue: &'a DashMap, - pub new_cells: &'a mut [[Option; CHUNK_SIZE as usize]; CHUNK_SIZE as usize], +/// What a particle decided to do this tick. +#[derive(Debug, Clone, Copy)] +pub enum StepAction { + /// Stay at the current cell, possibly with updated state (e.g. a flipped + /// flow direction). + Stay(Particle), + /// Act on another cell. + Act(TargetedAction), } -impl<'a> SimulationContext<'a> { - pub fn new( - map: &'a Map, - original_chunk: &'a Chunk, - chunk_queue: &'a DashMap, - new_cells: &'a mut [[Option; CHUNK_SIZE as usize]; CHUNK_SIZE as usize], - ) -> Self { - Self { - map, - original_chunk, - chunk_queue, - new_cells, - } - } +/// An action directed at a target cell. +#[derive(Debug, Clone, Copy)] +pub struct TargetedAction { + /// Target cell in world coordinates. + pub target: UVec2, + pub kind: ActionKind, } -/// Tries to move a particle to a new position, handling interactions and validation. -pub fn try_move( - context: &SimulationContext, - new_pos: UVec2, - particle: Particle, -) -> Option { - // First try to move to an empty spot. - if validate_move_empty(context, new_pos) { - Some(MoveResult::Move(new_pos, particle)) - } else if let Some((result, interaction_type)) = resolve_interaction(context, new_pos, particle) - { - match interaction_type { - InteractionType::Replace => Some(MoveResult::Move(new_pos, result)), - InteractionType::Preserve => Some(MoveResult::Preserve { - source_particle: particle, - target_pos: new_pos, - result, - }), - } - } else { - None +#[derive(Debug, Clone, Copy)] +pub enum ActionKind { + /// Move into an empty target cell, carrying the given particle state. + MoveTo(Particle), + /// The source is consumed and the target becomes `result` + /// (a [`InteractionType::Replace`] interaction, e.g. water + lava → obsidian). + ReplaceTarget { expected: Particle, result: Particle }, + /// The source stays put and the target becomes `result` + /// (a [`InteractionType::Preserve`] interaction, e.g. water + acid → water). + ConvertTarget { expected: Particle, result: Particle }, +} + +/// A step that crosses a chunk boundary or mutates another particle. +/// +/// These cannot be applied during the parallel per-chunk pass, so they are +/// deferred to the serial apply phase ([`Map::apply_deferred_steps`]) where +/// they are re-validated against the post-simulation map. A step whose source +/// or target no longer matches what the particle saw simply does not happen; +/// the source particle keeps its cell and may retry next tick. This is what +/// guarantees particles are never duplicated or destroyed by conflicts. +#[derive(Debug, Clone, Copy)] +pub struct DeferredStep { + /// World position of the acting particle. + pub source_pos: UVec2, + /// The acting particle, which remains at `source_pos` until the step is applied. + pub source_particle: Particle, + pub action: TargetedAction, +} + +/// Decides what a particle moving into `target` should do: move into it if +/// it's free, otherwise interact with its occupant if a rule allows it. +pub fn try_move(context: &StepContext, target: UVec2, particle: Particle) -> Option { + if validate_move_empty(context, target) { + return Some(StepAction::Act(TargetedAction { + target, + kind: ActionKind::MoveTo(particle), + })); } + resolve_interaction(context, target, particle) } -/// Checks if a particle can move to a new position. +/// Checks if a particle can move into `target` as an empty cell. /// -/// This function first verifies that the new position is valid within the map's boundaries. -/// If the new position is within the same chunk, it also ensures that the spot is empty -/// in the chunk's updated state. If the position is outside the original chunk, movement -/// is checked against what is currently in the queue. -fn validate_move_empty(context: &SimulationContext, new_pos: UVec2) -> bool { - // Was it valid on the older not-yet-updated map? - context.map.is_valid_position(new_pos) - && match context.original_chunk.is_within_chunk(new_pos) { - // We're within the same new chunk... Let's make sure it's empty in the new chunk too. - true => { - let local = world_to_chunk_local(new_pos); - context.new_cells[local.x as usize][local.y as usize].is_none() - } - // Not within the same chunk, so have we already queued a move to this location? - false => !context.chunk_queue.contains_key(&new_pos), - } +/// The target must be empty in the start-of-tick map and, for same-chunk +/// moves, still empty in the chunk's next state. Cross-chunk moves are +/// optimistic: the serial apply phase re-validates them. +fn validate_move_empty(context: &StepContext, target: UVec2) -> bool { + context.map.is_valid_position(target) + && (!context.chunk.is_within_chunk(target) || { + let local = world_to_chunk_local(target); + context.new_cells[local.x as usize][local.y as usize].is_none() + }) } -/// Attempts to resolve an interaction between a moving particle and the particle at `new_pos`. -/// Returns the resulting particle and interaction type if an interaction is possible. +/// Attempts to resolve an interaction between a moving particle and the +/// particle at `target` (as of the start of this tick). Interactions are +/// always deferred and re-validated at apply time, so a stale read here can +/// delay an interaction by a tick but never corrupt state. fn resolve_interaction( - context: &SimulationContext, - new_pos: UVec2, + context: &StepContext, + target: UVec2, particle: Particle, -) -> Option<(Particle, InteractionType)> { - if !context.map.within_bounds(new_pos) { - return None; - } +) -> Option { + let target_particle = context.map.get_particle_at(target)?; - // Ensure there's a particle at target... - let target_particle = context.map.get_particle_at(new_pos)?; - - let interaction_pair = InteractionPair { + let rule = INTERACTION_RULES.get(&InteractionPair { source: particle, target: target_particle, + })?; + + let kind = match rule.interaction_type { + InteractionType::Replace => ActionKind::ReplaceTarget { + expected: target_particle, + result: rule.result, + }, + InteractionType::Preserve => ActionKind::ConvertTarget { + expected: target_particle, + result: rule.result, + }, }; - // Ensure these two particles can interact... - let rule = INTERACTION_RULES.get(&interaction_pair)?; - - // Now handle whether it's within the same chunk or not. - if context.original_chunk.is_within_chunk(new_pos) { - // Check if the new chunk has a valid interaction rule - let local_pos = world_to_chunk_local(new_pos); - let new_target = context.new_cells[local_pos.x as usize][local_pos.y as usize]?; - INTERACTION_RULES - .get(&InteractionPair { - source: particle, - target: new_target, - }) - .map(|r| (r.result, r.interaction_type)) - } else { - // If it's outside the chunk, check if it's already queued for movement - if context.chunk_queue.contains_key(&new_pos) { - None - } else { - Some((rule.result, rule.interaction_type)) - } - } + Some(StepAction::Act(TargetedAction { target, kind })) } -/// Handles the result of a particle movement calculation, either updating the local chunk -/// or queueing for inter-chunk movement. -pub fn handle_particle_movement( - original_chunk: &Chunk, - new_cells: &mut [[Option; CHUNK_SIZE as usize]; CHUNK_SIZE as usize], +/// Applies a particle's decided action to the chunk's next state. +/// +/// In-chunk moves and stays are written directly to `new_cells`. Everything +/// else — cross-chunk moves and all interactions — is pushed onto `deferred` +/// for the serial apply phase, with the source particle keeping its cell +/// until the step is confirmed. +/// +/// Returns whether the chunk changed (or has a deferred step pending). +pub(crate) fn apply_local_step( + chunk: &Chunk, + new_cells: &mut ChunkCells, + deferred: &mut Vec, source_pos: UVec2, - new_pos: UVec2, - particle: Particle, - preserve_source: bool, -) -> Option { - // If the new position is not within the chunk, queue it for inter-chunk movement. - if !original_chunk.is_within_chunk(new_pos) { - Some(ParticleMove { - source_pos, - target_pos: new_pos, - particle, - preserve_source, - }) - } else { - // Otherwise, update the local chunk's new_cells directly - let particle_local_pos = world_to_chunk_local(new_pos); - new_cells[particle_local_pos.x as usize][particle_local_pos.y as usize] = Some(particle); - None + original: Particle, + action: StepAction, +) -> bool { + let local = world_to_chunk_local(source_pos); + + match action { + StepAction::Stay(particle) => { + new_cells[local.x as usize][local.y as usize] = Some(particle); + // Direction flips compare equal here on purpose: a particle that + // only flipped is visually and positionally settled. + particle != original + } + StepAction::Act(act) => match act.kind { + ActionKind::MoveTo(particle) if chunk.is_within_chunk(act.target) => { + let target_local = world_to_chunk_local(act.target); + new_cells[target_local.x as usize][target_local.y as usize] = Some(particle); + true + } + _ => { + new_cells[local.x as usize][local.y as usize] = Some(original); + deferred.push(DeferredStep { + source_pos, + source_particle: original, + action: act, + }); + true + } + }, } } diff --git a/src/utils/coords.rs b/src/utils/coords.rs index a8cf6fd..0060669 100644 --- a/src/utils/coords.rs +++ b/src/utils/coords.rs @@ -17,6 +17,12 @@ pub fn get_chunk_from_world_pos(world_pos: UVec2) -> UVec2 { UVec2::new(world_pos.x / CHUNK_SIZE, world_pos.y / CHUNK_SIZE) } +/// Index of a chunk in the map's flat, row-major chunk vector. +/// This is the single indexing convention shared by map storage and generation. +pub fn chunk_index(chunk_pos: UVec2, chunks_wide: u32) -> usize { + (chunk_pos.x + chunk_pos.y * chunks_wide) as usize +} + /// Convert floating-point world coordinates to chunk coordinates pub fn world_vec2_to_chunk(world_pos: Vec2) -> UVec2 { // Convert Vec2 to UVec2 by flooring the values to integers @@ -131,3 +137,48 @@ pub fn chunk_screen_rect(chunk_pos: UVec2, map_width: u32, map_height: u32) -> ( (Vec2::splat(chunk_size_pixels), center_pos) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn world_chunk_round_trip() { + for &world_pos in &[ + UVec2::new(0, 0), + UVec2::new(31, 31), + UVec2::new(32, 0), + UVec2::new(0, 32), + UVec2::new(100, 250), + ] { + let chunk_pos = get_chunk_from_world_pos(world_pos); + let local_pos = world_to_chunk_local(world_pos); + assert!(local_pos.x < CHUNK_SIZE && local_pos.y < CHUNK_SIZE); + assert_eq!(chunk_local_to_world(chunk_pos, local_pos), world_pos); + } + } + + #[test] + fn chunk_index_is_row_major() { + let chunks_wide = 5; + assert_eq!(chunk_index(UVec2::new(0, 0), chunks_wide), 0); + assert_eq!(chunk_index(UVec2::new(4, 0), chunks_wide), 4); + assert_eq!(chunk_index(UVec2::new(0, 1), chunks_wide), 5); + assert_eq!(chunk_index(UVec2::new(3, 2), chunks_wide), 13); + } + + #[test] + fn bresenham_line_connects_endpoints() { + let start = UVec2::new(2, 3); + let end = UVec2::new(7, 1); + let points = bresenham_line(start, end); + + assert_eq!(points.first(), Some(&start)); + assert_eq!(points.last(), Some(&end)); + // Each step moves at most one cell on each axis. + for pair in points.windows(2) { + assert!(pair[0].x.abs_diff(pair[1].x) <= 1); + assert!(pair[0].y.abs_diff(pair[1].y) <= 1); + } + } +} diff --git a/src/world/chunk.rs b/src/world/chunk.rs index cfdedbf..6b90154 100644 --- a/src/world/chunk.rs +++ b/src/world/chunk.rs @@ -1,34 +1,51 @@ -use std::{collections::HashMap, sync::Arc}; +use std::collections::HashMap; use crate::{ particle::{Particle, ParticleType}, render::chunk_material::INDICE_BUFFER_SIZE, - simulation::{fluid::FluidSimulator, SimulationContext, Simulator}, + simulation::{apply_local_step, fluid::FluidSimulator, DeferredStep, Simulator, StepContext}, + utils::coords::chunk_local_to_world, }; use bevy::prelude::*; -use dashmap::DashMap; +use rand::{rngs::SmallRng, SeedableRng}; use super::Map; /// The square size of a chunk in particle units (not pixels). /// Note: If you modify this, you must update the shader's indices buffer size. -pub(crate) const CHUNK_SIZE: u32 = 32; +pub const CHUNK_SIZE: u32 = 32; /// The range (in chunks) at which chunks are considered active around the player. pub(crate) const ACTIVE_CHUNK_RANGE: u32 = 12; -/// Represents a particle that needs to move to a new position. Used in queue system. -/// Note: This is used in a HashMap where the key is the target position, which is why we don't store it. -#[derive(Debug, Clone)] -pub struct ParticleMove { - /// Source position in world coordinates - pub source_pos: UVec2, - /// Target position in world coordinates - pub target_pos: UVec2, - /// The particle to place at the target position - pub particle: Particle, - /// If true, the source particle is NOT removed (Preserve interaction). - pub preserve_source: bool, +/// The cells of a chunk, indexed `[x][y]` in chunk-local coordinates. +pub type ChunkCells = [[Option; CHUNK_SIZE as usize]; CHUNK_SIZE as usize]; + +/// Which borders of a chunk changed during a simulation step. +/// Used to wake neighboring chunks whose particles may now be able to move +/// into (or interact with) the changed cells. +#[derive(Debug, Clone, Copy, Default)] +pub struct BorderActivity { + pub left: bool, + pub right: bool, + pub bottom: bool, + pub top: bool, + pub bottom_left: bool, + pub bottom_right: bool, + pub top_left: bool, + pub top_right: bool, +} + +/// The result of simulating one chunk for one tick. +#[derive(Debug, Default)] +pub struct ChunkSimOutcome { + /// Steps that must be applied serially by the map: cross-chunk moves and + /// all particle interactions. + pub deferred: Vec, + /// Whether any cell changed (or a deferred step is pending). + pub changed: bool, + /// Which borders changed, for waking neighboring chunks. + pub border: BorderActivity, } /// A chunk represents a square section of the world map @@ -37,15 +54,17 @@ pub struct Chunk { /// Position of this chunk in chunk coordinates (not world coordinates) pub position: UVec2, /// Particles stored in this chunk, indexed by local coordinates - /// Only contains entries for cells that have particles - pub cells: [[Option; CHUNK_SIZE as usize]; CHUNK_SIZE as usize], - /// Whether this chunk has been modified since last update - pub dirty: bool, - /// Whether this chunk is non-homogenous and needs active simulation - pub should_simulate: bool, + pub cells: ChunkCells, /// Monotonically increasing version counter, bumped on any cell change. /// Used by the renderer to skip unchanged chunks. pub version: u64, + /// Whether this chunk is known to contain liquid particles. Set when a + /// liquid is placed and recomputed on every simulation pass. + has_liquid: bool, + /// Whether this chunk needs simulating. Cleared when a simulation pass + /// produces no changes; set again by any mutation (in this chunk or on a + /// facing border of a neighbor). + awake: bool, } impl Chunk { @@ -54,9 +73,9 @@ impl Chunk { Self { position, cells: [[None; CHUNK_SIZE as usize]; CHUNK_SIZE as usize], - dirty: false, - should_simulate: false, version: 0, + has_liquid: false, + awake: false, } } @@ -88,116 +107,104 @@ impl Chunk { self.cells[local_pos.x as usize][local_pos.y as usize] } - /// Set a particle at the given local position + /// Set a particle at the given local position, waking the chunk. pub fn set_particle(&mut self, local_pos: UVec2, particle: Option) { if !self.is_in_bounds(local_pos) { return; } self.cells[local_pos.x as usize][local_pos.y as usize] = particle; - self.dirty = true; self.version += 1; - } - - /// Updates the should_simulate flag by checking if the chunk contains any fluid particles. - fn update_active_state(&mut self) { - self.should_simulate = false; - - for y in 0..CHUNK_SIZE { - for x in 0..CHUNK_SIZE { - if let Some(Particle::Liquid(_)) = self.cells[x as usize][y as usize] { - self.should_simulate = true; - return; // Early return once we find a fluid - } - } + self.awake = true; + if matches!(particle, Some(Particle::Liquid(_))) { + self.has_liquid = true; } } - /// Update particles in this chunk if it's dirty - pub fn trigger_refresh(&mut self) { - if !self.dirty { - return; - } - - // TODO: Perform logic for collider regeneration, etc. here. - - // Did an active particle enter or leave this chunk? - self.update_active_state(); + /// Whether this chunk needs a simulation pass this tick. + pub fn should_simulate(&self) -> bool { + self.has_liquid && self.awake + } - self.dirty = false; + /// Mark this chunk as needing simulation (e.g. a neighboring border cell changed). + pub fn wake(&mut self) { + self.awake = true; } - /// Simulate active particles (like fluids) in this chunk. - /// This method handles simulation for particles that stay within this chunk. - /// Modifies `self` in place. - pub fn simulate( - &mut self, - map: &Map, - interchunk_queue: Arc>, - ) { - // Only proceed if this chunk has active particles. - if !self.should_simulate { - return; + /// Simulate active particles (like fluids) in this chunk for one tick. + /// + /// Movement within the chunk is applied directly; anything that crosses + /// the chunk border or mutates another particle is returned as a deferred + /// step for the map to apply serially. + /// + /// If nothing changed, the chunk puts itself to sleep: it will not + /// simulate again until something mutates it (or a facing neighbor border). + pub fn simulate(&mut self, map: &Map, tick: u64) -> ChunkSimOutcome { + if !self.should_simulate() { + return ChunkSimOutcome::default(); } - // Create a copy of the current state to read from. + // Read from the start-of-tick state, write to a fresh one. let original_cells = self.cells; - // Create a new state to write to (initially empty). - let mut new_cells = [[None; CHUNK_SIZE as usize]; CHUNK_SIZE as usize]; + let mut new_cells: ChunkCells = [[None; CHUNK_SIZE as usize]; CHUNK_SIZE as usize]; + + let mut deferred = Vec::new(); + let mut changed = false; + let mut has_liquid = false; + // Seeded per (tick, chunk) so simulation is deterministic regardless + // of thread scheduling. + let mut rng = SmallRng::seed_from_u64(step_seed(tick, self.position)); - // Process all particles in the chunk. for (x, column) in original_cells.iter().enumerate() { - for (y, &particle) in column.iter().enumerate() { - // Skip empty cells. - let Some(particle) = particle else { continue }; + for (y, &cell) in column.iter().enumerate() { + let Some(particle) = cell else { continue }; match particle { Particle::Liquid(fluid) => { - // For fluids, calculate new position using the original state. - // This will append to the queue of ParticleMoves if there is interchunk movement. - if let Some(particle_move) = FluidSimulator.simulate( - SimulationContext::new( + has_liquid = true; + let world_pos = + chunk_local_to_world(self.position, UVec2::new(x as u32, y as u32)); + let action = FluidSimulator.calculate_step( + &StepContext { map, - self, - interchunk_queue.as_ref(), - &mut new_cells, - ), + chunk: self, + new_cells: &new_cells, + }, fluid, - x as u32, - y as u32, - ) { - interchunk_queue - .entry(particle_move.target_pos) - .and_modify(|existing| { - let particle_move = particle_move.clone(); - // Use abs_diff to avoid i32 casts - let existing_distance = - existing.source_pos.x.abs_diff(existing.target_pos.x) - + existing.source_pos.y.abs_diff(existing.target_pos.y); - - let new_distance = - particle_move.source_pos.x.abs_diff(particle_move.target_pos.x) - + particle_move.source_pos.y.abs_diff(particle_move.target_pos.y); - - // Particle that's closer to the target position wins - if new_distance < existing_distance { - *existing = particle_move; - } - }) - .or_insert(particle_move); - } + world_pos, + &mut rng, + ); + changed |= apply_local_step( + self, + &mut new_cells, + &mut deferred, + world_pos, + particle, + action, + ); } + // Everything else is static: carry it over unchanged. _ => new_cells[x][y] = Some(particle), } } } - // Update the chunk with the new state. Swap is fast. + let border = border_activity(&original_cells, &new_cells); self.cells = new_cells; + self.has_liquid = has_liquid; + if changed { + self.version += 1; + } else { + // A liquid-bearing chunk where nothing moves is settled: sleep + // until something mutates it or a neighboring border changes. + self.awake = false; + } - // Mark the chunk as dirty after simulation to ensure other systems update. - self.dirty = true; - self.version += 1; + ChunkSimOutcome { + deferred, + changed, + border, + } } /// Convert the particles in this chunk to a list of spritesheet indices. @@ -254,3 +261,31 @@ impl Chunk { && world_pos.y < self.y_max() } } + +/// Mixes a tick number and chunk position into an RNG seed. +fn step_seed(tick: u64, chunk_pos: UVec2) -> u64 { + let pos = (u64::from(chunk_pos.x) << 32) | u64::from(chunk_pos.y); + tick.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ pos.wrapping_mul(0xD6E8_FEB8_6659_FD93) +} + +/// Compares the border cells of the old and new state, reporting which edges +/// (and corners, for diagonal neighbors) changed. +fn border_activity(old: &ChunkCells, new: &ChunkCells) -> BorderActivity { + let max = (CHUNK_SIZE - 1) as usize; + let changed = |x: usize, y: usize| old[x][y] != new[x][y]; + + let mut border = BorderActivity { + bottom_left: changed(0, 0), + bottom_right: changed(max, 0), + top_left: changed(0, max), + top_right: changed(max, max), + ..Default::default() + }; + for i in 0..=max { + border.left |= changed(0, i); + border.right |= changed(max, i); + border.bottom |= changed(i, 0); + border.top |= changed(i, max); + } + border +} diff --git a/src/world/generator.rs b/src/world/generator.rs index cc9b324..dc8e0b4 100644 --- a/src/world/generator.rs +++ b/src/world/generator.rs @@ -1,193 +1,161 @@ use crate::{ particle::{Common, Particle, Special}, - utils::coords::{get_chunk_from_world_pos, world_to_chunk_local}, - world::chunk::Chunk, + utils::coords::{ + chunk_index, chunk_local_to_world, get_chunk_from_world_pos, world_to_chunk_local, + }, + world::chunk::{Chunk, CHUNK_SIZE}, }; -use bevy::{ecs::system::Commands, log::info_span, math::UVec2, prelude::info}; -use rand::Rng; -use std::{cell::UnsafeCell, sync::Arc}; +use bevy::{ecs::system::Commands, log::info, log::info_span, math::UVec2}; +use rand::{rngs::SmallRng, Rng, SeedableRng}; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use strum::IntoEnumIterator; -use super::{chunk::CHUNK_SIZE, Map}; +use super::Map; -pub(crate) struct UnsafeChunkData { - pub chunks: UnsafeCell>, -} - -unsafe impl Sync for UnsafeChunkData {} - -/// Generate terrain data for the entire map. -pub(crate) fn generate_all_data(map_width: u32, map_height: u32) -> Vec { - let _ = info_span!("generate_map_data_all").entered(); +/// Generate terrain for the entire map, returning chunks in row-major order +/// (matching [`chunk_index`]). The same seed always produces the same world. +pub(crate) fn generate_chunks(map_width: u32, map_height: u32, seed: u64) -> Vec { + let _ = info_span!("generate_chunks").entered(); let start_method = std::time::Instant::now(); // Pre-compute all surface heights let surface_heights = calculate_surface_heights(map_width, map_height); - // Create empty chunks - let chunks = create_empty_chunks(map_width, map_height); - - // Create unsafe wrapper to allow parallel writing - let unsafe_data = Arc::new(UnsafeChunkData { - chunks: UnsafeCell::new(chunks), - }); - - // Determine number of threads to use - let num_cpus = num_cpus::get(); - // Used to calculate the number of columns to process per thread. - let work_unit = (map_width as usize / num_cpus).max(1); - - // Process columns in parallel - let start_parallel = std::time::Instant::now(); - let mut handles = Vec::new(); - - for thread_id in 0..num_cpus { - let unsafe_data_clone = Arc::clone(&unsafe_data); - let surface_heights_clone = surface_heights.clone(); - - let start_x = thread_id * work_unit; - - // If we're the last thread, process the remaining columns. - let end_x = if thread_id == num_cpus - 1 { - map_width as usize - } else { - (thread_id + 1) * work_unit - }; - - handles.push(std::thread::spawn(move || { - process_columns_range( - start_x, - end_x, - &surface_heights_clone, - map_width, - map_height, - unsafe_data_clone, - ); - })); + let chunks_wide = map_width / CHUNK_SIZE; + let chunks_tall = map_height / CHUNK_SIZE; + + // Each chunk is generated independently (and in parallel) with its own + // seeded RNG. Particles that fall outside their chunk — ore veins rolled + // near a border — are collected as spill and applied serially afterwards. + let results: Vec<(Chunk, Vec<(UVec2, Particle)>)> = (0..chunks_wide * chunks_tall) + .into_par_iter() + .map(|i| { + let chunk_pos = UVec2::new(i % chunks_wide, i / chunks_wide); + generate_chunk(chunk_pos, &surface_heights, map_width, map_height, seed) + }) + .collect(); + + let mut chunks = Vec::with_capacity(results.len()); + let mut spills = Vec::new(); + for (chunk, mut spill) in results { + chunks.push(chunk); + spills.append(&mut spill); } - // Wait for all threads to complete - for handle in handles { - handle.join().unwrap(); + // Vein particles may overwrite whatever the neighboring chunk generated, + // matching the in-chunk rule that specials overwrite commons. + for (position, particle) in spills { + let index = chunk_index(get_chunk_from_world_pos(position), chunks_wide); + chunks[index].set_particle(world_to_chunk_local(position), Some(particle)); } - info!(" Parallel processing took: {:?}", start_parallel.elapsed()); - info!("Total generate_all_data time: {:?}", start_method.elapsed()); + info!("Total generate_chunks time: {:?}", start_method.elapsed()); - // Return the completed chunks vector - unsafe { (*unsafe_data.chunks.get()).clone() } + chunks } -/// Process a range of columns in the map -fn process_columns_range( - start_x: usize, - end_x: usize, +/// Generate a single chunk's terrain. Returns the chunk together with any +/// particles that belong to neighboring chunks (vein spill-over). +fn generate_chunk( + chunk_pos: UVec2, surface_heights: &[u32], map_width: u32, map_height: u32, - unsafe_data: Arc, -) { - let _ = info_span!( - "generate_map_data_thread", - width_range = format!("{}..{}", start_x, end_x) - ) - .entered(); - let mut rng = rand::rng(); - - for (x, _) in surface_heights - .iter() - .enumerate() - .skip(start_x) - .take(end_x - start_x) - { - let surface_height = surface_heights[x]; - - for y in 0..map_height as usize { - let position = UVec2::new(x as u32, y as u32); - let special_particle = if y as u32 > surface_height { - None - } else { - let depth = surface_height - y as u32; - Map::roll_special_particle(depth, &mut rng) - }; - - if let Some(Particle::Special(special)) = special_particle { - process_special_particle(position, special, map_width, map_height, &unsafe_data); - } else if y as u32 <= surface_height { - // If no special particle was rolled, use common particle - let depth = surface_height - y as u32; - process_common_particle(position, depth, &unsafe_data, map_width); + seed: u64, +) -> (Chunk, Vec<(UVec2, Particle)>) { + let mut chunk = Chunk::new(chunk_pos); + let mut spill = Vec::new(); + let mut rng = SmallRng::seed_from_u64(chunk_seed(seed, chunk_pos)); + + for local_x in 0..CHUNK_SIZE { + for local_y in 0..CHUNK_SIZE { + let local_pos = UVec2::new(local_x, local_y); + let world_pos = chunk_local_to_world(chunk_pos, local_pos); + + let surface_height = surface_heights[world_pos.x as usize]; + if world_pos.y > surface_height { + continue; // Above the surface: air. + } + let depth = surface_height - world_pos.y; + + if let Some(particle) = roll_special_particle(depth, &mut rng) { + let placements = match particle { + Particle::Special(Special::Ore(_)) => { + spawn_vein(world_pos, particle, map_width, map_height, &mut rng) + } + _ => vec![(world_pos, particle)], + }; + + for (position, particle) in placements { + if chunk.is_within_chunk(position) { + // Specials may overwrite commons (and earlier specials). + chunk.set_particle(world_to_chunk_local(position), Some(particle)); + } else { + spill.push((position, particle)); + } + } + } else if chunk.get_particle(local_pos).is_none() { + // Common particles never overwrite specials (e.g. an earlier vein). + let common = Common::get_exclusive_at_depth(depth); + chunk.set_particle(local_pos, Some(common.into())); } } } -} -/// Helper function to convert world position to chunk index -fn world_to_chunk_index(position: UVec2, map_width: u32) -> (UVec2, usize) { - let chunk_pos = get_chunk_from_world_pos(position); - let local_pos = world_to_chunk_local(position); - let chunks_wide = map_width / CHUNK_SIZE; - let chunk_index = (chunk_pos.x + chunk_pos.y * chunks_wide) as usize; - (local_pos, chunk_index) + (chunk, spill) } -/// Process special particles (ores and gems) and place them in the world. -/// Note: Special particles are allowed to overwrite common particles. -fn process_special_particle( - position: UVec2, - special: Special, - map_width: u32, - map_height: u32, - unsafe_data: &Arc, -) { - let particles = match special { - Special::Ore(_) => spawn_vein(position, Particle::Special(special), map_width, map_height), - Special::Gem(_) => vec![(position, Particle::Special(special))], - }; - - // Place all the spawned particles - for (spawn_pos, particle) in particles { - let (local_pos, chunk_index) = world_to_chunk_index(spawn_pos, map_width); - - // Use unsafe to set the particle in the shared chunk data - unsafe { - let chunks = &mut *unsafe_data.chunks.get(); - chunks[chunk_index].set_particle(local_pos, Some(particle)); - } - } +/// Mixes the world seed and a chunk position into that chunk's RNG seed. +fn chunk_seed(seed: u64, chunk_pos: UVec2) -> u64 { + let pos = (u64::from(chunk_pos.x) << 32) | u64::from(chunk_pos.y); + seed ^ pos.wrapping_mul(0x9E37_79B9_7F4A_7C15) } -/// Process common particles and place them in the world. -/// Note: Common particles are not allowed to overwrite special particles. -fn process_common_particle( - position: UVec2, - depth: u32, - unsafe_data: &Arc, - map_width: u32, -) { - // Get common particle based on depth - let common_particle = Common::get_exclusive_at_depth(depth).into(); - - // Convert world position to chunk and local coordinates - let (local_pos, chunk_index) = world_to_chunk_index(position, map_width); - - // Use unsafe to set the particle in the shared chunk data - unsafe { - let chunks = &mut *unsafe_data.chunks.get(); +/// Uses a weighted random roll to determine if a special particle should +/// spawn at the given depth, and if so, which one. +/// +/// Spawn chances are per-mille: the sum of all chances valid at one depth +/// must stay below 1000, or specials would spawn on every roll. +fn roll_special_particle(depth: u32, rng: &mut impl Rng) -> Option { + let valid = |special: &Special| depth >= special.min_depth() && depth < special.max_depth(); + + let total_weight: i32 = Special::iter() + .filter(valid) + .map(|special| special.spawn_chance()) + .sum(); + if total_weight == 0 { + return None; + } + debug_assert!( + total_weight < 1000, + "special spawn chances at depth {depth} sum to {total_weight}, \ + which eats the entire no-spawn chance" + ); + + // First roll: does any special spawn at all? + if rng.random_range(0..1000) >= total_weight { + return None; + } - if chunks[chunk_index].get_particle(local_pos).is_none() { - chunks[chunk_index].set_particle(local_pos, Some(common_particle)); + // Second roll: weighted selection of which special spawns. + let mut roll = rng.random_range(0..total_weight); + for special in Special::iter().filter(valid) { + roll -= special.spawn_chance(); + if roll < 0 { + return Some(Particle::Special(special)); } } + None } /// Generates and returns a vein (a small cluster of ore particles) around the specified position -pub fn spawn_vein( +fn spawn_vein( position: UVec2, particle: Particle, map_width: u32, map_height: u32, + rng: &mut impl Rng, ) -> Vec<(UVec2, Particle)> { - let mut rng = rand::rng(); let mut vein_particles = vec![(position, particle)]; // Start with the central particle // Determine vein size (3-6 additional particles) @@ -229,25 +197,8 @@ pub fn setup_map(mut commands: Commands) { commands.insert_resource(map); } -/// Create and initialize empty chunks. -/// This function is useful because it can properly assign positions to chunks. -fn create_empty_chunks(map_width: u32, map_height: u32) -> Vec { - let chunks_wide = map_width / CHUNK_SIZE; - let chunks_tall = map_height / CHUNK_SIZE; - let mut chunks = Vec::with_capacity((chunks_wide * chunks_tall) as usize); - for x in 0..chunks_wide { - for y in 0..chunks_tall { - // We must push in y, x order because the chunks are stored in a 1D vector. - chunks.push(Chunk::new(UVec2::new(y, x))); - } - } - chunks -} - /// Calculate surface heights for terrain generation fn calculate_surface_heights(map_width: u32, map_height: u32) -> Vec { - let _ = info_span!("calculate_surface_heights").entered(); - let base_height = (map_height as f32 * 0.95) as u32; (0..map_width) diff --git a/src/world/map.rs b/src/world/map.rs index 19597d9..1b36627 100644 --- a/src/world/map.rs +++ b/src/world/map.rs @@ -1,17 +1,16 @@ -use crate::particle::{Particle, Special}; +use crate::particle::Particle; use crate::player::Player; -use crate::utils; -use crate::utils::coords::{screen_to_world, world_vec2_to_chunk}; -use crate::world::chunk::{Chunk, ParticleMove, ACTIVE_CHUNK_RANGE, CHUNK_SIZE}; -use crate::world::generator::generate_all_data; +use crate::simulation::{ActionKind, DeferredStep}; +use crate::utils::coords::{ + chunk_index, get_chunk_from_world_pos, screen_to_world, world_to_chunk_local, + world_vec2_to_chunk, +}; +use crate::world::chunk::{BorderActivity, Chunk, ChunkSimOutcome, ACTIVE_CHUNK_RANGE, CHUNK_SIZE}; +use crate::world::generator::generate_chunks; use bevy::prelude::*; -use dashmap::DashMap; -use rand::prelude::*; -use rand::rngs::ThreadRng; -use rayon::iter::{IntoParallelRefMutIterator, ParallelIterator}; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; use std::collections::HashMap; use std::collections::HashSet; -use std::sync::Arc; /// The rate at which the map is simulated per second. pub(crate) const SIMULATION_RATE: f64 = 80.0; @@ -20,55 +19,96 @@ pub(crate) const SIMULATION_RATE: f64 = 80.0; pub struct Map { pub width: u32, pub height: u32, - pub chunks: Vec>, + /// All chunks in a flat, row-major vector; see [`coords::chunk_index`]. + chunks: Vec, pub active_chunks: HashSet, + /// Number of simulation steps taken so far. Seeds the per-chunk RNG. + tick: u64, } impl Map { /// Create a new empty world with the given width and height (in particle units). pub fn empty(width: u32, height: u32) -> Self { - let chunks_wide = (width / CHUNK_SIZE) as usize; - let chunks_tall = (height / CHUNK_SIZE) as usize; - - let mut chunks: Vec> = vec![vec![]; chunks_wide]; + assert!( + width % CHUNK_SIZE == 0 && height % CHUNK_SIZE == 0, + "map dimensions ({width}x{height}) must be multiples of CHUNK_SIZE ({CHUNK_SIZE})" + ); - // Initialize all chunks - for (cx, chunk_col) in chunks.iter_mut().enumerate() { - *chunk_col = Vec::with_capacity(chunks_tall); - for cy in 0..chunks_tall { - let chunk_pos = UVec2::new(cx as u32, cy as u32); - chunk_col.push(Chunk::new(chunk_pos)); - } - } + let chunks_wide = width / CHUNK_SIZE; + let chunks_tall = height / CHUNK_SIZE; + let chunks = (0..chunks_wide * chunks_tall) + .map(|i| Chunk::new(UVec2::new(i % chunks_wide, i / chunks_wide))) + .collect(); Self { width, height, chunks, active_chunks: HashSet::new(), + tick: 0, } } + /// Create a new world with terrain, using a random seed. + /// - `width`: Number of chunks wide the map should be + /// - `height`: Number of chunks tall the map should be + pub fn generate(width: u32, height: u32) -> Self { + Self::generate_with_seed(width, height, rand::random()) + } + + /// Create a new world with terrain. The same seed always produces the same world. + pub fn generate_with_seed(width: u32, height: u32, seed: u64) -> Self { + let _ = info_span!("map_generate").entered(); + let start_total = std::time::Instant::now(); + + // Convert chunk counts to particle dimensions + let map_width = width * CHUNK_SIZE; + let map_height = height * CHUNK_SIZE; + + let mut map = Map::empty(map_width, map_height); + map.chunks = generate_chunks(map_width, map_height, seed); + debug_assert!( + map.chunks + .iter() + .enumerate() + .all(|(i, chunk)| map.chunk_index_of(chunk.position) == i), + "generated chunks are not in row-major order" + ); + + map.log_composition(); + info!("Total Map::generate time: {:?}", start_total.elapsed()); + + map + } + + fn chunks_wide(&self) -> u32 { + self.width / CHUNK_SIZE + } + + fn chunks_tall(&self) -> u32 { + self.height / CHUNK_SIZE + } + + /// Index of the chunk at the given chunk coordinates in the flat vector. + fn chunk_index_of(&self, chunk_pos: UVec2) -> usize { + chunk_index(chunk_pos, self.chunks_wide()) + } + /// Analyze and log the composition of the world fn log_composition(&self) { let mut particle_counts: HashMap = HashMap::new(); let mut total_particles = 0; // Count particles - for chunk_col in self.chunks.iter() { - for chunk in chunk_col.iter() { - let chunk_composition = chunk.get_composition(); - - for (particle, count) in chunk_composition { - *particle_counts.entry(particle).or_insert(0) += count; - total_particles += count; - } + for chunk in &self.chunks { + for (particle, count) in chunk.get_composition() { + *particle_counts.entry(particle).or_insert(0) += count; + total_particles += count; } } let air_count = self.width * self.height - total_particles; - - let total_cells = total_particles + air_count; + let total_cells = self.width * self.height; // Log results info!("\nMap Composition Analysis:"); @@ -83,7 +123,7 @@ impl Map { // Convert to vec for sorting let mut counts: Vec<_> = particle_counts.into_iter().collect(); - counts.sort_by(|a, b| b.1.cmp(&a.1)); // Sort by count, descending + counts.sort_by_key(|&(_, count)| std::cmp::Reverse(count)); for (particle_type, count) in counts { let percentage = (count as f32 / total_cells as f32) * 100.0; @@ -94,112 +134,91 @@ impl Map { } } - /// Uses a weighted random roll to determine if a special particle should spawn, and if so, which one. - /// Returns `None` if no special particle should spawn. - pub(crate) fn roll_special_particle(depth: u32, rng: &mut ThreadRng) -> Option { - // Get valid special particles for this depth - let mut valid_particles: Vec<_> = Special::all_variants() - .into_iter() - .filter(|p| depth >= p.min_depth() && depth < p.max_depth()) - .collect(); - - if valid_particles.is_empty() { - return None; - } - - // Sort particles from lowest to highest spawn chance - valid_particles.sort_unstable_by_key(|p| p.spawn_chance()); - - // Calculate total spawn weight - let total_weight: i32 = valid_particles.iter().map(|p| p.spawn_chance()).sum(); - - // First check: determine if we spawn any special particle - if rng.random_range(0..1000) >= total_weight { + /// Helper function to get a particle at the specified position. + /// Returns `None` for out-of-bounds positions. + pub fn get_particle_at(&self, position: UVec2) -> Option { + if !self.within_bounds(position) { return None; } - // Second check: weighted selection of which particle to spawn - let random_val = rng.random_range(0..total_weight); - let mut acc = 0; - for &special in &valid_particles { - acc += special.spawn_chance(); - if random_val < acc { - return Some(Particle::Special(special)); - } - } - None + let chunk = &self.chunks[self.chunk_index_of(get_chunk_from_world_pos(position))]; + chunk.get_particle(world_to_chunk_local(position)) } - /// Distribute inputted 1D Vec of chunks into the 2D vector structure - fn distribute_among_chunks(&mut self, chunks_vec: Vec) { - let cw = (self.width / CHUNK_SIZE) as usize; - for (i, chunk) in chunks_vec.into_iter().enumerate() { - let x = i % cw; - let y = i / cw; - self.chunks[x][y] = chunk; + /// Helper function to set a particle at the specified map position. + /// Wakes the containing chunk and, for border cells, the facing neighbors. + pub fn set_particle_at(&mut self, position: UVec2, particle: Option) { + if !self.within_bounds(position) { + return; } - } - /// Create a new world with terrain. - /// - `width`: Number of chunks wide the map should be - /// - `height`: Number of chunks tall the map should be - pub fn generate(width: u32, height: u32) -> Self { - let _ = info_span!("map_generate").entered(); - let start_total = std::time::Instant::now(); - - // Convert chunk counts to particle dimensions - let map_width = width * CHUNK_SIZE; - let map_height = height * CHUNK_SIZE; - - // Create an empty map - let mut map = Map::empty(map_width, map_height); - - // Generate all map data and get the populated chunks - let chunks_vec = generate_all_data(map_width, map_height); - - // Distribute chunks into the 2D vector structure - map.distribute_among_chunks(chunks_vec); - - // Print composition statistics - let start_log = std::time::Instant::now(); - map.log_composition(); - info!("log_composition took: {:?}", start_log.elapsed()); - - info!("Total Map::generate time: {:?}", start_total.elapsed()); - - map + let chunk_pos = get_chunk_from_world_pos(position); + let local_pos = world_to_chunk_local(position); + let index = self.chunk_index_of(chunk_pos); + self.chunks[index].set_particle(local_pos, particle); + self.wake_neighbors_of_cell(chunk_pos, local_pos); } - /// Helper function to get a particle at the specified position. - /// Returns `None` for out-of-bounds positions. - pub fn get_particle_at(&self, position: UVec2) -> Option { - if position.x >= self.width || position.y >= self.height { - return None; + /// Wakes the chunks adjacent to a mutated border cell: their particles may + /// now be able to move into (or interact with) the changed cell. + fn wake_neighbors_of_cell(&mut self, chunk_pos: UVec2, local_pos: UVec2) { + let max_local = CHUNK_SIZE - 1; + for dx in -1..=1i32 { + for dy in -1..=1i32 { + if dx == 0 && dy == 0 { + continue; + } + let touches_x = match dx { + -1 => local_pos.x == 0, + 1 => local_pos.x == max_local, + _ => true, + }; + let touches_y = match dy { + -1 => local_pos.y == 0, + 1 => local_pos.y == max_local, + _ => true, + }; + if touches_x && touches_y { + self.wake_chunk_at_offset(chunk_pos, dx, dy); + } + } } - - let chunk_pos = utils::coords::get_chunk_from_world_pos(position); - let local_pos = utils::coords::world_to_chunk_local(position); - - let chunk = &self.chunks[chunk_pos.x as usize][chunk_pos.y as usize]; - chunk.get_particle(local_pos) } - /// Helper function to set a particle at the specified map position while handling chunk boundaries. - pub fn set_particle_at(&mut self, position: UVec2, particle: Option) { - if position.x >= self.width || position.y >= self.height { + /// Wakes the chunk at `chunk_pos + (dx, dy)`, if it exists. + fn wake_chunk_at_offset(&mut self, chunk_pos: UVec2, dx: i32, dy: i32) { + let nx = i64::from(chunk_pos.x) + i64::from(dx); + let ny = i64::from(chunk_pos.y) + i64::from(dy); + if nx < 0 || ny < 0 || nx >= i64::from(self.chunks_wide()) || ny >= i64::from(self.chunks_tall()) + { return; } + let index = self.chunk_index_of(UVec2::new(nx as u32, ny as u32)); + self.chunks[index].wake(); + } - let chunk_pos = utils::coords::get_chunk_from_world_pos(position); - let local_pos = utils::coords::world_to_chunk_local(position); - - let chunk = &mut self.chunks[chunk_pos.x as usize][chunk_pos.y as usize]; - chunk.set_particle(local_pos, particle); + /// Wakes the neighbors facing the borders of `chunk_pos` that changed. + fn wake_border_neighbors(&mut self, chunk_pos: UVec2, border: BorderActivity) { + let edges = [ + (border.left, -1, 0), + (border.right, 1, 0), + (border.bottom, 0, -1), + (border.top, 0, 1), + (border.bottom_left, -1, -1), + (border.bottom_right, 1, -1), + (border.top_left, -1, 1), + (border.top_right, 1, 1), + ]; + for (changed, dx, dy) in edges { + if changed { + self.wake_chunk_at_offset(chunk_pos, dx, dy); + } + } } /// Returns a list of chunk positions within a radius of the given world position pub fn get_chunks_near(&self, position: Vec2, range: u32) -> Vec { - let center_chunk = utils::coords::world_vec2_to_chunk(position); + let center_chunk = world_vec2_to_chunk(position); let chunk_range = range.div_ceil(CHUNK_SIZE); let mut nearby_chunks = Vec::new(); @@ -211,20 +230,12 @@ impl Map { let max_y = center_chunk.y.saturating_add(chunk_range); // Calculate map bounds in chunk coordinates - let max_chunk_x = self.width / CHUNK_SIZE - 1; - let max_chunk_y = self.height / CHUNK_SIZE - 1; + let max_chunk_x = self.chunks_wide() - 1; + let max_chunk_y = self.chunks_tall() - 1; // Collect all chunk positions within the circular range and map bounds - for x in min_x..=max_x { - if x > max_chunk_x { - continue; - } - - for y in min_y..=max_y { - if y > max_chunk_y { - continue; - } - + for x in min_x..=max_x.min(max_chunk_x) { + for y in min_y..=max_y.min(max_chunk_y) { let chunk_pos = UVec2::new(x, y); let dx = center_chunk.x.abs_diff(chunk_pos.x); @@ -242,79 +253,101 @@ impl Map { nearby_chunks } - /// Update all active chunks that are marked as dirty. - pub fn update_dirty_chunks(&mut self) { - for chunk_pos in self.active_chunks.iter() { - let chunk = &mut self.chunks[chunk_pos.x as usize][chunk_pos.y as usize]; - if chunk.dirty { - chunk.trigger_refresh(); - } - } - } - /// Trigger a simulation of active particles in all active chunks. /// /// Uses a two-phase approach: - /// 1. First simulate each chunk internally (for in-chunk particle updates) - /// 2. Then handle cross-chunk particle movement with a message queue system + /// 1. Each awake chunk is simulated in parallel against the start-of-tick + /// map; in-chunk movement is applied directly. + /// 2. Cross-chunk moves and interactions are deferred steps, applied + /// serially in a deterministic order with re-validation, so conflicting + /// steps can never duplicate or destroy particles. pub fn simulate_active_chunks(&mut self) { - // Parallel-safe interchunk queue. - let interchunk_queue = Arc::new(DashMap::new()); - // Copy only chunks that need simulation - let mut active_chunks = self.copy_simulatable_chunks(); - - // Parallel simulation: Process each chunk in parallel - active_chunks - .par_iter_mut() - .for_each(|chunk| chunk.simulate(self, interchunk_queue.clone())); - - // Write back only modified chunks - for chunk in active_chunks { - self.set_chunk_at(chunk.position, chunk); - } + self.tick = self.tick.wrapping_add(1); + let tick = self.tick; - // We do this at the end for a second pass of processing. - // For example, we can process from the lowest y-value to the highest. - self.apply_particle_moves(Arc::try_unwrap(interchunk_queue).unwrap()); - } + let simulatable = self.copy_simulatable_chunks(); + if simulatable.is_empty() { + return; + } - /// Apply all particle moves in a consistent way that avoids conflicts. - fn apply_particle_moves(&mut self, interchunk_queue: DashMap) { - // Collect queue into a Vec. - let mut moves: Vec<(UVec2, ParticleMove)> = interchunk_queue - .iter() - .map(|entry| (*entry.key(), entry.value().clone())) + // Parallel phase: simulate each chunk against the start-of-tick map. + let map_view: &Map = self; + let outcomes: Vec<(Chunk, ChunkSimOutcome)> = simulatable + .into_par_iter() + .map(|mut chunk| { + let outcome = chunk.simulate(map_view, tick); + (chunk, outcome) + }) .collect(); - // Sort moves to ensure deterministic behavior. - moves.sort_by_key(|m| (m.0.y, m.0.x)); // Process bottom-to-top + // Serial phase: write back chunks, wake neighbors of changed borders, + // then apply the deferred steps. + let mut deferred = Vec::new(); + for (chunk, outcome) in outcomes { + let chunk_pos = chunk.position; + let index = self.chunk_index_of(chunk_pos); + self.chunks[index] = chunk; - // First, remove particles from source positions (only for non-preserve moves). - for movement in &moves { - if !movement.1.preserve_source { - self.set_particle_at(movement.1.source_pos, None); + if outcome.changed { + self.wake_border_neighbors(chunk_pos, outcome.border); } + deferred.extend(outcome.deferred); } - // Then, try to place particles at target positions if they're still empty. - for movement in moves { - if self.get_particle_at(movement.0).is_none() { - self.set_particle_at(movement.0, Some(movement.1.particle)); - } else if !movement.1.preserve_source { - // Target is occupied; restore the particle to its source position. - // Only needed for non-preserve moves since preserve sources were never removed. - self.set_particle_at(movement.1.source_pos, Some(movement.1.particle)); + self.apply_deferred_steps(deferred); + } + + /// Applies deferred steps (cross-chunk moves and interactions) serially. + /// + /// Steps are sorted into a deterministic order, then re-validated against + /// the current map state: a step only happens if its source particle is + /// still in place and its target still looks like what the particle saw. + /// Losing steps simply don't happen — the source particle stays put and + /// may retry next tick. Particles are never duplicated or destroyed by + /// conflicting steps. + fn apply_deferred_steps(&mut self, mut steps: Vec) { + // Process bottom-to-top; source position breaks ties for a total order. + steps.sort_by_key(|step| { + ( + step.action.target.y, + step.action.target.x, + step.source_pos.y, + step.source_pos.x, + ) + }); + + for step in steps { + // The acting particle must still be where it was when it decided this step. + if self.get_particle_at(step.source_pos) != Some(step.source_particle) { + continue; + } + + let target = step.action.target; + match step.action.kind { + ActionKind::MoveTo(particle) => { + if self.within_bounds(target) && self.get_particle_at(target).is_none() { + self.set_particle_at(step.source_pos, None); + self.set_particle_at(target, Some(particle)); + } + } + ActionKind::ReplaceTarget { expected, result } => { + if self.get_particle_at(target) == Some(expected) { + self.set_particle_at(step.source_pos, None); + self.set_particle_at(target, Some(result)); + } + } + ActionKind::ConvertTarget { expected, result } => { + if self.get_particle_at(target) == Some(expected) { + self.set_particle_at(target, Some(result)); + } + } } } } - // Get a chunk at a specific position in local map coordinates. + // Get a chunk at a specific position in chunk coordinates. pub fn get_chunk_at(&self, position: &UVec2) -> &Chunk { - &self.chunks[position.x as usize][position.y as usize] - } - - pub fn set_chunk_at(&mut self, position: UVec2, chunk: Chunk) { - self.chunks[position.x as usize][position.y as usize] = chunk; + &self.chunks[chunk_index(*position, self.chunks_wide())] } /// Check if a possible position is within the map bounds. @@ -331,8 +364,8 @@ impl Map { fn copy_simulatable_chunks(&self) -> Vec { self.active_chunks .iter() - .map(|pos| &self.chunks[pos.x as usize][pos.y as usize]) - .filter(|chunk| chunk.should_simulate) + .map(|pos| self.get_chunk_at(pos)) + .filter(|chunk| chunk.should_simulate()) .cloned() .collect() } @@ -368,12 +401,6 @@ pub fn update_active_chunks(mut map: ResMut, player_query: Query<&Transform let min_y = center_chunk.y.saturating_sub(UPDATE_RANGE); let max_y = (center_chunk.y + UPDATE_RANGE).min(max_chunk_y); - // Debug information - debug!( - "Player at world coords: ({}, {}), updating rectangular chunk region: x={}..{}, y={}..{}", - player_pos.x, player_pos.y, min_x, max_x, min_y, max_y - ); - // Clear the current active chunks map.active_chunks.clear(); @@ -383,9 +410,6 @@ pub fn update_active_chunks(mut map: ResMut, player_query: Query<&Transform map.active_chunks.insert(UVec2::new(x, y)); } } - - // Update any dirty chunks in the active area - map.update_dirty_chunks(); } /// System that simulates active particles in chunks diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..1fcdc38 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,117 @@ +//! Shared helpers for integration tests. +#![allow(dead_code)] // Not every test binary uses every helper. + +use bevy::math::UVec2; +use cavernborn::particle::{Common, Direction, Liquid, Particle, Solid, Special}; +use cavernborn::world::chunk::CHUNK_SIZE; +use cavernborn::world::Map; + +/// Build an empty map of `w_chunks` x `h_chunks` chunks with every chunk +/// active, as the player-driven activation system would do in game. +pub fn empty_map(w_chunks: u32, h_chunks: u32) -> Map { + let mut map = Map::empty(w_chunks * CHUNK_SIZE, h_chunks * CHUNK_SIZE); + activate_all_chunks(&mut map); + map +} + +/// Mark every chunk as active so `simulate_active_chunks` considers the whole map. +pub fn activate_all_chunks(map: &mut Map) { + for x in 0..map.width / CHUNK_SIZE { + for y in 0..map.height / CHUNK_SIZE { + map.active_chunks.insert(UVec2::new(x, y)); + } + } +} + +/// Run `times` simulation ticks. +pub fn tick(map: &mut Map, times: u32) { + for _ in 0..times { + map.simulate_active_chunks(); + } +} + +pub fn water() -> Particle { + Particle::Liquid(Liquid::Water(Direction::default())) +} + +pub fn lava() -> Particle { + Particle::Liquid(Liquid::Lava(Direction::default())) +} + +pub fn acid() -> Particle { + Particle::Liquid(Liquid::Acid(Direction::default())) +} + +pub fn stone() -> Particle { + Particle::Common(Common::Stone) +} + +pub fn obsidian() -> Particle { + Particle::Solid(Solid::Obsidian) +} + +/// Fill the rectangle `x0..x1` x `y0..y1` (half-open) with a particle. +pub fn fill_rect(map: &mut Map, x0: u32, x1: u32, y0: u32, y1: u32, particle: Particle) { + for x in x0..x1 { + for y in y0..y1 { + map.set_particle_at(UVec2::new(x, y), Some(particle)); + } + } +} + +/// Count particles matching a predicate across the whole map. +pub fn count_particles(map: &Map, predicate: impl Fn(Particle) -> bool) -> u32 { + let mut count = 0; + for x in 0..map.width { + for y in 0..map.height { + if let Some(particle) = map.get_particle_at(UVec2::new(x, y)) { + if predicate(particle) { + count += 1; + } + } + } + } + count +} + +pub fn total_particles(map: &Map) -> u32 { + count_particles(map, |_| true) +} + +pub fn is_water(particle: Particle) -> bool { + matches!(particle, Particle::Liquid(Liquid::Water(_))) +} + +pub fn is_lava(particle: Particle) -> bool { + matches!(particle, Particle::Liquid(Liquid::Lava(_))) +} + +pub fn is_acid(particle: Particle) -> bool { + matches!(particle, Particle::Liquid(Liquid::Acid(_))) +} + +/// Render the map as ASCII art, top row first (y is up in world coordinates). +pub fn ascii_map(map: &Map) -> String { + let mut out = String::with_capacity(((map.width + 1) * map.height) as usize); + for y in (0..map.height).rev() { + for x in 0..map.width { + out.push(particle_char(map.get_particle_at(UVec2::new(x, y)))); + } + out.push('\n'); + } + out +} + +fn particle_char(particle: Option) -> char { + match particle { + None => '.', + Some(Particle::Common(Common::Dirt)) => 'D', + Some(Particle::Common(Common::Stone)) => 'S', + Some(Particle::Special(Special::Ore(_))) => 'G', + Some(Particle::Special(Special::Gem(_))) => 'R', + Some(Particle::Liquid(Liquid::Water(_))) => 'w', + Some(Particle::Liquid(Liquid::Lava(_))) => 'L', + Some(Particle::Liquid(Liquid::Acid(_))) => 'a', + Some(Particle::Solid(Solid::Obsidian)) => 'O', + } +} diff --git a/tests/generator_tests.rs b/tests/generator_tests.rs new file mode 100644 index 0000000..63cf1f4 --- /dev/null +++ b/tests/generator_tests.rs @@ -0,0 +1,51 @@ +mod common; + +use bevy::math::UVec2; +use cavernborn::world::Map; + +#[test] +fn generation_is_deterministic_for_a_seed() { + let a = Map::generate_with_seed(2, 2, 42); + let b = Map::generate_with_seed(2, 2, 42); + + assert_eq!( + common::ascii_map(&a), + common::ascii_map(&b), + "two worlds generated from the same seed should be identical" + ); +} + +#[test] +fn different_seeds_generate_different_worlds() { + let a = Map::generate_with_seed(2, 2, 42); + let b = Map::generate_with_seed(2, 2, 43); + + assert_ne!(common::ascii_map(&a), common::ascii_map(&b)); +} + +#[test] +fn generated_terrain_has_no_floating_gaps() { + let map = Map::generate_with_seed(4, 2, 7); + + // Terrain is generated as solid columns up to the surface: scanning any + // column bottom-up, there must be no air below the topmost particle. + for x in 0..map.width { + let top_filled = (0..map.height) + .rev() + .find(|&y| map.get_particle_at(UVec2::new(x, y)).is_some()); + if let Some(top) = top_filled { + for y in 0..top { + assert!( + map.get_particle_at(UVec2::new(x, y)).is_some(), + "air gap at ({x}, {y}) below surface at y={top}" + ); + } + } + } +} + +#[test] +fn generated_terrain_snapshot() { + let map = Map::generate_with_seed(2, 2, 42); + insta::assert_snapshot!(common::ascii_map(&map)); +} diff --git a/tests/simulation_tests.rs b/tests/simulation_tests.rs new file mode 100644 index 0000000..cafcb9d --- /dev/null +++ b/tests/simulation_tests.rs @@ -0,0 +1,232 @@ +mod common; + +use bevy::math::UVec2; +use common::*; + +/// Regression test for inter-chunk move conflicts destroying particles: +/// the particle count must stay constant on every single tick while a blob +/// of water falls and spreads across chunk boundaries. +#[test] +fn water_count_is_conserved_while_settling() { + let mut map = empty_map(2, 2); + + // A 12x8 blob of water dropped in mid-air, straddling the vertical + // chunk boundary at x=32 so plenty of cross-chunk moves happen. + fill_rect(&mut map, 26, 38, 40, 48, water()); + let initial = total_particles(&map); + assert_eq!(initial, 12 * 8); + + for tick_no in 0..300 { + map.simulate_active_chunks(); + assert_eq!( + total_particles(&map), + initial, + "particle count changed at tick {tick_no}" + ); + } +} + +#[test] +fn water_falls_to_the_floor() { + let mut map = empty_map(1, 1); + map.set_particle_at(UVec2::new(16, 20), Some(water())); + + tick(&mut map, 60); + + assert_eq!(count_particles(&map, is_water), 1); + let floor_water = (0..map.width) + .filter(|&x| map.get_particle_at(UVec2::new(x, 0)).is_some_and(is_water)) + .count(); + assert_eq!( + floor_water, 1, + "the single water particle should end up on the floor row" + ); +} + +#[test] +fn water_falls_across_chunk_boundaries() { + // One chunk wide, two chunks tall: water starts in the top chunk and + // must cross the boundary at y=32 to reach the floor. + let mut map = empty_map(1, 2); + map.set_particle_at(UVec2::new(16, 40), Some(water())); + + tick(&mut map, 80); + + assert_eq!(count_particles(&map, is_water), 1); + let floor_water = (0..map.width) + .filter(|&x| { + map.get_particle_at(UVec2::new(x, 0)) + .is_some_and(is_water) + }) + .count(); + assert_eq!(floor_water, 1, "water should have fallen into the bottom chunk"); +} + +/// A settled pool is a deterministic fixed point: 64 particles on a +/// 32-wide floor settle into exactly two full rows. +#[test] +fn settled_water_forms_a_flat_pool() { + let mut map = empty_map(1, 1); + fill_rect(&mut map, 12, 20, 10, 18, water()); + + tick(&mut map, 400); + + assert_eq!(count_particles(&map, is_water), 64); + insta::assert_snapshot!(ascii_map(&map)); +} + +/// Replace interaction within a single chunk: water flowing onto lava +/// consumes the water and turns the lava into obsidian. +#[test] +fn water_meeting_lava_forms_obsidian_within_a_chunk() { + let mut map = empty_map(1, 1); + // Stone floor pocket wide enough that the lava can't flow out + // (liquids can reach `viscosity - 1` cells diagonally along the floor). + fill_rect(&mut map, 13, 20, 0, 1, stone()); + map.set_particle_at(UVec2::new(16, 0), Some(lava())); + map.set_particle_at(UVec2::new(16, 1), Some(water())); + + tick(&mut map, 3); + + assert_eq!(map.get_particle_at(UVec2::new(16, 0)), Some(obsidian())); + assert_eq!(count_particles(&map, is_water), 0, "water is consumed"); + assert_eq!(count_particles(&map, is_lava), 0, "lava became obsidian"); +} + +/// Regression test for cross-chunk interactions: previously a Replace whose +/// target was in another chunk turned the *source* cell into the result and +/// left the target untouched. The obsidian must form at the lava's position. +#[test] +fn water_meeting_lava_forms_obsidian_across_chunks() { + // One chunk wide, two chunks tall; the boundary is at y=32. + let mut map = empty_map(1, 2); + + // A stone block with a one-cell pocket at (16, 31) — the top row of the + // bottom chunk — holding the lava in place. + fill_rect(&mut map, 12, 21, 28, 32, stone()); + map.set_particle_at(UVec2::new(16, 31), Some(lava())); + // Water in the top chunk, two cells above the lava. + map.set_particle_at(UVec2::new(16, 33), Some(water())); + + tick(&mut map, 3); + + assert_eq!( + map.get_particle_at(UVec2::new(16, 31)), + Some(obsidian()), + "obsidian must form at the lava's position, not the water's" + ); + assert_eq!(count_particles(&map, is_water), 0); + assert_eq!(count_particles(&map, is_lava), 0); +} + +/// Preserve interaction within a single chunk: water flowing onto acid +/// keeps the water and converts the acid into water. +#[test] +fn water_converts_acid_within_a_chunk() { + let mut map = empty_map(1, 1); + // Acid has viscosity 4, so the pocket walls extend 4 cells each way. + fill_rect(&mut map, 12, 21, 0, 1, stone()); + map.set_particle_at(UVec2::new(16, 0), Some(acid())); + map.set_particle_at(UVec2::new(16, 1), Some(water())); + + tick(&mut map, 3); + + assert_eq!(count_particles(&map, is_acid), 0, "acid is neutralized"); + assert_eq!( + count_particles(&map, is_water), + 2, + "the original water survives and the acid becomes water" + ); +} + +/// Regression test for cross-chunk Preserve interactions, which previously +/// silently did nothing. +#[test] +fn water_converts_acid_across_chunks() { + let mut map = empty_map(1, 2); + + fill_rect(&mut map, 12, 21, 28, 32, stone()); + map.set_particle_at(UVec2::new(16, 31), Some(acid())); + map.set_particle_at(UVec2::new(16, 33), Some(water())); + + tick(&mut map, 3); + + assert_eq!(count_particles(&map, is_acid), 0); + assert_eq!(count_particles(&map, is_water), 2); +} + +/// Regression test for settled chunks never sleeping: once a pool has +/// settled, its chunk must stop simulating and stop bumping its version +/// (which would re-upload its material to the GPU every tick). +#[test] +fn settled_chunks_sleep_and_versions_freeze() { + let mut map = empty_map(1, 1); + fill_rect(&mut map, 12, 20, 10, 18, water()); + + tick(&mut map, 400); + + let chunk_pos = UVec2::new(0, 0); + assert!( + !map.get_chunk_at(&chunk_pos).should_simulate(), + "a fully settled chunk should be asleep" + ); + + let version_before = map.get_chunk_at(&chunk_pos).version; + tick(&mut map, 20); + assert_eq!( + map.get_chunk_at(&chunk_pos).version, + version_before, + "a sleeping chunk's version must not change" + ); +} + +/// Mutating a sleeping pool (e.g. the player erasing a particle) must wake +/// it and let the water flow again. +#[test] +fn mutating_a_sleeping_pool_wakes_it() { + let mut map = empty_map(1, 1); + fill_rect(&mut map, 12, 20, 10, 18, water()); + tick(&mut map, 400); + + let chunk_pos = UVec2::new(0, 0); + assert!(!map.get_chunk_at(&chunk_pos).should_simulate()); + + // Erase one water particle from the floor row: the particle above it + // should fall into the hole. + map.set_particle_at(UVec2::new(16, 0), None); + assert!( + map.get_chunk_at(&chunk_pos).should_simulate(), + "mutation must wake the chunk" + ); + + let version_before = map.get_chunk_at(&chunk_pos).version; + tick(&mut map, 10); + + assert!( + map.get_chunk_at(&chunk_pos).version > version_before, + "the woken chunk should simulate and change" + ); + assert_eq!(count_particles(&map, is_water), 63); + assert!( + map.get_particle_at(UVec2::new(16, 0)).is_some_and(is_water), + "water above should have fallen into the hole" + ); +} + +/// The simulation is fully deterministic: per-chunk seeded RNG and an +/// ordered apply phase make thread scheduling irrelevant. +#[test] +fn simulation_is_deterministic() { + let build = || { + let mut map = empty_map(2, 1); + fill_rect(&mut map, 20, 44, 20, 28, water()); + map + }; + + let mut a = build(); + let mut b = build(); + tick(&mut a, 150); + tick(&mut b, 150); + + assert_eq!(ascii_map(&a), ascii_map(&b)); +} diff --git a/tests/snapshots/generator_tests__generated_terrain_snapshot.snap b/tests/snapshots/generator_tests__generated_terrain_snapshot.snap new file mode 100644 index 0000000..2164a0d --- /dev/null +++ b/tests/snapshots/generator_tests__generated_terrain_snapshot.snap @@ -0,0 +1,68 @@ +--- +source: tests/generator_tests.rs +expression: "common::ascii_map(&map)" +--- +.......DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD....... +.....DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD..... +...DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD... +DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD +DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD +DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD +DDDDDDDDDDDDDDDDDDDDDDDSSSSSSSSSSSSSSSSSSDDDDDDDDDDDDDDDDDDDDDDD +DDDDDDDDDDDDDDDDDDDSSSSSSSSSSSSSSSSSSSSSSSSSSDDDDDDDDDDDDDDDDDDD +DDDDDDDDDDDDDDDDSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSDDDDDDDDDDDDDDDD +DDDDDDDDDDDDDSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSDDDDDDDDDDDDDD +DDDDDDDDDDDSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSDDDDDDDDDDD +DDDDDDDDDSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSDDDDDDDDD +DDDDDDDSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSDDDDDDD +DDDDDSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSDDDDD +DDDSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSDDD +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSGGSSGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSGGGSGGSSSSSSSGGSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSGSSSSSSSSGGSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSGSGGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSGGSSSSSGGSSSSSSSGSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSSSSGGSSSSSSGGSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSSSSSGSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSGSSSSSSSSSSSSSSSSGSSSSSSSSSGGGSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSGSSSSSSSSSSSSSSSSSSGSSSSSSSSGGGSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSGGSSSSSSSSSSSSSSSSSSSGGSSSGSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSGGSSSGSSSSSSSSSSSSSSSSSSSSGSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSGSSSGSSSSSSSGSSSSSSSSSSSSSSSSSSSSSSGSSSSSSSSSSGGSSSS +SSSSSSSSSGSSSSSSSSSSSGGSGGSSSSSSSSSSSSSSSSSSSSSGSSSSSSSSSSGGSSSS +SSSSSSSSGSSSSSSSSSSSSSSGSSGSSSSSSSSSGSSSSSSSSSGSSSSSSSSSSSGSSSSS +SSSSSSSGSGSSSSSSSSSSSSSSSSGGGSSSSSSSSGSSSSSSSGGSSSSSSSSSSSSSSSSG +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSSSSSGSSSSSSSSSSSSSSSGG +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSS +SSSSSSSSSSSSSSSGGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSS +SSGGSSSSSSSSSGGGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSGSSGSSSSGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSGGSSSSSSSSGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSGGSSSSSSGGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS +SSGSSSSGSSGSSSSSSSSGSSSSSSSSSSSSGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGS +SGGGSSGGSSSSSSSSSSSGSSSSSSSSSSSSGGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSG +SGSSSSSSSSSSSSSSSSSSSSSSSSSSSGSGSGSSSSSSSSSSSSSSSSSSSSSSSSSSSSGG +SSSSSSSSSSSSSSSSSSSSSSSSSSSSGGGSSSSSSSSSSSGGSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSSGGSSSSSGGSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSSSSSSSSSSSSSSSSSSSSSGSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSSSGSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSSSSSSSSSSSSGSSSSGSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSSSSSSSSSSSGGSSSGGSSSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSGGSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGGGSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSGSSSGSGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSG +SGGSSSSSSSSSSSSSSSSSSSSSSSGSSSSGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSG +SSGGSGSSSSSSSSGGSSSSSSSSSSGSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGGG +SSSSGGSSSSSSSSGGSSSSSSSSSSSSGSSSSSSSSSSGGGSSSSSSSSSSSSSSSSSGSSGS +SSSSSSGSSSSSSGGGSSSSSSSSSSSSSGSSSSSSSSSGGGSSSSSSSSSSSSSSSSGSSSGG +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGSSSSSSSSSGGGSSSSSSGSSSSSSSGSSSSSS +SSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSGGSSSSSSSSSSSSSSSGSSSSSSSSSSSSSS diff --git a/tests/snapshots/simulation_tests__settled_water_forms_a_flat_pool.snap b/tests/snapshots/simulation_tests__settled_water_forms_a_flat_pool.snap new file mode 100644 index 0000000..7471f25 --- /dev/null +++ b/tests/snapshots/simulation_tests__settled_water_forms_a_flat_pool.snap @@ -0,0 +1,36 @@ +--- +source: tests/simulation_tests.rs +expression: ascii_map(&map) +--- +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +................................ +wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww +wwwwwwwwwwwwwwwwwwwwwwwwwwwwwwww diff --git a/tests/world_tests.rs b/tests/world_tests.rs index e56428e..64fc8d4 100644 --- a/tests/world_tests.rs +++ b/tests/world_tests.rs @@ -1,82 +1,73 @@ -// Include the crate's source code -#[path = "../src/particle/mod.rs"] -mod particle; - +use cavernborn::particle::Common; use strum::IntoEnumIterator; -#[cfg(test)] -mod tests { - use super::particle::Common; - use super::*; - - /// Test to ensure all Common particle variants have exclusive depth ranges - #[test] - fn test_common_variants_have_exclusive_ranges() { - // Get all pairs of Common variants - let variants: Vec = Common::iter().collect(); +/// Test to ensure all Common particle variants have exclusive depth ranges +#[test] +fn test_common_variants_have_exclusive_ranges() { + // Get all pairs of Common variants + let variants: Vec = Common::iter().collect(); - for (i, variant1) in variants.iter().enumerate() { - for variant2 in variants.iter().skip(i + 1) { - // Get the depth ranges for both variants - let min1 = variant1.min_depth(); - let max1 = variant1.max_depth(); - let min2 = variant2.min_depth(); - let max2 = variant2.max_depth(); + for (i, variant1) in variants.iter().enumerate() { + for variant2 in variants.iter().skip(i + 1) { + // Get the depth ranges for both variants + let min1 = variant1.min_depth(); + let max1 = variant1.max_depth(); + let min2 = variant2.min_depth(); + let max2 = variant2.max_depth(); - // Check if the ranges overlap using half-open intervals [min, max) - // Range 1: [min1, max1) and Range 2: [min2, max2) - // They overlap if: min1 < max2 && min2 < max1 - let overlap = min1 < max2 && min2 < max1; + // Check if the ranges overlap using half-open intervals [min, max) + // Range 1: [min1, max1) and Range 2: [min2, max2) + // They overlap if: min1 < max2 && min2 < max1 + let overlap = min1 < max2 && min2 < max1; - // Assert that there is no overlap - assert!( - !overlap, - "Common variants {:?} and {:?} have overlapping depth ranges: [{}-{}) and [{}-{})", - variant1, variant2, min1, max1, min2, max2 - ); - } + // Assert that there is no overlap + assert!( + !overlap, + "Common variants {:?} and {:?} have overlapping depth ranges: [{}-{}) and [{}-{})", + variant1, variant2, min1, max1, min2, max2 + ); } } +} + +/// Test to ensure get_exclusive_at_depth returns the correct variant for each depth +#[test] +fn test_get_exclusive_at_depth() { + // Test each Common variant's range + for variant in Common::iter() { + let min_depth = variant.min_depth(); + let max_depth = variant.max_depth(); - /// Test to ensure get_exclusive_at_depth returns the correct variant for each depth - #[test] - fn test_get_exclusive_at_depth() { - // Test each Common variant's range - for variant in Common::iter() { - let min_depth = variant.min_depth(); - let max_depth = variant.max_depth(); + // Test at the minimum depth (inclusive) + assert_eq!( + Common::get_exclusive_at_depth(min_depth), + variant, + "get_exclusive_at_depth({}) should return {:?}", + min_depth, + variant + ); - // Test at the minimum depth (inclusive) + // Test at the maximum depth minus 1 (since max is exclusive) + if max_depth > min_depth + 1 { assert_eq!( - Common::get_exclusive_at_depth(min_depth), + Common::get_exclusive_at_depth(max_depth - 1), variant, "get_exclusive_at_depth({}) should return {:?}", - min_depth, + max_depth - 1, variant ); + } - // Test at the maximum depth minus 1 (since max is exclusive) - if max_depth > min_depth + 1 { - assert_eq!( - Common::get_exclusive_at_depth(max_depth - 1), - variant, - "get_exclusive_at_depth({}) should return {:?}", - max_depth - 1, - variant - ); - } - - // Test at the middle of the range - if max_depth > min_depth + 2 { - let mid_depth = min_depth + (max_depth - min_depth) / 2; - assert_eq!( - Common::get_exclusive_at_depth(mid_depth), - variant, - "get_exclusive_at_depth({}) should return {:?}", - mid_depth, - variant - ); - } + // Test at the middle of the range + if max_depth > min_depth + 2 { + let mid_depth = min_depth + (max_depth - min_depth) / 2; + assert_eq!( + Common::get_exclusive_at_depth(mid_depth), + variant, + "get_exclusive_at_depth({}) should return {:?}", + mid_depth, + variant + ); } } }