What was on git could not be installed. foundry-module/ referenced core/ rather than containing it, so an installed copy had no generator at all — it only ever worked here because the live module directory had core/ copied in by hand. core/ is now vendored into foundry-module/module/core/, minus cli.mjs and test.mjs which are desk tools. module.json points manifest and download at release assets, the way mapwright distributes, so a remote server can install by URL without reaching into the repo tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
151 lines
6.3 KiB
JavaScript
151 lines
6.3 KiB
JavaScript
/**
|
|
* DELVE as an authoring system.
|
|
*
|
|
* Four readings of generated output landed on the same verdict: the fiction and the structure are
|
|
* good, the table-readiness is not, and the honest product is a strong first draft a GM finishes.
|
|
* "The handwritten delve still tells the GM what matters faster."
|
|
*
|
|
* That reframes the density problem rather than solving it. Everything on the page is a vice at
|
|
* the table and a virtue at the desk. So a delve now has two surfaces:
|
|
*
|
|
* WORKSHEET — everything, labelled, with explicit prompts for what only a human can write.
|
|
* PLAY SHEET — only what survived, in the GM's own words, tight enough to scan.
|
|
*
|
|
* The delve JSON is the working file. It carries an `authored` layer that the generator never
|
|
* touches, so rerolling a component cannot destroy written prose.
|
|
*/
|
|
|
|
import { Rng } from './rng.mjs';
|
|
import { generateDelve } from './delve.mjs';
|
|
|
|
/** Fields a GM writes. Anything here, once written, outranks the generated text. */
|
|
export const AUTHORED_FIELDS = ['readAloud', 'notes', 'nameOverride'];
|
|
|
|
/** Components that can be individually rerolled. */
|
|
export const REROLLABLE = ['situation', 'decision', 'temptation', 'feature', 'name'];
|
|
|
|
/** A fresh working file: the generated delve plus an empty authored layer per area. */
|
|
export function newWorkingFile(params) {
|
|
const delve = generateDelve(params);
|
|
delve.authored = { title: null, intro: null, areas: {} };
|
|
for (const a of delve.areas) {
|
|
delve.authored.areas[a.index] = { readAloud: null, notes: null, nameOverride: null, locked: [] };
|
|
}
|
|
delve.authored.ending = { readAloud: null, notes: null, locked: [] };
|
|
return delve;
|
|
}
|
|
|
|
const authoredFor = (d, i) => d.authored?.areas?.[i] ?? { locked: [] };
|
|
|
|
export const isLocked = (d, i, component) => (authoredFor(d, i).locked ?? []).includes(component);
|
|
|
|
export function lock(d, i, component) {
|
|
const a = authoredFor(d, i);
|
|
if (!a.locked.includes(component)) a.locked.push(component);
|
|
return d;
|
|
}
|
|
|
|
export function unlock(d, i, component) {
|
|
const a = authoredFor(d, i);
|
|
a.locked = (a.locked ?? []).filter(c => c !== component);
|
|
return d;
|
|
}
|
|
|
|
export function setAuthored(d, i, field, value) {
|
|
if (!AUTHORED_FIELDS.includes(field)) throw new Error(`not an authored field: ${field}`);
|
|
if (i === 'ending') { d.authored.ending[field] = value; return d; }
|
|
authoredFor(d, i)[field] = value;
|
|
return d;
|
|
}
|
|
|
|
/**
|
|
* Reroll one component of one area, keeping everything else — including anything written.
|
|
*
|
|
* Regeneration draws from a salted seed so a reroll gives something genuinely different rather
|
|
* than the same draw again, and the salt is recorded so the file stays reproducible.
|
|
*/
|
|
export function reroll(d, i, component, pack) {
|
|
if (!REROLLABLE.includes(component)) throw new Error(`not rerollable: ${component}`);
|
|
if (isLocked(d, i, component)) return d;
|
|
|
|
d.rerolls = d.rerolls ?? {};
|
|
const key = `${i}:${component}`;
|
|
d.rerolls[key] = (d.rerolls[key] ?? 0) + 1;
|
|
|
|
const salt = `${d.seed}::reroll::${key}::${d.rerolls[key]}`;
|
|
const rng = new Rng(salt);
|
|
const motif = pack.motifs?.[d.skeleton.motif.id] ?? {};
|
|
const area = d.areas.find(a => a.index === i);
|
|
if (!area) throw new Error(`no area ${i}`);
|
|
|
|
/**
|
|
* Draw fresh, avoiding what the rest of the delve uses. When every option is already in play —
|
|
* six areas against a six-item pool — prefer duplicating another area over handing back what is
|
|
* already here. A reroll that changes nothing looks broken.
|
|
*/
|
|
const choose = (all, taken, keyOf, current) => {
|
|
const fresh = all.filter(x => !taken.has(keyOf(x)));
|
|
if (fresh.length) return rng.pick(fresh);
|
|
const notCurrent = all.filter(x => keyOf(x) !== current);
|
|
return notCurrent.length ? rng.pick(notCurrent) : null;
|
|
};
|
|
|
|
// Draw fresh, avoiding what the rest of the delve is already using.
|
|
const used = (get, current) => {
|
|
const taken = new Set(d.areas.filter(x => x.index !== i).map(get).filter(Boolean));
|
|
if (current) taken.add(current); // never hand back what is already there
|
|
return taken;
|
|
};
|
|
switch (component) {
|
|
case 'situation': {
|
|
const taken = used(a => a.situation?.occupant + a.situation?.doing, area.situation ? area.situation.occupant + area.situation.doing : null);
|
|
const pick = choose(motif.situations ?? [], taken, x => x.occupant + x.doing,
|
|
area.situation ? area.situation.occupant + area.situation.doing : null);
|
|
if (pick) area.situation = { occupant: pick.occupant, doing: pick.doing, onArrival: pick.onArrival, because: pick.because, offer: pick.offer };
|
|
break;
|
|
}
|
|
case 'decision': {
|
|
const taken = used(a => a.decision?.cue, area.decision?.cue);
|
|
const pick = choose(motif.decisions ?? [], taken, x => x.cue, area.decision?.cue);
|
|
if (pick) area.decision = pick;
|
|
break;
|
|
}
|
|
case 'temptation': {
|
|
if (!area.hoard) break;
|
|
const taken = used(a => a.temptation?.id, area.temptation?.id);
|
|
const pick = choose(motif.temptations ?? [], taken, x => x.id, area.temptation?.id);
|
|
if (pick) area.temptation = pick;
|
|
break;
|
|
}
|
|
case 'feature': {
|
|
const taken = used(a => a.cueFragments?.[1], area.cueFragments?.[1]);
|
|
const pick = choose(motif.features ?? [], taken, x => x, area.cueFragments?.[1]);
|
|
if (pick) area.cueFragments = [area.cueFragments[0], pick].filter(Boolean);
|
|
break;
|
|
}
|
|
case 'name': {
|
|
const names = pack.areaNames?.[area.role] ?? [];
|
|
const taken = used(a => a.name, area.name);
|
|
const pick = choose(names, taken, x => x, area.name);
|
|
if (pick) area.name = pick;
|
|
break;
|
|
}
|
|
}
|
|
return d;
|
|
}
|
|
|
|
/** What still needs a human. This is the worksheet's to-do list and the play sheet's gate. */
|
|
export function outstanding(d) {
|
|
const todo = [];
|
|
if (!d.authored?.intro) todo.push({ where: 'intro', what: 'an opening paragraph for the delve' });
|
|
for (const a of d.areas) {
|
|
const au = authoredFor(d, a.index);
|
|
if (!au.readAloud) todo.push({ where: `area ${a.index}`, what: 'read-aloud text' });
|
|
}
|
|
if (!d.authored?.ending?.readAloud) todo.push({ where: 'ending', what: 'read-aloud text' });
|
|
if (d.ending.authored && !d.authored?.ending?.notes) todo.push({ where: 'ending', what: 'the authored climax — DELVE deliberately leaves this to you' });
|
|
return todo;
|
|
}
|
|
|
|
export const readyToPlay = d => outstanding(d).length === 0;
|