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.
12 kB · 307 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308//! Notice-board + directions rendering: pure display strings//! (docs/03-systems-design.md §3.7).//!//! Design traces://! - docs/03 §3.7 (notice boards: pay, known hazards, *what the poster didn't//! know* — gossip checks; no quest compass — landmark + depth + draft;//! the log records every direction verbatim and searchably).//! - docs/02-lore-bible.md §3 (county voice: Anglo-Saxon mud on top,//! liturgical engineering underneath).//!//! Relationship to `town::board_text` (same crate): `town::board_text` takes//! `(title, pay, tier)` triples; [`board_list`] takes//! `(title, pay, tier, poster)` quadruples — a SUPERSET for boards where the//! poster's name is the gossip seed (every poster is a person to ask about).//! The two are deliberately STANDALONE (no cross-module import): this work//! ran parallel to town UI, so this module duplicates the tiny tier-mark//! table rather than coupling. Unification (one board renderer, optional//! poster column) is parent-side cleanup, NOT a behavior change — outputs//! here are specified to match `town::board_text` line-for-line on the//! shared columns.//!//! Display laws (shared voice law: the GAME never lies)://! - Empty boards say so honestly with the innkeep nudge (same line as//! `town::board_text` — a bare board in Milthollow is unusual enough to//! ask about).//! - [`contract_card`] splits KNOWN vs UNKNOWN visually: the//! poster-doesn't-know made legible. The UNKNOWN half is a gossip pointer//! ("ask ..."), never a spoiler.//! - [`directions_card`] renders landmark + depth + draft ONLY. NO//! coordinates, NO compass rose — compasses don't exist. Coordinates//! `(x, y, z)` are sim-private (`hexwork-core` `dungeon.rs`); if a//! coordinate pattern ever reaches these functions' inputs, the caller is//! at fault — these functions take prose strings, never cells.//!//! Pure string building only: no I/O, no terminal calls, no RNG, no width//! truncation — the caller boxes. MSRV 1.75: no inline format args capture.
/// Roman tier marks for contract tiers 1–5 (docs/03 §3.7: vermin →/// hauntings → saints → Deep doors). Anything outside 1–5 renders `?` —/// ignorance is the safe default, never a wrong numeral. (Mirrors/// `town::board_text`'s table; see module docs on the parallel rule.)fn tier_mark(tier: u8) -> &'static str { match tier { 1 => "I", 2 => "II", 3 => "III", 4 => "IV", 5 => "V", _ => "?", }}
/// Notice-board listing for `contracts` of `(title, pay_cu, tier, poster)`.////// One line per contract: tier mark + title + pay + poster (the poster is/// the gossip seed — every name is someone to ask). An empty board says so/// honestly with the innkeep nudge.#[must_use]pub fn board_list(contracts: &[(String, u32, u8, String)]) -> String { if contracts.is_empty() { return "the board is bare (unusual — ask the innkeep)".to_owned(); } let mut out = String::from("notice board:"); for (title, pay, tier, poster) in contracts { out.push_str( &("· [".to_owned() + tier_mark(*tier) + "] " + title + " — " + &pay.to_string() + "cu — posted by " + poster), ); out.push('\n'); } // Drop the trailing newline the loop leaves: the caller boxes, and a // dangling blank line reads as a missing posting. out.pop(); out}
/// Full contract card: KNOWN vs UNKNOWN split (docs/03 §3.7).////// `hazards` is the poster's honest half; `unknown` is the/// poster-doesn't-know gossip pointer; `prep` is the prep-before-fight/// loadout; `floor` renders as "floor N" (the depth number is public board/// knowledge — delvers price by depth). Sections are visually split so the/// player can see exactly where the poster's knowledge ends.#[allow(clippy::too_many_arguments)]#[must_use]pub fn contract_card( title: &str, pay: u32, tier: u8, poster: &str, hazards: &[String], unknown: &str, prep: &str, floor: u8,) -> String { let mut out = String::new(); out.push_str( &("[".to_owned() + tier_mark(tier) + "] " + title + " — " + &pay.to_string() + "cu"), ); out.push_str(&("\nposted by ".to_owned() + poster + " — floor " + &floor.to_string())); out.push_str("\nKNOWN (the poster swears it):"); if hazards.is_empty() { out.push_str("\n· (the poster admits no hazard — ask why)"); } else { for hazard in hazards { out.push_str(&("\n· ".to_owned() + hazard)); } } out.push_str("\nUNKNOWN (the poster doesn't know — ask around):"); if unknown.trim().is_empty() { out.push_str("\n· (no word yet — ask around)"); } else { out.push_str(&("\n· ".to_owned() + unknown)); } out.push_str(&("\nPREP (go ready, come back):\n· ".to_owned() + prep)); out}
/// Directions card: landmark + depth + draft, nothing else (docs/03 §3.7).////// `landmark` is prose ("below the mill, where the air tastes of copper"),/// `depth` the floor number, `draft` the airflow cue ("third landing where/// the bell-rope hangs"). NO coordinates, NO compass rose — compasses don't/// exist. Takes prose strings, never sim cells, so a coordinate pattern can/// only arrive if the caller leaks one (tested: this function emits none of/// its own).#[must_use]pub fn directions_card(landmark: &str, depth: u8, draft: &str) -> String { "go: ".to_owned() + landmark + "\ndepth: floor " + &depth.to_string() + "\ndraft: " + draft + "\n(no compass — chalk your way, read the draft, ask the log)"}
#[cfg(test)]mod tests { use super::*;
fn quad(title: &str, pay: u32, tier: u8, poster: &str) -> (String, u32, u8, String) { (title.to_owned(), pay, tier, poster.to_owned()) }
#[test] fn board_lists_each_contract_with_pay_and_poster() { let rows = vec![ quad("rats in the granary", 25, 1, "Miller Odo of Milthollow"), quad("the Deep door", 500, 5, "Warden-Commander Ilse"), ]; let s = board_list(&rows); for (title, pay, _, poster) in &rows { assert!(s.contains(title.as_str()), "lists {title:?}: {s:?}"); assert!(s.contains(&format!("{pay}cu")), "prices {title:?}: {s:?}"); assert!( s.contains(poster.as_str()), "names the poster (gossip seed) {title:?}: {s:?}" ); } assert!(s.contains("notice board"), "frames the board: {s:?}"); }
#[test] fn board_tier_marks_run_one_to_five() { let rows = vec![ quad("a", 1, 1, "p"), quad("b", 1, 2, "p"), quad("c", 1, 3, "p"), quad("d", 1, 4, "p"), quad("e", 1, 5, "p"), ]; let s = board_list(&rows); for mark in ["[I]", "[II]", "[III]", "[IV]", "[V]"] { assert!(s.contains(mark), "tier mark {mark}: {s:?}"); } }
#[test] fn board_empty_is_honest_with_innkeep_nudge() { assert_eq!( board_list(&[]), "the board is bare (unusual — ask the innkeep)" ); }
#[test] fn board_bad_tier_marks_ignorance() { let s = board_list(&[quad("odd posting", 10, 9, "Somebody of Nowhere")]); assert!(s.contains("[?]"), "out-of-range tier is honest: {s:?}"); assert!(s.contains("odd posting"), "title still listed: {s:?}"); }
#[test] fn card_shows_title_pay_tier_poster_floor() { let s = contract_card( "Rats in the granary", 25, 1, "Miller Odo of Milthollow", &["cable-rats in the walls".to_owned()], "the miller does not know the nest hums; ask the ratcatchers", "bring a pole and chalk", 1, ); for bit in [ "Rats in the granary", "25cu", "[I]", "Miller Odo", "floor 1", ] { assert!(s.contains(bit), "card shows {bit:?}: {s:?}"); } }
#[test] fn card_splits_known_vs_unknown() { let s = contract_card( "Bell in the mere", 120, 2, "Warden Sarella of Copperwash", &["cold black water".to_owned()], "the poster does not know the bell keeps a sailor; ask the reed-cutters", "bring rope and a friend", 2, ); let known_at = s.find("KNOWN").expect("KNOWN section: {s:?}"); let unknown_at = s.find("UNKNOWN").expect("UNKNOWN section: {s:?}"); assert!(known_at < unknown_at, "KNOWN before UNKNOWN: {s:?}"); assert!(s.contains("cold black water"), "hazard in KNOWN: {s:?}"); let unknown_half = &s[unknown_at..]; assert!( unknown_half.contains("keeps a sailor"), "hint in UNKNOWN: {s:?}" ); assert!(s.contains("PREP"), "prep section present: {s:?}"); assert!(s.contains("rope and a friend"), "prep text shown: {s:?}"); }
#[test] fn card_empty_hazards_and_unknown_stay_honest() { let s = contract_card("Odd job", 10, 1, "Somebody", &[], "", "bring questions", 1); assert!(s.contains("KNOWN"), "KNOWN header survives: {s:?}"); assert!(s.contains("UNKNOWN"), "UNKNOWN header survives: {s:?}"); assert!( s.contains("ask around") || s.contains("ask why"), "honest fallback, never blank authority: {s:?}" ); }
#[test] fn directions_shows_landmark_depth_draft() { let s = directions_card( "below the mill, where the air tastes of copper", 3, "third landing where the bell-rope hangs", ); assert!(s.contains("below the mill"), "landmark: {s:?}"); assert!(s.contains("floor 3"), "depth: {s:?}"); assert!(s.contains("bell-rope"), "draft: {s:?}"); }
#[test] fn directions_has_no_compass_and_no_coordinates() { let s = directions_card("below the mill", 3, "third landing"); let lower = s.to_lowercase(); assert!( !lower.contains("compass") || lower.contains("no compass"), "never a compass rose (the disclaimer names its absence): {s:?}" ); for coord in [ "(0,", "(1,", "(3,", "x=", "x:", "N/E/S/W", "north", "south", "east", "west", ] { assert!( !s.contains(coord) && !lower.contains(&coord.to_lowercase()), "no coordinate/compass leak {coord:?}: {s:?}" ); } // Digit check: the ONLY digits allowed are the floor number itself. let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect(); assert_eq!(digits, "3", "only the floor number carries digits: {s:?}"); }
#[test] fn card_never_emits_coordinates_of_its_own() { let s = contract_card( "Rats", 25, 1, "Miller Odo", &["rats".to_owned()], "ask the ratcatchers", "bring a pole", 1, ); assert!(!s.contains("(1,"), "no cell tuples: {s:?}"); assert!(!s.contains("x="), "no axis leaks: {s:?}"); }}