` +
+ `
` +
+ `
` +
+ `
` +
+ `${cardParameters.title} ` +
+ `${subtitle} ` +
+ `
` +
+ `
` +
+ `
`;
+ output = replaceStyleInformation(output, cardParameters);
+ return output;
+ };
+
+ //Inline roll for a plain-number damage formula. Roll20 can't start an inline roll with a negative number,
+ //so healing amounts are written as "0 - n". Non-numeric values count as 0.
+ /**
+ * Turns a plain-number damage value into an inline-roll string. Negative (healing) values are written as '0 - n'
+ * because Roll20 inline rolls cannot start with a negative number.
+ *
+ * @param {number} base - Flat damage amount; non-finite values are treated as 0.
+ * @returns {string} Inline-roll string such as '[[5]]' or '[[0 - 5]]'.
+ */
+ const baseDamageFormula = function (base) {
+ const n = Number.isFinite(base) ? base : 0;
+ return n < 0 ? `[[0 - ${Math.abs(n)}]]` : `[[${n}]]`;
+ };
+
+ //Damage row(s) for the chat card: every damage roll with a non-zero result is shown, so formula 2
+ //still appears when formula 1 is absent. Returns '' when there is no damage to show.
+ /**
+ * Builds the damage section of the chat card from the rolled damage results. Every roll with a non-zero result is
+ * shown, so damage 2 still appears when damage 1 is absent.
+ *
+ * @param {object[]} damageRolls - Damage roll results, each with styled_1 (inline-roll HTML) and result_1 (array of
+ * totals).
+ * @param {object} aoeLink - AoE link object from state; supplies damageType1/damageType2 and cardParameters.
+ * @returns {string} HTML for the damage row, or '' when there is no damage to show.
+ */
+ const buildDamageRows = function (damageRolls, aoeLink) {
+ const shown = damageRolls
+ .map((r, idx) => {
+ return {
+ styled: r.styled_1,
+ type: idx === 0 ? aoeLink.damageType1 : aoeLink.damageType2,
+ value: r.result_1 ? r.result_1[0] : 0,
+ };
+ })
+ .filter((r) => r.value);
+ if (shown.length === 0) {
+ return '';
+ }
+ const second = shown[1] || { styled: '', type: '' };
+ return buildDamageRow(shown[0].styled, shown[0].type, second.styled, second.type, aoeLink.cardParameters);
+ };
+
+ /**
+ * Builds a one-row HTML table showing one or two damage rolls with their damage types.
+ *
+ * @param {string} damRoll1 - Inline-roll HTML for the first damage roll.
+ * @param {string} damType1 - Damage type label for the first roll.
+ * @param {string} [damRoll2=''] - Inline-roll HTML for the second damage roll; omitted from the row when empty.
+ * @param {string} [damType2=''] - Damage type label for the second roll.
+ * @param {object} cardParameters - Chat card style settings used to fill placeholders.
+ * @returns {string} HTML table for the damage row.
+ */
+ const buildDamageRow = function (damRoll1, damType1, damRoll2 = '', damType2 = '', cardParameters) {
+ const damageCell = (roll, type) =>
+ `
` +
+ `Dmg: ` +
+ `${roll}` +
+ `${type} ` +
+ ` `;
+
+ let rowData = damageCell(damRoll1, damType1);
+ if (damRoll2 !== '') {
+ rowData = rowData + damageCell(damRoll2, damType2);
+ }
+
+ let output =
+ `
`;
+
+ output = replaceStyleInformation(output, cardParameters);
+ return output;
+ };
+
+ /**
+ * Colours for the next row of the saving-throw table, alternating odd/even rows via the shared tableLineCounter.
+ *
+ * @param {object} cardParameters - Chat card style settings, including odd/even row colours.
+ * @returns {{rowBackground:string, fontColor:string}} Background and text colour for the row.
+ */
+ const nextRowColors = function (cardParameters) {
+ tableLineCounter += 1;
+ return tableLineCounter % 2 === 0
+ ? { rowBackground: cardParameters.evenrowbackground, fontColor: cardParameters.evenrowfontcolor }
+ : { rowBackground: cardParameters.oddrowbackground, fontColor: cardParameters.oddrowfontcolor };
+ };
+
+ /**
+ * Builds the header row (Target / Roll / P/F? / Dam.) of the saving-throw table.
+ * Increments the shared tableLineCounter to choose the odd/even row colours.
+ *
+ * @param {object} cardParameters - Chat card style settings, including odd/even row colours.
+ * @returns {string} HTML
for the save table header.
+ */
+ const buildSaveHeaderRow = function (cardParameters) {
+ const { rowBackground, fontColor } = nextRowColors(cardParameters);
+
+ let output =
+ ` ` +
+ `Target ` +
+ `Roll ` +
+ `P/F? ` +
+ `Dam. ` +
+ ` `;
+ output = replaceStyleInformation(output, cardParameters);
+ return output;
+ };
+
+ /**
+ * Wraps the GM-only save rows (e.g. tokens on the GM layer) in their own table for a separate whisper to the GM.
+ * Closes the outer card divs opened by buildTitle.
+ *
+ * @param {string} saveRows - Concatenated HTML
rows for GM-only targets.
+ * @param {object} cardParameters - Chat card style settings used to fill placeholders.
+ * @returns {string} HTML table for the GM whisper.
+ */
+ const buildGMOutput = function (saveRows, cardParameters) {
+ let output =
+ `` +
+ `
` +
+ `` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ ` ` +
+ `${saveRows}` +
+ `
`;
+
+ output = replaceStyleInformation(output, cardParameters);
+ return output;
+ };
+
+ /**
+ * Assembles the saving-throw table from the header row, the save rows and the description row, and closes the outer
+ * card divs opened by buildTitle.
+ *
+ * @param {string} saveHeaderRow - HTML header row ('' to omit).
+ * @param {string} saveRows - Concatenated HTML save rows ('' to omit).
+ * @param {string} descRow - HTML description row ('' to omit).
+ * @param {object} cardParameters - Chat card style settings used to fill placeholders.
+ * @returns {string} HTML for the table body and card closing tags.
+ */
+ const buildTableBody = function (saveHeaderRow, saveRows, descRow, cardParameters) {
+ let output =
+ `` +
+ `${saveHeaderRow}` +
+ `${saveRows}` +
+ `${descRow}` +
+ `
`;
+
+ output = replaceStyleInformation(output, cardParameters);
+ return output;
+ };
+
+ /**
+ * Builds one target's row in the saving-throw table: token image (ping-pull link), name, save roll(s), a P/F badge
+ * and the damage taken.
+ * Unless damage is auto-applied, the P/F badge is a button that runs !smartapply with the damage details.
+ * Increments the shared tableLineCounter for row colours.
+ *
+ * @param {string} imageURL - Token image URL.
+ * @param {string} id - Target token id.
+ * @param {string} layer - Token layer; 'gmlayer' makes the ping GM-only and the damage message a GM whisper.
+ * @param {string} name - Display name of the target.
+ * @param {boolean} success - True if the target passed its save.
+ * @param {string} saveInline1 - Inline-roll HTML for the first save roll.
+ * @param {string} saveInline2 - Inline-roll HTML for the second save roll (advantage/disadvantage).
+ * @param {boolean} roll2x - Whether to show the second save roll.
+ * @param {number[]} dam - Damage amounts, joined with '/'.
+ * @param {boolean} autoApply - If true, damage was already applied so no !smartapply link is added.
+ * @param {string[]} bar - Token bar(s) that damage is applied to.
+ * @param {string} markerString - Status markers to apply ('::' separators); 'n/a' if empty.
+ * @param {string} RVI - Resist/vulnerable/immune indicator shown as superscript after the name.
+ * @param {string} zeroHPmarker - Marker(s) to apply at 0 HP ('::' separators); 'n/a' if empty.
+ * @param {boolean} removeAtZero - Whether the token is removed at 0 HP.
+ * @param {object} cardParameters - Chat card style settings, including odd/even row colours.
+ * @param {string} whisperString - Whisper prefix passed to !smartapply.
+ * @param {boolean} whisperResults - Whether damage messages should be whispered to the GM.
+ * @returns {{layer:string, output:string}} The token layer and the row HTML.
+ */
+ const buildSaveRow = function (
+ imageURL,
+ id,
+ layer,
+ name,
+ success,
+ saveInline1,
+ saveInline2,
+ roll2x,
+ dam,
+ autoApply,
+ bar,
+ markerString,
+ RVI,
+ zeroHPmarker,
+ removeAtZero,
+ cardParameters,
+ whisperString,
+ whisperResults,
+ ) {
+ let damString = dam.join('/');
+ markerString = markerString.replace(/::/g, '%%');
+ zeroHPmarker = zeroHPmarker.replace(/::/g, '%%');
+ if (markerString === '') {
+ markerString = 'n/a';
+ }
+ if (zeroHPmarker === '') {
+ zeroHPmarker = 'n/a';
+ }
+ const gmWhisperDamageMsg = layer === 'gmlayer' || whisperResults ? 'true' : 'false';
+
+ const removeAtZeroMsg = removeAtZero ? 'true' : 'false';
+ const barString = bar.join(',');
+
+ //replace some problem characters before creating the !smartapply button action text. These will get un-replaced when triggered
+ const tempName = name.replace(/'/g, '%apostrophe%').replace(/`/g, '%backtick%');
+
+ const applyDamLink = autoApply
+ ? ''
+ : ` href='!smartapply --args|${id}|"${tempName}"|${barString}|${damString}|${markerString}|${zeroHPmarker}|${removeAtZeroMsg}|${gmWhisperDamageMsg}|${whisperString}'`;
+ damString = damString.replace(/\//g, ' / ');
+
+ let RVI_output = '';
+ if (RVI.length > 0) {
+ RVI_output = `${RVI} `;
+ }
+
+ const { rowBackground, fontColor } = nextRowColors(cardParameters);
+
+ //P/F badge: green "P" for a pass, red "F" for a fail; a button for !smartapply unless damage was auto-applied
+ const result = `${success ? 'P' : 'F'} `;
+
+ //ping-pull for tokens on gm layer will only be visible to gm. Otherwise, all players see ping-pull
+ const pingCmd = layer === 'gmlayer' ? '!smartpinggm' : '!smartpingall';
+
+ let output =
+ ` ` +
+ `` +
+ `` +
+ `${name} ` +
+ RVI_output +
+ ` ` +
+ `` +
+ `${saveInline1}${roll2x ? saveInline2 : ''}` +
+ ` ` +
+ `${result}` +
+ `${damString} ` +
+ ` `;
+
+ output = replaceStyleInformation(output, cardParameters);
+
+ return { layer: layer, output: output };
+ };
+
+ /**
+ * Builds the description row shown at the bottom of the saving-throw table.
+ *
+ * @param {string} descText - Description text or HTML.
+ * @param {object} cardParameters - Chat card style settings used to fill placeholders.
+ * @returns {string} HTML for the description row.
+ */
+ const buildDescRow = function (descText, cardParameters) {
+ let output =
+ `
` +
+ `` +
+ `${descText}
` +
+ ` ` +
+ ` `;
+
+ output = replaceStyleInformation(output, cardParameters);
+ return output;
+ };
+
+ /**
+ * Replaces every !{styleName} placeholder in an HTML string with the matching value from cardParameters.
+ *
+ * @param {string} outputLine - HTML containing !{...} placeholders.
+ * @param {object} cardParmeters - Chat card style settings keyed by placeholder name.
+ * @returns {string} HTML with the known placeholders filled in.
+ */
+ const replaceStyleInformation = function (outputLine, cardParmeters) {
+ const styleList = [
+ 'tableborder',
+ 'tablebgcolor',
+ 'tableborderradius',
+ 'tableshadow',
+ 'titlecardbackground',
+ 'titlecardbottomborder',
+ 'titlefontsize',
+ 'titlefontlineheight',
+ 'titlefontcolor',
+ 'bodyfontsize',
+ 'subtitlefontsize',
+ 'subtitlefontcolor',
+ 'titlefontface',
+ 'bodyfontface',
+ 'subtitlefontface',
+ 'buttonbackground',
+ 'buttontextcolor',
+ 'buttonbordercolor',
+ 'dicefontcolor',
+ 'dicefontsize',
+ 'descriptiontext',
+ 'leftmargin',
+ ];
+
+ for (let i = 0; i < styleList.length; i++) {
+ outputLine = outputLine.replace(new RegExp('!{' + styleList[i] + '}', 'g'), cardParmeters[styleList[i]]);
+ }
+ return outputLine;
+ };
+
+ // ————— SAVES & INLINE ROLLS —————
+ const saveList = {
+ '5estr': {
+ name: 'STR Save',
+ formula:
+ '[[d20 + ([[d0 + @{strength_save_bonus}@{pbd_safe}]]*(1-@{npc})) [PC] + (@{npc_str_save}*@{npc}) [NPC]]]',
+ },
+ '5edex': {
+ name: 'DEX Save',
+ formula:
+ '[[d20 + ([[d0 + @{dexterity_save_bonus}@{pbd_safe}]]*(1-@{npc})) [PC] + (@{npc_dex_save}*@{npc}) [NPC]]]',
+ },
+ '5econ': {
+ name: 'CON Save',
+ formula:
+ '[[d20 + ([[d0 + @{constitution_save_bonus}@{pbd_safe}]]*(1-@{npc})) [PC] + (@{npc_con_save}*@{npc}) [NPC]]]',
+ },
+ '5eint': {
+ name: 'INT Save',
+ formula:
+ '[[d20 + ([[d0 + @{intelligence_save_bonus}@{pbd_safe}]]*(1-@{npc})) [PC] + (@{npc_int_save}*@{npc}) [NPC]]]',
+ },
+ '5ewis': {
+ name: 'WIS Save',
+ formula: '[[d20 + ([[d0 + @{wisdom_save_bonus}@{pbd_safe}]]*(1-@{npc})) [PC] + (@{npc_wis_save}*@{npc}) [NPC]]]',
+ },
+ '5echa': {
+ name: 'CHA Save',
+ formula:
+ '[[d20 + ([[d0 + @{charisma_save_bonus}@{pbd_safe}]]*(1-@{npc})) [PC] + (@{npc_cha_save}*@{npc}) [NPC]]]',
+ },
+ pf1fort: {
+ name: 'FORT Save',
+ formula: '[[1d20+@{fortitude}[MOD]+(@{saves_condition})[CONDITION]+@{rollmod_save}[QUERY]]]',
+ },
+ pf1ref: {
+ name: 'REF Save',
+ formula: '[[1d20+@{reflex}[MOD]+(@{saves_condition})[CONDITION]+@{rollmod_save}[QUERY]]]',
+ },
+ pf1will: {
+ name: 'WILL Save',
+ formula: '[[1d20+@{will}[MOD]+(@{saves_condition})[CONDITION]+@{rollmod_save}[QUERY]]]',
+ },
+ pf2fort: {
+ name: 'FORT Save',
+ formula:
+ '[[1d20cs20cf1 + [@{saving_throws_fortitude_proficiency_display}] (@{saving_throws_fortitude})[@{text_modifier}] + (@{query_roll_bonus})[@{text_bonus}]]]',
+ },
+ pf2ref: {
+ name: 'REF Save',
+ formula:
+ '[[1d20cs20cf1 + [@{saving_throws_reflex_proficiency_display}] (@{saving_throws_reflex})[@{text_modifier}] + (@{query_roll_bonus})[@{text_bonus}]]]',
+ },
+ pf2will: {
+ name: 'WILL Save',
+ formula:
+ '[[1d20cs20cf1 + [@{saving_throws_will_proficiency_display}] (@{saving_throws_will})[@{text_modifier}] + (@{query_roll_bonus})[@{text_bonus}]]]',
+ },
+ pfcfort: {
+ name: 'FORT Save',
+ formula:
+ '[[1d20+@{total-Fort}[tot]+@{Fort-ability-mod}[mod]+@{Fort-trait}[trait]+@{Fort-resist}[res]+@{Fort-misc-mod}[misc]+@{saves-cond}[cond]+@{buff_Fort-total}[buff1]+@{buff_saves-total}[buff2]]]',
+ },
+ pfcref: {
+ name: 'REF Save',
+ formula:
+ '[[1d20+@{total-Ref}[tot]+@{Ref-ability-mod}[mod]+@{Ref-trait}[trait]+@{Ref-resist}[res]+@{Ref-misc-mod}[misc]+@{saves-cond}[cond]+@{buff_Ref-total}[buff1]+@{buff_saves-total}[buff2]]]',
+ },
+ pfcwill: {
+ name: 'WILL Save',
+ formula:
+ '[[1d20+@{total-Will}[tot]+@{Will-ability-mod}[mod]+@{Will-trait}[trait]+@{Will-resist}[res]+@{Will-misc-mod}[misc]+@{saves-cond}[cond]+@{buff_Will-total}[buff1]+@{buff_saves-total}[buff2]]]',
+ },
+ custom: {
+ name: '',
+ formula: '',
+ },
+ };
+ /**
+ * Escapes characters that Roll20 chat would otherwise read as HTML, roll or macro syntax, replacing them with HTML
+ * entities.
+ *
+ * @param {string} str - Text to escape.
+ * @param {boolean} [weak] - If true, leave '<' and '>' unescaped.
+ * @returns {string} The escaped text.
+ */
+ const htmlReplace = (str, weak) => {
+ const entities = {
+ '<': 'lt',
+ '>': 'gt',
+ "'": '#39',
+ '@': '#64',
+ '{': '#123',
+ '|': '#124',
+ '}': '#125',
+ '[': '#91',
+ '"': 'quot',
+ ']': '#93',
+ '*': '#42',
+ '&': 'amp',
+ };
+ const regExp = weak ? /['"@{|}[*&\]]/g : /[<>'"@{|}[*&\]]/g;
+ return str.replace(regExp, (c) => '&' + entities[c] + ';');
+ };
+
+ /**
+ * Renders a rolled expression as a Roll20-style inline-roll span showing the total, with a hover tooltip of the
+ * formula and dice. The border colour marks a crit (green), fumble (red) or both (blue).
+ *
+ * @param {object} roll - Inline roll from msg.inlinerolls (needs expression and results).
+ * @param {boolean} hideformula - If true, omit the tooltip so the formula is hidden.
+ * @returns {string} HTML
for the inline roll.
+ */
+ const makeInlineroll = (roll, hideformula) => {
+ const boundary = (results) => {
+ switch (detectCritical(results)) {
+ case 'crit':
+ return ';border:2px solid #3FB315';
+ case 'mixed':
+ return ';border:2px solid #4A57ED';
+ case 'fumble':
+ return ';border:2px solid #B31515';
+ default:
+ return '';
+ }
+ };
+ const background = 'background-color: #FEF68E';
+ return (
+ '${roll.results.total || '0'} `
+ );
+ };
+ /**
+ * Recursively converts a Roll20 roll result tree into tooltip text, wrapping individual dice in crit/fumble styled
+ * spans.
+ *
+ * @param {object} roll - Roll20 roll result node (type 'R' dice, 'M' math, 'V' roll set, 'G' group).
+ * @returns {string} Text/HTML describing the roll; '' for unknown node types.
+ */
+ const rollToText = (roll) => {
+ switch (roll.type) {
+ case 'R': {
+ const c = (roll.mods && roll.mods.customCrit) || [
+ {
+ comp: '==',
+ point: roll.sides,
+ },
+ ],
+ f = (roll.mods && roll.mods.customFumble) || [
+ {
+ comp: '==',
+ point: 1,
+ },
+ ],
+ styledRolls = roll.results.map((r) => {
+ const style = rollIsCrit(r.v, c[0].comp, c[0].point)
+ ? ' critsuccess'
+ : rollIsCrit(r.v, f[0].comp, f[0].point)
+ ? ' critfail'
+ : '';
+ return `${r.v} `;
+ });
+ return `(${styledRolls.join('+')})`;
+ }
+ case 'M':
+ return roll.expr
+ .toString()
+ .replace(/(\+|-)/g, '$1 ')
+ .replace(/\*/g, '&' + 'ast' + ';');
+ case 'V':
+ return roll.rolls.map(rollToText).join(' ');
+ case 'G':
+ return `'(${roll.rolls.map((a) => a.map(rollToText).join(' ')).join(' ')})`;
+ default:
+ return '';
+ }
+ };
+ /**
+ * Recursively checks a Roll20 roll result tree for critical successes and fumbles, honouring custom crit/fumble
+ * ranges (cs/cf).
+ *
+ * @param {object} roll - Roll20 roll result node.
+ * @returns {('crit'|'fumble'|'mixed'|false)} 'mixed' if both occur, 'crit' or 'fumble' if only one does, otherwise
+ * false.
+ */
+ const detectCritical = (roll) => {
+ let s = [];
+ if (roll.type === 'V') s = (roll.rolls || []).map(detectCritical);
+ if (roll.type === 'G') s = _.flatten(roll.rolls || []).map(detectCritical);
+ if (roll.type === 'R') {
+ const crit = (roll.mods && roll.mods.customCrit) || [
+ {
+ comp: '==',
+ point: roll.sides || 0,
+ },
+ ];
+ const fumble = (roll.mods && roll.mods.customFumble) || [
+ {
+ comp: '==',
+ point: 1,
+ },
+ ];
+ if (roll.results.some((r) => rollIsCrit(r.v, crit[0].comp, crit[0].point))) s.push('crit');
+ if (roll.results.some((r) => rollIsCrit(r.v, fumble[0].comp, fumble[0].point))) s.push('fumble');
+ }
+ const c = s.includes('crit'),
+ f = s.includes('fumble'),
+ m = s.includes('mixed') || (c && f);
+ return m ? 'mixed' : c ? 'crit' : f ? 'fumble' : false;
+ };
+ /**
+ * Compares one die value against a crit/fumble threshold.
+ *
+ * @param {number} value - Die result.
+ * @param {string} comp - Comparison operator: '==', '<=' or '>='.
+ * @param {number} point - Threshold value.
+ * @returns {boolean|undefined} Result of the comparison; undefined for an unknown operator.
+ */
+ const rollIsCrit = (value, comp, point) => {
+ switch (comp) {
+ case '==':
+ return value == point;
+ case '<=':
+ return value <= point;
+ case '>=':
+ return value >= point;
+ }
+ };
+
+ // ————— BASIC TYPES —————
+ /**
+ * Constructor for a simple 2D point. Use with new.
+ *
+ * @param {number} x - X coordinate.
+ * @param {number} y - Y coordinate.
+ */
+ const pt = function (x, y) {
+ this.x = x;
+ this.y = y;
+ };
+
+ /**
+ * Normalises a Roll20 library image URL to its 'thumb' size so the API can use it in createObj. Adds a random
+ * cache-busting query string if the URL has none.
+ *
+ * @param {string} imgsrc - Image URL.
+ * @returns {string|undefined} The cleaned thumb URL, or undefined if the URL is not a Roll20 library image.
+ */
+ const getCleanImgsrc = function (imgsrc) {
+ const parts = imgsrc.match(/(.*\/images\/.*)(thumb|med|original|max)([^\?]*)(\?[^?]+)?$/);
+ if (parts) {
+ return parts[1] + 'thumb' + parts[3] + (parts[4] ? parts[4] : `?${Math.round(Math.random() * 9999999)}`);
+ }
+ return;
+ };
+
+ // ————— INSTALL —————
+ /**
+ * Abilities given to the AoEControlToken character SmartAoE creates on first run: [name, macro action, token
+ * action?]. The AoEGenerator macro links to the AoE ones by name.
+ */
+ const CONTROL_TOKEN_ABILITIES = [
+ ['CW', '!smartrotateorigin cw', true],
+ ['CCW', '!smartrotateorigin ccw', true],
+ ['Trigger-ALL', '!smarttrigger', true],
+ ['Trigger-Target', '!smartquery @{target|Choose a Target|token_id}', true],
+ //Square, float
+ ['5ft-Square', '!smartaoe {{\n --aoeType|square, float\n --radius|2.5ft\n --fx|burn-fire\n}}', false],
+ ['10ft-Square', '!smartaoe {{\n --aoeType|square, float\n --radius|5ft\n --fx|burn-fire\n}}', false],
+ ['20ft-Square', '!smartaoe {{\n --aoeType|square, float\n --radius|10ft\n --fx|burn-fire\n}}', false],
+ ['30ft-Square', '!smartaoe {{\n --aoeType|square, float\n --radius|15ft\n --fx|burn-fire\n}}', false],
+ ['40ft-Square', '!smartaoe {{\n --aoeType|square, float\n --radius|20ft\n --fx|burn-fire\n}}', false],
+ ['Variable-Square', '!smartaoe {{\n --aoeType|square\n --fx|burn-fire\n}}', false],
+ //Circle, float
+ [
+ '5ft-Circle',
+ '!smartaoe {{\n --aoeType|circle, float\n --radius|2.5ft\n --minGridArea|0.5\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '10ft-Circle',
+ '!smartaoe {{\n --aoeType|circle, float\n --radius|5ft\n --minGridArea|0.5\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '20ft-Circle',
+ '!smartaoe {{\n --aoeType|circle, float\n --radius|10ft\n --minGridArea|0.5\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '30ft-Circle',
+ '!smartaoe {{\n --aoeType|circle, float\n --radius|15ft\n --minGridArea|0.5\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '40ft-Circle',
+ '!smartaoe {{\n --aoeType|circle, float\n --radius|20ft\n --minGridArea|0.5\n --fx|burn-fire\n}}',
+ false,
+ ],
+ ['Variable-Circle', '!smartaoe {{\n --aoeType|circle\n --minGridArea|0.5\n --fx|burn-fire\n}}', false],
+ //PF circle, float
+ [
+ '5ft-PF-Circle',
+ '!smartaoe {{\n --aoeType|PFcircle, float\n --radius|2.5ft\n --minGridArea|0.5\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '10ft-PF-Circle',
+ '!smartaoe {{\n --aoeType|PFcircle, float\n --radius|5ft\n --minGridArea|0.5\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '20ft-PF-Circle',
+ '!smartaoe {{\n --aoeType|PFcircle, float\n --radius|10ft\n --minGridArea|0.5\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '30ft-PF-Circle',
+ '!smartaoe {{\n --aoeType|PFcircle, float\n --radius|15ft\n --minGridArea|0.5\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '40ft-PF-Circle',
+ '!smartaoe {{\n --aoeType|PFcircle, float\n --radius|20ft\n --minGridArea|0.5\n --fx|burn-fire\n}}',
+ false,
+ ],
+ ['Variable-PF-Circle', '!smartaoe {{\n --aoeType|PFcircle\n --minGridArea|0.5\n --fx|burn-fire\n}}', false],
+ //Centered on Caster, square
+ [
+ '5ft-Square-Caster',
+ '!smartaoe {{\n --aoeType|square, float\n --controlTokName|self\n --radius|5ft\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '10ft-Square-Caster',
+ '!smartaoe {{\n --aoeType|square, float\n --controlTokName|self\n --radius|10ft\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '15ft-Square-Caster',
+ '!smartaoe {{\n --aoeType|square, float\n --controlTokName|self\n --radius|15ft\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '20ft-Square-Caster',
+ '!smartaoe {{\n --aoeType|square, float\n --controlTokName|self\n --radius|20ft\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '30ft-Square-Caster',
+ '!smartaoe {{\n --aoeType|square, float\n --controlTokName|self\n --radius|30ft\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '40ft-Square-Caster',
+ '!smartaoe {{\n --aoeType|square, float\n --controlTokName|self\n --radius|40ft\n --forceIntersection|0\n}}',
+ false,
+ ],
+ //Centered on Caster, circle
+ [
+ '5ft-Circle-Caster',
+ '!smartaoe {{\n --aoeType|circle, float\n --controlTokName|self\n --radius|5ft\n --minGridArea|0.5\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '10ft-Circle-Caster',
+ '!smartaoe {{\n --aoeType|circle, float\n --controlTokName|self\n --radius|10ft\n --minGridArea|0.5\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '15ft-Circle-Caster',
+ '!smartaoe {{\n --aoeType|circle, float\n --controlTokName|self\n --radius|15ft\n --minGridArea|0.5\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '20ft-Circle-Caster',
+ '!smartaoe {{\n --aoeType|circle, float\n --controlTokName|self\n --radius|20ft\n --minGridArea|0.5\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '30ft-Circle-Caster',
+ '!smartaoe {{\n --aoeType|circle, float\n --controlTokName|self\n --radius|30ft\n --minGridArea|0.5\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '40ft-Circle-Caster',
+ '!smartaoe {{\n --aoeType|circle, float\n --controlTokName|self\n --radius|40ft\n --minGridArea|0.5\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ //Centered on Caster, PF circle
+ [
+ '5ft-PF-Circle-Caster',
+ '!smartaoe {{\n --aoeType|PFcircle, float\n --controlTokName|self\n --radius|5ft\n --minGridArea|0.5\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '10ft-PF-Circle-Caster',
+ '!smartaoe {{\n --aoeType|PFcircle, float\n --controlTokName|self\n --radius|10ft\n --minGridArea|0.5\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '15ft-PF-Circle-Caster',
+ '!smartaoe {{\n --aoeType|PFcircle, float\n --controlTokName|self\n --radius|15ft\n --minGridArea|0.5\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '20ft-PF-Circle-Caster',
+ '!smartaoe {{\n --aoeType|PFcircle, float\n --controlTokName|self\n --radius|20ft\n --minGridArea|0.5\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '30ft-PF-Circle-Caster',
+ '!smartaoe {{\n --aoeType|PFcircle, float\n --controlTokName|self\n --radius|30ft\n --minGridArea|0.5\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '40ft-PF-Circle-Caster',
+ '!smartaoe {{\n --aoeType|PFcircle, float\n --controlTokName|self\n --radius|40ft\n --minGridArea|0.5\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ //Lines
+ ['30ft-Line', '!smartaoe {{\n --aoeType|line\n --radius|30ft\n --fx|burn-fire\n}}', false],
+ ['60ft-Line', '!smartaoe {{\n --aoeType|line\n --radius|60ft\n --fx|burn-fire\n}}', false],
+ ['100ft-Line', '!smartaoe {{\n --aoeType|line\n --radius|100ft\n --fx|burn-fire\n}}', false],
+ ['Variable-Line', '!smartaoe {{\n --aoeType|line\n --fx|burn-fire\n}}', false],
+ //5e cones
+ [
+ '15ft-5eCone',
+ '!smartaoe {{\n --aoeType|5econe\n --radius|15ft\n --origin|nearest, face\n --minGridArea|0.25\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '30ft-5eCone',
+ '!smartaoe {{\n --aoeType|5econe\n --radius|30ft\n --origin|nearest, face\n --minGridArea|0.25\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '60ft-5eCone',
+ '!smartaoe {{\n --aoeType|5econe\n --radius|60ft\n --origin|nearest, face\n --minGridArea|0.25\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ 'Variable-5eCone',
+ '!smartaoe {{\n --aoeType|5econe\n --origin|nearest,face\n --minGridArea|0.25\n --fx|burn-fire\n}}',
+ false,
+ ],
+ //PF cones
+ [
+ '15ft-PFCone',
+ '!smartaoe {{\n --aoeType|PFcone\n --radius|15ft\n --origin|nearest, face\n --forceIntersection|0\n --minGridArea|0.50\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '30ft-PFCone',
+ '!smartaoe {{\n --aoeType|PFcone\n --radius|30ft\n --origin|nearest\n --forceIntersection|1\n --minGridArea|0.50\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '60ft-PFCone',
+ '!smartaoe {{\n --aoeType|PFcone\n --radius|60ft\n --origin|nearest\n --forceIntersection|1\n --minGridArea|0.50\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ 'Variable-PFCone',
+ '!smartaoe {{\n --aoeType|PFcone\n --origin|nearest\n --forceIntersection|1\n --minGridArea|0.50\n --fx|burn-fire\n}}',
+ false,
+ ],
+ //Normal cones, user enters cone angle
+ [
+ '15ft-Cone',
+ '!smartaoe {{\n --aoeType|cone, ?{Enter cone angle|90}\n --radius|15ft\n --minGridArea|0.50\n --origin|nearest, face\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '30ft-Cone',
+ '!smartaoe {{\n --aoeType|cone, ?{Enter cone angle|90}\n --radius|30ft\n --minGridArea|0.50\n --origin|nearest, face\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ '60ft-Cone',
+ '!smartaoe {{\n --aoeType|cone, ?{Enter cone angle|90}\n --radius|60ft\n --minGridArea|0.50\n --origin|nearest, face\n --forceIntersection|0\n --fx|burn-fire\n}}',
+ false,
+ ],
+ [
+ 'Variable-Cone',
+ '!smartaoe {{\n --aoeType|cone, ?{Enter cone angle|90}\n --minGridArea|0.50\n --origin|nearest, face\n --forceIntersection|1\n --fx|burn-fire\n}}',
+ false,
+ ],
+ ];
+
+ /**
+ * Runs on startup: logs the version, creates or resets state[scriptName] when the schema version changes, and sets
+ * up the default helpers.
+ * If missing, creates the 'AoEControlToken' character (reticle default token plus CW/CCW/Trigger token actions and
+ * many preset AoE abilities) and the GM 'AoEGenerator' collections macro, and whispers the GM about what was
+ * created. Errors are logged.
+ *
+ * @returns {Promise} Resolves when setup is complete.
+ */
+ async function checkInstall() {
+ log(scriptName + ' v' + version + ' initialized.');
+
+ //delete state[scriptName];
+
+ if (!_.has(state, scriptName) || state[scriptName].version !== schemaVersion) {
+ log(' > Updating Schema to v' + schemaVersion + ' <');
+ switch (state[scriptName] && state[scriptName].version) {
+ case 0.1:
+ /* falls through */
+ case 'UpdateSchemaVersion':
+ state[scriptName].version = schemaVersion;
+ break;
+
+ default:
+ resetLinks();
+ break;
+ }
+ }
+
+ //----------------------------------------------------------------------------------
+ // Check for existence of the default control token. Create it if it doesn't exist
+ // Also creates multiple abilities/token actions, plus a collections macro for calling up an AoE Generator chat menu
+ //----------------------------------------------------------------------------------
+ //declared outside the try block so the catch handler can name them (it threw a ReferenceError and hid the real error)
+ const charName = 'AoEControlToken';
+ const macroName = 'AoEGenerator';
+ try {
+ const URL = 'https://s3.amazonaws.com/files.d20.io/images/219939338/Q-RiO5B4NhyaPGwc1YMz9w/max.png?1620037815'; //reticle image
+
+ const controlTokenChars =
+ findObjs(
+ {
+ _type: 'character',
+ name: charName,
+ },
+ { caseInsensitive: true },
+ ) || [];
+
+ if (controlTokenChars.length === 0) {
+ log(`====> From ${scriptName}: The default ${charName} character does not exist! Creating now.`);
+
+ const charObj = {
+ avatar: URL,
+ name: charName,
+ inplayerjournals: 'all',
+ controlledby: 'all',
+ };
+ const newChar = createObj('character', charObj);
+ if (newChar) {
+ const charID = newChar.get('_id');
+ const pageID = Campaign().get('playerpageid');
+
+ //create/set the default token for the control token character
+ const tokObj = {
+ imgsrc: getCleanImgsrc(URL),
+ represents: charID,
+ name: charName,
+ has_bright_light_vision: true, //UDL
+ light_hassight: true, //LDL
+ width: 70,
+ height: 70,
+ left: 100,
+ top: 100,
+ layer: 'objects',
+ pageid: pageID,
+ };
+
+ const newTok = createObj('graphic', tokObj);
+ if (newTok) {
+ setDefaultTokenForCharacter(newChar, newTok);
+ newTok.remove();
+ }
+
+ //create common abilities for the new character
+ CONTROL_TOKEN_ABILITIES.forEach(([name, action, istokenaction]) => {
+ createObj('ability', { characterid: charID, name, action, istokenaction });
+ });
+
+ log(`====> From ${scriptName}: Character (${charName}) was created.`);
+ sendChat(
+ scriptName,
+ `/w gm \`\`SmartAoE has generated character (${charName}) with default token and abilities, for use as a control token.\`\``,
+ );
+ }
+ }
+
+ const menuMacros =
+ findObjs(
+ {
+ _type: 'macro',
+ name: macroName,
+ },
+ { caseInsensitive: true },
+ ) || [];
+
+ if (menuMacros.length === 0) {
+ log(`====> From ${scriptName}: Creating collections macro: ${macroName}`);
+
+ const arrow = `🏹`;
+ const explosion = `💥`;
+
+ const actionText =
+ `/w gm &{template:default}{{name=AoE Generator}} {{${arrow}${arrow} ***CAST AT RANGE*** ${arrow}${arrow} \n` +
+ `**_____Square / Cube______** \n` +
+ `[5ft](~AoEControlToken|5ft-Square) [10ft](~AoEControlToken|10ft-Square) [20ft](~AoEControlToken|20ft-Square) [30ft](~AoEControlToken|30ft-Square) [40ft](~AoEControlToken|40ft-Square)\n` +
+ `**_____Circle / Sphere______** \n` +
+ `[5ft](~AoEControlToken|5ft-Circle) [10ft](~AoEControlToken|10ft-Circle) [20ft](~AoEControlToken|20ft-Circle) [30ft](~AoEControlToken|30ft-Circle) [40ft](~AoEControlToken|40ft-Circle)\n` +
+ `**____PF Circle / Sphere____** \n` +
+ `[5ft](~AoEControlToken|5ft-PF-Circle) [10ft](~AoEControlToken|10ft-PF-Circle) [20ft](~AoEControlToken|20ft-PF-Circle) [30ft](~AoEControlToken|30ft-PF-Circle) [40ft](~AoEControlToken|40ft-PF-Circle)\n` +
+ `------------------------------------------\n` +
+ `((${explosion})) ***FROM CASTER*** ((${explosion})) \n` +
+ `**_____Square / Cube______** \n` +
+ `[5ft](~AoEControlToken|5ft-Square-Caster) [10ft](~AoEControlToken|10ft-Square-Caster) [15ft](~AoEControlToken|15ft-Square-Caster) [20ft](~AoEControlToken|20ft-Square-Caster) [30ft](~AoEControlToken|30ft-Square-Caster)\n` +
+ `[40ft](~AoEControlToken|40ft-Square-Caster) [Variable](~AoEControlToken|Variable-Square) \n` +
+ `**_____Circle / Sphere______** \n` +
+ `[5ft](~AoEControlToken|5ft-Circle-Caster) [10ft](~AoEControlToken|10ft-Circle-Caster) [15ft](~AoEControlToken|15ft-Circle-Caster) [20ft](~AoEControlToken|20ft-Circle-Caster) [30ft](~AoEControlToken|30ft-Circle-Caster)\n` +
+ `[40ft](~AoEControlToken|40ft-Circle-Caster) [Variable](~AoEControlToken|Variable-Circle) \n` +
+ `**____PF Circle / Sphere____** \n` +
+ `[5ft](~AoEControlToken|5ft-PF-Circle-Caster) [10ft](~AoEControlToken|10ft-PF-Circle-Caster) [15ft](~AoEControlToken|15ft-PF-Circle-Caster) [20ft](~AoEControlToken|20ft-PF-Circle-Caster) [30ft](~AoEControlToken|30ft-PF-Circle-Caster)\n` +
+ `[40ft](~AoEControlToken|40ft-PF-Circle-Caster) [Variable](~AoEControlToken|Variable-PF-Circle) \n` +
+ `**__________Line__________** \n` +
+ `[30ft](~AoEControlToken|30ft-Line) [60ft](~AoEControlToken|60ft-Line) [100ft](~AoEControlToken|100ft-Line) [Variable](~AoEControlToken|Variable-Line)\n` +
+ `**_______Cone (Query)______**\n` +
+ `[15ft](~AoEControlToken|15ft-Cone) [30ft](~AoEControlToken|30ft-Cone) [60ft](~AoEControlToken|60ft-Cone) [Variable](~AoEControlToken|Variable-Cone)\n` +
+ `**_________5e-Cone________**\n` +
+ `[15ft](~AoEControlToken|15ft-5eCone) [30ft](~AoEControlToken|30ft-5eCone) [60ft](~AoEControlToken|60ft-5eCone) [Variable](~AoEControlToken|Variable-5eCone)\n` +
+ `**_________PF-Cone________**\n` +
+ `[15ft](~AoEControlToken|15ft-PFCone) [30ft](~AoEControlToken|30ft-PFCone) [60ft](~AoEControlToken|60ft-PFCone) [Variable](~AoEControlToken|Variable-PFCone)\n` +
+ `}}`;
+
+ const players = findObjs({ _type: 'player' }).filter((p) => playerIsGM(p.get('_id')));
+ if (players.length === 0) {
+ log(`====> From ${scriptName}: No GM found, so the collections macro (${macroName}) was not created.`);
+ return;
+ }
+ const gmID = players[0].get('_id');
+
+ const macro = createObj('macro', { playerid: gmID, name: macroName, visibleto: gmID, action: actionText });
+
+ if (macro) {
+ log(`====> From ${scriptName}: Collections macro (${macroName}) was created.`);
+ sendChat(
+ scriptName,
+ `/w gm \`\`SmartAoE has created a collections macro named (${macroName}). You may want to trim down the options depending on your game system of choice. \`\``,
+ );
+ } else {
+ log(`====> From ${scriptName}: Failed to create collections macro (${macroName}).`);
+ sendChat(scriptName, `/w gm \`\`SmartAoE failed to create a collections macro named (${macroName}) \`\``);
+ }
+ }
+ } catch (err) {
+ log(`Error creating character ${charName} and/or macro ${macroName}: ${err.message}`);
+ }
+ }
+
+ // ————— AOE LINKS (STATE) —————
+ /**
+ * Expands a hex colour (#rgb, #rgba, #rrggbb or #rrggbbaa) to the full 8-digit #rrggbbaa form.
+ *
+ * @param {string} htmlstring - Hex colour string, with or without '#'.
+ * @param {string} [defaultAlpha='ff'] - Alpha to append when the input has none.
+ * @returns {string} Colour in '#rrggbbaa' form (other lengths are returned unchanged with '#').
+ */
+ const toFullColor = function (htmlstring, defaultAlpha = 'ff') {
+ let s = htmlstring.toLowerCase().replace(/[^0-9a-f]/g, ''); //strip every non-hex character (was the first only)
+ switch (s.length) {
+ case 3:
+ s = `${s[0]}${s[0]}${s[1]}${s[1]}${s[2]}${s[2]}${defaultAlpha}`;
+ break;
+ case 4:
+ s = `${s[0]}${s[0]}${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`;
+ break;
+ case 6:
+ s = `${s}${defaultAlpha}`;
+ break;
+ }
+ return `#${s}`;
+ };
+
+ /**
+ * Empties SmartAoE's state: every AoE in the campaign is unlinked (paths and control tokens are left on the board).
+ * Used on first install, when the stored schema is unknown, and by !smartclearcache.
+ */
+ const resetLinks = function () {
+ state[scriptName] = {
+ version: schemaVersion,
+ links: [],
+ };
+ };
+
+ /**
+ * Writes recalculated path ids, origin index and bounding box back to an existing AoE link in state.
+ *
+ * @param {number} linkIndex - Index of the link in state[scriptName].links.
+ * @param {string[]} pathIDs - Ids of the AoE's current Roll20 paths (copied).
+ * @param {number} originIndex - Index into the link's originPts of the active origin.
+ * @param {pt[]} boundingBox - Corner points [UL, UR, LR, LL] of the affected grid area.
+ */
+ const updateAoELink = function (linkIndex, pathIDs, originIndex, boundingBox) {
+ //clear old paths from memory
+
+ state[scriptName].links[linkIndex].pathIDs = pathIDs.map((x) => x);
+ state[scriptName].links[linkIndex].originIndex = originIndex;
+ state[scriptName].links[linkIndex].boundingBox = boundingBox;
+ };
+
+ /**
+ * Creates a new AoE link record from all of the parsed !smartaoe options and saves it to state[scriptName].links.
+ * Also records the zero-HP markers via rememberZeroHPMarkers.
+ * Each parameter is copied to the link property of the same name; only those whose meaning is not obvious are listed.
+ *
+ * @param {string} controlTokName - Name of the control token.
+ * @param {string} controlTokID - Id of the control token that moves the AoE.
+ * @param {string} aoeType - AoE shape (e.g. 'square', 'circle', 'PFcircle', 'line', 'cone', '5econe', 'PFcone').
+ * @param {pt} originPt - Starting origin point, stored as originPts[0].
+ * @param {string} originTokID - Id of the caster/origin token.
+ * @param {string} pathID - Id of the AoE's first drawn path (the link keeps a list of path ids).
+ * @param {string} pageID - Page the AoE is on.
+ * @param {object} cardParameters - Chat card style settings for the results card.
+ * @param {...*} rest - The remaining options (save, damage, resistance, condition, sound, whisper, layer and caster
+ * settings) stored unchanged.
+ * @returns {object} The new link object.
+ */
+ const makeAoELink = function (
+ controlTokName,
+ controlTokID,
+ aoeType,
+ coneWidth,
+ aoeFloat,
+ instant,
+ forceIntersection,
+ aoeColor,
+ aoeOutlineColor,
+ gridColor,
+ radius,
+ wallWidth,
+ originType,
+ originPt,
+ minGridArea,
+ minTokArea,
+ originTokID,
+ pathID,
+ pageID,
+ fxType,
+ saveFormula,
+ saveName,
+ ignoreAttr,
+ ignoreVal,
+ DC,
+ noSave,
+ damageBar,
+ autoApply,
+ damageFormula1,
+ damageFormula2,
+ damageBase1,
+ damageBase2,
+ damageType1,
+ damageType2,
+ rollDamage1,
+ rollDamage2,
+ damageSaveRule,
+ resistanceRule,
+ vulnerableRule,
+ immunityRule,
+ resistAttrs,
+ vulnerableAttrs,
+ immunityAttrs,
+ conditionPass,
+ conditionFail,
+ zeroHPmarker,
+ removeAtZero,
+ hideNames,
+ spawnSound,
+ moveSound,
+ triggerSound,
+ deleteSound,
+ cardParameters,
+ whisperString,
+ whisperAll,
+ whisperResults,
+ outlineOnly,
+ aoeLayer,
+ casterCondition,
+ casterConditionOnFail,
+ affectsCaster,
+ ) {
+ const link = {
+ //tokName: state[scriptName].links.length.toString(),
+ controlTokName: controlTokName,
+ aoeType: aoeType,
+ coneWidth: coneWidth,
+ aoeFloat: aoeFloat,
+ instant: instant,
+ forceIntersection: forceIntersection,
+ aoeColor: aoeColor,
+ aoeOutlineColor: aoeOutlineColor,
+ gridColor: gridColor,
+ radius: radius,
+ wallWidth: wallWidth,
+ fxType: fxType,
+ minGridArea: minGridArea,
+ minTokArea: minTokArea,
+ pageID: pageID,
+ originTokID: originTokID,
+ controlTokID: controlTokID,
+ boundingBox: [], //will be array of pts [UL, UR, LR, LL], defined by the min/max x/y of the affected grid squares
+ originType: originType,
+ originPts: [originPt],
+ originIndex: 0,
+ pathIDs: [pathID],
+ saveFormula: saveFormula,
+ saveName: saveName,
+ ignoreAttr: ignoreAttr,
+ ignoreVal: ignoreVal,
+ DC: DC,
+ noSave: noSave,
+ damageBar: damageBar,
+ autoApply: autoApply,
+ damageFormula1: damageFormula1,
+ damageFormula2: damageFormula2,
+ damageBase1: damageBase1,
+ damageBase2: damageBase2,
+ damageType1: damageType1,
+ damageType2: damageType2,
+ rollDamage1: rollDamage1,
+ rollDamage2: rollDamage2,
+ damageSaveRule: damageSaveRule,
+ resistanceRule: resistanceRule,
+ vulnerableRule: vulnerableRule,
+ immunityRule: immunityRule,
+ resistAttrs: resistAttrs,
+ vulnerableAttrs: vulnerableAttrs,
+ immunityAttrs: immunityAttrs,
+ conditionPass: conditionPass,
+ conditionFail: conditionFail,
+ zeroHPmarker: zeroHPmarker,
+ removeAtZero: removeAtZero,
+ hideNames: hideNames,
+ spawnSound: spawnSound,
+ moveSound: moveSound,
+ triggerSound: triggerSound,
+ deleteSound: deleteSound,
+ cardParameters: cardParameters,
+ whisperString: whisperString,
+ whisperAll: whisperAll,
+ whisperResults: whisperResults,
+ outlineOnly: outlineOnly,
+ aoeLayer: aoeLayer,
+ casterCondition: casterCondition,
+ casterConditionOnFail: casterConditionOnFail,
+ affectsCaster: affectsCaster,
+ };
+
+ state[scriptName].links.push(link);
+ rememberZeroHPMarkers(zeroHPmarker);
+ return link;
+ };
+
+ //Return object containing arrays of {[linkObj], [indices]} from state object, given tokenID or pathID based on searchType. Returns undefined if none found.
+ //normally an AoEControlToken id is passed, but a potential target token_id can also be passed (fromTarget=true) to find all AoE's that overlap the target's square
+ /**
+ * Finds AoE links in state. Normally matches links whose control or origin token is the given id. With fromTarget,
+ * treats the id as a target token and returns the links on its page whose grid squares cover at least minTokArea of
+ * the token.
+ * Grid-square paths that no longer exist are skipped.
+ *
+ * @param {string} ID - Control/origin token id, or a target token id when fromTarget is true.
+ * @param {boolean} [fromTarget=false] - Search by area overlap with the target token instead of by id.
+ * @returns {{links:object[], indices:number[]}|undefined} Matching links and their indices in state, or undefined
+ * if none are found.
+ */
+ const getAoELinks = function (ID, fromTarget = false) {
+ const linkObjs = [];
+ const indices = [];
+
+ if (fromTarget) {
+ const tok = getObj('graphic', ID);
+ if (!tok) {
+ return undefined;
+ }
+ const tokX = tok.get('left');
+ const tokY = tok.get('top');
+ const tokWidth = tok.get('width');
+ const tokHeight = tok.get('height');
+ const tokArea = tokWidth * tokHeight;
+ const tokCorners = getCellCoords(tokX, tokY, tokWidth, tokHeight);
+ const pageID = tok.get('_pageid');
+ const page = getObj('page', pageID);
+ if (!page) {
+ return undefined;
+ }
+ const pageGridIncrement = page.get('snapping_increment');
+
+ state[scriptName].links.forEach((link, index) => {
+ if (link.pageID === pageID) {
+ let totalOverlapArea = 0;
+ (link.pathIDs || []).forEach((pid) => {
+ const path = getObj('path', pid);
+
+ //skip paths that no longer exist (deleted by hand, page copied, etc.). A stale AoE used to
+ //throw "Cannot read properties of undefined (reading 'get')" and break every Trigger-Target
+ //only check grid squares, not the outer AoE boundary path
+ if (path && path.get('width') * path.get('height') <= 4900 * pageGridIncrement) {
+ const gridSq = getCellCoords(path.get('left'), path.get('top'), path.get('width'), path.get('height'));
+ const overlapArea = calcRectangleOverlapArea(gridSq, tokCorners);
+ totalOverlapArea += overlapArea;
+ }
+ });
+ if (totalOverlapArea >= link.minTokArea * tokArea) {
+ linkObjs.push(link);
+ indices.push(index);
+ }
+ }
+ });
+ } else {
+ state[scriptName].links.forEach((link, index) => {
+ if (link.controlTokID === ID || link.originTokID === ID) {
+ linkObjs.push(link);
+ indices.push(index);
+ }
+ });
+ }
+
+ if (linkObjs.length > 0) {
+ const retVal = {
+ links: linkObjs,
+ indices: indices,
+ };
+ return retVal;
+ } else {
+ return undefined;
+ }
+ };
+
+ // ————— TOKENS & PATHS —————
+ /**
+ * Creates one or two control tokens on the objects layer from a default-token JSON, at the given position and size,
+ * optionally setting tooltip and side.
+ * Whispers an error and returns early if the image is not in a Roll20 library or the requested side is invalid.
+ *
+ * @param {string} who - Player name used in error whispers.
+ * @param {string} tokenJSON - Default token JSON of the control token character.
+ * @param {string} pageID - Page to create the token(s) on.
+ * @param {number} spawnX - Left (centre x) in pixels.
+ * @param {number} spawnY - Top (centre y) in pixels.
+ * @param {number} size - Width and height in pixels.
+ * @param {string} controlledby - Comma-separated player ids that control the token.
+ * @param {boolean} isDrawing - Whether the token is marked as a drawing.
+ * @param {number|string} currentSideNew - 1-based side to show, a non-number for a random side, or -999 to leave
+ * unchanged.
+ * @param {string} tooltip - Tooltip text; '' for none.
+ * @param {number} [numSpawns=1] - Number of tokens to create (1 or 2).
+ * @returns {Promise} The created Roll20 graphic (token) objects, or undefined on error.
+ */
+ const spawnTokenAtXY = async function (
+ who,
+ tokenJSON,
+ pageID,
+ spawnX,
+ spawnY,
+ size,
+ controlledby,
+ isDrawing,
+ currentSideNew,
+ tooltip,
+ numSpawns = 1,
+ ) {
+ let controlTok;
+ let controlTok2;
+ const controlToks = [];
+ let sidesArr;
+ const whisperString = `/w "${who}"`;
+
+ try {
+ const baseObj = JSON.parse(tokenJSON);
+
+ //set token properties
+ baseObj.pageid = pageID;
+ baseObj.left = spawnX;
+ baseObj.top = spawnY;
+ baseObj.width = size;
+ baseObj.height = size;
+ baseObj.controlledby = controlledby;
+ baseObj.isdrawing = isDrawing;
+ baseObj.layer = 'objects';
+
+ if (tooltip !== '') {
+ baseObj.tooltip = tooltip;
+ baseObj.show_tooltip = true;
+ }
+
+ baseObj.imgsrc = getCleanImgsrc(baseObj.imgsrc); //ensure that we're using the thumb.png
+
+ //image must exist in personal Roll20 image library
+ if (baseObj.imgsrc === undefined) {
+ sendChat(
+ scriptName,
+ `${whisperString} Unable to find imgsrc for default token of \(${baseObj.name}\) You must use an image file that has been uploaded to your Roll20 Library.`,
+ );
+ return;
+ }
+
+ if (baseObj.hasOwnProperty('sides')) {
+ sidesArr = baseObj['sides'].split('|');
+
+ if (currentSideNew !== -999 && sidesArr[0] !== '') {
+ //check for random side
+ if (isNaN(currentSideNew)) {
+ currentSideNew = randomInteger(sidesArr.length) - 1; // Setting to random side. currentSide is 1-based for user
+ } else {
+ currentSideNew = parseInt(currentSideNew) - 1; //currentSide is 1-based for user
+ }
+
+ //set the current side, if it exists (was "> 0 ||", which let any number through and failed on a missing side)
+ if (currentSideNew >= 0 && currentSideNew <= sidesArr.length - 1) {
+ const newSideImg = getCleanImgsrc(sidesArr[currentSideNew]); //URL of the image
+ baseObj['currentSide'] = currentSideNew;
+ baseObj['imgsrc'] = newSideImg;
+ } else {
+ sendChat(
+ scriptName,
+ `${whisperString} Error: the control token has no side ${currentSideNew + 1} (it has ${sidesArr.length}).`,
+ );
+ return; //(previously returned an unrelated shared variable)
+ }
+ }
+ }
+
+ controlTok = await createObj('graphic', baseObj);
+ controlToks.push(controlTok);
+ if (numSpawns === 2) {
+ controlTok2 = await createObj('graphic', baseObj);
+ controlToks.push(controlTok2);
+ }
+ return controlToks;
+ } catch (err) {
+ sendChat(scriptName, `${whisperString} Unhandled exception: ${err.message}`);
+ }
+ };
+
+ /**
+ * Builds the Roll20 path JSON for a D&D 5e flat cone (a triangle from the origin), adding invisible corner points
+ * so the path's bounding box stays square and centred on the origin.
+ *
+ * @param {number} rad - Cone length in pixels.
+ * @param {number} z - Extra radius so the cone's far corners fit (rad/(2*sin(atan(0.5))) - rad).
+ * @param {number} coneWidth - Cone angle in degrees.
+ * @param {number} coneDirection - Direction in degrees (0 = up, clockwise).
+ * @returns {string} Path JSON string for createObj('path').
+ */
+ const build5eCone = function (rad, z, coneWidth, coneDirection) {
+ let pointsJSON = '';
+ const deg2rad = Math.PI / 180;
+ //see above for details of what "z" is
+
+ const ptOrigin = new pt(rad + z, rad + z);
+ const ptUL = new pt(0, 0);
+ const ptUR = new pt(2 * (rad + z), 0);
+ const ptLR = new pt(2 * (rad + z), 2 * (rad + z));
+ const ptLL = new pt(0, 2 * (rad + z));
+
+ //normalize rotation to 360deg and find defining angles (converted to radians)
+
+ coneDirection = normalizeTo360deg(coneDirection);
+
+ //define "cone" angles (in degrees)
+ const th1 = deg2rad * (coneDirection - coneWidth / 2); //angle of trailing cone side
+ const th2 = deg2rad * (coneDirection + coneWidth / 2); //angle of leading cone side
+
+ //a 5e cone is defined by the orgin and two pts
+ const pt1 = get5eConePathPt(rad + z, th1);
+ const pt2 = get5eConePathPt(rad + z, th2);
+
+ //start path at the origin pt, connect to pts 1&2, then back to origin
+ pointsJSON = `[[\"M\",${ptOrigin.x},${ptOrigin.y}],[\"L\",${pt1.x},${pt1.y}],[\"L\",${pt2.x},${pt2.y}],[\"L\",${ptOrigin.x},${ptOrigin.y}],`;
+ //add "phantom" single points to path corresponding to the four corners to keep the size computations correct
+ pointsJSON =
+ pointsJSON +
+ `[\"M\",${ptUL.x},${ptUL.y}],[\"L\",${ptUL.x},${ptUL.y}],[\"M\",${ptUR.x},${ptUR.y}],[\"L\",${ptUR.x},${ptUR.y}],[\"M\",${ptLR.x},${ptLR.y}],[\"L\",${ptLR.x},${ptLR.y}],[\"M\",${ptLL.x},${ptLL.y}],[\"L\",${ptLL.x},${ptLL.y}],[\"M\",${ptUL.x},${ptUL.y}],[\"L\",${ptUL.x},${ptUL.y}]]`;
+
+ return pointsJSON;
+ };
+
+ /**
+ * Converts degrees to radians.
+ *
+ * @param {number} degrees - Angle in degrees.
+ * @returns {number} Angle in radians.
+ */
+ const degreesToRadians = function (degrees) {
+ const pi = Math.PI;
+ return degrees * (pi / 180);
+ };
+
+ //cx, cy = coordinates of the center of rotation
+ /**
+ * Rotates a point around a centre by the given angle. Changes the passed point in place and also returns it.
+ *
+ * @param {number} cX - X of the centre of rotation.
+ * @param {number} cY - Y of the centre of rotation.
+ * @param {number} angle - Rotation in radians.
+ * @param {{x:number, y:number}} p - Point to rotate (changed in place).
+ * @returns {{x:number, y:number}} The same, rotated, point.
+ */
+ const rotatePoint = function (cX, cY, angle, p) {
+ s = Math.sin(angle);
+ c = Math.cos(angle);
+
+ // translate point back to origin:
+ p.x -= cX;
+ p.y -= cY;
+
+ // rotate point
+ newX = p.x * c - p.y * s;
+ newY = p.x * s + p.y * c;
+
+ // translate point back:
+ p.x = newX + cX;
+ p.y = newY + cY;
+
+ return p;
+ };
+
+ /**
+ * Works out the geometry of a rectangular wall AoE that runs from the origin towards the control token: its rotated
+ * corners, centre, circumscribing box size, extreme points, and a slightly enlarged polygon for hit tests.
+ *
+ * @param {pt} originPtPx - Wall origin in pixels.
+ * @param {pt} controlPtPx - Control token position in pixels; sets the wall angle (and length when variable).
+ * @param {number|string} radius - Wall length in pixels, or 'variable' to use the origin-to-control distance.
+ * @param {number} wallWidth - Wall thickness in pixels.
+ * @returns {object} {width, angle, radius, pt1..pt4, pMinX, pMaxX, pMinY, pMaxY, pCenter, widthBB, heightBB,
+ * expandedPolygon}.
+ */
+ const getWallParams = function (originPtPx, controlPtPx, radius, wallWidth) {
+ let rad = radius;
+ if (radius === 'variable') {
+ rad = distBetweenPts(originPtPx, controlPtPx);
+ }
+
+ const wallAngle = getAngle2ControlToken(originPtPx, controlPtPx);
+ const wallAngleRad = degreesToRadians(wallAngle);
+
+ //get map coords of wall boundary, rotated around the originPt
+ //first, get unrotated rectangle corner and center points
+ const pt1_0 = new pt(originPtPx.x, originPtPx.y - wallWidth / 2);
+ const pt2_0 = new pt(originPtPx.x + rad, originPtPx.y - wallWidth / 2);
+ const pt3_0 = new pt(originPtPx.x + rad, originPtPx.y + wallWidth / 2);
+ const pt4_0 = new pt(originPtPx.x, originPtPx.y + wallWidth / 2);
+ const pCenter_0 = new pt(originPtPx.x + rad / 2, originPtPx.y);
+
+ //then rotate these points around the originPt
+ const pt1 = rotatePoint(originPtPx.x, originPtPx.y, wallAngleRad - (90 * Math.PI) / 180, pt1_0);
+ const pt2 = rotatePoint(originPtPx.x, originPtPx.y, wallAngleRad - (90 * Math.PI) / 180, pt2_0);
+ const pt3 = rotatePoint(originPtPx.x, originPtPx.y, wallAngleRad - (90 * Math.PI) / 180, pt3_0);
+ const pt4 = rotatePoint(originPtPx.x, originPtPx.y, wallAngleRad - (90 * Math.PI) / 180, pt4_0);
+ const pCenter = rotatePoint(originPtPx.x, originPtPx.y, wallAngleRad - (90 * Math.PI) / 180, pCenter_0);
+
+ //now, get dimensions of the rectangle circumscribing the rotated wall rectangle
+ const minX = Math.min(pt1.x, pt2.x, pt3.x, pt4.x);
+ const maxX = Math.max(pt1.x, pt2.x, pt3.x, pt4.x);
+ const minY = Math.min(pt1.y, pt2.y, pt3.y, pt4.y);
+ const maxY = Math.max(pt1.y, pt2.y, pt3.y, pt4.y);
+ const widthBB = maxX - minX;
+ const heightBB = maxY - minY;
+
+ //find pts with minX, maxX, minY, MaxY
+ const ptsArr = [pt1, pt2, pt3, pt4];
+ const pMinX = ptsArr.reduce(function (prev, curr) {
+ return prev.x < curr.x ? prev : curr;
+ });
+ const pMaxX = ptsArr.reduce(function (prev, curr) {
+ return prev.x > curr.x ? prev : curr;
+ });
+ const pMinY = ptsArr.reduce(function (prev, curr) {
+ return prev.y < curr.y ? prev : curr;
+ });
+ const pMaxY = ptsArr.reduce(function (prev, curr) {
+ return prev.y > curr.y ? prev : curr;
+ });
+
+ //here we are going to create a new polygon that is s (10px) wider on each side.
+ // This is to help overcome the floating point error issues with the isPointInPolygon function
+ const s = 10;
+ let tempPt1 = { x: pt1_0.x - s, y: pt1_0.y - s };
+ let tempPt2 = { x: pt2_0.x + s, y: pt2_0.y - s };
+ let tempPt3 = { x: pt3_0.x + s, y: pt3_0.y + s };
+ let tempPt4 = { x: pt4_0.x - s, y: pt4_0.y + s };
+ tempPt1 = rotatePoint(originPtPx.x, originPtPx.y, wallAngleRad - (90 * Math.PI) / 180, tempPt1);
+ tempPt2 = rotatePoint(originPtPx.x, originPtPx.y, wallAngleRad - (90 * Math.PI) / 180, tempPt2);
+ tempPt3 = rotatePoint(originPtPx.x, originPtPx.y, wallAngleRad - (90 * Math.PI) / 180, tempPt3);
+ tempPt4 = rotatePoint(originPtPx.x, originPtPx.y, wallAngleRad - (90 * Math.PI) / 180, tempPt4);
+ const expandedPolygon = [tempPt1, tempPt2, tempPt3, tempPt4, tempPt1];
+
+ return {
+ width: wallWidth,
+ angle: wallAngle,
+ radius: rad,
+ pt1: pt1, //the corner pts
+ pt2: pt2,
+ pt3: pt3,
+ pt4: pt4,
+ pMinX: pMinX, //corner pt with smallest X value
+ pMaxX: pMaxX, //corner pt with largest X value
+ pMinY: pMinY, //corner pt with smallest Y value
+ pMaxY: pMaxY, //corner pt with largest Y value
+ pCenter: pCenter, //the geometric center of the rotated wall
+ widthBB: widthBB, //the width of the rectangle circumscribing the wall
+ heightBB: heightBB, //the height of the rectangle circumscribing the wall
+ expandedPolygon: expandedPolygon, //a rectangle that is s (10px) wider on each side. This polygon is rotated already
+ };
+ };
+
+ //The bounding box of the path will be a square with side lengths equal to the diagonal of the wall rectangle
+ /**
+ * Builds the Roll20 path JSON for a wall: a closed rectangle through the four corner points from getWallParams.
+ *
+ * @param {object} wallParams - Result of getWallParams (uses pt1..pt4).
+ * @returns {string} Path JSON string for createObj('path').
+ */
+ const buildWallPath = function (wallParams) {
+ const pt1 = wallParams.pt1;
+ const pt2 = wallParams.pt2;
+ const pt3 = wallParams.pt3;
+ const pt4 = wallParams.pt4;
+ //Full rectangle (5 pts - wraps back around to pt1 again)
+ const wallPointsJSON = `[[\"M\",${pt1.x},${pt1.y}],[\"L\",${pt2.x},${pt2.y}],[\"L\",${pt3.x},${pt3.y}],[\"L\",${pt4.x},${pt4.y}],[\"L\",${pt1.x},${pt1.y}]]`;
+
+ return wallPointsJSON;
+ };
+
+ /**
+ * Builds the Roll20 path JSON for a closed square with sides of 2*rad, starting at the local origin (0,0).
+ *
+ * @param {number} rad - Half the side length in pixels.
+ * @returns {string} Path JSON string for createObj('path').
+ */
+ const buildSquarePath = function (rad) {
+ const ptUL = new pt(0, 0);
+ const ptUR = new pt(2 * rad, 0);
+ const ptLR = new pt(2 * rad, 2 * rad);
+ const ptLL = new pt(0, 2 * rad);
+
+ //Full square
+ const squarePointsJSON = `[[\"M\",${ptUL.x},${ptUL.y}],[\"L\",${ptUR.x},${ptUR.y}],[\"L\",${ptLR.x},${ptLR.y}],[\"L\",${ptLL.x},${ptLL.y}],[\"L\",${ptUL.x},${ptUL.y}]]`;
+
+ return squarePointsJSON;
+ };
+
+ //Circle building portion of function is modified from TheAaron's "dlcircle" script
+ /**
+ * Builds the Roll20 path JSON for a circle (coneWidth 360) or a round-ended cone of the given angle, inside a 2*rad
+ * square. Cones get invisible corner points to keep the bounding box square.
+ * Circle code is adapted from TheAaron's dlcircle script.
+ *
+ * @param {number} rad - Radius in pixels.
+ * @param {number} coneWidth - Cone angle in degrees; 360 draws a full circle.
+ * @param {number} coneDirection - Cone direction in degrees (0 = up, clockwise); ignored for circles.
+ * @returns {string} Path JSON string for createObj('path').
+ */
+ const buildCirclePath = function (rad, coneWidth, coneDirection) {
+ let circlePoints;
+ let steps, stepSize;
+ const deg2rad = Math.PI / 180;
+
+ steps = Math.min(Math.max(Math.round((Math.PI * 2 * Math.sqrt((2 * rad * rad) / 2)) / 35), 4), 20);
+
+ const at = (theta) => ({ x: Math.cos(theta) * rad, y: Math.sin(theta) * rad });
+
+ if (coneWidth === 360) {
+ //Build a full circle
+ stepSize = Math.PI / (2 * steps);
+
+ let acc = [[], [], [], []];
+ let th = 0;
+ _.times(steps + 1, () => {
+ const pt = at(th);
+ acc[0].push([pt.x, pt.y]);
+ acc[1].push([-pt.x, pt.y]);
+ acc[2].push([-pt.x, -pt.y]);
+ acc[3].push([pt.x, -pt.y]);
+ th += stepSize;
+ });
+ acc = acc[0].concat(acc[1].reverse().slice(1), acc[2].slice(1), acc[3].reverse().slice(1));
+
+ //Some js wizardry from TheAaron with the array map function. I couldn't make it work without returning the outer (1st & last) square brackets
+ //So, we will take this string, strip the last "]", then append the grid points to the path
+ circlePoints = JSON.stringify(acc.map((v, i) => [i ? 'L' : 'M', rad + v[0], rad + v[1]]));
+ circlePoints = circlePoints.substring(0, circlePoints.length - 1);
+ } else {
+ //build a cone instead
+ steps = 50;
+ stepSize = deg2rad * (coneWidth / steps);
+
+ const oX = rad;
+ const oY = rad;
+ let x, y;
+ const startAngle = deg2rad * (coneDirection - coneWidth / 2);
+ const endAngle = deg2rad * (coneDirection + coneWidth / 2);
+ const ptUL = new pt(0, 0);
+ const ptUR = new pt(2 * rad, 0);
+ const ptLR = new pt(2 * rad, 2 * rad);
+ const ptLL = new pt(0, 2 * rad);
+
+ //start path at the origin pt
+ circlePoints = `[[\"M\",${oX},${oY}],`;
+
+ //for loop takes into account cumulative floating point precision error
+ for (let th = startAngle; th < endAngle + Number.EPSILON * steps; th += stepSize) {
+ //change in "normal" polar coord conversion due to 0deg being straight up and positive Y being "down"
+ x = oX + oX * Math.sin(th);
+ y = oY + oY * Math.cos(th + deg2rad * 180);
+ circlePoints = circlePoints + `[\"L\",${x},${y}],`;
+ }
+
+ //connect back to the origin pt
+ circlePoints = circlePoints + `[\"L\",${oX},${oY}],`;
+ //add "phantom" single points to path corresponding to the four corners to keep the size computations correct
+ circlePoints =
+ circlePoints +
+ `[\"M\",${ptUL.x},${ptUL.y}],[\"L\",${ptUL.x},${ptUL.y}],[\"M\",${ptUR.x},${ptUR.y}],[\"L\",${ptUR.x},${ptUR.y}],[\"M\",${ptLR.x},${ptLR.y}],[\"L\",${ptLR.x},${ptLR.y}],[\"M\",${ptLL.x},${ptLL.y}],[\"L\",${ptLL.x},${ptLL.y}],[\"M\",${ptUL.x},${ptUL.y}],[\"L\",${ptUL.x},${ptUL.y}]`;
+ }
+
+ return circlePoints + ']';
+ };
+
+ /**
+ * Removes the Roll20 path objects with the given ids from the board, skipping any that no longer exist. Does not
+ * change the link stored in state.
+ *
+ * @param {string[]} pathIDs - Ids of the paths to remove.
+ * @returns {Promise} Resolves once all removals have been requested.
+ */
+ async function deleteLinkedPaths(pathIDs) {
+ //delete the linked paths and clear the pathIDs array from the state object
+ //uses its own variable: it used to overwrite the shared "path" that AoE creation was still using
+ pathIDs.forEach((pathID) => {
+ const oldPath = getObj('path', pathID);
+ if (oldPath) {
+ oldPath.remove();
+ }
+ });
+ }
+
+ /**
+ * Creates a Roll20 path object on the given page with the supplied geometry and styling. Side effect: adds a new
+ * path to the tabletop.
+ *
+ * @param {string} pathstring - Roll20 path data (JSON-encoded array of SVG-like commands, e.g.
+ * [["M",x,y],["L",x,y]]).
+ * @param {string} pageID - ID of the page to draw on.
+ * @param {string} layer - Layer name ('objects', 'map', 'gmlayer', 'walls').
+ * @param {string} fillColor - Fill colour (hex or 'transparent').
+ * @param {string} strokeColor - Stroke colour (hex or 'transparent').
+ * @param {number} strokeWidth - Stroke width in pixels.
+ * @param {number} height - Bounding-box height in pixels.
+ * @param {number} width - Bounding-box width in pixels.
+ * @param {number} left - X of the path centre in pixels.
+ * @param {number} top - Y of the path centre in pixels.
+ * @returns {object} The created Roll20 path object.
+ */
+ const createPath = function (
+ pathstring,
+ pageID,
+ layer,
+ fillColor,
+ strokeColor,
+ strokeWidth,
+ height,
+ width,
+ left,
+ top,
+ ) {
+ const path = createObj('path', {
+ pageid: pageID,
+ path: pathstring,
+ fill: fillColor,
+ stroke: strokeColor,
+ layer: layer,
+ stroke_width: strokeWidth,
+ width: width,
+ height: height,
+ left: left,
+ top: top,
+ });
+ return path;
+ };
+
+ // ————— GEOMETRY —————
+ /**
+ * Wraps an angle in degrees into the range [0, 360).
+ *
+ * @param {number} deg - Angle in degrees (may be negative or above 360).
+ * @returns {number} Equivalent angle in [0, 360).
+ */
+ const normalizeTo360deg = function (deg) {
+ deg = deg % 360;
+ if (deg < 0) {
+ deg += 360;
+ }
+ return deg;
+ };
+
+ /**
+ * Projects a point at a given distance and bearing from an origin, used to place a line's end point when the AoE
+ * has a fixed length. Pixel coordinates; bearing in degrees, 0 = straight up, increasing clockwise (Roll20 y grows
+ * downward).
+ *
+ * @param {pt} oPt - Origin point in pixels.
+ * @param {number} rad - Distance from the origin in pixels.
+ * @param {number} angle - Bearing in degrees; must be in [0, 360) or nothing is returned.
+ * @returns {pt|undefined} The projected point, or undefined if angle is outside [0, 360).
+ */
+ const getNewControlPt = function (oPt, rad, angle) {
+ const deg2rad = Math.PI / 180;
+ let smallAngle;
+ let retPt;
+ angle = normalizeTo360deg(angle); //angles outside 0-360 used to return undefined
+
+ if (angle === 0) {
+ retPt = new pt(oPt.x, oPt.y - rad);
+ } else if (angle > 0 && angle < 90) {
+ smallAngle = angle * deg2rad;
+ retPt = new pt(oPt.x + rad * Math.sin(smallAngle), oPt.y - rad * Math.cos(smallAngle));
+ } else if (angle === 90) {
+ retPt = new pt(oPt.x + rad, oPt.y);
+ } else if (angle > 90 && angle < 180) {
+ smallAngle = angle * deg2rad - 90 * deg2rad;
+ retPt = new pt(oPt.x + rad * Math.cos(smallAngle), oPt.y + rad * Math.sin(smallAngle));
+ } else if (angle === 180) {
+ retPt = new pt(oPt.x, oPt.y + rad);
+ } else if (angle > 180 && angle < 270) {
+ smallAngle = angle * deg2rad - 180 * deg2rad;
+ retPt = new pt(oPt.x - rad * Math.sin(smallAngle), oPt.y + rad * Math.cos(smallAngle));
+ } else if (angle === 270) {
+ retPt = new pt(oPt.x - rad, oPt.y);
+ } else if (angle > 270 && angle < 360) {
+ smallAngle = angle * deg2rad - 270 * deg2rad;
+ retPt = new pt(oPt.x - rad * Math.cos(smallAngle), oPt.y - rad * Math.sin(smallAngle));
+ }
+
+ return retPt;
+ };
+
+ /**
+ * Returns the bearing from the origin point to the control point, used as the AoE direction. Degrees, 0 = straight
+ * up, increasing clockwise (Roll20 y grows downward).
+ *
+ * @param {{x:number, y:number}} oPt - Origin point in pixels.
+ * @param {{x:number, y:number}} cPt - Control point in pixels.
+ * @returns {number} Bearing in degrees in [0, 360); NaN if the two points coincide.
+ */
+ const getAngle2ControlToken = function (oPt, cPt) {
+ const deg2rad = Math.PI / 180;
+ let cAngle; //return value (angle from originPt to controlPt)
+
+ if (cPt.x === oPt.x && cPt.y === oPt.y) {
+ return 0; //same point: no direction, treat as straight up (was NaN)
+ }
+
+ const dX = Math.abs(cPt.x - oPt.x);
+ const dY = Math.abs(cPt.y - oPt.y);
+ const smallAngle = Math.atan(dY / dX); //this does not take into account the quadrant in which the angle lies. More tests req'd to determine correct relative angle
+
+ if (cPt.x < oPt.x && cPt.y < oPt.y) {
+ //UL quadrant
+ cAngle = 270 * deg2rad + smallAngle;
+ } else if (cPt.x > oPt.x && cPt.y < oPt.y) {
+ //UR quadrant
+ cAngle = 90 * deg2rad - smallAngle;
+ } else if (cPt.x > oPt.x && cPt.y > oPt.y) {
+ //LR quadrant
+ cAngle = 90 * deg2rad + smallAngle;
+ } else if (cPt.x < oPt.x && cPt.y > oPt.y) {
+ //LL quadrant
+ cAngle = 270 * deg2rad - smallAngle;
+ } else if (cPt.x === oPt.x && cPt.y < oPt.y) {
+ //straight up
+ cAngle = 0 * deg2rad;
+ } else if (cPt.x > oPt.x && cPt.y === oPt.y) {
+ //straight right
+ cAngle = 90 * deg2rad;
+ } else if (cPt.x === oPt.x && cPt.y > oPt.y) {
+ //straight down
+ cAngle = 180 * deg2rad;
+ } else if (cPt.x < oPt.x && cPt.y === oPt.y) {
+ //straight left
+ cAngle = 270 * deg2rad;
+ }
+
+ return cAngle / deg2rad; //angle expressed in degrees
+ };
+
+ /**
+ * Snaps a pixel point to the centre of the grid cell that contains it and expresses that centre in grid units (cell
+ * index minus 0.5, so the first cell's centre is 0.5). A cell is 70 * pageGridIncrement pixels wide.
+ *
+ * @param {{x:number, y:number}} Pt - Point in pixels.
+ * @param {number} pageGridIncrement - Page snapping_increment (cells per 70px unit).
+ * @returns {pt} Centre of the containing cell, in grid units.
+ */
+ const convertPtPixels2Units = function (Pt, pageGridIncrement) {
+ const closestCellX = Math.ceil(Pt.x / (70 * pageGridIncrement)) * 70 * pageGridIncrement - 35 * pageGridIncrement;
+ const closestCellY = Math.ceil(Pt.y / (70 * pageGridIncrement)) * 70 * pageGridIncrement - 35 * pageGridIncrement;
+
+ const newX = closestCellX / (70 * pageGridIncrement);
+ const newY = closestCellY / (70 * pageGridIncrement);
+
+ const PtInUnits = new pt(newX, newY);
+ return PtInUnits;
+ };
+
+ /**
+ * Converts parallel arrays of x and y coordinates from grid units to pixels (multiplies by 70 * pageGridIncrement).
+ *
+ * @param {{xVals:number[], yVals:number[]}} obj - Coordinates in grid units.
+ * @param {number} pageGridIncrement - Page snapping_increment.
+ * @returns {{xVals:number[], yVals:number[]}} New object with coordinates in pixels.
+ */
+ const convertLocationsArrUnits2Pixels = function (obj, pageGridIncrement) {
+ const newX = obj.xVals.map((val) => val * 70 * pageGridIncrement);
+ const newY = obj.yVals.map((val) => val * 70 * pageGridIncrement);
+ return { xVals: newX, yVals: newY };
+ };
+
+ /**
+ * Rasterises a line AoE onto the grid using Bresenham's algorithm, stepping one cell at a time between the two end
+ * cells, then converts the result to pixels. Inputs are in grid units (as returned by convertPtPixels2Units) so the
+ * output is the pixel centre of each covered cell.
+ *
+ * @param {number} x1 - Start x in grid units.
+ * @param {number} y1 - Start y in grid units.
+ * @param {number} x2 - End x in grid units.
+ * @param {number} y2 - End y in grid units.
+ * @param {number} pageGridIncrement - Page snapping_increment.
+ * @returns {{xVals:number[], yVals:number[]}} Pixel centres of the cells the line passes through.
+ */
+ const getLineLocations = function (x1, y1, x2, y2, pageGridIncrement) {
+ let lineCoords = {
+ xVals: [],
+ yVals: [],
+ };
+ // Iterators, counters required by algorithm
+ let x, y, dx, dy, dx1, dy1, px, py, xe, ye, i;
+ // Calculate line deltas
+ dx = x2 - x1;
+ dy = y2 - y1;
+ // Create a positive copy of deltas (makes iterating easier)
+ dx1 = Math.abs(dx);
+ dy1 = Math.abs(dy);
+ // Calculate error intervals for both axis
+ px = 2 * dy1 - dx1;
+ py = 2 * dx1 - dy1;
+ // The line is X-axis dominant
+ if (dy1 <= dx1) {
+ // Line is drawn left to right
+ if (dx >= 0) {
+ x = x1;
+ y = y1;
+ xe = x2;
+ } else {
+ // Line is drawn right to left (swap ends)
+ x = x2;
+ y = y2;
+ xe = x1;
+ }
+ //pixel(x, y); // Draw first pixel
+ lineCoords.xVals.push(x);
+ lineCoords.yVals.push(y);
+ // Rasterize the line
+ for (i = 0; x < xe; i++) {
+ x = x + 1;
+ // Deal with octants...
+ if (px < 0) {
+ px = px + 2 * dy1;
+ } else {
+ if ((dx < 0 && dy < 0) || (dx > 0 && dy > 0)) {
+ y = y + 1;
+ } else {
+ y = y - 1;
+ }
+ px = px + 2 * (dy1 - dx1);
+ }
+ // Draw pixel from line span at
+ // currently rasterized position
+ lineCoords.xVals.push(x);
+ lineCoords.yVals.push(y);
+ }
+ } else {
+ // The line is Y-axis dominant
+ // Line is drawn bottom to top
+ if (dy >= 0) {
+ x = x1;
+ y = y1;
+ ye = y2;
+ } else {
+ // Line is drawn top to bottom
+ x = x2;
+ y = y2;
+ ye = y1;
+ }
+ //pixel(x, y); // Draw first pixel
+ lineCoords.xVals.push(x);
+ lineCoords.yVals.push(y);
+
+ // Rasterize the line
+ for (i = 0; y < ye; i++) {
+ y = y + 1;
+ // Deal with octants...
+ if (py <= 0) {
+ py = py + 2 * dx1;
+ } else {
+ if ((dx < 0 && dy < 0) || (dx > 0 && dy > 0)) {
+ x = x + 1;
+ } else {
+ x = x - 1;
+ }
+ py = py + 2 * (dx1 - dy1);
+ }
+ // Draw pixel from line span at
+ // currently rasterized position
+ lineCoords.xVals.push(x);
+ lineCoords.yVals.push(y);
+ }
+ }
+
+ lineCoords = convertLocationsArrUnits2Pixels(lineCoords, pageGridIncrement);
+
+ return lineCoords;
+ };
+
+ /**
+ * Distance between two points in pixels. By default this is straight-line (Euclidean); with calcType 'PF' it
+ * applies Pathfinder diagonal rules (every other diagonal counts 1.5 squares), rounded down to whole scale units
+ * and converted back to pixels.
+ *
+ * @param {{x:number, y:number}} pt1 - First point in pixels.
+ * @param {{x:number, y:number}} pt2 - Second point in pixels.
+ * @param {string} [calcType='Euclidean'] - 'Euclidean' or 'PF'.
+ * @param {number} [gridIncrement=-999] - Page snapping_increment; required for 'PF'.
+ * @param {number} [scaleNumber=-999] - Page scale_number (units per cell); required for 'PF'.
+ * @returns {number} Distance in pixels.
+ */
+ const distBetweenPts = function (pt1, pt2, calcType = 'Euclidean', gridIncrement = -999, scaleNumber = -999) {
+ let distPx; //distance in Pixels
+ let distUnits; //distance in units (gridded maps only)
+ if (calcType === 'PF' && gridIncrement !== -999 && scaleNumber !== -999) {
+ //using 'Pathfinder' distance rules, where every other diagonal unit counts as 1.5 units.
+ //..or using 5e diagonal rules where each diag only counts 1 square.
+ //..5e is special due to how t is constructed. We use Euclidean distance to determine if in cone, but we can display in 5e units.
+ //Only compatible with gridded maps
+ //convert from pixels to units, do the funky pathfinder math, then convert back to pixels
+ const dX = (Math.abs(pt1.x - pt2.x) * scaleNumber) / 70 / gridIncrement;
+ const dY = (Math.abs(pt1.y - pt2.y) * scaleNumber) / 70 / gridIncrement;
+ const maxDelta = Math.max(dX, dY);
+ const minDelta = Math.min(dX, dY);
+ let minFloor1pt5Delta;
+ if (calcType === 'PF') {
+ //every other diagonal counts as 1.5 squares
+ minFloor1pt5Delta = Math.floor(1.5 * minDelta);
+ }
+
+ //convert dist back to pixels
+ distUnits = Math.floor((maxDelta - minDelta + minFloor1pt5Delta) / scaleNumber) * scaleNumber;
+ distPx = (distUnits * 70 * gridIncrement) / scaleNumber;
+ //floor( ( maxDelta-minDelta + minFloor1pt5Delta ) /5 )*5
+
+ // [[ floor((([[{abs((@{position-x}-@{target|Target|position-x})*5/70),abs((@{position-y}-@{target|Target|position-y})*5/70)}kh1]]-
+ // [[{abs((@{position-x}-@{target|Target|position-x})*5/70),abs((@{position-y}-@{target|Target|position-y})*5/70)}kl1]])+
+ // floor(1.5*([[{abs((@{position-x}-@{target|Target|position-x})*5/70),abs((@{position-y}-@{target|Target|position-y})*5/70)}kl1]])))/5)*5]]
+ } else {
+ //default Pythagorean theorem
+ distPx = Math.sqrt(Math.pow(pt1.x - pt2.x, 2) + Math.pow(pt1.y - pt2.y, 2));
+ }
+
+ return distPx;
+ };
+
+ /**
+ * Calculates the area of a simple polygon with the shoelace formula. Vertices must already be ordered (clockwise or
+ * anticlockwise); the polygon is closed automatically.
+ *
+ * @param {pt[]} vertices - Ordered polygon vertices.
+ * @returns {number} Absolute area in square pixels (or the squared units of the input).
+ */
+ function calcPolygonArea(vertices) {
+ //vertices is an array of pts
+ //NOTE: vertices must be in clockwise or counter-clockwise order for this to work!!!
+ let total = 0;
+
+ for (let i = 0, l = vertices.length; i < l; i++) {
+ const addX = vertices[i].x;
+ const addY = vertices[i == vertices.length - 1 ? 0 : i + 1].y;
+ const subX = vertices[i == vertices.length - 1 ? 0 : i + 1].x;
+ const subY = vertices[i].y;
+
+ total += addX * addY * 0.5;
+ total -= subX * subY * 0.5;
+ }
+
+ return Math.abs(total);
+ }
+
+ /**
+ * Calculates the overlap area of two axis-aligned rectangles, used to work out how much of a token a grid-cell path
+ * covers.
+ *
+ * @param {pt[]} rect1 - Corners in the order [UL, UR, LR, LL] (as from getCellCoords).
+ * @param {pt[]} rect2 - Corners in the same order.
+ * @returns {number} Overlap area in square pixels, 0 if they do not overlap.
+ */
+ const calcRectangleOverlapArea = function (rect1, rect2) {
+ //rect 1&2 are arrays of pts [UL, UR, LR, LL], in that order!
+ const x_overlap = Math.max(0, Math.min(rect1[1].x, rect2[1].x) - Math.max(rect1[0].x, rect2[0].x));
+ const y_overlap = Math.max(0, Math.min(rect1[3].y, rect2[3].y) - Math.max(rect1[0].y, rect2[0].y));
+ return x_overlap * y_overlap;
+ };
+
+ /**
+ * Tests whether two axis-aligned rectangles overlap or touch. Pixel coordinates, y grows downward.
+ *
+ * @param {{x:number, y:number}} topLeft1 - Upper-left corner of the first rectangle.
+ * @param {{x:number, y:number}} bottomRight1 - Lower-right corner of the first rectangle.
+ * @param {{x:number, y:number}} topLeft2 - Upper-left corner of the second rectangle.
+ * @param {{x:number, y:number}} bottomRight2 - Lower-right corner of the second rectangle.
+ * @returns {boolean} True if the rectangles overlap or share an edge.
+ */
+ const rectanglesOverlap = function (topLeft1, bottomRight1, topLeft2, bottomRight2) {
+ if (topLeft1.x > bottomRight2.x || topLeft2.x > bottomRight1.x) {
+ return false;
+ }
+ if (topLeft1.y > bottomRight2.y || topLeft2.y > bottomRight1.y) {
+ return false;
+ }
+ return true;
+ };
+
+ //adapted from https://stackoverflow.com/posts/63436180/revisions
+ /**
+ * Tests whether a point lies inside a polygon using ray casting. Points on a vertex or an edge count as inside.
+ * Adapted from https://stackoverflow.com/posts/63436180/revisions.
+ *
+ * @param {{x:number, y:number}} P - Point to test.
+ * @param {{x:number, y:number}[]} polygon - Polygon vertices in order (closing vertex optional).
+ * @returns {boolean} True if inside or on the boundary.
+ */
+ const isPointInPolygon = function (P, polygon) {
+ const between = (p, a, b) => (p >= a && p <= b) || (p <= a && p >= b);
+ let inside = false;
+ for (let i = polygon.length - 1, j = 0; j < polygon.length; i = j, j++) {
+ const A = polygon[i];
+ const B = polygon[j];
+ // corner cases
+ if ((P.x == A.x && P.y == A.y) || (P.x == B.x && P.y == B.y)) return true;
+ if (A.y == B.y && P.y == A.y && between(P.x, A.x, B.x)) return true;
+
+ if (between(P.y, A.y, B.y)) {
+ // if P inside the vertical range
+ // filter out "ray pass vertex" problem by treating the line a little lower
+ if ((P.y == A.y && B.y >= A.y) || (P.y == B.y && A.y >= B.y)) continue;
+ // calc cross product `PA X PB`, P lays on left side of AB if c > 0
+ const c = (A.x - P.x) * (B.y - P.y) - (B.x - P.x) * (A.y - P.y);
+ if (c == 0) return true;
+ if (A.y < B.y == c > 0) inside = !inside;
+ }
+ }
+ return inside ? true : false;
+ };
+
+ //*******************************************************************************************************************************
+ /* The following is an alternative to the isPointInPolygon function, which appears to be more sensitive to floating point errors
+ //*******************************************************************************************************************************
+ *
+ *
+ *
+ *
+ *
+ */
+ //
+ /**
+ * Squares a number.
+ *
+ * @param {number} x - Value to square.
+ * @returns {number} x * x.
+ */
+ function sqr(x) {
+ return x * x;
+ }
+ /**
+ * Squared Euclidean distance between two points (avoids a square root).
+ *
+ * @param {{x:number, y:number}} v - First point.
+ * @param {{x:number, y:number}} w - Second point.
+ * @returns {number} Squared distance.
+ */
+ function distSquared(v, w) {
+ return sqr(v.x - w.x) + sqr(v.y - w.y);
+ }
+ /**
+ * Squared distance from a point to the nearest point on a line segment.
+ *
+ * @param {{x:number, y:number}} P - Point to test.
+ * @param {{x:number, y:number}} endPt1 - First end of the segment.
+ * @param {{x:number, y:number}} endPt2 - Second end of the segment.
+ * @returns {number} Squared distance to the segment.
+ */
+ function distToSegmentSquared(P, endPt1, endPt2) {
+ const l2 = distSquared(endPt1, endPt2);
+ if (l2 === 0) return distSquared(P, endPt1);
+ let t = ((P.x - endPt1.x) * (endPt2.x - endPt1.x) + (P.y - endPt1.y) * (endPt2.y - endPt1.y)) / l2;
+ t = Math.max(0, Math.min(1, t));
+ return distSquared(P, { x: endPt1.x + t * (endPt2.x - endPt1.x), y: endPt1.y + t * (endPt2.y - endPt1.y) });
+ }
+ /**
+ * Distance from a point to the nearest point on a line segment.
+ *
+ * @param {{x:number, y:number}} P - Point to test.
+ * @param {{x:number, y:number}} endPt1 - First end of the segment.
+ * @param {{x:number, y:number}} endPt2 - Second end of the segment.
+ * @returns {number} Distance to the segment.
+ */
+ function distToSegment(P, endPt1, endPt2) {
+ return Math.sqrt(distToSegmentSquared(P, endPt1, endPt2));
+ }
+ /**
+ * Tests whether a point is inside a wall polygon, treating anything within 0.1px of an edge as inside. More
+ * tolerant of floating-point error than isPointInPolygon alone.
+ *
+ * @param {{x:number, y:number}} P - Point to test, in pixels.
+ * @param {{x:number, y:number}[]} polygon - Closed polygon (first vertex repeated at the end).
+ * @returns {boolean} True if on an edge or inside.
+ */
+ const isPointInWall = function (P, polygon) {
+ //first, look for cases where P lies on one of line segments of the polygon
+ for (let i = 0; i < polygon.length - 1; i += 1) {
+ const a = polygon[i];
+ const b = polygon[i + 1];
+
+ if (distToSegment(P, a, b) < 0.1) {
+ return true;
+ }
+ }
+ //If not on a line segment, use normal algorithm
+ return isPointInPolygon(P, polygon);
+ };
+
+ /**
+ * Tests whether a point lies within a cone AoE. Angles are in degrees, 0 = straight up, increasing clockwise;
+ * distances in pixels. A 3% tolerance is applied to the radius, and a point at the origin always counts as inside.
+ * For flat (5e) cones the limit is the straight outer edge rather than the arc.
+ *
+ * @param {{x:number, y:number}} pt - Point to test.
+ * @param {{x:number, y:number}} oPt - Cone origin.
+ * @param {number} rad - Cone length in pixels.
+ * @param {number} coneDirection - Centre-line bearing in degrees.
+ * @param {number} coneWidth - Full cone angle in degrees.
+ * @param {boolean} isFlatCone - True for a 5e-style triangular cone.
+ * @param {string} [calcType='Euclidean'] - 'Euclidean' or 'PF' distance rules.
+ * @param {number} [gridIncrement=-999] - Page snapping_increment; needed for 'PF'.
+ * @param {number} [scaleNumber=-999] - Page scale_number; needed for 'PF'.
+ * @returns {boolean} True if the point is inside the cone.
+ */
+ const isPointInCone = function (
+ pt,
+ oPt,
+ rad,
+ coneDirection,
+ coneWidth,
+ isFlatCone,
+ calcType = 'Euclidean',
+ gridIncrement = -999,
+ scaleNumber = -999,
+ ) {
+ const deg2rad = Math.PI / 180;
+ let pAngle; //the angle between the cone origin and the test pt
+ let smallAngle;
+ const startAngle = deg2rad * Math.floor(normalizeTo360deg(coneDirection - coneWidth / 2)); //round down to nearest degree to account for flotaing pt errors
+ let endAngle = deg2rad * Math.ceil(normalizeTo360deg(coneDirection + coneWidth / 2)); //round down to nearest degree to account for flotaing pt errors
+ const centerAngle = deg2rad * normalizeTo360deg(coneDirection);
+ let criticalDist;
+
+ //special case: Angle calcs will fail when the pt to check is same as origin point - count this as in the cone.
+ if (Math.round(pt.x) === Math.round(oPt.x) && Math.round(pt.y) === Math.round(oPt.y)) {
+ return true;
+ }
+
+ // Calculate polar co-ordinates
+ let polarRadius;
+
+ if (calcType === 'PF' && gridIncrement !== -999 && scaleNumber !== -999) {
+ polarRadius = distBetweenPts(oPt, pt, calcType, gridIncrement, scaleNumber);
+ } else {
+ polarRadius = distBetweenPts(oPt, pt);
+ }
+
+ const dX = Math.abs(pt.x - oPt.x);
+ const dY = Math.abs(pt.y - oPt.y);
+
+ //calculate "smallAngle" - this does not take into account the quadrant in which the angle lies. More tests req'd to determine correct relative angle
+ if (dX === 0) {
+ smallAngle = 90 * deg2rad;
+ } else {
+ smallAngle = Math.atan(dY / dX);
+ }
+
+ if (pt.x < oPt.x && pt.y < oPt.y) {
+ //UL quadrant
+ pAngle = 270 * deg2rad + smallAngle;
+ } else if (pt.x > oPt.x && pt.y < oPt.y) {
+ //UR quadrant
+ pAngle = 90 * deg2rad - smallAngle;
+ } else if (pt.x > oPt.x && pt.y > oPt.y) {
+ //LR quadrant
+ pAngle = 90 * deg2rad + smallAngle;
+ } else if (pt.x < oPt.x && pt.y > oPt.y) {
+ //LL quadrant
+ pAngle = 270 * deg2rad - smallAngle;
+ } else if (pt.x === oPt.x && pt.y < oPt.y) {
+ //straight up
+ pAngle = 0 * deg2rad;
+ } else if (pt.x > oPt.x && pt.y === oPt.y) {
+ //straight right
+ pAngle = 90 * deg2rad;
+ } else if (pt.x === oPt.x && pt.y > oPt.y) {
+ //straight down
+ pAngle = 180 * deg2rad;
+ } else if (pt.x < oPt.x && pt.y === oPt.y) {
+ //straight left
+ pAngle = 270 * deg2rad;
+ }
+
+ //2nd test angle: Add 360deg to pAngle (to handle cases where startAngle is a negative value and endAngle is positive)
+ const pAngle360 = pAngle + 360 * deg2rad;
+ if (endAngle < startAngle) {
+ endAngle = endAngle + 360 * deg2rad;
+ }
+
+ if (isFlatCone) {
+ //for 5e-style cones. Basically a triangle (no rounded outer face)
+ //angle between the point and the cone's centre line, the short way round (e.g. 350deg and 10deg are 20deg apart)
+ const diff = Math.abs(pAngle - centerAngle);
+ const dTheta = Math.min(diff, 2 * Math.PI - diff);
+ criticalDist = rad / Math.cos(dTheta);
+ } else {
+ //compare to full radius cone
+ criticalDist = rad;
+ }
+
+ const err = 1.03;
+ //test pAngle and pAngle360 against start/end Angles
+ // (pAngle360*err >= startAngle) && (pAngle360 <= endAngle*err) && (polarRadius <= criticalDist*err) ) {
+ if (
+ (pAngle >= startAngle && pAngle <= endAngle && polarRadius <= criticalDist * err) ||
+ (pAngle360 >= startAngle && pAngle360 <= endAngle && polarRadius <= criticalDist * err)
+ ) {
+ return true;
+ } else {
+ return false;
+ }
+ };
+
+ // Returns intersection of two line segments.
+ /**
+ * Finds the point where two line segments cross, used to clip grid cells against AoE edges.
+ *
+ * @param {number} p0_x - Segment 1 start x.
+ * @param {number} p0_y - Segment 1 start y.
+ * @param {number} p1_x - Segment 1 end x.
+ * @param {number} p1_y - Segment 1 end y.
+ * @param {number} p2_x - Segment 2 start x.
+ * @param {number} p2_y - Segment 2 start y.
+ * @param {number} p3_x - Segment 2 end x.
+ * @param {number} p3_y - Segment 2 end y.
+ * @returns {{x:number, y:number}|null} The intersection, or null if the segments do not cross (parallel or
+ * overlapping segments also give null).
+ */
+ const getIntersectionPt = function (p0_x, p0_y, p1_x, p1_y, p2_x, p2_y, p3_x, p3_y) {
+ const s1_x = p1_x - p0_x;
+ const s1_y = p1_y - p0_y;
+ const s2_x = p3_x - p2_x;
+ const s2_y = p3_y - p2_y;
+
+ const s = (-s1_y * (p0_x - p2_x) + s1_x * (p0_y - p2_y)) / (-s2_x * s1_y + s1_x * s2_y);
+ const t = (s2_x * (p0_y - p2_y) - s2_y * (p0_x - p2_x)) / (-s2_x * s1_y + s1_x * s2_y);
+
+ if (s >= 0 && s <= 1 && t >= 0 && t <= 1) {
+ // intersection at one point
+ const intX = p0_x + t * s1_x;
+ const intY = p0_y + t * s1_y;
+ return { x: intX, y: intY };
+ // // overlapping vertical lines
+ // // overlapping horizontal lines
+ } else {
+ // No intersection
+ return null;
+ }
+ };
+
+ /**
+ * Projects the end of a cone side from the origin at a given angle and length, rounded to whole pixels. Angle in
+ * radians, 0 = straight up, increasing clockwise (Roll20 y grows downward).
+ *
+ * @param {{x:number, y:number}} oPt - Cone origin in pixels.
+ * @param {number} rad - Length in pixels.
+ * @param {number} theta - Bearing in radians.
+ * @returns {pt} The end point.
+ */
+ const getConeEndPt = function (oPt, rad, theta) {
+ const deg2rad = Math.PI / 180;
+ //change in "normal" polar coord conversion due to 0deg being straight up and positive Y being "down"
+ const x = Math.round(oPt.x + rad * Math.sin(theta));
+ const y = Math.round(oPt.y + rad * Math.cos(theta + deg2rad * 180));
+ return new pt(x, y);
+ };
+
+ /**
+ * Returns the three vertices of a cone: the origin and the ends of its two sides. For a 5e cone the sides are
+ * lengthened by z so the flat outer edge sits at the right distance.
+ *
+ * @param {string} aoeType - AoE type; '5econe' uses the flat-cone geometry.
+ * @param {pt} oPt - Cone origin in pixels.
+ * @param {number} coneDirection - Centre-line bearing in degrees (0 = up, clockwise).
+ * @param {number} coneWidth - Full cone angle in degrees.
+ * @param {number} rad - Cone length in pixels.
+ * @param {number} z - Extra side length in pixels for 5e cones (0 otherwise).
+ * @returns {pt[]} [origin, trailing-side end, leading-side end].
+ */
+ const getConeEndPts2 = function (aoeType, oPt, coneDirection, coneWidth, rad, z) {
+ const deg2rad = Math.PI / 180;
+ coneDirection = normalizeTo360deg(coneDirection);
+ const th1 = deg2rad * (coneDirection - coneWidth / 2); //angle of trailing cone side
+ const th2 = deg2rad * (coneDirection + coneWidth / 2); //angle of leading cone side
+ const sideLength = aoeType === '5econe' ? rad + z : rad; //a 5e cone's sides reach past rad to its flat end
+ return [oPt, getConeEndPt(oPt, sideLength, th1), getConeEndPt(oPt, sideLength, th2)];
+ };
+
+ /**
+ * Projects a 5e cone side end relative to a local origin at (rad, rad), for building the cone outline path inside
+ * its own bounding box. Not rounded. Angle in radians, 0 = straight up, clockwise.
+ *
+ * @param {number} rad - Side length in pixels; also the local origin offset.
+ * @param {number} theta - Bearing in radians.
+ * @returns {pt} The end point in path-local pixel coordinates.
+ */
+ const get5eConePathPt = function (rad, theta) {
+ const deg2rad = Math.PI / 180;
+ //change in "normal" polar coord conversion due to 0deg being straight up and positive Y being "down"
+ const x = rad + rad * Math.sin(theta);
+ const y = rad + rad * Math.cos(theta + deg2rad * 180);
+ return new pt(x, y);
+ };
+
+ //clockwise ordering of corner points of cell
+ /**
+ * Returns the corners of a rectangle centred on (i, j), in clockwise order starting at the upper-left, with the
+ * first corner repeated to close the shape. Used for grid cells and token footprints.
+ *
+ * @param {number} i - Centre x in pixels.
+ * @param {number} j - Centre y in pixels.
+ * @param {number} width - Width in pixels.
+ * @param {number} [height=-999] - Height in pixels; defaults to width (a square).
+ * @returns {pt[]} [UL, UR, LR, LL, UL].
+ */
+ const getCellCoords = function (i, j, width, height = -999) {
+ if (height === -999) {
+ height = width;
+ }
+ const ptUL = new pt(i - width / 2, j - height / 2);
+ const ptUR = new pt(i + width / 2, j - height / 2);
+ const ptLR = new pt(i + width / 2, j + height / 2);
+ const ptLL = new pt(i - width / 2, j + height / 2);
+ const ptUL2 = new pt(i - width / 2, j - height / 2);
+
+ const points = [ptUL, ptUR, ptLR, ptLL, ptUL2]; //note the repeat of ptUL to close the cell
+ return points;
+ };
+
+ //returns the grid corner that is the farthest away from the origin pt
+ /**
+ * Returns whichever of the given points is farthest (Euclidean) from the origin. Used for Pathfinder distance
+ * checks on grid cells.
+ *
+ * @param {{x:number, y:number}} oPt - Origin point.
+ * @param {pt[]} cornerPts - Candidate points (e.g. a cell's corners).
+ * @returns {pt} The farthest point.
+ */
+ const getFarthestCorner = function (oPt, cornerPts) {
+ let maxDist = -999999;
+ let farthestPt;
+ cornerPts.forEach((pt) => {
+ const d = distBetweenPts(oPt, pt);
+ if (d > maxDist) {
+ maxDist = d;
+ farthestPt = pt;
+ }
+ });
+ return farthestPt;
+ };
+
+ /**
+ * Appends a point to an array only if no point with the same x and y is already there. Mutates arr.
+ *
+ * @param {{x:number, y:number}[]} arr - Array to add to.
+ * @param {{x:number, y:number}} obj - Point to add.
+ * @returns {number} 1 if added, 0 if it was a duplicate.
+ */
+ const pushUniquePtToArray = function (arr, obj) {
+ const index = arr.findIndex((e) => e.x === obj.x && e.y === obj.y);
+ if (index === -1) {
+ arr.push(obj);
+ return 1;
+ } else {
+ return 0;
+ }
+ };
+
+ /**
+ * Appends a value to an array only if it is not already present (strict equality). Mutates arr.
+ *
+ * @param {Array} arr - Array to add to.
+ * @param {*} element - Value to add.
+ * @returns {number} 1 if added, 0 if it was a duplicate.
+ */
+ const pushUniqueElementToArray = function (arr, element) {
+ const index = arr.findIndex((e) => e === element);
+ if (index === -1) {
+ arr.push(element);
+ return 1;
+ } else {
+ return 0;
+ }
+ };
+
+ /**
+ * Orders Roll20 path IDs by the distance of each path's centre from a point, nearest first. IDs of paths that no
+ * longer exist are dropped.
+ *
+ * @param {string[]} pathsArr - Roll20 path object IDs.
+ * @param {{x:number, y:number}} oPt - Reference point in pixels.
+ * @returns {string[]} The surviving IDs, sorted by distance.
+ */
+ const sortPathsByDistanceToOrigin = function (pathsArr, oPt) {
+ let tempPath;
+ let tempPt;
+ let dist;
+
+ //populate temp array of objects with pathIDs & dist
+ const pathDistID = [];
+ pathsArr.forEach((pathID) => {
+ tempPath = getObj('path', pathID);
+ if (!tempPath) {
+ return; //path no longer exists
+ }
+ tempPt = new pt(tempPath.get('left'), tempPath.get('top'));
+ dist = distBetweenPts(tempPt, oPt);
+ pathDistID.push({ id: pathID, dist: dist });
+ });
+
+ //sort temp array by dist
+ const pathDistIDSorted = pathDistID.sort((a, b) => a.dist - b.dist);
+
+ //copy the sorted xy coords to a return array
+ const retArr = pathDistIDSorted.map((e) => e.id);
+ return retArr;
+ };
+
+ /**
+ * Sorts points by angle around their centroid so they form a non-self-intersecting polygon (needed before
+ * calcPolygonArea). Uses atan2 ascending, which is clockwise on screen because Roll20 y grows downward.
+ *
+ * @param {{x:number, y:number}[]} ptsArr - Points to sort.
+ * @returns {{x:number, y:number}[]} New array of plain {x, y} objects in clockwise order.
+ */
+ const sortPtsClockwise = function (ptsArr) {
+ const center = ptsArr.reduce(
+ (acc, { x, y }) => {
+ acc.x += x / ptsArr.length;
+ acc.y += y / ptsArr.length;
+ return acc;
+ },
+ { x: 0, y: 0 },
+ );
+
+ // Add an angle property to each point using tan(angle) = y/x
+ const angles = ptsArr.map(({ x, y }) => {
+ return { x, y, angle: (Math.atan2(y - center.y, x - center.x) * 180) / Math.PI };
+ });
+
+ // Sort your points by angle
+ const pointsSorted = angles.sort((a, b) => a.angle - b.angle);
+
+ //copy the sorted xy coords to a return array
+ const retArr = pointsSorted.map((pt) => {
+ return { x: pt.x, y: pt.y };
+ });
+ return retArr;
+ };
+
+ /**
+ * Finds the grid-cell centre nearest to a point, searching only centres within one cell (70 * pageGridIncrement px)
+ * on each axis.
+ *
+ * @param {{x:number, y:number}} testPt - Point in pixels.
+ * @param {pt[]} ptArray - Candidate grid-cell centres in pixels.
+ * @param {number} pageGridIncrement - Page snapping_increment.
+ * @returns {pt|undefined} The nearest centre, or undefined if none is within one cell.
+ */
+ const getClosestGridPt = function (testPt, ptArray, pageGridIncrement) {
+ //first, filter out points in the master array that are farther than 1 unit away
+ const arr = ptArray.filter((pt) => {
+ if (
+ pt.x <= testPt.x + 70 * pageGridIncrement &&
+ pt.x >= testPt.x - 70 * pageGridIncrement &&
+ pt.y <= testPt.y + 70 * pageGridIncrement &&
+ pt.y >= testPt.y - 70 * pageGridIncrement
+ ) {
+ return true;
+ }
+ });
+
+ let minDist = 99999;
+ let d, idx;
+ arr.forEach((pt, i) => {
+ d = distBetweenPts(pt, testPt);
+ if (d < minDist) {
+ minDist = d;
+ idx = i;
+ }
+ });
+ return arr[idx];
+ };
+
+ //function taken from https://stackoverflow.com/questions/37224912/circle-line-segment-collision/37225895
+ /**
+ * Finds where a line segment crosses a circle's circumference. Adapted from
+ * https://stackoverflow.com/questions/37224912/circle-line-segment-collision/37225895.
+ *
+ * @param {{x:number, y:number}} pt1 - Segment start.
+ * @param {{x:number, y:number}} pt2 - Segment end.
+ * @param {{x:number, y:number}} centerPt - Circle centre.
+ * @param {number} rad - Circle radius (same units as the points).
+ * @returns {{x:number, y:number}[]} Zero, one or two intersection points lying on the segment.
+ */
+ const getLineCircleIntersections = function (pt1, pt2, centerPt, rad) {
+ let b, c, d, u1, u2, ret, retP1, retP2, v1, v2;
+
+ const circle = {
+ radius: rad,
+ center: centerPt,
+ };
+
+ const line = {
+ p1: pt1,
+ p2: pt2,
+ };
+
+ v1 = {};
+ v2 = {};
+ v1.x = line.p2.x - line.p1.x;
+ v1.y = line.p2.y - line.p1.y;
+ v2.x = line.p1.x - circle.center.x;
+ v2.y = line.p1.y - circle.center.y;
+ b = v1.x * v2.x + v1.y * v2.y;
+ c = 2 * (v1.x * v1.x + v1.y * v1.y);
+ b *= -2;
+ d = Math.sqrt(b * b - 2 * c * (v2.x * v2.x + v2.y * v2.y - circle.radius * circle.radius));
+ if (isNaN(d)) {
+ // no intercept
+ return [];
+ }
+ u1 = (b - d) / c; // these represent the unit distance of point one and two on the line
+ u2 = (b + d) / c;
+ retP1 = {}; // return points
+ retP2 = {};
+ ret = []; // return array
+ if (u1 <= 1 && u1 >= 0) {
+ // add point if on the line segment
+ retP1.x = line.p1.x + v1.x * u1;
+ retP1.y = line.p1.y + v1.y * u1;
+ ret[0] = retP1;
+ }
+ if (u2 <= 1 && u2 >= 0) {
+ // second add point if on the line segment
+ retP2.x = line.p1.x + v1.x * u2;
+ retP2.y = line.p1.y + v1.y * u2;
+ ret[ret.length] = retP2;
+ }
+ return ret;
+ };
+
+ /**
+ * Tests whether a point is within a circle, flooring the distance to the nearest pixel to absorb floating-point
+ * error.
+ *
+ * @param {{x:number, y:number}} pt - Point to test, in pixels.
+ * @param {{x:number, y:number}} centerPt - Circle centre, in pixels.
+ * @param {number} rad - Radius in pixels.
+ * @returns {boolean} True if inside or on the circle.
+ */
+ const isPointInCircle = function (pt, centerPt, rad) {
+ const d = Math.floor(distBetweenPts(pt, centerPt)); //rounded down to nearest pixel to account for floating point errors
+ return d <= rad;
+ };
+
+ /**
+ * Grid-cell bounds of a search box: the centres of the cells nearest its two corners.
+ *
+ * @param {pt} minPt - Upper-left corner of the box, in pixels.
+ * @param {pt} maxPt - Lower-right corner of the box, in pixels.
+ * @param {pt[]} pageGridCenters - Every grid-cell centre on the page.
+ * @param {number} pageGridIncrement - Page snapping_increment.
+ * @returns {{minX:number, maxX:number, minY:number, maxY:number}} Centre coordinates of the outermost cells.
+ */
+ const getGridBounds = function (minPt, maxPt, pageGridCenters, pageGridIncrement) {
+ return {
+ minX: getClosestGridPt(minPt, pageGridCenters, pageGridIncrement).x,
+ maxX: getClosestGridPt(maxPt, pageGridCenters, pageGridIncrement).x,
+ minY: getClosestGridPt(minPt, pageGridCenters, pageGridIncrement).y,
+ maxY: getClosestGridPt(maxPt, pageGridCenters, pageGridIncrement).y,
+ };
+ };
+
+ /**
+ * Grid-cell bounds of the box around an AoE origin, widened by the origin offset on each axis so it covers the whole
+ * AoE (circles, squares and cones).
+ *
+ * @param {pt} oPt - AoE origin (already including any offset from the token centre).
+ * @param {number} radX - Horizontal half-size of the AoE, in pixels.
+ * @param {number} radY - Vertical half-size of the AoE, in pixels.
+ * @param {number} offsetX - Horizontal origin offset, in pixels.
+ * @param {number} offsetY - Vertical origin offset, in pixels.
+ * @param {pt[]} pageGridCenters - Every grid-cell centre on the page.
+ * @param {number} pageGridIncrement - Page snapping_increment.
+ * @returns {{minX:number, maxX:number, minY:number, maxY:number}} Centre coordinates of the outermost cells.
+ */
+ const getOriginGridBounds = function (oPt, radX, radY, offsetX, offsetY, pageGridCenters, pageGridIncrement) {
+ const searchX = radX + Math.abs(offsetX || 0);
+ const searchY = radY + Math.abs(offsetY || 0);
+ return getGridBounds(
+ new pt(oPt.x - searchX, oPt.y - searchY),
+ new pt(oPt.x + searchX, oPt.y + searchY),
+ pageGridCenters,
+ pageGridIncrement,
+ );
+ };
+
+ /**
+ * Works out which grid cells inside the given bounds an AoE covers. This is the shared engine behind the circle,
+ * square, wall and cone shapes, which differ only in the tests they pass in:
+ * - a cell whose four corners are all inside is kept;
+ * - otherwise the points where the AoE's edge crosses each side of the cell (top, right, bottom, left) are added to
+ * the cell, plus any AoE vertices inside the cell. With fewer than two such points the cell is dropped (unless
+ * keepUncrossed keeps it); otherwise the cell is clipped to the AoE and kept if the covered area is at least
+ * minGridArea of a cell.
+ *
+ * @param {object} shape - The AoE's tests.
+ * @param {function(pt):boolean} shape.isCornerInside - Whether a cell corner is inside the AoE.
+ * @param {function(pt):boolean} [shape.isPointInside=shape.isCornerInside] - Whether a clipped point is kept.
+ * @param {function(pt, pt):pt[]} shape.edgeCrossings - Where the AoE's edge crosses the cell side from a to b.
+ * (called with the side index as a third argument).
+ * @param {pt[]} [shape.vertices=[]] - AoE corner points to add when they fall inside a cell.
+ * @param {function(pt[]):boolean} [shape.keepUncrossed] - For a partly covered cell with fewer than two crossing
+ * points: return true to keep it anyway (gets the cell's corner points). Used for circles smaller than a cell.
+ * @param {pt} [shape.farFrom] - If given, each cell records its corner farthest from this point (PF distances).
+ * @param {{minX:number, maxX:number, minY:number, maxY:number}} bounds - Centres of the outermost cells to test.
+ * @param {number} minGridArea - Minimum covered fraction of a cell (0-1).
+ * @param {number} pageGridIncrement - Page snapping_increment.
+ * @returns {{center:pt, farthestCorner:(pt|undefined)}[]} The covered cells.
+ */
+ const getCoveredCells = function (shape, bounds, minGridArea, pageGridIncrement) {
+ const cellSize = 70 * pageGridIncrement;
+ const isPointInside = shape.isPointInside || shape.isCornerInside;
+ const vertices = shape.vertices || [];
+ const covered = [];
+
+ //columns, then rows, as the shapes always did (the order of the returned cells is kept)
+ for (let i = bounds.minX; i <= bounds.maxX; i = i + cellSize) {
+ for (let j = bounds.minY; j <= bounds.maxY; j = j + cellSize) {
+ const corners = getCellCoords(i, j, cellSize); //[UL, UR, LR, LL, UL] (UL repeated to close the cell)
+ const cell = {
+ center: new pt(i, j),
+ farthestCorner: shape.farFrom ? getFarthestCorner(shape.farFrom, corners) : undefined,
+ };
+
+ if (corners.slice(0, 4).every((c) => shape.isCornerInside(c))) {
+ covered.push(cell); //fully inside
+ continue;
+ }
+
+ //partly covered (or not at all): collect the AoE edge crossings on each side of the cell
+ let points = corners.slice();
+ let numAddedPts = 0;
+ for (let side = 0; side < 4; side++) {
+ const sideCrossings = [];
+ shape.edgeCrossings(corners[side], corners[side + 1], side).forEach((p) => {
+ if (p) pushUniquePtToArray(sideCrossings, p);
+ });
+ sideCrossings.forEach((p) => points.push(p));
+ numAddedPts += sideCrossings.length;
+ }
+ //and the AoE's own corners that sit inside this cell
+ vertices.forEach((v) => {
+ if (isPointInPolygon(v, corners)) {
+ numAddedPts += pushUniquePtToArray(points, v); //1 if added, 0 if already there
+ }
+ });
+
+ points = sortPtsClockwise(points);
+
+ if (numAddedPts < 2) {
+ //must have two crossing points to calculate an area
+ if (shape.keepUncrossed && shape.keepUncrossed(corners)) {
+ covered.push(cell);
+ }
+ continue;
+ }
+
+ //clip the cell to the AoE, then measure what is left
+ points = sortPtsClockwise(points.filter((p) => isPointInside(p)));
+ const area = calcPolygonArea(points);
+ if (area >= minGridArea * 70 * 70 * pageGridIncrement * pageGridIncrement) {
+ covered.push(cell);
+ }
+ }
+ }
+ return covered;
+ };
+
+ /**
+ * Crossing points of the cell side a-b with each of the given straight AoE edges.
+ *
+ * @param {pt} a - Start of the cell side.
+ * @param {pt} b - End of the cell side.
+ * @param {pt[][]} edges - AoE edges as [start, end] pairs.
+ * @returns {Array} One entry per edge (null where they don't cross).
+ */
+ const getSegmentCrossings = function (a, b, edges) {
+ return edges.map(([p, q]) => getIntersectionPt(a.x, a.y, b.x, b.y, p.x, p.y, q.x, q.y));
+ };
+
+ /**
+ * Turns a list of covered cells into the {xVals, yVals} lists of cell centres that the path drawing uses.
+ *
+ * @param {{center:pt}[]} cells - Covered cells, in order.
+ * @returns {{xVals:number[], yVals:number[]}} Pixel centres of the cells.
+ */
+ const cellsToCoords = function (cells) {
+ return {
+ xVals: cells.map((c) => c.center.x),
+ yVals: cells.map((c) => c.center.y),
+ };
+ };
+
+ /**
+ * Works out which grid cells a circle AoE covers. Cells fully inside are kept; partly covered cells are clipped to
+ * the circle and kept if the covered area is at least minGridArea of a cell. A circle smaller than a cell that sits
+ * inside it keeps that cell. For 'PFcircle', cells must also pass a Pathfinder distance check (cell centre, or
+ * farthest corner if forceIntersection). All values in pixels.
+ *
+ * @param {pt[]} pageGridCenters - Every grid-cell centre on the page.
+ * @param {string} aoeType - 'circle' or 'PFcircle'.
+ * @param {number} minGridArea - Minimum covered fraction of a cell (0-1).
+ * @param {pt} oPt - Circle centre.
+ * @param {number} rad - Radius in pixels.
+ * @param {number} pageGridIncrement - Page snapping_increment.
+ * @param {number} pageScaleNumber - Page scale_number, for PF distances.
+ * @param {number} offsetX - Horizontal origin offset from the token centre; widens the search box.
+ * @param {number} offsetY - Vertical origin offset; widens the search box.
+ * @param {boolean} forceIntersection - For 'PFcircle', test the farthest corner instead of the centre.
+ * @returns {{xVals:number[], yVals:number[]}} Pixel centres of the covered cells.
+ */
+ const getCircleLocations = function (
+ pageGridCenters,
+ aoeType,
+ minGridArea,
+ oPt,
+ rad,
+ pageGridIncrement,
+ pageScaleNumber,
+ offsetX,
+ offsetY,
+ forceIntersection,
+ ) {
+ const cellArea = 70 * 70 * pageGridIncrement * pageGridIncrement;
+ const bounds = getOriginGridBounds(oPt, rad, rad, offsetX, offsetY, pageGridCenters, pageGridIncrement);
+ let cells = getCoveredCells(
+ {
+ isCornerInside: (p) => isPointInCircle(p, oPt, rad),
+ edgeCrossings: (a, b) => getLineCircleIntersections(a, b, oPt, rad),
+ //a circle smaller than a cell can sit inside it without crossing an edge: keep that cell if enough is covered
+ keepUncrossed: (corners) =>
+ oPt.x - rad >= corners[0].x &&
+ oPt.x + rad <= corners[2].x &&
+ oPt.y - rad >= corners[0].y &&
+ oPt.y + rad <= corners[2].y &&
+ Math.PI * rad * rad >= minGridArea * cellArea,
+ farFrom: oPt,
+ },
+ bounds,
+ minGridArea,
+ pageGridIncrement,
+ );
+
+ if (aoeType === 'PFcircle') {
+ //one last check to see if the grid square satisfies PF-style distance rules (every other diag counts as 2sq)
+ cells = cells.filter((c) => {
+ const testPt = forceIntersection ? c.farthestCorner : c.center;
+ return distBetweenPts(oPt, testPt, 'PF', pageGridIncrement, pageScaleNumber) <= rad;
+ });
+ }
+ return cellsToCoords(cells);
+ };
+
+ /**
+ * Works out which grid cells a wall AoE (a rotated rectangle from getWallParams) covers. Cells fully inside are
+ * kept; partly covered cells are clipped against the wall polygon and kept if the covered area is at least
+ * minGridArea of a cell. All values in pixels.
+ *
+ * @param {pt[]} pageGridCenters - Every grid-cell centre on the page.
+ * @param {number} minGridArea - Minimum covered fraction of a cell (0-1).
+ * @param {object} wallParams - From getWallParams: corners pt1-pt4 and extreme points pMinX, pMaxX, pMinY, pMaxY.
+ * @param {number} pageGridIncrement - Page snapping_increment.
+ * @returns {{xVals:number[], yVals:number[]}} Pixel centres of the covered cells.
+ */
+ const getWallLocations = function (pageGridCenters, minGridArea, wallParams, pageGridIncrement) {
+ const wallAoEPolygon = [wallParams.pt1, wallParams.pt2, wallParams.pt3, wallParams.pt4, wallParams.pt1];
+ const wallEdges = [0, 1, 2, 3].map((k) => [wallAoEPolygon[k], wallAoEPolygon[k + 1]]);
+ const bounds = {
+ minX: getClosestGridPt(wallParams.pMinX, pageGridCenters, pageGridIncrement).x,
+ maxX: getClosestGridPt(wallParams.pMaxX, pageGridCenters, pageGridIncrement).x,
+ minY: getClosestGridPt(wallParams.pMinY, pageGridCenters, pageGridIncrement).y,
+ maxY: getClosestGridPt(wallParams.pMaxY, pageGridCenters, pageGridIncrement).y,
+ };
+ const cells = getCoveredCells(
+ {
+ isCornerInside: (p) => isPointInPolygon(p, wallAoEPolygon),
+ isPointInside: (p) => isPointInWall(p, wallAoEPolygon), //tolerant of floating-point error on the edges
+ edgeCrossings: (a, b) => getSegmentCrossings(a, b, wallEdges),
+ vertices: wallAoEPolygon.slice(0, 4),
+ },
+ bounds,
+ minGridArea,
+ pageGridIncrement,
+ );
+ return cellsToCoords(cells);
+ };
+
+ /**
+ * Works out which grid cells a square AoE covers. The square is centred on the origin with half-width rad, widened
+ * by the offsets on each side. Cells fully inside are kept; partly covered cells are clipped and kept if the
+ * covered area is at least minGridArea of a cell. All values in pixels.
+ *
+ * @param {pt[]} pageGridCenters - Every grid-cell centre on the page.
+ * @param {number} minGridArea - Minimum covered fraction of a cell (0-1).
+ * @param {pt} oPt - Square centre.
+ * @param {number} rad - Half the side length in pixels.
+ * @param {number} pageGridIncrement - Page snapping_increment.
+ * @param {number} offsetX - Horizontal widening, in pixels (also widens the search box).
+ * @param {number} offsetY - Vertical widening, in pixels.
+ * @returns {{xVals:number[], yVals:number[]}} Pixel centres of the covered cells.
+ */
+ const getSquareLocations = function (pageGridCenters, minGridArea, oPt, rad, pageGridIncrement, offsetX, offsetY) {
+ const squareAoEPolygon = [
+ new pt(oPt.x - rad - offsetX, oPt.y - rad - offsetY),
+ new pt(oPt.x + rad + offsetX, oPt.y - rad - offsetY),
+ new pt(oPt.x + rad + offsetX, oPt.y + rad + offsetY),
+ new pt(oPt.x - rad - offsetX, oPt.y + rad + offsetY),
+ new pt(oPt.x - rad - offsetX, oPt.y - rad - offsetY),
+ ];
+ //a horizontal cell side can only cross the square's vertical edges, and a vertical side its horizontal edges
+ const verticalEdges = [
+ [squareAoEPolygon[1], squareAoEPolygon[2]],
+ [squareAoEPolygon[3], squareAoEPolygon[4]],
+ ];
+ const horizontalEdges = [
+ [squareAoEPolygon[0], squareAoEPolygon[1]],
+ [squareAoEPolygon[2], squareAoEPolygon[3]],
+ ];
+ const bounds = getOriginGridBounds(oPt, rad, rad, offsetX, offsetY, pageGridCenters, pageGridIncrement);
+ const cells = getCoveredCells(
+ {
+ isCornerInside: (p) => isPointInPolygon(p, squareAoEPolygon),
+ edgeCrossings: (a, b, side) => getSegmentCrossings(a, b, side % 2 === 0 ? verticalEdges : horizontalEdges),
+ vertices: squareAoEPolygon.slice(0, 4),
+ },
+ bounds,
+ minGridArea,
+ pageGridIncrement,
+ );
+ return cellsToCoords(cells);
+ };
+
+ /**
+ * Works out which grid cells a cone covers. Supports 'cone' (round-ended), '5econe' (flat-ended triangle, 53deg)
+ * and 'PFcone' (round-ended, then filtered by Pathfinder distance to each cell's farthest corner). Cells fully
+ * inside are kept; partly covered cells are clipped to the cone and kept if the covered area is at least
+ * minGridArea of a cell. All values in pixels.
+ *
+ * @param {pt[]} pageGridCenters - Every grid-cell centre on the page.
+ * @param {string} aoeType - 'cone', '5econe' or 'PFcone'.
+ * @param {number} minGridArea - Minimum covered fraction of a cell (0-1).
+ * @param {pt} oPt - Cone origin.
+ * @param {number} coneDirection - Centre-line bearing in degrees (0 = up, clockwise).
+ * @param {number} coneWidth - Full cone angle in degrees.
+ * @param {number} rad - Cone length in pixels.
+ * @param {number} pageGridIncrement - Page snapping_increment.
+ * @param {number} pageScaleNumber - Page scale_number, for PF distances.
+ * @param {number} offsetX - Horizontal origin offset from the token centre; widens the search box.
+ * @param {number} offsetY - Vertical origin offset; widens the search box.
+ * @param {number} oWidth - Origin token width in pixels (pads the search box for round cones).
+ * @param {number} oHeight - Origin token height in pixels.
+ * @returns {{xVals:number[], yVals:number[]}} Pixel centres of the covered cells.
+ */
+ const getConeLocations = function (
+ pageGridCenters,
+ aoeType,
+ minGridArea,
+ oPt,
+ coneDirection,
+ coneWidth,
+ rad,
+ pageGridIncrement,
+ pageScaleNumber,
+ offsetX,
+ offsetY,
+ oWidth,
+ oHeight,
+ ) {
+ const isFlatCone = aoeType === '5econe';
+ let bbRadX;
+ let bbRadY;
+ let coneEndPts; //[origin, trailing-side end, leading-side end]
+
+ if (isFlatCone) {
+ const z = rad / (2 * Math.sin(Math.atan(0.5))) - rad;
+ coneEndPts = getConeEndPts2(aoeType, oPt, coneDirection, coneWidth, rad, z);
+ const sizeX = Math.max(
+ Math.abs(coneEndPts[0].x - coneEndPts[1].x),
+ Math.abs(coneEndPts[1].x - coneEndPts[2].x),
+ Math.abs(coneEndPts[2].x - coneEndPts[0].x),
+ );
+ const sizeY = Math.max(
+ Math.abs(coneEndPts[0].y - coneEndPts[1].y),
+ Math.abs(coneEndPts[1].y - coneEndPts[2].y),
+ Math.abs(coneEndPts[2].y - coneEndPts[0].y),
+ );
+ //Extend the bounding box by 1.5 times
+ bbRadX = Math.ceil((sizeX * 1.5) / (70 * pageGridIncrement)) * 70 * pageGridIncrement;
+ bbRadY = Math.ceil((sizeY * 1.5) / (70 * pageGridIncrement)) * 70 * pageGridIncrement;
+ } else {
+ coneEndPts = getConeEndPts2(aoeType, oPt, coneDirection, coneWidth, rad, 0);
+ bbRadX = rad + oWidth / 2;
+ bbRadY = rad + oHeight / 2;
+ }
+
+ const inCone = (p) => isPointInCone(p, oPt, rad, coneDirection, coneWidth, isFlatCone);
+ const coneSides = [
+ [oPt, coneEndPts[1]],
+ [oPt, coneEndPts[2]],
+ ];
+ const flatEnd = [[coneEndPts[1], coneEndPts[2]]];
+
+ //where a cell side crosses the cone: its two straight sides, then the flat end (5e) or the arc (round cones)
+ const edgeCrossings = (a, b) => {
+ const crossings = getSegmentCrossings(a, b, coneSides);
+ if (isFlatCone) {
+ return crossings.concat(getSegmentCrossings(a, b, flatEnd));
+ }
+ if (coneWidth !== 360) {
+ return crossings.concat(getLineCircleIntersections(a, b, oPt, rad).filter(inCone));
+ }
+ return crossings; //a full circle has no arc ends to clip against
+ };
+
+ const bounds = getOriginGridBounds(oPt, bbRadX, bbRadY, offsetX, offsetY, pageGridCenters, pageGridIncrement);
+ let cells = getCoveredCells(
+ { isCornerInside: inCone, edgeCrossings, vertices: coneEndPts, farFrom: oPt },
+ bounds,
+ minGridArea,
+ pageGridIncrement,
+ );
+
+ if (aoeType === 'PFcone') {
+ //one last check to see if the grid square satisfies PF-style distance rules (every other diag counts as 2sq)
+ cells = cells.filter(
+ (c) => distBetweenPts(oPt, c.farthestCorner, 'PF', pageGridIncrement, pageScaleNumber) <= rad,
+ );
+ }
+ return cellsToCoords(cells);
+ };
+
+ //The following finds the intersection of the control line with the origin token (always thru center of token)
+ // then finds the closest possiblePt (corners or faces) to that intersection pt
+ /**
+ * For 'nearest' origin modes: finds where the line from the control point to the token's centre crosses the token's
+ * bounding box, then returns the candidate origin point closest to that crossing. Pixels.
+ *
+ * @param {{x:number, y:number}} cPt - Control point.
+ * @param {number} minX - Left edge of the origin token.
+ * @param {number} minY - Top edge of the origin token.
+ * @param {number} maxX - Right edge of the origin token.
+ * @param {number} maxY - Bottom edge of the origin token.
+ * @param {{x:number, y:number}[]} possiblePts - Candidate origins (from getPossibleOriginPts).
+ * @returns {{x:number, y:number}} The chosen origin point.
+ */
+ const getNearestOriginPt = function (cPt, minX, minY, maxX, maxY, possiblePts) {
+ let intersectPt;
+ let dist;
+ let minDist = 99999;
+ let nearestPt;
+
+ const midX = (minX + maxX) / 2;
+ const midY = (minY + maxY) / 2;
+ const m = (midY - cPt.y) / (midX - cPt.x);
+ if (cPt.x <= midX) {
+ // check "left" side
+ const minXy = m * (minX - cPt.x) + cPt.y;
+ if (minY <= minXy && minXy <= maxY) {
+ intersectPt = { x: minX, y: minXy };
+ }
+ }
+
+ if (cPt.x >= midX) {
+ // check "right" side
+ const maxXy = m * (maxX - cPt.x) + cPt.y;
+ if (minY <= maxXy && maxXy <= maxY) {
+ intersectPt = { x: maxX, y: maxXy };
+ }
+ }
+
+ if (cPt.y <= midY) {
+ // check "top" side
+ const minYx = (minY - cPt.y) / m + cPt.x;
+ if (minX <= minYx && minYx <= maxX) {
+ intersectPt = { x: minYx, y: minY };
+ }
+ }
+
+ if (cPt.y >= midY) {
+ // check "bottom" side
+ const maxYx = (maxY - cPt.y) / m + cPt.x;
+ if (minX <= maxYx && maxYx <= maxX) {
+ intersectPt = { x: maxYx, y: maxY };
+ }
+ }
+
+ // edge case when finding midpoint intersection: m = 0/0 = NaN
+ if (cPt.x === midX && cPt.y === midY) {
+ intersectPt = { x: cPt.x, y: cPt.y };
+ }
+
+ //Now, find the nearest "possiblePt" to this intersection pt
+
+ if (!intersectPt) {
+ intersectPt = { x: cPt.x, y: cPt.y }; //no edge crossing found (floating-point edge case): use the control point
+ }
+
+ for (let i = 0; i < possiblePts.length; i++) {
+ dist = distBetweenPts(intersectPt, possiblePts[i]);
+ if (dist < minDist || i === 0) {
+ nearestPt = possiblePts[i];
+ minDist = dist;
+ }
+ }
+
+ return nearestPt;
+ };
+
+ /**
+ * Lists candidate origin points around the edge of a token: its grid corners, plus half-cell face midpoints when
+ * allowFaces is true. Pixels.
+ *
+ * @param {{x:number, y:number}} oPt - Token centre.
+ * @param {number} height - Token height in pixels.
+ * @param {number} width - Token width in pixels.
+ * @param {number} pageGridIncrement - Page snapping_increment.
+ * @param {boolean} [allowFaces=false] - Also include points every half cell.
+ * @returns {{x:number, y:number}[]} Unique edge points sorted clockwise.
+ */
+ const getPossibleOriginPts = function (oPt, height, width, pageGridIncrement, allowFaces = false) {
+ //gets all the corner points and 1/2 grid width faces around an origin pt
+ let allPts = [];
+
+ let incr;
+ if (allowFaces) {
+ incr = 35 * pageGridIncrement;
+ } else {
+ incr = 70 * pageGridIncrement;
+ }
+
+ for (let i = oPt.x - width / 2; i <= oPt.x + width / 2; i += incr) {
+ pushUniquePtToArray(allPts, { x: i, y: oPt.y - height / 2 });
+ pushUniquePtToArray(allPts, { x: i, y: oPt.y + height / 2 });
+ }
+ for (let j = oPt.y - height / 2; j <= oPt.y + height / 2; j += incr) {
+ pushUniquePtToArray(allPts, { x: oPt.x - width / 2, y: j });
+ pushUniquePtToArray(allPts, { x: oPt.x + width / 2, y: j });
+ }
+ //sort the points clockwise
+ allPts = sortPtsClockwise(allPts);
+ return allPts;
+ };
+
+ /**
+ * Works out the grid cells covered by an AoE link by building the page's grid-cell centres and calling the
+ * shape-specific function for link.aoeType (cone types, square, wall, circle types, otherwise line). For a
+ * fixed-radius line the control point is first moved to the set length along the same bearing. Pixels throughout.
+ *
+ * @param {object} link - AoE link from state (aoeType, radius, coneWidth, minGridArea, aoeFloat, forceIntersection,
+ * pageID, ...).
+ * @param {object} oTok - Roll20 graphic for the origin token.
+ * @param {pt} originPtPx - Origin point.
+ * @param {pt} controlPtPx - Control point.
+ * @param {number} offsetX - Origin x offset from the token centre.
+ * @param {number} offsetY - Origin y offset from the token centre.
+ * @param {number} pageGridIncrement - Page snapping_increment.
+ * @param {number} pageScaleNumber - Page scale_number.
+ * @param {object} [wallParams] - From getWallParams; needed only for walls.
+ * @returns {{xVals:number[], yVals:number[]}|undefined} Pixel centres of the covered cells.
+ */
+ const getPathLocations = function (
+ link,
+ oTok,
+ originPtPx,
+ controlPtPx,
+ offsetX,
+ offsetY,
+ pageGridIncrement,
+ pageScaleNumber,
+ wallParams,
+ ) {
+ let locationsArr; //return value - array of pts (x & y)
+ let coneDirection = 0; //angle (in degrees) between originPt & controlPt
+ let rad = 0; //radius of effect in pixels
+
+ //find center coords of each token
+ const oHeight = oTok.get('height');
+ const oWidth = oTok.get('width');
+
+ const page = getObj('page', link.pageID);
+ const pageGridCenters = [];
+ const pageWidth = page.get('width');
+ const pageHeight = page.get('height');
+
+ for (let i = 0 - pageWidth; i < 2 * pageWidth * (1 / pageGridIncrement); i++) {
+ for (let j = 0 - pageHeight; j < 2 * pageHeight * (1 / pageGridIncrement); j++) {
+ pageGridCenters.push(
+ new pt(
+ 35 * pageGridIncrement + i * 70 * pageGridIncrement,
+ 35 * pageGridIncrement + j * 70 * pageGridIncrement,
+ ),
+ );
+ }
+ }
+ if (page) {
+ const originPtU = convertPtPixels2Units(originPtPx, pageGridIncrement);
+ let controlPtU = convertPtPixels2Units(controlPtPx, pageGridIncrement);
+
+ //add error handling. TBD
+ //etc..
+
+ switch (link.aoeType) {
+ case '5econe':
+ //Fall through
+ case 'PFcone':
+ //Fall through
+ case 'cone':
+ //use pt in pixels for cones (higher resolution needed)
+ coneDirection = getAngle2ControlToken(originPtPx, controlPtPx);
+ if (link.radius === 'variable') {
+ rad = distBetweenPts(originPtPx, controlPtPx);
+ } else {
+ rad = link.radius;
+ }
+ locationsArr = getConeLocations(
+ pageGridCenters,
+ link.aoeType,
+ link.minGridArea,
+ originPtPx,
+ coneDirection,
+ link.coneWidth,
+ rad,
+ pageGridIncrement,
+ pageScaleNumber,
+ offsetX,
+ offsetY,
+ oWidth,
+ oHeight,
+ );
+ break;
+ case 'square':
+ rad = link.radius;
+ if (link.radius === 'variable' && !link.aoeFloat) {
+ rad = Math.max(Math.abs(originPtPx.x - controlPtPx.x), Math.abs(originPtPx.y - controlPtPx.y));
+ }
+ locationsArr = getSquareLocations(
+ pageGridCenters,
+ link.minGridArea,
+ originPtPx,
+ rad,
+ pageGridIncrement,
+ offsetX,
+ offsetY,
+ );
+ break;
+ case 'wall':
+ //walls are a bit different. We passed wallParams into this function, which includes radius, so we don't have to calculate again.
+ locationsArr = getWallLocations(pageGridCenters, link.minGridArea, wallParams, pageGridIncrement);
+ break;
+ case 'PFcircle':
+ //Fall through
+ case 'circle':
+ rad = link.radius;
+ if (link.radius === 'variable' && !link.aoeFloat) {
+ rad = distBetweenPts(originPtPx, controlPtPx);
+ }
+ locationsArr = getCircleLocations(
+ pageGridCenters,
+ link.aoeType,
+ link.minGridArea,
+ originPtPx,
+ rad,
+ pageGridIncrement,
+ pageScaleNumber,
+ offsetX,
+ offsetY,
+ link.forceIntersection,
+ );
+ break;
+ case 'line':
+ // falls through: 'line' is the default
+ default:
+ if (link.radius !== 'variable') {
+ //if pre-defined radius, recalculate the controlPt (originPt remains the same)
+ const direction = getAngle2ControlToken(originPtPx, controlPtPx);
+
+ controlPtPx = getNewControlPt(originPtPx, link.radius, direction, pageGridIncrement);
+ controlPtU = convertPtPixels2Units(controlPtPx, pageGridIncrement);
+ }
+ locationsArr = getLineLocations(originPtU.x, originPtU.y, controlPtU.x, controlPtU.y, pageGridIncrement);
+ break;
+ }
+
+ return locationsArr;
+ }
+ };
+
+ /**
+ * Finds the index of a point in an array of points by exact x/y equality.
+ * Used to record which candidate origin point an AoE is currently using.
+ *
+ * @param {pt[]} arr - Array of points to search.
+ * @param {pt} pt - Point to look for.
+ * @returns {number} Index of the first matching point, or -1 if not found.
+ */
+ const getIndexFromPtArray = function (arr, pt) {
+ for (let i = 0; i < arr.length; i++) {
+ if (arr[i].x === pt.x && arr[i].y === pt.y) {
+ return i;
+ }
+ }
+ return -1;
+ };
+
+ // ————— MAP UPDATES —————
+ /**
+ * Redraws every AoE in the given link set: deletes the old grid-square and outline paths, recalculates the origin
+ * point (optionally re-picking the nearest corner/face), creates new Roll20 paths for the affected cells and the true
+ * outline, then saves the new path IDs, origin index and bounding box to state via updateAoELink.
+ *
+ * @param {{links: object[], indices: number[]}} aoeLinks - Links (and their state indices) returned by getAoELinks.
+ * @param {boolean} [recalcNearest=true] - Re-compute candidate origin points and pick the nearest; false reuses the
+ * stored originIndex.
+ * @param {number} [offsetX=0] - X offset (px) of the origin from the origin token centre; overwritten when the
+ * nearest origin is recalculated.
+ * @param {number} [offsetY=0] - Y offset (px) of the origin from the origin token centre; overwritten when the
+ * nearest origin is recalculated.
+ * @returns {Promise} Resolves once all paths have been created and state updated.
+ */
+ async function updateMapWithAoE(aoeLinks, recalcNearest = true, offsetX = 0, offsetY = 0) {
+ let pathLocations;
+ let newPaths = [];
+ let pageGridIncrement;
+ let rad;
+
+ //delete the linked paths and clear the pathIDs array from the state object
+
+ //do for each matching link in state object
+ for (let a = 0; a < aoeLinks.indices.length; a++) {
+ newPaths = [];
+
+ //generate new paths based on aoeType and current posiitons or originTok and controlTok
+ const oTok = getObj('graphic', aoeLinks.links[a].originTokID);
+ const oHeight = oTok.get('height');
+ const oWidth = oTok.get('width');
+
+ const cTok = getObj('graphic', aoeLinks.links[a].controlTokID);
+
+ let originPtPx = new pt(oTok.get('left'), oTok.get('top')); //the origin coords of the effect (in pixels) - this may be different than the origin token center
+ const controlPtPx = new pt(cTok.get('left'), cTok.get('top')); //the coords of the control pt of the effect (in pixels) - this may be different than the control token center
+
+ const pageID = aoeLinks.links[a].pageID;
+ const page = getObj('page', pageID);
+
+ //physically delete existing paths
+ deleteLinkedPaths(aoeLinks.links[a].pathIDs);
+
+ if (
+ aoeLinks.links[a].aoeFloat === true &&
+ (aoeLinks.links[a].aoeType === 'square' ||
+ aoeLinks.links[a].aoeType === 'circle' ||
+ aoeLinks.links[a].aoeType === 'PFcircle')
+ ) {
+ originPtPx.x = controlPtPx.x;
+ originPtPx.y = controlPtPx.y;
+ }
+
+ if (page && recalcNearest) {
+ let oX, oY;
+ pageGridIncrement = page.get('snapping_increment');
+ pageScaleNumber = page.get('scale_number');
+
+ //possibly shift origin pt
+ if (
+ aoeLinks.links[a].originType.match(/nearest/i) &&
+ (aoeLinks.links[a].aoeType.match(/cone/i) ||
+ (aoeLinks.links[a].aoeType.match(/wall/i) && aoeLinks.links[a].forceIntersection === false))
+ ) {
+ const possibleOrigins = getPossibleOriginPts(
+ originPtPx,
+ oHeight,
+ oWidth,
+ pageGridIncrement,
+ aoeLinks.links[a].originType.match(/face/i),
+ );
+ aoeLinks.links[a].originPts = possibleOrigins.map((x) => x);
+
+ oX = originPtPx.x;
+ oY = originPtPx.y;
+ originPtPx = getNearestOriginPt(
+ controlPtPx,
+ originPtPx.x - oWidth / 2,
+ originPtPx.y - oHeight / 2,
+ originPtPx.x + oWidth / 2,
+ originPtPx.y + oHeight / 2,
+ possibleOrigins,
+ );
+ aoeLinks.links[a].originIndex = getIndexFromPtArray(possibleOrigins, originPtPx);
+
+ offsetX = originPtPx.x - oX;
+ offsetY = originPtPx.y - oY;
+ } else {
+ aoeLinks.links[a].originPts = [originPtPx];
+ aoeLinks.links[a].originIndex = 0;
+ }
+ } else if (page) {
+ pageGridIncrement = page.get('snapping_increment');
+ pageScaleNumber = page.get('scale_number');
+ //use the originPt found in state object link
+ originIndex = aoeLinks.links[a].originIndex;
+ originPtPx = aoeLinks.links[a].originPts[originIndex];
+ }
+
+ //walls are handled a bit differently. We're going to pre-calculate some parameters, which get used twice: once for grid area paths and once for aoe outline path
+ let wallParams;
+ if (aoeLinks.links[a].aoeType === 'wall') {
+ wallParams = getWallParams(originPtPx, controlPtPx, aoeLinks.links[a].radius, aoeLinks.links[a].wallWidth);
+ }
+
+ if (aoeLinks.links[a].outlineOnly === false) {
+ //This function returns the coords of the grid area paths (the individual squares)
+ pathLocations = getPathLocations(
+ aoeLinks.links[a],
+ oTok,
+ originPtPx,
+ controlPtPx,
+ offsetX,
+ offsetY,
+ pageGridIncrement,
+ pageScaleNumber,
+ wallParams,
+ );
+
+ //define the bounding box of affected grid squares
+ const ptUL = new pt(
+ Math.min(...pathLocations.xVals) - 35 * pageGridIncrement,
+ Math.min(...pathLocations.yVals) - 35 * pageGridIncrement,
+ );
+ const ptUR = new pt(
+ Math.max(...pathLocations.xVals) + 35 * pageGridIncrement,
+ Math.min(...pathLocations.yVals) - 35 * pageGridIncrement,
+ );
+ const ptLR = new pt(
+ Math.max(...pathLocations.xVals) + 35 * pageGridIncrement,
+ Math.max(...pathLocations.yVals) + 35 * pageGridIncrement,
+ );
+ const ptLL = new pt(
+ Math.min(...pathLocations.xVals) - 35 * pageGridIncrement,
+ Math.max(...pathLocations.yVals) + 35 * pageGridIncrement,
+ );
+ aoeLinks.links[a].boundingBox = [ptUL, ptUR, ptLR, ptLL];
+
+ let path;
+ let pathstring;
+
+ for (let p = 0; p < pathLocations.xVals.length; p++) {
+ pathstring = buildSquarePath(35 * pageGridIncrement);
+ path = await new Promise(function (resolve) {
+ const thePath = createPath(
+ pathstring,
+ pageID,
+ aoeLinks.links[a].aoeLayer,
+ aoeLinks.links[a].aoeColor,
+ aoeLinks.links[a].gridColor,
+ 2,
+ 70 * pageGridIncrement,
+ 70 * pageGridIncrement,
+ pathLocations.xVals[p],
+ pathLocations.yVals[p],
+ );
+ resolve(thePath);
+ });
+ newPaths.push(path.get('_id'));
+ }
+ }
+
+ //create a path with the true outline of the AoE
+ if (aoeLinks.links[a].aoeType === '5econe') {
+ //
+ const coneDirection = getAngle2ControlToken(originPtPx, controlPtPx);
+ if (aoeLinks.links[a].radius === 'variable') {
+ rad = distBetweenPts(originPtPx, controlPtPx);
+ } else {
+ rad = aoeLinks.links[a].radius;
+ }
+ const coneWidth = 53.14; //hardcode
+ const z = rad / (2 * Math.sin(Math.atan(0.5))) - rad;
+
+ pathstring = build5eCone(rad, z, coneWidth, coneDirection);
+ path = await new Promise(function (resolve) {
+ const thePath = createPath(
+ pathstring,
+ pageID,
+ aoeLinks.links[a].aoeLayer,
+ 'transparent',
+ aoeLinks.links[a].aoeOutlineColor,
+ 3,
+ rad * 2,
+ rad * 2,
+ originPtPx.x - z,
+ originPtPx.y - z,
+ );
+ resolve(thePath);
+ });
+ newPaths.push(path.get('_id'));
+ } else if (aoeLinks.links[a].aoeType === 'cone' || aoeLinks.links[a].aoeType === 'PFcone') {
+ //
+ const coneDirection = getAngle2ControlToken(originPtPx, controlPtPx);
+ if (aoeLinks.links[a].radius === 'variable') {
+ rad = distBetweenPts(originPtPx, controlPtPx);
+ } else {
+ rad = aoeLinks.links[a].radius;
+ }
+ pathstring = buildCirclePath(rad, aoeLinks.links[a].coneWidth, coneDirection);
+ path = await new Promise(function (resolve) {
+ const thePath = createPath(
+ pathstring,
+ pageID,
+ aoeLinks.links[a].aoeLayer,
+ 'transparent',
+ aoeLinks.links[a].aoeOutlineColor,
+ 3,
+ rad * 2,
+ rad * 2,
+ originPtPx.x,
+ originPtPx.y,
+ );
+ resolve(thePath);
+ });
+ newPaths.push(path.get('_id'));
+ } else if (aoeLinks.links[a].aoeType === 'square') {
+ //
+ rad = aoeLinks.links[a].radius;
+ if (aoeLinks.links[a].radius === 'variable' && !aoeLinks.links[a].aoeFloat) {
+ rad = Math.max(Math.abs(originPtPx.x - controlPtPx.x), Math.abs(originPtPx.y - controlPtPx.y));
+ }
+
+ pathstring = buildSquarePath(rad);
+ path = await new Promise(function (resolve) {
+ const thePath = createPath(
+ pathstring,
+ pageID,
+ aoeLinks.links[a].aoeLayer,
+ 'transparent',
+ aoeLinks.links[a].aoeOutlineColor,
+ 3,
+ rad * 2,
+ rad * 2,
+ originPtPx.x,
+ originPtPx.y,
+ );
+ resolve(thePath);
+ });
+ newPaths.push(path.get('_id'));
+ } else if (aoeLinks.links[a].aoeType === 'circle' || aoeLinks.links[a].aoeType === 'PFcircle') {
+ //
+ if (aoeLinks.links[a].radius === 'variable') {
+ rad = distBetweenPts(originPtPx, controlPtPx);
+ } else {
+ rad = aoeLinks.links[a].radius;
+ }
+ pathstring = buildCirclePath(rad, 360, 0);
+ path = await new Promise(function (resolve) {
+ const thePath = createPath(
+ pathstring,
+ pageID,
+ aoeLinks.links[a].aoeLayer,
+ 'transparent',
+ aoeLinks.links[a].aoeOutlineColor,
+ 3,
+ rad * 2,
+ rad * 2,
+ originPtPx.x,
+ originPtPx.y,
+ );
+ resolve(thePath);
+ });
+ newPaths.push(path.get('_id'));
+ } else if (aoeLinks.links[a].aoeType === 'wall') {
+ //
+ pathstring = buildWallPath(wallParams);
+ path = await new Promise(function (resolve) {
+ const thePath = createPath(
+ pathstring,
+ pageID,
+ aoeLinks.links[a].aoeLayer,
+ 'transparent',
+ aoeLinks.links[a].aoeOutlineColor,
+ 3,
+ wallParams.heightBB,
+ wallParams.widthBB,
+ wallParams.pCenter.x,
+ wallParams.pCenter.y,
+ );
+ resolve(thePath);
+ });
+ newPaths.push(path.get('_id'));
+ }
+
+ //Update the State object with new paths, originIndex, and bounding box array
+ updateAoELink(aoeLinks.indices[a], newPaths, aoeLinks.links[a].originIndex, aoeLinks.links[a].boundingBox);
+ }
+ }
+
+ /**
+ * Moves a token to the nearest grid intersection on its page, then notifies observers of the change.
+ * Used for floating AoEs whose control token must sit on grid lines (forceIntersection).
+ *
+ * @param {object} tok - Roll20 graphic (token) to snap; its left/top are updated.
+ */
+ const snapToIntersection = function (tok) {
+ const pageID = tok.get('_pageid');
+ const page = getObj('page', pageID);
+ const pageGridIncrement = page.get('snapping_increment');
+ const pageWidthPx = page.get('width') * 70;
+ const pageHeightPx = page.get('height') * 70;
+
+ const intersections = [];
+ for (let i = 0; i <= pageWidthPx * (1 / pageGridIncrement); i += 70 * pageGridIncrement) {
+ for (let j = 0; j <= pageHeightPx * (1 / pageGridIncrement); j += 70 * pageGridIncrement) {
+ intersections.push(new pt(i, j));
+ }
+ }
+ const tokPt = new pt(tok.get('left'), tok.get('top'));
+ const newPt = getClosestGridPt(tokPt, intersections, pageGridIncrement);
+
+ //grab the previous token settings before setting new ones
+ const prevTok = JSON.parse(JSON.stringify(tok));
+ //set new tok settings
+ tok.set({ left: newPt.x, top: newPt.y });
+ //trigger tokenChange event for other api scripts
+ notifyObservers('tokenChange', tok, prevTok);
+ };
+
+ // ————— EVENT HANDLERS —————
+ //Move DL path to remain under source token
+ /**
+ * 'change:graphic' handler. When a token linked to an AoE is moved, optionally snaps it to a grid intersection,
+ * plays the move sound and redraws the linked AoE(s) via updateMapWithAoE. Ignores changes that do not alter
+ * left/top.
+ *
+ * @param {object} obj - Roll20 graphic (token) after the change.
+ * @param {object} prev - Previous attribute values of the graphic (plain object).
+ * @returns {Promise}
+ */
+ async function smartAoE_handleTokenChange(obj, prev) {
+ //find all paths linked to token, returns an array of aoeLinks objects or undefined
+ //aoeLinks object looks like {links:[{aoeType, originTokID, controlTokID, pathIDs[], pageID}], indices:[]}
+
+ //only trigger when token is moved
+ if (obj.get('left') === prev.left && obj.get('top') === prev.top) {
+ return;
+ }
+
+ const tokID = obj.get('id');
+ const aoeLinks = getAoELinks(tokID);
+
+ //potentially force the AoEControlToken to snap to grid intersection for floating AoE squares
+ let tempLinks = [];
+ if (aoeLinks) {
+ tempLinks = aoeLinks.links.filter((link) => {
+ return (
+ link.forceIntersection &&
+ (link.controlTokID === tokID || (link.originTokID === tokID && obj.get('name') === 'AoEControlToken'))
+ );
+ });
+ }
+
+ if (tempLinks.length > 0) {
+ snapToIntersection(obj);
+ }
+
+ if (aoeLinks && obj) {
+ //optional sound fx during move
+ const trackName = aoeLinks.links[0].moveSound;
+ if (trackName !== '') {
+ whisperString = aoeLinks.links[0].whisperString || '';
+ playJukeboxTrack(trackName, whisperString);
+ }
+ updateMapWithAoE(aoeLinks);
+ }
+ }
+
+ /**
+ * Tears down every AoE linked to a token: removes any turn-tracker entry for it, deletes the linked paths,
+ * splices the links out of state, removes associated control tokens (and the second token spawned for walls),
+ * and plays the delete sound.
+ *
+ * @param {object} obj - Roll20 graphic (token) being removed; may be an origin or control token.
+ */
+ const deleteAoELinksAndControlToks = function (obj) {
+ const controlToks = [];
+ const tokID = obj['id'];
+
+ //Possibly remove a value from the turn tracker
+ modifyTurnTracker('remove', '', '', '', tokID, '');
+
+ const aoeLinks = getAoELinks(obj.get('id'));
+
+ if (aoeLinks) {
+ //aoeLinks.indices.forEach((index) => {
+ for (let i = aoeLinks.indices.length - 1; i > -1; i--) {
+ //delete the linked paths and clear the the associated links arrays from the state object
+ const stateLinkIndex = aoeLinks.indices[i];
+ deleteLinkedPaths(state[scriptName].links[stateLinkIndex].pathIDs);
+
+ //If deleted token is a source token, remove all control tokens associated with that source token
+ if (tokID === aoeLinks.links[i].originTokID && tokID !== aoeLinks.links[i].controlTokID) {
+ controlToks.push(aoeLinks.links[i].controlTokID);
+ }
+
+ //for walls (for which we spawned *two* controlToks), delete the "origin tok" also
+ if (aoeLinks.links[i].aoeType.match(/wall/i)) {
+ controlToks.push(aoeLinks.links[i].originTokID);
+ }
+
+ state[scriptName].links.splice(stateLinkIndex, 1);
+ }
+ controlToks.forEach((id) => {
+ removeToken(id);
+ });
+ const trackName = aoeLinks.links[0].deleteSound;
+ if (trackName !== '') {
+ const whisperString = aoeLinks.links[0].whisperString || '';
+ playJukeboxTrack(trackName, whisperString);
+ }
+ }
+ };
+
+ /**
+ * 'destroy:graphic' handler. Cleans up any AoE links, paths and control tokens tied to the deleted token.
+ *
+ * @param {object} obj - Roll20 graphic (token) that was destroyed.
+ */
+ const smartAoE_handleRemoveToken = function (obj) {
+ deleteAoELinksAndControlToks(obj);
+ };
+
+ // ————— ROLL PARSING —————
+ //returns character object for given name
+ /**
+ * Looks up a character in the journal by name (case-insensitive). Used to find the character whose default
+ * token is spawned as the AoE control token.
+ *
+ * @param {string} charName - Character name to find.
+ * @returns {object|undefined} First matching Roll20 character, or undefined if none.
+ */
+ const getCharacterFromName = function (charName) {
+ const character = findObjs(
+ {
+ _type: 'character',
+ name: charName,
+ },
+ { caseInsensitive: true },
+ )[0];
+ return character;
+ };
+
+ //libInline HTML-encodes roll expressions (e.g. "*" -> "*", "{" -> "{", "<" -> "<").
+ //Decode them all before re-rolling, otherwise [[2*13d6]] was re-rolled as [[2*13d6]] = 2.
+ /**
+ * Decodes the HTML entities libInline puts into roll expressions (e.g. * for *) so the expression can be
+ * re-rolled correctly; without this [[2*13d6]] would be re-rolled as [[2*13d6]].
+ *
+ * @param {string} expr - Encoded inline-roll expression (falsy values are treated as an empty string).
+ * @returns {string} Decoded expression.
+ */
+ const decodeInlineExpression = function (expr) {
+ return String(expr || '')
+ .replace(/([0-9a-f]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16)))
+ .replace(/(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10)))
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/&/g, '&')
+ .replace(/\uFF1C/g, '<');
+ };
+
+ /**
+ * Replaces each $[[n]] placeholder in a string with that roll's total from libInline data, and collects the
+ * roll objects that were used.
+ *
+ * @param {string} str - Text containing $[[n]] placeholders.
+ * @param {object[]} parsedInlines - Inline roll data from libInline.getRollData.
+ * @returns {{text: string, inlines: object[]}} Substituted text and the inline rolls it referenced.
+ */
+ const substituteInlineRolls = function (str, parsedInlines) {
+ let index;
+ const inlineArr = [];
+ const reg = /\$\[\[\d+\]\]/g;
+
+ const foundInlines = str.match(reg) || [];
+
+ foundInlines.forEach((inline) => {
+ index = inline.replace(/\$\[\[/, '').replace(/\]\]/, '');
+ str = str.replace(inline, parsedInlines[index].total);
+ inlineArr.push(parsedInlines[index]);
+ });
+ return { text: str, inlines: inlineArr };
+ };
+
+ /**
+ * Parses a !smartaoe chat message into "--cmd|value" arguments, flattening {{ }} wrappers and line breaks and
+ * substituting inline-roll totals. Mutates msg.content as a side effect.
+ *
+ * @param {object} msg - Roll20 chat message.
+ * @returns {{cmd: string, params: string, inlines: object[]}[]} One entry per argument; cmd is lower-cased.
+ */
+ const parseArgs = function (msg) {
+ msg.content = msg.content.replace(/ \n/g, ' ').replace(/(\{\{(.*?)\}\})/g, ' $2 ');
+
+ const parsedInlines = libInline.getRollData(msg) || [];
+
+ //Check for inline rolls
+ let args = msg.content.split(/\s+--/);
+ args.shift();
+ args = args.map((arg) => {
+ const cmds = arg.split('|');
+ const retVals = substituteInlineRolls(cmds[1], parsedInlines);
+
+ return {
+ cmd: cmds.shift().toLowerCase().trim(),
+ params: retVals.text,
+ inlines: retVals.inlines,
+ };
+ });
+ return args;
+ };
+
+ /**
+ * Turns a value read from a character sheet into text. Beacon sheets can return numbers, booleans (true/false
+ * become '1'/'0' so they work in roll formulas) and arrays (joined with commas).
+ *
+ * @param {*} value - Value from getSheetItem or an attribute's 'current'.
+ * @returns {string} The value as text ('' when missing).
+ */
+ const sheetValueToString = function (value) {
+ if (value === null || value === undefined) return '';
+ if (typeof value === 'boolean') return value ? '1' : '0';
+ if (Array.isArray(value)) return value.map(sheetValueToString).join(',');
+ if (typeof value === 'object') return '';
+ return String(value);
+ };
+
+ /**
+ * Reads a character sheet item with getSheetItem, which also sees Beacon (e.g. D&D 2024) computed properties that
+ * findObjs and getAttrByName miss. Tries the name as given, then the Beacon custom-attribute form "user."
+ * only if the sheet has no such item: an item that exists but is empty (e.g. an NPC with no resistances) stops
+ * there, because Roll20 logs an error to the API console for every missing user.* attribute.
+ *
+ * @param {string} charID - Roll20 character ID.
+ * @param {string} attrName - Attribute or computed property name.
+ * @param {string} [type='current'] - 'current' or 'max'.
+ * @returns {Promise<{name: string, value: *}|undefined>} The name that matched and its value, or undefined if
+ * getSheetItem is unavailable or nothing matched (or the value is empty).
+ */
+ const getSheetValue = async function (charID, attrName, type = 'current') {
+ if (typeof getSheetItem !== 'function') return undefined;
+ for (const name of [attrName, `user.${attrName}`]) {
+ let value;
+ try {
+ value = await getSheetItem(charID, name, type);
+ } catch (err) {
+ continue; //not found on this sheet; try the next name
+ }
+ if (value === '') return undefined;
+ if (value !== null && value !== undefined) return { name, value };
+ }
+ return undefined;
+ };
+
+ /**
+ * Reads one value of a character's attribute: the legacy attribute object if there is one (case-insensitive name),
+ * otherwise the sheet item (Beacon sheets). An empty legacy attribute doesn't hide a sheet item: Beacon characters
+ * can carry leftover legacy attributes (e.g. from a 2014 sheet or another mod) with the same name.
+ *
+ * @param {string} charID - Roll20 character ID.
+ * @param {string} attrName - Attribute name.
+ * @param {string} [type='current'] - 'current' or 'max'.
+ * @returns {Promise<{attr: object|undefined, sheetName: string|undefined, raw: string}|undefined>} The legacy
+ * attribute or the sheet item name that matched, and the value as text; undefined if neither exists.
+ */
+ const readCharacterValue = async function (charID, attrName, type = 'current') {
+ const attr = findObjs(
+ {
+ _type: 'attribute',
+ name: attrName,
+ _characterid: charID,
+ },
+ { caseInsensitive: true },
+ )[0];
+ const legacyRaw = attr ? sheetValueToString(attr.get(type)) : '';
+ if (attr && legacyRaw !== '') return { attr, sheetName: undefined, raw: legacyRaw };
+ const sheetValue = await getSheetValue(charID, attrName, type);
+ if (sheetValue) return { attr: undefined, sheetName: sheetValue.name, raw: sheetValueToString(sheetValue.value) };
+ if (attr) return { attr, sheetName: undefined, raw: legacyRaw };
+ return undefined;
+ };
+
+ //replaceAttributes and processTokenRolls modified from Jakob's group-check script
+ /**
+ * Recursively expands @{attribute} references in a roll formula using the given character's attribute values.
+ * Reads through getSheetItem first (works for legacy and Beacon sheets), then getAttrByName (legacy sheet
+ * defaults). Missing attributes become empty strings.
+ *
+ * @param {string} formula - Roll formula containing @{attrName} references.
+ * @param {string} charID - Roll20 character ID to read attributes from.
+ * @returns {Promise} Formula with all attribute references replaced.
+ */
+ const replaceAttributes = async function (formula, charID) {
+ const myGetAttrByName = async (attrName) => {
+ const sheetValue = await getSheetValue(charID, attrName);
+ if (sheetValue) return sheetValueToString(sheetValue.value);
+ return sheetValueToString(getAttrByName(charID, attrName));
+ };
+
+ //a pass replaces every reference; repeat while values themselves contain references
+ while (/@{(.*?)}/.test(formula)) {
+ const names = [...new Set([...formula.matchAll(/@{(.*?)}/g)].map((m) => m[1]))];
+ const values = {};
+ for (const name of names) values[name] = await myGetAttrByName(name);
+ formula = formula.replace(/@{(.*?)}/g, (_, attrName) => values[attrName]);
+ }
+ return formula;
+ };
+
+ /**
+ * Sorts target entries by layer name in descending order, so "objects" targets come before "gmlayer" ones
+ * (keeps hidden-name numbering from leaking GM-layer targets). Sorts in place.
+ *
+ * @param {object[]} tokenRolls - Target entries from processTokenRolls (each with a layer property).
+ * @returns {object[]} The same array, sorted.
+ */
+ const sortTokenRollsByLayer = function (tokenRolls) {
+ //sorts in descending order ("objects", then "gmlayer")
+ tokenRolls.sort((a, b) => {
+ const layerA = a.layer.toLowerCase(),
+ layerB = b.layer.toLowerCase();
+
+ if (layerA > layerB) {
+ return -1;
+ }
+ if (layerA < layerB) {
+ return 1;
+ }
+ return 0;
+ });
+ return tokenRolls;
+ };
+
+ /**
+ * Removes the damage types that only apply to nonmagical attacks from a resistance, vulnerability or immunity list
+ * (e.g. 2014 stat blocks' "bludgeoning, piercing, and slashing from nonmagical attacks that aren't silvered"). AoEs
+ * are treated as magical, as for Magic Resistance, so these mustn't match a bludgeoning AoE.
+ *
+ * @param {string} text - Lower-cased damage type list.
+ * @returns {string} The list without the nonmagical damage types.
+ */
+ const removeNonmagicalDamage = function (text) {
+ //"bludgeoning, piercing, and slashing", "bludgeoning or piercing", ...
+ const physical = '(?:(?:bludgeoning|piercing|slashing)(?:\\s*,\\s*(?:and\\s+|or\\s+)?|\\s+(?:and|or)\\s+)?)+';
+ return text
+ .replace(new RegExp(`${physical}\\s*(?:damage\\s+)?from\\s+non-?magical[^;]*`, 'g'), '')
+ .replace(new RegExp(`non-?magical\\s+${physical}[^;]*`, 'g'), '');
+ };
+
+ /**
+ * Checks a Beacon (D&D 2024) sheet's "store" attribute for an enabled Magic Resistance feature. Only feature names
+ * are checked: the whole store also holds item and spell text, which can mention magic resistance.
+ *
+ * @param {*} store - The current value of the character's "store" attribute (an object on Beacon sheets).
+ * @returns {boolean} True if the sheet has an enabled feature named Magic Resistance.
+ */
+ const hasBeaconMagicResistance = function (store) {
+ let data = store;
+ if (typeof data === 'string') {
+ try {
+ data = JSON.parse(data);
+ } catch (err) {
+ return false;
+ }
+ }
+ const integrants = data?.integrants?.integrants;
+ if (!integrants || typeof integrants !== 'object') return false;
+ return Object.values(integrants).some(
+ (item) =>
+ item &&
+ item._enabled !== false &&
+ typeof item.name === 'string' &&
+ item.name.toLowerCase().includes('magic resistance'),
+ );
+ };
+
+ /**
+ * Builds the saving-throw entry for one target token: reads its resistances, vulnerabilities and immunities from the
+ * link's configured attributes, detects Magic Resistance (roll twice), and expands the save formula with the
+ * character's attributes ([[0]] when the AoE has no save). Attributes not found as legacy attribute objects are
+ * read from the sheet with getSheetItem (Beacon sheets); Magic Resistance is read from a Beacon sheet's features.
+ *
+ * @param {object} token - Roll20 graphic (token) hit by the AoE.
+ * @param {object} link - AoE link from state.
+ * @returns {Promise} Target entry (pic, name, layer, saveName, DC, roll2x, formula, id, RVI strings),
+ * or null if the token has no character.
+ */
+ const processTokenRolls = async function (token, link) {
+ const charID = token.get('represents'),
+ character = getObj('character', charID),
+ displayName = token.get('name');
+ const tokenPic = token.get('imgsrc').replace(/(?:max|original|med)\.png/, 'thumb.png');
+ const layer = token.get('layer');
+
+ const attrs = findObjs(
+ {
+ _type: 'attribute',
+ _characterid: charID,
+ },
+ { caseInsensitive: true },
+ );
+
+ let resistances = '';
+ let vulnerabilities = '';
+ let immunities = '';
+ let roll2x = false;
+
+ /**
+ * Joins the lower-cased values of the named attributes: legacy attribute objects where they have a value,
+ * otherwise the sheet item of that name (an empty leftover legacy attribute must not hide a Beacon sheet's value).
+ *
+ * @param {string[]} attrNames - Attribute names configured on the link (e.g. resistAttrs).
+ * @returns {Promise} The values joined, '' when none are found.
+ */
+ const readDamageAttrs = async (attrNames) => {
+ const values = [];
+ for (const attrName of attrNames) {
+ const legacyValues = (attrs || [])
+ .filter((att) => att.get('name') === attrName)
+ .map((e) => sheetValueToString(e.get('current')).toLowerCase())
+ .filter((v) => v !== '');
+ if (legacyValues.length > 0) {
+ values.push(...legacyValues);
+ } else {
+ const sheetValue = await getSheetValue(charID, attrName);
+ if (sheetValue) values.push(sheetValueToString(sheetValue.value).toLowerCase());
+ }
+ }
+ return values.map(removeNonmagicalDamage).join('');
+ };
+
+ if (!character) return null;
+
+ resistances = await readDamageAttrs(link.resistAttrs);
+ vulnerabilities = await readDamageAttrs(link.vulnerableAttrs);
+ immunities = await readDamageAttrs(link.immunityAttrs);
+
+ if (attrs) {
+ //Magic Resistance (likely 5e-only)
+ const tempAttrs = attrs.filter((att) => {
+ const current = att.get('current');
+ if (att.get('name') === 'store') return hasBeaconMagicResistance(current);
+ return sheetValueToString(current).toLowerCase().includes('magic resist');
+ });
+ if (tempAttrs.length > 0) {
+ roll2x = true;
+ }
+ }
+
+ let computedFormula;
+ if (link.noSave === true) {
+ computedFormula = '[[0]]';
+ } else {
+ computedFormula = await replaceAttributes(link.saveFormula, charID);
+ }
+
+ return {
+ pic: tokenPic,
+ name: displayName,
+ layer: layer,
+ //"roll2": (ro === "roll2"),
+ saveName: link.saveName,
+ DC: link.DC,
+ //"damage": damage,
+ roll2x: roll2x,
+ formula: computedFormula,
+ id: token.id,
+ resistances: resistances,
+ vulnerabilities: vulnerabilities,
+ immunities: immunities,
+ };
+ };
+
+ // Format inline rolls
+ /**
+ * Recursively sums the raw dice values in a Roll20 roll result, ignoring dropped dice and non-dice terms.
+ *
+ * @param {object} roll - Roll20 roll result node (type V, G or R).
+ * @returns {number} Sum of kept dice results (0 for other node types).
+ */
+ const extractDiceRoll = (roll) => {
+ if (roll.type === 'V' && roll.rolls) {
+ return roll.rolls.map(extractDiceRoll).reduce((m, x) => m + x, 0);
+ }
+ if (roll.type === 'G' && roll.rolls) {
+ return _.flatten(roll.rolls)
+ .map(extractDiceRoll)
+ .reduce((m, x) => m + x, 0);
+ }
+ if (roll.type === 'R') {
+ return roll.results
+ .filter((x) => x.v && !x.d)
+ .map((x) => x.v)
+ .reduce((m, x) => m + x, 0);
+ } else {
+ return 0;
+ }
+ };
+
+ /**
+ * Copies roll results from sendChat callback messages into the matching rows (damage rolls or targets), adding
+ * result_n (totals), raw_n (dice only) and styled_n (inline-roll HTML) for each line n of the rolled text.
+ *
+ * @param {object[][]} messages - Message arrays returned by sendChat callbacks, one per row.
+ * @param {object[]} rows - Rows to populate, in the same order (mutated).
+ * @returns {object[]} The populated rows.
+ */
+ const attachRollResults = function (messages, rows) {
+ messages.forEach((msgList, j) => {
+ const inlinerollData = (msgList[0].inlinerolls || []).map((roll) => {
+ return {
+ raw: extractDiceRoll(roll.results),
+ result: roll.results.total || 0,
+ styled: makeInlineroll(roll, false), //convert to variable later
+ };
+ });
+ msgList[0].content.split(' ').forEach((str, n) => {
+ rows[j][`result_${n + 1}`] = [];
+ rows[j][`raw_${n + 1}`] = [];
+ rows[j][`styled_${n + 1}`] = str.replace(/\$\[\[(\d+)\]\]/g, (_, number) => {
+ rows[j][`result_${n + 1}`].push(inlinerollData[parseInt(number)].result);
+ rows[j][`raw_${n + 1}`].push(inlinerollData[parseInt(number)].raw);
+ return inlinerollData[parseInt(number)].styled;
+ });
+ });
+ });
+ return rows;
+ };
+
+ //this was modified from Jakob's group-check api script
+ /**
+ * Formats rolled damage results, then either rolls every target's save (and continues in processFinalMessages) or,
+ * if there are no targets, sends the damage-only chat card. Mutates damageRolls.
+ *
+ * @param {object[][]} messages - sendChat callback messages for the damage rolls.
+ * @param {object[]} damageRolls - Damage roll entries ({formula}) to populate.
+ * @param {object[]} tokenRolls - Target entries from processTokenRolls.
+ * @param {object} aoeLink - AoE link from state (card parameters, whisper settings, etc.).
+ */
+ const processMessagesMaster = function (messages, damageRolls, tokenRolls, aoeLink) {
+ //takes all the rolls that were processed thru the Roll20 die roller for all tokens and formats them.
+ // then, we will format the final chat output
+ let whisperString = '';
+ attachRollResults(messages, damageRolls);
+
+ if (tokenRolls.length > 0) {
+ Promise.all(
+ tokenRolls.map(
+ (o) =>
+ new Promise((resolve) => {
+ sendChat('', `${o.formula}${o.roll2x ? ` ${o.formula}` : ''}`, resolve);
+ }),
+ ),
+ )
+ .then(async (messages) => processFinalMessages(messages, damageRolls, tokenRolls, aoeLink))
+ .catch((err) => sendErrorMessage(err));
+ } else {
+ let output = buildTitle(aoeLink.cardParameters);
+ const damageOutput = buildDamageRows(damageRolls, aoeLink);
+
+ if (damageOutput) {
+ output = output + damageOutput;
+
+ const descRow = buildDescRow(aoeLink.cardParameters.descriptiontext, aoeLink.cardParameters);
+ output = output + buildTableBody('', '', descRow, aoeLink.cardParameters);
+
+ if (aoeLink.whisperAll) {
+ whisperString = aoeLink.whisperString;
+ }
+ sendChat(scriptName, `${whisperString} ${output}`);
+ }
+ }
+ };
+
+ // ————— DAMAGE & MARKERS —————
+ /**
+ * Deletes a graphic from the board if it still exists.
+ *
+ * @param {string} tokID - Roll20 graphic ID.
+ */
+ const removeToken = function (tokID) {
+ const tok = getObj('graphic', tokID);
+
+ if (tok) {
+ tok.remove();
+ }
+ };
+
+ /**
+ * Applies damage (or healing, if negative) to a token's bar(s), cascading leftover damage or healing to the next bar;
+ * damage never goes below 0 and healing never goes above max. Adds or removes the 0 HP marker, optionally removes
+ * the token at 0 HP, and otherwise updates the bars and notifies observers.
+ *
+ * @param {string} tokID - Roll20 graphic (token) ID.
+ * @param {string} name - Name to show in the result message.
+ * @param {Array} bar - Bar numbers (1-3) to apply to, in order.
+ * @param {Array} damageArr - Damage amounts to add together (negative heals).
+ * @param {string} zeroHPmarker - Status marker(s) for 0 HP (comma-separated); also recorded via
+ * rememberZeroHPMarkers.
+ * @param {boolean} [removeAtZero=false] - Remove the token when its last bar reaches 0.
+ * @returns {string} Summary such as "Applied 12 damage to Goblin" or "Healed Goblin by 5".
+ */
+ const applyDamage = function (tokID, name, bar, damageArr, zeroHPmarker, removeAtZero = false) {
+ rememberZeroHPMarkers(zeroHPmarker); //before the marker is added, so observers can recognise it
+ const tok = getObj('graphic', tokID);
+ const currentHP = [];
+ const maxHP = [];
+ const newHP = [];
+ let amountHealed;
+ let remainingDamage;
+
+ //Multiple bar values may be present. bar is an array of 1 to 3 elements. Damage will apply to them in order, cascading down if more damage remains.
+ if (bar.length > 0) {
+ bar.map((b) => {
+ currentHP.push(parseInt(tok.get(`bar${b}_value`)) || 0);
+ maxHP.push(parseInt(tok.get(`bar${b}_max`)) || 99999);
+ });
+ } else {
+ currentHP.push(0);
+ maxHP.push(0);
+ }
+
+ const damage = damageArr.map((e) => parseInt(e)).reduce((a, b) => a + b);
+
+ if (damage >= 0) {
+ //damage can't reduce below 0
+ //first bar value
+ newHP.push(Math.max(currentHP[0] - damage, 0));
+ if (damage > currentHP[0] && bar.length > 1) {
+ remainingDamage = damage - currentHP[0];
+ //second bar value
+ newHP.push(Math.max(currentHP[1] - remainingDamage, 0));
+ if (remainingDamage > currentHP[1] && bar.length > 2) {
+ remainingDamage = remainingDamage - currentHP[1];
+ //third bar value
+ newHP.push(Math.max(currentHP[2] - remainingDamage, 0));
+ }
+ }
+ returnString = `Applied ${damage} damage to ${name}`;
+ } else {
+ //negative damage = healing! Can't heal above max.
+ //Heal the first bar up to its max, then cascade only the leftover healing to the next bar.
+ //A bar already above its max (e.g. temp HP) is left as-is rather than reduced to max.
+ let remainingHeal = Math.abs(damage);
+ amountHealed = 0;
+ for (let i = 0; i < currentHP.length; i++) {
+ if (i > 0 && remainingHeal <= 0) {
+ break;
+ }
+ const healed = Math.min(remainingHeal, Math.max(maxHP[i] - currentHP[i], 0));
+ newHP.push(currentHP[i] + healed);
+ amountHealed += healed;
+ remainingHeal -= healed;
+ }
+ //newHP and maxHP are arrays, so compare bar by bar (newHP===maxHP was never true)
+ const atMax = newHP.every((hp, idx) => hp >= maxHP[idx]);
+ returnString = atMax ? `Healed ${name} by ${amountHealed} (@Max)` : `Healed ${name} by ${amountHealed}`;
+ }
+
+ if (zeroHPmarker && newHP[bar.length - 1] === 0) {
+ addStatusMarkers(tokID, zeroHPmarker);
+ } else if (zeroHPmarker && newHP[bar.length - 1] > 0) {
+ removeStatusMarkers(tokID, zeroHPmarker);
+ }
+
+ //newHP is an array; like the zeroHPmarker check above, test the last bar (newHP===0 was never true)
+ if (removeAtZero && newHP[bar.length - 1] === 0) {
+ removeToken(tokID);
+ } else {
+ //grab the previous token settings before setting new ones
+ const prevTok = JSON.parse(JSON.stringify(tok));
+ //set new tok settings
+ newHP.forEach((hp, idx) => {
+ tok.set(`bar${bar[idx]}_value`, hp);
+ });
+ //trigger tokenChange event for other api scripts
+ notifyObservers('tokenChange', tok, prevTok);
+ }
+
+ return returnString;
+ };
+
+ //0 HP marker tags (--zeroHPmarker) used this session. Kept after an AoE is deleted (e.g. --instant AoEs are
+ //removed before their damage is applied) so other scripts can tell SmartAoE's 0 HP markers apart.
+ const knownZeroHPMarkers = new Set();
+ /**
+ * Records 0 HP marker tags used this session in knownZeroHPMarkers, so other scripts (via the public ZeroHPMarkers
+ * accessor) can recognise SmartAoE's markers even after the AoE is deleted.
+ *
+ * @param {string} markerString - Comma-separated status markers.
+ */
+ const rememberZeroHPMarkers = function (markerString) {
+ splitStatusMarkers(markerString).forEach((m) => knownZeroHPMarkers.add(markerTag(m)));
+ };
+
+ //Splits a statusmarkers string into trimmed, non-empty entries
+ /**
+ * Splits a statusmarkers string into trimmed, non-empty entries.
+ *
+ * @param {string} markerString - Comma-separated status markers (falsy is treated as empty).
+ * @returns {string[]} Marker entries.
+ */
+ const splitStatusMarkers = function (markerString) {
+ return (markerString || '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter((s) => s !== '');
+ };
+
+ //Marker tag without its optional @number badge, e.g. "red@2" -> "red", "Down::123@1" -> "Down::123"
+ /**
+ * Strips the optional @number badge from a marker, e.g. "red@2" becomes "red".
+ *
+ * @param {string} marker - Status marker entry.
+ * @returns {string} Marker tag without the badge.
+ */
+ const markerTag = function (marker) {
+ return marker.replace(/@\d+$/, '');
+ };
+
+ /**
+ * Adds status markers to a token, skipping any whose tag is already present (badges ignored) and duplicates within
+ * the input. Notifies observers only if the markers actually changed.
+ *
+ * @param {string} tokID - Roll20 graphic (token) ID.
+ * @param {string} markerString - Comma-separated markers to add.
+ */
+ const addStatusMarkers = function (tokID, markerString) {
+ if (!markerString) {
+ return;
+ }
+
+ const tok = getObj('graphic', tokID);
+ if (!tok) {
+ return;
+ }
+ const currentMarkers = splitStatusMarkers(tok.get('statusmarkers'));
+ const currentTags = currentMarkers.map(markerTag);
+
+ //we don't want to duplicate statusmarkers, so compare whole marker tags (ignoring any @number badge).
+ //Filtering avoids the skipped entries of splicing inside forEach, and substring matches such as "red" in "red@2".
+ const newMarkers = splitStatusMarkers(markerString).filter((m, idx, arr) => {
+ return !currentTags.includes(markerTag(m)) && arr.findIndex((x) => markerTag(x) === markerTag(m)) === idx;
+ });
+ if (newMarkers.length === 0) {
+ return;
+ }
+
+ //grab the previous token settings before setting new ones
+ const prevTok = JSON.parse(JSON.stringify(tok));
+ //set new tok settings (no stray leading/trailing commas)
+ tok.set('statusmarkers', currentMarkers.concat(newMarkers).join(','));
+ //trigger tokenChange event for other api scripts
+ notifyObservers('tokenChange', tok, prevTok);
+ };
+
+ /**
+ * Removes status markers from a token by tag (so "red" also removes "red@2"). Notifies observers only if
+ * something was removed.
+ *
+ * @param {string} tokID - Roll20 graphic (token) ID.
+ * @param {string} markerString - Comma-separated markers to remove.
+ */
+ const removeStatusMarkers = function (tokID, markerString) {
+ if (!markerString) {
+ return;
+ }
+ const tok = getObj('graphic', tokID);
+ if (!tok) {
+ return;
+ }
+ const allMarkers = splitStatusMarkers(tok.get('statusmarkers'));
+
+ //compare whole marker tags so "red@2" is removed by "red", and spaces after commas are ignored
+ const tagsToRemove = splitStatusMarkers(markerString).map(markerTag);
+ const currentMarkers = allMarkers.filter((m) => !tagsToRemove.includes(markerTag(m)));
+ if (currentMarkers.length === allMarkers.length) {
+ return;
+ }
+
+ //grab the previous token settings before setting new ones
+ const prevTok = JSON.parse(JSON.stringify(tok));
+ //set new tok settings
+ tok.set('statusmarkers', currentMarkers.join(','));
+ //trigger tokenChange event for other api scripts
+ notifyObservers('tokenChange', tok, prevTok);
+ };
+
+ /**
+ * Applies a simple arithmetic rule such as "*0.5", "/2", "+5" or "-10" to a value. Multiplication and division
+ * are rounded down. Used for save, resistance, vulnerability and immunity adjustments.
+ *
+ * @param {number} val - Value to adjust.
+ * @param {string} rule - Operator followed by a number; whitespace is ignored.
+ * @returns {number} Adjusted value.
+ */
+ const applyMathRule = function (val, rule) {
+ //rule should look like "*2", "-10", "/2", etc.
+ rule = rule.replace(/\s+/g, '');
+
+ const operator = rule.substring(0, 1);
+ const operand = parseFloat(rule.replace(operator, ''));
+
+ let retVal = val; //unknown operator: leave the value unchanged
+ switch (operator) {
+ case '*':
+ retVal = Math.floor(val * operand);
+ break;
+ case '/':
+ retVal = Math.floor(val / operand);
+ break;
+ case '+':
+ retVal = val + operand;
+ break;
+ case '-':
+ retVal = val - operand;
+ break;
+ }
+ return retVal;
+ };
+
+ // ————— RESULTS OUTPUT —————
+ //this was modified from Jakob's group-check api script
+ /**
+ * Takes the rolled saving throws, works out each target's damage (save rule plus resistances, vulnerabilities and
+ * immunities) and pass/fail markers, optionally auto-applies damage and markers, applies any caster condition, and
+ * sends the public chat card plus GM-whispered rows and damage summaries.
+ *
+ * @param {object[][]} messages - sendChat callback messages for the save rolls, one per target.
+ * @param {object[]} damageRolls - Formatted damage roll entries.
+ * @param {object[]} tokenRolls - Target entries from processTokenRolls (mutated with roll results).
+ * @param {object} aoeLink - AoE link from state.
+ */
+ const processFinalMessages = function (messages, damageRolls, tokenRolls, aoeLink) {
+ //roll results arrive as arrays (one entry per inline roll in the line). Turn them into numbers before doing maths:
+ //"[5] + 2" used to give "52", and a line with two rolls compared as NaN
+ const lineTotal = (vals) => [].concat(vals).reduce((sum, v) => sum + (Number(v) || 0), 0);
+ const firstRoll = (vals) => Number([].concat(vals)[0]) || 0;
+ //takes all the rolls that were processed thru the Roll20 die roller for all tokens and formats them.
+ // then, we will format the final chat output
+ const publicDamageMessages = [];
+ const gmDamageMessages = [];
+ let autoApplyMsg = '';
+ let name; //override for ApplyDamage function
+ let whisperString = '';
+ let atLeastOneFailedSave = false;
+
+ if (aoeLink.whisperAll) {
+ whisperString = aoeLink.whisperString || '';
+ }
+
+ attachRollResults(messages, tokenRolls);
+
+ // Format rows of output
+ const outputNew = buildTitle(aoeLink.cardParameters) + buildDamageRows(damageRolls, aoeLink);
+
+ const saveHeaderRow = buildSaveHeaderRow(aoeLink.cardParameters);
+
+ //Calculate damage and status markers based on individual saving throws
+ const saveRolls = tokenRolls.map((o) => {
+ let tempDam1, tempDam2;
+ const thisDamage = [];
+ let thisMarker = '';
+ let thisSaveRoll;
+ const RVI = []; //Resistances, Vulnerabilities, Immunities
+ let RVI_string = '';
+
+ tempDam1 = lineTotal(damageRolls[0].result_1);
+
+ const damType1 = aoeLink.damageType1.toLowerCase();
+ const damType2 = aoeLink.damageType2.toLowerCase();
+
+ thisSaveRoll = firstRoll(o.result_1);
+ if (o.roll2x) {
+ RVI.push('A'); //rolling with advantage
+ if (o.result_1[0] >= o.result_2[0]) {
+ o.styled_2 = o.styled_2.replace('color:#000000', 'color:#999999');
+ } else if (o.result_2[0] > o.result_1[0]) {
+ thisSaveRoll = firstRoll(o.result_2);
+ o.styled_1 = o.styled_1.replace('color:#000000', 'color:#999999');
+ }
+ }
+
+ //Account for Vulnerabilities, resistances, and immunities for damage types 1 & 2
+ if (damType1 !== '' && o.vulnerabilities.includes(damType1)) {
+ tempDam1 = applyMathRule(tempDam1, aoeLink.vulnerableRule);
+ pushUniqueElementToArray(RVI, 'V');
+ }
+ if (damType1 !== '' && o.resistances.includes(damType1)) {
+ tempDam1 = applyMathRule(tempDam1, aoeLink.resistanceRule);
+ pushUniqueElementToArray(RVI, 'R');
+ }
+ if (damType1 !== '' && o.immunities.includes(damType1)) {
+ tempDam1 = applyMathRule(tempDam1, aoeLink.immunityRule);
+ pushUniqueElementToArray(RVI, 'I');
+ }
+ if (damageRolls.length > 1) {
+ tempDam2 = lineTotal(damageRolls[1].result_1);
+ if (damType2 !== '' && o.vulnerabilities.includes(damType2)) {
+ tempDam2 = applyMathRule(tempDam2, aoeLink.vulnerableRule);
+ pushUniqueElementToArray(RVI, 'V');
+ }
+ if (damType2 !== '' && o.resistances.includes(damType2)) {
+ tempDam2 = applyMathRule(tempDam2, aoeLink.resistanceRule);
+ pushUniqueElementToArray(RVI, 'R');
+ }
+ if (damType2 !== '' && o.immunities.includes(damType2)) {
+ tempDam2 = applyMathRule(tempDam2, aoeLink.immunityRule);
+ pushUniqueElementToArray(RVI, 'I');
+ }
+ }
+ if (RVI.length > 0) {
+ RVI_string = RVI.join(',');
+ }
+
+ //check save result
+ if (thisSaveRoll >= o.DC) {
+ success = true;
+ tempDam1 = applyMathRule(tempDam1, aoeLink.damageSaveRule);
+
+ thisDamage.push(tempDam1);
+ //check for 2nd damage type
+ if (damageRolls.length > 1) {
+ tempDam2 = applyMathRule(tempDam2, aoeLink.damageSaveRule);
+ thisDamage.push(tempDam2);
+ }
+ if (aoeLink.conditionPass && aoeLink.autoApply) {
+ addStatusMarkers(o.id, aoeLink.conditionPass);
+ }
+ thisMarker = aoeLink.conditionPass;
+ } else {
+ success = false;
+ atLeastOneFailedSave = true;
+ thisDamage.push(tempDam1);
+ if (damageRolls.length > 1) {
+ thisDamage.push(tempDam2);
+ }
+ if (aoeLink.conditionFail && aoeLink.autoApply) {
+ addStatusMarkers(o.id, aoeLink.conditionFail);
+ }
+
+ thisMarker = aoeLink.conditionFail;
+ }
+
+ if (aoeLink.hideNames) {
+ targetNum += 1;
+ name = `Target_${targetNum}`;
+ } else {
+ name = o.name;
+ }
+
+ if (aoeLink.autoApply) {
+ const damMsg = applyDamage(
+ o.id,
+ name,
+ aoeLink.damageBar,
+ thisDamage,
+ aoeLink.zeroHPmarker,
+ aoeLink.removeAtZero,
+ );
+ if (damMsg !== '') {
+ if (o.layer === 'gmlayer' || aoeLink.whisperResults) {
+ gmDamageMessages.push(damMsg);
+ } else {
+ publicDamageMessages.push(damMsg);
+ }
+ }
+ }
+
+ return buildSaveRow(
+ o.pic,
+ o.id,
+ o.layer,
+ name,
+ success,
+ o.styled_1,
+ o.styled_2,
+ o.roll2x,
+ thisDamage,
+ aoeLink.autoApply,
+ aoeLink.damageBar,
+ thisMarker,
+ RVI_string,
+ aoeLink.zeroHPmarker,
+ aoeLink.removeAtZero,
+ aoeLink.cardParameters,
+ whisperString,
+ aoeLink.whisperResults,
+ );
+ });
+
+ if (aoeLink.casterCondition && !aoeLink.casterConditionOnFail) {
+ addStatusMarkers(aoeLink.originTokID, aoeLink.casterCondition);
+ } else if (aoeLink.casterCondition && aoeLink.casterConditionOnFail && atLeastOneFailedSave) {
+ addStatusMarkers(aoeLink.originTokID, aoeLink.casterCondition);
+ }
+
+ const publicSaveRolls = saveRolls
+ .filter((r) => {
+ return r.layer !== 'gmlayer' && !aoeLink.whisperResults;
+ })
+ .map((e) => {
+ return e.output;
+ })
+ .join('');
+
+ const gmSaveRolls = saveRolls
+ .filter((r) => {
+ return r.layer === 'gmlayer' || aoeLink.whisperResults;
+ })
+ .map((e) => {
+ return e.output;
+ })
+ .join('');
+
+ const descRow = buildDescRow(aoeLink.cardParameters.descriptiontext, aoeLink.cardParameters);
+
+ publicOutput = outputNew + buildTableBody(saveHeaderRow, publicSaveRolls, descRow, aoeLink.cardParameters);
+
+ //public output
+ sendChat(scriptName, `${whisperString} ${publicOutput}`);
+
+ if (publicDamageMessages.length > 0) {
+ autoApplyMsg = `${whisperString} ${publicDamageMessages.join(' ')}`;
+ sendChat(scriptName, autoApplyMsg);
+ }
+
+ //GM output
+ if (gmSaveRolls) {
+ gmOutput = buildGMOutput(gmSaveRolls, aoeLink.cardParameters);
+ sendChat(scriptName, `/w gm ${gmOutput}`);
+ }
+ if (gmDamageMessages.length > 0) {
+ autoApplyMsg = '/w gm ' + gmDamageMessages.join(' ');
+ sendChat(scriptName, autoApplyMsg);
+ }
+ };
+
+ /**
+ * Posts a public chat message reporting an error that happened while processing rolls.
+ *
+ * @param {*} err - Error or message from the failed roll promise.
+ */
+ const sendErrorMessage = function (err) {
+ const errorMessage =
+ 'Something went wrong when processing rolls. ' + `The error message generated by Roll20 is: ${err}`;
+ sendChat('SmartAoE', errorMessage);
+ };
+
+ // ————— HELP HANDOUT —————
+ /** Name of the GM-only help handout SmartAoE creates and keeps up to date. */
+ const HANDOUT_NAME = `${scriptName} — Help & Reference`;
+
+ /** Chat commands for the help handout: [command, description]. Keep in step with COMMAND_HANDLERS and the README. */
+ const HANDOUT_COMMANDS = [
+ [
+ '!smartaoe {{ --param|value … }}',
+ 'Create an AoE for the selected token (or --selectedID). See Parameters below.',
+ ],
+ [
+ '!smarttrigger [controlTokenID]',
+ 'Trigger the selected control token’s AoE (or the one whose id is given) on everything inside it. Selecting one of the AoE squares also works.',
+ ],
+ [
+ '!smartquery ',
+ 'Trigger only the AoEs that overlap a token’s square, e.g. !smartquery @{target|Choose a Target|token_id} for start/end-of-turn effects.',
+ ],
+ ['!smartrotateorigin cw|ccw', 'Move a cone’s origin to the next corner/face of the caster.'],
+ ['!smartremove', 'Remove all AoEs and control tokens linked to the selected token.'],
+ [
+ '!smartclearcache',
+ 'Forget every stored AoE link in the game (tokens and drawings stay). Use only if links are corrupted.',
+ ],
+ ['!smarthelp', 'Rebuild this help handout and whisper a link to it.'],
+ ['!smartapply --args|…', 'Used by the chat card’s apply buttons. Not normally typed by hand.'],
+ ];
+
+ /** `!smartaoe` parameters for the help handout, grouped: [group, [[parameter, values, default], …]]. */
+ const HANDOUT_PARAMS = [
+ [
+ 'Shape and placement',
+ [
+ [
+ '--aoeType',
+ 'line, square, circle, PFcircle, wall, cone[, angle], PFcone, 5econe; add ", float" to squares/circles to detach them',
+ 'line',
+ ],
+ ['--radius', 'number with units (30ft, 6u), bare number = pixels, or variable', 'variable'],
+ ['--width', 'wall thickness (wall only)', '1 square'],
+ ['--origin', 'center, nearest, or "nearest, face"', 'center'],
+ ['--offset', 'x, y offset of the origin from the caster, in grid squares', '0, 0'],
+ ['--forceIntersection', 'snap the control token to grid intersections', 'auto'],
+ ['--minGridArea / --minTokArea', 'fraction (0–1) of a square / token that must be covered', '0.01'],
+ ['--instant', 'delete the control token once triggered', 'false'],
+ ['--affectsCaster', 'the caster can be affected', 'false'],
+ ['--aoeLayer', 'objects/token, gm or map (map avoids the Jumpgate layering issue)', 'objects'],
+ [
+ '--selectedID / --playerID',
+ 'caster token id / controlling player id (for calls from other scripts)',
+ 'selection',
+ ],
+ ],
+ ],
+ [
+ 'Control token & appearance',
+ [
+ ['--controlTokName', 'character whose default token is used, or self', 'AoEControlToken'],
+ ['--controlTokSize / --controlTokSide', 'size in squares / side of a multi-sided token', '1 / default'],
+ ['--isDrawing / --tooltip', 'control token drawing flag / tooltip text', 'false / none'],
+ ['--aoeColor / --aoeOutlineColor', '#RRGGBBAA fill / #RRGGBB outline, or player', '#ff000050 / #ff0000'],
+ ['--gridColor / --outlineOnly', 'grid-line colour (alpha 00 hides) / outline without fill', '#000000 / false'],
+ ['--fx', 'Roll20 effect - on each affected square', 'none'],
+ ['--spawnSound / --moveSound / --triggerSound / --deleteSound', 'jukebox track names', 'none'],
+ ['--turnorder', 'name, value, formula, linked', 'linked'],
+ ],
+ ],
+ [
+ 'Saves, damage & markers',
+ [
+ ['--dc / --saveFormula', 'save DC / 5eSTR…5eCHA or a formula (<<…>> and a{…} roll per target)', '0 / none'],
+ [
+ '--noSave / --ignore',
+ 'no save (assumed when there is no --saveFormula) / attrName, value to leave characters out',
+ 'false / none',
+ ],
+ [
+ '--damageFormula1 / --damageFormula2',
+ 'number or roll each; negative heals; formula 2 can be used alone',
+ 'none',
+ ],
+ ['--damageType1 / --damageType2', 'damage type of each formula', 'none'],
+ ['--damageSaveRule', 'operation on a successful save', '*0.5'],
+ ['--bar / --autoApply', 'bar(s) to change, e.g. 3,1 / apply without the chat buttons', '1 / false'],
+ [
+ '--resistAttr / --vulnerableAttr / --immunityAttr',
+ 'attributes listing the target’s defences; AoEs count as magical, so types resisted only from nonmagical attacks are ignored',
+ 'npc_resistances …',
+ ],
+ [
+ '--resistanceRule / --vulnerableRule',
+ 'operation for resistance / vulnerability (immunity is *0)',
+ '*0.5 / *2',
+ ],
+ [
+ '--resource',
+ 'attrName, cost, alias, limit — resource (e.g. spell slot) spent by the caster: counts down to 0, or with a limit (number, max or an attribute) counts up to it. 2024-sheet spell slots (lvlN_slots_expended) count up to lvlN_slots_total without a limit',
+ 'cost 1',
+ ],
+ ['--conditionFail / --conditionPass', 'marker(s) on a failed / passed save (@# adds a number)', 'none'],
+ [
+ '--zeroHPmarker / --removeAtZero',
+ 'marker when the last bar reaches 0 / remove the token at 0',
+ 'none / false',
+ ],
+ ['--casterCondition', 'marker on the caster, or "marker, onFail"', 'none'],
+ ],
+ ],
+ [
+ 'Chat card',
+ [
+ [
+ '--title / --leftSub / --rightSub / --desc',
+ 'card title, subtitles and description (%br% = line break)',
+ 'SmartAoE',
+ ],
+ ['--hideNames', 'show targets as Target_1, Target_2…', 'false'],
+ ['--whisperAll / --whisperResults', 'whisper the whole card / only the results', 'false'],
+ ['--chatAvatarsEnabled', 'false if chat avatars are turned off', 'true'],
+ [
+ 'Styling',
+ '--titleCardBackground, --titleFontFace/Color/Size, --bodyFontFace/Size, --tableBGColor, --evenRowBackground, …',
+ 'see README',
+ ],
+ ],
+ ],
+ ];
+
+ /** Example macros for the help handout: [title, macro text]. */
+ const HANDOUT_EXAMPLES = [
+ ['20 ft cube that floats freely', '!smartaoe {{\n --aoeType|square, float\n --radius|10ft\n}}'],
+ [
+ '5e cone that rotates around the caster',
+ '!smartaoe {{\n --aoeType|5econe\n --radius|30ft\n --minGridArea|0.25\n --origin|nearest, face\n}}',
+ ],
+ [
+ 'Fireball: DEX save, half damage, 0 HP marker',
+ '!smartaoe {{\n --aoeType|circle, float\n --radius|20ft\n --dc|@{selected|spell_save_dc}\n --saveFormula|5eDEX\n --damageFormula1|[[8d6]]\n --damageType1|fire\n --zeroHPmarker|dead\n --fx|burst-fire\n}}',
+ ],
+ [
+ 'Healing with no save',
+ '!smartaoe {{\n --aoeType|circle, float\n --radius|15ft\n --noSave|true\n --damageFormula1|-10\n --autoApply|true\n}}',
+ ],
+ ];
+
+ /** Tips for the help handout: [title, text]. Mirrors the README. Text in `backticks` is shown as code. */
+ const HANDOUT_TIPS = [
+ [
+ 'Jumpgate layering',
+ 'In Jumpgate the AoE squares can cover tokens (a Roll20 issue). Add `--aoeLayer|map`, or refresh the browser. Clicking the control token may select a square instead; Trigger still works.',
+ ],
+ ['Attached squares and circles', 'Fixed-radius squares/circles without `float` only fit one-square casters.'],
+ [
+ 'Line lengths',
+ 'Lines use true distance, so diagonals are shorter than 5e grid counting. Leave out `--radius` and place the token by hand for an exact count.',
+ ],
+ [
+ 'Called from other scripts',
+ 'Pass `--selectedID` and `--playerID`, or install SelectManager and run `!smconfig +playerid` once.',
+ ],
+ [
+ '2024 (Beacon) sheets',
+ 'Use the Mod Script Sandbox v1.5. Saves, damage-type attributes, `--ignore` and `--resource` read the 2024 sheet; spell slots (`lvlN_slots_expended`) count up to `lvlN_slots_total`. Magic Resistance comes from a feature of that name.',
+ ],
+ ['Stale links', 'If AoEs stop matching the map (e.g. after copying pages), run `!smartclearcache`.'],
+ ];
+
+ /**
+ * Grid rules for the help handout: [rules, settings]. Mirrors the README's "Grid rules by game system". Text in
+ * `backticks` is shown as code.
+ */
+ const HANDOUT_GRID_RULES = [
+ [
+ 'D&D 5e 2024: an area covering at least half a square affects the whole square',
+ '`--minGridArea|0.5`; cones with `--aoeType|5econe`',
+ ],
+ [
+ 'D&D 5e 2014, DMG: the half-square rule is given for circular areas (spheres, cylinders)',
+ '`--minGridArea|0.5` on circles; the defaults on other shapes',
+ ],
+ [
+ "D&D 5e 2014, Xanathar's template method: a square counts if any part of it is under the template",
+ 'The defaults (`0.01` / `0.01`); cones with `5econe`',
+ ],
+ [
+ 'Pathfinder 1e, D&D 3.5, Starfinder 1e: bursts and cones start at a grid intersection, every second diagonal counts double, and a square is in the area if its far edge is',
+ "`--aoeType|PFcircle, float` or `PFcone` with `--forceIntersection|1` (tests each square's farthest corner)",
+ ],
+ [
+ 'Pathfinder 2e, Starfinder 2e: bursts start at a grid corner and are measured like movement; a creature is hit if any square of its space is in the area',
+ "`PFcircle` with `--forceIntersection|1` for bursts, `PFcone` for cones, `line` for lines; keep `--minTokArea` at `0.01`. Emanations (from the sides of a creature's space) aren't modelled exactly: a `PFcircle` from a Medium creature's centre is closest",
+ ],
+ ['Templates where any creature the template touches is affected', 'The defaults (`0.01` / `0.01`)'],
+ [
+ 'A square counts only if its centre is in the area',
+ 'No exact setting; `--minGridArea|0.5` is closest for circles',
+ ],
+ ];
+
+ /**
+ * Inline styles for the help handout. Roll20 strips