Expand detail: cast/OTEM/locations, separate Field Dossier, pay up to 2000

- Raise CBS pay scale so the reward slider reaches ~2000c (Red), higher for
  Black/Platinum.
- New per-briefing detail: generated cast (NPCs), OTEM (items & equipment) and
  locations, each with a player line and a GM-only secret (core/data/detail.js).
- New separate player document: fieldDossierHTML() — cast, OTEM, locations and
  a procedural schematic site map (art: siteMapSVG). GM Dossier gains the
  matching secrets section.
- Standalone app: Player Brief / Field Dossier / GM Dossier / All views.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 16:33:36 +01:00
co-authored by Claude Opus 4.8
parent ce58096913
commit 405afa6f94
8 changed files with 418 additions and 27 deletions
+3 -2
View File
@@ -7,7 +7,7 @@
import { generateBPN, randomSeed } from "../core/bpn-core.js";
import { COLOURS } from "../core/data/colours.js";
import { framesForColour } from "../core/data/frames.js";
import { renderBrief, renderDossier, renderBoth, briefHTML, dossierHTML } from "../render/bpn-render.js";
import { renderBrief, renderField, renderDossier, renderBoth, briefHTML, fieldDossierHTML, dossierHTML } from "../render/bpn-render.js";
const LIB_KEY = "bpn-library-v1";
@@ -129,6 +129,7 @@ function draw() {
el.output.classList.add("bpn");
if (!current) return;
if (view === "player") renderBrief(current, el.output);
else if (view === "field") renderField(current, el.output);
else if (view === "gm") renderDossier(current, el.output);
else renderBoth(current, el.output);
}
@@ -219,7 +220,7 @@ function exportHtml() {
<link rel="stylesheet" href="${fonts}">
<link rel="stylesheet" href="${css}">
<style>body{margin:0;background:#0a0e14;padding:24px;}.bpn{display:grid;gap:26px;max-width:960px;margin:0 auto;}</style>
</head><body><div class="bpn">${briefHTML(current)}${dossierHTML(current)}</div></body></html>`;
</head><body><div class="bpn">${briefHTML(current)}${fieldDossierHTML(current)}${dossierHTML(current)}</div></body></html>`;
const blob = new Blob([doc], { type: "text/html" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
+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."
]
};
+2 -1
View File
@@ -90,8 +90,9 @@
<div class="stage__bar">
<div class="viewtoggle" role="group" aria-label="View">
<button data-view="player" aria-pressed="true">Player Brief</button>
<button data-view="field" aria-pressed="false">Field Dossier</button>
<button data-view="gm" aria-pressed="false">GM Dossier</button>
<button data-view="both" aria-pressed="false">Both</button>
<button data-view="both" aria-pressed="false">All</button>
</div>
</div>
<div id="output" class="output bpn"></div>
+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) {
+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);
}