Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d47a167fd0 | ||
|
|
c1e467c91b | ||
|
|
1b2f9aa385 | ||
|
|
0bbf57b693 | ||
|
|
9d220a3428 | ||
|
|
3a180479cb | ||
|
|
7f5804e2f1 | ||
|
|
1ca0a64295 | ||
|
|
316716e001 | ||
|
|
246a5f607d | ||
|
|
5fae50e25b | ||
|
|
7af690205c | ||
|
|
d22421efbd | ||
|
|
b4990533a8 | ||
|
|
075be5c1de | ||
|
|
e2d1de2fb8 | ||
|
|
592d540910 | ||
|
|
1be441f775 |
@@ -1,7 +1,8 @@
|
|||||||
# DELVE — a dungeon layer for VANITY
|
# DELVE — a dungeon layer for VANITY
|
||||||
|
|
||||||
*Working name `vanity-delve`. **Draft 6**, 2026-08-05.*
|
*Working name `vanity-delve`. **Draft 7**, 2026-08-05.*
|
||||||
*First draft describing a tool that **exists**. The core is built, tested and generating.*
|
*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/) |
|
| 1. Handwrite a delve, run it twice on paper | ✅ complete — [`paper/`](paper/) |
|
||||||
| 2. Seam spike | ✅ complete — [`spike/SPIKE-REPORT.md`](spike/SPIKE-REPORT.md) |
|
| 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** |
|
| 3. Land the Forge seams | ✅ **merged and released** — VANITY v0.10.4, PR #1 |
|
||||||
| 4. Core + Barrow content | ✅ built — [`core/`](core/), 11/11 tests passing |
|
| 4. Core + content | ✅ built — [`core/`](core/), **39/39 tests passing** |
|
||||||
| 5. Curated overlays | not started |
|
| 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 |
|
| 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,
|
**What runs today:** the module installs from its manifest and raises a whole delve in the world
|
||||||
GM-readable delve. Samples in [`samples/`](samples/).
|
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 |
|
| 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
|
long delve outruns its motif; temptations may not. A generic relic breaks the fiction, while a
|
||||||
repeated one only looks thin.
|
repeated one only looks thin.
|
||||||
|
|
||||||
**Remaining limit:** the second cue fragment still comes from a global `features` pool. That is
|
**Closed in draft 7:** the `features` pool was the last piece of generic furniture, and it is now
|
||||||
the last piece of generic furniture.
|
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
|
### 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
|
director.mjs arc roles, heat, hoard tier, density, Bane beats
|
||||||
beat.mjs an area as a beat: Cue + Truth
|
beat.mjs an area as a beat: Cue + Truth
|
||||||
pressure.mjs the clock and the tab — never rolls a die
|
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
|
render.mjs markdown
|
||||||
cli.mjs --seed --areas --depth --density --greed --json
|
render-authoring.mjs the two surfaces — worksheet and play sheet (§2)
|
||||||
content/barrow.json the theme pack
|
cli.mjs --new --file --theme --themes --seed --areas
|
||||||
test.mjs 11 invariants
|
--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
|
**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
|
roll is due and what it means; the adapter rolls it at the table. That separation is what makes
|
||||||
the seed promise honest.
|
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
|
## 5. The Forge seams — landed
|
||||||
|
|
||||||
Branch `delve-seams` in a clone of `slaguru666/Vanity`, **committed, unpushed, awaiting review**.
|
Merged into `slaguru666/Vanity` as PR #1 from branch `delve-seams`, and **published in VANITY
|
||||||
All default to current behaviour, so nothing changes for existing callers.
|
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 |
|
| Seam | Change | Verified |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -195,7 +262,7 @@ Neither gates a first release.
|
|||||||
| Parameter | Range | Default | Confidence |
|
| Parameter | Range | Default | Confidence |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Areas** (excludes ending slot) | 3–12 | **6** | *working default* — one theme, one table, two passes |
|
| **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 | |
|
| Depth | 1–5 | 2 | |
|
||||||
| Party | 1–8 | 4 | |
|
| Party | 1–8 | 4 | |
|
||||||
| Deadliness | forgiving/standard/cruel | standard | **severity only** |
|
| 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
|
- **A trigger glossary**, so `seenTwice` is not shorthand
|
||||||
- **Accommodation/appetite compatibility**, killing the equation-output kernels
|
- **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
|
**Draft 7 closed most of the rest.** "Mostly prompts" was the charge; the answer was to make every
|
||||||
failure outcomes, concrete clue answers, and a full ending packet with negotiation terms.
|
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
|
## 13. Open questions
|
||||||
|
|
||||||
1. **Does the module beat a document?** The spike answers the mechanical half — unmodified, the
|
1. **Does the module beat a document?** The mechanical half is now settled *in shipped code* — the
|
||||||
Forge emits 9 cards and 3 folders per delve that no document could intercept, and the seams
|
unmodified Forge emits 9 cards and 3 folders per delve that no document could intercept, and
|
||||||
take that to 0. The *table* half is still open until a live session.
|
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?
|
2. Is `Ending: Authored` right, or right only for its author?
|
||||||
3. Is 6 areas right beyond one theme and one simulated table?
|
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?
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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, 41 tests |
|
||||||
|
| `foundry-module/` | the Foundry module, with `core/` vendored — 60 tests |
|
||||||
|
| `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)];
|
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 file = args.file;
|
||||||
const load = () => JSON.parse(readFileSync(file, 'utf8'));
|
const load = () => JSON.parse(readFileSync(file, 'utf8'));
|
||||||
const save = d => writeFileSync(file, JSON.stringify(d, null, 2));
|
const save = d => writeFileSync(file, JSON.stringify(d, null, 2));
|
||||||
|
|||||||
+23
-12
@@ -2,7 +2,7 @@
|
|||||||
"id": "barrow",
|
"id": "barrow",
|
||||||
"label": "Barrow",
|
"label": "Barrow",
|
||||||
"forgeStageType": "barrow",
|
"forgeStageType": "barrow",
|
||||||
"schema": 1,
|
"schema": 2,
|
||||||
"_model": "Everything derives from one kernel: an APPETITE and the ACCOMMODATION made for it. The motif, danger, prize kind and faction all follow from the appetite. Cue fragments hang off the MOTIF and are tagged by FACET (institution/ritual/demand/wound/anchor) and level, so two delves sharing a motif draw different imagery. The foreshadow chain is a fixed facet progression mapped onto the arc — which is why area 2's bows and area 4's scratched eyes both point at the same wound.",
|
"_model": "Everything derives from one kernel: an APPETITE and the ACCOMMODATION made for it. The motif, danger, prize kind and faction all follow from the appetite. Cue fragments hang off the MOTIF and are tagged by FACET (institution/ritual/demand/wound/anchor) and level, so two delves sharing a motif draw different imagery. The foreshadow chain is a fixed facet progression mapped onto the arc — which is why area 2's bows and area 4's scratched eyes both point at the same wound.",
|
||||||
"appetites": [
|
"appetites": [
|
||||||
{
|
{
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"How do I look?\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "remembered",
|
"id": "remembered",
|
||||||
@@ -47,7 +48,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"Say my name.\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "obeyed",
|
"id": "obeyed",
|
||||||
@@ -69,7 +71,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"Do as you are told.\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "young",
|
"id": "young",
|
||||||
@@ -91,7 +94,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"Tell me I have not changed.\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "envied",
|
"id": "envied",
|
||||||
@@ -113,7 +117,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"What did you bring me?\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "attended",
|
"id": "attended",
|
||||||
@@ -135,7 +140,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"You are not leaving.\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "forgiven",
|
"id": "forgiven",
|
||||||
@@ -157,7 +163,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"Tell me it was not a sin.\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "first",
|
"id": "first",
|
||||||
@@ -179,7 +186,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"Who goes first?\""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"accommodations": [
|
"accommodations": [
|
||||||
@@ -2102,7 +2110,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"harmedBy": "blessed, silvered or magical weapons ONLY",
|
"harmedBy": "blessed, silvered or magical weapons ONLY",
|
||||||
"avoid": "it will take an appeasement over a fight every time"
|
"avoid": "it will take an appeasement over a fight every time",
|
||||||
|
"beforeInitiative": "Say so before initiative."
|
||||||
},
|
},
|
||||||
"nightmare": {
|
"nightmare": {
|
||||||
"line": "1 Wraith, 1 Ghoul and 4 Skeletons",
|
"line": "1 Wraith, 1 Ghoul and 4 Skeletons",
|
||||||
@@ -2136,7 +2145,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"harmedBy": "blessed, silvered or magical weapons for the Wraith; anything for the rest",
|
"harmedBy": "blessed, silvered or magical weapons for the Wraith; anything for the rest",
|
||||||
"avoid": "do not run this without an appeasement route open"
|
"avoid": "do not run this without an appeasement route open",
|
||||||
|
"beforeInitiative": "Say so before initiative."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"triggerGlossary": {
|
"triggerGlossary": {
|
||||||
@@ -2146,5 +2156,6 @@
|
|||||||
"lingering": "stayed in one place longer than it takes to search it",
|
"lingering": "stayed in one place longer than it takes to search it",
|
||||||
"disturbed": "struck or moved something that had not moved in centuries"
|
"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
+20
-15
@@ -9,7 +9,17 @@ import { buildArea, fallbackRoute } from './beat.mjs';
|
|||||||
import { Pressure } from './pressure.mjs';
|
import { Pressure } from './pressure.mjs';
|
||||||
import { baneBeatFor } from './director.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.
|
||||||
|
*
|
||||||
|
* A change that originates in a pack rather than in this file belongs to `catalogVersion` instead
|
||||||
|
* — beat.mjs embeds the pack's roster verbatim, so a new roster field changes the emitted file
|
||||||
|
* without changing anything here. `beforeInitiative` is why the packs are at schema 2.
|
||||||
|
*/
|
||||||
|
export const GENERATOR_VERSION = '0.2.0';
|
||||||
|
|
||||||
export function generateDelve(params = {}) {
|
export function generateDelve(params = {}) {
|
||||||
const {
|
const {
|
||||||
@@ -103,7 +113,7 @@ export function generateDelve(params = {}) {
|
|||||||
...plan.ending,
|
...plan.ending,
|
||||||
name: (pack.areaNames?.ending ?? ['The Ending'])[0],
|
name: (pack.areaNames?.ending ?? ['The Ending'])[0],
|
||||||
cueFragments: [endFs?.fragment].filter(Boolean),
|
cueFragments: [endFs?.fragment].filter(Boolean),
|
||||||
question: endingQuestion(skeleton),
|
question: endingQuestion(skeleton, pack),
|
||||||
authored: plan.ending.mode === 'authored',
|
authored: plan.ending.mode === 'authored',
|
||||||
},
|
},
|
||||||
triggerGlossary: pack.triggerGlossary ?? {},
|
triggerGlossary: pack.triggerGlossary ?? {},
|
||||||
@@ -112,17 +122,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) {
|
* The bottom problem asks one thing, and it is the same question the whole delve has been about.
|
||||||
const q = {
|
* It lives on the appetite in the theme pack — hardcoding it here meant any theme whose appetite
|
||||||
seen: '"How do I look?"',
|
* ids differed from barrow's silently fell through to a generic line.
|
||||||
remembered: '"Say my name."',
|
*/
|
||||||
obeyed: '"Do as you are told."',
|
function endingQuestion(sk, pack) {
|
||||||
young: '"Tell me I have not changed."',
|
const appetite = pack?.appetites?.find(a => a.id === sk.knot.appetiteId);
|
||||||
envied: '"What did you bring me?"',
|
return appetite?.asks ?? '"Well?"';
|
||||||
attended: '"You are not leaving."',
|
|
||||||
forgiven: '"Tell me it was not a sin."',
|
|
||||||
first: '"Who goes first?"',
|
|
||||||
};
|
|
||||||
return q[sk.knot.appetiteId] ?? '"Well?"';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* GM what matters faster" — and the only way to be fast is to carry less.
|
* GM what matters faster" — and the only way to be fast is to carry less.
|
||||||
*/
|
*/
|
||||||
import { outstanding, isLocked, REROLLABLE } from './authoring.mjs';
|
import { outstanding, isLocked, REROLLABLE } from './authoring.mjs';
|
||||||
|
import { initiativeWarning } from './roster.mjs';
|
||||||
|
|
||||||
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||||
const cost = c => {
|
const cost = c => {
|
||||||
@@ -76,7 +77,7 @@ export function renderWorksheet(d) {
|
|||||||
if (a.encounter?.roster) {
|
if (a.encounter?.roster) {
|
||||||
const R = a.encounter.roster;
|
const R = a.encounter.roster;
|
||||||
L.push(`**ENCOUNTER** ${R.line} — ${R.foes.map(f => `${f.n}× ${f.name} atk${f.atk}/def${f.def}/G${f.grit}`).join(', ')}`);
|
L.push(`**ENCOUNTER** ${R.line} — ${R.foes.map(f => `${f.n}× ${f.name} atk${f.atk}/def${f.def}/G${f.grit}`).join(', ')}`);
|
||||||
L.push(` · harmed by ${R.harmedBy} · ${R.avoid}`);
|
L.push(` · harmed by ${R.harmedBy}${initiativeWarning(R) ? ` · ${initiativeWarning(R)}` : ''} · ${R.avoid}`);
|
||||||
}
|
}
|
||||||
if (a.temptation) {
|
if (a.temptation) {
|
||||||
const t = a.temptation;
|
const t = a.temptation;
|
||||||
@@ -143,7 +144,12 @@ export function renderPlay(d) {
|
|||||||
L.push(`**${cap(a.decision.cue)}** — ${rv.roll ? `\`[${rv.roll}]\` ${rv.success}` : ''}${rv.failure ? ` · **miss** ${rv.failure}` : ''}${rv.orElse ? ` · **or** ${rv.orElse}` : ''}`);
|
L.push(`**${cap(a.decision.cue)}** — ${rv.roll ? `\`[${rv.roll}]\` ${rv.success}` : ''}${rv.failure ? ` · **miss** ${rv.failure}` : ''}${rv.orElse ? ` · **or** ${rv.orElse}` : ''}`);
|
||||||
if (a.encounter?.roster) {
|
if (a.encounter?.roster) {
|
||||||
const R = a.encounter.roster;
|
const R = a.encounter.roster;
|
||||||
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`);
|
// `avoid` earns its place here — it is how the table gets past the fight without one, which
|
||||||
|
// is table-facing by definition. It also keeps every surface rendering both prose fields, so
|
||||||
|
// initiativeWarning's "the prose already says it" rule means the same thing everywhere; when
|
||||||
|
// the play sheet alone omitted `avoid`, a legacy warning living there vanished from it.
|
||||||
|
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`
|
||||||
|
+ `${initiativeWarning(R) ? ` **${initiativeWarning(R)}**` : ''} *${R.avoid}.*`);
|
||||||
}
|
}
|
||||||
if (a.temptation) L.push(`**${cap(a.temptation.id)}:** ${a.temptation.benefit}. *Use: ${cost(a.temptation.useCost)}. ${a.temptation.standingDrawback}.*`);
|
if (a.temptation) L.push(`**${cap(a.temptation.id)}:** ${a.temptation.benefit}. *Use: ${cost(a.temptation.useCost)}. ${a.temptation.standingDrawback}.*`);
|
||||||
if (w.notes) L.push(`**Note:** ${w.notes}`);
|
if (w.notes) L.push(`**Note:** ${w.notes}`);
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,6 @@
|
|||||||
/** Render a delve to GM-readable markdown, in the house style. */
|
/** Render a delve to GM-readable markdown, in the house style. */
|
||||||
import { skeletonBlurb } from './skeleton.mjs';
|
import { skeletonBlurb } from './skeleton.mjs';
|
||||||
|
import { initiativeWarning } from './roster.mjs';
|
||||||
|
|
||||||
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||||
|
|
||||||
@@ -104,8 +105,9 @@ export function renderMarkdown(d) {
|
|||||||
L.push('');
|
L.push('');
|
||||||
|
|
||||||
if (R) {
|
if (R) {
|
||||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}` +
|
const warn = initiativeWarning(R);
|
||||||
`${R.harmedBy.includes('ONLY') ? ' — **say so before initiative**' : ''}. *${R.avoid}.*`);
|
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}.`
|
||||||
|
+ `${warn ? ` **${warn}**` : ''} *${R.avoid}.*`);
|
||||||
L.push('');
|
L.push('');
|
||||||
L.push('| Foe | atk | def | Grit | Nerve | |');
|
L.push('| Foe | atk | def | Grit | Nerve | |');
|
||||||
L.push('|---|---|---|---|---|---|');
|
L.push('|---|---|---|---|---|---|');
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* The one place that decides whether a fight needs a warning before initiative.
|
||||||
|
*
|
||||||
|
* Some rosters carry a restriction a party cannot discover safely: a Wraith that only blessed,
|
||||||
|
* silvered or magical weapons touch, a Troll that only stays down if fire closes the wound. Finding
|
||||||
|
* that out by swinging and missing is how playtest 1 died, so the GM says it aloud first.
|
||||||
|
*
|
||||||
|
* This was a renderer heuristic — synthesise the line whenever `harmedBy` contained the word ONLY.
|
||||||
|
* It printed twice on the rosters that already said it in prose, and when the heuristic was deleted
|
||||||
|
* the warning vanished from twelve rosters at once. Worse, the spelling was never the semantics:
|
||||||
|
* barrow's nightmare Wraith and forest's battle Troll carry the same restriction without the word.
|
||||||
|
*
|
||||||
|
* So it is authored data now — `roster.beforeInitiative` — and this module is the only thing that
|
||||||
|
* reads it. RESTRICTS remains, but only as a tripwire: it tells the validator that new content
|
||||||
|
* *looks* like it needs the field, and it recovers the warning for delve files generated before the
|
||||||
|
* field existed. It is not the source of truth; the packs are.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Vocabulary the packs actually use for "your weapon may not work". A tripwire, not an oracle. */
|
||||||
|
export const RESTRICTS = /\bONLY\b|blessed|silvered|magical|only stays down|fire for the/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to say before initiative, or null. Never returns a line the prose already carries, so it
|
||||||
|
* cannot print twice.
|
||||||
|
*/
|
||||||
|
export function initiativeWarning(roster) {
|
||||||
|
if (!roster) return null;
|
||||||
|
// Prose wins, and is checked first. A hand-edited file can carry both the field and the old
|
||||||
|
// sentence; returning the field before looking would print it twice, which is the fault this
|
||||||
|
// whole migration exists to remove.
|
||||||
|
const prose = `${roster.harmedBy ?? ''} ${roster.avoid ?? ''}`;
|
||||||
|
if (/say so before initiative/i.test(prose)) return null;
|
||||||
|
if (roster.beforeInitiative) return roster.beforeInitiative;
|
||||||
|
return RESTRICTS.test(roster.harmedBy ?? '') ? 'Say so before initiative.' : null;
|
||||||
|
}
|
||||||
+15
-5
@@ -22,7 +22,13 @@
|
|||||||
import { arcRole } from './director.mjs';
|
import { arcRole } from './director.mjs';
|
||||||
|
|
||||||
/** Fills {conscript}-style slots. */
|
/** 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. */
|
/** Facet order used when a facet is exhausted — borrow from a neighbour before repeating. */
|
||||||
const FACET_ORDER = ['institution', 'ritual', 'demand', 'wound', 'anchor'];
|
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 prize = { kind: appetite.prize, ...(pack.prizes?.[appetite.prize] ?? {}) };
|
||||||
|
|
||||||
const transgression =
|
const transgression =
|
||||||
`The ${claimant} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
|
`${named(claimant)} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
|
||||||
`so that ${appetite.purpose}.`;
|
`so that ${appetite.purpose}.`;
|
||||||
|
// The bottom problem is the claimant itself — a person in a tomb, an office in a town.
|
||||||
|
|
||||||
const bottomProblem = {
|
const bottomProblem = {
|
||||||
label: `The ${claimant}`,
|
label: named(claimant),
|
||||||
wantNow: appetite.want,
|
wantNow: appetite.want,
|
||||||
failureState: `is ${appetite.lack} now, and cannot bear it`,
|
failureState: `is ${appetite.lack} now, and cannot bear it`,
|
||||||
handledBy: 'giving them what they want, at a price',
|
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));
|
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.
|
// A barrow or keep is named for its occupant — drawing that separately produced tombs named
|
||||||
const placeName = `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`;
|
// 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 ----------------------------------------------
|
// ---- the foreshadow chain ----------------------------------------------
|
||||||
const progression = pack.facetProgression ?? {};
|
const progression = pack.facetProgression ?? {};
|
||||||
|
|||||||
+31
-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 { Rng } from './rng.mjs';
|
||||||
import { generateDelve } from './delve.mjs';
|
import { generateDelve } from './delve.mjs';
|
||||||
import { renderMarkdown } from './render.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;
|
let pass = 0, fail = 0;
|
||||||
const t = (name, cond, detail = '') => { cond ? pass++ : fail++; console.log(`${cond ? ' ok ' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); };
|
const t = (name, cond, detail = '') => { cond ? pass++ : fail++; console.log(`${cond ? ' ok ' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); };
|
||||||
|
|
||||||
@@ -227,5 +230,31 @@ import { renderWorksheet, renderPlay } from './render-authoring.mjs';
|
|||||||
`play ${done.split('\n').length} vs worksheet ${renderWorksheet(d).split('\n').length} lines`);
|
`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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every shipped pack must satisfy the structural rules. validate-pack.mjs was a tool you had to
|
||||||
|
// remember to run; a pack regression should fail the suite. The initiative warning went missing
|
||||||
|
// from twelve rosters without anything noticing.
|
||||||
|
{
|
||||||
|
const { validatePack } = await import('./validate-pack.mjs');
|
||||||
|
const bad = readdirSync(dir).filter(f => f.endsWith('.json') && f !== 'index.json')
|
||||||
|
.map(f => validatePack(JSON.parse(readFileSync(join(dir, f), 'utf8')), f.replace('.json', '')))
|
||||||
|
.filter(r => r.errs.length);
|
||||||
|
t('every shipped pack validates', bad.length === 0,
|
||||||
|
bad.length ? bad.map(b => `${b.name}: ${b.errs[0]}`).join('; ') : '16 packs');
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`\n${pass} passed, ${fail} failed`);
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
process.exit(fail ? 1 : 0);
|
process.exit(fail ? 1 : 0);
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
/**
|
||||||
|
* 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 { RESTRICTS } from './roster.mjs';
|
||||||
|
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`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A restriction the party cannot discover safely must be told to them before initiative, and
|
||||||
|
* it must be said exactly once. This lived as a renderer heuristic — synthesise the warning
|
||||||
|
* whenever `harmedBy` contained the word ONLY — which printed twice on the rosters that
|
||||||
|
* already said it in prose, and vanished from twelve rosters when the heuristic was removed.
|
||||||
|
* It is authored data now, and this is the check that keeps it that way.
|
||||||
|
*/
|
||||||
|
if (/say so before initiative/i.test(`${r.harmedBy} ${r.avoid ?? ''}`))
|
||||||
|
E(`rosters.${h}: put the initiative warning in beforeInitiative, not in the prose — it renders twice otherwise`);
|
||||||
|
if (RESTRICTS.test(r.harmedBy) && !r.beforeInitiative)
|
||||||
|
E(`rosters.${h}: harmedBy restricts what works, so it needs beforeInitiative — the party cannot discover this safely`);
|
||||||
|
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,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.
|
||||||
@@ -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.
|
||||||
@@ -21,5 +21,5 @@ dungeon**, and it builds — in one folder per document type:
|
|||||||
Every document is stamped with a `dungeonId`, so the 🗑 button removes a whole dungeon and
|
Every document is stamped with a `dungeonId`, so the 🗑 button removes a whole dungeon and
|
||||||
nothing else.
|
nothing else.
|
||||||
|
|
||||||
Requires the VANITY system **0.10.5+** for quiet output; on older versions the Forge posts its
|
Requires the VANITY system **0.10.4+** for quiet output; on older versions the Forge posts its
|
||||||
own cards over DELVE's and the module says so on startup.
|
own cards over DELVE's and the module says so on startup.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "vanity-delve",
|
"id": "vanity-delve",
|
||||||
"title": "DELVE — a dungeon layer for VANITY",
|
"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.",
|
"description": "Generates a coherent delve and sequences VANITY's Forge to stage it, one area at a time.",
|
||||||
"version": "0.1.1",
|
"version": "0.6.5",
|
||||||
"compatibility": {
|
"compatibility": {
|
||||||
"minimum": "13",
|
"minimum": "13",
|
||||||
"verified": "14.365"
|
"verified": "14.365"
|
||||||
@@ -11,9 +11,10 @@
|
|||||||
"systems": [
|
"systems": [
|
||||||
{
|
{
|
||||||
"id": "vanity",
|
"id": "vanity",
|
||||||
"type": "requires",
|
"type": "system",
|
||||||
|
"manifest": "https://raw.githubusercontent.com/slaguru666/Vanity/main/system.json",
|
||||||
"compatibility": {
|
"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",
|
"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",
|
"download": "https://github.com/slaguru666/vanity-delve/releases/latest/download/vanity-delve.zip",
|
||||||
"url": "https://github.com/slaguru666/vanity-delve",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"id": "barrow",
|
"id": "barrow",
|
||||||
"label": "Barrow",
|
"label": "Barrow",
|
||||||
"forgeStageType": "barrow",
|
"forgeStageType": "barrow",
|
||||||
"schema": 1,
|
"schema": 2,
|
||||||
"_model": "Everything derives from one kernel: an APPETITE and the ACCOMMODATION made for it. The motif, danger, prize kind and faction all follow from the appetite. Cue fragments hang off the MOTIF and are tagged by FACET (institution/ritual/demand/wound/anchor) and level, so two delves sharing a motif draw different imagery. The foreshadow chain is a fixed facet progression mapped onto the arc — which is why area 2's bows and area 4's scratched eyes both point at the same wound.",
|
"_model": "Everything derives from one kernel: an APPETITE and the ACCOMMODATION made for it. The motif, danger, prize kind and faction all follow from the appetite. Cue fragments hang off the MOTIF and are tagged by FACET (institution/ritual/demand/wound/anchor) and level, so two delves sharing a motif draw different imagery. The foreshadow chain is a fixed facet progression mapped onto the arc — which is why area 2's bows and area 4's scratched eyes both point at the same wound.",
|
||||||
"appetites": [
|
"appetites": [
|
||||||
{
|
{
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"How do I look?\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "remembered",
|
"id": "remembered",
|
||||||
@@ -47,7 +48,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"Say my name.\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "obeyed",
|
"id": "obeyed",
|
||||||
@@ -69,7 +71,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"Do as you are told.\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "young",
|
"id": "young",
|
||||||
@@ -91,7 +94,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"Tell me I have not changed.\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "envied",
|
"id": "envied",
|
||||||
@@ -113,7 +117,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"What did you bring me?\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "attended",
|
"id": "attended",
|
||||||
@@ -135,7 +140,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"You are not leaving.\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "forgiven",
|
"id": "forgiven",
|
||||||
@@ -157,7 +163,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"Tell me it was not a sin.\""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "first",
|
"id": "first",
|
||||||
@@ -179,7 +186,8 @@
|
|||||||
"bane": 1
|
"bane": 1
|
||||||
},
|
},
|
||||||
"repeatable": true
|
"repeatable": true
|
||||||
}
|
},
|
||||||
|
"asks": "\"Who goes first?\""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"accommodations": [
|
"accommodations": [
|
||||||
@@ -2102,7 +2110,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"harmedBy": "blessed, silvered or magical weapons ONLY",
|
"harmedBy": "blessed, silvered or magical weapons ONLY",
|
||||||
"avoid": "it will take an appeasement over a fight every time"
|
"avoid": "it will take an appeasement over a fight every time",
|
||||||
|
"beforeInitiative": "Say so before initiative."
|
||||||
},
|
},
|
||||||
"nightmare": {
|
"nightmare": {
|
||||||
"line": "1 Wraith, 1 Ghoul and 4 Skeletons",
|
"line": "1 Wraith, 1 Ghoul and 4 Skeletons",
|
||||||
@@ -2136,7 +2145,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"harmedBy": "blessed, silvered or magical weapons for the Wraith; anything for the rest",
|
"harmedBy": "blessed, silvered or magical weapons for the Wraith; anything for the rest",
|
||||||
"avoid": "do not run this without an appeasement route open"
|
"avoid": "do not run this without an appeasement route open",
|
||||||
|
"beforeInitiative": "Say so before initiative."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"triggerGlossary": {
|
"triggerGlossary": {
|
||||||
@@ -2146,5 +2156,6 @@
|
|||||||
"lingering": "stayed in one place longer than it takes to search it",
|
"lingering": "stayed in one place longer than it takes to search it",
|
||||||
"disturbed": "struck or moved something that had not moved in centuries"
|
"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,7 +9,17 @@ import { buildArea, fallbackRoute } from './beat.mjs';
|
|||||||
import { Pressure } from './pressure.mjs';
|
import { Pressure } from './pressure.mjs';
|
||||||
import { baneBeatFor } from './director.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.
|
||||||
|
*
|
||||||
|
* A change that originates in a pack rather than in this file belongs to `catalogVersion` instead
|
||||||
|
* — beat.mjs embeds the pack's roster verbatim, so a new roster field changes the emitted file
|
||||||
|
* without changing anything here. `beforeInitiative` is why the packs are at schema 2.
|
||||||
|
*/
|
||||||
|
export const GENERATOR_VERSION = '0.2.0';
|
||||||
|
|
||||||
export function generateDelve(params = {}) {
|
export function generateDelve(params = {}) {
|
||||||
const {
|
const {
|
||||||
@@ -103,7 +113,7 @@ export function generateDelve(params = {}) {
|
|||||||
...plan.ending,
|
...plan.ending,
|
||||||
name: (pack.areaNames?.ending ?? ['The Ending'])[0],
|
name: (pack.areaNames?.ending ?? ['The Ending'])[0],
|
||||||
cueFragments: [endFs?.fragment].filter(Boolean),
|
cueFragments: [endFs?.fragment].filter(Boolean),
|
||||||
question: endingQuestion(skeleton),
|
question: endingQuestion(skeleton, pack),
|
||||||
authored: plan.ending.mode === 'authored',
|
authored: plan.ending.mode === 'authored',
|
||||||
},
|
},
|
||||||
triggerGlossary: pack.triggerGlossary ?? {},
|
triggerGlossary: pack.triggerGlossary ?? {},
|
||||||
@@ -112,17 +122,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) {
|
* The bottom problem asks one thing, and it is the same question the whole delve has been about.
|
||||||
const q = {
|
* It lives on the appetite in the theme pack — hardcoding it here meant any theme whose appetite
|
||||||
seen: '"How do I look?"',
|
* ids differed from barrow's silently fell through to a generic line.
|
||||||
remembered: '"Say my name."',
|
*/
|
||||||
obeyed: '"Do as you are told."',
|
function endingQuestion(sk, pack) {
|
||||||
young: '"Tell me I have not changed."',
|
const appetite = pack?.appetites?.find(a => a.id === sk.knot.appetiteId);
|
||||||
envied: '"What did you bring me?"',
|
return appetite?.asks ?? '"Well?"';
|
||||||
attended: '"You are not leaving."',
|
|
||||||
forgiven: '"Tell me it was not a sin."',
|
|
||||||
first: '"Who goes first?"',
|
|
||||||
};
|
|
||||||
return q[sk.knot.appetiteId] ?? '"Well?"';
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
* GM what matters faster" — and the only way to be fast is to carry less.
|
* GM what matters faster" — and the only way to be fast is to carry less.
|
||||||
*/
|
*/
|
||||||
import { outstanding, isLocked, REROLLABLE } from './authoring.mjs';
|
import { outstanding, isLocked, REROLLABLE } from './authoring.mjs';
|
||||||
|
import { initiativeWarning } from './roster.mjs';
|
||||||
|
|
||||||
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||||
const cost = c => {
|
const cost = c => {
|
||||||
@@ -76,7 +77,7 @@ export function renderWorksheet(d) {
|
|||||||
if (a.encounter?.roster) {
|
if (a.encounter?.roster) {
|
||||||
const R = a.encounter.roster;
|
const R = a.encounter.roster;
|
||||||
L.push(`**ENCOUNTER** ${R.line} — ${R.foes.map(f => `${f.n}× ${f.name} atk${f.atk}/def${f.def}/G${f.grit}`).join(', ')}`);
|
L.push(`**ENCOUNTER** ${R.line} — ${R.foes.map(f => `${f.n}× ${f.name} atk${f.atk}/def${f.def}/G${f.grit}`).join(', ')}`);
|
||||||
L.push(` · harmed by ${R.harmedBy} · ${R.avoid}`);
|
L.push(` · harmed by ${R.harmedBy}${initiativeWarning(R) ? ` · ${initiativeWarning(R)}` : ''} · ${R.avoid}`);
|
||||||
}
|
}
|
||||||
if (a.temptation) {
|
if (a.temptation) {
|
||||||
const t = a.temptation;
|
const t = a.temptation;
|
||||||
@@ -143,7 +144,12 @@ export function renderPlay(d) {
|
|||||||
L.push(`**${cap(a.decision.cue)}** — ${rv.roll ? `\`[${rv.roll}]\` ${rv.success}` : ''}${rv.failure ? ` · **miss** ${rv.failure}` : ''}${rv.orElse ? ` · **or** ${rv.orElse}` : ''}`);
|
L.push(`**${cap(a.decision.cue)}** — ${rv.roll ? `\`[${rv.roll}]\` ${rv.success}` : ''}${rv.failure ? ` · **miss** ${rv.failure}` : ''}${rv.orElse ? ` · **or** ${rv.orElse}` : ''}`);
|
||||||
if (a.encounter?.roster) {
|
if (a.encounter?.roster) {
|
||||||
const R = a.encounter.roster;
|
const R = a.encounter.roster;
|
||||||
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`);
|
// `avoid` earns its place here — it is how the table gets past the fight without one, which
|
||||||
|
// is table-facing by definition. It also keeps every surface rendering both prose fields, so
|
||||||
|
// initiativeWarning's "the prose already says it" rule means the same thing everywhere; when
|
||||||
|
// the play sheet alone omitted `avoid`, a legacy warning living there vanished from it.
|
||||||
|
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`
|
||||||
|
+ `${initiativeWarning(R) ? ` **${initiativeWarning(R)}**` : ''} *${R.avoid}.*`);
|
||||||
}
|
}
|
||||||
if (a.temptation) L.push(`**${cap(a.temptation.id)}:** ${a.temptation.benefit}. *Use: ${cost(a.temptation.useCost)}. ${a.temptation.standingDrawback}.*`);
|
if (a.temptation) L.push(`**${cap(a.temptation.id)}:** ${a.temptation.benefit}. *Use: ${cost(a.temptation.useCost)}. ${a.temptation.standingDrawback}.*`);
|
||||||
if (w.notes) L.push(`**Note:** ${w.notes}`);
|
if (w.notes) L.push(`**Note:** ${w.notes}`);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/** Render a delve to GM-readable markdown, in the house style. */
|
/** Render a delve to GM-readable markdown, in the house style. */
|
||||||
import { skeletonBlurb } from './skeleton.mjs';
|
import { skeletonBlurb } from './skeleton.mjs';
|
||||||
|
import { initiativeWarning } from './roster.mjs';
|
||||||
|
|
||||||
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||||
|
|
||||||
@@ -104,8 +105,9 @@ export function renderMarkdown(d) {
|
|||||||
L.push('');
|
L.push('');
|
||||||
|
|
||||||
if (R) {
|
if (R) {
|
||||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}` +
|
const warn = initiativeWarning(R);
|
||||||
`${R.harmedBy.includes('ONLY') ? ' — **say so before initiative**' : ''}. *${R.avoid}.*`);
|
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}.`
|
||||||
|
+ `${warn ? ` **${warn}**` : ''} *${R.avoid}.*`);
|
||||||
L.push('');
|
L.push('');
|
||||||
L.push('| Foe | atk | def | Grit | Nerve | |');
|
L.push('| Foe | atk | def | Grit | Nerve | |');
|
||||||
L.push('|---|---|---|---|---|---|');
|
L.push('|---|---|---|---|---|---|');
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* The one place that decides whether a fight needs a warning before initiative.
|
||||||
|
*
|
||||||
|
* Some rosters carry a restriction a party cannot discover safely: a Wraith that only blessed,
|
||||||
|
* silvered or magical weapons touch, a Troll that only stays down if fire closes the wound. Finding
|
||||||
|
* that out by swinging and missing is how playtest 1 died, so the GM says it aloud first.
|
||||||
|
*
|
||||||
|
* This was a renderer heuristic — synthesise the line whenever `harmedBy` contained the word ONLY.
|
||||||
|
* It printed twice on the rosters that already said it in prose, and when the heuristic was deleted
|
||||||
|
* the warning vanished from twelve rosters at once. Worse, the spelling was never the semantics:
|
||||||
|
* barrow's nightmare Wraith and forest's battle Troll carry the same restriction without the word.
|
||||||
|
*
|
||||||
|
* So it is authored data now — `roster.beforeInitiative` — and this module is the only thing that
|
||||||
|
* reads it. RESTRICTS remains, but only as a tripwire: it tells the validator that new content
|
||||||
|
* *looks* like it needs the field, and it recovers the warning for delve files generated before the
|
||||||
|
* field existed. It is not the source of truth; the packs are.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Vocabulary the packs actually use for "your weapon may not work". A tripwire, not an oracle. */
|
||||||
|
export const RESTRICTS = /\bONLY\b|blessed|silvered|magical|only stays down|fire for the/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to say before initiative, or null. Never returns a line the prose already carries, so it
|
||||||
|
* cannot print twice.
|
||||||
|
*/
|
||||||
|
export function initiativeWarning(roster) {
|
||||||
|
if (!roster) return null;
|
||||||
|
// Prose wins, and is checked first. A hand-edited file can carry both the field and the old
|
||||||
|
// sentence; returning the field before looking would print it twice, which is the fault this
|
||||||
|
// whole migration exists to remove.
|
||||||
|
const prose = `${roster.harmedBy ?? ''} ${roster.avoid ?? ''}`;
|
||||||
|
if (/say so before initiative/i.test(prose)) return null;
|
||||||
|
if (roster.beforeInitiative) return roster.beforeInitiative;
|
||||||
|
return RESTRICTS.test(roster.harmedBy ?? '') ? 'Say so before initiative.' : null;
|
||||||
|
}
|
||||||
@@ -22,7 +22,13 @@
|
|||||||
import { arcRole } from './director.mjs';
|
import { arcRole } from './director.mjs';
|
||||||
|
|
||||||
/** Fills {conscript}-style slots. */
|
/** 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. */
|
/** Facet order used when a facet is exhausted — borrow from a neighbour before repeating. */
|
||||||
const FACET_ORDER = ['institution', 'ritual', 'demand', 'wound', 'anchor'];
|
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 prize = { kind: appetite.prize, ...(pack.prizes?.[appetite.prize] ?? {}) };
|
||||||
|
|
||||||
const transgression =
|
const transgression =
|
||||||
`The ${claimant} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
|
`${named(claimant)} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
|
||||||
`so that ${appetite.purpose}.`;
|
`so that ${appetite.purpose}.`;
|
||||||
|
// The bottom problem is the claimant itself — a person in a tomb, an office in a town.
|
||||||
|
|
||||||
const bottomProblem = {
|
const bottomProblem = {
|
||||||
label: `The ${claimant}`,
|
label: named(claimant),
|
||||||
wantNow: appetite.want,
|
wantNow: appetite.want,
|
||||||
failureState: `is ${appetite.lack} now, and cannot bear it`,
|
failureState: `is ${appetite.lack} now, and cannot bear it`,
|
||||||
handledBy: 'giving them what they want, at a price',
|
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));
|
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.
|
// A barrow or keep is named for its occupant — drawing that separately produced tombs named
|
||||||
const placeName = `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`;
|
// 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 ----------------------------------------------
|
// ---- the foreshadow chain ----------------------------------------------
|
||||||
const progression = pack.facetProgression ?? {};
|
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,13 +12,17 @@
|
|||||||
*/
|
*/
|
||||||
import { generateDelve } from './core/delve.mjs';
|
import { generateDelve } from './core/delve.mjs';
|
||||||
import { coinSeed, Rng } from './core/rng.mjs';
|
import { coinSeed, Rng } from './core/rng.mjs';
|
||||||
|
import { foeStats, classifyFoes } from './foes.mjs';
|
||||||
|
import { initiativeWarning } from './core/roster.mjs';
|
||||||
|
|
||||||
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
|
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
|
||||||
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||||
const esc = s => foundry.utils.escapeHTML?.(String(s ?? '')) ?? String(s ?? '');
|
const esc = s => foundry.utils.escapeHTML?.(String(s ?? '')) ?? String(s ?? '');
|
||||||
|
|
||||||
const MOD = 'vanity-delve';
|
const MOD = 'vanity-delve';
|
||||||
const THEMES = ['barrow'];
|
let THEMES = [{ id: 'barrow', label: 'Barrow' }];
|
||||||
|
|
||||||
|
export function setThemes(list) { THEMES = list; }
|
||||||
const PICKS = {
|
const PICKS = {
|
||||||
depth: [1, 2, 3, 4, 5],
|
depth: [1, 2, 3, 4, 5],
|
||||||
party: [1, 2, 3, 4, 5, 6, 7, 8],
|
party: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
@@ -76,9 +80,13 @@ export class DelveForgeApp extends HandlebarsApplicationMixin(ApplicationV2) {
|
|||||||
*/
|
*/
|
||||||
export async function raiseDungeon(params = {}) {
|
export async function raiseDungeon(params = {}) {
|
||||||
if (!game.user.isGM) return ui.notifications.warn('DELVE is a GM tool.');
|
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 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 sk = d.skeleton;
|
||||||
const title = sk.placeName;
|
const title = sk.placeName;
|
||||||
|
|
||||||
@@ -119,7 +127,7 @@ export async function raiseDungeon(params = {}) {
|
|||||||
heat: area.encounter.heat, forStage: area.name,
|
heat: area.encounter.heat, forStage: area.name,
|
||||||
...(seams ? { hoard: false, post: false, folderId: folders.Actor.id } : {}),
|
...(seams ? { hoard: false, post: false, folderId: folders.Actor.id } : {}),
|
||||||
}).catch(e => { console.error('DELVE | encounter failed', e); return null; });
|
}).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) {
|
if (area.hoard) {
|
||||||
const h = await game.vanity.forge.hoard({ size: area.hoard, ...quiet })
|
const h = await game.vanity.forge.hoard({ size: area.hoard, ...quiet })
|
||||||
@@ -151,7 +159,7 @@ export async function raiseDungeon(params = {}) {
|
|||||||
<span class="delve-sub">${d.areas.length} areas · ${esc(d.params.theme)} · depth ${d.params.depth} · seed ${esc(seed)}</span></header>
|
<span class="delve-sub">${d.areas.length} areas · ${esc(d.params.theme)} · depth ${d.params.depth} · seed ${esc(seed)}</span></header>
|
||||||
<p>${scenes.filter(Boolean).length} scenes, ${game.actors.filter(a => a.folder?.id === folders.Actor.id).length} actors and a journal, all in the folder <b>${esc(title)}</b>.</p>
|
<p>${scenes.filter(Boolean).length} scenes, ${game.actors.filter(a => a.folder?.id === folders.Actor.id).length} actors and a journal, all in the folder <b>${esc(title)}</b>.</p>
|
||||||
<p>@UUID[${journal.uuid}]{Open the adventure}</p>
|
<p>@UUID[${journal.uuid}]{Open the adventure}</p>
|
||||||
${seams ? '' : '<p><b>⚠ Forge seams missing</b> — update the VANITY system to 0.10.5 for quiet output.</p>'}
|
${seams ? '' : '<p><b>⚠ Forge seams missing</b> — update the VANITY system to 0.10.4 for quiet output.</p>'}
|
||||||
</div>`,
|
</div>`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,6 +168,26 @@ export async function raiseDungeon(params = {}) {
|
|||||||
|
|
||||||
/* ---------------------------------------------------------------- journal */
|
/* ---------------------------------------------------------------- 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.
|
||||||
|
*/
|
||||||
|
export 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)}.${initiativeWarning(c.roster) ? ` <b>${esc(initiativeWarning(c.roster))}</b>` : ''} <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) {
|
function buildPages(d, scenes) {
|
||||||
const sk = d.skeleton, ap = sk.appeasement;
|
const sk = d.skeleton, ap = sk.appeasement;
|
||||||
const pages = [];
|
const pages = [];
|
||||||
@@ -208,11 +236,7 @@ function buildPages(d, scenes) {
|
|||||||
${rv.failure ? `<li><b>Miss</b> → ${esc(rv.failure)}</li>` : ''}
|
${rv.failure ? `<li><b>Miss</b> → ${esc(rv.failure)}</li>` : ''}
|
||||||
${rv.orElse ? `<li><b>Or</b> ${esc(rv.orElse)}</li>` : ''}
|
${rv.orElse ? `<li><b>Or</b> ${esc(rv.orElse)}</li>` : ''}
|
||||||
</ul>
|
</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>
|
${foeSection(classifyFoes(a, a._foes ?? []))}
|
||||||
<table><thead><tr><th>Foe</th><th>atk</th><th>def</th><th>Grit</th><th>Nerve</th><th></th></tr></thead><tbody>
|
|
||||||
${R.foes.map(f => `<tr><td>${f.n}× ${esc(f.name)}</td><td>${f.atk}</td><td>${f.def}</td><td>${f.grit}</td><td>${f.nerve}</td><td><i>${esc(f.note)}</i></td></tr>`).join('')}
|
|
||||||
</tbody></table>` : ''}
|
|
||||||
${a._foes?.length ? `<p><b>Rolled for you:</b> ${a._foes.map(f => `@UUID[${f.uuid}]{${esc(f.name)}}`).join(' · ')}</p>` : ''}
|
|
||||||
${a.temptation ? `<p><b>${esc(cap(a.temptation.id))}</b> — ${esc(a.temptation.cue)}: ${esc(a.temptation.benefit)}.<br>
|
${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>` : ''}
|
<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>` : ''}
|
${a._hoard?.length ? `<p><b>Hoard.</b></p><ul>${a._hoard.map(l => `<li>${l}</li>`).join('')}</ul>` : ''}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Raising one area, as a sequence of decisions rather than a sequence of Foundry calls.
|
||||||
|
*
|
||||||
|
* Every effect arrives as an injected function, so the whole thing runs under plain node and its
|
||||||
|
* failure paths can be tested. That is the point: three releases in a row shipped a bug on a path
|
||||||
|
* that only runs when the Forge misbehaves, and reasoning about those paths turned out not to be
|
||||||
|
* evidence.
|
||||||
|
*
|
||||||
|
* The contract, which the tests hold:
|
||||||
|
*
|
||||||
|
* The scene is the commit point. If it fails, nothing was entered — no cards, no advance, and a
|
||||||
|
* retry is safe. Once it exists the area IS entered, so the turn advances immediately and
|
||||||
|
* everything after it degrades loudly rather than rolling back. A retry after a later failure
|
||||||
|
* must not raise the same scene twice.
|
||||||
|
*
|
||||||
|
* Every degradation says what the GM has lost. Silence is what made the old hoard failure
|
||||||
|
* invisible and the old population failure lie about a roster that did not exist.
|
||||||
|
*/
|
||||||
|
import { foeStats, foeLine, classifyFoes } from './foes.mjs';
|
||||||
|
import { initiativeWarning } from './core/roster.mjs';
|
||||||
|
|
||||||
|
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>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one injected effect, surviving both a rejected promise and a synchronous throw.
|
||||||
|
* `Promise.resolve(fn())` is not enough: a synchronous throw escapes before there is a promise to
|
||||||
|
* attach a catch to, which is how the first version of this file crashed the whole of enter().
|
||||||
|
*/
|
||||||
|
const attempt = async (fx, fn) => {
|
||||||
|
try { return { ok: true, value: await fn() }; }
|
||||||
|
catch (e) { fx.log?.(e); return { ok: false, value: null }; }
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap an async function so overlapping calls are refused rather than interleaved.
|
||||||
|
*
|
||||||
|
* enter() hangs off a button, and it awaits the Forge for seconds at a time. Two overlapping calls
|
||||||
|
* both read the same index and stage the same area twice. This closes that within one client; two
|
||||||
|
* separate GM browsers still race, and the durable fix for that is a world-level lock rather than
|
||||||
|
* a module variable.
|
||||||
|
*/
|
||||||
|
export function singleFlight(fn, onBusy) {
|
||||||
|
let busy = false;
|
||||||
|
return async (...args) => {
|
||||||
|
if (busy) return onBusy?.();
|
||||||
|
busy = true;
|
||||||
|
try { return await fn(...args); } finally { busy = false; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The foe section of the GM card. Mirrors forge-app's foeSection; both switch on the same kind. */
|
||||||
|
export function foeBlock(c) {
|
||||||
|
if (c.kind === 'forged') return `<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>` : ''}`;
|
||||||
|
if (c.kind === 'planned') return `<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>${initiativeWarning(c.roster) ? ` <b>${initiativeWarning(c.roster)}</b>` : ''} ${c.roster.avoid}.</p>`;
|
||||||
|
if (c.kind === 'unavailable') return `<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>`;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two cards an area posts: the players' read-aloud, and the GM's card. */
|
||||||
|
export function areaCards({ area, name, authored = {}, classification }) {
|
||||||
|
const rv = area.decision?.resolve ?? {};
|
||||||
|
return {
|
||||||
|
readAloud: authored.readAloud ?? `<i>(unwritten)</i> ${area.cueFragments.join('. ')}.`,
|
||||||
|
gm: {
|
||||||
|
label: `⛏ ${area.index} · ${name}`,
|
||||||
|
sub: `${area.role} · ${area.facet}`,
|
||||||
|
body: `${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>
|
||||||
|
${foeBlock(classification)}
|
||||||
|
${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>` : ''}
|
||||||
|
${authored.notes ? `<p><b>Your note:</b> ${authored.notes}</p>` : ''}
|
||||||
|
<p><code>${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}</code></p>`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} fx injected effects: stage, encounter, hoard, commit, readAloud, gm, warn, error
|
||||||
|
* @param {object} ctx { area, name, authored }
|
||||||
|
* @returns {{entered: boolean, failed?: string, kind?: string}}
|
||||||
|
*/
|
||||||
|
export async function stageArea(fx, { area, name, authored = {} }) {
|
||||||
|
const scene = await attempt(fx, () => fx.stage(name));
|
||||||
|
if (!scene.ok || !scene.value) {
|
||||||
|
fx.error(`DELVE: the Forge could not raise ${name}. Nothing staged; try again.`);
|
||||||
|
return { entered: false, failed: 'stage' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The scene exists, so this area has been entered. Commit before anything that may fail, or a
|
||||||
|
// retry restages it. If the commit itself fails the scene is still real and the delve is still
|
||||||
|
// playable, but the index no longer matches the world — say so, because the next press of ⏩
|
||||||
|
// would raise this area a second time.
|
||||||
|
const committed = await attempt(fx, () => fx.commit());
|
||||||
|
if (!committed.ok) fx.warn(`DELVE: ${name} is staged, but the delve's progress could not be saved. Pressing ⏩ again will raise it a second time — reload and check before continuing.`);
|
||||||
|
|
||||||
|
let forged = [];
|
||||||
|
if (area.encounter) {
|
||||||
|
const enc = await attempt(fx, () => fx.encounter(area.encounter.heat));
|
||||||
|
if (enc.ok && enc.value) forged = (enc.value.actors ?? []).map(foeStats);
|
||||||
|
else fx.warn(area.encounter.roster
|
||||||
|
? `DELVE: could not populate ${name} — the planned roster stands in.`
|
||||||
|
: `DELVE: could not populate ${name}, and this theme has no roster at ${area.encounter.heat}. Improvise the fight or skip it.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (area.hoard) {
|
||||||
|
const h = await attempt(fx, () => fx.hoard(area.hoard));
|
||||||
|
if (!h.ok || !h.value) fx.warn(`DELVE: could not lay the ${area.hoard} hoard in ${name} — improvise it or skip it.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const classification = classifyFoes(area, forged);
|
||||||
|
const cards = areaCards({ area, name, authored, classification });
|
||||||
|
|
||||||
|
// Players first — the scene is up and this is what they came for.
|
||||||
|
const said = (await attempt(fx, () => fx.readAloud(name, cards.readAloud))).ok;
|
||||||
|
const told = (await attempt(fx, () => fx.gm(cards.gm.label, cards.gm.sub, cards.gm.body))).ok;
|
||||||
|
if (!said || !told) fx.warn(`DELVE: ${name} is staged but its ${!said && !told ? 'cards' : !said ? 'read-aloud' : 'GM card'} did not post. The turn has advanced; read from the worksheet.`);
|
||||||
|
|
||||||
|
return { entered: true, committed: committed.ok, kind: classification.kind };
|
||||||
|
}
|
||||||
@@ -16,19 +16,55 @@
|
|||||||
*/
|
*/
|
||||||
import { coinSeed, Rng } from './core/rng.mjs';
|
import { coinSeed, Rng } from './core/rng.mjs';
|
||||||
import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.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';
|
||||||
|
import { stageArea, singleFlight } from './stage.mjs';
|
||||||
|
|
||||||
const MOD = 'vanity-delve';
|
const MOD = 'vanity-delve';
|
||||||
const FLAG = 'state';
|
const FLAG = 'state';
|
||||||
|
|
||||||
let PACK = null;
|
let PACK = null;
|
||||||
let seamsPresent = false;
|
let seamsPresent = false;
|
||||||
|
let loadPack = async () => null;
|
||||||
|
|
||||||
|
/** The geometries VANITY's Forge can actually build — mirrors validate-pack.mjs. */
|
||||||
|
const GEOMETRIES = ['barrow', 'cave', 'fen', 'village', 'forest'];
|
||||||
|
|
||||||
const getState = () => game.settings.get(MOD, FLAG) ?? null;
|
const getState = () => game.settings.get(MOD, FLAG) ?? null;
|
||||||
const setState = async s => game.settings.set(MOD, FLAG, s);
|
const setState = async s => game.settings.set(MOD, FLAG, s);
|
||||||
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>`;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
const usable = p => GEOMETRIES.includes(p?.forgeStageType);
|
||||||
|
if (PACK?.id === id && usable(PACK)) return PACK; // validate the cache too
|
||||||
|
const p = await loadPack(id);
|
||||||
|
if (!usable(p)) 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;
|
||||||
|
}
|
||||||
/** GM-only: truth, numbers, leverage. Never read aloud. */
|
/** GM-only: truth, numbers, leverage. Never read aloud. */
|
||||||
const gmCard = (label, sub, body) => ChatMessage.create({
|
const gmCard = (label, sub, body) => ChatMessage.create({
|
||||||
speaker: { alias: 'DELVE' },
|
speaker: { alias: 'DELVE' },
|
||||||
@@ -50,6 +86,7 @@ async function load(working) {
|
|||||||
if (!d?.skeleton || !Array.isArray(d.areas)) return ui.notifications.error('DELVE: that is not a delve file.');
|
if (!d?.skeleton || !Array.isArray(d.areas)) return ui.notifications.error('DELVE: that is not a delve file.');
|
||||||
|
|
||||||
const todo = outstanding(d);
|
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' });
|
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: [] });
|
await setState({ delve: d, folderId: folder.id, at: 0, turn: 0, tab: [], clock: [] });
|
||||||
|
|
||||||
@@ -74,12 +111,20 @@ async function loadFile(name) {
|
|||||||
/** Generate an unfinished draft in-world. Convenience only — the desk is the right place. */
|
/** Generate an unfinished draft in-world. Convenience only — the desk is the right place. */
|
||||||
async function draft(params = {}) {
|
async function draft(params = {}) {
|
||||||
const seed = params.seed || coinSeed(new Rng(String(game.world.id)));
|
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.');
|
ui.notifications.warn('DELVE: unfinished draft. Write the read-aloud in the worksheet first.');
|
||||||
return load(d);
|
return load(d);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enter() {
|
const enter = singleFlight(
|
||||||
|
() => enterOnce(),
|
||||||
|
() => ui.notifications.warn('DELVE: already raising an area — wait for it to finish.'),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function enterOnce() {
|
||||||
const st = getState();
|
const st = getState();
|
||||||
if (!st) return ui.notifications.warn('DELVE: nothing loaded.');
|
if (!st) return ui.notifications.warn('DELVE: nothing loaded.');
|
||||||
const d = st.delve;
|
const d = st.delve;
|
||||||
@@ -88,37 +133,31 @@ async function enter() {
|
|||||||
const area = d.areas[st.at];
|
const area = d.areas[st.at];
|
||||||
const w = d.authored?.areas?.[area.index] ?? {};
|
const w = d.authored?.areas?.[area.index] ?? {};
|
||||||
const name = w.nameOverride ?? area.name;
|
const name = w.nameOverride ?? area.name;
|
||||||
|
const pack = await packFor(d);
|
||||||
|
if (!pack) return;
|
||||||
ui.notifications.info(`DELVE: raising ${name}…`);
|
ui.notifications.info(`DELVE: raising ${name}…`);
|
||||||
|
|
||||||
|
// The decisions live in stage.mjs so their failure paths can be tested; this is only the wiring.
|
||||||
const quiet = seamsPresent ? { post: false, folderId: st.folderId } : {};
|
const quiet = seamsPresent ? { post: false, folderId: st.folderId } : {};
|
||||||
const stage = await game.vanity.forge.stage({
|
const fx = {
|
||||||
type: PACK.forgeStageType, size: 'medium', name, populate: false, activate: true, ...quiet,
|
stage: n => game.vanity.forge.stage({
|
||||||
});
|
type: pack.forgeStageType, size: 'medium', name: n, populate: false, activate: true, ...quiet,
|
||||||
if (area.encounter) await game.vanity.forge.encounter({
|
}),
|
||||||
heat: area.encounter.heat, forStage: name,
|
encounter: heat => game.vanity.forge.encounter({
|
||||||
|
heat, forStage: name,
|
||||||
...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}),
|
...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}),
|
||||||
});
|
}),
|
||||||
if (area.hoard) await game.vanity.forge.hoard({ size: area.hoard, ...(seamsPresent ? { post: false } : {}) });
|
hoard: size => game.vanity.forge.hoard({ size, ...(seamsPresent ? { post: false } : {}) }),
|
||||||
|
// An incremented copy, never a mutation of st: a failed write must leave the in-memory state
|
||||||
// Players first — the scene is up and this is what they came for.
|
// exactly as it was, or the retry advice in the warning is wrong.
|
||||||
await readAloudCard(name, w.readAloud ?? `<i>(unwritten)</i> ${area.cueFragments.join('. ')}.`);
|
commit: () => setState({ ...st, at: st.at + 1, turn: st.turn + 1 }),
|
||||||
|
readAloud: readAloudCard,
|
||||||
// Then the GM, quietly.
|
gm: gmCard,
|
||||||
const R = area.encounter?.roster;
|
warn: m => ui.notifications.warn(m),
|
||||||
const rv = area.decision?.resolve ?? {};
|
error: m => ui.notifications.error(m),
|
||||||
await gmCard(`⛏ ${area.index} · ${name}`, `${area.role} · ${area.facet}`,
|
log: e => console.error('DELVE | staging', e),
|
||||||
`${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>` : ''}
|
return stageArea(fx, { area, name, authored: w });
|
||||||
<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>` : ''}
|
|
||||||
${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>`);
|
|
||||||
|
|
||||||
st.at += 1; st.turn += 1;
|
|
||||||
await setState(st);
|
|
||||||
return { stage, area };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ending() {
|
async function ending() {
|
||||||
@@ -157,8 +196,13 @@ Hooks.once('init', () => {
|
|||||||
|
|
||||||
Hooks.once('ready', async () => {
|
Hooks.once('ready', async () => {
|
||||||
if (!game.user.isGM) return;
|
if (!game.user.isGM) return;
|
||||||
PACK = await foundry.utils.fetchJsonWithTimeout(`modules/${MOD}/module/core/content/barrow.json`)
|
const fetchJson = p => foundry.utils.fetchJsonWithTimeout(p).catch(() => fetch(p).then(r => r.json()).catch(() => null));
|
||||||
.catch(() => fetch(`modules/${MOD}/module/core/content/barrow.json`).then(r => r.json()));
|
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`));
|
||||||
|
await packById('barrow'); // sets PACK, and validates it like any other
|
||||||
seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? ''));
|
seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? ''));
|
||||||
|
|
||||||
game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon,
|
game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon,
|
||||||
@@ -166,6 +210,9 @@ Hooks.once('ready', async () => {
|
|||||||
load, loadFile, draft, enter, ending, bane, clock, state: getState,
|
load, loadFile, draft, enter, ending, bane, clock, state: getState,
|
||||||
outstanding: () => outstanding(getState()?.delve ?? { areas: [] }),
|
outstanding: () => outstanding(getState()?.delve ?? { areas: [] }),
|
||||||
ready: () => readyToPlay(getState()?.delve ?? { areas: [] }),
|
ready: () => readyToPlay(getState()?.delve ?? { areas: [] }),
|
||||||
|
// the validating resolver, not the raw fetcher — raiseDungeon stages from this,
|
||||||
|
// so a pack with no forgeStageType must never reach forge.stage({type: undefined})
|
||||||
|
loadPack: id => packById(id),
|
||||||
get pack() { return PACK; } };
|
get pack() { return PACK; } };
|
||||||
console.log(`DELVE | ready. Forge seams ${seamsPresent ? 'present' : 'ABSENT'}.`);
|
console.log(`DELVE | ready. Forge seams ${seamsPresent ? 'present' : 'ABSENT'}.`);
|
||||||
if (!seamsPresent) ui.notifications.warn('DELVE: Forge seams not installed — see the delve-seams branch.');
|
if (!seamsPresent) ui.notifications.warn('DELVE: Forge seams not installed — see the delve-seams branch.');
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="delve-grid">
|
<div class="delve-grid">
|
||||||
<label>Theme
|
<label>Theme
|
||||||
<select name="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>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>Areas
|
<label>Areas
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
/**
|
||||||
|
* Adapter tests. No Foundry, no shim — foes.mjs and stage.mjs are pure on purpose.
|
||||||
|
*
|
||||||
|
* These exist because the same area of code broke in three consecutive releases, every time on a
|
||||||
|
* path that only runs when something misbehaves, and every time I convinced myself by reading it.
|
||||||
|
* Each case below is a bug that shipped or nearly shipped.
|
||||||
|
*
|
||||||
|
* Run: node foundry-module/test.mjs
|
||||||
|
*/
|
||||||
|
import { foeStats, foeLine, classifyFoes } from './module/foes.mjs';
|
||||||
|
import { stageArea, areaCards, foeBlock, singleFlight } from './module/stage.mjs';
|
||||||
|
import { initiativeWarning } from '../core/roster.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',
|
||||||
|
beforeInitiative: 'Say so before initiative.',
|
||||||
|
avoid: 'send someone plain',
|
||||||
|
};
|
||||||
|
const area = (extra = {}) => ({
|
||||||
|
index: 2, role: 'complication', facet: 'ritual', name: 'The Sail Loft',
|
||||||
|
cueFragments: ['a tide glass turned by the watch'],
|
||||||
|
decision: { cue: 'a bell rope still swinging', resolve: { roll: 'Poise 2', success: 'it comes to order', failure: 'the harbour turns early', orElse: 'leave it' } },
|
||||||
|
trigger: 'seenTwice', fallback: { route: 'the dead will receive a guest' },
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
const withEnc = (r = roster) => area({ encounter: { heat: 'fight', roster: r } });
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- reading actors
|
||||||
|
const f = foeStats(actor);
|
||||||
|
t('foeStats reads 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}'));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- the four kinds
|
||||||
|
t('no encounter → none', classifyFoes(area(), []).kind === 'none');
|
||||||
|
t('actors forged → forged', classifyFoes(withEnc(), [f]).kind === 'forged');
|
||||||
|
t('nothing forged but a roster → planned', classifyFoes(withEnc(), []).kind === 'planned',
|
||||||
|
'the 0.6.2 regression: population off left no numbers at all');
|
||||||
|
t('heat but neither → unavailable', classifyFoes(area({ encounter: { heat: 'fight' } }), []).kind === 'unavailable');
|
||||||
|
t('a forged classification exposes no roster to render from', classifyFoes(withEnc(), [f]).roster === undefined);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- what reaches the card
|
||||||
|
const forgedBlock = foeBlock(classifyFoes(withEnc(), [f]));
|
||||||
|
t('forged block runs off the actors', forgedBlock.includes('Morthollow-born') && forgedBlock.includes('in the world'));
|
||||||
|
t('forged block never asserts the plan\'s tactics', !forgedBlock.includes('blessed, silvered'),
|
||||||
|
'roster guidance names monsters the Forge did not create');
|
||||||
|
t('forged block still names the plan', forgedBlock.includes('1 Ghoul and 2 Skeletons'));
|
||||||
|
|
||||||
|
const plannedBlock = foeBlock(classifyFoes(withEnc(), []));
|
||||||
|
t('planned block is runnable', plannedBlock.includes('Ghoul') && plannedBlock.includes('4/3/4'));
|
||||||
|
t('planned block carries the immunity', plannedBlock.includes('blessed, silvered'));
|
||||||
|
t('planned block warns before initiative', plannedBlock.includes('Say so before initiative.'),
|
||||||
|
'the 0.6.4 regression: deleting the ONLY gate stripped this from 12 rosters');
|
||||||
|
t('the warning is said exactly once',
|
||||||
|
(plannedBlock.match(/say so before initiative/gi) ?? []).length === 1);
|
||||||
|
t('unavailable says so rather than rendering nothing',
|
||||||
|
foeBlock(classifyFoes(area({ encounter: { heat: 'fight' } }), [])).includes('nothing to run'));
|
||||||
|
|
||||||
|
const cards = areaCards({ area: withEnc(), name: 'The Sail Loft', authored: {}, classification: classifyFoes(withEnc(), [f]) });
|
||||||
|
t('an unwritten read-aloud is marked as such', cards.readAloud.includes('(unwritten)'));
|
||||||
|
t('written prose outranks the fragments',
|
||||||
|
areaCards({ area: withEnc(), name: 'x', authored: { readAloud: 'They have been waiting.' }, classification: classifyFoes(withEnc(), [f]) })
|
||||||
|
.readAloud === 'They have been waiting.');
|
||||||
|
t('the GM card carries the decision and its miss', cards.gm.body.includes('Poise 2') && cards.gm.body.includes('Miss:'));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- the failure matrix
|
||||||
|
const spy = (over = {}) => {
|
||||||
|
const log = { calls: [], warns: [], errors: [], committed: 0, cards: 0 };
|
||||||
|
const fx = {
|
||||||
|
stage: n => { log.calls.push('stage'); return { id: 'scn1', name: n }; },
|
||||||
|
encounter: () => { log.calls.push('encounter'); return { actors: [actor] }; },
|
||||||
|
hoard: () => { log.calls.push('hoard'); return { size: 'cache' }; },
|
||||||
|
commit: async () => { log.committed++; },
|
||||||
|
readAloud: () => { log.cards++; },
|
||||||
|
gm: () => { log.cards++; },
|
||||||
|
warn: m => log.warns.push(m),
|
||||||
|
error: m => log.errors.push(m),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
return { fx, log };
|
||||||
|
};
|
||||||
|
const boom = () => { throw new Error('forge exploded'); };
|
||||||
|
const ctx = (a = withEnc()) => ({ area: { ...a, hoard: 'cache' }, name: 'The Sail Loft', authored: {} });
|
||||||
|
|
||||||
|
{
|
||||||
|
const { fx, log } = spy();
|
||||||
|
const r = await stageArea(fx, ctx());
|
||||||
|
t('happy path enters, commits once and posts both cards',
|
||||||
|
r.entered && log.committed === 1 && log.cards === 2 && !log.warns.length && !log.errors.length);
|
||||||
|
t('happy path reports the kind it rendered', r.kind === 'forged');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { fx, log } = spy({ stage: boom });
|
||||||
|
const r = await stageArea(fx, ctx());
|
||||||
|
t('a failed scene enters nothing', !r.entered && r.failed === 'stage');
|
||||||
|
t('a failed scene does not commit — a retry is safe', log.committed === 0,
|
||||||
|
'the v0.6.3 bug: scene left behind, index unchanged, retry duplicated it');
|
||||||
|
t('a failed scene posts no cards and says why', log.cards === 0 && log.errors.length === 1);
|
||||||
|
t('a failed scene never calls the Forge again', !log.calls.includes('encounter'));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { fx, log } = spy({ encounter: boom });
|
||||||
|
const r = await stageArea(fx, ctx());
|
||||||
|
t('a failed population still enters — the scene is up', r.entered && log.committed === 1);
|
||||||
|
t('a failed population falls back to the plan', r.kind === 'planned');
|
||||||
|
t('a failed population says the roster stands in', log.warns.some(w => /planned roster stands in/.test(w)));
|
||||||
|
t('a failed population still posts both cards', log.cards === 2);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { fx, log } = spy({ encounter: boom });
|
||||||
|
const r = await stageArea(fx, ctx(area({ encounter: { heat: 'fight' } })));
|
||||||
|
t('a failed population with no roster does not claim one', !log.warns.some(w => /roster stands in/.test(w)),
|
||||||
|
'the 0.6.4 regression: it promised a roster that did not exist');
|
||||||
|
t('a failed population with no roster says what is actually lost',
|
||||||
|
log.warns.some(w => /no roster at fight/.test(w)) && r.kind === 'unavailable');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { fx, log } = spy({ hoard: boom });
|
||||||
|
const r = await stageArea(fx, ctx());
|
||||||
|
t('a failed hoard still enters', r.entered && log.committed === 1);
|
||||||
|
t('a failed hoard is visible to the GM', log.warns.some(w => /hoard/.test(w)),
|
||||||
|
'the 0.6.4 regression: swallowed to console while the turn advanced');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { fx, log } = spy({ gm: boom });
|
||||||
|
const r = await stageArea(fx, ctx());
|
||||||
|
t('a failed GM card does not roll back the turn', r.entered && log.committed === 1);
|
||||||
|
t('a failed GM card tells the GM to read from the worksheet',
|
||||||
|
log.warns.some(w => /GM card did not post/.test(w)));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { fx, log } = spy({ readAloud: boom, gm: boom });
|
||||||
|
await stageArea(fx, ctx());
|
||||||
|
t('both cards failing reports once, not twice', log.warns.filter(w => /did not post/.test(w)).length === 1);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// async rejections, not just throws — the Forge returns promises
|
||||||
|
const { fx, log } = spy({ encounter: () => Promise.reject(new Error('async')) });
|
||||||
|
const r = await stageArea(fx, ctx());
|
||||||
|
t('a rejected promise is handled like a throw', r.entered && r.kind === 'planned' && log.warns.length === 1);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { fx, log } = spy({ stage: () => null });
|
||||||
|
const r = await stageArea(fx, ctx());
|
||||||
|
t('a Forge that returns nothing counts as a failed scene', !r.entered && log.committed === 0);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const { fx, log } = spy({ commit: () => { throw new Error('settings write failed'); } });
|
||||||
|
const r = await stageArea(fx, ctx());
|
||||||
|
t('a failed commit does not take down staging', r.entered && r.committed === false);
|
||||||
|
t('a failed commit warns that a retry will restage',
|
||||||
|
log.warns.some(w => /raise it a second time/.test(w)),
|
||||||
|
'the scene is real but the index does not know it');
|
||||||
|
t('a failed commit still posts the cards', log.cards === 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- the warning
|
||||||
|
t('the field is used when present', initiativeWarning({ harmedBy: 'anything', beforeInitiative: 'Say so.' }) === 'Say so.');
|
||||||
|
t('a plain roster needs no warning', initiativeWarning({ harmedBy: 'anything' }) === null);
|
||||||
|
t('a legacy ONLY roster still gets one', initiativeWarning({ harmedBy: 'blessed weapons ONLY' }) === 'Say so before initiative.',
|
||||||
|
'delve files generated before the field existed must not lose it');
|
||||||
|
t('a legacy Wraith roster spelled without ONLY still gets one',
|
||||||
|
initiativeWarning({ harmedBy: 'blessed, silvered or magical weapons for the Wraith; anything for the rest' }) !== null,
|
||||||
|
'the spelling was never the semantics — barrow/nightmare had no ONLY');
|
||||||
|
t('a legacy roster that says it in prose is not given it twice',
|
||||||
|
initiativeWarning({ harmedBy: 'blessed weapons ONLY — say so before initiative' }) === null);
|
||||||
|
t('prose in avoid also suppresses the fallback',
|
||||||
|
initiativeWarning({ harmedBy: 'blessed weapons ONLY', avoid: 'burn it — say so before initiative' }) === null);
|
||||||
|
t('the field never overrides prose that already says it',
|
||||||
|
initiativeWarning({ harmedBy: 'blessed weapons ONLY — say so before initiative', beforeInitiative: 'Say so before initiative.' }) === null,
|
||||||
|
'a hand-edited file can carry both; returning the field first printed it twice');
|
||||||
|
t('null roster is safe', initiativeWarning(null) === null);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- single flight
|
||||||
|
{
|
||||||
|
let running = 0, peak = 0, refused = 0;
|
||||||
|
const guarded = singleFlight(async () => {
|
||||||
|
running++; peak = Math.max(peak, running);
|
||||||
|
await new Promise(r => setTimeout(r, 5));
|
||||||
|
running--; return 'done';
|
||||||
|
}, () => { refused++; return 'busy'; });
|
||||||
|
const [a, b] = await Promise.all([guarded(), guarded()]);
|
||||||
|
t('two overlapping calls never run together', peak === 1, `peak concurrency ${peak}`);
|
||||||
|
t('the second is refused, not queued', a === 'done' && b === 'busy' && refused === 1);
|
||||||
|
t('the guard clears so a later call still runs', (await guarded()) === 'done');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const guarded = singleFlight(async () => { throw new Error('boom'); }, () => 'busy');
|
||||||
|
await guarded().catch(() => {});
|
||||||
|
t('a throw does not leave the guard stuck', (await guarded().catch(() => 'threw')) === 'threw');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- surface parity
|
||||||
|
// forge-app destructures three Foundry symbols at import time. Everything under test is pure, so
|
||||||
|
// the smallest possible shim makes the journal renderer importable and its parity with the chat
|
||||||
|
// renderer checkable — the two drifted apart twice before they shared a classifier.
|
||||||
|
{
|
||||||
|
globalThis.foundry ??= {
|
||||||
|
applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: c => c } },
|
||||||
|
utils: { escapeHTML: s => String(s ?? '') },
|
||||||
|
};
|
||||||
|
const { foeSection } = await import('./module/forge-app.mjs').catch(() => ({ foeSection: null }));
|
||||||
|
if (!foeSection) { t('journal renderer importable for parity checks', false, 'needs a foundry shim'); }
|
||||||
|
else for (const [kind, c] of [
|
||||||
|
['forged', classifyFoes(withEnc(), [f])],
|
||||||
|
['planned', classifyFoes(withEnc(), [])],
|
||||||
|
['unavailable', classifyFoes(area({ encounter: { heat: 'fight' } }), [])],
|
||||||
|
['none', classifyFoes(area(), [])],
|
||||||
|
]) {
|
||||||
|
const chat = foeBlock(c), journal = foeSection(c);
|
||||||
|
t(`both surfaces agree on ${kind}: both render or both stay silent`,
|
||||||
|
(chat.trim() === '') === (journal.trim() === ''));
|
||||||
|
if (kind === 'planned') t('both surfaces carry the warning on planned',
|
||||||
|
/say so before initiative/i.test(chat) && /say so before initiative/i.test(journal));
|
||||||
|
if (kind === 'forged') t('neither surface asserts the plan on forged',
|
||||||
|
!/blessed, silvered/.test(chat) && !/blessed, silvered/.test(journal));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
Reference in New Issue
Block a user