import { MODULE_ID, SETTINGS, DEFAULT_REACTION, ACTION_TYPES, TRIGGER_TYPES, TRIGGER_PALETTE, ACTION_PALETTE } from "../constants.js"; import { assignReactionToSelectedTokens, removeAssignment } from "./assignment-manager.js"; const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api; export class ConfigurableReactionsConfigApp extends HandlebarsApplicationMixin(ApplicationV2) { static DEFAULT_OPTIONS = { id: "configurable-reactions-config", tag: "section", classes: ["configurable-reactions", "standard-form"], window: { title: "CONFIGURABLE_REACTIONS.App.Title", icon: "fa-solid fa-bolt" }, position: { width: 1040, height: 820 }, actions: { createReaction: ConfigurableReactionsConfigApp.#onCreateReaction, saveReactionJson: ConfigurableReactionsConfigApp.#onSaveReactionJson, deleteReaction: ConfigurableReactionsConfigApp.#onDeleteReaction, assignSelectedTokens: ConfigurableReactionsConfigApp.#onAssignSelectedTokens, removeAssignment: ConfigurableReactionsConfigApp.#onRemoveAssignment, removeAction: ConfigurableReactionsConfigApp.#onRemoveAction, saveReactionBasics: ConfigurableReactionsConfigApp.#onSaveReactionBasics, clearTriggerSpell: ConfigurableReactionsConfigApp.#onClearTriggerSpell, clearActionSpell: ConfigurableReactionsConfigApp.#onClearActionSpell, removeActionStatus: ConfigurableReactionsConfigApp.#onRemoveActionStatus } }; static PARTS = { main: { template: `modules/${MODULE_ID}/templates/reaction-config.hbs` } }; async _prepareContext(options) { const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []); const assignments = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.ASSIGNMENTS) ?? []); const selectedReactionId = this._selectedReactionId ?? reactions[0]?.id ?? null; const selectedReaction = reactions.find(r => r.id === selectedReactionId) ?? reactions[0] ?? null; this._selectedReactionId = selectedReaction?.id ?? null; const triggerPalette = TRIGGER_PALETTE.map(entry => ({ ...entry, label: game.i18n.localize(entry.labelKey), hint: game.i18n.localize(entry.hintKey) })); const actionPalette = ACTION_PALETTE.map(entry => ({ ...entry, label: game.i18n.localize(entry.labelKey), hint: game.i18n.localize(entry.hintKey) })); const activeTrigger = triggerPalette.find(t => t.type === selectedReaction?.trigger?.type) ?? null; const triggerSpellName = selectedReaction?.trigger?.spell?.itemName || selectedReaction?.trigger?.spell?.itemUuid || ""; const activeTriggerAcceptsSpell = isSpellTrigger(selectedReaction?.trigger?.type); const statusEffects = getStatusEffectOptions(); const visualActions = (selectedReaction?.actions ?? []).map((action, index) => { const paletteEntry = actionPalette.find(p => p.type === action.type); const selectedStatusIds = Array.from(new Set(action.statuses ?? [])); const selectedStatuses = selectedStatusIds.map(statusId => resolveStatusEffect(statusId, statusEffects)); return { ...action, index, label: paletteEntry?.label ?? action.type, icon: paletteEntry?.icon ?? "fa-solid fa-puzzle-piece", summary: summarizeAction(action, statusEffects), isSpellAction: action.type === ACTION_TYPES.CAST_SPELL_FROM_TOKEN, spellName: action.spellSelection?.itemName || action.spellSelection?.itemUuid || "", isStatusAction: action.type === ACTION_TYPES.APPLY_STATUS, selectedStatuses, availableStatuses: statusEffects.filter(status => !selectedStatusIds.includes(status.id)) }; }); return { reactions, assignments: assignments.map(a => ({ ...a, reactionName: reactions.find(r => r.id === a.reactionId)?.name ?? a.reactionId })), selectedReaction, selectedReactionJson: selectedReaction ? JSON.stringify(selectedReaction, null, 2) : "", triggerTypes: TRIGGER_TYPES, actionTypes: ACTION_TYPES, triggerPalette, actionPalette, activeTrigger, activeTriggerAcceptsSpell, triggerSpellName, visualActions, statusEffects }; } _onRender(context, options) { super._onRender(context, options); this.element.querySelector("[name='selectedReactionId']")?.addEventListener("change", event => { this._selectedReactionId = event.currentTarget.value; this.render({ force: true }); }); this.#activateStatusSelectors(); this.#activateDragAndDrop(); } #activateStatusSelectors() { for (const select of this.element.querySelectorAll("[data-cr-status-select]")) { select.addEventListener("change", async event => { const actionIndex = Number(event.currentTarget.dataset.actionIndex); const statusId = event.currentTarget.value; if (!Number.isInteger(actionIndex) || !statusId) return; await this.#addActionStatus(actionIndex, statusId); }); } } #activateDragAndDrop() { for (const draggable of this.element.querySelectorAll("[data-cr-drag]")) { draggable.setAttribute("draggable", "true"); draggable.addEventListener("dragstart", event => { const payload = { kind: draggable.dataset.crDrag, type: draggable.dataset.crType, actionIndex: draggable.dataset.actionIndex ? Number(draggable.dataset.actionIndex) : null }; event.dataTransfer.setData("application/json", JSON.stringify(payload)); event.dataTransfer.setData("text/plain", JSON.stringify(payload)); event.dataTransfer.effectAllowed = "copyMove"; }); } for (const dropZone of this.element.querySelectorAll("[data-cr-drop]")) { dropZone.addEventListener("dragover", event => { event.preventDefault(); dropZone.classList.add("cr-drop-active"); event.dataTransfer.dropEffect = "copy"; }); dropZone.addEventListener("dragleave", () => dropZone.classList.remove("cr-drop-active")); dropZone.addEventListener("drop", async event => { event.preventDefault(); dropZone.classList.remove("cr-drop-active"); event.stopPropagation(); const payload = readDragPayload(event); if (!payload) return; const droppedItem = await resolveDroppedItem(payload); if (dropZone.dataset.crDrop === "trigger" && payload.kind === "trigger") { await this.#setTrigger(payload.type); return; } if (dropZone.dataset.crDrop === "trigger" && isSpellItem(droppedItem)) { await this.#setTriggerSpell(droppedItem); return; } if (dropZone.dataset.crDrop === "actions" && payload.kind === "action") { await this.#addAction(createDefaultAction(payload.type)); return; } if (dropZone.dataset.crDrop === "actions" && isSpellItem(droppedItem)) { await this.#addAction(createDefaultAction(ACTION_TYPES.CAST_SPELL_FROM_TOKEN, droppedItem)); return; } if (dropZone.dataset.crDrop === "action-spell" && isSpellItem(droppedItem)) { await this.#setActionSpell(Number(dropZone.dataset.actionIndex), droppedItem); return; } }); } } static async #onCreateReaction(event, target) { const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []); const reaction = foundry.utils.deepClone(DEFAULT_REACTION); reaction.id = foundry.utils.randomID(); reaction.name = game.i18n.localize("CONFIGURABLE_REACTIONS.Reactions.NewReaction"); reactions.push(reaction); await game.settings.set(MODULE_ID, SETTINGS.REACTIONS, reactions); this._selectedReactionId = reaction.id; this.render({ force: true }); } static async #onSaveReactionJson(event, target) { const textarea = this.element.querySelector("[name='reactionJson']"); if (!textarea) return; let reaction; try { reaction = JSON.parse(textarea.value); } catch (error) { ui.notifications.error(game.i18n.localize("CONFIGURABLE_REACTIONS.Errors.InvalidJson")); return; } if (!reaction.id) reaction.id = foundry.utils.randomID(); if (!reaction.name) reaction.name = game.i18n.localize("CONFIGURABLE_REACTIONS.Reactions.NewReaction"); reaction.actions ??= []; const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []); const index = reactions.findIndex(r => r.id === reaction.id); if (index >= 0) reactions[index] = reaction; else reactions.push(reaction); await game.settings.set(MODULE_ID, SETTINGS.REACTIONS, reactions); this._selectedReactionId = reaction.id; this.render({ force: true }); } static async #onDeleteReaction(event, target) { const reactionId = this.element.querySelector("[name='selectedReactionId']")?.value; if (!reactionId) return; const confirmed = await foundry.applications.api.DialogV2.confirm({ window: { title: game.i18n.localize("CONFIGURABLE_REACTIONS.Reactions.Delete") }, content: `

${game.i18n.localize("CONFIGURABLE_REACTIONS.Reactions.DeleteConfirm")}

` }); if (!confirmed) return; const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []) .filter(r => r.id !== reactionId); const assignments = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.ASSIGNMENTS) ?? []) .filter(a => a.reactionId !== reactionId); await game.settings.set(MODULE_ID, SETTINGS.REACTIONS, reactions); await game.settings.set(MODULE_ID, SETTINGS.ASSIGNMENTS, assignments); this._selectedReactionId = reactions[0]?.id ?? null; this.render({ force: true }); } static async #onAssignSelectedTokens(event, target) { const reactionId = this.element.querySelector("[name='selectedReactionId']")?.value; await assignReactionToSelectedTokens(reactionId); this.render({ force: true }); } static async #onRemoveAssignment(event, target) { const assignmentId = target.dataset.assignmentId; await removeAssignment(assignmentId); this.render({ force: true }); } static async #onRemoveAction(event, target) { const actionIndex = Number(target.dataset.actionIndex); if (!Number.isInteger(actionIndex)) return; await this.#mutateSelectedReaction(reaction => { reaction.actions ??= []; reaction.actions.splice(actionIndex, 1); }); } static async #onClearTriggerSpell(event, target) { await this.#mutateSelectedReaction(reaction => { reaction.trigger ??= {}; delete reaction.trigger.spell; }); } static async #onClearActionSpell(event, target) { const actionIndex = Number(target.dataset.actionIndex); if (!Number.isInteger(actionIndex)) return; await this.#mutateSelectedReaction(reaction => { const action = reaction.actions?.[actionIndex]; if (!action || action.type !== ACTION_TYPES.CAST_SPELL_FROM_TOKEN) return; action.spellSelection = { mode: "itemName", itemName: "" }; }); } static async #onRemoveActionStatus(event, target) { const actionIndex = Number(target.dataset.actionIndex); const statusId = target.dataset.statusId; if (!Number.isInteger(actionIndex) || !statusId) return; await this.#mutateSelectedReaction(reaction => { const action = reaction.actions?.[actionIndex]; if (!action || action.type !== ACTION_TYPES.APPLY_STATUS) return; action.statuses = (action.statuses ?? []).filter(id => id !== statusId); }); } static async #onSaveReactionBasics(event, target) { const name = this.element.querySelector("[name='reactionName']")?.value?.trim(); const enabled = this.element.querySelector("[name='reactionEnabled']")?.checked === true; await this.#mutateSelectedReaction(reaction => { if (name) reaction.name = name; reaction.enabled = enabled; }); } async #setTrigger(triggerType) { await this.#mutateSelectedReaction(reaction => { reaction.trigger ??= {}; reaction.trigger.type = triggerType; if (!isSpellTrigger(triggerType)) delete reaction.trigger.spell; }); } async #setTriggerSpell(item) { await this.#mutateSelectedReaction(reaction => { reaction.trigger ??= {}; if (!isSpellTrigger(reaction.trigger.type)) reaction.trigger.type = TRIGGER_TYPES.SPELL_SEEN; reaction.trigger.spell = buildSpellSelection(item); }); } async #setActionSpell(actionIndex, item) { if (!Number.isInteger(actionIndex)) return; await this.#mutateSelectedReaction(reaction => { const action = reaction.actions?.[actionIndex]; if (!action || action.type !== ACTION_TYPES.CAST_SPELL_FROM_TOKEN) return; action.spellSelection = buildSpellSelection(item); }); } async #addAction(action) { await this.#mutateSelectedReaction(reaction => { reaction.actions ??= []; reaction.actions.push(action); }); } async #addActionStatus(actionIndex, statusId) { await this.#mutateSelectedReaction(reaction => { const action = reaction.actions?.[actionIndex]; if (!action || action.type !== ACTION_TYPES.APPLY_STATUS) return; action.statuses ??= []; if (!action.statuses.includes(statusId)) action.statuses.push(statusId); }); } async #mutateSelectedReaction(mutator) { const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []); const reaction = reactions.find(r => r.id === this._selectedReactionId); if (!reaction) return; mutator(reaction); await game.settings.set(MODULE_ID, SETTINGS.REACTIONS, reactions); this.render({ force: true }); } } function readDragPayload(event) { const raw = event.dataTransfer.getData("application/json") || event.dataTransfer.getData("text/plain"); if (!raw) return null; try { return JSON.parse(raw); } catch (error) { console.warn(`${MODULE_ID} | Invalid drag payload`, error); return null; } } async function resolveDroppedItem(data) { if (!data) return null; if (data.uuid) { const document = await fromUuid(data.uuid); if (document?.documentName === "Item") return document; } if (data.type === "Item" && data.id) { return game.items.get(data.id) ?? null; } return null; } function isSpellItem(item) { return item?.documentName === "Item" && item.type === "spell"; } function isSpellTrigger(type) { return [TRIGGER_TYPES.SPELL_SEEN, TRIGGER_TYPES.SPELL_CAST_START, TRIGGER_TYPES.SPELL_CAST_COMPLETE].includes(type); } function buildSpellSelection(item) { return { mode: "itemName", itemName: item.name }; } function createDefaultAction(type, item = null) { switch (type) { case ACTION_TYPES.TELEPORT: return { id: foundry.utils.randomID(), type: ACTION_TYPES.TELEPORT, enabled: true, radius: 30, askOwner: false, snapToGrid: true, maxAttempts: 80, consumeOnFailure: false }; case ACTION_TYPES.APPLY_STATUS: return { id: foundry.utils.randomID(), type: ACTION_TYPES.APPLY_STATUS, enabled: true, statuses: [], duration: { type: "rounds", value: 1 } }; case ACTION_TYPES.CAST_SPELL_FROM_TOKEN: return { id: foundry.utils.randomID(), type: ACTION_TYPES.CAST_SPELL_FROM_TOKEN, enabled: true, casterMode: "affectedToken", spellSelection: item ? buildSpellSelection(item) : { mode: "itemName", itemName: "" }, targetMode: "self", consumeSlot: true, consumeUses: true }; case ACTION_TYPES.USE_INVENTORY_ITEM: return { id: foundry.utils.randomID(), type: ACTION_TYPES.USE_INVENTORY_ITEM, enabled: true, itemSelection: { mode: "itemName", itemName: "" }, userMode: "affectedToken", targetMode: "self", activationMode: "openSheet", consumeUses: true }; default: return { id: foundry.utils.randomID(), type, enabled: true }; } } function getStatusEffectOptions() { return (CONFIG.statusEffects ?? []).map(status => ({ id: status.id, label: game.i18n.localize(status.name ?? status.label ?? status.id), icon: status.img ?? status.icon ?? "icons/svg/aura.svg" })).sort((a, b) => a.label.localeCompare(b.label, game.i18n.lang)); } function resolveStatusEffect(statusId, statusEffects = getStatusEffectOptions()) { return statusEffects.find(status => status.id === statusId) ?? { id: statusId, label: statusId, icon: "icons/svg/aura.svg" }; } function summarizeAction(action, statusEffects = getStatusEffectOptions()) { switch (action.type) { case ACTION_TYPES.TELEPORT: return game.i18n.format("CONFIGURABLE_REACTIONS.Actions.Teleport.Summary", { radius: Number(action.radius) || 30, mode: action.askOwner ? game.i18n.localize("CONFIGURABLE_REACTIONS.Actions.Teleport.OwnerMode") : game.i18n.localize("CONFIGURABLE_REACTIONS.Actions.Teleport.RandomMode") }); case ACTION_TYPES.APPLY_STATUS: { const statuses = action.statuses?.length ? action.statuses.map(statusId => resolveStatusEffect(statusId, statusEffects).label).join(", ") : game.i18n.localize("CONFIGURABLE_REACTIONS.Actions.ApplyStatus.NoStatuses"); const duration = action.duration?.type === "unlimited" ? game.i18n.localize("CONFIGURABLE_REACTIONS.Duration.Unlimited") : `${action.duration?.value ?? 1} ${action.duration?.type ?? "rounds"}`; return `${statuses} ยท ${duration}`; } case ACTION_TYPES.CAST_SPELL_FROM_TOKEN: return action.spellSelection?.itemName || action.spellSelection?.itemUuid || game.i18n.localize("CONFIGURABLE_REACTIONS.Actions.CastSpellFromToken.Unconfigured"); case ACTION_TYPES.USE_INVENTORY_ITEM: return action.itemSelection?.itemName || action.itemSelection?.itemUuid || game.i18n.localize("CONFIGURABLE_REACTIONS.Actions.UseInventoryItem.Unconfigured"); default: return action.type; } }