diff --git a/README.md b/README.md
index 72cd293..55169e4 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/module.json b/module.json
index d900d16..9e1e6a4 100644
--- a/module.json
+++ b/module.json
@@ -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",
diff --git a/module/app/generator-app.mjs b/module/app/generator-app.mjs
index 1aa97d1..cc6963b 100644
--- a/module/app/generator-app.mjs
+++ b/module/app/generator-app.mjs
@@ -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) => `
${html}
`;
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)) + `Player document: @UUID[${playerEntry.uuid}]{${base} [Player]}
`, O.NONE)
- ]
+ await JournalEntry.create({
+ name: `${base} [GM]`, folder: fid, ownership: { default: O.NONE }, flags,
+ pages: [page("GM Dossier", wrap(dossierHTML(bpn, ART)) + `Player document: @UUID[${playerEntry.uuid}]{${base} [Player]}
`, 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() {
diff --git a/module/vendor/art/bpn-art.js b/module/vendor/art/bpn-art.js
index 6cb496c..284d58f 100644
--- a/module/vendor/art/bpn-art.js
+++ b/module/vendor/art/bpn-art.js
@@ -331,6 +331,113 @@ export function portraitSVG(bpn, { size = 120 } = {}) {
`;
}
+/* ------------------------------------------------------------------------ */
+/* 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 = ``;
+ const jaw = ``;
+
+ let hair = "";
+ if (top === "hairShort") hair = ``;
+ else if (top === "hairCrop") hair = ``;
+ else if (top === "bald") hair = ``;
+ else if (top === "hood") hair = ``;
+ else if (top === "helmet") hair = ``;
+ else if (top === "cap") hair = ``;
+
+ const ey = cy - 4;
+ let eyeMarks = "";
+ if (eyes === "plain") eyeMarks = ``;
+ else if (eyes === "visor") eyeMarks = ``;
+ else if (eyes === "goggles") eyeMarks = ``;
+ else eyeMarks = ``;
+
+ const nose = ``;
+
+ const my = cy + 17;
+ let mouthMark = "";
+ if (mouth === "neutral") mouthMark = ``;
+ else if (mouth === "grim") mouthMark = ``;
+ else if (mouth === "smirk") mouthMark = ``;
+ else mouthMark = ``;
+
+ let extraMark = "";
+ if (extra === "earpiece") extraMark = ``;
+ else if (extra === "scar") extraMark = ``;
+ else if (extra === "collar") extraMark = ``;
+
+ // light hatch shading down the right cheek
+ const hatch = Array.from({ length: 4 }, (_, i) =>
+ ``).join("");
+
+ const shoulders = ``;
+
+ return ``;
+}
+
+/* ------------------------------------------------------------------------ */
+/* 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: ``,
+ canister: ``,
+ weapon: ``,
+ chip: ``,
+ crate: ``,
+ bug: ``,
+ component: `${Array.from({ length: 8 }, (_, i) => { const a = i / 8 * Math.PI * 2; return ``; }).join("")}`,
+ relay: ``,
+ kit: ``,
+ drone: ``,
+ key: ``,
+ card: ``
+ };
+ const inner = glyphs[cat] ?? glyphs.slate;
+ return ``;
+}
+
/* ------------------------------------------------------------------------ */
/* 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))}`;
+}
diff --git a/module/vendor/core/bpn-core.js b/module/vendor/core/bpn-core.js
index 9c2ad02..71d9b2a 100644
--- a/module/vendor/core/bpn-core.js
+++ b/module/vendor/core/bpn-core.js
@@ -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,
diff --git a/module/vendor/core/data/detail.js b/module/vendor/core/data/detail.js
index bba8a93..2e564ad 100644
--- a/module/vendor/core/data/detail.js
+++ b/module/vendor/core/data/detail.js
@@ -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: [
diff --git a/module/vendor/render/bpn-render.js b/module/vendor/render/bpn-render.js
index 532c74a..2ee11cb 100644
--- a/module/vendor/render/bpn-render.js
+++ b/module/vendor/render/bpn-render.js
@@ -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 `
-
${esc(n.name)}
-
${esc(n.role)}
-
${esc(n.player)}
+/** 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"
+ ? `
})
`
+ : npcPortraitSVG(npc, { size: 168, accent });
+ return `
+
${art}
+
+
${esc(npc.name)}
+
${esc(npc.role)} · ${esc(npc.disposition)}
+
${esc(npc.description)}
+
Why they matter${esc(npc.significance)}
+
`;
}
-function otemRow(o) {
- return `
-
${esc(o.name)}
-
${esc(o.player)}
+/** A simple item card: line-art icon, what it is, a description. */
+export function itemCardHTML(item, { artMode = "inline", accent = "#9fb0c4" } = {}) {
+ const art = artMode === "img"
+ ? `
})
`
+ : itemIconSVG(item, { size: 120, accent });
+ return `
+
${art}
+
+
${esc(item.name)}
+
${esc(item.description)}
+
${esc(item.player)}
+
`;
}
@@ -163,11 +179,11 @@ export function fieldDossierHTML(bpn, { artMode = "inline" } = {}) {
Cast on the Ground
- ${bpn.cast.map(castRow).join("")}
+ ${bpn.cast.map((n) => npcCardHTML(n, { artMode, accent: c.accent })).join("")}
OTEM · Items & Equipment
- ${bpn.otem.map(otemRow).join("")}
+ ${bpn.otem.map((o) => itemCardHTML(o, { artMode, accent: c.accent })).join("")}
Key Locations
diff --git a/styles/bpn-render.css b/styles/bpn-render.css
index a65553a..e98e217 100644
--- a/styles/bpn-render.css
+++ b/styles/bpn-render.css
@@ -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); }