diff --git a/crates/hexwork-core/src/action.rs b/crates/hexwork-core/src/action.rs new file mode 100644 index 0000000..b9bb827 --- /dev/null +++ b/crates/hexwork-core/src/action.rs @@ -0,0 +1,64 @@ +//! Canonical vocabulary for actions that have crossed the input boundary. + +use crate::services::Service; + +/// Eight explicit movement directions; invalid deltas cannot enter a journal. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Direction { + North, + South, + West, + East, + NorthWest, + NorthEast, + SouthWest, + SouthEast, +} + +impl Direction { + /// Horizontal voxel delta for this direction. + #[must_use] + pub const fn delta(&self) -> (i32, i32) { + match self { + Self::North => (0, 1), + Self::South => (0, -1), + Self::West => (-1, 0), + Self::East => (1, 0), + Self::NorthWest => (-1, 1), + Self::NorthEast => (1, 1), + Self::SouthWest => (-1, -1), + Self::SouthEast => (1, -1), + } + } +} + +/// Typed committed actions. Strings are canonical content/town identifiers, +/// never terminal command text. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CommittedAction { + Move(Direction), + Wait, + InspectBoard { town: String }, + AcceptContract { id: String }, + UseTransition { transition_id: u64 }, + ResolveAttack { target_actor: u64 }, + TurnIn { contract_id: String }, + UseService { service: Service }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_direction_deltas_are_explicit() { + assert_eq!(Direction::North.delta(), (0, 1)); + assert_eq!(Direction::South.delta(), (0, -1)); + assert_eq!(Direction::West.delta(), (-1, 0)); + assert_eq!(Direction::East.delta(), (1, 0)); + assert_eq!(Direction::NorthWest.delta(), (-1, 1)); + assert_eq!(Direction::NorthEast.delta(), (1, 1)); + assert_eq!(Direction::SouthWest.delta(), (-1, -1)); + assert_eq!(Direction::SouthEast.delta(), (1, -1)); + } +} diff --git a/crates/hexwork-core/src/actor.rs b/crates/hexwork-core/src/actor.rs index 02c17d9..1506ce5 100644 --- a/crates/hexwork-core/src/actor.rs +++ b/crates/hexwork-core/src/actor.rs @@ -1,6 +1,9 @@ //! Actors: continuous positions in a voxel world (docs/04 §4.3). -//! Voxel occupancy is derived from actors, never authoritative — so -//! falling, leaning, climbing, and riding work. +//! +//! [`crate::state::State::actors`] is the sole simulation entity authority. +//! [`crate::state::State::ecs`] is dormant infrastructure and actors are never +//! mirrored into it. Voxel occupancy is likewise derived from actors, never +//! authoritative — so falling, leaning, climbing, and riding work. /// Body posture. Rendered as glyph variants (docs/05 §5.1). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -13,6 +16,21 @@ pub enum Stance { Rope, } +impl Stance { + /// Stable persistence tag; declaration order is not the wire protocol. + #[must_use] + pub const fn canonical_tag(self) -> u8 { + match self { + Self::Stand => 0, + Self::Crouch => 1, + Self::Climb => 2, + Self::Swim => 3, + Self::Prone => 4, + Self::Rope => 5, + } + } +} + /// A living (or unliving) thing with a continuous position. #[derive(Clone, Debug)] pub struct Actor { diff --git a/crates/hexwork-core/src/cause.rs b/crates/hexwork-core/src/cause.rs index 6ef4f8e..d4a60bb 100644 --- a/crates/hexwork-core/src/cause.rs +++ b/crates/hexwork-core/src/cause.rs @@ -36,6 +36,15 @@ impl CauseChain { self.links.push(link); } + /// Reserve cause capacity before an atomic multi-link transaction mutates. + pub(crate) fn try_reserve( + &mut self, + additional: usize, + ) -> Result<(), std::collections::TryReserveError> { + self.links.try_reserve(additional) + } + + /// Links in canonical occurrence order (oldest first). pub fn links(&self) -> &[CauseLink] { &self.links } diff --git a/crates/hexwork-core/src/character.rs b/crates/hexwork-core/src/character.rs index b38efbd..da0e4ff 100644 --- a/crates/hexwork-core/src/character.rs +++ b/crates/hexwork-core/src/character.rs @@ -50,6 +50,19 @@ pub enum Ancestry { } /// All ancestries, for iteration (chargen UI, tests). +impl Ancestry { + /// Stable persistence tag; declaration order is not the wire protocol. + #[must_use] + pub const fn canonical_tag(self) -> u8 { + match self { + Self::Mudborn => 0, + Self::CantBorn => 1, + Self::PaleTouched => 2, + Self::Foundling => 3, + } + } +} + pub const ALL_ANCESTRIES: [Ancestry; 4] = [ Ancestry::Mudborn, Ancestry::CantBorn, @@ -171,6 +184,21 @@ pub enum Calling { } /// All callings, for iteration (chargen UI, tests). +impl Calling { + /// Stable persistence tag; declaration order is not the wire protocol. + #[must_use] + pub const fn canonical_tag(self) -> u8 { + match self { + Self::Ratcatcher => 0, + Self::Lampbearer => 1, + Self::Tinker => 2, + Self::CorpseTender => 3, + Self::TollWard => 4, + Self::ChoirDeserter => 5, + } + } +} + pub const ALL_CALLINGS: [Calling; 6] = [ Calling::Ratcatcher, Calling::Lampbearer, diff --git a/crates/hexwork-core/src/checkpoint.rs b/crates/hexwork-core/src/checkpoint.rs new file mode 100644 index 0000000..15c24fd --- /dev/null +++ b/crates/hexwork-core/src/checkpoint.rs @@ -0,0 +1,842 @@ +//! Owned, plain-data checkpoint image for the deterministic kernel. +//! This is an in-memory boundary, not a serialized wire format. + +use std::collections::BTreeMap; + +use crate::actor::Actor; +use crate::bargain::Ledger; +use crate::character::Character; +use crate::connector::Connector; +use crate::dungeon::{Chalk, MulePath, VaultChoice}; +use crate::hymn::HymnCase; +use crate::inventory::Pack; +use crate::item::Instance; +use crate::light::LightSource; +use crate::modes::ChapelDebt; +use crate::npc::{ShopMemory, Townsfolk}; +use crate::pressure::{PressureEdge, PressureNode}; +use crate::reputation::Reputation; +use crate::rot::CorpseClock; +use crate::runtime::{ActiveContract, ActorIdentity, RunLocation, RuntimeInvariantError, Wallet}; +use crate::sound::SoundEvent; +use crate::state::{FarField, GossipPacket, Spill}; +use crate::voxel::{GridLoadError, Voxel}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChunkImage { + pub coord: (i32, i32, i32), + pub voxels: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CauseImage { + pub tick: u64, + pub rule: String, + pub detail: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LawImage { + pub rule: String, + pub first_seen_tick: u64, +} + +/// Exact projection of every future-affecting `State` field. +#[derive(Clone, Debug)] +pub struct StateImage { + pub tick: u64, + pub seed: u64, + pub rng_master: [u64; 4], + pub ecs_entity_count: u32, + pub chunks: Vec, + pub actors: Vec, + pub actor_identities: BTreeMap, + pub wallets: BTreeMap, + pub contracts: BTreeMap, + pub run_location: RunLocation, + pub connectors: Vec, + pub pressure_nodes: Vec, + pub pressure_edges: Vec, + pub causes: Vec, + pub far: FarField, + pub laws: Vec, + pub fall_odo: BTreeMap, + pub lights: Vec, + pub sounds: Vec, + pub corpses: BTreeMap<(i32, i32, i32), CorpseClock>, + pub signal_sources: Vec<(i32, i32, i32)>, + pub spill: Spill, + pub characters: BTreeMap, + pub reputations: BTreeMap, + pub items: BTreeMap, + pub ground_items: BTreeMap, + pub packs: BTreeMap, + pub idf: BTreeMap, + pub hymn_cases: BTreeMap, + pub cast_queue: Vec<(u64, String, [i32; 3])>, + pub bargains: BTreeMap, + pub deep_weather: i8, + pub npcs: BTreeMap, + pub shops: BTreeMap<(i32, i32, i32), ShopMemory>, + pub gossip: Vec, + pub chalk: Chalk, + pub mule: Option, + pub vault_choice: Option, + pub chapel_debts: BTreeMap, + pub next_id: u64, + pub step_mode: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum StateImageError { + NonEmptyEcs { entities: u32 }, + InvalidRng, + Grid(GridLoadError), + UnknownRule { rule: String }, + DuplicateLaw { rule: String }, + NonCanonicalLawOrder { previous: String, current: String }, + Runtime(RuntimeInvariantError), +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::connector::ConnectorKind; + use crate::reputation::{Faction, GodId, Reputation}; + use crate::runtime::{ActorIdentity, ActorRole, RunLocation}; + use crate::state::{State, TICKS_PER_DAY}; + use crate::voxel::{material, CHUNK_VOL}; + + fn valid_image() -> StateImage { + let mut state = State::new(9); + state.spawn_actor_with_identity( + [0.0; 3], + ActorIdentity::new("delver", "Ada", ActorRole::Player), + ); + state.initialize_town("milthollow").unwrap(); + state.to_image().unwrap() + } + + fn image_with_stone_floor() -> StateImage { + let mut image = valid_image(); + let mut voxels = vec![Voxel::default(); CHUNK_VOL]; + let index = 47 * 48 * 48; + voxels[index].material = material::STONE; + image.chunks.push(ChunkImage { + coord: (0, 0, -1), + voxels, + }); + image + } + + fn connector() -> Connector { + Connector { + id: 17, + kind: ConnectorKind::Shaft, + from: [0.0, 0.0, -3.0], + to: [0.0, 0.0, 0.0], + length: 3.0, + anchor_quality: 80, + load_kg: 0.0, + blocked: false, + } + } + + fn rejects_nonfinite( + mut mutate: impl FnMut(&mut StateImage, f32), + table: &'static str, + row: u64, + field: &'static str, + ) { + for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + let mut image = valid_image(); + mutate(&mut image, value); + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Runtime(RuntimeInvariantError::NonFiniteField { + table, + row, + field, + }) + ); + } + } + + fn rejects_range( + mut mutate: impl FnMut(&mut StateImage), + table: &'static str, + row: u64, + field: &'static str, + ) { + let mut image = valid_image(); + mutate(&mut image); + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Runtime(RuntimeInvariantError::InvalidFloatRange { + table, + row, + field, + }) + ); + } + + #[test] + fn malformed_image_markers_and_rng_are_rejected() { + let mut image = valid_image(); + image.ecs_entity_count = 1; + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::NonEmptyEcs { entities: 1 } + ); + let mut image = valid_image(); + image.rng_master = [0; 4]; + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::InvalidRng + ); + } + + #[test] + fn malformed_chunks_are_rejected_without_voxel_writes() { + let mut image = valid_image(); + image.chunks.push(ChunkImage { + coord: (0, 0, 0), + voxels: Vec::new(), + }); + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Grid(GridLoadError::ChunkLength { actual: 0 }) + ); + + let mut image = valid_image(); + image.chunks = vec![ + ChunkImage { + coord: (0, 0, 0), + voxels: vec![Voxel::default(); CHUNK_VOL], + }, + ChunkImage { + coord: (0, 0, 0), + voxels: vec![Voxel::default(); CHUNK_VOL], + }, + ]; + assert!(matches!( + State::from_image(image), + Err(StateImageError::Grid( + GridLoadError::DuplicateCoordinate { .. } + )) + )); + + let mut image = valid_image(); + image.chunks = vec![ + ChunkImage { + coord: (1, 0, 0), + voxels: vec![Voxel::default(); CHUNK_VOL], + }, + ChunkImage { + coord: (0, 0, 0), + voxels: vec![Voxel::default(); CHUNK_VOL], + }, + ]; + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Grid(GridLoadError::NonCanonicalOrder { + previous: (1, 0, 0), + current: (0, 0, 0), + }) + ); + } + + #[test] + fn unknown_and_noncanonical_static_rules_are_rejected() { + let mut image = valid_image(); + image.causes.push(CauseImage { + tick: 1, + rule: "invented".into(), + detail: "bad".into(), + }); + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::UnknownRule { + rule: "invented".into() + } + ); + + let mut image = valid_image(); + image.laws = vec![ + LawImage { + rule: "gravity".into(), + first_seen_tick: 1, + }, + LawImage { + rule: "gravity".into(), + first_seen_tick: 2, + }, + ]; + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::DuplicateLaw { + rule: "gravity".into() + } + ); + + let mut image = valid_image(); + image.laws = vec![ + LawImage { + rule: "gravity".into(), + first_seen_tick: 1, + }, + LawImage { + rule: "death".into(), + first_seen_tick: 2, + }, + ]; + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::NonCanonicalLawOrder { + previous: "gravity".into(), + current: "death".into(), + } + ); + } + + #[test] + fn reputation_arrays_require_canonical_tags_and_order() { + let mut image = valid_image(); + let actor = image.actors[0].id; + image.reputations.insert(actor, Reputation::new()); + assert!(State::from_image(image.clone()).is_ok()); + + let cases = [ + ( + "factions", + 1, + Faction::Lampwrights.canonical_tag(), + true, + false, + ), + ( + "factions", + 0, + Faction::Congregation.canonical_tag(), + true, + true, + ), + ( + "god_credit", + 1, + GodId::Lamplighter.canonical_tag(), + false, + false, + ), + ( + "god_credit", + 0, + GodId::Tollkeeper.canonical_tag(), + false, + true, + ), + ]; + for (table, index, found, faction_table, reordered) in cases { + let mut malformed = image.clone(); + let reputation = malformed.reputations.get_mut(&actor).unwrap(); + if faction_table { + if reordered { + reputation.factions.swap(0, 1); + } else { + reputation.factions[1].0 = Faction::Lampwrights; + } + } else if reordered { + reputation.god_credit.swap(0, 1); + } else { + reputation.god_credit[1].0 = GodId::Lamplighter; + } + assert_eq!( + State::from_image(malformed).unwrap_err(), + StateImageError::Runtime(RuntimeInvariantError::NonCanonicalReputationArray { + actor_id: actor, + table, + index, + expected: index as u8, + found, + }) + ); + } + } + + #[test] + fn runtime_corruption_is_rejected() { + let mut image = valid_image(); + image.next_id = 1; + assert!(matches!( + State::from_image(image), + Err(StateImageError::Runtime( + RuntimeInvariantError::NextIdNotAboveLiveId { .. } + )) + )); + + let mut image = valid_image(); + image.run_location = RunLocation::Town(" ".into()); + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Runtime(RuntimeInvariantError::InvalidRunLocation) + ); + + let mut image = valid_image(); + image.actors[0].id = 0; + assert!(matches!( + State::from_image(image), + Err(StateImageError::Runtime( + RuntimeInvariantError::ZeroStableId { .. } + )) + )); + + let mut image = valid_image(); + let actor = image.actors[0].id; + image.contracts.insert( + "bad".into(), + ActiveContract { + id: "different".into(), + taker: actor, + accepted_tick: 0, + floor: 1, + pay_cu: 1, + objective_target: 1, + objective_progress: 0, + status: crate::runtime::ContractStatus::Active, + reward_town: "milthollow".into(), + standing_delta: 1, + }, + ); + assert!(matches!( + State::from_image(image), + Err(StateImageError::Runtime( + RuntimeInvariantError::ContractKeyMismatch { .. } + )) + )); + + let mut image = valid_image(); + image.ground_items.insert(999, (0, 0, 0)); + assert!(matches!( + State::from_image(image), + Err(StateImageError::Runtime( + RuntimeInvariantError::DanglingGroundItem { item_id: 999 } + )) + )); + + let mut image = valid_image(); + let actor = image.actors[0].id; + image.chapel_debts.insert( + actor, + ChapelDebt { + burials_owed: 0, + chapel: crate::modes::LAMPLIGHTER_CHAPEL.into(), + }, + ); + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Runtime(RuntimeInvariantError::ZeroChapelDebt { actor_id: actor }) + ); + } + + #[test] + fn every_authoritative_float_category_rejects_all_nonfinite_values() { + for (index, field) in ["pos[0]", "pos[1]", "pos[2]"].into_iter().enumerate() { + rejects_nonfinite( + |image, value| image.actors[0].pos[index] = value, + "actors", + 1, + field, + ); + } + rejects_nonfinite( + |image, value| image.actors[0].radius = value, + "actors", + 1, + "radius", + ); + for (index, field) in ["from[0]", "from[1]", "from[2]"].into_iter().enumerate() { + rejects_nonfinite( + |image, value| { + let mut row = connector(); + row.from[index] = value; + image.connectors.push(row); + }, + "connectors", + 17, + field, + ); + } + for (index, field) in ["to[0]", "to[1]", "to[2]"].into_iter().enumerate() { + rejects_nonfinite( + |image, value| { + let mut row = connector(); + row.to[index] = value; + image.connectors.push(row); + }, + "connectors", + 17, + field, + ); + } + rejects_nonfinite( + |image, value| { + let mut row = connector(); + row.length = value; + image.connectors.push(row); + }, + "connectors", + 17, + "length", + ); + rejects_nonfinite( + |image, value| { + let mut row = connector(); + row.load_kg = value; + image.connectors.push(row); + }, + "connectors", + 17, + "load_kg", + ); + rejects_nonfinite( + |image, value| { + image.pressure_nodes.push(PressureNode { + id: 23, + pressure: value, + }); + }, + "pressure_nodes", + 23, + "pressure", + ); + rejects_nonfinite( + |image, value| { + image.pressure_edges.push(PressureEdge { + from: 23, + to: 24, + openness: value, + hidden: false, + }); + }, + "pressure_edges", + 0, + "openness", + ); + rejects_nonfinite( + |image, value| { + image.fall_odo.insert(1, value); + }, + "fall_odo", + 1, + "distance", + ); + rejects_nonfinite( + |image, value| image.spill.gas_flows.push((23, 24, value)), + "spill.gas_flows", + 0, + "flow", + ); + rejects_nonfinite( + |image, value| { + let mut pack = Pack::new(); + pack.max_weight_kg = value; + image.packs.insert(1, pack); + }, + "packs", + 1, + "max_weight_kg", + ); + } + + #[test] + fn authoritative_float_ranges_are_enforced_without_rejecting_signed_flow() { + rejects_range(|image| image.actors[0].radius = 0.0, "actors", 1, "radius"); + rejects_range(|image| image.actors[0].radius = -0.1, "actors", 1, "radius"); + rejects_range( + |image| image.actors[0].pos[0] = f32::MAX, + "actors", + 1, + "pos[0]", + ); + rejects_range( + |image| image.actors[0].pos[2] = f32::MIN, + "actors", + 1, + "pos[2]", + ); + rejects_range( + |image| { + let mut row = connector(); + row.length = -1.0; + image.connectors.push(row); + }, + "connectors", + 17, + "length", + ); + rejects_range( + |image| { + let mut row = connector(); + row.length = 2.0; + image.connectors.push(row); + }, + "connectors", + 17, + "length", + ); + rejects_range( + |image| { + let mut row = connector(); + row.load_kg = -0.5; + image.connectors.push(row); + }, + "connectors", + 17, + "load_kg", + ); + rejects_range( + |image| { + image.pressure_edges.push(PressureEdge { + from: 23, + to: 24, + openness: -0.01, + hidden: false, + }); + }, + "pressure_edges", + 0, + "openness", + ); + rejects_range( + |image| { + image.pressure_edges.push(PressureEdge { + from: 23, + to: 24, + openness: 1.01, + hidden: true, + }); + }, + "pressure_edges", + 0, + "openness", + ); + rejects_range( + |image| { + image.fall_odo.insert(1, -0.5); + }, + "fall_odo", + 1, + "distance", + ); + rejects_range( + |image| { + let mut pack = Pack::new(); + pack.max_weight_kg = 0.0; + image.packs.insert(1, pack); + }, + "packs", + 1, + "max_weight_kg", + ); + rejects_range( + |image| { + let mut pack = Pack::new(); + pack.max_weight_kg = -0.1; + image.packs.insert(1, pack); + }, + "packs", + 1, + "max_weight_kg", + ); + + let mut image = valid_image(); + image.pressure_nodes.push(PressureNode { + id: 23, + pressure: -1.0, + }); + image.pressure_edges.push(PressureEdge { + from: 23, + to: 24, + openness: 0.75, + hidden: true, + }); + image.spill.gas_flows.push((23, 24, -0.25)); + assert!(State::from_image(image).is_ok()); + } + + #[test] + fn exhausted_counters_and_unadvanceable_far_clocks_are_rejected() { + let mut image = valid_image(); + image.tick = u64::MAX; + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Runtime(RuntimeInvariantError::TickExhausted) + ); + + let mut image = valid_image(); + image.far.day_tick = TICKS_PER_DAY; + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Runtime(RuntimeInvariantError::InvalidFarDayTick { + day_tick: TICKS_PER_DAY, + }) + ); + + let mut image = valid_image(); + image.far.day = u64::MAX; + image.far.day_tick = TICKS_PER_DAY - 1; + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Runtime(RuntimeInvariantError::FarDayExhausted) + ); + + let mut image = valid_image(); + image.next_id = u64::MAX; + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Runtime(RuntimeInvariantError::NextIdExhausted) + ); + } + + #[test] + fn one_pending_corpse_at_last_safe_id_restores_and_steps_once() { + let mut image = image_with_stone_floor(); + image.next_id = u64::MAX - 1; + image.actors[0].hp = 0; + let mut state = State::from_image(image).unwrap(); + state.step(); + assert_eq!(state.items.get(&(u64::MAX - 1)).unwrap().id, u64::MAX - 1); + assert_eq!(state.ground_items.get(&(u64::MAX - 1)), Some(&(0, 0, 0))); + assert!(format!("{state:?}").contains(&format!("next_id: {}", u64::MAX))); + } + + #[test] + fn multiple_pending_corpses_reject_when_id_headroom_overflows() { + let mut image = valid_image(); + image.next_id = u64::MAX - 1; + image.actors[0].hp = 0; + let mut second = image.actors[0].clone(); + second.id = 2; + image.actors.push(second); + image + .actor_identities + .insert(2, ActorIdentity::new("delver", "Bea", ActorRole::Player)); + image.wallets.insert(2, Wallet::default()); + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Runtime(RuntimeInvariantError::NextIdExhausted) + ); + } + + #[test] + fn accepted_clock_boundary_can_step_once() { + let mut image = valid_image(); + image.tick = u64::MAX - 1; + image.far.day = u64::MAX; + image.far.day_tick = TICKS_PER_DAY - 2; + let mut state = State::from_image(image).unwrap(); + state.step(); + assert_eq!(state.tick, u64::MAX); + assert_eq!(state.far.day, u64::MAX); + assert_eq!(state.far.day_tick, TICKS_PER_DAY - 1); + } + + #[test] + fn restored_falling_actor_saturates_hp_and_dies_in_gravity_path() { + let mut image = image_with_stone_floor(); + image.actors[0].pos = [0.0, 0.0, 32.0]; + image.actors[0].hp = i32::MIN + 1; + let mut state = State::from_image(image).unwrap(); + state.step(); + + assert_eq!(state.actors[0].pos[2], 0.0); + assert_eq!(state.actors[0].hp, i32::MIN); + assert!(!state.actors[0].alive); + assert_eq!( + state.items.len(), + 0, + "gravity death predates Phase 12 corpse discovery" + ); + assert!(state.causes.links().iter().any(|link| { + link.rule == "gravity" + && link + .detail + .contains("falls 32.0m and lands hard (30 damage)") + })); + assert!(state.causes.links().iter().any(|link| { + link.rule == "gravity" && link.detail.contains("killed by the fall (fell 32.0m)") + })); + assert!(!state.causes.links().iter().any(|link| link.rule == "death")); + } + + #[test] + fn exotic_finite_float_bits_survive_image_restore_and_hash() { + let mut state = State::new(41); + state.pressure_nodes.push(PressureNode { + id: 7, + pressure: -0.0, + }); + state.spill.gas_flows.push((7, 8, f32::from_bits(1))); + let hash_before = state.try_hash_state().unwrap(); + let image = state.to_image().unwrap(); + assert_eq!( + image.pressure_nodes[0].pressure.to_bits(), + (-0.0f32).to_bits() + ); + assert_eq!(image.spill.gas_flows[0].2.to_bits(), 1); + + let restored = State::from_image(image).unwrap(); + let round_trip = restored.to_image().unwrap(); + assert_eq!( + round_trip.pressure_nodes[0].pressure.to_bits(), + (-0.0f32).to_bits() + ); + assert_eq!(round_trip.spill.gas_flows[0].2.to_bits(), 1); + assert_eq!(restored.try_hash_state().unwrap(), hash_before); + } + + #[test] + fn chapel_debt_restore_requires_a_living_actor_positive_count_and_exact_chapel() { + let mut image = valid_image(); + image + .chapel_debts + .insert(999, crate::modes::resurrection_debt()); + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Runtime(RuntimeInvariantError::OrphanChapelDebt { actor_id: 999 }) + ); + + let mut image = valid_image(); + image.actors[0].alive = false; + image + .chapel_debts + .insert(1, crate::modes::resurrection_debt()); + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Runtime(RuntimeInvariantError::DeadChapelDebtor { actor_id: 1 }) + ); + + let mut image = valid_image(); + image.chapel_debts.insert( + 1, + ChapelDebt { + burials_owed: 2, + chapel: "Chapel of the Lamplighter".into(), + }, + ); + assert_eq!( + State::from_image(image).unwrap_err(), + StateImageError::Runtime(RuntimeInvariantError::NonCanonicalChapelDebt { actor_id: 1 }) + ); + + let mut image = valid_image(); + image.chapel_debts.insert( + 1, + ChapelDebt { + burials_owed: u8::MAX, + chapel: crate::modes::LAMPLIGHTER_CHAPEL.into(), + }, + ); + assert!(State::from_image(image).is_ok()); + } +} diff --git a/crates/hexwork-core/src/connector.rs b/crates/hexwork-core/src/connector.rs index 8a4ed05..a23e511 100644 --- a/crates/hexwork-core/src/connector.rs +++ b/crates/hexwork-core/src/connector.rs @@ -32,6 +32,22 @@ pub struct Connector { pub blocked: bool, } +impl ConnectorKind { + /// Stable persistence tag; declaration order is not the wire protocol. + #[must_use] + pub const fn canonical_tag(self) -> u8 { + match self { + Self::Stairwell => 0, + Self::Shaft => 1, + Self::Well => 2, + Self::Vent => 3, + Self::ElevatorCar => 4, + Self::Counterweight => 5, + Self::RopeAnchor => 6, + } + } +} + impl Connector { pub fn depth(&self) -> f32 { (self.to[2] - self.from[2]).abs() diff --git a/crates/hexwork-core/src/dungeon.rs b/crates/hexwork-core/src/dungeon.rs index 93150bf..870919a 100644 --- a/crates/hexwork-core/src/dungeon.rs +++ b/crates/hexwork-core/src/dungeon.rs @@ -148,8 +148,8 @@ pub struct MulePath { /// The Vault Choice, set once at floor 12 (docs/03 §3.7: Widow Rennick → /// Drowned Saint → Deep Door → SEAL vs CROWN, NG+ boss-haunt reads it). /// SEAL buries the machine (chapel wall, dark-free towns); CROWN takes the -/// Core's seat (NG+ haunts replays as a boss). Set-once is parent-enforced -/// (State never overwrites a set choice — see the setter-free shape). +/// Core's seat (NG+ haunts replays as a boss). [`crate::state::State`] owns +/// the implementation-enforced write-once setter and read-only accessor. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum VaultChoice { /// Bury the machine, end the salvage economy, towns go dark but free. @@ -158,6 +158,18 @@ pub enum VaultChoice { Crown, } +impl VaultChoice { + /// Stable persistence tag. Zero is reserved for an undecided State, so the + /// concrete choices retain their existing one-based protocol values. + #[must_use] + pub const fn canonical_tag(self) -> u8 { + match self { + Self::Seal => 1, + Self::Crown => 2, + } + } +} + /// Mule-pathing: Manhattan waypoints via chalk if present, else straight /// (docs/03 §3.7: mule-pathing replaces markers). /// diff --git a/crates/hexwork-core/src/epitaphs.rs b/crates/hexwork-core/src/epitaphs.rs index 7fb5d09..08d8fd3 100644 --- a/crates/hexwork-core/src/epitaphs.rs +++ b/crates/hexwork-core/src/epitaphs.rs @@ -23,7 +23,7 @@ //! 200+ target; new lines are appended to [`EPITAPHS`] (never reordered — //! the FNV slot of every existing cause is load-bearing for replay //! stability). [`RULE_SUBTITLES`] mirrors [`crate::laws::RULES`] plus the -//! four outcome tags (`gravity`, `rope`, `structure`, `death`) and grows +//! five outcome tags (`gravity`, `rope`, `structure`, `death`, `melee`) and grows //! toward 100+ replay subtitles the same way. //! //! Core law: pure functions only. No RNG, no I/O, no threads. Cause-hashing @@ -138,8 +138,8 @@ pub fn epitaph(cause: &str) -> String { /// cause-link rule via this function; [`crate::laws::explain`] stays the /// first-sight source during live play (docs/04 §4.10.5). /// -/// Covers the 16 canonical [`crate::laws::RULES`] plus the 4 outcome tags -/// (`gravity`, `rope`, `structure`, `death`): 20+ non-empty lines. Unknown +/// Covers the 16 canonical [`crate::laws::RULES`] plus the 5 outcome tags +/// (`gravity`, `rope`, `structure`, `death`, `melee`): 20+ non-empty lines. Unknown /// tags fall back to [`crate::laws::DEFAULT_LINE`] — the Deep keeps its own /// ledger. Grows toward 100+ at 1.0 alongside [`EPITAPHS`]. #[must_use] @@ -175,11 +175,12 @@ pub fn subtitle(rule: &str) -> &'static str { "rope" => "the rope gives way — CHECK GRATES; the replay counts the strands", "structure" => "the Deep shifts — masonry gives way; the replay dusts the frame", "death" => "it fell still — the replay bows its head", + "melee" => "steel answered claw — the replay marks each strike and sole return", _ => crate::laws::DEFAULT_LINE, } } -/// All subtitle rules: the 16 canonical laws plus the 4 outcome tags. +/// All subtitle rules: the 16 canonical laws plus the 5 outcome tags. /// /// Length is the machine-checked subtitle volume floor (20+ today, 100+ at /// 1.0). The parent feeds one [`subtitle`] call per cause-link rule. @@ -204,6 +205,7 @@ pub const RULE_SUBTITLES: &[&str] = &[ "rope", "structure", "death", + "melee", ]; #[cfg(test)] @@ -259,7 +261,7 @@ mod tests { #[test] fn epitaph_covers_all_laws_and_outcome_tags() { let mut rules: Vec<&str> = crate::laws::RULES.to_vec(); - rules.extend(["gravity", "rope", "structure", "death"]); + rules.extend(["gravity", "rope", "structure", "death", "melee"]); for rule in rules { let stone = epitaph(rule); assert!(!stone.is_empty(), "rule '{rule}' stamps an empty stone"); @@ -316,7 +318,7 @@ mod tests { "canonical law '{rule}' needs a replay subtitle" ); } - for rule in ["gravity", "rope", "structure", "death"] { + for rule in ["gravity", "rope", "structure", "death", "melee"] { assert!( RULE_SUBTITLES.contains(&rule), "outcome tag '{rule}' needs a replay subtitle" diff --git a/crates/hexwork-core/src/gravity.rs b/crates/hexwork-core/src/gravity.rs index 94d533e..5185d1e 100644 --- a/crates/hexwork-core/src/gravity.rs +++ b/crates/hexwork-core/src/gravity.rs @@ -306,7 +306,7 @@ pub fn step_falling_with_mass( } match victim { None => { - actors[plan.index].hp -= damage; + 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:.1}m)"); @@ -316,13 +316,13 @@ pub fn step_falling_with_mass( Some(j) => { let victim_share = damage / 2; let lander_share = damage - victim_share; - actors[plan.index].hp -= lander_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:.1}m)"); causes.push(CauseLink::new(tick, "gravity", epitaph)); } - actors[j].hp -= victim_share; + 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!( @@ -424,6 +424,39 @@ mod tests { .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![Actor::new(1, [0.0, 0.0, 32.0])]; + 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![ + Actor::new(1, [0.0, 0.0, 32.0]), + Actor::new(2, [0.5, 0.0, 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(); diff --git a/crates/hexwork-core/src/item.rs b/crates/hexwork-core/src/item.rs index 1a02778..4ec00d7 100644 --- a/crates/hexwork-core/src/item.rs +++ b/crates/hexwork-core/src/item.rs @@ -145,6 +145,22 @@ pub enum Condition { } impl Condition { + /// Stable persistence tag; declaration order is not the wire protocol. + #[must_use] + pub const fn canonical_tag(self) -> u8 { + match self { + Self::Sound => 0, + Self::Rust => 1, + Self::Damp => 2, + Self::Cracked => 3, + Self::Blessed => 4, + Self::Humming => 5, + Self::Owed => 6, + Self::Spent => 7, + Self::Perishable => 8, + } + } + /// All nine conditions, for round-trip tests and parent-side tables. pub const ALL: [Condition; 9] = [ Condition::Sound, diff --git a/crates/hexwork-core/src/laws.rs b/crates/hexwork-core/src/laws.rs index 2386a9b..fdc2f57 100644 --- a/crates/hexwork-core/src/laws.rs +++ b/crates/hexwork-core/src/laws.rs @@ -41,6 +41,36 @@ pub const RULES: [&str; 16] = [ /// entry above renders its own line in [`explain`]). pub const DEFAULT_LINE: &str = "The Deep keeps its own ledger — watch, and learn."; +/// Map an owned checkpoint spelling to the exact static core vocabulary. +/// Unknown strings are rejected by checkpoint restoration; no string is leaked. +#[must_use] +pub fn canonical_rule_tag(rule: &str) -> Option<&'static str> { + match rule { + "rooms breathe" => Some("rooms breathe"), + "heat rises" => Some("heat rises"), + "acid pits iron" => Some("acid pits iron"), + "shock grounds" => Some("shock grounds"), + "waste nothing" => Some("waste nothing"), + "gravity is fair" => Some("gravity is fair"), + "sound travels" => Some("sound travels"), + "light is truth" => Some("light is truth"), + "rust spreads" => Some("rust spreads"), + "grates leak" => Some("grates leak"), + "water douses" => Some("water douses"), + "heat melts" => Some("heat melts"), + "heat dries" => Some("heat dries"), + "wild surge" => Some("wild surge"), + "fire & heat" => Some("fire & heat"), + "the rope gives way" => Some("the rope gives way"), + "gravity" => Some("gravity"), + "rope" => Some("rope"), + "structure" => Some("structure"), + "death" => Some("death"), + "melee" => Some("melee"), + _ => None, + } +} + /// Registry of which laws have spoken, and on which tick. Ordered map: /// deterministic iteration for snapshots and replays (determinism law). #[derive(Clone, Debug, Default)] @@ -89,7 +119,7 @@ impl LawLog { /// /// Two tag families share this registry: LAW tags (physics of the Deep, /// canonical in [`RULES`]) and OUTCOME tags (`gravity`, `rope`, `structure`, -/// `death` — the module that delivered the hurt, used on damage/epitaph +/// `death`, `melee` — the module that delivered the hurt, used on damage/epitaph /// links). `explain` renders dedicated lines for both; only truly unknown /// tags fall to [`DEFAULT_LINE`]. pub fn explain(rule: &str) -> &'static str { @@ -114,6 +144,7 @@ pub fn explain(rule: &str) -> &'static str { "rope" => "The rope gives way — CHECK GRATES.", "structure" => "The Deep shifts — masonry gives way.", "death" => "It fell still.", + "melee" => "Steel answers claw — every surviving foe answers once.", _ => DEFAULT_LINE, } } @@ -148,6 +179,14 @@ mod tests { } } + #[test] + fn outcome_tags_are_canonical_and_explained() { + for tag in ["gravity", "rope", "structure", "death", "melee"] { + assert_eq!(canonical_rule_tag(tag), Some(tag)); + assert_ne!(explain(tag), DEFAULT_LINE); + } + } + #[test] fn unknown_rule_has_default() { assert_eq!(explain("frogs"), DEFAULT_LINE); diff --git a/crates/hexwork-core/src/lib.rs b/crates/hexwork-core/src/lib.rs index 1834ada..f6e3447 100644 --- a/crates/hexwork-core/src/lib.rs +++ b/crates/hexwork-core/src/lib.rs @@ -6,12 +6,14 @@ //! - All randomness from seeded streams (`seed ^ domain_tag`). //! - `BTreeMap`/indexed pools only — never `HashMap` in sim paths. +pub mod action; pub mod actor; pub mod air; pub mod bargain; pub mod casting; pub mod cause; pub mod character; +pub mod checkpoint; pub mod connector; pub mod death; pub mod dungeon; @@ -35,6 +37,7 @@ pub mod reputation; pub mod rng; pub mod rope; pub mod rot; +pub mod runtime; pub mod scent; pub mod schedule; pub mod services; diff --git a/crates/hexwork-core/src/light.rs b/crates/hexwork-core/src/light.rs index 5586f01..ef2925c 100644 --- a/crates/hexwork-core/src/light.rs +++ b/crates/hexwork-core/src/light.rs @@ -247,7 +247,7 @@ pub fn lantern_cone(origin: [f32; 3], dir: [f32; 3]) -> Vec<(i32, i32, i32)> { let py = cy as f32 - origin[1]; let pz = cz as f32 - origin[2]; let t = px * dx + py * dy + pz * dz; - if t < 0.5 || t > RANGE { + if !(0.5..=RANGE).contains(&t) { continue; } let perp2 = px * px + py * py + pz * pz - t * t; diff --git a/crates/hexwork-core/src/modes.rs b/crates/hexwork-core/src/modes.rs index 9eecda4..4be2705 100644 --- a/crates/hexwork-core/src/modes.rs +++ b/crates/hexwork-core/src/modes.rs @@ -82,6 +82,16 @@ pub struct ChapelDebt { pub chapel: String, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChapelDebtError { + UnknownActor, + DeadActor, + NoDebt, +} + +/// Exact canonical chapel name stored in Story resurrection debts. +pub const LAMPLIGHTER_CHAPEL: &str = "chapel of the Lamplighter"; + /// Wake at the chapel: 1 burial owed at the chapel of the Lamplighter. /// /// Both values are pinned — the Corpse-tender's terms are not negotiable @@ -91,7 +101,7 @@ pub struct ChapelDebt { pub fn resurrection_debt() -> ChapelDebt { ChapelDebt { burials_owed: 1, - chapel: "chapel of the Lamplighter".to_owned(), + chapel: LAMPLIGHTER_CHAPEL.to_owned(), } } @@ -179,7 +189,7 @@ mod tests { let d = resurrection_debt(); assert_eq!(d.burials_owed, 1, "a second chance costs one burial"); assert_eq!( - d.chapel, "chapel of the Lamplighter", + d.chapel, LAMPLIGHTER_CHAPEL, "the Corpse-tender keeps the Lamplighter's chapel" ); } @@ -195,7 +205,7 @@ mod tests { fn debt_paid_counts_down_longer_debts() { let mut d = ChapelDebt { burials_owed: 2, - chapel: "chapel of the Lamplighter".to_owned(), + chapel: LAMPLIGHTER_CHAPEL.to_owned(), }; assert!(!debt_paid(&mut d), "one of two worked = still owed"); assert_eq!(d.burials_owed, 1); @@ -207,7 +217,7 @@ mod tests { fn debt_paid_at_zero_stays_zero_and_reports_paid() { let mut d = ChapelDebt { burials_owed: 0, - chapel: "chapel of the Lamplighter".to_owned(), + chapel: LAMPLIGHTER_CHAPEL.to_owned(), }; assert!(debt_paid(&mut d), "extra rites are never punished"); assert_eq!(d.burials_owed, 0, "saturates, never wraps to 255"); diff --git a/crates/hexwork-core/src/npc.rs b/crates/hexwork-core/src/npc.rs index e730e49..f2f05c1 100644 --- a/crates/hexwork-core/src/npc.rs +++ b/crates/hexwork-core/src/npc.rs @@ -77,6 +77,28 @@ pub enum Job { /// Every [`Job`] variant, in declaration order. Pins the 13-count (see the /// [`Job`] note) and gives the reviewer one list to cross-check against /// `schedule::ALL_JOB_NAMES`. +impl Job { + /// Stable persistence tag; declaration order is not the wire protocol. + #[must_use] + pub const fn canonical_tag(self) -> u8 { + match self { + Self::Lamplighter => 0, + Self::Miller => 1, + Self::Tinker => 2, + Self::Lampwright => 3, + Self::Warden => 4, + Self::Mender => 5, + Self::Fence => 6, + Self::Auctioneer => 7, + Self::Innkeep => 8, + Self::CorpseTender => 9, + Self::Chaplain => 10, + Self::CoachDriver => 11, + Self::Beggar => 12, + } + } +} + pub const ALL_JOBS: [Job; 13] = [ Job::Lamplighter, Job::Miller, diff --git a/crates/hexwork-core/src/reputation.rs b/crates/hexwork-core/src/reputation.rs index 8605529..98fa955 100644 --- a/crates/hexwork-core/src/reputation.rs +++ b/crates/hexwork-core/src/reputation.rs @@ -65,6 +65,34 @@ pub enum GodId { Audit = 4, } +impl Faction { + /// Stable persistence tag; save discriminants and canonical bytes agree. + #[must_use] + pub const fn canonical_tag(self) -> u8 { + match self { + Self::Lampwrights => 0, + Self::Congregation => 1, + Self::Wardens => 2, + Self::Audit => 3, + Self::Choir => 4, + } + } +} + +impl GodId { + /// Stable persistence tag; save discriminants and canonical bytes agree. + #[must_use] + pub const fn canonical_tag(self) -> u8 { + match self { + Self::Lamplighter => 0, + Self::Tollkeeper => 1, + Self::Choir => 2, + Self::Rustmother => 3, + Self::Audit => 4, + } + } +} + /// Standing with one faction: rank plus Audit arrears. /// /// `debt_cu` is copper-unit arrears and may go negative, which means credit diff --git a/crates/hexwork-core/src/rng.rs b/crates/hexwork-core/src/rng.rs index 5001dcb..9d51c8e 100644 --- a/crates/hexwork-core/src/rng.rs +++ b/crates/hexwork-core/src/rng.rs @@ -2,8 +2,12 @@ //! domain-tagged sub-streams (`seed ^ domain_tag`), so adding a particle //! never reschedules combat (docs/04 §4.2). +/// Invalid canonical Xoshiro256** state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InvalidRngState; + /// Xoshiro256** — small, fast, fully deterministic across platforms. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct Rng { s: [u64; 4], } @@ -24,6 +28,22 @@ impl Rng { } } + /// Restore canonical internal words, rejecting Xoshiro's absorbing state. + pub const fn from_canonical_state(state: [u64; 4]) -> Result { + if state[0] == 0 && state[1] == 0 && state[2] == 0 && state[3] == 0 { + Err(InvalidRngState) + } else { + Ok(Self { s: state }) + } + } + + /// Canonical internal words for persistence/replay hashing. Their order is + /// the Xoshiro256** protocol order and must not be changed silently. + #[must_use] + pub const fn canonical_state(&self) -> [u64; 4] { + self.s + } + pub fn next_u64(&mut self) -> u64 { let result = self.s[1].wrapping_mul(5).rotate_left(7).wrapping_mul(9); let t = self.s[1] << 17; @@ -59,3 +79,18 @@ pub mod domains { pub const GOSSIP: u64 = 0x605519; pub const WORLDGEN_SEED: u64 = 0xBE550EED; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_state_restore_rejects_only_all_zero() { + assert_eq!(Rng::from_canonical_state([0; 4]), Err(InvalidRngState)); + let state = [0, 0, 0, 1]; + assert_eq!( + Rng::from_canonical_state(state).unwrap().canonical_state(), + state + ); + } +} diff --git a/crates/hexwork-core/src/runtime.rs b/crates/hexwork-core/src/runtime.rs new file mode 100644 index 0000000..55111f8 --- /dev/null +++ b/crates/hexwork-core/src/runtime.rs @@ -0,0 +1,386 @@ +//! Content-agnostic authoritative live-run records. +//! +//! Template ids, display names, town ids, and contract ids are opaque strings: +//! content crates choose their values while the deterministic core owns their +//! lifecycle, arithmetic, validation, and canonical persistence projection. + +/// Simulation role attached to an actor's canonical identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ActorRole { + Player, + Npc, + Monster, +} + +impl ActorRole { + /// Stable persistence tag; declaration order is not the wire protocol. + #[must_use] + pub const fn canonical_tag(self) -> u8 { + match self { + Self::Player => 0, + Self::Npc => 1, + Self::Monster => 2, + } + } +} + +/// Canonical identity for an authoritative [`crate::actor::Actor`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActorIdentity { + pub template_id: String, + pub display_name: String, + pub role: ActorRole, +} + +impl ActorIdentity { + pub fn new( + template_id: impl Into, + display_name: impl Into, + role: ActorRole, + ) -> Self { + Self { + template_id: template_id.into(), + display_name: display_name.into(), + role, + } + } + + /// Presentation-only fallback derived from canonical identity. + #[must_use] + pub fn fallback_glyph(&self) -> char { + match self.role { + ActorRole::Player => '@', + ActorRole::Npc => 'n', + ActorRole::Monster => self.template_id.chars().next().unwrap_or('m'), + } + } + + pub(crate) fn legacy() -> Self { + Self::new("actor", "Actor", ActorRole::Npc) + } +} + +/// Currency carried by one actor. Balances are private so all mutation uses +/// checked State transactions or these explicit arithmetic operations. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Wallet { + copper: u32, + scrip: u32, +} + +/// Why a checked wallet operation failed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WalletError { + Overflow, + InsufficientFunds, +} + +impl Wallet { + #[must_use] + pub const fn new(copper: u32, scrip: u32) -> Self { + Self { copper, scrip } + } + + #[must_use] + pub const fn copper(&self) -> u32 { + self.copper + } + + #[must_use] + pub const fn scrip(&self) -> u32 { + self.scrip + } + + pub fn checked_earn_copper(&mut self, amount: u32) -> Result<(), WalletError> { + self.copper = self + .copper + .checked_add(amount) + .ok_or(WalletError::Overflow)?; + Ok(()) + } + + pub fn checked_earn_scrip(&mut self, amount: u32) -> Result<(), WalletError> { + self.scrip = self + .scrip + .checked_add(amount) + .ok_or(WalletError::Overflow)?; + Ok(()) + } + + /// Credit up to `amount` and return the amount actually credited. + pub fn saturating_earn_copper(&mut self, amount: u32) -> u32 { + let credited = amount.min(u32::MAX - self.copper); + self.copper += credited; + credited + } + + /// Credit up to `amount` and return the amount actually credited. + pub fn saturating_earn_scrip(&mut self, amount: u32) -> u32 { + let credited = amount.min(u32::MAX - self.scrip); + self.scrip += credited; + credited + } + + pub fn checked_spend_copper(&mut self, amount: u32) -> Result<(), WalletError> { + self.copper = self + .copper + .checked_sub(amount) + .ok_or(WalletError::InsufficientFunds)?; + Ok(()) + } + + pub fn checked_spend_scrip(&mut self, amount: u32) -> Result<(), WalletError> { + self.scrip = self + .scrip + .checked_sub(amount) + .ok_or(WalletError::InsufficientFunds)?; + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ContractStatus { + Active, + Completed, + TurnedIn, + Failed, +} + +impl ContractStatus { + /// Stable persistence tag; declaration order is not the wire protocol. + #[must_use] + pub const fn canonical_tag(self) -> u8 { + match self { + Self::Active => 0, + Self::Completed => 1, + Self::TurnedIn => 2, + Self::Failed => 3, + } + } +} + +/// Runtime projection of content-defined contract data. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActiveContract { + pub id: String, + pub taker: u64, + pub accepted_tick: u64, + pub floor: u8, + pub pay_cu: u32, + pub objective_target: u32, + pub objective_progress: u32, + pub status: ContractStatus, + /// Town where completion must be turned in. + pub reward_town: String, + /// Exact town-ledger standing awarded with copper. + pub standing_delta: i16, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RuntimeInvariantError { + EcsNotEmpty { + entities: u32, + }, + ZeroStableId { + table: &'static str, + }, + DuplicateActorId { + actor_id: u64, + }, + StableIdCollision { + id: u64, + first: &'static str, + second: &'static str, + }, + MissingIdentity { + actor_id: u64, + }, + OrphanIdentity { + actor_id: u64, + }, + MissingWallet { + actor_id: u64, + }, + OrphanWallet { + actor_id: u64, + }, + OrphanActorMetadata { + table: &'static str, + actor_id: u64, + }, + OrphanChapelDebt { + actor_id: u64, + }, + DeadChapelDebtor { + actor_id: u64, + }, + ZeroChapelDebt { + actor_id: u64, + }, + NonCanonicalChapelDebt { + actor_id: u64, + }, + NonCanonicalReputationArray { + actor_id: u64, + table: &'static str, + index: usize, + expected: u8, + found: u8, + }, + NonFiniteField { + table: &'static str, + row: u64, + field: &'static str, + }, + InvalidFloatRange { + table: &'static str, + row: u64, + field: &'static str, + }, + UnknownStaticRule { + rule: String, + }, + ContractKeyMismatch { + key: String, + embedded: String, + }, + InvalidContractId { + id: String, + }, + InvalidContractFloor { + id: String, + floor: u8, + }, + InvalidContractTarget { + id: String, + }, + InvalidContractProgress { + id: String, + }, + InvalidRewardTown { + id: String, + }, + ContractTakerMissing { + id: String, + taker: u64, + }, + ContractTakerDead { + id: String, + taker: u64, + }, + ItemKeyMismatch { + table: &'static str, + key: u64, + embedded: u64, + }, + DanglingEquippedItem { + actor_id: u64, + item_id: u64, + }, + DuplicateItemId { + item_id: u64, + }, + DanglingGroundItem { + item_id: u64, + }, + LooseItemMissingGround { + item_id: u64, + }, + InvalidRunLocation, + TickExhausted, + InvalidFarDayTick { + day_tick: u64, + }, + FarDayExhausted, + NextIdNotAboveLiveId { + next_id: u64, + live_id: u64, + }, + NextIdExhausted, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ContractError { + BlankId, + DuplicateId, + InvalidFloor, + InvalidObjectiveTarget, + BlankRewardTown, + UnknownTaker, + DeadTaker, + NotFound, + NotActive, + NotOpen, + NotCompleted, + WrongTaker, + WrongLocation, + MissingWallet, + MissingReputation, + PayoutOverflow, + StandingOverflow, + InvalidRuntime(RuntimeInvariantError), +} + +/// Damage leg rejected by [`crate::state::State::resolve_melee_exchange`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MeleeStrike { + Outgoing, + Retaliation, +} + +/// Atomic reciprocal-melee failure. State is unchanged for every variant. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MeleeError { + InvalidRuntime(RuntimeInvariantError), + SameActor, + UnknownAttacker, + UnknownDefender, + DeadAttacker, + DeadDefender, + NonPositiveDamage { strike: MeleeStrike }, + ObjectiveRequiresDefenderDeath, + ObjectiveComplete, + InvalidObjective(ContractError), + IdExhausted, + AllocationFailed, +} + +/// Fully committed zero-tick reciprocal-melee outcome. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MeleeExchange { + pub outgoing_damage: i32, + pub defender_killed: bool, + pub retaliation_damage: Option, + pub attacker_killed: bool, + pub objective_progress: Option<(u32, u32)>, +} + +/// Coarse authoritative run location. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RunLocation { + /// No content plan/load has selected a starting town yet. + Uninitialized, + Town(String), + Deep(u8), +} + +impl RunLocation { + /// Stable persistence tag for the location kind. Town id and Deep depth + /// are encoded separately after this tag. + #[must_use] + pub const fn canonical_tag(&self) -> u8 { + match self { + Self::Uninitialized => 0, + Self::Town(_) => 1, + Self::Deep(_) => 2, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LocationError { + BlankTown, + InvalidDepth, + IllegalTransition, + InvalidRuntime(RuntimeInvariantError), +} diff --git a/crates/hexwork-core/src/state.rs b/crates/hexwork-core/src/state.rs index f3735de..519042d 100644 --- a/crates/hexwork-core/src/state.rs +++ b/crates/hexwork-core/src/state.rs @@ -1,8 +1,10 @@ //! World state: the deterministic sim kernel (docs/04 §4.2–4.3). //! -//! Owns the tick counter, master seed, RNG streams, ECS entities, voxel -//! grid, actors, connectors, pressure graph, and the cause-link record. -//! All 12 phases run here in fixed order. No I/O. No threads. No wall-clock. +//! Owns the tick counter, master seed, RNG streams, voxel grid, actors, +//! connectors, pressure graph, and the cause-link record. `actors` is the sole +//! simulation entity authority; `ecs` is dormant infrastructure and receives +//! no mirrored entities. All 12 phases run here in fixed order. No I/O. No +//! threads. No wall-clock. use std::collections::BTreeMap; @@ -12,6 +14,7 @@ use crate::bargain::Ledger; use crate::casting::{self, CastCtx}; use crate::cause::{CauseChain, CauseLink}; use crate::character::{record_use, Character, Skill}; +use crate::checkpoint::{CauseImage, ChunkImage, LawImage, StateImage, StateImageError}; use crate::connector::Connector; use crate::dungeon::{Chalk, MulePath, VaultChoice}; use crate::ecs::Ecs; @@ -20,15 +23,20 @@ use crate::fluids; use crate::gravity; use crate::hymn::{self, HymnCase}; use crate::inventory::Pack; -use crate::item::Instance; +use crate::item::{self, Instance, ItemTemplate}; use crate::laws::{self, LawLog}; use crate::light::{self, LightSource}; +use crate::modes::{self, ChapelDebt}; use crate::npc::{ShopMemory, Townsfolk}; use crate::phases::{Phase, ORDER}; use crate::pressure::{PressureEdge, PressureNode}; use crate::reputation::Reputation; use crate::rng::{domains, Rng}; use crate::rot::{self, CorpseClock}; +use crate::runtime::{ + ActiveContract, ActorIdentity, ContractError, ContractStatus, LocationError, MeleeError, + MeleeExchange, MeleeStrike, RunLocation, RuntimeInvariantError, Wallet, WalletError, +}; use crate::scent; use crate::schedule::day_phase; use crate::signal; @@ -41,6 +49,25 @@ pub const TICK_DT_SECS: f64 = 0.1; /// Ticks per in-game day at 10Hz (24 in-game minutes per real second would be /// too fast; the day clock below is a schedule driver, not real time). pub const TICKS_PER_DAY: u64 = 8_640; +// Nearest inward f32 values at the i32 extrema; enough headroom for the +// active-region +/-24 arithmetic performed during one ordinary step. +const SAFE_ACTOR_COORD_MIN: f32 = -2_147_483_520.0; +const SAFE_ACTOR_COORD_MAX: f32 = 2_147_483_520.0; + +/// Checked unattended-time preflight failure. The state is unchanged on error. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnattendedAdvanceError { + TickOverflow, + FarFieldOverflow, +} + +/// Atomic paid unattended-time failure. Both wallet and clocks remain unchanged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PaidUnattendedError { + Wallet(WalletError), + Clock(UnattendedAdvanceError), +} + /// Active-region half-extent in voxels around the lead actor. Fluids, fire, /// and signal scan this box; `structure`/`scent` clamp it to their 64-span /// bound internally (deterministic truncation, documented). Sized so the @@ -83,6 +110,35 @@ impl FarField { self.day += 1; } } + + fn checked_advance_by(&self, ticks: u64) -> Option<(u64, u64)> { + if self.day_tick >= TICKS_PER_DAY { + return None; + } + let whole_days = ticks / TICKS_PER_DAY; + let remainder = ticks % TICKS_PER_DAY; + let day_ticks = self.day_tick.checked_add(remainder)?; + let carry = day_ticks / TICKS_PER_DAY; + let day = self.day.checked_add(whole_days)?.checked_add(carry)?; + Some((day, day_ticks % TICKS_PER_DAY)) + } +} + +#[derive(Debug)] +struct PreparedDeath { + actor_id: u64, + actor_index: usize, + position: [f32; 3], + corpse: Instance, + contracts_to_fail: Vec, + death_detail: String, +} + +#[derive(Debug)] +struct PreparedDeathBatch { + deaths: Vec, + learned_detail: Option, + next_id: u64, } /// Deterministic world kernel. No I/O. No threads. No wall-clock. @@ -91,9 +147,16 @@ pub struct State { pub tick: u64, pub seed: u64, rng_master: Rng, + /// Dormant infrastructure. [`State::actors`] is the sole simulation + /// entity authority and production actor spawns never mirror into ECS. pub ecs: Ecs, pub grid: ChunkedGrid, + /// Sole authoritative collection of simulation entities. pub actors: Vec, + actor_identities: BTreeMap, + wallets: BTreeMap, + contracts: BTreeMap, + run_location: RunLocation, pub connectors: Vec, pub pressure_nodes: Vec, pub pressure_edges: Vec, @@ -131,6 +194,9 @@ pub struct State { /// live in content data; this table owns the per-run state (condition, /// fuel, curses). Corpse-drop on death mints corpse-tagged instances. pub items: BTreeMap, + /// Canonical positions for loose item instances. Keys always reference + /// [`State::items`]; carried items instead live exclusively in a Pack. + pub ground_items: BTreeMap, /// HAUL packs keyed by actor id (docs/03 §3.2: weight + bulk + noise). pub packs: BTreeMap, /// Per-delver identification working book (docs/03 §3.4). Template ids @@ -172,8 +238,11 @@ pub struct State { /// actor; this is its chalk-following route. None = unhitched/gone. pub mule: Option, /// The Vault Choice, set once at floor 12 (docs/03 §3.7: SEAL vs CROWN, - /// NG+ boss-haunt reads it). None = undecided. - pub vault_choice: Option, + /// NG+ boss-haunt reads it). None = undecided. Private so callers cannot + /// bypass [`State::set_vault_choice`]'s write-once invariant. + vault_choice: Option, + /// Authoritative Story resurrection labor, keyed by the indebted actor. + chapel_debts: BTreeMap, next_id: u64, /// When true, the outer loop advances exactly one tick per Shift press /// (roguelike step mode, docs/03 §3.2). The kernel just exposes the @@ -184,6 +253,95 @@ pub struct State { /// Canonical hymn-id → verb map (mirrors assets/data/hymns.toml verbs). /// New hymns extend BOTH this table and the TOML; the content-shape test /// pins them together (wiring-plan §1). +/// Canonical persistence/replay hash protocol version. Increment whenever any +/// domain, field order, enum tag, or primitive encoding changes. +pub const CANONICAL_HASH_VERSION: u64 = 3; + +/// Release-build budget for explicitly hashing a fully populated 27-chunk +/// persistence snapshot. Canonical hashing is intentionally a persistence, +/// checkpoint, and replay boundary operation—not tick or render work. Parent +/// callers must snapshot/hash off the tick/render path and may move this work +/// to a background worker when their ownership model permits it. +pub const CANONICAL_HASH_RELEASE_BUDGET_MS: u64 = 250; + +struct CanonicalHasher { + hash: u64, +} + +impl CanonicalHasher { + fn new() -> Self { + let mut hasher = Self { + hash: 0xcbf2_9ce4_8422_2325, + }; + hasher.u64(0x4845_5857_4f52_4b00); // "HEXWORK\0" + hasher.u64(CANONICAL_HASH_VERSION); + hasher + } + + fn domain(&mut self, tag: u64, len: usize) { + self.u64(0xd04a_1e00_0000_0000 | tag); + self.u64(len as u64); + } + + fn u64(&mut self, value: u64) { + self.hash ^= value; + self.hash = self.hash.wrapping_mul(0x100_0000_01b3); + } + + fn bool(&mut self, value: bool) { + self.u64(u64::from(value)); + } + + fn bytes(&mut self, bytes: &[u8]) { + self.u64(bytes.len() as u64); + for &byte in bytes { + self.u64(u64::from(byte)); + } + } + + fn text(&mut self, text: &str) { + self.bytes(text.as_bytes()); + } + + fn option_u64(&mut self, value: Option) { + match value { + None => self.u64(0), + Some(value) => { + self.u64(1); + self.u64(value); + } + } + } + + fn cell(&mut self, cell: (i32, i32, i32)) { + self.u64(cell.0 as u64); + self.u64(cell.1 as u64); + self.u64(cell.2 as u64); + } + + fn item(&mut self, item: &Instance) { + self.u64(item.id); + self.text(&item.template); + self.u64(u64::from(item.material)); + self.bool(item.identified); + self.bool(item.cursed); + self.bool(item.blessed); + self.u64(u64::from(item.condition.canonical_tag())); + self.bool(item.silvered); + self.bool(item.illegal); + self.domain(1, item.tags.len()); + for tag in &item.tags { + self.text(tag); + } + self.u64(u64::from(item.fuel)); + self.u64(item.wet_ticks); + } + + fn finish(self) -> u64 { + self.hash + } +} + fn template_to_verb(template: &str) -> String { match template { "fiat_lux" => "FIAT LUX", @@ -226,6 +384,11 @@ impl State { ecs: Ecs::new(), grid: ChunkedGrid::new(), actors: Vec::new(), + actor_identities: BTreeMap::new(), + wallets: BTreeMap::new(), + contracts: BTreeMap::new(), + // Content chooses the starting town id before live play. + run_location: RunLocation::Uninitialized, connectors: Vec::new(), pressure_nodes: Vec::new(), pressure_edges: Vec::new(), @@ -241,6 +404,7 @@ impl State { characters: BTreeMap::new(), reputations: BTreeMap::new(), items: BTreeMap::new(), + ground_items: BTreeMap::new(), packs: BTreeMap::new(), idf: BTreeMap::new(), hymn_cases: BTreeMap::new(), @@ -253,23 +417,1031 @@ impl State { chalk: Chalk::new(), mule: None, vault_choice: None, + chapel_debts: BTreeMap::new(), next_id: 1, step_mode: false, } - } - - fn alloc_id(&mut self) -> u64 { - let id = self.next_id; - self.next_id += 1; - id - } - - pub fn spawn_actor(&mut self, pos: [f32; 3]) -> u64 { - let id = self.alloc_id(); - let mut a = Actor::new(id, pos); - a.hp = 10; - self.actors.push(a); - id + } + + fn alloc_id(&mut self) -> u64 { + let id = self.next_id; + self.next_id = self + .next_id + .checked_add(1) + .expect("authoritative id space exhausted"); + id + } + + /// Read the run's irrevocable Vault Choice, or `None` before floor 12. + #[must_use] + pub fn vault_choice(&self) -> Option { + self.vault_choice + } + + /// Commit the Vault Choice exactly once. + /// + /// A second attempt cannot replace the first choice and returns that + /// original choice as the error, allowing UI callers to explain what was + /// already sworn without reading or mutating internal state directly. + pub fn set_vault_choice(&mut self, choice: VaultChoice) -> Result<(), VaultChoice> { + if let Some(original) = self.vault_choice { + return Err(original); + } + self.vault_choice = Some(choice); + Ok(()) + } + + /// Read an actor's outstanding Story resurrection labor. + #[must_use] + pub fn chapel_debt(&self, actor_id: u64) -> Option<&ChapelDebt> { + self.chapel_debts.get(&actor_id) + } + + /// Assign the canonical one-burial resurrection debt to a living actor. + pub fn assign_chapel_debt(&mut self, actor_id: u64) -> Result<(), modes::ChapelDebtError> { + let actor = self + .actors + .iter() + .find(|actor| actor.id == actor_id) + .ok_or(modes::ChapelDebtError::UnknownActor)?; + if !actor.alive { + return Err(modes::ChapelDebtError::DeadActor); + } + self.chapel_debts + .insert(actor_id, modes::resurrection_debt()); + Ok(()) + } + + /// Work one burial. The row is removed exactly when the debt is paid. + pub fn work_chapel_debt(&mut self, actor_id: u64) -> Result { + let debt = self + .chapel_debts + .get_mut(&actor_id) + .ok_or(modes::ChapelDebtError::NoDebt)?; + let paid = modes::debt_paid(debt); + if paid { + self.chapel_debts.remove(&actor_id); + } + Ok(paid) + } + + /// Compatibility spawn path. New content-aware callers should use + /// [`State::spawn_actor_with_identity`]. Both paths create exactly one + /// authoritative [`Actor`] and never create an ECS entity. + pub fn spawn_actor(&mut self, pos: [f32; 3]) -> u64 { + self.spawn_actor_with_identity(pos, ActorIdentity::legacy()) + } + + /// Spawn one authoritative actor and attach runtime metadata keyed by the + /// same stable id. ECS remains dormant: no mirrored entity is created. + pub fn spawn_actor_with_identity(&mut self, pos: [f32; 3], identity: ActorIdentity) -> u64 { + let id = self.alloc_id(); + let mut actor = Actor::new(id, pos); + actor.hp = 10; + self.actors.push(actor); + self.actor_identities.insert(id, identity); + self.wallets.insert(id, Wallet::default()); + id + } + + /// Spawn one loose content-derived item at a world cell and return its + /// stable authoritative id. Conversion errors do not consume an id. + pub fn spawn_ground_item( + &mut self, + template: &ItemTemplate, + position: (i32, i32, i32), + ) -> Result { + let id = self.next_id; + let instance = item::spawn(template, id)?; + let allocated = self.alloc_id(); + debug_assert_eq!(allocated, id); + self.items.insert(id, instance); + self.ground_items.insert(id, position); + Ok(id) + } + + /// Spawn one content-derived item directly into an existing actor pack and + /// return its stable authoritative id. Conversion and ownership checks run + /// before the id is consumed. + pub fn spawn_carried_item( + &mut self, + actor_id: u64, + template: &ItemTemplate, + ) -> Result { + if !self.actors.iter().any(|actor| actor.id == actor_id) { + return Err(format!("unknown item owner {actor_id}")); + } + if !self.packs.contains_key(&actor_id) { + return Err(format!("actor {actor_id} has no pack")); + } + let id = self.next_id; + let instance = item::spawn(template, id)?; + let allocated = self.alloc_id(); + debug_assert_eq!(allocated, id); + self.packs + .get_mut(&actor_id) + .expect("pack preflighted") + .add(instance); + Ok(id) + } + + #[must_use] + pub fn identity(&self, actor_id: u64) -> Option<&ActorIdentity> { + self.actor_identities.get(&actor_id) + } + + pub fn identity_mut(&mut self, actor_id: u64) -> Option<&mut ActorIdentity> { + self.actor_identities.get_mut(&actor_id) + } + + #[must_use] + pub fn wallet(&self, actor_id: u64) -> Option<&Wallet> { + self.wallets.get(&actor_id) + } + + pub fn earn_copper(&mut self, actor_id: u64, amount: u32) -> Result<(), WalletError> { + self.wallets + .get_mut(&actor_id) + .ok_or(WalletError::InsufficientFunds)? + .checked_earn_copper(amount) + } + + pub fn earn_scrip(&mut self, actor_id: u64, amount: u32) -> Result<(), WalletError> { + self.wallets + .get_mut(&actor_id) + .ok_or(WalletError::InsufficientFunds)? + .checked_earn_scrip(amount) + } + + pub fn spend_copper(&mut self, actor_id: u64, amount: u32) -> Result<(), WalletError> { + self.wallets + .get_mut(&actor_id) + .ok_or(WalletError::InsufficientFunds)? + .checked_spend_copper(amount) + } + + pub fn spend_scrip(&mut self, actor_id: u64, amount: u32) -> Result<(), WalletError> { + self.wallets + .get_mut(&actor_id) + .ok_or(WalletError::InsufficientFunds)? + .checked_spend_scrip(amount) + } + + #[must_use] + pub fn contract(&self, contract_id: &str) -> Option<&ActiveContract> { + self.contracts.get(contract_id) + } + + pub fn contracts(&self) -> impl Iterator { + self.contracts + .iter() + .map(|(id, contract)| (id.as_str(), contract)) + } + + #[allow(clippy::too_many_arguments)] + pub fn accept_contract( + &mut self, + id: impl Into, + taker: u64, + floor: u8, + pay_cu: u32, + objective_target: u32, + reward_town: impl Into, + standing_delta: i16, + ) -> Result<(), ContractError> { + self.validate_runtime() + .map_err(ContractError::InvalidRuntime)?; + let id = id.into(); + let reward_town = reward_town.into(); + if id.trim().is_empty() { + return Err(ContractError::BlankId); + } + if self.contracts.contains_key(&id) { + return Err(ContractError::DuplicateId); + } + if !(1..=12).contains(&floor) { + return Err(ContractError::InvalidFloor); + } + if objective_target == 0 { + return Err(ContractError::InvalidObjectiveTarget); + } + if reward_town.trim().is_empty() { + return Err(ContractError::BlankRewardTown); + } + let actor = self + .actors + .iter() + .find(|actor| actor.id == taker) + .ok_or(ContractError::UnknownTaker)?; + if !actor.alive { + return Err(ContractError::DeadTaker); + } + if self.run_location != RunLocation::Town(reward_town.clone()) { + return Err(ContractError::WrongLocation); + } + self.contracts.insert( + id.clone(), + ActiveContract { + id, + taker, + accepted_tick: self.tick, + floor, + pay_cu, + objective_target, + objective_progress: 0, + status: ContractStatus::Active, + reward_town, + standing_delta, + }, + ); + Ok(()) + } + + pub fn advance_contract_objective( + &mut self, + contract_id: &str, + taker: u64, + amount: u32, + ) -> Result<(), ContractError> { + self.validate_runtime() + .map_err(ContractError::InvalidRuntime)?; + let contract = self + .contracts + .get(contract_id) + .ok_or(ContractError::NotFound)?; + if contract.taker != taker { + return Err(ContractError::WrongTaker); + } + if contract.status != ContractStatus::Active { + return Err(ContractError::NotActive); + } + if self.run_location != RunLocation::Deep(contract.floor) { + return Err(ContractError::WrongLocation); + } + let contract = self + .contracts + .get_mut(contract_id) + .expect("contract checked above"); + contract.objective_progress = contract + .objective_progress + .saturating_add(amount) + .min(contract.objective_target); + if contract.objective_progress == contract.objective_target { + contract.status = ContractStatus::Completed; + } + Ok(()) + } + + /// Resolve one zero-tick reciprocal melee exchange as a checked transaction. + /// The attacker strikes first; a surviving defender retaliates exactly once. + #[allow(clippy::too_many_lines)] + pub fn resolve_melee_exchange( + &mut self, + attacker_id: u64, + defender_id: u64, + damage: i32, + retaliation_damage: i32, + defender_kill_objective: Option<&str>, + ) -> Result { + self.validate_runtime() + .map_err(MeleeError::InvalidRuntime)?; + if attacker_id == defender_id { + return Err(MeleeError::SameActor); + } + if damage <= 0 { + return Err(MeleeError::NonPositiveDamage { + strike: MeleeStrike::Outgoing, + }); + } + if retaliation_damage <= 0 { + return Err(MeleeError::NonPositiveDamage { + strike: MeleeStrike::Retaliation, + }); + } + + let attacker_index = self + .actors + .iter() + .position(|actor| actor.id == attacker_id) + .ok_or(MeleeError::UnknownAttacker)?; + let defender_index = self + .actors + .iter() + .position(|actor| actor.id == defender_id) + .ok_or(MeleeError::UnknownDefender)?; + let attacker = &self.actors[attacker_index]; + let defender = &self.actors[defender_index]; + if !attacker.alive || attacker.hp <= 0 { + return Err(MeleeError::DeadAttacker); + } + if !defender.alive || defender.hp <= 0 { + return Err(MeleeError::DeadDefender); + } + + let defender_hp = defender.hp.saturating_sub(damage); + let defender_killed = defender_hp <= 0; + let (attacker_hp, retaliation) = if defender_killed { + (attacker.hp, None) + } else { + ( + attacker.hp.saturating_sub(retaliation_damage), + Some(retaliation_damage), + ) + }; + let attacker_killed = attacker_hp <= 0; + let death_id = if defender_killed { + Some(defender_id) + } else if attacker_killed { + Some(attacker_id) + } else { + None + }; + + let objective_progress = if let Some(contract_id) = defender_kill_objective { + if !defender_killed { + return Err(MeleeError::ObjectiveRequiresDefenderDeath); + } + let contract = self + .contracts + .get(contract_id) + .ok_or(MeleeError::InvalidObjective(ContractError::NotFound))?; + if contract.taker != attacker_id { + return Err(MeleeError::InvalidObjective(ContractError::WrongTaker)); + } + if contract.status != ContractStatus::Active { + return Err(MeleeError::InvalidObjective(ContractError::NotActive)); + } + if self.run_location != RunLocation::Deep(contract.floor) { + return Err(MeleeError::InvalidObjective(ContractError::WrongLocation)); + } + if contract.objective_progress >= contract.objective_target { + return Err(MeleeError::ObjectiveComplete); + } + Some((contract.objective_progress + 1, contract.objective_target)) + } else { + None + }; + + let prepared_deaths = if let Some(actor_id) = death_id { + let unrelated_pending = self + .actors + .iter() + .filter(|actor| actor.alive && actor.hp <= 0) + .count() as u64; + let reserved_end = self + .next_id + .checked_add(1) + .and_then(|next| next.checked_add(unrelated_pending)); + if !reserved_end.is_some_and(|end| end < u64::MAX) { + return Err(MeleeError::IdExhausted); + } + Some(self.prepare_deaths(&[actor_id])?) + } else { + None + }; + + let cause_count = 1 + + usize::from(retaliation.is_some()) + + prepared_deaths.as_ref().map_or(0, |batch| { + batch.deaths.len() + usize::from(batch.learned_detail.is_some()) + }); + let outgoing_cause = + format!("actor {attacker_id} strikes actor {defender_id} ({damage} damage)"); + let retaliation_cause = retaliation.map(|actual| { + format!("actor {defender_id} retaliates against actor {attacker_id} ({actual} damage)") + }); + self.causes + .try_reserve(cause_count) + .map_err(|_| MeleeError::AllocationFailed)?; + + self.actors[defender_index].hp = defender_hp; + self.causes + .push(CauseLink::new(self.tick, "melee", outgoing_cause)); + if let Some(actual) = retaliation { + self.actors[attacker_index].hp = attacker_hp; + self.causes.push(CauseLink::new( + self.tick, + "melee", + retaliation_cause.expect("retaliation detail precomputed"), + )); + debug_assert_eq!(actual, retaliation_damage); + } + if let Some(prepared) = prepared_deaths { + self.commit_deaths(prepared, false); + } + if let (Some(contract_id), Some((current, target))) = + (defender_kill_objective, objective_progress) + { + let contract = self + .contracts + .get_mut(contract_id) + .expect("objective contract preflighted"); + contract.objective_progress = current; + if current == target { + contract.status = ContractStatus::Completed; + } + } + + debug_assert!(self.validate_runtime().is_ok()); + Ok(MeleeExchange { + outgoing_damage: damage, + defender_killed, + retaliation_damage: retaliation, + attacker_killed, + objective_progress, + }) + } + + pub fn fail_contract(&mut self, contract_id: &str) -> Result<(), ContractError> { + self.validate_runtime() + .map_err(ContractError::InvalidRuntime)?; + self.fail_contract_without_validation(contract_id) + } + + /// Apply the open → failed transition without validating the surrounding + /// State first. Death phase needs this narrow internal path because marking + /// a contracted taker dead temporarily violates the runtime invariant that + /// every open contract has a living taker. Callers must restore the full + /// invariant before returning control to the outer simulation loop. + fn fail_contract_without_validation(&mut self, contract_id: &str) -> Result<(), ContractError> { + let contract = self + .contracts + .get_mut(contract_id) + .ok_or(ContractError::NotFound)?; + if !matches!( + contract.status, + ContractStatus::Active | ContractStatus::Completed + ) { + return Err(ContractError::NotOpen); + } + contract.status = ContractStatus::Failed; + Ok(()) + } + + pub fn turn_in_contract(&mut self, contract_id: &str) -> Result<(), ContractError> { + self.validate_runtime() + .map_err(ContractError::InvalidRuntime)?; + let contract = self + .contracts + .get(contract_id) + .ok_or(ContractError::NotFound)?; + if contract.status != ContractStatus::Completed { + return Err(ContractError::NotCompleted); + } + if self.run_location != RunLocation::Town(contract.reward_town.clone()) { + return Err(ContractError::WrongLocation); + } + let taker = contract.taker; + let pay_cu = contract.pay_cu; + let reward_town = contract.reward_town.clone(); + let standing_delta = contract.standing_delta; + + let wallet = self + .wallets + .get(&taker) + .ok_or(ContractError::MissingWallet)?; + wallet + .copper() + .checked_add(pay_cu) + .ok_or(ContractError::PayoutOverflow)?; + let reputation = self + .reputations + .get(&taker) + .ok_or(ContractError::MissingReputation)?; + reputation + .ledger(&reward_town) + .checked_add(standing_delta) + .ok_or(ContractError::StandingOverflow)?; + + self.wallets + .get_mut(&taker) + .expect("wallet preflighted") + .checked_earn_copper(pay_cu) + .expect("wallet addition preflighted"); + self.reputations + .get_mut(&taker) + .expect("reputation preflighted") + .adjust_ledger(&reward_town, standing_delta); + self.contracts + .get_mut(contract_id) + .expect("contract checked above") + .status = ContractStatus::TurnedIn; + Ok(()) + } + + #[must_use] + pub fn run_location(&self) -> &RunLocation { + &self.run_location + } + + /// Select the initial nonblank town after content planning/loading. + pub fn initialize_town(&mut self, town: impl Into) -> Result<(), LocationError> { + self.validate_runtime() + .map_err(LocationError::InvalidRuntime)?; + let town = town.into(); + if town.trim().is_empty() { + return Err(LocationError::BlankTown); + } + if self.run_location != RunLocation::Uninitialized { + return Err(LocationError::IllegalTransition); + } + self.run_location = RunLocation::Town(town); + Ok(()) + } + + /// Move one legal edge: Town→Deep1, Deep1→Town, or adjacent Deep floors. + pub fn transition_location(&mut self, next: RunLocation) -> Result<(), LocationError> { + self.validate_runtime() + .map_err(LocationError::InvalidRuntime)?; + if let RunLocation::Town(town) = &next { + if town.trim().is_empty() { + return Err(LocationError::BlankTown); + } + } + if matches!(next, RunLocation::Deep(depth) if !(1..=12).contains(&depth)) { + return Err(LocationError::InvalidDepth); + } + let legal = match (&self.run_location, &next) { + (RunLocation::Town(_), RunLocation::Deep(1)) => true, + (RunLocation::Deep(1), RunLocation::Town(_)) => true, + (RunLocation::Deep(from), RunLocation::Deep(to)) => from.abs_diff(*to) == 1, + _ => false, + }; + if !legal { + return Err(LocationError::IllegalTransition); + } + self.run_location = next; + Ok(()) + } + + /// Checkpoint/load-only setter. It validates the restored value but does + /// not enforce gameplay adjacency; call [`State::validate_runtime`] after + /// restoring every remaining table. + pub fn restore_run_location(&mut self, location: RunLocation) -> Result<(), LocationError> { + match &location { + RunLocation::Town(town) if town.trim().is_empty() => { + return Err(LocationError::BlankTown) + } + RunLocation::Deep(depth) if !(1..=12).contains(depth) => { + return Err(LocationError::InvalidDepth) + } + _ => {} + } + self.run_location = location; + Ok(()) + } + + /// Validate canonical authority after world planning/load and before + /// persistence/replay hashing. Public legacy tables remain mutable, so the + /// parent MUST call this after assembling or restoring State. + pub fn validate_runtime(&self) -> Result<(), RuntimeInvariantError> { + if !self.ecs.world().is_empty() { + return Err(RuntimeInvariantError::EcsNotEmpty { + entities: self.ecs.world().len(), + }); + } + let mut actor_ids = BTreeMap::new(); + let mut max_live_id = 0; + for actor in &self.actors { + if actor.id == 0 { + return Err(RuntimeInvariantError::ZeroStableId { table: "actors" }); + } + if actor_ids.insert(actor.id, actor.alive).is_some() { + return Err(RuntimeInvariantError::DuplicateActorId { actor_id: actor.id }); + } + max_live_id = max_live_id.max(actor.id); + for (field, value) in [ + ("pos[0]", actor.pos[0]), + ("pos[1]", actor.pos[1]), + ("pos[2]", actor.pos[2]), + ("radius", actor.radius), + ] { + if !value.is_finite() { + return Err(RuntimeInvariantError::NonFiniteField { + table: "actors", + row: actor.id, + field, + }); + } + } + for (field, value) in [ + ("pos[0]", actor.pos[0]), + ("pos[1]", actor.pos[1]), + ("pos[2]", actor.pos[2]), + ] { + if !(SAFE_ACTOR_COORD_MIN..=SAFE_ACTOR_COORD_MAX).contains(&value) { + return Err(RuntimeInvariantError::InvalidFloatRange { + table: "actors", + row: actor.id, + field, + }); + } + } + if actor.radius <= 0.0 { + return Err(RuntimeInvariantError::InvalidFloatRange { + table: "actors", + row: actor.id, + field: "radius", + }); + } + if !self.actor_identities.contains_key(&actor.id) { + return Err(RuntimeInvariantError::MissingIdentity { actor_id: actor.id }); + } + if !self.wallets.contains_key(&actor.id) { + return Err(RuntimeInvariantError::MissingWallet { actor_id: actor.id }); + } + } + for id in self.actor_identities.keys() { + if !actor_ids.contains_key(id) { + return Err(RuntimeInvariantError::OrphanIdentity { actor_id: *id }); + } + } + for id in self.wallets.keys() { + if !actor_ids.contains_key(id) { + return Err(RuntimeInvariantError::OrphanWallet { actor_id: *id }); + } + } + for (table, ids) in [ + ( + "fall_odo", + self.fall_odo.keys().copied().collect::>(), + ), + ("characters", self.characters.keys().copied().collect()), + ("reputations", self.reputations.keys().copied().collect()), + ("packs", self.packs.keys().copied().collect()), + ("hymn_cases", self.hymn_cases.keys().copied().collect()), + ("bargains", self.bargains.keys().copied().collect()), + ("npcs", self.npcs.keys().copied().collect()), + ] { + if let Some(id) = ids.into_iter().find(|id| !actor_ids.contains_key(id)) { + return Err(RuntimeInvariantError::OrphanActorMetadata { + table, + actor_id: id, + }); + } + } + let mut item_ids = BTreeMap::new(); + for (key, item) in &self.items { + if *key == 0 || item.id == 0 { + return Err(RuntimeInvariantError::ZeroStableId { table: "items" }); + } + if actor_ids.contains_key(key) { + return Err(RuntimeInvariantError::StableIdCollision { + id: *key, + first: "actors", + second: "items", + }); + } + if *key != item.id { + return Err(RuntimeInvariantError::ItemKeyMismatch { + table: "items", + key: *key, + embedded: item.id, + }); + } + item_ids.insert(item.id, ()); + max_live_id = max_live_id.max(item.id); + if !self.ground_items.contains_key(key) { + return Err(RuntimeInvariantError::LooseItemMissingGround { item_id: *key }); + } + } + for item_id in self.ground_items.keys() { + if !self.items.contains_key(item_id) { + return Err(RuntimeInvariantError::DanglingGroundItem { item_id: *item_id }); + } + } + const CANONICAL_FACTIONS: [crate::reputation::Faction; 5] = [ + crate::reputation::Faction::Lampwrights, + crate::reputation::Faction::Congregation, + crate::reputation::Faction::Wardens, + crate::reputation::Faction::Audit, + crate::reputation::Faction::Choir, + ]; + const CANONICAL_GODS: [crate::reputation::GodId; 5] = [ + crate::reputation::GodId::Lamplighter, + crate::reputation::GodId::Tollkeeper, + crate::reputation::GodId::Choir, + crate::reputation::GodId::Rustmother, + crate::reputation::GodId::Audit, + ]; + for (actor_id, reputation) in &self.reputations { + for (index, ((found, _), expected)) in reputation + .factions + .iter() + .zip(CANONICAL_FACTIONS) + .enumerate() + { + if *found != expected { + return Err(RuntimeInvariantError::NonCanonicalReputationArray { + actor_id: *actor_id, + table: "factions", + index, + expected: expected.canonical_tag(), + found: found.canonical_tag(), + }); + } + } + for (index, ((found, _), expected)) in + reputation.god_credit.iter().zip(CANONICAL_GODS).enumerate() + { + if *found != expected { + return Err(RuntimeInvariantError::NonCanonicalReputationArray { + actor_id: *actor_id, + table: "god_credit", + index, + expected: expected.canonical_tag(), + found: found.canonical_tag(), + }); + } + } + } + for (actor_id, pack) in &self.packs { + for (key, item) in &pack.items { + if *key == 0 || item.id == 0 { + return Err(RuntimeInvariantError::ZeroStableId { table: "packs" }); + } + if actor_ids.contains_key(key) { + return Err(RuntimeInvariantError::StableIdCollision { + id: *key, + first: "actors", + second: "packs", + }); + } + if *key != item.id { + return Err(RuntimeInvariantError::ItemKeyMismatch { + table: "packs", + key: *key, + embedded: item.id, + }); + } + if item_ids.insert(item.id, ()).is_some() { + return Err(RuntimeInvariantError::DuplicateItemId { item_id: item.id }); + } + max_live_id = max_live_id.max(item.id); + } + for item_id in [pack.equipped_weapon, pack.equipped_armor] + .into_iter() + .flatten() + { + if !pack.items.contains_key(&item_id) { + return Err(RuntimeInvariantError::DanglingEquippedItem { + actor_id: *actor_id, + item_id, + }); + } + } + } + for connector in &self.connectors { + for (field, value) in [ + ("from[0]", connector.from[0]), + ("from[1]", connector.from[1]), + ("from[2]", connector.from[2]), + ("to[0]", connector.to[0]), + ("to[1]", connector.to[1]), + ("to[2]", connector.to[2]), + ("length", connector.length), + ("load_kg", connector.load_kg), + ] { + if !value.is_finite() { + return Err(RuntimeInvariantError::NonFiniteField { + table: "connectors", + row: connector.id, + field, + }); + } + } + if connector.length < 0.0 || connector.length < connector.depth() { + return Err(RuntimeInvariantError::InvalidFloatRange { + table: "connectors", + row: connector.id, + field: "length", + }); + } + if connector.load_kg < 0.0 { + return Err(RuntimeInvariantError::InvalidFloatRange { + table: "connectors", + row: connector.id, + field: "load_kg", + }); + } + } + for node in &self.pressure_nodes { + if !node.pressure.is_finite() { + return Err(RuntimeInvariantError::NonFiniteField { + table: "pressure_nodes", + row: node.id, + field: "pressure", + }); + } + } + for (index, edge) in self.pressure_edges.iter().enumerate() { + if !edge.openness.is_finite() { + return Err(RuntimeInvariantError::NonFiniteField { + table: "pressure_edges", + row: index as u64, + field: "openness", + }); + } + if !(0.0..=1.0).contains(&edge.openness) { + return Err(RuntimeInvariantError::InvalidFloatRange { + table: "pressure_edges", + row: index as u64, + field: "openness", + }); + } + } + for (actor_id, distance) in &self.fall_odo { + if !distance.is_finite() { + return Err(RuntimeInvariantError::NonFiniteField { + table: "fall_odo", + row: *actor_id, + field: "distance", + }); + } + if *distance < 0.0 { + return Err(RuntimeInvariantError::InvalidFloatRange { + table: "fall_odo", + row: *actor_id, + field: "distance", + }); + } + } + for (index, (_, _, flow)) in self.spill.gas_flows.iter().enumerate() { + if !flow.is_finite() { + return Err(RuntimeInvariantError::NonFiniteField { + table: "spill.gas_flows", + row: index as u64, + field: "flow", + }); + } + } + for (actor_id, pack) in &self.packs { + if !pack.max_weight_kg.is_finite() { + return Err(RuntimeInvariantError::NonFiniteField { + table: "packs", + row: *actor_id, + field: "max_weight_kg", + }); + } + if pack.max_weight_kg <= 0.0 { + return Err(RuntimeInvariantError::InvalidFloatRange { + table: "packs", + row: *actor_id, + field: "max_weight_kg", + }); + } + } + for (caster, _, _) in &self.cast_queue { + if !actor_ids.contains_key(caster) { + return Err(RuntimeInvariantError::OrphanActorMetadata { + table: "cast_queue", + actor_id: *caster, + }); + } + } + for shop in self.shops.values() { + for id in shop + .broke + .keys() + .chain(shop.stole.keys()) + .chain(shop.killed.keys()) + { + if !actor_ids.contains_key(id) { + return Err(RuntimeInvariantError::OrphanActorMetadata { + table: "shops", + actor_id: *id, + }); + } + } + } + for (actor_id, debt) in &self.chapel_debts { + let Some(alive) = actor_ids.get(actor_id) else { + return Err(RuntimeInvariantError::OrphanChapelDebt { + actor_id: *actor_id, + }); + }; + if !alive { + return Err(RuntimeInvariantError::DeadChapelDebtor { + actor_id: *actor_id, + }); + } + if debt.burials_owed == 0 { + return Err(RuntimeInvariantError::ZeroChapelDebt { + actor_id: *actor_id, + }); + } + if debt.chapel != modes::LAMPLIGHTER_CHAPEL { + return Err(RuntimeInvariantError::NonCanonicalChapelDebt { + actor_id: *actor_id, + }); + } + } + for link in self.causes.links() { + if laws::canonical_rule_tag(link.rule).is_none() { + return Err(RuntimeInvariantError::UnknownStaticRule { + rule: link.rule.to_owned(), + }); + } + } + for rule in self.laws.seen.keys() { + if laws::canonical_rule_tag(rule).is_none() { + return Err(RuntimeInvariantError::UnknownStaticRule { + rule: (*rule).to_owned(), + }); + } + } + for (key, contract) in &self.contracts { + if key != &contract.id { + return Err(RuntimeInvariantError::ContractKeyMismatch { + key: key.clone(), + embedded: contract.id.clone(), + }); + } + if contract.id.trim().is_empty() { + return Err(RuntimeInvariantError::InvalidContractId { + id: contract.id.clone(), + }); + } + if !(1..=12).contains(&contract.floor) { + return Err(RuntimeInvariantError::InvalidContractFloor { + id: contract.id.clone(), + floor: contract.floor, + }); + } + if contract.objective_target == 0 { + return Err(RuntimeInvariantError::InvalidContractTarget { + id: contract.id.clone(), + }); + } + if contract.objective_progress > contract.objective_target { + return Err(RuntimeInvariantError::InvalidContractProgress { + id: contract.id.clone(), + }); + } + if contract.reward_town.trim().is_empty() { + return Err(RuntimeInvariantError::InvalidRewardTown { + id: contract.id.clone(), + }); + } + let Some(alive) = actor_ids.get(&contract.taker) else { + return Err(RuntimeInvariantError::ContractTakerMissing { + id: contract.id.clone(), + taker: contract.taker, + }); + }; + if matches!( + contract.status, + ContractStatus::Active | ContractStatus::Completed + ) && !alive + { + return Err(RuntimeInvariantError::ContractTakerDead { + id: contract.id.clone(), + taker: contract.taker, + }); + } + } + match &self.run_location { + RunLocation::Town(town) if town.trim().is_empty() => { + return Err(RuntimeInvariantError::InvalidRunLocation) + } + RunLocation::Deep(depth) if !(1..=12).contains(depth) => { + return Err(RuntimeInvariantError::InvalidRunLocation) + } + _ => {} + } + if self.tick == u64::MAX { + return Err(RuntimeInvariantError::TickExhausted); + } + if self.far.day_tick >= TICKS_PER_DAY { + return Err(RuntimeInvariantError::InvalidFarDayTick { + day_tick: self.far.day_tick, + }); + } + if self.far.day == u64::MAX && self.far.day_tick == TICKS_PER_DAY - 1 { + return Err(RuntimeInvariantError::FarDayExhausted); + } + if self.next_id == u64::MAX { + return Err(RuntimeInvariantError::NextIdExhausted); + } + let pending_corpse_allocations = self + .actors + .iter() + .filter(|actor| actor.alive && actor.hp <= 0) + .count() as u64; + if self + .next_id + .checked_add(pending_corpse_allocations) + .is_none() + { + return Err(RuntimeInvariantError::NextIdExhausted); + } + if self.next_id <= max_live_id { + return Err(RuntimeInvariantError::NextIdNotAboveLiveId { + next_id: self.next_id, + live_id: max_live_id, + }); + } + Ok(()) } /// Ensure the 3×3×3 active chunk set around a chunk coordinate exists. @@ -343,11 +1515,100 @@ impl State { } self.tick += 1; self.far.advance(); - // NOTE: `% 600` instead of `u64::is_multiple_of` — the latter needs - // Rust 1.87+ and our MSRV floor is 1.75 (workspace rust-version). - if self.tick % 600 == 0 { - let _ = self.hash_state(); + // Full canonical hashing is intentionally restricted to explicit + // persistence/replay/checkpoint boundaries; a simulation tick never + // scans the loaded world merely to discard the digest. + } + + /// Advance protected off-screen time without scanning every loaded voxel on + /// every tick. This is the deterministic inn-sleep path: it runs NPC + /// schedule intents, live-item drying/rust, corpse clocks/bursts, gossip, + /// and the authoritative tick/FarField clocks. It deliberately omits the + /// spatial fluids, gas, fire, light, sound, and structure phases while the + /// player is safely lodged; ordinary active play continues to use `step`. + pub fn preflight_unattended( + &self, + ticks: u64, + ) -> Result<(u64, u64, u64), UnattendedAdvanceError> { + let final_tick = self + .tick + .checked_add(ticks) + .ok_or(UnattendedAdvanceError::TickOverflow)?; + let (final_day, final_day_tick) = self + .far + .checked_advance_by(ticks) + .ok_or(UnattendedAdvanceError::FarFieldOverflow)?; + Ok((final_tick, final_day, final_day_tick)) + } + + pub fn advance_unattended(&mut self, ticks: u64) -> Result<(), UnattendedAdvanceError> { + let final_clocks = self.preflight_unattended(ticks)?; + self.advance_unattended_preflighted(ticks, final_clocks); + Ok(()) + } + + /// Debit and advance as one transaction. All fallible checks happen before + /// mutation; the private commit cannot fail after the wallet changes. + pub fn spend_copper_and_advance_unattended( + &mut self, + actor_id: u64, + amount: u32, + ticks: u64, + ) -> Result<(), PaidUnattendedError> { + let final_clocks = self + .preflight_unattended(ticks) + .map_err(PaidUnattendedError::Clock)?; + self.wallets + .get_mut(&actor_id) + .ok_or(PaidUnattendedError::Wallet(WalletError::InsufficientFunds))? + .checked_spend_copper(amount) + .map_err(PaidUnattendedError::Wallet)?; + self.advance_unattended_preflighted(ticks, final_clocks); + Ok(()) + } + + fn advance_unattended_preflighted( + &mut self, + ticks: u64, + (final_tick, final_day, final_day_tick): (u64, u64, u64), + ) { + for _ in 0..ticks { + self.phase_intent(); + + for (id, instance) in &mut self.items { + let wet = self + .ground_items + .get(id) + .is_some_and(|cell| self.grid.get_world(cell.0, cell.1, cell.2).fluid_amt > 0); + item::rust_tick(instance, wet); + if !wet { + item::dry(instance); + } + } + for pack in self.packs.values_mut() { + for instance in pack.items.values_mut() { + item::dry(instance); + } + } + + if !self.corpses.is_empty() { + let tick = self.tick; + let mut rng = self.fork_rng(domains::GOSSIP); + let _ = rot::step_rot( + &mut self.grid, + &mut self.corpses, + &mut rng, + &mut self.causes, + tick, + ); + } + self.drain_gossip(); + self.tick += 1; + self.far.advance(); } + debug_assert_eq!(self.tick, final_tick); + debug_assert_eq!(self.far.day, final_day); + debug_assert_eq!(self.far.day_tick, final_day_tick); } fn run_phase(&mut self, phase: Phase) { @@ -837,255 +2098,759 @@ impl State { let (rmin, rmax) = self.active_region(); let (_changed, spillover) = scent::step_scent(&mut self.grid, rmin, rmax, tick); self.spill.scent_pending = spillover; + self.drain_gossip(); + } + + fn drain_gossip(&mut self) { // Gossip drain (docs/02 §4: 3-day latency): arrived packets move // town ledgers (grudges travel, faces blur). let day = self.far.day; let mut arrived: Vec = Vec::new(); - self.gossip.retain(|p| { - if crate::schedule::gossip_arrived(p.sent_day, day) { - arrived.push(p.clone()); + self.gossip.retain(|packet| { + if crate::schedule::gossip_arrived(packet.sent_day, day) { + arrived.push(packet.clone()); false } else { true } }); - for p in arrived { - for rep in self.reputations.values_mut() { - rep.adjust_ledger(&p.to, p.ledger_delta); + for packet in arrived { + for reputation in self.reputations.values_mut() { + reputation.adjust_ledger(&packet.to, packet.ledger_delta); } self.causes.push(CauseLink::new( - tick, + self.tick, "sound travels", - format!("word travels {}→{}: the ledger remembers", p.from, p.to), + format!( + "word travels {}→{}: the ledger remembers", + packet.from, packet.to + ), )); } } fn phase_death(&mut self) { - let tick = self.tick; - // Snapshot the newly dead first (borrow discipline: deaths mutate - // actors, corpse-drop mints items after). + // Snapshot the newly dead first in authoritative actor order. let fresh_deaths: Vec = self .actors .iter() - .filter(|a| a.alive && a.hp <= 0) - .map(|a| a.id) + .filter(|actor| actor.alive && actor.hp <= 0) + .map(|actor| actor.id) .collect(); - for a in &mut self.actors { - if a.alive && a.hp <= 0 { - a.alive = false; - self.causes.push(CauseLink::new( - tick, - "death", - format!("actor {} fell still", a.id), - )); + let prepared = self + .prepare_deaths(&fresh_deaths) + .expect("validated phase-death allocation budget exhausted"); + let cause_count = prepared.deaths.len() + usize::from(prepared.learned_detail.is_some()); + self.causes + .try_reserve(cause_count) + .expect("validated phase-death cause capacity exhausted"); + self.commit_deaths(prepared, true); + } + + fn prepare_deaths(&self, actor_ids: &[u64]) -> Result { + let death_count = u64::try_from(actor_ids.len()).map_err(|_| MeleeError::IdExhausted)?; + let next_id = self + .next_id + .checked_add(death_count) + .ok_or(MeleeError::IdExhausted)?; + if actor_ids.is_empty() { + return Ok(PreparedDeathBatch { + deaths: Vec::new(), + learned_detail: None, + next_id, + }); + } + let mut deaths = Vec::new(); + deaths + .try_reserve(actor_ids.len()) + .map_err(|_| MeleeError::AllocationFailed)?; + for (offset, actor_id) in actor_ids.iter().copied().enumerate() { + let actor_index = self + .actors + .iter() + .position(|actor| actor.id == actor_id) + .expect("death actor selected from authority"); + let actor = &self.actors[actor_index]; + let corpse_id = self + .next_id + .checked_add(u64::try_from(offset).map_err(|_| MeleeError::IdExhausted)?) + .ok_or(MeleeError::IdExhausted)?; + let mut contracts_to_fail = Vec::new(); + let contract_count = self + .contracts + .values() + .filter(|contract| { + contract.taker == actor_id + && matches!( + contract.status, + ContractStatus::Active | ContractStatus::Completed + ) + }) + .count(); + contracts_to_fail + .try_reserve(contract_count) + .map_err(|_| MeleeError::AllocationFailed)?; + contracts_to_fail.extend( + self.contracts + .iter() + .filter(|(_, contract)| { + contract.taker == actor_id + && matches!( + contract.status, + ContractStatus::Active | ContractStatus::Completed + ) + }) + .map(|(id, _)| id.clone()), + ); + deaths.push(PreparedDeath { + actor_id, + actor_index, + position: actor.pos, + corpse: Instance::corpse(corpse_id, actor.pos), + contracts_to_fail, + death_detail: format!("actor {actor_id} fell still"), + }); + } + let learned_count = self.idf.values().filter(|uses| **uses >= 2).count(); + let mut learned_templates = Vec::new(); + learned_templates + .try_reserve(learned_count) + .map_err(|_| MeleeError::AllocationFailed)?; + learned_templates.extend( + self.idf + .iter() + .filter(|(_, uses)| **uses >= 2) + .map(|(template, _)| template.clone()), + ); + let learned_detail = (!learned_templates.is_empty()).then(|| { + format!( + "the vault keeps what was learned: {}", + learned_templates.join(", ") + ) + }); + Ok(PreparedDeathBatch { + deaths, + learned_detail, + next_id, + }) + } + + fn commit_deaths(&mut self, mut prepared: PreparedDeathBatch, broad_chapel_cleanup: bool) { + let tick = self.tick; + for death in &prepared.deaths { + debug_assert_eq!(self.actors[death.actor_index].id, death.actor_id); + self.actors[death.actor_index].alive = false; + self.causes + .push(CauseLink::new(tick, "death", death.death_detail.clone())); + } + if broad_chapel_cleanup { + let actors = &self.actors; + self.chapel_debts.retain(|actor_id, _| { + actors + .iter() + .any(|actor| actor.id == *actor_id && actor.alive) + }); + } else { + for death in &prepared.deaths { + self.chapel_debts.remove(&death.actor_id); } } - // Corpse-drop (NetHack law, docs/03 §3.4): the dead leave a - // corpse-tagged instance at their feet — butcher/eat/carry/cure are - // Phase 9/10 actions keyed off is_corpse/will_rot. - // Debts are gear-like (docs/03 §3.9: the Audit auctions arrears): - // drop unreceipted mud debts with the corpse; convert receipted and - // collections debts into town-ledger negatives + Audit arrears - // gossip-side (collectors visit dungeon floors, docs/03 §3.6). - // Death-record hook (Phase 11, docs/03 §3.9): Classic/Iron deaths - // stamp epitaph + ghost + vault-merge + auction listing. The hook - // records into `causes` (replay-visible) and stashes the vault merge - // set + auction lots as cause-adjacent links; the actual vault JSON - // + save deletion happen parent-side (cli owns fs). - for id in fresh_deaths { - if let Some(a) = self.actors.iter().find(|a| a.id == id) { - let pos = a.pos; - let corpse = Instance::corpse(self.alloc_id(), pos); - self.items.insert(corpse.id, corpse); - // Butchery is Dungeoneering-adjacent fieldcraft: witnesses - // with sheets note the lesson (Search practice — the living - // learn to look for what the dead teach). - for c in self.characters.values_mut() { - record_use(c, Skill::Search); + for death in &prepared.deaths { + for contract_id in &death.contracts_to_fail { + if let Some(contract) = self.contracts.get_mut(contract_id) { + contract.status = ContractStatus::Failed; + } else { + debug_assert!(false, "death contract disappeared after preflight"); } } - // Vault merge set (learning persists, gear does not): the idf - // working book (template → uses-seen) merges into lineage - // knowledge parent-side (save::knowledge); the kernel logs WHAT - // merges so replays show the vault write. + } + self.next_id = prepared.next_id; + for death in prepared.deaths { + let corpse_id = death.corpse.id; + self.ground_items.insert( + corpse_id, + ( + death.position[0].round() as i32, + death.position[1].round() as i32, + death.position[2].round() as i32, + ), + ); + self.items.insert(corpse_id, death.corpse); + for character in self.characters.values_mut() { + record_use(character, Skill::Search); + } if !self.idf.is_empty() { - let kept: Vec = self - .idf - .iter() - .filter(|(_, uses)| **uses >= 2) - .map(|(t, _)| t.clone()) - .collect(); - if !kept.is_empty() { - self.causes.push(CauseLink::new( - tick, - "waste nothing", - format!("the vault keeps what was learned: {}", kept.join(", ")), - )); + if let Some(detail) = prepared.learned_detail.take() { + self.causes + .push(CauseLink::new(tick, "waste nothing", detail)); } self.idf.clear(); } - if let Some(ledger) = self.bargains.remove(&id) { - let rep = self.reputations.entry(id).or_default(); + if let Some(ledger) = self.bargains.remove(&death.actor_id) { + let reputation = self.reputations.entry(death.actor_id).or_default(); for debt in ledger.debts { if debt.altar_receipt || debt.boon == crate::bargain::COLLECTIONS_BOON { - rep.adjust_ledger("milthollow", -10); - let standing = rep + reputation.adjust_ledger("milthollow", -10); + if let Some((_, standing)) = reputation .factions .iter_mut() - .find(|(f, _)| *f == crate::reputation::Faction::Audit); - if let Some((_, s)) = standing { - s.debt_cu = s.debt_cu.saturating_add(50); + .find(|(faction, _)| *faction == crate::reputation::Faction::Audit) + { + standing.debt_cu = standing.debt_cu.saturating_add(50); } } - // Unreceipted mud debts die with the delver. The god - // remembers, but the ledger is ash. } } } } - /// Canonical state hash for the determinism contract (docs/04 §4.2): - /// same seed + same input-tick log = same hash. Covers everything the - /// phases mutate: actors, connectors, pressure, voxel contents (loaded - /// chunks in BTreeMap order, incl. scent + `pad`/`extra`), corpses, the - /// law log, lights, and the far-field clock. - pub fn hash_state(&self) -> u64 { - // FNV-1a. Float bits hashed raw (deterministic across platforms for - // IEEE-754 inputs produced by our fixed-point paths). - let mut h: u64 = 0xcbf2_9ce4_8422_2325; - let mut mix = |w: u64| { - h ^= w; - h = h.wrapping_mul(0x100_0000_01b3); + /// Produce an exact owned plain-data image. Keeping this explicit literal + /// next to `State` construction makes field additions compile-time visible. + pub fn to_image(&self) -> Result { + self.validate_runtime()?; + Ok(StateImage { + tick: self.tick, + seed: self.seed, + rng_master: self.rng_master.canonical_state(), + ecs_entity_count: 0, + chunks: self + .grid + .iter_loaded() + .map(|(coord, chunk)| ChunkImage { + coord: *coord, + voxels: chunk.iter().copied().collect(), + }) + .collect(), + actors: self.actors.clone(), + actor_identities: self.actor_identities.clone(), + wallets: self.wallets.clone(), + contracts: self.contracts.clone(), + run_location: self.run_location.clone(), + connectors: self.connectors.clone(), + pressure_nodes: self.pressure_nodes.clone(), + pressure_edges: self.pressure_edges.clone(), + causes: self + .causes + .links() + .iter() + .map(|link| CauseImage { + tick: link.tick, + rule: link.rule.to_owned(), + detail: link.detail.clone(), + }) + .collect(), + far: self.far.clone(), + laws: self + .laws + .seen + .iter() + .map(|(rule, tick)| LawImage { + rule: (*rule).to_owned(), + first_seen_tick: *tick, + }) + .collect(), + fall_odo: self.fall_odo.clone(), + lights: self.lights.clone(), + sounds: self.sounds.clone(), + corpses: self.corpses.clone(), + signal_sources: self.signal_sources.clone(), + spill: self.spill.clone(), + characters: self.characters.clone(), + reputations: self.reputations.clone(), + items: self.items.clone(), + ground_items: self.ground_items.clone(), + packs: self.packs.clone(), + idf: self.idf.clone(), + hymn_cases: self.hymn_cases.clone(), + cast_queue: self.cast_queue.clone(), + bargains: self.bargains.clone(), + deep_weather: self.deep_weather, + npcs: self.npcs.clone(), + shops: self.shops.clone(), + gossip: self.gossip.clone(), + chalk: self.chalk.clone(), + mule: self.mule.clone(), + vault_choice: self.vault_choice, + chapel_debts: self.chapel_debts.clone(), + next_id: self.next_id, + step_mode: self.step_mode, + }) + } + + /// Restore an exact image without gameplay transitions or per-voxel writes. + pub fn from_image(image: StateImage) -> Result { + let StateImage { + tick, + seed, + rng_master, + ecs_entity_count, + chunks, + actors, + actor_identities, + wallets, + contracts, + run_location, + connectors, + pressure_nodes, + pressure_edges, + causes, + far, + laws: law_rows, + fall_odo, + lights, + sounds, + corpses, + signal_sources, + spill, + characters, + reputations, + items, + ground_items, + packs, + idf, + hymn_cases, + cast_queue, + bargains, + deep_weather, + npcs, + shops, + gossip, + chalk, + mule, + vault_choice, + chapel_debts, + next_id, + step_mode, + } = image; + if ecs_entity_count != 0 { + return Err(StateImageError::NonEmptyEcs { + entities: ecs_entity_count, + }); + } + let rng_master = + Rng::from_canonical_state(rng_master).map_err(|_| StateImageError::InvalidRng)?; + let grid = ChunkedGrid::from_loaded( + chunks + .into_iter() + .map(|row| (row.coord, row.voxels)) + .collect(), + ) + .map_err(StateImageError::Grid)?; + + let mut cause_chain = CauseChain::new(); + for row in causes { + let rule = laws::canonical_rule_tag(&row.rule) + .ok_or(StateImageError::UnknownRule { rule: row.rule })?; + cause_chain.push(CauseLink::new(row.tick, rule, row.detail)); + } + let mut law_log = LawLog::new(); + let mut previous: Option = None; + for row in law_rows { + if let Some(prior) = &previous { + if row.rule <= *prior { + if row.rule == *prior { + return Err(StateImageError::DuplicateLaw { rule: row.rule }); + } + return Err(StateImageError::NonCanonicalLawOrder { + previous: prior.clone(), + current: row.rule, + }); + } + } + let rule = laws::canonical_rule_tag(&row.rule).ok_or_else(|| { + StateImageError::UnknownRule { + rule: row.rule.clone(), + } + })?; + previous = Some(row.rule); + law_log.seen.insert(rule, row.first_seen_tick); + } + + let state = Self { + tick, + seed, + rng_master, + ecs: Ecs::new(), + grid, + actors, + actor_identities, + wallets, + contracts, + run_location, + connectors, + pressure_nodes, + pressure_edges, + causes: cause_chain, + far, + laws: law_log, + fall_odo, + lights, + sounds, + corpses, + signal_sources, + spill, + characters, + reputations, + items, + ground_items, + packs, + idf, + hymn_cases, + cast_queue, + bargains, + deep_weather, + npcs, + shops, + gossip, + chalk, + mule, + vault_choice, + chapel_debts, + next_id, + step_mode, }; - mix(self.tick); - mix(self.seed); - mix(self.actors.len() as u64); - for a in &self.actors { - mix(a.id); - mix(a.pos[0].to_bits() as u64); - mix(a.pos[1].to_bits() as u64); - mix(a.pos[2].to_bits() as u64); - mix(a.hp as u64); - mix(a.alive as u64); - } - mix(self.connectors.len() as u64); - for c in &self.connectors { - mix(c.id); - mix(c.load_kg.to_bits() as u64); - mix(c.blocked as u64); - } - mix(self.pressure_nodes.len() as u64); - for n in &self.pressure_nodes { - mix(n.id); - mix(n.pressure.to_bits() as u64); - } - // Voxel contents: loaded-chunk count + per-chunk FNV over the raw - // 16-byte cells. BTreeMap iteration is sorted, so order is canonical. - // (Chunk exposes no iteration yet — wire per-cell reads here via the - // public get path is O(volume); instead hash the grid's public - // observable: loaded count + active-region sample. Full per-voxel - // hashing lands with Chunk iteration in the Phase 13 perf pass, when - // volumes make the cost measurable. Until then the region sample - // covers everything the gate tests mutate.) - mix(self.grid.loaded_count() as u64); - let (rmin, rmax) = self.active_region(); - let (mut x0, mut y0, mut z0) = (rmin.0, rmin.1, rmin.2); - let (mut x1, mut y1, mut z1) = (rmax.0, rmax.1, rmax.2); - // Bound the sample so hashing never dominates the tick. - const SAMPLE_SPAN: i32 = 32; - x1 = x1.min(x0 + SAMPLE_SPAN); - y1 = y1.min(y0 + SAMPLE_SPAN); - z1 = z1.min(z0 + SAMPLE_SPAN); - x0 = x0.max(x1 - SAMPLE_SPAN); - y0 = y0.max(y1 - SAMPLE_SPAN); - z0 = z0.max(z1 - SAMPLE_SPAN); - for z in (z0..=z1).step_by(4) { - for y in (y0..=y1).step_by(4) { - for x in (x0..=x1).step_by(4) { - let v = self.grid.get_world(x, y, z); - mix(v.material as u64); - mix(v.occ as u64); - mix(((v.fluid_kind as u64) << 8) | v.fluid_amt as u64); - mix(((v.gas_kind as u64) << 8) | v.gas_amt as u64); - mix(((v.light as u64) << 8) | v.scent as u64); - mix(((v.flags as u64) << 8) | v.pad as u64); - mix(v.pressure as u64); - mix(v.flow as u64); - mix(v.extra as u64); + state.validate_runtime().map_err(StateImageError::Runtime)?; + Ok(state) + } + + /// Versioned full canonical persistence/replay hash. This compatibility + /// wrapper panics on invalid public legacy state; checkpoint/load callers + /// that need a recoverable error use [`State::try_hash_state`]. + #[must_use] + pub fn hash_state(&self) -> u64 { + self.try_hash_state() + .expect("State runtime invariants must hold before canonical hashing") + } + + /// Fallible canonical hash over every future-affecting authoritative field. + pub fn try_hash_state(&self) -> Result { + self.validate_runtime()?; + let mut h = CanonicalHasher::new(); + h.domain(0, 6); + h.u64(self.tick); + h.u64(self.seed); + for word in self.rng_master.canonical_state() { + h.u64(word); + } + h.domain(1, 1); // ECS is validated empty and encoded explicitly. + h.u64(0); + + h.domain(2, self.grid.loaded_count()); + for (coord, chunk) in self.grid.iter_loaded() { + h.cell(*coord); + h.domain(3, chunk.iter().len()); + for voxel in chunk.iter() { + h.u64(u64::from(voxel.material)); + h.u64(u64::from(voxel.occ)); + h.u64(u64::from(voxel.fluid_kind)); + h.u64(u64::from(voxel.fluid_amt)); + h.u64(u64::from(voxel.gas_kind)); + h.u64(u64::from(voxel.gas_amt)); + h.u64(u64::from(voxel.light)); + h.u64(u64::from(voxel.scent)); + h.u64(u64::from(voxel.flags)); + h.u64(u64::from(voxel.pad)); + h.u64(voxel.pressure as u64); + h.u64(voxel.flow as u64); + h.u64(u64::from(voxel.extra)); + } + } + h.domain(4, self.actors.len()); + for actor in &self.actors { + h.u64(actor.id); + for value in actor.pos { + h.u64(u64::from(value.to_bits())); + } + h.u64(u64::from(actor.radius.to_bits())); + h.u64(u64::from(actor.stance.canonical_tag())); + h.u64(actor.hp as u64); + h.bool(actor.alive); + } + h.domain(5, self.actor_identities.len()); + for (id, identity) in &self.actor_identities { + h.u64(*id); + h.text(&identity.template_id); + h.text(&identity.display_name); + h.u64(u64::from(identity.role.canonical_tag())); + } + h.domain(6, self.wallets.len()); + for (id, wallet) in &self.wallets { + h.u64(*id); + h.u64(u64::from(wallet.copper())); + h.u64(u64::from(wallet.scrip())); + } + h.domain(7, self.contracts.len()); + for (key, contract) in &self.contracts { + h.text(key); + h.text(&contract.id); + h.u64(contract.taker); + h.u64(contract.accepted_tick); + h.u64(u64::from(contract.floor)); + h.u64(u64::from(contract.pay_cu)); + h.u64(u64::from(contract.objective_target)); + h.u64(u64::from(contract.objective_progress)); + h.u64(u64::from(contract.status.canonical_tag())); + h.text(&contract.reward_town); + h.u64(contract.standing_delta as u64); + } + h.domain(8, 1); + h.u64(u64::from(self.run_location.canonical_tag())); + match &self.run_location { + RunLocation::Uninitialized => {} + RunLocation::Town(town) => h.text(town), + RunLocation::Deep(depth) => h.u64(u64::from(*depth)), + } + h.domain(9, self.connectors.len()); + for connector in &self.connectors { + h.u64(connector.id); + h.u64(u64::from(connector.kind.canonical_tag())); + for value in connector.from.into_iter().chain(connector.to) { + h.u64(u64::from(value.to_bits())); + } + h.u64(u64::from(connector.length.to_bits())); + h.u64(u64::from(connector.anchor_quality)); + h.u64(u64::from(connector.load_kg.to_bits())); + h.bool(connector.blocked); + } + h.domain(10, self.pressure_nodes.len()); + for node in &self.pressure_nodes { + h.u64(node.id); + h.u64(u64::from(node.pressure.to_bits())); + } + h.domain(11, self.pressure_edges.len()); + for edge in &self.pressure_edges { + h.u64(edge.from); + h.u64(edge.to); + h.u64(u64::from(edge.openness.to_bits())); + h.bool(edge.hidden); + } + h.domain(12, self.causes.len()); + for link in self.causes.links() { + h.u64(link.tick); + h.text(link.rule); + h.text(&link.detail); + } + h.domain(13, 2); + h.u64(self.far.day); + h.u64(self.far.day_tick); + h.domain(14, self.laws.seen.len()); + for (rule, tick) in &self.laws.seen { + h.text(rule); + h.u64(*tick); + } + h.domain(15, self.fall_odo.len()); + for (id, distance) in &self.fall_odo { + h.u64(*id); + h.u64(u64::from(distance.to_bits())); + } + h.domain(16, self.lights.len()); + for light in &self.lights { + h.cell((light.x, light.y, light.z)); + h.u64(u64::from(light.radius)); + h.bool(light.warm); + } + h.domain(17, self.sounds.len()); + for sound in &self.sounds { + h.cell((sound.x, sound.y, sound.z)); + h.u64(u64::from(sound.loudness)); + h.u64(u64::from(sound.timbre)); + } + h.domain(18, self.corpses.len()); + for (cell, clock) in &self.corpses { + h.cell(*cell); + h.u64(clock.age_ticks); + h.u64(clock.bursts_at); + } + h.domain(19, self.signal_sources.len()); + for cell in &self.signal_sources { + h.cell(*cell); + } + h.domain(20, self.spill.gas_flows.len()); + for (from, to, flow) in &self.spill.gas_flows { + h.u64(*from); + h.u64(*to); + h.u64(u64::from(flow.to_bits())); + } + h.bool(self.spill.light_pending); + h.bool(self.spill.signal_pending); + h.bool(self.spill.scent_pending); + h.domain(21, self.characters.len()); + for (id, character) in &self.characters { + h.u64(*id); + h.u64(u64::from(character.ancestry.canonical_tag())); + h.u64(u64::from(character.calling.canonical_tag())); + for value in character.attrs { + h.u64(u64::from(value)); + } + for value in character.skills { + h.u64(u64::from(value)); + } + for value in character.uses { + h.u64(u64::from(value)); + } + for value in character.pips { + h.u64(u64::from(value)); + } + } + h.domain(22, self.reputations.len()); + for (id, reputation) in &self.reputations { + h.u64(*id); + for (faction, standing) in reputation.factions { + h.u64(u64::from(faction.canonical_tag())); + h.u64(standing.rank as u64); + h.u64(standing.debt_cu as u64); + } + h.domain(23, reputation.town_ledger.len()); + for (town, standing) in &reputation.town_ledger { + h.text(town); + h.u64(*standing as u64); + } + for (god, credit) in reputation.god_credit { + h.u64(u64::from(god.canonical_tag())); + h.u64(credit as u64); + } + } + h.domain(24, self.items.len()); + for (key, item) in &self.items { + h.u64(*key); + h.item(item); + } + h.domain(25, self.ground_items.len()); + for (item_id, cell) in &self.ground_items { + h.u64(*item_id); + h.cell(*cell); + } + h.domain(26, self.packs.len()); + for (actor_id, pack) in &self.packs { + h.u64(*actor_id); + h.option_u64(pack.equipped_weapon); + h.option_u64(pack.equipped_armor); + h.u64(u64::from(pack.max_weight_kg.to_bits())); + h.u64(u64::from(pack.max_bulk)); + h.domain(26, pack.items.len()); + for (key, item) in &pack.items { + h.u64(*key); + h.item(item); + } + } + h.domain(27, self.idf.len()); + for (template, uses) in &self.idf { + h.text(template); + h.u64(u64::from(*uses)); + } + h.domain(28, self.hymn_cases.len()); + for (id, case) in &self.hymn_cases { + h.u64(*id); + h.u64(u64::from(case.max_slots)); + h.domain(29, case.cards.len()); + for card in &case.cards { + h.text(&card.template); + h.u64(u64::from(card.conduct_base)); + h.u64(u64::from(card.slots)); + h.bool(card.fragile); + h.domain(30, card.miscasts.len()); + for miscast in &card.miscasts { + h.text(miscast); } } } - mix(self.corpses.len() as u64); - for (pos, clock) in self.corpses.iter() { - mix(pos.0 as u64); - mix(pos.1 as u64); - mix(pos.2 as u64); - mix(clock.age_ticks); - mix(clock.bursts_at); - } - mix(self.laws.len() as u64); - mix(self.lights.len() as u64); - mix(self.fall_odo.len() as u64); - // Character sheets + reputations mutate via growth/standing (Phase 6). - // BTreeMap iteration is sorted, so order is canonical. - mix(self.characters.len() as u64); - for (id, c) in self.characters.iter() { - mix(*id); - mix(c.attrs.iter().fold(0u64, |a, v| a * 31 + *v as u64)); - mix(c.skills.iter().fold(0u64, |a, v| a * 31 + *v as u64)); - } - mix(self.reputations.len() as u64); - for (id, r) in self.reputations.iter() { - mix(*id); - mix(r.factions.iter().fold(0u64, |a, (_, s)| { - a.wrapping_mul(31).wrapping_add(s.rank as u64) - })); - } - // Hymn-cases + bargain ledgers + cast queue + weather mutate via - // casting/bargains (Phase 8). Sorted/canonical order throughout. - mix(self.hymn_cases.len() as u64); - for (id, case) in self.hymn_cases.iter() { - mix(*id); - mix(case.slots_used() as u64); - mix(case.cards.len() as u64); - } - mix(self.cast_queue.len() as u64); - mix(self.bargains.len() as u64); - for (id, ledger) in self.bargains.iter() { - mix(*id); - mix(ledger.len() as u64); - } - mix(self.deep_weather as u64); - // NPCs + shops + gossip mutate via Intent/Rot (Phase 9). NPC ids - // sorted; shop cells sorted; gossip in queue order (insertion is - // deterministic — queue order IS canonical order). - mix(self.npcs.len() as u64); - for (id, n) in self.npcs.iter() { - mix(*id); - mix(n.home.0 as u64); - mix(n.home.1 as u64); - mix(n.home.2 as u64); - } - mix(self.shops.len() as u64); - mix(self.gossip.len() as u64); - // Chalk + mule + vault choice mutate via player action (Phase 10). - // Chalk marks sorted; mule waypoints in walk order (deterministic); - // choice is a single discriminant (None = undecided). - mix(self.chalk.len() as u64); - mix(self - .mule - .as_ref() - .map(|m| m.waypoints.len() as u64) - .unwrap_or(0)); - mix(match self.vault_choice { - None => 0, - Some(VaultChoice::Seal) => 1, - Some(VaultChoice::Crown) => 2, - }); - mix(self.far.day); - mix(self.far.day_tick); - h + h.domain(31, self.cast_queue.len()); + for (caster, template, target) in &self.cast_queue { + h.u64(*caster); + h.text(template); + h.cell((target[0], target[1], target[2])); + } + h.domain(32, self.bargains.len()); + for (id, ledger) in &self.bargains { + h.u64(*id); + h.domain(33, ledger.debts.len()); + for debt in &ledger.debts { + h.u64(u64::from(debt.god.canonical_tag())); + h.text(&debt.boon); + h.text(&debt.owed); + h.option_u64(debt.ticks_left); + h.bool(debt.altar_receipt); + } + } + h.domain(34, 1); + h.u64(self.deep_weather as u64); + h.domain(35, self.npcs.len()); + for (id, npc) in &self.npcs { + h.u64(*id); + h.text(&npc.name); + h.u64(u64::from(npc.job.canonical_tag())); + h.cell(npc.home); + h.cell(npc.work); + h.u64(npc.debt_cu as u64); + } + h.domain(36, self.shops.len()); + for (cell, shop) in &self.shops { + h.cell(*cell); + h.domain(37, shop.broke.len()); + for (id, count) in &shop.broke { + h.u64(*id); + h.u64(u64::from(*count)); + } + h.domain(38, shop.stole.len()); + for (id, stole) in &shop.stole { + h.u64(*id); + h.bool(*stole); + } + h.domain(39, shop.killed.len()); + for (id, killed) in &shop.killed { + h.u64(*id); + h.bool(*killed); + } + } + h.domain(40, self.gossip.len()); + for packet in &self.gossip { + h.text(&packet.from); + h.text(&packet.to); + h.u64(packet.sent_day); + h.u64(packet.ledger_delta as u64); + } + h.domain(41, self.chalk.marks.len()); + for (cell, text) in &self.chalk.marks { + h.cell(*cell); + h.text(text); + } + h.domain(42, usize::from(self.mule.is_some())); + if let Some(mule) = &self.mule { + h.domain(43, mule.waypoints.len()); + for cell in &mule.waypoints { + h.cell(*cell); + } + } + h.domain(44, 1); + h.u64(u64::from( + self.vault_choice.map_or(0, VaultChoice::canonical_tag), + )); + h.domain(45, 2); + h.u64(self.next_id); + h.bool(self.step_mode); + h.domain(46, self.chapel_debts.len()); + for (actor_id, debt) in &self.chapel_debts { + h.u64(*actor_id); + h.u64(u64::from(debt.burials_owed)); + h.text(&debt.chapel); + } + Ok(h.finish()) + } + + /// Cheap O(actors + small tables) debugging fingerprint. It is not a + /// persistence/replay digest and intentionally excludes voxels and queues. + #[must_use] + pub fn diagnostic_hash(&self) -> u64 { + let mut h = CanonicalHasher::new(); + h.domain(0xff, self.actors.len()); + h.u64(self.tick); + h.u64(self.seed); + for actor in &self.actors { + h.u64(actor.id); + h.u64(actor.hp as u64); + h.bool(actor.alive); + } + h.finish() } } @@ -1120,6 +2885,420 @@ mod tests { assert!(!s.causes.is_empty()); } + fn melee_pair(attacker_hp: i32, defender_hp: i32) -> (State, u64, u64) { + let mut state = State::new(77); + let attacker = state.spawn_actor_with_identity( + [1.0, 2.0, 3.0], + ActorIdentity::new("delver", "Ada", crate::runtime::ActorRole::Player), + ); + let defender = state.spawn_actor_with_identity( + [2.0, 2.0, 3.0], + ActorIdentity::new("rat", "Cable-rat", crate::runtime::ActorRole::Monster), + ); + state.actors[0].hp = attacker_hp; + state.actors[1].hp = defender_hp; + (state, attacker, defender) + } + + #[test] + fn melee_survivor_retaliates_once_and_killing_blow_does_not() { + let (mut state, attacker, defender) = melee_pair(10, 6); + let first = state + .resolve_melee_exchange(attacker, defender, 3, 3, None) + .unwrap(); + assert_eq!(first.outgoing_damage, 3); + assert_eq!(first.retaliation_damage, Some(3)); + assert!(!first.defender_killed); + assert!(!first.attacker_killed); + assert_eq!(state.actors[0].hp, 7); + assert_eq!(state.actors[1].hp, 3); + let second = state + .resolve_melee_exchange(attacker, defender, 3, 3, None) + .unwrap(); + assert_eq!(second.retaliation_damage, None); + assert!(second.defender_killed); + assert_eq!(state.actors[0].hp, 7); + assert_eq!(state.tick, 0); + assert_eq!( + state + .causes + .links() + .iter() + .filter(|link| link.detail.contains("retaliates")) + .count(), + 1 + ); + + let (mut lethal, attacker, defender) = melee_pair(10, 30); + let outcomes: Vec = (0..4) + .map(|_| { + lethal + .resolve_melee_exchange(attacker, defender, 3, 3, None) + .unwrap() + }) + .collect(); + assert!(outcomes.last().unwrap().attacker_killed); + assert!(!outcomes.last().unwrap().defender_killed); + assert_eq!(lethal.actors[0].hp, -2); + assert_eq!(lethal.actors[1].hp, 18); + assert_eq!(lethal.items.len(), 1); + assert_eq!(lethal.ground_items.values().next(), Some(&(1, 2, 3))); + assert_eq!(lethal.tick, 0); + + let (mut saturated, attacker, defender) = melee_pair(1, 1); + saturated + .resolve_melee_exchange(attacker, defender, i32::MAX, 1, None) + .unwrap(); + assert_eq!(saturated.actors[1].hp, i32::MIN + 2); + } + + #[test] + fn melee_dead_invalid_blocked_or_exhausted_rejects_atomically() { + let errors = [ + (1, 1, 3, 3, None, MeleeError::SameActor), + (99, 2, 3, 3, None, MeleeError::UnknownAttacker), + (1, 99, 3, 3, None, MeleeError::UnknownDefender), + ( + 1, + 2, + 0, + 3, + None, + MeleeError::NonPositiveDamage { + strike: MeleeStrike::Outgoing, + }, + ), + ( + 1, + 2, + 3, + i32::MIN, + None, + MeleeError::NonPositiveDamage { + strike: MeleeStrike::Retaliation, + }, + ), + ( + 1, + 2, + 3, + 3, + Some("missing"), + MeleeError::ObjectiveRequiresDefenderDeath, + ), + ]; + for (attacker, defender, damage, retaliation, objective, expected) in errors { + let (mut state, _, _) = melee_pair(10, 10); + let before = state.to_image().unwrap(); + let before_hash = state.try_hash_state().unwrap(); + assert_eq!( + state.resolve_melee_exchange(attacker, defender, damage, retaliation, objective,), + Err(expected) + ); + assert_eq!(state.try_hash_state().unwrap(), before_hash); + let after = state.to_image().unwrap(); + assert_eq!(after.tick, before.tick); + assert_eq!(after.actors.len(), before.actors.len()); + assert!(after + .actors + .iter() + .zip(before.actors.iter()) + .all(|(a, b)| a.id == b.id && a.hp == b.hp && a.alive == b.alive)); + assert_eq!(after.causes, before.causes); + assert_eq!(after.items, before.items); + assert_eq!(after.ground_items, before.ground_items); + assert_eq!(after.chapel_debts, before.chapel_debts); + assert_eq!(after.bargains, before.bargains); + assert_eq!(after.contracts, before.contracts); + assert_eq!(after.next_id, before.next_id); + } + + let (mut dead, attacker, defender) = melee_pair(10, 10); + dead.actors[0].alive = false; + assert_eq!( + dead.resolve_melee_exchange(attacker, defender, 3, 3, None), + Err(MeleeError::DeadAttacker) + ); + dead.actors[0].alive = true; + dead.actors[0].hp = 0; + assert_eq!( + dead.resolve_melee_exchange(attacker, defender, 3, 3, None), + Err(MeleeError::DeadAttacker) + ); + dead.actors[0].hp = 10; + dead.actors[1].alive = false; + assert_eq!( + dead.resolve_melee_exchange(attacker, defender, 3, 3, None), + Err(MeleeError::DeadDefender) + ); + dead.actors[1].alive = true; + dead.actors[1].hp = 0; + assert_eq!( + dead.resolve_melee_exchange(attacker, defender, 3, 3, None), + Err(MeleeError::DeadDefender) + ); + + let (mut objective, attacker, defender) = melee_pair(10, 1); + objective.initialize_town("milthollow").unwrap(); + objective + .accept_contract("rat_cull", attacker, 1, 1, 1, "milthollow", 0) + .unwrap(); + let before_hash = objective.try_hash_state().unwrap(); + assert_eq!( + objective.resolve_melee_exchange(attacker, defender, 3, 3, Some("missing")), + Err(MeleeError::InvalidObjective(ContractError::NotFound)) + ); + assert_eq!(objective.try_hash_state().unwrap(), before_hash); + assert_eq!(objective.actors[1].hp, 1); + assert_eq!( + objective.resolve_melee_exchange(attacker, defender, 3, 3, Some("rat_cull")), + Err(MeleeError::InvalidObjective(ContractError::WrongLocation)) + ); + objective.transition_location(RunLocation::Deep(1)).unwrap(); + objective.fail_contract("rat_cull").unwrap(); + assert_eq!( + objective.resolve_melee_exchange(attacker, defender, 3, 3, Some("rat_cull")), + Err(MeleeError::InvalidObjective(ContractError::NotActive)) + ); + + let (exhausted, attacker, defender) = melee_pair(10, 1); + let mut exhausted_image = exhausted.to_image().unwrap(); + exhausted_image.next_id = u64::MAX - 1; + let mut exhausted = State::from_image(exhausted_image).unwrap(); + let before = exhausted.to_image().unwrap(); + assert_eq!( + exhausted.resolve_melee_exchange(attacker, defender, i32::MAX, 3, None), + Err(MeleeError::IdExhausted) + ); + assert_eq!(exhausted.to_image().unwrap().next_id, before.next_id); + assert_eq!(exhausted.actors[1].hp, 1); + assert!(exhausted.items.is_empty()); + } + + #[test] + fn melee_combined_pending_id_boundary_is_atomic_and_not_over_rejected() { + fn boundary_state(next_id: u64) -> (State, u64, u64, u64) { + let (mut state, attacker, defender) = melee_pair(10, 1); + let pending = state.spawn_actor([8.0, 8.0, 8.0]); + state.actors[2].hp = 0; + let mut image = state.to_image().unwrap(); + image.next_id = next_id; + ( + State::from_image(image).unwrap(), + attacker, + defender, + pending, + ) + } + + let (mut exhausted, attacker, defender, pending) = boundary_state(u64::MAX - 2); + let before = exhausted.to_image().unwrap(); + let before_hash = exhausted.try_hash_state().unwrap(); + assert_eq!( + exhausted.resolve_melee_exchange(attacker, defender, 3, 3, None), + Err(MeleeError::IdExhausted) + ); + assert_eq!(exhausted.try_hash_state().unwrap(), before_hash); + let after = exhausted.to_image().unwrap(); + assert_eq!(after.tick, before.tick); + assert_eq!(after.actors.len(), before.actors.len()); + assert!(after + .actors + .iter() + .zip(before.actors.iter()) + .all(|(a, b)| a.id == b.id && a.hp == b.hp && a.alive == b.alive)); + assert_eq!(after.causes, before.causes); + assert_eq!(after.items, before.items); + assert_eq!(after.ground_items, before.ground_items); + assert_eq!(after.chapel_debts, before.chapel_debts); + assert_eq!(after.bargains, before.bargains); + assert_eq!(after.contracts, before.contracts); + assert_eq!(after.next_id, before.next_id); + assert!( + after + .actors + .iter() + .find(|actor| actor.id == pending) + .unwrap() + .alive + ); + + let (mut valid, attacker, defender, pending) = boundary_state(u64::MAX - 3); + let result = valid + .resolve_melee_exchange(attacker, defender, 3, 3, None) + .unwrap(); + assert!(result.defender_killed); + assert_eq!(valid.next_id, u64::MAX - 2); + assert!( + valid + .actors + .iter() + .find(|actor| actor.id == pending) + .unwrap() + .alive + ); + assert_eq!(valid.validate_runtime(), Ok(())); + assert!(valid.try_hash_state().is_ok()); + } + + #[test] + fn empty_prepared_deaths_skip_idf_and_keep_broad_chapel_cleanup() { + let (mut state, attacker, _) = melee_pair(10, 10); + state.idf.insert("mature_lore".into(), 2); + state.assign_chapel_debt(attacker).unwrap(); + state.actors[0].alive = false; + + let prepared = state.prepare_deaths(&[]).unwrap(); + assert!(prepared.deaths.is_empty()); + assert_eq!(prepared.learned_detail, None); + assert_eq!(prepared.next_id, state.next_id); + state.commit_deaths(prepared, true); + + assert_eq!(state.idf.get("mature_lore"), Some(&2)); + assert_eq!(state.chapel_debt(attacker), None); + assert!(state.causes.is_empty()); + } + + #[test] + fn melee_death_cleans_debt_contracts_and_mints_one_corpse() { + use crate::bargain::{Debt, Ledger}; + use crate::item::Condition; + use crate::reputation::{Faction, GodId, Reputation}; + + let (mut state, attacker, defender) = melee_pair(10, 3); + state.initialize_town("milthollow").unwrap(); + state + .accept_contract("active", defender, 1, 1, 2, "milthollow", 0) + .unwrap(); + state + .accept_contract("completed", defender, 1, 1, 1, "milthollow", 0) + .unwrap(); + state.transition_location(RunLocation::Deep(1)).unwrap(); + state + .advance_contract_objective("completed", defender, 1) + .unwrap(); + state + .transition_location(RunLocation::Town("milthollow".into())) + .unwrap(); + state + .accept_contract("terminal", defender, 1, 1, 1, "milthollow", 0) + .unwrap(); + state.fail_contract("terminal").unwrap(); + state.assign_chapel_debt(defender).unwrap(); + state.reputations.insert(defender, Reputation::new()); + state.bargains.insert( + defender, + Ledger { + debts: vec![Debt { + god: GodId::Audit, + boon: "receipt".into(), + owed: "pay".into(), + ticks_left: None, + altar_receipt: true, + }], + }, + ); + state.idf.insert("known_relic".into(), 2); + let corpse_id = state.next_id; + let outcome = state + .resolve_melee_exchange(attacker, defender, i32::MAX, i32::MAX, None) + .unwrap(); + assert!(outcome.defender_killed); + assert_eq!(outcome.retaliation_damage, None); + assert!(!state.actors[1].alive); + assert_eq!( + state.contract("active").unwrap().status, + ContractStatus::Failed + ); + assert_eq!( + state.contract("completed").unwrap().status, + ContractStatus::Failed + ); + assert_eq!( + state.contract("terminal").unwrap().status, + ContractStatus::Failed + ); + assert_eq!(state.chapel_debt(defender), None); + assert!(!state.bargains.contains_key(&defender)); + assert_eq!(state.reputations[&defender].ledger("milthollow"), -10); + assert_eq!( + state.reputations[&defender] + .factions + .iter() + .find(|(faction, _)| *faction == Faction::Audit) + .unwrap() + .1 + .debt_cu, + 50 + ); + let corpse = &state.items[&corpse_id]; + assert_eq!(corpse.template, "corpse"); + assert_eq!(corpse.material, crate::voxel::material::FLESH); + assert!(corpse.identified); + assert_eq!(corpse.condition, Condition::Sound); + assert_eq!(corpse.tags, vec!["corpse", "perishable"]); + assert_eq!(state.ground_items[&corpse_id], (2, 2, 3)); + assert_eq!(state.next_id, corpse_id + 1); + assert_eq!(state.validate_runtime(), Ok(())); + assert!(state.try_hash_state().is_ok()); + state.step(); + assert_eq!(state.items.len(), 1, "finalized death is not reminted"); + } + + #[test] + fn melee_cause_image_roundtrip() { + let (mut state, attacker, defender) = melee_pair(10, 6); + state + .resolve_melee_exchange(attacker, defender, 3, 3, None) + .unwrap(); + let image = state.to_image().unwrap(); + assert_eq!(image.causes[0].rule, "melee"); + assert!(image.causes[0].detail.contains("actor 1 strikes actor 2")); + assert!(image.causes[1].detail.contains("retaliates")); + let restored = State::from_image(image).unwrap(); + assert_eq!(restored.try_hash_state(), state.try_hash_state()); + assert_eq!(restored.causes.links()[0].rule, "melee"); + } + + #[test] + fn melee_finalizes_only_exchange_death_and_credits_objective_once() { + let (mut state, attacker, defender) = melee_pair(10, 1); + let pending = state.spawn_actor([8.0, 8.0, 8.0]); + state.initialize_town("milthollow").unwrap(); + state + .accept_contract("rat_cull", attacker, 1, 1, 1, "milthollow", 0) + .unwrap(); + state.transition_location(RunLocation::Deep(1)).unwrap(); + state.actors[2].hp = 0; + let result = state + .resolve_melee_exchange(attacker, defender, i32::MAX, 3, Some("rat_cull")) + .unwrap(); + assert_eq!(result.objective_progress, Some((1, 1))); + assert_eq!( + state.contract("rat_cull").unwrap().status, + ContractStatus::Completed + ); + assert!( + state + .actors + .iter() + .find(|actor| actor.id == pending) + .unwrap() + .alive + ); + assert_eq!(state.items.len(), 1); + state.step(); + assert_eq!(state.items.len(), 2); + assert!( + !state + .actors + .iter() + .find(|actor| actor.id == pending) + .unwrap() + .alive + ); + } + #[test] fn step_mode_advances_single_tick() { let mut s = State::new(9); @@ -1137,6 +3316,101 @@ mod tests { assert_eq!(s.grid.loaded_count(), 27); } + #[test] + fn unattended_advance_matches_empty_world_clock_scope() { + let mut stepped = State::new(7); + let mut unattended = State::new(7); + stepped.far.day_tick = TICKS_PER_DAY - 2; + unattended.far.day_tick = TICKS_PER_DAY - 2; + for _ in 0..2 { + stepped.step(); + } + unattended.advance_unattended(2).unwrap(); + assert_eq!(unattended.tick, stepped.tick); + assert_eq!(unattended.far.day, stepped.far.day); + assert_eq!(unattended.far.day_tick, stepped.far.day_tick); + assert_eq!(unattended.validate_runtime(), Ok(())); + } + + #[test] + fn unattended_advance_overflow_is_atomic() { + let mut tick_overflow = State::new(7); + tick_overflow.tick = u64::MAX; + let before = tick_overflow.diagnostic_hash(); + assert_eq!( + tick_overflow.advance_unattended(1), + Err(UnattendedAdvanceError::TickOverflow) + ); + assert_eq!(tick_overflow.diagnostic_hash(), before); + + let mut day_overflow = State::new(8); + day_overflow.far.day = u64::MAX; + day_overflow.far.day_tick = TICKS_PER_DAY - 1; + let before = day_overflow.diagnostic_hash(); + assert_eq!( + day_overflow.advance_unattended(1), + Err(UnattendedAdvanceError::FarFieldOverflow) + ); + assert_eq!(day_overflow.diagnostic_hash(), before); + } + + #[test] + fn paid_unattended_advance_preflights_wallet_and_clock_atomically() { + use crate::runtime::{ActorIdentity, ActorRole}; + + let mut poor = State::new(9); + let actor = poor.spawn_actor_with_identity( + [0.0, 0.0, 0.0], + ActorIdentity::new("player", "Ada", ActorRole::Player), + ); + let before = poor.diagnostic_hash(); + assert_eq!( + poor.spend_copper_and_advance_unattended(actor, 1, 1), + Err(PaidUnattendedError::Wallet(WalletError::InsufficientFunds)) + ); + assert_eq!(poor.diagnostic_hash(), before); + + let mut overflow = State::new(10); + let actor = overflow.spawn_actor_with_identity( + [0.0, 0.0, 0.0], + ActorIdentity::new("player", "Ada", ActorRole::Player), + ); + overflow.earn_copper(actor, 8).unwrap(); + overflow.tick = u64::MAX; + let before = overflow.diagnostic_hash(); + assert_eq!( + overflow.spend_copper_and_advance_unattended(actor, 8, 1), + Err(PaidUnattendedError::Clock( + UnattendedAdvanceError::TickOverflow + )) + ); + assert_eq!(overflow.diagnostic_hash(), before); + assert_eq!(overflow.wallet(actor).unwrap().copper(), 8); + } + + #[test] + fn unattended_advance_runs_npc_schedule_motion() { + use crate::npc::{Job, Townsfolk}; + use crate::runtime::{ActorIdentity, ActorRole}; + let mut state = State::new(8); + let id = state.spawn_actor_with_identity( + [9.0, 0.0, 0.0], + ActorIdentity::new("townsfolk", "Mara", ActorRole::Npc), + ); + state.npcs.insert( + id, + Townsfolk { + name: "Mara".into(), + job: Job::Miller, + home: (0, 0, 0), + work: (9, 0, 0), + debt_cu: 0, + }, + ); + state.advance_unattended(TICKS_PER_DAY).unwrap(); + assert_eq!(state.actors[0].pos, [0.0, 0.0, 0.0]); + } + #[test] fn far_field_clock_advances() { // Rollover logic is unit-tested directly (fast); the step-based check diff --git a/crates/hexwork-core/src/voxel.rs b/crates/hexwork-core/src/voxel.rs index 2ade58a..8c21c63 100644 --- a/crates/hexwork-core/src/voxel.rs +++ b/crates/hexwork-core/src/voxel.rs @@ -73,6 +73,21 @@ pub fn idx(x: usize, y: usize, z: usize) -> usize { (z * CHUNK + y) * CHUNK + x } +/// Malformed bulk chunk/grid reconstruction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GridLoadError { + ChunkLength { + actual: usize, + }, + DuplicateCoordinate { + coord: (i32, i32, i32), + }, + NonCanonicalOrder { + previous: (i32, i32, i32), + current: (i32, i32, i32), + }, +} + /// One 48³ chunk: 110_592 voxels × 16B ≈ 1.7MB, heap-boxed. #[derive(Debug)] pub struct Chunk { @@ -88,6 +103,18 @@ impl Chunk { } } + /// Build directly from canonical x-fastest voxel storage. + pub fn from_voxels(voxels: Vec) -> Result { + if voxels.len() != CHUNK_VOL { + return Err(GridLoadError::ChunkLength { + actual: voxels.len(), + }); + } + Ok(Self { + voxels: voxels.into_boxed_slice(), + }) + } + pub fn get(&self, x: usize, y: usize, z: usize) -> Voxel { self.voxels[idx(x, y, z)] } @@ -101,6 +128,11 @@ impl Chunk { v.material = material; } } + + /// Voxels in canonical linear order: x fastest, then y, then z. + pub fn iter(&self) -> impl ExactSizeIterator { + self.voxels.iter() + } } impl Default for Chunk { @@ -136,6 +168,35 @@ impl ChunkedGrid { self.chunks.len() } + /// Loaded chunks in canonical coordinate order, with each chunk exposing + /// cells in x-fastest linear order through [`Chunk::iter`]. + pub fn iter_loaded(&self) -> impl Iterator { + self.chunks.iter() + } + + /// Build loaded chunks without per-voxel insertion. Rows must already be + /// strictly increasing in canonical tuple order; equality is a duplicate. + pub fn from_loaded(loaded: Vec<((i32, i32, i32), Vec)>) -> Result { + let mut chunks = BTreeMap::new(); + let mut previous = None; + for (coord, voxels) in loaded { + if let Some(prior) = previous { + if coord == prior { + return Err(GridLoadError::DuplicateCoordinate { coord }); + } + if coord < prior { + return Err(GridLoadError::NonCanonicalOrder { + previous: prior, + current: coord, + }); + } + } + previous = Some(coord); + chunks.insert(coord, Chunk::from_voxels(voxels)?); + } + Ok(Self { chunks }) + } + /// Ensure the 3×3×3 active set around a chunk exists (docs/04 §4.3). pub fn activate(&mut self, cx: i32, cy: i32, cz: i32) { for dz in -1..=1 { @@ -218,4 +279,20 @@ mod tests { assert_eq!(grid.get_world(48, 0, -1).material, material::STONE); assert!(grid.is_loaded(1, 0, -1)); } + + #[test] + fn bulk_load_validates_length_and_order() { + assert_eq!( + Chunk::from_voxels(Vec::new()).unwrap_err(), + GridLoadError::ChunkLength { actual: 0 } + ); + let rows = vec![ + ((1, 0, 0), vec![Voxel::default(); CHUNK_VOL]), + ((0, 0, 0), vec![Voxel::default(); CHUNK_VOL]), + ]; + assert!(matches!( + ChunkedGrid::from_loaded(rows), + Err(GridLoadError::NonCanonicalOrder { .. }) + )); + } } diff --git a/crates/hexwork-core/tests/canonical_hash.rs b/crates/hexwork-core/tests/canonical_hash.rs new file mode 100644 index 0000000..99d048b --- /dev/null +++ b/crates/hexwork-core/tests/canonical_hash.rs @@ -0,0 +1,694 @@ +use std::time::Instant; + +use hexwork_core::actor::Stance; +use hexwork_core::bargain::{Debt, Ledger}; +use hexwork_core::cause::CauseLink; +use hexwork_core::character::{self, Ancestry, Calling}; +use hexwork_core::connector::{Connector, ConnectorKind}; +use hexwork_core::dungeon::{MulePath, VaultChoice}; +use hexwork_core::hymn::{HymnCard, HymnCase}; +use hexwork_core::inventory::Pack; +use hexwork_core::item::{Condition, ItemTemplate}; +use hexwork_core::light::LightSource; +use hexwork_core::modes::ChapelDebt; +use hexwork_core::npc::{Job, ShopMemory, Townsfolk}; +use hexwork_core::pressure::{PressureEdge, PressureNode}; +use hexwork_core::reputation::{Faction, GodId, Reputation}; +use hexwork_core::rot::CorpseClock; +use hexwork_core::runtime::{ActorIdentity, ActorRole, ContractStatus, RunLocation}; +use hexwork_core::sound::SoundEvent; +use hexwork_core::state::{GossipPacket, State, CANONICAL_HASH_VERSION}; +use hexwork_core::voxel::{material, Voxel}; + +fn base() -> (State, u64) { + let mut state = State::new(140); + let player = state.spawn_actor_with_identity( + [1.0, 2.0, 3.0], + ActorIdentity::new("delver", "Ada", ActorRole::Player), + ); + state.reputations.insert(player, Reputation::new()); + state.initialize_town("milthollow").unwrap(); + (state, player) +} + +fn changed(label: &str, mutate: impl FnOnce(&mut State, u64)) { + let (baseline, player) = base(); + let expected = baseline.hash_state(); + let (mut altered, altered_player) = base(); + assert_eq!(player, altered_player); + mutate(&mut altered, altered_player); + assert_eq!( + altered.validate_runtime(), + Ok(()), + "invalid fixture: {label}" + ); + assert_ne!(expected, altered.hash_state(), "hash omitted {label}"); +} + +#[test] +fn protocol_version_and_empty_state_golden() { + assert_eq!(CANONICAL_HASH_VERSION, 3); + assert_eq!(State::new(140).hash_state(), 17_533_628_633_659_024_988); +} + +#[test] +fn every_canonical_enum_tag_is_explicit_and_stable() { + assert_eq!( + [ + Stance::Stand, + Stance::Crouch, + Stance::Climb, + Stance::Swim, + Stance::Prone, + Stance::Rope, + ] + .map(Stance::canonical_tag), + [0, 1, 2, 3, 4, 5] + ); + assert_eq!( + [ActorRole::Player, ActorRole::Npc, ActorRole::Monster].map(ActorRole::canonical_tag), + [0, 1, 2] + ); + assert_eq!( + [ + ContractStatus::Active, + ContractStatus::Completed, + ContractStatus::TurnedIn, + ContractStatus::Failed, + ] + .map(ContractStatus::canonical_tag), + [0, 1, 2, 3] + ); + assert_eq!(RunLocation::Uninitialized.canonical_tag(), 0); + assert_eq!(RunLocation::Town("town".into()).canonical_tag(), 1); + assert_eq!(RunLocation::Deep(12).canonical_tag(), 2); + assert_eq!( + [ + ConnectorKind::Stairwell, + ConnectorKind::Shaft, + ConnectorKind::Well, + ConnectorKind::Vent, + ConnectorKind::ElevatorCar, + ConnectorKind::Counterweight, + ConnectorKind::RopeAnchor, + ] + .map(ConnectorKind::canonical_tag), + [0, 1, 2, 3, 4, 5, 6] + ); + assert_eq!( + [ + Ancestry::Mudborn, + Ancestry::CantBorn, + Ancestry::PaleTouched, + Ancestry::Foundling, + ] + .map(Ancestry::canonical_tag), + [0, 1, 2, 3] + ); + assert_eq!( + [ + Calling::Ratcatcher, + Calling::Lampbearer, + Calling::Tinker, + Calling::CorpseTender, + Calling::TollWard, + Calling::ChoirDeserter, + ] + .map(Calling::canonical_tag), + [0, 1, 2, 3, 4, 5] + ); + assert_eq!( + [ + Faction::Lampwrights, + Faction::Congregation, + Faction::Wardens, + Faction::Audit, + Faction::Choir, + ] + .map(Faction::canonical_tag), + [0, 1, 2, 3, 4] + ); + assert_eq!( + [ + GodId::Lamplighter, + GodId::Tollkeeper, + GodId::Choir, + GodId::Rustmother, + GodId::Audit, + ] + .map(GodId::canonical_tag), + [0, 1, 2, 3, 4] + ); + assert_eq!( + Condition::ALL.map(Condition::canonical_tag), + [0, 1, 2, 3, 4, 5, 6, 7, 8] + ); + assert_eq!( + hexwork_core::npc::ALL_JOBS.map(Job::canonical_tag), + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] + ); + assert_eq!(VaultChoice::Seal.canonical_tag(), 1); + assert_eq!(VaultChoice::Crown.canonical_tag(), 2); +} + +#[test] +fn densely_populated_representative_state_golden() { + let mut state = State::new(0x5eed); + state.initialize_town("milthollow").unwrap(); + let active = state.spawn_actor_with_identity( + [1.25, -2.5, 3.75], + ActorIdentity::new("delver", "Ada", ActorRole::Player), + ); + let completed = state.spawn_actor_with_identity( + [2.0, 3.0, 4.0], + ActorIdentity::new("delver", "Bede", ActorRole::Player), + ); + let turned_in = state.spawn_actor_with_identity( + [3.0, 4.0, 5.0], + ActorIdentity::new("delver", "Cwen", ActorRole::Player), + ); + let failed = state.spawn_actor_with_identity( + [4.0, 5.0, 6.0], + ActorIdentity::new("delver", "Dun", ActorRole::Player), + ); + let npc = state.spawn_actor_with_identity( + [5.0, 6.0, 7.0], + ActorIdentity::new("warden", "Rennick", ActorRole::Npc), + ); + let monster = state.spawn_actor_with_identity( + [6.0, 7.0, 8.0], + ActorIdentity::new("drowned", "The Drowned", ActorRole::Monster), + ); + state.actors[0].stance = Stance::Rope; + state.actors[1].stance = Stance::Swim; + state.actors[5].hp = 7; + state.reputations.insert(active, Reputation::new()); + state.reputations.insert(completed, Reputation::new()); + state.reputations.insert(turned_in, Reputation::new()); + state.reputations.insert(failed, Reputation::new()); + for (id, taker) in [ + ("active", active), + ("completed", completed), + ("turned-in", turned_in), + ("failed", failed), + ] { + state + .accept_contract(id, taker, 1, 13, 2, "milthollow", 3) + .unwrap(); + } + state.fail_contract("failed").unwrap(); + state.transition_location(RunLocation::Deep(1)).unwrap(); + state + .advance_contract_objective("completed", completed, 2) + .unwrap(); + state + .advance_contract_objective("turned-in", turned_in, 2) + .unwrap(); + state + .transition_location(RunLocation::Town("milthollow".into())) + .unwrap(); + state.turn_in_contract("turned-in").unwrap(); + + let mut character = character::new(Ancestry::PaleTouched, Calling::ChoirDeserter); + character.attrs = [1, 5, 10, 15, 20]; + character.skills = [0, 1, 2, 3, 5, 8, 13, 21, 34, 55, 67, 78, 89, 100]; + character.uses = [0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 121, 144, 169]; + character.pips = [13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]; + state.characters.insert(active, character); + let reputation = state.reputations.get_mut(&active).unwrap(); + reputation.adjust_faction(Faction::Lampwrights, 3); + reputation.adjust_debt(Faction::Audit, 77); + reputation.adjust_ledger("milthollow", -12); + reputation.adjust_ledger("copperwash", 19); + reputation.adjust_god(GodId::Rustmother, -8); + + state.packs.insert(active, Pack::new()); + let template = ItemTemplate { + id: "saint-bone_lantern".into(), + material: "SAINT_BONE".into(), + condition: "humming".into(), + identified: true, + cursed: true, + blessed: true, + silvered: true, + illegal: true, + tags: vec!["lamp".into(), "relic".into(), "owed".into()], + }; + let carried = state.spawn_carried_item(active, &template).unwrap(); + let pack = state.packs.get_mut(&active).unwrap(); + let item = pack.items.get_mut(&carried).unwrap(); + item.fuel = 93; + item.wet_ticks = 41; + pack.equipped_weapon = Some(carried); + pack.max_weight_kg = 17.5; + pack.max_bulk = 9; + + state.connectors.push(Connector { + id: 71, + kind: ConnectorKind::ElevatorCar, + from: [-1.0, 2.0, 9.0], + to: [3.0, 4.0, -12.0], + length: 21.5, + anchor_quality: 88, + load_kg: 147.25, + blocked: true, + }); + state.connectors.push(Connector { + id: 72, + kind: ConnectorKind::RopeAnchor, + from: [0.0; 3], + to: [0.0, 0.0, -3.0], + length: 3.0, + anchor_quality: 64, + load_kg: 0.5, + blocked: false, + }); + let _extra = state.spawn_actor_with_identity( + [7.0, 8.0, 9.0], + ActorIdentity::new("mule", "Muck", ActorRole::Npc), + ); + state.npcs.insert( + npc, + Townsfolk { + name: "Widow Rennick".into(), + job: Job::Auctioneer, + home: (10, -2, 1), + work: (11, -3, 0), + debt_cu: -44, + }, + ); + state + .spawn_ground_item(&ItemTemplate::new("corpse", "FLESH", "sound"), (9, 9, 9)) + .unwrap(); + state.idf.insert("saint-bone_lantern".into(), 4); + state.deep_weather = -5; + state.step_mode = true; + state.set_vault_choice(VaultChoice::Crown).unwrap(); + state.restore_run_location(RunLocation::Deep(7)).unwrap(); + state.assign_chapel_debt(active).unwrap(); + assert_eq!( + state.chapel_debt(active), + Some(&ChapelDebt { + burials_owed: 1, + chapel: "chapel of the Lamplighter".into(), + }) + ); + + state.grid.set_world( + 1, + 2, + 3, + Voxel { + material: material::COPPER, + occ: 1, + fluid_kind: 2, + fluid_amt: 3, + gas_kind: 4, + gas_amt: 5, + light: 6, + scent: 7, + flags: 8, + pad: 9, + pressure: -10, + flow: 11, + extra: 12, + }, + ); + state.pressure_nodes.push(PressureNode { + id: 901, + pressure: 1.25, + }); + state.pressure_edges.push(PressureEdge { + from: 901, + to: 902, + openness: 0.75, + hidden: true, + }); + state + .causes + .push(CauseLink::new(17, "gravity", "fixture fell")); + state.laws.first_time("gravity", 17); + state.fall_odo.insert(active, 2.5); + state.lights.push(LightSource { + x: 1, + y: 2, + z: 3, + radius: 4, + warm: false, + }); + state.sounds.push(SoundEvent { + x: 3, + y: 2, + z: 1, + loudness: 5, + timbre: 6, + }); + state.corpses.insert((4, 5, 6), CorpseClock::new(99)); + state.signal_sources.push((7, 8, 9)); + state.spill.gas_flows.push((901, 902, 0.25)); + state.spill.light_pending = true; + state.spill.signal_pending = true; + state.spill.scent_pending = true; + let mut hymn_case = HymnCase::new(); + hymn_case + .slot(HymnCard { + template: "fiat_lux".into(), + conduct_base: 60, + slots: 1, + fragile: true, + miscasts: vec!["strobe".into(), "cherub".into()], + }) + .unwrap(); + state.hymn_cases.insert(active, hymn_case); + state + .cast_queue + .push((active, "fiat_lux".into(), [1, 2, 3])); + state.bargains.insert( + active, + Ledger { + debts: vec![Debt { + god: GodId::Lamplighter, + boon: "light".into(), + owed: "bury the next dead".into(), + ticks_left: Some(999), + altar_receipt: true, + }], + }, + ); + let mut shop = ShopMemory::default(); + shop.broke.insert(active, 2); + shop.stole.insert(active, true); + shop.killed.insert(active, false); + state.shops.insert((10, 11, 12), shop); + state.gossip.push(GossipPacket { + from: "milthollow".into(), + to: "copperwash".into(), + sent_day: 2, + ledger_delta: -3, + }); + state.chalk.mark((13, 14, 15), "danger").unwrap(); + state.mule = Some(MulePath { + waypoints: vec![(1, 1, 1), (2, 2, 2)], + }); + state.far.day = 2; + state.far.day_tick = 17; + + assert_eq!(state.validate_runtime(), Ok(())); + assert_eq!( + state.contract("active").unwrap().status, + ContractStatus::Active + ); + assert_eq!( + state.contract("completed").unwrap().status, + ContractStatus::Completed + ); + assert_eq!( + state.contract("turned-in").unwrap().status, + ContractStatus::TurnedIn + ); + assert_eq!( + state.contract("failed").unwrap().status, + ContractStatus::Failed + ); + assert_eq!(state.identity(monster).unwrap().role, ActorRole::Monster); + assert_eq!(state.hash_state(), 5_095_315_100_632_118_578); + + let image = state.to_image().unwrap(); + let mut restored = State::from_image(image).unwrap(); + assert_eq!(restored.tick, state.tick); + assert_eq!(restored.seed, state.seed); + assert_eq!(restored.run_location(), state.run_location()); + assert_eq!(restored.vault_choice(), state.vault_choice()); + assert_eq!(restored.chapel_debt(active), state.chapel_debt(active)); + assert_eq!(restored.try_hash_state(), state.try_hash_state()); + for _ in 0..4 { + state.step(); + restored.step(); + assert_eq!(restored.try_hash_state(), state.try_hash_state()); + } +} + +#[test] +fn canonical_hash_covers_core_clock_rng_and_grid() { + changed("tick", |state, _| state.tick = 1); + changed("seed", |state, _| state.seed = 141); + changed("rng internal state", |state, _| { + let _ = state.fork_rng(7); + }); + changed("loaded voxel", |state, _| { + state.grid.set_world( + 1, + 1, + 1, + Voxel { + material: material::COPPER, + ..Voxel::default() + }, + ); + }); +} + +#[test] +fn canonical_hash_covers_actor_identity_wallet_contract_location() { + changed("actor body", |state, _| state.actors[0].radius = 0.8); + changed("identity", |state, player| { + state.identity_mut(player).unwrap().display_name.push('!'); + }); + changed("wallet", |state, player| { + state.earn_copper(player, 1).unwrap() + }); + changed("contract", |state, player| { + state + .accept_contract("c", player, 1, 3, 1, "milthollow", 2) + .unwrap(); + }); + changed("location", |state, _| { + state.transition_location(RunLocation::Deep(1)).unwrap(); + }); +} + +#[test] +fn canonical_hash_covers_physics_and_event_queues() { + changed("connector", |state, _| { + state.connectors.push(Connector { + id: 50, + kind: ConnectorKind::Shaft, + from: [0.0, 0.0, 0.0], + to: [0.0, 0.0, -3.0], + length: 3.0, + anchor_quality: 70, + load_kg: 2.5, + blocked: false, + }); + }); + changed("pressure node", |state, _| { + state.pressure_nodes.push(PressureNode { + id: 1, + pressure: 1.5, + }); + }); + changed("pressure edge", |state, _| { + state.pressure_edges.push(PressureEdge { + from: 1, + to: 2, + openness: 0.5, + hidden: true, + }); + }); + changed("cause link", |state, _| { + state.causes.push(CauseLink::new(1, "gravity", "fell")); + }); + changed("law entry", |state, _| { + state.laws.first_time("gravity", 1); + }); + changed("fall odometer", |state, player| { + state.fall_odo.insert(player, 1.25); + }); + changed("light", |state, _| { + state.lights.push(LightSource { + x: 1, + y: 2, + z: 3, + radius: 4, + warm: true, + }); + }); + changed("sound", |state, _| { + state.sounds.push(SoundEvent { + x: 1, + y: 2, + z: 3, + loudness: 4, + timbre: 5, + }); + }); + changed("corpse", |state, _| { + state.corpses.insert((1, 2, 3), CorpseClock::new(99)); + }); + changed("signal source", |state, _| { + state.signal_sources.push((1, 2, 3)) + }); + changed("spill", |state, _| { + state.spill.gas_flows.push((1, 2, 0.25)); + state.spill.light_pending = true; + }); +} + +#[test] +fn canonical_hash_covers_progression_inventory_and_magic() { + changed("character", |state, player| { + state + .characters + .insert(player, character::new(Ancestry::CantBorn, Calling::Tinker)); + }); + changed("reputation", |state, player| { + state + .reputations + .get_mut(&player) + .unwrap() + .adjust_god(GodId::Choir, 3); + }); + changed("world item", |state, _| { + state + .spawn_ground_item(&ItemTemplate::new("corpse", "FLESH", "sound"), (0, 0, 0)) + .unwrap(); + }); + changed("pack", |state, player| { + state.packs.insert(player, Pack::new()); + state + .spawn_carried_item(player, &ItemTemplate::new("corpse", "FLESH", "sound")) + .unwrap(); + }); + changed("identification", |state, _| { + state.idf.insert("corpse".into(), 2); + }); + changed("hymn case", |state, player| { + let mut case = HymnCase::new(); + case.slot(HymnCard { + template: "fiat_lux".into(), + conduct_base: 60, + slots: 1, + fragile: false, + miscasts: vec!["strobe".into()], + }) + .unwrap(); + state.hymn_cases.insert(player, case); + }); + changed("cast queue", |state, player| { + state + .cast_queue + .push((player, "fiat_lux".into(), [1, 2, 3])); + }); + changed("bargain debt", |state, player| { + state.bargains.insert( + player, + Ledger { + debts: vec![Debt { + god: GodId::Lamplighter, + boon: "light".into(), + owed: "bury the next dead".into(), + ticks_left: Some(9), + altar_receipt: true, + }], + }, + ); + }); + changed("deep weather", |state, _| state.deep_weather = -3); +} + +#[test] +fn canonical_hash_covers_town_dungeon_and_mode_state() { + changed("npc", |state, _| { + let npc = state.spawn_actor_with_identity( + [4.0, 5.0, 6.0], + ActorIdentity::new("reeve", "Rennick", ActorRole::Npc), + ); + state.npcs.insert( + npc, + Townsfolk { + name: "Rennick".into(), + job: Job::Warden, + home: (1, 2, 3), + work: (4, 5, 6), + debt_cu: -2, + }, + ); + }); + changed("shop memory", |state, player| { + let mut shop = ShopMemory::default(); + shop.broke.insert(player, 1); + state.shops.insert((1, 2, 3), shop); + }); + changed("gossip", |state, _| { + state.gossip.push(GossipPacket { + from: "milthollow".into(), + to: "copperwash".into(), + sent_day: 1, + ledger_delta: -2, + }); + }); + changed("chalk", |state, _| { + state.chalk.mark((1, 2, 3), "danger").unwrap() + }); + changed("mule", |state, _| { + state.mule = Some(MulePath { + waypoints: vec![(1, 2, 3)], + }); + }); + changed("vault choice", |state, _| { + state.set_vault_choice(VaultChoice::Seal).unwrap() + }); + changed("next id", |state, _| { + state.spawn_actor([9.0; 3]); + }); + changed("step mode", |state, _| state.step_mode = true); + changed("far field", |state, _| state.far.day = 1); +} + +#[test] +fn step_has_no_hidden_full_world_hash_scan() { + let mut sparse = State::new(1); + sparse.spawn_actor([0.0, 0.0, 8.0]); + let mut populated = State::new(1); + populated.spawn_actor([0.0, 0.0, 8.0]); + populated.activate_around(0, 0, 0); + + let sparse_start = Instant::now(); + sparse.step(); + let sparse_elapsed = sparse_start.elapsed(); + let populated_start = Instant::now(); + populated.step(); + let populated_elapsed = populated_start.elapsed(); + + // A hidden full 27-chunk hash would dominate by orders of magnitude. Keep + // this generous to avoid timing flakiness while catching accidental scans. + assert!( + populated_elapsed < sparse_elapsed.saturating_mul(100) + std::time::Duration::from_secs(2) + ); +} + +#[test] +#[ignore = "release-only persistence-boundary budget; run with --release --ignored"] +fn canonical_hash_populated_27_chunks_stays_within_release_budget() { + #[cfg(debug_assertions)] + panic!("run this ignored persistence budget with cargo test --release -- --ignored"); + + #[cfg(not(debug_assertions))] + { + let (mut state, _) = base(); + state.activate_around(0, 0, 0); + let start = Instant::now(); + let hash = state.hash_state(); + let elapsed = start.elapsed(); + assert_ne!(hash, 0); + eprintln!("27-chunk canonical persistence hash: {elapsed:?}"); + assert!( + elapsed + <= std::time::Duration::from_millis( + hexwork_core::state::CANONICAL_HASH_RELEASE_BUDGET_MS, + ), + "27-chunk canonical persistence hash took {elapsed:?}, budget is {}ms; parent callers must snapshot/hash off tick/render and may background it", + hexwork_core::state::CANONICAL_HASH_RELEASE_BUDGET_MS + ); + } +} diff --git a/crates/hexwork-core/tests/phase10_ea_gate.rs b/crates/hexwork-core/tests/phase10_ea_gate.rs index 61bb73a..222dace 100644 --- a/crates/hexwork-core/tests/phase10_ea_gate.rs +++ b/crates/hexwork-core/tests/phase10_ea_gate.rs @@ -96,15 +96,17 @@ fn choir_sings_below_depth_9_line() { #[test] fn vault_choice_sets_once_and_reads_back() { let mut s = State::new(140); - assert_eq!(s.vault_choice, None); - s.vault_choice = Some(VaultChoice::Seal); - assert_eq!(s.vault_choice, Some(VaultChoice::Seal)); - // Set-once: parent never overwrites (shape test — no setter exists). - s.vault_choice = Some(VaultChoice::Crown); - // The TYPE allows it; the PLAN forbids it. Document the tension: - // multiple assignment compiles, so the gate asserts the read-back - // contract (NG+ reads what floor 12 wrote), not the borrow checker. - assert_eq!(s.vault_choice, Some(VaultChoice::Crown)); + assert_eq!(s.vault_choice(), None); + assert_eq!(s.set_vault_choice(VaultChoice::Seal), Ok(())); + assert_eq!(s.vault_choice(), Some(VaultChoice::Seal)); + + // The invariant is implementation-enforced: a second assignment reports + // the original oath and cannot replace it. + assert_eq!( + s.set_vault_choice(VaultChoice::Crown), + Err(VaultChoice::Seal) + ); + assert_eq!(s.vault_choice(), Some(VaultChoice::Seal)); } /// Chalk + mule pathing replace the compass (no coordinates leak). diff --git a/crates/hexwork-core/tests/phase8_magic.rs b/crates/hexwork-core/tests/phase8_magic.rs index c7cca9c..98ac2c1 100644 --- a/crates/hexwork-core/tests/phase8_magic.rs +++ b/crates/hexwork-core/tests/phase8_magic.rs @@ -48,7 +48,10 @@ fn card_for(h: &hexwork_content::Hymn) -> HymnCard { #[test] fn mirror_covers_every_canonical_hymn() { let hymns = canonical_hymns(); - assert_eq!(hymns.len(), 7); + assert!( + hymns.len() >= 7, + "the seven launch hymns remain the minimum; additions must also parse" + ); for h in &hymns { let card = card_for(h); assert_eq!(card.template, h.id); diff --git a/crates/hexwork-core/tests/runtime_state.rs b/crates/hexwork-core/tests/runtime_state.rs new file mode 100644 index 0000000..2db63d6 --- /dev/null +++ b/crates/hexwork-core/tests/runtime_state.rs @@ -0,0 +1,628 @@ +use hexwork_core::character::{self, Ancestry, Calling}; +use hexwork_core::ecs::StableId; +use hexwork_core::inventory::Pack; +use hexwork_core::item::{Instance, ItemTemplate}; +use hexwork_core::modes::ChapelDebtError; +use hexwork_core::reputation::Reputation; +use hexwork_core::runtime::{ + ActorIdentity, ActorRole, ContractError, ContractStatus, LocationError, RunLocation, + RuntimeInvariantError, Wallet, WalletError, +}; +use hexwork_core::state::State; + +fn identity(template: &str, name: &str, role: ActorRole) -> ActorIdentity { + ActorIdentity::new(template, name, role) +} + +fn state_with_player() -> (State, u64) { + let mut state = State::new(140); + let player = state.spawn_actor_with_identity( + [1.0, 2.0, 3.0], + identity("delver", "Ada", ActorRole::Player), + ); + state.reputations.insert(player, Reputation::new()); + state.initialize_town("milthollow").unwrap(); + (state, player) +} + +fn accept(state: &mut State, player: u64, floor: u8) { + state + .accept_contract("rat_cull", player, floor, 45, 3, "milthollow", 2) + .unwrap(); +} + +fn descend_to(state: &mut State, floor: u8) { + for depth in 1..=floor { + state.transition_location(RunLocation::Deep(depth)).unwrap(); + } +} + +#[test] +fn spawn_creates_exact_authority_links_without_ecs_mirror() { + let (state, player) = state_with_player(); + assert_eq!(state.actors.len(), 1); + assert_eq!(state.actors[0].id, player); + assert_eq!(state.identity(player).unwrap().display_name, "Ada"); + assert_eq!(state.wallet(player), Some(&Wallet::default())); + assert!(state.ecs.world().is_empty()); + assert_eq!(state.validate_runtime(), Ok(())); +} + +#[test] +fn chapel_debt_is_private_authority_and_removed_when_worked() { + let (mut state, player) = state_with_player(); + assert_eq!( + state.assign_chapel_debt(999), + Err(ChapelDebtError::UnknownActor) + ); + state.assign_chapel_debt(player).unwrap(); + assert_eq!(state.chapel_debt(player).unwrap().burials_owed, 1); + assert_eq!(state.work_chapel_debt(player), Ok(true)); + assert_eq!(state.chapel_debt(player), None); + assert_eq!(state.work_chapel_debt(player), Err(ChapelDebtError::NoDebt)); +} + +#[test] +fn chapel_debt_requires_life_and_is_removed_on_death() { + let (mut state, player) = state_with_player(); + state.actors[0].alive = false; + assert_eq!( + state.assign_chapel_debt(player), + Err(ChapelDebtError::DeadActor) + ); + + state.actors[0].alive = true; + state.assign_chapel_debt(player).unwrap(); + state.actors[0].hp = 0; + state.step(); + assert!(!state.actors[0].alive); + assert_eq!(state.chapel_debt(player), None); + assert_eq!(state.validate_runtime(), Ok(())); +} + +#[test] +fn legacy_spawn_also_creates_mandatory_links() { + let mut state = State::new(1); + let id = state.spawn_actor([0.0; 3]); + assert!(state.identity(id).is_some()); + assert!(state.wallet(id).is_some()); + assert_eq!(state.validate_runtime(), Ok(())); +} + +#[test] +fn spawn_ids_are_unique() { + let (mut state, first) = state_with_player(); + let second = + state.spawn_actor_with_identity([2.0; 3], identity("rat", "Rat", ActorRole::Monster)); + assert_ne!(first, second); + assert_eq!(state.validate_runtime(), Ok(())); +} + +#[test] +fn validator_rejects_nonempty_ecs() { + let (mut state, _) = state_with_player(); + state.ecs.spawn((StableId(99),)); + assert_eq!( + state.validate_runtime(), + Err(RuntimeInvariantError::EcsNotEmpty { entities: 1 }) + ); + assert!(matches!( + state.try_hash_state(), + Err(RuntimeInvariantError::EcsNotEmpty { .. }) + )); +} + +#[test] +fn validator_rejects_duplicate_actor_ids() { + let (mut state, _) = state_with_player(); + state.actors.push(state.actors[0].clone()); + assert!(matches!( + state.validate_runtime(), + Err(RuntimeInvariantError::DuplicateActorId { .. }) + )); +} + +#[test] +fn validator_rejects_orphan_optional_actor_map() { + let (mut state, _) = state_with_player(); + state + .characters + .insert(999, character::new(Ancestry::Mudborn, Calling::Ratcatcher)); + assert_eq!( + state.validate_runtime(), + Err(RuntimeInvariantError::OrphanActorMetadata { + table: "characters", + actor_id: 999, + }) + ); +} + +#[test] +fn validator_rejects_item_key_mismatch() { + let (mut state, _) = state_with_player(); + state.items.insert(3, Instance::corpse(4, [0.0; 3])); + assert_eq!( + state.validate_runtime(), + Err(RuntimeInvariantError::ItemKeyMismatch { + table: "items", + key: 3, + embedded: 4, + }) + ); +} + +#[test] +fn ground_item_spawn_allocates_stable_id_and_hashes_position() { + let (mut state, player) = state_with_player(); + let template = ItemTemplate::new("chalk_stick", "STONE", "sound"); + let id = state.spawn_ground_item(&template, (4, 5, 6)).unwrap(); + assert!(id > player); + assert_eq!(state.items.get(&id).unwrap().template, "chalk_stick"); + assert_eq!(state.ground_items.get(&id), Some(&(4, 5, 6))); + let first = state.try_hash_state().unwrap(); + state.ground_items.insert(id, (5, 5, 6)); + assert_ne!(first, state.try_hash_state().unwrap()); +} + +#[test] +fn carried_item_spawn_uses_same_stable_id_space() { + let (mut state, player) = state_with_player(); + state.packs.insert(player, Pack::new()); + let template = ItemTemplate::new("rope_10m", "ROPE", "sound"); + let id = state.spawn_carried_item(player, &template).unwrap(); + assert_eq!( + state.packs.get(&player).unwrap().get(id).unwrap().template, + "rope_10m" + ); + assert!(!state.items.contains_key(&id)); + assert!(!state.ground_items.contains_key(&id)); + assert_eq!(state.validate_runtime(), Ok(())); +} + +#[test] +fn invalid_ground_item_template_does_not_consume_id() { + let (mut state, player) = state_with_player(); + let bad = ItemTemplate::new("bad", "not-a-material", "sound"); + assert!(state.spawn_ground_item(&bad, (0, 0, 0)).is_err()); + let actor = + state.spawn_actor_with_identity([0.0; 3], identity("rat", "Rat", ActorRole::Monster)); + assert_eq!(actor, player + 1); +} + +#[test] +fn validator_rejects_dangling_ground_item() { + let (mut state, _) = state_with_player(); + state.ground_items.insert(999, (1, 2, 3)); + assert_eq!( + state.validate_runtime(), + Err(RuntimeInvariantError::DanglingGroundItem { item_id: 999 }) + ); +} + +#[test] +fn validator_rejects_loose_item_without_ground_position() { + let (mut state, _) = state_with_player(); + state.items.insert(3, Instance::corpse(3, [0.0; 3])); + assert_eq!( + state.validate_runtime(), + Err(RuntimeInvariantError::LooseItemMissingGround { item_id: 3 }) + ); +} + +#[test] +fn validator_rejects_zero_and_cross_kind_stable_ids() { + let (mut zero_actor, _) = state_with_player(); + zero_actor.actors[0].id = 0; + assert_eq!( + zero_actor.validate_runtime(), + Err(RuntimeInvariantError::ZeroStableId { table: "actors" }) + ); + + let (mut zero_loose, _) = state_with_player(); + zero_loose.items.insert(0, Instance::corpse(0, [0.0; 3])); + zero_loose.ground_items.insert(0, (0, 0, 0)); + assert_eq!( + zero_loose.validate_runtime(), + Err(RuntimeInvariantError::ZeroStableId { table: "items" }) + ); + + let (mut zero_packed, player) = state_with_player(); + let mut pack = Pack::new(); + pack.add(Instance::corpse(0, [0.0; 3])); + zero_packed.packs.insert(player, pack); + assert_eq!( + zero_packed.validate_runtime(), + Err(RuntimeInvariantError::ZeroStableId { table: "packs" }) + ); + + let (mut collision, player) = state_with_player(); + collision + .items + .insert(player, Instance::corpse(player, [0.0; 3])); + collision.ground_items.insert(player, (0, 0, 0)); + assert_eq!( + collision.validate_runtime(), + Err(RuntimeInvariantError::StableIdCollision { + id: player, + first: "actors", + second: "items", + }) + ); + + let (mut packed_collision, player) = state_with_player(); + let mut pack = Pack::new(); + pack.add(Instance::corpse(player, [0.0; 3])); + packed_collision.packs.insert(player, pack); + assert_eq!( + packed_collision.validate_runtime(), + Err(RuntimeInvariantError::StableIdCollision { + id: player, + first: "actors", + second: "packs", + }) + ); +} + +#[test] +fn validator_rejects_duplicate_item_ownership() { + let (mut state, player) = state_with_player(); + state.items.insert(3, Instance::corpse(3, [0.0; 3])); + state.ground_items.insert(3, (0, 0, 0)); + let mut pack = Pack::new(); + pack.add(Instance::corpse(3, [0.0; 3])); + state.packs.insert(player, pack); + assert_eq!( + state.validate_runtime(), + Err(RuntimeInvariantError::DuplicateItemId { item_id: 3 }) + ); +} + +#[test] +fn wallet_balances_are_read_only_and_state_transactions_are_checked() { + let (mut state, player) = state_with_player(); + assert_eq!(state.earn_copper(player, 10), Ok(())); + assert_eq!(state.earn_scrip(player, 4), Ok(())); + assert_eq!(state.spend_copper(player, 3), Ok(())); + assert_eq!(state.spend_scrip(player, 1), Ok(())); + assert_eq!(state.wallet(player).unwrap().copper(), 7); + assert_eq!(state.wallet(player).unwrap().scrip(), 3); + assert_eq!( + state.spend_copper(player, 8), + Err(WalletError::InsufficientFunds) + ); + assert_eq!(state.wallet(player).unwrap().copper(), 7); +} + +#[test] +fn saturating_earn_returns_actual_credit() { + let mut wallet = Wallet::new(u32::MAX - 2, 0); + assert_eq!(wallet.saturating_earn_copper(7), 2); + assert_eq!(wallet.copper(), u32::MAX); + assert_eq!(wallet.saturating_earn_scrip(3), 3); +} + +#[test] +fn run_location_starts_uninitialized_and_rejects_blank_town() { + let mut state = State::new(4); + assert_eq!(state.run_location(), &RunLocation::Uninitialized); + assert_eq!(state.initialize_town(" "), Err(LocationError::BlankTown)); + assert_eq!(state.run_location(), &RunLocation::Uninitialized); +} + +#[test] +fn location_transitions_enforce_adjacency_and_boundaries() { + let mut state = State::new(4); + state.initialize_town("milthollow").unwrap(); + assert_eq!( + state.transition_location(RunLocation::Deep(2)), + Err(LocationError::IllegalTransition) + ); + state.transition_location(RunLocation::Deep(1)).unwrap(); + state.transition_location(RunLocation::Deep(2)).unwrap(); + assert_eq!( + state.transition_location(RunLocation::Deep(4)), + Err(LocationError::IllegalTransition) + ); + assert_eq!( + state.transition_location(RunLocation::Deep(13)), + Err(LocationError::InvalidDepth) + ); + state.transition_location(RunLocation::Deep(3)).unwrap(); + assert_eq!(state.run_location(), &RunLocation::Deep(3)); +} + +#[test] +fn only_deep_one_can_return_to_town() { + let (mut state, _) = state_with_player(); + descend_to(&mut state, 2); + assert_eq!( + state.transition_location(RunLocation::Town("milthollow".into())), + Err(LocationError::IllegalTransition) + ); + state.transition_location(RunLocation::Deep(1)).unwrap(); + state + .transition_location(RunLocation::Town("milthollow".into())) + .unwrap(); +} + +#[test] +fn restore_setter_is_validated_but_not_adjacent() { + let mut state = State::new(1); + assert_eq!( + state.restore_run_location(RunLocation::Town(String::new())), + Err(LocationError::BlankTown) + ); + assert_eq!(state.restore_run_location(RunLocation::Deep(12)), Ok(())); +} + +#[test] +fn contract_acceptance_validates_all_inputs() { + let (mut state, player) = state_with_player(); + assert_eq!( + state.accept_contract(" ", player, 1, 1, 1, "milthollow", 1), + Err(ContractError::BlankId) + ); + assert_eq!( + state.accept_contract("bad-floor", player, 0, 1, 1, "milthollow", 1), + Err(ContractError::InvalidFloor) + ); + assert_eq!( + state.accept_contract("bad-target", player, 1, 1, 0, "milthollow", 1), + Err(ContractError::InvalidObjectiveTarget) + ); + assert_eq!( + state.accept_contract("bad-town", player, 1, 1, 1, " ", 1), + Err(ContractError::BlankRewardTown) + ); + assert_eq!( + state.accept_contract("orphan", 999, 1, 1, 1, "milthollow", 1), + Err(ContractError::UnknownTaker) + ); +} + +#[test] +fn contract_rejects_dead_taker() { + let (mut state, player) = state_with_player(); + state.actors[0].alive = false; + assert_eq!( + state.accept_contract("c", player, 1, 1, 1, "milthollow", 1), + Err(ContractError::DeadTaker) + ); +} + +#[test] +fn contract_acceptance_requires_exact_current_town_without_mutation() { + let mut uninitialized = State::new(1); + let uninitialized_player = uninitialized.spawn_actor([0.0; 3]); + assert_eq!( + uninitialized.accept_contract("c", uninitialized_player, 1, 1, 1, "milthollow", 1), + Err(ContractError::WrongLocation) + ); + assert!(uninitialized.contracts().next().is_none()); + + let (mut deep, deep_player) = state_with_player(); + deep.transition_location(RunLocation::Deep(1)).unwrap(); + assert_eq!( + deep.accept_contract("c", deep_player, 1, 1, 1, "milthollow", 1), + Err(ContractError::WrongLocation) + ); + assert!(deep.contracts().next().is_none()); + + let (mut wrong_town, wrong_town_player) = state_with_player(); + assert_eq!( + wrong_town.accept_contract("c", wrong_town_player, 1, 1, 1, "copperwash", 1), + Err(ContractError::WrongLocation) + ); + assert!(wrong_town.contracts().next().is_none()); +} + +#[test] +fn active_contract_fails_when_taker_dies_during_step() { + let (mut state, player) = state_with_player(); + accept(&mut state, player, 1); + state.actors[0].hp = 0; + + state.step(); + + assert_eq!( + state.contract("rat_cull").unwrap().status, + ContractStatus::Failed + ); + assert_eq!(state.validate_runtime(), Ok(())); + assert!(state.try_hash_state().is_ok()); +} + +#[test] +fn completed_contract_fails_and_terminal_contracts_survive_taker_death() { + let (mut state, player) = completed_contract(); + state + .accept_contract("terminal", player, 1, 7, 1, "milthollow", 0) + .unwrap(); + state.fail_contract("terminal").unwrap(); + state.actors[0].hp = 0; + + state.step(); + + assert_eq!( + state.contract("rat_cull").unwrap().status, + ContractStatus::Failed + ); + assert_eq!( + state.contract("terminal").unwrap().status, + ContractStatus::Failed + ); + assert_eq!(state.validate_runtime(), Ok(())); + assert!(state.try_hash_state().is_ok()); +} + +#[test] +fn turned_in_contract_survives_taker_death() { + let (mut state, player) = completed_contract(); + state.turn_in_contract("rat_cull").unwrap(); + state.actors[0].hp = 0; + + state.step(); + + assert_eq!( + state.contract("rat_cull").unwrap().status, + ContractStatus::TurnedIn + ); + assert_eq!(state.validate_runtime(), Ok(())); + assert!(state.try_hash_state().is_ok()); + assert!( + !state + .actors + .iter() + .find(|actor| actor.id == player) + .unwrap() + .alive + ); +} + +#[test] +fn contract_progress_is_scoped_to_taker_and_matching_floor() { + let (mut state, player) = state_with_player(); + let other = + state.spawn_actor_with_identity([2.0; 3], identity("other", "Other", ActorRole::Player)); + accept(&mut state, player, 2); + assert_eq!( + state.advance_contract_objective("rat_cull", player, 1), + Err(ContractError::WrongLocation) + ); + descend_to(&mut state, 2); + assert_eq!( + state.advance_contract_objective("rat_cull", other, 1), + Err(ContractError::WrongTaker) + ); + assert_eq!( + state.advance_contract_objective("rat_cull", player, 2), + Ok(()) + ); + assert_eq!(state.contract("rat_cull").unwrap().objective_progress, 2); +} + +#[test] +fn objective_clamps_and_completes() { + let (mut state, player) = state_with_player(); + accept(&mut state, player, 1); + descend_to(&mut state, 1); + state + .advance_contract_objective("rat_cull", player, u32::MAX) + .unwrap(); + let contract = state.contract("rat_cull").unwrap(); + assert_eq!(contract.objective_progress, 3); + assert_eq!(contract.status, ContractStatus::Completed); + assert_eq!( + state.advance_contract_objective("rat_cull", player, 1), + Err(ContractError::NotActive) + ); +} + +#[test] +fn fail_transition_is_terminal_and_idempotent() { + let (mut state, player) = state_with_player(); + accept(&mut state, player, 1); + assert_eq!(state.fail_contract("rat_cull"), Ok(())); + assert_eq!( + state.contract("rat_cull").unwrap().status, + ContractStatus::Failed + ); + assert_eq!(state.fail_contract("rat_cull"), Err(ContractError::NotOpen)); +} + +fn completed_contract() -> (State, u64) { + let (mut state, player) = state_with_player(); + accept(&mut state, player, 1); + descend_to(&mut state, 1); + state + .advance_contract_objective("rat_cull", player, 3) + .unwrap(); + state + .transition_location(RunLocation::Town("milthollow".into())) + .unwrap(); + (state, player) +} + +#[test] +fn turn_in_requires_exact_reward_town() { + let (mut state, _) = completed_contract(); + state + .restore_run_location(RunLocation::Town("copperwash".into())) + .unwrap(); + assert_eq!( + state.turn_in_contract("rat_cull"), + Err(ContractError::WrongLocation) + ); +} + +#[test] +fn turn_in_atomically_credits_copper_and_standing_once() { + let (mut state, player) = completed_contract(); + assert_eq!(state.turn_in_contract("rat_cull"), Ok(())); + assert_eq!(state.wallet(player).unwrap().copper(), 45); + assert_eq!(state.reputations[&player].ledger("milthollow"), 2); + assert_eq!( + state.turn_in_contract("rat_cull"), + Err(ContractError::NotCompleted) + ); + assert_eq!(state.wallet(player).unwrap().copper(), 45); + assert_eq!(state.reputations[&player].ledger("milthollow"), 2); +} + +#[test] +fn missing_reputation_aborts_payout_without_wallet_mutation() { + let (mut state, player) = completed_contract(); + state.reputations.remove(&player); + assert_eq!( + state.turn_in_contract("rat_cull"), + Err(ContractError::MissingReputation) + ); + assert_eq!(state.wallet(player).unwrap().copper(), 0); + assert_eq!( + state.contract("rat_cull").unwrap().status, + ContractStatus::Completed + ); +} + +#[test] +fn payout_overflow_is_atomic() { + let (mut state, player) = completed_contract(); + state.earn_copper(player, u32::MAX).unwrap(); + assert_eq!( + state.turn_in_contract("rat_cull"), + Err(ContractError::PayoutOverflow) + ); + assert_eq!(state.wallet(player).unwrap().copper(), u32::MAX); + assert_eq!(state.reputations[&player].ledger("milthollow"), 0); + assert_eq!( + state.contract("rat_cull").unwrap().status, + ContractStatus::Completed + ); +} + +#[test] +fn maximum_character_counters_hash_without_debug_overflow() { + let (mut state, player) = state_with_player(); + let mut character = character::new(Ancestry::Foundling, Calling::Tinker); + character.attrs = [u8::MAX; character::ATTRIBUTE_COUNT]; + character.skills = [u8::MAX; character::SKILL_COUNT]; + character.uses = [u32::MAX; character::SKILL_COUNT]; + character.pips = [u8::MAX; character::SKILL_COUNT]; + state.characters.insert(player, character); + assert!(state.try_hash_state().is_ok()); +} + +#[test] +fn same_seed_and_same_runtime_actions_hash_equal() { + let (mut first, first_player) = state_with_player(); + let (mut second, second_player) = state_with_player(); + for (state, player) in [(&mut first, first_player), (&mut second, second_player)] { + state.earn_copper(player, 8).unwrap(); + accept(state, player, 1); + descend_to(state, 1); + state + .advance_contract_objective("rat_cull", player, 1) + .unwrap(); + } + assert_eq!(first.hash_state(), second.hash_state()); +}