Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4990533a8 | ||
|
|
075be5c1de | ||
|
|
e2d1de2fb8 | ||
|
|
592d540910 |
@@ -0,0 +1,11 @@
|
||||
/** Regenerate content/index.json — the browser cannot list a directory, so packs need a manifest. */
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
const dir = join(dirname(fileURLToPath(import.meta.url)), 'content');
|
||||
const themes = readdirSync(dir).filter(f => f.endsWith('.json') && f !== 'index.json')
|
||||
.map(f => JSON.parse(readFileSync(join(dir, f), 'utf8')))
|
||||
.map(p => ({ id: p.id, label: p.label ?? p.id, geometry: p.forgeStageType, motifs: Object.keys(p.motifs ?? {}).length }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
writeFileSync(join(dir, 'index.json'), JSON.stringify({ schema: 1, themes }, null, 2));
|
||||
console.log(` ${themes.length} themes: ${themes.map(t => t.id).join(', ')}`);
|
||||
+8
-1
@@ -24,7 +24,14 @@ const args = Object.fromEntries(process.argv.slice(2).map(a => {
|
||||
return [k, v === '' ? true : (/^\d+$/.test(v) ? Number(v) : v)];
|
||||
}));
|
||||
|
||||
const pack = JSON.parse(readFileSync(join(here, 'content', `${args.theme ?? 'barrow'}.json`), 'utf8'));
|
||||
const index = JSON.parse(readFileSync(join(here, 'content', 'index.json'), 'utf8'));
|
||||
if (args.themes) { console.log(index.themes.map(t => `${t.id.padEnd(10)} ${t.geometry.padEnd(8)} ${t.motifs} motifs`).join('\n')); process.exit(0); }
|
||||
const themeId = args.theme ?? 'barrow';
|
||||
if (!index.themes.some(t => t.id === themeId)) {
|
||||
console.error(`unknown theme "${themeId}" — available: ${index.themes.map(t => t.id).join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const pack = JSON.parse(readFileSync(join(here, 'content', `${themeId}.json`), 'utf8'));
|
||||
const file = args.file;
|
||||
const load = () => JSON.parse(readFileSync(file, 'utf8'));
|
||||
const save = d => writeFileSync(file, JSON.stringify(d, null, 2));
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"How do I look?\""
|
||||
},
|
||||
{
|
||||
"id": "remembered",
|
||||
@@ -47,7 +48,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"Say my name.\""
|
||||
},
|
||||
{
|
||||
"id": "obeyed",
|
||||
@@ -69,7 +71,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"Do as you are told.\""
|
||||
},
|
||||
{
|
||||
"id": "young",
|
||||
@@ -91,7 +94,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"Tell me I have not changed.\""
|
||||
},
|
||||
{
|
||||
"id": "envied",
|
||||
@@ -113,7 +117,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"What did you bring me?\""
|
||||
},
|
||||
{
|
||||
"id": "attended",
|
||||
@@ -135,7 +140,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"You are not leaving.\""
|
||||
},
|
||||
{
|
||||
"id": "forgiven",
|
||||
@@ -157,7 +163,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"Tell me it was not a sin.\""
|
||||
},
|
||||
{
|
||||
"id": "first",
|
||||
@@ -179,7 +186,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"Who goes first?\""
|
||||
}
|
||||
],
|
||||
"accommodations": [
|
||||
@@ -2146,5 +2154,6 @@
|
||||
"lingering": "stayed in one place longer than it takes to search it",
|
||||
"disturbed": "struck or moved something that had not moved in centuries"
|
||||
},
|
||||
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>."
|
||||
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>.",
|
||||
"placeNameStyle": "claimant"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"themes": [
|
||||
{
|
||||
"id": "barrow",
|
||||
"label": "Barrow",
|
||||
"geometry": "barrow",
|
||||
"motifs": 8
|
||||
},
|
||||
{
|
||||
"id": "castle",
|
||||
"label": "Castle",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "church",
|
||||
"label": "Church",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "forest",
|
||||
"label": "Forest",
|
||||
"geometry": "forest",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "palace",
|
||||
"label": "Palace",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "temple",
|
||||
"label": "Temple",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "town",
|
||||
"label": "Town",
|
||||
"geometry": "village",
|
||||
"motifs": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+9
-14
@@ -103,7 +103,7 @@ export function generateDelve(params = {}) {
|
||||
...plan.ending,
|
||||
name: (pack.areaNames?.ending ?? ['The Ending'])[0],
|
||||
cueFragments: [endFs?.fragment].filter(Boolean),
|
||||
question: endingQuestion(skeleton),
|
||||
question: endingQuestion(skeleton, pack),
|
||||
authored: plan.ending.mode === 'authored',
|
||||
},
|
||||
triggerGlossary: pack.triggerGlossary ?? {},
|
||||
@@ -112,17 +112,12 @@ export function generateDelve(params = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The bottom problem asks one thing. It is the same question the whole delve has been about. */
|
||||
function endingQuestion(sk) {
|
||||
const q = {
|
||||
seen: '"How do I look?"',
|
||||
remembered: '"Say my name."',
|
||||
obeyed: '"Do as you are told."',
|
||||
young: '"Tell me I have not changed."',
|
||||
envied: '"What did you bring me?"',
|
||||
attended: '"You are not leaving."',
|
||||
forgiven: '"Tell me it was not a sin."',
|
||||
first: '"Who goes first?"',
|
||||
};
|
||||
return q[sk.knot.appetiteId] ?? '"Well?"';
|
||||
/**
|
||||
* The bottom problem asks one thing, and it is the same question the whole delve has been about.
|
||||
* It lives on the appetite in the theme pack — hardcoding it here meant any theme whose appetite
|
||||
* ids differed from barrow's silently fell through to a generic line.
|
||||
*/
|
||||
function endingQuestion(sk, pack) {
|
||||
const appetite = pack?.appetites?.find(a => a.id === sk.knot.appetiteId);
|
||||
return appetite?.asks ?? '"Well?"';
|
||||
}
|
||||
|
||||
+6
-2
@@ -64,6 +64,7 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
|
||||
const transgression =
|
||||
`The ${claimant} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
|
||||
`so that ${appetite.purpose}.`;
|
||||
// The bottom problem is the claimant itself — a person in a tomb, an office in a town.
|
||||
|
||||
const bottomProblem = {
|
||||
label: `The ${claimant}`,
|
||||
@@ -79,8 +80,11 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
|
||||
};
|
||||
const secondary = r.derive('faction2').pick(pack.factions.filter(f => f.id !== primary.id));
|
||||
|
||||
// Named for its occupant. Drawing the name separately produced tombs named after strangers.
|
||||
const placeName = `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`;
|
||||
// A barrow or keep is named for its occupant — drawing that separately produced tombs named
|
||||
// after strangers. A settlement is not: it has its own name and the claimant merely runs it.
|
||||
const placeName = pack.placeNameStyle === 'own'
|
||||
? `${r.derive('place1').pick(pack.placeNames.first)} ${r.derive('place2').pick(pack.placeNames.second)}`
|
||||
: `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`;
|
||||
|
||||
// ---- the foreshadow chain ----------------------------------------------
|
||||
const progression = pack.facetProgression ?? {};
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Theme pack validator.
|
||||
*
|
||||
* A pack is ~136 authored entries and every structural rule in it was learned the hard way:
|
||||
* situations that render as broken English, decisions with no way through, motifs too thin to
|
||||
* fill six areas without repeating. Rather than rediscover those per theme, this checks them.
|
||||
*
|
||||
* Run: node validate-pack.mjs [name ...] (default: every pack in content/)
|
||||
*/
|
||||
import { readFileSync, readdirSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const FACETS = ['institution', 'ritual', 'demand', 'wound', 'anchor'];
|
||||
const ROLES = ['approach', 'complication', 'turn', 'descent', 'threshold', 'ending'];
|
||||
const GEOMETRIES = ['barrow', 'cave', 'fen', 'village', 'forest'];
|
||||
const HEATS = ['skirmish', 'fight', 'battle', 'nightmare'];
|
||||
|
||||
/** Minimums sized so a 6-area delve never repeats — the default and most common case. */
|
||||
const MIN = { fragmentsPerFacet: 3, features: 6, decisions: 6, temptations: 5, situations: 6, areaNamesPerRole: 4 };
|
||||
|
||||
export function validatePack(pack, name = pack?.id ?? '?') {
|
||||
const errs = [], warns = [];
|
||||
const E = m => errs.push(m), W = m => warns.push(m);
|
||||
|
||||
if (!pack.id) E('no id');
|
||||
if (!GEOMETRIES.includes(pack.forgeStageType))
|
||||
E(`forgeStageType "${pack.forgeStageType}" is not one VANITY can build (${GEOMETRIES.join(', ')})`);
|
||||
|
||||
for (const k of ['appetites', 'accommodations', 'claimants', 'motifs', 'prizes', 'areaNames', 'factions', 'rosters'])
|
||||
if (!pack[k]) E(`missing ${k}`);
|
||||
if (errs.length) return { name, errs, warns };
|
||||
|
||||
const motifIds = Object.keys(pack.motifs);
|
||||
|
||||
for (const a of pack.appetites) {
|
||||
for (const f of ['id', 'want', 'lack', 'purpose', 'motif', 'prize', 'conscript', 'appeasement', 'asks'])
|
||||
if (!a[f]) E(`appetite ${a.id ?? '?'}: missing ${f}`);
|
||||
if (a.motif && !motifIds.includes(a.motif)) E(`appetite ${a.id}: motif "${a.motif}" does not exist`);
|
||||
if (a.prize && !pack.prizes?.[a.prize]) E(`appetite ${a.id}: prize "${a.prize}" has no entry`);
|
||||
const ap = a.appeasement ?? {};
|
||||
for (const f of ['move', 'attribute', 'successes', 'why', 'gain', 'cost'])
|
||||
if (ap[f] === undefined) E(`appetite ${a.id}: appeasement missing ${f}`);
|
||||
}
|
||||
|
||||
// Every appetite must have at least one accommodation that fits it, or the kernel cannot form.
|
||||
for (const a of pack.appetites) {
|
||||
const fits = pack.accommodations.filter(x => !x.fits || x.fits.includes(a.id));
|
||||
if (!fits.length) E(`appetite ${a.id}: no accommodation fits it`);
|
||||
}
|
||||
for (const acc of pack.accommodations) {
|
||||
if (!acc.text?.includes('{conscript}')) W(`accommodation ${acc.id}: no {conscript} slot — will read oddly`);
|
||||
}
|
||||
|
||||
for (const [id, m] of Object.entries(pack.motifs)) {
|
||||
for (const f of ['danger', 'dangerLine', 'trigger', 'facets'])
|
||||
if (!m[f]) E(`motif ${id}: missing ${f}`);
|
||||
for (const facet of FACETS) {
|
||||
const n = (m.facets?.[facet] ?? []).length;
|
||||
if (n < MIN.fragmentsPerFacet) E(`motif ${id}: facet ${facet} has ${n} fragments, needs ${MIN.fragmentsPerFacet}`);
|
||||
}
|
||||
for (const [key, min] of [['features', MIN.features], ['decisions', MIN.decisions],
|
||||
['temptations', MIN.temptations], ['situations', MIN.situations]]) {
|
||||
const n = (m[key] ?? []).length;
|
||||
if (n < min) E(`motif ${id}: ${n} ${key}, needs ${min} so a 6-area delve never repeats`);
|
||||
}
|
||||
|
||||
for (const d of m.decisions ?? []) {
|
||||
const r = d.resolve;
|
||||
if (!r) { E(`motif ${id}: decision "${String(d.cue).slice(0, 40)}" has no resolve`); continue; }
|
||||
if (!r.roll) E(`motif ${id}: decision "${String(d.cue).slice(0, 40)}" has no attempt roll — a pick, not a situation`);
|
||||
if (r.roll && (!r.success || !r.failure)) E(`motif ${id}: decision "${String(d.cue).slice(0, 40)}" states only half its outcomes`);
|
||||
if (!r.orElse) E(`motif ${id}: decision "${String(d.cue).slice(0, 40)}" gates progress with no alternative`);
|
||||
}
|
||||
|
||||
// The opener reads "<occupant> — <doing>. When you walk in, <onArrival>."
|
||||
for (const s of m.situations ?? []) {
|
||||
for (const f of ['occupant', 'doing', 'onArrival', 'because', 'offer'])
|
||||
if (!s[f]) E(`motif ${id}: situation missing ${f}`);
|
||||
if (/\b(is|are|but)\b|—/.test(s.occupant ?? '')) E(`motif ${id}: occupant "${s.occupant}" carries a verb; must be a noun phrase`);
|
||||
if (!/^\w+ing\b/.test(s.doing ?? '') || /^(some|no|any|every)thing\b/.test(s.doing ?? ''))
|
||||
E(`motif ${id}: doing "${String(s.doing).slice(0, 40)}" must be a participle phrase`);
|
||||
}
|
||||
|
||||
for (const t of m.temptations ?? []) {
|
||||
for (const f of ['id', 'cue', 'benefit', 'useCost', 'standingDrawback'])
|
||||
if (!t[f]) E(`motif ${id}: temptation ${t.id ?? '?'} missing ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const r of ROLES) {
|
||||
const n = (pack.areaNames?.[r] ?? []).length;
|
||||
if (n < MIN.areaNamesPerRole) E(`areaNames.${r}: ${n} names, needs ${MIN.areaNamesPerRole}`);
|
||||
}
|
||||
const allNames = Object.values(pack.areaNames ?? {}).flat();
|
||||
if (new Set(allNames).size !== allNames.length) E('areaNames contains duplicates across roles');
|
||||
|
||||
for (const h of HEATS) {
|
||||
const r = pack.rosters?.[h];
|
||||
if (!r) { E(`rosters.${h} missing`); continue; }
|
||||
if (!r.line || !r.foes?.length) E(`rosters.${h}: needs a line and foes`);
|
||||
if (!r.harmedBy) E(`rosters.${h}: must say what harms them — a party discovering an immunity by failing is how playtest 1 died`);
|
||||
if (!r.avoid) W(`rosters.${h}: no avoidance note`);
|
||||
for (const f of r.foes ?? [])
|
||||
for (const k of ['n', 'name', 'atk', 'def', 'grit', 'nerve'])
|
||||
if (f[k] === undefined) E(`rosters.${h}: foe ${f.name ?? '?'} missing ${k}`);
|
||||
}
|
||||
|
||||
for (const r of ROLES) if (!pack.facetProgression?.[r]) E(`facetProgression.${r} missing`);
|
||||
if (!pack.triggerGlossary || Object.keys(pack.triggerGlossary).length < 3) W('triggerGlossary is thin');
|
||||
|
||||
return { name, errs, warns };
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const dir = join(here, 'content');
|
||||
const names = process.argv.slice(2).length
|
||||
? process.argv.slice(2)
|
||||
: readdirSync(dir).filter(f => f.endsWith('.json') && f !== 'index.json').map(f => f.replace('.json', ''));
|
||||
let bad = 0;
|
||||
for (const n of names) {
|
||||
const pack = JSON.parse(readFileSync(join(dir, `${n}.json`), 'utf8'));
|
||||
const { errs, warns } = validatePack(pack, n);
|
||||
const counts = `${Object.keys(pack.motifs ?? {}).length} motifs`;
|
||||
if (!errs.length) console.log(` ✅ ${n.padEnd(10)} ${counts}${warns.length ? ` (${warns.length} warnings)` : ''}`);
|
||||
else { bad++; console.log(` ❌ ${n.padEnd(10)} ${errs.length} errors`); for (const e of errs.slice(0, 12)) console.log(` ${e}`); }
|
||||
for (const w of warns.slice(0, 4)) console.log(` ⚠ ${w}`);
|
||||
}
|
||||
process.exit(bad ? 1 : 0);
|
||||
}
|
||||
@@ -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.1.2",
|
||||
"version": "0.4.0",
|
||||
"compatibility": {
|
||||
"minimum": "13",
|
||||
"verified": "14.365"
|
||||
@@ -11,7 +11,8 @@
|
||||
"systems": [
|
||||
{
|
||||
"id": "vanity",
|
||||
"type": "requires",
|
||||
"type": "system",
|
||||
"manifest": "https://raw.githubusercontent.com/slaguru666/Vanity/main/system.json",
|
||||
"compatibility": {
|
||||
"minimum": "0.10.0"
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"How do I look?\""
|
||||
},
|
||||
{
|
||||
"id": "remembered",
|
||||
@@ -47,7 +48,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"Say my name.\""
|
||||
},
|
||||
{
|
||||
"id": "obeyed",
|
||||
@@ -69,7 +71,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"Do as you are told.\""
|
||||
},
|
||||
{
|
||||
"id": "young",
|
||||
@@ -91,7 +94,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"Tell me I have not changed.\""
|
||||
},
|
||||
{
|
||||
"id": "envied",
|
||||
@@ -113,7 +117,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"What did you bring me?\""
|
||||
},
|
||||
{
|
||||
"id": "attended",
|
||||
@@ -135,7 +140,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"You are not leaving.\""
|
||||
},
|
||||
{
|
||||
"id": "forgiven",
|
||||
@@ -157,7 +163,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"Tell me it was not a sin.\""
|
||||
},
|
||||
{
|
||||
"id": "first",
|
||||
@@ -179,7 +186,8 @@
|
||||
"bane": 1
|
||||
},
|
||||
"repeatable": true
|
||||
}
|
||||
},
|
||||
"asks": "\"Who goes first?\""
|
||||
}
|
||||
],
|
||||
"accommodations": [
|
||||
@@ -2146,5 +2154,6 @@
|
||||
"lingering": "stayed in one place longer than it takes to search it",
|
||||
"disturbed": "struck or moved something that had not moved in centuries"
|
||||
},
|
||||
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>."
|
||||
"_globalPoolsNote": "decisions/temptations/features below are FALLBACKS ONLY. Real content is motif-scoped under motifs.<id>.",
|
||||
"placeNameStyle": "claimant"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"themes": [
|
||||
{
|
||||
"id": "barrow",
|
||||
"label": "Barrow",
|
||||
"geometry": "barrow",
|
||||
"motifs": 8
|
||||
},
|
||||
{
|
||||
"id": "castle",
|
||||
"label": "Castle",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "church",
|
||||
"label": "Church",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "forest",
|
||||
"label": "Forest",
|
||||
"geometry": "forest",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "palace",
|
||||
"label": "Palace",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "temple",
|
||||
"label": "Temple",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "town",
|
||||
"label": "Town",
|
||||
"geometry": "village",
|
||||
"motifs": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -103,7 +103,7 @@ export function generateDelve(params = {}) {
|
||||
...plan.ending,
|
||||
name: (pack.areaNames?.ending ?? ['The Ending'])[0],
|
||||
cueFragments: [endFs?.fragment].filter(Boolean),
|
||||
question: endingQuestion(skeleton),
|
||||
question: endingQuestion(skeleton, pack),
|
||||
authored: plan.ending.mode === 'authored',
|
||||
},
|
||||
triggerGlossary: pack.triggerGlossary ?? {},
|
||||
@@ -112,17 +112,12 @@ export function generateDelve(params = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The bottom problem asks one thing. It is the same question the whole delve has been about. */
|
||||
function endingQuestion(sk) {
|
||||
const q = {
|
||||
seen: '"How do I look?"',
|
||||
remembered: '"Say my name."',
|
||||
obeyed: '"Do as you are told."',
|
||||
young: '"Tell me I have not changed."',
|
||||
envied: '"What did you bring me?"',
|
||||
attended: '"You are not leaving."',
|
||||
forgiven: '"Tell me it was not a sin."',
|
||||
first: '"Who goes first?"',
|
||||
};
|
||||
return q[sk.knot.appetiteId] ?? '"Well?"';
|
||||
/**
|
||||
* The bottom problem asks one thing, and it is the same question the whole delve has been about.
|
||||
* It lives on the appetite in the theme pack — hardcoding it here meant any theme whose appetite
|
||||
* ids differed from barrow's silently fell through to a generic line.
|
||||
*/
|
||||
function endingQuestion(sk, pack) {
|
||||
const appetite = pack?.appetites?.find(a => a.id === sk.knot.appetiteId);
|
||||
return appetite?.asks ?? '"Well?"';
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
|
||||
const transgression =
|
||||
`The ${claimant} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
|
||||
`so that ${appetite.purpose}.`;
|
||||
// The bottom problem is the claimant itself — a person in a tomb, an office in a town.
|
||||
|
||||
const bottomProblem = {
|
||||
label: `The ${claimant}`,
|
||||
@@ -79,8 +80,11 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
|
||||
};
|
||||
const secondary = r.derive('faction2').pick(pack.factions.filter(f => f.id !== primary.id));
|
||||
|
||||
// Named for its occupant. Drawing the name separately produced tombs named after strangers.
|
||||
const placeName = `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`;
|
||||
// A barrow or keep is named for its occupant — drawing that separately produced tombs named
|
||||
// after strangers. A settlement is not: it has its own name and the claimant merely runs it.
|
||||
const placeName = pack.placeNameStyle === 'own'
|
||||
? `${r.derive('place1').pick(pack.placeNames.first)} ${r.derive('place2').pick(pack.placeNames.second)}`
|
||||
: `${r.derive('place').pick(pack.placeNames.first)} ${claimant}`;
|
||||
|
||||
// ---- the foreshadow chain ----------------------------------------------
|
||||
const progression = pack.facetProgression ?? {};
|
||||
|
||||
@@ -18,7 +18,9 @@ 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';
|
||||
const THEMES = ['barrow'];
|
||||
let THEMES = [{ id: 'barrow', label: 'Barrow' }];
|
||||
|
||||
export function setThemes(list) { THEMES = list; }
|
||||
const PICKS = {
|
||||
depth: [1, 2, 3, 4, 5],
|
||||
party: [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
@@ -76,7 +78,9 @@ export class DelveForgeApp extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
*/
|
||||
export async function raiseDungeon(params = {}) {
|
||||
if (!game.user.isGM) return ui.notifications.warn('DELVE is a GM tool.');
|
||||
const PACK = game.delve.pack;
|
||||
// Load the chosen theme's pack on demand — only the default is preloaded.
|
||||
const PACK = await game.delve.loadPack(params.theme ?? 'barrow');
|
||||
if (!PACK) return ui.notifications.error(`DELVE: could not load the ${params.theme} theme.`);
|
||||
const seed = params.seed || coinSeed(new Rng(String(Date.now())));
|
||||
const d = generateDelve({ pack: PACK, ...params, seed });
|
||||
const sk = d.skeleton;
|
||||
|
||||
@@ -16,13 +16,14 @@
|
||||
*/
|
||||
import { coinSeed, Rng } from './core/rng.mjs';
|
||||
import { newWorkingFile, outstanding, readyToPlay } from './core/authoring.mjs';
|
||||
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog } from './forge-app.mjs';
|
||||
import { DelveForgeApp, raiseDungeon, listDungeons, removeDungeon, removeDungeonDialog, setThemes } from './forge-app.mjs';
|
||||
|
||||
const MOD = 'vanity-delve';
|
||||
const FLAG = 'state';
|
||||
|
||||
let PACK = null;
|
||||
let seamsPresent = false;
|
||||
let loadPack = async () => null;
|
||||
|
||||
const getState = () => game.settings.get(MOD, FLAG) ?? null;
|
||||
const setState = async s => game.settings.set(MOD, FLAG, s);
|
||||
@@ -157,8 +158,13 @@ Hooks.once('init', () => {
|
||||
|
||||
Hooks.once('ready', async () => {
|
||||
if (!game.user.isGM) return;
|
||||
PACK = await foundry.utils.fetchJsonWithTimeout(`modules/${MOD}/module/core/content/barrow.json`)
|
||||
.catch(() => fetch(`modules/${MOD}/module/core/content/barrow.json`).then(r => r.json()));
|
||||
const fetchJson = p => foundry.utils.fetchJsonWithTimeout(p).catch(() => fetch(p).then(r => r.json()).catch(() => null));
|
||||
const base = `modules/${MOD}/module/core/content`;
|
||||
const index = await fetchJson(`${base}/index.json`);
|
||||
if (index?.themes?.length) setThemes(index.themes);
|
||||
const packs = {};
|
||||
loadPack = async id => (packs[id] ??= await fetchJson(`${base}/${id}.json`));
|
||||
PACK = await loadPack('barrow');
|
||||
seamsPresent = /post\s*=\s*true/.test(String(game.vanity?.forge?.hoard ?? ''));
|
||||
|
||||
game.delve = { forge: () => new DelveForgeApp().render(true), raise: raiseDungeon,
|
||||
@@ -166,6 +172,7 @@ 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),
|
||||
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.');
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="delve-grid">
|
||||
<label>Theme
|
||||
<select name="theme">
|
||||
{{#each themes}}<option value="{{this}}" {{#if (eq this ../v.theme)}}selected{{/if}}>{{this}}</option>{{/each}}
|
||||
{{#each themes}}<option value="{{this.id}}" {{#if (eq this.id ../v.theme)}}selected{{/if}}>{{this.label}}</option>{{/each}}
|
||||
</select>
|
||||
</label>
|
||||
<label>Areas
|
||||
|
||||
Reference in New Issue
Block a user