All user data for FoundryVTT. Includes worlds, systems, modules, and any asset in the "foundryuserdata" directory. Does NOT include the FoundryVTT installation itself.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1095 lines
37 KiB

  1. import { FEATURE_CONTROL, TVA_CONFIG, getFlagMappings, updateSettings } from '../settings.js';
  2. import {
  3. applyCEEffect,
  4. applyTMFXPreset,
  5. determineAddedRemovedEffects,
  6. executeMacro,
  7. EXPRESSION_OPERATORS,
  8. getAllActorTokens,
  9. getFileName,
  10. mergeMappings,
  11. tv_executeScript,
  12. updateTokenImage,
  13. } from '../utils.js';
  14. import { broadcastDrawOverlays, drawOverlays } from '../token/overlay.js';
  15. import { registerHook, unregisterHook } from './hooks.js';
  16. import { CORE_TEMPLATES } from '../mappingTemplates.js';
  17. const EXPRESSION_MATCH_RE = /(\\\()|(\\\))|(\|\|)|(\&\&)|(\\\!)/g;
  18. const PF2E_ITEM_TYPES = ['condition', 'effect', 'weapon', 'equipment'];
  19. const ITEM_TYPES = ['equipment', 'weapon'];
  20. const feature_id = 'EffectMappings';
  21. export function registerEffectMappingHooks() {
  22. if (!FEATURE_CONTROL[feature_id]) {
  23. [
  24. 'canvasReady',
  25. 'createActiveEffect',
  26. 'deleteActiveEffect',
  27. 'preUpdateActiveEffect',
  28. 'updateActiveEffect',
  29. 'createCombatant',
  30. 'deleteCombatant',
  31. 'preUpdateCombat',
  32. 'updateCombat',
  33. 'deleteCombat',
  34. 'preUpdateToken',
  35. 'preUpdateActor',
  36. 'updateActor',
  37. 'updateToken',
  38. 'createToken',
  39. 'preUpdateItem',
  40. 'updateItem',
  41. 'createItem',
  42. 'deleteItem',
  43. ].forEach((name) => unregisterHook(feature_id, name));
  44. return;
  45. }
  46. if (game.user.isGM) {
  47. registerHook(feature_id, 'canvasReady', _refreshTokenMappings);
  48. _refreshTokenMappings();
  49. }
  50. registerHook(feature_id, 'createActiveEffect', (activeEffect, options, userId) => {
  51. if (!activeEffect.parent || activeEffect.disabled || game.userId !== userId) return;
  52. const effectName = activeEffect.name ?? activeEffect.label;
  53. _updateImageOnEffectChange(effectName, activeEffect.parent, true);
  54. });
  55. registerHook(feature_id, 'deleteActiveEffect', (activeEffect, options, userId) => {
  56. if (!activeEffect.parent || activeEffect.disabled || game.userId !== userId) return;
  57. const effectName = activeEffect.name ?? activeEffect.label;
  58. _updateImageOnEffectChange(effectName, activeEffect.parent, false);
  59. });
  60. registerHook(feature_id, 'preUpdateActiveEffect', _preUpdateActiveEffect);
  61. registerHook(feature_id, 'updateActiveEffect', _updateActiveEffect);
  62. registerHook(feature_id, 'preUpdateToken', _preUpdateToken);
  63. registerHook(feature_id, 'preUpdateActor', _preUpdateActor);
  64. registerHook(feature_id, 'updateActor', _updateActor);
  65. registerHook(feature_id, 'updateToken', _updateToken);
  66. registerHook(feature_id, 'createToken', _createToken);
  67. registerHook(feature_id, 'createCombatant', _createCombatant);
  68. registerHook(feature_id, 'deleteCombatant', (combatant, options, userId) => {
  69. if (game.userId !== userId) return;
  70. _deleteCombatant(combatant);
  71. });
  72. registerHook(feature_id, 'preUpdateCombat', _preUpdateCombat);
  73. registerHook(feature_id, 'updateCombat', _updateCombat);
  74. registerHook(feature_id, 'deleteCombat', (combat, options, userId) => {
  75. if (game.userId !== userId) return;
  76. combat.combatants.forEach((combatant) => {
  77. _deleteCombatant(combatant);
  78. });
  79. });
  80. const applicable_item_types = game.system.id === 'pf2e' ? PF2E_ITEM_TYPES : ITEM_TYPES;
  81. // Want to track condition/effect previous name so that the config can be reverted for it
  82. registerHook(feature_id, 'preUpdateItem', (item, change, options, userId) => {
  83. if (game.user.id === userId && applicable_item_types.includes(item.type)) {
  84. options['token-variants-old-name'] = item.name;
  85. }
  86. _preUpdateAssign(item.parent, change, options);
  87. });
  88. registerHook(feature_id, 'createItem', (item, options, userId) => {
  89. if (game.userId !== userId || !applicable_item_types.includes(item.type) || !item.parent) return;
  90. _updateImageOnEffectChange(item.name, item.parent, true);
  91. });
  92. registerHook(feature_id, 'deleteItem', (item, options, userId) => {
  93. if (game.userId !== userId || !applicable_item_types.includes(item.type) || !item.parent || item.disabled) return;
  94. _updateImageOnEffectChange(item.name, item.parent, false);
  95. });
  96. // Status Effects can be applied "stealthily" on item equip/un-equip
  97. registerHook(feature_id, 'updateItem', _updateItem);
  98. }
  99. async function _refreshTokenMappings() {
  100. for (const tkn of canvas.tokens.placeables) {
  101. await updateWithEffectMapping(tkn);
  102. }
  103. }
  104. function _createCombatant(combatant, options, userId) {
  105. if (game.userId !== userId) return;
  106. const token = combatant._token || canvas.tokens.get(combatant.tokenId);
  107. if (!token || !token.actor) return;
  108. updateWithEffectMapping(token, {
  109. added: ['token-variants-combat'],
  110. });
  111. }
  112. function _preUpdateActiveEffect(activeEffect, change, options, userId) {
  113. if (!activeEffect.parent || game.userId !== userId) return;
  114. if ('name' in change) {
  115. options['token-variants-old-name'] = activeEffect.name;
  116. }
  117. }
  118. function _updateActiveEffect(activeEffect, change, options, userId) {
  119. if (!activeEffect.parent || game.userId !== userId) return;
  120. const added = [];
  121. const removed = [];
  122. if ('disabled' in change) {
  123. if (change.disabled) removed.push(activeEffect.name);
  124. else added.push(activeEffect.name);
  125. }
  126. if ('name' in change) {
  127. removed.push(options['token-variants-old-name']);
  128. added.push(change.name);
  129. }
  130. if (added.length || removed.length) {
  131. _updateImageOnMultiEffectChange(activeEffect.parent, added, removed);
  132. }
  133. }
  134. function _preUpdateToken(token, change, options, userId) {
  135. if (game.user.id !== userId || change.actorId) return;
  136. const preUpdateEffects = getTokenEffects(token, true);
  137. if (preUpdateEffects.length) {
  138. setProperty(options, 'token-variants.preUpdateEffects', preUpdateEffects);
  139. }
  140. if (game.system.id === 'dnd5e' && token.actor?.isPolymorphed) {
  141. setProperty(options, 'token-variants.wasPolymorphed', true);
  142. }
  143. }
  144. async function _updateToken(token, change, options, userId) {
  145. if (game.user.id !== userId || change.actorId) return;
  146. // TODO
  147. token.object?.tvaOverlays?.forEach((ov) => ov.htmlOverlay?.render());
  148. const addedEffects = [];
  149. const removedEffects = [];
  150. const preUpdateEffects = getProperty(options, 'token-variants.preUpdateEffects') || [];
  151. const postUpdateEffects = getTokenEffects(token, true);
  152. determineAddedRemovedEffects(addedEffects, removedEffects, postUpdateEffects, preUpdateEffects);
  153. if (addedEffects.length || removedEffects.length || 'actorLink' in change) {
  154. updateWithEffectMapping(token, { added: addedEffects, removed: removedEffects });
  155. } else if (getProperty(options, 'token-variants.wasPolymorphed') && !token.actor?.isPolymorphed) {
  156. updateWithEffectMapping(token);
  157. }
  158. if (game.userId === userId && 'hidden' in change) {
  159. updateWithEffectMapping(token, {
  160. added: change.hidden ? ['token-variants-visibility'] : [],
  161. removed: !change.hidden ? ['token-variants-visibility'] : [],
  162. });
  163. }
  164. }
  165. function _preUpdateActor(actor, change, options, userId) {
  166. if (game.user.id !== userId) return;
  167. _preUpdateAssign(actor, change, options);
  168. }
  169. async function _updateActor(actor, change, options, userId) {
  170. if (game.user.id !== userId) return;
  171. if ('flags' in change && 'token-variants' in change.flags) {
  172. const tokenVariantFlags = change.flags['token-variants'];
  173. if ('effectMappings' in tokenVariantFlags || '-=effectMappings' in tokenVariantFlags) {
  174. const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
  175. tokens.forEach((tkn) => updateWithEffectMapping(tkn));
  176. for (const tkn of tokens) {
  177. if (tkn.object && TVA_CONFIG.filterEffectIcons) {
  178. await tkn.object.drawEffects();
  179. }
  180. }
  181. }
  182. }
  183. _preUpdateCheck(actor, options);
  184. }
  185. function _preUpdateAssign(actor, change, options) {
  186. if (!actor) return;
  187. // Determine which comparators are applicable so that we can compare after the
  188. // actor update
  189. const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
  190. if (TVA_CONFIG.internalEffects.hpChange.enabled && tokens.length) {
  191. applyHpChangeEffect(actor, change, tokens);
  192. }
  193. for (const tkn of tokens) {
  194. const preUpdateEffects = getTokenEffects(tkn, true);
  195. if (preUpdateEffects.length) {
  196. setProperty(options, 'token-variants.' + tkn.id + '.preUpdateEffects', preUpdateEffects);
  197. }
  198. }
  199. }
  200. function _preUpdateCheck(actor, options, pAdded = [], pRemoved = []) {
  201. if (!actor) return;
  202. const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
  203. for (const tkn of tokens) {
  204. // Check if effects changed by comparing them against the ones calculated in preUpdate*
  205. const added = [...pAdded];
  206. const removed = [...pRemoved];
  207. const postUpdateEffects = getTokenEffects(tkn, true);
  208. const preUpdateEffects = getProperty(options, 'token-variants.' + tkn.id + '.preUpdateEffects') ?? [];
  209. determineAddedRemovedEffects(added, removed, postUpdateEffects, preUpdateEffects);
  210. if (added.length || removed.length) updateWithEffectMapping(tkn, { added, removed });
  211. }
  212. }
  213. function _createToken(token, options, userId) {
  214. if (userId && userId === game.user.id) updateWithEffectMapping(token);
  215. }
  216. function _preUpdateCombat(combat, round, options, userId) {
  217. if (game.userId !== userId) return;
  218. options['token-variants'] = {
  219. combatantId: combat?.combatant?.token?.id,
  220. nextCombatantId: combat?.nextCombatant?.token?.id,
  221. };
  222. }
  223. function _updateCombat(combat, round, options, userId) {
  224. if (game.userId !== userId) return;
  225. const previousCombatantId = options['token-variants']?.combatantId;
  226. const previousNextCombatantId = options['token-variants']?.nextCombatantId;
  227. const currentCombatantId = combat?.combatant?.token?.id;
  228. const currentNextCombatantId = combat?.nextCombatant?.token?.id;
  229. const updateCombatant = function (id, added = [], removed = []) {
  230. if (game.user.isGM) {
  231. const token = canvas.tokens.get(id);
  232. if (token) updateWithEffectMapping(token, { added, removed });
  233. } else {
  234. const message = {
  235. handlerName: 'effectMappings',
  236. args: { tokenId: id, sceneId: canvas.scene.id, added, removed },
  237. type: 'UPDATE',
  238. };
  239. game.socket?.emit('module.token-variants', message);
  240. }
  241. };
  242. if (previousCombatantId !== currentCombatantId) {
  243. if (previousCombatantId) updateCombatant(previousCombatantId, [], ['combat-turn']);
  244. if (currentCombatantId) updateCombatant(currentCombatantId, ['combat-turn'], []);
  245. }
  246. if (previousNextCombatantId !== currentNextCombatantId) {
  247. if (previousNextCombatantId) updateCombatant(previousNextCombatantId, [], ['combat-turn-next']);
  248. if (currentNextCombatantId) updateCombatant(currentNextCombatantId, ['combat-turn-next'], []);
  249. }
  250. }
  251. function _updateItem(item, change, options, userId) {
  252. const added = [];
  253. const removed = [];
  254. if (game.user.id === userId) {
  255. // Handle condition/effect name change
  256. if (options['token-variants-old-name'] != null && options['token-variants-old-name'] !== item.name) {
  257. added.push(item.name);
  258. removed.push(options['token-variants-old-name']);
  259. }
  260. _preUpdateCheck(item.parent, options, added, removed);
  261. }
  262. }
  263. let EFFECT_M_QUEUES = {};
  264. let EFFECT_M_TIMER;
  265. export async function updateWithEffectMapping(token, { added = [], removed = [] } = {}) {
  266. const callUpdateWithEffectMapping = function () {
  267. for (const id of Object.keys(EFFECT_M_QUEUES)) {
  268. const m = EFFECT_M_QUEUES[id];
  269. _updateWithEffectMapping(m.token, m.opts.added, m.opts.removed);
  270. }
  271. EFFECT_M_QUEUES = {};
  272. };
  273. clearTimeout(EFFECT_M_TIMER);
  274. if (token.id in EFFECT_M_QUEUES) {
  275. const opts = EFFECT_M_QUEUES[token.id].opts;
  276. added.forEach((a) => opts.added.add(a));
  277. removed.forEach((a) => opts.removed.add(a));
  278. } else {
  279. EFFECT_M_QUEUES[token.id] = {
  280. token,
  281. opts: { added: new Set(added), removed: new Set(removed) },
  282. };
  283. }
  284. EFFECT_M_TIMER = setTimeout(callUpdateWithEffectMapping, 100);
  285. }
  286. async function _updateWithEffectMapping(token, added, removed) {
  287. const placeable = token.object ?? token._object ?? token;
  288. token = token.document ?? token;
  289. const tokenImgName = token.getFlag('token-variants', 'name') || getFileName(token.texture.src);
  290. let tokenDefaultImg = token.getFlag('token-variants', 'defaultImg');
  291. const animate = !TVA_CONFIG.disableTokenUpdateAnimation;
  292. const tokenUpdateObj = {};
  293. const hadActiveHUD = token.object?.hasActiveHUD;
  294. const toggleStatus = canvas.tokens.hud.object?.id === token.id ? canvas.tokens.hud._statusEffects : false;
  295. let effects = getTokenEffects(token);
  296. // If effect is included in `added` or `removed` we need to:
  297. // 1. Insert it into `effects` if it's not there in case of 'added' and place it on top of the list
  298. // 2. Remove it in case of 'removed'
  299. for (const ef of added) {
  300. const i = effects.findIndex((s) => s === ef);
  301. if (i === -1) {
  302. effects.push(ef);
  303. } else if (i < effects.length - 1) {
  304. effects.splice(i, 1);
  305. effects.push(ef);
  306. }
  307. }
  308. for (const ef of removed) {
  309. const i = effects.findIndex((s) => s === ef);
  310. if (i !== -1) {
  311. effects.splice(i, 1);
  312. }
  313. }
  314. const mappings = getAllEffectMappings(token);
  315. // 3. Configurations may contain effect names in a form of a logical expressions
  316. // We need to evaluate them and insert them into effects/added/removed if needed
  317. for (const mapping of mappings) {
  318. evaluateMappingExpression(mapping, effects, token, added, removed);
  319. }
  320. // Accumulate all scripts that will need to be run after the update
  321. const executeOnCallback = [];
  322. const deferredUpdateScripts = [];
  323. for (const ef of removed) {
  324. const script = mappings.find((m) => m.id === ef)?.config?.tv_script;
  325. if (script) {
  326. if (script.onRemove) {
  327. if (script.onRemove.includes('tvaUpdate')) deferredUpdateScripts.push(script.onRemove);
  328. else executeOnCallback.push({ script: script.onRemove, token });
  329. }
  330. if (script.tmfxPreset) executeOnCallback.push({ tmfxPreset: script.tmfxPreset, token, action: 'remove' });
  331. if (script.ceEffect?.name) executeOnCallback.push({ ceEffect: script.ceEffect, token, action: 'remove' });
  332. if (script.macroOnApply) executeOnCallback.push({ macro: script.macroOnApply, token });
  333. }
  334. }
  335. for (const ef of added) {
  336. const script = mappings.find((m) => m.id === ef)?.config?.tv_script;
  337. if (script) {
  338. if (script.onApply) {
  339. if (script.onApply.includes('tvaUpdate')) deferredUpdateScripts.push(script.onApply);
  340. else executeOnCallback.push({ script: script.onApply, token });
  341. }
  342. if (script.tmfxPreset) executeOnCallback.push({ tmfxPreset: script.tmfxPreset, token, action: 'apply' });
  343. if (script.ceEffect?.name) executeOnCallback.push({ ceEffect: script.ceEffect, token, action: 'apply' });
  344. if (script.macroOnRemove) executeOnCallback.push({ macro: script.macroOnRemove, token });
  345. }
  346. }
  347. // Next we're going to determine what configs need to be applied and in what order
  348. // Filter effects that do not have a mapping and sort based on priority
  349. effects = mappings.filter((m) => effects.includes(m.id)).sort((ef1, ef2) => ef1.priority - ef2.priority);
  350. // Check if image update should be prevented based on module settings
  351. let disableImageUpdate = false;
  352. if (TVA_CONFIG.disableImageChangeOnPolymorphed && token.actor?.isPolymorphed) {
  353. disableImageUpdate = true;
  354. } else if (
  355. TVA_CONFIG.disableImageUpdateOnNonPrototype &&
  356. token.actor?.prototypeToken?.texture?.src !== token.texture.src
  357. ) {
  358. disableImageUpdate = true;
  359. const tknImg = token.texture.src;
  360. for (const m of mappings) {
  361. if (m.imgSrc === tknImg) {
  362. disableImageUpdate = false;
  363. break;
  364. }
  365. }
  366. }
  367. if (disableImageUpdate) {
  368. tokenDefaultImg = '';
  369. }
  370. let updateCall;
  371. if (effects.length > 0) {
  372. // Some effect mappings may not have images, find a mapping with one if it exists
  373. const newImg = { imgSrc: '', imgName: '' };
  374. if (!disableImageUpdate) {
  375. for (let i = effects.length - 1; i >= 0; i--) {
  376. if (effects[i].imgSrc) {
  377. let iSrc = effects[i].imgSrc;
  378. if (iSrc.includes('*') || (iSrc.includes('{') && iSrc.includes('}'))) {
  379. // wildcard image, if this effect hasn't been newly applied we do not want to randomize the image again
  380. if (!added.has(effects[i].overlayConfig?.effect)) {
  381. newImg.imgSrc = token.texture.src;
  382. newImg.imgName = getFileName(newImg.imgSrc);
  383. break;
  384. }
  385. }
  386. newImg.imgSrc = effects[i].imgSrc;
  387. newImg.imgName = effects[i].imgName;
  388. break;
  389. }
  390. }
  391. }
  392. // Collect custom configs to be applied to the token
  393. let config;
  394. if (TVA_CONFIG.stackStatusConfig) {
  395. config = {};
  396. for (const ef of effects) {
  397. config = mergeObject(config, ef.config);
  398. }
  399. } else {
  400. for (let i = effects.length - 1; i >= 0; i--) {
  401. if (effects[i].config && Object.keys(effects[i].config).length !== 0) {
  402. config = effects[i].config;
  403. break;
  404. }
  405. }
  406. }
  407. // Use or update the default (original) token image
  408. if (!newImg.imgSrc && tokenDefaultImg) {
  409. delete tokenUpdateObj.flags?.['token-variants']?.defaultImg;
  410. setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultImg', null);
  411. newImg.imgSrc = tokenDefaultImg.imgSrc;
  412. newImg.imgName = tokenDefaultImg.imgName;
  413. } else if (!tokenDefaultImg && newImg.imgSrc) {
  414. setProperty(tokenUpdateObj, 'flags.token-variants.defaultImg', {
  415. imgSrc: token.texture.src,
  416. imgName: tokenImgName,
  417. });
  418. }
  419. updateCall = () =>
  420. updateTokenImage(newImg.imgSrc ?? null, {
  421. token,
  422. imgName: newImg.imgName ? newImg.imgName : tokenImgName,
  423. tokenUpdate: tokenUpdateObj,
  424. callback: _postTokenUpdateProcessing.bind(null, token, hadActiveHUD, toggleStatus, executeOnCallback),
  425. config: config,
  426. animate,
  427. });
  428. }
  429. // If no mapping has been found and the default image (image prior to effect triggered update) is different from current one
  430. // reset the token image back to default
  431. if (effects.length === 0 && tokenDefaultImg) {
  432. delete tokenUpdateObj.flags?.['token-variants']?.defaultImg;
  433. setProperty(tokenUpdateObj, 'flags.token-variants.-=defaultImg', null);
  434. updateCall = () =>
  435. updateTokenImage(tokenDefaultImg.imgSrc, {
  436. token,
  437. imgName: tokenDefaultImg.imgName,
  438. tokenUpdate: tokenUpdateObj,
  439. callback: _postTokenUpdateProcessing.bind(null, token, hadActiveHUD, toggleStatus, executeOnCallback),
  440. animate,
  441. });
  442. // If no default image exists but a custom effect is applied, we still want to perform an update to
  443. // clear it
  444. } else if (effects.length === 0 && token.getFlag('token-variants', 'usingCustomConfig')) {
  445. updateCall = () =>
  446. updateTokenImage(token.texture.src, {
  447. token,
  448. imgName: tokenImgName,
  449. tokenUpdate: tokenUpdateObj,
  450. callback: _postTokenUpdateProcessing.bind(null, token, hadActiveHUD, toggleStatus, executeOnCallback),
  451. animate,
  452. });
  453. }
  454. if (updateCall) {
  455. if (deferredUpdateScripts.length) {
  456. for (let i = 0; i < deferredUpdateScripts.length; i++) {
  457. if (i === deferredUpdateScripts.length - 1) {
  458. await tv_executeScript(deferredUpdateScripts[i], {
  459. token,
  460. tvaUpdate: () => {
  461. updateCall();
  462. },
  463. });
  464. } else {
  465. await tv_executeScript(deferredUpdateScripts[i], {
  466. token,
  467. tvaUpdate: () => {},
  468. });
  469. }
  470. }
  471. } else {
  472. updateCall();
  473. }
  474. } else {
  475. if (executeOnCallback.length || deferredUpdateScripts.length) {
  476. _postTokenUpdateProcessing(token, hadActiveHUD, toggleStatus, executeOnCallback);
  477. _postTokenUpdateProcessing(token, hadActiveHUD, toggleStatus, deferredUpdateScripts);
  478. }
  479. }
  480. broadcastDrawOverlays(placeable);
  481. }
  482. async function _postTokenUpdateProcessing(token, hadActiveHUD, toggleStatus, scripts) {
  483. if (hadActiveHUD && token.object) {
  484. canvas.tokens.hud.bind(token.object);
  485. if (toggleStatus) canvas.tokens.hud._toggleStatusEffects(true);
  486. }
  487. for (const scr of scripts) {
  488. if (scr.script) {
  489. await tv_executeScript(scr.script, { token: scr.token });
  490. } else if (scr.tmfxPreset) {
  491. await applyTMFXPreset(scr.token, scr.tmfxPreset, scr.action);
  492. } else if (scr.ceEffect) {
  493. await applyCEEffect(scr.token, scr.ceEffect, scr.action);
  494. } else if (scr.macro) {
  495. await executeMacro(scr.macro, token);
  496. }
  497. }
  498. }
  499. export function getAllEffectMappings(token = null, includeDisabled = false) {
  500. let allMappings = getFlagMappings(token);
  501. const unique = new Set();
  502. // TODO: replace with a setting
  503. allMappings.forEach((m) => unique.add(TVA_CONFIG.mergeGroup ? m.group : m.label));
  504. // Sort out global mappings that do not apply to this actor
  505. let applicableGlobal = TVA_CONFIG.globalMappings;
  506. if (token?.actor?.type) {
  507. const actorType = token.actor.type;
  508. applicableGlobal = applicableGlobal.filter((m) => {
  509. if (!m.targetActors || m.targetActors.includes(actorType)) {
  510. return !unique.has(TVA_CONFIG.mergeGroup ? m.group : m.label);
  511. }
  512. return false;
  513. });
  514. }
  515. allMappings = allMappings.concat(applicableGlobal);
  516. if (!includeDisabled) allMappings = allMappings.filter((m) => !m.disabled);
  517. return allMappings;
  518. }
  519. export async function setOverlayVisibility({
  520. userName = null,
  521. userId = null,
  522. label = null,
  523. group = null,
  524. token = null,
  525. visible = true,
  526. } = {}) {
  527. if (!label && !group) return;
  528. if (userName) userId = game.users.find((u) => u.name === userName)?.id;
  529. if (!userId) return;
  530. let tokenMappings = getFlagMappings(token);
  531. let globalMappings = TVA_CONFIG.globalMappings;
  532. let updateToken = false;
  533. let updateGlobal = false;
  534. const updateMappings = function (mappings) {
  535. mappings = mappings.filter((m) => m.overlay && (m.label === label || m.group === group));
  536. let found = false;
  537. if (mappings.length) found = true;
  538. mappings.forEach((m) => {
  539. const overlayConfig = m.overlayConfig;
  540. if (visible) {
  541. if (!overlayConfig.limitedUsers) overlayConfig.limitedUsers = [];
  542. if (!overlayConfig.limitedUsers.find((u) => u === userId)) overlayConfig.limitedUsers.push(userId);
  543. } else if (overlayConfig.limitedUsers) {
  544. overlayConfig.limitedUsers = overlayConfig.limitedUsers.filter((u) => u !== userId);
  545. }
  546. });
  547. return found;
  548. };
  549. updateToken = updateMappings(tokenMappings);
  550. updateGlobal = updateMappings(globalMappings);
  551. if (updateGlobal) await updateSettings({ globalMappings: globalMappings });
  552. if (updateToken) {
  553. const actor = game.actors.get(token.document.actorId);
  554. if (actor) await actor.setFlag('token-variants', 'effectMappings', tokenMappings);
  555. }
  556. if (updateToken || updateGlobal) drawOverlays(token);
  557. }
  558. function _getTemplateMappings(templateName) {
  559. return (
  560. TVA_CONFIG.templateMappings.find((t) => t.name === templateName) ??
  561. CORE_TEMPLATES.find((t) => t.name === templateName)
  562. )?.mappings;
  563. }
  564. export async function applyTemplate(token, templateName = null, mappings = null) {
  565. if (templateName) mappings = _getTemplateMappings(templateName);
  566. if (!token || !mappings) return;
  567. const actor = game.actors.get(token.actor.id);
  568. if (!actor) return;
  569. const templateMappings = deepClone(mappings);
  570. templateMappings.forEach((tm) => (tm.tokens = [token.id]));
  571. const actMappings = mergeMappings(templateMappings, getFlagMappings(actor));
  572. await actor.setFlag('token-variants', 'effectMappings', actMappings);
  573. await updateWithEffectMapping(token);
  574. drawOverlays(token);
  575. }
  576. export async function removeTemplate(token, templateName = null, mappings = null) {
  577. if (templateName) mappings = _getTemplateMappings(templateName);
  578. if (!token || !mappings) return;
  579. const actor = game.actors.get(token.actor.id);
  580. if (!actor) return;
  581. let actMappings = getFlagMappings(actor);
  582. mappings.forEach((m) => {
  583. let i = actMappings.findIndex((m2) => m2.id === m.id);
  584. if (i !== -1) {
  585. actMappings[i].tokens = actMappings[i].tokens.filter((t) => t !== token.id);
  586. if (actMappings[i].tokens.length === 0) actMappings.splice(i, 1);
  587. }
  588. });
  589. if (actMappings.length) await actor.setFlag('token-variants', 'effectMappings', actMappings);
  590. else await actor.unsetFlag('token-variants', 'effectMappings');
  591. await updateWithEffectMapping(token);
  592. drawOverlays(token);
  593. }
  594. export function toggleTemplate(token, templateName = null, mappings = null) {
  595. if (templateName) mappings = _getTemplateMappings(templateName);
  596. if (!token || !mappings) return;
  597. const actor = game.actors.get(token.actor.id);
  598. if (!actor) return;
  599. const actMappings = getFlagMappings(actor);
  600. if (actMappings.some((m) => mappings.some((m2) => m2.id === m.id && m.tokens?.includes(token.id)))) {
  601. removeTemplate(token, null, mappings);
  602. } else {
  603. applyTemplate(token, null, mappings);
  604. }
  605. }
  606. export function toggleTemplateOnSelected(templateName = null, mappings = null) {
  607. canvas.tokens.controlled.forEach((t) => toggleTemplate(t, templateName, mappings));
  608. }
  609. function getHPChangeEffect(token, effects) {
  610. const internals = token.actor?.getFlag('token-variants', 'internalEffects') || {};
  611. const delta = getProperty(
  612. token,
  613. `${isNewerVersion('11', game.version) ? 'actorData' : 'delta'}.flags.token-variants.internalEffects`
  614. );
  615. if (delta) mergeObject(internals, delta);
  616. if (internals['hp--'] != null) effects.push('hp--');
  617. if (internals['hp++'] != null) effects.push('hp++');
  618. }
  619. function applyHpChangeEffect(actor, change, tokens) {
  620. let duration = Number(TVA_CONFIG.internalEffects.hpChange.duration);
  621. const newHpValue = getProperty(change, `system.${TVA_CONFIG.systemHpPath}.value`);
  622. if (newHpValue != null) {
  623. const [currentHpVal, _] = getTokenHP(tokens[0]);
  624. if (currentHpVal !== newHpValue) {
  625. if (currentHpVal < newHpValue) {
  626. setProperty(change, 'flags.token-variants.internalEffects.-=hp--', null);
  627. setProperty(change, 'flags.token-variants.internalEffects.hp++', newHpValue - currentHpVal);
  628. if (duration) {
  629. setTimeout(() => {
  630. actor.update({
  631. 'flags.token-variants.internalEffects.-=hp++': null,
  632. });
  633. }, duration * 1000);
  634. }
  635. } else {
  636. setProperty(change, 'flags.token-variants.internalEffects.-=hp++', null);
  637. setProperty(change, 'flags.token-variants.internalEffects.hp--', newHpValue - currentHpVal);
  638. if (duration) {
  639. setTimeout(() => {
  640. actor.update({
  641. 'flags.token-variants.internalEffects.-=hp--': null,
  642. });
  643. }, duration * 1000);
  644. }
  645. }
  646. }
  647. }
  648. }
  649. export function getTokenEffects(token, includeExpressions = false) {
  650. const data = token.document ?? token;
  651. let effects = [];
  652. // TVA Effects
  653. const tokenInCombat = game.combats.some((combat) => {
  654. return combat.combatants.some((c) => c.tokenId === token.id);
  655. });
  656. if (tokenInCombat) {
  657. effects.push('token-variants-combat');
  658. }
  659. if (game.combat?.started) {
  660. if (game.combat?.combatant?.token?.id === token.id) {
  661. effects.push('combat-turn');
  662. } else if (game.combat?.nextCombatant?.token?.id === token.id) {
  663. effects.push('combat-turn-next');
  664. }
  665. }
  666. if (data.hidden) {
  667. effects.push('token-variants-visibility');
  668. }
  669. if (TVA_CONFIG.internalEffects.hpChange.enabled) {
  670. getHPChangeEffect(data, effects);
  671. }
  672. // Actor/Token effects
  673. if (data.actorLink) {
  674. getEffectsFromActor(token.actor, effects);
  675. } else {
  676. if (game.system.id === 'pf2e') {
  677. (data.delta?.items || []).forEach((item) => {
  678. if (_activePF2EItem(item)) {
  679. effects.push(item.name);
  680. }
  681. });
  682. } else {
  683. (data.effects || []).filter((ef) => !ef.disabled && !ef.isSuppressed).forEach((ef) => effects.push(ef.label));
  684. getEffectsFromActor(token.actor, effects);
  685. }
  686. }
  687. // Expression/Mapping effects
  688. evaluateComparatorEffects(token, effects);
  689. evaluateStateEffects(token, effects);
  690. // Include mappings marked as always applicable
  691. // as well as the ones defined as logical expressions if needed
  692. const mappings = getAllEffectMappings(token);
  693. for (const m of mappings) {
  694. if (m.tokens?.length && !m.tokens.includes(data.id)) continue;
  695. if (m.alwaysOn) effects.unshift(m.id);
  696. else if (includeExpressions) {
  697. const evaluation = evaluateMappingExpression(m, effects, token);
  698. if (evaluation) effects.unshift(m.id);
  699. }
  700. }
  701. return effects;
  702. }
  703. export function getEffectsFromActor(actor, effects = []) {
  704. if (!actor) return effects;
  705. if (game.system.id === 'pf2e') {
  706. (actor.items || []).forEach((item, id) => {
  707. if (_activePF2EItem(item)) effects.push(item.name);
  708. });
  709. } else {
  710. (actor.effects || []).forEach((activeEffect, id) => {
  711. if (!activeEffect.disabled && !activeEffect.isSuppressed) effects.push(activeEffect.name ?? activeEffect.label);
  712. });
  713. (actor.items || []).forEach((item) => {
  714. if (ITEM_TYPES.includes(item.type) && item.system.equipped) effects.push(item.name ?? item.label);
  715. });
  716. }
  717. return effects;
  718. }
  719. function _activePF2EItem(item) {
  720. if (PF2E_ITEM_TYPES.includes(item.type)) {
  721. if ('active' in item) {
  722. return item.active;
  723. } else if ('isEquipped' in item) {
  724. return item.isEquipped;
  725. } else {
  726. return true;
  727. }
  728. }
  729. return false;
  730. }
  731. export const VALID_EXPRESSION = new RegExp('([a-zA-Z\\-\\.\\+]+)([><=]+)(".*"|-?\\d+)(%{0,1})');
  732. export function evaluateComparator(token, expression) {
  733. const match = expression.match(VALID_EXPRESSION);
  734. if (match) {
  735. const property = match[1];
  736. let currVal;
  737. let maxVal;
  738. if (property === 'hp') {
  739. [currVal, maxVal] = getTokenHP(token);
  740. } else if (property === 'hp++' || property === 'hp--') {
  741. [currVal, maxVal] = getTokenHP(token);
  742. currVal = getProperty(token, `actor.flags.token-variants.internalEffects.${property}`) ?? 0;
  743. } else currVal = getProperty(token, property);
  744. if (currVal == null) currVal = 0;
  745. const sign = match[2];
  746. let val = Number(match[3]);
  747. if (isNaN(val)) {
  748. val = match[3].substring(1, match[3].length - 1);
  749. if (val === 'true') val = true;
  750. if (val === 'false') val = false;
  751. // Convert currVal to a truthy/falsy one if this is a bool check
  752. if (val === true || val === false) {
  753. if (isEmpty(currVal)) currVal = false;
  754. else currVal = Boolean(currVal);
  755. }
  756. }
  757. const isPercentage = Boolean(match[4]);
  758. if (property === 'rotation') {
  759. maxVal = 360;
  760. } else if (maxVal == null) {
  761. maxVal = 999999;
  762. }
  763. const toCompare = isPercentage ? (currVal / maxVal) * 100 : currVal;
  764. let passed = false;
  765. if (sign === '=') {
  766. passed = toCompare == val;
  767. } else if (sign === '>') {
  768. passed = toCompare > val;
  769. } else if (sign === '<') {
  770. passed = toCompare < val;
  771. } else if (sign === '>=') {
  772. passed = toCompare >= val;
  773. } else if (sign === '<=') {
  774. passed = toCompare <= val;
  775. } else if (sign === '<>') {
  776. passed = toCompare < val || toCompare > val;
  777. }
  778. return passed;
  779. }
  780. return false;
  781. }
  782. export function evaluateComparatorEffects(token, effects = []) {
  783. token = token.document ?? token;
  784. const mappings = getAllEffectMappings(token);
  785. const matched = new Set();
  786. for (const m of mappings) {
  787. const expressions = m.expression
  788. .split(EXPRESSION_MATCH_RE)
  789. .filter(Boolean)
  790. .map((exp) => exp.trim())
  791. .filter(Boolean);
  792. for (let i = 0; i < expressions.length; i++) {
  793. if (evaluateComparator(token, expressions[i])) {
  794. matched.add(expressions[i]);
  795. }
  796. }
  797. }
  798. // Remove duplicate expressions and insert into effects
  799. matched.forEach((exp) => effects.unshift(exp));
  800. return effects;
  801. }
  802. export function evaluateStateEffects(token, effects) {
  803. if (game.system.id === 'pf2e') {
  804. const deathIcon = game.settings.get('pf2e', 'deathIcon');
  805. if ((token.document ?? token).overlayEffect === deathIcon) effects.push('Dead');
  806. }
  807. }
  808. /**
  809. * Replaces {1,a,5,b} type string in the expressions with (1|a|5|b)
  810. * @param {*} exp
  811. * @returns
  812. */
  813. function _findReplaceBracketWildcard(exp) {
  814. let nExp = '';
  815. let lIndex = 0;
  816. while (lIndex >= 0) {
  817. let i1 = exp.indexOf('\\\\\\{', lIndex);
  818. if (i1 !== -1) {
  819. let i2 = exp.indexOf('\\\\\\}', i1);
  820. if (i2 !== -1) {
  821. nExp += exp.substring(lIndex, i1);
  822. nExp +=
  823. '(' +
  824. exp
  825. .substring(i1 + 4, i2)
  826. .split(',')
  827. .join('|') +
  828. ')';
  829. }
  830. lIndex = i2 + 4;
  831. } else {
  832. return nExp + exp.substring(lIndex, exp.length);
  833. }
  834. }
  835. return nExp ?? exp;
  836. }
  837. function _testRegExEffect(effect, effects) {
  838. let re = effect.replace(/[/\-\\^$+?.()|[\]{}]/g, '\\$&').replaceAll('\\\\*', '.*');
  839. re = _findReplaceBracketWildcard(re);
  840. re = new RegExp('^' + re + '$');
  841. return effects.find((ef) => re.test(ef));
  842. }
  843. export function evaluateMappingExpression(mapping, effects, token, added = new Set(), removed = new Set()) {
  844. let arrExpression = mapping.expression
  845. .split(EXPRESSION_MATCH_RE)
  846. .filter(Boolean)
  847. .map((s) => s.trim())
  848. .filter(Boolean);
  849. let temp = '';
  850. let hasAdded = false;
  851. let hasRemoved = false;
  852. for (let exp of arrExpression) {
  853. if (EXPRESSION_OPERATORS.includes(exp)) {
  854. temp += exp.replace('\\', '');
  855. continue;
  856. }
  857. if (/\\\*|\\{.*\\}/g.test(exp)) {
  858. let rExp = _testRegExEffect(exp, effects);
  859. if (rExp) {
  860. temp += 'true';
  861. } else {
  862. temp += 'false';
  863. }
  864. if (_testRegExEffect(exp, added)) hasAdded = true;
  865. else if (_testRegExEffect(exp, removed)) hasRemoved = true;
  866. continue;
  867. } else if (effects.includes(exp)) {
  868. temp += 'true';
  869. } else {
  870. temp += 'false';
  871. }
  872. if (!hasAdded && added.has(exp)) hasAdded = true;
  873. if (!hasRemoved && removed.has(exp)) hasRemoved = true;
  874. }
  875. try {
  876. let evaluation = eval(temp);
  877. // Evaluate JS code
  878. if (mapping.codeExp) {
  879. try {
  880. token = token.document ?? token;
  881. if (!eval(mapping.codeExp)) evaluation = false;
  882. else if (!mapping.expression) evaluation = true;
  883. } catch (e) {
  884. evaluation = false;
  885. }
  886. }
  887. if (evaluation) {
  888. if (hasAdded || hasRemoved) {
  889. added.add(mapping.id);
  890. effects.push(mapping.id);
  891. } else effects.unshift(mapping.id);
  892. } else if (hasRemoved || hasAdded) {
  893. removed.add(mapping.id);
  894. }
  895. return evaluation;
  896. } catch (e) {}
  897. return false;
  898. }
  899. function _getTokenHPv11(token) {
  900. let attributes;
  901. if (token.actorLink) {
  902. attributes = getProperty(token.actor?.system, TVA_CONFIG.systemHpPath);
  903. } else {
  904. attributes = mergeObject(
  905. getProperty(token.actor?.system, TVA_CONFIG.systemHpPath) || {},
  906. getProperty(token.delta?.system) || {},
  907. {
  908. inplace: false,
  909. }
  910. );
  911. }
  912. return [attributes?.value, attributes?.max];
  913. }
  914. export function getTokenHP(token) {
  915. if (!isNewerVersion('11', game.version)) return _getTokenHPv11(token);
  916. let attributes;
  917. if (token.actorLink) {
  918. attributes = getProperty(token.actor.system, TVA_CONFIG.systemHpPath);
  919. } else {
  920. attributes = mergeObject(
  921. getProperty(token.actor.system, TVA_CONFIG.systemHpPath) || {},
  922. getProperty(token.actorData?.system) || {},
  923. {
  924. inplace: false,
  925. }
  926. );
  927. }
  928. return [attributes?.value, attributes?.max];
  929. }
  930. async function _updateImageOnEffectChange(effectName, actor, added = true) {
  931. const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
  932. for (const token of tokens) {
  933. await updateWithEffectMapping(token, {
  934. added: added ? [effectName] : [],
  935. removed: !added ? [effectName] : [],
  936. });
  937. }
  938. }
  939. async function _updateImageOnMultiEffectChange(actor, added = [], removed = []) {
  940. if (!actor) return;
  941. const tokens = actor.token ? [actor.token] : getAllActorTokens(actor, true, !TVA_CONFIG.mappingsCurrentSceneOnly);
  942. for (const token of tokens) {
  943. await updateWithEffectMapping(token, {
  944. added: added,
  945. removed: removed,
  946. });
  947. }
  948. }
  949. async function _deleteCombatant(combatant) {
  950. const token = combatant._token || canvas.tokens.get(combatant.tokenId);
  951. if (!token || !token.actor) return;
  952. await updateWithEffectMapping(token, {
  953. removed: ['token-variants-combat'],
  954. });
  955. }