//! 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, /// 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]); } }