Files
slaguru666andClaude Opus 5 e2d0ed5bb2 A player briefing, and the historical range
The generator was already writing the timeline, the cast and the texture and
filing all of it behind the GM screen. New PLAYERS page: a read-aloud opening,
what it is like there, what is known so far, who is there and what they want —
but never what they lie about. Page ownership is now declared by the page rather
than guessed from its index, which would have hidden any second player page.

Historical range: 38 weapons (from 6) and 14 armours (from 2), each with its
period stated, from arming swords and longbows through matchlocks and Brown Bess
to the Lee-Enfield and a trench club. Five new skills to hang them on, including
Firearm (Muzzle-loader), because a muzzle-loader is not a rifle.

275 icons; every weapon maps to a real skill and every item has art. Catalogue is
206 items across four eras.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 23:02:20 +01:00

452 lines
21 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.
/**
* 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";
import { phaseRampFor } from "../rules.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.
// ONE middle phase, not two. A five-phase arc with two interchangeable middles
// read as padding — the case is Approach, the thing in the way, the job, and out.
// Four phases is a session; five was a session and a half with a repeat in it.
const middle = some(PHASE_KINDS.filter(k => !["approach", "extraction", "containment"].includes(k.id)), 1, 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,
// 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 ramp; the classification bites on the way home,
// where the fiction says it should. The ramp itself lives in rules.mjs.
...phaseRampFor(i, k.id),
// 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>`;
/** A table, escaped. Cells may carry markup only when the caller has escaped it. */
const table = (head, rows) =>
`<table><thead><tr>${head.map(h => `<th>${h}</th>`).join("")}</tr></thead><tbody>`
+ rows.map(r => `<tr>${r.map(c => `<td>${c}</td>`).join("")}</tr>`).join("")
+ `</tbody></table>`;
/** The player-facing brief. Everything here is true as far as the agency knows. */
/**
* The players' page. Everything on it is something an agent would be told, or would
* see on arrival — the cast's names and what they want, but never what they lie
* about; the texture of the place, but never why it is like that.
*
* The generator was already writing all of this and filing every word of it behind
* the GM screen: the timeline, the people, and the two lines of texture that are the
* best writing in the whole document. A player brief that opens on a classification
* note is a memo. This is a briefing.
*/
export function introPage(m) {
const first = m.timeline[0];
// The LAST timeline entry is the team's own arrival — "today: the team crosses" —
// which makes nonsense of "since then, ...". Use the last thing that happened
// before they did.
const prior = m.timeline.filter(t => !/team crosses/i.test(t.what));
const recent = prior[prior.length - 1] ?? first;
return `
<h2>Read this out</h2>
<p><em>${esc(m.classification.note)}</em></p>
<p>It is <strong>${esc(m.place)}</strong>. You have <strong>${esc(m.duration)}</strong>,
and the department has been clear that this is the window and not an estimate.
${esc(first.when.charAt(0).toUpperCase() + first.when.slice(1))}, ${esc(first.what.toLowerCase())}
Since then it has not stopped. Most recently, ${esc(recent.what.toLowerCase())}</p>
<p>You are going because ${esc(m.anomaly.containment.toLowerCase().replace(/\.$/, ""))},
and because somebody has to be able to say afterwards that a department attended.</p>
<h2>What it is like there</h2>
${ul(m.texture)}
<p>None of that is in the file. It is what you will notice in the first ten minutes,
and the file was written by somebody who was not there.</p>
<h2>What is known so far</h2>
${table(["When", "What"], m.timeline.map(t => [esc(t.when), esc(t.what)]))}
<h2>Who is there</h2>
${table(["Name", "Who they are", "What they want from you"],
m.cast.map(c => [`<strong>${esc(c.name)}</strong>`, esc(c.role), esc(c.wants)]))}
<p>They are not adversaries. Two of them will be helpful and at least one of them is
not telling you everything, which is ordinary and is not the same as lying.</p>
<h2>Before you go</h2>
<ul>
<li>Draw your kit. What you do not sign for, you do not have.</li>
<li>Somebody had better be able to make an arrangement, because most cases close that
way and this one is no exception.</li>
<li>Your anchor stays on you. It is the way back and it is the reason you are still
you when you get there.</li>
</ul>`;
}
export function briefPage(m) {
// The brief used to open on a classification note and never say where the site
// was, how long the team had, what was likely to be in the way, or how they were
// getting out — all of which the generator already knew. An operational brief
// answers those four first and explains itself afterwards.
const ex = m.phases[m.phases.length - 1];
return `
<table class="rb-brief-head"><tbody>
<tr><th>Where</th><td><strong>${esc(m.place)}</strong></td></tr>
<tr><th>When</th><td><strong>${esc(m.duration)}</strong> — that is the window, not an estimate.</td></tr>
<tr><th>Classification</th><td><strong>${esc(m.classification.name)}</strong> — ${esc(m.classification.note)}</td></tr>
<tr><th>Phases</th><td>${m.phases.length}: ${m.phases.map(p => esc(p.name)).join(" &rarr; ")}</td></tr>
</tbody></table>
<h2>What you are being sent to do</h2>
<p><strong>${esc(m.resolution)}</strong></p>
<h2>What is reported</h2>
<p>${esc(m.anomaly.brief)}</p>
<p><strong>Why it cannot be left.</strong> ${esc(m.anomaly.containment)}</p>
<p><strong>Signs you have the right site:</strong></p>
${ul(m.anomaly.signs)}
<h2>What is likely to be in your way</h2>
<p><strong>${esc(m.adversary.name)}</strong> — expect around ${m.adversary.count}, tier
<strong>${esc(m.adversary.tier)}</strong>.</p>
<p><strong>Why they care.</strong> ${esc(m.adversary.motive)}</p>
<p><strong>How they will go about it.</strong> ${esc(m.adversary.tactics)}</p>
<p><strong>What might work instead of shooting.</strong> ${esc(m.adversary.leverage)}</p>
<p><strong>Also on the ground:</strong> ${esc(m.group.name)} — ${esc(m.group.description)}</p>
<p><strong>Do not:</strong> ${esc(m.group.taboo?.text ?? m.group.taboo)}
${m.group.taboo?.penaltyIfViolated ? `(${m.group.taboo.penaltyIfViolated}% if you do)` : ""}</p>
<p><strong>They want:</strong> ${esc(m.group.desire?.text ?? m.group.desire)}</p>
<p><strong>Known complication.</strong> ${esc(m.complication)}</p>
<h2>How you get back</h2>
<ul>
<li>Extraction is <strong>${esc(ex.terrain)}</strong>.</li>
<li>Return is a <strong>Transposition</strong> roll per agent. Anyone may aim for
somebody else instead of themselves.</li>
<li>Classification modifier on the way home: <strong>${m.crossingModifier ? `${m.crossingModifier}%` : "none"}</strong>.</li>
<li>A rough return costs Coherence. A crossing that goes badly wrong is the only way
an Art is ever acquired.</li>
</ul>
<h2>What you start with</h2>
<ul>
<li><strong>Containment ${m.tracks.containment} · Resources ${m.tracks.resources} · Clearance ${m.tracks.clearance}</strong></li>
<li>Phases cost <strong>${m.phaseCost} Resources</strong> in total if nothing goes wrong. It will.</li>
<li>Ground is <strong>rumoured</strong>, not known.</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} · Briefing`, content: introPage(m), players: true },
{ name: `${m.codename} · Orders`, content: briefPage(m), players: true },
{ 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");
}