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

273 lines
16 KiB
TypeScript

import {
baseClassFor,
classById,
classIconUrl,
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 }),
});
const ROM_FIVE_HYBRID_PROFILES: Readonly<Partial<Record<ClassId, Readonly<{
role: PartyRole;
gearArchetype: GearArchetype;
weaponLayout: WeaponLayout;
statWeights: Readonly<Partial<Record<NumericItemStatKey, number>>>;
}>>>> = Object.freeze({
"rom-knight": Object.freeze({ role: "tank", gearArchetype: "block-tank", weaponLayout: "weapon-shield", statWeights: Object.freeze({ ...WEIGHTS["block-tank"], armor: 0.16, blockValue: 0.76 }) }),
"rom-priest": Object.freeze({ role: "healer", gearArchetype: "healer", weaponLayout: "weapon-shield", statWeights: Object.freeze({ ...WEIGHTS.healer, spirit: 0.7, mp5: 0.95 }) }),
"rom-scout": Object.freeze({ role: "damage", gearArchetype: "ranged-dps", weaponLayout: "ranged", statWeights: Object.freeze({ ...WEIGHTS["ranged-dps"], rangedAttackPower: 0.6 }) }),
"rom-rogue": Object.freeze({ role: "damage", gearArchetype: "agility-dps", weaponLayout: "dual-wield", statWeights: Object.freeze({ ...WEIGHTS["agility-dps"], expertiseRating: 0.78 }) }),
"rom-mage": Object.freeze({ role: "damage", gearArchetype: "spell-dps", weaponLayout: "two-hand", statWeights: Object.freeze({ ...WEIGHTS["spell-dps"], spellPower: 1.1 }) }),
});
export function isRomHybridGearClass(classId: ClassId): boolean {
return ROM_FIVE_HYBRID_PROFILES[classId] !== undefined;
}
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]));
const CLASSIC_SPECIALIZATION_ICON_BASENAME: Readonly<Record<string, string>> = Object.freeze({
"warrior:arms": "ability_rogue_eviscerate",
"warrior:fury": "ability_warrior_innerrage",
"warrior:protection": "inv_shield_06",
"paladin:holy": "spell_holy_holybolt",
"paladin:protection": "spell_holy_devotionaura",
"paladin:retribution": "spell_holy_auraoflight",
"hunter:beast-mastery": "ability_hunter_beasttaming",
"hunter:marksmanship": "ability_marksmanship",
"hunter:survival": "ability_hunter_swiftstrike",
"rogue:assassination": "ability_rogue_eviscerate",
"rogue:combat": "ability_backstab",
"rogue:subtlety": "ability_stealth",
"priest:discipline": "spell_holy_wordfortitude",
"priest:holy": "spell_holy_guardianspirit",
"priest:shadow": "spell_shadow_shadowwordpain",
"death-knight:blood": "spell_deathknight_bloodpresence",
"death-knight:frost": "spell_deathknight_frostpresence",
"death-knight:unholy": "spell_deathknight_unholypresence",
"shaman:elemental": "spell_nature_lightning",
"shaman:enhancement": "spell_nature_lightningshield",
"shaman:restoration": "spell_nature_magicimmunity",
"mage:arcane": "spell_holy_magicalsentry",
"mage:fire": "spell_fire_firebolt02",
"mage:frost": "spell_frost_frostbolt02",
"warlock:affliction": "spell_shadow_deathcoil",
"warlock:demonology": "spell_shadow_metamorphosis",
"warlock:destruction": "spell_shadow_rainoffire",
"druid:balance": "spell_nature_starfall",
"druid:feral-bear": "ability_racial_bearform",
"druid:feral-cat": "ability_racial_bearform",
"druid:restoration": "spell_nature_healingtouch",
});
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;
}
/** Returns the dedicated WoW talent-tree icon when available, otherwise the class icon. */
export function specializationIconUrl(classId: ClassId, specializationId: string | null | undefined): string {
const specialization = specializationById(specializationId);
const iconBasename = specialization?.classId === classId
? CLASSIC_SPECIALIZATION_ICON_BASENAME[specialization.id]
: undefined;
return iconBasename
? `/assets/ui/talents/icons/${iconBasename}.png`
: classIconUrl(classId);
}
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 hybrid = ROM_FIVE_HYBRID_PROFILES[classId];
if (hybrid?.role === role) return Object.freeze({
id: `${classId}:role-${role}`,
classId,
displayName: `${definition.name} ${role}`,
role,
talentTree: "Primary class",
gearArchetype: hybrid.gearArchetype,
weaponLayout: hybrid.weaponLayout,
statWeights: hybrid.statWeights,
});
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" : base === "rogue" ? "dual-wield" : "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);
}