1 Commits
Author SHA1 Message Date
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
9 changed files with 456 additions and 55 deletions
+11 -5
View File
@@ -1,12 +1,18 @@
# 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 **two 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.
- **`… [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.3.0",
"compatibility": {
"minimum": "13",
"verified": "14.361",
+31 -25
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 { briefHTML, fieldDossierHTML, dossierHTML } from "../vendor/render/bpn-render.js";
import { crestDataURI } from "../vendor/art/bpn-art.js";
const MODULE_ID = "sla-blueprint-news";
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
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;
@@ -168,31 +172,33 @@ export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2)
const O = CONST.DOCUMENT_OWNERSHIP_LEVELS;
const HTML = CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
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}`,
// Separate player document: the Mission Brief + the Field Dossier handout.
const playerEntry = await JournalEntry.create({
name: `${base} [Player]`,
ownership: { default: O.OBSERVER },
flags: { [MODULE_ID]: { seed: bpn.seed, colourKey: bpn.colourKey, frameId: bpn.gm.frameId, sclLevel: bpn.sclLevel } },
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.
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)
]
});
ui.notifications.info(`Created "${base}": a player document (Brief + Field Dossier) and a separate GM document.`);
gmEntry.sheet?.render(true);
}
static async #onChat() {
+55
View File
@@ -331,6 +331,58 @@ export function portraitSVG(bpn, { size = 120 } = {}) {
</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 +394,6 @@ 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))}`;
}
+76
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,66 @@ 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);
}
/** 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)
});
}
const otem = sample(rng, DETAIL.otemNames, 3).map((name) => ({
name,
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 +445,20 @@ 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, {
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"
}
+90
View File
@@ -0,0 +1,90 @@
/* 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."
],
/* ---- 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."
]
};
+79 -4
View File
@@ -12,8 +12,8 @@
*/
import {
crestSVG, bannerSVG, portraitSVG,
crestDataURI, bannerDataURI, portraitDataURI
crestSVG, bannerSVG, portraitSVG, siteMapSVG,
crestDataURI, bannerDataURI, portraitDataURI, siteMapDataURI
} from "../art/bpn-art.js";
function esc(value) {
@@ -120,6 +120,65 @@ 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. */
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>
</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>
</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-detail-list">${bpn.cast.map(castRow).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>
</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 +242,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 +275,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);
}
+90
View File
@@ -282,6 +282,74 @@
}
.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; }
/* ===================== GM DOSSIER ===================== */
.bpn-dossier .bpn-doc { box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); }
@@ -356,6 +424,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 +449,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) {