Rebuilt to Tim's brief. Time below runs at half speed, so the intake looks fifty-odd and is eighty-odd, and the Seat's own instruments agree with Centre that the war happened. The Board goes in dressed as 1974 and unarmed; Act Two is a committee that only accepts the three exhibits its own clause 5 names. Winning the vote is what arms the trap: surfacing triggers Annex D, and the Address is Mr Smith's song. The children are found after the argument is won, not before. Hatcher's Undersigned are the red herring and the key to telling a forged mark from a real one. Handout pack and the warden actor follow the new brief; SO-H06 added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
280 lines
16 KiB
JavaScript
280 lines
16 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
|
|
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" },
|
|
{ name: "10 · The lobby and Enquiries", src: "art/scenes/so_09_lobby.webp" },
|
|
{ name: "11 · The studio", src: "art/scenes/so_19_studio.webp" },
|
|
{ name: "12 · The sick bay", src: "art/scenes/so_10_sickbay.webp" },
|
|
{ name: "13 · The pipe room", src: "art/scenes/so_11_pipe_room.webp" },
|
|
{ name: "14 · The plant floor", src: "art/scenes/so_12_plant.webp" },
|
|
{ name: "15 · The Vigil hall", src: "art/scenes/so_13_vigil.webp" },
|
|
{ name: "16 · The coal roads", src: "art/scenes/so_14_tunnels.webp" },
|
|
{ name: "17 · The Sump", src: "art/scenes/so_15_sump.webp" },
|
|
{ name: "18 · The dormitories", src: "art/scenes/so_16_dormitory.webp" },
|
|
{ name: "19 · The canteen", src: "art/scenes/so_17_canteen.webp" },
|
|
{ name: "20 · The Estate", src: "art/scenes/so_18_estate.webp" },
|
|
|
|
/* v1.2 — the stuff between the numbered rooms. The Seat is a place people live in. */
|
|
{ name: "21 · The door, from the inside", src: "art/scenes/so_20_blastdoor.webp" },
|
|
{ name: "22 · The stair down", src: "art/scenes/so_21_stair.webp" },
|
|
{ name: "23 · The telephone exchange", src: "art/scenes/so_22_exchange.webp" },
|
|
{ name: "24 · The laundry", src: "art/scenes/so_23_laundry.webp" },
|
|
{ name: "25 · The washroom", src: "art/scenes/so_24_washroom.webp" },
|
|
{ name: "26 · Stores", src: "art/scenes/so_25_store.webp" },
|
|
{ name: "27 · The water gallery", src: "art/scenes/so_26_water.webp" },
|
|
{ name: "28 · The changing lobby", src: "art/scenes/so_27_lobbykit.webp" },
|
|
{ name: "29 · The kitchen", src: "art/scenes/so_28_kitchen.webp" },
|
|
{ name: "30 · The chapel", src: "art/scenes/so_29_chapel.webp" },
|
|
{ name: "31 · The workshop", src: "art/scenes/so_30_workshop.webp" },
|
|
{ name: "32 · The library", src: "art/scenes/so_31_library.webp" },
|
|
{ name: "33 · The barber's corner", src: "art/scenes/so_32_barber.webp" },
|
|
{ name: "34 · The mushroom gallery", src: "art/scenes/so_33_mushroom.webp" },
|
|
{ name: "35 · The play space", src: "art/scenes/so_34_playspace.webp" },
|
|
{ name: "36 · A family cabin", src: "art/scenes/so_35_cabin.webp" },
|
|
{ name: "37 · The battery room", src: "art/scenes/so_36_battery.webp" },
|
|
{ name: "38 · The ventilation gallery", src: "art/scenes/so_37_vent.webp" },
|
|
{ name: "39 · The haulage incline", src: "art/scenes/so_38_incline.webp" },
|
|
{ name: "40 · The memorial garden", src: "art/scenes/so_39_rest.webp" }
|
|
];
|
|
|
|
/* The underground as thirteen blueprint scenes, drawn by tools/bellhouse-plans.mjs. Numbers
|
|
are the Locations table's. 2800x2000 at 100px to the square, like Culland Hall. */
|
|
export const SO_SCENES = [
|
|
{ key: "bh_lobby", name: "Bellhouse 1 · Lobby and Enquiries (6)", nav: "Lobby", src: "art/maps/bh-lobby.webp" },
|
|
{ key: "bh_control", name: "Bellhouse 2 · Control Room and Studio (7)", nav: "Control", src: "art/maps/bh-control.webp" },
|
|
{ key: "bh_registry", name: "Bellhouse 3 · Registry (8)", nav: "Registry", src: "art/maps/bh-registry.webp" },
|
|
{ key: "bh_sickbay", name: "Bellhouse 4 · Sick Bay (9)", nav: "Sick Bay", src: "art/maps/bh-sickbay.webp" },
|
|
{ key: "bh_creche", name: "Bellhouse 5 · Creche and School (10)", nav: "Creche", src: "art/maps/bh-creche.webp" },
|
|
{ key: "bh_plant", name: "Bellhouse 6 · Pipe Room and Plant Floor (11, 12)", nav: "Plant", src: "art/maps/bh-plant.webp" },
|
|
{ key: "bh_sump", name: "Bellhouse 7 · Coal Roads and the Sump (14, 15)", nav: "Sump", src: "art/maps/bh-sump.webp" },
|
|
{ key: "bh_vigil", name: "Bellhouse 8 · Vigil Hall (13)", nav: "Vigil Hall", src: "art/maps/bh-vigil.webp" },
|
|
{ key: "bh_dorms", name: "Bellhouse 9 · Dormitories and Laundry (16)", nav: "Dormitories", src: "art/maps/bh-dorms.webp" },
|
|
{ key: "bh_canteen", name: "Bellhouse 10 · Canteen and Officers' Cabins (17)", nav: "Canteen", src: "art/maps/bh-canteen.webp" },
|
|
{ key: "bh_estate_s", name: "Bellhouse 11 · The Estate, south (18)", nav: "Estate S", src: "art/maps/bh-estate-south.webp" },
|
|
{ key: "bh_estate_m", name: "Bellhouse 12 · The Estate, middle (18)", nav: "Estate M", src: "art/maps/bh-estate-middle.webp" },
|
|
{ key: "bh_estate_n", name: "Bellhouse 13 · The Estate, north (18)", nav: "Estate N", src: "art/maps/bh-estate-north.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 wardens, born below. Use for all of them, and for Hatcher's Undersigned in a scuffle.",
|
|
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. The "
|
|
+ "Board carries no firearms and neither do they: this is a scuffle in a corridor in "
|
|
+ "front of witnesses, and every blow of it is spent at the vote." },
|
|
|
|
{ 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. Mr Bewley and Mr Prosser are THROUGH TRAIN's own portraits.
|
|
Mr Smith has none and must never have one. */
|
|
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: "Kit Marlow", src: "art/portraits/so_kit_marlow.webp" },
|
|
{ name: "Nell Teague", src: "art/portraits/so_nell_teague.webp" },
|
|
{ name: "Ruth Cotter", src: "art/portraits/so_ruth_cotter.webp" },
|
|
{ name: "Mr Ogden, Enquiries", src: "art/portraits/so_ogden.webp" },
|
|
{ name: "Joan Wetherlaw", src: "art/portraits/so_joan_wetherlaw.webp" },
|
|
{ name: "Mr Bewley, Registry", src: "art/portraits/tt_bewley.svg" },
|
|
{ name: "Mr Prosser, Stores", src: "art/portraits/tt_prosser.svg" }
|
|
];
|