//! Town services as priced actions: inn, tinker, lampwright, warden post, //! menders, fence, auction. //! //! Design traces: //! - docs/03-systems-design.md §3.6 (services: inn with sleep = world //! advances; tinker identify/mend/copy; lampwright lamps/oil/scrip; //! warden post bounties + seal-law; menders healing + curse triage; //! fences via the Congregation; Audit auction where you buy back your own //! confiscated loot — humiliating, beloved; Audit sells debt to collectors //! who visit dungeon floors). //! - docs/03 §3.2 PREP (sleep = world advances: factions move, bounties age, //! corpses rot, rivals delve). //! - docs/03 §3.4 (remove-curse is a PERSON — the Corpse-tender, who keeps a //! garden watered with holy runoff; bring her something that knew the //! deceased. Menders only triage curses; the split is load-bearing). //! - docs/02-lore-bible.md §5 (oil scrip: drinking money is burning money). //! //! Ownership notes: //! - Pure logic only: these functions PRICE actions. Coin movement, FarField //! advance, bounty postings, pack mutation, and collector spawns are //! parent-side (State applies; see the wiring plan). Nothing here touches //! [`crate::reputation`] records directly — the parent calls //! `adjust_ledger` / `barter_mod` / `adjust_debt` around these prices. //! - Tinker honesty bands live in `hexwork_content::identify::tinker_identify` //! (fair / half-fair-lies / refuse); the 15cu here is the `fair_cu` the //! parent passes in. Never reimplement the bands here. //! - Core determinism law: pure functions only. No RNG, no time, no I/O, no //! threads. All arithmetic saturates or widens. MSRV 1.75. // Intent constants: every number below is pinned by a test in this file. /// Inn sleep: 8cu a night (docs/03 §3.6 — and the night moves the world). pub const INN_SLEEP_CU: u32 = 8; /// Tinker identify: 15cu fair price (docs/03 §3.4 — lies if underpaid). pub const TINKER_IDENTIFY_CU: u32 = 15; /// Lampwright lamp: 50cu (or 10 scrip — same value, scrip IS oil money). pub const LAMP_PRICE_CU: u32 = 50; /// Lampwright lamp in oil scrip: 50cu / 5cu-per-scrip = 10 scrip. pub const LAMP_PRICE_SCRIP: u32 = 10; /// Lampwright oil flask: 5cu = 1 scrip (the 1:1 scrip exchange — one scrip /// note buys one flask because both ARE 5cu of lamp oil, docs/02 §5). pub const OIL_FLASK_CU: u32 = 5; /// Oil flask in scrip: 1 (see [`OIL_FLASK_CU`]). pub const OIL_FLASK_SCRIP: u32 = 1; /// Mender healing: 10cu (stitches, poultices, honest work). pub const MENDER_HEAL_CU: u32 = 10; /// Mender curse triage: 40cu — and triage is ALL the mender does (docs/03 /// §3.4 split: removal is the Corpse-tender's garden, see /// [`garden_removes_curse`]; the mender stabilizes, names the price of a /// cure, and points at the garden). pub const MENDER_TRIAGE_CU: u32 = 40; /// Fence payout rate: 60% of base value, no questions asked. pub const FENCE_RATE_PCT: u32 = 60; /// Auction humiliation premium: +10% over the hammer price to buy back your /// own confiscated loot (docs/03 §3.9 lineage vault: gear does not persist — /// the Audit auctions it). pub const AUCTION_FEE_PCT: u32 = 10; /// The seven town services (docs/03 §3.6; Milthollow's set in towns.toml). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Service { /// Coaching inn (*The Gilded Whetstone*): sleep 8cu, world advances. Inn, /// Tinker stall: identify 15cu fair (underpaid bands lie — parent-side /// `identify::tinker_identify` owns the fib table). Tinker, /// Lampwright: lamps, oil, scrip — scrip IS oil money (1:1). Lampwright, /// Warden post: bounties + seal-law. Pinning a notice costs nothing; /// bounty RATES live parent-side on contract `pay_cu`. Warden, /// Menders: healing 10cu + curse triage 40cu (NOT removal — garden). Mender, /// Congregation fence: 60% of value, no questions, buys anything /// including Audit-illegal goods (the spread is the fee; the silence is /// free). Fence, /// Audit auction: buy back your own confiscated loot at +10% (beloved). Auction, } impl Service { /// Stable canonical tag for wire and canonical-hash encoding. Zero is /// reserved for "no service"; the save wire table delegates here so the /// journal and the pending-action hash cannot drift. #[must_use] pub const fn canonical_tag(self) -> u8 { match self { Self::Inn => 1, Self::Tinker => 2, Self::Lampwright => 3, Self::Warden => 4, Self::Mender => 5, Self::Fence => 6, Self::Auction => 7, } } } /// A service's price in both tenders: `copper` in copper units, `scrip` in /// oil-scrip notes. A zero tender means "not priced in that tender" (till /// policy is parent-side); it never means free — see each variant's docs. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ServicePrice { /// Copper-unit price (0 = no copper tender priced). pub copper: u32, /// Oil-scrip price (0 = no scrip tender priced). pub scrip: u32, } /// Headline price for a service (pinned per variant, tested). /// /// - Inn: 8cu sleep ([`INN_SLEEP_CU`] — the night moves the world; the /// parent advances FarField by [`sleep_ticks`]). /// - Tinker: 15cu identify ([`TINKER_IDENTIFY_CU`] — the `fair_cu` the /// parent passes to `identify::tinker_identify`; underpaid bands lie or /// refuse THERE). /// - Lampwright: lamp 50cu / 10 scrip (same value: 10 × 5cu — the Guild /// takes either, because scrip IS oil money); oil flask 5cu = 1 scrip /// ([`OIL_FLASK_CU`] / [`OIL_FLASK_SCRIP`], the 1:1 exchange). /// - Warden: 0/0 — posting bounties is free; bounty rates live parent-side /// on contract pay. /// - Mender: heal 10cu ([`MENDER_HEAL_CU`]); curse triage 40cu /// ([`MENDER_TRIAGE_CU`]) — triage only, removal is the garden. /// - Fence: 0/0 — no fee, no questions; profit is the 40% spread (see /// [`fence_offer`]: pays 60% of base, buys anything incl. illegal). /// - Auction: 0/0 — no entry fee; the house takes +10% at the hammer (see /// [`auction_buyback`]: your own sword costs you 110%). #[must_use] pub fn price(s: Service) -> ServicePrice { match s { Service::Inn => ServicePrice { copper: INN_SLEEP_CU, scrip: 0, }, Service::Tinker => ServicePrice { copper: TINKER_IDENTIFY_CU, scrip: 0, }, Service::Lampwright => ServicePrice { copper: LAMP_PRICE_CU, scrip: LAMP_PRICE_SCRIP, }, Service::Warden | Service::Fence | Service::Auction => ServicePrice { copper: 0, scrip: 0, }, Service::Mender => ServicePrice { copper: MENDER_HEAL_CU, scrip: 0, }, } } /// Fence payout for a `base_cu` base value: 60% ([`FENCE_RATE_PCT`]), no /// questions, anything bought including Audit-illegal goods (docs/03 §3.6: /// the Congregation fence is the no-questions till). Truncates down; the /// 40% spread is the fence's fee. Saturates on absurd inputs (widens to /// `u64`, clamps to `u32::MAX`). #[must_use] pub fn fence_offer(base_cu: u32) -> u32 { let out = base_cu as u64 * u64::from(FENCE_RATE_PCT) / 100; out.min(u32::MAX as u64) as u32 } /// Auction buyback for a `hammer_cu` hammer price: +10% humiliation premium /// ([`AUCTION_FEE_PCT`]). This is the beloved line made arithmetic: the /// Audit confiscates your sword on death (docs/03 §3.9 — gear never /// persists), auctions it, and charges YOU 110% to hold it again. Saturates /// on absurd inputs. #[must_use] pub fn auction_buyback(hammer_cu: u32) -> u32 { let out = hammer_cu as u64 * (100 + u64::from(AUCTION_FEE_PCT)) / 100; out.min(u32::MAX as u64) as u32 } /// Ticks one inn sleep advances the world: 8640, one full day (docs/03 §3.2 /// PREP: sleep = world advances). The parent adds this to FarField and rots /// corpses, ages bounties, moves factions, and drains the gossip queue — /// THE sim-runs-without-you proof (wiring plan §2). #[must_use] pub fn sleep_ticks() -> u64 { 8640 } /// Corpse-tender garden gate (docs/03 §3.4: remove-curse is a PERSON, not a /// scroll — she keeps a garden watered with holy runoff). /// /// Bring her something that knew the deceased: a belonging of the deceased /// OR a memory (either suffices — `true`); with neither she can do nothing /// (`false`). The curse-clear on the `Instance` + consuming the offering are /// parent-side; this is the gate. #[must_use] pub fn garden_removes_curse(offered_memory: bool, offered_belonging: bool) -> bool { offered_memory || offered_belonging } /// Debt collectors for `debt_cu` copper units of Audit debt (docs/03 §3.6: /// the Audit sells the debt to collectors who visit dungeon floors). /// /// Deterministic table: 0 collectors at zero/negative debt; otherwise one /// per commenced 100cu block (1–100 → 1, …, 301–400 → 4), capped at 5 — a /// full Audit choir at 401cu+. The parent spawns them at dungeon stairs; /// collection behavior is parent-side. #[must_use] pub fn collectors_for(debt_cu: i32) -> u8 { if debt_cu <= 0 { return 0; } (debt_cu as u32).div_ceil(100).min(5) as u8 } #[cfg(test)] mod tests { use super::*; #[test] fn inn_sleep_costs_eight_coppers() { assert_eq!( price(Service::Inn), ServicePrice { copper: 8, scrip: 0 }, "a bed, and the world moves on" ); assert_eq!(INN_SLEEP_CU, 8); } #[test] fn tinker_identify_costs_fifteen_fair() { assert_eq!( price(Service::Tinker), ServicePrice { copper: 15, scrip: 0 }, "fair_cu the parent passes to identify::tinker_identify" ); assert_eq!(TINKER_IDENTIFY_CU, 15); } #[test] fn lampwright_lamp_and_oil_pinned() { assert_eq!( price(Service::Lampwright), ServicePrice { copper: 50, scrip: 10 }, "lamp 50cu / 10 scrip" ); assert_eq!(LAMP_PRICE_CU, 50); assert_eq!(OIL_FLASK_CU, 5, "oil flask 5cu"); assert_eq!(OIL_FLASK_SCRIP, 1, "…= 1 scrip: the 1:1 exchange"); } #[test] fn lampwright_scrip_tender_matches_copper_value() { // Scrip IS oil money (1 scrip = 5cu of lamp oil, economy::SCRIP_IN_COPPER): // both tenders must price the same lamp. let p = price(Service::Lampwright); assert_eq!(p.scrip * 5, p.copper, "10 scrip × 5cu = 50cu"); } #[test] fn warden_posts_bounties_free() { assert_eq!( price(Service::Warden), ServicePrice { copper: 0, scrip: 0 }, "pinning a notice is free; rates live on contract pay_cu" ); } #[test] fn mender_heal_and_triage_pinned() { assert_eq!( price(Service::Mender), ServicePrice { copper: 10, scrip: 0 }, "healing 10cu headline" ); assert_eq!(MENDER_HEAL_CU, 10); assert_eq!(MENDER_TRIAGE_CU, 40, "triage only — removal is the garden"); } #[test] fn fence_rate_sixty_percent_no_questions() { assert_eq!( price(Service::Fence), ServicePrice { copper: 0, scrip: 0 }, "no fee — the 40% spread is the fee" ); assert_eq!(FENCE_RATE_PCT, 60); assert_eq!(fence_offer(100), 60); assert_eq!(fence_offer(7), 4, "truncates down, house favors house"); assert_eq!(fence_offer(0), 0, "nothing in, nothing out"); assert_eq!(fence_offer(u32::MAX), (u32::MAX as u64 * 60 / 100) as u32); } #[test] fn auction_humiliation_premium_ten_percent() { assert_eq!( price(Service::Auction), ServicePrice { copper: 0, scrip: 0 }, "no entry fee — the +10% at the hammer is the fee" ); assert_eq!(AUCTION_FEE_PCT, 10); assert_eq!(auction_buyback(100), 110, "YOUR OWN sword costs 110%"); assert_eq!(auction_buyback(0), 0); assert_eq!(auction_buyback(10), 11); assert_eq!( auction_buyback(u32::MAX), u32::MAX, "saturates, never wraps" ); } #[test] fn all_seven_services_have_pinned_prices() { // Exhaustive match: adding an eighth service fails to compile here // until its price is pinned — the table cannot silently grow. let table = [ (Service::Inn, 8, 0), (Service::Tinker, 15, 0), (Service::Lampwright, 50, 10), (Service::Warden, 0, 0), (Service::Mender, 10, 0), (Service::Fence, 0, 0), (Service::Auction, 0, 0), ]; for (service, copper, scrip) in table { assert_eq!( price(service), ServicePrice { copper, scrip }, "{service:?} price drifted without a design decision" ); } } #[test] fn sleep_is_one_full_day() { assert_eq!(sleep_ticks(), 8640, "docs/03 §3.2 PREP: sleep advances all"); } #[test] fn garden_truth_table() { // Remove-curse is a person: she needs something that knew the // deceased — a belonging OR a memory; either suffices. assert!(garden_removes_curse(true, true), "both offerings: yes"); assert!(garden_removes_curse(true, false), "memory alone: yes"); assert!(garden_removes_curse(false, true), "belonging alone: yes"); assert!( !garden_removes_curse(false, false), "nothing offered: no cure" ); } #[test] fn collectors_bands() { assert_eq!(collectors_for(0), 0, "no debt, no collectors"); assert_eq!(collectors_for(-50), 0, "credit is not debt"); assert_eq!(collectors_for(i32::MIN), 0, "deep credit, still nobody"); assert_eq!(collectors_for(50), 1, "first commenced block"); assert_eq!(collectors_for(100), 1, "block boundary holds one"); assert_eq!(collectors_for(101), 2, "second block retains two"); assert_eq!(collectors_for(250), 3); assert_eq!(collectors_for(400), 4); assert_eq!(collectors_for(401), 5, "full choir at 401cu+"); assert_eq!( collectors_for(600), 5, "capped — the Audit sends five, never six" ); assert_eq!(collectors_for(i32::MAX), 5, "cap holds at the ceiling"); } }