From 30b6e3d32f4b6150d1ac0bcac5c671cd4dedb2bc Mon Sep 17 00:00:00 2001 From: Alexander Drozdov Date: Mon, 26 Jul 2021 17:58:19 +0300 Subject: [PATCH] Parse advanced item descriptions (Ctrl + Alt + C) (#425) --- .eslintrc.js | 1 + src/assets/data/en/client_strings.ts | 8 +- src/assets/data/ru/client_strings.ts | 8 +- src/main/shortcuts.ts | 13 +- src/parser/ParsedItem.ts | 6 +- src/parser/Parser.ts | 140 ++++++------ src/parser/advanced-mod-desc.ts | 169 +++++++++++++- src/parser/constants.ts | 13 +- src/parser/modifiers.ts | 119 +--------- src/parser/stat-translations.ts | 209 ++++++++++++++++++ .../price-check/price-prediction/poeprices.ts | 14 +- 11 files changed, 473 insertions(+), 227 deletions(-) create mode 100644 src/parser/stat-translations.ts diff --git a/.eslintrc.js b/.eslintrc.js index 5a51775e..4fffe386 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -29,6 +29,7 @@ module.exports = { '@typescript-eslint/prefer-readonly': 'off', '@typescript-eslint/no-floating-promises': 'off', '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/prefer-reduce-type-parameter': 'off', // TODO: refactor IPC and enable '@typescript-eslint/consistent-type-assertions': 'off' }, diff --git a/src/assets/data/en/client_strings.ts b/src/assets/data/en/client_strings.ts index 1236bce2..77741f24 100644 --- a/src/assets/data/en/client_strings.ts +++ b/src/assets/data/en/client_strings.ts @@ -36,15 +36,14 @@ const dict = { 'Synthesised Item': 'Synthesised Item', '/^Synthesised (.*)$/': /^Synthesised (.*)$/, '/^Vaal .*$/': /^Vaal .*$/, - 'Veiled Prefix': 'Veiled Prefix', - 'Veiled Suffix': 'Veiled Suffix', + 'VEILED_PREFIX': 'Veiled Prefix', + 'VEILED_SUFFIX': 'Veiled Suffix', '/^Currently has \\d+ Charges$/': /^Currently has \d+ Charges$/, '/^Spawns a Level (\\d+) Monster when Harvested$/': /^Spawns a Level (\d+) Monster when Harvested$/, 'Right-click this item then left-click the ground to plant it in the Sacred Grove.': 'Right-click this item then left-click the ground to plant it in the Sacred Grove.', "Combine this with four other different samples in Tane's Laboratory.": "Combine this with four other different samples in Tane's Laboratory.", 'Right-click to add this to your bestiary.': 'Right-click to add this to your bestiary.', 'Right-click to add this prophecy to your character.': 'Right-click to add this prophecy to your character.', - 'Added Small Passive Skills grant: ': 'Added Small Passive Skills grant: ', '/^.* Brain$/': /^.* Brain$/, '/^.* Eye$/': /^.* Eye$/, '/^.* Lung$/': /^.* Lung$/, @@ -76,7 +75,8 @@ const dict = { 'PREFIX_MODIFIER': 'Prefix Modifier', 'SUFFIX_MODIFIER': 'Suffix Modifier', 'CRAFTED_PREFIX': 'Master Crafted Prefix Modifier', - 'CRAFTED_SUFFIX': 'Master Crafted Suffix Modifier' + 'CRAFTED_SUFFIX': 'Master Crafted Suffix Modifier', + 'UNSCALABLE_VALUE': ' — Unscalable Value' } export default dict diff --git a/src/assets/data/ru/client_strings.ts b/src/assets/data/ru/client_strings.ts index 367920e5..e68b36df 100644 --- a/src/assets/data/ru/client_strings.ts +++ b/src/assets/data/ru/client_strings.ts @@ -38,15 +38,14 @@ const dict: TranslationDict = { 'Synthesised Item': 'Синтезированный предмет', '/^Synthesised (.*)$/': /^(?:Синтезированный|Синтезированная|Синтезированное|Синтезированные) (.*?)\u00a0*$/, '/^Vaal .*$/': /^.* ваал$/, - 'Veiled Prefix': 'Завуалированный префикс', - 'Veiled Suffix': 'Завуалированный суффикс', + 'VEILED_PREFIX': 'Завуалированный префикс', + 'VEILED_SUFFIX': 'Завуалированный суффикс', '/^Currently has \\d+ Charges$/': /^Содержит зарядов: \d+$/, '/^Spawns a Level (\\d+) Monster when Harvested$/': /^При сборе появляется монстр (\d+) уровня$/, 'Right-click this item then left-click the ground to plant it in the Sacred Grove.': 'Щелкните ПКМ, затем ЛКМ по земле в Священной роще, чтобы посадить растение.', "Combine this with four other different samples in Tane's Laboratory.": 'Объедините эту часть с четырьмя другими в Лаборатории Танэ.', 'Right-click to add this to your bestiary.': 'Нажмите ПКМ, чтобы добавить это в ваш Бестиарий.', 'Right-click to add this prophecy to your character.': 'Нажмите ПКМ, чтобы добавить это пророчество вашему персонажу.', - 'Added Small Passive Skills grant: ': 'Добавленные малые пассивные умения даруют: ', '/^.* Brain$/': /^Мозг: .*$/, '/^.* Eye$/': /^Глаз: .*$/, '/^.* Lung$/': /^Лёгкое: .*$/, @@ -78,7 +77,8 @@ const dict: TranslationDict = { 'PREFIX_MODIFIER': 'Префикс', 'SUFFIX_MODIFIER': 'Суффикс', 'CRAFTED_PREFIX': 'Мастерский префикс', - 'CRAFTED_SUFFIX': 'Мастерский суффикс' + 'CRAFTED_SUFFIX': 'Мастерский суффикс', + 'UNSCALABLE_VALUE': ' — Неизменяемое значение' } export default dict diff --git a/src/main/shortcuts.ts b/src/main/shortcuts.ts index c6b8b21e..41dc83e5 100644 --- a/src/main/shortcuts.ts +++ b/src/main/shortcuts.ts @@ -30,19 +30,12 @@ function priceCheck (lockedMode: boolean) { if (!lockedMode) { if (config.get('priceCheckKeyHold') === 'Ctrl') { - robotjs.keyTap('C') + robotjs.keyTap('C', ['Alt']) } else /* Alt */ { - // not interested in advanced item text (Ctrl + Alt + C) - robotjs.keyToggle('Alt', 'up') robotjs.keyTap('C', ['Ctrl']) - // restore Alt - robotjs.keyToggle('Alt', 'down') - // cancel alt-visibility - // (using Ctrl key (Alt + Ctrl), to ensure nothing triggers on the user's system) - robotjs.keyTap('Ctrl') } } else { - robotjs.keyTap('C', ['Ctrl']) + robotjs.keyTap('C', ['Ctrl', 'Alt']) } } @@ -56,7 +49,7 @@ function itemCheck () { }) .catch(() => {}) hotkeyPressPosition = screen.getCursorScreenPoint() - robotjs.keyTap('C', ['Ctrl']) + robotjs.keyTap('C', ['Ctrl', 'Alt']) } function registerGlobal () { diff --git a/src/parser/ParsedItem.ts b/src/parser/ParsedItem.ts index 8323a124..05e15960 100644 --- a/src/parser/ParsedItem.ts +++ b/src/parser/ParsedItem.ts @@ -1,6 +1,7 @@ import { ItemRarity, ItemInfluence } from './constants' import { ItemModifier, ModifierType } from './modifiers' import { ItemCategory } from './meta' +import type { ParsedModifier } from './advanced-mod-desc' export interface ParsedItem { rarity: ItemRarity @@ -33,7 +34,11 @@ export interface ParsedItem { isMirrored?: boolean influences: ItemInfluence[] isSynthesised?: boolean + /** + * @deprecated + */ modifiers: ItemModifier[] + newMods: ParsedModifier[] unknownModifiers: Array<{ text: string type: ModifierType @@ -47,7 +52,6 @@ export interface ParsedItem { category?: ItemCategory icon?: string rawText: string - isAdvancedDesc: boolean } export type HeistJob = diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts index af144c68..ad2f4377 100644 --- a/src/parser/Parser.ts +++ b/src/parser/Parser.ts @@ -5,10 +5,12 @@ import { CLIENT_STRINGS as _$, ITEM_NAME_REF_BY_TRANSLATED } from '@/assets/data' -import { ModifierType, sectionToStatStrings, tryFindModifier, getRollOrMinmaxAvg } from './modifiers' +import { ModifierType } from './modifiers' +import { linesToStatStrings, tryParseTranslation, getRollOrMinmaxAvg } from './stat-translations' import { ItemCategory } from './meta' import { HeistJob, ParsedItem } from './ParsedItem' import { magicBasetype } from './magic-name' +import { isModInfoLine, groupLinesByMod, parseModInfoLine, parseModType, ModifierInfo, ParsedModifier, sumStatsFromMods } from './advanced-mod-desc' const SECTION_PARSED = 1 const SECTION_SKIPPED = 0 @@ -45,7 +47,8 @@ const parsers: ParserFn[] = [ parseMirrored, parseModifiers, parseModifiers, - parseModifiers + parseModifiers, + transformToLegacyModifiers ] export function parseClipboard (clipboard: string) { @@ -77,7 +80,6 @@ export function parseClipboard (clipboard: string) { sections.shift() parsed.rawText = clipboard - parsed.isAdvancedDesc = isAdvancedDescription(lines) // each section can be parsed at most by one parser for (const parser of parsers) { @@ -196,12 +198,12 @@ function parseNamePlate (section: string[]) { isUnidentified: false, isCorrupted: false, modifiers: [], + newMods: [], unknownModifiers: [], influences: [], sockets: {}, extra: {}, - rawText: undefined!, - isAdvancedDesc: false + rawText: undefined! } return item } @@ -429,10 +431,10 @@ function parseWeapon (section: string[], item: ParsedItem) { isParsed = SECTION_PARSED; continue } if (line.startsWith(_$[C.TAG_PHYSICAL_DAMAGE])) { - const [min, max] = line + item.props.physicalDamage = getRollOrMinmaxAvg(line .substr(_$[C.TAG_PHYSICAL_DAMAGE].length) .split('-').map(str => parseInt(str, 10)) - item.props.physicalDamage = (min + max) / 2 + ) isParsed = SECTION_PARSED; continue } if (line.startsWith(_$[C.TAG_ELEMENTAL_DAMAGE])) { @@ -463,80 +465,41 @@ function parseModifiers (section: string[], item: ParsedItem) { return PARSER_SKIPPED } - const countBefore = (item.modifiers.length + item.unknownModifiers.length) + if (!section.some(line => + line.endsWith(C.ENCHANT_LINE) || + isModInfoLine(line) || + (line === _$.VEILED_PREFIX || line === _$.VEILED_SUFFIX) + )) { + return SECTION_SKIPPED + } - const statIterator = sectionToStatStrings(section) - let stat = statIterator.next() - while (!stat.done) { - if (parseVeiledNested(stat.value, item)) { - stat = statIterator.next(true) - continue + if (section.some(line => line.endsWith(C.ENCHANT_LINE))) { + const { lines } = parseModType(section) + const modInfo: ModifierInfo = { + type: ModifierType.Enchant, + tags: [] } + parseStatsFromMod(lines, item, { info: modInfo, stats: [] }) + } else { + section = section.filter(line => !parseVeiledNested(line, item)) - let modType: ModifierType | undefined - - // cleanup suffix - if (stat.value.endsWith(C.IMPLICIT_SUFFIX)) { - stat.value = stat.value.slice(0, -C.IMPLICIT_SUFFIX.length) - modType = ModifierType.Implicit - } else if (stat.value.endsWith(C.CRAFTED_SUFFIX)) { - stat.value = stat.value.slice(0, -C.CRAFTED_SUFFIX.length) - modType = ModifierType.Crafted - } else if (stat.value.endsWith(C.ENCHANT_SUFFIX)) { - stat.value = stat.value.slice(0, -C.ENCHANT_SUFFIX.length) - modType = ModifierType.Enchant - } else if (stat.value.endsWith(C.FRACTURED_SUFFIX)) { - stat.value = stat.value.slice(0, -C.FRACTURED_SUFFIX.length) - modType = ModifierType.Fractured - } else { - modType = ModifierType.Explicit - } - - const mod = tryFindModifier(stat.value) - if (mod && mod.trade.ids[modType]) { - mod.type = modType - item.modifiers.push(mod) - stat = statIterator.next(true) - } else { - if ( - mod != null || // not found on trade, but successfully parsed - modType === ModifierType.Enchant || // has separate section - modType === ModifierType.Implicit || // has separate section - modType === ModifierType.Fractured || // always comes first in section - (modType === ModifierType.Crafted && ( - !stat.value.includes('\n') || // not multiline, on section transition from explicit to crafted mods - item.modifiers.some(m => m.type === ModifierType.Crafted) || - item.unknownModifiers.some(m => m.type === ModifierType.Crafted) - )) - ) { - item.unknownModifiers.push({ - text: stat.value, - type: modType - }) - stat = statIterator.next(true) - } else { - stat = statIterator.next(false) - } + for (const { modLine, statLines } of groupLinesByMod(section)) { + const { modType, lines } = parseModType(statLines) + const modInfo = parseModInfoLine(modLine, modType) + parseStatsFromMod(lines, item, { info: modInfo, stats: [] }) } } - if (countBefore < (item.modifiers.length + item.unknownModifiers.length)) { - item.unknownModifiers.push(...stat.value.map(line => ({ - text: line, - type: ModifierType.Explicit - }))) - - return SECTION_PARSED - } - return SECTION_SKIPPED + return SECTION_PARSED } +// TODO blocked by https://www.pathofexile.com/forum/view-thread/3148119 function parseVeiledNested (text: string, item: ParsedItem) { - if (text === _$[C.VEILED_SUFFIX]) { + if (text === _$.VEILED_SUFFIX) { item.extra.veiled = (item.extra.veiled == null ? 'suffix' : 'prefix-suffix') return true } - if (text === _$[C.VEILED_PREFIX]) { + if (text === _$.VEILED_PREFIX) { item.extra.veiled = (item.extra.veiled == null ? 'prefix' : 'prefix-suffix') return true } @@ -686,6 +649,41 @@ function markupConditionParser (text: string) { return text } -function isAdvancedDescription (lines: string[]): boolean { - return lines.some(line => line.startsWith('{') && line.endsWith('}')) +function parseStatsFromMod (lines: string[], item: ParsedItem, modifier: ParsedModifier) { + const statIterator = linesToStatStrings(lines) + let stat = statIterator.next() + while (!stat.done) { + const parsedStat = tryParseTranslation(stat.value, modifier.info.type) + if (parsedStat) { + modifier.stats.push(parsedStat) + stat = statIterator.next(true) + } else { + stat = statIterator.next(false) + } + } + + item.newMods.push(modifier) + + item.unknownModifiers.push(...stat.value.map(line => ({ + text: line, + type: modifier.info.type + }))) +} + +/** + * @deprecated + */ +function transformToLegacyModifiers (_: string[], item: ParsedItem) { + item.modifiers = sumStatsFromMods(item.newMods) + return PARSER_SKIPPED as SectionParseResult // fake parser +} + +export function removeLinesEnding ( + lines: readonly string[], ending: string +): string[] { + return lines.map(line => + line.endsWith(ending) + ? line.slice(0, -ending.length) + : line + ) } diff --git a/src/parser/advanced-mod-desc.ts b/src/parser/advanced-mod-desc.ts index d3c072ae..a9cb9fe5 100644 --- a/src/parser/advanced-mod-desc.ts +++ b/src/parser/advanced-mod-desc.ts @@ -1,18 +1,27 @@ -import { CLIENT_STRINGS as _$ } from '@/assets/data' +import { CLIENT_STRINGS as _$, STAT_BY_MATCH_STR } from '@/assets/data' +import * as C from './constants' +import { percentRoll } from '@/web/price-check/filters/util' +import type { ParsedStat } from './stat-translations' +import { LegacyItemModifier, ModifierType } from './modifiers' +import { removeLinesEnding } from './Parser' -// TODO "— Unscalable Value" +export interface ParsedModifier { + info: ModifierInfo + stats: ParsedStat[] +} -interface ModifierInfo { +export interface ModifierInfo { + type: ModifierType generation?: 'suffix' | 'prefix' name?: string tier?: number rank?: number - tags?: string[] - catalystIncr?: number + tags: string[] + rollIncr?: number } -export function parseModInfoLine (line: string): ModifierInfo { - const [modText, tagsText, catalystText] = line +export function parseModInfoLine (line: string, type: ModifierType): ModifierInfo { + const [modText, tagsText, incrText] = line .slice(1, -1) .split('\u2014') .map(_ => _.trim()) @@ -36,7 +45,149 @@ export function parseModInfoLine (line: string): ModifierInfo { const tier = Number(match.groups!.tier) || undefined const rank = Number(match.groups!.rank) || undefined const tags = tagsText ? tagsText.split(', ') : [] - const catalystIncr = parseInt(catalystText, 10) || undefined + const rollIncr = parseInt(incrText, 10) || undefined - return { generation, name, tier, rank, tags, catalystIncr } + return { type, generation, name, tier, rank, tags, rollIncr } +} + +export function isModInfoLine (line: string): boolean { + return line.startsWith('{') && line.endsWith('}') +} + +interface GroupedModLines { + modLine: string + statLines: string[] +} + +export function * groupLinesByMod (lines: string[]): Generator { + if (!lines.length || !isModInfoLine(lines[0])) { + return + } + + let last: GroupedModLines | undefined + for (const line of lines) { + if (!isModInfoLine(line)) { + last!.statLines.push(line) + } else { + if (last) { yield last } + last = { modLine: line, statLines: [] } + } + } + yield last! +} + +export function parseModType (lines: string[]): { modType: ModifierType, lines: string[] } { + let modType: ModifierType + if (lines.some(line => line.endsWith(C.ENCHANT_LINE))) { + modType = ModifierType.Enchant + lines = removeLinesEnding(lines, C.ENCHANT_LINE) + } else if (lines.some(line => line.endsWith(C.IMPLICIT_LINE))) { + modType = ModifierType.Implicit + lines = removeLinesEnding(lines, C.IMPLICIT_LINE) + } else if (lines.some(line => line.endsWith(C.FRACTURED_LINE))) { + modType = ModifierType.Fractured + lines = removeLinesEnding(lines, C.FRACTURED_LINE) + } else if (lines.some(line => line.endsWith(C.CRAFTED_LINE))) { + modType = ModifierType.Crafted + lines = removeLinesEnding(lines, C.CRAFTED_LINE) + } else { + modType = ModifierType.Explicit + } + + return { modType, lines } +} + +// stat values internally stored as ints, +// this is the most common formatter +const DIV_BY_100 = 2 + +function applyIncr (mod: ModifierInfo, stat: ParsedStat): ParsedStat | null { + const { rollIncr } = mod + const { roll } = stat + + if (!rollIncr || !roll || roll.unscalable) { + return null + } + + return { + translation: stat.translation, + roll: { + unscalable: roll.unscalable, + dp: roll.dp, + value: percentRoll(roll.value, rollIncr, (roll.value > 0) ? Math.floor : Math.ceil, roll.dp && DIV_BY_100), + min: percentRoll(roll.min, rollIncr, (roll.min > 0) ? Math.floor : Math.ceil, roll.dp && DIV_BY_100), + max: percentRoll(roll.max, rollIncr, (roll.max > 0) ? Math.floor : Math.ceil, roll.dp && DIV_BY_100) + } + } +} + +export function sumStatsFromMods (mods: readonly ParsedModifier[]): LegacyItemModifier[] { + const out: LegacyItemModifier[] = [] + + const merged: ParsedStat[] = [] + + mods = mods.map(mod => ({ + ...mod, + stats: mod.stats.map(stat => + applyIncr(mod.info, stat) ?? stat + ) + })) + + for (const modA of mods) { + for (const statA of modA.stats) { + if (merged.includes(statA)) { + continue + } + + const dbStatA = STAT_BY_MATCH_STR.get(statA.translation.string)!.stat + + const toMerge = mods + .reduce((filtered, modB) => { + if (modB.info.type === modA.info.type) { + const targetStat = modB.stats.find(statB => + dbStatA.stat.matchers.some(matcher => matcher.string === statB.translation.string) + ) + if (targetStat) { + filtered.push({ + info: modB.info, + stat: targetStat + }) + } + } + return filtered + }, [] as Array<{ info: ModifierInfo, stat: ParsedStat }>) + + if (toMerge.length === 1) { + out.push({ + stat: dbStatA.stat, + trade: dbStatA.trade, + string: statA.translation.string, + type: modA.info.type, + negate: statA.translation.negate, + value: statA.roll?.value + }) + } else { + const rollValue = toMerge.reduce((sum, { stat }) => sum + stat.roll!.value, 0) + const translation = + (dbStatA.stat.matchers.find(m => m.value === rollValue)) ?? + ((statA.translation.value == null) + ? statA.translation + : dbStatA.stat.matchers.find(m => m.value == null && !m.negate)) ?? + ({ string: `Report bug if you see this text (${statA.translation.string})` }) + + out.push({ + stat: dbStatA.stat, + trade: dbStatA.trade, + string: translation.string, + type: modA.info.type, + negate: translation.negate, + value: rollValue + }) + } + + merged.push(...toMerge.map(mod => mod.stat)) + } + } + + return out } diff --git a/src/parser/constants.ts b/src/parser/constants.ts index 4211f7e9..99f4b97d 100644 --- a/src/parser/constants.ts +++ b/src/parser/constants.ts @@ -45,10 +45,10 @@ export const TAG_EVASION = 'Evasion Rating: ' export const TAG_ENERGY_SHIELD = 'Energy Shield: ' export const TAG_BLOCK_CHANCE = 'Chance to Block: ' -export const IMPLICIT_SUFFIX = ' (implicit)' -export const CRAFTED_SUFFIX = ' (crafted)' -export const ENCHANT_SUFFIX = ' (enchant)' -export const FRACTURED_SUFFIX = ' (fractured)' +export const IMPLICIT_LINE = ' (implicit)' +export const CRAFTED_LINE = ' (crafted)' +export const ENCHANT_LINE = ' (enchant)' +export const FRACTURED_LINE = ' (fractured)' export const CORRUPTED = 'Corrupted' export const UNIDENTIFIED = 'Unidentified' @@ -60,8 +60,6 @@ export const MAP_BLIGHTED = '/^Blighted (.*)$/' export const ITEM_SUPERIOR = '/^Superior (.*)$/' export const VAAL_GEM = '/^Vaal .*$/' -export const CLUSTER_JEWEL_GRANT = 'Added Small Passive Skills grant: ' - export const PROPHECY_HELP = 'Right-click to add this prophecy to your character.' export const BEAST_HELP = 'Right-click to add this to your bestiary.' export const METAMORPH_HELP = "Combine this with four other different samples in Tane's Laboratory." @@ -73,9 +71,6 @@ export const METAMORPH_LUNG = '/^.* Lung$/' export const METAMORPH_HEART = '/^.* Heart$/' export const METAMORPH_LIVER = '/^.* Liver$/' -export const VEILED_PREFIX = 'Veiled Prefix' -export const VEILED_SUFFIX = 'Veiled Suffix' - export const PROPHECY_ALVA = 'You will find Alva and complete her mission.' export const PROPHECY_EINHAR = 'You will find Einhar and complete his mission.' export const PROPHECY_NIKO = 'You will find Niko and complete his mission.' diff --git a/src/parser/modifiers.ts b/src/parser/modifiers.ts index bd629ffb..d3a163ef 100644 --- a/src/parser/modifiers.ts +++ b/src/parser/modifiers.ts @@ -1,5 +1,4 @@ -import { STAT_BY_MATCH_STR, Stat, StatMatcher, CLIENT_STRINGS as _$ } from '@/assets/data' -import * as C from './constants' +import type { Stat, StatMatcher } from '@/assets/data' export enum ModifierType { Pseudo = 'pseudo', @@ -11,123 +10,11 @@ export enum ModifierType { Fractured = 'fractured' } -export interface ItemModifier extends +export interface LegacyItemModifier extends Stat, Pick { value?: number type: ModifierType } -export function * sectionToStatStrings (section: string[]): Generator { - const notParsedLines: string[] = [] - - let idx = 0 - let multi = (idx + 1) < section.length - - while (idx < section.length) { - let str: string - - if (multi) { - if ( - section[idx].startsWith(_$[C.CLUSTER_JEWEL_GRANT]) && - section[idx + 1].startsWith(_$[C.CLUSTER_JEWEL_GRANT]) - ) { - str = `${section[idx].slice(0, -C.ENCHANT_SUFFIX.length)}\n${section[idx + 1]}` - } else if ( - section[idx].endsWith(C.IMPLICIT_SUFFIX) || - section[idx].endsWith(C.CRAFTED_SUFFIX) || - section[idx].endsWith(C.ENCHANT_SUFFIX) || - section[idx].endsWith(C.FRACTURED_SUFFIX) - ) { - multi = false - str = section[idx] - } else { - str = `${section[idx]}\n${section[idx + 1]}` - } - } else { - str = section[idx] - } - - const isParsed: boolean = yield str - - if (isParsed) { - idx += multi ? 2 : 1 - multi = (idx + 1) < section.length - } else { - if (multi) { - multi = false - } else { - idx += 1 - multi = (idx + 1) < section.length - notParsedLines.push(str) - } - } - } - return notParsedLines -} - -const PLACEHOLDER_MAP = [ - // 0 # - [[]], - // 1 # - [[0], []], - // 2 # - [[0, 1], [0], [1], []], - // 3 # - [[0, 1, 2], [1, 2], [0, 2], [0, 1], [2], [1], [0]], - // 4 # - [[0, 1, 2, 3], [1, 2, 3], [0, 2, 3], [0, 1, 3], [0, 1, 2], [2, 3], [1, 3], [1, 2], [0, 3], [0, 2], [0, 1]] -] - -export function tryFindModifier (stat: string): ItemModifier | undefined { - const matches = [] as string[] - - const withPlaceholders = stat - .replace(/(? { - matches.push(value) - return '#' - }) - - if (matches.length >= PLACEHOLDER_MAP.length) return - - const comboVariants = PLACEHOLDER_MAP[matches.length] - for (const combo of comboVariants) { - let pIdx = -1 - const possibleStat = withPlaceholders.replace(/#/gm, () => { - pIdx += 1 - if (combo.includes(pIdx)) { - return matches[pIdx] - } else { - return '#' - } - }) - - const found = STAT_BY_MATCH_STR.get(possibleStat) - if (found) { - let values = matches - .filter((_, idx) => !combo.includes(idx)) - .map(str => Number(str) * (found.matcher.negate ? -1 : 1)) - - if (!values.length && found.matcher.value) { - values = [found.matcher.value] - } - - return { - stat: found.stat.stat, - trade: found.stat.trade, - string: found.matcher.string, - negate: found.matcher.negate, - value: values.length ? getRollOrMinmaxAvg(values) : undefined, - type: undefined! - } - } - } -} - -export function getRollOrMinmaxAvg (values: number[]): number { - if (values.length === 2) { - return (values[0] + values[1]) / 2 - } else { - return values[0] - } -} +export { LegacyItemModifier as ItemModifier } diff --git a/src/parser/stat-translations.ts b/src/parser/stat-translations.ts new file mode 100644 index 00000000..1aa74f46 --- /dev/null +++ b/src/parser/stat-translations.ts @@ -0,0 +1,209 @@ +import { CLIENT_STRINGS as _$, STAT_BY_MATCH_STR, StatMatcher } from '@/assets/data' +import type { ModifierType } from './modifiers' + +// This file is a little messy and scary, +// but that's how stats translations are parsed :-D + +export interface ParsedStat { + readonly translation: StatMatcher + roll?: { + unscalable: boolean + dp: boolean + value: number + min: number + max: number + } +} + +interface StatString { + string: string + unscalable: boolean +} + +export function * linesToStatStrings (lines: string[]): Generator { + const notParsedLines: string[] = [] + + let reminderString = false + + outer: + for (let start = 0; start < lines.length; start += 1) { + if ((lines[start].trim()).startsWith('(')) { + reminderString = true + } + if (reminderString && (lines[start].trim()).endsWith(')')) { + reminderString = false + continue + } + if (reminderString) { + continue + } + + for (let end = start; end < lines.length; end += 1) { + let str = lines.slice(start, end + 1).join('\n') + + const unscalable = str.endsWith(_$.UNSCALABLE_VALUE) + if (unscalable) { + str = str.slice(0, -_$.UNSCALABLE_VALUE.length) + } + + const isParsed: boolean = yield { string: str, unscalable } + if (isParsed) { + continue outer + } + } + notParsedLines.push(lines[start]) + } + return notParsedLines +} + +const PLACEHOLDER_MAP = [ + // 0 # -> max 0 # + [[]], + // 1 # -> max 1 # + [[0], []], + // 2 # -> max 2 # + [[0, 1], [0], [1], []], + // 3 # -> max 2 # + [[0, 1, 2], [1, 2], [0, 2], [0, 1], [2], [1], [0]], + // 4 # -> max 2 # + [[0, 1, 2, 3], [1, 2, 3], [0, 2, 3], [0, 1, 3], [0, 1, 2], [2, 3], [1, 3], [1, 2], [0, 3], [0, 2], [0, 1]] +] + +function * _statPlaceholderGenerator (stat: string) { + // combinations to detect stat text range "Victario(Cadiro-Victario)" + // ^^^^^^ ^^^^^^^^ + // but ignore (dashed-text) literal inside translation string + function * _firstPass (stat: string): Generator { + const matches = [] as string[] + const withPlaceholders = stat + .replace(/(?:(?.[^)-]*)-(?[^)]+)\))/gm, (match, min, max) => { + if (Number.isNaN(Number(min)) || Number.isNaN(Number(max))) { + matches.push(match) + return '#' + } else { + return match + } + }) + + for (let j = 0; j < 2 ** matches.length; j += 1) { + const replacements: number[] = [] + for (let idx = 0; idx < matches.length; idx += 1) { + if ((j & (2 ** idx))) { + replacements.push(idx) + } + } + + { + let idx = -1 + yield withPlaceholders.replace(/#/gm, () => { + idx += 1 + return replacements.includes(idx) + ? matches[idx] + : '' + }) + } + } + } + + for (stat of _firstPass(stat)) { + const matches: Array<{ + roll: number + rollStr: string + decimal: boolean + bounds?: { min: number, max: number } + }> = [] + const withPlaceholders = stat + .replace(/(?(?.[^)-]*)(?:-(?[^)]+))?\))?/gm, (_, roll: string, min?: string, max?: string) => { + if (min != null && max == null) { + // example: Watchstone "# uses remaining" + max = min + } + + const captured: typeof matches[number] = { + roll: Number(roll), + rollStr: roll, + decimal: roll.includes('.') || min?.includes('.') || max?.includes('.') || false, + bounds: { min: Number(min), max: Number(max) } + } + matches.push(captured) + + if (Number.isNaN(captured.bounds!.min) || Number.isNaN(captured.bounds!.max)) { + captured.bounds = undefined + return (min != null) ? `#(${min}-${max})` : '#' + } else { + return '#' + } + }) + + if (matches.length < PLACEHOLDER_MAP.length) { + for (const replacements of PLACEHOLDER_MAP[matches.length]) { + let idx = -1 + const replaced = withPlaceholders.replace(/#/gm, () => { + idx += 1 + return replacements.includes(idx) + ? matches[idx].rollStr + : '#' + }) + + yield { + stat: replaced, + values: matches + .filter((_, idx) => !replacements.includes(idx)) as + Array> + } + } + } + } +} + +export function tryParseTranslation (stat: StatString, modType: ModifierType): ParsedStat | undefined { + for (const combination of _statPlaceholderGenerator(stat.string)) { + const found = STAT_BY_MATCH_STR.get(combination.stat) + if (!found || !found.stat.trade.ids[modType]) { + continue + } + + if (found.matcher.negate) { + for (const stat of combination.values) { + stat.roll *= -1 + if (stat.bounds) { + // do not swap (TODO: or do? can't say from Ventor's Gamble) + stat.bounds.min *= -1 + stat.bounds.max *= -1 + } + } + } + + if (!combination.values.length && found.matcher.value) { + combination.values = [{ + roll: found.matcher.value, + decimal: false, + bounds: { + min: found.matcher.value, + max: found.matcher.value + } + }] + } + + return { + translation: found.matcher, + roll: combination.values.length + ? { + unscalable: stat.unscalable, + dp: found.stat.stat.dp || combination.values.some(stat => stat.decimal), + value: getRollOrMinmaxAvg(combination.values.map(stat => stat.roll)), + min: getRollOrMinmaxAvg(combination.values.map(stat => stat.bounds?.min ?? stat.roll)), + max: getRollOrMinmaxAvg(combination.values.map(stat => stat.bounds?.max ?? stat.roll)) + } + : undefined + } + } +} + +export function getRollOrMinmaxAvg (values: number[]): number { + if (values.length === 2) { + return (values[0] + values[1]) / 2 + } else { + return values[0] + } +} diff --git a/src/web/price-check/price-prediction/poeprices.ts b/src/web/price-check/price-prediction/poeprices.ts index 0bb74360..25502cae 100644 --- a/src/web/price-check/price-prediction/poeprices.ts +++ b/src/web/price-check/price-prediction/poeprices.ts @@ -26,7 +26,7 @@ export interface RareItemPrice { export async function requestPoeprices (item: ParsedItem): Promise { const query = querystring({ - i: utf8ToBase64(item.rawText), + i: utf8ToBase64(transformItemText(item.rawText)), l: league.value, s: 'awakened-poe-trade' }) @@ -56,7 +56,7 @@ export async function requestPoeprices (item: ParsedItem): Promise) { .map(pair => pair.map(encodeURIComponent).join('=')) .join('&') } + +/** + * @deprecated TODO blocked by poeprices.info not supporting advanced text + */ +function transformItemText (rawText: string) { + // this may not account for all cases + return rawText.replace(/(?<=\d)(\([^)]+\))/gm, '') +}