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.
6.1 kB · 160 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161//! Trainers, books, ghost-teachers: priced progression gates.//!//! Design trace: docs/03-systems-design.md §3.1 ("Trainers in towns; books;//! ghosts teach at prices") and §3.6 (town services: the tinker stall//! identifies/mends/copies; shopkeepers keep ledgers — the ledger itself is//! parent-side, this module only prices the lesson). Determinism law docs/04//! §4.10.2: fixed price curves and fixed pip grants, no RNG, no I/O.//!//! Payment split: this module checks the offered copper and grants the point;//! the purse, the ledger-memory ("sometimes lies if underpaid"), and the//! consumable-book / memory sacrifices live parent-side (Phases 9/10/11).
use crate::character::{Character, Skill, MAX_SKILL};
/// What a lesson costs (§3.1: everything teaches at a price).////// Ghosts take memories, not coin: `copper == 0 && memory == true` (docs/03/// §3.4: a dead delver's shade identifies one item for a memory; the memory/// sacrifice — deleting a learned map region per docs/02 §3 — is a Phase/// 9/10/11 content concern, hooked here).#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct Price { pub copper: u32, pub memory: bool,}
/// Trainer price for one lesson at `skill_level`: 10 + 2 per current level.////// Deterministic and monotonic: a lesson at level 50 costs 110cu, at level 99/// costs 208cu (docs/02 §5 salvage economy: mastery money is elevator money).pub fn trainer_price(skill_level: u8) -> u32 { 10 + 2 * (skill_level as u32)}
/// One trainer lesson: grants ONE full skill point and resets progress pips.////// - `Err` if `paid_cu` is under the [`trainer_price`] for the current level/// (the shopkeeper's ledger remembers — parent-side, docs/03 §3.6)./// - `Err` if the skill is already 100 (trainers cannot teach past mastery)./// - Otherwise the skill rises exactly one point and pips reset to zero:/// trainers teach, practice keeps. The lifetime `uses` counter is untouched.pub fn train_skill(c: &mut Character, skill: Skill, paid_cu: u32) -> Result<(), &'static str> { let level = c.skills[skill.index()]; if level >= MAX_SKILL { return Err("already at maximum (100): trainers cannot teach past mastery"); } if paid_cu < trainer_price(level) { return Err("underpaid: the ledger remembers (docs/03 §3.6)"); } c.skills[skill.index()] = level + 1; c.pips[skill.index()] = 0; Ok(())}
/// Pips granted by studying a book: 5 + `skill_index % 3` (so 5–7 by skill).////// Fixed per skill, deterministic by construction. Books are consumable/// parent-side (one reading per copy); this function only sizes the grant —/// the parent adds the pips via `record_use`-style accounting.pub fn book_bonus(skill: Skill) -> u8 { 5 + ((skill.index() % 3) as u8)}
/// What a ghost-teacher charges: no coin, one memory (§3.1, docs/03 §3.4).////// A dead delver's shade identifies/teaches for a memory of the living's/// choosing; the content table (which shade, which lesson) is Phase 9/10 work.pub fn ghost_price() -> Price { Price { copper: 0, memory: true, }}
#[cfg(test)]mod tests { use super::*; use crate::character::{new, ALL_SKILLS};
#[test] fn price_curve_spot_values() { assert_eq!(trainer_price(0), 10); assert_eq!(trainer_price(50), 110); assert_eq!(trainer_price(99), 208); assert_eq!(trainer_price(100), 210); }
#[test] fn price_curve_monotonic() { for lvl in 0..100 { assert!(trainer_price(lvl) < trainer_price(lvl + 1)); } }
#[test] fn underpay_errs() { let mut ch = new( crate::character::Ancestry::Mudborn, crate::character::Calling::Tinker, ); // Tinker starts at 15 (kit bonus): price is 10 + 2*15 = 40. assert_eq!(trainer_price(ch.skills[Skill::Tinker.index()]), 40); assert!(train_skill(&mut ch, Skill::Tinker, 39).is_err()); assert_eq!( ch.skills[Skill::Tinker.index()], 15, "failed lesson teaches nothing" ); }
#[test] fn exact_pay_grants_point_and_resets_pips() { let mut ch = new( crate::character::Ancestry::Foundling, crate::character::Calling::Ratcatcher, ); ch.pips[Skill::Search.index()] = 7; let price = trainer_price(ch.skills[Skill::Search.index()]); assert!(train_skill(&mut ch, Skill::Search, price).is_ok()); // Search started at 10 (Ratcatcher bonus) → 11, pips cleared. assert_eq!(ch.skills[Skill::Search.index()], 11); assert_eq!(ch.pips[Skill::Search.index()], 0); // Overpaying also works (change is the ledger's problem, parent-side). let price2 = trainer_price(11); assert!(train_skill(&mut ch, Skill::Search, price2 + 500).is_ok()); assert_eq!(ch.skills[Skill::Search.index()], 12); }
#[test] fn cap_100_errs_even_when_rich() { let mut ch = new( crate::character::Ancestry::CantBorn, crate::character::Calling::ChoirDeserter, ); ch.skills[Skill::Swim.index()] = 100; assert!(train_skill(&mut ch, Skill::Swim, u32::MAX).is_err()); assert_eq!(ch.skills[Skill::Swim.index()], 100); }
#[test] fn book_pips_bounded_and_deterministic() { for s in ALL_SKILLS { let b = book_bonus(s); assert!((5..=7).contains(&b), "{s:?} book grant {b} out of 5–7 band"); assert_eq!(b, book_bonus(s), "book grants must be deterministic"); } // Spot-check the formula 5 + index % 3. assert_eq!(book_bonus(Skill::Climb), 5); // index 0 assert_eq!(book_bonus(Skill::Swim), 6); // index 1 assert_eq!(book_bonus(Skill::Sneak), 7); // index 2 assert_eq!(book_bonus(Skill::Search), 5); // index 3 }
#[test] fn ghost_price_is_memory_not_coin() { let p = ghost_price(); assert_eq!(p.copper, 0); assert!(p.memory, "ghosts take memories, not coin (docs/03 §3.4)"); }}