Make the module installable: vendor core, point at release assets
What was on git could not be installed. foundry-module/ referenced core/ rather than containing it, so an installed copy had no generator at all — it only ever worked here because the live module directory had core/ copied in by hand. core/ is now vendored into foundry-module/module/core/, minus cli.mjs and test.mjs which are desk tools. module.json points manifest and download at release assets, the way mapwright distributes, so a remote server can install by URL without reaching into the repo tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
node_modules/
|
||||
*.log
|
||||
core/*.json
|
||||
!core/content/*.json
|
||||
|
||||
-535
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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?"';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user