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

512 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`)});
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 = /^(?<ptPre>weapon|staff) \((?<ptParens>[^)]+)\)$/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 \((?<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;