diff --git a/renderer/public/images/extractor.png b/renderer/public/images/extractor.png new file mode 100644 index 00000000..1154c4c0 Binary files /dev/null and b/renderer/public/images/extractor.png differ diff --git a/renderer/specs/Parser/augment.test.ts b/renderer/specs/Parser/augment.test.ts index ef1a9965..7717f6c3 100644 --- a/renderer/specs/Parser/augment.test.ts +++ b/renderer/specs/Parser/augment.test.ts @@ -1,36 +1,12 @@ -import { init, STAT_BY_REF } from "@/assets/data"; +import { init } from "@/assets/data"; +import { ItemCategory } from "@/parser"; import { ParsedModifier } from "@/parser/advanced-mod-desc"; -import { ModifierType, StatCalculated } from "@/parser/modifiers"; +import { ModifierType } from "@/parser/modifiers"; import { __testExports } from "@/parser/Parser"; +import { createTestItem, makeCalcStat } from "@specs/helper"; import { setupTests } from "@specs/vitest.setup"; import { beforeEach, describe, expect, it } from "vitest"; -function makeCalcStat(ref: string, value: number): StatCalculated { - const stat = STAT_BY_REF(ref)!; - - return { - stat, - type: ModifierType.Augment, - sources: [ - { - contributes: { - value, - }, - modifier: { - info: { - type: ModifierType.Augment, - tags: [], - }, - stats: [], - }, - stat: { - stat, - }, - }, - ], - } as unknown as StatCalculated; -} - describe("determineAugments", () => { beforeEach(async () => { setupTests(); @@ -46,7 +22,7 @@ describe("determineAugments", () => { type: ModifierType.Augment, }, } as unknown as ParsedModifier, - makeCalcStat("#% increased Physical Damage", 36), + [makeCalcStat("#% increased Physical Damage", 36, ModifierType.Augment)], ); expect(result.map((augment) => augment.refName)).toEqual(expectedRef); @@ -61,7 +37,7 @@ describe("determineAugments", () => { type: ModifierType.Augment, }, } as unknown as ParsedModifier, - makeCalcStat("#% to Lightning Resistance", 10), + [makeCalcStat("#% to Lightning Resistance", 10, ModifierType.Augment)], ); expect(result.map((augment) => augment.refName)).toEqual(expectedRef); @@ -98,3 +74,224 @@ describe("BFS", () => { }, ); }); + +describe("parseAugmentSockets", () => { + beforeEach(async () => { + setupTests(); + await init("en"); + }); + + it("should skip for items without sockets", () => { + const item = createTestItem(); + + const result = __testExports.parseAugmentSockets([], item); + expect(result).toEqual("PARSER_SKIPPED"); + }); + + it.each([ + [["Sockets: S"], 1], + [["Sockets: S S"], 2], + [["Sockets: S S S"], 3], + [["Sockets: S S S S"], 4], + [["Sockets: S S S S S"], 5], + ])( + "%#. should add correct augment socket count(%o -> %o)", + (lines, expected) => { + const item = createTestItem(); + item.category = ItemCategory.Gloves; + + const result = __testExports.parseAugmentSockets(lines, item); + expect(result).toEqual("SECTION_PARSED"); + expect(item.augmentSockets?.normal).toEqual(1); + expect(item.augmentSockets?.empty).toEqual(0); + expect(item.augmentSockets?.current).toEqual(expected); + expect( + item.augmentSockets?.augments.every((v) => v === null), + ).toBeTruthy(); + expect(item.augmentSockets?.augments.length).toEqual(expected); + }, + ); + + it("should add sockets on non-socket section", () => { + const item = createTestItem(); + item.category = ItemCategory.Gloves; + + const result = __testExports.parseAugmentSockets(["text"], item); + expect(result).toEqual("SECTION_SKIPPED"); + expect(item.augmentSockets?.normal).toEqual(1); + expect(item.augmentSockets?.empty).toEqual(1); + expect(item.augmentSockets?.current).toEqual(0); + expect(item.augmentSockets?.augments.every((v) => v === null)).toBeTruthy(); + expect(item.augmentSockets?.augments.length).toEqual(1); + }); +}); + +describe("applyAugmentSockets", () => { + beforeEach(async () => { + setupTests(); + await init("en"); + }); + + it("should do nothing on item without sockets", () => { + const item = createTestItem(); + + __testExports.applyAugmentSockets(item); + expect(item).toEqual(createTestItem()); + }); + + it("should set empty on item with no augment stats", () => { + const item = createTestItem(); + item.category = ItemCategory.Gloves; + item.augmentSockets = { + empty: 0, + current: 2, + normal: 2, + augments: [null, null], + }; + + __testExports.applyAugmentSockets(item); + expect(item.augmentSockets.empty).toEqual(2); + expect(item.augmentSockets.current).toEqual(2); + expect(item.augmentSockets.normal).toEqual(2); + expect(item.augmentSockets.augments.every((v) => v === null)).toBeTruthy(); + }); + + it("should apply correct augment to item", () => { + const item = createTestItem(); + item.category = ItemCategory.Bow; + item.augmentSockets = { + empty: 0, + current: 2, + normal: 2, + augments: [null, null], + }; + + const stat = makeCalcStat( + "#% increased Physical Damage", + 18, + ModifierType.Augment, + ); + item.statsByType = [stat]; + item.newMods = [ + { + info: { + type: ModifierType.Augment, + tags: [], + }, + stats: [stat.sources[0].stat], + }, + ]; + + __testExports.applyAugmentSockets(item); + expect(item.augmentSockets.empty).toEqual(1); + expect(item.augmentSockets.current).toEqual(2); + expect(item.augmentSockets.normal).toEqual(2); + expect(item.augmentSockets.augments[0]?.refName).toEqual( + "Greater Iron Rune", + ); + expect(item.augmentSockets.augments[1]).toBeNull(); + }); + + it("should apply multiple different augments to item", () => { + const item = createTestItem(); + item.category = ItemCategory.Bow; + item.augmentSockets = { + empty: 0, + current: 2, + normal: 2, + augments: [null, null], + }; + + const stat1 = makeCalcStat( + "#% increased Physical Damage", + 18, + ModifierType.Augment, + ); + const stat2 = makeCalcStat( + "Bow Attacks fire # additional Arrows", + 1, + ModifierType.Augment, + ); + item.statsByType = [stat1, stat2]; + item.newMods = [ + { + info: { + type: ModifierType.Augment, + tags: [], + }, + stats: [stat1.sources[0].stat, stat2.sources[0].stat], + }, + ]; + + __testExports.applyAugmentSockets(item); + expect(item.augmentSockets.empty).toEqual(0); + expect(item.augmentSockets.current).toEqual(2); + expect(item.augmentSockets.normal).toEqual(2); + expect( + item.augmentSockets.augments.some( + (i) => i?.refName === "Greater Iron Rune", + ), + ).toBeTruthy(); + expect( + item.augmentSockets.augments.some( + (i) => i?.refName === "Countess Seske's Rune of Archery", + ), + ).toBeTruthy(); + }); + + it("should join multiline augments", () => { + const item = createTestItem(); + item.category = ItemCategory.Staff; + item.augmentSockets = { + empty: 0, + current: 2, + normal: 2, + augments: [null, null], + }; + + const stat1 = makeCalcStat( + "Meta Skills gain #% increased Energy", + 40, + ModifierType.Augment, + ); + const stat2 = makeCalcStat( + "#% increased Spirit", + -25, + ModifierType.Augment, + ); + const stat3 = makeCalcStat( + "#% increased Mana Regeneration Rate", + 20, + ModifierType.Augment, + ); + item.statsByType = [stat1, stat2, stat3]; + item.newMods = [ + { + info: { + type: ModifierType.Augment, + tags: [], + }, + stats: [ + stat1.sources[0].stat, + stat2.sources[0].stat, + stat3.sources[0].stat, + ], + }, + ]; + + __testExports.applyAugmentSockets(item); + expect(item.augmentSockets.empty).toEqual(0); + expect(item.augmentSockets.current).toEqual(2); + expect(item.augmentSockets.normal).toEqual(2); + expect( + item.augmentSockets.augments.some( + (i) => i?.refName === "Idol of the Martyr", + ), + ).toBeTruthy(); + expect( + item.augmentSockets.augments.some( + (i) => i?.refName === "Lesser Inspiration Rune", + ), + ).toBeTruthy(); + }); +}); diff --git a/renderer/specs/Parser/items.ts b/renderer/specs/Parser/items.ts index 52efc9f7..380e7f6e 100644 --- a/renderer/specs/Parser/items.ts +++ b/renderer/specs/Parser/items.ts @@ -1333,3 +1333,81 @@ CharmQuality.prefixCount = 1; CharmQuality.suffixCount = 1; // #endregion CharmQuality + +// #region BowThreeAugments +export const BowThreeAugments = new TestItem(`Item Class: Bows +Rarity: Rare +Oblivion Branch +Obliterator Bow +-------- +Quality: +20% (augmented) +Physical Damage: 288-534 (augmented) +Fire Damage: 47-92 (fire) +Critical Hit Chance: 8.45% (augmented) +Attacks per Second: 1.27 (augmented) +-------- +Requires: Level 78, 163 Dex +-------- +Sockets: S S S +-------- +Item Level: 80 +-------- +38% increased Physical Damage (rune) +Bow Attacks fire an additional Arrow (rune) +-------- +{ Implicit Modifier } +50% reduced Projectile Range +-------- +{ Prefix Modifier "Cruel" (Tier: 3) — Damage, Physical, Attack } +151(135-154)% increased Physical Damage +{ Desecrated Prefix Modifier "Razor-sharp" (Tier: 3) — Damage, Physical, Attack } +Adds 21(16-24) to 39(28-42) Physical Damage +{ Prefix Modifier "Blasting" (Tier: 3) — Damage, Elemental, Fire, Attack } +Adds 47(47-59) to 92(74-97) Fire Damage +{ Suffix Modifier "of Renown" (Tier: 2) — Attack, Speed } +15(14-16)% increased Attack Speed +{ Suffix Modifier "of the Vampire" (Tier: 1) — Life, Physical, Attack } +Leeches 9.6(9-9.9)% of Physical Damage as Life +{ Crafted Suffix Modifier "of Calamity" (Tier: 3) — Attack, Critical } ++3.45(3.11-3.8)% to Critical Hit Chance +-------- +Corrupted +`); + +BowThreeAugments.category = ItemCategory.Bow; +BowThreeAugments.rarity = ItemRarity.Rare; +BowThreeAugments.itemLevel = 80; +BowThreeAugments.quality = 20; +BowThreeAugments.weaponPHYSICAL = 411; +BowThreeAugments.weaponFIRE = 69.5; +BowThreeAugments.weaponELEMENTAL = BowThreeAugments.weaponFIRE; +BowThreeAugments.weaponCRIT = 8.45; +BowThreeAugments.weaponAS = 1.27; +BowThreeAugments.requires = { + level: 78, + str: 0, + dex: 163, + int: 0, +}; + +BowThreeAugments.info.refName = "Obliterator Bow"; +BowThreeAugments.sectionCount = 9; +BowThreeAugments.prefixCount = 3; +BowThreeAugments.suffixCount = 3; +BowThreeAugments.implicitCount = 1; +BowThreeAugments.augmentSockets = { + empty: 0, + current: 3, + normal: 2, + augments: [ + createEditorItem("Perfect Iron Rune", "20% increased Physical Damage", 20), + createEditorItem("Greater Iron Rune", "18% increased Physical Damage", 18), + createEditorItem( + "Countess Seske's Rune of Archery", + "Bow Attacks fire an additional Arrow", + 1, + ), + ], +}; + +// #endregion BowThreeAugments diff --git a/renderer/specs/helper.ts b/renderer/specs/helper.ts index 6bf7a557..d5973b6a 100644 --- a/renderer/specs/helper.ts +++ b/renderer/specs/helper.ts @@ -1,4 +1,9 @@ +import { STAT_BY_REF } from "@/assets/data"; +import { ItemCategory } from "@/parser"; +import { ParsedModifier } from "@/parser/advanced-mod-desc"; +import { ModifierType, StatCalculated } from "@/parser/modifiers"; import { createVirtualItem, ParsedItem } from "@/parser/ParsedItem"; +import { ParsedStat } from "@/parser/stat-translations"; import { FilterTag, StatFilter } from "@/web/price-check/filters/interfaces"; export function createTestStatFilter(): StatFilter { @@ -43,9 +48,71 @@ export function createTestItem(): ParsedItem { info: { refName: "test", namespace: "ITEM", + craftable: { + category: ItemCategory.Unknown, + }, name: "", icon: "", tags: [], }, }; } + +export function createParsedStat( + statRef: string, + value: number | [number, number], +): ParsedStat { + const stat = STAT_BY_REF(statRef)!; + + return { + stat, + translation: stat.matchers[0], + roll: { + unscalable: false, + dp: stat.dp || false, + value: Array.isArray(value) ? (value[0] + value[1]) / 2 : value, + min: Array.isArray(value) ? value[0] : value, + max: Array.isArray(value) ? value[1] : value, + }, + }; +} + +export function createParsedModifier( + statRef: string, + value: number | [number, number], + type: ModifierType = ModifierType.Explicit, +): ParsedModifier { + return { + info: { + type, + tags: [], + }, + stats: [createParsedStat(statRef, value)], + }; +} + +export function makeCalcStat( + ref: string, + value: number, + type: ModifierType = ModifierType.Explicit, +): StatCalculated { + const parsedStat = createParsedStat(ref, value); + + return { + stat: parsedStat.stat, + type, + sources: [ + { + contributes: parsedStat.roll, + modifier: { + info: { + type, + tags: [], + }, + stats: [parsedStat], + }, + stat: parsedStat, + }, + ], + }; +} diff --git a/renderer/src/parser/Parser.ts b/renderer/src/parser/Parser.ts index 573eab0b..64ebd20b 100644 --- a/renderer/src/parser/Parser.ts +++ b/renderer/src/parser/Parser.ts @@ -44,6 +44,7 @@ import { calcPropPercentile, QUALITY_STATS } from "./calc-q20"; import { AppConfig } from "@/web/Config"; import { buildEditorItems, getSavedAugments } from "./augment-builder"; import { useAugment } from "@/web/price-check/item-editor/augment"; +import { combinations } from "./utils"; type SectionParseResult = | "SECTION_PARSED" @@ -855,14 +856,16 @@ function parseAugmentSockets(section: string[], item: ParsedItem) { empty: 0, current, normal: categoryMax, - augments: Array(categoryMax).fill(null), + augments: Array(current).fill(null), }; } else { item.augmentSockets = { empty: 0, current, normal: categoryMax, - augments: Array(categoryMax).fill(null), + augments: Array(categoryMax > current ? categoryMax : current).fill( + null, + ), }; } @@ -1322,23 +1325,44 @@ function applyAugmentSockets(item: ParsedItem) { const augmentMods = item.newMods.filter( (mod) => mod.info.type === ModifierType.Augment, ); + console.log(augmentMods); const augmentStats = item.statsByType.filter( (calc) => calc.type === ModifierType.Augment, ); - const augments: Array = augmentMods - .map((mod) => { - const stat = augmentStats.find( - (stat) => stat.sources[0].stat === mod.stats[0], - ); - if (!stat) return []; + console.log(augmentStats); + + let statCombinations = combinations(augmentStats) + .filter((f) => f.length) + .toSorted((a, b) => b.length - a.length); + const augs: BaseType[][] = []; + for (let i = 0; i < statCombinations.length; ) { + const foundAugs = determineAugments(augmentMods[0], statCombinations[i]); + if (foundAugs.length) { + augs.push(foundAugs); + for (const combStat of statCombinations[i]) { + // drop any that we use here + statCombinations = statCombinations.filter( + (f) => f.find((s) => s === combStat) === undefined, + ); + } + i = 0; + continue; + } + i++; + } + + const augments: Array = augs + .map((augGroup) => { return buildEditorItems( - determineAugments(mod, stat), + augGroup, item.category ?? ItemCategory.Unknown, true, ); }) .flat(); + console.log(augments); + if ( augments.length < Math.max(item.augmentSockets.current, item.augmentSockets.normal) @@ -2133,21 +2157,58 @@ function modifiedBfs( function determineAugments( mod: ParsedModifier, - statCalc: StatCalculated, + statCalcs: StatCalculated[], ): BaseType[] { if (mod.info.type !== ModifierType.Augment) return []; - const augmentAppliedValue = statCalc.sources[0].contributes?.value; + const augmentAppliedValue = statCalcs + .map((s) => s.sources[0].contributes?.value) + .filter((v) => v !== undefined); - const augmentTradeId = statCalc.stat.trade.ids[ModifierType.Augment][0]; - const possibleAugments = AUGMENT_DATA_BY_TRADE_ID[augmentTradeId]; + // const augmentTradeId = statCalcs[0].stat.trade.ids[ModifierType.Augment][0]; + // const possibleAugments = AUGMENT_DATA_BY_TRADE_ID[augmentTradeId]; + + const allTradeIds = statCalcs.map( + (c) => c.stat.trade.ids[ModifierType.Augment][0], + ); + const allPossibleAugments = allTradeIds.map( + (id) => AUGMENT_DATA_BY_TRADE_ID[id], + ); + const augmentRefSets = allPossibleAugments.map( + (augGroup) => new Set(augGroup.map((a) => a.refName)), + ); + console.log(augmentRefSets); + + const morePossibleAugments = augmentRefSets.reduce((acc, set) => { + return set.intersection(acc); + }, augmentRefSets[0]); + console.log("morePossibleAugments", morePossibleAugments); + + // intersect all possible augments + const possibleAugments = allPossibleAugments + .map((augGroup) => + augGroup.filter((aug) => morePossibleAugments.has(aug.refName)), + ) + .find((f) => f.length); + + console.log(possibleAugments); if (!possibleAugments) return []; // something like "Raven-Touched" - if (!augmentAppliedValue) { + if (!augmentAppliedValue || !augmentAppliedValue.length) { const singleAugment = possibleAugments[0]; return [ITEM_BY_REF("ITEM", singleAugment.refName)![0]]; } + if (augmentAppliedValue.length > 1) { + const likelyAugment = possibleAugments.find( + (aug) => + new Set(aug.values).intersection(new Set(augmentAppliedValue)).size === + augmentAppliedValue.length, + ); + if (!likelyAugment) return []; + return [ITEM_BY_REF("ITEM", likelyAugment.refName)![0]]; + } + // // Calculate how many of this augment are in the item const availableAugmentValues = possibleAugments .map((augment) => { @@ -2166,7 +2227,7 @@ function determineAugments( // BFS to find all combinations with minimum count const likelyValues = - modifiedBfs(augmentAppliedValue, [], availableAugmentValues) ?? []; + modifiedBfs(augmentAppliedValue[0], [], availableAugmentValues) ?? []; return likelyValues.map((v) => { const augment = possibleAugments.find((aug) => @@ -2206,4 +2267,6 @@ export const __testExports = { parseTrials, determineAugments, modifiedBfs, + parseAugmentSockets, + applyAugmentSockets, }; diff --git a/renderer/src/parser/utils.ts b/renderer/src/parser/utils.ts new file mode 100644 index 00000000..696c0f8d --- /dev/null +++ b/renderer/src/parser/utils.ts @@ -0,0 +1,6 @@ +export function combinations(arr: T[]): T[][] { + return arr.reduce( + (acc, curr) => [...acc, ...acc.map((a) => [...a, curr])], + [[]], + ); +} diff --git a/renderer/src/web/background/Prices.ts b/renderer/src/web/background/Prices.ts index 1bb7a641..87948af2 100644 --- a/renderer/src/web/background/Prices.ts +++ b/renderer/src/web/background/Prices.ts @@ -356,6 +356,78 @@ export const usePoeninja = createGlobalState(() => { return currency; } + function toDivine(value: CurrencyValue): CurrencyValue { + if (value.currency === "div") return value; + if (xchgRate.value) { + if (xchgRateCurrency.value !== value.currency) { + throw new Error( + `Cannot convert ${value.currency} to div, current core currency is ${xchgRateCurrency.value}`, + ); + } + + return { + min: value.min / xchgRate.value, + max: value.max / xchgRate.value, + currency: "div", + }; + } + return value; + } + + function addPrice( + addend1: CurrencyValue, + addend2: CurrencyValue, + ): CurrencyValue { + const addend1Div = toDivine(addend1); + const addend2Div = toDivine(addend2); + + return autoCurrency([ + addend1Div.min + addend2Div.min, + addend1Div.max + addend2Div.max, + ]); + } + + function subtractPrice( + minuend: CurrencyValue, + subtrahend: CurrencyValue, + ): CurrencyValue { + const minuendDiv = toDivine(minuend); + const subtrahendDiv = toDivine(subtrahend); + + return autoCurrency([ + minuendDiv.min - subtrahendDiv.max, + minuendDiv.max - subtrahendDiv.min, + ]); + } + + function comparePrice( + left: CurrencyValue, + op: ">" | ">=" | "==" | "===" | "<=" | "<", + right: CurrencyValue, + ): boolean { + const leftDiv = toDivine(left); + const rightDiv = toDivine(right); + + switch (op) { + case ">": + return leftDiv.min > rightDiv.max; + case ">=": + return leftDiv.min >= rightDiv.max; + case "==": + return leftDiv.min === rightDiv.max && leftDiv.max === rightDiv.min; + case "===": + return ( + left.currency === right.currency && + leftDiv.min === rightDiv.max && + leftDiv.max === rightDiv.min + ); + case "<=": + return leftDiv.max <= rightDiv.min; + case "<": + return leftDiv.max < rightDiv.min; + } + } + setInterval(() => { load(); }, RETRY_INTERVAL_MS); @@ -384,6 +456,10 @@ export const usePoeninja = createGlobalState(() => { initialLoading: () => isLoading.value && !PRICES_DB.length, availableCoreCurrencies: readonly(availableCoreCurrencies), ITEM_DROP, + // math + addPrice, + subtractPrice, + comparePrice, }; }); diff --git a/renderer/src/web/price-check/trade/ExtractionValue.vue b/renderer/src/web/price-check/trade/ExtractionValue.vue new file mode 100644 index 00000000..8ea3536a --- /dev/null +++ b/renderer/src/web/price-check/trade/ExtractionValue.vue @@ -0,0 +1,116 @@ + + diff --git a/renderer/src/web/price-check/trade/TradeListing.vue b/renderer/src/web/price-check/trade/TradeListing.vue index fda4b4a8..bfc16dca 100644 --- a/renderer/src/web/price-check/trade/TradeListing.vue +++ b/renderer/src/web/price-check/trade/TradeListing.vue @@ -92,6 +92,9 @@ + + +
-
+
- +
~ {{ @@ -89,6 +96,14 @@ export default defineComponent({ type: Object as PropType, default: undefined, }, + showImg: { + type: Boolean, + default: true, + }, + showArrow: { + type: Boolean, + default: true, + }, }, setup(props) { const minText = computed(() =>