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>>; } const WEIGHTS: Readonly>>>> = 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>>; }>>>> = 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> = 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); }