Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d220a3428 | ||
|
|
3a180479cb | ||
|
|
7f5804e2f1 | ||
|
|
1ca0a64295 | ||
|
|
316716e001 | ||
|
|
246a5f607d | ||
|
|
5fae50e25b | ||
|
|
7af690205c | ||
|
|
d22421efbd | ||
|
|
b4990533a8 | ||
|
|
075be5c1de |
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2154,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"
|
||||
}
|
||||
@@ -1169,5 +1169,6 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"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
@@ -12,6 +12,90 @@
|
||||
"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
+7
-1
@@ -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 {
|
||||
|
||||
+3
-2
@@ -104,8 +104,9 @@ export function renderMarkdown(d) {
|
||||
L.push('');
|
||||
|
||||
if (R) {
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}` +
|
||||
`${R.harmedBy.includes('ONLY') ? ' — **say so before initiative**' : ''}. *${R.avoid}.*`);
|
||||
// No appended "say so before initiative" — the packs that need it already say it, and three
|
||||
// battle rosters carry the phrase verbatim, so the addendum printed it twice.
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}. *${R.avoid}.*`);
|
||||
L.push('');
|
||||
L.push('| Foe | atk | def | Grit | Nerve | |');
|
||||
L.push('|---|---|---|---|---|---|');
|
||||
|
||||
+15
-5
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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.2.0",
|
||||
"version": "0.6.4",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1169,5 +1169,6 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"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
@@ -12,6 +12,90 @@
|
||||
"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
@@ -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 {
|
||||
|
||||
@@ -104,8 +104,9 @@ export function renderMarkdown(d) {
|
||||
L.push('');
|
||||
|
||||
if (R) {
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}` +
|
||||
`${R.harmedBy.includes('ONLY') ? ' — **say so before initiative**' : ''}. *${R.avoid}.*`);
|
||||
// No appended "say so before initiative" — the packs that need it already say it, and three
|
||||
// battle rosters carry the phrase verbatim, so the addendum printed it twice.
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}. *${R.avoid}.*`);
|
||||
L.push('');
|
||||
L.push('| Foe | atk | def | Grit | Nerve | |');
|
||||
L.push('|---|---|---|---|---|---|');
|
||||
|
||||
@@ -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 ?? {};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Who is actually in the room.
|
||||
*
|
||||
* A pack's roster is the encounter DELVE *planned* — "1 Ghoul and 2 Skeletons", with stats and,
|
||||
* in some themes, tactical notes naming specific monsters. The Forge takes no cast: it rolls its
|
||||
* own from the heat. So the plan and the world are two different lists, and the rule both surfaces
|
||||
* follow is **show what exists**:
|
||||
*
|
||||
* forged the Forge made actors — run those, and say the plan's tactics are not about them
|
||||
* planned nothing was forged (population off, or the Forge failed) — the plan IS the
|
||||
* encounter, so it renders in full and its guidance applies
|
||||
* unavailable an area carries combat heat but has neither — say so rather than render nothing
|
||||
* none no encounter here at all
|
||||
*
|
||||
* This file is deliberately Foundry-free so the decision can be tested without a VTT. The two
|
||||
* surfaces disagreed about it twice — 0.6.2 lost the planned roster entirely when nothing was
|
||||
* forged, and the caption claimed the plan's guidance applied either way — because each surface
|
||||
* made the choice for itself in a ternary. They now both switch on `kind`.
|
||||
*/
|
||||
|
||||
/** What a forged actor actually is, read off the document rather than off 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 forged foe as a line of stats, in the roster's 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>` : ''}`;
|
||||
|
||||
/**
|
||||
* Decide which of the four cases an area is in.
|
||||
* @param {object} area a delve area
|
||||
* @param {Array} forged actors the Forge created, already through foeStats — [] if it failed
|
||||
*/
|
||||
export function classifyFoes(area, forged = []) {
|
||||
if (!area?.encounter) return { kind: 'none' };
|
||||
const heat = area.encounter.heat;
|
||||
const planned = area.encounter.roster ?? null;
|
||||
if (forged.length) return { kind: 'forged', heat, foes: forged, planned };
|
||||
if (planned) return { kind: 'planned', heat, roster: planned };
|
||||
return { kind: 'unavailable', heat };
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
import { generateDelve } from './core/delve.mjs';
|
||||
import { coinSeed, Rng } from './core/rng.mjs';
|
||||
import { foeStats, classifyFoes } from './foes.mjs';
|
||||
|
||||
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
|
||||
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||
@@ -82,7 +83,9 @@ export async function raiseDungeon(params = {}) {
|
||||
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;
|
||||
|
||||
@@ -123,7 +126,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 })
|
||||
@@ -164,6 +167,26 @@ export async function raiseDungeon(params = {}) {
|
||||
|
||||
/* ---------------------------------------------------------------- journal */
|
||||
|
||||
/**
|
||||
* The journal's foe section, driven by classifyFoes so it cannot disagree with the chat card.
|
||||
* The roster's tactical notes name particular monsters, so they print only where the plan is
|
||||
* itself the encounter.
|
||||
*/
|
||||
function foeSection(c) {
|
||||
const table = rows => `<table><thead><tr><th>Foe</th><th>atk</th><th>def</th><th>Grit</th><th>Nerve</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
if (c.kind === 'forged') return `
|
||||
<p><b>${esc(cap(c.heat))} — in the world.</b> These are the actors the Forge created; run the fight off these.</p>
|
||||
${table(c.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(''))}
|
||||
${c.planned ? `<p><i>DELVE planned ${esc(c.planned.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>` : ''}`;
|
||||
if (c.kind === 'planned') return `
|
||||
<p><b>${esc(cap(c.heat))} — not cast.</b> Nothing was forged for this area, so the plan is the encounter. Cast it by hand:</p>
|
||||
${table(c.roster.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(''))}
|
||||
<p>Harmed by ${esc(c.roster.harmedBy)}. <i>${esc(c.roster.avoid)}.</i></p>`;
|
||||
if (c.kind === 'unavailable') return `
|
||||
<p><b>${esc(cap(c.heat))} — nothing to run.</b> No actors were created and this theme has no roster at this heat. Improvise the fight or skip it; the area's decision and fallback still stand.</p>`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildPages(d, scenes) {
|
||||
const sk = d.skeleton, ap = sk.appeasement;
|
||||
const pages = [];
|
||||
@@ -212,11 +235,7 @@ 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>
|
||||
<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>` : ''}
|
||||
${foeSection(classifyFoes(a, a._foes ?? []))}
|
||||
${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>` : ''}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { coinSeed, Rng } from './core/rng.mjs';
|
||||
import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs';
|
||||
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes } from './forge-app.mjs';
|
||||
import { foeStats, foeLine, classifyFoes } from './foes.mjs';
|
||||
|
||||
const MOD = 'vanity-delve';
|
||||
const FLAG = 'state';
|
||||
@@ -27,6 +28,39 @@ 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 && PACK.forgeStageType) return PACK; // validate the cache too
|
||||
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>`;
|
||||
|
||||
@@ -51,6 +85,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: [] });
|
||||
|
||||
@@ -75,7 +110,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({ ...params, seed, pack }); // pack last — see raiseDungeon
|
||||
ui.notifications.warn('DELVE: unfinished draft. Write the read-aloud in the worksheet first.');
|
||||
return load(d);
|
||||
}
|
||||
@@ -89,30 +127,52 @@ 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}…`);
|
||||
|
||||
// A Forge failure must not leave the area half-raised. If the scene itself fails there is
|
||||
// nothing to run, so stop before the turn advances and let the GM try again. If the population
|
||||
// fails the scene is up and the delve is still playable — the planned roster stands in.
|
||||
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,
|
||||
});
|
||||
if (area.encounter) await game.vanity.forge.encounter({
|
||||
type: pack.forgeStageType, size: 'medium', name, populate: false, activate: true, ...quiet,
|
||||
}).catch(e => { console.error('DELVE | stage failed', e); return null; });
|
||||
if (!stage) return ui.notifications.error(`DELVE: the Forge could not raise ${name}. Nothing staged; try again.`);
|
||||
|
||||
const enc = area.encounter ? await game.vanity.forge.encounter({
|
||||
heat: area.encounter.heat, forStage: name,
|
||||
...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}),
|
||||
});
|
||||
if (area.hoard) await game.vanity.forge.hoard({ size: area.hoard, ...(seamsPresent ? { post: false } : {}) });
|
||||
}).catch(e => {
|
||||
console.error('DELVE | encounter failed', e);
|
||||
ui.notifications.warn(`DELVE: could not populate ${name} — the planned roster stands in.`);
|
||||
return null;
|
||||
}) : null;
|
||||
if (area.hoard) await game.vanity.forge.hoard({ size: area.hoard, ...(seamsPresent ? { post: false } : {}) })
|
||||
.catch(e => { console.error('DELVE | hoard failed', e); return null; });
|
||||
|
||||
// Players first — the scene is up and this is what they came for.
|
||||
await readAloudCard(name, w.readAloud ?? `<i>(unwritten)</i> ${area.cueFragments.join('. ')}.`);
|
||||
|
||||
// Then the GM, quietly.
|
||||
const R = area.encounter?.roster;
|
||||
const rv = area.decision?.resolve ?? {};
|
||||
const c = classifyFoes(area, (enc?.actors ?? []).map(foeStats));
|
||||
const foeBlock =
|
||||
c.kind === 'forged'
|
||||
? `<p><b>${cap(c.heat)} — in the world:</b></p>${list(c.foes.map(foeLine))}${
|
||||
c.planned ? `<p><i>DELVE planned ${c.planned.line}; the Forge rolled its own, so the plan's tactics do not describe these.</i></p>` : ''}`
|
||||
: c.kind === 'planned'
|
||||
? `<p><b>${cap(c.heat)} — not cast.</b> Nothing was forged; run the plan by hand:</p>${
|
||||
list(c.roster.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 ${c.roster.harmedBy}.</b> ${c.roster.avoid}.</p>`
|
||||
: c.kind === 'unavailable'
|
||||
? `<p><b>${cap(c.heat)} — nothing to run.</b> No actors, and no roster at this heat. Improvise or skip; the decision and fallback still stand.</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>`);
|
||||
@@ -164,7 +224,7 @@ Hooks.once('ready', async () => {
|
||||
if (index?.themes?.length) setThemes(index.themes);
|
||||
const packs = {};
|
||||
loadPack = async id => (packs[id] ??= await fetchJson(`${base}/${id}.json`));
|
||||
PACK = await loadPack('barrow');
|
||||
await packById('barrow'); // sets PACK, and validates it like any other
|
||||
seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? ''));
|
||||
|
||||
game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Adapter tests. No Foundry, no shim — foes.mjs is pure on purpose.
|
||||
*
|
||||
* These exist because the same decision broke twice in two releases. 0.6.2 showed the forged
|
||||
* actors and silently dropped the planned roster when nothing had been forged; 0.6.3 fixed that
|
||||
* but the choice still lived in two hand-written ternaries, one per surface. Every case below is
|
||||
* a bug that shipped or nearly shipped.
|
||||
*
|
||||
* Run: node foundry-module/test.mjs
|
||||
*/
|
||||
import { foeStats, foeLine, classifyFoes } from './module/foes.mjs';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const t = (name, cond, detail = '') => { cond ? pass++ : fail++; console.log(`${cond ? ' ok ' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); };
|
||||
|
||||
const actor = {
|
||||
name: 'Morthollow-born', uuid: 'Actor.abc',
|
||||
system: { attack1: { pool: 4 }, defence: { pool: 3 }, grit: { value: 5 }, nerve: 6, trick: 'freezes on a hit' },
|
||||
};
|
||||
const roster = {
|
||||
line: '1 Ghoul and 2 Skeletons',
|
||||
foes: [{ n: 1, name: 'Ghoul', atk: 4, def: 3, grit: 4, nerve: 5, note: 'freezes' }],
|
||||
harmedBy: 'blessed, silvered or magical weapons ONLY — say so before initiative',
|
||||
avoid: 'the Ghoul goes for court dress first',
|
||||
};
|
||||
const withEnc = (extra = {}) => ({ encounter: { heat: 'fight', roster, ...extra } });
|
||||
|
||||
// --- reading a forged actor -------------------------------------------------
|
||||
const f = foeStats(actor);
|
||||
t('foeStats reads the stats off the document', f.atk === 4 && f.def === 3 && f.grit === 5 && f.nerve === 6);
|
||||
t('foeStats keeps the uuid so the GM can open it', f.uuid === 'Actor.abc');
|
||||
const bare = foeStats({ name: 'X', uuid: 'Actor.z', system: {} });
|
||||
t('a missing stat degrades rather than throwing', bare.atk === null && foeLine(bare).includes('?/?/?'));
|
||||
t('foeLine links the actor', foeLine(f).includes('@UUID[Actor.abc]{Morthollow-born}'));
|
||||
t('foeLine omits an empty trick', !foeLine(bare).includes('<i>'));
|
||||
|
||||
// --- the four cases ---------------------------------------------------------
|
||||
t('no encounter → none', classifyFoes({}, []).kind === 'none');
|
||||
t('no encounter → none, even with stray actors', classifyFoes({}, [f]).kind === 'none');
|
||||
|
||||
const forged = classifyFoes(withEnc(), [f]);
|
||||
t('actors forged → forged', forged.kind === 'forged' && forged.foes.length === 1);
|
||||
t('forged keeps the plan, so it can be named as not-these', forged.planned === roster);
|
||||
|
||||
const planned = classifyFoes(withEnc(), []);
|
||||
t('nothing forged but a roster → planned', planned.kind === 'planned' && planned.roster === roster,
|
||||
'the 0.6.2 regression: population off left no numbers at all');
|
||||
|
||||
const nothing = classifyFoes({ encounter: { heat: 'fight' } }, []);
|
||||
t('heat but neither actors nor roster → unavailable', nothing.kind === 'unavailable',
|
||||
'must not render silence for an area labelled with combat');
|
||||
t('unavailable still reports the heat', nothing.heat === 'fight');
|
||||
|
||||
// --- the invariant the two surfaces kept breaking ---------------------------
|
||||
t('the plan is never the encounter while actors exist', forged.kind !== 'planned',
|
||||
'roster guidance names monsters the Forge did not create');
|
||||
t('a forged classification carries no roster field to render from',
|
||||
forged.roster === undefined, 'so a surface cannot accidentally run the plan');
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
Reference in New Issue
Block a user