Files
Exiled-Exchange-2/renderer/src/parser/Parser.ts
2025-09-16 21:15:38 -05:00

1704 lines
46 KiB
TypeScript

import { Result, ok, err } from "neverthrow";
import {
CLIENT_STRINGS as _$,
CLIENT_STRINGS_REF as _$REF,
ITEM_BY_REF,
STAT_BY_MATCH_STR,
BaseType,
ITEM_BY_TRANSLATED,
} from "@/assets/data";
import { ModifierType, StatCalculated, sumStatsByModType } from "./modifiers";
import {
linesToStatStrings,
tryParseTranslation,
getRollOrMinmaxAvg,
} from "./stat-translations";
import { ItemCategory } from "./meta";
import {
IncursionRoom,
ParsedItem,
ItemInfluence,
ItemRarity,
itemIsModifiable,
} from "./ParsedItem";
import { magicBasetype } from "./magic-name";
import {
// isModInfoLine,
// groupLinesByMod,
// parseModInfoLine,
parseModType,
ModifierInfo,
ParsedModifier,
ENCHANT_LINE,
SCOURGE_LINE,
IMPLICIT_LINE,
RUNE_LINE,
isModInfoLine,
groupLinesByMod,
parseModInfoLine,
ADDED_RUNE_LINE,
} from "./advanced-mod-desc";
import { calcPropPercentile, QUALITY_STATS } from "./calc-q20";
type SectionParseResult =
| "SECTION_PARSED"
| "SECTION_SKIPPED"
| "PARSER_SKIPPED";
type ParserFn = (section: string[], item: ParserState) => SectionParseResult;
type VirtualParserFn = (item: ParserState) => Result<never, string> | void;
interface ParserState extends ParsedItem {
name: string;
baseType: string | undefined;
infoVariants: BaseType[];
}
const parsers: Array<ParserFn | { virtual: VirtualParserFn }> = [
parseUnidentified,
{ virtual: parseSuperior },
{ virtual: parseExceptional },
parseSynthesised,
parseCategoryByHelpText,
{ virtual: normalizeName },
parseVaalGemName,
{ virtual: findInDatabase },
// -----------
parseItemLevel,
parseRequirements,
parseTalismanTier,
parseGem,
parseArmour,
parseWeapon,
parseCaster,
parseFlask,
parseJewelery,
parseCharmSlots,
parseSpirit,
parsePriceNote,
parseUnneededText,
parseFracturedText,
parseTimelostRadius,
parseStackSize,
parseCorrupted,
parseFoil,
parseInfluence,
parseMap,
parseWaystone,
parseSockets,
parseRuneSockets,
parseHeistBlueprint,
parseAreaLevel,
parseAtzoatlRooms,
parseMirroredTablet,
parseFilledCoffin,
parseMirrored,
parseSanctified,
parseSentinelCharge,
parseLogbookArea,
parseLogbookArea,
parseLogbookArea,
parseModifiers, // enchant
parseModifiers, // rune
parseModifiers, // implicit
parseModifiers, // grant skill
parseModifiers, // explicit
// catch enchant and rune since they don't have curlys rn
parseModifiersPoe2, // enchant
parseModifiersPoe2, // rune
// HACK: catch implicit and explicit for controllers
parseModifiersPoe2, // implicit
parseModifiersPoe2, // grant skill
parseModifiersPoe2, // explicit
{ virtual: transformToLegacyModifiers },
{ virtual: parseFractured },
{ virtual: parseBlightedMap },
{ virtual: applyRuneSockets },
{ virtual: applyElementalAdded },
{ virtual: pickCorrectVariant },
{ virtual: calcBasePercentile },
];
export function parseClipboard(clipboard: string): Result<ParsedItem, string> {
try {
let sections = itemTextToSections(clipboard);
if (sections[0][2] === _$.CANNOT_USE_ITEM) {
sections[0].pop(); // remove CANNOT_USE_ITEM line
sections[1].unshift(...sections[0]); // prepend item class & rarity into second section
sections.shift(); // remove first section where CANNOT_USE_ITEM line was
}
const parsed = parseNamePlate(sections[0]);
if (!parsed.isOk()) return parsed;
sections.shift();
parsed.value.rawText = clipboard;
// each section can be parsed at most by one parser
// and each parser can only be used to parse one section
for (const parser of parsers) {
if (typeof parser === "object") {
const error = parser.virtual(parsed.value);
if (error) return error;
continue;
}
for (const section of sections) {
const result = parser(section, parsed.value);
if (result === "SECTION_PARSED") {
sections = sections.filter((s) => s !== section);
break;
} else if (result === "PARSER_SKIPPED") {
break;
}
}
}
return Object.freeze(parsed);
} catch (e) {
console.log(e);
return err("item.parse_error");
}
}
function itemTextToSections(text: string) {
const lines = text.split(/\r?\n/);
if (lines[lines.length - 1] === "") {
lines.pop();
}
const sections: string[][] = [[]];
lines.reduce((section, line) => {
if (line !== "--------") {
section.push(line);
return section;
} else {
const section: string[] = [];
sections.push(section);
return section;
}
}, sections[0]);
return sections.filter((section) => section.length);
}
function normalizeName(item: ParserState) {
if (item.rarity === ItemRarity.Magic) {
const baseType = magicBasetype(item.name);
if (baseType) {
item.name = baseType;
}
}
if (item.rarity === ItemRarity.Normal || item.rarity === ItemRarity.Rare) {
if (item.baseType) {
if (_$.MAP_BLIGHTED.test(item.baseType)) {
item.baseType = _$REF.MAP_BLIGHTED.exec(item.baseType)![1];
} else if (_$.MAP_BLIGHT_RAVAGED.test(item.baseType)) {
item.baseType = _$REF.MAP_BLIGHT_RAVAGED.exec(item.baseType)![1];
}
} else {
if (_$.MAP_BLIGHTED.test(item.name)) {
item.name = _$REF.MAP_BLIGHTED.exec(item.name)![1];
} else if (_$.MAP_BLIGHT_RAVAGED.test(item.name)) {
item.name = _$REF.MAP_BLIGHT_RAVAGED.exec(item.name)![1];
}
}
}
if (item.category === ItemCategory.MetamorphSample) {
if (_$.METAMORPH_BRAIN.test(item.name)) {
item.name = "Metamorph Brain";
} else if (_$.METAMORPH_EYE.test(item.name)) {
item.name = "Metamorph Eye";
} else if (_$.METAMORPH_LUNG.test(item.name)) {
item.name = "Metamorph Lung";
} else if (_$.METAMORPH_HEART.test(item.name)) {
item.name = "Metamorph Heart";
} else if (_$.METAMORPH_LIVER.test(item.name)) {
item.name = "Metamorph Liver";
}
}
}
function findInDatabase(item: ParserState) {
let info: BaseType[] | undefined;
if (item.category === ItemCategory.DivinationCard) {
info = ITEM_BY_REF("DIVINATION_CARD", item.name);
} else if (item.category === ItemCategory.CapturedBeast) {
info = ITEM_BY_REF("CAPTURED_BEAST", item.baseType ?? item.name);
} else if (item.category === ItemCategory.Gem) {
info = ITEM_BY_REF("GEM", item.name);
} else if (item.category === ItemCategory.MetamorphSample) {
info = ITEM_BY_REF("ITEM", item.name);
} else if (item.category === ItemCategory.Voidstone) {
info = ITEM_BY_REF("ITEM", "Charged Compass");
} else if (item.rarity === ItemRarity.Unique && !item.isUnidentified) {
info = ITEM_BY_REF("UNIQUE", item.name);
} else {
info = ITEM_BY_REF("ITEM", item.baseType ?? item.name);
}
if (!info?.length) {
// HACK: controller support while poe2 doesn't have advanced copy for controllers
if (item.category === ItemCategory.DivinationCard) {
info = ITEM_BY_TRANSLATED("DIVINATION_CARD", item.name);
} else if (item.category === ItemCategory.CapturedBeast) {
info = ITEM_BY_TRANSLATED("CAPTURED_BEAST", item.baseType ?? item.name);
} else if (item.category === ItemCategory.Gem) {
info = ITEM_BY_TRANSLATED("GEM", item.name);
} else if (item.category === ItemCategory.MetamorphSample) {
info = ITEM_BY_TRANSLATED("ITEM", item.name);
} else if (item.category === ItemCategory.Voidstone) {
info = ITEM_BY_TRANSLATED("ITEM", "Charged Compass");
} else if (item.rarity === ItemRarity.Unique && !item.isUnidentified) {
info = ITEM_BY_TRANSLATED("UNIQUE", item.name);
} else {
info = ITEM_BY_TRANSLATED("ITEM", item.baseType ?? item.name);
}
if (!info?.length) {
return err("item.unknown");
}
}
if (info[0].unique) {
const uniqueInfo = info.filter(
(info) => info.unique!.base === item.baseType,
);
if (uniqueInfo?.length) {
info = uniqueInfo;
} else if (item.baseType) {
const baseInfo = ITEM_BY_TRANSLATED("ITEM", item.baseType);
if (baseInfo?.length) {
info = info.filter((info) => info.unique!.base === baseInfo[0].refName);
}
}
}
item.infoVariants = info;
// choose 1st variant, correct one will be picked at the end of parsing
item.info = info[0];
// same for every variant
if (!item.category) {
if (item.info.craftable) {
item.category = item.info.craftable.category;
} else if (item.info.unique) {
item.category = ITEM_BY_REF(
"ITEM",
item.info.unique.base,
)![0].craftable!.category;
}
}
// Override charm since its flask in trade
if (item.category === ItemCategory.Charm) {
item.category = ItemCategory.Flask;
}
}
function parseMap(section: string[], item: ParsedItem) {
if (section[0].startsWith(_$.MAP_TIER)) {
item.mapTier = Number(section[0].slice(_$.MAP_TIER.length));
return "SECTION_PARSED";
}
return "SECTION_SKIPPED";
}
function parseWaystone(section: string[], item: ParsedItem) {
if (section[0].startsWith(_$.WAYSTONE_TIER)) {
item.mapTier = Number(section[0].slice(_$.WAYSTONE_TIER.length));
return "SECTION_PARSED";
}
return "SECTION_SKIPPED";
}
function parseBlightedMap(item: ParsedItem) {
if (item.category !== ItemCategory.Map) return;
const calc = item.statsByType.find(
(calc) =>
calc.type === ModifierType.Implicit &&
calc.stat.ref.startsWith("Area is infested with Fungal Growths"),
);
if (calc !== undefined) {
if (calc.sources[0].contributes!.value === 9) {
item.mapBlighted = "Blight-ravaged";
item.info.icon = ITEM_BY_REF("ITEM", "Blight-ravaged Map")![0].icon;
} else {
item.mapBlighted = "Blighted";
item.info.icon = ITEM_BY_REF("ITEM", "Blighted Map")![0].icon;
}
}
}
function parseFractured(item: ParserState) {
if (item.newMods.some((mod) => mod.info.type === ModifierType.Fractured)) {
item.isFractured = true;
}
}
function pickCorrectVariant(item: ParserState) {
if (!item.info.disc) return;
for (const variant of item.infoVariants) {
const cond = variant.disc!;
if (cond.propAR && !item.armourAR) continue;
if (cond.propEV && !item.armourEV) continue;
if (cond.propES && !item.armourES) continue;
if (cond.mapTier === "W" && !(item.mapTier! <= 5)) continue;
if (cond.mapTier === "Y" && !(item.mapTier! >= 6 && item.mapTier! <= 10))
continue;
if (cond.mapTier === "R" && !(item.mapTier! >= 11)) continue;
if (
cond.hasImplicit &&
!item.statsByType.some(
(calc) =>
calc.type === ModifierType.Implicit &&
calc.stat.ref === cond.hasImplicit!.ref,
)
)
continue;
if (
cond.hasExplicit &&
!item.statsByType.some(
(calc) =>
calc.type === ModifierType.Explicit &&
calc.stat.ref === cond.hasExplicit!.ref,
)
)
continue;
if (cond.sectionText && !item.rawText.includes(cond.sectionText)) continue;
item.info = variant;
}
// it may happen that we don't find correct variant
// i.e. corrupted implicit on Two-Stone Ring
}
function parseNamePlate(section: string[]) {
let line = section.shift();
let uncutSkillGem = false;
if (!line?.startsWith(_$.ITEM_CLASS)) {
// FIXME: Uncut skill gems (remove)
if (line && section.unshift(line) && isUncutSkillGem(section)) {
uncutSkillGem = true;
} else {
return err("item.parse_error");
}
}
line = section.shift();
let rarityText: string | undefined;
if (line?.startsWith(_$.RARITY)) {
rarityText = line.slice(_$.RARITY.length);
line = section.shift();
}
let name: string;
if (line != null) {
name = markupConditionParser(line);
} else {
return err("item.parse_error");
}
line = section.shift();
const baseType = line && markupConditionParser(line);
const item: ParserState = {
rarity: undefined,
category: undefined,
name,
baseType,
isUnidentified: false,
isCorrupted: false,
newMods: [],
statsByType: [],
unknownModifiers: [],
influences: [],
info: undefined!,
infoVariants: undefined!,
rawText: undefined!,
};
switch (rarityText) {
case _$.RARITY_CURRENCY:
item.category = ItemCategory.Currency;
break;
case _$.RARITY_DIVCARD:
item.category = ItemCategory.DivinationCard;
break;
case _$.RARITY_GEM:
item.category = ItemCategory.Gem;
break;
case _$.RARITY_NORMAL:
case _$.RARITY_QUEST:
item.rarity = ItemRarity.Normal;
break;
case _$.RARITY_MAGIC:
item.rarity = ItemRarity.Magic;
break;
case _$.RARITY_RARE:
item.rarity = ItemRarity.Rare;
break;
case _$.RARITY_UNIQUE:
item.rarity = ItemRarity.Unique;
break;
}
if (uncutSkillGem) {
item.category = ItemCategory.UncutGem;
}
return ok(item);
}
function parseInfluence(section: string[], item: ParsedItem) {
if (section.length <= 2) {
const countBefore = item.influences.length;
for (const line of section) {
switch (line) {
case _$.INFLUENCE_CRUSADER:
item.influences.push(ItemInfluence.Crusader);
break;
case _$.INFLUENCE_ELDER:
item.influences.push(ItemInfluence.Elder);
break;
case _$.INFLUENCE_SHAPER:
item.influences.push(ItemInfluence.Shaper);
break;
case _$.INFLUENCE_HUNTER:
item.influences.push(ItemInfluence.Hunter);
break;
case _$.INFLUENCE_REDEEMER:
item.influences.push(ItemInfluence.Redeemer);
break;
case _$.INFLUENCE_WARLORD:
item.influences.push(ItemInfluence.Warlord);
break;
}
}
if (countBefore < item.influences.length) {
return "SECTION_PARSED";
}
}
return "SECTION_SKIPPED";
}
// #region Small Sections
function parseCorrupted(section: string[], item: ParsedItem) {
if (section[0].trim() === _$.CORRUPTED) {
item.isCorrupted = true;
return "SECTION_PARSED";
} else if (section[0] === _$.UNMODIFIABLE) {
item.isCorrupted = true;
item.isUnmodifiable = true;
return "SECTION_PARSED";
}
return "SECTION_SKIPPED";
}
function parseFoil(section: string[], item: ParsedItem) {
if (item.rarity !== ItemRarity.Unique) {
return "PARSER_SKIPPED";
}
if (section[0] === _$.FOIL_UNIQUE) {
item.isFoil = true;
return "SECTION_PARSED";
}
return "SECTION_SKIPPED";
}
function parseUnidentified(section: string[], item: ParsedItem) {
if (section[0] === _$.UNIDENTIFIED) {
item.isUnidentified = true;
return "SECTION_PARSED";
}
return "SECTION_SKIPPED";
}
function parseItemLevel(section: string[], item: ParsedItem) {
let prefix = _$.ITEM_LEVEL;
if (item.info.refName === "Filled Coffin") {
prefix = _$.CORPSE_LEVEL;
}
for (const line of section) {
if (line.startsWith(prefix)) {
item.itemLevel = Number(line.slice(prefix.length));
return "SECTION_PARSED";
}
}
return "SECTION_SKIPPED";
}
function parseRequirements(section: string[], item: ParsedItem) {
if (
section[0].startsWith(_$.REQUIREMENTS) ||
section[0].startsWith(_$.REQUIRES)
) {
return "SECTION_PARSED";
}
return "SECTION_SKIPPED";
}
function parseTalismanTier(section: string[], item: ParsedItem) {
if (section[0].startsWith(_$.TALISMAN_TIER)) {
item.talismanTier = Number(section[0].slice(_$.TALISMAN_TIER.length));
return "SECTION_PARSED";
}
return "SECTION_SKIPPED";
}
function parseVaalGemName(section: string[], item: ParserState) {
if (item.category !== ItemCategory.Gem) return "PARSER_SKIPPED";
// TODO blocked by https://www.pathofexile.com/forum/view-thread/3231236
if (section.length === 1) {
let gemName: string | undefined;
if (ITEM_BY_REF("GEM", section[0])) {
gemName = section[0];
}
if (gemName) {
item.name = ITEM_BY_REF("GEM", gemName)![0].refName;
return "SECTION_PARSED";
}
}
return "SECTION_SKIPPED";
}
function parseGem(section: string[], item: ParsedItem) {
if (
item.category !== ItemCategory.Gem &&
item.category !== ItemCategory.UncutGem
) {
return "PARSER_SKIPPED";
}
const gemLevelLineNumber = item.category === ItemCategory.Gem ? 1 : 0;
if (section[gemLevelLineNumber]?.startsWith(_$.GEM_LEVEL)) {
// "Level: 20 (Max)"
item.gemLevel = parseInt(
section[gemLevelLineNumber].slice(_$.GEM_LEVEL.length),
10,
);
parseQualityNested(section, item);
return "SECTION_PARSED";
}
return "SECTION_SKIPPED";
}
// #endregion
function parseStackSize(section: string[], item: ParsedItem) {
if (
item.rarity !== ItemRarity.Normal &&
item.category !== ItemCategory.Currency &&
item.category !== ItemCategory.DivinationCard
) {
return "PARSER_SKIPPED";
}
if (section[0].startsWith(_$.STACK_SIZE)) {
// Portal Scroll "Stack Size: 2[localized separator]448/40"
const [value, max] = section[0]
.slice(_$.STACK_SIZE.length)
.replace(/[^\d/]/g, "")
.split("/")
.map(Number);
item.stackSize = { value, max };
return "SECTION_PARSED";
}
return "SECTION_SKIPPED";
}
function parseRuneSockets(section: string[], item: ParsedItem) {
const categoryMax = getMaxSockets(item);
const armourOrWeapon =
categoryMax &&
(isArmourOrWeaponOrCaster(item.category) ||
item.info.refName === "Darkness Enthroned");
if (!armourOrWeapon) return "PARSER_SKIPPED";
if (section[0].startsWith(_$.SOCKETS)) {
const sockets = section[0].slice(_$.SOCKETS.length).trimEnd();
const current = sockets.split("S").length - 1;
if (!itemIsModifiable(item)) {
item.runeSockets = {
empty: 0,
current,
normal: categoryMax,
};
} else {
item.runeSockets = {
empty: 0,
current,
normal: categoryMax,
};
}
return "SECTION_PARSED";
}
if (categoryMax && itemIsModifiable(item)) {
item.runeSockets = {
empty: categoryMax,
current: 0,
normal: categoryMax,
};
}
return "SECTION_SKIPPED";
}
function parseSockets(section: string[], item: ParsedItem) {
if (item.category === ItemCategory.Gem && section[0].startsWith(_$.SOCKETS)) {
let sockets = section[0].slice(_$.SOCKETS.length).trimEnd();
sockets = sockets.replace(/[^ -]/g, "#");
item.gemSockets = {
number: sockets.split("#").length - 1,
white: sockets.split("W").length - 1,
linked: undefined,
};
if (sockets === "#-#-#-#-#-#") {
item.gemSockets.linked = 6;
} else if (
sockets === "# #-#-#-#-#" ||
sockets === "#-#-#-#-# #" ||
sockets === "#-#-#-#-#"
) {
item.gemSockets.linked = 5;
}
return "SECTION_PARSED";
}
return "SECTION_SKIPPED";
}
function parseQualityNested(section: string[], item: ParsedItem) {
for (const line of section) {
if (line.startsWith(_$.QUALITY)) {
// "Quality: +20% (augmented)"
item.quality = parseInt(line.slice(_$.QUALITY.length), 10);
break;
}
}
}
function parseArmour(section: string[], item: ParsedItem) {
let isParsed: SectionParseResult = "SECTION_SKIPPED";
for (const line of section) {
if (line.startsWith(_$.ARMOUR)) {
item.armourAR = parseInt(line.slice(_$.ARMOUR.length), 10);
isParsed = "SECTION_PARSED";
continue;
}
if (line.startsWith(_$.EVASION)) {
item.armourEV = parseInt(line.slice(_$.EVASION.length), 10);
isParsed = "SECTION_PARSED";
continue;
}
if (line.startsWith(_$.ENERGY_SHIELD)) {
item.armourES = parseInt(line.slice(_$.ENERGY_SHIELD.length), 10);
isParsed = "SECTION_PARSED";
continue;
}
if (line.startsWith(_$.BLOCK_CHANCE)) {
item.armourBLOCK = parseInt(line.slice(_$.BLOCK_CHANCE.length), 10);
isParsed = "SECTION_PARSED";
continue;
}
}
if (isParsed === "SECTION_PARSED") {
parseQualityNested(section, item);
}
if (item.rarity === "Unique") {
// undo everything
item.armourAR = undefined;
item.armourEV = undefined;
item.armourES = undefined;
item.armourBLOCK = undefined;
}
return isParsed;
}
function parseWeapon(section: string[], item: ParsedItem) {
let isParsed: SectionParseResult = "SECTION_SKIPPED";
for (const line of section) {
if (line.startsWith(_$.CRIT_CHANCE)) {
// No regex since it can have decimals
item.weaponCRIT = parseFloat(line.slice(_$.CRIT_CHANCE.length));
isParsed = "SECTION_PARSED";
continue;
}
if (line.startsWith(_$.ATTACK_SPEED)) {
// No regex since it can have decimals
item.weaponAS = parseFloat(line.slice(_$.ATTACK_SPEED.length));
isParsed = "SECTION_PARSED";
continue;
}
if (line.startsWith(_$.PHYSICAL_DAMAGE)) {
item.weaponPHYSICAL = getRollOrMinmaxAvg(
line
.slice(_$.PHYSICAL_DAMAGE.length)
.split(_$.HYPHEN)
.map((str) => parseInt(str.replace(/[^\d]/g, ""), 10)),
);
isParsed = "SECTION_PARSED";
continue;
}
if (line.startsWith(_$.ELEMENTAL_DAMAGE)) {
item.weaponELEMENTAL = line
.slice(_$.ELEMENTAL_DAMAGE.length)
.split(", ")
.map((element) =>
getRollOrMinmaxAvg(
element
.split(_$.HYPHEN)
.map((str) => parseInt(str.replace(/[^\d]/g, ""), 10)),
),
)
.reduce((sum, x) => sum + x, 0);
isParsed = "SECTION_PARSED";
continue;
}
if (line.startsWith(_$.FIRE_DAMAGE)) {
const fireDamage = line
.slice(_$.FIRE_DAMAGE.length)
.split(", ")
.map((element) =>
getRollOrMinmaxAvg(
element
.split(_$.HYPHEN)
.map((str) => parseInt(str.replace(/[^\d]/g, ""), 10)),
),
)
.reduce((sum, x) => sum + x, 0);
if (item.weaponELEMENTAL) {
item.weaponELEMENTAL = fireDamage + item.weaponELEMENTAL;
} else {
item.weaponELEMENTAL = fireDamage;
}
isParsed = "SECTION_PARSED";
continue;
}
if (line.startsWith(_$.COLD_DAMAGE)) {
const coldDamage = line
.slice(_$.COLD_DAMAGE.length)
.split(", ")
.map((element) =>
getRollOrMinmaxAvg(
element
.split(_$.HYPHEN)
.map((str) => parseInt(str.replace(/[^\d]/g, ""), 10)),
),
)
.reduce((sum, x) => sum + x, 0);
if (item.weaponELEMENTAL) {
item.weaponELEMENTAL = coldDamage + item.weaponELEMENTAL;
} else {
item.weaponELEMENTAL = coldDamage;
}
isParsed = "SECTION_PARSED";
continue;
}
if (line.startsWith(_$.LIGHTNING_DAMAGE)) {
const lightningDamage = line
.slice(_$.LIGHTNING_DAMAGE.length)
.split(", ")
.map((element) =>
getRollOrMinmaxAvg(
element
.split(_$.HYPHEN)
.map((str) => parseInt(str.replace(/[^\d]/g, ""), 10)),
),
)
.reduce((sum, x) => sum + x, 0);
if (item.weaponELEMENTAL) {
item.weaponELEMENTAL = lightningDamage + item.weaponELEMENTAL;
} else {
item.weaponELEMENTAL = lightningDamage;
}
isParsed = "SECTION_PARSED";
continue;
}
if (line.startsWith(_$.RELOAD_SPEED)) {
// No regex since it can have decimals
item.weaponReload = parseFloat(line.slice(_$.RELOAD_SPEED.length));
isParsed = "SECTION_PARSED";
continue;
}
}
if (isParsed === "SECTION_PARSED") {
parseQualityNested(section, item);
}
if (item.rarity === "Unique") {
// undo everything
item.weaponELEMENTAL = undefined;
item.weaponAS = undefined;
item.weaponPHYSICAL = undefined;
item.weaponCOLD = undefined;
item.weaponLIGHTNING = undefined;
item.weaponFIRE = undefined;
item.weaponCRIT = undefined;
item.weaponReload = undefined;
}
return isParsed;
}
function parseCaster(section: string[], item: ParsedItem) {
if (
item.category !== ItemCategory.Wand &&
item.category !== ItemCategory.Sceptre &&
item.category !== ItemCategory.Staff
)
return "PARSER_SKIPPED";
if (section.length === 1 && section[0].startsWith(_$.QUALITY)) {
parseQualityNested(section, item);
return "SECTION_PARSED";
}
return "SECTION_SKIPPED";
}
function parseLogbookArea(section: string[], item: ParsedItem) {
if (item.info.refName !== "Expedition Logbook") return "PARSER_SKIPPED";
if (section.length < 3) return "SECTION_SKIPPED";
// skip Area, parse Faction
const faction = STAT_BY_MATCH_STR(section[1]);
if (!faction) return "SECTION_SKIPPED";
const areaMods: ParsedModifier[] = [
{
info: { tags: [], type: ModifierType.Pseudo },
stats: [
{
stat: faction.stat,
translation: faction.matcher,
},
],
},
];
const { modType, lines } = parseModType(section.slice(2));
for (const line of lines) {
const found = tryParseTranslation(
{ string: line, unscalable: false },
modType,
);
if (found) {
areaMods.push({
info: { tags: [], type: modType },
stats: [found],
});
}
}
areaMods.shift();
if (areaMods.length) {
if (!item.logbookAreaMods) {
item.logbookAreaMods = [areaMods];
} else {
item.logbookAreaMods.push(areaMods);
}
}
return "SECTION_PARSED";
}
export function parseModifiersPoe2(section: string[], item: ParsedItem) {
if (
item.rarity !== ItemRarity.Normal &&
item.rarity !== ItemRarity.Magic &&
item.rarity !== ItemRarity.Rare &&
item.rarity !== ItemRarity.Unique
) {
return "PARSER_SKIPPED";
}
let foundAnyMods = false;
const hasEndingTag = section.find(
(line) =>
line.endsWith(ENCHANT_LINE) ||
line.endsWith(SCOURGE_LINE) ||
line.endsWith(RUNE_LINE) ||
line.endsWith(ADDED_RUNE_LINE) ||
line.startsWith(_$.GRANTS_SKILL),
);
if (hasEndingTag) {
const { lines } = parseModType(section);
let modType;
if (hasEndingTag.endsWith(ENCHANT_LINE)) {
modType = ModifierType.Enchant;
} else if (hasEndingTag.endsWith(SCOURGE_LINE)) {
modType = ModifierType.Scourge;
} else if (hasEndingTag.endsWith(ADDED_RUNE_LINE)) {
modType = ModifierType.AddedRune;
} else if (hasEndingTag.endsWith(RUNE_LINE)) {
modType = ModifierType.Rune;
} else if (hasEndingTag.startsWith(_$.GRANTS_SKILL)) {
modType = ModifierType.Skill;
} else {
throw new Error("Invalid ending tag");
}
const modInfo: ModifierInfo = {
type: modType,
tags: [],
};
foundAnyMods = parseStatsFromMod(lines, item, { info: modInfo, stats: [] });
} else {
for (const statLines of section) {
let { modType, lines } = parseModType([statLines]);
if (
modType === ModifierType.Explicit &&
item.category === ItemCategory.Relic
) {
modType = ModifierType.Sanctum;
}
// const modInfo = parseModInfoLine(modLine, modType);
const found = parseStatsFromMod(lines, item, {
info: { type: modType, tags: [] },
stats: [],
});
foundAnyMods = found || foundAnyMods;
if (modType === ModifierType.Veiled) {
item.isVeiled = true;
}
}
}
return foundAnyMods ? "SECTION_PARSED" : "SECTION_SKIPPED";
}
function parseModifiers(section: string[], item: ParsedItem) {
if (
item.rarity !== ItemRarity.Normal &&
item.rarity !== ItemRarity.Magic &&
item.rarity !== ItemRarity.Rare &&
item.rarity !== ItemRarity.Unique
) {
return "PARSER_SKIPPED";
}
const recognizedLine = section.find(
(line) =>
line.endsWith(ENCHANT_LINE) ||
line.endsWith(RUNE_LINE) ||
line.startsWith(_$.GRANTS_SKILL) ||
isModInfoLine(line),
);
if (!recognizedLine) {
return "SECTION_SKIPPED";
}
if (isModInfoLine(recognizedLine)) {
for (const { modLine, statLines } of groupLinesByMod(section)) {
const { modType, lines } = parseModType(statLines);
const modInfo = parseModInfoLine(modLine, modType);
if (
item.category === ItemCategory.Relic &&
modInfo.type === ModifierType.Explicit
) {
modInfo.type = ModifierType.Sanctum;
}
parseStatsFromMod(lines, item, { info: modInfo, stats: [] });
if (modType === ModifierType.Veiled) {
item.isVeiled = true;
}
}
} else {
const { lines } = parseModType(section);
const modInfo: ModifierInfo = {
type: recognizedLine.endsWith(ENCHANT_LINE)
? ModifierType.Enchant
: recognizedLine.startsWith(_$.GRANTS_SKILL)
? ModifierType.Skill
: ModifierType.Rune,
tags: [],
};
parseStatsFromMod(lines, item, { info: modInfo, stats: [] });
}
return "SECTION_PARSED";
}
function applyRuneSockets(item: ParsedItem) {
// If we have any rune sockets
if (item.runeSockets) {
// Count current mods that are of type Rune
const runeMods = item.newMods.filter(
(mod) => mod.info.type === ModifierType.Rune,
);
const runeStats = item.statsByType.filter(
(calc) => calc.type === ModifierType.Rune,
);
const runes = runeMods
.map((mod) => {
const stat = runeStats.find(
(stat) => stat.sources[0].stat === mod.stats[0],
);
if (!stat) return [];
return runeCount(mod, stat);
})
.flat();
// HACK: fix since I can't detect how many exist due to rune tiers
const tempFix = runes.reduce((x, y) => x + y, 0) > 0;
const potentialEmptySockets = tempFix
? 0
: Math.max(item.runeSockets.normal, item.runeSockets.current);
item.runeSockets.empty = potentialEmptySockets;
}
}
function parseMirrored(section: string[], item: ParsedItem) {
if (section.length === 1) {
if (section[0] === _$.MIRRORED) {
item.isMirrored = true;
return "SECTION_PARSED";
}
}
return "SECTION_SKIPPED";
}
function parseSanctified(section: string[], item: ParsedItem) {
if (section.length === 1) {
if (section[0] === _$.SANCTIFIED) {
item.isSanctified = true;
return "SECTION_PARSED";
}
}
return "SECTION_SKIPPED";
}
function parseFlask(section: string[], item: ParsedItem) {
// the purpose of this parser is to "consume" flask buffs
// so they are not recognized as modifiers
let isParsed: SectionParseResult = "SECTION_SKIPPED";
for (const line of section) {
if (_$.FLASK_CHARGES.test(line)) {
isParsed = "SECTION_PARSED";
break;
}
}
if (isParsed) {
parseQualityNested(section, item);
}
return isParsed;
}
function parseJewelery(section: string[], item: ParsedItem) {
if (
item.category !== ItemCategory.Amulet &&
item.category !== ItemCategory.Ring &&
item.category !== ItemCategory.Belt
) {
return "PARSER_SKIPPED";
}
for (const line of section) {
if (line.startsWith(_$.QUALITY.substring(0, _$.QUALITY.indexOf(":")))) {
return "SECTION_PARSED";
}
}
return "SECTION_SKIPPED";
}
function parseCharmSlots(section: string[], item: ParsedItem) {
// the purpose of this parser is to "consume" charm slot 1 sections
// so they are not recognized as modifiers
if (item.category !== ItemCategory.Belt) return "PARSER_SKIPPED";
let isParsed: SectionParseResult = "SECTION_SKIPPED";
for (const line of section) {
if (line.startsWith(_$.CHARM_SLOTS)) {
isParsed = "SECTION_PARSED";
break;
}
}
return isParsed;
}
function parseSpirit(section: string[], item: ParsedItem) {
// the purpose of this parser is to "consume" Spirit: 100 sections
// so they are not recognized as modifiers
if (item.category !== ItemCategory.Sceptre) return "PARSER_SKIPPED";
let isParsed: SectionParseResult = "SECTION_SKIPPED";
for (const line of section) {
if (line.startsWith(_$.BASE_SPIRIT)) {
isParsed = "SECTION_PARSED";
break;
}
}
return isParsed;
}
function parsePriceNote(section: string[], item: ParsedItem) {
for (const line of section) {
if (line.startsWith(_$.PRICE_NOTE)) {
item.note = line.slice(_$.PRICE_NOTE.length);
return "SECTION_PARSED";
}
}
return "SECTION_SKIPPED";
}
function parseFracturedText(section: string[], _item: ParsedItem) {
for (const line of section) {
if (line === _$.FRACTURED_ITEM) {
return "SECTION_PARSED";
}
}
return "SECTION_SKIPPED";
}
function parseUnneededText(section: string[], item: ParsedItem) {
if (
item.category !== ItemCategory.Quiver &&
item.category !== ItemCategory.Flask &&
item.category !== ItemCategory.Charm &&
item.category !== ItemCategory.Waystone &&
item.category !== ItemCategory.Map &&
item.category !== ItemCategory.Jewel &&
item.category !== ItemCategory.Relic &&
item.category !== ItemCategory.Tablet &&
item.info.refName !== "Expedition Logbook" &&
item.category !== ItemCategory.Shield &&
item.category !== ItemCategory.Spear &&
item.category !== ItemCategory.Buckler
) {
return "PARSER_SKIPPED";
}
for (const line of section) {
if (
line.startsWith(_$.QUIVER_HELP_TEXT) ||
line.startsWith(_$.FLASK_HELP_TEXT) ||
line.startsWith(_$.CHARM_HELP_TEXT) ||
line.startsWith(_$.WAYSTONE_HELP) ||
line.startsWith(_$.JEWEL_HELP) ||
line.startsWith(_$.SANCTUM_HELP) ||
line.startsWith(_$.PRECURSOR_TABLET_HELP) ||
line.startsWith(_$.LOGBOOK_HELP) ||
line.startsWith(_$.GRANTS_SKILL)
) {
return "SECTION_PARSED";
}
}
return "SECTION_SKIPPED";
}
function parseTimelostRadius(section: string[], item: ParsedItem) {
if (item.category !== ItemCategory.Jewel) return "PARSER_SKIPPED";
for (const line of section) {
if (line.startsWith(_$.TIMELESS_RADIUS)) {
return "SECTION_PARSED";
}
}
return "SECTION_SKIPPED";
}
function parseSentinelCharge(section: string[], item: ParsedItem) {
if (item.category !== ItemCategory.Sentinel) return "PARSER_SKIPPED";
if (section.length === 1) {
if (section[0].startsWith(_$.SENTINEL_CHARGE)) {
item.sentinelCharge = parseInt(
section[0].slice(_$.SENTINEL_CHARGE.length),
10,
);
return "SECTION_PARSED";
}
}
return "SECTION_SKIPPED";
}
function parseSynthesised(section: string[], item: ParserState) {
if (section.length === 1) {
if (section[0] === _$.SECTION_SYNTHESISED) {
item.isSynthesised = true;
if (item.baseType) {
item.baseType = _$REF.ITEM_SYNTHESISED.exec(item.baseType)![1];
} else {
item.name = _$REF.ITEM_SYNTHESISED.exec(item.name)![1];
}
return "SECTION_PARSED";
}
}
return "SECTION_SKIPPED";
}
function parseSuperior(item: ParserState) {
if (
item.rarity === ItemRarity.Normal ||
(item.rarity === ItemRarity.Magic && item.isUnidentified) ||
(item.rarity === ItemRarity.Rare && item.isUnidentified) ||
(item.rarity === ItemRarity.Unique && item.isUnidentified)
) {
if (_$REF.ITEM_SUPERIOR.test(item.name)) {
item.name = _$REF.ITEM_SUPERIOR.exec(item.name)![1];
}
}
}
function parseExceptional(item: ParserState) {
if (
item.rarity === ItemRarity.Normal ||
(item.rarity === ItemRarity.Magic && item.isUnidentified) ||
(item.rarity === ItemRarity.Rare && item.isUnidentified) ||
(item.rarity === ItemRarity.Unique && item.isUnidentified)
) {
if (_$REF.ITEM_EXCEPTIONAL.test(item.name)) {
item.name = _$REF.ITEM_EXCEPTIONAL.exec(item.name)![1];
}
}
}
function parseCategoryByHelpText(section: string[], item: ParsedItem) {
if (section[0] === _$.BEAST_HELP) {
item.category = ItemCategory.CapturedBeast;
return "SECTION_PARSED";
} else if (section[0] === _$.METAMORPH_HELP) {
item.category = ItemCategory.MetamorphSample;
return "SECTION_PARSED";
} else if (section[0] === _$.VOIDSTONE_HELP) {
item.category = ItemCategory.Voidstone;
return "SECTION_PARSED";
}
return "SECTION_SKIPPED";
}
function parseHeistBlueprint(section: string[], item: ParsedItem) {
if (item.category !== ItemCategory.HeistBlueprint) return "PARSER_SKIPPED";
parseAreaLevelNested(section, item);
if (!item.areaLevel) {
return "SECTION_SKIPPED";
}
item.heist = {};
for (const line of section) {
if (line.startsWith(_$.HEIST_TARGET)) {
const targetText = line.slice(_$.HEIST_TARGET.length);
switch (targetText) {
case _$.HEIST_BLUEPRINT_ENCHANTS:
item.heist.target = "Enchants";
break;
case _$.HEIST_BLUEPRINT_GEMS:
item.heist.target = "Gems";
break;
case _$.HEIST_BLUEPRINT_REPLICAS:
item.heist.target = "Replicas";
break;
case _$.HEIST_BLUEPRINT_TRINKETS:
item.heist.target = "Trinkets";
break;
}
} else if (line.startsWith(_$.HEIST_WINGS_REVEALED)) {
item.heist.wingsRevealed = parseInt(
line.slice(_$.HEIST_WINGS_REVEALED.length),
10,
);
}
}
return "SECTION_PARSED";
}
function parseAreaLevelNested(section: string[], item: ParsedItem) {
for (const line of section) {
if (line.startsWith(_$.AREA_LEVEL)) {
item.areaLevel = Number(line.slice(_$.AREA_LEVEL.length));
break;
}
}
}
function parseAreaLevel(section: string[], item: ParsedItem) {
if (
item.info.refName !== "Chronicle of Atzoatl" &&
item.info.refName !== "Expedition Logbook" &&
item.info.refName !== "Mirrored Tablet" &&
item.info.refName !== "Forbidden Tome"
)
return "PARSER_SKIPPED";
parseAreaLevelNested(section, item);
return item.areaLevel ? "SECTION_PARSED" : "SECTION_SKIPPED";
}
function parseAtzoatlRooms(section: string[], item: ParsedItem) {
if (item.info.refName !== "Chronicle of Atzoatl") return "PARSER_SKIPPED";
if (section[0] !== _$.INCURSION_OPEN) return "SECTION_SKIPPED";
let state = IncursionRoom.Open;
for (const line of section.slice(1)) {
if (line === _$.INCURSION_OBSTRUCTED) {
state = IncursionRoom.Obstructed;
continue;
}
const found = STAT_BY_MATCH_STR(line);
if (found) {
item.newMods.push({
info: { tags: [], type: ModifierType.Pseudo },
stats: [
{
stat: found.stat,
translation: {
string:
state === IncursionRoom.Open
? found.matcher.string
: `${_$.INCURSION_OBSTRUCTED} ${found.matcher.string}`,
},
roll: {
value: state,
min: state,
max: state,
dp: false,
unscalable: true,
},
},
],
});
} else {
item.unknownModifiers.push({
text: line,
type: ModifierType.Pseudo,
});
}
}
return "SECTION_PARSED";
}
function parseMirroredTablet(section: string[], item: ParsedItem) {
if (item.info.refName !== "Mirrored Tablet") return "PARSER_SKIPPED";
if (section.length < 8) return "SECTION_SKIPPED";
for (const line of section) {
const found = tryParseTranslation(
{ string: line, unscalable: true },
ModifierType.Pseudo,
);
if (found) {
item.newMods.push({
info: { tags: [], type: ModifierType.Pseudo },
stats: [found],
});
} else {
item.unknownModifiers.push({
text: line,
type: ModifierType.Pseudo,
});
}
}
return "SECTION_PARSED";
}
function parseFilledCoffin(section: string[], item: ParsedItem) {
if (item.info.refName !== "Filled Coffin") return "PARSER_SKIPPED";
if (!section.some((line) => line.endsWith(IMPLICIT_LINE)))
return "SECTION_SKIPPED";
const { lines } = parseModType(section);
const modInfo: ModifierInfo = {
type: ModifierType.Necropolis,
tags: [],
};
parseStatsFromMod(lines, item, { info: modInfo, stats: [] });
return "SECTION_PARSED";
}
function markupConditionParser(text: string) {
// ignores state set by <<set:__>>
// always evaluates first condition to true <if:__>{...}
// full markup: https://gist.github.com/SnosMe/151549b532df8ea08025a76ae2920ca4
text = text.replace(/<<set:.+?>>/g, "");
text = text.replace(
/<(if:.+?|elif:.+?|else)>{(.+?)}/g,
(_, type: string, body: string) => {
return type.startsWith("if:") ? body : "";
},
);
return text;
}
function parseStatsFromMod(
lines: string[],
item: ParsedItem,
modifier: ParsedModifier,
): boolean {
item.newMods.push(modifier);
if (modifier.info.type === ModifierType.Veiled) {
return true;
}
const statIterator = linesToStatStrings(lines);
let stat = statIterator.next();
while (!stat.done) {
if (item.info.refName === "From Nothing") {
stat.value.string = stat.value.string.replace("()", "");
}
const parsedStat = tryParseTranslation(stat.value, modifier.info.type);
if (parsedStat) {
modifier.stats.push(parsedStat);
stat = statIterator.next(true);
} else {
stat = statIterator.next(false);
}
}
if (item.rarity !== ItemRarity.Unique) {
item.unknownModifiers.push(
...stat.value.map((line) => ({
text: line,
type: modifier.info.type,
})),
);
}
return true;
}
/**
* @deprecated
*/
function transformToLegacyModifiers(item: ParsedItem) {
item.statsByType = sumStatsByModType(item.newMods);
}
function applyElementalAdded(item: ParsedItem) {
if (item.weaponELEMENTAL && item.rarity !== "Unique") {
const knownRefs = new Set<string>([
"Adds # to # Lightning Damage",
"Adds # to # Cold Damage",
"Adds # to # Fire Damage",
]);
item.statsByType.forEach((calc) => {
if (knownRefs.has(calc.stat.ref)) {
for (const source of calc.sources) {
if (calc.stat.ref === "Adds # to # Lightning Damage") {
if (item.weaponLIGHTNING) {
item.weaponLIGHTNING =
source.contributes!.value + item.weaponLIGHTNING;
} else {
item.weaponLIGHTNING = source.contributes!.value;
}
} else if (calc.stat.ref === "Adds # to # Cold Damage") {
if (item.weaponCOLD) {
item.weaponCOLD = source.contributes!.value + item.weaponCOLD;
} else {
item.weaponCOLD = source.contributes!.value;
}
} else if (calc.stat.ref === "Adds # to # Fire Damage") {
if (item.weaponFIRE) {
item.weaponFIRE = source.contributes!.value + item.weaponFIRE;
} else {
item.weaponFIRE = source.contributes!.value;
}
}
}
}
});
}
}
function calcBasePercentile(item: ParsedItem) {
const info = item.info.unique
? ITEM_BY_REF("ITEM", item.info.unique.base)![0].armour
: item.info.armour;
if (!info) return;
// Base percentile is the same for all defences.
// Using `AR/EV -> ES` order to improve accuracy
// of calculation (larger rolls = more precise).
if (item.armourAR && info.ar) {
item.basePercentile = calcPropPercentile(
item.armourAR,
info.ar,
QUALITY_STATS.ARMOUR,
item,
);
} else if (item.armourEV && info.ev) {
item.basePercentile = calcPropPercentile(
item.armourEV,
info.ev,
QUALITY_STATS.EVASION,
item,
);
} else if (item.armourES && info.es) {
item.basePercentile = calcPropPercentile(
item.armourES,
info.es,
QUALITY_STATS.ENERGY_SHIELD,
item,
);
}
}
export function removeLinesEnding(
lines: readonly string[],
ending: string,
): string[] {
return lines.map((line) =>
line.endsWith(ending) ? line.slice(0, -ending.length) : line,
);
}
export function parseAffixStrings(clipboard: string): string {
return clipboard.replace(/\[([^\]|]+)\|?([^\]]*)\]/g, (_, part1, part2) => {
return part2 || part1;
});
}
export function getMaxSockets(item: ParsedItem) {
if (item.info.refName === "Darkness Enthroned") {
return 2;
}
const { category } = item;
switch (category) {
case ItemCategory.BodyArmour:
case ItemCategory.TwoHandedAxe:
case ItemCategory.TwoHandedMace:
case ItemCategory.TwoHandedSword:
case ItemCategory.Crossbow:
case ItemCategory.Bow:
case ItemCategory.Warstaff:
case ItemCategory.Staff:
return 2;
case ItemCategory.Helmet:
case ItemCategory.Shield:
case ItemCategory.Gloves:
case ItemCategory.Boots:
case ItemCategory.OneHandedAxe:
case ItemCategory.OneHandedMace:
case ItemCategory.OneHandedSword:
case ItemCategory.Claw:
case ItemCategory.Dagger:
case ItemCategory.Focus:
case ItemCategory.Spear:
case ItemCategory.Flail:
case ItemCategory.Wand:
case ItemCategory.Buckler:
case ItemCategory.Sceptre:
return 1;
default:
return 0;
}
}
export function isArmourOrWeaponOrCaster(
category: ItemCategory | undefined,
): "armour" | "weapon" | "caster" | undefined {
switch (category) {
case ItemCategory.BodyArmour:
case ItemCategory.Boots:
case ItemCategory.Gloves:
case ItemCategory.Helmet:
case ItemCategory.Shield:
case ItemCategory.Focus:
case ItemCategory.Buckler:
return "armour";
case ItemCategory.OneHandedAxe:
case ItemCategory.OneHandedMace:
case ItemCategory.OneHandedSword:
case ItemCategory.Quiver:
case ItemCategory.Claw:
case ItemCategory.Dagger:
case ItemCategory.Sceptre:
case ItemCategory.TwoHandedAxe:
case ItemCategory.TwoHandedMace:
case ItemCategory.TwoHandedSword:
case ItemCategory.Crossbow:
case ItemCategory.Bow:
case ItemCategory.Warstaff:
case ItemCategory.Spear:
case ItemCategory.Flail:
return "weapon";
case ItemCategory.Wand:
case ItemCategory.Staff:
return "caster";
default:
return undefined;
}
}
function runeCount(mod: ParsedModifier, statCalc: StatCalculated): number {
if (mod.info.type !== ModifierType.Rune) return 0;
// HACK: fix since I can't detect how many exist due to rune tiers
// const runeTradeId = statCalc.stat.trade.ids[ModifierType.Rune][0];
// const runeSingle = RUNE_SINGLE_VALUE[runeTradeId];
// // Calculate how many of this rune are in the item
// const runeAppliedValue = statCalc.sources[0].contributes!.value;
// const runeSingleValue = runeSingle.values[0];
// const totalRunes = Math.floor(runeAppliedValue / runeSingleValue);
return 1;
}
export function replaceHashWithValues(template: string, values: number[]) {
let result = template;
values.forEach((value: number) => {
result = result.replace("#", value.toString()); // Replace the first occurrence of #
});
return result;
}
function isUncutSkillGem(section: string[]): boolean {
if (section.length !== 2) return false;
const translated = _$.RARITY + _$.RARITY_CURRENCY;
return section[0] === translated && section[1] !== undefined;
}
// Disable since this is export for tests
// eslint-disable-next-line @typescript-eslint/naming-convention
export const __testExports = {
itemTextToSections,
parseNamePlate,
isUncutSkillGem,
parseWeapon,
parseArmour,
parseModifiers,
};