3 Commits
Author SHA1 Message Date
tevansandClaude Opus 4.8 1d8c9a0610 v0.4.1 — fix scene-control button on Foundry v14 (tokens group + onChange)
The toolbar button was registered on the notes group with onClick, which v14
ignores, so no entry point appeared. Now mirrors the Zero Engine system's
working pattern: token group, visible+button, onChange (v14) / array (v13).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:32:39 +01:00
tevansandClaude Opus 4.8 cd09c043b2 v0.4.0 — generate a package: NPC character sheets + item cards with art
Create now builds a journal folder containing the player + GM documents, one
character-sheet entry per NPC (B&W ident portrait, description, significance)
and one card entry per OTEM item (line-art icon + description).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 18:26:19 +01:00
tevansandClaude Opus 4.8 1271df8cd7 v0.3.0 — cast/OTEM/locations, separate player document, pay up to 2000
Re-vendors the expanded engine. Generator gains a Field Dossier view; Create
now writes a separate player document (Mission Brief + Field Dossier pages) and
a linked GM-only document. Pay scale raised to ~2000c.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 16:33:39 +01:00
10 changed files with 751 additions and 70 deletions
+17 -5
View File
@@ -1,12 +1,24 @@
# 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:
creates a **folder ("package")** containing several separate journal entries:
- **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.
- one **character-sheet entry per NPC** — a black-and-white "ident" portrait,
who they are, their disposition, and why they matter (player handout).
- one **card entry per OTEM item** — a line-art icon and a description.
plus the two core documents:
- **`… [Player]`** (ownership *Observer*) — a player document with two pages:
- **Mission Brief** — the official corporate dispatch.
- **Field Dossier** — a player handout: the cast (NPCs), OTEM (items &
equipment), and key locations with a schematic site map.
- **`… [GM]`** (ownership *None* for players) — the GM document: the twist, the
withheld fact, the true threat read, levers, dice state, and the GM-only
secrets for every NPC / item / location, linked back to the player document.
The GM picks the operative **SCL level** and dials the **credits** and **SCL
increase** rewards on sliders (pay scales up to ~2000c on standard work).
Styled in the SLA house look with bundled local fonts and **procedural artwork**
generated per briefing (flat, non-photorealistic). NPC/monster generation is a
+1 -1
View File
@@ -2,7 +2,7 @@
"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.2.0",
"version": "0.4.1",
"compatibility": {
"minimum": "13",
"verified": "14.361",
+50 -27
View File
@@ -12,16 +12,19 @@
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";
import { briefHTML, fieldDossierHTML, dossierHTML, npcCardHTML, itemCardHTML } from "../vendor/render/bpn-render.js";
import { crestDataURI, npcPortraitDataURI, itemIconDataURI } from "../vendor/art/bpn-art.js";
const MODULE_ID = "sla-blueprint-news";
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
const ART = { artMode: "img" };
const previewHTMLFor = (bpn, view) =>
view === "gm" ? dossierHTML(bpn, { artMode: "img" })
: view === "both" ? briefHTML(bpn, { artMode: "img" }) + dossierHTML(bpn, { artMode: "img" })
: briefHTML(bpn, { artMode: "img" });
const previewHTMLFor = (bpn, view) => {
if (view === "field") return fieldDossierHTML(bpn, ART);
if (view === "gm") return dossierHTML(bpn, ART);
if (view === "both") return briefHTML(bpn, ART) + fieldDossierHTML(bpn, ART) + dossierHTML(bpn, ART);
return briefHTML(bpn, ART);
};
const cbsReadoutFor = (r) =>
`${r.cbsBasisValue}c ${r.cbsBasis === "op" ? "per Op" : "per Squad"}${r.cbsOpenEnded ? "+" : ""} · gross ${r.grossContractValue}c · ${r.takeHomeEach}`;
@@ -99,8 +102,9 @@ export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2)
const viewButtons = [
{ view: "player", label: "Player Brief" },
{ view: "field", label: "Field Dossier" },
{ view: "gm", label: "GM Dossier" },
{ view: "both", label: "Both" }
{ view: "both", label: "All" }
].map((v) => ({ ...v, active: v.view === this.state.view }));
const r = bpn.reward;
@@ -167,32 +171,51 @@ export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2)
const bpn = this._bpn();
const O = CONST.DOCUMENT_OWNERSHIP_LEVELS;
const HTML = CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
const accent = bpn.colour.accent;
const wrap = (html) => `<div class="bpn">${html}</div>`;
const base = `${bpn.colour.label} BPN — ${bpn.shortTitle}`;
const flags = { [MODULE_ID]: { seed: bpn.seed, colourKey: bpn.colourKey, frameId: bpn.gm.frameId, sclLevel: bpn.sclLevel } };
const page = (name, content, owner) => ({ name, type: "text", title: { show: false, level: 1 }, ownership: { default: owner }, text: { format: HTML, content } });
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, sclLevel: bpn.sclLevel } },
// A folder is the "package" that holds the whole briefing's documents.
const folder = await Folder.create({ name: base, type: "JournalEntry", color: accent });
const fid = folder.id;
// Separate player document: Mission Brief + Field Dossier handout.
const playerEntry = await JournalEntry.create({
name: `${base} [Player]`, folder: fid, ownership: { default: O.OBSERVER }, flags,
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" })) }
}
page("Mission Brief", wrap(briefHTML(bpn, ART)), O.OBSERVER),
page("Field Dossier", wrap(fieldDossierHTML(bpn, ART)), O.OBSERVER)
]
});
ui.notifications.info(`Created BPN journal "${entry.name}" (player Brief + GM Dossier page).`);
entry.sheet?.render(true);
// Separate GM-only document, linked back to the player document.
await JournalEntry.create({
name: `${base} [GM]`, folder: fid, ownership: { default: O.NONE }, flags,
pages: [page("GM Dossier", wrap(dossierHTML(bpn, ART)) + `<p class="bpn-rolls">Player document: @UUID[${playerEntry.uuid}]{${base} [Player]}</p>`, O.NONE)]
});
// One character-sheet document per NPC (player handout, portrait + sheet).
const npcEntries = bpn.cast.map((npc) => ({
name: npc.name, folder: fid,
ownership: { default: O.OBSERVER },
flags: { [MODULE_ID]: { kind: "npc", seed: npc.seed, bpn: bpn.seed } },
pages: [page("Character Sheet", wrap(npcCardHTML(npc, { artMode: "img", accent })), O.OBSERVER)]
}));
// One card document per item (player handout, icon + description).
const itemEntries = bpn.otem.map((item) => ({
name: item.name, folder: fid,
ownership: { default: O.OBSERVER },
flags: { [MODULE_ID]: { kind: "otem", seed: item.seed, bpn: bpn.seed } },
pages: [page(item.name, wrap(itemCardHTML(item, { artMode: "img", accent })), O.OBSERVER)]
}));
await JournalEntry.createDocuments([...npcEntries, ...itemEntries]);
ui.notifications.info(`Created package "${base}": player + GM documents, ${bpn.cast.length} NPC sheets and ${bpn.otem.length} item cards in a folder.`);
playerEntry.sheet?.render(true);
}
static async #onChat() {
+18 -13
View File
@@ -33,27 +33,32 @@ Hooks.on("renderJournalDirectory", (_app, html) => {
header.appendChild(btn);
});
/* Notes-layer toolbar button as a second entry point (v13/v14 control shape). */
/* Token-group toolbar button (GM only). v14 controls is a keyed object and
tools fire via onChange; v13 is an array. */
Hooks.on("getSceneControlButtons", (controls) => {
if (!game.user?.isGM) return;
const open = () => BlueprintNewsApp.open();
const tool = {
name: "slaBpnOpen",
title: game.i18n.localize("SLABPN.ToolbarOpen"),
icon: "fas fa-file-contract",
visible: true,
button: true,
onClick: () => BlueprintNewsApp.open()
toggle: false,
onChange: open,
onClick: 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);
if (controls && !Array.isArray(controls)) {
const grp = controls.tokens ?? controls.token ?? null;
if (grp) {
grp.tools ??= {};
grp.tools[tool.name] = tool;
}
return;
}
const grp = controls.find((c) => c.name === "token" || c.name === "tokens");
if (grp) {
if (Array.isArray(grp.tools)) grp.tools.push(tool);
else { grp.tools ??= {}; grp.tools[tool.name] = tool; }
}
});
+168
View File
@@ -331,6 +331,165 @@ export function portraitSVG(bpn, { size = 120 } = {}) {
</svg>`;
}
/* ------------------------------------------------------------------------ */
/* NPC PORTRAIT — a simple black-and-white "ident" mugshot, procedural and */
/* deterministic per NPC. Stencil ink line-art: head, hair/hood/helmet, eyes */
/* or visor, mouth or breather, an accessory, light hatch shading. Greyscale */
/* only; the colour band shows just as a thin tag bar. */
/* ------------------------------------------------------------------------ */
export function npcPortraitSVG(npc, { size = 168, accent = "#9fb0c4" } = {}) {
const seed = npc?.seed ?? npc?.name ?? "npc";
const uid = String(seed).replace(/[^A-Za-z0-9]/g, "") + "n";
const rng = rngFor(seed, "face");
const ink = "#e9eef4";
const dark = "#0d1117";
const skin = "#1b212b";
const stroke = (w = 2) => `fill="none" stroke="${ink}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"`;
const choose = (arr) => arr[Math.floor(rng() * arr.length)];
const rx = choose([18, 20, 22]);
const ry = choose([25, 28, 32]);
const top = choose(["hairShort", "hairCrop", "bald", "hood", "helmet", "cap"]);
const eyes = choose(["plain", "visor", "goggles", "augmetic"]);
const mouth = choose(["neutral", "grim", "breather", "smirk"]);
const extra = choose(["none", "earpiece", "scar", "collar"]);
const cx = 84, cy = 78; // head centre in a 168x190 space
const head = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="${skin}" stroke="${ink}" stroke-width="2"/>`;
const jaw = `<path d="M${cx - rx + 2} ${cy + ry - 8} Q${cx} ${cy + ry + 6} ${cx + rx - 2} ${cy + ry - 8}" ${stroke(1.5)}/>`;
let hair = "";
if (top === "hairShort") hair = `<path d="M${cx - rx - 2} ${cy - 6} Q${cx} ${cy - ry - 12} ${cx + rx + 2} ${cy - 6} Q${cx + rx - 6} ${cy - ry + 4} ${cx} ${cy - ry + 2} Q${cx - rx + 6} ${cy - ry + 4} ${cx - rx - 2} ${cy - 6} Z" fill="${ink}" opacity="0.9"/>`;
else if (top === "hairCrop") hair = `<path d="M${cx - rx} ${cy - ry + 8} Q${cx} ${cy - ry - 4} ${cx + rx} ${cy - ry + 8}" ${stroke(5)}/>`;
else if (top === "bald") hair = `<path d="M${cx - 8} ${cy - ry + 4} Q${cx} ${cy - ry - 2} ${cx + 8} ${cy - ry + 4}" ${stroke(1)} opacity="0.5"/>`;
else if (top === "hood") hair = `<path d="M${cx - rx - 10} ${cy + 8} Q${cx - rx - 12} ${cy - ry - 18} ${cx} ${cy - ry - 20} Q${cx + rx + 12} ${cy - ry - 18} ${cx + rx + 10} ${cy + 8}" ${stroke(2.5)}/>`;
else if (top === "helmet") hair = `<path d="M${cx - rx - 3} ${cy - 2} Q${cx} ${cy - ry - 14} ${cx + rx + 3} ${cy - 2}" ${stroke(3)}/><line x1="${cx}" y1="${cy - ry - 10}" x2="${cx}" y2="${cy - 4}" ${stroke(1.5)}/>`;
else if (top === "cap") hair = `<path d="M${cx - rx - 6} ${cy - ry + 10} H${cx + rx + 2} Q${cx} ${cy - ry - 8} ${cx - rx - 6} ${cy - ry + 10} Z" fill="${ink}" opacity="0.85"/>`;
const ey = cy - 4;
let eyeMarks = "";
if (eyes === "plain") eyeMarks = `<line x1="${cx - 13}" y1="${ey}" x2="${cx - 6}" y2="${ey}" ${stroke(2)}/><line x1="${cx + 6}" y1="${ey}" x2="${cx + 13}" y2="${ey}" ${stroke(2)}/><line x1="${cx - 14}" y1="${ey - 6}" x2="${cx - 5}" y2="${ey - 7}" ${stroke(1.2)}/><line x1="${cx + 5}" y1="${ey - 7}" x2="${cx + 14}" y2="${ey - 6}" ${stroke(1.2)}/>`;
else if (eyes === "visor") eyeMarks = `<rect x="${cx - 16}" y="${ey - 4}" width="32" height="8" rx="4" fill="${dark}" stroke="${ink}" stroke-width="2"/><line x1="${cx - 12}" y1="${ey}" x2="${cx + 12}" y2="${ey}" stroke="${ink}" stroke-width="1" opacity="0.5"/>`;
else if (eyes === "goggles") eyeMarks = `<circle cx="${cx - 9}" cy="${ey}" r="5.5" ${stroke(2)}/><circle cx="${cx + 9}" cy="${ey}" r="5.5" ${stroke(2)}/><line x1="${cx - 3.5}" y1="${ey}" x2="${cx + 3.5}" y2="${ey}" ${stroke(2)}/>`;
else eyeMarks = `<line x1="${cx - 13}" y1="${ey}" x2="${cx - 6}" y2="${ey}" ${stroke(2)}/><rect x="${cx + 5}" y="${ey - 4}" width="10" height="8" rx="1.5" fill="${dark}" stroke="${ink}" stroke-width="2"/><line x1="${cx + 7}" y1="${ey}" x2="${cx + 13}" y2="${ey}" stroke="${ink}" stroke-width="1"/>`;
const nose = `<path d="M${cx} ${ey + 4} L${cx - 3} ${cy + 8} L${cx + 2} ${cy + 8}" ${stroke(1.4)}/>`;
const my = cy + 17;
let mouthMark = "";
if (mouth === "neutral") mouthMark = `<line x1="${cx - 7}" y1="${my}" x2="${cx + 7}" y2="${my}" ${stroke(1.8)}/>`;
else if (mouth === "grim") mouthMark = `<path d="M${cx - 8} ${my - 1} Q${cx} ${my + 3} ${cx + 8} ${my - 1}" ${stroke(1.8)}/>`;
else if (mouth === "smirk") mouthMark = `<path d="M${cx - 7} ${my + 1} Q${cx} ${my - 1} ${cx + 8} ${my - 3}" ${stroke(1.8)}/>`;
else mouthMark = `<rect x="${cx - 12}" y="${my - 7}" width="24" height="15" rx="4" fill="${dark}" stroke="${ink}" stroke-width="2"/><line x1="${cx - 12}" y1="${my}" x2="${cx - 22}" y2="${my - 4}" ${stroke(1.5)}/><line x1="${cx + 12}" y1="${my}" x2="${cx + 22}" y2="${my - 4}" ${stroke(1.5)}/><circle cx="${cx}" cy="${my}" r="2" fill="${ink}"/>`;
let extraMark = "";
if (extra === "earpiece") extraMark = `<circle cx="${cx + rx - 1}" cy="${ey + 4}" r="3" ${stroke(1.5)}/><line x1="${cx + rx + 1}" y1="${ey + 6}" x2="${cx + rx + 6}" y2="${ey + 14}" ${stroke(1.5)}/>`;
else if (extra === "scar") extraMark = `<line x1="${cx - 12}" y1="${cy - 14}" x2="${cx - 4}" y2="${cy + 6}" ${stroke(1.5)}/>`;
else if (extra === "collar") extraMark = `<path d="M${cx - 22} 168 L${cx - 8} ${cy + ry + 6} M${cx + 22} 168 L${cx + 8} ${cy + ry + 6}" ${stroke(2)}/>`;
// light hatch shading down the right cheek
const hatch = Array.from({ length: 4 }, (_, i) =>
`<line x1="${cx + 6 + i * 3}" y1="${cy - 6 + i * 2}" x2="${cx + 10 + i * 3}" y2="${cy + 10 + i * 2}" stroke="${ink}" stroke-width="1" opacity="0.18"/>`).join("");
const shoulders = `<path d="M22 190 V164 C22 142 48 130 84 130 C120 130 146 142 146 164 V190 Z" fill="${skin}" stroke="${ink}" stroke-width="2"/>`;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 168 190" width="${size}" height="${Math.round(size * 190 / 168)}" role="img" aria-label="${npc?.name ?? "ident"} portrait">
<rect x="2" y="2" width="164" height="186" rx="10" fill="${dark}" stroke="${rgba(accent, 0.45)}" stroke-width="2"/>
<rect x="2" y="2" width="164" height="16" rx="10" fill="${rgba(accent, 0.18)}"/>
<text x="10" y="14" font-family="Roboto Mono, monospace" font-size="9" letter-spacing="2" fill="${rgba(accent, 0.85)}">IDENT</text>
${shoulders}
${extra === "collar" ? extraMark : ""}
${head}${jaw}${hatch}
${hair}
${eyeMarks}${nose}${mouthMark}
${extra !== "collar" ? extraMark : ""}
<line x1="10" y1="178" x2="158" y2="178" stroke="${rgba(accent, 0.3)}" stroke-width="1"/>
</svg>`;
}
/* ------------------------------------------------------------------------ */
/* ITEM ICON — simple black-and-white line-art keyed to the OTEM category. */
/* ------------------------------------------------------------------------ */
export function itemIconSVG(item, { size = 120, accent = "#9fb0c4" } = {}) {
const ink = "#e9eef4";
const dark = "#0d1117";
const s = (w = 2.4) => `fill="none" stroke="${ink}" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"`;
const cat = item?.category ?? "slate";
const glyphs = {
slate: `<rect x="34" y="24" width="32" height="52" rx="4" ${s()}/><line x1="40" y1="36" x2="60" y2="36" ${s(1.6)}/><line x1="40" y1="44" x2="60" y2="44" ${s(1.6)}/><line x1="40" y1="52" x2="54" y2="52" ${s(1.6)}/><circle cx="50" cy="68" r="2.2" fill="${ink}"/>`,
canister: `<rect x="38" y="26" width="24" height="48" rx="6" ${s()}/><ellipse cx="50" cy="26" rx="12" ry="4" ${s(1.6)}/><line x1="38" y1="46" x2="62" y2="46" ${s(1.6)}/><rect x="44" y="18" width="12" height="8" rx="2" ${s(1.6)}/>`,
weapon: `<path d="M28 44 H64 V54 H50 L46 64 H40 L42 54 H28 Z" ${s()}/><line x1="34" y1="44" x2="34" y2="38" ${s(1.6)}/>`,
chip: `<rect x="34" y="34" width="32" height="32" rx="3" ${s()}/><rect x="42" y="42" width="16" height="16" rx="2" ${s(1.6)}/><line x1="38" y1="30" x2="38" y2="34" ${s(1.4)}/><line x1="50" y1="30" x2="50" y2="34" ${s(1.4)}/><line x1="62" y1="30" x2="62" y2="34" ${s(1.4)}/><line x1="38" y1="66" x2="38" y2="70" ${s(1.4)}/><line x1="62" y1="66" x2="62" y2="70" ${s(1.4)}/>`,
crate: `<rect x="28" y="32" width="44" height="36" rx="3" ${s()}/><line x1="28" y1="50" x2="72" y2="50" ${s(1.6)}/><line x1="50" y1="32" x2="50" y2="68" ${s(1.6)}/><circle cx="34" cy="38" r="1.8" fill="${ink}"/><circle cx="66" cy="38" r="1.8" fill="${ink}"/>`,
bug: `<ellipse cx="50" cy="52" rx="10" ry="14" ${s()}/><circle cx="50" cy="36" r="5" ${s(1.6)}/><line x1="47" y1="32" x2="44" y2="26" ${s(1.4)}/><line x1="53" y1="32" x2="56" y2="26" ${s(1.4)}/><line x1="40" y1="46" x2="30" y2="42" ${s(1.4)}/><line x1="40" y1="56" x2="30" y2="58" ${s(1.4)}/><line x1="60" y1="46" x2="70" y2="42" ${s(1.4)}/><line x1="60" y1="56" x2="70" y2="58" ${s(1.4)}/>`,
component: `<circle cx="50" cy="50" r="14" ${s()}/><circle cx="50" cy="50" r="5" ${s(1.6)}/><g stroke="${ink}" stroke-width="2.4" stroke-linecap="round">${Array.from({ length: 8 }, (_, i) => { const a = i / 8 * Math.PI * 2; return `<line x1="${50 + Math.cos(a) * 14}" y1="${50 + Math.sin(a) * 14}" x2="${50 + Math.cos(a) * 19}" y2="${50 + Math.sin(a) * 19}"/>`; }).join("")}</g>`,
relay: `<line x1="50" y1="30" x2="50" y2="70" ${s()}/><rect x="44" y="68" width="12" height="6" ${s(1.6)}/><path d="M40 34 Q50 24 60 34" ${s(1.6)}/><path d="M36 30 Q50 16 64 30" ${s(1.4)}/><circle cx="50" cy="30" r="2.5" fill="${ink}"/>`,
kit: `<rect x="30" y="36" width="40" height="30" rx="4" ${s()}/><line x1="42" y1="36" x2="42" y2="30" ${s(1.6)}/><line x1="58" y1="36" x2="58" y2="30" ${s(1.6)}/><line x1="50" y1="44" x2="50" y2="58" ${s(2)}/><line x1="43" y1="51" x2="57" y2="51" ${s(2)}/>`,
drone: `<rect x="44" y="44" width="12" height="12" rx="2" ${s()}/><line x1="44" y1="44" x2="32" y2="32" ${s(1.6)}/><line x1="56" y1="44" x2="68" y2="32" ${s(1.6)}/><line x1="44" y1="56" x2="32" y2="68" ${s(1.6)}/><line x1="56" y1="56" x2="68" y2="68" ${s(1.6)}/><circle cx="30" cy="30" r="4" ${s(1.6)}/><circle cx="70" cy="30" r="4" ${s(1.6)}/><circle cx="30" cy="70" r="4" ${s(1.6)}/><circle cx="70" cy="70" r="4" ${s(1.6)}/>`,
key: `<circle cx="38" cy="50" r="10" ${s()}/><line x1="48" y1="50" x2="72" y2="50" ${s()}/><line x1="66" y1="50" x2="66" y2="58" ${s(1.8)}/><line x1="72" y1="50" x2="72" y2="60" ${s(1.8)}/>`,
card: `<rect x="28" y="36" width="44" height="30" rx="3" ${s()}/><rect x="33" y="42" width="12" height="14" rx="1.5" ${s(1.6)}/><line x1="50" y1="44" x2="66" y2="44" ${s(1.4)}/><line x1="50" y1="50" x2="66" y2="50" ${s(1.4)}/><line x1="50" y1="56" x2="60" y2="56" ${s(1.4)}/>`
};
const inner = glyphs[cat] ?? glyphs.slate;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="${size}" height="${size}" role="img" aria-label="${item?.name ?? "item"} icon">
<rect x="2" y="2" width="96" height="96" rx="10" fill="${dark}" stroke="${rgba(accent, 0.45)}" stroke-width="2"/>
${inner}
</svg>`;
}
/* ------------------------------------------------------------------------ */
/* SITE MAP — a schematic, non-photorealistic sector diagram for the Field */
/* Dossier. Numbered nodes (one per location) wired along a route over a */
/* faint grid, with an entry marker. Labels live in the dossier list. */
/* ------------------------------------------------------------------------ */
export function siteMapSVG(bpn, { width = 640, height = 320 } = {}) {
const accent = bpn?.colour?.accent ?? "#dce8f9";
const seed = bpn?.seed ?? "BPN";
const uid = String(seed).replace(/[^A-Za-z0-9]/g, "") + "m";
const rng = rngFor(seed, "map");
const dark = "#0b1018";
const sites = Array.isArray(bpn?.sites) && bpn.sites.length ? bpn.sites : [{}, {}, {}];
const n = sites.length;
const W = width, H = height;
const mx = 70, my = 64;
// node positions: spread along x, seeded y within the band
const nodes = sites.map((_, i) => ({
x: mx + (n === 1 ? (W - 2 * mx) / 2 : (i * (W - 2 * mx)) / (n - 1)),
y: my + rfloat(rng, 0, H - 2 * my)
}));
// route polyline through the nodes
const route = nodes.map((p, i) => `${i ? "L" : "M"}${r2(p.x)} ${r2(p.y)}`).join(" ");
const nodeMarks = nodes.map((p, i) => `
<g transform="translate(${r2(p.x)} ${r2(p.y)})">
<circle r="16" fill="${dark}" stroke="${accent}" stroke-width="2.5"/>
<circle r="22" fill="none" stroke="${rgba(accent, 0.25)}" stroke-width="1"/>
<text x="0" y="5" text-anchor="middle" font-family="Roboto Mono, monospace" font-size="15" font-weight="700" fill="${mix(accent, "#ffffff", 0.3)}">${i + 1}</text>
</g>`).join("");
// entry chevron at the first node
const e = nodes[0];
const entry = `<g transform="translate(${r2(e.x - 30)} ${r2(e.y)})"><path d="M-10 -8 L2 0 L-10 8" fill="none" stroke="${accent}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/><text x="-12" y="-12" text-anchor="end" font-family="Roboto Mono, monospace" font-size="10" letter-spacing="1.5" fill="${rgba(accent, 0.7)}">ENTRY</text></g>`;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" role="img" aria-label="sector schematic">
<defs>
<linearGradient id="mg-${uid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${mix(dark, accent, 0.10)}"/><stop offset="1" stop-color="#05080d"/></linearGradient>
<pattern id="grid-${uid}" width="28" height="28" patternUnits="userSpaceOnUse"><path d="M28 0 H0 V28" fill="none" stroke="${rgba(accent, 0.10)}" stroke-width="1"/></pattern>
</defs>
<rect width="${W}" height="${H}" rx="10" fill="url(#mg-${uid})"/>
<rect width="${W}" height="${H}" rx="10" fill="url(#grid-${uid})"/>
<rect x="1" y="1" width="${W - 2}" height="${H - 2}" rx="10" fill="none" stroke="${rgba(accent, 0.4)}" stroke-width="1.5"/>
<text x="18" y="28" font-family="Roboto Mono, monospace" font-size="11" letter-spacing="2" fill="${rgba(accent, 0.75)}">SECTOR SCHEMATIC // ${String(bpn?.bpnId ?? "")}</text>
<path d="${route}" fill="none" stroke="${rgba(accent, 0.55)}" stroke-width="2" stroke-dasharray="2 6" stroke-linecap="round"/>
${entry}
${nodeMarks}
</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) {
@@ -342,3 +501,12 @@ export function bannerDataURI(bpn, opts) {
export function portraitDataURI(bpn, opts) {
return `data:image/svg+xml;utf8,${encodeURIComponent(portraitSVG(bpn, opts))}`;
}
export function siteMapDataURI(bpn, opts) {
return `data:image/svg+xml;utf8,${encodeURIComponent(siteMapSVG(bpn, opts))}`;
}
export function npcPortraitDataURI(npc, opts) {
return `data:image/svg+xml;utf8,${encodeURIComponent(npcPortraitSVG(npc, opts))}`;
}
export function itemIconDataURI(item, opts) {
return `data:image/svg+xml;utf8,${encodeURIComponent(itemIconSVG(item, opts))}`;
}
+113
View File
@@ -11,6 +11,7 @@
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";
import { DETAIL } from "./data/detail.js";
/* ---- seeded randomness -------------------------------------------------- */
@@ -248,6 +249,102 @@ function buildMissionBrief(b) {
].join(" ");
}
/* ---- expanded detail: cast / OTEM / sites ------------------------------- */
/** Pick n distinct entries from arr (deterministic via rng). */
function sample(rng, arr, n) {
const pool = [...arr];
const out = [];
for (let i = 0; i < n && pool.length; i++) {
out.push(pool.splice(Math.floor(rng() * pool.length), 1)[0]);
}
return out;
}
function ucFirst(s = "") {
const v = String(s);
return v.charAt(0).toUpperCase() + v.slice(1);
}
/** Classify an OTEM name into an icon category for the line-art item icon. */
function otemCategory(name = "") {
const n = String(name).toLowerCase();
if (n.includes("slate") || n.includes("data")) return "slate";
if (n.includes("canister") || n.includes("sample")) return "canister";
if (n.includes("weapon") || n.includes("cache")) return "weapon";
if (n.includes("chip") || n.includes("credential")) return "chip";
if (n.includes("crate") || n.includes("evidence")) return "crate";
if (n.includes("bug") || n.includes("surveillance")) return "bug";
if (n.includes("component") || n.includes("prototype")) return "component";
if (n.includes("relay") || n.includes("comms")) return "relay";
if (n.includes("med") || n.includes("kit")) return "kit";
if (n.includes("drone") || n.includes("camera")) return "drone";
if (n.includes("key")) return "key";
if (n.includes("card") || n.includes("id")) return "card";
return "slate";
}
/** Build the cast, OTEM and locations. Each NPC is a small character sheet
* (description, significance, appearance + a stable seed for its portrait);
* each item carries a description + category for its line-art icon. Player
* facing fields plus a GM-only `gm` secret. */
function buildDetail(rng, ctx) {
const makeNpc = (i, base) => {
const build = pick(rng, DETAIL.npcBuilds);
const feature = pick(rng, DETAIL.npcFeatures);
const habit = pick(rng, DETAIL.npcHabits);
const role = base.role ?? pick(rng, DETAIL.npcRoles);
return {
seed: `${ctx.seed}::npc::${i}`,
name: base.name,
role,
disposition: base.disposition ?? pick(rng, DETAIL.npcDispositions),
appearance: { build, feature, habit },
description: `${ucFirst(build)} ${role.toLowerCase()} with ${feature}; ${habit}.`,
significance: base.significance ?? pick(rng, DETAIL.npcSignificance),
player: base.player ?? pick(rng, DETAIL.npcPlayerNotes),
gm: base.gm ?? pick(rng, DETAIL.npcSecrets)
};
};
const cast = [
makeNpc(0, {
name: ctx.contactName,
role: ctx.contactRole,
disposition: "Cooperative",
significance: "The squad's authorised contact on the ground.",
player: `On-site contact, holding at ${lcFirst(trimEnd(ctx.contactLocation))}.`
}),
makeNpc(1, {
name: ctx.threatLead || "Reported hostile",
role: "Designated threat",
disposition: "Openly hostile",
significance: "The hostile command flagged as the squad's primary problem.",
player: ctx.threatCount || "Hostile lead flagged by command.",
gm: ctx.oppositionAttitude ? `Really: ${lcFirst(ctx.oppositionAttitude)}` : pick(rng, DETAIL.npcSecrets)
}),
makeNpc(2, { name: `${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}` }),
makeNpc(3, { name: `${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}` })
];
const otem = sample(rng, DETAIL.otemNames, 3).map((name, i) => ({
seed: `${ctx.seed}::otem::${i}`,
name,
category: otemCategory(name),
description: pick(rng, DETAIL.otemDescriptions),
player: pick(rng, DETAIL.otemPlayerNotes),
gm: pick(rng, DETAIL.otemSecrets)
}));
const sites = sample(rng, DETAIL.siteNames, 3).map((name) => ({
name: ucFirst(name),
player: pick(rng, DETAIL.sitePlayerNotes),
gm: pick(rng, DETAIL.siteSecrets)
}));
return { cast, otem, sites };
}
/* ---- SCL level ---------------------------------------------------------- */
/** Operative Security Clearance Level: 10 (lowest) … 1 (highest). */
@@ -384,5 +481,21 @@ export function generateBPN(opts = {}) {
};
bpn.missionBrief = take(ov.missionBrief, buildMissionBrief(bpn));
// Expanded detail for the separate player Field Dossier (player lines) and
// the GM Dossier (secrets). Built last so it can read the resolved cast.
const detail = buildDetail(rng, {
seed,
contactName: bpn.contact.name,
contactRole: bpn.contact.role,
contactLocation: bpn.contact.location,
threatLead: bpn.gm.threatLead,
threatCount: bpn.gm.threatCountLabel,
oppositionAttitude: bpn.gm.oppositionAttitude
});
bpn.cast = detail.cast;
bpn.otem = detail.otem;
bpn.sites = detail.sites;
return bpn;
}
+23 -20
View File
@@ -1,6 +1,9 @@
/* 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. */
classification, pay/SCL/media profile, and a render accent.
CBS pay ranges scale so the reward slider reaches up to ~2000c on standard
emergency work (Red), with Black/Platinum running higher. */
export const COLOURS = [
{
@@ -11,8 +14,8 @@ export const COLOURS = [
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 },
cbsPerOpRange: { min: 50, max: 300 },
cbsPerSquadRange: { min: 100, max: 600 },
mediaCoverage: "Rare / None",
accent: "#57b3ff"
},
@@ -24,8 +27,8 @@ export const COLOURS = [
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 },
cbsPerOpRange: { min: 100, max: 400 },
cbsPerSquadRange: { min: 200, max: 900 },
mediaCoverage: "Station Analysis / None",
accent: "#ffdb6e"
},
@@ -37,8 +40,8 @@ export const COLOURS = [
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 },
cbsPerOpRange: { min: 150, max: 500 },
cbsPerSquadRange: { min: 300, max: 1100 },
mediaCoverage: "Station Analysis / Third Eye",
accent: "#78d48f"
},
@@ -50,8 +53,8 @@ export const COLOURS = [
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 },
cbsPerOpRange: { min: 200, max: 600 },
cbsPerSquadRange: { min: 400, max: 1300 },
mediaCoverage: "Third Eye / Station Analysis",
accent: "#dce8f9"
},
@@ -63,8 +66,8 @@ export const COLOURS = [
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 },
cbsPerOpRange: { min: 250, max: 700 },
cbsPerSquadRange: { min: 500, max: 1500 },
mediaCoverage: "Station Analysis",
accent: "#9da6b3"
},
@@ -76,8 +79,8 @@ export const COLOURS = [
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 },
cbsPerOpRange: { min: 300, max: 850 },
cbsPerSquadRange: { min: 600, max: 1700 },
mediaCoverage: "Third Eye / GoreZone",
accent: "#d7dee8"
},
@@ -89,8 +92,8 @@ export const COLOURS = [
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 },
cbsPerOpRange: { min: 350, max: 900 },
cbsPerSquadRange: { min: 700, max: 1800 },
mediaCoverage: "Third Eye / Contract Circuit",
accent: "#41d0a3"
},
@@ -102,8 +105,8 @@ export const COLOURS = [
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 },
cbsPerOpRange: { min: 400, max: 1000 },
cbsPerSquadRange: { min: 800, max: 2000 },
mediaCoverage: "Third Eye / GoreZone",
accent: "#ff6f6f"
},
@@ -115,8 +118,8 @@ export const COLOURS = [
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 },
cbsPerOpRange: { min: 600, max: 1500 },
cbsPerSquadRange: { min: 1200, max: 3000 },
mediaCoverage: "None (classified)",
accent: "#9a7cff"
},
@@ -129,7 +132,7 @@ export const COLOURS = [
sclReq: "5 - 1",
sclRange: { min: 0.3, typical: 0.5, max: 1.0 },
cbsPerOpRange: { min: 1000, max: null },
cbsPerSquadRange: { min: 1500, max: null },
cbsPerSquadRange: { min: 2000, max: null },
mediaCoverage: "Classified / Special",
accent: "#f5f0d7"
}
+128
View File
@@ -0,0 +1,128 @@
/* Vocabulary for the expanded briefing detail — cast (NPCs), OTEM (items &
equipment), and locations. Pure data; bpn-core assembles entries from these
with the briefing seed, so every briefing gets coherent, varied detail with
a player-facing line and a GM-only secret.
"OTEM" is used here for mission-relevant items, gear, and objects of note. */
export const DETAIL = {
/* ---- cast / NPCs ---- */
npcRoles: [
"Sector fixer", "Nervous witness", "Off-duty Shiver", "Street medic",
"Corporate liaison", "Information broker", "Frightened bystander",
"Local enforcer", "Black-market runner", "Maintenance technician",
"Embedded journalist", "Karma field tech", "Departmental clerk",
"Sponsor's handler", "Squatter elder", "Transit controller"
],
npcDispositions: [
"Cooperative", "Wary", "Openly hostile", "Opportunistic", "Terrified",
"Loyal to the issuing department", "Bitter and uncooperative",
"Eager to please the cameras", "Quietly desperate"
],
npcPlayerNotes: [
"Can point the squad to the right door if handled well.",
"Keeps demanding to know who is paying for the damage.",
"Has a name and a face the public already half-recognises.",
"Will talk, but only away from the crowd.",
"Carries a comms slate they keep glancing at.",
"Insists they 'didn't see anything' a little too quickly.",
"Offers help in exchange for a favour later.",
"Is clearly waiting for someone who has not arrived."
],
npcSecrets: [
"Is quietly feeding movement reports to the true employer.",
"Knows where the real objective is and is sitting on it.",
"Will flip to the squad's side if offered a clean way out.",
"Is marked for removal by the opposition and does not know it.",
"Has already sold the squad's arrival time to a third party.",
"Is an undercover asset of a department that is not on the BPN.",
"Plans to loot the site the moment the shooting starts.",
"Is the actual target the GM is steering the mission toward."
],
npcBuilds: [
"tall and gaunt", "short and wiry", "broad and scarred", "lean and twitchy",
"heavyset and unhurried", "compact and precise", "stooped and tired", "rangy and restless"
],
npcFeatures: [
"a battered breather mask", "a mirrored visor", "a shaved, tattooed scalp",
"a long oilcloth coat", "a crude augmetic eye", "cropped iron-grey hair",
"a jaw brace and old burns", "corporate-neat hair and a dead stare",
"a hood pulled low", "a wired earpiece they keep touching"
],
npcHabits: [
"never stops watching the exits", "talks too fast when cornered",
"keeps one hand near a concealed weapon", "quotes regulations at everyone",
"won't make eye contact", "smiles at exactly the wrong moments",
"flinches at every comms burst", "counts the squad's gear without thinking"
],
npcSignificance: [
"Holds the access the squad needs to move at all.",
"Knows the layout better than the map command supplied.",
"Can vouch for the squad — or burn them with one word.",
"Is the only working line back to the contact.",
"Controls who gets in and out of the site.",
"Has eyes on the objective right now.",
"Is the loose thread the whole job hangs on.",
"Will be the first casualty if things go loud."
],
/* ---- item descriptions ---- */
otemDescriptions: [
"Standard-issue casing, non-standard contents — somebody went to trouble over this.",
"Looks ordinary until you notice the tamper seals have been replaced twice.",
"Heavier than it should be, and warm to the touch.",
"Marked with a department code that was retired years ago.",
"Scuffed and field-worn, clearly passed through several hands.",
"Pristine, sealed, and very obviously expensive.",
"Jury-rigged from three other things that were never meant to meet.",
"Quiet now, but the indicator light says it is still doing something."
],
/* ---- OTEM (items & equipment) ---- */
otemNames: [
"Sealed data slate", "Sample canister", "Concealed weapon cache",
"Forged clearance chip", "Departmental evidence crate", "Surveillance bug cluster",
"Prototype component", "Encrypted comms relay", "Med-kit with restricted stims",
"Sponsor-branded camera drone", "Maintenance master-key", "Blood-stained ID card"
],
otemPlayerNotes: [
"Logged on the BPN manifest; command wants it back intact.",
"Spotted on the contact's last report, location uncertain.",
"Rumoured to be on-site but not officially confirmed.",
"Will read as contraband if a Shiver scans the squad.",
"Worth a quiet bonus to the right buyer.",
"Fragile — rough handling will ruin its value."
],
otemSecrets: [
"Is still broadcasting a locator only the GM's faction can hear.",
"Is a decoy; the real article left the site hours ago.",
"Is booby-trapped to wipe or detonate if opened wrong.",
"Implicates the issuing department if it ever reaches Head Office.",
"Is worth far more than the BPN pay and everyone on-site knows it.",
"Matches evidence from a case SLA officially closed."
],
/* ---- locations / sites ---- */
siteNames: [
"the loading dock", "a sub-level service gallery", "the relay/control room",
"a collapsed access stair", "the upper catwalks", "a sealed storage bay",
"the perimeter checkpoint", "a flooded maintenance shaft", "the lobby atrium",
"a back-alley cut-through", "the rooftop plant deck", "an evidence locker"
],
sitePlayerNotes: [
"Marked as the recommended entry point.",
"Command flags it as the likely chokepoint.",
"Civilian traffic runs straight through it.",
"Reported as the contact's fallback position.",
"Surveillance here is patchy at best.",
"Last place the objective was logged."
],
siteSecrets: [
"Has a second exit the opposition is using to flank.",
"Is wired to seal on a silent alarm and trap whoever is inside.",
"Hides the true objective behind a maintenance panel.",
"Is where the escalation event will first break out.",
"Conceals a body the issuing department wants nobody to find.",
"Is overlooked on the official map command handed the squad."
]
};
+95 -4
View File
@@ -12,8 +12,8 @@
*/
import {
crestSVG, bannerSVG, portraitSVG,
crestDataURI, bannerDataURI, portraitDataURI
crestSVG, bannerSVG, portraitSVG, siteMapSVG, npcPortraitSVG, itemIconSVG,
crestDataURI, bannerDataURI, portraitDataURI, siteMapDataURI, npcPortraitDataURI, itemIconDataURI
} from "../art/bpn-art.js";
function esc(value) {
@@ -120,6 +120,81 @@ export function briefHTML(bpn, { art = true, artMode = "inline" } = {}) {
</article>`;
}
/* ---------------- PLAYER FIELD DOSSIER ---------------- */
/* A separate player-facing document: the cast, OTEM and locations the squad
are cleared to know, with a schematic site map. The GM-only secrets for the
same entries live in the GM Dossier. */
/** A simple NPC character-sheet card: portrait, who they are, why they matter. */
export function npcCardHTML(npc, { artMode = "inline", accent = "#9fb0c4" } = {}) {
const art = artMode === "img"
? `<img src="${npcPortraitDataURI(npc, { size: 168, accent })}" alt="${esc(npc.name)}">`
: npcPortraitSVG(npc, { size: 168, accent });
return `<div class="bpn-npc-card">
<div class="bpn-npc-card__art">${art}</div>
<div class="bpn-npc-card__body">
<div class="bpn-npc-card__name">${esc(npc.name)}</div>
<div class="bpn-npc-card__role">${esc(npc.role)} · ${esc(npc.disposition)}</div>
<p class="bpn-npc-card__desc">${esc(npc.description)}</p>
<div class="bpn-npc-card__sig"><span class="bpn-label">Why they matter</span>${esc(npc.significance)}</div>
</div>
</div>`;
}
/** A simple item card: line-art icon, what it is, a description. */
export function itemCardHTML(item, { artMode = "inline", accent = "#9fb0c4" } = {}) {
const art = artMode === "img"
? `<img src="${itemIconDataURI(item, { size: 120, accent })}" alt="${esc(item.name)}">`
: itemIconSVG(item, { size: 120, accent });
return `<div class="bpn-item-card">
<div class="bpn-item-card__art">${art}</div>
<div class="bpn-item-card__body">
<div class="bpn-item-card__name">${esc(item.name)}</div>
<p class="bpn-item-card__desc">${esc(item.description)}</p>
<div class="bpn-item-card__note">${esc(item.player)}</div>
</div>
</div>`;
}
function siteRow(s, i) {
return `<div class="bpn-detail-item">
<div class="bpn-detail-item__name"><span class="bpn-site-no">${i + 1}</span>${esc(s.name)}</div>
<div class="bpn-detail-item__line">${esc(s.player)}</div>
</div>`;
}
export function fieldDossierHTML(bpn, { artMode = "inline" } = {}) {
const c = bpn.colour;
const map = artMode === "img"
? `<img class="bpn-sitemap__img" src="${siteMapDataURI(bpn, { width: 640, height: 320 })}" alt="sector schematic">`
: siteMapSVG(bpn, { width: 640, height: 320 });
return `<article class="bpn-field" style="--bpn-accent:${esc(c.accent)};">
<div class="bpn-doc">
<header class="bpn-field__head">
<span class="bpn-hero__band"><b>${esc(c.label)}</b> Field Dossier</span>
<h2 class="bpn-field__title">${esc(bpn.shortTitle)} — Operative Pack</h2>
<p class="bpn-field__sub">Player reference: who you'll meet, what's in play, and where. Cleared for squad eyes.</p>
</header>
<div class="bpn-body">
<section>
<h2 class="bpn-section__title">Cast on the Ground</h2>
<div class="bpn-card-grid">${bpn.cast.map((n) => npcCardHTML(n, { artMode, accent: c.accent })).join("")}</div>
</section>
<section>
<h2 class="bpn-section__title">OTEM · Items &amp; Equipment</h2>
<div class="bpn-card-grid bpn-card-grid--items">${bpn.otem.map((o) => itemCardHTML(o, { artMode, accent: c.accent })).join("")}</div>
</section>
<section>
<h2 class="bpn-section__title">Key Locations</h2>
<div class="bpn-sitemap">${map}</div>
<div class="bpn-detail-list">${bpn.sites.map(siteRow).join("")}</div>
</section>
</div>
</div>
</article>`;
}
/* ---------------- GM DOSSIER ---------------- */
export function dossierHTML(bpn, { artMode = "inline" } = {}) {
@@ -183,6 +258,15 @@ export function dossierHTML(bpn, { artMode = "inline" } = {}) {
</div>
</section>
<section>
<h2 class="bpn-section__title">Cast · OTEM · Sites — GM Notes</h2>
<div class="bpn-gmnotes">
${bpn.cast.map((n) => `<div class="bpn-gmnote"><span class="bpn-gmnote__k">${esc(n.name)} — ${esc(n.role)}</span><span class="bpn-gmnote__v">${esc(n.gm)}</span></div>`).join("")}
${bpn.otem.map((o) => `<div class="bpn-gmnote"><span class="bpn-gmnote__k">${esc(o.name)}</span><span class="bpn-gmnote__v">${esc(o.gm)}</span></div>`).join("")}
${bpn.sites.map((s, i) => `<div class="bpn-gmnote"><span class="bpn-gmnote__k">${i + 1}. ${esc(s.name)}</span><span class="bpn-gmnote__v">${esc(s.gm)}</span></div>`).join("")}
</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>
@@ -207,10 +291,17 @@ export function renderDossier(bpn, el) {
root.innerHTML = dossierHTML(bpn);
}
/* Render both surfaces into one element (used for the print / "both" view). */
export function renderField(bpn, el, opts) {
const root = typeof el === "string" ? document.querySelector(el) : el;
if (!root) return;
root.classList.add("bpn");
root.innerHTML = fieldDossierHTML(bpn, opts);
}
/* Render all three surfaces into one element (the print / "all" 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);
root.innerHTML = briefHTML(bpn, opts) + fieldDossierHTML(bpn, opts) + dossierHTML(bpn, opts);
}
+138
View File
@@ -282,6 +282,122 @@
}
.bpn-footer .bpn-value { font-family: var(--bpn-head); font-size: 0.96rem; }
/* ===================== PLAYER FIELD DOSSIER ===================== */
.bpn-field__head {
padding: 18px 22px;
border-bottom: 1px solid var(--bpn-line);
background:
linear-gradient(90deg, color-mix(in srgb, var(--bpn-accent) 18%, transparent), transparent 55%),
linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel));
}
.bpn-field__title {
font-family: var(--bpn-display);
font-weight: 700;
font-size: clamp(1.7rem, 4vw, 2.4rem);
line-height: 0.96;
letter-spacing: 0.02em;
text-transform: uppercase;
margin: 10px 0 4px;
color: #fff;
}
.bpn-field__sub { margin: 0; color: var(--bpn-muted); font-size: 0.98rem; }
.bpn-detail-list { display: grid; gap: 10px; }
.bpn-detail-item {
position: relative;
padding: 12px 14px 12px 16px;
border-radius: 10px;
background: linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel));
border: 1px solid var(--bpn-line);
}
.bpn-detail-item::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-detail-item__name {
font-family: var(--bpn-head);
font-weight: 700;
font-size: 1.06rem;
color: color-mix(in srgb, var(--bpn-accent) 24%, var(--bpn-text));
display: flex;
align-items: center;
gap: 8px;
}
.bpn-detail-item__role {
font-family: var(--bpn-mono);
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--bpn-muted);
margin: 2px 0 5px;
}
.bpn-detail-item__line { color: var(--bpn-text); font-size: 1rem; line-height: 1.5; }
.bpn-site-no {
flex: 0 0 auto;
width: 22px; height: 22px;
display: inline-grid; place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--bpn-accent) 24%, #0c1119);
border: 1px solid color-mix(in srgb, var(--bpn-accent) 55%, transparent);
font-family: var(--bpn-mono);
font-size: 0.8rem;
font-weight: 700;
}
.bpn-sitemap { margin-bottom: 12px; }
.bpn-sitemap svg, .bpn-sitemap__img { width: 100%; height: auto; display: block; border-radius: 10px; }
/* NPC + item cards */
.bpn-card-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
}
.bpn-card-grid--items { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
.bpn-npc-card, .bpn-item-card {
display: grid;
gap: 12px;
padding: 12px;
border-radius: 10px;
background: linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel));
border: 1px solid var(--bpn-line);
}
.bpn-npc-card { grid-template-columns: 96px 1fr; }
.bpn-item-card { grid-template-columns: 72px 1fr; align-items: start; }
.bpn-npc-card__art { width: 96px; }
.bpn-item-card__art { width: 72px; }
.bpn-npc-card__art svg, .bpn-npc-card__art img,
.bpn-item-card__art svg, .bpn-item-card__art img { width: 100%; height: auto; display: block; border-radius: 8px; }
.bpn-npc-card__name, .bpn-item-card__name {
font-family: var(--bpn-head);
font-weight: 700;
font-size: 1.1rem;
color: color-mix(in srgb, var(--bpn-accent) 24%, var(--bpn-text));
}
.bpn-npc-card__role {
font-family: var(--bpn-mono);
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--bpn-muted);
margin: 2px 0 6px;
}
.bpn-npc-card__desc, .bpn-item-card__desc { margin: 0 0 8px; font-size: 0.98rem; line-height: 1.5; color: var(--bpn-text); }
.bpn-npc-card__sig .bpn-label { margin-bottom: 2px; }
.bpn-npc-card__sig { font-size: 0.95rem; color: var(--bpn-text); }
.bpn-item-card__note { font-family: var(--bpn-mono); font-size: 0.78rem; color: var(--bpn-muted); }
@media (max-width: 480px) {
.bpn-npc-card { grid-template-columns: 72px 1fr; }
.bpn-npc-card__art { width: 72px; }
}
/* ===================== GM DOSSIER ===================== */
.bpn-dossier .bpn-doc { box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); }
@@ -356,6 +472,24 @@
border-radius: 4px;
}
.bpn-gmnotes { display: grid; gap: 8px; }
.bpn-gmnote {
display: grid;
grid-template-columns: minmax(0, 38%) 1fr;
gap: 12px;
padding: 9px 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--bpn-line);
}
.bpn-gmnote__k {
font-family: var(--bpn-mono);
font-size: 0.78rem;
letter-spacing: 0.04em;
color: color-mix(in srgb, var(--bpn-accent) 30%, var(--bpn-text));
}
.bpn-gmnote__v { color: var(--bpn-text); font-size: 0.96rem; }
.bpn-rolls {
font-family: var(--bpn-mono);
font-size: 0.78rem;
@@ -363,6 +497,10 @@
letter-spacing: 0.04em;
}
@media (max-width: 720px) {
.bpn-gmnote { grid-template-columns: 1fr; gap: 3px; }
}
/* ---------- responsive: small running laptop ----------------------------- */
@media (max-width: 720px) {