11 Commits
Author SHA1 Message Date
slaguru666andClaude Opus 5 3a180479cb v0.6.3 — the plan stays runnable when nothing is forged
0.6.2 fixed the foe block by showing the actors the Forge created, and in
doing so broke the populate-off path: with nothing forged there were no
combat numbers at all, and the caption still pointed the GM at "the table
above" when no table had rendered. Turning population off used to leave
the planned roster runnable. It does again.

One rule now governs both surfaces: show what exists. The roster's
tactical guidance describes the planned foes, so it travels with the plan
and only when the plan IS the encounter. With foes forged, the plan is a
one-line note saying its tactics do not describe them; with nothing
forged, the full planned roster renders and its guidance applies, because
there it is the encounter. The previous wording claimed the guidance
applied either way, which asserted exactly what the fix existed to deny,
and gating the caveat on the literal word ONLY missed the Troll that
regenerates unless burned, the Ogre's 12 Grit and the Skeletons that
return until their Necromancer stops.

packFor no longer falls back to barrow. Generated files always record a
theme, but load() takes hand-edited JSON too, and guessing the geometry
is the bug it was written to prevent — it now fails closed, and also
rejects a pack with no forgeStageType. draft() keeps a default because
drafting chooses a theme rather than being told one.

raiseDungeon spreads caller params before the pack, so a programmatic
raise({pack}) can no longer generate from one pack while the maps are
staged from another.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:53:13 +01:00
slaguru666andClaude Opus 5 7f5804e2f1 v0.6.2 — publish the theme and roster fixes
Both are correctness bugs a GM would hit on the first non-barrow delve,
so they should not wait for the next feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:42:34 +01:00
slaguru666andClaude Opus 5 1ca0a64295 Stage the delve's own theme; show the foes that exist, not the plan
Two bugs, both of them the adapter drifting from what the core says.

PACK was a module global set once at boot to barrow. load() never
switched it and enter() read PACK.forgeStageType, so a port delve
authored at the desk staged barrow geometry — the fiction said quayside
and the map was a burial chamber. Fifteen of the sixteen themes, silently
wrong, on the path the module documents as its intended entry point. It
now resolves per call from d.params.theme, which is written from pack.id
at generation and so is always present and always right. Per call rather
than once at load, because a page reload resets the global while the
staged delve in world state survives; and before the folder is created,
so a bad theme fails without leaving anything behind.

The pack roster describes the encounter DELVE planned. The Forge takes no
cast — it rolls its own monsters from the heat — so the roster and the
actors in the world were never the same list, and both surfaces printed
the roster's stats as though they were. In several themes the roster also
carries "blessed, silvered or magical weapons ONLY", naming a Wraith that
was never created; the journal told the GM to say so before initiative.
Both surfaces now run off the actors that exist, read from the documents
themselves, and the plan is kept but labelled as the plan. The chat card
drops the immunity line entirely — it is the live surface and a false
immunity is worst there; the journal keeps it captioned, since a GM at
the desk may choose to cast the fight by hand.

Adds one core test for the contract the fix rests on: every delve records
the pack it came from, across all sixteen. The adapter itself has no test
harness — see the note in the commit for the release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 23:41:31 +01:00
slaguru666andClaude Opus 5 316716e001 Bump GENERATOR_VERSION to 0.2.0; let the tests run from anywhere
GENERATOR_VERSION had sat at 0.1.0 while the emitted object gained the
authored layer and the content model moved under it, so a delve file
claimed a generator that no longer existed — both samples say 0.1.0 and
only one of them has an authored layer. Nothing gates on the stamp, so
this is informational only and old files still load. The comment on it
now says what it tracks and when to bump it, which is what stopped it
being bumped before.

test.mjs read ./content/barrow.json relative to the working directory, so
it only ran from inside core/ and crashed from the repo root. It now
resolves from the module file, matching build-index.mjs and
validate-pack.mjs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:58:29 +01:00
slaguru666andClaude Opus 5 246a5f607d v0.6.1 — publish the corrected VANITY floor
The 0.10.4 requirement only reaches an installing GM through the release
manifest, so it needs a release to take effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:54:18 +01:00
slaguru666andClaude Opus 5 5fae50e25b Correct the VANITY floor to 0.10.4; bring DESIGN.md up to draft 7
The manifest asked for VANITY 0.10.0, but the Forge seams only landed in
0.10.4. Below that floor the seams are absent and every delve emits the 9
chat cards and 3 folders the spike measured — the exact failure the seams
exist to prevent. The README said 0.10.5, which was safe but wrong; both
now say 0.10.4 and say why.

DESIGN.md still described the tool as an unreleased core with one theme.
It now matches what shipped: seams merged and released, the Foundry slice
at v0.6.0, 16 themes across 5 geometries, 39 tests, the pack validator and
the authoring layer. The features pool is motif-scoped now, so the "last
piece of generic furniture" is gone.

Open questions gain the one that matters: DELVE has never been run at a
live table, and no amount of desk validation will close that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:29:21 +01:00
slaguru666andClaude Opus 5 7af690205c feat: wild family complete — cave, mountain, river, lake, valley theme packs
Five wild-kernel themes, each 4 motifs:
  cave      dark · deep · echo · keeping        (cave geometry)
  mountain  ascent · cold · pass · weight       (cave)
  river     crossing · current · course · drowned  (fen)
  lake      stillness · mirror · sunken · rising   (fen)
  valley    ridges · shelter · bottom · weather (forest)

Two rendering fixes surfaced by the new claimants, both in skeleton.mjs:
  - `{conscript}'s` rendered "the climbers's"; conscripts are plural noun
    phrases, so fill() now collapses s's to s'.
  - claimants carrying their own article rendered "The The Grey"; named()
    only prefixes when one is absent.

All 16 themes validate; 39 tests pass. Module → 0.6.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:22:01 +01:00
slaguru666andClaude Opus 5 d22421efbd feat: settlement family complete — village, market, city, port theme packs
Four settlement-kernel themes on village geometry, each 4 motifs:
  village  kinship · harvest · custom · quiet
  market   bargain · weights · crowd · credit
  city     works · rolls · wards · liberties
  port     cargo · passage · tide · wreck

All validate. 11 themes total; index.json rebuilt. Module → 0.5.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:57:31 +01:00
slaguru666andClaude Opus 5 b4990533a8 feat: interior family complete — church, temple, palace theme packs
Three interior-kernel themes on barrow geometry, each 4 motifs:
  church   faith · congregation · offering · sanctity
  temple   exactness · sacrifice · silence · exclusion
  palace   service · delight · splendour · audience

All validate. 7 themes total; index.json rebuilt. Module → 0.4.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 17:48:28 +01:00
tevansandClaude Opus 5 075be5c1de Town and Forest: two new kernels, not just two new packs
Barrow and Castle share a kernel — a dead claimant whose appetite outlived them.
A market or a river has no corpse in it, so the other two families needed the
kernel to generalise, and it does: something wanted a thing, and something was
done to keep it that way. Only who "something" is changes.

TOWN, on village geometry. The appetite belongs to the place and the claimants
are offices — a Corporation, a Watch Committee, a Guild of Mercers. It wants to
be prosperous, respectable, safe or necessary, and it priced people out,
chartered them away or struck them from the rolls to stay that way. Motifs:
ledger, appearances, vigilance, thoroughfare.

FOREST, on forest geometry. The appetite belongs to the wood, and the claimants
are its own names for itself. It wants to be entered, fed, spread or left
uncounted, and it took people, grew through them, or let them wander. Motifs:
paths, tithe, reclaiming, trackless.

Settlements also needed their own naming rule. A barrow is named for its
occupant; a town is not — it has a name and the claimant merely runs it. Packs
declare placeNameStyle, so "The Town of Watch Committee" is now Cold Harrow, run
by the Watch Committee.

Three of fifteen requested themes are done. The remaining twelve are the same
job repeated rather than a design question, since all three kernels now exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:41:10 +01:00
tevansandClaude Opus 5 e2d1de2fb8 Themes are pluggable; add Castle; validate packs
Groundwork for many themes, plus the first new one.

A pack validator enforces every structural rule this project learned the hard
way: situations that render as broken English, decisions that gate progress with
no alternative, motifs too thin to fill six areas without repeating, rosters
that do not say what harms them. Fifteen packs cannot be hand-checked; this
checks them in a second.

Themes are now discovered from content/index.json rather than hardcoded, so the
CLI takes --theme and --themes, the Foundry dropdown fills itself, and packs
load on demand instead of all at once.

Castle: four appetites — to be feared, never to give ground, to be obeyed at
once, the name to go on — over barrow geometry, since a keep is rooms and
corridors. 168 kernels from its own claimants and accommodations.

Fixed a bug the second theme exposed: the question the bottom problem asks was
hardcoded against barrow's appetite ids, so every other theme fell through to a
generic "Well?". It lives on the appetite in the pack now, and the validator
requires it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 16:14:33 +01:00
50 changed files with 36591 additions and 105 deletions
+100 -24
View File
@@ -1,7 +1,8 @@
# DELVE — a dungeon layer for VANITY
*Working name `vanity-delve`. **Draft 6**, 2026-08-05.*
*First draft describing a tool that **exists**. The core is built, tested and generating.*
*Working name `vanity-delve`. **Draft 7**, 2026-08-05.*
*Describes a tool that **exists** and is released. The core is built, tested and generating; the
Foundry module is published at v0.6.0.*
---
@@ -11,19 +12,52 @@
|---|---|
| 1. Handwrite a delve, run it twice on paper | ✅ complete — [`paper/`](paper/) |
| 2. Seam spike | ✅ complete — [`spike/SPIKE-REPORT.md`](spike/SPIKE-REPORT.md) |
| 3. Land the Forge seams | ✅ **branch ready for review** — `~/projects/Vanity` branch `delve-seams`, committed, **not pushed** |
| 4. Core + Barrow content | ✅ built — [`core/`](core/), 11/11 tests passing |
| 3. Land the Forge seams | ✅ **merged and released** — VANITY v0.10.4, PR #1 |
| 4. Core + content | ✅ built — [`core/`](core/), **39/39 tests passing** |
| 5. Curated overlays | not started |
| 6. Foundry vertical slice | not started |
| 6. Foundry vertical slice | ✅ **released** — v0.1.0 → v0.6.0 |
| 7. Growing Delve Map | not started |
| 8. More themes | not started |
| 8. More themes | ✅ **16 themes**, 68 motifs — all passing [`validate-pack.mjs`](core/validate-pack.mjs) |
**What runs today:** `node core/cli.mjs --seed=gilded-court-404` emits a complete, coherent,
GM-readable delve. Samples in [`samples/`](samples/).
**What runs today:** the module installs from its manifest and raises a whole delve in the world
on one button, and `node core/cli.mjs --new --seed=gilded-court-404` emits the same delve as a
worksheet at the desk. Samples in [`samples/`](samples/).
**What has never happened:** a live session. Everything since the two paper playtests has been
validated at the desk, by tests and by reading. Open question 1 is still open, and it is the only
one that matters.
### Versions
| | |
|---|---|
| Module | 0.6.0 |
| Requires | VANITY **0.10.4+** — the release the seams landed in |
| Foundry | v13 minimum, verified 14.365 |
| Generator stamp | `GENERATOR_VERSION` **0.2.0** — written into every delve file, **not** the module version |
`GENERATOR_VERSION` had sat at 0.1.0 while the emitted object gained the `authored` layer and the
content model moved under it, so files claimed a generator that no longer existed — the two
samples in [`samples/`](samples/) are both stamped 0.1.0 and only one of them has an `authored`
layer. It tracks the *shape of the delve file and what a seed yields*, and the comment on it now
says when to bump it. Old files still load.
---
## 1. What changed since draft 5
## 1. What changed
### In draft 7 — the tool shipped
| Change | Source |
|---|---|
| **Seams: "unpushed branch" → merged** | PR #1 into `slaguru666/Vanity`, released in **v0.10.4** |
| **Step 6 shipped** | The Foundry module went v0.1.0 → v0.6.0 in a day |
| **One theme → 16** | Castle, then the interior, settlement and wild families — 68 motifs |
| **11 tests → 39** | The authoring layer, situations and the subtraction pass each brought invariants |
| **A pack validator exists** | [`validate-pack.mjs`](core/validate-pack.mjs) — the structural rules, checked per theme rather than rediscovered |
| **Required VANITY floor corrected** | The manifest said 0.10.0; the seams land in 0.10.4. Below that a delve emits 9 chat cards and 3 folders |
### In draft 6, since draft 5
| Change | Source |
|---|---|
@@ -125,8 +159,15 @@ Two rules the tests enforce:
long delve outruns its motif; temptations may not. A generic relic breaks the fiction, while a
repeated one only looks thin.
**Remaining limit:** the second cue fragment still comes from a global `features` pool. That is
the last piece of generic furniture.
**Closed in draft 7:** the `features` pool was the last piece of generic furniture, and it is now
motif-scoped too. Every pack carries **6 features, 6 decisions, 5 temptations and 6 situations per
motif**; the top-level pools survive only as fallbacks for a delve long enough to outrun its motif,
and the pack itself says so (`_globalPoolsNote`). Nothing generic reaches a six-area delve.
**Areas arrive as situations, not prompts.** A `situations` pool per motif gives each area an
occupant doing something, with a leverage point — *"The tide watch, turning a glass that has
already run through — it asks what the hour is, and means the tide."* Four test invariants police
the grammar, because the first pass rendered broken English.
### The appeasement move
@@ -159,23 +200,49 @@ core/ pure JS, seeded, zero Foundry globals, Node-testable
director.mjs arc roles, heat, hoard tier, density, Bane beats
beat.mjs an area as a beat: Cue + Truth
pressure.mjs the clock and the tab — never rolls a die
delve.mjs composition
delve.mjs composition; GENERATOR_VERSION
authoring.mjs the working file — reroll, lock, setAuthored, outstanding
render.mjs markdown
cli.mjs --seed --areas --depth --density --greed --json
content/barrow.json the theme pack
test.mjs 11 invariants
render-authoring.mjs the two surfaces — worksheet and play sheet (§2)
cli.mjs --new --file --theme --themes --seed --areas
--reroll --lock --unlock --write --text
--play --json --todo --out (worksheet is the default)
validate-pack.mjs the structural rules a theme pack must satisfy
build-index.mjs regenerates content/index.json
content/*.json 16 theme packs + index.json
test.mjs 39 invariants
```
**The core never sees an Actor, a UUID, HTML or a roll.** `pressure.mjs` decides *when* a clock
roll is due and what it means; the adapter rolls it at the table. That separation is what makes
the seed promise honest.
**The module vendors the core.** `foundry-module/module/core/` is a copy of `core/` minus the
Node-only tools (`cli.mjs`, `test.mjs`, `validate-pack.mjs`, `build-index.mjs`). It must stay
byte-identical to `core/` for the shared files — a drift there means the table and the desk
generate different delves from the same seed.
**Themes are pluggable.** A pack declares its `forgeStageType` from the five geometries VANITY can
build — `barrow · cave · fen · village · forest` — so a new theme is authored content, not code:
| Geometry | Themes |
|---|---|
| barrow | barrow, castle, church, palace, temple |
| village | city, market, port, town, village |
| cave | cave, mountain |
| forest | forest, valley |
| fen | lake, river |
---
## 5. The Forge seams — landed
Branch `delve-seams` in a clone of `slaguru666/Vanity`, **committed, unpushed, awaiting review**.
All default to current behaviour, so nothing changes for existing callers.
Merged into `slaguru666/Vanity` as PR #1 from branch `delve-seams`, and **published in VANITY
v0.10.4**. All default to current behaviour, so nothing changed for existing callers.
**This is the module's hard floor.** `module.json` requires VANITY 0.10.4 or later. On anything
earlier the seams simply are not there, and every delve emits the 9 chat cards and 3 folders the
spike measured — the exact failure the seams exist to prevent.
| Seam | Change | Verified |
|---|---|---|
@@ -195,7 +262,7 @@ Neither gates a first release.
| Parameter | Range | Default | Confidence |
|---|---|---|---|
| **Areas** (excludes ending slot) | 3–12 | **6** | *working default* — one theme, one table, two passes |
| Theme | Barrow (+4 planned) | Barrow | only Barrow exists |
| Theme | 16 packs, 5 geometries (§4) | Barrow | all validate; **only Barrow is playtested** |
| Depth | 1–5 | 2 | |
| Party | 1–8 | 4 | |
| Deadliness | forgiving/standard/cruel | standard | **severity only** |
@@ -293,18 +360,27 @@ if the rest of the packet is operational. It wasn't. Fixed since:
- **A trigger glossary**, so `seenTwice` is not shorthand
- **Accommodation/appetite compatibility**, killing the equation-output kernels
Still missing, and still the gap to a document: per-area read-aloud, exact route costs and
failure outcomes, concrete clue answers, and a full ending packet with negotiation terms.
**Draft 7 closed most of the rest.** "Mostly prompts" was the charge; the answer was to make every
area arrive as a situation with an occupant, a leverage point and a way through, and to enforce it
with tests: every decision now states success *and* failure, offers an attempt rather than a pick,
and never gates progress without an alternative — playtest 1 died on exactly that.
Still missing, and still the gap to a document: per-area read-aloud (deliberately — §11), exact
route costs, concrete clue answers, and a full ending packet with negotiation terms.
## 13. Open questions
1. **Does the module beat a document?** The spike answers the mechanical half — unmodified, the
Forge emits 9 cards and 3 folders per delve that no document could intercept, and the seams
take that to 0. The *table* half is still open until a live session.
1. **Does the module beat a document?** The mechanical half is now settled *in shipped code* — the
unmodified Forge emits 9 cards and 3 folders per delve that no document could intercept, and
the released seams take that to 0. The *table* half is still open, and no amount of desk work
will close it. **This is the blocking question.**
2. Is `Ending: Authored` right, or right only for its author?
3. Is 6 areas right beyond one theme and one simulated table?
4. Is the ~5-Bane target reachable without feeling mechanical?
4. Is the ~5-Bane target reachable without feeling mechanical? Still **UNCALIBRATED** — the target
is ~5, playtest 2 reached 3, and nothing since has been measured against a real table.
5. Do cue *fragments* work at a live table, or does a GM need prose?
6. **Do the other 15 themes hold up?** They validate structurally, but only Barrow has ever been
read closely against a playtest. A pack can pass every invariant and still be dull.
---
+3 -1
View File
@@ -12,7 +12,9 @@ cold — then drops it into your world.
https://github.com/slaguru666/vanity-delve/releases/latest/download/module.json
```
Requires the [VANITY](https://github.com/slaguru666/Vanity) system, **0.10.5 or later**.
Requires the [VANITY](https://github.com/slaguru666/Vanity) system, **0.10.4 or later** — that is
the release the Forge seams landed in, and without them a delve emits nine stray chat cards and
three stray folders.
* **⛏** raise a dungeon
* **🗑** remove one you don't want — it deletes exactly what it made and nothing else
+11
View File
@@ -0,0 +1,11 @@
/** Regenerate content/index.json — the browser cannot list a directory, so packs need a manifest. */
import { readFileSync, writeFileSync, readdirSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const dir = join(dirname(fileURLToPath(import.meta.url)), 'content');
const themes = readdirSync(dir).filter(f => f.endsWith('.json') && f !== 'index.json')
.map(f => JSON.parse(readFileSync(join(dir, f), 'utf8')))
.map(p => ({ id: p.id, label: p.label ?? p.id, geometry: p.forgeStageType, motifs: Object.keys(p.motifs ?? {}).length }))
.sort((a, b) => a.label.localeCompare(b.label));
writeFileSync(join(dir, 'index.json'), JSON.stringify({ schema: 1, themes }, null, 2));
console.log(` ${themes.length} themes: ${themes.map(t => t.id).join(', ')}`);
+8 -1
View File
@@ -24,7 +24,14 @@ const args = Object.fromEntries(process.argv.slice(2).map(a => {
return [k, v === '' ? true : (/^\d+$/.test(v) ? Number(v) : v)];
}));
const pack = JSON.parse(readFileSync(join(here, 'content', `${args.theme ?? 'barrow'}.json`), 'utf8'));
const index = JSON.parse(readFileSync(join(here, 'content', 'index.json'), 'utf8'));
if (args.themes) { console.log(index.themes.map(t => `${t.id.padEnd(10)} ${t.geometry.padEnd(8)} ${t.motifs} motifs`).join('\n')); process.exit(0); }
const themeId = args.theme ?? 'barrow';
if (!index.themes.some(t => t.id === themeId)) {
console.error(`unknown theme "${themeId}" — available: ${index.themes.map(t => t.id).join(', ')}`);
process.exit(1);
}
const pack = JSON.parse(readFileSync(join(here, 'content', `${themeId}.json`), 'utf8'));
const file = args.file;
const load = () => JSON.parse(readFileSync(file, 'utf8'));
const save = d => writeFileSync(file, JSON.stringify(d, null, 2));
+18 -9
View File
@@ -25,7 +25,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"How do I look?\""
},
{
"id": "remembered",
@@ -47,7 +48,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Say my name.\""
},
{
"id": "obeyed",
@@ -69,7 +71,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Do as you are told.\""
},
{
"id": "young",
@@ -91,7 +94,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Tell me I have not changed.\""
},
{
"id": "envied",
@@ -113,7 +117,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"What did you bring me?\""
},
{
"id": "attended",
@@ -135,7 +140,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"You are not leaving.\""
},
{
"id": "forgiven",
@@ -157,7 +163,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Tell me it was not a sin.\""
},
{
"id": "first",
@@ -179,7 +186,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Who goes first?\""
}
],
"accommodations": [
@@ -2146,5 +2154,6 @@
"lingering": "stayed in one place longer than it takes to search it",
"disturbed": "struck or moved something that had not moved in centuries"
},
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>."
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>.",
"placeNameStyle": "claimant"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
{
"schema": 1,
"themes": [
{
"id": "barrow",
"label": "Barrow",
"geometry": "barrow",
"motifs": 8
},
{
"id": "castle",
"label": "Castle",
"geometry": "barrow",
"motifs": 4
},
{
"id": "cave",
"label": "Cave",
"geometry": "cave",
"motifs": 4
},
{
"id": "church",
"label": "Church",
"geometry": "barrow",
"motifs": 4
},
{
"id": "city",
"label": "City",
"geometry": "village",
"motifs": 4
},
{
"id": "forest",
"label": "Forest",
"geometry": "forest",
"motifs": 4
},
{
"id": "lake",
"label": "Lake",
"geometry": "fen",
"motifs": 4
},
{
"id": "market",
"label": "Market",
"geometry": "village",
"motifs": 4
},
{
"id": "mountain",
"label": "Mountain",
"geometry": "cave",
"motifs": 4
},
{
"id": "palace",
"label": "Palace",
"geometry": "barrow",
"motifs": 4
},
{
"id": "port",
"label": "Port",
"geometry": "village",
"motifs": 4
},
{
"id": "river",
"label": "River",
"geometry": "fen",
"motifs": 4
},
{
"id": "temple",
"label": "Temple",
"geometry": "barrow",
"motifs": 4
},
{
"id": "town",
"label": "Town",
"geometry": "village",
"motifs": 4
},
{
"id": "valley",
"label": "Valley",
"geometry": "forest",
"motifs": 4
},
{
"id": "village",
"label": "Village",
"geometry": "village",
"motifs": 4
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -15
View File
@@ -9,7 +9,13 @@ import { buildArea, fallbackRoute } from './beat.mjs';
import { Pressure } from './pressure.mjs';
import { baneBeatFor } from './director.mjs';
export const GENERATOR_VERSION = '0.1.0';
/**
* Stamped into every delve file and shown on both surfaces, so a file says which generator made
* it. Bump it when the shape of the emitted object changes or when content selection changes
* enough that the same seed no longer yields the same delve — not when the module version moves.
* 0.2.0: the `authored` layer, motif-scoped decisions/temptations/features, areas as situations.
*/
export const GENERATOR_VERSION = '0.2.0';
export function generateDelve(params = {}) {
const {
@@ -103,7 +109,7 @@ export function generateDelve(params = {}) {
...plan.ending,
name: (pack.areaNames?.ending ?? ['The Ending'])[0],
cueFragments: [endFs?.fragment].filter(Boolean),
question: endingQuestion(skeleton),
question: endingQuestion(skeleton, pack),
authored: plan.ending.mode === 'authored',
},
triggerGlossary: pack.triggerGlossary ?? {},
@@ -112,17 +118,12 @@ export function generateDelve(params = {}) {
};
}
/** 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?"';
/**
* The bottom problem asks one thing, and it is the same question the whole delve has been about.
* It lives on the appetite in the theme pack — hardcoding it here meant any theme whose appetite
* ids differed from barrow's silently fell through to a generic line.
*/
function endingQuestion(sk, pack) {
const appetite = pack?.appetites?.find(a => a.id === sk.knot.appetiteId);
return appetite?.asks ?? '"Well?"';
}
+15 -5
View File
@@ -22,7 +22,13 @@
import { arcRole } from './director.mjs';
/** Fills {conscript}-style slots. */
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`)
// Conscripts are plural noun phrases ("the climbers"), so a template's `{conscript}'s`
// renders "the climbers's". English takes a bare apostrophe on a plural already ending in s.
.replace(/s's\b/g, "s'");
/** Claimants may already carry their own article — "The Grey" must not become "The The Grey". */
const named = c => (/^(the|a|an) /i.test(c) ? c : `The ${c}`);
/** Facet order used when a facet is exhausted — borrow from a neighbour before repeating. */
const FACET_ORDER = ['institution', 'ritual', 'demand', 'wound', 'anchor'];
@@ -62,11 +68,12 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
const prize = { kind: appetite.prize, ...(pack.prizes?.[appetite.prize] ?? {}) };
const transgression =
`The ${claimant} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
`${named(claimant)} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
`so that ${appetite.purpose}.`;
// The bottom problem is the claimant itself — a person in a tomb, an office in a town.
const bottomProblem = {
label: `The ${claimant}`,
label: named(claimant),
wantNow: appetite.want,
failureState: `is ${appetite.lack} now, and cannot bear it`,
handledBy: 'giving them what they want, at a price',
@@ -79,8 +86,11 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
};
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}`;
// A barrow or keep is named for its occupant — drawing that separately produced tombs named
// after strangers. A settlement is not: it has its own name and the claimant merely runs it.
const placeName = pack.placeNameStyle === 'own'
? `${r.derive('place1').pick(pack.placeNames.first)} ${r.derive('place2').pick(pack.placeNames.second)}`
: `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`;
// ---- the foreshadow chain ----------------------------------------------
const progression = pack.facetProgression ?? {};
+19 -2
View File
@@ -1,9 +1,12 @@
import { readFileSync } from 'fs';
import { readFileSync, readdirSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { Rng } from './rng.mjs';
import { generateDelve } from './delve.mjs';
import { renderMarkdown } from './render.mjs';
const pack = JSON.parse(readFileSync('./content/barrow.json', 'utf8'));
const dir = join(dirname(fileURLToPath(import.meta.url)), 'content');
const pack = JSON.parse(readFileSync(join(dir, 'barrow.json'), 'utf8'));
let pass = 0, fail = 0;
const t = (name, cond, detail = '') => { cond ? pass++ : fail++; console.log(`${cond ? ' ok ' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); };
@@ -227,5 +230,19 @@ import { renderWorksheet, renderPlay } from './render-authoring.mjs';
`play ${done.split('\n').length} vs worksheet ${renderWorksheet(d).split('\n').length} lines`);
}
// A delve must carry the pack it came from. The Foundry adapter reads `params.theme` to decide
// which geometry to stage; when it trusted a boot-time global instead, every non-barrow delve got
// a barrow map. The adapter cannot be tested here, so the contract it depends on is tested here.
{
const ids = readdirSync(dir).filter(f => f.endsWith('.json') && f !== 'index.json')
.map(f => f.replace('.json', ''));
const wrong = ids.filter(id => {
const p = JSON.parse(readFileSync(join(dir, `${id}.json`), 'utf8'));
return generateDelve({ pack: p, seed: `theme-${id}` }).params.theme !== id;
});
t('every delve records the pack it was generated from', wrong.length === 0,
wrong.length ? `wrong: ${wrong.join(', ')}` : `${ids.length} packs`);
}
console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);
+131
View File
@@ -0,0 +1,131 @@
/**
* Theme pack validator.
*
* A pack is ~136 authored entries and every structural rule in it was learned the hard way:
* situations that render as broken English, decisions with no way through, motifs too thin to
* fill six areas without repeating. Rather than rediscover those per theme, this checks them.
*
* Run: node validate-pack.mjs [name ...] (default: every pack in content/)
*/
import { readFileSync, readdirSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
const here = dirname(fileURLToPath(import.meta.url));
const FACETS = ['institution', 'ritual', 'demand', 'wound', 'anchor'];
const ROLES = ['approach', 'complication', 'turn', 'descent', 'threshold', 'ending'];
const GEOMETRIES = ['barrow', 'cave', 'fen', 'village', 'forest'];
const HEATS = ['skirmish', 'fight', 'battle', 'nightmare'];
/** Minimums sized so a 6-area delve never repeats — the default and most common case. */
const MIN = { fragmentsPerFacet: 3, features: 6, decisions: 6, temptations: 5, situations: 6, areaNamesPerRole: 4 };
export function validatePack(pack, name = pack?.id ?? '?') {
const errs = [], warns = [];
const E = m => errs.push(m), W = m => warns.push(m);
if (!pack.id) E('no id');
if (!GEOMETRIES.includes(pack.forgeStageType))
E(`forgeStageType "${pack.forgeStageType}" is not one VANITY can build (${GEOMETRIES.join(', ')})`);
for (const k of ['appetites', 'accommodations', 'claimants', 'motifs', 'prizes', 'areaNames', 'factions', 'rosters'])
if (!pack[k]) E(`missing ${k}`);
if (errs.length) return { name, errs, warns };
const motifIds = Object.keys(pack.motifs);
for (const a of pack.appetites) {
for (const f of ['id', 'want', 'lack', 'purpose', 'motif', 'prize', 'conscript', 'appeasement', 'asks'])
if (!a[f]) E(`appetite ${a.id ?? '?'}: missing ${f}`);
if (a.motif && !motifIds.includes(a.motif)) E(`appetite ${a.id}: motif "${a.motif}" does not exist`);
if (a.prize && !pack.prizes?.[a.prize]) E(`appetite ${a.id}: prize "${a.prize}" has no entry`);
const ap = a.appeasement ?? {};
for (const f of ['move', 'attribute', 'successes', 'why', 'gain', 'cost'])
if (ap[f] === undefined) E(`appetite ${a.id}: appeasement missing ${f}`);
}
// Every appetite must have at least one accommodation that fits it, or the kernel cannot form.
for (const a of pack.appetites) {
const fits = pack.accommodations.filter(x => !x.fits || x.fits.includes(a.id));
if (!fits.length) E(`appetite ${a.id}: no accommodation fits it`);
}
for (const acc of pack.accommodations) {
if (!acc.text?.includes('{conscript}')) W(`accommodation ${acc.id}: no {conscript} slot — will read oddly`);
}
for (const [id, m] of Object.entries(pack.motifs)) {
for (const f of ['danger', 'dangerLine', 'trigger', 'facets'])
if (!m[f]) E(`motif ${id}: missing ${f}`);
for (const facet of FACETS) {
const n = (m.facets?.[facet] ?? []).length;
if (n < MIN.fragmentsPerFacet) E(`motif ${id}: facet ${facet} has ${n} fragments, needs ${MIN.fragmentsPerFacet}`);
}
for (const [key, min] of [['features', MIN.features], ['decisions', MIN.decisions],
['temptations', MIN.temptations], ['situations', MIN.situations]]) {
const n = (m[key] ?? []).length;
if (n < min) E(`motif ${id}: ${n} ${key}, needs ${min} so a 6-area delve never repeats`);
}
for (const d of m.decisions ?? []) {
const r = d.resolve;
if (!r) { E(`motif ${id}: decision "${String(d.cue).slice(0, 40)}" has no resolve`); continue; }
if (!r.roll) E(`motif ${id}: decision "${String(d.cue).slice(0, 40)}" has no attempt roll — a pick, not a situation`);
if (r.roll && (!r.success || !r.failure)) E(`motif ${id}: decision "${String(d.cue).slice(0, 40)}" states only half its outcomes`);
if (!r.orElse) E(`motif ${id}: decision "${String(d.cue).slice(0, 40)}" gates progress with no alternative`);
}
// The opener reads "<occupant> — <doing>. When you walk in, <onArrival>."
for (const s of m.situations ?? []) {
for (const f of ['occupant', 'doing', 'onArrival', 'because', 'offer'])
if (!s[f]) E(`motif ${id}: situation missing ${f}`);
if (/\b(is|are|but)\b|—/.test(s.occupant ?? '')) E(`motif ${id}: occupant "${s.occupant}" carries a verb; must be a noun phrase`);
if (!/^\w+ing\b/.test(s.doing ?? '') || /^(some|no|any|every)thing\b/.test(s.doing ?? ''))
E(`motif ${id}: doing "${String(s.doing).slice(0, 40)}" must be a participle phrase`);
}
for (const t of m.temptations ?? []) {
for (const f of ['id', 'cue', 'benefit', 'useCost', 'standingDrawback'])
if (!t[f]) E(`motif ${id}: temptation ${t.id ?? '?'} missing ${f}`);
}
}
for (const r of ROLES) {
const n = (pack.areaNames?.[r] ?? []).length;
if (n < MIN.areaNamesPerRole) E(`areaNames.${r}: ${n} names, needs ${MIN.areaNamesPerRole}`);
}
const allNames = Object.values(pack.areaNames ?? {}).flat();
if (new Set(allNames).size !== allNames.length) E('areaNames contains duplicates across roles');
for (const h of HEATS) {
const r = pack.rosters?.[h];
if (!r) { E(`rosters.${h} missing`); continue; }
if (!r.line || !r.foes?.length) E(`rosters.${h}: needs a line and foes`);
if (!r.harmedBy) E(`rosters.${h}: must say what harms them — a party discovering an immunity by failing is how playtest 1 died`);
if (!r.avoid) W(`rosters.${h}: no avoidance note`);
for (const f of r.foes ?? [])
for (const k of ['n', 'name', 'atk', 'def', 'grit', 'nerve'])
if (f[k] === undefined) E(`rosters.${h}: foe ${f.name ?? '?'} missing ${k}`);
}
for (const r of ROLES) if (!pack.facetProgression?.[r]) E(`facetProgression.${r} missing`);
if (!pack.triggerGlossary || Object.keys(pack.triggerGlossary).length < 3) W('triggerGlossary is thin');
return { name, errs, warns };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const dir = join(here, 'content');
const names = process.argv.slice(2).length
? process.argv.slice(2)
: readdirSync(dir).filter(f => f.endsWith('.json') && f !== 'index.json').map(f => f.replace('.json', ''));
let bad = 0;
for (const n of names) {
const pack = JSON.parse(readFileSync(join(dir, `${n}.json`), 'utf8'));
const { errs, warns } = validatePack(pack, n);
const counts = `${Object.keys(pack.motifs ?? {}).length} motifs`;
if (!errs.length) console.log(` ✅ ${n.padEnd(10)} ${counts}${warns.length ? ` (${warns.length} warnings)` : ''}`);
else { bad++; console.log(` ❌ ${n.padEnd(10)} ${errs.length} errors`); for (const e of errs.slice(0, 12)) console.log(` ${e}`); }
for (const w of warns.slice(0, 4)) console.log(` ⚠ ${w}`);
}
process.exit(bad ? 1 : 0);
}
+356
View File
@@ -0,0 +1,356 @@
# DELVE — the App and the Rules
*Draft 1, 2026-08-05. Written against the code at `316716e`, module v0.6.1, generator 0.2.0.*
*Every number here was read out of the source, not remembered. Line references are live.*
---
## 0. What this document is
`DESIGN.md` is a design history — it records how DELVE was argued into existence, including the
parts that failed. It is the wrong thing to hand someone who asks "what is this and how do I run
it?"
This is that document. Two halves:
- **The App** — what the software is, the two surfaces, and the workflow at the desk and the table.
- **The Rules** — the in-play procedure a GM actually runs, stated as rules rather than as
architecture.
It is a draft. §7 lists what I believe is wrong with it.
---
## 1. Design intent
1. **DELVE is an authoring system, not an adventure generator.** It gets a GM to a strong first
draft in a minute instead of an evening. It does not hand over something to run cold.
2. **It generates the fiction, the order and the pressure.** It does not generate read-aloud prose
or the climax, and it says so on the page where they are missing.
3. **The fiction coheres because it is projected from one root draw**, not assembled from four
independent tables.
4. **Generation and table dice stay apart.** The core decides *when* a roll is due and what it
means; the table rolls it. This is what makes the seed promise honest.
5. **It is deliberately not a balance engine.** It plans pressure; it does not guarantee a fair
fight.
---
# PART ONE — THE APP
## 2. The two surfaces and one working file
Four independent reviews of generated output converged on the same verdict: *"the handwritten
delve still tells the GM what matters faster."* The density was the problem — and it turned out
not to be a bug to render away. **Everything on the page is a vice at the table and a virtue at
the desk.**
So there are two surfaces over one file:
| | For | Carries |
|---|---|---|
| **Worksheet** | the desk | everything, labelled, reroll commands beside each component, explicit prompts where only a human can write |
| **Play sheet** | the table | the GM's own prose, the live leverage point, the decision on one line, the numbers. Nothing else |
**The JSON file is the document.** It carries an `authored` layer the generator never touches
([`authoring.mjs:22`](../../core/authoring.mjs)), so **rerolling a component cannot destroy
prose**. Lock what is good, reroll what is not, write the read-aloud, export the play sheet.
- Authored fields: `readAloud`, `notes`, `nameOverride`
- Rerollable components: `situation`, `decision`, `temptation`, `feature`, `name`
- A reroll draws from a salted seed and avoids what the rest of the delve already uses; when the
pool is exhausted it duplicates another area rather than handing back the same thing, because
**a reroll that changes nothing looks broken**
## 3. At the desk — the CLI
`core/` is pure JavaScript with no Foundry dependencies, so the whole authoring loop runs in Node.
```bash
node core/cli.mjs --new --seed=my-delve --theme=port --file=my.json
node core/cli.mjs --file=my.json # the worksheet (default)
node core/cli.mjs --file=my.json --reroll=3:situation
node core/cli.mjs --file=my.json --lock=3:decision
node core/cli.mjs --file=my.json --write=3:readAloud --text="They have been waiting."
node core/cli.mjs --file=my.json --todo # what still needs a human
node core/cli.mjs --file=my.json --play # the table-facing sheet
node core/cli.mjs --themes # list the 16 packs
```
**A fresh delve is not ready to play, and says so.** `outstanding()` returns the intro, one
read-aloud per area, the ending read-aloud, and — when the ending is `authored` — the climax
itself. A six-area delve starts with **9 outstanding items**. The play sheet prints a warning
banner until they are gone.
## 4. At the table — the Foundry module
Three tools in the scene controls:
| | Tool | Does |
|---|---|---|
| ⛏ | `delve-forge` | raise a dungeon — opens the panel, generates or loads, stages it in the world |
| 🗑 | `delve-remove` | remove one — deletes exactly what it made and nothing else |
| ⏩ | `delve-next` | stage the next area of a delve you finished at the desk |
**Entering an area** ([`vanity-delve.mjs:83`](../../foundry-module/module/vanity-delve.mjs)) does
five things in a fixed order:
1. Builds the scene — `forge.stage({ populate: false, activate: true })`
2. Forges the encounter, if the area plans one — with `hoard: false` so loot is decoupled
3. Forges the hoard, if the area plans one — at its own tier
4. **Posts the read-aloud to everyone** — the GM's prose, or the cue fragments marked *(unwritten)*
5. **Posts the GM card, quietly** — situation, decision with resolution, foe block, temptation,
and a code line carrying the trigger, the Bane beat and the fallback route
Players first, then the GM. That ordering is deliberate: the scene is up and the read-aloud is
what the table came for.
**The seams.** DELVE requires VANITY **0.10.4+**. Below that floor the Forge's suppression options
do not exist and every delve emits 9 stray chat cards and 3 stray folders. The module detects this
at boot and warns. Requires Foundry v13+, verified on 14.365.
## 5. What the seed promises
State this plainly in any UI. **A partial seed sold as a full one is worse than no seed.**
| Reproducible from the seed | Recorded only | Live at the table |
|---|---|---|
| Fiction, area order, heat, hoard tier, foreshadow chain, situations, decisions, temptations | Encounter composition, mood, hoard contents | The clock, the tab, player choices |
The gap exists because VANITY's Forge uses `Math.random` internally (36 sites). Until an injectable
RNG lands, a seed replays the *delve* but not the *dungeon's population*.
`GENERATOR_VERSION` (currently **0.2.0**) is stamped into every file. It tracks the shape of the
file and what a seed yields — **not** the module version.
---
# PART TWO — THE RULES
*This is the procedure. A GM who has read this section can run a delve.*
## 6. The kernel — where a delve comes from
One root draw, everything else projected:
```
APPETITE ──> motif ──> danger, attention trigger, every cue fragment
│ ──> prize kind
│ ──> who was conscripted
└─ + ACCOMMODATION ──> the transgression, the bottom problem, the primary faction
```
An **appetite** is a want — *to be looked at*, *never to age*, *to be first*. An
**accommodation** is the monstrous thing someone did to satisfy it — *had the court interred
alive*, *had the faces struck off*. Accommodations declare which appetites they fit, because
drawing freely produced legible nonsense.
8 appetites × 6 accommodations × 7 claimants = **336 kernels from 21 authored lines**. Measured:
60 generated delves produced **51 distinct kernels**.
**The GM needs one line of this at the table:** the transgression, the bottom problem, and what
the thing at the bottom is now unable to bear.
## 7. The shape of a delve
Areas are assigned **arc roles** by position, so a delve has a shape rather than a flat run of
rooms. Proportional — works at 3 areas or 12 ([`director.mjs:37`](../../core/director.mjs)):
| Position | Role | Foreshadow facet |
|---|---|---|
| first 20% | approach | institution |
| to 45% | complication | ritual |
| to 65% | turn | demand |
| to 85% | descent | wound |
| last 15% | threshold | anchor |
| — | ending (separate slot) | demand |
The facet column is the **foreshadow chain**, and it is why area 2's held bows and area 4's
scratched-out eyes point at the same faceless queen — they are facets of one motif, dealt in a
deliberate order. The progression was proposed independently and turned out to describe the
hand-authored paper delve exactly.
**Approach areas stay quiet.** An empty area is what makes an occupied one land.
## 8. An area, as the GM runs it
Every area carries the same parts, in this order on the card:
**1 · The situation.** Someone doing something, and what changes when the players walk in.
> *"The tide watch, turning a glass that has already run through — it asks what the hour is, and
> means the tide."*
It comes with an **offer** — what the players can get out of it — and a **because**, the reason it
behaves that way. Situations are drawn from the motif, never a global pool.
**2 · The decision.** One thing you can do about the situation. Always states three outcomes:
`[Wits 2]` success · **miss** what failure costs · **or** the alternative that needs no roll
Four test invariants police this: every decision states success **and** failure, offers an attempt
rather than a pick, and **never gates progress without an alternative**. Playtest 1 died on a gate
with no fallback.
**3 · The clue.** Automatic on entry — the cue fragment, plus *"and it was done deliberately"*.
The roll buys extra: `[VANITY: Observation — 1 success]` buys **who** did it and **roughly when**.
Clues are never gated behind a roll; playtest 1 lost both its clues to single unopposed rolls and
the motif never reached the table.
**4 · The encounter**, where one is planned. Foe counts and atk/def/Grit/Nerve in a table, plus
**"harmed by"** stated *before* initiative and **"avoidable"** spelled out.
**5 · The temptation**, where there is a hoard. The decision lives at **use**, not acceptance —
both playtests took every free-acceptance item without discussion, so free acceptance is not a
temptation, it is an automatic yes.
```
benefit what it does
useCost usually +2 Bane
standingDrawback what it costs you for the rest of the delve
```
**6 · The failure case.** Every area states it. Ordinary areas: *"if they skip this, move its cue
fragment into the next area so the motif still completes."* The threshold: *"if they skip this,
the ending opens with them holding nothing the bottom problem wants."*
**Nothing repeats inside one delve.** Situations, decisions, temptations and features are dealt
without replacement. Picking independently per area repeated a temptation in **75 of 80** test
delves.
## 9. The appeasement move — the way through that is not violence
The best mechanic in the hand-authored delve turned out to project *exactly* from the appetite.
Every appetite implies how it is fed:
| Appetite | The move | Roll |
|---|---|---|
| to be looked at | look at it and say plainly what you see | `[Flair 2]` |
| to be first | stand aside and let it go ahead of you | `[Poise 2]` |
| never to age | tell it that it has not changed | `[Flair 2]` |
| never to be alone | sit down and stay a while | `[Poise 2]` |
**+1 Vanity now, +1 Bane later. Repeatable, in every area.**
> **Say the Bane aloud when it banks**, so the table can see the route is open.
This is the delve's social spine. It is always thematically exact, and a party that appeases even
once reaches an ending that will *trade* rather than fight.
## 10. The clock — Wandering the Dark
**Triggers lead; the timer is a floor.** Playtest 1's clock fired 0 times in 4 rolls on a 3-Turn
timer, while the attention trigger fired on the first roll of pass 2.
Every area emits at least one **attention trigger**:
| Trigger | Means |
|---|---|
| `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 |
**Procedure.** Roll `1d6` on any attention trigger, or when the timer comes round.
**1 — something comes.** Telegraph one Turn ahead — the GM voices a cue before it is due. When it
fires, roll **Reaction (2d6)** for its mood.
| Pressure setting | Timer period |
|---|---|
| slow | every 4 Turns |
| standard | every 3 Turns |
| hunted | every 2 Turns |
**Escalation.** If the clock has not fired by the halfway area, the trigger number widens from 1
to 2 for the rest of the delve. A clock that never bites is decoration.
> ⚠ **The escalation and the period do not currently reach the table.** See §7 of the features
> draft — this section describes the rule as designed and as implemented in `pressure.mjs`, not as
> the Foundry module currently behaves.
## 11. The tab and the Reckoning
**Banes are emitted by named beats, never hoped for.** Playtest 1 banked 1 Bane in ~40 rolls
because Stumbles are rare. The director offers a Bane beat when the tab falls behind pace — always
when behind, and only half the time when on pace, so it does not feel mechanical.
| Source | Banks a Bane when you… |
|---|---|
| `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 |
**Target: ~5 Banes by the ending. THE RECKONING falls at 6.**
> ⚠ **The target is UNCALIBRATED.** Playtest 2 reached 3. It is a target, not a promise.
Quiet areas offer only `noticing` and `announcing`; louder areas add `flattery`, `relicUse` and
`refusal`.
## 12. Endings
The ending is a **separate slot** after the last area, and by default it is **yours**.
- **`authored`** (default) — DELVE writes the question the place asks and leaves the climax blank.
It says so on the card: *"the climax is unwritten. DELVE leaves this to you on purpose —
improvise, or stop and write it."*
- **`generated`** — the ending gets a real heat (one step above the delve's base) and a vault-tier
hoard.
**Appeased even once → it listens, and will trade the prize.** That is the ending the social spine
buys.
## 13. Parameters
| Parameter | Range | Default | Confidence |
|---|---|---|---|
| Areas (excludes the ending slot) | 3–12 | 6 | working default — one theme, one table, two passes |
| Theme | 16 packs, 5 geometries | barrow | all validate; **only barrow is playtested** |
| Depth | 1–5 | 2 | |
| Party | 1–8 | 4 | ≤2 softens the heat, ≥7 hardens it |
| Deadliness | forgiving / standard / cruel | standard | **severity only** |
| Density | sparse / standard / infested | standard | **frequency only** — 0.20 / 0.34 / 0.55 of areas |
| Greed | lean / standard / glutted | standard | shifts the hoard tier one step |
| Pressure | slow / standard / hunted | standard | ⚠ **currently inert — see features draft** |
| Ending | authored / generated | authored | a default, not a law |
**Heat** runs `skirmish → fight → battle → nightmare`; **hoards** run
`pocket → cache → chest → vault → kingly`. Descent and threshold areas run one step hotter. The
area before the ending always carries something — the threshold should have teeth. Treasure is not
only behind fights: unless greed is `lean`, one treasureless area gets a bonus hoard.
## 14. What DELVE will not do
- **No physically coherent tiled dungeon.** v1 is a pointcrawl.
- **No balance guarantee.**
- **No sheet writes.** DELVE offers; the players and the GM decide.
- **No system-generated climax by default.**
- **No full replay from seed** until the Forge's RNG seam lands.
- **No generated read-aloud prose.** Cue fragments only, labelled as fragments. This is the honest
ceiling, and faking prose would be the one dishonest thing this tool could do.
---
## 15. What I think is wrong with this draft
Flagging these rather than hiding them, since this is going out for review.
1. **§10 and §13 document a rule the software does not run.** The pressure setting is inert end to
end and the escalation never reaches the table. I have written the rule as designed and flagged
it twice, but a rules document that describes unimplemented behaviour is a liability. The
alternative — documenting the degenerate behaviour — is worse. **The right fix is in the code,
not here.**
2. **Part Two is written for a GM; Part One is written for a developer.** They may want to be two
files. I kept them together because the seed promise and the two surfaces are rules-relevant.
3. **No worked example.** A rules document of this kind usually ends with one complete annotated
area. I have not included one because the samples in `samples/` are stamped generator 0.1.0 and
one lacks the authored layer.
4. **The Reckoning is underspecified here.** I state that it falls at 6 because that is what
`reckoningDue()` returns, but *what happens* at a Reckoning is VANITY's rule, not DELVE's, and I
have not cross-checked the rulebook. Someone should.
5. **Nothing here has been tested at a table.** Every claim is read from source or from two paper
playtests of a hand-written delve.
+237
View File
@@ -0,0 +1,237 @@
# DELVE — proposed features
*Draft 1, 2026-08-05. Against `316716e`, module v0.6.1, generator 0.2.0.*
*Ranked. Every "Evidence" line was verified in the source or by running the code, not recalled.*
---
## The gate
**DELVE has never been run at a live table.** Sixteen themes, 39 tests, six releases and two paper
playtests of a *hand-written* delve — but no session has ever used the generated article.
That fact sets the order below. Items 1–4 are things I can justify without a session, because they
are defects or they are measurement. Items 5–9 are things I would not start until a table has run
one delve, because a session will change what they should be. **The most valuable thing that could
happen to this project is four hours and four players, not another feature.**
---
# TIER 1 — defects wearing feature costumes
*These should land before anything new is built. Both are the same root cause.*
## 1. Wire the live Pressure engine to the table
**What.** The Foundry module should instantiate `Pressure` from the loaded delve and drive the
clock through it, instead of reimplementing a degenerate version inline.
**Why.** `pressure.mjs` is 130 lines that exist *because two playtests falsified the originals* —
escalation, telegraphing, and pace-checking were each added to fix a specific observed failure.
None of that reaches a table. The module's clock is:
```js
const fired = roll.total <= 1; // vanity-delve.mjs:147
```
A hardcoded 1. So:
- **Escalation never happens.** `Pressure.escalate()` widens the trigger from 1 to 2 if the clock
has not fired by the halfway area. Playtest 1's central failure was a clock that rolled 1-in-6
four times and never fired — the exact scenario escalation was written to prevent — and at the
table it still cannot fire.
- **The telegraph never fires.** `telegraphDue()` is never called, so the "voice a cue one Turn
ahead" rule is documentation only.
- **The timer never comes round.** `timerDue()` is never called. `st.turn` increments per area and
is otherwise unused.
- **Pace-checking is generation-only.** `onPace()` shapes which Bane beats get *planned*, but the
live tab (`st.tab`) never feeds back, so a table that banks Banes fast or slow changes nothing.
**Evidence.** The module imports exactly three core modules — `rng`, `authoring`, `forge-app`
([`vanity-delve.mjs:17-19`](../../foundry-module/module/vanity-delve.mjs)). `Pressure`,
`timerDue`, `telegraphDue`, `escalate`, `onPace` and `triggerNumber` appear **nowhere** in
`foundry-module/`. The only hit for "Pressure" is an `<h3>` in the panel.
**Cost.** Small. `Pressure` is already pure and serialisable; the module already persists
`st.clock` and `st.tab` as world state. This is roughly: construct on load, replay the recorded
rolls, call `recordClock()` instead of comparing to a literal, and surface `triggerNumber` in the
card.
**Risk.** Low, but it changes live behaviour mid-campaign for anyone with a delve loaded. Needs a
state migration or a version check on the stored state blob.
**Done when.** A delve that reaches its halfway area without a clock firing shows "something comes
on 1–2" on the next card, and the escalation is visible to the GM.
## 2. Make the pressure setting mean something
**What.** Record the clock period in the delve file and derive the panel text from it.
**Why.** `slow` / `standard` / `hunted` is offered in the UI, stored in `lastParams`, and passed
into generation — and then discarded. `Pressure.summary()`
([`pressure.mjs:119`](../../core/pressure.mjs)) emits `turn`, `clockRolls`, `clockFired`,
`triggerNumber`, `escalated`, `tab` and `target` — **but not `period`**. So the one number the
setting controls never leaves the constructor.
**Evidence.** Generating the same seed at all three settings produces byte-identical pressure
blocks:
```
slow → {"turn":0,...,"triggerNumber":1,"escalated":false,"tab":6,"target":5}
standard → {"turn":0,...,"triggerNumber":1,"escalated":false,"tab":6,"target":5}
hunted → {"turn":0,...,"triggerNumber":1,"escalated":false,"tab":6,"target":5}
```
And the panel hardcodes the standard value regardless
([`forge-app.mjs:188`](../../foundry-module/module/forge-app.mjs)):
> `Roll 1d6 on any attention trigger, or every 3 Turns.`
A GM who picks `hunted` is told 3 Turns and gets 3 Turns.
**Cost.** Trivial — add `period` to `summary()`, read it in the panel. It is item 1's prerequisite
and probably the same commit.
**Risk.** None beyond item 1. Old delve files lack `period`; default to 3.
---
# TIER 2 — build steps designed but never started
## 3. The overlay catalog *(build step 5)*
**What.** A curated encounter catalog layered over the Forge's output, with per-entry theme,
faction, role, uniqueness — plus two fields that must be **tests, not tags**:
- **playability** — *can a hero with a mundane weapon damage this at all?*
- **survivability** — *can the party's weakest member survive one clean hit and two rounds of being
focused?*
**Why.** Both tests come from a corpse. The Wraith's immunity made a finale literally unwinnable; a
5d6 armour-ignoring attack that drains max Grit, against 3 Grit, is not a fight. DELVE currently
plans *heat* and hands composition to the Forge, so it cannot promise either property.
**Cost.** Large — it is authored content per theme plus a selection layer, and it interacts with
the roster data already in the packs.
**Risk.** This is the item most likely to be wrong before a live session. Heat may turn out to be
sufficient at a real table, in which case a curated catalog is a lot of authoring for a problem
that only appeared in simulation. **I would hold this until after the session.**
## 4. A Bane calibration harness
**What.** A pure-core simulator: run N delves against a modelled table, count Banes at the ending,
report the distribution. Then set `baneTarget` from data instead of intuition.
**Why.** The target has been marked **UNCALIBRATED** since draft 5. It is ~5; playtest 2 reached 3.
Every Bane-beat decision in `baneBeatFor()` keys off `onPace()`, which keys off that number — so an
uncalibrated target silently miscalibrates the whole tab engine.
**Cost.** Small, and it needs no Foundry and no table. This is the cheapest real answer available
right now.
**Risk.** A simulated table is not a table. The output is a *prior*, not a calibration, and should
be labelled as one — the same mistake as "6 areas: validated" would be easy to repeat here.
**Done when.** DESIGN.md can state a target with a measured distribution behind it, and open
question 4 either closes or gets sharper.
---
# TIER 3 — worth doing, but after a session
## 5. The play sheet as a Foundry surface
**What.** Render the play sheet in-world — a GM window carrying the current area's prose, the
decision, the numbers, and the live tab, instead of scrollback.
**Why.** The play sheet is currently CLI markdown only. At the table the GM card is *chat*, and
chat scrolls away. The whole two-surface design exists because table-facing material must be
scannable; a surface you have to scroll back through is not.
**Cost.** Medium. Renderer exists (`render-authoring.mjs`); this is a Foundry Application over it.
## 6. In-world authoring
**What.** Write the read-aloud, notes and title inside Foundry — the `authored` layer is already
the right shape for it.
**Why.** Today the only authoring path is the CLI, which means a GM who wants to fix one area's
prose leaves the VTT, edits JSON, and reloads. The module can already *detect* unfinished work
(`outstanding()` is exposed on `game.delve`) but offers no way to resolve it.
**Cost.** Medium. Needs a save-back path to `worlds/<id>/delves/`, which the module currently only
reads.
**Risk.** Two writers on one file. Needs a clear rule about which side owns the file.
## 7. A theme quality audit beyond Barrow
**What.** Read one generated delve per theme against the barrow benchmark, and add whatever
invariant each failure implies.
**Why.** Sixteen packs pass `validate-pack.mjs`, but validation is structural — it proves a pack
*can* fill six areas without repeating, not that the result is any good. Only barrow has ever been
read closely against a playtest. **A pack can pass every invariant and still be dull.**
**Cost.** Medium, and mostly reading rather than coding.
**Risk.** None. This is the item most likely to find something surprising per hour spent.
## 8. The growing delve map *(build step 7)*
**What.** A map that accumulates as areas are staged — the pointcrawl made visible.
**Why.** v1 is explicitly a pointcrawl and players will ask where they are.
**Cost.** Large. Browser-only by necessity (`forgeStage` needs `Image`/canvas/`XMLSerializer`), so
it cannot be tested in the CLI, which is where all 39 tests live.
**Risk.** High. This is the feature most likely to consume a week and produce something a GM
sketches better on paper. **I would want a session to ask for it before building it.**
## 9. Injectable RNG in the Forge → full replay
**What.** Thread a seeded RNG through VANITY's Forge so a seed replays the population too.
**Why.** It closes the honesty gap in the seed promise (§5 of the app draft).
**Cost.** Large and it is in the *other* repo — 36 `Math.random` sites across four helpers. It was
explicitly deferred as not gating a first release, and that judgment still looks right.
---
# TIER 4 — proposed and rejected
**More themes.** Sixteen is already more than has been validated at a table. Another family adds
authored content behind an unmeasured quality bar. **Stop until item 7 runs.**
**Multi-motif or two-faction delves.** The fiction model coheres *because* it has one root draw.
Two motifs is the most plausible way to break the thing that currently works best.
**Generated read-aloud prose.** Named in DESIGN.md as the one dishonest thing this tool could do.
Still true. The cue-fragment ceiling is the honest one.
**A balance guarantee.** Out of scope by design, and the overlay tests (item 3) are the correct
scoped version of this instinct.
---
## Suggested sequence
1. **Items 1 + 2** — one commit, small, fixes a rule that is documented but not running.
2. **Item 4** — cheap, no table needed, turns an admitted unknown into a number.
3. **Run a session.** Four hours, four players, one barrow delve, generated not hand-written.
4. **Item 7** while the session is fresh.
5. Re-rank 3, 5, 6, 8 against what the session actually showed. **I expect at least one of them to
look wrong afterwards, and I would rather find out which one before building it than after.**
## Where I am least confident
- **Item 3's priority.** I have ranked a designed-and-documented build step below a simulation
harness and a reading exercise. That is arguable, and the argument turns entirely on whether
heat-without-curation survives a real fight.
- **Whether items 5 and 6 are one feature.** A play-sheet window that cannot be edited may be half
a thing; shipping both at once may be the smaller total change.
- **The whole ranking assumes the session happens.** If it realistically will not happen soon, tier
3 should be reordered to whatever makes the tool most useful at the desk — probably 6, then 5.
+2 -2
View File
@@ -2,7 +2,7 @@
"id": "vanity-delve",
"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.3",
"version": "0.6.3",
"compatibility": {
"minimum": "13",
"verified": "14.365"
@@ -14,7 +14,7 @@
"type": "system",
"manifest": "https://raw.githubusercontent.com/slaguru666/Vanity/main/system.json",
"compatibility": {
"minimum": "0.10.0"
"minimum": "0.10.4"
}
}
]
+18 -9
View File
@@ -25,7 +25,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"How do I look?\""
},
{
"id": "remembered",
@@ -47,7 +48,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Say my name.\""
},
{
"id": "obeyed",
@@ -69,7 +71,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Do as you are told.\""
},
{
"id": "young",
@@ -91,7 +94,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Tell me I have not changed.\""
},
{
"id": "envied",
@@ -113,7 +117,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"What did you bring me?\""
},
{
"id": "attended",
@@ -135,7 +140,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"You are not leaving.\""
},
{
"id": "forgiven",
@@ -157,7 +163,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Tell me it was not a sin.\""
},
{
"id": "first",
@@ -179,7 +186,8 @@
"bane": 1
},
"repeatable": true
}
},
"asks": "\"Who goes first?\""
}
],
"accommodations": [
@@ -2146,5 +2154,6 @@
"lingering": "stayed in one place longer than it takes to search it",
"disturbed": "struck or moved something that had not moved in centuries"
},
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>."
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>.",
"placeNameStyle": "claimant"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,101 @@
{
"schema": 1,
"themes": [
{
"id": "barrow",
"label": "Barrow",
"geometry": "barrow",
"motifs": 8
},
{
"id": "castle",
"label": "Castle",
"geometry": "barrow",
"motifs": 4
},
{
"id": "cave",
"label": "Cave",
"geometry": "cave",
"motifs": 4
},
{
"id": "church",
"label": "Church",
"geometry": "barrow",
"motifs": 4
},
{
"id": "city",
"label": "City",
"geometry": "village",
"motifs": 4
},
{
"id": "forest",
"label": "Forest",
"geometry": "forest",
"motifs": 4
},
{
"id": "lake",
"label": "Lake",
"geometry": "fen",
"motifs": 4
},
{
"id": "market",
"label": "Market",
"geometry": "village",
"motifs": 4
},
{
"id": "mountain",
"label": "Mountain",
"geometry": "cave",
"motifs": 4
},
{
"id": "palace",
"label": "Palace",
"geometry": "barrow",
"motifs": 4
},
{
"id": "port",
"label": "Port",
"geometry": "village",
"motifs": 4
},
{
"id": "river",
"label": "River",
"geometry": "fen",
"motifs": 4
},
{
"id": "temple",
"label": "Temple",
"geometry": "barrow",
"motifs": 4
},
{
"id": "town",
"label": "Town",
"geometry": "village",
"motifs": 4
},
{
"id": "valley",
"label": "Valley",
"geometry": "forest",
"motifs": 4
},
{
"id": "village",
"label": "Village",
"geometry": "village",
"motifs": 4
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -15
View File
@@ -9,7 +9,13 @@ import { buildArea, fallbackRoute } from './beat.mjs';
import { Pressure } from './pressure.mjs';
import { baneBeatFor } from './director.mjs';
export const GENERATOR_VERSION = '0.1.0';
/**
* Stamped into every delve file and shown on both surfaces, so a file says which generator made
* it. Bump it when the shape of the emitted object changes or when content selection changes
* enough that the same seed no longer yields the same delve — not when the module version moves.
* 0.2.0: the `authored` layer, motif-scoped decisions/temptations/features, areas as situations.
*/
export const GENERATOR_VERSION = '0.2.0';
export function generateDelve(params = {}) {
const {
@@ -103,7 +109,7 @@ export function generateDelve(params = {}) {
...plan.ending,
name: (pack.areaNames?.ending ?? ['The Ending'])[0],
cueFragments: [endFs?.fragment].filter(Boolean),
question: endingQuestion(skeleton),
question: endingQuestion(skeleton, pack),
authored: plan.ending.mode === 'authored',
},
triggerGlossary: pack.triggerGlossary ?? {},
@@ -112,17 +118,12 @@ export function generateDelve(params = {}) {
};
}
/** 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?"';
/**
* The bottom problem asks one thing, and it is the same question the whole delve has been about.
* It lives on the appetite in the theme pack — hardcoding it here meant any theme whose appetite
* ids differed from barrow's silently fell through to a generic line.
*/
function endingQuestion(sk, pack) {
const appetite = pack?.appetites?.find(a => a.id === sk.knot.appetiteId);
return appetite?.asks ?? '"Well?"';
}
+15 -5
View File
@@ -22,7 +22,13 @@
import { arcRole } from './director.mjs';
/** Fills {conscript}-style slots. */
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`)
// Conscripts are plural noun phrases ("the climbers"), so a template's `{conscript}'s`
// renders "the climbers's". English takes a bare apostrophe on a plural already ending in s.
.replace(/s's\b/g, "s'");
/** Claimants may already carry their own article — "The Grey" must not become "The The Grey". */
const named = c => (/^(the|a|an) /i.test(c) ? c : `The ${c}`);
/** Facet order used when a facet is exhausted — borrow from a neighbour before repeating. */
const FACET_ORDER = ['institution', 'ritual', 'demand', 'wound', 'anchor'];
@@ -62,11 +68,12 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
const prize = { kind: appetite.prize, ...(pack.prizes?.[appetite.prize] ?? {}) };
const transgression =
`The ${claimant} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
`${named(claimant)} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
`so that ${appetite.purpose}.`;
// The bottom problem is the claimant itself — a person in a tomb, an office in a town.
const bottomProblem = {
label: `The ${claimant}`,
label: named(claimant),
wantNow: appetite.want,
failureState: `is ${appetite.lack} now, and cannot bear it`,
handledBy: 'giving them what they want, at a price',
@@ -79,8 +86,11 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
};
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}`;
// A barrow or keep is named for its occupant — drawing that separately produced tombs named
// after strangers. A settlement is not: it has its own name and the claimant merely runs it.
const placeName = pack.placeNameStyle === 'own'
? `${r.derive('place1').pick(pack.placeNames.first)} ${r.derive('place2').pick(pack.placeNames.second)}`
: `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`;
// ---- the foreshadow chain ----------------------------------------------
const progression = pack.facetProgression ?? {};
+45 -7
View File
@@ -18,7 +18,36 @@ 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'];
/**
* What a forged foe actually is.
*
* A pack's roster describes the encounter DELVE *planned* — "1 Ghoul and 2 Skeletons", with stats
* and, in some themes, "blessed, silvered or magical weapons ONLY". The Forge does not take a
* cast: it rolls its own monsters from the heat. So the roster and the actors in the world are two
* different lists, and printing the roster's numbers beside the Forge's actors told the GM to run
* a fight against foes that were never created.
*
* Read the numbers off the documents that exist. The plan is still worth showing — it carries
* authored tactical guidance — but it has to be labelled as the plan.
*/
export const foeStats = a => ({
name: a.name,
uuid: a.uuid,
atk: a.system?.attack1?.pool ?? null,
def: a.system?.defence?.pool ?? null,
grit: a.system?.grit?.value ?? null,
nerve: a.system?.nerve ?? null,
trick: a.system?.trick ?? '',
});
/** One foe as a line of stats, in the roster's own vocabulary so the two read alike. */
export const foeLine = f =>
`<b>@UUID[${f.uuid}]{${f.name}}</b> — ${f.atk ?? '?'}/${f.def ?? '?'}/${f.grit ?? '?'}, Nerve ${f.nerve ?? '?'}${f.trick ? `. <i>${f.trick}</i>` : ''}`;
let THEMES = [{ id: 'barrow', label: 'Barrow' }];
export function setThemes(list) { THEMES = list; }
const PICKS = {
depth: [1, 2, 3, 4, 5],
party: [1, 2, 3, 4, 5, 6, 7, 8],
@@ -76,9 +105,13 @@ export class DelveForgeApp extends HandlebarsApplicationMixin(ApplicationV2) {
*/
export async function raiseDungeon(params = {}) {
if (!game.user.isGM) return ui.notifications.warn('DELVE is a GM tool.');
const PACK = game.delve.pack;
// Load the chosen theme's pack on demand — only the default is preloaded.
const PACK = await game.delve.loadPack(params.theme ?? 'barrow');
if (!PACK) return ui.notifications.error(`DELVE: could not load the ${params.theme} theme.`);
const seed = params.seed || coinSeed(new Rng(String(Date.now())));
const d = generateDelve({ pack: PACK, ...params, seed });
// pack last: a programmatic caller passing params.pack would otherwise generate from one pack
// while the maps below are staged from the one actually loaded.
const d = generateDelve({ ...params, seed, pack: PACK });
const sk = d.skeleton;
const title = sk.placeName;
@@ -119,7 +152,7 @@ export async function raiseDungeon(params = {}) {
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 }));
area._foes = (enc?.actors ?? []).map(foeStats);
}
if (area.hoard) {
const h = await game.vanity.forge.hoard({ size: area.hoard, ...quiet })
@@ -208,11 +241,16 @@ function buildPages(d, scenes) {
${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>
${a._foes?.length ? `<p><b>${esc(cap(a.encounter.heat))} — in the world.</b> These are the actors the Forge created; run the fight off these.</p>
<table><thead><tr><th>Foe</th><th>atk</th><th>def</th><th>Grit</th><th>Nerve</th><th></th></tr></thead><tbody>
${a._foes.map(f => `<tr><td>@UUID[${f.uuid}]{${esc(f.name)}}</td><td>${f.atk ?? '?'}</td><td>${f.def ?? '?'}</td><td>${f.grit ?? '?'}</td><td>${f.nerve ?? '?'}</td><td><i>${esc(f.trick)}</i></td></tr>`).join('')}
</tbody></table>
${R ? `<p><i>DELVE planned ${esc(R.line)}. The Forge rolls its own cast, so the plan's foes are not these — its tactical notes describe monsters that were not created.</i></p>` : ''}`
: R ? `<p><b>${esc(cap(a.encounter.heat))} — not cast.</b> Nothing was forged for this area, so the plan is the encounter. Cast it by hand:</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>` : ''}
</tbody></table>
<p>Harmed by ${esc(R.harmedBy)}${R.harmedBy.includes('ONLY') ? ' — <b>say so before initiative</b>' : ''}. <i>${esc(R.avoid)}.</i></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>` : ''}
+70 -9
View File
@@ -16,16 +16,50 @@
*/
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';
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes, foeStats, foeLine } from './forge-app.mjs';
const MOD = 'vanity-delve';
const FLAG = 'state';
let PACK = null;
let seamsPresent = false;
let loadPack = async () => null;
const getState = () => game.settings.get(MOD, FLAG) ?? null;
const setState = async s => game.settings.set(MOD, FLAG, s);
/**
* The pack a delve was actually authored against.
*
* PACK is only a boot-time default. A delve brought in from the desk may be any of the sixteen
* themes, and staging one with the wrong pack gives a harbour delve a barrow map — the fiction
* says quayside and the geometry says burial chamber. Resolved per call rather than once at load,
* because the global resets on a page reload while the staged delve in world state does not.
*
* `params.theme` is written from `pack.id` at generation time, so every delve the generator made
* carries it. `load()` also accepts hand-edited JSON, and there the field may be absent — which is
* why this fails closed rather than falling back to barrow. Guessing the geometry is the bug.
*/
async function packById(id) {
if (!id) return null;
if (PACK?.id === id) return PACK;
const p = await loadPack(id);
if (!p?.forgeStageType) return null;
PACK = p;
return p;
}
/** The pack a staged delve was authored against. Never guesses. */
async function packFor(d) {
const id = d?.params?.theme;
if (!id) {
ui.notifications.error('DELVE: that delve does not record a theme — refusing to stage, the geometry would be a guess.');
return null;
}
const p = await packById(id);
if (!p) ui.notifications.error(`DELVE: could not load the ${id} theme — refusing to stage, it would use the wrong geometry.`);
return p;
}
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
const list = items => `<ul>${items.filter(Boolean).map(i => `<li>${i}</li>`).join('')}</ul>`;
@@ -50,6 +84,7 @@ async function load(working) {
if (!d?.skeleton || !Array.isArray(d.areas)) return ui.notifications.error('DELVE: that is not a delve file.');
const todo = outstanding(d);
if (!await packFor(d)) return; // before anything is created in the world
const folder = await Folder.create({ name: `Delve — ${d.authored?.title ?? d.skeleton.placeName}`, type: 'Actor' });
await setState({ delve: d, folderId: folder.id, at: 0, turn: 0, tab: [], clock: [] });
@@ -74,7 +109,10 @@ async function loadFile(name) {
/** Generate an unfinished draft in-world. Convenience only — the desk is the right place. */
async function draft(params = {}) {
const seed = params.seed || coinSeed(new Rng(String(game.world.id)));
const d = newWorkingFile({ pack: PACK, ...params, seed });
const theme = params.theme ?? 'barrow'; // drafting picks a theme; staging must be told one
const pack = await packById(theme);
if (!pack) return ui.notifications.error(`DELVE: could not load the ${theme} theme.`);
const d = newWorkingFile({ pack, ...params, seed });
ui.notifications.warn('DELVE: unfinished draft. Write the read-aloud in the worksheet first.');
return load(d);
}
@@ -88,16 +126,18 @@ async function enter() {
const area = d.areas[st.at];
const w = d.authored?.areas?.[area.index] ?? {};
const name = w.nameOverride ?? area.name;
const pack = await packFor(d);
if (!pack) return;
ui.notifications.info(`DELVE: raising ${name}…`);
const quiet = seamsPresent ? { post: false, folderId: st.folderId } : {};
const stage = await game.vanity.forge.stage({
type: PACK.forgeStageType, size: 'medium', name, populate: false, activate: true, ...quiet,
type: pack.forgeStageType, size: 'medium', name, populate: false, activate: true, ...quiet,
});
if (area.encounter) await game.vanity.forge.encounter({
const enc = area.encounter ? await game.vanity.forge.encounter({
heat: area.encounter.heat, forStage: name,
...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}),
});
}) : null;
if (area.hoard) await game.vanity.forge.hoard({ size: area.hoard, ...(seamsPresent ? { post: false } : {}) });
// Players first — the scene is up and this is what they came for.
@@ -105,13 +145,28 @@ async function enter() {
// Then the GM, quietly.
const R = area.encounter?.roster;
const foes = (enc?.actors ?? []).map(foeStats);
const rv = area.decision?.resolve ?? {};
/**
* One rule, both surfaces: show what exists. The roster's tactical guidance — what harms it, how
* to avoid it — describes the planned foes, so it travels with the plan and only when the plan
* IS the encounter. With nothing forged (populate off, or the Forge failed) the plan is all
* there is, and it must stay runnable.
*/
const foeBlock = foes.length
? `<p><b>${cap(area.encounter.heat)} — in the world:</b></p>${list(foes.map(foeLine))}${
R ? `<p><i>DELVE planned ${R.line}; the Forge rolled its own, so the plan's tactics do not describe these.</i></p>` : ''}`
: R
? `<p><b>${cap(area.encounter.heat)} — not cast.</b> Nothing was forged; run the plan by hand:</p>${
list(R.foes.map(f => `${f.n}× <b>${f.name}</b> — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. <i>${f.note}</i>`))
}<p><b>Harmed by ${R.harmedBy}.</b> ${R.avoid}.</p>`
: '';
await gmCard(`⛏ ${area.index} · ${name}`, `${area.role} · ${area.facet}`,
`${area.situation ? `<p><b>Here:</b> ${cap(area.situation.occupant)}, ${area.situation.doing} — ${area.situation.onArrival}.<br>
<b>They can:</b> ${area.situation.offer}. <i>${cap(area.situation.because)}.</i></p>` : ''}
<p><b>${cap(area.decision.cue)}</b>${rv.roll ? ` — [${rv.roll}] ${rv.success}` : ''}${rv.failure ? `<br><b>Miss:</b> ${rv.failure}` : ''}${rv.orElse ? `<br><b>Or:</b> ${rv.orElse}` : ''}</p>
${R ? `<p><b>${cap(area.encounter.heat)}:</b> ${R.line}</p>${list(R.foes.map(f => `${f.n}× <b>${f.name}</b> — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. <i>${f.note}</i>`))}
<p><b>Harmed by ${R.harmedBy}.</b> ${R.avoid}.</p>` : ''}
${foeBlock}
${area.temptation ? `<p><b>${cap(area.temptation.id)}:</b> ${area.temptation.benefit}. <i>Use: ${area.temptation.useCost?.bane ? `+${area.temptation.useCost.bane} Bane` : '—'}. ${area.temptation.standingDrawback}.</i></p>` : ''}
${w.notes ? `<p><b>Your note:</b> ${w.notes}</p>` : ''}
<p><code>${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}</code></p>`);
@@ -157,8 +212,13 @@ Hooks.once('init', () => {
Hooks.once('ready', async () => {
if (!game.user.isGM) return;
PACK = await foundry.utils.fetchJsonWithTimeout(`modules/${MOD}/module/core/content/barrow.json`)
.catch(() => fetch(`modules/${MOD}/module/core/content/barrow.json`).then(r => r.json()));
const fetchJson = p => foundry.utils.fetchJsonWithTimeout(p).catch(() => fetch(p).then(r => r.json()).catch(() => null));
const base = `modules/${MOD}/module/core/content`;
const index = await fetchJson(`${base}/index.json`);
if (index?.themes?.length) setThemes(index.themes);
const packs = {};
loadPack = async id => (packs[id] ??= await fetchJson(`${base}/${id}.json`));
PACK = await loadPack('barrow');
seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? ''));
game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon,
@@ -166,6 +226,7 @@ Hooks.once('ready', async () => {
load, loadFile, draft, enter, ending, bane, clock, state: getState,
outstanding: () => outstanding(getState()?.delve ?? { areas: [] }),
ready: () => readyToPlay(getState()?.delve ?? { areas: [] }),
loadPack: id => loadPack(id),
get pack() { return PACK; } };
console.log(`DELVE | ready. Forge seams ${seamsPresent ? 'present' : 'ABSENT'}.`);
if (!seamsPresent) ui.notifications.warn('DELVE: Forge seams not installed — see the delve-seams branch.');
+1 -1
View File
@@ -5,7 +5,7 @@
<div class="delve-grid">
<label>Theme
<select name="theme">
{{#each themes}}<option value="{{this}}" {{#if (eq this ../v.theme)}}selected{{/if}}>{{this}}</option>{{/each}}
{{#each themes}}<option value="{{this.id}}" {{#if (eq this.id ../v.theme)}}selected{{/if}}>{{this.label}}</option>{{/each}}
</select>
</label>
<label>Areas