Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7af690205c | ||
|
|
d22421efbd | ||
|
|
b4990533a8 | ||
|
|
075be5c1de | ||
|
|
e2d1de2fb8 |
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"themes": [
|
||||
{
|
||||
"id": "barrow",
|
||||
"label": "Barrow",
|
||||
"geometry": "barrow",
|
||||
"motifs": 8
|
||||
},
|
||||
{
|
||||
"id": "castle",
|
||||
"label": "Castle",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "cave",
|
||||
"label": "Cave",
|
||||
"geometry": "cave",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "church",
|
||||
"label": "Church",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "city",
|
||||
"label": "City",
|
||||
"geometry": "village",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "forest",
|
||||
"label": "Forest",
|
||||
"geometry": "forest",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "lake",
|
||||
"label": "Lake",
|
||||
"geometry": "fen",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "market",
|
||||
"label": "Market",
|
||||
"geometry": "village",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "mountain",
|
||||
"label": "Mountain",
|
||||
"geometry": "cave",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "palace",
|
||||
"label": "Palace",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "port",
|
||||
"label": "Port",
|
||||
"geometry": "village",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "river",
|
||||
"label": "River",
|
||||
"geometry": "fen",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "temple",
|
||||
"label": "Temple",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "town",
|
||||
"label": "Town",
|
||||
"geometry": "village",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "valley",
|
||||
"label": "Valley",
|
||||
"geometry": "forest",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "village",
|
||||
"label": "Village",
|
||||
"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
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
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
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?"';
|
||||
}
|
||||
|
||||
+15
-5
@@ -22,7 +22,13 @@
|
||||
import { arcRole } from './director.mjs';
|
||||
|
||||
/** Fills {conscript}-style slots. */
|
||||
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
|
||||
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`)
|
||||
// Conscripts are plural noun phrases ("the climbers"), so a template's `{conscript}'s`
|
||||
// renders "the climbers's". English takes a bare apostrophe on a plural already ending in s.
|
||||
.replace(/s's\b/g, "s'");
|
||||
|
||||
/** Claimants may already carry their own article — "The Grey" must not become "The The Grey". */
|
||||
const named = c => (/^(the|a|an) /i.test(c) ? c : `The ${c}`);
|
||||
|
||||
/** Facet order used when a facet is exhausted — borrow from a neighbour before repeating. */
|
||||
const FACET_ORDER = ['institution', 'ritual', 'demand', 'wound', 'anchor'];
|
||||
@@ -62,11 +68,12 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
|
||||
const prize = { kind: appetite.prize, ...(pack.prizes?.[appetite.prize] ?? {}) };
|
||||
|
||||
const transgression =
|
||||
`The ${claimant} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
|
||||
`${named(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}`,
|
||||
label: named(claimant),
|
||||
wantNow: appetite.want,
|
||||
failureState: `is ${appetite.lack} now, and cannot bear it`,
|
||||
handledBy: 'giving them what they want, at a price',
|
||||
@@ -79,8 +86,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.3",
|
||||
"version": "0.6.0",
|
||||
"compatibility": {
|
||||
"minimum": "13",
|
||||
"verified": "14.365"
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"themes": [
|
||||
{
|
||||
"id": "barrow",
|
||||
"label": "Barrow",
|
||||
"geometry": "barrow",
|
||||
"motifs": 8
|
||||
},
|
||||
{
|
||||
"id": "castle",
|
||||
"label": "Castle",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "cave",
|
||||
"label": "Cave",
|
||||
"geometry": "cave",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "church",
|
||||
"label": "Church",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "city",
|
||||
"label": "City",
|
||||
"geometry": "village",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "forest",
|
||||
"label": "Forest",
|
||||
"geometry": "forest",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "lake",
|
||||
"label": "Lake",
|
||||
"geometry": "fen",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "market",
|
||||
"label": "Market",
|
||||
"geometry": "village",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "mountain",
|
||||
"label": "Mountain",
|
||||
"geometry": "cave",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "palace",
|
||||
"label": "Palace",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "port",
|
||||
"label": "Port",
|
||||
"geometry": "village",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "river",
|
||||
"label": "River",
|
||||
"geometry": "fen",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "temple",
|
||||
"label": "Temple",
|
||||
"geometry": "barrow",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "town",
|
||||
"label": "Town",
|
||||
"geometry": "village",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "valley",
|
||||
"label": "Valley",
|
||||
"geometry": "forest",
|
||||
"motifs": 4
|
||||
},
|
||||
{
|
||||
"id": "village",
|
||||
"label": "Village",
|
||||
"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
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
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
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?"';
|
||||
}
|
||||
|
||||
@@ -22,7 +22,13 @@
|
||||
import { arcRole } from './director.mjs';
|
||||
|
||||
/** Fills {conscript}-style slots. */
|
||||
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
|
||||
const fill = (tpl, vars) => tpl.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`)
|
||||
// Conscripts are plural noun phrases ("the climbers"), so a template's `{conscript}'s`
|
||||
// renders "the climbers's". English takes a bare apostrophe on a plural already ending in s.
|
||||
.replace(/s's\b/g, "s'");
|
||||
|
||||
/** Claimants may already carry their own article — "The Grey" must not become "The The Grey". */
|
||||
const named = c => (/^(the|a|an) /i.test(c) ? c : `The ${c}`);
|
||||
|
||||
/** Facet order used when a facet is exhausted — borrow from a neighbour before repeating. */
|
||||
const FACET_ORDER = ['institution', 'ritual', 'demand', 'wound', 'anchor'];
|
||||
@@ -62,11 +68,12 @@ export function generateSkeleton({ pack, areas = 6, ending = 'authored', rng })
|
||||
const prize = { kind: appetite.prize, ...(pack.prizes?.[appetite.prize] ?? {}) };
|
||||
|
||||
const transgression =
|
||||
`The ${claimant} ${fill(accommodation.text, { conscript: appetite.conscript })}, ` +
|
||||
`${named(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}`,
|
||||
label: named(claimant),
|
||||
wantNow: appetite.want,
|
||||
failureState: `is ${appetite.lack} now, and cannot bear it`,
|
||||
handledBy: 'giving them what they want, at a price',
|
||||
@@ -79,8 +86,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