From 12fc703a73ef57de625aa9d3b7fd1d6c2488ecd9 Mon Sep 17 00:00:00 2001 From: Mijago Date: Sun, 27 Jul 2025 09:03:10 +0200 Subject: [PATCH 1/4] feat: initial progress for available-items.service.ts --- .../slot-limitation-selection.component.ts | 111 +--- src/app/services/available-items.service.ts | 562 ++++++++++++++++++ src/app/services/inventory.service.ts | 149 +---- src/app/services/results-builder.worker.ts | 93 +-- 4 files changed, 604 insertions(+), 311 deletions(-) create mode 100644 src/app/services/available-items.service.ts diff --git a/src/app/components/authenticated-v2/settings/desired-mod-limit-selection/slot-limitation-selection/slot-limitation-selection.component.ts b/src/app/components/authenticated-v2/settings/desired-mod-limit-selection/slot-limitation-selection/slot-limitation-selection.component.ts index 9a4a04cb..d89e31e0 100644 --- a/src/app/components/authenticated-v2/settings/desired-mod-limit-selection/slot-limitation-selection/slot-limitation-selection.component.ts +++ b/src/app/components/authenticated-v2/settings/desired-mod-limit-selection/slot-limitation-selection/slot-limitation-selection.component.ts @@ -26,13 +26,12 @@ import { } from "../../../../../data/enum/armor-stat"; import { DestinyClass } from "bungie-api-ts/destiny2"; import { InventoryService } from "../../../../../services/inventory.service"; -import { DatabaseService } from "../../../../../services/database.service"; import { Subject } from "rxjs"; import { takeUntil } from "rxjs/operators"; import { environment } from "../../../../../../environments/environment"; import { ItemIconServiceService } from "src/app/services/item-icon-service.service"; import { ModUrl } from "../../../results/table-mod-display/table-mod-display.component"; -import { ArmorSystem } from "src/app/data/types/IManifestArmor"; +import { AvailableItemsService } from "../../../../../services/available-items.service"; @Component({ selector: "app-slot-limitation-selection", @@ -54,7 +53,6 @@ export class SlotLimitationSelectionComponent implements OnInit, OnDestroy { @Output() possible: EventEmitter = new EventEmitter(); - fixedExoticInThisSlot: boolean = false; isPossible: boolean = true; configSelectedClass: DestinyClass = DestinyClass.Unknown; configAssumeLegendaryIsArtifice: boolean = false; @@ -102,76 +100,9 @@ export class SlotLimitationSelectionComponent implements OnInit, OnDestroy { public config: ConfigurationService, public inventory: InventoryService, private iconService: ItemIconServiceService, - private db: DatabaseService + private availableItems: AvailableItemsService ) {} - public async runPossibilityCheck() { - const mustCheckArmorPerk = this.armorPerkLock && this.armorPerk != ArmorPerkOrSlot.Any; - - let results = 0; - if (mustCheckArmorPerk) { - // check if the current slot is locked to a specific exotic - if (this.fixedExoticInThisSlot) { - this.configSelectedExotic.forEach(async (exoticHash) => { - var exotics = await this.db.inventoryArmor - .where("clazz") - .equals(this.configSelectedClass) - .and( - (f) => - f.perk == this.armorPerk || - (this.armorPerk == ArmorPerkOrSlot.SlotArtifice && - this.configAssumeExoticIsArtifice && - f.isExotic && - f.armorSystem == ArmorSystem.Armor2) || - (this.armorPerk == ArmorPerkOrSlot.SlotArtifice && - this.configAssumeLegendaryIsArtifice && - !f.isExotic && - f.armorSystem == ArmorSystem.Armor2) || - (this.armorPerk == ArmorPerkOrSlot.SlotArtifice && - this.configAssumeClassItemIsArtifice && - f.slot == ArmorSlot.ArmorSlotClass && - !f.isExotic && - f.armorSystem === ArmorSystem.Armor2) - ) - .and((f) => f.hash == exoticHash) - .and((f) => f.isExotic == 1) - .count(); - results += exotics; - this.isPossible = results > 0; - this.possible.next(this.isPossible); - }); - } else { - results += await this.db.inventoryArmor - .where("clazz") - .equals(this.configSelectedClass) - .and((f) => this.configSelectedExoticSum == 0 || !f.isExotic) - .and((f) => f.slot == this.slot) - .and( - (f) => - f.perk == this.armorPerk || - (this.armorPerk == ArmorPerkOrSlot.SlotArtifice && - this.configAssumeExoticIsArtifice && - f.isExotic && - f.armorSystem == ArmorSystem.Armor2) || - (this.armorPerk == ArmorPerkOrSlot.SlotArtifice && - this.configAssumeLegendaryIsArtifice && - !f.isExotic && - f.armorSystem == ArmorSystem.Armor2) || - (this.armorPerk == ArmorPerkOrSlot.SlotArtifice && - this.configAssumeClassItemIsArtifice && - f.slot == ArmorSlot.ArmorSlotClass && - !f.isExotic && - f.armorSystem === ArmorSystem.Armor2) - ) - .count(); - this.isPossible = results > 0; - } - } else { - this.isPossible = true; - } - this.possible.next(this.isPossible); - } - get slotName(): string { switch (this.slot) { case ArmorSlot.ArmorSlotHelmet: @@ -190,20 +121,17 @@ export class SlotLimitationSelectionComponent implements OnInit, OnDestroy { } ngOnInit(): void { + this.availableItems + .getItemsForSlot$(this.slot) + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe((items) => { + this.isPossible = items.length > 0; + this.possible.next(this.isPossible); + }); + this.config.configuration.pipe(takeUntil(this.ngUnsubscribe)).subscribe(async (c) => { const newExoticSum = c.selectedExotics.reduce((acc, x) => acc + x, 0); - var mustRunPossibilityCheck = - this.configSelectedClass != c.characterClass || - this.configAssumeLegendaryIsArtifice != c.assumeEveryLegendaryIsArtifice || - this.configAssumeExoticIsArtifice != c.assumeEveryExoticIsArtifice || - this.configAssumeClassItemIsArtifice != c.assumeClassItemIsArtifice || - this.selection != c.maximumModSlots[this.slot].value || - this.armorPerk != c.armorPerks[this.slot].value || - this.armorPerkLock != c.armorPerks[this.slot].fixed || - this.configSelectedExoticSum != newExoticSum || - this.maximumModSlots != c.maximumModSlots[this.slot].value; - this.configAssumeLegendaryIsArtifice = c.assumeEveryLegendaryIsArtifice; this.configAssumeExoticIsArtifice = c.assumeEveryExoticIsArtifice; this.configAssumeClassItemIsArtifice = c.assumeClassItemIsArtifice; @@ -214,15 +142,6 @@ export class SlotLimitationSelectionComponent implements OnInit, OnDestroy { this.maximumModSlots = c.maximumModSlots[this.slot].value; this.configSelectedExoticSum = newExoticSum; this.configSelectedExotic = c.selectedExotics; - - this.fixedExoticInThisSlot = - (await this.inventory.getExoticsForClass(c.characterClass)) - // TODO LOOK AT THIS - .filter((x) => c.selectedExotics.indexOf(x.items[0].hash) > -1) - .map((e) => e.items[0].slot) - .indexOf(this.slot) > -1; - - if (mustRunPossibilityCheck) await this.runPossibilityCheck(); }); } @@ -258,4 +177,14 @@ export class SlotLimitationSelectionComponent implements OnInit, OnDestroy { this.ngUnsubscribe.next(); this.ngUnsubscribe.complete(); } + + // Helper method to check if items are available for a specific slot + hasItemsForSlot(slot: ArmorSlot): boolean { + return this.availableItems.hasItemsForSlot(slot); + } + + // Helper method to get item count for a specific slot + getItemCountForSlot(slot: ArmorSlot): number { + return this.availableItems.getItemsForSlot(slot).length; + } } diff --git a/src/app/services/available-items.service.ts b/src/app/services/available-items.service.ts new file mode 100644 index 00000000..5a9274d0 --- /dev/null +++ b/src/app/services/available-items.service.ts @@ -0,0 +1,562 @@ +/* + * Copyright (c) 2023 D2ArmorPicker by Mijago. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { Injectable } from "@angular/core"; +import { BehaviorSubject, Observable } from "rxjs"; +import { map, distinctUntilChanged, take, tap } from "rxjs/operators"; +import { DatabaseService } from "./database.service"; +import { ConfigurationService } from "./configuration.service"; +import { AuthService } from "./auth.service"; +import { ArmorSlot } from "../data/enum/armor-slot"; +import { ArmorPerkOrSlot } from "../data/enum/armor-stat"; +import { ArmorSystem } from "../data/types/IManifestArmor"; +import { IInventoryArmor, InventoryArmorSource, isEqualItem } from "../data/types/IInventoryArmor"; +import { IPermutatorArmor } from "../data/types/IPermutatorArmor"; +import { BuildConfiguration } from "../data/buildConfiguration"; +import { TierType } from "bungie-api-ts/destiny2"; +import { FORCE_USE_NO_EXOTIC, MAXIMUM_MASTERWORK_LEVEL } from "../data/constants"; +import { isEqual as _isEqual } from "lodash"; + +export interface AvailableItemsBySlot { + [ArmorSlot.ArmorSlotHelmet]: IPermutatorArmor[]; + [ArmorSlot.ArmorSlotGauntlet]: IPermutatorArmor[]; + [ArmorSlot.ArmorSlotChest]: IPermutatorArmor[]; + [ArmorSlot.ArmorSlotLegs]: IPermutatorArmor[]; + [ArmorSlot.ArmorSlotClass]: IPermutatorArmor[]; +} + +type ValidArmorSlot = + | ArmorSlot.ArmorSlotHelmet + | ArmorSlot.ArmorSlotGauntlet + | ArmorSlot.ArmorSlotChest + | ArmorSlot.ArmorSlotLegs + | ArmorSlot.ArmorSlotClass; + +export interface AvailableItemsInfo { + itemsBySlot: AvailableItemsBySlot; + totalItems: number; + classItems: IPermutatorArmor[]; + availableClassItemPerkTypes: Set; + exoticClassItems: IPermutatorArmor[]; + legendaryClassItems: IPermutatorArmor[]; + exoticClassItemIsEnforced: boolean; +} + +@Injectable({ + providedIn: "root", +}) +export class AvailableItemsService { + private _availableItems = new BehaviorSubject({ + itemsBySlot: { + [ArmorSlot.ArmorSlotHelmet]: [], + [ArmorSlot.ArmorSlotGauntlet]: [], + [ArmorSlot.ArmorSlotChest]: [], + [ArmorSlot.ArmorSlotLegs]: [], + [ArmorSlot.ArmorSlotClass]: [], + }, + totalItems: 0, + classItems: [], + availableClassItemPerkTypes: new Set(), + exoticClassItems: [], + legendaryClassItems: [], + exoticClassItemIsEnforced: false, + }); + + public readonly availableItems$: Observable = this._availableItems + .asObservable() + .pipe( + tap((items: AvailableItemsInfo) => + console.debug("AvailableItemsService: availableItems$ emitted", items) + ) + ); + + // Memoized slot observables to ensure consistency + private _slotObservables = new Map>(); + + constructor( + private db: DatabaseService, + private config: ConfigurationService, + private auth: AuthService + ) { + // Update available items when config changes + this.config.configuration + .pipe( + distinctUntilChanged((prev, curr) => { + for (let key of Object.keys(prev) as (keyof BuildConfiguration)[]) { + if (!curr.hasOwnProperty(key) || !_isEqual(prev[key], curr[key])) { + return false; + } + } + return true; + }) + //debounceTime(1) // Debounce to avoid rapid updates + ) + .subscribe(async (config) => { + if (this.auth.isAuthenticated()) { + await this.updateAvailableItems(config); + } + }); + } + + /** + * Manually trigger an update of available items + * This should be called by InventoryService when inventory data changes + */ + async refreshAvailableItems(): Promise { + if (this.auth.isAuthenticated()) { + // Get the current configuration value + const currentConfig = await new Promise((resolve) => { + this.config.configuration + .pipe(take(1)) + .subscribe((config: BuildConfiguration) => resolve(config)); + }); + await this.updateAvailableItems(currentConfig); + } + } + + /** + * Get current available items synchronously + */ + get availableItems(): AvailableItemsInfo { + return this._availableItems.value; + } + + /** + * Get items for a specific slot + */ + getItemsForSlot(slot: ArmorSlot): IPermutatorArmor[] { + const validSlots = [ + ArmorSlot.ArmorSlotHelmet, + ArmorSlot.ArmorSlotGauntlet, + ArmorSlot.ArmorSlotChest, + ArmorSlot.ArmorSlotLegs, + ArmorSlot.ArmorSlotClass, + ]; + + if (!validSlots.includes(slot)) { + return []; + } + + return this._availableItems.value.itemsBySlot[slot as ValidArmorSlot] || []; + } + + /** + * Get items for a specific slot as observable + */ + getItemsForSlot$(slot: ArmorSlot): Observable { + const validSlots = [ + ArmorSlot.ArmorSlotHelmet, + ArmorSlot.ArmorSlotGauntlet, + ArmorSlot.ArmorSlotChest, + ArmorSlot.ArmorSlotLegs, + ArmorSlot.ArmorSlotClass, + ]; + + if (!validSlots.includes(slot)) { + return new BehaviorSubject([]).asObservable(); + } + + // Return memoized observable for consistency + if (!this._slotObservables.has(slot)) { + const slotObservable = this.availableItems$.pipe( + map((items) => { + const slotItems = items.itemsBySlot[slot as ValidArmorSlot] || []; + console.debug( + `AvailableItemsService: getItemsForSlot$ for slot ${slot} returned ${slotItems.length} items` + ); + return slotItems; + }), + distinctUntilChanged((prev, curr) => _isEqual(prev, curr)) + ); + this._slotObservables.set(slot, slotObservable); + } + + return this._slotObservables.get(slot)!; + } + + /** + * Get all class items + */ + get classItems(): IPermutatorArmor[] { + return this._availableItems.value.classItems; + } + + /** + * Get all class items as observable + */ + get classItems$(): Observable { + return this.availableItems$.pipe( + map((items) => items.classItems), + distinctUntilChanged((prev, curr) => _isEqual(prev, curr)) + ); + } + + /** + * Get available class item perk types + */ + get availableClassItemPerkTypes(): Set { + return this._availableItems.value.availableClassItemPerkTypes; + } + + /** + * Get available class item perk types as observable + */ + get availableClassItemPerkTypes$(): Observable> { + return this.availableItems$.pipe( + map((items) => items.availableClassItemPerkTypes), + distinctUntilChanged((prev, curr) => _isEqual(prev, curr)) + ); + } + + /** + * Check if any items are available for the given configuration + */ + hasAvailableItems(): boolean { + const items = this._availableItems.value; + return Object.values(items.itemsBySlot).some((slotItems) => slotItems.length > 0); + } + + /** + * Check if items are available for a specific slot + */ + hasItemsForSlot(slot: ArmorSlot): boolean { + return this.getItemsForSlot(slot).length > 0; + } + + private async updateAvailableItems(config: BuildConfiguration): Promise { + try { + // Get all inventory items for the current class + let inventoryItems = (await this.db.inventoryArmor + .where("clazz") + .equals(config.characterClass) + .distinct() + .toArray()) as IInventoryArmor[]; + + // Apply all filtering logic (extracted from inventory.service.ts and results-builder.worker.ts) + const filteredItems = this.applyItemFilters(inventoryItems, config); + + // Convert to permutator armor items + const permutatorItems = this.convertToPermutatorArmor(filteredItems); + + // Remove duplicates (collection/vendor items if inventory version exists) + const deduplicatedItems = this.removeDuplicateItems(permutatorItems); + + // Process class items specially + const processedClassItems = this.processClassItems(deduplicatedItems, config); + + // Group items by slot + const itemsBySlot = this.groupItemsBySlot(deduplicatedItems); + + // Calculate additional info + const availableClassItemPerkTypes = new Set(processedClassItems.map((item) => item.perk)); + const exoticClassItems = processedClassItems.filter((item) => item.isExotic); + const legendaryClassItems = processedClassItems.filter((item) => !item.isExotic); + const exoticClassItemIsEnforced = exoticClassItems.some( + (item) => config.selectedExotics.indexOf(item.hash) > -1 + ); + + // Update the behavior subject + console.debug("AvailableItemsService: Updating available items", { + totalItems: deduplicatedItems.length, + helmets: itemsBySlot[ArmorSlot.ArmorSlotHelmet].length, + gauntlets: itemsBySlot[ArmorSlot.ArmorSlotGauntlet].length, + chests: itemsBySlot[ArmorSlot.ArmorSlotChest].length, + legs: itemsBySlot[ArmorSlot.ArmorSlotLegs].length, + classItems: itemsBySlot[ArmorSlot.ArmorSlotClass].length, + }); + + this._availableItems.next({ + itemsBySlot, + totalItems: deduplicatedItems.length, + classItems: processedClassItems, + availableClassItemPerkTypes, + exoticClassItems, + legendaryClassItems, + exoticClassItemIsEnforced, + }); + } catch (error) { + console.error("Error updating available items:", error); + } + } + + private applyItemFilters( + items: IInventoryArmor[], + config: BuildConfiguration + ): IInventoryArmor[] { + const exoticSlots = items + .filter((item) => config.selectedExotics.indexOf(item.hash) > -1) + .map((item) => item.slot); + + const exoticLimitedSlots = exoticSlots.length > 0 ? exoticSlots[0] : null; + + return ( + items + // Only armor pieces + .filter((item) => item.slot !== ArmorSlot.ArmorSlotNone) + // Filter disabled items + .filter((item) => config.disabledItems.indexOf(item.itemInstanceId) === -1) + // Filter featured armor if enforced + .filter((item) => !config.enforceFeaturedArmor || item.isFeatured) + // Filter armor system + .filter((item) => item.armorSystem === ArmorSystem.Armor3 || config.allowLegacyArmor) + // Filter collection/vendor rolls based on settings + .filter((item) => { + switch (item.source) { + case InventoryArmorSource.Collections: + return config.includeCollectionRolls; + case InventoryArmorSource.Vendor: + return config.includeVendorRolls; + default: + return true; + } + }) + // Filter selected exotic enforcement + .filter( + (item) => config.selectedExotics.indexOf(FORCE_USE_NO_EXOTIC) === -1 || !item.isExotic + ) + // Filter based on selected exotics + .filter((item) => { + if (config.selectedExotics.length === 0) return true; + if (item.isExotic) { + return config.selectedExotics.some((exoticHash) => item.hash === exoticHash); + } else { + // For non-exotic items, ensure no selected exotic conflicts with this slot + return item.slot !== exoticLimitedSlots; + } + }) + // Filter masterworked exotics if required + .filter( + (item) => + !config.onlyUseMasterworkedExotics || + !(item.rarity === TierType.Exotic && item.masterworkLevel !== MAXIMUM_MASTERWORK_LEVEL) + ) + // Filter masterworked legendaries if required + .filter( + (item) => + !config.onlyUseMasterworkedLegendaries || + !( + item.rarity === TierType.Superior && item.masterworkLevel !== MAXIMUM_MASTERWORK_LEVEL + ) + ) + // Filter blue armor pieces if not allowed + .filter( + (item) => + config.allowBlueArmorPieces || + item.rarity === TierType.Exotic || + item.rarity === TierType.Superior + ) + // Filter sunset armor if ignored + .filter((item) => !config.ignoreSunsetArmor || !item.isSunset) + // Filter armor perks + .filter((item) => { + const slotConfig = config.armorPerks[item.slot]; + + // If perk is set to "Any", allow all items + if (slotConfig.value === ArmorPerkOrSlot.Any) return true; + + // If perk is not fixed, allow all items + if (!slotConfig.fixed) return true; + + // For exotic items, they might have different perk requirements + if (item.isExotic && item.perk === slotConfig.value) return true; + + // Check if the item's perk matches the required perk + if (slotConfig.value === item.perk) return true; + + // Special case for artifice assumption + if ( + slotConfig.value === ArmorPerkOrSlot.SlotArtifice && + item.armorSystem === ArmorSystem.Armor2 && + ((config.assumeEveryLegendaryIsArtifice && !item.isExotic) || + (config.assumeEveryExoticIsArtifice && item.isExotic)) + ) { + return true; + } + + return false; + }) + ); + } + + private convertToPermutatorArmor(items: IInventoryArmor[]): IPermutatorArmor[] { + return items.map((armor) => ({ + id: armor.id, + hash: armor.hash, + slot: armor.slot, + clazz: armor.clazz, + perk: armor.perk, + isExotic: armor.isExotic, + rarity: armor.rarity, + isSunset: armor.isSunset, + masterworkLevel: armor.masterworkLevel, + archetypeStats: armor.archetypeStats, + mobility: armor.mobility, + resilience: armor.resilience, + recovery: armor.recovery, + discipline: armor.discipline, + intellect: armor.intellect, + strength: armor.strength, + source: armor.source, + exoticPerkHash: armor.exoticPerkHash, + icon: armor.icon, + watermarkIcon: armor.watermarkIcon, + name: armor.name, + energyLevel: armor.energyLevel, + tier: armor.tier, + armorSystem: armor.armorSystem, + })); + } + + private removeDuplicateItems(items: IPermutatorArmor[]): IPermutatorArmor[] { + return items.filter((item) => { + if (item.source === InventoryArmorSource.Inventory) return true; + + const purchasedItemInstance = items.find( + (rhs) => + rhs.source === InventoryArmorSource.Inventory && this.isEqualPermutatorItem(item, rhs) + ); + + // If this item is a collection/vendor item, ignore it if the player + // already has a real copy of the same item. + return purchasedItemInstance === undefined; + }); + } + + private isEqualPermutatorItem(item1: IPermutatorArmor, item2: IPermutatorArmor): boolean { + // Convert to IInventoryArmor-like objects for comparison + const armor1 = item1 as any; + const armor2 = item2 as any; + return isEqualItem(armor1, armor2); + } + + private processClassItems( + items: IPermutatorArmor[], + config: BuildConfiguration + ): IPermutatorArmor[] { + let classItems = items.filter((item) => item.slot === ArmorSlot.ArmorSlotClass); + + // Apply artifice assumptions + if ( + config.assumeEveryLegendaryIsArtifice || + config.assumeEveryExoticIsArtifice || + config.assumeClassItemIsArtifice + ) { + classItems = classItems.map((item) => { + if ( + (item.armorSystem === ArmorSystem.Armor2 && + ((config.assumeEveryLegendaryIsArtifice && !item.isExotic) || + (config.assumeEveryExoticIsArtifice && item.isExotic))) || + (config.assumeClassItemIsArtifice && !item.isExotic) + ) { + return { ...item, perk: ArmorPerkOrSlot.SlotArtifice }; + } + return item; + }); + } + + // Filter class items based on fixed perk requirements + if ( + config.armorPerks[ArmorSlot.ArmorSlotClass].fixed && + config.armorPerks[ArmorSlot.ArmorSlotClass].value !== ArmorPerkOrSlot.Any + ) { + classItems = classItems.filter( + (item) => item.perk === config.armorPerks[ArmorSlot.ArmorSlotClass].value + ); + } + + // Filter exotic class items based on selected exotic perks + if (config.selectedExoticPerks && config.selectedExoticPerks.length >= 2) { + const firstPerkFilter = config.selectedExoticPerks[0]; + const secondPerkFilter = config.selectedExoticPerks[1]; + + if (firstPerkFilter !== ArmorPerkOrSlot.Any || secondPerkFilter !== ArmorPerkOrSlot.Any) { + classItems = classItems.filter((item) => { + if (!item.isExotic || !item.exoticPerkHash || item.exoticPerkHash.length < 2) { + return true; // Keep non-exotic items or items without proper perk data + } + + const hasFirstPerk = + firstPerkFilter === ArmorPerkOrSlot.Any || + item.exoticPerkHash.includes(firstPerkFilter); + const hasSecondPerk = + secondPerkFilter === ArmorPerkOrSlot.Any || + item.exoticPerkHash.includes(secondPerkFilter); + + return hasFirstPerk && hasSecondPerk; + }); + } + } + + // Sort by masterwork level, descending + classItems = classItems.sort((a, b) => (b.masterworkLevel ?? 0) - (a.masterworkLevel ?? 0)); + + // Check if any armor perks is not "any" for deduplication purposes + const doesNotRequireArmorPerks = ![ + config.armorPerks[ArmorSlot.ArmorSlotHelmet].value, + config.armorPerks[ArmorSlot.ArmorSlotGauntlet].value, + config.armorPerks[ArmorSlot.ArmorSlotChest].value, + config.armorPerks[ArmorSlot.ArmorSlotLegs].value, + config.armorPerks[ArmorSlot.ArmorSlotClass].value, + ].every((v) => v === ArmorPerkOrSlot.Any); + + // Check if any stat is fixed for deduplication purposes + const anyStatFixed = Object.values(config.minimumStatTiers).some((v) => v.fixed); + + // Deduplicate class items based on stats and other criteria + return classItems.filter( + (item, index, self) => + index === + self.findIndex( + (i) => + i.mobility === item.mobility && + i.resilience === item.resilience && + i.recovery === item.recovery && + i.discipline === item.discipline && + i.intellect === item.intellect && + i.strength === item.strength && + i.isExotic === item.isExotic && + ((i.isExotic && config.assumeExoticsMasterworked) || + (!i.isExotic && config.assumeLegendariesMasterworked) || + (anyStatFixed && i.masterworkLevel === item.masterworkLevel) || + !anyStatFixed) && + (i.isExotic + ? i.exoticPerkHash[0] === item.exoticPerkHash[0] && + i.exoticPerkHash[1] === item.exoticPerkHash[1] + : true) && + (doesNotRequireArmorPerks || i.perk === item.perk) + ) + ); + } + + private groupItemsBySlot(items: IPermutatorArmor[]): AvailableItemsBySlot { + const itemsBySlot: AvailableItemsBySlot = { + [ArmorSlot.ArmorSlotHelmet]: [], + [ArmorSlot.ArmorSlotGauntlet]: [], + [ArmorSlot.ArmorSlotChest]: [], + [ArmorSlot.ArmorSlotLegs]: [], + [ArmorSlot.ArmorSlotClass]: [], + }; + + for (const item of items) { + if (item.slot in itemsBySlot) { + (itemsBySlot as any)[item.slot].push(item); + } + } + + return itemsBySlot; + } +} diff --git a/src/app/services/inventory.service.ts b/src/app/services/inventory.service.ts index f918508e..3e0efc48 100644 --- a/src/app/services/inventory.service.ts +++ b/src/app/services/inventory.service.ts @@ -22,7 +22,7 @@ import { ConfigurationService } from "./configuration.service"; import { debounceTime } from "rxjs/operators"; import { BehaviorSubject, Observable, ReplaySubject, Subject } from "rxjs"; import { BuildConfiguration } from "../data/buildConfiguration"; -import { ArmorPerkOrSlot, STAT_MOD_VALUES, StatModifier } from "../data/enum/armor-stat"; +import { STAT_MOD_VALUES, StatModifier } from "../data/enum/armor-stat"; import { StatusProviderService } from "./status-provider.service"; import { BungieApiService } from "./bungie-api.service"; import { AuthService } from "./auth.service"; @@ -32,13 +32,8 @@ import { ResultDefinition, ResultItem, } from "../components/authenticated-v2/results/results.component"; -import { - IInventoryArmor, - InventoryArmorSource, - isEqualItem, - totalStats, -} from "../data/types/IInventoryArmor"; -import { DestinyClass, TierType } from "bungie-api-ts/destiny2"; +import { IInventoryArmor, InventoryArmorSource, totalStats } from "../data/types/IInventoryArmor"; +import { DestinyClass } from "bungie-api-ts/destiny2"; import { IPermutatorArmorSet } from "../data/types/IPermutatorArmorSet"; import { getSkillTier, getWaste } from "./results-builder.worker"; import { IPermutatorArmor } from "../data/types/IPermutatorArmor"; @@ -47,6 +42,7 @@ import { VendorsService } from "./vendors.service"; import { ModOptimizationStrategy } from "../data/enum/mod-optimization-strategy"; import { isEqual as _isEqual } from "lodash"; import { getDifferences } from "../data/commonFunctions"; +import { AvailableItemsService } from "./available-items.service"; type info = { results: ResultDefinition[]; @@ -99,7 +95,6 @@ export class InventoryService { private totalPermutationCount = 0; private resultMaximumTiers: number[][] = []; private selectedExotics: IManifestArmor[] = []; - private inventoryArmorItems: IInventoryArmor[] = []; private permutatorArmorItems: IPermutatorArmor[] = []; private endResults: ResultDefinition[] = []; @@ -110,7 +105,8 @@ export class InventoryService { private api: BungieApiService, private auth: AuthService, private router: Router, - private vendors: VendorsService + private vendors: VendorsService, + private availableItems: AvailableItemsService ) { this._inventory = new ReplaySubject(1); this.inventory = this._inventory.asObservable(); @@ -208,7 +204,11 @@ export class InventoryService { triggerResultsUpdate: boolean = true ) { // trigger armor update behaviour - if (triggerInventoryUpdate) this._inventory.next(null); + if (triggerInventoryUpdate) { + this._inventory.next(null); + // Refresh available items when inventory changes + await this.availableItems.refreshAvailableItems(); + } // Do not update results in Help and Cluster pages if (this.shouldCalculateResults()) { @@ -302,122 +302,15 @@ export class InventoryService { ); this.selectedExotics = this.selectedExotics.filter((i) => !!i); - this.inventoryArmorItems = (await this.db.inventoryArmor - .where("clazz") - .equals(config.characterClass) - .distinct() - .toArray()) as IInventoryArmor[]; - - this.inventoryArmorItems = this.inventoryArmorItems - // only armor :) - .filter((item) => item.slot != ArmorSlot.ArmorSlotNone) - // filter disabled items - .filter((item) => config.disabledItems.indexOf(item.itemInstanceId) == -1) - // filter armor 3.0 - .filter((item) => !config.enforceFeaturedArmor || item.isFeatured) - .filter((item) => item.armorSystem === ArmorSystem.Armor3 || config.allowLegacyArmor) - // filter collection/vendor rolls if not allowed - .filter((item) => { - switch (item.source) { - case InventoryArmorSource.Collections: - return config.includeCollectionRolls; - case InventoryArmorSource.Vendor: - return config.includeVendorRolls; - default: - return true; - } - }) - // filter the selected exotic right here - .filter( - (item) => config.selectedExotics.indexOf(FORCE_USE_NO_EXOTIC) == -1 || !item.isExotic - ) - .filter( - (item) => - this.selectedExotics.length === 0 || - (item.isExotic && this.selectedExotics.some((exotic) => exotic.hash === item.hash)) || - (!item.isExotic && this.selectedExotics.every((exotic) => exotic.slot !== item.slot)) - ) - - // config.OnlyUseMasterworkedExotics - only keep exotics that are masterworked - .filter( - (item) => - !config.onlyUseMasterworkedExotics || - !(item.rarity == TierType.Exotic && item.masterworkLevel != MAXIMUM_MASTERWORK_LEVEL) - ) - - // config.OnlyUseMasterworkedLegendaries - only keep legendaries that are masterworked - .filter( - (item) => - !config.onlyUseMasterworkedLegendaries || - !(item.rarity == TierType.Superior && item.masterworkLevel != MAXIMUM_MASTERWORK_LEVEL) - ) - - // non-legendaries and non-exotics - .filter( - (item) => - config.allowBlueArmorPieces || - item.rarity == TierType.Exotic || - item.rarity == TierType.Superior - ) - // sunset armor - .filter((item) => !config.ignoreSunsetArmor || !item.isSunset) - // armor perks - .filter((item) => { - return ( - (item.isExotic && item.perk == config.armorPerks[item.slot].value) || - config.armorPerks[item.slot].value == ArmorPerkOrSlot.Any || - !config.armorPerks[item.slot].fixed || - (config.armorPerks[item.slot].fixed && - config.armorPerks[item.slot].value == item.perk) || - (config.armorPerks[item.slot].value === ArmorPerkOrSlot.SlotArtifice && - item.armorSystem === ArmorSystem.Armor2 && - ((config.assumeEveryLegendaryIsArtifice && !item.isExotic) || - (config.assumeEveryExoticIsArtifice && item.isExotic))) - ); - }); - // console.log(items.map(d => "id:'"+d.itemInstanceId+"'").join(" or ")) - - // Remove collection items if they are in inventory - this.inventoryArmorItems = this.inventoryArmorItems.filter((item) => { - if (item.source === InventoryArmorSource.Inventory) return true; - - const purchasedItemInstance = this.inventoryArmorItems.find( - (rhs) => rhs.source === InventoryArmorSource.Inventory && isEqualItem(item, rhs) - ); - - // If this item is a collection/vendor item, ignore it if the player - // already has a real copy of the same item. - return purchasedItemInstance === undefined; - }); - this.permutatorArmorItems = this.inventoryArmorItems.map((armor) => { - return { - id: armor.id, - hash: armor.hash, - slot: armor.slot, - clazz: armor.clazz, - perk: armor.perk, - isExotic: armor.isExotic, - rarity: armor.rarity, - isSunset: armor.isSunset, - masterworkLevel: armor.masterworkLevel, - archetypeStats: armor.archetypeStats, - mobility: armor.mobility, - resilience: armor.resilience, - recovery: armor.recovery, - discipline: armor.discipline, - intellect: armor.intellect, - strength: armor.strength, - source: armor.source, - exoticPerkHash: armor.exoticPerkHash, - - icon: armor.icon, - watermarkIcon: armor.watermarkIcon, - name: armor.name, - energyLevel: armor.energyLevel, - tier: armor.tier, - armorSystem: armor.armorSystem, - }; - }); + // Get filtered items from the available items service + const availableItemsInfo = this.availableItems.availableItems; + this.permutatorArmorItems = [ + ...availableItemsInfo.itemsBySlot[ArmorSlot.ArmorSlotHelmet], + ...availableItemsInfo.itemsBySlot[ArmorSlot.ArmorSlotGauntlet], + ...availableItemsInfo.itemsBySlot[ArmorSlot.ArmorSlotChest], + ...availableItemsInfo.itemsBySlot[ArmorSlot.ArmorSlotLegs], + ...availableItemsInfo.itemsBySlot[ArmorSlot.ArmorSlotClass], + ]; nthreads = this.estimateRequiredThreads(); @@ -486,7 +379,7 @@ export class InventoryService { for (let armorSet of this.results) { let items = armorSet.armor.map((x) => - this.inventoryArmorItems.find((y) => y.id == x) + this.permutatorArmorItems.find((y) => y.id == x) ) as IInventoryArmor[]; let exotic = items.find((x) => x.isExotic); let v: ResultDefinition = { diff --git a/src/app/services/results-builder.worker.ts b/src/app/services/results-builder.worker.ts index 109bafbb..48cf3fff 100644 --- a/src/app/services/results-builder.worker.ts +++ b/src/app/services/results-builder.worker.ts @@ -16,7 +16,7 @@ */ // region Imports -import { BuildConfiguration, FixableSelection } from "../data/buildConfiguration"; +import { BuildConfiguration } from "../data/buildConfiguration"; import { IDestinyArmor, InventoryArmorSource } from "../data/types/IInventoryArmor"; import { ArmorSlot } from "../data/enum/armor-slot"; import { FORCE_USE_ANY_EXOTIC, MAXIMUM_MASTERWORK_LEVEL } from "../data/constants"; @@ -287,9 +287,6 @@ addEventListener("message", async ({ data }) => { const threadSplit = data.threadSplit as { count: number; current: number }; const config = data.config as BuildConfiguration; - const anyStatFixed = Object.values(config.minimumStatTiers).some( - (v: FixableSelection) => v.fixed - ); let items = data.items as IPermutatorArmor[]; if (threadSplit == undefined || config == undefined || items == undefined) { @@ -348,94 +345,6 @@ addEventListener("message", async ({ data }) => { } let classItems = items.filter((i) => i.slot == ArmorSlot.ArmorSlotClass); - // Sort by Masterwork, descending - classItems = classItems.sort((a, b) => (b.masterworkLevel ?? 0) - (a.masterworkLevel ?? 0)); - - // Filter exotic class items based on selected exotic perks if they are not "Any" - if (config.selectedExoticPerks && config.selectedExoticPerks.length >= 2) { - const firstPerkFilter = config.selectedExoticPerks[0]; - const secondPerkFilter = config.selectedExoticPerks[1]; - - if (firstPerkFilter !== ArmorPerkOrSlot.Any || secondPerkFilter !== ArmorPerkOrSlot.Any) { - classItems = classItems.filter((item) => { - if (!item.isExotic || !item.exoticPerkHash || item.exoticPerkHash.length < 2) { - return true; // Keep non-exotic items or items without proper perk data - } - - const hasFirstPerk = - firstPerkFilter === ArmorPerkOrSlot.Any || item.exoticPerkHash.includes(firstPerkFilter); - const hasSecondPerk = - secondPerkFilter === ArmorPerkOrSlot.Any || - item.exoticPerkHash.includes(secondPerkFilter); - - return hasFirstPerk && hasSecondPerk; - }); - } - } - - if ( - config.assumeEveryLegendaryIsArtifice || - config.assumeEveryExoticIsArtifice || - config.assumeClassItemIsArtifice - ) { - classItems = classItems.map((item) => { - if ( - (item.armorSystem == ArmorSystem.Armor2 && - ((config.assumeEveryLegendaryIsArtifice && !item.isExotic) || - (config.assumeEveryExoticIsArtifice && item.isExotic))) || - (config.assumeClassItemIsArtifice && !item.isExotic) - ) { - return { ...item, perk: ArmorPerkOrSlot.SlotArtifice }; - } - return item; - }); - } - - // If the config says that we need a fixed class item, filter the class items accordingly - if ( - config.armorPerks[ArmorSlot.ArmorSlotClass].fixed && - config.armorPerks[ArmorSlot.ArmorSlotClass].value != ArmorPerkOrSlot.Any - ) - classItems = classItems.filter( - (d) => d.perk == config.armorPerks[ArmorSlot.ArmorSlotClass].value - ); - - // true if any armorPerks is not "any" - const doesNotRequireArmorPerks = ![ - config.armorPerks[ArmorSlot.ArmorSlotHelmet].value, - config.armorPerks[ArmorSlot.ArmorSlotGauntlet].value, - config.armorPerks[ArmorSlot.ArmorSlotChest].value, - config.armorPerks[ArmorSlot.ArmorSlotLegs].value, - config.armorPerks[ArmorSlot.ArmorSlotClass].value, - ].every((v) => v === ArmorPerkOrSlot.Any); - - classItems = classItems.filter( - (item, index, self) => - index === - self.findIndex( - (i) => - i.mobility === item.mobility && - i.resilience === item.resilience && - i.recovery === item.recovery && - i.discipline === item.discipline && - i.intellect === item.intellect && - i.strength === item.strength && - i.isExotic === item.isExotic && - ((i.isExotic && config.assumeExoticsMasterworked) || - (!i.isExotic && config.assumeLegendariesMasterworked) || - // If there is any stat fixed, we check if the masterwork level is the same as the first item - (anyStatFixed && i.masterworkLevel === item.masterworkLevel) || - // If there is no stat fixed, then we just use the masterwork level of the first item. - // As it is already sorted descending, we can just check if the masterwork level is the same - !anyStatFixed) && - (i.isExotic - ? i.exoticPerkHash[0] === item.exoticPerkHash[0] && - i.exoticPerkHash[1] === item.exoticPerkHash[1] - : true) && // if it's not exotic, we don't care about the exotic perks - (doesNotRequireArmorPerks || i.perk === item.perk) - ) - ); - //*/ const exoticClassItems = classItems.filter((d) => d.isExotic); const legendaryClassItems = classItems.filter((d) => !d.isExotic); From 90d60df80877a43e57d39b6055e1102d8de2c948 Mon Sep 17 00:00:00 2001 From: Mijago Date: Sun, 27 Jul 2025 09:03:10 +0200 Subject: [PATCH 2/4] fix: minor update order fix in ignored-items-list --- .../ignored-items-list/ignored-items-list.component.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/components/authenticated-v2/settings/ignored-items-list/ignored-items-list.component.ts b/src/app/components/authenticated-v2/settings/ignored-items-list/ignored-items-list.component.ts index 308b31d7..14228c14 100644 --- a/src/app/components/authenticated-v2/settings/ignored-items-list/ignored-items-list.component.ts +++ b/src/app/components/authenticated-v2/settings/ignored-items-list/ignored-items-list.component.ts @@ -75,7 +75,6 @@ export class IgnoredItemsListComponent implements OnInit, OnDestroy { ngOnInit(): void { this.config.configuration.pipe(takeUntil(this.ngUnsubscribe)).subscribe(async (cb) => { - this.characterClass = null; const newDisabledItems: IDisplayInventoryArmor[][] = [[], [], [], [], [], []]; let items = []; @@ -118,11 +117,10 @@ export class IgnoredItemsListComponent implements OnInit, OnDestroy { newDisabledItems[item.slot].push(item); } - this.characterClass = cb.characterClass; - for (let row of newDisabledItems) { row.sort((a, b) => a.hash - b.hash); } + this.characterClass = cb.characterClass; this.disabledItems = newDisabledItems; }); } From 36520dd51f4978d2637a901af74c9ec27eb1d9b8 Mon Sep 17 00:00:00 2001 From: Mijago Date: Sun, 27 Jul 2025 09:03:10 +0200 Subject: [PATCH 3/4] fix: correctly filter class items, more performant --- src/app/services/available-items.service.ts | 191 +++++--------------- src/app/services/inventory.service.ts | 16 +- 2 files changed, 58 insertions(+), 149 deletions(-) diff --git a/src/app/services/available-items.service.ts b/src/app/services/available-items.service.ts index 5a9274d0..f9c87f02 100644 --- a/src/app/services/available-items.service.ts +++ b/src/app/services/available-items.service.ts @@ -17,7 +17,7 @@ import { Injectable } from "@angular/core"; import { BehaviorSubject, Observable } from "rxjs"; -import { map, distinctUntilChanged, take, tap } from "rxjs/operators"; +import { map, distinctUntilChanged, take, debounceTime } from "rxjs/operators"; import { DatabaseService } from "./database.service"; import { ConfigurationService } from "./configuration.service"; import { AuthService } from "./auth.service"; @@ -49,11 +49,7 @@ type ValidArmorSlot = export interface AvailableItemsInfo { itemsBySlot: AvailableItemsBySlot; totalItems: number; - classItems: IPermutatorArmor[]; - availableClassItemPerkTypes: Set; - exoticClassItems: IPermutatorArmor[]; - legendaryClassItems: IPermutatorArmor[]; - exoticClassItemIsEnforced: boolean; + filteredClassItemsForGeneration: IPermutatorArmor[]; } @Injectable({ @@ -69,20 +65,11 @@ export class AvailableItemsService { [ArmorSlot.ArmorSlotClass]: [], }, totalItems: 0, - classItems: [], - availableClassItemPerkTypes: new Set(), - exoticClassItems: [], - legendaryClassItems: [], - exoticClassItemIsEnforced: false, + filteredClassItemsForGeneration: [], }); - public readonly availableItems$: Observable = this._availableItems - .asObservable() - .pipe( - tap((items: AvailableItemsInfo) => - console.debug("AvailableItemsService: availableItems$ emitted", items) - ) - ); + public readonly availableItems$: Observable = + this._availableItems.asObservable(); // Memoized slot observables to ensure consistency private _slotObservables = new Map>(); @@ -95,15 +82,7 @@ export class AvailableItemsService { // Update available items when config changes this.config.configuration .pipe( - distinctUntilChanged((prev, curr) => { - for (let key of Object.keys(prev) as (keyof BuildConfiguration)[]) { - if (!curr.hasOwnProperty(key) || !_isEqual(prev[key], curr[key])) { - return false; - } - } - return true; - }) - //debounceTime(1) // Debounce to avoid rapid updates + debounceTime(1) // Debounce to avoid rapid updates ) .subscribe(async (config) => { if (this.auth.isAuthenticated()) { @@ -175,9 +154,6 @@ export class AvailableItemsService { const slotObservable = this.availableItems$.pipe( map((items) => { const slotItems = items.itemsBySlot[slot as ValidArmorSlot] || []; - console.debug( - `AvailableItemsService: getItemsForSlot$ for slot ${slot} returned ${slotItems.length} items` - ); return slotItems; }), distinctUntilChanged((prev, curr) => _isEqual(prev, curr)) @@ -188,40 +164,6 @@ export class AvailableItemsService { return this._slotObservables.get(slot)!; } - /** - * Get all class items - */ - get classItems(): IPermutatorArmor[] { - return this._availableItems.value.classItems; - } - - /** - * Get all class items as observable - */ - get classItems$(): Observable { - return this.availableItems$.pipe( - map((items) => items.classItems), - distinctUntilChanged((prev, curr) => _isEqual(prev, curr)) - ); - } - - /** - * Get available class item perk types - */ - get availableClassItemPerkTypes(): Set { - return this._availableItems.value.availableClassItemPerkTypes; - } - - /** - * Get available class item perk types as observable - */ - get availableClassItemPerkTypes$(): Observable> { - return this.availableItems$.pipe( - map((items) => items.availableClassItemPerkTypes), - distinctUntilChanged((prev, curr) => _isEqual(prev, curr)) - ); - } - /** * Check if any items are available for the given configuration */ @@ -255,38 +197,18 @@ export class AvailableItemsService { // Remove duplicates (collection/vendor items if inventory version exists) const deduplicatedItems = this.removeDuplicateItems(permutatorItems); - // Process class items specially - const processedClassItems = this.processClassItems(deduplicatedItems, config); - // Group items by slot const itemsBySlot = this.groupItemsBySlot(deduplicatedItems); - // Calculate additional info - const availableClassItemPerkTypes = new Set(processedClassItems.map((item) => item.perk)); - const exoticClassItems = processedClassItems.filter((item) => item.isExotic); - const legendaryClassItems = processedClassItems.filter((item) => !item.isExotic); - const exoticClassItemIsEnforced = exoticClassItems.some( - (item) => config.selectedExotics.indexOf(item.hash) > -1 + const classItems = this.deduplicateClassItemsForGeneration( + itemsBySlot[ArmorSlot.ArmorSlotClass], + config ); - // Update the behavior subject - console.debug("AvailableItemsService: Updating available items", { - totalItems: deduplicatedItems.length, - helmets: itemsBySlot[ArmorSlot.ArmorSlotHelmet].length, - gauntlets: itemsBySlot[ArmorSlot.ArmorSlotGauntlet].length, - chests: itemsBySlot[ArmorSlot.ArmorSlotChest].length, - legs: itemsBySlot[ArmorSlot.ArmorSlotLegs].length, - classItems: itemsBySlot[ArmorSlot.ArmorSlotClass].length, - }); - this._availableItems.next({ itemsBySlot, totalItems: deduplicatedItems.length, - classItems: processedClassItems, - availableClassItemPerkTypes, - exoticClassItems, - legendaryClassItems, - exoticClassItemIsEnforced, + filteredClassItemsForGeneration: classItems, }); } catch (error) { console.error("Error updating available items:", error); @@ -338,6 +260,38 @@ export class AvailableItemsService { return item.slot !== exoticLimitedSlots; } }) + // If it is an exotic class item, and we enforced perks, filter by selected exotic perks + // Filter exotic class items based on selected exotic perks + .filter((item) => { + if ( + item.slot !== ArmorSlot.ArmorSlotClass || + !item.isExotic || + !config.selectedExoticPerks || + config.selectedExoticPerks.length < 2 + ) { + return true; + } + + const firstPerkFilter = config.selectedExoticPerks[0]; + const secondPerkFilter = config.selectedExoticPerks[1]; + + if (firstPerkFilter === ArmorPerkOrSlot.Any && secondPerkFilter === ArmorPerkOrSlot.Any) { + return true; + } + + if (!item.exoticPerkHash || item.exoticPerkHash.length < 2) { + return true; + } + + const hasFirstPerk = + firstPerkFilter === ArmorPerkOrSlot.Any || + item.exoticPerkHash.includes(firstPerkFilter); + const hasSecondPerk = + secondPerkFilter === ArmorPerkOrSlot.Any || + item.exoticPerkHash.includes(secondPerkFilter); + + return hasFirstPerk && hasSecondPerk; + }) // Filter masterworked exotics if required .filter( (item) => @@ -443,67 +397,10 @@ export class AvailableItemsService { return isEqualItem(armor1, armor2); } - private processClassItems( - items: IPermutatorArmor[], + private deduplicateClassItemsForGeneration( + classItems: IPermutatorArmor[], config: BuildConfiguration ): IPermutatorArmor[] { - let classItems = items.filter((item) => item.slot === ArmorSlot.ArmorSlotClass); - - // Apply artifice assumptions - if ( - config.assumeEveryLegendaryIsArtifice || - config.assumeEveryExoticIsArtifice || - config.assumeClassItemIsArtifice - ) { - classItems = classItems.map((item) => { - if ( - (item.armorSystem === ArmorSystem.Armor2 && - ((config.assumeEveryLegendaryIsArtifice && !item.isExotic) || - (config.assumeEveryExoticIsArtifice && item.isExotic))) || - (config.assumeClassItemIsArtifice && !item.isExotic) - ) { - return { ...item, perk: ArmorPerkOrSlot.SlotArtifice }; - } - return item; - }); - } - - // Filter class items based on fixed perk requirements - if ( - config.armorPerks[ArmorSlot.ArmorSlotClass].fixed && - config.armorPerks[ArmorSlot.ArmorSlotClass].value !== ArmorPerkOrSlot.Any - ) { - classItems = classItems.filter( - (item) => item.perk === config.armorPerks[ArmorSlot.ArmorSlotClass].value - ); - } - - // Filter exotic class items based on selected exotic perks - if (config.selectedExoticPerks && config.selectedExoticPerks.length >= 2) { - const firstPerkFilter = config.selectedExoticPerks[0]; - const secondPerkFilter = config.selectedExoticPerks[1]; - - if (firstPerkFilter !== ArmorPerkOrSlot.Any || secondPerkFilter !== ArmorPerkOrSlot.Any) { - classItems = classItems.filter((item) => { - if (!item.isExotic || !item.exoticPerkHash || item.exoticPerkHash.length < 2) { - return true; // Keep non-exotic items or items without proper perk data - } - - const hasFirstPerk = - firstPerkFilter === ArmorPerkOrSlot.Any || - item.exoticPerkHash.includes(firstPerkFilter); - const hasSecondPerk = - secondPerkFilter === ArmorPerkOrSlot.Any || - item.exoticPerkHash.includes(secondPerkFilter); - - return hasFirstPerk && hasSecondPerk; - }); - } - } - - // Sort by masterwork level, descending - classItems = classItems.sort((a, b) => (b.masterworkLevel ?? 0) - (a.masterworkLevel ?? 0)); - // Check if any armor perks is not "any" for deduplication purposes const doesNotRequireArmorPerks = ![ config.armorPerks[ArmorSlot.ArmorSlotHelmet].value, diff --git a/src/app/services/inventory.service.ts b/src/app/services/inventory.service.ts index 3e0efc48..c65f23dc 100644 --- a/src/app/services/inventory.service.ts +++ b/src/app/services/inventory.service.ts @@ -309,7 +309,7 @@ export class InventoryService { ...availableItemsInfo.itemsBySlot[ArmorSlot.ArmorSlotGauntlet], ...availableItemsInfo.itemsBySlot[ArmorSlot.ArmorSlotChest], ...availableItemsInfo.itemsBySlot[ArmorSlot.ArmorSlotLegs], - ...availableItemsInfo.itemsBySlot[ArmorSlot.ArmorSlotClass], + ...availableItemsInfo.filteredClassItemsForGeneration, ]; nthreads = this.estimateRequiredThreads(); @@ -372,6 +372,18 @@ export class InventoryService { this.resultMaximumTiers.push(data.runtime.maximumPossibleTiers); } if (data.done == true && doneWorkerCount == nthreads) { + const allItemIds = this.results.flatMap((x) => x.armor); + let inventoryArmorItems = (await this.db.inventoryArmor + .where("clazz") + .equals(config.characterClass) + .distinct() + .and((item) => item != null) + .toArray()) as IInventoryArmor[]; + + inventoryArmorItems = inventoryArmorItems.filter( + (x) => allItemIds.includes(x.id) || this.selectedExotics.some((y) => y.hash == x.hash) + ); + this.status.modifyStatus((s) => (s.calculatingResults = false)); this._calculationProgress.next(0); @@ -379,7 +391,7 @@ export class InventoryService { for (let armorSet of this.results) { let items = armorSet.armor.map((x) => - this.permutatorArmorItems.find((y) => y.id == x) + inventoryArmorItems.find((y) => y.id == x) ) as IInventoryArmor[]; let exotic = items.find((x) => x.isExotic); let v: ResultDefinition = { From 70d67da8c59b12a48b7a25ff3c8f8771dbc83abb Mon Sep 17 00:00:00 2001 From: Mijago Date: Sun, 27 Jul 2025 09:03:10 +0200 Subject: [PATCH 4/4] feat: added warning when a slot has no items --- .../results/results.component.html | 25 ++++ .../results/results.component.scss | 15 ++ .../results/results.component.ts | 131 ++++++++++++------ src/app/data/enum/armor-slot.ts | 11 ++ src/app/services/inventory.service.ts | 11 ++ 5 files changed, 152 insertions(+), 41 deletions(-) diff --git a/src/app/components/authenticated-v2/results/results.component.html b/src/app/components/authenticated-v2/results/results.component.html index 6f9514e2..7f813804 100644 --- a/src/app/components/authenticated-v2/results/results.component.html +++ b/src/app/components/authenticated-v2/results/results.component.html @@ -267,6 +267,31 @@

You cancelled the calculation process

search_off

No results found

Try adjusting your filters or search criteria.

+ +
+
+ warning + Warning: There is not enough legendary armor available for your current + settings. +
+
+ + +
+ +
+ warning + {{ getArmorSlotName(slot.key) }}: No items available for this slot + due to your current settings. +
+
+
+
+

Common issues:

    diff --git a/src/app/components/authenticated-v2/results/results.component.scss b/src/app/components/authenticated-v2/results/results.component.scss index b158684a..f3646e6b 100644 --- a/src/app/components/authenticated-v2/results/results.component.scss +++ b/src/app/components/authenticated-v2/results/results.component.scss @@ -373,6 +373,21 @@ tr.result-detail-row { text-align: center; color: var(--mdc-theme-on-surface); + .no-items-warning { + display: flex; + align-items: center; + gap: 8px; + color: #ff9800; + background: rgba(255, 152, 0, 0.08); + border: 1px solid #ff9800; + border-radius: 4px; + padding: 6px 12px; + margin: 8px 0; + font-size: 14px; + font-weight: 500; + width: fit-content; + box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.04); + } mat-icon { font-size: 4rem; width: 4rem; diff --git a/src/app/components/authenticated-v2/results/results.component.ts b/src/app/components/authenticated-v2/results/results.component.ts index 86d9c759..056ef239 100644 --- a/src/app/components/authenticated-v2/results/results.component.ts +++ b/src/app/components/authenticated-v2/results/results.component.ts @@ -25,11 +25,12 @@ import { MatSort } from "@angular/material/sort"; import { StatusProviderService } from "../../../services/status-provider.service"; import { animate, state, style, transition, trigger } from "@angular/animations"; import { DestinyClass } from "bungie-api-ts/destiny2"; -import { ArmorSlot } from "../../../data/enum/armor-slot"; +import { ArmorSlot, ArmorSlotNames } from "../../../data/enum/armor-slot"; import { FixableSelection } from "../../../data/buildConfiguration"; import { Subject } from "rxjs"; import { takeUntil } from "rxjs/operators"; import { InventoryArmorSource } from "src/app/data/types/IInventoryArmor"; +import { AvailableItemsService } from "src/app/services/available-items.service"; export interface ResultDefinition { exotic: @@ -103,6 +104,7 @@ export class ResultsComponent implements OnInit, OnDestroy { _config_maximumStatMods: number = 5; _config_selectedExotics: number[] = []; + _selectedExoticSlot: ArmorSlot = ArmorSlot.ArmorSlotNone; // Used to filter results by selected exotic slots _config_tryLimitWastedStats: boolean = false; _config_onlyUseMasterworkedExotics: Boolean = false; _config_onlyUseMasterworkedLegendaries: Boolean = false; @@ -145,10 +147,21 @@ export class ResultsComponent implements OnInit, OnDestroy { initializing: boolean = true; // Flag to indicate if the page is still initializing cancelledCalculation: boolean = false; + // Count of items per armor slot, [legendary, exotic] + slotsWithNoLegendaryItems: number = 0; + itemCountPerSlot: { [key in Exclude]: [number, number] } = { + [ArmorSlot.ArmorSlotHelmet]: [0, 0], + [ArmorSlot.ArmorSlotGauntlet]: [0, 0], + [ArmorSlot.ArmorSlotChest]: [0, 0], + [ArmorSlot.ArmorSlotLegs]: [0, 0], + [ArmorSlot.ArmorSlotClass]: [0, 0], + }; + constructor( private inventory: InventoryService, public configService: ConfigurationService, - public status: StatusProviderService + public status: StatusProviderService, + public availableItems: AvailableItemsService ) { // Load saved view mode from localStorage const savedViewMode = localStorage.getItem("d2ap-view-mode") as "table" | "cards"; @@ -158,6 +171,29 @@ export class ResultsComponent implements OnInit, OnDestroy { } ngOnInit(): void { + // Check for item counts per slot so that we can show a warning if no items are available for a slot + for (let slot of Object.values(ArmorSlot) as ArmorSlot[]) { + if (slot === ArmorSlot.ArmorSlotNone) continue; // Skip the 'none' slot + if (this.itemCountPerSlot[slot] === undefined || this.itemCountPerSlot[slot] === null) + continue; + this.itemCountPerSlot[slot] = [0, 0]; + const sll = slot; + this.availableItems + .getItemsForSlot$(slot) + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe((items) => { + const exoticCount = items.filter((i) => i.isExotic).length; + this.itemCountPerSlot[sll] = [ + items.length - exoticCount, // Legendary count + exoticCount, // Exotic count + ]; + + this.slotsWithNoLegendaryItems = Object.values(this.itemCountPerSlot).filter( + (count) => count[0] === 0 + ).length; + }); + } + this.status.status.pipe(takeUntil(this.ngUnsubscribe)).subscribe((s) => { this.isCalculatingPermutations = s.calculatingPermutations || s.calculatingResults; @@ -171,45 +207,54 @@ export class ResultsComponent implements OnInit, OnDestroy { this.computationProgress = progress; }); // - this.configService.configuration.pipe(takeUntil(this.ngUnsubscribe)).subscribe((c: any) => { - this.selectedClass = c.characterClass; - this._config_assumeLegendariesMasterworked = c.assumeLegendariesMasterworked; - this._config_assumeExoticsMasterworked = c.assumeExoticsMasterworked; - this._config_tryLimitWastedStats = c.tryLimitWastedStats; - - this._config_maximumStatMods = c.maximumStatMods; - this._config_legacyArmor = c.allowLegacyArmor; - this._config_onlyUseMasterworkedExotics = c.onlyUseMasterworkedExotics; - this._config_onlyUseMasterworkedLegendaries = c.onlyUseMasterworkedLegendaries; - this._config_includeCollectionRolls = c.includeCollectionRolls; - this._config_includeVendorRolls = c.includeVendorRolls; - this._config_onlyShowResultsWithNoWastedStats = c.onlyShowResultsWithNoWastedStats; - this._config_assumeEveryLegendaryIsArtifice = c.assumeEveryLegendaryIsArtifice; - this._config_assumeEveryExoticIsArtifice = c.assumeEveryExoticIsArtifice; - this._config_enforceFeaturedArmor = c.enforceFeaturedArmor; - this._config_selectedExotics = c.selectedExotics; - this._config_armorPerkLimitation = Object.entries(c.armorPerks) - .filter((v: any) => v[1].value != ArmorPerkOrSlot.Any) - .map((k: any) => k[1]); - this._config_modslotLimitation = Object.entries(c.maximumModSlots) - .filter((v: any) => v[1].value < 5) - .map((k: any) => k[1]); - - let columns = [ - "exotic", - "health", - "melee", - "grenade", - "super", - "class", - "weapon", - "total", - "mods", - ]; - if (c.includeVendorRolls || c.includeCollectionRolls) columns.push("source"); - columns.push("dropdown"); - this.shownColumns = columns; - }); + this.configService.configuration + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe(async (c: any) => { + this.selectedClass = c.characterClass; + this._config_assumeLegendariesMasterworked = c.assumeLegendariesMasterworked; + this._config_assumeExoticsMasterworked = c.assumeExoticsMasterworked; + this._config_tryLimitWastedStats = c.tryLimitWastedStats; + + this._config_maximumStatMods = c.maximumStatMods; + this._config_legacyArmor = c.allowLegacyArmor; + this._config_onlyUseMasterworkedExotics = c.onlyUseMasterworkedExotics; + this._config_onlyUseMasterworkedLegendaries = c.onlyUseMasterworkedLegendaries; + this._config_includeCollectionRolls = c.includeCollectionRolls; + this._config_includeVendorRolls = c.includeVendorRolls; + this._config_onlyShowResultsWithNoWastedStats = c.onlyShowResultsWithNoWastedStats; + this._config_assumeEveryLegendaryIsArtifice = c.assumeEveryLegendaryIsArtifice; + this._config_assumeEveryExoticIsArtifice = c.assumeEveryExoticIsArtifice; + this._config_enforceFeaturedArmor = c.enforceFeaturedArmor; + this._config_selectedExotics = c.selectedExotics; + + if (c.selectedExotics.length > 0 && c.selectedExotics[0] > 0) { + this._selectedExoticSlot = await this.inventory.getSlotByItemHash(c.selectedExotics[0]); + } else { + this._selectedExoticSlot = ArmorSlot.ArmorSlotNone; + } + + this._config_armorPerkLimitation = Object.entries(c.armorPerks) + .filter((v: any) => v[1].value != ArmorPerkOrSlot.Any) + .map((k: any) => k[1]); + this._config_modslotLimitation = Object.entries(c.maximumModSlots) + .filter((v: any) => v[1].value < 5) + .map((k: any) => k[1]); + + let columns = [ + "exotic", + "health", + "melee", + "grenade", + "super", + "class", + "weapon", + "total", + "mods", + ]; + if (c.includeVendorRolls || c.includeCollectionRolls) columns.push("source"); + columns.push("dropdown"); + this.shownColumns = columns; + }); this.inventory.armorResults.pipe(takeUntil(this.ngUnsubscribe)).subscribe(async (value) => { this._results = value.results; @@ -254,6 +299,10 @@ export class ResultsComponent implements OnInit, OnDestroy { }; } + getArmorSlotName(slot: ArmorSlot | string): string { + return ArmorSlotNames[slot as ArmorSlot] || "Unknown Slot"; + } + cancelCalculation() { this.inventory.cancelCalculation(); } diff --git a/src/app/data/enum/armor-slot.ts b/src/app/data/enum/armor-slot.ts index a43e1271..4eef94f0 100644 --- a/src/app/data/enum/armor-slot.ts +++ b/src/app/data/enum/armor-slot.ts @@ -15,6 +15,8 @@ * along with this program. If not, see . */ +import { EnumDictionary } from "../types/EnumDictionary"; + export enum ArmorSlot { ArmorSlotNone, ArmorSlotHelmet, @@ -23,3 +25,12 @@ export enum ArmorSlot { ArmorSlotLegs, ArmorSlotClass, } + +export const ArmorSlotNames: EnumDictionary = { + [ArmorSlot.ArmorSlotNone]: "None", + [ArmorSlot.ArmorSlotHelmet]: "Helmet", + [ArmorSlot.ArmorSlotGauntlet]: "Gauntlet", + [ArmorSlot.ArmorSlotChest]: "Chest", + [ArmorSlot.ArmorSlotLegs]: "Legs", + [ArmorSlot.ArmorSlotClass]: "Class Item", +}; diff --git a/src/app/services/inventory.service.ts b/src/app/services/inventory.service.ts index c65f23dc..2d8267a5 100644 --- a/src/app/services/inventory.service.ts +++ b/src/app/services/inventory.service.ts @@ -631,4 +631,15 @@ export class InventoryService { return false; } } + + getSlotByItemHash(hash: number): PromiseLike { + return this.db.manifestArmor + .where("hash") + .equals(hash) + .first() + .then((item) => { + if (item == null) return ArmorSlot.ArmorSlotNone; + return item.slot; + }); + } }