Files
blueprint-news/render/bpn-render.js
T
tevansandClaude Opus 4.8 2090e8902c Blueprint News: engine-agnostic SLA BPN briefing generator
Reusable framework-free component (core generation engine, seeded procedural
SVG art, render layer) plus a standalone app built on it. Produces two separate
surfaces per briefing — Player Brief and GM Dossier — in the SLA house style
with bundled local fonts. Per-band procedural artwork, deterministic from seed.
NPC/monster generation left to a future companion module via a clean seam.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:24:14 +01:00

207 lines
8.0 KiB
JavaScript

/* Blueprint News — render layer.
*
* Turns a BPN object (from core) into the two separate surfaces:
* - the Player Brief: the official corporate dispatch
* - the GM Dossier: the handler-only truth
*
* Pure DOM + string building, framework-free. Functions come in two flavours:
* briefHTML(bpn) / dossierHTML(bpn) -> markup strings (export, embedding)
* renderBrief(bpn, el) / renderDossier(bpn, el) -> mount into an element
*
* The host page must include render/bpn-render.css and the bundled fonts.css.
*/
import { crestSVG, bannerSVG, portraitSVG } from "../art/bpn-art.js";
function esc(value) {
return String(value ?? "").replace(/[&<>"']/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]
));
}
/* The SLA "Third Eye" mark, used on the GM banner. */
function thirdEyeSVG(colour = "#ff5b5b") {
return `<svg class="bpn-eye" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M32 14 C50 14 58 28 58 32 C58 36 50 50 32 50 C14 50 6 36 6 32 C6 28 14 14 32 14 Z" fill="none" stroke="${colour}" stroke-width="3"/>
<circle cx="32" cy="32" r="9" fill="none" stroke="${colour}" stroke-width="3"/>
<line x1="10" y1="54" x2="54" y2="10" stroke="${colour}" stroke-width="3"/>
</svg>`;
}
function fact(label, value) {
return `<div><span class="bpn-label">${esc(label)}</span><span class="bpn-value">${esc(value)}</span></div>`;
}
function card(label, value) {
return `<div class="bpn-card"><span class="bpn-label">${esc(label)}</span><span class="bpn-value">${esc(value)}</span></div>`;
}
/* ---------------- PLAYER BRIEF ---------------- */
export function briefHTML(bpn, { art = true } = {}) {
const c = bpn.colour;
const banner = art ? bannerSVG(bpn, { width: 1200, height: 360 }) : "";
const crest = crestSVG(bpn, { size: 184 });
return `<article class="bpn-brief" style="--bpn-accent:${esc(c.accent)};">
<div class="bpn-doc">
<header class="bpn-hero">
<div class="bpn-hero__art" aria-hidden="true">${banner}</div>
<div class="bpn-stamp">Issued by<b>${esc(bpn.issuingDepartment)}</b></div>
<div class="bpn-hero__crest">${crest}</div>
<div class="bpn-hero__copy">
<span class="bpn-hero__band"><b>${esc(c.label)}</b> BPN &middot; ${esc(c.type)}</span>
<h1 class="bpn-hero__title">${esc(bpn.shortTitle)}</h1>
<p class="bpn-hero__sub">${esc(c.summary)}</p>
</div>
</header>
<div class="bpn-facts">
${fact("BPN ID", bpn.bpnId)}
${fact("SCL Requirement", bpn.sclRequirement)}
${fact("Gross Contract", bpn.reward.grossContract)}
${fact("Est. Take-home", bpn.reward.takeHomeEach)}
</div>
<div class="bpn-body">
<section>
<h2 class="bpn-section__title">Mission Brief</h2>
<p class="bpn-brief-text">${esc(bpn.missionBrief)}</p>
</section>
<section>
<h2 class="bpn-section__title">Objectives</h2>
<div class="bpn-objective"><span class="bpn-label">Primary</span><span class="bpn-value">${esc(bpn.objective)}</span></div>
<div class="bpn-objective" style="margin-top:8px;"><span class="bpn-label">Secondary</span><span class="bpn-value">${esc(bpn.extraObjective)}</span></div>
</section>
<section>
<h2 class="bpn-section__title">Operational Picture</h2>
<div class="bpn-grid">
${card("Location", bpn.location)}
${card("Atmosphere", bpn.atmosphere)}
${card("Declared Opposition", bpn.opposition)}
${card("Mission Focus", bpn.missionFocus)}
</div>
</section>
<section>
<h2 class="bpn-section__title">On-site Contact &amp; Pressures</h2>
<div class="bpn-contact">
<div class="bpn-contact__ident">${portraitSVG(bpn, { size: 120 })}</div>
<div class="bpn-contact__detail">
<span class="bpn-label">Primary Contact</span>
<span class="bpn-value bpn-contact__name">${esc(bpn.contact.name)}</span>
<span class="bpn-value">${esc(bpn.contact.role)}</span>
<span class="bpn-contact__pos">${esc(bpn.contact.location)}</span>
</div>
</div>
<div class="bpn-grid" style="margin-top:12px;">
${card("Civilian Pressure", bpn.civilianFaction)}
${card("Operational Hook", bpn.hook)}
</div>
</section>
</div>
<footer class="bpn-footer">
${fact("Media Posture", bpn.reward.mediaCoverage)}
${fact("Squad Size", `${bpn.squadSize} Operatives`)}
${fact("SCL Increase", bpn.reward.sclIncrease)}
${fact("Cleanup Directive", bpn.cleanupDirective)}
</footer>
</div>
</article>`;
}
/* ---------------- GM DOSSIER ---------------- */
export function dossierHTML(bpn) {
const c = bpn.colour;
const g = bpn.gm;
const seam = bpn.npcSeam;
const r = g.rolls ?? {};
const seamRow = (seam.contactArchetype || seam.threatArchetype)
? `<section class="bpn-seam">
<p class="bpn-eyebrow">NPC / Monster Hand-off</p>
<p class="bpn-value" style="margin:6px 0 0;">This brief reserves two cast slots for the companion NPC module:</p>
<p style="margin:8px 0 0; line-height:1.9;">
Contact archetype <code>${esc(seam.contactArchetype ?? "—")}</code> &mdash; ${esc(bpn.contact.name)}, ${esc(bpn.contact.role)}.<br>
Threat archetype <code>${esc(seam.threatArchetype ?? "—")}</code> &mdash; ${esc(seam.threatRole ?? g.threatLead ?? "—")}.
</p>
</section>`
: "";
return `<article class="bpn-dossier" style="--bpn-accent:${esc(c.accent)};">
<div class="bpn-doc">
<header class="bpn-dossier__banner">
${thirdEyeSVG("#ff5b5b")}
<h2>GM Dossier — ${esc(bpn.shortTitle)}</h2>
<span class="bpn-eyebrow">Restricted &middot; Handler Eyes Only</span>
</header>
<div class="bpn-body">
<div class="bpn-grid">
<div class="bpn-reveal bpn-twist">
<span class="bpn-label">Operational Twist</span>
<span class="bpn-value">${esc(g.localTwist)}</span>
</div>
<div class="bpn-reveal">
<span class="bpn-label">Withheld Fact</span>
<span class="bpn-value">${esc(g.complication)}</span>
</div>
</div>
<section>
<h2 class="bpn-section__title">True Threat Read</h2>
<div class="bpn-card">
<dl class="bpn-threat-read">
<dt>Lead</dt><dd>${esc(g.threatLead || "—")}</dd>
<dt>Numbers</dt><dd>${esc(g.threatCountLabel || "—")}</dd>
<dt>Attitude</dt><dd>${esc(g.oppositionAttitude)}</dd>
</dl>
</div>
</section>
<section>
<h2 class="bpn-section__title">Levers &amp; Escalation</h2>
<div class="bpn-grid">
${card("Escalation Trigger", g.escalationEvent)}
${card("Civilian Lever", bpn.civilianFaction)}
${card("Financier Reality", g.financierNote)}
${card("CBS Bands", `${bpn.reward.cbsBandOp} per Op / ${bpn.reward.cbsBandSquad} per Squad`)}
</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>
</div>
</div>
</article>`;
}
/* ---------------- mount helpers ---------------- */
export function renderBrief(bpn, el, opts) {
const root = typeof el === "string" ? document.querySelector(el) : el;
if (!root) return;
root.classList.add("bpn");
root.innerHTML = briefHTML(bpn, opts);
}
export function renderDossier(bpn, el) {
const root = typeof el === "string" ? document.querySelector(el) : el;
if (!root) return;
root.classList.add("bpn");
root.innerHTML = dossierHTML(bpn);
}
/* Render both surfaces into one element (used for the print / "both" 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);
}