/** * 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, "&").replace(/ `
| ${h} | `).join("")}
|---|
| ${c} | `).join("")}
${esc(m.classification.note)}
It is ${esc(m.place)}. You have ${esc(m.duration)}, 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())}
You are going because ${esc(m.anomaly.containment.toLowerCase().replace(/\.$/, ""))}, and because somebody has to be able to say afterwards that a department attended.
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.
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.
| Where | ${esc(m.place)} |
|---|---|
| When | ${esc(m.duration)} — that is the window, not an estimate. |
| Classification | ${esc(m.classification.name)} — ${esc(m.classification.note)} |
| Phases | ${m.phases.length}: ${m.phases.map(p => esc(p.name)).join(" → ")} |
${esc(m.resolution)}
${esc(m.anomaly.brief)}
Why it cannot be left. ${esc(m.anomaly.containment)}
Signs you have the right site:
${ul(m.anomaly.signs)}${esc(m.adversary.name)} — expect around ${m.adversary.count}, tier ${esc(m.adversary.tier)}.
Why they care. ${esc(m.adversary.motive)}
How they will go about it. ${esc(m.adversary.tactics)}
What might work instead of shooting. ${esc(m.adversary.leverage)}
Also on the ground: ${esc(m.group.name)} — ${esc(m.group.description)}
Do not: ${esc(m.group.taboo?.text ?? m.group.taboo)} ${m.group.taboo?.penaltyIfViolated ? `(${m.group.taboo.penaltyIfViolated}% if you do)` : ""}
They want: ${esc(m.group.desire?.text ?? m.group.desire)}
Known complication. ${esc(m.complication)}
${esc(m.anomaly.truth)}
${esc(m.gap.text)}
How it surfaces. ${esc(m.gap.reveal)}
${esc(m.anomaly.closes)}
${esc(m.failure)}
Why. ${esc(m.adversary.motive)}
How they fight. ${esc(m.adversary.tactics)}
Leverage. ${esc(m.adversary.leverage)}
Statblock: ${esc(m.adversary.npc)}, tier ${esc(m.adversary.tier)}.
${esc(m.group.description)}
Taboo. ${esc(m.group.taboo.text)}
They want. ${esc(m.group.desire.text)}
The correct offer. ${esc(m.group.offerForm)}
| Who | They are | They want | They lie |
|---|---|---|---|
| ${esc(c.name)} | ${esc(c.role)} | ${esc(c.wants)} | ${esc(c.lies)} |
| # | Phase | Ground | Diff. | Mod | Cost |
|---|---|---|---|---|---|
| ${i + 1} | ${esc(p.name)} | ${esc(p.terrain)} | ${esc(p.difficulty)} | ${p.modifier ? `${p.modifier}%` : "—"} | −${p.costSupply}${p.costPower ? ` −${p.costPower}` : ""} |
Each phase is about ${esc(m.phases[0].about)} and so on; the bold row is where the gap surfaces.
${m.phases.map((p, i) => `This phase is about ${esc(p.about)}. A sign of it here: ${esc(p.sign)}.
${p.reveal ? `The gap surfaces. ${esc(p.reveal)}
` : ""}`).join("")}`; } export function pressurePage(m) { return `Costs ${esc(m.clock.costs)}. Advance it when the team are slow, loud or unlucky.
| When | What |
|---|---|
| ${esc(t.when)} | ${esc(t.what)} |
${esc(m.debriefQuestion)}
`; } /** 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"); }