Files
5etools-mirror-2.github.io/js/converter-creature.js
TheGiddyLimit d075252329 v1.203.0
2024-03-26 22:43:48 +00:00

1857 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
class _ParseStateTextCreature extends BaseParseStateText {
constructor (
{
toConvert,
options,
entity,
},
) {
super({toConvert, options, entity});
this.additionalTypeTags = [];
}
addAdditionalTypeTag (val) {
const toFind = val.toLowerCase();
if (this.additionalTypeTags.some(it => it.toLowerCase() === toFind)) return;
this.additionalTypeTags.push(val);
}
}
// TODO easy improvements to be made:
// - improve "broken line" fixing:
// - across lines that end with: "Melee Weapon Attack:"
// - creature's name breaking across multiple lines
// - lines starting "DC" breaking across multiple lines
// - lines starting with attack range e.g. "100/400 ft."
class CreatureParser extends BaseParser {
static _NO_ABSORB_SUBTITLES = [
"SAVING THROWS",
"SKILLS",
"DAMAGE VULNERABILITIES",
"DAMAGE RESISTANCE",
"DAMAGE IMMUNITIES",
"CONDITION IMMUNITIES",
"SENSES",
"LANGUAGES",
"CHALLENGE",
"PROFICIENCY BONUS",
];
static _NO_ABSORB_TITLES = [
"ACTION",
"LEGENDARY ACTION",
"VILLAIN ACTION", // homebrew
"MYTHIC ACTION",
"REACTION",
"BONUS ACTION",
"UTILITY SPELL", // homebrew
];
static _RE_START_SAVING_THROWS = "(?:Saving Throw|Save)s?";
static _RE_START_SKILLS = "Skills?";
static _RE_START_DAMAGE_VULN = "Damage Vulnerabilit(?:y|ies)";
static _RE_START_DAMAGE_RES = "Damage Resistances?";
static _RE_START_DAMAGE_IMM = "Damage Immunit(?:y|ies)";
static _RE_START_CONDITION_IMM = "Condition Immunit(?:y|ies)";
static _RE_START_SENSES = "Senses?";
static _RE_START_LANGUAGES = "Languages?";
static _RE_START_CHALLENGE = "Challenge";
static _RE_START_PROF_BONUS = "Proficiency Bonus(?: \\(PB\\))?";
static _LINE_MODES = {
UNKNOWN: "unknown",
TRAITS: "traits",
ACTIONS: "actions",
REACTIONS: "reactions",
BONUS_ACTIONS: "bonusActions",
LEGENDARY_ACTIONS: "legendaryActions",
MYTHIC_ACTIONS: "mythicActions",
BREW_FEATURES: "brewFeatures", // homebrew
};
/**
* If the current line ends in a comma, we can assume the next line is a broken/wrapped part of the current line
*/
static _absorbBrokenLine (
{
isCrLine,
meta,
},
) {
if (!meta.curLine) return false;
if (isCrLine) return false; // avoid absorbing past the CR line
const nxtLine = meta.toConvert[meta.ixToConvert + 1];
if (!nxtLine) return false;
if (ConvertUtil.isNameLine(nxtLine)) return false; // avoid absorbing the start of traits
if (ConvertUtil.isListItemLine(nxtLine)) return false;
if (this._NO_ABSORB_TITLES.some(it => nxtLine.toUpperCase().includes(it))) return false;
if (this._NO_ABSORB_SUBTITLES.some(it => nxtLine.toUpperCase().startsWith(it))) return false;
meta.ixToConvert++;
meta.curLine = `${meta.curLine.trim()} ${nxtLine.trim()}`;
return true;
}
static _isStartNextLineParsingPhase ({line}) {
return /^(?:action|legendary action|mythic action|reaction|bonus action)s?(?:\s+\([^)]+\))?$/i.test(line)
// Homebrew
|| /^(?:feature|villain action|utility spell)s?(?:\s+\([^)]+\))?$/i.test(line);
}
static _isNonMergeableEntryLine_noSentenceBreak ({line, lineNxt}) {
return !/[.?!]$/.test(line.trim())
&& /^(?:[A-Z]|\d+[ ,])/.test(lineNxt.trim());
}
/**
* Parses statblocks from raw text pastes
* @param inText Input text.
* @param options Options object.
* @param options.cbWarning Warning callback.
* @param options.cbOutput Output callback.
* @param options.isAppend Default output append mode.
* @param options.source Entity source.
* @param options.page Entity page.
* @param options.titleCaseFields Array of fields to be title-cased in this entity (if enabled).
* @param options.isTitleCase Whether title-case fields should be title-cased in this entity.
*/
static doParseText (inText, options) {
options = this._getValidOptions(options);
if (!inText || !inText.trim()) return options.cbWarning("No input!");
const toConvert = this._getLinesToConvert({inText, options});
const stats = {};
stats.source = options.source || "";
// for the user to fill out
stats.page = options.page;
const meta = new _ParseStateTextCreature({toConvert, options, entity: stats});
meta.doPreLoop();
// Pre step to handle CR/XP/etc. which has, due to awkward text flow, landed at the bottom of the statblock
for (; meta.ixToConvert < meta.toConvert.length; meta.ixToConvert++) {
meta.initCurLine();
if (meta.isSkippableCurLine()) continue;
if (this._doParseText_crAlt({meta, stats, cbWarning: options.cbWarning})) continue;
if (this._doParseText_role({meta})) continue;
}
meta.doPostLoop();
meta.doPreLoop();
for (; meta.ixToConvert < meta.toConvert.length; meta.ixToConvert++) {
meta.initCurLine();
if (meta.isSkippableCurLine()) continue;
// name of monster
if (meta.ixToConvert === 0) {
// region
const mCr = /^(?<name>.*)\s+(?<cr>CR (?:\d+(?:\/\d+)?|[⅛¼½]) .*$)/.exec(meta.curLine);
if (mCr) {
meta.curLine = mCr.groups.name;
meta.toConvert.splice(meta.ixToConvert + 1, 0, mCr.groups.cr);
}
// endregion
stats.name = this._getAsTitle("name", meta.curLine, options.titleCaseFields, options.isTitleCase);
// If the name is immediately repeated, skip it
if ((meta.toConvert[meta.ixToConvert + 1] || "").trim() === meta.curLine) meta.toConvert.splice(meta.ixToConvert + 1, 1);
continue;
}
// challenge rating alt
if (this._doParseText_crAlt({meta, stats, cbWarning: options.cbWarning})) continue;
// homebrew "role"
if (this._doParseText_role({meta})) continue;
// homebrew resources: "souls"
if (this._RE_BREW_RESOURCE_SOULS.test(meta.curLine)) {
this._brew_setResourceSouls(stats, meta, options);
meta.toConvert.splice(meta.ixToConvert, 1);
meta.ixToConvert--;
continue;
}
// size type alignment
if (meta.ixToConvert === 1) {
this._setCleanSizeTypeAlignment(stats, meta, options);
continue;
}
// armor class
if (meta.ixToConvert === 2) {
stats.ac = ConvertUtil.getStatblockLineHeaderText({reStartStr: "Armor Class", line: meta.curLine});
continue;
}
// hit points
if (meta.ixToConvert === 3) {
this._setCleanHp(stats, meta.curLine);
continue;
}
// speed
if (meta.ixToConvert === 4) {
this._setCleanSpeed(stats, meta.curLine, options);
continue;
}
// ability scores
if (/STR\s*DEX\s*CON\s*INT\s*WIS\s*CHA/i.test(meta.curLine)) {
// skip forward a line and grab the ability scores
++meta.ixToConvert;
this._mutAbilityScoresFromSingleLine(stats, meta);
continue;
}
// Alternate ability scores (all six abbreviations followed by all six scores, each on new lines)
if (this._getSequentialAbilityScoreSectionLineCount(stats, meta) === 6) {
meta.ixToConvert += this._getSequentialAbilityScoreSectionLineCount(stats, meta);
this._mutAbilityScoresFromSingleLine(stats, meta);
continue;
}
// Alternate ability scores (alternating lines of abbreviation and score)
if (Parser.ABIL_ABVS.includes(meta.curLine.toLowerCase())) {
// skip forward a line and grab the ability score
++meta.ixToConvert;
switch (meta.curLine.toLowerCase()) {
case "str": stats.str = this._tryGetStat(meta.toConvert[meta.ixToConvert]); continue;
case "dex": stats.dex = this._tryGetStat(meta.toConvert[meta.ixToConvert]); continue;
case "con": stats.con = this._tryGetStat(meta.toConvert[meta.ixToConvert]); continue;
case "int": stats.int = this._tryGetStat(meta.toConvert[meta.ixToConvert]); continue;
case "wis": stats.wis = this._tryGetStat(meta.toConvert[meta.ixToConvert]); continue;
case "cha": stats.cha = this._tryGetStat(meta.toConvert[meta.ixToConvert]); continue;
}
}
// Alternate ability scores ()
if (this._handleSpecialAbilityScores({stats, meta})) {
continue;
}
// saves (optional)
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_SAVING_THROWS, line: meta.curLine})) {
// noinspection StatementWithEmptyBodyJS
while (this._absorbBrokenLine({meta}));
this._setCleanSaves(stats, meta.curLine, options);
continue;
}
// skills (optional)
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_SKILLS, line: meta.curLine})) {
// noinspection StatementWithEmptyBodyJS
while (this._absorbBrokenLine({meta}));
this._setCleanSkills(stats, meta.curLine, options);
continue;
}
// damage vulnerabilities (optional)
if (
ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_DAMAGE_VULN, line: meta.curLine})
) {
// noinspection StatementWithEmptyBodyJS
while (this._absorbBrokenLine({meta}));
this._setCleanDamageVuln(stats, meta.curLine, options);
continue;
}
// damage resistances (optional)
if (
ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_DAMAGE_RES, line: meta.curLine})
) {
// noinspection StatementWithEmptyBodyJS
while (this._absorbBrokenLine({meta}));
this._setCleanDamageRes(stats, meta.curLine, options);
continue;
}
// damage immunities (optional)
if (
ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_DAMAGE_IMM, line: meta.curLine})
) {
// noinspection StatementWithEmptyBodyJS
while (this._absorbBrokenLine({meta}));
this._setCleanDamageImm(stats, meta.curLine, options);
continue;
}
// condition immunities (optional)
if (
ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_CONDITION_IMM, line: meta.curLine})
) {
// noinspection StatementWithEmptyBodyJS
while (this._absorbBrokenLine({meta}));
this._setCleanConditionImm(stats, meta.curLine, options);
continue;
}
// senses
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_SENSES, line: meta.curLine})) {
// noinspection StatementWithEmptyBodyJS
while (this._absorbBrokenLine({meta}));
this._setCleanSenses({stats, line: meta.curLine, cbWarning: options.cbWarning});
continue;
}
// languages
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_LANGUAGES, line: meta.curLine})) {
// noinspection StatementWithEmptyBodyJS
while (this._absorbBrokenLine({meta}));
this._setCleanLanguages(stats, meta.curLine);
continue;
}
// challenge rating
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_CHALLENGE, line: meta.curLine})) {
// noinspection StatementWithEmptyBodyJS
while (this._absorbBrokenLine({isCrLine: true, meta}));
this._setCleanCr(stats, meta, {cbWarning: options.cbWarning, header: this._RE_START_CHALLENGE});
continue;
}
// proficiency bonus
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: this._RE_START_PROF_BONUS, line: meta.curLine})) {
// noinspection StatementWithEmptyBodyJS
while (this._absorbBrokenLine({meta}));
this._setCleanPbNote(stats, meta.curLine);
continue;
}
// traits
stats.trait = [];
stats.action = [];
stats.reaction = [];
stats.bonus = [];
stats.legendary = [];
stats.mythic = [];
let curTrait = {};
let lineMode = this._LINE_MODES.TRAITS;
let isLegendaryDescription = false;
let isMythicDescription = false;
// Join together lines which are probably split over multiple lines of text
for (let j = meta.ixToConvert; j < meta.toConvert.length; ++j) {
let line = meta.toConvert[j];
let lineNxt = meta.toConvert[j + 1];
if (!lineNxt) continue;
if (this._isStartNextLineParsingPhase({line}) || this._isStartNextLineParsingPhase({line: lineNxt})) continue;
if (!this._isNonMergeableEntryLine_noSentenceBreak({line, lineNxt})) continue;
if (ConvertUtil.isNameLine(lineNxt, {exceptions: new Set(["cantrips"]), splitterPunc: /(\.)/g})) {
// If line+1 looks like a name line but has no content, and line+2 looks like a name line and *does*
// have content, then we assume that line+1 is actually part of the current entry as otherwise
// line+1 is a no-text entry.
const lineNxtNxt = meta.toConvert[j + 2];
const {entry: entryNxt} = ConvertUtil.splitNameLine(lineNxt);
if (
// If line+1 has an entry, it's a legitimate name line
entryNxt?.trim()
// If line+2 doesn't exist, line+1 is a legitimate name line
|| !lineNxtNxt?.trim()
// If line+2 is next phase, line+1 is a legitimate name line
|| this._isStartNextLineParsingPhase({line: lineNxtNxt})
) continue;
if (!ConvertUtil.isNameLine(lineNxtNxt, {exceptions: new Set(["cantrips"]), splitterPunc: /(\.)/g})) continue;
}
// Avoid eating spellcasting `At Will: ...`
const splColonNext = lineNxt.split(/(?::| -) /g).filter(Boolean);
if (line.trim().endsWith(":") && splColonNext.length > 1 && /^[A-Z\d][\\/a-z]/.test(splColonNext[0].trim())) continue;
meta.toConvert[j] = `${line.trim()} ${lineNxt.trim()}`;
meta.toConvert.splice(j + 1, 1);
if (j === meta.ixToConvert) meta.curLine = meta.toConvert[meta.ixToConvert];
--j;
}
// keep going through traits til we hit actions
while (meta.ixToConvert < meta.toConvert.length) {
if (this._isStartNextLineParsingPhase({line: meta.curLine})) {
lineMode = this._LINE_MODES.UNKNOWN;
// Homebrew
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: "FEATURES?", line: meta.curLine.toUpperCase()})) lineMode = this._LINE_MODES.BREW_FEATURES;
// Homebrew
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: "UTILITY SPELLS?", line: meta.curLine.toUpperCase()})) {
lineMode = this._LINE_MODES.TRAITS;
// Fake a spellcasting entry by adding a generic header
const nxtLineMeta = meta.getNextLineMeta();
if (nxtLineMeta) {
meta.toConvert[nxtLineMeta.ixToConvertNext] = `Spellcasting (Utility). ${meta.toConvert[nxtLineMeta.ixToConvertNext]}`;
}
}
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: "ACTIONS?", line: meta.curLine.toUpperCase()})) lineMode = this._LINE_MODES.ACTIONS;
if (lineMode === this._LINE_MODES.ACTIONS) {
const mActionNote = /actions:?\s*\((.*?)\)/gi.exec(meta.curLine);
if (mActionNote) stats.actionNote = mActionNote[1];
}
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: "REACTIONS?", line: meta.curLine.toUpperCase()})) lineMode = this._LINE_MODES.REACTIONS;
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: "BONUS ACTIONS?", line: meta.curLine.toUpperCase()})) lineMode = this._LINE_MODES.BONUS_ACTIONS;
if (
ConvertUtil.isStatblockLineHeaderStart({reStartStr: "LEGENDARY ACTIONS?", line: meta.curLine.toUpperCase()})
// Homebrew
|| ConvertUtil.isStatblockLineHeaderStart({reStartStr: "VILLAIN ACTIONS?", line: meta.curLine.toUpperCase()})
) lineMode = this._LINE_MODES.LEGENDARY_ACTIONS;
isLegendaryDescription = lineMode === this._LINE_MODES.LEGENDARY_ACTIONS;
if (ConvertUtil.isStatblockLineHeaderStart({reStartStr: "MYTHIC ACTIONS", line: meta.curLine.toUpperCase()})) lineMode = this._LINE_MODES.MYTHIC_ACTIONS;
isMythicDescription = lineMode === this._LINE_MODES.MYTHIC_ACTIONS;
meta.ixToConvert++;
meta.curLine = meta.toConvert[meta.ixToConvert];
}
// Homebrew; ensure "Signature Attack (...). ..." features are always parsed as actions
if (
lineMode !== this._LINE_MODES.ACTIONS
&& /^Signature Attack(?: \([^)]+\))?\./.test(meta.curLine)
) {
lineMode = this._LINE_MODES.ACTIONS;
}
// Handle reaction intro
if (lineMode === this._LINE_MODES.REACTIONS) {
if (/^[^.!?]+ can take (?:up to )?(two|three|four|five) reactions per round but only one per turn\.$/.test(meta.curLine)) {
stats.reactionHeader = [
meta.curLine,
];
meta.ixToConvert++;
meta.curLine = meta.toConvert[meta.ixToConvert];
continue;
}
}
curTrait.name = "";
curTrait.entries = [];
if (isLegendaryDescription || isMythicDescription) {
const compressed = meta.curLine.replace(/\s*/g, "").toLowerCase();
if (isLegendaryDescription) {
// usually the first paragraph is a description of how many legendary actions the creature can make
// but in the case that it's missing the substring "legendary" and "action" it's probably an action
if (!(compressed.includes("legendary") || compressed.includes("villain")) && !compressed.includes("action")) isLegendaryDescription = false;
} else if (isMythicDescription) {
// as above--mythic action headers include the text "legendary action"
if (!compressed.includes("legendary") && !compressed.includes("action")) isLegendaryDescription = false;
}
}
if (isLegendaryDescription) {
curTrait.entries.push(meta.curLine.trim());
isLegendaryDescription = false;
} else if (isMythicDescription) {
if (/mythic\s+trait/i.test(meta.curLine)) {
stats.mythicHeader = [meta.curLine.trim()];
} else {
curTrait.entries.push(meta.curLine.trim());
}
isMythicDescription = false;
} else {
const {name, entry} = ConvertUtil.splitNameLine(meta.curLine);
curTrait.name = name;
curTrait.entries.push(entry);
}
meta.ixToConvert++;
meta.curLine = meta.toConvert[meta.ixToConvert];
// collect subsequent paragraphs
while (meta.curLine && !ConvertUtil.isNameLine(meta.curLine, {exceptions: new Set(["cantrips"]), splitterPunc: /([.?!])/g}) && !this._isStartNextLineParsingPhase({line: meta.curLine})) {
if (BaseParser._isContinuationLine(curTrait.entries, meta.curLine)) {
curTrait.entries.last(`${curTrait.entries.last().trim()} ${meta.curLine.trim()}`);
} else {
curTrait.entries.push(meta.curLine.trim());
}
meta.ixToConvert++;
meta.curLine = meta.toConvert[meta.ixToConvert];
}
if (curTrait.name || curTrait.entries) {
// convert dice tags
DiceConvert.convertTraitActionDice(curTrait);
switch (lineMode) {
case this._LINE_MODES.UNKNOWN: options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Discarded un-categorizable entry "${JSON.stringify(curTrait)}"!`); break;
case this._LINE_MODES.TRAITS: if (this._hasEntryContent(curTrait)) stats.trait.push(curTrait); break;
case this._LINE_MODES.ACTIONS: if (this._hasEntryContent(curTrait)) stats.action.push(curTrait); break;
case this._LINE_MODES.REACTIONS: if (this._hasEntryContent(curTrait)) stats.reaction.push(curTrait); break;
case this._LINE_MODES.BONUS_ACTIONS: if (this._hasEntryContent(curTrait)) stats.bonus.push(curTrait); break;
case this._LINE_MODES.LEGENDARY_ACTIONS: if (this._hasEntryContent(curTrait)) stats.legendary.push(curTrait); break;
case this._LINE_MODES.MYTHIC_ACTIONS: if (this._hasEntryContent(curTrait)) stats.mythic.push(curTrait); break;
case this._LINE_MODES.BREW_FEATURES: {
if (!this._hasEntryContent(curTrait)) break;
const prop = this._getEntryProp({entry: curTrait});
stats[prop].push(curTrait);
break;
}
default: throw new Error(`Unhandled line mode "${lineMode}"!`);
}
}
curTrait = {};
}
CreatureParser._PROPS_ENTRIES.forEach(prop => this._doMergeBulletedLists(stats, prop));
CreatureParser._PROPS_ENTRIES.forEach(prop => this._doMergeNumberedLists(stats, prop));
CreatureParser._PROPS_ENTRIES.forEach(prop => this._doMergeHangingLists(stats, prop));
["action"].forEach(prop => this._doMergeBreathWeaponLists(stats, prop));
["action"].forEach(prop => this._doMergeEyeRayLists(stats, prop));
// Remove keys if they are empty
if (stats.trait.length === 0) delete stats.trait;
if (stats.action.length === 0) delete stats.action;
if (stats.bonus.length === 0) delete stats.bonus;
if (stats.reaction.length === 0) delete stats.reaction;
if (stats.legendary.length === 0) delete stats.legendary;
if (stats.mythic.length === 0) delete stats.mythic;
}
meta.doPostLoop();
this._doCleanLegendaryActionHeader(stats);
this._addExtraTypeTags(stats, meta);
this._doStatblockPostProcess(stats, false, options);
const statsOut = PropOrder.getOrdered(stats, "monster");
options.cbOutput(statsOut, options.isAppend);
}
static _doParseText_crAlt ({meta, stats, cbWarning}) {
if (!ConvertUtil.isStatblockLineHeaderStart({reStartStr: "CR", line: meta.curLine})) return false;
// noinspection StatementWithEmptyBodyJS
while (this._absorbBrokenLine({isCrLine: true, meta}));
// Region merge following "<n> XP" line into CR line
const lineNxt = meta.toConvert[meta.ixToConvert + 1];
if (lineNxt && /^[\d,]+ XP$/.test(lineNxt)) {
meta.curLine = `${meta.curLine.trim()} (${lineNxt.trim()})`;
meta.toConvert[meta.ixToConvert] = meta.curLine;
meta.toConvert.splice(meta.ixToConvert + 1, 1);
}
// endregion
this._setCleanCr(stats, meta, {cbWarning, header: "CR"});
// remove the line, as we expect alignment as line 1
meta.toConvert.splice(meta.ixToConvert, 1);
meta.ixToConvert--;
return true;
}
static _doParseText_role ({meta}) {
if (!["companion", "retainer"].includes(meta.curLine.toLowerCase())) return false;
meta.addAdditionalTypeTag(meta.curLine.toTitleCase());
// remove the line, as we expect alignment as line 1
meta.toConvert.splice(meta.ixToConvert, 1);
meta.ixToConvert--;
return true;
}
static _getLinesToConvert ({inText, options}) {
let clean = this._getCleanInput(inText, options);
// region Handle bad OCR'ing of headers
[
"Legendary Actions?",
"Villain Actions?",
"Bonus Actions?",
"Reactions?",
"Actions?",
]
.map(it => ({re: new RegExp(`\\n\\s*${it.split("").join("\\s*")}\\s*\\n`, "g"), original: it.replace(/[^a-zA-Z ]/g, "")}))
.forEach(({re, original}) => clean = clean.replace(re, `\n${original}\n`));
// endregion
// region Handle bad OCR'ing of dice
clean = clean
.replace(/\nl\/(?<unit>day)[.:]\s*/g, (...m) => `\n1/${m.last().unit}: `)
.replace(/\b(?<num>[liI!]|\d+)?d[1liI!]\s*[oO0]\b/g, (...m) => `${m.last().num ? isNaN(m.last().num) ? "1" : m.last().num : ""}d10`)
.replace(/\b(?<num>[liI!]|\d+)?d[1liI!]\s*2\b/g, (...m) => `${m.last().num ? isNaN(m.last().num) ? "1" : m.last().num : ""}d12`)
.replace(/\b[liI!1]\s*d\s*(?<faces>\d+)\b/g, (...m) => `1d${m.last().faces}`)
.replace(/\b(?<num>\d+)\s*d\s*(?<faces>\d+)\b/g, (...m) => `${m.last().num}d${m.last().faces}`)
;
// endregion
// region Handle misc OCR issues
clean = clean
.replace(/\bI nt\b/g, "Int")
.replace(/\(-[lI!]\)/g, "(-1)")
;
// endregion
// Handle modifiers split across lines
clean = clean
.replace(/([-+] +)\n +(\d+|PB)/g, (...m) => `${m[1]}${m[2]}`)
;
// Handle CR XP on separate line
clean = clean
.replace(/\n(\([\d,]+ XP\)\n)/g, (...m) => m[1])
;
// region Split sentences which should *generally* in the same paragraph
clean = clean
// Handle split "DC-sentence then effect-sentence"
.replace(/(\.\s*?)\n(On a (?:failed save|failure|success|successful save)\b)/g, (...m) => `${m[1].trimEnd()} ${m[2]}`)
// Handle split "The target..." sentences
.replace(/(\.\s*?)\n(The target (?:then|must|regains)\b)/g, (...m) => `${m[1].trimEnd()} ${m[2]}`)
// Handle split "A creature..." sentences
.replace(/(\.\s*?)\n(A creature (?:takes)\b)/g, (...m) => `${m[1].trimEnd()} ${m[2]}`)
;
// endregion
clean = clean
// Handle split `Melee Attack: ...`
.replace(/((?:Melee|Ranged) (?:(?:Weapon|Spell|Area|Power) )?Attack:)\s*?\n\s*([-+])/g, (...m) => `${m[1]} ${m[2]}`)
// Handle split `Hit: ... damage. If ...`
.replace(/(Hit: [^.!?]+ damage(?: [^.!?]+)?[.!?])\s*?\n\s*(If)\b/g, (...m) => `${m[1].trimEnd()} ${m[2].trimStart()}`)
;
clean = clean
// Homebrew spell action superscript
// handle `commune\n+, ...`
.replace(/([a-z]) *\n([ABR+], )/mg, (...m) => `${m[1]} ${m[2]}`)
// handle `commune\n+\n`
.replace(/([a-z]) *\n([ABR+])(\n|$)/mg, (...m) => `${m[1]} ${m[2]}${m[3]}`)
;
clean = clean
// We don't expect any bare lines starting with parens
.replace(/ *\n\(/, " (");
const statsHeadFootSpl = clean.split(/(Challenge|Proficiency Bonus \(PB\))/i);
statsHeadFootSpl[0] = statsHeadFootSpl[0]
// collapse multi-line ability scores
.replace(/^(?:\d\d?\s*\([-—+]?\d+\)\s*)+$/gim, (...m) => `${m[0].replace(/\n/g, " ").replace(/\s+/g, " ")}\n`);
// (re-assemble after cleaning ability scores and) split into lines
clean = statsHeadFootSpl.join("");
// Re-clean after applying the above
clean = this._getCleanInput(clean);
let cleanLines = clean.split("\n").filter(it => it && it.trim());
// Split apart "Challenge" and "Proficiency Bonus" if they are on the same line
const ixChallengePb = cleanLines.findIndex(line => /^Challenge/.test(line.trim()) && /Proficiency Bonus/.test(line));
if (~ixChallengePb) {
let line = cleanLines[ixChallengePb];
const [challengePart, pbLabel, pbRest] = line.split(/(Proficiency Bonus)/);
cleanLines[ixChallengePb] = challengePart;
cleanLines.splice(ixChallengePb + 1, 0, [pbLabel, pbRest].join(""));
}
return cleanLines;
}
static _handleSpecialAbilityScores ({stats, meta}) {
const matches = [];
let i = meta.ixToConvert;
for (; i < meta.toConvert.length; ++i) {
const l = meta.toConvert[i].trim();
const m = new RegExp(`^${Parser.ABIL_ABVS.map(ab => `(?<${ab}>${ab.toUpperCase()}(?:, |\\b))?`).join("")}(?<text>.*)$`).exec(l);
if (!m) break;
if (!Parser.ABIL_ABVS.some(ab => m.groups[ab])) break;
matches.push(m);
}
if (!matches.length) return false;
matches
.forEach(m => {
Parser.ABIL_ABVS
.forEach(ab => {
if (!m.groups[ab]) return;
stats[ab] = {special: m.groups.text.trim()};
});
});
meta.ixToConvert += i - meta.ixToConvert;
return true;
}
static _getEntryProp ({entry}) {
if (typeof entry?.entries?.[0] !== "string") return "trait";
const [str] = entry.entries;
if (/\b(?:as a|can use (?:a|their)) bonus action\b/i.test(str)) return "bonus";
if (/\b(?:as a|can use (?:a|their)) reaction\b/i.test(str)) return "reaction";
if (/\b(?:(?:as|use) (an|their) action|takes the [A-Z][^.!?]+ action\b)\b/i.test(str)) return "action";
// can use their reaction
// Homebrew: for unclassified psionic powers, assume action by default
if (/\b\d+(?:st|nd|rd|th)[-\u2012-\u2014]Order Power\b/.test(entry.name)) return "action";
return "trait";
}
static _doCleanLegendaryActionHeader (stats) {
if (!stats.legendary?.length) return;
stats.legendary = stats.legendary
.map(it => {
if (!it.name.trim() && !it.entries.length) return null;
const m = /can take (\d) legendary actions/gi.exec(it.entries[0]);
if (!it.name.trim() && m) {
if (m[1] !== "3") stats.legendaryActions = Number(m[1]);
return null;
}
if (!it.name.trim() && it.entries[0].includes("villain")) {
stats.legendaryHeader = it.entries;
return null;
}
return it;
})
.filter(Boolean);
}
static _doMergeBulletedLists (stats, prop) {
if (!stats[prop]) return;
stats[prop]
.forEach(block => {
if (!block?.entries?.length) return;
for (let i = 0; i < block.entries.length; ++i) {
const curLine = block.entries[i];
if (typeof curLine !== "string") continue;
let lst = null;
let offset = 1;
while (block.entries.length) {
let nxtLine = block.entries[i + offset];
if (typeof nxtLine !== "string" || !/^[•●]/.test(nxtLine.trim())) break;
nxtLine = nxtLine.replace(/^[•●]\s*/, "");
const listItem = this._doMergeBulletedLists_getListItem(nxtLine);
if (!lst) {
lst = {type: "list", items: [listItem]};
block.entries[i + offset] = lst;
offset++;
} else {
lst.items.push(listItem);
block.entries.splice(i + offset, 1);
}
}
}
});
}
static _doMergeBulletedLists_getListItem (str) {
if (!ConvertUtil.isNameLine(str)) return str;
const {name, entry} = ConvertUtil.splitNameLine(str);
return {
type: "item",
name,
entry,
};
}
static _mutAssignPrettyType ({obj, type}) {
const tmp = {...obj};
Object.keys(obj).forEach(k => delete obj[k]);
obj.type = "item";
Object.assign(obj, tmp);
}
static _doMergeNumberedLists (stats, prop) {
if (!stats[prop]) return;
for (let i = 0; i < stats[prop].length; ++i) {
const cpyCur = MiscUtil.copyFast(stats[prop][i]);
if (
!(
typeof cpyCur?.entries?.last() === "string"
&& cpyCur?.entries?.last().trim().endsWith(":")
)
) continue;
let lst = null;
const slice = stats[prop].slice(i + 1);
while (slice.length) {
const cpyNxt = MiscUtil.copyFast(slice[0]);
if (/^\d+(?:-\d+)?[.!?:] [A-Za-z]/.test(cpyNxt?.name || "")) {
if (!lst) {
lst = {type: "list", style: "list-hang-notitle", items: []};
cpyCur.entries.push(lst);
}
this._mutAssignPrettyType({obj: cpyNxt, type: "item"});
lst.items.push(cpyNxt);
slice.shift();
continue;
}
break;
}
// Ensure a list has a meaningful amount of items, or it's probably not a list
if (lst == null || lst.items.length < 2) continue;
stats[prop].splice(i + 1, lst.items.length);
stats[prop][i].entries.push(lst);
}
}
static _doMergeBreathWeaponLists (stats, prop) {
if (!stats[prop]) return;
for (let i = 0; i < stats[prop].length; ++i) {
const cur = stats[prop][i];
if (
typeof cur?.entries?.last() === "string"
&& cur?.entries?.last().trim().endsWith(":")
&& cur?.entries?.last().trim().includes("following breath weapon")
) {
let lst = null;
while (stats[prop].length) {
const nxt = stats[prop][i + 1];
if (/\bbreath\b/i.test(nxt?.name || "")) {
if (!lst) {
lst = {type: "list", style: "list-hang-notitle", items: []};
cur.entries.push(lst);
}
this._mutAssignPrettyType({obj: nxt, type: "item"});
lst.items.push(nxt);
stats[prop].splice(i + 1, 1);
continue;
}
break;
}
}
}
}
static _doMergeEyeRayLists (stats, prop) {
if (!stats[prop]) return;
for (let i = 0; i < stats[prop].length; ++i) {
const cur = stats[prop][i];
if (
/^eye (?:ray|psionic)s?/i.test(cur.name || "")
) {
let lst = null;
while (stats[prop].length) {
const nxt = stats[prop][i + 1];
if (/^\d+\.\s+/i.test(nxt?.name || "")) {
if (!lst) {
lst = {type: "list", style: "list-hang-notitle", items: []};
cur.entries.push(lst);
}
this._mutAssignPrettyType({obj: nxt, type: "item"});
lst.items.push(nxt);
stats[prop].splice(i + 1, 1);
continue;
}
break;
}
}
}
}
/**
* Merge together likely hanging lists. Note that this should be fairly conservative, as merging unwanted entries
* into the list is worse than not merging some list entries.
*/
static _doMergeHangingLists (stats, prop) {
if (!stats[prop]) return;
for (let i = 0; i < stats[prop].length; ++i) {
const cur = stats[prop][i];
if (typeof cur?.entries?.last() !== "string" || !cur?.entries?.last().trim().endsWith(":")) continue;
const ptrList = {_: null};
if (
this._doMergeHangingLists_generic({
stats,
prop,
ix: i,
cur,
ptrList,
fnIsMatchCurEntry: cur => /\b(?:following( effects)?|their effects follow)[^.!?]*:/.test(cur.entries.last().trim()),
fnIsMatchNxtStr: ({entryNxt, entryNxtStr}) => /\bthe target\b/i.test(entryNxtStr) && !entryNxt.name?.includes("("),
})
) continue;
if (
this._doMergeHangingLists_generic({
stats,
prop,
ix: i,
cur,
ptrList,
fnIsMatchCurEntry: cur => /\bfollowing effects of that Elemental's choice\b/.test(cur.entries.last().trim()),
fnIsMatchNxtStr: ({entryNxt, entryNxtStr}) => /\b[Tt]he Elemental\b/i.test(entryNxtStr),
})
) continue;
}
}
static _doMergeHangingLists_generic ({stats, prop, ix, cur, ptrList, fnIsMatchCurEntry, fnIsMatchNxtStr}) {
if (!fnIsMatchCurEntry(cur)) return false;
while (stats[prop].length) {
const entryNxt = stats[prop][ix + 1];
const entryNxtStr = entryNxt?.entries?.[0];
if (!entryNxtStr || typeof entryNxtStr !== "string") break;
if (!fnIsMatchNxtStr({entryNxt, entryNxtStr})) break;
if (!ptrList._) {
ptrList._ = {type: "list", style: "list-hang-notitle", items: []};
cur.entries.push(ptrList._);
}
this._mutAssignPrettyType({obj: entryNxt, type: "item"});
ptrList._.items.push(entryNxt);
stats[prop].splice(ix + 1, 1);
}
return true;
}
/**
* Parses statblocks from Homebrewery/GM Binder Markdown
* @param inText Input text.
* @param options Options object.
* @param options.cbWarning Warning callback.
* @param options.cbOutput Output callback.
* @param options.isAppend Default output append mode.
* @param options.source Entity source.
* @param options.page Entity page.
* @param options.titleCaseFields Array of fields to be title-cased in this entity (if enabled).
* @param options.isTitleCase Whether title-case fields should be title-cased in this entity.
*/
static doParseMarkdown (inText, options) {
options = this._getValidOptions(options);
const isInlineLegendaryActionItem = (line) => /^-\s*\*\*\*?[^*]+/gi.test(line.trim());
if (!inText || !inText.trim()) return options.cbWarning("No input!");
const toConvert = this._getCleanInput(inText, options).split("\n");
let stats = null;
const getNewStatblock = () => {
return {
source: options.source,
page: options.page,
};
};
let step = 0;
let hasMultipleBlocks = false;
const doOutputStatblock = () => {
if (trait != null) doAddFromParsed();
if (stats) {
this._addExtraTypeTags(stats, meta);
this._doStatblockPostProcess(stats, true, options);
const statsOut = PropOrder.getOrdered(stats, "monster");
options.cbOutput(statsOut, options.isAppend);
}
stats = getNewStatblock();
if (hasMultipleBlocks) options.isAppend = true; // append any further blocks we find in this parse
step = 0;
};
let isPrevBlank = true;
let nextPrevBlank = true;
let trait = null;
const getCleanLegendaryActionText = (line) => {
return ConverterUtilsMarkdown.getCleanTraitText(line.trim().replace(/^-\s*/, ""));
};
const doAddFromParsed = () => {
if (step === 9) { // traits
doAddTrait();
} else if (step === 10) { // actions
doAddAction();
} else if (step === 11) { // reactions
doAddReaction();
} else if (step === 12) { // bonus actions
doAddBonusAction();
} else if (step === 13) { // legendary actions
doAddLegendary();
} else if (step === 14) { // mythic actions
doAddMythic();
}
};
const _doAddGenericAction = (prop) => {
if (this._hasEntryContent(trait)) {
stats[prop] = stats[prop] || [];
DiceConvert.convertTraitActionDice(trait);
stats[prop].push(trait);
}
trait = null;
};
const doAddTrait = () => _doAddGenericAction("trait");
const doAddAction = () => _doAddGenericAction("action");
const doAddReaction = () => _doAddGenericAction("reaction");
const doAddBonusAction = () => _doAddGenericAction("bonus");
const doAddLegendary = () => _doAddGenericAction("legendary");
const doAddMythic = () => _doAddGenericAction("mythic");
// TODO(Future) create and switch to a `_ParseMetaMarkdownCreature`; factor shared to mixin
const meta = new _ParseStateTextCreature({toConvert});
for (let i = 0; i < meta.toConvert.length; i++) {
let curLineRaw = ConverterUtilsMarkdown.getCleanRaw(meta.toConvert[i]);
meta.curLine = curLineRaw;
if (ConverterUtilsMarkdown.isBlankLine(curLineRaw)) {
isPrevBlank = true;
continue;
} else nextPrevBlank = false;
meta.curLine = this._stripMarkdownQuote(meta.curLine);
if (ConverterUtilsMarkdown.isBlankLine(meta.curLine)) continue;
else if (
(meta.curLine === "___" && isPrevBlank) // handle nicely separated blocks
|| curLineRaw === "___" // handle multiple stacked blocks
) {
if (stats !== null) hasMultipleBlocks = true;
doOutputStatblock();
isPrevBlank = nextPrevBlank;
continue;
} else if (meta.curLine === "___") {
isPrevBlank = nextPrevBlank;
continue;
}
// name of monster
if (step === 0) {
meta.curLine = ConverterUtilsMarkdown.getNoHashes(meta.curLine);
stats.name = this._getAsTitle("name", meta.curLine, options.titleCaseFields, options.isTitleCase);
step++;
continue;
}
// size type alignment
if (step === 1) {
meta.curLine = meta.curLine.replace(/^\**(.*?)\**$/, "$1");
this._setCleanSizeTypeAlignment(stats, meta, options);
step++;
continue;
}
// armor class
if (step === 2) {
stats.ac = ConverterUtilsMarkdown.getNoDashStarStar(meta.curLine).replace(/Armor Class/g, "").trim();
step++;
continue;
}
// hit points
if (step === 3) {
this._setCleanHp(stats, ConverterUtilsMarkdown.getNoDashStarStar(meta.curLine));
step++;
continue;
}
// speed
if (step === 4) {
this._setCleanSpeed(stats, ConverterUtilsMarkdown.getNoDashStarStar(meta.curLine), options);
step++;
continue;
}
// ability scores
if (step === 5 || step === 6 || step === 7) {
// skip the two header rows
if (meta.curLine.replace(/\s*/g, "").startsWith("|STR") || meta.curLine.replace(/\s*/g, "").startsWith("|:-")) {
step++;
continue;
}
const abilities = meta.curLine.split("|").map(it => it.trim()).filter(Boolean);
Parser.ABIL_ABVS.map((abi, j) => stats[abi] = this._tryGetStat(abilities[j]));
step++;
continue;
}
if (step === 8) {
// saves (optional)
if (~meta.curLine.indexOf("Saving Throws")) {
this._setCleanSaves(stats, ConverterUtilsMarkdown.getNoDashStarStar(meta.curLine), options);
continue;
}
// skills (optional)
if (~meta.curLine.indexOf("Skills")) {
this._setCleanSkills(stats, ConverterUtilsMarkdown.getNoDashStarStar(meta.curLine), options);
continue;
}
// damage vulnerabilities (optional)
if (~meta.curLine.indexOf("Damage Vulnerabilities")) {
this._setCleanDamageVuln(stats, ConverterUtilsMarkdown.getNoDashStarStar(meta.curLine), options);
continue;
}
// damage resistances (optional)
if (~meta.curLine.indexOf("Damage Resistance")) {
this._setCleanDamageRes(stats, ConverterUtilsMarkdown.getNoDashStarStar(meta.curLine), options);
continue;
}
// damage immunities (optional)
if (~meta.curLine.indexOf("Damage Immunities")) {
this._setCleanDamageImm(stats, ConverterUtilsMarkdown.getNoDashStarStar(meta.curLine), options);
continue;
}
// condition immunities (optional)
if (~meta.curLine.indexOf("Condition Immunities")) {
this._setCleanConditionImm(stats, ConverterUtilsMarkdown.getNoDashStarStar(meta.curLine), options);
continue;
}
// senses
if (~meta.curLine.indexOf("Senses")) {
this._setCleanSenses({stats, line: ConverterUtilsMarkdown.getNoDashStarStar(meta.curLine), cbWarning: options.cbWarning});
continue;
}
// languages
if (~meta.curLine.indexOf("Languages")) {
this._setCleanLanguages(stats, ConverterUtilsMarkdown.getNoDashStarStar(meta.curLine));
continue;
}
// CR
if (~meta.curLine.indexOf("Challenge")) {
meta.curLine = ConverterUtilsMarkdown.getNoDashStarStar(meta.curLine);
this._setCleanCr(stats, meta, {cbWarning: options.cbWarning});
continue;
}
// PB
if (~meta.curLine.indexOf("Proficiency Bonus")) {
this._setCleanPbNote(stats, ConverterUtilsMarkdown.getNoDashStarStar(meta.curLine));
continue;
}
const [nextLine1, nextLine2] = this._getNextLinesMarkdown(meta, {ixCur: i, isPrevBlank, nextPrevBlank}, 2);
// Skip past Giffyglyph builder junk
if (nextLine1 && nextLine2 && ~nextLine1.indexOf("Attacks") && ~nextLine2.indexOf("Attack DCs")) {
i = this._advanceLinesMarkdown(meta, {ixCur: i, isPrevBlank, nextPrevBlank}, 2);
}
step++;
}
const cleanedLine = ConverterUtilsMarkdown.getNoTripleHash(meta.curLine);
if (cleanedLine.toLowerCase() === "actions") {
doAddFromParsed();
step = 10;
continue;
} else if (cleanedLine.toLowerCase() === "reactions") {
doAddFromParsed();
step = 11;
continue;
} else if (cleanedLine.toLowerCase() === "bonus actions") {
doAddFromParsed();
step = 12;
continue;
} else if (cleanedLine.toLowerCase() === "legendary actions") {
doAddFromParsed();
step = 13;
continue;
} else if (cleanedLine.toLowerCase() === "mythic actions") {
doAddFromParsed();
step = 14;
continue;
}
// traits
if (step === 9) {
if (ConverterUtilsMarkdown.isInlineHeader(meta.curLine)) {
doAddTrait();
trait = {name: "", entries: []};
const [name, text] = ConverterUtilsMarkdown.getCleanTraitText(meta.curLine);
trait.name = name;
trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(text));
} else {
trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(meta.curLine));
}
}
// actions
if (step === 10) {
if (ConverterUtilsMarkdown.isInlineHeader(meta.curLine)) {
doAddAction();
trait = {name: "", entries: []};
const [name, text] = ConverterUtilsMarkdown.getCleanTraitText(meta.curLine);
trait.name = name;
trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(text));
} else {
trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(meta.curLine));
}
}
// reactions
if (step === 11) {
if (ConverterUtilsMarkdown.isInlineHeader(meta.curLine)) {
doAddReaction();
trait = {name: "", entries: []};
const [name, text] = ConverterUtilsMarkdown.getCleanTraitText(meta.curLine);
trait.name = name;
trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(text));
} else {
trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(meta.curLine));
}
}
// bonus actions
if (step === 12) {
if (ConverterUtilsMarkdown.isInlineHeader(meta.curLine)) {
doAddBonusAction();
trait = {name: "", entries: []};
const [name, text] = ConverterUtilsMarkdown.getCleanTraitText(meta.curLine);
trait.name = name;
trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(text));
} else {
trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(meta.curLine));
}
}
// legendary actions
if (step === 13) {
if (isInlineLegendaryActionItem(meta.curLine)) {
doAddLegendary();
trait = {name: "", entries: []};
const [name, text] = getCleanLegendaryActionText(meta.curLine);
trait.name = name;
trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(text));
} else if (ConverterUtilsMarkdown.isInlineHeader(meta.curLine)) {
doAddLegendary();
trait = {name: "", entries: []};
const [name, text] = ConverterUtilsMarkdown.getCleanTraitText(meta.curLine);
trait.name = name;
trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(text));
} else {
if (!trait) { // legendary action intro text
// ignore generic LA intro; the renderer will insert it
if (!meta.curLine.toLowerCase().includes("can take 3 legendary actions")) {
trait = {name: "", entries: [ConverterUtilsMarkdown.getNoLeadingSymbols(meta.curLine)]};
}
} else trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(meta.curLine));
}
}
// mythic actions
if (step === 14) {
if (isInlineLegendaryActionItem(meta.curLine)) {
doAddMythic();
trait = {name: "", entries: []};
const [name, text] = getCleanLegendaryActionText(meta.curLine);
trait.name = name;
trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(text));
} else if (ConverterUtilsMarkdown.isInlineHeader(meta.curLine)) {
doAddMythic();
trait = {name: "", entries: []};
const [name, text] = ConverterUtilsMarkdown.getCleanTraitText(meta.curLine);
trait.name = name;
trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(text));
} else {
if (!trait) { // mythic action intro text
if (meta.curLine.toLowerCase().includes("mythic trait is active")) {
stats.mythicHeader = [ConverterUtilsMarkdown.getNoLeadingSymbols(meta.curLine)];
}
} else trait.entries.push(ConverterUtilsMarkdown.getNoLeadingSymbols(meta.curLine));
}
}
}
doOutputStatblock();
}
static _stripMarkdownQuote (line) {
return line.replace(/^\s*>\s*/, "").trim();
}
static _callOnNextLinesMarkdown (meta, {ixCur, isPrevBlank, nextPrevBlank}, numLines, fn) {
const len = meta.toConvert.length;
for (let i = ixCur + 1; i < len; ++i) {
const line = meta.toConvert[i];
if (ConverterUtilsMarkdown.isBlankLine(line)) {
isPrevBlank = true;
continue;
} else nextPrevBlank = false;
const cleanLine = this._stripMarkdownQuote(line);
if (ConverterUtilsMarkdown.isBlankLine(cleanLine)) continue;
else if (
(cleanLine === "___" && isPrevBlank) // handle nicely separated blocks
|| line === "___" // handle multiple stacked blocks
) {
break;
} else if (cleanLine === "___") {
isPrevBlank = nextPrevBlank;
continue;
}
fn(cleanLine, i);
if (!--numLines) break;
}
}
static _getNextLinesMarkdown (meta, {ixCur, isPrevBlank, nextPrevBlank}, numLines) {
const out = [];
const fn = cleanLine => out.push(cleanLine);
this._callOnNextLinesMarkdown(meta, {ixCur, isPrevBlank, nextPrevBlank}, numLines, fn);
return out;
}
static _advanceLinesMarkdown (meta, {ixCur, isPrevBlank, nextPrevBlank}, numLines) {
let ixOut = ixCur + 1;
const fn = (_, i) => ixOut = i + 1;
this._callOnNextLinesMarkdown(meta, {ixCur, isPrevBlank, nextPrevBlank}, numLines, fn);
return ixOut;
}
// SHARED UTILITY FUNCTIONS ////////////////////////////////////////////////////////////////////////////////////////
static _doStatblockPostProcess (stats, isMarkdown, options) {
this._doFilterAddSpellcasting(stats, "trait", isMarkdown, options);
this._doFilterAddSpellcasting(stats, "action", isMarkdown, options);
CreatureParser._PROPS_ENTRIES
.filter(prop => stats[prop])
.forEach(prop => {
stats[prop].forEach(it => RechargeConvert.tryConvertRecharge(it, () => {}, () => options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Manual recharge tagging required for ${prop} "${it.name}"`)));
});
CreatureParser._PROPS_ENTRIES
.filter(prop => stats[prop])
.forEach(prop => SpellTag.tryRun(stats[prop]));
AcConvert.tryPostProcessAc(
stats,
(ac) => options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}AC "${ac}" requires manual conversion`),
(ac) => options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Failed to parse AC "${ac}"`),
);
TagAttack.tryTagAttacks(stats, (atk) => options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Manual attack tagging required for "${atk}"`));
TagHit.tryTagHits(stats);
TagDc.tryTagDcs(stats);
TagCondition.tryTagConditions(stats, {isTagInflicted: true});
TagCondition.tryTagConditionsSpells(
stats,
{
cbMan: (sp) => options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Spell "${sp}" could not be found during condition tagging`),
isTagInflicted: true,
},
);
TagCondition.tryTagConditionsRegionalsLairs(
stats,
{
cbMan: (legendaryGroup) => options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Legendary group "${legendaryGroup.name} :: ${legendaryGroup.source}" could not be found during condition tagging`),
isTagInflicted: true,
},
);
CreatureSpecialEquipmentTagger.tryRun(stats);
TraitActionTag.tryRun(stats);
LanguageTag.tryRun(stats);
SenseFilterTag.tryRun(stats);
SpellcastingTypeTag.tryRun(stats);
DamageTypeTag.tryRun(stats);
DamageTypeTag.tryRunSpells(stats);
DamageTypeTag.tryRunRegionalsLairs(stats);
CreatureSavingThrowTagger.tryRun(stats);
CreatureSavingThrowTagger.tryRunSpells(stats);
CreatureSavingThrowTagger.tryRunRegionalsLairs(stats);
SkillTag.tryRunProps(stats, {props: Renderer.monster.CHILD_PROPS_EXTENDED});
MiscTag.tryRun(stats);
DetectNamedCreature.tryRun(stats);
TagImmResVulnConditional.tryRun(stats);
DragonAgeTag.tryRun(stats);
AttachedItemTag.tryRun(stats);
this._doStatblockPostProcess_doCleanup(stats, options);
}
static _doFilterAddSpellcasting (stats, prop, isMarkdown, options) {
if (!stats[prop]) return;
const spellcasting = [];
stats[prop] = stats[prop].map(ent => {
if (!ent.name || !ent.name.toLowerCase().includes("spellcasting")) return ent;
const parsed = SpellcastingTraitConvert.tryParseSpellcasting(ent, {isMarkdown, cbErr: options.cbErr, displayAs: prop, actions: stats.action, reactions: stats.reaction});
if (!parsed) return ent;
spellcasting.push(parsed);
return null;
}).filter(Boolean);
if (spellcasting.length) stats.spellcasting = [...stats.spellcasting || [], ...spellcasting];
}
static _doStatblockPostProcess_doCleanup (stats, options) {
// remove any empty arrays
Object.keys(stats).forEach(k => {
if (stats[k] instanceof Array && stats[k].length === 0) {
delete stats[k];
}
});
}
static _tryConvertNumber (strNumber) {
try {
return Number(strNumber.replace(/—/g, "-"));
} catch (e) {
return strNumber;
}
}
static _tryParseType ({stats, strType}) {
strType = strType.trim().toLowerCase();
const mSwarm = /^(?<prefix>.*)swarm of (?<size>\w+) (?<type>\w+)(?: \((?<tags>[^)]+)\))?$/i.exec(strType);
if (mSwarm) {
const swarmTypeSingular = Parser.monTypeFromPlural(mSwarm[3]);
const out = { // retain any leading junk, as we'll parse it out in a later step
type: `${mSwarm.groups.prefix}${swarmTypeSingular}`,
swarmSize: mSwarm.groups.size[0].toUpperCase(),
};
if (mSwarm.groups.tags) out.tags = this._tryParseType_getTags({str: mSwarm.groups.tags});
return out;
}
const mParens = /^(.*?) (\(.*?\))\s*$/.exec(strType);
let type, tags, note;
if (mParens) {
// If there are multiple sizes, assume bracketed text is a note referring to this
if (stats.size.length > 1) {
note = mParens[2];
} else {
tags = this._tryParseType_getTags({str: mParens[2]});
}
strType = mParens[1];
}
if (/ or /.test(strType)) {
const pts = strType.split(/(?:, |,? or )/g);
type = {
choose: pts.map(it => it.trim()).filter(Boolean),
};
}
if (!type) type = strType;
if (typeof type === "string" && !tags && !note) return type;
const out = {type};
if (tags) out.tags = tags;
if (note) out.note = note;
return out;
}
static _tryParseType_getTags ({str}) {
return str.split(",").map(s => s.replace(/\(/g, "").replace(/\)/g, "").trim());
}
static _getSequentialAbilityScoreSectionLineCount (stats, meta) {
if (stats.str != null) return false; // Skip if we already have ability scores
let cntLines = 0;
const nextSixLines = [];
for (let i = meta.ixToConvert; nextSixLines.length < 6; ++i) {
const line = (meta.toConvert[i] || "").toLowerCase();
if (Parser.ABIL_ABVS.includes(line)) nextSixLines.push(line);
else break;
cntLines++;
}
return cntLines;
}
static _mutAbilityScoresFromSingleLine (stats, meta) {
const abilities = meta.toConvert[meta.ixToConvert].trim().replace(/[-\u2012\u2013\u2014]+/g, "-").split(/ ?\(([+-])?[0-9]*\) ?/g);
stats.str = this._tryConvertNumber(abilities[0]);
stats.dex = this._tryConvertNumber(abilities[2]);
stats.con = this._tryConvertNumber(abilities[4]);
stats.int = this._tryConvertNumber(abilities[6]);
stats.wis = this._tryConvertNumber(abilities[8]);
stats.cha = this._tryConvertNumber(abilities[10]);
}
static _tryGetStat (strLine) {
try {
return this._tryConvertNumber(/(\d+) ?\(.*?\)/.exec(strLine)[1]);
} catch (e) {
return 0;
}
}
// SHARED PARSING FUNCTIONS ////////////////////////////////////////////////////////////////////////////////////////
static _setCleanSizeTypeAlignment (stats, meta, options) {
this._setCleanSizeTypeAlignment_sidekick(stats, meta, options)
|| this._setCleanSizeTypeAlignment_standard(stats, meta, options);
stats.type = this._tryParseType({stats, strType: stats.type});
this._setCleanSizeTypeAlignment_postProcess(stats, meta, options);
}
static _setCleanSizeTypeAlignment_sidekick (stats, meta, options) {
const mSidekick = /^(\d+)(?:st|nd|rd|th)\s*\W+\s*level\s+(.*)$/i.exec(meta.curLine.trim());
if (!mSidekick) return false;
// sidekicks
stats.level = Number(mSidekick[1]);
stats.size = mSidekick[2].trim()[0].toUpperCase();
stats.type = mSidekick[2].split(" ").splice(1).join(" ");
}
static _setCleanSizeTypeAlignment_standard (stats, meta, options) {
const ixSwarm = / swarm /i.exec(meta.curLine)?.index;
// regular creatures
// region Size
const reSize = new RegExp(`\\b(${Object.values(Parser.SIZE_ABV_TO_FULL).join("|")})\\b`, "i");
const reSizeGlobal = new RegExp(reSize, "gi");
const tks = meta.curLine.split(reSizeGlobal);
let ixSizeLast = -1;
for (let ixSize = 0; ixSize < tks.length; ++ixSize) {
if (ixSwarm != null && tks.slice(0, ixSizeLast + 1).join("").length >= ixSwarm) break;
const tk = tks[ixSize];
if (reSize.test(tk)) {
ixSizeLast = ixSize;
(stats.size = stats.size || []).push(tk[0].toUpperCase());
}
}
stats.size.sort(SortUtil.ascSortSize);
// endregion
const tksNoSize = tks.slice(ixSizeLast + 1);
const spl = tksNoSize.join("").split(StrUtil.COMMAS_NOT_IN_PARENTHESES_REGEX);
const ptsOtherSizeOrType = spl[0].split(" ").map(it => it.trim()).filter(Boolean);
stats.type = ptsOtherSizeOrType.join(" ");
stats.alignment = (spl[1] || "").toLowerCase();
AlignmentConvert.tryConvertAlignment(stats, (ali) => options.cbWarning(`Alignment "${ali}" requires manual conversion`));
}
static _setCleanSizeTypeAlignment_postProcess (stats, meta, options) {
const validTypes = new Set(Parser.MON_TYPES);
if (!stats.type.type?.choose && (!validTypes.has(stats.type.type || stats.type))) {
// check if the last word is a creature type
const curType = stats.type.type || stats.type;
let parts = curType.split(/(\W+)/g);
parts = parts.filter(Boolean);
if (validTypes.has(parts.last())) {
const note = parts.slice(0, -1);
if (stats.type.type) {
stats.type.type = parts.last();
} else {
stats.type = parts.last();
}
stats.sizeNote = note.join("").trim();
}
}
}
static _addExtraTypeTags (stats, meta) {
if (!meta.additionalTypeTags?.length) return;
// Transform to complex form if simple
if (!stats.type.type) stats.type = {type: stats.type};
if (!stats.type.tags?.length) stats.type.tags = [];
stats.type.tags.push(...meta.additionalTypeTags);
}
static _setCleanHp (stats, line) {
const rawHp = ConvertUtil.getStatblockLineHeaderText({reStartStr: "Hit Points", line});
// split HP into average and formula
const m = /^(\d+)\s*\((.*?)\)$/.exec(rawHp.trim());
if (!m) stats.hp = {special: rawHp}; // for e.g. Avatar of Death
else if (!Renderer.dice.lang.getTree3(m[2])) stats.hp = {special: rawHp}; // for e.g. "x (see notes)"
else {
stats.hp = {
average: Number(m[1]),
formula: m[2],
};
DiceConvert.cleanHpDice(stats);
}
}
static _setCleanSpeed (stats, line, options) {
stats.speed = line;
SpeedConvert.tryConvertSpeed(stats, options.cbWarning);
}
static _setCleanSaves (stats, line, options) {
stats.save = ConvertUtil.getStatblockLineHeaderText({reStartStr: this._RE_START_SAVING_THROWS, line});
if (!stats.save?.trim()) return;
// convert to object format
const spl = stats.save.split(",").map(it => it.trim().toLowerCase()).filter(it => it);
const out = {};
spl.forEach(it => {
const m = /^(?<abil>\w+)\s*(?<sign>[-+])\s*(?<save>\d+)(?<plusPb>(?:\s+plus\s+|\s*\+\s*)PB)?$/i.exec(it);
if (m) {
out[m.groups.abil.slice(0, 3)] = `${m.groups.sign}${m.groups.save}${m.groups.plusPb ? m.groups.plusPb.replace(/\bpb\b/gi, "PB") : ""}`;
return;
}
if (/^\+PB to all$/i.test(it)) {
Parser.ABIL_ABVS.forEach(ab => out[ab] = "+PB");
return;
}
options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Save "${it}" requires manual conversion`);
});
stats.save = out;
}
static _setCleanSkills (stats, line, options) {
stats.skill = ConvertUtil.getStatblockLineHeaderText({reStartStr: this._RE_START_SKILLS, line}).toLowerCase();
const split = stats.skill.split(",").map(it => it.trim()).filter(Boolean);
const reSkill = new RegExp(`^(?<skill>${Object.keys(Parser.SKILL_TO_ATB_ABV).join("|")})\\s+(?<val>.*)$`, "i");
const newSkills = {};
try {
split.forEach(s => {
const m = reSkill.exec(s);
if (!m) {
options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Skill "${s}" requires manual conversion`);
return;
}
newSkills[m.groups.skill] = m.groups.val.replace(/\b\+?pb\b/g, "PB");
});
stats.skill = newSkills;
if (stats.skill[""]) delete stats.skill[""]; // remove empty properties
} catch (ignored) {
setTimeout(() => { throw ignored; });
}
}
static _setCleanDamageVuln (stats, line, options) {
stats.vulnerable = ConvertUtil.getStatblockLineHeaderText({reStartStr: this._RE_START_DAMAGE_VULN, line});
stats.vulnerable = CreatureDamageVulnerabilityConverter.getParsed(stats.vulnerable, options);
if (stats.vulnerable == null) delete stats.vulnerable;
}
static _setCleanDamageRes (stats, line, options) {
stats.resist = ConvertUtil.getStatblockLineHeaderText({reStartStr: this._RE_START_DAMAGE_RES, line});
stats.resist = CreatureDamageResistanceConverter.getParsed(stats.resist, options);
if (stats.resist == null) delete stats.resist;
}
static _setCleanDamageImm (stats, line, options) {
stats.immune = ConvertUtil.getStatblockLineHeaderText({reStartStr: this._RE_START_DAMAGE_IMM, line});
stats.immune = CreatureDamageImmunityConverter.getParsed(stats.immune, options);
if (stats.immune == null) delete stats.immune;
}
static _setCleanConditionImm (stats, line, options) {
stats.conditionImmune = ConvertUtil.getStatblockLineHeaderText({reStartStr: this._RE_START_CONDITION_IMM, line});
stats.conditionImmune = CreatureConditionImmunityConverter.getParsed(stats.conditionImmune, options);
if (stats.conditionImmune == null) delete stats.conditionImmune;
}
static _setCleanSenses ({stats, line, cbWarning}) {
const senses = ConvertUtil.getStatblockLineHeaderText({reStartStr: this._RE_START_SENSES, line});
const tempSenses = [];
senses
.split(StrUtil.COMMA_SPACE_NOT_IN_PARENTHESES_REGEX)
.forEach(pt => {
pt = pt.trim();
if (!pt) return;
if (!pt.toLowerCase().includes("passive perception")) return tempSenses.push(pt.toLowerCase());
let ptPassive = pt.split(/passive perception/i)[1].trim();
if (!isNaN(ptPassive)) return stats.passive = this._tryConvertNumber(ptPassive);
if (
!/^\d+\s+(?:plus|\+)\s+PB$/i.test(ptPassive)
&& !/^\d+\s+(?:plus|\+)\s+\(PB\s*(?:×|\*|x|times)\s*\d+\)$/i.test(ptPassive)
) return cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Passive perception "${ptPassive}" requires manual conversion`);
// Handle e.g. "10 plus PB"
stats.passive = ptPassive;
});
if (tempSenses.length) stats.senses = tempSenses;
else delete stats.senses;
}
static _setCleanLanguages (stats, line) {
stats.languages = ConvertUtil.getStatblockLineHeaderText({reStartStr: this._RE_START_LANGUAGES, line});
if (stats.languages && /^([-–‒—]|\\u201\d)+$/.exec(stats.languages.trim())) delete stats.languages;
else {
stats.languages = stats.languages
// Clean caps words
.split(/(\W)/g)
.map(s => {
return s
.replace(/Telepathy/g, "telepathy")
.replace(/All/g, "all")
.replace(/Understands/g, "understands")
.replace(/Cant/g, "cant")
.replace(/Can/g, "can");
})
.join("")
.split(StrUtil.COMMA_SPACE_NOT_IN_PARENTHESES_REGEX);
}
}
static _setCleanCr (stats, meta, {cbWarning, header = "Challenge"} = {}) {
let line = ConvertUtil.getStatblockLineHeaderText({reStartStr: header, line: meta.curLine});
let xp = null;
line = line
.replace(/\((?<amount>[0-9,]+)\s*XP\)/i, (...m) => {
const amountRaw = m.last().amount.replace(/,/, "");
if (isNaN(amountRaw)) return m[0];
xp = Number(amountRaw);
return "";
})
.trim();
if (!line) return;
if (/^[-\u2012-\u2014]$/.test(line)) return;
const reTags = new RegExp(`\\b(?<tag>${Object.keys(this._BREW_CR_LINE_TAGS).map(it => it.escapeRegexp()).join("|")})\\b`, "gi");
line = line
.replace(reTags, (...m) => {
meta.addAdditionalTypeTag(this._BREW_CR_LINE_TAGS[m.last().tag.toLowerCase()]);
return "";
})
.trim();
if (!line) return;
if (/^[⅛¼½]$/.test(line)) {
line = Parser.numberToCr(Parser.vulgarToNumber(line));
}
if (!/^(\d+\/\d+|\d+)$/.test(line)) {
cbWarning(`${stats.name ? `(${stats.name}) ` : ""}CR requires manual conversion "${line}"`);
return;
}
const cr = line;
if (xp != null && Parser.crToXpNumber(cr) !== xp) {
stats.cr = {
cr,
xp,
};
return;
}
stats.cr = cr;
}
static _BREW_CR_LINE_TAGS = {
// region MCDM
"ambusher": "Ambusher",
"artillery": "Artillery",
"brute": "Brute",
"companion": "Companion",
"controller": "Controller",
"leader": "Leader",
"minion": "Minion",
"retainer": "Retainer",
"skirmisher": "Skirmisher",
"soldier": "Soldier",
"solo": "Solo",
"support": "Support",
// endregion
};
static _setCleanPbNote (stats, line) {
stats.pbNote = ConvertUtil.getStatblockLineHeaderText({reStartStr: this._RE_START_PROF_BONUS, line});
if (stats.pbNote && !isNaN(stats.pbNote) && Parser.crToPb(stats.cr) === Number(stats.pbNote)) delete stats.pbNote;
}
// region SHARED HOMEBREW PARSING FUNCTIONS /////////////////////////////////////////////////////////////////////////
static _RE_BREW_RESOURCE_SOULS = /^Souls (?<value>\d+) \((?<formula>[^)]+)\)$/;
static _brew_setResourceSouls (stats, meta, options) {
const m = this._RE_BREW_RESOURCE_SOULS.exec(meta.curLine);
(stats.resource = stats.resource || [])
.push({
name: "Souls",
value: Number(m.groups.value),
formula: m.groups.formula,
});
}
// endregion
}
CreatureParser._PROPS_ENTRIES = Renderer.monster.CHILD_PROPS_EXTENDED.filter(it => it !== "spellcasting");
globalThis.CreatureParser = CreatureParser;