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.
15 kB · 388 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389//! 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"); }}