Files
sla-blueprint-news/module/app/generator-app.mjs
T
tevansandClaude Opus 4.8 70fd7bfc89 SLA Blueprint News v0.1.0 — Foundry module wrapper for the BPN generator
ApplicationV2 generator (mirrors the standalone app) over the vendored
engine-agnostic component. Creates a journal entry per briefing with a
player-facing Mission Brief page and a GM-only Dossier page; procedural
art in sanitiser-safe img mode; SLA house fonts bundled. Verified idiom
against mapwright (v14.361).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:22:02 +01:00

184 lines
6.4 KiB
JavaScript

/**
* 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.
*
* The render layer is engine-free; this file is the only Foundry-aware code.
*/
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";
const MODULE_ID = "sla-blueprint-news";
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
export class BlueprintNewsApp extends HandlebarsApplicationMixin(ApplicationV2) {
state = {
seed: randomSeed(),
colourKey: "",
frameId: "",
squadSize: 4,
paymentType: "",
view: "player"
};
static DEFAULT_OPTIONS = {
id: "sla-blueprint-news-app",
tag: "div",
classes: ["sla-bpn-window"],
window: { title: "SLABPN.Title", resizable: true, icon: "fas fa-file-contract" },
position: { width: 1080, height: 820 },
actions: {
reroll: BlueprintNewsApp.#onReroll,
applySeed: BlueprintNewsApp.#onApplySeed,
copySeed: BlueprintNewsApp.#onCopySeed,
view: BlueprintNewsApp.#onView,
create: BlueprintNewsApp.#onCreate,
chat: BlueprintNewsApp.#onChat
}
};
static PARTS = { body: { template: `modules/${MODULE_ID}/templates/generator.html` } };
static open() { return new BlueprintNewsApp().render({ force: true }); }
/** Resolve the current briefing from UI state (deterministic from seed). */
_bpn() {
return generateBPN({
seed: this.state.seed,
colourKey: this.state.colourKey || undefined,
frameId: this.state.frameId || undefined,
squadSize: this.state.squadSize,
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;
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 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
};
}
_onRender() {
const root = this.element;
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 = "";
this.render();
});
});
}
static #onReroll() { this.state.seed = randomSeed(); this.render(); }
static #onApplySeed() {
const v = this.element.querySelector('[data-field="seed"]')?.value?.trim();
if (v) this.state.seed = v;
this.render();
}
static async #onCopySeed() {
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 async #onCreate() {
const bpn = this._bpn();
const O = CONST.DOCUMENT_OWNERSHIP_LEVELS;
const HTML = CONST.JOURNAL_ENTRY_PAGE_FORMATS.HTML;
const wrap = (html) => `<div class="bpn">${html}</div>`;
const 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 } },
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" })) }
}
]
});
ui.notifications.info(`Created BPN journal "${entry.name}" (player Brief + GM Dossier page).`);
entry.sheet?.render(true);
}
static async #onChat() {
const bpn = this._bpn();
const card = `<div class="bpn"><div class="bpn-chat-card" style="--bpn-accent:${bpn.colour.accent};">
<div class="bpn-chat-card__head">
<img src="${crestDataURI(bpn, { size: 56 })}" width="56" height="56" alt="">
<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>
</div>
<p class="bpn-chat-card__brief">${foundry.utils.escapeHTML(bpn.missionBrief)}</p>
</div></div>`;
await ChatMessage.create({ speaker: { alias: "SLA Blueprint News" }, content: card });
}
}