Release Healer Man 0.1.54
This commit is contained in:
1 parent
d6976e6ef6
commit
2dbb0acfc1
26 files changed
+1893
-912
No files matched your search
@@ -76,7 +76,7 @@ describe("Boss Testing", () => {
|
||||
expect(scale(1, 10).maxHealth).toBe(nativeHealth * bossDifficultyMultipliers(10).health);
|
||||
const campaign = dungeonEnemyScaling(maraudon, maraudon.entities[celebras.entityId], celebras.spawn.id,
|
||||
1, maraudon.defaultDifficultyId, 1);
|
||||
expect(campaign.maxHealth).toBe(950);
|
||||
expect(campaign.maxHealth).toBe(1050);
|
||||
expect(scale(1).damageMultiplier).toBe(campaign.damageMultiplier);
|
||||
expect(entity.combat?.attacks).toEqual(maraudon.entities[celebras.entityId].combat?.attacks);
|
||||
});
|
||||
@@ -113,7 +113,7 @@ describe("Boss Testing", () => {
|
||||
* (entity.combat?.healthMultiplier ?? 1),
|
||||
540,
|
||||
));
|
||||
expect(campaign.maxHealth).toBe(Math.max(testing.maxHealth, 950 * 1.8));
|
||||
expect(campaign.maxHealth).toBe(Math.max(testing.maxHealth, 1050 * 1.9));
|
||||
});
|
||||
|
||||
it("recognizes actual boss deaths and awards no progression or collectible rewards", () => {
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
import { createDeathKnightRunes } from "./combatActors";
|
||||
import { aiHintsForAbility } from "./abilityRules";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
import { partyTalentPrimaryTreeId } from "./partyTalents";
|
||||
import { specializationsForClass } from "./specializationCatalog";
|
||||
import { talentNodeById } from "./talentCatalog";
|
||||
|
||||
const SIGNATURES = Object.freeze({
|
||||
warrior: "Mortal Strike",
|
||||
@@ -156,6 +158,26 @@ describe("shared combat rotations", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("learns every primary-tree action named by its level-80 rotation", () => {
|
||||
for (const classId of BASE_CLASS_IDS) {
|
||||
for (const specialization of specializationsForClass(classId)) {
|
||||
const profile = rotationProfileFor(classId, specialization.id)!;
|
||||
const primaryTreeId = partyTalentPrimaryTreeId(classId, specialization.displayName);
|
||||
const ranks = runtimePartyTalentRanksForLevel(classId, specialization.displayName, 80);
|
||||
const primaryActions = profile.abilityPriority
|
||||
.map((name) => ability(classId, name))
|
||||
.filter((candidate) => candidate.talentNodeId
|
||||
&& talentNodeById(candidate.talentNodeId)?.treeId === primaryTreeId);
|
||||
for (const candidate of primaryActions) {
|
||||
expect(
|
||||
learnedAbilityForBuild(candidate, 80, ranks),
|
||||
`${specialization.id}:${candidate.name}`,
|
||||
).not.toBeNull();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps specialization talent actions isolated", () => {
|
||||
const furyTalents = runtimePartyTalentRanksForLevel("warrior", "Fury", 80);
|
||||
const armsTalents = runtimePartyTalentRanksForLevel("warrior", "Arms", 80);
|
||||
@@ -438,6 +460,45 @@ describe("shared combat rotations", () => {
|
||||
.toBe(lightningBolt.id);
|
||||
});
|
||||
|
||||
it("maintains Earth Shield on a valid full-health ally", () => {
|
||||
const earthShield = ability("shaman", "Earth Shield");
|
||||
const shaman = context({
|
||||
classId: "shaman",
|
||||
specializationId: "shaman:restoration",
|
||||
role: "healer",
|
||||
selfId: "shaman",
|
||||
primaryResource: "mana",
|
||||
resources: { mana: 10_000 },
|
||||
maximumResources: { mana: 10_000 },
|
||||
abilities: [earthShield],
|
||||
friendlyTargets: [{ id: "tank", health: 1_000, maxHealth: 1_000 }],
|
||||
maintainedAbilityIds: new Set(),
|
||||
activeAuraExclusiveGroups: new Set(),
|
||||
});
|
||||
|
||||
expect(evaluateCombatRotation(shaman)).toMatchObject({
|
||||
ability: { id: earthShield.id },
|
||||
targetId: "tank",
|
||||
});
|
||||
expect(evaluateCombatRotation({ ...shaman, friendlyTargets: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it("uses Feral Spirit as an Enhancement major cooldown on a durable target", () => {
|
||||
const feralSpirit = ability("shaman", "Feral Spirit");
|
||||
const shaman = context({
|
||||
classId: "shaman",
|
||||
specializationId: "shaman:enhancement",
|
||||
primaryResource: "mana",
|
||||
resources: { mana: 10_000 },
|
||||
maximumResources: { mana: 10_000 },
|
||||
abilities: [feralSpirit],
|
||||
hostileTarget: { id: "boss", health: 100_000, maxHealth: 100_000, boss: true },
|
||||
activeSummonAbilityIds: new Set(),
|
||||
});
|
||||
|
||||
expect(evaluateCombatRotation(shaman)?.ability.id).toBe(feralSpirit.id);
|
||||
});
|
||||
|
||||
it("enters a learned Death Knight presence before attacking", () => {
|
||||
const bloodPresence = learnedAbilityForBuild(ability("death-knight", "Blood Presence"), 1, {})!;
|
||||
const icyTouch = learnedAbilityForBuild(ability("death-knight", "Icy Touch"), 1, {})!;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { spendDeathKnightRunes } from "./combatResources";
|
||||
import { healingAbilityCanAidOthers } from "./healingAbilityRules";
|
||||
import type { PartyRole } from "./partyRoles";
|
||||
|
||||
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v2";
|
||||
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v3";
|
||||
|
||||
export interface RotationProfile {
|
||||
readonly id: string;
|
||||
@@ -90,9 +90,9 @@ const profile = (
|
||||
|
||||
/** Playerbots priorities translated to the executable Healer Man spell model. */
|
||||
export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.freeze([
|
||||
profile("warrior:arms", "warrior", ["Battle Stance", "Mortal Strike", "Rend", "Overpower", "Execute", "Slam", "Heroic Strike"]),
|
||||
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("warrior:protection", "warrior", ["Defensive Stance", "Battle Stance", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("warrior:arms", "warrior", ["Battle Stance", "Bloodrage", "Rend", "Mortal Strike", "Overpower", "Execute", "Slam", "Heroic Strike"]),
|
||||
profile("warrior:fury", "warrior", ["Berserker Stance", "Battle Stance", "Bloodrage", "Bloodthirst", "Whirlwind", "Execute", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("warrior:protection", "warrior", ["Defensive Stance", "Battle Stance", "Bloodrage", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Sunder Armor", "Heroic Strike"]),
|
||||
profile("paladin:holy", "paladin", ["Concentration Aura", "Devotion Aura", "Blessing of Might", "Seal of Wisdom", "Seal of Light", "Seal of Righteousness", "Holy Shock", "Flash of Light", "Holy Light", "Sacred Shield", "Judgement of Light"]),
|
||||
profile("paladin:protection", "paladin", ["Devotion Aura", "Blessing of Might", "Seal of Vengeance", "Seal of Corruption", "Seal of Wisdom", "Seal of Righteousness", "Hammer of the Righteous", "Shield of Righteousness", "Holy Shield", "Consecration", "Judgement of Wisdom"]),
|
||||
profile("paladin:retribution", "paladin", ["Retribution Aura", "Devotion Aura", "Blessing of Might", "Seal of Command", "Seal of Vengeance", "Seal of Corruption", "Seal of Righteousness", "Divine Storm", "Crusader Strike", "Judgement of Wisdom", "Consecration", "Exorcism", "Hammer of Wrath"]),
|
||||
@@ -106,8 +106,8 @@ export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.free
|
||||
profile("priest:holy", "priest", ["Inner Fire", "Circle of Healing", "Prayer of Mending", "Flash Heal", "Greater Heal", "Renew"]),
|
||||
profile("priest:shadow", "priest", ["Shadowform", "Inner Fire", "Vampiric Touch", "Devouring Plague", "Shadow Word: Pain", "Mind Blast", "Mind Flay", "Shadow Word: Death"]),
|
||||
profile("death-knight:blood", "death-knight", ["Frost Presence", "Blood Presence", "Heart Strike", "Death Strike", "Icy Touch", "Plague Strike", "Death Coil"]),
|
||||
profile("death-knight:frost", "death-knight", ["Blood Presence", "Howling Blast", "Obliterate", "Frost Strike", "Icy Touch", "Plague Strike"]),
|
||||
profile("death-knight:unholy", "death-knight", ["Unholy Presence", "Blood Presence", "Raise Dead", "Scourge Strike", "Death and Decay", "Plague Strike", "Icy Touch", "Death Coil"]),
|
||||
profile("death-knight:frost", "death-knight", ["Blood Presence", "Icy Touch", "Plague Strike", "Howling Blast", "Frost Strike", "Obliterate"]),
|
||||
profile("death-knight:unholy", "death-knight", ["Unholy Presence", "Blood Presence", "Raise Dead", "Plague Strike", "Icy Touch", "Scourge Strike", "Death Coil", "Death and Decay"]),
|
||||
profile("shaman:elemental", "shaman", ["Flametongue Weapon", "Rockbiter Weapon", "Lightning Shield", "Flame Shock", "Lava Burst", "Chain Lightning", "Lightning Bolt"]),
|
||||
profile("shaman:enhancement", "shaman", ["Windfury Weapon", "Rockbiter Weapon", "Lightning Shield", "Feral Spirit", "Stormstrike", "Lava Lash", "Earth Shock", "Lightning Bolt"]),
|
||||
profile("shaman:restoration", "shaman", ["Water Shield", "Rockbiter Weapon", "Lightning Shield", "Riptide", "Chain Heal", "Lesser Healing Wave", "Healing Wave", "Earth Shield"]),
|
||||
@@ -402,9 +402,11 @@ function scoreAbility(
|
||||
// the hostile target. Hybrid damage builds otherwise refresh HoTs on enemies
|
||||
// forever because those auras also look like maintenance buffs.
|
||||
const actionableHealing = healingCategory
|
||||
&& !(maintenanceBuff && categories.has("resource"))
|
||||
&& !maintenanceBuff
|
||||
&& !petSummon
|
||||
&& !offensiveSummon;
|
||||
if (maintenanceBuff && healingCategory
|
||||
&& (ability.target === "friendly" || ability.target === "any") && !lowest) return null;
|
||||
if (actionableHealing && !damaging) {
|
||||
const healingTargetHealth = ability.target === "self" ? selfHealth : lowestHealth;
|
||||
if ((ability.target === "friendly" || ability.target === "any") && !lowest) return null;
|
||||
@@ -419,7 +421,7 @@ function scoreAbility(
|
||||
// summons are valid player buttons but are not autonomous combat actions.
|
||||
if (categories.has("summon") && !damaging && !petSummon && !offensiveSummon && !maintenanceBuff) return null;
|
||||
if (categories.has("buff") && !damaging && !defensive && !maintenanceBuff
|
||||
&& !petSummon && !offensiveSummon) return null;
|
||||
&& !categories.has("resource") && !petSummon && !offensiveSummon) return null;
|
||||
if (categories.has("movement") && !damaging && !context.moving) return null;
|
||||
if (context.requiredControl && categories.has("control")) {
|
||||
return { ability, score: 1_016_000, ruleId: "reactive-control", reason: "encounter requested control", targetId: context.hostileTarget?.id ?? null };
|
||||
@@ -441,7 +443,7 @@ function scoreAbility(
|
||||
if (profileIndex >= 0) {
|
||||
score += context.role === "healer" || context.level < 10
|
||||
? 4_000 - profileIndex * 120
|
||||
: 100_000 - profileIndex * 1_000;
|
||||
: 100_000 - profileIndex * 5_000;
|
||||
ruleId = `profile-${slug(ability.name)}`;
|
||||
reason = `${rotationProfile!.specializationId} priority ${profileIndex + 1}`;
|
||||
}
|
||||
|
||||
@@ -100,9 +100,9 @@ describe("dungeon boss durability and difficulty", () => {
|
||||
|
||||
it("gives each level-three Stockades party its source-calibrated health minimum", () => {
|
||||
const easy = dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "wow");
|
||||
expect(easy).toMatchObject({ level: 4, maxHealth: 1365 });
|
||||
expect(easy).toMatchObject({ level: 4, maxHealth: 1500 });
|
||||
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 10, "wow").maxHealth)
|
||||
.toBe(1365 * bossDifficultyMultipliers(10).health);
|
||||
.toBe(1500 * bossDifficultyMultipliers(10).health);
|
||||
// The dungeon catalog must not choose the companion power curve.
|
||||
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "rom").maxHealth).toBeCloseTo(7074.8);
|
||||
expect(dungeonEnemyScaling(stockades, stockadesEntity, stockadesBoss.id, 3, "normal", 1, "coa").maxHealth).toBe(10766);
|
||||
|
||||
@@ -9,8 +9,8 @@ import type { ContentCategoryId } from "../app/contentCategories";
|
||||
const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
|
||||
// The starter anchors include enough room for authentic presence, imbue,
|
||||
// disease, and shock rotations to retain the 25/45-second mechanic window.
|
||||
[2, 950],
|
||||
[4, 1_365],
|
||||
[2, 1_050],
|
||||
[4, 1_500],
|
||||
[9, 7_000],
|
||||
[21, 11_000],
|
||||
[41, 23_000],
|
||||
@@ -25,6 +25,7 @@ const WOW_BOSS_TEST_HEALTH_FLOOR: readonly (readonly [level: number, health: num
|
||||
[80, 180_000],
|
||||
];
|
||||
const BOSS_TEST_HEALTH_FLOOR_MULTIPLIER = 0.6;
|
||||
const FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER = 1.9;
|
||||
|
||||
function interpolatedFloor(level: number, anchors: readonly (readonly [number, number])[]): number {
|
||||
const upperIndex = anchors.findIndex(([anchorLevel]) => level <= anchorLevel);
|
||||
@@ -67,7 +68,7 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
|
||||
const finalBoss = boss && !testing && finalDungeonBoss(dungeon, difficultyId)?.id === instanceId;
|
||||
// Match durability to the character's game, including when crossing dungeon catalogs.
|
||||
const partyHealthFloor = partyBossHealthFloor(level, characterSource, testing);
|
||||
const bossFloor = partyHealthFloor * (finalBoss ? 1.8 : 1);
|
||||
const bossFloor = partyHealthFloor * (finalBoss ? FINAL_BOSS_HEALTH_FLOOR_MULTIPLIER : 1);
|
||||
// A test room's sole objective must not promote every boss to a campaign
|
||||
// finale. A reduced floor still prevents high-level source creatures with
|
||||
// tiny extrapolated level-two health from dying before mechanics can run.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1 +1 @@
|
||||
{"schemaVersion":1,"maximumCoaLevel":60,"coaBudgetsByClass":{"barbarian":{"maximumAbility":26,"maximumTalent":25},"witch-doctor":{"maximumAbility":26,"maximumTalent":25},"felsworn":{"maximumAbility":26,"maximumTalent":25},"witch-hunter":{"maximumAbility":26,"maximumTalent":25},"stormbringer":{"maximumAbility":26,"maximumTalent":25},"knight-of-xoroth":{"maximumAbility":26,"maximumTalent":25},"guardian":{"maximumAbility":26,"maximumTalent":25},"templar":{"maximumAbility":26,"maximumTalent":25},"bloodmage":{"maximumAbility":26,"maximumTalent":25},"ranger":{"maximumAbility":26,"maximumTalent":25},"chronomancer":{"maximumAbility":26,"maximumTalent":25},"necromancer":{"maximumAbility":26,"maximumTalent":25},"pyromancer":{"maximumAbility":26,"maximumTalent":25},"cultist":{"maximumAbility":26,"maximumTalent":25},"starcaller":{"maximumAbility":26,"maximumTalent":25},"sun-cleric":{"maximumAbility":26,"maximumTalent":25},"tinker":{"maximumAbility":26,"maximumTalent":25},"venomancer":{"maximumAbility":26,"maximumTalent":25},"reaper":{"maximumAbility":26,"maximumTalent":25},"primalist":{"maximumAbility":26,"maximumTalent":25},"runemaster":{"maximumAbility":26,"maximumTalent":25}},"trees":[{"id":"death-knight-blood","classId":"death-knight"},{"id":"death-knight-frost","classId":"death-knight"},{"id":"death-knight-unholy","classId":"death-knight"},{"id":"druid-balance","classId":"druid"},{"id":"druid-feral-combat","classId":"druid"},{"id":"druid-restoration","classId":"druid"},{"id":"hunter-beast-mastery","classId":"hunter"},{"id":"hunter-marksmanship","classId":"hunter"},{"id":"hunter-survival","classId":"hunter"},{"id":"mage-arcane","classId":"mage"},{"id":"mage-fire","classId":"mage"},{"id":"mage-frost","classId":"mage"},{"id":"paladin-holy","classId":"paladin"},{"id":"paladin-protection","classId":"paladin"},{"id":"paladin-retribution","classId":"paladin"},{"id":"priest-discipline","classId":"priest"},{"id":"priest-holy","classId":"priest"},{"id":"priest-shadow","classId":"priest"},{"id":"rogue-assassination","classId":"rogue"},{"id":"rogue-combat","classId":"rogue"},{"id":"rogue-subtlety","classId":"rogue"},{"id":"shaman-elemental","classId":"shaman"},{"id":"shaman-enhancement","classId":"shaman"},{"id":"shaman-restoration","classId":"shaman"},{"id":"warlock-affliction","classId":"warlock"},{"id":"warlock-demonology","classId":"warlock"},{"id":"warlock-destruction","classId":"warlock"},{"id":"warrior-arms","classId":"warrior"},{"id":"warrior-fury","classId":"warrior"},{"id":"warrior-protection","classId":"warrior"},{"id":"coa-barbarian-31","classId":"barbarian"},{"id":"coa-barbarian-32","classId":"barbarian"},{"id":"coa-barbarian-33","classId":"barbarian"},{"id":"coa-barbarian-87","classId":"barbarian"},{"id":"coa-witch-doctor-36","classId":"witch-doctor"},{"id":"coa-witch-doctor-34","classId":"witch-doctor"},{"id":"coa-witch-doctor-35","classId":"witch-doctor"},{"id":"coa-witch-doctor-87","classId":"witch-doctor"},{"id":"coa-felsworn-37","classId":"felsworn"},{"id":"coa-felsworn-38","classId":"felsworn"},{"id":"coa-felsworn-18","classId":"felsworn"},{"id":"coa-felsworn-87","classId":"felsworn"},{"id":"coa-witch-hunter-39","classId":"witch-hunter"},{"id":"coa-witch-hunter-40","classId":"witch-hunter"},{"id":"coa-witch-hunter-41","classId":"witch-hunter"},{"id":"coa-witch-hunter-94","classId":"witch-hunter"},{"id":"coa-witch-hunter-87","classId":"witch-hunter"},{"id":"coa-stormbringer-42","classId":"stormbringer"},{"id":"coa-stormbringer-43","classId":"stormbringer"},{"id":"coa-stormbringer-44","classId":"stormbringer"},{"id":"coa-stormbringer-87","classId":"stormbringer"},{"id":"coa-knight-of-xoroth-46","classId":"knight-of-xoroth"},{"id":"coa-knight-of-xoroth-45","classId":"knight-of-xoroth"},{"id":"coa-knight-of-xoroth-47","classId":"knight-of-xoroth"},{"id":"coa-knight-of-xoroth-87","classId":"knight-of-xoroth"},{"id":"coa-guardian-7","classId":"guardian"},{"id":"coa-guardian-48","classId":"guardian"},{"id":"coa-guardian-49","classId":"guardian"},{"id":"coa-guardian-87","classId":"guardian"},{"id":"coa-templar-50","classId":"templar"},{"id":"coa-templar-16","classId":"templar"},{"id":"coa-templar-51","classId":"templar"},{"id":"coa-templar-87","classId":"templar"},{"id":"coa-bloodmage-26","classId":"bloodmage"},{"id":"coa-bloodmage-52","classId":"bloodmage"},{"id":"coa-bloodmage-53","classId":"bloodmage"},{"id":"coa-bloodmage-93","classId":"bloodmage"},{"id":"coa-bloodmage-87","classId":"bloodmage"},{"id":"coa-ranger-55","classId":"ranger"},{"id":"coa-ranger-87","classId":"ranger"},{"id":"coa-ranger-2","classId":"ranger"},{"id":"coa-ranger-54","classId":"ranger"},{"id":"coa-chronomancer-56","classId":"chronomancer"},{"id":"coa-chronomancer-57","classId":"chronomancer"},{"id":"coa-chronomancer-87","classId":"chronomancer"},{"id":"coa-chronomancer-58","classId":"chronomancer"},{"id":"coa-necromancer-61","classId":"necromancer"},{"id":"coa-necromancer-87","classId":"necromancer"},{"id":"coa-necromancer-59","classId"Line truncated
|
||||
{"schemaVersion":1,"maximumCoaLevel":60,"coaBudgetsByClass":{"barbarian":{"maximumAbility":26,"maximumTalent":25},"witch-doctor":{"maximumAbility":26,"maximumTalent":25},"felsworn":{"maximumAbility":26,"maximumTalent":25},"witch-hunter":{"maximumAbility":26,"maximumTalent":25},"stormbringer":{"maximumAbility":26,"maximumTalent":25},"knight-of-xoroth":{"maximumAbility":26,"maximumTalent":25},"guardian":{"maximumAbility":26,"maximumTalent":25},"templar":{"maximumAbility":26,"maximumTalent":25},"bloodmage":{"maximumAbility":26,"maximumTalent":25},"ranger":{"maximumAbility":26,"maximumTalent":25},"chronomancer":{"maximumAbility":26,"maximumTalent":25},"necromancer":{"maximumAbility":26,"maximumTalent":25},"pyromancer":{"maximumAbility":26,"maximumTalent":25},"cultist":{"maximumAbility":26,"maximumTalent":25},"starcaller":{"maximumAbility":26,"maximumTalent":25},"sun-cleric":{"maximumAbility":26,"maximumTalent":25},"tinker":{"maximumAbility":26,"maximumTalent":25},"venomancer":{"maximumAbility":26,"maximumTalent":25},"reaper":{"maximumAbility":26,"maximumTalent":25},"primalist":{"maximumAbility":26,"maximumTalent":25},"runemaster":{"maximumAbility":26,"maximumTalent":25}},"trees":[{"id":"death-knight-blood","classId":"death-knight"},{"id":"death-knight-frost","classId":"death-knight"},{"id":"death-knight-unholy","classId":"death-knight"},{"id":"druid-balance","classId":"druid"},{"id":"druid-feral-combat","classId":"druid"},{"id":"druid-restoration","classId":"druid"},{"id":"hunter-beast-mastery","classId":"hunter"},{"id":"hunter-marksmanship","classId":"hunter"},{"id":"hunter-survival","classId":"hunter"},{"id":"mage-arcane","classId":"mage"},{"id":"mage-fire","classId":"mage"},{"id":"mage-frost","classId":"mage"},{"id":"paladin-holy","classId":"paladin"},{"id":"paladin-protection","classId":"paladin"},{"id":"paladin-retribution","classId":"paladin"},{"id":"priest-discipline","classId":"priest"},{"id":"priest-holy","classId":"priest"},{"id":"priest-shadow","classId":"priest"},{"id":"rogue-assassination","classId":"rogue"},{"id":"rogue-combat","classId":"rogue"},{"id":"rogue-subtlety","classId":"rogue"},{"id":"shaman-elemental","classId":"shaman"},{"id":"shaman-enhancement","classId":"shaman"},{"id":"shaman-restoration","classId":"shaman"},{"id":"warlock-affliction","classId":"warlock"},{"id":"warlock-demonology","classId":"warlock"},{"id":"warlock-destruction","classId":"warlock"},{"id":"warrior-arms","classId":"warrior"},{"id":"warrior-fury","classId":"warrior"},{"id":"warrior-protection","classId":"warrior"},{"id":"coa-barbarian-31","classId":"barbarian"},{"id":"coa-barbarian-32","classId":"barbarian"},{"id":"coa-barbarian-33","classId":"barbarian"},{"id":"coa-barbarian-87","classId":"barbarian"},{"id":"coa-witch-doctor-36","classId":"witch-doctor"},{"id":"coa-witch-doctor-34","classId":"witch-doctor"},{"id":"coa-witch-doctor-35","classId":"witch-doctor"},{"id":"coa-witch-doctor-87","classId":"witch-doctor"},{"id":"coa-felsworn-37","classId":"felsworn"},{"id":"coa-felsworn-38","classId":"felsworn"},{"id":"coa-felsworn-18","classId":"felsworn"},{"id":"coa-felsworn-87","classId":"felsworn"},{"id":"coa-witch-hunter-39","classId":"witch-hunter"},{"id":"coa-witch-hunter-40","classId":"witch-hunter"},{"id":"coa-witch-hunter-41","classId":"witch-hunter"},{"id":"coa-witch-hunter-94","classId":"witch-hunter"},{"id":"coa-witch-hunter-87","classId":"witch-hunter"},{"id":"coa-stormbringer-42","classId":"stormbringer"},{"id":"coa-stormbringer-43","classId":"stormbringer"},{"id":"coa-stormbringer-44","classId":"stormbringer"},{"id":"coa-stormbringer-87","classId":"stormbringer"},{"id":"coa-knight-of-xoroth-46","classId":"knight-of-xoroth"},{"id":"coa-knight-of-xoroth-45","classId":"knight-of-xoroth"},{"id":"coa-knight-of-xoroth-47","classId":"knight-of-xoroth"},{"id":"coa-knight-of-xoroth-87","classId":"knight-of-xoroth"},{"id":"coa-guardian-7","classId":"guardian"},{"id":"coa-guardian-48","classId":"guardian"},{"id":"coa-guardian-49","classId":"guardian"},{"id":"coa-guardian-87","classId":"guardian"},{"id":"coa-templar-50","classId":"templar"},{"id":"coa-templar-16","classId":"templar"},{"id":"coa-templar-51","classId":"templar"},{"id":"coa-templar-87","classId":"templar"},{"id":"coa-bloodmage-26","classId":"bloodmage"},{"id":"coa-bloodmage-52","classId":"bloodmage"},{"id":"coa-bloodmage-53","classId":"bloodmage"},{"id":"coa-bloodmage-93","classId":"bloodmage"},{"id":"coa-bloodmage-87","classId":"bloodmage"},{"id":"coa-ranger-55","classId":"ranger"},{"id":"coa-ranger-87","classId":"ranger"},{"id":"coa-ranger-2","classId":"ranger"},{"id":"coa-ranger-54","classId":"ranger"},{"id":"coa-chronomancer-56","classId":"chronomancer"},{"id":"coa-chronomancer-57","classId":"chronomancer"},{"id":"coa-chronomancer-87","classId":"chronomancer"},{"id":"coa-chronomancer-58","classId":"chronomancer"},{"id":"coa-necromancer-61","classId":"necromancer"},{"id":"coa-necromancer-87","classId":"necromancer"},{"id":"coa-necromancer-59","classId"Line truncated
|
||||
@@ -6,6 +6,7 @@ import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady, partyAbilityAmount } from "./partyAbilityAi";
|
||||
import { usePartyStore, type PartyMember } from "./partyStore";
|
||||
import { deriveCharacterStats } from "./wotlkStats";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
|
||||
function memberFor(classId: PartyMember["classId"], role: PartyMember["role"]): PartyMember {
|
||||
const base = usePartyStore.getState().members[0];
|
||||
@@ -88,6 +89,44 @@ describe("catalog-driven party rotations", () => {
|
||||
expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("banks Rage for a higher-priority signature strike instead of starving it with Slam", () => {
|
||||
const talents = runtimePartyTalentRanksForLevel("warrior", "Arms", 80);
|
||||
const base = {
|
||||
...memberFor("warrior", "damage"),
|
||||
specialization: "Arms",
|
||||
specializationId: "warrior:arms",
|
||||
talentRanks: talents,
|
||||
};
|
||||
const stance = abilitiesForClass("warrior").find((ability) => ability.name === "Battle Stance")!;
|
||||
const rend = abilitiesForClass("warrior").find((ability) => ability.name === "Rend")!;
|
||||
const maintainedAbilityIds = new Set([stance.id, rend.id]);
|
||||
const target = { ...useCombatStore.getState().mobs.caster, activeCast: null };
|
||||
const empty = { ...base, resource: 0, resourcePools: { rage: 0 } };
|
||||
expect(choosePartyAbility(empty, { now: 2_000, target, maintainedAbilityIds })?.name)
|
||||
.toBe("Bloodrage");
|
||||
const bloodrage = abilitiesForClass("warrior").find((ability) => ability.name === "Bloodrage")!;
|
||||
const saving = { ...base, resource: 15, resourcePools: { rage: 15 }, cooldowns: { [bloodrage.id]: 60_000 } };
|
||||
expect(choosePartyAbility(saving, { now: 2_000, target, maintainedAbilityIds })).toBeNull();
|
||||
const funded = { ...base, resource: 30, resourcePools: { rage: 30 }, cooldowns: { [bloodrage.id]: 60_000 } };
|
||||
expect(choosePartyAbility(funded, { now: 2_000, target, maintainedAbilityIds })?.name)
|
||||
.toBe("Mortal Strike");
|
||||
});
|
||||
|
||||
it("opens Arcane combat with the learned Arcane Barrage signature", () => {
|
||||
const member = {
|
||||
...memberFor("mage", "damage"),
|
||||
specialization: "Arcane",
|
||||
specializationId: "mage:arcane",
|
||||
talentRanks: runtimePartyTalentRanksForLevel("mage", "Arcane", 80),
|
||||
};
|
||||
const maintainedAbilityIds = new Set(abilitiesForClass("mage")
|
||||
.filter((ability) => ["Mage Armor", "Ice Armor", "Frost Armor"].includes(ability.name))
|
||||
.map((ability) => ability.id));
|
||||
const target = { ...useCombatStore.getState().mobs.caster, activeCast: null };
|
||||
expect(choosePartyAbility(member, { now: 2_000, target, maintainedAbilityIds })?.name)
|
||||
.toBe("Arcane Barrage");
|
||||
});
|
||||
|
||||
it("preserves spell base damage when its bonus-power coefficient is zero", () => {
|
||||
const member = { ...memberFor("warrior", "tank"), level: 5, gearStats: EMPTY_ITEM_STATS };
|
||||
const ability = abilitiesForClass("warrior").find((candidate) => candidate.id === "warrior-heroic-strike")!;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { aiHintsForAbility, runeCostForAbility } from "./abilityRules";
|
||||
import {
|
||||
evaluateCombatRotation,
|
||||
rotationProfileFor,
|
||||
type RotationDecision,
|
||||
type RotationFriendlyTarget,
|
||||
} from "./combatRotation";
|
||||
@@ -66,6 +67,42 @@ function learnedAbilities(member: PartyMember): readonly AbilityDefinition[] {
|
||||
.filter((ability): ability is AbilityDefinition => ability !== null);
|
||||
}
|
||||
|
||||
function isComboFinisher(ability: AbilityDefinition): boolean {
|
||||
return ability.effects.some((effect) => effect.kind === "finisher-damage"
|
||||
|| (effect.kind === "combo" && effect.amount < 0));
|
||||
}
|
||||
|
||||
const RESERVED_SIGNATURE_ACTIONS = new Set(["Mortal Strike"]);
|
||||
|
||||
function shouldReserveForHigherPriority(
|
||||
member: PartyMember,
|
||||
learned: readonly AbilityDefinition[],
|
||||
decision: RotationDecision,
|
||||
now: number,
|
||||
): boolean {
|
||||
const rotation = rotationProfileFor(member.classId, member.specializationId, member.specialization);
|
||||
const decisionIndex = rotation?.abilityPriority.indexOf(decision.ability.name) ?? -1;
|
||||
if (!rotation || decisionIndex <= 0) return false;
|
||||
return rotation.abilityPriority.slice(0, decisionIndex).some((name) => {
|
||||
const candidate = learned.find((ability) => ability.name === name);
|
||||
if (!candidate || candidate.passive || isComboFinisher(candidate)) return false;
|
||||
if (!RESERVED_SIGNATURE_ACTIONS.has(candidate.name)) return false;
|
||||
if ((member.cooldowns[candidate.id] ?? 0) > now) return false;
|
||||
if ((member.schoolLockedUntil[candidate.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||
const categories = aiHintsForAbility(candidate).categories;
|
||||
if (!categories.includes("damage") || categories.includes("execute")) return false;
|
||||
const blockedCosts = abilityCosts(member, candidate).filter((cost) => (
|
||||
cost.amount > currentResource(member, cost.resource)
|
||||
));
|
||||
if (!blockedCosts.length) return false;
|
||||
return blockedCosts.some((cost) => !decision.ability.effects.some((effect) => (
|
||||
effect.kind === "resource"
|
||||
&& (effect.resource ?? member.resourceType) === cost.resource
|
||||
&& effect.amount > 0
|
||||
)));
|
||||
});
|
||||
}
|
||||
|
||||
export function choosePartyRotationDecision(
|
||||
member: PartyMember,
|
||||
context: PartyAbilityContext,
|
||||
@@ -85,7 +122,7 @@ export function choosePartyRotationDecision(
|
||||
.map((resource) => [resource, companionResourceMaximum(member, resource)]),
|
||||
);
|
||||
const learned = learnedAbilities(member);
|
||||
return evaluateCombatRotation({
|
||||
const decision = evaluateCombatRotation({
|
||||
now: context.now,
|
||||
classId: member.classId,
|
||||
secondaryClassId: member.secondaryClassId,
|
||||
@@ -119,6 +156,9 @@ export function choosePartyRotationDecision(
|
||||
hasActivePet: context.hasActivePet,
|
||||
canUseAbility: context.canUseAbility,
|
||||
});
|
||||
return decision && shouldReserveForHigherPriority(member, learned, decision, context.now)
|
||||
? null
|
||||
: decision;
|
||||
}
|
||||
|
||||
export function choosePartyAbility(
|
||||
|
||||
@@ -157,6 +157,70 @@ describe("NPC sustained damage audit", () => {
|
||||
expect(hit?.rawAmount).toBeGreaterThan(700);
|
||||
});
|
||||
|
||||
it("preserves Runic Power generated by companion rune attacks", () => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
const frost = createConfiguredPartyMember({
|
||||
name: "Balance Frost Knight",
|
||||
classId: "death-knight",
|
||||
specialization: "Frost",
|
||||
specializationId: "death-knight:frost",
|
||||
talentTreeId: "death-knight-frost",
|
||||
role: "damage",
|
||||
}, 80, 0, "runic-generation");
|
||||
const icyTouch = abilitiesForClass("death-knight").find((ability) => ability.name === "Icy Touch")!;
|
||||
usePartyStore.setState({ members: [{
|
||||
...frost,
|
||||
cooldowns: Object.fromEntries(abilitiesForClass("death-knight")
|
||||
.filter((ability) => ability.id !== icyTouch.id)
|
||||
.map((ability) => [ability.id, 60_000])),
|
||||
}] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerPartyRuntimePosition(frost.id, [-12, 0, 0]);
|
||||
useCombatStore.getState().registerMob("runic-target", { name: "Training target", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition("runic-target", [0, 0, 0]);
|
||||
useCombatStore.getState().damageMob("runic-target", 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(frost.id, "runic-target", true);
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
expect(usePartyStore.getState().members[0].resourcePools["runic-power"]).toBe(10);
|
||||
});
|
||||
|
||||
it("casts three-yard melee abilities from the companion melee formation", () => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
const rogue = createConfiguredPartyMember({
|
||||
name: "Balance Combat Rogue",
|
||||
classId: "rogue",
|
||||
specialization: "Combat",
|
||||
specializationId: "rogue:combat",
|
||||
talentTreeId: "rogue-combat",
|
||||
role: "damage",
|
||||
}, 80, 0, "melee-reach");
|
||||
const sinisterStrike = abilitiesForClass("rogue").find((ability) => ability.name === "Sinister Strike")!;
|
||||
usePartyStore.setState({ members: [{
|
||||
...rogue,
|
||||
cooldowns: Object.fromEntries(abilitiesForClass("rogue")
|
||||
.filter((ability) => ability.id !== sinisterStrike.id)
|
||||
.map((ability) => [ability.id, 60_000])),
|
||||
}] });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerPartyRuntimePosition(rogue.id, [0, 0, 0]);
|
||||
useCombatStore.getState().registerMob("melee-reach-target", { name: "Training target", maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition("melee-reach-target", [3.3, 0, 0]);
|
||||
useCombatStore.getState().damageMob("melee-reach-target", 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(rogue.id, "melee-reach-target", true);
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({
|
||||
kind: "damage",
|
||||
sourceActorId: rogue.id,
|
||||
abilityId: sinisterStrike.id,
|
||||
}));
|
||||
});
|
||||
|
||||
for (const level of [1, 3, 5, 20, 80]) {
|
||||
it(`executes damaging abilities for every party template at level ${level}`, () => {
|
||||
const templates = partyTemplates(level);
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
partyUsesRangedAttacks,
|
||||
resetPartyRuntimeClock,
|
||||
} from "./partyRuntime";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
@@ -72,6 +72,74 @@ describe("party combat runtime", () => {
|
||||
expect(effects.find(effect => effect.targetId === first.targetId)?.nextTickAt).toBe(first.nextTickAt);
|
||||
});
|
||||
|
||||
it("precasts Earth Shield on the tank while the Restoration Shaman is at full health", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const tank = createConfiguredPartyMember({
|
||||
name: "Tank",
|
||||
classId: "warrior",
|
||||
specialization: "Protection",
|
||||
specializationId: "warrior:protection",
|
||||
talentTreeId: "warrior-protection",
|
||||
role: "tank",
|
||||
}, 80, 0, "earth-shield-runtime");
|
||||
const healer = createConfiguredPartyMember({
|
||||
name: "Healer",
|
||||
classId: "shaman",
|
||||
specialization: "Restoration",
|
||||
specializationId: "shaman:restoration",
|
||||
talentTreeId: "shaman-restoration",
|
||||
role: "healer",
|
||||
}, 80, 1, "earth-shield-runtime");
|
||||
usePartyStore.getState().initializeParty("earth-shield-runtime", 80, useCombatStore.getState().health, "damage");
|
||||
usePartyStore.setState({ members: [tank, healer] });
|
||||
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
|
||||
registerPartyRuntimePosition(healer.id, [-12, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().registerMob("earth-shield-boss", {
|
||||
name: "Earth Shield boss", boss: true, maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("earth-shield-boss", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("earth-shield-boss", 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(tank.id, "earth-shield-boss", true);
|
||||
updatePartyRuntimeTargetVisibility(healer.id, "earth-shield-boss", true);
|
||||
|
||||
for (const now of [1_000, 2_500, 4_000]) advanceDungeonPartyCombat(now);
|
||||
|
||||
expect(useCombatStore.getState().auras.some((aura) => (
|
||||
aura.sourceId === healer.id
|
||||
&& aura.targetId === tank.id
|
||||
&& aura.definition.exclusiveGroup === "shaman:earth-shield"
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("turns Feral Spirit into two live Enhancement summons against a boss", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
|
||||
const shaman = createConfiguredPartyMember({
|
||||
name: "Enhancement",
|
||||
classId: "shaman",
|
||||
specialization: "Enhancement",
|
||||
specializationId: "shaman:enhancement",
|
||||
talentTreeId: "shaman-enhancement",
|
||||
role: "damage",
|
||||
}, 80, 0, "feral-spirit-runtime");
|
||||
usePartyStore.getState().initializeParty("feral-spirit-runtime", 80, useCombatStore.getState().health, "damage");
|
||||
usePartyStore.setState({ members: [shaman] });
|
||||
registerPartyRuntimePosition(shaman.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
useCombatStore.getState().registerMob("feral-spirit-boss", {
|
||||
name: "Feral Spirit boss", boss: true, maxHealth: 100_000, xpReward: 0,
|
||||
});
|
||||
setMobPosition("feral-spirit-boss", [2, 0, 0]);
|
||||
useCombatStore.getState().damageMob("feral-spirit-boss", 1, 100);
|
||||
updatePartyRuntimeTargetVisibility(shaman.id, "feral-spirit-boss", true);
|
||||
for (const now of [1_000, 2_500, 4_000, 5_500, 7_000]) advanceDungeonPartyCombat(now);
|
||||
|
||||
const feralSpirit = abilitiesForClass("shaman").find((ability) => ability.name === "Feral Spirit")!;
|
||||
expect(useCombatStore.getState().summons.filter((summon) => (
|
||||
summon.ownerActorId === shaman.id && summon.sourceAbilityId === feralSpirit.id
|
||||
))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
|
||||
registerEngagedMob();
|
||||
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
|
||||
|
||||
@@ -745,9 +745,16 @@ function partyAbilityTargetIsValid(
|
||||
&& !ability.effects.some((effect) => effect.kind === "resurrection")) return false;
|
||||
const distance = Math.sqrt(distanceSquared(origin, targetPosition));
|
||||
const targeting = targetingRulesForAbility(ability);
|
||||
const maximumRange = hostile && ability.target === "self"
|
||||
const catalogMaximumRange = hostile && ability.target === "self"
|
||||
? targeting.radius ?? ability.radius ?? ability.range.max
|
||||
: ability.range.max;
|
||||
// Several stock melee SkillLine rows use a three-yard DBC range while the
|
||||
// companion formation deliberately settles just outside that distance.
|
||||
// Apply the same combat-reach leeway used by companion weapon swings so
|
||||
// builders, diseases, bleeds, and finishers remain usable in formation.
|
||||
const maximumRange = hostile && catalogMaximumRange > 0 && catalogMaximumRange <= 5
|
||||
? Math.max(catalogMaximumRange, partyAttackRange(member))
|
||||
: catalogMaximumRange;
|
||||
if (distance < ability.range.min || distance > maximumRange || Math.abs(origin[1] - targetPosition[1]) > 8) return false;
|
||||
if (targeting.requiresLineOfSight && !combatHasLineOfSight(origin, targetPosition)) return false;
|
||||
if (hostile && targeting.requiresFacing) {
|
||||
@@ -905,6 +912,45 @@ function choosePartySupportIntent(
|
||||
return { ability, targetId: dispelTargetId };
|
||||
}
|
||||
}
|
||||
// Preventive damage-taken procs (Earth Shield / Prayer of Mending-shaped
|
||||
// effects) belong on the tank before damage lands. Normal HoTs and direct
|
||||
// heals still wait for the injured-target path below.
|
||||
const preventiveTarget = members
|
||||
.filter((candidate) => candidate.health > 0)
|
||||
.sort((left, right) => (
|
||||
Number(right.role === "tank") - Number(left.role === "tank")
|
||||
|| left.id.localeCompare(right.id)
|
||||
))[0];
|
||||
if (preventiveTarget) {
|
||||
const maintainedAbilityIds = new Set(combat.auras
|
||||
.filter((aura) => aura.sourceId === member.id && (aura.expiresAt === null || aura.expiresAt > now))
|
||||
.flatMap((aura) => abilitiesForCharacter(member.classId, member.secondaryClassId)
|
||||
.map((ability) => learnedAbilityForBuild(ability, member.level, member.talentRanks))
|
||||
.filter((ability): ability is AbilityDefinition => ability !== null)
|
||||
.filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id === aura.definition.id))
|
||||
.map((ability) => ability.id)));
|
||||
const activeAuraExclusiveGroups = new Set(combat.auras
|
||||
.filter((aura) => aura.sourceId === member.id && (aura.expiresAt === null || aura.expiresAt > now))
|
||||
.flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : []));
|
||||
const preventive = choosePartyAbility(member, {
|
||||
now,
|
||||
target: null,
|
||||
friendlyTargets: [{
|
||||
id: preventiveTarget.id,
|
||||
health: preventiveTarget.health,
|
||||
maxHealth: preventiveTarget.maxHealth,
|
||||
}],
|
||||
maintainedAbilityIds,
|
||||
activeAuraExclusiveGroups,
|
||||
canUseAbility: ability => (ability.target === "friendly" || ability.target === "any")
|
||||
&& ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.disposition === "buff"
|
||||
&& effect.aura.procs?.some((proc) => proc.triggers === "damage-taken"))
|
||||
&& partyAbilityTargetIsValid(member, ability, preventiveTarget.id),
|
||||
});
|
||||
if (preventive) return { ability: preventive, targetId: preventiveTarget.id };
|
||||
}
|
||||
const injured = [
|
||||
...(!simulatedPartyExcludesPlayer() && combat.health > 0 && combat.health < combat.maxHealth
|
||||
? [{ id: PLAYER_AGGRO_ID, health: combat.health, maxHealth: combat.maxHealth, role: "healer" as const }]
|
||||
@@ -1089,6 +1135,23 @@ function executePartyAbility(
|
||||
ability.id,
|
||||
now,
|
||||
);
|
||||
// Resource restoration writes through the authoritative actor store.
|
||||
// Mirror only its resource fields: the actor has not received this
|
||||
// action's cooldown/GCD yet, so a full actor-to-member copy would erase
|
||||
// the committed action state and let generators such as Bloodrage spam.
|
||||
if (friendlyTargetId === member.id) {
|
||||
const resourceMirror = memberFromCompanionActor(
|
||||
runtimeMember,
|
||||
useCombatStore.getState().actors[member.id],
|
||||
);
|
||||
runtimeMember = {
|
||||
...runtimeMember,
|
||||
resource: resourceMirror.resource,
|
||||
maxResource: resourceMirror.maxResource,
|
||||
resourcePools: resourceMirror.resourcePools,
|
||||
lastResourceSpentAt: resourceMirror.lastResourceSpentAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (effect.kind === "combo") {
|
||||
const current = runtimeMember.comboTargetId === targetId ? runtimeMember.comboPoints : 0;
|
||||
|
||||
@@ -14,6 +14,5 @@ describe("generated party talent runtime plans", () => {
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
normalizeTalentRanks,
|
||||
spentTalentPoints,
|
||||
talentNodeById,
|
||||
talentNodesForTree,
|
||||
talentPointsForLevel,
|
||||
talentTreeById,
|
||||
} from "./talentCatalog";
|
||||
@@ -27,10 +28,13 @@ const EXPECTED_SPECIALIZATIONS = [
|
||||
["warrior", "Arms", "warrior-arms"],
|
||||
["warrior", "Fury", "warrior-fury"],
|
||||
["paladin", "Retribution", "paladin-retribution"],
|
||||
["hunter", "Beast Mastery", "hunter-beast-mastery"],
|
||||
["hunter", "Marksmanship", "hunter-marksmanship"],
|
||||
["hunter", "Survival", "hunter-survival"],
|
||||
["rogue", "Combat", "rogue-combat"],
|
||||
["rogue", "Assassination", "rogue-assassination"],
|
||||
["rogue", "Subtlety", "rogue-subtlety"],
|
||||
["priest", "Discipline", "priest-discipline"],
|
||||
["priest", "Shadow", "priest-shadow"],
|
||||
["death-knight", "Frost", "death-knight-frost"],
|
||||
["death-knight", "Unholy", "death-knight-unholy"],
|
||||
@@ -41,7 +45,9 @@ const EXPECTED_SPECIALIZATIONS = [
|
||||
["mage", "Frost", "mage-frost"],
|
||||
["warlock", "Affliction", "warlock-affliction"],
|
||||
["warlock", "Demonology", "warlock-demonology"],
|
||||
["warlock", "Destruction", "warlock-destruction"],
|
||||
["druid", "Balance", "druid-balance"],
|
||||
["druid", "Feral (Bear)", "druid-feral-combat"],
|
||||
["druid", "Feral (Cat)", "druid-feral-combat"],
|
||||
] as const satisfies readonly (readonly [ClassId, string, string])[];
|
||||
|
||||
@@ -87,6 +93,20 @@ describe("party talent specialization plans", () => {
|
||||
expect(selection.rank).toBeLessThanOrEqual(selection.maxRank);
|
||||
}
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("reaches every primary-tree capstone before branching into a secondary tree", () => {
|
||||
for (const { classId, specialization, primaryTreeId } of PARTY_TALENT_SPECIALIZATIONS) {
|
||||
const level60 = learnedPartyTalentSelections(classId, specialization, 60);
|
||||
const primary = level60.filter((selection) => selection.inPrimaryTree);
|
||||
const deepestRow = Math.max(...talentNodesForTree(primaryTreeId).map((node) => node.row));
|
||||
expect(primary).toHaveLength(51);
|
||||
expect(
|
||||
Math.max(...primary.map((selection) => talentNodeById(selection.nodeId)?.row ?? 0)),
|
||||
`${classId}:${specialization}`,
|
||||
)
|
||||
.toBe(deepestRow);
|
||||
}
|
||||
});
|
||||
|
||||
it("spends exactly the points available at every level from 1 through 80", () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ClassId } from "../app/characterCatalog";
|
||||
import type { BaseClassId, ClassId } from "../app/characterCatalog";
|
||||
import { clampPlayerLevel, MAX_PLAYER_LEVEL } from "./progression";
|
||||
import {
|
||||
allocateTalentPoint,
|
||||
spentTalentPointsInTree,
|
||||
talentDescriptionForRank,
|
||||
talentNodeById,
|
||||
talentNodesForTree,
|
||||
@@ -10,9 +11,10 @@ import {
|
||||
talentTreesForClass,
|
||||
type TalentRanks,
|
||||
} from "./talentCatalog";
|
||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||
|
||||
export interface PartyTalentSpecializationDefinition {
|
||||
readonly classId: ClassId;
|
||||
readonly classId: BaseClassId;
|
||||
readonly specialization: string;
|
||||
readonly primaryTreeId: string;
|
||||
}
|
||||
@@ -33,10 +35,13 @@ export const PARTY_TALENT_SPECIALIZATIONS = Object.freeze([
|
||||
{ classId: "warrior", specialization: "Arms", primaryTreeId: "warrior-arms" },
|
||||
{ classId: "warrior", specialization: "Fury", primaryTreeId: "warrior-fury" },
|
||||
{ classId: "paladin", specialization: "Retribution", primaryTreeId: "paladin-retribution" },
|
||||
{ classId: "hunter", specialization: "Beast Mastery", primaryTreeId: "hunter-beast-mastery" },
|
||||
{ classId: "hunter", specialization: "Marksmanship", primaryTreeId: "hunter-marksmanship" },
|
||||
{ classId: "hunter", specialization: "Survival", primaryTreeId: "hunter-survival" },
|
||||
{ classId: "rogue", specialization: "Combat", primaryTreeId: "rogue-combat" },
|
||||
{ classId: "rogue", specialization: "Assassination", primaryTreeId: "rogue-assassination" },
|
||||
{ classId: "rogue", specialization: "Subtlety", primaryTreeId: "rogue-subtlety" },
|
||||
{ classId: "priest", specialization: "Discipline", primaryTreeId: "priest-discipline" },
|
||||
{ classId: "priest", specialization: "Shadow", primaryTreeId: "priest-shadow" },
|
||||
{ classId: "death-knight", specialization: "Frost", primaryTreeId: "death-knight-frost" },
|
||||
{ classId: "death-knight", specialization: "Unholy", primaryTreeId: "death-knight-unholy" },
|
||||
@@ -47,7 +52,9 @@ export const PARTY_TALENT_SPECIALIZATIONS = Object.freeze([
|
||||
{ classId: "mage", specialization: "Frost", primaryTreeId: "mage-frost" },
|
||||
{ classId: "warlock", specialization: "Affliction", primaryTreeId: "warlock-affliction" },
|
||||
{ classId: "warlock", specialization: "Demonology", primaryTreeId: "warlock-demonology" },
|
||||
{ classId: "warlock", specialization: "Destruction", primaryTreeId: "warlock-destruction" },
|
||||
{ classId: "druid", specialization: "Balance", primaryTreeId: "druid-balance" },
|
||||
{ classId: "druid", specialization: "Feral (Bear)", primaryTreeId: "druid-feral-combat" },
|
||||
{ classId: "druid", specialization: "Feral (Cat)", primaryTreeId: "druid-feral-combat" },
|
||||
] as const satisfies readonly PartyTalentSpecializationDefinition[]);
|
||||
|
||||
@@ -98,31 +105,85 @@ function requireSpecialization(
|
||||
return definition;
|
||||
}
|
||||
|
||||
function orderedCandidateNodeIds(definition: PartyTalentSpecializationDefinition): readonly string[] {
|
||||
function orderedCandidateNodeIds(
|
||||
definition: PartyTalentSpecializationDefinition,
|
||||
ranks: TalentRanks,
|
||||
): readonly string[] {
|
||||
const trees = talentTreesForClass(definition.classId);
|
||||
const primary = trees.find((tree) => tree.id === definition.primaryTreeId);
|
||||
if (!primary) {
|
||||
throw new Error(`Unknown primary talent tree ${definition.primaryTreeId} for ${definition.classId}`);
|
||||
}
|
||||
return [primary, ...trees.filter((tree) => tree.id !== primary.id)]
|
||||
const primaryNodes = talentNodesForTree(primary.id);
|
||||
const deepestPrimaryRow = Math.max(...primaryNodes.map((node) => node.row));
|
||||
const reachedDeepestPrimaryRow = primaryNodes.some((node) => (
|
||||
node.row === deepestPrimaryRow && (ranks[node.id] ?? 0) > 0
|
||||
));
|
||||
const primaryComplete = spentTalentPointsInTree(ranks, primary.id) >= 51
|
||||
&& reachedDeepestPrimaryRow;
|
||||
const eligibleTrees = primaryComplete
|
||||
? trees.filter((tree) => tree.id !== primary.id)
|
||||
: [primary];
|
||||
const treeOrder = new Map(eligibleTrees.map((tree, index) => [tree.id, index]));
|
||||
const activeTalentIds = new Set(
|
||||
WOW335_ABILITIES_BY_CLASS[definition.classId]
|
||||
.flatMap((ability) => ability.talentNodeId ? [ability.talentNodeId] : []),
|
||||
);
|
||||
const activePrerequisiteIds = new Set(
|
||||
[...activeTalentIds].flatMap((nodeId) => {
|
||||
const node = talentNodeById(nodeId);
|
||||
return node?.prerequisites.map((entry) => entry.talentId) ?? [];
|
||||
}),
|
||||
);
|
||||
const capstoneRequirements = new Map<string, number>();
|
||||
const addRequirement = (nodeId: string, requiredRank: number): void => {
|
||||
capstoneRequirements.set(nodeId, Math.max(requiredRank, capstoneRequirements.get(nodeId) ?? 0));
|
||||
const node = talentNodeById(nodeId);
|
||||
for (const prerequisite of node?.prerequisites ?? []) {
|
||||
addRequirement(prerequisite.talentId, prerequisite.requiredRank);
|
||||
}
|
||||
};
|
||||
const deepestNodes = primaryNodes.filter((node) => node.row === deepestPrimaryRow);
|
||||
const capstoneCandidates = deepestNodes.some((node) => activeTalentIds.has(node.id))
|
||||
? deepestNodes.filter((node) => activeTalentIds.has(node.id))
|
||||
: deepestNodes;
|
||||
const capstoneTarget = [...capstoneCandidates].sort((left, right) => left.index - right.index)[0];
|
||||
if (capstoneTarget) addRequirement(capstoneTarget.id, 1);
|
||||
const neededCapstonePathIds = new Set(
|
||||
[...capstoneRequirements].filter(([nodeId, requiredRank]) => (
|
||||
(ranks[nodeId] ?? 0) < requiredRank
|
||||
)).map(([nodeId]) => nodeId),
|
||||
);
|
||||
return eligibleTrees
|
||||
.flatMap((tree) => talentNodesForTree(tree.id))
|
||||
.sort((left, right) => (
|
||||
Number(neededCapstonePathIds.has(right.id)) - Number(neededCapstonePathIds.has(left.id))
|
||||
|| right.row - left.row
|
||||
|| Number(activeTalentIds.has(right.id)) - Number(activeTalentIds.has(left.id))
|
||||
|| Number(activePrerequisiteIds.has(right.id)) - Number(activePrerequisiteIds.has(left.id))
|
||||
|| (treeOrder.get(left.treeId) ?? 0) - (treeOrder.get(right.treeId) ?? 0)
|
||||
|| left.index - right.index
|
||||
))
|
||||
.map((node) => node.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a stable legal build without duplicating talent rules here. The
|
||||
* primary tree is exhausted in catalog row/index order before secondary trees;
|
||||
* every individual rank is accepted by the shared allocator at its real level.
|
||||
* primary tree advances to its deepest tier with exactly the 51 points needed
|
||||
* for a Wrath capstone, then spends the remaining 20 points in one secondary
|
||||
* tree. Within each unlocked tier, executable talents and their prerequisites
|
||||
* are preferred. This mirrors the shape of the Playerbots PvE templates while
|
||||
* keeping every rank validated against Healer Man's imported talent catalog.
|
||||
*/
|
||||
function buildAllocationPlan(
|
||||
definition: PartyTalentSpecializationDefinition,
|
||||
): readonly PartyTalentSelection[] {
|
||||
const candidates = orderedCandidateNodeIds(definition);
|
||||
let ranks: TalentRanks = {};
|
||||
const selections: PartyTalentSelection[] = [];
|
||||
|
||||
for (let level = 10; level <= MAX_PLAYER_LEVEL; level += 1) {
|
||||
let selected: PartyTalentSelection | null = null;
|
||||
const candidates = orderedCandidateNodeIds(definition, ranks);
|
||||
for (const nodeId of candidates) {
|
||||
const node = talentNodeById(nodeId);
|
||||
if (!node || (ranks[nodeId] ?? 0) >= node.maxRank) continue;
|
||||
|
||||
@@ -150,6 +150,26 @@ describe("Ascension-synchronized classic talent trees", () => {
|
||||
expect(allocateTalentPoint(maxed, "priest", rankNode.id, 80).reason).toBe("max-rank");
|
||||
});
|
||||
|
||||
it("clamps stale stock prerequisite ranks to the patched talent maximum", () => {
|
||||
for (const node of TALENT_NODES.filter((candidate) => candidate.system === "classic")) {
|
||||
for (const prerequisite of node.prerequisites) {
|
||||
expect(talentNodeById(prerequisite.talentId), `${node.name} prerequisite`).toBeDefined();
|
||||
expect(
|
||||
prerequisite.requiredRank,
|
||||
`${node.name} requires ${prerequisite.talentId}`,
|
||||
).toBeLessThanOrEqual(talentNodeById(prerequisite.talentId)?.maxRank ?? 0);
|
||||
}
|
||||
}
|
||||
expect(talentNodeById("talent-1917")?.prerequisites).toContainEqual({
|
||||
talentId: "talent-1791",
|
||||
requiredRank: 1,
|
||||
});
|
||||
expect(talentNodeById("talent-1791")?.prerequisites).toContainEqual({
|
||||
talentId: "talent-1789",
|
||||
requiredRank: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("deterministically trims invalid, legacy, and over-budget persisted allocations", () => {
|
||||
const arcane = firstNode("mage-arcane");
|
||||
const fire = firstNode("mage-fire");
|
||||
|
||||
@@ -175,11 +175,33 @@ const CLASSIC_TALENT_TREES: readonly TalentTreeDefinition[] = WOW335_TALENT_TREE
|
||||
};
|
||||
});
|
||||
|
||||
const CLASSIC_TALENT_NODES: readonly TalentNodeDefinition[] = WOW335_TALENT_NODES.map((node) => ({
|
||||
...node,
|
||||
maxRank: node.rankSpellIds.length,
|
||||
system: "classic",
|
||||
}));
|
||||
const CLASSIC_MAX_RANK_BY_ID: ReadonlyMap<string, number> = new Map<string, number>(
|
||||
WOW335_TALENT_NODES.map((node) => [node.id, node.rankSpellIds.length]),
|
||||
);
|
||||
|
||||
const CLASSIC_TALENT_NODES: readonly TalentNodeDefinition[] = WOW335_TALENT_NODES.map((node) => {
|
||||
// Some patched 3.3.5a trees reduce a prerequisite talent's rank count while
|
||||
// retaining the stock Talent.dbc requirement (for example 5/5 Nature's
|
||||
// Rejuvenation becomes 3/3). Clamp that stale requirement to the imported
|
||||
// prerequisite's real maximum so downstream talents remain reachable.
|
||||
const prerequisites = node.prerequisites
|
||||
.filter((prerequisite) => CLASSIC_MAX_RANK_BY_ID.has(prerequisite.talentId))
|
||||
.map((prerequisite) => ({
|
||||
...prerequisite,
|
||||
requiredRank: Math.min(
|
||||
prerequisite.requiredRank,
|
||||
CLASSIC_MAX_RANK_BY_ID.get(prerequisite.talentId)!,
|
||||
),
|
||||
}));
|
||||
return {
|
||||
...node,
|
||||
prerequisiteId: prerequisites[0]?.talentId ?? null,
|
||||
prerequisiteRank: prerequisites[0]?.requiredRank ?? null,
|
||||
prerequisites,
|
||||
maxRank: node.rankSpellIds.length,
|
||||
system: "classic",
|
||||
};
|
||||
});
|
||||
|
||||
function coaTreeId(classId: CoaClassId, tabId: number): string {
|
||||
return `coa-${classId}-${tabId}`;
|
||||
|
||||
@@ -106,6 +106,27 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
|
||||
it("provides mechanics for previously silent class scripts and hidden proc spells", () => {
|
||||
expect(effectsOfKind(byId("wow335-warrior-slam"), "damage")).not.toEqual([]);
|
||||
expect(effectsOfKind(byId("wow335-warrior-bloodthirst"), "damage")).toContainEqual(
|
||||
expect.objectContaining({ coefficient: 1.2 }),
|
||||
);
|
||||
expect(effectsOfKind(byId("wow335-warrior-bloodrage"), "resource")).toEqual([
|
||||
{ kind: "resource", resource: "rage", amount: 20 },
|
||||
]);
|
||||
expect(effectsOfKind(byId("wow335-warrior-bloodrage"), "heal")).toEqual([]);
|
||||
expect(effectsOfKind(byId("wow335-warrior-execute"), "execute")).toContainEqual(
|
||||
expect.objectContaining({ coefficient: 1.8 }),
|
||||
);
|
||||
expect(effectsOfKind(byId("wow335-warrior-execute"), "heal")).toEqual([]);
|
||||
expect(effectsOfKind(byId("wow335-rogue-mutilate"), "damage")).toContainEqual(
|
||||
expect.objectContaining({ coefficient: 2 }),
|
||||
);
|
||||
expect(effectsOfKind(byId("wow335-rogue-mutilate"), "combo")).not.toEqual([]);
|
||||
expect(effectsOfKind(byId("wow335-rogue-killing-spree"), "dot")).toContainEqual(
|
||||
expect.objectContaining({ coefficient: 0.5, ticks: 5 }),
|
||||
);
|
||||
expect(effectsOfKind(byId("wow335-warlock-conflagrate"), "damage")).toEqual([
|
||||
expect.objectContaining({ coefficient: 1.4 }),
|
||||
]);
|
||||
expect(effectsOfKind(byId("wow335-death-knight-death-grip"), "pull")).not.toEqual([]);
|
||||
expect(effectsOfKind(byId("wow335-shaman-windfury-weapon"), "apply-aura")[0]?.aura.procs).not.toEqual([]);
|
||||
expect(effectsOfKind(byId("wow335-shaman-lightning-shield"), "apply-aura")[0]?.aura.procs).toEqual(expect.arrayContaining([
|
||||
@@ -113,6 +134,15 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it("models Penance as the Discipline rotation's friendly heal", () => {
|
||||
const penance = byId("wow335-priest-penance");
|
||||
expect(penance.target).toBe("friendly");
|
||||
expect(effectsOfKind(penance, "heal")).toEqual([
|
||||
expect.objectContaining({ coefficient: 1.8 }),
|
||||
]);
|
||||
expect(effectsOfKind(penance, "damage")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps triggered shield damage children out of learned Shaman ranks", () => {
|
||||
const lightningShield = abilityAtLevel(byId("wow335-shaman-lightning-shield"), 80);
|
||||
expect(lightningShield.dbcSpellId).toBe(49281);
|
||||
@@ -127,6 +157,27 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
expect(effectsOfKind(waterShield, "apply-aura").every((effect) => (
|
||||
effect.aura.exclusiveGroup === "shaman:elemental-shield"
|
||||
))).toBe(true);
|
||||
|
||||
const earthShield = byId("wow335-shaman-earth-shield");
|
||||
const earthProc = effectsOfKind(earthShield, "apply-aura")
|
||||
.flatMap((effect) => effect.aura.procs ?? [])
|
||||
.find((proc) => proc.id.endsWith(":earth-shield"));
|
||||
expect(earthProc).toMatchObject({
|
||||
triggers: "damage-taken",
|
||||
charges: 9,
|
||||
internalCooldownMs: 3_000,
|
||||
action: { kind: "heal", target: "aura-target" },
|
||||
});
|
||||
expect(effectsOfKind(earthShield, "apply-aura").every((effect) => (
|
||||
effect.aura.exclusiveGroup === "shaman:earth-shield"
|
||||
))).toBe(true);
|
||||
});
|
||||
|
||||
it("models both offensive Spirit Wolves instead of the inert DBC summon marker", () => {
|
||||
expect(effectsOfKind(byId("wow335-shaman-feral-spirit"), "summon")).toEqual([
|
||||
expect.objectContaining({ creatureId: 29264, coefficient: 0.35, durationMs: 45_000 }),
|
||||
expect.objectContaining({ creatureId: 29264, coefficient: 0.35, durationMs: 45_000 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates source-named stat and avoidance auras", () => {
|
||||
|
||||
@@ -322,6 +322,9 @@ function exclusiveGroupForAbility(classId: BaseClassId, abilityName: string): st
|
||||
if (/\bArmor$/i.test(abilityName)) return `${classId}:armor`;
|
||||
if (/\bPresence$/i.test(abilityName)) return "death-knight:presence";
|
||||
if (["Lightning Shield", "Water Shield"].includes(abilityName)) return "shaman:elemental-shield";
|
||||
// Earth Shield is maintained on an ally while the Shaman keeps a personal
|
||||
// Lightning/Water Shield, so it cannot share their caster-global group.
|
||||
if (abilityName === "Earth Shield") return "shaman:earth-shield";
|
||||
if (/ Weapon$/i.test(abilityName)) return "shaman:weapon-enchant";
|
||||
return undefined;
|
||||
}
|
||||
@@ -558,6 +561,20 @@ function namedAuraForEffect(
|
||||
action: { kind: "resource", resource: "mana", amount: Math.max(1, effect.basePoints), target: "aura-target" },
|
||||
});
|
||||
}
|
||||
if (abilityName === "Earth Shield" && effectIndex === 0) {
|
||||
specialProcs.push({
|
||||
id: `${auraId}:earth-shield`, triggers: "damage-taken", chance: 1,
|
||||
internalCooldownMs: 3_000, charges: 9,
|
||||
action: {
|
||||
kind: "heal",
|
||||
amount: Math.max(1, Math.round(
|
||||
effect.basePoints
|
||||
+ playerCombatPower(Math.max(1, rank.sourceLevel || rank.level)) * Math.max(0, effect.coefficient),
|
||||
)),
|
||||
target: "aura-target",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (["Frost Armor", "Ice Armor"].includes(abilityName) && effect.auraType === 42) {
|
||||
specialProcs.push({
|
||||
id: `${auraId}:frost-slow`, triggers: "damage-taken", chance: 1,
|
||||
@@ -724,6 +741,11 @@ function classScriptEffects(
|
||||
});
|
||||
switch (`${classId}:${abilityName}`) {
|
||||
case "warrior:Slam": return [{ kind: "damage", coefficient: 1.5 }];
|
||||
// The player spell delegates its weapon hit to a triggered child spell;
|
||||
// retain the imported self-heal aura and restore the missing attack.
|
||||
case "warrior:Bloodthirst": return [{ kind: "damage", coefficient: 1.2 }];
|
||||
case "warrior:Bloodrage": return [{ kind: "resource", resource: "rage", amount: 20 }];
|
||||
case "warrior:Execute": return [{ kind: "execute", coefficient: 1.8 }];
|
||||
case "warrior:Charge": return [{ kind: "status", status: "stun", durationMs: 1_500 }];
|
||||
case "warrior:Intercept": return [{ kind: "status", status: "stun", durationMs: 3_000 }];
|
||||
case "warrior:Sunder Armor": return [{
|
||||
@@ -758,6 +780,12 @@ function classScriptEffects(
|
||||
case "hunter:Freezing Arrow": return [{ kind: "status", status: "freeze", durationMs: 10_000 }];
|
||||
case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }];
|
||||
case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }];
|
||||
case "rogue:Killing Spree": return [{
|
||||
kind: "dot", coefficient: 0.5, ticks: 5, intervalMs: 500, tag: "killing-spree",
|
||||
}];
|
||||
// Mutilate triggers separate main-hand and off-hand child attacks that are
|
||||
// not player-facing SkillLine records. The DBC combo award remains intact.
|
||||
case "rogue:Mutilate": return [{ kind: "damage", coefficient: 2 }];
|
||||
case "rogue:Garrote": return [{ kind: "status", status: "silence", durationMs: 3_000 }];
|
||||
case "rogue:Cloak of Shadows": return [{
|
||||
kind: "dispel", mode: "cleanse", relationship: "friendly", categories: ["magic"], maxCount: 10,
|
||||
@@ -766,7 +794,7 @@ function classScriptEffects(
|
||||
// trigger spells. Keep the imported cast/cost/rank data and supply only the
|
||||
// missing executable payload.
|
||||
case "priest:Mind Flay": return [{ kind: "damage", coefficient: 0.9 }];
|
||||
case "priest:Penance": return [{ kind: "damage", coefficient: 0.8 }];
|
||||
case "priest:Penance": return [{ kind: "heal", coefficient: 1.8 }];
|
||||
case "priest:Shadowfiend": return [{ kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 }];
|
||||
case "priest:Prayer of Mending": return [{
|
||||
kind: "apply-aura",
|
||||
@@ -818,7 +846,13 @@ function classScriptEffects(
|
||||
id: `wow335:${rank.spellId}:windfury-proc`, triggers: "hit", chance: 0.2,
|
||||
action: { kind: "damage", amount: Math.max(1, Math.round(power * 0.75)), school: "physical", target: "event-other" },
|
||||
}])];
|
||||
case "shaman:Feral Spirit": return Array.from({ length: 2 }, () => ({
|
||||
kind: "summon" as const, creatureId: 29264, coefficient: 0.35, durationMs: 45_000,
|
||||
}));
|
||||
case "warlock:Soulshatter": return [{ kind: "threat", mode: "percent", amount: -90 }];
|
||||
// Conflagrate's imported parent row contains a one-point server marker;
|
||||
// model the consumed-DoT burst rather than treating that marker as damage.
|
||||
case "warlock:Conflagrate": return [{ kind: "damage", coefficient: 1.4 }];
|
||||
case "warlock:Demonic Circle: Summon": return [{ kind: "summon", creatureId: 19175, coefficient: 0, durationMs: 360_000 }];
|
||||
case "warlock:Demonic Circle: Teleport": return [{ kind: "movement", movement: "teleport", toward: "away" }];
|
||||
case "hunter:Misdirection": return [{ kind: "threat", mode: "redirect", amount: 100 }];
|
||||
@@ -1137,6 +1171,16 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
||||
const scriptedFallbacks = classScriptEffects(classId, abilityName, rank);
|
||||
const executable = effects
|
||||
.filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported")
|
||||
.filter((effect) => !(
|
||||
(classId === "warlock" && abilityName === "Conflagrate" && effect.kind === "damage")
|
||||
|| (classId === "warrior" && abilityName === "Execute"
|
||||
&& (effect.kind === "damage" || effect.kind === "heal"))
|
||||
|| (classId === "priest" && abilityName === "Penance"
|
||||
&& (effect.kind === "damage" || effect.kind === "heal"))
|
||||
|| (classId === "shaman" && abilityName === "Feral Spirit" && effect.kind === "summon")
|
||||
|| (classId === "warrior" && abilityName === "Bloodrage"
|
||||
&& (effect.kind === "resource" || effect.kind === "heal"))
|
||||
))
|
||||
.map((effect) => effect.kind === "utility" ? effect : effect);
|
||||
const result = scriptedFallbacks.length
|
||||
? [...executable, ...scriptedFallbacks]
|
||||
@@ -1316,6 +1360,7 @@ function abilityNameRadius(abilityName: string): number {
|
||||
|
||||
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
|
||||
if (["Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
|
||||
if (abilityName === "Penance") return "friendly";
|
||||
if (abilityName === "Death Coil") return "hostile";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user