import { FEATURE_CONTROL, TVA_CONFIG, getFlagMappings, updateSettings } from '../settings.js';
|
|
import {
|
|
applyCEEffect,
|
|
applyTMFXPreset,
|
|
determineAddedRemovedEffects,
|
|
executeMacro,
|
|
EXPRESSION_OPERATORS,
|
|
getAllActorTokens,
|
|
getFileName,
|
|
mergeMappings,
|
|
tv_executeScript,
|
|
updateTokenImage,
|
|
} from '../utils.js';
|
|
import { broadcastOverlayRedraw, drawOverlays } from '../token/overlay.js';
|
|
import { registerHook, unregisterHook } from './hooks.js';
|
|
import { CORE_TEMPLATES } from '../mappingTemplates.js';
|
|
|
|
const EXPRESSION_MATCH_RE = /(\\\()|(\\\))|(\|\|)|(\&\&)|(\\\!)/g;
|
|
const PF2E_ITEM_TYPES = ['condition', 'effect', 'weapon', 'equipment'];
|
|
const ITEM_TYPES = ['equipment', 'weapon'];
|
|
const feature_id = 'EffectMappings';
|
|
|
|
export function registerEffectMappingHooks() {
|
|
if (!FEATURE_CONTROL[feature_id]) {
|
|
[
|
|
'canvasReady',
|
|
'createActiveEffect',
|
|
'deleteActiveEffect',
|
|
'preUpdateActiveEffect',
|
|
'updateActiveEffect',
|
|
'createCombatant',
|
|
'deleteCombatant',
|
|
'preUpdateCombat',
|
|
'updateCombat',
|
|
'deleteCombat',
|
|
'preUpdateToken',
|
|
'preUpdateActor',
|
|
'updateActor',
|
|
'updateToken',
|
|
'createToken',
|
|
'preUpdateItem',
|
|
'updateItem',
|
|
'createItem',
|
|
'deleteItem',
|
|
].forEach((name) => unregisterHook(feature_id, name));
|
|
return;
|
|
}
|
|
|
|
if (game.user.isGM) {
|
|
registerHook(feature_id, 'canvasReady', _refreshTokenMappings);
|
|
_refreshTokenMappings();
|
|
}
|
|
|
|
registerHook(feature_id, 'createActiveEffect', (activeEffect, options, userId) => {
|
|
if (!activeEffect.parent || activeEffect.disabled || game.userId !== userId) return;
|
|
const effectName = activeEffect.name ?? activeEffect.label;
|
|
_updateImageOnEffectChange(effectName, activeEffect.parent, true);
|
|
});
|
|
registerHook(feature_id, 'deleteActiveEffect', (activeEffect, options, userId) => {
|
|
if (!activeEffect.parent || activeEffect.disabled || game.userId !== userId) return;
|
|
const effectName = activeEffect.name ?? activeEffect.label;
|
|
_updateImageOnEffectChange(effectName, activeEffect.parent, false);
|
|
});
|
|
registerHook(feature_id, 'preUpdateActiveEffect', _preUpdateActiveEffect);
|
|
registerHook(feature_id, 'updateActiveEffect', _updateActiveEffect);
|
|
registerHook(feature_id, 'preUpdateToken', _preUpdateToken);
|
|
registerHook(feature_id, 'preUpdateActor', _preUpdateActor);
|
|
registerHook(feature_id, 'updateActor', _updateActor);
|
|
registerHook(feature_id, 'updateToken', _updateToken);
|
|
registerHook(feature_id, 'createToken', _createToken);
|
|
registerHook(feature_id, 'createCombatant', _createCombatant);
|
|
registerHook(feature_id, 'deleteCombatant', (combatant, options, userId) => {
|
|
if (game.userId !== userId) return;
|
|
_deleteCombatant(combatant);
|
|
});
|
|
registerHook(feature_id, 'preUpdateCombat', _preUpdateCombat);
|
|
registerHook(feature_id, 'updateCombat', _updateCombat);
|
|
registerHook(feature_id, 'deleteCombat', (combat, options, userId) => {
|
|
if (game.userId !== userId) return;
|
|
combat.combatants.forEach((combatant) => {
|
|
_deleteCombatant(combatant);
|
|
});
|
|
});
|
|
|
|
const applicable_item_types = game.system.id === 'pf2e' ? PF2E_ITEM_TYPES : ITEM_TYPES;
|
|
// Want to track condition/effect previous name so that the config can be reverted for it
|
|
registerHook(feature_id, 'preUpdateItem', (item, change, options, userId) => {
|
|
if (game.user.id === userId && applicable_item_types.includes(item.type)) {
|
|
options['token-variants-old-name'] = item.name;
|
|
}
|
|
_preUpdateAssign(item.parent, change, options);
|
|
});
|
|
|
|
registerHook(feature_id, 'createItem', (item, options, userId) => {
|
|
if (game.userId !== userId || !applicable_item_types.includes(item.type) || !item.parent) return;
|
|
_updateImageOnEffectChange(item.name, item.parent, true);
|
|
});
|
|
|
|
registerHook(feature_id, 'deleteItem', (item, options, userId) => {
|
|
if (game.userId !== userId || !applicable_item_types.includes(item.type) || !item.parent || item.disabled) return;
|
|
_updateImageOnEffectChange(item.name, item.parent, false);
|
|
});
|
|
|
|
// Status Effects can be applied "stealthily" on item equip/un-equip
|
|
registerHook(feature_id, 'updateItem', _updateItem);
|
|
}
|
|
|
|
async function _refreshTokenMappings() {
|
|
for (const tkn of canvas.tokens.placeables) {
|
|
await updateWithEffectMapping(tkn);
|
|
}
|
|
}
|
|
|
|
function _createCombatant(combatant, options, userId) {
|
|
if (game.userId !== userId) return;
|
|
const token = combatant._token || canvas.tokens.get(combatant.tokenId);
|
|
if (!token || !token.actor) return;
|
|
|
|
updateWithEffectMapping(token, {
|
|
added: ['token-variants-combat'],
|
|
});
|
|
}
|
|
|
|
function _preUpdateActiveEffect(activeEffect, change, options, userId) {
|
|
if (!activeEffect.parent || game.userId !== userId) return;
|
|
|
|
if ('label' in change) {
|
|
options['token-variants-old-name'] = activeEffect.label;
|
|
}
|
|
}
|
|
|
|
function _updateActiveEffect(activeEffect, change, options, userId) {
|
|
if (!activeEffect.parent || game.userId !== userId) return;
|
|
|
|
const added = [];
|
|
const removed = [];
|
|
|
|
if ('disabled' in change) {
|
|
if (change.disabled) removed.push(activeEffect.label);
|
|
else added.push(activeEffect.label);
|
|
}
|
|
if ('label' in change) {
|
|
removed.push(options['token-variants-old-name']);
|
|
added.push(change.label);
|
|
}
|
|
|
|
if (added.length || removed.length) {
|
|
_updateImageOnMultiEffectChange(activeEffect.parent, added, removed);
|
|
}
|
|
}
|
|
|
|
function _preUpdateToken(token, change, options, userId) {
|
|
if (game.user.id !== userId || change.actorId) return;
|
|
|
|
const preUpdateEffects = getTokenEffects(token, true);
|
|
if (preUpdateEffects.length) {
|
|
setProperty(options, 'token-variants.preUpdateEffects', preUpdateEffects);
|
|
}
|
|
|
|
if (game.system.id === 'dnd5e' && token.actor?.isPolymorphed) {
|
|
setProperty(options, 'token-variants.wasPolymorphed', true);
|
|
}
|
|
}
|
|
|
|
async function _updateToken(token, change, options, userId) {
|
|
if (game.user.id !== userId || change.actorId) return;
|
|
|
|
// TODO
|
|
token.object?.tvaOverlays?.forEach((ov) => ov.htmlOverlay?.render());
|
|
|
|
const addedEffects = [];
|
|
const removedEffects = [];
|
|
const preUpdateEffects = getProperty(options, 'token-variants.preUpdateEffects') || [];
|
|
const postUpdateEffects = getTokenEffects(token, true);
|
|
determineAddedRemovedEffects(addedEffects, removedEffects, postUpdateEffects, preUpdateEffects);
|
|
|
|
if (addedEffects.length || removedEffects.length || 'actorLink' in change) {
|
|
updateWithEffectMapping(token, { added: addedEffects, removed: removedEffects });
|
|
} else if (getProperty(options, 'token-variants.wasPolymorphed') && !token.actor?.isPolymorphed) {
|
|
updateWithEffectMapping(token);
|
|
}
|
|
|
|
if (game.userId === userId && 'hidden' in change) {
|
|
updateWithEffectMapping(token, {
|
|
added: change.hidden ? ['token-variants-visibility'] : [],
|
|
removed: !change.hidden ? ['token-variants-visibility'] : [],
|
|
});
|
|
}
|
|
}
|
|
|
|
function _preUpdateActor(actor, change, options, userId) {
|
|
if (game.user.id !== userId) return;
|
|
_preUpdateAssign(actor, change, options);
|
|
}
|
|
|
|
async function _updateActor(actor, change, options, userId) {
|
|
if (game.user.id !== userId) return;
|
|
|
|
if ('flags' in change && 'token-variants' in change.flags) {
|
|
const tokenVariantFlags = change.flags['token-variants'];
|
|
if ('effectMappings' in tokenVariantFlags || '-=effectMappings' in tokenVariantFlags) {
|
|
const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
|
|
tokens.forEach((tkn) => updateWithEffectMapping(tkn));
|
|
for (const tkn of tokens) {
|
|
if (tkn.object && TVA_CONFIG.filterEffectIcons) {
|
|
await tkn.object.drawEffects();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_preUpdateCheck(actor, options);
|
|
}
|
|
|
|
function _preUpdateAssign(actor, change, options) {
|
|
if (!actor) return;
|
|
|
|
// Determine which comparators are applicable so that we can compare after the
|
|
// actor update
|
|
const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
|
|
if (TVA_CONFIG.internalEffects.hpChange.enabled && tokens.length) {
|
|
applyHpChangeEffect(actor, change, tokens);
|
|
}
|
|
for (const tkn of tokens) {
|
|
const preUpdateEffects = getTokenEffects(tkn, true);
|
|
|
|
if (preUpdateEffects.length) {
|
|
setProperty(options, 'token-variants.' + tkn.id + '.preUpdateEffects', preUpdateEffects);
|
|
}
|
|
}
|
|
}
|
|
|
|
function _preUpdateCheck(actor, options, pAdded = [], pRemoved = []) {
|
|
if (!actor) return;
|
|
const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
|
|
for (const tkn of tokens) {
|
|
// Check if effects changed by comparing them against the ones calculated in preUpdate*
|
|
const added = [...pAdded];
|
|
const removed = [...pRemoved];
|
|
const postUpdateEffects = getTokenEffects(tkn, true);
|
|
const preUpdateEffects = getProperty(options, 'token-variants.' + tkn.id + '.preUpdateEffects') ?? [];
|
|
|
|
determineAddedRemovedEffects(added, removed, postUpdateEffects, preUpdateEffects);
|
|
if (added.length || removed.length) updateWithEffectMapping(tkn, { added, removed });
|
|
}
|
|
}
|
|
|
|
function _createToken(token, options, userId) {
|
|
if (userId && userId === game.user.id) updateWithEffectMapping(token);
|
|
}
|
|
|
|
function _preUpdateCombat(combat, round, options, userId) {
|
|
if (game.userId !== userId) return;
|
|
options['token-variants'] = {
|
|
combatantId: combat?.combatant?.token?.id,
|
|
nextCombatantId: combat?.nextCombatant?.token?.id,
|
|
};
|
|
}
|
|
|
|
function _updateCombat(combat, round, options, userId) {
|
|
if (game.userId !== userId) return;
|
|
|
|
const previousCombatantId = options['token-variants']?.combatantId;
|
|
const previousNextCombatantId = options['token-variants']?.nextCombatantId;
|
|
|
|
const currentCombatantId = combat?.combatant?.token?.id;
|
|
const currentNextCombatantId = combat?.nextCombatant?.token?.id;
|
|
|
|
const updateCombatant = function (id, added = [], removed = []) {
|
|
if (game.user.isGM) {
|
|
const token = canvas.tokens.get(id);
|
|
if (token) updateWithEffectMapping(token, { added, removed });
|
|
} else {
|
|
const message = {
|
|
handlerName: 'effectMappings',
|
|
args: { tokenId: id, sceneId: canvas.scene.id, added, removed },
|
|
type: 'UPDATE',
|
|
};
|
|
game.socket?.emit('module.token-variants', message);
|
|
}
|
|
};
|
|
|
|
if (previousCombatantId !== currentCombatantId) {
|
|
if (previousCombatantId) updateCombatant(previousCombatantId, [], ['combat-turn']);
|
|
if (currentCombatantId) updateCombatant(currentCombatantId, ['combat-turn'], []);
|
|
}
|
|
if (previousNextCombatantId !== currentNextCombatantId) {
|
|
if (previousNextCombatantId) updateCombatant(previousNextCombatantId, [], ['combat-turn-next']);
|
|
if (currentNextCombatantId) updateCombatant(currentNextCombatantId, ['combat-turn-next'], []);
|
|
}
|
|
}
|
|
|
|
function _updateItem(item, change, options, userId) {
|
|
const added = [];
|
|
const removed = [];
|
|
|
|
if (game.user.id === userId) {
|
|
// Handle condition/effect name change
|
|
if (options['token-variants-old-name'] !== item.name) {
|
|
added.push(item.name);
|
|
removed.push(options['token-variants-old-name']);
|
|
}
|
|
|
|
_preUpdateCheck(item.parent, options, added, removed);
|
|
}
|
|
}
|
|
|
|
let EFFECT_M_QUEUES = {};
|
|
let EFFECT_M_TIMER;
|
|
|
|
export async function updateWithEffectMapping(token, { added = [], removed = [] } = {}) {
|
|
const callUpdateWithEffectMapping = function () {
|
|
for (const id of Object.keys(EFFECT_M_QUEUES)) {
|
|
const m = EFFECT_M_QUEUES[id];
|
|
_updateWithEffectMapping(m.token, m.opts.added, m.opts.removed);
|
|
}
|
|
EFFECT_M_QUEUES = {};
|
|
};
|
|
|
|
clearTimeout(EFFECT_M_TIMER);
|
|
|
|
if (token.id in EFFECT_M_QUEUES) {
|
|
const opts = EFFECT_M_QUEUES[token.id].opts;
|
|
added.forEach((a) => opts.added.add(a));
|
|
removed.forEach((a) => opts.removed.add(a));
|
|
} else {
|
|
EFFECT_M_QUEUES[token.id] = {
|
|
token,
|
|
opts: { added: new Set(added), removed: new Set(removed) },
|
|
};
|
|
}
|
|
EFFECT_M_TIMER = setTimeout(callUpdateWithEffectMapping, 100);
|
|
}
|
|
|
|
async function _updateWithEffectMapping(token, added, removed) {
|
|
const placeable = token.object ?? token._object ?? token;
|
|
token = token.document ?? token;
|
|
|
|
const tokenImgName = token.getFlag('token-variants', 'name') || getFileName(token.texture.src);
|
|
let tokenDefaultImg = token.getFlag('token-variants', 'defaultImg');
|
|
const animate = !TVA_CONFIG.disableTokenUpdateAnimation;
|
|
const tokenUpdateObj = {};
|
|
const hadActiveHUD = token.object?.hasActiveHUD;
|
|
const toggleStatus = canvas.tokens.hud.object?.id === token.id ? canvas.tokens.hud._statusEffects : false;
|
|
|
|
let effects = getTokenEffects(token);
|
|
|
|
// If effect is included in `added` or `removed` we need to:
|
|
// 1. Insert it into `effects` if it's not there in case of 'added' and place it on top of the list
|
|
// 2. Remove it in case of 'removed'
|
|
for (const ef of added) {
|
|
const i = effects.findIndex((s) => s === ef);
|
|
if (i === -1) {
|
|
effects.push(ef);
|
|
} else if (i < effects.length - 1) {
|
|
effects.splice(i, 1);
|
|
effects.push(ef);
|
|
}
|
|
}
|
|
for (const ef of removed) {
|
|
const i = effects.findIndex((s) => s === ef);
|
|
if (i !== -1) {
|
|
effects.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
const mappings = getAllEffectMappings(token);
|
|
|
|
// 3. Configurations may contain effect names in a form of a logical expressions
|
|
// We need to evaluate them and insert them into effects/added/removed if needed
|
|
for (const mapping of mappings) {
|
|
evaluateMappingExpression(mapping, effects, token, added, removed);
|
|
}
|
|
|
|
// Accumulate all scripts that will need to be run after the update
|
|
const executeOnCallback = [];
|
|
const deferredUpdateScripts = [];
|
|
for (const ef of removed) {
|
|
const script = mappings.find((m) => m.id === ef)?.config?.tv_script;
|
|
if (script) {
|
|
if (script.onRemove) {
|
|
if (script.onRemove.includes('tvaUpdate')) deferredUpdateScripts.push(script.onRemove);
|
|
else executeOnCallback.push({ script: script.onRemove, token });
|
|
}
|
|
if (script.tmfxPreset) executeOnCallback.push({ tmfxPreset: script.tmfxPreset, token, action: 'remove' });
|
|
if (script.ceEffect?.name) executeOnCallback.push({ ceEffect: script.ceEffect, token, action: 'remove' });
|
|
if (script.macroOnApply) executeOnCallback.push({ macro: script.macroOnApply, token });
|
|
}
|
|
}
|
|
for (const ef of added) {
|
|
const script = mappings.find((m) => m.id === ef)?.config?.tv_script;
|
|
if (script) {
|
|
if (script.onApply) {
|
|
if (script.onApply.includes('tvaUpdate')) deferredUpdateScripts.push(script.onApply);
|
|
else executeOnCallback.push({ script: script.onApply, token });
|
|
}
|
|
if (script.tmfxPreset) executeOnCallback.push({ tmfxPreset: script.tmfxPreset, token, action: 'apply' });
|
|
if (script.ceEffect?.name) executeOnCallback.push({ ceEffect: script.ceEffect, token, action: 'apply' });
|
|
if (script.macroOnRemove) executeOnCallback.push({ macro: script.macroOnRemove, token });
|
|
}
|
|
}
|
|
|
|
// Next we're going to determine what configs need to be applied and in what order
|
|
// Filter effects that do not have a mapping and sort based on priority
|
|
effects = mappings.filter((m) => effects.includes(m.id)).sort((ef1, ef2) => ef1.priority - ef2.priority);
|
|
|
|
// Check if image update should be prevented based on module settings
|
|
let disableImageUpdate = false;
|
|
if (TVA_CONFIG.disableImageChangeOnPolymorphed && token.actor?.isPolymorphed) {
|
|
disableImageUpdate = true;
|
|
} else if (
|
|
TVA_CONFIG.disableImageUpdateOnNonPrototype &&
|
|
token.actor?.prototypeToken?.texture?.src !== token.texture.src
|
|
) {
|
|
disableImageUpdate = true;
|
|
const tknImg = token.texture.src;
|
|
for (const m of mappings) {
|
|
if (m.imgSrc === tknImg) {
|
|
disableImageUpdate = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (disableImageUpdate) {
|
|
tokenDefaultImg = '';
|
|
}
|
|
|
|
let updateCall;
|
|
|
|
if (effects.length > 0) {
|
|
// Some effect mappings may not have images, find a mapping with one if it exists
|
|
const newImg = { imgSrc: '', imgName: '' };
|
|
|
|
if (!disableImageUpdate) {
|
|
for (let i = effects.length - 1; i >= 0; i--) {
|
|
if (effects[i].imgSrc) {
|
|
let iSrc = effects[i].imgSrc;
|
|
if (iSrc.includes('*') || (iSrc.includes('{') && iSrc.includes('}'))) {
|
|
// wildcard image, if this effect hasn't been newly applied we do not want to randomize the image again
|
|
if (!added.has(effects[i].overlayConfig?.effect)) {
|
|
newImg.imgSrc = token.texture.src;
|
|
newImg.imgName = getFileName(newImg.imgSrc);
|
|
break;
|
|
}
|
|
}
|
|
newImg.imgSrc = effects[i].imgSrc;
|
|
newImg.imgName = effects[i].imgName;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect custom configs to be applied to the token
|
|
let config;
|
|
if (TVA_CONFIG.stackStatusConfig) {
|
|
config = {};
|
|
for (const ef of effects) {
|
|
config = mergeObject(config, ef.config);
|
|
}
|
|
} else {
|
|
for (let i = effects.length - 1; i >= 0; i--) {
|
|
if (effects[i].config && Object.keys(effects[i].config).length !== 0) {
|
|
config = effects[i].config;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use or update the default (original) token image
|
|
if (!newImg.imgSrc && tokenDefaultImg) {
|
|
delete tokenUpdateObj.flags?.['token-variants']?.defaultImg;
|
|
setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultImg', null);
|
|
newImg.imgSrc = tokenDefaultImg.imgSrc;
|
|
newImg.imgName = tokenDefaultImg.imgName;
|
|
} else if (!tokenDefaultImg && newImg.imgSrc) {
|
|
setProperty(tokenUpdateObj, 'flags.token-variants.defaultImg', {
|
|
imgSrc: token.texture.src,
|
|
imgName: tokenImgName,
|
|
});
|
|
}
|
|
|
|
updateCall = () =>
|
|
updateTokenImage(newImg.imgSrc ?? null, {
|
|
token,
|
|
imgName: newImg.imgName ? newImg.imgName : tokenImgName,
|
|
tokenUpdate: tokenUpdateObj,
|
|
callback: _postTokenUpdateProcessing.bind(null, token, hadActiveHUD, toggleStatus, executeOnCallback),
|
|
config: config,
|
|
animate,
|
|
});
|
|
}
|
|
|
|
// If no mapping has been found and the default image (image prior to effect triggered update) is different from current one
|
|
// reset the token image back to default
|
|
if (effects.length === 0 && tokenDefaultImg) {
|
|
delete tokenUpdateObj.flags?.['token-variants']?.defaultImg;
|
|
setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultImg', null);
|
|
|
|
updateCall = () =>
|
|
updateTokenImage(tokenDefaultImg.imgSrc, {
|
|
token,
|
|
imgName: tokenDefaultImg.imgName,
|
|
tokenUpdate: tokenUpdateObj,
|
|
callback: _postTokenUpdateProcessing.bind(null, token, hadActiveHUD, toggleStatus, executeOnCallback),
|
|
animate,
|
|
});
|
|
// If no default image exists but a custom effect is applied, we still want to perform an update to
|
|
// clear it
|
|
} else if (effects.length === 0 && token.getFlag('token-variants', 'usingCustomConfig')) {
|
|
updateCall = () =>
|
|
updateTokenImage(token.texture.src, {
|
|
token,
|
|
imgName: tokenImgName,
|
|
tokenUpdate: tokenUpdateObj,
|
|
callback: _postTokenUpdateProcessing.bind(null, token, hadActiveHUD, toggleStatus, executeOnCallback),
|
|
animate,
|
|
});
|
|
}
|
|
|
|
if (updateCall) {
|
|
if (deferredUpdateScripts.length) {
|
|
for (let i = 0; i < deferredUpdateScripts.length; i++) {
|
|
if (i === deferredUpdateScripts.length - 1) {
|
|
await tv_executeScript(deferredUpdateScripts[i], {
|
|
token,
|
|
tvaUpdate: () => {
|
|
updateCall();
|
|
},
|
|
});
|
|
} else {
|
|
await tv_executeScript(deferredUpdateScripts[i], {
|
|
token,
|
|
tvaUpdate: () => {},
|
|
});
|
|
}
|
|
}
|
|
} else {
|
|
updateCall();
|
|
}
|
|
} else {
|
|
if (executeOnCallback.length || deferredUpdateScripts.length) {
|
|
_postTokenUpdateProcessing(token, hadActiveHUD, toggleStatus, executeOnCallback);
|
|
_postTokenUpdateProcessing(token, hadActiveHUD, toggleStatus, deferredUpdateScripts);
|
|
}
|
|
}
|
|
broadcastOverlayRedraw(placeable);
|
|
}
|
|
|
|
async function _postTokenUpdateProcessing(token, hadActiveHUD, toggleStatus, scripts) {
|
|
if (hadActiveHUD && token.object) {
|
|
canvas.tokens.hud.bind(token.object);
|
|
if (toggleStatus) canvas.tokens.hud._toggleStatusEffects(true);
|
|
}
|
|
for (const scr of scripts) {
|
|
if (scr.script) {
|
|
await tv_executeScript(scr.script, { token: scr.token });
|
|
} else if (scr.tmfxPreset) {
|
|
await applyTMFXPreset(scr.token, scr.tmfxPreset, scr.action);
|
|
} else if (scr.ceEffect) {
|
|
await applyCEEffect(scr.token, scr.ceEffect, scr.action);
|
|
} else if (scr.macro) {
|
|
await executeMacro(scr.macro, token);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function getAllEffectMappings(token = null, includeDisabled = false) {
|
|
let allMappings = getFlagMappings(token);
|
|
const unique = new Set();
|
|
|
|
// TODO: replace with a setting
|
|
allMappings.forEach((m) => unique.add(TVA_CONFIG.mergeGroup ? m.group : m.label));
|
|
|
|
// Sort out global mappings that do not apply to this actor
|
|
let applicableGlobal = TVA_CONFIG.globalMappings;
|
|
if (token?.actor?.type) {
|
|
const actorType = token.actor.type;
|
|
applicableGlobal = applicableGlobal.filter((m) => {
|
|
if (!m.targetActors || m.targetActors.includes(actorType)) {
|
|
return !unique.has(TVA_CONFIG.mergeGroup ? m.group : m.label);
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
allMappings = allMappings.concat(applicableGlobal);
|
|
|
|
if (!includeDisabled) allMappings = allMappings.filter((m) => !m.disabled);
|
|
|
|
return allMappings;
|
|
}
|
|
|
|
export async function setOverlayVisibility({
|
|
userName = null,
|
|
userId = null,
|
|
label = null,
|
|
group = null,
|
|
token = null,
|
|
visible = true,
|
|
} = {}) {
|
|
if (!label && !group) return;
|
|
if (userName) userId = game.users.find((u) => u.name === userName)?.id;
|
|
if (!userId) return;
|
|
|
|
let tokenMappings = getFlagMappings(token);
|
|
let globalMappings = TVA_CONFIG.globalMappings;
|
|
|
|
let updateToken = false;
|
|
let updateGlobal = false;
|
|
|
|
const updateMappings = function (mappings) {
|
|
mappings = mappings.filter((m) => m.overlay && (m.label === label || m.group === group));
|
|
let found = false;
|
|
if (mappings.length) found = true;
|
|
|
|
mappings.forEach((m) => {
|
|
const overlayConfig = m.overlayConfig;
|
|
if (visible) {
|
|
if (!overlayConfig.limitedUsers) overlayConfig.limitedUsers = [];
|
|
if (!overlayConfig.limitedUsers.find((u) => u === userId)) overlayConfig.limitedUsers.push(userId);
|
|
} else if (overlayConfig.limitedUsers) {
|
|
overlayConfig.limitedUsers = overlayConfig.limitedUsers.filter((u) => u !== userId);
|
|
}
|
|
});
|
|
return found;
|
|
};
|
|
|
|
updateToken = updateMappings(tokenMappings);
|
|
updateGlobal = updateMappings(globalMappings);
|
|
|
|
if (updateGlobal) await updateSettings({ globalMappings: globalMappings });
|
|
if (updateToken) {
|
|
const actor = game.actors.get(token.document.actorId);
|
|
if (actor) await actor.setFlag('token-variants', 'effectMappings', tokenMappings);
|
|
}
|
|
if (updateToken || updateGlobal) drawOverlays(token);
|
|
}
|
|
|
|
function _getTemplateMappings(templateName) {
|
|
return (
|
|
TVA_CONFIG.templateMappings.find((t) => t.name === templateName) ??
|
|
CORE_TEMPLATES.find((t) => t.name === templateName)
|
|
)?.mappings;
|
|
}
|
|
|
|
export async function applyTemplate(token, templateName = null, mappings = null) {
|
|
if (templateName) mappings = _getTemplateMappings(templateName);
|
|
if (!token || !mappings) return;
|
|
|
|
const actor = game.actors.get(token.actor.id);
|
|
if (!actor) return;
|
|
const templateMappings = deepClone(mappings);
|
|
templateMappings.forEach((tm) => (tm.tokens = [token.id]));
|
|
|
|
const actMappings = mergeMappings(templateMappings, getFlagMappings(actor));
|
|
await actor.setFlag('token-variants', 'effectMappings', actMappings);
|
|
await updateWithEffectMapping(token);
|
|
drawOverlays(token);
|
|
}
|
|
|
|
export async function removeTemplate(token, templateName = null, mappings = null) {
|
|
if (templateName) mappings = _getTemplateMappings(templateName);
|
|
if (!token || !mappings) return;
|
|
|
|
const actor = game.actors.get(token.actor.id);
|
|
if (!actor) return;
|
|
|
|
let actMappings = getFlagMappings(actor);
|
|
mappings.forEach((m) => {
|
|
let i = actMappings.findIndex((m2) => m2.id === m.id);
|
|
if (i !== -1) {
|
|
actMappings[i].tokens = actMappings[i].tokens.filter((t) => t !== token.id);
|
|
if (actMappings[i].tokens.length === 0) actMappings.splice(i, 1);
|
|
}
|
|
});
|
|
|
|
if (actMappings.length) await actor.setFlag('token-variants', 'effectMappings', actMappings);
|
|
else await actor.unsetFlag('token-variants', 'effectMappings');
|
|
await updateWithEffectMapping(token);
|
|
drawOverlays(token);
|
|
}
|
|
|
|
export function toggleTemplate(token, templateName = null, mappings = null) {
|
|
if (templateName) mappings = _getTemplateMappings(templateName);
|
|
if (!token || !mappings) return;
|
|
|
|
const actor = game.actors.get(token.actor.id);
|
|
if (!actor) return;
|
|
|
|
const actMappings = getFlagMappings(actor);
|
|
if (actMappings.some((m) => mappings.some((m2) => m2.id === m.id && m.tokens?.includes(token.id)))) {
|
|
removeTemplate(token, null, mappings);
|
|
} else {
|
|
applyTemplate(token, null, mappings);
|
|
}
|
|
}
|
|
|
|
export function toggleTemplateOnSelected(templateName = null, mappings = null) {
|
|
canvas.tokens.controlled.forEach((t) => toggleTemplate(t, templateName, mappings));
|
|
}
|
|
|
|
function getHPChangeEffect(token, effects) {
|
|
const internals = token.actor?.getFlag('token-variants', 'internalEffects') || {};
|
|
const delta = getProperty(
|
|
token,
|
|
`${isNewerVersion('11', game.version) ? 'actorData' : 'delta'}.flags.token-variants.internalEffects`
|
|
);
|
|
if (delta) mergeObject(internals, delta);
|
|
if (internals['hp--'] != null) effects.push('hp--');
|
|
if (internals['hp++'] != null) effects.push('hp++');
|
|
}
|
|
|
|
function applyHpChangeEffect(actor, change, tokens) {
|
|
let duration = Number(TVA_CONFIG.internalEffects.hpChange.duration);
|
|
|
|
const newHpValue = getProperty(change, `system.${TVA_CONFIG.systemHpPath}.value`);
|
|
if (newHpValue != null) {
|
|
const [currentHpVal, _] = getTokenHP(tokens[0]);
|
|
if (currentHpVal !== newHpValue) {
|
|
if (currentHpVal < newHpValue) {
|
|
setProperty(change, 'flags.token-variants.internalEffects.-=hp--', null);
|
|
setProperty(change, 'flags.token-variants.internalEffects.hp++', newHpValue - currentHpVal);
|
|
if (duration) {
|
|
setTimeout(() => {
|
|
actor.update({
|
|
'flags.token-variants.internalEffects.-=hp++': null,
|
|
});
|
|
}, duration * 1000);
|
|
}
|
|
} else {
|
|
setProperty(change, 'flags.token-variants.internalEffects.-=hp++', null);
|
|
setProperty(change, 'flags.token-variants.internalEffects.hp--', newHpValue - currentHpVal);
|
|
if (duration) {
|
|
setTimeout(() => {
|
|
actor.update({
|
|
'flags.token-variants.internalEffects.-=hp--': null,
|
|
});
|
|
}, duration * 1000);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function getTokenEffects(token, includeExpressions = false) {
|
|
const data = token.document ?? token;
|
|
let effects = [];
|
|
|
|
// TVA Effects
|
|
const tokenInCombat = game.combats.some((combat) => {
|
|
return combat.combatants.some((c) => c.tokenId === token.id);
|
|
});
|
|
if (tokenInCombat) {
|
|
effects.push('token-variants-combat');
|
|
}
|
|
|
|
if (game.combat?.started) {
|
|
if (game.combat?.combatant?.token?.id === token.id) {
|
|
effects.push('combat-turn');
|
|
} else if (game.combat?.nextCombatant?.token?.id === token.id) {
|
|
effects.push('combat-turn-next');
|
|
}
|
|
}
|
|
if (data.hidden) {
|
|
effects.push('token-variants-visibility');
|
|
}
|
|
|
|
if (TVA_CONFIG.internalEffects.hpChange.enabled) {
|
|
getHPChangeEffect(data, effects);
|
|
}
|
|
|
|
// Actor/Token effects
|
|
if (data.actorLink) {
|
|
getEffectsFromActor(token.actor, effects);
|
|
} else {
|
|
if (game.system.id === 'pf2e') {
|
|
(data.delta?.items || []).forEach((item) => {
|
|
if (_activePF2EItem(item)) {
|
|
effects.push(item.name);
|
|
}
|
|
});
|
|
} else {
|
|
(data.effects || []).filter((ef) => !ef.disabled && !ef.isSuppressed).forEach((ef) => effects.push(ef.label));
|
|
getEffectsFromActor(token.actor, effects);
|
|
}
|
|
}
|
|
|
|
// Expression/Mapping effects
|
|
evaluateComparatorEffects(token, effects);
|
|
evaluateStateEffects(token, effects);
|
|
|
|
// Include mappings marked as always applicable
|
|
// as well as the ones defined as logical expressions if needed
|
|
const mappings = getAllEffectMappings(token);
|
|
|
|
for (const m of mappings) {
|
|
if (m.tokens?.length && !m.tokens.includes(data.id)) continue;
|
|
if (m.alwaysOn) effects.unshift(m.id);
|
|
else if (includeExpressions) {
|
|
const evaluation = evaluateMappingExpression(m, effects, token);
|
|
if (evaluation) effects.unshift(m.id);
|
|
}
|
|
}
|
|
|
|
return effects;
|
|
}
|
|
|
|
export function getEffectsFromActor(actor, effects = []) {
|
|
if (!actor) return effects;
|
|
|
|
if (game.system.id === 'pf2e') {
|
|
(actor.items || []).forEach((item, id) => {
|
|
if (_activePF2EItem(item)) effects.push(item.name);
|
|
});
|
|
} else {
|
|
(actor.effects || []).forEach((activeEffect, id) => {
|
|
if (!activeEffect.disabled && !activeEffect.isSuppressed) effects.push(activeEffect.name ?? activeEffect.label);
|
|
});
|
|
(actor.items || []).forEach((item) => {
|
|
if (ITEM_TYPES.includes(item.type) && item.system.equipped) effects.push(item.name ?? item.label);
|
|
});
|
|
}
|
|
|
|
return effects;
|
|
}
|
|
|
|
function _activePF2EItem(item) {
|
|
if (PF2E_ITEM_TYPES.includes(item.type)) {
|
|
if ('active' in item) {
|
|
return item.active;
|
|
} else if ('isEquipped' in item) {
|
|
return item.isEquipped;
|
|
} else {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export const VALID_EXPRESSION = new RegExp('([a-zA-Z\\-\\.\\+]+)([><=]+)(".*"|-?\\d+)(%{0,1})');
|
|
|
|
export function evaluateComparator(token, expression) {
|
|
const match = expression.match(VALID_EXPRESSION);
|
|
if (match) {
|
|
const property = match[1];
|
|
|
|
let currVal;
|
|
let maxVal;
|
|
if (property === 'hp') {
|
|
[currVal, maxVal] = getTokenHP(token);
|
|
} else if (property === 'hp++' || property === 'hp--') {
|
|
[currVal, maxVal] = getTokenHP(token);
|
|
currVal = getProperty(token, `actor.flags.token-variants.internalEffects.${property}`) ?? 0;
|
|
} else currVal = getProperty(token, property);
|
|
if (currVal == null) currVal = 0;
|
|
|
|
const sign = match[2];
|
|
let val = Number(match[3]);
|
|
if (isNaN(val)) {
|
|
val = match[3].substring(1, match[3].length - 1);
|
|
if (val === 'true') val = true;
|
|
if (val === 'false') val = false;
|
|
// Convert currVal to a truthy/falsy one if this is a bool check
|
|
if (val === true || val === false) {
|
|
if (isEmpty(currVal)) currVal = false;
|
|
else currVal = Boolean(currVal);
|
|
}
|
|
}
|
|
const isPercentage = Boolean(match[4]);
|
|
|
|
if (property === 'rotation') {
|
|
maxVal = 360;
|
|
} else if (maxVal == null) {
|
|
maxVal = 999999;
|
|
}
|
|
const toCompare = isPercentage ? (currVal / maxVal) * 100 : currVal;
|
|
|
|
let passed = false;
|
|
if (sign === '=') {
|
|
passed = toCompare == val;
|
|
} else if (sign === '>') {
|
|
passed = toCompare > val;
|
|
} else if (sign === '<') {
|
|
passed = toCompare < val;
|
|
} else if (sign === '>=') {
|
|
passed = toCompare >= val;
|
|
} else if (sign === '<=') {
|
|
passed = toCompare <= val;
|
|
} else if (sign === '<>') {
|
|
passed = toCompare < val || toCompare > val;
|
|
}
|
|
return passed;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function evaluateComparatorEffects(token, effects = []) {
|
|
token = token.document ?? token;
|
|
|
|
const mappings = getAllEffectMappings(token);
|
|
|
|
const matched = new Set();
|
|
|
|
for (const m of mappings) {
|
|
const expressions = m.expression
|
|
.split(EXPRESSION_MATCH_RE)
|
|
.filter(Boolean)
|
|
.map((exp) => exp.trim())
|
|
.filter(Boolean);
|
|
for (let i = 0; i < expressions.length; i++) {
|
|
if (evaluateComparator(token, expressions[i])) {
|
|
matched.add(expressions[i]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove duplicate expressions and insert into effects
|
|
matched.forEach((exp) => effects.unshift(exp));
|
|
|
|
return effects;
|
|
}
|
|
|
|
export function evaluateStateEffects(token, effects) {
|
|
if (game.system.id === 'pf2e') {
|
|
const deathIcon = game.settings.get('pf2e', 'deathIcon');
|
|
if ((token.document ?? token).overlayEffect === deathIcon) effects.push('Dead');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Replaces {1,a,5,b} type string in the expressions with (1|a|5|b)
|
|
* @param {*} exp
|
|
* @returns
|
|
*/
|
|
function _findReplaceBracketWildcard(exp) {
|
|
let nExp = '';
|
|
let lIndex = 0;
|
|
while (lIndex >= 0) {
|
|
let i1 = exp.indexOf('\\\\\\{', lIndex);
|
|
if (i1 !== -1) {
|
|
let i2 = exp.indexOf('\\\\\\}', i1);
|
|
if (i2 !== -1) {
|
|
nExp += exp.substring(lIndex, i1);
|
|
nExp +=
|
|
'(' +
|
|
exp
|
|
.substring(i1 + 4, i2)
|
|
.split(',')
|
|
.join('|') +
|
|
')';
|
|
}
|
|
lIndex = i2 + 4;
|
|
} else {
|
|
return nExp + exp.substring(lIndex, exp.length);
|
|
}
|
|
}
|
|
return nExp ?? exp;
|
|
}
|
|
|
|
function _testRegExEffect(effect, effects) {
|
|
let re = effect.replace(/[/\-\\^$+?.()|[\]{}]/g, '\\$&').replaceAll('\\\\*', '.*');
|
|
re = _findReplaceBracketWildcard(re);
|
|
re = new RegExp('^' + re + '$');
|
|
return effects.find((ef) => re.test(ef));
|
|
}
|
|
|
|
export function evaluateMappingExpression(mapping, effects, token, added = new Set(), removed = new Set()) {
|
|
let arrExpression = mapping.expression
|
|
.split(EXPRESSION_MATCH_RE)
|
|
.filter(Boolean)
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
|
|
let temp = '';
|
|
let hasAdded = false;
|
|
let hasRemoved = false;
|
|
for (let exp of arrExpression) {
|
|
if (EXPRESSION_OPERATORS.includes(exp)) {
|
|
temp += exp.replace('\\', '');
|
|
continue;
|
|
}
|
|
|
|
if (/\\\*|\\{.*\\}/g.test(exp)) {
|
|
let rExp = _testRegExEffect(exp, effects);
|
|
if (rExp) {
|
|
temp += 'true';
|
|
} else {
|
|
temp += 'false';
|
|
}
|
|
|
|
if (_testRegExEffect(exp, added)) hasAdded = true;
|
|
else if (_testRegExEffect(exp, removed)) hasRemoved = true;
|
|
continue;
|
|
} else if (effects.includes(exp)) {
|
|
temp += 'true';
|
|
} else {
|
|
temp += 'false';
|
|
}
|
|
|
|
if (!hasAdded && added.has(exp)) hasAdded = true;
|
|
if (!hasRemoved && removed.has(exp)) hasRemoved = true;
|
|
}
|
|
|
|
try {
|
|
let evaluation = eval(temp);
|
|
|
|
// Evaluate JS code
|
|
if (mapping.codeExp) {
|
|
try {
|
|
token = token.document ?? token;
|
|
if (!eval(mapping.codeExp)) evaluation = false;
|
|
else if (!mapping.expression) evaluation = true;
|
|
} catch (e) {
|
|
evaluation = false;
|
|
}
|
|
}
|
|
|
|
if (evaluation) {
|
|
if (hasAdded || hasRemoved) {
|
|
added.add(mapping.id);
|
|
effects.push(mapping.id);
|
|
} else effects.unshift(mapping.id);
|
|
} else if (hasRemoved || hasAdded) {
|
|
removed.add(mapping.id);
|
|
}
|
|
return evaluation;
|
|
} catch (e) {}
|
|
return false;
|
|
}
|
|
|
|
function _getTokenHPv11(token) {
|
|
let attributes;
|
|
|
|
if (token.actorLink) {
|
|
attributes = getProperty(token.actor?.system, TVA_CONFIG.systemHpPath);
|
|
} else {
|
|
attributes = mergeObject(
|
|
getProperty(token.actor?.system, TVA_CONFIG.systemHpPath) || {},
|
|
getProperty(token.delta?.system) || {},
|
|
{
|
|
inplace: false,
|
|
}
|
|
);
|
|
}
|
|
|
|
return [attributes?.value, attributes?.max];
|
|
}
|
|
|
|
export function getTokenHP(token) {
|
|
if (!isNewerVersion('11', game.version)) return _getTokenHPv11(token);
|
|
|
|
let attributes;
|
|
|
|
if (token.actorLink) {
|
|
attributes = getProperty(token.actor.system, TVA_CONFIG.systemHpPath);
|
|
} else {
|
|
attributes = mergeObject(
|
|
getProperty(token.actor.system, TVA_CONFIG.systemHpPath) || {},
|
|
getProperty(token.actorData?.system) || {},
|
|
{
|
|
inplace: false,
|
|
}
|
|
);
|
|
}
|
|
return [attributes?.value, attributes?.max];
|
|
}
|
|
|
|
async function _updateImageOnEffectChange(effectName, actor, added = true) {
|
|
const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
|
|
for (const token of tokens) {
|
|
await updateWithEffectMapping(token, {
|
|
added: added ? [effectName] : [],
|
|
removed: !added ? [effectName] : [],
|
|
});
|
|
}
|
|
}
|
|
|
|
async function _updateImageOnMultiEffectChange(actor, added = [], removed = []) {
|
|
if (!actor) return;
|
|
const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
|
|
for (const token of tokens) {
|
|
await updateWithEffectMapping(token, {
|
|
added: added,
|
|
removed: removed,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function _deleteCombatant(combatant) {
|
|
const token = combatant._token || canvas.tokens.get(combatant.tokenId);
|
|
if (!token || !token.actor) return;
|
|
await updateWithEffectMapping(token, {
|
|
removed: ['token-variants-combat'],
|
|
});
|
|
}
|