farming-weight
Version:
Tools for calculating farming weight and fortune in Hypixel Skyblock
1,049 lines (1,048 loc) • 58.6 kB
JavaScript
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),