Files
tevansandClaude Opus 5 df01615c5a Pivot: DELVE is an authoring system
Four readings of generated output reached the same verdict, and the last one
answered 'would you run this tomorrow' with No. Three renderer passes each
fixed what the previous review named and each introduced a new fault: adding
resolution blocks made it menu-like, and subtracting blocks buried the clue and
the skip guidance. The density is not a rendering bug. Everything on the page
is a vice at the table and a virtue at the desk.

So one working file, two surfaces. The worksheet carries everything, labelled,
with reroll commands beside each component and explicit prompts where only a
human can write. The play sheet carries the GM's own prose, the leverage point,
the decision on one line and the numbers — 61 lines against the worksheet's 137.

The JSON is the document. An authored layer holds what the GM writes and the
generator never touches it, so a reroll cannot destroy prose. Lock what is
good, reroll what is not, write the read-aloud, export.

Two bugs found while building it, both in reroll: the exclusion set omitted the
area's own current value so a reroll could hand back what was already there,
and when every option was in use — six areas against a six-item pool — the
fallback did the same. A reroll that changes nothing looks broken.

DELVE does not write the read-aloud and does not write the climax. It gets a GM
to a strong first draft in a minute instead of an evening, then gets out of the
way. Tests 28 -> 38.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 08:48:17 +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;