Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d47a167fd0 | ||
|
|
c1e467c91b | ||
|
|
1b2f9aa385 | ||
|
|
0bbf57b693 | ||
|
|
9d220a3428 |
@@ -45,8 +45,8 @@ and two paper playtests that failed before anything worked.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `core/` | the generator — pure JS, 39 tests |
|
||||
| `foundry-module/` | the Foundry module, with `core/` vendored |
|
||||
| `core/` | the generator — pure JS, 41 tests |
|
||||
| `foundry-module/` | the Foundry module, with `core/` vendored — 60 tests |
|
||||
| `paper/` | a hand-written delve and both playtest logs |
|
||||
| `spike/` | the harness that measured VANITY's Forge outside Foundry |
|
||||
| `samples/` | generated output |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "barrow",
|
||||
"label": "Barrow",
|
||||
"forgeStageType": "barrow",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"_model": "Everything derives from one kernel: an APPETITE and the ACCOMMODATION made for it. The motif, danger, prize kind and faction all follow from the appetite. Cue fragments hang off the MOTIF and are tagged by FACET (institution/ritual/demand/wound/anchor) and level, so two delves sharing a motif draw different imagery. The foreshadow chain is a fixed facet progression mapped onto the arc — which is why area 2's bows and area 4's scratched eyes both point at the same wound.",
|
||||
"appetites": [
|
||||
{
|
||||
@@ -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",
|
||||
@@ -2144,7 +2145,8 @@
|
||||
}
|
||||
],
|
||||
"harmedBy": "blessed, silvered or magical weapons for the Wraith; anything for the rest",
|
||||
"avoid": "do not run this without an appeasement route open"
|
||||
"avoid": "do not run this without an appeasement route open",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
}
|
||||
},
|
||||
"triggerGlossary": {
|
||||
@@ -2156,4 +2158,4 @@
|
||||
},
|
||||
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>.",
|
||||
"placeNameStyle": "claimant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "castle",
|
||||
"label": "Castle",
|
||||
"forgeStageType": "barrow",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"_model": "Same kernel as barrow: an APPETITE and the ACCOMMODATION made for it. A castle's appetites are about holding — ground, fear, obedience, a name — and its accommodations are what a house does to keep them.",
|
||||
"facetProgression": {
|
||||
"approach": "institution",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "cave",
|
||||
"label": "Cave",
|
||||
"forgeStageType": "cave",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Wild kernel underground. The appetite belongs to the hole itself — it wants to be gone into, gone down, spoken to, and to keep whatever comes. Claimants are the cave's own names for itself, and the accommodation is what it did to whoever came in.",
|
||||
"facetProgression": {
|
||||
@@ -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; the swarm douses open flames",
|
||||
"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",
|
||||
@@ -381,7 +382,8 @@
|
||||
}
|
||||
],
|
||||
"harmedBy": "anything; fire for the Troll",
|
||||
"avoid": "do not run this without an appeasement route open and a way out at your back"
|
||||
"avoid": "do not run this without an appeasement route open and a way out at your back",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
}
|
||||
},
|
||||
"motifs": {
|
||||
@@ -1170,4 +1172,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "church",
|
||||
"label": "Church",
|
||||
"forgeStageType": "barrow",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Interior kernel. A parish church wants to be believed, attended, given to, or kept holy, and its accommodations are what a cure of souls does to keep a flock.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "city",
|
||||
"label": "City",
|
||||
"forgeStageType": "village",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "own",
|
||||
"_model": "Settlement kernel at its largest. Nobody here knows anybody, so the city knows instead — by ward, by roll, by works, by freedom granted and withheld. Its cruelties are all administrative, which is why none of them can be argued with.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "forest",
|
||||
"label": "Forest",
|
||||
"forgeStageType": "forest",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"_model": "The wild kernel: the APPETITE belongs to the place itself. A wood wants to be entered, fed, spread, or left uncounted, and the ACCOMMODATION is what it did to whoever was here — took them, grew through them, let them wander. Claimants are the wood's own names for itself.",
|
||||
"facetProgression": {
|
||||
"approach": "institution",
|
||||
@@ -349,7 +349,8 @@
|
||||
}
|
||||
],
|
||||
"harmedBy": "anything, but the Troll only stays down if fire closes the wound",
|
||||
"avoid": "it is territorial and will not follow past the treeline"
|
||||
"avoid": "it is territorial and will not follow past the treeline",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"line": "the Hunt: 1 Wraith mounted, 1 Troll and 4 Dire Wolves",
|
||||
@@ -383,7 +384,8 @@
|
||||
}
|
||||
],
|
||||
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; fire for the Troll; anything for the wolves",
|
||||
"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 +1175,4 @@
|
||||
}
|
||||
},
|
||||
"placeNameStyle": "claimant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "lake",
|
||||
"label": "Lake",
|
||||
"forgeStageType": "fen",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Wild kernel in still water. Where the river wants movement, the lake wants none: to be left flat, to be looked into, to keep what is under it exactly as it was, and to be a little bigger every year. Claimants are the water's own names for itself.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "market",
|
||||
"label": "Market",
|
||||
"forgeStageType": "village",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "own",
|
||||
"_model": "Settlement kernel, but the place is a transaction rather than a community. The appetite belongs to the market itself; the claimants are its offices; and everything it did to people it did by contract, which is why none of it can be undone by force.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "mountain",
|
||||
"label": "Mountain",
|
||||
"forgeStageType": "cave",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Wild kernel at altitude. The appetite belongs to the hill — it wants to be gone up, to be cold, to be the only way through, and to have everything come to rest at its foot. Claimants are the mountain's own names, and its cruelties are all things weather does anyway.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "palace",
|
||||
"label": "Palace",
|
||||
"forgeStageType": "barrow",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Interior kernel at its most VANITY-shaped. A palace wants to be attended, delighted, admired or centred on, and its accommodations are what a court does to people who are useful to that.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "port",
|
||||
"label": "Port",
|
||||
"forgeStageType": "village",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "own",
|
||||
"_model": "Settlement kernel on the water. The appetite belongs to the harbour, which is a place people are always about to leave — so every cruelty here is a way of stopping them, and every prize is something that makes leaving possible again.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "river",
|
||||
"label": "River",
|
||||
"forgeStageType": "fen",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Wild kernel in moving water. The appetite belongs to the river — it wants to be asked before it is crossed, to carry things away, to run where it has always run, and to be given somebody rather than have to take them. Claimants are the river's own names.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "temple",
|
||||
"label": "Temple",
|
||||
"forgeStageType": "barrow",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Interior kernel, older and stranger than a church. A temple wants the rite performed exactly, to be fed, to keep its name unsaid, or to be the only one. Its accommodations are what a priesthood does when a god is literal.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "town",
|
||||
"label": "Town",
|
||||
"forgeStageType": "village",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"_model": "The settlement kernel: the APPETITE belongs to the place, not to a buried person. A town wants to be prosperous, respectable, safe or necessary, and its ACCOMMODATION is what a corporation does to keep it that way — price people out, charter them away, wall them off. Claimants are offices, not corpses.",
|
||||
"facetProgression": {
|
||||
"approach": "institution",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "valley",
|
||||
"label": "Valley",
|
||||
"forgeStageType": "forest",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Wild kernel that keeps you by being kind. The appetite belongs to the valley — it wants nothing to go over the top, everything to come in out of the wind, everything to end up at the bottom, and its own sky. Every cruelty here looks like hospitality, which is what makes it hard to leave.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "village",
|
||||
"label": "Village",
|
||||
"forgeStageType": "village",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "own",
|
||||
"_model": "The settlement kernel at its smallest. The appetite belongs to the place; the claimant is an office, not a person; and what a village does to people who might leave is quieter and worse than what a town does.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ import { baneBeatFor } from './director.mjs';
|
||||
* it. Bump it when the shape of the emitted object changes or when content selection changes
|
||||
* enough that the same seed no longer yields the same delve — not when the module version moves.
|
||||
* 0.2.0: the `authored` layer, motif-scoped decisions/temptations/features, areas as situations.
|
||||
*
|
||||
* A change that originates in a pack rather than in this file belongs to `catalogVersion` instead
|
||||
* — beat.mjs embeds the pack's roster verbatim, so a new roster field changes the emitted file
|
||||
* without changing anything here. `beforeInitiative` is why the packs are at schema 2.
|
||||
*/
|
||||
export const GENERATOR_VERSION = '0.2.0';
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* GM what matters faster" — and the only way to be fast is to carry less.
|
||||
*/
|
||||
import { outstanding, isLocked, REROLLABLE } from './authoring.mjs';
|
||||
import { initiativeWarning } from './roster.mjs';
|
||||
|
||||
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||
const cost = c => {
|
||||
@@ -76,7 +77,7 @@ export function renderWorksheet(d) {
|
||||
if (a.encounter?.roster) {
|
||||
const R = a.encounter.roster;
|
||||
L.push(`**ENCOUNTER** ${R.line} — ${R.foes.map(f => `${f.n}× ${f.name} atk${f.atk}/def${f.def}/G${f.grit}`).join(', ')}`);
|
||||
L.push(` · harmed by ${R.harmedBy} · ${R.avoid}`);
|
||||
L.push(` · harmed by ${R.harmedBy}${initiativeWarning(R) ? ` · ${initiativeWarning(R)}` : ''} · ${R.avoid}`);
|
||||
}
|
||||
if (a.temptation) {
|
||||
const t = a.temptation;
|
||||
@@ -143,7 +144,12 @@ export function renderPlay(d) {
|
||||
L.push(`**${cap(a.decision.cue)}** — ${rv.roll ? `\`[${rv.roll}]\` ${rv.success}` : ''}${rv.failure ? ` · **miss** ${rv.failure}` : ''}${rv.orElse ? ` · **or** ${rv.orElse}` : ''}`);
|
||||
if (a.encounter?.roster) {
|
||||
const R = a.encounter.roster;
|
||||
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`);
|
||||
// `avoid` earns its place here — it is how the table gets past the fight without one, which
|
||||
// is table-facing by definition. It also keeps every surface rendering both prose fields, so
|
||||
// initiativeWarning's "the prose already says it" rule means the same thing everywhere; when
|
||||
// the play sheet alone omitted `avoid`, a legacy warning living there vanished from it.
|
||||
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`
|
||||
+ `${initiativeWarning(R) ? ` **${initiativeWarning(R)}**` : ''} *${R.avoid}.*`);
|
||||
}
|
||||
if (a.temptation) L.push(`**${cap(a.temptation.id)}:** ${a.temptation.benefit}. *Use: ${cost(a.temptation.useCost)}. ${a.temptation.standingDrawback}.*`);
|
||||
if (w.notes) L.push(`**Note:** ${w.notes}`);
|
||||
|
||||
+4
-2
@@ -1,5 +1,6 @@
|
||||
/** Render a delve to GM-readable markdown, in the house style. */
|
||||
import { skeletonBlurb } from './skeleton.mjs';
|
||||
import { initiativeWarning } from './roster.mjs';
|
||||
|
||||
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||
|
||||
@@ -104,8 +105,9 @@ export function renderMarkdown(d) {
|
||||
L.push('');
|
||||
|
||||
if (R) {
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}` +
|
||||
`${R.harmedBy.includes('ONLY') ? ' — **say so before initiative**' : ''}. *${R.avoid}.*`);
|
||||
const warn = initiativeWarning(R);
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}.`
|
||||
+ `${warn ? ` **${warn}**` : ''} *${R.avoid}.*`);
|
||||
L.push('');
|
||||
L.push('| Foe | atk | def | Grit | Nerve | |');
|
||||
L.push('|---|---|---|---|---|---|');
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The one place that decides whether a fight needs a warning before initiative.
|
||||
*
|
||||
* Some rosters carry a restriction a party cannot discover safely: a Wraith that only blessed,
|
||||
* silvered or magical weapons touch, a Troll that only stays down if fire closes the wound. Finding
|
||||
* that out by swinging and missing is how playtest 1 died, so the GM says it aloud first.
|
||||
*
|
||||
* This was a renderer heuristic — synthesise the line whenever `harmedBy` contained the word ONLY.
|
||||
* It printed twice on the rosters that already said it in prose, and when the heuristic was deleted
|
||||
* the warning vanished from twelve rosters at once. Worse, the spelling was never the semantics:
|
||||
* barrow's nightmare Wraith and forest's battle Troll carry the same restriction without the word.
|
||||
*
|
||||
* So it is authored data now — `roster.beforeInitiative` — and this module is the only thing that
|
||||
* reads it. RESTRICTS remains, but only as a tripwire: it tells the validator that new content
|
||||
* *looks* like it needs the field, and it recovers the warning for delve files generated before the
|
||||
* field existed. It is not the source of truth; the packs are.
|
||||
*/
|
||||
|
||||
/** Vocabulary the packs actually use for "your weapon may not work". A tripwire, not an oracle. */
|
||||
export const RESTRICTS = /\bONLY\b|blessed|silvered|magical|only stays down|fire for the/i;
|
||||
|
||||
/**
|
||||
* What to say before initiative, or null. Never returns a line the prose already carries, so it
|
||||
* cannot print twice.
|
||||
*/
|
||||
export function initiativeWarning(roster) {
|
||||
if (!roster) return null;
|
||||
// Prose wins, and is checked first. A hand-edited file can carry both the field and the old
|
||||
// sentence; returning the field before looking would print it twice, which is the fault this
|
||||
// whole migration exists to remove.
|
||||
const prose = `${roster.harmedBy ?? ''} ${roster.avoid ?? ''}`;
|
||||
if (/say so before initiative/i.test(prose)) return null;
|
||||
if (roster.beforeInitiative) return roster.beforeInitiative;
|
||||
return RESTRICTS.test(roster.harmedBy ?? '') ? 'Say so before initiative.' : null;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* Run: node validate-pack.mjs [name ...] (default: every pack in content/)
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { RESTRICTS } from './roster.mjs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
@@ -102,6 +103,18 @@ export function validatePack(pack, name = pack?.id ?? '?') {
|
||||
if (!r.line || !r.foes?.length) E(`rosters.${h}: needs a line and foes`);
|
||||
if (!r.harmedBy) E(`rosters.${h}: must say what harms them — a party discovering an immunity by failing is how playtest 1 died`);
|
||||
if (!r.avoid) W(`rosters.${h}: no avoidance note`);
|
||||
|
||||
/**
|
||||
* A restriction the party cannot discover safely must be told to them before initiative, and
|
||||
* it must be said exactly once. This lived as a renderer heuristic — synthesise the warning
|
||||
* whenever `harmedBy` contained the word ONLY — which printed twice on the rosters that
|
||||
* already said it in prose, and vanished from twelve rosters when the heuristic was removed.
|
||||
* It is authored data now, and this is the check that keeps it that way.
|
||||
*/
|
||||
if (/say so before initiative/i.test(`${r.harmedBy} ${r.avoid ?? ''}`))
|
||||
E(`rosters.${h}: put the initiative warning in beforeInitiative, not in the prose — it renders twice otherwise`);
|
||||
if (RESTRICTS.test(r.harmedBy) && !r.beforeInitiative)
|
||||
E(`rosters.${h}: harmedBy restricts what works, so it needs beforeInitiative — the party cannot discover this safely`);
|
||||
for (const f of r.foes ?? [])
|
||||
for (const k of ['n', 'name', 'atk', 'def', 'grit', 'nerve'])
|
||||
if (f[k] === undefined) E(`rosters.${h}: foe ${f.name ?? '?'} missing ${k}`);
|
||||
|
||||
@@ -21,5 +21,5 @@ dungeon**, and it builds — in one folder per document type:
|
||||
Every document is stamped with a `dungeonId`, so the 🗑 button removes a whole dungeon and
|
||||
nothing else.
|
||||
|
||||
Requires the VANITY system **0.10.5+** for quiet output; on older versions the Forge posts its
|
||||
Requires the VANITY system **0.10.4+** for quiet output; on older versions the Forge posts its
|
||||
own cards over DELVE's and the module says so on startup.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "vanity-delve",
|
||||
"title": "DELVE — a dungeon layer for VANITY",
|
||||
"description": "Generates a coherent delve and sequences VANITY's Forge to stage it, one area at a time.",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.5",
|
||||
"compatibility": {
|
||||
"minimum": "13",
|
||||
"verified": "14.365"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "barrow",
|
||||
"label": "Barrow",
|
||||
"forgeStageType": "barrow",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"_model": "Everything derives from one kernel: an APPETITE and the ACCOMMODATION made for it. The motif, danger, prize kind and faction all follow from the appetite. Cue fragments hang off the MOTIF and are tagged by FACET (institution/ritual/demand/wound/anchor) and level, so two delves sharing a motif draw different imagery. The foreshadow chain is a fixed facet progression mapped onto the arc — which is why area 2's bows and area 4's scratched eyes both point at the same wound.",
|
||||
"appetites": [
|
||||
{
|
||||
@@ -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",
|
||||
@@ -2144,7 +2145,8 @@
|
||||
}
|
||||
],
|
||||
"harmedBy": "blessed, silvered or magical weapons for the Wraith; anything for the rest",
|
||||
"avoid": "do not run this without an appeasement route open"
|
||||
"avoid": "do not run this without an appeasement route open",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
}
|
||||
},
|
||||
"triggerGlossary": {
|
||||
@@ -2156,4 +2158,4 @@
|
||||
},
|
||||
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>.",
|
||||
"placeNameStyle": "claimant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "castle",
|
||||
"label": "Castle",
|
||||
"forgeStageType": "barrow",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"_model": "Same kernel as barrow: an APPETITE and the ACCOMMODATION made for it. A castle's appetites are about holding — ground, fear, obedience, a name — and its accommodations are what a house does to keep them.",
|
||||
"facetProgression": {
|
||||
"approach": "institution",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "cave",
|
||||
"label": "Cave",
|
||||
"forgeStageType": "cave",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Wild kernel underground. The appetite belongs to the hole itself — it wants to be gone into, gone down, spoken to, and to keep whatever comes. Claimants are the cave's own names for itself, and the accommodation is what it did to whoever came in.",
|
||||
"facetProgression": {
|
||||
@@ -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; the swarm douses open flames",
|
||||
"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",
|
||||
@@ -381,7 +382,8 @@
|
||||
}
|
||||
],
|
||||
"harmedBy": "anything; fire for the Troll",
|
||||
"avoid": "do not run this without an appeasement route open and a way out at your back"
|
||||
"avoid": "do not run this without an appeasement route open and a way out at your back",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
}
|
||||
},
|
||||
"motifs": {
|
||||
@@ -1170,4 +1172,4 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "church",
|
||||
"label": "Church",
|
||||
"forgeStageType": "barrow",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Interior kernel. A parish church wants to be believed, attended, given to, or kept holy, and its accommodations are what a cure of souls does to keep a flock.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "city",
|
||||
"label": "City",
|
||||
"forgeStageType": "village",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "own",
|
||||
"_model": "Settlement kernel at its largest. Nobody here knows anybody, so the city knows instead — by ward, by roll, by works, by freedom granted and withheld. Its cruelties are all administrative, which is why none of them can be argued with.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "forest",
|
||||
"label": "Forest",
|
||||
"forgeStageType": "forest",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"_model": "The wild kernel: the APPETITE belongs to the place itself. A wood wants to be entered, fed, spread, or left uncounted, and the ACCOMMODATION is what it did to whoever was here — took them, grew through them, let them wander. Claimants are the wood's own names for itself.",
|
||||
"facetProgression": {
|
||||
"approach": "institution",
|
||||
@@ -349,7 +349,8 @@
|
||||
}
|
||||
],
|
||||
"harmedBy": "anything, but the Troll only stays down if fire closes the wound",
|
||||
"avoid": "it is territorial and will not follow past the treeline"
|
||||
"avoid": "it is territorial and will not follow past the treeline",
|
||||
"beforeInitiative": "Say so before initiative."
|
||||
},
|
||||
"nightmare": {
|
||||
"line": "the Hunt: 1 Wraith mounted, 1 Troll and 4 Dire Wolves",
|
||||
@@ -383,7 +384,8 @@
|
||||
}
|
||||
],
|
||||
"harmedBy": "blessed, silvered or magical weapons for the Wraith ONLY; fire for the Troll; anything for the wolves",
|
||||
"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 +1175,4 @@
|
||||
}
|
||||
},
|
||||
"placeNameStyle": "claimant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "lake",
|
||||
"label": "Lake",
|
||||
"forgeStageType": "fen",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Wild kernel in still water. Where the river wants movement, the lake wants none: to be left flat, to be looked into, to keep what is under it exactly as it was, and to be a little bigger every year. Claimants are the water's own names for itself.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "market",
|
||||
"label": "Market",
|
||||
"forgeStageType": "village",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "own",
|
||||
"_model": "Settlement kernel, but the place is a transaction rather than a community. The appetite belongs to the market itself; the claimants are its offices; and everything it did to people it did by contract, which is why none of it can be undone by force.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "mountain",
|
||||
"label": "Mountain",
|
||||
"forgeStageType": "cave",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Wild kernel at altitude. The appetite belongs to the hill — it wants to be gone up, to be cold, to be the only way through, and to have everything come to rest at its foot. Claimants are the mountain's own names, and its cruelties are all things weather does anyway.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "palace",
|
||||
"label": "Palace",
|
||||
"forgeStageType": "barrow",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Interior kernel at its most VANITY-shaped. A palace wants to be attended, delighted, admired or centred on, and its accommodations are what a court does to people who are useful to that.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "port",
|
||||
"label": "Port",
|
||||
"forgeStageType": "village",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "own",
|
||||
"_model": "Settlement kernel on the water. The appetite belongs to the harbour, which is a place people are always about to leave — so every cruelty here is a way of stopping them, and every prize is something that makes leaving possible again.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "river",
|
||||
"label": "River",
|
||||
"forgeStageType": "fen",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Wild kernel in moving water. The appetite belongs to the river — it wants to be asked before it is crossed, to carry things away, to run where it has always run, and to be given somebody rather than have to take them. Claimants are the river's own names.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "temple",
|
||||
"label": "Temple",
|
||||
"forgeStageType": "barrow",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Interior kernel, older and stranger than a church. A temple wants the rite performed exactly, to be fed, to keep its name unsaid, or to be the only one. Its accommodations are what a priesthood does when a god is literal.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "town",
|
||||
"label": "Town",
|
||||
"forgeStageType": "village",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"_model": "The settlement kernel: the APPETITE belongs to the place, not to a buried person. A town wants to be prosperous, respectable, safe or necessary, and its ACCOMMODATION is what a corporation does to keep it that way — price people out, charter them away, wall them off. Claimants are offices, not corpses.",
|
||||
"facetProgression": {
|
||||
"approach": "institution",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "valley",
|
||||
"label": "Valley",
|
||||
"forgeStageType": "forest",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "claimant",
|
||||
"_model": "Wild kernel that keeps you by being kind. The appetite belongs to the valley — it wants nothing to go over the top, everything to come in out of the wind, everything to end up at the bottom, and its own sky. Every cruelty here looks like hospitality, which is what makes it hard to leave.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "village",
|
||||
"label": "Village",
|
||||
"forgeStageType": "village",
|
||||
"schema": 1,
|
||||
"schema": 2,
|
||||
"placeNameStyle": "own",
|
||||
"_model": "The settlement kernel at its smallest. The appetite belongs to the place; the claimant is an office, not a person; and what a village does to people who might leave is quieter and worse than what a town does.",
|
||||
"facetProgression": {
|
||||
@@ -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 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ import { baneBeatFor } from './director.mjs';
|
||||
* it. Bump it when the shape of the emitted object changes or when content selection changes
|
||||
* enough that the same seed no longer yields the same delve — not when the module version moves.
|
||||
* 0.2.0: the `authored` layer, motif-scoped decisions/temptations/features, areas as situations.
|
||||
*
|
||||
* A change that originates in a pack rather than in this file belongs to `catalogVersion` instead
|
||||
* — beat.mjs embeds the pack's roster verbatim, so a new roster field changes the emitted file
|
||||
* without changing anything here. `beforeInitiative` is why the packs are at schema 2.
|
||||
*/
|
||||
export const GENERATOR_VERSION = '0.2.0';
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
* GM what matters faster" — and the only way to be fast is to carry less.
|
||||
*/
|
||||
import { outstanding, isLocked, REROLLABLE } from './authoring.mjs';
|
||||
import { initiativeWarning } from './roster.mjs';
|
||||
|
||||
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||
const cost = c => {
|
||||
@@ -76,7 +77,7 @@ export function renderWorksheet(d) {
|
||||
if (a.encounter?.roster) {
|
||||
const R = a.encounter.roster;
|
||||
L.push(`**ENCOUNTER** ${R.line} — ${R.foes.map(f => `${f.n}× ${f.name} atk${f.atk}/def${f.def}/G${f.grit}`).join(', ')}`);
|
||||
L.push(` · harmed by ${R.harmedBy} · ${R.avoid}`);
|
||||
L.push(` · harmed by ${R.harmedBy}${initiativeWarning(R) ? ` · ${initiativeWarning(R)}` : ''} · ${R.avoid}`);
|
||||
}
|
||||
if (a.temptation) {
|
||||
const t = a.temptation;
|
||||
@@ -143,7 +144,12 @@ export function renderPlay(d) {
|
||||
L.push(`**${cap(a.decision.cue)}** — ${rv.roll ? `\`[${rv.roll}]\` ${rv.success}` : ''}${rv.failure ? ` · **miss** ${rv.failure}` : ''}${rv.orElse ? ` · **or** ${rv.orElse}` : ''}`);
|
||||
if (a.encounter?.roster) {
|
||||
const R = a.encounter.roster;
|
||||
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`);
|
||||
// `avoid` earns its place here — it is how the table gets past the fight without one, which
|
||||
// is table-facing by definition. It also keeps every surface rendering both prose fields, so
|
||||
// initiativeWarning's "the prose already says it" rule means the same thing everywhere; when
|
||||
// the play sheet alone omitted `avoid`, a legacy warning living there vanished from it.
|
||||
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`
|
||||
+ `${initiativeWarning(R) ? ` **${initiativeWarning(R)}**` : ''} *${R.avoid}.*`);
|
||||
}
|
||||
if (a.temptation) L.push(`**${cap(a.temptation.id)}:** ${a.temptation.benefit}. *Use: ${cost(a.temptation.useCost)}. ${a.temptation.standingDrawback}.*`);
|
||||
if (w.notes) L.push(`**Note:** ${w.notes}`);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Render a delve to GM-readable markdown, in the house style. */
|
||||
import { skeletonBlurb } from './skeleton.mjs';
|
||||
import { initiativeWarning } from './roster.mjs';
|
||||
|
||||
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||
|
||||
@@ -104,8 +105,9 @@ export function renderMarkdown(d) {
|
||||
L.push('');
|
||||
|
||||
if (R) {
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}` +
|
||||
`${R.harmedBy.includes('ONLY') ? ' — **say so before initiative**' : ''}. *${R.avoid}.*`);
|
||||
const warn = initiativeWarning(R);
|
||||
L.push(`**${cap(a.encounter.heat)} — ${R.line}.** Harmed by ${R.harmedBy}.`
|
||||
+ `${warn ? ` **${warn}**` : ''} *${R.avoid}.*`);
|
||||
L.push('');
|
||||
L.push('| Foe | atk | def | Grit | Nerve | |');
|
||||
L.push('|---|---|---|---|---|---|');
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The one place that decides whether a fight needs a warning before initiative.
|
||||
*
|
||||
* Some rosters carry a restriction a party cannot discover safely: a Wraith that only blessed,
|
||||
* silvered or magical weapons touch, a Troll that only stays down if fire closes the wound. Finding
|
||||
* that out by swinging and missing is how playtest 1 died, so the GM says it aloud first.
|
||||
*
|
||||
* This was a renderer heuristic — synthesise the line whenever `harmedBy` contained the word ONLY.
|
||||
* It printed twice on the rosters that already said it in prose, and when the heuristic was deleted
|
||||
* the warning vanished from twelve rosters at once. Worse, the spelling was never the semantics:
|
||||
* barrow's nightmare Wraith and forest's battle Troll carry the same restriction without the word.
|
||||
*
|
||||
* So it is authored data now — `roster.beforeInitiative` — and this module is the only thing that
|
||||
* reads it. RESTRICTS remains, but only as a tripwire: it tells the validator that new content
|
||||
* *looks* like it needs the field, and it recovers the warning for delve files generated before the
|
||||
* field existed. It is not the source of truth; the packs are.
|
||||
*/
|
||||
|
||||
/** Vocabulary the packs actually use for "your weapon may not work". A tripwire, not an oracle. */
|
||||
export const RESTRICTS = /\bONLY\b|blessed|silvered|magical|only stays down|fire for the/i;
|
||||
|
||||
/**
|
||||
* What to say before initiative, or null. Never returns a line the prose already carries, so it
|
||||
* cannot print twice.
|
||||
*/
|
||||
export function initiativeWarning(roster) {
|
||||
if (!roster) return null;
|
||||
// Prose wins, and is checked first. A hand-edited file can carry both the field and the old
|
||||
// sentence; returning the field before looking would print it twice, which is the fault this
|
||||
// whole migration exists to remove.
|
||||
const prose = `${roster.harmedBy ?? ''} ${roster.avoid ?? ''}`;
|
||||
if (/say so before initiative/i.test(prose)) return null;
|
||||
if (roster.beforeInitiative) return roster.beforeInitiative;
|
||||
return RESTRICTS.test(roster.harmedBy ?? '') ? 'Say so before initiative.' : null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Who is actually in the room.
|
||||
*
|
||||
* A pack's roster is the encounter DELVE *planned* — "1 Ghoul and 2 Skeletons", with stats and,
|
||||
* in some themes, tactical notes naming specific monsters. The Forge takes no cast: it rolls its
|
||||
* own from the heat. So the plan and the world are two different lists, and the rule both surfaces
|
||||
* follow is **show what exists**:
|
||||
*
|
||||
* forged the Forge made actors — run those, and say the plan's tactics are not about them
|
||||
* planned nothing was forged (population off, or the Forge failed) — the plan IS the
|
||||
* encounter, so it renders in full and its guidance applies
|
||||
* unavailable an area carries combat heat but has neither — say so rather than render nothing
|
||||
* none no encounter here at all
|
||||
*
|
||||
* This file is deliberately Foundry-free so the decision can be tested without a VTT. The two
|
||||
* surfaces disagreed about it twice — 0.6.2 lost the planned roster entirely when nothing was
|
||||
* forged, and the caption claimed the plan's guidance applied either way — because each surface
|
||||
* made the choice for itself in a ternary. They now both switch on `kind`.
|
||||
*/
|
||||
|
||||
/** What a forged actor actually is, read off the document rather than off the plan. */
|
||||
export const foeStats = a => ({
|
||||
name: a.name,
|
||||
uuid: a.uuid,
|
||||
atk: a.system?.attack1?.pool ?? null,
|
||||
def: a.system?.defence?.pool ?? null,
|
||||
grit: a.system?.grit?.value ?? null,
|
||||
nerve: a.system?.nerve ?? null,
|
||||
trick: a.system?.trick ?? '',
|
||||
});
|
||||
|
||||
/** One forged foe as a line of stats, in the roster's vocabulary so the two read alike. */
|
||||
export const foeLine = f =>
|
||||
`<b>@UUID[${f.uuid}]{${f.name}}</b> — ${f.atk ?? '?'}/${f.def ?? '?'}/${f.grit ?? '?'}, Nerve ${f.nerve ?? '?'}${f.trick ? `. <i>${f.trick}</i>` : ''}`;
|
||||
|
||||
/**
|
||||
* Decide which of the four cases an area is in.
|
||||
* @param {object} area a delve area
|
||||
* @param {Array} forged actors the Forge created, already through foeStats — [] if it failed
|
||||
*/
|
||||
export function classifyFoes(area, forged = []) {
|
||||
if (!area?.encounter) return { kind: 'none' };
|
||||
const heat = area.encounter.heat;
|
||||
const planned = area.encounter.roster ?? null;
|
||||
if (forged.length) return { kind: 'forged', heat, foes: forged, planned };
|
||||
if (planned) return { kind: 'planned', heat, roster: planned };
|
||||
return { kind: 'unavailable', heat };
|
||||
}
|
||||
@@ -12,39 +12,14 @@
|
||||
*/
|
||||
import { generateDelve } from './core/delve.mjs';
|
||||
import { coinSeed, Rng } from './core/rng.mjs';
|
||||
import { foeStats, classifyFoes } from './foes.mjs';
|
||||
import { initiativeWarning } from './core/roster.mjs';
|
||||
|
||||
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
|
||||
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||
const esc = s => foundry.utils.escapeHTML?.(String(s ?? '')) ?? String(s ?? '');
|
||||
|
||||
const MOD = 'vanity-delve';
|
||||
|
||||
/**
|
||||
* What a forged foe actually is.
|
||||
*
|
||||
* A pack's roster describes the encounter DELVE *planned* — "1 Ghoul and 2 Skeletons", with stats
|
||||
* and, in some themes, "blessed, silvered or magical weapons ONLY". The Forge does not take a
|
||||
* cast: it rolls its own monsters from the heat. So the roster and the actors in the world are two
|
||||
* different lists, and printing the roster's numbers beside the Forge's actors told the GM to run
|
||||
* a fight against foes that were never created.
|
||||
*
|
||||
* Read the numbers off the documents that exist. The plan is still worth showing — it carries
|
||||
* authored tactical guidance — but it has to be labelled as the plan.
|
||||
*/
|
||||
export const foeStats = a => ({
|
||||
name: a.name,
|
||||
uuid: a.uuid,
|
||||
atk: a.system?.attack1?.pool ?? null,
|
||||
def: a.system?.defence?.pool ?? null,
|
||||
grit: a.system?.grit?.value ?? null,
|
||||
nerve: a.system?.nerve ?? null,
|
||||
trick: a.system?.trick ?? '',
|
||||
});
|
||||
|
||||
/** One foe as a line of stats, in the roster's own vocabulary so the two read alike. */
|
||||
export const foeLine = f =>
|
||||
`<b>@UUID[${f.uuid}]{${f.name}}</b> — ${f.atk ?? '?'}/${f.def ?? '?'}/${f.grit ?? '?'}, Nerve ${f.nerve ?? '?'}${f.trick ? `. <i>${f.trick}</i>` : ''}`;
|
||||
|
||||
let THEMES = [{ id: 'barrow', label: 'Barrow' }];
|
||||
|
||||
export function setThemes(list) { THEMES = list; }
|
||||
@@ -184,7 +159,7 @@ export async function raiseDungeon(params = {}) {
|
||||
<span class="delve-sub">${d.areas.length} areas · ${esc(d.params.theme)} · depth ${d.params.depth} · seed ${esc(seed)}</span></header>
|
||||
<p>${scenes.filter(Boolean).length} scenes, ${game.actors.filter(a => a.folder?.id === folders.Actor.id).length} actors and a journal, all in the folder <b>${esc(title)}</b>.</p>
|
||||
<p>@UUID[${journal.uuid}]{Open the adventure}</p>
|
||||
${seams ? '' : '<p><b>⚠ Forge seams missing</b> — update the VANITY system to 0.10.5 for quiet output.</p>'}
|
||||
${seams ? '' : '<p><b>⚠ Forge seams missing</b> — update the VANITY system to 0.10.4 for quiet output.</p>'}
|
||||
</div>`,
|
||||
});
|
||||
|
||||
@@ -193,6 +168,26 @@ export async function raiseDungeon(params = {}) {
|
||||
|
||||
/* ---------------------------------------------------------------- journal */
|
||||
|
||||
/**
|
||||
* The journal's foe section, driven by classifyFoes so it cannot disagree with the chat card.
|
||||
* The roster's tactical notes name particular monsters, so they print only where the plan is
|
||||
* itself the encounter.
|
||||
*/
|
||||
export function foeSection(c) {
|
||||
const table = rows => `<table><thead><tr><th>Foe</th><th>atk</th><th>def</th><th>Grit</th><th>Nerve</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
if (c.kind === 'forged') return `
|
||||
<p><b>${esc(cap(c.heat))} — in the world.</b> These are the actors the Forge created; run the fight off these.</p>
|
||||
${table(c.foes.map(f => `<tr><td>@UUID[${f.uuid}]{${esc(f.name)}}</td><td>${f.atk ?? '?'}</td><td>${f.def ?? '?'}</td><td>${f.grit ?? '?'}</td><td>${f.nerve ?? '?'}</td><td><i>${esc(f.trick)}</i></td></tr>`).join(''))}
|
||||
${c.planned ? `<p><i>DELVE planned ${esc(c.planned.line)}. The Forge rolls its own cast, so the plan's foes are not these — its tactical notes describe monsters that were not created.</i></p>` : ''}`;
|
||||
if (c.kind === 'planned') return `
|
||||
<p><b>${esc(cap(c.heat))} — not cast.</b> Nothing was forged for this area, so the plan is the encounter. Cast it by hand:</p>
|
||||
${table(c.roster.foes.map(f => `<tr><td>${f.n}× ${esc(f.name)}</td><td>${f.atk}</td><td>${f.def}</td><td>${f.grit}</td><td>${f.nerve}</td><td><i>${esc(f.note)}</i></td></tr>`).join(''))}
|
||||
<p>Harmed by ${esc(c.roster.harmedBy)}.${initiativeWarning(c.roster) ? ` <b>${esc(initiativeWarning(c.roster))}</b>` : ''} <i>${esc(c.roster.avoid)}.</i></p>`;
|
||||
if (c.kind === 'unavailable') return `
|
||||
<p><b>${esc(cap(c.heat))} — nothing to run.</b> No actors were created and this theme has no roster at this heat. Improvise the fight or skip it; the area's decision and fallback still stand.</p>`;
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildPages(d, scenes) {
|
||||
const sk = d.skeleton, ap = sk.appeasement;
|
||||
const pages = [];
|
||||
@@ -241,16 +236,7 @@ function buildPages(d, scenes) {
|
||||
${rv.failure ? `<li><b>Miss</b> → ${esc(rv.failure)}</li>` : ''}
|
||||
${rv.orElse ? `<li><b>Or</b> ${esc(rv.orElse)}</li>` : ''}
|
||||
</ul>
|
||||
${a._foes?.length ? `<p><b>${esc(cap(a.encounter.heat))} — in the world.</b> These are the actors the Forge created; run the fight off these.</p>
|
||||
<table><thead><tr><th>Foe</th><th>atk</th><th>def</th><th>Grit</th><th>Nerve</th><th></th></tr></thead><tbody>
|
||||
${a._foes.map(f => `<tr><td>@UUID[${f.uuid}]{${esc(f.name)}}</td><td>${f.atk ?? '?'}</td><td>${f.def ?? '?'}</td><td>${f.grit ?? '?'}</td><td>${f.nerve ?? '?'}</td><td><i>${esc(f.trick)}</i></td></tr>`).join('')}
|
||||
</tbody></table>
|
||||
${R ? `<p><i>DELVE planned ${esc(R.line)}. The Forge rolls its own cast, so the plan's foes are not these — its tactical notes describe monsters that were not created.</i></p>` : ''}`
|
||||
: R ? `<p><b>${esc(cap(a.encounter.heat))} — not cast.</b> Nothing was forged for this area, so the plan is the encounter. Cast it by hand:</p>
|
||||
<table><thead><tr><th>Foe</th><th>atk</th><th>def</th><th>Grit</th><th>Nerve</th><th></th></tr></thead><tbody>
|
||||
${R.foes.map(f => `<tr><td>${f.n}× ${esc(f.name)}</td><td>${f.atk}</td><td>${f.def}</td><td>${f.grit}</td><td>${f.nerve}</td><td><i>${esc(f.note)}</i></td></tr>`).join('')}
|
||||
</tbody></table>
|
||||
<p>Harmed by ${esc(R.harmedBy)}${R.harmedBy.includes('ONLY') ? ' — <b>say so before initiative</b>' : ''}. <i>${esc(R.avoid)}.</i></p>` : ''}
|
||||
${foeSection(classifyFoes(a, a._foes ?? []))}
|
||||
${a.temptation ? `<p><b>${esc(cap(a.temptation.id))}</b> — ${esc(a.temptation.cue)}: ${esc(a.temptation.benefit)}.<br>
|
||||
<i>Using it costs ${a.temptation.useCost?.bane ? `+${a.temptation.useCost.bane} Bane` : '—'}. While carried, ${esc(a.temptation.standingDrawback)}.</i></p>` : ''}
|
||||
${a._hoard?.length ? `<p><b>Hoard.</b></p><ul>${a._hoard.map(l => `<li>${l}</li>`).join('')}</ul>` : ''}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Raising one area, as a sequence of decisions rather than a sequence of Foundry calls.
|
||||
*
|
||||
* Every effect arrives as an injected function, so the whole thing runs under plain node and its
|
||||
* failure paths can be tested. That is the point: three releases in a row shipped a bug on a path
|
||||
* that only runs when the Forge misbehaves, and reasoning about those paths turned out not to be
|
||||
* evidence.
|
||||
*
|
||||
* The contract, which the tests hold:
|
||||
*
|
||||
* The scene is the commit point. If it fails, nothing was entered — no cards, no advance, and a
|
||||
* retry is safe. Once it exists the area IS entered, so the turn advances immediately and
|
||||
* everything after it degrades loudly rather than rolling back. A retry after a later failure
|
||||
* must not raise the same scene twice.
|
||||
*
|
||||
* Every degradation says what the GM has lost. Silence is what made the old hoard failure
|
||||
* invisible and the old population failure lie about a roster that did not exist.
|
||||
*/
|
||||
import { foeStats, foeLine, classifyFoes } from './foes.mjs';
|
||||
import { initiativeWarning } from './core/roster.mjs';
|
||||
|
||||
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||
const list = items => `<ul>${items.filter(Boolean).map(i => `<li>${i}</li>`).join('')}</ul>`;
|
||||
|
||||
/**
|
||||
* Run one injected effect, surviving both a rejected promise and a synchronous throw.
|
||||
* `Promise.resolve(fn())` is not enough: a synchronous throw escapes before there is a promise to
|
||||
* attach a catch to, which is how the first version of this file crashed the whole of enter().
|
||||
*/
|
||||
const attempt = async (fx, fn) => {
|
||||
try { return { ok: true, value: await fn() }; }
|
||||
catch (e) { fx.log?.(e); return { ok: false, value: null }; }
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap an async function so overlapping calls are refused rather than interleaved.
|
||||
*
|
||||
* enter() hangs off a button, and it awaits the Forge for seconds at a time. Two overlapping calls
|
||||
* both read the same index and stage the same area twice. This closes that within one client; two
|
||||
* separate GM browsers still race, and the durable fix for that is a world-level lock rather than
|
||||
* a module variable.
|
||||
*/
|
||||
export function singleFlight(fn, onBusy) {
|
||||
let busy = false;
|
||||
return async (...args) => {
|
||||
if (busy) return onBusy?.();
|
||||
busy = true;
|
||||
try { return await fn(...args); } finally { busy = false; }
|
||||
};
|
||||
}
|
||||
|
||||
/** The foe section of the GM card. Mirrors forge-app's foeSection; both switch on the same kind. */
|
||||
export function foeBlock(c) {
|
||||
if (c.kind === 'forged') return `<p><b>${cap(c.heat)} — in the world:</b></p>${list(c.foes.map(foeLine))}${
|
||||
c.planned ? `<p><i>DELVE planned ${c.planned.line}; the Forge rolled its own, so the plan's tactics do not describe these.</i></p>` : ''}`;
|
||||
if (c.kind === 'planned') return `<p><b>${cap(c.heat)} — not cast.</b> Nothing was forged; run the plan by hand:</p>${
|
||||
list(c.roster.foes.map(f => `${f.n}× <b>${f.name}</b> — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. <i>${f.note}</i>`))
|
||||
}<p><b>Harmed by ${c.roster.harmedBy}.</b>${initiativeWarning(c.roster) ? ` <b>${initiativeWarning(c.roster)}</b>` : ''} ${c.roster.avoid}.</p>`;
|
||||
if (c.kind === 'unavailable') return `<p><b>${cap(c.heat)} — nothing to run.</b> No actors, and no roster at this heat. Improvise or skip; the decision and fallback still stand.</p>`;
|
||||
return '';
|
||||
}
|
||||
|
||||
/** The two cards an area posts: the players' read-aloud, and the GM's card. */
|
||||
export function areaCards({ area, name, authored = {}, classification }) {
|
||||
const rv = area.decision?.resolve ?? {};
|
||||
return {
|
||||
readAloud: authored.readAloud ?? `<i>(unwritten)</i> ${area.cueFragments.join('. ')}.`,
|
||||
gm: {
|
||||
label: `⛏ ${area.index} · ${name}`,
|
||||
sub: `${area.role} · ${area.facet}`,
|
||||
body: `${area.situation ? `<p><b>Here:</b> ${cap(area.situation.occupant)}, ${area.situation.doing} — ${area.situation.onArrival}.<br>
|
||||
<b>They can:</b> ${area.situation.offer}. <i>${cap(area.situation.because)}.</i></p>` : ''}
|
||||
<p><b>${cap(area.decision.cue)}</b>${rv.roll ? ` — [${rv.roll}] ${rv.success}` : ''}${rv.failure ? `<br><b>Miss:</b> ${rv.failure}` : ''}${rv.orElse ? `<br><b>Or:</b> ${rv.orElse}` : ''}</p>
|
||||
${foeBlock(classification)}
|
||||
${area.temptation ? `<p><b>${cap(area.temptation.id)}:</b> ${area.temptation.benefit}. <i>Use: ${area.temptation.useCost?.bane ? `+${area.temptation.useCost.bane} Bane` : '—'}. ${area.temptation.standingDrawback}.</i></p>` : ''}
|
||||
${authored.notes ? `<p><b>Your note:</b> ${authored.notes}</p>` : ''}
|
||||
<p><code>${area.trigger}${area.baneBeat ? ` · ${area.baneBeat}` : ''} · fallback: ${area.fallback.route}</code></p>`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} fx injected effects: stage, encounter, hoard, commit, readAloud, gm, warn, error
|
||||
* @param {object} ctx { area, name, authored }
|
||||
* @returns {{entered: boolean, failed?: string, kind?: string}}
|
||||
*/
|
||||
export async function stageArea(fx, { area, name, authored = {} }) {
|
||||
const scene = await attempt(fx, () => fx.stage(name));
|
||||
if (!scene.ok || !scene.value) {
|
||||
fx.error(`DELVE: the Forge could not raise ${name}. Nothing staged; try again.`);
|
||||
return { entered: false, failed: 'stage' };
|
||||
}
|
||||
|
||||
// The scene exists, so this area has been entered. Commit before anything that may fail, or a
|
||||
// retry restages it. If the commit itself fails the scene is still real and the delve is still
|
||||
// playable, but the index no longer matches the world — say so, because the next press of ⏩
|
||||
// would raise this area a second time.
|
||||
const committed = await attempt(fx, () => fx.commit());
|
||||
if (!committed.ok) fx.warn(`DELVE: ${name} is staged, but the delve's progress could not be saved. Pressing ⏩ again will raise it a second time — reload and check before continuing.`);
|
||||
|
||||
let forged = [];
|
||||
if (area.encounter) {
|
||||
const enc = await attempt(fx, () => fx.encounter(area.encounter.heat));
|
||||
if (enc.ok && enc.value) forged = (enc.value.actors ?? []).map(foeStats);
|
||||
else fx.warn(area.encounter.roster
|
||||
? `DELVE: could not populate ${name} — the planned roster stands in.`
|
||||
: `DELVE: could not populate ${name}, and this theme has no roster at ${area.encounter.heat}. Improvise the fight or skip it.`);
|
||||
}
|
||||
|
||||
if (area.hoard) {
|
||||
const h = await attempt(fx, () => fx.hoard(area.hoard));
|
||||
if (!h.ok || !h.value) fx.warn(`DELVE: could not lay the ${area.hoard} hoard in ${name} — improvise it or skip it.`);
|
||||
}
|
||||
|
||||
const classification = classifyFoes(area, forged);
|
||||
const cards = areaCards({ area, name, authored, classification });
|
||||
|
||||
// Players first — the scene is up and this is what they came for.
|
||||
const said = (await attempt(fx, () => fx.readAloud(name, cards.readAloud))).ok;
|
||||
const told = (await attempt(fx, () => fx.gm(cards.gm.label, cards.gm.sub, cards.gm.body))).ok;
|
||||
if (!said || !told) fx.warn(`DELVE: ${name} is staged but its ${!said && !told ? 'cards' : !said ? 'read-aloud' : 'GM card'} did not post. The turn has advanced; read from the worksheet.`);
|
||||
|
||||
return { entered: true, committed: committed.ok, kind: classification.kind };
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
*/
|
||||
import { coinSeed, Rng } from './core/rng.mjs';
|
||||
import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs';
|
||||
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes, foeStats, foeLine } from './forge-app.mjs';
|
||||
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes } from './forge-app.mjs';
|
||||
import { stageArea, singleFlight } from './stage.mjs';
|
||||
|
||||
const MOD = 'vanity-delve';
|
||||
const FLAG = 'state';
|
||||
@@ -25,6 +26,9 @@ let PACK = null;
|
||||
let seamsPresent = false;
|
||||
let loadPack = async () => null;
|
||||
|
||||
/** The geometries VANITY's Forge can actually build — mirrors validate-pack.mjs. */
|
||||
const GEOMETRIES = ['barrow', 'cave', 'fen', 'village', 'forest'];
|
||||
|
||||
const getState = () => game.settings.get(MOD, FLAG) ?? null;
|
||||
const setState = async s => game.settings.set(MOD, FLAG, s);
|
||||
|
||||
@@ -42,9 +46,10 @@ const setState = async s => game.settings.set(MOD, FLAG, s);
|
||||
*/
|
||||
async function packById(id) {
|
||||
if (!id) return null;
|
||||
if (PACK?.id === id) return PACK;
|
||||
const usable = p => GEOMETRIES.includes(p?.forgeStageType);
|
||||
if (PACK?.id === id && usable(PACK)) return PACK; // validate the cache too
|
||||
const p = await loadPack(id);
|
||||
if (!p?.forgeStageType) return null;
|
||||
if (!usable(p)) return null;
|
||||
PACK = p;
|
||||
return p;
|
||||
}
|
||||
@@ -60,9 +65,6 @@ async function packFor(d) {
|
||||
if (!p) ui.notifications.error(`DELVE: could not load the ${id} theme — refusing to stage, it would use the wrong geometry.`);
|
||||
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' },
|
||||
@@ -112,12 +114,17 @@ async function draft(params = {}) {
|
||||
const theme = params.theme ?? 'barrow'; // drafting picks a theme; staging must be told one
|
||||
const pack = await packById(theme);
|
||||
if (!pack) return ui.notifications.error(`DELVE: could not load the ${theme} theme.`);
|
||||
const d = newWorkingFile({ pack, ...params, seed });
|
||||
const d = newWorkingFile({ ...params, seed, pack }); // pack last — see raiseDungeon
|
||||
ui.notifications.warn('DELVE: unfinished draft. Write the read-aloud in the worksheet first.');
|
||||
return load(d);
|
||||
}
|
||||
|
||||
async function enter() {
|
||||
const enter = singleFlight(
|
||||
() => enterOnce(),
|
||||
() => ui.notifications.warn('DELVE: already raising an area — wait for it to finish.'),
|
||||
);
|
||||
|
||||
async function enterOnce() {
|
||||
const st = getState();
|
||||
if (!st) return ui.notifications.warn('DELVE: nothing loaded.');
|
||||
const d = st.delve;
|
||||
@@ -130,50 +137,27 @@ async function enter() {
|
||||
if (!pack) return;
|
||||
ui.notifications.info(`DELVE: raising ${name}…`);
|
||||
|
||||
// The decisions live in stage.mjs so their failure paths can be tested; this is only the wiring.
|
||||
const quiet = seamsPresent ? { post: false, folderId: st.folderId } : {};
|
||||
const stage = await game.vanity.forge.stage({
|
||||
type: pack.forgeStageType, size: 'medium', name, populate: false, activate: true, ...quiet,
|
||||
});
|
||||
const enc = area.encounter ? await game.vanity.forge.encounter({
|
||||
heat: area.encounter.heat, forStage: name,
|
||||
...(seamsPresent ? { hoard: false, post: false, folderId: st.folderId } : {}),
|
||||
}) : null;
|
||||
if (area.hoard) await game.vanity.forge.hoard({ size: area.hoard, ...(seamsPresent ? { post: false } : {}) });
|
||||
|
||||
// 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 R = area.encounter?.roster;
|
||||
const foes = (enc?.actors ?? []).map(foeStats);
|
||||
const rv = area.decision?.resolve ?? {};
|
||||
|
||||
/**
|
||||
* One rule, both surfaces: show what exists. The roster's tactical guidance — what harms it, how
|
||||
* to avoid it — describes the planned foes, so it travels with the plan and only when the plan
|
||||
* IS the encounter. With nothing forged (populate off, or the Forge failed) the plan is all
|
||||
* there is, and it must stay runnable.
|
||||
*/
|
||||
const foeBlock = foes.length
|
||||
? `<p><b>${cap(area.encounter.heat)} — in the world:</b></p>${list(foes.map(foeLine))}${
|
||||
R ? `<p><i>DELVE planned ${R.line}; the Forge rolled its own, so the plan's tactics do not describe these.</i></p>` : ''}`
|
||||
: R
|
||||
? `<p><b>${cap(area.encounter.heat)} — not cast.</b> Nothing was forged; run the plan by hand:</p>${
|
||||
list(R.foes.map(f => `${f.n}× <b>${f.name}</b> — ${f.atk}/${f.def}/${f.grit}, Nerve ${f.nerve}. <i>${f.note}</i>`))
|
||||
}<p><b>Harmed by ${R.harmedBy}.</b> ${R.avoid}.</p>`
|
||||
: '';
|
||||
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 } : {}) }),
|
||||
// An incremented copy, never a mutation of st: a failed write must leave the in-memory state
|
||||
// exactly as it was, or the retry advice in the warning is wrong.
|
||||
commit: () => setState({ ...st, at: st.at + 1, turn: st.turn + 1 }),
|
||||
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() {
|
||||
@@ -218,7 +202,7 @@ Hooks.once('ready', async () => {
|
||||
if (index?.themes?.length) setThemes(index.themes);
|
||||
const packs = {};
|
||||
loadPack = async id => (packs[id] ??= await fetchJson(`${base}/${id}.json`));
|
||||
PACK = await loadPack('barrow');
|
||||
await packById('barrow'); // sets PACK, and validates it like any other
|
||||
seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? ''));
|
||||
|
||||
game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon,
|
||||
@@ -226,7 +210,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.');
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Adapter tests. No Foundry, no shim — foes.mjs and stage.mjs are pure on purpose.
|
||||
*
|
||||
* These exist because the same area of code broke in three consecutive releases, every time on a
|
||||
* path that only runs when something misbehaves, and every time I convinced myself by reading it.
|
||||
* Each case below is a bug that shipped or nearly shipped.
|
||||
*
|
||||
* Run: node foundry-module/test.mjs
|
||||
*/
|
||||
import { foeStats, foeLine, classifyFoes } from './module/foes.mjs';
|
||||
import { stageArea, areaCards, foeBlock, singleFlight } from './module/stage.mjs';
|
||||
import { initiativeWarning } from '../core/roster.mjs';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const t = (name, cond, detail = '') => { cond ? pass++ : fail++; console.log(`${cond ? ' ok ' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); };
|
||||
|
||||
const actor = {
|
||||
name: 'Morthollow-born', uuid: 'Actor.abc',
|
||||
system: { attack1: { pool: 4 }, defence: { pool: 3 }, grit: { value: 5 }, nerve: 6, trick: 'freezes on a hit' },
|
||||
};
|
||||
const roster = {
|
||||
line: '1 Ghoul and 2 Skeletons',
|
||||
foes: [{ n: 1, name: 'Ghoul', atk: 4, def: 3, grit: 4, nerve: 5, note: 'freezes' }],
|
||||
harmedBy: 'blessed, silvered or magical weapons ONLY',
|
||||
beforeInitiative: 'Say so before initiative.',
|
||||
avoid: 'send someone plain',
|
||||
};
|
||||
const area = (extra = {}) => ({
|
||||
index: 2, role: 'complication', facet: 'ritual', name: 'The Sail Loft',
|
||||
cueFragments: ['a tide glass turned by the watch'],
|
||||
decision: { cue: 'a bell rope still swinging', resolve: { roll: 'Poise 2', success: 'it comes to order', failure: 'the harbour turns early', orElse: 'leave it' } },
|
||||
trigger: 'seenTwice', fallback: { route: 'the dead will receive a guest' },
|
||||
...extra,
|
||||
});
|
||||
const withEnc = (r = roster) => area({ encounter: { heat: 'fight', roster: r } });
|
||||
|
||||
// ---------------------------------------------------------------- reading actors
|
||||
const f = foeStats(actor);
|
||||
t('foeStats reads stats off the document', f.atk === 4 && f.def === 3 && f.grit === 5 && f.nerve === 6);
|
||||
t('foeStats keeps the uuid so the GM can open it', f.uuid === 'Actor.abc');
|
||||
const bare = foeStats({ name: 'X', uuid: 'Actor.z', system: {} });
|
||||
t('a missing stat degrades rather than throwing', bare.atk === null && foeLine(bare).includes('?/?/?'));
|
||||
t('foeLine links the actor', foeLine(f).includes('@UUID[Actor.abc]{Morthollow-born}'));
|
||||
|
||||
// ---------------------------------------------------------------- the four kinds
|
||||
t('no encounter → none', classifyFoes(area(), []).kind === 'none');
|
||||
t('actors forged → forged', classifyFoes(withEnc(), [f]).kind === 'forged');
|
||||
t('nothing forged but a roster → planned', classifyFoes(withEnc(), []).kind === 'planned',
|
||||
'the 0.6.2 regression: population off left no numbers at all');
|
||||
t('heat but neither → unavailable', classifyFoes(area({ encounter: { heat: 'fight' } }), []).kind === 'unavailable');
|
||||
t('a forged classification exposes no roster to render from', classifyFoes(withEnc(), [f]).roster === undefined);
|
||||
|
||||
// ---------------------------------------------------------------- what reaches the card
|
||||
const forgedBlock = foeBlock(classifyFoes(withEnc(), [f]));
|
||||
t('forged block runs off the actors', forgedBlock.includes('Morthollow-born') && forgedBlock.includes('in the world'));
|
||||
t('forged block never asserts the plan\'s tactics', !forgedBlock.includes('blessed, silvered'),
|
||||
'roster guidance names monsters the Forge did not create');
|
||||
t('forged block still names the plan', forgedBlock.includes('1 Ghoul and 2 Skeletons'));
|
||||
|
||||
const plannedBlock = foeBlock(classifyFoes(withEnc(), []));
|
||||
t('planned block is runnable', plannedBlock.includes('Ghoul') && plannedBlock.includes('4/3/4'));
|
||||
t('planned block carries the immunity', plannedBlock.includes('blessed, silvered'));
|
||||
t('planned block warns before initiative', plannedBlock.includes('Say so before initiative.'),
|
||||
'the 0.6.4 regression: deleting the ONLY gate stripped this from 12 rosters');
|
||||
t('the warning is said exactly once',
|
||||
(plannedBlock.match(/say so before initiative/gi) ?? []).length === 1);
|
||||
t('unavailable says so rather than rendering nothing',
|
||||
foeBlock(classifyFoes(area({ encounter: { heat: 'fight' } }), [])).includes('nothing to run'));
|
||||
|
||||
const cards = areaCards({ area: withEnc(), name: 'The Sail Loft', authored: {}, classification: classifyFoes(withEnc(), [f]) });
|
||||
t('an unwritten read-aloud is marked as such', cards.readAloud.includes('(unwritten)'));
|
||||
t('written prose outranks the fragments',
|
||||
areaCards({ area: withEnc(), name: 'x', authored: { readAloud: 'They have been waiting.' }, classification: classifyFoes(withEnc(), [f]) })
|
||||
.readAloud === 'They have been waiting.');
|
||||
t('the GM card carries the decision and its miss', cards.gm.body.includes('Poise 2') && cards.gm.body.includes('Miss:'));
|
||||
|
||||
// ---------------------------------------------------------------- the failure matrix
|
||||
const spy = (over = {}) => {
|
||||
const log = { calls: [], warns: [], errors: [], committed: 0, cards: 0 };
|
||||
const fx = {
|
||||
stage: n => { log.calls.push('stage'); return { id: 'scn1', name: n }; },
|
||||
encounter: () => { log.calls.push('encounter'); return { actors: [actor] }; },
|
||||
hoard: () => { log.calls.push('hoard'); return { size: 'cache' }; },
|
||||
commit: async () => { log.committed++; },
|
||||
readAloud: () => { log.cards++; },
|
||||
gm: () => { log.cards++; },
|
||||
warn: m => log.warns.push(m),
|
||||
error: m => log.errors.push(m),
|
||||
...over,
|
||||
};
|
||||
return { fx, log };
|
||||
};
|
||||
const boom = () => { throw new Error('forge exploded'); };
|
||||
const ctx = (a = withEnc()) => ({ area: { ...a, hoard: 'cache' }, name: 'The Sail Loft', authored: {} });
|
||||
|
||||
{
|
||||
const { fx, log } = spy();
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('happy path enters, commits once and posts both cards',
|
||||
r.entered && log.committed === 1 && log.cards === 2 && !log.warns.length && !log.errors.length);
|
||||
t('happy path reports the kind it rendered', r.kind === 'forged');
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ stage: boom });
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('a failed scene enters nothing', !r.entered && r.failed === 'stage');
|
||||
t('a failed scene does not commit — a retry is safe', log.committed === 0,
|
||||
'the v0.6.3 bug: scene left behind, index unchanged, retry duplicated it');
|
||||
t('a failed scene posts no cards and says why', log.cards === 0 && log.errors.length === 1);
|
||||
t('a failed scene never calls the Forge again', !log.calls.includes('encounter'));
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ encounter: boom });
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('a failed population still enters — the scene is up', r.entered && log.committed === 1);
|
||||
t('a failed population falls back to the plan', r.kind === 'planned');
|
||||
t('a failed population says the roster stands in', log.warns.some(w => /planned roster stands in/.test(w)));
|
||||
t('a failed population still posts both cards', log.cards === 2);
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ encounter: boom });
|
||||
const r = await stageArea(fx, ctx(area({ encounter: { heat: 'fight' } })));
|
||||
t('a failed population with no roster does not claim one', !log.warns.some(w => /roster stands in/.test(w)),
|
||||
'the 0.6.4 regression: it promised a roster that did not exist');
|
||||
t('a failed population with no roster says what is actually lost',
|
||||
log.warns.some(w => /no roster at fight/.test(w)) && r.kind === 'unavailable');
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ hoard: boom });
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('a failed hoard still enters', r.entered && log.committed === 1);
|
||||
t('a failed hoard is visible to the GM', log.warns.some(w => /hoard/.test(w)),
|
||||
'the 0.6.4 regression: swallowed to console while the turn advanced');
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ gm: boom });
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('a failed GM card does not roll back the turn', r.entered && log.committed === 1);
|
||||
t('a failed GM card tells the GM to read from the worksheet',
|
||||
log.warns.some(w => /GM card did not post/.test(w)));
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ readAloud: boom, gm: boom });
|
||||
await stageArea(fx, ctx());
|
||||
t('both cards failing reports once, not twice', log.warns.filter(w => /did not post/.test(w)).length === 1);
|
||||
}
|
||||
{
|
||||
// async rejections, not just throws — the Forge returns promises
|
||||
const { fx, log } = spy({ encounter: () => Promise.reject(new Error('async')) });
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('a rejected promise is handled like a throw', r.entered && r.kind === 'planned' && log.warns.length === 1);
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ stage: () => null });
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('a Forge that returns nothing counts as a failed scene', !r.entered && log.committed === 0);
|
||||
}
|
||||
{
|
||||
const { fx, log } = spy({ commit: () => { throw new Error('settings write failed'); } });
|
||||
const r = await stageArea(fx, ctx());
|
||||
t('a failed commit does not take down staging', r.entered && r.committed === false);
|
||||
t('a failed commit warns that a retry will restage',
|
||||
log.warns.some(w => /raise it a second time/.test(w)),
|
||||
'the scene is real but the index does not know it');
|
||||
t('a failed commit still posts the cards', log.cards === 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- the warning
|
||||
t('the field is used when present', initiativeWarning({ harmedBy: 'anything', beforeInitiative: 'Say so.' }) === 'Say so.');
|
||||
t('a plain roster needs no warning', initiativeWarning({ harmedBy: 'anything' }) === null);
|
||||
t('a legacy ONLY roster still gets one', initiativeWarning({ harmedBy: 'blessed weapons ONLY' }) === 'Say so before initiative.',
|
||||
'delve files generated before the field existed must not lose it');
|
||||
t('a legacy Wraith roster spelled without ONLY still gets one',
|
||||
initiativeWarning({ harmedBy: 'blessed, silvered or magical weapons for the Wraith; anything for the rest' }) !== null,
|
||||
'the spelling was never the semantics — barrow/nightmare had no ONLY');
|
||||
t('a legacy roster that says it in prose is not given it twice',
|
||||
initiativeWarning({ harmedBy: 'blessed weapons ONLY — say so before initiative' }) === null);
|
||||
t('prose in avoid also suppresses the fallback',
|
||||
initiativeWarning({ harmedBy: 'blessed weapons ONLY', avoid: 'burn it — say so before initiative' }) === null);
|
||||
t('the field never overrides prose that already says it',
|
||||
initiativeWarning({ harmedBy: 'blessed weapons ONLY — say so before initiative', beforeInitiative: 'Say so before initiative.' }) === null,
|
||||
'a hand-edited file can carry both; returning the field first printed it twice');
|
||||
t('null roster is safe', initiativeWarning(null) === null);
|
||||
|
||||
// ---------------------------------------------------------------- single flight
|
||||
{
|
||||
let running = 0, peak = 0, refused = 0;
|
||||
const guarded = singleFlight(async () => {
|
||||
running++; peak = Math.max(peak, running);
|
||||
await new Promise(r => setTimeout(r, 5));
|
||||
running--; return 'done';
|
||||
}, () => { refused++; return 'busy'; });
|
||||
const [a, b] = await Promise.all([guarded(), guarded()]);
|
||||
t('two overlapping calls never run together', peak === 1, `peak concurrency ${peak}`);
|
||||
t('the second is refused, not queued', a === 'done' && b === 'busy' && refused === 1);
|
||||
t('the guard clears so a later call still runs', (await guarded()) === 'done');
|
||||
}
|
||||
{
|
||||
const guarded = singleFlight(async () => { throw new Error('boom'); }, () => 'busy');
|
||||
await guarded().catch(() => {});
|
||||
t('a throw does not leave the guard stuck', (await guarded().catch(() => 'threw')) === 'threw');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- surface parity
|
||||
// forge-app destructures three Foundry symbols at import time. Everything under test is pure, so
|
||||
// the smallest possible shim makes the journal renderer importable and its parity with the chat
|
||||
// renderer checkable — the two drifted apart twice before they shared a classifier.
|
||||
{
|
||||
globalThis.foundry ??= {
|
||||
applications: { api: { ApplicationV2: class {}, HandlebarsApplicationMixin: c => c } },
|
||||
utils: { escapeHTML: s => String(s ?? '') },
|
||||
};
|
||||
const { foeSection } = await import('./module/forge-app.mjs').catch(() => ({ foeSection: null }));
|
||||
if (!foeSection) { t('journal renderer importable for parity checks', false, 'needs a foundry shim'); }
|
||||
else for (const [kind, c] of [
|
||||
['forged', classifyFoes(withEnc(), [f])],
|
||||
['planned', classifyFoes(withEnc(), [])],
|
||||
['unavailable', classifyFoes(area({ encounter: { heat: 'fight' } }), [])],
|
||||
['none', classifyFoes(area(), [])],
|
||||
]) {
|
||||
const chat = foeBlock(c), journal = foeSection(c);
|
||||
t(`both surfaces agree on ${kind}: both render or both stay silent`,
|
||||
(chat.trim() === '') === (journal.trim() === ''));
|
||||
if (kind === 'planned') t('both surfaces carry the warning on planned',
|
||||
/say so before initiative/i.test(chat) && /say so before initiative/i.test(journal));
|
||||
if (kind === 'forged') t('neither surface asserts the plan on forged',
|
||||
!/blessed, silvered/.test(chat) && !/blessed, silvered/.test(journal));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${pass} passed, ${fail} failed`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
Reference in New Issue
Block a user