Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d8c9a0610 | ||
|
|
cd09c043b2 | ||
|
|
1271df8cd7 | ||
|
|
88e8881d31 |
@@ -1,12 +1,24 @@
|
||||
# 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 a **folder ("package")** containing several 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.
|
||||
- one **character-sheet entry per NPC** — a black-and-white "ident" portrait,
|
||||
who they are, their disposition, and why they matter (player handout).
|
||||
- one **card entry per OTEM item** — a line-art icon and a description.
|
||||
|
||||
plus the two core documents:
|
||||
|
||||
- **`… [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.4.1",
|
||||
"compatibility": {
|
||||
"minimum": "13",
|
||||
"verified": "14.361",
|
||||
|
||||
+109
-56
@@ -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 { crestDataURI } from "../vendor/art/bpn-art.js";
|
||||
import { briefHTML, fieldDossierHTML, dossierHTML, npcCardHTML, itemCardHTML } from "../vendor/render/bpn-render.js";
|
||||
import { crestDataURI, npcPortraitDataURI, itemIconDataURI } 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,57 @@ 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 accent = bpn.colour.accent;
|
||||
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}`,
|
||||
ownership: { default: O.OBSERVER },
|
||||
flags: { [MODULE_ID]: { seed: bpn.seed, colourKey: bpn.colourKey, frameId: bpn.gm.frameId } },
|
||||
// A folder is the "package" that holds the whole briefing's documents.
|
||||
const folder = await Folder.create({ name: base, type: "JournalEntry", color: accent });
|
||||
const fid = folder.id;
|
||||
|
||||
// Separate player document: Mission Brief + Field Dossier handout.
|
||||
const playerEntry = await JournalEntry.create({
|
||||
name: `${base} [Player]`, folder: fid, ownership: { default: O.OBSERVER }, 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.
|
||||
await JournalEntry.create({
|
||||
name: `${base} [GM]`, folder: fid, 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)]
|
||||
});
|
||||
|
||||
// One character-sheet document per NPC (player handout, portrait + sheet).
|
||||
const npcEntries = bpn.cast.map((npc) => ({
|
||||
name: npc.name, folder: fid,
|
||||
ownership: { default: O.OBSERVER },
|
||||
flags: { [MODULE_ID]: { kind: "npc", seed: npc.seed, bpn: bpn.seed } },
|
||||
pages: [page("Character Sheet", wrap(npcCardHTML(npc, { artMode: "img", accent })), O.OBSERVER)]
|
||||
}));
|
||||
|
||||
// One card document per item (player handout, icon + description).
|
||||
const itemEntries = bpn.otem.map((item) => ({
|
||||
name: item.name, folder: fid,
|
||||
ownership: { default: O.OBSERVER },
|
||||
flags: { [MODULE_ID]: { kind: "otem", seed: item.seed, bpn: bpn.seed } },
|
||||
pages: [page(item.name, wrap(itemCardHTML(item, { artMode: "img", accent })), O.OBSERVER)]
|
||||
}));
|
||||
|
||||
await JournalEntry.createDocuments([...npcEntries, ...itemEntries]);
|
||||
|
||||
ui.notifications.info(`Created package "${base}": player + GM documents, ${bpn.cast.length} NPC sheets and ${bpn.otem.length} item cards in a folder.`);
|
||||
playerEntry.sheet?.render(true);
|
||||
}
|
||||
|
||||
static async #onChat() {
|
||||
@@ -173,7 +226,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>
|
||||
|
||||
+18
-13
@@ -33,27 +33,32 @@ Hooks.on("renderJournalDirectory", (_app, html) => {
|
||||
header.appendChild(btn);
|
||||
});
|
||||
|
||||
/* Notes-layer toolbar button as a second entry point (v13/v14 control shape). */
|
||||
/* Token-group toolbar button (GM only). v14 controls is a keyed object and
|
||||
tools fire via onChange; v13 is an array. */
|
||||
Hooks.on("getSceneControlButtons", (controls) => {
|
||||
if (!game.user?.isGM) return;
|
||||
const open = () => BlueprintNewsApp.open();
|
||||
const tool = {
|
||||
name: "slaBpnOpen",
|
||||
title: game.i18n.localize("SLABPN.ToolbarOpen"),
|
||||
icon: "fas fa-file-contract",
|
||||
visible: true,
|
||||
button: true,
|
||||
onClick: () => BlueprintNewsApp.open()
|
||||
toggle: false,
|
||||
onChange: open,
|
||||
onClick: open
|
||||
};
|
||||
if (controls && typeof controls === "object" && !Array.isArray(controls)) {
|
||||
const notes = controls.notes;
|
||||
if (notes) {
|
||||
notes.tools ??= {};
|
||||
notes.tools[tool.name] ??= tool;
|
||||
}
|
||||
} else if (Array.isArray(controls)) {
|
||||
const notes = controls.find((c) => c.name === "notes");
|
||||
if (notes) {
|
||||
notes.tools ??= [];
|
||||
if (!notes.tools.some((t) => t.name === tool.name)) notes.tools.push(tool);
|
||||
if (controls && !Array.isArray(controls)) {
|
||||
const grp = controls.tokens ?? controls.token ?? null;
|
||||
if (grp) {
|
||||
grp.tools ??= {};
|
||||
grp.tools[tool.name] = tool;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const grp = controls.find((c) => c.name === "token" || c.name === "tokens");
|
||||
if (grp) {
|
||||
if (Array.isArray(grp.tools)) grp.tools.push(tool);
|
||||
else { grp.tools ??= {}; grp.tools[tool.name] = tool; }
|
||||
}
|
||||
});
|
||||
|
||||
Vendored
+168
@@ -331,6 +331,165 @@ export function portraitSVG(bpn, { size = 120 } = {}) {
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* 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 = `<ellipse cx="${cx}" cy="${cy}" rx="${rx}" ry="${ry}" fill="${skin}" stroke="${ink}" stroke-width="2"/>`;
|
||||
const jaw = `<path d="M${cx - rx + 2} ${cy + ry - 8} Q${cx} ${cy + ry + 6} ${cx + rx - 2} ${cy + ry - 8}" ${stroke(1.5)}/>`;
|
||||
|
||||
let hair = "";
|
||||
if (top === "hairShort") hair = `<path d="M${cx - rx - 2} ${cy - 6} Q${cx} ${cy - ry - 12} ${cx + rx + 2} ${cy - 6} Q${cx + rx - 6} ${cy - ry + 4} ${cx} ${cy - ry + 2} Q${cx - rx + 6} ${cy - ry + 4} ${cx - rx - 2} ${cy - 6} Z" fill="${ink}" opacity="0.9"/>`;
|
||||
else if (top === "hairCrop") hair = `<path d="M${cx - rx} ${cy - ry + 8} Q${cx} ${cy - ry - 4} ${cx + rx} ${cy - ry + 8}" ${stroke(5)}/>`;
|
||||
else if (top === "bald") hair = `<path d="M${cx - 8} ${cy - ry + 4} Q${cx} ${cy - ry - 2} ${cx + 8} ${cy - ry + 4}" ${stroke(1)} opacity="0.5"/>`;
|
||||
else if (top === "hood") hair = `<path d="M${cx - rx - 10} ${cy + 8} Q${cx - rx - 12} ${cy - ry - 18} ${cx} ${cy - ry - 20} Q${cx + rx + 12} ${cy - ry - 18} ${cx + rx + 10} ${cy + 8}" ${stroke(2.5)}/>`;
|
||||
else if (top === "helmet") hair = `<path d="M${cx - rx - 3} ${cy - 2} Q${cx} ${cy - ry - 14} ${cx + rx + 3} ${cy - 2}" ${stroke(3)}/><line x1="${cx}" y1="${cy - ry - 10}" x2="${cx}" y2="${cy - 4}" ${stroke(1.5)}/>`;
|
||||
else if (top === "cap") hair = `<path d="M${cx - rx - 6} ${cy - ry + 10} H${cx + rx + 2} Q${cx} ${cy - ry - 8} ${cx - rx - 6} ${cy - ry + 10} Z" fill="${ink}" opacity="0.85"/>`;
|
||||
|
||||
const ey = cy - 4;
|
||||
let eyeMarks = "";
|
||||
if (eyes === "plain") eyeMarks = `<line x1="${cx - 13}" y1="${ey}" x2="${cx - 6}" y2="${ey}" ${stroke(2)}/><line x1="${cx + 6}" y1="${ey}" x2="${cx + 13}" y2="${ey}" ${stroke(2)}/><line x1="${cx - 14}" y1="${ey - 6}" x2="${cx - 5}" y2="${ey - 7}" ${stroke(1.2)}/><line x1="${cx + 5}" y1="${ey - 7}" x2="${cx + 14}" y2="${ey - 6}" ${stroke(1.2)}/>`;
|
||||
else if (eyes === "visor") eyeMarks = `<rect x="${cx - 16}" y="${ey - 4}" width="32" height="8" rx="4" fill="${dark}" stroke="${ink}" stroke-width="2"/><line x1="${cx - 12}" y1="${ey}" x2="${cx + 12}" y2="${ey}" stroke="${ink}" stroke-width="1" opacity="0.5"/>`;
|
||||
else if (eyes === "goggles") eyeMarks = `<circle cx="${cx - 9}" cy="${ey}" r="5.5" ${stroke(2)}/><circle cx="${cx + 9}" cy="${ey}" r="5.5" ${stroke(2)}/><line x1="${cx - 3.5}" y1="${ey}" x2="${cx + 3.5}" y2="${ey}" ${stroke(2)}/>`;
|
||||
else eyeMarks = `<line x1="${cx - 13}" y1="${ey}" x2="${cx - 6}" y2="${ey}" ${stroke(2)}/><rect x="${cx + 5}" y="${ey - 4}" width="10" height="8" rx="1.5" fill="${dark}" stroke="${ink}" stroke-width="2"/><line x1="${cx + 7}" y1="${ey}" x2="${cx + 13}" y2="${ey}" stroke="${ink}" stroke-width="1"/>`;
|
||||
|
||||
const nose = `<path d="M${cx} ${ey + 4} L${cx - 3} ${cy + 8} L${cx + 2} ${cy + 8}" ${stroke(1.4)}/>`;
|
||||
|
||||
const my = cy + 17;
|
||||
let mouthMark = "";
|
||||
if (mouth === "neutral") mouthMark = `<line x1="${cx - 7}" y1="${my}" x2="${cx + 7}" y2="${my}" ${stroke(1.8)}/>`;
|
||||
else if (mouth === "grim") mouthMark = `<path d="M${cx - 8} ${my - 1} Q${cx} ${my + 3} ${cx + 8} ${my - 1}" ${stroke(1.8)}/>`;
|
||||
else if (mouth === "smirk") mouthMark = `<path d="M${cx - 7} ${my + 1} Q${cx} ${my - 1} ${cx + 8} ${my - 3}" ${stroke(1.8)}/>`;
|
||||
else mouthMark = `<rect x="${cx - 12}" y="${my - 7}" width="24" height="15" rx="4" fill="${dark}" stroke="${ink}" stroke-width="2"/><line x1="${cx - 12}" y1="${my}" x2="${cx - 22}" y2="${my - 4}" ${stroke(1.5)}/><line x1="${cx + 12}" y1="${my}" x2="${cx + 22}" y2="${my - 4}" ${stroke(1.5)}/><circle cx="${cx}" cy="${my}" r="2" fill="${ink}"/>`;
|
||||
|
||||
let extraMark = "";
|
||||
if (extra === "earpiece") extraMark = `<circle cx="${cx + rx - 1}" cy="${ey + 4}" r="3" ${stroke(1.5)}/><line x1="${cx + rx + 1}" y1="${ey + 6}" x2="${cx + rx + 6}" y2="${ey + 14}" ${stroke(1.5)}/>`;
|
||||
else if (extra === "scar") extraMark = `<line x1="${cx - 12}" y1="${cy - 14}" x2="${cx - 4}" y2="${cy + 6}" ${stroke(1.5)}/>`;
|
||||
else if (extra === "collar") extraMark = `<path d="M${cx - 22} 168 L${cx - 8} ${cy + ry + 6} M${cx + 22} 168 L${cx + 8} ${cy + ry + 6}" ${stroke(2)}/>`;
|
||||
|
||||
// light hatch shading down the right cheek
|
||||
const hatch = Array.from({ length: 4 }, (_, i) =>
|
||||
`<line x1="${cx + 6 + i * 3}" y1="${cy - 6 + i * 2}" x2="${cx + 10 + i * 3}" y2="${cy + 10 + i * 2}" stroke="${ink}" stroke-width="1" opacity="0.18"/>`).join("");
|
||||
|
||||
const shoulders = `<path d="M22 190 V164 C22 142 48 130 84 130 C120 130 146 142 146 164 V190 Z" fill="${skin}" stroke="${ink}" stroke-width="2"/>`;
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 168 190" width="${size}" height="${Math.round(size * 190 / 168)}" role="img" aria-label="${npc?.name ?? "ident"} portrait">
|
||||
<rect x="2" y="2" width="164" height="186" rx="10" fill="${dark}" stroke="${rgba(accent, 0.45)}" stroke-width="2"/>
|
||||
<rect x="2" y="2" width="164" height="16" rx="10" fill="${rgba(accent, 0.18)}"/>
|
||||
<text x="10" y="14" font-family="Roboto Mono, monospace" font-size="9" letter-spacing="2" fill="${rgba(accent, 0.85)}">IDENT</text>
|
||||
${shoulders}
|
||||
${extra === "collar" ? extraMark : ""}
|
||||
${head}${jaw}${hatch}
|
||||
${hair}
|
||||
${eyeMarks}${nose}${mouthMark}
|
||||
${extra !== "collar" ? extraMark : ""}
|
||||
<line x1="10" y1="178" x2="158" y2="178" stroke="${rgba(accent, 0.3)}" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* 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: `<rect x="34" y="24" width="32" height="52" rx="4" ${s()}/><line x1="40" y1="36" x2="60" y2="36" ${s(1.6)}/><line x1="40" y1="44" x2="60" y2="44" ${s(1.6)}/><line x1="40" y1="52" x2="54" y2="52" ${s(1.6)}/><circle cx="50" cy="68" r="2.2" fill="${ink}"/>`,
|
||||
canister: `<rect x="38" y="26" width="24" height="48" rx="6" ${s()}/><ellipse cx="50" cy="26" rx="12" ry="4" ${s(1.6)}/><line x1="38" y1="46" x2="62" y2="46" ${s(1.6)}/><rect x="44" y="18" width="12" height="8" rx="2" ${s(1.6)}/>`,
|
||||
weapon: `<path d="M28 44 H64 V54 H50 L46 64 H40 L42 54 H28 Z" ${s()}/><line x1="34" y1="44" x2="34" y2="38" ${s(1.6)}/>`,
|
||||
chip: `<rect x="34" y="34" width="32" height="32" rx="3" ${s()}/><rect x="42" y="42" width="16" height="16" rx="2" ${s(1.6)}/><line x1="38" y1="30" x2="38" y2="34" ${s(1.4)}/><line x1="50" y1="30" x2="50" y2="34" ${s(1.4)}/><line x1="62" y1="30" x2="62" y2="34" ${s(1.4)}/><line x1="38" y1="66" x2="38" y2="70" ${s(1.4)}/><line x1="62" y1="66" x2="62" y2="70" ${s(1.4)}/>`,
|
||||
crate: `<rect x="28" y="32" width="44" height="36" rx="3" ${s()}/><line x1="28" y1="50" x2="72" y2="50" ${s(1.6)}/><line x1="50" y1="32" x2="50" y2="68" ${s(1.6)}/><circle cx="34" cy="38" r="1.8" fill="${ink}"/><circle cx="66" cy="38" r="1.8" fill="${ink}"/>`,
|
||||
bug: `<ellipse cx="50" cy="52" rx="10" ry="14" ${s()}/><circle cx="50" cy="36" r="5" ${s(1.6)}/><line x1="47" y1="32" x2="44" y2="26" ${s(1.4)}/><line x1="53" y1="32" x2="56" y2="26" ${s(1.4)}/><line x1="40" y1="46" x2="30" y2="42" ${s(1.4)}/><line x1="40" y1="56" x2="30" y2="58" ${s(1.4)}/><line x1="60" y1="46" x2="70" y2="42" ${s(1.4)}/><line x1="60" y1="56" x2="70" y2="58" ${s(1.4)}/>`,
|
||||
component: `<circle cx="50" cy="50" r="14" ${s()}/><circle cx="50" cy="50" r="5" ${s(1.6)}/><g stroke="${ink}" stroke-width="2.4" stroke-linecap="round">${Array.from({ length: 8 }, (_, i) => { const a = i / 8 * Math.PI * 2; return `<line x1="${50 + Math.cos(a) * 14}" y1="${50 + Math.sin(a) * 14}" x2="${50 + Math.cos(a) * 19}" y2="${50 + Math.sin(a) * 19}"/>`; }).join("")}</g>`,
|
||||
relay: `<line x1="50" y1="30" x2="50" y2="70" ${s()}/><rect x="44" y="68" width="12" height="6" ${s(1.6)}/><path d="M40 34 Q50 24 60 34" ${s(1.6)}/><path d="M36 30 Q50 16 64 30" ${s(1.4)}/><circle cx="50" cy="30" r="2.5" fill="${ink}"/>`,
|
||||
kit: `<rect x="30" y="36" width="40" height="30" rx="4" ${s()}/><line x1="42" y1="36" x2="42" y2="30" ${s(1.6)}/><line x1="58" y1="36" x2="58" y2="30" ${s(1.6)}/><line x1="50" y1="44" x2="50" y2="58" ${s(2)}/><line x1="43" y1="51" x2="57" y2="51" ${s(2)}/>`,
|
||||
drone: `<rect x="44" y="44" width="12" height="12" rx="2" ${s()}/><line x1="44" y1="44" x2="32" y2="32" ${s(1.6)}/><line x1="56" y1="44" x2="68" y2="32" ${s(1.6)}/><line x1="44" y1="56" x2="32" y2="68" ${s(1.6)}/><line x1="56" y1="56" x2="68" y2="68" ${s(1.6)}/><circle cx="30" cy="30" r="4" ${s(1.6)}/><circle cx="70" cy="30" r="4" ${s(1.6)}/><circle cx="30" cy="70" r="4" ${s(1.6)}/><circle cx="70" cy="70" r="4" ${s(1.6)}/>`,
|
||||
key: `<circle cx="38" cy="50" r="10" ${s()}/><line x1="48" y1="50" x2="72" y2="50" ${s()}/><line x1="66" y1="50" x2="66" y2="58" ${s(1.8)}/><line x1="72" y1="50" x2="72" y2="60" ${s(1.8)}/>`,
|
||||
card: `<rect x="28" y="36" width="44" height="30" rx="3" ${s()}/><rect x="33" y="42" width="12" height="14" rx="1.5" ${s(1.6)}/><line x1="50" y1="44" x2="66" y2="44" ${s(1.4)}/><line x1="50" y1="50" x2="66" y2="50" ${s(1.4)}/><line x1="50" y1="56" x2="60" y2="56" ${s(1.4)}/>`
|
||||
};
|
||||
const inner = glyphs[cat] ?? glyphs.slate;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="${size}" height="${size}" role="img" aria-label="${item?.name ?? "item"} icon">
|
||||
<rect x="2" y="2" width="96" height="96" rx="10" fill="${dark}" stroke="${rgba(accent, 0.45)}" stroke-width="2"/>
|
||||
${inner}
|
||||
</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 +501,12 @@ 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))}`;
|
||||
}
|
||||
export function npcPortraitDataURI(npc, opts) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(npcPortraitSVG(npc, opts))}`;
|
||||
}
|
||||
export function itemIconDataURI(item, opts) {
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(itemIconSVG(item, opts))}`;
|
||||
}
|
||||
|
||||
Vendored
+199
-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,130 @@ 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);
|
||||
}
|
||||
|
||||
/** Classify an OTEM name into an icon category for the line-art item icon. */
|
||||
function otemCategory(name = "") {
|
||||
const n = String(name).toLowerCase();
|
||||
if (n.includes("slate") || n.includes("data")) return "slate";
|
||||
if (n.includes("canister") || n.includes("sample")) return "canister";
|
||||
if (n.includes("weapon") || n.includes("cache")) return "weapon";
|
||||
if (n.includes("chip") || n.includes("credential")) return "chip";
|
||||
if (n.includes("crate") || n.includes("evidence")) return "crate";
|
||||
if (n.includes("bug") || n.includes("surveillance")) return "bug";
|
||||
if (n.includes("component") || n.includes("prototype")) return "component";
|
||||
if (n.includes("relay") || n.includes("comms")) return "relay";
|
||||
if (n.includes("med") || n.includes("kit")) return "kit";
|
||||
if (n.includes("drone") || n.includes("camera")) return "drone";
|
||||
if (n.includes("key")) return "key";
|
||||
if (n.includes("card") || n.includes("id")) return "card";
|
||||
return "slate";
|
||||
}
|
||||
|
||||
/** Build the cast, OTEM and locations. Each NPC is a small character sheet
|
||||
* (description, significance, appearance + a stable seed for its portrait);
|
||||
* each item carries a description + category for its line-art icon. Player
|
||||
* facing fields plus a GM-only `gm` secret. */
|
||||
function buildDetail(rng, ctx) {
|
||||
const makeNpc = (i, base) => {
|
||||
const build = pick(rng, DETAIL.npcBuilds);
|
||||
const feature = pick(rng, DETAIL.npcFeatures);
|
||||
const habit = pick(rng, DETAIL.npcHabits);
|
||||
const role = base.role ?? pick(rng, DETAIL.npcRoles);
|
||||
return {
|
||||
seed: `${ctx.seed}::npc::${i}`,
|
||||
name: base.name,
|
||||
role,
|
||||
disposition: base.disposition ?? pick(rng, DETAIL.npcDispositions),
|
||||
appearance: { build, feature, habit },
|
||||
description: `${ucFirst(build)} ${role.toLowerCase()} with ${feature}; ${habit}.`,
|
||||
significance: base.significance ?? pick(rng, DETAIL.npcSignificance),
|
||||
player: base.player ?? pick(rng, DETAIL.npcPlayerNotes),
|
||||
gm: base.gm ?? pick(rng, DETAIL.npcSecrets)
|
||||
};
|
||||
};
|
||||
|
||||
const cast = [
|
||||
makeNpc(0, {
|
||||
name: ctx.contactName,
|
||||
role: ctx.contactRole,
|
||||
disposition: "Cooperative",
|
||||
significance: "The squad's authorised contact on the ground.",
|
||||
player: `On-site contact, holding at ${lcFirst(trimEnd(ctx.contactLocation))}.`
|
||||
}),
|
||||
makeNpc(1, {
|
||||
name: ctx.threatLead || "Reported hostile",
|
||||
role: "Designated threat",
|
||||
disposition: "Openly hostile",
|
||||
significance: "The hostile command flagged as the squad's primary problem.",
|
||||
player: ctx.threatCount || "Hostile lead flagged by command.",
|
||||
gm: ctx.oppositionAttitude ? `Really: ${lcFirst(ctx.oppositionAttitude)}` : pick(rng, DETAIL.npcSecrets)
|
||||
}),
|
||||
makeNpc(2, { name: `${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}` }),
|
||||
makeNpc(3, { name: `${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}` })
|
||||
];
|
||||
|
||||
const otem = sample(rng, DETAIL.otemNames, 3).map((name, i) => ({
|
||||
seed: `${ctx.seed}::otem::${i}`,
|
||||
name,
|
||||
category: otemCategory(name),
|
||||
description: pick(rng, DETAIL.otemDescriptions),
|
||||
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 +394,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 +418,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 +446,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 +481,21 @@ 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, {
|
||||
seed,
|
||||
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
+128
@@ -0,0 +1,128 @@
|
||||
/* 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."
|
||||
],
|
||||
npcBuilds: [
|
||||
"tall and gaunt", "short and wiry", "broad and scarred", "lean and twitchy",
|
||||
"heavyset and unhurried", "compact and precise", "stooped and tired", "rangy and restless"
|
||||
],
|
||||
npcFeatures: [
|
||||
"a battered breather mask", "a mirrored visor", "a shaved, tattooed scalp",
|
||||
"a long oilcloth coat", "a crude augmetic eye", "cropped iron-grey hair",
|
||||
"a jaw brace and old burns", "corporate-neat hair and a dead stare",
|
||||
"a hood pulled low", "a wired earpiece they keep touching"
|
||||
],
|
||||
npcHabits: [
|
||||
"never stops watching the exits", "talks too fast when cornered",
|
||||
"keeps one hand near a concealed weapon", "quotes regulations at everyone",
|
||||
"won't make eye contact", "smiles at exactly the wrong moments",
|
||||
"flinches at every comms burst", "counts the squad's gear without thinking"
|
||||
],
|
||||
npcSignificance: [
|
||||
"Holds the access the squad needs to move at all.",
|
||||
"Knows the layout better than the map command supplied.",
|
||||
"Can vouch for the squad — or burn them with one word.",
|
||||
"Is the only working line back to the contact.",
|
||||
"Controls who gets in and out of the site.",
|
||||
"Has eyes on the objective right now.",
|
||||
"Is the loose thread the whole job hangs on.",
|
||||
"Will be the first casualty if things go loud."
|
||||
],
|
||||
|
||||
/* ---- item descriptions ---- */
|
||||
otemDescriptions: [
|
||||
"Standard-issue casing, non-standard contents — somebody went to trouble over this.",
|
||||
"Looks ordinary until you notice the tamper seals have been replaced twice.",
|
||||
"Heavier than it should be, and warm to the touch.",
|
||||
"Marked with a department code that was retired years ago.",
|
||||
"Scuffed and field-worn, clearly passed through several hands.",
|
||||
"Pristine, sealed, and very obviously expensive.",
|
||||
"Jury-rigged from three other things that were never meant to meet.",
|
||||
"Quiet now, but the indicator light says it is still doing something."
|
||||
],
|
||||
|
||||
/* ---- 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
+95
-4
@@ -12,8 +12,8 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
crestSVG, bannerSVG, portraitSVG,
|
||||
crestDataURI, bannerDataURI, portraitDataURI
|
||||
crestSVG, bannerSVG, portraitSVG, siteMapSVG, npcPortraitSVG, itemIconSVG,
|
||||
crestDataURI, bannerDataURI, portraitDataURI, siteMapDataURI, npcPortraitDataURI, itemIconDataURI
|
||||
} from "../art/bpn-art.js";
|
||||
|
||||
function esc(value) {
|
||||
@@ -120,6 +120,81 @@ 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. */
|
||||
|
||||
/** A simple NPC character-sheet card: portrait, who they are, why they matter. */
|
||||
export function npcCardHTML(npc, { artMode = "inline", accent = "#9fb0c4" } = {}) {
|
||||
const art = artMode === "img"
|
||||
? `<img src="${npcPortraitDataURI(npc, { size: 168, accent })}" alt="${esc(npc.name)}">`
|
||||
: npcPortraitSVG(npc, { size: 168, accent });
|
||||
return `<div class="bpn-npc-card">
|
||||
<div class="bpn-npc-card__art">${art}</div>
|
||||
<div class="bpn-npc-card__body">
|
||||
<div class="bpn-npc-card__name">${esc(npc.name)}</div>
|
||||
<div class="bpn-npc-card__role">${esc(npc.role)} · ${esc(npc.disposition)}</div>
|
||||
<p class="bpn-npc-card__desc">${esc(npc.description)}</p>
|
||||
<div class="bpn-npc-card__sig"><span class="bpn-label">Why they matter</span>${esc(npc.significance)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** A simple item card: line-art icon, what it is, a description. */
|
||||
export function itemCardHTML(item, { artMode = "inline", accent = "#9fb0c4" } = {}) {
|
||||
const art = artMode === "img"
|
||||
? `<img src="${itemIconDataURI(item, { size: 120, accent })}" alt="${esc(item.name)}">`
|
||||
: itemIconSVG(item, { size: 120, accent });
|
||||
return `<div class="bpn-item-card">
|
||||
<div class="bpn-item-card__art">${art}</div>
|
||||
<div class="bpn-item-card__body">
|
||||
<div class="bpn-item-card__name">${esc(item.name)}</div>
|
||||
<p class="bpn-item-card__desc">${esc(item.description)}</p>
|
||||
<div class="bpn-item-card__note">${esc(item.player)}</div>
|
||||
</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-card-grid">${bpn.cast.map((n) => npcCardHTML(n, { artMode, accent: c.accent })).join("")}</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="bpn-section__title">OTEM · Items & Equipment</h2>
|
||||
<div class="bpn-card-grid bpn-card-grid--items">${bpn.otem.map((o) => itemCardHTML(o, { artMode, accent: c.accent })).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 +258,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 +291,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,122 @@
|
||||
}
|
||||
.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; }
|
||||
|
||||
/* NPC + item cards */
|
||||
.bpn-card-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
.bpn-card-grid--items { grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); }
|
||||
|
||||
.bpn-npc-card, .bpn-item-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel));
|
||||
border: 1px solid var(--bpn-line);
|
||||
}
|
||||
.bpn-npc-card { grid-template-columns: 96px 1fr; }
|
||||
.bpn-item-card { grid-template-columns: 72px 1fr; align-items: start; }
|
||||
|
||||
.bpn-npc-card__art { width: 96px; }
|
||||
.bpn-item-card__art { width: 72px; }
|
||||
.bpn-npc-card__art svg, .bpn-npc-card__art img,
|
||||
.bpn-item-card__art svg, .bpn-item-card__art img { width: 100%; height: auto; display: block; border-radius: 8px; }
|
||||
|
||||
.bpn-npc-card__name, .bpn-item-card__name {
|
||||
font-family: var(--bpn-head);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
color: color-mix(in srgb, var(--bpn-accent) 24%, var(--bpn-text));
|
||||
}
|
||||
.bpn-npc-card__role {
|
||||
font-family: var(--bpn-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bpn-muted);
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
.bpn-npc-card__desc, .bpn-item-card__desc { margin: 0 0 8px; font-size: 0.98rem; line-height: 1.5; color: var(--bpn-text); }
|
||||
.bpn-npc-card__sig .bpn-label { margin-bottom: 2px; }
|
||||
.bpn-npc-card__sig { font-size: 0.95rem; color: var(--bpn-text); }
|
||||
.bpn-item-card__note { font-family: var(--bpn-mono); font-size: 0.78rem; color: var(--bpn-muted); }
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bpn-npc-card { grid-template-columns: 72px 1fr; }
|
||||
.bpn-npc-card__art { width: 72px; }
|
||||
}
|
||||
|
||||
/* ===================== GM DOSSIER ===================== */
|
||||
|
||||
.bpn-dossier .bpn-doc { box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); }
|
||||
@@ -356,6 +472,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 +497,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