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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d220a3428
commit
0bbf57b693
@@ -2110,7 +2110,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
"line": "1 Wraith, 1 Ghoul and 4 Skeletons",
|
||||
@@ -2156,4 +2157,4 @@
|
||||
},
|
||||
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>.",
|
||||
"placeNameStyle": "claimant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,7 +381,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1171,4 +1172,4 @@
|
||||
}
|
||||
},
|
||||
"placeNameStyle": "claimant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,8 +346,9 @@
|
||||
"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",
|
||||
"avoid": "it will not leave the water; stay off the streamway and it stays in"
|
||||
"harmedBy": "anything, but the Troll only stays down if fire touches it, and note the swarm is putting your fires out",
|
||||
"avoid": "it will not leave the water; stay off the streamway and it stays in",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"line": "1 Troll, 1 Ogre and 3 Ghouls of the Kept",
|
||||
@@ -1170,4 +1171,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1171,4 +1172,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,7 +396,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1185,4 +1186,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +383,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1173,4 +1174,4 @@
|
||||
}
|
||||
},
|
||||
"placeNameStyle": "claimant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,8 +337,9 @@
|
||||
"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",
|
||||
"avoid": "it will not go into deep water; the boat is safe from it and the shore is not"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1161,4 +1163,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +397,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1186,4 +1187,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,8 +338,9 @@
|
||||
"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",
|
||||
"avoid": "it will not leave the gully; the buttress is longer and it is climbable"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1162,4 +1164,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,8 +347,9 @@
|
||||
"note": "fearless"
|
||||
}
|
||||
],
|
||||
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons — say so before initiative",
|
||||
"avoid": "it would far rather be flattered than fight, and will pause to be"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1171,4 +1173,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,8 +360,9 @@
|
||||
"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",
|
||||
"avoid": "it wants the crew accounted for; name one of the drowned and it stops to enter them"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1184,4 +1186,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,8 +337,9 @@
|
||||
"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",
|
||||
"avoid": "it holds the bridge and takes a toll; pay it and it lets you over"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1161,4 +1163,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1171,4 +1172,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +397,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1187,4 +1188,4 @@
|
||||
}
|
||||
},
|
||||
"placeNameStyle": "own"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,8 +337,9 @@
|
||||
"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",
|
||||
"avoid": "it holds the head wall and nothing else; the col is a longer climb and it is clear"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1170,4 +1172,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,8 +362,9 @@
|
||||
"note": "fearless"
|
||||
}
|
||||
],
|
||||
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons — say so before initiative",
|
||||
"avoid": "it wants the count right; give it a name off the register and it stops to check"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1186,4 +1188,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -104,9 +104,8 @@ export function renderMarkdown(d) {
|
||||
L.push('');
|
||||
|
||||
if (R) {
|
||||
// No appended "say so before initiative" — the packs that need it already say it, and three
|
||||
// battle rosters carry the phrase verbatim, so the addendum printed it twice.
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}. *${R.avoid}.*`);
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}.`
|
||||
+ `${R.beforeInitiative ? ` **${R.beforeInitiative}**` : ''} *${R.avoid}.*`);
|
||||
L.push('');
|
||||
L.push('| Foe | atk | def | Grit | Nerve | |');
|
||||
L.push('|---|---|---|---|---|---|');
|
||||
|
||||
@@ -244,5 +244,17 @@ import { renderWorksheet, renderPlay } from './render-authoring.mjs';
|
||||
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`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
|
||||
@@ -102,6 +102,18 @@ export function validatePack(pack, name = pack?.id ?? '?') {
|
||||
if (!r.line || !r.foes?.length) E(`rosters.${h}: needs a line and foes`);
|
||||
if (!r.harmedBy) E(`rosters.${h}: must say what harms them — a party discovering an immunity by failing is how playtest 1 died`);
|
||||
if (!r.avoid) W(`rosters.${h}: no avoidance note`);
|
||||
|
||||
/**
|
||||
* A restriction the party cannot discover safely must be told to them before initiative, and
|
||||
* it must be said exactly once. This lived as a renderer heuristic — synthesise the warning
|
||||
* whenever `harmedBy` contained the word ONLY — which printed twice on the rosters that
|
||||
* already said it in prose, and vanished from twelve rosters when the heuristic was removed.
|
||||
* It is authored data now, and this is the check that keeps it that way.
|
||||
*/
|
||||
if (/say so before initiative/i.test(`${r.harmedBy} ${r.avoid ?? ''}`))
|
||||
E(`rosters.${h}: put the initiative warning in beforeInitiative, not in the prose — it renders twice otherwise`);
|
||||
if (r.harmedBy.includes('ONLY') && !r.beforeInitiative)
|
||||
E(`rosters.${h}: harmedBy restricts what works, so it needs beforeInitiative — the party cannot discover this safely`);
|
||||
for (const f of r.foes ?? [])
|
||||
for (const k of ['n', 'name', 'atk', 'def', 'grit', 'nerve'])
|
||||
if (f[k] === undefined) E(`rosters.${h}: foe ${f.name ?? '?'} missing ${k}`);
|
||||
|
||||
@@ -2110,7 +2110,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
"line": "1 Wraith, 1 Ghoul and 4 Skeletons",
|
||||
@@ -2156,4 +2157,4 @@
|
||||
},
|
||||
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>.",
|
||||
"placeNameStyle": "claimant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,7 +381,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1171,4 +1172,4 @@
|
||||
}
|
||||
},
|
||||
"placeNameStyle": "claimant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,8 +346,9 @@
|
||||
"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",
|
||||
"avoid": "it will not leave the water; stay off the streamway and it stays in"
|
||||
"harmedBy": "anything, but the Troll only stays down if fire touches it, and note the swarm is putting your fires out",
|
||||
"avoid": "it will not leave the water; stay off the streamway and it stays in",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"line": "1 Troll, 1 Ogre and 3 Ghouls of the Kept",
|
||||
@@ -1170,4 +1171,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1171,4 +1172,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,7 +396,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1185,4 +1186,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +383,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1173,4 +1174,4 @@
|
||||
}
|
||||
},
|
||||
"placeNameStyle": "claimant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,8 +337,9 @@
|
||||
"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",
|
||||
"avoid": "it will not go into deep water; the boat is safe from it and the shore is not"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1161,4 +1163,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +397,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1186,4 +1187,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,8 +338,9 @@
|
||||
"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",
|
||||
"avoid": "it will not leave the gully; the buttress is longer and it is climbable"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1162,4 +1164,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,8 +347,9 @@
|
||||
"note": "fearless"
|
||||
}
|
||||
],
|
||||
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons — say so before initiative",
|
||||
"avoid": "it would far rather be flattered than fight, and will pause to be"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1171,4 +1173,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,8 +360,9 @@
|
||||
"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",
|
||||
"avoid": "it wants the crew accounted for; name one of the drowned and it stops to enter them"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1184,4 +1186,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,8 +337,9 @@
|
||||
"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",
|
||||
"avoid": "it holds the bridge and takes a toll; pay it and it lets you over"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1161,4 +1163,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1171,4 +1172,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,7 +397,8 @@
|
||||
}
|
||||
],
|
||||
"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": {
|
||||
@@ -1187,4 +1188,4 @@
|
||||
}
|
||||
},
|
||||
"placeNameStyle": "own"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,8 +337,9 @@
|
||||
"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",
|
||||
"avoid": "it holds the head wall and nothing else; the col is a longer climb and it is clear"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1170,4 +1172,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,8 +362,9 @@
|
||||
"note": "fearless"
|
||||
}
|
||||
],
|
||||
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; anything for the Skeletons — say so before initiative",
|
||||
"avoid": "it wants the count right; give it a name off the register and it stops to check"
|
||||
"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",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"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",
|
||||
"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": {
|
||||
@@ -1186,4 +1188,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,9 +104,8 @@ export function renderMarkdown(d) {
|
||||
L.push('');
|
||||
|
||||
if (R) {
|
||||
// No appended "say so before initiative" — the packs that need it already say it, and three
|
||||
// battle rosters carry the phrase verbatim, so the addendum printed it twice.
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}. *${R.avoid}.*`);
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}.`
|
||||
+ `${R.beforeInitiative ? ` **${R.beforeInitiative}**` : ''} *${R.avoid}.*`);
|
||||
L.push('');
|
||||
L.push('| Foe | atk | def | Grit | Nerve | |');
|
||||
L.push('|---|---|---|---|---|---|');
|
||||
|
||||
@@ -181,7 +181,7 @@ function foeSection(c) {
|
||||
if (c.kind === 'planned') return `
|
||||
<p><b>${esc(cap(c.heat))} — not cast.</b> Nothing was forged for this area, so the plan is the encounter. Cast it by hand:</p>
|
||||
${table(c.roster.foes.map(f => `<tr><td>${f.n}× ${esc(f.name)}</td><td>${f.atk}</td><td>${f.def}</td><td>${f.grit}</td><td>${f.nerve}</td><td><i>${esc(f.note)}</i></td></tr>`).join(''))}
|
||||
<p>Harmed by ${esc(c.roster.harmedBy)}. <i>${esc(c.roster.avoid)}.</i></p>`;
|
||||
<p>Harmed by ${esc(c.roster.harmedBy)}.${c.roster.beforeInitiative ? ` <b>${esc(c.roster.beforeInitiative)}</b>` : ''} <i>${esc(c.roster.avoid)}.</i></p>`;
|
||||
if (c.kind === 'unavailable') return `
|
||||
<p><b>${esc(cap(c.heat))} — nothing to run.</b> No actors were created and this theme has no roster at this heat. Improvise the fight or skip it; the area's decision and fallback still stand.</p>`;
|
||||
return '';
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
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 }; }
|
||||
};
|
||||
|
||||
/** 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>${c.roster.beforeInitiative ? ` <b>${c.roster.beforeInitiative}</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.
|
||||
await fx.commit();
|
||||
|
||||
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, kind: classification.kind };
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
import { coinSeed, Rng } from './core/rng.mjs';
|
||||
import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs';
|
||||
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes } from './forge-app.mjs';
|
||||
import { foeStats, foeLine, classifyFoes } from './foes.mjs';
|
||||
import { stageArea } from './stage.mjs';
|
||||
|
||||
const MOD = 'vanity-delve';
|
||||
const FLAG = 'state';
|
||||
@@ -61,9 +61,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.`);
|
||||
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. */
|
||||
const gmCard = (label, sub, body) => ChatMessage.create({
|
||||
speaker: { alias: 'DELVE' },
|
||||
@@ -131,55 +128,25 @@ async function enter() {
|
||||
if (!pack) return;
|
||||
ui.notifications.info(`DELVE: raising ${name}…`);
|
||||
|
||||
// A Forge failure must not leave the area half-raised. If the scene itself fails there is
|
||||
// nothing to run, so stop before the turn advances and let the GM try again. If the population
|
||||
// fails the scene is up and the delve is still playable — the planned roster stands in.
|
||||
// The decisions live in stage.mjs so their failure paths can be tested; this is only the wiring.
|
||||
const quiet = seamsPresent ? { post: false, folderId: st.folderId } : {};
|
||||
const stage = await game.vanity.forge.stage({
|
||||
type: pack.forgeStageType, size: 'medium', name, populate: false, activate: true, ...quiet,
|
||||
}).catch(e => { console.error('DELVE | stage failed', e); return null; });
|
||||
if (!stage) return ui.notifications.error(`DELVE: the Forge could not raise ${name}. Nothing staged; try again.`);
|
||||
|
||||
const enc = area.encounter ? await game.vanity.forge.encounter({
|
||||
heat: area.encounter.heat, forStage: name,
|
||||
...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}),
|
||||
}).catch(e => {
|
||||
console.error('DELVE | encounter failed', e);
|
||||
ui.notifications.warn(`DELVE: could not populate ${name} — the planned roster stands in.`);
|
||||
return null;
|
||||
}) : null;
|
||||
if (area.hoard) await game.vanity.forge.hoard({ size: area.hoard, ...(seamsPresent ? { post: false } : {}) })
|
||||
.catch(e => { console.error('DELVE | hoard failed', e); return null; });
|
||||
|
||||
// Players first — the scene is up and this is what they came for.
|
||||
await readAloudCard(name, w.readAloud ?? `<i>(unwritten)</i> ${area.cueFragments.join('. ')}.`);
|
||||
|
||||
// Then the GM, quietly.
|
||||
const 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 };
|
||||
const fx = {
|
||||
stage: n => game.vanity.forge.stage({
|
||||
type: pack.forgeStageType, size: 'medium', name: n, populate: false, activate: true, ...quiet,
|
||||
}),
|
||||
encounter: heat => game.vanity.forge.encounter({
|
||||
heat, forStage: name,
|
||||
...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}),
|
||||
}),
|
||||
hoard: size => game.vanity.forge.hoard({ size, ...(seamsPresent ? { post: false } : {}) }),
|
||||
commit: async () => { st.at += 1; st.turn += 1; await setState(st); },
|
||||
readAloud: readAloudCard,
|
||||
gm: gmCard,
|
||||
warn: m => ui.notifications.warn(m),
|
||||
error: m => ui.notifications.error(m),
|
||||
log: e => console.error('DELVE | staging', e),
|
||||
};
|
||||
return stageArea(fx, { area, name, authored: w });
|
||||
}
|
||||
|
||||
async function ending() {
|
||||
@@ -232,7 +199,9 @@ Hooks.once('ready', async () => {
|
||||
load, loadFile, draft, enter, ending, bane, clock, state: getState,
|
||||
outstanding: () => outstanding(getState()?.delve ?? { areas: [] }),
|
||||
ready: () => readyToPlay(getState()?.delve ?? { areas: [] }),
|
||||
loadPack: id => loadPack(id),
|
||||
// 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; } };
|
||||
console.log(`DELVE | ready. Forge seams ${seamsPresent ? 'present' : 'ABSENT'}.`);
|
||||
if (!seamsPresent) ui.notifications.warn('DELVE: Forge seams not installed — see the delve-seams branch.');
|
||||
|
||||
+128
-30
@@ -1,14 +1,14 @@
|
||||
/**
|
||||
* 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
|
||||
* actors and silently dropped the planned roster when nothing had been forged; 0.6.3 fixed that
|
||||
* but the choice still lived in two hand-written ternaries, one per surface. Every case below is
|
||||
* a bug that shipped or nearly shipped.
|
||||
* These exist because the same area of code broke in three consecutive releases, every time on a
|
||||
* path that only runs when something misbehaves, and every time I convinced myself by reading it.
|
||||
* Each case below is a bug that shipped or nearly shipped.
|
||||
*
|
||||
* Run: node foundry-module/test.mjs
|
||||
*/
|
||||
import { foeStats, foeLine, classifyFoes } from './module/foes.mjs';
|
||||
import { stageArea, areaCards, foeBlock } from './module/stage.mjs';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const t = (name, cond, detail = '') => { cond ? pass++ : fail++; console.log(`${cond ? ' ok ' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); };
|
||||
@@ -20,42 +20,140 @@ const actor = {
|
||||
const roster = {
|
||||
line: '1 Ghoul and 2 Skeletons',
|
||||
foes: [{ n: 1, name: 'Ghoul', atk: 4, def: 3, grit: 4, nerve: 5, note: 'freezes' }],
|
||||
harmedBy: 'blessed, silvered or magical weapons ONLY — say so before initiative',
|
||||
avoid: 'the Ghoul goes for court dress first',
|
||||
harmedBy: 'blessed, silvered or magical weapons ONLY',
|
||||
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);
|
||||
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');
|
||||
const bare = foeStats({ name: 'X', uuid: 'Actor.z', system: {} });
|
||||
t('a missing stat degrades rather than throwing', bare.atk === null && foeLine(bare).includes('?/?/?'));
|
||||
t('foeLine links the actor', foeLine(f).includes('@UUID[Actor.abc]{Morthollow-born}'));
|
||||
t('foeLine omits an empty trick', !foeLine(bare).includes('<i>'));
|
||||
|
||||
// --- the four cases ---------------------------------------------------------
|
||||
t('no encounter → none', classifyFoes({}, []).kind === 'none');
|
||||
t('no encounter → none, even with stray actors', classifyFoes({}, [f]).kind === 'none');
|
||||
|
||||
const forged = classifyFoes(withEnc(), [f]);
|
||||
t('actors forged → forged', forged.kind === 'forged' && forged.foes.length === 1);
|
||||
t('forged keeps the plan, so it can be named as not-these', forged.planned === roster);
|
||||
|
||||
const planned = classifyFoes(withEnc(), []);
|
||||
t('nothing forged but a roster → planned', planned.kind === 'planned' && planned.roster === roster,
|
||||
// ---------------------------------------------------------------- the four kinds
|
||||
t('no encounter → none', classifyFoes(area(), []).kind === 'none');
|
||||
t('actors forged → forged', classifyFoes(withEnc(), [f]).kind === 'forged');
|
||||
t('nothing forged but a roster → planned', classifyFoes(withEnc(), []).kind === 'planned',
|
||||
'the 0.6.2 regression: population off left no numbers at all');
|
||||
t('heat but neither → unavailable', classifyFoes(area({ encounter: { heat: 'fight' } }), []).kind === 'unavailable');
|
||||
t('a forged classification exposes no roster to render from', classifyFoes(withEnc(), [f]).roster === undefined);
|
||||
|
||||
const nothing = classifyFoes({ encounter: { heat: 'fight' } }, []);
|
||||
t('heat but neither actors nor roster → unavailable', nothing.kind === 'unavailable',
|
||||
'must not render silence for an area labelled with combat');
|
||||
t('unavailable still reports the heat', nothing.heat === 'fight');
|
||||
|
||||
// --- the invariant the two surfaces kept breaking ---------------------------
|
||||
t('the plan is never the encounter while actors exist', forged.kind !== 'planned',
|
||||
// ---------------------------------------------------------------- what reaches the card
|
||||
const forgedBlock = foeBlock(classifyFoes(withEnc(), [f]));
|
||||
t('forged block runs off the actors', forgedBlock.includes('Morthollow-born') && forgedBlock.includes('in the world'));
|
||||
t('forged block never asserts the plan\'s tactics', !forgedBlock.includes('blessed, silvered'),
|
||||
'roster guidance names monsters the Forge did not create');
|
||||
t('a forged classification carries no roster field to render from',
|
||||
forged.roster === undefined, 'so a surface cannot accidentally run the plan');
|
||||
t('forged block still names the plan', forgedBlock.includes('1 Ghoul and 2 Skeletons'));
|
||||
|
||||
const plannedBlock = foeBlock(classifyFoes(withEnc(), []));
|
||||
t('planned block is runnable', plannedBlock.includes('Ghoul') && plannedBlock.includes('4/3/4'));
|
||||
t('planned block carries the immunity', plannedBlock.includes('blessed, silvered'));
|
||||
t('planned block warns before initiative', plannedBlock.includes('Say so before initiative.'),
|
||||
'the 0.6.4 regression: deleting the ONLY gate stripped this from 12 rosters');
|
||||
t('the warning is said exactly once',
|
||||
(plannedBlock.match(/say so before initiative/gi) ?? []).length === 1);
|
||||
t('unavailable says so rather than rendering nothing',
|
||||
foeBlock(classifyFoes(area({ encounter: { heat: 'fight' } }), [])).includes('nothing to run'));
|
||||
|
||||
const cards = areaCards({ area: withEnc(), name: 'The Sail Loft', authored: {}, classification: classifyFoes(withEnc(), [f]) });
|
||||
t('an unwritten read-aloud is marked as such', cards.readAloud.includes('(unwritten)'));
|
||||
t('written prose outranks the fragments',
|
||||
areaCards({ area: withEnc(), name: 'x', authored: { readAloud: 'They have been waiting.' }, classification: classifyFoes(withEnc(), [f]) })
|
||||
.readAloud === 'They have been waiting.');
|
||||
t('the GM card carries the decision and its miss', cards.gm.body.includes('Poise 2') && cards.gm.body.includes('Miss:'));
|
||||
|
||||
// ---------------------------------------------------------------- the failure matrix
|
||||
const spy = (over = {}) => {
|
||||
const log = { calls: [], warns: [], errors: [], committed: 0, cards: 0 };
|
||||
const fx = {
|
||||
stage: n => { log.calls.push('stage'); return { id: 'scn1', name: n }; },
|
||||
encounter: () => { log.calls.push('encounter'); return { actors: [actor] }; },
|
||||
hoard: () => { log.calls.push('hoard'); return { size: 'cache' }; },
|
||||
commit: async () => { log.committed++; },
|
||||
readAloud: () => { log.cards++; },
|
||||
gm: () => { log.cards++; },
|
||||
warn: m => log.warns.push(m),
|
||||
error: m => log.errors.push(m),
|
||||
...over,
|
||||
};
|
||||
return { fx, log };
|
||||
};
|
||||
const boom = () => { throw new Error('forge exploded'); };
|
||||
const ctx = (a = withEnc()) => ({ area: { ...a, hoard: 'cache' }, name: 'The Sail Loft', authored: {} });
|
||||
|
||||
{
|
||||
const { fx, log } = spy();
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('happy path enters, commits once and posts both cards',
|
||||
r.entered && log.committed === 1 && log.cards === 2 && !log.warns.length && !log.errors.length);
|
||||
t('happy path reports the kind it rendered', r.kind === 'forged');
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ stage: boom });
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('a failed scene enters nothing', !r.entered && r.failed === 'stage');
|
||||
t('a failed scene does not commit — a retry is safe', log.committed === 0,
|
||||
'the v0.6.3 bug: scene left behind, index unchanged, retry duplicated it');
|
||||
t('a failed scene posts no cards and says why', log.cards === 0 && log.errors.length === 1);
|
||||
t('a failed scene never calls the Forge again', !log.calls.includes('encounter'));
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ encounter: boom });
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('a failed population still enters — the scene is up', r.entered && log.committed === 1);
|
||||
t('a failed population falls back to the plan', r.kind === 'planned');
|
||||
t('a failed population says the roster stands in', log.warns.some(w => /planned roster stands in/.test(w)));
|
||||
t('a failed population still posts both cards', log.cards === 2);
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ encounter: boom });
|
||||
const r = await stageArea(fx, ctx(area({ encounter: { heat: 'fight' } })));
|
||||
t('a failed population with no roster does not claim one', !log.warns.some(w => /roster stands in/.test(w)),
|
||||
'the 0.6.4 regression: it promised a roster that did not exist');
|
||||
t('a failed population with no roster says what is actually lost',
|
||||
log.warns.some(w => /no roster at fight/.test(w)) && r.kind === 'unavailable');
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ hoard: boom });
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('a failed hoard still enters', r.entered && log.committed === 1);
|
||||
t('a failed hoard is visible to the GM', log.warns.some(w => /hoard/.test(w)),
|
||||
'the 0.6.4 regression: swallowed to console while the turn advanced');
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ gm: boom });
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('a failed GM card does not roll back the turn', r.entered && log.committed === 1);
|
||||
t('a failed GM card tells the GM to read from the worksheet',
|
||||
log.warns.some(w => /GM card did not post/.test(w)));
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ readAloud: boom, gm: boom });
|
||||
await stageArea(fx, ctx());
|
||||
t('both cards failing reports once, not twice', log.warns.filter(w => /did not post/.test(w)).length === 1);
|
||||
}
|
||||
{
|
||||
// async rejections, not just throws — the Forge returns promises
|
||||
const { fx, log } = spy({ encounter: () => Promise.reject(new Error('async')) });
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('a rejected promise is handled like a throw', r.entered && r.kind === 'planned' && log.warns.length === 1);
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ stage: () => null });
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('a Forge that returns nothing counts as a failed scene', !r.entered && log.committed === 0);
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user