diff --git a/core/content/barrow.json b/core/content/barrow.json index ce36ac7..9a1d821 100644 --- a/core/content/barrow.json +++ b/core/content/barrow.json @@ -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..", "placeNameStyle": "claimant" -} \ No newline at end of file +} diff --git a/core/content/castle.json b/core/content/castle.json index c3281d5..98f1554 100644 --- a/core/content/castle.json +++ b/core/content/castle.json @@ -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" -} \ No newline at end of file +} diff --git a/core/content/cave.json b/core/content/cave.json index a15f9da..08c9507 100644 --- a/core/content/cave.json +++ b/core/content/cave.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/core/content/church.json b/core/content/church.json index 889bd29..b68d193 100644 --- a/core/content/church.json +++ b/core/content/church.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/core/content/city.json b/core/content/city.json index 653caa4..eb9d6ef 100644 --- a/core/content/city.json +++ b/core/content/city.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/core/content/forest.json b/core/content/forest.json index 6c37bbb..fbd2312 100644 --- a/core/content/forest.json +++ b/core/content/forest.json @@ -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" -} \ No newline at end of file +} diff --git a/core/content/lake.json b/core/content/lake.json index a79a915..b15f3f3 100644 --- a/core/content/lake.json +++ b/core/content/lake.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/core/content/market.json b/core/content/market.json index 7fd5eff..5f65513 100644 --- a/core/content/market.json +++ b/core/content/market.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/core/content/mountain.json b/core/content/mountain.json index 452c2e7..6174740 100644 --- a/core/content/mountain.json +++ b/core/content/mountain.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/core/content/palace.json b/core/content/palace.json index 4b703ae..fc396cb 100644 --- a/core/content/palace.json +++ b/core/content/palace.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/core/content/port.json b/core/content/port.json index db46a07..4cc79b2 100644 --- a/core/content/port.json +++ b/core/content/port.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/core/content/river.json b/core/content/river.json index 5a43158..7a08ed3 100644 --- a/core/content/river.json +++ b/core/content/river.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/core/content/temple.json b/core/content/temple.json index 5504fd4..e515a4d 100644 --- a/core/content/temple.json +++ b/core/content/temple.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/core/content/town.json b/core/content/town.json index 01923f9..dd409fe 100644 --- a/core/content/town.json +++ b/core/content/town.json @@ -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" -} \ No newline at end of file +} diff --git a/core/content/valley.json b/core/content/valley.json index f037e1a..275d11b 100644 --- a/core/content/valley.json +++ b/core/content/valley.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/core/content/village.json b/core/content/village.json index 76f1dc7..6e0696b 100644 --- a/core/content/village.json +++ b/core/content/village.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/core/render.mjs b/core/render.mjs index 566848b..a39d6bb 100644 --- a/core/render.mjs +++ b/core/render.mjs @@ -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('|---|---|---|---|---|---|'); diff --git a/core/test.mjs b/core/test.mjs index 2d2bf7f..3b6e57f 100644 --- a/core/test.mjs +++ b/core/test.mjs @@ -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); diff --git a/core/validate-pack.mjs b/core/validate-pack.mjs index 3634041..6ee7c7f 100644 --- a/core/validate-pack.mjs +++ b/core/validate-pack.mjs @@ -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}`); diff --git a/foundry-module/module/core/content/barrow.json b/foundry-module/module/core/content/barrow.json index ce36ac7..9a1d821 100644 --- a/foundry-module/module/core/content/barrow.json +++ b/foundry-module/module/core/content/barrow.json @@ -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..", "placeNameStyle": "claimant" -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/castle.json b/foundry-module/module/core/content/castle.json index c3281d5..98f1554 100644 --- a/foundry-module/module/core/content/castle.json +++ b/foundry-module/module/core/content/castle.json @@ -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" -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/cave.json b/foundry-module/module/core/content/cave.json index a15f9da..08c9507 100644 --- a/foundry-module/module/core/content/cave.json +++ b/foundry-module/module/core/content/cave.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/church.json b/foundry-module/module/core/content/church.json index 889bd29..b68d193 100644 --- a/foundry-module/module/core/content/church.json +++ b/foundry-module/module/core/content/church.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/city.json b/foundry-module/module/core/content/city.json index 653caa4..eb9d6ef 100644 --- a/foundry-module/module/core/content/city.json +++ b/foundry-module/module/core/content/city.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/forest.json b/foundry-module/module/core/content/forest.json index 6c37bbb..fbd2312 100644 --- a/foundry-module/module/core/content/forest.json +++ b/foundry-module/module/core/content/forest.json @@ -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" -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/lake.json b/foundry-module/module/core/content/lake.json index a79a915..b15f3f3 100644 --- a/foundry-module/module/core/content/lake.json +++ b/foundry-module/module/core/content/lake.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/market.json b/foundry-module/module/core/content/market.json index 7fd5eff..5f65513 100644 --- a/foundry-module/module/core/content/market.json +++ b/foundry-module/module/core/content/market.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/mountain.json b/foundry-module/module/core/content/mountain.json index 452c2e7..6174740 100644 --- a/foundry-module/module/core/content/mountain.json +++ b/foundry-module/module/core/content/mountain.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/palace.json b/foundry-module/module/core/content/palace.json index 4b703ae..fc396cb 100644 --- a/foundry-module/module/core/content/palace.json +++ b/foundry-module/module/core/content/palace.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/port.json b/foundry-module/module/core/content/port.json index db46a07..4cc79b2 100644 --- a/foundry-module/module/core/content/port.json +++ b/foundry-module/module/core/content/port.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/river.json b/foundry-module/module/core/content/river.json index 5a43158..7a08ed3 100644 --- a/foundry-module/module/core/content/river.json +++ b/foundry-module/module/core/content/river.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/temple.json b/foundry-module/module/core/content/temple.json index 5504fd4..e515a4d 100644 --- a/foundry-module/module/core/content/temple.json +++ b/foundry-module/module/core/content/temple.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/town.json b/foundry-module/module/core/content/town.json index 01923f9..dd409fe 100644 --- a/foundry-module/module/core/content/town.json +++ b/foundry-module/module/core/content/town.json @@ -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" -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/valley.json b/foundry-module/module/core/content/valley.json index f037e1a..275d11b 100644 --- a/foundry-module/module/core/content/valley.json +++ b/foundry-module/module/core/content/valley.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/foundry-module/module/core/content/village.json b/foundry-module/module/core/content/village.json index 76f1dc7..6e0696b 100644 --- a/foundry-module/module/core/content/village.json +++ b/foundry-module/module/core/content/village.json @@ -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 @@ ] } } -} \ No newline at end of file +} diff --git a/foundry-module/module/core/render.mjs b/foundry-module/module/core/render.mjs index 566848b..a39d6bb 100644 --- a/foundry-module/module/core/render.mjs +++ b/foundry-module/module/core/render.mjs @@ -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('|---|---|---|---|---|---|'); diff --git a/foundry-module/module/forge-app.mjs b/foundry-module/module/forge-app.mjs index 4cf190a..5dd7bd4 100644 --- a/foundry-module/module/forge-app.mjs +++ b/foundry-module/module/forge-app.mjs @@ -181,7 +181,7 @@ function foeSection(c) { if (c.kind === 'planned') return `

${esc(cap(c.heat))} — not cast. Nothing was forged for this area, so the plan is the encounter. Cast it by hand:

${table(c.roster.foes.map(f => `${f.n}× ${esc(f.name)}${f.atk}${f.def}${f.grit}${f.nerve}${esc(f.note)}`).join(''))} -

Harmed by ${esc(c.roster.harmedBy)}. ${esc(c.roster.avoid)}.

`; +

Harmed by ${esc(c.roster.harmedBy)}.${c.roster.beforeInitiative ? ` ${esc(c.roster.beforeInitiative)}` : ''} ${esc(c.roster.avoid)}.

`; if (c.kind === 'unavailable') return `

${esc(cap(c.heat))} — nothing to run. 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.

`; return ''; diff --git a/foundry-module/module/stage.mjs b/foundry-module/module/stage.mjs new file mode 100644 index 0000000..556b055 --- /dev/null +++ b/foundry-module/module/stage.mjs @@ -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 => ``; + +/** + * 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 `

${cap(c.heat)} — in the world:

${list(c.foes.map(foeLine))}${ + c.planned ? `

DELVE planned ${c.planned.line}; the Forge rolled its own, so the plan's tactics do not describe these.

` : ''}`; + if (c.kind === 'planned') return `

${cap(c.heat)} — not cast. Nothing was forged; run the plan by hand:

${ + list(c.roster.foes.map(f => `${f.n}× ${f.name} — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. ${f.note}`)) + }

Harmed by ${c.roster.harmedBy}.${c.roster.beforeInitiative ? ` ${c.roster.beforeInitiative}` : ''} ${c.roster.avoid}.

`; + if (c.kind === 'unavailable') return `

${cap(c.heat)} — nothing to run. No actors, and no roster at this heat. Improvise or skip; the decision and fallback still stand.

`; + 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 ?? `(unwritten) ${area.cueFragments.join('. ')}.`, + gm: { + label: `⛏ ${area.index} · ${name}`, + sub: `${area.role} · ${area.facet}`, + body: `${area.situation ? `

Here: ${cap(area.situation.occupant)}, ${area.situation.doing} — ${area.situation.onArrival}.
+ They can: ${area.situation.offer}. ${cap(area.situation.because)}.

` : ''} +

${cap(area.decision.cue)}${rv.roll ? ` — [${rv.roll}] ${rv.success}` : ''}${rv.failure ? `
Miss: ${rv.failure}` : ''}${rv.orElse ? `
Or: ${rv.orElse}` : ''}

+ ${foeBlock(classification)} + ${area.temptation ? `

${cap(area.temptation.id)}: ${area.temptation.benefit}. Use: ${area.temptation.useCost?.bane ? `+${area.temptation.useCost.bane} Bane` : '—'}. ${area.temptation.standingDrawback}.

` : ''} + ${authored.notes ? `

Your note: ${authored.notes}

` : ''} +

${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}

`, + }, + }; +} + +/** + * @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 }; +} diff --git a/foundry-module/module/vanity-delve.mjs b/foundry-module/module/vanity-delve.mjs index 4945ee4..0a80c79 100644 --- a/foundry-module/module/vanity-delve.mjs +++ b/foundry-module/module/vanity-delve.mjs @@ -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 => ``; - /** 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 ?? `(unwritten) ${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' - ? `

${cap(c.heat)} — in the world:

${list(c.foes.map(foeLine))}${ - c.planned ? `

DELVE planned ${c.planned.line}; the Forge rolled its own, so the plan's tactics do not describe these.

` : ''}` - : c.kind === 'planned' - ? `

${cap(c.heat)} — not cast. Nothing was forged; run the plan by hand:

${ - list(c.roster.foes.map(f => `${f.n}× ${f.name} — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. ${f.note}`)) - }

Harmed by ${c.roster.harmedBy}. ${c.roster.avoid}.

` - : c.kind === 'unavailable' - ? `

${cap(c.heat)} — nothing to run. No actors, and no roster at this heat. Improvise or skip; the decision and fallback still stand.

` - : ''; - await gmCard(`⛏ ${area.index} · ${name}`, `${area.role} · ${area.facet}`, - `${area.situation ? `

Here: ${cap(area.situation.occupant)}, ${area.situation.doing} — ${area.situation.onArrival}.
- They can: ${area.situation.offer}. ${cap(area.situation.because)}.

` : ''} -

${cap(area.decision.cue)}${rv.roll ? ` — [${rv.roll}] ${rv.success}` : ''}${rv.failure ? `
Miss: ${rv.failure}` : ''}${rv.orElse ? `
Or: ${rv.orElse}` : ''}

- ${foeBlock} - ${area.temptation ? `

${cap(area.temptation.id)}: ${area.temptation.benefit}. Use: ${area.temptation.useCost?.bane ? `+${area.temptation.useCost.bane} Bane` : '—'}. ${area.temptation.standingDrawback}.

` : ''} - ${w.notes ? `

Your note: ${w.notes}

` : ''} -

${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}

`); - - 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.'); diff --git a/foundry-module/test.mjs b/foundry-module/test.mjs index c39d0ab..30e5e6c 100644 --- a/foundry-module/test.mjs +++ b/foundry-module/test.mjs @@ -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('')); -// --- 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);