Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1271df8cd7 |
@@ -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
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Vendored
+55
@@ -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))}`;
|
||||
}
|
||||
|
||||
Vendored
+76
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+23
-20
@@ -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"
|
||||
}
|
||||
|
||||
Vendored
+90
@@ -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."
|
||||
]
|
||||
};
|
||||
Vendored
+79
-4
@@ -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 & 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)} · frame ${esc(g.frameId)} · 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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user