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.
14 kB · 390 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391//! Headless test arena: the slice playtest bed and `hexwork --arena` stage.//!//! Design traces://! - docs/04 §4.8 (rule-chain tests need canonical setups) + Phase 2 wiring//! plan §5 (the 20 canonical gate setups — oil+fire, vent tag, copper —//! must agree with `tests/phase2_laws.rs` fixtures or the gate suite//! forks; this module mirrors those shapes exactly).//! - Phase 5 wiring plan §3: `hexwork --arena` prints [`describe`] (the CI//! slice proof: tick > 0, causes non-empty after demo ticks).//! - docs/02 §3 (Milthollow, pop. 312 — coaching inn, Lamplighter chapel,//! lampwright): [`Arena::new`] currently starts on the Milthollow road at//! dusk with a lantern, but does not instantiate town geometry or residents.//! Phase 9 subsystem code exists; the cross-phase integration gate owns the//! production town transition.//! - docs/03 §3.3 (the canonical mimic kill: oil + ignite + smoke through a//! grate chokes the room) + §3.2 step mode: [`demo_room`] stages it//! headless, [`tick_n`] runs it without a terminal.//!//! Dependency rule: this historical law-fixture stays core-only (hand-built//! voxels + actors). Production initialization must use content + worldgen;//! keeping this fixture isolated must not be mistaken for a live-game path.
use hexwork_core::fluids::fluid;use hexwork_core::light::LightSource;use hexwork_core::state::State;use hexwork_core::voxel::{extra, flags, material, Voxel};
/// Seed for the canonical demo stage: fixed so `--arena` output, gate/// tests, and replays agree byte-for-byte (determinism contract,/// docs/04 §4.2). Matches the slice default seed in `main.rs`.#[cfg(test)]const DEMO_SEED: u64 = 140;
/// Posed hit points for the historical demo rat: cable-rat per the content/// shape (hp 6, docs/04 §4.4). The fixture still lacks actor identity, so HP/// tags this pose; production rendering must not infer species this way.const RAT_HP: i32 = 6;
/// Fire-test oil depth, mirroring the `fire.rs`/`fluids.rs` fixture shape/// (oil cell: stone bed, `OIL` amt 4 — Phase 2 wiring plan §1 conformance).const OIL_AMT: u8 = 4;
/// Lantern flood radius for arena lamps (matches the `state.rs` kernel/// test shape so light-phase behaviour agrees across fixtures).const LANTERN_RADIUS: u8 = 6;
/// A headless run wrapping the deterministic simulation kernel.#[derive(Debug)]pub struct Arena { /// Deterministic sim kernel (docs/04 §4.2). pub state: State,}
impl Arena { /// Milthollow-road start (docs/02 §3): active chunks around origin, /// player delver at `[0, 0, 8]`, one warm lantern glow pushed into /// `state.lights`. This fixture has NO town geometry; Phase 9 subsystem /// code exists but production Milthollow integration remains open. /// (Associated spelling; the free [`new`] wrapper delegates /// here so both `Arena::new(s)` and `arena::new(s)` compile.) #[cfg(test)] pub fn new(seed: u64) -> Self { let mut state = State::new(seed); state.activate_around(0, 0, 0); state.spawn_actor(hexwork_core::spatial::FixedVec3::from_cell_feet([0, 0, 8]).unwrap()); state.lights.push(LightSource { x: 0, y: 0, z: 8, radius: LANTERN_RADIUS, warm: true, }); Arena { state } }
/// The same canonical stage rooted in a caller-supplied seed. /// Production `--arena --seed` uses this; fixed golden tests keep using /// [`Arena::demo_room`]. pub fn demo_room_with_seed(seed: u64) -> Self { demo_room_with_seed(seed) }
/// Run `n` headless ticks (method spelling of free [`tick_n`]). pub fn tick_n(&mut self, n: u64) { tick_n(self, n); }}
/// The canonical gate-test stage (hand-built, deterministic): a 9×9 stone/// floor at z = 0 carrying every Phase 2 prop in miniature. Each prop and/// the gate test it serves:////// - Stone floor (x, y in 0..9, z = 0): solid ground for falls —/// `boulder_down_shaft_arrives_below`, `thrown_potion_arcs_and_shatters`./// - Oil cell at (2, 2, 1) + burning wood neighbour at (3, 2, 1) (the/// `fire.rs` fixture shape): `oil_ignites_and_spreads`,/// `oil_under_door_grate_chokes_actor` (the mimic kill),/// `water_douses_fire_to_steam` (pour water on it), smoke for/// `smoke_rises_through_shaft`./// - Copper wall stripe (x in 0..9, y = 6, z = 1): `conducts()` copper —/// `shock_chains_through_water` (live cable + water + copper tragedy),/// `verdigris_eats_copper_wire`./// - Hidden vent at (7, 7, 1), tagged `extra::HIDDEN_VENT` (masked —/// never bare `extra != 0`, per the voxel protocol):/// `flour_reveals_hidden_vent`./// - Rat-pose actor (hp 6, cable-rat shape) across the room at (7, 7):/// the far victim/lure — `coin_throw_lures_loudest_node`, bowling,/// `acid_blood_pits_iron_grate`, `corpse_bloats_bursts_spawns` (kill it)./// - Player delver at (1, 1) with a warm lantern: the pourer of the oil.////// Layout note: the rat nests by the vent (cable-rats nest in walls,/// docs/04 §4.4) and the copper stripe splits the room, so smoke, signal,/// and scent all have a stage to cross.#[cfg(test)]pub fn demo_room() -> Arena { demo_room_with_seed(DEMO_SEED)}
/// Build the canonical demo geometry using an explicit deterministic seed.pub fn demo_room_with_seed(seed: u64) -> Arena { let mut state = State::new(seed); state.activate_around(0, 0, 0);
// Stone floor: 9×9 at z = 0. for y in 0..9 { for x in 0..9 { state.grid.set_world( x, y, 0, Voxel { material: material::STONE, occ: 1, ..Voxel::default() }, ); } }
// Oil cell (fire-test shape: stone bed, OIL amt 4). state.grid.set_world( 2, 2, 1, Voxel { material: material::STONE, occ: 1, fluid_kind: fluid::OIL, fluid_amt: OIL_AMT, ..Voxel::default() }, ); // Burning wood neighbour (first sight of flame: fuel counter inits in // `pad`, never in `extra` — see `fire.rs`). state.grid.set_world( 3, 2, 1, Voxel { material: material::WOOD, occ: 1, flags: flags::BURNING, ..Voxel::default() }, );
// Copper wall stripe across the room. for x in 0..9 { state.grid.set_world( x, 6, 1, Voxel { material: material::COPPER, occ: 1, ..Voxel::default() }, ); }
// Hidden vent: sealed stone block carrying the tag. The reveal rule // (flour/smoke → SECRET_REVEALED + link) fires in the sim; the tag is // the stage. state.grid.set_world( 7, 7, 1, Voxel { material: material::STONE, occ: 1, extra: extra::HIDDEN_VENT, ..Voxel::default() }, );
state.spawn_actor(hexwork_core::spatial::FixedVec3::from_cell_feet([1, 1, 2]).unwrap()); let rat = state.spawn_actor(hexwork_core::spatial::FixedVec3::from_cell_feet([7, 7, 3]).unwrap()); if let Some(a) = state.actors.iter_mut().find(|a| a.id == rat) { a.hp = RAT_HP; } state.lights.push(LightSource { x: 1, y: 1, z: 2, radius: LANTERN_RADIUS, warm: true, }); Arena { state }}
/// Run `n` headless ticks (the slice playtest bed + soak hook,/// docs/04 §4.8: no terminal, no wall-clock, deterministic).pub fn tick_n(arena: &mut Arena, n: u64) { for _ in 0..n { arena.state.step(); }}
/// Canonical state hash (determinism contract, docs/04 §4.2): same seed +/// same ticks = same hash. Powers `--arena` proofs and replay checks.pub fn hash(arena: &Arena) -> u64 { arena.state.hash_state()}
/// Multi-line proof surface for `hexwork --arena` (Phase 5 wiring plan/// §3): tick, hash, actor positions/hp, light count, cause count plus the/// last 5 cause details — the "every hazard speaks" check (docs/04/// §4.10.5: no silent rules).pub fn describe(arena: &Arena) -> String { let mut out = String::new(); out.push_str("HEXWORK arena stage\n"); out.push_str(&format!("seed: {}\n", arena.state.seed)); out.push_str(&format!("tick: {}\n", arena.state.tick)); out.push_str(&format!("hash: {:#x}\n", hash(arena))); out.push_str(&format!("actors: {}\n", arena.state.actors.len())); for a in &arena.state.actors { out.push_str(&format!( "actor {} pos=({:.2}, {:.2}, {:.2}) hp={} alive={}\n", a.id, a.position.presentation_metres()[0], a.position.presentation_metres()[1], a.position.presentation_metres()[2], a.hp, a.alive )); } out.push_str(&format!("lights: {}\n", arena.state.lights.len())); let links = arena.state.causes.links(); out.push_str(&format!("causes: {}\n", links.len())); let tail = links.len().saturating_sub(5); for l in &links[tail..] { out.push_str(&format!("[t{}][{}] {}\n", l.tick, l.rule, l.detail)); } out}
#[cfg(test)]mod tests { use super::*;
#[test] fn new_spawns_player_lantern_and_chunks() { // Exercise the parent-facing associated path (free new() delegates). let a = Arena::new(140); assert_eq!(hash(&a), hash(&Arena::new(140))); assert_eq!(a.state.tick, 0); assert_eq!(a.state.seed, 140); assert_eq!(a.state.actors.len(), 1); let p = a.state.actors.first().unwrap(); assert_eq!(p.position.millimetres(), [500, 500, 8000]); assert!(p.alive); assert_eq!(a.state.lights.len(), 1); let lamp = &a.state.lights[0]; assert!((lamp.x, lamp.y, lamp.z) == (0, 0, 8)); assert!(lamp.warm); // 3×3×3 active set around origin (docs/04 §4.3). assert_eq!(a.state.grid.loaded_count(), 27); }
#[test] fn demo_room_stone_floor_9x9() { let a = demo_room(); for (x, y) in [(0, 0), (8, 8), (4, 4), (0, 8), (8, 0)] { let v = a.state.grid.get_world(x, y, 0); assert_eq!(v.material, material::STONE, "floor at ({x},{y},0)"); assert_eq!(v.occ, 1); } // Open air above the middle of the room (no prop at 4,4,1). assert_eq!(a.state.grid.get_world(4, 4, 1).material, material::AIR); }
#[test] fn demo_room_oil_and_burning_neighbour() { let a = demo_room(); let oil = a.state.grid.get_world(2, 2, 1); assert_eq!(oil.fluid_kind, fluid::OIL); assert_eq!(oil.fluid_amt, OIL_AMT); let fire = a.state.grid.get_world(3, 2, 1); assert_ne!(fire.flags & flags::BURNING, 0); assert_eq!(fire.material, material::WOOD); }
#[test] fn demo_room_copper_stripe_conducts() { let a = demo_room(); for x in 0..9 { let v = a.state.grid.get_world(x, 6, 1); assert_eq!(v.material, material::COPPER, "stripe at ({x},6,1)"); } // The stripe is the shock-tragedy conductor (docs/03 §3.3.8). assert!(hexwork_core::materials::conducts(material::COPPER)); }
#[test] fn demo_room_hidden_vent_masked_tag() { let a = demo_room(); let v = a.state.grid.get_world(7, 7, 1); // Masked check — bare `extra != 0` is banned (voxel protocol). assert_eq!(v.extra & extra::HIDDEN_VENT, extra::HIDDEN_VENT); assert_eq!(v.extra, extra::HIDDEN_VENT); }
#[test] fn demo_room_rat_posed_at_distance() { let a = demo_room(); assert_eq!(a.state.actors.len(), 2); let player = a.state.actors.first().unwrap().id; let rats: Vec<_> = a.state.actors.iter().filter(|x| x.id != player).collect(); assert_eq!(rats.len(), 1); assert_eq!(rats[0].hp, RAT_HP); assert!(rats[0].alive); // Across the room from the fire at (3,2): the far victim/lure. let mm = rats[0].position.millimetres(); let dx = i128::from(mm[0]) - 3500; let dy = i128::from(mm[1]) - 2500; assert!(dx * dx + dy * dy > 4000_i128 * 4000); }
#[test] fn tick_n_advances_tick() { let mut a = demo_room(); tick_n(&mut a, 25); assert_eq!(a.state.tick, 25); }
#[test] fn headless_run_is_deterministic() { let mut a = demo_room(); let mut b = demo_room(); tick_n(&mut a, 20); tick_n(&mut b, 20); assert_eq!(a.state.tick, b.state.tick); assert_eq!(hash(&a), hash(&b)); // Fresh builds agree too. assert_eq!(hash(&demo_room()), hash(&demo_room())); }
#[test] fn explicit_seed_roots_demo_state_and_hash() { let mut a = demo_room_with_seed(7); let mut b = demo_room_with_seed(7); let mut c = demo_room_with_seed(8); tick_n(&mut a, 20); tick_n(&mut b, 20); tick_n(&mut c, 20); assert_eq!(a.state.seed, 7); assert_eq!(hash(&a), hash(&b), "same seed reproduces arena state"); assert_ne!(hash(&a), hash(&c), "different --seed changes arena state"); }
#[test] fn fire_speaks_after_ticks() { // The oil-shivers mark (fluids) and the ash/oil-catch links (fire) // are deterministic within 20 ticks — "every hazard speaks". let mut a = demo_room(); tick_n(&mut a, 20); let links = a.state.causes.links(); assert!(!links.is_empty()); assert!(links.iter().any(|l| l.rule == "fire & heat")); }
#[test] fn describe_shows_proof_surface() { let mut a = demo_room(); tick_n(&mut a, 5); let d = describe(&a); assert!(d.contains("tick: 5"), "{d}"); assert!(d.contains("hash: 0x"), "{d}"); assert!(d.contains("seed: 140"), "{d}"); assert!(d.contains("actor"), "{d}"); assert!(d.contains("hp="), "{d}"); assert!(d.contains("lights: 1"), "{d}"); assert!(d.contains("causes:"), "{d}"); }}