diff --git a/slazero2.mjs b/slazero2.mjs index 7c1db86..96a0a34 100644 --- a/slazero2.mjs +++ b/slazero2.mjs @@ -3950,6 +3950,7 @@ Hooks.on("getSceneControlButtons", (controls) => { { name: "sla-threat", title: "NPC Threat Board", icon: "fas fa-skull", fn: () => SLANPCThreatBoard.open() }, { name: "sla-bpn", title: "BPN Tracker", icon: "fas fa-clipboard-check", fn: () => SLABPNTracker.open() }, { name: "sla-condapp", title: "Condition Applicator", icon: "fas fa-bolt", fn: () => SLAConditionApplicator.open() }, + { name: "sla-bpn-gen", title: "Generate BPN", icon: "fas fa-file-signature", fn: () => SLABPNGenerator.open() }, ]; // Foundry v14: controls is a plain object keyed by group name @@ -10589,3 +10590,241 @@ Conditions tick automatically. When Health or Resolve breaks, the d66 critical r await game.settings.set("slazero2", FLAG_KEY, true); }); + +// ══════════════════════════════════════════════════════════════════════════════ +// SLA ZERO TWO — CORE BPN GENERATOR (v2.1.0) +// Blueprint News files generated in-system: colour logic, code, contact, +// objectives, complication, reward. Creates a briefing journal + chat post. +// (The standalone sla-blueprint-news-zero module targets the original +// zero-engine system; SLA Zero Two carries its own generator in core.) +// ══════════════════════════════════════════════════════════════════════════════ + +class SLABPNGenerator { + static COLOURS = { + blue: { + label: "Blue", tint: "#3399ff", brief: "civic order / security", + objectives: [ + "Escort a Shiver patrol through {sector} and return with all bodies breathing", + "Guard the {place} against gang reprisal for one full shift", + "Recover a stolen SLA property crate before it crosses {sector}", + "Suppress a street disturbance in {sector} without collateral on camera" + ], + reward: [180, 320], complication: [ + "The gang has a sponsor with cameras of its own", + "One of the Shivers is on the take", + "The property crate is leaking something", + "A rival squad has the same contract" + ] + }, + green: { + label: "Green", tint: "#44cc66", brief: "investigation / intelligence", + objectives: [ + "Trace the source of counterfeit KickStart flooding {sector}", + "Locate a missing DAC researcher last seen near {place}", + "Document serial-killer activity in {sector} and identify the pattern", + "Verify or refute Carrien sightings below {place}" + ], + reward: [250, 450], complication: [ + "The trail leads somewhere SLA does not want filmed", + "The witness will only talk to an Ebon", + "The evidence keeps being bought before you arrive", + "Someone is running the same investigation — better funded" + ] + }, + red: { + label: "Red", tint: "#cc2222", brief: "hostile suppression", + objectives: [ + "Clear the {place} of Carrien infestation — flame recommended", + "Terminate the gang leadership of {sector} — collateral discouraged, not forbidden", + "Put down a rogue Stormer unit gone feral near {place}", + "Break the siege of the {place} and hold until relieved" + ], + reward: [400, 700], complication: [ + "They are dug in deeper than the brief admits", + "Half of them used to be SLA", + "The extraction window is a lie", + "Something bigger is eating the gang from below" + ] + }, + silver: { + label: "Silver", tint: "#aaaacc", brief: "corporate / escort", + objectives: [ + "Escort a Dept. of Propaganda film crew through {sector} — keep the talent alive", + "Bodyguard a corporate negotiator at the {place} summit", + "Recover a defecting executive from {sector} before their employer does", + "Secure the {place} for a sponsorship gala" + ], + reward: [350, 600], complication: [ + "The principal wants to be filmed in danger", + "The other side bid on your contract too", + "The defector is carrying more than data", + "The gala guest list includes someone who wants you dead" + ] + }, + gold: { + label: "Gold", tint: "#e3c300", brief: "high value / high oversight", + objectives: [ + "Retrieve a prototype Sleight asset from a crash site in {sector}", + "Extract a Dark Finder informant alive from {place}", + "Recover LAD-flagged remains before a rival house does", + "Deliver a sealed case to {place}. Do not open the case." + ], + reward: [700, 1200], complication: [ + "Head Office is watching this one live", + "The asset has opinions about being retrieved", + "The case is ticking", + "Success means someone important owes you — and hates it" + ] + }, + black: { + label: "Black", tint: "#555555", brief: "deniable", + objectives: [ + "Erase all evidence of an operation that never happened in {sector}", + "Retire a decommissioned operative quietly — no cameras, no LAD ping", + "Plant evidence at the {place} and be gone before the Shivers arrive", + "Escort a thing that does not appear on any manifest out of {sector}" + ], + reward: [600, 1000], complication: [ + "There is no debrief for this one", + "Your own footage is the loose end", + "The target knows you are coming and has prepared a confession", + "Payment routes through an account that does not exist" + ] + } + }; + + static SECTORS = ["Downtown", "Suburbia", "Lower Downtown", "Cannibal Sector 1 fringe", "Sector 41", "the Fen Street corridor", "Grid Square 19", "the Pipe District"]; + static PLACES = ["Karma clinic", "Third Eye News relay", "abandoned DAC lab", "Shiver station 9", "monorail interchange", "Meat Market", "sponsorship arena", "sub-level archive"]; + + static _pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } + + static _fill(text) { + return text.replace("{sector}", this._pick(this.SECTORS)).replace("{place}", this._pick(this.PLACES)); + } + + static _code(colourKey) { + const digits = String(Math.floor(Math.random() * 900) + 100); + const letter = "ABCDEFGHJKLMNPRSTVWX"[Math.floor(Math.random() * 20)]; + return `${colourKey.charAt(0).toUpperCase()}${digits}${letter}`; + } + + static async generate(colourKey) { + const c = this.COLOURS[colourKey]; + if (!c) return; + const code = this._code(colourKey); + const objective = this._fill(this._pick(c.objectives)); + const complication = this._pick(c.complication); + const reward = Math.round((c.reward[0] + Math.random() * (c.reward[1] - c.reward[0])) / 10) * 10; + const contact = _slaRandomName("human"); + + const briefHtml = ` +
+

BPN ${code} — ${c.label.toUpperCase()}

+

${c.brief}

+
+

ISSUING CONTACT: ${contact}, SLA Industries

+

OBJECTIVE: ${objective}.

+

REWARD: ${reward}c per operative on completion and debrief.

+

NOTES: Standard rules of engagement. Ammunition is billed. The cameras are always on.

+
`; + const gmHtml = ` +
+

GM DOSSIER — what the brief doesn't say

+

COMPLICATION: ${complication}.

+

SUGGESTED OPPOSITION: use the Group NPC Generator at threat ${colourKey === "red" || colourKey === "gold" ? "4-6" : colourKey === "black" ? "5-7" : "2-4"}.

+
`; + + const journal = await JournalEntry.create({ + name: `BPN ${code} — ${c.label}`, + pages: [ + { name: "Operative Brief", type: "text", text: { format: 1, content: briefHtml } }, + { name: "GM Dossier", type: "text", text: { format: 1, content: gmHtml }, + ownership: { default: CONST.DOCUMENT_OWNERSHIP_LEVELS.NONE } } + ] + }); + + await ChatMessage.create({ + speaker: { alias: "SLA Industries — Dispatch" }, + content: `
+
📠 BPN ${code} — ${c.label.toUpperCase()}
+
${objective}.
+
Reward ${reward}c/operative · contact ${contact} · brief filed in Journal
+
` + }); + + journal.sheet.render(true); + return journal; + } + + static open() { + const buttons = Object.entries(this.COLOURS).map(([key, c]) => + ``).join(""); + + const dlg = new Dialog({ + title: "Generate BPN", + content: `
${buttons}
`, + buttons: { + random: { + icon: '', label: "Random Colour", + callback: () => this.generate(this._pick(Object.keys(this.COLOURS))) + }, + cancel: { icon: '', label: "Cancel" } + }, + default: "random", + render: (html) => { + const root = html instanceof HTMLElement ? html : html[0]; + root.querySelectorAll(".bpn-gen-colour").forEach(btn => { + btn.addEventListener("click", () => { dlg.close(); this.generate(btn.dataset.colour); }); + }); + } + }, { classes: ["slazero2", "dialog"], width: 400 }); + dlg.render(true); + } +} + +// ══════════════════════════════════════════════════════════════════════════════ +// SLA ZERO TWO — ROLLTABLE PROVISIONING (v2.1.0) +// The dice engine draws "Ranged/Melee Weapon Critical Fail (2-12)" rolltables +// by name. Ship them (plus Panic and the d66 crit references) into any world +// where they're missing. Idempotent by table name. +// ══════════════════════════════════════════════════════════════════════════════ + +Hooks.once("ready", async () => { + if (!game.user?.isGM) return; + try { + const resp = await fetch("systems/slazero2/packs/tables-data.json"); + if (!resp.ok) return; + const data = await resp.json(); + let created = 0; + for (const key of Object.keys(data)) { + const t = data[key]; + if (!t?.name || game.tables.getName(t.name)) continue; + await RollTable.create({ + name: t.name, + description: t.description || "", + formula: (t.formula || "1d6").replace("@stress", "0"), + replacement: true, + displayRoll: true, + results: (t.results || []).map(r => ({ + type: CONST.TABLE_RESULT_TYPES?.TEXT ?? 0, + text: r.text, + range: r.range, + weight: 1 + })) + }); + created++; + } + if (created > 0) { + console.log(`SLA Zero Two | Created ${created} system rolltable(s)`); + ui.notifications.info(`SLA Zero Two: ${created} rules rolltable(s) added to this world.`); + } + } catch (err) { + console.warn("SLA Zero Two | Rolltable provisioning failed:", err); + } +}); diff --git a/system.json b/system.json index 9739ccc..2455103 100644 --- a/system.json +++ b/system.json @@ -2,7 +2,7 @@ "id": "slazero2", "title": "SLA Zero Two", "description": "SLA Zero Two \u2014 the sequel system for SLA Industries on the Zero Engine ruleset. All the mechanics of Zero Engine v1.7.6 with a full presentation overhaul: custom dice, redesigned sheets, and cinematic chat cards.", - "version": "2.0.0", + "version": "2.1.0", "compatibility": { "minimum": 13, "verified": "14.359"