4 Commits
Author SHA1 Message Date
slaguru666andClaude Opus 5 d47a167fd0 Packs go to schema 2, so the catalog stamp records the new roster field
beat.mjs embeds a pack's roster verbatim into every area, so adding
beforeInitiative changed the shape of the emitted delve file — and two files
from the same seed could carry different structures under identical stamps.

The change originates in the packs, not in the generator's composition, so it
belongs to catalogVersion rather than GENERATOR_VERSION: the packs are at
schema 2 and a file now stamps barrow@2. The note on GENERATOR_VERSION says
which stamp owns which kind of change, since that was the ambiguity.

README's test counts were still 39 and silent about the adapter suite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:52:08 +01:00
slaguru666andClaude Opus 5 c1e467c91b Prose wins over the field; the play sheet carries avoid; the guard is testable
Still unreleased, but module.json is at 0.6.5 now.

initiativeWarning checked the field before the prose, so a hand-edited file
carrying both printed the warning twice — the exact fault the migration was
meant to end. Prose is checked first now and always wins.

The play sheet was the only surface that did not render `avoid`, so three
legacy rosters whose warning lived there — church, forest and temple at
nightmare — lost it entirely: the helper suppressed its fallback on finding
prose the play sheet never showed. It renders `avoid` now, which it should
have anyway; it is how the table gets past a fight without having one, which
is table-facing by definition. All five surfaces now render both prose
fields, so "the prose already says it" means the same thing everywhere.

Replayed all 64 rosters in their pre-field form through every surface: 26
need a warning, 130 of 130 checks say it exactly once. The current packs
give the same result.

commit() no longer mutates st before the write — it passes an incremented
copy, so a failed write really does leave the state as it was, which is what
the retry warning promises.

The single-flight guard moved into stage.mjs as singleFlight() and has four
tests: overlapping calls never run together, the second is refused rather
than queued, the guard clears afterwards, and a throw does not wedge it. It
closes the window within one client only; two GM browsers still race, and
that needs a world-level lock rather than a module variable.

41 core tests, 60 adapter tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:46:43 +01:00
slaguru666andClaude Opus 5 1b2f9aa385 The warning reaches every surface; commit and concurrency are covered
Still unreleased. Answering the three blockers.

The worksheet and play sheet never rendered the new field, and stripping
the prose meant they said nothing at all — the two surfaces the whole
two-surface design is about. All five renderers now go through one helper,
core/roster.mjs, which also recovers the warning for delve files generated
before the field existed and refuses to add a line the prose already
carries. Measured across four themes: 40 of 40 delves carrying a roster now
warn on worksheet, play sheet and markdown alike.

Twenty-three was the wrong set. The spelling was never the semantics:
barrow's nightmare Wraith, cave's nightmare Troll and forest's battle Troll
carry the same non-discoverable restriction without the word ONLY. Marked,
so 26. I audited all 64 rosters rather than trusting the count — the ones
left unmarked are discoverable by trying (an Ogre with 12 Grit, mooks that
keep coming until the necromancer stops), and damage works on all of them.

commit() was unprotected, so a failed settings write threw out of stageArea
with the scene already real. It is caught now and says plainly that a retry
will raise the area twice. enter() also has a single-flight guard: it is
bound to a button, it awaits the Forge for seconds, and two overlapping
calls both read the same index.

Also: packById checks the geometry is one VANITY can build rather than just
truthy; foeSection is exported and the suite holds the two renderers to
agreement on all four kinds; and the module README and the seams warning
said 0.10.5 where everything else says 0.10.4.

41 core tests, 54 adapter tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:35:15 +01:00
slaguru666andClaude Opus 5 0bbf57b693 The initiative warning becomes authored data, and staging gets a harness
Unreleased. The warning that v0.6.4 deleted is restored, but not as the
string heuristic that caused both faults — rosters carry beforeInitiative
now, and the renderers print it. 23 rosters have it: the 18 whose harmedBy
restricts what works, plus 5 Troll rosters that carried the instruction in
prose and so had two conventions between them. The prose copies are gone,
so it is said exactly once wherever it is said.

validate-pack enforces both halves — ONLY implies the field, and the phrase
may not appear in prose — and core/test.mjs now runs the validator over all
sixteen packs, so a pack regression fails the suite rather than waiting for
someone to remember the tool. Both rules were confirmed by breaking a pack
deliberately.

enter()'s decisions move to module/stage.mjs behind injected effects, which
makes the failure paths runnable under plain node. That immediately found a
bug in the first version of the extraction: Promise.resolve(fx.stage())
does not catch a synchronous throw, so a Forge that threw rather than
rejected would have taken the whole of enter() down. Nineteen of the new
tests are that matrix — failed scene, failed population with and without a
roster, failed hoard, failed cards, async rejection, and a Forge that
returns nothing.

The contract those tests hold: the scene is the commit point. Fail there
and nothing was entered, so a retry is safe; succeed and the turn advances
before anything else can fail, so a retry cannot restage. Everything after
degrades with a warning that says what was actually lost — the encounter
failure no longer promises a roster the area does not have, and the hoard
failure is no longer swallowed to the console.

Also: game.delve.loadPack is the validating resolver, so raiseDungeon can
no longer stage from a pack with no forgeStageType.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:21:10 +01:00
49 changed files with 680 additions and 235 deletions
+2 -2
View File
@@ -45,8 +45,8 @@ and two paper playtests that failed before anything worked.
| | | | | |
|---|---| |---|---|
| `core/` | the generator — pure JS, 39 tests | | `core/` | the generator — pure JS, 41 tests |
| `foundry-module/` | the Foundry module, with `core/` vendored | | `foundry-module/` | the Foundry module, with `core/` vendored — 60 tests |
| `paper/` | a hand-written delve and both playtest logs | | `paper/` | a hand-written delve and both playtest logs |
| `spike/` | the harness that measured VANITY's Forge outside Foundry | | `spike/` | the harness that measured VANITY's Forge outside Foundry |
| `samples/` | generated output | | `samples/` | generated output |
+5 -3
View File
@@ -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": [
{ {
@@ -2110,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",
@@ -2144,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": {
+3 -2
View File
@@ -2,7 +2,7 @@
"id": "castle", "id": "castle",
"label": "Castle", "label": "Castle",
"forgeStageType": "barrow", "forgeStageType": "barrow",
"schema": 1, "schema": 2,
"_model": "Same kernel as barrow: an APPETITE and the ACCOMMODATION made for it. A castle's appetites are about holding — ground, fear, obedience, a name — and its accommodations are what a house does to keep them.", "_model": "Same kernel as barrow: an APPETITE and the ACCOMMODATION made for it. A castle's appetites are about holding — ground, fear, obedience, a name — and its accommodations are what a house does to keep them.",
"facetProgression": { "facetProgression": {
"approach": "institution", "approach": "institution",
@@ -381,7 +381,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
+6 -4
View File
@@ -2,7 +2,7 @@
"id": "cave", "id": "cave",
"label": "Cave", "label": "Cave",
"forgeStageType": "cave", "forgeStageType": "cave",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Wild kernel underground. The appetite belongs to the hole itself — it wants to be gone into, gone down, spoken to, and to keep whatever comes. Claimants are the cave's own names for itself, and the accommodation is what it did to whoever came in.", "_model": "Wild kernel underground. The appetite belongs to the hole itself — it wants to be gone into, gone down, spoken to, and to keep whatever comes. Claimants are the cave's own names for itself, and the accommodation is what it did to whoever came in.",
"facetProgression": { "facetProgression": {
@@ -346,8 +346,9 @@
"note": "douses open flames on a hit" "note": "douses open flames on a hit"
} }
], ],
"harmedBy": "anything, but the Troll only stays down if fire touches it — say so before initiative, and note the swarm is putting your fires out", "harmedBy": "anything, but the Troll only stays down if fire touches it; the swarm douses open flames",
"avoid": "it will not leave the water; stay off the streamway and it stays in" "avoid": "it will not leave the water; stay off the streamway and it stays in",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Troll, 1 Ogre and 3 Ghouls of the Kept", "line": "1 Troll, 1 Ogre and 3 Ghouls of the Kept",
@@ -381,7 +382,8 @@
} }
], ],
"harmedBy": "anything; fire for the Troll", "harmedBy": "anything; fire for the Troll",
"avoid": "do not run this without an appeasement route open and a way out at your back" "avoid": "do not run this without an appeasement route open and a way out at your back",
"beforeInitiative": "Say so before initiative."
} }
}, },
"motifs": { "motifs": {
+3 -2
View File
@@ -2,7 +2,7 @@
"id": "church", "id": "church",
"label": "Church", "label": "Church",
"forgeStageType": "barrow", "forgeStageType": "barrow",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Interior kernel. A parish church wants to be believed, attended, given to, or kept holy, and its accommodations are what a cure of souls does to keep a flock.", "_model": "Interior kernel. A parish church wants to be believed, attended, given to, or kept holy, and its accommodations are what a cure of souls does to keep a flock.",
"facetProgression": { "facetProgression": {
@@ -382,7 +382,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest",
"avoid": "it will not strike anyone kneeling — say so before initiative" "avoid": "it will not strike anyone kneeling",
"beforeInitiative": "Say so before initiative."
} }
}, },
"motifs": { "motifs": {
+3 -2
View File
@@ -2,7 +2,7 @@
"id": "city", "id": "city",
"label": "City", "label": "City",
"forgeStageType": "village", "forgeStageType": "village",
"schema": 1, "schema": 2,
"placeNameStyle": "own", "placeNameStyle": "own",
"_model": "Settlement kernel at its largest. Nobody here knows anybody, so the city knows instead — by ward, by roll, by works, by freedom granted and withheld. Its cruelties are all administrative, which is why none of them can be argued with.", "_model": "Settlement kernel at its largest. Nobody here knows anybody, so the city knows instead — by ward, by roll, by works, by freedom granted and withheld. Its cruelties are all administrative, which is why none of them can be argued with.",
"facetProgression": { "facetProgression": {
@@ -396,7 +396,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
+5 -3
View File
@@ -2,7 +2,7 @@
"id": "forest", "id": "forest",
"label": "Forest", "label": "Forest",
"forgeStageType": "forest", "forgeStageType": "forest",
"schema": 1, "schema": 2,
"_model": "The wild kernel: the APPETITE belongs to the place itself. A wood wants to be entered, fed, spread, or left uncounted, and the ACCOMMODATION is what it did to whoever was here — took them, grew through them, let them wander. Claimants are the wood's own names for itself.", "_model": "The wild kernel: the APPETITE belongs to the place itself. A wood wants to be entered, fed, spread, or left uncounted, and the ACCOMMODATION is what it did to whoever was here — took them, grew through them, let them wander. Claimants are the wood's own names for itself.",
"facetProgression": { "facetProgression": {
"approach": "institution", "approach": "institution",
@@ -349,7 +349,8 @@
} }
], ],
"harmedBy": "anything, but the Troll only stays down if fire closes the wound", "harmedBy": "anything, but the Troll only stays down if fire closes the wound",
"avoid": "it is territorial and will not follow past the treeline" "avoid": "it is territorial and will not follow past the treeline",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "the Hunt: 1 Wraith mounted, 1 Troll and 4 Dire Wolves", "line": "the Hunt: 1 Wraith mounted, 1 Troll and 4 Dire Wolves",
@@ -383,7 +384,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; fire for the Troll; anything for the wolves", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; fire for the Troll; anything for the wolves",
"avoid": "the Hunt passes over anyone standing perfectly still — say so before initiative" "avoid": "the Hunt passes over anyone standing perfectly still",
"beforeInitiative": "Say so before initiative."
} }
}, },
"motifs": { "motifs": {
+6 -4
View File
@@ -2,7 +2,7 @@
"id": "lake", "id": "lake",
"label": "Lake", "label": "Lake",
"forgeStageType": "fen", "forgeStageType": "fen",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Wild kernel in still water. Where the river wants movement, the lake wants none: to be left flat, to be looked into, to keep what is under it exactly as it was, and to be a little bigger every year. Claimants are the water's own names for itself.", "_model": "Wild kernel in still water. Where the river wants movement, the lake wants none: to be left flat, to be looked into, to keep what is under it exactly as it was, and to be a little bigger every year. Claimants are the water's own names for itself.",
"facetProgression": { "facetProgression": {
@@ -337,8 +337,9 @@
"note": "free Called Shot to freeze on a hit" "note": "free Called Shot to freeze on a hit"
} }
], ],
"harmedBy": "anything, but the Troll only stays down if fire touches it, and everything here is wet — say so before initiative", "harmedBy": "anything, but the Troll only stays down if fire touches it, and everything here is wet",
"avoid": "it will not go into deep water; the boat is safe from it and the shore is not" "avoid": "it will not go into deep water; the boat is safe from it and the shore is not",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith standing on the water, 1 Troll and 4 Ghouls", "line": "1 Wraith standing on the water, 1 Troll and 4 Ghouls",
@@ -372,7 +373,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll and it is wet", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll and it is wet",
"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."
} }
}, },
"motifs": { "motifs": {
+3 -2
View File
@@ -2,7 +2,7 @@
"id": "market", "id": "market",
"label": "Market", "label": "Market",
"forgeStageType": "village", "forgeStageType": "village",
"schema": 1, "schema": 2,
"placeNameStyle": "own", "placeNameStyle": "own",
"_model": "Settlement kernel, but the place is a transaction rather than a community. The appetite belongs to the market itself; the claimants are its offices; and everything it did to people it did by contract, which is why none of it can be undone by force.", "_model": "Settlement kernel, but the place is a transaction rather than a community. The appetite belongs to the market itself; the claimants are its offices; and everything it did to people it did by contract, which is why none of it can be undone by force.",
"facetProgression": { "facetProgression": {
@@ -397,7 +397,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
+6 -4
View File
@@ -2,7 +2,7 @@
"id": "mountain", "id": "mountain",
"label": "Mountain", "label": "Mountain",
"forgeStageType": "cave", "forgeStageType": "cave",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Wild kernel at altitude. The appetite belongs to the hill — it wants to be gone up, to be cold, to be the only way through, and to have everything come to rest at its foot. Claimants are the mountain's own names, and its cruelties are all things weather does anyway.", "_model": "Wild kernel at altitude. The appetite belongs to the hill — it wants to be gone up, to be cold, to be the only way through, and to have everything come to rest at its foot. Claimants are the mountain's own names, and its cruelties are all things weather does anyway.",
"facetProgression": { "facetProgression": {
@@ -338,8 +338,9 @@
"note": "hold the top of the gully" "note": "hold the top of the gully"
} }
], ],
"harmedBy": "anything, but the Troll only stays down if fire touches it — say so before initiative, and there is no fuel above the snowline", "harmedBy": "anything, but the Troll only stays down if fire touches it, and there is no fuel above the snowline",
"avoid": "it will not leave the gully; the buttress is longer and it is climbable" "avoid": "it will not leave the gully; the buttress is longer and it is climbable",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith above the snowline, 1 Troll and 3 Dire Wolves", "line": "1 Wraith above the snowline, 1 Troll and 3 Dire Wolves",
@@ -373,7 +374,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll",
"avoid": "do not run this without an appeasement route open and shelter within a Turn" "avoid": "do not run this without an appeasement route open and shelter within a Turn",
"beforeInitiative": "Say so before initiative."
} }
}, },
"motifs": { "motifs": {
+6 -4
View File
@@ -2,7 +2,7 @@
"id": "palace", "id": "palace",
"label": "Palace", "label": "Palace",
"forgeStageType": "barrow", "forgeStageType": "barrow",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Interior kernel at its most VANITY-shaped. A palace wants to be attended, delighted, admired or centred on, and its accommodations are what a court does to people who are useful to that.", "_model": "Interior kernel at its most VANITY-shaped. A palace wants to be attended, delighted, admired or centred on, and its accommodations are what a court does to people who are useful to that.",
"facetProgression": { "facetProgression": {
@@ -347,8 +347,9 @@
"note": "fearless" "note": "fearless"
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons — say so before initiative", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons",
"avoid": "it would far rather be flattered than fight, and will pause to be" "avoid": "it would far rather be flattered than fight, and will pause to be",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith, 1 Ogre in the under-stair and 4 Skeletons", "line": "1 Wraith, 1 Ogre in the under-stair and 4 Skeletons",
@@ -382,7 +383,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
+6 -4
View File
@@ -2,7 +2,7 @@
"id": "port", "id": "port",
"label": "Port", "label": "Port",
"forgeStageType": "village", "forgeStageType": "village",
"schema": 1, "schema": 2,
"placeNameStyle": "own", "placeNameStyle": "own",
"_model": "Settlement kernel on the water. The appetite belongs to the harbour, which is a place people are always about to leave — so every cruelty here is a way of stopping them, and every prize is something that makes leaving possible again.", "_model": "Settlement kernel on the water. The appetite belongs to the harbour, which is a place people are always about to leave — so every cruelty here is a way of stopping them, and every prize is something that makes leaving possible again.",
"facetProgression": { "facetProgression": {
@@ -360,8 +360,9 @@
"note": "fearless; still working the ship they died on" "note": "fearless; still working the ship they died on"
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons — say so before initiative", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons",
"avoid": "it wants the crew accounted for; name one of the drowned and it stops to enter them" "avoid": "it wants the crew accounted for; name one of the drowned and it stops to enter them",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith, 1 Ogre out of the mud berth and 5 Skeletons", "line": "1 Wraith, 1 Ogre out of the mud berth and 5 Skeletons",
@@ -395,7 +396,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
+6 -4
View File
@@ -2,7 +2,7 @@
"id": "river", "id": "river",
"label": "River", "label": "River",
"forgeStageType": "fen", "forgeStageType": "fen",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Wild kernel in moving water. The appetite belongs to the river — it wants to be asked before it is crossed, to carry things away, to run where it has always run, and to be given somebody rather than have to take them. Claimants are the river's own names.", "_model": "Wild kernel in moving water. The appetite belongs to the river — it wants to be asked before it is crossed, to carry things away, to run where it has always run, and to be given somebody rather than have to take them. Claimants are the river's own names.",
"facetProgression": { "facetProgression": {
@@ -337,8 +337,9 @@
"note": "free Called Shot to freeze on a hit" "note": "free Called Shot to freeze on a hit"
} }
], ],
"harmedBy": "anything, but the Troll only stays down if fire touches it, and everything here is wet — say so before initiative", "harmedBy": "anything, but the Troll only stays down if fire touches it, and everything here is wet",
"avoid": "it holds the bridge and takes a toll; pay it and it lets you over" "avoid": "it holds the bridge and takes a toll; pay it and it lets you over",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith at the ford, 1 Troll and 4 Ghouls", "line": "1 Wraith at the ford, 1 Troll and 4 Ghouls",
@@ -372,7 +373,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll and it is wet", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll and it is wet",
"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."
} }
}, },
"motifs": { "motifs": {
+3 -2
View File
@@ -2,7 +2,7 @@
"id": "temple", "id": "temple",
"label": "Temple", "label": "Temple",
"forgeStageType": "barrow", "forgeStageType": "barrow",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Interior kernel, older and stranger than a church. A temple wants the rite performed exactly, to be fed, to keep its name unsaid, or to be the only one. Its accommodations are what a priesthood does when a god is literal.", "_model": "Interior kernel, older and stranger than a church. A temple wants the rite performed exactly, to be fed, to keep its name unsaid, or to be the only one. Its accommodations are what a priesthood does when a god is literal.",
"facetProgression": { "facetProgression": {
@@ -382,7 +382,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest",
"avoid": "it does not strike anyone who has said nothing — say so before initiative" "avoid": "it does not strike anyone who has said nothing",
"beforeInitiative": "Say so before initiative."
} }
}, },
"motifs": { "motifs": {
+3 -2
View File
@@ -2,7 +2,7 @@
"id": "town", "id": "town",
"label": "Town", "label": "Town",
"forgeStageType": "village", "forgeStageType": "village",
"schema": 1, "schema": 2,
"_model": "The settlement kernel: the APPETITE belongs to the place, not to a buried person. A town wants to be prosperous, respectable, safe or necessary, and its ACCOMMODATION is what a corporation does to keep it that way — price people out, charter them away, wall them off. Claimants are offices, not corpses.", "_model": "The settlement kernel: the APPETITE belongs to the place, not to a buried person. A town wants to be prosperous, respectable, safe or necessary, and its ACCOMMODATION is what a corporation does to keep it that way — price people out, charter them away, wall them off. Claimants are offices, not corpses.",
"facetProgression": { "facetProgression": {
"approach": "institution", "approach": "institution",
@@ -397,7 +397,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
+6 -4
View File
@@ -2,7 +2,7 @@
"id": "valley", "id": "valley",
"label": "Valley", "label": "Valley",
"forgeStageType": "forest", "forgeStageType": "forest",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Wild kernel that keeps you by being kind. The appetite belongs to the valley — it wants nothing to go over the top, everything to come in out of the wind, everything to end up at the bottom, and its own sky. Every cruelty here looks like hospitality, which is what makes it hard to leave.", "_model": "Wild kernel that keeps you by being kind. The appetite belongs to the valley — it wants nothing to go over the top, everything to come in out of the wind, everything to end up at the bottom, and its own sky. Every cruelty here looks like hospitality, which is what makes it hard to leave.",
"facetProgression": { "facetProgression": {
@@ -337,8 +337,9 @@
"note": "hold the ground below it" "note": "hold the ground below it"
} }
], ],
"harmedBy": "anything, but the Troll only stays down if fire touches it — say so before initiative; there is wood in the hanging wood", "harmedBy": "anything, but the Troll only stays down if fire touches it; there is wood in the hanging wood",
"avoid": "it holds the head wall and nothing else; the col is a longer climb and it is clear" "avoid": "it holds the head wall and nothing else; the col is a longer climb and it is clear",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith on the col, 1 Troll, 1 Ogre and 2 Dire Wolves", "line": "1 Wraith on the col, 1 Troll, 1 Ogre and 2 Dire Wolves",
@@ -381,7 +382,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll",
"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."
} }
}, },
"motifs": { "motifs": {
+6 -4
View File
@@ -2,7 +2,7 @@
"id": "village", "id": "village",
"label": "Village", "label": "Village",
"forgeStageType": "village", "forgeStageType": "village",
"schema": 1, "schema": 2,
"placeNameStyle": "own", "placeNameStyle": "own",
"_model": "The settlement kernel at its smallest. The appetite belongs to the place; the claimant is an office, not a person; and what a village does to people who might leave is quieter and worse than what a town does.", "_model": "The settlement kernel at its smallest. The appetite belongs to the place; the claimant is an office, not a person; and what a village does to people who might leave is quieter and worse than what a town does.",
"facetProgression": { "facetProgression": {
@@ -362,8 +362,9 @@
"note": "fearless" "note": "fearless"
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons — say so before initiative", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons",
"avoid": "it wants the count right; give it a name off the register and it stops to check" "avoid": "it wants the count right; give it a name off the register and it stops to check",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith, 1 Ogre off the common and 5 Skeletons", "line": "1 Wraith, 1 Ogre off the common and 5 Skeletons",
@@ -397,7 +398,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
+4
View File
@@ -14,6 +14,10 @@ import { baneBeatFor } from './director.mjs';
* it. Bump it when the shape of the emitted object changes or when content selection changes * 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. * 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. * 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 const GENERATOR_VERSION = '0.2.0';
+8 -2
View File
@@ -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 -3
View File
@@ -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,9 +105,9 @@ export function renderMarkdown(d) {
L.push(''); L.push('');
if (R) { if (R) {
// No appended "say so before initiative" — the packs that need it already say it, and three const warn = initiativeWarning(R);
// battle rosters carry the phrase verbatim, so the addendum printed it twice. L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}.`
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}. *${R.avoid}.*`); + `${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('|---|---|---|---|---|---|');
+35
View File
@@ -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;
}
+12
View File
@@ -244,5 +244,17 @@ import { renderWorksheet, renderPlay } from './render-authoring.mjs';
wrong.length ? `wrong: ${wrong.join(', ')}` : `${ids.length} packs`); 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);
+13
View File
@@ -8,6 +8,7 @@
* Run: node validate-pack.mjs [name ...] (default: every pack in content/) * Run: node validate-pack.mjs [name ...] (default: every pack in content/)
*/ */
import { readFileSync, readdirSync } from 'fs'; import { readFileSync, readdirSync } from 'fs';
import { RESTRICTS } from './roster.mjs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
@@ -102,6 +103,18 @@ export function validatePack(pack, name = pack?.id ?? '?') {
if (!r.line || !r.foes?.length) E(`rosters.${h}: needs a line and foes`); 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.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`); 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 f of r.foes ?? [])
for (const k of ['n', 'name', 'atk', 'def', 'grit', 'nerve']) for (const k of ['n', 'name', 'atk', 'def', 'grit', 'nerve'])
if (f[k] === undefined) E(`rosters.${h}: foe ${f.name ?? '?'} missing ${k}`); if (f[k] === undefined) E(`rosters.${h}: foe ${f.name ?? '?'} missing ${k}`);
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.6.4", "version": "0.6.5",
"compatibility": { "compatibility": {
"minimum": "13", "minimum": "13",
"verified": "14.365" "verified": "14.365"
@@ -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": [
{ {
@@ -2110,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",
@@ -2144,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": {
@@ -2,7 +2,7 @@
"id": "castle", "id": "castle",
"label": "Castle", "label": "Castle",
"forgeStageType": "barrow", "forgeStageType": "barrow",
"schema": 1, "schema": 2,
"_model": "Same kernel as barrow: an APPETITE and the ACCOMMODATION made for it. A castle's appetites are about holding — ground, fear, obedience, a name — and its accommodations are what a house does to keep them.", "_model": "Same kernel as barrow: an APPETITE and the ACCOMMODATION made for it. A castle's appetites are about holding — ground, fear, obedience, a name — and its accommodations are what a house does to keep them.",
"facetProgression": { "facetProgression": {
"approach": "institution", "approach": "institution",
@@ -381,7 +381,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
+6 -4
View File
@@ -2,7 +2,7 @@
"id": "cave", "id": "cave",
"label": "Cave", "label": "Cave",
"forgeStageType": "cave", "forgeStageType": "cave",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Wild kernel underground. The appetite belongs to the hole itself — it wants to be gone into, gone down, spoken to, and to keep whatever comes. Claimants are the cave's own names for itself, and the accommodation is what it did to whoever came in.", "_model": "Wild kernel underground. The appetite belongs to the hole itself — it wants to be gone into, gone down, spoken to, and to keep whatever comes. Claimants are the cave's own names for itself, and the accommodation is what it did to whoever came in.",
"facetProgression": { "facetProgression": {
@@ -346,8 +346,9 @@
"note": "douses open flames on a hit" "note": "douses open flames on a hit"
} }
], ],
"harmedBy": "anything, but the Troll only stays down if fire touches it — say so before initiative, and note the swarm is putting your fires out", "harmedBy": "anything, but the Troll only stays down if fire touches it; the swarm douses open flames",
"avoid": "it will not leave the water; stay off the streamway and it stays in" "avoid": "it will not leave the water; stay off the streamway and it stays in",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Troll, 1 Ogre and 3 Ghouls of the Kept", "line": "1 Troll, 1 Ogre and 3 Ghouls of the Kept",
@@ -381,7 +382,8 @@
} }
], ],
"harmedBy": "anything; fire for the Troll", "harmedBy": "anything; fire for the Troll",
"avoid": "do not run this without an appeasement route open and a way out at your back" "avoid": "do not run this without an appeasement route open and a way out at your back",
"beforeInitiative": "Say so before initiative."
} }
}, },
"motifs": { "motifs": {
@@ -2,7 +2,7 @@
"id": "church", "id": "church",
"label": "Church", "label": "Church",
"forgeStageType": "barrow", "forgeStageType": "barrow",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Interior kernel. A parish church wants to be believed, attended, given to, or kept holy, and its accommodations are what a cure of souls does to keep a flock.", "_model": "Interior kernel. A parish church wants to be believed, attended, given to, or kept holy, and its accommodations are what a cure of souls does to keep a flock.",
"facetProgression": { "facetProgression": {
@@ -382,7 +382,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest",
"avoid": "it will not strike anyone kneeling — say so before initiative" "avoid": "it will not strike anyone kneeling",
"beforeInitiative": "Say so before initiative."
} }
}, },
"motifs": { "motifs": {
+3 -2
View File
@@ -2,7 +2,7 @@
"id": "city", "id": "city",
"label": "City", "label": "City",
"forgeStageType": "village", "forgeStageType": "village",
"schema": 1, "schema": 2,
"placeNameStyle": "own", "placeNameStyle": "own",
"_model": "Settlement kernel at its largest. Nobody here knows anybody, so the city knows instead — by ward, by roll, by works, by freedom granted and withheld. Its cruelties are all administrative, which is why none of them can be argued with.", "_model": "Settlement kernel at its largest. Nobody here knows anybody, so the city knows instead — by ward, by roll, by works, by freedom granted and withheld. Its cruelties are all administrative, which is why none of them can be argued with.",
"facetProgression": { "facetProgression": {
@@ -396,7 +396,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
@@ -2,7 +2,7 @@
"id": "forest", "id": "forest",
"label": "Forest", "label": "Forest",
"forgeStageType": "forest", "forgeStageType": "forest",
"schema": 1, "schema": 2,
"_model": "The wild kernel: the APPETITE belongs to the place itself. A wood wants to be entered, fed, spread, or left uncounted, and the ACCOMMODATION is what it did to whoever was here — took them, grew through them, let them wander. Claimants are the wood's own names for itself.", "_model": "The wild kernel: the APPETITE belongs to the place itself. A wood wants to be entered, fed, spread, or left uncounted, and the ACCOMMODATION is what it did to whoever was here — took them, grew through them, let them wander. Claimants are the wood's own names for itself.",
"facetProgression": { "facetProgression": {
"approach": "institution", "approach": "institution",
@@ -349,7 +349,8 @@
} }
], ],
"harmedBy": "anything, but the Troll only stays down if fire closes the wound", "harmedBy": "anything, but the Troll only stays down if fire closes the wound",
"avoid": "it is territorial and will not follow past the treeline" "avoid": "it is territorial and will not follow past the treeline",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "the Hunt: 1 Wraith mounted, 1 Troll and 4 Dire Wolves", "line": "the Hunt: 1 Wraith mounted, 1 Troll and 4 Dire Wolves",
@@ -383,7 +384,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; fire for the Troll; anything for the wolves", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; fire for the Troll; anything for the wolves",
"avoid": "the Hunt passes over anyone standing perfectly still — say so before initiative" "avoid": "the Hunt passes over anyone standing perfectly still",
"beforeInitiative": "Say so before initiative."
} }
}, },
"motifs": { "motifs": {
+6 -4
View File
@@ -2,7 +2,7 @@
"id": "lake", "id": "lake",
"label": "Lake", "label": "Lake",
"forgeStageType": "fen", "forgeStageType": "fen",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Wild kernel in still water. Where the river wants movement, the lake wants none: to be left flat, to be looked into, to keep what is under it exactly as it was, and to be a little bigger every year. Claimants are the water's own names for itself.", "_model": "Wild kernel in still water. Where the river wants movement, the lake wants none: to be left flat, to be looked into, to keep what is under it exactly as it was, and to be a little bigger every year. Claimants are the water's own names for itself.",
"facetProgression": { "facetProgression": {
@@ -337,8 +337,9 @@
"note": "free Called Shot to freeze on a hit" "note": "free Called Shot to freeze on a hit"
} }
], ],
"harmedBy": "anything, but the Troll only stays down if fire touches it, and everything here is wet — say so before initiative", "harmedBy": "anything, but the Troll only stays down if fire touches it, and everything here is wet",
"avoid": "it will not go into deep water; the boat is safe from it and the shore is not" "avoid": "it will not go into deep water; the boat is safe from it and the shore is not",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith standing on the water, 1 Troll and 4 Ghouls", "line": "1 Wraith standing on the water, 1 Troll and 4 Ghouls",
@@ -372,7 +373,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll and it is wet", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll and it is wet",
"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."
} }
}, },
"motifs": { "motifs": {
@@ -2,7 +2,7 @@
"id": "market", "id": "market",
"label": "Market", "label": "Market",
"forgeStageType": "village", "forgeStageType": "village",
"schema": 1, "schema": 2,
"placeNameStyle": "own", "placeNameStyle": "own",
"_model": "Settlement kernel, but the place is a transaction rather than a community. The appetite belongs to the market itself; the claimants are its offices; and everything it did to people it did by contract, which is why none of it can be undone by force.", "_model": "Settlement kernel, but the place is a transaction rather than a community. The appetite belongs to the market itself; the claimants are its offices; and everything it did to people it did by contract, which is why none of it can be undone by force.",
"facetProgression": { "facetProgression": {
@@ -397,7 +397,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
@@ -2,7 +2,7 @@
"id": "mountain", "id": "mountain",
"label": "Mountain", "label": "Mountain",
"forgeStageType": "cave", "forgeStageType": "cave",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Wild kernel at altitude. The appetite belongs to the hill — it wants to be gone up, to be cold, to be the only way through, and to have everything come to rest at its foot. Claimants are the mountain's own names, and its cruelties are all things weather does anyway.", "_model": "Wild kernel at altitude. The appetite belongs to the hill — it wants to be gone up, to be cold, to be the only way through, and to have everything come to rest at its foot. Claimants are the mountain's own names, and its cruelties are all things weather does anyway.",
"facetProgression": { "facetProgression": {
@@ -338,8 +338,9 @@
"note": "hold the top of the gully" "note": "hold the top of the gully"
} }
], ],
"harmedBy": "anything, but the Troll only stays down if fire touches it — say so before initiative, and there is no fuel above the snowline", "harmedBy": "anything, but the Troll only stays down if fire touches it, and there is no fuel above the snowline",
"avoid": "it will not leave the gully; the buttress is longer and it is climbable" "avoid": "it will not leave the gully; the buttress is longer and it is climbable",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith above the snowline, 1 Troll and 3 Dire Wolves", "line": "1 Wraith above the snowline, 1 Troll and 3 Dire Wolves",
@@ -373,7 +374,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll",
"avoid": "do not run this without an appeasement route open and shelter within a Turn" "avoid": "do not run this without an appeasement route open and shelter within a Turn",
"beforeInitiative": "Say so before initiative."
} }
}, },
"motifs": { "motifs": {
@@ -2,7 +2,7 @@
"id": "palace", "id": "palace",
"label": "Palace", "label": "Palace",
"forgeStageType": "barrow", "forgeStageType": "barrow",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Interior kernel at its most VANITY-shaped. A palace wants to be attended, delighted, admired or centred on, and its accommodations are what a court does to people who are useful to that.", "_model": "Interior kernel at its most VANITY-shaped. A palace wants to be attended, delighted, admired or centred on, and its accommodations are what a court does to people who are useful to that.",
"facetProgression": { "facetProgression": {
@@ -347,8 +347,9 @@
"note": "fearless" "note": "fearless"
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons — say so before initiative", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons",
"avoid": "it would far rather be flattered than fight, and will pause to be" "avoid": "it would far rather be flattered than fight, and will pause to be",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith, 1 Ogre in the under-stair and 4 Skeletons", "line": "1 Wraith, 1 Ogre in the under-stair and 4 Skeletons",
@@ -382,7 +383,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
+6 -4
View File
@@ -2,7 +2,7 @@
"id": "port", "id": "port",
"label": "Port", "label": "Port",
"forgeStageType": "village", "forgeStageType": "village",
"schema": 1, "schema": 2,
"placeNameStyle": "own", "placeNameStyle": "own",
"_model": "Settlement kernel on the water. The appetite belongs to the harbour, which is a place people are always about to leave — so every cruelty here is a way of stopping them, and every prize is something that makes leaving possible again.", "_model": "Settlement kernel on the water. The appetite belongs to the harbour, which is a place people are always about to leave — so every cruelty here is a way of stopping them, and every prize is something that makes leaving possible again.",
"facetProgression": { "facetProgression": {
@@ -360,8 +360,9 @@
"note": "fearless; still working the ship they died on" "note": "fearless; still working the ship they died on"
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons — say so before initiative", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons",
"avoid": "it wants the crew accounted for; name one of the drowned and it stops to enter them" "avoid": "it wants the crew accounted for; name one of the drowned and it stops to enter them",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith, 1 Ogre out of the mud berth and 5 Skeletons", "line": "1 Wraith, 1 Ogre out of the mud berth and 5 Skeletons",
@@ -395,7 +396,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
@@ -2,7 +2,7 @@
"id": "river", "id": "river",
"label": "River", "label": "River",
"forgeStageType": "fen", "forgeStageType": "fen",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Wild kernel in moving water. The appetite belongs to the river — it wants to be asked before it is crossed, to carry things away, to run where it has always run, and to be given somebody rather than have to take them. Claimants are the river's own names.", "_model": "Wild kernel in moving water. The appetite belongs to the river — it wants to be asked before it is crossed, to carry things away, to run where it has always run, and to be given somebody rather than have to take them. Claimants are the river's own names.",
"facetProgression": { "facetProgression": {
@@ -337,8 +337,9 @@
"note": "free Called Shot to freeze on a hit" "note": "free Called Shot to freeze on a hit"
} }
], ],
"harmedBy": "anything, but the Troll only stays down if fire touches it, and everything here is wet — say so before initiative", "harmedBy": "anything, but the Troll only stays down if fire touches it, and everything here is wet",
"avoid": "it holds the bridge and takes a toll; pay it and it lets you over" "avoid": "it holds the bridge and takes a toll; pay it and it lets you over",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith at the ford, 1 Troll and 4 Ghouls", "line": "1 Wraith at the ford, 1 Troll and 4 Ghouls",
@@ -372,7 +373,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll and it is wet", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll and it is wet",
"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."
} }
}, },
"motifs": { "motifs": {
@@ -2,7 +2,7 @@
"id": "temple", "id": "temple",
"label": "Temple", "label": "Temple",
"forgeStageType": "barrow", "forgeStageType": "barrow",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Interior kernel, older and stranger than a church. A temple wants the rite performed exactly, to be fed, to keep its name unsaid, or to be the only one. Its accommodations are what a priesthood does when a god is literal.", "_model": "Interior kernel, older and stranger than a church. A temple wants the rite performed exactly, to be fed, to keep its name unsaid, or to be the only one. Its accommodations are what a priesthood does when a god is literal.",
"facetProgression": { "facetProgression": {
@@ -382,7 +382,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest",
"avoid": "it does not strike anyone who has said nothing — say so before initiative" "avoid": "it does not strike anyone who has said nothing",
"beforeInitiative": "Say so before initiative."
} }
}, },
"motifs": { "motifs": {
+3 -2
View File
@@ -2,7 +2,7 @@
"id": "town", "id": "town",
"label": "Town", "label": "Town",
"forgeStageType": "village", "forgeStageType": "village",
"schema": 1, "schema": 2,
"_model": "The settlement kernel: the APPETITE belongs to the place, not to a buried person. A town wants to be prosperous, respectable, safe or necessary, and its ACCOMMODATION is what a corporation does to keep it that way — price people out, charter them away, wall them off. Claimants are offices, not corpses.", "_model": "The settlement kernel: the APPETITE belongs to the place, not to a buried person. A town wants to be prosperous, respectable, safe or necessary, and its ACCOMMODATION is what a corporation does to keep it that way — price people out, charter them away, wall them off. Claimants are offices, not corpses.",
"facetProgression": { "facetProgression": {
"approach": "institution", "approach": "institution",
@@ -397,7 +397,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
@@ -2,7 +2,7 @@
"id": "valley", "id": "valley",
"label": "Valley", "label": "Valley",
"forgeStageType": "forest", "forgeStageType": "forest",
"schema": 1, "schema": 2,
"placeNameStyle": "claimant", "placeNameStyle": "claimant",
"_model": "Wild kernel that keeps you by being kind. The appetite belongs to the valley — it wants nothing to go over the top, everything to come in out of the wind, everything to end up at the bottom, and its own sky. Every cruelty here looks like hospitality, which is what makes it hard to leave.", "_model": "Wild kernel that keeps you by being kind. The appetite belongs to the valley — it wants nothing to go over the top, everything to come in out of the wind, everything to end up at the bottom, and its own sky. Every cruelty here looks like hospitality, which is what makes it hard to leave.",
"facetProgression": { "facetProgression": {
@@ -337,8 +337,9 @@
"note": "hold the ground below it" "note": "hold the ground below it"
} }
], ],
"harmedBy": "anything, but the Troll only stays down if fire touches it — say so before initiative; there is wood in the hanging wood", "harmedBy": "anything, but the Troll only stays down if fire touches it; there is wood in the hanging wood",
"avoid": "it holds the head wall and nothing else; the col is a longer climb and it is clear" "avoid": "it holds the head wall and nothing else; the col is a longer climb and it is clear",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith on the col, 1 Troll, 1 Ogre and 2 Dire Wolves", "line": "1 Wraith on the col, 1 Troll, 1 Ogre and 2 Dire Wolves",
@@ -381,7 +382,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest, fire for the Troll",
"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."
} }
}, },
"motifs": { "motifs": {
@@ -2,7 +2,7 @@
"id": "village", "id": "village",
"label": "Village", "label": "Village",
"forgeStageType": "village", "forgeStageType": "village",
"schema": 1, "schema": 2,
"placeNameStyle": "own", "placeNameStyle": "own",
"_model": "The settlement kernel at its smallest. The appetite belongs to the place; the claimant is an office, not a person; and what a village does to people who might leave is quieter and worse than what a town does.", "_model": "The settlement kernel at its smallest. The appetite belongs to the place; the claimant is an office, not a person; and what a village does to people who might leave is quieter and worse than what a town does.",
"facetProgression": { "facetProgression": {
@@ -362,8 +362,9 @@
"note": "fearless" "note": "fearless"
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons — say so before initiative", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons",
"avoid": "it wants the count right; give it a name off the register and it stops to check" "avoid": "it wants the count right; give it a name off the register and it stops to check",
"beforeInitiative": "Say so before initiative."
}, },
"nightmare": { "nightmare": {
"line": "1 Wraith, 1 Ogre off the common and 5 Skeletons", "line": "1 Wraith, 1 Ogre off the common and 5 Skeletons",
@@ -397,7 +398,8 @@
} }
], ],
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the rest", "harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; 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."
} }
}, },
"motifs": { "motifs": {
+4
View File
@@ -14,6 +14,10 @@ import { baneBeatFor } from './director.mjs';
* it. Bump it when the shape of the emitted object changes or when content selection changes * 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. * 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. * 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 const GENERATOR_VERSION = '0.2.0';
@@ -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 -3
View File
@@ -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,9 +105,9 @@ export function renderMarkdown(d) {
L.push(''); L.push('');
if (R) { if (R) {
// No appended "say so before initiative" — the packs that need it already say it, and three const warn = initiativeWarning(R);
// battle rosters carry the phrase verbatim, so the addendum printed it twice. L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}.`
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}. *${R.avoid}.*`); + `${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('|---|---|---|---|---|---|');
+35
View File
@@ -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;
}
+4 -3
View File
@@ -13,6 +13,7 @@
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 { 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);
@@ -158,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>`,
}); });
@@ -172,7 +173,7 @@ export async function raiseDungeon(params = {}) {
* The roster's tactical notes name particular monsters, so they print only where the plan is * The roster's tactical notes name particular monsters, so they print only where the plan is
* itself the encounter. * itself the encounter.
*/ */
function foeSection(c) { 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>`; 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 ` 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> <p><b>${esc(cap(c.heat))} — in the world.</b> These are the actors the Forge created; run the fight off these.</p>
@@ -181,7 +182,7 @@ function foeSection(c) {
if (c.kind === 'planned') return ` 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> <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(''))} ${table(c.roster.foes.map(f => `<tr><td>${f.n}× ${esc(f.name)}</td><td>${f.atk}</td><td>${f.def}</td><td>${f.grit}</td><td>${f.nerve}</td><td><i>${esc(f.note)}</i></td></tr>`).join(''))}
<p>Harmed by ${esc(c.roster.harmedBy)}. <i>${esc(c.roster.avoid)}.</i></p>`; <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 ` 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>`; <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 ''; return '';
+124
View File
@@ -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 };
}
+35 -55
View File
@@ -17,7 +17,7 @@
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, setThemes } from './forge-app.mjs'; import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes } from './forge-app.mjs';
import { foeStats, foeLine, classifyFoes } from './foes.mjs'; import { stageArea, singleFlight } from './stage.mjs';
const MOD = 'vanity-delve'; const MOD = 'vanity-delve';
const FLAG = 'state'; const FLAG = 'state';
@@ -26,6 +26,9 @@ let PACK = null;
let seamsPresent = false; let seamsPresent = false;
let loadPack = async () => null; 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);
@@ -43,9 +46,10 @@ const setState = async s => game.settings.set(MOD, FLAG, s);
*/ */
async function packById(id) { async function packById(id) {
if (!id) return null; if (!id) return null;
if (PACK?.id === id && PACK.forgeStageType) return PACK; // validate the cache too const usable = p => GEOMETRIES.includes(p?.forgeStageType);
if (PACK?.id === id && usable(PACK)) return PACK; // validate the cache too
const p = await loadPack(id); const p = await loadPack(id);
if (!p?.forgeStageType) return null; if (!usable(p)) return null;
PACK = p; PACK = p;
return p; return p;
} }
@@ -61,9 +65,6 @@ async function packFor(d) {
if (!p) ui.notifications.error(`DELVE: could not load the ${id} theme — refusing to stage, it would use the wrong geometry.`); if (!p) ui.notifications.error(`DELVE: could not load the ${id} theme — refusing to stage, it would use the wrong geometry.`);
return p; return p;
} }
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
const list = items => `<ul>${items.filter(Boolean).map(i => `<li>${i}</li>`).join('')}</ul>`;
/** 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' },
@@ -118,7 +119,12 @@ async function draft(params = {}) {
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;
@@ -131,55 +137,27 @@ async function enter() {
if (!pack) return; if (!pack) return;
ui.notifications.info(`DELVE: raising ${name}…`); ui.notifications.info(`DELVE: raising ${name}…`);
// A Forge failure must not leave the area half-raised. If the scene itself fails there is // The decisions live in stage.mjs so their failure paths can be tested; this is only the wiring.
// nothing to run, so stop before the turn advances and let the GM try again. If the population
// fails the scene is up and the delve is still playable — the planned roster stands in.
const quiet = seamsPresent ? { post: false, folderId: st.folderId } : {}; const 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({
}).catch(e => { console.error('DELVE | stage failed', e); return null; }); type: pack.forgeStageType, size: 'medium', name: n, populate: false, activate: true, ...quiet,
if (!stage) return ui.notifications.error(`DELVE: the Forge could not raise ${name}. Nothing staged; try again.`); }),
encounter: heat => game.vanity.forge.encounter({
const enc = area.encounter ? await game.vanity.forge.encounter({ heat, forStage: name,
heat: area.encounter.heat, forStage: name,
...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}), ...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}),
}).catch(e => { }),
console.error('DELVE | encounter failed', e); hoard: size => game.vanity.forge.hoard({ size, ...(seamsPresent ? { post: false } : {}) }),
ui.notifications.warn(`DELVE: could not populate ${name} — the planned roster stands in.`); // An incremented copy, never a mutation of st: a failed write must leave the in-memory state
return null; // exactly as it was, or the retry advice in the warning is wrong.
}) : null; commit: () => setState({ ...st, at: st.at + 1, turn: st.turn + 1 }),
if (area.hoard) await game.vanity.forge.hoard({ size: area.hoard, ...(seamsPresent ? { post: false } : {}) }) readAloud: readAloudCard,
.catch(e => { console.error('DELVE | hoard failed', e); return null; }); gm: gmCard,
warn: m => ui.notifications.warn(m),
// Players first — the scene is up and this is what they came for. error: m => ui.notifications.error(m),
await readAloudCard(name, w.readAloud ?? `<i>(unwritten)</i> ${area.cueFragments.join('. ')}.`); log: e => console.error('DELVE | staging', e),
};
// Then the GM, quietly. return stageArea(fx, { area, name, authored: w });
const rv = area.decision?.resolve ?? {};
const c = classifyFoes(area, (enc?.actors ?? []).map(foeStats));
const foeBlock =
c.kind === 'forged'
? `<p><b>${cap(c.heat)} — in the world:</b></p>${list(c.foes.map(foeLine))}${
c.planned ? `<p><i>DELVE planned ${c.planned.line}; the Forge rolled its own, so the plan's tactics do not describe these.</i></p>` : ''}`
: c.kind === 'planned'
? `<p><b>${cap(c.heat)} — not cast.</b> Nothing was forged; run the plan by hand:</p>${
list(c.roster.foes.map(f => `${f.n}× <b>${f.name}</b> — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. <i>${f.note}</i>`))
}<p><b>Harmed by ${c.roster.harmedBy}.</b> ${c.roster.avoid}.</p>`
: c.kind === 'unavailable'
? `<p><b>${cap(c.heat)} — nothing to run.</b> No actors, and no roster at this heat. Improvise or skip; the decision and fallback still stand.</p>`
: '';
await gmCard(`⛏ ${area.index} · ${name}`, `${area.role} · ${area.facet}`,
`${area.situation ? `<p><b>Here:</b> ${cap(area.situation.occupant)}, ${area.situation.doing} — ${area.situation.onArrival}.<br>
<b>They can:</b> ${area.situation.offer}. <i>${cap(area.situation.because)}.</i></p>` : ''}
<p><b>${cap(area.decision.cue)}</b>${rv.roll ? ` — [${rv.roll}] ${rv.success}` : ''}${rv.failure ? `<br><b>Miss:</b> ${rv.failure}` : ''}${rv.orElse ? `<br><b>Or:</b> ${rv.orElse}` : ''}</p>
${foeBlock}
${area.temptation ? `<p><b>${cap(area.temptation.id)}:</b> ${area.temptation.benefit}. <i>Use: ${area.temptation.useCost?.bane ? `+${area.temptation.useCost.bane} Bane` : '—'}. ${area.temptation.standingDrawback}.</i></p>` : ''}
${w.notes ? `<p><b>Your note:</b> ${w.notes}</p>` : ''}
<p><code>${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}</code></p>`);
st.at += 1; st.turn += 1;
await setState(st);
return { stage, area };
} }
async function ending() { async function ending() {
@@ -232,7 +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: [] }),
loadPack: id => loadPack(id), // 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.');
+201 -30
View File
@@ -1,14 +1,15 @@
/** /**
* Adapter tests. No Foundry, no shim — foes.mjs is pure on purpose. * Adapter tests. No Foundry, no shim — foes.mjs and stage.mjs are pure on purpose.
* *
* These exist because the same decision broke twice in two releases. 0.6.2 showed the forged * These exist because the same area of code broke in three consecutive releases, every time on a
* actors and silently dropped the planned roster when nothing had been forged; 0.6.3 fixed that * path that only runs when something misbehaves, and every time I convinced myself by reading it.
* but the choice still lived in two hand-written ternaries, one per surface. Every case below is * Each case below is a bug that shipped or nearly shipped.
* a bug that shipped or nearly shipped.
* *
* Run: node foundry-module/test.mjs * Run: node foundry-module/test.mjs
*/ */
import { foeStats, foeLine, classifyFoes } from './module/foes.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; 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 : ''}`); };
@@ -20,42 +21,212 @@ const actor = {
const roster = { const roster = {
line: '1 Ghoul and 2 Skeletons', line: '1 Ghoul and 2 Skeletons',
foes: [{ n: 1, name: 'Ghoul', atk: 4, def: 3, grit: 4, nerve: 5, note: 'freezes' }], foes: [{ n: 1, name: 'Ghoul', atk: 4, def: 3, grit: 4, nerve: 5, note: 'freezes' }],
harmedBy: 'blessed, silvered or magical weapons ONLY — say so before initiative', harmedBy: 'blessed, silvered or magical weapons ONLY',
avoid: 'the Ghoul goes for court dress first', beforeInitiative: 'Say so before initiative.',
avoid: 'send someone plain',
}; };
const withEnc = (extra = {}) => ({ encounter: { heat: 'fight', roster, ...extra } }); 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 a forged actor ------------------------------------------------- // ---------------------------------------------------------------- reading actors
const f = foeStats(actor); const f = foeStats(actor);
t('foeStats reads the stats off the document', f.atk === 4 && f.def === 3 && f.grit === 5 && f.nerve === 6); t('foeStats 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'); t('foeStats keeps the uuid so the GM can open it', f.uuid === 'Actor.abc');
const bare = foeStats({ name: 'X', uuid: 'Actor.z', system: {} }); const bare = foeStats({ name: 'X', uuid: 'Actor.z', system: {} });
t('a missing stat degrades rather than throwing', bare.atk === null && foeLine(bare).includes('?/?/?')); t('a missing stat degrades rather than throwing', bare.atk === null && foeLine(bare).includes('?/?/?'));
t('foeLine links the actor', foeLine(f).includes('@UUID[Actor.abc]{Morthollow-born}')); t('foeLine links the actor', foeLine(f).includes('@UUID[Actor.abc]{Morthollow-born}'));
t('foeLine omits an empty trick', !foeLine(bare).includes('<i>'));
// --- the four cases --------------------------------------------------------- // ---------------------------------------------------------------- the four kinds
t('no encounter → none', classifyFoes({}, []).kind === 'none'); t('no encounter → none', classifyFoes(area(), []).kind === 'none');
t('no encounter → none, even with stray actors', classifyFoes({}, [f]).kind === 'none'); t('actors forged → forged', classifyFoes(withEnc(), [f]).kind === 'forged');
t('nothing forged but a roster → planned', classifyFoes(withEnc(), []).kind === 'planned',
const forged = classifyFoes(withEnc(), [f]);
t('actors forged → forged', forged.kind === 'forged' && forged.foes.length === 1);
t('forged keeps the plan, so it can be named as not-these', forged.planned === roster);
const planned = classifyFoes(withEnc(), []);
t('nothing forged but a roster → planned', planned.kind === 'planned' && planned.roster === roster,
'the 0.6.2 regression: population off left no numbers at all'); '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);
const nothing = classifyFoes({ encounter: { heat: 'fight' } }, []); // ---------------------------------------------------------------- what reaches the card
t('heat but neither actors nor roster → unavailable', nothing.kind === 'unavailable', const forgedBlock = foeBlock(classifyFoes(withEnc(), [f]));
'must not render silence for an area labelled with combat'); t('forged block runs off the actors', forgedBlock.includes('Morthollow-born') && forgedBlock.includes('in the world'));
t('unavailable still reports the heat', nothing.heat === 'fight'); t('forged block never asserts the plan\'s tactics', !forgedBlock.includes('blessed, silvered'),
// --- the invariant the two surfaces kept breaking ---------------------------
t('the plan is never the encounter while actors exist', forged.kind !== 'planned',
'roster guidance names monsters the Forge did not create'); 'roster guidance names monsters the Forge did not create');
t('a forged classification carries no roster field to render from', t('forged block still names the plan', forgedBlock.includes('1 Ghoul and 2 Skeletons'));
forged.roster === undefined, 'so a surface cannot accidentally run the plan');
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`); console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0); process.exit(fail ? 1 : 0);