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.
16 kB · 500 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501//! Typed, validated content-pack loading for production initialization.//!//! [`RuntimePack::load`] takes an explicit workspace/content root, reads the//! seven canonical manifest files in manifest order, parses their schema//! wrappers, runs all pack validators and cross-reference checks, and hashes//! the exact bytes that were parsed. The resulting vectors are private and//! exposed only as shared slices/lookups, so initialized content cannot drift//! away from its recorded hash.
use std::collections::{BTreeMap, BTreeSet};use std::fmt;use std::path::Path;
use serde::de::DeserializeOwned;
use crate::gods::validate_bargain_table;use crate::load::{load_text, LoadError};use crate::pack::{manifest, pack_hash};use crate::schema::{Contract, Floor, God, Hymn, Item, Monster, Town};use crate::validate::{ validate_contracts, validate_floors, validate_gods, validate_hymns, validate_items, validate_monsters, validate_towns, ContentError,};use crate::{ContractsFile, FloorsFile, GodsFile, HymnsFile, ItemsFile, MonstersFile, TownsFile};
/// A complete canonical content pack, parsed and validated as one immutable unit.#[derive(Clone, Debug)]pub struct RuntimePack { items: Vec<Item>, monsters: Vec<Monster>, hymns: Vec<Hymn>, gods: Vec<God>, contracts: Vec<Contract>, floors: Vec<Floor>, towns: Vec<Town>, hash: String,}
/// A content-pack load/validation failure, or an invalid runtime town lookup.#[derive(Clone, Debug, PartialEq, Eq)]pub enum RuntimePackError { /// A manifest file could not be read or parsed. Load(LoadError), /// All schema validators and cross-reference checks ran and found errors. Validation(Vec<ContentError>), /// A town-scoped operation named a town absent from this validated pack. UnknownTown(String),}
impl RuntimePackError { /// Source file for a single load error, or the first validation error. #[must_use] pub fn file(&self) -> Option<&str> { match self { Self::Load(error) => Some(&error.file), Self::Validation(errors) => errors.first().map(|error| error.file.as_str()), Self::UnknownTown(_) => None, } }
/// Source line for a single load error, or the first validation error. #[must_use] pub fn line(&self) -> Option<usize> { match self { Self::Load(error) => error.line, Self::Validation(errors) => errors.first().and_then(|error| error.line), Self::UnknownTown(_) => None, } }
/// Every structured content validation error, when validation failed. #[must_use] pub fn validation_errors(&self) -> Option<&[ContentError]> { match self { Self::Validation(errors) => Some(errors), Self::Load(_) | Self::UnknownTown(_) => None, } }}
impl fmt::Display for RuntimePackError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Load(error) => error.fmt(formatter), Self::Validation(errors) => { write!( formatter, "content pack validation failed with {} error(s)", errors.len() )?; for error in errors { write!(formatter, "\n{error}")?; } Ok(()) } Self::UnknownTown(id) => write!(formatter, "unknown town '{id}'"), } }}
impl std::error::Error for RuntimePackError { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { match self { Self::Load(error) => Some(error), Self::Validation(_) | Self::UnknownTown(_) => None, } }}
impl From<LoadError> for RuntimePackError { fn from(error: LoadError) -> Self { Self::Load(error) }}
struct SourceFile { path: String, text: String,}
impl RuntimePack { /// Load, parse, validate, cross-check, and hash all canonical content below `root`. /// /// `root` is explicit by design: production initialization chooses the /// content tree and no process-global cache or implicit current directory /// participates. The hash is SHA-256 over the exact UTF-8 file bytes in /// [`manifest`] order, matching [`crate::pack::load_all`] + [`pack_hash`]. pub fn load(root: impl AsRef<Path>) -> Result<Self, RuntimePackError> { let root = root.as_ref(); let mut sources = Vec::with_capacity(7); for relative in manifest() { let path = root.join(relative); let display = path.to_string_lossy().into_owned(); let text = load_text(&display)?; sources.push(SourceFile { path: display, text, }); }
let ItemsFile { item: items } = parse_source(&sources[0])?; let MonstersFile { monster: monsters } = parse_source(&sources[1])?; let HymnsFile { hymn: hymns } = parse_source(&sources[2])?; let GodsFile { god: gods } = parse_source(&sources[3])?; let ContractsFile { contract: contracts, } = parse_source(&sources[4])?; let FloorsFile { floor: floors } = parse_source(&sources[5])?; let TownsFile { town: towns } = parse_source(&sources[6])?;
let entry_lines = [ top_level_id_lines(&sources[0].text, "[[item]]"), top_level_id_lines(&sources[1].text, "[[monster]]"), top_level_id_lines(&sources[2].text, "[[hymn]]"), top_level_id_lines(&sources[3].text, "[[god]]"), top_level_id_lines(&sources[4].text, "[[contract]]"), top_level_id_lines(&sources[5].text, "[[floor]]"), top_level_id_lines(&sources[6].text, "[[town]]"), ];
let mut errors = Vec::new(); collect_validation( &items, &sources[0].path, &entry_lines[0], validate_items, &mut errors, ); collect_validation( &monsters, &sources[1].path, &entry_lines[1], validate_monsters, &mut errors, ); collect_validation( &hymns, &sources[2].path, &entry_lines[2], validate_hymns, &mut errors, ); collect_validation( &gods, &sources[3].path, &entry_lines[3], validate_gods, &mut errors, ); for (index, god) in gods.iter().enumerate() { errors.extend(validate_bargain_table(god).into_iter().map(|message| { ContentError::new( &sources[3].path, entry_lines[3].get(index).copied(), message, ) })); } collect_validation( &contracts, &sources[4].path, &entry_lines[4], validate_contracts, &mut errors, ); collect_validation( &floors, &sources[5].path, &entry_lines[5], validate_floors, &mut errors, ); collect_validation( &towns, &sources[6].path, &entry_lines[6], validate_towns, &mut errors, );
collect_duplicate_ids( "item", items.iter().map(|entry| entry.id.as_str()), &sources[0].path, &entry_lines[0], &mut errors, ); collect_duplicate_ids( "monster", monsters.iter().map(|entry| entry.id.as_str()), &sources[1].path, &entry_lines[1], &mut errors, ); collect_duplicate_ids( "hymn", hymns.iter().map(|entry| entry.id.as_str()), &sources[2].path, &entry_lines[2], &mut errors, ); collect_duplicate_ids( "god", gods.iter().map(|entry| entry.id.as_str()), &sources[3].path, &entry_lines[3], &mut errors, ); collect_duplicate_ids( "contract", contracts.iter().map(|entry| entry.id.as_str()), &sources[4].path, &entry_lines[4], &mut errors, ); collect_duplicate_ids( "floor", floors.iter().map(|entry| entry.id.as_str()), &sources[5].path, &entry_lines[5], &mut errors, ); collect_duplicate_ids( "town", towns.iter().map(|entry| entry.id.as_str()), &sources[6].path, &entry_lines[6], &mut errors, );
let contract_ids: BTreeSet<&str> = contracts .iter() .map(|contract| contract.id.as_str()) .collect(); for (index, town) in towns.iter().enumerate() { for contract_id in &town.contracts { if !contract_ids.contains(contract_id.as_str()) { errors.push(ContentError::new( &sources[6].path, entry_lines[6].get(index).copied(), format!( "town '{}' references unknown contract '{contract_id}'", town.id ), )); } } }
let floor_depths: BTreeSet<u8> = floors.iter().map(|floor| floor.depth).collect(); for (index, contract) in contracts.iter().enumerate() { if !floor_depths.contains(&contract.floor) { errors.push(ContentError::new( &sources[4].path, entry_lines[4].get(index).copied(), format!( "contract '{}' names unloaded floor {}", contract.id, contract.floor ), )); } }
if !errors.is_empty() { return Err(RuntimePackError::Validation(errors)); }
let blobs: Vec<&[u8]> = sources .iter() .map(|source| source.text.as_bytes()) .collect(); let hash = pack_hash(&blobs); debug_assert!(!hash.is_empty(), "SHA-256 formatting must never be empty");
Ok(Self { items, monsters, hymns, gods, contracts, floors, towns, hash, }) }
/// Canonical SHA-256 identity of the exact manifest-order bytes parsed into this pack. #[must_use] pub fn hash(&self) -> &str { &self.hash }
/// All items in canonical file order. #[must_use] pub fn items(&self) -> &[Item] { &self.items }
/// All monsters in canonical file order. #[must_use] pub fn monsters(&self) -> &[Monster] { &self.monsters }
/// All hymns in canonical file order. #[must_use] pub fn hymns(&self) -> &[Hymn] { &self.hymns }
/// All gods in canonical file order. #[must_use] pub fn gods(&self) -> &[God] { &self.gods }
/// All contracts in canonical file order. #[must_use] pub fn contracts(&self) -> &[Contract] { &self.contracts }
/// All floors in canonical file order. #[must_use] pub fn floors(&self) -> &[Floor] { &self.floors }
/// All towns in canonical file order. #[must_use] pub fn towns(&self) -> &[Town] { &self.towns }
/// Find a town by its file-local ID. #[must_use] pub fn town(&self, id: &str) -> Option<&Town> { self.towns.iter().find(|town| town.id == id) }
/// Find a contract by its file-local ID. #[must_use] pub fn contract(&self, id: &str) -> Option<&Contract> { self.contracts.iter().find(|contract| contract.id == id) }
/// Find a monster by its file-local ID. #[must_use] pub fn monster(&self, id: &str) -> Option<&Monster> { self.monsters.iter().find(|monster| monster.id == id) }
/// Find an item by its file-local ID. #[must_use] pub fn item(&self, id: &str) -> Option<&Item> { self.items.iter().find(|item| item.id == id) }
/// Find the canonical floor at `depth`. #[must_use] pub fn floor(&self, depth: u8) -> Option<&Floor> { self.floors.iter().find(|floor| floor.depth == depth) }
/// Build one town's deterministic, tier-capped notice board. /// /// This applies [`crate::contracts::board_for`] ordering first, then /// intersects it with the IDs in [`Town::contracts`], preserving that /// deterministic tier/pay/pack ordering. pub fn board_for_town( &self, town_id: &str, tier_max: u8, ) -> Result<Vec<&Contract>, RuntimePackError> { let town = self .town(town_id) .ok_or_else(|| RuntimePackError::UnknownTown(town_id.to_owned()))?; let posted: BTreeSet<&str> = town.contracts.iter().map(String::as_str).collect(); Ok( crate::contracts::board_for(&self.contracts, town_id, tier_max) .into_iter() .filter(|contract| posted.contains(contract.id.as_str())) .collect(), ) }}
fn parse_source<T>(source: &SourceFile) -> Result<T, RuntimePackError>where T: DeserializeOwned,{ toml::from_str(&source.text).map_err(|error| { RuntimePackError::Load(LoadError { file: source.path.clone(), line: error.span().map(|span| { source.text[..span.start.min(source.text.len())] .matches('\n') .count() + 1 }), message: error.message().to_owned(), }) })}
fn top_level_id_lines(text: &str, header: &str) -> Vec<usize> { let mut lines = Vec::new(); let mut awaiting_id = false; for (index, line) in text.lines().enumerate() { let trimmed = line.trim(); if trimmed == header { awaiting_id = true; } else if awaiting_id && trimmed.starts_with("id") && trimmed.contains('=') { lines.push(index + 1); awaiting_id = false; } } lines}
fn collect_validation<T>( entries: &[T], file: &str, lines: &[usize], validator: fn(&[T]) -> Result<(), Vec<ContentError>>, output: &mut Vec<ContentError>,) { for (index, entry) in entries.iter().enumerate() { if let Err(mut errors) = validator(std::slice::from_ref(entry)) { for error in &mut errors { error.file = file.to_owned(); error.line = lines.get(index).copied(); } output.extend(errors); } }}
fn collect_duplicate_ids<'a>( kind: &str, ids: impl Iterator<Item = &'a str>, file: &str, lines: &[usize], errors: &mut Vec<ContentError>,) { let mut first_lines = BTreeMap::new(); for (index, id) in ids.enumerate() { let line = lines.get(index).copied(); if let Some(first_line) = first_lines.insert(id, line) { let first = first_line .map(|line| format!("line {line}")) .unwrap_or_else(|| "an earlier entry".to_owned()); errors.push(ContentError::new( file, line, format!("duplicate {kind} id '{id}' (first declared at {first})"), )); } }}