Something went wrong. Try again.
HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust.
Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
Something went wrong. Try again.
24 kB · 829 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830//! 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<String>, pub recipes: BTreeSet<String>, pub translated_hymns: BTreeSet<String>,}
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<bool, KnowledgeError> { 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<bool, KnowledgeError> { self.learn_with_limits( KnowledgeCategory::MappedRegions, id, KnowledgeLimits::WORKING, ) }
pub(crate) fn learn_recipe(&mut self, id: String) -> Result<bool, KnowledgeError> { self.learn_with_limits(KnowledgeCategory::Recipes, id, KnowledgeLimits::WORKING) }
pub(crate) fn learn_translation(&mut self, id: String) -> Result<bool, KnowledgeError> { 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<bool, KnowledgeError> { 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<String>, display_name: impl Into<String>, 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<i32>, 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 }) ); }}