Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1271df8cd7 | ||
|
|
88e8881d31 |
@@ -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.1.0",
|
||||
"version": "0.3.0",
|
||||
"compatibility": {
|
||||
"minimum": "13",
|
||||
"verified": "14.361",
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* SLA Blueprint News — generator window (ApplicationV2 + Handlebars).
|
||||
*
|
||||
* Mirrors the standalone app over the vendored engine: pick band / frame /
|
||||
* squad / payment, watch a live preview, then Create Journals (a player-facing
|
||||
* Mission Brief page + a GM-only Dossier page) or Post to Chat.
|
||||
* squad / payment, set the operative SCL level, dial the credits and SCL-
|
||||
* increase rewards on sliders (live preview, no hardcoded numbers), then
|
||||
* Create Journals (player Brief page + GM-only Dossier page) or Post to Chat.
|
||||
*
|
||||
* The render layer is engine-free; this file is the only Foundry-aware code.
|
||||
*/
|
||||
@@ -11,11 +12,22 @@
|
||||
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) => {
|
||||
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}`;
|
||||
|
||||
export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2) {
|
||||
state = {
|
||||
@@ -24,6 +36,9 @@ export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2)
|
||||
frameId: "",
|
||||
squadSize: 4,
|
||||
paymentType: "",
|
||||
sclLevel: null, // null → core defaults per band
|
||||
cbsValue: null, // null → use the seed's rolled default
|
||||
sclIncrease: null,
|
||||
view: "player"
|
||||
};
|
||||
|
||||
@@ -32,7 +47,7 @@ export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2)
|
||||
tag: "div",
|
||||
classes: ["sla-bpn-window"],
|
||||
window: { title: "SLABPN.Title", resizable: true, icon: "fas fa-file-contract" },
|
||||
position: { width: 1080, height: 820 },
|
||||
position: { width: 1080, height: 860 },
|
||||
actions: {
|
||||
reroll: BlueprintNewsApp.#onReroll,
|
||||
applySeed: BlueprintNewsApp.#onApplySeed,
|
||||
@@ -54,54 +69,54 @@ export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2)
|
||||
colourKey: this.state.colourKey || undefined,
|
||||
frameId: this.state.frameId || undefined,
|
||||
squadSize: this.state.squadSize,
|
||||
sclLevel: this.state.sclLevel ?? undefined,
|
||||
cbsValue: this.state.cbsValue ?? undefined,
|
||||
sclIncrease: this.state.sclIncrease ?? undefined,
|
||||
overrides: { paymentType: this.state.paymentType || "__RANDOM__" }
|
||||
});
|
||||
}
|
||||
|
||||
async _prepareContext() {
|
||||
const bpn = this._bpn();
|
||||
// keep state in step with what was actually rolled (band/frame/seed)
|
||||
this.state.seed = bpn.seed;
|
||||
this.state.sclLevel = bpn.sclLevel; // reflect the resolved level in the select
|
||||
|
||||
const colourOptions = [
|
||||
{ key: "", label: "Any (roll d20)", selected: this.state.colourKey === "" }
|
||||
].concat(COLOURS.map((c) => ({
|
||||
key: c.key,
|
||||
label: `${c.label} — ${c.type}`,
|
||||
selected: c.key === this.state.colourKey
|
||||
})));
|
||||
const colourOptions = [{ key: "", label: "Any (roll d20)", selected: this.state.colourKey === "" }]
|
||||
.concat(COLOURS.map((c) => ({ key: c.key, label: `${c.label} — ${c.type}`, selected: c.key === this.state.colourKey })));
|
||||
|
||||
const frames = this.state.colourKey ? framesForColour(this.state.colourKey) : [];
|
||||
const frameOptions = [{ id: "", label: "Any in band", selected: this.state.frameId === "" }]
|
||||
.concat(frames.map((f) => ({ id: f.id, label: f.shortTitle, selected: f.id === this.state.frameId })));
|
||||
|
||||
const previewHTML = this.state.view === "gm"
|
||||
? dossierHTML(bpn, { artMode: "img" })
|
||||
: this.state.view === "both"
|
||||
? briefHTML(bpn, { artMode: "img" }) + dossierHTML(bpn, { artMode: "img" })
|
||||
: briefHTML(bpn, { artMode: "img" });
|
||||
|
||||
const paymentOptions = [
|
||||
{ value: "", label: "Any" },
|
||||
{ value: "Per Squad", label: "Per Squad" },
|
||||
{ value: "Per Operative", label: "Per Operative" }
|
||||
].map((o) => ({ ...o, selected: o.value === this.state.paymentType }));
|
||||
|
||||
const sclOptions = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1].map((n) => ({
|
||||
value: n,
|
||||
label: `SCL ${n}${n === 10 ? " (lowest)" : n === 1 ? " (highest)" : ""}`,
|
||||
selected: n === bpn.sclLevel
|
||||
}));
|
||||
|
||||
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 }));
|
||||
|
||||
return {
|
||||
state: this.state,
|
||||
colourOptions,
|
||||
frameOptions,
|
||||
frameDisabled: !this.state.colourKey,
|
||||
paymentOptions,
|
||||
viewButtons,
|
||||
previewHTML
|
||||
const r = bpn.reward;
|
||||
const reward = {
|
||||
basisLabel: r.cbsBasis === "op" ? "per operative" : "per squad",
|
||||
cbsMin: r.cbsMin, cbsMax: r.cbsMax, cbsValue: r.cbsBasisValue,
|
||||
sclMin: r.sclMin, sclMax: r.sclMax, sclValue: r.sclIncreaseValue,
|
||||
cbsReadout: cbsReadoutFor(r),
|
||||
scliReadout: `${r.sclIncrease} SCL`
|
||||
};
|
||||
|
||||
return { state: this.state, colourOptions, frameOptions, paymentOptions, sclOptions, viewButtons, reward, previewHTML: previewHTMLFor(bpn, this.state.view) };
|
||||
}
|
||||
|
||||
_onRender() {
|
||||
@@ -109,18 +124,40 @@ export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2)
|
||||
root.querySelectorAll("[data-field]").forEach((el) => {
|
||||
el.addEventListener("change", () => {
|
||||
const f = el.dataset.field;
|
||||
this.state[f] = f === "squadSize" ? Number(el.value) || 4 : el.value;
|
||||
if (f === "colourKey") this.state.frameId = "";
|
||||
if (f === "squadSize") this.state.squadSize = Number(el.value) || 4;
|
||||
else if (f === "sclLevel") this.state.sclLevel = Number(el.value) || 10;
|
||||
else this.state[f] = el.value;
|
||||
// changing the band or payment basis resets the dialled rewards
|
||||
if (f === "colourKey") { this.state.frameId = ""; this.state.cbsValue = null; this.state.sclIncrease = null; }
|
||||
if (f === "paymentType") { this.state.cbsValue = null; this.state.sclIncrease = null; }
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
// sliders update the preview live, without a full re-render (keeps the grip)
|
||||
root.querySelectorAll("[data-slider]").forEach((el) => {
|
||||
el.addEventListener("input", () => {
|
||||
this.state[el.dataset.slider] = Number(el.value);
|
||||
this.#refreshPreview();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static #onReroll() { this.state.seed = randomSeed(); this.render(); }
|
||||
#refreshPreview() {
|
||||
const bpn = this._bpn();
|
||||
const host = this.element.querySelector(".sla-bpn-gen__preview");
|
||||
if (host) host.innerHTML = previewHTMLFor(bpn, this.state.view);
|
||||
const cbsR = this.element.querySelector('[data-readout="cbs"]');
|
||||
const scliR = this.element.querySelector('[data-readout="scli"]');
|
||||
if (cbsR) cbsR.textContent = cbsReadoutFor(bpn.reward);
|
||||
if (scliR) scliR.textContent = `${bpn.reward.sclIncrease} SCL`;
|
||||
}
|
||||
|
||||
static #onReroll() { this.state.seed = randomSeed(); this.state.cbsValue = null; this.state.sclIncrease = null; this.render(); }
|
||||
|
||||
static #onApplySeed() {
|
||||
const v = this.element.querySelector('[data-field="seed"]')?.value?.trim();
|
||||
if (v) this.state.seed = v;
|
||||
this.state.cbsValue = null; this.state.sclIncrease = null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
@@ -128,41 +165,40 @@ export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2)
|
||||
try { await navigator.clipboard.writeText(this.state.seed); ui.notifications.info("Seed copied."); } catch (_) {}
|
||||
}
|
||||
|
||||
static #onView(event, target) {
|
||||
this.state.view = target.dataset.view || "player";
|
||||
this.render();
|
||||
}
|
||||
static #onView(event, target) { this.state.view = target.dataset.view || "player"; this.render(); }
|
||||
|
||||
static async #onCreate() {
|
||||
const bpn = this._bpn();
|
||||
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 } },
|
||||
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() {
|
||||
@@ -173,7 +209,7 @@ export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2)
|
||||
<div>
|
||||
<div class="bpn-chat-card__band">${bpn.colour.label} BPN · ${bpn.colour.type}</div>
|
||||
<div class="bpn-chat-card__title">${bpn.shortTitle}</div>
|
||||
<div class="bpn-chat-card__meta">${bpn.bpnId} · SCL ${bpn.sclRequirement} · ${bpn.reward.grossContract}</div>
|
||||
<div class="bpn-chat-card__meta">${bpn.bpnId} · ${bpn.sclRequirement} · ${bpn.reward.grossContract}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="bpn-chat-card__brief">${foundry.utils.escapeHTML(bpn.missionBrief)}</p>
|
||||
|
||||
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
+162
-17
@@ -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 -------------------------------------------------- */
|
||||
|
||||
@@ -108,6 +109,19 @@ function mediaFromRoll(roll = 1) {
|
||||
return "GoreZone / Contract Circuit Special";
|
||||
}
|
||||
|
||||
function clampNum(n, lo, hi) {
|
||||
return Math.max(lo, Math.min(hi, n));
|
||||
}
|
||||
|
||||
/* For an open-ended band (Platinum has no max) we still need a finite slider
|
||||
ceiling — three times the floor reads as "and up". */
|
||||
function basisBounds(range) {
|
||||
const min = Number(range?.min ?? 0);
|
||||
const openEnded = range?.max == null;
|
||||
const max = openEnded ? Math.round(min * 3) : Number(range.max);
|
||||
return { min, max, openEnded };
|
||||
}
|
||||
|
||||
function rewardPackage(rng, colour, input) {
|
||||
const paymentTypeRoll = rollDie(rng, 6);
|
||||
const cbsTierRoll = rollDie(rng, 6);
|
||||
@@ -119,12 +133,18 @@ function rewardPackage(rng, colour, input) {
|
||||
const paymentType = input.paymentType && input.paymentType !== "__RANDOM__"
|
||||
? String(input.paymentType)
|
||||
: (paymentTypeRoll <= 4 ? "Per Squad" : "Per Operative");
|
||||
const cbsTier = tierFromRoll(cbsTierRoll);
|
||||
const sclTier = tierFromRoll(sclTierRoll);
|
||||
|
||||
const grossPerOp = creditsFromRange(colour.cbsPerOpRange, cbsTier);
|
||||
const grossPerSquad = creditsFromRange(colour.cbsPerSquadRange, cbsTier);
|
||||
const grossContractValue = paymentType === "Per Operative" ? grossPerOp * squadSize : grossPerSquad;
|
||||
// Credits are expressed per the chosen payment basis (per Operative or per
|
||||
// Squad). The slider runs across that band's range; an explicit cbsValue
|
||||
// (from the slider) overrides the rolled default.
|
||||
const basis = paymentType === "Per Operative" ? "op" : "squad";
|
||||
const range = basis === "op" ? colour.cbsPerOpRange : colour.cbsPerSquadRange;
|
||||
const { min: cbsMin, max: cbsMax, openEnded: cbsOpenEnded } = basisBounds(range);
|
||||
const rolledBasis = creditsFromRange(range, tierFromRoll(cbsTierRoll));
|
||||
const cbsBasisValue = input.cbsValue != null && Number.isFinite(Number(input.cbsValue))
|
||||
? clampNum(Math.round(Number(input.cbsValue)), cbsMin, cbsOpenEnded ? Math.max(cbsMax, Math.round(Number(input.cbsValue))) : cbsMax)
|
||||
: clampNum(rolledBasis, cbsMin, cbsMax);
|
||||
const grossContractValue = basis === "op" ? cbsBasisValue * squadSize : cbsBasisValue;
|
||||
|
||||
const financierCutPercent = input.financierCutPercent
|
||||
? Number(input.financierCutPercent)
|
||||
@@ -132,22 +152,37 @@ function rewardPackage(rng, colour, input) {
|
||||
const netContract = Math.max(0, Math.round(grossContractValue * (1 - financierCutPercent / 100)));
|
||||
const takeHomeEach = Math.max(1, Math.floor(netContract / squadSize));
|
||||
|
||||
// SCL increase also runs across the band's range; an explicit sclIncrease
|
||||
// (from the slider) overrides the rolled default.
|
||||
const sclMin = Number(colour.sclRange?.min ?? 0.05);
|
||||
const sclMax = Number(colour.sclRange?.max ?? colour.sclRange?.typical ?? 0.2);
|
||||
const sclByTier = {
|
||||
low: Number(colour.sclRange?.min ?? 0.1),
|
||||
mid: Number(colour.sclRange?.typical ?? colour.sclRange?.min ?? 0.1),
|
||||
high: Number(colour.sclRange?.max ?? colour.sclRange?.typical ?? 0.2)
|
||||
low: sclMin,
|
||||
mid: Number(colour.sclRange?.typical ?? sclMin),
|
||||
high: sclMax
|
||||
};
|
||||
const sclIncreaseValue = input.sclIncrease != null && Number.isFinite(Number(input.sclIncrease))
|
||||
? clampNum(Number(input.sclIncrease), sclMin, sclMax)
|
||||
: clampNum(sclByTier[tierFromRoll(sclTierRoll)] ?? sclByTier.mid, sclMin, sclMax);
|
||||
|
||||
return {
|
||||
paymentType,
|
||||
squadSize,
|
||||
basis,
|
||||
cbsBasisValue,
|
||||
cbsMin,
|
||||
cbsMax,
|
||||
cbsOpenEnded,
|
||||
grossContractValue,
|
||||
netContract,
|
||||
takeHomeEach,
|
||||
financierCutPercent,
|
||||
cbsBandOp: `${colour.cbsPerOpRange?.min ?? 0}c - ${colour.cbsPerOpRange?.max ?? "?"}c`,
|
||||
cbsBandSquad: `${colour.cbsPerSquadRange?.min ?? 0}c - ${colour.cbsPerSquadRange?.max ?? "?"}c`,
|
||||
sclIncrease: formatScl(sclByTier[sclTier] ?? sclByTier.mid),
|
||||
sclIncreaseValue,
|
||||
sclMin,
|
||||
sclMax,
|
||||
sclIncrease: formatScl(sclIncreaseValue),
|
||||
mediaCoverage: input.mediaCoverage && input.mediaCoverage !== "__RANDOM__"
|
||||
? String(input.mediaCoverage)
|
||||
: mediaFromRoll(mediaRoll),
|
||||
@@ -214,16 +249,94 @@ 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). */
|
||||
function clampScl(n) {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v)) return 10;
|
||||
return Math.max(1, Math.min(10, Math.round(v)));
|
||||
}
|
||||
|
||||
/** A sensible default level per band — the top (easiest) end of its range. */
|
||||
function defaultSclFromBand(colour) {
|
||||
const m = String(colour?.sclReq ?? "10").match(/\d+/);
|
||||
return clampScl(m ? Number(m[0]) : 10);
|
||||
}
|
||||
|
||||
/* ---- public API --------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Resolve a complete BPN from inputs.
|
||||
* @param {object} opts
|
||||
* @param {string} [opts.seed] Reproducibility seed; one is generated if absent.
|
||||
* @param {string} [opts.colourKey] Force a colour band; otherwise rolled on the d20 table.
|
||||
* @param {string} [opts.frameId] Force a scenario frame within the band.
|
||||
* @param {object} [opts.overrides] Any field to pin to a specific value (e.g. {location: "..."}).
|
||||
* @param {number} [opts.squadSize] Default 4.
|
||||
* @param {string} [opts.seed] Reproducibility seed; one is generated if absent.
|
||||
* @param {string} [opts.colourKey] Force a colour band; otherwise rolled on the d20 table.
|
||||
* @param {string} [opts.frameId] Force a scenario frame within the band.
|
||||
* @param {number} [opts.squadSize] Default 4.
|
||||
* @param {number} [opts.sclLevel] Operative SCL level 1–10; defaults to the band's top.
|
||||
* @param {number} [opts.cbsValue] Explicit credits for the chosen payment basis (slider).
|
||||
* @param {number} [opts.sclIncrease] Explicit SCL increase reward (slider).
|
||||
* @param {object} [opts.overrides] Any field to pin to a specific value (e.g. {location: "..."}).
|
||||
*/
|
||||
export function generateBPN(opts = {}) {
|
||||
const seed = take(opts.seed, randomSeed());
|
||||
@@ -245,7 +358,14 @@ export function generateBPN(opts = {}) {
|
||||
const frames = framesForColour(colour.key);
|
||||
const frame = (opts.frameId && frames.find((f) => f.id === opts.frameId)) || pick(rng, frames);
|
||||
|
||||
const reward = rewardPackage(rng, colour, { squadSize: opts.squadSize ?? ov.squadSize, ...ov });
|
||||
const reward = rewardPackage(rng, colour, {
|
||||
squadSize: opts.squadSize ?? ov.squadSize,
|
||||
...ov,
|
||||
cbsValue: opts.cbsValue ?? ov.cbsValue,
|
||||
sclIncrease: opts.sclIncrease ?? ov.sclIncrease
|
||||
});
|
||||
|
||||
const sclLevel = clampScl(opts.sclLevel ?? ov.sclLevel ?? defaultSclFromBand(colour));
|
||||
|
||||
const contactName = take(
|
||||
ov.contactName,
|
||||
@@ -262,7 +382,9 @@ export function generateBPN(opts = {}) {
|
||||
colour,
|
||||
issuingDepartment: take(ov.issuingDepartment, frame.issuingDepartment || pick(rng, TABLES.issuingDepartments)),
|
||||
squadSize: reward.squadSize,
|
||||
sclRequirement: take(ov.sclRequirement, colour.sclReq),
|
||||
sclLevel,
|
||||
sclRequirement: take(ov.sclRequirement, `SCL ${sclLevel}`),
|
||||
sclBandRange: colour.sclReq,
|
||||
|
||||
contact: {
|
||||
name: contactName,
|
||||
@@ -288,9 +410,17 @@ export function generateBPN(opts = {}) {
|
||||
financierCut: `${reward.financierCutPercent}%`,
|
||||
financierCutPercent: reward.financierCutPercent,
|
||||
sclIncrease: reward.sclIncrease,
|
||||
sclIncreaseValue: reward.sclIncreaseValue,
|
||||
sclMin: reward.sclMin,
|
||||
sclMax: reward.sclMax,
|
||||
mediaCoverage: reward.mediaCoverage,
|
||||
cbsBandOp: reward.cbsBandOp,
|
||||
cbsBandSquad: reward.cbsBandSquad
|
||||
cbsBandSquad: reward.cbsBandSquad,
|
||||
cbsBasis: reward.basis,
|
||||
cbsBasisValue: reward.cbsBasisValue,
|
||||
cbsMin: reward.cbsMin,
|
||||
cbsMax: reward.cbsMax,
|
||||
cbsOpenEnded: reward.cbsOpenEnded
|
||||
},
|
||||
|
||||
// Seam for a future NPC/monster module: archetype keys only, no stat schema.
|
||||
@@ -315,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) {
|
||||
|
||||
@@ -48,6 +48,34 @@
|
||||
}
|
||||
.sla-bpn-gen__row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
|
||||
.sla-bpn-gen__field input[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #b88a24, #d6b24e);
|
||||
cursor: pointer;
|
||||
}
|
||||
.sla-bpn-gen__field input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
background: #f4efe5; border: 2px solid #b88a24; cursor: pointer;
|
||||
}
|
||||
.sla-bpn-gen__field input[type="range"]::-moz-range-thumb {
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
background: #f4efe5; border: 2px solid #b88a24; cursor: pointer;
|
||||
}
|
||||
.sla-bpn-gen__readout {
|
||||
font-family: "Roboto Mono", monospace;
|
||||
font-size: 0.72rem;
|
||||
color: #d6b24e;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.sla-bpn-gen__btn {
|
||||
border-radius: 7px;
|
||||
border: 1px solid #2c333f;
|
||||
|
||||
@@ -27,6 +27,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sla-bpn-gen__field">
|
||||
<label>Operative SCL Level</label>
|
||||
<select data-field="sclLevel">
|
||||
{{#each sclOptions}}<option value="{{value}}" {{#if selected}}selected{{/if}}>{{label}}</option>{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="sla-bpn-gen__field">
|
||||
<label>Reward — {{reward.basisLabel}}</label>
|
||||
<input type="range" data-slider="cbsValue" min="{{reward.cbsMin}}" max="{{reward.cbsMax}}" step="5" value="{{reward.cbsValue}}">
|
||||
<div class="sla-bpn-gen__readout" data-readout="cbs">{{reward.cbsReadout}}</div>
|
||||
</div>
|
||||
|
||||
<div class="sla-bpn-gen__field">
|
||||
<label>SCL Increase Reward</label>
|
||||
<input type="range" data-slider="sclIncrease" min="{{reward.sclMin}}" max="{{reward.sclMax}}" step="0.05" value="{{reward.sclValue}}">
|
||||
<div class="sla-bpn-gen__readout" data-readout="scli">{{reward.scliReadout}}</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="sla-bpn-gen__btn sla-bpn-gen__btn--primary" data-action="reroll">
|
||||
<i class="fas fa-dice"></i> Re-roll (new seed)
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user