diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4cb23bb9c..b329343e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,10 @@ jobs: target: x86_64-unknown-linux-musl - os: ubuntu-24.04-arm target: aarch64-unknown-linux-musl + - os: ubuntu-22.04 # cuz glibc forward incompatible + target: x86_64-unknown-linux-gnu + - os: ubuntu-22.04-arm + target: aarch64-unknown-linux-gnu - os: windows-latest target: x86_64-pc-windows-msvc - os: macos-14 @@ -60,13 +64,15 @@ jobs: with: shared-key: "ferrumc" - name: Install musl-tools - if: runner.os == 'Linux' + if: contains(matrix.target, 'musl') run: sudo apt-get update && sudo apt-get install -y musl-tools + - name: Build run: cargo build --profile production --target ${{ matrix.target }} -p ferrumc --features release env: - CC: ${{ runner.os == 'Linux' && 'musl-gcc' || '' }} - CXX: ${{ runner.os == 'Linux' && 'g++' || '' }} + CC: ${{ contains(matrix.target, 'musl') && 'musl-gcc' || '' }} + CXX: ${{ contains(matrix.target, 'musl') && 'g++' || '' }} + - name: Package (Linux/macOS) if: runner.os != 'Windows' run: tar -czf ferrumc-${{ github.ref_name }}-${{ matrix.target }}.tar.gz -C target/${{ matrix.target }}/production ferrumc diff --git a/assets/data/configs/main-config.toml b/assets/data/configs/main-config.toml index f122903b4..c443fc59d 100644 --- a/assets/data/configs/main-config.toml +++ b/assets/data/configs/main-config.toml @@ -60,6 +60,13 @@ chunks_per_tick = 0 # set to a static number or unlimited. Setting this to 0 disables the minimum. chunks_per_tick_min = 16 +[fluids] +# Which fluid-spreading algorithm to use. +# "vanilla" - Minecraft-faithful: fluid steers toward the nearest hole (bounded slope search). +# Matches vanilla behaviour at a higher CPU cost. +# "simplified" - Cheaper approximation: uniform spread with no hole steering. +algorithm = "vanilla" + [dashboard] # The port the dashboard will run on. port = 9000 diff --git a/src/bin/Cargo.toml b/src/bin/Cargo.toml index 763b85bf9..788909117 100644 --- a/src/bin/Cargo.toml +++ b/src/bin/Cargo.toml @@ -55,6 +55,7 @@ crossbeam-channel = { workspace = true } uuid = { workspace = true } dhat = { workspace = true, optional = true } bitcode = { workspace = true } +ctor = { workspace = true } [features] dhat = ["dep:dhat"] diff --git a/src/bin/src/commands/mod.rs b/src/bin/src/commands/mod.rs new file mode 100644 index 000000000..63e9ed486 --- /dev/null +++ b/src/bin/src/commands/mod.rs @@ -0,0 +1,10 @@ +//! Binary-local commands (those that need ECS resources defined in the binary crate, such as the +//! fluid scheduler/control, which the shared `ferrumc-default-commands` crate cannot name). +//! +//! Commands register themselves at startup via the `#[command]` macro's `#[ctor]`. [`init`] only +//! exists to guarantee this module is linked into the final binary so those constructors run. + +pub mod tick; + +/// Ensures the binary-local command modules are linked so their `#[ctor]` registrations fire. +pub fn init() {} diff --git a/src/bin/src/commands/tick.rs b/src/bin/src/commands/tick.rs new file mode 100644 index 000000000..f59b053e0 --- /dev/null +++ b/src/bin/src/commands/tick.rs @@ -0,0 +1,66 @@ +//! `/tick` debug commands for the fluid simulation clock. +//! +//! These let a developer pause and single-step fluid spreading so the large cascade produced by a +//! single bucket placement can be inspected one game tick at a time, instead of being buried in +//! logs. Only the fluid simulation is affected; the rest of the server keeps ticking, so you can +//! still move around and place/break blocks while fluids are frozen. +//! +//! * `/tick freeze` — stop advancing fluid ticks. +//! * `/tick run` — resume normal fluid ticking. +//! * `/tick step [n]` — while frozen, advance fluid simulation by `n` ticks (default 1). + +use crate::systems::fluids::FluidTickControl; +use bevy_ecs::prelude::ResMut; +use ferrumc_commands::arg::primitive::int::Integer; +use ferrumc_commands::Sender; +use ferrumc_macros::command; +use ferrumc_text::{NamedColor, TextComponentBuilder}; + +#[command("tick freeze")] +fn tick_freeze(#[sender] sender: Sender, mut control: ResMut) { + control.frozen = true; + control.steps = 0; + sender.send_message( + TextComponentBuilder::new("Fluid simulation frozen. Use /tick step [n] or /tick run.") + .color(NamedColor::Yellow) + .build(), + false, + ); +} + +#[command("tick run")] +fn tick_run(#[sender] sender: Sender, mut control: ResMut) { + control.frozen = false; + control.steps = 0; + sender.send_message( + TextComponentBuilder::new("Fluid simulation resumed.") + .color(NamedColor::Green) + .build(), + false, + ); +} + +/// Step count is bounded so a typo cannot queue a runaway number of steps. +type StepCount = Integer<1, 10_000>; + +#[command("tick step")] +fn tick_step( + #[sender] sender: Sender, + #[arg] count: StepCount, + mut control: ResMut, +) { + // Stepping implies frozen: if the user steps without freezing first, freeze now so the steps + // are meaningful rather than racing the normal clock. + control.frozen = true; + let n = (*count).max(0) as u32; + control.steps = control.steps.saturating_add(n); + sender.send_message( + TextComponentBuilder::new(format!( + "Queued {n} fluid step(s); {} pending.", + control.steps + )) + .color(NamedColor::Aqua) + .build(), + false, + ); +} diff --git a/src/bin/src/game_loop.rs b/src/bin/src/game_loop.rs index 1a7ce5f30..202c1ff19 100644 --- a/src/bin/src/game_loop.rs +++ b/src/bin/src/game_loop.rs @@ -78,6 +78,8 @@ pub fn start_game_loop(global_state: GlobalState) -> Result<(), BinaryError> { // Initialize default server commands (e.g., /stop, /help, etc.) ferrumc_default_commands::init(); + // Initialize binary-local commands (e.g., /tick freeze|step|run for fluid debugging). + crate::commands::init(); // Wrap global state for ECS resource access let global_state_res = GlobalStateResource(global_state.clone()); diff --git a/src/bin/src/main.rs b/src/bin/src/main.rs index 3520dc224..ba9b42b06 100644 --- a/src/bin/src/main.rs +++ b/src/bin/src/main.rs @@ -10,6 +10,7 @@ use std::time::Instant; use tracing::{error, info}; mod cli; +pub(crate) mod commands; pub(crate) mod errors; mod game_loop; mod launch; diff --git a/src/bin/src/register_resources.rs b/src/bin/src/register_resources.rs index 874ae0a89..5e2f52053 100644 --- a/src/bin/src/register_resources.rs +++ b/src/bin/src/register_resources.rs @@ -1,4 +1,4 @@ -use crate::systems::fluids::{ActiveDimension, FluidScheduler}; +use crate::systems::fluids::{ActiveDimension, FluidScheduler, FluidTickControl}; use crate::systems::new_connections::NewConnectionRecv; use bevy_ecs::prelude::World; use crossbeam_channel::Receiver; @@ -25,6 +25,7 @@ pub fn register_resources( world.insert_resource(TickCounter::new()); world.insert_resource(FluidScheduler::default()); world.insert_resource(ActiveDimension::default()); + world.insert_resource(FluidTickControl::default()); world.insert_resource(ServerPerformance::new(get_global_config().tps)); world.insert_resource(PhysicalRegistry::new()); } diff --git a/src/bin/src/systems/fluids/mod.rs b/src/bin/src/systems/fluids/mod.rs index ec73a73f1..6b0ec144c 100644 --- a/src/bin/src/systems/fluids/mod.rs +++ b/src/bin/src/systems/fluids/mod.rs @@ -20,19 +20,26 @@ use ferrumc_core::transform::position::Position; use ferrumc_messages::BlockBrokenEvent; use ferrumc_net::connection::StreamWriter; use ferrumc_net::packets::outgoing::block_update::BlockUpdate; +use ferrumc_net::packets::outgoing::level_event::LevelEventPacket; use ferrumc_net_codec::net_types::network_position::NetworkPosition; use ferrumc_net_codec::net_types::var_int::VarInt; use ferrumc_state::{GlobalState, GlobalStateResource}; use ferrumc_world::block_state_id::BlockStateId; use ferrumc_world::dimension::Dimension; use ferrumc_world::fluid::is_fluid; -use ferrumc_world::fluid::spread::{compute_fluid_tick, BlockView, FluidChange}; +use ferrumc_world::fluid::spread::{fluid_neighbours, would_react, BlockView, FluidChange}; use ferrumc_world::fluid::{fluid_state, FluidKind, FluidRules}; use ferrumc_world::pos::BlockPos; use ferrumc_world::scheduler::{BlockTickScheduler, ScheduledTick, TickKind}; use std::collections::HashMap; use tracing::{error, trace}; +/// Delay (in ticks) before a lava block that has just touched water resolves its solidification. +/// +/// Vanilla reacts essentially on the next block update; a 1-tick delay keeps that "instant on +/// contact" feel rather than making lava wait out its slow 30-tick spread cadence. +const REACTION_DELAY: u64 = 1; + /// Minimum number of due ticks in a batch before the parallel evaluation path is used. /// /// Below this threshold the overhead of dispatching work to the thread pool outweighs the benefit, @@ -75,6 +82,36 @@ fn rules_for_block(block: BlockStateId, dim: Dimension) -> Option { #[derive(Resource, Default)] pub struct FluidScheduler(pub BlockTickScheduler); +/// Debug control for the fluid simulation clock. +/// +/// Lets `/tick freeze` / `/tick step` / `/tick run` pause and single-step fluid spreading so the +/// (potentially huge) cascade of a single placement can be inspected one game tick at a time +/// without drowning in logs. Only the fluid system consults this; the rest of the server keeps +/// running normally, so players can still move and place blocks while fluids are frozen. +#[derive(Default, Resource)] +pub struct FluidTickControl { + /// When true, fluid ticks do not advance unless `steps` is positive. + pub frozen: bool, + /// Number of single steps queued up while frozen. Each processed tick decrements this. + pub steps: u32, +} + +impl FluidTickControl { + /// Returns true if a fluid tick is allowed to run this game tick, consuming one queued step if + /// the simulation is frozen. + fn allow_tick(&mut self) -> bool { + if !self.frozen { + return true; + } + if self.steps > 0 { + self.steps -= 1; + true + } else { + false + } + } +} + /// A [`BlockView`] backed by the live world. /// /// Holds an owned [`GlobalState`] handle (an `Arc` clone) so it can be moved into worker threads @@ -108,8 +145,16 @@ impl BlockView for WorldBlockView { /// The delay comes from [`FluidRules::for_kind`] for whatever fluid is at `pos`, so water and /// lava (and Nether vs overworld lava) tick at their own cadences. /// -/// Intended to be called from block placement/break handlers (and from neighbour updates) to kick -/// off or continue spreading. No-op for non-fluid blocks. +/// Schedules a fluid tick for `pos` if it currently contains fluid, picking the right delay. +/// +/// The delay is normally [`FluidRules::for_kind`] for whatever fluid is at `pos` (so water and +/// lava, overworld vs Nether, tick at their own cadences). But if the block is lava that is already +/// in contact with water — i.e. it would solidify this tick — it is scheduled at the fast +/// [`REACTION_DELAY`] instead, so the reaction happens essentially on contact rather than after +/// lava's slow spread cadence. +/// +/// Intended to be called from block placement/break handlers and from neighbour propagation. No-op +/// for non-fluid blocks. pub fn seed_fluid_tick( scheduler: &mut BlockTickScheduler, state: &GlobalState, @@ -117,16 +162,23 @@ pub fn seed_fluid_tick( current_tick: u64, pos: BlockPos, ) { - let block = match ferrumc_utils::world::load_or_generate_chunk(state, pos.chunk(), dim.name()) { - Ok(chunk) => chunk.get_block(pos.chunk_block_pos()), - Err(_) => return, + let view = WorldBlockView { + state: state.clone(), + dim, }; - if let Some(rules) = rules_for_block(block, dim.0) { - scheduler.schedule(pos, TickKind::FluidSpread, current_tick, rules.tick_delay); - } else { - // Keep the early exit on non-fluid blocks for callers that pass arbitrary positions. + let block = view.block_at(pos); + let Some(rules) = rules_for_block(block, dim.0) else { debug_assert!(!is_fluid(block), "rules_for_block disagreed with is_fluid"); - } + return; + }; + // A lava block already touching water solidifies almost immediately rather than waiting out its + // spread cadence. + let delay = if would_react(pos, &view) { + REACTION_DELAY + } else { + rules.tick_delay + }; + scheduler.schedule(pos, TickKind::FluidSpread, current_tick, delay); } /// The six axis-aligned neighbours of a block position. @@ -165,11 +217,41 @@ pub fn seed_on_block_break( } } -/// Writes a single block change back to the world. Returns true on success. +/// Writes a single block change back to the world. Returns true if the world was actually +/// modified (so the caller knows to broadcast and wake neighbours), false otherwise. +/// +/// Guards against two classes of bad writes: +/// * **No-op writes** — if the cell already holds `new_block` there is nothing to do. Skipping +/// these is what stops a settled fluid body from re-broadcasting and re-waking neighbours every +/// tick (the visual "keeps changing shape" symptom). +/// * **Eating solid blocks** — a fluid change must never overwrite a non-fluid, non-air block. +/// The pure kernels never target solids, but a stale read in the parallel phase (or a block +/// placed between evaluation and apply) theoretically could; refusing the write here makes that +/// impossible rather than silently destroying terrain. Reactions (`fizz`, e.g. lava→stone) are +/// allowed to replace fluid with rock, which is their whole purpose. fn apply_change(state: &GlobalState, dim: ActiveDimension, change: &FluidChange) -> bool { - match ferrumc_utils::world::load_or_generate_mut(state, change.pos.chunk(), dim.name()) { + let chunk_pos = change.pos.chunk(); + let block_pos = change.pos.chunk_block_pos(); + + match ferrumc_utils::world::load_or_generate_mut(state, chunk_pos, dim.name()) { Ok(mut chunk) => { - chunk.set_block(change.pos.chunk_block_pos(), change.new_block); + let current = chunk.get_block(block_pos); + // Nothing to do if the block is already what we'd write. + if current == change.new_block { + return false; + } + // Refuse to overwrite a solid block with fluid. A reaction (fizz) replaces fluid with + // rock and is fine; a plain fluid flow must only ever land on air or fluid. + if ferrumc_world::fluid::is_fluid(change.new_block) + && ferrumc_world::fluid::is_solid_obstacle(current) + { + trace!( + "[fluid] refused to overwrite solid block at {:?} with fluid", + change.pos.pos + ); + return false; + } + chunk.set_block(block_pos, change.new_block); true } Err(err) => { @@ -245,13 +327,21 @@ fn reduce_changes(changes: Vec) -> Vec { by_pos.into_values().collect() } -/// Priority key for [`reduce_changes`]. Higher wins. Fluids outrank non-fluids; among fluids a -/// lower level (stronger) outranks; ties fall back to the raw id for stability. +/// Priority key for [`reduce_changes`]. Higher wins. +/// +/// Lava/water solidifications (`fizz`) outrank everything: once a cell is set to turn into rock, +/// no competing fluid flow at the same position may override it (this is what keeps a down-flow +/// "stone" from being clobbered by the water cell's own recede in the same batch). Below that, +/// fluids outrank non-fluids, and among fluids a lower level (stronger) wins; ties fall back to the +/// raw id for stability. fn change_priority(change: &FluidChange) -> (u8, i32, u32) { + if change.fizz { + return (3, 0, change.new_block.raw()); + } match fluid_state(change.new_block) { - // Fluid: top tier (2). Stronger (lower level) should win, so negate the level. + // Fluid: middle tier (2). Stronger (lower level) should win, so negate the level. Some(state) => (2, -(state.level as i32), change.new_block.raw()), - // Non-fluid (air/removal): lower tier (1). + // Non-fluid (air/removal): lowest tier (1). None => (1, 0, change.new_block.raw()), } } @@ -280,8 +370,9 @@ fn evaluate_batch( } /// Evaluates a single ticking position against the world, picking the right [`FluidRules`] from -/// whatever fluid currently sits there. Non-fluid positions produce no changes (the block was -/// removed or replaced between scheduling and now). +/// whatever fluid currently sits there, and dispatching to the configured spreading kernel. +/// Non-fluid positions produce no changes (the block was removed or replaced between scheduling +/// and now). #[inline] fn evaluate_position(view: &V, dim: Dimension, pos: BlockPos) -> Vec { let block = view.block_at(pos); @@ -289,7 +380,8 @@ fn evaluate_position(view: &V, dim: Dimension, pos: BlockPos) -> V return Vec::new(); }; let rules = FluidRules::for_kind(state.kind, dim); - compute_fluid_tick(pos, view, rules) + let algorithm = get_global_config().fluids.algorithm; + ferrumc_world::fluid::compute_tick(algorithm, pos, view, rules) } /// Serial evaluation: computes changes for every position on the calling thread. @@ -357,18 +449,37 @@ fn evaluate_parallel( reduce_changes(all) } +/// Horizontal radius (in blocks) that a single fluid tick may read. +/// +/// The current six-neighbour model only needs radius 1, but the vanilla-style spread we are moving +/// toward does a bounded slope search (`getSlopeDistance`, default depth 4) to find the nearest +/// hole. Pre-warming to this radius now means the parallel read phase stays a genuine read-only +/// phase once the new kernel lands: every chunk the search can touch is already cached, so no +/// worker thread triggers chunk generation (which writes, and is unsafe to do concurrently). +/// +/// Kept slightly larger than vanilla's depth-4 search so a downward probe at the rim of the search +/// box still lands on a pre-warmed chunk. +const FLUID_SEARCH_RADIUS: i32 = 5; + /// Loads or generates, on the calling thread, every chunk the parallel evaluation phase might read. /// -/// A fluid tick reads the ticking block and its six axis-aligned neighbours, so the set of chunks -/// touched is each position's chunk plus the chunks of its neighbours. Doing this serially up front -/// removes all chunk generation/insertion from the parallel phase, leaving only cache reads there. +/// A fluid tick reads a horizontal box of half-width [`FLUID_SEARCH_RADIUS`] around the ticking +/// block (plus one below, for downward probes). The set of chunks touched is therefore every chunk +/// spanned by that box for every ticking position. Doing this serially up front removes all chunk +/// generation/insertion from the parallel phase, leaving only cache reads there. fn prewarm_chunks(state: &GlobalState, dim: ActiveDimension, positions: &[BlockPos]) { use std::collections::HashSet; + let r = FLUID_SEARCH_RADIUS; let mut chunks = HashSet::new(); for &pos in positions { - chunks.insert(pos.chunk()); - for neighbour in neighbours(pos) { - chunks.insert(neighbour.chunk()); + // The search is horizontal, so we only need to span chunks in x/z. Iterate every chunk + // column the half-width-`r` box covers (robust even if `r` exceeds a chunk width). + let min = (pos + (-r, 0, -r)).chunk(); + let max = (pos + (r, 0, r)).chunk(); + for cx in min.x()..=max.x() { + for cz in min.z()..=max.z() { + chunks.insert(ferrumc_world::pos::ChunkPos::new(cx, cz)); + } } } for chunk_pos in chunks { @@ -384,9 +495,14 @@ fn prewarm_chunks(state: &GlobalState, dim: ActiveDimension, positions: &[BlockP /// been computed. Runs on the calling (main) thread, so writes are serial and need no locking /// beyond the per-chunk guard taken inside [`apply_change`]. /// -/// Re-scheduling reads the [`FluidRules`] for the *new* block at each change, so a freshly placed -/// lava cell gets lava's cadence, even if the original tick that produced the change was scheduled -/// at the water cadence (e.g. a water tick removing itself adjacent to lava). +/// For each applied change this: +/// * broadcasts the new block to nearby players, plus the lava-extinguish "fizz" effect when the +/// change is a solidification ([`FluidChange::fizz`]); +/// * re-ticks the changed flowing block itself when it asked for it; +/// * wakes the fluid **neighbours** of the changed position (via [`seed_fluid_tick`], which also +/// gives a lava-meets-water neighbour the fast reaction cadence). This neighbour propagation is +/// what lets a recede, a fresh spread, or a solidification ripple through the whole body of fluid +/// instead of stopping at the first ring — the analogue of vanilla's neighbour block updates. fn apply_changes( changes: &[FluidChange], scheduler: &mut BlockTickScheduler, @@ -395,9 +511,7 @@ fn apply_changes( current: u64, players: &Query<(Entity, &StreamWriter, &Position)>, ) { - // Fallback delay for changes that turn a fluid into a non-fluid (e.g. drying up). In - // practice such changes set `reschedule = false`, but if a future change ever asks to be - // rescheduled we still need a defensible delay; water's cadence is the conservative choice. + // Fallback cadence for re-ticking a changed block whose new state is not itself fluid. let fallback_delay = FluidRules::for_kind(FluidKind::Water, dim.0).tick_delay; for change in changes { @@ -405,12 +519,53 @@ fn apply_changes( continue; } broadcast_change(change, state, players); + if change.fizz { + // Play the hiss + smoke at the solidified block. + broadcast_fizz(change.pos, state, players); + } + + // Re-tick the changed block itself if it is still flowing fluid that may keep evolving. if change.reschedule { let delay = rules_for_block(change.new_block, dim.0) .map(|r| r.tick_delay) .unwrap_or(fallback_delay); scheduler.schedule(change.pos, TickKind::FluidSpread, current, delay); } + + // Wake fluid neighbours so the update propagates outward. `seed_fluid_tick` skips + // non-fluid neighbours and picks the reaction cadence for lava that now touches water. + for neighbour in fluid_neighbours(change.pos) { + seed_fluid_tick(scheduler, state, dim, current, neighbour); + } + } +} + +/// Broadcasts the lava-extinguish effect (sound + smoke) at `pos` to nearby players. +fn broadcast_fizz( + pos: BlockPos, + state: &GlobalState, + players: &Query<(Entity, &StreamWriter, &Position)>, +) { + let render_distance = get_global_config().chunk_render_distance as i32; + let target_chunk = pos.chunk(); + let packet = LevelEventPacket::lava_extinguish(NetworkPosition { + x: pos.pos.x, + y: pos.pos.y as i16, + z: pos.pos.z, + }); + + for (eid, conn, position) in players.iter() { + if !state.players.is_connected(eid) { + continue; + } + let player_chunk = position.chunk(); + if (target_chunk.x() - player_chunk.x).abs() <= render_distance + && (target_chunk.z() - player_chunk.y).abs() <= render_distance + { + if let Err(err) = conn.send_packet_ref(&packet) { + trace!("[fluid] failed to send lava-fizz level event: {:?}", err); + } + } } } @@ -425,8 +580,15 @@ pub fn process_fluid_ticks( tick: Res, state: Res, dim: Res, + mut control: ResMut, players: Query<(Entity, &StreamWriter, &Position)>, ) { + // Debug freeze/step: when frozen, only advance on an explicitly queued step. Checked before + // draining so a frozen simulation leaves its pending ticks untouched. + if !control.allow_tick() { + return; + } + let current = tick.get(); let due = scheduler.0.drain_due(current); if due.is_empty() { @@ -499,6 +661,7 @@ mod tests { world.insert_resource(state.clone()); world.insert_resource(TickCounter::new()); world.insert_resource(TEST_DIM); + world.insert_resource(FluidTickControl::default()); let mut sched = FluidScheduler::default(); // Seed the source as the placement handler would. @@ -575,6 +738,25 @@ mod tests { .unwrap_or(TEST_DELAY); scheduler.schedule(change.pos, TickKind::FluidSpread, tick, delay); } + // Wake fluid neighbours so updates propagate, matching production `apply_changes`. + for neighbour in fluid_neighbours(change.pos) { + let block = match ferrumc_utils::world::load_or_generate_chunk( + state, + neighbour.chunk(), + TEST_DIM_NAME, + ) { + Ok(chunk) => chunk.get_block(neighbour.chunk_block_pos()), + Err(_) => continue, + }; + if let Some(rules) = rules_for_block(block, TEST_DIM.0) { + scheduler.schedule( + neighbour, + TickKind::FluidSpread, + tick, + rules.tick_delay, + ); + } + } } } } @@ -696,21 +878,9 @@ mod tests { #[test] fn reduce_changes_is_order_independent() { let pos = BlockPos::of(5, 70, 5); - let strong = FluidChange { - pos, - new_block: fluid_block(FluidKind::Water, 1), - reschedule: true, - }; - let weak = FluidChange { - pos, - new_block: fluid_block(FluidKind::Water, 6), - reschedule: false, - }; - let air = FluidChange { - pos, - new_block: block!("air"), - reschedule: false, - }; + let strong = FluidChange::flow(pos, fluid_block(FluidKind::Water, 1), true); + let weak = FluidChange::flow(pos, fluid_block(FluidKind::Water, 6), false); + let air = FluidChange::flow(pos, block!("air"), false); let a = reduce_changes(vec![strong, weak, air]); let b = reduce_changes(vec![air, weak, strong]); @@ -727,6 +897,133 @@ mod tests { assert!(a[0].reschedule); } + /// End-to-end through the real apply path: flowing lava adjacent to a water source solidifies + /// into cobblestone. + #[test] + fn flowing_lava_beside_water_source_turns_to_cobblestone() { + let (state, _tmp) = create_test_state(); + let global = &state.0; + + let _ = ferrumc_utils::world::load_or_generate_chunk( + global, + BlockPos::of(0, 64, 0).chunk(), + TEST_DIM_NAME, + ) + .expect("generate chunk"); + + let lava_pos = BlockPos::of(0, 64, 0); + let water_pos = BlockPos::of(1, 64, 0); + global + .world + .set_block_and_fetch(lava_pos, TEST_DIM_NAME, fluid_block(FluidKind::Lava, 2)) + .expect("set flowing lava"); + global + .world + .set_block_and_fetch(water_pos, TEST_DIM_NAME, fluid_block(FluidKind::Water, 0)) + .expect("set water source"); + + let mut scheduler = BlockTickScheduler::new(); + scheduler.schedule(lava_pos, TickKind::FluidSpread, 0, 0); + run_to_steady_state(global, &mut scheduler, EvalMode::Serial, 50); + + let result = + ferrumc_utils::world::load_or_generate_chunk(global, lava_pos.chunk(), TEST_DIM_NAME) + .expect("load chunk") + .get_block(lava_pos.chunk_block_pos()); + + assert_eq!( + result, + block!("cobblestone"), + "flowing lava touching a water source horizontally should become cobblestone, got {}", + result + ); + } + + /// End-to-end through the real apply path: flowing lava adjacent to a water source solidifies + /// into stone. This exercises `compute_fluid_tick`'s reaction branch plus the production + /// `apply_changes` (write + neighbour waking), not just the pure algorithm. + #[test] + fn flowing_lava_above_water_source_turns_to_stone() { + let (state, _tmp) = create_test_state(); + let global = &state.0; + + let _ = ferrumc_utils::world::load_or_generate_chunk( + global, + BlockPos::of(0, 64, 0).chunk(), + TEST_DIM_NAME, + ) + .expect("generate chunk"); + + let lava_pos = BlockPos::of(0, 65, 0); + let water_pos = BlockPos::of(0, 64, 0); + global + .world + .set_block_and_fetch(lava_pos, TEST_DIM_NAME, fluid_block(FluidKind::Lava, 2)) + .expect("set flowing lava"); + global + .world + .set_block_and_fetch(water_pos, TEST_DIM_NAME, fluid_block(FluidKind::Water, 0)) + .expect("set water source"); + + let mut scheduler = BlockTickScheduler::new(); + // Tick the lava: it should flow down and turn the water into stone. + scheduler.schedule(lava_pos, TickKind::FluidSpread, 0, 0); + run_to_steady_state(global, &mut scheduler, EvalMode::Serial, 50); + + let result = + ferrumc_utils::world::load_or_generate_chunk(global, water_pos.chunk(), TEST_DIM_NAME) + .expect("load chunk") + .get_block(water_pos.chunk_block_pos()); + + assert_eq!( + result, + block!("stone"), + "flowing lava falling into water should become stone at the water's position, got {}", + result + ); + } + + //obsidian check :3 + #[test] + fn flowing_water_above_lava_source_turns_to_obsidian() { + let (state, _tmp) = create_test_state(); + let global = &state.0; + + let _ = ferrumc_utils::world::load_or_generate_chunk( + global, + BlockPos::of(0, 64, 0).chunk(), + TEST_DIM_NAME, + ) + .expect("generate chunk"); + + let lava_pos = BlockPos::of(0, 64, 0); + let water_pos = BlockPos::of(0, 65, 0); + global + .world + .set_block_and_fetch(lava_pos, TEST_DIM_NAME, fluid_block(FluidKind::Lava, 0)) + .expect("set lava source"); + global + .world + .set_block_and_fetch(water_pos, TEST_DIM_NAME, fluid_block(FluidKind::Water, 0)) + .expect("set flowing water"); + + let mut scheduler = BlockTickScheduler::new(); + scheduler.schedule(water_pos, TickKind::FluidSpread, 0, 0); + run_to_steady_state(global, &mut scheduler, EvalMode::Serial, 50); + + let result = + ferrumc_utils::world::load_or_generate_chunk(global, lava_pos.chunk(), TEST_DIM_NAME) + .expect("load chunk") + .get_block(lava_pos.chunk_block_pos()); + + assert_eq!( + result, + block!("obsidian"), + "flowing water falling into lava source should become obsidian at the lava's position, got {}", + result + ); + } + /// Manual benchmark comparing serial vs parallel evaluation on a large batch. Ignored by /// default (it only prints timing data and is sensitive to machine load); run explicitly with: /// `cargo test -p ferrumc --bin ferrumc bench_serial_vs_parallel -- --ignored --nocapture`. diff --git a/src/lib/config/src/server_config.rs b/src/lib/config/src/server_config.rs index 6c12fdcc0..a791db515 100644 --- a/src/lib/config/src/server_config.rs +++ b/src/lib/config/src/server_config.rs @@ -55,6 +55,8 @@ pub struct ServerConfig { pub default_gamemode: String, pub dashboard: DashboardConfig, pub performance: PerformanceConfig, + #[serde(default)] + pub fluids: FluidConfig, } /// The database configuration section from [ServerConfig]. @@ -90,6 +92,30 @@ pub struct PerformanceConfig { pub chunks_per_tick: i32, } +/// Selects which fluid-spreading kernel the server uses. +/// +/// `simplified` is a cheap approximation (uniform spread, no hole steering); `vanilla` is the +/// Minecraft-faithful bounded slope search that steers fluid toward the nearest hole at a higher +/// CPU cost. The actual kernels live in `ferrumc-world`; this enum is the config-level selector so +/// it can be validated at load time without the config crate depending on the world crate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum FluidAlgorithm { + /// Cheap approximation: per-block feeder re-derivation, uniform horizontal spread. + Simplified, + /// Vanilla-faithful bounded slope search (`getSlopeDistance`): fluid steers toward holes. + #[default] + Vanilla, +} + +/// The fluid simulation configuration section from [ServerConfig]. +#[derive(Default, Debug, Deserialize, Serialize)] +pub struct FluidConfig { + /// Which spreading algorithm to use. Defaults to `vanilla`. + #[serde(default)] + pub algorithm: FluidAlgorithm, +} + fn create_config() -> ServerConfig { let config_location = get_root_path().join("configs"); let main_config_file = config_location.join("config.toml"); diff --git a/src/lib/net/src/packets/outgoing/level_event.rs b/src/lib/net/src/packets/outgoing/level_event.rs new file mode 100644 index 000000000..7e566130f --- /dev/null +++ b/src/lib/net/src/packets/outgoing/level_event.rs @@ -0,0 +1,35 @@ +use ferrumc_macros::{packet, NetEncode}; +use ferrumc_net_codec::net_types::network_position::NetworkPosition; + +/// The clientbound **Level Event** packet. +/// +/// Triggers a fixed, client-side audiovisual effect at a position — a sound and/or particles the +/// client already knows how to play, selected by `event_id`. Unlike a raw sound packet this needs +/// no sound-registry id and bundles the matching particles, which is exactly how vanilla plays the +/// lava-extinguish "fizz" when a fluid solidifies. +#[derive(NetEncode)] +#[packet(packet_id = "level_event", state = "play")] +pub struct LevelEventPacket { + pub event_id: i32, + pub location: NetworkPosition, + /// Event-specific extra data; 0 for the effects we use. + pub data: i32, + /// When true, the client ignores distance when scaling volume. Always false for us. + pub disable_relative_volume: bool, +} + +impl LevelEventPacket { + /// Event id for the lava-extinguish effect (`LAVA_FIZZ` / `1501` in vanilla): the hissing + /// sound plus a puff of smoke, played when lava solidifies on contact with water. + pub const LAVA_EXTINGUISH: i32 = 1501; + + /// Builds the lava-fizz effect at `location`. + pub fn lava_extinguish(location: NetworkPosition) -> Self { + Self { + event_id: Self::LAVA_EXTINGUISH, + location, + data: 0, + disable_relative_volume: false, + } + } +} diff --git a/src/lib/net/src/packets/outgoing/mod.rs b/src/lib/net/src/packets/outgoing/mod.rs index 4d4ae6b80..5cfcd1bfd 100644 --- a/src/lib/net/src/packets/outgoing/mod.rs +++ b/src/lib/net/src/packets/outgoing/mod.rs @@ -61,5 +61,6 @@ pub mod respawn; pub mod set_health; pub mod update_time; +pub mod level_event; pub mod synchronise_vehicle_position; pub mod update_tags; diff --git a/src/lib/world/src/fluid/mod.rs b/src/lib/world/src/fluid/mod.rs index dfefd9e32..a4613fc60 100644 --- a/src/lib/world/src/fluid/mod.rs +++ b/src/lib/world/src/fluid/mod.rs @@ -14,10 +14,31 @@ use crate::block_state_id::{BlockStateId, ID2BLOCK}; use crate::dimension::Dimension; +use ferrumc_config::server_config::FluidAlgorithm; use lazy_static::lazy_static; use std::collections::HashMap; pub mod spread; +pub mod vanilla; + +/// Dispatches a single fluid tick to the kernel selected by `algorithm`. +/// +/// Both kernels share the same seams (a read-only [`spread::BlockView`] in, a `Vec` of +/// [`spread::FluidChange`] out) and both return real mutations only, leaving neighbour waking to +/// the caller. This indirection is the single switch point: callers pass the configured +/// [`FluidAlgorithm`] and never name a kernel directly. +#[inline] +pub fn compute_tick( + algorithm: FluidAlgorithm, + pos: crate::pos::BlockPos, + view: &V, + rules: FluidRules, +) -> Vec { + match algorithm { + FluidAlgorithm::Simplified => spread::compute_fluid_tick(pos, view, rules), + FluidAlgorithm::Vanilla => vanilla::compute_fluid_tick_vanilla(pos, view, rules), + } +} #[cfg(test)] mod tests_integration; @@ -62,6 +83,14 @@ pub struct FluidRules { /// Number of game ticks between scheduled updates of a flowing block of this fluid. /// Vanilla water = 5, overworld lava = 30, Nether lava = 10. pub tick_delay: u64, + /// How many blocks the vanilla-style slope search (`getSlopeDistance`) looks outward for the + /// nearest hole before giving up. Vanilla: water = 4, overworld lava = 2, Nether lava = 4. + /// Only used by the vanilla spread kernel; the simplified model ignores it. + pub slope_find_distance: u8, + /// Whether two or more adjacent source blocks can spontaneously create a new source between + /// them (infinite water). Vanilla: water = true, lava = false (lava is never infinite in + /// unmodified overworld/Nether). Only used by the vanilla spread kernel. + pub can_form_source: bool, } impl FluidRules { @@ -75,16 +104,22 @@ impl FluidRules { level_step: 1, max_spread_level: 7, tick_delay: 5, + slope_find_distance: 4, + can_form_source: true, }, (FluidKind::Lava, Dimension::Nether) => Self { level_step: 1, max_spread_level: 7, tick_delay: 10, + slope_find_distance: 4, + can_form_source: false, }, (FluidKind::Lava, Dimension::Overworld | Dimension::End) => Self { level_step: 2, max_spread_level: 7, tick_delay: 30, + slope_find_distance: 2, + can_form_source: false, }, } } @@ -191,6 +226,15 @@ pub fn is_fluid(block: BlockStateId) -> bool { FLUID_TABLES.id_to_fluid.contains_key(&block.raw()) } +/// Returns true if `block` is a solid obstacle from a fluid's point of view: not air, not any +/// fluid. Fluid flow must never overwrite such a block; the apply path uses this as a safety guard +/// against accidentally "eating" terrain. +#[inline] +pub fn is_solid_obstacle(block: BlockStateId) -> bool { + use ferrumc_macros::block; + block != block!("air") && block != block!("void_air") && !is_fluid(block) +} + /// Returns the block state ID for the given fluid kind and level. /// /// `level` is clamped to the valid range `0..=MAX_FLUID_LEVEL`. diff --git a/src/lib/world/src/fluid/spread.rs b/src/lib/world/src/fluid/spread.rs index 6fb7f7fc6..a3d9d372f 100644 --- a/src/lib/world/src/fluid/spread.rs +++ b/src/lib/world/src/fluid/spread.rs @@ -10,19 +10,25 @@ //! //! The model approximates vanilla without the full "shortest path to a hole" search: //! -//! * A block is a *source* (level 0) or *flowing* (level 1..=[`MAX_SPREAD_LEVEL`]). +//! * A block is a *source* (level 0) or *flowing* (level 1..=`max_spread_level`). //! * If the cell below is replaceable, fluid flows straight down. Downward flow always produces a //! near-source flowing block, so a falling column stays full. //! * Otherwise, fluid spreads horizontally to each replaceable neighbour whose resulting level //! would be lower than that neighbour's current level, decrementing the level by one step each -//! block. Once the level passes [`MAX_SPREAD_LEVEL`], the fluid no longer spreads horizontally. -//! * A flowing (non-source) block that has lost all of its upstream support dries up: it is -//! scheduled to be removed. (Upstream = a same-fluid source/flow above, or an adjacent block -//! with a stronger, i.e. lower, level.) +//! block. Once the level passes the cap, the fluid no longer spreads horizontally. +//! * A flowing (non-source) block re-derives its own level each tick from its strongest remaining +//! feeder (`min feeder level + step`, vanilla-style). If that feeder is removed it recedes one +//! step at a time rather than vanishing, and only dries up (turns to air) once no feeder can +//! support any level within the cap. (Upstream feeders = a same-fluid source/flow above, or a +//! horizontally adjacent same-fluid block.) +//! * When lava and water meet, the lava solidifies (vanilla's two mechanics): a lava block with +//! water above or beside it hardens in place — a *source* into obsidian, *flowing* lava into +//! cobblestone — and lava flowing *down* onto a water cell turns that water into stone. Water is +//! never the block that changes. Each solidification flags a "fizz" effect for the caller to play +//! the lava-extinguish sound. //! //! Water and lava share this logic; they differ only in [`crate::fluid::FluidKind`]-derived -//! parameters supplied by the caller (spread distance and tick delay live with the caller, not -//! here). +//! parameters supplied by the caller (spread distance, level step and tick delay). //! til 2026-06-02, there is a crash problem due to(maybe) chunk storage. Placing any fluid may cause block disappear in a specific section. It is fine, NCC is gonna fix it in another PR. use crate::block_state_id::BlockStateId; @@ -49,15 +55,49 @@ pub struct FluidChange { /// Whether the changed block should itself be scheduled for a follow-up fluid tick (because it /// is flowing fluid that may continue to spread). pub reschedule: bool, + /// Whether this change is a lava/water solidification, which the client should accompany with + /// the lava-extinguish "fizz" sound and smoke (LevelEvent 1501). The caller is responsible for + /// emitting that effect; the algorithm just flags which changes are reactions. + pub fizz: bool, } -/// Returns true if a fluid may overwrite this block. +impl FluidChange { + /// A normal (non-reaction) fluid change: places `new_block` at `pos`, optionally rescheduling. + pub fn flow(pos: BlockPos, new_block: BlockStateId, reschedule: bool) -> Self { + Self { + pos, + new_block, + reschedule, + fizz: false, + } + } + + /// A lava/water solidification change: places rock at `pos` and flags the fizz effect. Rock is + /// not fluid, so it never reschedules itself. + pub fn reaction(pos: BlockPos, new_block: BlockStateId) -> Self { + Self { + pos, + new_block, + reschedule: false, + fizz: true, + } + } +} + +/// Returns true if fluid of `kind` may flow into / overwrite this block. /// -/// The simplified model treats air and existing fluid of any kind as replaceable. Solid blocks are -/// not. (Fluid-vs-fluid interactions such as water meeting lava are intentionally out of scope for -/// this phase; they are recorded as future work.) -fn is_replaceable(block: BlockStateId) -> bool { - block == block!("air") || block == block!("void_air") || fluid_state(block).is_some() +/// Air (and void air) is always replaceable. Same-fluid blocks are replaceable (the caller further +/// checks whether the flow would actually strengthen them). The *opposite* fluid is intentionally +/// not replaceable here: lava meeting water is resolved by the solidification reaction, not by one +/// fluid overwriting the other. Solid blocks are never replaceable. +pub(crate) fn is_replaceable_by(kind: FluidKind, block: BlockStateId) -> bool { + if block == block!("air") || block == block!("void_air") { + return true; + } + match fluid_state(block) { + Some(state) => state.kind == kind, + None => false, + } } /// The six axis-aligned neighbours of `pos`, plus convenience accessors. @@ -67,27 +107,127 @@ fn below(pos: BlockPos) -> BlockPos { fn above(pos: BlockPos) -> BlockPos { pos + (0, 1, 0) } -const HORIZONTAL: [(i32, i32, i32); 4] = [(1, 0, 0), (-1, 0, 0), (0, 0, 1), (0, 0, -1)]; +pub(crate) const HORIZONTAL: [(i32, i32, i32); 4] = [(1, 0, 0), (-1, 0, 0), (0, 0, 1), (0, 0, -1)]; + +/// The six axis-aligned neighbours of `pos` (the cells a fluid tick at `pos` can influence). +/// +/// The caller uses this to propagate updates: after applying a change at `pos`, every neighbour +/// that currently holds fluid should be re-ticked so a recede/spread/solidify ripples outward +/// instead of stopping at a single ring. This is the FerrumC analogue of vanilla's neighbour +/// block updates. +pub fn fluid_neighbours(pos: BlockPos) -> [BlockPos; 6] { + [ + above(pos), + below(pos), + pos + HORIZONTAL[0], + pos + HORIZONTAL[1], + pos + HORIZONTAL[2], + pos + HORIZONTAL[3], + ] +} -/// Returns true if `neighbour` provides upstream support for a flowing block of `kind`. +/// The block a lava cell hardens into when water is in contact from the side or above (the +/// vanilla `shouldSpreadLiquid` set: up + horizontals, never directly below). /// -/// Support comes from the same fluid directly above, or from a horizontally adjacent same-fluid -/// block with a strictly stronger (lower) level. -fn provides_support(kind: FluidKind, self_level: u8, neighbour: Option) -> bool { - match neighbour { - Some(state) if state.kind == kind => state.level < self_level, - _ => false, +/// A lava *source* becomes obsidian; *flowing* lava becomes cobblestone. (The "stone" result is a +/// different mechanic — lava flowing *down* onto water — handled in [`down_flow_reaction`].) +fn lava_harden_in_place(lava: FluidState) -> BlockStateId { + debug_assert!(lava.kind == FluidKind::Lava); + if lava.is_source() { + block!("obsidian") + } else { + block!("cobblestone") } } +/// If the lava at `pos` should harden *in place* this tick, returns the rock it becomes. +/// +/// Mirrors vanilla: lava checks the five cells `{above, N, S, E, W}` (deliberately excluding the +/// cell directly below) for water. Water below is handled by the down-flow path instead, so that a +/// lava-above/water-below column solidifies the lower (water) cell into stone, not the upper lava. +pub(crate) fn lava_harden_check( + pos: BlockPos, + lava: FluidState, + view: &V, +) -> Option { + let touches_water = std::iter::once(above(pos)) + .chain(HORIZONTAL.iter().map(|&o| pos + o)) + .any(|n| matches!(fluid_state(view.block_at(n)), Some(s) if s.kind == FluidKind::Water)); + touches_water.then(|| lava_harden_in_place(lava)) +} + +/// If lava at `pos` sits directly above a water cell, returns the [`FluidChange`] that turns that +/// water cell into stone (the classic "stone generator"). Shared by both spread kernels. +pub(crate) fn lava_water_down_reaction( + pos: BlockPos, + view: &V, +) -> Option { + let below_pos = below(pos); + matches!(fluid_state(view.block_at(below_pos)), Some(s) if s.kind == FluidKind::Water) + .then(|| FluidChange::reaction(below_pos, block!("stone"))) +} + +/// Computes the lowest level a flowing block of `kind` could legitimately hold this tick, given its +/// surroundings, or `None` if no feeder can support any level (so it should disappear). +/// +/// Feeders are same-fluid blocks that can push into `pos`: +/// * fluid directly above feeds a full (near-source) column, pinning this block to `level_step`; +/// * each horizontally adjacent same-fluid block feeds `its level + level_step`. +/// +/// The result is the strongest (lowest) feed that still fits within `max_spread_level`. +fn supported_level( + pos: BlockPos, + kind: FluidKind, + rules: FluidRules, + view: &V, +) -> Option { + // Fluid directly above => behaves like a falling column: stays near-source. + if matches!(fluid_state(view.block_at(above(pos))), Some(s) if s.kind == kind) { + return Some(rules.level_step.min(rules.max_spread_level)); + } + + let mut best: Option = None; + for &offset in HORIZONTAL.iter() { + if let Some(state) = fluid_state(view.block_at(pos + offset)) { + if state.kind == kind { + let fed = state.level.saturating_add(rules.level_step); + if fed <= rules.max_spread_level { + best = Some(best.map_or(fed, |b| b.min(fed))); + } + } + } + } + best +} + +/// Returns true if a fluid tick at `pos` would immediately produce a lava/water solidification. +/// +/// Used by the scheduler to give lava that has just come into contact with water a fast follow-up +/// tick, so the reaction resolves almost immediately (as in vanilla) instead of waiting out lava's +/// slow 30-tick cadence. Only lava is ever the reacting party. +pub fn would_react(pos: BlockPos, view: &V) -> bool { + let Some(state) = fluid_state(view.block_at(pos)) else { + return false; + }; + if state.kind != FluidKind::Lava { + return false; + } + // In-place harden (water above / beside) or down-flow onto water below. + lava_harden_check(pos, state, view).is_some() + || matches!(fluid_state(view.block_at(below(pos))), Some(s) if s.kind == FluidKind::Water) +} + /// Computes the changes a single fluid block produces on its tick. /// /// `pos` must currently contain a fluid (otherwise an empty change set is returned). `rules` /// supplies the per-fluid, per-dimension parameters (level step, max spread level); the caller is /// expected to look these up via [`FluidRules::for_kind`] using the block at `pos`. /// -/// The returned changes are not applied; the caller is responsible for writing them back and for -/// scheduling any follow-up ticks indicated by [`FluidChange::reschedule`]. +/// The returned changes are *real* block mutations only — this function never emits no-op +/// "neighbour wake" entries. Propagation is a scheduling concern owned by the caller: after +/// applying these changes it must re-tick the fluid neighbours of every changed position (see +/// [`fluid_neighbours`]). Doing it that way keeps conflict resolution (e.g. lava solidifying vs a +/// neighbour's flow) from being polluted by no-op writes. pub fn compute_fluid_tick( pos: BlockPos, view: &V, @@ -100,54 +240,66 @@ pub fn compute_fluid_tick( let kind = state.kind; let mut changes = Vec::new(); - // --- Determine whether this block still has upstream support. --- - // Sources are always supported. Flowing blocks need either fluid above or a stronger - // horizontal neighbour. - let supported = if state.is_source() { - true - } else { - let above_state = fluid_state(view.block_at(above(pos))); - let above_supports = matches!(above_state, Some(s) if s.kind == kind); - above_supports - || HORIZONTAL.iter().any(|&offset| { - let n = fluid_state(view.block_at(pos + offset)); - provides_support(kind, state.level, n) - }) - }; + // --- Lava/water interaction takes precedence over everything else. --- + // Only lava is the active party; water keeps flowing normally. Both reaction checks run before + // the recede/flow logic, so contact with water always resolves first (even for lava that would + // otherwise dry up or recede this tick). + if kind == FluidKind::Lava { + // (a) In-place harden: lava with water above or beside it solidifies where it stands. + // Source -> obsidian, flowing -> cobblestone. The cell directly below is deliberately + // excluded; that is the down-flow case. + if let Some(rock) = lava_harden_check(pos, state, view) { + changes.push(FluidChange::reaction(pos, rock)); + return changes; + } + // (b) Down-flow harden: lava sitting directly above a water cell turns that water into + // stone (the classic "stone generator"). The water cell is the one that changes. + let below_pos = below(pos); + if matches!(fluid_state(view.block_at(below_pos)), Some(s) if s.kind == FluidKind::Water) { + changes.push(FluidChange::reaction(below_pos, block!("stone"))); + return changes; + } + } - // A flowing block with no support dries up. - if !supported { - changes.push(FluidChange { - pos, - new_block: block!("air"), - reschedule: false, - }); - return changes; + // --- A flowing block re-derives its level from its feeders. --- + // Sources are fixed. Flowing blocks recede one step (or dry up) when their feed weakens, and + // strengthen when a stronger feed appears. The caller wakes the neighbours so the adjustment + // ripples through the whole body of fluid instead of stopping at one ring. + if !state.is_source() { + match supported_level(pos, kind, rules, view) { + None => { + // No feeder can support any level: dry up. + changes.push(FluidChange::flow(pos, block!("air"), false)); + return changes; + } + Some(target) if target != state.level => { + // Level changed (receding or strengthening). We do not also spread this tick; the + // block re-ticks (and neighbours are woken) so it settles one step at a time, + // matching vanilla's gradual recede. + changes.push(FluidChange::flow(pos, fluid_block(kind, target), true)); + return changes; + } + Some(_) => { /* level unchanged: fall through to normal flow */ } + } } - // --- Vertical flow takes priority. Or fluid will pruh~ pruh~ unfolded in the air --- + let effective_level = state.level; + + // --- Vertical flow takes priority. --- let below_pos = below(pos); let below_block = view.block_at(below_pos); - if is_replaceable(below_block) { + if is_replaceable_by(kind, below_block) { // Falling fluid stays effectively full so columns do not thin out as they fall. - let falling = fluid_block(kind, 1); + let falling = fluid_block(kind, rules.level_step.min(rules.max_spread_level)); if below_block != falling { - changes.push(FluidChange { - pos: below_pos, - new_block: falling, - reschedule: true, - }); + changes.push(FluidChange::flow(below_pos, falling, true)); } - // When fluid can fall, vanilla does not also spread horizontally from this block, so we - // stop here. And this code should be reviewed if it is conflict with chunk saving. + // When fluid can fall, vanilla does not also spread horizontally from this block. return changes; } // --- Horizontal spread. --- - // The level step is fluid- and dimension-dependent: vanilla water steps by 1 (7 blocks of - // reach), overworld lava by 2 (3 blocks), nether lava by 1 (7 blocks). Saturating add so a - // pathological level near u8::MAX cannot wrap around. - let next_level = state.level.saturating_add(rules.level_step); + let next_level = effective_level.saturating_add(rules.level_step); if next_level > rules.max_spread_level { return changes; } @@ -156,21 +308,17 @@ pub fn compute_fluid_tick( for &offset in HORIZONTAL.iter() { let n_pos = pos + offset; let n_block = view.block_at(n_pos); - if !is_replaceable(n_block) { + if !is_replaceable_by(kind, n_block) { continue; } // Only flow in if it would make the neighbour stronger (lower level) than it currently is. let improves = match fluid_state(n_block) { Some(n_state) if n_state.kind == kind => next_level < n_state.level, - Some(_) => false, // different fluid: skip in this phase + Some(_) => false, // opposite fluid: handled by the reaction path, not here None => true, // air: always flow in }; if improves { - changes.push(FluidChange { - pos: n_pos, - new_block: outflow, - reschedule: true, - }); + changes.push(FluidChange::flow(n_pos, outflow, true)); } } @@ -255,28 +403,35 @@ mod tests { } #[test] - fn flowing_decrements_level_each_step() { + fn flowing_spreads_at_its_level_plus_step() { let mut view = MapView::new(); - // A supported level-3 flowing block (source above) on solid ground spreads at level 4. - view.set(p(0, 65, 0), fluid_block(FluidKind::Water, 0)); - view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 3)); + // A level-2 flowing block fed by a level-1 neighbour to the west (so it is stable at 2), + // on solid ground. It should spread at level 3 into its other open neighbours. + view.set(p(-1, 64, 0), fluid_block(FluidKind::Water, 1)); + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 2)); view.set(p(0, 63, 0), block!("stone")); let changes = compute_fluid_tick(p(0, 64, 0), &view, water_rules()); - // Ground below is solid and the block is supported, so it spreads sideways at level 4. - assert_eq!(changes.len(), 4); - for c in &changes { - assert_eq!(c.new_block, fluid_block(FluidKind::Water, 4)); - } + let spread: Vec<_> = changes + .iter() + .filter(|c| c.new_block == fluid_block(FluidKind::Water, 3)) + .collect(); + assert_eq!( + spread.len(), + 3, + "should spread at level 3 into 3 open neighbours" + ); + // The feeder is never overwritten. + assert!(changes.iter().all(|c| c.pos != p(-1, 64, 0))); } #[test] fn does_not_spread_past_max_level() { let mut view = MapView::new(); - // A supported block at the rules' max level: next level would exceed the cap, so no - // spread. + // A level-7 block fed by a level-6 neighbour to the west (stable at 7) on solid ground. + // The next step would be 8 > 7, so it must neither recede nor spread. let rules = water_rules(); - view.set(p(0, 65, 0), fluid_block(FluidKind::Water, 0)); + view.set(p(-1, 64, 0), fluid_block(FluidKind::Water, 6)); view.set( p(0, 64, 0), fluid_block(FluidKind::Water, rules.max_spread_level), @@ -286,7 +441,8 @@ mod tests { let changes = compute_fluid_tick(p(0, 64, 0), &view, rules); assert!( changes.is_empty(), - "fluid at max level should not spread further" + "fluid at max level should not spread further, got {:?}", + changes ); } @@ -371,21 +527,21 @@ mod tests { fn overworld_lava_caps_spread_short_of_water() { use crate::dimension::Dimension; - // Overworld lava: level_step = 2, max_spread_level = 7. From a source we expect - // level 2 -> 4 -> 6 to be reachable (3 blocks); a level-6 block must not spread further - // because 6 + 2 = 8 > 7. + // Overworld lava: level_step = 2, max_spread_level = 7. A level-6 block fed by a level-4 + // neighbour (4 + 2 = 6, stable) must not spread further, because 6 + 2 = 8 > 7. let lava = FluidRules::for_kind(FluidKind::Lava, Dimension::Overworld); assert_eq!(lava.level_step, 2); let mut view = MapView::new(); - view.set(p(0, 65, 0), fluid_block(FluidKind::Lava, 0)); // support from above + view.set(p(-1, 64, 0), fluid_block(FluidKind::Lava, 4)); // horizontal feeder view.set(p(0, 64, 0), fluid_block(FluidKind::Lava, 6)); view.set(p(0, 63, 0), block!("stone")); let changes = compute_fluid_tick(p(0, 64, 0), &view, lava); assert!( changes.is_empty(), - "overworld lava at level 6 should stop (next would be 8, > 7)" + "overworld lava at level 6 should stop (next would be 8, > 7), got {:?}", + changes ); } @@ -393,20 +549,209 @@ mod tests { fn nether_lava_spreads_like_water() { use crate::dimension::Dimension; - // Nether lava: level_step = 1, max_spread_level = 7 — same reach as water. + // Nether lava: level_step = 1, max_spread_level = 7 — same reach as water. A level-6 block + // fed by a level-5 neighbour (stable at 6) can still take one more step to level 7. let lava = FluidRules::for_kind(FluidKind::Lava, Dimension::Nether); assert_eq!(lava.level_step, 1); let mut view = MapView::new(); - view.set(p(0, 65, 0), fluid_block(FluidKind::Lava, 0)); + view.set(p(-1, 64, 0), fluid_block(FluidKind::Lava, 5)); // horizontal feeder view.set(p(0, 64, 0), fluid_block(FluidKind::Lava, 6)); view.set(p(0, 63, 0), block!("stone")); let changes = compute_fluid_tick(p(0, 64, 0), &view, lava); - // Nether lava can still take one more step from level 6 -> 7. - assert_eq!(changes.len(), 4); - for c in &changes { - assert_eq!(c.new_block, fluid_block(FluidKind::Lava, 7)); + let spread: Vec<_> = changes + .iter() + .filter(|c| c.new_block == fluid_block(FluidKind::Lava, 7)) + .collect(); + // West is the feeder; the other three horizontal neighbours get level 7. + assert_eq!(spread.len(), 3); + } + + // --- Receding behaviour (the source-removal scenario). --- + + #[test] + fn flowing_recedes_one_level_when_feeder_weakens() { + let mut view = MapView::new(); + // A level-1 block whose only feeder is a level-3 neighbour to the west. The strongest feed + // is 3 + 1 = 4, so this block should recede from 1 to 4 (weaker), not vanish. + view.set(p(-1, 64, 0), fluid_block(FluidKind::Water, 3)); + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 1)); + view.set(p(0, 63, 0), block!("stone")); + + let changes = compute_fluid_tick(p(0, 64, 0), &view, water_rules()); + let self_change = changes + .iter() + .find(|c| c.pos == p(0, 64, 0)) + .expect("the block should re-derive its level"); + assert_eq!( + self_change.new_block, + fluid_block(FluidKind::Water, 4), + "should recede to feeder level + step, not dry up" + ); + assert!(self_change.reschedule); + } + + #[test] + fn recede_only_changes_self_now_caller_wakes_neighbours() { + let mut view = MapView::new(); + // Level-1 block with feeders: level-3 to the west (feeds 4) and level-2 to the east + // (feeds 3). The strongest (lowest) feed wins, so it re-derives to level 3. The pure + // function changes only this block; waking neighbours is the caller's responsibility, so + // no neighbour entries appear here. + view.set(p(-1, 64, 0), fluid_block(FluidKind::Water, 3)); + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 1)); + view.set(p(1, 64, 0), fluid_block(FluidKind::Water, 2)); + view.set(p(0, 63, 0), block!("stone")); + + let changes = compute_fluid_tick(p(0, 64, 0), &view, water_rules()); + assert_eq!( + changes.len(), + 1, + "only the ticking block changes: {:?}", + changes + ); + assert_eq!(changes[0].pos, p(0, 64, 0)); + assert_eq!( + changes[0].new_block, + fluid_block(FluidKind::Water, 3), + "re-derives to strongest feeder (east level 2 -> 3)" + ); + assert!(changes[0].reschedule); + } + + #[test] + fn isolated_flowing_block_dries_up() { + let mut view = MapView::new(); + // A level-1 block whose only fluid neighbour is at the max level (7 + 1 = 8 > cap, so it + // cannot feed). With no valid feeder the block dries up. (Neighbour waking is the caller's + // job and is verified in the integration tests.) + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 1)); + view.set(p(1, 64, 0), fluid_block(FluidKind::Water, 7)); + view.set(p(0, 63, 0), block!("stone")); + + let changes = compute_fluid_tick(p(0, 64, 0), &view, water_rules()); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].pos, p(0, 64, 0)); + assert_eq!(changes[0].new_block, block!("air")); + assert!(!changes[0].reschedule); + } + + // --- Lava/water interaction. --- + // + // Vanilla splits this into two mechanics: + // * in-place harden — lava with water above or beside it (never just below): source -> + // obsidian, flowing -> cobblestone; + // * down-flow — lava directly above water turns that water into stone. + + #[test] + fn lava_source_with_water_beside_becomes_obsidian() { + use crate::dimension::Dimension; + let lava = FluidRules::for_kind(FluidKind::Lava, Dimension::Overworld); + + let mut view = MapView::new(); + view.set(p(0, 64, 0), fluid_block(FluidKind::Lava, 0)); // lava source + view.set(p(1, 64, 0), fluid_block(FluidKind::Water, 3)); // water beside it + + let changes = compute_fluid_tick(p(0, 64, 0), &view, lava); + let self_change = changes.iter().find(|c| c.pos == p(0, 64, 0)).unwrap(); + assert_eq!(self_change.new_block, block!("obsidian")); + assert!( + self_change.fizz, + "solidification should flag the fizz effect" + ); + } + + #[test] + fn lava_source_with_water_above_becomes_obsidian() { + use crate::dimension::Dimension; + let lava = FluidRules::for_kind(FluidKind::Lava, Dimension::Overworld); + + // Water directly above the lava source: the lava (lower cell) hardens to obsidian. + let mut view = MapView::new(); + view.set(p(0, 65, 0), fluid_block(FluidKind::Water, 0)); // water above + view.set(p(0, 64, 0), fluid_block(FluidKind::Lava, 0)); // lava source below + + let changes = compute_fluid_tick(p(0, 64, 0), &view, lava); + let self_change = changes.iter().find(|c| c.pos == p(0, 64, 0)).unwrap(); + assert_eq!(self_change.new_block, block!("obsidian")); + } + + #[test] + fn flowing_lava_with_water_beside_becomes_cobblestone() { + use crate::dimension::Dimension; + let lava = FluidRules::for_kind(FluidKind::Lava, Dimension::Overworld); + + // Flowing lava beside water always hardens to cobblestone, regardless of the water's level + // (this is the in-place harden rule, not the down-flow "stone" rule). + for water_level in [0u8, 5] { + let mut view = MapView::new(); + view.set(p(0, 64, 0), fluid_block(FluidKind::Lava, 2)); // flowing lava + view.set(p(1, 64, 0), fluid_block(FluidKind::Water, water_level)); + + let changes = compute_fluid_tick(p(0, 64, 0), &view, lava); + let self_change = changes.iter().find(|c| c.pos == p(0, 64, 0)).unwrap(); + assert_eq!( + self_change.new_block, + block!("cobblestone"), + "flowing lava beside water (level {water_level}) should be cobblestone" + ); } } + + #[test] + fn lava_flowing_down_onto_water_turns_the_water_to_stone() { + use crate::dimension::Dimension; + let lava = FluidRules::for_kind(FluidKind::Lava, Dimension::Overworld); + + // Lava (upper) directly above water (lower). Ticking the lava must turn the LOWER cell + // (the water) into stone — not the upper lava. + let mut view = MapView::new(); + view.set(p(0, 65, 0), fluid_block(FluidKind::Lava, 2)); // flowing lava above + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 0)); // water source below + + let changes = compute_fluid_tick(p(0, 65, 0), &view, lava); + let lower = changes + .iter() + .find(|c| c.pos == p(0, 64, 0)) + .expect("the lower (water) cell should change"); + assert_eq!(lower.new_block, block!("stone")); + assert!(lower.fizz); + // The upper lava itself is not turned to rock by this rule. + assert!(changes.iter().all(|c| !(c.pos == p(0, 65, 0) && c.fizz))); + } + + #[test] + fn water_touching_lava_is_unaffected_itself() { + // The reaction only solidifies lava; the water block keeps behaving like water. Ticking + // the water beside lava should not turn the water into rock, nor flow into the lava cell. + let mut view = MapView::new(); + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 0)); // water source + view.set(p(1, 64, 0), fluid_block(FluidKind::Lava, 2)); // flowing lava beside it + view.set(p(0, 63, 0), block!("stone")); + + let changes = compute_fluid_tick(p(0, 64, 0), &view, water_rules()); + assert!(changes.iter().all(|c| c.pos != p(0, 64, 0))); + assert!( + changes.iter().all(|c| c.pos != p(1, 64, 0)), + "water should not flow into the opposite fluid; that is the reaction's job" + ); + } + + #[test] + fn water_above_lava_hardens_the_lava_not_a_lower_cell() { + use crate::dimension::Dimension; + let lava = FluidRules::for_kind(FluidKind::Lava, Dimension::Overworld); + + // Symmetry check for the "下面的流体总是变化" report: water above flowing lava must harden + // the lava (the cell with water above), here at y=64, into cobblestone. + let mut view = MapView::new(); + view.set(p(0, 65, 0), fluid_block(FluidKind::Water, 0)); // water above + view.set(p(0, 64, 0), fluid_block(FluidKind::Lava, 2)); // flowing lava below + view.set(p(0, 63, 0), block!("stone")); // solid floor so lava can't fall + + let changes = compute_fluid_tick(p(0, 64, 0), &view, lava); + let self_change = changes.iter().find(|c| c.pos == p(0, 64, 0)).unwrap(); + assert_eq!(self_change.new_block, block!("cobblestone")); + } } diff --git a/src/lib/world/src/fluid/tests_integration.rs b/src/lib/world/src/fluid/tests_integration.rs index 55fff8e83..ac3f2c575 100644 --- a/src/lib/world/src/fluid/tests_integration.rs +++ b/src/lib/world/src/fluid/tests_integration.rs @@ -8,10 +8,11 @@ use crate::block_state_id::BlockStateId; use crate::dimension::Dimension; -use crate::fluid::spread::{compute_fluid_tick, BlockView}; -use crate::fluid::{fluid_block, fluid_state, FluidKind, FluidRules}; +use crate::fluid::spread::BlockView; +use crate::fluid::{compute_tick, fluid_block, fluid_state, FluidKind, FluidRules}; use crate::pos::BlockPos; use crate::scheduler::{BlockTickScheduler, TickKind}; +use ferrumc_config::server_config::FluidAlgorithm; use ferrumc_macros::block; use std::collections::HashMap; @@ -49,14 +50,33 @@ fn p(x: i32, y: i32, z: i32) -> BlockPos { BlockPos::of(x, y, z) } -/// Runs the full scheduler-driven fluid loop for `ticks` game ticks, returning the number of block -/// changes applied across the whole run. Mirrors the server's `process_fluid_ticks`: each due tick -/// looks up the rules for the fluid currently at that position and feeds them to the algorithm. +/// Runs the full scheduler-driven fluid loop for `ticks` game ticks using the simplified kernel, +/// returning the number of block changes applied. Convenience wrapper over [`run_with`]. fn run( world: &mut MapWorld, scheduler: &mut BlockTickScheduler, start_tick: u64, ticks: u64, +) -> usize { + run_with( + world, + scheduler, + FluidAlgorithm::Simplified, + start_tick, + ticks, + ) +} + +/// Runs the full scheduler-driven fluid loop for `ticks` game ticks with the chosen kernel, +/// returning the number of block changes applied across the whole run. Mirrors the server's +/// `process_fluid_ticks`: each due tick looks up the rules for the fluid currently at that +/// position and dispatches to the selected algorithm, then wakes fluid neighbours. +fn run_with( + world: &mut MapWorld, + scheduler: &mut BlockTickScheduler, + algorithm: FluidAlgorithm, + start_tick: u64, + ticks: u64, ) -> usize { // All integration tests in this file run in the overworld, where water and lava use their // own rules. The reschedule delay mirrors whichever fluid produced the change. @@ -79,12 +99,13 @@ fn run( continue; }; let rules = FluidRules::for_kind(state.kind, DIM); - changes.extend(compute_fluid_tick(tick.pos, &&*world, rules)); + changes.extend(compute_tick(algorithm, tick.pos, &&*world, rules)); } } - // Write phase: apply changes and re-schedule, using the rules for whatever the new block - // is (the changed block, not the source position, drives the follow-up cadence). + // Write phase: apply each change, re-schedule the changed block if it asked, and wake the + // changed cell's fluid neighbours so updates ripple outward (mirrors the server's + // `apply_changes`, which performs the same neighbour propagation). for change in &changes { world.set(change.pos, change.new_block); total_changes += 1; @@ -96,6 +117,12 @@ fn run( .unwrap_or_else(|| FluidRules::for_kind(FluidKind::Water, DIM).tick_delay); scheduler.schedule(change.pos, TickKind::FluidSpread, current, delay); } + for neighbour in crate::fluid::spread::fluid_neighbours(change.pos) { + if let Some(state) = fluid_state(world.get(neighbour)) { + let delay = FluidRules::for_kind(state.kind, DIM).tick_delay; + scheduler.schedule(neighbour, TickKind::FluidSpread, current, delay); + } + } } } @@ -250,3 +277,448 @@ fn steady_state_stops_rescheduling() { "scheduler should drain to empty once fluid settles" ); } + +/// Removing the source must make the *whole* flowing body recede level-by-level and ultimately +/// disappear, not just blank the ring next to the source. This is the regression the receding +/// rewrite targets. +#[test] +fn removing_source_drains_entire_flow() { + let mut world = MapWorld::new(); + let mut scheduler = BlockTickScheduler::new(); + + // Flat floor with a source at the centre; let it spread out fully first. + for x in -8..=8 { + for z in -8..=8 { + world.set(p(x, 63, z), block!("stone")); + } + } + let source = p(0, 64, 0); + world.set(source, fluid_block(FluidKind::Water, 0)); + scheduler.schedule(source, TickKind::FluidSpread, 0, 5); + run(&mut world, &mut scheduler, 0, 200); + + // Sanity: water has spread several blocks out from the source. + assert!( + fluid_state(world.get(p(3, 64, 0))).is_some(), + "precondition: water should have spread before source removal" + ); + + // Remove the source and re-tick its position (as a bucket pickup / block break would). + world.set(source, block!("air")); + scheduler.schedule(source, TickKind::FluidSpread, 200, 0); + // Also wake the immediate neighbours so the drain kicks off, mirroring seed_on_block_break. + for n in [p(1, 64, 0), p(-1, 64, 0), p(0, 64, 1), p(0, 64, -1)] { + scheduler.schedule(n, TickKind::FluidSpread, 200, 0); + } + run(&mut world, &mut scheduler, 200, 600); + + // Every flowing-water cell should be gone; nothing flowing should remain anywhere on the floor. + let mut remaining = Vec::new(); + for x in -8..=8 { + for z in -8..=8 { + if fluid_state(world.get(p(x, 64, z))).is_some() { + remaining.push((x, z)); + } + } + } + assert!( + remaining.is_empty(), + "all flowing water should drain after the source is removed, but these cells remain: {:?}", + remaining + ); + // And the scheduler should be idle again. + assert_eq!( + scheduler.pending_count(), + 0, + "scheduler should settle after draining" + ); +} + +// ============================================================================================= +// Vanilla-kernel stress tests: complex terrain that triggers deep, cascading multi-level updates. +// ============================================================================================= + +/// Seeds the source and every fluid neighbour set so the loop has work to do from tick 0. +fn seed(scheduler: &mut BlockTickScheduler, pos: BlockPos) { + scheduler.schedule(pos, TickKind::FluidSpread, 0, 0); +} + +/// Counts fluid cells of `kind` in a cuboid (inclusive bounds). Used to assert on the shape of a +/// settled flow without pinning every individual cell. +fn count_fluid( + world: &MapWorld, + kind: FluidKind, + x: (i32, i32), + y: (i32, i32), + z: (i32, i32), +) -> usize { + let mut n = 0; + for cx in x.0..=x.1 { + for cy in y.0..=y.1 { + for cz in z.0..=z.1 { + if let Some(s) = fluid_state(world.get(p(cx, cy, cz))) { + if s.kind == kind { + n += 1; + } + } + } + } + } + n +} + +/// A multi-level staircase basin: water poured at the top must cascade down several ledges, each +/// drop re-triggering spread on the level below. A source-fed cascade is perpetually animated +/// (standing waterfalls between ledges), so this asserts the cascade reaches the bottom and that +/// per-tick activity stays *bounded* (no runaway feedback), rather than reaching zero changes. +/// This is the "complex terrain → many cascading updates" stress case, run through the vanilla +/// slope kernel. +#[test] +fn vanilla_water_cascades_down_a_staircase_with_bounded_activity() { + let mut world = MapWorld::new(); + let mut scheduler = BlockTickScheduler::new(); + + // Build a 4-step descending staircase along +x. Each step is a 3-wide (z = -1..=1) ledge, + // one block lower than the previous, with a back wall so water must flow forward (+x) and + // spill onto the next step. + // + // step i occupies x in [i*3 .. i*3+2], floor at y = 66 - i. + for i in 0..4 { + let floor_y = 66 - i; + let x0 = i * 3; + for x in x0..x0 + 3 { + for z in -1..=1 { + world.set(p(x, floor_y, z), block!("stone")); + } + } + // Side walls (z = -2 and z = 2) so the flow stays in the channel. + for x in x0..x0 + 3 { + world.set(p(x, floor_y + 1, -2), block!("stone")); + world.set(p(x, floor_y + 1, 2), block!("stone")); + } + } + // End wall after the last step so water pools on the bottom ledge instead of pouring off the + // edge of the world (which would never settle). + for y in 63..=66 { + for z in -2..=2 { + world.set(p(12, y, z), block!("stone")); + } + } + // A back wall behind the source so it cannot flow -x off the top step. It must span the full + // channel width (z = -1..=1, matching the side walls at z = +-2) — sealing only z = 0 would + // leave the corners (-1, *, +-1) open, and the slope search would steer the source around the + // corner into that void (distance 2) instead of down the staircase (distance 3). + for z in -1..=1 { + world.set(p(-1, 67, z), block!("stone")); + world.set(p(-1, 66, z), block!("stone")); + } + + // Pour a water source at the top step. + let source = p(0, 67, 0); + world.set(source, fluid_block(FluidKind::Water, 0)); + seed(&mut scheduler, source); + + // Run long enough for the cascade to reach the bottom step. + run_with(&mut world, &mut scheduler, FluidAlgorithm::Vanilla, 0, 2000); + + // Water must have reached the lowest step (floor y = 63), proving the cascade propagated all + // the way down through every ledge's spill. + let bottom_wet = count_fluid(&world, FluidKind::Water, (9, 11), (64, 64), (-1, 1)); + assert!( + bottom_wet > 0, + "water should have cascaded down to the bottom step, found none there" + ); + + // The source is untouched. + assert!(fluid_state(world.get(source)).unwrap().is_source()); + + // A source-fed cascade has standing waterfalls between ledges, so it is perpetually animated; + // we do not assert zero-change steady state here. What we DO require is that the work stays + // *bounded* — the scheduler must not blow up tick over tick (a runaway would mean a feedback + // loop). Compare the change volume of two equal-length windows well after the initial fill; + // they should be in the same ballpark, not growing. + let window_a = run_with( + &mut world, + &mut scheduler, + FluidAlgorithm::Vanilla, + 2000, + 200, + ); + let window_b = run_with( + &mut world, + &mut scheduler, + FluidAlgorithm::Vanilla, + 2200, + 200, + ); + assert!( + window_b <= window_a * 2 + 8, + "cascade activity should stay bounded, not grow (a={window_a}, b={window_b})" + ); +} + +/// A pit with a single drain hole off to one side: water poured in the middle of a large flat +/// floor should steer toward the hole (vanilla slope search) and pour down it, rather than filling +/// the whole floor uniformly. Stresses repeated slope searches over many cells. +#[test] +fn vanilla_flow_steers_into_a_single_drain_and_settles() { + let mut world = MapWorld::new(); + let mut scheduler = BlockTickScheduler::new(); + + // 11x11 solid floor at y=63, walls around the rim at y=64 so water can't escape sideways. + for x in -5..=5 { + for z in -5..=5 { + world.set(p(x, 63, z), block!("stone")); + if x.abs() == 5 || z.abs() == 5 { + world.set(p(x, 64, z), block!("stone")); + } + } + } + // One drain hole within water's reach (max horizontal reach is 7 blocks): put it at (3,_,2), + // Manhattan distance 5 from the source. Remove the floor there and give the column a floor far + // below so it terminates. + world.blocks.remove(&(3, 63, 2)); + world.set(p(3, 60, 2), block!("stone")); + + let source = p(0, 64, 0); + world.set(source, fluid_block(FluidKind::Water, 0)); + seed(&mut scheduler, source); + + run_with(&mut world, &mut scheduler, FluidAlgorithm::Vanilla, 0, 4000); + + // Water should have found and used the drain: the column below the hole is wet. + let drained = count_fluid(&world, FluidKind::Water, (3, 3), (60, 62), (2, 2)); + assert!( + drained > 0, + "water should have flowed down the drain hole, found none in the drain column" + ); + + // Steering check: water should NOT have filled the far corner away from the drain. The + // opposite corner from the drain (-5,-5) sits 13 blocks away by path and is beyond a steered + // flow's reach, so it must stay dry while the drain keeps pulling flow. + assert!( + fluid_state(world.get(p(-5, 64, -5))).is_none(), + "water should steer toward the drain, not fill the far corner" + ); + // A pit with an open drain is perpetually animated (water keeps flowing in and falling), so we + // intentionally do NOT assert steady state here — that is correct vanilla behaviour. +} + +/// A 2x2 water pool on solid ground, with one cell broken out, must heal back to four sources via +/// infinite-source formation — then stay settled. Exercises source formation inside the full loop. +#[test] +fn vanilla_two_by_two_pool_heals_to_infinite_source() { + let mut world = MapWorld::new(); + let mut scheduler = BlockTickScheduler::new(); + + // Solid floor under a 2x2 area at y=64; sources at three corners, the fourth left as flowing. + for x in 0..=1 { + for z in 0..=1 { + world.set(p(x, 63, z), block!("stone")); + } + } + // Walls around so nothing escapes. + for x in -1..=2 { + for z in -1..=2 { + if !(0..=1).contains(&x) || !(0..=1).contains(&z) { + world.set(p(x, 64, z), block!("stone")); + world.set(p(x, 63, z), block!("stone")); + } + } + } + world.set(p(0, 64, 0), fluid_block(FluidKind::Water, 0)); + world.set(p(1, 64, 0), fluid_block(FluidKind::Water, 0)); + world.set(p(0, 64, 1), fluid_block(FluidKind::Water, 0)); + // The fourth cell starts as flowing water (as if a source was just scooped out). + world.set(p(1, 64, 1), fluid_block(FluidKind::Water, 1)); + + seed(&mut scheduler, p(1, 64, 1)); + for n in crate::fluid::spread::fluid_neighbours(p(1, 64, 1)) { + scheduler.schedule(n, TickKind::FluidSpread, 0, 0); + } + + run_with(&mut world, &mut scheduler, FluidAlgorithm::Vanilla, 0, 200); + + // All four cells should now be sources (the pool healed). + for (x, z) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let s = fluid_state(world.get(p(x, 64, z))) + .unwrap_or_else(|| panic!("cell ({x},{z}) should hold water")); + assert!( + s.is_source(), + "2x2 pool cell ({x},{z}) should have healed to a source, was level {}", + s.level + ); + } + + let after = run_with( + &mut world, + &mut scheduler, + FluidAlgorithm::Vanilla, + 200, + 200, + ); + assert_eq!( + after, 0, + "healed pool must be stable, {after} further changes" + ); +} + +/// Minimal reproduction of a single ledge: a water source on an upper floor whose edge drops one +/// block to a lower floor. The flow off the edge must settle, not oscillate. (Diagnostic for the +/// staircase non-convergence.) +#[test] +fn vanilla_single_ledge_settles() { + let mut world = MapWorld::new(); + let mut scheduler = BlockTickScheduler::new(); + + // Upper floor at y=64 for x in 0..=1; lower floor at y=63 for x in 2..=5, with an end wall at + // x=6 so water pools on the lower floor instead of pouring off the end of the world. + for x in 0..=1 { + world.set(p(x, 64, 0), block!("stone")); + } + for x in 2..=5 { + world.set(p(x, 63, 0), block!("stone")); + } + // Narrow channel: walls at z=-1 and z=1 along the whole run, both heights. + for x in 0..=6 { + for y in 63..=66 { + world.set(p(x, y, -1), block!("stone")); + world.set(p(x, y, 1), block!("stone")); + } + } + // End wall so the lower floor is a closed basin. + for y in 63..=66 { + world.set(p(6, y, 0), block!("stone")); + } + // Back wall behind source. + world.set(p(-1, 65, 0), block!("stone")); + world.set(p(-1, 66, 0), block!("stone")); + + let source = p(0, 65, 0); + world.set(source, fluid_block(FluidKind::Water, 0)); + seed(&mut scheduler, source); + + run_with(&mut world, &mut scheduler, FluidAlgorithm::Vanilla, 0, 1000); + let after = run_with( + &mut world, + &mut scheduler, + FluidAlgorithm::Vanilla, + 1000, + 200, + ); + assert_eq!( + after, 0, + "a single ledge must settle, but {after} further changes occurred (oscillation)" + ); +} + +/// An open flat platform (edges drop off into the void) with a water source in the middle. The +/// **top layer** must reach a stable shape: interior cells settle to a fixed level gradient and the +/// rim cells settle to air (water that reached the rim falls off and is not re-spread). Regression +/// for the max-level edge oscillation where rim cells flip-flopped between max-level water and air +/// every tick forever. We assert the top layer stops changing; the falling columns below the rim +/// are intentionally ignored (an edge waterfall is perpetually animated, which is correct). +#[test] +fn vanilla_open_platform_top_layer_settles() { + use std::collections::HashMap; + + let mut world = MapWorld::new(); + let mut scheduler = BlockTickScheduler::new(); + for x in -4..=4 { + for z in -4..=4 { + world.set(p(x, 63, z), block!("stone")); + } + } + let source = p(0, 64, 0); + world.set(source, fluid_block(FluidKind::Water, 0)); + scheduler.schedule(source, TickKind::FluidSpread, 0, 0); + + // Let it spread out and reach the rim. + run_with(&mut world, &mut scheduler, FluidAlgorithm::Vanilla, 0, 400); + + // Snapshot the entire top layer (y = 64) over the platform plus a one-block rim margin. + let snapshot = |w: &MapWorld| -> HashMap<(i32, i32), u32> { + let mut m = HashMap::new(); + for x in -5..=5 { + for z in -5..=5 { + m.insert((x, z), w.get(p(x, 64, z)).raw()); + } + } + m + }; + let before = snapshot(&world); + // Run more ticks; the top layer must not change at all. + run_with( + &mut world, + &mut scheduler, + FluidAlgorithm::Vanilla, + 400, + 200, + ); + let after = snapshot(&world); + + let mut changed: Vec<_> = before + .iter() + .filter(|(k, v)| after.get(*k) != Some(*v)) + .map(|(k, _)| *k) + .collect(); + changed.sort(); + assert!( + changed.is_empty(), + "open-platform top layer must stabilise, but these cells kept changing: {:?}", + changed + ); +} + +/// Regression for steering that broke after the first ring filled: a source with a hole within +/// range must keep steering toward the hole on every tick, not fan out to the other directions +/// once the downhill neighbour is already wet. Reproduces the user's "tick 1 correct, tick 2 fans +/// out" report. Drives the loop tick by tick and asserts no water ever appears on the side away +/// from the hole while the hole is still being approached. +#[test] +fn vanilla_keeps_steering_after_first_ring_fills() { + let mut world = MapWorld::new(); + let mut scheduler = BlockTickScheduler::new(); + + // Large flat floor so the floor edges are far outside slope range (no phantom holes). + for x in 0..=20 { + for z in 0..=20 { + world.set(p(x, 63, z), block!("stone")); + } + } + // A single hole three blocks east of the source, on the same z row. + world.set(p(13, 62, 10), block!("stone")); // catch floor for the pit + world.blocks.remove(&(13, 63, 10)); + + let source = p(10, 64, 10); + world.set(source, fluid_block(FluidKind::Water, 0)); + scheduler.schedule(source, TickKind::FluidSpread, 0, 0); + + // Run enough ticks for the flow to travel the three blocks east and start falling in. + run_with(&mut world, &mut scheduler, FluidAlgorithm::Vanilla, 0, 60); + + // The flow must have reached the hole column (water fell in). + assert!( + fluid_state(world.get(p(13, 63, 10))).is_some() + || fluid_state(world.get(p(13, 62, 10))).is_none(), // (catch floor stays solid) + "water should have reached and entered the hole" + ); + assert!( + fluid_state(world.get(p(11, 64, 10))).is_some() + && fluid_state(world.get(p(12, 64, 10))).is_some(), + "water should have flowed east toward the hole" + ); + + // Crucially: while steering toward the hole, the source must NOT have fanned out west, since + // west leads nowhere (no hole in range). The cell directly west of the source stays dry. + assert!( + fluid_state(world.get(p(9, 64, 10))).is_none(), + "water must not fan out away from the hole (west of source should be dry)" + ); + // Nor straight north/south of the source one block out (those directions have no hole either). + assert!( + fluid_state(world.get(p(10, 64, 9))).is_none() + && fluid_state(world.get(p(10, 64, 11))).is_none(), + "water must not fan out north/south of the source while steering toward the hole" + ); +} diff --git a/src/lib/world/src/fluid/vanilla.rs b/src/lib/world/src/fluid/vanilla.rs new file mode 100644 index 000000000..5cf42a024 --- /dev/null +++ b/src/lib/world/src/fluid/vanilla.rs @@ -0,0 +1,777 @@ +//! Vanilla-faithful fluid spreading kernel (work in progress). +//! +//! This is the second of two interchangeable spreading kernels. The first, +//! [`crate::fluid::spread::compute_fluid_tick`], is a cheap approximation; this one mirrors +//! Minecraft's `FlowingFluid` logic, including the bounded "slope search" (`getSlopeDistance`) +//! that makes fluid steer toward the nearest hole instead of spreading uniformly. +//! +//! It deliberately shares the same seams as the simplified kernel so the two can be A/B compared +//! and swapped without touching the caller: +//! * input is a read-only [`BlockView`] plus the ticking [`BlockPos`] and the [`FluidRules`]; +//! * output is a `Vec` of *real* mutations only (neighbour waking stays the caller's +//! job); +//! * the lava/water solidification rules and the `fizz` flag are identical (reused from +//! [`crate::fluid::spread`]). +//! +//! # Level model +//! +//! Vanilla encodes a flowing fluid's "amount" as 1..=8 where 8 is full (just below a source) and 1 +//! is the thinnest, and uses a separate `falling` flag. FerrumC's block model instead stores a +//! `level` 0..=15 where 0 is the source and higher is thinner (see [`FluidState`]). To stay within +//! the existing block mapping we keep FerrumC's convention and express "amount" as +//! `max_spread_level + 1 - level` internally only where the slope comparison needs a magnitude. +//! +//! # Determinism +//! +//! The slope search and the spread decision must be order-independent so the parallel and serial +//! evaluators agree (the parity test is the guard). Every neighbour iteration uses the fixed +//! [`HORIZONTAL`] order, and ties in the slope search are broken by that same order. + +use crate::block_state_id::BlockStateId; +use crate::fluid::spread::{ + is_replaceable_by, lava_harden_check, lava_water_down_reaction, BlockView, FluidChange, + HORIZONTAL, +}; +use crate::fluid::{fluid_block, fluid_state, FluidKind, FluidRules}; +use crate::pos::BlockPos; +use ferrumc_macros::block; + +fn below(pos: BlockPos) -> BlockPos { + pos + (0, -1, 0) +} +fn above(pos: BlockPos) -> BlockPos { + pos + (0, 1, 0) +} + +/// Whether fluid of `kind` can flow *down* into the block below `pos` (used to decide if a +/// position counts as a "hole" the slope search should steer toward). +fn can_flow_down_into(kind: FluidKind, pos: BlockPos, view: &V) -> bool { + is_replaceable_by(kind, view.block_at(below(pos))) +} + +/// Returns the shortest horizontal distance (in blocks, 1..=`limit`) from `from` to a cell where +/// the fluid could fall downward, searching only through cells the fluid could actually flow +/// across. Returns `limit` if no hole is found within range — matching vanilla, which treats +/// "no hole in range" as the maximum distance so flat spreading still happens. +/// +/// This is a bounded breadth-first search (vanilla's `getSlopeDistance` is the recursive +/// equivalent). `origin` is excluded from being treated as its own hole. +fn slope_distance( + kind: FluidKind, + origin: BlockPos, + from: BlockPos, + limit: u8, + view: &V, +) -> u8 { + use std::collections::HashSet; + use std::collections::VecDeque; + + // `from` is one block out from the origin already, so it sits at depth 1. + let mut visited: HashSet<(i32, i32, i32)> = HashSet::new(); + visited.insert((origin.pos.x, origin.pos.y, origin.pos.z)); + visited.insert((from.pos.x, from.pos.y, from.pos.z)); + + // If the starting cell itself is a hole, distance is 1. + if can_flow_down_into(kind, from, view) { + return 1; + } + + let mut queue: VecDeque<(BlockPos, u8)> = VecDeque::new(); + queue.push_back((from, 1)); + + while let Some((cell, depth)) = queue.pop_front() { + if depth >= limit { + // Cannot expand further; this branch contributes no hole within range. + continue; + } + for &offset in HORIZONTAL.iter() { + let next = cell + offset; + let key = (next.pos.x, next.pos.y, next.pos.z); + if !visited.insert(key) { + continue; + } + // Can only flow across cells that are open to this fluid. + if !is_replaceable_by(kind, view.block_at(next)) { + continue; + } + let next_depth = depth + 1; + if can_flow_down_into(kind, next, view) { + // BFS guarantees the first hole reached is at the minimum distance. + return next_depth; + } + queue.push_back((next, next_depth)); + } + } + + // No hole within range. Return a sentinel strictly larger than any real distance so a real + // hole found at exactly `limit` is still preferred over flat ground (which returns this). + NO_HOLE +} + +/// Sentinel returned by [`slope_distance`] when no hole is reachable within the search limit. +/// Larger than any real distance so "found a hole at the max distance" still beats "no hole". +const NO_HOLE: u8 = u8::MAX; + +/// The level a freshly spread flowing block would take when fed from a block of `source_level` +/// (or from a source, when `source_level` is 0): one `level_step` thinner than its feeder. +/// +/// Intentionally **not** clamped to the cap. The caller compares the result against +/// `max_spread_level` to decide whether the fluid is still thick enough to spread at all; clamping +/// here would defeat that check and let a max-level cell spread forever (an edge cell would then +/// flip-flop between max-level water and air every tick). +fn spread_level(rules: FluidRules, source_level: u8) -> u8 { + source_level.saturating_add(rules.level_step) +} + +/// Computes a single fluid block's changes for this tick, vanilla-style. +/// +/// Mirrors `FlowingFluid.tick` + `spread`: resolve lava/water reactions first, then fall straight +/// down if possible (full column), otherwise spread horizontally — but only toward the directions +/// the slope search says are closest to a hole (steering), decrementing the level by `level_step`. +/// +/// Like the simplified kernel this returns only real mutations; the caller wakes neighbours. +/// DEBUG ONLY: returns the slope distance the spread step computes for each of the four +/// horizontal directions [+x, -x, +z, -z] from `pos`, or `None` where the direction cannot be +/// spread into. Used by diagnostics and steering tests to inspect the spread decision directly. +pub fn debug_spread_distances( + pos: BlockPos, + view: &V, + rules: FluidRules, +) -> [Option; 4] { + let Some(state) = fluid_state(view.block_at(pos)) else { + return [None; 4]; + }; + let kind = state.kind; + let next_level = spread_level(rules, state.level); + let limit = rules.slope_find_distance.max(1); + let mut out = [None; 4]; + if next_level > rules.max_spread_level { + return out; + } + for (i, &offset) in HORIZONTAL.iter().enumerate() { + let n_pos = pos + offset; + if !can_search_into(kind, n_pos, view) { + continue; + } + let dd = if can_flow_down_into(kind, n_pos, view) { + 0 + } else { + slope_distance(kind, pos, n_pos, limit, view) + }; + out[i] = Some(dd); + } + out +} + +pub fn compute_fluid_tick_vanilla( + pos: BlockPos, + view: &V, + rules: FluidRules, +) -> Vec { + let Some(state) = fluid_state(view.block_at(pos)) else { + return Vec::new(); + }; + let kind = state.kind; + let mut changes = Vec::new(); + + // --- Lava/water interaction first (identical rules to the simplified kernel). --- + if kind == FluidKind::Lava { + if let Some(rock) = lava_harden_check(pos, state, view) { + changes.push(FluidChange::reaction(pos, rock)); + return changes; + } + if let Some(reaction) = lava_water_down_reaction(pos, view) { + changes.push(reaction); + return changes; + } + } + + // --- Recede / dry-up for flowing blocks that have lost their feed. --- + // The vanilla "new liquid" computation: a flowing block's level is dictated by its strongest + // horizontal feeder or by fluid above. If that drops below what the block currently is, it + // recedes one step; if no feeder remains, it dries up. (Shared with the simplified model via + // `new_liquid_level`.) + if !state.is_source() { + match new_liquid_level(pos, kind, rules, view) { + None => { + changes.push(FluidChange::flow(pos, block!("air"), false)); + return changes; + } + Some(target) if target != state.level => { + changes.push(FluidChange::flow(pos, fluid_block(kind, target), true)); + return changes; + } + Some(_) => {} + } + } + + // --- Downward flow takes priority: a full column. --- + let below_pos = below(pos); + if is_replaceable_by(kind, view.block_at(below_pos)) { + let falling = fluid_block(kind, rules.level_step.min(rules.max_spread_level)); + if view.block_at(below_pos) != falling { + changes.push(FluidChange::flow(below_pos, falling, true)); + } + return changes; + } + + // --- Horizontal spread with slope steering. --- + let next_level = spread_level(rules, state.level); + if next_level > rules.max_spread_level { + return changes; + } + + // Slope steering, in two stages so a direction that already leads to the hole keeps "claiming" + // the steer even after its immediate cell has filled: + // + // 1. For every direction whose immediate neighbour is *terrain-passable* for this fluid + // (air or same fluid — independent of whether flowing there would currently be an + // improvement), compute the slope distance to the nearest hole. This is the steering + // decision and must not depend on the transient fill state of the first cell, otherwise a + // source whose downhill neighbour is already wet would wrongly fan out the other ways. + // 2. Emit flow only to the minimum-distance direction(s) AND only where it is actually an + // improvement (`can_spread_into`). A direction that is the chosen steer but already full + // simply produces no change this tick. + // + // If no direction finds a hole they all tie at NO_HOLE and the fluid spreads uniformly (flat + // ground), matching vanilla. + let limit = rules.slope_find_distance.max(1); + let mut dir_distance: [Option; 4] = [None; 4]; + let mut min_distance = NO_HOLE; + for (i, &offset) in HORIZONTAL.iter().enumerate() { + let n_pos = pos + offset; + if !can_search_into(kind, n_pos, view) { + continue; + } + let d = if can_flow_down_into(kind, n_pos, view) { + 0 // an immediately-falling neighbour is the strongest possible attractor + } else { + slope_distance(kind, pos, n_pos, limit, view) + }; + dir_distance[i] = Some(d); + if d < min_distance { + min_distance = d; + } + } + + let outflow = fluid_block(kind, next_level); + for (i, &offset) in HORIZONTAL.iter().enumerate() { + if dir_distance[i] == Some(min_distance) { + let n_pos = pos + offset; + // Only actually place fluid where it would be an improvement; the steering above may + // have selected a direction whose first cell is already adequately filled. + if can_spread_into(kind, n_pos, next_level, view) { + changes.push(FluidChange::flow(n_pos, outflow, true)); + } + } + } + + changes +} + +/// Whether flow at `next_level` may enter `n_pos` (open to this fluid and an improvement). +fn can_spread_into( + kind: FluidKind, + n_pos: BlockPos, + next_level: u8, + view: &V, +) -> bool { + let n_block = view.block_at(n_pos); + if !is_replaceable_by(kind, n_block) { + return false; + } + match fluid_state(n_block) { + Some(n_state) if n_state.kind == kind => next_level < n_state.level, + Some(_) => false, // opposite fluid: reaction path handles it + None => true, // air + } +} + +/// Whether the slope search may *traverse* `n_pos` for this fluid: the cell is terrain-passable +/// (air or the same fluid), regardless of whether depositing flow there would currently be an +/// improvement. Used for the steering decision so an already-filled downhill cell still counts as +/// "the way to the hole" and keeps the fluid from fanning out the other directions. +fn can_search_into(kind: FluidKind, n_pos: BlockPos, view: &V) -> bool { + is_replaceable_by(kind, view.block_at(n_pos)) +} + +/// The level a flowing block should hold this tick, or `None` if it should dry up. +/// +/// Mirrors vanilla's `getNewLiquid`, in priority order: +/// 1. **Source formation** — if this fluid can form sources and the block has at least two +/// horizontally adjacent same-fluid *sources* with solid (or same-fluid source) support below, +/// it becomes a source (`Some(0)`). This is what makes a 2x2 (or two-with-a-gap) water pool +/// self-heal into infinite water. +/// 2. fluid directly above feeds a full column → near-source level; +/// 3. otherwise the strongest horizontal same-fluid neighbour (`neighbour.level + level_step`). +/// +/// Returns the strongest (lowest) supportable level within the cap, or `None` if nothing feeds it. +fn new_liquid_level( + pos: BlockPos, + kind: FluidKind, + rules: FluidRules, + view: &V, +) -> Option { + if forms_source(pos, kind, rules, view) { + return Some(0); + } + + if matches!(fluid_state(view.block_at(above(pos))), Some(s) if s.kind == kind) { + return Some(rules.level_step.min(rules.max_spread_level)); + } + + let mut best: Option = None; + for &offset in HORIZONTAL.iter() { + if let Some(state) = fluid_state(view.block_at(pos + offset)) { + if state.kind == kind { + let fed = state.level.saturating_add(rules.level_step); + if fed <= rules.max_spread_level { + best = Some(best.map_or(fed, |b| b.min(fed))); + } + } + } + } + best +} + +/// Whether the cell at `pos` should spontaneously become a source of `kind` this tick. +/// +/// Vanilla `canConvertToSource` + the source-count check in `getNewLiquid`: the fluid must allow +/// infinite sources (`rules.can_form_source`), there must be at least two horizontally adjacent +/// *source* blocks of the same fluid, and the block below must be either a same-fluid source or a +/// solid block (so the new source has something to rest on). This is how water heals back into a +/// full source between existing sources; lava never qualifies in the overworld/Nether. +fn forms_source(pos: BlockPos, kind: FluidKind, rules: FluidRules, view: &V) -> bool { + if !rules.can_form_source { + return false; + } + + let adjacent_sources = HORIZONTAL + .iter() + .filter(|&&offset| { + matches!( + fluid_state(view.block_at(pos + offset)), + Some(s) if s.kind == kind && s.is_source() + ) + }) + .count(); + if adjacent_sources < 2 { + return false; + } + + let below_block = view.block_at(below(pos)); + let below_is_same_source = + matches!(fluid_state(below_block), Some(s) if s.kind == kind && s.is_source()); + // "Solid" here means it is neither air/replaceable nor any fluid — something the source can + // rest on. Using the existing replaceability + fluid checks keeps this consistent with how the + // rest of the kernel classifies blocks without needing a full collision model. + let below_is_solid = + fluid_state(below_block).is_none() && !is_replaceable_by(kind, below_block); + + below_is_same_source || below_is_solid +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dimension::Dimension; + use std::collections::HashMap; + + /// Map-backed [`BlockView`]; missing positions read as air. + struct MapView { + blocks: HashMap<(i32, i32, i32), BlockStateId>, + } + impl MapView { + fn new() -> Self { + Self { + blocks: HashMap::new(), + } + } + fn set(&mut self, pos: BlockPos, b: BlockStateId) { + self.blocks.insert((pos.pos.x, pos.pos.y, pos.pos.z), b); + } + } + impl BlockView for MapView { + fn block_at(&self, pos: BlockPos) -> BlockStateId { + self.blocks + .get(&(pos.pos.x, pos.pos.y, pos.pos.z)) + .copied() + .unwrap_or_else(|| block!("air")) + } + } + fn p(x: i32, y: i32, z: i32) -> BlockPos { + BlockPos::of(x, y, z) + } + fn water() -> FluidRules { + FluidRules::for_kind(FluidKind::Water, Dimension::Overworld) + } + + /// A solid platform with a single hole: water on top should steer toward the hole rather than + /// spreading uniformly in all four directions. + #[test] + fn spread_steers_toward_a_hole() { + let mut view = MapView::new(); + // Large solid floor at y=63 over the whole search area, so no direction finds a hole by + // simply running off the edge of a tiny platform. + for x in -6..=6 { + for z in -6..=6 { + view.set(p(x, 63, z), block!("stone")); + } + } + // Carve ONE hole in the floor two blocks east of the source (remove the floor at (2,63,0)). + view.blocks.remove(&(2, 63, 0)); + // Water source at (0,64,0). + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 0)); + + let changes = compute_fluid_tick_vanilla(p(0, 64, 0), &view, water()); + // The nearest (only) hole is east: distance 2 going +x, larger in every other direction. + assert!( + changes.iter().any(|c| c.pos == p(1, 64, 0)), + "water should spread toward the hole (+x), changes: {:?}", + changes + ); + // It must NOT spread in the other three directions, which are strictly farther from a hole. + assert!( + changes.iter().all(|c| c.pos != p(-1, 64, 0)), + "water should not spread away from the nearest hole (west), changes: {:?}", + changes + ); + assert!( + changes + .iter() + .all(|c| c.pos != p(0, 64, 1) && c.pos != p(0, 64, -1)), + "water should not spread along z, away from the hole, changes: {:?}", + changes + ); + } + + /// On a fully flat platform with no hole in range, spread is uniform in all four directions + /// (the slope search returns the limit everywhere, so all directions tie). + #[test] + fn flat_platform_spreads_uniformly() { + let mut view = MapView::new(); + for x in -3..=3 { + for z in -3..=3 { + view.set(p(x, 63, z), block!("stone")); + } + } + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 0)); + + let changes = compute_fluid_tick_vanilla(p(0, 64, 0), &view, water()); + let spread: Vec<_> = changes.iter().filter(|c| c.pos.pos.y == 64).collect(); + assert_eq!( + spread.len(), + 4, + "flat ground should spread to all 4 sides: {:?}", + changes + ); + for c in &spread { + assert_eq!(c.new_block, fluid_block(FluidKind::Water, 1)); + } + } + + /// Source over air falls straight down and does not also spread sideways. + #[test] + fn source_over_air_falls() { + let mut view = MapView::new(); + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 0)); + let changes = compute_fluid_tick_vanilla(p(0, 64, 0), &view, water()); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].pos, p(0, 63, 0)); + } + + /// An immediately-falling neighbour (distance 0) outranks a far hole, so flow goes there. + #[test] + fn prefers_adjacent_drop_over_distant_hole() { + let mut view = MapView::new(); + // Source at origin, floored. + view.set(p(0, 63, 0), block!("stone")); + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 0)); + // East neighbour (1,64,0) has NO floor -> immediate drop (distance 0). + // West side floored for a long way with a hole far away. + for x in -4..=-1 { + view.set(p(x, 63, 0), block!("stone")); + } + view.set(p(0, 63, 1), block!("stone")); + view.set(p(0, 63, -1), block!("stone")); + + let changes = compute_fluid_tick_vanilla(p(0, 64, 0), &view, water()); + assert!( + changes.iter().any(|c| c.pos == p(1, 64, 0)), + "should flow toward the adjacent drop, changes: {:?}", + changes + ); + assert!( + changes.iter().all(|c| c.pos != p(-1, 64, 0)), + "should not flow away from the adjacent drop" + ); + } + + /// Lava reaction parity: the vanilla kernel reuses the same solidification rules. + #[test] + fn lava_reactions_match_shared_rules() { + let lava = FluidRules::for_kind(FluidKind::Lava, Dimension::Overworld); + + // flowing lava beside water -> cobblestone + let mut v = MapView::new(); + v.set(p(0, 64, 0), fluid_block(FluidKind::Lava, 2)); + v.set(p(1, 64, 0), fluid_block(FluidKind::Water, 0)); + let c = compute_fluid_tick_vanilla(p(0, 64, 0), &v, lava); + assert_eq!( + c.iter().find(|c| c.pos == p(0, 64, 0)).unwrap().new_block, + block!("cobblestone") + ); + + // lava above water -> water below becomes stone + let mut v = MapView::new(); + v.set(p(0, 65, 0), fluid_block(FluidKind::Lava, 2)); + v.set(p(0, 64, 0), fluid_block(FluidKind::Water, 0)); + let c = compute_fluid_tick_vanilla(p(0, 65, 0), &v, lava); + let lower = c.iter().find(|c| c.pos == p(0, 64, 0)).unwrap(); + assert_eq!(lower.new_block, block!("stone")); + assert!(lower.fizz); + } + + // --- Infinite source formation (vanilla canConvertToSource). --- + + /// A flowing water cell flanked by two water sources, resting on solid ground, converts into a + /// new source — the core of infinite water. + #[test] + fn flowing_water_between_two_sources_becomes_source() { + let mut view = MapView::new(); + view.set(p(0, 63, 0), block!("stone")); // solid support below the gap + view.set(p(-1, 64, 0), fluid_block(FluidKind::Water, 0)); // source west + view.set(p(1, 64, 0), fluid_block(FluidKind::Water, 0)); // source east + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 1)); // flowing in the middle + + let changes = compute_fluid_tick_vanilla(p(0, 64, 0), &view, water()); + let self_change = changes + .iter() + .find(|c| c.pos == p(0, 64, 0)) + .expect("middle cell should change"); + assert_eq!( + self_change.new_block, + fluid_block(FluidKind::Water, 0), + "flowing water between two sources on solid ground becomes a source" + ); + } + + /// Only one adjacent source is not enough to form a new source. + #[test] + fn one_source_does_not_form_a_source() { + let mut view = MapView::new(); + view.set(p(0, 63, 0), block!("stone")); + view.set(p(-1, 64, 0), fluid_block(FluidKind::Water, 0)); // single source + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 1)); + + let changes = compute_fluid_tick_vanilla(p(0, 64, 0), &view, water()); + // It is fed (stays level 1) but must NOT become a source. + assert!( + changes + .iter() + .all(|c| !(c.pos == p(0, 64, 0) && c.new_block == fluid_block(FluidKind::Water, 0))), + "a single adjacent source must not create a new source, changes: {:?}", + changes + ); + } + + /// With no solid support below (the gap sits over air), two sources still do not form a new + /// source — the cell would fall instead. + #[test] + fn no_support_below_prevents_source_formation() { + let mut view = MapView::new(); + // No floor under the middle cell; sources to either side are floored so they persist. + view.set(p(-1, 63, 0), block!("stone")); + view.set(p(1, 63, 0), block!("stone")); + view.set(p(-1, 64, 0), fluid_block(FluidKind::Water, 0)); + view.set(p(1, 64, 0), fluid_block(FluidKind::Water, 0)); + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 1)); + + let changes = compute_fluid_tick_vanilla(p(0, 64, 0), &view, water()); + // It should flow down rather than convert to a source. + assert!( + changes + .iter() + .all(|c| !(c.pos == p(0, 64, 0) && c.new_block == fluid_block(FluidKind::Water, 0))), + "no support below must prevent source formation, changes: {:?}", + changes + ); + assert!( + changes.iter().any(|c| c.pos == p(0, 63, 0)), + "the unsupported cell should flow downward instead" + ); + } + + /// Lava never forms infinite sources in the overworld, even flanked by two lava sources. + #[test] + fn lava_never_forms_a_source() { + let lava = FluidRules::for_kind(FluidKind::Lava, Dimension::Overworld); + let mut view = MapView::new(); + view.set(p(0, 63, 0), block!("stone")); + view.set(p(-1, 64, 0), fluid_block(FluidKind::Lava, 0)); + view.set(p(1, 64, 0), fluid_block(FluidKind::Lava, 0)); + view.set(p(0, 64, 0), fluid_block(FluidKind::Lava, 2)); + + let changes = compute_fluid_tick_vanilla(p(0, 64, 0), &view, lava); + assert!( + changes + .iter() + .all(|c| !(c.pos == p(0, 64, 0) && c.new_block == fluid_block(FluidKind::Lava, 0))), + "overworld lava must never form an infinite source, changes: {:?}", + changes + ); + } + + /// A flowing cell already at the maximum spread level must NOT spread further. Regression for + /// an edge-oscillation bug: when `spread_level` clamped to the cap, a max-level edge cell would + /// repeatedly spread max-level water into a neighbour that then dried up, flip-flopping every + /// tick forever. The fed level must exceed the cap so the spread is rejected. + #[test] + fn max_level_cell_does_not_spread() { + let rules = water(); + // A level-7 (max) flowing cell, fed by a level-6 neighbour to the west so it is stable, + // on solid ground with open air to the east. It must not spread east (7 + 1 = 8 > cap). + let mut view = MapView::new(); + view.set(p(-1, 64, 0), fluid_block(FluidKind::Water, 6)); + view.set( + p(0, 64, 0), + fluid_block(FluidKind::Water, rules.max_spread_level), + ); + view.set(p(0, 63, 0), block!("stone")); + view.set(p(1, 63, 0), block!("stone")); // floor east so spread (not fall) would be tested + + let changes = compute_fluid_tick_vanilla(p(0, 64, 0), &view, rules); + assert!( + changes.iter().all(|c| c.pos != p(1, 64, 0)), + "max-level water must not spread to its neighbour, changes: {:?}", + changes + ); + } + + /// On a symmetric flat platform with no hole in range, all four directions must tie and the + /// source spreads to all four. Regression for a steering bug where the recursive slope search + /// (no visited set) returned non-minimal, asymmetric distances and dropped two of the four + /// symmetric directions. + #[test] + fn symmetric_flat_spreads_all_four_directions() { + let r = water(); + let mut view = MapView::new(); + for x in -3..=3 { + for z in -3..=3 { + view.set(p(x, 63, z), block!("stone")); + } + } + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 0)); + + let changes = compute_fluid_tick_vanilla(p(0, 64, 0), &view, r); + let dirs: std::collections::HashSet<(i32, i32)> = + changes.iter().map(|c| (c.pos.pos.x, c.pos.pos.z)).collect(); + for d in [(1, 0), (-1, 0), (0, 1), (0, -1)] { + assert!( + dirs.contains(&d), + "missing spread direction {:?}: {:?}", + d, + dirs + ); + } + assert_eq!( + dirs.len(), + 4, + "should spread exactly four ways on symmetric flat ground" + ); + } + + /// Two holes at different distances: water steers to the NEARER one only. Confirms the BFS + /// returns true shortest-path distances. + #[test] + fn steers_to_nearer_of_two_holes() { + let r = water(); + let mut view = MapView::new(); + // East-west corridor floored at y=63, walls at z=+-1. + for x in -4..=4 { + view.set(p(x, 63, 0), block!("stone")); + view.set(p(x, 64, -1), block!("stone")); + view.set(p(x, 64, 1), block!("stone")); + } + view.set(p(5, 64, 0), block!("stone")); + view.set(p(-5, 64, 0), block!("stone")); + // East hole at x=2 (distance 2); west hole at x=-3 (distance 3). + view.blocks.remove(&(2, 63, 0)); + view.blocks.remove(&(-3, 63, 0)); + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 0)); + + let changes = compute_fluid_tick_vanilla(p(0, 64, 0), &view, r); + assert!( + changes.iter().any(|c| c.pos == p(1, 64, 0)), + "should spread east toward the nearer hole, {:?}", + changes + ); + assert!( + changes.iter().all(|c| c.pos != p(-1, 64, 0)), + "should NOT spread west toward the farther hole, {:?}", + changes + ); + } + + /// A hole at *exactly* the slope search limit must still be preferred over flat directions. + /// Regression: `slope_distance` used to return `limit` both for "hole found at limit" and for + /// "no hole within range", so a source 4 blocks from a hole (with water's limit = 4) tied the + /// hole direction with the three flat directions and spread uniformly instead of steering. + #[test] + fn hole_at_exactly_the_limit_still_steers() { + let r = water(); + assert_eq!(r.slope_find_distance, 4); + + // Large floor so the floor edges are far outside the search range (no phantom holes). + let mut view = MapView::new(); + for x in -10..=10 { + for z in -10..=10 { + view.set(p(x, 63, z), block!("stone")); + } + } + // Single hole exactly 4 blocks east of the source. + view.blocks.remove(&(4, 63, 0)); + view.set(p(0, 64, 0), fluid_block(FluidKind::Water, 0)); + + // The source must steer ONLY east (+x); the other three directions have no hole in range. + let dists = debug_spread_distances(p(0, 64, 0), &view, r); + assert_eq!( + dists[0], + Some(4), + "east should find the hole at distance 4: {:?}", + dists + ); + assert_eq!( + dists[1], + Some(NO_HOLE), + "west has no hole in range: {:?}", + dists + ); + assert_eq!( + dists[2], + Some(NO_HOLE), + "+z has no hole in range: {:?}", + dists + ); + assert_eq!( + dists[3], + Some(NO_HOLE), + "-z has no hole in range: {:?}", + dists + ); + + let changes = compute_fluid_tick_vanilla(p(0, 64, 0), &view, r); + assert!( + changes.iter().any(|c| c.pos == p(1, 64, 0)), + "source must spread east toward the limit-distance hole: {:?}", + changes + ); + assert!( + changes + .iter() + .all(|c| c.pos != p(-1, 64, 0) && c.pos != p(0, 64, 1) && c.pos != p(0, 64, -1)), + "source must NOT spread to the holeless directions: {:?}", + changes + ); + } +}