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>
This commit is contained in:
2026-06-17 18:26:19 +01:00
co-authored by Claude Opus 4.8
parent 1271df8cd7
commit cd09c043b2
8 changed files with 333 additions and 58 deletions
+7 -1
View File
@@ -1,7 +1,13 @@
# SLA Blueprint News (Foundry VTT module)
Generate SLA Industries **BPN** mission briefings inside Foundry. Each briefing
creates **two separate journal entries**:
creates a **folder ("package")** containing several separate journal entries:
- 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.
+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.3.0",
"version": "0.4.0",
"compatibility": {
"minimum": "13",
"verified": "14.361",
+32 -15
View File
@@ -12,8 +12,8 @@
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, fieldDossierHTML, 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;
@@ -171,16 +171,19 @@ 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 } });
// Separate player document: the Mission Brief + the Field Dossier handout.
// 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]`,
ownership: { default: O.OBSERVER },
flags,
name: `${base} [Player]`, folder: fid, ownership: { default: O.OBSERVER }, flags,
pages: [
page("Mission Brief", wrap(briefHTML(bpn, ART)), O.OBSERVER),
page("Field Dossier", wrap(fieldDossierHTML(bpn, ART)), O.OBSERVER)
@@ -188,17 +191,31 @@ export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2)
});
// Separate GM-only document, linked back to the player document.
const gmEntry = await JournalEntry.create({
name: `${base} [GM]`,
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)
]
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)]
});
ui.notifications.info(`Created "${base}": a player document (Brief + Field Dossier) and a separate GM document.`);
gmEntry.sheet?.render(true);
// 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() {
+113
View File
@@ -331,6 +331,113 @@ 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 */
@@ -397,3 +504,9 @@ export function portraitDataURI(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))}`;
}
+64 -27
View File
@@ -266,36 +266,72 @@ function ucFirst(s = "") {
return v.charAt(0).toUpperCase() + v.slice(1);
}
/** Build the cast, OTEM and locations for a briefing. Each entry carries a
* player-facing `player` line and a GM-only `gm` secret. */
function buildDetail(rng, ctx) {
const cast = [];
// The on-site contact and the reported threat are always part of the cast.
cast.push({
name: ctx.contactName,
role: ctx.contactRole,
player: `On-site contact, holding at ${lcFirst(trimEnd(ctx.contactLocation))}.`,
gm: pick(rng, DETAIL.npcSecrets)
});
cast.push({
name: ctx.threatLead || "Reported hostile",
role: "Designated threat",
player: ctx.threatCount || "Hostile lead flagged by command.",
gm: ctx.oppositionAttitude ? `Really: ${lcFirst(ctx.oppositionAttitude)}` : pick(rng, DETAIL.npcSecrets)
});
// Plus two generated supporting NPCs.
for (let i = 0; i < 2; i++) {
const role = pick(rng, DETAIL.npcRoles);
cast.push({
name: `${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}`,
role,
player: `${pick(rng, DETAIL.npcDispositions)} ${role.toLowerCase()}. ${pick(rng, DETAIL.npcPlayerNotes)}`,
gm: pick(rng, DETAIL.npcSecrets)
});
/** 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";
}
const otem = sample(rng, DETAIL.otemNames, 3).map((name) => ({
/** 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)
}));
@@ -449,6 +485,7 @@ export function generateBPN(opts = {}) {
// 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,
+38
View File
@@ -39,6 +39,44 @@ export const DETAIL = {
"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: [
+29 -13
View File
@@ -12,8 +12,8 @@
*/
import {
crestSVG, bannerSVG, portraitSVG, siteMapSVG,
crestDataURI, bannerDataURI, portraitDataURI, siteMapDataURI
crestSVG, bannerSVG, portraitSVG, siteMapSVG, npcPortraitSVG, itemIconSVG,
crestDataURI, bannerDataURI, portraitDataURI, siteMapDataURI, npcPortraitDataURI, itemIconDataURI
} from "../art/bpn-art.js";
function esc(value) {
@@ -125,18 +125,34 @@ export function briefHTML(bpn, { art = true, artMode = "inline" } = {}) {
are cleared to know, with a schematic site map. The GM-only secrets for the
same entries live in the GM Dossier. */
function castRow(n) {
return `<div class="bpn-detail-item">
<div class="bpn-detail-item__name">${esc(n.name)}</div>
<div class="bpn-detail-item__role">${esc(n.role)}</div>
<div class="bpn-detail-item__line">${esc(n.player)}</div>
/** 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>`;
}
function otemRow(o) {
return `<div class="bpn-detail-item">
<div class="bpn-detail-item__name">${esc(o.name)}</div>
<div class="bpn-detail-item__line">${esc(o.player)}</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>`;
}
@@ -163,11 +179,11 @@ export function fieldDossierHTML(bpn, { artMode = "inline" } = {}) {
<div class="bpn-body">
<section>
<h2 class="bpn-section__title">Cast on the Ground</h2>
<div class="bpn-detail-list">${bpn.cast.map(castRow).join("")}</div>
<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-detail-list">${bpn.otem.map(otemRow).join("")}</div>
<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>
+48
View File
@@ -350,6 +350,54 @@
.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); }