"use strict"; /* * Various utilities to assist in statblock parse/conversion. Formatted as a Node module, to allow external use. * * In all cases, the first argument, `m`, is a monster statblock. * Additionally, `cbMan` is a callback which should accept up to two arguments representing part of the statblock which * require manual consideration/tagging, and an error message, respectively. * Where available, `cbErr` accepts the same arguments, and may be called when an error occurs (the parser encounters * something too far from acceptable to be solved with manual conversion; for instance, in the case of completely junk * data, or common errors which should be corrected prior to running the parser). */ class ConverterConst {} ConverterConst.STR_RE_DAMAGE_TYPE = "(acid|bludgeoning|cold|fire|force|lightning|necrotic|piercing|poison|psychic|radiant|slashing|thunder)"; ConverterConst.RE_DAMAGE_TYPE = new RegExp(`\\b${ConverterConst.STR_RE_DAMAGE_TYPE}\\b`, "g"); ConverterConst.STR_RE_CLASS = `(?artificer|barbarian|bard|cleric|druid|fighter|monk|paladin|ranger|rogue|sorcerer|warlock|wizard)`; class ConverterUtils { static splitConjunct (str) { return str .split(/(?:,? (?:and|or) |, )/gi) .map(it => it.trim()) .filter(Boolean) ; } } globalThis.ConverterUtils = ConverterUtils; class _ParseStateBase { constructor ( { toConvert, options, entity, }, ) { this.curLine = null; this.ixToConvert = 0; this.stage = "name"; this.toConvert = toConvert; this.options = options; this.entity = entity; } doPreLoop () { // No-op } doPostLoop () { this.ixToConvert = 0; } initCurLine () { this.curLine = this.toConvert[this.ixToConvert].trim(); } _isSkippableLine () { throw new Error("Unimplemented!"); } isSkippableCurLine () { return this._isSkippableLine(this.curLine); } } class BaseParseStateText extends _ParseStateBase { _isSkippableLine (l) { return l.trim() === ""; } getNextLineMeta () { for (let i = this.ixToConvert + 1; i < this.toConvert.length; ++i) { const l = this.toConvert[i]?.trim(); if (this._isSkippableLine(l)) continue; return {ixToConvertNext: i, nxtLine: l}; } return null; } } globalThis.BaseParseStateText = BaseParseStateText; class BaseParseStateMarkdown extends _ParseStateBase { _isSkippableLine (l) { return ConverterUtilsMarkdown.isBlankLine(l); } } globalThis.BaseParseStateMarkdown = BaseParseStateMarkdown; class BaseParser { static _getValidOptions (options) { options = options || {}; if (!options.cbWarning || !options.cbOutput) throw new Error(`Missing required callback options!`); return options; } // region conversion static _getAsTitle (prop, line, titleCaseFields, isTitleCase) { return titleCaseFields && titleCaseFields.includes(prop) && isTitleCase ? line.toLowerCase().toTitleCase() : line; } static _getCleanInput (ipt, options = null) { let iptClean = ipt .replace(/\n\r/g, "\n") .replace(/\r\n/g, "\n") .replace(/\r/g, "\n") .replace(/­\s*\n\s*/g, "") // Soft hyphen .replace(/\s*\u00A0\s*/g, " ") // Non-breaking space .replace(/[−–‒]/g, "-") // convert minus signs to hyphens ; iptClean = CleanUtil.getCleanString(iptClean, {isFast: false}) // Ensure CR always has a space before the dash .replace(/(Challenge)([-\u2012-\u2014])/, "$1 $2"); // Connect together words which are divided over two lines iptClean = iptClean .replace(/((?: | ")[A-Za-z][a-z]+)- *\n([a-z])/g, "$1$2"); // Connect line-broken parentheses iptClean = this._getCleanInput_parens(iptClean, "(", ")"); iptClean = this._getCleanInput_parens(iptClean, "[", "]"); iptClean = this._getCleanInput_parens(iptClean, "{", "}"); iptClean = this._getCleanInput_quotes(iptClean, `"`, `"`); // Connect lines ending in, or starting in, a comma iptClean = iptClean .replace(/, *\n+ */g, ", ") .replace(/ *\n+, */g, ", "); iptClean = iptClean // Connect together e.g. `5d10\nForce damage` .replace(new RegExp(`(?\\d+) *\\n+(?${ConverterConst.STR_RE_DAMAGE_TYPE} damage)\\b`, "gi"), (...m) => `${m.last().start} ${m.last().end}`) // Connect together likely determiners/conjunctions/etc. .replace(/(?\b(the|a|a cumulative|an|this|that|these|those|its|his|her|their|they|have|extra|and|or|as|on|uses|to|at|using|reduced|effect|reaches|with|of) *)\n+\s*/g, (...m) => `${m.last().start} `) // Connect together e.g.: // - `+5\nto hit`, `your Spell Attack Modifier\nto hit` // - `your Wisdom\nmodifier` .replace(/(?[a-z0-9]) *\n+ *(?to hit|modifier)\b/g, (...m) => `${m.last().start} ${m.last().end}`) // Connect together ` ()` .replace(new RegExp(`\\b(?${Object.values(Parser.ATB_ABV_TO_FULL).join("|")}) *\\n+ *(?\\((?:${Object.keys(Parser.SKILL_TO_ATB_ABV).join("|")})\\))`, "gi"), (...m) => `${m.last().start.trim()} ${m.last().end.trim()}`) // Connect together e.g. `increases by\n1d6 when` .replace(/(?[a-z0-9]) *\n+ *(?\d+d\d+( *[-+] *\d+)?,? [a-z]+)/g, (...m) => `${m.last().start} ${m.last().end}`) // Connect together e.g. `2d4\n+PB` .replace(/(?(?:\d+)?d\d+) *\n *(?[-+] *(?:\d+|PB) [a-z]+)/g, (...m) => `${m.last().start} ${m.last().end}`) // Connect together likely word pairs .replace(/\b(?hit) *\n* *(?points)\b/gi, (...m) => `${m.last().start} ${m.last().end}`) .replace(/\b(?save) *\n* *(?DC)\b/gi, (...m) => `${m.last().start} ${m.last().end}`) ; if (options) { // Apply `PAGE=...` iptClean = iptClean .replace(/(?:\n|^)PAGE=(?\d+)(?:\n|$)/gi, (...m) => { options.page = Number(m.last().page); return ""; }); } return iptClean; } static _getCleanInput_parens (iptClean, cOpen, cClose) { const lines = iptClean .split("\n"); for (let i = 0; i < lines.length; ++i) { const line = lines[i]; const lineNxt = lines[i + 1]; if (!lineNxt) continue; const cntOpen = line.split(cOpen).length - 1; const cntClose = line.split(cClose).length - 1; if (cntOpen <= cntClose) continue; lines[i] = `${line} ${lineNxt}`.replace(/ {2}/g, " "); lines.splice(i + 1, 1); i--; } return lines.join("\n"); } static _getCleanInput_quotes (iptClean, cOpen, cClose) { const lines = iptClean .split("\n"); for (let i = 0; i < lines.length; ++i) { const line = lines[i]; const lineNxt = lines[i + 1]; if (!lineNxt) continue; const cntOpen = line.split(cOpen).length - 1; const cntClose = line.split(cClose).length - 1; if (!(cntOpen % 2) || !(cntClose % 2)) continue; lines[i] = `${line} ${lineNxt}`.replace(/ {2}/g, " "); lines.splice(i + 1, 1); i--; } return lines.join("\n"); } static _hasEntryContent (trait) { return trait && (trait.name || (trait.entries.length === 1 && trait.entries[0]) || trait.entries.length > 1); } /** * Check if a line is likely to be a badly-newline'd continuation of the previous line. * @param entryArray * @param curLine * @param [opts] * @param [opts.noLowercase] Disable lowercase-word checking. * @param [opts.noNumber] Disable number checking. * @param [opts.noParenthesis] Disable parenthesis ("(") checking. * @param [opts.noSavingThrow] Disable saving throw checking. * @param [opts.noAbilityName] Disable ability checking. * @param [opts.noHit] Disable "Hit:" checking. * @param [opts.noSpellcastingAbility] Disable spellcasting ability checking. * @param [opts.noSpellcastingWarlockSlotLevel] Disable spellcasting warlock slot checking. * @param [opts.noDc] Disable "DC" checking */ static _isContinuationLine (entryArray, curLine, opts) { opts = opts || {}; // If there is no previous entry to add to, do not continue if (!entryArray) return false; const lastEntry = entryArray.last(); if (typeof lastEntry !== "string") return false; // If the current string ends in a comma if (/,\s*$/.test(lastEntry)) return true; // If the current string ends in a dash if (/[-\u2014]\s*$/.test(lastEntry)) return true; // If the current string ends in a conjunction if (/ (?:and|or)\s*$/.test(lastEntry)) return true; const cleanLine = curLine.trim(); if (/^\d..-\d..[- ][Ll]evel\s+\(/.test(cleanLine) && !opts.noSpellcastingWarlockSlotLevel) return false; // Start of a list item if (/^[•●]/.test(cleanLine)) return false; // A lowercase word if (/^[a-z]/.test(cleanLine) && !opts.noLowercase) return true; // An ordinal (e.g. "3rd"), but not a spell level (e.g. "1st level") if (/^\d[a-z][a-z]/.test(cleanLine) && !/^\d[a-z][a-z][- ][Ll]evel/gi.test(cleanLine)) return true; // A number (e.g. damage; "5 (1d6 + 2)"), optionally with slash-separated parts (e.g. "30/120 ft.") if (/^\d+(\/\d+)*\s+/.test(cleanLine) && !opts.noNumber) return true; // Opening brackets (e.g. damage; "(1d6 + 2)") if (/^\(/.test(cleanLine) && !opts.noParenthesis) return true; // An ability score name followed by "saving throw" if (/^(Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma)\s+saving throw/.test(cleanLine) && !opts.noSavingThrow) return true; // An ability score name if (/^(Strength|Dexterity|Constitution|Intelligence|Wisdom|Charisma)\s/.test(cleanLine) && !opts.noAbilityName) return true; // "Hit:" e.g. inside creature attacks if (/^Hit:/.test(cleanLine) && !opts.noHit) return true; if (/^(Intelligence|Wisdom|Charisma)\s+\(/.test(cleanLine) && !opts.noSpellcastingAbility) return true; if (/^DC\s+/.test(cleanLine) && !opts.noDc) return true; return false; } static _isJsonLine (curLine) { return curLine.startsWith(`__VE_JSON__`); } static _getJsonFromLine (curLine) { curLine = curLine.replace(/^__VE_JSON__/, ""); return JSON.parse(curLine); } // endregion } class TaggerUtils { static _ALL_LEGENDARY_GROUPS = null; static _ALL_SPELLS = null; static init ({legendaryGroups, spells}) { this._ALL_LEGENDARY_GROUPS = legendaryGroups; this._ALL_SPELLS = spells; } static findLegendaryGroup ({name, source}) { name = name.toLowerCase(); source = source.toLowerCase(); const doFind = arr => arr.find(it => it.name.toLowerCase() === name && it.source.toLowerCase() === source); const fromPrerelease = typeof PrereleaseUtil !== "undefined" ? doFind(PrereleaseUtil.getBrewProcessedFromCache("legendaryGroup")) : null; if (fromPrerelease) return fromPrerelease; const fromBrew = typeof BrewUtil2 !== "undefined" ? doFind(BrewUtil2.getBrewProcessedFromCache("legendaryGroup")) : null; if (fromBrew) return fromBrew; return doFind(this._ALL_LEGENDARY_GROUPS); } static findSpell ({name, source}) { name = name.toLowerCase(); source = source.toLowerCase(); const doFind = arr => arr.find(s => (s.name.toLowerCase() === name || (typeof s.srd === "string" && s.srd.toLowerCase() === name)) && s.source.toLowerCase() === source); const fromPrerelease = typeof PrereleaseUtil !== "undefined" ? doFind(PrereleaseUtil.getBrewProcessedFromCache("spell")) : null; if (fromPrerelease) return fromPrerelease; const fromBrew = typeof BrewUtil2 !== "undefined" ? doFind(BrewUtil2.getBrewProcessedFromCache("spell")) : null; if (fromBrew) return fromBrew; return doFind(this._ALL_SPELLS); } /** * * @param targetTags e.g. `["@condition"]` * @param ptrStack * @param depth * @param str * @param tagCount * @param meta * @param meta.fnTag * @param [meta.isAllowTagsWithinTags] */ static walkerStringHandler (targetTags, ptrStack, depth, tagCount, str, meta) { const tagSplit = Renderer.splitByTags(str); const len = tagSplit.length; for (let i = 0; i < len; ++i) { const s = tagSplit[i]; if (!s) continue; if (s.startsWith("{@")) { const [tag, text] = Renderer.splitFirstSpace(s.slice(1, -1)); ptrStack._ += `{${tag}${text.length ? " " : ""}`; if (!meta.isAllowTagsWithinTags) { // Never tag anything within an existing tag this.walkerStringHandler(targetTags, ptrStack, depth + 1, tagCount + 1, text, meta); } else { // Tag something within an existing tag only if it doesn't match our tag(s) if (targetTags.includes(tag)) { this.walkerStringHandler(targetTags, ptrStack, depth + 1, tagCount + 1, text, meta); } else { this.walkerStringHandler(targetTags, ptrStack, depth + 1, tagCount, text, meta); } } ptrStack._ += `}`; } else { // avoid tagging things wrapped in existing tags if (tagCount) { ptrStack._ += s; } else { let sMod = s; sMod = meta.fnTag(sMod); ptrStack._ += sMod; } } } } static getSpellsFromString (str, {cbMan} = {}) { const strSpellcasting = str; const knownSpells = {}; strSpellcasting.replace(/{@spell ([^}]+)}/g, (...m) => { let [spellName, spellSource] = m[1].split("|").map(it => it.toLowerCase()); spellSource = spellSource || Parser.SRC_PHB.toLowerCase(); (knownSpells[spellSource] = knownSpells[spellSource] || new Set()).add(spellName); }); const out = []; Object.entries(knownSpells) .forEach(([source, spellSet]) => { spellSet.forEach(it => { const spell = TaggerUtils.findSpell({name: it, source}); if (!spell) return cbMan ? cbMan(`${it} :: ${source}`) : null; out.push(spell); }); }); return out; } } class TagCondition { static _KEY_BLOCKLIST = new Set([ ...MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST, "conditionImmune", ]); static _CONDITIONS = [ "blinded", "charmed", "deafened", "exhaustion", "frightened", "grappled", "incapacitated", "invisible", "paralyzed", "petrified", "poisoned", "prone", "restrained", "stunned", "unconscious", ]; static _STATUS_MATCHER = new RegExp(`\\b(concentration|surprised)\\b`, "g"); static _STATUS_MATCHER_ALT = new RegExp(`\\b(concentrating)\\b`, "g"); static _STATUS_MATCHER_ALT_REPLACEMENTS = { "concentrating": "concentration||", }; static _conditionMatcher = null; static _conditionSourceMap = null; static init ({conditionsBrew = []} = {}) { const conditions = [ ...this._CONDITIONS, ...(conditionsBrew || []).map(it => it.name.toLowerCase().escapeRegexp()), ]; this._conditionMatcher = new RegExp(`\\b(${conditions.join("|")})\\b`, "g"); this._conditionSourceMap = conditionsBrew.mergeMap(({name, source}) => ({[name.toLowerCase()]: source})); } static getConditionUid (conditionName) { const lower = conditionName.toLowerCase(); const source = this._conditionSourceMap[lower]; if (!source) return lower; return `${lower}|${source.toLowerCase()}`; } static _getConvertedEntry (mon, entry, {inflictedSet, inflictedAllowlist} = {}) { const walker = MiscUtil.getWalker({keyBlocklist: this._KEY_BLOCKLIST}); const nameStack = []; const walkerHandlers = { preObject: (obj) => nameStack.push(obj.name), postObject: () => nameStack.pop(), string: [ (str) => { if (nameStack.includes("Antimagic Susceptibility")) return str; if (nameStack.includes("Sneak Attack (1/Turn)")) return str; const ptrStack = {_: ""}; return this._walkerStringHandler(ptrStack, 0, 0, str, {inflictedSet, inflictedAllowlist}); }, ], }; entry = MiscUtil.copy(entry); return walker.walk(entry, walkerHandlers); } static _getModifiedString (str) { return str .replace(this._conditionMatcher, (...m) => { const name = m[1]; const source = this._conditionSourceMap[name.toLowerCase()]; if (!source) return `{@condition ${name}}`; return `{@condition ${name}|${source}}`; }) .replace(this._STATUS_MATCHER, (...m) => `{@status ${m[1]}}`) .replace(this._STATUS_MATCHER_ALT, (...m) => `{@status ${this._STATUS_MATCHER_ALT_REPLACEMENTS[m[1].toLowerCase()]}${m[1]}}`) ; } static _walkerStringHandler (ptrStack, depth, conditionCount, str, {inflictedSet, inflictedAllowlist} = {}) { TaggerUtils.walkerStringHandler( ["@condition", "@status"], ptrStack, depth, conditionCount, str, { fnTag: this._getModifiedString.bind(this), }, ); // Only the outermost loop needs return the final string if (depth !== 0) return; // Collect inflicted conditions for tagging if (inflictedSet) this._collectInflictedConditions(ptrStack._, {inflictedSet, inflictedAllowlist}); return ptrStack._; } static _handleProp (m, prop, {inflictedSet, inflictedAllowlist} = {}) { if (!m[prop]) return; m[prop] = m[prop].map(entry => this._getConvertedEntry(m, entry, {inflictedSet, inflictedAllowlist})); } static tryTagConditions (m, {isTagInflicted = false, isInflictedAddOnly = false, inflictedAllowlist = null} = {}) { const inflictedSet = isTagInflicted ? new Set() : null; this._handleProp(m, "action", {inflictedSet, inflictedAllowlist}); this._handleProp(m, "reaction", {inflictedSet, inflictedAllowlist}); this._handleProp(m, "bonus", {inflictedSet, inflictedAllowlist}); this._handleProp(m, "trait", {inflictedSet, inflictedAllowlist}); this._handleProp(m, "legendary", {inflictedSet, inflictedAllowlist}); this._handleProp(m, "mythic", {inflictedSet, inflictedAllowlist}); this._handleProp(m, "variant", {inflictedSet, inflictedAllowlist}); this._handleProp(m, "entries", {inflictedSet, inflictedAllowlist}); this._handleProp(m, "entriesHigherLevel", {inflictedSet, inflictedAllowlist}); this._mutAddInflictedSet({m, inflictedSet, isInflictedAddOnly, prop: "conditionInflict"}); } static _collectInflictedConditions (str, {inflictedSet, inflictedAllowlist} = {}) { if (!inflictedSet) return; TagCondition._CONDITION_INFLICTED_MATCHERS.forEach(re => str.replace(re, (...m) => { this._collectInflictedConditions_withAllowlist({inflictedSet, inflictedAllowlist, cond: m[1]}); // ", {@condition ...}, ..." if (m[2]) m[2].replace(/{@condition ([^}]+)}/g, (...n) => this._collectInflictedConditions_withAllowlist({inflictedSet, inflictedAllowlist, cond: n[1]})); // " and {@condition ...} if (m[3]) m[3].replace(/{@condition ([^}]+)}/g, (...n) => this._collectInflictedConditions_withAllowlist({inflictedSet, inflictedAllowlist, cond: n[1]})); })); } static _collectInflictedConditions_withAllowlist ({inflictedAllowlist, inflictedSet, cond}) { if (!inflictedAllowlist || inflictedAllowlist.has(cond)) inflictedSet.add(cond); return ""; } static tryTagConditionsSpells (m, {cbMan, isTagInflicted, isInflictedAddOnly, inflictedAllowlist} = {}) { if (!m.spellcasting) return false; const inflictedSet = isTagInflicted ? new Set() : null; const spells = TaggerUtils.getSpellsFromString(JSON.stringify(m.spellcasting), {cbMan}); spells.forEach(spell => { if (spell.conditionInflict) spell.conditionInflict.filter(c => !inflictedAllowlist || inflictedAllowlist.has(c)).forEach(c => inflictedSet.add(c)); }); this._mutAddInflictedSet({m, inflictedSet, isInflictedAddOnly, prop: "conditionInflictSpell"}); } static tryTagConditionsRegionalsLairs (m, {cbMan, isTagInflicted, isInflictedAddOnly, inflictedAllowlist} = {}) { if (!m.legendaryGroup) return; const inflictedSet = isTagInflicted ? new Set() : null; const meta = TaggerUtils.findLegendaryGroup({name: m.legendaryGroup.name, source: m.legendaryGroup.source}); if (!meta) return cbMan ? cbMan(m.legendaryGroup) : null; this._collectInflictedConditions(JSON.stringify(meta), {inflictedSet, inflictedAllowlist}); this._mutAddInflictedSet({m, inflictedSet, isInflictedAddOnly, prop: "conditionInflictLegendary"}); } static _mutAddInflictedSet ({m, inflictedSet, isInflictedAddOnly, prop}) { if (!inflictedSet) return; if (isInflictedAddOnly) { (m[prop] || []).forEach(it => inflictedSet.add(it)); if (inflictedSet.size) m[prop] = [...inflictedSet].map(it => it.toLowerCase()).sort(SortUtil.ascSortLower); return; } if (inflictedSet.size) m[prop] = [...inflictedSet].map(it => it.toLowerCase()).sort(SortUtil.ascSortLower); else delete m[prop]; } // region Run basic tagging static tryRunBasic (it) { const walker = MiscUtil.getWalker({keyBlocklist: this._KEY_BLOCKLIST}); return walker.walk( it, { string: (str) => { const ptrStack = {_: ""}; TaggerUtils.walkerStringHandler( ["@condition", "@status"], ptrStack, 0, 0, str, { fnTag: this._getModifiedString.bind(this), }, ); return ptrStack._ .replace(/{@condition (prone)} (to)\b/gi, "$1 $2") .replace(/{@condition (petrified)} (wood)\b/gi, "$1 $2") .replace(/{@condition (invisible)} (stalker)/gi, "$1 $2") ; }, }, ); } // endregion } // Each should have one group which matches the condition name. // A comma/and part is appended to the end to handle chains of conditions. TagCondition.__TGT = `(?:target|wielder)`; TagCondition._CONDITION_INFLICTED_MATCHERS = [ `(?:creature|enemy|target) is \\w+ {@condition ([^}]+)}`, // "is knocked prone" `(?:creature|enemy|target) becomes (?:\\w+ )?{@condition ([^}]+)}`, `saving throw (?:by \\d+ or more, it )?is (?:\\w+ )?{@condition ([^}]+)}`, // MM :: Sphinx :: First Roar `(?:the save|fails) by \\d+ or more, [^.!?]+?{@condition ([^}]+)}`, // VGM :: Fire Giant Dreadnought :: Shield Charge `(?:${TagCondition.__TGT}|creatures?|humanoid|undead|other creatures|enemy) [^.!?]+?(?:succeed|make|pass)[^.!?]+?saving throw[^.!?]+?or (?:fall|be(?:come)?|is) (?:\\w+ )?{@condition ([^}]+)}`, `and then be (?:\\w+ )?{@condition ([^}]+)}`, `(?:be|is) knocked (?:\\w+ )?{@condition (prone|unconscious)}`, `a (?:\\w+ )?{@condition [^}]+} (?:creature|enemy) is (?:\\w+ )?{@condition ([^}]+)}`, // e.g. `a frightened creature is paralyzed` `(? typeof it === "object" ? it : ({re: it, flags: "gi"})) .map(({re, flags}) => new RegExp(`${re}((?:, {@condition [^}]+})*)(,? (?:and|or) {@condition [^}]+})?`, flags)); class TagUtil { static isNoneOrEmpty (str) { if (!str || !str.trim()) return false; return !!TagUtil.NONE_EMPTY_REGEX.exec(str); } } TagUtil.NONE_EMPTY_REGEX = /^(([-\u2014\u2013\u2221])+|none)$/gi; class DiceConvert { static convertTraitActionDice (traitOrAction) { if (traitOrAction.entries) { traitOrAction.entries = traitOrAction.entries .filter(it => it.trim ? it.trim() : true) .map(entry => this._getConvertedEntry(entry, true)); } } static getTaggedEntry (entry) { return this._getConvertedEntry(entry); } static _getConvertedEntry (entry, isTagHits = false) { if (!DiceConvert._walker) { DiceConvert._walker = MiscUtil.getWalker({ keyBlocklist: new Set([ ...MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST, "dmg1", "dmg2", "area", ]), }); DiceConvert._walkerHandlers = { string: (str) => { const ptrStack = {_: ""}; TaggerUtils.walkerStringHandler( ["@dice", "@hit", "@damage", "@scaledice", "@scaledamage", "@d20"], ptrStack, 0, 0, str, { fnTag: this._walkerStringHandler.bind(this, isTagHits), }, ); return ptrStack._; }, }; } entry = MiscUtil.copy(entry); return DiceConvert._walker.walk(entry, DiceConvert._walkerHandlers); } static _RE_NO_FORMAT_STRINGS = /(\b(?:plus|minus|PB)\b)/; static _walkerStringHandler (isTagHits, str) { if (isTagHits) { str = str // Handle e.g. `+3 to hit` // Handle e.g. `+3 plus PB to hit` .replace(/(?[-+])?(?\d+(?: (?:plus|minus|[-+]) PB)?)(?= to hit)\b/g, (...m) => `{@hit ${m.last().op === "-" ? "-" : ""}${m.last().bonus}}`) ; } // re-tag + format dice str = str .replace(/\b(\s*[-+]\s*)?(([1-9]\d*|PB)?d([1-9]\d*)(\s*?(?:plus|minus|[-+×x*÷/])\s*?(\d,\d|\d|PB)+(\.\d+)?)?)+(?:\s*\+\s*\bPB\b)?\b/gi, (...m) => { const expanded = m[0] .split(this._RE_NO_FORMAT_STRINGS) .map(pt => { pt = pt.trim(); if (!pt) return pt; if (this._RE_NO_FORMAT_STRINGS.test(pt)) return pt; return pt .replace(/([^0-9d.,PB])/g, " $1 ") .replace(/\s+/g, " "); }) .filter(Boolean) .join(" "); return `{@dice ${expanded}}`; }); // unwrap double-tagged let last; do { last = str; str = str.replace(/{@(dice|damage|scaledice|scaledamage|d20) ([^}]*){@(dice|damage|scaledice|scaledamage|d20) ([^}]*)}([^}]*)}/gi, (...m) => { // Choose the strongest dice type we have const nxtType = [ m[1] === "scaledamage" || m[3] === "scaledamage" ? "scaledamage" : null, m[1] === "damage" || m[3] === "damage" ? "damage" : null, m[1] === "d20" || m[3] === "d20" ? "d20" : null, m[1] === "scaledice" || m[3] === "scaledice" ? "scaledice" : null, m[1] === "dice" || m[3] === "dice" ? "dice" : null, ].filter(Boolean)[0]; return `{@${nxtType} ${m[2]}${m[4]}${m[5]}}`; }); } while (last !== str); do { last = str; str = str.replace(/{@b ({@(?:dice|damage|scaledice|scaledamage|d20) ([^}]*)})}/gi, "$1"); } while (last !== str); // tag @damage (creature style) str = str.replace(/\d+ \({@dice (?:[^|}]*)}\)(?:\s+[-+]\s+[-+a-zA-Z0-9 ]*?)?(?: [a-z]+(?:(?:, |, or | or )[a-z]+)*)? damage/ig, (...m) => m[0].replace(/{@dice /gi, "{@damage ")); // tag @damage (spell/etc style) str = str.replace(/{@dice (?:[^|}]*)}(?:\s+[-+]\s+[-+a-zA-Z0-9 ]*?)?(?:\s+[-+]\s+the spell's level)?(?: [a-z]+(?:(?:, |, or | or )[a-z]+)*)? damage/ig, (...m) => m[0].replace(/{@dice /gi, "{@damage ")); return str; } static cleanHpDice (m) { if (m.hp && m.hp.formula) { m.hp.formula = m.hp.formula .replace(/\s+/g, "") // crush spaces .replace(/([^0-9d])/gi, " $1 "); // add spaces } } } DiceConvert._walker = null; class ArtifactPropertiesTag { static tryRun (it, opts) { const walker = MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST}); walker.walk(it, { string: (str) => str.replace(/major beneficial|minor beneficial|major detrimental|minor detrimental/gi, (...m) => { const mode = m[0].trim().toLowerCase(); switch (mode) { case "major beneficial": return `{@table Artifact Properties; Major Beneficial Properties|dmg|${m[0]}}`; case "minor beneficial": return `{@table Artifact Properties; Minor Beneficial Properties|dmg|${m[0]}}`; case "major detrimental": return `{@table Artifact Properties; Major Detrimental Properties|dmg|${m[0]}}`; case "minor detrimental": return `{@table Artifact Properties; Minor Detrimental Properties|dmg|${m[0]}}`; } }), }); } } class SkillTag { static tryRun (it) { const walker = MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST}); return walker.walk( it, { string: (str) => { const ptrStack = {_: ""}; TaggerUtils.walkerStringHandler( ["@skill"], ptrStack, 0, 0, str, { fnTag: this._fnTag, }, ); return ptrStack._; }, }, ); } static _fnTag (strMod) { return strMod.replace(/\b(Acrobatics|Animal Handling|Arcana|Athletics|Deception|History|Insight|Intimidation|Investigation|Medicine|Nature|Perception|Performance|Persuasion|Religion|Sleight of Hand|Stealth|Survival)\b/g, (...m) => `{@skill ${m[1]}}`); } static tryRunProps (ent, {props} = {}) { props .filter(prop => ent[prop]) .forEach(prop => this.tryRun(ent[prop])); } } class ActionTag { static tryRun (it) { const walker = MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST}); return walker.walk( it, { string: (str) => { const ptrStack = {_: ""}; TaggerUtils.walkerStringHandler( ["@action"], ptrStack, 0, 0, str, { fnTag: this._fnTag, }, ); return ptrStack._; }, }, ); } static _fnTag (strMod) { // Avoid tagging text within titles if (strMod.toTitleCase() === strMod) return strMod; const reAction = /\b(Attack|Dash|Disengage|Dodge|Help|Hide|Ready|Search|Use an Object|shove a creature)\b/g; let mAction; while ((mAction = reAction.exec(strMod))) { const ixMatchEnd = mAction.index + mAction[0].length; const ptTag = mAction[1] === "shove a creature" ? "shove" : mAction[1]; const ptTrailing = mAction[1] === "shove a creature" ? ` a creature` : ""; const replaceAs = `{@action ${ptTag}}${ptTrailing}`; strMod = `${strMod.slice(0, mAction.index)}${replaceAs}${strMod.slice(ixMatchEnd, strMod.length)}` .replace(/{@action Attack} (and|or) damage roll/g, "Attack $1 damage roll") ; reAction.lastIndex += replaceAs.length - 1; } strMod = strMod .replace(/(Extra|Sneak|Weapon|Spell) {@action Attack}/g, (...m) => `${m[1]} Attack`) ; return strMod; } } class SenseTag { static tryRun (it) { const walker = MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST}); return walker.walk( it, { string: (str) => { const ptrStack = {_: ""}; TaggerUtils.walkerStringHandler( ["@sense"], ptrStack, 0, 0, str, { fnTag: this._fnTag, }, ); return ptrStack._; }, }, ); } static _fnTag (strMod) { return strMod.replace(/(tremorsense|blindsight|truesight|darkvision)/ig, (...m) => `{@sense ${m[0]}${m[0].toLowerCase() === "tremorsense" ? "|MM" : ""}}`); } } class EntryConvert { static tryRun (stats, prop) { if (!stats[prop]) return; const walker = MiscUtil.getWalker({keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST}); walker.walk( stats, { array: (arr, objProp) => { if (objProp !== prop) return arr; const getNewList = () => ({type: "list", items: []}); const checkFinalizeList = () => { if (tmpList.items.length) { out.push(tmpList); tmpList = getNewList(); } }; const out = []; let tmpList = getNewList(); for (let i = 0; i < arr.length; ++i) { const it = arr[i]; if (typeof it !== "string") { checkFinalizeList(); out.push(it); continue; } const mBullet = /^\s*[-•●]\s*(.*)$/.exec(it); if (!mBullet) { checkFinalizeList(); out.push(it); continue; } tmpList.items.push(mBullet[1].trim()); } checkFinalizeList(); return out; }, }, ); } static _StateCoalesce = class { constructor ({ptrI, toConvert}) { this.ptrI = ptrI; this.toConvert = toConvert; this.entries = []; this.stack = [this.entries]; this.curLine = toConvert[ptrI._].trim(); } popList () { while (this.stack.last().type === "list") this.stack.pop(); } popNestedEntries () { while (this.stack.length > 1) this.stack.pop(); } getCurrentEntryArray () { if (this.stack.last().type === "list") return this.stack.last().items; if (this.stack.last().type === "entries") return this.stack.last().entries; if (this.stack.last() instanceof Array) return this.stack.last(); return null; } addEntry ({entry, isAllowCombine = false}) { isAllowCombine = isAllowCombine && typeof entry === "string"; const target = this.stack.last(); if (target instanceof Array) { if (isAllowCombine && typeof target.last() === "string") { target.last(`${target.last().trimEnd()} ${entry.trimStart()}`); } else { target.push(entry); } } else if (target.type === "list") { if (isAllowCombine && typeof target.items.last() === "string") { target.items.last(`${target.items.last().trimEnd()} ${entry.trimStart()}`); } else { target.items.push(entry); } } else if (target.type === "entries") { if (isAllowCombine && typeof target.entries.last() === "string") { target.entries.last(`${target.entries.last().trimEnd()} ${entry.trimStart()}`); } else { target.entries.push(entry); } } if (typeof entry !== "string") this.stack.push(entry); } incrementLine (offset = 1) { this.ptrI._ += offset; this.curLine = this.toConvert[this.ptrI._]; } getRemainingLines ({isFilterEmpty = false} = {}) { const slice = this.toConvert.slice(this.ptrI._); return !isFilterEmpty ? slice : slice.filter(l => l.trim()); } }; /** * * @param ptrI * @param toConvert * @param [opts] * @param [opts.fnStop] Function which should return true for the current line if it is to stop coalescing. */ static coalesceLines (ptrI, toConvert, opts) { opts = opts || {}; if (toConvert[ptrI._] == null) return []; const state = new this._StateCoalesce({ptrI, toConvert}); while (ptrI._ < toConvert.length) { if (opts.fnStop && opts.fnStop(state.curLine)) break; if (BaseParser._isJsonLine(state.curLine)) { state.popNestedEntries(); // this implicitly pops nested lists state.addEntry({entry: BaseParser._getJsonFromLine(state.curLine)}); state.incrementLine(); continue; } if (ConvertUtil.isListItemLine(state.curLine)) { if (state.stack.last().type !== "list") { const list = { type: "list", items: [], }; state.addEntry({entry: list}); } state.curLine = state.curLine.replace(/^\s*[•●]\s*/, ""); state.addEntry({entry: state.curLine.trim()}); state.incrementLine(); continue; } const tableMeta = this._coalesceLines_getTableMeta({state}); if (tableMeta) { state.addEntry({entry: tableMeta.table}); state.incrementLine(tableMeta.offsetIx); continue; } if (ConvertUtil.isNameLine(state.curLine)) { state.popNestedEntries(); // this implicitly pops nested lists const {name, entry} = ConvertUtil.splitNameLine(state.curLine); const parentEntry = { type: "entries", name, entries: [entry], }; state.addEntry({entry: parentEntry}); state.incrementLine(); continue; } if (ConvertUtil.isTitleLine(state.curLine)) { state.popNestedEntries(); // this implicitly pops nested lists const entry = { type: "entries", name: state.curLine.trim(), entries: [], }; state.addEntry({entry}); state.incrementLine(); continue; } if (BaseParser._isContinuationLine(state.getCurrentEntryArray(), state.curLine)) { state.addEntry({entry: state.curLine.trim(), isAllowCombine: true}); state.incrementLine(); continue; } state.popList(); state.addEntry({entry: state.curLine.trim()}); state.incrementLine(); } this._coalesceLines_postProcessLists({entries: state.entries}); return state.entries; } // region Table conversion // Parses a (very) limited set of inputs: two-column rollable tables with well-formatted rows static _RE_TABLE_COLUMNS = null; static _coalesceLines_getTableMeta ({state}) { const linesRemaining = state.getRemainingLines({isFilterEmpty: true}); let offsetIx = 0; let caption = null; if (ConvertUtil.isTitleLine(linesRemaining[0])) { caption = linesRemaining[0].trim(); linesRemaining.shift(); offsetIx++; } const lineHeaders = linesRemaining.shift(); offsetIx++; this._RE_TABLE_COLUMNS ||= new RegExp(`^\\s*(?${RollerUtil.DICE_REGEX.source}) +(?
.*)$`); const mHeaders = this._RE_TABLE_COLUMNS.exec(lineHeaders); if (!mHeaders) return null; const rows = []; for (const l of linesRemaining) { const [cell0, ...rest] = l.trim() .split(/\s+/) .map(it => it.trim()) .filter(Boolean); if (!Renderer.table.isRollableCell(cell0)) break; rows.push([ cell0, rest.join(" "), ]); offsetIx++; } if (!rows.length) return null; const table = {type: "table"}; if (caption) table.caption = caption; Object.assign( table, { colLabels: [ mHeaders.groups.dice.trim(), mHeaders.groups.header.trim(), ], colStyles: [ "col-2 text-center", "col-10", ], rows, }, ); return {table, offsetIx}; } // endregion static _coalesceLines_postProcessLists ({entries}) { const walker = MiscUtil.getWalker({isNoModification: true}); walker.walk( entries, { object: obj => { if (obj.type !== "list") return; if (obj.style) return; if (!obj.items.length) return; if (!obj.items.every(li => { if (typeof li !== "string") return false; return ConvertUtil.isNameLine(li); })) return; obj.style = "list-hang-notitle"; obj.items = obj.items .map(li => { const {name, entry} = ConvertUtil.splitNameLine(li); return { type: "item", name, entry, }; }); }, }, ); } } class ConvertUtil { static getTokens (str) { return str.split(/[ \n\u2013\u2014]/g).map(it => it.trim()).filter(Boolean); } /** * (Inline titles) * Checks if a line of text starts with a name, e.g. * "Big Attack. Lorem ipsum..." vs "Lorem ipsum..." * @param line * @param {Set} exceptions A set of (lowercase) exceptions which should always be treated as "not a name" (e.g. "cantrips") * @param {RegExp} splitterPunc Regexp to use when splitting by punctuation. * @returns {boolean} */ static isNameLine (line, {exceptions = null, splitterPunc = null} = {}) { if (ConvertUtil.isListItemLine(line)) return false; const spl = this._getMergedSplitName({line, splitterPunc}); if (spl.map(it => it.trim()).filter(Boolean).length === 1) return false; if ( // Heuristic: single-column text is generally 50-60 characters; shorter lines with no other text are likely not name lines spl.join("").length <= 40 && spl.map(it => it.trim()).filter(Boolean).length === 2 && /^[.!?:]$/.test(spl[1]) ) return false; // ignore everything inside parentheses const namePart = ConvertUtil.getWithoutParens(spl[0]); if (!namePart) return false; // (If this is _everything_ cancel) const reStopwords = new RegExp(`^(${StrUtil.TITLE_LOWER_WORDS.join("|")})$`, "i"); const tokens = namePart.split(/([ ,;:]+)/g); const cleanTokens = tokens.filter(it => { const isStopword = reStopwords.test(it.trim()); reStopwords.lastIndex = 0; return !isStopword; }); const namePartNoStopwords = cleanTokens.join("").trim(); // if it's an ability score, it's not a name if (Object.values(Parser.ATB_ABV_TO_FULL).includes(namePartNoStopwords)) return false; // if it's a dice, it's not a name if (/^\d*d\d+\b/.test(namePartNoStopwords)) return false; if (exceptions && exceptions.has(namePartNoStopwords.toLowerCase())) return false; // if it's in title case after removing all stopwords, it's a name return namePartNoStopwords.toTitleCase() === namePartNoStopwords; } static isTitleLine (line) { line = line.trim(); const lineNoPrefix = line.replace(/^Feature: /, ""); if (lineNoPrefix.length && lineNoPrefix.toTitleCase() === lineNoPrefix) return true; if (/[.!?:]/.test(line)) return false; return line.toTitleCase() === line; } static isListItemLine (line) { return /^[•●]/.test(line.trim()); } static splitNameLine (line, isKeepPunctuation = false) { const spl = this._getMergedSplitName({line}); const rawName = spl[0]; const entry = line.substring(rawName.length + spl[1].length, line.length).trim(); const name = this.getCleanTraitActionName(rawName); const out = {name, entry}; if ( isKeepPunctuation // If the name ends with something besides ".", maintain it || /^[?!:]"?$/.test(spl[1]) ) out.name += spl[1].trim(); return out; } static _getMergedSplitName ({line, splitterPunc}) { let spl = line.split(splitterPunc || /([.!?:]+)/g); // Handle e.g. "Feature: Name of the Feature" if ( spl.length === 3 && spl[0] === "Feature" && spl[1] === ":" && spl[2].toTitleCase() === spl[2] ) return [spl.join("")]; if ( spl.length > 3 && ( // Handle e.g. "1. Freezing Ray. ..." /^\d+$/.test(spl[0]) // Handle e.g. "1-10: "All Fine Here!" ..." || /^\d+-\d+:?$/.test(spl[0]) // Handle e.g. "Action 1: Close In. ... || /^Action \d+$/.test(spl[0]) // Handle e.g. "5th Level: Lay Low (3/Day). ..." || /^\d+(?:st|nd|rd|th) Level$/.test(spl[0]) ) ) { spl = [ spl.slice(0, 3).join(""), ...spl.slice(3), ]; } // Handle e.g. "Mr. Blue" or "If Mr. Blue" for (let i = 0; i < spl.length - 2; ++i) { const toCheck = `${spl[i]}${spl[i + 1]}`; if (!toCheck.split(" ").some(it => ConvertUtil._CONTRACTIONS.has(it))) continue; spl[i] = `${spl[i]}${spl[i + 1]}${spl[i + 2]}`; spl.splice(i + 1, 2); } // Handle e.g. "Shield? Shield! ..." if ( spl.length > 4 && spl[0].trim() === spl[2].trim() && /^[.!?:]+$/g.test(spl[1]) && /^[.!?:]+$/g.test(spl[3]) ) { spl = [ spl.slice(0, 3).join(""), ...spl.slice(3), ]; } // Handle e.g. "3rd Level: Death from Above! (3/Day). ..." if ( spl.length > 3 && ( /^[.!?:]+$/.test(spl[1]) && /^\s*\([^)]+\)\s*$/.test(spl[2]) && /^[.!?:]+$/.test(spl[3]) ) ) { spl = [ spl.slice(0, 3).join(""), ...spl.slice(3), ]; } if (spl.length >= 3 && spl[0].includes(`"`) && spl[2].startsWith(`"`)) { spl = [ `${spl[0]}${spl[1]}${spl[2].slice(0, 1)}`, "", spl[2].slice(1), ...spl.slice(3), ]; } return spl; } static getCleanTraitActionName (name) { return name // capitalize unit in e.g. "(3/Day)" .replace(/(\(\d+\/)([a-z])([^)]+\))/g, (...m) => `${m[1]}${m[2].toUpperCase()}${m[3]}`) ; } /** * Takes a string containing parenthesized parts, and removes them. */ static getWithoutParens (string) { let skipSpace = false; let char; let cleanString = ""; const len = string.length; for (let i = 0; i < len; ++i) { char = string[i]; switch (char) { case ")": { // scan back through the stack, remove last parens let foundOpen = -1; for (let j = cleanString.length - 1; j >= 0; --j) { if (cleanString[j] === "(") { foundOpen = j; break; } } if (~foundOpen) { cleanString = cleanString.substring(0, foundOpen); skipSpace = true; } else { cleanString += ")"; } break; } case " ": if (skipSpace) skipSpace = false; else cleanString += " "; break; default: skipSpace = false; cleanString += char; break; } } return cleanString; } static cleanDashes (str) { return str.replace(/[-\u2011-\u2015]/g, "-"); } static isStatblockLineHeaderStart ({reStartStr, line}) { const m = this._getStatblockLineHeaderRegExp({reStartStr}).exec(line); return m?.index === 0; } static getStatblockLineHeaderText ({reStartStr, line}) { const m = this._getStatblockLineHeaderRegExp({reStartStr}).exec(line); if (!m) return line; return line.slice(m.index + m[0].length).trim(); } static _getStatblockLineHeaderRegExp ({reStartStr}) { return new RegExp(`\\s*${reStartStr}\\s*?(?::|\\.|\\b)\\s*`, "i"); } } ConvertUtil._CONTRACTIONS = new Set(["Mr.", "Mrs.", "Ms.", "Dr."]); class AlignmentUtil { static tryGetConvertedAlignment (align, {cbMan = null} = {}) { if (!(align || "").trim()) return {}; let alignmentPrefix; // region Support WBtW and onwards formatting align = align.trim().replace(/^typically\s+/, () => { alignmentPrefix = "typically "; return ""; }); // endregion const orParts = (align || "").split(/ or /g).map(it => it.trim().replace(/[.,;]$/g, "").trim()); const out = []; orParts.forEach(part => { Object.values(AlignmentUtil.ALIGNMENTS).forEach(it => { if (it.regex.test(part)) return out.push({alignment: it.output}); const mChange = it.regexChance.exec(part); if (mChange) out.push({alignment: it.output, chance: Number(mChange[1])}); }); }); if (out.length === 1) return {alignmentPrefix, alignment: out[0].alignment}; if (out.length) return {alignmentPrefix, alignment: out}; if (cbMan) cbMan(align); return {alignmentPrefix, alignment: align}; } } // These are arranged in order of preferred precedence AlignmentUtil.ALIGNMENTS_RAW = { "lawful good": ["L", "G"], "neutral good": ["N", "G"], "chaotic good": ["C", "G"], "chaotic neutral": ["C", "N"], "lawful evil": ["L", "E"], "lawful neutral": ["L", "N"], "neutral evil": ["N", "E"], "chaotic evil": ["C", "E"], "(?:any )?non-?good( alignment)?": ["L", "NX", "C", "NY", "E"], "(?:any )?non-?lawful( alignment)?": ["NX", "C", "G", "NY", "E"], "(?:any )?non-?evil( alignment)?": ["L", "NX", "C", "NY", "G"], "(?:any )?non-?chaotic( alignment)?": ["NX", "L", "G", "NY", "E"], "(?:any )?chaotic( alignment)?": ["C", "G", "NY", "E"], "(?:any )?evil( alignment)?": ["L", "NX", "C", "E"], "(?:any )?lawful( alignment)?": ["L", "G", "NY", "E"], "(?:any )?good( alignment)?": ["L", "NX", "C", "G"], "good": ["G"], "lawful": ["L"], "neutral": ["N"], "chaotic": ["C"], "evil": ["E"], "any neutral( alignment)?": ["NX", "NY", "N"], "unaligned": ["U"], "any alignment": ["A"], }; AlignmentUtil.ALIGNMENTS = {}; Object.entries(AlignmentUtil.ALIGNMENTS_RAW).forEach(([k, v]) => { AlignmentUtil.ALIGNMENTS[k] = { output: v, regex: RegExp(`^${k}$`, "i"), regexChance: RegExp(`^${k}\\s*\\((\\d+)\\s*%\\)$`, "i"), regexWeak: RegExp(k, "i"), }; }); globalThis.ConvertUtil = ConvertUtil; globalThis.ConverterConst = ConverterConst; globalThis.BaseParser = BaseParser; globalThis.TagCondition = TagCondition; globalThis.SenseTag = SenseTag; globalThis.DiceConvert = DiceConvert; globalThis.ArtifactPropertiesTag = ArtifactPropertiesTag; globalThis.EntryConvert = EntryConvert; globalThis.SkillTag = SkillTag; globalThis.ActionTag = ActionTag; globalThis.TaggerUtils = TaggerUtils; globalThis.TagUtil = TagUtil; globalThis.AlignmentUtil = AlignmentUtil;