mirror of
https://github.com/Kornstalx/5etools-mirror-2.github.io.git
synced 2025-10-28 20:45:35 -05:00
519 lines
18 KiB
JavaScript
519 lines
18 KiB
JavaScript
"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`)});
|
|
ItemMiscTag.tryRun(stats);
|
|
BonusTag.tryRun(stats);
|
|
ItemOtherTagsTag.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 = /^(?<ptPre>weapon|staff|rod) \((?<ptParens>[^)]+)\)$/i.exec(part);
|
|
if (mBaseWeapon) {
|
|
if (mBaseWeapon.groups.ptPre.toLowerCase() === "staff") stats.staff = true;
|
|
if (mBaseWeapon.groups.ptPre.toLowerCase() === "rod") {
|
|
if (stats.type) {
|
|
throw new Error(`Multiple types! "${stats.type}" -> "${mBaseWeapon.groups.ptParens}"`);
|
|
}
|
|
stats.type = "RD";
|
|
}
|
|
|
|
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 \((?<type>[^)]+)\)$/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 \((?<category>[^)]+)\)$/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;
|