1 Commits
Author SHA1 Message Date
tevansandClaude Opus 4.8 88e8881d31 v0.2.0 — SCL-level chooser + credits / SCL-increase sliders
Re-vendors the updated engine and adds to the generator window: an Operative
SCL Level select, a credits slider (per chosen payment basis) and an SCL-
increase slider, both with live preview and no hardcoded numbers. The chosen
SCL level flows through to the briefing and journal output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 15:51:10 +01:00
5 changed files with 198 additions and 52 deletions
+1 -1
View File
@@ -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.2.0",
"compatibility": {
"minimum": "13",
"verified": "14.361",
+64 -34
View File
@@ -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.
*/
@@ -17,6 +18,14 @@ import { crestDataURI } from "../vendor/art/bpn-art.js";
const MODULE_ID = "sla-blueprint-news";
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
const previewHTMLFor = (bpn, view) =>
view === "gm" ? dossierHTML(bpn, { artMode: "img" })
: view === "both" ? briefHTML(bpn, { artMode: "img" }) + dossierHTML(bpn, { artMode: "img" })
: briefHTML(bpn, { artMode: "img" });
const 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 = {
seed: randomSeed(),
@@ -24,6 +33,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 +44,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 +66,53 @@ 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: "gm", label: "GM Dossier" },
{ view: "both", label: "Both" }
].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 +120,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,10 +161,7 @@ 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();
@@ -142,7 +172,7 @@ export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2)
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 } },
flags: { [MODULE_ID]: { seed: bpn.seed, colourKey: bpn.colourKey, frameId: bpn.gm.frameId, sclLevel: bpn.sclLevel } },
pages: [
{
name: "Mission Brief",
@@ -173,7 +203,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>
+86 -17
View File
@@ -108,6 +108,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 +132,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 +151,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 +248,34 @@ function buildMissionBrief(b) {
].join(" ");
}
/* ---- 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 +297,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 +321,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 +349,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.
+28
View File
@@ -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;
+19
View File
@@ -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>