forked from phenom/healer-man
199 lines
12 KiB
TypeScript
199 lines
12 KiB
TypeScript
import {
|
|
baseClassFor,
|
|
classById,
|
|
type ClassId,
|
|
} from "../app/characterCatalog";
|
|
import type { ItemStats, NumericItemStatKey } from "./itemStats";
|
|
import type { PartyRole } from "./partyRoles";
|
|
|
|
export type GearArchetype =
|
|
| "strength-dps"
|
|
| "agility-dps"
|
|
| "ranged-dps"
|
|
| "spell-dps"
|
|
| "healer"
|
|
| "block-tank"
|
|
| "non-block-tank";
|
|
|
|
export type WeaponLayout =
|
|
| "two-hand"
|
|
| "dual-wield"
|
|
| "weapon-shield"
|
|
| "one-hand"
|
|
| "ranged"
|
|
| "caster";
|
|
|
|
export interface SpecializationDefinition {
|
|
readonly id: string;
|
|
readonly classId: ClassId;
|
|
readonly displayName: string;
|
|
readonly role: PartyRole;
|
|
readonly talentTree: string;
|
|
readonly gearArchetype: GearArchetype;
|
|
readonly weaponLayout: WeaponLayout;
|
|
readonly statWeights: Readonly<Partial<Record<NumericItemStatKey, number>>>;
|
|
}
|
|
|
|
const WEIGHTS: Readonly<Record<GearArchetype, Readonly<Partial<Record<NumericItemStatKey, number>>>>> = Object.freeze({
|
|
"strength-dps": Object.freeze({ strength: 1, stamina: 0.18, attackPower: 0.5, hitRating: 0.82, criticalStrikeRating: 0.72, hasteRating: 0.62, expertiseRating: 0.76, armorPenetrationRating: 0.66 }),
|
|
"agility-dps": Object.freeze({ agility: 1, stamina: 0.18, attackPower: 0.45, hitRating: 0.82, criticalStrikeRating: 0.78, hasteRating: 0.64, expertiseRating: 0.72, armorPenetrationRating: 0.66 }),
|
|
"ranged-dps": Object.freeze({ agility: 1, stamina: 0.16, rangedAttackPower: 0.5, hitRating: 0.86, criticalStrikeRating: 0.78, hasteRating: 0.66, armorPenetrationRating: 0.6 }),
|
|
"spell-dps": Object.freeze({ intellect: 0.8, spirit: 0.28, stamina: 0.14, spellPower: 1, hitRating: 0.86, criticalStrikeRating: 0.68, hasteRating: 0.74 }),
|
|
healer: Object.freeze({ intellect: 0.82, spirit: 0.62, stamina: 0.16, spellPower: 1, criticalStrikeRating: 0.46, hasteRating: 0.68, mp5: 0.82 }),
|
|
"block-tank": Object.freeze({ strength: 0.64, stamina: 1, armor: 0.12, defenseRating: 0.86, dodgeRating: 0.7, parryRating: 0.68, blockRating: 0.72, blockValue: 0.66, expertiseRating: 0.38 }),
|
|
"non-block-tank": Object.freeze({ agility: 0.66, strength: 0.28, stamina: 1, armor: 0.14, defenseRating: 0.78, dodgeRating: 0.86, parryRating: 0.46, expertiseRating: 0.4 }),
|
|
});
|
|
|
|
type SpecTuple = readonly [
|
|
classId: ClassId,
|
|
displayName: string,
|
|
role: PartyRole,
|
|
gearArchetype: GearArchetype,
|
|
weaponLayout: WeaponLayout,
|
|
talentTree?: string,
|
|
];
|
|
|
|
function specId(classId: ClassId, name: string): string {
|
|
return `${classId}:${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`;
|
|
}
|
|
|
|
function define(tuple: SpecTuple): SpecializationDefinition {
|
|
const [classId, displayName, role, gearArchetype, weaponLayout, talentTree = displayName] = tuple;
|
|
return Object.freeze({
|
|
id: specId(classId, displayName),
|
|
classId,
|
|
displayName,
|
|
role,
|
|
talentTree,
|
|
gearArchetype,
|
|
weaponLayout,
|
|
statWeights: WEIGHTS[gearArchetype],
|
|
});
|
|
}
|
|
|
|
const CLASSIC_SPECIALIZATIONS: readonly SpecTuple[] = [
|
|
["warrior", "Arms", "damage", "strength-dps", "two-hand"],
|
|
["warrior", "Fury", "damage", "strength-dps", "dual-wield"],
|
|
["warrior", "Protection", "tank", "block-tank", "weapon-shield"],
|
|
["paladin", "Holy", "healer", "healer", "weapon-shield"],
|
|
["paladin", "Protection", "tank", "block-tank", "weapon-shield"],
|
|
["paladin", "Retribution", "damage", "strength-dps", "two-hand"],
|
|
["hunter", "Beast Mastery", "damage", "ranged-dps", "ranged"],
|
|
["hunter", "Marksmanship", "damage", "ranged-dps", "ranged"],
|
|
["hunter", "Survival", "damage", "ranged-dps", "ranged"],
|
|
["rogue", "Assassination", "damage", "agility-dps", "dual-wield"],
|
|
["rogue", "Combat", "damage", "agility-dps", "dual-wield"],
|
|
["rogue", "Subtlety", "damage", "agility-dps", "dual-wield"],
|
|
["priest", "Discipline", "healer", "healer", "caster"],
|
|
["priest", "Holy", "healer", "healer", "caster"],
|
|
["priest", "Shadow", "damage", "spell-dps", "caster"],
|
|
["death-knight", "Blood", "tank", "non-block-tank", "two-hand"],
|
|
["death-knight", "Frost", "damage", "strength-dps", "dual-wield"],
|
|
["death-knight", "Unholy", "damage", "strength-dps", "two-hand"],
|
|
["shaman", "Elemental", "damage", "spell-dps", "weapon-shield"],
|
|
["shaman", "Enhancement", "damage", "agility-dps", "dual-wield"],
|
|
["shaman", "Restoration", "healer", "healer", "weapon-shield"],
|
|
["mage", "Arcane", "damage", "spell-dps", "caster"],
|
|
["mage", "Fire", "damage", "spell-dps", "caster"],
|
|
["mage", "Frost", "damage", "spell-dps", "caster"],
|
|
["warlock", "Affliction", "damage", "spell-dps", "caster"],
|
|
["warlock", "Demonology", "damage", "spell-dps", "caster"],
|
|
["warlock", "Destruction", "damage", "spell-dps", "caster"],
|
|
["druid", "Balance", "damage", "spell-dps", "caster"],
|
|
["druid", "Feral (Bear)", "tank", "non-block-tank", "two-hand", "Feral Combat"],
|
|
["druid", "Feral (Cat)", "damage", "agility-dps", "two-hand", "Feral Combat"],
|
|
["druid", "Restoration", "healer", "healer", "caster"],
|
|
] as const;
|
|
|
|
/** CoA roles are authored explicitly. Support trees map to damage, the closest runtime role. */
|
|
const COA_SPECIALIZATIONS: readonly SpecTuple[] = [
|
|
["barbarian", "Brutality", "damage", "agility-dps", "two-hand"], ["barbarian", "Headhunting", "damage", "agility-dps", "ranged"], ["barbarian", "Ancestry", "damage", "agility-dps", "two-hand"],
|
|
["witch-doctor", "Shadowhunting", "damage", "ranged-dps", "ranged"], ["witch-doctor", "Voodoo", "damage", "spell-dps", "caster"], ["witch-doctor", "Brewing", "healer", "healer", "caster"],
|
|
["felsworn", "Slayer", "damage", "agility-dps", "dual-wield"], ["felsworn", "Infernal", "tank", "non-block-tank", "two-hand"], ["felsworn", "Tyrant", "damage", "spell-dps", "caster"],
|
|
["witch-hunter", "Boltslinger", "damage", "ranged-dps", "ranged"], ["witch-hunter", "Houndmaster", "damage", "ranged-dps", "ranged"], ["witch-hunter", "Inquisition", "damage", "agility-dps", "dual-wield"], ["witch-hunter", "Black Knight", "tank", "non-block-tank", "two-hand"],
|
|
["stormbringer", "Maelstrom", "damage", "spell-dps", "caster"], ["stormbringer", "Lightning", "damage", "spell-dps", "caster"], ["stormbringer", "Wind", "damage", "spell-dps", "caster"],
|
|
["knight-of-xoroth", "Hellfire", "damage", "strength-dps", "two-hand"], ["knight-of-xoroth", "Defiance", "tank", "block-tank", "weapon-shield"], ["knight-of-xoroth", "War", "damage", "strength-dps", "two-hand"],
|
|
["guardian", "Gladiator", "damage", "strength-dps", "one-hand"], ["guardian", "Inspiration", "damage", "strength-dps", "weapon-shield"], ["guardian", "Vanguard", "tank", "block-tank", "weapon-shield"],
|
|
["templar", "Zealot", "damage", "agility-dps", "dual-wield"], ["templar", "Oathkeeper", "tank", "non-block-tank", "two-hand"], ["templar", "Crusader", "damage", "agility-dps", "two-hand"],
|
|
["bloodmage", "Sanguine", "damage", "agility-dps", "dual-wield"], ["bloodmage", "Accursed", "damage", "spell-dps", "caster"], ["bloodmage", "Eternal", "tank", "non-block-tank", "two-hand"], ["bloodmage", "Fleshweaver", "healer", "healer", "caster"],
|
|
["ranger", "Archery", "damage", "ranged-dps", "ranged"], ["ranger", "Brigand", "damage", "agility-dps", "dual-wield"], ["ranger", "Farstrider", "damage", "ranged-dps", "ranged"],
|
|
["chronomancer", "Infinite", "damage", "spell-dps", "caster"], ["chronomancer", "Time", "healer", "healer", "caster"], ["chronomancer", "Artificer", "damage", "spell-dps", "caster"],
|
|
["necromancer", "Death", "damage", "spell-dps", "caster"], ["necromancer", "Rime", "damage", "spell-dps", "caster"], ["necromancer", "Animation", "damage", "spell-dps", "caster"],
|
|
["pyromancer", "Incineration", "damage", "spell-dps", "caster"], ["pyromancer", "Flameweaving", "damage", "spell-dps", "caster"], ["pyromancer", "Draconic", "damage", "spell-dps", "caster"],
|
|
["cultist", "Godblade", "damage", "strength-dps", "two-hand"], ["cultist", "Corruption", "damage", "spell-dps", "caster"], ["cultist", "Heretic", "healer", "healer", "caster"], ["cultist", "Dreadnought", "tank", "block-tank", "weapon-shield"],
|
|
["starcaller", "Moon Guard", "tank", "block-tank", "weapon-shield"], ["starcaller", "Sentinel", "damage", "ranged-dps", "ranged"], ["starcaller", "Moon Priest", "healer", "healer", "caster"], ["starcaller", "Warden", "damage", "strength-dps", "two-hand"],
|
|
["sun-cleric", "Piety", "damage", "spell-dps", "caster"], ["sun-cleric", "Blessings", "healer", "healer", "caster"], ["sun-cleric", "Valkyrie", "tank", "block-tank", "weapon-shield"], ["sun-cleric", "Seraphim", "damage", "spell-dps", "caster"],
|
|
["tinker", "Demolition", "damage", "ranged-dps", "ranged"], ["tinker", "Invention", "healer", "healer", "ranged"], ["tinker", "Mechanics", "damage", "ranged-dps", "ranged"],
|
|
["venomancer", "Venom", "damage", "spell-dps", "caster"], ["venomancer", "Stalking", "damage", "agility-dps", "dual-wield"], ["venomancer", "Fortitude", "tank", "non-block-tank", "two-hand"], ["venomancer", "Vizier", "healer", "healer", "caster"],
|
|
["reaper", "Harvest", "damage", "agility-dps", "two-hand"], ["reaper", "Soul", "damage", "spell-dps", "caster"], ["reaper", "Domination", "tank", "non-block-tank", "two-hand"],
|
|
["primalist", "Primal", "damage", "agility-dps", "two-hand"], ["primalist", "Geomancy", "damage", "spell-dps", "caster"], ["primalist", "Life", "healer", "healer", "caster"], ["primalist", "Mountain King", "tank", "non-block-tank", "two-hand"],
|
|
["runemaster", "Runic", "damage", "spell-dps", "caster"], ["runemaster", "Arcane", "damage", "spell-dps", "caster"], ["runemaster", "Riftblade", "tank", "non-block-tank", "two-hand"],
|
|
] as const;
|
|
|
|
export const SPECIALIZATION_DEFINITIONS: readonly SpecializationDefinition[] = Object.freeze(
|
|
[...CLASSIC_SPECIALIZATIONS, ...COA_SPECIALIZATIONS].map(define),
|
|
);
|
|
|
|
const BY_ID = new Map(SPECIALIZATION_DEFINITIONS.map((definition) => [definition.id, definition]));
|
|
|
|
export function specializationsForClass(classId: ClassId): readonly SpecializationDefinition[] {
|
|
return SPECIALIZATION_DEFINITIONS.filter((definition) => definition.classId === classId);
|
|
}
|
|
|
|
export function specializationById(id: string | null | undefined): SpecializationDefinition | null {
|
|
return id ? BY_ID.get(id) ?? null : null;
|
|
}
|
|
|
|
export function normalizeSpecializationId(classId: ClassId, value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const definition = specializationById(value);
|
|
return definition?.classId === classId ? definition.id : null;
|
|
}
|
|
|
|
export function specializationScore(stats: ItemStats, definition: SpecializationDefinition): number {
|
|
let score = 0;
|
|
for (const [key, weight] of Object.entries(definition.statWeights)) {
|
|
const value = stats[key as NumericItemStatKey];
|
|
if (typeof value === "number" && Number.isFinite(value)) score += value * (weight ?? 0);
|
|
}
|
|
const melee = stats.meleeWeapon;
|
|
const ranged = stats.rangedWeapon;
|
|
const weapon = definition.weaponLayout === "ranged" ? ranged : melee;
|
|
if (weapon?.damageMax && weapon.speedMs) score += ((weapon.damageMin ?? 0) + weapon.damageMax) * 0.42;
|
|
return Math.round(score * 100) / 100;
|
|
}
|
|
|
|
/** RoM has no saved specialization; derive a complete gear profile from primary class and activity role. */
|
|
export function romGearProfile(classId: ClassId, role: PartyRole): SpecializationDefinition {
|
|
const definition = classById(classId);
|
|
const base = baseClassFor(classId);
|
|
const caster = ["priest", "mage", "warlock"].includes(base) || definition.primaryStat === "intellect" || definition.primaryStat === "spirit" || definition.primaryStat === "wisdom";
|
|
const gearArchetype: GearArchetype = role === "healer"
|
|
? "healer"
|
|
: role === "tank"
|
|
? (["warrior", "paladin"].includes(base) ? "block-tank" : "non-block-tank")
|
|
: caster ? "spell-dps" : base === "hunter" ? "ranged-dps" : definition.primaryStat === "strength" ? "strength-dps" : "agility-dps";
|
|
const weaponLayout: WeaponLayout = role === "tank" && gearArchetype === "block-tank"
|
|
? "weapon-shield"
|
|
: gearArchetype === "ranged-dps" ? "ranged" : caster ? "caster" : "two-hand";
|
|
return Object.freeze({
|
|
id: `${classId}:role-${role}`,
|
|
classId,
|
|
displayName: `${definition.name} ${role}`,
|
|
role,
|
|
talentTree: "Primary class",
|
|
gearArchetype,
|
|
weaponLayout,
|
|
statWeights: WEIGHTS[gearArchetype],
|
|
});
|
|
}
|
|
|
|
export function gearProfileFor(
|
|
classId: ClassId,
|
|
specializationId: string | null | undefined,
|
|
fallbackRole: PartyRole,
|
|
): SpecializationDefinition {
|
|
return specializationById(normalizeSpecializationId(classId, specializationId))
|
|
?? romGearProfile(classId, fallbackRole);
|
|
}
|