import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { classById, type ClassId } from "../app/characterCatalog"; import { useShellStore } from "../app/shellStore"; import { EQUIPMENT_SLOTS, PLAYER_EQUIPMENT_OWNER_ID, classCanEquipItem, equippedOwnerId, equipmentSlotForItem, type EquipmentSlot, } from "../game/equipment"; import { useCombatStore } from "../game/combatStore"; import { itemStatSummary, weaponDps, type ItemStats, } from "../game/itemStats"; import type { InventoryItem } from "../game/lootTypes"; import { usePartyStore } from "../game/partyStore"; import { deriveCharacterStats, type CharacterStats } from "../game/wotlkStats"; import { useGameStore } from "../game/store"; import { useMenuController, type MenuAction } from "../input/useMenuController"; import { ControllerButton } from "./ControllerButton"; import { compareEquipmentUpgrade, resolvedAverageItemLevel, resolvedEquipmentLoadout, type HeirloomContext, } from "../game/heirloomEquipment"; import { gearProfileFor } from "../game/specializationCatalog"; import type { PartyRole } from "../game/partyRoles"; const SLOT_LABELS: Readonly> = Object.freeze({ head: "Head", neck: "Neck", shoulder: "Shoulders", back: "Back", chest: "Chest", wrist: "Wrists", hands: "Hands", waist: "Waist", legs: "Legs", feet: "Feet", finger: "Finger", trinket: "Trinket", "main-hand": "Main hand", "off-hand": "Off hand", ranged: "Ranged", }); interface EquipmentOwner { readonly id: string; readonly name: string; readonly detail: string; readonly classId: ClassId; readonly secondaryClassId?: ClassId | null; readonly specializationId: string | null; readonly role: PartyRole; readonly level: number; readonly ownerKind: "player" | "party"; } type StatPage = "attributes" | "offense" | "defense"; const STAT_PAGES: readonly StatPage[] = ["attributes", "offense", "defense"]; const ITEM_STAT_LABELS: readonly [keyof ItemStats, string][] = [ ["strength", "Strength"], ["agility", "Agility"], ["stamina", "Stamina"], ["intellect", "Intellect"], ["spirit", "Spirit"], ["attackPower", "Attack Power"], ["rangedAttackPower", "Ranged Attack Power"], ["spellPower", "Spell Power"], ["criticalStrikeRating", "Critical Strike Rating"], ["hitRating", "Hit Rating"], ["hasteRating", "Haste Rating"], ["expertiseRating", "Expertise Rating"], ["armorPenetrationRating", "Armor Penetration Rating"], ["defenseRating", "Defense Rating"], ["dodgeRating", "Dodge Rating"], ["parryRating", "Parry Rating"], ["blockRating", "Block Rating"], ["blockValue", "Block Value"], ["resilienceRating", "Resilience Rating"], ["spellPenetration", "Spell Penetration"], ["mp5", "Mana per 5 sec"], ["healthPer5", "Health per 5 sec"], ["arcaneResistance", "Arcane Resistance"], ["fireResistance", "Fire Resistance"], ["frostResistance", "Frost Resistance"], ["natureResistance", "Nature Resistance"], ["shadowResistance", "Shadow Resistance"], ["health", "Health"], ["mana", "Mana"], ]; function displayNumber(value: number, digits = 0): string { return value.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits, }); } function itemStatRows(item: InventoryItem): readonly { label: string; value: string; beneficial?: boolean }[] { const rows: { label: string; value: string; beneficial?: boolean }[] = []; const weapon = item.stats.meleeWeapon ?? item.stats.rangedWeapon; if (weapon) { rows.push({ label: `${weapon.damageSchool[0].toUpperCase()}${weapon.damageSchool.slice(1)} damage`, value: `${displayNumber(weapon.damageMin, weapon.damageMin % 1 ? 1 : 0)}–${displayNumber(weapon.damageMax, weapon.damageMax % 1 ? 1 : 0)}`, }); rows.push({ label: "Speed", value: (weapon.speedMs / 1_000).toFixed(2) }); rows.push({ label: "Damage per second", value: weaponDps(weapon).toFixed(2) }); } if (item.stats.armor > 0) rows.push({ label: "Armor", value: displayNumber(item.stats.armor) }); for (const [key, label] of ITEM_STAT_LABELS) { if (key === "armor" || key === "meleeWeapon" || key === "rangedWeapon") continue; const value = item.stats[key]; if (typeof value === "number" && value > 0) { rows.push({ label, value: `+${displayNumber(value, value % 1 ? 2 : 0)}`, beneficial: true, }); } } return rows; } function characterRows(stats: CharacterStats, page: StatPage): readonly { label: string; value: string }[] { if (page === "attributes") { return [ { label: "Strength", value: displayNumber(stats.strength) }, { label: "Agility", value: displayNumber(stats.agility) }, { label: "Stamina", value: displayNumber(stats.stamina) }, { label: "Intellect", value: displayNumber(stats.intellect) }, { label: "Spirit", value: displayNumber(stats.spirit) }, { label: "Health", value: displayNumber(stats.health) }, ...(stats.mana ? [{ label: "Mana", value: displayNumber(stats.mana) }] : []), { label: "Health / 5 sec", value: displayNumber(stats.healthPer5, 1) }, ...(stats.mana ? [{ label: "Mana / 5 sec", value: displayNumber(stats.manaPer5, 1) }] : []), ]; } if (page === "offense") { return [ { label: "Attack Power", value: displayNumber(stats.attackPower) }, { label: "Ranged Attack Power", value: displayNumber(stats.rangedAttackPower) }, { label: "Spell Power", value: displayNumber(stats.spellPower) }, { label: "Melee Crit", value: `${stats.meleeCritPercent.toFixed(2)}%` }, { label: "Ranged Crit", value: `${stats.rangedCritPercent.toFixed(2)}%` }, { label: "Spell Crit", value: `${stats.spellCritPercent.toFixed(2)}%` }, { label: "Melee Hit", value: `${stats.meleeHitPercent.toFixed(2)}%` }, { label: "Ranged Hit", value: `${stats.rangedHitPercent.toFixed(2)}%` }, { label: "Spell Hit", value: `${stats.spellHitPercent.toFixed(2)}%` }, { label: "Melee Haste", value: `${stats.meleeHastePercent.toFixed(2)}%` }, { label: "Ranged Haste", value: `${stats.rangedHastePercent.toFixed(2)}%` }, { label: "Spell Haste", value: `${stats.spellHastePercent.toFixed(2)}%` }, { label: "Expertise", value: stats.expertise.toFixed(2) }, { label: "Armor Penetration", value: `${stats.armorPenetrationPercent.toFixed(2)}%` }, { label: "Spell Penetration", value: displayNumber(stats.spellPenetration) }, ]; } return [ { label: "Armor", value: `${displayNumber(stats.armor)} · ${stats.armorReductionPercent.toFixed(2)}%` }, { label: "Defense", value: stats.defenseSkill.toFixed(2) }, { label: "Dodge", value: `${stats.dodgePercent.toFixed(2)}%` }, { label: "Parry", value: `${stats.parryPercent.toFixed(2)}%` }, { label: "Block", value: `${stats.blockPercent.toFixed(2)}%` }, { label: "Block Value", value: displayNumber(stats.blockValue) }, { label: "Resilience damage reduction", value: `${stats.resiliencePercent.toFixed(2)}%` }, { label: "Arcane Resistance", value: displayNumber(stats.arcaneResistance) }, { label: "Fire Resistance", value: displayNumber(stats.fireResistance) }, { label: "Frost Resistance", value: displayNumber(stats.frostResistance) }, { label: "Nature Resistance", value: displayNumber(stats.natureResistance) }, { label: "Shadow Resistance", value: displayNumber(stats.shadowResistance) }, ]; } function itemType(item: InventoryItem): string { return [ item.armorType, item.weaponType, SLOT_LABELS[equipmentSlotForItem(item) ?? "trinket"], ].filter(Boolean).join(" · "); } function EquipmentDialog({ readOnly = false }: { readOnly?: boolean }) { const character = useShellStore((state) => state.activeCharacter); const inventory = useCombatStore((state) => state.inventory); const equipment = useCombatStore((state) => state.equipment); const playerClassId = useCombatStore((state) => state.classId); const playerSecondaryClassId = useCombatStore((state) => state.secondaryClassId); const playerLevel = useCombatStore((state) => state.level); const playerMaxHealth = useCombatStore((state) => state.maxHealth); const playerGearStats = useCombatStore((state) => state.gearStats); const playerSpecializationId = useCombatStore((state) => state.specializationId); const playerGearRole = useCombatStore((state) => state.gearRole); const feedback = useCombatStore((state) => state.feedback); const members = usePartyStore((state) => state.members); const openOverlay = useGameStore((state) => state.openOverlay); const close = () => openOverlay("pause"); const owners = useMemo(() => [ { id: PLAYER_EQUIPMENT_OWNER_ID, name: character?.name ?? "You", detail: "Player", classId: playerClassId, secondaryClassId: playerSecondaryClassId, specializationId: playerSpecializationId, role: playerGearRole, level: playerLevel, ownerKind: "player" as const, }, ...members.map((member) => ({ id: member.id, name: member.name, detail: `${member.specialization} · ${member.role === "damage" ? "DPS" : member.role}`, classId: member.classId, secondaryClassId: member.secondaryClassId, specializationId: member.specializationId, role: member.role, level: member.level, ownerKind: "party" as const, })), ], [character?.name, members, playerClassId, playerSecondaryClassId, playerGearRole, playerLevel, playerSpecializationId]); const [ownerId, setOwnerId] = useState(PLAYER_EQUIPMENT_OWNER_ID); const [inspectedId, setInspectedId] = useState(() => inventory.at(-1)?.instanceId ?? ""); const [statPage, setStatPage] = useState("attributes"); useEffect(() => { if (!owners.some((owner) => owner.id === ownerId)) setOwnerId(PLAYER_EQUIPMENT_OWNER_ID); }, [ownerId, owners]); useEffect(() => { if (!inventory.some((item) => item.instanceId === inspectedId)) { setInspectedId(inventory.at(-1)?.instanceId ?? ""); } }, [inspectedId, inventory]); const owner = owners.find((candidate) => candidate.id === ownerId) ?? owners[0]; const inspected = inventory.find((item) => item.instanceId === inspectedId) ?? inventory.at(-1) ?? null; const ownerById = useMemo(() => new Map(owners.map((candidate) => [candidate.id, candidate])), [owners]); const heirloomContext: HeirloomContext = { ownerId: owner.id, ownerKind: owner.ownerKind, classId: owner.classId, secondaryClassId: owner.secondaryClassId, specializationId: owner.specializationId, role: owner.role, level: owner.level, }; const loadout = useMemo( () => resolvedEquipmentLoadout(equipment, inventory, heirloomContext), [equipment, inventory, owner.classId, owner.secondaryClassId, owner.id, owner.level, owner.ownerKind, owner.role, owner.specializationId], ); const earnedLoadout = equipment[owner.id] ?? {}; const sortedInventory = useMemo(() => [...inventory].sort((left, right) => ( right.itemLevel - left.itemLevel || right.acquiredAt - left.acquiredAt )), [inventory]); const equip = (item: InventoryItem) => { setInspectedId(item.instanceId); if (readOnly) return; useCombatStore.getState().equipItem(item.instanceId, owner.id); }; const actions = useMemo(() => [ ...owners.map((candidate) => ({ id: `equipment-owner-${candidate.id}`, run: () => setOwnerId(candidate.id), })), ...EQUIPMENT_SLOTS.flatMap((slot) => earnedLoadout[slot] ? [{ id: `equipment-slot-${slot}`, run: () => { if (!readOnly) useCombatStore.getState().unequipItem(owner.id, slot); }, }] : []), ...STAT_PAGES.map((page) => ({ id: `equipment-stat-${page}`, run: () => setStatPage(page), })), ...sortedInventory.map((item) => ({ id: `equipment-item-${item.instanceId}`, run: () => equip(item), })), { id: "equipment-back", run: close }, ], [earnedLoadout, owner.id, owners, sortedInventory]); const controller = useMenuController(actions, { columns: 2, initialId: `equipment-owner-${PLAYER_EQUIPMENT_OWNER_ID}`, onBack: close, }); const inspectedOwnerId = inspected ? equippedOwnerId(equipment, inspected.instanceId) : null; const inspectedOwner = inspectedOwnerId ? ownerById.get(inspectedOwnerId) : null; const compatible = inspected ? classCanEquipItem(owner.classId, inspected, owner) : false; const gearLevel = resolvedAverageItemLevel(equipment, inventory, heirloomContext); const gearProfile = gearProfileFor(owner.classId, owner.specializationId, owner.role); const inspectedComparison = inspected && compatible ? compareEquipmentUpgrade(inspected, loadout, gearProfile, heirloomContext) : null; const selectedMember = members.find((member) => member.id === owner.id) ?? null; const ownerLevel = selectedMember?.level ?? playerLevel; const ownerMaxHealth = selectedMember?.maxHealth ?? playerMaxHealth; const ownerGearStats = selectedMember?.gearStats ?? playerGearStats; const ownerRaceId = selectedMember?.raceId ?? character?.raceId ?? "human"; const ownerStats = deriveCharacterStats(owner.classId, ownerLevel, ownerGearStats, ownerRaceId); const visibleCharacterRows = characterRows(ownerStats, statPage); const inspectedStatRows = inspected ? itemStatRows(inspected) : []; return (

Shared expedition inventory

Inventory & Equipment

{inventory.length}Items