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>
452 lines
21 KiB
JavaScript
452 lines
21 KiB
JavaScript
/**
|
||
* 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(/</g, "<");
|
||
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(" → ")}</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)} — ${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)} — ${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}%` : "—"}</td>
|
||
<td style="text-align:right">−${p.costSupply}${p.costPower ? ` −${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)} — ${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 — ${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");
|
||
}
|