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.
15 kB · 412 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413//! TOML content-file loading with `file:line` errors (docs/04 §4.4).//!//! Designers edit `assets/data/*.toml`, not Rust. [`load_file`] is the single//! choke point that reads those files: I/O failures and TOML parse failures//! both come back as [`LoadError`], which always names the file and — for//! parse errors the `toml` crate can place — the 1-based line, so a content//! failure points at the offending data line instead of a Rust backtrace.//!//! Workstream split note: the canonical schema structs live in workstream 1//! (`schema.rs`). The test-only mirrors in this file (`ItemsFile` etc.)//! deliberately duplicate their TOML field names exactly, so unifying the//! types later is mechanical (same keys, same field names, delete the mirror).
use std::fmt;use std::fs;
use serde::de::DeserializeOwned;
/// Failure to load or parse one content file (docs/04 §4.4: validation errors/// carry `file:line`).#[derive(Clone, Debug, PartialEq, Eq)]pub struct LoadError { /// File path as passed to [`load_file`] (or the joined path for /// [`crate::pack::load_all`]). pub file: String, /// 1-based TOML line for parse errors. `None` for I/O errors and for /// parse errors the `toml` crate cannot place (span-less serde /// data-model errors). pub line: Option<usize>, /// Human-readable cause: I/O description or `toml` error message. pub message: String,}
impl fmt::Display for LoadError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self.line { Some(line) => write!(f, "{}:{}: {}", self.file, line, self.message), None => write!(f, "{}: {}", self.file, self.message), } }}
impl std::error::Error for LoadError {}
/// Read `path` to a string, mapping I/O failures to [`LoadError`].////// Shared by [`load_file`] and/// [`crate::pack::load_all`]: raw text loading must/// NOT go through `toml::from_str::<String>`, which rejects TOML documents/// (`invalid type: map, expected a string`). TOML parsing only happens when/// deserialising into a structured `T`.pub fn load_text(path: &str) -> Result<String, LoadError> { fs::read_to_string(path).map_err(|err| LoadError { file: path.to_owned(), line: None, message: format!("cannot read file: {err}"), })}
/// Load `path` and deserialise its TOML into `T`.////// `path` is used verbatim for both reading and error attribution, so callers/// should pass the stable canonical form (see/// [`crate::pack::manifest`]). I/O errors return a/// [`LoadError`] with [`LoadError::line`] `None`; TOML errors carry the/// 1-based line derived from the `toml` error span.pub fn load_file<T>(path: &str) -> Result<T, LoadError>where T: DeserializeOwned,{ let text = load_text(path)?; parse_text(&text, path)}
/// Parse TOML `text` as if read from `file`.////// Pure helper behind [`load_file`]'s error mapping; lets tests feed inline/// TOML without touching the filesystem.fn parse_text<T>(text: &str, file: &str) -> Result<T, LoadError>where T: DeserializeOwned,{ toml::from_str(text).map_err(|err| LoadError { file: file.to_owned(), line: err.span().map(|span| { // Byte offset -> 1-based line: count newlines before the span start. // `get` guards against a mid-character boundary; on failure we // fall back to line 1 rather than panicking in error handling. let head = text.get(..span.start.min(text.len())).unwrap_or(""); head.matches('\n').count() + 1 }), message: err.message().to_owned(), })}
#[cfg(test)]mod tests { use super::*; // Typed loading tests parse through the canonical schema wrappers // (`crate::{ItemsFile, …}`) — no local mirrors. Field names are identical // to assets/data/*.toml, so a parse failure here means data drifted from // the schema and the DATA file must be fixed (sim + schema beat content). use crate::{ ContractsFile, FloorsFile, GodsFile, HymnsFile, ItemsFile, MonstersFile, TownsFile, };
#[test] fn loads_items_file() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/data/items.toml"); let data: ItemsFile = load_file(path).expect("items.toml must parse"); assert_eq!(data.item.len(), 40, "canonical pack ships 40 items so far"); for item in &data.item { assert!( item.uses.len() >= 2, "two-uses law: '{}' has {} uses", item.id, item.uses.len() ); assert!(!item.intent.is_empty(), "'{}' needs an intent", item.id); assert!(item.weight_kg >= 0.0, "'{}' has negative weight", item.id); } let ids: Vec<&str> = data.item.iter().map(|i| i.id.as_str()).collect(); for expected in [ "lamp_oil", "drowned_bell_captive", "audit_lien_chit", "tin_lamp", ] { assert!(ids.contains(&expected), "missing item '{expected}'"); } }
#[test] fn loads_monsters_file() { let path = concat!( env!("CARGO_MANIFEST_DIR"), "/../../assets/data/monsters.toml" ); let data: MonstersFile = load_file(path).expect("monsters.toml must parse"); assert_eq!( data.monster.len(), 42, "EA ships 42 monsters (docs/03 §3.11 slice floor)" ); for monster in &data.monster { assert!(!monster.uses.is_empty(), "'{}' needs a use", monster.id); assert!( monster.depth_min <= monster.depth_max, "'{}' has an inverted depth band", monster.id ); assert!( !monster.intent.is_empty(), "'{}' needs an intent", monster.id ); } assert_eq!( data.monster.iter().filter(|m| m.boss).count(), 5, "rat-king + Saint of Rust + Matriarch + Seraph + Drowned Saint are bosses" ); }
#[test] fn loads_hymns_file() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/data/hymns.toml"); let data: HymnsFile = load_file(path).expect("hymns.toml must parse"); assert_eq!( data.hymn.len(), 7, "canonical pack ships 7 runtime-backed hymns" ); for hymn in &data.hymn { assert!( hymn.miscast_table.len() >= 2, "'{}' needs 2+ miscasts", hymn.id ); assert!( (0..=100).contains(&hymn.conduct_base), "'{}' conduct_base out of range", hymn.id ); assert!(!hymn.intent.is_empty(), "'{}' needs an intent", hymn.id); } }
#[test] fn loads_gods_file() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/data/gods.toml"); let data: GodsFile = load_file(path).expect("gods.toml must parse"); assert_eq!(data.god.len(), 5, "EA ships 5 gods (docs/02 §2)"); for god in &data.god { assert!(god.boons.len() >= 2, "'{}' needs 2+ boons", god.name); for boon in &god.boons { assert!(!boon.small_print.is_empty(), "boon needs small print"); } assert!(!god.blasphemy.is_empty(), "'{}' needs blasphemy", god.name); } }
#[test] fn loads_contracts_file() { let path = concat!( env!("CARGO_MANIFEST_DIR"), "/../../assets/data/contracts.toml" ); let data: ContractsFile = load_file(path).expect("contracts.toml must parse"); assert_eq!( data.contract.len(), 62, "EA ships 62 contracts (docs/03 §3.11 floor)" ); for contract in &data.contract { assert!( (1..=5).contains(&contract.tier), "'{}' tier out of range", contract.id ); assert!( !contract.unknown_hint.is_empty(), "'{}' unknown_hint must never be empty (schema law)", contract.id ); assert!( !contract.intent.is_empty(), "'{}' needs an intent", contract.id ); } }
#[test] fn loads_floors_file() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/data/floors.toml"); let data: FloorsFile = load_file(path).expect("floors.toml must parse"); assert_eq!( data.floor.len(), 12, "EA ships the 12-floor Deep (docs/03 §3.8)" ); for floor in &data.floor { assert!( floor.connectors.len() >= 3, "floor {} needs 3+ connectors", floor.depth ); assert!( ["Shallows", "Works", "Deep"].contains(&floor.stratum.as_str()), "stratum must be canonical" ); assert!( !floor.intent.is_empty(), "floor {} needs intent", floor.depth ); } }
#[test] fn loads_towns_file() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/data/towns.toml"); let data: TownsFile = load_file(path).expect("towns.toml must parse"); assert_eq!(data.town.len(), 5, "EA ships 5 towns (docs/02 §3 county)"); let town = &data.town[0]; assert_eq!(town.pop, 312, "Milthollow pop is 312 (docs/02 §3)"); assert_eq!( town.gossip_days, 3, "gossip latency is a sim rule (docs/02 §4)" ); assert!(!town.contracts.is_empty(), "town must reference contracts"); }
#[test] fn missing_file_error_carries_path() { let path = concat!( env!("CARGO_MANIFEST_DIR"), "/../../assets/data/no_such_file.toml" ); let err = load_file::<ItemsFile>(path).expect_err("missing file must fail"); assert_eq!(err.file, path, "error must carry the path"); assert_eq!(err.line, None, "I/O errors have no line"); assert!(err.to_string().contains(path), "Display must name the file"); }
#[test] fn display_shape_with_and_without_line() { let with_line = LoadError { file: "assets/data/items.toml".to_owned(), line: Some(12), message: "expected a value".to_owned(), }; assert_eq!( with_line.to_string(), "assets/data/items.toml:12: expected a value" ); let io_err = LoadError { file: "assets/data/items.toml".to_owned(), line: None, message: "cannot read file: nope".to_owned(), }; assert_eq!( io_err.to_string(), "assets/data/items.toml: cannot read file: nope" ); }
#[test] fn malformed_toml_reports_line() { #[derive(Debug, serde::Deserialize)] struct Tiny { #[allow(dead_code)] id: String, } let bad = "id = \"x\"\n= bad\n"; let err = parse_text::<Tiny>(bad, "bad-file.toml").expect_err("bad TOML must fail"); assert_eq!(err.file, "bad-file.toml"); assert_eq!(err.line, Some(2), "error must point at line 2"); assert!(err.to_string().starts_with("bad-file.toml:2: ")); }
#[test] fn canonical_items_parser_fixed_toml_diagnostics_preserve_literal_lines() { struct DiagnosticCase { name: &'static str, input: &'static str, expected_line: usize, }
let cases = [ DiagnosticCase { name: "unfinished_array_with_terminal_lf", input: "# fixture\n[[item]]\nid = [\n", expected_line: 4, }, DiagnosticCase { name: "unfinished_array_with_extra_blank_lines", input: "# fixture\n[[item]]\nid = [\n\n\n", expected_line: 6, }, DiagnosticCase { name: "unfinished_array_without_terminal_lf", input: "# fixture\n[[item]]\nid = [", expected_line: 3, }, DiagnosticCase { name: "incomplete_assignment", input: "x =\n", expected_line: 1, }, DiagnosticCase { name: "utf8_incomplete_array", input: "x = [\"é\"", expected_line: 1, }, DiagnosticCase { name: "unfinished_array_crlf", input: "# fixture\r\n[[item]]\r\nid = [\r\n", expected_line: 4, }, DiagnosticCase { name: "invalid_token_before_newline", input: "x = ?\ny = 1\n", expected_line: 1, }, DiagnosticCase { name: "trailing_comment_and_blank_line", input: "x = [\n# trailing comment\n\n", expected_line: 4, }, DiagnosticCase { name: "trailing_horizontal_layout", input: "x = [\n \t\r\n", expected_line: 3, }, DiagnosticCase { name: "unterminated_multiline_basic_string", input: "x = \"\"\"\n# apparent comment\n", expected_line: 3, }, DiagnosticCase { name: "unterminated_multiline_literal_string", input: "x = '''\n# apparent comment\n", expected_line: 3, }, DiagnosticCase { name: "array_after_valid_multiline_string", input: "x = [\"\"\"alpha\n# content\n\"\"\"", expected_line: 3, }, DiagnosticCase { name: "array_with_trailing_comment", input: "x = [\n 1,\n # trailing\n", expected_line: 4, }, ];
for case in cases { let err = parse_text::<ItemsFile>(case.input, "items-fixture.toml") .expect_err("every fixed diagnostic input must be malformed TOML"); assert_eq!(err.file, "items-fixture.toml", "case {}", case.name); assert_eq!(err.line, Some(case.expected_line), "case {}", case.name); assert!( err.to_string() .starts_with(&format!("items-fixture.toml:{}: ", case.expected_line)), "case {}", case.name ); } }}