//! 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 = 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"); } }