diff --git a/scripts/apps/assignment-manager.js b/scripts/apps/assignment-manager.js new file mode 100644 index 0000000..29d2325 --- /dev/null +++ b/scripts/apps/assignment-manager.js @@ -0,0 +1,161 @@ +import { MODULE_ID, SETTINGS } from "../constants.js"; + +export async function assignReactionToSelectedTokens(reactionId) { + if (!game.user.isGM) { + ui.notifications.warn(game.i18n.localize("CONFIGURABLE_REACTIONS.Errors.GmOnly")); + return; + } + + if (!reactionId) { + ui.notifications.warn(game.i18n.localize("CONFIGURABLE_REACTIONS.Errors.NoReactionSelected")); + return; + } + + const selectedTokens = canvas?.tokens?.controlled ?? []; + if (!selectedTokens.length) { + ui.notifications.warn(game.i18n.localize("CONFIGURABLE_REACTIONS.Errors.NoTokensSelected")); + return; + } + + const reactions = game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []; + const reaction = reactions.find(r => r.id === reactionId); + if (!reaction) { + ui.notifications.error(game.i18n.localize("CONFIGURABLE_REACTIONS.Errors.ReactionNotFound")); + return; + } + + const assignments = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.ASSIGNMENTS) ?? []); + let assignedCount = 0; + let skippedCount = 0; + + for (const token of selectedTokens) { + const tokenDocument = token.document; + const actor = token.actor; + + if (!actor) { + skippedCount++; + continue; + } + + const assignment = buildAssignmentForToken(tokenDocument, actor, reactionId); + const alreadyAssigned = assignments.some(existing => + existing.reactionId === assignment.reactionId && + existing.mode === assignment.mode && + existing.actorUuid === assignment.actorUuid && + existing.tokenUuid === assignment.tokenUuid + ); + + if (alreadyAssigned) { + skippedCount++; + continue; + } + + assignments.push(assignment); + + if (assignment.mode === "actor") { + await addReactionFlag(actor, reactionId); + await ensureManagedReactionEffectOnActor(actor, reaction); + } else { + await addReactionFlag(tokenDocument, reactionId); + } + + assignedCount++; + } + + await game.settings.set(MODULE_ID, SETTINGS.ASSIGNMENTS, assignments); + ui.notifications.info(game.i18n.format("CONFIGURABLE_REACTIONS.Assignments.AssignedResult", { assigned: assignedCount, skipped: skippedCount })); +} + +export async function removeAssignment(assignmentId) { + if (!game.user.isGM) return; + + const assignments = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.ASSIGNMENTS) ?? []); + const assignment = assignments.find(a => a.id === assignmentId); + if (!assignment) return; + + await game.settings.set(MODULE_ID, SETTINGS.ASSIGNMENTS, assignments.filter(a => a.id !== assignmentId)); + + if (assignment.mode === "actor" && assignment.actorUuid) { + const actor = await fromUuid(assignment.actorUuid); + if (actor) await removeReactionFromDocument(actor, assignment.reactionId, true); + } + + if (assignment.mode === "token" && assignment.tokenUuid) { + const tokenDocument = await fromUuid(assignment.tokenUuid); + if (tokenDocument) await removeReactionFromDocument(tokenDocument, assignment.reactionId, false); + } +} + +function buildAssignmentForToken(tokenDocument, actor, reactionId) { + if (tokenDocument.actorLink === true) { + return { + id: foundry.utils.randomID(), + reactionId, + mode: "actor", + actorUuid: actor.uuid, + tokenUuid: null, + sceneUuid: null, + name: actor.name + }; + } + + return { + id: foundry.utils.randomID(), + reactionId, + mode: "token", + actorUuid: null, + tokenUuid: tokenDocument.uuid, + sceneUuid: tokenDocument.parent?.uuid ?? canvas.scene?.uuid ?? null, + name: tokenDocument.name + }; +} + +async function addReactionFlag(document, reactionId) { + const assigned = foundry.utils.deepClone(document.getFlag(MODULE_ID, "assignedReactions") ?? []); + if (!assigned.some(entry => entry.reactionId === reactionId)) { + assigned.push({ reactionId, assignedAt: Date.now(), assignedBy: game.user.id }); + } + await document.setFlag(MODULE_ID, "assignedReactions", assigned); +} + +async function ensureManagedReactionEffectOnActor(actor, reaction) { + if (!game.settings.get(MODULE_ID, SETTINGS.CREATE_MANAGED_EFFECTS_ON_ASSIGNMENT)) return; + + const existing = actor.effects.find(effect => + effect.getFlag(MODULE_ID, "reactionId") === reaction.id && + effect.getFlag(MODULE_ID, "managed") === true + ); + + if (existing) return; + + await actor.createEmbeddedDocuments("ActiveEffect", [{ + name: game.i18n.format("CONFIGURABLE_REACTIONS.Effects.ManagedReaction", { name: reaction.name }), + icon: reaction.icon ?? "icons/svg/aura.svg", + origin: `Module.${MODULE_ID}`, + disabled: false, + transfer: false, + changes: [], + flags: { + [MODULE_ID]: { + reactionId: reaction.id, + managed: true + } + } + }]); +} + +async function removeReactionFromDocument(document, reactionId, removeManagedEffects) { + const assigned = foundry.utils.deepClone(document.getFlag(MODULE_ID, "assignedReactions") ?? []) + .filter(entry => entry.reactionId !== reactionId); + + if (assigned.length) await document.setFlag(MODULE_ID, "assignedReactions", assigned); + else await document.unsetFlag(MODULE_ID, "assignedReactions"); + + if (!removeManagedEffects || !document.effects) return; + + const effectIds = document.effects + .filter(effect => effect.getFlag(MODULE_ID, "reactionId") === reactionId && effect.getFlag(MODULE_ID, "managed") === true) + .map(effect => effect.id); + + if (effectIds.length) await document.deleteEmbeddedDocuments("ActiveEffect", effectIds); +} diff --git a/scripts/apps/reaction-config-app.js b/scripts/apps/reaction-config-app.js new file mode 100644 index 0000000..9bab8bd --- /dev/null +++ b/scripts/apps/reaction-config-app.js @@ -0,0 +1,177 @@ +import { MODULE_ID, SETTINGS, DEFAULT_REACTION, ACTION_TYPES, TRIGGER_TYPES } 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: 900, + height: 760 + }, + actions: { + createReaction: ConfigurableReactionsConfigApp.#onCreateReaction, + saveReactionJson: ConfigurableReactionsConfigApp.#onSaveReactionJson, + deleteReaction: ConfigurableReactionsConfigApp.#onDeleteReaction, + assignSelectedTokens: ConfigurableReactionsConfigApp.#onAssignSelectedTokens, + removeAssignment: ConfigurableReactionsConfigApp.#onRemoveAssignment, + addTeleportAction: ConfigurableReactionsConfigApp.#onAddTeleportAction, + addStatusAction: ConfigurableReactionsConfigApp.#onAddStatusAction + } + }; + + 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; + + 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, + statusEffects: CONFIG.statusEffects.map(s => ({ id: s.id, name: game.i18n.localize(s.name ?? s.id) })) + }; + } + + _onRender(context, options) { + super._onRender(context, options); + + this.element.querySelector("[name='selectedReactionId']")?.addEventListener("change", event => { + this._selectedReactionId = event.currentTarget.value; + this.render({ force: true }); + }); + } + + 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"); + + 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 #onAddTeleportAction(event, target) { + await this.#addAction({ + id: foundry.utils.randomID(), + type: ACTION_TYPES.TELEPORT, + enabled: true, + radius: 30, + askOwner: false, + snapToGrid: true, + maxAttempts: 80, + consumeOnFailure: false + }); + } + + static async #onAddStatusAction(event, target) { + await this.#addAction({ + id: foundry.utils.randomID(), + type: ACTION_TYPES.APPLY_STATUS, + enabled: true, + statuses: [], + duration: { + type: "rounds", + value: 1 + } + }); + } + + async #addAction(action) { + const reactions = foundry.utils.deepClone(game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []); + const reaction = reactions.find(r => r.id === this._selectedReactionId); + if (!reaction) return; + + reaction.actions ??= []; + reaction.actions.push(action); + + await game.settings.set(MODULE_ID, SETTINGS.REACTIONS, reactions); + this.render({ force: true }); + } +}