From b9e0346bafcdcc41392da77577cdc0de460e8e53 Mon Sep 17 00:00:00 2001 From: Ecmelt Date: Fri, 6 Feb 2026 18:09:18 +0000 Subject: [PATCH 1/4] Put quantity 0 for bits that are fulfilled elsewhere --- src/calculateTreeQuantity.ts | 23 +++++++++++++----- src/cheapestTree.ts | 45 ++++++++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/calculateTreeQuantity.ts b/src/calculateTreeQuantity.ts index 1eb07c0..088d85a 100755 --- a/src/calculateTreeQuantity.ts +++ b/src/calculateTreeQuantity.ts @@ -3,22 +3,27 @@ import { RecipeTreeWithCraftFlags, RecipeTree, RecipeTreeWithQuantity } from './ export function calculateTreeQuantity( amount: number, tree: RecipeTreeWithCraftFlags, - availableItems?: Record + availableItems?: Record, + ignoredBitIds?: Array ): RecipeTreeWithCraftFlags export function calculateTreeQuantity( amount: number, tree: RecipeTree, - availableItems?: Record + availableItems?: Record, + ignoredBitIds?: Array ): RecipeTreeWithQuantity export function calculateTreeQuantity( amount: number, tree: RecipeTree | RecipeTreeWithCraftFlags, - availableItems: Record = {} + availableItems: Record = {}, + ignoredBitIds: Array = [] ) { // Make sure that we don't modify the passed-in object // We still want to work with a reference in the actual calculation // since the availableItems are a shared state for all sub-recipes - return calculateTreeQuantityInner(amount, tree, { ...availableItems }) + return calculateTreeQuantityInner(amount, tree, { ...availableItems }, false, 0, [ + ...ignoredBitIds, + ]) } // Go through a recipe tree and set 'totalQuantity' based on the @@ -28,13 +33,18 @@ function calculateTreeQuantityInner( tree: RecipeTree | RecipeTreeWithCraftFlags, availableItems: Record, ignoreAvailable = false, - nesting = 0 + nesting = 0, + ignoredBitIds: Array ): RecipeTreeWithCraftFlags | RecipeTreeWithQuantity { const output = tree.output || 1 // Calculate the total quantity needed let treeQuantity = amount * tree.quantity + if (typeof tree.achievement_bit === 'number') { + ignoredBitIds.includes(tree.id) ? (treeQuantity = 0) : ignoredBitIds.push(tree.id) + } + // Round amount to nearest multiple of the tree output treeQuantity = Math.ceil(treeQuantity / output) * output const totalQuantity = Math.round(treeQuantity) @@ -69,7 +79,8 @@ function calculateTreeQuantityInner( component, availableItems, ignoreAvailable, - ++nesting + ++nesting, + ignoredBitIds ) }) diff --git a/src/cheapestTree.ts b/src/cheapestTree.ts index 9a24915..78c6cba 100755 --- a/src/cheapestTree.ts +++ b/src/cheapestTree.ts @@ -21,12 +21,14 @@ export function cheapestTree( '103049': '0', } ): RecipeTreeWithCraftFlags { - tree = applyEfficiencyTiersToTree(tree, userEfficiencyTiers) + const ignoredBitIds: Array = [] + tree = applyEfficiencyTiersToTree(tree, userEfficiencyTiers, ignoredBitIds) if (valueOwnItems) { const treeWithQuantityWithoutAvailableItems = calculateTreeQuantity( amount, tree as RecipeTree, - {} + {}, + ignoredBitIds ) const treeWithPriceWithoutAvailableItems = calculateTreePrices( treeWithQuantityWithoutAvailableItems, @@ -40,7 +42,12 @@ export function cheapestTree( } // Adjust the tree total and used quantities - const treeWithQuantity = calculateTreeQuantity(amount, tree as RecipeTree, availableItems) + const treeWithQuantity = calculateTreeQuantity( + amount, + tree as RecipeTree, + availableItems, + ignoredBitIds + ) // Set the initial craft flags based on the subtree prices const treeWithPrices = calculateTreePrices(treeWithQuantity, itemPrices) @@ -54,7 +61,8 @@ export function cheapestTree( const treeWithQuantityPostFlags = calculateTreeQuantity( amount, treeWithCraftFlags, - availableItems + availableItems, + ignoredBitIds ) // Recalculate the correct tree price @@ -110,10 +118,18 @@ function disableCraftForItemIds( function applyEfficiencyTiersToTree( tree: Omit & { id: number | null }, // FIXME Not sure why this can be null - userEfficiencyTiers: Record + userEfficiencyTiers: Record, + ignoredBitIds: Array, + bitItemIds = new Set(), + normalItemIds = new Set(), + isRootNode = true ): NestedRecipe { - const id = tree.id ? tree.id.toString() : '' + let id = '' + if (tree.id && (tree.type as 'Recipe' | 'Currency' | 'Item') !== 'Currency') { + id = tree.id.toString() + typeof tree.achievement_bit === 'number' ? bitItemIds.add(tree.id) : normalItemIds.add(tree.id) + } if ( ['102306', '102205', '103049'].includes(id) && tree.merchant && @@ -155,10 +171,25 @@ function applyEfficiencyTiersToTree( tree = { ...tree, components: tree.components.map((component) => - applyEfficiencyTiersToTree(component as NestedRecipe, userEfficiencyTiers) + applyEfficiencyTiersToTree( + component as NestedRecipe, + userEfficiencyTiers, + ignoredBitIds, + bitItemIds, + normalItemIds, + false + ) ), } } + if (isRootNode) { + bitItemIds.forEach((id) => { + if (normalItemIds.has(id)) { + ignoredBitIds.push(id) + } + }) + } + return tree as NestedRecipe } From d958627038951ac27d576bc9eb31c346b96ade8c Mon Sep 17 00:00:00 2001 From: Ecmelt Date: Sun, 8 Feb 2026 00:08:49 +0000 Subject: [PATCH 2/4] Better clarity with namings, added a quick test with some scenarios --- src/calculateTreeQuantity.ts | 14 +-- src/cheapestTree.ts | 133 +++++++++++++++++----------- tests/calculateTreeQuantity.spec.ts | 109 +++++++++++++++++++++++ 3 files changed, 195 insertions(+), 61 deletions(-) diff --git a/src/calculateTreeQuantity.ts b/src/calculateTreeQuantity.ts index 088d85a..0947317 100755 --- a/src/calculateTreeQuantity.ts +++ b/src/calculateTreeQuantity.ts @@ -4,25 +4,25 @@ export function calculateTreeQuantity( amount: number, tree: RecipeTreeWithCraftFlags, availableItems?: Record, - ignoredBitIds?: Array + ignoredBitItemIds?: Array ): RecipeTreeWithCraftFlags export function calculateTreeQuantity( amount: number, tree: RecipeTree, availableItems?: Record, - ignoredBitIds?: Array + ignoredBitItemIds?: Array ): RecipeTreeWithQuantity export function calculateTreeQuantity( amount: number, tree: RecipeTree | RecipeTreeWithCraftFlags, availableItems: Record = {}, - ignoredBitIds: Array = [] + ignoredBitItemIds: Array = [] ) { // Make sure that we don't modify the passed-in object // We still want to work with a reference in the actual calculation // since the availableItems are a shared state for all sub-recipes return calculateTreeQuantityInner(amount, tree, { ...availableItems }, false, 0, [ - ...ignoredBitIds, + ...ignoredBitItemIds, ]) } @@ -34,7 +34,7 @@ function calculateTreeQuantityInner( availableItems: Record, ignoreAvailable = false, nesting = 0, - ignoredBitIds: Array + ignoredBitItemIds: Array ): RecipeTreeWithCraftFlags | RecipeTreeWithQuantity { const output = tree.output || 1 @@ -42,7 +42,7 @@ function calculateTreeQuantityInner( let treeQuantity = amount * tree.quantity if (typeof tree.achievement_bit === 'number') { - ignoredBitIds.includes(tree.id) ? (treeQuantity = 0) : ignoredBitIds.push(tree.id) + ignoredBitItemIds.includes(tree.id) ? (treeQuantity = 0) : ignoredBitItemIds.push(tree.id) } // Round amount to nearest multiple of the tree output @@ -80,7 +80,7 @@ function calculateTreeQuantityInner( availableItems, ignoreAvailable, ++nesting, - ignoredBitIds + ignoredBitItemIds ) }) diff --git a/src/cheapestTree.ts b/src/cheapestTree.ts index 78c6cba..7f1de46 100755 --- a/src/cheapestTree.ts +++ b/src/cheapestTree.ts @@ -21,14 +21,14 @@ export function cheapestTree( '103049': '0', } ): RecipeTreeWithCraftFlags { - const ignoredBitIds: Array = [] - tree = applyEfficiencyTiersToTree(tree, userEfficiencyTiers, ignoredBitIds) + const ignoredBitItemIds: Array = [] + tree = initialTreeChecks(tree, userEfficiencyTiers, ignoredBitItemIds) if (valueOwnItems) { const treeWithQuantityWithoutAvailableItems = calculateTreeQuantity( amount, tree as RecipeTree, {}, - ignoredBitIds + ignoredBitItemIds ) const treeWithPriceWithoutAvailableItems = calculateTreePrices( treeWithQuantityWithoutAvailableItems, @@ -46,7 +46,7 @@ export function cheapestTree( amount, tree as RecipeTree, availableItems, - ignoredBitIds + ignoredBitItemIds ) // Set the initial craft flags based on the subtree prices @@ -62,7 +62,7 @@ export function cheapestTree( amount, treeWithCraftFlags, availableItems, - ignoredBitIds + ignoredBitItemIds ) // Recalculate the correct tree price @@ -116,65 +116,25 @@ function disableCraftForItemIds( return tree } -function applyEfficiencyTiersToTree( - tree: Omit & { id: number | null }, // FIXME Not sure why this can be null +export function initialTreeChecks( + tree: NestedRecipe, userEfficiencyTiers: Record, - ignoredBitIds: Array, + ignoredBitItemIds: Array, bitItemIds = new Set(), normalItemIds = new Set(), isRootNode = true ): NestedRecipe { - let id = '' - - if (tree.id && (tree.type as 'Recipe' | 'Currency' | 'Item') !== 'Currency') { - id = tree.id.toString() - typeof tree.achievement_bit === 'number' ? bitItemIds.add(tree.id) : normalItemIds.add(tree.id) - } - if ( - ['102306', '102205', '103049'].includes(id) && - tree.merchant && - tree.merchant.name.includes('Homestead Refinement') - ) { - const efficiencyTier = Number(userEfficiencyTiers[id]) - - if (efficiencyTier > 0) { - const component = { ...tree.components[0] } - - // Each efficiency tier lowers input by 50%, if it drops below one then doubles output - component.quantity = component.quantity / (efficiencyTier * 2) - - // Bug: Onions are discounted by 75% with first tier - if (component.id === 12142) { - component.quantity = efficiencyTier === 1 ? 1 : 0.5 - } - - // Bug: Potatoes are not discounted with first tier - if (component.id === 12135) { - component.quantity = efficiencyTier === 1 ? 8 : 4 - } - - let updatedTree = { ...tree, output: component.quantity < 1 ? tree.output * 2 : tree.output } - - // Bug: Iron ore output also halves with second tier - if (component.id === 19699 && efficiencyTier === 2) { - updatedTree.output = updatedTree.output / 2 - } - - component.quantity = component.quantity < 1 ? 1 : component.quantity - updatedTree = { ...updatedTree, components: [component, ...tree.components.slice(1)] } - - tree = updatedTree - } - } + collectItemDataForIgnoringBits(tree, bitItemIds, normalItemIds) + tree = applyEfficiencyTiersToTree(tree, userEfficiencyTiers) if ('components' in tree && Array.isArray(tree.components)) { tree = { ...tree, components: tree.components.map((component) => - applyEfficiencyTiersToTree( + initialTreeChecks( component as NestedRecipe, userEfficiencyTiers, - ignoredBitIds, + ignoredBitItemIds, bitItemIds, normalItemIds, false @@ -186,10 +146,75 @@ function applyEfficiencyTiersToTree( if (isRootNode) { bitItemIds.forEach((id) => { if (normalItemIds.has(id)) { - ignoredBitIds.push(id) + ignoredBitItemIds.push(id) } }) } - return tree as NestedRecipe + return tree +} + +function collectItemDataForIgnoringBits( + tree: NestedRecipe, + bitItemIds: Set, + normalItemIds: Set +) { + if (!tree.id) return + if ((tree.type as 'Recipe' | 'Currency' | 'Item') === 'Currency') return + + if (typeof tree.achievement_bit === 'number') { + bitItemIds.add(tree.id) + } else { + normalItemIds.add(tree.id) + } +} + +function applyEfficiencyTiersToTree( + tree: NestedRecipe, + userEfficiencyTiers: Record +): NestedRecipe { + if (!tree.id) return tree + const id = tree.id ? tree.id.toString() : '' + + if ( + !['102306', '102205', '103049'].includes(id) || + !tree.merchant || + !tree.merchant.name.includes('Homestead Refinement') + ) { + return tree + } + + const efficiencyTier = Number(userEfficiencyTiers[id]) + if (!(efficiencyTier > 0)) return tree + + const component = { ...tree.components[0] } + + // Each efficiency tier lowers input by 50%, if it drops below one then doubles output + component.quantity = component.quantity / (efficiencyTier * 2) + + // Bug: Onions are discounted by 75% with first tier + if (component.id === 12142) { + component.quantity = efficiencyTier === 1 ? 1 : 0.5 + } + + // Bug: Potatoes are not discounted with first tier + if (component.id === 12135) { + component.quantity = efficiencyTier === 1 ? 8 : 4 + } + + let updatedTree = { + ...tree, + output: component.quantity < 1 ? tree.output * 2 : tree.output, + } + + // Bug: Iron ore output also halves with second tier + if (component.id === 19699 && efficiencyTier === 2) { + updatedTree.output = updatedTree.output / 2 + } + + component.quantity = component.quantity < 1 ? 1 : component.quantity + updatedTree = { ...updatedTree, components: [component, ...tree.components.slice(1)] } + tree = updatedTree + + return tree } diff --git a/tests/calculateTreeQuantity.spec.ts b/tests/calculateTreeQuantity.spec.ts index e211bc9..b793bef 100755 --- a/tests/calculateTreeQuantity.spec.ts +++ b/tests/calculateTreeQuantity.spec.ts @@ -1,3 +1,4 @@ +import { initialTreeChecks } from '../src/cheapestTree' import { calculateTreeQuantity } from '../src/calculateTreeQuantity' import { RecipeTree, RecipeTreeWithCraftFlags } from '../src/types' @@ -381,4 +382,112 @@ describe('calculateTreeQuantity (used quantity)', () => { expect(calculateTreeQuantity(1, recipeTree, availableItems)).toMatchSnapshot() }) + + it('handles achievement bit items correctly', () => { + const recipeTree: RecipeTree = { + ...RECIPE_PARTIAL, + id: 100, + quantity: 1, + output: 1, + components: [ + { + ...ITEM_PARTIAL, + id: 55, + quantity: 1, + output: 1, + achievement_bit: 0, + }, + { + ...RECIPE_PARTIAL, + id: 200, + quantity: 1, + output: 1, + components: [ + { + ...ITEM_PARTIAL, + id: 55, + quantity: 1, + output: 1, + achievement_bit: 0, + }, + ], + }, + { + ...ITEM_PARTIAL, + id: 55, + quantity: 2, + output: 1, + }, + { + ...ITEM_PARTIAL, + id: 56, + quantity: 1, + output: 1, + achievement_bit: 1, + }, + { + ...ITEM_PARTIAL, + id: 56, + quantity: 1, + output: 1, + achievement_bit: 1, + }, + { + ...ITEM_PARTIAL, + id: 999, + quantity: 1, + output: 1, + }, + { + ...ITEM_PARTIAL, + id: 999, + quantity: 3, + output: 1, + }, + ], + } + + const ignoredBitItemIds: Array = [] + initialTreeChecks( + recipeTree as any, + { '102306': '0', '102205': '0', '103049': '0' }, + ignoredBitItemIds + ) + + const adjusted = calculateTreeQuantity(1, recipeTree, {}, ignoredBitItemIds) + + const [ + firstBitTopComponent, + firstBitComponentwithNestedBit, + normalItemWithFirstBitItemId, + secondBitTopComponent, + secondBitTopComponentDuplicate, + normalItemWithNoBitVersion, + normalItemWithNoBitVersionDuplicate, + ] = adjusted.components as Array + + const firstBitInsideComponent = firstBitComponentwithNestedBit.components[0] + + // Bit exists as real item elsewhere, zeroed + expect(firstBitTopComponent.totalQuantity).toBe(0) + expect(firstBitTopComponent.usedQuantity).toBe(0) + + // Bit exists as real item elsewhere, zeroed in deeper nesting + expect(firstBitInsideComponent.totalQuantity).toBe(0) + expect(firstBitInsideComponent.usedQuantity).toBe(0) + + // Real item is not zeroed when bit version exists + expect(normalItemWithFirstBitItemId.totalQuantity).toBe(2) + expect(normalItemWithFirstBitItemId.usedQuantity).toBe(2) + + // Duplicate bit items, first one is kept + expect(secondBitTopComponent.totalQuantity).toBe(1) + + // Duplicate bit items, second one is zeroed + expect(secondBitTopComponentDuplicate.totalQuantity).toBe(0) + + // Real duplicate items are unaffected + expect(normalItemWithNoBitVersion.totalQuantity).toBe(1) + expect(normalItemWithNoBitVersionDuplicate.totalQuantity).toBe(3) + }) }) From 65440c5823344044393083c128f8d5bc99ce9103 Mon Sep 17 00:00:00 2001 From: queicherius Date: Mon, 9 Feb 2026 17:01:56 +0000 Subject: [PATCH 3/4] Bump subpackage --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index d1f15ce..c08d636 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@devoxa/eslint-config": "2.0.5", "@devoxa/flocky": "^1.3.1", "@devoxa/prettier-config": "1.0.0", - "@gw2efficiency/recipe-nesting": "^3.3.0", + "@gw2efficiency/recipe-nesting": "^3.4.0", "@types/jest": "27.0.1", "@types/node": "15.12.5", "eslint": "7.32.0", diff --git a/yarn.lock b/yarn.lock index 106c9ca..6ddcc3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -335,10 +335,10 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@gw2efficiency/recipe-nesting@^3.3.0": - version "3.3.0" - resolved "https://registry.yarnpkg.com/@gw2efficiency/recipe-nesting/-/recipe-nesting-3.3.0.tgz#4d97dcb2e2c126cdcb1c93afb9cfed28d027ece4" - integrity sha512-tbbO/0XNyHeSVCDnYN0dNxRYeOWMN0MDoEqjyEqxMQ36EuchR0D1qgcHGA56nNc7gI9okfLhxZWslTB7kuivmw== +"@gw2efficiency/recipe-nesting@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@gw2efficiency/recipe-nesting/-/recipe-nesting-3.4.0.tgz#43eeea9e43b44aaa24d8f9e7881e095796b33453" + integrity sha512-p4y8EmJpX9A5jNk6MhUwHTvESFnrjzfsKW5DpGthQVVUvMWRlammo6LG7mIptAeHKuHf8TcAXTf8FvxThe/B+w== dependencies: "@devoxa/flocky" "^1.3.1" From 8e42057a866aedb2aea7cb8db9405dc2b9252186 Mon Sep 17 00:00:00 2001 From: queicherius Date: Mon, 9 Feb 2026 19:38:02 +0000 Subject: [PATCH 4/4] Fix types --- tests/calculateTreeQuantity.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/calculateTreeQuantity.spec.ts b/tests/calculateTreeQuantity.spec.ts index b793bef..08e71bb 100755 --- a/tests/calculateTreeQuantity.spec.ts +++ b/tests/calculateTreeQuantity.spec.ts @@ -1,6 +1,7 @@ import { initialTreeChecks } from '../src/cheapestTree' import { calculateTreeQuantity } from '../src/calculateTreeQuantity' import { RecipeTree, RecipeTreeWithCraftFlags } from '../src/types' +import { NestedRecipe } from '@gw2efficiency/recipe-nesting' const RECIPE_PARTIAL = { id: 1, @@ -449,24 +450,25 @@ describe('calculateTreeQuantity (used quantity)', () => { const ignoredBitItemIds: Array = [] initialTreeChecks( - recipeTree as any, + recipeTree as unknown as NestedRecipe, { '102306': '0', '102205': '0', '103049': '0' }, ignoredBitItemIds ) const adjusted = calculateTreeQuantity(1, recipeTree, {}, ignoredBitItemIds) + type Component = { totalQuantity: number; usedQuantity: 0 } const [ firstBitTopComponent, - firstBitComponentwithNestedBit, + firstBitComponentWithNestedBit, normalItemWithFirstBitItemId, secondBitTopComponent, secondBitTopComponentDuplicate, normalItemWithNoBitVersion, normalItemWithNoBitVersionDuplicate, - ] = adjusted.components as Array + ] = adjusted.components as Array }> - const firstBitInsideComponent = firstBitComponentwithNestedBit.components[0] + const firstBitInsideComponent = firstBitComponentWithNestedBit.components[0] // Bit exists as real item elsewhere, zeroed expect(firstBitTopComponent.totalQuantity).toBe(0)