SLA Blueprint News v0.1.0 — Foundry module wrapper for the BPN generator

ApplicationV2 generator (mirrors the standalone app) over the vendored
engine-agnostic component. Creates a journal entry per briefing with a
player-facing Mission Brief page and a GM-only Dossier page; procedural
art in sanitiser-safe img mode; SLA house fonts bundled. Verified idiom
against mapwright (v14.361).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 13:22:02 +01:00
co-authored by Claude Opus 4.8
commit 70fd7bfc89
28 changed files with 2838 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.DS_Store
*.zip
+50
View File
@@ -0,0 +1,50 @@
# SLA Blueprint News (Foundry VTT module)
Generate SLA Industries **BPN** mission briefings inside Foundry. Each briefing
is created as a **single journal entry** with two pages:
- **Mission Brief** — player-facing (default ownership *Observer*), the official
corporate dispatch.
- **GM Dossier** — GM-only (ownership *None* for players): the twist, the
withheld fact, the true threat read, levers, and the dice state.
Styled in the SLA house look with bundled local fonts and **procedural artwork**
generated per briefing (flat, non-photorealistic). NPC/monster generation is a
separate concern — each briefing exposes an archetype seam for a companion module.
This module is the Foundry wrapper around an **engine-agnostic** core
([blueprint-news](https://github.com/slaguru666/blueprint-news)); the generation,
art, and render layers are vendored under `module/vendor/` and contain no Foundry
calls.
## Use
GM only. Open the generator from:
- the **Journal sidebar** header button (“Blueprint News”), or
- the **Notes** scene-controls toolbar, or
- the console / macro: `game.slaBlueprintNews.open()`
Pick a colour band / scenario frame (or leave on *Any* to roll), watch the live
preview, then **Create Journal** (writes the two pages) or **Post to Chat**.
Every briefing is reproducible from its **seed**, which also drives the artwork.
To reveal a brief to players, show or grant them the journal entry as usual —
they will only see the Mission Brief page, never the GM Dossier.
## Install
**On a server (recommended):** Setup → Add-on Modules → Install Module → paste
the manifest URL:
```
https://raw.githubusercontent.com/slaguru666/sla-blueprint-news/main/module.json
```
**By hand:** copy this `sla-blueprint-news/` folder into your server's
`Data/modules/` directory and restart Foundry.
## Compatibility
Foundry VTT v13–v14 (verified 14.361). System-agnostic — it writes plain journal
entries and does not touch any system's actor/item schema.
+270
View File
@@ -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;
}
+22
View File
@@ -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;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
{
"SLABPN.Title": "SLA Blueprint News",
"SLABPN.Open": "Blueprint News",
"SLABPN.ToolbarOpen": "Open Blueprint News"
}
+34
View File
@@ -0,0 +1,34 @@
{
"id": "sla-blueprint-news",
"title": "SLA Blueprint News",
"description": "Generate SLA Industries BPN mission briefings as journal entries — a player-facing Brief and a separate GM-only Dossier, in the SLA house style with procedural artwork. Engine-agnostic core; this module is the Foundry wrapper.",
"version": "0.1.0",
"compatibility": {
"minimum": "13",
"verified": "14.361",
"maximum": "14"
},
"authors": [
{
"name": "Tim Evans"
}
],
"esmodules": [
"module/blueprint-news.mjs"
],
"styles": [
"fonts/fonts.css",
"styles/bpn-render.css",
"styles/module.css"
],
"languages": [
{
"lang": "en",
"name": "English",
"path": "lang/en.json"
}
],
"manifest": "https://raw.githubusercontent.com/slaguru666/sla-blueprint-news/main/module.json",
"download": "https://github.com/slaguru666/sla-blueprint-news/releases/latest/download/sla-blueprint-news.zip",
"url": "https://github.com/slaguru666/sla-blueprint-news"
}
+183
View File
@@ -0,0 +1,183 @@
/**
* SLA Blueprint News — generator window (ApplicationV2 + Handlebars).
*
* Mirrors the standalone app over the vendored engine: pick band / frame /
* squad / payment, watch a live preview, then Create Journals (a player-facing
* Mission Brief page + a GM-only Dossier page) or Post to Chat.
*
* The render layer is engine-free; this file is the only Foundry-aware code.
*/
import { generateBPN, randomSeed } from "../vendor/core/bpn-core.js";
import { COLOURS } from "../vendor/core/data/colours.js";
import { framesForColour } from "../vendor/core/data/frames.js";
import { briefHTML, dossierHTML } from "../vendor/render/bpn-render.js";
import { crestDataURI } from "../vendor/art/bpn-art.js";
const MODULE_ID = "sla-blueprint-news";
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2) {
state = {
seed: randomSeed(),
colourKey: "",
frameId: "",
squadSize: 4,
paymentType: "",
view: "player"
};
static DEFAULT_OPTIONS = {
id: "sla-blueprint-news-app",
tag: "div",
classes: ["sla-bpn-window"],
window: { title: "SLABPN.Title", resizable: true, icon: "fas fa-file-contract" },
position: { width: 1080, height: 820 },
actions: {
reroll: BlueprintNewsApp.#onReroll,
applySeed: BlueprintNewsApp.#onApplySeed,
copySeed: BlueprintNewsApp.#onCopySeed,
view: BlueprintNewsApp.#onView,
create: BlueprintNewsApp.#onCreate,
chat: BlueprintNewsApp.#onChat
}
};
static PARTS = { body: { template: `modules/${MODULE_ID}/templates/generator.html` } };
static open() { return new BlueprintNewsApp().render({ force: true }); }
/** Resolve the current briefing from UI state (deterministic from seed). */
_bpn() {
return generateBPN({
seed: this.state.seed,
colourKey: this.state.colourKey || undefined,
frameId: this.state.frameId || undefined,
squadSize: this.state.squadSize,
overrides: { paymentType: this.state.paymentType || "__RANDOM__" }
});
}
async _prepareContext() {
const bpn = this._bpn();
// keep state in step with what was actually rolled (band/frame/seed)
this.state.seed = bpn.seed;
const colourOptions = [
{ key: "", label: "Any (roll d20)", selected: this.state.colourKey === "" }
].concat(COLOURS.map((c) => ({
key: c.key,
label: `${c.label} — ${c.type}`,
selected: c.key === this.state.colourKey
})));
const frames = this.state.colourKey ? framesForColour(this.state.colourKey) : [];
const frameOptions = [{ id: "", label: "Any in band", selected: this.state.frameId === "" }]
.concat(frames.map((f) => ({ id: f.id, label: f.shortTitle, selected: f.id === this.state.frameId })));
const previewHTML = this.state.view === "gm"
? dossierHTML(bpn, { artMode: "img" })
: this.state.view === "both"
? briefHTML(bpn, { artMode: "img" }) + dossierHTML(bpn, { artMode: "img" })
: briefHTML(bpn, { artMode: "img" });
const paymentOptions = [
{ value: "", label: "Any" },
{ value: "Per Squad", label: "Per Squad" },
{ value: "Per Operative", label: "Per Operative" }
].map((o) => ({ ...o, selected: o.value === this.state.paymentType }));
const viewButtons = [
{ view: "player", label: "Player Brief" },
{ view: "gm", label: "GM Dossier" },
{ view: "both", label: "Both" }
].map((v) => ({ ...v, active: v.view === this.state.view }));
return {
state: this.state,
colourOptions,
frameOptions,
frameDisabled: !this.state.colourKey,
paymentOptions,
viewButtons,
previewHTML
};
}
_onRender() {
const root = this.element;
root.querySelectorAll("[data-field]").forEach((el) => {
el.addEventListener("change", () => {
const f = el.dataset.field;
this.state[f] = f === "squadSize" ? Number(el.value) || 4 : el.value;
if (f === "colourKey") this.state.frameId = "";
this.render();
});
});
}
static #onReroll() { this.state.seed = randomSeed(); this.render(); }
static #onApplySeed() {
const v = this.element.querySelector('[data-field="seed"]')?.value?.trim();
if (v) this.state.seed = v;
this.render();
}
static async #onCopySeed() {
try { await navigator.clipboard.writeText(this.state.seed); ui.notifications.info("Seed copied."); } catch (_) {}
}
static #onView(event, target) {
this.state.view = target.dataset.view || "player";
this.render();
}
static async #onCreate() {
const bpn = this._bpn();
const O = CONST.DOCUMENT_OWNERSHIP_LEVELS;
const HTML = CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
const wrap = (html) => `<div class="bpn">${html}</div>`;
const entry = await JournalEntry.create({
name: `${bpn.colour.label} BPN — ${bpn.shortTitle}`,
ownership: { default: O.OBSERVER },
flags: { [MODULE_ID]: { seed: bpn.seed, colourKey: bpn.colourKey, frameId: bpn.gm.frameId } },
pages: [
{
name: "Mission Brief",
type: "text",
title: { show: false, level: 1 },
ownership: { default: O.OBSERVER },
text: { format: HTML, content: wrap(briefHTML(bpn, { artMode: "img" })) }
},
{
name: "GM Dossier",
type: "text",
title: { show: false, level: 1 },
ownership: { default: O.NONE },
text: { format: HTML, content: wrap(dossierHTML(bpn, { artMode: "img" })) }
}
]
});
ui.notifications.info(`Created BPN journal "${entry.name}" (player Brief + GM Dossier page).`);
entry.sheet?.render(true);
}
static async #onChat() {
const bpn = this._bpn();
const card = `<div class="bpn"><div class="bpn-chat-card" style="--bpn-accent:${bpn.colour.accent};">
<div class="bpn-chat-card__head">
<img src="${crestDataURI(bpn, { size: 56 })}" width="56" height="56" alt="">
<div>
<div class="bpn-chat-card__band">${bpn.colour.label} BPN · ${bpn.colour.type}</div>
<div class="bpn-chat-card__title">${bpn.shortTitle}</div>
<div class="bpn-chat-card__meta">${bpn.bpnId} · SCL ${bpn.sclRequirement} · ${bpn.reward.grossContract}</div>
</div>
</div>
<p class="bpn-chat-card__brief">${foundry.utils.escapeHTML(bpn.missionBrief)}</p>
</div></div>`;
await ChatMessage.create({ speaker: { alias: "SLA Blueprint News" }, content: card });
}
}
+59
View File
@@ -0,0 +1,59 @@
/**
* SLA Blueprint News — entry point.
*
* Thin Foundry wrapper around the engine-agnostic component vendored under
* module/vendor/. Registers a global API and a Journal-directory button that
* opens the generator; the generator writes briefings into journal entries.
*/
import { BlueprintNewsApp } from "./app/generator-app.mjs";
const MODULE_ID = "sla-blueprint-news";
Hooks.once("init", () => {
game.slaBlueprintNews = { open: () => BlueprintNewsApp.open() };
});
/* Journal directory header button (GM only) — the natural home, since this
tool produces journal entries. */
Hooks.on("renderJournalDirectory", (_app, html) => {
if (!game.user?.isGM) return;
const root = html instanceof HTMLElement ? html : html?.[0];
if (!root) return;
const header =
root.querySelector(".directory-header .action-buttons") ??
root.querySelector(".directory-header .header-actions") ??
root.querySelector(".directory-header");
if (!header || header.querySelector(".sla-bpn-open")) return;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "sla-bpn-open";
btn.innerHTML = `<i class="fas fa-file-contract"></i> ${game.i18n.localize("SLABPN.Open")}`;
btn.addEventListener("click", () => BlueprintNewsApp.open());
header.appendChild(btn);
});
/* Notes-layer toolbar button as a second entry point (v13/v14 control shape). */
Hooks.on("getSceneControlButtons", (controls) => {
if (!game.user?.isGM) return;
const tool = {
name: "slaBpnOpen",
title: game.i18n.localize("SLABPN.ToolbarOpen"),
icon: "fas fa-file-contract",
button: true,
onClick: () => BlueprintNewsApp.open()
};
if (controls && typeof controls === "object" && !Array.isArray(controls)) {
const notes = controls.notes;
if (notes) {
notes.tools ??= {};
notes.tools[tool.name] ??= tool;
}
} else if (Array.isArray(controls)) {
const notes = controls.find((c) => c.name === "notes");
if (notes) {
notes.tools ??= [];
if (!notes.tools.some((t) => t.name === tool.name)) notes.tools.push(tool);
}
}
});
+344
View File
@@ -0,0 +1,344 @@
/* 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 `<path d="M-9 -9 L9 9 M9 -9 L-9 9" ${stroke}/>`;
if (f.includes("escort") || f.includes("protect")) return `<path d="M0 -11 L10 -5 V4 C10 10 5 12 0 12 C-5 12 -10 10 -10 4 V-5 Z" ${stroke}/>`;
if (f.includes("retrieve")) return `<path d="M-9 -6 H9 V8 H-9 Z M-9 -6 L0 -11 L9 -6" ${stroke}/>`;
if (f.includes("investigate") || f.includes("profile")) return `<circle r="6" cx="-2" cy="-2" ${stroke}/><path d="M3 3 L10 10" ${stroke}/>`;
if (f.includes("contain") || f.includes("quarantine")) return `<circle r="3" ${stroke}/><path d="M0 -11 A11 11 0 0 1 9.5 5 M0 11 A11 11 0 0 1 -9.5 5 M0 11 A11 11 0 0 0 9.5 -5" ${stroke}/>`;
if (f.includes("riot") || f.includes("crowd")) return `<path d="M-10 8 V-2 M-3 8 V-6 M4 8 V-2 M11 8 V-8" ${stroke}/>`;
return `<path d="M0 -10 V10 M-7 0 H7 M-5 -6 H5 M-5 6 H5" ${stroke}/>`;
}
/* ------------------------------------------------------------------------ */
/* 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) => `<circle cx="${x}" cy="${y}" r="2.4" fill="${rgba(accent, 0.55)}"/>`;
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 `<line x1="${r2(x1)}" y1="${r2(y1)}" x2="${r2(x2)}" y2="${r2(y2)}" stroke="${rgba(accent, 0.4)}" stroke-width="${i % 3 === 0 ? 3 : 1.5}"/>`;
}).join("");
const jitter = r2(rfloat(rng, -2, 2));
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="${size}" height="${size}" role="img" aria-label="${bpn?.colour?.label ?? ""} BPN crest">
<defs>
<linearGradient id="cg-${uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="${mix(dark, accent, 0.18)}"/>
<stop offset="1" stop-color="${dark}"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="120" height="120" rx="22" fill="url(#cg-${uid})"/>
<rect x="9" y="9" width="110" height="110" rx="18" fill="none" stroke="${rim}" stroke-width="3.5"/>
${ticks}
${rivet(20, 20)}${rivet(108, 20)}${rivet(20, 108)}${rivet(108, 108)}
<g transform="translate(${64 + jitter} 64)">
<path d="M0 -34 C26 -34 40 -10 40 0 C40 10 26 34 0 34 C-26 34 -40 10 -40 0 C-40 -10 -26 -34 0 -34 Z" fill="none" stroke="${accent}" stroke-width="4"/>
<path d="M0 -22 C18 -22 28 -7 28 0 C28 7 18 22 0 22 C-18 22 -28 7 -28 0 C-28 -7 -18 -22 0 -22 Z" fill="${rgba(accent, 0.10)}"/>
<circle r="14" fill="${rgba(dark, 0.9)}" stroke="${rgba(accent, 0.5)}" stroke-width="1.5"/>
${glyph}
</g>
</svg>`;
}
/* ------------------------------------------------------------------------ */
/* 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(`<rect x="${r2(wx)}" y="${r2(wy)}" width="4" height="6" fill="${rgba(accent, alpha * rfloat(rng, 0.5, 1))}"/>`);
}
}
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(`<rect x="${x}" y="${r2(top)}" width="${w}" height="${r2(H - top)}" fill="#060a11"/>`);
if (rng() < 0.4) { const mx = x + rint(rng, 6, w - 6); out.push(`<line x1="${mx}" y1="${r2(top)}" x2="${mx}" y2="${r2(top - rint(rng, 10, 34))}" stroke="#060a11" stroke-width="2"/>`); }
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(`<ellipse cx="${r2(cx)}" cy="${r2(H * 0.62)}" rx="${r2(rx)}" ry="${r2(ry)}" fill="none" stroke="${rgba(accent, 0.10 + i * 0.03)}" stroke-width="${6 - i}"/>`); }
out.push(`<rect x="0" y="${r2(H * 0.7)}" width="${W}" height="${r2(H * 0.3)}" fill="#04070c"/>`);
for (let i = 0; i < 26; i++) { const x = rfloat(rng, 0, W); out.push(`<line x1="${r2(x)}" y1="${r2(H * 0.72)}" x2="${r2(x)}" y2="${r2(H * 0.74 + rfloat(rng, 0, 8))}" stroke="${rgba(accent, 0.2)}" stroke-width="1"/>`); } // water glints
out.push(`<rect x="40" y="${r2(H * 0.3)}" width="14" height="${r2(H * 0.5)}" fill="#060a11"/><rect x="${W - 54}" y="${r2(H * 0.3)}" width="14" height="${r2(H * 0.5)}" fill="#060a11"/>`); // 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(`<rect x="${r2(x - 7)}" y="${r2(H * 0.32)}" width="14" height="${r2(H * 0.68)}" fill="#04070c"/>`); if (rng() < 0.8) out.push(`<rect x="${r2(x - 26)}" y="${r2(H * 0.28)}" width="52" height="14" rx="2" fill="${rgba(accent, 0.5)}"/>`); } // pillars + route boards
return out.join("");
},
wall(rng, W, H, accent) {
const out = [], top = H * 0.34;
out.push(`<rect x="0" y="${r2(top)}" width="${W}" height="${r2(H - top)}" fill="#070b12"/>`);
for (let x = 0; x < W; x += 64) out.push(`<rect x="${x + 4}" y="${r2(top)}" width="56" height="${r2(H - top)}" fill="none" stroke="${rgba(accent, 0.12)}" stroke-width="1.5"/>`); // plating
for (let x = 0; x < W; x += 64) out.push(`<rect x="${x + 18}" y="${r2(top - 16)}" width="28" height="16" fill="#070b12"/>`); // 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(`<g transform="rotate(${k * 4} ${W / 2} ${y})"><rect x="-40" y="${r2(y)}" width="${W + 80}" height="13" fill="${rgba(accent, 0.5)}"/></g>`); }
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(`<rect x="${r2(x)}" y="${r2(y)}" width="64" height="22" fill="#04070c" stroke="${rgba(accent, 0.18)}" stroke-width="1"/>`); } // containers
out.push(`<path d="M${r2(W * 0.6)} ${r2(H * 0.3)} h120 v10 h-120 z M${r2(W * 0.62)} ${r2(H * 0.31)} v${r2(H * 0.5)}" fill="#04070c" stroke="${rgba(accent, 0.2)}" stroke-width="2"/>`); // gantry crane
return out.join("");
},
stage(rng, W, H, accent) {
const out = [MOTIFS.skyline(rng, W, H, accent, 0.5)];
out.push(`<path d="M${r2(W * 0.2)} ${r2(H * 0.26)} H${r2(W * 0.8)} V${r2(H * 0.3)} H${r2(W * 0.2)} Z" fill="#04070c" stroke="${rgba(accent, 0.3)}" stroke-width="2"/>`); // truss
for (let i = 0; i < 5; i++) { const x = W * (0.28 + i * 0.11); out.push(`<polygon points="${r2(x)},${r2(H * 0.3)} ${r2(x - 40)},${H} ${r2(x + 40)},${H}" fill="${rgba(accent, 0.06)}"/>`); } // spotlight cones
return out.join("");
},
canal(rng, W, H, accent) {
const out = [MOTIFS.skyline(rng, W, H, accent, 0.5)];
out.push(`<rect x="0" y="${r2(H * 0.78)}" width="${W}" height="${r2(H * 0.22)}" fill="${rgba(accent, 0.07)}"/>`); // canal
for (let i = 0; i < 7; i++) { const x = rfloat(rng, 30, W - 30); out.push(`<rect x="${r2(x)}" y="${r2(H * 0.72)}" width="18" height="26" rx="3" fill="#04070c" stroke="${rgba(accent, 0.4)}" stroke-width="1.5"/>`); } // 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(`<rect x="${r2(tx - 16)}" y="${r2(H * 0.12)}" width="32" height="${r2(H * 0.88)}" fill="#04070c" stroke="${rgba(accent, 0.25)}" stroke-width="1.5"/>`); // broadcast tower
out.push(`<line x1="${r2(tx)}" y1="${r2(H * 0.12)}" x2="${r2(tx)}" y2="${r2(H * 0.02)}" stroke="${rgba(accent, 0.5)}" stroke-width="2"/>`);
out.push(`<line x1="0" y1="${r2(H * 0.5)}" x2="${W}" y2="${r2(H * 0.46)}" stroke="#04070c" stroke-width="8"/>`); // 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(`<rect x="${r2(cx - w / 2)}" y="${r2(H * 0.5 - h / 2)}" width="${r2(w)}" height="${r2(h)}" fill="none" stroke="${rgba(accent, 0.06 + i * 0.02)}" stroke-width="1.5"/>`); } // receding corridor
out.push(`<rect x="${r2(cx - 20)}" y="${r2(H * 0.36)}" width="40" height="${r2(H * 0.28)}" fill="#04070c" stroke="${rgba(accent, 0.3)}" stroke-width="1.5"/>`); // far door
for (let i = 0; i < 5; i++) out.push(`<rect x="${r2(cx + 26)}" y="${r2(H * 0.4 + i * 12)}" width="4" height="4" fill="${rgba(accent, 0.5)}"/>`); // 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(`<rect x="${x}" y="${r2(top)}" width="${w}" height="${r2(H - top)}" fill="#05080e" stroke="${rgba(accent, 0.10)}" stroke-width="1"/>`); out.push(litWindowGrid(rng, x, top, w, H - top, accent, 0.4, 18)); x += w + rint(rng, 40, 90); }
out.push(`<line x1="0" y1="${r2(H * 0.7)}" x2="${W}" y2="${r2(H * 0.68)}" stroke="#04070c" stroke-width="6"/>`);
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(`<rect x="${x}" y="${r2(top)}" width="${w}" height="${r2(H - top)}" fill="${mix(dark, accent, 0.06)}"/>`); 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 `<polygon points="${r2(ox)},-10 ${r2(ox - s)},${H} ${r2(ox + s)},${H}" fill="${rgba(accent, 0.05)}"/>`; }).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 `<line x1="${r2(x)}" y1="${r2(y)}" x2="${r2(x + 1.5)}" y2="${r2(y + len)}" stroke="${rgba(accent, rfloat(rng, 0.05, 0.18))}" stroke-width="1"/>`; }).join("");
if (scene.smog > 0) smog = Array.from({ length: Math.round(scene.smog * 5) }, (_, i) => `<ellipse cx="${r2(rfloat(rng, 0, W))}" cy="${r2(rfloat(rng, H * 0.4, H))}" rx="${r2(rfloat(rng, 120, 320))}" ry="${r2(rfloat(rng, 30, 80))}" fill="${rgba(accent, 0.04)}"/>`).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 `<circle cx="${r2(x)}" cy="${r2(y)}" r="${r2(rfloat(rng, 1.5, 3))}" fill="${rgba(accent, rfloat(rng, 0.1, 0.3))}"/>`; }).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 `<circle cx="${r2(x)}" cy="${r2(H * 0.12)}" r="${r2(rfloat(rng, 8, 14))}" fill="${rgba(accent, 0.6)}"/><polygon points="${r2(x)},${r2(H * 0.12)} ${r2(x - 90)},${H} ${r2(x + 90)},${H}" fill="${rgba(accent, 0.06)}"/>`; }).join("");
if (scene.beacons) beaconGlow = Array.from({ length: rint(rng, 2, 4) }, () => `<circle cx="${r2(rfloat(rng, 0, W))}" cy="${r2(rfloat(rng, H * 0.2, H * 0.5))}" r="${r2(rfloat(rng, 16, 34))}" fill="${rgba(accent, 0.10)}"/>`).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(`<path d="M${x} ${bandY} l14 8 l14 -8 l0 8 l-14 8 l-14 -8 Z" fill="${rgba(accent, 0.5)}"/>`); hazard = `<g>${chev.join("")}</g>`; }
// giant faint Third Eye
const eyeX = rng() < 0.5 ? W * 0.78 : W * 0.22, eyeScale = rfloat(rng, 1.6, 2.2);
const eye = `<g transform="translate(${r2(eyeX)} ${r2(H * 0.46)}) scale(${r2(eyeScale)})" opacity="0.12"><path d="M0 -34 C26 -34 40 -10 40 0 C40 10 26 34 0 34 C-26 34 -40 10 -40 0 C-40 -10 -26 -34 0 -34 Z" fill="none" stroke="${accent}" stroke-width="3"/><circle r="13" fill="none" stroke="${accent}" stroke-width="3"/></g>`;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="xMidYMid slice" role="img" aria-label="${bpn?.colour?.label ?? ""} sector backdrop">
<defs>
<linearGradient id="sky-${uid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${mix(dark, accent, 0.16)}"/><stop offset="0.55" stop-color="${dark}"/><stop offset="1" stop-color="#04070c"/></linearGradient>
<radialGradient id="glow-${uid}" cx="50%" cy="0%" r="80%"><stop offset="0" stop-color="${rgba(accent, 0.18)}"/><stop offset="1" stop-color="rgba(0,0,0,0)"/></radialGradient>
<pattern id="scan-${uid}" width="3" height="3" patternUnits="userSpaceOnUse"><rect width="3" height="1" fill="rgba(0,0,0,0.22)"/></pattern>
<radialGradient id="vig-${uid}" cx="50%" cy="50%" r="75%"><stop offset="0.55" stop-color="rgba(0,0,0,0)"/><stop offset="1" stop-color="rgba(0,0,0,0.55)"/></radialGradient>
<filter id="grain-${uid}"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
</defs>
<rect width="${W}" height="${H}" fill="url(#sky-${uid})"/>
<rect width="${W}" height="${H}" fill="url(#glow-${uid})"/>
${beams}${beaconGlow}${smog}
${eye}
${back}
${fg}
${floods}${crowd}${rain}
${hazard}
<rect width="${W}" height="${H}" fill="url(#scan-${uid})"/>
<rect width="${W}" height="${H}" filter="url(#grain-${uid})" opacity="0.05"/>
<rect width="${W}" height="${H}" fill="url(#vig-${uid})"/>
</svg>`;
}
/* ------------------------------------------------------------------------ */
/* 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(`<circle cx="${x}" cy="${y}" r="${r2(0.8 + p * 1.6)}" fill="${rgba(accent, 0.18 + p * 0.22)}"/>`);
}
}
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" width="${size}" height="${size}" role="img" aria-label="contact ident">
<defs><clipPath id="clip-${uid}"><path d="M60 24 a18 18 0 0 1 18 18 a18 18 0 0 1 -36 0 a18 18 0 0 1 18 -18 Z M24 120 v-14 c0 -20 16 -32 36 -32 c20 0 36 12 36 32 v14 Z"/></clipPath></defs>
<rect x="2" y="2" width="116" height="116" rx="12" fill="${dark}" stroke="${rgba(accent, 0.4)}" stroke-width="2"/>
<g clip-path="url(#clip-${uid})">
<rect x="0" y="0" width="120" height="120" fill="${rgba(accent, 0.08)}"/>
${dots.join("")}
</g>
<path d="M60 24 a18 18 0 0 1 18 18 a18 18 0 0 1 -36 0 a18 18 0 0 1 18 -18 Z" fill="none" stroke="${rgba(accent, 0.55)}" stroke-width="2"/>
<path d="M24 120 v-14 c0 -20 16 -32 36 -32 c20 0 36 12 36 32 v14" fill="none" stroke="${rgba(accent, 0.55)}" stroke-width="2"/>
<line x1="10" y1="104" x2="110" y2="104" stroke="${rgba(accent, 0.3)}" stroke-width="1"/>
</svg>`;
}
/* Convenience: data URIs (handy as <img> srcs or journal icons, and required
in hosts that sanitise inline <svg> such as Foundry journal pages). */
export function crestDataURI(bpn, opts) {
return `data:image/svg+xml;utf8,${encodeURIComponent(crestSVG(bpn, opts))}`;
}
export function bannerDataURI(bpn, opts) {
return `data:image/svg+xml;utf8,${encodeURIComponent(bannerSVG(bpn, opts))}`;
}
export function portraitDataURI(bpn, opts) {
return `data:image/svg+xml;utf8,${encodeURIComponent(portraitSVG(bpn, opts))}`;
}
+319
View File
@@ -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;
}
+156
View File
@@ -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;
}
+362
View File
@@ -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;
}
+208
View File
@@ -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."
]
};
+216
View File
@@ -0,0 +1,216 @@
/* 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,
crestDataURI, bannerDataURI, portraitDataURI
} from "../art/bpn-art.js";
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
));
}
/* The SLA "Third Eye" mark, used on the GM banner. */
function thirdEyeSVG(colour = "#ff5b5b") {
return `<svg class="bpn-eye" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M32 14 C50 14 58 28 58 32 C58 36 50 50 32 50 C14 50 6 36 6 32 C6 28 14 14 32 14 Z" fill="none" stroke="${colour}" stroke-width="3"/>
<circle cx="32" cy="32" r="9" fill="none" stroke="${colour}" stroke-width="3"/>
<line x1="10" y1="54" x2="54" y2="10" stroke="${colour}" stroke-width="3"/>
</svg>`;
}
function fact(label, value) {
return `<div><span class="bpn-label">${esc(label)}</span><span class="bpn-value">${esc(value)}</span></div>`;
}
function card(label, value) {
return `<div class="bpn-card"><span class="bpn-label">${esc(label)}</span><span class="bpn-value">${esc(value)}</span></div>`;
}
/* ---------------- PLAYER BRIEF ---------------- */
export function briefHTML(bpn, { art = true, artMode = "inline" } = {}) {
const c = bpn.colour;
const img = artMode === "img";
const banner = !art ? ""
: img ? `<img class="bpn-hero__artimg" src="${bannerDataURI(bpn, { width: 1200, height: 360 })}" alt="">`
: bannerSVG(bpn, { width: 1200, height: 360 });
const crest = img ? `<img src="${crestDataURI(bpn, { size: 184 })}" alt="">` : crestSVG(bpn, { size: 184 });
const portrait = img ? `<img src="${portraitDataURI(bpn, { size: 120 })}" alt="">` : portraitSVG(bpn, { size: 120 });
return `<article class="bpn-brief" style="--bpn-accent:${esc(c.accent)};">
<div class="bpn-doc">
<header class="bpn-hero">
<div class="bpn-hero__art" aria-hidden="true">${banner}</div>
<div class="bpn-stamp">Issued by<b>${esc(bpn.issuingDepartment)}</b></div>
<div class="bpn-hero__crest">${crest}</div>
<div class="bpn-hero__copy">
<span class="bpn-hero__band"><b>${esc(c.label)}</b> BPN &middot; ${esc(c.type)}</span>
<h1 class="bpn-hero__title">${esc(bpn.shortTitle)}</h1>
<p class="bpn-hero__sub">${esc(c.summary)}</p>
</div>
</header>
<div class="bpn-facts">
${fact("BPN ID", bpn.bpnId)}
${fact("SCL Requirement", bpn.sclRequirement)}
${fact("Gross Contract", bpn.reward.grossContract)}
${fact("Est. Take-home", bpn.reward.takeHomeEach)}
</div>
<div class="bpn-body">
<section>
<h2 class="bpn-section__title">Mission Brief</h2>
<p class="bpn-brief-text">${esc(bpn.missionBrief)}</p>
</section>
<section>
<h2 class="bpn-section__title">Objectives</h2>
<div class="bpn-objective"><span class="bpn-label">Primary</span><span class="bpn-value">${esc(bpn.objective)}</span></div>
<div class="bpn-objective" style="margin-top:8px;"><span class="bpn-label">Secondary</span><span class="bpn-value">${esc(bpn.extraObjective)}</span></div>
</section>
<section>
<h2 class="bpn-section__title">Operational Picture</h2>
<div class="bpn-grid">
${card("Location", bpn.location)}
${card("Atmosphere", bpn.atmosphere)}
${card("Declared Opposition", bpn.opposition)}
${card("Mission Focus", bpn.missionFocus)}
</div>
</section>
<section>
<h2 class="bpn-section__title">On-site Contact &amp; Pressures</h2>
<div class="bpn-contact">
<div class="bpn-contact__ident">${portrait}</div>
<div class="bpn-contact__detail">
<span class="bpn-label">Primary Contact</span>
<span class="bpn-value bpn-contact__name">${esc(bpn.contact.name)}</span>
<span class="bpn-value">${esc(bpn.contact.role)}</span>
<span class="bpn-contact__pos">${esc(bpn.contact.location)}</span>
</div>
</div>
<div class="bpn-grid" style="margin-top:12px;">
${card("Civilian Pressure", bpn.civilianFaction)}
${card("Operational Hook", bpn.hook)}
</div>
</section>
</div>
<footer class="bpn-footer">
${fact("Media Posture", bpn.reward.mediaCoverage)}
${fact("Squad Size", `${bpn.squadSize} Operatives`)}
${fact("SCL Increase", bpn.reward.sclIncrease)}
${fact("Cleanup Directive", bpn.cleanupDirective)}
</footer>
</div>
</article>`;
}
/* ---------------- GM DOSSIER ---------------- */
export function dossierHTML(bpn, { artMode = "inline" } = {}) {
const c = bpn.colour;
const g = bpn.gm;
const seam = bpn.npcSeam;
const r = g.rolls ?? {};
const eye = artMode === "img"
? `<img class="bpn-eye" src="data:image/svg+xml;utf8,${encodeURIComponent(thirdEyeSVG("#ff5b5b"))}" alt="">`
: thirdEyeSVG("#ff5b5b");
const seamRow = (seam.contactArchetype || seam.threatArchetype)
? `<section class="bpn-seam">
<p class="bpn-eyebrow">NPC / Monster Hand-off</p>
<p class="bpn-value" style="margin:6px 0 0;">This brief reserves two cast slots for the companion NPC module:</p>
<p style="margin:8px 0 0; line-height:1.9;">
Contact archetype <code>${esc(seam.contactArchetype ?? "—")}</code> &mdash; ${esc(bpn.contact.name)}, ${esc(bpn.contact.role)}.<br>
Threat archetype <code>${esc(seam.threatArchetype ?? "—")}</code> &mdash; ${esc(seam.threatRole ?? g.threatLead ?? "—")}.
</p>
</section>`
: "";
return `<article class="bpn-dossier" style="--bpn-accent:${esc(c.accent)};">
<div class="bpn-doc">
<header class="bpn-dossier__banner">
${eye}
<h2>GM Dossier — ${esc(bpn.shortTitle)}</h2>
<span class="bpn-eyebrow">Restricted &middot; Handler Eyes Only</span>
</header>
<div class="bpn-body">
<div class="bpn-grid">
<div class="bpn-reveal bpn-twist">
<span class="bpn-label">Operational Twist</span>
<span class="bpn-value">${esc(g.localTwist)}</span>
</div>
<div class="bpn-reveal">
<span class="bpn-label">Withheld Fact</span>
<span class="bpn-value">${esc(g.complication)}</span>
</div>
</div>
<section>
<h2 class="bpn-section__title">True Threat Read</h2>
<div class="bpn-card">
<dl class="bpn-threat-read">
<dt>Lead</dt><dd>${esc(g.threatLead || "—")}</dd>
<dt>Numbers</dt><dd>${esc(g.threatCountLabel || "—")}</dd>
<dt>Attitude</dt><dd>${esc(g.oppositionAttitude)}</dd>
</dl>
</div>
</section>
<section>
<h2 class="bpn-section__title">Levers &amp; Escalation</h2>
<div class="bpn-grid">
${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`)}
</div>
</section>
${seamRow}
<p class="bpn-rolls">Seed ${esc(bpn.seed)} &middot; frame ${esc(g.frameId)} &middot; 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)}.</p>
</div>
</div>
</article>`;
}
/* ---------------- 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);
}
+390
View File
@@ -0,0 +1,390 @@
/* 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,
.bpn-brief .bpn-hero__artimg { width: 100%; height: 100%; display: block; object-fit: cover; }
.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,
.bpn-hero__crest img { 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,
.bpn-contact__ident img { 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; }
}
+155
View File
@@ -0,0 +1,155 @@
/* SLA Blueprint News — generator window chrome + chat card.
The briefing surfaces themselves are styled by styles/bpn-render.css. */
.sla-bpn-open {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: "Roboto Mono", monospace;
letter-spacing: 0.04em;
}
.sla-bpn-gen {
display: grid;
grid-template-columns: 300px 1fr;
height: 100%;
min-height: 0;
font-family: "Josefin Sans", system-ui, sans-serif;
color: #eef3fa;
}
.sla-bpn-gen__rail {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
background: #11161f;
border-right: 1px solid #2c333f;
overflow-y: auto;
}
.sla-bpn-gen__field { display: grid; gap: 5px; }
.sla-bpn-gen__field > label {
font-family: "Roboto Mono", monospace;
font-size: 0.68rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #9fb0c4;
}
.sla-bpn-gen__field select,
.sla-bpn-gen__field input {
width: 100%;
padding: 8px 10px;
border-radius: 7px;
border: 1px solid #2c333f;
background: #0a0e14;
color: #eef3fa;
font-family: inherit;
}
.sla-bpn-gen__row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.sla-bpn-gen__btn {
border-radius: 7px;
border: 1px solid #2c333f;
background: #161c26;
color: #eef3fa;
font-family: inherit;
font-weight: 600;
padding: 9px 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.sla-bpn-gen__btn:hover { filter: brightness(1.14); }
.sla-bpn-gen__btn--primary { border-color: #b88a24; }
.sla-bpn-gen__btn--go {
border: 1px solid #b88a24;
background: linear-gradient(180deg, #d6b24e, #b88a24);
color: #1a1208;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.sla-bpn-gen__divider { height: 1px; background: #2c333f; }
.sla-bpn-gen__hint { font-size: 0.78rem; color: #9fb0c4; line-height: 1.4; margin: 0; }
.sla-bpn-gen__hint b { color: #d6b24e; }
.sla-bpn-gen__stage {
display: flex;
flex-direction: column;
min-height: 0;
background: #0a0e14;
}
.sla-bpn-gen__viewtoggle {
display: flex;
gap: 2px;
justify-content: center;
padding: 12px;
border-bottom: 1px solid #2c333f;
}
.sla-bpn-gen__viewtoggle button {
border: 1px solid #2c333f;
background: #11161f;
color: #9fb0c4;
font-family: "Roboto Mono", monospace;
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 7px 16px;
cursor: pointer;
}
.sla-bpn-gen__viewtoggle button:first-child { border-radius: 999px 0 0 999px; }
.sla-bpn-gen__viewtoggle button:last-child { border-radius: 0 999px 999px 0; }
.sla-bpn-gen__viewtoggle button.is-active {
background: linear-gradient(180deg, #d6b24e, #b88a24);
color: #1a1208;
font-weight: 700;
}
.sla-bpn-gen__preview {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 20px;
display: grid;
gap: 22px;
align-content: start;
}
/* chat card */
.bpn-chat-card {
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--bpn-accent, #888);
background: linear-gradient(180deg, #161c26, #0d1117);
}
.bpn-chat-card__head {
display: grid;
grid-template-columns: 56px 1fr;
gap: 10px;
align-items: center;
padding: 10px 12px;
background: linear-gradient(90deg, color-mix(in srgb, var(--bpn-accent, #888) 22%, transparent), transparent 55%);
}
.bpn-chat-card__band {
font-family: "Roboto Mono", monospace;
font-size: 0.64rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: color-mix(in srgb, var(--bpn-accent, #888) 60%, #fff);
}
.bpn-chat-card__title {
font-family: "Modesto Condensed", "Josefin Sans", sans-serif;
font-size: 1.5rem;
text-transform: uppercase;
color: #fff;
line-height: 1;
}
.bpn-chat-card__meta { font-family: "Roboto Mono", monospace; font-size: 0.72rem; color: #9fb0c4; margin-top: 3px; }
.bpn-chat-card__brief { padding: 10px 12px; margin: 0; color: #dfe7f1; font-size: 0.92rem; line-height: 1.45; }
/* keep our briefing readable inside Foundry journal sheets */
.journal-entry-content .bpn,
.journal-sheet .bpn,
.prose-mirror .bpn { color: #eef3fa; }
+63
View File
@@ -0,0 +1,63 @@
<div class="sla-bpn-gen">
<aside class="sla-bpn-gen__rail">
<div class="sla-bpn-gen__field">
<label>Colour Band</label>
<select data-field="colourKey">
{{#each colourOptions}}<option value="{{key}}" {{#if selected}}selected{{/if}}>{{label}}</option>{{/each}}
</select>
</div>
<div class="sla-bpn-gen__field">
<label>Scenario Frame</label>
<select data-field="frameId" {{#if frameDisabled}}disabled{{/if}}>
{{#each frameOptions}}<option value="{{id}}" {{#if selected}}selected{{/if}}>{{label}}</option>{{/each}}
</select>
</div>
<div class="sla-bpn-gen__row">
<div class="sla-bpn-gen__field">
<label>Squad Size</label>
<input type="number" min="1" max="8" data-field="squadSize" value="{{state.squadSize}}">
</div>
<div class="sla-bpn-gen__field">
<label>Payment</label>
<select data-field="paymentType">
{{#each paymentOptions}}<option value="{{value}}" {{#if selected}}selected{{/if}}>{{label}}</option>{{/each}}
</select>
</div>
</div>
<button type="button" class="sla-bpn-gen__btn sla-bpn-gen__btn--primary" data-action="reroll">
<i class="fas fa-dice"></i> Re-roll (new seed)
</button>
<div class="sla-bpn-gen__field">
<label>Seed</label>
<input type="text" data-field="seed" value="{{state.seed}}" spellcheck="false">
<div class="sla-bpn-gen__row" style="margin-top:6px;">
<button type="button" class="sla-bpn-gen__btn" data-action="applySeed">Apply</button>
<button type="button" class="sla-bpn-gen__btn" data-action="copySeed">Copy</button>
</div>
</div>
<div class="sla-bpn-gen__divider"></div>
<button type="button" class="sla-bpn-gen__btn sla-bpn-gen__btn--go" data-action="create">
<i class="fas fa-file-contract"></i> Create Journal
</button>
<button type="button" class="sla-bpn-gen__btn" data-action="chat">
<i class="fas fa-comment"></i> Post to Chat
</button>
<p class="sla-bpn-gen__hint">Creates one journal entry with a player-facing <b>Mission Brief</b> page and a GM-only <b>Dossier</b> page.</p>
</aside>
<main class="sla-bpn-gen__stage">
<div class="sla-bpn-gen__viewtoggle">
{{#each viewButtons}}
<button type="button" data-action="view" data-view="{{view}}" class="{{#if active}}is-active{{/if}}">{{label}}</button>
{{/each}}
</div>
<div class="sla-bpn-gen__preview bpn">{{{previewHTML}}}</div>
</main>
</div>