UNPKG

farming-weight

Version:

Tools for calculating farming weight and fortune in Hypixel Skyblock

1,049 lines (1,048 loc) 58.6 kB
import { normalizeAttributes } from '../constants/attributes.js'; import { getChipInputLevel, getChipLevel, getChipTempMultiplierPerLevel, normalizeChipId, normalizeChipLevels, } from '../constants/chips.js'; import { CROP_INFO } from '../constants/crops.js'; import { compareRarity } from '../constants/reforge-types.js'; import { getContributoryStats, Stat } from '../constants/stats.js'; import { TEMPORARY_FORTUNE } from '../constants/tempfortune.js'; import { getQueryStats, UpgradeAction, UpgradeCategory, } from '../constants/upgrades.js'; import { buildEffectEnvironment } from '../effects/environment.js'; import { resolveOverbloomBreakdown, resolveStatBreakdown } from '../effects/resolver.js'; import { effectsToSummaries } from '../effects/summary.js'; import { FarmingAccessory } from '../fortune/farmingaccessory.js'; import { ArmorSet, FarmingArmor } from '../fortune/farmingarmor.js'; import { FarmingEquipment } from '../fortune/farmingequipment.js'; import { FarmingPet } from '../fortune/farmingpet.js'; import { FarmingTool } from '../fortune/farmingtool.js'; import { FarmingPets } from '../items/pets.js'; import { FARMING_ATTRIBUTE_SHARD_CLASSES } from '../items/sources/attributes.js'; import { GARDEN_CHIP_CLASSES } from '../items/sources/chips.js'; import { FARMING_TOOLS } from '../items/tools.js'; import { getSourceProgress } from '../upgrades/getsourceprogress.js'; import { withGroupedUpgrades } from '../upgrades/groups.js'; import { getFakeItem } from '../upgrades/itemregistry.js'; import { CROP_FORTUNE_SOURCES } from '../upgrades/sources/cropsources.js'; import { collectCropFortuneSourceEffects, collectGeneralFortuneSourceEffects, } from '../upgrades/sources/effectsources.js'; import { GENERAL_FORTUNE_SOURCES } from '../upgrades/sources/generalsources.js'; import { filterAndSortUpgrades } from '../upgrades/upgradeutils.js'; import { nextRarity, previousRarity } from '../util/itemstats.js'; import { calculateDetailedDropsFromEffects } from '../util/ratecalc-effects.js'; import { createFarmingWeightCalculator } from '../weight/weightcalc.js'; export function createFarmingPlayer(options) { return new FarmingPlayer(options); } export class FarmingPlayer { get fortune() { return this.permFortune + this.tempFortune; } get attributes() { return this.options.attributes ?? {}; } constructor(options) { this.setOptions(options); } setOptions(options) { this.options = options; this.activeAccessories = []; this.options.attributes = normalizeAttributes(this.options.attributes); this.options.chips = normalizeChipLevels(this.options.chips); this.populatePets(); this.populateTools(); this.populateArmor(); this.populateEquipment(); this.populateActiveAccessories(); this.permFortune = this.getGeneralFortune(); this.tempFortune = this.getTempFortune(); } populatePets() { this.options.pets ??= []; if (this.options.pets[0] instanceof FarmingPet) { this.pets = this.options.pets.sort((a, b) => b.fortune - a.fortune); for (const pet of this.pets) pet.setOptions(this.options); } else { this.pets = FarmingPet.fromArray(this.options.pets, this.options); } this.selectedPet = this.options.selectedPet ?? this.pets.find((p) => p.pet.active) ?? this.pets[0]; } populateTools() { this.options.tools ??= []; if (this.options.tools[0] instanceof FarmingTool) { this.tools = this.options.tools; for (const tool of this.tools) tool.setOptions(this.options); this.tools.sort((a, b) => b.fortune - a.fortune); } else { this.tools = FarmingTool.fromArray(this.options.tools, this.options); } if (this.options.selectedTool) { const uuid = this.options.selectedTool.item.uuid; this.selectedTool = this.tools.find((t) => t.item.uuid === uuid) ?? this.tools[0]; } else { this.selectedTool = this.tools[0]; } } populateArmor() { this.options.armor ??= []; if (this.options.armor instanceof ArmorSet) { this.armorSet = this.options.armor; this.armor = this.armorSet.pieces; this.armor.sort((a, b) => b.potential - a.potential); this.equipment = this.armorSet.equipmentPieces; this.equipment.sort((a, b) => b.fortune - a.fortune); this.armorSet.setOptions(this.options); } else if (this.options.armor[0] instanceof FarmingArmor) { this.armor = this.options.armor.sort((a, b) => b.potential - a.potential); for (const a of this.armor) a.setOptions(this.options); this.armorSet = new ArmorSet(this.armor); } else { this.armor = FarmingArmor.fromArray(this.options.armor, this.options); this.armorSet = new ArmorSet(this.armor); } } populateEquipment() { this.options.equipment ??= []; // If equipment was already set by populateArmor (from ArmorSet), don't overwrite it if (this.equipment && this.equipment.length > 0) { // Load in equipment to armor set if it's empty if (this.armorSet.equipment.filter((e) => e).length === 0) { this.armorSet.setEquipment(this.equipment); } return; } if (this.options.equipment[0] instanceof FarmingEquipment) { this.equipment = this.options.equipment.sort((a, b) => b.fortune - a.fortune); for (const e of this.equipment) e.setOptions(this.options); } else { this.equipment = FarmingEquipment.fromArray(this.options.equipment, this.options); } // Load in equipment to armor set if it's empty if (this.armorSet.equipment.filter((e) => e).length === 0) { this.armorSet.setEquipment(this.equipment); } } populateActiveAccessories() { this.options.accessories ??= []; this.activeAccessories = []; let pool = []; if (this.options.accessories[0] instanceof FarmingAccessory) { pool = this.options.accessories.sort((a, b) => b.fortune - a.fortune); for (const acc of pool) acc.setOptions(this.options); } else { pool = FarmingAccessory.fromArray(this.options.accessories, this.options); } // Filter by unique family (keep highest rarity/fortune) const familyMap = new Map(); const others = []; for (const acc of pool) { const family = acc.info.family; if (family) { const existing = familyMap.get(family); if (!existing || (acc.info.familyOrder ?? 0) > (existing.info.familyOrder ?? 0)) { familyMap.set(family, acc); } } else { others.push(acc); } } this.activeAccessories = [...familyMap.values(), ...others].sort((a, b) => b.fortune - a.fortune); this.accessories = pool; } syncActiveAccessories() { this.options.accessories = this.accessories; this.populateActiveAccessories(); } changeArmor(armor) { this.armorSet = new ArmorSet(armor.sort((a, b) => b.fortune - a.fortune)); } selectTool(tool) { this.selectedTool = tool; this.options.selectedTool = tool; this.permFortune = this.getGeneralFortune(); } selectPet(pet) { this.selectedPet = pet; this.options.selectedPet = pet; this.permFortune = this.getGeneralFortune(); } setStrength(strength) { this.options.strength = strength; for (const pet of this.pets) { pet.fortune = pet.getFortune(); } this.permFortune = this.getGeneralFortune(); } getProgress(stats) { return getSourceProgress(this, GENERAL_FORTUNE_SOURCES, false, stats); } getPetProgress(stats) { return this.pets.flatMap((pet) => pet.getProgress(stats, this)); } getUpgrades(options) { const hasExplicitStats = (options?.stats?.length ?? 0) > 0 || options?.stat !== undefined; const stats = hasExplicitStats ? getQueryStats(options) : undefined; const upgrades = getSourceProgress(this, GENERAL_FORTUNE_SOURCES, false, stats).flatMap((source) => source.upgrades ?? []); const armorSetUpgrades = this.armorSet.getUpgrades(options); if (armorSetUpgrades.length > 0) { upgrades.push(...armorSetUpgrades); } // For non-FarmingFortune stats (e.g. Overbloom), tool upgrades aren't tied to a single // crop and won't be surfaced by getCropUpgrades, so include them here. if (stats?.some((stat) => stat !== Stat.FarmingFortune)) { const tools = this.options.selectedCrop !== undefined ? [this.getSelectedCropTool(this.options.selectedCrop)].filter((tool) => tool !== undefined) : this.tools; for (const tool of tools) { upgrades.push(...tool.getUpgrades(options)); } } const filtered = filterAndSortUpgrades(upgrades, options); return options?.includeUpgradeGroups ? withGroupedUpgrades(filtered) : filtered; } getStatView(query) { const stats = query.stats.length > 0 ? query.stats : [Stat.FarmingFortune]; const totals = {}; const breakdowns = {}; for (const stat of stats) { totals[stat] = this.getStat(stat, query.crop); breakdowns[stat] = this.getStatBreakdown(stat, query.crop); } const env = this.buildEnvironment(query.crop); const effects = effectsToSummaries(this.collectEffects(env), stats); const upgrades = this.getUpgrades({ stats }); return { totals, breakdowns, effects, upgrades, }; } getCropUpgrades(crop, tool) { const upgrades = []; if (!crop) return upgrades; const cropUpgrades = this.getCropProgress(crop); for (const source of cropUpgrades) { if (source.upgrades) { upgrades.push(...source.upgrades); } } const cropTool = tool ?? this.getSelectedCropTool(crop); if (cropTool) { const toolUpgrades = cropTool.getUpgrades({ stat: CROP_INFO[crop].fortuneType }); upgrades.push(...toolUpgrades); } else { const startingInfo = FARMING_TOOLS[CROP_INFO[crop].startingTool]; if (startingInfo) { const fakeItem = getFakeItem(startingInfo.skyblockId, this.options); const startingToolFortune = fakeItem?.getStat(CROP_INFO[crop].fortuneType, crop) ?? 0; upgrades.push({ title: startingInfo.name, action: UpgradeAction.Purchase, increase: startingToolFortune, stats: { [CROP_INFO[crop].fortuneType]: startingToolFortune, }, wiki: startingInfo.wiki, purchase: startingInfo.skyblockId, max: fakeItem?.getProgress()?.reduce((acc, p) => acc + p.max, 0) ?? 0, category: UpgradeCategory.Item, cost: { copper: 250, }, meta: { type: 'buy_item', id: startingInfo.skyblockId, }, }); } } upgrades.sort((a, b) => b.increase - a.increase); return upgrades; } getGeneralFortune() { const breakdown = this.getStatBreakdown(Stat.FarmingFortune); this.breakdown = breakdown; return Object.values(breakdown).reduce((acc, val) => acc + val.value, 0); } getToolStat(tool, stat, crop) { let val = 0; if (stat === Stat.FarmingFortune) { val += tool.getFortune(); } else { val += tool.getStat(stat); } return val; } /** * Build the canonical {@link EffectEnvironment} for this player + crop. * Thin wrapper over {@link buildEffectEnvironment}; provided so callers * never have to import the helper directly. */ buildEnvironment(crop) { return buildEffectEnvironment(this, crop); } /** * Aggregate the declarative {@link Effect}[] from every active source on * the player: armor set (armor + equipment + set bonuses, including each * piece's reforge & enchants), the selected tool, active accessories, the * selected pet, every attribute shard, and every garden chip. * * Sources with a `getActive` guard that returns `active: false` are * skipped. Sources whose `getEffects` returns `[]` contribute nothing. * * This method is the single seam consumed by the new effect-resolver * pipeline (`getStat`, `getRates`). It does **not** apply scopes - that's * the resolver's job - so the returned list includes every effect the * player could plausibly emit, with their declarative scopes intact. */ collectEffects(env) { const effects = []; effects.push(...collectGeneralFortuneSourceEffects(this)); // Armor set: armor pieces, equipment pieces, and set bonuses. // (Per-piece reforge/enchant effects are emitted by each piece.) effects.push(...this.armorSet.getEffects(env)); // Tool: for crop-scoped calculations, use the selected/best tool for // that crop. For crop-agnostic stat queries, use the selected tool. const tool = env.crop ? this.getSelectedCropTool(env.crop) : this.selectedTool; if (tool) { effects.push(...tool.getEffects(env)); } // Active accessories only - the same filtering `getStatBreakdown` // applies, so we don't double-count Helianthus etc. for (const accessory of this.activeAccessories) { effects.push(...accessory.getEffects(env)); } // Selected pet. if (this.selectedPet) { effects.push(...this.selectedPet.getEffects(env, this)); } // Attribute shards. for (const shard of Object.values(FARMING_ATTRIBUTE_SHARD_CLASSES)) { const active = shard.getActive?.(this, env); if (active && active.active === false) continue; effects.push(...shard.getEffects(this, env)); } // Garden chips. for (const chip of Object.values(GARDEN_CHIP_CLASSES)) { const active = chip.getActive?.(this, env); if (active && active.active === false) continue; effects.push(...chip.getEffects(this, env)); } effects.push(...collectCropFortuneSourceEffects(this, env)); return effects; } getStat(stat, targetCrop) { const breakdown = this.getStatBreakdown(stat, targetCrop); return Object.values(breakdown).reduce((acc, val) => acc + val.value, 0); } getStatBreakdown(stat, targetCrop) { const breakdown = {}; const add = (name, value, stat) => { if (value !== 0) { if (!breakdown[name]) { breakdown[name] = { value: 0, stat }; } breakdown[name].value += value; } }; const contributingStats = getContributoryStats(stat); const env = this.buildEnvironment(targetCrop); const effects = this.collectEffects(env); const statContext = { env, crop: targetCrop }; for (const targetStat of contributingStats) { const resolved = targetStat === Stat.Overbloom ? resolveOverbloomBreakdown(effects, statContext, Stat.Overbloom) : resolveStatBreakdown(effects, targetStat, statContext); for (const [name, value] of Object.entries(resolved)) { add(name, value, targetStat); } } // Temporary Fortune if (this.tempFortuneBreakdown) { for (const [name, entry] of Object.entries(this.tempFortuneBreakdown)) { // Temporary fortune is always FarmingFortune if (contributingStats.includes(entry.stat)) { add(name, entry.value, entry.stat); } } } // Calculate base fortune total before late-phase modifiers const baseFortune = Object.values(breakdown).reduce((acc, val) => acc + val.value, 0); this.baseFortune = baseFortune; // Create context for late calculations const lateCtx = { player: this, baseFortune, stat, crop: targetCrop, }; // Pet late calculations (ex: Pig Pet's Trample, Rose Dragon's Symbiosis) // Late effects are included in the pet's breakdown entry, not as separate entries if (this.selectedPet) { const lateResult = this.selectedPet.getLateStats(lateCtx); const petName = this.selectedPet.info.name ?? 'Selected Pet'; // Get the stat type from the late breakdown entries (they specify their stat) const lateBreakdownEntries = lateResult.breakdown ? Object.values(lateResult.breakdown) : []; const contributingLateEntry = lateBreakdownEntries.find((entry) => contributingStats.includes(entry.stat)); const lateStat = contributingLateEntry?.stat ?? lateBreakdownEntries[0]?.stat ?? Stat.FarmingFortune; const lateResultContributes = contributingStats.includes(lateStat); // Add late additive effects to the pet's entry if (lateResultContributes && lateResult.additive) { if (breakdown[petName]) { breakdown[petName].value += lateResult.additive; } else { breakdown[petName] = { value: lateResult.additive, stat: lateStat }; } } // Apply multiplier to total fortune (add as reduction to pet's entry) if (lateResultContributes && lateResult.multiplier !== undefined && lateResult.multiplier !== 1) { const reduction = baseFortune * (lateResult.multiplier - 1); if (breakdown[petName]) { breakdown[petName].value += reduction; } else { breakdown[petName] = { value: reduction, stat: lateStat }; } } } return breakdown; } getTempFortune() { let sum = 0; const breakdown = {}; // Hypercharge multiplier scales by chip rarity tiers: // Rare (<=10): 1 + 0.03 * level // Epic (<=15): 1 + 0.03 * level // Legendary (>15): 2x boost to temporary fortune sources const hyperLevel = getChipLevel(getChipInputLevel(this.options.chips, 'hypercharge')) ?? 0; const perLevel = getChipTempMultiplierPerLevel('hypercharge', hyperLevel) ?? 0; const hyperchargeMultiplier = 1 + perLevel * hyperLevel; if (!this.options.temporaryFortune) { this.tempFortuneBreakdown = breakdown; return sum; } for (const entry of Object.keys(this.options.temporaryFortune)) { const source = TEMPORARY_FORTUNE[entry]; if (!source) continue; const fortune = source.fortune(this.options.temporaryFortune); if (fortune) { const stat = source.stat ?? Stat.FarmingFortune; // Hypercharge chip only boosts farming fortune sources, not Overbloom or other stats. const boosted = stat === Stat.FarmingFortune ? fortune * hyperchargeMultiplier : fortune; breakdown[source.name] = { value: boosted, stat }; if (stat === Stat.FarmingFortune) sum += boosted; } } this.tempFortuneBreakdown = breakdown; // Merge temp breakdown into main breakdown if (this.breakdown) { Object.assign(this.breakdown, breakdown); } else { this.breakdown = breakdown; } return sum; } getCropFortune(crop, tool = this.selectedTool) { // If no crop, we return general farming fortune const fortuneType = crop ? CROP_INFO[crop].fortuneType : Stat.FarmingFortune; const breakdown = this.getStatBreakdown(fortuneType, crop); const fortune = Object.values(breakdown).reduce((acc, val) => acc + val.value, 0); return { fortune, breakdown, }; } getCropProgress(crop, stats) { return getSourceProgress({ crop, player: this }, CROP_FORTUNE_SOURCES, false, stats); } getRates(crop, blocksBroken) { const tool = this.getBestTool(crop); const cropFortune = this.getCropFortune(crop, tool); const fortune = cropFortune.fortune; const env = this.buildEnvironment(crop); const effects = this.collectEffects(env); return calculateDetailedDropsFromEffects({ crop, blocksBroken, farmingFortune: fortune, armorPieces: this.armorSet.specialDropsCount(crop), bountiful: tool?.bountiful ?? false, mooshroom: this.selectedPet?.type === FarmingPets.MooshroomCow, maxTool: tool?.getCurrentLevelProgress().maxed ?? false, chips: this.options.chips, pet: this.selectedPet, effects, env, }); } getUpgradeRateImpact(upgrade, options) { const before = this.getRates(options.crop, options.blocksBroken); const clonedPlayer = this.clone(); clonedPlayer.applyUpgrade(upgrade); const after = clonedPlayer.getRates(options.crop, options.blocksBroken); return { before, after, delta: diffDetailedDrops(before, after), }; } getWeightCalc(info) { return createFarmingWeightCalculator({ collection: this.options.collection, pests: this.options.bestiaryKills, farmingXp: this.options.farmingXp, levelCapUpgrade: Math.min((this.options.farmingLevel ?? 0) - 50, 0), ...info, }); } getBestTool(crop) { return this.tools.find((t) => t.crops.includes(crop)); } getSelectedCropTool(crop) { const matches = this.tools.filter((t) => t.crops.includes(crop)); // If specific tool is selected, use it if (this.selectedTool && this.selectedTool.crops.includes(crop)) { return this.selectedTool; } return matches.sort((a, b) => b.fortune - a.fortune)[0]; } applyUpgrade(upgrade) { if (!upgrade.meta) return; const { type, itemUuid, key, value, id } = upgrade.meta; if (type === 'upgrade_group') { for (const groupedUpgrade of upgrade.groupedUpgrades ?? []) { this.applyUpgrade(groupedUpgrade); } this.permFortune = this.getGeneralFortune(); return; } if ((type === 'pet_level' || type === 'pet_item') && itemUuid) { const index = this.pets.findIndex((pet) => pet.pet.uuid === itemUuid); const target = this.pets[index]; if (target) { const nextPetData = { ...target.pet }; if (type === 'pet_level' && value) { nextPetData.exp = target.getXpForLevel(Number(value)); } else if (type === 'pet_item' && id) { nextPetData.heldItem = id; } const updatedPet = new FarmingPet(nextPetData, this.options); this.pets[index] = updatedPet; this.options.pets = this.pets; if (this.selectedPet?.pet.uuid === itemUuid) { this.selectedPet = updatedPet; this.options.selectedPet = updatedPet; } this.permFortune = this.getGeneralFortune(); } return; } if (itemUuid) { const candidates = [...this.tools, ...this.armor, ...this.equipment, ...this.accessories]; const target = candidates.find((i) => i.item.uuid === itemUuid); if (target) { if (type === 'enchant' && key) { target.item.enchantments ??= {}; target.item.enchantments[key] = Number(value); // Re-instantiate to update enchantment-based logic if (target instanceof FarmingTool) { const idx = this.tools.indexOf(target); if (idx >= 0) { const updatedTool = new FarmingTool(target.item, this.options); this.tools[idx] = updatedTool; if (this.selectedTool === target) { this.selectedTool = updatedTool; } } } else if (target instanceof FarmingArmor) { const idx = this.armor.indexOf(target); if (idx >= 0) { const updatedPiece = new FarmingArmor(target.item, this.options); this.armor[idx] = updatedPiece; this.armorSet.updateArmorSlot(updatedPiece); } } else if (target instanceof FarmingEquipment) { const idx = this.equipment.indexOf(target); if (idx >= 0) { const updatedPiece = new FarmingEquipment(target.item, this.options); this.equipment[idx] = updatedPiece; this.armorSet.updateEquipmentSlot(updatedPiece); } } else if (target instanceof FarmingAccessory) { const idx = this.accessories.indexOf(target); if (idx >= 0) { this.accessories[idx] = new FarmingAccessory(target.item, this.options); } } } else if (type === 'reforge' && id) { target.item.attributes ??= {}; target.item.attributes.modifier = id; // Re-initialize to update logic if (target instanceof FarmingTool) { const idx = this.tools.indexOf(target); if (idx >= 0) { const updatedTool = new FarmingTool(target.item, this.options); this.tools[idx] = updatedTool; if (this.selectedTool === target) { this.selectedTool = updatedTool; } } } else if (target instanceof FarmingArmor) { const idx = this.armor.indexOf(target); if (idx >= 0) { const updatedPiece = new FarmingArmor(target.item, this.options); this.armor[idx] = updatedPiece; this.armorSet.updateArmorSlot(updatedPiece); } } else if (target instanceof FarmingEquipment) { const idx = this.equipment.indexOf(target); if (idx >= 0) { const updatedPiece = new FarmingEquipment(target.item, this.options); this.equipment[idx] = updatedPiece; this.armorSet.updateEquipmentSlot(updatedPiece); } } else if (target instanceof FarmingAccessory) { const idx = this.accessories.indexOf(target); if (idx >= 0) { this.accessories[idx] = new FarmingAccessory(target.item, this.options); } } } else if (type === 'item' && id === 'farming_for_dummies_count') { target.item.attributes ??= {}; target.item.attributes.farming_for_dummies_count = String(value); // Re-instantiate so getUpgrades reflects the updated FFD count if (target instanceof FarmingTool) { const idx = this.tools.indexOf(target); if (idx >= 0) { this.tools[idx] = new FarmingTool(target.item, this.options); } } } else if (type === 'gem' && upgrade.meta.slot && value) { target.item.gems ??= {}; target.item.gems[upgrade.meta.slot] = String(value); // Re-instantiate so getUpgrades reflects the updated gem // Re-instantiate so getUpgrades reflects the updated gem if (target instanceof FarmingTool) { const idx = this.tools.indexOf(target); if (idx >= 0) { const newTool = new FarmingTool(target.item, this.options); this.tools[idx] = newTool; if (this.selectedTool === target) { this.selectedTool = newTool; } } } else if (target instanceof FarmingArmor) { const idx = this.armor.indexOf(target); if (idx >= 0) { const updatedPiece = new FarmingArmor(target.item, this.options); this.armor[idx] = updatedPiece; this.armorSet.updateArmorSlot(updatedPiece); } } else if (target instanceof FarmingEquipment) { const idx = this.equipment.indexOf(target); if (idx >= 0) { const updatedPiece = new FarmingEquipment(target.item, this.options); this.equipment[idx] = updatedPiece; this.armorSet.updateEquipmentSlot(updatedPiece); } } else if (target instanceof FarmingAccessory) { const idx = this.accessories.indexOf(target); if (idx >= 0) { this.accessories[idx] = new FarmingAccessory(target.item, this.options); } } } else if (type === 'item' && id === 'rarity_upgrades' && value) { target.item.attributes ??= {}; target.item.attributes.rarity_upgrades = String(value); setItemRarityAttribute(target.item, nextRarity(target.rarity)); // Recomb affects rarity, which affects stats. Need to reload tool. if (target instanceof FarmingTool) { const idx = this.tools.indexOf(target); if (idx >= 0) { this.tools[idx] = new FarmingTool(target.item, this.options); } } else if (target instanceof FarmingArmor) { const idx = this.armor.indexOf(target); if (idx >= 0) { const updatedPiece = new FarmingArmor(target.item, this.options); this.armor[idx] = updatedPiece; this.armorSet.updateArmorSlot(updatedPiece); } } else if (target instanceof FarmingEquipment) { const idx = this.equipment.indexOf(target); if (idx >= 0) { const updatedPiece = new FarmingEquipment(target.item, this.options); this.equipment[idx] = updatedPiece; this.armorSet.updateEquipmentSlot(updatedPiece); } } else if (target instanceof FarmingAccessory) { const idx = this.accessories.indexOf(target); if (idx >= 0) { this.accessories[idx] = new FarmingAccessory(target.item, this.options); } } } else if (type === 'buy_item' && id) { // Tier upgrade: replace the old item with a new one const newItem = getFakeItem(id); if (newItem) { // Transfer enchantments, attributes, gems from old item newItem.item.enchantments = { ...newItem.item.enchantments, ...target.item.enchantments, }; newItem.item.attributes = { ...newItem.item.attributes, ...target.item.attributes, }; newItem.item.gems = { ...newItem.item.gems, ...target.item.gems, }; setItemRarityAttribute(newItem.item, getUpgradedItemRarity(target.rarity, target.info.maxRarity, newItem.info.maxRarity)); // Preserve the old item's UUID so the item remains trackable newItem.item.uuid = target.item.uuid; if (target instanceof FarmingTool && newItem instanceof FarmingTool) { const idx = this.tools.indexOf(target); if (idx >= 0) { const newTool = new FarmingTool(newItem.item, this.options); this.tools[idx] = newTool; // console.log('DEBUG applyUpgrade check:', { target: target.item.skyblockId, selected: this.selectedTool?.item.skyblockId, equal: this.selectedTool === target }); if (this.selectedTool === target) { this.selectedTool = newTool; // console.log('DEBUG applyUpgrade updated selectedTool to', newTool.item.skyblockId); } } } else if (target instanceof FarmingArmor && newItem instanceof FarmingArmor) { const idx = this.armor.indexOf(target); if (idx >= 0) { const updatedPiece = new FarmingArmor(newItem.item, this.options); this.armor[idx] = updatedPiece; this.armorSet.updateArmorSlot(updatedPiece); } } else if (target instanceof FarmingEquipment && newItem instanceof FarmingEquipment) { const idx = this.equipment.indexOf(target); if (idx >= 0) { target.applyTierUpgradeStateTo(newItem); const updatedPiece = new FarmingEquipment(newItem.item, this.options); this.equipment[idx] = updatedPiece; this.armorSet.updateEquipmentSlot(updatedPiece); } } else if (target instanceof FarmingAccessory && newItem instanceof FarmingAccessory) { const idx = this.accessories.indexOf(target); if (idx >= 0) { this.accessories[idx] = new FarmingAccessory(newItem.item, this.options); } } this.permFortune = this.getGeneralFortune(); } } if (target instanceof FarmingAccessory) { this.syncActiveAccessories(); } this.permFortune = this.getGeneralFortune(); } } else if (type === 'skill') { if (key === 'farmingLevel' && value) { this.options.farmingLevel = Number(value); } else if (key === 'anitaBonus' && value) { this.options.anitaBonus = Number(value); } else if (key === 'communityCenter' && value) { this.options.communityCenter = Number(value); } this.permFortune = this.getGeneralFortune(); } else if (type === 'plot' && (value || id)) { this.options.plotsUnlocked = Number(value); // Also add to plots array if using id (the plot name) if (id) { this.options.plots ??= []; if (!this.options.plots.includes(id)) { this.options.plots.push(id); } } this.permFortune = this.getGeneralFortune(); } else if (type === 'attribute' && key && value) { this.options.attributes ??= {}; this.options.attributes[key] = Number(value); this.permFortune = this.getGeneralFortune(); } else if (type === 'chip' && id && value) { this.options.chips ??= {}; const normalizedId = normalizeChipId(id); if (normalizedId) { this.options.chips[normalizedId] = Number(value); } this.permFortune = this.getGeneralFortune(); this.tempFortune = this.getTempFortune(); } else if (type === 'crop_upgrade' && key && value) { this.options.cropUpgrades ??= {}; // @ts-ignore this.options.cropUpgrades[key] = Number(value); this.permFortune = this.getGeneralFortune(); } else if (type === 'setting' && key && value) { if (key === 'cocoaFortuneUpgrade') { this.options.cocoaFortuneUpgrade = Number(value); } else if (key === 'dnaMilestone') { this.options.dnaMilestone = Number(value); } else if (key === 'refinedTruffles') { this.options.refinedTruffles = Number(value); } this.permFortune = this.getGeneralFortune(); } else if (type === 'unlock' && id) { if (id === 'personal_best') { this.options.personalBestsUnlocked = true; } else if (id === 'exportable_crop' && key) { this.options.exportableCrops ??= {}; this.options.exportableCrops[key] = true; } this.permFortune = this.getGeneralFortune(); } else if (type === 'buy_item' && id) { const newItem = getFakeItem(id); if (newItem) { if (newItem instanceof FarmingTool) { const oldIdx = itemUuid ? this.tools.findIndex((t) => t.item.uuid === itemUuid) : -1; if (oldIdx >= 0) { const oldItem = this.tools[oldIdx]; // Transfer enchantments, attributes, gems from old item newItem.item.enchantments = { ...newItem.item.enchantments, ...oldItem.item.enchantments, }; newItem.item.attributes = { ...newItem.item.attributes, ...oldItem.item.attributes, }; newItem.item.gems = { ...newItem.item.gems, ...oldItem.item.gems }; // Re-instantiate to recalculate fortune with transferred properties this.tools[oldIdx] = new FarmingTool(newItem.item, this.options); } else { this.tools.push(newItem); } } else if (newItem instanceof FarmingArmor) { const oldIdx = itemUuid ? this.armor.findIndex((a) => a.item.uuid === itemUuid) : -1; if (oldIdx >= 0) { const oldItem = this.armor[oldIdx]; newItem.item.enchantments = { ...newItem.item.enchantments, ...oldItem.item.enchantments, }; newItem.item.attributes = { ...newItem.item.attributes, ...oldItem.item.attributes, }; newItem.item.gems = { ...newItem.item.gems, ...oldItem.item.gems }; this.armor[oldIdx] = new FarmingArmor(newItem.item, this.options); } else { const addedPiece = new FarmingArmor(newItem.item, this.options); this.armor.push(addedPiece); this.armorSet.updateArmorSlot(addedPiece); } } else if (newItem instanceof FarmingEquipment) { const oldIdx = itemUuid ? this.equipment.findIndex((e) => e.item.uuid === itemUuid) : -1; if (oldIdx >= 0) { const oldItem = this.equipment[oldIdx]; newItem.item.enchantments = { ...newItem.item.enchantments, ...oldItem.item.enchantments, }; newItem.item.attributes = { ...newItem.item.attributes, ...oldItem.item.attributes, }; newItem.item.gems = { ...newItem.item.gems, ...oldItem.item.gems }; oldItem.applyTierUpgradeStateTo(newItem); this.equipment[oldIdx] = new FarmingEquipment(newItem.item, this.options); } else { const addedPiece = new FarmingEquipment(newItem.item, this.options); this.equipment.push(addedPiece); this.armorSet.updateEquipmentSlot(addedPiece); } } else if (newItem instanceof FarmingAccessory) { const oldIdx = itemUuid ? this.accessories.findIndex((a) => a.item.uuid === itemUuid) : -1; if (oldIdx >= 0) { const oldItem = this.accessories[oldIdx]; newItem.item.enchantments = { ...newItem.item.enchantments, ...oldItem.item.enchantments, }; newItem.item.attributes = { ...newItem.item.attributes, ...oldItem.item.attributes, }; newItem.item.gems = { ...newItem.item.gems, ...oldItem.item.gems }; this.accessories[oldIdx] = new FarmingAccessory(newItem.item, this.options); } else { this.accessories.push(newItem); } } if (newItem instanceof FarmingAccessory) { this.syncActiveAccessories(); } this.permFortune = this.getGeneralFortune(); } } } /** * Creates a deep clone of this FarmingPlayer that can be modified without affecting the original. */ clone() { const cloneItems = (items) => { return items.map((item) => ({ ...item.item, enchantments: { ...item.item.enchantments }, attributes: { ...item.item.attributes }, gems: { ...item.item.gems }, lore: [...(item.item.lore ?? [])], })); }; const selectedToolUuid = this.selectedTool?.item.uuid; const selectedPetUuid = this.selectedPet?.pet.uuid; const clonedOptions = { ...this.options, tools: cloneItems(this.tools), armor: cloneItems(this.armor), equipment: cloneItems(this.equipment), accessories: cloneItems(this.accessories), pets: this.pets.map((p) => ({ ...p.pet })), cropUpgrades: { ...this.options.cropUpgrades }, milestones: { ...this.options.milestones }, exportableCrops: { ...this.options.exportableCrops }, personalBests: { ...this.options.personalBests }, collection: { ...this.options.collection }, bestiaryKills: { ...this.options.bestiaryKills }, attributes: { ...this.options.attributes }, plots: [...(this.options.plots ?? [])], selectedTool: undefined, selectedPet: undefined, }; const clonedPlayer = new FarmingPlayer(clonedOptions); if (selectedToolUuid) { const selectedTool = clonedPlayer.tools.find((tool) => tool.item.uuid === selectedToolUuid); if (selectedTool) clonedPlayer.selectTool(selectedTool); } if (selectedPetUuid) { const selectedPet = clonedPlayer.pets.find((pet) => pet.pet.uuid === selectedPetUuid); if (selectedPet) clonedPlayer.selectPet(selectedPet); } return clonedPlayer; } /** * Expands an upgrade into a tree of follow-up upgrades. * This applies the upgrade on a cloned player and recursively finds upgrades * for the same target item. * * @param upgrade - The upgrade to expand * @param options.maxDepth - Maximum recursion depth (default: 10) * @param options.crop - Crop for crop-specific fortune calculations * @param options.stats - Stats to track (default: [Stat.FarmingFortune]) * @param options.includeAllTierUpgradeChildren - If true, first-level children of tier upgrades include ALL available upgrades for the new item (default: false) */ expandUpgrade(upgrade, options) { const { maxDepth = 10, crop, stats = [Stat.FarmingFortune], includeAllTierUpgradeChildren = false, } = options ?? {}; const visited = new Set(); const usedConflictKeys = new Set(); return this.buildUpgradeTree(upgrade, 0, maxDepth, crop, visited, usedConflictKeys, stats, includeAllTierUpgradeChildren); } buildUpgradeTree(upgrade, depth, maxDepth, crop, visited, usedConflictKeys, stats, includeAllTierUpgradeChildren) { // Create unique key for this upgrade to detect cycles const upgradeKey = this.getUpgradeKey(upgrade); if (visited.has(upgradeKey)) { // Return a leaf node if we've seen this exact upgrade before const currentStats = this.getAllStats(stats, crop); return { upgrade, statsBefore: currentStats, statsAfter: currentStats, statsGained: {}, totalCost: upgrade.cost, children: [], }; } visited.add(upgradeKey); // Clone player and apply upgrade const clonedPlayer = this.clone(); const statsBefore = clonedPlayer.getAllStats(stats, crop); clonedPlayer.applyUpgrade(upgrade); const statsAfter = clonedPlayer.getAllStats(stats, crop); const statsGained = this.computeStatsDiff(statsBefore, statsAfter); const node = { upgrade, statsBefore, statsAfter, statsGained, totalCost: upgrade.cost, children: [], }; if (upgrade.meta?.type === 'upgrade_group') { const sequentialPlayer = this.clone(); for (const groupedUpgrade of upgrade.groupedUpgrades ?? []) { const childStatsBefore = sequentialPlayer.getAllStats(stats, crop); const childPlayer = sequentialPlayer.clone(); childPlayer.applyUpgrade(groupedUpgrade); const childStatsAfter = childPlayer.getAllStats(stats, crop); node.children.push({ upgrade: groupedUpgrade, statsBefore: childStatsBefore, statsAfter: childStatsAfter, statsGained: this.computeStatsDiff(childStatsBefore, childStatsAfter),