commit 2090e8902c65753d3b47de2ee561e3ea16499bfc Author: Tim Evans Date: Wed Jun 17 11:24:14 2026 +0100 Blueprint News: engine-agnostic SLA BPN briefing generator Reusable framework-free component (core generation engine, seeded procedural SVG art, render layer) plus a standalone app built on it. Produces two separate surfaces per briefing — Player Brief and GM Dossier — in the SLA house style with bundled local fonts. Per-band procedural artwork, deterministic from seed. NPC/monster generation left to a future companion module via a clean seam. Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f75d22 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +.claude/ +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9cfab30 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# Blueprint News + +An engine-agnostic generator for SLA Industries **BPN** (Blue Print News) mission +briefings. It produces two clearly separated surfaces from one briefing: + +- **Player Brief** — the official corporate dispatch the squad is allowed to see. +- **GM Dossier** — the handler-only truth: the twist, the withheld fact, the real + threat read, the levers, and the dice state. + +Every briefing is reproducible from a short **seed**, which also drives the +**procedural SVG artwork** — flat, stencil/propaganda SLA styling, never +photorealistic. Fonts are bundled locally, so it looks identical offline. + +It deliberately owns **no game-engine schema**. NPC and monster *generation* are +left to companion modules; each briefing reserves a clean hand-off seam +(`npcSeam`) carrying archetype keys only. + +## Run the app + +No build step. Serve the folder over HTTP (ES modules need it) and open it: + +```bash +python3 -m http.server 8778 +# then open http://localhost:8778/index.html +``` + +The app: pick a colour band / scenario frame (or leave on *Any* to roll), +**Generate**, toggle **Player / GM / Both**, **Save** to a local library, +**Print** (clean print stylesheet) or **Export HTML** (self-contained file). + +## Architecture — a component, plus an app built on it + +The reusable component is three framework-free ES-module layers with no DOM or +engine dependencies in the core: + +``` +core/ generation engine (pure, deterministic) + bpn-core.js generateBPN(opts) -> resolved BPN object; randomSeed() + data/colours.js the 10 colour bands + d20 issue table + data/frames.js hand-authored scenario frames (with npcSeam hints) + data/tables.js generic SLA flavour tables +art/ + bpn-art.js seeded procedural SVG: crestSVG(), bannerSVG() +render/ + bpn-render.js briefHTML() / dossierHTML() + mount helpers + bpn-render.css the SLA-styled, responsive, print-ready stylesheet +assets/fonts/ bundled Josefin Sans, Roboto Mono, Modesto Condensed +app/ the standalone generator UI (consumes the above) +index.html +``` + +### Using the component in another engine + +```js +import { generateBPN } from "./core/bpn-core.js"; +import { briefHTML, dossierHTML } from "./render/bpn-render.js"; + +const bpn = generateBPN({ seed: "BPN-1A2B-3C4D", colourKey: "red" }); +host.innerHTML = `
${briefHTML(bpn)}
`; // player-facing +gmPanel.innerHTML = `
${dossierHTML(bpn)}
`; // GM-only +``` + +Include `render/bpn-render.css` and `assets/fonts/fonts.css` in the host. The +markup is plain HTML/SVG scoped under `.bpn`, so it drops into a Foundry journal +page, a website, or an Electron app unchanged. + +`generateBPN(opts)` accepts: `seed`, `colourKey`, `frameId`, `squadSize`, and an +`overrides` object to pin any individual field. Same inputs → identical output, +art included. + +### The NPC / monster seam + +Each briefing exposes `bpn.npcSeam = { contactArchetype, threatArchetype, +threatRole }` — archetype keys only, no stats. A future NPC/monster module reads +these to spin up cast members in whatever engine it targets, without this module +ever importing that engine's actor schema. The GM Dossier surfaces the seam +explicitly so the handler knows which cast slots are reserved. + +## Colour bands + +Blue · Yellow · Green · White · Grey · Silver · Jade · Red · Black · Platinum — +each with its own classification, SCL requirement, CBS pay band, media posture, +and render accent. See `core/data/colours.js`. + +## Provenance + +Rebuilt from the strongest parts of the earlier Foundry-bound experiments +(`sla-bpn-dispatch`'s mission engine and player/GM split, the `sla-bpn-toolkit` +data, `sla-briefing-desk`'s output pattern), with every engine call removed and +the real SLA house fonts and palette applied. diff --git a/app/app.css b/app/app.css new file mode 100644 index 0000000..8423e75 --- /dev/null +++ b/app/app.css @@ -0,0 +1,216 @@ +/* Blueprint News — standalone app chrome. + The briefing itself is styled by render/bpn-render.css; this only dresses + the surrounding generator UI (control rail, stage, library) in the same + SLA palette and fonts. */ + +:root { + --bg: #0a0e14; + --rail: #11161f; + --rail-2: #161c26; + --line: #2c333f; + --text: #eef3fa; + --muted: #9fb0c4; + --gold: #d6b24e; + --gold-deep: #b88a24; + --accent: #d05e1a; + --display: "Modesto Condensed", "Josefin Sans", sans-serif; + --head: "Josefin Sans", system-ui, sans-serif; + --mono: "Roboto Mono", ui-monospace, monospace; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + min-height: 100%; + background: + radial-gradient(circle at 18% -10%, rgba(208, 94, 26, 0.10), transparent 40%), + linear-gradient(180deg, #0b0f16, #070a0f); + color: var(--text); + font-family: var(--head); + font-size: 16px; +} + +.app { + display: grid; + grid-template-columns: 320px 1fr; + min-height: 100vh; +} + +/* ---------- top wordmark bar ---------- */ +.topbar { + grid-column: 1 / -1; + display: flex; + align-items: center; + gap: 14px; + padding: 12px 22px; + border-bottom: 1px solid var(--line); + background: linear-gradient(180deg, #11161f, #0c1017); +} +.topbar__mark { + font-family: var(--display); + font-weight: 700; + font-size: 1.9rem; + letter-spacing: 0.04em; + text-transform: uppercase; + line-height: 1; +} +.topbar__mark b { color: var(--gold); } +.topbar__tag { + font-family: var(--mono); + font-size: 0.72rem; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--muted); +} +.topbar__spacer { flex: 1; } +.topbar__hint { font-family: var(--mono); font-size: 0.7rem; color: var(--muted); letter-spacing: 0.08em; } + +/* ---------- control rail ---------- */ +.rail { + border-right: 1px solid var(--line); + background: var(--rail); + padding: 18px; + display: flex; + flex-direction: column; + gap: 16px; + overflow-y: auto; + max-height: calc(100vh - 58px); +} + +.field { display: grid; gap: 6px; } +.field > label { + font-family: var(--mono); + font-size: 0.7rem; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--muted); +} +.field input, .field select { + width: 100%; + padding: 9px 11px; + border-radius: 8px; + border: 1px solid var(--line); + background: #0a0e14; + color: var(--text); + font-family: var(--head); + font-size: 0.96rem; +} +.field input:focus, .field select:focus { outline: none; border-color: var(--gold); } +.field--row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } + +.seed-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: end; } +.seed-row input { font-family: var(--mono); font-size: 0.86rem; } + +.btn { + border-radius: 8px; + border: 1px solid var(--line); + background: var(--rail-2); + color: var(--text); + font-family: var(--head); + font-weight: 600; + letter-spacing: 0.03em; + padding: 10px 14px; + cursor: pointer; + transition: filter 0.12s, border-color 0.12s; +} +.btn:hover { filter: brightness(1.12); border-color: var(--muted); } +.btn--primary { + border: 1px solid var(--gold-deep); + background: linear-gradient(180deg, var(--gold), var(--gold-deep)); + color: #1a1208; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.btn--ghost { background: transparent; } +.btn--sm { padding: 8px 10px; font-size: 0.82rem; } +.btn-row { display: flex; gap: 8px; flex-wrap: wrap; } + +.rail__divider { height: 1px; background: var(--line); margin: 2px 0; } +.rail__heading { + font-family: var(--mono); + font-size: 0.7rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--gold); + margin: 0; +} + +/* library */ +.library { display: grid; gap: 8px; } +.lib-item { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 8px; + padding: 9px 11px; + border-radius: 8px; + border: 1px solid var(--line); + background: #0a0e14; +} +.lib-item__main { min-width: 0; cursor: pointer; } +.lib-item__title { + font-family: var(--head); + font-weight: 600; + font-size: 0.92rem; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.lib-item__meta { font-family: var(--mono); font-size: 0.68rem; color: var(--muted); } +.lib-item__band { + display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 6px; vertical-align: middle; +} +.lib-del { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 1rem; padding: 4px 6px; border-radius: 6px; } +.lib-del:hover { color: var(--danger, #ff5b5b); background: rgba(255,91,91,0.1); } +.lib-empty { font-family: var(--mono); font-size: 0.74rem; color: var(--muted); } + +/* ---------- stage ---------- */ +.stage { + padding: 22px; + overflow-y: auto; + max-height: calc(100vh - 58px); +} + +.viewtoggle { + display: inline-flex; + border: 1px solid var(--line); + border-radius: 999px; + padding: 3px; + margin: 0 auto 18px; + background: var(--rail); + gap: 2px; +} +.viewtoggle button { + border: none; + background: transparent; + color: var(--muted); + font-family: var(--mono); + font-size: 0.74rem; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 7px 16px; + border-radius: 999px; + cursor: pointer; +} +.viewtoggle button[aria-pressed="true"] { + background: linear-gradient(180deg, var(--gold), var(--gold-deep)); + color: #1a1208; + font-weight: 700; +} +.stage__bar { display: flex; justify-content: center; align-items: center; gap: 14px; flex-wrap: wrap; } + +.output { display: grid; gap: 26px; } + +/* ---------- responsive ---------- */ +@media (max-width: 860px) { + .app { grid-template-columns: 1fr; } + .rail { max-height: none; border-right: none; border-bottom: 1px solid var(--line); } + .stage { max-height: none; } + .topbar__tag, .topbar__hint { display: none; } +} + +@media print { + .topbar, .rail, .stage__bar { display: none !important; } + .app { display: block; } + .stage { padding: 0; max-height: none; overflow: visible; } +} diff --git a/app/app.js b/app/app.js new file mode 100644 index 0000000..34a696e --- /dev/null +++ b/app/app.js @@ -0,0 +1,212 @@ +/* Blueprint News — standalone app. + Thin UI layer over the engine-agnostic component (core + render + art). + Generates briefings, toggles Player / GM / Both, keeps a local library, + and prints / exports. No build step, no dependencies. */ + +import { generateBPN, randomSeed } from "../core/bpn-core.js"; +import { COLOURS } from "../core/data/colours.js"; +import { framesForColour } from "../core/data/frames.js"; +import { renderBrief, renderDossier, renderBoth, briefHTML, dossierHTML } from "../render/bpn-render.js"; + +const LIB_KEY = "bpn-library-v1"; + +const el = { + colour: document.getElementById("f-colour"), + frame: document.getElementById("f-frame"), + squad: document.getElementById("f-squad"), + payment: document.getElementById("f-payment"), + seed: document.getElementById("f-seed"), + generate: document.getElementById("btn-generate"), + reroll: document.getElementById("btn-reroll"), + useSeed: document.getElementById("btn-use-seed"), + copySeed: document.getElementById("btn-copy-seed"), + save: document.getElementById("btn-save"), + print: document.getElementById("btn-print"), + exportHtml: document.getElementById("btn-export"), + library: document.getElementById("library"), + output: document.getElementById("output"), + viewBtns: Array.from(document.querySelectorAll("[data-view]")) +}; + +let current = null; +let view = "player"; + +/* ---------- control population ---------- */ + +function populateColours() { + el.colour.innerHTML = + `` + + COLOURS.map((c) => ``).join(""); +} + +function populateFrames() { + const key = el.colour.value; + if (!key) { + el.frame.innerHTML = ``; + el.frame.disabled = true; + return; + } + el.frame.disabled = false; + el.frame.innerHTML = + `` + + framesForColour(key).map((f) => ``).join(""); +} + +/* ---------- generation ---------- */ + +function optsFromControls(seed) { + return { + seed, + colourKey: el.colour.value || undefined, + frameId: el.frame.value || undefined, + squadSize: Number(el.squad.value) || 4, + overrides: { + paymentType: el.payment.value || "__RANDOM__" + } + }; +} + +function generate(opts) { + current = generateBPN(opts); + el.seed.value = current.seed; + // reflect the resolved band/frame back into the controls + el.colour.value = current.colourKey; + populateFrames(); + el.frame.value = current.gm.frameId || ""; + draw(); +} + +function draw() { + el.output.classList.add("bpn"); + if (!current) return; + if (view === "player") renderBrief(current, el.output); + else if (view === "gm") renderDossier(current, el.output); + else renderBoth(current, el.output); +} + +/* ---------- library ---------- */ + +function loadLibrary() { + try { return JSON.parse(localStorage.getItem(LIB_KEY)) || []; } + catch { return []; } +} + +function saveLibrary(list) { + localStorage.setItem(LIB_KEY, JSON.stringify(list)); +} + +function renderLibrary() { + const list = loadLibrary(); + if (!list.length) { + el.library.innerHTML = `

No saved briefings yet.

`; + return; + } + el.library.innerHTML = list.map((item, i) => { + const band = COLOURS.find((c) => c.key === item.colourKey); + return `
+
+
${escapeAttr(item.label)}
+
${escapeAttr(item.colourKey)} · ${escapeAttr(item.seed)}
+
+ +
`; + }).join(""); +} + +function escapeAttr(s) { + return String(s ?? "").replace(/[<>&"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """ }[c])); +} + +function saveCurrent() { + if (!current) return; + const list = loadLibrary(); + const item = { + seed: current.seed, + colourKey: current.colourKey, + frameId: current.gm.frameId, + squadSize: current.squadSize, + paymentType: current.reward.paymentType, + label: `${current.colour.label} BPN — ${current.shortTitle}` + }; + // de-dupe by seed + const without = list.filter((x) => x.seed !== item.seed); + without.unshift(item); + saveLibrary(without.slice(0, 60)); + renderLibrary(); +} + +function loadItem(item) { + el.colour.value = item.colourKey || ""; + populateFrames(); + el.frame.value = item.frameId || ""; + el.squad.value = item.squadSize || 4; + el.payment.value = item.paymentType || ""; + generate({ + seed: item.seed, + colourKey: item.colourKey, + frameId: item.frameId, + squadSize: item.squadSize, + overrides: { paymentType: item.paymentType || "__RANDOM__" } + }); +} + +/* ---------- export ---------- */ + +function exportHtml() { + if (!current) return; + const css = document.getElementById("link-render").href; + const fonts = document.getElementById("link-fonts").href; + const doc = ` + +${escapeAttr(current.colour.label)} BPN — ${escapeAttr(current.shortTitle)} + + + +
${briefHTML(current)}${dossierHTML(current)}
`; + const blob = new Blob([doc], { type: "text/html" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `${current.seed}-${current.colourKey}.html`; + a.click(); + URL.revokeObjectURL(a.href); +} + +/* ---------- wiring ---------- */ + +function setView(next) { + view = next; + el.viewBtns.forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.view === next))); + draw(); +} + +el.colour.addEventListener("change", populateFrames); +el.generate.addEventListener("click", () => generate(optsFromControls(randomSeed()))); +el.reroll.addEventListener("click", () => generate(optsFromControls(randomSeed()))); +el.useSeed.addEventListener("click", () => generate(optsFromControls(el.seed.value.trim() || randomSeed()))); +el.copySeed.addEventListener("click", async () => { + try { await navigator.clipboard.writeText(el.seed.value); el.copySeed.textContent = "Copied"; setTimeout(() => (el.copySeed.textContent = "Copy"), 1200); } catch {} +}); +el.save.addEventListener("click", saveCurrent); +el.print.addEventListener("click", () => window.print()); +el.exportHtml.addEventListener("click", exportHtml); +el.viewBtns.forEach((b) => b.addEventListener("click", () => setView(b.dataset.view))); + +el.library.addEventListener("click", (e) => { + const del = e.target.closest("[data-del]"); + const load = e.target.closest("[data-load]"); + if (del) { + const list = loadLibrary(); + list.splice(Number(del.dataset.del), 1); + saveLibrary(list); + renderLibrary(); + } else if (load) { + loadItem(loadLibrary()[Number(load.dataset.load)]); + } +}); + +/* ---------- boot ---------- */ +populateColours(); +populateFrames(); +renderLibrary(); +setView("player"); +generate(optsFromControls(randomSeed())); diff --git a/art/bpn-art.js b/art/bpn-art.js new file mode 100644 index 0000000..c3f33c7 --- /dev/null +++ b/art/bpn-art.js @@ -0,0 +1,337 @@ +/* Blueprint News — procedural SVG artwork. + * + * Flat, graphic, stencil/propaganda-poster styling: hard angles, heavy blacks, + * a single colour-band accent, halftone and scanline textures. Deliberately + * NOT photorealistic. All shapes derive from the BPN seed, so a given briefing + * always renders the same art while every briefing differs. + * + * Each colour band gets its own SCENE — a foreground motif (sewer arch, transit + * concourse, perimeter wall, vault corridor, …) plus tuned atmosphere and + * lighting — so a Blue street job and a Black covert op never look alike. + * + * No DOM and no engine APIs — each function returns an SVG markup string. + */ + +/* --- seeded rng (local copy so art is usable standalone) ----------------- */ + +function hashSeed(str) { + let h = 2166136261 >>> 0; + const s = String(str); + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function mulberry32(a) { + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function rngFor(seed, salt) { + return mulberry32(hashSeed(`${seed}::${salt}`)); +} + +const rint = (rng, lo, hi) => Math.floor(rng() * (hi - lo + 1)) + lo; +const rfloat = (rng, lo, hi) => rng() * (hi - lo) + lo; +const r2 = (n) => Math.round(n * 100) / 100; + +/* --- colour helpers ------------------------------------------------------ */ + +function hexToRgb(hex) { + const h = String(hex || "#dce8f9").replace("#", ""); + const n = h.length === 3 ? h.split("").map((c) => c + c).join("") : h; + return { r: parseInt(n.slice(0, 2), 16), g: parseInt(n.slice(2, 4), 16), b: parseInt(n.slice(4, 6), 16) }; +} + +function mix(hex, withHex, t) { + const a = hexToRgb(hex); + const b = hexToRgb(withHex); + const c = (k) => Math.round(a[k] + (b[k] - a[k]) * t); + return `rgb(${c("r")},${c("g")},${c("b")})`; +} + +function rgba(hex, alpha) { + const { r, g, b } = hexToRgb(hex); + return `rgba(${r},${g},${b},${alpha})`; +} + +/* --- per-band scene definitions ----------------------------------------- */ +/* motif: which foreground silhouette to draw. + rain/smog/crowd: atmospheric layer. floodlights/beacons: lighting. + density: building/back-skyline busyness (0 sparse … 1 dense). */ +const SCENES = { + blue: { motif: "tunnel", rain: 0.8, smog: 0, crowd: 0, floodlights: false, beacons: false, density: 0.5 }, + yellow: { motif: "transit", rain: 0, smog: 0.2, crowd: 0.9, floodlights: false, beacons: false, density: 0.7 }, + green: { motif: "wall", rain: 0.5, smog: 0.1, crowd: 0, floodlights: true, beacons: false, density: 0.5 }, + white: { motif: "cordon", rain: 0.2, smog: 0.1, crowd: 0, floodlights: false, beacons: false, density: 0.6 }, + grey: { motif: "gantry", rain: 0, smog: 0.6, crowd: 0, floodlights: false, beacons: false, density: 0.8 }, + silver: { motif: "stage", rain: 0, smog: 0, crowd: 0.7, floodlights: true, beacons: false, density: 0.6 }, + jade: { motif: "canal", rain: 0.3, smog: 0.8, crowd: 0, floodlights: false, beacons: false, density: 0.5 }, + red: { motif: "tower", rain: 0.6, smog: 0.3, crowd: 0, floodlights: true, beacons: true, density: 0.7 }, + black: { motif: "vault", rain: 0, smog: 0, crowd: 0, floodlights: false, beacons: false, density: 0.2 }, + platinum: { motif: "exec", rain: 0, smog: 0.1, crowd: 0, floodlights: false, beacons: false, density: 0.3 } +}; + +/* Mission-focus glyph drawn inside the crest pupil. */ +function focusGlyph(focus = "", accent = "#fff") { + const f = String(focus).toLowerCase(); + const stroke = `stroke="${accent}" stroke-width="3.5" fill="none" stroke-linecap="round" stroke-linejoin="round"`; + if (f.includes("eliminate") || f.includes("hunt") || f.includes("kill")) return ``; + if (f.includes("escort") || f.includes("protect")) return ``; + if (f.includes("retrieve")) return ``; + if (f.includes("investigate") || f.includes("profile")) return ``; + if (f.includes("contain") || f.includes("quarantine")) return ``; + if (f.includes("riot") || f.includes("crowd")) return ``; + return ``; +} + +/* ------------------------------------------------------------------------ */ +/* CREST — the BPN sigil. */ +/* ------------------------------------------------------------------------ */ + +export function crestSVG(bpn, { size = 128 } = {}) { + const accent = bpn?.colour?.accent ?? "#dce8f9"; + const seed = bpn?.seed ?? "BPN"; + const uid = String(seed).replace(/[^A-Za-z0-9]/g, ""); + const rng = rngFor(seed, "crest"); + const dark = "#101722"; + const rim = mix(accent, "#ffffff", 0.12); + const glyph = focusGlyph(bpn?.missionFocus ?? "", mix(accent, "#ffffff", 0.25)); + const rivet = (x, y) => ``; + const ticks = Array.from({ length: 12 }, (_, i) => { + const a = (i / 12) * Math.PI * 2; + const x1 = 64 + Math.cos(a) * 50, y1 = 64 + Math.sin(a) * 50; + const x2 = 64 + Math.cos(a) * (i % 3 === 0 ? 44 : 47), y2 = 64 + Math.sin(a) * (i % 3 === 0 ? 44 : 47); + return ``; + }).join(""); + const jitter = r2(rfloat(rng, -2, 2)); + + return ` + + + + + + + + + ${ticks} + ${rivet(20, 20)}${rivet(108, 20)}${rivet(20, 108)}${rivet(108, 108)} + + + + + ${glyph} + +`; +} + +/* ------------------------------------------------------------------------ */ +/* FOREGROUND MOTIFS — one per scene. Each returns markup positioned within */ +/* a 1200x360 banner space (W,H passed in). */ +/* ------------------------------------------------------------------------ */ + +function litWindowGrid(rng, x, y, w, h, accent, alpha, step = 14) { + const out = []; + for (let wx = x + 6; wx < x + w - 6; wx += step) { + for (let wy = y + 8; wy < y + h - 6; wy += 18) { + if (rng() < 0.32) out.push(``); + } + } + return out.join(""); +} + +const MOTIFS = { + // generic angular skyline used by most street-level scenes + skyline(rng, W, H, accent, density) { + let x = -20; const out = []; + const gap = density > 0.6 ? [2, 8] : [6, 16]; + while (x < W + 20) { + const w = rint(rng, 34, 96), h = rint(rng, H * 0.4, H * 0.82), top = H + 4 - h; + out.push(``); + if (rng() < 0.4) { const mx = x + rint(rng, 6, w - 6); out.push(``); } + out.push(litWindowGrid(rng, x, top, w, H - top, accent, 0.85)); + x += w + rint(rng, gap[0], gap[1]); + } + return out.join(""); + }, + tunnel(rng, W, H, accent) { + const cx = W * rfloat(rng, 0.4, 0.6), out = []; + for (let i = 0; i < 5; i++) { const ry = H * (0.34 + i * 0.11), rx = ry * 1.5; out.push(``); } + out.push(``); + for (let i = 0; i < 26; i++) { const x = rfloat(rng, 0, W); out.push(``); } // water glints + out.push(``); // side pipes + return out.join(""); + }, + transit(rng, W, H, accent) { + const out = [MOTIFS.skyline(rng, W, H, accent, 0.7)]; + for (let i = 0; i < 6; i++) { const x = (i + 0.5) * (W / 6); out.push(``); if (rng() < 0.8) out.push(``); } // pillars + route boards + return out.join(""); + }, + wall(rng, W, H, accent) { + const out = [], top = H * 0.34; + out.push(``); + for (let x = 0; x < W; x += 64) out.push(``); // plating + for (let x = 0; x < W; x += 64) out.push(``); // battlements + return out.join(""); + }, + cordon(rng, W, H, accent) { + const out = [MOTIFS.skyline(rng, W, H, accent, 0.6)]; + // diagonal cordon tape across the lower third + for (let k = -1; k <= 1; k += 2) { const y = H * (0.72 + k * 0.06); out.push(``); } + return out.join(""); + }, + gantry(rng, W, H, accent) { + const out = [MOTIFS.skyline(rng, W, H, accent, 0.8)]; + for (let i = 0; i < 10; i++) { const x = rfloat(rng, 0, W - 70), y = H - rint(rng, 24, 70); out.push(``); } // containers + out.push(``); // gantry crane + return out.join(""); + }, + stage(rng, W, H, accent) { + const out = [MOTIFS.skyline(rng, W, H, accent, 0.5)]; + out.push(``); // truss + for (let i = 0; i < 5; i++) { const x = W * (0.28 + i * 0.11); out.push(``); } // spotlight cones + return out.join(""); + }, + canal(rng, W, H, accent) { + const out = [MOTIFS.skyline(rng, W, H, accent, 0.5)]; + out.push(``); // canal + for (let i = 0; i < 7; i++) { const x = rfloat(rng, 30, W - 30); out.push(``); } // hazard barrels + return out.join(""); + }, + tower(rng, W, H, accent) { + const out = [MOTIFS.skyline(rng, W, H, accent, 0.7)]; + const tx = W * rfloat(rng, 0.4, 0.6); + out.push(``); // broadcast tower + out.push(``); + out.push(``); // monorail line + return out.join(""); + }, + vault(rng, W, H, accent) { + const cx = W * 0.5, out = []; + for (let i = 0; i < 6; i++) { const s = 1 - i * 0.15, w = W * 0.5 * s, h = H * 0.9 * s; out.push(``); } // receding corridor + out.push(``); // far door + for (let i = 0; i < 5; i++) out.push(``); // biometric panel + return out.join(""); + }, + exec(rng, W, H, accent) { + const out = []; let x = 40; + while (x < W - 40) { const w = rint(rng, 70, 120), h = rint(rng, H * 0.55, H * 0.95), top = H + 4 - h; out.push(``); out.push(litWindowGrid(rng, x, top, w, H - top, accent, 0.4, 18)); x += w + rint(rng, 40, 90); } + out.push(``); + return out.join(""); + } +}; + +/* ------------------------------------------------------------------------ */ +/* BANNER — the wide hero backdrop, composed from the band's scene. */ +/* ------------------------------------------------------------------------ */ + +export function bannerSVG(bpn, { width = 1200, height = 360 } = {}) { + const accent = bpn?.colour?.accent ?? "#dce8f9"; + const key = bpn?.colourKey ?? "white"; + const seed = bpn?.seed ?? "BPN"; + const uid = String(seed).replace(/[^A-Za-z0-9]/g, ""); + const scene = SCENES[key] ?? SCENES.white; + const rng = rngFor(seed, "banner"); + const dark = "#0b1018"; + const W = width, H = height; + + // hazy back skyline for depth (skip for vault/exec which set their own mood) + let back = ""; + if (!["vault", "exec"].includes(scene.motif)) { + let x = -20; const out = []; + while (x < W + 20) { const w = rint(rng, 40, 96), h = rint(rng, H * 0.28, H * 0.6), top = H * 0.92 - h; out.push(``); x += w + rint(rng, 4, 14); } + back = out.join(""); + } + + const fg = (MOTIFS[scene.motif] ?? MOTIFS.skyline)(rng, W, H, accent, scene.density); + + // searchlight beams + const beams = Array.from({ length: rint(rng, 2, 4) }, () => { const ox = rfloat(rng, 0, W), s = rfloat(rng, 40, 120); return ``; }).join(""); + + // atmosphere layers + let rain = "", smog = "", crowd = ""; + if (scene.rain > 0) rain = Array.from({ length: Math.round(scene.rain * 110) }, () => { const x = rfloat(rng, 0, W), y = rfloat(rng, 0, H), len = rfloat(rng, 3, 11); return ``; }).join(""); + if (scene.smog > 0) smog = Array.from({ length: Math.round(scene.smog * 5) }, (_, i) => ``).join(""); + if (scene.crowd > 0) crowd = Array.from({ length: Math.round(scene.crowd * 90) }, () => { const x = rfloat(rng, 0, W), y = rfloat(rng, H * 0.82, H - 4); return ``; }).join(""); + + // lighting + let floods = "", beaconGlow = ""; + if (scene.floodlights) floods = Array.from({ length: rint(rng, 2, 3) }, () => { const x = rfloat(rng, W * 0.2, W * 0.8); return ``; }).join(""); + if (scene.beacons) beaconGlow = Array.from({ length: rint(rng, 2, 4) }, () => ``).join(""); + + // hazard chevrons for high-alert bands + let hazard = ""; + if (["red", "black", "jade", "green"].includes(key)) { const bandY = H - 16, chev = []; for (let x = -20; x < W + 20; x += 28) chev.push(``); hazard = `${chev.join("")}`; } + + // giant faint Third Eye + const eyeX = rng() < 0.5 ? W * 0.78 : W * 0.22, eyeScale = rfloat(rng, 1.6, 2.2); + const eye = ``; + + return ` + + + + + + + + + + ${beams}${beaconGlow}${smog} + ${eye} + ${back} + ${fg} + ${floods}${crowd}${rain} + ${hazard} + + + +`; +} + +/* ------------------------------------------------------------------------ */ +/* PORTRAIT — a stylised halftone contact silhouette for the brief. */ +/* Flat shoulders + head bust, halftone dot shading, accent rim. Not a face: */ +/* an anonymous corporate ID-photo abstraction, deliberately non-realistic. */ +/* ------------------------------------------------------------------------ */ + +export function portraitSVG(bpn, { size = 120 } = {}) { + const accent = bpn?.colour?.accent ?? "#dce8f9"; + const seed = bpn?.seed ?? "BPN"; + const uid = String(seed).replace(/[^A-Za-z0-9]/g, "") + "p"; + const rng = rngFor(seed, "portrait"); + const dark = "#0c1119"; + // halftone dots over the bust area + const dots = []; + for (let y = 18; y < 120; y += 8) { + for (let x = 12; x < 108; x += 8) { + const d = Math.hypot(x - 60, y - 70); + const p = Math.max(0, 1 - d / 70); + if (rng() < p * 0.7) dots.push(``); + } + } + return ` + + + + + ${dots.join("")} + + + + +`; +} + +/* Convenience: data URI for the crest (handy as an src or journal icon). */ +export function crestDataURI(bpn, opts) { + return `data:image/svg+xml;utf8,${encodeURIComponent(crestSVG(bpn, opts))}`; +} diff --git a/assets/fonts/fonts-google.css b/assets/fonts/fonts-google.css new file mode 100644 index 0000000..512331c --- /dev/null +++ b/assets/fonts/fonts-google.css @@ -0,0 +1,270 @@ +/* vietnamese */ +@font-face { + font-family: 'Josefin Sans'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(g-joefinan-1.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Josefin Sans'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(g-joefinan-0.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Josefin Sans'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(g-joefinan-2.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* vietnamese */ +@font-face { + font-family: 'Josefin Sans'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(g-joefinan-1.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Josefin Sans'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(g-joefinan-0.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Josefin Sans'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(g-joefinan-2.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* vietnamese */ +@font-face { + font-family: 'Josefin Sans'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(g-joefinan-1.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Josefin Sans'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(g-joefinan-0.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Josefin Sans'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(g-joefinan-2.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* vietnamese */ +@font-face { + font-family: 'Josefin Sans'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(g-joefinan-1.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Josefin Sans'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(g-joefinan-0.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Josefin Sans'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(g-joefinan-2.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(g-robotomono-5.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(g-robotomono-8.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(g-robotomono-6.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(g-robotomono-3.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(g-robotomono-4.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(g-robotomono-7.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(g-robotomono-5.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(g-robotomono-8.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(g-robotomono-6.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(g-robotomono-3.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(g-robotomono-4.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url(g-robotomono-7.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} +/* cyrillic-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(g-robotomono-5.woff2) format('woff2'); + unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} +/* cyrillic */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(g-robotomono-8.woff2) format('woff2'); + unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} +/* greek */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(g-robotomono-6.woff2) format('woff2'); + unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF; +} +/* vietnamese */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(g-robotomono-3.woff2) format('woff2'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; +} +/* latin-ext */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(g-robotomono-4.woff2) format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} +/* latin */ +@font-face { + font-family: 'Roboto Mono'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url(g-robotomono-7.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/assets/fonts/fonts.css b/assets/fonts/fonts.css new file mode 100644 index 0000000..2b533f6 --- /dev/null +++ b/assets/fonts/fonts.css @@ -0,0 +1,22 @@ +/* Bundled SLA Industries house faces — fully local, no network required. + Josefin Sans (display + UI) and Roboto Mono (data/labels) come from the + rewritten Google CSS; Modesto Condensed (heavy industrial wordmark) is + carried over from the SLA-BRP system. */ + +@import url("fonts-google.css"); + +@font-face { + font-family: "Modesto Condensed"; + src: url("modesto-condensed.woff2") format("woff2"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Modesto Condensed"; + src: url("modesto-condensed-bold.woff2") format("woff2"); + font-weight: 700; + font-style: normal; + font-display: swap; +} diff --git a/assets/fonts/g-joefinan-0.woff2 b/assets/fonts/g-joefinan-0.woff2 new file mode 100644 index 0000000..30e7717 Binary files /dev/null and b/assets/fonts/g-joefinan-0.woff2 differ diff --git a/assets/fonts/g-joefinan-1.woff2 b/assets/fonts/g-joefinan-1.woff2 new file mode 100644 index 0000000..242a741 Binary files /dev/null and b/assets/fonts/g-joefinan-1.woff2 differ diff --git a/assets/fonts/g-joefinan-2.woff2 b/assets/fonts/g-joefinan-2.woff2 new file mode 100644 index 0000000..547aed3 Binary files /dev/null and b/assets/fonts/g-joefinan-2.woff2 differ diff --git a/assets/fonts/g-robotomono-3.woff2 b/assets/fonts/g-robotomono-3.woff2 new file mode 100644 index 0000000..2c06834 Binary files /dev/null and b/assets/fonts/g-robotomono-3.woff2 differ diff --git a/assets/fonts/g-robotomono-4.woff2 b/assets/fonts/g-robotomono-4.woff2 new file mode 100644 index 0000000..532a888 Binary files /dev/null and b/assets/fonts/g-robotomono-4.woff2 differ diff --git a/assets/fonts/g-robotomono-5.woff2 b/assets/fonts/g-robotomono-5.woff2 new file mode 100644 index 0000000..b02e2d6 Binary files /dev/null and b/assets/fonts/g-robotomono-5.woff2 differ diff --git a/assets/fonts/g-robotomono-6.woff2 b/assets/fonts/g-robotomono-6.woff2 new file mode 100644 index 0000000..ae2f9eb Binary files /dev/null and b/assets/fonts/g-robotomono-6.woff2 differ diff --git a/assets/fonts/g-robotomono-7.woff2 b/assets/fonts/g-robotomono-7.woff2 new file mode 100644 index 0000000..bfa169c Binary files /dev/null and b/assets/fonts/g-robotomono-7.woff2 differ diff --git a/assets/fonts/g-robotomono-8.woff2 b/assets/fonts/g-robotomono-8.woff2 new file mode 100644 index 0000000..8a15f5c Binary files /dev/null and b/assets/fonts/g-robotomono-8.woff2 differ diff --git a/assets/fonts/modesto-condensed-bold.woff2 b/assets/fonts/modesto-condensed-bold.woff2 new file mode 100644 index 0000000..642c594 Binary files /dev/null and b/assets/fonts/modesto-condensed-bold.woff2 differ diff --git a/assets/fonts/modesto-condensed.woff2 b/assets/fonts/modesto-condensed.woff2 new file mode 100644 index 0000000..bf6a617 Binary files /dev/null and b/assets/fonts/modesto-condensed.woff2 differ diff --git a/core/bpn-core.js b/core/bpn-core.js new file mode 100644 index 0000000..55ff859 --- /dev/null +++ b/core/bpn-core.js @@ -0,0 +1,319 @@ +/* Blueprint News — engine-agnostic generation core. + * + * No DOM, no game-engine APIs. Given a seed (and optional overrides) it + * produces a fully-resolved BPN object that is identical every time for the + * same inputs — which lets the renderer and the procedural art stay in sync. + * + * The returned object deliberately separates what the squad is allowed to see + * (top-level fields) from what only the GM sees (the `gm` block). + */ + +import { COLOURS, COLOUR_ROLL_TABLE, colourByKey } from "./data/colours.js"; +import { TABLES, COLOUR_MISSION_IDEAS } from "./data/tables.js"; +import { framesForColour } from "./data/frames.js"; + +/* ---- seeded randomness -------------------------------------------------- */ + +function hashSeed(str) { + let h = 2166136261 >>> 0; + const s = String(str); + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 16777619); + } + return h >>> 0; +} + +function mulberry32(a) { + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function makeRng(seed) { + return mulberry32(hashSeed(seed)); +} + +/** A fresh human-friendly seed, e.g. "BPN-7F3A-21C9". Uses Math.random by + * design — call this once at the app layer, then everything downstream is + * deterministic from the returned string. */ +export function randomSeed() { + const block = () => Math.floor(Math.random() * 0xffff).toString(16).toUpperCase().padStart(4, "0"); + return `BPN-${block()}-${block()}`; +} + +/* ---- small helpers ------------------------------------------------------ */ + +function pick(rng, arr) { + return Array.isArray(arr) && arr.length ? arr[Math.floor(rng() * arr.length)] : ""; +} + +function rollDie(rng, faces) { + return Math.floor(rng() * faces) + 1; +} + +function pad(n, width = 3) { + return String(n).padStart(width, "0"); +} + +function take(value, fallback = "") { + const v = String(value ?? "").trim(); + return v || String(fallback ?? "").trim(); +} + +function clampSquad(value) { + const n = Number(value ?? 4); + if (!Number.isFinite(n)) return 4; + return Math.max(1, Math.min(8, Math.round(n))); +} + +function formatScl(value = 0) { + const n = Number(value ?? 0); + if (!Number.isFinite(n)) return "+0.0"; + const fixed = Math.abs(n) >= 1 ? n.toFixed(1) : n.toFixed(2); + const trimmed = fixed.replace(/\.?0+$/, ""); + return `${n >= 0 ? "+" : ""}${trimmed}`; +} + +/* ---- reward / SCL maths ------------------------------------------------- */ + +function creditsFromRange(range = { min: 0, max: 0 }, tier = "mid") { + const min = Number(range?.min ?? 0); + const max = range?.max == null ? null : Number(range.max); + if (!Number.isFinite(min)) return 0; + if (max === null || !Number.isFinite(max)) { + if (tier === "low") return Math.round(min); + if (tier === "mid") return Math.round(min * 1.15); + return Math.round(min * 1.35); + } + if (tier === "low") return Math.round(min); + if (tier === "high") return Math.round(max); + return Math.round((min + max) / 2); +} + +function tierFromRoll(roll = 1) { + if (roll <= 2) return "low"; + if (roll <= 4) return "mid"; + return "high"; +} + +function mediaFromRoll(roll = 1) { + if (roll <= 1) return "None"; + if (roll <= 3) return "Station Analysis"; + if (roll <= 5) return "Third Eye News"; + return "GoreZone / Contract Circuit Special"; +} + +function rewardPackage(rng, colour, input) { + const paymentTypeRoll = rollDie(rng, 6); + const cbsTierRoll = rollDie(rng, 6); + const sclTierRoll = rollDie(rng, 6); + const mediaRoll = rollDie(rng, 6); + const financierRoll = rollDie(rng, 6); + + const squadSize = clampSquad(input.squadSize); + const paymentType = input.paymentType && input.paymentType !== "__RANDOM__" + ? String(input.paymentType) + : (paymentTypeRoll <= 4 ? "Per Squad" : "Per Operative"); + const cbsTier = tierFromRoll(cbsTierRoll); + const sclTier = tierFromRoll(sclTierRoll); + + const grossPerOp = creditsFromRange(colour.cbsPerOpRange, cbsTier); + const grossPerSquad = creditsFromRange(colour.cbsPerSquadRange, cbsTier); + const grossContractValue = paymentType === "Per Operative" ? grossPerOp * squadSize : grossPerSquad; + + const financierCutPercent = input.financierCutPercent + ? Number(input.financierCutPercent) + : (financierRoll <= 2 ? 10 : financierRoll <= 4 ? 15 : 20); + const netContract = Math.max(0, Math.round(grossContractValue * (1 - financierCutPercent / 100))); + const takeHomeEach = Math.max(1, Math.floor(netContract / squadSize)); + + const sclByTier = { + low: Number(colour.sclRange?.min ?? 0.1), + mid: Number(colour.sclRange?.typical ?? colour.sclRange?.min ?? 0.1), + high: Number(colour.sclRange?.max ?? colour.sclRange?.typical ?? 0.2) + }; + + return { + paymentType, + squadSize, + grossContractValue, + netContract, + takeHomeEach, + financierCutPercent, + cbsBandOp: `${colour.cbsPerOpRange?.min ?? 0}c - ${colour.cbsPerOpRange?.max ?? "?"}c`, + cbsBandSquad: `${colour.cbsPerSquadRange?.min ?? 0}c - ${colour.cbsPerSquadRange?.max ?? "?"}c`, + sclIncrease: formatScl(sclByTier[sclTier] ?? sclByTier.mid), + mediaCoverage: input.mediaCoverage && input.mediaCoverage !== "__RANDOM__" + ? String(input.mediaCoverage) + : mediaFromRoll(mediaRoll), + notes: `Financier cut assumed at ${financierCutPercent}%. Take-home is after the financier slice, split across ${squadSize} Operatives.`, + rolls: { paymentType: paymentTypeRoll, cbsTier: cbsTierRoll, sclTier: sclTierRoll, media: mediaRoll, financier: financierRoll } + }; +} + +/* ---- derived prose ------------------------------------------------------ */ + +function objectiveFromFocus(focusText = "") { + const t = String(focusText ?? "").toLowerCase(); + if (t.includes("eliminate")) return "Neutralise the designated threat and confirm status."; + if (t.includes("escort") || t.includes("protect")) return "Keep the protected asset moving and alive through the full route."; + if (t.includes("retrieve")) return "Recover the designated item, data, or specimen intact and preserve chain of custody."; + if (t.includes("investigate")) return "Identify culprit(s), secure evidence, and produce an admissible corporate finding."; + if (t.includes("contain") || t.includes("quarantine")) return "Lock the threat down before it jumps sectors."; + if (t.includes("riot") || t.includes("crowd")) return "Restore order without losing the public narrative."; + if (t.includes("hunt")) return "Track, isolate, and capture or terminate the named target."; + if (t.includes("clean up")) return "Recover all traces of the prior SLA incident before public disclosure."; + if (t.includes("sabotage") || t.includes("destroy")) return "Disable the objective and leave a believable false story behind."; + return "Complete the assigned contract objective and report all outcomes."; +} + +function cleanupDirective(colourKey = "blue") { + const k = String(colourKey ?? "blue").toLowerCase(); + const map = { + blue: "Restore basic street function and hand the site back looking manageable.", + yellow: "Protect the recovered asset's chain of custody at all times.", + green: "Confirm every kill and leave nothing twitching for the cameras.", + white: "Return with evidence, witnesses, and a narrative command can sign off on.", + grey: "Keep the paperwork clean and the department's name out of the fallout.", + silver: "Keep public optics sponsor-safe from briefing to extraction.", + jade: "Seal contamination and route all samples through Karma only.", + red: "Stabilise the zone first, then explain it to the cameras.", + black: "Remove traces, minimise witnesses, and suppress operational signatures.", + platinum: "Preserve classification and return all material to Head Office only." + }; + return map[k] ?? "Recover evidence, suppress compromising exposure, and file a command-ready debrief."; +} + +/** Trim trailing whitespace/punctuation so clauses join cleanly. */ +function trimEnd(s = "") { + return String(s).replace(/[.\s]+$/, ""); +} + +/** Lowercase only the first letter — keeps acronyms (SLA) and names (DarkNight). */ +function lcFirst(s = "") { + const v = String(s); + return v.charAt(0).toLowerCase() + v.slice(1); +} + +function buildMissionBrief(b) { + const objective = lcFirst(trimEnd(objectiveFromFocus(b.missionFocus))); + const loc = trimEnd(b.location); + const opp = lcFirst(trimEnd(b.opposition)); + return [ + `${b.issuingDepartment} has issued ${b.bpnId}, a ${b.colour.label.toUpperCase()} ${b.colour.type.toLowerCase()} contract.`, + `${b.contact.name} — ${b.contact.role} — wants the squad to ${objective}.`, + `Operational spine: ${loc}.`, + `${b.hook}`, + `Expect ${opp}.`, + `Media posture is ${b.reward.mediaCoverage.toLowerCase()}; command expects a clean narrative.` + ].join(" "); +} + +/* ---- public API --------------------------------------------------------- */ + +/** + * Resolve a complete BPN from inputs. + * @param {object} opts + * @param {string} [opts.seed] Reproducibility seed; one is generated if absent. + * @param {string} [opts.colourKey] Force a colour band; otherwise rolled on the d20 table. + * @param {string} [opts.frameId] Force a scenario frame within the band. + * @param {object} [opts.overrides] Any field to pin to a specific value (e.g. {location: "..."}). + * @param {number} [opts.squadSize] Default 4. + */ +export function generateBPN(opts = {}) { + const seed = take(opts.seed, randomSeed()); + const rng = makeRng(seed); + const ov = opts.overrides ?? {}; + + // Colour band: forced, or rolled on the d20 issue table. + let colour; + let colourRoll = 0; + if (opts.colourKey) { + colour = colourByKey(opts.colourKey); + } else { + colourRoll = rollDie(rng, 20); + const row = COLOUR_ROLL_TABLE.find((r) => colourRoll >= r.min && colourRoll <= r.max) ?? COLOUR_ROLL_TABLE[0]; + colour = colourByKey(row.key); + } + + // Scenario frame within the band. + const frames = framesForColour(colour.key); + const frame = (opts.frameId && frames.find((f) => f.id === opts.frameId)) || pick(rng, frames); + + const reward = rewardPackage(rng, colour, { squadSize: opts.squadSize ?? ov.squadSize, ...ov }); + + const contactName = take( + ov.contactName, + `${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}` + ); + + const hookFallback = frame.colourMissionIdea || pick(rng, COLOUR_MISSION_IDEAS[colour.key] ?? []); + + const bpn = { + seed, + bpnId: take(ov.bpnId, `${colour.label.toUpperCase().slice(0, 2)}-${pad(rollDie(rng, 999))}-${pad(rollDie(rng, 999))}`), + shortTitle: take(ov.shortTitle, frame.shortTitle), + colourKey: colour.key, + colour, + issuingDepartment: take(ov.issuingDepartment, frame.issuingDepartment || pick(rng, TABLES.issuingDepartments)), + squadSize: reward.squadSize, + sclRequirement: take(ov.sclRequirement, colour.sclReq), + + contact: { + name: contactName, + role: take(ov.contactRole, frame.contactRole), + location: take(ov.contactLocation, frame.contactLocation) + }, + + missionFocus: take(ov.missionFocus, frame.missionFocus || pick(rng, TABLES.missionFocus)), + hook: take(ov.hook, hookFallback), + objective: objectiveFromFocus(take(ov.missionFocus, frame.missionFocus)), + location: take(ov.location, frame.location || pick(rng, TABLES.locations)), + atmosphere: take(ov.atmosphere, frame.atmosphere || pick(rng, TABLES.atmospheres)), + opposition: take(ov.opposition, frame.opposition || pick(rng, TABLES.opposition)), + civilianFaction: take(ov.civilianFaction, frame.civilianFaction || pick(rng, TABLES.civilianFactions)), + extraObjective: take(ov.extraObjective, frame.extraObjective || pick(rng, TABLES.extraObjectives)), + cleanupDirective: cleanupDirective(colour.key), + + reward: { + paymentType: reward.paymentType, + grossContract: `${reward.grossContractValue}c (${reward.paymentType})`, + grossContractValue: reward.grossContractValue, + takeHomeEach: `${reward.takeHomeEach}c each`, + financierCut: `${reward.financierCutPercent}%`, + financierCutPercent: reward.financierCutPercent, + sclIncrease: reward.sclIncrease, + mediaCoverage: reward.mediaCoverage, + cbsBandOp: reward.cbsBandOp, + cbsBandSquad: reward.cbsBandSquad + }, + + // Seam for a future NPC/monster module: archetype keys only, no stat schema. + npcSeam: { + contactArchetype: frame.contactArchetype ?? null, + threatArchetype: frame.threatArchetype ?? null, + threatRole: frame.threatRole ?? null + }, + + // GM-only truth. The renderer keeps this on a separate surface. + gm: { + complication: take(ov.complication, frame.complication || pick(rng, TABLES.complications)), + localTwist: take(ov.localTwist, frame.localTwist || pick(rng, TABLES.localTwists)), + oppositionAttitude: take(ov.oppositionAttitude, frame.oppositionAttitude || pick(rng, TABLES.oppositionAttitudes)), + escalationEvent: take(ov.escalationEvent, frame.escalationEvent || pick(rng, TABLES.escalationEvents)), + threatLead: take(ov.threatLead, frame.threatRole), + threatCountLabel: take(ov.threatCountLabel, frame.threatCountLabel), + financierNote: reward.notes, + frameId: frame.id, + rolls: { colour: colourRoll, ...reward.rolls } + } + }; + + bpn.missionBrief = take(ov.missionBrief, buildMissionBrief(bpn)); + return bpn; +} diff --git a/core/data/colours.js b/core/data/colours.js new file mode 100644 index 0000000..b4ee944 --- /dev/null +++ b/core/data/colours.js @@ -0,0 +1,156 @@ +/* SLA Industries BPN colour bands — the spine of the whole system. + Pure data, no engine or DOM dependencies. Each band carries its + classification, pay/SCL/media profile, and a render accent. */ + +export const COLOURS = [ + { + key: "blue", + label: "Blue", + type: "Street Maintenance", + summary: "Street maintenance and dirty clean-up contracts.", + details: "Sewer clearances, vermin extermination, infrastructure support, and low-end evictions.", + sclReq: "10 - 9", + sclRange: { min: 0.05, typical: 0.1, max: 0.2 }, + cbsPerOpRange: { min: 50, max: 150 }, + cbsPerSquadRange: { min: 100, max: 300 }, + mediaCoverage: "Rare / None", + accent: "#57b3ff" + }, + { + key: "yellow", + label: "Yellow", + type: "Retrieval", + summary: "Recover a person, item, data package, or specimen.", + details: "Primary objective is bring it back, often issued by Housing, Cloak, or Karma.", + sclReq: "10 - 9", + sclRange: { min: 0.05, typical: 0.1, max: 0.2 }, + cbsPerOpRange: { min: 100, max: 200 }, + cbsPerSquadRange: { min: 200, max: 400 }, + mediaCoverage: "Station Analysis / None", + accent: "#ffdb6e" + }, + { + key: "green", + label: "Green", + type: "Cloak / Extermination", + summary: "Combat-focused extermination and suppression assignments.", + details: "Kill or capture designated threats before they escalate into full emergency response.", + sclReq: "10 - 8", + sclRange: { min: 0.1, typical: 0.1, max: 0.3 }, + cbsPerOpRange: { min: 100, max: 250 }, + cbsPerSquadRange: { min: 200, max: 500 }, + mediaCoverage: "Station Analysis / Third Eye", + accent: "#78d48f" + }, + { + key: "white", + label: "White", + type: "Investigation", + summary: "Detective and analytical assignments.", + details: "Evidence-led cases with witness work, profiling, and pattern analysis.", + sclReq: "10 - 7", + sclRange: { min: 0.1, typical: 0.1, max: 0.3 }, + cbsPerOpRange: { min: 150, max: 300 }, + cbsPerSquadRange: { min: 250, max: 500 }, + mediaCoverage: "Third Eye / Station Analysis", + accent: "#dce8f9" + }, + { + key: "grey", + label: "Grey", + type: "Departmental Enforcement / Corporate", + summary: "Internal enforcement and politically sensitive corporate work.", + details: "Escort, asset protection, audit support, and departmentally awkward clean-ups.", + sclReq: "9 - 7", + sclRange: { min: 0.1, typical: 0.2, max: 0.3 }, + cbsPerOpRange: { min: 200, max: 400 }, + cbsPerSquadRange: { min: 300, max: 600 }, + mediaCoverage: "Station Analysis", + accent: "#9da6b3" + }, + { + key: "silver", + label: "Silver", + type: "Diplomatic / Escort / PR", + summary: "High-profile, social, and reputation-sensitive contracts.", + details: "VIP escort, public appearances, negotiations, and camera-facing operations.", + sclReq: "9 - 6", + sclRange: { min: 0.1, typical: 0.2, max: 0.4 }, + cbsPerOpRange: { min: 200, max: 500 }, + cbsPerSquadRange: { min: 300, max: 800 }, + mediaCoverage: "Third Eye / GoreZone", + accent: "#d7dee8" + }, + { + key: "jade", + label: "Jade", + type: "Environmental / Biohazard", + summary: "Containment and neutralisation of biological and chemical threats.", + details: "Biohazard incidents, toxic leaks, mutations, and containment failures.", + sclReq: "9 - 6", + sclRange: { min: 0.1, typical: 0.2, max: 0.4 }, + cbsPerOpRange: { min: 250, max: 500 }, + cbsPerSquadRange: { min: 400, max: 800 }, + mediaCoverage: "Third Eye / Contract Circuit", + accent: "#41d0a3" + }, + { + key: "red", + label: "Red", + type: "Emergency Response", + summary: "Crisis-response and red-alert operations.", + details: "Riots, terror incidents, major failures, and high-risk live-response deployments.", + sclReq: "10 - 5", + sclRange: { min: 0.1, typical: 0.2, max: 0.5 }, + cbsPerOpRange: { min: 200, max: 500 }, + cbsPerSquadRange: { min: 300, max: 1000 }, + mediaCoverage: "Third Eye / GoreZone", + accent: "#ff6f6f" + }, + { + key: "black", + label: "Black", + type: "Covert / Black Ops", + summary: "Classified, deniable, and often off-record operations.", + details: "Assassination, sabotage, deep infiltration, and disavowed clean-up actions.", + sclReq: "7 - 4", + sclRange: { min: 0.2, typical: 0.4, max: 0.7 }, + cbsPerOpRange: { min: 500, max: 1000 }, + cbsPerSquadRange: { min: 800, max: 2000 }, + mediaCoverage: "None (classified)", + accent: "#9a7cff" + }, + { + key: "platinum", + label: "Platinum", + type: "Head Office / Top Secret", + summary: "Highest-level Head Office operations.", + details: "World-shaping, deeply classified, and politically lethal assignments.", + sclReq: "5 - 1", + sclRange: { min: 0.3, typical: 0.5, max: 1.0 }, + cbsPerOpRange: { min: 1000, max: null }, + cbsPerSquadRange: { min: 1500, max: null }, + mediaCoverage: "Classified / Special", + accent: "#f5f0d7" + } +]; + +/* d20 issue table — how often each band lands when rolled randomly. */ +export const COLOUR_ROLL_TABLE = [ + { min: 1, max: 4, key: "blue" }, + { min: 5, max: 7, key: "yellow" }, + { min: 8, max: 9, key: "green" }, + { min: 10, max: 12, key: "white" }, + { min: 13, max: 13, key: "grey" }, + { min: 14, max: 14, key: "silver" }, + { min: 15, max: 15, key: "jade" }, + { min: 16, max: 17, key: "red" }, + { min: 18, max: 19, key: "black" }, + { min: 20, max: 20, key: "platinum" } +]; + +export const COLOUR_BY_KEY = Object.fromEntries(COLOURS.map((c) => [c.key, c])); + +export function colourByKey(key = "white") { + return COLOUR_BY_KEY[String(key ?? "white").toLowerCase()] ?? COLOUR_BY_KEY.white; +} diff --git a/core/data/frames.js b/core/data/frames.js new file mode 100644 index 0000000..eecaecd --- /dev/null +++ b/core/data/frames.js @@ -0,0 +1,362 @@ +/* Hand-authored scenario frames, two or more per colour band. Each frame is a + coherent, ready-to-run BPN: player-facing situation plus the GM's true + pressure point and twist. The `contactArchetype` / `threatArchetype` fields + are deliberately retained as a seam — a future NPC/monster module can read + them to spin up stat blocks without this module owning any engine schema. */ + +export const FRAMES = { + blue: [ + { + id: "blue-pump-clearance", + shortTitle: "Pump Ghosts", + missionFocus: "Eliminate a threat before it reaches public view.", + colourMissionIdea: "Clear Carrien from a sewer pumping station before repair crews go live on-site.", + location: "Sewer network and maintenance tunnels beneath a low-SCL tenement arc.", + atmosphere: "Oppressive rain above ground, ankle-deep runoff below, and every tunnel echoing with machine throb.", + opposition: "Carrien packs and one nest-alpha using the flooded access galleries.", + oppositionAttitude: "Are starving, territorial, and driven to charge anything making light or noise.", + civilianFaction: "Maintenance workers trying to keep the block from flooding into the hab levels.", + extraObjective: "Recover one intact sample of the contamination feeding the nest.", + escalationEvent: "If the pumps stay offline too long, the whole block begins to flood and civilians panic upward.", + complication: "The maintenance foreman has been selling access schematics to scavengers and will try to hide it.", + localTwist: "The contamination is not accidental; someone seeded the tunnels to force a redevelopment contract.", + issuingDepartment: "Mort Department / Ops Control", + contactRole: "Shift Maintenance Controller", + contactArchetype: "controller", + contactLocation: "A barricaded relay room overlooking the lower pump shafts.", + threatArchetype: "carrien", + threatRole: "Nest Alpha", + threatCountLabel: "One alpha and a fast-moving pack." + }, + { + id: "blue-eviction-cleanout", + shortTitle: "Residue Notice", + missionFocus: "Clean up an old SLA operational mess.", + colourMissionIdea: "Evict squatters and secure a condemned recycler before demolition crews arrive.", + location: "A half-powered recycling depot in a condemned housing strip.", + atmosphere: "Chemical fog hangs low and the whole block stinks of hot metal, wet plaster, and old blood.", + opposition: "A desperate street gang holding the depot plus a few terrified scavenger families inside the same shell.", + oppositionAttitude: "Talk hard, posture for cameras, but are ready to fold if offered one way out.", + civilianFaction: "Neighbourhood residents who want the gang gone but do not trust SLA to distinguish them from the problem.", + extraObjective: "Keep the recycler's core machinery functional for sponsor inspection.", + escalationEvent: "A scavenger fire starts in the upper catwalks, pushing everyone toward the same exit lanes.", + complication: "The depot contains evidence from an earlier SLA kill-team visit that nobody filed correctly.", + localTwist: "The original paperwork deliberately misclassified the site so a corporate shell company could seize it cheaply.", + issuingDepartment: "Department-M / Internal Containment", + contactRole: "Department-M Disposal Clerk", + contactArchetype: "analyst", + contactLocation: "A mobile command kiosk parked behind a portable blast shield.", + threatArchetype: "ganger", + threatRole: "Depot Gang Boss", + threatCountLabel: "One loud boss and half a dozen badly coordinated shooters." + } + ], + yellow: [ + { + id: "yellow-prototype-recovery", + shortTitle: "Cold Return", + missionFocus: "Retrieve an item or specimen intact.", + colourMissionIdea: "Recover a stolen prototype before it disappears into the black-market chain.", + location: "A transit interchange feeding into a hidden market spine.", + atmosphere: "Packed concourses, vendor noise, flashing route boards, and too many bodies to easily track the courier.", + opposition: "A retrieval crew working for organised crime with local route control and fast bikes on standby.", + oppositionAttitude: "Would rather run than fight, but will kill witnesses if boxed in.", + civilianFaction: "Black-market traders who know the unofficial routes and sell them to whoever pays first.", + extraObjective: "Bring back the data case unopened and verifiably untampered.", + escalationEvent: "The courier network triggers a station lockdown and reroutes all civilian flow through the kill zone.", + complication: "The package is still broadcasting, making every interested party converge on the same target.", + localTwist: "The prototype is bait; Head Office actually wants the retrieval crew followed back to a higher-value buyer.", + issuingDepartment: "Cloak Tactical Oversight", + contactRole: "Independent Financier", + contactArchetype: "fixer", + contactLocation: "A quiet cafe mezzanine overlooking the station's arrivals board.", + threatArchetype: "retrieval-crew", + threatRole: "Courier Handler", + threatCountLabel: "One handler, two bike-runners, and a sniper looking after the exchange." + } + ], + green: [ + { + id: "green-cannibal-perimeter", + shortTitle: "Wall Bite", + missionFocus: "Eliminate a threat before it reaches public view.", + colourMissionIdea: "Hit a breach team before it can break through the Cannibal Sector perimeter and trigger a broadcast panic.", + location: "The Cannibal Sector perimeter wall and the service roads wrapped around it.", + atmosphere: "Sirens, floodlights, wet concrete, and the constant thump of distant impact on the wall plating.", + opposition: "Cannibal raiders with improvised breaching charges and a local guide inside the city side.", + oppositionAttitude: "Are committed, feral, and keen to take someone back with them if the breach fails.", + civilianFaction: "A transient cluster of freight workers trapped near the service roads during lockdown.", + extraObjective: "Capture one living raider who can identify the city-side collaborator.", + escalationEvent: "A secondary breach warning forces Shiver units to peel away, leaving the squad exposed.", + complication: "The internal guide is wearing a legitimate contractor pass and can blend into evac traffic.", + localTwist: "The breach is a distraction for an unrelated extraction happening two gates away.", + issuingDepartment: "Shiver Command Liaison Desk", + contactRole: "Perimeter Shiver Sergeant", + contactArchetype: "shiver", + contactLocation: "An armoured checkpoint container bolted to the inner wall road.", + threatArchetype: "raider", + threatRole: "Breacher Chief", + threatCountLabel: "A brutal lead breacher with a fast assault cell behind him." + }, + { + id: "green-kill-order", + shortTitle: "Dead Feed", + missionFocus: "Hunt a named hostile and confirm status.", + colourMissionIdea: "Stop a contract killer before the second murder finishes the media pattern.", + location: "A rain-soaked rooftop district threaded with service bridges and maintenance lifts.", + atmosphere: "Visibility is broken by rain, neon reflections, and intermittent drone spotlights.", + opposition: "A professional killer with a two-person support team and pre-positioned escape routes.", + oppositionAttitude: "Remain cool, efficient, and willing to trade casualties for a clean getaway window.", + civilianFaction: "A cluster of thrill-streamers trying to capture the chase from nearby rooftops.", + extraObjective: "Recover the killer's weapon package and comms slate intact.", + escalationEvent: "The support team remotely kills the lights and forces the pursuit onto unsecured skybridges.", + complication: "The target has a face the public already knows, making collateral narrative control much harder.", + localTwist: "The supposed contract is only the visible layer; the true target is hidden among the responding authorities.", + issuingDepartment: "Mort Department / Ops Control", + contactRole: "Veteran Desk Op", + contactArchetype: "detective", + contactLocation: "A temporary command room in a shuttered rooftop transit office.", + threatArchetype: "killer", + threatRole: "Named Contract Killer", + threatCountLabel: "One surgical hitter backed by two disciplined support shooters." + } + ], + white: [ + { + id: "white-serial-pattern", + shortTitle: "Witness Static", + missionFocus: "Investigate a crime scene with political fallout.", + colourMissionIdea: "Link a string of identical killings before the murderer reaches a protected witness.", + location: "A residential block, its adjoining monorail stop, and the forensic seal around the newest murder scene.", + atmosphere: "The block is unnaturally quiet, everyone is watching from behind cheap screens, and Shivers are over-managing the cordon.", + opposition: "A serial killer cell staging scenes to manipulate a bigger investigation.", + oppositionAttitude: "Enjoy the attention, leave deliberate clues, and want the squad to see the pattern too late.", + civilianFaction: "Residents terrified of speaking after seeing just enough to know SLA is already late.", + extraObjective: "Secure the witness and move them somewhere the killer cannot predict.", + escalationEvent: "An old evidence trail suddenly points at a different scene and splits available manpower.", + complication: "The witness's own statement is incomplete because they are protecting someone inside the block.", + localTwist: "The killer is recreating an older SLA-blacklisted case and expects the same official cover-up.", + issuingDepartment: "Mort Department / Ops Control", + contactRole: "Case Liaison Detective", + contactArchetype: "detective", + contactLocation: "A sealed witness room improvised from a laundrette office.", + threatArchetype: "killer", + threatRole: "Pattern Cell Leader", + threatCountLabel: "A lead killer with one cleanup partner and one obsessed documentarian." + }, + { + id: "white-ammo-sabotage", + shortTitle: "Spent Truth", + missionFocus: "Retrieve data or recordings without contamination.", + colourMissionIdea: "Trace sabotaged SLA ammunition back through the supply chain before anyone realises how deep it goes.", + location: "A corporate ammo depot, a Shiver evidence locker, and a low-grade loading district in between.", + atmosphere: "Power flickers every few minutes, loading servitors keep stuttering mid-task, and everybody is too tense to blink.", + opposition: "Corporate security and one inside accountant trying to erase the trail before it reaches Head Office.", + oppositionAttitude: "Prefer intimidation and paperwork pressure, but will switch to lethal containment if exposed.", + civilianFaction: "Dock loaders who know the movement pattern of every crate but do not trust official investigators.", + extraObjective: "Recover one clean crate and one tampered crate for comparison testing.", + escalationEvent: "Depot lockdown procedures seal the archive level and begin deleting local logs.", + complication: "The inside accountant is cooperating because their family is already off the board if they stop.", + localTwist: "The sabotage was designed to make a specific squad fail publicly rather than to broadly poison supply.", + issuingDepartment: "Department-M / Internal Containment", + contactRole: "Corporate Asset Auditor", + contactArchetype: "analyst", + contactLocation: "A dark logistics office above the depot loading floor.", + threatArchetype: "corpsec", + threatRole: "Security Overseer", + threatCountLabel: "One hard-edged overseer and a disciplined internal security team." + } + ], + grey: [ + { + id: "grey-audit-escort", + shortTitle: "Quiet Ledger", + missionFocus: "Protect or escort a cargo asset.", + colourMissionIdea: "Keep an audit convoy moving while everyone who profited from the missing cargo tries to erase the proof.", + location: "An industrial spine road running between bonded warehouses and a secure internal archive.", + atmosphere: "The sector is all sodium light, surveillance masts, and thin excuses from everyone in a suit.", + opposition: "Departmentally protected smugglers, hired heavies, and one executive aide running the cover story.", + oppositionAttitude: "Would rather pressure, delay, and redirect than openly trade fire in company view.", + civilianFaction: "Warehouse staff who know something is wrong but fear losing the only stable work they have.", + extraObjective: "Keep the audit chain unbroken from seizure to final archive.", + escalationEvent: "A sponsor legal team arrives and tries to freeze the whole operation with paperwork.", + complication: "The archive itself contains evidence implicating the issuing department.", + localTwist: "The convoy is only half the story; the real theft walked out days ago wearing a department badge.", + issuingDepartment: "Department-M / Internal Containment", + contactRole: "Departmental Enforcement Liaison", + contactArchetype: "corporate", + contactLocation: "An armoured office container chained to the convoy lead truck.", + threatArchetype: "corpsec", + threatRole: "Internal Security Chief", + threatCountLabel: "One security chief and a quiet detail of professional enforcers." + } + ], + silver: [ + { + id: "silver-live-escort", + shortTitle: "Smile Cut", + missionFocus: "Protect or escort a person through a hostile district.", + colourMissionIdea: "Keep a sponsor-backed public figure alive without letting the cameras see how close the job gets.", + location: "A ribbon of sponsor venues, red-carpet choke points, and one ugly service corridor nobody told the cameras about.", + atmosphere: "Flash lamps, branded drones, crowd heat, and just enough panic underneath the applause to be dangerous.", + opposition: "A stalker cell, freelance agitators, and one rival sponsor fixer hoping to turn the event into a scandal.", + oppositionAttitude: "Would prefer embarrassment over blood but are ready to escalate if the window closes.", + civilianFaction: "Fans and paid attendees packed too tightly to separate cleanly from the real threat.", + extraObjective: "Protect the sponsor narrative as much as the body being moved.", + escalationEvent: "A live-feed malfunction dumps backstage footage into the public stream and sends the route sideways.", + complication: "The VIP is hiding a personal agenda and keeps deviating from the protected path.", + localTwist: "The entire appearance is bait designed to expose who inside the sponsor chain is leaking movement plans.", + issuingDepartment: "Media Relations / Sponsor Operations", + contactRole: "Media Fixer", + contactArchetype: "media", + contactLocation: "A mobile control booth hidden behind the main broadcast gantry.", + threatArchetype: "security", + threatRole: "Hostile Fixer", + threatCountLabel: "One well-dressed operator with hired hands embedded in the crowd." + } + ], + jade: [ + { + id: "jade-containment", + shortTitle: "Wet Bloom", + missionFocus: "Contain or quarantine an outbreak or Ebb event.", + colourMissionIdea: "Lock down a contaminated block before the spill crosses into the transit grid.", + location: "A mixed-use block connected to drainage canals and a damaged med-lab service annex.", + atmosphere: "Filtered alarms, chemical stink, and a wet sheen on every surface that might be alive.", + opposition: "Mutated survivors, a failed lab asset, and one containment officer already planning to burn the evidence.", + oppositionAttitude: "Act unpredictably and spike between blind panic and sudden aggression.", + civilianFaction: "A street clinic trying to keep the least infected residents alive long enough to evacuate.", + extraObjective: "Secure one uncontaminated sample for Karma analysis.", + escalationEvent: "Containment seals begin dropping floor by floor, isolating parts of the squad from one another.", + complication: "The official quarantine map is wrong and funnels civilians toward the hottest zone.", + localTwist: "The contamination profile matches a programme nobody admits still exists.", + issuingDepartment: "Karma Hazard Response", + contactRole: "Field Scientist", + contactArchetype: "scientist", + contactLocation: "A decontamination vestibule made from two bolted ambulance trailers.", + threatArchetype: "stormer", + threatRole: "Mutated Lab Asset", + threatCountLabel: "One unstable asset and several contaminated survivors acting around it." + } + ], + red: [ + { + id: "red-monorail-hostage", + shortTitle: "Dead Platform", + missionFocus: "Protect or escort a person through a hostile district.", + colourMissionIdea: "Board a hijacked monorail, cut the threat out, and stop a public disaster before it multiplies.", + location: "A moving monorail line, two sealed stations, and the service deck beneath the track.", + atmosphere: "Screaming brakes, emergency klaxons, static over public address, and media drones already racing the train.", + opposition: "A terror cell with improvised explosives and a hardline trigger operator.", + oppositionAttitude: "Need the spectacle as much as the tactical win and will kill quickly if denied it.", + civilianFaction: "Hostages trapped inside the train with just enough courage to help if guided correctly.", + extraObjective: "Keep the train itself intact enough for a survivor extraction.", + escalationEvent: "A secondary device threatens the inbound station and splits command priorities.", + complication: "One hostage is secretly tied to the cell and is feeding them squad movement.", + localTwist: "The train was chosen because someone important is riding under a false manifest.", + issuingDepartment: "Shiver Command Liaison Desk", + contactRole: "Transit Crisis Controller", + contactArchetype: "controller", + contactLocation: "A sealed signal room with live route telemetry and too many shouting department heads.", + threatArchetype: "riot", + threatRole: "Cell Trigger Man", + threatCountLabel: "One detonation lead, two shooters, and a train full of leverage." + }, + { + id: "red-broadcast-retake", + shortTitle: "Signal Mercy", + missionFocus: "Eliminate a threat before it reaches public view.", + colourMissionIdea: "Retake a broadcast tower before the final message turns a bad district into a citywide panic.", + location: "A media tower, its control floors, and the access streets packed with fleeing civilians.", + atmosphere: "Emergency beacons, dropped live feeds, smoke in the ventilation stacks, and every screen in Mort pointing at the same problem.", + opposition: "An extremist crew with tower access and stolen media credentials.", + oppositionAttitude: "Expect to die but want the audience to watch it happen in the right order.", + civilianFaction: "Panicking evacuees clogging the lower service roads and making every clean shot harder.", + extraObjective: "Recover the message core and identify who green-lit tower access.", + escalationEvent: "Backup transmitters spin up on a timer and broaden the threat window.", + complication: "The tower engineer who can shut the system down has been wounded and hidden by the crowd.", + localTwist: "The message is less propaganda than data dump, and one department in SLA wants it to air.", + issuingDepartment: "Media Relations / Sponsor Operations", + contactRole: "Broadcast Security Captain", + contactArchetype: "shiver", + contactLocation: "A lower-floor security post running on emergency battery.", + threatArchetype: "security", + threatRole: "Tower Seizure Lead", + threatCountLabel: "One charismatic lead and a handful of committed technical shooters." + } + ], + black: [ + { + id: "black-silent-vault", + shortTitle: "Mute Entry", + missionFocus: "Retrieve data or recordings without contamination.", + colourMissionIdea: "Slip into a sealed vault, lift one file set, and leave the archive looking untouched.", + location: "A corporate research vault buried under a routine logistics floor.", + atmosphere: "Cold air, sound-dampened corridors, biometric locks, and too much silence to trust.", + opposition: "Internal black security and one compartmentalised watcher who does not know the whole job.", + oppositionAttitude: "Stay disciplined, silent, and willing to erase the evidence of their own failure.", + civilianFaction: "Night-shift technicians who do not understand the stakes but know every route in the building.", + extraObjective: "Remove a second innocuous-looking archive block that actually matters more than the main objective.", + escalationEvent: "A silent alarm brings in an internal response team with orders to close the floor permanently.", + complication: "The file set is mirrored to a live observer station and cannot simply be taken.", + localTwist: "The client is not paying for the theft; they are paying to confirm what Head Office already suspects.", + issuingDepartment: "Head Office Special Assignments", + contactRole: "Darkfinder Case Officer", + contactArchetype: "corporate", + contactLocation: "A blackout-safe service apartment three blocks from the target tower.", + threatArchetype: "blackops", + threatRole: "Vault Warden", + threatCountLabel: "One silent warden and a two-person ghost response team." + }, + { + id: "black-disavowed-burn", + shortTitle: "Ash Ledger", + missionFocus: "Sabotage or destroy a target off the books.", + colourMissionIdea: "Burn a deniable site and seed the right false evidence before anyone asks why it existed.", + location: "A shell company warehouse wrapped around an unlisted lab basement.", + atmosphere: "Generator hum, shuttered loading bays, and an air that already feels abandoned on purpose.", + opposition: "Contract cleaners and one site supervisor prepared for a final-burn contingency.", + oppositionAttitude: "Know the site is disposable and only care about buying enough time to erase the real trail.", + civilianFaction: "Nearby dock crews who heard there was money in tonight's unusual overtime orders.", + extraObjective: "Leave one trail leading toward a rival corporate culprit.", + escalationEvent: "A chemical fire spreads faster than expected and starts opening parts of the hidden basement.", + complication: "One asset inside the site is still alive and worth far more than the building.", + localTwist: "The false evidence you were told to plant matches a previous cover story almost word for word.", + issuingDepartment: "Head Office Special Assignments", + contactRole: "Freelance Financier", + contactArchetype: "fixer", + contactLocation: "A remote comms booth patched through a dead sponsor office.", + threatArchetype: "blackops", + threatRole: "Cleanup Supervisor", + threatCountLabel: "One hard cleanup lead, one torch specialist, and a final-burn team." + } + ], + platinum: [ + { + id: "platinum-head-office", + shortTitle: "Quiet Crown", + missionFocus: "Protect or escort a person through a hostile district.", + colourMissionIdea: "Extract a Head Office asset who knows too much without revealing the extraction is even happening.", + location: "A sealed executive sector stitched into an older transit artery beneath it.", + atmosphere: "Too clean above, too secret below, and everyone on-site acting as if the wrong word could kill them.", + opposition: "A compartmentalised internal response team and one asset handler trying to rewrite the operation in real time.", + oppositionAttitude: "Never shout, rarely miss, and assume everyone around them is expendable.", + civilianFaction: "None officially, though a handful of overlooked service workers are trapped inside the machinery.", + extraObjective: "Return every mission record to Head Office only.", + escalationEvent: "Command changes the extraction route mid-run, implying compromise at the original pickup.", + complication: "The protected asset is deciding whether the squad is the rescue team or the last cleanup team they will ever see.", + localTwist: "The extraction itself is cover for a private conversation one Head Office director wants another never to hear.", + issuingDepartment: "Head Office Special Assignments", + contactRole: "Head Office Courier", + contactArchetype: "corporate", + contactLocation: "A private transit berth accessible only by dead-route credentials.", + threatArchetype: "blackops", + threatRole: "Executive Recovery Lead", + threatCountLabel: "One elite retrieval leader and a pair of immaculate shadow operators." + } + ] +}; + +export function framesForColour(key = "white") { + return FRAMES[String(key ?? "white").toLowerCase()] ?? FRAMES.white; +} diff --git a/core/data/tables.js b/core/data/tables.js new file mode 100644 index 0000000..afcd44d --- /dev/null +++ b/core/data/tables.js @@ -0,0 +1,208 @@ +/* Generic SLA flavour tables used to fill any gaps a scenario frame + leaves open, and to drive fully-random briefings. Pure data. */ + +export const TABLES = { + missionFocus: [ + "Eliminate a threat before it reaches public view.", + "Capture a target alive for interrogation.", + "Protect or escort a person through a hostile district.", + "Protect or escort a cargo asset.", + "Retrieve an item or specimen intact.", + "Retrieve data or recordings without contamination.", + "Investigate a crime scene with political fallout.", + "Profile and locate a serial killer before media lock-on.", + "Contain or quarantine an outbreak or Ebb event.", + "Crowd control and riot suppression under cameras.", + "Hunt a named hostile and confirm status.", + "Clean up an old SLA operational mess." + ], + + locations: [ + "Downtown backstreets.", + "Sewer network and maintenance tunnels.", + "Industrial sector and power spine.", + "Residential block or tenement arc.", + "Transit system and monorail stations.", + "Cannibal Sector perimeter.", + "Corporate laboratory or asset vault.", + "Nightclub and entertainment block.", + "Broadcast tower and media hub.", + "Hospital, triage zone, or Karma lab." + ], + + opposition: [ + "Street gang with light arms and local lookouts.", + "Organised crime syndicate with political backing.", + "Serial killer cell already moving to a second target.", + "DarkNight agents with a deniable brief.", + "Carrien pack using service tunnels.", + "Rogue biogenetic asset or escaped Stormer variant.", + "Rival SLA squad chasing the same payout.", + "Corrupt Shivers protecting the wrong people.", + "Cultists, Sh'ien influence, or Ebb residue.", + "Corporate security with strict no-survivor orders." + ], + + complications: [ + "The issuing department withheld one critical fact.", + "Third Eye goes live before the squad enters the zone.", + "A witness can identify one operative by name.", + "The target is embedded in civilians.", + "The package is alive and unstable.", + "Transit lockdown traps everyone inside the district.", + "The real employer is not the signatory on the BPN.", + "L.A.D. response in the zone is unreliable.", + "Communications are jammed or intercepted.", + "The BPN is partly a ratings play dressed up as emergency work." + ], + + contactRoles: [ + "Shiver precinct liaison", + "Mort Department dispatcher", + "Department-M observer", + "Corporate asset handler", + "Embedded media fixer", + "Sector security captain", + "Karma field scientist", + "Independent financier", + "Freelance financier", + "Darkfinder case officer" + ], + + contactFirstNames: [ + "Anika", "Blaine", "Caro", "Drex", "Ephra", "Fenn", "Galia", "Havel", + "Iona", "Jarek", "Kiva", "Luther", "Mara", "Nikol", "Orin", "Petra" + ], + + contactLastNames: [ + "Voss", "Kane", "Talon", "Mercer", "Sloane", "Varik", "Drahl", "Kessler", + "Morn", "Black", "Harrow", "Vale", "Kestral", "Renn", "Soren", "Yarrow" + ], + + civilianFactions: [ + "Local residents trying to keep their block standing.", + "Black-market traders who know the unofficial routes.", + "A fan club for a specific operative or squad.", + "A semi-legal street clinic with decent gear and bad ties.", + "Displaced refugees from a devastated sector.", + "A corporate outreach team doing smiling PR work." + ], + + oppositionAttitudes: [ + "Underestimate the squad and get cocky.", + "Are terrified of SLA but cornered and desperate.", + "See the squad as expendable obstacles, not people.", + "Are only in it for the money.", + "Hope to cut a deal or run rather than die.", + "Use the squad as a distraction for a bigger move." + ], + + atmospheres: [ + "Oppressive rain, thunder, and ankle-deep water in the streets.", + "Chemical smog and neon halos around every light source.", + "Rolling blackouts, flicker failures, and generator hum.", + "Industrial noise and heavy machine vibration.", + "Crowded streets with sale events or festivals in progress.", + "Curfew with Shivers on every corner and everyone watching." + ], + + localTwists: [ + "The district is regarded as cursed by locals.", + "A recent promo shoot left half-finished set pieces everywhere.", + "A minor celebrity is present and drawing attention.", + "Surveillance coverage is patchy or disabled.", + "Someone is independently live-streaming the operation.", + "A rival squad failed a BPN here recently and left damage behind." + ], + + extraObjectives: [ + "Capture at least one enemy alive for interrogation.", + "Recover specific data intact with no EMP or overkill.", + "Ensure a particular building remains undamaged.", + "Plant a tracking device unnoticed.", + "Secure a specific piece of gear for a sponsor.", + "Ensure that no civilians witness one critical event." + ], + + escalationEvents: [ + "Opposition reinforcements arrive mid-operation.", + "The environment becomes more dangerous: fire, flood, or collapse.", + "Media crews show up and start reframing the mission.", + "A key NPC flees, forcing an ugly pursuit.", + "Communications fail at the worst possible moment.", + "A supposedly neutral faction chooses a side." + ], + + issuingDepartments: [ + "Mort Department / Ops Control", + "Department-M / Internal Containment", + "Shiver Command Liaison Desk", + "Karma Hazard Response", + "Cloak Tactical Oversight", + "Media Relations / Sponsor Operations", + "Head Office Special Assignments" + ], + + mediaOptions: [ + "None", + "Rare / None", + "Station Analysis", + "Third Eye News", + "GoreZone / Contract Circuit Special", + "Classified / Special" + ] +}; + +/* Per-colour mission hooks — short pitched ideas keyed by band. */ +export const COLOUR_MISSION_IDEAS = { + blue: [ + "Clear out Carrien and secure the pump station before repairs begin.", + "Survey a derelict subway spur and mark safe lanes for clean-up crews.", + "Evict a low-level gang from a recycling depot without wrecking the machinery." + ], + yellow: [ + "Recover a stolen prototype before it disappears through the black market.", + "Locate a missing departmental courier and return the carried drives intact.", + "Retrieve a captured informant before rivals extract anything useful." + ], + green: [ + "Neutralise a threat before it can trigger full emergency escalation.", + "Sweep a district after repeated monster sightings and stop the panic source.", + "Guard a prisoner transfer while hostile assets try to reclaim the cargo." + ], + white: [ + "Uncover who is issuing forged BPNs to lure squads into ambushes.", + "Trace sabotaged SLA-manufactured ammo back to the source.", + "Link a string of identical killings across multiple sectors." + ], + grey: [ + "Protect engineers installing surveillance hardware in a tense district.", + "Assist a corporate audit team investigating missing shipments.", + "Guard a prototype vehicle during a live test run." + ], + silver: [ + "Escort a diplomatic envoy without provoking a public incident.", + "Keep a sponsor-backed appearance alive while controlling optics.", + "Secure a high-profile interview venue before the cameras roll." + ], + jade: [ + "Contain a biohazard leak in a mixed-use block and secure samples.", + "Neutralise a mutated outbreak before it reaches transport lines.", + "Lock down a failed research ward and extract surviving technicians." + ], + red: [ + "Board a hijacked monorail in motion and rescue the hostages.", + "Retake a broadcast tower seized by extremists.", + "Stabilise a district where fires, looters, and panic are all spreading." + ], + black: [ + "Infiltrate a sealed compound, remove a target, and leave no public trace.", + "Sabotage a rival operation and redirect blame elsewhere.", + "Extract records from an internal vault while command denies the mission exists." + ], + platinum: [ + "Recover a lost prototype before anyone realises it escaped.", + "Investigate an off-limits sector where sensors keep going dark.", + "Quietly remove an inconvenient public hero from the board." + ] +}; diff --git a/index.html b/index.html new file mode 100644 index 0000000..c3bec7e --- /dev/null +++ b/index.html @@ -0,0 +1,86 @@ + + + + + + Blueprint News — SLA BPN Generator + + + + + +
+
+
BLUEPRINT NEWS
+
SLA Industries · Contract Dispatch Grid
+
+
Player Brief & GM Dossier · engine-agnostic
+
+ + + +
+
+
+ + + +
+
+
+
+
+ + + + diff --git a/render/bpn-render.css b/render/bpn-render.css new file mode 100644 index 0000000..ef4d960 --- /dev/null +++ b/render/bpn-render.css @@ -0,0 +1,387 @@ +/* Blueprint News — render layer styling. + * + * SLA Industries house look: heavy condensed uppercase titles, Roboto Mono + * data labels, dark slate panels, a single colour-band accent per brief, thin + * accent rules with inset glow, Third-Eye crest, stamp / redaction motifs. + * + * Two clearly distinct surfaces: + * .bpn-brief — the official corporate dispatch the squad is allowed to see + * .bpn-dossier — the handler-only truth behind it (darker, "RESTRICTED") + * + * Sizing is rem-based and tuned for legibility on a small running laptop: + * body copy never drops below ~15px and stays high-contrast. Everything is + * scoped under .bpn so it can drop into any host without bleeding. */ + +.bpn { + /* fonts */ + --bpn-display: "Modesto Condensed", "Josefin Sans", "Oswald", sans-serif; + --bpn-head: "Josefin Sans", system-ui, sans-serif; + --bpn-body: "Josefin Sans", system-ui, sans-serif; + --bpn-mono: "Roboto Mono", ui-monospace, monospace; + + /* palette (from the SLA-BRP system) */ + --bpn-bg: #0d1117; + --bpn-panel: #161c26; + --bpn-panel-2: #1f2632; + --bpn-line: #3a4250; + --bpn-text: #eef3fa; + --bpn-muted: #9fb0c4; + --bpn-cream: #f4efe5; + --bpn-gold: #d6b24e; + --bpn-gold-deep: #b88a24; + --bpn-danger: #ff5b5b; + + /* per-brief accent is set inline from the colour band */ + --bpn-accent: #dce8f9; + + color: var(--bpn-text); + font-family: var(--bpn-body); + font-size: 16px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +.bpn *, +.bpn *::before, +.bpn *::after { box-sizing: border-box; } + +/* ---------- shared atoms ------------------------------------------------- */ + +.bpn-eyebrow { + font-family: var(--bpn-mono); + font-size: 0.74rem; + letter-spacing: 0.22em; + text-transform: uppercase; + color: color-mix(in srgb, var(--bpn-accent) 70%, var(--bpn-muted)); + margin: 0; +} + +.bpn-label { + display: block; + font-family: var(--bpn-mono); + font-size: 0.78rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: color-mix(in srgb, var(--bpn-accent) 50%, var(--bpn-muted)); + margin-bottom: 4px; +} + +.bpn-value { color: var(--bpn-text); font-size: 1rem; } + +.bpn-card { + position: relative; + padding: 14px 16px; + border-radius: 10px; + background: linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel)); + border: 1px solid var(--bpn-line); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.bpn-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 3px; + border-radius: 10px 0 0 10px; + background: linear-gradient(180deg, var(--bpn-accent), transparent); + opacity: 0.85; +} + +.bpn-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +/* ---------- the document shell ------------------------------------------- */ + +.bpn-doc { + max-width: 920px; + margin: 0 auto; + border-radius: 14px; + overflow: hidden; + border: 1px solid var(--bpn-line); + background: var(--bpn-bg); + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45); +} + +/* ===================== PLAYER BRIEF ===================== */ + +.bpn-brief .bpn-hero { + position: relative; + min-height: 200px; + display: flex; + align-items: flex-end; + padding: 20px 22px; + isolation: isolate; +} + +.bpn-brief .bpn-hero__art { + position: absolute; + inset: 0; + z-index: -1; +} +.bpn-brief .bpn-hero__art svg { width: 100%; height: 100%; display: block; } + +.bpn-brief .bpn-hero::after { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: linear-gradient(0deg, rgba(7, 10, 16, 0.92) 8%, rgba(7, 10, 16, 0.35) 60%, transparent); +} + +.bpn-hero__crest { + width: 92px; + height: 92px; + flex: 0 0 auto; + margin-right: 18px; + filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.5)); +} +.bpn-hero__crest svg { width: 100%; height: 100%; display: block; } + +.bpn-hero__copy { min-width: 0; } + +.bpn-hero__band { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 3px 12px; + border-radius: 999px; + background: color-mix(in srgb, var(--bpn-accent) 22%, #0c1119); + border: 1px solid color-mix(in srgb, var(--bpn-accent) 60%, transparent); + font-family: var(--bpn-mono); + font-size: 0.72rem; + letter-spacing: 0.2em; + text-transform: uppercase; + color: color-mix(in srgb, var(--bpn-accent) 40%, #ffffff); + margin-bottom: 8px; +} +.bpn-hero__band b { color: color-mix(in srgb, var(--bpn-accent) 70%, #ffffff); } + +.bpn-hero__title { + font-family: var(--bpn-display); + font-weight: 700; + font-size: clamp(2.1rem, 6vw, 3.4rem); + line-height: 0.92; + letter-spacing: 0.02em; + text-transform: uppercase; + margin: 0; + color: #fff; + text-shadow: 0 2px 14px rgba(0, 0, 0, 0.6); +} + +.bpn-hero__sub { + margin: 6px 0 0; + font-family: var(--bpn-head); + color: var(--bpn-cream); + font-size: 1.02rem; + opacity: 0.92; +} + +/* the corner "issued" stamp */ +.bpn-stamp { + position: absolute; + top: 14px; + right: 16px; + z-index: 1; + font-family: var(--bpn-mono); + font-size: 0.64rem; + letter-spacing: 0.18em; + text-transform: uppercase; + text-align: right; + color: color-mix(in srgb, var(--bpn-accent) 55%, #fff); +} +.bpn-stamp b { + display: block; + padding: 2px 8px; + margin-top: 4px; + border: 1.5px solid color-mix(in srgb, var(--bpn-accent) 60%, transparent); + border-radius: 4px; + transform: rotate(-3deg); +} + +/* fact strip under the hero */ +.bpn-facts { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1px; + background: var(--bpn-line); + border-bottom: 1px solid var(--bpn-line); +} +.bpn-facts > div { + background: var(--bpn-panel); + padding: 12px 14px; +} +.bpn-facts .bpn-value { + font-family: var(--bpn-mono); + font-weight: 700; + font-size: 1.06rem; + color: color-mix(in srgb, var(--bpn-accent) 22%, var(--bpn-text)); +} + +.bpn-body { padding: 20px 22px 24px; display: grid; gap: 18px; } + +.bpn-section__title { + font-family: var(--bpn-head); + font-weight: 700; + font-size: 1.12rem; + letter-spacing: 0.04em; + text-transform: uppercase; + margin: 0 0 4px; + padding-bottom: 6px; + border-bottom: 1px solid var(--bpn-line); + color: color-mix(in srgb, var(--bpn-accent) 30%, var(--bpn-text)); +} + +.bpn-brief-text { + font-size: 1.05rem; + line-height: 1.6; + color: var(--bpn-text); +} + +.bpn-objective { + border-left: 3px solid var(--bpn-accent); + padding: 10px 14px; + background: color-mix(in srgb, var(--bpn-accent) 8%, transparent); + border-radius: 0 8px 8px 0; +} + +.bpn-contact { + display: grid; + grid-template-columns: 120px 1fr; + gap: 16px; + align-items: center; + padding: 14px 16px; + border-radius: 10px; + background: linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel)); + border: 1px solid var(--bpn-line); +} +.bpn-contact__ident { width: 120px; height: 120px; } +.bpn-contact__ident svg { width: 100%; height: 100%; display: block; } +.bpn-contact__detail { display: grid; gap: 3px; } +.bpn-contact__name { + font-family: var(--bpn-head); + font-weight: 700; + font-size: 1.25rem; + color: color-mix(in srgb, var(--bpn-accent) 22%, var(--bpn-text)); +} +.bpn-contact__pos { color: var(--bpn-muted); font-size: 0.95rem; margin-top: 4px; } + +.bpn-footer { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + padding: 16px 22px; + border-top: 1px solid var(--bpn-line); + background: var(--bpn-panel); + font-family: var(--bpn-mono); +} +.bpn-footer .bpn-value { font-family: var(--bpn-head); font-size: 0.96rem; } + +/* ===================== GM DOSSIER ===================== */ + +.bpn-dossier .bpn-doc { box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); } + +.bpn-dossier__banner { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + background: + repeating-linear-gradient(45deg, rgba(255, 91, 91, 0.16) 0 12px, transparent 12px 24px), + #1a0f12; + border-bottom: 2px solid var(--bpn-danger); +} +.bpn-dossier__banner .bpn-eye { + width: 26px; height: 26px; flex: 0 0 auto; +} +.bpn-dossier__banner h2 { + margin: 0; + font-family: var(--bpn-display); + font-size: 1.5rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #ffd9d9; +} +.bpn-dossier__banner .bpn-eyebrow { color: #ff9b9b; margin-left: auto; } + +.bpn-dossier .bpn-body { padding: 18px 20px 22px; } + +.bpn-reveal { + position: relative; + padding: 14px 16px 14px 18px; + border-radius: 10px; + background: linear-gradient(180deg, #221a22, #161019); + border: 1px solid #4a2b3a; +} +.bpn-reveal::before { + content: ""; + position: absolute; inset: 0 auto 0 0; width: 3px; + background: var(--bpn-danger); + border-radius: 10px 0 0 10px; +} +.bpn-reveal .bpn-label { color: #ff9b9b; } + +.bpn-twist { border-color: #6a2b2b; } +.bpn-twist .bpn-label { color: var(--bpn-danger); } + +.bpn-threat-read { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 14px; + font-family: var(--bpn-mono); + font-size: 0.92rem; +} +.bpn-threat-read dt { color: var(--bpn-muted); letter-spacing: 0.08em; text-transform: uppercase; font-size: 0.72rem; align-self: center; } +.bpn-threat-read dd { margin: 0; color: var(--bpn-text); } + +/* NPC seam — clearly flagged as the hand-off point for the future module */ +.bpn-seam { + border: 1px dashed var(--bpn-line); + border-radius: 10px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.02); +} +.bpn-seam .bpn-eyebrow { color: var(--bpn-gold); } +.bpn-seam code { + font-family: var(--bpn-mono); + font-size: 0.86rem; + color: var(--bpn-gold); + background: rgba(214, 178, 78, 0.1); + padding: 1px 6px; + border-radius: 4px; +} + +.bpn-rolls { + font-family: var(--bpn-mono); + font-size: 0.78rem; + color: var(--bpn-muted); + letter-spacing: 0.04em; +} + +/* ---------- responsive: small running laptop ----------------------------- */ + +@media (max-width: 720px) { + .bpn { font-size: 15px; } + .bpn-facts { grid-template-columns: repeat(2, 1fr); } + .bpn-hero__crest { width: 64px; height: 64px; margin-right: 12px; } + .bpn-brief .bpn-hero { min-height: 150px; padding: 16px; } + .bpn-stamp { display: none; } + .bpn-contact { grid-template-columns: 88px 1fr; gap: 12px; } + .bpn-contact__ident { width: 88px; height: 88px; } +} + +@media (max-width: 420px) { + .bpn-facts { grid-template-columns: 1fr 1fr; } + .bpn-hero__title { font-size: clamp(1.8rem, 9vw, 2.4rem); } +} + +/* ---------- print / PDF export ------------------------------------------- */ + +@media print { + .bpn { font-size: 12pt; } + .bpn-doc { box-shadow: none; border: 1px solid #000; max-width: 100%; } + .bpn-doc, .bpn-brief, .bpn-dossier { break-inside: avoid; } + .bpn-dossier { margin-top: 18px; } +} diff --git a/render/bpn-render.js b/render/bpn-render.js new file mode 100644 index 0000000..796a4cb --- /dev/null +++ b/render/bpn-render.js @@ -0,0 +1,206 @@ +/* Blueprint News — render layer. + * + * Turns a BPN object (from core) into the two separate surfaces: + * - the Player Brief: the official corporate dispatch + * - the GM Dossier: the handler-only truth + * + * Pure DOM + string building, framework-free. Functions come in two flavours: + * briefHTML(bpn) / dossierHTML(bpn) -> markup strings (export, embedding) + * renderBrief(bpn, el) / renderDossier(bpn, el) -> mount into an element + * + * The host page must include render/bpn-render.css and the bundled fonts.css. + */ + +import { crestSVG, bannerSVG, portraitSVG } from "../art/bpn-art.js"; + +function esc(value) { + return String(value ?? "").replace(/[&<>"']/g, (c) => ( + { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c] + )); +} + +/* The SLA "Third Eye" mark, used on the GM banner. */ +function thirdEyeSVG(colour = "#ff5b5b") { + return ``; +} + +function fact(label, value) { + return `
${esc(label)}${esc(value)}
`; +} + +function card(label, value) { + return `
${esc(label)}${esc(value)}
`; +} + +/* ---------------- PLAYER BRIEF ---------------- */ + +export function briefHTML(bpn, { art = true } = {}) { + const c = bpn.colour; + const banner = art ? bannerSVG(bpn, { width: 1200, height: 360 }) : ""; + const crest = crestSVG(bpn, { size: 184 }); + + return `
+
+
+ +
Issued by${esc(bpn.issuingDepartment)}
+
${crest}
+
+ ${esc(c.label)} BPN · ${esc(c.type)} +

${esc(bpn.shortTitle)}

+

${esc(c.summary)}

+
+
+ +
+ ${fact("BPN ID", bpn.bpnId)} + ${fact("SCL Requirement", bpn.sclRequirement)} + ${fact("Gross Contract", bpn.reward.grossContract)} + ${fact("Est. Take-home", bpn.reward.takeHomeEach)} +
+ +
+
+

Mission Brief

+

${esc(bpn.missionBrief)}

+
+ +
+

Objectives

+
Primary${esc(bpn.objective)}
+
Secondary${esc(bpn.extraObjective)}
+
+ +
+

Operational Picture

+
+ ${card("Location", bpn.location)} + ${card("Atmosphere", bpn.atmosphere)} + ${card("Declared Opposition", bpn.opposition)} + ${card("Mission Focus", bpn.missionFocus)} +
+
+ +
+

On-site Contact & Pressures

+
+
${portraitSVG(bpn, { size: 120 })}
+
+ Primary Contact + ${esc(bpn.contact.name)} + ${esc(bpn.contact.role)} + ${esc(bpn.contact.location)} +
+
+
+ ${card("Civilian Pressure", bpn.civilianFaction)} + ${card("Operational Hook", bpn.hook)} +
+
+
+ +
+ ${fact("Media Posture", bpn.reward.mediaCoverage)} + ${fact("Squad Size", `${bpn.squadSize} Operatives`)} + ${fact("SCL Increase", bpn.reward.sclIncrease)} + ${fact("Cleanup Directive", bpn.cleanupDirective)} +
+
+
`; +} + +/* ---------------- GM DOSSIER ---------------- */ + +export function dossierHTML(bpn) { + const c = bpn.colour; + const g = bpn.gm; + const seam = bpn.npcSeam; + const r = g.rolls ?? {}; + + const seamRow = (seam.contactArchetype || seam.threatArchetype) + ? `
+

NPC / Monster Hand-off

+

This brief reserves two cast slots for the companion NPC module:

+

+ Contact archetype ${esc(seam.contactArchetype ?? "—")} — ${esc(bpn.contact.name)}, ${esc(bpn.contact.role)}.
+ Threat archetype ${esc(seam.threatArchetype ?? "—")} — ${esc(seam.threatRole ?? g.threatLead ?? "—")}. +

+
` + : ""; + + return `
+
+
+ ${thirdEyeSVG("#ff5b5b")} +

GM Dossier — ${esc(bpn.shortTitle)}

+ Restricted · Handler Eyes Only +
+ +
+
+
+ Operational Twist + ${esc(g.localTwist)} +
+
+ Withheld Fact + ${esc(g.complication)} +
+
+ +
+

True Threat Read

+
+
+
Lead
${esc(g.threatLead || "—")}
+
Numbers
${esc(g.threatCountLabel || "—")}
+
Attitude
${esc(g.oppositionAttitude)}
+
+
+
+ +
+

Levers & Escalation

+
+ ${card("Escalation Trigger", g.escalationEvent)} + ${card("Civilian Lever", bpn.civilianFaction)} + ${card("Financier Reality", g.financierNote)} + ${card("CBS Bands", `${bpn.reward.cbsBandOp} per Op / ${bpn.reward.cbsBandSquad} per Squad`)} +
+
+ + ${seamRow} + +

Seed ${esc(bpn.seed)} · frame ${esc(g.frameId)} · rolls: colour ${esc(r.colour ?? 0)}, pay ${esc(r.paymentType ?? 0)}, CBS ${esc(r.cbsTier ?? 0)}, SCL ${esc(r.sclTier ?? 0)}, media ${esc(r.media ?? 0)}, financier ${esc(r.financier ?? 0)}.

+
+
+
`; +} + +/* ---------------- mount helpers ---------------- */ + +export function renderBrief(bpn, el, opts) { + const root = typeof el === "string" ? document.querySelector(el) : el; + if (!root) return; + root.classList.add("bpn"); + root.innerHTML = briefHTML(bpn, opts); +} + +export function renderDossier(bpn, el) { + const root = typeof el === "string" ? document.querySelector(el) : el; + if (!root) return; + root.classList.add("bpn"); + root.innerHTML = dossierHTML(bpn); +} + +/* Render both surfaces into one element (used for the print / "both" view). */ +export function renderBoth(bpn, el, opts) { + const root = typeof el === "string" ? document.querySelector(el) : el; + if (!root) return; + root.classList.add("bpn"); + root.innerHTML = briefHTML(bpn, opts) + dossierHTML(bpn); +}