//! World state: the deterministic sim kernel (docs/04 §4.2–4.3). //! //! 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; use crate::actor::Actor; use crate::air::{self, pack_cell}; 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; use crate::fire; use crate::fluids; use crate::gravity; use crate::hymn::{self, HymnCase}; use crate::inventory::Pack; 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::observation::{ observe, observer_origin, LocationKey, ObservationError, ObservationLimits, ObservationMemory, ObservationRow, }; use crate::pending::{Pending, PendingAction, PendingError}; 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::{ validate_knowledge_id, ActiveContract, ActorIdentity, ContractError, ContractStatus, ItemTransfer, KnowledgeCategory, KnowledgeError, LocationError, MeleeError, MeleeExchange, MeleeStrike, RunKnowledge, RunLocation, RuntimeInvariantError, TransferError, Wallet, WalletError, }; use crate::scent; use crate::schedule::day_phase; use crate::signal; use crate::sound::{self, SoundEvent}; use crate::structure; use crate::voxel::ChunkedGrid; /// Fixed simulation step: 10Hz → 100ms per tick. 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. /// 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 /// full-region scan stays affordable: 49³ ≈ 118k cells worst case, and each /// phase only READS empty cells (writes are budget-capped at 4k). pub const ACTIVE_HALF_EXTENT: i32 = 24; /// Deferred per-system spillover carried to the next tick (docs/04 §4.2: /// capped phases defer, never drop). Signal/scent report a bool; gas/light /// report tail counts re-queued here. #[derive(Clone, Debug, Default)] pub struct Spill { /// One desired world heading per actor, consumed once by Move. pub desired_moves: BTreeMap, /// Swept body cells awaiting this tick's hazard phase; deduplicated per actor. pub crossed_cells: BTreeMap>, /// Gas flow triples left unprocessed (`flows[consumed..]` semantics: /// parent rebuilds flows each tick and skips the first `gas_skip`). pub gas_flows: Vec<(u64, u64, f32)>, /// Light needs a re-run when the flood exceeded budget. pub light_pending: bool, /// Signal wave was truncated; re-run next tick. pub signal_pending: bool, /// Scent pass was truncated; re-run next tick. pub scent_pending: bool, } /// Abstract far-field record for unloaded towns (docs/04 §4.3): schedules /// and prices advance cheaply without voxels. Full town sim lands in Phase 9; /// the kernel already ticks its clock so gossip latency has a time base. #[derive(Clone, Debug, Default)] pub struct FarField { /// Days elapsed since run start. pub day: u64, /// Tick remainder toward the next day. pub day_tick: u64, } impl FarField { fn advance(&mut self) { self.day_tick += 1; if self.day_tick >= TICKS_PER_DAY { self.day_tick = 0; 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: crate::spatial::FixedVec3, corpse: Instance, contracts_to_fail: Vec, death_detail: String, } #[derive(Debug)] struct PreparedDeathBatch { deaths: Vec, next_id: u64, } /// Deterministic world kernel. No I/O. No threads. No wall-clock. #[derive(Debug)] pub struct State { pub tick: u64, pub seed: u64, /// Durable Story branch epoch (docs/fluid §7). `0` for a fresh run. Story /// resurrection increments it and restamps the restored observation /// provenance so a descriptor, target, observation, or queued input from a /// rolled-back branch cannot pass as current. Part of the canonical hash, /// the checkpoint image, and the wire. pub branch_epoch: 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, pub causes: CauseChain, pub far: FarField, /// First-time law registry (§4.10.5 "no silent rules"). The kernel gates /// every display-facing hazard link on `first_time` so laws speak once /// per run while replays keep every occurrence. pub laws: LawLog, /// Per-actor metres descended, owned here and passed to /// `gravity::step_falling_with_mass` every Move tick (total-height /// damage). Clear an actor's entry on teleport. pub fall_odo: BTreeMap, /// Live light sources: lantern cone anchor + carried glow + flora. /// The Light phase consumes this list every tick. pub lights: Vec, /// Sound queue drained by the Sound phase each tick. pub sounds: Vec, /// Corpse clocks owned by the Rot phase (positions → burst spawns). pub corpses: BTreeMap<(i32, i32, i32), CorpseClock>, /// Live signal origins (frayed cable, hymn-case discharge — Phase 8 hook). pub signal_sources: Vec<(i32, i32, i32)>, /// Deferred spillover re-queued next tick. pub spill: Spill, /// Character sheets keyed by actor id (docs/03 §3.1). Spawned actors get /// a Character at chargen/spawn; growth accrues via `record_use` at the /// call sites below. pub characters: BTreeMap, /// Per-delver standing keyed by actor id (docs/03 §3.1: ledgers follow /// the DELVER — grudges remember faces; knowledge follows the LINEAGE /// via `hexwork-save::knowledge`). Town gossip (Phase 9) moves ledger /// entries between delvers at 3-day latency. pub reputations: BTreeMap, /// Live item instances keyed by instance id (docs/03 §3.4). Templates /// 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, /// Core-owned deterministic map, recipe, and translation knowledge for /// this run. Mutation is restricted to validated learning operations. knowledge: RunKnowledge, /// Per-delver identification working book (docs/03 §3.4). Template ids /// learned here merge into the lineage vault at death (Phase 11); /// learning persists, gear does not. Stored as template → UsesSeen /// count (mirrors `hexwork-content` IdfBook without the content dep; /// Unknown≡absent, Tried≡1, Known≡2+ — see identify.rs ladder). pub idf: BTreeMap, /// Hymn-cases keyed by actor id (docs/03 §3.5: loadout strategy). /// Cast intents queue here; the Hymns phase resolves them. pub hymn_cases: BTreeMap, /// Pending cast intents: (caster, hymn template, target cell). Queued by /// prayer/hymn actions (Phase 9 UI); drained by the Hymns phase. pub cast_queue: Vec<(u64, String, [i32; 3])>, /// Per-delver bargain ledgers keyed by actor id (docs/03 §3.5: debts in /// words). Numeric god-credit lives in `reputations`; this is the TEXT /// side. Never enters the lineage vault (debts are gear-like, §3.9). pub bargains: BTreeMap, /// Deep-weather conduct modifier, -20..=+20 (docs/03 §3.5: copper + /// weather shape the roll). Shallows 0, Works drift, Deep storms /// (Phase 10 owns the weather sim; Phase 8 seeds + reads). pub deep_weather: i8, /// Townsfolk records keyed by npc actor id (docs/03 §3.6). NPCs ARE /// actors (they move, block, die) with Townsfolk records — no Character /// (they don't grow by PC rules), no packs (infinite-ish stock + /// restock clock, documented in the wiring plan). pub npcs: BTreeMap, /// Shop memories keyed by shop-floor anchor cell (docs/03 §3.6: /// NetHack-law ledgers/memory/grudges — shop-LOCAL; town ledgers stay /// town-global in `reputations`, never double-punish). pub shops: BTreeMap<(i32, i32, i32), ShopMemory>, /// Gossip packets in flight (docs/02 §4: 3-day latency). Drained in the /// Rot phase via `gossip_arrived`; ledger deltas land on arrival. pub gossip: Vec, /// Player chalk marks (docs/03 §3.7: no compass — chalk + annotations + /// mule-pathing replace markers). Per-delver; dust-capped at 64. pub chalk: Chalk, /// The mule's path (docs/01: you will lose the mule). The mule IS an /// 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. 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 /// single-step primitive; key handling lives in `hexwork-term`/`cli`. pub step_mode: bool, /// The single pending committed-action slot (docs/fluid §4.3). At most one /// operation is in flight; it stores a typed, candidate-free descriptor and /// the authored remaining ticks — never a cloned `State`. Admission and /// finalization policy belong to the caller (stage 2); the kernel owns the /// slot's shape, hash, image projection, and validation invariants. pub pending: Option, /// Canonical player terrain-observation memory (docs/fluid §5), keyed by /// active location + voxel boundary face. Bounded per location and in /// aggregate; refreshed at admitted world-tick boundaries, on initial /// location setup, and on explicit arrival. Private so it can only change /// through the canonical observation pass or the explicit epoch/clear /// lifecycle hooks below. observations: ObservationMemory, /// Non-canonical light-bake memo (docs/03 §3.3 law 1 wiring): the source /// set of the last completed `Light` pass. The lamp list is owned by /// [`State::lights`] and the outer loop moves a carried lamp by overwriting /// that list in place, so the phase keeps its own record of what was baked /// to know when a lamp appeared, moved, or was doused and therefore when a /// full re-bake (plus clearing the departed lamps' old boxes) is required. /// This is a pure derived cache: it is excluded from the checkpoint image, /// the canonical hash, and the wire, and [`State::from_image`] rebuilds it /// empty, so the first post-restore light phase re-bakes exactly the field a /// live run holds without perturbing persisted or replay bytes. light_baked: Vec, } /// 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 = 0x8005_0005; // development fixed schema, NOT final v5 /// Appended canonical hash domain for [`RunKnowledge`]. Domains 0..=46 retain /// their v3 order and primitive encodings; this is the sole v4 addition. pub const RUN_KNOWLEDGE_HASH_DOMAIN: u64 = 47; /// Appended canonical hash domain for the single pending-action slot /// (docs/fluid §4.3). One outer row: presence, start tick, remaining ticks, /// operation tag, and every captured target id. Absent = zero rows. pub const PENDING_HASH_DOMAIN: u64 = 48; /// Appended canonical hash domain for the canonical player terrain-observation /// memory (docs/fluid §5). One outer row per location, then one row per reached /// boundary face carrying its last-seen appearance, observation tick, and /// branch epoch. Absent = zero rows. pub const OBSERVATION_HASH_DOMAIN: u64 = 49; /// Appended canonical hash domain for the durable Story branch epoch /// (docs/fluid §7). One outer row: the epoch scalar. `0` for a fresh run; a /// Story resurrection increments it. Hashing it means a rolled-back branch and /// its restored successor cannot collide even when their record contents and /// ticks agree. pub const BRANCH_EPOCH_HASH_DOMAIN: u64 = 50; /// 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", "aperi" => "APERI", "pondus" => "PONDUS", "sile" => "SILE", "fluctus" => "FLUCTUS", "memoria" => "MEMORIA", "caro_vale" => "CARO VALE", other => other, } .to_string() } /// Gossip packet in flight (docs/02 §4: steal in Milthollow, Copperwash /// hears in 3 days). Queued by ledger-changing deeds; drained in Rot via /// `schedule::gossip_arrived`; the delta lands on the target town ledger. #[derive(Clone, Debug, PartialEq, Eq)] pub struct GossipPacket { /// Town id where the deed happened. pub from: String, /// Town id that hears about it. pub to: String, /// FarField day the packet was sent. pub sent_day: u64, /// Ledger delta to apply on arrival (grudges travel, faces blur). pub ledger_delta: i16, } impl State { pub fn new(seed: u64) -> Self { let mut rng_master = Rng::from_seed(seed); // Draw once so `fork` streams diverge from the raw seed. let _ = rng_master.next_u64(); let _ = domains::WORLDGEN_SEED; Self { tick: 0, seed, branch_epoch: 0, rng_master, 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(), causes: CauseChain::new(), far: FarField::default(), laws: LawLog::new(), fall_odo: BTreeMap::new(), lights: Vec::new(), sounds: Vec::new(), corpses: BTreeMap::new(), signal_sources: Vec::new(), spill: Spill::default(), characters: BTreeMap::new(), reputations: BTreeMap::new(), items: BTreeMap::new(), ground_items: BTreeMap::new(), packs: BTreeMap::new(), knowledge: RunKnowledge::new(), idf: BTreeMap::new(), hymn_cases: BTreeMap::new(), cast_queue: Vec::new(), bargains: BTreeMap::new(), deep_weather: 0, npcs: BTreeMap::new(), shops: BTreeMap::new(), gossip: Vec::new(), chalk: Chalk::new(), mule: None, vault_choice: None, chapel_debts: BTreeMap::new(), next_id: 1, step_mode: false, pending: None, observations: ObservationMemory::new(), light_baked: Vec::new(), } } 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) } /// Admit exactly one player sample for the next world tick. Does not advance time. /// The session must call `step()` once even if collision blocks the sample. pub fn admit_player_movement(&mut self, actor_id: u64, heading: Option) -> Result<(), crate::locomotion::MovementAdmissionError> { use crate::locomotion::{MovementAdmissionError as Error, MovementSample}; self.validate_runtime().map_err(|_| Error::InvalidState)?; if !self.actors.iter().any(|a| a.id == actor_id && a.alive) || !self.actor_identities.get(&actor_id).is_some_and(|i| i.role == crate::runtime::ActorRole::Player) { return Err(Error::MissingLivePlayer); } if self.spill.desired_moves.contains_key(&actor_id) { return Err(Error::AlreadyAdmitted); } self.spill.desired_moves.insert(actor_id, MovementSample::player(heading)); Ok(()) } /// 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: crate::spatial::FixedVec3) -> 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: crate::spatial::FixedVec3, 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) } /// Move one loose item at the living actor's rounded cell into their pack. /// Every rejection occurs before the first ownership-table mutation. pub fn pickup_ground_item( &mut self, actor_id: u64, item_id: u64, ) -> Result { self.validate_runtime() .map_err(TransferError::InvalidRuntime)?; let actor = self .actors .iter() .find(|actor| actor.id == actor_id) .ok_or(TransferError::UnknownActor)?; if !actor.alive || actor.hp <= 0 { return Err(TransferError::DeadActor); } if !self.packs.contains_key(&actor_id) { return Err(TransferError::MissingPack); } let [x, y, z] = actor.position.cell(); let cell = (x, y, z); if !self.items.contains_key(&item_id) { return Err(TransferError::GroundItemMissing); } if self.ground_items.get(&item_id) != Some(&cell) { return Err(TransferError::GroundItemElsewhere); } let item = self.items.remove(&item_id).expect("loose item preflighted"); let removed_cell = self .ground_items .remove(&item_id) .expect("ground location validated with runtime"); debug_assert_eq!(removed_cell, cell); let replaced = self .packs .get_mut(&actor_id) .expect("pack preflighted") .items .insert(item_id, item); debug_assert!(replaced.is_none()); Ok(ItemTransfer { item_id, cell }) } /// Move one carried item to the living actor's rounded cell. /// Every rejection occurs before the first ownership-table mutation. pub fn drop_carried_item( &mut self, actor_id: u64, item_id: u64, ) -> Result { self.validate_runtime() .map_err(TransferError::InvalidRuntime)?; let actor = self .actors .iter() .find(|actor| actor.id == actor_id) .ok_or(TransferError::UnknownActor)?; if !actor.alive || actor.hp <= 0 { return Err(TransferError::DeadActor); } let pack = self .packs .get(&actor_id) .ok_or(TransferError::MissingPack)?; let [x, y, z] = actor.position.cell(); let cell = (x, y, z); let item = pack .items .get(&item_id) .ok_or(TransferError::CarriedItemMissing)?; if item.sticks() { return Err(TransferError::Stuck); } let pack = self.packs.get_mut(&actor_id).expect("pack preflighted"); let item = pack .items .remove(&item_id) .expect("carried item preflighted"); if pack.equipped_weapon == Some(item_id) { pack.equipped_weapon = None; } if pack.equipped_armor == Some(item_id) { pack.equipped_armor = None; } let replaced_item = self.items.insert(item_id, item); debug_assert!(replaced_item.is_none()); let replaced_cell = self.ground_items.insert(item_id, cell); debug_assert!(replaced_cell.is_none()); Ok(ItemTransfer { item_id, cell }) } /// Read the sole authoritative working-run map/recipe/translation book. #[must_use] pub const fn knowledge(&self) -> &RunKnowledge { &self.knowledge } /// Learn one canonical mapped-region ID. Relearning is an idempotent no-op. pub fn learn_mapped_region(&mut self, id: impl Into) -> Result { self.knowledge.learn_mapped_region(id.into()) } /// Learn one canonical recipe ID. Recipe IDs remain distinct from item /// template IDs in the identification book. pub fn learn_recipe(&mut self, id: impl Into) -> Result { self.knowledge.learn_recipe(id.into()) } /// Learn one canonical translated-hymn ID. Content existence validation is /// deliberately a session/content concern; core validates only ID shape. pub fn learn_translation(&mut self, id: impl Into) -> Result { self.knowledge.learn_translation(id.into()) } /// Establish known item-template state for inheritance or paid identification /// without reducing a higher existing count. Production `idf` represents /// effective template knowledge as a count >= 2; this monotonic raise records /// knowledge, not physical use, and emits no use event or RNG draw. /// Returns the retained count after the raise. pub fn initialize_known_template( &mut self, template_id: impl Into, ) -> Result { let template_id = template_id.into(); validate_knowledge_id(KnowledgeCategory::IdentifiedTemplates, &template_id)?; let uses = self.idf.entry(template_id).or_insert(2); if *uses < 2 { *uses = 2; } Ok(*uses) } /// Whether this concrete item is effectively identified by either its own /// flag or the run's per-template use-count book. #[must_use] pub fn is_effectively_identified(&self, instance: &Instance) -> bool { instance.identified || self .idf .get(&instance.template) .is_some_and(|uses| *uses >= 2) } /// Canonically ordered known item-template rows for typed lineage /// extraction. Tried rows (count 1) remain in `idf` but are omitted here. pub fn known_templates(&self) -> impl Iterator { self.idf .iter() .filter(|(_, uses)| **uses >= 2) .map(|(template, _)| template.as_str()) } #[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. 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.resolve_melee_exchange_with_retaliation( attacker_id, defender_id, damage, Some(retaliation_damage), defender_kill_objective, ) } /// Resolve a checked zero-tick exchange with an explicitly optional counter. /// `None` creates no return damage or cause. `Some` must be positive even /// when the outgoing strike kills the defender, who never retaliates. #[allow(clippy::too_many_lines)] pub fn resolve_melee_exchange_with_retaliation( &mut self, attacker_id: u64, defender_id: u64, damage: i32, retaliation_damage: Option, 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.is_some_and(|actual| actual <= 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 if let Some(actual) = retaliation_damage { (attacker.hp.saturating_sub(actual), Some(actual)) } else { (attacker.hp, None) }; 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()); 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!(Some(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); // Initial location setup is an explicit observation boundary (docs §5.1). self.refresh_observations(); 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); } // Explicit lifecycle boundary (docs §5.2): the departing location's // observation memory is cleared, then the arrival pass re-observes. let departing = LocationKey::from_run_location(&self.run_location); self.observations.clear_location(&departing); self.run_location = next; self.refresh_observations(); 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(()) } /// Immutable projection of the canonical player terrain-observation memory /// (docs/fluid §5). Rendering and the map read detached, bounded values from /// this borrow; no live-grid handle crosses the projection boundary. #[must_use] pub fn observations(&self) -> &ObservationMemory { &self.observations } /// Bounded, canonical-order rows of the observation memory, for persistence /// and checkpoint projection. #[must_use] pub fn observation_rows(&self) -> Vec { self.observations.rows() } /// Authoritative feet position of the live player actor, if any. fn player_feet(&self) -> Option { self.actors .iter() .find(|actor| { actor.alive && self .actor_identities .get(&actor.id) .is_some_and(|identity| identity.role == crate::runtime::ActorRole::Player) }) .map(|actor| actor.position) } /// Run the canonical omnidirectional observation pass from the live player /// actor's authoritative position and admit the reached boundary faces for /// the current location (docs/fluid §5.1). /// /// This is independent of render frame rate and window size: it samples at /// an admitted world-tick boundary and is not affected by camera frustum. /// Every produced record is stamped with the live [`State::branch_epoch`], /// so a discovery made after a Story rollback carries the restored branch's /// epoch and cannot alias an old branch's discovery at a reused tick /// (docs/fluid §7). /// /// Returns the number of newly observed faces. A capacity rejection leaves /// the memory unchanged (unknown/stale, never wrong); the caller decides /// whether that is fatal. pub fn observe_player(&mut self) -> Result { self.observe_player_with_epoch(self.branch_epoch) } /// [`State::observe_player`] with an explicit branch epoch. pub fn observe_player_with_epoch(&mut self, branch_epoch: u64) -> Result { let Some(feet) = self.player_feet() else { return Ok(0); }; let location = LocationKey::from_run_location(&self.run_location); if location.is_uninitialized() { return Ok(0); } let Ok(origin) = observer_origin(feet) else { return Ok(0); }; let reached = observe(&self.grid, origin, self.tick, branch_epoch); self.observations .admit(&location, &reached, ObservationLimits::canonical()) } /// Restamp every retained observation record with a new branch epoch /// (docs/fluid §7: rollback restamps so a stale descriptor, target, or /// observation cannot pass as current). Stage 2 calls this on resurrection. pub fn restamp_observation_epoch(&mut self, branch_epoch: u64) { self.observations.restamp_epoch(branch_epoch); } /// Clear the observation memory at an explicit lifecycle boundary. Arrival /// (§5.2: the cache is cleared or restamped only at its explicit lifecycle /// boundary) uses this before re-observing the new location. pub fn clear_observations(&mut self) { self.observations.clear_all(); } /// Run the canonical observation pass as a best-effort tick-boundary /// refresh, stamping the live branch epoch. A capacity rejection retains the /// prior memory rather than evicting remembered faces, so a saturated memory /// degrades to blank for the overflow faces and never to a wrong remembered /// value. fn refresh_observations(&mut self) { let _ = self.observe_player_with_epoch(self.branch_epoch); } /// 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> { self.knowledge .validate() .map_err(RuntimeInvariantError::Knowledge)?; for template_id in self.idf.keys() { validate_knowledge_id(KnowledgeCategory::IdentifiedTemplates, template_id) .map_err(RuntimeInvariantError::Knowledge)?; } 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); actor.body.bounds(actor.position).map_err(|_| RuntimeInvariantError::InvalidFixedGeometry { table: "actors", row: actor.id })?; 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 (id, sample) in &self.spill.desired_moves { if !self.actors.iter().any(|a| a.id == *id && a.alive) { return Err(RuntimeInvariantError::OrphanActorMetadata { table: "spill.desired_moves", actor_id: *id }); } if !sample.valid() { return Err(RuntimeInvariantError::InvalidFixedGeometry { table: "spill.desired_moves", row: *id }); } } for (id,cells) in &self.spill.crossed_cells { if !self.actors.iter().any(|a| a.id == *id) { return Err(RuntimeInvariantError::OrphanActorMetadata { table: "spill.crossed_cells", actor_id: *id }); } for &(x,y,z) in cells { if crate::spatial::FixedVec3::from_cell_feet([x,y,z]).is_err() { return Err(RuntimeInvariantError::InvalidFixedGeometry {table:"spill.crossed_cells",row:*id}); } } } // Fall distances are unsigned millimetres; no nonfinite/negative encoding exists. 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, }); } self.validate_pending().map_err(RuntimeInvariantError::Pending)?; self.validate_observations()?; Ok(()) } /// Validate the canonical observation memory: the bounded per-location and /// aggregate caps, cap consistency, and provenance (every retained record's /// location must be a well-formed live Town/Deep key, never the reserved /// uninitialized location). fn validate_observations(&self) -> Result<(), RuntimeInvariantError> { self.observations .validate(ObservationLimits::canonical()) .map_err(RuntimeInvariantError::Observation) } /// Validate the single pending-action slot against the live world. /// /// Enforces: `remaining <= total`; the operation starts at or before the /// current tick; every captured target id resolves to a live entity (no /// zero sentinels, no dangling ids); canonical ids are non-blank; and the /// Inn descriptor agrees with the slot's own countdown and the authored day. fn validate_pending(&self) -> Result<(), PendingError> { let Some(pending) = &self.pending else { return Ok(()); }; let total = pending.action.total_ticks(); if pending.remaining_ticks > total { return Err(PendingError::RemainingExceedsTotal { remaining: pending.remaining_ticks, total, }); } if pending.start_tick > self.tick { return Err(PendingError::StartTickInFuture { start_tick: pending.start_tick, tick: self.tick, }); } match &pending.action { PendingAction::Move { .. } | PendingAction::Wait | PendingAction::UseService { .. } => {} PendingAction::InspectBoard { town } => { if town.trim().is_empty() { return Err(PendingError::BlankIdentifier { table: "pending.inspect_board.town", }); } } PendingAction::AcceptContract { id } => { if id.trim().is_empty() { return Err(PendingError::BlankIdentifier { table: "pending.accept_contract.id", }); } } PendingAction::UseTransition { transition_id } => { if *transition_id == 0 { return Err(PendingError::ZeroTarget { table: "pending.transition_id", }); } } PendingAction::ResolveAttack { target_actor } => { if *target_actor == 0 { return Err(PendingError::ZeroTarget { table: "pending.target_actor", }); } if !self.actors.iter().any(|actor| actor.id == *target_actor) { return Err(PendingError::UnknownTarget { table: "pending.target_actor", id: *target_actor, }); } } PendingAction::Pickup { item_id } => { if *item_id == 0 { return Err(PendingError::ZeroTarget { table: "pending.item_id", }); } // Pickup targets a loose item on the ground. `self.items` holds // exactly the ground/loose instances (each mirrored in // `self.ground_items`); carried instances live in packs and are // not pickable. A carried id here is a dangling ground target. if !self.items.contains_key(item_id) { return Err(PendingError::UnknownTarget { table: "pending.item_id", id: *item_id, }); } } PendingAction::Drop { item_id } | PendingAction::Wield { item_id } | PendingAction::TinkerIdentify { item_id } => { if *item_id == 0 { return Err(PendingError::ZeroTarget { table: "pending.item_id", }); } // Drop/Wield/TinkerIdentify act on what the actor carries. // Carried instances live in `packs[actor].items`, never in // `self.items` (which holds only ground/loose instances), so // resolve against the packs. The descriptor captures no actor, // so any pack holding the id is the correct target class. if !self .packs .values() .any(|pack| pack.items.contains_key(item_id)) { return Err(PendingError::UnknownTarget { table: "pending.item_id", id: *item_id, }); } } PendingAction::TurnIn { contract_id } => { if contract_id.trim().is_empty() { return Err(PendingError::BlankIdentifier { table: "pending.turn_in.contract_id", }); } } PendingAction::Inn(inn) => { let authored = crate::services::sleep_ticks(); if inn.total_ticks != authored { return Err(PendingError::InnTotalNotAuthored { total: inn.total_ticks, authored, }); } if inn.remaining_ticks != pending.remaining_ticks { return Err(PendingError::InnRemainingMismatch { slot_remaining: pending.remaining_ticks, inn_remaining: inn.remaining_ticks, }); } } } Ok(()) } /// Ensure the 3×3×3 active chunk set around a chunk coordinate exists. pub fn activate_around(&mut self, cx: i32, cy: i32, cz: i32) { self.grid.activate(cx, cy, cz); } /// Fork the subsystem stream for one tick phase that needs randomness. pub fn fork_rng(&mut self, domain_tag: u64) -> Rng { self.rng_master.fork(domain_tag) } /// Push a display-facing hazard link, speaking its law line exactly once /// per run (docs/04 §4.10.5). Every occurrence is recorded for replays; /// the NetHack-voice `explain` line fires on first sight only. pub fn speak_law(&mut self, rule: &'static str) { let tick = self.tick; if self.laws.first_time(rule, tick) { self.causes .push(CauseLink::new(tick, rule, laws::explain(rule))); } } /// Active region around the lead actor (or origin when actorless). fn active_region(&self) -> ((i32, i32, i32), (i32, i32, i32)) { let (cx, cy, cz) = self .actors .first() .map(|a| { let [x, y, z] = a.position.cell(); (x, y, z) }) .unwrap_or((0, 0, 0)); let e = ACTIVE_HALF_EXTENT; ((cx - e, cy - e, cz - e), (cx + e, cy + e, cz + e)) } /// Derive the draft field: flow across every pressure edge from endpoint /// pressures (docs/04 §4.3, docs/03 §3.3.3). Index-aligned with /// `pressure_edges`. Hidden vents contribute zero until flour or smoke /// reveals them (Phase 2). Positive = `from` → `to`. pub fn drafts(&self) -> Vec { self.pressure_edges .iter() .map(|e| { let pf = self .pressure_nodes .iter() .find(|n| n.id == e.from) .map(|n| n.pressure) .unwrap_or(0.0); let pt = self .pressure_nodes .iter() .find(|n| n.id == e.to) .map(|n| n.pressure) .unwrap_or(0.0); e.flow(pf, pt) }) .collect() } /// Advance exactly one 100ms tick through all 12 phases in order. /// This IS the Shift-step primitive: call once per keypress in step mode. pub fn step(&mut self) { for phase in ORDER { self.run_phase(phase); } self.tick += 1; self.far.advance(); // Canonical observation boundary (docs §5.1): refresh the player's // omnidirectional terrain memory from the authoritative position at // this admitted tick, independent of render frame rate/window size. self.refresh_observations(); // 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.advance_unattended_tick_body(); } 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); } /// Advance exactly one unattended tick (docs/fluid §4.4). /// /// This is the single-tick primitive a caller chunks (the caller owns the /// `≤5`-per-iteration bound, [`crate::pacing::MAX_TICKS_PER_ITERATION`]). /// It advances ONLY the unattended whitelist: canonical NPC intent plus /// canonical NPC locomotion (desired moves drained through the real Move /// sweep — intent never writes positions), live-item and corpse clocks, and /// gossip. It never runs a normal hazard phase, so the player resting at the /// inn anchor is neither moved nor exposed. Preflight runs first, so an /// exhausted tick or FarField clock returns an error with no mutation. pub fn advance_unattended_tick(&mut self) -> Result<(), UnattendedAdvanceError> { self.preflight_unattended(1)?; self.advance_unattended_tick_body(); Ok(()) } fn advance_unattended_tick_body(&mut self) { self.phase_intent(); self.apply_desired_moves(); // Unattended scope has no hazard phase; do not retain stale swept exposure. self.spill.crossed_cells.clear(); 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(); } fn run_phase(&mut self, phase: Phase) { match phase { Phase::Intent => self.phase_intent(), Phase::Hymns => self.phase_hymns(), Phase::Move => self.phase_move(), Phase::Fluids => self.phase_fluids(), Phase::Gas => self.phase_gas(), Phase::Fire => self.phase_fire(), Phase::Signal => self.phase_signal(), Phase::Light => self.phase_light(), Phase::Sound => self.phase_sound(), Phase::Structure => self.phase_structure(), Phase::Rot => self.phase_rot(), Phase::Death => self.phase_death(), } } /// Intent phase (docs/03 §3.6, docs/04 §4.2): the sim runs without the /// player. Day phase from the FarField clock; per npc, stimulus SHOULD /// be pre-sorted by live Actor distance (ties stay deterministic in /// decide either way) — here the sounds queue drains loudest-first. /// WalkTo applies 1m steps (Move-path nudge shape); Work/Sleep are /// no-ops with NO practice hooks (NPCs don't grow); Flee/Investigate /// move at speed 2. fn phase_intent(&mut self) { use crate::npc::Intent; let tick = self.tick; let phase = day_phase(self.far.day_tick); // Stimulus: queued sound positions, loudest-first, pre-sorted by // live distance per npc below (decide tie-breaks deterministically). let stimulus: Vec<(i32, i32, i32)> = self.sounds.iter().map(|e| (e.x, e.y, e.z)).collect(); let ids: Vec = self.npcs.keys().copied().collect(); // Phase label for the log (dusk belongs to lamplighters). let _ = phase; for id in ids { let Some(npc) = self.npcs.get(&id) else { continue; }; // Pre-sort stimulus by live Actor distance (Manhattan from the // npc's actor pos; home-anchored fallback inside decide). let here = self .actors .iter() .find(|a| a.id == id) .map(|a| a.position.cell()) .unwrap_or([npc.home.0, npc.home.1, npc.home.2]); let mut sorted = stimulus.clone(); sorted.sort_by_key(|p| { (i64::from(p.0) - i64::from(here[0])).abs() + (i64::from(p.1) - i64::from(here[1])).abs() + (i64::from(p.2) - i64::from(here[2])).abs() }); let intent = crate::npc::decide(npc, tick, &sorted); match intent { Intent::WalkTo(t) | Intent::Investigate(t) | Intent::Flee(t) => { let speed = match intent { Intent::Flee(_) | Intent::Investigate(_) => 2000, _ => 1000, }; if let Some(a) = self.actors.iter().find(|a| a.id == id) { if let Ok(target) = crate::spatial::FixedVec3::from_cell_feet([t.0, t.1, t.2]) { let heading = crate::locomotion::heading_toward(a.position, target); self.spill.desired_moves.entry(id).or_insert(crate::locomotion::MovementSample { heading, speed_mm_s: speed, stop_at: Some(target) }); } } } Intent::Work | Intent::Sleep | Intent::Trade(_) => {} } } // Cantor trigger (docs/03 §3.8: the Choir sings your name after // floor 9 — diegetic difficulty warning, beloved). Any actor below // the Deep threshold (z < -90, the depth-9 line) hears it once per // run (law-gated). Depth-gate helper lives in worldgen::deep; // core must not dep worldgen (arch §4.1), so the z-threshold here // mirrors choir_territory(depth >= 9) — pinned by the EA gate test. // FIRST-SIGHT ONLY: the law registry gates the line; the per-tick // cause-link records every hearing for replays (docs/04 §4.10.5). let deep_enough = self.actors.iter().any(|a| a.position.millimetres()[2] < -90_000); if deep_enough { self.speak_law("sound travels"); if self.laws.first_seen("sound travels") == Some(tick) { self.causes.push(CauseLink::new( tick, "sound travels", "the Choir sings your name — turn back or descend forever", )); } } } /// Hymns phase (docs/03 §3.5): drain the cast queue. For each intent: /// conduct roll (Aether + card + copper + weather) → deterministic /// resolve off the COMBAT stream → Cast applies the verb outcome, /// Miscast maps via MiscastKind (KernelPanic spawns a cherub hook). /// Casts practice Hymns (success AND failure teach — NetHack law). /// Tenday bargain debts tick here too (collections come due). fn phase_hymns(&mut self) { let tick = self.tick; let weather = self.deep_weather; let intents = std::mem::take(&mut self.cast_queue); for (caster, template, target) in intents { let Some(case) = self.hymn_cases.get(&caster) else { continue; }; let Some(card) = case.cards.iter().find(|c| c.template == template) else { continue; }; let card = card.clone(); let aether = self .characters .get(&caster) .map(|c| crate::character::attr(c, crate::character::Attribute::Aether)) .unwrap_or(8); // Copper near the caster conducts hymns (docs/03 §3.5). let copper_near = self.copper_near_caster(caster); let chance = hymn::conduct_chance( card.conduct_base, &hymn::ConductMods { aether, card_worn: false, copper_near, deep_weather: weather, }, ); let mut rng = self.fork_rng(domains::COMBAT); let roll = (rng.below(100)) as u8; match hymn::resolve(roll, chance, &card.miscasts) { hymn::CastOut::Cast => { self.apply_verb(caster, &template, target); self.speak_law("heat rises"); if let Some(c) = self.characters.get_mut(&caster) { record_use(c, Skill::Hymns); } } hymn::CastOut::Miscast(outcome) => { let kind = casting::miscast_kind(&outcome); self.causes.push(CauseLink::new( tick, "wild surge", format!("the {template} misfires ({outcome})"), )); if matches!(kind, casting::MiscastKind::KernelPanic) { // Everyone groans: cherub hook via signal sources. let pos = self .actors .iter() .find(|a| a.id == caster) .map(|a| a.position.cell()) .unwrap_or([0, 0, 8]); self.signal_sources.push((pos[0], pos[1], pos[2])); self.causes.push(CauseLink::new( tick, "shock grounds", "a kernel-panicked cherub audits the air — everyone groans", )); } if let Some(c) = self.characters.get_mut(&caster) { record_use(c, Skill::Hymns); } } } } // Tenday bargain debts come due (collections encounters parent-side; // the kernel only speaks the law line once per run). let due_count: usize = self .bargains .values() .map(|ledger| crate::bargain::due(ledger, tick).len()) .sum(); for _ in 0..due_count { self.speak_law("waste nothing"); } } /// True when copper (wire, pipe, rail) sits near the caster. fn copper_near_caster(&self, caster: u64) -> bool { let Some(a) = self.actors.iter().find(|a| a.id == caster) else { return false; }; let [cx, cy, cz] = a.position.cell(); for dz in -2..=2 { for dy in -2..=2 { for dx in -2..=2 { let m = self.grid.get_world(cx + dx, cy + dy, cz + dz).material; if m == crate::voxel::material::COPPER || m == crate::voxel::material::BRASS { return true; } } } } false } /// Map a cast verb outcome onto sim mutations (docs/03 §3.5). /// Templates key by hymn id (`fiat_lux`); the verb table resolves via /// the canonical id→verb map (mirrors hymns.toml; new hymns extend it). fn apply_verb(&mut self, caster: u64, template: &str, target: [i32; 3]) { let tick = self.tick; let Some(verb) = casting::parse_verb(&template_to_verb(template)) else { return; }; let cpos = self .actors .iter() .find(|a| a.id == caster) .map(|a| a.position) .unwrap_or_else(|| crate::spatial::FixedVec3::from_cell_feet([0, 0, 8]).expect("constant position")); let Ok(fixed_target) = crate::spatial::FixedVec3::from_cell_feet(target) else { return; }; let ctx = CastCtx { caster_pos: cpos, target: fixed_target, mass_kg: 80.0, water_present: false, ward_present: false, }; match verb { casting::Verb::FiatLux => { let out = casting::fiat_lux(&ctx); self.lights.push(crate::light::LightSource { x: out.at.0, y: out.at.1, z: out.at.2, radius: out.radius, warm: out.warm, }); } casting::Verb::Aperi => { let out = casting::aperi(&ctx); if out.loud { // The Audit hears: loud sound + law line. self.sounds.push(crate::sound::SoundEvent { x: target[0], y: target[1], z: target[2], loudness: 24, timbre: 0, }); self.speak_law("sound travels"); } } casting::Verb::Pondus => { // Falling solved or caused: seed the odometer hook (up = // feather, down = stone). Direction from template naming is // parent-side; default down (stone) is the safe reading. let out = casting::pondus(&ctx, false); let _ = out; } casting::Verb::Sile => { let out = casting::sile(&ctx); let _ = out; // Silence bubble: swallow pending sounds near target. self.sounds.clear(); } casting::Verb::Fluctus => { let out = casting::fluctus(&ctx); let _ = out; } casting::Verb::Memoria => { let out = casting::memoria(&ctx); if out.drops_flammables { // The ink costs: drop flammable-carrying state is // inventory parent-side (Phase 9 action). self.causes.push(CauseLink::new( tick, "heat rises", "the recall ink takes all flammables as its fee", )); } } casting::Verb::CaroVale => { let out = casting::caro_vale(&ctx); let _ = out; self.causes.push(CauseLink::new( tick, "waste nothing", "the corpse chooses its one honest answer", )); } } } fn apply_desired_moves(&mut self) { use crate::{heading::MovementRemainder, locomotion_grid as policy}; let samples = std::mem::take(&mut self.spill.desired_moves); let starts: BTreeMap<_,_> = self.actors.iter().filter(|a| a.alive).filter_map(|a| policy::body_box(a.position,a.body).ok().map(|b| (a.id,b))).collect(); let grounded: BTreeMap<_,_> = self.actors.iter().map(|a| (a.id,policy::supported(&self.grid,a.position,a.body))).collect(); let mut accepted = starts.clone(); for _ in 0..crate::locomotion::SUBSTEPS_PER_100_MS { for (id,sample) in &samples { let Some(actor) = self.actors.iter_mut().find(|a| a.id == *id && a.alive) else { continue; }; if !sample.valid() { continue; } actor.heading = sample.heading; if matches!(actor.stance, crate::actor::Stance::Rope | crate::actor::Stance::Climb) { continue; } let Ok((xy,trial)) = actor.movement_remainder.project(sample.heading,sample.speed_mm_s) else { continue; }; let mut delta = [xy[0],xy[1],0]; let mut arrived=false; if let Some(target)=sample.stop_at { let a=actor.position.millimetres();let b=target.millimetres(); for axis in 0..2 { let remaining=b[axis]-a[axis]; delta[axis]=if remaining>=0 {delta[axis].clamp(0,remaining)} else {delta[axis].clamp(remaining,0)}; } arrived=(0..2).all(|axis|a[axis]+delta[axis]==b[axis]); } let occupied: Vec<_> = starts.iter().chain(accepted.iter()).filter(|(other,_)| **other != *id).map(|(_,b)| *b).collect(); match policy::substep(&self.grid,actor.position,actor.body,delta,&occupied,grounded[id]) { Ok((result,cells)) => { let before = actor.position.millimetres(); let after = result.position.millimetres(); let mut fractions = if arrived {[0;2]} else {trial.numerators()}; for axis in 0..2 { if after[axis]-before[axis] != delta[axis] { fractions[axis]=0; } } actor.movement_remainder = MovementRemainder::new(fractions).expect("trial remainder"); actor.position = result.position; if let Ok(b) = policy::body_box(actor.position,actor.body) { accepted.insert(*id,b); } self.spill.crossed_cells.entry(*id).or_default().extend(cells); } Err(_) => { actor.movement_remainder = MovementRemainder::ZERO; } } } } } fn phase_move(&mut self) { self.apply_desired_moves(); // Rope anchors on connectors snap before falls (rope docs order). // Snapshot connector loads first: apply_load mutates causes. let tick = self.tick; let anchor_loads: Vec<(u64, f32, crate::connector::ConnectorKind)> = self .connectors .iter() .map(|c| (c.id, c.load_kg, c.kind)) .collect(); for (id, load_kg, kind) in anchor_loads { if kind == crate::connector::ConnectorKind::RopeAnchor && load_kg > 0.0 { let connector = self.connectors.iter().find(|c| c.id == id).unwrap(); let mut rope = crate::rope::from_connector(connector); crate::rope::apply_load(&mut rope, load_kg, &mut self.causes, tick); if rope.cut { self.speak_law("the rope gives way"); } } } let tick = self.tick; let masses = BTreeMap::new(); // Seed climbers' Ropework before the descent: airborne actors on // ropes practice the craft (Phase 6 easy call site; swim/sneak/etc. // hook in at their own phases — see wiring plan §2). let rope_users: Vec = self .actors .iter() .filter(|a| a.alive && a.stance == crate::actor::Stance::Rope) .map(|a| a.id) .collect(); let before_fall: BTreeMap<_,_> = self.actors.iter().map(|a|(a.id,a.position)).collect(); gravity::step_falling_with_mass( &mut self.actors, &self.grid, &masses, &mut self.fall_odo, &mut self.causes, tick, ); for actor in &self.actors { let from=before_fall[&actor.id]; if from!=actor.position { if let Ok(cells)=crate::locomotion_grid::vertical_cells(from,actor.position,actor.body) { self.spill.crossed_cells.entry(actor.id).or_default().extend(cells); } } } for id in rope_users { if let Some(c) = self.characters.get_mut(&id) { record_use(c, Skill::Ropework); } } // Falls survived are Dungeoneering lessons: actors that descended // this tick (odometer non-empty) read shafts better next time. let descended: Vec = self.fall_odo.keys().copied().collect(); for id in descended { if let Some(c) = self.characters.get_mut(&id) { record_use(c, Skill::Dungeoneering); } } } fn phase_fluids(&mut self) { let (rmin, rmax) = self.active_region(); let tick = self.tick; let mut rng = self.fork_rng(domains::FLUIDS); let (_updated, _spill) = fluids::step_fluids(&mut self.grid, rmin, rmax, &mut rng, &mut self.causes, tick); // Fluids reports (updated, spillover); the spillover tail re-runs // implicitly next tick via the region scan (no explicit queue: the // region is re-derived every tick, so deferred cells are revisited). let _ = _spill; let _ = _updated; } fn phase_gas(&mut self) { // Build flow triples from edges + drafts + cell→volume map. The Gas // module works on packed cell pairs; the pressure graph works on node // ids — the join below maps node ids to representative cells. Full // volume-membership mapping lands with worldgen anchor rooms; until // then each edge maps to the cells adjacent to its endpoint nodes' // anchor positions (nodes carry no positions, so the parent keeps a // parallel anchor table — empty here means still-air buoyancy only). let tick = self.tick; let drafts = self.drafts(); let mut flows: Vec<(u64, u64, f32)> = self .pressure_edges .iter() .zip(drafts.iter()) .filter(|(e, _)| !e.hidden) .filter_map(|(e, &mag)| { let a = self.node_anchor(e.from)?; let b = self.node_anchor(e.to)?; Some((pack_cell(a.0, a.1, a.2), pack_cell(b.0, b.1, b.2), mag)) }) .collect(); flows.append(&mut self.spill.gas_flows); let (_moved, spillover) = air::advect_markers_capped(&mut self.grid, &flows, tick, &mut self.causes); if spillover > 0 { let keep = flows.len().saturating_sub(spillover); self.spill.gas_flows = flows[keep..].to_vec(); } } /// Anchor cell for a pressure node id. Nodes carry no positions (sound /// module contract); the parent keeps this parallel table. Empty until /// worldgen anchor rooms register volumes (Phase 10) — until then the /// Gas phase runs still-air buoyancy + re-queued spillover only. fn node_anchor(&self, _id: u64) -> Option<(i32, i32, i32)> { None } fn phase_fire(&mut self) { let crossed = std::mem::take(&mut self.spill.crossed_cells); for actor in self.actors.iter_mut().filter(|a| a.alive) { let [x,y,z] = actor.position.cell(); let burning = |cell: &(i32,i32,i32)| self.grid.get_world(cell.0,cell.1,cell.2).flags & crate::voxel::flags::BURNING != 0; if burning(&(x,y,z)) || crossed.get(&actor.id).is_some_and(|cells| cells.iter().any(burning)) { actor.hp = actor.hp.saturating_sub(1); self.causes.push(CauseLink::new(self.tick,"fire",format!("actor {} crosses flame (1 damage)",actor.id))); } } let (rmin, rmax) = self.active_region(); let tick = self.tick; let mut rng = self.fork_rng(domains::COMBAT); let (_updated, _spill) = fire::step_fire(&mut self.grid, rmin, rmax, &mut rng, &mut self.causes, tick); let _ = (_updated, _spill); // Fragile hymn-cards cook off in heat (docs/03 §3.3 law 5): any // carrier case holding a fragile card near burning cells auto-fires // it at the carrier's feet. Carrier heat = carrier cell BURNING. let hot_carriers: Vec = self .actors .iter() .filter(|a| { let [x, y, z] = a.position.cell(); let v = self.grid.get_world(x, y, z); v.flags & crate::voxel::flags::BURNING != 0 }) .map(|a| a.id) .collect(); for caster in hot_carriers { let fragile: Vec = self .hymn_cases .get(&caster) .map(|case| { case.cards .iter() .filter(|c| hymn::cooks_off(c, true)) .map(|c| c.template.clone()) .collect() }) .unwrap_or_default(); for template in fragile { let cell = self .actors .iter() .find(|a| a.id == caster) .map(|a| a.position.cell()) .unwrap_or([0, 0, 8]); self.cast_queue.push((caster, template, cell)); } } } fn phase_signal(&mut self) { let (rmin, rmax) = self.active_region(); let tick = self.tick; let sources = std::mem::take(&mut self.signal_sources); let (_energized, spillover) = signal::step_signal(&self.grid, &sources, rmin, rmax, &mut self.causes, tick); self.signal_sources = sources; self.spill.signal_pending = spillover; } /// Docs/04 §4.2 phase 8 `Light`: bake the 0–24 voxel field from the live /// source list (docs/03 §3.3.1). /// /// The `light` module's wiring contract is a dirty-only pass each tick plus a /// full pass after the world changes. The source list is owned by /// [`State::lights`] and the outer loop moves a carried lamp by overwriting /// it in place, so this phase detects its own structural change: when the live /// source set differs from the last baked set — a lamp appeared, moved, or was /// doused — it clears the departed lamps' *old* boxes (no stale illumination /// survives a doused or carried-away lamp, docs/03 §3.3.1) and runs a full /// pass. A deferred pass left over from an over-budget tick forces the same /// full re-bake, so deferred light is actually written rather than silently /// dropped (docs/04 §4.2). /// /// Without this, no cell is ever marked [`light::FLAG_LIGHT_DIRTY`] and the /// dirty-only pass has an empty dirty set, so the authoritative voxel field /// stays dark while sources exist — and the canonical observation pass, which /// requires positive light in the air cell off a hit face, records nothing. fn phase_light(&mut self) { let tick = self.tick; // A baked field always reads full brightness at a LOADED source's own // cell (the flood seeds the source unconditionally), so a source that // does not means the field is stale — the source arrived before its // chunk was loaded, or another phase clobbered the cell. Treat that as // a re-bake trigger so this phase is self-healing regardless of the // outer loop's light-registration and chunk-streaming order. let edge = crate::voxel::CHUNK as i32; let stale_source = self.lights.iter().any(|s| { self.grid .is_loaded(s.x.div_euclid(edge), s.y.div_euclid(edge), s.z.div_euclid(edge)) && self.grid.get_world(s.x, s.y, s.z).light != light::MAX_LIGHT }); let rebake = self.spill.light_pending || self.lights != self.light_baked || stale_source; if rebake { for old in &self.light_baked { if !self.lights.contains(old) { let r = old.radius.min(light::MAX_LIGHT_RADIUS) as i32; light::clear_light_box(&mut self.grid, old.x, old.y, old.z, r); } } } let (_updated, spillover) = light::propagate_light_capped(&mut self.grid, &self.lights, !rebake); self.spill.light_pending = spillover > 0; self.light_baked = self.lights.clone(); // First-sight law line for entering lit strangeness is renderer-side; // the kernel records the tick so replays keep it. let _ = tick; } fn phase_sound(&mut self) { // Drain the queue; nodes[0] is the source room by contract (nodes // carry no positions — the parent orders them source-first). let events = std::mem::take(&mut self.sounds); for ev in &events { let heard = sound::propagate_sound(&self.grid, &self.pressure_edges, &self.pressure_nodes, ev); // AI investigate = argmax excluding the source room (source is // tied-for-loudest by construction; see sound docs MINOR-5). let _ = heard; } } fn phase_structure(&mut self) { // Tight dirty bounds: callers pass the active region but the module // clamps to its 64-span bound internally (deterministic truncation). let (rmin, rmax) = self.active_region(); let tick = self.tick; structure::step_structure(&mut self.grid, rmin, rmax, &mut self.causes, tick); } fn phase_rot(&mut self) { let tick = self.tick; let mut rng = self.fork_rng(domains::GOSSIP); let _spawned = rot::step_rot( &mut self.grid, &mut self.corpses, &mut rng, &mut self.causes, tick, ); // Burst positions feed the content-owned corpse-spawn table (Phase // 9/10); until then they resolve as stain + gas + link only. 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(|packet| { if crate::schedule::gossip_arrived(packet.sent_day, day) { arrived.push(packet.clone()); false } else { true } }); for packet in arrived { for reputation in self.reputations.values_mut() { reputation.adjust_ledger(&packet.to, packet.ledger_delta); } self.causes.push(CauseLink::new( self.tick, "sound travels", format!( "word travels {}→{}: the ledger remembers", packet.from, packet.to ), )); } } fn phase_death(&mut self) { // Snapshot the newly dead first in authoritative actor order. let fresh_deaths: Vec = self .actors .iter() .filter(|actor| actor.alive && actor.hp <= 0) .map(|actor| actor.id) .collect(); let prepared = self .prepare_deaths(&fresh_deaths) .expect("validated phase-death allocation budget exhausted"); self.causes .try_reserve(prepared.deaths.len()) .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(), 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.position, corpse: Instance::corpse(corpse_id, [0.0; 3]), contracts_to_fail, death_detail: format!("actor {actor_id} fell still"), }); } Ok(PreparedDeathBatch { deaths, next_id }) } fn commit_deaths(&mut self, 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); } } 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"); } } } self.next_id = prepared.next_id; for death in prepared.deaths { let corpse_id = death.corpse.id; self.ground_items.insert( corpse_id, { let [x, y, z] = death.position.cell(); (x, y, z) }, ); self.items.insert(corpse_id, death.corpse); for character in self.characters.values_mut() { record_use(character, Skill::Search); } 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 { reputation.adjust_ledger("milthollow", -10); if let Some((_, standing)) = reputation .factions .iter_mut() .find(|(faction, _)| *faction == crate::reputation::Faction::Audit) { standing.debt_cu = standing.debt_cu.saturating_add(50); } } } } } } /// 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, branch_epoch: self.branch_epoch, 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(), knowledge: self.knowledge.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, pending: self.pending.iter().cloned().collect(), observations: self.observations.rows(), }) } /// Restore an exact image without gameplay transitions or per-voxel writes. pub fn from_image(image: StateImage) -> Result { let StateImage { tick, seed, branch_epoch, 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, knowledge, idf, hymn_cases, cast_queue, bargains, deep_weather, npcs, shops, gossip, chalk, mule, vault_choice, chapel_debts, next_id, step_mode, pending: pending_rows, observations: observation_rows, } = 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 pending = match pending_rows.len() { 0 => None, 1 => Some( pending_rows .into_iter() .next() .expect("length checked to be exactly one"), ), count => { return Err(StateImageError::Runtime(RuntimeInvariantError::Pending( PendingError::MultipleSlots { count }, ))) } }; let observations = ObservationMemory::from_rows(observation_rows, ObservationLimits::canonical()) .map_err(StateImageError::Observation)?; let state = Self { tick, seed, branch_epoch, 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, knowledge, idf, hymn_cases, cast_queue, bargains, deep_weather, npcs, shops, gossip, chalk, mule, vault_choice, chapel_debts, next_id, step_mode, pending, observations, // Rebuild the non-canonical light-bake memo empty: the image carries // voxel light and the lamp list, not this cache, so the first light // phase after restore re-bakes the same field a live run holds. light_baked: Vec::new(), }; 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.position.millimetres() { h.u64(value as u64); } for value in actor.body.millimetres() { h.u64(value as u64); } h.option_u64(actor.heading.map(|h| u64::from(h.bin()))); for n in actor.movement_remainder.numerators() { h.u64(n as u64); } 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(*distance); } 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.u64(self.spill.desired_moves.len() as u64); for (id, sample) in &self.spill.desired_moves { h.u64(*id); h.option_u64(sample.heading.map(|v| u64::from(v.bin()))); h.u64(u64::from(sample.speed_mm_s)); h.bool(sample.stop_at.is_some()); if let Some(p)=sample.stop_at {for n in p.millimetres() {h.u64(n as u64);}} } h.u64(self.spill.crossed_cells.len() as u64); for (id,cells) in &self.spill.crossed_cells { h.u64(*id); h.u64(cells.len() as u64); for cell in cells { h.cell(*cell); } } 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); } } } 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); } // v4 append-only domain: one outer row per named category, then the // explicit category tag and its canonically ordered, length-framed IDs. h.domain(RUN_KNOWLEDGE_HASH_DOMAIN, 3); for (category_tag, ids) in [ (0, &self.knowledge.mapped_regions), (1, &self.knowledge.recipes), (2, &self.knowledge.translated_hymns), ] { h.u64(category_tag); h.u64(ids.len() as u64); for id in ids { h.text(id); } } // v5 append-only domain: the single pending-action slot. Presence is // framed explicitly so an empty slot and a present slot cannot collide. h.domain(PENDING_HASH_DOMAIN, usize::from(self.pending.is_some())); if let Some(pending) = &self.pending { h.u64(pending.start_tick); h.u64(pending.remaining_ticks); h.u64(u64::from(pending.action.tag() as u8)); match &pending.action { PendingAction::Move { direction } => { h.u64(u64::from(direction.canonical_tag())); } PendingAction::Wait => {} PendingAction::InspectBoard { town } => h.text(town), PendingAction::AcceptContract { id } => h.text(id), PendingAction::UseTransition { transition_id } => h.u64(*transition_id), PendingAction::ResolveAttack { target_actor } => h.u64(*target_actor), PendingAction::Pickup { item_id } | PendingAction::Drop { item_id } | PendingAction::Wield { item_id } | PendingAction::TinkerIdentify { item_id } => h.u64(*item_id), PendingAction::TurnIn { contract_id } => h.text(contract_id), PendingAction::UseService { service } => { h.u64(u64::from(service.canonical_tag())); } PendingAction::Inn(inn) => { h.u64(inn.total_ticks); h.u64(inn.remaining_ticks); } } } // v5 append-only domain: the canonical player terrain-observation // memory (docs/fluid §5). One outer row per location, then one row per // reached boundary face. Record contents AND observation ticks are both // hashed so a stale memory and a refreshed one cannot collide. h.domain(OBSERVATION_HASH_DOMAIN, self.observations.len()); for (location, map) in self.observations.iter() { h.u64(u64::from(location.kind)); h.text(&location.town); h.u64(u64::from(location.depth)); h.u64(map.len() as u64); for (key, record) in map { h.cell(key.cell); h.u64(u64::from(key.face)); h.u64(u64::from(record.material)); h.u64(u64::from(record.occupancy)); h.u64(u64::from(record.door_appearance)); h.u64(record.observed_tick); h.u64(record.branch_epoch); } } // v5 append-only domain: the durable Story branch epoch (docs/fluid §7). // One framing row so a rolled-back branch and its restored successor // cannot collide even when every other field agrees. h.domain(BRANCH_EPOCH_HASH_DOMAIN, 1); h.u64(self.branch_epoch); 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() } } #[cfg(test)] mod tests { use super::*; #[test] fn known_template_raise_is_monotonic() { for initial in [None, Some(0), Some(1), Some(2), Some(3), Some(255)] { let mut state = State::new(42); state.idf.insert("unrelated".into(), 1); if let Some(count) = initial { state.idf.insert("hymn_card_blank".into(), count); } let before_hash = state.try_hash_state().unwrap(); let before_idf = state.idf.clone(); let before_rng = state.rng_master.canonical_state(); let before_tick = state.tick; let expected = initial.unwrap_or(0).max(2); assert_eq!( state.initialize_known_template("hymn_card_blank").unwrap(), expected ); assert_eq!( state.initialize_known_template("hymn_card_blank").unwrap(), expected ); assert_eq!(state.idf["unrelated"], 1); assert_eq!( state.known_templates().collect::>(), ["hymn_card_blank"] ); assert_eq!(state.rng_master.canonical_state(), before_rng); assert_eq!(state.tick, before_tick); // Restore only IDF: the canonical hash must show no other mutation. state.idf = before_idf; assert_eq!(state.try_hash_state().unwrap(), before_hash); } } #[test] fn same_seed_same_hash() { let mut a = State::new(1234); let mut b = State::new(1234); for _ in 0..100 { a.step(); b.step(); } assert_eq!(a.hash_state(), b.hash_state()); } #[test] fn different_seeds_diverge() { let a = State::new(1); let b = State::new(2); assert_ne!(a.hash_state(), b.hash_state()); } #[test] fn death_records_cause() { let mut s = State::new(7); let id = s.spawn_actor(crate::spatial::FixedVec3::from_cell_feet([0, 0, 0]).unwrap()); s.actors.iter_mut().find(|a| a.id == id).unwrap().hp = 0; s.step(); 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( crate::spatial::FixedVec3::from_cell_feet([1, 2, 3]).unwrap(), ActorIdentity::new("delver", "Ada", crate::runtime::ActorRole::Player), ); let defender = state.spawn_actor_with_identity( crate::spatial::FixedVec3::from_cell_feet([2, 2, 3]).unwrap(), 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_optional_retaliation_is_explicit_and_atomic() { for counter in [None, Some(3)] { for defender_hp in [3, 6] { let (mut state, attacker, defender) = melee_pair(10, defender_hp); let causes = state.causes.len(); let result = state .resolve_melee_exchange_with_retaliation(attacker, defender, 3, counter, None) .unwrap(); let expected = if defender_hp == 3 { None } else { counter }; assert_eq!(result.retaliation_damage, expected); assert_eq!(result.defender_killed, defender_hp == 3); assert_eq!(state.actors[0].hp, 10 - expected.unwrap_or(0)); assert_eq!(state.actors[1].hp, defender_hp - 3); assert_eq!(state.tick, 0); assert_eq!( state.causes.len() - causes, 1 + usize::from(expected.is_some()) + usize::from(result.defender_killed) ); assert_eq!( state .causes .links() .iter() .filter(|cause| cause.detail.contains("retaliates")) .count(), usize::from(expected.is_some()) ); } } for counter in [Some(0), Some(-1)] { for defender_hp in [1, 6] { let (mut state, attacker, defender) = melee_pair(10, defender_hp); let hash = state.try_hash_state().unwrap(); let causes = state.causes.clone(); assert_eq!( state.resolve_melee_exchange_with_retaliation( attacker, defender, 3, counter, None ), Err(MeleeError::NonPositiveDamage { strike: MeleeStrike::Retaliation }) ); assert_eq!(state.try_hash_state().unwrap(), hash); assert_eq!(state.causes.links(), causes.links()); } } for counter in [None, Some(3)] { let (mut state, attacker, defender) = melee_pair(10, 1); let hash = state.try_hash_state().unwrap(); let causes = state.causes.clone(); assert_eq!( state.resolve_melee_exchange_with_retaliation( attacker, defender, 3, counter, Some("missing") ), Err(MeleeError::InvalidObjective(ContractError::NotFound)) ); assert_eq!(state.try_hash_state().unwrap(), hash); assert_eq!(state.causes.links(), causes.links()); let mut image = state.to_image().unwrap(); image.next_id = u64::MAX - 1; let mut exhausted = State::from_image(image).unwrap(); let hash = exhausted.try_hash_state().unwrap(); assert_eq!( exhausted .resolve_melee_exchange_with_retaliation(attacker, defender, 3, counter, None), Err(MeleeError::IdExhausted) ); assert_eq!(exhausted.try_hash_state().unwrap(), hash); assert_eq!(exhausted.causes.links(), causes.links()); assert_eq!(exhausted.next_id, u64::MAX - 1); assert_eq!(exhausted.actors[1].hp, 1); assert!(exhausted.items.is_empty()); } } #[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(crate::spatial::FixedVec3::from_cell_feet([8, 8, 8]).unwrap()); 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_preserve_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.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.idf.get("known_relic"), Some(&2)); 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(crate::spatial::FixedVec3::from_cell_feet([8, 8, 8]).unwrap()); 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); s.step_mode = true; s.step(); assert_eq!(s.tick, 1); s.step(); assert_eq!(s.tick, 2); } #[test] fn activate_loads_27_chunks() { let mut s = State::new(11); s.activate_around(0, 0, 0); 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( crate::spatial::FixedVec3::from_cell_feet([0, 0, 0]).unwrap(), 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( crate::spatial::FixedVec3::from_cell_feet([0, 0, 0]).unwrap(), 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( crate::spatial::FixedVec3::from_cell_feet([9, 0, 0]).unwrap(), 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].position.millimetres(), [500, 500, 0]); } #[test] fn far_field_clock_advances() { // Rollover logic is unit-tested directly (fast); the step-based check // below only asserts the kernel drives the clock (600 steps keeps // debug test times sane now that ticks run all 12 wired phases). let mut f = FarField::default(); for _ in 0..TICKS_PER_DAY { f.advance(); } assert_eq!(f.day, 1); assert_eq!(f.day_tick, 0); let mut s = State::new(13); for _ in 0..600 { s.step(); } assert_eq!(s.far.day, 0); assert_eq!(s.far.day_tick, 600); } #[test] fn drafts_flow_downhill_only() { use crate::pressure::{PressureEdge, PressureNode}; let mut s = State::new(21); s.pressure_nodes.push(PressureNode { id: 1, pressure: 2.0, }); s.pressure_nodes.push(PressureNode { id: 2, pressure: 0.5, }); s.pressure_edges.push(PressureEdge { from: 1, to: 2, openness: 0.5, hidden: false, }); s.pressure_edges.push(PressureEdge { from: 1, to: 2, openness: 99.0, hidden: true, }); let d = s.drafts(); assert!((d[0] - 0.75).abs() < 1e-6); assert_eq!(d[1], 0.0); } #[test] fn speak_law_fires_once() { let mut s = State::new(31); s.speak_law("rooms breathe"); s.speak_law("rooms breathe"); assert_eq!(s.laws.len(), 1); // First sight speaks the NetHack-voice line; replay keeps both. assert_eq!(s.causes.len(), 1); assert!(s.causes.links()[0].detail.contains("SUCKED")); } #[test] fn full_tick_mutates_and_hashes() { let mut s = State::new(41); s.activate_around(0, 0, 0); s.spawn_actor(crate::spatial::FixedVec3::from_cell_feet([4, 4, 8]).unwrap()); s.lights.push(LightSource { x: 4, y: 4, z: 8, radius: 6, warm: true, }); let h0 = s.hash_state(); s.step(); assert_eq!(s.tick, 1); // Gravity settled the actor; light wrote cells: hash must move. assert_ne!(s.hash_state(), h0); // Determinism: replay the same tick from scratch. let mut t = State::new(41); t.activate_around(0, 0, 0); t.spawn_actor(crate::spatial::FixedVec3::from_cell_feet([4, 4, 8]).unwrap()); t.lights.push(LightSource { x: 4, y: 4, z: 8, radius: 6, warm: true, }); t.step(); assert_eq!(s.hash_state(), t.hash_state()); } #[test] fn light_phase_bakes_initial_sources_and_moves_without_a_trail() { let mut s = State::new(43); s.activate_around(0, 0, 0); s.spawn_actor(crate::spatial::FixedVec3::from_cell_feet([4, 4, 8]).unwrap()); s.lights.push(LightSource { x: 4, y: 4, z: 8, radius: 6, warm: true, }); // A registered source on an unstepped world ships a dark field: the // dirty-only pass has an empty dirty set until the phase bakes. assert_eq!(s.grid.get_world(5, 4, 8).light, 0); s.step(); assert!( s.grid.get_world(5, 4, 8).light > 0, "the Light phase must bake the registered source on the first tick" ); // The carried lamp moves: the departed box is cleared, so the lamp // leaves no stale illumination behind (docs/03 §3.3.1). s.lights[0] = LightSource { x: 24, y: 4, z: 8, radius: 6, warm: true, }; s.step(); assert_eq!( s.grid.get_world(5, 4, 8).light, 0, "a moved lamp must leave no light trail" ); assert!(s.grid.get_world(25, 4, 8).light > 0); // Dousing the lamp (removing the source) darkens the box. s.lights.clear(); s.step(); assert_eq!(s.grid.get_world(25, 4, 8).light, 0); } }