/** * 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(/ ``; /** A table, escaped. Cells may carry markup only when the caller has escaped it. */ const table = (head, rows) => `${head.map(h => ``).join("")}` + rows.map(r => `${r.map(c => ``).join("")}`).join("") + `
${h}
${c}
`; /** 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 `

Read this out

${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.

What it is like there

${ul(m.texture)}

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.

What is known so far

${table(["When", "What"], m.timeline.map(t => [esc(t.when), esc(t.what)]))}

Who is there

${table(["Name", "Who they are", "What they want from you"], m.cast.map(c => [`${esc(c.name)}`, esc(c.role), esc(c.wants)]))}

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.

Before you go

`; } 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 `
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(" → ")}

What you are being sent to do

${esc(m.resolution)}

What is reported

${esc(m.anomaly.brief)}

Why it cannot be left. ${esc(m.anomaly.containment)}

Signs you have the right site:

${ul(m.anomaly.signs)}

What is likely to be in your way

${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)}

How you get back

What you start with

`; } /** The GM's page. The brief is wrong and this says how. */ export function truthPage(m) { return `

What is actually happening

${esc(m.anomaly.truth)}

The gap — ${esc(m.gap.name)}

${esc(m.gap.text)}

How it surfaces. ${esc(m.gap.reveal)}

How it closes

${esc(m.anomaly.closes)}

If it does not

${esc(m.failure)}

What the place is like

${ul(m.texture)}

Physical evidence

${ul(m.anomaly.evidence)}`; } export function castPage(m) { return `

${esc(m.adversary.name)} — ${m.adversary.count}

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.name)} — ${m.group.population}

${esc(m.group.description)}

Taboo. ${esc(m.group.taboo.text)}

They want. ${esc(m.group.desire.text)}

The correct offer. ${esc(m.group.offerForm)}

People

${m.cast.map(c => ``).join("")}
WhoThey areThey wantThey lie
${esc(c.name)}${esc(c.role)}${esc(c.wants)}${esc(c.lies)}
`; } export function phasesPage(m) { return `

Phases

${m.phases.map((p, i) => ` `).join("")}
#PhaseGroundDiff.ModCost
${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) => `

${i + 1}. ${esc(p.name)} — ${esc(p.terrain)}

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 `

The clock — ${esc(m.clock.name)}

Costs ${esc(m.clock.costs)}. Advance it when the team are slow, loud or unlucky.

    ${m.clock.steps.map(s => `
  1. ${esc(s)}
  2. `).join("")}

What it will cost them

${ul(m.coherenceHooks)}

Timeline before they arrive

${m.timeline.map(t => ``).join("")}
WhenWhat
${esc(t.when)}${esc(t.what)}

At the debrief

${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"); }