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>
389 lines
16 KiB
JavaScript
389 lines
16 KiB
JavaScript
/* Blueprint News — engine-agnostic generation core.
|
||
*
|
||
* No DOM, no game-engine APIs. Given a seed (and optional overrides) it
|
||
* produces a fully-resolved BPN object that is identical every time for the
|
||
* same inputs — which lets the renderer and the procedural art stay in sync.
|
||
*
|
||
* The returned object deliberately separates what the squad is allowed to see
|
||
* (top-level fields) from what only the GM sees (the `gm` block).
|
||
*/
|
||
|
||
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";
|
||
|
||
/* ---- seeded randomness -------------------------------------------------- */
|
||
|
||
function hashSeed(str) {
|
||
let h = 2166136261 >>> 0;
|
||
const s = String(str);
|
||
for (let i = 0; i < s.length; i++) {
|
||
h ^= s.charCodeAt(i);
|
||
h = Math.imul(h, 16777619);
|
||
}
|
||
return h >>> 0;
|
||
}
|
||
|
||
function mulberry32(a) {
|
||
return function () {
|
||
a |= 0;
|
||
a = (a + 0x6d2b79f5) | 0;
|
||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
|
||
function makeRng(seed) {
|
||
return mulberry32(hashSeed(seed));
|
||
}
|
||
|
||
/** A fresh human-friendly seed, e.g. "BPN-7F3A-21C9". Uses Math.random by
|
||
* design — call this once at the app layer, then everything downstream is
|
||
* deterministic from the returned string. */
|
||
export function randomSeed() {
|
||
const block = () => Math.floor(Math.random() * 0xffff).toString(16).toUpperCase().padStart(4, "0");
|
||
return `BPN-${block()}-${block()}`;
|
||
}
|
||
|
||
/* ---- small helpers ------------------------------------------------------ */
|
||
|
||
function pick(rng, arr) {
|
||
return Array.isArray(arr) && arr.length ? arr[Math.floor(rng() * arr.length)] : "";
|
||
}
|
||
|
||
function rollDie(rng, faces) {
|
||
return Math.floor(rng() * faces) + 1;
|
||
}
|
||
|
||
function pad(n, width = 3) {
|
||
return String(n).padStart(width, "0");
|
||
}
|
||
|
||
function take(value, fallback = "") {
|
||
const v = String(value ?? "").trim();
|
||
return v || String(fallback ?? "").trim();
|
||
}
|
||
|
||
function clampSquad(value) {
|
||
const n = Number(value ?? 4);
|
||
if (!Number.isFinite(n)) return 4;
|
||
return Math.max(1, Math.min(8, Math.round(n)));
|
||
}
|
||
|
||
function formatScl(value = 0) {
|
||
const n = Number(value ?? 0);
|
||
if (!Number.isFinite(n)) return "+0.0";
|
||
const fixed = Math.abs(n) >= 1 ? n.toFixed(1) : n.toFixed(2);
|
||
const trimmed = fixed.replace(/\.?0+$/, "");
|
||
return `${n >= 0 ? "+" : ""}${trimmed}`;
|
||
}
|
||
|
||
/* ---- reward / SCL maths ------------------------------------------------- */
|
||
|
||
function creditsFromRange(range = { min: 0, max: 0 }, tier = "mid") {
|
||
const min = Number(range?.min ?? 0);
|
||
const max = range?.max == null ? null : Number(range.max);
|
||
if (!Number.isFinite(min)) return 0;
|
||
if (max === null || !Number.isFinite(max)) {
|
||
if (tier === "low") return Math.round(min);
|
||
if (tier === "mid") return Math.round(min * 1.15);
|
||
return Math.round(min * 1.35);
|
||
}
|
||
if (tier === "low") return Math.round(min);
|
||
if (tier === "high") return Math.round(max);
|
||
return Math.round((min + max) / 2);
|
||
}
|
||
|
||
function tierFromRoll(roll = 1) {
|
||
if (roll <= 2) return "low";
|
||
if (roll <= 4) return "mid";
|
||
return "high";
|
||
}
|
||
|
||
function mediaFromRoll(roll = 1) {
|
||
if (roll <= 1) return "None";
|
||
if (roll <= 3) return "Station Analysis";
|
||
if (roll <= 5) return "Third Eye News";
|
||
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);
|
||
const sclTierRoll = rollDie(rng, 6);
|
||
const mediaRoll = rollDie(rng, 6);
|
||
const financierRoll = rollDie(rng, 6);
|
||
|
||
const squadSize = clampSquad(input.squadSize);
|
||
const paymentType = input.paymentType && input.paymentType !== "__RANDOM__"
|
||
? String(input.paymentType)
|
||
: (paymentTypeRoll <= 4 ? "Per Squad" : "Per Operative");
|
||
|
||
// 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)
|
||
: (financierRoll <= 2 ? 10 : financierRoll <= 4 ? 15 : 20);
|
||
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: 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`,
|
||
sclIncreaseValue,
|
||
sclMin,
|
||
sclMax,
|
||
sclIncrease: formatScl(sclIncreaseValue),
|
||
mediaCoverage: input.mediaCoverage && input.mediaCoverage !== "__RANDOM__"
|
||
? String(input.mediaCoverage)
|
||
: mediaFromRoll(mediaRoll),
|
||
notes: `Financier cut assumed at ${financierCutPercent}%. Take-home is after the financier slice, split across ${squadSize} Operatives.`,
|
||
rolls: { paymentType: paymentTypeRoll, cbsTier: cbsTierRoll, sclTier: sclTierRoll, media: mediaRoll, financier: financierRoll }
|
||
};
|
||
}
|
||
|
||
/* ---- derived prose ------------------------------------------------------ */
|
||
|
||
function objectiveFromFocus(focusText = "") {
|
||
const t = String(focusText ?? "").toLowerCase();
|
||
if (t.includes("eliminate")) return "Neutralise the designated threat and confirm status.";
|
||
if (t.includes("escort") || t.includes("protect")) return "Keep the protected asset moving and alive through the full route.";
|
||
if (t.includes("retrieve")) return "Recover the designated item, data, or specimen intact and preserve chain of custody.";
|
||
if (t.includes("investigate")) return "Identify culprit(s), secure evidence, and produce an admissible corporate finding.";
|
||
if (t.includes("contain") || t.includes("quarantine")) return "Lock the threat down before it jumps sectors.";
|
||
if (t.includes("riot") || t.includes("crowd")) return "Restore order without losing the public narrative.";
|
||
if (t.includes("hunt")) return "Track, isolate, and capture or terminate the named target.";
|
||
if (t.includes("clean up")) return "Recover all traces of the prior SLA incident before public disclosure.";
|
||
if (t.includes("sabotage") || t.includes("destroy")) return "Disable the objective and leave a believable false story behind.";
|
||
return "Complete the assigned contract objective and report all outcomes.";
|
||
}
|
||
|
||
function cleanupDirective(colourKey = "blue") {
|
||
const k = String(colourKey ?? "blue").toLowerCase();
|
||
const map = {
|
||
blue: "Restore basic street function and hand the site back looking manageable.",
|
||
yellow: "Protect the recovered asset's chain of custody at all times.",
|
||
green: "Confirm every kill and leave nothing twitching for the cameras.",
|
||
white: "Return with evidence, witnesses, and a narrative command can sign off on.",
|
||
grey: "Keep the paperwork clean and the department's name out of the fallout.",
|
||
silver: "Keep public optics sponsor-safe from briefing to extraction.",
|
||
jade: "Seal contamination and route all samples through Karma only.",
|
||
red: "Stabilise the zone first, then explain it to the cameras.",
|
||
black: "Remove traces, minimise witnesses, and suppress operational signatures.",
|
||
platinum: "Preserve classification and return all material to Head Office only."
|
||
};
|
||
return map[k] ?? "Recover evidence, suppress compromising exposure, and file a command-ready debrief.";
|
||
}
|
||
|
||
/** Trim trailing whitespace/punctuation so clauses join cleanly. */
|
||
function trimEnd(s = "") {
|
||
return String(s).replace(/[.\s]+$/, "");
|
||
}
|
||
|
||
/** Lowercase only the first letter — keeps acronyms (SLA) and names (DarkNight). */
|
||
function lcFirst(s = "") {
|
||
const v = String(s);
|
||
return v.charAt(0).toLowerCase() + v.slice(1);
|
||
}
|
||
|
||
function buildMissionBrief(b) {
|
||
const objective = lcFirst(trimEnd(objectiveFromFocus(b.missionFocus)));
|
||
const loc = trimEnd(b.location);
|
||
const opp = lcFirst(trimEnd(b.opposition));
|
||
return [
|
||
`${b.issuingDepartment} has issued ${b.bpnId}, a ${b.colour.label.toUpperCase()} ${b.colour.type.toLowerCase()} contract.`,
|
||
`${b.contact.name} — ${b.contact.role} — wants the squad to ${objective}.`,
|
||
`Operational spine: ${loc}.`,
|
||
`${b.hook}`,
|
||
`Expect ${opp}.`,
|
||
`Media posture is ${b.reward.mediaCoverage.toLowerCase()}; command expects a clean narrative.`
|
||
].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 {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());
|
||
const rng = makeRng(seed);
|
||
const ov = opts.overrides ?? {};
|
||
|
||
// Colour band: forced, or rolled on the d20 issue table.
|
||
let colour;
|
||
let colourRoll = 0;
|
||
if (opts.colourKey) {
|
||
colour = colourByKey(opts.colourKey);
|
||
} else {
|
||
colourRoll = rollDie(rng, 20);
|
||
const row = COLOUR_ROLL_TABLE.find((r) => colourRoll >= r.min && colourRoll <= r.max) ?? COLOUR_ROLL_TABLE[0];
|
||
colour = colourByKey(row.key);
|
||
}
|
||
|
||
// Scenario frame within the band.
|
||
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,
|
||
cbsValue: opts.cbsValue ?? ov.cbsValue,
|
||
sclIncrease: opts.sclIncrease ?? ov.sclIncrease
|
||
});
|
||
|
||
const sclLevel = clampScl(opts.sclLevel ?? ov.sclLevel ?? defaultSclFromBand(colour));
|
||
|
||
const contactName = take(
|
||
ov.contactName,
|
||
`${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}`
|
||
);
|
||
|
||
const hookFallback = frame.colourMissionIdea || pick(rng, COLOUR_MISSION_IDEAS[colour.key] ?? []);
|
||
|
||
const bpn = {
|
||
seed,
|
||
bpnId: take(ov.bpnId, `${colour.label.toUpperCase().slice(0, 2)}-${pad(rollDie(rng, 999))}-${pad(rollDie(rng, 999))}`),
|
||
shortTitle: take(ov.shortTitle, frame.shortTitle),
|
||
colourKey: colour.key,
|
||
colour,
|
||
issuingDepartment: take(ov.issuingDepartment, frame.issuingDepartment || pick(rng, TABLES.issuingDepartments)),
|
||
squadSize: reward.squadSize,
|
||
sclLevel,
|
||
sclRequirement: take(ov.sclRequirement, `SCL ${sclLevel}`),
|
||
sclBandRange: colour.sclReq,
|
||
|
||
contact: {
|
||
name: contactName,
|
||
role: take(ov.contactRole, frame.contactRole),
|
||
location: take(ov.contactLocation, frame.contactLocation)
|
||
},
|
||
|
||
missionFocus: take(ov.missionFocus, frame.missionFocus || pick(rng, TABLES.missionFocus)),
|
||
hook: take(ov.hook, hookFallback),
|
||
objective: objectiveFromFocus(take(ov.missionFocus, frame.missionFocus)),
|
||
location: take(ov.location, frame.location || pick(rng, TABLES.locations)),
|
||
atmosphere: take(ov.atmosphere, frame.atmosphere || pick(rng, TABLES.atmospheres)),
|
||
opposition: take(ov.opposition, frame.opposition || pick(rng, TABLES.opposition)),
|
||
civilianFaction: take(ov.civilianFaction, frame.civilianFaction || pick(rng, TABLES.civilianFactions)),
|
||
extraObjective: take(ov.extraObjective, frame.extraObjective || pick(rng, TABLES.extraObjectives)),
|
||
cleanupDirective: cleanupDirective(colour.key),
|
||
|
||
reward: {
|
||
paymentType: reward.paymentType,
|
||
grossContract: `${reward.grossContractValue}c (${reward.paymentType})`,
|
||
grossContractValue: reward.grossContractValue,
|
||
takeHomeEach: `${reward.takeHomeEach}c each`,
|
||
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,
|
||
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.
|
||
npcSeam: {
|
||
contactArchetype: frame.contactArchetype ?? null,
|
||
threatArchetype: frame.threatArchetype ?? null,
|
||
threatRole: frame.threatRole ?? null
|
||
},
|
||
|
||
// GM-only truth. The renderer keeps this on a separate surface.
|
||
gm: {
|
||
complication: take(ov.complication, frame.complication || pick(rng, TABLES.complications)),
|
||
localTwist: take(ov.localTwist, frame.localTwist || pick(rng, TABLES.localTwists)),
|
||
oppositionAttitude: take(ov.oppositionAttitude, frame.oppositionAttitude || pick(rng, TABLES.oppositionAttitudes)),
|
||
escalationEvent: take(ov.escalationEvent, frame.escalationEvent || pick(rng, TABLES.escalationEvents)),
|
||
threatLead: take(ov.threatLead, frame.threatRole),
|
||
threatCountLabel: take(ov.threatCountLabel, frame.threatCountLabel),
|
||
financierNote: reward.notes,
|
||
frameId: frame.id,
|
||
rolls: { colour: colourRoll, ...reward.rolls }
|
||
}
|
||
};
|
||
|
||
bpn.missionBrief = take(ov.missionBrief, buildMissionBrief(bpn));
|
||
return bpn;
|
||
}
|