Files
RingBRP/tools/scenario-standingorder.mjs
T
slaguru666andClaude Opus 5 5ae31944ee STANDING ORDER v1.1: simpler case; the children carry the song
Cut the register mechanic, the census creature, the paperwork route and
half the clues. The under-twelves know Mr Smith's song from the nightly
creche speaker, so Act Three is a choice: burn the tape and take the
children into care, keep the families below, or cure them with a cut
tape first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:34:15 +01:00

222 lines
11 KiB
JavaScript

/**
* STANDING ORDER — the three-act crossing case, as data.
*
* Unlike the other scenarios this one is not transcribed by hand. The GM pages are read
* out of docs/scenarios/STANDING_ORDER.md at build time, one page per top-level section,
* so the compendium cannot drift from the document the desk playtests were run against.
* The cost is a small Markdown reader below, which handles exactly the constructs the
* house format uses and throws on nothing — an unknown construct comes through as a
* paragraph, which is visible in the journal rather than silently dropped.
*
* Two cast actors, and only two: the wardens the Controller sends in Act Two, and the man
* who leads them. Everybody else below is there to be talked to, and the quarantine unit
* is the bestiary's own. The detention fight is measured by tools/detention-tables.mjs
* against the declared cast, and the document cites that artifact.
*/
import { readFileSync } from "node:fs";
import path from "node:path";
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1")), "..");
const SOURCE = "docs/scenarios/STANDING_ORDER.md";
const MD = readFileSync(path.join(ROOT, SOURCE), "utf8");
/* Authoring sections, not table material. */
const SKIP = [/^STATUS\b/, /^Open questions\b/];
/* ---------------------------------------------------------------- markdown */
const esc = s => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
function inline(s) {
return esc(s)
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/(^|[^*\w])\*(?!\s)(.+?)\*(?!\w)/g, "$1<em>$2</em>");
}
const cells = line => line.trim().replace(/^\||\|$/g, "").split("|").map(c => inline(c.trim()));
function blocks(lines) {
const out = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim() || /^---+\s*$/.test(line)) { i++; continue; }
let m;
if ((m = line.match(/^(#{3,6})\s+(.*)$/))) {
const n = Math.min(m[1].length - 1, 4);
out.push(`<h${n}>${inline(m[2])}</h${n}>`);
i++; continue;
}
if (/^\|/.test(line)) {
const rows = [];
while (i < lines.length && /^\|/.test(lines[i])) rows.push(lines[i++]);
const body = rows.filter((r, k) => k !== 1 || !/^\|[\s:|-]+\|?\s*$/.test(r));
const [head, ...rest] = body;
const th = cells(head);
const thead = th.some(Boolean) ? `<thead><tr>${th.map(c => `<th>${c}</th>`).join("")}</tr></thead>` : "";
out.push(`<table>${thead}<tbody>${rest.map(r => `<tr>${cells(r).map(c => `<td>${c}</td>`).join("")}</tr>`).join("")}</tbody></table>`);
continue;
}
if (/^>/.test(line)) {
const inner = [];
while (i < lines.length && /^>/.test(lines[i])) inner.push(lines[i++].replace(/^>\s?/, ""));
const html = blocks(inner);
const readAloud = /read aloud/i.test(inner[0] ?? "");
out.push(`<blockquote${readAloud ? ' class="rb-readaloud"' : ""}>${html}</blockquote>`);
continue;
}
if ((m = line.match(/^(\s*)([-*]|\d+\.)\s+/))) {
const ordered = /\d/.test(m[2]);
const items = [];
while (i < lines.length) {
const im = lines[i].match(/^(\s*)([-*]|\d+\.)\s+(.*)$/);
if (im && im[1].length === 0) { items.push([im[3]]); i++; continue; }
if (items.length && lines[i].trim() && /^\s+/.test(lines[i])) {
items.at(-1).push(lines[i].trim()); i++; continue;
}
break;
}
const tag = ordered ? "ol" : "ul";
out.push(`<${tag}>${items.map(x => `<li>${inline(x.join(" "))}</li>`).join("")}</${tag}>`);
continue;
}
const para = [];
while (i < lines.length && lines[i].trim() && !/^(#{3,6}\s|\||>|[-*]\s|\d+\.\s|---+\s*$)/.test(lines[i])) {
para.push(lines[i++].trim());
}
if (!para.length) { para.push(lines[i++].trim()); }
out.push(`<p>${inline(para.join(" "))}</p>`);
}
return out.join("");
}
const toHtml = text => blocks(text.replace(/<!--[\s\S]*?-->/g, "").split("\n"));
/* ---------------------------------------------------------------- sections */
function sections(md) {
const out = [];
let cur = null;
for (const line of md.split("\n")) {
const m = line.match(/^##\s+(.*)$/);
if (m) { cur = { title: m[1].trim(), body: [] }; out.push(cur); continue; }
if (cur) cur.body.push(line);
}
return out.map(s => ({ ...s, body: s.body.join("\n") }));
}
const ALL = sections(MD);
const titleOf = t => t.replace(/\*/g, "").replace(/\s+/g, " ").trim();
const section = re => {
const s = ALL.find(x => re.test(x.title));
if (!s) throw new Error(`${SOURCE}: no section matching ${re}`);
return s;
};
/* The tagline under the title, and STATUS's "not obvious" list, lead the first page:
they are what a stand-in GM needs before anything else. */
const tagline = MD.split("\n## ")[0].split("\n").filter(l => /^>/.test(l)).join("\n");
const notObvious = section(/^STATUS\b/).body.split(/^### /m)
.find(b => /^The .*things that are not obvious/.test(b));
if (!notObvious) throw new Error(`${SOURCE}: STATUS has lost "The things that are not obvious"`);
const ART = (id, alt) =>
`<figure class="rb-plate"><img src="systems/ringbrp/art/scenes/${id}.webp" alt="${alt}"/></figure>`;
export const SO_PAGES = [];
{
const essentials = section(/^GM ESSENTIALS/);
SO_PAGES.push({
name: "1 · Run this first",
content: ART("so_01_cover", "Sheldon Moor, the vent head")
+ toHtml(tagline)
+ toHtml("### " + notObvious)
+ "<h2>GM essentials</h2>" + toHtml(essentials.body)
});
let n = 2;
for (const s of ALL) {
if (s === essentials || SKIP.some(re => re.test(s.title))) continue;
SO_PAGES.push({ name: `${n++} · ${titleOf(s.title)}`, content: toHtml(s.body) });
}
}
/* Player handouts: the quoted text of each SO-H, and nothing of the GM's "what it is
for". The italic production line under each heading stays out too — it tells the GM
when to hand it over, and tells the player nothing they should read. */
export const SO_HANDOUTS = section(/^Handouts\b/).body.split(/^### /m).slice(1).map(chunk => {
const [head, ...rest] = chunk.split("\n");
const quote = rest.filter(l => /^>/.test(l)).map(l => l.replace(/^>\s?/, ""));
if (!quote.length) throw new Error(`${SOURCE}: handout "${head}" has no quoted text`);
const code = head.match(/^(SO-H\d+)/)?.[1];
if (!code) throw new Error(`${SOURCE}: handout heading "${head}" has no SO-H code`);
/* A handout is a typed page: its line breaks are the document's, so they are kept, and a
blank quoted line is a paragraph break. */
const body = quote.join("\n").split(/\n\s*\n/)
.map(par => `<p>${par.split("\n").map(l => inline(l.trim())).join("<br>")}</p>`).join("");
return { name: head.replace(/\s+—\s+/, " · ").trim(), content: body, code };
});
export const SO_PLATES = [
{ name: "1 · Sheldon Moor — the vent head", src: "art/scenes/so_01_cover.webp" },
{ name: "2 · Wetherlaw farm", src: "art/scenes/so_02_wetherlaw.webp" },
{ name: "3 · The soot edge", src: "art/scenes/so_03_ash_edge.webp" },
{ name: "4 · The adit mouth", src: "art/scenes/so_04_adit.webp" },
{ name: "5 · Bellhouse — the corridor", src: "art/scenes/so_05_corridor.webp" },
{ name: "6 · The Control Room", src: "art/scenes/so_06_control_room.webp" },
{ name: "7 · The Registry", src: "art/scenes/so_07_registry.webp" },
{ name: "8 · The crèche", src: "art/scenes/so_08_creche.webp" },
{ name: "9 · Bellhouse — section", src: "art/maps/bellhouse-section.webp" }
];
/* The detention detail. Truncheons, not firearms: the Seat's armoury is locked and the
Controller holds the key, and the order that sends them says DETAIN. Tactics are written
for the GM; the harness does not model restraint, so its figures are the ceiling. */
export const SO_CAST = [
{ key: "so_warden", name: "Bellhouse warden",
role: "ACT TWO. One of the site's eleven police, born below. Use for all of them.",
chosenStyle: "reflex", naturalArmour: 0, species: "baseline",
ch: { str: 13, siz: 13, con: 13, int: 10, pow: 11, dex: 12, cha: 10, edu: 10 },
skills: [{ fam: "melee_weapon", spec: "club", val: 50 },
{ fam: "brawl", spec: "", val: 50 },
{ fam: "dodge", spec: "", val: 30 },
{ fam: "spot", spec: "", val: 45 }],
weapons: ["punch", "cudgel"],
tactics: "A wooden truncheon, issued 1974 and oiled every Friday since. They have "
+ "arrested drunks and a man who stole from the vegetable tunnels; they have never fought anybody "
+ "who fights back. They come in pairs, ask once, and take hold of arms. THEY ARE "
+ "DETAINING, NOT KILLING: a warden stops hitting an agent who is down and puts the "
+ "cuffs on instead. A warden who is hurt badly steps back and calls for Teague. If an "
+ "agent draws a firearm, every warden in sight stops and stares, because nobody below "
+ "has seen one used, and the next thing they do is run for the Controller." },
{ key: "so_teague", name: "Warden Michael Teague",
role: "ACT TWO. Head of the site's police. The man sent to fetch them.",
chosenStyle: "reflex", naturalArmour: 0, species: "baseline",
ch: { str: 14, siz: 14, con: 14, int: 12, pow: 13, dex: 13, cha: 12, edu: 12 },
skills: [{ fam: "melee_weapon", spec: "club", val: 60 },
{ fam: "brawl", spec: "", val: 60 },
{ fam: "dodge", spec: "", val: 40 },
{ fam: "insight", spec: "", val: 50 },
{ fam: "spot", spec: "", val: 55 }],
weapons: ["punch", "cudgel"],
tactics: "Not a villain: a widower who does what the Controller asks. He leads the "
+ "detail himself because he will not send anybody where he would not go. His "
+ "service pistol is in the armoury and he leaves it there on purpose. He asks them to "
+ "come quietly, twice. SCRIPT BREAK: his daughter Nell, nine, is in the creche; an "
+ "agent who names her puts a father in the corridor instead of a warden, and he "
+ "stands the detail down. He will fight anybody who tries to take her." }
];
/* The people, for showing. Joan Wetherlaw has no portrait yet — see STANDING_ORDER_ART. */
export const SO_FACES = [
{ name: "Controller Edwin Sowerby", src: "art/portraits/so_sowerby.webp" },
{ name: "Registrar Alice Pennyfeather", src: "art/portraits/so_pennyfeather.webp" },
{ name: "Dr Ivy Larkhall", src: "art/portraits/so_larkhall.webp" },
{ name: "Deputy Controller Edith Marlow", src: "art/portraits/so_marlow.webp" },
{ name: "Warden Michael Teague", src: "art/portraits/so_teague.webp" },
{ name: "Sub-Officer Kit Marlow", src: "art/portraits/so_kit_marlow.webp" }
];