import type { BossGroupId } from "../bossCatalog"; import type { HealerClassId, MemberId, RunBuffId } from "../types"; import { groupDrop, type DifficultySlug, type MaterialStack } from "./loot"; export type GearOwnerId = HealerClassId | Exclude; export type GearSlotId = "weapon" | "helmet" | "chest" | "legs" | "feet"; export type GearLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10; export type GearStatId = "healingPower" | "damage" | "attackCooldown" | "maxHealth" | "moveSpeed" | "stunResist" | "hazardDamageTaken"; export interface GearSlotProgress { level: GearLevel; } export interface ClassGearProgress { slots: Record; infusionAbilityId: string | null; passiveInfusionId: RunBuffId | null; } export type GearProgress = Record; export interface GearRecipe { ownerId: GearOwnerId; slotId: GearSlotId; statId: GearStatId; primaryGroupId: BossGroupId; secondaryGroupId: BossGroupId; } export interface GearUpgradeCost { itemId: string; itemName: string; quantity: number; } export const GEAR_OWNER_ORDER: readonly GearOwnerId[] = ["priest", "druid", "shaman", "brann", "nia", "orin", "vale"]; export const GEAR_SLOT_ORDER: readonly GearSlotId[] = ["weapon", "helmet", "chest", "legs", "feet"]; export const MAX_GEAR_LEVEL: GearLevel = 10; export const GEAR_OWNER_LABELS: Record = { priest: "Priest", druid: "Druid", shaman: "Shaman", brann: "Brann · Knight", nia: "Nia · Ranger", orin: "Orin · Mage", vale: "Vale · Rogue", }; export const GEAR_SLOT_LABELS: Record = { weapon: "Weapon", helmet: "Helmet", chest: "Chest", legs: "Legs", feet: "Feet", }; export const GEAR_STAT_LABELS: Record = { healingPower: "Healing Power", damage: "Damage", attackCooldown: "Cooldown", maxHealth: "Max Health", moveSpeed: "Move Speed", stunResist: "Stun Resist", hazardDamageTaken: "Hazard Damage Taken", }; type RecipeSeed = Record; const HEALER_RECIPES: RecipeSeed = { weapon: ["bind-venom", "breath-skyfall"], helmet: ["ricochet", "burrow-eruption"], chest: ["charge", "shockwave-fall"], legs: ["slash-cross", "ricochet"], feet: ["bind-venom", "burrow-eruption"], }; const OWNER_RECIPE_SEEDS: Record = { priest: HEALER_RECIPES, druid: HEALER_RECIPES, shaman: HEALER_RECIPES, brann: { weapon: ["charge", "scuttle-burst"], helmet: ["slash-cross", "burrow-eruption"], chest: ["charge", "breath-skyfall"], legs: ["charge", "ricochet"], feet: ["burrow-eruption", "bind-venom"], }, nia: { weapon: ["ricochet", "breath-skyfall"], helmet: ["bind-venom", "burrow-eruption"], chest: ["charge", "rift-lanes"], legs: ["ricochet", "slash-cross"], feet: ["burrow-eruption", "bind-venom"], }, orin: { weapon: ["breath-skyfall", "bind-venom"], helmet: ["burrow-eruption", "ricochet"], chest: ["bind-venom", "charge"], legs: ["ricochet", "slash-cross"], feet: ["burrow-eruption", "charge"], }, vale: { weapon: ["slash-cross", "breath-skyfall"], helmet: ["ricochet", "burrow-eruption"], chest: ["charge", "rift-lanes"], legs: ["slash-cross", "ricochet"], feet: ["bind-venom", "burrow-eruption"], }, }; function statFor(ownerId: GearOwnerId, slotId: GearSlotId): GearStatId { if (slotId === "weapon") return ownerId === "priest" || ownerId === "druid" || ownerId === "shaman" ? "healingPower" : "damage"; if (slotId === "helmet") return ownerId === "brann" ? "stunResist" : "attackCooldown"; if (slotId === "chest") return "maxHealth"; if (slotId === "legs") return "moveSpeed"; return "hazardDamageTaken"; } export const GEAR_RECIPES = Object.fromEntries(GEAR_OWNER_ORDER.map((ownerId) => [ ownerId, Object.fromEntries(GEAR_SLOT_ORDER.map((slotId) => { const [primaryGroupId, secondaryGroupId] = OWNER_RECIPE_SEEDS[ownerId][slotId]; return [slotId, { ownerId, slotId, statId: statFor(ownerId, slotId), primaryGroupId, secondaryGroupId }]; })), ])) as Record>; export function createDefaultGearProgress(): GearProgress { return Object.fromEntries(GEAR_OWNER_ORDER.map((ownerId) => [ ownerId, { slots: Object.fromEntries(GEAR_SLOT_ORDER.map((slotId) => [slotId, { level: 0 }])), infusionAbilityId: null, passiveInfusionId: null, }, ])) as GearProgress; } export function gearUpgradeCosts(ownerId: GearOwnerId, slotId: GearSlotId, currentLevel: GearLevel): GearUpgradeCost[] { if (currentLevel >= MAX_GEAR_LEVEL) return []; const nextLevel = currentLevel + 1 as Exclude; const recipe = GEAR_RECIPES[ownerId][slotId]; const difficultySlug = upgradeDifficultySlug(nextLevel); const primary = groupDrop(recipe.primaryGroupId, difficultySlug); const secondary = groupDrop(recipe.secondaryGroupId, difficultySlug); if (nextLevel === 1) return [{ itemId: primary.id, itemName: primary.name, quantity: 2 }]; const primaryQuantity = nextLevel <= 3 ? 3 : nextLevel <= 5 ? nextLevel : nextLevel; const secondaryQuantity = nextLevel === 2 ? 1 : nextLevel === 3 ? 2 : nextLevel - 1; return [ { itemId: primary.id, itemName: primary.name, quantity: primaryQuantity }, { itemId: secondary.id, itemName: secondary.name, quantity: secondaryQuantity }, ]; } export function canAffordGearUpgrade(inventory: readonly MaterialStack[], costs: readonly GearUpgradeCost[]): boolean { return costs.every((cost) => (inventory.find((item) => item.id === cost.itemId)?.quantity ?? 0) >= cost.quantity); } export function canUpgradeGearSlot( progress: GearProgress, inventory: readonly MaterialStack[], ownerId: GearOwnerId, slotId: GearSlotId, ): boolean { const currentLevel = progress[ownerId].slots[slotId].level; return currentLevel < MAX_GEAR_LEVEL && canAffordGearUpgrade(inventory, gearUpgradeCosts(ownerId, slotId, currentLevel)); } export function spendGearCosts(inventory: readonly MaterialStack[], costs: readonly GearUpgradeCost[]): MaterialStack[] { if (!canAffordGearUpgrade(inventory, costs)) { const missing = costs.find((cost) => (inventory.find((item) => item.id === cost.itemId)?.quantity ?? 0) < cost.quantity); throw new Error(missing ? `Need ${missing.quantity} ${missing.itemName}.` : "Missing gear materials."); } return inventory.flatMap((item) => { const cost = costs.find((entry) => entry.itemId === item.id); if (!cost) return [{ ...item }]; const quantity = item.quantity - cost.quantity; return quantity > 0 ? [{ ...item, quantity }] : []; }); } export function upgradeGearSlot( progress: GearProgress, inventory: readonly MaterialStack[], ownerId: GearOwnerId, slotId: GearSlotId, ): { gearProgress: GearProgress; inventory: MaterialStack[] } { const currentLevel = progress[ownerId].slots[slotId].level; if (currentLevel >= MAX_GEAR_LEVEL) throw new Error(`${GEAR_SLOT_LABELS[slotId]} already at +${MAX_GEAR_LEVEL}.`); const costs = gearUpgradeCosts(ownerId, slotId, currentLevel); const inventoryAfter = spendGearCosts(inventory, costs); return { inventory: inventoryAfter, gearProgress: { ...progress, [ownerId]: { ...progress[ownerId], slots: { ...progress[ownerId].slots, [slotId]: { level: currentLevel + 1 as GearLevel }, }, }, }, }; } export function gearBonusText(statId: GearStatId, level: number): string { if (statId === "moveSpeed") return `+${formatPercent(level * 2.5)}%`; if (statId === "attackCooldown" || statId === "hazardDamageTaken") return `-${level * 3}%`; if (statId === "stunResist") return `-${level * 8}%`; return `+${level * 4}%`; } function formatPercent(value: number): string { return Number.isInteger(value) ? String(value) : value.toFixed(1); } // IWT2 parity: +6 through +10 return to Veteran drops while infusion costs use higher tiers. function upgradeDifficultySlug(level: Exclude): DifficultySlug { if (level <= 2) return "initiate"; if (level >= 6) return "veteran"; if (level === 3) return "veteran"; if (level === 4) return "champion"; return "mythic"; }