Files
slaguru666andClaude Opus 5 c1e467c91b Prose wins over the field; the play sheet carries avoid; the guard is testable
Still unreleased, but module.json is at 0.6.5 now.

initiativeWarning checked the field before the prose, so a hand-edited file
carrying both printed the warning twice — the exact fault the migration was
meant to end. Prose is checked first now and always wins.

The play sheet was the only surface that did not render `avoid`, so three
legacy rosters whose warning lived there — church, forest and temple at
nightmare — lost it entirely: the helper suppressed its fallback on finding
prose the play sheet never showed. It renders `avoid` now, which it should
have anyway; it is how the table gets past a fight without having one, which
is table-facing by definition. All five surfaces now render both prose
fields, so "the prose already says it" means the same thing everywhere.

Replayed all 64 rosters in their pre-field form through every surface: 26
need a warning, 130 of 130 checks say it exactly once. The current packs
give the same result.

commit() no longer mutates st before the write — it passes an incremented
copy, so a failed write really does leave the state as it was, which is what
the retry warning promises.

The single-flight guard moved into stage.mjs as singleFlight() and has four
tests: overlapping calls never run together, the second is refused rather
than queued, the guard clears afterwards, and a throw does not wedge it. It
closes the window within one client only; two GM browsers still race, and
that needs a world-level lock rather than a module variable.

41 core tests, 60 adapter tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:46:43 +01:00

170 lines
8.5 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Two renders of one delve.
*
* WORKSHEET is for the desk: everything the generator knows, labelled, with the reroll commands
* beside each component and an explicit prompt where only a human can write.
*
* PLAY is for the table: the GM's own prose, the live leverage point, the decision on one line,
* the numbers. Nothing else. Four reviews said the same thing — "the handwritten delve tells the
* GM what matters faster" — and the only way to be fast is to carry less.
*/
import { outstanding, isLocked, REROLLABLE } from './authoring.mjs';
import { initiativeWarning } from './roster.mjs';
const cap = s => (s ? s[0].toUpperCase() + s.slice(1) : s);
const cost = c => {
if (!c) return 'free';
const b = [];
if (c.bane) b.push(`+${c.bane} Bane${c.bane > 1 ? 's' : ''}`);
if (c.vice) b.push(`the Vice ${c.vice}`);
return b.length ? b.join(', ') : 'free';
};
const au = (d, i) => d.authored?.areas?.[i] ?? {};
/* ------------------------------------------------------------- worksheet */
export function renderWorksheet(d) {
const L = [];
const sk = d.skeleton, ap = sk.appeasement;
const todo = outstanding(d);
L.push(`# WORKSHEET — ${d.authored?.title ?? sk.placeName}`);
L.push('');
L.push(`\`seed ${d.seed} · ${d.params.theme} · ${d.params.areas} areas · depth ${d.params.depth} · generator ${d.generatorVersion}\``);
L.push('');
L.push(todo.length
? `**${todo.length} things still need you.** This is a first draft, not a play document. Finish it, then export the play sheet.`
: `**Finished.** Export the play sheet.`);
L.push('');
if (todo.length) { for (const t of todo) L.push(`- [ ] **${t.where}** — ${t.what}`); L.push(''); }
L.push('---');
L.push('');
L.push('## The kernel');
L.push('');
L.push(`| | |`);
L.push(`|---|---|`);
L.push(`| **Transgression** | ${sk.knot.transgression} |`);
L.push(`| **Bottom problem** | ${sk.bottomProblem.label} — ${sk.bottomProblem.failureState} |`);
L.push(`| **Prize** | ${sk.prize.label} — ${sk.prize.function} |`);
L.push(`| **Motif / danger** | ${sk.motif.id} · ${sk.motif.danger} — ${sk.motif.dangerLine} |`);
L.push(`| **Appeasement** | ${ap.move} \`[${ap.attribute} ${ap.successes}]\` — +${ap.gain.vanity} Vanity, +${ap.cost.bane} Bane, repeatable |`);
L.push(`| **It asks** | ${d.ending.question} |`);
L.push('');
L.push(`**WRITE ▸ an opening paragraph.** ${d.authored?.intro ? '✔ written' : '_(nothing yet — set `authored.intro`)_'}`);
if (d.authored?.intro) { L.push(''); L.push(`> ${d.authored.intro}`); }
L.push('');
L.push('---');
L.push('');
for (const a of d.areas) {
const w = au(d, a.index);
const locks = (w.locked ?? []);
const rerollable = REROLLABLE.filter(c => !locks.includes(c) && (c !== 'temptation' || a.hoard));
L.push(`## Area ${a.index} — ${w.nameOverride ?? a.name}`);
L.push(`\`${a.role} · ${a.facet}${a.encounter ? ` · ${a.encounter.heat}` : ''}${a.hoard ? ` · ${a.hoard}` : ''}\``);
L.push(`*reroll:* ${rerollable.map(c => `\`--reroll=${a.index}:${c}\``).join(' ')}${locks.length ? ` · *locked:* ${locks.join(', ')}` : ''}`);
L.push('');
L.push(`**IMAGE** ${a.cueFragments.join(' · ')}`);
if (a.situation) {
L.push(`**SITUATION** ${cap(a.situation.occupant)} — ${a.situation.doing}. On arrival, ${a.situation.onArrival}.`);
L.push(`**WHY** ${cap(a.situation.because)}. **OFFERS** ${a.situation.offer}.`);
}
L.push(`**DECISION** ${cap(a.decision.cue)} — *${a.decision.cost}*`);
const rv = a.decision.resolve ?? {};
if (rv.roll) L.push(` · \`[${rv.roll}]\` → ${rv.success}`);
if (rv.failure) L.push(` · **miss** → ${rv.failure}`);
if (rv.orElse) L.push(` · **or** ${rv.orElse}`);
if (a.encounter?.roster) {
const R = a.encounter.roster;
L.push(`**ENCOUNTER** ${R.line} — ${R.foes.map(f => `${f.n}× ${f.name} atk${f.atk}/def${f.def}/G${f.grit}`).join(', ')}`);
L.push(` · harmed by ${R.harmedBy}${initiativeWarning(R) ? ` · ${initiativeWarning(R)}` : ''} · ${R.avoid}`);
}
if (a.temptation) {
const t = a.temptation;
L.push(`**TEMPTATION** ${cap(t.id)} — ${t.cue}: ${t.benefit}`);
L.push(` · take ${cost(t.acceptanceCost)} · use ${cost(t.useCost)} · standing: ${t.standingDrawback}`);
}
L.push(`**PRESSURE** trigger ${a.trigger}${a.baneBeat ? ` · bane beat ${a.baneBeat}` : ''} · fallback ${a.fallback.route}`);
L.push('');
L.push(`**WRITE ▸ read-aloud** — ≤3 sentences, built from the IMAGE and SITUATION above.`);
L.push(w.readAloud ? `> ${w.readAloud}` : `> _(nothing yet)_`);
if (w.notes) { L.push(''); L.push(`**Notes.** ${w.notes}`); }
L.push('');
L.push('---');
L.push('');
}
const we = d.authored?.ending ?? {};
L.push(`## Ending — ${d.ending.name}`);
L.push(d.ending.authored ? '`AUTHORED SLOT — DELVE stops here on purpose`' : '`generated`');
L.push('');
L.push(`**IMAGE** ${d.ending.cueFragments.join(' · ')}`);
L.push(`**IT WANTS** ${sk.bottomProblem.wantNow}. **IT ASKS** ${d.ending.question}`);
L.push(`**APPEASED EVEN ONCE** it listens, and will trade ${sk.prize.label}.`);
L.push('');
L.push('**WRITE ▸ read-aloud**');
L.push(we.readAloud ? `> ${we.readAloud}` : `> _(nothing yet)_`);
L.push('');
L.push('**WRITE ▸ the climax.** How it goes, what harms it, what it will take instead of a fight.');
L.push(we.notes ? we.notes : `_(nothing yet — this is the seam; DELVE will not write it for you)_`);
L.push('');
return L.join('\n');
}
/* ------------------------------------------------------------ play sheet */
export function renderPlay(d) {
const L = [];
const sk = d.skeleton, ap = sk.appeasement;
const todo = outstanding(d);
L.push(`# ${d.authored?.title ?? sk.placeName}`);
L.push('');
if (todo.length) {
L.push(`> ⚠ **${todo.length} unfinished** — ${todo.slice(0, 3).map(t => t.where).join(', ')}${todo.length > 3 ? '…' : ''}.`);
L.push('> Fragments are shown where nothing was written. Run the worksheet first.');
L.push('');
}
if (d.authored?.intro) { L.push(`> ${d.authored.intro}`); L.push(''); }
L.push(`**Appease:** ${ap.move} \`[${ap.attribute} ${ap.successes}]\` — +1 Vanity now, +1 Bane later. Repeatable, every area.`);
L.push(`**Clock:** 1d6 on a trigger. **1 — something comes.** **Tab:** ${d.pressure.target} by the end.`);
L.push(`**GM truth:** ${sk.knot.transgression} ${sk.bottomProblem.label} ${sk.bottomProblem.failureState}.`);
L.push('');
L.push('---');
L.push('');
for (const a of d.areas) {
const w = au(d, a.index);
const rv = a.decision.resolve ?? {};
L.push(`### ${a.index} · ${w.nameOverride ?? a.name}`);
L.push('');
L.push(w.readAloud ? `> ${w.readAloud}` : `> *(unwritten)* ${a.cueFragments.join('. ')}.`);
L.push('');
if (a.situation) L.push(`**Here:** ${cap(a.situation.occupant)}, ${a.situation.doing} — ${a.situation.onArrival}. **They can:** ${a.situation.offer}.`);
L.push(`**${cap(a.decision.cue)}** — ${rv.roll ? `\`[${rv.roll}]\` ${rv.success}` : ''}${rv.failure ? ` · **miss** ${rv.failure}` : ''}${rv.orElse ? ` · **or** ${rv.orElse}` : ''}`);
if (a.encounter?.roster) {
const R = a.encounter.roster;
// `avoid` earns its place here — it is how the table gets past the fight without one, which
// is table-facing by definition. It also keeps every surface rendering both prose fields, so
// initiativeWarning's "the prose already says it" rule means the same thing everywhere; when
// the play sheet alone omitted `avoid`, a legacy warning living there vanished from it.
L.push(`**${cap(a.encounter.heat)}:** ${R.foes.map(f => `${f.n}× ${f.name} \`${f.atk}/${f.def}/${f.grit}\``).join(' · ')} — harmed by ${R.harmedBy}.`
+ `${initiativeWarning(R) ? ` **${initiativeWarning(R)}**` : ''} *${R.avoid}.*`);
}
if (a.temptation) L.push(`**${cap(a.temptation.id)}:** ${a.temptation.benefit}. *Use: ${cost(a.temptation.useCost)}. ${a.temptation.standingDrawback}.*`);
if (w.notes) L.push(`**Note:** ${w.notes}`);
L.push(`\`${a.trigger}${a.baneBeat ? ` · ${a.baneBeat}` : ''} · skipped → move the image on\``);
L.push('');
}
const we = d.authored?.ending ?? {};
L.push(`### ${d.ending.name}`);
L.push('');
L.push(we.readAloud ? `> ${we.readAloud}` : `> *(unwritten)* ${d.ending.cueFragments.join('. ')}.`);
L.push('');
L.push(`**It asks:** ${d.ending.question}`);
L.push(we.notes ? we.notes : `*(the climax is unwritten — this is the authored slot)*`);
L.push('');
return L.join('\n');
}