forked from phenom/healer-man
Release Healer Man 0.1.15
This commit is contained in:
1 parent
7b41b102fa
commit
f49e54f35f
43 files changed
+1478
-112
No files matched your search
@@ -0,0 +1,378 @@
|
||||
import {
|
||||
classById,
|
||||
type ClassId,
|
||||
} from "../app/characterCatalog";
|
||||
import {
|
||||
classCanEquipItem,
|
||||
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 {
|
||||
gearProfileFor,
|
||||
specializationScore,
|
||||
type GearArchetype,
|
||||
type SpecializationDefinition,
|
||||
type WeaponLayout,
|
||||
} from "./specializationCatalog";
|
||||
|
||||
export const PLAYER_HEIRLOOM_LEVEL_CAP = 79;
|
||||
export const PARTY_HEIRLOOM_LEVEL_CAP = 80;
|
||||
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 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>();
|
||||
|
||||
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(layout: WeaponLayout): readonly EquipmentSlot[] {
|
||||
switch (layout) {
|
||||
case "dual-wield":
|
||||
case "weapon-shield":
|
||||
return ["main-hand", "off-hand"];
|
||||
case "ranged":
|
||||
return ["main-hand", "ranged"];
|
||||
case "caster":
|
||||
return ["main-hand", "off-hand", "ranged"];
|
||||
default:
|
||||
return ["main-hand"];
|
||||
}
|
||||
}
|
||||
|
||||
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") return "dagger";
|
||||
if (slot === "ranged") return profile.gearArchetype === "spell-dps" || profile.gearArchetype === "healer" ? "wand" : "bow";
|
||||
if (slot !== "main-hand") return null;
|
||||
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(slot: EquipmentSlot, layout: WeaponLayout): string {
|
||||
if (slot === "main-hand") return layout === "two-hand" || layout === "ranged" ? "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 {
|
||||
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);
|
||||
if (equipmentSlotForItem(candidate) !== slot || !classCanEquipItem(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) });
|
||||
}
|
||||
|
||||
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(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 sourceStats = statDistribution(profile.gearArchetype, budget, armor);
|
||||
const sourceWeapon = weaponStats(slot, inventorySlot, weaponType, budget);
|
||||
let stats = itemStatsFor({
|
||||
itemId: 9_000_000,
|
||||
quality: "heirloom",
|
||||
inventorySlot,
|
||||
armorType,
|
||||
weaponType,
|
||||
itemLevel: level,
|
||||
sourceItemLevel: level,
|
||||
sourceStats,
|
||||
sourceWeapon,
|
||||
});
|
||||
const currentScore = specializationScore(stats, profile);
|
||||
const catalogScore = highestCatalogScore(context.classId, slot, level, profile)
|
||||
+ (slot === "main-hand" && ["two-hand", "ranged"].includes(layout)
|
||||
? highestCatalogScore(context.classId, "off-hand", level, profile)
|
||||
: 0);
|
||||
const conflictSafety = slot === "main-hand" && ["two-hand", "ranged"].includes(layout) ? 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);
|
||||
const result: InventoryItem = Object.freeze({
|
||||
instanceId: `heirloom:${context.ownerId}:${profile.id}:${slot}`,
|
||||
itemId: 9_000_000 + stableNumber % 900_000,
|
||||
itemKey: `heirloom-${profile.id.replace(/:/g, "-")}-${slot}`,
|
||||
name: `${profile.displayName} Heirloom ${SLOT_NAMES[slot]}`,
|
||||
quality: "heirloom",
|
||||
inventorySlot,
|
||||
armorType,
|
||||
weaponType,
|
||||
itemLevel: level,
|
||||
sourceItemLevel: level,
|
||||
stats,
|
||||
quantity: 1,
|
||||
sourceBossKey: "heirloom",
|
||||
sourceBossName: "Heirloom Armory",
|
||||
acquiredAt: 0,
|
||||
});
|
||||
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(profile.weaponLayout)]) 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)) 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;
|
||||
for (const item of items) {
|
||||
for (const key of ITEM_STAT_KEYS) numeric[key] += item.stats[key];
|
||||
if (item.stats.meleeWeapon) meleeWeapon = item.stats.meleeWeapon;
|
||||
if (item.stats.rangedWeapon) rangedWeapon = item.stats.rangedWeapon;
|
||||
}
|
||||
return Object.freeze({ ...numeric, meleeWeapon, rangedWeapon });
|
||||
}
|
||||
|
||||
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 {};
|
||||
}
|
||||
Reference in new issue
Block a user