import { normalizeItemStats, MAX_PERSISTED_ITEM_LEVEL, type ItemQuality, type ItemStats, } from "./itemStats"; import { normalizeRomNativeGearStats } from "./romNativeStatsTypes"; import type { ContentCategoryId } from "../app/contentCategories"; export type { ItemQuality, ItemStats } from "./itemStats"; /** A concrete item owned by a character, distinct from its SQL item template. */ export interface InventoryItem { /** Source-game namespace. Missing legacy metadata is normalized with the owning profile. */ readonly contentCategory?: ContentCategoryId | null; readonly instanceId: string; readonly itemId: number; readonly itemKey: string; readonly name: string; readonly quality: ItemQuality; readonly inventorySlot: string; readonly armorType: string | null; readonly weaponType: string | null; /** Scaled at boss death from the killing player's level. */ readonly itemLevel: number; /** Original 3.3.5a item level retained for provenance and future balancing. */ readonly sourceItemLevel: number; /** Concrete scaled attributes captured when the boss drops this item. */ readonly stats: ItemStats; readonly quantity: number; readonly sourceBossKey: string; readonly sourceBossName: string; readonly acquiredAt: number; } const ITEM_QUALITIES = new Set(["common", "uncommon", "rare", "epic", "legendary"]); export interface NormalizeInventoryOptions { /** Saved character/party owner; dungeon and loot-source identities are not owner categories. */ readonly ownerCategory?: ContentCategoryId; } const CONTENT_CATEGORIES = new Set(["wow", "rom", "coa"]); /** Returns true only for an explicit item/owner namespace match. */ export function inventoryItemMatchesCategory( item: Pick, expectedCategory: ContentCategoryId | undefined, ): boolean { return expectedCategory !== undefined && item.contentCategory === expectedCategory; } /** A RoM item is usable only when it carries nonempty validated native values. */ export function inventoryItemUsableForCategory( item: Pick, expectedCategory: ContentCategoryId | undefined, ): boolean { if (!inventoryItemMatchesCategory(item, expectedCategory)) return false; if (expectedCategory !== "rom") return true; const native = normalizeRomNativeGearStats(item.stats?.romNative); return Boolean(native && Object.keys(native).length > 0); } /** Safely migrates persisted profiles created before inventory existed. */ export function normalizeInventory( value: unknown, options: NormalizeInventoryOptions = {}, ): InventoryItem[] { if (!Array.isArray(value)) return []; const normalized: InventoryItem[] = []; for (const candidate of value) { if (!candidate || typeof candidate !== "object") continue; const item = candidate as Partial; if ( typeof item.instanceId !== "string" || !Number.isFinite(item.itemId) || typeof item.itemKey !== "string" || typeof item.name !== "string" || !ITEM_QUALITIES.has(item.quality as ItemQuality) || typeof item.inventorySlot !== "string" || !Number.isFinite(item.itemLevel) || !Number.isFinite(item.sourceItemLevel) || !Number.isFinite(item.quantity) || typeof item.sourceBossKey !== "string" || typeof item.sourceBossName !== "string" || !Number.isFinite(item.acquiredAt) ) continue; const itemLevel = Math.max(1, Math.min(MAX_PERSISTED_ITEM_LEVEL, Math.trunc(item.itemLevel!))); const contentCategory = CONTENT_CATEGORIES.has(item.contentCategory as ContentCategoryId) ? item.contentCategory as ContentCategoryId : item.contentCategory === undefined && options.ownerCategory === "wow" ? "wow" : null; normalized.push({ contentCategory, instanceId: item.instanceId, itemId: Math.max(1, Math.trunc(item.itemId!)), itemKey: item.itemKey, name: item.name, quality: item.quality as ItemQuality, inventorySlot: item.inventorySlot, armorType: typeof item.armorType === "string" ? item.armorType : null, weaponType: typeof item.weaponType === "string" ? item.weaponType : null, itemLevel, sourceItemLevel: Math.max(1, Math.trunc(item.sourceItemLevel!)), stats: normalizeItemStats(item.stats, { itemId: Math.max(1, Math.trunc(item.itemId!)), quality: item.quality as ItemQuality, inventorySlot: item.inventorySlot, armorType: typeof item.armorType === "string" ? item.armorType : null, weaponType: typeof item.weaponType === "string" ? item.weaponType : null, itemLevel, sourceItemLevel: Math.max(1, Math.trunc(item.sourceItemLevel!)), }), quantity: Math.max(1, Math.trunc(item.quantity!)), sourceBossKey: item.sourceBossKey, sourceBossName: item.sourceBossName, acquiredAt: Math.max(0, Math.trunc(item.acquiredAt!)), }); } return normalized; }