Files
healer-man/src/game/heirloomEquipment.ts
T

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 {};
}