//! Owned, plain-data checkpoint image for the deterministic kernel. //! This is an in-memory boundary, not a serialized wire format. use std::collections::BTreeMap; use crate::actor::Actor; use crate::bargain::Ledger; use crate::character::Character; use crate::connector::Connector; use crate::dungeon::{Chalk, MulePath, VaultChoice}; use crate::hymn::HymnCase; use crate::inventory::Pack; use crate::item::Instance; use crate::light::LightSource; use crate::modes::ChapelDebt; use crate::npc::{ShopMemory, Townsfolk}; use crate::observation::{ObservationError, ObservationRow}; use crate::pending::Pending; use crate::pressure::{PressureEdge, PressureNode}; use crate::reputation::Reputation; use crate::rot::CorpseClock; use crate::runtime::{ ActiveContract, ActorIdentity, RunKnowledge, RunLocation, RuntimeInvariantError, Wallet, }; use crate::sound::SoundEvent; use crate::state::{FarField, GossipPacket, Spill}; use crate::voxel::{GridLoadError, Voxel}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ChunkImage { pub coord: (i32, i32, i32), pub voxels: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CauseImage { pub tick: u64, pub rule: String, pub detail: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct LawImage { pub rule: String, pub first_seen_tick: u64, } /// Exact projection of every future-affecting `State` field. #[derive(Clone, Debug)] pub struct StateImage { pub tick: u64, pub seed: u64, /// Durable Story branch epoch (docs/fluid §7). `0` for a fresh run; a Story /// resurrection increments it. Carried in the image so the epoch survives a /// save/reload and replay reproduces the same fold. pub branch_epoch: u64, pub rng_master: [u64; 4], pub ecs_entity_count: u32, pub chunks: Vec, pub actors: Vec, pub actor_identities: BTreeMap, pub wallets: BTreeMap, pub contracts: BTreeMap, pub run_location: RunLocation, pub connectors: Vec, pub pressure_nodes: Vec, pub pressure_edges: Vec, pub causes: Vec, pub far: FarField, pub laws: Vec, pub fall_odo: BTreeMap, pub lights: Vec, pub sounds: Vec, pub corpses: BTreeMap<(i32, i32, i32), CorpseClock>, pub signal_sources: Vec<(i32, i32, i32)>, pub spill: Spill, pub characters: BTreeMap, pub reputations: BTreeMap, pub items: BTreeMap, pub ground_items: BTreeMap, pub packs: BTreeMap, pub knowledge: RunKnowledge, pub idf: BTreeMap, pub hymn_cases: BTreeMap, pub cast_queue: Vec<(u64, String, [i32; 3])>, pub bargains: BTreeMap, pub deep_weather: i8, pub npcs: BTreeMap, pub shops: BTreeMap<(i32, i32, i32), ShopMemory>, pub gossip: Vec, pub chalk: Chalk, pub mule: Option, pub vault_choice: Option, pub chapel_debts: BTreeMap, pub next_id: u64, pub step_mode: bool, /// The pending-action slot as a 0-or-1 element vector so a malformed image /// carrying a *second* concurrent slot can be rejected rather than silently /// truncated (docs/fluid §4.3: exactly one pending action at a time). pub pending: Vec, /// Canonical player terrain-observation memory as a bounded, canonical-order /// row vector (docs/fluid §5). A malformed image with duplicate or reversed /// rows is rejected rather than silently coalesced. pub observations: Vec, } #[derive(Clone, Debug, PartialEq)] pub enum StateImageError { NonEmptyEcs { entities: u32 }, InvalidRng, Grid(GridLoadError), UnknownRule { rule: String }, DuplicateLaw { rule: String }, NonCanonicalLawOrder { previous: String, current: String }, Runtime(RuntimeInvariantError), /// A malformed observation row vector (duplicate/reversed or over-cap). Observation(ObservationError), } #[cfg(test)] mod tests { use super::*; use crate::connector::ConnectorKind; use crate::reputation::{Faction, GodId, Reputation}; use crate::runtime::{ActorIdentity, ActorRole, RunLocation}; use crate::state::{State, TICKS_PER_DAY}; use crate::voxel::{material, CHUNK_VOL}; #[test] fn fixed_body_bounds_and_staged_moves_rejected() { use crate::spatial::{FixedVec3, MAX_POSITION_MM}; let mut image = valid_image(); image.actors[0].position = FixedVec3::new([MAX_POSITION_MM, 500, 0]).unwrap(); assert!(State::from_image(image).is_err()); use crate::locomotion::MovementSample; for (id, speed_mm_s) in [(0, 3000), (999, 3000), (1, 2001), (1, u32::MAX)] { let mut image = valid_image(); image.spill.desired_moves.insert(id, MovementSample { heading: None, speed_mm_s, stop_at: None }); assert!(State::from_image(image).is_err()); } let mut image = valid_image(); image.actors[0].alive = false; image.spill.desired_moves.insert(1, MovementSample::player(None)); assert!(State::from_image(image).is_err()); let mut image = valid_image(); image.actors[0].position = FixedVec3::new([MAX_POSITION_MM - 300, 500, 0]).unwrap(); image.spill.desired_moves.insert(1, MovementSample::player(Some(crate::heading::Heading::EAST))); assert!(State::from_image(image).is_ok(), "blocked samples remain valid committed time"); } fn valid_image() -> StateImage { let mut state = State::new(9); state.spawn_actor_with_identity( crate::spatial::FixedVec3::from_cell_feet([0; 3]).unwrap(), ActorIdentity::new("delver", "Ada", ActorRole::Player), ); state.initialize_town("milthollow").unwrap(); state.to_image().unwrap() } fn image_with_stone_floor() -> StateImage { let mut image = valid_image(); let mut voxels = vec![Voxel::default(); CHUNK_VOL]; let index = 47 * 48 * 48; voxels[index].material = material::STONE; image.chunks.push(ChunkImage { coord: (0, 0, -1), voxels, }); image } fn connector() -> Connector { Connector { id: 17, kind: ConnectorKind::Shaft, from: [0.0, 0.0, -3.0], to: [0.0, 0.0, 0.0], length: 3.0, anchor_quality: 80, load_kg: 0.0, blocked: false, } } fn rejects_nonfinite( mut mutate: impl FnMut(&mut StateImage, f32), table: &'static str, row: u64, field: &'static str, ) { for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { let mut image = valid_image(); mutate(&mut image, value); assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::NonFiniteField { table, row, field, }) ); } } fn rejects_range( mut mutate: impl FnMut(&mut StateImage), table: &'static str, row: u64, field: &'static str, ) { let mut image = valid_image(); mutate(&mut image); assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::InvalidFloatRange { table, row, field, }) ); } #[test] fn knowledge_roundtrips_and_continues_deterministically() { let mut state = State::new(9); state.learn_mapped_region("town:milthollow").unwrap(); state.learn_recipe("brine-poultice").unwrap(); state.learn_translation("fiat_lux").unwrap(); state.idf.insert("tried_relic".into(), 1); state.initialize_known_template("lamp_oil").unwrap(); let image = state.to_image().unwrap(); assert_eq!(image.knowledge, *state.knowledge()); let mut restored = State::from_image(image).unwrap(); assert_eq!(restored.knowledge(), state.knowledge()); assert_eq!(restored.idf, state.idf); assert_eq!(restored.try_hash_state(), state.try_hash_state()); state.step(); restored.step(); assert_eq!(restored.try_hash_state(), state.try_hash_state()); } #[test] fn malformed_knowledge_and_idf_ids_are_rejected_from_images() { use crate::runtime::{KnowledgeCategory, KnowledgeError}; let mut image = valid_image(); image.knowledge.recipes.insert("bad/id".into()); assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::Knowledge( KnowledgeError::InvalidId { category: KnowledgeCategory::Recipes, id: "bad/id".into(), } )) ); let mut image = valid_image(); image.idf.insert("Bad Template".into(), 2); assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::Knowledge( KnowledgeError::InvalidId { category: KnowledgeCategory::IdentifiedTemplates, id: "Bad Template".into(), } )) ); } #[test] fn oversized_knowledge_image_is_rejected() { use crate::runtime::{KnowledgeCategory, KnowledgeError, MAPPED_REGION_LIMIT}; let mut image = valid_image(); image .knowledge .mapped_regions .extend((0..=MAPPED_REGION_LIMIT).map(|index| format!("region:{index}"))); assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::Knowledge( KnowledgeError::FieldLimit { category: KnowledgeCategory::MappedRegions, limit: MAPPED_REGION_LIMIT, } )) ); } #[test] fn malformed_image_markers_and_rng_are_rejected() { let mut image = valid_image(); image.ecs_entity_count = 1; assert_eq!( State::from_image(image).unwrap_err(), StateImageError::NonEmptyEcs { entities: 1 } ); let mut image = valid_image(); image.rng_master = [0; 4]; assert_eq!( State::from_image(image).unwrap_err(), StateImageError::InvalidRng ); } #[test] fn malformed_chunks_are_rejected_without_voxel_writes() { let mut image = valid_image(); image.chunks.push(ChunkImage { coord: (0, 0, 0), voxels: Vec::new(), }); assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Grid(GridLoadError::ChunkLength { actual: 0 }) ); let mut image = valid_image(); image.chunks = vec![ ChunkImage { coord: (0, 0, 0), voxels: vec![Voxel::default(); CHUNK_VOL], }, ChunkImage { coord: (0, 0, 0), voxels: vec![Voxel::default(); CHUNK_VOL], }, ]; assert!(matches!( State::from_image(image), Err(StateImageError::Grid( GridLoadError::DuplicateCoordinate { .. } )) )); let mut image = valid_image(); image.chunks = vec![ ChunkImage { coord: (1, 0, 0), voxels: vec![Voxel::default(); CHUNK_VOL], }, ChunkImage { coord: (0, 0, 0), voxels: vec![Voxel::default(); CHUNK_VOL], }, ]; assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Grid(GridLoadError::NonCanonicalOrder { previous: (1, 0, 0), current: (0, 0, 0), }) ); } #[test] fn unknown_and_noncanonical_static_rules_are_rejected() { let mut image = valid_image(); image.causes.push(CauseImage { tick: 1, rule: "invented".into(), detail: "bad".into(), }); assert_eq!( State::from_image(image).unwrap_err(), StateImageError::UnknownRule { rule: "invented".into() } ); let mut image = valid_image(); image.laws = vec![ LawImage { rule: "gravity".into(), first_seen_tick: 1, }, LawImage { rule: "gravity".into(), first_seen_tick: 2, }, ]; assert_eq!( State::from_image(image).unwrap_err(), StateImageError::DuplicateLaw { rule: "gravity".into() } ); let mut image = valid_image(); image.laws = vec![ LawImage { rule: "gravity".into(), first_seen_tick: 1, }, LawImage { rule: "death".into(), first_seen_tick: 2, }, ]; assert_eq!( State::from_image(image).unwrap_err(), StateImageError::NonCanonicalLawOrder { previous: "gravity".into(), current: "death".into(), } ); } #[test] fn reputation_arrays_require_canonical_tags_and_order() { let mut image = valid_image(); let actor = image.actors[0].id; image.reputations.insert(actor, Reputation::new()); assert!(State::from_image(image.clone()).is_ok()); let cases = [ ( "factions", 1, Faction::Lampwrights.canonical_tag(), true, false, ), ( "factions", 0, Faction::Congregation.canonical_tag(), true, true, ), ( "god_credit", 1, GodId::Lamplighter.canonical_tag(), false, false, ), ( "god_credit", 0, GodId::Tollkeeper.canonical_tag(), false, true, ), ]; for (table, index, found, faction_table, reordered) in cases { let mut malformed = image.clone(); let reputation = malformed.reputations.get_mut(&actor).unwrap(); if faction_table { if reordered { reputation.factions.swap(0, 1); } else { reputation.factions[1].0 = Faction::Lampwrights; } } else if reordered { reputation.god_credit.swap(0, 1); } else { reputation.god_credit[1].0 = GodId::Lamplighter; } assert_eq!( State::from_image(malformed).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::NonCanonicalReputationArray { actor_id: actor, table, index, expected: index as u8, found, }) ); } } #[test] fn runtime_corruption_is_rejected() { let mut image = valid_image(); image.next_id = 1; assert!(matches!( State::from_image(image), Err(StateImageError::Runtime( RuntimeInvariantError::NextIdNotAboveLiveId { .. } )) )); let mut image = valid_image(); image.run_location = RunLocation::Town(" ".into()); assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::InvalidRunLocation) ); let mut image = valid_image(); image.actors[0].id = 0; assert!(matches!( State::from_image(image), Err(StateImageError::Runtime( RuntimeInvariantError::ZeroStableId { .. } )) )); let mut image = valid_image(); let actor = image.actors[0].id; image.contracts.insert( "bad".into(), ActiveContract { id: "different".into(), taker: actor, accepted_tick: 0, floor: 1, pay_cu: 1, objective_target: 1, objective_progress: 0, status: crate::runtime::ContractStatus::Active, reward_town: "milthollow".into(), standing_delta: 1, }, ); assert!(matches!( State::from_image(image), Err(StateImageError::Runtime( RuntimeInvariantError::ContractKeyMismatch { .. } )) )); let mut image = valid_image(); image.ground_items.insert(999, (0, 0, 0)); assert!(matches!( State::from_image(image), Err(StateImageError::Runtime( RuntimeInvariantError::DanglingGroundItem { item_id: 999 } )) )); let mut image = valid_image(); let actor = image.actors[0].id; image.chapel_debts.insert( actor, ChapelDebt { burials_owed: 0, chapel: crate::modes::LAMPLIGHTER_CHAPEL.into(), }, ); assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::ZeroChapelDebt { actor_id: actor }) ); } #[test] fn every_authoritative_float_category_rejects_all_nonfinite_values() { // Actor geometry is validated integer data, with no nonfinite encoding. for (index, field) in ["from[0]", "from[1]", "from[2]"].into_iter().enumerate() { rejects_nonfinite( |image, value| { let mut row = connector(); row.from[index] = value; image.connectors.push(row); }, "connectors", 17, field, ); } for (index, field) in ["to[0]", "to[1]", "to[2]"].into_iter().enumerate() { rejects_nonfinite( |image, value| { let mut row = connector(); row.to[index] = value; image.connectors.push(row); }, "connectors", 17, field, ); } rejects_nonfinite( |image, value| { let mut row = connector(); row.length = value; image.connectors.push(row); }, "connectors", 17, "length", ); rejects_nonfinite( |image, value| { let mut row = connector(); row.load_kg = value; image.connectors.push(row); }, "connectors", 17, "load_kg", ); rejects_nonfinite( |image, value| { image.pressure_nodes.push(PressureNode { id: 23, pressure: value, }); }, "pressure_nodes", 23, "pressure", ); rejects_nonfinite( |image, value| { image.pressure_edges.push(PressureEdge { from: 23, to: 24, openness: value, hidden: false, }); }, "pressure_edges", 0, "openness", ); rejects_nonfinite( |image, value| image.spill.gas_flows.push((23, 24, value)), "spill.gas_flows", 0, "flow", ); rejects_nonfinite( |image, value| { let mut pack = Pack::new(); pack.max_weight_kg = value; image.packs.insert(1, pack); }, "packs", 1, "max_weight_kg", ); } #[test] fn authoritative_float_ranges_are_enforced_without_rejecting_signed_flow() { use crate::spatial::{BodyExtents, FixedVec3}; assert!(BodyExtents::new(0, 300, 1800).is_err()); assert!(BodyExtents::new(-1, 300, 1800).is_err()); assert!(FixedVec3::new([i64::MAX, 0, 0]).is_err()); assert!(FixedVec3::new([0, 0, i64::MIN]).is_err()); rejects_range( |image| { let mut row = connector(); row.length = -1.0; image.connectors.push(row); }, "connectors", 17, "length", ); rejects_range( |image| { let mut row = connector(); row.length = 2.0; image.connectors.push(row); }, "connectors", 17, "length", ); rejects_range( |image| { let mut row = connector(); row.load_kg = -0.5; image.connectors.push(row); }, "connectors", 17, "load_kg", ); rejects_range( |image| { image.pressure_edges.push(PressureEdge { from: 23, to: 24, openness: -0.01, hidden: false, }); }, "pressure_edges", 0, "openness", ); rejects_range( |image| { image.pressure_edges.push(PressureEdge { from: 23, to: 24, openness: 1.01, hidden: true, }); }, "pressure_edges", 0, "openness", ); rejects_range( |image| { let mut pack = Pack::new(); pack.max_weight_kg = 0.0; image.packs.insert(1, pack); }, "packs", 1, "max_weight_kg", ); rejects_range( |image| { let mut pack = Pack::new(); pack.max_weight_kg = -0.1; image.packs.insert(1, pack); }, "packs", 1, "max_weight_kg", ); let mut image = valid_image(); image.pressure_nodes.push(PressureNode { id: 23, pressure: -1.0, }); image.pressure_edges.push(PressureEdge { from: 23, to: 24, openness: 0.75, hidden: true, }); image.spill.gas_flows.push((23, 24, -0.25)); assert!(State::from_image(image).is_ok()); } #[test] fn exhausted_counters_and_unadvanceable_far_clocks_are_rejected() { let mut image = valid_image(); image.tick = u64::MAX; assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::TickExhausted) ); let mut image = valid_image(); image.far.day_tick = TICKS_PER_DAY; assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::InvalidFarDayTick { day_tick: TICKS_PER_DAY, }) ); let mut image = valid_image(); image.far.day = u64::MAX; image.far.day_tick = TICKS_PER_DAY - 1; assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::FarDayExhausted) ); let mut image = valid_image(); image.next_id = u64::MAX; assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::NextIdExhausted) ); } #[test] fn one_pending_corpse_at_last_safe_id_restores_and_steps_once() { let mut image = image_with_stone_floor(); image.next_id = u64::MAX - 1; image.actors[0].hp = 0; let mut state = State::from_image(image).unwrap(); state.step(); assert_eq!(state.items.get(&(u64::MAX - 1)).unwrap().id, u64::MAX - 1); assert_eq!(state.ground_items.get(&(u64::MAX - 1)), Some(&(0, 0, 0))); assert!(format!("{state:?}").contains(&format!("next_id: {}", u64::MAX))); } #[test] fn multiple_pending_corpses_reject_when_id_headroom_overflows() { let mut image = valid_image(); image.next_id = u64::MAX - 1; image.actors[0].hp = 0; let mut second = image.actors[0].clone(); second.id = 2; image.actors.push(second); image .actor_identities .insert(2, ActorIdentity::new("delver", "Bea", ActorRole::Player)); image.wallets.insert(2, Wallet::default()); assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::NextIdExhausted) ); } #[test] fn accepted_clock_boundary_can_step_once() { let mut image = valid_image(); image.tick = u64::MAX - 1; image.far.day = u64::MAX; image.far.day_tick = TICKS_PER_DAY - 2; let mut state = State::from_image(image).unwrap(); state.step(); assert_eq!(state.tick, u64::MAX); assert_eq!(state.far.day, u64::MAX); assert_eq!(state.far.day_tick, TICKS_PER_DAY - 1); } #[test] fn restored_falling_actor_saturates_hp_and_dies_in_gravity_path() { let mut image = image_with_stone_floor(); image.actors[0].position = crate::spatial::FixedVec3::from_cell_feet([0, 0, 32]).unwrap(); image.actors[0].hp = i32::MIN + 1; let mut state = State::from_image(image).unwrap(); state.step(); assert_eq!(state.actors[0].position.millimetres()[2], 0); assert_eq!(state.actors[0].hp, i32::MIN); assert!(!state.actors[0].alive); assert_eq!( state.items.len(), 0, "gravity death predates Phase 12 corpse discovery" ); assert!(state.causes.links().iter().any(|link| { link.rule == "gravity" && link .detail .contains("falls 32.0m and lands hard (30 damage)") })); assert!(state.causes.links().iter().any(|link| { link.rule == "gravity" && link.detail.contains("killed by the fall (fell 32.0m)") })); assert!(!state.causes.links().iter().any(|link| link.rule == "death")); } #[test] fn exotic_finite_float_bits_survive_image_restore_and_hash() { let mut state = State::new(41); state.pressure_nodes.push(PressureNode { id: 7, pressure: -0.0, }); state.spill.gas_flows.push((7, 8, f32::from_bits(1))); let hash_before = state.try_hash_state().unwrap(); let image = state.to_image().unwrap(); assert_eq!( image.pressure_nodes[0].pressure.to_bits(), (-0.0f32).to_bits() ); assert_eq!(image.spill.gas_flows[0].2.to_bits(), 1); let restored = State::from_image(image).unwrap(); let round_trip = restored.to_image().unwrap(); assert_eq!( round_trip.pressure_nodes[0].pressure.to_bits(), (-0.0f32).to_bits() ); assert_eq!(round_trip.spill.gas_flows[0].2.to_bits(), 1); assert_eq!(restored.try_hash_state().unwrap(), hash_before); } #[test] fn chapel_debt_restore_requires_a_living_actor_positive_count_and_exact_chapel() { let mut image = valid_image(); image .chapel_debts .insert(999, crate::modes::resurrection_debt()); assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::OrphanChapelDebt { actor_id: 999 }) ); let mut image = valid_image(); image.actors[0].alive = false; image .chapel_debts .insert(1, crate::modes::resurrection_debt()); assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::DeadChapelDebtor { actor_id: 1 }) ); let mut image = valid_image(); image.chapel_debts.insert( 1, ChapelDebt { burials_owed: 2, chapel: "Chapel of the Lamplighter".into(), }, ); assert_eq!( State::from_image(image).unwrap_err(), StateImageError::Runtime(RuntimeInvariantError::NonCanonicalChapelDebt { actor_id: 1 }) ); let mut image = valid_image(); image.chapel_debts.insert( 1, ChapelDebt { burials_owed: u8::MAX, chapel: crate::modes::LAMPLIGHTER_CHAPEL.into(), }, ); assert!(State::from_image(image).is_ok()); } }