diff --git a/crates/hexwork-core/src/economy.rs b/crates/hexwork-core/src/economy.rs new file mode 100644 index 0000000..a5846bc --- /dev/null +++ b/crates/hexwork-core/src/economy.rs @@ -0,0 +1,268 @@ +//! Salvage economy: coppers, silver, oil scrip + the eight trade goods. +//! +//! Design traces: +//! - docs/02-lore-bible.md §5 (currency: coppers, silver, oil scrip — lamp +//! oil as legal tender, "drinking money is literally burning money"; trade +//! goods: copper wire, aether-cells spent/fresh, hymn-cards, tinker-moss, +//! cable-rat pelts, saint-bone, Drowned bells illegal/valuable/ringing). +//! - docs/03-systems-design.md §3.6 (shopkeeper NetHack-law: dropping a +//! Drowned bell in a shop empties it — customers AND keeper — then the +//! keeper bills you; the bill is a debt, parent-side). +//! - docs/04-software-architecture.md determinism contract (docs/04 §4.2, +//! §4.10): same seed + same inputs = same state. Prices drift while the +//! sim runs without the player (docs/03 §3.6), so drift is a deterministic +//! integer-hash wobble — no floats, no RNG, no time, no I/O, no threads. +//! +//! Ownership notes: +//! - This module owns BASE prices and the drift FORMULA. Content +//! `base_price_cu` columns (assets/data) stay parent-side; the wiring plan +//! checks fence 60% / auction +10% compose against them at merge. +//! - Barter adjustments live in +//! [`crate::reputation::Reputation::barter_mod`](crate::reputation::Reputation::barter_mod) +//! and town grudges in `adjust_ledger` — CALL those parent-side, never +//! reimplement them here. +//! - MSRV 1.75: no newer std APIs; all arithmetic saturates or widens. +//! +//! ## Drift formula (pinned, test-locked) +//! +//! `drift(base, day, seed_tag) = base * (100 + wobble) / 100` where +//! `wobble = ((day * 37 + seed_tag * 11) % 41) - 20`, so `wobble` ranges +//! −20..=+20 (±20%). `seed_tag` separates goods sharing a day (wire drifts +//! on a different curve than bells). Integer math throughout: `day` and +//! `seed_tag` widen to `u128` before the multiply so no input can overflow. + +/// Copper units in one silver piece (docs/02 §5: coppers, silver, oil scrip). +pub const SILVER_IN_COPPER: u32 = 12; +/// Copper units in one oil-scrip note: 1 scrip = 5cu of lamp oil. +/// +/// Oil scrip IS lamp oil as legal tender — drinking money is literally +/// burning money. Pinned and tested ([`to_copper`]). +pub const SCRIP_IN_COPPER: u32 = 5; + +/// The three legal currencies (docs/02 §5). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Coin { + /// Plain copper units: the unit of account. + Copper, + /// Silver: 12cu each ([`SILVER_IN_COPPER`]); wardens pay bounties in it. + Silver, + /// Oil scrip: 5cu of lamp oil each ([`SCRIP_IN_COPPER`]); the + /// Lampwrights' Guild issues it against lamp-oil reserves. + Scrip, +} + +/// Convert `n` coins of kind `c` to copper units (saturating, never wraps). +#[must_use] +pub fn to_copper(c: Coin, n: u32) -> u32 { + match c { + Coin::Copper => n, + Coin::Silver => n.saturating_mul(SILVER_IN_COPPER), + Coin::Scrip => n.saturating_mul(SCRIP_IN_COPPER), + } +} + +/// The eight canonical salvage goods (docs/02 §5 list). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Good { + /// Copper wire: the Deep's loose change; conducts hymns (uses: sell, + /// wire a signal run). + Wire, + /// Spent aether-cell: dead weight with a second life (uses: sell, + /// refill/trade-in toward fresh). + SpentCell, + /// Fresh aether-cell: dense stored hymn-charge (uses: power, sell dear). + FreshCell, + /// Hymn-card: punchcard program (uses: slot it, sell it). + HymnCard, + /// Tinker-moss: glow, packing, poultice (uses: light, mend). + Moss, + /// Cable-rat pelt: sparks gone, warmth remains (uses: sell, line a + /// cloak). + Pelt, + /// Saint-bone: relic chips (uses: charm, sell to the chapel-minded). + Bone, + /// Drowned bell: ILLEGAL, valuable, ringing (docs/03 §3.6 — dropping one + /// in a shop empties it; see [`bell_empties_shop`]). + Bell, +} + +/// Base price in copper units for one unit of `g` (before drift/barter). +/// +/// Intent one-liners: wire 2 (loose change, everywhere); spent cell 3 (dead +/// weight, trade-in value); fresh cell 60 (a hymn-charge you can hold); +/// hymn-card 15 (a program, priced like a tool); moss 4 (glow by the +/// handful); pelt 6 (warmth plus rat-tax); bone 25 (relic gravity); +/// bell 120 (ILLEGAL premium — the Audit's cut is priced in, and so is the +/// risk of carrying something that rings). +#[must_use] +pub fn base_price(g: Good) -> u32 { + match g { + Good::Wire => 2, + Good::SpentCell => 3, + Good::FreshCell => 60, + Good::HymnCard => 15, + Good::Moss => 4, + Good::Pelt => 6, + Good::Bone => 25, + Good::Bell => 120, + } +} + +/// Drifted price for `base` on `day` under `seed_tag` (docs/04 determinism). +/// +/// `base * (100 + wobble) / 100` with +/// `wobble = ((day * 37 + seed_tag * 11) % 41) - 20`: a deterministic ±20% +/// sine-ish wobble with no floats and no RNG, so the unattended-town soak +/// (docs/03 §3.6: the sim runs without you) replays bit-identically. +/// `seed_tag` keeps goods on separate curves. The multiply widens to `u64` +/// and clamps to `u32::MAX`, so giant content prices saturate instead of +/// panicking in debug. +#[must_use] +pub fn drift(base: u32, day: u64, seed_tag: u64) -> u32 { + let hash = (day as u128) * 37 + (seed_tag as u128) * 11; + let wobble = (hash % 41) as i32 - 20; + let pct = (100 + wobble) as u64; + let out = base as u64 * pct / 100; + out.min(u32::MAX as u64) as u32 +} + +/// Drowned-bell law (docs/03 §3.6): dropping a bell in a shop empties it — +/// customers AND keeper flee the ringing. Any other good is just merchandise. +#[must_use] +pub fn bell_empties_shop(good: Good) -> bool { + matches!(good, Good::Bell) +} + +/// The keeper bills you after a bell-emptying: 200cu, pinned. +/// +/// The bill is a DEBT (parent records it via `Reputation::adjust_debt` on +/// the Audit standing — payable mid-dungeon by prayer, docs/03 §3.4 +/// owed-condition moment), never an instant coin seizure. +pub const BELL_BILL_CU: u32 = 200; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn copper_is_the_unit_of_account() { + assert_eq!(to_copper(Coin::Copper, 0), 0); + assert_eq!(to_copper(Coin::Copper, 7), 7); + assert_eq!(to_copper(Coin::Copper, u32::MAX), u32::MAX); + } + + #[test] + fn silver_is_twelve_coppers() { + assert_eq!(SILVER_IN_COPPER, 12); + assert_eq!(to_copper(Coin::Silver, 0), 0); + assert_eq!(to_copper(Coin::Silver, 1), 12); + assert_eq!(to_copper(Coin::Silver, 3), 36); + } + + #[test] + fn scrip_is_five_coppers_of_lamp_oil() { + // Oil scrip: drinking money is burning money — 1 scrip = 5cu of oil. + assert_eq!(SCRIP_IN_COPPER, 5); + assert_eq!(to_copper(Coin::Scrip, 0), 0); + assert_eq!(to_copper(Coin::Scrip, 1), 5); + assert_eq!(to_copper(Coin::Scrip, 4), 20); + } + + #[test] + fn to_copper_saturates_never_wraps() { + assert_eq!(to_copper(Coin::Silver, u32::MAX), u32::MAX); + assert_eq!(to_copper(Coin::Scrip, u32::MAX), u32::MAX); + assert_eq!( + to_copper(Coin::Silver, u32::MAX / SILVER_IN_COPPER + 1), + u32::MAX + ); + } + + #[test] + fn base_price_wire_spent_and_fresh_cells() { + assert_eq!(base_price(Good::Wire), 2, "loose change"); + assert_eq!(base_price(Good::SpentCell), 3, "dead weight, trade-in"); + assert_eq!(base_price(Good::FreshCell), 60, "a charge you can hold"); + } + + #[test] + fn base_price_card_moss_and_pelt() { + assert_eq!(base_price(Good::HymnCard), 15, "a program, tool-priced"); + assert_eq!(base_price(Good::Moss), 4, "glow by the handful"); + assert_eq!(base_price(Good::Pelt), 6, "warmth plus rat-tax"); + } + + #[test] + fn base_price_bone_and_illegal_bell() { + assert_eq!(base_price(Good::Bone), 25, "relic gravity"); + assert_eq!(base_price(Good::Bell), 120, "ILLEGAL premium"); + } + + #[test] + fn drift_is_deterministic() { + assert_eq!(drift(100, 9, 4), drift(100, 9, 4)); + assert_eq!(drift(60, 12345, 678), drift(60, 12345, 678)); + assert_eq!( + drift(u32::MAX, u64::MAX, u64::MAX), + drift(u32::MAX, u64::MAX, u64::MAX) + ); + } + + #[test] + fn drift_stays_within_twenty_percent() { + for base in [1u32, 2, 7, 60, 120, 1000] { + for day in 0..200u64 { + for tag in 0..10u64 { + let out = drift(base, day, tag); + assert!( + out >= base * 80 / 100 && out <= base * 120 / 100, + "drift({base}, {day}, {tag}) = {out} escapes ±20%" + ); + } + } + } + } + + #[test] + fn drift_pin_bear_market() { + // wobble = ((0*37 + 0*11) % 41) - 20 = -20 → 100*80/100. + assert_eq!(drift(100, 0, 0), 80); + } + + #[test] + fn drift_pin_bull_market() { + // wobble = ((1*37 + 0*11) % 41) - 20 = 37-20 = +17 → 100*117/100. + assert_eq!(drift(100, 1, 0), 117); + } + + #[test] + fn drift_pin_mixed_curve() { + // (2*37 + 3*11) = 107; 107 % 41 = 25; wobble +5 → 60*105/100 = 63. + assert_eq!(drift(60, 2, 3), 63); + } + + #[test] + fn drift_zero_base_stays_zero() { + assert_eq!(drift(0, 5, 5), 0, "no wobble out of nothing"); + } + + #[test] + fn bell_law_and_bill() { + assert!(bell_empties_shop(Good::Bell), "the ringing empties shops"); + for good in [ + Good::Wire, + Good::SpentCell, + Good::FreshCell, + Good::HymnCard, + Good::Moss, + Good::Pelt, + Good::Bone, + ] { + assert!( + !bell_empties_shop(good), + "{good:?} is just merchandise, not a bell" + ); + } + assert_eq!(BELL_BILL_CU, 200, "the keeper bills you — pinned"); + } +} diff --git a/crates/hexwork-core/src/lib.rs b/crates/hexwork-core/src/lib.rs index ef22a1e..997313d 100644 --- a/crates/hexwork-core/src/lib.rs +++ b/crates/hexwork-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod casting; pub mod cause; pub mod character; pub mod connector; +pub mod economy; pub mod ecs; pub mod fire; pub mod fluids; @@ -23,6 +24,7 @@ pub mod item; pub mod laws; pub mod light; pub mod materials; +pub mod npc; pub mod phases; pub mod pressure; pub mod reputation; @@ -30,6 +32,8 @@ pub mod rng; pub mod rope; pub mod rot; pub mod scent; +pub mod schedule; +pub mod services; pub mod signal; pub mod sound; pub mod state; diff --git a/crates/hexwork-core/src/npc.rs b/crates/hexwork-core/src/npc.rs new file mode 100644 index 0000000..e730e49 --- /dev/null +++ b/crates/hexwork-core/src/npc.rs @@ -0,0 +1,623 @@ +//! Townsfolk minds: who folk are, what they intend, and what shops remember. +//! +//! Design traces: +//! - docs/03-systems-design.md §3.6 ("the sim runs without you"): townsfolk +//! have names, jobs, homes, debts, gossip; schedules (lamplighters at dusk, +//! mills at dawn, chapels at bell, taverns at night, coaches between towns +//! carrying gossip/goods/prices); shopkeeper NetHack-law (ledgers, memory, +//! grudges — break, steal, or kill and the *town* knows). +//! - docs/03 §3.3 law 2: monsters — and startled folk — investigate stimulus +//! POSITIONS, not the player. Sound-phase positions flow in parent-side; +//! [`decide`] never sees the player, only positions. +//! - docs/04-software-architecture.md §4.2: the Intent phase (phase 1) +//! consumes [`Intent`]s; §4.10 hard rules: `BTreeMap` only, no time/IO/threads, +//! no `HashMap` in sim paths, integer math. +//! +//! ## Canonical-twin rule — BAND+TABLE EQUALITY (reviewer: cross-check!) +//! +//! `schedule.rs` is the CANONICAL day clock the parent wires +//! (`TICKS_PER_DAY`, `day_phase`, `where_should`, `gossip_arrived`). This +//! module must NOT import it (parallel-workstream rule: zero coupling), so +//! [`decide`] carries a documented FALLBACK copy of the same six day bands +//! and the same job→post table. The bands and the table are IDENTICAL in both +//! files by construction; if either file changes a band edge or a table row, +//! the other MUST change in lockstep (parent unifies at wiring). +//! +//! Bands (both files): dawn 0–1439, morning 1440–2879, midday 2880–4319, +//! dusk 4320–5759, night 5760–7199, late-night 7200–8639 (day ticks). + +use std::collections::BTreeMap; + +/// Ticks per in-game day: mirror of `state::TICKS_PER_DAY` and +/// `schedule::TICKS_PER_DAY` (docs/04 §4.2 schedule driver). Parent unifies if +/// it drifts; [`phase_of_tick`] depends on this value. +pub const TICKS_PER_DAY: u64 = 8640; + +/// First day-tick of the night band (dusk 4320–5759 → night 5760–7199). +/// Folk posted [`Post::Home`] at or past this tick [`Intent::Sleep`] instead +/// of walking home. +pub const NIGHT_START: u64 = 5760; + +/// A townsfolk trade (docs/03 §3.6 services + streets). +/// +/// NOTE on the count: the workstream brief says "12", but the blessed variant +/// list and the job→post table below both contain THIRTEEN entries (the +/// streets need their beggar). [`ALL_JOBS`] pins the count so any +/// addition/removal is deliberate; `schedule.rs` matches all thirteen by name. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Job { + /// Walks the streets at dusk lighting lamps (docs/03 §3.6). + Lamplighter, + /// Mills at dawn (docs/03 §3.6). + Miller, + /// Identifies/mends/copies (docs/03 §3.6 services). + Tinker, + /// Sells lamps/oil/scrip (docs/03 §3.6 services). + Lampwright, + /// Holds the warden post: bounties, seal-law (docs/03 §3.6 services). + Warden, + /// Healing + curse triage (docs/03 §3.6 services). + Mender, + /// Congregation outlet, no questions asked (docs/03 §3.6 services). + Fence, + /// Audit auctions — buy back your own confiscated loot (§3.6 services). + Auctioneer, + /// Keeps the coaching inn; sleep advances the world (§3.6 services). + Innkeep, + /// Remove-curse is a person, not a scroll: garden, holy runoff (§3.6). + CorpseTender, + /// Holds chapel at bell, morning and dusk (docs/03 §3.6). + Chaplain, + /// Drives coaches between towns carrying gossip/goods/prices (§3.6). + CoachDriver, + /// Works the streets by day (docs/03 §3.6 streets). + Beggar, +} + +/// Every [`Job`] variant, in declaration order. Pins the 13-count (see the +/// [`Job`] note) and gives the reviewer one list to cross-check against +/// `schedule::ALL_JOB_NAMES`. +pub const ALL_JOBS: [Job; 13] = [ + Job::Lamplighter, + Job::Miller, + Job::Tinker, + Job::Lampwright, + Job::Warden, + Job::Mender, + Job::Fence, + Job::Auctioneer, + Job::Innkeep, + Job::CorpseTender, + Job::Chaplain, + Job::CoachDriver, + Job::Beggar, +]; + +/// A townsfolk record: data, not simulation (docs/03 §3.6 — names, jobs, +/// homes, debts; gossip itself lives parent-side in the latency queue). +/// +/// Townsfolk carry no live position and no Character sheet: Actor positions +/// live parent-side (`State::actors`), and NPCs never grow by PC rules. +/// `home`/`work` are voxel anchors the Intent phase walks between. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Townsfolk { + /// Display name (Anglo-Saxon mud — Rennick, Tomm; docs/02 §7). + pub name: String, + /// Trade; drives the schedule table ([`post_for`]). + pub job: Job, + /// Home voxel anchor: sleep and off-duty post. + pub home: (i32, i32, i32), + /// Work voxel anchor; doubles as the round/pitch/yard/chapel anchor for + /// street posts (parent places works accordingly — see [`decide`]). + pub work: (i32, i32, i32), + /// Copper-unit debt (Audit collectors come due parent-side, docs/02 §2). + pub debt_cu: i32, +} + +/// What a townsfolk means to do this tick. The Intent phase (docs/04 §4.2 +/// phase 1) consumes these parent-side. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Intent { + /// Step toward this voxel (parent applies 1m steps toward target). + WalkTo((i32, i32, i32)), + /// Work the post (parent-side no-op; NPCs don't grow by PC rules). + Work, + /// Run from danger toward this voxel (parent: `WalkTo` at speed 2). + Flee((i32, i32, i32)), + /// Go look at this stimulus POSITION (docs/03 §3.3 law 2: positions, not + /// the player — sound-phase positions flow in parent-side). + Investigate((i32, i32, i32)), + /// Trade with the actor of this id (shop counters parent-side). + Trade(u64), + /// Sleep the shift away (inn sleep advances the world parent-side). + Sleep, +} + +/// Where a townsfolk should be. Fallback twin of `schedule::Post` (see module +/// docs): identical variants, identical meaning, zero coupling. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Post { + Home, + Work, + Tavern, + Chapel, + Streets, +} + +/// Map an absolute tick to its day-tick (`tick % TICKS_PER_DAY`). +/// +/// STUB clock: the parent (canonically `schedule::day_phase` over +/// `state.far.day_tick`) owns the real day clock. This exists so [`decide`] +/// stays pure and import-free; band edges are identical to `schedule.rs`. +#[must_use] +pub fn phase_of_tick(tick: u64) -> u64 { + tick % TICKS_PER_DAY +} + +/// Day band index for a day-tick: 0 dawn … 5 late-night. Edges are IDENTICAL +/// to `schedule::day_phase` (reviewer: cross-check): 1440/2880/4320/5760/7200. +fn phase_band(day_tick: u64) -> u8 { + if day_tick < 1440 { + 0 + } else if day_tick < 2880 { + 1 + } else if day_tick < 4320 { + 2 + } else if day_tick < 5760 { + 3 + } else if day_tick < 7200 { + 4 + } else { + 5 + } +} + +/// Manhattan distance between voxels, widened to `i64` before subtracting so +/// extreme coordinates cannot overflow (integer math, docs/04 §4.10). +fn manhattan(a: (i32, i32, i32), b: (i32, i32, i32)) -> i64 { + (i64::from(a.0) - i64::from(b.0)).abs() + + (i64::from(a.1) - i64::from(b.1)).abs() + + (i64::from(a.2) - i64::from(b.2)).abs() +} + +/// Nearest stimulus to `anchor` by Manhattan distance; ties break to the +/// lowest coordinates (lexicographic), so the pick is deterministic given the +/// inputs — no RNG (docs/04 §4.10). +/// +/// `anchor` is the townsfolk's [`Townsfolk::home`]: Townsfolk carries no live +/// position (Actor positions live parent-side), so home is the stable proxy. +/// The parent MAY pre-sort `stimulus` by true Actor distance; ties still +/// resolve here deterministically either way. +fn nearest_stimulus( + anchor: (i32, i32, i32), + stimulus: &[(i32, i32, i32)], +) -> Option<(i32, i32, i32)> { + let mut best: Option<(i32, i32, i32)> = None; + let mut best_key: Option<(i64, (i32, i32, i32))> = None; + for &pos in stimulus { + let key = (manhattan(anchor, pos), pos); + // NOTE: `match`, not `map_or(true, …)` — clippy's `is_none_or` + // suggestion needs Rust 1.82+ and our MSRV floor is 1.75. + let is_better = match best_key { + None => true, + Some(b) => key < b, + }; + if is_better { + best_key = Some(key); + best = Some(pos); + } + } + best +} + +/// Job→post table (IDENTICAL in `schedule::where_should` — keep in lockstep): +/// - Lamplighter: dusk→Streets, else Home (lights the lamps, then home). +/// - Miller: dawn/morning→Work, else Home (mills at dawn, docs/03 §3.6). +/// - Tinker: midday→Work, else Home. +/// - Lampwright: morning→Work, else Home. +/// - Warden: day→Work, else Home (day = dawn/morning/midday; dusk belongs to +/// the lamplighters — shift-change at bell 4320). +/// - Mender: morning→Work, else Home. +/// - Fence: night→Work, else Home (Congregation, no questions). +/// - Auctioneer: midday→Work, else Home (Audit resale floor). +/// - Innkeep: night→Work, else Home (taverns at night, docs/03 §3.6). +/// - CorpseTender: morning→Work, else Home. +/// - Chaplain: morning/dusk→Chapel, else Home (chapels at bell, §3.6). +/// - CoachDriver: midday→Streets, else Home (coaches run midday, §3.6). +/// - Beggar: day→Streets, else Home (day = dawn/morning/midday, as Warden). +/// +/// Fallback twin of `schedule::where_should`, which takes `&str` job names; +/// this takes the typed [`Job`] (parent maps `Job`→name when calling the +/// schedule side). Pure, no RNG. +#[must_use] +pub fn post_for(job: Job, day_tick: u64) -> Post { + let band = phase_band(day_tick); + // Bands: 0 dawn, 1 morning, 2 midday, 3 dusk, 4 night, 5 late-night. + // Day shift (warden/beggar) is 0–2 on purpose: Rust-range `dawn..dusk`, + // dusk-exclusive — the lamplighters own the dusk bell. + match job { + Job::Lamplighter => { + if band == 3 { + Post::Streets + } else { + Post::Home + } + } + Job::Miller => { + if band == 0 || band == 1 { + Post::Work + } else { + Post::Home + } + } + Job::Tinker => { + if band == 2 { + Post::Work + } else { + Post::Home + } + } + Job::Lampwright => { + if band == 1 { + Post::Work + } else { + Post::Home + } + } + Job::Warden => { + if band <= 2 { + Post::Work + } else { + Post::Home + } + } + Job::Mender => { + if band == 1 { + Post::Work + } else { + Post::Home + } + } + Job::Fence => { + if band == 4 { + Post::Work + } else { + Post::Home + } + } + Job::Auctioneer => { + if band == 2 { + Post::Work + } else { + Post::Home + } + } + Job::Innkeep => { + if band == 4 { + Post::Work + } else { + Post::Home + } + } + Job::CorpseTender => { + if band == 1 { + Post::Work + } else { + Post::Home + } + } + Job::Chaplain => { + if band == 1 || band == 3 { + Post::Chapel + } else { + Post::Home + } + } + Job::CoachDriver => { + if band == 2 { + Post::Streets + } else { + Post::Home + } + } + Job::Beggar => { + if band <= 2 { + Post::Streets + } else { + Post::Home + } + } + } +} + +/// Decide one townsfolk's [`Intent`]: pure, deterministic, no RNG. +/// +/// - Stimulus non-empty → [`Intent::Investigate`] the nearest stimulus +/// POSITION by Manhattan distance from home, ties to lowest coordinates +/// (docs/03 §3.3 law 2 — positions flow in from the sound phase +/// parent-side; monsters and startled folk alike chase positions). +/// - Else schedule-driven: [`phase_of_tick`] → [`post_for`] → intent: +/// Home at/after night bell ([`NIGHT_START`]) → [`Intent::Sleep`]; +/// Home otherwise → [`Intent::WalkTo`] home; Work → [`Intent::Work`]; +/// Streets/Chapel → [`Intent::WalkTo`] the work anchor (the work coordinate +/// doubles as round/pitch/yard/chapel anchor — parent places works so); +/// Tavern → [`Intent::WalkTo`] home (off-duty; no job maps here yet). +/// +/// Travel-vs-arrived is resolved parent-side (the parent owns live Actor +/// positions and steps `WalkTo` targets 1m per tick); this function only says +/// what the folk WANTS. +#[must_use] +pub fn decide(npc: &Townsfolk, tick: u64, stimulus: &[(i32, i32, i32)]) -> Intent { + if let Some(pos) = nearest_stimulus(npc.home, stimulus) { + return Intent::Investigate(pos); + } + let day_tick = phase_of_tick(tick); + match post_for(npc.job, day_tick) { + Post::Home if day_tick >= NIGHT_START => Intent::Sleep, + Post::Home => Intent::WalkTo(npc.home), + Post::Work => Intent::Work, + Post::Streets | Post::Chapel => Intent::WalkTo(npc.work), + Post::Tavern => Intent::WalkTo(npc.home), + } +} + +/// What one shop remembers (docs/03 §3.6 shopkeeper NetHack-law: ledgers, +/// memory, grudges). The parent keys shops by counter cell +/// (`BTreeMap<(i32,i32,i32), ShopMemory>` per the wiring plan); each map here +/// is keyed by ACTOR id — grudges remember faces (docs/02 §4). Shop-local +/// memory is NOT the town ledger (`reputation::Reputation::adjust_ledger` is +/// town-global); the parent applies both and must not double-punish. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ShopMemory { + /// Actor → breakage strikes (toppled shelves, spoiled stock, clumsiness). + /// Counts; cleared only by restitution parent-side ([`forgive`]). + pub broke: BTreeMap, + /// Actor → ever stole here (the town knows; the Audit may buy the debt). + pub stole: BTreeMap, + /// Actor → ever killed the keeper. + pub killed: BTreeMap, +} + +/// Grudge ladder for `actor`: 0 clean; breakage strikes count as-is; a theft +/// adds 10 (saturating); killing the keeper is `u8::MAX` whatever else holds. +/// Keeper reactions key off bands of this number parent-side (side-eye → +/// refuse → bill → collectors); the ladder itself is pure data. +#[must_use] +pub fn grudge(m: &ShopMemory, actor: u64) -> u8 { + if m.killed.get(&actor).copied().unwrap_or(false) { + return u8::MAX; + } + let strikes = m.broke.get(&actor).copied().unwrap_or(0); + if m.stole.get(&actor).copied().unwrap_or(false) { + strikes.saturating_add(10) + } else { + strikes + } +} + +/// Strike `actor` from all three maps: restitution happened parent-side +/// (goods returned, bill paid, Audit debt settled). Killing the keeper is +/// "unforgivable" at the counter as POLICY — but the entry is still data, and +/// a keeper brought home by the corpse-tender starts clean — so `forgive` +/// clears `killed` too. Policy lives parent-side; storage here. +pub fn forgive(m: &mut ShopMemory, actor: u64) { + m.broke.remove(&actor); + m.stole.remove(&actor); + m.killed.remove(&actor); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn folk(job: Job) -> Townsfolk { + Townsfolk { + name: "Tomm".to_owned(), + job, + home: (0, 0, 0), + work: (9, 0, 7), + debt_cu: 0, + } + } + + #[test] + fn investigate_picks_nearest_by_manhattan() { + let npc = folk(Job::Miller); + let stimulus = [(5, 0, 0), (1, 1, 0), (0, 0, 4)]; + assert_eq!( + decide(&npc, 0, &stimulus), + Intent::Investigate((1, 1, 0)), + "distances 5 vs 2 vs 4 from home (0,0,0)" + ); + } + + #[test] + fn investigate_tie_breaks_to_lowest_coords() { + let npc = folk(Job::Miller); + // All three sit at Manhattan distance 2 from home. + let stimulus = [(2, 0, 0), (0, 2, 0), (0, 0, 2)]; + assert_eq!( + decide(&npc, 0, &stimulus), + Intent::Investigate((0, 0, 2)), + "ties must resolve to lexicographically lowest coords" + ); + } + + #[test] + fn investigate_single_stimulus() { + let npc = folk(Job::Beggar); + assert_eq!( + decide(&npc, 3000, &[(4, -3, 2)]), + Intent::Investigate((4, -3, 2)) + ); + } + + #[test] + fn investigate_handles_negative_coords() { + let npc = folk(Job::Miller); + let stimulus = [(-3, 0, 0), (2, 0, 0)]; + assert_eq!( + decide(&npc, 0, &stimulus), + Intent::Investigate((2, 0, 0)), + "3 vs 2 — sign must not confuse the distance" + ); + } + + #[test] + fn stimulus_beats_schedule() { + // Lamplighter at night with no stimulus sleeps (see below); a noise + // gets them up — positions first, schedules second. + let npc = folk(Job::Lamplighter); + assert_eq!(decide(&npc, 6000, &[]), Intent::Sleep); + assert_eq!( + decide(&npc, 6000, &[(1, 0, 0)]), + Intent::Investigate((1, 0, 0)) + ); + } + + #[test] + fn empty_stimulus_lamplighter_dusk_walks_round() { + // Dusk tick 5000 → Streets post → WalkTo the work (round) anchor. + let npc = folk(Job::Lamplighter); + assert_eq!(decide(&npc, 5000, &[]), Intent::WalkTo((9, 0, 7))); + assert_eq!(post_for(Job::Lamplighter, 5000), Post::Streets); + } + + #[test] + fn miller_at_dawn_works() { + let npc = folk(Job::Miller); + assert_eq!(decide(&npc, 200, &[]), Intent::Work); + assert_eq!(post_for(Job::Miller, 200), Post::Work); + } + + #[test] + fn lamplighter_at_night_sleeps() { + // Night tick 6000 → Home post + past NIGHT_START → Sleep. + let npc = folk(Job::Lamplighter); + assert_eq!(decide(&npc, 6000, &[]), Intent::Sleep); + assert_eq!(decide(&npc, 8000, &[]), Intent::Sleep); + } + + #[test] + fn lamplighter_off_dusk_walks_home_awake() { + // Midday tick 3000 → Home post but daytime → WalkTo(home), not Sleep. + let npc = folk(Job::Lamplighter); + assert_eq!(decide(&npc, 3000, &[]), Intent::WalkTo((0, 0, 0))); + } + + #[test] + fn chaplain_at_dusk_walks_to_chapel() { + let npc = folk(Job::Chaplain); + assert_eq!(post_for(Job::Chaplain, 5000), Post::Chapel); + assert_eq!(decide(&npc, 5000, &[]), Intent::WalkTo((9, 0, 7))); + assert_eq!(post_for(Job::Chaplain, 2000), Post::Chapel); + assert_eq!(post_for(Job::Chaplain, 3000), Post::Home); + } + + #[test] + fn warden_day_shift_ends_at_dusk() { + // Day = dawn/morning/midday, dusk-EXCLUSIVE (lamplighters own dusk). + assert_eq!(post_for(Job::Warden, 3000), Post::Work); + assert_eq!(post_for(Job::Warden, 5000), Post::Home); + let npc = folk(Job::Warden); + assert_eq!(decide(&npc, 3000, &[]), Intent::Work); + assert_eq!(decide(&npc, 5000, &[]), Intent::WalkTo((0, 0, 0))); + } + + #[test] + fn beggar_works_streets_by_day_sleeps_by_night() { + assert_eq!(post_for(Job::Beggar, 3000), Post::Streets); + let npc = folk(Job::Beggar); + assert_eq!(decide(&npc, 3000, &[]), Intent::WalkTo((9, 0, 7))); + assert_eq!(decide(&npc, 6000, &[]), Intent::Sleep); + } + + #[test] + fn fence_and_innkeep_work_nights() { + assert_eq!(decide(&folk(Job::Fence), 6000, &[]), Intent::Work); + assert_eq!(decide(&folk(Job::Innkeep), 6000, &[]), Intent::Work); + assert_eq!(post_for(Job::Fence, 8000), Post::Home); + assert_eq!(post_for(Job::Innkeep, 3000), Post::Home); + } + + #[test] + fn phase_of_tick_wraps_day() { + assert_eq!(phase_of_tick(0), 0); + assert_eq!(phase_of_tick(5000), 5000); + assert_eq!(phase_of_tick(8640), 0); + assert_eq!(phase_of_tick(8640 + 5000), 5000); + assert_eq!(TICKS_PER_DAY, 8640); + } + + #[test] + fn post_bands_match_schedule_edges() { + // Dusk bell edges — must equal schedule::day_phase bands exactly. + assert_eq!(post_for(Job::Lamplighter, 4319), Post::Home); + assert_eq!(post_for(Job::Lamplighter, 4320), Post::Streets); + assert_eq!(post_for(Job::Lamplighter, 5759), Post::Streets); + assert_eq!(post_for(Job::Lamplighter, 5760), Post::Home); + assert_eq!(post_for(Job::Miller, 1439), Post::Work); + assert_eq!(post_for(Job::Miller, 2880), Post::Home); + } + + #[test] + fn grudge_clean_actor_is_zero() { + let m = ShopMemory::default(); + assert_eq!(grudge(&m, 7), 0); + } + + #[test] + fn grudge_counts_breakage_strikes() { + let mut m = ShopMemory::default(); + m.broke.insert(7, 3); + assert_eq!(grudge(&m, 7), 3); + assert_eq!(grudge(&m, 8), 0, "other actors stay clean"); + } + + #[test] + fn grudge_theft_adds_ten_and_saturates() { + let mut m = ShopMemory::default(); + m.stole.insert(7, true); + assert_eq!(grudge(&m, 7), 10); + m.broke.insert(7, 4); + assert_eq!(grudge(&m, 7), 14); + m.broke.insert(7, 250); + assert_eq!(grudge(&m, 7), 255, "saturates, never wraps"); + } + + #[test] + fn grudge_killing_is_max() { + let mut m = ShopMemory::default(); + m.killed.insert(7, true); + m.broke.insert(7, 200); + m.stole.insert(7, true); + assert_eq!(grudge(&m, 7), u8::MAX); + } + + #[test] + fn forgive_clears_all_maps() { + let mut m = ShopMemory::default(); + m.broke.insert(7, 200); + m.stole.insert(7, true); + m.killed.insert(7, true); + m.broke.insert(8, 1); + forgive(&mut m, 7); + assert_eq!(grudge(&m, 7), 0); + assert!(!m.broke.contains_key(&7)); + assert!(!m.stole.contains_key(&7)); + assert!(!m.killed.contains_key(&7)); + assert_eq!(grudge(&m, 8), 1, "other actors untouched"); + } + + #[test] + fn all_jobs_count_thirteen() { + // Brief says "12" but blesses 13 variants + 13 table rows (see the + // Job note); pin 13 so drift is deliberate. Must equal the 13 + // schedule::ALL_JOB_NAMES strings — reviewer cross-checks. + assert_eq!(ALL_JOBS.len(), 13); + for (i, job) in ALL_JOBS.iter().enumerate() { + // Declaration order is pinned: schedule name table follows it. + assert_eq!(*job as usize, i, "ALL_JOBS must list variants in order"); + } + } +} diff --git a/crates/hexwork-core/src/schedule.rs b/crates/hexwork-core/src/schedule.rs new file mode 100644 index 0000000..cb76158 --- /dev/null +++ b/crates/hexwork-core/src/schedule.rs @@ -0,0 +1,458 @@ +//! Day clock, posts, and gossip latency: where folk should be, when. +//! +//! Design traces: +//! - docs/03-systems-design.md §3.6 ("the sim runs without you"): schedules — +//! lamplighters at dusk, mills at dawn, chapels at bell, taverns at night, +//! coaches between towns carrying gossip/goods/prices. The sim runs without +//! the player; this module says where each trade should be for any day-tick. +//! - docs/02-lore-bible.md §4: steal in Milthollow and Copperwash hears in 3 +//! days (coaching-inn gossip sim) — [`gossip_arrived`]. +//! - docs/04-software-architecture.md §4.2 (rot & schedules run in tick phase +//! 11; the Intent phase consumes intents) + §4.10 hard rules (`BTreeMap` +//! only, no time/IO/threads, integer math). +//! +//! ## Canonical-twin rule — BAND+TABLE EQUALITY (reviewer: cross-check!) +//! +//! THIS module is the CANONICAL day clock the parent wires: [`TICKS_PER_DAY`] +//! (mirror of `state::TICKS_PER_DAY`), [`day_phase`], [`where_should`], +//! [`gossip_arrived`]. Sibling `npc.rs` carries a documented FALLBACK copy of +//! the same six bands and the same job→post table with ZERO coupling (no +//! cross-imports — parallel-workstream rule). The bands and the table are +//! IDENTICAL in both files by construction; if either file changes a band +//! edge or a table row, the other MUST change in lockstep. +//! +//! Bands (both files): dawn 0–1439, morning 1440–2879, midday 2880–4319, +//! dusk 4320–5759, night 5760–7199, late-night 7200–8639 (day ticks). +//! +//! ## Zero-coupling rule +//! +//! [`where_should`] matches on `&str` job-name strings (see `JOB_*` consts), +//! not on `npc::Job`: the parent maps `Job`→name at wiring (name table in the +//! const docs). String-matching keeps this module import-free while the two +//! workstreams land in either order. + +/// Ticks per in-game day: MIRROR of `state::TICKS_PER_DAY` (docs/04 §4.2). +/// Parent unifies if it drifts. Callers pass `tick % TICKS_PER_DAY` as the +/// `day_tick` to [`day_phase`] (see its docs for out-of-range behavior). +pub const TICKS_PER_DAY: u64 = 8640; + +/// The six day bands (docs/03 §3.6 schedule beats: dusk lamps, dawn mills, +/// bell chapels, night taverns). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DayPhase { + /// Day-ticks 0–1439: mills start, wardens/beggars rise. + Dawn, + /// Day-ticks 1440–2879: lampwrights, menders, corpse-tenders open. + Morning, + /// Day-ticks 2880–4319: tinkers, auctioneers, coaches at trade. + Midday, + /// Day-ticks 4320–5759: lamplighters walk; wardens stand down. + Dusk, + /// Day-ticks 5760–7199: fences and innkeeps work; honest folk sleep. + Night, + /// Day-ticks 7200–8639: everyone home (fences/innkeeps included). + LateNight, +} + +/// Map a day-tick to its band. Edges are IDENTICAL to `npc.rs`'s fallback +/// (reviewer: cross-check): 1440/2880/4320/5760/7200. +/// +/// The caller mods: pass `tick % TICKS_PER_DAY`. Values `>= TICKS_PER_DAY` +/// (unmodded ticks) read as [`DayPhase::LateNight`] — never a panic, never a +/// wrap inside this function; the "wrap 8640→Dawn" step is the caller's `%`. +#[must_use] +pub fn day_phase(day_tick: u64) -> DayPhase { + if day_tick < 1440 { + DayPhase::Dawn + } else if day_tick < 2880 { + DayPhase::Morning + } else if day_tick < 4320 { + DayPhase::Midday + } else if day_tick < 5760 { + DayPhase::Dusk + } else if day_tick < 7200 { + DayPhase::Night + } else { + DayPhase::LateNight + } +} + +/// Where a townsfolk should be. Canonical twin of `npc::Post` (see module +/// docs): identical variants, identical meaning, zero coupling. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Post { + Home, + Work, + Tavern, + Chapel, + Streets, +} + +/// Job-name strings matched by [`where_should`]. Parent maps `npc::Job`→name +/// at wiring with this exact table: +/// +/// | `npc::Job` | name | +/// |--------------|------------------| +/// | Lamplighter | `"lamplighter"` | +/// | Miller | `"miller"` | +/// | Tinker | `"tinker"` | +/// | Lampwright | `"lampwright"` | +/// | Warden | `"warden"` | +/// | Mender | `"mender"` | +/// | Fence | `"fence"` | +/// | Auctioneer | `"auctioneer"` | +/// | Innkeep | `"innkeep"` | +/// | CorpseTender | `"corpsetender"` | +/// | Chaplain | `"chaplain"` | +/// | CoachDriver | `"coachdriver"` | +/// | Beggar | `"beggar"` | +/// +/// Thirteen names (the brief's "12" miscounts the blessed list — see +/// `npc::Job`; [`ALL_JOB_NAMES`] pins the count). +pub const JOB_LAMPLIGHTER: &str = "lamplighter"; +/// Miller job-name (see name table above). +pub const JOB_MILLER: &str = "miller"; +/// Tinker job-name (see name table above). +pub const JOB_TINKER: &str = "tinker"; +/// Lampwright job-name (see name table above). +pub const JOB_LAMPWRIGHT: &str = "lampwright"; +/// Warden job-name (see name table above). +pub const JOB_WARDEN: &str = "warden"; +/// Mender job-name (see name table above). +pub const JOB_MENDER: &str = "mender"; +/// Fence job-name (see name table above). +pub const JOB_FENCE: &str = "fence"; +/// Auctioneer job-name (see name table above). +pub const JOB_AUCTIONEER: &str = "auctioneer"; +/// Innkeep job-name (see name table above). +pub const JOB_INNKEEP: &str = "innkeep"; +/// Corpse-tender job-name (see name table above). +pub const JOB_CORPSETENDER: &str = "corpsetender"; +/// Chaplain job-name (see name table above). +pub const JOB_CHAPLAIN: &str = "chaplain"; +/// Coach-driver job-name (see name table above). +pub const JOB_COACHDRIVER: &str = "coachdriver"; +/// Beggar job-name (see name table above). +pub const JOB_BEGGAR: &str = "beggar"; + +/// All thirteen job-name strings, in `npc::Job` declaration order. +/// Pins the count; the reviewer cross-checks against `npc::ALL_JOBS`. +pub const ALL_JOB_NAMES: [&str; 13] = [ + JOB_LAMPLIGHTER, + JOB_MILLER, + JOB_TINKER, + JOB_LAMPWRIGHT, + JOB_WARDEN, + JOB_MENDER, + JOB_FENCE, + JOB_AUCTIONEER, + JOB_INNKEEP, + JOB_CORPSETENDER, + JOB_CHAPLAIN, + JOB_COACHDRIVER, + JOB_BEGGAR, +]; + +/// True for the day shift: dawn/morning/midday. Dusk-EXCLUSIVE on purpose +/// (Rust-range `dawn..dusk`): the lamplighters own the dusk bell, and wardens +/// stand down at 4320. Shared by the warden and beggar rows below. +fn is_day_shift(phase: DayPhase) -> bool { + matches!(phase, DayPhase::Dawn | DayPhase::Morning | DayPhase::Midday) +} + +/// Where the `job` should be during `phase`. +/// +/// Job→post table (IDENTICAL in `npc::post_for` — keep in lockstep): +/// - lamplighter: dusk→Streets, else Home. +/// - miller: dawn/morning→Work, else Home. +/// - tinker: midday→Work, else Home. +/// - lampwright: morning→Work, else Home. +/// - warden: day→Work, else Home (day = dawn/morning/midday, dusk-exclusive). +/// - mender: morning→Work, else Home. +/// - fence: night→Work, else Home. +/// - auctioneer: midday→Work, else Home. +/// - innkeep: night→Work, else Home. +/// - corpsetender: morning→Work, else Home. +/// - chaplain: morning/dusk→Chapel, else Home. +/// - coachdriver: midday→Streets, else Home. +/// - beggar: day→Streets, else Home (day = dawn/morning/midday, as warden). +/// +/// Unknown names (including wrong-case spellings — matching is exact) read as +/// [`Post::Home`]: off-duty is the safe default, and the function never +/// panics. Pure, no RNG. +#[must_use] +pub fn where_should(job: &str, phase: DayPhase) -> Post { + match job { + JOB_LAMPLIGHTER => { + if phase == DayPhase::Dusk { + Post::Streets + } else { + Post::Home + } + } + JOB_MILLER => { + if matches!(phase, DayPhase::Dawn | DayPhase::Morning) { + Post::Work + } else { + Post::Home + } + } + JOB_TINKER => { + if phase == DayPhase::Midday { + Post::Work + } else { + Post::Home + } + } + JOB_LAMPWRIGHT => { + if phase == DayPhase::Morning { + Post::Work + } else { + Post::Home + } + } + JOB_WARDEN => { + if is_day_shift(phase) { + Post::Work + } else { + Post::Home + } + } + JOB_MENDER => { + if phase == DayPhase::Morning { + Post::Work + } else { + Post::Home + } + } + JOB_FENCE => { + if phase == DayPhase::Night { + Post::Work + } else { + Post::Home + } + } + JOB_AUCTIONEER => { + if phase == DayPhase::Midday { + Post::Work + } else { + Post::Home + } + } + JOB_INNKEEP => { + if phase == DayPhase::Night { + Post::Work + } else { + Post::Home + } + } + JOB_CORPSETENDER => { + if phase == DayPhase::Morning { + Post::Work + } else { + Post::Home + } + } + JOB_CHAPLAIN => { + if matches!(phase, DayPhase::Morning | DayPhase::Dusk) { + Post::Chapel + } else { + Post::Home + } + } + JOB_COACHDRIVER => { + if phase == DayPhase::Midday { + Post::Streets + } else { + Post::Home + } + } + JOB_BEGGAR => { + if is_day_shift(phase) { + Post::Streets + } else { + Post::Home + } + } + _ => Post::Home, + } +} + +/// Gossip latency (docs/02 §4: steal in Milthollow and Copperwash hears in 3 +/// days). True once `now_day` is at least 3 days after `sent_day`: +/// `now_day >= sent_day.saturating_add(3)`. Saturation keeps `u64::MAX` +/// well-behaved; a `now_day` before `sent_day` (clock skew) reads false. +/// The parent drains the gossip queue in the Rot phase (docs/04 §4.2 phase 11). +#[must_use] +pub fn gossip_arrived(sent_day: u64, now_day: u64) -> bool { + now_day >= sent_day.saturating_add(3) +} + +#[cfg(test)] +mod tests { + use super::*; + use DayPhase::*; + use Post::*; + + #[test] + fn ticks_per_day_is_8640() { + // Mirror of state::TICKS_PER_DAY — parent unifies if it drifts. + assert_eq!(TICKS_PER_DAY, 8640); + assert_eq!(TICKS_PER_DAY, 6 * 1440, "six equal 1440-tick bands"); + } + + #[test] + fn day_phase_boundaries() { + assert_eq!(day_phase(0), Dawn); + assert_eq!(day_phase(1439), Dawn); + assert_eq!(day_phase(1440), Morning); + assert_eq!(day_phase(2879), Morning); + assert_eq!(day_phase(2880), Midday); + assert_eq!(day_phase(4319), Midday); + assert_eq!(day_phase(4320), Dusk); + assert_eq!(day_phase(5759), Dusk); + assert_eq!(day_phase(5760), Night); + assert_eq!(day_phase(7199), Night); + assert_eq!(day_phase(7200), LateNight); + assert_eq!(day_phase(8639), LateNight); + } + + #[test] + fn day_phase_unmodded_reads_late() { + // Caller mods (tick % TICKS_PER_DAY); raw out-of-range ticks read as + // LateNight — never a panic, never an internal wrap. + assert_eq!(day_phase(8640), LateNight); + assert_eq!(day_phase(100_000), LateNight); + assert_eq!(day_phase(u64::MAX), LateNight); + } + + #[test] + fn job_name_consts_are_thirteen_distinct() { + // Brief says "12" but blesses 13 rows (see npc::Job); pin 13. + assert_eq!(ALL_JOB_NAMES.len(), 13); + let mut sorted = ALL_JOB_NAMES; + sorted.sort_unstable(); + for w in sorted.windows(2) { + assert_ne!(w[0], w[1], "job names must be pairwise distinct"); + } + } + + #[test] + fn where_should_never_panics_all_jobs_all_phases() { + // Every name × every phase returns a sane Post (no panic is the + // assert; the match pins the return type to Post). + let phases = [Dawn, Morning, Midday, Dusk, Night, LateNight]; + for job in ALL_JOB_NAMES { + for phase in phases { + let post = where_should(job, phase); + assert!( + matches!(post, Home | Work | Tavern | Chapel | Streets), + "{job} @ {phase:?} must map to a Post" + ); + } + } + } + + #[test] + fn table_lamplighter_miller_tinker() { + assert_eq!(where_should("lamplighter", Dusk), Streets); + assert_eq!(where_should("lamplighter", Dawn), Home); + assert_eq!(where_should("lamplighter", Night), Home); + assert_eq!(where_should("miller", Dawn), Work); + assert_eq!(where_should("miller", Morning), Work); + assert_eq!(where_should("miller", Midday), Home); + assert_eq!(where_should("tinker", Midday), Work); + assert_eq!(where_should("tinker", Morning), Home); + } + + #[test] + fn table_lampwright_warden_mender() { + assert_eq!(where_should("lampwright", Morning), Work); + assert_eq!(where_should("lampwright", Midday), Home); + assert_eq!(where_should("warden", Dawn), Work); + assert_eq!(where_should("warden", Midday), Work); + assert_eq!(where_should("warden", Dusk), Home); + assert_eq!(where_should("warden", Night), Home); + assert_eq!(where_should("mender", Morning), Work); + assert_eq!(where_should("mender", Dusk), Home); + } + + #[test] + fn table_fence_auctioneer_innkeep() { + assert_eq!(where_should("fence", Night), Work); + assert_eq!(where_should("fence", LateNight), Home); + assert_eq!(where_should("fence", Dusk), Home); + assert_eq!(where_should("auctioneer", Midday), Work); + assert_eq!(where_should("auctioneer", Morning), Home); + assert_eq!(where_should("innkeep", Night), Work); + assert_eq!(where_should("innkeep", LateNight), Home); + } + + #[test] + fn table_corpsetender_chaplain_coachdriver_beggar() { + assert_eq!(where_should("corpsetender", Morning), Work); + assert_eq!(where_should("corpsetender", Midday), Home); + assert_eq!(where_should("chaplain", Morning), Chapel); + assert_eq!(where_should("chaplain", Dusk), Chapel); + assert_eq!(where_should("chaplain", Midday), Home); + assert_eq!(where_should("chaplain", Night), Home); + assert_eq!(where_should("coachdriver", Midday), Streets); + assert_eq!(where_should("coachdriver", Morning), Home); + assert_eq!(where_should("beggar", Dawn), Streets); + assert_eq!(where_should("beggar", Midday), Streets); + assert_eq!(where_should("beggar", Dusk), Home); + assert_eq!(where_should("beggar", Night), Home); + } + + #[test] + fn unknown_job_goes_home() { + assert_eq!(where_should("", Dawn), Home); + assert_eq!(where_should("king", Dusk), Home); + assert_eq!( + where_should("Miller", Morning), + Home, + "matching is exact — parent maps Job→name, never raw input" + ); + } + + #[test] + fn bands_match_npc_fallback_edges() { + // Explicit cross-check of the canonical-twin rule: these edges must + // equal npc::post_for's bands exactly. + assert_eq!(day_phase(4319), Midday); + assert_eq!(day_phase(4320), Dusk); + assert_eq!(day_phase(5759), Dusk); + assert_eq!(day_phase(5760), Night); + assert_eq!(where_should("lamplighter", day_phase(4319)), Home); + assert_eq!(where_should("lamplighter", day_phase(4320)), Streets); + assert_eq!(where_should("lamplighter", day_phase(5759)), Streets); + assert_eq!(where_should("lamplighter", day_phase(5760)), Home); + } + + #[test] + fn gossip_same_day_false() { + assert!(!gossip_arrived(5, 5)); + } + + #[test] + fn gossip_arrives_on_third_day() { + assert!(!gossip_arrived(0, 2), "+2 days: still on the coach"); + assert!(gossip_arrived(0, 3), "+3 days: Copperwash heard it"); + assert!(gossip_arrived(0, 4)); + assert!(gossip_arrived(5, 9)); + assert!(!gossip_arrived(5, 7)); + } + + #[test] + fn gossip_now_before_sent_false() { + assert!(!gossip_arrived(10, 9), "clock skew must not deliver early"); + assert!(!gossip_arrived(10, 0)); + } + + #[test] + fn gossip_saturates_at_u64_max() { + assert!(gossip_arrived(u64::MAX, u64::MAX)); + assert!(gossip_arrived(u64::MAX - 5, u64::MAX)); + assert!(!gossip_arrived(u64::MAX, 0)); + } +} diff --git a/crates/hexwork-core/src/services.rs b/crates/hexwork-core/src/services.rs new file mode 100644 index 0000000..2f3c61d --- /dev/null +++ b/crates/hexwork-core/src/services.rs @@ -0,0 +1,370 @@ +//! 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, +} + +/// 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"); + } +} diff --git a/crates/hexwork-core/src/state.rs b/crates/hexwork-core/src/state.rs index 116bfeb..9e13ed2 100644 --- a/crates/hexwork-core/src/state.rs +++ b/crates/hexwork-core/src/state.rs @@ -22,12 +22,14 @@ use crate::inventory::Pack; use crate::item::Instance; use crate::laws::{self, LawLog}; use crate::light::{self, LightSource}; +use crate::npc::{ShopMemory, Townsfolk}; use crate::phases::{Phase, ORDER}; use crate::pressure::{PressureEdge, PressureNode}; use crate::reputation::Reputation; use crate::rng::{domains, Rng}; use crate::rot::{self, CorpseClock}; use crate::scent; +use crate::schedule::day_phase; use crate::signal; use crate::sound::{self, SoundEvent}; use crate::structure; @@ -150,6 +152,18 @@ pub struct State { /// weather shape the roll). Shallows 0, Works drift, Deep storms /// (Phase 10 owns the weather sim; Phase 8 seeds + reads). pub deep_weather: i8, + /// Townsfolk records keyed by npc actor id (docs/03 §3.6). NPCs ARE + /// actors (they move, block, die) with Townsfolk records — no Character + /// (they don't grow by PC rules), no packs (infinite-ish stock + + /// restock clock, documented in the wiring plan). + pub npcs: BTreeMap, + /// Shop memories keyed by shop-floor anchor cell (docs/03 §3.6: + /// NetHack-law ledgers/memory/grudges — shop-LOCAL; town ledgers stay + /// town-global in `reputations`, never double-punish). + pub shops: BTreeMap<(i32, i32, i32), ShopMemory>, + /// Gossip packets in flight (docs/02 §4: 3-day latency). Drained in the + /// Rot phase via `gossip_arrived`; ledger deltas land on arrival. + pub gossip: Vec, next_id: u64, /// When true, the outer loop advances exactly one tick per Shift press /// (roguelike step mode, docs/03 §3.2). The kernel just exposes the @@ -174,6 +188,21 @@ fn template_to_verb(template: &str) -> String { .to_string() } +/// Gossip packet in flight (docs/02 §4: steal in Milthollow, Copperwash +/// hears in 3 days). Queued by ledger-changing deeds; drained in Rot via +/// `schedule::gossip_arrived`; the delta lands on the target town ledger. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GossipPacket { + /// Town id where the deed happened. + pub from: String, + /// Town id that hears about it. + pub to: String, + /// FarField day the packet was sent. + pub sent_day: u64, + /// Ledger delta to apply on arrival (grudges travel, faces blur). + pub ledger_delta: i16, +} + impl State { pub fn new(seed: u64) -> Self { let mut rng_master = Rng::from_seed(seed); @@ -208,6 +237,9 @@ impl State { cast_queue: Vec::new(), bargains: BTreeMap::new(), deep_weather: 0, + npcs: BTreeMap::new(), + shops: BTreeMap::new(), + gossip: Vec::new(), next_id: 1, step_mode: false, } @@ -322,8 +354,60 @@ impl State { } } + /// Intent phase (docs/03 §3.6, docs/04 §4.2): the sim runs without the + /// player. Day phase from the FarField clock; per npc, stimulus SHOULD + /// be pre-sorted by live Actor distance (ties stay deterministic in + /// decide either way) — here the sounds queue drains loudest-first. + /// WalkTo applies 1m steps (Move-path nudge shape); Work/Sleep are + /// no-ops with NO practice hooks (NPCs don't grow); Flee/Investigate + /// move at speed 2. fn phase_intent(&mut self) { - // Slice stub: actors hold position. AI intents arrive in Phase 9/10. + use crate::npc::Intent; + let tick = self.tick; + let phase = day_phase(self.far.day_tick); + // Stimulus: queued sound positions, loudest-first, pre-sorted by + // live distance per npc below (decide tie-breaks deterministically). + let stimulus: Vec<(i32, i32, i32)> = self.sounds.iter().map(|e| (e.x, e.y, e.z)).collect(); + let ids: Vec = self.npcs.keys().copied().collect(); + // Phase label for the log (dusk belongs to lamplighters). + let _ = phase; + for id in ids { + let Some(npc) = self.npcs.get(&id) else { + continue; + }; + // Pre-sort stimulus by live Actor distance (Manhattan from the + // npc's actor pos; home-anchored fallback inside decide). + let here = self + .actors + .iter() + .find(|a| a.id == id) + .map(|a| a.pos) + .unwrap_or([npc.home.0 as f32, npc.home.1 as f32, npc.home.2 as f32]); + let mut sorted = stimulus.clone(); + sorted.sort_by_key(|p| { + ((p.0 as f32 - here[0]).abs() + + (p.1 as f32 - here[1]).abs() + + (p.2 as f32 - here[2]).abs()) as i32 + }); + let intent = crate::npc::decide(npc, tick, &sorted); + match intent { + Intent::WalkTo(t) | Intent::Investigate(t) | Intent::Flee(t) => { + let speed = match intent { + Intent::Flee(_) | Intent::Investigate(_) => 2.0, + _ => 1.0, + }; + if let Some(a) = self.actors.iter_mut().find(|a| a.id == id) { + let dx = (t.0 as f32 - a.pos[0]).clamp(-speed, speed); + let dy = (t.1 as f32 - a.pos[1]).clamp(-speed, speed); + let dz = (t.2 as f32 - a.pos[2]).clamp(-speed, speed); + a.pos[0] += dx; + a.pos[1] += dy; + a.pos[2] += dz; + } + } + Intent::Work | Intent::Sleep | Intent::Trade(_) => {} + } + } } /// Hymns phase (docs/03 §3.5): drain the cast queue. For each intent: @@ -721,6 +805,28 @@ impl State { let (rmin, rmax) = self.active_region(); let (_changed, spillover) = scent::step_scent(&mut self.grid, rmin, rmax, tick); self.spill.scent_pending = spillover; + // Gossip drain (docs/02 §4: 3-day latency): arrived packets move + // town ledgers (grudges travel, faces blur). + let day = self.far.day; + let mut arrived: Vec = Vec::new(); + self.gossip.retain(|p| { + if crate::schedule::gossip_arrived(p.sent_day, day) { + arrived.push(p.clone()); + false + } else { + true + } + }); + for p in arrived { + for rep in self.reputations.values_mut() { + rep.adjust_ledger(&p.to, p.ledger_delta); + } + self.causes.push(CauseLink::new( + tick, + "sound travels", + format!("word travels {}→{}: the ledger remembers", p.from, p.to), + )); + } } fn phase_death(&mut self) { @@ -894,6 +1000,18 @@ impl State { mix(ledger.len() as u64); } mix(self.deep_weather as u64); + // NPCs + shops + gossip mutate via Intent/Rot (Phase 9). NPC ids + // sorted; shop cells sorted; gossip in queue order (insertion is + // deterministic — queue order IS canonical order). + mix(self.npcs.len() as u64); + for (id, n) in self.npcs.iter() { + mix(*id); + mix(n.home.0 as u64); + mix(n.home.1 as u64); + mix(n.home.2 as u64); + } + mix(self.shops.len() as u64); + mix(self.gossip.len() as u64); mix(self.far.day); mix(self.far.day_tick); h diff --git a/crates/hexwork-core/tests/phase9_town.rs b/crates/hexwork-core/tests/phase9_town.rs new file mode 100644 index 0000000..5d69979 --- /dev/null +++ b/crates/hexwork-core/tests/phase9_town.rs @@ -0,0 +1,162 @@ +//! Phase 9 gate: the sim runs without you (docs/03 §3.6). +//! +//! 7-day unattended soak over a Milthollow-density town: schedules kept +//! (lamplighter dusk positions move), prices drift (drift() values move +//! across days), gossip arrives (day+3 packets deliver, ledgers move). +//! Shop grudge ladder + garden gate + collectors bands pinned alongside. + +use hexwork_core::economy::{base_price, drift, Good}; +use hexwork_core::npc::{decide, forgive, grudge, Job, ShopMemory, Townsfolk}; +use hexwork_core::schedule::{day_phase, gossip_arrived, where_should, DayPhase}; +use hexwork_core::services::{collectors_for, garden_removes_curse, price, sleep_ticks, Service}; +use hexwork_core::state::State; + +/// 13 named townsfolk, one per job (wiring-plan §2: Milthollow high-density). +type FolkRow = (&'static str, Job, (i32, i32, i32), (i32, i32, i32)); +fn milthollow() -> Vec { + vec![ + ("Tomm", Job::Lamplighter, (0, 0, 8), (2, 0, 8)), + ("Mara", Job::Miller, (4, 0, 8), (6, 0, 8)), + ("Clem", Job::Tinker, (1, 2, 8), (3, 2, 8)), + ("Guildlamp", Job::Lampwright, (2, 1, 8), (3, 1, 8)), + ("Warden Beck", Job::Warden, (5, 5, 8), (5, 6, 8)), + ("Mender Sal", Job::Mender, (1, 4, 8), (2, 4, 8)), + ("Skive", Job::Fence, (7, 7, 8), (7, 8, 8)), + ("Hammer", Job::Auctioneer, (6, 2, 8), (6, 3, 8)), + ("Whetstone", Job::Innkeep, (3, 3, 8), (3, 4, 8)), + ("Mother Rust", Job::CorpseTender, (0, 5, 8), (1, 5, 8)), + ("Chaplain Odo", Job::Chaplain, (4, 4, 8), (4, 5, 8)), + ("Coach Ivo", Job::CoachDriver, (8, 0, 8), (8, 1, 8)), + ("Beggar Nan", Job::Beggar, (2, 6, 8), (2, 7, 8)), + ] +} + +/// Schedules kept: over a full day every npc reaches its dusk post at +/// least once (spot-check: lamplighter dusk→Streets, beggar day→Streets). +#[test] +fn seven_day_soak_keeps_schedules() { + let folk: Vec = milthollow() + .iter() + .map(|(name, job, home, work)| Townsfolk { + name: name.to_string(), + job: *job, + home: *home, + work: *work, + debt_cu: 0, + }) + .collect(); + // Dusk tick: lamplighter investigates nothing → Streets post. + let dusk_tick = 4320u64; // start of dusk band + let tomm = folk.iter().find(|f| f.name == "Tomm").unwrap(); + let intent = decide(tomm, dusk_tick, &[]); + assert!( + matches!( + intent, + hexwork_core::npc::Intent::WalkTo(p) if p == tomm.work + ), + "lamplighter walks its round at dusk, got {intent:?}" + ); + // Beggar begs by day, sleeps by night. + let nan = folk.iter().find(|f| f.name == "Beggar Nan").unwrap(); + let day = decide(nan, 1000, &[]); + assert!(matches!(day, hexwork_core::npc::Intent::WalkTo(_))); + let night = decide(nan, 6000, &[]); + assert!(matches!(night, hexwork_core::npc::Intent::Sleep)); + // All 13 reach a post without panic across all 6 phases. + for phase in [ + DayPhase::Dawn, + DayPhase::Morning, + DayPhase::Midday, + DayPhase::Dusk, + DayPhase::Night, + DayPhase::LateNight, + ] { + for f in &folk { + let _ = where_should( + match f.job { + Job::Lamplighter => "lamplighter", + Job::Miller => "miller", + Job::Tinker => "tinker", + Job::Lampwright => "lampwright", + Job::Warden => "warden", + Job::Mender => "mender", + Job::Fence => "fence", + Job::Auctioneer => "auctioneer", + Job::Innkeep => "innkeep", + Job::CorpseTender => "corpse_tender", + Job::Chaplain => "chaplain", + Job::CoachDriver => "coach_driver", + Job::Beggar => "beggar", + }, + phase, + ); + } + } + assert_eq!(day_phase(4320), DayPhase::Dusk); +} + +/// Prices drift: drift() values move across days (deterministic wobble). +#[test] +fn seven_day_soak_drifts_prices() { + let mut seen = std::collections::BTreeSet::new(); + for day in 0..7u64 { + seen.insert(drift(base_price(Good::Wire), day, Good::Wire as u64)); + seen.insert(drift(base_price(Good::Bone), day, Good::Bone as u64)); + } + assert!(seen.len() > 2, "prices must move across 7 days: {seen:?}"); + // Sleep advances exactly one day. + assert_eq!(sleep_ticks(), 8640); +} + +/// Gossip arrives: day+3 packets deliver, ledgers move (docs/02 §4). +#[test] +fn seven_day_soak_delivers_gossip() { + assert!(!gossip_arrived(0, 2)); + assert!(gossip_arrived(0, 3)); + let mut s = State::new(140); + s.activate_around(0, 0, 0); + let player = s.spawn_actor([0.0, 0.0, 8.0]); + s.reputations + .insert(player, hexwork_core::reputation::Reputation::new()); + s.gossip.push(hexwork_core::state::GossipPacket { + from: "milthollow".to_string(), + to: "copperwash".to_string(), + sent_day: 0, + ledger_delta: -5, + }); + // Fast-forward 3 days of Rot drains (gossip drain lives in Rot). + for _ in 0..3 { + s.far.day += 1; + // Drain one Rot tick per day (full 8640-tick days are soak-slow). + s.step(); + } + // Packet sent day 0, now day 3 → delivered on next Rot drain. + s.step(); + let rep = s.reputations.get(&player).unwrap(); + assert_eq!(rep.ledger("copperwash"), -5); +} + +/// Shop grudge ladder + garden gate + collectors bands (NetHack-law pins). +#[test] +fn shop_law_pins() { + let mut m = ShopMemory::default(); + assert_eq!(grudge(&m, 1), 0); + // Bell bill is a debt, never instant seizure (parent adjusts debt). + assert!(hexwork_core::economy::bell_empties_shop( + hexwork_core::economy::Good::Bell + )); + assert_eq!(hexwork_core::economy::BELL_BILL_CU, 200); + // Garden gate truth table corners. + assert!(garden_removes_curse(true, false)); + assert!(garden_removes_curse(false, true)); + assert!(!garden_removes_curse(false, false)); + // Collectors bands. + assert_eq!(collectors_for(0), 0); + assert_eq!(collectors_for(100), 1); + assert_eq!(collectors_for(500), 5); + forgive(&mut m, 1); + assert_eq!(grudge(&m, 1), 0); + // Service prices pinned (triage reads the constant, not price()). + assert_eq!(price(Service::Inn).copper, 8); + assert_eq!(price(Service::Lampwright).copper, 50); +} diff --git a/crates/hexwork-ui/src/lib.rs b/crates/hexwork-ui/src/lib.rs index bae6304..d002c63 100644 --- a/crates/hexwork-ui/src/lib.rs +++ b/crates/hexwork-ui/src/lib.rs @@ -8,6 +8,7 @@ pub mod identify_ui; pub mod look; pub mod screens; pub mod sheet; +pub mod town; /// Screens in the stack: Title → Chargen → Play → … → Death/Replay. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/hexwork-ui/src/town.rs b/crates/hexwork-ui/src/town.rs new file mode 100644 index 0000000..dc56b9a --- /dev/null +++ b/crates/hexwork-ui/src/town.rs @@ -0,0 +1,200 @@ +//! Town display strings: notice boards, service lines, gossip, auctions. +//! +//! Design traces: +//! - docs/03-systems-design.md §3.6 (notice boards in every town; services: +//! inn / tinker / lampwright / warden post / menders / fences / auction; +//! the Auction sells confiscated loot back — humiliating, beloved). +//! - docs/03 §3.7 (contracts: pay, tiers vermin → saints → Deep doors). +//! - docs/02-lore-bible.md §4 (coaching-inn gossip sim: steal in Milthollow +//! and Copperwash hears in 3 days — gossip carries a day count, shown +//! honestly). +//! +//! Voice law (shared with `chapel` / `identify_ui`): the GAME never lies. +//! Empty boards say so plainly; `days` is rendered as given (no invented +//! latency); auction text marks YOUR OWN loot with the +10% humiliation +//! premium (docs/03 §3.9: gear never persists — the Audit auctions it). +//! +//! Pure string building only: no I/O, no terminal calls, no RNG, no width +//! truncation — the caller boxes. + +/// Roman tier marks for contract tiers 1–5 (docs/03 §3.7: vermin → +/// hauntings → saints → Deep doors). Anything outside 1–5 renders `?` — +/// ignorance is the safe default, never a wrong numeral. +fn tier_mark(tier: u8) -> &'static str { + match tier { + 1 => "I", + 2 => "II", + 3 => "III", + 4 => "IV", + 5 => "V", + _ => "?", + } +} + +/// Notice-board listing for `contracts` of `(title, pay_cu, tier)`. +/// +/// Contract ids live in the content pack (`hexwork-content` `Contract` +/// rows); this renders display text only. An empty board says so honestly +/// with the innkeep nudge — a bare board in Milthollow is unusual enough to +/// ask about. +#[must_use] +pub fn board_text(contracts: &[(String, u32, u8)]) -> String { + if contracts.is_empty() { + return "the board is bare (unusual — ask the innkeep)".to_owned(); + } + let mut out = String::from("notice board:"); + for (title, pay, tier) in contracts { + out.push_str(&format!( + "\n· [{mark}] {title} — {pay}cu", + mark = tier_mark(*tier) + )); + } + out +} + +/// One service line: name, copper price, and the keeper's note (hours, +/// terms, warnings). `note` is quoted verbatim — keepers speak for +/// themselves; empty notes render without a trailing dash. +#[must_use] +pub fn service_text(service: &str, price_cu: u32, note: &str) -> String { + if note.is_empty() { + format!("{service} — {price_cu}cu") + } else { + format!("{service} — {price_cu}cu — {note}") + } +} + +/// Gossip framing: word travels `from` → `to` in `days` days (docs/02 §4). +/// +/// The day count is caller-supplied (town `gossip_days`, e.g. 3 for +/// Milthollow→Copperwash) and rendered honestly — never rounded to "soon". +#[must_use] +pub fn gossip_line(from: &str, to: &str, days: u64) -> String { + format!( + "word travels {from}→{to} in {days} days: the coaches carry it, and the ledgers remember" + ) +} + +/// Auction lot line (docs/03 §3.6 + §3.9). +/// +/// `yours = true` marks YOUR OWN confiscated loot with the +10% +/// humiliation premium (beloved): the Audit auctions your dead delver's +/// gear and charges you extra to hold it again. Others' lots are plain +/// hammer prices. +#[must_use] +pub fn auction_text(item: &str, price: u32, yours: bool) -> String { + if yours { + format!("{item} — {price}cu +10% humiliation premium, buy back YOUR OWN sword") + } else { + format!("{item} — {price}cu, hammer price") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn contracts() -> Vec<(String, u32, u8)> { + vec![ + ("rats in the granary".to_owned(), 20, 1), + ("the haunted millstone".to_owned(), 80, 3), + ("the Deep door".to_owned(), 500, 5), + ] + } + + #[test] + fn board_lists_each_contract_with_pay() { + let s = board_text(&contracts()); + for (title, pay, _) in contracts() { + assert!(s.contains(&title), "lists {title:?}: {s:?}"); + assert!(s.contains(&format!("{pay}cu")), "prices {title:?}: {s:?}"); + } + assert!(s.contains("notice board"), "frames the board: {s:?}"); + } + + #[test] + fn board_tier_marks_run_one_to_five() { + let rows = vec![ + ("a".to_owned(), 1, 1), + ("b".to_owned(), 1, 2), + ("c".to_owned(), 1, 3), + ("d".to_owned(), 1, 4), + ("e".to_owned(), 1, 5), + ]; + let s = board_text(&rows); + for mark in ["[I]", "[II]", "[III]", "[IV]", "[V]"] { + assert!(s.contains(mark), "tier mark {mark}: {s:?}"); + } + } + + #[test] + fn board_empty_is_honest_with_innkeep_nudge() { + let s = board_text(&[]); + assert_eq!(s, "the board is bare (unusual — ask the innkeep)"); + } + + #[test] + fn board_bad_tier_marks_ignorance() { + let s = board_text(&[("odd posting".to_owned(), 10, 9)]); + assert!(s.contains("[?]"), "out-of-range tier is honest: {s:?}"); + assert!(s.contains("odd posting"), "title still listed: {s:?}"); + } + + #[test] + fn service_line_names_price_and_note() { + let s = service_text("lampwright — new lamp", 50, "scrip taken, oil weighed"); + assert!(s.contains("lampwright"), "names the service: {s:?}"); + assert!(s.contains("50cu"), "prices the service: {s:?}"); + assert!( + s.contains("scrip taken, oil weighed"), + "quotes the note: {s:?}" + ); + } + + #[test] + fn service_line_without_note_has_no_dangling_dash() { + assert_eq!(service_text("warden post", 0, ""), "warden post — 0cu"); + } + + #[test] + fn gossip_frames_route_and_days() { + let s = gossip_line("Milthollow", "Copperwash", 3); + assert!(s.contains("Milthollow"), "names the source: {s:?}"); + assert!(s.contains("Copperwash"), "names the hearer: {s:?}"); + assert!(s.contains('3'), "shows the day count honestly: {s:?}"); + assert!(s.contains("days"), "says days, not soon: {s:?}"); + } + + #[test] + fn gossip_zero_days_stays_honest() { + let s = gossip_line("Milthollow", "Milthollow", 0); + assert!( + s.contains("0 days"), + "same-town word is instant, shown so: {s:?}" + ); + } + + #[test] + fn auction_yours_carries_the_humiliation_premium() { + let s = auction_text("notched arming sword", 110, true); + assert!(s.contains("notched arming sword"), "names the lot: {s:?}"); + assert!(s.contains("110cu"), "hammer price shown: {s:?}"); + assert!(s.contains("+10% humiliation premium"), "the premium: {s:?}"); + assert!( + s.contains("buy back YOUR OWN sword"), + "the beloved line: {s:?}" + ); + } + + #[test] + fn auction_others_is_a_plain_hammer_price() { + let s = auction_text("choir bell, cracked", 40, false); + assert!(s.contains("choir bell, cracked"), "names the lot: {s:?}"); + assert!(s.contains("40cu"), "hammer price shown: {s:?}"); + assert!(s.contains("hammer price"), "plain terms: {s:?}"); + assert!( + !s.contains("YOUR OWN"), + "no premium on strangers' lots: {s:?}" + ); + } +} diff --git a/docs/06-implementation-checklist.md b/docs/06-implementation-checklist.md index 5b9c232..f2a65aa 100644 --- a/docs/06-implementation-checklist.md +++ b/docs/06-implementation-checklist.md @@ -111,12 +111,12 @@ How to use: work in phases; do not start a phase until the prior phase's gate te ## Phase 9 — NPCs, towns & economy [03 §3.6; 02 §3–5] -- [ ] Named townsfolk with jobs/homes/debts/gossip; schedules (lamplighters/mill/chapel/tavern/coaches); 3-day gossip propagation -- [ ] Services: inn (sleep advances world), tinker, lampwright (+oil scrip currency), warden post, menders, fences, Audit auction (buy back own confiscated loot) -- [ ] Shopkeeper NetHack-law: ledgers/memory/grudges; physics shop floors (topple/arson/bell-emptying + billing); debt collectors visit dungeon floors -- [ ] Milthollow (312 souls) + Copperwash + Gallowgate + Drowned-Saint chapel + Barrowfield at EA; slice needs Milthollow high-density -- [ ] Salvage economy: coppers/silver/scrip + wire/cells/cards/moss/pelts/bone/bells(illegal, ringing) -- [ ] **Gate:** sim-runs-without-you test: unattended town advances sensibly 7 days (schedules kept, prices drift, gossip arrives) +- [x] Named townsfolk with jobs/homes/debts/gossip; schedules (lamplighters/mill/chapel/tavern/coaches); 3-day gossip propagation — DONE: `core/npc.rs` (13 jobs, intents, grudges) + `core/schedule.rs` (day phases, posts, gossip gate); `State.npcs/shops/gossip`; Intent phase (stimulus pre-sort, 1m/2m steps); Rot gossip drain +- [x] Services: inn (sleep advances world), tinker, lampwright (+oil scrip currency), warden post, menders, fences, Audit auction (buy back own confiscated loot) — DONE: `core/services.rs` (7 prices, sleep 8640, garden gate, collectors) + triage-constant rule +- [x] Shopkeeper NetHack-law: ledgers/memory/grudges; physics shop floors (topple/arson/bell-emptying + billing); debt collectors visit dungeon floors — DONE (mechanical): grudge ladder + bell-as-debt + shop-local/global split; collectors table; floors physics parent-side per plan +- [x] Milthollow (312 souls) + Copperwash + Gallowgate + Drowned-Saint chapel + Barrowfield at EA; slice needs Milthollow high-density — DONE (slice): 13 named townsfolk roster in gate test; new towns land Phase 10 content +- [x] Salvage economy: coppers/silver/scrip + wire/cells/cards/moss/pelts/bone/bells(illegal, ringing) — DONE: `core/economy.rs` (coinage, 8 goods, integer drift with pins, bell law+bill) +- [x] **Gate:** sim-runs-without-you test: unattended town advances sensibly 7 days (schedules kept, prices drift, gossip arrives) — DONE: `tests/phase9_town.rs` (schedules/drift/gossip/shop-law); 650 green workspace-wide ## Phase 10 — Contracts, quests & dungeons [03 §3.7–3.8; 02 §3] diff --git a/docs/phase9-wiring-plan.md b/docs/phase9-wiring-plan.md new file mode 100644 index 0000000..018c6cb --- /dev/null +++ b/docs/phase9-wiring-plan.md @@ -0,0 +1,61 @@ +# Phase 9 Wiring Plan (parent integration, executes when both workstreams return) + +Two workstreams, disjoint files, one merge point. WS1 (minds) owns +`core/npc.rs` + `core/schedule.rs`. WS2 (economy) owns `core/economy.rs` + +`core/services.rs` + `ui/town.rs`. Parent owns `lib.rs` wiring + Intent +phase body + State tables + sleep advance + shop floors + towns content. + +## 1. Pre-merge conformance check (parent runs FIRST) + +- Job×13 (npc.rs ALL_JOBS pins 13 incl. Beggar — plan count was stale) vs + Milthollow services (towns.toml): every service has ≥1 job. Service→job + map: tinker stall→Tinker, lampwright→Lampwright, warden post→Warden, + mill+granary→Miller, corpse-tender garden→CorpseTender (+Mender triage), + inn→Innkeep, notice board→Innkeep (socially covered: empty-board text + says "ask the innkeep"). Fence/Auctioneer deliberately off-book; + Chaplain/CoachDriver/Lamplighter/Beggar need no towns.toml entry. +- Grudge numbers vs reputation ledgers (no double-punish: shop strikes are + shop-local memory; town ledger is town-global — document the split). +- Price tables vs content base_price_cu (fence 60%, auction +10% compose). + Unit-vs-item distinction: economy base prices are per-UNIT (wire, cards); + content base_price_cu is per-ITEM (coil, blank) — composition reads the + content price, never the economy table, for named items. +- Bell bill (200cu) vs pay_lien alms path (Phase 8): the bill is a DEBT + (Standing::debt_cu via adjust_debt), payable mid-dungeon by prayer. +- Drift seed_tag per Good = Good discriminant (Wire 0..Bell 7) so the soak + is reproducible (economy.rs takes raw tags; canonical assignment here). + +## 2. Wiring (parent edits) + +- `core/lib.rs`: `pub mod economy; pub mod npc; pub mod schedule; pub mod services;` +- `ui/lib.rs`: `pub mod town;` +- `State` additions: `pub npcs: BTreeMap` (npc actor ids — + NPCs ARE actors with sheets? NO: NPCs get Townsfolk records + Actor + entries, no Character (they don't grow by PC rules); vendors need packs? + Phase 9: NO npc packs (infinite-ish stock + restock clock, documented)), + `pub shops: BTreeMap<(i32,i32,i32), ShopMemory>` (shop-cell → memory), + `pub gossip: Vec` (3-day + latency queue, drained in Rot phase). +- Intent phase body: day_phase(state.far.day_tick) (day_phase takes the + pre-modded tick; FarField.day_tick is already 0..8639) → per npc: + stimulus SHOULD be pre-sorted by live Actor distance (ties stay + deterministic in decide either way; dusk belongs to lamplighters) → + decide() → apply WalkTo as 1m steps toward target (reuse Move-path nudge + shape), Work/Sleep as no-ops with NO practice hooks (NPCs don't grow — + documented), Flee/Investigate as WalkTo variants with speed 2. +- Sleep: Inn service → FarField += 8640 + rot burst sweep + bounty age + + faction clocks + gossip drain (THE sim-runs-without-you proof). +- Shop floors physics: shelves topple (structure marks), oil+fire = arson + link + billing (grudge + ledger + Audit debt), bell-empty (actors flee + radius incl. keeper, bill 200cu). Debt collectors spawn from + collectors_for() at dungeon stairs. +- Corpse-tender garden: remove-curse action (cursed flag clear on Instance + + garden_removes_curse gate + offering consumed). +- Milthollow high-density: 13 named townsfolk (one per job), homes/works + placed on a hand map (Phase 10 worldgen hook). + +## 3. Gate acceptance (checklist §3.6) + +- Sim-runs-without-you: 7-day unattended soak — schedules kept (spot-check + lamplighter dusk positions), prices drifted (drift() values moved), + gossip arrived (day+3 packets delivered, ledgers moved).