Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
246a5f607d | ||
|
|
5fae50e25b | ||
|
|
7af690205c | ||
|
|
d22421efbd | ||
|
|
b4990533a8 | ||
|
|
075be5c1de | ||
|
|
e2d1de2fb8 | ||
|
|
592d540910 | ||
|
|
1be441f775 | ||
|
|
decb3bf930 |
@@ -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,46 @@
|
||||
|---|---|
|
||||
| 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.1.0 — written into every delve file, **not** the module version |
|
||||
|
||||
---
|
||||
|
||||
## 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 +153,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 +194,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 +256,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,19 +354,28 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
*Private VANITY development material — not for sale or distribution.*
|
||||
*VANITY development notes — part of [DELVE](https://github.com/slaguru666/vanity-delve).*
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 slaguru666
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,56 @@
|
||||
# DELVE
|
||||
|
||||
An authoring system and Foundry VTT module for the **VANITY** tabletop RPG. Press one button and
|
||||
it generates a whole dungeon — fiction, areas, maps, encounters, hoards and a journal you can read
|
||||
cold — then drops it into your world.
|
||||
|
||||
## Install (Foundry)
|
||||
|
||||
**Add-on Modules → Install Module → Manifest URL:**
|
||||
|
||||
```
|
||||
https://github.com/slaguru666/vanity-delve/releases/latest/download/module.json
|
||||
```
|
||||
|
||||
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
|
||||
* **⏩** stage a delve you finished at the desk
|
||||
|
||||
## Also a CLI
|
||||
|
||||
`core/` is pure JavaScript with no Foundry dependencies, so a delve can be generated, rerolled and
|
||||
finished outside the VTT:
|
||||
|
||||
```bash
|
||||
node core/cli.mjs --new --seed=my-delve --file=my.json
|
||||
node core/cli.mjs --file=my.json --worksheet # everything, with prompts for what only you can write
|
||||
node core/cli.mjs --file=my.json --reroll=3:situation
|
||||
node core/cli.mjs --file=my.json --play # the tight, table-facing sheet
|
||||
```
|
||||
|
||||
## What it does and does not do
|
||||
|
||||
It generates the fiction, the order and the pressure. It does **not** write your read-aloud text
|
||||
or your climax, and it says so — those are prompts, not output. It gets you to a strong first
|
||||
draft in a minute instead of an evening, then gets out of the way.
|
||||
|
||||
[`DESIGN.md`](DESIGN.md) has the whole design history, including four rounds of adversarial review
|
||||
and two paper playtests that failed before anything worked.
|
||||
|
||||
## Layout
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `core/` | the generator — pure JS, 39 tests |
|
||||
| `foundry-module/` | the Foundry module, with `core/` vendored |
|
||||
| `paper/` | a hand-written delve and both playtest logs |
|
||||
| `spike/` | the harness that measured VANITY's Forge outside Foundry |
|
||||
| `samples/` | generated output |
|
||||
|
||||
## Licence
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
+9
-14
@@ -103,7 +103,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 +112,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?"';
|
||||
}
|
||||
|
||||
+1
-1
@@ -165,6 +165,6 @@ export function renderMarkdown(d) {
|
||||
}
|
||||
L.push('---');
|
||||
L.push('');
|
||||
L.push('*Private VANITY development material — not for sale or distribution.*');
|
||||
L.push('*Generated by [DELVE](https://github.com/slaguru666/vanity-delve) for the VANITY RPG.*');
|
||||
return L.join('\n');
|
||||
}
|
||||
|
||||
+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 ?? {};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 slaguru666
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -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.0",
|
||||
"version": "0.6.1",
|
||||
"compatibility": {
|
||||
"minimum": "13",
|
||||
"verified": "14.365"
|
||||
@@ -11,9 +11,10 @@
|
||||
"systems": [
|
||||
{
|
||||
"id": "vanity",
|
||||
"type": "requires",
|
||||
"type": "system",
|
||||
"manifest": "https://raw.githubusercontent.com/slaguru666/Vanity/main/system.json",
|
||||
"compatibility": {
|
||||
"minimum": "0.10.0"
|
||||
"minimum": "0.10.4"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -32,5 +33,6 @@
|
||||
"manifest": "https://github.com/slaguru666/vanity-delve/releases/latest/download/module.json",
|
||||
"download": "https://github.com/slaguru666/vanity-delve/releases/latest/download/vanity-delve.zip",
|
||||
"url": "https://github.com/slaguru666/vanity-delve",
|
||||
"readme": "https://github.com/slaguru666/vanity-delve/blob/main/foundry-module/README.md"
|
||||
"readme": "https://github.com/slaguru666/vanity-delve/blob/main/foundry-module/README.md",
|
||||
"license": "https://github.com/slaguru666/vanity-delve/blob/main/LICENSE"
|
||||
}
|
||||
@@ -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
@@ -103,7 +103,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 +112,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?"';
|
||||
}
|
||||
|
||||
@@ -165,6 +165,6 @@ export function renderMarkdown(d) {
|
||||
}
|
||||
L.push('---');
|
||||
L.push('');
|
||||
L.push('*Private VANITY development material — not for sale or distribution.*');
|
||||
L.push('*Generated by [DELVE](https://github.com/slaguru666/vanity-delve) for the VANITY RPG.*');
|
||||
return L.join('\n');
|
||||
}
|
||||
|
||||
@@ -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 ?? {};
|
||||
|
||||
@@ -18,7 +18,9 @@ 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'];
|
||||
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,7 +78,9 @@ 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 });
|
||||
const sk = d.skeleton;
|
||||
|
||||
@@ -16,13 +16,14 @@
|
||||
*/
|
||||
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 } 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);
|
||||
@@ -157,8 +158,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 +172,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.');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -209,4 +209,4 @@ the gang, take the portrait in A5, and have nobody bow — the path v1 punishes
|
||||
|
||||
---
|
||||
|
||||
*Private VANITY development material — not for sale or distribution.*
|
||||
*VANITY development notes — part of [DELVE](https://github.com/slaguru666/vanity-delve).*
|
||||
|
||||
@@ -170,4 +170,4 @@ Next: **step 2, the seam spike.**
|
||||
|
||||
---
|
||||
|
||||
*Private VANITY development material — not for sale or distribution.*
|
||||
*VANITY development notes — part of [DELVE](https://github.com/slaguru666/vanity-delve).*
|
||||
|
||||
@@ -344,4 +344,4 @@ Pass 2 is **contrarian**: Court Door, nobody bows, ally the gang, take the portr
|
||||
|
||||
---
|
||||
|
||||
*Private VANITY development material — not for sale or distribution.*
|
||||
*VANITY development notes — part of [DELVE](https://github.com/slaguru666/vanity-delve).*
|
||||
|
||||
@@ -40,7 +40,7 @@ On success it goes first, and The Silent Host will not open hostilities first.
|
||||
|
||||
---
|
||||
|
||||
### AREA 1 — The Threshold Stones
|
||||
### AREA 1 — The Mourners' Steps
|
||||
*approach · institution*
|
||||
|
||||
> A stair too grand for the door it serves. The queue disturbed — three of the dead pushed aside. One of the Host — reading out an order of precedence to an empty hall. When you walk in, it pauses and asks where you fall in it. None of it is accident — it was done this way on purpose.
|
||||
@@ -76,7 +76,7 @@ On success it goes first, and The Silent Host will not open hostilities first.
|
||||
|
||||
---
|
||||
|
||||
### AREA 3 — The Waiting Room
|
||||
### AREA 3 — The Antechamber
|
||||
*complication · ritual*
|
||||
|
||||
> A herald's script with the order still marked. Someone has walked the corridor the wrong way, scuffing one side. One of the Host with a herald's staff — announcing arrivals to an empty stair. When you walk in, it announces you, inventing a rank. None of it is accident — it was done this way on purpose.
|
||||
@@ -122,7 +122,7 @@ On success it goes first, and The Silent Host will not open hostilities first.
|
||||
|
||||
---
|
||||
|
||||
### AREA 5 — The Sunken Nave
|
||||
### AREA 5 — The Lower Cut
|
||||
*descent · wound · pocket*
|
||||
|
||||
> A procession that doubled back on itself. Banners pulled down and stacked without regard for order. A procession — passing through, unhurried, with you in its way. When you walk in, it does not stop or go round. None of it is accident — it was done this way on purpose.
|
||||
@@ -143,7 +143,7 @@ On success it goes first, and The Silent Host will not open hostilities first.
|
||||
|
||||
---
|
||||
|
||||
### AREA 6 — The Last Door
|
||||
### AREA 6 — The Robing Room
|
||||
*threshold · anchor · battle · chest*
|
||||
|
||||
> A seat at the head of everything, occupied. A rank-plate prised off and dropped, still shiny where it sat. A queue of the Host — waiting to pass a door, in strict order, not moving. When you walk in, the queue opens a gap at exactly your rank. None of it is accident — it was done this way on purpose.
|
||||
@@ -204,4 +204,4 @@ Handled by giving them what they want, at a price.
|
||||
|
||||
---
|
||||
|
||||
*Private VANITY development material — not for sale or distribution.*
|
||||
*Generated by [DELVE](https://github.com/slaguru666/vanity-delve) for the VANITY RPG.*
|
||||
@@ -40,7 +40,7 @@ On success it was obeyed, and The Silent Host will not open hostilities first.
|
||||
|
||||
---
|
||||
|
||||
### AREA 1 — The Weeping Stair
|
||||
### AREA 1 — The Outer Cut
|
||||
*approach · institution*
|
||||
|
||||
> Servants' bones arranged as though mid-errand. Someone has been talking here, and stopped when you arrived. Three of the Host in servants' dress — carrying out an errand that has no destination left. When you walk in, they stop, and wait to be told what to do. None of it is accident — it was done this way on purpose.
|
||||
@@ -58,7 +58,7 @@ On success it was obeyed, and The Silent Host will not open hostilities first.
|
||||
|
||||
---
|
||||
|
||||
### AREA 2 — The Lesser Vault
|
||||
### AREA 2 — The Waiting Room
|
||||
*complication · ritual · fight · cache*
|
||||
|
||||
> Orders scratched into the floor, the last one unfinished. A door braced shut from within by furniture. A living grave-robber — repeating a phrase over and over, trying to get the intonation right. When you walk in, they go quiet and put their back to the wall. None of it is accident — it was done this way on purpose.
|
||||
@@ -86,7 +86,7 @@ On success it was obeyed, and The Silent Host will not open hostilities first.
|
||||
|
||||
---
|
||||
|
||||
### AREA 3 — The Crossing
|
||||
### AREA 3 — The Middle Vault
|
||||
*turn · demand*
|
||||
|
||||
> A hall where your own voice arrives before you speak. An order half-scratched out and rewritten by a different hand. The bells — ringing a summons room by room, coming closer. When you walk in, the bell in this room begins. None of it is accident — it was done this way on purpose.
|
||||
@@ -104,7 +104,7 @@ On success it was obeyed, and The Silent Host will not open hostilities first.
|
||||
|
||||
---
|
||||
|
||||
### AREA 4 — The Servants' Deep
|
||||
### AREA 4 — The Cold Gallery
|
||||
*descent · wound · pocket*
|
||||
|
||||
> An instruction repeated until the stone gave out. Boot-prints that stop mid-corridor and do not resume. A single figure at a speaking-tube — reporting, at length, to a listener who died first. When you walk in, it pauses mid-sentence and asks whether you are the reply. None of it is accident — it was done this way on purpose.
|
||||
@@ -186,4 +186,4 @@ Handled by giving them what they want, at a price.
|
||||
|
||||
---
|
||||
|
||||
*Private VANITY development material — not for sale or distribution.*
|
||||
*Generated by [DELVE](https://github.com/slaguru666/vanity-delve) for the VANITY RPG.*
|
||||
@@ -40,7 +40,7 @@ On success it hears that nothing has aged, and The Silent Host will not open hos
|
||||
|
||||
---
|
||||
|
||||
### AREA 1 — The Outer Cut
|
||||
### AREA 1 — The Mourners' Steps
|
||||
*approach · institution*
|
||||
|
||||
> Lamps trimmed and filled, all of them. A seal broken and the contents already spoiled. Two of the Host — repairing something that is not damaged. When you walk in, they set the work down and wait for inspection. None of it is accident — it was done this way on purpose.
|
||||
@@ -58,7 +58,7 @@ On success it hears that nothing has aged, and The Silent Host will not open hos
|
||||
|
||||
---
|
||||
|
||||
### AREA 2 — The Lesser Vault
|
||||
### AREA 2 — The Attendants' Row
|
||||
*complication · ritual · fight · cache*
|
||||
|
||||
> A meal laid out, still warm, for people long past eating. One preserved thing removed, leaving a clean shape in the dust. One of the Host — trimming a lamp that has never gone out. When you walk in, it will not stop, and speaks without looking up. None of it is accident — it was done this way on purpose.
|
||||
@@ -86,7 +86,7 @@ On success it hears that nothing has aged, and The Silent Host will not open hos
|
||||
|
||||
---
|
||||
|
||||
### AREA 3 — The Attendants' Row
|
||||
### AREA 3 — The Lesser Vault
|
||||
*complication · ritual · fight · cache*
|
||||
|
||||
> A clock wound by somebody, recently. A fire built recently, of furniture that was three centuries old. Something young — waiting, exactly as instructed, and has been. When you walk in, it asks whether it is allowed to stop now. None of it is accident — it was done this way on purpose.
|
||||
@@ -114,7 +114,7 @@ On success it hears that nothing has aged, and The Silent Host will not open hos
|
||||
|
||||
---
|
||||
|
||||
### AREA 4 — The Turning Hall
|
||||
### AREA 4 — The Crossing
|
||||
*turn · demand · pocket*
|
||||
|
||||
> A room that resets itself if you look away. A clock stopped by hand — the pendulum caught and tied. A living robber, badly hurt — refusing to bleed, and frightened by it. When you walk in, they grab you and ask what is wrong with them. None of it is accident — it was done this way on purpose.
|
||||
@@ -135,7 +135,7 @@ On success it hears that nothing has aged, and The Silent Host will not open hos
|
||||
|
||||
---
|
||||
|
||||
### AREA 5 — The Sunken Nave
|
||||
### AREA 5 — The Servants' Deep
|
||||
*descent · wound*
|
||||
|
||||
> Something kept so well it has forgotten how to end. Rot, in exactly one place, spreading. The room — undoing your presence, footprints filling behind you, dust returning. When you walk in, it works faster. None of it is accident — it was done this way on purpose.
|
||||
@@ -153,7 +153,7 @@ On success it hears that nothing has aged, and The Silent Host will not open hos
|
||||
|
||||
---
|
||||
|
||||
### AREA 6 — The Antechamber Royal
|
||||
### AREA 6 — The Quiet Room
|
||||
*threshold · anchor · battle · chest*
|
||||
|
||||
> A casket sized for something smaller than a body. Something here has been eaten, which should not be possible. Two of the Host — setting a table for a meal that is always about to happen. When you walk in, they lay two more places. None of it is accident — it was done this way on purpose.
|
||||
@@ -214,4 +214,4 @@ Handled by giving them what they want, at a price.
|
||||
|
||||
---
|
||||
|
||||
*Private VANITY development material — not for sale or distribution.*
|
||||
*Generated by [DELVE](https://github.com/slaguru666/vanity-delve) for the VANITY RPG.*
|
||||
@@ -58,7 +58,7 @@ On success it is not alone, and The Silent Host will not open hostilities first.
|
||||
|
||||
---
|
||||
|
||||
### AREA 2 — The First Hall
|
||||
### AREA 2 — The Waiting Room
|
||||
*complication · ritual · pocket*
|
||||
|
||||
> Places set every evening for three hundred years. Someone has been counting the dead — tally marks by the entrance. Two of the Host, hand in hand — refusing to be parted to let anyone past. When you walk in, they look at you and do not move. None of it is accident — it was done this way on purpose.
|
||||
@@ -79,7 +79,7 @@ On success it is not alone, and The Silent Host will not open hostilities first.
|
||||
|
||||
---
|
||||
|
||||
### AREA 3 — The Waiting Room
|
||||
### AREA 3 — The Attendants' Row
|
||||
*complication · ritual · fight · cache*
|
||||
|
||||
> Chairs drawn up in a circle, all facing one seat. A bedroll, used, and not by anyone buried here. A dozen of the Host — sitting in a circle, facing one empty chair. When you walk in, they shuffle round to make room for you. None of it is accident — it was done this way on purpose.
|
||||
@@ -107,7 +107,7 @@ On success it is not alone, and The Silent Host will not open hostilities first.
|
||||
|
||||
---
|
||||
|
||||
### AREA 4 — The Turning Hall
|
||||
### AREA 4 — The Gallery
|
||||
*turn · demand · fight · cache*
|
||||
|
||||
> A doorway with the hinges taken off. A name carved beside another, joining them, badly. A second set of footsteps — keeping pace with yours, close. When you walk in, they stop when you stop, close. None of it is accident — it was done this way on purpose.
|
||||
@@ -135,7 +135,7 @@ On success it is not alone, and The Silent Host will not open hostilities first.
|
||||
|
||||
---
|
||||
|
||||
### AREA 5 — The Servants' Deep
|
||||
### AREA 5 — The Lower Cut
|
||||
*descent · wound*
|
||||
|
||||
> A name called so often it wore into the wall. A door tied open with a belt, so it could not close. Three of the Host — laying out a game for four. When you walk in, they look up and count you. None of it is accident — it was done this way on purpose.
|
||||
@@ -153,7 +153,7 @@ On success it is not alone, and The Silent Host will not open hostilities first.
|
||||
|
||||
---
|
||||
|
||||
### AREA 6 — The Quiet Room
|
||||
### AREA 6 — The Antechamber Royal
|
||||
*threshold · anchor · battle · chest*
|
||||
|
||||
> A chair kept empty and dusted. A chair dragged out of position and left where it was needed. A living robber, alone — talking steadily to a companion who is not there. When you walk in, they introduce you to the empty air. None of it is accident — it was done this way on purpose.
|
||||
@@ -214,4 +214,4 @@ Handled by giving them what they want, at a price.
|
||||
|
||||
---
|
||||
|
||||
*Private VANITY development material — not for sale or distribution.*
|
||||
*Generated by [DELVE](https://github.com/slaguru666/vanity-delve) for the VANITY RPG.*
|
||||
@@ -104,4 +104,4 @@ sequenced — but only if it stops talking to the table on its own.
|
||||
|
||||
---
|
||||
|
||||
*Private VANITY development material — not for sale or distribution.*
|
||||
*VANITY development notes — part of [DELVE](https://github.com/slaguru666/vanity-delve).*
|
||||
|
||||
Reference in New Issue
Block a user