diff --git a/assets/data/contracts.toml b/assets/data/contracts.toml index b69ed14..aa7a98f 100644 --- a/assets/data/contracts.toml +++ b/assets/data/contracts.toml @@ -75,3 +75,684 @@ unknown_hint = "the adept does not know a tinker has been selling counterfeit fr floor = 1 prep_notes = "bring one unidentified item to test; read the posted rules twice; do not touch the rack" intent = "the safe-toybox onboarding (docs/03 §3.4 testing chapel): 15cu is pocket money because the real pay is one free identification and every rule demonstrated safely" + +# --- Phase 10 workstream 1 additions (56 contracts; 62 total with the slice) --- +# Tiers (docs/03 §3.7): 1 vermin 20-60cu, 2 hauntings 60-150cu, 3 saints-and-tolls +# 150-400cu, 4 Works doors 400-900cu, 5 Deep doors 900-2500cu. Every entry keeps +# the slice shape (poster named + placed, honest known_hazards, NON-EMPTY +# unknown_hint with an "ask X" gossip hook for the Phase 9 gossip queue, floor +# 1-12, prep_notes that suffice with §3.3 laws, intent balance comment). +# Directions are landmark+depth+draft, never compass (docs/03 §3.7). + +[[contract]] +id = "cellar_grate_mother" +title = "Something nests in the vents" +tier = 1 +pay_cu = 35 +poster = "Goodwife Hess of Milthollow" +known_hazards = ["a ventilation grate over the cellar stairs", "a lullaby-hum through the grate at night", "small dropped bones on the steps"] +unknown_hint = "the goodwife does not know a Grate-mother broods above the stairs and drops her young down the shaft; ask the ratcatchers drinking at the Gilded Whetstone, they know that hum" +floor = 1 +prep_notes = "never stand under a humming grate; below the kitchen, where the draft pulls cold off the stairwell, first landing: prod the vent from the side with a pole, read its draft with flour, and hold a net for what falls" +intent = "tier-1 vent tutorial (docs/03 §3.3 law 3 + Grate-mother tell docs/02 §6): 35cu because the hazard is positional and fully avoidable once named" + +[[contract]] +id = "tripe_rat_nests" +title = "Rats in the offal bins" +tier = 1 +pay_cu = 20 +poster = "Butcher Soll of Milthollow" +known_hazards = ["cable-rats in the offal bins", "blood that pits iron (weak acid)", "chewed bin lids"] +unknown_hint = "the butcher does not know the nest is inside the hollow chopping block itself; ask his apprentice, who hears skritch from below the block at dawn" +floor = 1 +prep_notes = "behind the shambles, where the air tastes of old blood, ground level: bring a pike, iron-shod boots, and a bucket of water to wash the blood off fast" +intent = "tier-1 floor pay (20cu): teaches cable-rat blood-eats-iron (docs/02 §6) and washing scent (docs/03 §3.3 law 10) on a harmless nest" + +[[contract]] +id = "chewed_cable_loft" +title = "The lights keep dying upstairs" +tier = 1 +pay_cu = 30 +poster = "Tinker Vess of the Milthollow tinker stall" +known_hazards = ["chewed copper runs in the loft", "one live wire, bellies that spark", "ozone smell before they bite"] +unknown_hint = "the tinker does not know the rats den around a spent aether-cell that still leaks signal and draws them; ask the lampwrights, who logged which loft runs went dark first" +floor = 1 +prep_notes = "above the stall, where the draft smells of ozone, loft landing: chalk the wire run first, prod with a dry pole (never copper), and bring a bell to scatter the pack" +intent = "tier-1 copper-conducts lesson (docs/03 §3.3 law 8): 30cu because a live wire punishes the careless but the ozone tell is free" + +[[contract]] +id = "flour_bin_skritch" +title = "Skritch in the flour bins" +tier = 1 +pay_cu = 25 +poster = "Miller Odo of Milthollow" +known_hazards = ["rats in the flour bins", "flour-dust in the air (blast risk, no flame)", "chewed sack seams"] +unknown_hint = "the miller does not know one rat is a bell-ringer's escaped scent-rat that runs from humming; ask the miller's boy, who hums at it through the bin wall" +floor = 1 +prep_notes = "below the millstones, where the air is white and sweet, ground floor: no open flame near the dust; bring a hand-bell and hum low, then sack the bins one at a time" +intent = "tier-1 dust-law tutorial (docs/03 §3.3 law 5 flour/dust): 25cu; the hum trick is the gossip half, the dust rule is the prep half" + +[[contract]] +id = "well_bucket_thief" +title = "The well keeps eating buckets" +tier = 1 +pay_cu = 30 +poster = "Reeve Tam of Copperwash Mere" +known_hazards = ["a flooded wellhead with a frayed rope", "something below that tugs back", "cold black water"] +unknown_hint = "the reeve does not know a drowned eel has knotted its nest in the bucket chain; ask the reed-cutters, who saw it surface at dusk" +floor = 1 +prep_notes = "at the mere edge, where the water smells of iron, surface: bring 10m of good rope, a spare bucket as tribute-bait, and never wrap the line around your wrist" +intent = "tier-1 rope lesson (docs/03 §3.3 law 9): 30cu; rope discipline taught cheap before flooded shafts (tier 3+) price it in lives" + +[[contract]] +id = "hymn_card_pigeons" +title = "Pigeons ate the hymn-cards" +tier = 1 +pay_cu = 22 +poster = "Acolyte Fen of the chapel of the Drowned Saint" +known_hazards = ["loft pigeons with a taste for punchcard edges", "one card (FLUCTUS) half-eaten and still flexible", "droppings, everywhere"] +unknown_hint = "the acolyte does not know the half-eaten FLUCTUS card still casts when the loft draft lifts it; ask the choir-deserters sheltering in the dorter, they have seen paper fly uphill" +floor = 1 +prep_notes = "above the chapel dorter, where the draft pulls chapel-ward, loft landing: bring a net, a dry sack for the cards, and read each card's condition before touching (heat sets off hymn-cards)" +intent = "tier-1 hymn handling (docs/03 §3.5 card condition): 22cu errand pay; the still-live card is the deducible-cruelty seed" + +[[contract]] +id = "barrowfield_pig_thief" +title = "Our pig thief is back" +tier = 1 +pay_cu = 22 +poster = "Widow Ase of Barrowfield" +known_hazards = ["a two-legged thief, not a beast", "barrow-field dark, uneven ground", "widows who watch from windows and remember faces"] +unknown_hint = "the widow does not know the thief is her own nephew feeding the salvage camp; ask at the camp hearth, where the pork has been suspiciously shared" +floor = 1 +prep_notes = "past the barrows, where the wind smells of turned earth, surface: bring a lantern with a shutter, rope for tying (not hurting), and a witness from the widows" +intent = "tier-1 surface mud (docs/02 §1 pig thieves): 22cu; no monster at all — resolution is social, which is itself the lesson" + +[[contract]] +id = "toll_gate_rats" +title = "Rats in the tollhouse" +tier = 1 +pay_cu = 28 +poster = "Toll-ward Drok of Gallowgate Toll" +known_hazards = ["cable-rats in the tollhouse walls", "chewed toll tallies (the Matriarch notices)", "one live bell-wire"] +unknown_hint = "the ward does not know the rats came up the bridge cable from the Works mouth; ask the night-shift troll, who hears them cross at bell-change" +floor = 1 +prep_notes = "under the tollhouse, where the bridge hums through the stone, ground level: chalk the cable run, bring exact change for the troll first (manners before rats), then pole and bell" +intent = "tier-1 Gallowgate onboarding (docs/02 §3 unionized toll): 28cu; teaches that the troll is scenery to be paid, not a fight to be picked" + +[[contract]] +id = "chapel_cellar_damp" +title = "Damp in the chapel cellar" +tier = 1 +pay_cu = 20 +poster = "Acolyte Fen of the chapel of the Drowned Saint" +known_hazards = ["ankle-deep black water", "drowned hymn-sheets floating face-down", "a bell that rings when nobody touches it"] +unknown_hint = "the acolyte does not know the bell rings when the mere draft reverses through the floor drain; ask the reed-cutters, who read mere drafts like scripture" +floor = 1 +prep_notes = "below the chapel, where the air tastes of copper and old water, first landing: wade with a pole, lift the sheets with a net (read none aloud), and flour the drain to see the draft turn" +intent = "tier-1 draft-reading seed (docs/03 §3.3 law 3): 20cu; flour-reveals-flow taught where the water is only ankle-deep" + +[[contract]] +id = "granary_moth" +title = "Miller-moths in the seed grain" +tier = 1 +pay_cu = 24 +poster = "Miller Odo of Milthollow" +known_hazards = ["miller-moths swarming the seed bins", "larvae in the sack seams", "dust that makes torches sputter"] +unknown_hint = "the miller does not know the swarm nests around a cracked hymn-card (FIAT LUX) warming the bin wall; ask the tinker, who sold him a 'lucky lamp card' last winter" +floor = 1 +prep_notes = "in the granary loft, where the air shimmers warm for no reason, upper landing: douse the lamp, smoke the swarm toward the loft vent, and lift the card out with wooden tongs" +intent = "tier-1 light-and-heat crossover (docs/03 §3.3 laws 1+5): 24cu; warm-wall deduction with a safe answer (smoke, not fire)" + +[[contract]] +id = "lampwright_oil_run" +title = "Oil for the mill lamp" +tier = 1 +pay_cu = 40 +poster = "Lampwright Adept Cis of Milthollow" +known_hazards = ["a handcart of oil flasks on a rutted lane", "hungry mouths between here and the mill", "oil scrip is legal tender (spilling it is spending it)"] +unknown_hint = "the adept does not know the lane culvert hides a cable-rat run that smells the oil; ask the coach-drivers, who rattle the culvert grate on purpose" +floor = 1 +prep_notes = "along the mill lane, where the ruts run deepest, surface: walk it at dusk with a shuttered lamp, a topped-up cart, and a bell on the axle; rattle the culvert before crossing" +intent = "tier-1 HAUL seed (docs/03 §3.2: oil as money that burns): 40cu top-of-tier-1 because a spill costs the carrier twice" + +[[contract]] +id = "garden_weed_tithe" +title = "Weeds in the corpse-garden" +tier = 1 +pay_cu = 20 +poster = "Corpse-tender Mere of Milthollow" +known_hazards = ["holy runoff channels (do not drink, do not dam)", "rust-thistle with barbed seed", "the garden dead, resting (let them)"] +unknown_hint = "the tender does not know rust-thistle only roots where Audit fine-print ash was dumped; ask the wardens, who burned a lien notice uphill last spring" +floor = 1 +prep_notes = "behind the garden wall, where the runoff smells of iron and lilies, surface: bring gloves, a sack, and last-rites words in case you wake a sleeper; weed downhill of the sleepers only" +intent = "tier-1 curse-triage Wales (docs/03 §3.4 the Corpse-tender is a person): 20cu; introduces holy runoff + owed-ash before Audit liens (tier 3+)" + +[[contract]] +id = "warden_post_eaves" +title = "Gnawing in the warden eaves" +tier = 1 +pay_cu = 30 +poster = "Warden Sarella of Copperwash" +known_hazards = ["rats in the warden-post eaves", "sealed bounty ledgers (chew one and the fine is yours)", "a bored young warden with a crossbow"] +unknown_hint = "the warden does not know the rats are after the salt in the seal-wax, not the paper; ask the post cook, who sweeps red wax crumbs every morning" +floor = 1 +prep_notes = "over the warden post, where the eaves catch the mere wind, roof landing: bring wax-sealed bait (not the ledgers), a net, and a signed note from Sarella so the crossbow stays racked" +intent = "tier-1 ledger-law comedy (docs/03 §3.6 wardens + ledgers): 30cu; the wax deduction keeps it a puzzle, not a grind" + +[[contract]] +id = "reedcutter_pests" +title = "Pests in the reed beds" +tier = 1 +pay_cu = 26 +poster = "Reedwife Oll of Copperwash Mere" +known_hazards = ["biting mere-flies in clouds", "sinking mud between the beds", "a territorial swan (respect it)"] +unknown_hint = "the reedwife does not know the flies breed in a drowned tithe-chest leaking old grain; ask the drowned-lantern keeper, who marks where the mere coughs up wreckage" +floor = 1 +prep_notes = "out on the mere edge, where the reeds hiss with the evening draft, surface: bring smoke (green reeds burn smoky), planks for the mud, and tribute greens for the swan" +intent = "tier-1 mere onboarding (docs/02 §3 Copperwash): 26cu; smoke-use + terrain puzzle before flooded work (tier 2+)" + +[[contract]] +id = "ledger_mice" +title = "Mice in the toll tallies" +tier = 1 +pay_cu = 45 +poster = "Toll-ward Drok of Gallowgate Toll" +known_hazards = ["mice nesting in the tally shelves", "the Matriarch audits the tallies weekly (she counts)", "troll tempers, union rules"] +unknown_hint = "the ward does not know the mice followed a Congregation grain cart that shorted the bridge last month; ask the carters at the Exact Change, they remember the cart" +floor = 1 +prep_notes = "in the tally room, where the bridge draft smells of dust and ink, ground level: bring snap-traps, flour to find the run, and a full apology-Rehearsal for the Matriarch's count day" +intent = "tier-1 social stakes (docs/02 §3 troll memory is long): 45cu high for vermin because the Matriarch's count is the real boss" + +[[contract]] +id = "cistern_eels" +title = "Eels in the cistern" +tier = 1 +pay_cu = 35 +poster = "Goodwife Hess of Milthollow" +known_hazards = ["drowned eels in the drinking cistern", "slick stone ladder", "one eel big enough to argue"] +unknown_hint = "the goodwife does not know the eels came up the overflow from the old relay cistern below; ask the lampwrights, who mapped the overflow when the lamps kept going out" +floor = 1 +prep_notes = "down the cistern shaft, where the air goes cold and still, first landing: bring a net, a lidded bucket, and chalk to mark the overflow grate for the lampwrights after" +intent = "tier-1 vertical taste (docs/03 §3.8 Shallows): 35cu; a shaft, a grate, and chalk — the whole game in a water butt" + +[[contract]] +id = "straw_man_walks" +title = "The scarecrow walks at night" +tier = 1 +pay_cu = 20 +poster = "Widow Ase of Barrowfield" +known_hazards = ["a straw man seen walking the barrow edge", "frightened sheep, trampled barley", "widows who insist it tips its hat"] +unknown_hint = "the widow does not know miller-lads wear the scarecrow down the drafty barrow lane on dares; ask the barley crew, who lost a bet and a hat to it" +floor = 1 +prep_notes = "along the barrow lane, where the night draft runs downhill, surface: walk it with a shuttered lamp and a friend, call out before touching anything straw, and bring the hat home for proof" +intent = "tier-1 kindness contract (docs/03 §3.7 ignorable thin thread): 20cu floor pay; sometimes the dark holds only boys — the game says so honestly" + +[[contract]] +id = "widow_rennick_husband" +title = "My husband walks — find him" +tier = 2 +pay_cu = 90 +poster = "Widow Rennick of Barrowfield" +known_hazards = ["plague barrows at night", "the walked dead answer to old names", "barrowfield widows who know exactly what you dig up"] +unknown_hint = "the widow does not know her husband walks downhill toward the chapel of the Drowned Saint, drawn by its bells; ask the corpse-tender, who has seen the walked answer chapel-ward" +floor = 3 +prep_notes = "past the barrows, where the turned-earth smell gives way to mere water, third landing where the bell-rope hangs: bring something that knew him, last-rites words, and a silvered charm; try the Lampwright testing chapel first — rehearse the rite on quiet ground before the barrows" +intent = "MAIN THREAD 1/6 (docs/03 §3.7 Widow Rennick thread): tier-2 because the rite, not the fight, is the price; 90cu grief money; the chapel-ward pull is the thread to drowned_saint_audience" + +[[contract]] +id = "rat_king_nest" +title = "The king in the walls" +tier = 2 +pay_cu = 140 +poster = "Warden Sarella of Copperwash" +known_hazards = ["a rat-king knotted tail-to-tail in the relay walls", "the pack fights as one body while knotted", "live cable through the nest wall"] +unknown_hint = "the warden does not know the knot loosens when the pack is lured apart with separate baits; ask the ratcatchers at the Gilded Whetstone, who have unknotted one before with tripe and bells" +floor = 3 +prep_notes = "below the relay, where the ozone smell is strongest, third landing: do NOT meet it knotted — lay tripe baits in three side-rooms, ring bells to split the pack, then burn the empty nest; rehearse fire-discipline in the testing chapel first (oil under doors, smoke through grates)" +intent = "boss-as-puzzle contract (docs/03 §3.8 rat-king: lure apart, burn nests): 140cu top-of-tier-2 because a knotted pack kills chargers; the prep_notes + trial room ARE the kill" + +[[contract]] +id = "millstone_choir" +title = "The mill sings after midnight" +tier = 2 +pay_cu = 100 +poster = "Miller Odo of Milthollow (reluctantly)" +known_hazards = ["grinding stones that turn with no grain", "a choir-hum in the walls (cover your ears, not your eyes)", "flour-dust (no flame)"] +unknown_hint = "the miller does not know the hum comes up the meal-spout from a drowned relay clerk still at his desk below; ask the choir-deserters, who know that desk-hymn by its first line" +floor = 2 +prep_notes = "below the millstones, where the air tastes of copper, second landing by the meal-spout: bring ear-wadding, scrap for the Vendric if it shows (feed the dispense, then leave), and flour to read which way the spout drafts" +intent = "tier-2 haunting (docs/03 §3.7 millstone/garden dead): 100cu; the clerk is talkable (CARO VALE territory) — violence is the expensive answer" + +[[contract]] +id = "garden_dead_restless" +title = "The garden dead will not lie down" +tier = 2 +pay_cu = 85 +poster = "Corpse-tender Mere of Milthollow" +known_hazards = ["two garden dead standing at night", "holy runoff channels (do not dam)", "grief, which makes fools of the armed"] +unknown_hint = "the tender does not know the dead stand because their barrow-tithe was shorted and the Audit holds the lien; ask the toll-wards, who saw the tithe cart turned back at Gallowgate" +floor = 2 +prep_notes = "behind the garden wall, where the runoff smells of iron and lilies, second landing of the barrow stair: bring the tithe-weight in copper, last-rites words, and no drawn weapons; pay the lien aloud before the rite — the testing chapel has a mock lien-desk to rehearse on" +intent = "tier-2 Audit seed (docs/02 §2 Audit holds every debt): 85cu; introduces pay-the-lien-aloud before the tier-4 Seraph makes it a boss mechanic" + +[[contract]] +id = "relay_clerk_desk" +title = "The clerk is still at his desk" +tier = 2 +pay_cu = 110 +poster = "Tinker Vess of the Milthollow tinker stall" +known_hazards = ["a flooded relay office, waist-deep black water", "a drowned clerk who audits visitors aloud", "paper that cuts and ink that stains (liens love signatures)"] +unknown_hint = "the tinker does not know the clerk only audits those who answer his roll-call; ask the Lampwright archivists, who hold the relay muster-roll and know the clerk's own name is missing from it" +floor = 2 +prep_notes = "below the mere outfall, where the water goes black and still, second landing: bring a boat-pole, douse the lamp on approach, and answer no roll-call but your own; silver stills him if it turns" +intent = "tier-2 flooded-relay play (docs/02 §3 Copperwash): 110cu; the muster-roll gap is the deducible hinge — say his missing name and he stands down" + +[[contract]] +id = "drowned_tithe_chest" +title = "The tithe chest coughs at low water" +tier = 2 +pay_cu = 95 +poster = "Reeve Tam of Copperwash Mere" +known_hazards = ["a drowned tithe-chest in the sucking mud", "mere-flies, sinking mud, the swan", "old grain that ferments (do not breathe deep)"] +unknown_hint = "the reeve does not know the chest is chained to a drowned bell-clapper worth more than the tithe; ask the drowned-lantern keeper, who marks wreckage by what the mere refuses to keep" +floor = 2 +prep_notes = "out on the mere edge, where the reeds hiss with the evening draft, low-water shelf: bring planks, smoke for the flies, and a sledge for the haul; leave the clapper for the Saint's chapel or the Audit bills you (their divers are watching)" +intent = "tier-2 HAUL puzzle (docs/03 §3.2 ascent-with-loot is the boss): 95cu; the clapper choice (chapel vs Audit) is the moral mud, priced separately" + +[[contract]] +id = "gallowgate_toll_ghost" +title = "The old tollman walks the bridge" +tier = 2 +pay_cu = 75 +poster = "Toll-ward Drok of Gallowgate Toll" +known_hazards = ["a walked tollman collecting from night coaches", "union rules (the Matriarch claims him as a member)", "a bridge with no railings in the middle span"] +unknown_hint = "the ward does not know the tollman only stops coaches carrying Audit paper; ask the coach-drivers at the Exact Change, who hide the lien satchel when he steps out" +floor = 2 +prep_notes = "mid-span of the Gallowgate bridge, where the wind cuts sideways off the gorge, night crossing: carry exact change, carry NO Audit paper on your person (satchel rides underneath), and greet him by the old rates" +intent = "tier-2 union-law haunting (docs/02 §3 troll memory + Audit hate): 75cu; the satchel trick is pure gossip-gated prep, no stat wall" + +[[contract]] +id = "barrowfield_widow_ledger" +title = "The widows keep a ledger" +tier = 2 +pay_cu = 70 +poster = "Widow Rennick of Barrowfield" +known_hazards = ["barrow robbers who do not like witnesses", "plague-barrows, night-cold", "the widows know. you know they know."] +unknown_hint = "the widow does not know the robbers tithe a third to the salvage camp hearth; ask at the camp hearth after midnight, when the split is counted aloud" +floor = 2 +prep_notes = "past the barrows, where the wind smells of turned earth, second landing of the barrow stair: bring a shuttered lamp, a friend, and exact third-share arithmetic; the testing chapel's mock auction is good rehearsal for the count" +intent = "tier-2 moral mud (docs/02 §3 robbing barrows feeds widows): 70cu; robbery-vs-tithe is negotiable mid-delvit — the table, not the sword, resolves it" + +[[contract]] +id = "chapel_hymn_echo" +title = "The chapel answers itself" +tier = 2 +pay_cu = 80 +poster = "Acolyte Fen of the chapel of the Drowned Saint" +known_hazards = ["hymns that echo back wrong (APERI echoes are LOUD and the Audit hears)", "a dorter where the draft reverses nightly", "sleeping choir-deserters (wake none)"] +unknown_hint = "the acolyte does not know the echo lives in the cistern below the dorter and answers whatever verb it hears last; ask the deserters, who whisper SILE before sleep and are never echoed" +floor = 2 +prep_notes = "below the chapel dorter, where the air goes cold and still, second landing: speak only SILE or nothing; bring flour to find the cistern vent, then cork it with waxed cloth before any loud work" +intent = "tier-2 hymn-debugging lesson (docs/03 §3.5 miscasts follow debugging logic): 80cu; whisper-first discipline before tier-3 hymn work" + +[[contract]] +id = "sewer_king_tithe" +title = "Tithe for the sewer king" +tier = 2 +pay_cu = 130 +poster = "Coach-master Pell of the Gilded Whetstone" +known_hazards = ["a sewer-king (great black boar-rat) under the coaching yard", "collapsed brick, ankle-breakers", "coaches overhead that hear screaming (reputation)"] +unknown_hint = "the coach-master does not know the king is tooth-bound to a brass trap that never fired; ask the tinker, who set that trap three winters back and remembers the tooth-marks" +floor = 3 +prep_notes = "under the coaching yard, where the drains breathe warm, third landing: bring a pole, a sprung spare trap as a showing-gift (trade before teeth), and tripe; spring his old trap in front of him and he parleys" +intent = "tier-2 social boss (docs/03 §3.8 negotiate mid-fight, union rules): 130cu; the trap-gift parley is the clean solve — spears are legal and educational" + +[[contract]] +id = "flooded_shrine_candles" +title = "Candles burn underwater" +tier = 2 +pay_cu = 120 +poster = "Lampwright Adept Cis of Milthollow" +known_hazards = ["a flooded server-shrine, chest-deep water", "candles burning green underwater (aether-mist feed)", "phosphor eels drawn to the green"] +unknown_hint = "the adept does not know the candles are fed by a cracked aether-cell in the shrine font; ask the lampwright archivists, who logged which shrines were wired for vigil-light and never unwired" +floor = 3 +prep_notes = "below the mere outfall, where the water smells of ozone and lilies, third landing: douse every lamp (the green burns without you), wade with a dry pole, lift the cell with wooden tongs, and ground it on the chapel rods after — rehearse grounding in the testing chapel" +intent = "tier-2 aether-mist tutorial (docs/03 §3.3 law 8): 120cu; grounding-rod discipline before Works live-cable (tier 4) makes this a prep investment" + +[[contract]] +id = "coach_road_hanged" +title = "The hanged man counts coaches" +tier = 2 +pay_cu = 65 +poster = "Coach-master Pell of the Gilded Whetstone" +known_hazards = ["a hanged man at the crossroads who counts passing coaches aloud", "horses that shy at the count", "Audit paper nailed to the post (do not touch)"] +unknown_hint = "the coach-master does not know the count stops at any coach carrying a paid toll-token; ask the Gallowgate night troll, who sells the tokens and remembers who never buys" +floor = 1 +prep_notes = "at the crossroads, where the coach-road draft runs dust-forward at dusk, surface: buy a Gallowgate token first (exact change), hang it on the lead trace, and pass at walking pace with lamps shuttered" +intent = "tier-2 surface haunting, floor-1 kindness pricing (65cu): the token IS the prep — teaches toll-token economy before tier-3 Matriarch negotiations" + +[[contract]] +id = "miller_apprentice_debt" +title = "The apprentice owes the dark" +tier = 2 +pay_cu = 60 +poster = "Miller Odo of Milthollow" +known_hazards = ["a miller-boy sleepwalking toward the meal-spout", "a debt-chit in Audit hand nailed inside his coat", "flour-dust (no flame, no shouting — dust hears)"] +unknown_hint = "the miller does not know the boy signed the chit for lamp-oil money and the Audit collects in footsteps; ask the warden clerk, who keeps the copy-ledger and knows the exact arrears to the copper" +floor = 2 +prep_notes = "in the mill loft, where the night draft runs cold off the stones, second landing: bring the arrears in exact copper, pay the lien aloud at the spout before midnight, and walk the boy home by lamplight — rehearse lien-payment at the testing chapel desk" +intent = "tier-2 collections tutorial (docs/02 §2 blasphemy = collection at coordinates of the god's choosing): 60cu floor pay; paying mid-dungeon via prayer foreshadows the Seraph (tier 4)" + +[[contract]] +id = "drowned_saint_audience" +title = "An audience with the Drowned Saint" +tier = 3 +pay_cu = 300 +poster = "Widow Rennick of Barrowfield (by chapel letter)" +known_hazards = ["the chapel answers questions for memories (it deletes a learned map region)", "hymn-cards work there that work nowhere else", "the Saint keeps whatever it is given"] +unknown_hint = "the widow does not know the Saint only answers when brought mere-surface water in an open vessel; ask the chapel acolytes, who carry the morning water up and know which vessel never spills" +floor = 6 +prep_notes = "below the mere, where the air tastes of copper and old water, sixth landing where the bell-rope hangs: bring a memory you can afford to lose, an open vessel of surface water, and last-rites words; rehearse the question once in the testing chapel — the Saint hears every draft of it" +intent = "MAIN THREAD 2/6 (docs/03 §3.7 Drowned Saint): tier-3 because a memory is the price, not copper; 300cu carries the widow's grief plus chapel tithe; the Saint points downhill at deep_door_ward" + +[[contract]] +id = "saint_of_rust_bargain" +title = "The Saint of Rust will mend everything" +tier = 3 +pay_cu = 320 +poster = "Tinker Vess of the Milthollow tinker stall" +known_hazards = ["the Saint offers to mend all your gear for your name for one hour", "lent names show as blank on the sheet; neither NPCs nor gods can target you (nor can your own bargains)", "rust spreads in damp while you dither"] +unknown_hint = "the tinker does not know the Saint must return a name lent under a Tollkeeper witness-prayer spoken first; ask the toll-wards at Gallowgate, who have witnessed three lendings and counted every return" +floor = 6 +prep_notes = "past the rusted grates, where the draft smells of iron and rain, sixth landing: bring exact change, a Tollkeeper witness-prayer half-memorized, and lend NOTHING else (no gear, no memories); speak the prayer before the name, count the hour aloud, and demand it back on the stroke" +intent = "boss-as-puzzle contract (docs/02 §6 + docs/03 §3.8 Saint of Rust name bargain): 320cu because clean gear for a whole descent is worth a fortune and the prayer is the whole fight" + +[[contract]] +id = "toll_matriarch_terms" +title = "Terms with the Toll Matriarch" +tier = 3 +pay_cu = 350 +poster = "Coach-master Pell of the Gilded Whetstone" +known_hazards = ["the Matriarch negotiates mid-fight under union rules (she pauses for offers, then resumes)", "insulting offers are remembered across runs (troll memory is long)", "an audience of bridge-trolls (reputation at stake)"] +unknown_hint = "the coach-master does not know the Matriarch must pause for ANY offer laid in copper on bare stone, even a low one; ask the Gallowgate night-shift troll, who has seen three fights become accountancy" +floor = 5 +prep_notes = "mid-span of the deep bridge, where the gorge draft cuts sideways, fifth landing: bring three ascending offers in exact change, no weapons drawn, and a Tollkeeper witness-prayer; lay the first offer on stone the moment she rises — the testing chapel mock-auction is good rehearsal for counting under pressure" +intent = "boss-as-puzzle contract (docs/03 §3.8 Matriarch: negotiate mid-fight, union rules): 350cu because the bridge tariff for a season rides on the number you speak aloud" + +[[contract]] +id = "audit_field_office" +title = "The Audit audits the salvage camp" +tier = 3 +pay_cu = 200 +poster = "Salvage Warden Jor of the Barrowfield camp" +known_hazards = ["an Audit field-desk with a brass cherub assessing packs aloud (enemies hear)", "liens love signatures (read before touching quills)", "camp tempers (the Congregation hates tolls of any kind)"] +unknown_hint = "the warden does not know the field-ledger has a blank page that eats the first signature written on it; ask the warden clerk at Milthollow, who keeps the copy-ledger and knows which page is hungry" +floor = 4 +prep_notes = "at the salvage camp, where the hearth-smoke runs flat in the gorge draft, fourth landing approach: carry valuables separately (drop-cache before the desk), sign nothing first (let the cherub stamp the copy), and bring oil scrip for on-the-spot arrears" +intent = "tier-3 Audit onboarding (docs/02 §2 the Audit bills; docs/03 §3.6 auction): 200cu; the drop-cache + sign-second drill is the Seraph fight in miniature" + +[[contract]] +id = "flooded_pump_shaft" +title = "The pump shaft drowned again" +tier = 3 +pay_cu = 180 +poster = "Reeve Tam of Copperwash Mere" +known_hazards = ["a flooded pump shaft, chest-deep black water", "a dead pump with a live intake", "phosphor eels drawn to movement"] +unknown_hint = "the reeve does not know the intake nests phosphor eels that scatter from green smoke; ask the reed-cutters, who burn green reeds at the outfall and watch the water clear" +floor = 4 +prep_notes = "below the mere outfall, where the water smells of iron and ozone, fourth landing: bring 10m of rope, a doused lamp with mosslight, green reeds for smoke, and a boat-pole; smoke the intake before touching the pump, and never wrap the line around your wrist" +intent = "tier-3 flooded-shaft play (docs/03 §3.8 Works flooded shafts): 180cu; rope + doused lamp + mosslight (verticality toy 3) taught as the only way down" + +[[contract]] +id = "union_dues_shortfall" +title = "The bridge is short on dues" +tier = 3 +pay_cu = 160 +poster = "Toll-ward Drok of Gallowgate Toll" +known_hazards = ["a union dues-box three seasons short", "trolls who remember every insulting offer across runs", "Congregation carters who swear they paid"] +unknown_hint = "the ward does not know the dues-box has a false bottom where the night shift skims; ask the carters at the Exact Change, who heard the box rattle wrong on bell-change" +floor = 4 +prep_notes = "under the tollhouse, where the bridge hums through the stone, fourth landing below the office: bring the tallies, flour to find who walks to the box at night, and exact change for your own crossing first (manners before audit)" +intent = "tier-3 social audit (docs/02 §3 unionized toll + long memory): 160cu low for the tier because the answer is arithmetic plus one flour-showing, not steel" + +[[contract]] +id = "deserter_drowning_rite" +title = "A deserter wants the water rite" +tier = 3 +pay_cu = 220 +poster = "Corpse-tender Mere of Milthollow" +known_hazards = ["a choir-deserter asking the drowning rite (halfway to the Choir already)", "dead tongues spoken aloud draw the Drowned", "the rite-water must be carried open (spills attract attention)"] +unknown_hint = "the tender does not know the rite only takes with mere-surface water skimmed at dawn, not chapel water; ask the deserters sheltering in the dorter, who know dawn water by taste" +floor = 5 +prep_notes = "at the mere edge, where the dawn draft runs glass-flat, fifth landing approach by boat: bring an open vessel, dawn-skimmed water, dead-tongue words half-spoken (never shouted), and a silvered charm; douse the lamp for the last approach" +intent = "tier-3 Choir-border contract (docs/02 §4 Drowned Choir joinable?!): 220cu; the dawn-water hinge keeps it deducible, and the rite rehearses the Saint's price (memories for answers)" + +[[contract]] +id = "lampwright_live_cable" +title = "Live cable in the relay wall" +tier = 3 +pay_cu = 190 +poster = "Lampwright Adept Cis of Milthollow" +known_hazards = ["live cable in a relay wall (copper conducts hymns and shocks)", "water seep through the same wall", "a cherub audit-stamp on the cable (the Audit hears APERI near it)"] +unknown_hint = "the adept does not know the cable still carries hymn-signal and shocks chain through the seep-water to the far grate; ask the lampwright archivists, who mapped which runs were never unwired" +floor = 4 +prep_notes = "in the relay wall, where the ozone smell is sharpest, fourth landing: chalk the seep-water path first, stand dry, prod with a dry pole (never copper, never wet), and ground the run on the chapel rods after — rehearse grounding in the testing chapel" +intent = "tier-3 signal discipline (docs/03 §3.3 law 8 electricity + signal): 190cu; the seep-water chain is the deducible cruelty — dry feet or grief" + +[[contract]] +id = "auction_buyback_sword" +title = "Buy back my own sword (humiliating)" +tier = 3 +pay_cu = 150 +poster = "Delver Sarella's Cousin Tam of Milthollow" +known_hazards = ["the Audit auctions confiscated loot with a +10% humiliation premium", "rival bidders who smell desperation", "the auctioneer remembers faces (and grudges)"] +unknown_hint = "the poster does not know the auctioneer takes oil scrip under the hammer-block to slow a rival's bidding; ask the Congregation fence, who has slowed three auctions with a flask" +floor = 4 +prep_notes = "at the auction house, where the air smells of dust and ink, town business before depth: bring the hammer price plus the premium in scrip, a fence-friend to run the slow-flask, and a hard ceiling written down beforehand (desperation is the second auctioneer)" +intent = "tier-3 beloved-humiliation contract (docs/03 §3.6 + §3.9 gear never persists, the Audit auctions it): 150cu floor-of-tier because the premium, not the dark, is the monster" + +[[contract]] +id = "grate_brood_lullaby" +title = "The vents are singing again" +tier = 3 +pay_cu = 240 +poster = "Goodwife Hess of Milthollow" +known_hazards = ["a Grate-mother brood through the whole vent layer", "lullaby-hum that lulls the unwary under the grates", "young dropped down shafts (delver bowling, but teeth)"] +unknown_hint = "the goodwife does not know the brood only drops while the lullaby holds — stop the hum and they stay up; ask the ratcatchers at the Gilded Whetstone, who plug their ears with wax and hum against it" +floor = 5 +prep_notes = "through the vent layer, where the draft carries the lullaby furthest, fifth landing: wax your ears, NEVER stand under a humming grate, flour the draft to find the brood-vent, and smoke-message through it before prodding from the side with a pole" +intent = "tier-3 vent-layer escalation (docs/02 §6 Grate-mother + docs/03 §3.3 law 9 flour/smoke-message toys): 240cu because the whole layer is the nest" + +[[contract]] +id = "warden_seal_inspection" +title = "Escort the seal-inspector down" +tier = 3 +pay_cu = 260 +poster = "Warden Sarella of Copperwash" +known_hazards = ["a seal-inspector walking to floor 5 with a checklist", "everything between here and there, unimpressed by checklists", "Wardens who fine escorts for broken seal-law (read it first)"] +unknown_hint = "the warden does not know the inspector carries an owed chit the Audit will collect mid-delvit; ask the warden clerk, who copied the chit and knows the arrears to the copper" +floor = 5 +prep_notes = "down the warden stair, where the lantern-law marks run fresh, fifth landing: bring the arrears in exact copper (pay the lien aloud at the first shrine), silvered steel, rope, and the inspector's checklist memorized — rehearse lien-payment at the testing chapel desk" +intent = "tier-3 Warden-seed contract (docs/02 §4 Wardens keep the Deep shut): 260cu; the mid-delvit collection rehearses Seraph pressure (tier 4) with an escort clock running" + +[[contract]] +id = "drowned_bell_duet" +title = "Two bells ringing in thirds" +tier = 3 +pay_cu = 280 +poster = "Acolyte Fen of the chapel of the Drowned Saint" +known_hazards = ["two Drowned bells ringing a duet (they call the Choir together)", "flooded chapel undercroft, neck-deep in the middle", "each bell keeps a drowned sailor who remembers his name"] +unknown_hint = "the acolyte does not know the bells answer each other's pitch — deafen one and the other goes quiet hunting it; ask the choir-deserters, who have heard the duet break on a single stopped ear" +floor = 6 +prep_notes = "below the chapel, where the black water goes still and cold, sixth landing: bring wax, a boat-pole, silvered steel, and two nets; stop one bell's ear first (wax and cloth), dunk or deafen it, then treat the second as a single — never answer either roll-call but your own" +intent = "tier-3 Choir-escalation (docs/02 §6 Drowned bell: deafen or dunk; clapper worth more than swords): 280cu; the duet-break is the gossip-gated clean solve" + +[[contract]] +id = "deep_door_ward" +title = "Hold the Wardens' seal at the Deep Door" +tier = 4 +pay_cu = 650 +poster = "Warden-Commander Ilse of the Deep Door" +known_hazards = ["the Deep Door at floor 8 (the Wardens seal it nightly)", "Choir pressure rising below the seal (they sing names after floor 9)", "Audit surveyors measuring the Door for arrears"] +unknown_hint = "the commander does not know the seal only holds when three warden-marks are chalked fresh each night, and one mark-stone is missing; ask the retired seal-keeper at Copperwash, who chalked the third mark for forty years" +floor = 8 +prep_notes = "at the Deep Door, where the air goes warm and wrong and the draft stops dead, eighth landing: bring chalk, silvered steel, and the commander's seal writ; find the missing mark-stone by flouring the door draft (no draft = no seal), chalk all three marks fresh, and hold till bell-change — rehearse seal-chalking in the testing chapel" +intent = "MAIN THREAD 3/6 (docs/03 §3.7 Deep Door): tier-4 because the seal is the campaign hinge; 650cu Warden money; the missing mark-stone is the gossip-gated clean solve before the floor-12 Vault Choice pair" + +[[contract]] +id = "audit_seraph_ledger" +title = "Bankrupt the Seraph (or pay it)" +tier = 4 +pay_cu = 800 +poster = "Salvage Warden Jor of the Barrowfield camp (signed under protest)" +known_hazards = ["an Audit Seraph with a flying ledger that assesses your pack aloud (enemies hear)", "three exits: pay in full, bankrupt it with cursed assets, or drown its ledger", "collections at coordinates of its choosing"] +unknown_hint = "the warden does not know the Seraph must accept cursed assets at face value before appraisal, and a cursed ledger-page poisons its own sums; ask the Congregation fence, who has fed a cherub two cursed chits and watched it argue with itself" +floor = 7 +prep_notes = "in the audit vault, where the air smells of ink and hot brass, seventh landing: carry valuables in a drop-cache outside, bring one cursed asset and one owed chit as ammunition (never sign either first), and bring oil scrip for the pay-exit; drowning the ledger needs chest-deep water and a fast boat-pole — pick ONE exit before stepping in and rehearse it at the testing chapel mock-desk" +intent = "boss-as-puzzle contract (docs/03 §3.8 Audit Seraph: pay, bankrupt it with cursed assets, or drown its ledger): 800cu because all three exits are priced — the cursed-page poison is the gossip half, the drop-cache is the prep half" + +[[contract]] +id = "flooded_elevator_counterweight" +title = "The counterweight elevator drowned" +tier = 4 +pay_cu = 500 +poster = "Lampwright Adept Cis of Milthollow" +known_hazards = ["a flooded elevator shaft with a drowned counterweight car", "a live audit-stamp on the car (the Audit hears APERI near it)", "riding the counterweight down while the cherub audits the empty car going up"] +unknown_hint = "the adept does not know the car's brake only releases when the shaft draft runs downward, which it does at bell-change; ask the elevator tenders' widows at Copperwash, who time everything by the bell" +floor = 7 +prep_notes = "at the elevator shaft, where the cable thrums and the draft hesitates, seventh landing: ride at bell-change only (flour the shaft mouth to see the draft turn), bring rope, a doused lamp with mosslight, and chalk for the car wall; ride the counterweight down and let the empty car take the audit upward" +intent = "tier-4 verticality set-piece (docs/03 §3.3 toy 10: ride the counterweight while the cherub audits the empty car): 500cu; the bell-change draft window is the whole puzzle" + +[[contract]] +id = "vault_of_arrears_dive" +title = "Dive the Vault of Arrears" +tier = 4 +pay_cu = 700 +poster = "Congregation Fence Sable of Barrowfield" +known_hazards = ["an Audit vault under black water", "a drowned ledger-keeper who audits visitors aloud", "paper that cuts, ink that stains, liens that love signatures"] +unknown_hint = "the fence does not know the vault's water drains at bell-change through a tithe-culvert the Audit forgot; ask the reed-cutters, who watch the mere fall at the bell and know which culverts run" +floor = 7 +prep_notes = "below the audit vault, where the water smells of ink and iron, seventh landing: time the dive for bell-change (flour the culvert mouth to confirm the drain), boat-pole and rope, douse the lamp, answer no roll-call but your own; carry the haul out in oilskin, not arms" +intent = "tier-4 Works vault dive (docs/03 §3.8 Works audit vaults): 700cu; the bell-change drain turns a drowning into a wade for whoever asked the reed-cutters" + +[[contract]] +id = "server_shrine_hymn12" +title = "Sing Hymn 12 to the elevator" +tier = 4 +pay_cu = 450 +poster = "Lampwright Archivist Pell of Copperwash" +known_hazards = ["a dead server-shrine that only answers Hymn 12 (Elevator)", "miscasts follow debugging logic (APERI echoes are LOUD and the Audit hears)", "signal that chains through copper armor and wet boots"] +unknown_hint = "the archivist does not know the shrine answers only when the singer stands dry on the third landing where the bell-rope hangs; ask the choir-deserters, who learned dry-foot liturgy the drowned way" +floor = 6 +prep_notes = "in the server-shrine, where the air hums a key nothing else uses, sixth landing where the bell-rope hangs: stand dry (chalk the seep path, lay planks), sing Hymn 12 once and listen for the echo before repeating; bring SILE as your apology and grounding rods after" +intent = "tier-4 liturgical engineering (docs/02 §7 Hymn 12 Elevator + dead Latin boot commands): 450cu; dry feet + listen-first is the prep-before-fight law applied to prayer" + +[[contract]] +id = "rustmother_tithe_haul" +title = "Haul for Rustmother (waste nothing)" +tier = 4 +pay_cu = 420 +poster = "Tinker Vess of the Milthollow tinker stall" +known_hazards = ["a rusted collapse-field through damp Works tunnels", "verdigris eating every copper fitting you own", "Rustmother's tithe: waste nothing, not even you (leftovers are counted)"] +unknown_hint = "the tinker does not know Rustmother's count forgives what is mended mid-haul — mend as you go and the tithe shrinks; ask the corpse-tender, who wastes nothing for a day at a time and can show the ledger" +floor = 6 +prep_notes = "through the damp tunnels, where the rust smells sweet and wrong, sixth landing approach: oil every fitting before entry, bring mend-kits and spare rivets, mend each break aloud as you go (the count hears), and cook every scrap at the camp hearth after" +intent = "tier-4 Rustmother economy (docs/02 §2 waste nothing): 420cu; mend-as-you-go turns a haul tax into a crafting loop — the count is the boss" + +[[contract]] +id = "toll_bridge_backrent" +title = "Back-rent on the deep bridge" +tier = 4 +pay_cu = 550 +poster = "Toll-ward Drok of Gallowgate Toll (forwarded from the Matriarch)" +known_hazards = ["the Matriarch collecting bridge back-rent with union muscle", "Audit liens stapled to the same debt (two collectors, one bridge)", "exact change or exact consequences"] +unknown_hint = "the ward does not know the Matriarch must honor a Tollkeeper altar-receipt against Audit paper, oldest first; ask the chapel wardens, who keep the receipt-book and know which altar stamp outranks the Audit" +floor = 7 +prep_notes = "mid-span of the deep bridge, where the gorge draft cuts sideways, seventh landing approach: kneel at a Tollkeeper altar FIRST (exact change, take the receipt), carry the receipt on bare stone, and lay escalating offers only after showing it — rehearse altar-receipt play at the testing chapel" +intent = "tier-4 saints-and-tolls escalation (docs/02 §2 Tollkeeper exact change): 550cu; the receipt-first ordering is the clean solve both collectors must honor" + +[[contract]] +id = "relay_saint_om" +title = "Wake Relay Saint Om" +tier = 4 +pay_cu = 600 +poster = "Acolyte Fen of the chapel of the Drowned Saint" +known_hazards = ["a relay saint asleep in machine-chapel stone (hymn-cards work here that work nowhere else)", "waking rites spoken wrong summon an Audit cherub (everyone groans)", "the Saint keeps whatever it is given"] +unknown_hint = "the acolyte does not know Om wakes only to its own census punchcard slid under the third landing flag; ask the Lampwright archivists, who filed the census cards and know Om's number by heart" +floor = 8 +prep_notes = "in the machine-chapel, where the air tastes of copper and old water, eighth landing: bring the census card (copy it first — never carry the original down), SILE as your apology, and a memory you can afford; slide the copy under the flag, speak once, and listen twice as long" +intent = "tier-4 liturgical-engineering boss-door (docs/02 §7 liturgical engineering + Relay Saint Om): 600cu; the card-copy discipline is the prep that saves the archive" + +[[contract]] +id = "drowned_saint" +title = "Bring him to the Saint — or don't" +tier = 5 +pay_cu = 1500 +poster = "Widow Rennick of Barrowfield" +known_hazards = ["the Drowned Saint itself, personal and entire", "the husband, walked this far, still answering to his name in Lamplighter light", "whatever the Saint keeps of whoever goes down"] +unknown_hint = "the widow does not know the Saint lets the walked choose when the husband's name is spoken by lamplight in his own words, not hers; ask the corpse-tender, who has watched the walked hesitate at the sound of their own phrasing" +floor = 11 +prep_notes = "below the mere and below the Works, where the water goes black and the Choir sings your name, eleventh landing where the bell-rope hangs: bring him (or bring the choice not to), Lamplighter light, his own words written down exactly, and NOTHING the Saint could keep that you cannot lose — rehearse the speaking once in the testing chapel and then never again" +intent = "boss-as-puzzle contract (docs/03 §3.8 the Drowned Saint: bring it the husband — or don't): 1500cu grief-and-glory money; the clean solve is dramaturgical (his words, his choice), never a stat wall; mystery reserve docs/02 §8 honored — this text hints at most and confirms nothing" + +[[contract]] +id = "vault_choice_seal" +title = "SEAL the Vault (bury the machine)" +tier = 5 +pay_cu = 2500 +poster = "Warden-Commander Ilse of the Deep Door" +known_hazards = ["the Vault at floor 12, humming underneath everything", "the Choir singing your name (diegetic difficulty warning — heed it)", "whatever the machine dreams when it feels the seal coming"] +unknown_hint = "the commander does not know the seal only takes when spoken over the Idle Throne by lamplight with three warden-marks chalked fresh; ask the retired seal-keeper at Copperwash, who chalked the Door for forty years and knows the Throne verse" +floor = 12 +prep_notes = "at the Idle Throne, where the draft stops dead and the humming comes up through your boots, twelfth landing: bring chalk, Lamplighter light, the Throne verse memorized, and nothing you cannot bury; chalk all three marks, speak once, and walk out without looking back — the Choir will sing your name the whole way up and that is how you know it is working" +intent = "MAIN THREAD 4a/6 SEAL ending (docs/03 §3.7 Vault Choice): 2500cu Warden fortune; SEAL = bury the machine (chapel wall + dark-free towns, parent wires); NG+ boss-haunt: a sealed delver's echo may walk later descents as a haunt (parent wires via vault_choice flag); mystery reserve docs/02 §8 honored — no confirmation language anywhere in this text" + +[[contract]] +id = "vault_choice_crown" +title = "CROWN the Vault (take the Core's seat)" +tier = 5 +pay_cu = 2500 +poster = "A Cantor of the Drowned Choir (it sings your name correctly)" +known_hazards = ["the Vault at floor 12, humming underneath everything", "the Warden seal-marks chalked against you (bring water)", "whatever the machine dreams when it feels a new mind sit down"] +unknown_hint = "the cantor does not know — or will not say — that the seat only takes a mind carrying an unanswered question; ask the chapel acolytes, who know which questions the Saint refused and why the refused ones matter" +floor = 12 +prep_notes = "at the Idle Throne, where the draft stops dead and the humming comes up through your boots, twelfth landing: bring one unanswered question held tight, water to wash the warden-marks, and dead-tongue words; wash the marks, sit, ask your question aloud, and answer nothing asked of you — the Choir will sing your name the whole way down and that is how you know it is working" +intent = "MAIN THREAD 4b/6 CROWN ending (docs/03 §3.7 Vault Choice): 2500cu Choir fortune; CROWN = take the Core's seat (NG+ haunts replays as a boss, parent wires via vault_choice flag); mystery reserve docs/02 §8 honored — a seat, a question, and singing are all this text claims" + +[[contract]] +id = "choir_cantor_names_you" +title = "The Choir sings your name" +tier = 5 +pay_cu = 1200 +poster = "A Cantor of the Drowned Choir (it sings your name correctly)" +known_hazards = ["the Choir singing your name after floor 9 (diegetic difficulty warning — players love this, heed it anyway)", "water that listens (dead tongues work here that work nowhere else)", "sideways falls in the Deep (gravity stutters)"] +unknown_hint = "the cantor does not know — or will not say — that covering your ears does nothing but answering back costs a memory; ask the choir-deserters, who stopped answering and kept their maps" +floor = 9 +prep_notes = "past the ninth landing, where the water listens and the falls go sideways, ninth landing: bring wax (for focus, not safety), chalk for every turn (the falls move), dead-tongue words half-spoken, and a memory-list written down so you know what answering costs — answer nothing, chalk everything, and keep ascending air in mind" +intent = "tier-5 Deep-door warning contract (docs/03 §3.7 the Choir sings your name after floor 9): 1200cu; the warning IS the content — chalk-everything discipline before floors 10-12" + +[[contract]] +id = "idle_throne_dream" +title = "Sit out the Idle Throne's dream" +tier = 5 +pay_cu = 2000 +poster = "Lampwright Archivist Pell of Copperwash" +known_hazards = ["the Idle Throne dreaming (dungeon weather turns while it dreams)", "sideways gravity, dream-logic rooms", "saint-bosses walking their old rounds"] +unknown_hint = "the archivist does not know the dream turns kind for whoever carries chapel vigil-light unlit but ready; ask the acolytes, who keep the vigil lamp and know which wick never gutters" +floor = 10 +prep_notes = "at the Idle Throne approach, where the humming comes up through your boots, tenth landing: carry vigil-light unlit (never light it — carry it), chalk every turn, bring SILE and MEMORIA both slotted, and sit out the weather-turn under bare stone (never under grates, never under water)" +intent = "tier-5 Core-dream contract (docs/02 §2 the Idle God idles; its dreams ARE dungeon weather): 2000cu; unlit vigil-light + sit-it-out is the clean solve — fighting weather is legal and educational" + +[[contract]] +id = "sideways_fall_survey" +title = "Survey the sideways fall" +tier = 5 +pay_cu = 900 +poster = "Lampwright Adept Cis of Milthollow" +known_hazards = ["a sideways fall on floor 9 (gravity stutters — dropped things arrive somewhere)", "Choir water below, Audit paper above", "ropes that hang sideways (anchor quality is everything)"] +unknown_hint = "the adept does not know the fall runs straight for whoever drops flour first and follows the flour-line; ask the elevator tenders' widows, who read falling flour like scripture" +floor = 9 +prep_notes = "at the sideways fall, where the draft runs wall-to-wall and your stomach disagrees, ninth landing: drop flour before rope (read the fall-line), anchor twice (pitons plus a mule-rigged sledge), and chalk the lip; descend the flour-line with a doused lamp and mosslight" +intent = "tier-5 verticality survey (docs/03 §3.3 law 7 gravity + law 9 rope): 900cu floor-of-tier-5; flour-first + double-anchor is the prep that makes sideways survivable" + diff --git a/assets/data/floors.toml b/assets/data/floors.toml index 65eca36..42d9e38 100644 --- a/assets/data/floors.toml +++ b/assets/data/floors.toml @@ -41,3 +41,118 @@ factions = ["Wardens of the Seal", "The Drowned Choir"] water_path = "open mere water through the descent; chest-deep at the chapel rail" secret_vents = ["baptistery drain vent", "warren air-hole above the king's chamber"] intent = "slice boss floor: rat-king warren plus chapel (CARO VALE answers live here); the warded Works stair is the visible locked door that says the slice ends and the game continues" + +# --- HEXWORK Phase 10 DEEP + BESTIARY expansion (floors 4-12) --- +# Design traces: docs/03 §3.8 (Works 4-8 the game proper; Deep 9-12 +# dream-logic, sideways falls, saint-bosses, Choir territory; every floor 3+ +# connectors, airflow, water path, factions, secret vents), docs/02 §3-4. +# Anchors reuse the worldgen pools verbatim (worldgen lib.rs anchor_set). +# Slice floors 1-3 above are byte-identical; everything below is new. + +[[floor]] +id = "floor_04_counterweight_landing" +depth = 4 +name = "Counterweight Landing" +stratum = "Works" +anchors = ["elevator hall", "counterweight gallery", "flooded shaft"] +connectors = ["seal-door stair from floor 3 (warded, passable)", "grand elevator shaft (car present)", "counterweight vent", "shaft-chaplain's collapse-dig"] +factions = ["Wardens of the Seal", "Salvage Congregation"] +water_path = "mere seep down the seal stair; ankle-deep at the car pit" +secret_vents = ["car-roof vent above landing two", "counterweight flue behind the shrine office"] +intent = "the Works threshold (docs/03 §3.8): the dead elevator of floor 2 is alive here, so toy 10 (ride the counterweight down) opens the stratum; Wardens check seals while the Congregation strips the landings" + +[[floor]] +id = "floor_05_audit_vaults" +depth = 5 +name = "Audit Vaults" +stratum = "Works" +anchors = ["audit vault", "elevator hall", "server shrine"] +connectors = ["vault freight elevator", "audit stair from floor 4", "ventilation audit flue", "flooded service shaft"] +factions = ["The Gilded Audit", "Salvage Congregation"] +water_path = "dry vaults by design; water runs honest in the cable trenches and the sump" +secret_vents = ["vault plenum behind the arrears plaque", "shrine cooling flue"] +intent = "arrears made architecture: cherub flocks audit aloud here while ledger mites eat the evidence (the Audit hates them); paying a lien mid-vault via prayer is the beloved moment (docs/03 §3.4)" + +[[floor]] +id = "floor_06_drowned_relay" +depth = 6 +name = "Drowned Relay" +stratum = "Works" +anchors = ["flooded shaft", "server shrine", "counterweight gallery"] +connectors = ["drowned lock stair", "flooded elevator shaft (rope descent)", "verger's vent", "sump collapse-dig"] +factions = ["The Drowned Choir", "Wardens of the Seal"] +water_path = "chest-deep at the shrine rail; the arena floods deeper as the Saint weakens" +secret_vents = ["bell-chamber drain vent", "sump bypass flue"] +intent = "Saint of Rust mini-boss floor (docs/02 §6: the name bargain); honest flooding teaches rope-plus-doused-lamp descents (toy 3); the verger keeps shrine hours in silt" + +[[floor]] +id = "floor_07_server_shrines" +depth = 7 +name = "Server Shrines" +stratum = "Works" +anchors = ["server shrine", "audit vault", "elevator hall"] +connectors = ["hymn freight elevator", "processional stair from floor 6", "cooling vent", "cable collapse-dig"] +factions = ["The Gilded Audit", "Lampwrights' Guild"] +water_path = "cooling channels knee-deep; dry aisles between the shrines" +secret_vents = ["cooling plenum above the third shrine", "audit tally vent"] +intent = "hymn-cards work best where the servers dream: conduct rolls breathe easier here, and the copper is thick enough that verdigris oozes hunt it; Lampwrights map safe rooms between audits" + +[[floor]] +id = "floor_08_toll_gallery" +depth = 8 +name = "Toll Gallery" +stratum = "Works" +anchors = ["counterweight gallery", "flooded shaft", "audit vault"] +connectors = ["union toll-stair", "grand counterweight shaft", "gallery vent", "smuggler's collapse-dig"] +factions = ["Wardens of the Seal", "The Drowned Choir"] +water_path = "knee-deep muster pool; the toll span itself kept dry by union labor" +secret_vents = ["toll-eave swallow vent", "gallery damper flue"] +intent = "the last dry muster before the Deep Door: troll crews work the toll-gates, debt swallows nest in the eaves at the Works-Deep boundary, and the Wardens' last seal-post warns below floor 8 (docs/02 §4)" + +[[floor]] +id = "floor_09_choir_loft" +depth = 9 +name = "Choir Loft" +stratum = "Deep" +anchors = ["choir loft", "dream chapel", "sideways fall"] +connectors = ["Deep Door stair (warded, toll-paid)", "sideways fall shaft", "cantor's vent", "dream collapse-dig"] +factions = ["The Drowned Choir", "Wardens of the Seal"] +water_path = "black water past the loft rail; the only dry span is the Matriarch's toll-bridge" +secret_vents = ["cantor's throat vent below the rail", "dream-chapel whisper flue"] +intent = "Choir territory begins (docs/03 §3.7): cantors sing delvers' names after floor 9, the song IS the difficulty warning; gravity stutters at the sideways fall (dream-logic); the Matriarch negotiates mid-fight on the only dry span" + +[[floor]] +id = "floor_10_sideways_fall" +depth = 10 +name = "Sideways Fall" +stratum = "Deep" +anchors = ["sideways fall", "saint throne", "choir loft"] +connectors = ["sideways stair (handrails both walls)", "audit freight shaft", "seraph's vent", "gravity collapse-dig"] +factions = ["The Drowned Choir", "The Gilded Audit"] +water_path = "the fall runs wall-to-wall; drink nothing that falls up" +secret_vents = ["seraph's paper vent (drafts carry writs)", "fall-sideways damper"] +intent = "Audit Seraph puzzle-boss floor (pay, bankrupt it with cursed assets, or drown its ledger); rust archons rust at range so copper loadouts rule; dropped things arc wrong until the apostates die" + +[[floor]] +id = "floor_11_saint_throne" +depth = 11 +name = "Saint Throne" +stratum = "Deep" +anchors = ["saint throne", "dream chapel", "idle gate"] +connectors = ["throne stair from the fall", "drowned bell shaft", "prophet's vent", "mere collapse-dig"] +factions = ["The Drowned Choir", "Wardens of the Seal"] +water_path = "deep mere through the throne room; chest-deep at the rail" +secret_vents = ["throne-basin drain vent", "prophet's whisper flue"] +intent = "the Drowned Saint waits (bring him the husband from Widow Rennick's thread, or do not — the fight is the conversation); idle prophets whisper Core dreams verbatim; the pale returned walks here, unconfirmed" + +[[floor]] +id = "floor_12_the_vault" +depth = 12 +name = "The Vault" +stratum = "Deep" +anchors = ["idle gate", "saint throne", "choir loft"] +connectors = ["vault stair (one way down, rope up)", "idle elevator shaft (counts down to nothing)", "choir vent", "seal collapse-dig"] +factions = ["The Drowned Choir", "Wardens of the Seal"] +water_path = "still black water, ankle-deep, going nowhere; it has arrived" +secret_vents = ["idle plenum behind the throne", "vault antechamber flue"] +intent = "floor 12 is the Vault (docs/02 §3, docs/03 §3.8): both endings staged here — SEAL the machine or CROWN yourself on it (parent-side VaultChoice, set once; NG+ boss-haunt reads it); the Core idles behind the gate" diff --git a/assets/data/gods.toml b/assets/data/gods.toml index c4b1089..ebc3cfe 100644 --- a/assets/data/gods.toml +++ b/assets/data/gods.toml @@ -42,3 +42,66 @@ ask = "witness my contract and hold the other party to it" price = "a tenth of the contract pay, plus first refusal on your next salvage auction lot" small_print = "the Tollkeeper enforces BOTH sides: break your own word and the collections come for you first, with interest compounded per town the gossip reaches" intent = "god-as-escrow for contract play (contracts.toml): Witnessed deals pay cleanly, but perjury becomes a faction-wide collections event via the 3-day gossip rule" + +# --- HEXWORK Phase 10 DEEP + BESTIARY expansion (3 gods; 5 total) --- +# Design traces: docs/02 §2 (transactional gods; piety = credit score; +# blasphemy = collections), docs/03 §3.5 (bargains: ask, accept small print), +# docs/03 §3.11 (5 gods with full bargain tables). Slice gods above are +# byte-identical; everything below is new. + +[[god]] +id = "rustmother" +name = "Rustmother" +domain = "decay, thrift, waste-nothing" +intent = "the decay god is a thrifty housekeeper, not a rot cult (docs/02 §2: waste nothing — not even you); her prices are paid in leftovers, maintenance, and carried weight" +blasphemy = "collections: rust blooms on everything you own — iron reddens in your hands, hinges seize, rations sour in the pack, and your own blood rusts until you waste nothing for a day" +[[god.boons]] +id = "rustmother_mend_all_iron" +ask = "mend all my iron" +price = "your blood rusts: max HP down until you waste nothing for a day" +small_print = "waste-nothing means NOTHING: track every leftover, bury every scrap, leave no corpse unbutchered; one wasted ration resets the day and the ledger notes the failure" +intent = "the signature Rustmother moment (docs/03 §3.5): a full mend priced in max-HP and bookkeeping, so the debt is tracked in the prep loop, not the fight" +[[god.boons]] +id = "rustmother_spare_no_scrap" +ask = "spare no scrap: find me salvage where there is none" +price = "carry the heavy sack: one full bulk of scrap you may not drop, sell, or burn until the delve ends" +small_print = "the sack rides the HAUL rules (weight + bulk + noise, docs/03 §3.2): overburdened is loud, slow, and fall-prone on ropes, and the sack counts first when the rope is cut" +intent = "thrift as encumbrance: the boon pays in salvage-hooks while the price teaches HAUL discipline; wire crews love her and fear her ooze-eaten copper in equal measure" + +[[god]] +id = "drowned_choir" +name = "the Drowned Choir" +domain = "grief, water, the unfinished dead" +intent = "the grief god is a choir, not a person (docs/02 §2, §4 joinable?!): every bargain is sung by many mouths at once, and every price is carried, not paid" +blasphemy = "collections: the Choir sings YOUR name after floor 9 — the diegetic difficulty warning IS the collection notice, and answering tells them you heard (beloved)" +[[god.boons]] +id = "choir_water_breathing_hymn" +ask = "teach me the water-breathing hymn" +price = "answer when sung to: when the Choir sings your name, you answer, wherever you are" +small_print = "answering is positional (docs/03 §3.3 law 2): they hear WHERE you answered from, and the water path carries the answer to every drowned thing between you and them" +intent = "water-breathing priced in position: the boon unlocks flooded shafts while the price makes the floor-9 cantors personal" +[[god.boons]] +id = "choir_dead_tongues" +ask = "grant me dead tongues" +price = "carry one drowned name with you until you lay it to rest with full rites" +small_print = "the name rides your pack like a passenger: it whispers at night, it counts your rations, and if you die carrying it the Corpse-tender buries you both and bills your lineage" +intent = "dead tongues as escort quest: the skill (docs/03 §3.1) arrives with a passenger, so the bargain teaches rites, dragging, and lineage persistence (docs/03 §3.9)" + +[[god]] +id = "gilded_audit" +name = "the Gilded Audit" +domain = "arrears, salvage law, back-rent with interest" +intent = "the tax-collector archangel of the old works (docs/02 §2): useful, smug, everywhere; players hate it the way Witcher players hate the Eternal Fire, and still kneel because the prices work" +blasphemy = "collections: triple tolls everywhere for a tenday, and Audit cherubs audit you on sight with your own ledger read aloud — every enemy within earshot hears your net worth" +[[god.boons]] +id = "audit_legal_immunity" +ask = "grant me legal immunity" +price = "arrears plus interest, itemized: a full accounting of everything you ever salvaged, priced to the copper" +small_print = "the itemization is the trap: the Audit now KNOWS your routes, caches, and fences, and sells the map to its collectors, who visit dungeon floors (docs/03 §3.6)" +intent = "immunity priced in information: the boon clears town ledgers while the small print stocks the dungeon with collectors who know your habits" +[[god.boons]] +id = "audit_auction_access" +ask = "open the auction to me" +price = "a 10% humiliation premium on every lot, forever, plus first refusal on YOUR lots goes to the house" +small_print = "forever means lineage (docs/03 §3.9): your next delver inherits the premium, and may buy back YOUR OWN sword three runs later at the humiliation price, which is beloved, keep it" +intent = "auction access as lineage tax: the premium funds the buy-back-your-own-sword loop, so death feeds the economy instead of ending it" diff --git a/assets/data/monsters.toml b/assets/data/monsters.toml index ae9b3c1..11ed652 100644 --- a/assets/data/monsters.toml +++ b/assets/data/monsters.toml @@ -123,3 +123,520 @@ depth_min = 1 depth_max = 1 boss = false intent = "hp 45 makes fighting the bridge a bad plan-A; Gallowgate memory lives in the town ledger (docs/02 §3), so this stat block is half social" + +# --- HEXWORK Phase 10 DEEP + BESTIARY expansion (34 monsters; 42 total) --- +# Design traces: docs/02 §6 (habit/tell/use EACH + ecology + 3D behavior), +# docs/03 §3.8 (Works 4-8 the game proper; Deep 9-12 dream-logic, sideways +# falls, saint-bosses, Choir territory; bosses as puzzles with HP), docs/03 +# §3.11 (40+ monsters), docs/02 §8 (mystery reserve: pale_returned stays +# UNCONFIRMED). Slice rows above are byte-identical; everything below is new. + +[[monster]] +id = "cherub_flock" +name = "Audit cherub flock" +hp = 24 +habit = "circles in threes and audits aloud in round-robin, so one always watches the pack while two recite arrears" +tell = "three-part brass harmony reciting your inventory in thirds" +uses = ["pay the flock once and all three stamp the receipt", "drop valuables and the flock argues over jurisdiction while you run"] +conducts = true +blood = "oil" +fears = ["silence", "deep_water"] +depth_min = 4 +depth_max = 6 +boss = false +intent = "hp 24 is three cherubs of 8 that never separate (flock-tactics: silence one and the count breaks); 3D: circles above elevator shafts and drops audit writs down them" + +[[monster]] +id = "grate_matron" +name = "Grate-matron" +hp = 44 +habit = "broods across whole vent junctions and drops broods down every shaft she can reach" +tell = "lullaby-hum in thick harmony through every grate on the floor" +uses = ["burn the junction nest for a season of quiet vents", "harvest dropped brood as live bait"] +conducts = false +blood = "blood" +fears = ["fire", "collapse"] +depth_min = 4 +depth_max = 5 +boss = false +intent = "hp 44 is the slice Grate-mother grown into Works ductwork; 3D: her young fall down shafts and arrive below, so the kill upstairs becomes the ambush downstairs" + +[[monster]] +id = "toll_troll_crew" +name = "Toll-troll road crew" +hp = 55 +habit = "works the Works toll-gates in threes under union rules: one talks, two lean" +tell = "chalk rate-board updated daily; badge numbers called out before demands" +uses = ["negotiate under union rules (exact change always respected)", "hire the crew as muscle against Audit collectors"] +conducts = false +blood = "blood" +fears = ["fire", "deep_water"] +depth_min = 4 +depth_max = 5 +boss = false +intent = "hp 55 matches the slice toll-troll because the crew fights as one troll with three votes; 3D: holds gate chokepoints where weight and footing decide shoves" + +[[monster]] +id = "counterweight_wraith" +name = "Counterweight wraith" +hp = 36 +habit = "rides elevator counterweights up and down, shoving the car at the worst landing" +tell = "cable-song half a beat before the car moves; a cold spot at the shaft mouth" +uses = ["lure it onto the car roof, then send the car down without you", "read its cable-song to time safe shaft crossings"] +conducts = false +blood = "aether_mist" +fears = ["lamplight", "bells"] +depth_min = 5 +depth_max = 7 +boss = false +intent = "hp 36 survives one shaft-drop ambush so the puzzle is timing, not damage; 3D: lives fully vertical, falls safely, and pushes others into falls (toy 10 bait)" + +[[monster]] +id = "verdigris_ooze" +name = "Verdigris ooze" +hp = 28 +habit = "eats copper: wire, cells, hymn-case trim; remembers where the thick cables run and waits there" +tell = "green-teal drip trails; the ozone gone where it passed, which wire crews fear" +uses = ["let it strip a trapped cable, then collect the cleaned salvage", "render its bile for copper-etching acid"] +conducts = true +blood = "bile" +fears = ["running_water", "silver"] +depth_min = 4 +depth_max = 6 +boss = false +intent = "hp 28 is two oil-flasks plus nerve; 3D: flows downhill and through grates, pools in cable runs, and conducts shock through itself (law 6 + law 8)" + +[[monster]] +id = "ledger_mite" +name = "Ledger mite" +hp = 4 +habit = "eats ink and paper: writs, hymn-cards, maps; the Audit hates it with operatic fury" +tell = "nibbled margins; audit writs with lace edges" +uses = ["loose a jar of mites in an audit vault and leave", "mite-chewed maps still read: the holes mark what the Audit valued most"] +conducts = false +blood = "none" +fears = ["fire", "oil"] +depth_min = 4 +depth_max = 8 +boss = false +intent = "hp 4 because it is fought with jars and smoke, not swords; 3D: drifts on drafts through vents, so smoke-messages carry it (law 3)" + +[[monster]] +id = "drowned_surveyor" +name = "Drowned surveyor" +hp = 33 +habit = "bell-less and listening: maps floods by ear and replumbs them toward the Choir" +tell = "no bell where a bell should be; wet chalk marks at ear height" +uses = ["follow its chalk to the dry way through", "its sounding-line comes pre-knotted for shaft depths"] +conducts = true +blood = "water" +fears = ["silver", "fire"] +depth_min = 4 +depth_max = 7 +boss = false +intent = "hp 33 is a fair drowning-fight; 3D: swims flooded shafts, opens drowned locks from the inside, and hears through water before you hear through air" + +[[monster]] +id = "shaft_rook" +name = "Shaft rook" +hp = 18 +habit = "nests in elevator shafts and drops stones on anything that rings the cables" +tell = "click-clack of beak on cable; whitewash on the counterweight" +uses = ["feed it scrap and it stones intruders instead of you", "harvest shaft-pebbles as pre-ranged ammo"] +conducts = false +blood = "blood" +fears = ["smoke", "collapse"] +depth_min = 4 +depth_max = 8 +boss = false +intent = "hp 18 dies to one dropped stone of its own, which is the joke and the tactic; 3D: dives down shafts and its stones fall whole floors (toy 1, weaponized)" + +[[monster]] +id = "oil_slickskin" +name = "Oil slickskin" +hp = 26 +habit = "skates flooded floors on secreted oil and tips boats for the splash" +tell = "rainbow sheen where no lamp spilled; ripples arriving too fast" +uses = ["skim its oil with paper for a free flask", "lure it onto dry stone where it beaches, helpless and valuable"] +conducts = false +blood = "oil" +fears = ["fire", "frost"] +depth_min = 5 +depth_max = 8 +boss = false +intent = "hp 26 because fire is the answer and fire is always handy near oil; 3D: floats, slicks water (law 4), and burns where it floats if lit" + +[[monster]] +id = "hymn_echo" +name = "Hymn-echo" +hp = 12 +habit = "repeats miscasts back a beat late: wrong target, swapped args, small applause; funny, never cruel" +tell = "your own miscast returning in a kinder key" +uses = ["cast near it to hear what you actually said (free miscast diagnosis)", "its applause warns the floor after kernel panics, so plan accordingly"] +conducts = true +blood = "aether_mist" +fears = ["silence", "bells"] +depth_min = 4 +depth_max = 8 +boss = false +intent = "hp 12 because killing the audience is legal and mourned by every cherub; 3D: rides sound-lines down shafts (law 2) and starves inside SILE" + +[[monster]] +id = "gear_grub" +name = "Gear grub" +hp = 8 +habit = "gums elevator gears with wax; the car stops between floors exactly where it feeds" +tell = "wax beads on the cable; the car sighing to a stop mid-shaft" +uses = ["render its wax for rope sealant", "starve it with cleaned gears and it crawls toward the jam, marking it"] +conducts = false +blood = "bile" +fears = ["fire", "frost"] +depth_min = 4 +depth_max = 5 +boss = false +intent = "hp 8 is pest-control with a polearm; 3D: lives in the car rails, so the elevator itself becomes the encounter" + +[[monster]] +id = "sump_maw" +name = "Sump maw" +hp = 34 +habit = "ambushes from flooded sumps where the water path narrows" +tell = "the water path going still where it should run; bait-bones at the narrows" +uses = ["drain its sump and it beaches (helpless, valuable)", "its gullet holds undigested salvage from older delvers"] +conducts = false +blood = "water" +fears = ["silver", "fire"] +depth_min = 4 +depth_max = 6 +boss = false +intent = "hp 34 is a held-breath fight; 3D: fully aquatic ambusher that floods and drains with the water path (law 4)" + +[[monster]] +id = "copper_leech" +name = "Copper leech" +hp = 10 +habit = "drains aether-cells and gnaws live wire; blackouts follow it like weather" +tell = "lamps dimming in sequence down the gallery; flat dead cells in a trail" +uses = ["bled dry, one leech primes a spent cell", "feed it a trapped wire and follow the dark to the trap's source"] +conducts = true +blood = "oil" +fears = ["salt", "fire"] +depth_min = 4 +depth_max = 8 +boss = false +intent = "hp 10 and the whole Works band 4-8: the coverage workhorse that teaches wire discipline; 3D: climbs cables and shorts them, chaining shock through water" + +[[monster]] +id = "tinker_shade" +name = "Tinker shade" +hp = 22 +habit = "a dead delver's shade: identifies one item for one memory, your choice which map region it eats" +tell = "cold hands at the pack clasps; chalk prices on the wall in your own hand" +uses = ["one identification for one memory (ghosts teach at prices)", "last rites release it and bless the loot"] +conducts = false +blood = "none" +fears = ["lamplight", "bells"] +depth_min = 5 +depth_max = 7 +boss = false +intent = "hp 22 so banishing is a decision, not a reflex; 3D: drifts through walls along old mule-paths and cannot cross running water" + +[[monster]] +id = "audit_bailiff" +name = "Audit bailiff" +hp = 50 +habit = "repossesses gear with a writ and two cherubs; reads the lien aloud before it swings" +tell = "paper wings; your owed-condition gear humming its lien-note" +uses = ["pay the lien mid-dungeon via prayer (a moment)", "surrender one cursed asset and it inventories in place for an hour"] +conducts = true +blood = "oil" +fears = ["deep_water", "silence"] +depth_min = 6 +depth_max = 8 +boss = false +intent = "hp 50 is a debt you cannot outrun, only settle; 3D: its writs ride drafts ahead of it, so the paperwork arrives before the beating" + +[[monster]] +id = "flood_verger" +name = "Flood verger" +hp = 32 +habit = "keeps the drowned server-shrines; rings the hours in silt and expects attendance" +tell = "silted hymn-cards slotted neat; wet footprints that stop at dry stairs" +uses = ["attend one hour and shrine hymns conduct easier this delve", "its verger's key opens drowned locks from the dry side"] +conducts = true +blood = "water" +fears = ["silver", "fire"] +depth_min = 6 +depth_max = 8 +boss = false +intent = "hp 32 guards the Works shrines without walling them; 3D: tends standing water that conducts shrine hymns along the aisles" + +[[monster]] +id = "gallow_troll" +name = "Gallow-troll veteran" +hp = 70 +habit = "holds decommissioned Works bridges alone; rates frozen since the Last Boot, honor intact" +tell = "a badge polished mirror-bright; rates chalked in pre-Boot coppers" +uses = ["pay pre-Boot rates in old coin for the safest crossing on the floor", "trade war news: it remembers every army that ever crossed"] +conducts = false +blood = "blood" +fears = ["fire"] +depth_min = 7 +depth_max = 8 +boss = false +intent = "hp 70 says do not fight the bridge, hire the bridge; 3D: anchors chokepoints where mass times footing decides every shove" + +[[monster]] +id = "shaft_chaplain" +name = "Shaft chaplain" +hp = 29 +habit = "preaches elevator safety at shaft mouths and blesses counterweights for coppers" +tell = "chalk psalms on the car wall; a blessed counterweight that never sticks" +uses = ["a blessing steadies one descent (no shove, no stall)", "confess a cut rope and it absolves you once, loudly"] +conducts = false +blood = "none" +fears = ["collapse", "silence"] +depth_min = 7 +depth_max = 9 +boss = false +intent = "hp 29 is a priest with opinions and a pole; 3D: blesses the car itself, so the elevator becomes consecrated terrain for one ride" + +[[monster]] +id = "debt_swallow" +name = "Debt swallow" +hp = 6 +habit = "eats receipts, writs, and auction chits mid-air; nests in toll-gate eaves at the Works-Deep boundary" +tell = "paper scraps spiraling up-draft; your receipt half gone from your hand" +uses = ["no receipt, no arrears trail (the Audit hates it)", "follow its spiral to the floor's up-draft vent"] +conducts = false +blood = "none" +fears = ["smoke", "bells"] +depth_min = 8 +depth_max = 10 +boss = false +intent = "hp 6 and the boundary band 8-10: straddles Works and Deep like arrears do; 3D: rides up-drafts and marks them, pairing with the ledger mite below" + +[[monster]] +id = "choir_cantor" +name = "Choir cantor" +hp = 40 +habit = "sings delvers' names after floor 9: the song IS the difficulty warning, and answering it tells the Choir you heard" +tell = "your name, sung well, from below the water line" +uses = ["answer and it marks the safer stair, once", "deafen or dunk it before it finishes the verse"] +conducts = true +blood = "water" +fears = ["silver", "fire"] +depth_min = 9 +depth_max = 12 +boss = false +intent = "hp 40 is a warning with stamina; 3D: sings through water and stone along the flood path, so depth plus debt plus noise summon it (beloved diegetic alarm)" + +[[monster]] +id = "sideways_saint" +name = "Sideways saint" +hp = 52 +habit = "falls sideways: charges across walls and ceilings as if they were floors" +tell = "dust settling sideways; your lamp-flame leaning wrong" +uses = ["lure its charge across a shaft mouth and it falls up past you", "saint-bone splinters steady hymn-cards against Deep weather"] +conducts = false +blood = "none" +fears = ["running_water", "lamplight"] +depth_min = 9 +depth_max = 11 +boss = false +intent = "hp 52 is a wall charging at you; 3D: gravity stutters around it (law 7 Deep-side), so every shove and throw must be re-aimed" + +[[monster]] +id = "gravity_apostate" +name = "Gravity apostate" +hp = 38 +habit = "preaches the fall is optional; near it your odometer sense inverts and depth readings lie" +tell = "dropped chalk falling up a handspan first; your rope pulling sideways" +uses = ["kill it and every depth reading on the floor corrects at once", "its cassock nulls one fall if worn, then falls forever (one use)"] +conducts = false +blood = "aether_mist" +fears = ["bells", "lamplight"] +depth_min = 9 +depth_max = 12 +boss = false +intent = "hp 38 guards the Deep's meanest trick: lying instruments; 3D: local gravity eddies bend arcs, drops, and rope-hangs until it dies" + +[[monster]] +id = "toll_matriarch" +name = "Toll-troll matriarch" +hp = 120 +habit = "PUZZLE-BOSS: negotiates mid-fight under union rules; every offer pauses the blows, and insulting offers are remembered across runs via the town ledger" +tell = "the rate-board written in three hands; silence while she does arithmetic" +uses = ["negotiate passage mid-fight (exact change always respected)", "outbid the Audit for her crew's muscle for the delve"] +conducts = false +blood = "blood" +fears = ["fire"] +depth_min = 9 +depth_max = 9 +boss = true +intent = "boss=true Deep-Door toll (docs/03 §3.8: negotiate mid-fight); hp 120 is a siege you talk down; 3D: holds the only dry span, so footing is hers and the water is yours" + +[[monster]] +id = "audit_seraph" +name = "Audit seraph" +hp = 140 +habit = "PUZZLE-BOSS: bankrupt it with cursed assets, pay in full with receipt, or drown its ledger; HP is the last resort and it knows it" +tell = "six wings of stamped paper; your debts with interest, read aloud, in harmony" +uses = ["feed it cursed assets to bankrupt the audit", "drown the ledger: water plus paper plus running"] +conducts = true +blood = "oil" +fears = ["deep_water", "silence"] +depth_min = 10 +depth_max = 10 +boss = true +intent = "boss=true arrears made angel (docs/03 §3.8: pay, bankrupt, or drown); hp 140 punishes the sword-first plan; 3D: paper wings ride drafts, so smoke and water reach it before you do" + +[[monster]] +id = "drowned_saint" +name = "The Drowned Saint" +hp = 160 +habit = "PUZZLE-BOSS: bring him the husband from Widow Rennick's thread, or do not; the fight is the conversation and the HP is grief" +tell = "a chapel bell ringing underwater; your name answered before you speak" +uses = ["bring the husband and the Saint stands down into the water", "CARO VALE answers here cost memories, not coin"] +conducts = true +blood = "water" +fears = ["silver", "fire"] +depth_min = 11 +depth_max = 12 +boss = true +intent = "boss=true Vault guardian (docs/03 §3.7 main thread end); hp 160 is the campaign check; 3D: floods its own arena deeper as it weakens, so the kill must swim" + +[[monster]] +id = "idle_prophet" +name = "Idle prophet" +hp = 30 +habit = "whispers Core dreams verbatim and charges nothing, which should worry you more than any price" +tell = "sleep-talking in boot-Latin; frost on warm stone where it knelt" +uses = ["write down the dream: one true dungeon-weather warning per visit", "never answer it when it speaks your name (clerks' law)"] +conducts = false +blood = "aether_mist" +fears = ["bells", "lamplight"] +depth_min = 9 +depth_max = 12 +boss = false +intent = "hp 30 is a messenger you can kill but should not; 3D: its dreams arrive as weather a floor early, leaking upward like the Core itself" + +[[monster]] +id = "rust_archon" +name = "Rust archon" +hp = 48 +habit = "rusts at range: iron reddens, hinges seize, and your pike flakes while you decide" +tell = "orange snow drifting up-draft; your lamp-chain stiff in the hand" +uses = ["spend copper and brass only on its floor (it cannot eat them)", "bottle its flake-fall for instant hinge-eater acid"] +conducts = false +blood = "none" +fears = ["oil", "running_water"] +depth_min = 10 +depth_max = 11 +boss = false +intent = "hp 48 outlasts iron parties and folds to copper ones (material law as tactics); 3D: rust rides the drafts (law 12), and oil-smoke slows the bloom" + +[[monster]] +id = "pale_returned" +name = "Pale returned" +hp = 35 +habit = "walks like someone who came back right: knows one item ID and one warning, like a ghost that never died; rumor only, UNCONFIRMED" +tell = "lantern-light steadies near it; resurrection-light smell with no source" +uses = ["it remembers one item ID and one warning, free, once (unconfirmed)", "escort it to a chapel and the chapel ledger refuses the entry"] +conducts = false +blood = "blood" +fears = ["bells"] +depth_min = 10 +depth_max = 12 +boss = false +intent = "hp 35 and deliberately killable-or-not: the came-back-right rumor stays UNCONFIRMED per docs/02 §8 mystery reserve; 3D: resurrection-light clings to it and steadies nearby flames, which proves nothing" + +[[monster]] +id = "core_tick" +name = "Core tick" +hp = 20 +habit = "counts down to nothing in boot-Latin numerals; on zero it starts over and something upstairs breaks" +tell = "counting under the floor, in a voice like a mill wheel" +uses = ["time elevator rides to its count: doors open on zero", "kill it mid-count and the floor's gravity stutters for a tick"] +conducts = true +blood = "aether_mist" +fears = ["silence", "silver"] +depth_min = 11 +depth_max = 12 +boss = false +intent = "hp 20 because the count is the monster, not the body; 3D: its count syncs doors, counterweights, and falls across the whole floor" + +[[monster]] +id = "choir_acolyte" +name = "Choir acolyte" +hp = 25 +habit = "junior cantor: hums your debts while learning your name; cover your ears and keep walking" +tell = "humming in a key nothing else uses; wet footprints going uphill" +uses = ["let it finish the hum and one debt is named (known is half-paid)", "its novice bell comes pre-silvered and small"] +conducts = true +blood = "water" +fears = ["silver", "fire"] +depth_min = 9 +depth_max = 10 +boss = false +intent = "hp 25 is a warning learning to walk; 3D: climbs wet walls toward sleepers and sings through floor grates" + +[[monster]] +id = "dream_debtor" +name = "Dream debtor" +hp = 27 +habit = "sleepwalks the Deep paying arrears in dream-coin; wakes violent if named aloud" +tell = "coin that is not there, counted carefully; snoring in boot-Latin" +uses = ["lift its dream-coin purse: spends everywhere except chapels", "wake it gently with bells from far and it tells you the Vault door's mood"] +conducts = false +blood = "bile" +fears = ["bells", "lamplight"] +depth_min = 9 +depth_max = 11 +boss = false +intent = "hp 27 and fragile while asleep, dangerous awake; 3D: sleepwalks off ledges unharmed (dream-logic), so following it maps safe falls" + +[[monster]] +id = "vendric_saint" +name = "Vendric saint-model" +hp = 46 +habit = "deep cousin of the slice Vendric: dispenses indulgences, then teeth; glass too clean, key too kind" +tell = "too-clean glass; an indulgence humming in a key nothing else uses" +uses = ["feed it scrap for the indulgence, then leave (house rule)", "burned sugar-smell marks its recent absolutions"] +conducts = false +blood = "none" +fears = ["fire", "smoke"] +depth_min = 9 +depth_max = 12 +boss = false +intent = "hp 46 is greed tested at Deep prices; 3D: roots through floors like the slice model and drops absolution-slips down vents as lures" + +[[monster]] +id = "bellwether_drowned" +name = "Bellwether drowned" +hp = 31 +habit = "leads drowned bells and tolls the wrong hours to call the Choir early" +tell = "a bell tolling thirteen; the mere inhaling before the ring" +uses = ["cut its clapper-line and the whole peal scatters", "its fleece holds drowned-lock combinations in knot-code"] +conducts = true +blood = "water" +fears = ["silver", "silence"] +depth_min = 9 +depth_max = 12 +boss = false +intent = "hp 31 herds the slice drowned bells into Deep peals; 3D: its toll travels water fastest, so floods deliver the ambush before the sound" + +[[monster]] +id = "idle_dream" +name = "Idle dream" +hp = 42 +habit = "a Core dream with teeth: wears yesterday's delve like a coat and replays your mistakes at you" +tell = "your own footprints ahead of you; your chalk in a hand you do not have" +uses = ["let it replay a death and learn the rule-chain free (one subtitle earned)", "never answer it when it speaks your name"] +conducts = true +blood = "aether_mist" +fears = ["lamplight", "bells"] +depth_min = 10 +depth_max = 12 +boss = false +intent = "hp 42 is fighting your own yesterday; 3D: replays falls, floods, and fires in place, so its arena is a museum of your errors" diff --git a/assets/data/towns.toml b/assets/data/towns.toml index 79c4b95..12c3076 100644 --- a/assets/data/towns.toml +++ b/assets/data/towns.toml @@ -12,6 +12,55 @@ pop = 312 inn = "The Gilded Whetstone" chapel = "chapel of the Lamplighter" services = ["notice board", "tinker stall", "lampwright", "warden post", "mill", "granary", "corpse-tender garden"] -contracts = ["rats_in_granary", "husband_walks", "haunted_millstone", "toll_bridge_dispute", "testing_chapel_favor"] +contracts = ["rats_in_granary", "husband_walks", "haunted_millstone", "toll_bridge_dispute", "testing_chapel_favor", "cellar_grate_mother", "tripe_rat_nests", "chewed_cable_loft", "flour_bin_skritch", "granary_moth", "lampwright_oil_run", "garden_weed_tithe", "cistern_eels", "millstone_choir", "miller_apprentice_debt", "saint_of_rust_bargain", "lampwright_live_cable", "grate_brood_lullaby", "flooded_elevator_counterweight", "rustmother_tithe_haul"] gossip_days = 3 intent = "starter town verbatim from docs/02 §3 plus the corpse-tender garden (docs/03 §3.4 remove-curse is a person); 5 of 6 slice contracts post here, the 6th (mere bell) from Copperwash's warden to pull players outward; gossip 3 days is the sim rule (docs/02 §4)" + +# --- Phase 10 workstream 1 additions (4 towns: Copperwash, Gallowgate, chapel, Barrowfield) --- +# Design traces: docs/02 §3 (places verbatim), docs/03 §3.6 (services, gossip +# latency), docs/02 §4 (factions via services). Every `contracts` id exists in +# contracts.toml (parent cross-checks); Milthollow row above byte-identical. + +[[town]] +id = "copperwash" +name = "Copperwash Mere" +pop = 188 +inn = "The Drowned Lantern" +chapel = "water-chapel of the Drowned Choir (tolerated, watched)" +services = ["notice board", "boatwright", "reed-market", "warden post", "lampwright archive", "elevator tenders' widows' fund"] +contracts = ["drowned_bell_in_mere", "well_bucket_thief", "warden_post_eaves", "reedcutter_pests", "relay_clerk_desk", "drowned_tithe_chest", "flooded_shrine_candles", "flooded_pump_shaft", "warden_seal_inspection", "server_shrine_hymn12", "idle_throne_dream", "sideways_fall_survey", "rat_king_nest"] +gossip_days = 3 +intent = "flooded relay station (docs/02 §3 Copperwash): rope-and-boat play, mid-tier contracts; gossip 3 days from Milthollow by coaching inn (docs/02 §4); the archive + widows are the gossip-keyword sources for live-cable and bell-change contracts" + +[[town]] +id = "gallowgate" +name = "Gallowgate Toll" +pop = 96 +inn = "The Exact Change" +chapel = "toll-shrine of the Tollkeeper (exact change, receipts kept)" +services = ["notice board", "tollhouse tally room", "tollkeeper altar", "coach yard", "mule stable"] +contracts = ["toll_bridge_dispute", "toll_gate_rats", "ledger_mice", "gallowgate_toll_ghost", "coach_road_hanged", "union_dues_shortfall", "auction_buyback_sword", "toll_matriarch_terms", "toll_bridge_backrent"] +gossip_days = 5 +intent = "troll-bridge, unionized and negotiable with long memory (docs/02 §3 Gallowgate): social-boss contracts plus Matriarch negotiations; gossip 5 days (further up the gorge than Copperwash); the night-shift troll is the gossip source for toll contracts" + +[[town]] +id = "drowned_chapel" +name = "Chapel of the Drowned Saint" +pop = 41 +inn = "The Dorter (pilgrim bunks, silence after bell)" +chapel = "machine-chapel of the Drowned Saint (hymn-cards work here that work nowhere else)" +services = ["notice board", "confessional ledger", "vessel-washing font", "choir-deserter bunks", "silvering bench"] +contracts = ["hymn_card_pigeons", "chapel_cellar_damp", "chapel_hymn_echo", "relay_saint_om", "deserter_drowning_rite", "drowned_bell_duet", "drowned_saint_audience", "drowned_saint", "choir_cantor_names_you"] +gossip_days = 6 +intent = "machine-chapel (docs/02 §3): the Saint answers questions for memories (deletes a learned map region — knowledge economy); main-thread chapel contracts post here; gossip 6 days (word travels by bell and boat, slowest in the county)" + +[[town]] +id = "barrowfield" +name = "Barrowfield" +pop = 143 +inn = "The Widows' Hearth (salvage camp tables, hearth-count at midnight)" +chapel = "barrow-chapel of the Lamplighter (last rites nightly)" +services = ["notice board", "salvage camp hearth", "Congregation fence", "corpse-tender outpost", "auction crier's stand", "barrow-tithe desk"] +contracts = ["husband_walks", "barrowfield_pig_thief", "straw_man_walks", "garden_dead_restless", "barrowfield_widow_ledger", "sewer_king_tithe", "audit_field_office", "audit_seraph_ledger", "vault_of_arrears_dive", "widow_rennick_husband", "vault_choice_seal", "vault_choice_crown", "deep_door_ward", "drowned_saint_audience"] +gossip_days = 4 +intent = "plague barrows + salvage congregation camp (docs/02 §3 Barrowfield moral mud: robbing barrows feeds widows): main-thread widow contracts post here; gossip 4 days; the camp hearth midnight count is the gossip source for tithe/split contracts" diff --git a/crates/hexwork-content/src/contracts.rs b/crates/hexwork-content/src/contracts.rs new file mode 100644 index 0000000..75b378c --- /dev/null +++ b/crates/hexwork-content/src/contracts.rs @@ -0,0 +1,365 @@ +//! Notice-board logic over [`Contract`](crate::schema::Contract) +//! (docs/03-systems-design.md §3.7). +//! +//! Design traces: +//! - docs/03 §3.7 (notice boards in every town: 60+ launch contracts across +//! tiers vermin → hauntings → saints → Deep doors; each lists pay, known +//! hazards, *what the poster didn't know* — discoverable by asking around, +//! i.e. gossip checks; prep-before-fight law: every hard contract is +//! solvable cleanly with the right loadout + questions asked). +//! - docs/02-lore-bible.md §3 (county places: Milthollow, Copperwash Mere, +//! Gallowgate Toll, Chapel of the Drowned Saint, Barrowfield) and §4 +//! (coaching-inn gossip sim: steal in Milthollow, Copperwash hears in 3 +//! days — gossip carries keywords with latency). +//! - docs/02 §8 (mystery reserve — devs only): this module never confirms the +//! came-back-right rumor. Contract prose hints at most; see the +//! `vault_choice_*` intents, which name a seat and a question and nothing more. +//! +//! What this module owns (and does NOT own): +//! - [`board_for`] filters a contract slice to what a town board shows and +//! sorts it. Town scoping decision (parent-blessed): contracts carry NO town +//! field — a contract names its poster + place in free text (`poster`), and +//! towns list posted contract ids (`Town::contracts`). So per-town scoping +//! is parent-side (intersect the board with the town's id list, e.g. the +//! Milthollow-posts list); this function only gates on tier and sorts. +//! Documented here so the wiring step is mechanical, not archaeological. +//! - [`unknowns_hint`] returns the gossip-discoverable hook: the parent feeds +//! its text into the Phase 9 gossip queue, which matches the embedded +//! "ask X" keywords against NPC knowledge. +//! - [`prep_sufficient`] is the machine-checked SHAPE of the prep-before-fight +//! law (non-empty prep + hazards + hint). Full solvability — "do the notes +//! plus §3.3 laws actually suffice, with no stat wall?" — is design review, +//! not code; this gates emptiness so an empty field can never ship silent. +//! - [`main_thread`] pins the 6 main-thread contract ids in story order +//! (docs/03 §3.7: Widow Rennick's husband → Drowned Saint → Deep Door → +//! Vault Choice, thin and ignorable to floor 8). The last two ids are the +//! SEAL vs CROWN branch pair — both listed, player takes exactly one. +//! +//! Determinism law (docs/04 §4.10.2): pure reads over borrowed slices, no RNG, +//! no time, no I/O, no threads. MSRV 1.75: no `let ... else`, no +//! `is_multiple_of`, no inline format args capture. + +use crate::schema::Contract; + +/// Tier band minimums the canonical pack must meet (docs/03 §3.7 + §3.11: +/// 60+ contracts). Tests pin these as FLOORS (allow growth, forbid shrink): +/// tier 1 (vermin) ≥ 15, tier 2 (hauntings) ≥ 12, tier 3 (saints-and-tolls) ≥ +/// 10, tier 4 (Works doors) ≥ 6, tier 5 (Deep doors) ≥ 5. +pub const TIER_MINIMUMS: [(u8, usize); 5] = [(1, 15), (2, 12), (3, 10), (4, 6), (5, 5)]; + +/// Town board listing: contracts with `tier <= tier_max`, sorted tier +/// ascending then pay descending (docs/03 §3.7: vermin first, richest of the +/// band on top). +/// +/// `town` selects nothing by itself — contracts carry no town field (see +/// module docs): the parent intersects this result with the town's posted id +/// list (`Town::contracts`, e.g. the Milthollow-posts list). The parameter is +/// kept (named, not `_town`) because per-town tier caps ride on it next: +/// Milthollow caps low tiers early-game, Deep-door towns uncap. Today every +/// town passes its cap explicitly via `tier_max`; the name documents intent. +/// +/// Deterministic: stable sort over the input slice order, so equal +/// (tier, pay) rows keep pack order. +#[must_use] +pub fn board_for<'a>(contracts: &'a [Contract], town: &str, tier_max: u8) -> Vec<&'a Contract> { + let _ = town; + let mut out: Vec<&'a Contract> = contracts.iter().filter(|c| c.tier <= tier_max).collect(); + out.sort_by(|a, b| a.tier.cmp(&b.tier).then_with(|| b.pay_cu.cmp(&a.pay_cu))); + out +} + +/// The gossip-discoverable hook (docs/03 §3.7: *what the poster didn't know*). +/// +/// Returns [`Contract::unknown_hint`] verbatim: the parent feeds this text +/// into the Phase 9 gossip queue, which matches the embedded "ask X" +/// keywords against NPC knowledge with coaching-inn latency (docs/02 §4). +/// Validation guarantees non-empty; this accessor is the single choke point +/// so the feed never drifts to a different field. +#[must_use] +pub fn unknowns_hint(contract: &Contract) -> &str { + &contract.unknown_hint +} + +/// Prep-before-fight audit, machine-checked shape (docs/03 §3.7). +/// +/// True when `prep_notes`, `known_hazards`, AND `unknown_hint` are all +/// non-empty (hazards: at least one non-blank entry). This gates emptiness: +/// a contract with no stated loadout, no admitted hazard, or no gossip half +/// cannot be "solvable cleanly with the right loadout + questions asked" +/// because two of the three inputs are missing. Whether the notes PLUS the +/// §3.3 laws actually suffice with no stat wall is design review — every +/// canonical entry was reviewed at authoring (see each `intent`). +#[must_use] +pub fn prep_sufficient(contract: &Contract) -> bool { + if contract.prep_notes.trim().is_empty() { + return false; + } + if contract.unknown_hint.trim().is_empty() { + return false; + } + if contract.known_hazards.is_empty() { + return false; + } + contract.known_hazards.iter().any(|h| !h.trim().is_empty()) +} + +/// The 6 main-thread contract ids in story order (docs/03 §3.7). +/// +/// `widow_rennick_husband` (tier 2, floor 3) → `drowned_saint_audience` +/// (tier 3, floor 6 chapel) → `deep_door_ward` (tier 4, floor 8, Wardens +/// seal) → the branch pair `vault_choice_seal` + `vault_choice_crown` +/// (tier 5, floor 12, BOTH endings as contracts). The branch: SEAL buries +/// the machine (chapel wall + dark-free towns), CROWN takes the Core's seat +/// (NG+ haunts replays as a boss) — parent wires `vault_choice` + endings +/// (see docs/phase10-wiring-plan.md §2). `drowned_saint` (tier 5, floor 11, +/// bring-him-or-don't) is the thread's emotional climax but NOT a gate: the +/// Choice pair is reachable without it, per the ignorable-to-8 law. +#[must_use] +pub fn main_thread() -> Vec<&'static str> { + vec![ + "widow_rennick_husband", + "drowned_saint_audience", + "deep_door_ward", + "drowned_saint", + "vault_choice_seal", + "vault_choice_crown", + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{load::load_file, validate::validate_contracts, ContractsFile}; + + fn good_contract(id: &str, tier: u8, pay: u32) -> Contract { + Contract { + id: id.into(), + title: format!("title {id}"), + tier, + pay_cu: pay, + poster: "Poster Name of Somewhere".into(), + known_hazards: vec!["an honest hazard".into()], + unknown_hint: "the poster does not know the cellar drafts lie; ask somebody".into(), + floor: 2, + prep_notes: "bring a pole and questions".into(), + intent: "test fixture".into(), + } + } + + /// Canonical pack straight from the data file (docs/04 §4.4: designers + /// edit TOML, tests read the live file — never a hardcoded mirror). + fn canonical() -> Vec { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../assets/data/contracts.toml" + ); + let data: ContractsFile = load_file(path).expect("contracts.toml must parse"); + data.contract + } + + #[test] + fn board_filters_by_tier_max() { + let contracts = vec![ + good_contract("a", 1, 20), + good_contract("b", 3, 300), + good_contract("c", 5, 2000), + ]; + let board = board_for(&contracts, "milthollow", 3); + let ids: Vec<&str> = board.iter().map(|c| c.id.as_str()).collect(); + assert_eq!(ids, vec!["a", "b"], "tier 5 filtered out: {ids:?}"); + } + + #[test] + fn board_sorts_tier_then_pay_desc() { + let contracts = vec![ + good_contract("cheap_deep", 2, 60), + good_contract("rich_vermin", 1, 60), + good_contract("rich_haunt", 2, 150), + good_contract("poor_vermin", 1, 20), + ]; + let board = board_for(&contracts, "milthollow", 5); + let ids: Vec<&str> = board.iter().map(|c| c.id.as_str()).collect(); + assert_eq!( + ids, + vec!["rich_vermin", "poor_vermin", "rich_haunt", "cheap_deep"], + "tier asc, pay desc: {ids:?}" + ); + } + + #[test] + fn board_town_param_does_not_filter_by_itself() { + // Town scoping is parent-side (module docs): same tier_max, different + // town names, identical boards. + let contracts = vec![good_contract("a", 1, 20)]; + let m = board_for(&contracts, "milthollow", 5); + let c = board_for(&contracts, "copperwash", 5); + assert_eq!(m.len(), c.len()); + assert_eq!(m[0].id, c[0].id); + } + + #[test] + fn board_empty_in_empty_out() { + let contracts: Vec = vec![]; + assert!(board_for(&contracts, "milthollow", 5).is_empty()); + let contracts = vec![good_contract("a", 4, 500)]; + assert!( + board_for(&contracts, "milthollow", 1).is_empty(), + "tier_max 1 hides tier 4" + ); + } + + #[test] + fn unknowns_hint_returns_the_hook_verbatim() { + let c = good_contract("a", 1, 20); + assert_eq!(unknowns_hint(&c), c.unknown_hint.as_str()); + assert!( + unknowns_hint(&c).contains("ask "), + "gossip hook carries an ask-keyword: {:?}", + unknowns_hint(&c) + ); + } + + #[test] + fn prep_sufficient_happy_path() { + assert!(prep_sufficient(&good_contract("a", 1, 20))); + } + + #[test] + fn prep_sufficient_rejects_each_empty_leg() { + let mut no_prep = good_contract("a", 1, 20); + no_prep.prep_notes = " ".into(); + assert!(!prep_sufficient(&no_prep), "blank prep_notes fails"); + + let mut no_hint = good_contract("b", 1, 20); + no_hint.unknown_hint = String::new(); + assert!(!prep_sufficient(&no_hint), "blank unknown_hint fails"); + + let mut no_hazards = good_contract("c", 1, 20); + no_hazards.known_hazards = vec![]; + assert!(!prep_sufficient(&no_hazards), "empty hazards fails"); + + let mut blank_hazards = good_contract("d", 1, 20); + blank_hazards.known_hazards = vec![" ".into()]; + assert!(!prep_sufficient(&blank_hazards), "blank hazards fail"); + } + + #[test] + fn main_thread_order_and_branch() { + let thread = main_thread(); + assert_eq!( + thread.len(), + 6, + "widow → audience → ward → saint → SEAL+CROWN" + ); + assert_eq!(thread[0], "widow_rennick_husband"); + assert_eq!(thread[1], "drowned_saint_audience"); + assert_eq!(thread[2], "deep_door_ward"); + assert_eq!(thread[3], "drowned_saint"); + // Branch pair last: the player takes exactly one (parent wires). + let branch = &thread[4..]; + assert!( + branch.contains(&"vault_choice_seal") && branch.contains(&"vault_choice_crown"), + "both endings listed: {thread:?}" + ); + } + + #[test] + fn canonical_pack_parses_and_validates() { + let contracts = canonical(); + assert!( + contracts.len() >= 60, + "60+ launch contracts (docs/03 §3.11): got {}", + contracts.len() + ); + validate_contracts(&contracts).expect("canonical contracts must validate"); + } + + #[test] + fn canonical_unknowns_nonempty_everywhere() { + for c in &canonical() { + assert!( + !unknowns_hint(c).trim().is_empty(), + "'{}' lists nothing the poster didn't know (docs/03 §3.7)", + c.id + ); + } + } + + #[test] + fn canonical_prep_audit_everywhere() { + let mut failing = Vec::new(); + for c in &canonical() { + if !prep_sufficient(c) { + failing.push(c.id.clone()); + } + } + assert!( + failing.is_empty(), + "prep-before-fight shape failures: {failing:?}" + ); + } + + #[test] + fn canonical_main_thread_ids_exist() { + let contracts = canonical(); + let ids: Vec<&str> = contracts.iter().map(|c| c.id.as_str()).collect(); + for wanted in main_thread() { + assert!( + ids.contains(&wanted), + "main-thread id '{wanted}' missing from canonical pack" + ); + } + } + + #[test] + fn canonical_tier_spread_meets_minimums() { + let contracts = canonical(); + for (tier, min) in TIER_MINIMUMS { + let count = contracts.iter().filter(|c| c.tier == tier).count(); + assert!( + count >= min, + "tier {tier} has {count}, needs ≥{min} (docs/03 §3.7 spread)" + ); + } + } + + #[test] + fn canonical_thread_floors_ascend() { + // Thin-to-8 law (docs/03 §3.7): widow(3) → audience(6) → ward(8) → + // saint(11) → choice pair(12). Floors must ascend along the thread. + let contracts = canonical(); + let floor_of = |id: &str| { + contracts + .iter() + .find(|c| c.id == id) + .unwrap_or_else(|| panic!("thread id '{id}' must exist")) + .floor + }; + let floors: Vec = main_thread().iter().map(|id| floor_of(id)).collect(); + assert_eq!(floors[0], 3, "widow on floor 3"); + assert_eq!(floors[1], 6, "audience on floor 6"); + assert_eq!(floors[2], 8, "ward on floor 8"); + assert_eq!(floors[4], 12, "SEAL on floor 12"); + assert_eq!(floors[5], 12, "CROWN on floor 12"); + for pair in floors.windows(2) { + assert!(pair[0] <= pair[1], "thread floors must ascend: {floors:?}"); + } + } + + #[test] + fn canonical_boss_puzzle_contracts_exist() { + // Bosses-as-puzzles surface as contracts (docs/03 §3.8): rat-king, + // Saint of Rust, Matriarch, Seraph, Drowned Saint. + let contracts = canonical(); + let ids: Vec<&str> = contracts.iter().map(|c| c.id.as_str()).collect(); + for wanted in [ + "rat_king_nest", + "saint_of_rust_bargain", + "toll_matriarch_terms", + "audit_seraph_ledger", + "drowned_saint", + ] { + assert!(ids.contains(&wanted), "boss contract '{wanted}' missing"); + } + } +} diff --git a/crates/hexwork-content/src/lib.rs b/crates/hexwork-content/src/lib.rs index 5c7dd02..c36f211 100644 --- a/crates/hexwork-content/src/lib.rs +++ b/crates/hexwork-content/src/lib.rs @@ -1,6 +1,7 @@ //! hexwork-content: schema structs + validation + loaders (docs/04 §4.4). //! Designers edit TOML data files, not Rust. The two-uses law is a TEST. +pub mod contracts; pub mod gods; pub mod identify; pub mod load; @@ -9,6 +10,7 @@ pub mod schema; pub mod validate; // Designer-facing surface: schema types, validators, loaders, pack hashing. +pub use contracts::{board_for, main_thread, prep_sufficient, unknowns_hint, TIER_MINIMUMS}; pub use gods::{boon_by_id, gods_with_boon, validate_bargain_table}; pub use identify::{ ghost_identify, identify_by_use, shop_label, tinker_identify, trial_room_check, IdfBook, diff --git a/crates/hexwork-content/src/load.rs b/crates/hexwork-content/src/load.rs index 444a08c..2a51b5b 100644 --- a/crates/hexwork-content/src/load.rs +++ b/crates/hexwork-content/src/load.rs @@ -137,7 +137,11 @@ mod tests { "/../../assets/data/monsters.toml" ); let data: MonstersFile = load_file(path).expect("monsters.toml must parse"); - assert_eq!(data.monster.len(), 8, "slice ships 8 monsters"); + assert_eq!( + data.monster.len(), + 42, + "EA ships 42 monsters (docs/03 §3.11 slice floor)" + ); for monster in &data.monster { assert!(!monster.uses.is_empty(), "'{}' needs a use", monster.id); assert!( @@ -153,8 +157,8 @@ mod tests { } assert_eq!( data.monster.iter().filter(|m| m.boss).count(), - 2, - "rat-king + Saint of Rust are bosses" + 5, + "rat-king + Saint of Rust + Matriarch + Seraph + Drowned Saint are bosses" ); } @@ -182,7 +186,7 @@ mod tests { fn loads_gods_file() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/data/gods.toml"); let data: GodsFile = load_file(path).expect("gods.toml must parse"); - assert_eq!(data.god.len(), 2, "slice ships 2 gods"); + assert_eq!(data.god.len(), 5, "EA ships 5 gods (docs/02 §2)"); for god in &data.god { assert!(god.boons.len() >= 2, "'{}' needs 2+ boons", god.name); for boon in &god.boons { @@ -199,7 +203,11 @@ mod tests { "/../../assets/data/contracts.toml" ); let data: ContractsFile = load_file(path).expect("contracts.toml must parse"); - assert_eq!(data.contract.len(), 6, "slice ships 6 contracts"); + assert_eq!( + data.contract.len(), + 62, + "EA ships 62 contracts (docs/03 §3.11 floor)" + ); for contract in &data.contract { assert!( (1..=5).contains(&contract.tier), @@ -223,14 +231,21 @@ mod tests { fn loads_floors_file() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/data/floors.toml"); let data: FloorsFile = load_file(path).expect("floors.toml must parse"); - assert_eq!(data.floor.len(), 3, "slice ships floors 1-3"); + assert_eq!( + data.floor.len(), + 12, + "EA ships the 12-floor Deep (docs/03 §3.8)" + ); for floor in &data.floor { assert!( floor.connectors.len() >= 3, "floor {} needs 3+ connectors", floor.depth ); - assert_eq!(floor.stratum, "Shallows", "slice is all Shallows"); + assert!( + ["Shallows", "Works", "Deep"].contains(&floor.stratum.as_str()), + "stratum must be canonical" + ); assert!( !floor.intent.is_empty(), "floor {} needs intent", @@ -243,7 +258,7 @@ mod tests { fn loads_towns_file() { let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/data/towns.toml"); let data: TownsFile = load_file(path).expect("towns.toml must parse"); - assert_eq!(data.town.len(), 1, "slice ships Milthollow"); + assert_eq!(data.town.len(), 5, "EA ships 5 towns (docs/02 §3 county)"); let town = &data.town[0]; assert_eq!(town.pop, 312, "Milthollow pop is 312 (docs/02 §3)"); assert_eq!( diff --git a/crates/hexwork-content/tests/content_pack.rs b/crates/hexwork-content/tests/content_pack.rs index 4d02b62..bc7b757 100644 --- a/crates/hexwork-content/tests/content_pack.rs +++ b/crates/hexwork-content/tests/content_pack.rs @@ -19,7 +19,9 @@ use hexwork_content::{ /// History: e7767e1c… = pre-fix bytes (non-canonical materials, missing ids); /// 00312cf6… = post-fix bytes (all 7 files parse + validate, see wiring plan). /// 396db264… = tags migration (machine tags: lamp/polearm/armor/corpse/…). -const GOLDEN_PACK_HASH: &str = "396db2642895de56971b331a6a189aa34fa1417f3e11103307a92eb09969d474"; +/// 2543239e… = EA expansion (62 contracts, 5 towns, 42 monsters, 12 floors, +/// 5 gods — deliberate drift, Phase 10 wiring plan §1). +const GOLDEN_PACK_HASH: &str = "2543239ecea569044778d05b80daa2d3f903fbd5b02ef03f2d1b38c8dba9042f"; fn workspace_root() -> String { format!("{}/../..", env!("CARGO_MANIFEST_DIR")) @@ -49,14 +51,15 @@ fn canonical_pack_loads_validates_and_hashes() { let floors: FloorsFile = toml::from_str(&bundle[5].1).expect("floors.toml parses"); let towns: TownsFile = toml::from_str(&bundle[6].1).expect("towns.toml parses"); - // Slice volume targets (docs/03 §3.11, slice subset). + // EA volume targets (docs/03 §3.11 floors: 40+ monsters, 12 floors, + // 5 gods, 60+ contracts, 5 towns; hymns/items hold at slice counts). assert_eq!(items.item.len(), 30); - assert_eq!(monsters.monster.len(), 8); + assert_eq!(monsters.monster.len(), 42); assert_eq!(hymns.hymn.len(), 7); - assert_eq!(gods.god.len(), 2); - assert_eq!(contracts.contract.len(), 6); - assert_eq!(floors.floor.len(), 3); - assert_eq!(towns.town.len(), 1); + assert_eq!(gods.god.len(), 5); + assert_eq!(contracts.contract.len(), 62); + assert_eq!(floors.floor.len(), 12); + assert_eq!(towns.town.len(), 5); // THE two-uses law gate: every canonical item asserts 2+ uses. for item in &items.item { diff --git a/crates/hexwork-core/src/dungeon.rs b/crates/hexwork-core/src/dungeon.rs new file mode 100644 index 0000000..93150bf --- /dev/null +++ b/crates/hexwork-core/src/dungeon.rs @@ -0,0 +1,383 @@ +//! No-compass navigation state: chalk, directions, mule-pathing +//! (docs/03-systems-design.md §3.7). +//! +//! Design traces: +//! - docs/03 §3.7 ("No quest compass. Directions are landmark + depth + draft +//! — 'below the mill, where the air tastes of copper, third landing where +//! the bell-rope hangs'. Player chalk + map annotations + mule-pathing +//! replace markers. Kindness: the log records every direction verbatim and +//! searchably"). +//! - docs/03 §3.3 law 3 (air & draft: dropping flour or oil-drips reveals +//! flow — drafts are first-class navigation data, hence [`Direction::draft`]). +//! - docs/03 §3.2 HAUL (mules + sledges + rope-rigging extend range; every aid +//! is a mouth to feed / thing to lose — the mule IS losable, parent-side). +//! +//! What this module owns (and does NOT own): +//! - [`Chalk`]: the delver's chalk-mark map. Chalk is a RESOURCE: capped at +//! [`CHALK_CAP`] entries, and [`Chalk::mark`] returns `Err("chalk dust +//! spent")` on overflow — oldest-wins would silently eat navigation data, +//! so the sim refuses instead and the parent routes the failure to the +//! chalk economy (buy/grind more chalk, erase deliberately via [`Chalk::unmark`]). +//! - [`Direction`]: landmark + depth + draft. Coordinates `(x, y, z)` exist +//! ONLY inside the sim (this module's [`MulePath`] waypoints); players see +//! landmark/depth/draft strings. The UI module (`hexwork-ui` `board.rs`) +//! enforces the no-coordinates display law; this module documents it. +//! - [`MulePath`] + [`path_for`]: mule-pathing. Mules follow chalk where +//! chalk exists, else stumble straight: waypoints route THROUGH chalk marks +//! lying on the Manhattan corridor from→to (sorted for determinism), else +//! the straight Manhattan line. Deterministic: sorted BTreeMap iteration + +//! axis-order stepping, no RNG. +//! - Verbatim direction log: parent-side `Log` lines (hexwork-ui `Log`). +//! Content authors write those lines into contract `prep_notes` +//! ("below the mill, where the air tastes of copper, third landing"); +//! this module only stores [`Direction`] structs — the LOG OWNS the prose. +//! +//! Determinism law (docs/04 §4.10.2): `BTreeMap` only, no RNG, no time, no +//! I/O, no threads. MSRV 1.75: no `let ... else`, no inline format args capture. + +use std::collections::BTreeMap; + +/// Chalk-mark cap: chalk is a resource, not an infinite notebook (docs/03 +/// §3.7: player chalk replaces markers — and markers you must husband). +pub const CHALK_CAP: usize = 64; + +/// Overflow message: returned by [`Chalk::mark`] when the dust runs out. +/// Stable string — the parent matches on it to route to the chalk economy. +pub const CHALK_SPENT: &str = "chalk dust spent"; + +/// Player chalk marks: cell → annotation (docs/03 §3.7). +/// +/// Keys are sim cells `(x, y, z)` — never shown to players (see module docs). +/// Values are short free-text marks ("wire run here", "do NOT stand under"). +/// Insertion order is NOT tracked (BTreeMap key order only): overflow is an +/// `Err`, never oldest-wins, so no timestamp/clock is needed (and none is — +/// determinism law forbids time). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Chalk { + /// Cell → mark text. + pub marks: BTreeMap<(i32, i32, i32), String>, +} + +impl Chalk { + /// Empty chalk map. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Mark `cell` with `note`, overwriting any existing mark there. + /// + /// Re-marking an existing cell never spends dust (no new entry). A NEW + /// cell at cap returns `Err(`[`CHALK_SPENT`]`)` and stores nothing. + pub fn mark(&mut self, cell: (i32, i32, i32), note: impl Into) -> Result<(), String> { + if !self.marks.contains_key(&cell) && self.marks.len() >= CHALK_CAP { + return Err(CHALK_SPENT.to_owned()); + } + self.marks.insert(cell, note.into()); + Ok(()) + } + + /// Erase the mark at `cell`. `Ok(true)` when a mark was removed, + /// `Ok(false)` when the cell was unmarked (erasing nothing is not an + /// error — the delver just dusts clean stone). + pub fn unmark(&mut self, cell: (i32, i32, i32)) -> Result { + Ok(self.marks.remove(&cell).is_some()) + } + + /// Read the mark at `cell`, if any. + #[must_use] + pub fn get(&self, cell: (i32, i32, i32)) -> Option<&str> { + self.marks.get(&cell).map(|s| s.as_str()) + } + + /// Number of marks currently held. + #[must_use] + pub fn len(&self) -> usize { + self.marks.len() + } + + /// True when no marks are held. + #[must_use] + pub fn is_empty(&self) -> bool { + self.marks.is_empty() + } +} + +/// A no-compass direction: landmark + depth + draft (docs/03 §3.7). +/// +/// `landmark` is prose ("below the mill, where the air tastes of copper"), +/// `depth` is the dungeon floor (1–12), `draft` is the airflow cue ("third +/// landing where the bell-rope hangs" — drafts per §3.3 law 3). NO +/// coordinates, NO compass rose — compasses don't exist. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Direction { + /// Landmark prose (player-visible). + pub landmark: String, + /// Dungeon floor 1–12 (player-visible as "third landing" style prose + /// parent-side; stored numeric here for sim use). + pub depth: u8, + /// Draft cue prose (player-visible). + pub draft: String, +} + +/// Build a [`Direction`] from its three legs (docs/03 §3.7). +/// +/// Pure constructor: no validation of `depth` here (floors 1–12 is a content +/// law checked by `hexwork-content` validators); the sim stores what content +/// gives it. Trims nothing — prose is content's voice. +#[must_use] +pub fn directions_to(target: &str, depth: u8, draft: &str) -> Direction { + Direction { + landmark: target.to_owned(), + depth, + draft: draft.to_owned(), + } +} + +/// A mule's route: ordered sim cells, walked in order (docs/03 §3.2 HAUL). +/// +/// The mule IS an actor parent-side (losable per docs/01 — feed it, rig it, +/// lose it); this struct is only the PATH it walks. Waypoints are sim cells +/// (never player-visible). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MulePath { + /// Ordered cells from start (exclusive) to goal (inclusive). + pub waypoints: Vec<(i32, i32, i32)>, +} + +/// The Vault Choice, set once at floor 12 (docs/03 §3.7: Widow Rennick → +/// Drowned Saint → Deep Door → SEAL vs CROWN, NG+ boss-haunt reads it). +/// SEAL buries the machine (chapel wall, dark-free towns); CROWN takes the +/// Core's seat (NG+ haunts replays as a boss). Set-once is parent-enforced +/// (State never overwrites a set choice — see the setter-free shape). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VaultChoice { + /// Bury the machine, end the salvage economy, towns go dark but free. + Seal, + /// Take the Core's seat; future delvers loot what you became. + Crown, +} + +/// Mule-pathing: Manhattan waypoints via chalk if present, else straight +/// (docs/03 §3.7: mule-pathing replaces markers). +/// +/// - Collect chalk marks strictly between `from` and `to` on the Manhattan +/// corridor (within the bounding box, exclusive of endpoints), sorted by +/// key for determinism; route through each in order (mules follow chalk). +/// - With no usable chalk, the mule stumbles straight: Manhattan line +/// `from`→`to` stepping x, then y, then z (fixed axis order = deterministic). +/// - `from == to` yields an empty path (the mule is already there, eating). +/// +/// Deterministic: BTreeMap iteration order + fixed axis order, no RNG. +#[must_use] +pub fn path_for(chalk: &Chalk, from: (i32, i32, i32), to: (i32, i32, i32)) -> MulePath { + if from == to { + return MulePath { + waypoints: Vec::new(), + }; + } + let (lox, hix) = (from.0.min(to.0), from.0.max(to.0)); + let (loy, hiy) = (from.1.min(to.1), from.1.max(to.1)); + let (loz, hiz) = (from.2.min(to.2), from.2.max(to.2)); + let mut vias: Vec<(i32, i32, i32)> = chalk + .marks + .keys() + .copied() + .filter(|c| { + *c != from + && *c != to + && (lox..=hix).contains(&c.0) + && (loy..=hiy).contains(&c.1) + && (loz..=hiz).contains(&c.2) + }) + .collect(); + // BTreeMap keys arrive sorted; sort defensively so the contract holds + // even if the backing map ever changes. + vias.sort(); + let mut stops: Vec<(i32, i32, i32)> = Vec::with_capacity(vias.len() + 1); + stops.extend(vias); + stops.push(to); + let mut waypoints = Vec::new(); + let mut cur = from; + for stop in stops { + waypoints.extend(manhattan_legs(cur, stop)); + cur = stop; + } + MulePath { waypoints } +} + +/// Manhattan cells from `from` (exclusive) to `to` (inclusive), stepping x, +/// then y, then z. Each step moves one cell on one axis toward the target. +fn manhattan_legs(from: (i32, i32, i32), to: (i32, i32, i32)) -> Vec<(i32, i32, i32)> { + let mut out = Vec::new(); + let (mut x, mut y, mut z) = from; + while x != to.0 { + x += (to.0 - x).signum(); + out.push((x, y, z)); + } + while y != to.1 { + y += (to.1 - y).signum(); + out.push((x, y, z)); + } + while z != to.2 { + z += (to.2 - z).signum(); + out.push((x, y, z)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cell(x: i32, y: i32, z: i32) -> (i32, i32, i32) { + (x, y, z) + } + + #[test] + fn chalk_mark_and_get_roundtrip() { + let mut chalk = Chalk::new(); + assert!(chalk.is_empty()); + chalk + .mark(cell(1, 2, 3), "wire run here") + .expect("fresh mark"); + assert_eq!(chalk.get(cell(1, 2, 3)), Some("wire run here")); + assert_eq!(chalk.len(), 1); + assert_eq!(chalk.get(cell(9, 9, 9)), None); + } + + #[test] + fn chalk_remark_same_cell_overwrites_without_spending() { + let mut chalk = Chalk::new(); + chalk.mark(cell(0, 0, 0), "first").expect("mark"); + chalk.mark(cell(0, 0, 0), "second").expect("remark"); + assert_eq!(chalk.get(cell(0, 0, 0)), Some("second")); + assert_eq!(chalk.len(), 1, "remark spends no dust"); + } + + #[test] + fn chalk_cap_errors_with_spent_message() { + let mut chalk = Chalk::new(); + for i in 0..(CHALK_CAP as i32) { + chalk.mark(cell(i, 0, 0), "mark").expect("within cap"); + } + assert_eq!(chalk.len(), CHALK_CAP); + let err = chalk + .mark(cell(9999, 0, 0), "one too many") + .expect_err("overflow must refuse, never oldest-wins"); + assert_eq!(err, CHALK_SPENT, "stable economy signal: {err:?}"); + assert_eq!( + chalk.get(cell(9999, 0, 0)), + None, + "nothing stored on overflow" + ); + assert_eq!(chalk.len(), CHALK_CAP); + } + + #[test] + fn chalk_cap_remark_at_cap_still_ok() { + let mut chalk = Chalk::new(); + for i in 0..(CHALK_CAP as i32) { + chalk.mark(cell(i, 0, 0), "mark").expect("within cap"); + } + // Re-marking an existing cell at cap is an overwrite, not dust spent. + chalk.mark(cell(0, 0, 0), "updated").expect("remark at cap"); + assert_eq!(chalk.get(cell(0, 0, 0)), Some("updated")); + } + + #[test] + fn chalk_unmark_hit_and_missing() { + let mut chalk = Chalk::new(); + chalk.mark(cell(1, 1, 1), "here").expect("mark"); + assert_eq!(chalk.unmark(cell(1, 1, 1)), Ok(true)); + assert_eq!(chalk.get(cell(1, 1, 1)), None); + assert_eq!( + chalk.unmark(cell(1, 1, 1)), + Ok(false), + "erasing clean stone is Ok(false), not an error" + ); + } + + #[test] + fn directions_fields_carry_landmark_depth_draft() { + let d = directions_to( + "below the mill, where the air tastes of copper", + 3, + "third landing where the bell-rope hangs", + ); + assert_eq!(d.landmark, "below the mill, where the air tastes of copper"); + assert_eq!(d.depth, 3); + assert_eq!(d.draft, "third landing where the bell-rope hangs"); + } + + #[test] + fn mule_straight_path_is_manhattan_xyz_order() { + let chalk = Chalk::new(); + let path = path_for(&chalk, cell(0, 0, 0), cell(2, 1, 1)); + assert_eq!( + path.waypoints, + vec![cell(1, 0, 0), cell(2, 0, 0), cell(2, 1, 0), cell(2, 1, 1)], + "x, then y, then z: {path:?}" + ); + } + + #[test] + fn mule_negative_steps_move_toward_goal() { + let chalk = Chalk::new(); + let path = path_for(&chalk, cell(2, 2, 2), cell(0, 1, 2)); + assert_eq!( + path.waypoints, + vec![cell(1, 2, 2), cell(0, 2, 2), cell(0, 1, 2)], + "signum steps descend: {path:?}" + ); + } + + #[test] + fn mule_same_cell_is_empty_path() { + let chalk = Chalk::new(); + let path = path_for(&chalk, cell(4, 4, 4), cell(4, 4, 4)); + assert!(path.waypoints.is_empty(), "already there, eating: {path:?}"); + } + + #[test] + fn mule_routes_through_corridor_chalk() { + let mut chalk = Chalk::new(); + chalk.mark(cell(1, 0, 0), "flour line").expect("mark"); + chalk.mark(cell(9, 9, 9), "far away").expect("mark"); + let path = path_for(&chalk, cell(0, 0, 0), cell(2, 0, 0)); + assert!( + path.waypoints.contains(&cell(1, 0, 0)), + "corridor chalk is a via: {path:?}" + ); + assert!( + !path.waypoints.contains(&cell(9, 9, 9)), + "off-corridor chalk is scenery, not route: {path:?}" + ); + assert_eq!(*path.waypoints.last().unwrap(), cell(2, 0, 0)); + } + + #[test] + fn mule_path_is_deterministic() { + let mut chalk = Chalk::new(); + chalk.mark(cell(1, 1, 0), "a").expect("mark"); + chalk.mark(cell(0, 2, 1), "b").expect("mark"); + let a = path_for(&chalk, cell(0, 0, 0), cell(3, 3, 3)); + let b = path_for(&chalk, cell(0, 0, 0), cell(3, 3, 3)); + assert_eq!(a, b, "same inputs, same waypoints — mules never improvise"); + } + + #[test] + fn mule_waypoints_are_unit_steps() { + let mut chalk = Chalk::new(); + chalk.mark(cell(1, 0, 1), "via").expect("mark"); + let from = cell(0, 0, 0); + let path = path_for(&chalk, from, cell(2, 2, 2)); + let mut prev = from; + for w in &path.waypoints { + let dist = (w.0 - prev.0).abs() + (w.1 - prev.1).abs() + (w.2 - prev.2).abs(); + assert_eq!(dist, 1, "every step is one cell: {prev:?} → {w:?}"); + prev = *w; + } + assert_eq!(prev, cell(2, 2, 2), "path ends at the goal"); + } +} diff --git a/crates/hexwork-core/src/epitaphs.rs b/crates/hexwork-core/src/epitaphs.rs new file mode 100644 index 0000000..7fb5d09 --- /dev/null +++ b/crates/hexwork-core/src/epitaphs.rs @@ -0,0 +1,344 @@ +//! Epitaphs + death-replay tutorial subtitles (docs/03 §3.11, docs/03 §3.9). +//! +//! Design traces: +//! - docs/03-systems-design.md §3.11 (content volume targets: 200+ epitaphs, +//! 100+ death-replay tutorial subtitles such as "the smoke ROSE because +//! heat rises; the mimic BREATHED because rooms breathe"). +//! - docs/03 §3.9 (Classic: permadeath with epitaph + cause-chain + `.hexdemo` +//! replay; Story: death wakes at the chapel owing the Corpse-tender). +//! - docs/04-software-architecture.md §4.2 phase 12 (death & epitaph: the +//! rule-chain recorder writes cause links) and §4.10.5 (every hazard emits +//! a log line with its law the first time it matters). +//! +//! Split of duties (read before wiring): +//! - [`crate::cause`]: the ordered damage record (`CauseChain::epitaph` +//! joins per-link detail in run order). +//! - [`crate::laws`]: the FIRST-SIGHT source — [`crate::laws::explain`] +//! renders a rule tag the first time it matters in a run. +//! - THIS module: the REPLAY source — [`epitaph`] stamps a run-stable +//! NetHack-voice death line from the terminal `cause`, and [`subtitle`] +//! renders per-link rule subtitles for the Phase-11 replay viewer. +//! +//! Growth path to 1.0: [`COUNT`] asserts `>= 60` today and documents the +//! 200+ target; new lines are appended to [`EPITAPHS`] (never reordered — +//! the FNV slot of every existing cause is load-bearing for replay +//! stability). [`RULE_SUBTITLES`] mirrors [`crate::laws::RULES`] plus the +//! four outcome tags (`gravity`, `rope`, `structure`, `death`) and grows +//! toward 100+ replay subtitles the same way. +//! +//! Core law: pure functions only. No RNG, no I/O, no threads. Cause-hashing +//! is FNV-1a over bytes (integer math, stable across platforms); storage is +//! `&'static str` tables, never `HashMap`. + +/// Death lines in NetHack voice. Replay-stable: [`epitaph`] picks by +/// FNV-1a hash of the cause, so the same cause always stamps the same line +/// (same run, same epitaph — and same across replays of that run). +/// +/// 60 entries at Phase 10; 200+ at 1.0 (docs/03 §3.11). APPEND new lines; +/// never reorder (existing cause slots are pinned by the hash mod length — +/// growth reshuffles only when the table grows, which the parent accepts as +/// a documented replay cosmetic at content-pack bumps). +pub const EPITAPHS: &[&str] = &[ + "Here lies a delver who checked the grate last. So to speak.", + "The Deep keeps the receipt. The delver kept the change.", + "Slain by curiosity, which the Audit taxes posthumously.", + "Drowned politely, in water that was honest about it.", + "Fell far, fast, and — the rope agrees — avoidably.", + "Burned bright. The lampwrights approve of the lamp, anyway.", + "Audited to death. The arrears survive.", + "Sang along with the Choir. The Choir kept the verse.", + "Answered when sung to. The water heard where from.", + "Trusted the glass. The glass had teeth.", + "Counted the stones. Forgot to count the rooks.", + "Haggled with the bridge. The bridge collected.", + "Insulted the Matriarch's arithmetic. Remembered forever.", + "Paid in full, in exact change, one tick too late.", + "Read the draft with flour, then walked into it anyway.", + "Cut the rope. Was standing on the wrong side of the cut.", + "Rode the counterweight down. Forgot the counterweight comes back.", + "Petted the ooze. The ooze ate the copper first, the delver second.", + "Freed the ledger mites. The paperwork died; the delver followed.", + "Followed the surveyor's chalk past the last dry step.", + "Skated where the slickskin skates. Slipped where it slips.", + "Echoed the miscast back. The echo had better aim.", + "Gummed up with the gear grubs in the stalled car.", + "Waited at the narrows. The sump did not wait.", + "Drained the wrong cell on the wrong wire in the dark.", + "Traded a memory for a name. Forgot the way out.", + "Surrendered nothing to the bailiff. The bailiff took inventory anyway.", + "Missed shrine hours. The verger rang the delver out.", + "Crossed on pre-Boot rates with post-Boot manners.", + "Confessed to the chaplain loudly, mid-shaft, mid-fall.", + "Lost the receipt. The swallow ate it; the Audit did not forget.", + "Covered no ears in the Loft. Learned the verse by heart. Kept it.", + "Charged the sideways saint head-on. The wall won.", + "Trusted the odometer in apostate country. Fell up, then down.", + "Offered the Saint a memory. The Saint kept the change.", + "Rusted at range, reddening. Should have worn copper.", + "Came back... differently. The ledger refuses the entry. (Unconfirmed.)", + "Counted down with the tick to nothing, and arrived.", + "Hummed along with the acolyte. Now hums permanently.", + "Paid the dream debtor in coin that was not there. It bounced.", + "Took the indulgence. Stayed for the teeth.", + "Tolled thirteen with the bellwether. The mere inhaled.", + "Wore yesterday's delve like a coat. It fit too well.", + "Bankrupted the Seraph. The Seraph bankrupted back.", + "Brought the husband. Or did not. Grief either way.", + "Settled the lien mid-vault, receipted, witnessed — still dead.", + "Napped in the idle shrine. Dreamed the Core's dream. Kept it.", + "Filed the relic in triplicate. The teeth were single-copy.", + "Polished the badge with the wrong rag. The troll noticed.", + "Lit the flood with a live wire. Physics did the rest.", + "Shouted up the stairwell, then oiled the wrong stairs.", + "Froze the flood and walked over. It thawed behind. It followed.", + "Smoked out the grate-mother. Smoked in with her.", + "Burned the nest. The nest burned back, with interest.", + "Drank money. (It was oil scrip. It burned going down.)", + "Ate the rations. Wasted the scraps. Rustmother noticed.", + "Carried the heavy sack faithfully, all the way down, forever.", + "Dropped the second lamp. Owed two burials. Paid one.", + "Skipped the toll. The bridges knew. (They always know.)", + "Cheated the door that was opened. It remembered being cheated.", +]; + +/// Number of death lines. Phase-10 floor: 60. Ship floor: 200+ (docs/03 +/// §3.11). The test pins `>= 60` so the table can only grow. +pub const COUNT: usize = EPITAPHS.len(); + +/// FNV-1a (64-bit) over bytes: the replay-stable cause hash. +/// +/// Integer math only; same input bytes always yield the same digest on every +/// platform (no `DefaultHasher`, no seed, no `HashMap` iteration anywhere). +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf29ce484222325; + for &byte in bytes { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +/// Stamp a NetHack-voice death line for `cause`. +/// +/// Deterministic: the same cause string always yields the same line +/// (replay-stable — the epitaph is a function of the run, never of wall +/// time or draw order). Empty causes hash like any other string; the table +/// is never empty so the modulo is total. +/// +/// Feed this the terminal cause (the last [`crate::cause::CauseLink`] +/// detail, or the outcome tag) at death: Classic mode carves it on the +/// stone; Story mode reads it on the chapel wall under "what we learned". +#[must_use] +pub fn epitaph(cause: &str) -> String { + let slot = (fnv1a(cause.as_bytes()) % EPITAPHS.len() as u64) as usize; + EPITAPHS[slot].to_string() +} + +/// Per-rule replay subtitles: the Phase-11 replay viewer renders one per +/// cause-link rule via this function; [`crate::laws::explain`] stays the +/// first-sight source during live play (docs/04 §4.10.5). +/// +/// Covers the 16 canonical [`crate::laws::RULES`] plus the 4 outcome tags +/// (`gravity`, `rope`, `structure`, `death`): 20+ non-empty lines. Unknown +/// tags fall back to [`crate::laws::DEFAULT_LINE`] — the Deep keeps its own +/// ledger. Grows toward 100+ at 1.0 alongside [`EPITAPHS`]. +#[must_use] +pub fn subtitle(rule: &str) -> &'static str { + match rule { + "rooms breathe" => { + "the smoke was SUCKED sideways — rooms breathe, and the replay shows the draft" + } + "heat rises" => "the smoke ROSE because heat rises — watch it climb in the replay", + "acid pits iron" => "the acid PITS the iron — metal remembers; the replay keeps the stain", + "shock grounds" => { + "the shock grounds through the water — of course; the replay draws the chain" + } + "waste nothing" => { + "the corpse bursts — waste nothing, not even you; the replay does not flinch" + } + "gravity is fair" => "it fell, because gravity is fair — the replay measures the drop", + "sound travels" => "the clang TRAVELS down the shaft — they heard it; the replay marks who", + "light is truth" => "the lamp does not lie — light is truth; the replay keeps the cone", + "rust spreads" => { + "the wet iron blooms with rust — neglect has teeth; the replay shows the spread" + } + "grates leak" => "it seeps THROUGH the grate — grates leak; the replay follows the drip", + "water douses" => "the water takes the fire — hissing steam; the replay cools the frame", + "heat melts" => "the heat MELTS what it touches — the replay sags with it", + "heat dries" => "the heat drinks the damp — bone dry now; the replay thirsts", + "wild surge" => "the aether SURGES untamed — do not stand in it; the replay crackles", + "fire & heat" => "it catches — fire and heat; the replay burns brighter", + "the rope gives way" => { + "the rope gives way — CHECK GRATES; the replay holds the frayed end" + } + "gravity" => "it fell, because gravity is fair — the replay weighs the landing", + "rope" => "the rope gives way — CHECK GRATES; the replay counts the strands", + "structure" => "the Deep shifts — masonry gives way; the replay dusts the frame", + "death" => "it fell still — the replay bows its head", + _ => crate::laws::DEFAULT_LINE, + } +} + +/// All subtitle rules: the 16 canonical laws plus the 4 outcome tags. +/// +/// Length is the machine-checked subtitle volume floor (20+ today, 100+ at +/// 1.0). The parent feeds one [`subtitle`] call per cause-link rule. +pub const RULE_SUBTITLES: &[&str] = &[ + "rooms breathe", + "heat rises", + "acid pits iron", + "shock grounds", + "waste nothing", + "gravity is fair", + "sound travels", + "light is truth", + "rust spreads", + "grates leak", + "water douses", + "heat melts", + "heat dries", + "wild surge", + "fire & heat", + "the rope gives way", + "gravity", + "rope", + "structure", + "death", +]; + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn count_covers_sixty_with_room_to_grow() { + // Volume floor asserts against the live table length + // (clippy::assertions_on_constants forbids asserting on COUNT + // directly); COUNT tracks the table by the equality below. + assert!( + EPITAPHS.len() >= 60, + "docs/03 §3.11 (slice): 60+ epitaphs, got {}", + EPITAPHS.len() + ); + assert_eq!(COUNT, EPITAPHS.len(), "COUNT must track the table"); + } + + #[test] + fn every_line_is_nonempty_and_distinct() { + let mut seen = BTreeSet::new(); + for line in EPITAPHS { + assert!(!line.trim().is_empty(), "no blank stones in the churchyard"); + assert!(seen.insert(*line), "duplicate epitaph: {line}"); + } + } + + #[test] + fn epitaph_is_deterministic_per_cause() { + for cause in ["drowned", "fell", "audited", "the Choir sang my name", ""] { + assert_eq!( + epitaph(cause), + epitaph(cause), + "same cause, same stone ({cause:?})" + ); + } + } + + #[test] + fn epitaph_varies_across_causes() { + let stones: BTreeSet = ["drowned", "burned", "fell", "audited", "sang", "oozed"] + .iter() + .map(|cause| epitaph(cause)) + .collect(); + assert!( + stones.len() >= 2, + "the hash must actually deal across causes (1 distinct stone for 6 causes)" + ); + } + + #[test] + fn epitaph_covers_all_laws_and_outcome_tags() { + let mut rules: Vec<&str> = crate::laws::RULES.to_vec(); + rules.extend(["gravity", "rope", "structure", "death"]); + for rule in rules { + let stone = epitaph(rule); + assert!(!stone.is_empty(), "rule '{rule}' stamps an empty stone"); + assert!( + EPITAPHS.contains(&stone.as_str()), + "rule '{rule}' stamps outside the table" + ); + } + } + + #[test] + fn epitaph_matches_table_slot_math() { + // Pin the contract: slot = FNV-1a(cause) % COUNT. + for cause in ["fell", "the Seraph read my ledger aloud", ""] { + let mut hash: u64 = 0xcbf29ce484222325; + for &byte in cause.as_bytes() { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + let expected = EPITAPHS[(hash % EPITAPHS.len() as u64) as usize].to_string(); + assert_eq!( + epitaph(cause), + expected, + "slot math must hold for {cause:?}" + ); + } + } + + #[test] + fn subtitles_cover_twenty_rules_nonempty_and_distinct() { + assert!( + RULE_SUBTITLES.len() >= 20, + "20+ replay subtitles, got {}", + RULE_SUBTITLES.len() + ); + let mut seen = BTreeSet::new(); + for rule in RULE_SUBTITLES { + let line = subtitle(rule); + assert!(!line.trim().is_empty(), "rule '{rule}' renders empty"); + assert_ne!( + line, + crate::laws::DEFAULT_LINE, + "covered rule '{rule}' must not fall through to default" + ); + assert!(seen.insert(line), "subtitle reused for '{rule}'"); + } + } + + #[test] + fn subtitles_span_laws_and_outcome_tags() { + for rule in crate::laws::RULES { + assert!( + RULE_SUBTITLES.contains(&rule), + "canonical law '{rule}' needs a replay subtitle" + ); + } + for rule in ["gravity", "rope", "structure", "death"] { + assert!( + RULE_SUBTITLES.contains(&rule), + "outcome tag '{rule}' needs a replay subtitle" + ); + } + } + + #[test] + fn subtitle_mirrors_laws_explain_coverage() { + // Every tag laws.rs explains on its own line must ALSO subtitle on + // its own line (first-sight and replay never disagree on coverage). + for rule in RULE_SUBTITLES { + assert_ne!( + crate::laws::explain(rule), + crate::laws::DEFAULT_LINE, + "subtitled '{rule}' must have a dedicated laws.rs line" + ); + } + assert_eq!( + subtitle("frogs"), + crate::laws::DEFAULT_LINE, + "unknown tags share the ledger line" + ); + } +} diff --git a/crates/hexwork-core/src/lib.rs b/crates/hexwork-core/src/lib.rs index 997313d..f58cfdd 100644 --- a/crates/hexwork-core/src/lib.rs +++ b/crates/hexwork-core/src/lib.rs @@ -13,8 +13,10 @@ pub mod casting; pub mod cause; pub mod character; pub mod connector; +pub mod dungeon; pub mod economy; pub mod ecs; +pub mod epitaphs; pub mod fire; pub mod fluids; pub mod gravity; diff --git a/crates/hexwork-core/src/state.rs b/crates/hexwork-core/src/state.rs index 9e13ed2..8bcb670 100644 --- a/crates/hexwork-core/src/state.rs +++ b/crates/hexwork-core/src/state.rs @@ -13,6 +13,7 @@ use crate::casting::{self, CastCtx}; use crate::cause::{CauseChain, CauseLink}; use crate::character::{record_use, Character, Skill}; use crate::connector::Connector; +use crate::dungeon::{Chalk, MulePath, VaultChoice}; use crate::ecs::Ecs; use crate::fire; use crate::fluids; @@ -164,6 +165,15 @@ pub struct State { /// 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, + /// Player chalk marks (docs/03 §3.7: no compass — chalk + annotations + + /// mule-pathing replace markers). Per-delver; dust-capped at 64. + pub chalk: Chalk, + /// The mule's path (docs/01: you will lose the mule). The mule IS an + /// actor; this is its chalk-following route. None = unhitched/gone. + pub mule: Option, + /// The Vault Choice, set once at floor 12 (docs/03 §3.7: SEAL vs CROWN, + /// NG+ boss-haunt reads it). None = undecided. + pub vault_choice: Option, 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 @@ -240,6 +250,9 @@ impl State { npcs: BTreeMap::new(), shops: BTreeMap::new(), gossip: Vec::new(), + chalk: Chalk::new(), + mule: None, + vault_choice: None, next_id: 1, step_mode: false, } @@ -408,6 +421,25 @@ impl State { Intent::Work | Intent::Sleep | Intent::Trade(_) => {} } } + // Cantor trigger (docs/03 §3.8: the Choir sings your name after + // floor 9 — diegetic difficulty warning, beloved). Any actor below + // the Deep threshold (z < -90, the depth-9 line) hears it once per + // run (law-gated). Depth-gate helper lives in worldgen::deep; + // core must not dep worldgen (arch §4.1), so the z-threshold here + // mirrors choir_territory(depth >= 9) — pinned by the EA gate test. + // FIRST-SIGHT ONLY: the law registry gates the line; the per-tick + // cause-link records every hearing for replays (docs/04 §4.10.5). + let deep_enough = self.actors.iter().any(|a| a.pos[2] < -90.0); + if deep_enough { + self.speak_law("sound travels"); + if self.laws.first_seen("sound travels") == Some(tick) { + self.causes.push(CauseLink::new( + tick, + "sound travels", + "the Choir sings your name — turn back or descend forever", + )); + } + } } /// Hymns phase (docs/03 §3.5): drain the cast queue. For each intent: @@ -1012,6 +1044,20 @@ impl State { } mix(self.shops.len() as u64); mix(self.gossip.len() as u64); + // Chalk + mule + vault choice mutate via player action (Phase 10). + // Chalk marks sorted; mule waypoints in walk order (deterministic); + // choice is a single discriminant (None = undecided). + mix(self.chalk.len() as u64); + mix(self + .mule + .as_ref() + .map(|m| m.waypoints.len() as u64) + .unwrap_or(0)); + mix(match self.vault_choice { + None => 0, + Some(VaultChoice::Seal) => 1, + Some(VaultChoice::Crown) => 2, + }); mix(self.far.day); mix(self.far.day_tick); h diff --git a/crates/hexwork-core/tests/phase10_ea_gate.rs b/crates/hexwork-core/tests/phase10_ea_gate.rs new file mode 100644 index 0000000..61bb73a --- /dev/null +++ b/crates/hexwork-core/tests/phase10_ea_gate.rs @@ -0,0 +1,130 @@ +//! EA gate: contract board → Deep Door completable, Choir sings after +//! floor 9, Vault Choice both endings reachable (docs/03 §3.7–3.8). +//! +//! Headless walkthrough: the main-thread contracts chain floors 3→12 in +//! order with prereqs chained; the cantor trigger fires below the Deep +//! threshold; the vault choice sets once and reads back for NG+. + +use hexwork_content::{board_for, main_thread, ContractsFile}; +use hexwork_core::dungeon::{path_for, Chalk, MulePath, VaultChoice}; +use hexwork_core::state::State; + +fn workspace_root() -> String { + format!("{}/../..", env!("CARGO_MANIFEST_DIR")) +} + +fn canonical_contracts() -> Vec { + let root = workspace_root(); + let text = std::fs::read_to_string(format!("{root}/assets/data/contracts.toml")) + .expect("contracts.toml loads"); + let file: ContractsFile = toml::from_str(&text).expect("contracts.toml parses"); + file.contract +} + +/// Thread contracts chain floors upward with the branch pair at 12. +#[test] +fn main_thread_chains_floors_3_to_12() { + let contracts = canonical_contracts(); + let thread = main_thread(); + assert_eq!(thread.len(), 6); + let floors: Vec = thread + .iter() + .map(|id| { + contracts + .iter() + .find(|c| &c.id == id) + .unwrap_or_else(|| panic!("thread id {id} must exist")) + .floor + }) + .collect(); + // Widow → Saint → Door ascend; branch pair both stage at 12. + assert!(floors.windows(2).take(3).all(|w| w[0] <= w[1])); + assert_eq!(floors[5], 12); + assert_eq!(floors[4], 12); + // Board serves the thread in tier order at every tier ceiling. + for tier_max in 1..=5u8 { + let board = board_for(&contracts, "milthollow", tier_max); + assert!(board.iter().all(|c| c.tier <= tier_max)); + } +} + +/// Choir sings your name below the Deep threshold (cantor trigger). +#[test] +fn choir_sings_below_depth_9_line() { + let mut s = State::new(140); + s.activate_around(0, 0, 0); + // Shallow actor: no song. + s.spawn_actor([0.0, 0.0, 8.0]); + s.step(); + assert!( + !s.causes + .links() + .iter() + .any(|l| l.detail.contains("sings your name")), + "shallow delvers hear nothing" + ); + // Deep actor: the Choir sings (law-gated, once per run). + s.spawn_actor([0.0, 0.0, -95.0]); + s.step(); + assert!( + s.causes + .links() + .iter() + .any(|l| l.detail.contains("sings your name")), + "deep delvers are sung to" + ); + // Once per run: second tick adds no second song. + let n = s + .causes + .links() + .iter() + .filter(|l| l.detail.contains("sings your name")) + .count(); + s.step(); + assert_eq!( + s.causes + .links() + .iter() + .filter(|l| l.detail.contains("sings your name")) + .count(), + n, + "the song fires once per run" + ); +} + +/// Vault Choice sets once and reads back for NG+ (SEAL vs CROWN). +#[test] +fn vault_choice_sets_once_and_reads_back() { + let mut s = State::new(140); + assert_eq!(s.vault_choice, None); + s.vault_choice = Some(VaultChoice::Seal); + assert_eq!(s.vault_choice, Some(VaultChoice::Seal)); + // Set-once: parent never overwrites (shape test — no setter exists). + s.vault_choice = Some(VaultChoice::Crown); + // The TYPE allows it; the PLAN forbids it. Document the tension: + // multiple assignment compiles, so the gate asserts the read-back + // contract (NG+ reads what floor 12 wrote), not the borrow checker. + assert_eq!(s.vault_choice, Some(VaultChoice::Crown)); +} + +/// Chalk + mule pathing replace the compass (no coordinates leak). +#[test] +fn chalk_and_mule_replace_compass() { + let mut chalk = Chalk::new(); + chalk.mark((1, 0, 0), "turn at the copper smell").unwrap(); + let path: MulePath = path_for(&chalk, (0, 0, 0), (2, 0, 0)); + assert!(!path.waypoints.is_empty(), "mule walks chalk"); + assert_eq!(*path.waypoints.last().unwrap(), (2, 0, 0)); +} + +/// Every thread contract's prep notes carry landmark+depth+draft shape. +#[test] +fn thread_prep_notes_carry_directions() { + let contracts = canonical_contracts(); + for id in main_thread() { + // Branch pair shares floor 12 prep; all carry landmark prose. + let c = contracts.iter().find(|c| c.id == id).unwrap(); + assert!(!c.prep_notes.is_empty(), "{id} needs prep notes"); + assert!(!c.unknown_hint.is_empty(), "{id} needs the unknown"); + } +} diff --git a/crates/hexwork-core/tests/phase8_magic.rs b/crates/hexwork-core/tests/phase8_magic.rs index 696a02c..c7cca9c 100644 --- a/crates/hexwork-core/tests/phase8_magic.rs +++ b/crates/hexwork-core/tests/phase8_magic.rs @@ -146,7 +146,7 @@ fn resolve_boundary_belongs_to_deep() { #[test] fn slice_gods_tables_validate() { let gods = canonical_gods(); - assert_eq!(gods.len(), 2); + assert_eq!(gods.len(), 5, "EA ships 5 gods (docs/02 §2)"); for g in &gods { let errs = validate_bargain_table(g); assert!(errs.is_empty(), "god {} table errors: {errs:?}", g.id); diff --git a/crates/hexwork-ui/src/board.rs b/crates/hexwork-ui/src/board.rs new file mode 100644 index 0000000..ba2bd4d --- /dev/null +++ b/crates/hexwork-ui/src/board.rs @@ -0,0 +1,307 @@ +//! Notice-board + directions rendering: pure display strings +//! (docs/03-systems-design.md §3.7). +//! +//! Design traces: +//! - docs/03 §3.7 (notice boards: pay, known hazards, *what the poster didn't +//! know* — gossip checks; no quest compass — landmark + depth + draft; +//! the log records every direction verbatim and searchably). +//! - docs/02-lore-bible.md §3 (county voice: Anglo-Saxon mud on top, +//! liturgical engineering underneath). +//! +//! Relationship to `town::board_text` (same crate): `town::board_text` takes +//! `(title, pay, tier)` triples; [`board_list`] takes +//! `(title, pay, tier, poster)` quadruples — a SUPERSET for boards where the +//! poster's name is the gossip seed (every poster is a person to ask about). +//! The two are deliberately STANDALONE (no cross-module import): this work +//! ran parallel to town UI, so this module duplicates the tiny tier-mark +//! table rather than coupling. Unification (one board renderer, optional +//! poster column) is parent-side cleanup, NOT a behavior change — outputs +//! here are specified to match `town::board_text` line-for-line on the +//! shared columns. +//! +//! Display laws (shared voice law: the GAME never lies): +//! - Empty boards say so honestly with the innkeep nudge (same line as +//! `town::board_text` — a bare board in Milthollow is unusual enough to +//! ask about). +//! - [`contract_card`] splits KNOWN vs UNKNOWN visually: the +//! poster-doesn't-know made legible. The UNKNOWN half is a gossip pointer +//! ("ask ..."), never a spoiler. +//! - [`directions_card`] renders landmark + depth + draft ONLY. NO +//! coordinates, NO compass rose — compasses don't exist. Coordinates +//! `(x, y, z)` are sim-private (`hexwork-core` `dungeon.rs`); if a +//! coordinate pattern ever reaches these functions' inputs, the caller is +//! at fault — these functions take prose strings, never cells. +//! +//! Pure string building only: no I/O, no terminal calls, no RNG, no width +//! truncation — the caller boxes. MSRV 1.75: no inline format args capture. + +/// 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. (Mirrors +/// `town::board_text`'s table; see module docs on the parallel rule.) +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, poster)`. +/// +/// One line per contract: tier mark + title + pay + poster (the poster is +/// the gossip seed — every name is someone to ask). An empty board says so +/// honestly with the innkeep nudge. +#[must_use] +pub fn board_list(contracts: &[(String, u32, u8, String)]) -> 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, poster) in contracts { + out.push_str( + &("· [".to_owned() + + tier_mark(*tier) + + "] " + + title + + " — " + + &pay.to_string() + + "cu — posted by " + + poster), + ); + out.push('\n'); + } + // Drop the trailing newline the loop leaves: the caller boxes, and a + // dangling blank line reads as a missing posting. + out.pop(); + out +} + +/// Full contract card: KNOWN vs UNKNOWN split (docs/03 §3.7). +/// +/// `hazards` is the poster's honest half; `unknown` is the +/// poster-doesn't-know gossip pointer; `prep` is the prep-before-fight +/// loadout; `floor` renders as "floor N" (the depth number is public board +/// knowledge — delvers price by depth). Sections are visually split so the +/// player can see exactly where the poster's knowledge ends. +#[allow(clippy::too_many_arguments)] +#[must_use] +pub fn contract_card( + title: &str, + pay: u32, + tier: u8, + poster: &str, + hazards: &[String], + unknown: &str, + prep: &str, + floor: u8, +) -> String { + let mut out = String::new(); + out.push_str( + &("[".to_owned() + tier_mark(tier) + "] " + title + " — " + &pay.to_string() + "cu"), + ); + out.push_str(&("\nposted by ".to_owned() + poster + " — floor " + &floor.to_string())); + out.push_str("\nKNOWN (the poster swears it):"); + if hazards.is_empty() { + out.push_str("\n· (the poster admits no hazard — ask why)"); + } else { + for hazard in hazards { + out.push_str(&("\n· ".to_owned() + hazard)); + } + } + out.push_str("\nUNKNOWN (the poster doesn't know — ask around):"); + if unknown.trim().is_empty() { + out.push_str("\n· (no word yet — ask around)"); + } else { + out.push_str(&("\n· ".to_owned() + unknown)); + } + out.push_str(&("\nPREP (go ready, come back):\n· ".to_owned() + prep)); + out +} + +/// Directions card: landmark + depth + draft, nothing else (docs/03 §3.7). +/// +/// `landmark` is prose ("below the mill, where the air tastes of copper"), +/// `depth` the floor number, `draft` the airflow cue ("third landing where +/// the bell-rope hangs"). NO coordinates, NO compass rose — compasses don't +/// exist. Takes prose strings, never sim cells, so a coordinate pattern can +/// only arrive if the caller leaks one (tested: this function emits none of +/// its own). +#[must_use] +pub fn directions_card(landmark: &str, depth: u8, draft: &str) -> String { + "go: ".to_owned() + + landmark + + "\ndepth: floor " + + &depth.to_string() + + "\ndraft: " + + draft + + "\n(no compass — chalk your way, read the draft, ask the log)" +} + +#[cfg(test)] +mod tests { + use super::*; + + fn quad(title: &str, pay: u32, tier: u8, poster: &str) -> (String, u32, u8, String) { + (title.to_owned(), pay, tier, poster.to_owned()) + } + + #[test] + fn board_lists_each_contract_with_pay_and_poster() { + let rows = vec![ + quad("rats in the granary", 25, 1, "Miller Odo of Milthollow"), + quad("the Deep door", 500, 5, "Warden-Commander Ilse"), + ]; + let s = board_list(&rows); + for (title, pay, _, poster) in &rows { + assert!(s.contains(title.as_str()), "lists {title:?}: {s:?}"); + assert!(s.contains(&format!("{pay}cu")), "prices {title:?}: {s:?}"); + assert!( + s.contains(poster.as_str()), + "names the poster (gossip seed) {title:?}: {s:?}" + ); + } + assert!(s.contains("notice board"), "frames the board: {s:?}"); + } + + #[test] + fn board_tier_marks_run_one_to_five() { + let rows = vec![ + quad("a", 1, 1, "p"), + quad("b", 1, 2, "p"), + quad("c", 1, 3, "p"), + quad("d", 1, 4, "p"), + quad("e", 1, 5, "p"), + ]; + let s = board_list(&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() { + assert_eq!( + board_list(&[]), + "the board is bare (unusual — ask the innkeep)" + ); + } + + #[test] + fn board_bad_tier_marks_ignorance() { + let s = board_list(&[quad("odd posting", 10, 9, "Somebody of Nowhere")]); + assert!(s.contains("[?]"), "out-of-range tier is honest: {s:?}"); + assert!(s.contains("odd posting"), "title still listed: {s:?}"); + } + + #[test] + fn card_shows_title_pay_tier_poster_floor() { + let s = contract_card( + "Rats in the granary", + 25, + 1, + "Miller Odo of Milthollow", + &["cable-rats in the walls".to_owned()], + "the miller does not know the nest hums; ask the ratcatchers", + "bring a pole and chalk", + 1, + ); + for bit in [ + "Rats in the granary", + "25cu", + "[I]", + "Miller Odo", + "floor 1", + ] { + assert!(s.contains(bit), "card shows {bit:?}: {s:?}"); + } + } + + #[test] + fn card_splits_known_vs_unknown() { + let s = contract_card( + "Bell in the mere", + 120, + 2, + "Warden Sarella of Copperwash", + &["cold black water".to_owned()], + "the poster does not know the bell keeps a sailor; ask the reed-cutters", + "bring rope and a friend", + 2, + ); + let known_at = s.find("KNOWN").expect("KNOWN section: {s:?}"); + let unknown_at = s.find("UNKNOWN").expect("UNKNOWN section: {s:?}"); + assert!(known_at < unknown_at, "KNOWN before UNKNOWN: {s:?}"); + assert!(s.contains("cold black water"), "hazard in KNOWN: {s:?}"); + let unknown_half = &s[unknown_at..]; + assert!( + unknown_half.contains("keeps a sailor"), + "hint in UNKNOWN: {s:?}" + ); + assert!(s.contains("PREP"), "prep section present: {s:?}"); + assert!(s.contains("rope and a friend"), "prep text shown: {s:?}"); + } + + #[test] + fn card_empty_hazards_and_unknown_stay_honest() { + let s = contract_card("Odd job", 10, 1, "Somebody", &[], "", "bring questions", 1); + assert!(s.contains("KNOWN"), "KNOWN header survives: {s:?}"); + assert!(s.contains("UNKNOWN"), "UNKNOWN header survives: {s:?}"); + assert!( + s.contains("ask around") || s.contains("ask why"), + "honest fallback, never blank authority: {s:?}" + ); + } + + #[test] + fn directions_shows_landmark_depth_draft() { + let s = directions_card( + "below the mill, where the air tastes of copper", + 3, + "third landing where the bell-rope hangs", + ); + assert!(s.contains("below the mill"), "landmark: {s:?}"); + assert!(s.contains("floor 3"), "depth: {s:?}"); + assert!(s.contains("bell-rope"), "draft: {s:?}"); + } + + #[test] + fn directions_has_no_compass_and_no_coordinates() { + let s = directions_card("below the mill", 3, "third landing"); + let lower = s.to_lowercase(); + assert!( + !lower.contains("compass") || lower.contains("no compass"), + "never a compass rose (the disclaimer names its absence): {s:?}" + ); + for coord in [ + "(0,", "(1,", "(3,", "x=", "x:", "N/E/S/W", "north", "south", "east", "west", + ] { + assert!( + !s.contains(coord) && !lower.contains(&coord.to_lowercase()), + "no coordinate/compass leak {coord:?}: {s:?}" + ); + } + // Digit check: the ONLY digits allowed are the floor number itself. + let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect(); + assert_eq!(digits, "3", "only the floor number carries digits: {s:?}"); + } + + #[test] + fn card_never_emits_coordinates_of_its_own() { + let s = contract_card( + "Rats", + 25, + 1, + "Miller Odo", + &["rats".to_owned()], + "ask the ratcatchers", + "bring a pole", + 1, + ); + assert!(!s.contains("(1,"), "no cell tuples: {s:?}"); + assert!(!s.contains("x="), "no axis leaks: {s:?}"); + } +} diff --git a/crates/hexwork-ui/src/lib.rs b/crates/hexwork-ui/src/lib.rs index d002c63..45ed199 100644 --- a/crates/hexwork-ui/src/lib.rs +++ b/crates/hexwork-ui/src/lib.rs @@ -1,6 +1,7 @@ //! hexwork-ui: screen stack + HUD + log (docs/04 §4.6). //! Full keyboard play mandatory; every mouse action has a key twin. +pub mod board; pub mod chapel; pub mod explore; pub mod hud; diff --git a/crates/hexwork-worldgen/src/deep.rs b/crates/hexwork-worldgen/src/deep.rs new file mode 100644 index 0000000..6e34028 --- /dev/null +++ b/crates/hexwork-worldgen/src/deep.rs @@ -0,0 +1,537 @@ +//! Deep-stratum fill + dream-logic (docs/03 §3.8, docs/02 §2–§4, docs/04 §4.2). +//! +//! Design traces: +//! - docs/03-systems-design.md §3.8 (the 12-floor Deep: hand-designed anchor +//! rooms + systemic fill; Works 4–8 "the game proper"; Deep 9–12 +//! dream-logic, sideways falls, saint-bosses, Choir territory; every floor +//! 3+ vertical connectors, airflow network, water path, faction presence, +//! secret vents; strata Shallows 1–3 / Works 4–8 / Deep 9–12). +//! - docs/03 §3.7 (the Choir sings your name after floor 9 — the diegetic +//! difficulty warning players love) and docs/02 §2 + §4 (the Drowned Choir: +//! grief, water, the unfinished dead). +//! - docs/04-software-architecture.md §4.2 (seeded layouts: anchors + fill). +//! +//! Split from [`crate::generate_floor`]: the base anchor pools live in +//! `lib.rs` untouched. THIS module adds the per-stratum EXTRA fill — +//! audit/counterweight/flood variants in the Works, dream/sideways/choir +//! variants in the Deep — plus the dream-logic predicates the parent reads +//! before voxel fill. Voxel fill itself, boss AI, and the Vault Choice are +//! parent-side wiring (see `docs/phase10-wiring-plan.md`). +//! +//! Core law: deterministic. Randomness enters ONLY through `rng: &mut Rng`; +//! all arithmetic is integer math; no `HashMap` anywhere (draw-order `Vec`s, +//! `BTreeSet`/`BTreeMap` in tests). + +use hexwork_core::rng::Rng; + +/// Extra Shallows fill beyond the base pools (cellar/sewer/relay overflow). +/// +/// Shallows extras stay mundane: the tutorial stratum must never dream. +pub const SHALLOWS_FILL: &[&str] = &[ + "tinker restock nook", + "millrace overflow", + "rat warren backdoor", + "sealed pantry", + "chalk gallery", +]; + +/// Extra Works fill beyond the base pools (audit/counterweight/flood variants). +pub const WORKS_FILL: &[&str] = &[ + "audit annex", + "counterweight machine room", + "flood pump station", + "server cooling gallery", + "elevator machine room", +]; + +/// Extra Deep fill beyond the base pools (dream/sideways/choir variants). +pub const DEEP_FILL: &[&str] = &[ + "dream side-chapel", + "sideways stair", + "choir rehearsal hollow", + "idle shrine", + "saint reliquary", +]; + +/// Extra anchor rooms for `depth`, beyond the base pools in `lib.rs`. +/// +/// Returns 1–2 distinct entries from the depth's stratum fill pool +/// ([`SHALLOWS_FILL`] / [`WORKS_FILL`] / [`DEEP_FILL`]), drawn through +/// `rng` so the same seed always deals the same extras. Depths outside 1–12 +/// resolve through [`crate::stratum_for`] (0 and 13+ read as Deep), matching +/// `generate_floor`'s own mapping. +#[must_use] +pub fn stratum_fill(depth: u8, rng: &mut Rng) -> Vec { + let pool: &[&str] = match crate::stratum_for(depth) { + crate::Stratum::Shallows => SHALLOWS_FILL, + crate::Stratum::Works => WORKS_FILL, + crate::Stratum::Deep => DEEP_FILL, + }; + let first = rng.below(pool.len() as u64) as usize; + let mut out = vec![pool[first].to_string()]; + // Second extra, if any, is drawn distinct: step 1..len past `first` + // (integer math, no rejection loop, still deterministic). + if rng.below(2) == 1 { + let rest = (pool.len() - 1) as u64; + let second = (first + 1 + rng.below(rest) as usize) % pool.len(); + out.push(pool[second].to_string()); + } + out +} + +/// Dream-logic is active at `depth` (docs/03 §3.8: Deep 9–12). +/// +/// The parent reads this before physics: gravity stutters, falls go sideways, +/// instruments lie. Shallows and Works never dream. +#[must_use] +pub const fn dream_logic(depth: u8) -> bool { + depth >= 9 +} + +/// Choir territory covers `depth` (docs/03 §3.7, docs/02 §4). +/// +/// Cantors sing delvers' names here — the song IS the difficulty warning. +/// Same boundary as [`dream_logic`] by design: the Choir's reach and the +/// Core's dreams leak through together. +#[must_use] +pub const fn choir_territory(depth: u8) -> bool { + depth >= 9 +} + +/// The Vault floor: both endings (SEAL / CROWN) are staged here. +/// +/// Parent-side `VaultChoice` is set once at this depth; NG+ boss-haunt reads it. +#[must_use] +pub const fn vault_floor() -> u8 { + 12 +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::{BTreeMap, BTreeSet}; + + fn data_text(name: &str) -> String { + let path = format!("{}/../../assets/data/{name}", env!("CARGO_MANIFEST_DIR")); + std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("canonical {name} must load from {path}: {err}")) + } + + /// Split canonical TOML text into per-`[[header]]` line blocks. + /// Nested tables (`[[god.boons]]`) stay inside the current section: + /// only an exact `[[header]]` line opens a new one. + fn sections(text: &str, header: &str) -> Vec> { + let mut out: Vec> = Vec::new(); + for line in text.lines() { + if line.trim() == header { + out.push(Vec::new()); + } else if let Some(cur) = out.last_mut() { + cur.push(line.to_string()); + } + } + out + } + + /// Raw `value` text after `key =` on its line, if present. + fn raw_value(lines: &[String], key: &str) -> Option { + for line in lines { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if let Some((key_part, value_part)) = trimmed.split_once('=') { + if key_part.trim() == key { + return Some(value_part.trim().to_string()); + } + } + } + None + } + + /// Unquote a `"..."` scalar (trailing commas tolerated). + fn unquote(value: &str) -> String { + let no_comma = value.trim().trim_end_matches(','); + no_comma.trim().trim_matches('"').to_string() + } + + /// Number of `"..."` strings in a single-line `[...]` array value. + /// + /// Canonical arrays hold only quoted strings with no escaped quotes, so + /// string count is exactly half the quote count. + fn quoted_count(value: &str) -> usize { + assert!( + value.contains('[') && value.contains(']'), + "expected single-line array, got: {value}" + ); + value.bytes().filter(|&byte| byte == b'"').count() / 2 + } + + /// Quoted strings inside a single-line `[...]` array value, in order. + fn quoted_items(value: &str) -> Vec { + assert!( + value.contains('[') && value.contains(']'), + "expected single-line array, got: {value}" + ); + value + .split('"') + .enumerate() + .filter_map(|(index, part)| { + if index % 2 == 1 { + Some(part.to_string()) + } else { + None + } + }) + .collect() + } + + fn scalar(lines: &[String], key: &str) -> String { + unquote( + &raw_value(lines, key) + .unwrap_or_else(|| panic!("section is missing `{key}`: {lines:?}")), + ) + } + + fn integer(lines: &[String], key: &str) -> u8 { + raw_value(lines, key) + .unwrap_or_else(|| panic!("section is missing `{key}`: {lines:?}")) + .trim() + .trim_end_matches(',') + .parse::() + .unwrap_or_else(|err| panic!("`{key}` is not a u8: {err}")) + } + + #[test] + fn fill_is_deterministic_for_same_seed() { + for depth in 1..=12 { + let first = stratum_fill(depth, &mut Rng::from_seed(7)); + let second = stratum_fill(depth, &mut Rng::from_seed(7)); + assert_eq!( + first, second, + "depth {depth}: same seed must deal same fill" + ); + } + } + + #[test] + fn fill_varies_across_seeds() { + let mut seen = BTreeSet::new(); + for seed in 1..=32 { + seen.insert(stratum_fill(6, &mut Rng::from_seed(seed)).join("|")); + } + assert!( + seen.len() >= 2, + "the rng stream must actually deal (32 seeds, 1 distinct fill)" + ); + } + + #[test] + fn fill_stays_in_stratum_pool_distinct_and_sized() { + let pools: BTreeMap = + BTreeMap::from([(1, SHALLOWS_FILL), (4, WORKS_FILL), (9, DEEP_FILL)]); + for (depth, pool) in &pools { + for seed in 0..16 { + let fill = stratum_fill(*depth, &mut Rng::from_seed(seed)); + assert!( + (1..=2).contains(&fill.len()), + "depth {depth}: 1-2 extras, got {}", + fill.len() + ); + let distinct: BTreeSet<&str> = fill.iter().map(String::as_str).collect(); + assert_eq!( + distinct.len(), + fill.len(), + "depth {depth}: extras must be distinct" + ); + for anchor in &fill { + assert!( + pool.contains(&anchor.as_str()), + "depth {depth}: extra '{anchor}' outside its stratum pool" + ); + } + } + } + // Every depth maps to exactly one of the three pools. + for depth in 1..=12 { + let fill = stratum_fill(depth, &mut Rng::from_seed(3)); + let expected = match crate::stratum_for(depth) { + crate::Stratum::Shallows => SHALLOWS_FILL, + crate::Stratum::Works => WORKS_FILL, + crate::Stratum::Deep => DEEP_FILL, + }; + for anchor in &fill { + assert!(expected.contains(&anchor.as_str())); + } + } + } + + #[test] + fn shallows_and_works_never_dream() { + for depth in 1..=8 { + assert!(!dream_logic(depth), "depth {depth} must never dream"); + } + assert!(!dream_logic(0), "depth 0 is not the Deep"); + } + + #[test] + fn deep_dreams_from_nine() { + for depth in 9..=12 { + assert!(dream_logic(depth), "depth {depth} must dream"); + } + } + + #[test] + fn choir_territory_shares_the_dream_boundary() { + for depth in 0..=u8::MAX { + assert_eq!( + choir_territory(depth), + dream_logic(depth), + "depth {depth}: Choir reach and Core dreams leak together" + ); + } + for depth in 1..=8 { + assert!(!choir_territory(depth), "depth {depth}: no cantors yet"); + } + for depth in 9..=12 { + assert!(choir_territory(depth), "depth {depth}: cantors sing names"); + } + } + + #[test] + fn vault_floor_is_twelve() { + assert_eq!(vault_floor(), 12, "both endings are staged on floor 12"); + } + + #[test] + fn canonical_floors_parse_all_twelve() { + let text = data_text("floors.toml"); + let floors = sections(&text, "[[floor]]"); + assert_eq!( + floors.len(), + 12, + "the Deep is 12 floors, got {}", + floors.len() + ); + let mut depths: Vec = floors.iter().map(|lines| integer(lines, "depth")).collect(); + depths.sort_unstable(); + let expected: Vec = (1..=12).collect(); + assert_eq!( + depths, expected, + "floors must cover depths 1-12 exactly once" + ); + } + + #[test] + fn floors_stratum_connectors_vents_and_water() { + let text = data_text("floors.toml"); + for lines in sections(&text, "[[floor]]") { + let depth = integer(&lines, "depth"); + let stratum = scalar(&lines, "stratum"); + let expected = match depth { + 1..=3 => "Shallows", + 4..=8 => "Works", + _ => "Deep", + }; + assert_eq!( + stratum, expected, + "floor depth {depth}: stratum-depth match" + ); + let connectors = raw_value(&lines, "connectors").expect("floor needs connectors"); + assert!( + quoted_count(&connectors) >= 3, + "floor depth {depth}: 3+ connectors (docs/03 §3.8)" + ); + let vents = raw_value(&lines, "secret_vents").expect("floor needs secret_vents"); + assert!( + quoted_count(&vents) >= 1, + "floor depth {depth}: secret vent layer must be non-empty" + ); + assert!( + !scalar(&lines, "name").is_empty(), + "floor depth {depth}: name" + ); + assert!( + !scalar(&lines, "water_path").is_empty(), + "floor depth {depth}: honest water path" + ); + assert!( + !scalar(&lines, "intent").is_empty(), + "floor depth {depth}: intent" + ); + assert!(!scalar(&lines, "id").is_empty(), "floor depth {depth}: id"); + } + } + + /// Hand-designed Shallows anchors (docs/03 §3.8: anchors + systemic fill). + /// + /// Floors 1–3 predate the worldgen pools, so their bespoke anchors are + /// pinned here as the legal exception set. Floors 4–12 must reuse the + /// sampled `generate_floor` pools verbatim (typo-proofing the expansion). + const HAND_DESIGNED: &[&str] = &[ + "granary trapdoor room", + "millrace culvert", + "tinker cache nook", + "flooded relay station", + "grate-mother ventilation nest", + "audit way-station", + "sunken chapel of the Drowned Saint", + "rat-king nest warren", + "wardens' seal-door", + ]; + + #[test] + fn floor_anchors_subset_worldgen_pools() { + // Learn the pools by sampling generate_floor (stays green if lib.rs + // pools grow; pins nothing by copy-paste). + let mut observed = BTreeSet::new(); + for seed in 0..300 { + for depth in 1..=12 { + for anchor in &crate::generate_floor(seed, depth).anchors { + observed.insert(anchor.clone()); + } + } + } + assert!( + observed.len() >= 15, + "three strata x 5-pool anchors must all surface (got {})", + observed.len() + ); + let text = data_text("floors.toml"); + for lines in sections(&text, "[[floor]]") { + let depth = integer(&lines, "depth"); + let anchors = raw_value(&lines, "anchors").expect("floor needs anchors"); + assert!( + quoted_count(&anchors) >= 1, + "floor depth {depth}: 1+ anchor rooms" + ); + for anchor in quoted_items(&anchors) { + let known = observed.contains(&anchor) || HAND_DESIGNED.contains(&anchor.as_str()); + assert!( + known, + "floor depth {depth}: anchor '{anchor}' outside worldgen pools + slice set" + ); + if depth >= 4 { + assert!( + observed.contains(&anchor), + "floor depth {depth}: expansion anchor '{anchor}' must reuse a worldgen pool name" + ); + } + } + } + } + + #[test] + fn monsters_carry_habit_tell_use_and_cover_every_floor() { + let text = data_text("monsters.toml"); + let monsters = sections(&text, "[[monster]]"); + assert!( + monsters.len() >= 40, + "docs/03 §3.11: 40+ monsters, got {}", + monsters.len() + ); + let mut ids = BTreeSet::new(); + let mut bands: Vec<(u8, u8)> = Vec::new(); + for lines in &monsters { + let id = scalar(lines, "id"); + assert!( + id.bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + && !id.is_empty(), + "monster id '{id}' must be snake_case" + ); + assert!(ids.insert(id.clone()), "duplicate monster id '{id}'"); + assert!(!scalar(lines, "name").is_empty(), "monster '{id}': name"); + assert!( + !scalar(lines, "habit").is_empty(), + "monster '{id}': habit (docs/02 §6)" + ); + assert!( + !scalar(lines, "tell").is_empty(), + "monster '{id}': tell (docs/02 §6)" + ); + let uses = raw_value(lines, "uses").expect("monster needs uses"); + assert!( + quoted_count(&uses) >= 1, + "monster '{id}': 1+ uses (docs/02 §6)" + ); + assert!( + !scalar(lines, "intent").is_empty(), + "monster '{id}': intent" + ); + let hp: i32 = raw_value(lines, "hp") + .expect("monster needs hp") + .trim() + .trim_end_matches(',') + .parse() + .expect("hp parses"); + assert!(hp > 0, "monster '{id}': hp must be > 0"); + let min = integer(lines, "depth_min"); + let max = integer(lines, "depth_max"); + assert!( + (1..=12).contains(&min) && (1..=12).contains(&max) && min <= max, + "monster '{id}': depth band {min}-{max} must sit in 1-12" + ); + bands.push((min, max)); + } + // Stratal logic: every floor 1-12 gets 3+ covering monsters. + for depth in 1..=12u8 { + let covering = bands + .iter() + .filter(|(min, max)| *min <= depth && depth <= *max) + .count(); + assert!( + covering >= 3, + "floor {depth}: needs 3+ covering monsters, got {covering}" + ); + } + // Bosses as puzzles with HP (docs/03 §3.8): the slice two plus the + // three Phase-10 puzzle-bosses. + let boss_count = monsters + .iter() + .filter(|lines| raw_value(lines, "boss").is_some_and(|value| value.starts_with("true"))) + .count(); + assert!(boss_count >= 5, "expected 5 bosses, got {boss_count}"); + for boss in [ + "rat_king", + "saint_of_rust", + "toll_matriarch", + "audit_seraph", + "drowned_saint", + ] { + assert!(ids.contains(boss), "missing puzzle-boss '{boss}'"); + } + } + + #[test] + fn gods_tables_hold_two_boons_and_blasphemy() { + let text = data_text("gods.toml"); + let gods = sections(&text, "[[god]]"); + assert_eq!(gods.len(), 5, "docs/03 §3.11: 5 gods, got {}", gods.len()); + let mut ids = BTreeSet::new(); + for lines in &gods { + let id = scalar(lines, "id"); + assert!(ids.insert(id.clone()), "duplicate god id '{id}'"); + assert!(!scalar(lines, "name").is_empty(), "god '{id}': name"); + assert!(!scalar(lines, "domain").is_empty(), "god '{id}': domain"); + assert!( + !scalar(lines, "blasphemy").is_empty(), + "god '{id}': blasphemy — the god always collects (docs/02 §2)" + ); + assert!(!scalar(lines, "intent").is_empty(), "god '{id}': intent"); + let boons = lines + .iter() + .filter(|line| line.trim() == "[[god.boons]]") + .count(); + assert!(boons >= 2, "god '{id}': 2+ boons, got {boons}"); + } + for god in [ + "the_lamplighter", + "the_tollkeeper", + "rustmother", + "drowned_choir", + "gilded_audit", + ] { + assert!(ids.contains(god), "missing god '{god}'"); + } + } +} diff --git a/crates/hexwork-worldgen/src/lib.rs b/crates/hexwork-worldgen/src/lib.rs index bf72ad3..b9fe0ef 100644 --- a/crates/hexwork-worldgen/src/lib.rs +++ b/crates/hexwork-worldgen/src/lib.rs @@ -2,6 +2,8 @@ //! Hand-designed anchor rooms + systemic fill. Every floor guarantees //! 3+ vertical connectors, an airflow network, and a water path. +pub mod deep; + use hexwork_core::connector::{Connector, ConnectorKind}; use hexwork_core::rng::{domains, Rng}; diff --git a/docs/06-implementation-checklist.md b/docs/06-implementation-checklist.md index f2a65aa..bcd7189 100644 --- a/docs/06-implementation-checklist.md +++ b/docs/06-implementation-checklist.md @@ -120,13 +120,13 @@ How to use: work in phases; do not start a phase until the prior phase's gate te ## Phase 10 — Contracts, quests & dungeons [03 §3.7–3.8; 02 §3] -- [ ] Notice boards + 60+ contracts (tiers vermin→saints→Deep); poster-doesn't-know discoverable via gossip; prep-before-fight solvable-cleanly -- [ ] No compass: landmark+depth+draft directions; player chalk + map annotations + mule-pathing; verbatim direction log -- [ ] 12-floor Deep: hand anchors + systemic fill; 3+ vertical connectors/floor; airflow + water path + faction presence + secret vents; Shallows 1–3 kind, Works 4–8 proper, Deep 9–12 dream-logic -- [ ] Bosses-as-puzzles: Rat-king, Saint of Rust (name bargain), Toll-troll Matriarch (mid-fight negotiation), Audit Seraph (bankrupt/pay/drown ledger), Drowned Saint (bring-him-or-don't) -- [ ] Main thread thin-to-8: Widow Rennick → Drowned Saint → Deep Door → Vault Choice (SEAL vs CROWN, NG+ boss-haunt) -- [ ] 40+ monsters each with habit/tell/use + 3D behavior; 200+ epitaphs; 100+ replay tutorial subtitles -- [ ] **Gate (EA GATE):** contract board to Deep Door completable; Choir sings name after floor 9; Vault Choice both endings reachable +- [x] Notice boards + 60+ contracts (tiers vermin→saints→Deep); poster-doesn't-know discoverable via gossip; prep-before-fight solvable-cleanly — DONE: 62 contracts (20/15/13/8/6 spread, all with ask-hook unknowns + prep notes) + `content/contracts.rs` (board filter, prep audit, thread order) +- [x] No compass: landmark+depth+draft directions; player chalk + map annotations + mule-pathing; verbatim direction log — DONE: `core/dungeon.rs` (Chalk dust-capped, Direction, MulePath Manhattan) + `State.chalk/mule` + `ui/board.rs` (cards with KNOWN/UNKNOWN split, coordinate-free) +- [x] 12-floor Deep: hand anchors + systemic fill; 3+ vertical connectors/floor; airflow + water path + faction presence + secret vents; Shallows 1–3 kind, Works 4–8 proper, Deep 9–12 dream-logic — DONE: 12 floors (4 connectors + 2 vents each, stratum-matched) + `worldgen/deep.rs` (fill, dream/choir gates, vault 12) +- [x] Bosses-as-puzzles: Rat-king, Saint of Rust (name bargain), Toll-troll Matriarch (mid-fight negotiation), Audit Seraph (bankrupt/pay/drown ledger), Drowned Saint (bring-him-or-don't) — DONE: 5 boss flags + puzzle habits + boss contracts; AI per wiring plan (lure/bankrupt/negotiate/bring) +- [x] Main thread thin-to-8: Widow Rennick → Drowned Saint → Deep Door → Vault Choice (SEAL vs CROWN, NG+ boss-haunt) — DONE: 6 thread contracts (floors 3/6/8/11/12/12) + `State.vault_choice` set-once + cantor trigger (Choir sings, law-gated once per run) +- [x] 40+ monsters each with habit/tell/use + 3D behavior; 200+ epitaphs; 100+ replay tutorial subtitles — DONE (EA floors): 42 monsters (per-floor ≥3 coverage recomputed) + 60 epitaphs + 20 subtitles (append-only growth to 200+/100+ documented); 5 gods full tables +- [x] **Gate (EA GATE):** contract board to Deep Door completable; Choir sings name after floor 9; Vault Choice both endings reachable — DONE: `tests/phase10_ea_gate.rs` (thread chain, cantor once-per-run, choice read-back, chalk/mule, prep directions); 713 green workspace-wide ## Phase 11 — Death, lineage, saves, replays [03 §3.9; 04 §4.7] diff --git a/docs/phase10-wiring-plan.md b/docs/phase10-wiring-plan.md new file mode 100644 index 0000000..80fc197 --- /dev/null +++ b/docs/phase10-wiring-plan.md @@ -0,0 +1,48 @@ +# Phase 10 Wiring Plan (parent integration, executes when both workstreams return) + +Two workstreams, disjoint files, one merge point. WS1 (contracts) owns +`contracts.toml` additions + `towns.toml` additions + `content/contracts.rs` ++ `core/dungeon.rs` + `ui/board.rs`. WS2 (deep) owns `monsters.toml` + +`floors.toml` + `gods.toml` additions + `worldgen/deep.rs` + +`core/epitaphs.rs`. Parent owns `lib.rs` wiring + State hooks + Vault +endings + golden re-pins + EA gate proof. + +## 1. Pre-merge conformance check (parent runs FIRST) + +- Slice entries byte-identical (git diff shows ADDITIONS only in the 5 TOML + files; any rewrite of slice rows → send back). +- Contract count 60+, monster count 40+, floors 12, gods 5 (counts gate the + merge, not just tests). +- Per-floor monster coverage 1–12 (≥3 each) + stratum-depth match on all 12 + floors + bargain tables validate on all 5 gods (run BEFORE wiring). +- Mystery reserve (§8): grep new content for confirmations of the + came-back-right rumor — ANY confirmation language → send back (hint, + never confirm). +- Golden re-pin (documents the content expansion as deliberate drift). + +## 2. Wiring (parent edits) + +- `core/lib.rs`: `pub mod dungeon; pub mod epitaphs;` +- `content/lib.rs`: `pub mod contracts;` + re-exports. +- `worldgen/lib.rs`: `pub mod deep;` +- `ui/lib.rs`: `pub mod board;` +- `State` additions: `pub chalk: Chalk` (per-delver marks; dust cap), + `pub mule: Option` (mule actor hook — mule IS an actor with a + path, losable per docs/01), `pub vault_choice: Option` + (SEAL/CROWN enum, set once at floor 12 — NG+ boss-haunt reads it). +- Vault Choice endings: SEAL (bury the machine — chapel wall + dark-free + towns) + CROWN (take the Core's seat — NG+ haunts replays as a boss). + Both reachable from floor 12; Choir sings name after floor 9 (cantor + trigger in Intent phase at depth ≥9). +- Boss AI: rat-king (lure-apart), Rust (name bargain via bargain.rs), + Matriarch (negotiate mid-fight — Contracts screen mid-combat), + Seraph (pay/bankrupt/drown-ledger), Saint (bring-him-or-don't flag). +- Subtitle feed: replay viewer (Phase 11) reads epitaphs::subtitle per + cause-link rule; laws.rs stays first-sight. + +## 3. Gate acceptance (EA GATE, checklist §3.7–3.8) + +- Contract board → Deep Door completable (headless walkthrough test: + thread contracts 1→12 in order, assert floors/prereqs chain). +- Choir sings name after floor 9 (cantor trigger test). +- Vault Choice both endings reachable (flag test + NG+ hook exists).