/** * Active Effects Helper Module * * Provides utilities for managing Active Effects in Vagabond RPG. * Active Effects allow items (classes, perks, features) to modify actor stats, * crit thresholds, resources, and other values. * * Key Use Cases: * - Class features modifying crit thresholds for specific skills * - Perks adding bonuses to stats or saves * - Equipment providing armor or stat bonuses * - Conditions applying temporary penalties */ /** * Effect modes matching Foundry's CONST.ACTIVE_EFFECT_MODES */ export const EFFECT_MODES = { CUSTOM: 0, MULTIPLY: 1, ADD: 2, DOWNGRADE: 3, UPGRADE: 4, OVERRIDE: 5, }; /** * Common effect change keys for Vagabond RPG * Maps human-readable names to data paths */ export const EFFECT_KEYS = { // Stats "stat.might": "system.stats.might.value", "stat.dexterity": "system.stats.dexterity.value", "stat.awareness": "system.stats.awareness.value", "stat.reason": "system.stats.reason.value", "stat.presence": "system.stats.presence.value", "stat.luck": "system.stats.luck.value", // Resources "hp.bonus": "system.resources.hp.bonus", "mana.bonus": "system.resources.mana.bonus", "mana.castingMax": "system.resources.mana.castingMax", "luck.max": "system.resources.luck.max", "studiedDice.max": "system.resources.studiedDice.max", // Movement speeds "speed.walk": "system.speed.walk", "speed.fly": "system.speed.fly", "speed.swim": "system.speed.swim", "speed.climb": "system.speed.climb", "speed.burrow": "system.speed.burrow", "speed.bonus": "system.speed.bonus", armor: "system.armor", "itemSlots.bonus": "system.itemSlots.bonus", // Save bonuses (reduce difficulty) "save.reflex": "system.saves.reflex.bonus", "save.endure": "system.saves.endure.bonus", "save.will": "system.saves.will.bonus", // Skill crit thresholds "crit.arcana": "system.skills.arcana.critThreshold", "crit.brawl": "system.skills.brawl.critThreshold", "crit.craft": "system.skills.craft.critThreshold", "crit.detect": "system.skills.detect.critThreshold", "crit.finesse": "system.skills.finesse.critThreshold", "crit.influence": "system.skills.influence.critThreshold", "crit.leadership": "system.skills.leadership.critThreshold", "crit.medicine": "system.skills.medicine.critThreshold", "crit.mysticism": "system.skills.mysticism.critThreshold", "crit.performance": "system.skills.performance.critThreshold", "crit.sneak": "system.skills.sneak.critThreshold", "crit.survival": "system.skills.survival.critThreshold", // Attack crit thresholds "crit.attack.melee": "system.attacks.melee.critThreshold", "crit.attack.brawl": "system.attacks.brawl.critThreshold", "crit.attack.ranged": "system.attacks.ranged.critThreshold", "crit.attack.finesse": "system.attacks.finesse.critThreshold", // Senses (boolean, use OVERRIDE mode=5) "sense.darkvision": "system.senses.darkvision", "sense.blindsight": "system.senses.blindsight", "sense.allsight": "system.senses.allsight", "sense.echolocation": "system.senses.echolocation", "sense.seismicsense": "system.senses.seismicsense", "sense.telepathy": "system.senses.telepathy", // Movement capabilities (boolean, use OVERRIDE mode=5) "movement.fly": "system.movement.fly", "movement.swim": "system.movement.swim", "movement.climb": "system.movement.climb", "movement.cling": "system.movement.cling", "movement.phase": "system.movement.phase", // Skill training (boolean, use OVERRIDE mode=5) "skill.arcana.trained": "system.skills.arcana.trained", "skill.brawl.trained": "system.skills.brawl.trained", "skill.craft.trained": "system.skills.craft.trained", "skill.detect.trained": "system.skills.detect.trained", "skill.finesse.trained": "system.skills.finesse.trained", "skill.influence.trained": "system.skills.influence.trained", "skill.leadership.trained": "system.skills.leadership.trained", "skill.medicine.trained": "system.skills.medicine.trained", "skill.mysticism.trained": "system.skills.mysticism.trained", "skill.performance.trained": "system.skills.performance.trained", "skill.sneak.trained": "system.skills.sneak.trained", "skill.survival.trained": "system.skills.survival.trained", // Focus tracking "focus.maxConcurrent": "system.focus.maxConcurrent", // Status-related modifiers "damage.dealt.bonus": "system.modifiers.damageDealt", "healing.received.bonus": "system.modifiers.healingReceived", }; /** * Create an Active Effect data object from a simplified definition. * * @param {Object} options - Effect options * @param {string} options.name - Display name of the effect * @param {string} options.icon - Icon path * @param {Array} options.changes - Array of {key, value, mode} objects * @param {boolean} options.disabled - Whether effect starts disabled * @param {string} options.origin - UUID of the source item * @returns {Object} Active Effect data object */ export function createEffectData({ name, icon = "icons/svg/aura.svg", changes = [], disabled = false, origin = null, }) { // Convert simplified keys to full data paths const mappedChanges = changes.map((change) => ({ key: EFFECT_KEYS[change.key] || change.key, mode: change.mode ?? EFFECT_MODES.ADD, value: String(change.value), priority: change.priority ?? null, })); return { name, icon, changes: mappedChanges, disabled, origin, transfer: true, // Transfer to actor when item is owned }; } /** * Create a crit threshold reduction effect. * Common for class features that improve crits on specific skills. * * @param {string} skillOrAttack - Skill ID or "attack.type" * @param {number} reduction - Amount to reduce crit threshold (positive number) * @param {string} name - Display name * @param {string} origin - Source item UUID * @returns {Object} Active Effect data */ export function createCritReductionEffect(skillOrAttack, reduction, name, origin = null) { const key = skillOrAttack.startsWith("attack.") ? `crit.${skillOrAttack}` : `crit.${skillOrAttack}`; return createEffectData({ name, icon: "icons/svg/sword.svg", changes: [ { key, value: -Math.abs(reduction), // Negative to reduce threshold mode: EFFECT_MODES.ADD, }, ], origin, }); } /** * Create a stat bonus effect. * * @param {string} stat - Stat ID (might, dexterity, etc.) * @param {number} bonus - Bonus amount * @param {string} name - Display name * @param {string} origin - Source item UUID * @returns {Object} Active Effect data */ export function createStatBonusEffect(stat, bonus, name, origin = null) { return createEffectData({ name, icon: "icons/svg/upgrade.svg", changes: [ { key: `stat.${stat}`, value: bonus, mode: EFFECT_MODES.ADD, }, ], origin, }); } /** * Create a save bonus effect. * * @param {string} save - Save type (reflex, endure, will) * @param {number} bonus - Bonus amount (reduces difficulty) * @param {string} name - Display name * @param {string} origin - Source item UUID * @returns {Object} Active Effect data */ export function createSaveBonusEffect(save, bonus, name, origin = null) { return createEffectData({ name, icon: "icons/svg/shield.svg", changes: [ { key: `save.${save}`, value: bonus, mode: EFFECT_MODES.ADD, }, ], origin, }); } /** * Apply effects from an item to its parent actor. * Called when items with changes are added to an actor. * * @param {Item} item - The item with effects to apply * @returns {Promise} Created effects */ export async function applyItemEffects(item) { const actor = item.parent; if (!actor || !item.system.changes?.length) return []; const effectData = createEffectData({ name: item.name, icon: item.img, changes: item.system.changes, origin: item.uuid, }); return actor.createEmbeddedDocuments("ActiveEffect", [effectData]); } /** * Remove effects originating from a specific item. * * @param {Actor} actor - The actor to remove effects from * @param {string} itemUuid - UUID of the source item * @returns {Promise} */ export async function removeItemEffects(actor, itemUuid) { const effects = actor.effects.filter((e) => e.origin === itemUuid); if (effects.length) { const ids = effects.map((e) => e.id); await actor.deleteEmbeddedDocuments("ActiveEffect", ids); } } /** * Get all effects on an actor grouped by source type. * * @param {Actor} actor - The actor to analyze * @returns {Object} Effects grouped by source (class, perk, feature, equipment, other) */ export function getEffectsBySource(actor) { const grouped = { class: [], perk: [], feature: [], equipment: [], status: [], temporary: [], other: [], }; for (const effect of actor.effects) { if (!effect.origin) { grouped.temporary.push(effect); continue; } // Try to determine source type from origin UUID const sourceItem = fromUuidSync(effect.origin); if (sourceItem) { const type = sourceItem.type; if (grouped[type]) { grouped[type].push(effect); } else { grouped.other.push(effect); } } else { grouped.other.push(effect); } } return grouped; }