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.
6.5 kB · 182 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183//! Content-pack identity: manifest, hash, and raw bundle loading//! (docs/04 §4.4 + §4.7).//!//! Saves and `.hexdemo` replays record the content-pack hash so a replay//! never silently runs against edited data: replay code verifies//! [`pack_hash`] before stepping the sim headless (docs/04 §4.7).//!//! Hash scheme: SHA-256 over the concatenated raw bytes of the 7 canonical//! files, in [`manifest`] order. It is ORDER-SENSITIVE on purpose —//! `pack_hash(&[a, b]) != pack_hash(&[b, a])` — so save/replay code MUST pass//! files in manifest order (use [`load_all`], which does). Hashing raw bytes//! (not parsed values) means whitespace and comment edits also change the//! hash; that is intended: any data touch invalidates old replays.//!//! Hot reload: the debug watcher lives parent-side (cli). This crate only//! exposes the pure [`fmtime`] helper (debug builds) so the watcher can poll//! modification times without pulling in threads or a `notify` dependency.
use sha2::{Digest, Sha256};
use crate::load::{load_text, LoadError};
/// The 7 canonical content files, in load/hash order (docs/04 §4.4).////// Paths are relative to the workspace root (`assets/...`). [`load_all`]/// joins them onto `root`; [`pack_hash`] callers must pass file bytes in/// exactly this order.pub fn manifest() -> Vec<&'static str> { vec![ "assets/data/items.toml", "assets/data/monsters.toml", "assets/data/hymns.toml", "assets/data/gods.toml", "assets/data/contracts.toml", "assets/data/floors.toml", "assets/data/towns.toml", ]}
/// SHA-256 hex digest over the concatenated bytes of `files`.////// Order-sensitive by design: callers (save/replay) must pass files in/// [`manifest`] order. Raw bytes in, so any edit — value, comment, or/// whitespace — changes the hash and invalidates dependent replays.pub fn pack_hash(files: &[&[u8]]) -> String { let mut hasher = Sha256::new(); for file in files { hasher.update(file); } format!("{:x}", hasher.finalize())}
/// Read all 7 manifest files under `root`, returning `(path, text)` pairs in/// [`manifest`] order.////// Raw text back on purpose: typed parsing belongs to the parent after the/// workstream-1 type unification (this crate must not depend on `schema.rs`/// shapes yet). Errors are [`LoadError`] with the joined path attached.pub fn load_all(root: &str) -> Result<Vec<(String, String)>, LoadError> { let mut out = Vec::with_capacity(manifest().len()); for rel in manifest() { let path = format!("{root}/{rel}"); let text = load_text(&path)?; out.push((path, text)); } Ok(out)}
/// Modification time of `path`, for debug hot-reload polling.////// Returns `None` when the file is missing or its metadata is unreadable./// The polling watcher itself lives parent-side (cli); this stays a pure/// helper with no threads and no `notify` dependency. Debug builds only.#[cfg(debug_assertions)]pub fn fmtime(path: &str) -> Option<std::time::SystemTime> { std::fs::metadata(path).and_then(|m| m.modified()).ok()}
#[cfg(test)]mod tests { use super::*;
#[cfg(debug_assertions)] fn data_path(name: &str) -> String { format!("{}/../../assets/data/{name}", env!("CARGO_MANIFEST_DIR")) }
#[test] fn manifest_lists_seven_canonical_paths() { let paths = manifest(); assert_eq!(paths.len(), 7, "manifest must list all 7 data files"); assert_eq!(paths[0], "assets/data/items.toml"); assert_eq!(paths[6], "assets/data/towns.toml"); }
#[test] fn pack_hash_is_deterministic() { let a: &[u8] = b"lamp oil"; let b: &[u8] = b"cable-rat"; assert_eq!(pack_hash(&[a, b]), pack_hash(&[a, b])); assert_eq!(pack_hash(&[a, b]).len(), 64, "SHA-256 hex is 64 chars"); }
#[test] fn pack_hash_is_order_sensitive() { let a: &[u8] = b"lamp oil"; let b: &[u8] = b"cable-rat"; assert_ne!( pack_hash(&[a, b]), pack_hash(&[b, a]), "manifest order is part of the hash" ); }
#[test] fn pack_hash_changes_on_edit() { let before: &[u8] = b"pay_cu = 25"; let after: &[u8] = b"pay_cu = 26"; assert_ne!(pack_hash(&[before]), pack_hash(&[after])); }
#[test] fn load_all_reads_seven_files_in_manifest_order() { let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../.."); let bundle = load_all(root).expect("all 7 data files must load"); assert_eq!(bundle.len(), 7, "bundle must hold all 7 files"); for (entry, rel) in bundle.iter().zip(manifest()) { assert!( entry.0.ends_with(rel), "bundle order must follow manifest: {} vs {rel}", entry.0 ); assert!(!entry.1.is_empty(), "{} must be non-empty", entry.0); } let items = &bundle[0].1; assert!( items.contains("lamp_oil"), "first bundle entry must be items.toml content" ); }
#[test] fn load_all_missing_root_reports_joined_path() { let err = load_all("/no/such/root").expect_err("missing root must fail"); assert!( err.file.contains("assets/data/items.toml"), "error must carry the joined path, got: {}", err.file ); assert!(err.to_string().contains(&err.file)); }
#[test] fn real_pack_hash_is_stable_and_hex() { let root = concat!(env!("CARGO_MANIFEST_DIR"), "/../.."); let bundle = load_all(root).expect("all 7 data files must load"); let blobs: Vec<&[u8]> = bundle.iter().map(|(_, t)| t.as_bytes()).collect(); let first = pack_hash(&blobs); let second = pack_hash(&blobs); assert_eq!(first, second, "real pack hash must be deterministic"); assert_eq!(first.len(), 64); assert!( first.chars().all(|c| c.is_ascii_hexdigit()), "hash must be lowercase hex, got: {first}" ); }
#[cfg(debug_assertions)] #[test] fn fmtime_reports_real_files_and_missing_as_none() { let path = data_path("items.toml"); assert!( fmtime(&path).is_some(), "fmtime must see the real items.toml" ); assert_eq!( fmtime(&data_path("no_such_file.toml")), None, "missing files report None" ); }}