diff --git a/.gitignore b/.gitignore index 4e18901..46a75cc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,5 @@ Thumbs.db node_modules/ dist/ *.log -patches/ -.gitea/ \ No newline at end of file +patches/** +.gitea/** diff --git a/scripts/actions/apply-status-action.js b/scripts/actions/apply-status-action.js index f50a8b7..9a05bab 100644 --- a/scripts/actions/apply-status-action.js +++ b/scripts/actions/apply-status-action.js @@ -7,35 +7,35 @@ export async function executeApplyStatusAction(action, context) { const statuses = action.statuses ?? []; if (!statuses.length) return { success: false, consumed: false, reason: "NO_STATUSES" }; - const effects = statuses.map(statusId => { - const statusConfig = getStatusEffectConfig(statusId); - - return { - name: statusConfig?.name ? game.i18n.localize(statusConfig.name) : statusId, - icon: statusConfig?.img ?? statusConfig?.icon ?? "icons/svg/aura.svg", - statuses: [statusId], - origin: context.reactionOrigin, - duration: buildEffectDuration(action.duration), - changes: [], - flags: { - [MODULE_ID]: { - managed: true, - reactionId: context.reaction.id, - actionId: action.id - } - } - }; - }); - + const effects = statuses.map(statusId => buildStatusEffectData(statusId, action, context)); await actor.createEmbeddedDocuments("ActiveEffect", effects); return { success: true, consumed: true }; } +function buildStatusEffectData(statusId, action, context) { + const statusConfig = getStatusEffectConfig(statusId); + + return { + name: statusConfig?.name ? game.i18n.localize(statusConfig.name) : statusId, + icon: statusConfig?.img ?? "icons/svg/aura.svg", + statuses: [statusId], + origin: context.reactionOrigin, + duration: buildEffectDuration(action.duration), + changes: [], + flags: { + [MODULE_ID]: { + managed: true, + reactionId: context.reaction.id, + actionId: action.id + } + } + }; +} + function getStatusEffectConfig(statusId) { const statusEffects = CONFIG.statusEffects ?? []; - if (Array.isArray(statusEffects)) return statusEffects.find(status => status.id === statusId); - if (statusEffects instanceof Map) return statusEffects.get(statusId) ?? null; - return Object.values(statusEffects).find(status => status.id === statusId) ?? null; + if (statusEffects instanceof Map) return statusEffects.get(statusId); + return statusEffects.find(status => status.id === statusId); } function buildEffectDuration(duration) { diff --git a/scripts/apps/assignment-manager.js b/scripts/apps/assignment-manager.js index dd6146c..98d53b3 100644 --- a/scripts/apps/assignment-manager.js +++ b/scripts/apps/assignment-manager.js @@ -1,5 +1,9 @@ import { MODULE_ID, SETTINGS } from "../constants.js"; +const ASSIGNED_REACTIONS_FLAG = "assignedReactions"; +const MANAGED_FEATURE_FLAG = "managedAssignmentFeature"; +const FEATURE_TYPE = "feat"; + export async function assignReactionToSelectedTokens(reactionId) { if (!game.user.isGM) { ui.notifications.warn(game.i18n.localize("CONFIGURABLE_REACTIONS.Errors.GmOnly")); @@ -18,7 +22,7 @@ export async function assignReactionToSelectedTokens(reactionId) { } const reactions = game.settings.get(MODULE_ID, SETTINGS.REACTIONS) ?? []; - const reaction = reactions.find(r => r.id === reactionId); + const reaction = reactions.find(candidate => candidate.id === reactionId); if (!reaction) { ui.notifications.error(game.i18n.localize("CONFIGURABLE_REACTIONS.Errors.ReactionNotFound")); return; @@ -29,65 +33,70 @@ export async function assignReactionToSelectedTokens(reactionId) { 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); - await ensureManagedReactionEffectOnActor(actor, reaction, tokenDocument.uuid); - } - - assignedCount++; + const result = await assignReactionToToken({ token, reaction, reactionId, assignments }); + if (result === "assigned") assignedCount++; + else skippedCount++; } await game.settings.set(MODULE_ID, SETTINGS.ASSIGNMENTS, assignments); ui.notifications.info(game.i18n.format("CONFIGURABLE_REACTIONS.Assignments.AssignedResult", { assigned: assignedCount, skipped: skippedCount })); } +async function assignReactionToToken({ token, reaction, reactionId, assignments }) { + const tokenDocument = token?.document; + const actor = token?.actor; + if (!tokenDocument || !actor) return "skipped"; + + const assignment = buildAssignmentForToken(tokenDocument, actor, reactionId); + if (hasAssignment(assignments, assignment)) return "skipped"; + + assignments.push(assignment); + + if (assignment.mode === "actor") { + await addReactionFlag(actor, reactionId); + await ensureManagedReactionFeature(actor, reaction); + return "assigned"; + } + + await addReactionFlag(tokenDocument, reactionId); + await ensureManagedReactionFeature(actor, reaction, tokenDocument.uuid); + return "assigned"; +} + +function hasAssignment(assignments, assignment) { + return assignments.some(existing => + existing.reactionId === assignment.reactionId && + existing.mode === assignment.mode && + existing.actorUuid === assignment.actorUuid && + existing.tokenUuid === assignment.tokenUuid + ); +} + 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); + const assignment = assignments.find(candidate => candidate.id === assignmentId); if (!assignment) return; - await game.settings.set(MODULE_ID, SETTINGS.ASSIGNMENTS, assignments.filter(a => a.id !== assignmentId)); + await game.settings.set(MODULE_ID, SETTINGS.ASSIGNMENTS, assignments.filter(candidate => candidate.id !== assignmentId)); + await removeAssignmentMarkers(assignment); +} +async function removeAssignmentMarkers(assignment) { if (assignment.mode === "actor" && assignment.actorUuid) { const actor = await fromUuid(assignment.actorUuid); if (actor) await removeReactionFromDocument(actor, assignment.reactionId, true); + return; } - if (assignment.mode === "token" && assignment.tokenUuid) { - const tokenDocument = await fromUuid(assignment.tokenUuid); - if (tokenDocument) { - await removeReactionFromDocument(tokenDocument, assignment.reactionId, false); - if (tokenDocument.actor) await removeReactionFromDocument(tokenDocument.actor, assignment.reactionId, true, tokenDocument.uuid); - } - } + if (assignment.mode !== "token" || !assignment.tokenUuid) return; + + const tokenDocument = await fromUuid(assignment.tokenUuid); + if (!tokenDocument) return; + + await removeReactionFromDocument(tokenDocument, assignment.reactionId, false); + if (tokenDocument.actor) await removeReactionFromDocument(tokenDocument.actor, assignment.reactionId, true, tokenDocument.uuid); } function buildAssignmentForToken(tokenDocument, actor, reactionId) { @@ -115,57 +124,71 @@ function buildAssignmentForToken(tokenDocument, actor, reactionId) { } async function addReactionFlag(document, reactionId) { - const assigned = foundry.utils.deepClone(document.getFlag(MODULE_ID, "assignedReactions") ?? []); + const assigned = foundry.utils.deepClone(document.getFlag(MODULE_ID, ASSIGNED_REACTIONS_FLAG) ?? []); if (!assigned.some(entry => entry.reactionId === reactionId)) { assigned.push({ reactionId, assignedAt: Date.now(), assignedBy: game.user.id }); } - await document.setFlag(MODULE_ID, "assignedReactions", assigned); + await document.setFlag(MODULE_ID, ASSIGNED_REACTIONS_FLAG, assigned); } -async function ensureManagedReactionEffectOnActor(actor, reaction, tokenUuid = null) { +async function ensureManagedReactionFeature(actor, reaction, tokenUuid = null) { if (!game.settings.get(MODULE_ID, SETTINGS.CREATE_MANAGED_EFFECTS_ON_ASSIGNMENT)) return; + if (!actor?.createEmbeddedDocuments) return; + if (findManagedReactionFeature(actor, reaction.id, tokenUuid)) return; - const existing = actor.effects.find(effect => - effect.getFlag(MODULE_ID, "reactionId") === reaction.id && - effect.getFlag(MODULE_ID, "managed") === true && - (tokenUuid === null || effect.getFlag(MODULE_ID, "tokenUuid") === tokenUuid) + await actor.createEmbeddedDocuments("Item", [buildManagedReactionFeatureData(reaction, tokenUuid)]); +} + +function findManagedReactionFeature(actor, reactionId, tokenUuid = null) { + return actor.items?.find(item => + item.type === FEATURE_TYPE && + item.getFlag(MODULE_ID, "reactionId") === reactionId && + item.getFlag(MODULE_ID, MANAGED_FEATURE_FLAG) === true && + (tokenUuid === null || item.getFlag(MODULE_ID, "tokenUuid") === tokenUuid) ); +} - 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: [], +function buildManagedReactionFeatureData(reaction, tokenUuid = null) { + const name = game.i18n.format("CONFIGURABLE_REACTIONS.Effects.ManagedReaction", { name: reaction.name }); + return { + name, + type: FEATURE_TYPE, + img: reaction.icon ?? "icons/svg/aura.svg", + system: { + description: { + value: `
${name}
`, + chat: "" + } + }, flags: { [MODULE_ID]: { reactionId: reaction.id, managed: true, + [MANAGED_FEATURE_FLAG]: true, tokenUuid } } - }]); + }; } -async function removeReactionFromDocument(document, reactionId, removeManagedEffects, tokenUuid = null) { - const assigned = foundry.utils.deepClone(document.getFlag(MODULE_ID, "assignedReactions") ?? []) +async function removeReactionFromDocument(document, reactionId, removeManagedFeature, tokenUuid = null) { + const assigned = foundry.utils.deepClone(document.getFlag(MODULE_ID, ASSIGNED_REACTIONS_FLAG) ?? []) .filter(entry => entry.reactionId !== reactionId); - if (assigned.length) await document.setFlag(MODULE_ID, "assignedReactions", assigned); - else await document.unsetFlag(MODULE_ID, "assignedReactions"); + if (assigned.length) await document.setFlag(MODULE_ID, ASSIGNED_REACTIONS_FLAG, assigned); + else await document.unsetFlag(MODULE_ID, ASSIGNED_REACTIONS_FLAG); - if (!removeManagedEffects || !document.effects) return; - - const effectIds = document.effects - .filter(effect => - effect.getFlag(MODULE_ID, "reactionId") === reactionId && - effect.getFlag(MODULE_ID, "managed") === true && - (tokenUuid === null || effect.getFlag(MODULE_ID, "tokenUuid") === tokenUuid) - ) - .map(effect => effect.id); - - if (effectIds.length) await document.deleteEmbeddedDocuments("ActiveEffect", effectIds); + if (removeManagedFeature) await removeManagedReactionFeatures(document, reactionId, tokenUuid); +} + +async function removeManagedReactionFeatures(actor, reactionId, tokenUuid = null) { + const itemIds = actor.items + ?.filter(item => + item.getFlag(MODULE_ID, "reactionId") === reactionId && + item.getFlag(MODULE_ID, MANAGED_FEATURE_FLAG) === true && + (tokenUuid === null || item.getFlag(MODULE_ID, "tokenUuid") === tokenUuid) + ) + .map(item => item.id) ?? []; + + if (itemIds.length) await actor.deleteEmbeddedDocuments("Item", itemIds); } diff --git a/scripts/conditions/damage-condition.js b/scripts/conditions/damage-condition.js index e81333b..97c6c00 100644 --- a/scripts/conditions/damage-condition.js +++ b/scripts/conditions/damage-condition.js @@ -3,10 +3,8 @@ export async function checkDamageCondition(config, context) { const absoluteAmount = Math.abs(amount); if (absoluteAmount < (Number(config.minAmount) || 0)) return false; - if (config.amountMode === "damageOnly" && amount <= 0) return false; if (config.amountMode === "healingOnly" && amount >= 0) return false; - if (config.amountMode === "healingOnly") return true; const configuredTypes = config.types ?? []; diff --git a/scripts/triggers/damage-received.js b/scripts/triggers/damage-received.js index 4800d19..4cf3d13 100644 --- a/scripts/triggers/damage-received.js +++ b/scripts/triggers/damage-received.js @@ -4,25 +4,33 @@ import { getBestTokenDocumentForActor } from "../utils/token-utils.js"; export function registerDnd5eDamageTrigger() { Hooks.on("dnd5e.applyDamage", async (actor, amount, options = {}) => { - if (!game.user.isGM) return; - if (!actor) return; + await handleDamageReceivedHook(actor, amount, options); + }); - const hookOptions = normalizeDamageHookOptions(options); - const tokenDocument = resolveDamageTokenDocument(actor, hookOptions); - const damageTypes = extractDamageTypes(hookOptions); - const numericAmount = Number(amount) || 0; + Hooks.on("dnd5e.damageActor", async (actor, changes = {}, update = {}, userId = null) => { + const amount = normalizeDamageActorAmount(changes, update); + await handleDamageReceivedHook(actor, amount, { changes, update, userId }); + }); +} - await handleTrigger(TRIGGER_TYPES.DAMAGE_RECEIVED, { - actor: tokenDocument?.actor ?? actor, - targetActor: tokenDocument?.actor ?? actor, - tokenDocument, - targetTokenDocument: tokenDocument, - targetToken: tokenDocument?.object ?? null, - amount: numericAmount, - damageAmount: numericAmount, - damageTypes, - options: hookOptions - }); +async function handleDamageReceivedHook(actor, amount, options = {}) { + if (!game.user.isGM || !actor) return; + + const hookOptions = normalizeDamageHookOptions(options); + const tokenDocument = resolveDamageTokenDocument(actor, hookOptions); + const targetActor = tokenDocument?.actor ?? actor; + const numericAmount = Number(amount) || 0; + + await handleTrigger(TRIGGER_TYPES.DAMAGE_RECEIVED, { + actor: targetActor, + targetActor, + tokenDocument, + targetTokenDocument: tokenDocument, + targetToken: tokenDocument?.object ?? null, + amount: numericAmount, + damageAmount: numericAmount, + damageTypes: extractDamageTypes(hookOptions), + options: hookOptions }); } @@ -30,16 +38,28 @@ function normalizeDamageHookOptions(options) { return options && typeof options === "object" ? options : {}; } +function normalizeDamageActorAmount(changes, update) { + const candidates = [changes?.total, changes?.amount, changes?.value, update?.total, update?.amount, update?.value]; + const value = candidates.find(candidate => Number.isFinite(Number(candidate))); + return Number(value) || 0; +} + function resolveDamageTokenDocument(actor, options) { - return options.tokenDocument ?? - options.token?.document ?? - options.token ?? - options.targetTokenDocument ?? - options.targetToken?.document ?? - actor.token ?? + return asTokenDocument(options.tokenDocument) ?? + asTokenDocument(options.token) ?? + asTokenDocument(options.targetTokenDocument) ?? + asTokenDocument(options.targetToken) ?? + asTokenDocument(actor.token) ?? getBestTokenDocumentForActor(actor); } +function asTokenDocument(value) { + if (!value) return null; + if (value.documentName === "Token") return value; + if (value.document?.documentName === "Token") return value.document; + return null; +} + function extractDamageTypes(options) { const knownTypes = getKnownDamageTypes(); const collected = new Set(); @@ -55,8 +75,7 @@ function collectDamageTypes(value, knownTypes, collected, seen = new WeakSet(), return; } - if (typeof value !== "object") return; - if (seen.has(value)) return; + if (typeof value !== "object" || seen.has(value)) return; seen.add(value); if (Array.isArray(value) || value instanceof Set) { @@ -69,7 +88,7 @@ function collectDamageTypes(value, knownTypes, collected, seen = new WeakSet(), return; } - for (const key of ["type", "damageType", "damageTypeId", "types", "damageTypes", "damage", "damages", "damageDetail", "parts"]) { + for (const key of ["type", "damageType", "damageTypeId", "types", "damageTypes", "damage", "damages", "damageDetail", "parts", "workflow"]) { collectDamageTypes(value[key], knownTypes, collected, seen, depth + 1); } } diff --git a/scripts/utils/token-utils.js b/scripts/utils/token-utils.js index a94449c..7bf3579 100644 --- a/scripts/utils/token-utils.js +++ b/scripts/utils/token-utils.js @@ -4,24 +4,27 @@ export function getBestTokenDocumentForActor(actor) { const syntheticToken = getSyntheticTokenDocument(actor); if (syntheticToken) return syntheticToken; - const controlled = canvas?.tokens?.controlled?.find(token => token.actor?.uuid === actor.uuid || token.actor?.id === actor.id); + const controlled = canvas?.tokens?.controlled?.find(token => sameActor(token.actor, actor)); if (controlled) return controlled.document; - const combatant = game.combat?.combatants?.find(c => c.actor?.uuid === actor.uuid || c.actor?.id === actor.id); + const combatant = game.combat?.combatants?.find(candidate => sameActor(candidate.actor, actor)); if (combatant?.token) return combatant.token; const activeTokens = actor.getActiveTokens?.(true, true) ?? []; if (activeTokens.length === 1) return activeTokens[0].document; - return activeTokens[0]?.document ?? null; } function getSyntheticTokenDocument(actor) { - const tokenDocument = actor.token ?? actor.prototypeToken ?? null; + const tokenDocument = actor.token ?? null; if (tokenDocument?.documentName === "Token") return tokenDocument; return null; } +function sameActor(candidate, actor) { + return candidate?.uuid === actor.uuid || candidate?.id === actor.id; +} + export function getPrimaryOwner(actor) { if (!actor) return null; return game.users.find(user => user.active && !user.isGM && actor.testUserPermission(user, "OWNER")) ?? null;