Compare commits

...
1 Commits
Author SHA1 Message Date
phenom 2dbb0acfc1 Release Healer Man 0.1.54 2026-09-18 16:09:25 -04:00
26 changed files with 1893 additions and 912 deletions

No files matched your search

+21
View File
@@ -3598,3 +3598,24 @@ Vite's existing large-chunk advisory remains informational.
errors. Evidence is in
`playtest-artifacts/balance-lab/04b-ability-breakdown.png` and
`playtest-artifacts/balance-lab/06-mobile-abilities.png`.
## WoW full-spec mechanics and rotation pass (2026-09-18)
- `playerbots-inspired-v3` now gives every one of the 31 Wrath specs a legal,
level-scaled talent plan and executable profile. The pass repaired stale
talent prerequisites, companion Rage/Runic Power persistence, short melee
reach, warrior rage banking, and explicit profile ordering.
- Scripted payloads now make Bloodthirst, Bloodrage, Execute, Mutilate, Killing
Spree, Penance, Feral Spirit, Conflagrate, and Earth Shield mechanically
meaningful. Frost/Unholy disease setup, Arms Rend/Mortal Strike order, Arcane
Barrage, and healer maintenance behavior have direct regression coverage.
- The exhaustive level-80 headless audit completed 2,672 fights with no
simulation failures under `artifacts/balance-fixes-20260918-level80/`.
The final 40-run Stockades confirmation under
`artifacts/balance-fixes-20260918-stockade-level80-final/` exercised all 31
specs: Feral Spirit, Bloodrage, and Earth Shield were active in every eligible
run, while the repaired damage signatures produced damage in their applicable
runs. Low-level boss floors were recalibrated so ordinary/final Stockades
encounters still retain their 25/45-second mechanic windows. These are
structured combat checks, so no scene screenshot is applicable. Remaining
cross-spec DPS/HPS outliers require a separate coefficient-tuning pass.
+15 -8
View File
@@ -282,18 +282,22 @@ under `parties/<party-id>/` and adds these suite-level files:
`totalHealing` and `totalHealingReceived` are the corresponding absolute
values across the included runs. Reports created before target attribution
was added remain readable but require a rerun to populate healing received.
- Ability `activeRuns` counts simulations with at least one cast, hit, damage,
healing, overheal, or absorb event. `usageRate` divides that by every run for
that build; `damageShare` divides the ability's effective damage by the
class/spec's total effective damage. `available · unused` means the ability
was learned and executable at the tested level/talent setup but emitted no
combat event. This is a review signal, not proof of an AI defect: resurrection,
- Ability `activeRuns` counts simulations with at least one cast, completed
non-throughput release, hit, damage, healing, overheal, or absorb event.
Completed resource, aura, pet, and summon actions are recorded as casts even
though they create no immediate throughput event. `usageRate` divides that by
every run for that build; `damageShare` divides the ability's effective
damage by the class/spec's total effective damage. `available · unused` means
the ability was learned and executable at the tested level/talent setup but
emitted no combat event. This is a review signal, not proof of an AI defect:
resurrection,
execute, interrupt, dispel, emergency, and long-cooldown abilities can be
legitimately unused when their condition never occurs.
- Companion AI currently applies several instant attacks directly and therefore
may emit hits/damage without a separate cast event. Use **Active runs** and
**Hits** as the authoritative usage indicators; use **Casts** and **Casts/min**
as additional detail, especially for the active-player benchmark controller.
as additional detail, especially for the active-player benchmark controller
and non-throughput actions.
- Boss pressure flags in a party report compare DTPS with the non-zero median
boss for that exact party. Suite boss flags compare across all generated
parties. Suite class/spec flags compare DPS with every observed spec from the
@@ -337,12 +341,15 @@ remains the class's primary resource. A completed companion cast resolves the
member's currently learned rank before applying its effects, so cast-time spell
damage and healing cannot fall back to rank one.
`playerbots-inspired-v2` keeps level-10-and-up non-healer builds on their
`playerbots-inspired-v3` keeps level-10-and-up non-healer builds on their
specialization's authored damage priority whenever that build knows a profile
damage action. Reactive interrupts, taunts, control, and defenses still take
precedence. Bear and Cat actions are isolated to their matching Feral specs;
Overpower, Revenge, and Victory Rush require an explicitly modeled trigger;
and Drain Soul, Hammer of Wrath, and Kill Shot obey their target-health gates.
Incidental DBC aura categories cannot outrank explicit profile actions. Rage and
Runic Power generated during an action persist without erasing that action's
cooldown/GCD, and short-range melee abilities use companion combat reach.
## Code map
+16 -6
View File
@@ -1,6 +1,8 @@
# Combat rotation matrix
Healer Man uses AzerothCore playerbots commit `531282e` as a read-only behavioral reference. The implementation is an independent TypeScript policy over Healer Man's executable catalog, not a port of playerbots C++.
Healer Man uses AzerothCore playerbots commit `531282e` as a read-only behavioral reference. The complete historical checkout is available locally at `../wow-pvt-server-backups-2/azerothcore-wotlk/modules/mod-playerbots`; do not modify it or treat it as the active project. The implementation is an independent TypeScript policy over Healer Man's executable catalog, not a port of playerbots C++.
The most useful upstream starting points are `src/Ai/Class/<Class>/Strategy/` for actions, triggers, and relative priorities; `conf/playerbots.conf.dist` for the level-60 and level-80 PvE talent build links; `src/PlayerbotAIConfig.cpp` for parsing those links; and `src/Bot/Factory/PlayerbotFactory.cpp` plus `src/Mgr/Talent/Talentspec.cpp` for level-aware talent application. Translate these inputs through Healer Man's own talent legality, executable-effect, resource, encounter-state, and deterministic-simulation rules rather than copying C++ behavior literally.
| Concern | Playerbots behavior translated into Healer Man |
| --- | --- |
@@ -16,6 +18,14 @@ Healer Man uses AzerothCore playerbots commit `531282e` as a read-only behaviora
| Combo points | Builders run below four points; finishers require at least four points on the current target. |
| Major cooldowns | Offensive cooldowns of 60 seconds or longer are held for bosses or durable targets. |
`playerbots-inspired-v3` also generates legal level-scaled talent plans for all
31 classic specializations. Each level-80 plan spends 51 points in the selected
tree and 20 in a compatible secondary tree, prefers executable active talents
and their prerequisites, and sanitizes obsolete imported prerequisite links.
Runtime resource generation is actor-authoritative without erasing the action's
cooldown/GCD, and companion melee abilities share the formation combat-reach
leeway already used by weapon swings.
CoA and Runes of Magic builds use the same semantic rules when no classic profile applies. Runes of Magic candidates include primary, secondary-general, and primary/secondary elite skills through `abilitiesForCharacter`.
## Classic specialization profiles
@@ -24,9 +34,9 @@ The leftmost available action wins after reactive rules, learned-talent gating,
| Class | Specialization | Maintenance and core priority |
| --- | --- | --- |
| Warrior | Arms | Battle Stance; Mortal Strike; Rend; Overpower; Execute; Slam; Heroic Strike |
| Warrior | Fury | Berserker Stance; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
| Warrior | Protection | Defensive Stance; Shield Slam; Revenge; Devastate; Thunder Clap; Sunder Armor; Heroic Strike |
| Warrior | Arms | Battle Stance; Bloodrage; Rend; Mortal Strike; Overpower; Execute; Slam; Heroic Strike |
| Warrior | Fury | Berserker/Battle Stance; Bloodrage; Bloodthirst; Whirlwind; Execute; Sunder Armor; Heroic Strike |
| Warrior | Protection | Defensive/Battle Stance; Bloodrage; Shield Slam; Revenge; Devastate; Thunder Clap; Sunder Armor; Heroic Strike |
| Paladin | Holy | Concentration Aura; Blessing of Might; Holy Shock; Flash of Light; Holy Light; Sacred Shield; Judgement of Light |
| Paladin | Protection | Devotion Aura; Blessing of Might; Hammer of the Righteous; Shield of Righteousness; Holy Shield; Consecration; Judgement of Wisdom |
| Paladin | Retribution | Retribution Aura; Blessing of Might; Divine Storm; Crusader Strike; Judgement of Wisdom; Consecration; Exorcism; Hammer of Wrath |
@@ -40,8 +50,8 @@ The leftmost available action wins after reactive rules, learned-talent gating,
| Priest | Holy | Inner Fire; Circle of Healing; Prayer of Mending; Flash Heal; Greater Heal; Renew |
| Priest | Shadow | Shadowform; Inner Fire; Vampiric Touch; Devouring Plague; Shadow Word: Pain; Mind Blast; Mind Flay; Shadow Word: Death |
| Death Knight | Blood | Frost Presence (Blood Presence fallback); Heart Strike; Death Strike; Icy Touch; Plague Strike; Death Coil |
| Death Knight | Frost | Blood Presence; Howling Blast; Obliterate; Frost Strike; Icy Touch; Plague Strike |
| Death Knight | Unholy | Unholy Presence (Blood Presence fallback); Raise Dead; Scourge Strike; Death and Decay; Plague Strike; Icy Touch; Death Coil |
| Death Knight | Frost | Blood Presence; Icy Touch; Plague Strike; Howling Blast; Frost Strike; Obliterate |
| Death Knight | Unholy | Unholy Presence (Blood Presence fallback); Raise Dead; Plague Strike; Icy Touch; Scourge Strike; Death Coil; Death and Decay |
| Shaman | Elemental | Flametongue Weapon (Rockbiter fallback); Lightning Shield; Flame Shock; Lava Burst; Chain Lightning; Lightning Bolt |
| Shaman | Enhancement | Windfury Weapon (Rockbiter fallback); Lightning Shield; Feral Spirit; Stormstrike; Lava Lash; Earth Shock; Lightning Bolt |
| Shaman | Restoration | Water Shield; Rockbiter Weapon; Lightning Shield; Riptide; Chain Heal; Lesser Healing Wave; Healing Wave; Earth Shield |
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "healer-man",
"version": "0.1.53",
"version": "0.1.54",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
"version": "0.1.53",
"version": "0.1.54",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
"version": "0.1.53",
"version": "0.1.54",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{
"versionName": "0.1.53",
"versionCode": 1053
"versionName": "0.1.54",
"versionCode": 1054
}
+22
View File
@@ -9,6 +9,7 @@ import {
} from "../../src/app/characterCatalog";
import type { ContentCategoryId } from "../../src/app/contentCategories";
import {
abilityById,
abilitiesForCharacter,
learnedAbilityForBuild,
preloadAbilityCatalogsForClasses,
@@ -17,6 +18,7 @@ import { aiHintsForAbility } from "../../src/game/abilityRules";
import { PLAYER_AGGRO_ID } from "../../src/game/aggro";
import { installCombatClock } from "../../src/game/combatClock";
import { subscribeCombatEvents } from "../../src/game/combatEvents";
import { subscribeCombatPresentation } from "../../src/game/combatPresentation";
import { resetCombatSpatialProvider } from "../../src/game/combatSpatial";
import { useCombatStore } from "../../src/game/combatStore";
import { dungeonEnemyScaling } from "../../src/game/dungeonBossScaling";
@@ -238,6 +240,13 @@ function abilityMetrics(member: MutableMemberMetrics, abilityId: string | undefi
return created;
}
function abilityProducesAttributedCombatEvent(abilityId: string | undefined): boolean {
const ability = abilityId ? abilityById(abilityId) : null;
return ability?.effects.some((effect) => [
"damage", "dot", "finisher-damage", "execute", "heal", "hot", "shield", "resurrection",
].includes(effect.kind)) ?? true;
}
function currentHealth(actorId: string): readonly [number, number] {
const combat = useCombatStore.getState();
if (actorId === PLAYER_AGGRO_ID) return [combat.health, combat.maxHealth];
@@ -371,10 +380,22 @@ async function runBoss(
}));
const byActorId = new Map(memberMetrics.map((metrics) => [metrics.actorId, metrics]));
const bossAbilities = new Map<string, MutableAbilityMetrics>();
// Instant resource, aura, pet, and summon actions do not produce the damage,
// healing, absorb, or cast events used by the original report collector.
// Count their completed release once so Balance Lab does not label working
// actions such as Bloodrage and Feral Spirit as available-but-unused.
const stopPresentation = subscribeCombatPresentation((message) => {
if (message.kind !== "event" || message.event.phase !== "release") return;
const source = byActorId.get(message.event.sourceActorId);
if (!source || abilityProducesAttributedCombatEvent(message.event.abilityId)) return;
source.casts += 1;
abilityMetrics(source, message.event.abilityId).casts += 1;
});
const stopEvents = subscribeCombatEvents((event) => {
const source = byActorId.get(event.sourceActorId);
const target = byActorId.get(event.targetActorId);
if (event.kind === "cast" && source) {
if (!abilityProducesAttributedCombatEvent(event.abilityId)) return;
source.casts += 1;
abilityMetrics(source, event.abilityId).casts += 1;
} else if (event.kind === "damage") {
@@ -531,6 +552,7 @@ async function runBoss(
|| left.abilityId.localeCompare(right.abilityId)),
};
} finally {
stopPresentation();
stopEvents();
restoreClock();
clearMobRuntimeRegistry();
+2 -2
View File
@@ -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", () => {
+61
View File
@@ -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, {})!;
+11 -9
View File
@@ -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}`;
}
+2 -2
View File
@@ -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);
+4 -3
View File
@@ -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
View File
@@ -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
+39
View File
@@ -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")!;
+41 -1
View File
@@ -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(
+64
View File
@@ -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);
+69 -1
View File
@@ -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")!;
+64 -1
View File
@@ -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;
+1 -2
View File
@@ -14,6 +14,5 @@ describe("generated party talent runtime plans", () => {
);
}
}
});
}, 15_000);
});
+20
View File
@@ -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", () => {
+68 -7
View File
@@ -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;
+20
View File
@@ -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");
+27 -5
View File
@@ -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}`;
+51
View File
@@ -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", () => {
+46 -1
View File
@@ -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;
}