track HP in chat
This commit is contained in:
@@ -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
|
|
||||||
241
macro_activate-hp-tracking.js
Normal file
241
macro_activate-hp-tracking.js
Normal file
@@ -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 ? `<br><em>Source: ${source}</em>` : "";
|
||||||
|
|
||||||
|
ChatMessage.create({
|
||||||
|
content: `<strong>HP Monitor</strong><br>${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<Actor|Token>} 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;
|
||||||
|
}
|
||||||
34
macro_stop-hp-tracking.js
Normal file
34
macro_stop-hp-tracking.js
Normal file
@@ -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}.`);
|
||||||
BIN
src/FoundryVTT-11.315/foundryvtt.7z
Normal file
BIN
src/FoundryVTT-11.315/foundryvtt.7z
Normal file
Binary file not shown.
168
src/macro_arcanePool_applyEffects.js
Normal file
168
src/macro_arcanePool_applyEffects.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -1,103 +1,313 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
// Ensure a token is selected
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
ui.notifications.warn("You must select a token!");
|
ui.notifications.warn("You must select a token!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let ac = actor; // game.actors.getName("Zeratal");
|
const ac = actor;
|
||||||
let level = Math.floor((ac.classes["magus"].level - 1) / 4 + 1);
|
const magusLevel = ac.classes?.magus?.level ?? 0;
|
||||||
let arcanePool = ac.system.resources.classFeat_arcanePool.value;
|
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() {
|
function isAttackActive(item) {
|
||||||
let dialogContent = `
|
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 `
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
.container { padding: 10px; }
|
.container { padding: 10px; width: 100%; box-sizing: border-box; }
|
||||||
.info-row {
|
.weapon-info {
|
||||||
display: flex;
|
background: #e8f4f8;
|
||||||
align-items: center;
|
padding: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
background: #f0f0f0;
|
border-radius: 4px;
|
||||||
padding: 8px;
|
border-left: 4px solid #2196F3;
|
||||||
border-radius: 4px;
|
}
|
||||||
|
.weapon-info h3 { margin: 0 0 5px 0; color: #2196F3; }
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
.info-row label { margin-right: 10px; font-weight: bold; }
|
.info-row label { margin-right: 10px; font-weight: bold; }
|
||||||
.info-row input { margin-right: 20px; width: 50px; }
|
.info-row input { margin-right: 20px; width: 50px; }
|
||||||
.arcana-section {
|
.arcana-section {
|
||||||
margin: 10px 0;
|
margin: 10px 0;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #e8f4f8;
|
background: #fff3cd;
|
||||||
border-left: 4px solid #2196F3;
|
border-left: 4px solid #ffc107;
|
||||||
}
|
|
||||||
.arcana-item {
|
|
||||||
margin: 5px 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
}
|
||||||
|
.arcana-item { margin: 5px 0; display: flex; align-items: flex-start; }
|
||||||
.arcana-item input[type="checkbox"] {
|
.arcana-item input[type="checkbox"] {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.arcana-item label {
|
.arcana-item label { font-weight: normal; line-height: 1.4; }
|
||||||
font-weight: normal;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
|
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
|
||||||
th {
|
th {
|
||||||
background: #2196F3;
|
background: #2196F3;
|
||||||
color: white;
|
color: white;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
td { padding: 8px; border-bottom: 1px solid #ddd; }
|
td { padding: 8px; border-bottom: 1px solid #ddd; }
|
||||||
tr:hover { background: #f5f5f5; }
|
tr:hover { background: #f5f5f5; }
|
||||||
.value { font-weight: bold; text-align: center; }
|
.value { font-weight: bold; text-align: center; }
|
||||||
.total-display {
|
.total-display {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #e8f4f8;
|
background: #e8f4f8;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
.total-display div {
|
.total-display div { margin: 5px 0; }
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
.warning { color: red; }
|
.warning { color: red; }
|
||||||
.cost-high { color: #ff6b6b; }
|
.cost-high { color: #ff6b6b; }
|
||||||
.cost-medium { color: #ffa500; }
|
.cost-medium { color: #ffa500; }
|
||||||
.cost-low { color: #4caf50; }
|
.cost-low { color: #4caf50; }
|
||||||
input[type="checkbox"]:disabled, input[type="radio"]:disabled {
|
input[type="checkbox"]:disabled, input[type="radio"]:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
<div class="weapon-info">
|
||||||
|
<h3>⚒️ ${weaponName}</h3>
|
||||||
|
<p>Enhancements are applied via Arcane Pool buff items and stay in sync with any toggle source.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<label for="input-value">Enhancement Bonus:</label>
|
<label for="input-value">Enhancement Bonus:</label>
|
||||||
<input type="number" id="input-value" value="${level}" disabled>
|
<input type="number" id="input-value" value="${enhancementCap}" disabled>
|
||||||
<label for="arcane-pool-value">Available Pool:</label>
|
<label for="arcane-pool-value">Available Pool:</label>
|
||||||
<input type="text" id="arcane-pool-value" value="${arcanePool}" disabled>
|
<input type="text" id="arcane-pool-value" value="${arcanePool}" disabled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="arcana-section">
|
<div class="arcana-section">
|
||||||
|
<strong>🔮 Arcana Options:</strong>
|
||||||
<div class="arcana-item">
|
<div class="arcana-item">
|
||||||
<input type="checkbox" id="enduring-blade" class="arcana-checkbox" value="1">
|
<input type="checkbox" id="enduring-blade" class="arcana-checkbox" value="1">
|
||||||
<label for="enduring-blade">Enduring Blade (Costs +1 pool point, Duration: 1 min/level)</label>
|
<label for="enduring-blade">Enduring Blade (+1 pool, Duration: 1 min/level)</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="arcana-item">
|
<div class="arcana-item">
|
||||||
<input type="checkbox" id="ghost-blade" class="arcana-checkbox" value="1">
|
<input type="checkbox" id="ghost-blade" class="arcana-checkbox" value="1">
|
||||||
<label for="ghost-blade">Ghost Blade (Costs +1 pool point, Unlocks Ghost Touch & Brilliant Energy)</label>
|
<label for="ghost-blade">Ghost Blade (+1 pool, Unlocks Ghost Touch & Brilliant Energy)</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table name="InputTable">
|
<table name="InputTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -140,220 +350,230 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>Fire</td>
|
<td>Fire</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="radio" name="group1" class="action" value="0"> None
|
<input type="radio" name="group1" class="action" value="0" data-damage=""> None
|
||||||
<input type="radio" name="group1" class="action" value="1" id="Flaming"> Flaming
|
<input type="radio" name="group1" class="action" value="1" id="Flaming" data-damage="1d6[fire]"> Flaming
|
||||||
<input type="radio" name="group1" class="action" value="2" id="Flaming Burst"> Flaming Burst
|
<input type="radio" name="group1" class="action" value="2" id="Flaming Burst" data-damage="1d6[fire]+1d10[fire]"> Flaming Burst
|
||||||
</td>
|
</td>
|
||||||
<td class="value">0</td>
|
<td class="value">0</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Ice</td>
|
<td>Ice</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="radio" name="group2" class="action" value="0"> None
|
<input type="radio" name="group2" class="action" value="0" data-damage=""> None
|
||||||
<input type="radio" name="group2" class="action" value="1" id="Frost"> Frost
|
<input type="radio" name="group2" class="action" value="1" id="Frost" data-damage="1d6[cold]"> Frost
|
||||||
<input type="radio" name="group2" class="action" value="2" id="Icy Burst"> Icy Burst
|
<input type="radio" name="group2" class="action" value="2" id="Icy Burst" data-damage="1d6[cold]+1d10[cold]"> Icy Burst
|
||||||
</td>
|
</td>
|
||||||
<td class="value">0</td>
|
<td class="value">0</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Lightning</td>
|
<td>Lightning</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="radio" name="group3" class="action" value="0"> None
|
<input type="radio" name="group3" class="action" value="0" data-damage=""> None
|
||||||
<input type="radio" name="group3" class="action" value="1" id="Shock"> Shock
|
<input type="radio" name="group3" class="action" value="1" id="Shock" data-damage="1d6[electricity]"> Shock
|
||||||
<input type="radio" name="group3" class="action" value="2" id="Shocking Burst"> Shocking Burst
|
<input type="radio" name="group3" class="action" value="2" id="Shocking Burst" data-damage="1d6[electricity]+1d10[electricity]"> Shocking Burst
|
||||||
</td>
|
</td>
|
||||||
<td class="value">0</td>
|
<td class="value">0</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="total-display">
|
<div class="total-display">
|
||||||
<div>Enhancement Bonus Used: <span id="enhancement-used">0</span> / ${level}</div>
|
<div>Enhancement Bonus Used: <span id="enhancement-used">0</span> / ${enhancementCap}</div>
|
||||||
<div>Arcane Pool Cost: <span id="pool-cost">1</span> point(s)</div>
|
<div>Arcane Pool Cost: <span id="pool-cost">1</span> point(s)</div>
|
||||||
|
<div>Weapon Enhancement Applied: <span id="base-enhancement">${enhancementCap}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
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
|
function gatherSelections(html) {
|
||||||
openDialog();
|
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: '<i class="fas fa-magic"></i>',
|
||||||
|
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);
|
||||||
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user