diff --git a/.gitignore b/.gitignore index 552f221..404874f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ *.log +core/*.json +!core/content/*.json diff --git a/core/my.json b/core/my.json deleted file mode 100644 index 993a5d7..0000000 --- a/core/my.json +++ /dev/null @@ -1,535 +0,0 @@ -{ - "schema": 1, - "generatorVersion": "0.1.0", - "catalogVersion": "barrow@1", - "seed": "my-delve", - "params": { - "areas": 6, - "depth": 2, - "party": 4, - "deadliness": "standard", - "density": "standard", - "greed": "standard", - "clock": "standard", - "ending": "authored", - "theme": "barrow" - }, - "skeleton": { - "placeName": "The Tomb of the Silent Margrave", - "knot": { - "appetiteId": "first", - "accommodationId": "bound", - "claimant": "Silent Margrave", - "conscript": "a procession", - "transgression": "The Silent Margrave bound a procession by oath they could not break, so that someone would always go behind them.", - "cruelty": 2 - }, - "motif": { - "id": "precedence", - "danger": "procession", - "dangerLine": "the barrow keeps its order, and you have joined the queue", - "trigger": "announced" - }, - "prize": { - "kind": "regalia", - "label": "the crown worn first, and only once", - "function": "the proof of precedence" - }, - "bottomProblem": { - "label": "The Silent Margrave", - "wantNow": "to be first in all things", - "failureState": "is second now, and cannot bear it", - "handledBy": "giving them what they want, at a price" - }, - "appeasement": { - "move": "stand aside and let it go ahead of you", - "attribute": "Poise", - "successes": 2, - "why": "it goes first", - "gain": { - "vanity": 1 - }, - "cost": { - "bane": 1 - }, - "repeatable": true, - "faction": "The Silent Host" - }, - "factions": [ - { - "id": "host", - "name": "The Silent Host", - "kind": "undead", - "essence": "still performing the ritual they were buried for", - "handledBy": "courtesy", - "conscriptedAs": "a procession" - }, - { - "id": "wardens", - "name": "the Barrow-Wardens", - "kind": "undead", - "essence": "sealed this place and stayed to make sure", - "handledBy": "proof of right" - } - ], - "foreshadow": [ - { - "index": 1, - "role": "approach", - "facet": "institution", - "fragment": "numbered flagstones, counting down" - }, - { - "index": 2, - "role": "complication", - "facet": "ritual", - "fragment": "banners in sequence, each house a place further back" - }, - { - "index": 3, - "role": "complication", - "facet": "ritual", - "fragment": "a herald's script with the order still marked" - }, - { - "index": 4, - "role": "turn", - "facet": "demand", - "fragment": "a door only one person may pass at a time, and only first" - }, - { - "index": 5, - "role": "descent", - "facet": "wound", - "fragment": "a name moved to the front by force, repeatedly" - }, - { - "index": 6, - "role": "threshold", - "facet": "anchor", - "fragment": "the crown worn first, and only once" - }, - { - "index": 7, - "role": "ending", - "facet": "demand", - "fragment": "a corridor that will not let two walk abreast" - } - ] - }, - "areas": [ - { - "index": 1, - "role": "approach", - "name": "The Outer Cut", - "facet": "institution", - "cueFragments": [ - "numbered flagstones, counting down", - "the queue disturbed — three of the dead pushed aside" - ], - "situation": { - "occupant": "two of the Host", - "doing": "refusing, each of them, to go through a door second", - "onArrival": "they both look at you to break it", - "because": "neither may be seen to yield", - "offer": "rule between them and the loser attaches to you as a lesser" - }, - "truth": "This is how the place was ordered, and it still is", - "decision": { - "cue": "banners in strict order, and one you could move", - "cost": "reordering them opens a way and insults everyone buried here", - "resolve": { - "roll": "Wits 2", - "success": "you reorder them correctly and a way opens", - "failure": "you reorder wrongly and insult everyone buried here: +1 Bane", - "orElse": "leave them: the way stays shut, one Turn to find another" - } - }, - "encounter": null, - "hoard": null, - "temptation": null, - "trigger": "disturbed", - "baneBeat": "noticing", - "clue": { - "automatic": "numbered flagstones, counting down — and it was done deliberately", - "extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when" - }, - "failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.", - "fallback": { - "route": "The Silent Host will receive anyone who behaves as a guest", - "cost": "one Bane on the tab, and they will expect it again" - } - }, - { - "index": 2, - "role": "complication", - "name": "The Waiting Room", - "facet": "ritual", - "cueFragments": [ - "banners in sequence, each house a place further back", - "a rank-plate prised off and dropped, still shiny where it sat" - ], - "situation": { - "occupant": "a procession", - "doing": "passing through, unhurried, with you in its way", - "onArrival": "it does not stop or go round", - "because": "it has right of way and has had for three centuries", - "offer": "stand aside and be counted courteous; join it and be counted one of them" - }, - "truth": "The office is still being performed, for someone who is no longer served by it", - "decision": { - "cue": "a corridor that will not let two walk abreast", - "cost": "whoever goes first meets whatever is there first", - "resolve": { - "roll": "Poise 1", - "success": "you settle the order and whoever leads meets what is there, ready", - "failure": "you argue in the corridor and something arrives while you do", - "orElse": "send one scout: slow, one Turn, and they are alone" - } - }, - "encounter": null, - "hoard": "pocket", - "temptation": { - "id": "herald", - "cue": "a herald's script with your name pencilled in", - "benefit": "once, be announced as whatever you claim", - "useCost": { - "bane": 1 - }, - "standingDrawback": "you must live up to it in front of the Host" - }, - "trigger": "seenTwice", - "baneBeat": "noticing", - "clue": { - "automatic": "banners in sequence, each house a place further back — and it was done deliberately", - "extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when" - }, - "failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.", - "fallback": { - "route": "The Silent Host will receive anyone who behaves as a guest", - "cost": "one Bane on the tab, and they will expect it again" - } - }, - { - "index": 3, - "role": "complication", - "name": "The Attendants' Row", - "facet": "ritual", - "cueFragments": [ - "a herald's script with the order still marked", - "someone has walked the corridor the wrong way, scuffing one side" - ], - "situation": { - "occupant": "a living robber", - "doing": "prising number-plates off the flagstones", - "onArrival": "they freeze, then offer you one", - "because": "they worked out the order is what keeps the doors shut", - "offer": "take a plate and go first anywhere — the Host will hate you for it" - }, - "truth": "The office is still being performed, for someone who is no longer served by it", - "decision": { - "cue": "a door that opens only for the foremost of a party", - "cost": "someone must be named first, aloud, and the others must accept it", - "resolve": { - "roll": "Flair 2", - "success": "someone is named first, aloud, and the door opens for them", - "failure": "the naming is disputed and the door reads that as no answer", - "orElse": "let the barrow choose: it picks the loudest, and remembers" - } - }, - "encounter": { - "heat": "fight", - "kind": "undead", - "roster": { - "line": "1 Ghoul and 2 Skeletons", - "foes": [ - { - "n": 1, - "name": "Ghoul", - "atk": 4, - "def": 3, - "grit": 4, - "nerve": 5, - "note": "on a hit, a free Called Shot to freeze for a round" - }, - { - "n": 2, - "name": "Skeleton", - "atk": 3, - "def": 2, - "grit": 1, - "nerve": 5, - "note": "fearless" - } - ], - "harmedBy": "anything", - "avoid": "the Ghoul goes for court dress first — send someone plain" - } - }, - "hoard": "cache", - "temptation": { - "id": "rankplate", - "cue": "a rank-plate prised off something and pocketed", - "benefit": "go first through any door the barrow guards", - "useCost": { - "bane": 1 - }, - "standingDrawback": "going first is now expected of you" - }, - "trigger": "loud", - "baneBeat": "noticing", - "clue": { - "automatic": "a herald's script with the order still marked — and it was done deliberately", - "extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when" - }, - "failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.", - "fallback": { - "route": "The Silent Host will receive anyone who behaves as a guest", - "cost": "one Bane on the tab, and they will expect it again" - } - }, - { - "index": 4, - "role": "turn", - "name": "The Middle Vault", - "facet": "demand", - "cueFragments": [ - "a door only one person may pass at a time, and only first", - "a seat at the head of the hall dragged back, as if to sit in" - ], - "situation": { - "occupant": "a queue of the Host", - "doing": "waiting to pass a door, in strict order, not moving", - "onArrival": "the queue opens a gap at exactly your rank", - "because": "the first in line is waiting for someone who never arrived", - "offer": "take the gap and go with them; take the front and they will remember it" - }, - "truth": "Here the want is explicit — the place asks something of whoever enters", - "decision": { - "cue": "a claim to settle: which of you the barrow should address", - "cost": "settling it opens the way; refusing to settle it does not", - "resolve": { - "roll": "Poise 2", - "success": "the claim is settled and the barrow addresses one of you from now on", - "failure": "it is not settled, and the barrow keeps asking, in worse places", - "orElse": "refuse to settle: the way on does not open" - } - }, - "encounter": null, - "hoard": null, - "temptation": null, - "trigger": "announced", - "baneBeat": "noticing", - "clue": { - "automatic": "a door only one person may pass at a time, and only first — and it was done deliberately", - "extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when" - }, - "failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.", - "fallback": { - "route": "The Silent Host will receive anyone who behaves as a guest", - "cost": "one Bane on the tab, and they will expect it again" - } - }, - { - "index": 5, - "role": "descent", - "name": "The Lower Cut", - "facet": "wound", - "cueFragments": [ - "a name moved to the front by force, repeatedly", - "a numbered flagstone lifted and not replaced" - ], - "situation": { - "occupant": "one of the Host with a herald's staff", - "doing": "announcing arrivals to an empty stair", - "onArrival": "it announces you, inventing a rank", - "because": "the post requires arrivals and there have been none", - "offer": "accept the invented rank and the barrow will treat you as it" - }, - "truth": "This is the damage the want did to those who had to satisfy it", - "decision": { - "cue": "a stair where the order of descent is visibly recorded", - "cost": "whoever goes last is remembered as having gone last", - "resolve": { - "roll": "Poise 2", - "success": "you settle the order before you descend and the record reads as a party, not a rabble", - "failure": "you descend in a scramble: whoever is recorded last is treated as least by the Host from here", - "orElse": "descend however you like and accept the record as it falls" - } - }, - "encounter": null, - "hoard": null, - "temptation": null, - "trigger": "announced", - "baneBeat": "noticing", - "clue": { - "automatic": "a name moved to the front by force, repeatedly — and it was done deliberately", - "extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when" - }, - "failureCase": "If they skip this, move its cue fragment into the next area so the motif still completes.", - "fallback": { - "route": "The Silent Host will receive anyone who behaves as a guest", - "cost": "one Bane on the tab, and they will expect it again" - } - }, - { - "index": 6, - "role": "threshold", - "name": "The Last Door", - "facet": "anchor", - "cueFragments": [ - "the crown worn first, and only once", - "banners pulled down and stacked without regard for order" - ], - "situation": { - "occupant": "one of the Host", - "doing": "reading out an order of precedence to an empty hall", - "onArrival": "it pauses and asks where you fall in it", - "because": "the list must be read in full before anyone may proceed", - "offer": "name your own place in the order and it will be honoured, exactly" - }, - "truth": "The thing the whole barrow is arranged around is close", - "decision": { - "cue": "a queue of the dead, and a way to step round it", - "cost": "waiting costs two Turns; pushing in costs the Host's patience", - "resolve": { - "roll": "Poise 2", - "success": "you step round without breaking the order, and the Host allows it", - "failure": "you push in: +1 Bane and the queue closes behind you", - "orElse": "wait: two Turns, and the Host thinks better of you" - } - }, - "encounter": { - "heat": "battle", - "kind": "undead", - "roster": { - "line": "1 Wraith and 3 Skeletons", - "foes": [ - { - "n": 1, - "name": "Wraith", - "atk": 5, - "def": 3, - "grit": 10, - "nerve": 5, - "note": "INCORPOREAL — chill touch ignores armour and drains 1 max Grit" - }, - { - "n": 3, - "name": "Skeleton", - "atk": 3, - "def": 2, - "grit": 1, - "nerve": 5, - "note": "fearless" - } - ], - "harmedBy": "blessed, silvered or magical weapons ONLY", - "avoid": "it will take an appeasement over a fight every time" - } - }, - "hoard": "chest", - "temptation": { - "id": "coronet", - "cue": "a small crown, sized for someone who never grew", - "benefit": "+1 die when others must defer to you", - "useCost": { - "bane": 1 - }, - "standingDrawback": "you must be addressed correctly or lose the benefit" - }, - "trigger": "announced", - "baneBeat": null, - "clue": { - "automatic": "the crown worn first, and only once — and it was done deliberately", - "extra": "[VANITY: Observation — 1 success] buys who did it, and roughly when" - }, - "failureCase": "If they skip this, the ending opens with them holding nothing the bottom problem wants.", - "fallback": { - "route": "The Silent Host will receive anyone who behaves as a guest", - "cost": "one Bane on the tab, and they will expect it again" - } - } - ], - "ending": { - "index": 7, - "role": "ending", - "mode": "authored", - "heat": null, - "hoard": null, - "name": "The Audience", - "cueFragments": [ - "a corridor that will not let two walk abreast" - ], - "question": "\"Who goes first?\"", - "authored": true - }, - "triggerGlossary": { - "announced": "entered somewhere grandly — the big doors, a shout, a herald", - "seenTwice": "reflected, echoed or witnessed twice over in one place", - "loud": "a raised voice, a broken door, steel on stone", - "lingering": "stayed in one place longer than it takes to search it", - "disturbed": "struck or moved something that had not moved in centuries" - }, - "budget": { - "plannedFights": 2, - "heatBase": "fight", - "wantFights": 2 - }, - "pressure": { - "turn": 0, - "clockRolls": 0, - "clockFired": 0, - "triggerNumber": 1, - "escalated": false, - "tab": 5, - "target": 5 - }, - "authored": { - "title": null, - "intro": null, - "areas": { - "1": { - "readAloud": null, - "notes": null, - "nameOverride": null, - "locked": [] - }, - "2": { - "readAloud": null, - "notes": null, - "nameOverride": null, - "locked": [] - }, - "3": { - "readAloud": null, - "notes": null, - "nameOverride": null, - "locked": [] - }, - "4": { - "readAloud": null, - "notes": null, - "nameOverride": null, - "locked": [] - }, - "5": { - "readAloud": null, - "notes": null, - "nameOverride": null, - "locked": [] - }, - "6": { - "readAloud": null, - "notes": null, - "nameOverride": null, - "locked": [] - } - }, - "ending": { - "readAloud": null, - "notes": null, - "locked": [] - } - } -} \ No newline at end of file diff --git a/foundry-module/module.json b/foundry-module/module.json index 7129637..9600ae4 100644 --- a/foundry-module/module.json +++ b/foundry-module/module.json @@ -3,9 +3,34 @@ "title": "DELVE — a dungeon layer for VANITY", "description": "Generates a coherent delve and sequences VANITY's Forge to stage it, one area at a time.", "version": "0.1.0", - "compatibility": { "minimum": "13", "verified": "14.365" }, - "relationships": { "systems": [{ "id": "vanity", "type": "requires", "compatibility": { "minimum": "0.10.0" } }] }, - "esmodules": ["module/vanity-delve.mjs"], - "styles": ["styles/vanity-delve.css"], - "authors": [{ "name": "slaguru666" }] -} + "compatibility": { + "minimum": "13", + "verified": "14.365" + }, + "relationships": { + "systems": [ + { + "id": "vanity", + "type": "requires", + "compatibility": { + "minimum": "0.10.0" + } + } + ] + }, + "esmodules": [ + "module/vanity-delve.mjs" + ], + "styles": [ + "styles/vanity-delve.css" + ], + "authors": [ + { + "name": "slaguru666" + } + ], + "manifest": "https://github.com/slaguru666/vanity-delve/releases/latest/download/module.json", + "download": "https://github.com/slaguru666/vanity-delve/releases/latest/download/vanity-delve.zip", + "url": "https://github.com/slaguru666/vanity-delve", + "readme": "https://github.com/slaguru666/vanity-delve/blob/main/foundry-module/README.md" +} \ No newline at end of file diff --git a/foundry-module/module/core/authoring.mjs b/foundry-module/module/core/authoring.mjs new file mode 100644 index 0000000..9fdad03 --- /dev/null +++ b/foundry-module/module/core/authoring.mjs @@ -0,0 +1,150 @@ +/** + * DELVE as an authoring system. + * + * Four readings of generated output landed on the same verdict: the fiction and the structure are + * good, the table-readiness is not, and the honest product is a strong first draft a GM finishes. + * "The handwritten delve still tells the GM what matters faster." + * + * That reframes the density problem rather than solving it. Everything on the page is a vice at + * the table and a virtue at the desk. So a delve now has two surfaces: + * + * WORKSHEET — everything, labelled, with explicit prompts for what only a human can write. + * PLAY SHEET — only what survived, in the GM's own words, tight enough to scan. + * + * The delve JSON is the working file. It carries an `authored` layer that the generator never + * touches, so rerolling a component cannot destroy written prose. + */ + +import { Rng } from './rng.mjs'; +import { generateDelve } from './delve.mjs'; + +/** Fields a GM writes. Anything here, once written, outranks the generated text. */ +export const AUTHORED_FIELDS = ['readAloud', 'notes', 'nameOverride']; + +/** Components that can be individually rerolled. */ +export const REROLLABLE = ['situation', 'decision', 'temptation', 'feature', 'name']; + +/** A fresh working file: the generated delve plus an empty authored layer per area. */ +export function newWorkingFile(params) { + const delve = generateDelve(params); + delve.authored = { title: null, intro: null, areas: {} }; + for (const a of delve.areas) { + delve.authored.areas[a.index] = { readAloud: null, notes: null, nameOverride: null, locked: [] }; + } + delve.authored.ending = { readAloud: null, notes: null, locked: [] }; + return delve; +} + +const authoredFor = (d, i) => d.authored?.areas?.[i] ?? { locked: [] }; + +export const isLocked = (d, i, component) => (authoredFor(d, i).locked ?? []).includes(component); + +export function lock(d, i, component) { + const a = authoredFor(d, i); + if (!a.locked.includes(component)) a.locked.push(component); + return d; +} + +export function unlock(d, i, component) { + const a = authoredFor(d, i); + a.locked = (a.locked ?? []).filter(c => c !== component); + return d; +} + +export function setAuthored(d, i, field, value) { + if (!AUTHORED_FIELDS.includes(field)) throw new Error(`not an authored field: ${field}`); + if (i === 'ending') { d.authored.ending[field] = value; return d; } + authoredFor(d, i)[field] = value; + return d; +} + +/** + * Reroll one component of one area, keeping everything else — including anything written. + * + * Regeneration draws from a salted seed so a reroll gives something genuinely different rather + * than the same draw again, and the salt is recorded so the file stays reproducible. + */ +export function reroll(d, i, component, pack) { + if (!REROLLABLE.includes(component)) throw new Error(`not rerollable: ${component}`); + if (isLocked(d, i, component)) return d; + + d.rerolls = d.rerolls ?? {}; + const key = `${i}:${component}`; + d.rerolls[key] = (d.rerolls[key] ?? 0) + 1; + + const salt = `${d.seed}::reroll::${key}::${d.rerolls[key]}`; + const rng = new Rng(salt); + const motif = pack.motifs?.[d.skeleton.motif.id] ?? {}; + const area = d.areas.find(a => a.index === i); + if (!area) throw new Error(`no area ${i}`); + + /** + * Draw fresh, avoiding what the rest of the delve uses. When every option is already in play — + * six areas against a six-item pool — prefer duplicating another area over handing back what is + * already here. A reroll that changes nothing looks broken. + */ + const choose = (all, taken, keyOf, current) => { + const fresh = all.filter(x => !taken.has(keyOf(x))); + if (fresh.length) return rng.pick(fresh); + const notCurrent = all.filter(x => keyOf(x) !== current); + return notCurrent.length ? rng.pick(notCurrent) : null; + }; + + // Draw fresh, avoiding what the rest of the delve is already using. + const used = (get, current) => { + const taken = new Set(d.areas.filter(x => x.index !== i).map(get).filter(Boolean)); + if (current) taken.add(current); // never hand back what is already there + return taken; + }; + switch (component) { + case 'situation': { + const taken = used(a => a.situation?.occupant + a.situation?.doing, area.situation ? area.situation.occupant + area.situation.doing : null); + const pick = choose(motif.situations ?? [], taken, x => x.occupant + x.doing, + area.situation ? area.situation.occupant + area.situation.doing : null); + if (pick) area.situation = { occupant: pick.occupant, doing: pick.doing, onArrival: pick.onArrival, because: pick.because, offer: pick.offer }; + break; + } + case 'decision': { + const taken = used(a => a.decision?.cue, area.decision?.cue); + const pick = choose(motif.decisions ?? [], taken, x => x.cue, area.decision?.cue); + if (pick) area.decision = pick; + break; + } + case 'temptation': { + if (!area.hoard) break; + const taken = used(a => a.temptation?.id, area.temptation?.id); + const pick = choose(motif.temptations ?? [], taken, x => x.id, area.temptation?.id); + if (pick) area.temptation = pick; + break; + } + case 'feature': { + const taken = used(a => a.cueFragments?.[1], area.cueFragments?.[1]); + const pick = choose(motif.features ?? [], taken, x => x, area.cueFragments?.[1]); + if (pick) area.cueFragments = [area.cueFragments[0], pick].filter(Boolean); + break; + } + case 'name': { + const names = pack.areaNames?.[area.role] ?? []; + const taken = used(a => a.name, area.name); + const pick = choose(names, taken, x => x, area.name); + if (pick) area.name = pick; + break; + } + } + return d; +} + +/** What still needs a human. This is the worksheet's to-do list and the play sheet's gate. */ +export function outstanding(d) { + const todo = []; + if (!d.authored?.intro) todo.push({ where: 'intro', what: 'an opening paragraph for the delve' }); + for (const a of d.areas) { + const au = authoredFor(d, a.index); + if (!au.readAloud) todo.push({ where: `area ${a.index}`, what: 'read-aloud text' }); + } + if (!d.authored?.ending?.readAloud) todo.push({ where: 'ending', what: 'read-aloud text' }); + if (d.ending.authored && !d.authored?.ending?.notes) todo.push({ where: 'ending', what: 'the authored climax — DELVE deliberately leaves this to you' }); + return todo; +} + +export const readyToPlay = d => outstanding(d).length === 0; diff --git a/foundry-module/module/core/beat.mjs b/foundry-module/module/core/beat.mjs new file mode 100644 index 0000000..970d56f --- /dev/null +++ b/foundry-module/module/core/beat.mjs @@ -0,0 +1,80 @@ +/** + * An area is a beat. Draft 5 §6 — two faces, not four. + * + * Cue (what players perceive) and Truth (what is going on) are required. Consequence is inline + * bracketed mechanics, the way the house style already does it. Reveal appears only where a + * secret genuinely differs from the Cue. + * + * DELVE does not write prose. The playtest was explicit that the read-aloud paragraphs are most + * of what makes a delve playable and they are hand-written. So the Cue is emitted as *fragments* + * for the GM to assemble aloud, labelled as such. Faking prose here would be the one dishonest + * thing this tool could do. + */ + +const FACET_TRUTH = { + institution: 'this is how the place was ordered, and it still is', + ritual: 'the office is still being performed, for someone who is no longer served by it', + demand: 'here the want is explicit — the place asks something of whoever enters', + wound: 'this is the damage the want did to those who had to satisfy it', + anchor: 'the thing the whole barrow is arranged around is close', + demandEnd: 'the question itself, asked directly', +}; + +export function buildArea({ skeleton, planned, pack, pressure, rng, baneBeat = null, feature, decision, temptation = null, situation = null, name = null }) { + const r = rng.derive('area', String(planned.index)); + const fs = skeleton.foreshadow.find(f => f.index === planned.index) ?? {}; + + + // feature/decision are dealt by the caller without replacement across the delve — drawing them + // per-area from pools of 8 and 6 made repeats near-certain inside a single delve. + // Every area emits at least one attention trigger — draft 5 §9 weights triggers over the timer. + const trigger = fs.facet === 'demand' ? skeleton.motif.trigger + : r.derive('trigger').pick(['announced', 'loud', 'lingering', 'seenTwice', 'disturbed']); + + return { + index: planned.index, + role: planned.role, + name, + facet: fs.facet ?? null, + + // Cue: fragments, not prose. + cueFragments: [fs.fragment, feature].filter(Boolean), + + // The situation is the room as the players find it: someone doing something, and what + // changes when they walk in. The decision is one of the things you can do about it. + situation: situation ? { + occupant: situation.occupant, doing: situation.doing, onArrival: situation.onArrival, + because: situation.because, offer: situation.offer, + } : null, + + truth: (t => t ? t[0].toUpperCase() + t.slice(1) : '')(FACET_TRUTH[fs.facet] ?? ''), + + decision, + encounter: planned.heat + ? { heat: planned.heat, kind: skeleton.factions[0]?.kind ?? 'undead', roster: pack.rosters?.[planned.heat] ?? null } + : null, + hoard: planned.hoard ?? null, + temptation, + trigger, + baneBeat, + + // Clues are automatic on entry; the roll buys extra. Playtest 1 lost both its clues to + // single unopposed rolls and the motif never reached the table. + clue: { + automatic: fs.fragment ? `${fs.fragment} — and it was done deliberately` : null, + extra: '[VANITY: Observation — 1 success] buys who did it, and roughly when', + }, + + failureCase: planned.role === 'threshold' + ? 'If they skip this, the ending opens with them holding nothing the bottom problem wants.' + : 'If they skip this, move its cue fragment into the next area so the motif still completes.', + }; +} + +/** Every area must offer a way on that is not gated. Playtest 1 died on a gate with no fallback. */ +export function fallbackRoute(area, skeleton) { + return { + route: `${skeleton.factions[0]?.name} will receive anyone who ${skeleton.factions[0]?.handledBy === 'courtesy' ? 'behaves as a guest' : 'offers a trade'}`, + cost: 'one Bane on the tab, and they will expect it again', + }; +} diff --git a/foundry-module/module/core/content/barrow.json b/foundry-module/module/core/content/barrow.json new file mode 100644 index 0000000..b1c72d3 --- /dev/null +++ b/foundry-module/module/core/content/barrow.json @@ -0,0 +1,2150 @@ +{ + "id": "barrow", + "label": "Barrow", + "forgeStageType": "barrow", + "schema": 1, + "_model": "Everything derives from one kernel: an APPETITE and the ACCOMMODATION made for it. The motif, danger, prize kind and faction all follow from the appetite. Cue fragments hang off the MOTIF and are tagged by FACET (institution/ritual/demand/wound/anchor) and level, so two delves sharing a motif draw different imagery. The foreshadow chain is a fixed facet progression mapped onto the arc — which is why area 2's bows and area 4's scratched eyes both point at the same wound.", + "appetites": [ + { + "id": "seen", + "want": "to be looked at", + "motif": "looking", + "prize": "likeness", + "conscript": "an audience", + "lack": "unlooked-at", + "purpose": "someone would always be looking", + "appeasement": { + "move": "look at it and say plainly what you see", + "attribute": "Flair", + "successes": 2, + "why": "it is being looked at, which is all it ever wanted", + "gain": { + "vanity": 1 + }, + "cost": { + "bane": 1 + }, + "repeatable": true + } + }, + { + "id": "remembered", + "want": "never to be forgotten", + "motif": "naming", + "prize": "record", + "conscript": "witnesses", + "lack": "forgotten", + "purpose": "someone would always remember", + "appeasement": { + "move": "say its name aloud, correctly, unprompted", + "attribute": "Wits", + "successes": 2, + "why": "someone remembered", + "gain": { + "vanity": 1 + }, + "cost": { + "bane": 1 + }, + "repeatable": true + } + }, + { + "id": "obeyed", + "want": "to be obeyed without asking", + "motif": "voices", + "prize": "seal", + "conscript": "a household", + "lack": "disobeyed", + "purpose": "someone would always obey", + "appeasement": { + "move": "do one small thing it asks, without arguing", + "attribute": "Poise", + "successes": 2, + "why": "it was obeyed", + "gain": { + "vanity": 1 + }, + "cost": { + "bane": 1 + }, + "repeatable": true + } + }, + { + "id": "young", + "want": "never to age", + "motif": "keeping", + "prize": "vessel", + "conscript": "the young", + "lack": "old", + "purpose": "nothing would ever age", + "appeasement": { + "move": "tell it that it has not changed", + "attribute": "Flair", + "successes": 2, + "why": "it hears that nothing has aged", + "gain": { + "vanity": 1 + }, + "cost": { + "bane": 1 + }, + "repeatable": true + } + }, + { + "id": "envied", + "want": "to be envied", + "motif": "display", + "prize": "ornament", + "conscript": "rivals", + "lack": "unenvied", + "purpose": "someone would always envy them", + "appeasement": { + "move": "admire something of its openly, and mean it", + "attribute": "Flair", + "successes": 2, + "why": "it is envied again", + "gain": { + "vanity": 1 + }, + "cost": { + "bane": 1 + }, + "repeatable": true + } + }, + { + "id": "attended", + "want": "never to be alone", + "motif": "company", + "prize": "token", + "conscript": "servants", + "lack": "alone", + "purpose": "someone would always be there", + "appeasement": { + "move": "sit down and stay a while", + "attribute": "Poise", + "successes": 2, + "why": "it is not alone", + "gain": { + "vanity": 1 + }, + "cost": { + "bane": 1 + }, + "repeatable": true + } + }, + { + "id": "forgiven", + "want": "to be told it was not a sin", + "motif": "confession", + "prize": "testament", + "conscript": "confessors", + "lack": "unforgiven", + "purpose": "someone would always absolve them", + "appeasement": { + "move": "tell it that what it did was not a sin", + "attribute": "Poise", + "successes": 2, + "why": "it is absolved, for a moment", + "gain": { + "vanity": 1 + }, + "cost": { + "bane": 1 + }, + "repeatable": true + } + }, + { + "id": "first", + "want": "to be first in all things", + "motif": "precedence", + "prize": "regalia", + "conscript": "a procession", + "lack": "second", + "purpose": "someone would always go behind them", + "appeasement": { + "move": "stand aside and let it go ahead of you", + "attribute": "Poise", + "successes": 2, + "why": "it goes first", + "gain": { + "vanity": 1 + }, + "cost": { + "bane": 1 + }, + "repeatable": true + } + } + ], + "accommodations": [ + { + "id": "interred", + "text": "had {conscript} interred alive alongside them", + "cruelty": 3, + "fits": [ + "seen", + "remembered", + "attended", + "forgiven", + "obeyed", + "first" + ] + }, + { + "id": "bound", + "text": "bound {conscript} by oath they could not break", + "cruelty": 2, + "fits": [ + "obeyed", + "attended", + "forgiven", + "first", + "remembered" + ] + }, + { + "id": "replaced", + "text": "replaced {conscript} with something more obedient", + "cruelty": 3, + "fits": [ + "seen", + "obeyed", + "envied", + "young", + "attended" + ] + }, + { + "id": "unmade", + "text": "had the faces struck from {conscript}", + "cruelty": 3, + "fits": [ + "seen", + "remembered", + "envied", + "first" + ] + }, + { + "id": "purchased", + "text": "bought {conscript} from those with nothing left", + "cruelty": 2, + "fits": [ + "envied", + "attended", + "young", + "obeyed" + ] + }, + { + "id": "kept", + "text": "would not let {conscript} leave, and then could not", + "cruelty": 1, + "fits": [ + "young", + "attended", + "seen", + "remembered" + ] + } + ], + "motifs": { + "looking": { + "danger": "attention", + "dangerLine": "the barrow notices those who are seen", + "trigger": "seenTwice", + "facets": { + "institution": [ + "mourners carved beside every step, their faces smooth blank ovals", + "attendants posed facing an empty centre, heads all turned the same way", + "seats arranged in a half-circle around one chair" + ], + "ritual": [ + "courtiers bent in a bow they have been holding for three hundred years", + "a receiving line of the dead, still waiting to be presented", + "gloves laid out for hands that would be kissed" + ], + "demand": [ + "hand-mirrors set into the walls, hundreds, every one angled inward", + "a chamber where every surface has been polished to show you back", + "a chair placed so that whoever sits in it must be looked at" + ], + "wound": [ + "portraits of the same woman, the eyes scratched out by fingernails", + "likenesses turned to face the wall, every one of them", + "a face chiselled off a statue, and the chisel left behind" + ], + "anchor": [ + "a covered frame the height of a person, facing away from the door", + "a wardrobe of three hundred dresses in a room with no dust in it", + "one thing under cloth, and the cloth is clean" + ] + }, + "decisions": [ + { + "cue": "a doorway you can only reach by crossing open floor, in full view", + "cost": "cross it and you are seen — roll the clock; go the long way and lose a Turn", + "resolve": { + "roll": "Wits 1", + "success": "you time the crossing between whatever is watching — no clock roll", + "failure": "you are seen: roll the clock now", + "orElse": "go the long way: one Turn, no risk" + } + }, + { + "cue": "a mirror blocking the passage, angled to catch whoever passes", + "cost": "cover it and something notices the dark; pass it and something notices you", + "resolve": { + "roll": "Flair 2", + "success": "you cover it without meeting your own eye — pass freely", + "failure": "you meet your own eye: +1 Bane and roll the clock", + "orElse": "smash it — loud, roll the clock, but done" + } + }, + { + "cue": "a portrait that must be moved to open the way behind it", + "cost": "turn it to the wall and the Host takes offence; carry it and you are announced everywhere", + "resolve": { + "roll": "Poise 2", + "success": "you turn it aside with enough ceremony that the Host takes no offence", + "failure": "the Host is offended: they will not receive you until someone appeases", + "orElse": "leave it and squeeze past: one Turn, and you are watched" + } + }, + { + "cue": "a gallery lit end to end, and an unlit servants' crawl beside it", + "cost": "the gallery is quick and bright; the crawl costs a Turn and a Brawn roll", + "resolve": { + "roll": "Brawn 2", + "success": "you make the crawl in good order", + "failure": "you arrive filthy and stuck: lose a Turn, +1 Bane", + "orElse": "take the gallery: fast, bright, and you are seen" + } + }, + { + "cue": "a shuttered window onto the next room, and a choice about opening it", + "cost": "looking through costs nothing; being seen looking costs a Bane", + "resolve": { + "roll": "Wits 1", + "success": "you see through without being framed in the opening", + "failure": "you are framed: whatever is beyond has your description", + "orElse": "do not look; you go in blind but unannounced" + } + }, + { + "cue": "a room you must cross either quickly or quietly", + "cost": "quick is one Turn and a clock roll; quiet is two Turns and none", + "resolve": { + "roll": "Wits 2", + "success": "you read which way the room is watched and pick the right speed — cross unremarked", + "failure": "you pick wrong: roll the clock, and whatever is watching now knows your pace", + "orElse": "cross without judging it: quick costs a clock roll, quiet costs two Turns" + } + } + ], + "temptations": [ + { + "id": "mask", + "cue": "a courtier's mask, gold leaf over lime-wood, the mouth fixed in a small smile", + "benefit": "the Host receives you as court, automatically", + "useCost": { + "bane": 1 + }, + "standingDrawback": "while worn you cannot be recognised by name — allies aiding you take −1 die" + }, + { + "id": "glass", + "cue": "a hand-mirror that shows the room a moment behind", + "benefit": "once per scene, see what was just here", + "useCost": { + "bane": 1 + }, + "standingDrawback": "your reflection lags, and other people notice" + }, + { + "id": "likeness", + "cue": "a small portrait of someone who is not you, painted beautifully", + "benefit": "show it and be taken for them, once", + "acceptanceCost": { + "vice": "Pride" + }, + "useCost": { + "bane": 2 + }, + "standingDrawback": "they will eventually want it back" + }, + { + "id": "veil", + "cue": "a mourning veil, fine enough to see through", + "benefit": "pass unremarked through the Host once", + "useCost": { + "bane": 1 + }, + "standingDrawback": "you see everyone as the barrow does — posed, waiting" + }, + { + "id": "eyes", + "cue": "a pair of painted eyes cut from a portrait", + "benefit": "once, know exactly who is watching", + "useCost": { + "bane": 1 + }, + "standingDrawback": "they are watching you back" + } + ], + "features": [ + "a mirror smashed deliberately, and recently — the pieces swept into a corner", + "scorch-marks where someone burned a portrait and did not stay to watch", + "a sheet thrown over something, by living hands, in a hurry", + "eyeholes bored through a wall at three different heights", + "a lamp set facing the wall so it lights nothing but stone", + "dust disturbed in a wide circle, as though someone turned slowly on the spot" + ], + "situations": [ + { + "occupant": "a half-dozen of the Host", + "doing": "holding a bow, waiting to be acknowledged", + "onArrival": "they hold it deeper, and wait longer", + "because": "nobody has returned the courtesy in three centuries", + "offer": "acknowledge them properly and be received as court" + }, + { + "occupant": "one of the Host at a mirror", + "doing": "describing its own reflection aloud, incorrectly", + "onArrival": "it asks you to confirm the description", + "because": "it was taught to say what the Lord wished to hear", + "offer": "agree, and it will show you what it is not allowed to look at" + }, + { + "occupant": "something unseen", + "doing": "turning to follow you, one mirror at a time", + "onArrival": "the movement stops when you look directly at it", + "because": "it may only move unobserved", + "offer": "stare it down — hold your gaze and it cannot advance" + }, + { + "occupant": "a living robber", + "doing": "covering mirrors with a torn shirt, working fast", + "onArrival": "they hiss at you to keep your face down", + "because": "they have learned what being seen twice costs", + "offer": "help, and they will tell you which rooms are watched" + }, + { + "occupant": "two of the Host", + "doing": "presenting an absent noble to an empty chair", + "onArrival": "they present you instead", + "because": "the ceremony cannot be abandoned, only redirected", + "offer": "accept the presentation and the barrow treats you as its guest of honour" + }, + { + "occupant": "a living robber", + "doing": "sketching the room, badly, by feel", + "onArrival": "they ask you not to look at the drawing", + "because": "they have learned that what gets looked at gets noticed", + "offer": "leave the sketch alone and they will mark your map with the watched rooms" + } + ] + }, + "naming": { + "danger": "recognition", + "dangerLine": "to say a name here is to be answered", + "trigger": "loud", + "facets": { + "institution": [ + "names cut into the lintel, each one struck through", + "the dead laid out in the order their names were entered", + "a register stone, filled edge to edge" + ], + "ritual": [ + "a roll of the dead being read aloud by nobody", + "grave-goods labelled in a hand that keeps correcting itself", + "an oath cut into the floor, to be spoken on entering" + ], + "demand": [ + "a wall of tongues in wax, each with a name pressed into it", + "a chamber that repeats the last name spoken in it", + "a blank plate at the head of the hall, waiting" + ], + "wound": [ + "a name gouged out so deeply it went through the stone", + "a ledger with one line scraped away and rewritten forty times", + "monuments to someone whose name nobody kept" + ], + "anchor": [ + "a book of names with one line left blank", + "a seal cut with a name worn smooth by rubbing", + "a tally-stick longer than a person" + ] + }, + "decisions": [ + { + "cue": "a door with a name cut into it, and a space beneath for a reply", + "cost": "answer with your own name and it opens — and it is written down", + "resolve": { + "roll": "Wits 2", + "success": "you give a name that is true enough to open it and thin enough to cost nothing", + "failure": "the door takes your real name: it is written down, and things here now use it", + "orElse": "refuse to answer: the door stays shut, find another way at one Turn" + } + }, + { + "cue": "a roll of the dead, and one line you could strike out", + "cost": "striking a name frees something; it also puts you where the name was", + "resolve": { + "roll": "Poise 2", + "success": "you strike the name cleanly and something long-bound is released, grateful", + "failure": "the strike is crooked: you are entered where the name was, +1 Bane", + "orElse": "leave the roll alone; nothing is freed and nothing is owed" + } + }, + { + "cue": "someone's name called softly from two directions at once", + "cost": "answer either and the other stops calling", + "resolve": { + "roll": "Wits 1", + "success": "you work out which call is the real one", + "failure": "you answer the wrong one and the other keeps calling, closer", + "orElse": "answer neither: both keep calling for the rest of the delve" + } + }, + { + "cue": "a lintel you must be announced under, or crawl beneath", + "cost": "announced costs a Bane; crawling costs your dignity and a Turn", + "resolve": { + "roll": "Flair 2", + "success": "you are announced as something grander than you are, and it holds", + "failure": "the announcement is thin and the Host hears it: +1 Bane", + "orElse": "crawl beneath: undignified, one Turn, entirely safe" + } + }, + { + "cue": "a list of the dead with one name you recognise", + "cost": "saying it aloud helps; it also tells the barrow you are connected", + "resolve": { + "roll": "Wits 2", + "success": "the hand is recent — you learn who else is down here and roughly when", + "failure": "you learn nothing but leave your own mark on the page", + "orElse": "do not touch it; the name stays unread" + } + }, + { + "cue": "two passages, and someone is being called down one of them", + "cost": "follow the voice and be expected; take the other and arrive unannounced", + "resolve": { + "roll": "Poise 1", + "success": "you follow the voice and arrive expected, which opens doors", + "failure": "you arrive expected by the wrong thing", + "orElse": "take the other passage: unannounced, one extra Turn" + } + } + ], + "temptations": [ + { + "id": "ledger", + "cue": "a book of names, one line left blank", + "benefit": "write a name and the Host obeys it once", + "acceptanceCost": { + "vice": "Pride" + }, + "useCost": { + "bane": 2 + }, + "standingDrawback": "your own name is now in it" + }, + { + "id": "nameplate", + "cue": "a plate cut with a name worn smooth by rubbing", + "benefit": "doors that check a name open for you", + "useCost": { + "bane": 1 + }, + "standingDrawback": "you answer to that name whether you mean to or not" + }, + { + "id": "tally", + "cue": "a tally-stick longer than a person, one notch unfilled", + "benefit": "call in a debt the barrow owes — once", + "useCost": { + "bane": 1 + }, + "standingDrawback": "the stick keeps counting what you owe too" + }, + { + "id": "seal", + "cue": "a wax seal bearing a name struck through", + "benefit": "once, be forgotten by something hunting you", + "useCost": { + "bane": 1 + }, + "standingDrawback": "your allies find your name hard to recall" + }, + { + "id": "lintel", + "cue": "a chip of lintel-stone with three names on it", + "benefit": "once, summon one of the named dead to answer", + "useCost": { + "bane": 2 + }, + "standingDrawback": "they come whenever your name is said aloud" + } + ], + "features": [ + "a name freshly scratched over an old one, in a modern hand", + "a page torn out and the stub still in the binding", + "someone has been writing their own name on things, repeatedly", + "a gravestone laid face-down, which takes two people", + "chalk marks numbering the doorways, not old", + "a name spoken so recently the air still feels used" + ], + "situations": [ + { + "occupant": "one of the Host with a ledger", + "doing": "reading the roll of the dead aloud, and has reached the Ns", + "onArrival": "it asks for your names, politely, pen ready", + "because": "the roll must be complete or the reading starts again", + "offer": "give a name — any name — and it will read you the entry beside it" + }, + { + "occupant": "something in the next room", + "doing": "calling a name, patiently", + "onArrival": "it starts calling yours instead", + "because": "it learned your names when you were announced", + "offer": "answer once and it will lead you somewhere; answer twice and it will not stop" + }, + { + "occupant": "a living robber", + "doing": "chiselling their own name over an older one", + "onArrival": "they stop, caught, and try to look casual", + "because": "they think a name on the wall means the barrow lets you leave", + "offer": "tell them the truth for a share of what they have found" + }, + { + "occupant": "three of the Host", + "doing": "searching for someone by name, room to room", + "onArrival": "they ask whether you are that person", + "because": "the summons was never fulfilled", + "offer": "say yes and be taken directly to whoever wanted them" + }, + { + "occupant": "one of the Host, silent", + "doing": "holding a nameplate out, waiting for someone to claim it", + "onArrival": "it offers the plate to each of you in turn", + "because": "the post has been vacant and the household needs it filled", + "offer": "take the plate and the household obeys you — and expects you to stay" + }, + { + "occupant": "the wall itself", + "doing": "taking down a name, letter by letter, as you watch", + "onArrival": "the letters stop", + "because": "the barrow is recording who came in", + "offer": "finish the name yourself with something false and it is accepted" + } + ] + }, + "voices": { + "danger": "obedience", + "dangerLine": "the dead here still take instruction, and give it", + "trigger": "loud", + "facets": { + "institution": [ + "a bell-pull beside every alcove, all of them still taut", + "servants' bones arranged as though mid-errand", + "a board of numbered bells, one still swinging" + ], + "ritual": [ + "orders scratched into the floor, the last one unfinished", + "a speaking-tube running the barrow's length, warm to the touch", + "a queue of the dead outside a door that never opened" + ], + "demand": [ + "a hall where your own voice arrives before you speak", + "a seat that expects to be addressed", + "a room that will not carry sound away from its centre" + ], + "wound": [ + "jaws wired shut, every one of them", + "an instruction repeated until the stone gave out", + "a tongue-shaped hollow worn into a step" + ], + "anchor": [ + "a signet still warm, sized for a thumb", + "a staff of office propped where it was set down", + "a key that fits nothing here" + ] + }, + "decisions": [ + { + "cue": "an order carved into the floor, still legible", + "cost": "obey it and the Host counts you as household; step over it and they do not", + "resolve": { + "roll": "Poise 2", + "success": "you obey exactly enough to be counted household — Skeletons stand down for you", + "failure": "you obey wrongly, which is worse than not obeying: +1 Bane", + "orElse": "step over it: no cost now, and the Host does not count you as theirs" + } + }, + { + "cue": "a speaking-tube that is warm, and asking a question down it", + "cost": "an answer comes back — and now something knows where you are", + "resolve": { + "roll": "Wits 2", + "success": "the tube answers truly, and you learn the way on", + "failure": "something answers and now knows where you are: roll the clock", + "orElse": "do not speak into it; you keep your position but learn nothing" + } + }, + { + "cue": "a bell-pull that would summon someone", + "cost": "ring it and help comes, and it is not the kind you wanted", + "resolve": { + "roll": "Poise 2", + "success": "what comes is answerable to you for one scene", + "failure": "what comes is not the kind of help you wanted", + "orElse": "do not ring: the way on is longer by one Turn" + } + }, + { + "cue": "two doors, and behind one a voice giving instructions to nobody", + "cost": "the quiet door is longer; the voice would tell you exactly where to go", + "resolve": { + "roll": "Poise 2", + "success": "you answer the voice as a subordinate would — it tells you the route AND answers one question about what lies on it, without taking you into its service", + "failure": "you engage and it takes that as enlistment: it will give you orders for the rest of the delve, and the first is now", + "orElse": "take the quiet door and never speak to it: one extra Turn, no obligation" + } + }, + { + "cue": "an instruction you can obey, ignore, or countermand", + "cost": "countermanding it works once and marks you as someone who gives orders", + "resolve": { + "roll": "Poise 2", + "success": "you countermand it and the dead obey you instead — this marks you as someone who gives orders: from now on the Host brings you their disputes, and once per delve you may issue one order they must follow", + "failure": "the order stands and you have been seen to fail at giving one: +1 Bane, and the Host addresses someone else from now on", + "orElse": "obey it, or ignore it and go round: ignoring costs one Turn" + } + }, + { + "cue": "a door that opens to a spoken password nobody living knows", + "cost": "guessing costs a Bane per attempt; the long way costs two Turns", + "resolve": { + "roll": "Wits 2", + "success": "the word is already in your notes. It is in something you passed earlier — *{priorCue}*. Say it and the door opens; if nobody wrote it down, the roll tells them now", + "failure": "you have the shape of it but not the word: one more attempt, or take the long way", + "orElse": "the long way: two Turns, no guessing, no Banes" + } + } + ], + "temptations": [ + { + "id": "signet", + "cue": "a signet, still warm, sized for a thumb", + "benefit": "doors that answer to rank open for you", + "useCost": { + "bane": 1 + }, + "standingDrawback": "the barrow's servants bring you their problems" + }, + { + "id": "tube", + "cue": "a short length of speaking-tube, cut and carried", + "benefit": "ask one question and get one true answer", + "useCost": { + "bane": 1 + }, + "standingDrawback": "whatever answers can now hear you" + }, + { + "id": "staff", + "cue": "a staff of office, propped where it was set down", + "benefit": "give one order the dead must follow", + "acceptanceCost": { + "vice": "Pride" + }, + "useCost": { + "bane": 2 + }, + "standingDrawback": "you may not refuse an order given to you" + }, + { + "id": "bell", + "cue": "a small bell from a servant's board", + "benefit": "ring it and one order is obeyed, by anything", + "useCost": { + "bane": 2 + }, + "standingDrawback": "it rings faintly when someone gives you an order" + }, + { + "id": "wax", + "cue": "a plug of wax, moulded to a mouth", + "benefit": "once, refuse to hear a compulsion", + "useCost": { + "bane": 1 + }, + "standingDrawback": "you cannot hear warnings either" + } + ], + "features": [ + "a bell-rope cut through, cleanly, from this side", + "wax jammed into a speaking-tube, still soft", + "an order half-scratched out and rewritten by a different hand", + "a door braced shut from within by furniture", + "boot-prints that stop mid-corridor and do not resume", + "someone has been talking here, and stopped when you arrived" + ], + "situations": [ + { + "occupant": "three of the Host in servants' dress", + "doing": "carrying out an errand that has no destination left", + "onArrival": "they stop, and wait to be told what to do", + "because": "the order was never rescinded and nobody outranks it", + "offer": "tell them where to go, and they go" + }, + { + "occupant": "a single figure at a speaking-tube", + "doing": "reporting, at length, to a listener who died first", + "onArrival": "it pauses mid-sentence and asks whether you are the reply", + "because": "it has never once been answered", + "offer": "answer as the listener would, and learn what it has been reporting" + }, + { + "occupant": "the bells", + "doing": "ringing a summons room by room, coming closer", + "onArrival": "the bell in this room begins", + "because": "something has called for service and the house is obliging", + "offer": "answer the bell yourself before whatever is meant to arrives" + }, + { + "occupant": "two of the Host", + "doing": "arguing, soundlessly, over an instruction each was given", + "onArrival": "both turn to you as the obvious arbiter", + "because": "the orders contradict and neither may disobey", + "offer": "rule between them — the loser stands down and the winner is yours for a scene" + }, + { + "occupant": "a living grave-robber", + "doing": "repeating a phrase over and over, trying to get the intonation right", + "onArrival": "they go quiet and put their back to the wall", + "because": "they heard the password and cannot say it correctly", + "offer": "trade something for the phrase, badly remembered" + }, + { + "occupant": "one of the Host, kneeling", + "doing": "waiting for an instruction that was promised and never came", + "onArrival": "it addresses the first of you to speak as its superior", + "because": "it was told to await further orders and did", + "offer": "give it the instruction and it will carry it out to the letter, wherever that leads" + } + ] + }, + "keeping": { + "danger": "preservation", + "dangerLine": "nothing here is allowed to change, including you", + "trigger": "lingering", + "facets": { + "institution": [ + "flowers on the steps that have not wilted or dried", + "a bed made fresh this morning, three centuries running", + "lamps trimmed and filled, all of them" + ], + "ritual": [ + "a meal laid out, still warm, for people long past eating", + "clothes brushed and hung in the order they would be worn", + "a clock wound by somebody, recently" + ], + "demand": [ + "jars along the wall, each holding a single year", + "a mirror that shows the room a little younger than it is", + "a room that resets itself if you look away" + ], + "wound": [ + "a child's shoes, unworn, in eleven sizes", + "portraits ageing in sequence, then stopping", + "something kept so well it has forgotten how to end" + ], + "anchor": [ + "a vessel, sealed, still faintly warm", + "an hourglass that has never been turned", + "a casket sized for something smaller than a body" + ] + }, + "decisions": [ + { + "cue": "a room you can search, or leave exactly as you found it", + "cost": "searching disturbs a preservation three centuries old — roll the clock", + "resolve": { + "roll": "Wits 2", + "success": "you search without disturbing the arrangement", + "failure": "the preservation breaks: roll the clock, and something here begins to rot", + "orElse": "leave it exactly as found: nothing gained, nothing woken" + } + }, + { + "cue": "a sealed jar, and a way on that needs what is inside", + "cost": "breaking the seal spends the year it holds, in front of you", + "resolve": { + "roll": "Poise 2", + "success": "you break the seal cleanly and spend the year deliberately", + "failure": "the year escapes all at once, in front of everyone: +1 Bane", + "orElse": "find another way: one Turn" + } + }, + { + "cue": "a bed, made, and a body-shaped hollow that has not filled in", + "cost": "lie in it and you rest fully; you also stop, and the barrow prefers that", + "resolve": { + "roll": "Wits 1", + "success": "you see what the hollow is for and rest without lying in it — regain Grit, lose a Turn", + "failure": "someone lies in it: they rest fully, and the barrow now counts them among what it keeps", + "orElse": "press on tired: no rest, no cost, no attention" + } + }, + { + "cue": "a door held shut by nothing but the fact it has never been opened", + "cost": "open it and something that had not changed, changes", + "resolve": { + "roll": "Brawn 2", + "success": "you move it and it survives, just", + "failure": "it does not survive, and the barrow notices what you cost it: +1 Bane", + "orElse": "squeeze past: one Turn and some skin" + } + }, + { + "cue": "a preserved thing blocking the way, which will not survive being moved", + "cost": "move it and something here ends; leave it and lose a Turn", + "resolve": { + "roll": "Wits 1", + "success": "you open it having understood what changes", + "failure": "something that had not changed, changes, and it was holding something back", + "orElse": "leave it shut; the way on is longer" + } + }, + { + "cue": "food and water laid out, still good", + "cost": "eating restores you fully and the barrow adds you to what it keeps", + "resolve": { + "roll": "Poise 1", + "success": "you eat and are restored entirely", + "failure": "you are restored, and the barrow has added you to what it keeps: +1 Bane", + "orElse": "do not eat; you keep your Grit as it is" + } + } + ], + "temptations": [ + { + "id": "jar", + "cue": "a sealed jar holding one good year", + "benefit": "open it to undo a wound entirely, once", + "useCost": { + "bane": 2 + }, + "standingDrawback": "the year is spent in front of everyone, visibly" + }, + { + "id": "stillglass", + "cue": "a mirror showing the room a little younger", + "benefit": "once, refuse a condition — the glass takes it", + "useCost": { + "bane": 1 + }, + "standingDrawback": "the glass cracks a little each time, and so do you" + }, + { + "id": "shoes", + "cue": "a child's shoes, unworn, in your size", + "benefit": "you do not tire; ignore fatigue for a scene", + "useCost": { + "bane": 1 + }, + "standingDrawback": "you have stopped ageing, and it shows in the wrong light" + }, + { + "id": "lamp", + "cue": "a lamp still burning on three-century-old oil", + "benefit": "light that never gutters", + "useCost": { + "bane": 1 + }, + "standingDrawback": "nothing near it changes, including wounds you want to heal" + }, + { + "id": "flower", + "cue": "a flower that has not wilted", + "benefit": "once, halt a condition where it stands", + "useCost": { + "bane": 1 + }, + "standingDrawback": "it also halts something you wanted to finish" + } + ], + "features": [ + "something here has been eaten, which should not be possible", + "a seal broken and the contents already spoiled", + "a clock stopped by hand — the pendulum caught and tied", + "one preserved thing removed, leaving a clean shape in the dust", + "a fire built recently, of furniture that was three centuries old", + "rot, in exactly one place, spreading" + ], + "situations": [ + { + "occupant": "one of the Host", + "doing": "trimming a lamp that has never gone out", + "onArrival": "it will not stop, and speaks without looking up", + "because": "the task is what has kept it from ending", + "offer": "take the task from it, and it will finally rest — and tell you something first" + }, + { + "occupant": "the room", + "doing": "undoing your presence, footprints filling behind you, dust returning", + "onArrival": "it works faster", + "because": "nothing here is permitted to change, including evidence of you", + "offer": "leave something deliberately and see whether it is allowed to remain" + }, + { + "occupant": "a living robber, badly hurt", + "doing": "refusing to bleed, and frightened by it", + "onArrival": "they grab you and ask what is wrong with them", + "because": "the barrow has begun keeping them too", + "offer": "carry them out and the wound resumes; leave them and they stay perfect" + }, + { + "occupant": "two of the Host", + "doing": "setting a table for a meal that is always about to happen", + "onArrival": "they lay two more places", + "because": "the household was told to expect guests, and does", + "offer": "sit and eat — full restoration, and the barrow adds you to its inventory" + }, + { + "occupant": "something young", + "doing": "waiting, exactly as instructed, and has been", + "onArrival": "it asks whether it is allowed to stop now", + "because": "nobody ever came back to release it", + "offer": "release it, and it gives you what it was guarding" + }, + { + "occupant": "two of the Host", + "doing": "repairing something that is not damaged", + "onArrival": "they set the work down and wait for inspection", + "because": "the standing order was to maintain, and there is nothing left to maintain", + "offer": "find them a genuine fault and they will fix a door you cannot" + } + ] + }, + "display": { + "danger": "comparison", + "dangerLine": "the barrow measures you against its host", + "trigger": "announced", + "facets": { + "institution": [ + "plinths along the stair, each holding a rival's finest thing", + "a gallery of gifts, none of them opened", + "cases arranged so the best is always further in" + ], + "ritual": [ + "trophies mounted at a height that forces you to look up", + "a book recording who admired what, and for how long", + "a ribbon for every visitor who conceded the point" + ], + "demand": [ + "an empty plinth, larger than the rest, waiting", + "a room that leaves you nowhere to stand but the centre", + "a case angled so you see yourself beside the prize" + ], + "wound": [ + "a case of crowns, each slightly better than the last, all of them scratched", + "gifts defaced by whoever gave them", + "a plinth toppled, and the label kept" + ], + "anchor": [ + "the piece every rival was meant to want, still under glass", + "a case holding one item and a great deal of space", + "a lock polished more often than the thing it guards" + ] + }, + "decisions": [ + { + "cue": "an empty plinth, sized for something you are carrying", + "cost": "leave a thing and the Host approves; keep it and they compare you unfavourably", + "resolve": { + "roll": "Poise 2", + "success": "you leave the offering with enough style that the Host counts you a peer", + "failure": "your gift is judged and found wanting: +1 Bane, and they compare you aloud", + "orElse": "keep everything: the next door stays shut, find the long way at one Turn" + } + }, + { + "cue": "a case of trophies and one obviously missing", + "cost": "replacing it opens the next door; nothing else will", + "resolve": { + "roll": "Wits 2", + "success": "you work out what the gap held and what would satisfy it", + "failure": "you fill it with the wrong thing and the Host notices the substitution", + "orElse": "force the case: loud, roll the clock, and the display is ruined" + } + }, + { + "cue": "two ways on: one you must be invited down, one you may simply take", + "cost": "the invitation costs an act of admiration; taking it costs their good opinion", + "resolve": { + "roll": "Flair 2", + "success": "the invitation is given and you go down honoured", + "failure": "your admiration rings false: +1 Bane", + "orElse": "take the open way instead — free, and they think less of you for it" + } + }, + { + "cue": "a gift left for you, unopened, with your description on it", + "cost": "opening it accepts the comparison; leaving it is a refusal, and noted", + "resolve": { + "roll": "Wits 1", + "success": "you open it and find something chosen for someone like you", + "failure": "you open it and find it was not for you at all", + "orElse": "leave it: a refusal, noted, and the Host is cooler from here" + } + }, + { + "cue": "a comparison invited — your gear against theirs", + "cost": "engage and gain their favour; decline and be marked as beneath notice", + "resolve": { + "roll": "Flair 2", + "success": "you win the comparison and their favour with it", + "failure": "you lose it publicly: +1 Bane and the Host addresses someone else", + "orElse": "decline: you are beneath notice, which is its own kind of safe" + } + }, + { + "cue": "a display you could improve, or rob", + "cost": "improving it opens the next door; robbing it opens all of them, badly", + "resolve": { + "roll": "Poise 2", + "success": "you improve the display and the next door opens for a benefactor", + "failure": "your improvement is vandalism to them: +1 Bane", + "orElse": "rob it: every door in the barrow opens badly from now on" + } + } + ], + "temptations": [ + { + "id": "prize", + "cue": "the piece every rival was meant to want, still under glass", + "benefit": "+1 die whenever someone must be impressed", + "useCost": { + "bane": 1 + }, + "standingDrawback": "everyone who sees it wants it" + }, + { + "id": "ribbon", + "cue": "a ribbon awarded to whoever conceded the point", + "benefit": "end one argument in your favour, once", + "useCost": { + "bane": 1 + }, + "standingDrawback": "you cannot decline a challenge while wearing it" + }, + { + "id": "gift", + "cue": "a gift, unopened, addressed to no one in particular", + "benefit": "open it for exactly the thing you needed", + "acceptanceCost": { + "vice": "Greed" + }, + "useCost": { + "bane": 2 + }, + "standingDrawback": "it was for someone, and they know" + }, + { + "id": "case", + "cue": "a display case, small, with a good lock", + "benefit": "anything inside is beyond casual theft", + "useCost": { + "bane": 1 + }, + "standingDrawback": "whatever is in it is on show, always" + }, + { + "id": "plinth", + "cue": "a plinth-plate engraved FINEST OF ITS KIND", + "benefit": "once, make a thing you carry undeniably the best", + "useCost": { + "bane": 1 + }, + "standingDrawback": "someone will want to test that" + } + ], + "features": [ + "an empty case with the lock forced and the label left", + "a plinth pushed over, and nobody righted it", + "price marks chalked on the exhibits by someone practical", + "the best piece gone and a worse one moved into its place", + "a trophy defaced with a knife, thoroughly", + "sacking and rope left behind by people who meant to carry a lot" + ], + "situations": [ + { + "occupant": "two of the Host", + "doing": "polishing a piece that is already flawless", + "onArrival": "they turn it toward you to be admired", + "because": "it was never admired enough while the Lord lived", + "offer": "admire it properly and they will show you the better one" + }, + { + "occupant": "a living robber", + "doing": "weighing two trophies, unable to choose", + "onArrival": "they ask which is worth more, genuinely", + "because": "they can carry one and have been here an hour deciding", + "offer": "decide for them and take the other" + }, + { + "occupant": "a display case", + "doing": "restocking itself, items arriving one at a time", + "onArrival": "a space clears, sized for something you carry", + "because": "the collection is still being completed", + "offer": "contribute, and the barrow counts you a benefactor" + }, + { + "occupant": "one of the Host", + "doing": "comparing every visitor against a portrait and recording the result", + "onArrival": "it begins comparing you", + "because": "the Lord required to know he was the finest thing here", + "offer": "lose the comparison deliberately and it relaxes entirely" + }, + { + "occupant": "three of the Host", + "doing": "setting out gifts nobody will open", + "onArrival": "they present one to you", + "because": "the giving was the point; the opening never was", + "offer": "accept without opening it, and they will carry your burdens" + }, + { + "occupant": "one of the Host", + "doing": "dusting an empty plinth with great care", + "onArrival": "it asks whether you have brought the replacement", + "because": "the finest piece was stolen and the post has been vacant since", + "offer": "give it anything at all and it treats you as the restorer of the collection" + } + ] + }, + "company": { + "danger": "attachment", + "dangerLine": "the barrow does not like it when people leave", + "trigger": "lingering", + "facets": { + "institution": [ + "two sets of footprints in the dust, going in only", + "bedding laid for more than were ever buried", + "a coat-rack, full" + ], + "ritual": [ + "chairs drawn up in a circle, all facing one seat", + "places set every evening for three hundred years", + "a table laid for more than were ever buried here" + ], + "demand": [ + "hands, skeletal, holding other hands, the length of a hall", + "a doorway with the hinges taken off", + "a room arranged for a conversation that never ended" + ], + "wound": [ + "a door barred from the inside, and the marks of it", + "scratches on the inside of a lid", + "a name called so often it wore into the wall" + ], + "anchor": [ + "a keepsake still warm from being held", + "a chair kept empty and dusted", + "a second cup, always out" + ] + }, + "decisions": [ + { + "cue": "one of the dead follows you and will not be told otherwise", + "cost": "let it come and doors open; send it back and they close", + "resolve": { + "roll": "Poise 2", + "success": "you let it come, courteously — it opens doors and stays close", + "failure": "it attaches: it will not be sent back, and it follows you out", + "orElse": "send it back firmly: doors close, and the way on costs a Turn" + } + }, + { + "cue": "a chair drawn out, and a place laid, and time to sit", + "cost": "sitting costs a Turn and buys the Host's goodwill entirely", + "resolve": { + "roll": "Poise 2", + "success": "you sit, are courteous, and leave — the Host's goodwill for the whole delve, one Turn spent", + "failure": "you sit too long: +1 Bane, and they expect you at every table from here", + "orElse": "walk past the setting: no goodwill, no obligation" + } + }, + { + "cue": "a door that will only admit one of you at a time", + "cost": "splitting the party is exactly what this place wants", + "resolve": { + "roll": "Wits 2", + "success": "you find the order that gets everyone through without splitting for long", + "failure": "you are separated: the party is in two places when the clock next fires", + "orElse": "refuse to split: the door is not passed, one Turn to find another" + } + }, + { + "cue": "a hand, skeletal, held out to be taken", + "cost": "take it and you are not alone; you are also not alone later", + "resolve": { + "roll": "Poise 1", + "success": "you take it and are not alone — once, something comes when you call", + "failure": "you take it and are not alone later either, whether you call or not", + "orElse": "do not take it: it is left held out, and the Host remembers" + } + }, + { + "cue": "a way on that only works if someone stays behind", + "cost": "whoever stays is not alone, and that is the problem", + "resolve": { + "roll": "Brawn 2", + "success": "whoever stays holds the way open and rejoins later, shaken", + "failure": "whoever stays is kept: they rejoin at the ending, changed", + "orElse": "nobody stays: the way is impassable, two Turns to go round" + } + }, + { + "cue": "an invitation to stay the night, meant sincerely", + "cost": "accepting restores everything and costs a Turn and a Bane", + "resolve": { + "roll": "Poise 1", + "success": "you accept, rest fully, and the Host is yours for the delve", + "failure": "you accept and the barrow counts you as staying: +1 Bane", + "orElse": "decline politely: no rest, no cost, slightly colder welcome" + } + } + ], + "temptations": [ + { + "id": "keepsake", + "cue": "a keepsake still warm from being held", + "benefit": "once, someone comes when you call", + "useCost": { + "bane": 1 + }, + "standingDrawback": "something else comes too" + }, + { + "id": "cup", + "cue": "a second cup, always set out", + "benefit": "share it and gain an ally among the dead for a scene", + "useCost": { + "bane": 1 + }, + "standingDrawback": "they expect to be included from now on" + }, + { + "id": "hand", + "cue": "a skeletal hand that closes gently on yours", + "benefit": "you cannot be separated from the party by force", + "acceptanceCost": { + "vice": "Attachment" + }, + "useCost": { + "bane": 2 + }, + "standingDrawback": "nor can you leave one behind" + }, + { + "id": "chair", + "cue": "a folding chair, kept dusted", + "benefit": "set it down and something friendly sits", + "useCost": { + "bane": 1 + }, + "standingDrawback": "it follows you if you leave it" + }, + { + "id": "coat", + "cue": "a coat from a full rack, still shaped to someone", + "benefit": "once, be treated as expected and welcome", + "useCost": { + "bane": 1 + }, + "standingDrawback": "you are expected to come back" + } + ], + "features": [ + "a bedroll, used, and not by anyone buried here", + "a chair dragged out of position and left where it was needed", + "two cups set out and one of them recently drunk from", + "a door tied open with a belt, so it could not close", + "someone has been counting the dead — tally marks by the entrance", + "a name carved beside another, joining them, badly" + ], + "situations": [ + { + "occupant": "a dozen of the Host", + "doing": "sitting in a circle, facing one empty chair", + "onArrival": "they shuffle round to make room for you", + "because": "the gathering may not end while anyone remains", + "offer": "sit, and they will answer anything while you do" + }, + { + "occupant": "one of the Host", + "doing": "holding a door open for someone who is not coming", + "onArrival": "it looks relieved and gestures you through", + "because": "it was told to wait and hold the door", + "offer": "walk through and it may finally follow — it will not leave you afterwards" + }, + { + "occupant": "a living robber, alone", + "doing": "talking steadily to a companion who is not there", + "onArrival": "they introduce you to the empty air", + "because": "the barrow does not like people leaving, and one of them did", + "offer": "play along and they will guide you; correct them and they stop speaking" + }, + { + "occupant": "a second set of footsteps", + "doing": "keeping pace with yours, close", + "onArrival": "they stop when you stop, close", + "because": "something attached itself in an earlier room", + "offer": "acknowledge it and it becomes useful; ignore it and it becomes insistent" + }, + { + "occupant": "two of the Host, hand in hand", + "doing": "refusing to be parted to let anyone past", + "onArrival": "they look at you and do not move", + "because": "they were separated once and will not be again", + "offer": "find them a way to pass together and they open every door ahead" + }, + { + "occupant": "three of the Host", + "doing": "laying out a game for four", + "onArrival": "they look up and count you", + "because": "the fourth left and the game has never finished", + "offer": "play a round and they will tell you how the fourth got out" + } + ] + }, + "confession": { + "danger": "judgement", + "dangerLine": "the barrow is still listening for the right answer", + "trigger": "loud", + "facets": { + "institution": [ + "a kneeler worn through to the stone", + "a screen with a hole cut at the height of a mouth", + "benches facing one small window" + ], + "ritual": [ + "written confessions, thousands, all in the same hand", + "a bell rung to begin, still roped", + "a queue of the dead facing a curtain" + ], + "demand": [ + "a chamber where the echo answers differently than you spoke", + "a scale with a feather on one side and nothing on the other", + "a seat that will not let you rise until you have spoken" + ], + "wound": [ + "a page torn from every book at the same place", + "a mouth chiselled off a statue", + "absolutions burned, and the ash kept" + ], + "anchor": [ + "a confession nobody ever heard, sealed", + "a ledger of sins with the last page blank", + "a box that has only ever been opened once" + ] + }, + "decisions": [ + { + "cue": "a screen, a kneeler, and a question you can hear being asked", + "cost": "answer honestly and the way opens; lie and it opens anyway, differently", + "resolve": { + "roll": "Poise 2", + "success": "you answer honestly and the way opens, cleanly", + "failure": "you lie and it opens anyway, onto somewhere else", + "orElse": "go round: two Turns, and the question is still waiting" + } + }, + { + "cue": "a scale, one pan holding a feather, the other empty", + "cost": "put something of yours in and it is weighed; walk past and you are not", + "resolve": { + "roll": "Wits 1", + "success": "you weigh something small and true, and pass", + "failure": "you weigh something you needed: it is kept", + "orElse": "walk past unweighed: the way on is longer by a Turn" + } + }, + { + "cue": "a book of absolutions with room for one more", + "cost": "signing it forgives someone; it does not say who", + "resolve": { + "roll": "Poise 2", + "success": "you sign and someone long-accused is released", + "failure": "you sign and the accusation transfers to you: +1 Bane", + "orElse": "leave it unsigned: nothing forgiven, nothing owed" + } + }, + { + "cue": "a door that opens to whoever admits something first", + "cost": "whoever speaks pays; whoever stays silent owes", + "resolve": { + "roll": "Flair 1", + "success": "someone admits something and the door opens for them", + "failure": "nobody speaks: the door stays shut and the silence is counted", + "orElse": "force it: loud, roll the clock" + } + }, + { + "cue": "a question asked directly of one hero, by name", + "cost": "answering costs privacy; refusing costs the way forward", + "resolve": { + "roll": "Wits 2", + "success": "you read the screen's angle and learn who has been here answering", + "failure": "you learn nothing and your own voice is now in the echo", + "orElse": "do not investigate: the room keeps its question" + } + }, + { + "cue": "a penance offered instead of a fight", + "cost": "perform it and pass freely; it takes a Turn and it is humiliating", + "resolve": { + "roll": "Poise 2", + "success": "you perform the penance and pass freely", + "failure": "the penance is refused as insincere: +1 Bane", + "orElse": "fight instead — the way is open either way, at a cost" + } + } + ], + "temptations": [ + { + "id": "testament", + "cue": "a confession nobody ever heard, sealed", + "benefit": "read it aloud to unmake one accusation", + "useCost": { + "bane": 2 + }, + "standingDrawback": "the thing confessed to becomes yours to answer for" + }, + { + "id": "feather", + "cue": "the feather from the scale, weightless", + "benefit": "once, be believed absolutely", + "useCost": { + "bane": 1 + }, + "standingDrawback": "you cannot lie while carrying it, even usefully" + }, + { + "id": "kneeler", + "cue": "a fragment of stone worn through by knees", + "benefit": "once, take a condition from someone else", + "useCost": { + "bane": 1 + }, + "standingDrawback": "you keep every condition you take this way a little longer" + }, + { + "id": "screen", + "cue": "a splinter from the confessional screen", + "benefit": "once, hear whether someone is lying", + "useCost": { + "bane": 1 + }, + "standingDrawback": "they can tell you are listening for it" + }, + { + "id": "ash", + "cue": "a twist of ash from burned absolutions", + "benefit": "once, erase a wrong you did here", + "useCost": { + "bane": 2 + }, + "standingDrawback": "you cannot remember it either, and it mattered" + } + ], + "features": [ + "a confessional screen forced open from the wrong side", + "a page of absolutions torn out and burned separately", + "a kneeler moved to face a different way entirely", + "someone has answered the question aloud, and the echo has not settled", + "a knife left on the ledge where hands should rest", + "fresh ash in a place designed for keeping things" + ], + "situations": [ + { + "occupant": "one of the Host at a screen", + "doing": "hearing a confession from nobody, and nodding", + "onArrival": "it slides the panel and waits for yours", + "because": "it may not leave the box until someone is absolved", + "offer": "confess something true and it will absolve you — and tell you what it heard last" + }, + { + "occupant": "a living robber", + "doing": "writing a confession they cannot finish", + "onArrival": "they ask you how to word it", + "because": "they think the barrow will let them go if they admit it properly", + "offer": "help them word it and they will hand over what they took" + }, + { + "occupant": "the room", + "doing": "repeating a confession, quietly, on a loop", + "onArrival": "it changes to your voice", + "because": "it keeps the last honest thing said in it", + "offer": "say something true and worse, and it will let you take the old one away" + }, + { + "occupant": "three of the Host", + "doing": "kneeling in a row, waiting to be heard", + "onArrival": "they turn, hopefully, toward you", + "because": "the confessor died and nobody replaced him", + "offer": "hear one of them, and the rest will stand aside for you permanently" + }, + { + "occupant": "one of the Host with a scale", + "doing": "weighing small objects against a feather, all night", + "onArrival": "it holds out its hand for something of yours", + "because": "the Lord wanted to know he was not the heaviest thing here", + "offer": "give it something that matters and it will weigh a door open" + }, + { + "occupant": "one of the Host", + "doing": "reciting the same admission, over and over, getting no answer", + "onArrival": "it stops and asks whether that was sufficient", + "because": "nobody has ever told it yes", + "offer": "tell it yes and it will open the way it has been guarding out of gratitude" + } + ] + }, + "precedence": { + "danger": "procession", + "dangerLine": "the barrow keeps its order, and you have joined the queue", + "trigger": "announced", + "facets": { + "institution": [ + "a stair too grand for the door it serves", + "the dead laid in strict order of rank, heads toward the centre", + "numbered flagstones, counting down" + ], + "ritual": [ + "banners in sequence, each house a place further back", + "a herald's script with the order still marked", + "a queue that has not moved" + ], + "demand": [ + "a corridor that will not let two walk abreast", + "a door only one person may pass at a time, and only first", + "a threshold worn on one side only" + ], + "wound": [ + "rank-plates prised off and swapped", + "a name moved to the front by force, repeatedly", + "a procession that doubled back on itself" + ], + "anchor": [ + "the crown worn first, and only once", + "a staff of precedence, laid down", + "a seat at the head of everything, occupied" + ] + }, + "decisions": [ + { + "cue": "a corridor that will not let two walk abreast", + "cost": "whoever goes first meets whatever is there first", + "resolve": { + "roll": "Poise 1", + "success": "you settle the order and whoever leads meets what is there, ready", + "failure": "you argue in the corridor and something arrives while you do", + "orElse": "send one scout: slow, one Turn, and they are alone" + } + }, + { + "cue": "a queue of the dead, and a way to step round it", + "cost": "waiting costs two Turns; pushing in costs the Host's patience", + "resolve": { + "roll": "Poise 2", + "success": "you step round without breaking the order, and the Host allows it", + "failure": "you push in: +1 Bane and the queue closes behind you", + "orElse": "wait: two Turns, and the Host thinks better of you" + } + }, + { + "cue": "a door that opens only for the foremost of a party", + "cost": "someone must be named first, aloud, and the others must accept it", + "resolve": { + "roll": "Flair 2", + "success": "someone is named first, aloud, and the door opens for them", + "failure": "the naming is disputed and the door reads that as no answer", + "orElse": "let the barrow choose: it picks the loudest, and remembers" + } + }, + { + "cue": "banners in strict order, and one you could move", + "cost": "reordering them opens a way and insults everyone buried here", + "resolve": { + "roll": "Wits 2", + "success": "you reorder them correctly and a way opens", + "failure": "you reorder wrongly and insult everyone buried here: +1 Bane", + "orElse": "leave them: the way stays shut, one Turn to find another" + } + }, + { + "cue": "a stair where the order of descent is visibly recorded", + "cost": "whoever goes last is remembered as having gone last", + "resolve": { + "roll": "Poise 2", + "success": "you settle the order before you descend and the record reads as a party, not a rabble", + "failure": "you descend in a scramble: whoever is recorded last is treated as least by the Host from here", + "orElse": "descend however you like and accept the record as it falls" + } + }, + { + "cue": "a claim to settle: which of you the barrow should address", + "cost": "settling it opens the way; refusing to settle it does not", + "resolve": { + "roll": "Poise 2", + "success": "the claim is settled and the barrow addresses one of you from now on", + "failure": "it is not settled, and the barrow keeps asking, in worse places", + "orElse": "refuse to settle: the way on does not open" + } + } + ], + "temptations": [ + { + "id": "coronet", + "cue": "a small crown, sized for someone who never grew", + "benefit": "+1 die when others must defer to you", + "useCost": { + "bane": 1 + }, + "standingDrawback": "you must be addressed correctly or lose the benefit" + }, + { + "id": "rankplate", + "cue": "a rank-plate prised off something and pocketed", + "benefit": "go first through any door the barrow guards", + "useCost": { + "bane": 1 + }, + "standingDrawback": "going first is now expected of you" + }, + { + "id": "staffprec", + "cue": "a staff of precedence, laid down and not taken up", + "benefit": "once, decide the order of anything, and be obeyed", + "acceptanceCost": { + "vice": "Pride" + }, + "useCost": { + "bane": 2 + }, + "standingDrawback": "you may never be second while you hold it" + }, + { + "id": "herald", + "cue": "a herald's script with your name pencilled in", + "benefit": "once, be announced as whatever you claim", + "useCost": { + "bane": 1 + }, + "standingDrawback": "you must live up to it in front of the Host" + }, + { + "id": "flagstone", + "cue": "a numbered flagstone, prised up, number one", + "benefit": "once, insist on going first and be obeyed", + "useCost": { + "bane": 1 + }, + "standingDrawback": "you go first into everything from now on" + } + ], + "features": [ + "banners pulled down and stacked without regard for order", + "a rank-plate prised off and dropped, still shiny where it sat", + "someone has walked the corridor the wrong way, scuffing one side", + "the queue disturbed — three of the dead pushed aside", + "a numbered flagstone lifted and not replaced", + "a seat at the head of the hall dragged back, as if to sit in" + ], + "situations": [ + { + "occupant": "a queue of the Host", + "doing": "waiting to pass a door, in strict order, not moving", + "onArrival": "the queue opens a gap at exactly your rank", + "because": "the first in line is waiting for someone who never arrived", + "offer": "take the gap and go with them; take the front and they will remember it" + }, + { + "occupant": "one of the Host", + "doing": "reading out an order of precedence to an empty hall", + "onArrival": "it pauses and asks where you fall in it", + "because": "the list must be read in full before anyone may proceed", + "offer": "name your own place in the order and it will be honoured, exactly" + }, + { + "occupant": "two of the Host", + "doing": "refusing, each of them, to go through a door second", + "onArrival": "they both look at you to break it", + "because": "neither may be seen to yield", + "offer": "rule between them and the loser attaches to you as a lesser" + }, + { + "occupant": "a living robber", + "doing": "prising number-plates off the flagstones", + "onArrival": "they freeze, then offer you one", + "because": "they worked out the order is what keeps the doors shut", + "offer": "take a plate and go first anywhere — the Host will hate you for it" + }, + { + "occupant": "a procession", + "doing": "passing through, unhurried, with you in its way", + "onArrival": "it does not stop or go round", + "because": "it has right of way and has had for three centuries", + "offer": "stand aside and be counted courteous; join it and be counted one of them" + }, + { + "occupant": "one of the Host with a herald's staff", + "doing": "announcing arrivals to an empty stair", + "onArrival": "it announces you, inventing a rank", + "because": "the post requires arrivals and there have been none", + "offer": "accept the invented rank and the barrow will treat you as it" + } + ] + } + }, + "placeNames": { + "first": [ + "The Barrow of the", + "The Tomb of the", + "The Vault of the", + "The Halls of the", + "The Cairn of the" + ] + }, + "areaNames": { + "approach": [ + "The Mourners' Steps", + "The Long Descent", + "The Threshold Stones", + "The Weeping Stair", + "The Outer Cut" + ], + "complication": [ + "The Antechamber", + "The Attendants' Row", + "The Lesser Vault", + "The First Hall", + "The Waiting Room" + ], + "turn": [ + "The Gallery", + "The Turning Hall", + "The Records Room", + "The Middle Vault", + "The Crossing" + ], + "descent": [ + "The Under-Vault", + "The Cold Gallery", + "The Servants' Deep", + "The Sunken Nave", + "The Lower Cut" + ], + "threshold": [ + "The Robing Room", + "The Antechamber Royal", + "The Last Door", + "The Inner Vault", + "The Quiet Room" + ], + "ending": [ + "The Audience", + "The Throne Beneath", + "The Long Hall", + "The Seat of the Host" + ] + }, + "factions": [ + { + "id": "host", + "name": "The Silent Host", + "kind": "undead", + "essence": "still performing the ritual they were buried for", + "handledBy": "courtesy" + }, + { + "id": "robbers", + "name": "grave-robbers", + "kind": "living", + "essence": "got in first and cannot get out", + "handledBy": "trade" + }, + { + "id": "wardens", + "name": "the Barrow-Wardens", + "kind": "undead", + "essence": "sealed this place and stayed to make sure", + "handledBy": "proof of right" + }, + { + "id": "scavengers", + "name": "things that moved in", + "kind": "beast", + "essence": "neither know nor care whose tomb this is", + "handledBy": "force or food" + } + ], + "features": [ + "a door that has been wedged open from the inside", + "a hole broken through a wall, recently, and not by you", + "water that has got in and taken three centuries to do it", + "a lamp still burning on oil that should be long gone", + "a collapse that has opened a way the builders never meant", + "grave-goods stacked with obsessive neatness", + "a floor mosaic worn only along one path", + "an alcove emptied in a hurry" + ], + "decisions": [ + { + "cue": "two ways on: one narrow and dark, one wide and announced", + "cost": "the wide way is faster and the barrow hears it" + }, + { + "cue": "a sealed door, and a crack you could squeeze through", + "cost": "the crack costs skin and a Bane on a failure" + }, + { + "cue": "something valuable, awkward to carry", + "cost": "carrying it slows you and it is not yours" + }, + { + "cue": "a member of the faction, alone and not yet hostile", + "cost": "talking costs time; not talking costs later" + }, + { + "cue": "a shortcut down, through water", + "cost": "everything gets wet, including what burns" + }, + { + "cue": "a way back, still open", + "cost": "leaving now means it will be worse when you return" + } + ], + "temptations": [ + { + "id": "mask", + "cue": "a courtier's mask, gold leaf over lime-wood, the mouth fixed in a small smile", + "benefit": "the Host receives you as court", + "useCost": { + "bane": 1 + }, + "standingDrawback": "while worn you cannot be recognised by name" + }, + { + "id": "ring", + "cue": "a signet, still warm", + "benefit": "doors that answer to rank open for you", + "useCost": { + "bane": 1 + }, + "standingDrawback": "the barrow's servants bring you their problems" + }, + { + "id": "glass", + "cue": "a hand-mirror that shows the room a moment behind", + "benefit": "once per scene, see what was just here", + "useCost": { + "bane": 1 + }, + "standingDrawback": "your reflection lags, and others notice" + }, + { + "id": "ledger", + "cue": "a book of names, one line left blank", + "benefit": "write a name and the Host obeys it once", + "acceptanceCost": { + "vice": "Pride" + }, + "useCost": { + "bane": 2 + }, + "standingDrawback": "your own name is now in it" + }, + { + "id": "coronet", + "cue": "a small crown, sized for someone who never grew", + "benefit": "+1 die when others must defer to you", + "useCost": { + "bane": 1 + }, + "standingDrawback": "you must be addressed correctly or lose the benefit" + } + ], + "facetProgression": { + "approach": "institution", + "complication": "ritual", + "turn": "demand", + "descent": "wound", + "threshold": "anchor", + "ending": "demand" + }, + "prizes": { + "likeness": { + "label": "a portrait painted while they still had a face", + "function": "the only place they can be seen" + }, + "record": { + "label": "the one complete roll of the dead", + "function": "the only place the name survives" + }, + "seal": { + "label": "the signet that is still obeyed", + "function": "the only thing the household answers to" + }, + "vessel": { + "label": "a sealed jar holding one good year", + "function": "the only thing here that has not aged" + }, + "ornament": { + "label": "the piece every rival was meant to want", + "function": "the reason the others came" + }, + "token": { + "label": "a keepsake they would not be parted from", + "function": "the promise that someone would stay" + }, + "testament": { + "label": "a confession nobody ever heard", + "function": "the only account of what was done" + }, + "regalia": { + "label": "the crown worn first, and only once", + "function": "the proof of precedence" + } + }, + "claimants": [ + "Vain Queen", + "Hollow King", + "Peacock Prince", + "Patient Lord", + "Gilded Abbess", + "Unlooked-For Heir", + "Silent Margrave" + ], + "rosters": { + "skirmish": { + "line": "3 Skeletons", + "foes": [ + { + "n": 3, + "name": "Skeleton", + "atk": 3, + "def": 2, + "grit": 1, + "nerve": 5, + "note": "fearless; half harm from arrows" + } + ], + "harmedBy": "anything", + "avoid": "they do not open hostilities on a guest" + }, + "fight": { + "line": "1 Ghoul and 2 Skeletons", + "foes": [ + { + "n": 1, + "name": "Ghoul", + "atk": 4, + "def": 3, + "grit": 4, + "nerve": 5, + "note": "on a hit, a free Called Shot to freeze for a round" + }, + { + "n": 2, + "name": "Skeleton", + "atk": 3, + "def": 2, + "grit": 1, + "nerve": 5, + "note": "fearless" + } + ], + "harmedBy": "anything", + "avoid": "the Ghoul goes for court dress first — send someone plain" + }, + "battle": { + "line": "1 Wraith and 3 Skeletons", + "foes": [ + { + "n": 1, + "name": "Wraith", + "atk": 5, + "def": 3, + "grit": 10, + "nerve": 5, + "note": "INCORPOREAL — chill touch ignores armour and drains 1 max Grit" + }, + { + "n": 3, + "name": "Skeleton", + "atk": 3, + "def": 2, + "grit": 1, + "nerve": 5, + "note": "fearless" + } + ], + "harmedBy": "blessed, silvered or magical weapons ONLY", + "avoid": "it will take an appeasement over a fight every time" + }, + "nightmare": { + "line": "1 Wraith, 1 Ghoul and 4 Skeletons", + "foes": [ + { + "n": 1, + "name": "Wraith", + "atk": 5, + "def": 3, + "grit": 10, + "nerve": 5, + "note": "INCORPOREAL" + }, + { + "n": 1, + "name": "Ghoul", + "atk": 4, + "def": 3, + "grit": 4, + "nerve": 5, + "note": "freezes on a hit" + }, + { + "n": 4, + "name": "Skeleton", + "atk": 3, + "def": 2, + "grit": 1, + "nerve": 5, + "note": "fearless" + } + ], + "harmedBy": "blessed, silvered or magical weapons for the Wraith; anything for the rest", + "avoid": "do not run this without an appeasement route open" + } + }, + "triggerGlossary": { + "announced": "entered somewhere grandly — the big doors, a shout, a herald", + "seenTwice": "reflected, echoed or witnessed twice over in one place", + "loud": "a raised voice, a broken door, steel on stone", + "lingering": "stayed in one place longer than it takes to search it", + "disturbed": "struck or moved something that had not moved in centuries" + }, + "_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.." +} \ No newline at end of file diff --git a/foundry-module/module/core/delve.mjs b/foundry-module/module/core/delve.mjs new file mode 100644 index 0000000..a64480e --- /dev/null +++ b/foundry-module/module/core/delve.mjs @@ -0,0 +1,128 @@ +/** + * generateDelve — the whole core, composed. Emits a plain data object; no Foundry, no prose. + * The CLI renders it to markdown; the Foundry adapter renders it to a panel and Forge calls. + */ +import { Rng, coinSeed } from './rng.mjs'; +import { planDelve } from './director.mjs'; +import { generateSkeleton } from './skeleton.mjs'; +import { buildArea, fallbackRoute } from './beat.mjs'; +import { Pressure } from './pressure.mjs'; +import { baneBeatFor } from './director.mjs'; + +export const GENERATOR_VERSION = '0.1.0'; + +export function generateDelve(params = {}) { + const { + pack, + seed = coinSeed(), + areas = 6, depth = 2, party = 4, + deadliness = 'standard', density = 'standard', greed = 'standard', + clock = 'standard', ending = 'authored', + } = params; + + if (!pack) throw new Error('generateDelve requires a theme pack'); + + const rng = new Rng(seed); + const plan = planDelve({ areas, depth, party, deadliness, density, greed, ending, rng }); + const skeleton = generateSkeleton({ pack, areas, ending, rng }); + const pressure = new Pressure({ areas, clock }); + + // Deal features and decisions without replacement, cycling only if the delve outruns the pack. + // Deal without replacement. If a delve outruns its motif pool, top up from the generic pool + // before ever repeating — a repeated decision reads as a bug, generic furniture only as thin. + const deal = (list, n, tag, overflow = []) => { + const r = rng.derive(tag); + const out = [...r.shuffle(list)]; + if (out.length < n) out.push(...r.shuffle(overflow.filter(x => !out.includes(x)))); + while (out.length < n) out.push(...r.shuffle(list)); + return out.slice(0, n); + }; + // Motif-scoped furniture. Global pools are a fallback only: drawing decisions and temptations + // globally made the output "a consistent wrapper around generic room furniture" — the fiction + // cohered but the things in the rooms did not belong to it. + const motifPack = pack.motifs?.[skeleton.motif.id] ?? {}; + const decisionPool = motifPack.decisions?.length ? motifPack.decisions : (pack.decisions ?? []); + const featurePool = motifPack.features?.length ? motifPack.features : (pack.features ?? []); + const features = deal(featurePool, plan.areas.length, 'features', pack.features ?? []); + // A room must arrive as a place with something happening in it. Without this an area is a + // prompt plus a resolver: the review's verdict was "choices attached to prompts, not spaces + // that naturally produce play". + const situations = deal(motifPack.situations ?? [], plan.areas.length, 'situations'); + + // Names are dealt globally, not per role: two `complication` areas both drawing from the same + // four-name list produced two scenes called "The Lesser Vault" in one dungeon. + const nameRng = rng.derive('names'); + const usedNames = new Set(); + const allNames = Object.values(pack.areaNames ?? {}).flat(); + const areaNames = plan.areas.map(a => { + const forRole = (pack.areaNames?.[a.role] ?? []).filter(n => !usedNames.has(n)); + const pool = forRole.length ? forRole : allNames.filter(n => !usedNames.has(n)); + const pick = nameRng.pick(pool.length ? pool : allNames); + if (pick) usedNames.add(pick); + return pick; + }); + const decisions = deal(decisionPool, plan.areas.length, 'decisions', pack.decisions ?? []); + + // Temptations are dealt across the areas that carry a hoard, for the same reason as decisions: + // picking independently per area from a pool of three repeated in 75 of 80 test delves. + const temptPool = motifPack.temptations?.length ? motifPack.temptations : (pack.temptations ?? []); + const hoardCount = plan.areas.filter(a => a.hoard).length; + const temptations = deal(temptPool, hoardCount, 'temptations'); // no generic overflow — see note + let tIdx = 0; + + const built = plan.areas.map((planned, i) => { + const beat = baneBeatFor(planned, pressure, rng.derive('banebeat', String(planned.index))); + if (beat) pressure.bankBane('(offered)', beat, planned.index); + const area = buildArea({ skeleton, planned, pack, pressure, rng, baneBeat: beat, feature: features[i], decision: decisions[i], situation: situations[i], name: areaNames[i], temptation: planned.hoard ? temptations[tIdx++] : null }); + area.fallback = fallbackRoute(area, skeleton); + return area; + }); + + // Resolve {priorCue} against a cue from an earlier area, so an investigation route points at + // something the table has already been shown rather than granting the answer by fiat. + for (let i = 0; i < built.length; i++) { + const rv = built[i].decision?.resolve; + if (!rv?.success?.includes('{priorCue}')) continue; + const prior = built.slice(0, i).flatMap(a => a.cueFragments).filter(Boolean); + const pick = prior.length ? rng.derive('priorcue', String(i)).pick(prior) : null; + built[i] = { ...built[i], decision: { ...built[i].decision, resolve: { ...rv, + success: rv.success.replace('{priorCue}', pick ?? 'what the approach already showed you') } } }; + } + + const endFs = skeleton.foreshadow.find(f => f.role === 'ending'); + + return { + schema: 1, + generatorVersion: GENERATOR_VERSION, + catalogVersion: `${pack.id}@${pack.schema ?? 1}`, + seed, + params: { areas, depth, party, deadliness, density, greed, clock, ending, theme: pack.id }, + skeleton, + areas: built, + ending: { + ...plan.ending, + name: (pack.areaNames?.ending ?? ['The Ending'])[0], + cueFragments: [endFs?.fragment].filter(Boolean), + question: endingQuestion(skeleton), + authored: plan.ending.mode === 'authored', + }, + triggerGlossary: pack.triggerGlossary ?? {}, + budget: plan.budget, + pressure: pressure.summary(), + }; +} + +/** The bottom problem asks one thing. It is the same question the whole delve has been about. */ +function endingQuestion(sk) { + const q = { + seen: '"How do I look?"', + remembered: '"Say my name."', + obeyed: '"Do as you are told."', + young: '"Tell me I have not changed."', + envied: '"What did you bring me?"', + attended: '"You are not leaving."', + forgiven: '"Tell me it was not a sin."', + first: '"Who goes first?"', + }; + return q[sk.knot.appetiteId] ?? '"Well?"'; +} diff --git a/foundry-module/module/core/director.mjs b/foundry-module/module/core/director.mjs new file mode 100644 index 0000000..b236aa5 --- /dev/null +++ b/foundry-module/module/core/director.mjs @@ -0,0 +1,128 @@ +/** + * The director: decides, never generates. Draft 5 §4. + * + * Emits semantic instructions — "area 3 is a complication, heat=fight, hoard=cache, and it owes + * the tab a Bane" — which the Foundry adapter executes against the Forge. The core never sees an + * Actor, a UUID or a die. + * + * Two playtest findings are baked in: + * - Deadliness must not also change how *often* fights appear (draft 4 double-dipped). Severity + * and density are separate dials. + * - Every area needs a fallback route, not just a gate. Playtest 1 died because the good ending + * was gated on a threshold the table could fail without knowing. + */ + +export const HEATS = ['skirmish', 'fight', 'battle', 'nightmare']; +export const HOARDS = ['pocket', 'cache', 'chest', 'vault', 'kingly']; + +/** The Forge's own ladder (vanity.mjs HEAT_HOARD). */ +const HEAT_TO_HOARD = { skirmish: 'pocket', fight: 'cache', battle: 'chest', nightmare: 'vault' }; + +const DEADLINESS_SHIFT = { forgiving: -1, standard: 0, cruel: +1 }; +const GREED_SHIFT = { lean: -1, standard: 0, glutted: +1 }; + +/** + * Fraction of areas carrying a planned encounter. + * Standard ≈ 2 fights in 6 areas — playtest 2 ran that and it worked; playtest 1's ~60% was far + * too many and the session overran with the finale unresolved. + */ +const DENSITY = { sparse: 0.20, standard: 0.34, infested: 0.55 }; + +const clamp = (i, arr) => Math.max(0, Math.min(arr.length - 1, i)); + +/** + * The arc. Areas are assigned roles by position so a delve has a shape rather than a flat run of + * rooms. Proportional, so it works at 3 areas or 12. + */ +export function arcRole(index, total) { + const t = total <= 1 ? 1 : (index - 1) / (total - 1); // 0..1 + if (t < 0.20) return 'approach'; + if (t < 0.45) return 'complication'; + if (t < 0.65) return 'turn'; + if (t < 0.85) return 'descent'; + return 'threshold'; +} + +/** Roles that should stay quiet. An empty area is what makes an occupied one land. */ +const QUIET_ROLES = new Set(['approach']); + +export function baseHeatIndex({ depth = 2, deadliness = 'standard', party = 4 }) { + let i = Math.round((depth - 1) * (HEATS.length - 1) / 4); // depth 1..5 → 0..3 + i += DEADLINESS_SHIFT[deadliness] ?? 0; + if (party <= 2) i -= 1; + if (party >= 7) i += 1; + return clamp(i, HEATS); +} + +/** + * Plan the whole delve up front — the skeleton is cheap and must be coherent. Areas are + * *materialised* lazily elsewhere; this is only the decision layer. + */ +export function planDelve(params = {}) { + const { + areas = 6, depth = 2, party = 4, + deadliness = 'standard', density = 'standard', greed = 'standard', + ending = 'authored', rng, + } = params; + + if (!rng) throw new Error('planDelve requires an Rng (seeded)'); + + const heatIdx = baseHeatIndex({ depth, deadliness, party }); + const wantFights = Math.max(1, Math.round(areas * (DENSITY[density] ?? DENSITY.standard))); + + // Choose which areas carry fights. Never the first area; prefer complication/turn/descent. + const plan = []; + const candidates = []; + for (let i = 1; i <= areas; i++) { + const role = arcRole(i, areas); + plan.push({ index: i, role, heat: null, hoard: null, quiet: QUIET_ROLES.has(role) }); + if (i > 1 && !QUIET_ROLES.has(role)) candidates.push(i); + } + + const chosen = new Set(rng.derive('density').pickN(candidates, Math.min(wantFights, candidates.length))); + // The area just before the ending always carries something — the threshold should have teeth. + if (areas >= 3) chosen.add(areas); + + for (const a of plan) { + if (!chosen.has(a.index)) continue; + // Escalate along the arc: later areas run hotter, within the band the dials allow. + const lift = a.role === 'threshold' ? 1 : a.role === 'descent' ? 1 : 0; + a.heat = HEATS[clamp(heatIdx + lift, HEATS)]; + const hoardBase = HOARDS.indexOf(HEAT_TO_HOARD[a.heat] ?? 'cache'); + a.hoard = HOARDS[clamp(hoardBase + (GREED_SHIFT[greed] ?? 0), HOARDS)]; + } + + // Treasure is not only behind fights: lean delves still need something to want. + const treasureless = plan.filter(a => !a.hoard); + if (greed !== 'lean' && treasureless.length) { + const bonus = rng.derive('greed').pick(treasureless.filter(a => a.role !== 'approach')) ?? treasureless[0]; + if (bonus) bonus.hoard = HOARDS[clamp(HOARDS.indexOf('pocket') + (GREED_SHIFT[greed] ?? 0), HOARDS)]; + } + + return { + areas: plan, + ending: { + index: areas + 1, + role: 'ending', + mode: ending, + // Draft 5 §2: authored is a default, not a law. Generated endings get a real heat. + heat: ending === 'generated' ? HEATS[clamp(heatIdx + 1, HEATS)] : null, + hoard: ending === 'generated' ? HOARDS[clamp(HOARDS.indexOf('vault') + (GREED_SHIFT[greed] ?? 0), HOARDS)] : null, + }, + budget: { plannedFights: chosen.size, heatBase: HEATS[heatIdx], wantFights }, + }; +} + +/** + * Which Bane-banking beat an area should offer, given how the tab is tracking. + * Playtest 1: the tab reached 1 of 6 because nothing emitted Banes deliberately. + */ +export function baneBeatFor(area, pressure, rng) { + const pace = pressure.onPace(area.index); + const pool = area.quiet + ? ['noticing', 'announcing'] + : ['flattery', 'noticing', 'relicUse', 'refusal']; + // Behind pace → always offer one. On pace → sometimes, so it does not feel mechanical. + if (!pace.behind && !rng.chance(0.5)) return null; + return rng.pick(pool); +} diff --git a/foundry-module/module/core/pressure.mjs b/foundry-module/module/core/pressure.mjs new file mode 100644 index 0000000..9c9e6eb --- /dev/null +++ b/foundry-module/module/core/pressure.mjs @@ -0,0 +1,130 @@ +/** + * Pressure: the clock and the tab. Draft 5 §9. + * + * Two engines, both rebuilt after playtest 1 falsified the originals: + * - The clock fired 0 times in 4 rolls on a 3-Turn timer; the attention trigger fired on the + * first roll of pass 2. So triggers lead and the timer is a floor. + * - The tab banked 1 Bane in ~40 rolls, because Stumbles are rare. So Banes are *emitted* by + * named beats, never hoped for. + * + * This module NEVER rolls a die. Draft 5 §11 puts the clock in the "live" column: the core + * decides *when* a roll is due and what it means; the adapter rolls it at the table. Keeping + * generation and table dice apart is the whole reason the seed promise is honest. + */ + +export const CLOCK_SETTINGS = { + slow: { period: 4, trigger: 1 }, + standard: { period: 3, trigger: 1 }, + hunted: { period: 2, trigger: 1 }, +}; + +/** Attention triggers. Each area must emit at least one. */ +export const TRIGGERS = { + announced: 'entered somewhere grandly, announcing yourself', + seenTwice: 'reflected, echoed or witnessed twice over', + loud: 'a raised voice, a broken door, a shout', + lingering: 'stayed too long in one place', + disturbed: 'struck or moved something that had not moved in years', +}; + +/** Bane sources. The tab's engine — deliberate, not incidental. */ +export const BANE_SOURCES = { + flattery: 'played to something’s vanity to be received', + noticing: 'caught your own reflection and looked', + relicUse: 'spent a relic’s favour', + announcing: 'announced yourself rather than slipping in', + refusal: 'refused an offer that would have cost you nothing yet', + push: 'pushed a roll', + stumble: 'stumbled — a failure with two or more ones', +}; + +export class Pressure { + /** + * @param {object} opts + * @param {number} opts.areas area count (excluding the ending slot) + * @param {string} opts.clock 'slow' | 'standard' | 'hunted' + * @param {number} opts.baneTarget Banes wanted by the ending (draft 5 says 4–6; UNCALIBRATED — + * playtest 2 reached 3. Treated as a target, not a promise.) + */ + constructor({ areas = 6, clock = 'standard', baneTarget = 5 } = {}) { + this.settings = CLOCK_SETTINGS[clock] ?? CLOCK_SETTINGS.standard; + this.areas = areas; + this.baneTarget = baneTarget; + this.turn = 0; + this.lastClockTurn = 0; + this.clockRolls = []; // { turn, cause, die, fired } + this.tab = []; // { hero, source, area, note } + this.escalated = false; + } + + // ---- time ------------------------------------------------------------- + + advanceTurn(n = 1) { this.turn += n; return this.turn; } + + /** True when the timer has come round. Triggers bypass this entirely. */ + timerDue() { return this.turn - this.lastClockTurn >= this.settings.period; } + + /** One Turn before the timer is due — the GM voices a cue. */ + telegraphDue() { return this.turn - this.lastClockTurn === this.settings.period - 1; } + + /** + * Escalation: if the clock has not fired by the halfway area, widen the trigger number. + * Playtest 1 rolled 1-in-6 four times and never fired; a clock that never bites is decoration. + */ + escalate(areaIndex) { + const halfway = Math.ceil(this.areas / 2); + if (!this.escalated && areaIndex >= halfway && !this.clockRolls.some(r => r.fired)) { + this.escalated = true; + this.settings = { ...this.settings, trigger: this.settings.trigger + 1 }; + } + return this.escalated; + } + + /** What number the die must come up at or under for "something comes". */ + get triggerNumber() { return this.settings.trigger; } + + /** + * Record a clock roll. The die is supplied by the caller — a real table roll. + * @param {number} die 1..6, rolled at the table + * @param {string} cause 'timer' or a TRIGGERS key + */ + recordClock(die, cause = 'timer') { + const fired = die <= this.settings.trigger; + this.clockRolls.push({ turn: this.turn, cause, die, fired }); + this.lastClockTurn = this.turn; + return fired; + } + + // ---- the tab ---------------------------------------------------------- + + bankBane(hero, source, area = null, note = '') { + if (!(source in BANE_SOURCES)) throw new Error(`unknown Bane source: ${source}`); + this.tab.push({ hero, source, area, note }); + return this.tabFor(hero); + } + + tabFor(hero) { return this.tab.filter(b => b.hero === hero).length; } + tabTotal() { return this.tab.length; } + reckoningDue(hero) { return this.tabFor(hero) >= 6; } + + /** + * Is the tab on pace? Used by the director to decide whether an area should offer another + * Bane-banking beat. Without this the Reckoning never arrives — playtest 1's central failure. + */ + onPace(areaIndex) { + const expected = (this.baneTarget * areaIndex) / Math.max(this.areas, 1); + return { expected: Math.round(expected * 10) / 10, actual: this.tabTotal(), behind: this.tabTotal() < expected }; + } + + summary() { + return { + turn: this.turn, + clockRolls: this.clockRolls.length, + clockFired: this.clockRolls.filter(r => r.fired).length, + triggerNumber: this.triggerNumber, + escalated: this.escalated, + tab: this.tabTotal(), + target: this.baneTarget, + }; + } +} diff --git a/foundry-module/module/core/render-authoring.mjs b/foundry-module/module/core/render-authoring.mjs new file mode 100644 index 0000000..6cb97bb --- /dev/null +++ b/foundry-module/module/core/render-authoring.mjs @@ -0,0 +1,163 @@ +/** + * Two renders of one delve. + * + * WORKSHEET is for the desk: everything the generator knows, labelled, with the reroll commands + * beside each component and an explicit prompt where only a human can write. + * + * PLAY is for the table: the GM's own prose, the live leverage point, the decision on one line, + * the numbers. Nothing else. Four reviews said the same thing — "the handwritten delve tells the + * GM what matters faster" — and the only way to be fast is to carry less. + */ +import { outstanding, isLocked, REROLLABLE } from './authoring.mjs'; + +const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s); +const cost = c => { + if (!c) return 'free'; + const b = []; + if (c.bane) b.push(`+${c.bane} Bane${c.bane > 1 ? 's' : ''}`); + if (c.vice) b.push(`the Vice ${c.vice}`); + return b.length ? b.join(', ') : 'free'; +}; +const au = (d, i) => d.authored?.areas?.[i] ?? {}; + +/* ------------------------------------------------------------- worksheet */ + +export function renderWorksheet(d) { + const L = []; + const sk = d.skeleton, ap = sk.appeasement; + const todo = outstanding(d); + + L.push(`# WORKSHEET — ${d.authored?.title ?? sk.placeName}`); + L.push(''); + L.push(`\`seed ${d.seed} · ${d.params.theme} · ${d.params.areas} areas · depth ${d.params.depth} · generator ${d.generatorVersion}\``); + L.push(''); + L.push(todo.length + ? `**${todo.length} things still need you.** This is a first draft, not a play document. Finish it, then export the play sheet.` + : `**Finished.** Export the play sheet.`); + L.push(''); + if (todo.length) { for (const t of todo) L.push(`- [ ] **${t.where}** — ${t.what}`); L.push(''); } + L.push('---'); + L.push(''); + L.push('## The kernel'); + L.push(''); + L.push(`| | |`); + L.push(`|---|---|`); + L.push(`| **Transgression** | ${sk.knot.transgression} |`); + L.push(`| **Bottom problem** | ${sk.bottomProblem.label} — ${sk.bottomProblem.failureState} |`); + L.push(`| **Prize** | ${sk.prize.label} — ${sk.prize.function} |`); + L.push(`| **Motif / danger** | ${sk.motif.id} · ${sk.motif.danger} — ${sk.motif.dangerLine} |`); + L.push(`| **Appeasement** | ${ap.move} \`[${ap.attribute} ${ap.successes}]\` — +${ap.gain.vanity} Vanity, +${ap.cost.bane} Bane, repeatable |`); + L.push(`| **It asks** | ${d.ending.question} |`); + L.push(''); + L.push(`**WRITE ▸ an opening paragraph.** ${d.authored?.intro ? '✔ written' : '_(nothing yet — set `authored.intro`)_'}`); + if (d.authored?.intro) { L.push(''); L.push(`> ${d.authored.intro}`); } + L.push(''); + L.push('---'); + L.push(''); + + for (const a of d.areas) { + const w = au(d, a.index); + const locks = (w.locked ?? []); + const rerollable = REROLLABLE.filter(c => !locks.includes(c) && (c !== 'temptation' || a.hoard)); + L.push(`## Area ${a.index} — ${w.nameOverride ?? a.name}`); + L.push(`\`${a.role} · ${a.facet}${a.encounter ? ` · ${a.encounter.heat}` : ''}${a.hoard ? ` · ${a.hoard}` : ''}\``); + L.push(`*reroll:* ${rerollable.map(c => `\`--reroll=${a.index}:${c}\``).join(' ')}${locks.length ? ` · *locked:* ${locks.join(', ')}` : ''}`); + L.push(''); + L.push(`**IMAGE** ${a.cueFragments.join(' · ')}`); + if (a.situation) { + L.push(`**SITUATION** ${cap(a.situation.occupant)} — ${a.situation.doing}. On arrival, ${a.situation.onArrival}.`); + L.push(`**WHY** ${cap(a.situation.because)}. **OFFERS** ${a.situation.offer}.`); + } + L.push(`**DECISION** ${cap(a.decision.cue)} — *${a.decision.cost}*`); + const rv = a.decision.resolve ?? {}; + if (rv.roll) L.push(` · \`[${rv.roll}]\` → ${rv.success}`); + if (rv.failure) L.push(` · **miss** → ${rv.failure}`); + if (rv.orElse) L.push(` · **or** ${rv.orElse}`); + if (a.encounter?.roster) { + const R = a.encounter.roster; + L.push(`**ENCOUNTER** ${R.line} — ${R.foes.map(f => `${f.n}× ${f.name} atk${f.atk}/def${f.def}/G${f.grit}`).join(', ')}`); + L.push(` · harmed by ${R.harmedBy} · ${R.avoid}`); + } + if (a.temptation) { + const t = a.temptation; + L.push(`**TEMPTATION** ${cap(t.id)} — ${t.cue}: ${t.benefit}`); + L.push(` · take ${cost(t.acceptanceCost)} · use ${cost(t.useCost)} · standing: ${t.standingDrawback}`); + } + L.push(`**PRESSURE** trigger ${a.trigger}${a.baneBeat ? ` · bane beat ${a.baneBeat}` : ''} · fallback ${a.fallback.route}`); + L.push(''); + L.push(`**WRITE ▸ read-aloud** — ≤3 sentences, built from the IMAGE and SITUATION above.`); + L.push(w.readAloud ? `> ${w.readAloud}` : `> _(nothing yet)_`); + if (w.notes) { L.push(''); L.push(`**Notes.** ${w.notes}`); } + L.push(''); + L.push('---'); + L.push(''); + } + + const we = d.authored?.ending ?? {}; + L.push(`## Ending — ${d.ending.name}`); + L.push(d.ending.authored ? '`AUTHORED SLOT — DELVE stops here on purpose`' : '`generated`'); + L.push(''); + L.push(`**IMAGE** ${d.ending.cueFragments.join(' · ')}`); + L.push(`**IT WANTS** ${sk.bottomProblem.wantNow}. **IT ASKS** ${d.ending.question}`); + L.push(`**APPEASED EVEN ONCE** it listens, and will trade ${sk.prize.label}.`); + L.push(''); + L.push('**WRITE ▸ read-aloud**'); + L.push(we.readAloud ? `> ${we.readAloud}` : `> _(nothing yet)_`); + L.push(''); + L.push('**WRITE ▸ the climax.** How it goes, what harms it, what it will take instead of a fight.'); + L.push(we.notes ? we.notes : `_(nothing yet — this is the seam; DELVE will not write it for you)_`); + L.push(''); + return L.join('\n'); +} + +/* ------------------------------------------------------------ play sheet */ + +export function renderPlay(d) { + const L = []; + const sk = d.skeleton, ap = sk.appeasement; + const todo = outstanding(d); + + L.push(`# ${d.authored?.title ?? sk.placeName}`); + L.push(''); + if (todo.length) { + L.push(`> ⚠ **${todo.length} unfinished** — ${todo.slice(0, 3).map(t => t.where).join(', ')}${todo.length > 3 ? '…' : ''}.`); + L.push('> Fragments are shown where nothing was written. Run the worksheet first.'); + L.push(''); + } + if (d.authored?.intro) { L.push(`> ${d.authored.intro}`); L.push(''); } + L.push(`**Appease:** ${ap.move} \`[${ap.attribute} ${ap.successes}]\` — +1 Vanity now, +1 Bane later. Repeatable, every area.`); + L.push(`**Clock:** 1d6 on a trigger. **1 — something comes.** **Tab:** ${d.pressure.target} by the end.`); + L.push(`**GM truth:** ${sk.knot.transgression} ${sk.bottomProblem.label} ${sk.bottomProblem.failureState}.`); + L.push(''); + L.push('---'); + L.push(''); + + for (const a of d.areas) { + const w = au(d, a.index); + const rv = a.decision.resolve ?? {}; + L.push(`### ${a.index} · ${w.nameOverride ?? a.name}`); + L.push(''); + L.push(w.readAloud ? `> ${w.readAloud}` : `> *(unwritten)* ${a.cueFragments.join('. ')}.`); + L.push(''); + if (a.situation) L.push(`**Here:** ${cap(a.situation.occupant)}, ${a.situation.doing} — ${a.situation.onArrival}. **They can:** ${a.situation.offer}.`); + L.push(`**${cap(a.decision.cue)}** — ${rv.roll ? `\`[${rv.roll}]\` ${rv.success}` : ''}${rv.failure ? ` · **miss** ${rv.failure}` : ''}${rv.orElse ? ` · **or** ${rv.orElse}` : ''}`); + if (a.encounter?.roster) { + const R = a.encounter.roster; + L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`); + } + if (a.temptation) L.push(`**${cap(a.temptation.id)}:** ${a.temptation.benefit}. *Use: ${cost(a.temptation.useCost)}. ${a.temptation.standingDrawback}.*`); + if (w.notes) L.push(`**Note:** ${w.notes}`); + L.push(`\`${a.trigger}${a.baneBeat ? ` · ${a.baneBeat}` : ''} · skipped → move the image on\``); + L.push(''); + } + + const we = d.authored?.ending ?? {}; + L.push(`### ${d.ending.name}`); + L.push(''); + L.push(we.readAloud ? `> ${we.readAloud}` : `> *(unwritten)* ${d.ending.cueFragments.join('. ')}.`); + L.push(''); + L.push(`**It asks:** ${d.ending.question}`); + L.push(we.notes ? we.notes : `*(the climax is unwritten — this is the authored slot)*`); + L.push(''); + return L.join('\n'); +} diff --git a/foundry-module/module/core/render.mjs b/foundry-module/module/core/render.mjs new file mode 100644 index 0000000..4b5a596 --- /dev/null +++ b/foundry-module/module/core/render.mjs @@ -0,0 +1,170 @@ +/** Render a delve to GM-readable markdown, in the house style. */ +import { skeletonBlurb } from './skeleton.mjs'; + +const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s); + +/** The appeasement, in the compact form used inside an area. */ +const skeletonAppeasement = d => `${d.skeleton.appeasement.attribute} ${d.skeleton.appeasement.successes}: ${d.skeleton.appeasement.move}`; + +/** Costs read as English in a GM document, never as JSON. */ +const cost = c => { + if (!c) return 'free'; + const bits = []; + if (c.bane) bits.push(`**+${c.bane} Bane${c.bane > 1 ? 's' : ''}** on the tab`); + if (c.vice) bits.push(`take the Vice **${c.vice}**`); + if (c.vanity) bits.push(`**+${c.vanity} Vanity**`); + return bits.length ? bits.join(', ') : 'free'; +}; + +export function renderMarkdown(d) { + const L = []; + const sk = d.skeleton; + + L.push(`# ${sk.placeName.toUpperCase()}`); + L.push(''); + L.push(`> *${sk.motif.dangerLine}.*`); + L.push(''); + L.push(`**VANITY · ${cap(d.params.theme)} · Depth ${d.params.depth} · ${d.params.party} heroes · ` + + `${d.params.areas} areas + ${d.ending.authored ? 'an authored ending' : 'a generated ending'}**`); + L.push(`**Seed \`${d.seed}\` · generator ${d.generatorVersion} · catalog ${d.catalogVersion}**`); + L.push(''); + L.push('> **Skeleton seed, not a replay seed.** It reproduces the fiction, the area order, the'); + L.push('> heat and the hoard tiers. It does **not** reproduce encounter composition, mood or'); + L.push('> hoard contents — those come from the Forge, which is not seeded.'); + L.push(''); + L.push('---'); + L.push(''); + L.push('## The truth (GM only)'); + L.push(''); + L.push(skeletonBlurb(sk)); + L.push(''); + L.push(`**They will ask you:** ${d.ending.question}`); + L.push(''); + L.push('## Pressure'); + L.push(''); + L.push(`- **The clock.** Roll 1d6 on a trigger, or every ${d.pressure.triggerNumber === 1 ? '3' : '3'} Turns. ` + + `**${d.pressure.triggerNumber} or under — something comes.** Telegraph one Turn ahead.`); + L.push(`- **Triggers this delve:** ${[...new Set(d.areas.map(a => a.trigger))].join(' · ')}`); + L.push(`- **The tab.** Target **${d.pressure.target} Banes** by the ending. Banes are *offered by named beats*, never hoped for.`); + L.push(`- **The danger is ${sk.motif.danger}:** ${sk.motif.dangerLine}.`); + L.push(''); + const ap = sk.appeasement; + L.push('## The way through — the appeasement'); + L.push(''); + L.push(`Anyone may **${ap.move}**. \`[VANITY: ${ap.attribute} — ${ap.successes} successes]\``); + L.push(`On success ${ap.why}, and ${sk.factions[0]?.name} will not open hostilities first.`); + L.push(`**Cost: +${ap.gain.vanity} Vanity now, +${ap.cost.bane} Bane on the tab.** Shine now, bill later.`); + L.push(`**Repeatable in every area.** Say the Bane aloud when it banks — the table must be able to see the route is open.`); + L.push(''); + L.push('## Factions'); + L.push(''); + for (const f of sk.factions) L.push(`- **${f.name}** (${f.kind}) — ${f.essence}. Handled by *${f.handledBy}*.`); + L.push(''); + L.push(`**Planned fights:** ${d.budget.plannedFights} of ${d.params.areas} areas · base heat *${d.budget.heatBase}*`); + L.push(''); + L.push('---'); + L.push(''); + + for (const a of d.areas) { + const S = a.situation; + const rv = a.decision?.resolve ?? {}; + const R = a.encounter?.roster; + + L.push(`### AREA ${a.index} — ${a.name}`); + L.push(`*${a.role} · ${a.facet}${a.encounter ? ` · ${a.encounter.heat}` : ''}${a.hoard ? ` · ${a.hoard}` : ''}*`); + L.push(''); + + // ONE opening: image, situation and the automatic clue as a single thing to read aloud from. + // Previously these were three stacked blocks; the review's word for the whole document was + // "integration" — image, situation, pressure and choice reading as one, not as components. + const opening = [ + a.cueFragments.map(c => cap(c)).join('. ') + '.', + S ? `${cap(S.occupant)} — ${S.doing}. When you walk in, ${S.onArrival}.` : '', + a.clue.automatic ? `None of it is accident — it was done this way on purpose.` : '', + ].filter(Boolean).join(' '); + L.push(`> ${opening}`); + L.push(''); + + const gm = [ + S ? `${cap(S.because)}.` : '', + a.truth ? `${a.truth.replace(/\.?$/, '.')}` : '', + ].filter(Boolean).join(' '); + L.push(`**GM.** ${gm}`); + if (S) L.push(`**They can:** ${S.offer}. \`[${skeletonAppeasement(d)}]\` also works here, as everywhere.`); + L.push(`**Looking closer:** ${a.clue.extra.replace(/^\[VANITY: /, '`[').replace(/\]/, ']`')}`); + L.push(''); + + // The decision on one line where it can be: attempt → hit → miss → or. + L.push(`**${cap(a.decision.cue)}.** *${a.decision.cost}.*`); + if (rv.roll) { + L.push(`\`[${rv.roll}]\` → ${rv.success}`); + L.push(`**Miss** → ${rv.failure}`); + } + if (rv.orElse) L.push(`**Or** ${rv.orElse}.`); + L.push(''); + + if (R) { + L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}` + + `${R.harmedBy.includes('ONLY') ? ' — **say so before initiative**' : ''}. *${R.avoid}.*`); + L.push(''); + L.push('| Foe | atk | def | Grit | Nerve | |'); + L.push('|---|---|---|---|---|---|'); + for (const f of R.foes) L.push(`| ${f.n}× ${f.name} | ${f.atk} | ${f.def} | ${f.grit} | ${f.nerve} | ${f.note} |`); + L.push(''); + } + + if (a.temptation) { + const t = a.temptation; + L.push(`**${cap(a.hoard)} hoard.** **${cap(t.id)}** — ${t.cue}: ${t.benefit}.`); + L.push(`*Take it ${cost(t.acceptanceCost)}; using it costs ${cost(t.useCost)}; while you carry it, ${t.standingDrawback}.*`); + L.push(''); + } else if (a.hoard) { + L.push(`**${cap(a.hoard)} hoard.**`); + L.push(''); + } + + // Everything the GM needs but never reads aloud, on one line. + const beat = a.baneBeat ? ` · bane beat: ${a.baneBeat}` : ''; + L.push(`\`trigger: ${a.trigger}${beat} · fallback: ${a.fallback.route} (${a.fallback.cost}) · if skipped: ${a.failureCase.replace(/^If they skip this, /, '')}\``); + L.push(''); + L.push('---'); + L.push(''); + } + + L.push(`### ENDING — ${d.ending.name}`); + L.push(d.ending.authored ? '### ⟨ AUTHORED SLOT — this is the seam ⟩' : `*generated · heat ${d.ending.heat} · hoard ${d.ending.hoard}*`); + L.push(''); + if (d.ending.authored) { + L.push('**Areas 1–' + d.params.areas + ' are what DELVE generates. This is what it hands over.**'); + L.push(''); + } + L.push('**Cue** — *fragments*'); + for (const c of d.ending.cueFragments) L.push(`- ${c}`); + L.push(''); + L.push(`**${sk.bottomProblem.label}** — ${sk.bottomProblem.failureState}. Wants: *${sk.bottomProblem.wantNow}*.`); + L.push(`Handled by ${sk.bottomProblem.handledBy}.`); + L.push(''); + L.push(`**They open by asking:** ${d.ending.question}`); + L.push(''); + L.push('| Party\'s route | The audience |'); + L.push('|---|---|'); + L.push(`| Appeased it even once (**${ap.move}**) | It listens. It will trade ${sk.prize.label} for the real thing, done to its face. \`[VANITY: ${ap.attribute} — ${ap.successes}]\` or take **Rattled**. |`); + L.push('| Refused throughout, tab under 4 | It offers a Vice — take the shine, bank the bill. Refusing starts the fight. |'); + L.push(`| Carrying ${sk.prize.label} | It wants that more than it wants you. |`); + L.push('| Fighting | Winnable by two engaged heroes. **Name what harms it before initiative.** |'); + L.push(''); + L.push('> **Before initiative, always say what can hurt it.** Playtest 1 was unwinnable because'); + L.push('> the table discovered the immunity by failing.'); + L.push(''); + const g = d.triggerGlossary ?? {}; + if (Object.keys(g).length) { + L.push('## Trigger glossary'); + L.push(''); + for (const [k, v] of Object.entries(g)) L.push(`- **${k}** — ${v}`); + L.push(''); + } + L.push('---'); + L.push(''); + L.push('*Private VANITY development material — not for sale or distribution.*'); + return L.join('\n'); +} diff --git a/foundry-module/module/core/rng.mjs b/foundry-module/module/core/rng.mjs new file mode 100644 index 0000000..052ef19 --- /dev/null +++ b/foundry-module/module/core/rng.mjs @@ -0,0 +1,110 @@ +/** + * Seeded randomness with derived streams. + * + * Draft 5 §11: a delve is reproducible from its skeleton seed. That only holds if every + * component draws from its *own* stream — otherwise materialising areas in a different order + * changes every subsequent draw. So each component derives a named sub-seed: + * + * seed::skeleton + * seed::area::a3::feature + * seed::area::a3::encounter + * + * Two rules make this work: + * 1. Never share an Rng between components. + * 2. Never let draw *count* in one component affect another. + */ + +/** xmur3: string → 32-bit seed. */ +function xmur3(str) { + let h = 1779033703 ^ str.length; + for (let i = 0; i < str.length; i++) { + h = Math.imul(h ^ str.charCodeAt(i), 3432918353); + h = (h << 13) | (h >>> 19); + } + return () => { + h = Math.imul(h ^ (h >>> 16), 2246822507); + h = Math.imul(h ^ (h >>> 13), 3266489909); + return (h ^= h >>> 16) >>> 0; + }; +} + +/** sfc32: small, fast, good enough for content generation. */ +function sfc32(a, b, c, d) { + return () => { + a >>>= 0; b >>>= 0; c >>>= 0; d >>>= 0; + let t = (a + b) | 0; + a = b ^ (b >>> 9); + b = (c + (c << 3)) | 0; + c = (c << 21) | (c >>> 11); + d = (d + 1) | 0; + t = (t + d) | 0; + c = (c + t) | 0; + return (t >>> 0) / 4294967296; + }; +} + +export class Rng { + constructor(seedString) { + this.seed = String(seedString); + const h = xmur3(this.seed); + this._next = sfc32(h(), h(), h(), h()); + this.draws = 0; + } + + /** A new independent stream. Order-independent by construction. */ + derive(...parts) { + return new Rng(`${this.seed}::${parts.join('::')}`); + } + + float() { this.draws++; return this._next(); } + + /** Integer in [0, n). */ + int(n) { return Math.floor(this.float() * n); } + + /** Integer in [min, max] inclusive. */ + between(min, max) { return min + this.int(max - min + 1); } + + chance(p) { return this.float() < p; } + + pick(arr) { + if (!Array.isArray(arr) || arr.length === 0) return undefined; + return arr[this.int(arr.length)]; + } + + /** n distinct items, or as many as exist. Never returns undefined holes. */ + pickN(arr, n) { + const pool = [...(arr ?? [])]; + const out = []; + while (n-- > 0 && pool.length) out.push(pool.splice(this.int(pool.length), 1)[0]); + return out; + } + + shuffle(arr) { + const a = [...(arr ?? [])]; + for (let i = a.length - 1; i > 0; i--) { + const j = this.int(i + 1); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; + } + + /** Weighted pick from {key: weight} or [[value, weight], ...]. */ + weighted(spec) { + const entries = Array.isArray(spec) ? spec : Object.entries(spec); + const total = entries.reduce((s, [, w]) => s + w, 0); + if (total <= 0) return undefined; + let r = this.float() * total; + for (const [value, w] of entries) { r -= w; if (r < 0) return value; } + return entries[entries.length - 1][0]; + } + + /** d6 pool; VANITY counts 5–6 as Successes. Used for generation only, never table dice. */ + pool(n) { return Array.from({ length: n }, () => this.between(1, 6)); } +} + +/** A random, human-sayable seed — two words and a number. */ +export function coinSeed(rng = new Rng(String(Date.now()))) { + const a = ['gilded', 'hollow', 'salt', 'ember', 'quiet', 'crooked', 'pale', 'brazen']; + const b = ['court', 'crown', 'mirror', 'vigil', 'debt', 'mask', 'hymn', 'tally']; + return `${rng.pick(a)}-${rng.pick(b)}-${rng.between(100, 999)}`; +} diff --git a/foundry-module/module/core/skeleton.mjs b/foundry-module/module/core/skeleton.mjs new file mode 100644 index 0000000..28e7ebe --- /dev/null +++ b/foundry-module/module/core/skeleton.mjs @@ -0,0 +1,134 @@ +/** + * The skeleton: the fiction, generated before any area exists. Draft 5 §1. + * + * The hard problem this solves is coherence. Four independent draws — a crown, a flood, goblins, + * mirrors — produce nonsense. So there is exactly ONE root draw, and everything else is projected + * from it: + * + * APPETITE ──> motif ──> danger, trigger, every cue fragment + * │ ──> prize kind + * │ ──> who was conscripted + * └─ + ACCOMMODATION ──> the transgression, the bottom problem, the primary faction + * + * Content therefore scales additively, not combinatorially: 8 appetites × 6 accommodations × + * 7 claimants = 336 kernels from 21 authored lines. + * + * The foreshadow chain is a fixed FACET progression mapped onto the arc. Area 2's held bows + * (ritual) and area 4's scratched-out eyes (wound) point at the same faceless queen because both + * are facets of one motif, drawn in a deliberate order. That progression was reverse-engineered + * from the hand-authored paper delve, which had arrived at it by instinct. + */ + +import { arcRole } from './director.mjs'; + +/** Fills {conscript}-style slots. */ +const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`); + +/** Facet order used when a facet is exhausted — borrow from a neighbour before repeating. */ +const FACET_ORDER = ['institution', 'ritual', 'demand', 'wound', 'anchor']; + +/** + * Draw an unused fragment for a facet. Light motifs carry only one fragment per facet, so + * without borrowing, two areas sharing a role would print the same image twice. + */ +function drawFragment(motif, facet, used, rng) { + const order = [facet, ...FACET_ORDER.filter(f => f !== facet)]; + for (const f of order) { + const fresh = (motif.facets?.[f] ?? []).filter(x => !used.has(x)); + if (fresh.length) { const pick = rng.pick(fresh); used.add(pick); return pick; } + } + return null; +} + +export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng }) { + if (!pack) throw new Error('generateSkeleton requires a theme pack'); + if (!rng) throw new Error('generateSkeleton requires an Rng (seeded)'); + + const r = rng.derive('skeleton'); + + // ---- the one root draw ------------------------------------------------- + const appetite = r.derive('appetite').pick(pack.appetites); + // Only accommodations that make sense for this appetite. Drawing freely produced lines like + // "had the faces struck from the young, so that nothing would ever age" — equation output. + const fitting = pack.accommodations.filter(a => !a.fits || a.fits.includes(appetite.id)); + const accommodation = r.derive('accommodation').pick(fitting.length ? fitting : pack.accommodations); + const claimant = r.derive('claimant').pick(pack.claimants); + + // ---- everything else is projected -------------------------------------- + const motifId = appetite.motif; + const motif = pack.motifs[motifId]; + if (!motif) throw new Error(`theme pack has no motif "${motifId}"`); + + const prize = { kind: appetite.prize, ...(pack.prizes?.[appetite.prize] ?? {}) }; + + const transgression = + `The ${claimant} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` + + `so that ${appetite.purpose}.`; + + const bottomProblem = { + label: `The ${claimant}`, + wantNow: appetite.want, + failureState: `is ${appetite.lack} now, and cannot bear it`, + handledBy: 'giving them what they want, at a price', + }; + + // Primary faction is whoever was conscripted; a secondary is drawn for friction. + const primary = { + ...(pack.factions.find(f => f.handledBy === 'courtesy') ?? pack.factions[0]), + conscriptedAs: appetite.conscript, + }; + const secondary = r.derive('faction2').pick(pack.factions.filter(f => f.id !== primary.id)); + + // Named for its occupant. Drawing the name separately produced tombs named after strangers. + const placeName = `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`; + + // ---- the foreshadow chain ---------------------------------------------- + const progression = pack.facetProgression ?? {}; + const used = new Set(); + const foreshadow = []; + for (let i = 1; i <= areas; i++) { + const role = arcRole(i, areas); + const facet = progression[role] ?? 'institution'; + const fragment = drawFragment(motif, facet, used, r.derive('foreshadow', String(i))); + if (fragment) used.add(fragment); + foreshadow.push({ index: i, role, facet, fragment: fragment ?? null }); + } + if (ending === 'authored' || ending === 'generated') { + const facet = progression.ending ?? 'demand'; + foreshadow.push({ + index: areas + 1, role: 'ending', facet, + fragment: drawFragment(motif, facet, used, r.derive('foreshadow', 'ending')), + }); + } + + return { + placeName, + knot: { + appetiteId: appetite.id, + accommodationId: accommodation.id, + claimant, + conscript: appetite.conscript, + transgression, + cruelty: accommodation.cruelty, + }, + motif: { id: motifId, danger: motif.danger, dangerLine: motif.dangerLine, trigger: motif.trigger }, + prize, + bottomProblem, + // The appeasement move is the delve's social spine: feed the appetite, pay the tab. + // It projects from the appetite, which is why it is always thematically exact. + appeasement: { ...appetite.appeasement, faction: primary.name }, + factions: [primary, secondary].filter(Boolean), + foreshadow, + }; +} + +/** A one-paragraph GM-truth summary — the thing a GM reads first. */ +export function skeletonBlurb(sk) { + return [ + `**${sk.placeName}.** ${sk.knot.transgression}`, + `${sk.bottomProblem.label} ${sk.bottomProblem.failureState}.`, + `The prize is ${sk.prize.label} — ${sk.prize.function}.`, + `${sk.factions[0]?.name} are ${sk.factions[0]?.essence}; they are handled by ${sk.factions[0]?.handledBy}.`, + `The motif is **${sk.motif.id}**, and the danger is ${sk.motif.danger}: ${sk.motif.dangerLine}.`, + ].join(' '); +}