//! Content-agnostic authoritative live-run records. //! //! Template ids, display names, town ids, and contract ids are opaque strings: //! content crates choose their values while the deterministic core owns their //! lifecycle, arithmetic, validation, and canonical persistence projection. use std::collections::BTreeSet; use crate::observation::ObservationError; use crate::pending::PendingError; /// Maximum byte length of a canonical knowledge/template identifier. pub const KNOWLEDGE_ID_MAX_BYTES: usize = 96; /// Maximum mapped-region rows retained in a working run. pub const MAPPED_REGION_LIMIT: usize = 4_096; /// Maximum recipe rows retained in a working run. pub const RECIPE_LIMIT: usize = 4_096; /// Maximum translated-hymn rows retained in a working run. pub const TRANSLATION_LIMIT: usize = 1_024; /// Maximum rows across all working-run knowledge categories. pub const KNOWLEDGE_TOTAL_ROW_LIMIT: usize = 8_192; /// Maximum identifier bytes across all working-run knowledge categories. pub const KNOWLEDGE_TOTAL_ID_BYTES_LIMIT: usize = 1_048_576; // The byte ceiling is an independent defense-in-depth invariant; the stricter // row/id maxima currently make it unreachable for valid working knowledge. const _: () = assert!(KNOWLEDGE_TOTAL_ROW_LIMIT * KNOWLEDGE_ID_MAX_BYTES < KNOWLEDGE_TOTAL_ID_BYTES_LIMIT); /// One deterministic namespace in [`RunKnowledge`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum KnowledgeCategory { MappedRegions, Recipes, TranslatedHymns, IdentifiedTemplates, } /// Configurable validation ceilings. Production uses [`KnowledgeLimits::WORKING`]; /// smaller values let bounded tests exercise every aggregate check cheaply. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct KnowledgeLimits { pub mapped_regions: usize, pub recipes: usize, pub translated_hymns: usize, pub total_rows: usize, pub total_id_bytes: usize, } impl KnowledgeLimits { pub const WORKING: Self = Self { mapped_regions: MAPPED_REGION_LIMIT, recipes: RECIPE_LIMIT, translated_hymns: TRANSLATION_LIMIT, total_rows: KNOWLEDGE_TOTAL_ROW_LIMIT, total_id_bytes: KNOWLEDGE_TOTAL_ID_BYTES_LIMIT, }; } /// Why a working-run knowledge operation or image validation failed. #[derive(Clone, Debug, PartialEq, Eq)] pub enum KnowledgeError { InvalidId { category: KnowledgeCategory, id: String, }, FieldLimit { category: KnowledgeCategory, limit: usize, }, TotalRowLimit { limit: usize, }, TotalIdBytesLimit { limit: usize, }, } /// Core-owned mutable knowledge for one run. `BTreeSet` gives unique rows and /// canonical bytewise identifier order; `State` is the live mutation authority. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct RunKnowledge { pub mapped_regions: BTreeSet, pub recipes: BTreeSet, pub translated_hymns: BTreeSet, } impl RunKnowledge { #[must_use] pub const fn new() -> Self { Self { mapped_regions: BTreeSet::new(), recipes: BTreeSet::new(), translated_hymns: BTreeSet::new(), } } /// Validate canonical IDs and all production working-run ceilings. pub fn validate(&self) -> Result<(), KnowledgeError> { self.validate_with_limits(KnowledgeLimits::WORKING) } /// Validate with explicit trusted ceilings. This never normalizes, drops, /// or reorders input; the sets' iteration order is already canonical. pub fn validate_with_limits(&self, limits: KnowledgeLimits) -> Result<(), KnowledgeError> { for (category, rows, field_limit) in [ ( KnowledgeCategory::MappedRegions, &self.mapped_regions, limits.mapped_regions, ), (KnowledgeCategory::Recipes, &self.recipes, limits.recipes), ( KnowledgeCategory::TranslatedHymns, &self.translated_hymns, limits.translated_hymns, ), ] { if rows.len() > field_limit { return Err(KnowledgeError::FieldLimit { category, limit: field_limit, }); } for id in rows { validate_knowledge_id(category, id)?; } } let total_rows = self .mapped_regions .len() .checked_add(self.recipes.len()) .and_then(|rows| rows.checked_add(self.translated_hymns.len())) .ok_or(KnowledgeError::TotalRowLimit { limit: limits.total_rows, })?; if total_rows > limits.total_rows { return Err(KnowledgeError::TotalRowLimit { limit: limits.total_rows, }); } let total_id_bytes = self .mapped_regions .iter() .chain(&self.recipes) .chain(&self.translated_hymns) .try_fold(0usize, |total, id| total.checked_add(id.len())) .ok_or(KnowledgeError::TotalIdBytesLimit { limit: limits.total_id_bytes, })?; if total_id_bytes > limits.total_id_bytes { return Err(KnowledgeError::TotalIdBytesLimit { limit: limits.total_id_bytes, }); } Ok(()) } fn learn_with_limits( &mut self, category: KnowledgeCategory, id: String, limits: KnowledgeLimits, ) -> Result { validate_knowledge_id(category, &id)?; let rows = match category { KnowledgeCategory::MappedRegions => &self.mapped_regions, KnowledgeCategory::Recipes => &self.recipes, KnowledgeCategory::TranslatedHymns => &self.translated_hymns, KnowledgeCategory::IdentifiedTemplates => unreachable!("IDF is a separate book"), }; if rows.contains(&id) { return Ok(false); } let field_limit = match category { KnowledgeCategory::MappedRegions => limits.mapped_regions, KnowledgeCategory::Recipes => limits.recipes, KnowledgeCategory::TranslatedHymns => limits.translated_hymns, KnowledgeCategory::IdentifiedTemplates => unreachable!("IDF is a separate book"), }; if rows.len() >= field_limit { return Err(KnowledgeError::FieldLimit { category, limit: field_limit, }); } let total_rows = self.mapped_regions.len() + self.recipes.len() + self.translated_hymns.len(); if total_rows >= limits.total_rows { return Err(KnowledgeError::TotalRowLimit { limit: limits.total_rows, }); } let total_id_bytes: usize = self .mapped_regions .iter() .chain(&self.recipes) .chain(&self.translated_hymns) .map(String::len) .sum(); if total_id_bytes .checked_add(id.len()) .map_or(true, |bytes| bytes > limits.total_id_bytes) { return Err(KnowledgeError::TotalIdBytesLimit { limit: limits.total_id_bytes, }); } let inserted = match category { KnowledgeCategory::MappedRegions => self.mapped_regions.insert(id), KnowledgeCategory::Recipes => self.recipes.insert(id), KnowledgeCategory::TranslatedHymns => self.translated_hymns.insert(id), KnowledgeCategory::IdentifiedTemplates => unreachable!("IDF is a separate book"), }; debug_assert!(inserted); Ok(true) } pub(crate) fn learn_mapped_region(&mut self, id: String) -> Result { self.learn_with_limits( KnowledgeCategory::MappedRegions, id, KnowledgeLimits::WORKING, ) } pub(crate) fn learn_recipe(&mut self, id: String) -> Result { self.learn_with_limits(KnowledgeCategory::Recipes, id, KnowledgeLimits::WORKING) } pub(crate) fn learn_translation(&mut self, id: String) -> Result { self.learn_with_limits( KnowledgeCategory::TranslatedHymns, id, KnowledgeLimits::WORKING, ) } #[cfg(test)] pub(crate) fn learn_for_test( &mut self, category: KnowledgeCategory, id: String, limits: KnowledgeLimits, ) -> Result { self.learn_with_limits(category, id, limits) } } /// Validate the frozen core identifier grammar: ASCII 1..=96 bytes, starting /// alphanumeric lowercase, followed by lowercase alphanumeric or `_.:-`/`-`. pub fn validate_knowledge_id(category: KnowledgeCategory, id: &str) -> Result<(), KnowledgeError> { let bytes = id.as_bytes(); let valid_first = bytes .first() .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); let valid_rest = bytes.iter().skip(1).all(|byte| { byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'.' | b':' | b'-') }); if bytes.len() > KNOWLEDGE_ID_MAX_BYTES || !valid_first || !valid_rest { return Err(KnowledgeError::InvalidId { category, id: id.to_owned(), }); } Ok(()) } /// Simulation role attached to an actor's canonical identity. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ActorRole { Player, Npc, Monster, } impl ActorRole { /// Stable persistence tag; declaration order is not the wire protocol. #[must_use] pub const fn canonical_tag(self) -> u8 { match self { Self::Player => 0, Self::Npc => 1, Self::Monster => 2, } } } /// Canonical identity for an authoritative [`crate::actor::Actor`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ActorIdentity { pub template_id: String, pub display_name: String, pub role: ActorRole, } impl ActorIdentity { pub fn new( template_id: impl Into, display_name: impl Into, role: ActorRole, ) -> Self { Self { template_id: template_id.into(), display_name: display_name.into(), role, } } /// Presentation-only fallback derived from canonical identity. #[must_use] pub fn fallback_glyph(&self) -> char { match self.role { ActorRole::Player => '@', ActorRole::Npc => 'n', ActorRole::Monster => self.template_id.chars().next().unwrap_or('m'), } } pub(crate) fn legacy() -> Self { Self::new("actor", "Actor", ActorRole::Npc) } } /// Currency carried by one actor. Balances are private so all mutation uses /// checked State transactions or these explicit arithmetic operations. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Wallet { copper: u32, scrip: u32, } /// Why a checked wallet operation failed. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum WalletError { Overflow, InsufficientFunds, } impl Wallet { #[must_use] pub const fn new(copper: u32, scrip: u32) -> Self { Self { copper, scrip } } #[must_use] pub const fn copper(&self) -> u32 { self.copper } #[must_use] pub const fn scrip(&self) -> u32 { self.scrip } pub fn checked_earn_copper(&mut self, amount: u32) -> Result<(), WalletError> { self.copper = self .copper .checked_add(amount) .ok_or(WalletError::Overflow)?; Ok(()) } pub fn checked_earn_scrip(&mut self, amount: u32) -> Result<(), WalletError> { self.scrip = self .scrip .checked_add(amount) .ok_or(WalletError::Overflow)?; Ok(()) } /// Credit up to `amount` and return the amount actually credited. pub fn saturating_earn_copper(&mut self, amount: u32) -> u32 { let credited = amount.min(u32::MAX - self.copper); self.copper += credited; credited } /// Credit up to `amount` and return the amount actually credited. pub fn saturating_earn_scrip(&mut self, amount: u32) -> u32 { let credited = amount.min(u32::MAX - self.scrip); self.scrip += credited; credited } pub fn checked_spend_copper(&mut self, amount: u32) -> Result<(), WalletError> { self.copper = self .copper .checked_sub(amount) .ok_or(WalletError::InsufficientFunds)?; Ok(()) } pub fn checked_spend_scrip(&mut self, amount: u32) -> Result<(), WalletError> { self.scrip = self .scrip .checked_sub(amount) .ok_or(WalletError::InsufficientFunds)?; Ok(()) } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ContractStatus { Active, Completed, TurnedIn, Failed, } impl ContractStatus { /// Stable persistence tag; declaration order is not the wire protocol. #[must_use] pub const fn canonical_tag(self) -> u8 { match self { Self::Active => 0, Self::Completed => 1, Self::TurnedIn => 2, Self::Failed => 3, } } } /// Runtime projection of content-defined contract data. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ActiveContract { pub id: String, pub taker: u64, pub accepted_tick: u64, pub floor: u8, pub pay_cu: u32, pub objective_target: u32, pub objective_progress: u32, pub status: ContractStatus, /// Town where completion must be turned in. pub reward_town: String, /// Exact town-ledger standing awarded with copper. pub standing_delta: i16, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum RuntimeInvariantError { Knowledge(KnowledgeError), EcsNotEmpty { entities: u32, }, ZeroStableId { table: &'static str, }, DuplicateActorId { actor_id: u64, }, StableIdCollision { id: u64, first: &'static str, second: &'static str, }, MissingIdentity { actor_id: u64, }, OrphanIdentity { actor_id: u64, }, MissingWallet { actor_id: u64, }, OrphanWallet { actor_id: u64, }, OrphanActorMetadata { table: &'static str, actor_id: u64, }, OrphanChapelDebt { actor_id: u64, }, DeadChapelDebtor { actor_id: u64, }, ZeroChapelDebt { actor_id: u64, }, NonCanonicalChapelDebt { actor_id: u64, }, NonCanonicalReputationArray { actor_id: u64, table: &'static str, index: usize, expected: u8, found: u8, }, NonFiniteField { table: &'static str, row: u64, field: &'static str, }, InvalidFixedGeometry { table: &'static str, row: u64, }, InvalidFloatRange { table: &'static str, row: u64, field: &'static str, }, UnknownStaticRule { rule: String, }, ContractKeyMismatch { key: String, embedded: String, }, InvalidContractId { id: String, }, InvalidContractFloor { id: String, floor: u8, }, InvalidContractTarget { id: String, }, InvalidContractProgress { id: String, }, InvalidRewardTown { id: String, }, ContractTakerMissing { id: String, taker: u64, }, ContractTakerDead { id: String, taker: u64, }, ItemKeyMismatch { table: &'static str, key: u64, embedded: u64, }, DanglingEquippedItem { actor_id: u64, item_id: u64, }, DuplicateItemId { item_id: u64, }, DanglingGroundItem { item_id: u64, }, LooseItemMissingGround { item_id: u64, }, InvalidRunLocation, TickExhausted, InvalidFarDayTick { day_tick: u64, }, FarDayExhausted, NextIdNotAboveLiveId { next_id: u64, live_id: u64, }, NextIdExhausted, /// Pending-action descriptor or slot invariant failure. Pending(PendingError), /// Observation-memory bound/provenance/cap invariant failure. Observation(ObservationError), } #[derive(Clone, Debug, PartialEq, Eq)] pub enum ContractError { BlankId, DuplicateId, InvalidFloor, InvalidObjectiveTarget, BlankRewardTown, UnknownTaker, DeadTaker, NotFound, NotActive, NotOpen, NotCompleted, WrongTaker, WrongLocation, MissingWallet, MissingReputation, PayoutOverflow, StandingOverflow, InvalidRuntime(RuntimeInvariantError), } /// Prepared facts returned by one committed ground/carried ownership transfer. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ItemTransfer { pub item_id: u64, pub cell: (i32, i32, i32), } /// Atomic item-transfer failure. State is unchanged for every variant. #[derive(Clone, Debug, PartialEq, Eq)] pub enum TransferError { InvalidRuntime(RuntimeInvariantError), UnknownActor, DeadActor, MissingPack, InvalidActorPosition, GroundItemMissing, GroundItemElsewhere, CarriedItemMissing, Stuck, } /// Damage leg rejected by [`crate::state::State::resolve_melee_exchange`]. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MeleeStrike { Outgoing, Retaliation, } /// Atomic reciprocal-melee failure. State is unchanged for every variant. #[derive(Clone, Debug, PartialEq, Eq)] pub enum MeleeError { InvalidRuntime(RuntimeInvariantError), SameActor, UnknownAttacker, UnknownDefender, DeadAttacker, DeadDefender, NonPositiveDamage { strike: MeleeStrike }, ObjectiveRequiresDefenderDeath, ObjectiveComplete, InvalidObjective(ContractError), IdExhausted, AllocationFailed, } /// Fully committed zero-tick reciprocal-melee outcome. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct MeleeExchange { pub outgoing_damage: i32, pub defender_killed: bool, pub retaliation_damage: Option, pub attacker_killed: bool, pub objective_progress: Option<(u32, u32)>, } /// Coarse authoritative run location. #[derive(Clone, Debug, PartialEq, Eq)] pub enum RunLocation { /// No content plan/load has selected a starting town yet. Uninitialized, Town(String), Deep(u8), } impl RunLocation { /// Stable persistence tag for the location kind. Town id and Deep depth /// are encoded separately after this tag. #[must_use] pub const fn canonical_tag(&self) -> u8 { match self { Self::Uninitialized => 0, Self::Town(_) => 1, Self::Deep(_) => 2, } } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum LocationError { BlankTown, InvalidDepth, IllegalTransition, InvalidRuntime(RuntimeInvariantError), } #[cfg(test)] mod knowledge_tests { use super::*; fn limits(field: usize, rows: usize, bytes: usize) -> KnowledgeLimits { KnowledgeLimits { mapped_regions: field, recipes: field, translated_hymns: field, total_rows: rows, total_id_bytes: bytes, } } #[test] fn knowledge_id_grammar_is_exact() { for valid in ["a", "0", "town:milthollow", "deep:12", "a_b.c:d-e9"] { assert_eq!( validate_knowledge_id(KnowledgeCategory::Recipes, valid), Ok(()) ); } let max = format!("a{}", "z".repeat(KNOWLEDGE_ID_MAX_BYTES - 1)); assert_eq!( validate_knowledge_id(KnowledgeCategory::Recipes, &max), Ok(()) ); for invalid in [ "", "A", "_starts_bad", "has space", "has/slash", "has\\slash", "line\nbreak", "é", ] { assert!(matches!( validate_knowledge_id(KnowledgeCategory::Recipes, invalid), Err(KnowledgeError::InvalidId { .. }) )); } let too_long = format!("a{}", "z".repeat(KNOWLEDGE_ID_MAX_BYTES)); assert!(matches!( validate_knowledge_id(KnowledgeCategory::Recipes, &too_long), Err(KnowledgeError::InvalidId { .. }) )); } #[test] fn knowledge_limits_and_idempotence_are_atomic() { let mut knowledge = RunKnowledge::new(); let small = limits(2, 3, 12); assert_eq!( knowledge.learn_for_test(KnowledgeCategory::MappedRegions, "map:a".into(), small), Ok(true) ); assert_eq!( knowledge.learn_for_test(KnowledgeCategory::MappedRegions, "map:a".into(), small), Ok(false) ); let before = knowledge.clone(); assert!(matches!( knowledge.learn_for_test(KnowledgeCategory::MappedRegions, "BAD".into(), small), Err(KnowledgeError::InvalidId { .. }) )); assert_eq!(knowledge, before); assert_eq!( knowledge.learn_for_test(KnowledgeCategory::MappedRegions, "map:b".into(), small), Ok(true) ); let before = knowledge.clone(); assert_eq!( knowledge.learn_for_test(KnowledgeCategory::MappedRegions, "map:c".into(), small), Err(KnowledgeError::FieldLimit { category: KnowledgeCategory::MappedRegions, limit: 2, }) ); assert_eq!(knowledge, before); assert_eq!( knowledge.learn_for_test(KnowledgeCategory::Recipes, "r".into(), small), Ok(true) ); let before = knowledge.clone(); assert_eq!( knowledge.learn_for_test(KnowledgeCategory::Recipes, "s".into(), small), Err(KnowledgeError::TotalRowLimit { limit: 3 }) ); assert_eq!(knowledge, before); let mut bytes = RunKnowledge::new(); let byte_limits = limits(4, 4, 5); assert_eq!( bytes.learn_for_test(KnowledgeCategory::Recipes, "abcd".into(), byte_limits), Ok(true) ); let before = bytes.clone(); assert_eq!( bytes.learn_for_test(KnowledgeCategory::TranslatedHymns, "xy".into(), byte_limits), Err(KnowledgeError::TotalIdBytesLimit { limit: 5 }) ); assert_eq!(bytes, before); } #[test] fn production_limits_are_frozen() { assert_eq!( KnowledgeLimits::WORKING, KnowledgeLimits { mapped_regions: 4_096, recipes: 4_096, translated_hymns: 1_024, total_rows: 8_192, total_id_bytes: 1_048_576, } ); } #[test] fn aggregate_validation_reports_each_frozen_cap() { let mut field = RunKnowledge::new(); field.mapped_regions.extend(["a".into(), "b".into()]); assert_eq!( field.validate_with_limits(limits(1, 9, 99)), Err(KnowledgeError::FieldLimit { category: KnowledgeCategory::MappedRegions, limit: 1, }) ); let mut rows = RunKnowledge::new(); rows.mapped_regions.insert("a".into()); rows.recipes.insert("b".into()); assert_eq!( rows.validate_with_limits(limits(9, 1, 99)), Err(KnowledgeError::TotalRowLimit { limit: 1 }) ); let mut bytes = RunKnowledge::new(); bytes.translated_hymns.insert("abcdef".into()); assert_eq!( bytes.validate_with_limits(limits(9, 9, 5)), Err(KnowledgeError::TotalIdBytesLimit { limit: 5 }) ); } }