mirror of
https://github.com/Kornstalx/5etools-mirror-2.github.io.git
synced 2025-10-28 20:45:35 -05:00
2055 lines
58 KiB
JavaScript
2055 lines
58 KiB
JavaScript
"use strict";
|
|
|
|
class AcConvert {
|
|
static tryPostProcessAc (mon, cbMan, cbErr) {
|
|
const traitNames = new Set(
|
|
(mon.trait || [])
|
|
.map(it => it.name ? it.name.toLowerCase() : null)
|
|
.filter(Boolean),
|
|
);
|
|
|
|
if (this._tryPostProcessAc_special(mon, cbMan, cbErr)) return;
|
|
|
|
const nuAc = [];
|
|
|
|
const parts = mon.ac.trim().split(StrUtil.COMMAS_NOT_IN_PARENTHESES_REGEX).map(it => it.trim()).filter(Boolean);
|
|
parts.forEach(pt => {
|
|
// Use two expressions to ensure parentheses are paired
|
|
const mAc = /^(\d+)(?: \((.*?)\))?$/.exec(pt) || /^(\d+)(?: (.*?))?$/.exec(pt);
|
|
|
|
if (!mAc) {
|
|
if (cbErr) cbErr(pt, `${`${mon.name} ${mon.source} p${mon.page}`.padEnd(48)} => ${pt}`);
|
|
nuAc.push(pt);
|
|
return;
|
|
}
|
|
|
|
const [, acRaw, fromRaw] = mAc;
|
|
|
|
const acNum = Number(acRaw);
|
|
|
|
// Plain number
|
|
if (!fromRaw) return nuAc.push(acNum);
|
|
|
|
const nuAcTail = [];
|
|
const cur = {ac: acNum};
|
|
const froms = [];
|
|
|
|
let fromClean = fromRaw;
|
|
|
|
// region Handle alternates of the form:
|
|
// - `natural armor; 22 in shield form`
|
|
// - `natural armor; 16 while flying`
|
|
// - `natural armor; 18 with hardened by flame`
|
|
// - `shield; ac 12 without shield`
|
|
fromClean = fromClean
|
|
.replace(/^(?<from>.+); (?:(?:ac )?(?<nxtVal>\d+) (?<nxtCond>in .*? form|while .*?|includes .*?|without .*?|with .*?))$/i, (...m) => {
|
|
nuAcTail.push({
|
|
ac: Number(m.last().nxtVal),
|
|
condition: m.last().nxtCond,
|
|
braces: true,
|
|
});
|
|
return m.last().from;
|
|
});
|
|
// endregion
|
|
|
|
// region Handle alternates of the form:
|
|
// - `medium armor; includes shield`
|
|
fromClean = fromClean
|
|
.replace(/^(?<from>.+); (?:(?<nxtCond>includes .*?))$/i, (...m) => {
|
|
cur.condition = `(${m.last().nxtCond})`;
|
|
cur.braces = true;
|
|
return m.last().from;
|
|
});
|
|
// endregion
|
|
|
|
// region Handle "in ... form" parts
|
|
fromClean = fromClean
|
|
// FIXME(Future) Find an example of a creature with this AC form to check accuracy of this parse
|
|
.replace(/(?<nxtVal>\d+)? \((?<nxtCond>in .*? form)\)$/i, (...m) => {
|
|
if (m.last().nxtVal) {
|
|
nuAcTail.push({
|
|
ac: Number(m.last().nxtVal),
|
|
condition: m.last().nxtCond,
|
|
braces: true,
|
|
});
|
|
return "";
|
|
}
|
|
|
|
if (cur.condition) throw new Error(`Multiple AC conditions! "${cur.condition}" and "${m[0]}"`);
|
|
cur.condition = m[0].trim().toLowerCase();
|
|
return "";
|
|
})
|
|
.trim()
|
|
.replace(/(?<nxtVal>\d+)? (?<nxtCond>in .*? form)$/i, (...m) => {
|
|
if (m.last().nxtVal) {
|
|
nuAcTail.push({
|
|
ac: Number(m.last().nxtVal),
|
|
condition: m.last().nxtCond,
|
|
braces: true,
|
|
});
|
|
return "";
|
|
}
|
|
|
|
if (cur.condition) throw new Error(`Multiple AC conditions! "${cur.condition}" and "${m[0]}"`);
|
|
cur.condition = m[0].trim().toLowerCase();
|
|
return "";
|
|
})
|
|
.trim();
|
|
// endregion
|
|
|
|
// region Handle "while ..." parts
|
|
fromClean = fromClean
|
|
.replace(/^while .*$/, (...m) => {
|
|
if (cur.condition) throw new Error(`Multiple AC conditions! "${cur.condition}" and "${m[0]}"`);
|
|
cur.condition = m[0].trim().toLowerCase();
|
|
return "";
|
|
});
|
|
// endregion
|
|
|
|
fromClean
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/^\(|\)$/g, "")
|
|
.split(",")
|
|
.map(it => it.trim())
|
|
.filter(Boolean)
|
|
.forEach(fromLow => {
|
|
switch (fromLow) {
|
|
// literally nothing
|
|
case "unarmored": break;
|
|
|
|
// everything else
|
|
default: {
|
|
const simpleFrom = this._getSimpleFrom({fromLow, traitNames});
|
|
if (simpleFrom) return froms.push(simpleFrom);
|
|
|
|
// Special parsing for barding, as the pre-barding armor type might not exactly match our known
|
|
// barding names (e.g. "chainmail barding")
|
|
const mWithBarding = /^(?<ac>\d+) with (?<name>(?<type>.*?) barding)$/.exec(fromLow);
|
|
if (mWithBarding) {
|
|
let simpleFromBarding = this._getSimpleFrom({fromLow: mWithBarding.groups.type, traitNames});
|
|
if (simpleFromBarding) {
|
|
simpleFromBarding = simpleFromBarding
|
|
.replace(/{@item ([^}]+)}/, (...m) => {
|
|
let [name, source, displayName] = m[1].split("|");
|
|
name = `${name.replace(/ armor$/i, "")} barding`;
|
|
|
|
if (mWithBarding.groups.name === name) return `{@item ${name}${source ? `|${source}` : ""}}`;
|
|
return `{@item ${name}${source ? `|${source}` : "|"}|${mWithBarding.groups.name}}`;
|
|
});
|
|
|
|
nuAcTail.push({
|
|
ac: Number(mWithBarding.groups.ac),
|
|
condition: `with ${simpleFromBarding}`,
|
|
braces: true,
|
|
});
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (fromLow.endsWith("with mage armor") || fromLow.endsWith("with barkskin")) {
|
|
const numMatch = /(\d+) with (.*)/.exec(fromLow);
|
|
if (!numMatch) throw new Error("Spell AC but no leading number?");
|
|
|
|
let spell = null;
|
|
if (numMatch[2] === "mage armor") spell = `{@spell mage armor}`;
|
|
else if (numMatch[2] === "barkskin") spell = `{@spell barkskin}`;
|
|
else throw new Error(`Unhandled spell! ${numMatch[2]}`);
|
|
|
|
nuAcTail.push({
|
|
ac: Number(numMatch[1]),
|
|
condition: `with ${spell}`,
|
|
braces: true,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if (/^in .*? form$/i.test(fromLow)) {
|
|
// If there's an existing condition, flag a warning
|
|
if (cur.condition && cbMan) cbMan(fromLow, `AC requires manual checking: ${mon.name} ${mon.source} p${mon.page}`);
|
|
cur.condition = `${cur.condition ? `${cur.condition} ` : ""}${fromLow}`;
|
|
|
|
return;
|
|
}
|
|
|
|
if (cbMan) cbMan(fromLow, `AC requires manual checking: ${mon.name} ${mon.source} p${mon.page}`);
|
|
froms.push(fromLow);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (froms.length || cur.condition) {
|
|
if (froms.length) {
|
|
cur.from = froms
|
|
// Ensure "Unarmored Defense" is always properly capitalized
|
|
.map(it => it.toLowerCase() === "unarmored defense" ? "Unarmored Defense" : it);
|
|
}
|
|
nuAc.push(cur);
|
|
} else {
|
|
nuAc.push(cur.ac);
|
|
}
|
|
|
|
if (nuAcTail.length) nuAc.push(...nuAcTail);
|
|
});
|
|
|
|
mon.ac = nuAc;
|
|
}
|
|
|
|
static _tryPostProcessAc_special (mon, cbMan, cbErr) {
|
|
mon.ac = mon.ac.trim();
|
|
|
|
const mPlusSpecial = /^(\d+) (plus|\+) (?:PB|the level of the spell|your [^ ]+ modifier)(?: \([^)]+\))?$/i.exec(mon.ac);
|
|
if (mPlusSpecial) {
|
|
mon.ac = [{special: mon.ac}];
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
static _getSimpleFrom ({fromLow, traitNames}) {
|
|
switch (fromLow) {
|
|
// region unhandled/other
|
|
case "unarmored defense":
|
|
case "suave defense":
|
|
case "armor scraps":
|
|
case "barding scraps":
|
|
case "patchwork armor":
|
|
case "see natural armor feature":
|
|
case "barkskin trait":
|
|
case "sylvan warrior":
|
|
case "cage":
|
|
case "chains":
|
|
case "coin mail":
|
|
case "crude armored coat":
|
|
case "improvised armor":
|
|
case "magic robes":
|
|
case "makeshift armor":
|
|
case "natural and mystic armor":
|
|
case "padded armor":
|
|
case "padded leather":
|
|
case "parrying dagger":
|
|
case "plant fiber armor":
|
|
case "plus armor worn":
|
|
case "rag armor":
|
|
case "ring of protection +2":
|
|
case "see below":
|
|
case "wicker armor":
|
|
case "bone armor":
|
|
case "deflection":
|
|
case "mental defense":
|
|
case "blood aegis":
|
|
case "psychic defense":
|
|
case "glory": // BAM :: Reigar
|
|
case "mountain tattoo": // KftGV :: Prisoner 13
|
|
case "disarming charm": // TG :: Forge Fitzwilliam
|
|
case "graz'zt's gift": // KftGV :: Sythian Skalderang
|
|
case "damaged plate": // BGG :: Firegaunt
|
|
case "intellect fortress": // N.b. *not* the spell of the same name, as this usually appears as a creature feature
|
|
case "veiled presence": // BMT :: Enchanting Infiltrator
|
|
case "coat of lies": // Grim Hollow: Lairs of Etharis
|
|
case "rotting buff coat":
|
|
case "battered splint mail":
|
|
case "natural & tailored leather":
|
|
case "canny defense": // Dungeons of Drakkenheim
|
|
return fromLow;
|
|
// endregion
|
|
|
|
// region homebrew
|
|
// "Flee, Mortals!" retainers
|
|
case "light armor":
|
|
case "medium armor":
|
|
case "heavy armor":
|
|
return fromLow;
|
|
// "Flee, Mortals!"
|
|
case "issenblau plating":
|
|
case "psionic power armor":
|
|
case "precog reflexes":
|
|
case "pathfinder's boots":
|
|
return fromLow;
|
|
// endregion
|
|
|
|
// region au naturel
|
|
case "natural armor":
|
|
case "natural armour":
|
|
case "natural":
|
|
return "natural armor";
|
|
// endregion
|
|
|
|
// region spells
|
|
case "foresight bonus": return `{@spell foresight} bonus`;
|
|
case "natural barkskin": return `natural {@spell barkskin}`;
|
|
case "mage armor": return "{@spell mage armor}";
|
|
// endregion
|
|
|
|
// region armor (mostly handled by the item lookup; these are mis-named exceptions (usually for homebrew))
|
|
case "chainmail":
|
|
case "chain armor":
|
|
return "{@item chain mail|phb}";
|
|
|
|
case "plate mail":
|
|
case "platemail":
|
|
case "full plate":
|
|
return "{@item plate armor|phb}";
|
|
|
|
case "half-plate": return "{@item half plate armor|phb}";
|
|
|
|
case "scale armor": return "{@item scale mail|phb}";
|
|
case "splint armor": return "{@item splint mail|phb}";
|
|
case "chain shirt": return "{@item chain shirt|phb}";
|
|
case "shields": return "{@item shield|phb|shields}";
|
|
|
|
case "spiked shield": return "{@item shield|phb|spiked shield}";
|
|
// endregion
|
|
|
|
// region magic items
|
|
case "dwarven plate": return "{@item dwarven plate}";
|
|
case "elven chain": return "{@item elven chain}";
|
|
case "glamoured studded leather": return "{@item glamoured studded leather}";
|
|
case "bracers of defense": return "{@item bracers of defense}";
|
|
case "badge of the watch": return "{@item Badge of the Watch|wdh}";
|
|
case "cloak of protection": return "{@item cloak of protection}";
|
|
case "ring of protection": return "{@item ring of protection}";
|
|
case "robe of the archmagi": return "{@item robe of the archmagi}";
|
|
case "robe of the archmage": return "{@item robe of the archmagi}";
|
|
case "staff of power": return "{@item staff of power}";
|
|
case "wrought-iron tower": return "{@item wrought-iron tower|CoA}";
|
|
// endregion
|
|
|
|
default: {
|
|
if (AcConvert._ITEM_LOOKUP[fromLow]) {
|
|
const itemMeta = AcConvert._ITEM_LOOKUP[fromLow];
|
|
|
|
if (itemMeta.isExact) return `{@item ${fromLow}${itemMeta.source === Parser.SRC_DMG ? "" : `|${itemMeta.source}`}}`;
|
|
return `{@item ${itemMeta.name}${itemMeta.source === Parser.SRC_DMG ? "|" : `|${itemMeta.source}`}|${fromLow}}`;
|
|
}
|
|
|
|
if (/scraps of .*?armor/i.test(fromLow)) { // e.g. "scraps of hide armor"
|
|
return fromLow;
|
|
}
|
|
|
|
if (traitNames.has(fromLow)) {
|
|
return fromLow;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static init (items) {
|
|
const handlePlusName = (item, lowName) => {
|
|
const mBonus = /^(.+) (\+\d+)$/.exec(lowName);
|
|
if (mBonus) {
|
|
const plusFirstName = `${mBonus[2]} ${mBonus[1]}`;
|
|
AcConvert._ITEM_LOOKUP[plusFirstName] = {source: item.source, name: lowName};
|
|
}
|
|
};
|
|
|
|
AcConvert._ITEM_LOOKUP = {};
|
|
items
|
|
.filter(it => it.type === "HA" || it.type === "MA" || it.type === "LA" || it.type === "S")
|
|
.forEach(it => {
|
|
const lowName = it.name.toLowerCase();
|
|
AcConvert._ITEM_LOOKUP[lowName] = {source: it.source, isExact: true};
|
|
|
|
const noArmorName = lowName.replace(/(^|\s)(?:armor|mail)(\s|$)/g, "$1$2").trim().replace(/\s+/g, " ");
|
|
if (noArmorName !== lowName) {
|
|
AcConvert._ITEM_LOOKUP[noArmorName] = {source: it.source, name: lowName};
|
|
}
|
|
|
|
handlePlusName(it, lowName);
|
|
handlePlusName(it, noArmorName);
|
|
});
|
|
}
|
|
}
|
|
AcConvert._ITEM_LOOKUP = null;
|
|
|
|
globalThis.AcConvert = AcConvert;
|
|
|
|
/** @abstract */
|
|
class _CreatureImmunityResistanceVulnerabilityConverterBase {
|
|
static _modProp;
|
|
|
|
static _getCleanIpt ({ipt}) {
|
|
return ipt
|
|
.replace(/^none\b/i, "") // Thanks.
|
|
.replace(/\.+\s*$/, "")
|
|
.trim()
|
|
;
|
|
}
|
|
|
|
static _getSplitInput ({ipt}) {
|
|
return ipt
|
|
.toLowerCase()
|
|
|
|
// Split e.g.
|
|
// "Bludgeoning and Piercing from nonmagical attacks, Acid, Fire, Lightning"
|
|
.split(/(.*\b(?:from|by)\b[^,;.!?]+)(?:[,;] ?)?/g)
|
|
.map(it => it.trim())
|
|
.filter(Boolean)
|
|
|
|
// Split e.g.
|
|
// "poison; bludgeoning, piercing, and slashing from nonmagical attacks"
|
|
.flatMap(pt => pt.split(";"))
|
|
.map(it => it.trim())
|
|
.filter(Boolean)
|
|
;
|
|
}
|
|
|
|
/**
|
|
* @abstract
|
|
* @return {?object}
|
|
*/
|
|
static _getSpecialFromPart ({pt}) { throw new Error("Unimplemented!"); }
|
|
|
|
static _getIxPreNote ({pt}) { return -1; }
|
|
|
|
static _getUid (name) { return name; }
|
|
|
|
static getParsed (ipt, opts) {
|
|
ipt = this._getCleanIpt({ipt});
|
|
if (!ipt) return null;
|
|
|
|
let noteAll = null;
|
|
if (ipt.startsWith("(") && ipt.endsWith(")")) {
|
|
ipt = ipt
|
|
.replace(/^\(([^)]+)\)$/, "$1")
|
|
// Reflected in `TagImmResVulnConditional`
|
|
.replace(/ (in .* form)$/i, (...m) => {
|
|
noteAll = m[1];
|
|
return "";
|
|
});
|
|
}
|
|
|
|
const spl = this._getSplitInput({ipt});
|
|
|
|
const out = [];
|
|
|
|
spl
|
|
.forEach(section => {
|
|
let note = noteAll;
|
|
let preNote;
|
|
const newGroup = [];
|
|
|
|
section
|
|
.split(/,/g)
|
|
.forEach(pt => {
|
|
pt = pt.trim().replace(/^and /i, "").trim();
|
|
|
|
const special = this._getSpecialFromPart({pt});
|
|
if (special) return out.push(special);
|
|
|
|
pt = pt.replace(/\(from [^)]+\)$/i, (...m) => {
|
|
if (note) throw new Error(`Already has note!`);
|
|
note = m[0];
|
|
return "";
|
|
}).trim();
|
|
|
|
pt = pt.replace(/(?:damage )?(?:from|during) [^)]+$/i, (...m) => {
|
|
if (note) throw new Error(`Already has note!`);
|
|
note = m[0];
|
|
return "";
|
|
}).trim();
|
|
|
|
pt = pt.replace(/\bthat is nonmagical$/i, (...m) => {
|
|
if (note) throw new Error(`Already has note!`);
|
|
note = m[0];
|
|
return "";
|
|
}).trim();
|
|
|
|
const ixPreNote = this._getIxPreNote({pt});
|
|
if (ixPreNote > 0) {
|
|
preNote = pt.slice(0, ixPreNote).trim();
|
|
pt = pt.slice(ixPreNote).trim();
|
|
}
|
|
|
|
pt = pt.trim();
|
|
if (!pt) return;
|
|
|
|
pt
|
|
.split(/ and /g)
|
|
.forEach(val => newGroup.push(val));
|
|
});
|
|
|
|
const newGroupOut = newGroup
|
|
.map(it => this._getUid(it));
|
|
|
|
if (note || preNote) {
|
|
if (!newGroupOut.length) {
|
|
out.push({special: [preNote, note].filter(Boolean).join(" ")});
|
|
return;
|
|
}
|
|
|
|
const toAdd = {[this._modProp]: newGroupOut};
|
|
if (preNote) toAdd.preNote = preNote;
|
|
if (note) toAdd.note = note;
|
|
out.push(toAdd);
|
|
return;
|
|
}
|
|
|
|
// If there is no group metadata, flatten into the main array
|
|
out.push(...newGroupOut);
|
|
});
|
|
|
|
return out;
|
|
}
|
|
}
|
|
|
|
class _CreatureDamageImmunityResistanceVulnerabilityConverter extends _CreatureImmunityResistanceVulnerabilityConverterBase {
|
|
static _getCleanIpt ({ipt}) {
|
|
return super._getCleanIpt({ipt})
|
|
// Handle parens used instead of commas (e.g. "Hobgoblin Smokebinder" from Flee, Mortals!)
|
|
.replace(/(?:^|,? )\(([^)]+ in [^)]+ form)\)/gi, ", $1")
|
|
// handle the case where a comma is mistakenly used instead of a semicolon
|
|
.replace(/, (bludgeoning, piercing, and slashing from)/gi, "; $1")
|
|
;
|
|
}
|
|
|
|
static _getSpecialFromPart ({pt}) {
|
|
// region `"damage from spells"`
|
|
const mDamageFromThing = /^damage from .*$/i.exec(pt);
|
|
if (mDamageFromThing) return {special: pt};
|
|
// endregion
|
|
}
|
|
|
|
static _getIxPreNote ({pt}) {
|
|
return Math.min(...Parser.DMG_TYPES.map(it => pt.toLowerCase().indexOf(it)).filter(ix => ~ix));
|
|
}
|
|
}
|
|
|
|
class CreatureDamageVulnerabilityConverter extends _CreatureDamageImmunityResistanceVulnerabilityConverter {
|
|
static _modProp = "vulnerable";
|
|
}
|
|
|
|
globalThis.CreatureDamageVulnerabilityConverter = CreatureDamageVulnerabilityConverter;
|
|
|
|
class CreatureDamageResistanceConverter extends _CreatureDamageImmunityResistanceVulnerabilityConverter {
|
|
static _modProp = "resist";
|
|
}
|
|
|
|
globalThis.CreatureDamageResistanceConverter = CreatureDamageResistanceConverter;
|
|
|
|
class CreatureDamageImmunityConverter extends _CreatureDamageImmunityResistanceVulnerabilityConverter {
|
|
static _modProp = "immune";
|
|
}
|
|
|
|
globalThis.CreatureDamageImmunityConverter = CreatureDamageImmunityConverter;
|
|
|
|
class CreatureConditionImmunityConverter extends _CreatureImmunityResistanceVulnerabilityConverterBase {
|
|
static _modProp = "conditionImmune";
|
|
|
|
static _getSpecialFromPart ({pt}) { return null; }
|
|
|
|
static _getUid (name) {
|
|
return TagCondition.getConditionUid(name);
|
|
}
|
|
}
|
|
|
|
globalThis.CreatureConditionImmunityConverter = CreatureConditionImmunityConverter;
|
|
|
|
class TagAttack {
|
|
static tryTagAttacks (m, cbMan) {
|
|
TagAttack._PROPS.forEach(prop => this._handleProp({m, prop, cbMan}));
|
|
}
|
|
|
|
static _handleProp ({m, prop, cbMan}) {
|
|
if (!m[prop]) return;
|
|
|
|
m[prop]
|
|
.forEach(it => {
|
|
if (!it.entries) return;
|
|
|
|
const str = JSON.stringify(it.entries, null, "\t");
|
|
const out = str.replace(/([\t ]")((?:(?:[A-Z][a-z]*|or) )*Attack:) /g, (...m) => {
|
|
const lower = m[2].toLowerCase();
|
|
if (TagAttack.MAP[lower]) {
|
|
return `${m[1]}${TagAttack.MAP[lower]} `;
|
|
} else {
|
|
if (cbMan) cbMan(m[2]);
|
|
return m[0];
|
|
}
|
|
});
|
|
it.entries = JSON.parse(out);
|
|
});
|
|
}
|
|
}
|
|
TagAttack._PROPS = ["action", "reaction", "bonus", "trait", "legendary", "mythic", "variant"];
|
|
TagAttack.MAP = {
|
|
"melee weapon attack:": "{@atk mw}",
|
|
"ranged weapon attack:": "{@atk rw}",
|
|
"melee attack:": "{@atk m}",
|
|
"ranged attack:": "{@atk r}",
|
|
"area attack:": "{@atk a}",
|
|
"area weapon attack:": "{@atk aw}",
|
|
"melee spell attack:": "{@atk ms}",
|
|
"melee or ranged weapon attack:": "{@atk mw,rw}",
|
|
"ranged spell attack:": "{@atk rs}",
|
|
"melee or ranged spell attack:": "{@atk ms,rs}",
|
|
"melee or ranged attack:": "{@atk m,r}",
|
|
"melee power attack:": "{@atk mp}",
|
|
"ranged power attack:": "{@atk rp}",
|
|
"melee or ranged power attack:": "{@atk mp,rp}",
|
|
};
|
|
|
|
globalThis.TagAttack = TagAttack;
|
|
|
|
class TagHit {
|
|
static tryTagHits (m) {
|
|
TagHit._PROPS.forEach(prop => this._handleProp({m, prop}));
|
|
}
|
|
|
|
static _handleProp ({m, prop}) {
|
|
if (!m[prop]) return;
|
|
|
|
m[prop]
|
|
.forEach(it => {
|
|
if (!it.entries) return;
|
|
|
|
const str = JSON.stringify(it.entries, null, "\t");
|
|
const out = str.replace(/Hit: /g, "{@h}");
|
|
it.entries = JSON.parse(out);
|
|
});
|
|
}
|
|
}
|
|
TagHit._PROPS = ["action", "reaction", "bonus", "trait", "legendary", "mythic", "variant"];
|
|
|
|
globalThis.TagHit = TagHit;
|
|
|
|
class TagDc {
|
|
static tryTagDcs (m) {
|
|
TagDc._PROPS.forEach(prop => this._handleProp({m, prop}));
|
|
}
|
|
|
|
static _handleProp ({m, prop}) {
|
|
if (!m[prop]) return;
|
|
|
|
m[prop] = m[prop]
|
|
.map(it => {
|
|
const str = JSON.stringify(it, null, "\t");
|
|
const out = str.replace(/DC (\d+)(\s+plus PB|\s*\+\s*PB)?/g, "{@dc $1$2}");
|
|
return JSON.parse(out);
|
|
});
|
|
}
|
|
}
|
|
TagDc._PROPS = ["action", "reaction", "bonus", "trait", "legendary", "mythic", "variant", "spellcasting"];
|
|
|
|
globalThis.TagDc = TagDc;
|
|
|
|
class AlignmentConvert {
|
|
static tryConvertAlignment (stats, cbMan) {
|
|
const {alignmentPrefix, alignment} = AlignmentUtil.tryGetConvertedAlignment(stats.alignment, {cbMan});
|
|
|
|
stats.alignment = alignment;
|
|
if (!stats.alignment) delete stats.alignment;
|
|
|
|
stats.alignmentPrefix = alignmentPrefix;
|
|
if (!stats.alignmentPrefix) delete stats.alignmentPrefix;
|
|
}
|
|
}
|
|
|
|
globalThis.AlignmentConvert = AlignmentConvert;
|
|
|
|
class TraitActionTag {
|
|
static _TAGS = { // true = map directly; string = map to this string
|
|
trait: {
|
|
"turn immunity": "Turn Immunity",
|
|
"brute": "Brute",
|
|
"antimagic susceptibility": "Antimagic Susceptibility",
|
|
"sneak attack": "Sneak Attack",
|
|
"reckless": "Reckless",
|
|
"web sense": "Web Sense",
|
|
"flyby": "Flyby",
|
|
"pounce": "Pounce",
|
|
"water breathing": "Water Breathing",
|
|
|
|
"turn resistance": "Turn Resistance",
|
|
"turn defiance": "Turn Resistance",
|
|
"turning defiance": "Turn Resistance",
|
|
"turn resistance aura": "Turn Resistance",
|
|
"undead fortitude": "Undead Fortitude",
|
|
|
|
"aggressive": "Aggressive",
|
|
"illumination": "Illumination",
|
|
"rampage": "Rampage",
|
|
"rejuvenation": "Rejuvenation",
|
|
"web walker": "Web Walker",
|
|
"incorporeal movement": "Incorporeal Movement",
|
|
"incorporeal passage": "Incorporeal Movement",
|
|
|
|
"keen hearing and smell": "Keen Senses",
|
|
"keen sight and smell": "Keen Senses",
|
|
"keen hearing and sight": "Keen Senses",
|
|
"keen hearing": "Keen Senses",
|
|
"keen smell": "Keen Senses",
|
|
"keen senses": "Keen Senses",
|
|
|
|
"hold breath": "Hold Breath",
|
|
|
|
"charge": "Charge",
|
|
|
|
"fey ancestry": "Fey Ancestry",
|
|
|
|
"siege monster": "Siege Monster",
|
|
|
|
"pack tactics": "Pack Tactics",
|
|
|
|
"regeneration": "Regeneration",
|
|
|
|
"shapechanger": "Shapechanger",
|
|
|
|
"false appearance": "False Appearance",
|
|
|
|
"spider climb": "Spider Climb",
|
|
|
|
"sunlight sensitivity": "Sunlight Sensitivity",
|
|
"sunlight hypersensitivity": "Sunlight Sensitivity",
|
|
"light sensitivity": "Light Sensitivity",
|
|
"vampire weaknesses": "Sunlight Sensitivity",
|
|
|
|
"amphibious": "Amphibious",
|
|
|
|
"legendary resistance": "Legendary Resistances",
|
|
|
|
"magic weapon": "Magic Weapons",
|
|
"magic weapons": "Magic Weapons",
|
|
|
|
"magic resistance": "Magic Resistance",
|
|
|
|
"spell immunity": "Spell Immunity",
|
|
|
|
"ambush": "Ambusher",
|
|
"ambusher": "Ambusher",
|
|
|
|
"amorphous": "Amorphous",
|
|
"amorphous form": "Amorphous",
|
|
|
|
"death burst": "Death Burst",
|
|
"death throes": "Death Burst",
|
|
|
|
"devil's sight": "Devil's Sight",
|
|
"devil sight": "Devil's Sight",
|
|
|
|
"immutable form": "Immutable Form",
|
|
|
|
"tree stride": "Tree Stride",
|
|
|
|
"unusual nature": "Unusual Nature",
|
|
|
|
"tunneler": "Tunneler",
|
|
|
|
"beast of burden": "Beast of Burden",
|
|
},
|
|
action: {
|
|
"multiattack": "Multiattack",
|
|
"frightful presence": "Frightful Presence",
|
|
"teleport": "Teleport",
|
|
"swallow": "Swallow",
|
|
"tentacle": "Tentacles",
|
|
"tentacles": "Tentacles",
|
|
"change shape": "Shapechanger",
|
|
},
|
|
reaction: {
|
|
"parry": "Parry",
|
|
},
|
|
bonus: {
|
|
"change shape": "Shapechanger",
|
|
},
|
|
legendary: {
|
|
// unused
|
|
},
|
|
mythic: {
|
|
// unused
|
|
},
|
|
};
|
|
|
|
static _TAGS_DEEP = {
|
|
action: {
|
|
"Swallow": strEntries => /\bswallowed\b/i.test(strEntries),
|
|
},
|
|
};
|
|
|
|
static _doAdd ({tags, tag, allowlist}) {
|
|
if (allowlist && !allowlist.has(tag)) return;
|
|
tags.add(tag);
|
|
}
|
|
|
|
static _doTag ({m, cbMan, prop, tags, allowlist}) {
|
|
if (!m[prop]) return;
|
|
|
|
m[prop]
|
|
.forEach(t => {
|
|
if (!t.name) return;
|
|
t.name = t.name.trim();
|
|
|
|
const cleanName = Renderer.stripTags(t.name)
|
|
.toLowerCase()
|
|
.replace(/\([^)]+\)/g, "") // Remove parentheses
|
|
.trim();
|
|
|
|
const mapped = TraitActionTag._TAGS[prop][cleanName];
|
|
if (mapped) {
|
|
if (mapped === true) return this._doAdd({tags, tag: t.name, allowlist});
|
|
return this._doAdd({tags, tag: mapped, allowlist});
|
|
}
|
|
|
|
if (this._isTraits(prop)) {
|
|
if (cleanName.startsWith("keen ")) return this._doAdd({tags, tag: "Keen Senses", allowlist});
|
|
if (cleanName.endsWith(" absorption")) return this._doAdd({tags, tag: "Damage Absorption", allowlist});
|
|
}
|
|
|
|
if (this._isActions(prop)) {
|
|
if (/\bbreath\b/.test(cleanName)) return this._doAdd({tags, tag: "Breath Weapon", allowlist});
|
|
}
|
|
|
|
if (cbMan) cbMan(prop, tags, cleanName);
|
|
});
|
|
}
|
|
|
|
static _doTagDeep ({m, prop, tags, allowlist}) {
|
|
if (!TraitActionTag._TAGS_DEEP[prop]) return;
|
|
if (!m[prop]) return;
|
|
|
|
m[prop].forEach(t => {
|
|
if (!t.entries) return;
|
|
const strEntries = JSON.stringify(t.entries);
|
|
|
|
Object.entries(TraitActionTag._TAGS_DEEP[prop])
|
|
.forEach(([tagName, fnShouldTag]) => {
|
|
if (fnShouldTag(strEntries)) this._doAdd({tags, tag: tagName, allowlist});
|
|
});
|
|
});
|
|
}
|
|
|
|
static _isTraits (prop) { return prop === "trait"; }
|
|
static _isActions (prop) { return prop === "action"; }
|
|
|
|
static tryRun (m, {cbMan, allowlistTraitTags, allowlistActionTags} = {}) {
|
|
const traitTags = new Set(m.traitTags || []);
|
|
const actionTags = new Set(m.actionTags || []);
|
|
|
|
this._doTag({m, cbMan, prop: "trait", tags: traitTags, allowlist: allowlistTraitTags});
|
|
this._doTag({m, cbMan, prop: "action", tags: actionTags, allowlist: allowlistActionTags});
|
|
this._doTag({m, cbMan, prop: "reaction", tags: actionTags, allowlist: allowlistActionTags});
|
|
this._doTag({m, cbMan, prop: "bonus", tags: actionTags, allowlist: allowlistActionTags});
|
|
|
|
this._doTagDeep({m, prop: "action", tags: actionTags, allowlist: allowlistActionTags});
|
|
|
|
if (traitTags.size) m.traitTags = [...traitTags].sort(SortUtil.ascSortLower);
|
|
if (actionTags.size) m.actionTags = [...actionTags].sort(SortUtil.ascSortLower);
|
|
}
|
|
}
|
|
|
|
globalThis.TraitActionTag = TraitActionTag;
|
|
|
|
class LanguageTag {
|
|
/**
|
|
* @param m A creature statblock.
|
|
* @param [opt] Options object.
|
|
* @param [opt.cbAll] Callback to run on every parsed language.
|
|
* @param [opt.cbTracked] Callback to run on every tracked language.
|
|
* @param [opt.isAppendOnly] If tags should only be added, not removed.
|
|
*/
|
|
static tryRun (m, opt) {
|
|
opt = opt || {};
|
|
|
|
const tags = new Set();
|
|
|
|
if (m.languages) {
|
|
m.languages = m.languages.map(it => it.trim()).filter(it => !TagUtil.isNoneOrEmpty(it));
|
|
if (!m.languages.length) {
|
|
delete m.languages;
|
|
return;
|
|
} else {
|
|
m.languages = m.languages.map(it => it.replace(/but can(not|'t) speak/ig, "but can't speak"));
|
|
}
|
|
|
|
m.languages.forEach(l => {
|
|
if (opt.cbAll) opt.cbAll(l);
|
|
|
|
Object.keys(LanguageTag.LANGUAGE_MAP).forEach(k => {
|
|
const v = LanguageTag.LANGUAGE_MAP[k];
|
|
|
|
const re = new RegExp(`(^|[^-a-zA-Z])${k}([^-a-zA-Z]|$)`, "g");
|
|
|
|
if (re.exec(l)) {
|
|
if ((v === "XX" || v === "X") && (l.includes("knew in life") || l.includes("spoke in life"))) return;
|
|
if (v !== "CS" && /(one|the) languages? of its creator/i.exec(l)) return;
|
|
|
|
if (opt.cbTracked) opt.cbTracked(v);
|
|
tags.add(v);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
if (tags.size) {
|
|
if (!opt.isAppendOnly) m.languageTags = [...tags];
|
|
else {
|
|
(m.languageTags || []).forEach(t => tags.add(t));
|
|
m.languageTags = [...tags];
|
|
}
|
|
} else if (!opt.isAppendOnly) delete m.languageTags;
|
|
}
|
|
}
|
|
LanguageTag.LANGUAGE_MAP = {
|
|
"Abyssal": "AB",
|
|
"Aquan": "AQ",
|
|
"Auran": "AU",
|
|
"Celestial": "CE",
|
|
"Common": "C",
|
|
"can't speak": "CS",
|
|
"Draconic": "DR",
|
|
"Dwarvish": "D",
|
|
"Elvish": "E",
|
|
"Giant": "GI",
|
|
"Gnomish": "G",
|
|
"Goblin": "GO",
|
|
"Halfling": "H",
|
|
"Infernal": "I",
|
|
"Orc": "O",
|
|
"Primordial": "P",
|
|
"Sylvan": "S",
|
|
"Terran": "T",
|
|
"Undercommon": "U",
|
|
"Aarakocra": "OTH",
|
|
"one additional": "X",
|
|
"Blink Dog": "OTH",
|
|
"Bothii": "OTH",
|
|
"Bullywug": "OTH",
|
|
"one other language": "X",
|
|
"plus six more": "X",
|
|
"plus two more languages": "X",
|
|
"up to five other languages": "X",
|
|
"Druidic": "DU",
|
|
"Giant Eagle": "OTH",
|
|
"Giant Elk": "OTH",
|
|
"Giant Owl": "OTH",
|
|
"Gith": "GTH",
|
|
"Grell": "OTH",
|
|
"Grung": "OTH",
|
|
"Homarid": "OTH",
|
|
"Hook Horror": "OTH",
|
|
"Ice Toad": "OTH",
|
|
"Ixitxachitl": "OTH",
|
|
"Kruthik": "OTH",
|
|
"Netherese": "OTH",
|
|
"Olman": "OTH",
|
|
"Otyugh": "OTH",
|
|
"Primal": "OTH",
|
|
"Sahuagin": "OTH",
|
|
"Sphinx": "OTH",
|
|
"Thayan": "OTH",
|
|
"Thri-kreen": "OTH",
|
|
"Tlincalli": "OTH",
|
|
"Troglodyte": "OTH",
|
|
"Umber Hulk": "OTH",
|
|
"Vegepygmy": "OTH",
|
|
"Winter Wolf": "OTH",
|
|
"Worg": "OTH",
|
|
"Yeti": "OTH",
|
|
"Yikaria": "OTH",
|
|
"all": "XX",
|
|
"all but rarely speaks": "XX",
|
|
"any one language": "X",
|
|
"any two languages": "X",
|
|
"any three languages": "X",
|
|
"any four languages": "X",
|
|
"any five languages": "X",
|
|
"any six languages": "X",
|
|
"one language of its creator's choice": "X",
|
|
"two other languages": "X",
|
|
"telepathy": "TP",
|
|
"thieves' cant": "TC",
|
|
"Thieves' cant": "TC",
|
|
"Deep Speech": "DS",
|
|
"Gnoll": "OTH",
|
|
"Ignan": "IG",
|
|
"Modron": "OTH",
|
|
"Slaad": "OTH",
|
|
"all languages": "XX",
|
|
"any language": "X",
|
|
"knew in life": "LF",
|
|
"spoke in life": "LF",
|
|
};
|
|
|
|
globalThis.LanguageTag = LanguageTag;
|
|
|
|
class SenseFilterTag {
|
|
static tryRun (m, cbAll) {
|
|
if (m.senses) {
|
|
m.senses = m.senses.filter(it => !TagUtil.isNoneOrEmpty(it));
|
|
if (!m.senses.length) delete m.senses;
|
|
else {
|
|
const senseTags = new Set();
|
|
m.senses.map(it => it.trim().toLowerCase())
|
|
.forEach(s => {
|
|
Object.entries(SenseFilterTag.TAGS).forEach(([k, v]) => {
|
|
if (s.includes(k)) {
|
|
if (v === "D" && /\d\d\d ft/.exec(s)) senseTags.add("SD");
|
|
else senseTags.add(v);
|
|
}
|
|
});
|
|
|
|
if (cbAll) cbAll(s);
|
|
});
|
|
|
|
if (senseTags.size === 0) delete m.senseTags;
|
|
else m.senseTags = [...senseTags];
|
|
}
|
|
} else delete m.senseTags;
|
|
}
|
|
}
|
|
SenseFilterTag.TAGS = {
|
|
"blindsight": "B",
|
|
"darkvision": "D",
|
|
"tremorsense": "T",
|
|
"truesight": "U",
|
|
};
|
|
|
|
globalThis.SenseFilterTag = SenseFilterTag;
|
|
|
|
class SpellcastingTypeTag {
|
|
static tryRun (m, cbAll) {
|
|
if (!m.spellcasting) {
|
|
delete m.spellcastingTags;
|
|
} else {
|
|
const tags = new Set();
|
|
m.spellcasting.forEach(sc => {
|
|
if (!sc.name) return;
|
|
|
|
let isAdded = false;
|
|
|
|
if (/(^|[^a-zA-Z])psionics([^a-zA-Z]|$)/gi.exec(sc.name)) { tags.add("P"); isAdded = true; }
|
|
if (/(^|[^a-zA-Z])innate([^a-zA-Z]|$)/gi.exec(sc.name)) { tags.add("I"); isAdded = true; }
|
|
if (/(^|[^a-zA-Z])form([^a-zA-Z]|$)/gi.exec(sc.name)) { tags.add("F"); isAdded = true; }
|
|
if (/(^|[^a-zA-Z])shared([^a-zA-Z]|$)/gi.exec(sc.name)) { tags.add("S"); isAdded = true; }
|
|
|
|
if (sc.headerEntries) {
|
|
const strHeader = JSON.stringify(sc.headerEntries);
|
|
Object.entries(SpellcastingTypeTag.CLASSES).forEach(([tag, regex]) => {
|
|
regex.lastIndex = 0;
|
|
const match = regex.exec(strHeader);
|
|
if (match) {
|
|
tags.add(tag);
|
|
isAdded = true;
|
|
if (cbAll) cbAll(match[0]);
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!isAdded) tags.add("O");
|
|
|
|
if (cbAll) cbAll(sc.name);
|
|
});
|
|
if (tags.size) m.spellcastingTags = [...tags];
|
|
else delete m.spellcastingTags;
|
|
}
|
|
}
|
|
}
|
|
SpellcastingTypeTag.CLASSES = {
|
|
"CA": /(^|[^a-zA-Z])artificer([^a-zA-Z]|$)/gi,
|
|
"CB": /(^|[^a-zA-Z])bard([^a-zA-Z]|$)/gi,
|
|
"CC": /(^|[^a-zA-Z])cleric([^a-zA-Z]|$)/gi,
|
|
"CD": /(^|[^a-zA-Z])druid([^a-zA-Z]|$)/gi,
|
|
"CP": /(^|[^a-zA-Z])paladin([^a-zA-Z]|$)/gi,
|
|
"CR": /(^|[^a-zA-Z])ranger([^a-zA-Z]|$)/gi,
|
|
"CS": /(^|[^a-zA-Z])sorcerer([^a-zA-Z]|$)/gi,
|
|
"CL": /(^|[^a-zA-Z])warlock([^a-zA-Z]|$)/gi,
|
|
"CW": /(^|[^a-zA-Z])wizard([^a-zA-Z]|$)/gi,
|
|
};
|
|
|
|
globalThis.SpellcastingTypeTag = SpellcastingTypeTag;
|
|
|
|
/** @abstract */
|
|
class _PrimaryLegendarySpellsTaggerBase {
|
|
static _IS_INIT = false;
|
|
static _WALKER = null;
|
|
|
|
static _PROP_PRIMARY;
|
|
static _PROP_SPELLS;
|
|
static _PROP_LEGENDARY;
|
|
|
|
static _BLOCKLIST_NAMES = null;
|
|
|
|
static _init () {
|
|
if (this._IS_INIT) return true;
|
|
this._IS_INIT = true;
|
|
this._WALKER = MiscUtil.getWalker({isNoModification: true, keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST});
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @abstract
|
|
* @return void
|
|
*/
|
|
static _handleString ({m = null, str, outSet}) {
|
|
throw new Error("Unimplemented!");
|
|
}
|
|
|
|
static _handleEntries ({m = null, entries, outSet}) {
|
|
this._WALKER.walk(
|
|
entries,
|
|
{
|
|
string: (str) => this._handleString({m, str, outSet}),
|
|
},
|
|
);
|
|
}
|
|
|
|
static _handleProp ({m, prop, outSet}) {
|
|
if (!m[prop]) return;
|
|
|
|
m[prop].forEach(it => {
|
|
if (
|
|
it.name
|
|
&& this._BLOCKLIST_NAMES
|
|
&& this._BLOCKLIST_NAMES.has(it.name.toLowerCase().trim().replace(/\([^)]+\)/g, ""))
|
|
) return;
|
|
|
|
if (!it.entries) return;
|
|
|
|
this._handleEntries({m, entries: it.entries, outSet});
|
|
});
|
|
}
|
|
|
|
static _setPropOut (
|
|
{
|
|
outSet,
|
|
m,
|
|
propOut,
|
|
isAppendOnly,
|
|
},
|
|
) {
|
|
if (!isAppendOnly) delete m[propOut];
|
|
if (!outSet.size) return;
|
|
m[propOut] = [...outSet].sort(SortUtil.ascSortLower);
|
|
}
|
|
|
|
static tryRun (m, {isAppendOnly = false} = {}) {
|
|
this._init();
|
|
|
|
const outSet = new Set();
|
|
Renderer.monster.CHILD_PROPS
|
|
.filter(prop => prop !== "spellcasting")
|
|
.forEach(prop => this._handleProp({m, prop, outSet}));
|
|
|
|
this._setPropOut({outSet, m, propOut: this._PROP_PRIMARY, isAppendOnly});
|
|
}
|
|
|
|
/**
|
|
* @abstract
|
|
* @return void
|
|
*/
|
|
static _handleSpell ({spell, outSet}) {
|
|
throw new Error("Unimplemented!");
|
|
}
|
|
|
|
static tryRunSpells (m, {cbMan, isAppendOnly = false} = {}) {
|
|
if (!m.spellcasting) return;
|
|
|
|
this._init();
|
|
|
|
const outSet = new Set();
|
|
|
|
const spells = TaggerUtils.getSpellsFromString(JSON.stringify(m.spellcasting), {cbMan});
|
|
|
|
spells.forEach(spell => this._handleSpell({spell, outSet}));
|
|
|
|
this._setPropOut({outSet, m, propOut: this._PROP_SPELLS, isAppendOnly});
|
|
}
|
|
|
|
static tryRunRegionalsLairs (m, {cbMan, isAppendOnly = false} = {}) {
|
|
if (!m.legendaryGroup) return;
|
|
|
|
this._init();
|
|
|
|
const meta = TaggerUtils.findLegendaryGroup({name: m.legendaryGroup.name, source: m.legendaryGroup.source});
|
|
if (!meta) return;
|
|
|
|
const outSet = new Set();
|
|
this._handleEntries({entries: meta, outSet});
|
|
|
|
// region Also add from spells contained in the legendary group
|
|
const spells = TaggerUtils.getSpellsFromString(JSON.stringify(meta), {cbMan});
|
|
spells.forEach(spell => this._handleSpell({spell, outSet}));
|
|
// endregion
|
|
|
|
this._setPropOut({outSet, m, propOut: this._PROP_LEGENDARY, isAppendOnly});
|
|
}
|
|
|
|
/** Attempt to detect an e.g. TCE summon creature. */
|
|
static _isSummon (m) {
|
|
if (!m) return false;
|
|
|
|
let isSummon = false;
|
|
|
|
const reProbableSummon = /level of the spell|spell level|\+\s*PB(?:\W|$)|your (?:[^?!.]+)?level/g;
|
|
|
|
this._WALKER.walk(
|
|
m.ac,
|
|
{
|
|
string: (str) => {
|
|
if (isSummon) return;
|
|
if (reProbableSummon.test(str)) isSummon = true;
|
|
},
|
|
},
|
|
);
|
|
if (isSummon) return true;
|
|
|
|
this._WALKER.walk(
|
|
m.hp,
|
|
{
|
|
string: (str) => {
|
|
if (isSummon) return;
|
|
if (reProbableSummon.test(str)) isSummon = true;
|
|
},
|
|
},
|
|
);
|
|
if (isSummon) return true;
|
|
}
|
|
}
|
|
|
|
class DamageTypeTag extends _PrimaryLegendarySpellsTaggerBase {
|
|
static _PROP_PRIMARY = "damageTags";
|
|
static _PROP_LEGENDARY = "damageTagsLegendary";
|
|
static _PROP_SPELLS = "damageTagsSpell";
|
|
|
|
// Avoid parsing these, as they commonly have e.g. "self-damage" sections
|
|
// Note that these names should exclude parenthetical parts (as these are removed before lookup)
|
|
static _BLOCKLIST_NAMES = new Set([
|
|
"vampire weaknesses",
|
|
]);
|
|
|
|
static _init () {
|
|
if (super._init()) return;
|
|
Object.entries(Parser.DMGTYPE_JSON_TO_FULL).forEach(([k, v]) => this._TYPE_LOOKUP[v] = k);
|
|
}
|
|
|
|
static _handleString ({m = null, str, outSet}) {
|
|
str.replace(RollerUtil.REGEX_DAMAGE_DICE, (m0, average, prefix, diceExp, suffix) => {
|
|
suffix.replace(ConverterConst.RE_DAMAGE_TYPE, (m0, type) => outSet.add(this._TYPE_LOOKUP[type]));
|
|
});
|
|
|
|
str.replace(this._STATIC_DAMAGE_REGEX, (m0, type) => {
|
|
outSet.add(this._TYPE_LOOKUP[type]);
|
|
});
|
|
|
|
str.replace(this._TARGET_TASKES_DAMAGE_REGEX, (m0, type) => {
|
|
outSet.add(this._TYPE_LOOKUP[type]);
|
|
});
|
|
|
|
if (this._isSummon(m)) {
|
|
str.split(/[.?!]/g)
|
|
.forEach(sentence => {
|
|
let isSentenceMatch = this._SUMMON_DAMAGE_REGEX.test(sentence);
|
|
if (!isSentenceMatch) return;
|
|
|
|
sentence.replace(ConverterConst.RE_DAMAGE_TYPE, (m0, type) => {
|
|
outSet.add(this._TYPE_LOOKUP[type]);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
static _handleSpell ({spell, outSet}) {
|
|
if (!spell.damageInflict) return;
|
|
spell.damageInflict.forEach(it => outSet.add(DamageTypeTag._TYPE_LOOKUP[it]));
|
|
}
|
|
}
|
|
DamageTypeTag._STATIC_DAMAGE_REGEX = new RegExp(`\\d+ ${ConverterConst.STR_RE_DAMAGE_TYPE} damage`, "gi");
|
|
DamageTypeTag._TARGET_TASKES_DAMAGE_REGEX = new RegExp(`(?:a|the) target takes (?:{@dice |{@damage )[^}]+} ?${ConverterConst.STR_RE_DAMAGE_TYPE} damage`, "gi");
|
|
DamageTypeTag._SUMMON_DAMAGE_REGEX = /(?:{@dice |{@damage )[^}]+}(?:\s*\+\s*the spell's level)? ([a-z]+( \([-a-zA-Z0-9 ]+\))?( or [a-z]+( \([-a-zA-Z0-9 ]+\))?)? damage)/gi;
|
|
DamageTypeTag._TYPE_LOOKUP = {};
|
|
|
|
globalThis.DamageTypeTag = DamageTypeTag;
|
|
|
|
class MiscTag {
|
|
static _MELEE_WEAPON_MATCHERS = null;
|
|
static _RANGED_WEAPON_MATCHERS = null;
|
|
static _THROWN_WEAPON_MATCHERS = null;
|
|
|
|
static _IS_INIT = false;
|
|
|
|
static init ({items}) {
|
|
if (this._IS_INIT) return;
|
|
this._IS_INIT = true;
|
|
|
|
const weaponsBase = items
|
|
.filter(it => it._category === "Basic" && (it.type === "M" || it.type === "W"));
|
|
|
|
this._MELEE_WEAPON_MATCHERS = weaponsBase
|
|
.filter(it => it.type === "M")
|
|
.map(it => new RegExp(`(^|\\W)(${it.name.escapeRegexp()})(\\W|$)`, "gi"));
|
|
|
|
this._RANGED_WEAPON_MATCHERS = weaponsBase
|
|
.filter(it => it.type === "R")
|
|
.map(it => new RegExp(`(^|\\W)(${it.name.escapeRegexp()})(\\W|$)`, "gi"));
|
|
|
|
this._THROWN_WEAPON_MATCHERS = weaponsBase
|
|
.filter(it => it.property?.includes("T"))
|
|
.map(it => new RegExp(`(^|\\W)(${it.name.escapeRegexp()})(\\W|$)`, "gi"));
|
|
}
|
|
|
|
/** @return empty string for easy use in `.replace` */
|
|
static _addTag ({tagSet, allowlistTags, tag}) {
|
|
if (allowlistTags != null && !allowlistTags.has(tag)) return "";
|
|
tagSet.add(tag);
|
|
return "";
|
|
}
|
|
|
|
static _handleProp ({m, prop, tagSet, allowlistTags}) {
|
|
if (!m[prop]) return;
|
|
|
|
m[prop].forEach(it => {
|
|
let hasRangedAttack = false;
|
|
|
|
const strEntries = it.entries ? JSON.stringify(it.entries, null, "\t") : null;
|
|
|
|
if (strEntries) {
|
|
// Weapon attacks
|
|
// - any melee/ranged attack
|
|
strEntries.replace(/{@atk ([^}]+)}/g, (...mx) => {
|
|
const spl = mx[1].split(",");
|
|
if (spl.includes("rw")) {
|
|
this._addTag({tagSet, allowlistTags, tag: "RW"});
|
|
hasRangedAttack = true;
|
|
}
|
|
if (spl.includes("mw")) this._addTag({tagSet, allowlistTags, tag: "MW"});
|
|
});
|
|
|
|
// - reach
|
|
strEntries.replace(/reach (\d+) ft\./g, (...m) => {
|
|
if (Number(m[1]) > 5) this._addTag({tagSet, allowlistTags, tag: "RCH"});
|
|
});
|
|
|
|
// AoE effects
|
|
strEntries.replace(/\d+-foot[- ](line|cube|cone|radius|sphere|hemisphere|cylinder)/g, () => this._addTag({tagSet, allowlistTags, tag: "AOE"}));
|
|
strEntries.replace(/each creature within \d+ feet/gi, () => this._addTag({tagSet, allowlistTags, tag: "AOE"}));
|
|
|
|
strEntries.replace(/\bhit point maximum is reduced\b/gi, () => this._addTag({tagSet, allowlistTags, tag: "HPR"}));
|
|
}
|
|
|
|
if (it.name) {
|
|
// Melee weapons
|
|
// Ranged weapon
|
|
[
|
|
{res: this._MELEE_WEAPON_MATCHERS, tag: "MLW"},
|
|
{res: this._RANGED_WEAPON_MATCHERS, tag: "RNG"},
|
|
]
|
|
.forEach(({res, tag}) => {
|
|
res
|
|
.forEach(re => {
|
|
it.name
|
|
.replace(re, () => {
|
|
const mAtk = /{@atk ([^}]+)}/.exec(strEntries || "");
|
|
if (mAtk) {
|
|
const spl = mAtk[1].split(",");
|
|
// Avoid adding the "ranged attack" tag for spell attacks
|
|
if (spl.includes("rs")) return "";
|
|
}
|
|
this._addTag({tagSet, allowlistTags, tag});
|
|
return "";
|
|
});
|
|
});
|
|
});
|
|
|
|
// Thrown weapon
|
|
if (hasRangedAttack) this._THROWN_WEAPON_MATCHERS.forEach(r => it.name.replace(r, () => this._addTag({tagSet, allowlistTags, tag: "THW"})));
|
|
}
|
|
});
|
|
}
|
|
|
|
static tryRun (m, {isAdditiveOnly = false, allowlistTags = null} = {}) {
|
|
const tagSet = new Set(isAdditiveOnly ? m.miscTags || [] : []);
|
|
MiscTag._handleProp({m, prop: "action", tagSet, allowlistTags});
|
|
MiscTag._handleProp({m, prop: "trait", tagSet, allowlistTags});
|
|
MiscTag._handleProp({m, prop: "reaction", tagSet, allowlistTags});
|
|
MiscTag._handleProp({m, prop: "bonus", tagSet, allowlistTags});
|
|
MiscTag._handleProp({m, prop: "legendary", tagSet, allowlistTags});
|
|
MiscTag._handleProp({m, prop: "mythic", tagSet, allowlistTags});
|
|
if (tagSet.size) m.miscTags = [...tagSet];
|
|
else if (!isAdditiveOnly) delete m.miscTags;
|
|
}
|
|
}
|
|
|
|
globalThis.MiscTag = MiscTag;
|
|
|
|
class SpellcastingTraitConvert {
|
|
static SPELL_SRC_MAP = {};
|
|
static SPELL_SRD_MAP = {};
|
|
|
|
static init (spellData) {
|
|
spellData.forEach(s => {
|
|
this.SPELL_SRC_MAP[s.name.toLowerCase()] = s.source;
|
|
if (typeof s.srd === "string") this.SPELL_SRD_MAP[s.srd.toLowerCase()] = s.name;
|
|
});
|
|
}
|
|
|
|
static tryParseSpellcasting (ent, {isMarkdown, cbMan, cbErr, displayAs, actions, reactions}) {
|
|
try {
|
|
return this._parseSpellcasting({ent, isMarkdown, cbMan, displayAs, actions, reactions});
|
|
} catch (e) {
|
|
cbErr && cbErr(`Failed to parse spellcasting: ${e.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static _parseSpellcasting ({ent, isMarkdown, cbMan, displayAs, actions, reactions}) {
|
|
const spellcastingEntry = {
|
|
"name": ent.name,
|
|
"type": "spellcasting",
|
|
"headerEntries": [],
|
|
};
|
|
|
|
const headerEntry = this._getMutHeaderEntries({ent, cbMan, spellcastingEntry});
|
|
spellcastingEntry.headerEntries.push(headerEntry);
|
|
|
|
let hasAnyHeader = false;
|
|
ent.entries
|
|
.slice(1)
|
|
.forEach(line => {
|
|
line = line.replace(/,\s*\*/g, ",*"); // put asterisks on the correct side of commas
|
|
|
|
const usesMeta = this._getUsesMeta({line, isMarkdown});
|
|
if (usesMeta) {
|
|
hasAnyHeader = true;
|
|
|
|
const value = this._getParsedSpells({line: usesMeta.lineRemaining});
|
|
MiscUtil.getOrSet(spellcastingEntry, usesMeta.prop, usesMeta.propPer, value);
|
|
|
|
return;
|
|
}
|
|
|
|
if (/^Constant(?::| -) /.test(line)) {
|
|
hasAnyHeader = true;
|
|
spellcastingEntry.constant = this._getParsedSpells({line, isMarkdown});
|
|
return;
|
|
}
|
|
|
|
if (/^At[- ][Ww]ill(?::| -) /.test(line)) {
|
|
hasAnyHeader = true;
|
|
spellcastingEntry.will = this._getParsedSpells({line, isMarkdown});
|
|
return;
|
|
}
|
|
|
|
if (line.includes("Cantrip")) {
|
|
hasAnyHeader = true;
|
|
const value = this._getParsedSpells({line, isMarkdown});
|
|
if (!spellcastingEntry.spells) spellcastingEntry.spells = {"0": {"spells": []}};
|
|
spellcastingEntry.spells["0"].spells = value;
|
|
return;
|
|
}
|
|
|
|
if (/[- ][Ll]evel/.test(line) && /(?::| -) /.test(line)) {
|
|
hasAnyHeader = true;
|
|
let property = line.substring(0, 1);
|
|
const allSpells = this._getParsedSpells({line, isMarkdown});
|
|
spellcastingEntry.spells = spellcastingEntry.spells || {};
|
|
|
|
const out = {};
|
|
if (line.includes(" slot")) {
|
|
const mWarlock = /^(\d)..(?:[- ][Ll]evel)?-(\d)..[- ][Ll]evel \((\d) (\d)..[- ][Ll]evel slots?\)/.exec(line);
|
|
if (mWarlock) {
|
|
out.lower = parseInt(mWarlock[1]);
|
|
out.slots = parseInt(mWarlock[3]);
|
|
property = mWarlock[4];
|
|
} else {
|
|
const mSlots = /\((\d) slots?\)/.exec(line);
|
|
if (!mSlots) throw new Error(`Could not find slot count!`);
|
|
out.slots = parseInt(mSlots[1]);
|
|
}
|
|
}
|
|
// add these last, to have nicer ordering
|
|
out.spells = allSpells;
|
|
|
|
spellcastingEntry.spells[property] = out;
|
|
return;
|
|
}
|
|
|
|
if (hasAnyHeader) {
|
|
(spellcastingEntry.footerEntries ||= []).push(this._parseToHit(line));
|
|
} else {
|
|
spellcastingEntry.headerEntries.push(this._parseToHit(line));
|
|
}
|
|
});
|
|
|
|
SpellcastingTraitConvert.mutSpellcastingAbility(spellcastingEntry);
|
|
SpellcastingTraitConvert._mutDisplayAs(spellcastingEntry, displayAs);
|
|
|
|
this._addSplitOutSpells({spellcastingEntry, arrayOther: actions});
|
|
this._addSplitOutSpells({spellcastingEntry, arrayOther: reactions});
|
|
|
|
return spellcastingEntry;
|
|
}
|
|
|
|
static _getMutHeaderEntries ({ent, cbMan, spellcastingEntry}) {
|
|
let line = this._parseToHit(ent.entries[0]);
|
|
|
|
const usesMeta = this._getUsesMeta({line: ent.name});
|
|
|
|
line = line
|
|
.replace(/(?<pre>casts? (?:the )?)(?<spell>[^.,?!:]+)(?<post>\.| spell |at[ -]will)/g, (...m) => {
|
|
const isWill = m.last().post.toLowerCase().replace(/-/g, " ") === "at will";
|
|
|
|
if (!usesMeta && !isWill) {
|
|
cbMan(`Found spell in header with no usage info: ${m.last().spell}`);
|
|
return m[0];
|
|
}
|
|
|
|
const ptSpells = m.last().spell
|
|
.split(" and ")
|
|
.map(sp => {
|
|
const value = this._getParsedSpells({line: sp});
|
|
const hidden = MiscUtil.getOrSet(spellcastingEntry, "hidden", []);
|
|
|
|
if (isWill) {
|
|
const tgt = MiscUtil.getOrSet(spellcastingEntry, "will", []);
|
|
tgt.push(...value);
|
|
|
|
if (!hidden.includes("will")) hidden.push("will");
|
|
} else {
|
|
const tgt = MiscUtil.getOrSet(spellcastingEntry, usesMeta.prop, usesMeta.propPer, []);
|
|
tgt.push(...value);
|
|
|
|
if (!hidden.includes(usesMeta.prop)) hidden.push(usesMeta.prop);
|
|
}
|
|
|
|
return value.join(", ");
|
|
})
|
|
.join(" and ");
|
|
|
|
return [
|
|
m.last().pre,
|
|
ptSpells,
|
|
m.last().post,
|
|
]
|
|
.join(" ")
|
|
.replace(/ +/g, " ");
|
|
});
|
|
|
|
return line;
|
|
}
|
|
|
|
static _getUsesMeta ({line}) {
|
|
const perDurations = [
|
|
{re: /(?<cnt>\d+)\/rest(?<ptEach> each)?/i, prop: "rest"},
|
|
{re: /(?<cnt>\d+)\/day(?<ptEach> each)?/i, prop: "daily"},
|
|
{re: /(?<cnt>\d+)\/week(?<ptEach> each)?/i, prop: "weekly"},
|
|
{re: /(?<cnt>\d+)\/month(?<ptEach> each)?/i, prop: "monthly"},
|
|
{re: /(?<cnt>\d+)\/yeark(?<ptEach> each)?/i, prop: "yearly"},
|
|
];
|
|
|
|
const metasPerDuration = perDurations
|
|
.map(({re, prop}) => ({m: re.exec(line), prop}))
|
|
.filter(({m}) => !!m);
|
|
if (!metasPerDuration.length) return null;
|
|
|
|
// Arbitrarily pick the first
|
|
const [metaPerDuration] = metasPerDuration;
|
|
|
|
const propPer = `${metaPerDuration.m.groups.cnt}${metaPerDuration.m.groups.ptEach ? "e" : ""}`;
|
|
|
|
return {
|
|
prop: metaPerDuration.prop,
|
|
propPer,
|
|
lineRemaining: line.slice(metaPerDuration.m.length),
|
|
};
|
|
}
|
|
|
|
static _getParsedSpells ({line, isMarkdown}) {
|
|
const mLabelSep = /(?::| -) /.exec(line);
|
|
let spellPart = line.substring((mLabelSep?.index || 0) + (mLabelSep?.[0]?.length || 0)).trim();
|
|
|
|
if (isMarkdown) {
|
|
const cleanPart = (part) => {
|
|
part = part.trim();
|
|
while (part.startsWith("*") && part.endsWith("*")) {
|
|
part = part.replace(/^\*(.*)\*$/, "$1");
|
|
}
|
|
return part;
|
|
};
|
|
|
|
const cleanedInner = spellPart.split(StrUtil.COMMAS_NOT_IN_PARENTHESES_REGEX).map(it => cleanPart(it)).filter(it => it);
|
|
spellPart = cleanedInner.join(", ");
|
|
|
|
while (spellPart.startsWith("*") && spellPart.endsWith("*")) {
|
|
spellPart = spellPart.replace(/^\*(.*)\*$/, "$1");
|
|
}
|
|
}
|
|
|
|
// move asterisks before commas (e.g. "chaos bolt,*" -> "chaos bolt*,")
|
|
spellPart = spellPart.replace(/,\s*\*/g, "*,");
|
|
|
|
return spellPart.split(StrUtil.COMMAS_NOT_IN_PARENTHESES_REGEX).map(it => this._parseSpell(it));
|
|
}
|
|
|
|
static _parseSpell (str) {
|
|
str = str.trim();
|
|
|
|
const ptsSuffix = [];
|
|
|
|
// region Homebrew (e.g. "Flee, Mortals!", page 3)
|
|
const mBrewSuffixCastingTime = / +(?<time>[ABR+])\s*$/.exec(str);
|
|
if (mBrewSuffixCastingTime) {
|
|
str = str.slice(0, -mBrewSuffixCastingTime[0].length);
|
|
const action = mBrewSuffixCastingTime.groups.time;
|
|
// TODO(Future) pass in source?
|
|
ptsSuffix.unshift(`{@sup {@cite Casting Times|FleeMortals|${action}}}`);
|
|
}
|
|
// endregion
|
|
|
|
const ixAsterisk = str.indexOf("*");
|
|
if (~ixAsterisk) {
|
|
ptsSuffix.unshift("*");
|
|
str = str.substring(0, ixAsterisk);
|
|
}
|
|
|
|
const ixParenOpen = str.indexOf(" (");
|
|
if (~ixParenOpen) {
|
|
ptsSuffix.unshift(str.substring(ixParenOpen).trim());
|
|
str = str.substring(0, ixParenOpen);
|
|
}
|
|
|
|
str = this._parseSpell_getNonSrdSpellName(str);
|
|
|
|
return [
|
|
`{@spell ${str}${this._parseSpell_getSourcePart(str)}}`,
|
|
ptsSuffix.join(" "),
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
}
|
|
|
|
static _parseSpell_getNonSrdSpellName (spellName) {
|
|
const nonSrdName = SpellcastingTraitConvert.SPELL_SRD_MAP[spellName.toLowerCase().trim()];
|
|
if (!nonSrdName) return spellName;
|
|
|
|
if (spellName.toSpellCase() === spellName) return nonSrdName.toSpellCase();
|
|
if (spellName.toLowerCase() === spellName) return nonSrdName.toLowerCase();
|
|
if (spellName.toTitleCase() === spellName) return nonSrdName.toTitleCase();
|
|
return spellName;
|
|
}
|
|
|
|
static _parseSpell_getSourcePart (spellName) {
|
|
const source = SpellcastingTraitConvert._getSpellSource(spellName);
|
|
return `${source && source !== Parser.SRC_PHB ? `|${source}` : ""}`;
|
|
}
|
|
|
|
static _parseToHit (line) {
|
|
return line.replace(/ ([-+])(\d+)( to hit with spell)/g, (m0, m1, m2, m3) => ` {@hit ${m1 === "-" ? "-" : ""}${m2}}${m3}`);
|
|
}
|
|
|
|
static mutSpellcastingAbility (spellcastingEntry) {
|
|
if (spellcastingEntry.headerEntries) {
|
|
const m = /strength|dexterity|constitution|charisma|intelligence|wisdom/gi.exec(JSON.stringify(spellcastingEntry.headerEntries));
|
|
if (m) spellcastingEntry.ability = m[0].substring(0, 3).toLowerCase();
|
|
}
|
|
}
|
|
|
|
static _mutDisplayAs (spellcastingEntry, displayAs) {
|
|
if (!displayAs || displayAs === "trait") return;
|
|
spellcastingEntry.displayAs = displayAs;
|
|
}
|
|
|
|
static _getSpellSource (spellName) {
|
|
if (spellName && SpellcastingTraitConvert.SPELL_SRC_MAP[spellName.toLowerCase()]) return SpellcastingTraitConvert.SPELL_SRC_MAP[spellName.toLowerCase()];
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Add other actions/reactions with names such as:
|
|
* - "Fire Storm (7th-Level Spell; 1/Day)"
|
|
* - "Shocking Grasp (Cantrip)"
|
|
* - "Shield (1st-Level Spell; 3/Day)"
|
|
* as hidden spells (if they don't already exist). */
|
|
static _addSplitOutSpells ({spellcastingEntry, arrayOther}) {
|
|
if (!arrayOther?.length) return;
|
|
arrayOther.forEach(ent => {
|
|
if (!ent.name) return;
|
|
const mName = /^(.*?) \((\d(?:st|nd|rd|th)-level spell; (\d+\/day)|cantrip)\)/i.exec(ent.name);
|
|
if (!mName) return;
|
|
|
|
const [, spellName, spellLevelRecharge, spellRecharge] = mName;
|
|
|
|
const spellTag = this._parseSpell(spellName);
|
|
const uids = this._getSpellUids(spellTag);
|
|
|
|
if (spellLevelRecharge.toLowerCase() === "cantrip") {
|
|
spellcastingEntry.will = spellcastingEntry.will || [];
|
|
if (this._isExistingSpell(spellcastingEntry.will, uids)) return;
|
|
spellcastingEntry.will.push({entry: spellTag, hidden: true});
|
|
return;
|
|
}
|
|
|
|
const [numCharges, rechargeDuration] = spellRecharge.toLowerCase().split("/").map(it => it.trim()).filter(Boolean);
|
|
switch (rechargeDuration) {
|
|
case "day": {
|
|
const chargeKey = `${numCharges}e`;
|
|
const tgt = MiscUtil.getOrSet(spellcastingEntry, "daily", chargeKey, []);
|
|
if (this._isExistingSpell(tgt, uids)) return;
|
|
tgt.push({entry: spellTag, hidden: true});
|
|
break;
|
|
}
|
|
|
|
// (expand this as required)
|
|
|
|
default: throw new Error(`Unhandled recharge duration "${rechargeDuration}"`);
|
|
}
|
|
});
|
|
}
|
|
|
|
static _getSpellUids (str) {
|
|
const uids = [];
|
|
str.replace(/{@spell ([^}]+)}/gi, (...m) => {
|
|
const [name, source = Parser.SRC_PHB.toLowerCase()] = m[1].toLowerCase().split("|").map(it => it.trim());
|
|
uids.push(`${name}|${source}`);
|
|
});
|
|
return uids;
|
|
}
|
|
|
|
static _isExistingSpell (spellArray, uids) {
|
|
return spellArray.some(it => {
|
|
const str = (it.entry || it).toLowerCase().trim();
|
|
const existingUids = this._getSpellUids(str);
|
|
return existingUids.some(it => uids.includes(it));
|
|
});
|
|
}
|
|
}
|
|
|
|
globalThis.SpellcastingTraitConvert = SpellcastingTraitConvert;
|
|
|
|
class RechargeConvert {
|
|
static tryConvertRecharge (traitOrAction, cbAll, cbMan) {
|
|
if (traitOrAction.name) {
|
|
traitOrAction.name = traitOrAction.name.replace(/\((Recharge )(\d.*?)\)$/gi, (...m) => {
|
|
if (cbAll) cbAll(m[2]);
|
|
const num = m[2][0];
|
|
if (num === "6") return `{@recharge}`;
|
|
if (isNaN(Number(num))) {
|
|
if (cbMan) cbMan(traitOrAction.name);
|
|
return m[0];
|
|
}
|
|
return `{@recharge ${num}}`;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
globalThis.RechargeConvert = RechargeConvert;
|
|
|
|
class SpeedConvert {
|
|
static _splitSpeed (str) {
|
|
let c;
|
|
let ret = [];
|
|
let stack = "";
|
|
let para = 0;
|
|
for (let i = 0; i < str.length; ++i) {
|
|
c = str.charAt(i);
|
|
switch (c) {
|
|
case ",":
|
|
if (para === 0) {
|
|
ret.push(stack);
|
|
stack = "";
|
|
}
|
|
break;
|
|
case "(": para++; stack += c; break;
|
|
case ")": para--; stack += c; break;
|
|
default: stack += c;
|
|
}
|
|
}
|
|
if (stack) ret.push(stack);
|
|
return ret.map(it => it.trim()).filter(it => it);
|
|
}
|
|
|
|
static _tagHover (m) {
|
|
if (m.speed && m.speed.fly && m.speed.fly.condition) {
|
|
m.speed.fly.condition = m.speed.fly.condition.trim();
|
|
|
|
if (m.speed.fly.condition.toLowerCase().includes("hover")) m.speed.canHover = true;
|
|
}
|
|
}
|
|
|
|
static tryConvertSpeed (m, cbMan) {
|
|
if (typeof m.speed === "string") {
|
|
let line = m.speed.toLowerCase().trim().replace(/^speed[:.]?\s*/, "");
|
|
|
|
const out = {};
|
|
let byHand = false;
|
|
let prevSpeed = null;
|
|
|
|
SpeedConvert._splitSpeed(line.toLowerCase()).map(it => it.trim()).forEach(s => {
|
|
// For e.g. shapechanger speeds, store them behind a "condition" on the previous speed
|
|
const mParens = /^\((\w+?\s+)?(\d+)\s*ft\.?( .*)?\)$/.exec(s);
|
|
if (mParens && prevSpeed) {
|
|
if (typeof out[prevSpeed] === "number") out[prevSpeed] = {number: out[prevSpeed], condition: s};
|
|
else out[prevSpeed].condition = s;
|
|
return;
|
|
}
|
|
|
|
const m = /^(\w+?\s+)?(\d+)\s*ft\.?( .*)?$/.exec(s);
|
|
if (!m) {
|
|
byHand = true;
|
|
return;
|
|
}
|
|
|
|
let [_, mode, feet, condition] = m;
|
|
feet = Number(feet);
|
|
|
|
if (mode) mode = mode.trim().toLowerCase();
|
|
else mode = "walk";
|
|
|
|
if (SpeedConvert._SPEED_TYPES.has(mode)) {
|
|
if (condition) {
|
|
out[mode] = {
|
|
number: Number(feet),
|
|
condition: condition.trim(),
|
|
};
|
|
} else out[mode] = Number(feet);
|
|
prevSpeed = mode;
|
|
} else {
|
|
byHand = true;
|
|
prevSpeed = null;
|
|
}
|
|
});
|
|
|
|
// flag speed as invalid
|
|
if (Object.values(out).filter(s => (s.number != null ? s.number : s) % 5 !== 0).length) out.INVALID_SPEED = true;
|
|
|
|
// flag speed as needing hand-parsing
|
|
if (byHand) {
|
|
out.UNPARSED_SPEED = line;
|
|
if (cbMan) cbMan(`${m.name ? `(${m.name}) ` : ""}Speed requires manual conversion: "${line}"`);
|
|
}
|
|
|
|
m.speed = out;
|
|
SpeedConvert._tagHover(m);
|
|
}
|
|
}
|
|
}
|
|
SpeedConvert._SPEED_TYPES = new Set(Parser.SPEED_MODES);
|
|
|
|
globalThis.SpeedConvert = SpeedConvert;
|
|
|
|
class DetectNamedCreature {
|
|
static tryRun (mon) {
|
|
if (this._tryRun_nickname(mon)) return;
|
|
this._tryRun_heuristic(mon);
|
|
}
|
|
|
|
static _tryRun_nickname (mon) {
|
|
if (
|
|
/^[^"]+ "[^"]+" [^"]+/.test(mon.name)
|
|
|| /^[^']+ '[^']+' [^']+/.test(mon.name)
|
|
) {
|
|
mon.isNamedCreature = true;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static _tryRun_heuristic (mon) {
|
|
const totals = {yes: 0, no: 0};
|
|
|
|
this._doCheckProp(mon, totals, "trait");
|
|
this._doCheckProp(mon, totals, "spellcasting");
|
|
this._doCheckProp(mon, totals, "action");
|
|
this._doCheckProp(mon, totals, "reaction");
|
|
this._doCheckProp(mon, totals, "bonus");
|
|
this._doCheckProp(mon, totals, "legendary");
|
|
this._doCheckProp(mon, totals, "mythic");
|
|
|
|
if (totals.yes && totals.yes > totals.no) mon.isNamedCreature = true;
|
|
|
|
return true;
|
|
}
|
|
|
|
static _doCheckProp (mon, totals, prop) {
|
|
if (!mon.name) return;
|
|
if (mon.isNamedCreature) return;
|
|
if (!mon[prop]) return;
|
|
|
|
mon[prop].forEach(it => {
|
|
const prop = it.entries?.length ? "entries" : it.headerEntries?.length ? "headerEntries" : null;
|
|
if (!prop) return;
|
|
if (typeof it[prop][0] !== "string") return;
|
|
|
|
const namePart = (mon.name.split(/[ ,:.!;]/g)[0] || "").trim().escapeRegexp();
|
|
|
|
const isNotNamedCreature = new RegExp(`^The ${namePart}`).test(it[prop][0]);
|
|
const isNamedCreature = new RegExp(`^${namePart}`).test(it[prop][0]);
|
|
|
|
if (isNotNamedCreature && isNamedCreature) return;
|
|
if (isNamedCreature) totals.yes++;
|
|
if (isNotNamedCreature) totals.no++;
|
|
});
|
|
}
|
|
}
|
|
|
|
globalThis.DetectNamedCreature = DetectNamedCreature;
|
|
|
|
class TagImmResVulnConditional {
|
|
static tryRun (mon) {
|
|
this._handleProp(mon, "resist");
|
|
this._handleProp(mon, "immune");
|
|
this._handleProp(mon, "vulnerable");
|
|
this._handleProp(mon, "conditionImmune");
|
|
}
|
|
|
|
static _handleProp (mon, prop) {
|
|
if (!mon[prop] || !(mon[prop] instanceof Array)) return;
|
|
mon[prop].forEach(it => this._handleProp_recurse(it, prop));
|
|
}
|
|
|
|
static _handleProp_recurse (obj, prop) {
|
|
if (obj.note) {
|
|
const note = obj.note.toLowerCase().trim().replace(/^\(/, "").replace(/^damage/, "").trim();
|
|
if (
|
|
note.startsWith("while ")
|
|
|| note.startsWith("from ")
|
|
|| note.startsWith("from ")
|
|
|| note.startsWith("if ")
|
|
|| note.startsWith("against ")
|
|
|| note.startsWith("except ")
|
|
|| note.startsWith("with ")
|
|
|| note.startsWith("that is ")
|
|
|| /in .* form$/i.test(note)
|
|
) {
|
|
obj.cond = true;
|
|
}
|
|
}
|
|
|
|
if (obj[prop]) obj[prop].forEach(it => this._handleProp_recurse(it, prop));
|
|
}
|
|
}
|
|
|
|
globalThis.TagImmResVulnConditional = TagImmResVulnConditional;
|
|
|
|
class DragonAgeTag {
|
|
static tryRun (mon) {
|
|
const type = mon.type?.type ?? mon.type;
|
|
if (type !== "dragon") return;
|
|
|
|
mon.name.replace(/\b(?<age>young|adult|wyrmling|greatwyrm|ancient|aspect)\b/i, (...m) => {
|
|
mon.dragonAge = m.last().age.toLowerCase();
|
|
});
|
|
}
|
|
}
|
|
|
|
globalThis.DragonAgeTag = DragonAgeTag;
|
|
|
|
class AttachedItemTag {
|
|
static _WEAPON_DETAIL_CACHE;
|
|
|
|
static init ({items}) {
|
|
this._WEAPON_DETAIL_CACHE ||= {};
|
|
|
|
for (const item of items) {
|
|
if (item.type === "GV") continue;
|
|
if (!["M", "R"].includes(item.type)) continue;
|
|
|
|
const lowName = item.name.toLowerCase();
|
|
// If there's e.g. a " +1" suffix on the end, make a copy with it as a prefix instead
|
|
const prefixBonusKey = lowName.replace(/^(.*?)( \+\d+)$/, (...m) => `${m[2].trim()} ${m[1].trim()}`);
|
|
// And vice-versa
|
|
const suffixBonusKey = lowName.replace(/^(\+\d+) (.*?)$/, (...m) => `${m[2].trim()} ${m[1].trim()}`);
|
|
const suffixBonusKeyComma = lowName.replace(/^(\+\d+) (.*?)$/, (...m) => `${m[2].trim()}, ${m[1].trim()}`);
|
|
|
|
const itemKeys = [
|
|
lowName,
|
|
prefixBonusKey === lowName ? null : prefixBonusKey,
|
|
suffixBonusKey === lowName ? null : suffixBonusKey,
|
|
suffixBonusKeyComma === lowName ? null : suffixBonusKeyComma,
|
|
].filter(Boolean);
|
|
|
|
const cpy = MiscUtil.copy(item);
|
|
|
|
itemKeys.forEach(k => {
|
|
if (!this._WEAPON_DETAIL_CACHE[k]) {
|
|
this._WEAPON_DETAIL_CACHE[k] = cpy;
|
|
return;
|
|
}
|
|
|
|
// If there is already something in the cache, prefer DMG + PHB entries, then official sources
|
|
const existing = this._WEAPON_DETAIL_CACHE[k];
|
|
if (
|
|
!(existing.source === Parser.SRC_DMG || existing.source === Parser.SRC_PHB)
|
|
&& SourceUtil.isNonstandardSource(existing.source)
|
|
) {
|
|
this._WEAPON_DETAIL_CACHE[k] = cpy;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
static _isLikelyWeapon (act) {
|
|
if (!act.entries?.length || typeof act.entries[0] !== "string") return false;
|
|
const mAtk = /^{@atk ([^}]+)}/.exec(act.entries[0].trim());
|
|
if (!mAtk) return;
|
|
return mAtk[1].split(",").some(it => it.includes("w"));
|
|
}
|
|
|
|
// FIXME tags too aggressively; should limit by e.g.:
|
|
// - for creatures with a known "book" source, never use items from a known "adventure" source
|
|
// - for creatures with a known "adventure" source, never use items from a *different* "adventure" source
|
|
// - for a creature from a known source, never tag items from a more recent known source
|
|
static tryRun (mon, {cbNotFound = null, isAddOnly = false} = {}) {
|
|
if (!this._WEAPON_DETAIL_CACHE) throw new Error(`Attached item cache was not initialized!`);
|
|
|
|
if (!mon.action?.length) return;
|
|
|
|
const itemSet = new Set();
|
|
|
|
mon.action
|
|
.forEach(act => {
|
|
const weapon = this._WEAPON_DETAIL_CACHE[Renderer.monsterAction.getWeaponLookupName(act)];
|
|
if (weapon) return itemSet.add(DataUtil.proxy.getUid("item", weapon));
|
|
|
|
if (!cbNotFound) return;
|
|
|
|
if (!this._isLikelyWeapon(act)) return;
|
|
|
|
cbNotFound(act.name);
|
|
});
|
|
|
|
if (isAddOnly && mon.attachedItems) mon.attachedItems.forEach(it => itemSet.add(it));
|
|
|
|
if (!itemSet.size) delete mon.attachedItems;
|
|
else mon.attachedItems = [...itemSet].sort(SortUtil.ascSortLower);
|
|
}
|
|
}
|
|
|
|
globalThis.AttachedItemTag = AttachedItemTag;
|
|
|
|
class CreatureSavingThrowTagger extends _PrimaryLegendarySpellsTaggerBase {
|
|
static _PROP_PRIMARY = "savingThrowForced";
|
|
static _PROP_SPELLS = "savingThrowForcedSpell";
|
|
static _PROP_LEGENDARY = "savingThrowForcedLegendary";
|
|
|
|
static _handleString ({m = null, str, outSet}) {
|
|
str.replace(/{@dc (?<save>[^|}]+)(?:\|[^}]+)?}\s+(?<abil>Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma) saving throw/i, (...m) => {
|
|
outSet.add(m.last().abil.toLowerCase());
|
|
return "";
|
|
});
|
|
}
|
|
|
|
static _handleSpell ({spell, outSet}) {
|
|
if (!spell.savingThrow) return;
|
|
spell.savingThrow.forEach(it => outSet.add(it));
|
|
}
|
|
}
|
|
|
|
globalThis.CreatureSavingThrowTagger = CreatureSavingThrowTagger;
|
|
|
|
class CreatureSpecialEquipmentTagger {
|
|
static tryRun (mon) {
|
|
if (!mon.trait) return;
|
|
mon.trait = mon.trait
|
|
.map(ent => {
|
|
if (!/\bEquipment\b/.test(ent.name || "")) return ent;
|
|
return ItemTag.tryRun(ent);
|
|
});
|
|
}
|
|
}
|
|
|
|
globalThis.CreatureSpecialEquipmentTagger = CreatureSpecialEquipmentTagger;
|