The Forge: one button, a whole dungeon in the world
The brief was a tool in Foundry that generates everything on a click and injects
it as a new dungeon. Previously the module could only stage a delve authored at
the desk, and the only way in was a console command — an API with a button glued
on, which is why it looked like nothing was there.
The ⛏ button now opens a form (theme, areas, depth, heroes, deadliness, density,
greed, clock, ending, seed, and whether to build maps and roll encounters), and
Raise the dungeon builds the lot in about five seconds:
• a folder per document type, named for the dungeon
• one scene per area, walls and lighting placed, via the VANITY Forge
• encounter actors, foldered rather than scattered across the world
• a JournalEntry — GM truth, a page per area, the ending — which is the
adventure, and is written to be read cold
The journal is the deliverable. Scenes without it are a pile of maps.
Generating is cheap, so removing had to be. Every document DELVE creates is
stamped with a dungeonId, and a 🗑 button lists what it built and deletes one
dungeon entirely — confirming with exact counts first, and never deleting the
scene the GM is standing in. Colour is not an identifier: it is a Color object,
and a GM may recolour a folder.
Verified live: two dungeons raised from the form and the API, then one removed
cleanly at -3 scenes, -10 actors, -1 journal, -3 folders with nothing else
touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+535
@@ -0,0 +1,535 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# DELVE — Foundry module
|
||||
|
||||
Install by copying this directory to `Data/modules/vanity-delve/`, then copying the generator
|
||||
into it:
|
||||
|
||||
cp -R ../core foundry-module/module/core
|
||||
|
||||
`module/core/` is a verbatim copy of `core/`. It is not vendored in git because the standalone
|
||||
generator is the source of truth — if the copy ever needs editing to run inside Foundry, the
|
||||
portability claim in DESIGN.md §4 is false.
|
||||
|
||||
## Using it
|
||||
|
||||
The ⛏ button in the token controls opens the Forge. Set the parameters, press **Raise the
|
||||
dungeon**, and it builds — in one folder per document type:
|
||||
|
||||
* one scene per area, walls and lighting placed, via the VANITY Forge
|
||||
* encounter actors, foldered rather than scattered
|
||||
* a JournalEntry: GM truth, one page per area, the ending — this is the adventure
|
||||
|
||||
Every document is stamped with a `dungeonId`, so the 🗑 button removes a whole dungeon and
|
||||
nothing else.
|
||||
|
||||
Requires the VANITY system **0.10.5+** for quiet output; on older versions the Forge posts its
|
||||
own cards over DELVE's and the module says so on startup.
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* The DELVE Forge — one button, a form, and a complete dungeon injected into the world.
|
||||
*
|
||||
* Everything the generator knows becomes real Foundry content in one pass:
|
||||
* • a folder per dungeon, holding its scenes, actors and journal
|
||||
* • one scene per area, walls and lighting placed, raised via the VANITY Forge
|
||||
* • encounter actors, in that folder rather than scattered
|
||||
* • a JournalEntry — GM truth, one page per area, the ending — which IS the adventure
|
||||
*
|
||||
* The journal is the deliverable. Scenes without it are a pile of maps; the journal is what a
|
||||
* GM actually reads, and it is written so it can be read cold.
|
||||
*/
|
||||
import { generateDelve } from './core/delve.mjs';
|
||||
import { coinSeed, Rng } from './core/rng.mjs';
|
||||
|
||||
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
|
||||
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||
const esc = s => foundry.utils.escapeHTML?.(String(s ?? '')) ?? String(s ?? '');
|
||||
|
||||
const MOD = 'vanity-delve';
|
||||
const THEMES = ['barrow'];
|
||||
const PICKS = {
|
||||
depth: [1, 2, 3, 4, 5],
|
||||
party: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
deadliness: ['forgiving', 'standard', 'cruel'],
|
||||
density: ['sparse', 'standard', 'infested'],
|
||||
greed: ['lean', 'standard', 'glutted'],
|
||||
clock: ['slow', 'standard', 'hunted'],
|
||||
ending: ['authored', 'generated'],
|
||||
};
|
||||
|
||||
export class DelveForgeApp extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
static DEFAULT_OPTIONS = {
|
||||
id: 'delve-forge',
|
||||
tag: 'form',
|
||||
window: { title: 'DELVE — raise a dungeon', icon: 'fas fa-mountain', resizable: true },
|
||||
position: { width: 520, height: 'auto' },
|
||||
form: { handler: DelveForgeApp.#submit, closeOnSubmit: true },
|
||||
};
|
||||
|
||||
static PARTS = { body: { template: 'modules/vanity-delve/templates/forge.hbs' } };
|
||||
|
||||
async _prepareContext() {
|
||||
const last = game.settings.get('vanity-delve', 'lastParams') ?? {};
|
||||
return {
|
||||
themes: THEMES,
|
||||
picks: PICKS,
|
||||
v: {
|
||||
theme: last.theme ?? 'barrow', areas: last.areas ?? 6, depth: last.depth ?? 2,
|
||||
party: last.party ?? 4, deadliness: last.deadliness ?? 'standard',
|
||||
density: last.density ?? 'standard', greed: last.greed ?? 'standard',
|
||||
clock: last.clock ?? 'standard', ending: last.ending ?? 'authored',
|
||||
seed: '', maps: last.maps ?? true, populate: last.populate ?? true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static async #submit(event, form, formData) {
|
||||
const o = formData.object;
|
||||
const params = {
|
||||
theme: o.theme, areas: Number(o.areas), depth: Number(o.depth), party: Number(o.party),
|
||||
deadliness: o.deadliness, density: o.density, greed: o.greed, clock: o.clock,
|
||||
ending: o.ending, maps: !!o.maps, populate: !!o.populate,
|
||||
seed: (o.seed ?? '').trim() || undefined,
|
||||
};
|
||||
await game.settings.set('vanity-delve', 'lastParams', params);
|
||||
return raiseDungeon(params);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ build */
|
||||
|
||||
/**
|
||||
* Build the whole dungeon. Long-running and chatty on purpose: raising five scenes takes a few
|
||||
* seconds each and a silent UI reads as a hang.
|
||||
*/
|
||||
export async function raiseDungeon(params = {}) {
|
||||
if (!game.user.isGM) return ui.notifications.warn('DELVE is a GM tool.');
|
||||
const PACK = game.delve.pack;
|
||||
const seed = params.seed || coinSeed(new Rng(String(Date.now())));
|
||||
const d = generateDelve({ pack: PACK, ...params, seed });
|
||||
const sk = d.skeleton;
|
||||
const title = sk.placeName;
|
||||
|
||||
const seams = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? ''));
|
||||
const quiet = seams ? { post: false } : {};
|
||||
|
||||
// One folder per dungeon, for every document type it creates.
|
||||
// Stamp every created document so a dungeon can be found and removed later. Colour is not an
|
||||
// identifier — it is a Color object, and a GM may recolour a folder.
|
||||
const dungeonId = foundry.utils.randomID();
|
||||
const stamp = { flags: { [MOD]: { dungeonId, dungeonName: title } } };
|
||||
const folders = {};
|
||||
for (const type of ['Scene', 'Actor', 'JournalEntry']) {
|
||||
folders[type] = await Folder.create({ name: title, type, color: '#7a5cff', ...stamp });
|
||||
}
|
||||
|
||||
const notify = (i, n, what) => ui.notifications.info(`DELVE ${i}/${n} — ${what}`);
|
||||
const total = d.areas.length + 2;
|
||||
let step = 0;
|
||||
|
||||
// 1. Scenes, one per area.
|
||||
const scenes = [];
|
||||
for (const area of d.areas) {
|
||||
notify(++step, total, `raising ${area.name}`);
|
||||
let scene = null;
|
||||
if (params.maps !== false) {
|
||||
scene = await game.vanity.forge.stage({
|
||||
type: PACK.forgeStageType, size: 'medium', name: area.name,
|
||||
populate: false, activate: false, ...quiet,
|
||||
...(seams ? { folderId: folders.Actor.id } : {}),
|
||||
}).catch(e => { console.error('DELVE | stage failed', e); return null; });
|
||||
if (scene) await scene.update({ folder: folders.Scene.id, ...stamp });
|
||||
}
|
||||
scenes.push(scene);
|
||||
|
||||
if (params.populate !== false && area.encounter) {
|
||||
const enc = await game.vanity.forge.encounter({
|
||||
heat: area.encounter.heat, forStage: area.name,
|
||||
...(seams ? { hoard: false, post: false, folderId: folders.Actor.id } : {}),
|
||||
}).catch(e => { console.error('DELVE | encounter failed', e); return null; });
|
||||
area._foes = (enc?.actors ?? []).map(a => ({ name: a.name, uuid: a.uuid }));
|
||||
}
|
||||
if (area.hoard) {
|
||||
const h = await game.vanity.forge.hoard({ size: area.hoard, ...quiet })
|
||||
.catch(() => null);
|
||||
area._hoard = h?.lines ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. The journal — this is the adventure.
|
||||
notify(++step, total, 'writing the journal');
|
||||
const journal = await JournalEntry.create({
|
||||
name: title, folder: folders.JournalEntry.id, ...stamp,
|
||||
pages: buildPages(d, scenes),
|
||||
});
|
||||
// Stamp the actors the Forge made for us — it knows nothing about DELVE.
|
||||
const mine = game.actors.filter(a => a.folder?.id === folders.Actor.id);
|
||||
if (mine.length) await Actor.updateDocuments(mine.map(a => ({ _id: a.id, ...stamp })));
|
||||
|
||||
// 3. Land on something useful rather than leaving the GM to hunt.
|
||||
notify(++step, total, 'done');
|
||||
await journal.sheet.render(true);
|
||||
if (scenes[0]) await scenes[0].view();
|
||||
|
||||
await ChatMessage.create({
|
||||
speaker: { alias: 'DELVE' },
|
||||
whisper: ChatMessage.getWhisperRecipients('GM'),
|
||||
content: `<div class="vanity-roll delve-card">
|
||||
<header><span class="delve-label">⛏ ${esc(title)}</span>
|
||||
<span class="delve-sub">${d.areas.length} areas · ${esc(d.params.theme)} · depth ${d.params.depth} · seed ${esc(seed)}</span></header>
|
||||
<p>${scenes.filter(Boolean).length} scenes, ${game.actors.filter(a => a.folder?.id === folders.Actor.id).length} actors and a journal, all in the folder <b>${esc(title)}</b>.</p>
|
||||
<p>@UUID[${journal.uuid}]{Open the adventure}</p>
|
||||
${seams ? '' : '<p><b>⚠ Forge seams missing</b> — update the VANITY system to 0.10.5 for quiet output.</p>'}
|
||||
</div>`,
|
||||
});
|
||||
|
||||
return { delve: d, journal, scenes, folders, dungeonId };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- journal */
|
||||
|
||||
function buildPages(d, scenes) {
|
||||
const sk = d.skeleton, ap = sk.appeasement;
|
||||
const pages = [];
|
||||
const GM_ONLY = { default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE };
|
||||
|
||||
pages.push({
|
||||
name: '① The truth', type: 'text', ownership: GM_ONLY,
|
||||
text: { content: `
|
||||
<h2>${esc(sk.placeName)}</h2>
|
||||
<p><i>${esc(sk.motif.dangerLine)}.</i></p>
|
||||
<p><b>What was done.</b> ${esc(sk.knot.transgression)}</p>
|
||||
<p><b>What is left.</b> ${esc(sk.bottomProblem.label)} ${esc(sk.bottomProblem.failureState)}. It wants <b>${esc(sk.bottomProblem.wantNow)}</b>, and it opens by asking ${esc(d.ending.question)}</p>
|
||||
<p><b>The prize.</b> ${esc(sk.prize.label)} — ${esc(sk.prize.function)}.</p>
|
||||
<p><b>The danger.</b> ${esc(sk.motif.danger)}: ${esc(sk.motif.dangerLine)}.</p>
|
||||
<h3>The way through</h3>
|
||||
<p>Anyone may <b>${esc(ap.move)}</b> <code>[${esc(ap.attribute)} ${ap.successes}]</code>.
|
||||
On success ${esc(ap.why)}, and ${esc(sk.factions[0]?.name ?? 'the dead')} will not open hostilities.
|
||||
<b>It costs +${ap.gain.vanity} Vanity now and +${ap.cost.bane} Bane later.</b> Repeatable in every area —
|
||||
say the Bane aloud when it banks, so the table can see the route is open.</p>
|
||||
<h3>Pressure</h3>
|
||||
<ul>
|
||||
<li><b>Clock.</b> Roll 1d6 on any attention trigger, or every 3 Turns. <b>1 — something comes.</b> Telegraph one Turn ahead.</li>
|
||||
<li><b>The tab.</b> Aim for ${d.pressure.target} Banes by the ending. Banes come from named beats, not from hoping for Stumbles.</li>
|
||||
</ul>
|
||||
<h3>Factions</h3>
|
||||
<ul>${sk.factions.map(f => `<li><b>${esc(f.name)}</b> (${esc(f.kind)}) — ${esc(f.essence)}. Handled by <i>${esc(f.handledBy)}</i>.</li>`).join('')}</ul>
|
||||
<p><code>seed ${esc(d.seed)} · generator ${esc(d.generatorVersion)} · ${d.budget.plannedFights} planned fights</code></p>` },
|
||||
});
|
||||
|
||||
d.areas.forEach((a, i) => {
|
||||
const scene = scenes[i];
|
||||
const rv = a.decision?.resolve ?? {};
|
||||
const R = a.encounter?.roster;
|
||||
pages.push({
|
||||
name: `${a.index}. ${a.name}`, type: 'text', ownership: GM_ONLY,
|
||||
text: { content: `
|
||||
<p><code>${esc(a.role)} · ${esc(a.facet)}${a.encounter ? ` · ${esc(a.encounter.heat)}` : ''}${a.hoard ? ` · ${esc(a.hoard)} hoard` : ''}</code>
|
||||
${scene ? ` — @UUID[${scene.uuid}]{open the map}` : ''}</p>
|
||||
<blockquote><p>${esc(a.cueFragments.join('. '))}.
|
||||
${a.situation ? `${esc(cap(a.situation.occupant))} — ${esc(a.situation.doing)}. When you walk in, ${esc(a.situation.onArrival)}.` : ''}</p></blockquote>
|
||||
<p><b>Read that aloud, or rewrite it in your own words — it is a prompt, not a script.</b></p>
|
||||
${a.situation ? `<p><b>Why.</b> ${esc(cap(a.situation.because))}. <b>They can:</b> ${esc(a.situation.offer)}.</p>` : ''}
|
||||
<p><b>${esc(cap(a.decision.cue))}</b> — <i>${esc(a.decision.cost)}</i></p>
|
||||
<ul>
|
||||
${rv.roll ? `<li><code>[${esc(rv.roll)}]</code> → ${esc(rv.success)}</li>` : ''}
|
||||
${rv.failure ? `<li><b>Miss</b> → ${esc(rv.failure)}</li>` : ''}
|
||||
${rv.orElse ? `<li><b>Or</b> ${esc(rv.orElse)}</li>` : ''}
|
||||
</ul>
|
||||
${R ? `<p><b>${esc(cap(a.encounter.heat))} — ${esc(R.line)}.</b> Harmed by ${esc(R.harmedBy)}${R.harmedBy.includes('ONLY') ? ' — <b>say so before initiative</b>' : ''}. <i>${esc(R.avoid)}.</i></p>
|
||||
<table><thead><tr><th>Foe</th><th>atk</th><th>def</th><th>Grit</th><th>Nerve</th><th></th></tr></thead><tbody>
|
||||
${R.foes.map(f => `<tr><td>${f.n}× ${esc(f.name)}</td><td>${f.atk}</td><td>${f.def}</td><td>${f.grit}</td><td>${f.nerve}</td><td><i>${esc(f.note)}</i></td></tr>`).join('')}
|
||||
</tbody></table>` : ''}
|
||||
${a._foes?.length ? `<p><b>Rolled for you:</b> ${a._foes.map(f => `@UUID[${f.uuid}]{${esc(f.name)}}`).join(' · ')}</p>` : ''}
|
||||
${a.temptation ? `<p><b>${esc(cap(a.temptation.id))}</b> — ${esc(a.temptation.cue)}: ${esc(a.temptation.benefit)}.<br>
|
||||
<i>Using it costs ${a.temptation.useCost?.bane ? `+${a.temptation.useCost.bane} Bane` : '—'}. While carried, ${esc(a.temptation.standingDrawback)}.</i></p>` : ''}
|
||||
${a._hoard?.length ? `<p><b>Hoard.</b></p><ul>${a._hoard.map(l => `<li>${l}</li>`).join('')}</ul>` : ''}
|
||||
<p><code>trigger: ${esc(a.trigger)}${a.baneBeat ? ` · bane beat: ${esc(a.baneBeat)}` : ''} · fallback: ${esc(a.fallback.route)} · if skipped: ${esc(a.failureCase)}</code></p>` },
|
||||
});
|
||||
});
|
||||
|
||||
const e = d.ending;
|
||||
pages.push({
|
||||
name: `⌀ ${e.name}`, type: 'text', ownership: GM_ONLY,
|
||||
text: { content: `
|
||||
<blockquote><p>${esc(e.cueFragments.join('. '))}.</p></blockquote>
|
||||
${e.authored ? '<p><b>This is the authored slot.</b> DELVE stages the approach and stops here on purpose — the climax is yours to write.</p>' : ''}
|
||||
<p><b>${esc(sk.bottomProblem.label)}</b> — ${esc(sk.bottomProblem.failureState)}. It opens by asking ${esc(e.question)}</p>
|
||||
<table><thead><tr><th>What they did</th><th>The audience</th></tr></thead><tbody>
|
||||
<tr><td>Appeased it, even once</td><td>It listens, and will trade ${esc(sk.prize.label)} for the real thing, said to its face.</td></tr>
|
||||
<tr><td>Never appeased, tab under 4</td><td>It offers a Vice — the shine now, the bill later. Refusing starts the fight.</td></tr>
|
||||
<tr><td>Carrying the prize</td><td>It wants that more than it wants them.</td></tr>
|
||||
<tr><td>Fighting</td><td>Winnable by two engaged heroes. <b>Name what harms it before initiative.</b></td></tr>
|
||||
</tbody></table>
|
||||
<p><b>The Reckoning.</b> If the tab reads six mid-audience, stop and roll it. A delve about vanity billing them in front of this thing is the best ending it has — do not soften it.</p>` },
|
||||
});
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
|
||||
/* ----------------------------------------------------------------- remove */
|
||||
|
||||
/** Every dungeon DELVE has built in this world. */
|
||||
export function listDungeons() {
|
||||
const byId = new Map();
|
||||
for (const f of game.folders) {
|
||||
const id = f.getFlag(MOD, 'dungeonId');
|
||||
if (!id) continue;
|
||||
if (!byId.has(id)) byId.set(id, { id, name: f.getFlag(MOD, 'dungeonName') ?? f.name, folders: [] });
|
||||
byId.get(id).folders.push(f);
|
||||
}
|
||||
for (const d of byId.values()) {
|
||||
d.scenes = game.scenes.filter(s => s.getFlag(MOD, 'dungeonId') === d.id);
|
||||
d.actors = game.actors.filter(a => a.getFlag(MOD, 'dungeonId') === d.id);
|
||||
d.journals = game.journal.filter(j => j.getFlag(MOD, 'dungeonId') === d.id);
|
||||
}
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/** Delete one generated dungeon and everything it made. Confirms with exact counts first. */
|
||||
export async function removeDungeon(dungeonId, { confirm = true } = {}) {
|
||||
const d = listDungeons().find(x => x.id === dungeonId);
|
||||
if (!d) return ui.notifications.warn('DELVE: no such dungeon.');
|
||||
|
||||
if (confirm) {
|
||||
const ok = await foundry.applications.api.DialogV2.confirm({
|
||||
window: { title: `Remove ${d.name}?` },
|
||||
content: `<p>This deletes <b>${d.scenes.length} scenes</b>, <b>${d.actors.length} actors</b>,
|
||||
<b>${d.journals.length} journal(s)</b> and <b>${d.folders.length} folders</b>.</p>
|
||||
<p>Nothing else in the world is touched, and this cannot be undone.</p>`,
|
||||
rejectClose: false, modal: true,
|
||||
});
|
||||
if (!ok) return false;
|
||||
}
|
||||
|
||||
// Never delete the scene the GM is standing in.
|
||||
if (d.scenes.some(s => s.active)) {
|
||||
const other = game.scenes.find(s => !d.scenes.includes(s));
|
||||
if (other) await other.activate();
|
||||
}
|
||||
if (d.scenes.length) await Scene.deleteDocuments(d.scenes.map(s => s.id));
|
||||
if (d.actors.length) await Actor.deleteDocuments(d.actors.map(a => a.id));
|
||||
if (d.journals.length) await JournalEntry.deleteDocuments(d.journals.map(j => j.id));
|
||||
if (d.folders.length) await Folder.deleteDocuments(d.folders.map(f => f.id));
|
||||
ui.notifications.info(`DELVE: removed ${d.name}.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pick one to remove. */
|
||||
export async function removeDungeonDialog() {
|
||||
const all = listDungeons();
|
||||
if (!all.length) return ui.notifications.info('DELVE: no generated dungeons in this world.');
|
||||
const options = all.map(d => `<option value="${d.id}">${d.name} — ${d.scenes.length} scenes, ${d.actors.length} actors</option>`).join('');
|
||||
const id = await foundry.applications.api.DialogV2.prompt({
|
||||
window: { title: 'DELVE — remove a dungeon' },
|
||||
content: `<p>Which dungeon should go?</p><select name="id" style="width:100%">${options}</select>`,
|
||||
ok: { label: 'Choose', callback: (e, b, dlg) => dlg.element.querySelector('[name=id]').value },
|
||||
rejectClose: false, modal: true,
|
||||
});
|
||||
if (id) return removeDungeon(id);
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
*/
|
||||
import { coinSeed, Rng } from './core/rng.mjs';
|
||||
import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs';
|
||||
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog } from './forge-app.mjs';
|
||||
|
||||
const MOD = 'vanity-delve';
|
||||
const FLAG = 'state';
|
||||
@@ -151,6 +152,7 @@ async function clock(cause = 'timer') {
|
||||
|
||||
Hooks.once('init', () => {
|
||||
game.settings.register(MOD, FLAG, { scope: 'world', config: false, type: Object, default: null });
|
||||
game.settings.register(MOD, 'lastParams', { scope: 'world', config: false, type: Object, default: null });
|
||||
});
|
||||
|
||||
Hooks.once('ready', async () => {
|
||||
@@ -159,7 +161,9 @@ Hooks.once('ready', async () => {
|
||||
.catch(() => fetch(`modules/${MOD}/module/core/content/barrow.json`).then(r => r.json()));
|
||||
seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? ''));
|
||||
|
||||
game.delve = { load, loadFile, draft, enter, ending, bane, clock, state: getState,
|
||||
game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon,
|
||||
list: listDungeons, remove: removeDungeon, removeDialog: removeDungeonDialog,
|
||||
load, loadFile, draft, enter, ending, bane, clock, state: getState,
|
||||
outstanding: () => outstanding(getState()?.delve ?? { areas: [] }),
|
||||
ready: () => readyToPlay(getState()?.delve ?? { areas: [] }),
|
||||
get pack() { return PACK; } };
|
||||
@@ -172,10 +176,18 @@ Hooks.on('getSceneControlButtons', controls => {
|
||||
const group = Array.isArray(controls) ? controls.find(c => c.name === 'token') : controls.token;
|
||||
if (!group) return;
|
||||
const tools = Array.isArray(group.tools) ? group.tools : Object.values(group.tools ?? {});
|
||||
const warn = () => ui.notifications.warn('DELVE: load a finished delve first — game.delve.loadFile("name")');
|
||||
const open = () => new DelveForgeApp().render(true);
|
||||
tools.push({
|
||||
name: 'delve', title: 'DELVE — stage the next area', icon: 'fas fa-mountain', button: true,
|
||||
onClick: () => (getState() ? enter() : warn()),
|
||||
onChange: () => (getState() ? enter() : warn()),
|
||||
name: 'delve-forge', title: 'DELVE — raise a dungeon', icon: 'fas fa-mountain', button: true,
|
||||
onClick: open, onChange: open,
|
||||
});
|
||||
tools.push({
|
||||
name: 'delve-remove', title: 'DELVE — remove a generated dungeon', icon: 'fas fa-trash', button: true,
|
||||
onClick: () => removeDungeonDialog(), onChange: () => removeDungeonDialog(),
|
||||
});
|
||||
tools.push({
|
||||
name: 'delve-next', title: 'DELVE — stage the next area (pre-authored delve)', icon: 'fas fa-forward', button: true,
|
||||
onClick: () => (getState() ? enter() : ui.notifications.info('DELVE: no staged delve — use the ⛏ button to raise one.')),
|
||||
onChange: () => (getState() ? enter() : ui.notifications.info('DELVE: no staged delve — use the ⛏ button to raise one.')),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,3 +7,12 @@
|
||||
|
||||
.delve-read { border-left: 3px solid #e8c96a; font-size: 1.02em; }
|
||||
.delve-read p { margin: .2rem 0; }
|
||||
|
||||
.delve-forge .hint { opacity: .8; font-size: .92em; margin: 0 0 .6rem; }
|
||||
.delve-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .45rem .7rem; }
|
||||
.delve-grid label, .delve-toggles label { display: flex; flex-direction: column; font-size: .9em; gap: .15rem; }
|
||||
.delve-grid .sub, .delve-toggles .sub { opacity: .6; font-size: .85em; font-weight: normal; }
|
||||
.delve-toggles { margin-top: .6rem; display: flex; flex-direction: column; gap: .3rem; }
|
||||
.delve-toggles .checkbox { flex-direction: row; align-items: center; gap: .4rem; }
|
||||
.delve-actions { margin-top: .8rem; }
|
||||
.delve-actions button.bright { width: 100%; }
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<div class="delve-forge">
|
||||
<p class="hint">Generates a complete dungeon — fiction, areas, maps, encounters, hoards and a
|
||||
journal you can read cold — and drops it into this world in one folder.</p>
|
||||
|
||||
<div class="delve-grid">
|
||||
<label>Theme
|
||||
<select name="theme">
|
||||
{{#each themes}}<option value="{{this}}" {{#if (eq this ../v.theme)}}selected{{/if}}>{{this}}</option>{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Areas
|
||||
<input type="number" name="areas" value="{{v.areas}}" min="3" max="12" step="1">
|
||||
</label>
|
||||
<label>Depth
|
||||
<select name="depth">
|
||||
{{#each picks.depth}}<option value="{{this}}" {{#if (eq this ../v.depth)}}selected{{/if}}>{{this}}</option>{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Heroes
|
||||
<select name="party">
|
||||
{{#each picks.party}}<option value="{{this}}" {{#if (eq this ../v.party)}}selected{{/if}}>{{this}}</option>{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Deadliness
|
||||
<select name="deadliness">
|
||||
{{#each picks.deadliness}}<option value="{{this}}" {{#if (eq this ../v.deadliness)}}selected{{/if}}>{{this}}</option>{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Density
|
||||
<select name="density">
|
||||
{{#each picks.density}}<option value="{{this}}" {{#if (eq this ../v.density)}}selected{{/if}}>{{this}}</option>{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Greed
|
||||
<select name="greed">
|
||||
{{#each picks.greed}}<option value="{{this}}" {{#if (eq this ../v.greed)}}selected{{/if}}>{{this}}</option>{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Clock
|
||||
<select name="clock">
|
||||
{{#each picks.clock}}<option value="{{this}}" {{#if (eq this ../v.clock)}}selected{{/if}}>{{this}}</option>{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Ending
|
||||
<select name="ending">
|
||||
{{#each picks.ending}}<option value="{{this}}" {{#if (eq this ../v.ending)}}selected{{/if}}>{{this}}</option>{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Seed <span class="sub">blank = random</span>
|
||||
<input type="text" name="seed" value="{{v.seed}}" placeholder="gilded-court-404">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="delve-toggles">
|
||||
<label class="checkbox"><input type="checkbox" name="maps" {{#if v.maps}}checked{{/if}}> Build the maps <span class="sub">(slower — about a second per area)</span></label>
|
||||
<label class="checkbox"><input type="checkbox" name="populate" {{#if v.populate}}checked{{/if}}> Roll the encounters</label>
|
||||
</div>
|
||||
|
||||
<footer class="delve-actions">
|
||||
<button type="submit" class="bright"><i class="fas fa-mountain"></i> Raise the dungeon</button>
|
||||
</footer>
|
||||
</div>
|
||||
Reference in New Issue
Block a user