439 lines
20 KiB
TypeScript
439 lines
20 KiB
TypeScript
import {
|
|
contentCategoryForClass,
|
|
classById,
|
|
isRomClassId,
|
|
type ClassId,
|
|
} from "../app/characterCatalog";
|
|
import {
|
|
classCanEquipItem,
|
|
classItemShapeFitsEquipmentRules,
|
|
EQUIPMENT_SLOTS,
|
|
equipmentSlotForItem,
|
|
type EquipmentAssignments,
|
|
type EquipmentLoadout,
|
|
type EquipmentSlot,
|
|
} from "./equipment";
|
|
import {
|
|
EMPTY_ITEM_STATS,
|
|
ITEM_STAT_KEYS,
|
|
itemStatsFor,
|
|
type ItemStats,
|
|
type NumericItemStatKey,
|
|
type SourceItemStats,
|
|
type WeaponStats,
|
|
} from "./itemStats";
|
|
import { LOOT_CATALOG, type LootCatalogItem } from "./lootCatalog";
|
|
import type { InventoryItem } from "./lootTypes";
|
|
import type { PartyRole } from "./partyRoles";
|
|
import { romNativeGearReference } from "./romNativeGear";
|
|
import { romNativeGearFromSharedDirectStats, sumRomNativeGearStats } from "./romNativeStatsTypes";
|
|
import {
|
|
gearProfileFor,
|
|
isRomHybridGearClass,
|
|
specializationScore,
|
|
type GearArchetype,
|
|
type SpecializationDefinition,
|
|
type WeaponLayout,
|
|
} from "./specializationCatalog";
|
|
|
|
export const PLAYER_HEIRLOOM_LEVEL_CAP = 79;
|
|
export const PARTY_HEIRLOOM_LEVEL_CAP = 79;
|
|
export const FIRST_ENDGAME_ITEM_LEVEL = 85;
|
|
|
|
export type HeirloomOwnerKind = "player" | "party";
|
|
|
|
export interface HeirloomContext {
|
|
readonly ownerId: string;
|
|
readonly ownerKind: HeirloomOwnerKind;
|
|
readonly classId: ClassId;
|
|
readonly secondaryClassId?: ClassId | null;
|
|
readonly specializationId: string | null;
|
|
readonly role: PartyRole;
|
|
readonly level: number;
|
|
}
|
|
|
|
export interface EquipmentUpgradeComparison {
|
|
readonly isUpgrade: boolean;
|
|
readonly scoreDelta: number;
|
|
readonly currentScore: number;
|
|
readonly candidateScore: number;
|
|
readonly displacedSlots: readonly EquipmentSlot[];
|
|
readonly usefulStatDeltas: Readonly<Partial<Record<NumericItemStatKey, number>>>;
|
|
}
|
|
|
|
const BASE_SLOTS: readonly EquipmentSlot[] = [
|
|
"head", "neck", "shoulder", "back", "chest", "wrist", "hands", "waist",
|
|
"legs", "feet", "finger", "trinket",
|
|
];
|
|
|
|
const SLOT_WEIGHT: Readonly<Record<EquipmentSlot, number>> = Object.freeze({
|
|
head: 1,
|
|
neck: 0.55,
|
|
shoulder: 0.75,
|
|
back: 0.45,
|
|
chest: 1.2,
|
|
wrist: 0.55,
|
|
hands: 0.7,
|
|
waist: 0.65,
|
|
legs: 1.05,
|
|
feet: 0.75,
|
|
finger: 0.55,
|
|
trinket: 0.75,
|
|
"main-hand": 1,
|
|
"off-hand": 0.85,
|
|
ranged: 0.9,
|
|
});
|
|
|
|
const SLOT_NAMES: Readonly<Record<EquipmentSlot, string>> = Object.freeze({
|
|
head: "Crown", neck: "Pendant", shoulder: "Mantle", back: "Cloak", chest: "Vestment",
|
|
wrist: "Bindings", hands: "Grips", waist: "Sash", legs: "Legguards", feet: "Treads",
|
|
finger: "Signet", trinket: "Charm", "main-hand": "Weapon", "off-hand": "Focus", ranged: "Ranged Arm",
|
|
});
|
|
|
|
const ARMOR_SLOTS = new Set<EquipmentSlot>([
|
|
"head", "shoulder", "chest", "wrist", "hands", "waist", "legs", "feet",
|
|
]);
|
|
|
|
const CATALOG_ITEMS = LOOT_CATALOG.bosses.flatMap((boss) => boss.items);
|
|
const itemCache = new Map<string, InventoryItem>();
|
|
// Training placeholders are generated internally and are never serialized as
|
|
// earned inventory. Keep their legacy simulator projection private so it can
|
|
// preserve the existing RoM training profile without making direct WoW stats
|
|
// valid on a caller-supplied RoM item.
|
|
const romTrainingItems = new WeakSet<InventoryItem>();
|
|
|
|
function effectiveLevel(context: HeirloomContext): number {
|
|
const cap = context.ownerKind === "party" ? PARTY_HEIRLOOM_LEVEL_CAP : PLAYER_HEIRLOOM_LEVEL_CAP;
|
|
return Math.max(1, Math.min(cap, Math.trunc(Number.isFinite(context.level) ? context.level : 1)));
|
|
}
|
|
|
|
function weaponSlots(context: HeirloomContext, layout: WeaponLayout): readonly EquipmentSlot[] {
|
|
let slots: readonly EquipmentSlot[];
|
|
switch (layout) {
|
|
case "dual-wield":
|
|
case "weapon-shield":
|
|
slots = ["main-hand", "off-hand"];
|
|
break;
|
|
case "ranged":
|
|
slots = ["main-hand", "ranged"];
|
|
break;
|
|
case "caster":
|
|
slots = ["main-hand", "off-hand", "ranged"];
|
|
break;
|
|
default:
|
|
slots = ["main-hand"];
|
|
}
|
|
if (!isRomHybridGearClass(context.classId)) return slots;
|
|
const level = effectiveLevel(context);
|
|
const resolved = new Set(slots);
|
|
if (context.classId === "rom-rogue" && level < 16) resolved.delete("off-hand");
|
|
if (context.classId === "rom-rogue" && level >= 12) resolved.add("ranged");
|
|
if (context.classId === "rom-knight" && context.secondaryClassId === "rom-scout" && level >= 10) resolved.add("ranged");
|
|
if (context.classId === "rom-scout" && context.secondaryClassId === "rom-rogue" && level >= 16) resolved.add("off-hand");
|
|
return [...resolved];
|
|
}
|
|
|
|
function armorTypeForSlot(classId: ClassId, slot: EquipmentSlot, layout: WeaponLayout): string | null {
|
|
if (slot === "off-hand" && layout === "weapon-shield") return "shield";
|
|
if (ARMOR_SLOTS.has(slot)) return classById(classId).armor;
|
|
return null;
|
|
}
|
|
|
|
function weaponTypeFor(classId: ClassId, slot: EquipmentSlot, profile: SpecializationDefinition): string | null {
|
|
if (slot === "off-hand" && (profile.weaponLayout === "dual-wield" || classId === "rom-scout")) return "dagger";
|
|
if (slot === "ranged") return classId === "rom-rogue" ? "projectile"
|
|
: profile.gearArchetype === "spell-dps" || profile.gearArchetype === "healer" ? "wand" : "bow";
|
|
if (slot !== "main-hand") return null;
|
|
if (classId === "rom-scout" && profile.weaponLayout === "ranged") return "dagger";
|
|
if (classId === "rom-mage") return profile.weaponLayout === "two-hand" ? "staff" : "dagger";
|
|
if (profile.weaponLayout === "caster") {
|
|
return ["priest", "shaman", "druid", "witch-doctor", "chronomancer", "primalist", "venomancer"].includes(classId)
|
|
? "mace"
|
|
: "dagger";
|
|
}
|
|
if (profile.weaponLayout === "dual-wield") return "dagger";
|
|
if (classId === "hunter" || classId === "ranger") return "staff";
|
|
return "mace";
|
|
}
|
|
|
|
function inventorySlotFor(classId: ClassId, slot: EquipmentSlot, layout: WeaponLayout): string {
|
|
if (slot === "main-hand") return layout === "two-hand" || (layout === "ranged" && classId !== "rom-scout") ? "two-hand" : "one-hand";
|
|
return slot;
|
|
}
|
|
|
|
function statDistribution(archetype: GearArchetype, budget: number, armor: number): SourceItemStats {
|
|
const common = { stamina: Math.max(1, Math.round(budget * 0.28)), ...(armor > 0 ? { armor } : {}) };
|
|
switch (archetype) {
|
|
case "strength-dps": return { ...common, strength: Math.round(budget * 0.55), attackPower: Math.round(budget * 0.5), hitRating: Math.round(budget * 0.2), criticalStrikeRating: Math.round(budget * 0.2), hasteRating: Math.round(budget * 0.16), expertiseRating: Math.round(budget * 0.14), armorPenetrationRating: Math.round(budget * 0.14) };
|
|
case "agility-dps": return { ...common, agility: Math.round(budget * 0.55), attackPower: Math.round(budget * 0.48), hitRating: Math.round(budget * 0.2), criticalStrikeRating: Math.round(budget * 0.22), hasteRating: Math.round(budget * 0.16), expertiseRating: Math.round(budget * 0.14), armorPenetrationRating: Math.round(budget * 0.14) };
|
|
case "ranged-dps": return { ...common, agility: Math.round(budget * 0.55), rangedAttackPower: Math.round(budget * 0.52), hitRating: Math.round(budget * 0.21), criticalStrikeRating: Math.round(budget * 0.22), hasteRating: Math.round(budget * 0.17), armorPenetrationRating: Math.round(budget * 0.13) };
|
|
case "spell-dps": return { ...common, intellect: Math.round(budget * 0.5), spirit: Math.round(budget * 0.18), spellPower: Math.round(budget * 0.58), hitRating: Math.round(budget * 0.22), criticalStrikeRating: Math.round(budget * 0.18), hasteRating: Math.round(budget * 0.2) };
|
|
case "healer": return { ...common, intellect: Math.round(budget * 0.5), spirit: Math.round(budget * 0.38), spellPower: Math.round(budget * 0.58), mp5: Math.round(budget * 0.16), criticalStrikeRating: Math.round(budget * 0.12), hasteRating: Math.round(budget * 0.2) };
|
|
case "block-tank": return { ...common, strength: Math.round(budget * 0.35), stamina: Math.round(budget * 0.72), defenseRating: Math.round(budget * 0.24), dodgeRating: Math.round(budget * 0.18), parryRating: Math.round(budget * 0.18), blockRating: Math.round(budget * 0.2), blockValue: Math.round(budget * 0.2), expertiseRating: Math.round(budget * 0.1) };
|
|
case "non-block-tank": return { ...common, agility: Math.round(budget * 0.34), strength: Math.round(budget * 0.12), stamina: Math.round(budget * 0.72), defenseRating: Math.round(budget * 0.22), dodgeRating: Math.round(budget * 0.24), parryRating: Math.round(budget * 0.12), expertiseRating: Math.round(budget * 0.1) };
|
|
}
|
|
}
|
|
|
|
function weaponStats(slot: EquipmentSlot, inventorySlot: string, weaponType: string | null, budget: number): WeaponStats | null {
|
|
if (!weaponType) return null;
|
|
const twoHand = inventorySlot === "two-hand";
|
|
const multiplier = twoHand ? 1.55 : slot === "ranged" ? 1.15 : 0.9;
|
|
return {
|
|
damageMin: Math.max(1, Math.round(budget * 0.8 * multiplier * 10) / 10),
|
|
damageMax: Math.max(2, Math.round(budget * 1.25 * multiplier * 10) / 10),
|
|
damageSchool: "physical",
|
|
speedMs: weaponType === "dagger" ? 1800 : weaponType === "staff" ? 2900 : weaponType === "bow" ? 2800 : 2400,
|
|
};
|
|
}
|
|
|
|
function syntheticCatalogItem(template: LootCatalogItem, itemLevel: number): InventoryItem {
|
|
return {
|
|
contentCategory: "wow",
|
|
instanceId: `catalog:${template.itemId}`,
|
|
itemId: template.itemId,
|
|
itemKey: template.itemKey,
|
|
name: template.itemName,
|
|
quality: template.quality,
|
|
inventorySlot: template.inventorySlot,
|
|
armorType: template.armorType,
|
|
weaponType: template.weaponType,
|
|
itemLevel,
|
|
sourceItemLevel: template.sourceItemLevel,
|
|
stats: itemStatsFor({ ...template, itemLevel }),
|
|
quantity: 1,
|
|
sourceBossKey: "catalog",
|
|
sourceBossName: "Boss catalog",
|
|
acquiredAt: 0,
|
|
};
|
|
}
|
|
|
|
function highestCatalogScore(classId: ClassId, slot: EquipmentSlot, level: number, profile: SpecializationDefinition): number {
|
|
let highest = 0;
|
|
for (const template of CATALOG_ITEMS) {
|
|
const candidate = syntheticCatalogItem(template, level);
|
|
// Catalog comparison is an internal shape-only specialization baseline.
|
|
// These WoW templates never enter inventory or an equipment assignment.
|
|
if (equipmentSlotForItem(candidate) !== slot || !classItemShapeFitsEquipmentRules(classId, candidate)) continue;
|
|
highest = Math.max(highest, specializationScore(candidate.stats, profile));
|
|
}
|
|
return highest;
|
|
}
|
|
|
|
function scaleStats(stats: ItemStats, factor: number): ItemStats {
|
|
const numeric = Object.fromEntries(ITEM_STAT_KEYS.map((key) => [key, Math.max(0, Math.round(stats[key] * factor))])) as Record<NumericItemStatKey, number>;
|
|
const scaleWeapon = (weapon: WeaponStats | null): WeaponStats | null => weapon ? {
|
|
...weapon,
|
|
damageMin: Math.max(1, Math.round(weapon.damageMin * factor * 10) / 10),
|
|
damageMax: Math.max(2, Math.round(weapon.damageMax * factor * 10) / 10),
|
|
} : null;
|
|
return Object.freeze({
|
|
...numeric,
|
|
meleeWeapon: scaleWeapon(stats.meleeWeapon),
|
|
rangedWeapon: scaleWeapon(stats.rangedWeapon),
|
|
...(stats.romNative ? { romNative: stats.romNative } : {}),
|
|
});
|
|
}
|
|
|
|
function heirloomItem(
|
|
context: HeirloomContext,
|
|
profile: SpecializationDefinition,
|
|
slot: EquipmentSlot,
|
|
forcedLayout?: WeaponLayout,
|
|
): InventoryItem {
|
|
const level = effectiveLevel(context);
|
|
const layout = forcedLayout ?? profile.weaponLayout;
|
|
const cacheKey = `${context.ownerId}|${context.classId}|${profile.id}|${level}|${slot}|${layout}`;
|
|
const cached = itemCache.get(cacheKey);
|
|
if (cached) return cached;
|
|
const armorType = armorTypeForSlot(context.classId, slot, layout);
|
|
const weaponType = weaponTypeFor(context.classId, slot, { ...profile, weaponLayout: layout });
|
|
const inventorySlot = inventorySlotFor(context.classId, slot, layout);
|
|
const weight = SLOT_WEIGHT[slot];
|
|
const budget = Math.max(2, level * weight * 1.18);
|
|
const armor = armorType && armorType !== "shield"
|
|
? Math.max(1, Math.round(level * weight * ({ cloth: 1, leather: 1.35, mail: 1.7, chain: 1.7, plate: 2.1 }[armorType] ?? 1)))
|
|
: armorType === "shield" ? Math.max(2, Math.round(level * weight * 3.2)) : 0;
|
|
const distributedStats = statDistribution(profile.gearArchetype, budget, armor);
|
|
const sourceStats = armorType === "shield" && isRomHybridGearClass(context.classId)
|
|
? { ...distributedStats, blockValue: Math.max(1, Math.round(level * weight * 0.45)) }
|
|
: distributedStats;
|
|
const sourceWeapon = weaponStats(slot, inventorySlot, weaponType, budget);
|
|
const romNative = isRomClassId(context.classId)
|
|
? romNativeGearReference(context.classId, slot, level)
|
|
: null;
|
|
let stats = itemStatsFor({
|
|
itemId: 9_000_000,
|
|
quality: "heirloom",
|
|
inventorySlot,
|
|
armorType,
|
|
weaponType,
|
|
itemLevel: level,
|
|
sourceItemLevel: level,
|
|
sourceStats,
|
|
sourceWeapon,
|
|
...(romNative ? { romNative } : {}),
|
|
});
|
|
const currentScore = specializationScore(stats, profile);
|
|
const catalogScore = isRomHybridGearClass(context.classId) ? 0 : highestCatalogScore(context.classId, slot, level, profile)
|
|
+ (slot === "main-hand" && inventorySlot === "two-hand"
|
|
? highestCatalogScore(context.classId, "off-hand", level, profile)
|
|
: 0);
|
|
const conflictSafety = slot === "main-hand" && inventorySlot === "two-hand" ? 1.05 : 1.02;
|
|
const targetScore = catalogScore > 0 ? catalogScore * conflictSafety : currentScore;
|
|
if (currentScore > 0 && Math.abs(targetScore - currentScore) > 0.01) stats = scaleStats(stats, targetScore / currentScore);
|
|
const normalizedScore = specializationScore(stats, profile);
|
|
if (normalizedScore + 0.001 < targetScore) {
|
|
const [bestKey, bestWeight] = Object.entries(profile.statWeights)
|
|
.sort((left, right) => (right[1] ?? 0) - (left[1] ?? 0))[0] as [NumericItemStatKey, number];
|
|
stats = Object.freeze({
|
|
...stats,
|
|
[bestKey]: stats[bestKey] + Math.max(1, Math.ceil((targetScore - normalizedScore) / Math.max(0.01, bestWeight))),
|
|
});
|
|
}
|
|
const stableNumber = [...`${profile.id}:${slot}`].reduce((hash, char) => (hash * 31 + char.charCodeAt(0)) >>> 0, 17);
|
|
// RoM has no heirloom items. The shared fallback supplies automatic training
|
|
// gear until equipment progression is introduced; retain its stable IDs and
|
|
// internal quality so this label change cannot alter saved builds or budgets.
|
|
const romTrainingGear = isRomClassId(context.classId);
|
|
const result: InventoryItem = Object.freeze({
|
|
contentCategory: contentCategoryForClass(context.classId),
|
|
instanceId: `heirloom:${context.ownerId}:${profile.id}:${slot}`,
|
|
itemId: 9_000_000 + stableNumber % 900_000,
|
|
itemKey: `heirloom-${profile.id.replace(/:/g, "-")}-${slot}`,
|
|
name: `${profile.displayName} ${romTrainingGear ? "Training" : "Heirloom"} ${SLOT_NAMES[slot]}`,
|
|
quality: "heirloom",
|
|
inventorySlot,
|
|
armorType,
|
|
weaponType,
|
|
itemLevel: level,
|
|
sourceItemLevel: level,
|
|
stats,
|
|
quantity: 1,
|
|
sourceBossKey: "heirloom",
|
|
sourceBossName: romTrainingGear ? "Training Armory" : "Heirloom Armory",
|
|
acquiredAt: 0,
|
|
});
|
|
if (romTrainingGear) romTrainingItems.add(result);
|
|
itemCache.set(cacheKey, result);
|
|
return result;
|
|
}
|
|
|
|
export function resolvedEquipmentLoadout(
|
|
assignments: EquipmentAssignments,
|
|
inventory: readonly InventoryItem[],
|
|
context: HeirloomContext,
|
|
): Readonly<Partial<Record<EquipmentSlot, InventoryItem>>> {
|
|
const profile = gearProfileFor(context.classId, context.specializationId, context.role);
|
|
const result: Partial<Record<EquipmentSlot, InventoryItem>> = {};
|
|
for (const slot of [...BASE_SLOTS, ...weaponSlots(context, profile.weaponLayout)]) {
|
|
if (isRomHybridGearClass(context.classId) && slot === "wrist") continue;
|
|
result[slot] = heirloomItem(context, profile, slot);
|
|
}
|
|
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
|
|
const earned = assignments[context.ownerId] ?? {};
|
|
for (const slot of EQUIPMENT_SLOTS) {
|
|
const item = earned[slot] ? itemById.get(earned[slot]!) : null;
|
|
if (!item || !classCanEquipItem(context.classId, item, context)) continue;
|
|
if (slot === "main-hand" && item.inventorySlot === "two-hand") delete result["off-hand"];
|
|
if (slot === "off-hand" && result["main-hand"]?.inventorySlot === "two-hand") {
|
|
result["main-hand"] = heirloomItem(context, profile, "main-hand", "one-hand");
|
|
}
|
|
result[slot] = item;
|
|
}
|
|
return Object.freeze(result);
|
|
}
|
|
|
|
export function resolvedEquipmentItems(
|
|
assignments: EquipmentAssignments,
|
|
inventory: readonly InventoryItem[],
|
|
context: HeirloomContext,
|
|
): readonly InventoryItem[] {
|
|
const loadout = resolvedEquipmentLoadout(assignments, inventory, context);
|
|
return EQUIPMENT_SLOTS.flatMap((slot) => loadout[slot] ? [loadout[slot]!] : []);
|
|
}
|
|
|
|
export function equipmentStatsForItems(items: readonly InventoryItem[]): ItemStats {
|
|
if (!items.length) return EMPTY_ITEM_STATS;
|
|
const numeric = Object.fromEntries(ITEM_STAT_KEYS.map((key) => [key, 0])) as Record<NumericItemStatKey, number>;
|
|
let meleeWeapon: WeaponStats | null = null;
|
|
let rangedWeapon: WeaponStats | null = null;
|
|
const nativePayloads = [] as NonNullable<ItemStats["romNative"]>[];
|
|
for (const item of items) {
|
|
for (const key of ITEM_STAT_KEYS) numeric[key] += item.stats[key];
|
|
const nativePayload = item.stats.romNative
|
|
?? (romTrainingItems.has(item)
|
|
|| item.contentCategory === undefined
|
|
|| item.contentCategory === "wow"
|
|
? romNativeGearFromSharedDirectStats(item.stats)
|
|
: undefined);
|
|
if (nativePayload) nativePayloads.push(nativePayload);
|
|
if (item.stats.meleeWeapon) meleeWeapon = item.stats.meleeWeapon;
|
|
if (item.stats.rangedWeapon) rangedWeapon = item.stats.rangedWeapon;
|
|
}
|
|
const romNative = sumRomNativeGearStats(nativePayloads);
|
|
return Object.freeze({ ...numeric, meleeWeapon, rangedWeapon, ...(romNative ? { romNative } : {}) });
|
|
}
|
|
|
|
export function resolvedEquipmentStats(
|
|
assignments: EquipmentAssignments,
|
|
inventory: readonly InventoryItem[],
|
|
context: HeirloomContext,
|
|
): ItemStats {
|
|
return equipmentStatsForItems(resolvedEquipmentItems(assignments, inventory, context));
|
|
}
|
|
|
|
export function resolvedAverageItemLevel(
|
|
assignments: EquipmentAssignments,
|
|
inventory: readonly InventoryItem[],
|
|
context: HeirloomContext,
|
|
): number {
|
|
const items = resolvedEquipmentItems(assignments, inventory, context);
|
|
if (!items.length) return 0;
|
|
return Math.round(items.reduce((total, item) => total + item.itemLevel, 0) / items.length * 10) / 10;
|
|
}
|
|
|
|
function sumStats(items: readonly InventoryItem[]): ItemStats {
|
|
return equipmentStatsForItems(items);
|
|
}
|
|
|
|
export function compareEquipmentUpgrade(
|
|
candidate: InventoryItem,
|
|
currentLoadout: Readonly<Partial<Record<EquipmentSlot, InventoryItem>>>,
|
|
profile: SpecializationDefinition,
|
|
context?: HeirloomContext,
|
|
): EquipmentUpgradeComparison {
|
|
const slot = equipmentSlotForItem(candidate);
|
|
if (!slot) return { isUpgrade: false, scoreDelta: 0, currentScore: 0, candidateScore: 0, displacedSlots: [], usefulStatDeltas: {} };
|
|
const displacedSlots: EquipmentSlot[] = slot === "main-hand" && candidate.inventorySlot === "two-hand"
|
|
? ["main-hand", "off-hand"]
|
|
: slot === "off-hand" && currentLoadout["main-hand"]?.inventorySlot === "two-hand"
|
|
? ["main-hand", "off-hand"]
|
|
: [slot];
|
|
const currentItems = displacedSlots.flatMap((key) => currentLoadout[key] ? [currentLoadout[key]!] : []);
|
|
const currentStats = sumStats(currentItems);
|
|
const candidateItems = slot === "off-hand" && currentLoadout["main-hand"]?.inventorySlot === "two-hand" && context
|
|
? [heirloomItem(context, profile, "main-hand", "one-hand"), candidate]
|
|
: [candidate];
|
|
const candidateStats = sumStats(candidateItems);
|
|
const currentScore = specializationScore(currentStats, profile);
|
|
const candidateScore = specializationScore(candidateStats, profile);
|
|
const scoreDelta = Math.round((candidateScore - currentScore) * 100) / 100;
|
|
const usefulStatDeltas = Object.fromEntries(Object.keys(profile.statWeights).flatMap((rawKey) => {
|
|
const key = rawKey as NumericItemStatKey;
|
|
const delta = candidateStats[key] - currentStats[key];
|
|
return delta ? [[key, delta]] : [];
|
|
})) as Partial<Record<NumericItemStatKey, number>>;
|
|
return Object.freeze({
|
|
isUpgrade: scoreDelta > 0,
|
|
scoreDelta,
|
|
currentScore,
|
|
candidateScore,
|
|
displacedSlots,
|
|
usefulStatDeltas,
|
|
});
|
|
}
|
|
|
|
export function emptyResolvedLoadout(): EquipmentLoadout {
|
|
return {};
|
|
}
|