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 · 348 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349//! Morrowind-style character sheet (docs/03-systems-design.md §3.1).//!//! "Feel: Morrowind character sheet meets NetHack inventory. You are fragile,//! specific, and weird by hour 5." The sheet therefore shows EXACT numbers —//! attributes 1–20, skills 0–100, faction ranks, town ledger, god-credit —//! and hides nothing behind vague tiers.//!//! Decoupling: this module takes plain slices and numbers and owns no//! `hexwork-core` / `hexwork-save` types — the UI must NOT depend on core//! character types yet. The parent maps `Character` + `Reputation` →//! [`SheetData`] at wiring (Phase 6 wiring plan §2)://!//! | `SheetData` field | Parent source (Phase 6 wiring plan §2) |//! |-------------------|------------------------------------------|//! | `name` | `Character` name |//! | `ancestry` | `Character` ancestry display name |//! | `calling` | `Character` calling display name |//! | `attrs` | `Character` attributes, Brawn/Finesse/Wits/Nerve/Aether order |//! | `skills` | `Character` skills, canonical 14-skill order (see below) |//! | `factions` | `Reputation` ranks, Lampwrights/Congregation/Wardens/Audit/Choir order |//! | `ledger` | `Reputation::ledger(current_town_id)` |//! | `god_credit` | sum of the five `Reputation` god credits |//!//! Pure string building only: no I/O, no terminal calls, no RNG. Every line//! is clamped to `w` chars (never split mid-glyph); `w == 0` yields `""`.
/// Ten-step bar glyphs, index = filled cells 0–10.const BAR: [&str; 11] = [ "..........", "#.........", "##........", "###.......", "####......", "#####.....", "######....", "#######...", "########..", "#########.", "##########",];
/// Character sheet snapshot; see the module docs for the parent mapping/// table. Slices arrive in canonical order so this module never needs core/// types: 5 attributes, 14 skills, 5 factions.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct SheetData<'a> { /// Delver name; empty renders as `"Nameless"` (never a blank header). pub name: &'a str, /// Ancestry display name (e.g. `"Mudborn"`). pub ancestry: &'a str, /// Calling display name (e.g. `"Ratcatcher"`). pub calling: &'a str, /// Five attributes in Brawn, Finesse, Wits, Nerve, Aether order (1–20). pub attrs: [(&'a str, u8); 5], /// Fourteen skills in canonical order (docs/03 §3.1: Climb, Swim, Sneak, /// Search, Barter, Mend, Tinker, Hymns, Physick, Butcher, Ropework, /// Dungeoneering, Dead Tongues, Copperlore; 0–100). pub skills: [(&'a str, u8); 14], /// Five faction ranks in Lampwrights, Congregation, Wardens, Audit, /// Choir order (docs/02 §4). pub factions: [(&'a str, i8); 5], /// Town ledger for the current town: positive = trusted, negative = /// wanted. Parent passes `Reputation::ledger(current_town_id)`. pub ledger: i16, /// Net god-credit: negative = owed, or angry. Parent passes the sum of /// the five `Reputation` god credits (docs/02 §2). pub god_credit: i16,}
/// Attribute bar: value 1–20 → 10-char bar (`ceil(v/2)` cells; out-of-range/// values clamp, so a cursed 0 shows empty and 21+ shows full).#[must_use]pub fn attr_bar(v: u8) -> &'static str { let filled = u16::from(v).saturating_mul(10).div_ceil(20).min(10) as usize; BAR[filled]}
/// Skill bar: value 0–100 → 10-char bar (`ceil(v/10)` cells; clamps at 100,/// so trainer-overflow values still render a full bar, never panic).#[must_use]pub fn skill_bar(v: u8) -> &'static str { let filled = u16::from(v).saturating_mul(10).div_ceil(100).min(10) as usize; BAR[filled]}
/// Render a boxed Morrowind-style sheet: name/ancestry/calling header, an/// ATTRIBUTES block (value + bar), a SKILLS block (value + bar), and a/// STANDING block (faction ranks with exact numbers, town ledger,/// god-credit). Every line is exactly `w` chars for `w >= 4` (content is/// truncated, never wrapped); smaller widths yield truncated lines of at/// most `w` chars; `w == 0` yields `""`.#[must_use]pub fn render_sheet(d: &SheetData<'_>, w: usize) -> String { if w == 0 { return String::new(); } let mut lines: Vec<String> = Vec::new(); lines.push(border(w)); lines.push(row( if d.name.is_empty() { "Nameless" } else { d.name }, w, )); if d.ancestry.is_empty() && d.calling.is_empty() { lines.push(row("lineage unrecorded", w)); } else { lines.push(row( &format!("{} · {}", or_unknown(d.ancestry), or_unknown(d.calling)), w, )); } lines.push(rule("ATTRIBUTES", w)); for (label, v) in &d.attrs { lines.push(row(&format!("{label:<12} {v:>2} [{}]", attr_bar(*v)), w)); } lines.push(rule("SKILLS", w)); for (label, v) in &d.skills { lines.push(row(&format!("{label:<14} {v:>3} [{}]", skill_bar(*v)), w)); } lines.push(rule("STANDING", w)); for (label, r) in &d.factions { lines.push(row(&format!("{label:<14} {r:>+3} {}", rank_word(*r)), w)); } lines.push(row(&format!("Town ledger: {:+}", d.ledger), w)); lines.push(row(&format!("God-credit: {:+}", d.god_credit), w)); lines.push(border(w)); let mut out = lines.join("\n"); out.push('\n'); out}
/// Empty display strings read as `"unknown"` (a half-built chargen pick/// still renders a legible line).fn or_unknown(s: &str) -> &str { if s.is_empty() { "unknown" } else { s }}
/// Epithet for a faction rank. Flavor only — the exact number is always/// shown alongside (hour-5 law: no hiding).fn rank_word(r: i8) -> &'static str { match r { ..=-4 => "hated", -3..=-2 => "despised", -1 => "distrusted", 0 => "unknown", 1..=2 => "known", 3..=4 => "esteemed", _ => "exalted", }}
/// Truncate `s` to at most `w` chars (never split mid-glyph).fn fit(s: &str, w: usize) -> String { s.chars().take(w).collect()}
/// Full-width border: `+---+` of exactly `w` chars (`w >= 1`).fn border(w: usize) -> String { if w == 1 { return "+".to_owned(); } format!("+{}+", "-".repeat(w.saturating_sub(2)))}
/// Section divider: `+-- TITLE -----+` fitted to exactly `w` chars.fn rule(title: &str, w: usize) -> String { if w <= 1 { return border(w); } let inner = w - 1; // reserve the closing '+' let head: String = format!("+-- {title} ").chars().take(inner).collect(); let dashes = "-".repeat(inner.saturating_sub(head.chars().count())); format!("{head}{dashes}+")}
/// Body row: `| content |` fitted to exactly `w` chars (`w >= 4`);/// degenerate widths fall back to a bare truncated line.fn row(content: &str, w: usize) -> String { if w < 4 { return fit(content, w); } let inner = w - 4; let body: String = content.chars().take(inner).collect(); let pad = inner.saturating_sub(body.chars().count()); format!("| {body}{}|", " ".repeat(pad))}
#[cfg(test)]mod tests { use super::*;
fn sample() -> SheetData<'static> { SheetData { name: "Tomm", ancestry: "Mudborn", calling: "Ratcatcher", attrs: [ ("Brawn", 12), ("Finesse", 9), ("Wits", 11), ("Nerve", 10), ("Aether", 6), ], skills: [ ("Climb", 35), ("Swim", 20), ("Sneak", 40), ("Search", 30), ("Barter", 25), ("Mend", 15), ("Tinker", 10), ("Hymns", 5), ("Physick", 12), ("Butcher", 22), ("Ropework", 33), ("Dungeoneering", 28), ("Dead Tongues", 8), ("Copperlore", 18), ], factions: [ ("Lampwrights", 2), ("Congregation", -1), ("Wardens", 0), ("Audit", -3), ("Choir", 1), ], ledger: 20, god_credit: -5, } }
#[test] fn header_shows_name_ancestry_calling() { let sheet = render_sheet(&sample(), 60); assert!(sheet.contains("Tomm"), "name must be in the header"); assert!(sheet.contains("Mudborn"), "ancestry must be in the header"); assert!( sheet.contains("Ratcatcher"), "calling must be in the header" ); }
#[test] fn all_five_attrs_rendered_with_exact_numbers() { let sheet = render_sheet(&sample(), 60); for (label, v) in sample().attrs { assert!(sheet.contains(label), "attr {label} must render"); assert!( sheet.contains(&v.to_string()), "attr {label} value {v} must show exactly" ); } }
#[test] fn all_fourteen_skills_rendered() { let sheet = render_sheet(&sample(), 60); let skills = sample().skills; assert_eq!(skills.len(), 14, "spec fixes 14 skills"); for (label, _) in skills { assert!(sheet.contains(label), "skill {label} must render"); } }
#[test] fn skill_bars_pinned() { assert_eq!(skill_bar(0), ".........."); assert_eq!(skill_bar(50), "#####....."); assert_eq!(skill_bar(100), "##########"); }
#[test] fn attr_bars_pinned() { assert_eq!(attr_bar(1), "#........."); assert_eq!(attr_bar(10), "#####....."); assert_eq!(attr_bar(20), "##########"); }
#[test] fn bars_clamp_out_of_range() { assert_eq!(attr_bar(0), "..........", "cursed 0 shows empty"); assert_eq!(attr_bar(u8::MAX), "##########", "overflow shows full"); assert_eq!(skill_bar(u8::MAX), "##########", "never panics"); }
#[test] fn every_line_fits_width() { for w in [10, 20, 40, 80] { let sheet = render_sheet(&sample(), w); assert!(!sheet.is_empty(), "width {w} must still render"); for line in sheet.lines() { assert!( line.chars().count() <= w, "line exceeds width {w}: {line:?}" ); } } }
#[test] fn zero_width_is_empty() { assert_eq!(render_sheet(&sample(), 0), ""); }
#[test] fn empty_name_becomes_nameless() { let mut d = sample(); d.name = ""; let sheet = render_sheet(&d, 60); assert!( sheet.contains("Nameless"), "empty name must not print blank" ); }
#[test] fn empty_origin_stays_legible() { let mut d = sample(); d.ancestry = ""; d.calling = ""; let sheet = render_sheet(&d, 60); assert!(sheet.contains("lineage unrecorded")); }
#[test] fn ledger_and_credit_lines_present() { let sheet = render_sheet(&sample(), 60); assert!(sheet.contains("ledger"), "town ledger line must render"); assert!(sheet.contains("+20"), "ledger value must show exactly"); assert!(sheet.contains("credit"), "god-credit line must render"); assert!(sheet.contains("-5"), "credit value must show exactly"); }
#[test] fn faction_ranks_show_sign_and_number() { let sheet = render_sheet(&sample(), 60); assert!(sheet.contains("+2"), "positive ranks show a sign"); assert!(sheet.contains("-1"), "negative ranks show exactly"); assert!(sheet.contains("-3"), "Audit grudge shows exactly"); }}