import { PlatformCounts, REGION_KEYS, initPlatformCounts, mergePlatformCounts, singlePlatformCount } from './platform' type GoldAdvantageTag = 'ahead' | 'behind' | 'even' // Item tags that can be derived from purchase patterns type ItemTag = 'ahead' | 'behind' | 'region_euw' | 'region_eun' | 'region_na' | 'region_kr' type ItemTree = { data: number | undefined count: number children: Array // Gold advantage tracking boughtWhen: { aheadCount: number behindCount: number evenCount: number meanGold: number } // Platform tracking platformCount: PlatformCounts // Derived tags for display tags: Array } function treeInit(): ItemTree { return { data: undefined, count: 0, children: [], boughtWhen: { aheadCount: 0, behindCount: 0, evenCount: 0, meanGold: 0 }, platformCount: initPlatformCounts(), tags: [] } } /* * Merge a node with an item tree */ function nodeMerge(itemtree: ItemTree, node: ItemTree) { const item = node.data const count = node.count let next: ItemTree | null = null // Try to find an existing node in this tree level with same item for (const child of itemtree.children) { if (child.data == item) { child.count += 1 child.boughtWhen.aheadCount += node.boughtWhen.aheadCount child.boughtWhen.evenCount += node.boughtWhen.evenCount child.boughtWhen.behindCount += node.boughtWhen.behindCount // Merge platform counts mergePlatformCounts(child.platformCount, node.platformCount) next = child break } } // If not found, add item node at this level if (next == null && item !== undefined) { next = { data: item, count: count, children: [], boughtWhen: { ...node.boughtWhen }, platformCount: { ...node.platformCount }, tags: [] } itemtree.children.push(next) } return next! } /* * Merge a full build path with an existing item tree */ function treeMerge( itemtree: ItemTree, items: Array<{ itemId: number; goldAdvantage: GoldAdvantageTag; platform?: string }> ) { let current = itemtree for (const item of items) { current = nodeMerge(current, { data: item.itemId, count: 1, boughtWhen: { aheadCount: item.goldAdvantage == 'ahead' ? 1 : 0, evenCount: item.goldAdvantage == 'even' ? 1 : 0, behindCount: item.goldAdvantage == 'behind' ? 1 : 0, meanGold: 0 }, children: [], platformCount: item.platform ? singlePlatformCount(item.platform) : initPlatformCounts(), tags: [] }) } } function treeCutBranches(itemtree: ItemTree, thresholdCount: number, thresholdPerc: number) { // Remove branches that are above threshold count while (itemtree.children.length > thresholdCount) { const leastUsedBranch = itemtree.children.reduce( (a, b) => (Math.min(a.count, b.count) == a.count ? a : b), { data: undefined, count: +Infinity, children: [], boughtWhen: { aheadCount: 0, behindCount: 0, evenCount: 0, meanGold: 0 }, platformCount: initPlatformCounts(), tags: [] } ) itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1) } // Remove branches that are of too low usage const toRemove: Array = [] for (const child of itemtree.children) { if (child.count / itemtree.count < thresholdPerc) { toRemove.push(child) } } for (const tr of toRemove) { itemtree.children.splice(itemtree.children.indexOf(tr), 1) } itemtree.children.map(x => treeCutBranches(x, thresholdCount, thresholdPerc)) } function treeSort(itemtree: ItemTree) { itemtree.children.sort((a, b) => b.count - a.count) for (const item of itemtree.children) { treeSort(item) } } /* * Deep clone an ItemTree */ function treeClone(tree: ItemTree): ItemTree { return { data: tree.data, count: tree.count, children: tree.children.map(child => treeClone(child)), boughtWhen: { aheadCount: tree.boughtWhen.aheadCount, behindCount: tree.boughtWhen.behindCount, evenCount: tree.boughtWhen.evenCount, meanGold: tree.boughtWhen.meanGold }, platformCount: { ...tree.platformCount }, tags: [...tree.tags] } } /* * Merge two ItemTrees into one */ function treeMergeTree(t1: ItemTree, t2: ItemTree): ItemTree { // Merge counts for the root t1.count += t2.count // Merge platform counts mergePlatformCounts(t1.platformCount, t2.platformCount) // Merge boughtWhen t1.boughtWhen.aheadCount += t2.boughtWhen.aheadCount t1.boughtWhen.evenCount += t2.boughtWhen.evenCount t1.boughtWhen.behindCount += t2.boughtWhen.behindCount // Merge children from t2 into t1 for (const child2 of t2.children) { // Find matching child in t1 (same data value) const matchingChild = t1.children.find(child1 => child1.data === child2.data) if (matchingChild) { // Recursively merge matching children treeMergeTree(matchingChild, child2) } else { // Add a deep copy of child2 to t1 t1.children.push(treeClone(child2)) } } return t1 } /* * Flatten an ItemTree into a Set of item numbers */ function treeToSet(itemtree: ItemTree): Set { const items: Set = new Set() function traverse(node: ItemTree) { if (node.data !== undefined) { items.add(node.data) } for (const child of node.children) { traverse(child) } } traverse(itemtree) return items } /* * Calculate similarity between two trees as item sets. * Returns a number between 0 and 1, where 1 means identical and 0 means completely different. * Uses Jaccard similarity: |A ∩ B| / |A ∪ B| * Sets included in one another will have similarity close to 1. */ function areTreeSimilars(t1: ItemTree, t2: ItemTree): number { const set1 = treeToSet(t1) const set2 = treeToSet(t2) // Handle empty sets if (set1.size === 0 && set2.size === 0) { return 1.0 } // Calculate intersection const intersection = new Set() for (const item of Array.from(set1)) { if (set2.has(item)) { intersection.add(item) } } // Calculate union const union = new Set() for (const item of Array.from(set1)) { union.add(item) } for (const item of Array.from(set2)) { union.add(item) } // Jaccard similarity: |intersection| / |union| const similarity = intersection.size / Math.min(set1.size, set2.size) // Ensure result is between 0 and 1 return Math.max(0, Math.min(1, similarity)) } /* * Derive tags for an item based on purchase patterns * Tags are derived when a specific condition is dominant (>= 60% threshold) * For region tags, we compare against expected distribution to find items that are * significantly more popular in a region than expected */ function deriveTags(node: ItemTree, expectedRegionDistribution?: PlatformCounts): void { const tags: Array = [] // Derive gold situation tags const totalGoldSituations = node.boughtWhen.aheadCount + node.boughtWhen.behindCount + node.boughtWhen.evenCount if (totalGoldSituations > 0) { const aheadPct = node.boughtWhen.aheadCount / totalGoldSituations const behindPct = node.boughtWhen.behindCount / totalGoldSituations // Only tag if there's a dominant pattern (>= 60%) if (aheadPct >= 0.6) { tags.push('ahead') } else if (behindPct >= 0.6) { tags.push('behind') } } // Derive region tags by comparing against expected distribution const totalRegionCount = REGION_KEYS.reduce((sum, key) => sum + node.platformCount[key], 0) if (totalRegionCount > 0 && expectedRegionDistribution) { const totalExpected = REGION_KEYS.reduce((sum, key) => sum + expectedRegionDistribution[key], 0) if (totalExpected > 0) { // Tag if the item is significantly more popular in a region (>= 1.5x expected rate) // and has a minimum absolute percentage (>= 10%) const SIGNIFICANCE_THRESHOLD = 1.5 const MINIMUM_PCT = 0.1 // Loop through all regions to derive tags const regionTags: Array<{ key: keyof PlatformCounts; tag: ItemTag }> = [ { key: 'euw', tag: 'region_euw' }, { key: 'eun', tag: 'region_eun' }, { key: 'na', tag: 'region_na' }, { key: 'kr', tag: 'region_kr' } ] for (const { key, tag } of regionTags) { const expectedPct = expectedRegionDistribution[key] / totalExpected const actualPct = node.platformCount[key] / totalRegionCount if (actualPct >= expectedPct * SIGNIFICANCE_THRESHOLD && actualPct >= MINIMUM_PCT) { tags.push(tag) } } } } node.tags = tags // Recursively derive tags for children for (const child of node.children) { deriveTags(child, expectedRegionDistribution) } } /* * Apply tag derivation to an entire tree * expectedRegionDistribution: the total region distribution for the champion/lane, * used to detect items that are region-specific */ function treeDeriveTags(itemtree: ItemTree, expectedRegionDistribution?: PlatformCounts): void { deriveTags(itemtree, expectedRegionDistribution) } export { ItemTree, PlatformCounts, GoldAdvantageTag, ItemTag, treeMerge, treeInit, treeCutBranches, treeSort, treeMergeTree, areTreeSimilars, treeDeriveTags }