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.
29 kB · 799 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800//! Character & progression: ancestries, callings, attributes, skills, use-gated growth.//!//! Design trace: docs/03-systems-design.md §3.1 ("a delver, not a chosen one"//! — 4 ancestries with small nudges and no trap picks; 6 callings as starting//! kits, not classes; 5 attributes 1–20; 14 skills 0–100 grown by use under//! Elder-Scrolls law; trainers, books, and ghosts as priced progression).//! Determinism law docs/04 §4.10.2: all growth is integer counters//! (`uses`/`pips`), no RNG, no time, no I/O, no threads — deterministic by//! construction. The `BTreeMap`-only rule is vacuous here (fixed arrays, no maps).//!//! Ownership notes://! - A `Character` keys off an `Actor` id parent-side (the State table owns the//! actor-id → Character mapping); this module never touches actor.rs.//! - Factions and town ledgers (docs/02 §§3–4) are later reputation work, not//! this module: ancestry "standing" nudges surface here as data/flags, and//! their behavior lives parent-side.//! - Aether conductivity (§3.1: high Aether strengthens hymns AND attracts//! things that eat signal): the attraction behavior is a Phase 8 hook,//! parent-side; this module only stores the attribute.//! - MSRV 1.75: no `is_multiple_of` (use `% N == 0`).
/// Minimum attribute value (§3.1: attributes run 1–20).pub const MIN_ATTR: u8 = 1;/// Maximum attribute value (§3.1: attributes run 1–20).pub const MAX_ATTR: u8 = 20;/// Maximum skill value (§3.1: skills run 0–100).pub const MAX_SKILL: u8 = 100;/// Base attribute before calling modifiers (§3.1: callings are kits, not classes).pub const BASE_ATTR: u8 = 8;/// Number of attributes (§3.1: exactly 5).pub const ATTRIBUTE_COUNT: usize = 5;/// Number of skills (§3.1: exactly 14).pub const SKILL_COUNT: usize = 14;
// ---------------------------------------------------------------------------// Ancestry// ---------------------------------------------------------------------------
/// Ancestry: flavor + a small mechanical nudge, never a trap pick (§3.1).#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum Ancestry { /// Milthollow locals: barter prices, lantern-law standing. Mudborn, /// Salt-canton stock: swim, haggle. CantBorn, /// Resurrection-light survivors: see Drowned, feared in towns. PaleTouched, /// Raised by tinkers: identify faster, the gods distrust the unclaimed. Foundling,}
/// All ancestries, for iteration (chargen UI, tests).impl Ancestry { /// Stable persistence tag; declaration order is not the wire protocol. #[must_use] pub const fn canonical_tag(self) -> u8 { match self { Self::Mudborn => 0, Self::CantBorn => 1, Self::PaleTouched => 2, Self::Foundling => 3, } }}
pub const ALL_ANCESTRIES: [Ancestry; 4] = [ Ancestry::Mudborn, Ancestry::CantBorn, Ancestry::PaleTouched, Ancestry::Foundling,];
/// Small mechanical hooks as data (§3.1: "flavor + small mechanical nudge").////// Interpretation (behavior is parent-side; this struct is the contract):/// - `barter_pct`: percent added to prices the delver PAYS (negative = discount)./// - `swim_bonus` / `identify_bonus`: flat points the parent adds to Swim checks/// and identify rolls respectively./// - `god_distrust`: bargain/piety checks start colder (Phase 11 hook)./// - `feared_in_towns`: town-reaction tables treat the delver warily./// - `sees_drowned`: perceives Drowned where others see empty water/air.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct Nudge { pub barter_pct: i8, pub swim_bonus: u8, pub identify_bonus: u8, pub god_distrust: bool, pub feared_in_towns: bool, pub sees_drowned: bool,}
/// Mechanical nudge per ancestry (§3.1). Every ancestry carries at least one/// genuine upside — no trap picks (asserted in tests).pub fn nudge(a: Ancestry) -> Nudge { match a { Ancestry::Mudborn => Nudge { // Milthollow locals get fair prices; lantern-law standing is a // ledger concern, so it lives in flavor + parent-side reputation. barter_pct: -5, swim_bonus: 0, identify_bonus: 0, god_distrust: false, feared_in_towns: false, sees_drowned: false, }, Ancestry::CantBorn => Nudge { // Salt-canton stock: strong swimmers, sharp hagglers. barter_pct: -3, swim_bonus: 10, identify_bonus: 0, god_distrust: false, feared_in_towns: false, sees_drowned: false, }, Ancestry::PaleTouched => Nudge { // Resurrection-light survivors: they see the Drowned — and towns // cross themselves when one walks in. The sight is the upside that // keeps this pick viable despite the fear cost. barter_pct: 0, swim_bonus: 0, identify_bonus: 0, god_distrust: false, feared_in_towns: true, sees_drowned: true, }, Ancestry::Foundling => Nudge { // Tinker-raised: identifies gear faster; the gods distrust the // unclaimed (bargain tables start colder, Phase 11 hook). barter_pct: 0, swim_bonus: 0, identify_bonus: 10, god_distrust: true, feared_in_towns: false, sees_drowned: false, }, }}
/// (display name, flavor line, nudge summary) per ancestry (§3.1 + docs/02 tone).pub fn describe(a: Ancestry) -> (&'static str, &'static str, &'static str) { match a { Ancestry::Mudborn => ( "Mudborn", "Milthollow-born, raised under lantern-law; the wardens know your name and the lampwrights know your credit.", "Pays 5% less when bartering; lantern-law standing starts warm (parent-side ledger).", ), Ancestry::CantBorn => ( "Cant-born", "Salt-canton stock, raised where the mere meets the sea; you learned to swim before you learned to kneel.", "+10 to Swim checks; pays 3% less when haggling.", ), Ancestry::PaleTouched => ( "Pale-touched", "You stood in the Last Boot's resurrection light and came back mostly yourself. The Drowned wave at you. Towns notice.", "Sees the Drowned; feared in towns (reaction tables start cold).", ), Ancestry::Foundling => ( "Foundling, tinker-raised", "Left on a tinker cart and raised stall to stall; you can smell a fake saint-bone charm at ten paces. The gods never signed your papers.", "+10 to identify rolls; the gods distrust you (bargains start colder).", ), }}
// ---------------------------------------------------------------------------// Calling// ---------------------------------------------------------------------------
/// Calling: a starting kit, not a class — skills grow by use (§3.1).#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum Calling { /// Polearm + traps + smell range. Ratcatcher, /// Lantern craft, courage aura, oil-scrip stipend. Lampbearer, /// Identify, mend, hymn-cards. Tinker, /// Drowned lore, dragging, last rites = loot blessing. CorpseTender, /// Shields, negotiation, Audit standing. TollWard, /// Swim, dead tongues, wanted. ChoirDeserter,}
/// All callings, for iteration (chargen UI, tests).impl Calling { /// Stable persistence tag; declaration order is not the wire protocol. #[must_use] pub const fn canonical_tag(self) -> u8 { match self { Self::Ratcatcher => 0, Self::Lampbearer => 1, Self::Tinker => 2, Self::CorpseTender => 3, Self::TollWard => 4, Self::ChoirDeserter => 5, } }}
pub const ALL_CALLINGS: [Calling; 6] = [ Calling::Ratcatcher, Calling::Lampbearer, Calling::Tinker, Calling::CorpseTender, Calling::TollWard, Calling::ChoirDeserter,];
/// Starting kit granted by a calling (§3.1: "starting kits, not classes").////// - `items`: item ids into assets/data/items.toml (exact ids, verified in tests)./// - `skill_bonuses`: starting skill points applied by `new` (cap 100)./// - `attr_mods`: attribute adjustments applied by `new` on top of base 8s./// - `stipend_cu`: starting copper stipend (Lampbearer oil scrip, §3.1).#[derive(Clone, Debug, PartialEq, Eq)]pub struct Kit { pub items: Vec<&'static str>, pub skill_bonuses: Vec<(Skill, u8)>, pub attr_mods: Vec<(Attribute, i8)>, pub stipend_cu: u32,}
/// Starting kit per calling (§3.1).////// Item-id notes (verified against assets/data/items.toml; spec working names/// without a data entry were remapped to the nearest REAL id, never invented):/// - Ratcatcher spec "net" has no data entry → `net_catch` (catch falling loot,/// verticality toy 6). Smell range is an intent note: the parent grants the/// Ratcatcher an extended scent-detection radius (docs/03 §3.3 law 10)./// - Tinker spec "tinker_tools" has no data entry → `copper_wire_coil` (its/// "mend gear" use is the tinker signature); spec "chalk" → `chalk_stick`./// - CorpseTender spec "corpse_hook" has no data entry → `mule_harness` (its/// "corpse-dragging" use is the dragging signature, §3.1)./// - ChoirDeserter "wanted" is a flag, not an item: the parent seeds a Warden/// ledger entry at chargen (docs/03 §3.6 shop/ledger law).pub fn kit(c: Calling) -> Kit { match c { Calling::Ratcatcher => Kit { items: vec!["ratcatcher_pike", "net_catch", "tripe_ration"], skill_bonuses: vec![(Skill::Search, 10), (Skill::Sneak, 5), (Skill::Butcher, 5)], attr_mods: vec![(Attribute::Finesse, 1), (Attribute::Nerve, 1)], stipend_cu: 3, }, Calling::Lampbearer => Kit { items: vec!["lampwright_lantern", "lamp_oil", "oil_scrip_note"], skill_bonuses: vec![(Skill::Tinker, 10), (Skill::Hymns, 5)], attr_mods: vec![(Attribute::Nerve, 2)], stipend_cu: 25, }, Calling::Tinker => Kit { items: vec!["copper_wire_coil", "hymn_card_blank", "chalk_stick"], skill_bonuses: vec![(Skill::Tinker, 15), (Skill::Copperlore, 5)], attr_mods: vec![(Attribute::Wits, 2)], stipend_cu: 5, }, Calling::CorpseTender => Kit { items: vec!["mule_harness", "bandages_roll", "saint_bone_charm"], skill_bonuses: vec![ (Skill::DeadTongues, 10), (Skill::Physick, 5), (Skill::Butcher, 5), ], attr_mods: vec![(Attribute::Nerve, 1), (Attribute::Aether, 1)], stipend_cu: 0, }, Calling::TollWard => Kit { items: vec!["toll_ward_shield", "silvered_dagger", "audit_lien_chit"], skill_bonuses: vec![ (Skill::Barter, 10), (Skill::Copperlore, 5), (Skill::Search, 5), ], attr_mods: vec![(Attribute::Brawn, 1), (Attribute::Nerve, 1)], stipend_cu: 10, }, Calling::ChoirDeserter => Kit { items: vec!["tin_lamp", "waterskin", "iron_rations_3"], skill_bonuses: vec![(Skill::Swim, 10), (Skill::DeadTongues, 10)], attr_mods: vec![(Attribute::Finesse, 1), (Attribute::Aether, 1)], stipend_cu: 0, }, }}
// ---------------------------------------------------------------------------// Attributes & skills// ---------------------------------------------------------------------------
/// Attribute (§3.1: exactly 5, range 1–20, grow slowly by use + trainers).////// Aether is magic conductivity: high Aether means strong hymns AND attention/// from things that eat signal — the attraction behavior is a Phase 8 hook,/// parent-side (docs/03 §3.5, §3.3 law 8).#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum Attribute { Brawn, Finesse, Wits, Nerve, Aether,}
/// All attributes in index order.pub const ALL_ATTRIBUTES: [Attribute; ATTRIBUTE_COUNT] = [ Attribute::Brawn, Attribute::Finesse, Attribute::Wits, Attribute::Nerve, Attribute::Aether,];
impl Attribute { /// Array index for `Character.attrs` (declaration order). pub const fn index(self) -> usize { self as usize }}
/// Skill (§3.1: exactly 14, range 0–100, use-gated).////// Dungeoneering is the vertical sense: shaft depths, airflow reading (§3.1).#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub enum Skill { Climb, Swim, Sneak, Search, Barter, Mend, Tinker, Hymns, Physick, Butcher, Ropework, Dungeoneering, DeadTongues, Copperlore,}
/// All skills in index order (exact §3.1 list order).pub const ALL_SKILLS: [Skill; SKILL_COUNT] = [ Skill::Climb, Skill::Swim, Skill::Sneak, Skill::Search, Skill::Barter, Skill::Mend, Skill::Tinker, Skill::Hymns, Skill::Physick, Skill::Butcher, Skill::Ropework, Skill::Dungeoneering, Skill::DeadTongues, Skill::Copperlore,];
impl Skill { /// Array index for `Character.skills`/`uses`/`pips` (declaration order). pub const fn index(self) -> usize { self as usize }}
// ---------------------------------------------------------------------------// Character// ---------------------------------------------------------------------------
/// A delver: ancestry + calling + attributes + skills + growth counters (§3.1).////// Arrays are indexed by `Attribute::index` / `Skill::index`. Ancestry is a/// mechanical-nudge-only choice (see [`nudge`]): attribute bases come from the/// calling kit alone, so no ancestry is a stat trap.#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub struct Character { pub ancestry: Ancestry, pub calling: Calling, /// Attributes 1–20, indexed by `Attribute as usize`. pub attrs: [u8; ATTRIBUTE_COUNT], /// Skills 0–100, indexed by `Skill as usize`. pub skills: [u8; SKILL_COUNT], /// Lifetime use counters per skill; drive growth via [`record_use`]. pub uses: [u32; SKILL_COUNT], /// Progress pips toward the next skill point; reset on gain. pub pips: [u8; SKILL_COUNT],}
/// New delver: base 8s + calling `attr_mods` (clamped 1–20), kit/// `skill_bonuses` on zeroed skills (capped 100), counters zeroed (§3.1).pub fn new(ancestry: Ancestry, calling: Calling) -> Character { let mut c = Character { ancestry, calling, attrs: [BASE_ATTR; ATTRIBUTE_COUNT], skills: [0; SKILL_COUNT], uses: [0; SKILL_COUNT], pips: [0; SKILL_COUNT], }; for (at, m) in kit(calling).attr_mods { let i = at.index(); c.attrs[i] = c.attrs[i] .saturating_add_signed(m) .clamp(MIN_ATTR, MAX_ATTR); } for (s, b) in kit(calling).skill_bonuses { let i = s.index(); c.skills[i] = c.skills[i].saturating_add(b).min(MAX_SKILL); } c}
/// Uses of practice needed to raise `level` by one more point (§3.1,/// Elder-Scrolls law: higher levels need more uses; integer division).////// Table: L0→10, L4→10, L5→11, L25→15, L50→20, L75→25, L95→29, L99→29.pub fn uses_for_next_point(level: u8) -> u32 { 10 + (level as u32) / 5}
/// Record one successful use of a skill (§3.1: skills grow by use).////// Increments the lifetime `uses` counter and progress `pips`; when pips reach/// `uses_for_next_point(current level)` the skill rises by 1 (cap 100) and/// pips reset. At cap 100 the lifetime counter still ticks (history matters/// for epitaphs/lineage, §3.9) but pips stay zeroed. Deterministic: pure/// integer counters, no RNG (docs/04 §4.10.2).pub fn record_use(c: &mut Character, skill: Skill) { let i = skill.index(); c.uses[i] = c.uses[i].saturating_add(1); let level = c.skills[i]; if level >= MAX_SKILL { c.pips[i] = 0; return; } let pips = c.pips[i].saturating_add(1); if (pips as u32) >= uses_for_next_point(level) { c.skills[i] = level + 1; c.pips[i] = 0; } else { c.pips[i] = pips; }}
/// Trainer-driven attribute gain (§3.1: attributes "grow slowly by use +/// trainers"): +1 to `attr`, capped at 20. Pricing lives in training.rs/// (`trainer_price`); the caller pays and the ledger remembers (parent-side).pub fn train_attr(c: &mut Character, attr: Attribute) { let i = attr.index(); c.attrs[i] = c.attrs[i].saturating_add(1).min(MAX_ATTR);}
/// Attribute accessor.pub fn attr(c: &Character, at: Attribute) -> u8 { c.attrs[at.index()]}
/// Skill accessor.pub fn skill(c: &Character, s: Skill) -> u8 { c.skills[s.index()]}
// ---------------------------------------------------------------------------// Tests// ---------------------------------------------------------------------------
#[cfg(test)]mod tests { use super::*;
/// Viability rule (§3.1 "no trap picks"): a discount counts as upside, /// as does any bonus point or the Drowned-sight. Fear/god-distrust costs /// must always ride alongside at least one upside. fn has_upside(n: &Nudge) -> bool { n.barter_pct < 0 || n.swim_bonus > 0 || n.identify_bonus > 0 || n.sees_drowned }
#[test] fn ancestries_have_upside_no_trap_picks() { for a in ALL_ANCESTRIES { assert!(has_upside(&nudge(a)), "{a:?} must carry a real upside"); } // Spot-check the signature hooks. assert!(nudge(Ancestry::Mudborn).barter_pct < 0); assert!(nudge(Ancestry::CantBorn).swim_bonus > 0); assert!(nudge(Ancestry::PaleTouched).sees_drowned); assert!(nudge(Ancestry::PaleTouched).feared_in_towns); assert!(nudge(Ancestry::Foundling).identify_bonus > 0); assert!(nudge(Ancestry::Foundling).god_distrust); }
#[test] fn describe_names_and_flavor_nonempty() { for a in ALL_ANCESTRIES { let (name, flavor, nudge_text) = describe(a); assert!(!name.is_empty(), "{a:?} needs a display name"); assert!(!flavor.is_empty(), "{a:?} needs flavor"); assert!(!nudge_text.is_empty(), "{a:?} needs a nudge summary"); } }
#[test] fn ratcatcher_kit_contents() { let k = kit(Calling::Ratcatcher); assert_eq!( k.items, vec!["ratcatcher_pike", "net_catch", "tripe_ration"] ); assert!(!k.skill_bonuses.is_empty()); assert!(k.skill_bonuses.iter().any(|(s, _)| *s == Skill::Search)); }
#[test] fn lampbearer_kit_contents() { let k = kit(Calling::Lampbearer); assert_eq!( k.items, vec!["lampwright_lantern", "lamp_oil", "oil_scrip_note"] ); assert_eq!(k.stipend_cu, 25, "oil scrip stipend (§3.1)"); assert!(!k.skill_bonuses.is_empty()); assert!(!k.attr_mods.is_empty()); }
#[test] fn tinker_kit_contents() { let k = kit(Calling::Tinker); assert_eq!( k.items, vec!["copper_wire_coil", "hymn_card_blank", "chalk_stick"] ); assert!(k.skill_bonuses.iter().any(|(s, _)| *s == Skill::Tinker)); }
#[test] fn corpse_tender_kit_contents() { let k = kit(Calling::CorpseTender); assert_eq!( k.items, vec!["mule_harness", "bandages_roll", "saint_bone_charm"] ); assert!(k .skill_bonuses .iter() .any(|(s, _)| *s == Skill::DeadTongues)); }
#[test] fn toll_ward_kit_contents() { let k = kit(Calling::TollWard); assert_eq!( k.items, vec!["toll_ward_shield", "silvered_dagger", "audit_lien_chit"] ); assert!(k.skill_bonuses.iter().any(|(s, _)| *s == Skill::Barter)); }
#[test] fn choir_deserter_kit_contents() { let k = kit(Calling::ChoirDeserter); assert_eq!(k.items, vec!["tin_lamp", "waterskin", "iron_rations_3"]); assert!(k.skill_bonuses.iter().any(|(s, _)| *s == Skill::Swim)); assert!(k .skill_bonuses .iter() .any(|(s, _)| *s == Skill::DeadTongues)); }
#[test] fn all_kits_have_two_plus_items_and_bonuses() { for c in ALL_CALLINGS { let k = kit(c); assert!(k.items.len() >= 2, "{c:?} kit needs 2+ items"); assert!(!k.skill_bonuses.is_empty(), "{c:?} kit needs skill bonuses"); assert!(!k.attr_mods.is_empty(), "{c:?} kit needs attr mods"); } }
#[test] fn stipends_sane() { // Lampbearer oil scrip is the signature stipend; nobody starts rich // (docs/02 §5 salvage economy: delvers start poor). assert_eq!(kit(Calling::Lampbearer).stipend_cu, 25); for c in ALL_CALLINGS { assert!( kit(c).stipend_cu <= 25, "{c:?} stipend must not exceed oil scrip" ); } }
#[test] fn new_bases_sane() { for c in ALL_CALLINGS { let ch = new(Ancestry::Mudborn, c); for a in ALL_ATTRIBUTES { let v = attr(&ch, a); assert!((MIN_ATTR..=MAX_ATTR).contains(&v), "{c:?}/{a:?} = {v}"); } for s in ALL_SKILLS { assert!(ch.skills[s.index()] <= MAX_SKILL); } assert!(ch.uses.iter().all(|u| *u == 0)); assert!(ch.pips.iter().all(|p| *p == 0)); } // Spot-check: base 8s + calling mods only (ancestry is nudge-only). let lamp = new(Ancestry::Foundling, Calling::Lampbearer); assert_eq!(attr(&lamp, Attribute::Nerve), BASE_ATTR + 2); assert_eq!(attr(&lamp, Attribute::Brawn), BASE_ATTR); let tink = new(Ancestry::PaleTouched, Calling::Tinker); assert_eq!(attr(&tink, Attribute::Wits), BASE_ATTR + 2); assert_eq!(skill(&tink, Skill::Tinker), 15); }
#[test] fn attribute_index_covers_array() { assert_eq!(ALL_ATTRIBUTES.len(), ATTRIBUTE_COUNT); for (i, a) in ALL_ATTRIBUTES.iter().enumerate() { assert_eq!(a.index(), i); } assert_eq!(Attribute::Aether.index(), 4); }
#[test] fn skill_index_covers_array() { assert_eq!(ALL_SKILLS.len(), SKILL_COUNT); for (i, s) in ALL_SKILLS.iter().enumerate() { assert_eq!(s.index(), i); } assert_eq!(Skill::Copperlore.index(), 13); }
#[test] fn record_use_ten_uses_at_zero_grows_one() { let mut ch = new(Ancestry::Mudborn, Calling::Ratcatcher); assert_eq!(skill(&ch, Skill::Climb), 0); for _ in 0..9 { record_use(&mut ch, Skill::Climb); } assert_eq!(skill(&ch, Skill::Climb), 0, "9 uses must not grow level 0"); assert_eq!(ch.pips[Skill::Climb.index()], 9); record_use(&mut ch, Skill::Climb); assert_eq!(skill(&ch, Skill::Climb), 1, "10th use grows level 0 → 1"); assert_eq!(ch.pips[Skill::Climb.index()], 0, "pips reset on gain"); assert_eq!(ch.uses[Skill::Climb.index()], 10); }
#[test] fn growth_threshold_table() { assert_eq!(uses_for_next_point(0), 10); assert_eq!(uses_for_next_point(4), 10); assert_eq!(uses_for_next_point(5), 11); assert_eq!(uses_for_next_point(25), 15); assert_eq!(uses_for_next_point(50), 20); assert_eq!(uses_for_next_point(75), 25); assert_eq!(uses_for_next_point(95), 29); assert_eq!(uses_for_next_point(99), 29); // Monotonic non-decreasing across the whole band. for lvl in 0..100u8 { assert!(uses_for_next_point(lvl) <= uses_for_next_point(lvl + 1)); } }
#[test] fn high_level_threshold_higher() { let mut ch = new(Ancestry::CantBorn, Calling::ChoirDeserter); ch.skills[Skill::Swim.index()] = 50; // threshold 10 + 50/5 = 20 for _ in 0..19 { record_use(&mut ch, Skill::Swim); } assert_eq!( skill(&ch, Skill::Swim), 50, "19 uses must not grow level 50" ); record_use(&mut ch, Skill::Swim); assert_eq!(skill(&ch, Skill::Swim), 51, "20th use grows level 50 → 51"); assert_eq!(ch.pips[Skill::Swim.index()], 0); }
#[test] fn skill_cap_100_never_exceeded() { let mut ch = new(Ancestry::Mudborn, Calling::TollWard); ch.skills[Skill::Barter.index()] = 100; for _ in 0..50 { record_use(&mut ch, Skill::Barter); } assert_eq!(skill(&ch, Skill::Barter), 100); assert_eq!(ch.pips[Skill::Barter.index()], 0); // Lifetime counter still ticks at cap (history for epitaphs, §3.9). assert_eq!(ch.uses[Skill::Barter.index()], 50); // Growing INTO the cap stops exactly at 100. ch.skills[Skill::Barter.index()] = 99; // threshold 10 + 99/5 = 29 for _ in 0..60 { record_use(&mut ch, Skill::Barter); } assert_eq!(skill(&ch, Skill::Barter), 100); }
#[test] fn train_attr_caps_at_20() { let mut ch = new(Ancestry::Mudborn, Calling::Lampbearer); for _ in 0..30 { train_attr(&mut ch, Attribute::Nerve); } assert_eq!(attr(&ch, Attribute::Nerve), MAX_ATTR); train_attr(&mut ch, Attribute::Nerve); assert_eq!(attr(&ch, Attribute::Nerve), MAX_ATTR, "never exceeds 20"); }
#[test] fn train_attr_increments_one() { let mut ch = new(Ancestry::Foundling, Calling::Tinker); let before = attr(&ch, Attribute::Wits); train_attr(&mut ch, Attribute::Wits); assert_eq!(attr(&ch, Attribute::Wits), before + 1); // Other attributes untouched. assert_eq!(attr(&ch, Attribute::Brawn), BASE_ATTR); }
/// Hardcoded slice of assets/data/items.toml ids (from grep at authoring /// time; the file-cross-check test below pins kits to the live file). /// Pure assertion: no file I/O, deterministic anywhere. const KNOWN_ITEM_IDS: [&str; 30] = [ "lamp_oil", "rope_10m", "pitons_5", "pole_3m", "net_catch", "sledgehammer", "hand_bell", "flour_sack", "chalk_stick", "cable_rat_pelt", "copper_wire_coil", "spent_aether_cell", "fresh_aether_cell", "hymn_card_blank", "tinker_moss_poultice", "bandages_roll", "ratcatcher_pike", "toll_ward_shield", "silvered_dagger", "iron_rations_3", "waterskin", "lampwright_lantern", "oil_scrip_note", "tripe_ration", "cellar_newt_corpse", "saint_bone_charm", "drowned_bell_captive", "audit_lien_chit", "mule_harness", "tin_lamp", ];
#[test] fn kit_item_ids_are_known_slice_ids() { for c in ALL_CALLINGS { for item in kit(c).items { assert!( KNOWN_ITEM_IDS.contains(&item), "{c:?} kit item `{item}` is not a known items.toml id" ); } } }
#[test] fn kit_item_ids_exist_in_items_toml() { // Cross-check kits against the live data file (docs/03 §3.4: items as // data). File I/O in tests via CARGO_MANIFEST_DIR follows the content // tests pattern. Resolution order: (1) real wiring — manifest dir is // crates/hexwork-core, so ../../assets/... is the repo root; (2) // scratch-verification layout with ./assets/... beside the manifest. let manifest = env!("CARGO_MANIFEST_DIR"); let mut text: Option<String> = None; for tail in ["/../../assets/data/items.toml", "/assets/data/items.toml"] { if let Ok(t) = std::fs::read_to_string(format!("{manifest}{tail}")) { text = Some(t); break; } } let text = text.expect("items.toml must be reachable from CARGO_MANIFEST_DIR"); let mut missing = Vec::new(); for c in ALL_CALLINGS { for item in kit(c).items { if !text.contains(&format!("id = \"{item}\"")) { missing.push(item); } } } assert!( missing.is_empty(), "kit items missing from items.toml: {missing:?}" ); }}