Files
FoundryVTT/src/macro_arcaneSelector.js
centron\schwoerer 5669aa75ca track HP in chat
2025-11-14 09:25:31 +01:00

580 lines
21 KiB
JavaScript

(async () => {
if (!token) {
ui.notifications.warn("You must select a token!");
return;
}
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;
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 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 `
<html lang="en">
<head>
<style>
.container { padding: 10px; width: 100%; box-sizing: border-box; }
.weapon-info {
background: #e8f4f8;
padding: 10px;
margin-bottom: 10px;
border-radius: 4px;
border-left: 4px solid #2196F3;
}
.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 input { margin-right: 20px; width: 50px; }
.arcana-section {
margin: 10px 0;
padding: 10px;
background: #fff3cd;
border-left: 4px solid #ffc107;
}
.arcana-item { margin: 5px 0; display: flex; align-items: flex-start; }
.arcana-item input[type="checkbox"] {
margin-right: 8px;
margin-top: 3px;
flex-shrink: 0;
}
.arcana-item label { font-weight: normal; line-height: 1.4; }
table { width: 100%; border-collapse: collapse; margin: 15px 0; }
th {
background: #2196F3;
color: white;
padding: 8px;
text-align: left;
}
td { padding: 8px; border-bottom: 1px solid #ddd; }
tr:hover { background: #f5f5f5; }
.value { font-weight: bold; text-align: center; }
.total-display {
font-size: 16px;
font-weight: bold;
padding: 10px;
background: #e8f4f8;
border-radius: 4px;
margin-top: 10px;
}
.total-display div { margin: 5px 0; }
.warning { color: red; }
.cost-high { color: #ff6b6b; }
.cost-medium { color: #ffa500; }
.cost-low { color: #4caf50; }
input[type="checkbox"]:disabled, input[type="radio"]:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
</head>
<body>
<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">
<label for="input-value">Enhancement Bonus:</label>
<input type="number" id="input-value" value="${enhancementCap}" disabled>
<label for="arcane-pool-value">Available Pool:</label>
<input type="text" id="arcane-pool-value" value="${arcanePool}" disabled>
</div>
<div class="arcana-section">
<strong>🔮 Arcana Options:</strong>
<div class="arcana-item">
<input type="checkbox" id="enduring-blade" class="arcana-checkbox" value="1">
<label for="enduring-blade">Enduring Blade (+1 pool, Duration: 1 min/level)</label>
</div>
<div class="arcana-item">
<input type="checkbox" id="ghost-blade" class="arcana-checkbox" value="1">
<label for="ghost-blade">Ghost Blade (+1 pool, Unlocks Ghost Touch & Brilliant Energy)</label>
</div>
</div>
<table name="InputTable">
<thead>
<tr>
<th>Enhancement</th>
<th style="width: 60%;">Options</th>
<th style="width: 15%; text-align: center;">Cost</th>
</tr>
</thead>
<tbody>
<tr>
<td>Keen [1]</td>
<td><input type="checkbox" class="action" value="1" id="Keen"></td>
<td class="value">0</td>
</tr>
<tr>
<td>Ghost Touch [1]</td>
<td><input type="checkbox" class="action enhancement-option" value="1" id="Ghost Touch" data-requires="ghost-blade" disabled></td>
<td class="value">0</td>
</tr>
<tr>
<td>Speed [3]</td>
<td><input type="checkbox" class="action" value="3" id="Speed"></td>
<td class="value">0</td>
</tr>
<tr>
<td>Dancing [4]</td>
<td><input type="checkbox" class="action" value="4" id="Dancing"></td>
<td class="value">0</td>
</tr>
<tr>
<td>Brilliant Energy [4]</td>
<td><input type="checkbox" class="action enhancement-option" value="4" id="Brilliant Energy" data-requires="ghost-blade" disabled></td>
<td class="value">0</td>
</tr>
<tr>
<td>Vorpal [5]</td>
<td><input type="checkbox" class="action" value="5" id="Vorpal"></td>
<td class="value">0</td>
</tr>
<tr>
<td>Fire</td>
<td>
<input type="radio" name="group1" class="action" value="0" data-damage=""> None
<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" data-damage="1d6[fire]+1d10[fire]"> Flaming Burst
</td>
<td class="value">0</td>
</tr>
<tr>
<td>Ice</td>
<td>
<input type="radio" name="group2" class="action" value="0" data-damage=""> None
<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" data-damage="1d6[cold]+1d10[cold]"> Icy Burst
</td>
<td class="value">0</td>
</tr>
<tr>
<td>Lightning</td>
<td>
<input type="radio" name="group3" class="action" value="0" data-damage=""> None
<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" data-damage="1d6[electricity]+1d10[electricity]"> Shocking Burst
</td>
<td class="value">0</td>
</tr>
</tbody>
</table>
<div class="total-display">
<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>Weapon Enhancement Applied: <span id="base-enhancement">${enhancementCap}</span></div>
</div>
</div>
</body>
</html>
`;
}
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: '<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);
})();