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.
21 kB · 567 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568//! Reputation IS progression (docs/03-systems-design.md §3.1).//!//! There are no levels and no XP field anywhere: a delver is measured by//! faction ranks, town ledgers, god-credit, and lineage knowledge (the last//! lives in `hexwork-save`, docs/03 §3.9). Ancestry/calling nudges apply//! parent-side (chargen/State); this module only stores and adjusts numbers.//!//! Design traces://! - 5 factions, docs/02-lore-bible.md §4 (Elder-Scrolls law: joinable,//! scheming, mutually exclusive at the top).//! - 5 Small Gods, docs/02 §2. Gods are transactional: prayer is a bargain,//! piety is a credit score, blasphemy is debt collection.//! - Town ledgers, docs/02 §4 + §3: steal in Milthollow and Copperwash hears//! in 3 days. Ledgers follow the DELVER (grudges remember faces); gossip//! propagation is a parent/Phase-9 concern, not stored here.//!//! Core determinism law: pure functions only. `BTreeMap`, no RNG, no time,//! no I/O, no threads. All ops are bounds-checked and deterministic.
use std::collections::BTreeMap;
/// Minimum faction rank: hated.pub const MIN_RANK: i8 = -5;/// Maximum faction rank: exalted (and mutually exclusive at the top).pub const MAX_RANK: i8 = 5;/// Rank above which the exclusivity rule bites (docs/02 §4).pub const RIVALRY_THRESHOLD: i8 = 3;/// Ledger points per ±1 of barter modifier (see [`Reputation::barter_mod`]).pub const LEDGER_PER_BARTER_STEP: i16 = 10;/// Canonical town id for the starter town (docs/02 §3, Vale of Milthollow).pub const MILTHOLLOW: &str = "milthollow";
/// The five joinable, scheming factions (docs/02 §4).////// Discriminants are array indices into [`Reputation::factions`]; do not/// reorder without migrating saves.#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]pub enum Faction { /// Maintain lantern-law; map the Works. Hates cable-strippers. Lampwrights = 0, /// Strip everything, feed the poor. Hates the Audit and all tolls. Congregation = 1, /// Keep the Deep shut. Hates delvers who go below floor 8. Wardens = 2, /// Back-rent on all salvage, plus interest. Hated reciprocally. Audit = 3, /// Finish the resurrection. Hates the Wardens, and fire. Choir = 4,}
/// The five Small Gods (docs/02 §2): fragments of the Core's attention.////// Discriminants are array indices into [`Reputation::god_credit`].#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]pub enum GodId { /// Mercy, light, owed debts. Lamplighter = 0, /// Passage, contracts, exact change. Tollkeeper = 1, /// Grief, water, the unfinished dead. Choir = 2, /// Decay, thrift: waste nothing, not even you. Rustmother = 3, /// A tax-collector archangel of the old works. Audit = 4,}
impl Faction { /// Stable persistence tag; save discriminants and canonical bytes agree. #[must_use] pub const fn canonical_tag(self) -> u8 { match self { Self::Lampwrights => 0, Self::Congregation => 1, Self::Wardens => 2, Self::Audit => 3, Self::Choir => 4, } }}
impl GodId { /// Stable persistence tag; save discriminants and canonical bytes agree. #[must_use] pub const fn canonical_tag(self) -> u8 { match self { Self::Lamplighter => 0, Self::Tollkeeper => 1, Self::Choir => 2, Self::Rustmother => 3, Self::Audit => 4, } }}
/// Standing with one faction: rank plus Audit arrears.////// `debt_cu` is copper-unit arrears and may go negative, which means credit/// (the delver has overpaid or holds a receipt). Only the Audit collects;/// other factions ignore this field.#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]pub struct Standing { /// -5 (hated) ..= +5 (exalted). Starts 0. pub rank: i8, /// Audit arrears in copper units; negative = credit. pub debt_cu: i32,}
/// Reputation: faction ranks, town ledgers, god-credit (docs/03 §3.1).////// Per-delver record. Town ledgers remember faces, so they do NOT merge/// across a lineage; maps/recipes/translations do (see `hexwork-save`).#[derive(Clone, Debug)]pub struct Reputation { /// Fixed order: Lampwrights, Congregation, Wardens, Audit, Choir. pub factions: [(Faction, Standing); 5], /// Town id → ledger. Positive = trusted, negative = wanted. /// Canonical starter key is [`MILTHOLLOW`] (`"milthollow"`); keys are /// exact-match (case-sensitive) so gossip code cannot blur two towns. pub town_ledger: BTreeMap<String, i16>, /// Fixed order: Lamplighter, Tollkeeper, Choir, Rustmother, Audit. pub god_credit: [(GodId, i16); 5],}
impl Reputation { /// All-zero reputation: unknown everywhere, trusted nowhere. #[must_use] pub fn new() -> Self { Self { factions: [ (Faction::Lampwrights, Standing::default()), (Faction::Congregation, Standing::default()), (Faction::Wardens, Standing::default()), (Faction::Audit, Standing::default()), (Faction::Choir, Standing::default()), ], town_ledger: BTreeMap::new(), god_credit: [ (GodId::Lamplighter, 0), (GodId::Tollkeeper, 0), (GodId::Choir, 0), (GodId::Rustmother, 0), (GodId::Audit, 0), ], } }
fn standing(&self, faction: Faction) -> &Standing { &self.factions[faction as usize].1 }
fn standing_mut(&mut self, faction: Faction) -> &mut Standing { &mut self.factions[faction as usize].1 }
/// Rivalry table (docs/02 §4 "mutually exclusive at the top"): /// /// | Faction | Rivals | Why | /// |--------------|-------------------------------|--------------------------------------| /// | Lampwrights | Congregation | lantern-law vs live-cable stripping | /// | Congregation | Lampwrights, Audit | no-questions fences vs law and rent | /// | Wardens | Choir | keep the Deep shut vs finish it | /// | Choir | Wardens | resurrection vs the Seal (and fire) | /// | Audit | Congregation | back-rent vs debt-free salvage | /// /// The Audit is not rival to *everyone*: it sells legal immunity and /// auction access to all comers, so only the Congregation (which feeds /// the poor off unregistered salvage) is a structural rival. #[must_use] pub fn rivals(faction: Faction) -> &'static [Faction] { match faction { Faction::Lampwrights => &[Faction::Congregation], Faction::Congregation => &[Faction::Lampwrights, Faction::Audit], Faction::Wardens => &[Faction::Choir], Faction::Choir => &[Faction::Wardens], Faction::Audit => &[Faction::Congregation], } }
/// Raise or lower a faction rank, clamped to ±5 (deterministic, no RNG). /// /// Exclusivity at the top: when this adjustment *strictly raises* the /// faction above +3, every rival currently above +3 is pushed down 1. /// The push never cascades (rivals of rivals are untouched) and never /// fires on a zero/negative delta or on a clamped no-op, so standing /// still can never bleed a rival. pub fn adjust_faction(&mut self, faction: Faction, delta: i8) { let old = self.rank(faction); let next = (i16::from(old) + i16::from(delta)) .clamp(i16::from(MIN_RANK), i16::from(MAX_RANK)) as i8; self.standing_mut(faction).rank = next; if next > old && next > RIVALRY_THRESHOLD { for rival in Self::rivals(faction) { let standing = self.standing_mut(*rival); if standing.rank > RIVALRY_THRESHOLD { standing.rank -= 1; } } } }
/// Adjust Audit arrears (saturating). Negative delta overpays into credit. pub fn adjust_debt(&mut self, faction: Faction, delta: i32) { let standing = self.standing_mut(faction); standing.debt_cu = standing.debt_cu.saturating_add(delta); }
/// Adjust a town ledger entry (saturating). Missing towns start at 0. pub fn adjust_ledger(&mut self, town: &str, delta: i16) { let entry = self.town_ledger.entry(town.to_owned()).or_insert(0); *entry = entry.saturating_add(delta); }
/// Adjust god-credit (saturating). Blasphemy is a big negative delta /// (docs/02 §2: blasphemy = debt collection, at coordinates of the /// god's choosing — collection itself is parent-side). pub fn adjust_god(&mut self, god: GodId, delta: i16) { let slot = &mut self.god_credit[god as usize].1; *slot = slot.saturating_add(delta); }
/// Current faction rank. #[must_use] pub fn rank(&self, faction: Faction) -> i8 { self.standing(faction).rank }
/// Current arrears/credit with a faction (meaningful for the Audit). #[must_use] pub fn debt(&self, faction: Faction) -> i32 { self.standing(faction).debt_cu }
/// Current town ledger (0 for unknown towns). #[must_use] pub fn ledger(&self, town: &str) -> i16 { self.town_ledger.get(town).copied().unwrap_or(0) }
/// Current god-credit (negative = the god is owed, or is angry). #[must_use] pub fn credit(&self, god: GodId) -> i16 { self.god_credit[god as usize].1 }
/// True when this faction is held above +3 while a rival is also above /// +3: the standing is contested, and further raises will bleed the /// rival (see [`Reputation::adjust_faction`]). This is the honest, /// observable form of "mutually exclusive at the top". #[must_use] pub fn capped(&self, faction: Faction) -> bool { self.rank(faction) > RIVALRY_THRESHOLD && Self::rivals(faction) .iter() .any(|rival| self.rank(*rival) > RIVALRY_THRESHOLD) }
/// Barter price modifier for trade in `town`. /// /// Formula: `ledger_part + faction_part`, where /// `ledger_part = (ledger / 10)` clamped to ±3 (integer division /// truncates toward zero), and `faction_part = (best faction rank / 2)` /// clamped to ±2, best = highest rank across all five factions. /// Result range is ±5. Positive means the delver gets better prices. /// /// Ancestry nudges (e.g. Mudborn/Cant-born haggling, docs/03 §3.1) /// apply parent-side on top of this number, never inside it. #[must_use] pub fn barter_mod(&self, town: &str) -> i8 { let ledger_part = (self.ledger(town) / LEDGER_PER_BARTER_STEP).clamp(-3, 3) as i8; let best = self .factions .iter() .map(|(_, standing)| standing.rank) .max() .unwrap_or(0); let faction_part = (best / 2).clamp(-2, 2); ledger_part + faction_part }}
impl Default for Reputation { fn default() -> Self { Self::new() }}
#[cfg(test)]mod tests { use super::*;
fn rep() -> Reputation { Reputation::new() }
#[test] fn default_is_all_zero() { let r = rep(); for faction in [ Faction::Lampwrights, Faction::Congregation, Faction::Wardens, Faction::Audit, Faction::Choir, ] { assert_eq!(r.rank(faction), 0, "{faction:?} should start at 0"); assert_eq!(r.debt(faction), 0, "{faction:?} debt should start at 0"); } assert_eq!(r.ledger(MILTHOLLOW), 0); assert_eq!(r.ledger("copperwash"), 0); for god in [ GodId::Lamplighter, GodId::Tollkeeper, GodId::Choir, GodId::Rustmother, GodId::Audit, ] { assert_eq!(r.credit(god), 0, "{god:?} credit should start at 0"); } assert!(r.town_ledger.is_empty()); }
#[test] fn faction_clamps_at_top() { let mut r = rep(); r.adjust_faction(Faction::Lampwrights, 100); assert_eq!(r.rank(Faction::Lampwrights), MAX_RANK); r.adjust_faction(Faction::Lampwrights, 5); assert_eq!(r.rank(Faction::Lampwrights), MAX_RANK); }
#[test] fn faction_clamps_at_bottom() { let mut r = rep(); r.adjust_faction(Faction::Audit, i8::MIN); assert_eq!(r.rank(Faction::Audit), MIN_RANK); r.adjust_faction(Faction::Audit, -5); assert_eq!(r.rank(Faction::Audit), MIN_RANK); }
#[test] fn faction_adjust_is_signed_step() { let mut r = rep(); r.adjust_faction(Faction::Wardens, 2); r.adjust_faction(Faction::Wardens, -1); assert_eq!(r.rank(Faction::Wardens), 1); }
#[test] fn ledger_saturates_at_bounds() { let mut r = rep(); r.adjust_ledger(MILTHOLLOW, i16::MAX); assert_eq!(r.ledger(MILTHOLLOW), i16::MAX); r.adjust_ledger(MILTHOLLOW, 1); assert_eq!(r.ledger(MILTHOLLOW), i16::MAX); r.adjust_ledger("gallowgate", i16::MIN); assert_eq!(r.ledger("gallowgate"), i16::MIN); r.adjust_ledger("gallowgate", -1); assert_eq!(r.ledger("gallowgate"), i16::MIN); }
#[test] fn ledger_is_keyed_by_exact_town_id() { let mut r = rep(); r.adjust_ledger(MILTHOLLOW, 20); r.adjust_ledger("copperwash", -10); assert_eq!(r.ledger(MILTHOLLOW), 20); assert_eq!(r.ledger("copperwash"), -10); assert_eq!(r.ledger("Milthollow"), 0, "keys are exact-match"); assert_eq!(r.ledger("barrowfield"), 0, "unknown towns read 0"); }
#[test] fn god_credit_saturates() { let mut r = rep(); r.adjust_god(GodId::Lamplighter, i16::MAX); assert_eq!(r.credit(GodId::Lamplighter), i16::MAX); r.adjust_god(GodId::Lamplighter, 100); assert_eq!(r.credit(GodId::Lamplighter), i16::MAX); }
#[test] fn god_blasphemy_goes_deeply_negative() { let mut r = rep(); r.adjust_god(GodId::Choir, -1000); assert_eq!(r.credit(GodId::Choir), -1000); assert!(r.credit(GodId::Choir) < 0, "blasphemy must read negative"); r.adjust_god(GodId::Choir, i16::MIN); assert_eq!(r.credit(GodId::Choir), i16::MIN, "saturates, never wraps"); }
#[test] fn debt_can_go_negative_as_credit() { let mut r = rep(); r.adjust_debt(Faction::Audit, 500); assert_eq!(r.debt(Faction::Audit), 500); r.adjust_debt(Faction::Audit, -800); assert_eq!(r.debt(Faction::Audit), -300, "overpay becomes credit"); r.adjust_debt(Faction::Audit, i32::MIN); assert_eq!(r.debt(Faction::Audit), i32::MIN, "saturates, never wraps"); }
#[test] fn barter_baseline_is_zero() { assert_eq!(rep().barter_mod(MILTHOLLOW), 0); }
#[test] fn barter_ledger_steps_and_clamps() { let mut r = rep(); r.adjust_ledger(MILTHOLLOW, 25); assert_eq!(r.barter_mod(MILTHOLLOW), 2, "25/10 truncates to +2"); r.adjust_ledger(MILTHOLLOW, 5); assert_eq!(r.barter_mod(MILTHOLLOW), 3, "30/10 = +3"); r.adjust_ledger(MILTHOLLOW, 100); assert_eq!(r.barter_mod(MILTHOLLOW), 3, "ledger part clamps at +3"); r.adjust_ledger("gallowgate", -45); assert_eq!( r.barter_mod("gallowgate"), -3, "-45/10 = -4 truncates, clamps to -3" ); }
#[test] fn barter_faction_half_steps() { let mut r = rep(); r.adjust_faction(Faction::Lampwrights, 1); assert_eq!(r.barter_mod(MILTHOLLOW), 0, "rank 1/2 truncates to 0"); r.adjust_faction(Faction::Lampwrights, 3); assert_eq!(r.barter_mod(MILTHOLLOW), 2, "rank 4/2 = +2"); }
#[test] fn barter_faction_part_clamps_when_all_hated() { let mut r = rep(); for faction in [ Faction::Lampwrights, Faction::Congregation, Faction::Wardens, Faction::Audit, Faction::Choir, ] { r.adjust_faction(faction, -4); } assert_eq!(r.barter_mod(MILTHOLLOW), -2, "best -4/2 clamps at -2"); }
#[test] fn barter_combines_ledger_and_faction() { let mut r = rep(); r.adjust_ledger(MILTHOLLOW, 30); r.adjust_faction(Faction::Lampwrights, 4); assert_eq!(r.barter_mod(MILTHOLLOW), 5, "+3 ledger, +2 faction"); }
#[test] fn rivalry_pushes_rival_down_past_three() { let mut r = rep(); r.adjust_faction(Faction::Congregation, 4); r.adjust_faction(Faction::Lampwrights, 4); assert_eq!(r.rank(Faction::Lampwrights), 4); assert_eq!( r.rank(Faction::Congregation), 3, "rival above +3 is pushed down 1" ); }
#[test] fn rivalry_quiet_at_or_below_three() { let mut r = rep(); r.adjust_faction(Faction::Congregation, 5); r.adjust_faction(Faction::Lampwrights, 3); assert_eq!(r.rank(Faction::Lampwrights), 3); assert_eq!( r.rank(Faction::Congregation), 5, "raising exactly to +3 must not bleed a rival" ); }
#[test] fn rivalry_needs_a_strict_raise() { let mut r = rep(); r.adjust_faction(Faction::Lampwrights, 4); r.adjust_faction(Faction::Congregation, 4); assert_eq!(r.rank(Faction::Lampwrights), 3); assert_eq!(r.rank(Faction::Congregation), 4); r.adjust_faction(Faction::Lampwrights, 0); assert_eq!( r.rank(Faction::Congregation), 4, "a zero delta must not push anyone" ); r.adjust_faction(Faction::Lampwrights, -1); assert_eq!( r.rank(Faction::Congregation), 4, "lowering must not push anyone" ); }
#[test] fn rivalry_audit_bleeds_congregation() { let mut r = rep(); r.adjust_faction(Faction::Congregation, 4); r.adjust_faction(Faction::Audit, 5); assert_eq!(r.rank(Faction::Audit), 5); assert_eq!(r.rank(Faction::Congregation), 3); }
#[test] fn capped_flags_contested_top() { assert!(!rep().capped(Faction::Lampwrights)); let mut r = rep(); r.adjust_faction(Faction::Lampwrights, 5); assert!(!r.capped(Faction::Lampwrights), "no rival held high yet"); r.adjust_faction(Faction::Congregation, 5); assert_eq!(r.rank(Faction::Congregation), 5); assert_eq!(r.rank(Faction::Lampwrights), 4, "rival bled to 4"); assert!( r.capped(Faction::Congregation), "rank 5 with a rival at 4 is contested" ); assert!( r.capped(Faction::Lampwrights), "rank 4 with a rival at 5 is contested" ); assert!( !r.capped(Faction::Wardens), "uninvolved factions stay clear" ); }
#[test] fn rivalry_does_not_cascade() { // Raising Lampwrights past +3 must bleed Congregation (rival) but // leave Wardens untouched (rival-of-nothing-here, docs/02 §4). let mut r = rep(); r.adjust_faction(Faction::Wardens, 4); r.adjust_faction(Faction::Congregation, 4); r.adjust_faction(Faction::Lampwrights, 5); assert_eq!(r.rank(Faction::Lampwrights), 5); assert_eq!(r.rank(Faction::Congregation), 3, "rival bled once"); assert_eq!( r.rank(Faction::Wardens), 4, "non-rival must be untouched (no cascade)" ); }
#[test] fn rivals_table_matches_lore() { assert_eq!( Reputation::rivals(Faction::Lampwrights), &[Faction::Congregation] ); assert_eq!( Reputation::rivals(Faction::Congregation), &[Faction::Lampwrights, Faction::Audit] ); assert_eq!(Reputation::rivals(Faction::Wardens), &[Faction::Choir]); assert_eq!(Reputation::rivals(Faction::Choir), &[Faction::Wardens]); assert_eq!(Reputation::rivals(Faction::Audit), &[Faction::Congregation]); }}