import { baseClassFor, classById, type BaseClassId, type ClassId, } from "../app/characterCatalog"; import { EMPTY_ITEM_STATS, ITEM_STAT_KEYS, type ItemStats, type NumericItemStatKey, type WeaponStats, } from "./itemStats"; import type { InventoryItem } from "./lootTypes"; export const PLAYER_EQUIPMENT_OWNER_ID = "player"; export const EQUIPMENT_SLOTS = [ "head", "neck", "shoulder", "back", "chest", "wrist", "hands", "waist", "legs", "feet", "finger", "trinket", "main-hand", "off-hand", "ranged", ] as const; export type EquipmentSlot = (typeof EQUIPMENT_SLOTS)[number]; export type EquipmentLoadout = Partial>; export type EquipmentAssignments = Readonly>; export interface EquipmentChange { readonly ok: boolean; readonly assignments: EquipmentAssignments; readonly slot?: EquipmentSlot; readonly reason?: string; } const equipmentSlotSet = new Set(EQUIPMENT_SLOTS); const ARMOR_RANK: Readonly> = Object.freeze({ cloth: 1, leather: 2, mail: 3, chain: 3, plate: 4, }); const SHIELD_CLASSES = new Set([ "warrior", "paladin", "shaman", "knight-of-xoroth", "guardian", "cultist", "starcaller", "sun-cleric", "rom-warrior", "rom-knight", "rom-warden", "rom-champion", ]); const WEAPON_CLASSES: Readonly>> = Object.freeze({ bow: new Set(["warrior", "hunter", "rogue"]), dagger: new Set(["warrior", "hunter", "rogue", "priest", "shaman", "mage", "warlock", "druid"]), mace: new Set(["warrior", "paladin", "priest", "death-knight", "shaman", "druid"]), staff: new Set(["warrior", "hunter", "priest", "shaman", "mage", "warlock", "druid"]), wand: new Set(["priest", "mage", "warlock"]), }); export function equipmentSlotForItem(item: InventoryItem): EquipmentSlot | null { const slot = item.inventorySlot === "one-hand" || item.inventorySlot === "two-hand" ? "main-hand" : item.inventorySlot; return equipmentSlotSet.has(slot) ? slot as EquipmentSlot : null; } export function classCanEquipItem(classId: ClassId, item: InventoryItem): boolean { if (item.armorType === "shield" && !SHIELD_CLASSES.has(classId)) return false; if (item.armorType && item.armorType !== "shield") { const requiredRank = ARMOR_RANK[item.armorType] ?? Number.POSITIVE_INFINITY; if (requiredRank > ARMOR_RANK[classById(classId).armor]) return false; } if (item.weaponType) { const allowedClasses = WEAPON_CLASSES[item.weaponType]; if (!allowedClasses?.has(baseClassFor(classId))) return false; } return equipmentSlotForItem(item) !== null; } function mutableAssignments(assignments: EquipmentAssignments): Record { return Object.fromEntries( Object.entries(assignments).map(([ownerId, loadout]) => [ownerId, { ...loadout }]), ); } function removeItemAssignment( assignments: Record, instanceId: string, ): void { for (const [ownerId, loadout] of Object.entries(assignments)) { const next = { ...loadout }; for (const slot of EQUIPMENT_SLOTS) { if (next[slot] === instanceId) delete next[slot]; } if (Object.keys(next).length) assignments[ownerId] = next; else delete assignments[ownerId]; } } export function equipInventoryItem( assignments: EquipmentAssignments, inventory: readonly InventoryItem[], ownerId: string, ownerClassId: ClassId, instanceId: string, ): EquipmentChange { const item = inventory.find((candidate) => candidate.instanceId === instanceId); if (!item) return { ok: false, assignments, reason: "That item is no longer in your inventory." }; const slot = equipmentSlotForItem(item); if (!slot) return { ok: false, assignments, reason: `${item.name} does not use an equippable slot.` }; if (!classCanEquipItem(ownerClassId, item)) { return { ok: false, assignments, reason: `That class cannot equip ${item.name}.` }; } const next = mutableAssignments(assignments); removeItemAssignment(next, instanceId); const loadout = { ...(next[ownerId] ?? {}) }; if (slot === "main-hand" && item.inventorySlot === "two-hand") delete loadout["off-hand"]; if (slot === "off-hand" && loadout["main-hand"]) { const mainHand = inventory.find((candidate) => candidate.instanceId === loadout["main-hand"]); if (mainHand?.inventorySlot === "two-hand") delete loadout["main-hand"]; } loadout[slot] = instanceId; next[ownerId] = loadout; return { ok: true, assignments: next, slot }; } export function unequipInventorySlot( assignments: EquipmentAssignments, ownerId: string, slot: EquipmentSlot, ): EquipmentAssignments { const loadout = assignments[ownerId]; if (!loadout?.[slot]) return assignments; const next = mutableAssignments(assignments); const nextLoadout = { ...next[ownerId] }; delete nextLoadout[slot]; if (Object.keys(nextLoadout).length) next[ownerId] = nextLoadout; else delete next[ownerId]; return next; } export function equippedOwnerId( assignments: EquipmentAssignments, instanceId: string, ): string | null { for (const [ownerId, loadout] of Object.entries(assignments)) { if (Object.values(loadout).includes(instanceId)) return ownerId; } return null; } /** Returns one owner's equipped inventory records in stable character-slot order. */ export function equipmentItemsForOwner( assignments: EquipmentAssignments, inventory: readonly InventoryItem[], ownerId: string, ): InventoryItem[] { const loadout = assignments[ownerId]; if (!loadout) return []; const itemById = new Map(inventory.map((item) => [item.instanceId, item])); return EQUIPMENT_SLOTS.flatMap((slot) => { const instanceId = loadout[slot]; const item = instanceId ? itemById.get(instanceId) : null; return item ? [item] : []; }); } export function normalizeEquipment( value: unknown, inventory: readonly InventoryItem[], ): EquipmentAssignments { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const itemById = new Map(inventory.map((item) => [item.instanceId, item])); const assigned = new Set(); const normalized: Record = {}; for (const [ownerId, rawLoadout] of Object.entries(value as Record)) { if (!ownerId || !rawLoadout || typeof rawLoadout !== "object" || Array.isArray(rawLoadout)) continue; const loadout: EquipmentLoadout = {}; for (const [rawSlot, rawInstanceId] of Object.entries(rawLoadout as Record)) { if (!equipmentSlotSet.has(rawSlot) || typeof rawInstanceId !== "string" || assigned.has(rawInstanceId)) continue; const item = itemById.get(rawInstanceId); if (!item || equipmentSlotForItem(item) !== rawSlot) continue; loadout[rawSlot as EquipmentSlot] = rawInstanceId; assigned.add(rawInstanceId); } const mainHand = loadout["main-hand"] ? itemById.get(loadout["main-hand"]!) : null; if (mainHand?.inventorySlot === "two-hand") delete loadout["off-hand"]; if (Object.keys(loadout).length) normalized[ownerId] = loadout; } return normalized; } export function equipmentItemLevel( assignments: EquipmentAssignments, inventory: readonly InventoryItem[], ownerId: string, ): number { const itemById = new Map(inventory.map((item) => [item.instanceId, item])); return Object.values(assignments[ownerId] ?? {}).reduce( (total, instanceId) => total + (instanceId ? itemById.get(instanceId)?.itemLevel ?? 0 : 0), 0, ); } /** Adds the attributes from every item equipped by one character. */ export function equipmentStats( assignments: EquipmentAssignments, inventory: readonly InventoryItem[], ownerId: string, ): ItemStats { const loadout = assignments[ownerId]; if (!loadout) return EMPTY_ITEM_STATS; const itemById = new Map(inventory.map((item) => [item.instanceId, item])); const numeric = Object.fromEntries(ITEM_STAT_KEYS.map((key) => [key, 0])) as Record; let meleeWeapon: WeaponStats | null = null; let rangedWeapon: WeaponStats | null = null; for (const instanceId of Object.values(loadout)) { const stats = instanceId ? itemById.get(instanceId)?.stats : null; if (!stats) continue; for (const key of ITEM_STAT_KEYS) numeric[key] += stats[key]; if (stats.meleeWeapon) meleeWeapon = stats.meleeWeapon; if (stats.rangedWeapon) rangedWeapon = stats.rangedWeapon; } return Object.freeze({ ...numeric, meleeWeapon, rangedWeapon }); } /** Keeps incompatible items owned and visibly assigned while excluding their stats in the active class orientation. */ export function activeEquipmentStatsForClass( assignments: EquipmentAssignments, inventory: readonly InventoryItem[], ownerId: string, classId: ClassId, ): ItemStats { const loadout = assignments[ownerId]; if (!loadout) return EMPTY_ITEM_STATS; const itemById = new Map(inventory.map((item) => [item.instanceId, item])); const activeAssignments: EquipmentAssignments = { [ownerId]: Object.fromEntries(Object.entries(loadout).filter(([, instanceId]) => { const item = instanceId ? itemById.get(instanceId) : null; return Boolean(item && classCanEquipItem(classId, item)); })), }; return equipmentStats(activeAssignments, inventory, ownerId); }