/* Blueprint News — procedural SVG artwork.
*
* Flat, graphic, stencil/propaganda-poster styling: hard angles, heavy blacks,
* a single colour-band accent, halftone and scanline textures. Deliberately
* NOT photorealistic. All shapes derive from the BPN seed, so a given briefing
* always renders the same art while every briefing differs.
*
* Each colour band gets its own SCENE — a foreground motif (sewer arch, transit
* concourse, perimeter wall, vault corridor, …) plus tuned atmosphere and
* lighting — so a Blue street job and a Black covert op never look alike.
*
* No DOM and no engine APIs — each function returns an SVG markup string.
*/
/* --- seeded rng (local copy so art is usable standalone) ----------------- */
function hashSeed(str) {
let h = 2166136261 >>> 0;
const s = String(str);
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
function mulberry32(a) {
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function rngFor(seed, salt) {
return mulberry32(hashSeed(`${seed}::${salt}`));
}
const rint = (rng, lo, hi) => Math.floor(rng() * (hi - lo + 1)) + lo;
const rfloat = (rng, lo, hi) => rng() * (hi - lo) + lo;
const r2 = (n) => Math.round(n * 100) / 100;
/* --- colour helpers ------------------------------------------------------ */
function hexToRgb(hex) {
const h = String(hex || "#dce8f9").replace("#", "");
const n = h.length === 3 ? h.split("").map((c) => c + c).join("") : h;
return { r: parseInt(n.slice(0, 2), 16), g: parseInt(n.slice(2, 4), 16), b: parseInt(n.slice(4, 6), 16) };
}
function mix(hex, withHex, t) {
const a = hexToRgb(hex);
const b = hexToRgb(withHex);
const c = (k) => Math.round(a[k] + (b[k] - a[k]) * t);
return `rgb(${c("r")},${c("g")},${c("b")})`;
}
function rgba(hex, alpha) {
const { r, g, b } = hexToRgb(hex);
return `rgba(${r},${g},${b},${alpha})`;
}
/* --- per-band scene definitions ----------------------------------------- */
/* motif: which foreground silhouette to draw.
rain/smog/crowd: atmospheric layer. floodlights/beacons: lighting.
density: building/back-skyline busyness (0 sparse … 1 dense). */
const SCENES = {
blue: { motif: "tunnel", rain: 0.8, smog: 0, crowd: 0, floodlights: false, beacons: false, density: 0.5 },
yellow: { motif: "transit", rain: 0, smog: 0.2, crowd: 0.9, floodlights: false, beacons: false, density: 0.7 },
green: { motif: "wall", rain: 0.5, smog: 0.1, crowd: 0, floodlights: true, beacons: false, density: 0.5 },
white: { motif: "cordon", rain: 0.2, smog: 0.1, crowd: 0, floodlights: false, beacons: false, density: 0.6 },
grey: { motif: "gantry", rain: 0, smog: 0.6, crowd: 0, floodlights: false, beacons: false, density: 0.8 },
silver: { motif: "stage", rain: 0, smog: 0, crowd: 0.7, floodlights: true, beacons: false, density: 0.6 },
jade: { motif: "canal", rain: 0.3, smog: 0.8, crowd: 0, floodlights: false, beacons: false, density: 0.5 },
red: { motif: "tower", rain: 0.6, smog: 0.3, crowd: 0, floodlights: true, beacons: true, density: 0.7 },
black: { motif: "vault", rain: 0, smog: 0, crowd: 0, floodlights: false, beacons: false, density: 0.2 },
platinum: { motif: "exec", rain: 0, smog: 0.1, crowd: 0, floodlights: false, beacons: false, density: 0.3 }
};
/* Mission-focus glyph drawn inside the crest pupil. */
function focusGlyph(focus = "", accent = "#fff") {
const f = String(focus).toLowerCase();
const stroke = `stroke="${accent}" stroke-width="3.5" fill="none" stroke-linecap="round" stroke-linejoin="round"`;
if (f.includes("eliminate") || f.includes("hunt") || f.includes("kill")) return ``;
if (f.includes("escort") || f.includes("protect")) return ``;
if (f.includes("retrieve")) return ``;
if (f.includes("investigate") || f.includes("profile")) return ``;
if (f.includes("contain") || f.includes("quarantine")) return ``;
if (f.includes("riot") || f.includes("crowd")) return ``;
return ``;
}
/* ------------------------------------------------------------------------ */
/* CREST — the BPN sigil. */
/* ------------------------------------------------------------------------ */
export function crestSVG(bpn, { size = 128 } = {}) {
const accent = bpn?.colour?.accent ?? "#dce8f9";
const seed = bpn?.seed ?? "BPN";
const uid = String(seed).replace(/[^A-Za-z0-9]/g, "");
const rng = rngFor(seed, "crest");
const dark = "#101722";
const rim = mix(accent, "#ffffff", 0.12);
const glyph = focusGlyph(bpn?.missionFocus ?? "", mix(accent, "#ffffff", 0.25));
const rivet = (x, y) => ``;
const ticks = Array.from({ length: 12 }, (_, i) => {
const a = (i / 12) * Math.PI * 2;
const x1 = 64 + Math.cos(a) * 50, y1 = 64 + Math.sin(a) * 50;
const x2 = 64 + Math.cos(a) * (i % 3 === 0 ? 44 : 47), y2 = 64 + Math.sin(a) * (i % 3 === 0 ? 44 : 47);
return ``;
}).join("");
const jitter = r2(rfloat(rng, -2, 2));
return ``;
}
/* ------------------------------------------------------------------------ */
/* FOREGROUND MOTIFS — one per scene. Each returns markup positioned within */
/* a 1200x360 banner space (W,H passed in). */
/* ------------------------------------------------------------------------ */
function litWindowGrid(rng, x, y, w, h, accent, alpha, step = 14) {
const out = [];
for (let wx = x + 6; wx < x + w - 6; wx += step) {
for (let wy = y + 8; wy < y + h - 6; wy += 18) {
if (rng() < 0.32) out.push(``);
}
}
return out.join("");
}
const MOTIFS = {
// generic angular skyline used by most street-level scenes
skyline(rng, W, H, accent, density) {
let x = -20; const out = [];
const gap = density > 0.6 ? [2, 8] : [6, 16];
while (x < W + 20) {
const w = rint(rng, 34, 96), h = rint(rng, H * 0.4, H * 0.82), top = H + 4 - h;
out.push(``);
if (rng() < 0.4) { const mx = x + rint(rng, 6, w - 6); out.push(``); }
out.push(litWindowGrid(rng, x, top, w, H - top, accent, 0.85));
x += w + rint(rng, gap[0], gap[1]);
}
return out.join("");
},
tunnel(rng, W, H, accent) {
const cx = W * rfloat(rng, 0.4, 0.6), out = [];
for (let i = 0; i < 5; i++) { const ry = H * (0.34 + i * 0.11), rx = ry * 1.5; out.push(``); }
out.push(``);
for (let i = 0; i < 26; i++) { const x = rfloat(rng, 0, W); out.push(``); } // water glints
out.push(``); // side pipes
return out.join("");
},
transit(rng, W, H, accent) {
const out = [MOTIFS.skyline(rng, W, H, accent, 0.7)];
for (let i = 0; i < 6; i++) { const x = (i + 0.5) * (W / 6); out.push(``); if (rng() < 0.8) out.push(``); } // pillars + route boards
return out.join("");
},
wall(rng, W, H, accent) {
const out = [], top = H * 0.34;
out.push(``);
for (let x = 0; x < W; x += 64) out.push(``); // plating
for (let x = 0; x < W; x += 64) out.push(``); // battlements
return out.join("");
},
cordon(rng, W, H, accent) {
const out = [MOTIFS.skyline(rng, W, H, accent, 0.6)];
// diagonal cordon tape across the lower third
for (let k = -1; k <= 1; k += 2) { const y = H * (0.72 + k * 0.06); out.push(``); }
return out.join("");
},
gantry(rng, W, H, accent) {
const out = [MOTIFS.skyline(rng, W, H, accent, 0.8)];
for (let i = 0; i < 10; i++) { const x = rfloat(rng, 0, W - 70), y = H - rint(rng, 24, 70); out.push(``); } // containers
out.push(``); // gantry crane
return out.join("");
},
stage(rng, W, H, accent) {
const out = [MOTIFS.skyline(rng, W, H, accent, 0.5)];
out.push(``); // truss
for (let i = 0; i < 5; i++) { const x = W * (0.28 + i * 0.11); out.push(``); } // spotlight cones
return out.join("");
},
canal(rng, W, H, accent) {
const out = [MOTIFS.skyline(rng, W, H, accent, 0.5)];
out.push(``); // canal
for (let i = 0; i < 7; i++) { const x = rfloat(rng, 30, W - 30); out.push(``); } // hazard barrels
return out.join("");
},
tower(rng, W, H, accent) {
const out = [MOTIFS.skyline(rng, W, H, accent, 0.7)];
const tx = W * rfloat(rng, 0.4, 0.6);
out.push(``); // broadcast tower
out.push(``);
out.push(``); // monorail line
return out.join("");
},
vault(rng, W, H, accent) {
const cx = W * 0.5, out = [];
for (let i = 0; i < 6; i++) { const s = 1 - i * 0.15, w = W * 0.5 * s, h = H * 0.9 * s; out.push(``); } // receding corridor
out.push(``); // far door
for (let i = 0; i < 5; i++) out.push(``); // biometric panel
return out.join("");
},
exec(rng, W, H, accent) {
const out = []; let x = 40;
while (x < W - 40) { const w = rint(rng, 70, 120), h = rint(rng, H * 0.55, H * 0.95), top = H + 4 - h; out.push(``); out.push(litWindowGrid(rng, x, top, w, H - top, accent, 0.4, 18)); x += w + rint(rng, 40, 90); }
out.push(``);
return out.join("");
}
};
/* ------------------------------------------------------------------------ */
/* BANNER — the wide hero backdrop, composed from the band's scene. */
/* ------------------------------------------------------------------------ */
export function bannerSVG(bpn, { width = 1200, height = 360 } = {}) {
const accent = bpn?.colour?.accent ?? "#dce8f9";
const key = bpn?.colourKey ?? "white";
const seed = bpn?.seed ?? "BPN";
const uid = String(seed).replace(/[^A-Za-z0-9]/g, "");
const scene = SCENES[key] ?? SCENES.white;
const rng = rngFor(seed, "banner");
const dark = "#0b1018";
const W = width, H = height;
// hazy back skyline for depth (skip for vault/exec which set their own mood)
let back = "";
if (!["vault", "exec"].includes(scene.motif)) {
let x = -20; const out = [];
while (x < W + 20) { const w = rint(rng, 40, 96), h = rint(rng, H * 0.28, H * 0.6), top = H * 0.92 - h; out.push(``); x += w + rint(rng, 4, 14); }
back = out.join("");
}
const fg = (MOTIFS[scene.motif] ?? MOTIFS.skyline)(rng, W, H, accent, scene.density);
// searchlight beams
const beams = Array.from({ length: rint(rng, 2, 4) }, () => { const ox = rfloat(rng, 0, W), s = rfloat(rng, 40, 120); return ``; }).join("");
// atmosphere layers
let rain = "", smog = "", crowd = "";
if (scene.rain > 0) rain = Array.from({ length: Math.round(scene.rain * 110) }, () => { const x = rfloat(rng, 0, W), y = rfloat(rng, 0, H), len = rfloat(rng, 3, 11); return ``; }).join("");
if (scene.smog > 0) smog = Array.from({ length: Math.round(scene.smog * 5) }, (_, i) => ``).join("");
if (scene.crowd > 0) crowd = Array.from({ length: Math.round(scene.crowd * 90) }, () => { const x = rfloat(rng, 0, W), y = rfloat(rng, H * 0.82, H - 4); return ``; }).join("");
// lighting
let floods = "", beaconGlow = "";
if (scene.floodlights) floods = Array.from({ length: rint(rng, 2, 3) }, () => { const x = rfloat(rng, W * 0.2, W * 0.8); return ``; }).join("");
if (scene.beacons) beaconGlow = Array.from({ length: rint(rng, 2, 4) }, () => ``).join("");
// hazard chevrons for high-alert bands
let hazard = "";
if (["red", "black", "jade", "green"].includes(key)) { const bandY = H - 16, chev = []; for (let x = -20; x < W + 20; x += 28) chev.push(``); hazard = `${chev.join("")}`; }
// giant faint Third Eye
const eyeX = rng() < 0.5 ? W * 0.78 : W * 0.22, eyeScale = rfloat(rng, 1.6, 2.2);
const eye = ``;
return ``;
}
/* ------------------------------------------------------------------------ */
/* PORTRAIT — a stylised halftone contact silhouette for the brief. */
/* Flat shoulders + head bust, halftone dot shading, accent rim. Not a face: */
/* an anonymous corporate ID-photo abstraction, deliberately non-realistic. */
/* ------------------------------------------------------------------------ */
export function portraitSVG(bpn, { size = 120 } = {}) {
const accent = bpn?.colour?.accent ?? "#dce8f9";
const seed = bpn?.seed ?? "BPN";
const uid = String(seed).replace(/[^A-Za-z0-9]/g, "") + "p";
const rng = rngFor(seed, "portrait");
const dark = "#0c1119";
// halftone dots over the bust area
const dots = [];
for (let y = 18; y < 120; y += 8) {
for (let x = 12; x < 108; x += 8) {
const d = Math.hypot(x - 60, y - 70);
const p = Math.max(0, 1 - d / 70);
if (rng() < p * 0.7) dots.push(``);
}
}
return ``;
}
/* ------------------------------------------------------------------------ */
/* 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 */
/* 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) => `
${i + 1}
`).join("");
// entry chevron at the first node
const e = nodes[0];
const entry = `ENTRY`;
return ``;
}
/* Convenience: data URIs (handy as
srcs or journal icons, and required
in hosts that sanitise inline