"use strict"; class ItemParser extends BaseParser { static init (itemData, classData) { ItemParser._ALL_ITEMS = itemData; ItemParser._ALL_CLASSES = classData.class; } static getItem (itemName) { itemName = itemName.trim().toLowerCase(); itemName = ItemParser._MAPPED_ITEM_NAMES[itemName] || itemName; const matches = ItemParser._ALL_ITEMS.filter(it => it.name.toLowerCase() === itemName); if (matches.length > 1) throw new Error(`Multiple items found with name "${itemName}"`); if (matches.length) return matches[0]; return null; } /** * Parses items 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._getCleanInput(inText, options) .split("\n") .filter(it => it && it.trim()); const item = {}; item.source = options.source; // for the user to fill out item.page = options.page; // FIXME this duplicates functionality in converterutils let prevLine = null; let curLine = null; let i; for (i = 0; i < toConvert.length; i++) { prevLine = curLine; curLine = toConvert[i].trim(); if (curLine === "") continue; // name of item if (i === 0) { item.name = this._getAsTitle("name", curLine, options.titleCaseFields, options.isTitleCase); continue; } // tagline if (i === 1) { this._setCleanTaglineInfo(item, curLine, options); continue; } const ptrI = {_: i}; item.entries = EntryConvert.coalesceLines( ptrI, toConvert, ); i = ptrI._; } const statsOut = this._getFinalState(item, options); options.cbOutput(statsOut, options.isAppend); } static _getFinalState (item, options) { if (!item.entries.length) delete item.entries; else this._setWeight(item, options); if (item.staff) this._setQuarterstaffStats(item, options); this._mutRemoveBaseItemProps(item, options); this._doItemPostProcess(item, options); this._setCleanTaglineInfo_handleGenericType(item, options); this._doVariantPostProcess(item, options); return PropOrder.getOrdered(item, item.__prop || "item"); } // SHARED UTILITY FUNCTIONS //////////////////////////////////////////////////////////////////////////////////////// static _doItemPostProcess (stats, options) { TagCondition.tryTagConditions(stats); ArtifactPropertiesTag.tryRun(stats); if (stats.entries) { stats.entries = stats.entries.map(it => DiceConvert.getTaggedEntry(it)); EntryConvert.tryRun(stats, "entries"); stats.entries = SkillTag.tryRun(stats.entries); stats.entries = ActionTag.tryRun(stats.entries); stats.entries = SenseTag.tryRun(stats.entries); if (/is a (tiny|small|medium|large|huge|gargantuan) object/.test(JSON.stringify(stats.entries))) options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Item may be an object!`); } this._doItemPostProcess_addTags(stats, options); BasicTextClean.tryRun(stats); } static _doItemPostProcess_addTags (stats, options) { const manName = stats.name ? `(${stats.name}) ` : ""; ChargeTag.tryRun(stats); RechargeTypeTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Recharge type requires manual conversion`)}); RechargeAmountTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Recharge amount requires manual conversion`)}); BonusTag.tryRun(stats); ItemMiscTag.tryRun(stats); ItemSpellcastingFocusTag.tryRun(stats); DamageResistanceTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Damage resistance tagging requires manual conversion`)}); DamageImmunityTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Damage immunity tagging requires manual conversion`)}); DamageVulnerabilityTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Damage vulnerability tagging requires manual conversion`)}); ConditionImmunityTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Condition immunity tagging requires manual conversion`)}); ReqAttuneTagTag.tryRun(stats, {cbMan: () => options.cbWarning(`${manName}Attunement requirement tagging requires manual conversion`)}); TagJsons.mutTagObject(stats, {keySet: new Set(["entries"]), isOptimistic: true}); AttachedSpellTag.tryRun(stats); // TODO // - tag damage type? // - tag ability score adjustments } static _doVariantPostProcess (stats, options) { if (!stats.inherits) return; BonusTag.tryRun(stats, {isVariant: true}); } // SHARED PARSING FUNCTIONS //////////////////////////////////////////////////////////////////////////////////////// static _setCleanTaglineInfo (stats, curLine, options) { const parts = curLine.trim().split(StrUtil.COMMAS_NOT_IN_PARENTHESES_REGEX).map(it => it.trim()).filter(Boolean); const handlePartRarity = (rarity) => { rarity = rarity.trim().toLowerCase(); switch (rarity) { case "common": stats.rarity = rarity; return true; case "uncommon": stats.rarity = rarity; return true; case "rare": stats.rarity = rarity; return true; case "very rare": stats.rarity = rarity; return true; case "legendary": stats.rarity = rarity; return true; case "artifact": stats.rarity = rarity; return true; case "varies": case "rarity varies": { stats.rarity = "varies"; stats.__prop = "itemGroup"; return true; } case "unknown rarity": { // Make a best-guess as to whether or not the item is magical if (stats.wondrous || stats.staff || stats.type === "P" || stats.type === "RG" || stats.type === "RD" || stats.type === "WD" || stats.type === "SC" || stats.type === "MR") stats.rarity = "unknown (magic)"; else stats.rarity = "unknown"; return true; } } return false; }; let baseItem = null; for (let i = 0; i < parts.length; ++i) { let part = parts[i]; const partLower = part.toLowerCase(); // region wondrous/item type/staff/etc. switch (partLower) { case "wondrous item": stats.wondrous = true; continue; case "wondrous item (tattoo)": stats.wondrous = true; stats.tattoo = true; continue; case "potion": stats.type = "P"; continue; case "ring": stats.type = "RG"; continue; case "rod": stats.type = "RD"; continue; case "wand": stats.type = "WD"; continue; case "ammunition": stats.type = "A"; continue; case "staff": stats.staff = true; continue; case "master rune": stats.type = "MR"; continue; case "scroll": stats.type = "SC"; continue; } // endregion // region rarity/attunement // Check if the part is an exact match for a rarity string const isHandledRarity = handlePartRarity(partLower); if (isHandledRarity) continue; if (partLower.includes("(requires attunement")) { const [rarityRaw, ...rest] = part.split("("); const rarity = rarityRaw.trim().toLowerCase(); const isHandledRarity = rarity ? handlePartRarity(rarity) : true; if (!isHandledRarity) options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Rarity "${rarityRaw}" requires manual conversion`); let attunement = rest.join("("); attunement = attunement.replace(/^requires attunement/i, "").replace(/\)/, "").trim(); if (!attunement) { stats.reqAttune = true; } else { stats.reqAttune = attunement.toLowerCase(); } // if specific attunement is required, absorb any further parts which are class names if (/(^| )by a /i.test(stats.reqAttune)) { for (let ii = i + 1; ii < parts.length; ++ii) { const nxtPart = parts[ii] .trim() .replace(/^(?:or|and) /, "") .trim() .replace(/\)$/, "") .trim() .toLowerCase(); const isClassName = ItemParser._ALL_CLASSES.some(cls => cls.name.toLowerCase() === nxtPart); if (isClassName) { stats.reqAttune += `, ${parts[ii].replace(/\)$/, "")}`; i = ii; } } } continue; } // endregion // region weapon/armor const isGenericWeaponArmor = this._setCleanTaglineInfo_mutIsGenericWeaponArmor({stats, part, partLower, options}); if (isGenericWeaponArmor) continue; const mBaseWeapon = /^(?weapon|staff) \((?[^)]+)\)$/i.exec(part); if (mBaseWeapon) { if (mBaseWeapon.groups.ptPre.toLowerCase() === "staff") stats.staff = true; if (mBaseWeapon.groups.ptParens === "spear or javelin") { (stats.requires ||= []).push(...this._setCleanTaglineInfo_getGenericRequires({stats, str: "spear", options})); stats.__genericType = true; continue; } const ptsParens = ConverterUtils.splitConjunct(mBaseWeapon.groups.ptParens); const baseItems = ptsParens.map(pt => ItemParser.getItem(pt)); if (baseItems.some(it => it == null) || !baseItems.length) throw new Error(`Could not find base item(s) for "${mBaseWeapon.groups.ptParens}"`); if (baseItems.length === 1) { baseItem = baseItems[0]; continue; } throw new Error(`Multiple base item(s) for "${mBaseWeapon.groups.ptParens}"`); } const mBaseArmor = /^armou?r \((?[^)]+)\)$/i.exec(part); if (mBaseArmor) { if (this._setCleanTaglineInfo_isMutAnyArmor(stats, mBaseArmor)) { stats.__genericType = true; continue; } baseItem = this._setCleanTaglineInfo_getArmorBaseItem(mBaseArmor.groups.type); if (!baseItem) throw new Error(`Could not find base item "${mBaseArmor.groups.type}"`); continue; } // endregion // Warn about any unprocessed input options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Tagline part "${part}" requires manual conversion`); } this._setCleanTaglineInfo_handleBaseItem(stats, baseItem, options); } static _GENERIC_CATEGORY_TO_PROP = { "sword": "sword", "polearm": "polearm", }; static _setCleanTaglineInfo_mutIsGenericWeaponArmor ({stats, part, partLower, options}) { if (partLower === "weapon" || partLower === "weapon (any)") { (stats.requires ||= []).push(...this._setCleanTaglineInfo_getGenericRequires({stats, str: "weapon", options})); stats.__genericType = true; return true; } if (/^armou?r(?: \(any\))?$/.test(partLower)) { (stats.requires ||= []).push(...this._setCleanTaglineInfo_getGenericRequires({stats, str: "armor", options})); stats.__genericType = true; return true; } const mWeaponAnyX = /^weapon \(any ([^)]+)\)$/i.exec(part); if (mWeaponAnyX) { (stats.requires ||= []).push(...this._setCleanTaglineInfo_getGenericRequires({stats, str: mWeaponAnyX[1].trim(), options})); if (mWeaponAnyX[1].trim().toLowerCase() === "ammunition") stats.ammo = true; stats.__genericType = true; return true; } const mWeaponCategory = /^weapon \((?[^)]+)\)$/i.exec(part); if (!mWeaponCategory) return false; const ptsCategory = ConverterUtils.splitConjunct(mWeaponCategory.groups.category); if (!ptsCategory.length) return false; const strs = ptsCategory .map(pt => this._GENERIC_CATEGORY_TO_PROP[pt.toLowerCase()]); if (strs.some(it => it == null)) return false; (stats.requires ||= []).push(...strs.flatMap(str => this._setCleanTaglineInfo_getGenericRequires({stats, str, options}))); stats.__genericType = true; return true; } static _setCleanTaglineInfo_getArmorBaseItem (name) { let baseItem = ItemParser.getItem(name); if (!baseItem) baseItem = ItemParser.getItem(`${name} armor`); // "armor (plate)" -> "plate armor" return baseItem; } static _setCleanTaglineInfo_getProcArmorPart ({pt}) { switch (pt) { case "light": case "light armor": return {"type": "LA"}; case "medium": case "medium armor": return {"type": "MA"}; case "heavy": case "heavy armor": return {"type": "HA"}; default: { const baseItem = this._setCleanTaglineInfo_getArmorBaseItem(pt); if (!baseItem) throw new Error(`Could not find base item "${pt}"`); return {name: baseItem.name}; } } } static _setCleanTaglineInfo_isMutAnyArmor (stats, mBaseArmor) { if (/^any /i.test(mBaseArmor.groups.type)) { const ptAny = mBaseArmor.groups.type.replace(/^any /i, ""); const [ptInclude, ptExclude] = ptAny.split(/\bexcept\b/i).map(it => it.trim()).filter(Boolean); if (ptInclude) { stats.requires = [ ...(stats.requires || []), ...ptInclude.split(/\b(?:or|,)\b/g).map(it => it.trim()).filter(Boolean).map(it => this._setCleanTaglineInfo_getProcArmorPart({pt: it})), ]; } if (ptExclude) { Object.assign( stats.excludes = stats.excludes || {}, ptExclude.split(/\b(?:or|,)\b/g).map(it => it.trim()).filter(Boolean).mergeMap(it => this._setCleanTaglineInfo_getProcArmorPart({pt: it})), ); } return true; } return ConverterUtils.splitConjunct(mBaseArmor.groups.type) .every(ptType => { if (!/^(?:light|medium|heavy)$/i.test(ptType)) return false; stats.requires = [ ...(stats.requires || []), this._setCleanTaglineInfo_getProcArmorPart({pt: ptType}), ]; return true; }); } static _setCleanTaglineInfo_handleBaseItem (stats, baseItem, options) { if (!baseItem) return; const blocklistedProps = new Set([ "source", "srd", "basicRules", "page", ]); // Apply base item stats only if there's no existing data Object.entries(baseItem) .filter(([k]) => stats[k] === undefined && !k.startsWith("_") && !blocklistedProps.has(k)) .forEach(([k, v]) => stats[k] = v); // Clean unwanted base properties delete stats.armor; delete stats.value; stats.baseItem = `${baseItem.name.toLowerCase()}${baseItem.source === Parser.SRC_DMG ? "" : `|${baseItem.source}`}`; } static _setCleanTaglineInfo_getGenericRequires ({stats, str, options}) { switch (str.toLowerCase()) { case "weapon": return [{"weapon": true}]; case "sword": return [{"sword": true}]; case "axe": return [{"axe": true}]; case "armor": return [{"armor": true}]; case "bow": return [{"bow": true}]; case "crossbow": return [{"crossbow": true}]; case "bow or crossbow": return [{"bow": true}, {"crossbow": true}]; case "spear": return [{"spear": true}]; case "polearm": return [{"polearm": true}]; case "weapon that deals bludgeoning damage": case "bludgeoning": return [{"dmgType": "B"}]; case "piercing": return [{"dmgType": "P"}]; case "slashing": return [{"dmgType": "S"}]; case "ammunition": return [{"type": "A"}, {"type": "AF"}]; case "arrow": return [{"arrow": true}]; case "bolt": return [{"bolt": true}]; case "arrow or bolt": return [{"arrow": true}, {"bolt": true}]; case "melee": return [{"type": "M"}]; case "martial weapon": return [{"weaponCategory": "martial"}]; case "melee bludgeoning weapon": return [{"type": "M", "dmgType": "B"}]; default: { options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Tagline part "${str}" requires manual conversion`); return [{[str.toCamelCase()]: true}]; } } } static _RE_CATEGORIES_PREFIX_SUFFIX = /(?:weapon|blade|armor|sword|polearm|bow|crossbow|axe|ammunition|arrows?|bolts?)/; static _RE_CATEGORIES_PREFIX = new RegExp(`^${this._RE_CATEGORIES_PREFIX_SUFFIX.source} `, "i"); static _RE_CATEGORIES_SUFFIX = new RegExp(` ${this._RE_CATEGORIES_PREFIX_SUFFIX.source}$`, "i"); static _setCleanTaglineInfo_handleGenericType (stats, options) { if (!stats.__genericType) return; delete stats.__genericType; let prefixSuffixName = stats.name; prefixSuffixName = prefixSuffixName .replace(this._RE_CATEGORIES_PREFIX, "") .replace(this._RE_CATEGORIES_SUFFIX, ""); const isSuffix = /^\s*of /i.test(prefixSuffixName); stats.inherits = MiscUtil.copy(stats); // Clean/move inherit props into inherits object ["name", "requires", "excludes", "ammo"].forEach(prop => delete stats.inherits[prop]); // maintain some props on base object Object.keys(stats.inherits).forEach(k => delete stats[k]); if (isSuffix) stats.inherits.nameSuffix = ` ${prefixSuffixName.trim()}`; else stats.inherits.namePrefix = `${prefixSuffixName.trim()} `; stats.__prop = "magicvariant"; stats.type = "GV"; } static _setWeight (stats, options) { const strEntries = JSON.stringify(stats.entries); strEntries.replace(/weighs ([a-zA-Z0-9,]+) (pounds?|lbs?\.|tons?)/, (...m) => { if (m[2].toLowerCase().trim().startsWith("ton")) throw new Error(`Handling for tonnage is unimplemented!`); const noCommas = m[1].replace(/,/g, ""); if (!isNaN(noCommas)) stats.weight = Number(noCommas); const fromText = Parser.textToNumber(m[1]); if (!isNaN(fromText)) stats.weight = fromText; if (!stats.weight) options.cbWarning(`${stats.name ? `(${stats.name}) ` : ""}Weight "${m[1]}" requires manual conversion`); }); } static _setQuarterstaffStats (stats) { const cpyStatsQuarterstaff = MiscUtil.copy(ItemParser._ALL_ITEMS.find(it => it.name === "Quarterstaff" && it.source === Parser.SRC_PHB)); // remove unwanted properties delete cpyStatsQuarterstaff.name; delete cpyStatsQuarterstaff.source; delete cpyStatsQuarterstaff.page; delete cpyStatsQuarterstaff.rarity; delete cpyStatsQuarterstaff.value; delete cpyStatsQuarterstaff.srd; delete cpyStatsQuarterstaff.basicRules; Object.entries(cpyStatsQuarterstaff) .filter(([k]) => !k.startsWith("_")) .forEach(([k, v]) => { if (stats[k] == null) stats[k] = v; }); } static _mutRemoveBaseItemProps (stats) { if (stats.__prop === "baseitem") return; // region tags found only on basic items delete stats.armor; delete stats.axe; delete stats.bow; delete stats.crossbow; delete stats.dagger; delete stats.mace; delete stats.net; delete stats.spear; delete stats.sword; delete stats.weapon; delete stats.hammer; // endregion } } ItemParser._ALL_ITEMS = null; ItemParser._ALL_CLASSES = null; ItemParser._MAPPED_ITEM_NAMES = { "studded leather": "studded leather armor", "leather": "leather armor", "scale": "scale mail", }; globalThis.ItemParser = ItemParser;