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>
170 lines
8.5 KiB
JavaScript
170 lines
8.5 KiB
JavaScript
/**
|
||
* 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');
|
||
}
|