mirror of
https://github.com/Kornstalx/5etools-mirror-2.github.io.git
synced 2025-10-28 20:45:35 -05:00
1511 lines
48 KiB
JavaScript
1511 lines
48 KiB
JavaScript
"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 = `(?<name>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(`(?<start>\\d+) *\\n+(?<end>${ConverterConst.STR_RE_DAMAGE_TYPE} damage)\\b`, "gi"), (...m) => `${m.last().start} ${m.last().end}`)
|
||
// Connect together likely determiners/conjunctions/etc.
|
||
.replace(/(?<start>\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(/(?<start>[a-z0-9]) *\n+ *(?<end>to hit|modifier)\b/g, (...m) => `${m.last().start} ${m.last().end}`)
|
||
// Connect together `<ability> (<skill>)`
|
||
.replace(new RegExp(`\\b(?<start>${Object.values(Parser.ATB_ABV_TO_FULL).join("|")}) *\\n+ *(?<end>\\((?:${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(/(?<start>[a-z0-9]) *\n+ *(?<end>\d+d\d+( *[-+] *\d+)?,? [a-z]+)/g, (...m) => `${m.last().start} ${m.last().end}`)
|
||
// Connect together e.g. `2d4\n+PB`
|
||
.replace(/(?<start>(?:\d+)?d\d+) *\n *(?<end>[-+] *(?:\d+|PB) [a-z]+)/g, (...m) => `${m.last().start} ${m.last().end}`)
|
||
// Connect together likely word pairs
|
||
.replace(/\b(?<start>hit) *\n* *(?<end>points)\b/gi, (...m) => `${m.last().start} ${m.last().end}`)
|
||
.replace(/\b(?<start>save) *\n* *(?<end>DC)\b/gi, (...m) => `${m.last().start} ${m.last().end}`)
|
||
;
|
||
|
||
if (options) {
|
||
// Apply `PAGE=...`
|
||
iptClean = iptClean
|
||
.replace(/(?:\n|^)PAGE=(?<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)\\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`
|
||
`(?<!if )the[^.!?]+?${TagCondition.__TGT} is [^.!?]*?(?<!that isn't ){@condition ([^}]+)}`,
|
||
`the[^.!?]+?${TagCondition.__TGT} is [^.!?]+?, it is {@condition ([^}]+)}(?: \\(escape [^\\)]+\\))?`,
|
||
`begins to [^.!?]+? and is {@condition ([^}]+)}`, // e.g. `begins to turn to stone and is restrained`
|
||
`saving throw[^.!?]+?or [^.!?]+? and remain {@condition ([^}]+)}`, // e.g. `or fall asleep and remain unconscious`
|
||
`saving throw[^.!?]+?or be [^.!?]+? and land {@condition (prone)}`, // MM :: Cloud Giant :: Fling
|
||
`saving throw[^.!?]+?or be (?:pushed|pulled) [^.!?]+? and (?:\\w+ )?{@condition ([^}]+)}`, // MM :: Dragon Turtle :: Tail
|
||
`the engulfed (?:creature|enemy) [^.!?]+? {@condition ([^}]+)}`, // MM :: Gelatinous Cube :: Engulf
|
||
`the ${TagCondition.__TGT} is [^.!?]+? and (?:is )?{@condition ([^}]+)} while`, // MM :: Giant Centipede :: Bite
|
||
`on a failed save[^.!?]+?the (?:${TagCondition.__TGT}|creature) [^.!?]+? {@condition ([^}]+)}`, // MM :: Jackalwere :: Sleep Gaze
|
||
`on a failure[^.!?]+?${TagCondition.__TGT}[^.!?]+?(?:pushed|pulled)[^.!?]+?and (?:\\w+ )?{@condition ([^}]+)}`, // MM :: Marid :: Water Jet
|
||
`a[^.!?]+?(?:creature|enemy)[^.!?]+?to the[^.!?]+?is (?:also )?{@condition ([^}]+)}`, // MM :: Mimic :: Adhesive
|
||
`(?:creature|enemy) gains? \\w+ levels? of {@condition (exhaustion)}`, // MM :: Myconid Adult :: Euphoria Spores
|
||
`(?:saving throw|failed save)[^.!?]+? gains? \\w+ levels? of {@condition (exhaustion)}`, // ERLW :: Belashyrra :: Rend Reality
|
||
`(?:on a successful save|if the saving throw is successful), (?:the ${TagCondition.__TGT} |(?:a|the )creature |(?:an |the )enemy )[^.!?]*?isn't {@condition ([^}]+)}`,
|
||
`or take[^.!?]+?damage and (?:becomes?|is|be) {@condition ([^}]+)}`, // MM :: Quasit || Claw
|
||
`the (?:${TagCondition.__TGT}|creature|enemy) [^.!?]+? and is {@condition ([^}]+)}`, // MM :: Satyr :: Gentle Lullaby
|
||
`${TagCondition.__TGT}\\. [^.!?]+?damage[^.!?]+?and[^.!?]+?${TagCondition.__TGT} is {@condition ([^}]+)}`, // MM :: Vine Blight :: Constrict
|
||
`on a failure[^.!?]+?${TagCondition.__TGT} [^.!?]+?\\. [^.!?]+?is also {@condition ([^}]+)}`, // MM :: Water Elemental :: Whelm
|
||
`(?:(?:a|the|each) ${TagCondition.__TGT}|(?:a|the|each) creature|(?:an|each) enemy)[^.!?]+?takes?[^.!?]+?damage[^.!?]+?and [^.!?]+? {@condition ([^}]+)}`, // AI :: Keg Robot :: Hot Oil Spray
|
||
`(?:creatures|enemies) within \\d+ feet[^.!?]+must succeed[^.!?]+saving throw or be {@condition ([^}]+)}`, // VGM :: Deep Scion :: Psychic Screech
|
||
`creature that fails the save[^.!?]+?{@condition ([^}]+)}`, // VGM :: Gauth :: Stunning Gaze
|
||
`if the ${TagCondition.__TGT} is a creature[^.!?]+?saving throw[^.!?]*?\\. On a failed save[^.!?]+?{@condition ([^}]+)}`, // VGM :: Mindwitness :: Eye Rays
|
||
`while {@condition (?:[^}]+)} in this way, an? (?:${TagCondition.__TGT}|creature|enemy) [^.!?]+{@condition ([^}]+)}`, // VGM :: Vargouille :: Stunning Shriek
|
||
`${TagCondition.__TGT} must succeed[^.!?]+?saving throw[^.!?]+?{@condition ([^}]+)}`, // VGM :: Yuan-ti Pit Master :: Merrshaulk's Slumber
|
||
`fails the saving throw[^.!?]+?is instead{@condition ([^}]+)}`, // ERLW :: Sul Khatesh :: Maddening Secrets
|
||
`on a failure, the [^.!?]+? can [^.!?]+?{@condition ([^}]+)}`, // ERLW :: Zakya Rakshasa :: Martial Prowess
|
||
`the {@condition ([^}]+)} creature can repeat the saving throw`, // GGR :: Archon of the Triumvirate :: Pacifying Presence
|
||
`if the (?:${TagCondition.__TGT}|creature) is already {@condition [^}]+}, it becomes {@condition ([^}]+)}`,
|
||
`(?<!if the )(?:creature|${TagCondition.__TGT}) (?:also becomes|is) {@condition ([^}]+)}`, // MTF :: Eidolon :: Divine Dread
|
||
`magically (?:become|turn)s? {@condition (invisible)}`, // MM :: Will-o'-Wisp :: Invisibility
|
||
{re: `The (?!(?:[^.]+) can sense)(?:[^.]+) is {@condition (invisible)}`, flags: "g"}, // MM :: Invisible Stalker :: Invisibility
|
||
`succeed\\b[^.!?]+\\bsaving throw\\b[^.!?]+\\. (?:It|The (?:creature|target)) is {@condition ([^}]+)}`, // MM :: Beholder :: 6. Telekinetic Ray
|
||
]
|
||
.map(it => 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(/(?<op>[-+])?(?<bonus>\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*(?<dice>${RollerUtil.DICE_REGEX.source}) +(?<header>.*)$`);
|
||
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;
|