123 lines
4.9 KiB
TypeScript
123 lines
4.9 KiB
TypeScript
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<ItemQuality>(["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<ContentCategoryId>(["wow", "rom", "coa"]);
|
|
|
|
/** Returns true only for an explicit item/owner namespace match. */
|
|
export function inventoryItemMatchesCategory(
|
|
item: Pick<InventoryItem, "contentCategory">,
|
|
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<InventoryItem, "contentCategory" | "stats">,
|
|
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<InventoryItem>;
|
|
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;
|
|
}
|