const MODULE_ID = "sla-npc-director"; const FLAG_KEY = "directorState"; const STANCE_OPTIONS = ["Aggressive", "Holding", "Defensive", "Broken"]; const MORALE_OPTIONS = ["Steady", "Shaken", "Fragile", "Collapsed"]; const SUPPRESSION_OPTIONS = ["0", "1", "2", "3"]; const INTENT_OPTIONS = ["Rush", "Flank", "Hold", "Suppress", "Fallback", "Flee"]; function getHtmlRoot(html) { return html instanceof HTMLElement ? html : html?.[0] ?? null; } function getTargets() { const selectedTokens = canvas?.tokens?.controlled?.map((token) => token.document) ?? []; if (selectedTokens.length) return selectedTokens; const combatants = game.combat?.combatants?.map((combatant) => combatant.token ?? combatant.actor)?.filter(Boolean) ?? []; return combatants; } function readState(document) { return foundry.utils.mergeObject( { stance: "Holding", morale: "Steady", suppression: "0", intent: "Hold", note: "" }, document?.getFlag?.(MODULE_ID, FLAG_KEY) ?? {} ); } async function writeState(document, state) { if (!document?.setFlag) return; await document.setFlag(MODULE_ID, FLAG_KEY, state); } function buildSummary(entries) { const lines = entries.map((entry) => { const note = entry.note ? ` | ${foundry.utils.escapeHTML(entry.note)}` : ""; return `
  • ${foundry.utils.escapeHTML(entry.name)}: ${entry.stance}, ${entry.morale}, suppression ${entry.suppression}, intent ${entry.intent}${note}
  • `; }).join(""); return `

    SLA NPC Director

    `; } class SlaNpcDirectorApp extends FormApplication { static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "sla-npc-director-app", title: "SLA NPC Director", template: "modules/sla-npc-director/templates/npc-director.hbs", classes: ["sla-npc-director"], width: 980, height: "auto", closeOnSubmit: false, submitOnChange: false, resizable: true }); } async getData() { const entries = getTargets().map((document) => ({ name: document.name ?? document.actor?.name ?? "Unknown", docId: document.id, docType: document.documentName, ...readState(document) })); return { entries, stanceOptions: STANCE_OPTIONS, moraleOptions: MORALE_OPTIONS, suppressionOptions: SUPPRESSION_OPTIONS, intentOptions: INTENT_OPTIONS }; } activateListeners(html) { super.activateListeners(html); html.find("[data-action='refresh']").on("click", () => this.render(false)); html.find("[data-action='save']").on("click", async () => { await this._saveRows(); ui.notifications.info("SLA NPC Director states saved."); }); html.find("[data-action='chat']").on("click", async () => { const entries = await this._saveRows(); await ChatMessage.create({ user: game.user.id, speaker: { alias: "SLA NPC Director" }, whisper: ChatMessage.getWhisperRecipients("GM"), content: buildSummary(entries) }); }); } async _saveRows() { const rows = Array.from(this.form?.querySelectorAll(".sla-npc-director-row") ?? []); const summaries = []; for (const row of rows) { const docId = row.dataset.docId; const document = canvas?.scene?.tokens?.get?.(docId) ?? game.combat?.combatants?.find((combatant) => combatant.token?.id === docId)?.token; if (!document) continue; const state = { stance: row.querySelector("[data-field='stance']")?.value ?? "Holding", morale: row.querySelector("[data-field='morale']")?.value ?? "Steady", suppression: row.querySelector("[data-field='suppression']")?.value ?? "0", intent: row.querySelector("[data-field='intent']")?.value ?? "Hold", note: row.querySelector("[data-field='note']")?.value ?? "" }; await writeState(document, state); summaries.push({ name: document.name ?? document.actor?.name ?? "Unknown", ...state }); } return summaries; } async _updateObject() { await this._saveRows(); } static open() { return new SlaNpcDirectorApp().render(true); } } function appendOpenButton(root, selector, className, label) { if (!game.user?.isGM) return false; const header = root?.querySelector(selector); if (!header || header.querySelector(`.${className}`)) return false; const button = document.createElement("button"); button.type = "button"; button.className = className; button.innerHTML = ` ${label}`; button.addEventListener("click", () => SlaNpcDirectorApp.open()); header.appendChild(button); return true; } function installCombatButton(app, html) { const root = getHtmlRoot(html); appendOpenButton(root, ".directory-header .header-actions, .directory-header .action-buttons, .combat-sidebar .encounter-controls", "sla-npc-director-open", "NPC Director"); } function installActorDirectoryButton(app, html) { const root = getHtmlRoot(html); appendOpenButton(root, ".directory-header .header-actions, .directory-header .action-buttons", "sla-npc-director-actor-open", "NPC Director"); } function installSettingsButton(app, html) { const root = getHtmlRoot(html); appendOpenButton(root, ".settings-sidebar #game-details, .settings-sidebar .directory-header .header-actions, .settings-sidebar .action-buttons", "sla-npc-director-settings-open", "NPC Director"); } Hooks.once("init", () => { Handlebars.registerHelper("eq", (left, right) => left === right); game.settings.registerMenu(MODULE_ID, "npcDirectorMenu", { name: "SLA NPC Director", label: "Open NPC Director", hint: "Track combat stance, morale, suppression, and intent for current targets.", icon: "fas fa-user-shield", type: SlaNpcDirectorApp, restricted: true }); }); Hooks.on("renderCombatTracker", installCombatButton); Hooks.on("renderActorDirectory", installActorDirectoryButton); Hooks.on("renderSettings", installSettingsButton);