Files
vanity-delve/foundry-module/module/core/authoring.mjs
T
tevansandClaude Opus 5 9d953dba94 Make the module installable: vendor core, point at release assets
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>
2026-08-05 11:18:23 +01:00

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;