Files
RingBRP/tools/mission.mjs
T
slaguru666andClaude Opus 5 d0144f744e The Custodians v0.6.1 — full system
A Foundry VTT game system: Basic Roleplaying d100 in a drab British agency that
deals with crossings, where reality keeps a record of what belongs where and can be
made to get it wrong.

Contents:
- rules.mjs as the single authority for every rule formula, with 40 rules and 132
  spot-checked values. Nothing may restate a formula it defines.
- 25 postings, each with a key characteristic, trained skills, talents and its own
  kit; a 175-item catalogue across four eras; 231 generated icons.
- Reaction Lanes initiative, re-rolled every round including the first.
- Coherence, and the Quiet Arts, which only ever arrive from a crossing gone wrong.
- Graded defences: only a critical stops a critical.
- Borrowed Authority — access as a record rather than a hacking roll.
- A case generator, and a rules journal generated from rules.mjs so the book cannot
  drift from the code.

Built compendia are committed so a remote Foundry needs only the files.
node_modules is not; `npm install` restores it for rebuilding content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:09:07 +01:00

359 lines
16 KiB
JavaScript
Raw 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.
/**
* MISSION FRAMEWORK — the composer.
*
* A case is generated, but not at random: the parts are chosen to CONTRADICT each
* other in a specific way, because that contradiction is the scenario.
*
* The bible states the shape plainly — "They are briefed on a place and a time and
* a thing to do. They cross. They find that the brief was, as always, incomplete."
* So every mission has a BRIEF, a TRUTH, and a named GAP between them. A generator
* that produced only a situation would produce a location; producing a brief and a
* truth that disagree produces a case.
*
* Content lives in mission-tables.mjs. This file is machinery.
* Pure functions, no Foundry: the same code builds packs in Node and generates live
* missions in the browser.
*/
import {
ANOMALIES, CLASSES, PLACES, DURATION_SPANS, DURATION_VAGUE, GAPS, ADVERSARIES,
NAMES_FIRST, NAMES_LAST, NPC_ROLES, PHASE_KINDS, COMPLICATIONS, CLOCKS,
COHERENCE_HOOKS, RESOLUTIONS, DEBRIEF_QUESTIONS, FAILURE_STATES, GROUPS
} from "./mission-tables.mjs";
/* ---------------- picking ---------------- */
const pick = (arr, r) => arr[Math.floor(r() * arr.length)];
const pickWeighted = (arr, r) => {
const total = arr.reduce((n, x) => n + (x.weight ?? 1), 0);
let t = r() * total;
for (const x of arr) { t -= (x.weight ?? 1); if (t <= 0) return x; }
return arr.at(-1);
};
const some = (arr, n, r) => {
const copy = arr.slice(), out = [];
while (out.length < n && copy.length) out.push(copy.splice(Math.floor(r() * copy.length), 1)[0]);
return out;
};
const between = ([lo, hi], r) => lo + Math.floor(r() * (hi - lo + 1));
/**
* A small deterministic PRNG, so a seed reproduces a case exactly.
*
* splitmix32 rather than a plain LCG: with an LCG, seeds 1, 2 and 3 produced the
* SAME gap and the same phase shape, because consecutive small seeds stay
* correlated for the first several draws and every part of a mission is drawn at a
* fixed position in the sequence. The avalanche step decorrelates them.
*/
export function seeded(seed) {
let s = ((Number(seed) || 1) >>> 0) + 0x9e3779b9;
return () => {
s = (s + 0x9e3779b9) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 16), 0x21f0aaad) >>> 0;
t = Math.imul(t ^ (t >>> 15), 0x735a2d97) >>> 0;
return ((t ^ (t >>> 15)) >>> 0) / 4294967296;
};
}
const CODE_A = ["QUIET", "LOW", "GLASS", "PAPER", "SALT", "IRON", "SOFT", "LONG", "PALE", "SHORT", "DRY", "PLAIN"];
const CODE_B = ["HARBOUR", "LEDGER", "MARGIN", "TENANT", "ORCHARD", "SIGNAL", "COUNTY", "LANTERN", "TALLY", "VERGE", "PARISH", "CUTTING"];
const personName = r => `${pick(NAMES_FIRST, r)} ${pick(NAMES_LAST, r)}`;
/* ---------------- the parts ---------------- */
/** People the team will actually talk to. Each wants something and each lies once. */
function buildCast(r, { adversary, anomaly }) {
const roles = some(NPC_ROLES, 3, r);
const cast = roles.map(role => ({
name: personName(r), role: role.role, wants: role.wants, lies: role.lies
}));
// A revenant and a duplicate are singular and do not negotiate, so giving them a
// human spokesman who lies "about how many of them there are" was nonsense on two
// counts. Only the collective adversaries get a voice.
if (!["revenant", "duplicate"].includes(adversary.id)) {
cast.push({
name: personName(r),
role: `speaks for ${adversary.name.toLowerCase()}`,
wants: `to be left to it — ${adversary.motive.replace(/^Somebody/, "somebody")}`,
lies: "about how many of them there are"
});
}
// Duplicate names read as a clue in a game about duplicates.
const seen = new Set();
return cast.filter(c => !seen.has(c.name) && seen.add(c.name));
}
/**
* Phases. Always an approach and an extraction; the middle is drawn from the kinds
* that suit the anomaly. Cost rises as they get closer, and the phase where the GAP
* is revealed is marked, because that is the beat the whole case turns on.
*/
function buildPhases(r, { anomaly, gap }) {
// A CONTAINMENT phase is compulsory. Without it the spine was approach → search →
// extraction, and "getting out with it" presumed the it had been obtained
// offscreen: the generator produced an approach, not a session. The phase where
// they do the thing they were sent to do is the one the case is about.
const middle = some(PHASE_KINDS.filter(k => !["approach", "extraction", "containment"].includes(k.id)), 2, r);
const kinds = [
PHASE_KINDS.find(k => k.id === "approach"),
...middle,
PHASE_KINDS.find(k => k.id === "containment"),
PHASE_KINDS.find(k => k.id === "extraction")
];
const revealAt = 1 + Math.floor(r() * 2); // phase 2 or 3: never the first
// One sign per phase, not repeated: picking independently used the same beat in
// three of four phases.
const shuffled = some(anomaly.signs, anomaly.signs.length, r);
const signs = kinds.map((_, i) => shuffled[i % shuffled.length]);
return kinds.map((k, i) => ({
index: i,
kind: k.id,
name: k.name,
terrain: pick(k.terrains, r),
about: k.verb,
difficulty: i === 0 ? "average" : "difficult",
// The classification modifier belongs to the CROSSING, not to every phase. Both
// applied, so a Wrong-class extraction came out at -35% on top of Difficult,
// which floors a 71% Tradecraft to 1% and makes the case unplayable rather than
// hard. Phases carry their own gentle ramp; the classification bites on the way
// home, where the fiction says it should.
modifier: -(i * 5),
// Costs must be affordable from the starting budget. They totalled 7 against a
// starting 6 for every non-Near class, so half of all cases were arithmetically
// unfinishable before the extraction phase.
costSupply: i === 0 ? 1 : (k.id === "extraction" ? 1 : 2),
costPower: k.id === "interior" ? 1 : 0,
revealsGap: i === revealAt,
reveal: i === revealAt ? gap.reveal : null,
sign: signs[i]
}));
}
/** A day-by-day account of what has already happened, so the GM has a history. */
function buildTimeline(r, { anomaly, duration }) {
// The middle rows used to be hard-coded "a week later" / "six days ago", which
// read as nonsense against a nineteen-hour anomaly and contradicted row one in
// better than a quarter of cases. They are relative now, so any duration works.
return [
{ when: `${duration} ago`, what: "It starts. Nobody reports it because nobody has a word for it." },
{ when: "not long after", what: `${pick(anomaly.signs, r)} — noticed, written down, not acted on.` },
{ when: "later still", what: "The first person tells somebody official. The report is filed correctly and slowly." },
{ when: "once the file surfaces", what: `The agency notices. ${anomaly.containment}` },
{ when: "yesterday", what: "The brief is written. It is already out of date when it is signed." },
{ when: "today", what: "The team crosses." }
];
}
/* ---------------- the composer ---------------- */
/**
* Build a mission. Pass a seed for a reproducible one. `opts` can pin any part —
* classification, anomaly, adversary, gap — so a GM can generate around a fixed
* idea rather than accept the whole roll.
*/
export function generateMission({ seed = null, rnd = null, ...opts } = {}) {
const r = rnd ?? (seed !== null ? seeded(seed) : Math.random);
// THE ANOMALY IS THE PARENT KEY. Everything else is filtered to what it can
// actually support. Picking these independently produced a brief ordering a team
// to "recover the object" when the anomaly was a duplicated person, a Hollow
// world ("nobody home") whose containment note read "the town has noticed", and
// gaps whose reveal contradicted the truth outright. The tables did not need more
// content; they needed edges.
const anomaly = opts.anomalyId ? ANOMALIES.find(a => a.id === opts.anomalyId) : pick(ANOMALIES, r);
const fits = anomaly.fits ?? {};
const only = (arr, allow) => {
const kept = allow?.length ? arr.filter(x => allow.includes(x.id)) : arr;
return kept.length ? kept : arr; // never leave a table empty
};
const classification = opts.classId
? CLASSES.find(c => c.id === opts.classId)
: pickWeighted(only(CLASSES, fits.classes), r);
const adversary = opts.adversaryId
? ADVERSARIES.find(a => a.id === opts.adversaryId)
: pick(only(ADVERSARIES, fits.adversaries), r);
const gap = opts.gapId ? GAPS.find(g => g.id === opts.gapId) : pick(only(GAPS, fits.gaps), r);
const placeOptions = PLACES.filter(pl => !fits.placeKinds?.length
|| pl.kinds.some(k => fits.placeKinds.includes(k)));
const placeEntry = opts.place ? { text: opts.place, kinds: [] }
: pick(placeOptions.length ? placeOptions : PLACES, r);
const place = placeEntry.text;
// A span may follow "for"; a vague date may not. Keeping both in one table gave
// "has been open for since the spring" and a timeline row reading "since a date
// nobody will confirm ago".
const span = pick(DURATION_SPANS, r);
const vague = pick(DURATION_VAGUE, r);
const duration = span;
const clock = pick(CLOCKS, r);
// Every PLACES entry is lowercase and three anomaly templates open with {place},
// so one brief in five began with a lowercase letter.
const upperFirst = t => t.charAt(0).toUpperCase() + t.slice(1);
const fill = t => upperFirst(t.replace("{place}", place).replace("{duration}", span));
const codename = `${pick(CODE_A, r)} ${pick(CODE_B, r)}`;
const mission = {
codename, seed,
classification,
place, duration,
anomaly: {
...anomaly,
brief: fill(anomaly.brief),
truth: fill(anomaly.truth),
signs: some(anomaly.signs, 3, r),
evidence: some(anomaly.evidence, 2, r)
},
gap,
texture: some(classification.texture, 2, r),
adversary: { ...adversary, count: between(adversary.count, r) },
cast: buildCast(r, { adversary, anomaly }),
complication: pick(COMPLICATIONS, r),
clock,
coherenceHooks: some(COHERENCE_HOOKS, 2, r),
resolution: pick(only(RESOLUTIONS, fits.resolutions), r).text,
debriefQuestion: pick(DEBRIEF_QUESTIONS, r),
failure: pick(FAILURE_STATES, r),
group: pick(GROUPS, r),
timeline: buildTimeline(r, { anomaly, duration }),
// Resources are set AFTER the phases exist, from what the phases actually cost.
// They were a guess — 8 for Near, 6 otherwise — against a spine costing 7, so
// every non-Near case was unfinishable before extraction. A budget that
// contradicts its own costs is not difficulty, it is an arithmetic error.
tracks: { containment: 10, resources: 0, clearance: classification.id === "wrong" ? 4 : 6 },
crossingModifier: classification.modifier,
salvageEra: classification.era
};
mission.phases = buildPhases(r, mission);
// Enough to finish on a clean run, plus slack that the clock and complications
// will eat. A harder classification gets less slack, not an impossible sum.
const spine = mission.phases.reduce((n, p) => n + p.costSupply, 0);
const slack = classification.id === "near" ? 3 : (classification.id === "wrong" ? 1 : 2);
mission.tracks.resources = Math.min(10, spine + slack);
mission.phaseCost = spine;
return mission;
}
/* ---------------- rendering ---------------- */
const esc = s => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;");
const ul = xs => `<ul>${xs.map(x => `<li>${esc(x)}</li>`).join("")}</ul>`;
/** The player-facing brief. Everything here is true as far as the agency knows. */
export function briefPage(m) {
return `
<p><strong>Classification ${esc(m.classification.name)}.</strong> <em>${esc(m.classification.note)}</em></p>
<h2>The brief</h2>
<p>${esc(m.anomaly.brief)}</p>
<p><strong>Containment.</strong> ${esc(m.anomaly.containment)}</p>
<h2>What you are being sent to do</h2>
<p>${esc(m.resolution)}</p>
<h2>Known complication</h2>
<p>${esc(m.complication)}</p>
<h2>Reported signs</h2>
${ul(m.anomaly.signs)}
<h2>Starting state</h2>
<ul>
<li><strong>Containment ${m.tracks.containment} · Resources ${m.tracks.resources} · Clearance ${m.tracks.clearance}</strong></li>
<li>Destination knowledge: <strong>rumoured</strong> — every phase this journey is Difficult.</li>
<li>Return modifier for the classification: <strong>${m.crossingModifier ? `${m.crossingModifier}%` : "none"}</strong></li>
</ul>`;
}
/** The GM's page. The brief is wrong and this says how. */
export function truthPage(m) {
return `
<h2>What is actually happening</h2>
<p>${esc(m.anomaly.truth)}</p>
<h2>The gap — ${esc(m.gap.name)}</h2>
<p>${esc(m.gap.text)}</p>
<p><strong>How it surfaces.</strong> ${esc(m.gap.reveal)}</p>
<h2>How it closes</h2>
<p>${esc(m.anomaly.closes)}</p>
<h2>If it does not</h2>
<p>${esc(m.failure)}</p>
<h2>What the place is like</h2>
${ul(m.texture)}
<h2>Physical evidence</h2>
${ul(m.anomaly.evidence)}`;
}
export function castPage(m) {
return `
<h2>${esc(m.adversary.name)} &mdash; ${m.adversary.count}</h2>
<p><strong>Why.</strong> ${esc(m.adversary.motive)}</p>
<p><strong>How they fight.</strong> ${esc(m.adversary.tactics)}</p>
<p><strong>Leverage.</strong> ${esc(m.adversary.leverage)}</p>
<p class="notes">Statblock: <em>${esc(m.adversary.npc)}</em>, tier ${esc(m.adversary.tier)}.</p>
<h2>${esc(m.group.name)} &mdash; ${m.group.population}</h2>
<p>${esc(m.group.description)}</p>
<p><strong>Taboo.</strong> ${esc(m.group.taboo.text)}</p>
<p><strong>They want.</strong> ${esc(m.group.desire.text)}</p>
<p><strong>The correct offer.</strong> ${esc(m.group.offerForm)}</p>
<h2>People</h2>
<table><thead><tr><th>Who</th><th>They are</th><th>They want</th><th>They lie</th></tr></thead><tbody>
${m.cast.map(c => `<tr><td><strong>${esc(c.name)}</strong></td><td>${esc(c.role)}</td><td>${esc(c.wants)}</td><td>${esc(c.lies)}</td></tr>`).join("")}
</tbody></table>`;
}
export function phasesPage(m) {
return `
<h2>Phases</h2>
<table><thead><tr><th>#</th><th>Phase</th><th>Ground</th><th>Diff.</th><th>Mod</th><th>Cost</th></tr></thead><tbody>
${m.phases.map((p, i) => `<tr${p.revealsGap ? ' style="font-weight:600"' : ""}>
<td>${i + 1}</td><td>${esc(p.name)}</td><td>${esc(p.terrain)}</td>
<td>${esc(p.difficulty)}</td><td style="text-align:right">${p.modifier ? `${p.modifier}%` : "&mdash;"}</td>
<td style="text-align:right">&minus;${p.costSupply}${p.costPower ? ` &minus;${p.costPower}` : ""}</td></tr>`).join("")}
</tbody></table>
<p class="notes">Each phase is about <em>${esc(m.phases[0].about)}</em> and so on; the bold row is where the gap surfaces.</p>
${m.phases.map((p, i) => `<h3>${i + 1}. ${esc(p.name)} &mdash; ${esc(p.terrain)}</h3>
<p>This phase is about ${esc(p.about)}. A sign of it here: <em>${esc(p.sign)}</em>.</p>
${p.reveal ? `<p><strong>The gap surfaces.</strong> ${esc(p.reveal)}</p>` : ""}`).join("")}`;
}
export function pressurePage(m) {
return `
<h2>The clock &mdash; ${esc(m.clock.name)}</h2>
<p>Costs <strong>${esc(m.clock.costs)}</strong>. Advance it when the team are slow, loud or unlucky.</p>
<ol>${m.clock.steps.map(s => `<li>${esc(s)}</li>`).join("")}</ol>
<h2>What it will cost them</h2>
${ul(m.coherenceHooks)}
<h2>Timeline before they arrive</h2>
<table><thead><tr><th>When</th><th>What</th></tr></thead><tbody>
${m.timeline.map(t => `<tr><td><strong>${esc(t.when)}</strong></td><td>${esc(t.what)}</td></tr>`).join("")}
</tbody></table>
<h2>At the debrief</h2>
<p><em>${esc(m.debriefQuestion)}</em></p>`;
}
/** The whole case as journal pages, ready to be written into a compendium. */
export function missionPages(m) {
return [
{ name: `${m.codename} · Brief`, content: briefPage(m) },
{ name: `${m.codename} · The truth`, content: truthPage(m) },
{ name: `${m.codename} · Opposition`, content: castPage(m) },
{ name: `${m.codename} · Phases`, content: phasesPage(m) },
{ name: `${m.codename} · Pressure`, content: pressurePage(m) }
];
}
/** Plain text, for a terminal or a quick read. */
export function missionSummary(m) {
return [
`${m.codename} [${m.classification.name}]`,
` where ${m.place}`,
` brief ${m.anomaly.brief}`,
` truth ${m.anomaly.truth}`,
` the gap ${m.gap.name} — ${m.gap.text}`,
` against ${m.adversary.count} × ${m.adversary.name} (${m.adversary.npc})`,
` clock ${m.clock.name}, costs ${m.clock.costs}`,
` snag ${m.complication}`,
` ends ${m.resolution}`,
` phases ${m.phases.map(p => p.name).join(" → ")}`,
` cast ${m.cast.map(c => `${c.name} (${c.role})`).join("; ")}`
].join("\n");
}