diff --git a/ClaudeSetup2.sln b/ClaudeSetup2.sln deleted file mode 100644 index 9d8f7985..00000000 --- a/ClaudeSetup2.sln +++ /dev/null @@ -1,17 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.2.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {0CB29559-B831-47FA-BF24-985EB8A78794} - EndGlobalSection -EndGlobal diff --git a/macro_activate-hp-tracking.js b/macro_activate-hp-tracking.js new file mode 100644 index 00000000..d1b1ef93 --- /dev/null +++ b/macro_activate-hp-tracking.js @@ -0,0 +1,241 @@ +/** + * Activate HP tracking for a single actor. + * Drop this script into a Foundry macro. + */ + +// Change this lookup (or set ACTOR_ID directly) to match the actor you want to monitor. +const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null; +const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here"; // e.g. replace string with actor id +const HP_PATH = "system.attributes.hp.value"; + +if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") { + return ui.notifications.warn("Set ACTOR_ID before running the macro."); +} + +game.pf1 ??= {}; +game.pf1.hpLogger ??= {}; +game.pf1.hpLogger.sources ??= new Map(); +game.pf1.hpLogger.current ??= {}; + +const logKey = `pf1-hp-log-${ACTOR_ID}`; + +// Remove existing hook for the same actor so re-running stays idempotent. +if (game.pf1.hpLogger[logKey]) { + Hooks.off("updateActor", game.pf1.hpLogger[logKey]); + delete game.pf1.hpLogger[logKey]; +} + +ensureDamageSourceTracking(); +primeHpSnapshot(game.actors.get(ACTOR_ID) ?? TARGET_ACTOR); + +game.pf1.hpLogger[logKey] = Hooks.on("updateActor", (actor, change, options, userId) => { + if (actor.id !== ACTOR_ID) return; + + const newHP = readHpValue(actor); + if (newHP === null) return; + + const previous = game.pf1.hpLogger.current[ACTOR_ID]; + if (previous === undefined) { + game.pf1.hpLogger.current[ACTOR_ID] = newHP; + return; + } + if (Object.is(newHP, previous)) return; // No HP change. + + const diff = newHP - previous; + const diffText = diff >= 0 ? `+${diff}` : `${diff}`; + const user = game.users.get(userId); + const source = consumeDamageSource(actor.id) ?? inferManualSource(diff); + const sourceLine = source ? `
Source: ${source}` : ""; + + ChatMessage.create({ + content: `HP Monitor
${actor.name} HP: ${previous} -> ${newHP} (${diffText})${user ? ` by ${user.name}` : ""}${sourceLine}`, + speaker: { alias: "System" }, + }); + + game.pf1.hpLogger.current[ACTOR_ID] = newHP; +}); + +const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID; +ui.notifications.info(`HP change hook active for ${actorName}.`); + +/** + * Consume and clear the stored damage source for this actor, if any. + * @param {string} actorId + */ +function consumeDamageSource(actorId) { + const entry = game.pf1.hpLogger.sources.get(actorId); + if (!entry) return null; + game.pf1.hpLogger.sources.delete(actorId); + return entry.label; +} + +/** + * Provide a fallback label when the change was manual or unidentified. + * @param {number} diff + */ +function inferManualSource(diff) { + if (!Number.isFinite(diff) || diff === 0) return null; + return diff > 0 ? "Manual healing" : "Manual damage"; +} + +/** + * Ensure ActorPF.applyDamage is wrapped so we can capture the originating chat card. + */ +function ensureDamageSourceTracking() { + const state = game.pf1.hpLogger; + if (state.applyDamageWrapped) return; + + const ActorPF = pf1?.documents?.actor?.ActorPF; + if (!ActorPF?.applyDamage) return; + + const original = ActorPF.applyDamage; + ActorPF.applyDamage = async function wrappedApplyDamage(value, options = {}) { + try { + noteDamageSource(value, options); + } catch (err) { + console.warn("HP Logger | Failed to record damage source", err); + } + return original.call(this, value, options); + }; + + state.applyDamageWrapped = true; +} + +/** + * Record the best-guess label for the HP change if the tracked actor is among the targets. + * @param {number} value + * @param {object} options + */ +function noteDamageSource(value, options) { + const actors = resolveActorTargets(options?.targets); + if (!actors.some((a) => a?.id === ACTOR_ID)) return; + + const label = buildSourceLabel(value, options); + if (!label) return; + game.pf1.hpLogger.sources.set(ACTOR_ID, { label, ts: Date.now() }); +} + +/** + * Try to describe where the HP change originated. + * @param {number} value + * @param {object} options + */ +function buildSourceLabel(value, options = {}) { + const info = options.message?.flags?.pf1?.identifiedInfo ?? {}; + const metadata = options.message?.flags?.pf1?.metadata ?? {}; + const fromChatFlavor = options.message?.flavor?.trim(); + + const actorDoc = resolveActorFromMetadata(metadata); + const itemDoc = actorDoc ? resolveItemFromMetadata(actorDoc, metadata) : null; + const actorName = actorDoc?.name ?? options.message?.speaker?.alias ?? null; + const actionName = info.actionName ?? info.name ?? itemDoc?.name ?? fromChatFlavor ?? null; + + let label = null; + if (actorName && actionName) label = `${actorName} -> ${actionName}`; + else if (actionName) label = actionName; + else if (actorName) label = actorName; + else label = value < 0 ? "Healing" : "Damage"; + + if (options.isCritical) label += " (Critical)"; + if (options.asNonlethal) label += " [Nonlethal]"; + + return label; +} + +/** + * Store the actor's current HP so the first change has a baseline. + * @param {Actor|null} actor + */ +function primeHpSnapshot(actor) { + const hp = readHpValue(actor); + if (hp === null) return; + game.pf1.hpLogger.current[ACTOR_ID] = hp; +} + +/** + * Resolve actors targeted by the damage application. + * Falls back to currently controlled tokens if no explicit targets were supplied. + * @param {Array} targets + * @returns {Actor[]} + */ +function resolveActorTargets(targets) { + let list = []; + if (Array.isArray(targets) && targets.length) list = targets; + else list = canvas?.tokens?.controlled ?? []; + + return list + .map((entry) => { + if (!entry) return null; + if (entry instanceof Actor) return entry; + if (entry.actor) return entry.actor; + if (entry.document?.actor) return entry.document.actor; + return null; + }) + .filter((actor) => actor instanceof Actor && actor.id); +} + +/** + * Read HP from the actor using several possible data paths. + * @param {Actor|null} actor + * @returns {number|null} + */ +function readHpValue(actor) { + if (!actor) return null; + + const candidates = [ + HP_PATH, + HP_PATH.replace(/^system\./, "data."), + HP_PATH.replace(/^system\./, ""), + ]; + + for (const path of candidates) { + const value = foundry.utils.getProperty(actor, path); + if (value !== undefined) { + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : null; + } + if (actor.system) { + const trimmed = path.startsWith("system.") ? path.slice(7) : path; + const systemValue = foundry.utils.getProperty(actor.system, trimmed); + if (systemValue !== undefined) { + const numeric = Number(systemValue); + return Number.isFinite(numeric) ? numeric : null; + } + } + } + + return null; +} + +/** + * Fetch the actor referenced in the chat card metadata, if any. + * @param {object} metadata + * @returns {Actor|null} + */ +function resolveActorFromMetadata(metadata = {}) { + if (!metadata.actor) return null; + + if (typeof fromUuidSync === "function") { + try { + const doc = fromUuidSync(metadata.actor); + if (doc instanceof Actor) return doc; + } catch (err) { + console.warn("HP Logger | Failed to resolve actor UUID", err); + } + } + + // Fallback: assume uuid ends with actor id + const id = metadata.actor.split(".").pop(); + return game.actors.get(id) ?? null; +} + +/** + * Fetch the item referenced in the chat card metadata from the given actor. + * @param {Actor} actor + * @param {object} metadata + * @returns {Item|null} + */ +function resolveItemFromMetadata(actor, metadata = {}) { + if (!metadata.item || !(actor?.items instanceof Collection)) return null; + return actor.items.get(metadata.item) ?? null; +} diff --git a/macro_stop-hp-tracking.js b/macro_stop-hp-tracking.js new file mode 100644 index 00000000..6f648040 --- /dev/null +++ b/macro_stop-hp-tracking.js @@ -0,0 +1,34 @@ +/** + * Deactivate HP tracking for the configured actor. + * Use after running macro_activate-hp-tracking.js to stop logging. + */ + +const TARGET_ACTOR = game.actors.getName("Zeratal") ?? null; +const ACTOR_ID = TARGET_ACTOR?.id ?? "put-actor-id-here"; + +if (!ACTOR_ID || ACTOR_ID === "put-actor-id-here") { + return ui.notifications.warn("Set ACTOR_ID before running the macro."); +} + +game.pf1 ??= {}; +game.pf1.hpLogger ??= {}; + +const logKey = `pf1-hp-log-${ACTOR_ID}`; +const handler = game.pf1.hpLogger[logKey]; + +if (!handler) { + return ui.notifications.info(`No HP tracking hook found for ${TARGET_ACTOR?.name ?? ACTOR_ID}.`); +} + +Hooks.off("updateActor", handler); +delete game.pf1.hpLogger[logKey]; + +if (game.pf1.hpLogger.sources instanceof Map) { + game.pf1.hpLogger.sources.delete(ACTOR_ID); +} +if (game.pf1.hpLogger.current) { + delete game.pf1.hpLogger.current[ACTOR_ID]; +} + +const actorName = game.actors.get(ACTOR_ID)?.name ?? TARGET_ACTOR?.name ?? ACTOR_ID; +ui.notifications.info(`HP tracking disabled for ${actorName}.`); diff --git a/src/FoundryVTT-11.315/foundryvtt.7z b/src/FoundryVTT-11.315/foundryvtt.7z new file mode 100644 index 00000000..2a45a9c1 Binary files /dev/null and b/src/FoundryVTT-11.315/foundryvtt.7z differ diff --git a/src/macro_arcanePool_applyEffects.js b/src/macro_arcanePool_applyEffects.js new file mode 100644 index 00000000..3efa6cb6 --- /dev/null +++ b/src/macro_arcanePool_applyEffects.js @@ -0,0 +1,168 @@ +(async () => { + try { + const FLAG_SCOPE = "world"; + const FLAG_ROOT = "arcanePool"; + const flagPath = (key) => `${FLAG_ROOT}.${key}`; + const getFlag = (doc, key) => doc.getFlag(FLAG_SCOPE, flagPath(key)); + const setFlag = (doc, key, value) => doc.setFlag(FLAG_SCOPE, flagPath(key), value); + const readBuffFlag = (doc, key) => doc.getFlag(FLAG_SCOPE, flagPath(key)); + + // Normalize arguments + const rawArgs = Array.isArray(args) ? args : []; + const options = rawArgs[0] ?? {}; + + const actorId = options.actorId ?? actor?.id; + if (!actorId) { + console.warn("Arcane Pool AE: Missing actor id"); + return false; + } + + const targetActor = game.actors.get(actorId); + if (!targetActor) { + console.warn(`Arcane Pool AE: Actor ${actorId} not found`); + return false; + } + + // Merge config updates when provided + const currentConfig = getFlag(targetActor, "config") ?? {}; + const configUpdates = { + weaponId: options.weaponId, + enhancementBonus: options.enhancementBonus, + poolCost: options.poolCost, + enduringBlade: options.enduringBlade, + ghostBlade: options.ghostBlade, + }; + + const sanitizedUpdates = Object.fromEntries( + Object.entries(configUpdates).filter(([, value]) => value !== undefined) + ); + + const updatedConfig = { + ...currentConfig, + ...sanitizedUpdates, + }; + + if (!foundry.utils.isEmpty(updatedConfig) && !foundry.utils.isEqual(updatedConfig, currentConfig)) { + await setFlag(targetActor, "config", updatedConfig); + } + + const config = getFlag(targetActor, "config"); + const weaponId = config?.weaponId; + if (!weaponId) { + console.warn("Arcane Pool AE: No weaponId in config"); + return false; + } + + const weapon = targetActor.items.get(weaponId); + if (!weapon) { + console.warn(`Arcane Pool AE: Weapon ${weaponId} not found on actor ${targetActor.name}`); + return false; + } + + // Locate primary attack action (default "Attack") + const attacks = weapon.system?.actions ?? []; + const attackIndex = attacks.findIndex((action) => (action?.name ?? "").toLowerCase() === "attack"); + const attackPath = attackIndex >= 0 ? `system.actions.${attackIndex}` : null; + + // Cache baseline weapon data to allow clean restoration + let baseline = getFlag(weapon, "baseline"); + const baselineWeaponId = baseline?.weaponId; + + if (!baseline || baselineWeaponId !== weaponId) { + baseline = { + weaponId, + enh: weapon.system?.enh ?? 0, + damageParts: foundry.utils.duplicate(weapon.system?.damage?.parts ?? []), + attackAbility: attackPath + ? { + critRange: foundry.utils.getProperty(weapon.system, `${attackPath}.ability.critRange`) ?? null, + critMult: foundry.utils.getProperty(weapon.system, `${attackPath}.ability.critMult`) ?? null, + } + : null, + }; + + await setFlag(weapon, "baseline", baseline); + } + + const activeBuffs = targetActor.items.filter( + (item) => item.type === "buff" && item.system?.active && readBuffFlag(item, "tag") + ); + + const baseBuff = activeBuffs.find((buff) => readBuffFlag(buff, "role") === "base"); + + // Restore baseline if base buff is inactive + if (!baseBuff) { + const restoreData = { + "system.enh": baseline.enh ?? 0, + "system.damage.parts": baseline.damageParts ?? [], + "flags.arcanePool.active": false, + "flags.arcanePool.activeProperties": [], + }; + + if (baseline.attackAbility && attackPath) { + if (baseline.attackAbility.critRange !== null) { + restoreData[`${attackPath}.ability.critRange`] = baseline.attackAbility.critRange; + } + if (baseline.attackAbility.critMult !== null) { + restoreData[`${attackPath}.ability.critMult`] = baseline.attackAbility.critMult; + } + } + + await weapon.update(restoreData); + return true; + } + + const enhancementBonus = + Number(config?.enhancementBonus ?? readBuffFlag(baseBuff, "enhancementBonus") ?? 0) || 0; + + // Build new damage array starting from baseline + const newDamage = foundry.utils.duplicate(baseline.damageParts ?? []); + const activeProperties = []; + + for (const buff of activeBuffs) { + if (buff.id === baseBuff.id) continue; + + const propertyKey = readBuffFlag(buff, "property"); + if (!propertyKey) continue; + + activeProperties.push(propertyKey); + + const damageFormula = readBuffFlag(buff, "damageFormula"); + if (!damageFormula) continue; + + const parts = damageFormula.split("+").map((segment) => segment.trim()).filter(Boolean); + for (const part of parts) { + const match = part.match(/([^[]+)\[([^]+)]/); + if (!match) continue; + const [, dice, damageType] = match; + newDamage.push([dice.trim(), damageType.trim()]); + } + } + + const updateData = { + "system.enh": (baseline.enh ?? 0) + enhancementBonus, + "system.damage.parts": newDamage, + "flags.arcanePool.active": true, + "flags.arcanePool.activeProperties": activeProperties, + "flags.arcanePool.lastApplied": Date.now(), + }; + + // Keen, Speed, and other non-damage properties currently rely on player toggles. + // Additional automation can be added by extending property handlers here. + if (baseline.attackAbility && attackPath) { + if (baseline.attackAbility.critRange !== null) { + updateData[`${attackPath}.ability.critRange`] = baseline.attackAbility.critRange; + } + if (baseline.attackAbility.critMult !== null) { + updateData[`${attackPath}.ability.critMult`] = baseline.attackAbility.critMult; + } + } + + await weapon.update(updateData); + return true; + } catch (error) { + console.error("Arcane Pool AE: Failed to apply effects", error); + ui.notifications.error("Failed to synchronize Arcane Pool effects."); + return false; + } +})(); diff --git a/src/macro_arcaneSelector.js b/src/macro_arcaneSelector.js index 1264d5c8..0827554d 100644 --- a/src/macro_arcaneSelector.js +++ b/src/macro_arcaneSelector.js @@ -1,103 +1,313 @@ (async () => { - // Ensure a token is selected if (!token) { ui.notifications.warn("You must select a token!"); return; } - let ac = actor; // game.actors.getName("Zeratal"); - let level = Math.floor((ac.classes["magus"].level - 1) / 4 + 1); - let arcanePool = ac.system.resources.classFeat_arcanePool.value; + const ac = actor; + const magusLevel = ac.classes?.magus?.level ?? 0; + const enhancementCap = Math.max(1, Math.floor((magusLevel - 1) / 4 + 1)); + const arcanePool = ac.system?.resources?.classFeat_arcanePool?.value ?? 0; - console.log(level, arcanePool); + const FLAG_SCOPE = "world"; + const FLAG_ROOT = "arcanePool"; + const flagPath = (key) => `${FLAG_ROOT}.${key}`; + const getFlag = (doc, key, fallback) => { + const value = doc.getFlag(FLAG_SCOPE, flagPath(key)); + return value === undefined ? fallback : value; + }; + const setFlag = (doc, key, value) => doc.setFlag(FLAG_SCOPE, flagPath(key), value); + const readBuffFlag = (doc, key, fallback) => { + const value = doc.getFlag(FLAG_SCOPE, flagPath(key)); + return value === undefined ? fallback : value; + }; + const setBuffFlag = (doc, key, value) => doc.setFlag(FLAG_SCOPE, flagPath(key), value); - function openDialog() { - let dialogContent = ` + function isAttackActive(item) { + if (item.type !== "attack") return false; + + const equipped = item.system?.equipped; + if (typeof equipped === "boolean") return equipped; + + const held = item.system?.held; + if (typeof held === "string" && held.length) { + const normalized = held.toLowerCase(); + if (["none", "not", "unheld", "disabled", "inactive"].includes(normalized)) return false; + } + + return true; + } + + const equippedWeapons = (ac.itemTypes?.attack ?? ac.items.filter((item) => item.type === "attack")).filter( + isAttackActive + ); + if (!equippedWeapons.length) { + ui.notifications.error("No equipped weapon found! Equip a weapon before using Arcane Pool."); + return; + } + + const existingConfig = getFlag(ac, "config", {}) ?? {}; + const currentWeapon = + equippedWeapons.find((item) => item.id === existingConfig.weaponId) ?? equippedWeapons[0]; + + const SYNC_MACRO_NAME = "_arcanePoolApplyEffects"; + const OPTION_KEY_MAP = { + Keen: "keen", + "Ghost Touch": "ghostTouch", + Speed: "speed", + Dancing: "dancing", + "Brilliant Energy": "brilliantEnergy", + Vorpal: "vorpal", + Flaming: "flaming", + "Flaming Burst": "flamingBurst", + Frost: "frost", + "Icy Burst": "icyBurst", + Shock: "shock", + "Shocking Burst": "shockingBurst", + }; + + const BUFF_DEFINITIONS = { + base: { + name: "Arcane Pool (Enhancement)", + aliases: ["Arcane Pool", "Arcane Pool - Enhancement"], + img: "icons/magic/defensive/shield-barrier-flaming-pentagon.webp", + role: "base", + }, + keen: { + name: "Keen", + aliases: ["Arcane Pool - Keen"], + img: "icons/skills/melee/weapons-crossed-swords-yellow.webp", + property: "keen", + }, + ghostTouch: { + name: "Ghost Touch", + aliases: ["Arcane Pool - Ghost Touch"], + img: "icons/magic/perception/eye-slit-pink.webp", + property: "ghost-touch", + }, + speed: { + name: "Speed", + aliases: ["Arcane Pool - Speed"], + img: "icons/magic/movement/trail-streak-zigzag-yellow.webp", + property: "speed", + }, + dancing: { + name: "Dancing", + aliases: ["Arcane Pool - Dancing"], + img: "icons/magic/control/fear-fright-monster-green.webp", + property: "dancing", + }, + brilliantEnergy: { + name: "Brilliant Energy", + aliases: ["Arcane Pool - Brilliant Energy"], + img: "icons/magic/light/projectile-beam-strike-yellow.webp", + property: "brilliant-energy", + }, + vorpal: { + name: "Vorpal", + aliases: ["Arcane Pool - Vorpal"], + img: "icons/skills/melee/blade-tip-triple-blue.webp", + property: "vorpal", + }, + flaming: { + name: "Flaming", + aliases: ["Arcane Pool - Flaming"], + img: "icons/magic/fire/flame-burning-sword.webp", + property: "flaming", + damageFormula: "1d6[fire]", + }, + flamingBurst: { + name: "Flaming Burst", + aliases: ["Arcane Pool - Flaming Burst"], + img: "icons/magic/fire/explosion-fireball-medium-red.webp", + property: "flaming-burst", + damageFormula: "1d6[fire]+1d10[fire]", + }, + frost: { + name: "Frost", + aliases: ["Arcane Pool - Frost"], + img: "icons/magic/water/snowflake-ice-blue.webp", + property: "frost", + damageFormula: "1d6[cold]", + }, + icyBurst: { + name: "Icy Burst", + aliases: ["Arcane Pool - Icy Burst"], + img: "icons/magic/water/projectile-ice-snowball.webp", + property: "icy-burst", + damageFormula: "1d6[cold]+1d10[cold]", + }, + shock: { + name: "Shock", + aliases: ["Arcane Pool - Shock"], + img: "icons/magic/lightning/bolt-forked-blue.webp", + property: "shock", + damageFormula: "1d6[electricity]", + }, + shockingBurst: { + name: "Shocking Burst", + aliases: ["Arcane Pool - Shocking Burst"], + img: "icons/magic/lightning/bolt-strike-beam-blue.webp", + property: "shocking-burst", + damageFormula: "1d6[electricity]+1d10[electricity]", + }, + }; + + async function ensureSyncScript(buff) { + const script = ` +const macro = game.macros.getName("${SYNC_MACRO_NAME}"); +if (macro) { + macro.execute([{ actorId: this.actor?.id }]); +}`; + + const scriptCalls = foundry.utils.duplicate(buff.system?.scriptCalls ?? []); + const existing = scriptCalls.find((entry) => entry.name === "Arcane Pool Sync"); + + if (existing) { + if (existing.value !== script) { + existing.value = script; + await buff.update({ "system.scriptCalls": scriptCalls }); + } + return; + } + + scriptCalls.push({ + _id: foundry.utils.randomID(8), + name: "Arcane Pool Sync", + img: "icons/svg/coins.svg", + type: "script", + category: "toggle", + value: script, + hidden: false, + }); + + await buff.update({ "system.scriptCalls": scriptCalls }); + } + + async function ensureArcanePoolBuff(actorDocument, key) { + const definition = BUFF_DEFINITIONS[key]; + if (!definition) return null; + + const lookupNames = [definition.name, ...(definition.aliases ?? [])]; + const buff = actorDocument.items.find( + (item) => item.type === "buff" && lookupNames.includes(item.name) + ); + + if (!buff) { + const message = `Arcane Pool requires the "${definition.name}" buff on ${actorDocument.name}. Please add it to the actor.`; + ui.notifications.error(message); + throw new Error(message); + } + + if (readBuffFlag(buff, "tag") !== key) await setBuffFlag(buff, "tag", key); + if (readBuffFlag(buff, "role") !== (definition.role ?? "property")) { + await setBuffFlag(buff, "role", definition.role ?? "property"); + } + if (readBuffFlag(buff, "property") !== (definition.property ?? null)) { + await setBuffFlag(buff, "property", definition.property ?? null); + } + if (readBuffFlag(buff, "damageFormula") !== (definition.damageFormula ?? null)) { + await setBuffFlag(buff, "damageFormula", definition.damageFormula ?? null); + } + + await ensureSyncScript(buff); + return buff; + } + + const DIALOG_WIDTH = 480; // 20% wider than the default ~400px dialog + + function buildDialog(weaponName) { + return `
+
+

⚒️ ${weaponName}

+

Enhancements are applied via Arcane Pool buff items and stay in sync with any toggle source.

+
+
- +
- +
+ 🔮 Arcana Options:
- +
- +
- + @@ -140,220 +350,230 @@
Fire - None - Flaming - Flaming Burst + None + Flaming + Flaming Burst 0
Ice - None - Frost - Icy Burst + None + Frost + Icy Burst 0
Lightning - None - Shock - Shocking Burst + None + Shock + Shocking Burst 0
- +
-
Enhancement Bonus Used: 0 / ${level}
+
Enhancement Bonus Used: 0 / ${enhancementCap}
Arcane Pool Cost: 1 point(s)
+
Weapon Enhancement Applied: ${enhancementCap}
`; - - new Dialog({ - title: "Arcane Pool - Weapon Enhancement", - content: dialogContent, - buttons: { - confirm: { - label: "Apply Enhancements", - callback: async (html) => { - // Read all selections - let checkboxes = html.find('input[type="checkbox"]:checked'); - let radios = html.find('input[type="radio"]:checked'); - let enhancementUsed = parseInt(html.find("#enhancement-used").text()) || 0; - let poolCost = parseInt(html.find("#pool-cost").text()) || 1; - - // Collect all selected buffs - let selectedBuffs = []; - checkboxes.each(function() { - // Skip the arcana checkboxes (enduring-blade and ghost-blade) - if (this.id !== "enduring-blade" && this.id !== "ghost-blade") { - console.log(this.id); - selectedBuffs.push(this.id); - } - }); - radios.each(function() { - let value = $(this).val(); - if (value !== "0") { - selectedBuffs.push(this.id); - } - }); - console.log(actor); - - // Check if Enduring Blade is selected - if (html.find("#enduring-blade").is(":checked")) { - var bubb = actor.items.find(o => o.name === "Enduring Blade" && o.type ==="buff"); - selectedBuffs.push("Enduring Blade"); - } - - // Check if Ghost Blade is selected - if (html.find("#ghost-blade").is(":checked")) { - var bubb = actor.items.find(o => o.name === "Ghost Blade" && o.type ==="buff"); - selectedBuffs.push("Ghost Blade"); - } - - // Get all required macros upfront - const setBuffStatusMacro = game.macros.getName("_callSetBuffStatus"); - const changePoolBonusMacro = game.macros.getName("_callChangeArcanePoolBonus"); - const changePoolMacro = game.macros.getName("_callChangeArcanePool"); - - // Validate all macros exist - if (!setBuffStatusMacro || !changePoolBonusMacro || !changePoolMacro) { - ui.notifications.error("Required helper macros not found!"); - console.error("Missing macros:", { - setBuffStatus: !!setBuffStatusMacro, - changePoolBonus: !!changePoolBonusMacro, - changePool: !!changePoolMacro - }); - return; - } - - // Activate all selected buffs - // NOTE: Each buff toggle triggers its own macro that must complete before - // attack rolls can see the buff effects. The 150ms delay allows those - // buff macros to finish processing and apply effects to the character. - for (const buffName of selectedBuffs) { - await setBuffStatusMacro.execute({ - name: buffName, - status: true - }); - // Allow buff's triggered macro to complete and apply effects - await new Promise(resolve => setTimeout(resolve, 150)); - } - - // Activate Arcane Pool buff - await setBuffStatusMacro.execute({ - name: "Arcane Pool", - status: true - }); - // Allow Arcane Pool buff macro to complete - await new Promise(resolve => setTimeout(resolve, 150)); - - // Update enhancement bonus - await changePoolBonusMacro.execute({value: enhancementUsed}); - - // Deduct pool points - await changePoolMacro.execute({value: -poolCost}); - - ui.notifications.info(`Arcane Pool activated! Enhancements: ${selectedBuffs.join(', ')} | Pool Cost: ${poolCost} points`); - }, - disabled: true - }, - cancel: { - label: "Cancel" - } - }, - default: "confirm", - render: (html) => { - const confirmButton = html.closest('.dialog').find('button[data-button="confirm"]'); - - // Handle Ghost Blade checkbox to enable/disable dependent options - html.find('#ghost-blade').on('change', function() { - const isChecked = $(this).is(':checked'); - html.find('.enhancement-option[data-requires="ghost-blade"]').prop('disabled', !isChecked); - if (!isChecked) { - // Uncheck Ghost Touch and Brilliant Energy if Ghost Blade is unchecked - html.find('.enhancement-option[data-requires="ghost-blade"]').prop('checked', false); - } - calculateSum(); - }); - - // Attach the calculateSum function to inputs after the dialog is rendered - html.find('.action, .arcana-checkbox, #input-value').on('change', calculateSum); - - function calculateSum() { - let enhancementSum = 0; - let inputValue = parseInt(html.find('#input-value').val()) || 0; - let rows = html.find('[name="InputTable"] tbody tr'); - - // Calculate enhancement bonus used - rows.each(function() { - let actionCell = $(this).find('.action:checked'); - let valueCell = $(this).find('.value'); - let value = 0; - - if (actionCell.length) { - value = parseInt(actionCell.val()); - enhancementSum += value; - // Color code the cost - valueCell.removeClass('cost-high cost-medium cost-low'); - if (value >= 4) valueCell.addClass('cost-high'); - else if (value >= 2) valueCell.addClass('cost-medium'); - else if (value > 0) valueCell.addClass('cost-low'); - } else { - valueCell.removeClass('cost-high cost-medium cost-low'); - } - - valueCell.text(value); - }); - - // Calculate arcane pool cost (base 1 + arcana costs) - let poolCost = 1; - html.find('.arcana-checkbox:checked').each(function() { - poolCost += parseInt($(this).val()) || 0; - }); - - html.find('#enhancement-used').text(enhancementSum); - html.find('#pool-cost').text(poolCost); - - // Check constraints - let availablePool = parseInt(html.find('#arcane-pool-value').val()) || 0; - let enhancementExceeded = enhancementSum > inputValue; - let poolInsufficient = poolCost > availablePool; - - // Update display warnings - if (enhancementExceeded) { - html.find('#enhancement-used').addClass('warning'); - } else { - html.find('#enhancement-used').removeClass('warning'); - } - - if (poolInsufficient) { - html.find('#pool-cost').addClass('warning'); - } else { - html.find('#pool-cost').removeClass('warning'); - } - - // Enable/disable confirm button - if (enhancementExceeded || poolInsufficient) { - confirmButton.prop('disabled', true).css('background-color', 'red'); - } else { - confirmButton.prop('disabled', false).css('background-color', ''); - } - } - - // Initial calculation on render - calculateSum(); - } - }).render(true); } - // Call the function to open the dialog - openDialog(); -})(); \ No newline at end of file + function gatherSelections(html) { + const selections = []; + + html.find('input[type="checkbox"]:checked').each(function () { + if (this.id && this.id !== "enduring-blade" && this.id !== "ghost-blade") { + const value = parseInt(this.value) || 0; + selections.push({ id: this.id, cost: value, damage: $(this).data("damage") || "" }); + } + }); + + html.find('input[type="radio"]:checked').each(function () { + const value = parseInt(this.value) || 0; + if (value > 0 && this.id) { + selections.push({ id: this.id, cost: value, damage: $(this).data("damage") || "" }); + } + }); + + return selections; + } + + new Dialog({ + title: `Arcane Pool - ${currentWeapon.name}`, + content: buildDialog(currentWeapon.name), + buttons: { + confirm: { + label: "Apply Enhancements", + icon: '', + callback: async (html) => { + try { + const syncMacro = game.macros.getName(SYNC_MACRO_NAME); + if (!syncMacro) { + ui.notifications.error( + `Missing helper macro "${SYNC_MACRO_NAME}". Please create it with macro_arcanePool_applyEffects.js.` + ); + return; + } + + const enhancementUsed = parseInt(html.find("#enhancement-used").text()) || 0; + const poolCost = parseInt(html.find("#pool-cost").text()) || 1; + const enduringBlade = html.find("#enduring-blade").is(":checked"); + const ghostBlade = html.find("#ghost-blade").is(":checked"); + + if (enhancementUsed > enhancementCap) { + ui.notifications.error("Enhancement bonus exceeds current cap."); + return; + } + + const currentPool = ac.system.resources.classFeat_arcanePool.value ?? 0; + if (poolCost > currentPool) { + ui.notifications.error("Insufficient Arcane Pool points."); + return; + } + + const selections = gatherSelections(html); + const desiredKeys = new Set(["base"]); + + for (const sel of selections) { + const mapped = OPTION_KEY_MAP[sel.id]; + if (mapped) desiredKeys.add(mapped); + } + + const baseEnhancement = Math.max(0, enhancementCap - enhancementUsed); + + const ensuredBuffs = new Map(); + for (const key of desiredKeys) { + const buff = await ensureArcanePoolBuff(ac, key); + ensuredBuffs.set(key, buff); + } + + const baseBuff = ensuredBuffs.get("base"); + if (baseBuff) { + await setBuffFlag(baseBuff, "weaponId", currentWeapon.id); + await setBuffFlag(baseBuff, "enhancementBonus", baseEnhancement); + await setBuffFlag(baseBuff, "poolCost", poolCost); + await setBuffFlag(baseBuff, "enduringBlade", enduringBlade); + await setBuffFlag(baseBuff, "ghostBlade", ghostBlade); + } + + const arcaneBuffs = ac.items.filter( + (item) => item.type === "buff" && readBuffFlag(item, "tag") + ); + + for (const buff of arcaneBuffs) { + const tag = readBuffFlag(buff, "tag"); + const shouldBeActive = desiredKeys.has(tag); + if (buff.system?.active !== shouldBeActive) { + await buff.update({ "system.active": shouldBeActive }); + } + } + + await setFlag(ac, "config", { + weaponId: currentWeapon.id, + enhancementBonus: baseEnhancement, + poolCost, + enduringBlade, + ghostBlade, + }); + + await syncMacro.execute([ + { + actorId: ac.id, + weaponId: currentWeapon.id, + enhancementBonus: baseEnhancement, + poolCost, + enduringBlade, + ghostBlade, + }, + ]); + + await ac.update({ + "system.resources.classFeat_arcanePool.value": Math.max(currentPool - poolCost, 0), + }); + + const labels = selections.map((sel) => sel.id); + const appliedLabel = + labels.length > 0 + ? `${labels.join(", ")} with +${baseEnhancement} enhancement` + : `+${baseEnhancement} weapon enhancement`; + ui.notifications.info( + `Arcane Pool applied to ${currentWeapon.name}: ${appliedLabel}` + ); + } catch (error) { + console.error("Arcane Pool selector failed:", error); + ui.notifications.error(error.message ?? "Failed to apply Arcane Pool enhancements."); + } + }, + disabled: true, + }, + cancel: { + label: "Cancel", + }, + }, + default: "confirm", + render: (html) => { + const confirmButton = html.closest(".dialog").find('button[data-button="confirm"]'); + + html.find("#ghost-blade").on("change", function () { + const isChecked = $(this).is(":checked"); + html.find('.enhancement-option[data-requires="ghost-blade"]').prop("disabled", !isChecked); + if (!isChecked) { + html.find('.enhancement-option[data-requires="ghost-blade"]').prop("checked", false); + } + calculateSum(); + }); + + html.find(".action, .arcana-checkbox, #input-value").on("change", calculateSum); + + calculateSum(); + + function calculateSum() { + let enhancementSum = 0; + const cap = parseInt(html.find("#input-value").val()) || 0; + const poolAvailable = parseInt(html.find("#arcane-pool-value").val()) || 0; + + html.find('[name="InputTable"] tbody tr').each(function () { + const actionCell = $(this).find(".action:checked"); + const valueCell = $(this).find(".value"); + const value = actionCell.length ? parseInt(actionCell.val()) : 0; + + if (value > 0) enhancementSum += value; + + valueCell.text(value); + valueCell.removeClass("cost-high cost-medium cost-low"); + if (value >= 4) valueCell.addClass("cost-high"); + else if (value >= 2) valueCell.addClass("cost-medium"); + else if (value > 0) valueCell.addClass("cost-low"); + }); + + let poolCost = 1; + html.find(".arcana-checkbox:checked").each(function () { + poolCost += parseInt($(this).val()) || 0; + }); + + html.find("#enhancement-used").text(enhancementSum); + html.find("#pool-cost").text(poolCost); + const baseEnhancement = Math.max(0, cap - enhancementSum); + html.find("#base-enhancement").text(baseEnhancement); + + const enhancementExceeded = enhancementSum > cap; + const poolInsufficient = poolCost > poolAvailable; + + html.find("#enhancement-used").toggleClass("warning", enhancementExceeded); + html.find("#pool-cost").toggleClass("warning", poolInsufficient); + + confirmButton.prop("disabled", enhancementExceeded || poolInsufficient); + } + }, + }, { width: DIALOG_WIDTH }).render(true); +})();