Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
27 kB · 625 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626//! 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<actor id, metres descended>`)//! 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<f32> { 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<i64> { 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<FallPlan> { 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<FallEvent> { 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<u64, f32>, odo: &mut BTreeMap<u64, u64>, causes: &mut CauseChain, tick: u64,) -> Vec<FallEvent> { let mut plans: Vec<FallPlan> = 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]<b[0] && hi[0]>a[0] && lo[1]<b[1] && hi[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<usize> = 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::<Vec<_>>(); 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::<Vec<_>>()); 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()); }}