//! Gravity and falling — law 7 (docs/03-systems-design.md §3.3.7). //! //! Design trace: //! - "Falling damage by mass × height; landing ON someone shares it (delver //! bowling is legal)" plus shaft toys #1 (depth sounding with a dropped //! boulder) and #6 (cut the enemy rope, catch the loot) in docs/03 §3.3. //! - Tick phase: Move & collide, phase 3 (docs/04 §4.2). The parent `State` //! calls [`step_falling`] during Move; animated per-tick descents (if ever //! wanted) can slice the returned `from_z`/`to_z` span into waypoints. //! - Hard rules (docs/04 §4.10): deterministic — no `HashMap`, no time, no //! threads, no RNG draws (damage is a pure height table, so a fall never //! perturbs another subsystem's stream); every damaging landing pushes a //! `CauseLink` with rule tag `"gravity"` in NetHack voice. //! //! Motion model: a call integrates each airborne actor's whole remaining fall //! in fixed [`FALL_QUANTUM_M`] (1.0 m) quanta, up to [`MAX_FALL_PER_TICK_M`] //! per call. Positions therefore never drift by fractions of a metre, which //! keeps the raw-float position hashes used elsewhere (replays, `.hexdemo`) //! stable. Falls deeper than the per-call bound — e.g. down an unloaded //! shaft — keep falling across ticks: a still-airborne actor after this call //! is the parent's cross-strata arrival hook (load the destination volume, //! keep calling). //! //! Damage model: a landing deals [`fall_damage`] on the TOTAL descent, //! tracked in a parent-held odometer (`BTreeMap`) //! threaded through [`step_falling_with_mass`]. A 50 m shaft bills 48 damage //! on arrival even though no single call integrates more than //! [`MAX_FALL_PER_TICK_M`]; without the odometer only the final remainder //! would count (16 for that shaft, 0 for a 33.5 m one). The parent `State` //! owns the map (caller-side per wiring-plan §4 rule 3); [`step_falling`] //! passes a throwaway and therefore under-damages multi-call falls. //! //! Ground model: the top of the highest solid voxel at or below the feet is //! ground; solidity is `material != AIR` read through //! [`ChunkedGrid::get_world`]. Unloaded chunks read as AIR, i.e. there is //! deliberately no ground under unloaded space — things fall *through* toward //! whatever stratum the parent loads beneath them (toy #1: dropped things //! arrive *somewhere*). //! //! Mass model: [`step_falling`] assumes [`DEFAULT_MASS_KG`] per actor. //! [`step_falling_with_mass`] accepts per-actor totals (body + carried) in a //! `BTreeMap` keyed by actor id. Mass is recorded in the cause-link detail; //! the damage table itself is mass-independent by design until the parent //! supplies live weight/noise inputs. Weight-magic (`PONDUS`) hooks in here. use std::collections::BTreeMap; use crate::actor::Actor; use crate::cause::{CauseChain, CauseLink}; use crate::voxel::{material, ChunkedGrid}; /// Vertical integration quantum: falls advance in whole-metre steps. pub const FALL_QUANTUM_M: f32 = 1.0; /// Upper bound integrated per call so one tick cannot cross a whole stratum /// unnoticed. Deeper falls continue on the next tick (arrival hook). pub const MAX_FALL_PER_TICK_M: f32 = 32.0; /// A landing within this horizontal gap of another actor shares the damage /// (delver bowling is legal). pub const BOWLING_RADIUS_M: f32 = 0.8; /// Assumed body mass when the caller supplies no scale reading. pub const DEFAULT_MASS_KG: f32 = 80.0; /// How far below the feet [`ground_z`] scans for solid ground. pub const GROUND_SCAN_M: i32 = 64; /// One resolved fall: who landed, from where, and how much it hurt. #[derive(Clone, Debug, PartialEq)] pub struct FallEvent { /// Actor that moved (ids are assumed unique per call). pub actor_id: u64, /// Height at call start. pub from_z: i64, /// Millimetres at call end (ground level when landed). pub to_z: i64, /// Damage dealt on landing, 0 while still falling or for short drops. pub damage: i32, } /// Pure height table (docs/03 §3.3.7): under 3 m is free; above that every /// metre past 2 deals 1 damage. Deterministic by design — this replaces the /// 1d6-per-bracket roll a tabletop rule would use, so falls consume no RNG /// and never reschedule another sub-stream (docs/04 §4.2). pub fn fall_damage(height_m: f32) -> i32 { (height_m as i32 - 2).max(0) } /// Highest solid-voxel top surface at or below `z`, or `None` over the void. /// /// `None` means "keep falling": unloaded chunks read as AIR, which is the /// cross-strata arrival hook (docs/03 §3.3 toy #1). pub fn ground_z(grid: &ChunkedGrid, x: f32, y: f32, z: f32) -> Option { let cx = x.floor() as i32; let cy = y.floor() as i32; let cz = z.floor() as i32; if grid.get_world(cx, cy, cz).material != material::AIR { // Embedded in solid (parent Move never leaves actors here): treat as // already grounded rather than falling through the floor. return Some(z); } for d in 1..=GROUND_SCAN_M { if grid.get_world(cx, cy, cz - d).material != material::AIR { return Some((cz - d + 1) as f32); } } None } /// Integer ground query for authoritative actor motion. pub fn ground_z_fixed(grid: &ChunkedGrid, position: crate::spatial::FixedVec3) -> Option { let [cx, cy, cz] = position.cell(); if grid.get_world(cx, cy, cz).material != material::AIR { return Some(position.millimetres()[2]); } for d in 1..=GROUND_SCAN_M { let z = cz.checked_sub(d)?; if z <= i32::MIN { return None; } if grid.get_world(cx, cy, z).material != material::AIR { return Some((i64::from(z) + 1) * 1000); } } None } /// Thrown/dropped-thing arc preview ("thrown/dropped things arc", §3.3.7). /// /// Closed-form parabola sampled once per tick: `vel` is in metres-per-tick, /// `t` counts ticks, gravity is the fixed `9.8 * 0.1 = 0.98` m/tick². Point 0 /// (`t = 0`) is the release point. Pure and deterministic — replays and the /// "throw a voice down the side-shaft" preview (toy #4) agree exactly. pub const GRAVITY_PER_TICK2: f32 = 0.98; /// Sample `ticks` points along the thrown arc starting at `from`. pub fn arc_points(from: [f32; 3], vel: [f32; 3], ticks: u8) -> Vec<[f32; 3]> { (0..ticks) .map(|t| { let time = f32::from(t); [ from[0] + vel[0] * time, from[1] + vel[1] * time, from[2] + vel[2] * time - 0.5 * GRAVITY_PER_TICK2 * time * time, ] }) .collect() } /// One actor's descent, computed immutably before anything mutates (keeps /// slice order deterministic and borrow conflicts away). struct FallPlan { index: usize, actor_id: u64, from_z: i64, to: crate::spatial::FixedVec3, landed: bool, /// Millimetres descended in this call. drop_mm: u64, mass_kg: f32, } /// Plan a single actor's descent. `None` = grounded, embedded, dead, or over /// the void with no ground in scan range handled by the caller: over-void /// falls keep descending by the per-call bound (arrival hook). fn plan_descent(index: usize, actor: &Actor, grid: &ChunkedGrid, mass_kg: f32) -> Option { if !actor.alive { return None; } let from_z = actor.position.millimetres()[2]; let ground = ground_z_fixed(grid, actor.position); let clearance = ground.map(|z| from_z - z).unwrap_or(32_000); if clearance <= 0 { return None; } let drop_mm = clearance.min(32_000); let to = actor.position.checked_translate([0, 0, -drop_mm]).ok()?; actor.body.bounds(to).ok()?; Some(FallPlan { index, actor_id: actor.id, from_z, to, landed: ground.is_some() && drop_mm >= clearance, drop_mm: drop_mm as u64, mass_kg, }) } /// Advance airborne actors toward voxel-solid ground (Move phase hook). /// /// Returns one [`FallEvent`] per actor that moved — damage is nonzero only on /// landing ticks with a fall of 3 m or more. Still-falling actors simply have /// a lower `pos.z` and no event yet. Assumes [`DEFAULT_MASS_KG`] per actor; /// see [`step_falling_with_mass`] for weighed parties. /// /// Single-call convenience: spins up a throwaway odometer, so a fall that /// spans several calls is under-damaged (only the final call's remainder /// counts). The parent `State` must call [`step_falling_with_mass`] with its /// persistent odometer for correct total-height damage. pub fn step_falling( actors: &mut [Actor], grid: &ChunkedGrid, causes: &mut CauseChain, tick: u64, ) -> Vec { let mut odo = BTreeMap::new(); step_falling_with_mass(actors, grid, &BTreeMap::new(), &mut odo, causes, tick) } /// [`step_falling`] with per-actor totals (body + carried, kg) keyed by actor /// id; missing ids default to [`DEFAULT_MASS_KG`]. Mass is recorded in the /// cause detail; the damage table stays pure (see module docs). /// /// `odo` is the parent-held fall odometer (metres descended, keyed by actor /// id): every descent this call — landings, mid-fall steps, and cross-strata /// void steps — accrues into the actor's running total, and a landing deals /// [`fall_damage`] on the whole total (final drop included) before the entry /// is removed. Actors with no descent this call (grounded, embedded, dead) /// have any stale entry cleared, so the next fall starts from zero. /// Teleports are invisible here: the parent MUST clear a teleported actor's /// entry itself, or the next landing bills the pre-teleport height too. pub fn step_falling_with_mass( actors: &mut [Actor], grid: &ChunkedGrid, masses: &BTreeMap, odo: &mut BTreeMap, causes: &mut CauseChain, tick: u64, ) -> Vec { let mut plans: Vec = actors .iter() .enumerate() .filter_map(|(i, a)| { let mass = masses .get(&a.id) .copied() .unwrap_or(DEFAULT_MASS_KG) .max(0.0); plan_descent(i, a, grid, mass) }) .collect(); // Reserve both tick-start and accepted boxes. Vertical motion cannot push, // pass through, or swap through another live body; touching top faces is valid. plans.sort_by_key(|p| p.actor_id); let starts: BTreeMap<_,_> = actors.iter().filter(|a|a.alive).filter_map(|a| a.body.bounds(a.position).ok().map(|b|(a.id,b))).collect(); let mut accepted = starts.clone(); for plan in &mut plans { let actor=&actors[plan.index]; let (lo,hi)=actor.body.bounds(actor.position).expect("validated fall body"); let lo=lo.millimetres();let hi=hi.millimetres(); let mut z=plan.to.millimetres()[2]; for (id,(other_lo,other_hi)) in starts.iter().chain(accepted.iter()) { if *id==plan.actor_id {continue;} let a=other_lo.millimetres();let b=other_hi.millimetres(); if lo[0]a[0] && lo[1]a[1] && b[2]<=plan.from_z && b[2]>z { z=b[2];plan.landed=true; } } plan.to=actor.position.checked_translate([0,0,z-plan.from_z]).expect("bounded vertical clip"); plan.drop_mm=(plan.from_z-z) as u64; accepted.insert(plan.actor_id,actor.body.bounds(plan.to).expect("bounded fall body")); } // Reject the entire batch before any actor, cause, or odometer mutation. if plans.iter().any(|plan| odo.get(&plan.actor_id).copied().unwrap_or(0).checked_add(plan.drop_mm).is_none()) { return Vec::new(); } // Apply all motion first so landing shares see settled positions. for plan in &plans { actors[plan.index].position = plan.to; } // Accrue every descent into the odometer (TOTAL-height damage basis). // Entries live only across a fall: landing removes the entry below, and // actors with no descent this call (grounded/embedded/dead) keep no // running total, so the next fall starts from zero. for plan in &plans { let total = odo.entry(plan.actor_id).or_default(); *total = total.checked_add(plan.drop_mm).expect("preflighted fall odometer"); } for actor in actors.iter() { if !plans.iter().any(|plan| plan.actor_id == actor.id) { odo.remove(&actor.id); } } let mut events = Vec::new(); for plan in &plans { if !plan.landed { continue; } // Total descent including this call's final drop; the entry is done. let total = odo.remove(&plan.actor_id).unwrap_or(plan.drop_mm); let damage = i32::try_from((total / 1000).saturating_sub(2)).unwrap_or(i32::MAX); // Delver bowling: first other live actor within the radius of the // landing point, in slice order (deterministic). Only for real hurts; // soft landings on a friend's shoulders are free. let victim: Option = if damage > 0 { let mut found = None; for (k, other) in actors.iter().enumerate() { if k == plan.index || !other.alive { continue; } let other_mm = other.position.millimetres(); let to_mm = plan.to.millimetres(); let dx = i128::from(other_mm[0]) - i128::from(to_mm[0]); let dy = i128::from(other_mm[1]) - i128::from(to_mm[1]); if dx * dx + dy * dy <= 800 * 800 { found = Some(k); break; } } found } else { None }; let id = plan.actor_id; // Text-only metres: never used for geometry or damage. let height = format!("{}.{:01}", total / 1000, (total % 1000) / 100); let mass = plan.mass_kg; if damage > 0 { let detail = format!( "delver #{id} ({mass:.0}kg) falls {height}m and lands hard ({damage} damage)" ); causes.push(CauseLink::new(tick, "gravity", detail)); } match victim { None => { actors[plan.index].hp = actors[plan.index].hp.saturating_sub(damage); if actors[plan.index].hp <= 0 && actors[plan.index].alive { actors[plan.index].alive = false; let epitaph = format!("delver #{id} is killed by the fall (fell {height}m)"); causes.push(CauseLink::new(tick, "gravity", epitaph)); } } Some(j) => { let victim_share = damage / 2; let lander_share = damage - victim_share; actors[plan.index].hp = actors[plan.index].hp.saturating_sub(lander_share); if actors[plan.index].hp <= 0 && actors[plan.index].alive { actors[plan.index].alive = false; let epitaph = format!("delver #{id} is killed by the fall (fell {height}m)"); causes.push(CauseLink::new(tick, "gravity", epitaph)); } actors[j].hp = actors[j].hp.saturating_sub(victim_share); if actors[j].hp <= 0 && actors[j].alive { actors[j].alive = false; let epitaph = format!( "delver #{} is killed by delver bowling (fell {height}m)", actors[j].id ); causes.push(CauseLink::new(tick, "gravity", epitaph)); } if actors[plan.index].alive || actors[j].alive { let bowling = format!( "delver bowling is legal — delver #{id} lands on delver #{} and they split the pain", actors[j].id ); causes.push(CauseLink::new(tick, "gravity", bowling)); } } } events.push(FallEvent { actor_id: id, from_z: plan.from_z, to_z: plan.to.millimetres()[2], damage, }); } events } #[cfg(test)] #[allow(clippy::field_reassign_with_default)] mod tests { use super::*; use crate::voxel::Voxel; fn fixed_actor(id: u64, mm: [i64; 3]) -> Actor { Actor::new(id, crate::spatial::FixedVec3::new(mm).unwrap()) } fn stone_floor() -> ChunkedGrid { let mut grid = ChunkedGrid::new(); let stone = Voxel { material: material::STONE, ..Voxel::default() }; for x in -2..=2 { for y in -2..=2 { grid.set_world(x, y, -1, stone); } } grid } #[test] fn fixed_odometer_overflow_rejects_entire_batch_atomically() { let mut actors = vec![fixed_actor(1, [500, 500, 5000]), fixed_actor(2, [1500, 500, 5000])]; let before = actors.iter().map(|a| (a.position, a.hp, a.alive, a.stance)).collect::>(); let mut odo = BTreeMap::from([(1, 42), (2, u64::MAX)]); let before_odo = odo.clone(); let mut causes = CauseChain::new(); assert!(step_falling_with_mass(&mut actors, &stone_floor(), &BTreeMap::new(), &mut odo, &mut causes, 1).is_empty()); assert_eq!(before, actors.iter().map(|a| (a.position, a.hp, a.alive, a.stance)).collect::>()); assert_eq!(odo, before_odo); assert!(causes.is_empty()); } #[test] fn settle_to_ground() { let grid = stone_floor(); let mut actors = vec![fixed_actor(1, [0, 0, 500])]; let mut causes = CauseChain::new(); let events = step_falling(&mut actors, &grid, &mut causes, 1); assert_eq!(actors[0].position.millimetres()[2], 0); assert_eq!(events.len(), 1); assert_eq!(events[0].damage, 0); assert!(causes.is_empty()); } #[test] fn damage_table() { assert_eq!(fall_damage(0.0), 0); assert_eq!(fall_damage(2.9), 0); assert_eq!(fall_damage(3.0), 1); assert_eq!(fall_damage(6.0), 4); assert_eq!(fall_damage(10.0), 8); } #[test] fn long_fall_hurts_and_links() { let grid = stone_floor(); let mut actors = vec![fixed_actor(1, [0, 0, 5000])]; let mut causes = CauseChain::new(); let events = step_falling(&mut actors, &grid, &mut causes, 7); assert_eq!(actors[0].position.millimetres()[2], 0); assert_eq!(events[0].damage, 3); assert_eq!(actors[0].hp, 7); assert_eq!(causes.len(), 1); assert_eq!(causes.links()[0].rule, "gravity"); assert!(causes.links()[0].detail.contains("falls 5.0m")); } #[test] fn shared_landing_split() { let grid = stone_floor(); let mut actors = vec![ fixed_actor(1, [0, 0, 6800]), fixed_actor(2, [500, 0, 0]), ]; let mut causes = CauseChain::new(); let events = step_falling(&mut actors, &grid, &mut causes, 3); assert_eq!(events[0].damage, 3); assert_eq!(actors[0].position.millimetres()[2],1800,"lands on body, not through it"); // 5m actual descent: 3 damage split, lander takes2 and victim1. assert_eq!(actors[0].hp, 8); assert_eq!(actors[1].hp, 9); assert!(causes .links() .iter() .any(|l| l.detail.contains("delver bowling is legal"))); } #[test] fn landing_damage_saturates_for_solo_lander_and_both_bowling_shares() { let grid = stone_floor(); let mut solo = vec![fixed_actor(1, [0, 0, 32000])]; solo[0].hp = i32::MIN + 1; let mut causes = CauseChain::new(); step_falling(&mut solo, &grid, &mut causes, 1); assert_eq!(solo[0].hp, i32::MIN); assert!(!solo[0].alive); assert!(causes .links() .iter() .any(|link| link.detail.contains("killed by the fall"))); let mut bowling = vec![ fixed_actor(1, [0, 0, 32000]), fixed_actor(2, [500, 0, 0]), ]; bowling[0].hp = i32::MIN + 1; bowling[1].hp = i32::MIN + 1; let mut causes = CauseChain::new(); step_falling(&mut bowling, &grid, &mut causes, 2); assert_eq!(bowling[0].hp, i32::MIN); assert_eq!(bowling[1].hp, i32::MIN); assert!(!bowling[0].alive); assert!(!bowling[1].alive); assert!(causes .links() .iter() .any(|link| link.detail.contains("killed by delver bowling"))); } #[test] fn cross_strata_unloaded_keeps_falling() { let grid = ChunkedGrid::new(); let mut actors = vec![fixed_actor(1, [0, 0, 50000])]; let mut causes = CauseChain::new(); let events = step_falling(&mut actors, &grid, &mut causes, 1); assert!(events.is_empty()); assert_eq!(actors[0].position.millimetres()[2], 18000); let events = step_falling(&mut actors, &grid, &mut causes, 2); assert!(events.is_empty()); assert_eq!(actors[0].position.millimetres()[2], -14000); assert!(causes.is_empty()); } #[test] fn weighed_fall_records_mass() { let grid = stone_floor(); let mut actors = vec![fixed_actor(1, [0, 0, 5000])]; let masses = BTreeMap::from([(1u64, 200.0)]); let mut odo = BTreeMap::new(); let mut causes = CauseChain::new(); let events = step_falling_with_mass(&mut actors, &grid, &masses, &mut odo, &mut causes, 1); // Table stays pure (mass-independent by design); mass is witnessed. assert_eq!(events[0].damage, 3); assert!(causes.links()[0].detail.contains("200kg")); // Landing consumes the odometer entry. assert!(!odo.contains_key(&1)); } /// 50 m shaft across two calls: damage bills the TOTAL (48), not the /// final-tick remainder (16). Drives the parent-held odometer the way /// `State` will (persistent map across ticks). #[test] fn deep_fall_bills_total_height() { let grid = stone_floor(); let mut actors = vec![fixed_actor(1, [0, 0, 50000])]; actors[0].hp = 100; let masses = BTreeMap::new(); let mut odo = BTreeMap::new(); let mut causes = CauseChain::new(); // Tick 1: 50 m clearance, integrates 32 m, still airborne. let events = step_falling_with_mass(&mut actors, &grid, &masses, &mut odo, &mut causes, 1); assert!(events.is_empty()); assert_eq!(actors[0].position.millimetres()[2], 18000); assert_eq!(odo.get(&1), Some(&32000)); // Tick 2: final 18 m, lands. Total 50 m -> 48 damage. let events = step_falling_with_mass(&mut actors, &grid, &masses, &mut odo, &mut causes, 2); assert_eq!(actors[0].position.millimetres()[2], 0); assert_eq!(events.len(), 1); assert_eq!(events[0].damage, 48); assert_eq!(actors[0].hp, 52); assert!(causes.links()[0].detail.contains("falls 50.0m")); assert!(!odo.contains_key(&1)); } /// 33.5 m fall: the old remainder basis dealt 0 on the final 1.5 m snap; /// the total basis deals 31. #[test] fn fractional_fall_bills_total_height() { let grid = stone_floor(); let mut actors = vec![fixed_actor(1, [0, 0, 33500])]; actors[0].hp = 100; let masses = BTreeMap::new(); let mut odo = BTreeMap::new(); let mut causes = CauseChain::new(); let events = step_falling_with_mass(&mut actors, &grid, &masses, &mut odo, &mut causes, 1); assert!(events.is_empty()); assert_eq!(actors[0].position.millimetres()[2], 1500); let events = step_falling_with_mass(&mut actors, &grid, &masses, &mut odo, &mut causes, 2); assert_eq!(events.len(), 1); assert_eq!(events[0].damage, 31); assert_eq!(actors[0].hp, 69); } /// Void (cross-strata) steps accrue `MAX_FALL_PER_TICK_M` each into the /// odometer: one unloaded tick plus arrival onto loaded ground below /// bills the whole 50 m. #[test] fn void_steps_accrue_into_total() { let void_grid = ChunkedGrid::new(); let floor = stone_floor(); let mut actors = vec![fixed_actor(1, [0, 0, 50000])]; actors[0].hp = 100; let masses = BTreeMap::new(); let mut odo = BTreeMap::new(); let mut causes = CauseChain::new(); // Tick 1: unloaded — no ground, falls the per-call bound, no event. let events = step_falling_with_mass(&mut actors, &void_grid, &masses, &mut odo, &mut causes, 1); assert!(events.is_empty()); assert_eq!(actors[0].position.millimetres()[2], 18000); assert_eq!(odo.get(&1), Some(&32000)); // Tick 2: parent loads the destination volume; arrival bills 50 m. let events = step_falling_with_mass(&mut actors, &floor, &masses, &mut odo, &mut causes, 2); assert_eq!(events.len(), 1); assert_eq!(events[0].damage, 48); assert_eq!(actors[0].hp, 52); } /// Landing clears the entry, so a second fall starts from zero — not /// stacked on the first. #[test] fn odometer_resets_after_landing() { let grid = stone_floor(); let mut actors = vec![fixed_actor(1, [0, 0, 5000])]; actors[0].hp = 100; let masses = BTreeMap::new(); let mut odo = BTreeMap::new(); let mut causes = CauseChain::new(); let first = step_falling_with_mass(&mut actors, &grid, &masses, &mut odo, &mut causes, 1); assert_eq!(first[0].damage, 3); assert!(!odo.contains_key(&1)); // Climb (or get hauled) back up and fall again: fresh 5 m, not 10 m. actors[0].position = crate::spatial::FixedVec3::new([0, 0, 5000]).unwrap(); let second = step_falling_with_mass(&mut actors, &grid, &masses, &mut odo, &mut causes, 2); assert_eq!(second[0].damage, 3); assert_eq!(actors[0].hp, 94); } #[test] fn arc_deterministic() { let a = arc_points([0.0, 0.0, 10.0], [1.0, 0.0, 2.0], 4); let b = arc_points([0.0, 0.0, 10.0], [1.0, 0.0, 2.0], 4); assert_eq!(a, b); assert_eq!(a.len(), 4); assert_eq!(a[0], [0.0, 0.0, 10.0]); assert!((a[1][2] - 11.51).abs() < 1e-4); assert!(a[3][2] < a[2][2]); assert!(arc_points([0.0, 0.0, 0.0], [0.0, 0.0, 0.0], 0).is_empty()); } }