Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 11:23:13 -04:00
1 parent 1fedb9368d
commit cc3b2fa4d5
27 files changed
+1988 -83

No files matched your search

+33 -6
View File
@@ -35,8 +35,10 @@ The prompt selects:
Automatic class/spec selection has three strategies:
- **Random parties** preserves the original seeded behavior and generates the
requested number of unique parties per source.
- **Random parties** preserves the original seeded behavior and generates up
to the requested number of unique parties per source. If a Ready/Beta pool
can form fewer unique 1/1/3 rosters, Balance Lab runs each available roster
once and reports the cap instead of failing or duplicating identical data.
- **All specs** creates the minimum deterministic set of valid parties needed
to include every available class/spec build for the selected WoW, CoA, or
RoM source at least once.
@@ -484,6 +486,15 @@ all bosses use the same testing-room durability rule at the chosen level.
Runtime IDs include the simulation seed, which deterministically varies the
game's stable target and cooldown rolls.
Shared curves remain the baseline, but confirmed encounter outliers belong in
`src/game/bossBalanceOverrides.ts`. Each source-controlled entry identifies one
dungeon boss and may apply at one exact level, across a bounded level bracket,
or at every level. Health and damage multipliers are independent and compose;
isolated `boss-testing:` rooms resolve back to their source dungeon so the same
exception is exercised by Balance Lab and live dungeon runs. Record the audit
evidence in the entry reason and prefer this layer over another global curve
change when the problem is confined to particular encounters.
## Coverage and limitations
The selectable roster includes every authored WoW and CoA specialization plus
@@ -513,18 +524,32 @@ 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-v4` keeps level-10-and-up non-healer builds on their
`playerbots-inspired-v6-reactive-ready-five` 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.
Overpower and Victory Rush require an explicitly modeled trigger, while
Protection companions derive Revenge's five-second trigger from incoming
dodge, parry, and block combat events; Drain Soul, Hammer of Wrath, and Kill
Shot obey their target-health gates.
Off-profile control, interrupt, and debuff buttons cannot consume ordinary
rotation GCDs after their reactive conditions have been checked. 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;
short-range melee abilities use companion combat reach; and pets move every
combat tick while retaining their own attack cadence.
combat tick while retaining their own attack cadence. Untargeted cooldown
resets exclude the source ability, Discipline mitigation/absorbs precede direct
triage and moderate injuries receive a pending-HoT-aware periodic-heal pass,
and Assassination banks Energy for Mutilate while using Envenom as its primary
four-plus-point finisher, matching the pinned Playerbots strategy. Rogue weapon
hits now execute the maintained Instant/Deadly coatings: Deadly Poison stacks a
12-second DoT to five source-specific doses, Envenom uses one dose per combo
point for Nature damage and grants its temporary poison-frequency buff, Mutilate
offers two poison application opportunities, and Shiv guarantees the off-hand
Deadly application at four-plus combo points only while the pending Envenom
lacks enough doses. Envenom normally consumes the used doses; Orin's pinned 3/3
Master Poisoner build preserves them 100% of the time as specified by the
talent.
## Code map
@@ -556,6 +581,8 @@ combat tick while retaining their own attack cadence.
pools, and appearance for an exact companion.
- `src/game/bossTesting.ts`: the authoritative all-boss catalog and isolated
room construction.
- `src/game/bossBalanceOverrides.ts`: audited boss-specific health and damage
exceptions, optionally scoped to an exact level or level bracket.
When extending the tool, add fields to `raw-runs.json` first, aggregate them in
Python second, and document the metric here. Keep formula ownership in `src/game`
+35 -5
View File
@@ -11,14 +11,14 @@ The most useful upstream starting points are `src/Ai/Class/<Class>/Strategy/` fo
| Execute | Execute-category abilities move ahead of the normal profile below 20% target health. |
| Multiple targets | Cleave gains value at two hostiles; area damage moves ahead at three or more. |
| Healing | Critical, medium, and light triage bands begin below 30%, 60%, and 90% health. Area healing requires three injured allies. |
| Threat | Tanks interrupt first and taunt a hostile that is not attacking them; non-tanks drop dangerous threat before continuing their profile. |
| Threat | Tanks interrupt first and taunt a hostile that is not attacking them; non-tanks drop or redirect dangerous threat before continuing their profile. Threat effects update every engaged enemy and immediately resolve its next target. |
| Control | Interrupts are reactive. Long crowd control is used only when an encounter requests control. |
| Defense | Mitigation is used below 55% actor health and receives emergency priority below 35%. |
| Resources | Low-resource generators are favored below 30%; spenders still have to pass runtime cost and rune validation. |
| Defense | Mitigation is used below 55% actor health and receives emergency priority below 35%. Assassination uses Evasion for boss aggro, low-health melee pressure, or a telegraphed strong melee hit; Vanish resets threat for boss aggro or near-death escape. |
| Resources | Low-resource generators are favored below 30%; spenders still have to pass runtime cost and rune validation. Arms banks Rage for Mortal Strike and Assassination banks Energy for Mutilate instead of starving those signature actions with a cheaper filler. |
| 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-v5-ready-five` also generates legal level-scaled talent plans for all
`playerbots-inspired-v6-reactive-ready-five` 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.
@@ -43,7 +43,7 @@ The leftmost available action wins after reactive rules, learned-talent gating,
| Hunter | Beast Mastery | Aspect of the Dragonhawk/Hawk; Call Pet; Kill Command; Bestial Wrath; Serpent Sting; Arcane Shot; Multi-Shot; Steady Shot |
| Hunter | Marksmanship | Aspect of the Dragonhawk/Hawk; Call Pet; Misdirection; Rapid Fire; Readiness; Kill Shot; Chimera Shot; Aimed Shot; Serpent Sting; Arcane Shot; Steady Shot; Silencing Shot; Disengage; Feign Death |
| Hunter | Survival | Aspect of the Dragonhawk/Hawk; Call Pet; Kill Shot; Explosive Shot; Black Arrow; Serpent Sting; Aimed Shot; Arcane Shot; Steady Shot |
| Rogue | Assassination | Deadly/Instant Poison; Tricks of the Trade; Cold Blood; Envenom; Rupture; Mutilate; Eviscerate; Sinister Strike; Kick; Evasion; Cloak of Shadows; Vanish |
| Rogue | Assassination | Deadly/Instant Poison; Tricks of the Trade; Cold Blood; Envenom; Shiv; Rupture; Mutilate; Eviscerate; Sinister Strike; Kick; Evasion; Cloak of Shadows; Vanish |
| Rogue | Combat | Killing Spree; Blade Flurry; Eviscerate; Sinister Strike |
| Rogue | Subtlety | Shadow Dance; Rupture; Eviscerate; Hemorrhage; Backstab |
| Priest | Discipline | Inner Fire; Pain Suppression; Inner Focus; Power Infusion; Shadowfiend; Divine Hymn; Penance; Power Word: Shield; Flash/Greater Heal; Renew; Dispel Magic; Cure Disease; Resurrection |
@@ -92,3 +92,33 @@ major offensive and defensive cooldown decisions without unexplained idle
windows. Stock abilities whose Talent.dbc node was removed by the patched
client (currently Devastate, Shadowstep, and Lava Lash) use their retained
Spell.dbc source level rather than an impossible dangling talent gate.
The Ready-five runtime gives healer mitigation its own triage pass before
direct healing: Discipline uses Pain Suppression at the defensive threshold,
Power Word: Shield as named absorb capacity, and Renew on moderately injured
targets without replacing a pending HoT. Inner Focus is reserved for an ally
at or below 40% health or at least two allies at or below 60%, then empowers
the next direct single-target or area heal with a free cost and 25 percentage
points of critical chance. Protection Revenge is enabled for a
five-second window after an authoritative incoming dodge, parry, or block.
Untargeted cooldown resets affect other abilities only, so Readiness cannot
reset itself and monopolize the Hunter rotation. Blink and Disengage create
distance from threat, low-health danger, or an imminent strong melee hit;
Fire Blast is the instant Fire action available while moving.
Assassination treats Envenom as its primary four-plus-combo-point finisher,
matching the pinned Playerbots Assassination trigger, and banks Energy rather
than spending those points on cheaper Rupture while Envenom becomes affordable.
Rupture remains a bleed fallback rather than a required maintenance action.
Instant Poison now deals rank- and attack-power-scaled Nature damage. Deadly
Poison applies a source-specific, refreshable 12-second DoT that scales from one
to five doses. Envenom uses up to one dose per combo point, normally consumes
those doses, deals Nature damage per used dose, and grants its 1 + combo-point
second buff: 75% more frequent Instant Poison applications and 15 percentage
points more Deadly Poison application chance. Mutilate supplies two poison
opportunities. Shiv
models its off-hand strike by guaranteeing a Deadly Poison application and is
eligible at four-plus combo points only while the target lacks enough doses for
the pending Envenom. The pinned Assassination build's 3/3 Master Poisoner gives
Envenom a 100% chance to
preserve the doses while retaining their damage scaling.
+34 -7
View File
@@ -166,9 +166,7 @@ def random_party(builds: Sequence[dict[str, Any]], seed: int) -> list[dict[str,
generator = random.Random(seed)
# Named alternatives remain directly selectable and are covered by "all",
# but ordinary random/filler seats should use the declared default profile.
defaults = [build for build in builds if build.get("defaultTalentBuild")]
if defaults:
builds = defaults
builds = random_party_builds(builds)
by_role: dict[str, list[dict[str, Any]]] = defaultdict(list)
for build in builds:
by_role[build["role"]].append(build)
@@ -178,6 +176,21 @@ def random_party(builds: Sequence[dict[str, Any]], seed: int) -> list[dict[str,
return [generator.choice(by_role["tank"]), generator.choice(by_role["healer"]), *damage]
def random_party_builds(builds: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
defaults = [build for build in builds if build.get("defaultTalentBuild")]
return defaults or list(builds)
def random_party_capacity(builds: Sequence[dict[str, Any]]) -> int:
"""Return the number of unique 1/1/3 compositions in the random pool."""
by_role: dict[str, int] = defaultdict(int)
for build in random_party_builds(builds):
by_role[build["role"]] += 1
if not by_role["tank"] or not by_role["healer"] or by_role["damage"] < 3:
return 0
return by_role["tank"] * by_role["healer"] * math.comb(by_role["damage"], 3)
def readiness_eligible_builds(
builds: Sequence[dict[str, Any]],
policy: dict[str, Any] | None,
@@ -209,14 +222,28 @@ def automatic_parties(
configurations: list[dict[str, Any]] = []
for source in sources:
source_builds = [build for build in builds if build["source"] == source]
capacity = random_party_capacity(source_builds)
if capacity < 1:
raise SystemExit(
f"The {source} eligible pool does not expose enough tank, healer, and damage builds "
"for a 1/1/3 party."
)
target_configurations = min(configurations_per_source, capacity)
if target_configurations < configurations_per_source:
print(
f"[balance-lab] {source} eligible pool has {capacity} unique party "
f"configuration{'s' if capacity != 1 else ''}; running each once instead of "
f"duplicating {configurations_per_source - capacity} identical parties.",
file=sys.stderr,
)
seen: set[tuple[str, str, tuple[str, ...]]] = set()
attempts = 0
while len(seen) < configurations_per_source:
while len(seen) < target_configurations:
attempts += 1
if attempts > max(1_000, configurations_per_source * 100):
if attempts > max(1_000, target_configurations * 100):
raise SystemExit(
f"Could not generate {configurations_per_source} unique {source} parties. "
"Request fewer party configurations."
f"Could not generate {target_configurations} unique {source} parties from a "
f"calculated capacity of {capacity}."
)
party = random_party(source_builds, seed + offsets[source] + attempts - 1)
signature = (party[0]["id"], party[1]["id"], tuple(sorted(member["id"] for member in party[2:])))
+17 -3
View File
@@ -71,8 +71,11 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
assert(Number(await page.getByLabel('Coverage parties').inputValue()) > 1, 'all-spec coverage should derive multiple parties');
await page.getByLabel('Class/spec selection').selectOption('specific');
await page.getByLabel('Search Balance Lab class specs').fill('Paladin Holy');
await page.getByRole('checkbox', { name: /Paladin · Holy/ }).check();
await page.getByRole('checkbox', { name: /Paladin · Holy.*Holy Playerbots PvE/ }).check();
assert.equal(await page.getByLabel('Coverage parties').inputValue(), '1');
await page.getByLabel('Class/spec selection').selectOption('random');
await page.getByLabel('Parties per source').fill('10');
await page.getByText(/eligible pools provide 1 unique party configuration/i).waitFor();
await page.getByLabel('Runs per party/boss').fill('1');
await page.getByLabel('Fight limit (seconds)').fill('5');
await page.getByLabel('Bosses').selectOption('specific');
@@ -86,7 +89,7 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
await page.getByRole('button', { name: 'Run on server' }).click();
await page.locator('.balance-lab-job-list em.is-complete').waitFor({ timeout: 240_000 });
await page.getByRole('button', { name: 'Open full report' }).waitFor();
assert.equal(await page.locator('.balance-lab-artifacts > button').count(), 12);
assert((await page.locator('.balance-lab-artifacts > button').count()) >= 12);
await capture('04-server-job-complete');
await page.getByRole('button', { name: 'View graphs and filterable data' }).click();
@@ -100,7 +103,7 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
assert.match(await page.locator('.balance-results-table-panel tbody').innerText(), /Eck the Ferocious/);
await page.getByRole('button', { name: 'Class / spec', exact: true }).click();
const allClassRows = await page.locator('.balance-results-table-panel tbody tr').count();
assert(allClassRows >= 5, 'the complete five-member party should be present in the class/spec table');
assert.equal(allClassRows, 4, 'the four NPC teammates should be present in the NPC-only class/spec table');
await page.getByLabel('Sort class specs').selectOption('totalDamageTaken');
assert.equal(await page.getByLabel('Sort class specs').inputValue(), 'totalDamageTaken');
await page.getByLabel('Class role').selectOption('damage');
@@ -148,6 +151,17 @@ const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-balance-ui
await page.getByText('Every class/spec ability').scrollIntoViewIfNeeded();
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
await capture('07-mobile-abilities');
await page.setViewportSize({ width: 1440, height: 1000 });
await page.getByLabel('Level coverage').selectOption('brackets');
await page.getByLabel('Bracket spacing').selectOption('10');
assert.equal(await page.locator('.balance-lab-estimate span').filter({ hasText: 'Level jobs' }).locator('b').innerText(), '9');
await page.getByRole('button', { name: 'Queue 9 level jobs' }).click();
await page.waitForFunction(() => (
document.querySelectorAll('.balance-lab-job-list em.is-complete').length >= 10
), null, { timeout: 240_000 });
assert.equal(await page.locator('.balance-lab-job-list em.is-failed').count(), 0);
await capture('08-nine-bracket-jobs-complete');
assert.deepEqual(pageErrors, []);
console.log('Balance Lab GM playtest passed');
} finally {
+14
View File
@@ -6,6 +6,7 @@ from scripts.balance_lab import (
automatic_parties,
coverage_parties,
random_party,
random_party_capacity,
readiness_eligible_builds,
resolve_build,
runtime_session_command,
@@ -89,6 +90,19 @@ class BalanceLabControllerTests(unittest.TestCase):
"tank", "healer", "damage", "damage", "damage",
])
def test_automatic_parties_cap_to_the_unique_ready_pool(self) -> None:
builds = [
{"id": "ready-tank", "source": "wow", "role": "tank", "defaultTalentBuild": True},
{"id": "ready-healer", "source": "wow", "role": "healer", "defaultTalentBuild": True},
*({"id": f"ready-damage-{index}", "source": "wow", "role": "damage", "defaultTalentBuild": True}
for index in range(3)),
{"id": "testing-tank-alt", "source": "wow", "role": "tank", "defaultTalentBuild": False},
]
self.assertEqual(random_party_capacity(builds), 1)
configurations = automatic_parties(builds, ("wow",), 10, 29)
self.assertEqual(len(configurations), 1)
self.assertEqual(len({member["id"] for member in configurations[0]["party"]}), 5)
def test_coverage_parties_include_every_build_with_minimum_unique_parties(self) -> None:
builds = [
{"id": "tank-a", "source": "wow", "role": "tank"},
+2
View File
@@ -378,6 +378,8 @@ export function createBalanceLabJobService(options = {}) {
function appendLog(job, line) {
job.logTail.push(line.slice(0, 1_000));
if (job.logTail.length > 80) job.logTail.splice(0, job.logTail.length - 80);
const planned = /^Running\s+[\d,]+\s+simulations\s+\((\d+)\s+parties\s+x/i.exec(line);
if (planned) job.progress.partiesTotal = Number(planned[1]);
const party = /^Party\s+(\d+)\/(\d+):/i.exec(line);
if (party) {
job.progress.party = Number(party[1]);
+43
View File
@@ -144,6 +144,7 @@ test("queues a job, reports progress, and exposes only allow-listed artifacts",
setImmediate(() => {
mkdirSync(outputDir, { recursive: true });
writeFileSync(path.join(outputDir, "suite-report.json"), "{}\n");
child.stdout.write("Running 1 simulations (1 parties x 1 bosses x 1 runs per party/boss)...\n");
child.stdout.write("Party 1/1: wow-party-001\n");
child.stderr.write("[balance-lab] 1/1 Eck the Ferocious seed 1\n");
child.stdout.end();
@@ -176,6 +177,48 @@ test("queues a job, reports progress, and exposes only allow-listed artifacts",
}
});
test("corrects queued party progress when the eligible pool caps unique compositions", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-capacity-"));
try {
const spawnProcess = (_executable, args) => {
const child = new EventEmitter();
child.stdout = new PassThrough();
child.stderr = new PassThrough();
child.kill = () => child.emit("close", null);
const outputDir = args[args.indexOf("--output-dir") + 1];
setImmediate(() => {
mkdirSync(outputDir, { recursive: true });
writeFileSync(path.join(outputDir, "suite-report.json"), "{}\n");
child.stdout.write("Running 1 simulations (1 parties x 1 bosses x 1 runs per party/boss)...\n");
child.stdout.write("Party 1/1: wow-party-001\n");
child.stdout.end();
child.stderr.end();
child.emit("close", 0);
});
return child;
};
const service = createBalanceLabJobService({
dataDir: directory,
projectRoot: directory,
spawnProcess,
pythonExecutable: "python-test",
});
const started = service.start({
source: "wow", level: 40, partyConfigurations: 10, simulationsPerParty: 1,
durationSeconds: 5, partySeed: 1, bossCategory: "all", bossKeys: ["gundrak/entry-29932"],
}, { username: "Phenom" });
assert.equal(started.progress.partiesTotal, 10);
await new Promise((resolve) => setImmediate(resolve));
const completed = service.get(started.id);
assert.equal(completed.status, "complete");
assert.equal(completed.progress.partiesTotal, 1);
assert.equal(completed.progress.partiesCompleted, 1);
service.close();
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("snapshots the published companion readiness policy for each server job", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-balance-readiness-"));
try {
+9 -7
View File
@@ -157,7 +157,13 @@ export interface AbilityDamageResource {
export type AbilityEffect =
| ({ readonly kind: "damage"; readonly coefficient: number } & AbilityAmountScaling & AbilityDamageResource)
| { readonly kind: "finisher-damage"; readonly baseCoefficient: number; readonly perComboCoefficient: number }
| {
readonly kind: "finisher-damage";
readonly baseCoefficient: number;
readonly perComboCoefficient: number;
/** Optional stack-based payload used by poison-consuming finishers. */
readonly poison?: import("./roguePoison").RoguePoisonFinisherScaling;
}
| ({ readonly kind: "heal"; readonly coefficient: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
| ({ readonly kind: "shield"; readonly coefficient: number; readonly durationMs: number } & AbilityAmountScaling & AbilityHealingRecipients)
| ({ readonly kind: "dot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
@@ -500,9 +506,7 @@ const ROGUE = [
disposition: "buff",
durationMs: 3_600_000,
exclusiveGroup: "rogue-main-hand-poison",
utilityTags: ["weapon-enchant"],
procs: [{ id: "rogue-instant-poison-proc", triggers: "hit", chance: 0.2,
action: { kind: "damage", amount: 18, school: "nature", target: "event-other" } }],
utilityTags: ["weapon-enchant", "rogue-instant-poison"],
},
recipient: "caster",
sourceEffectType: 6,
@@ -525,9 +529,7 @@ const ROGUE = [
disposition: "buff",
durationMs: 3_600_000,
exclusiveGroup: "rogue-off-hand-poison",
utilityTags: ["weapon-enchant"],
procs: [{ id: "rogue-deadly-poison-proc", triggers: "hit", chance: 0.3,
action: { kind: "damage", amount: 12, school: "nature", target: "event-other" } }],
utilityTags: ["weapon-enchant", "rogue-deadly-poison-coating"],
},
recipient: "caster",
sourceEffectType: 6,
+3
View File
@@ -106,6 +106,9 @@ export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints {
effect.aura.absorbs?.length
|| effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
&& modifier.direction === "taken" && modifier.value < 0)
|| effect.aura.modifiers?.some((modifier) => modifier.kind === "stat"
&& ["block", "dodge", "parry"].includes(modifier.stat)
&& modifier.value > 0)
)) categories.add("mitigation");
if (effect.kind === "apply-aura" && effect.aura.modifiers?.some((modifier) => (
modifier.kind === "stat" && modifier.stat === "threat"
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import {
BOSS_BALANCE_OVERRIDES,
resolveBossBalanceOverride,
type BossBalanceOverride,
} from "./bossBalanceOverrides";
const overrides: readonly BossBalanceOverride[] = [
{ id: "all-levels", dungeonId: "test-dungeon", entityId: "test-boss",
healthScale: 0.9, damageScale: 0.8, reason: "test" },
{ id: "level-80", dungeonId: "test-dungeon", entityId: "test-boss",
minimumLevel: 80, maximumLevel: 80, damageScale: 0.5, reason: "test" },
];
describe("boss-specific balance overrides", () => {
it("composes broad and exact-level corrections for one boss", () => {
expect(resolveBossBalanceOverride("test-dungeon", "test-boss", 80, overrides)).toEqual({
healthScale: 0.9,
damageScale: 0.4,
matchedOverrideIds: ["all-levels", "level-80"],
});
expect(resolveBossBalanceOverride("test-dungeon", "test-boss", 70, overrides)).toEqual({
healthScale: 0.9,
damageScale: 0.8,
matchedOverrideIds: ["all-levels"],
});
});
it("matches the source dungeon while running in an isolated boss-testing room", () => {
expect(resolveBossBalanceOverride(
"boss-testing:test-dungeon:test-boss", "test-boss", 80, overrides,
).matchedOverrideIds).toEqual(["all-levels", "level-80"]);
});
it("leaves unrelated bosses and levels unchanged", () => {
expect(resolveBossBalanceOverride("another-dungeon", "test-boss", 80, overrides)).toEqual({
healthScale: 1,
damageScale: 1,
matchedOverrideIds: [],
});
});
it("ships the Drakos correction only at its audited level", () => {
expect(resolveBossBalanceOverride(
"boss-testing:the-oculus:entry-27654", "entry-27654", 80, BOSS_BALANCE_OVERRIDES,
)).toMatchObject({ damageScale: 0.45, matchedOverrideIds: ["level-80-drakos-pressure"] });
expect(resolveBossBalanceOverride(
"the-oculus", "entry-27654", 79, BOSS_BALANCE_OVERRIDES,
)).toMatchObject({ healthScale: 1, damageScale: 1, matchedOverrideIds: [] });
});
});
+80
View File
@@ -0,0 +1,80 @@
export interface BossBalanceOverride {
readonly id: string;
readonly dungeonId: string;
readonly entityId: string;
readonly minimumLevel?: number;
readonly maximumLevel?: number;
readonly healthScale?: number;
readonly damageScale?: number;
readonly reason: string;
}
export interface ResolvedBossBalanceOverride {
readonly healthScale: number;
readonly damageScale: number;
readonly matchedOverrideIds: readonly string[];
}
/**
* Evidence-based exceptions to the shared boss curve. Entries may cover one
* level, a bracket, or every level when both bounds are omitted. Multipliers
* compose so a broad boss correction can coexist with a narrower bracket fix.
*/
export const BOSS_BALANCE_OVERRIDES: readonly BossBalanceOverride[] = Object.freeze([
{ id: "level-80-amanitar-pressure", dungeonId: "ahnkahet-old-kingdom", entityId: "entry-30258",
minimumLevel: 80, maximumLevel: 80, damageScale: 0.7,
reason: "Ready-five audit: 100% wipes and 3.14x median incoming pressure." },
{ id: "level-80-warp-splinter-pressure", dungeonId: "botanica", entityId: "entry-17977",
minimumLevel: 80, maximumLevel: 80, damageScale: 0.56,
reason: "Ready-five audit: 100% wipes and 3.90x median incoming pressure." },
{ id: "level-80-maiden-of-grief-pressure", dungeonId: "halls-of-stone", entityId: "entry-27975",
minimumLevel: 80, maximumLevel: 80, damageScale: 0.56,
reason: "Ready-five audit: 100% wipes and 3.93x median incoming pressure." },
{ id: "level-80-ick-pressure", dungeonId: "pit-of-saron", entityId: "entry-36476",
minimumLevel: 80, maximumLevel: 80, damageScale: 0.54,
reason: "Ready-five audit: 100% wipes and 4.11x median incoming pressure." },
{ id: "level-80-murmur-pressure", dungeonId: "shadow-labyrinth", entityId: "entry-18708",
minimumLevel: 80, maximumLevel: 80, damageScale: 0.68,
reason: "Ready-five audit: 100% wipes and 3.22x median incoming pressure." },
{ id: "level-80-keristrasza-pressure", dungeonId: "the-nexus", entityId: "entry-26723",
minimumLevel: 80, maximumLevel: 80, damageScale: 0.7,
reason: "Ready-five audit: 100% wipes and 3.12x median incoming pressure." },
{ id: "level-80-drakos-pressure", dungeonId: "the-oculus", entityId: "entry-27654",
minimumLevel: 80, maximumLevel: 80, damageScale: 0.45,
reason: "Ready-five audit: 100% wipes and 4.89x median incoming pressure." },
{ id: "level-80-black-knight-phase-3-pressure", dungeonId: "trial-of-the-champion",
entityId: "wrath-event-black-knight-phase-3", minimumLevel: 80, maximumLevel: 80, damageScale: 0.5,
reason: "Ready-five audit: 100% wipes and 4.33x median incoming pressure." },
]);
function sourceDungeonId(dungeonId: string, entityId: string): string {
const prefix = "boss-testing:";
const suffix = `:${entityId}`;
return dungeonId.startsWith(prefix) && dungeonId.endsWith(suffix)
? dungeonId.slice(prefix.length, -suffix.length)
: dungeonId;
}
function safeScale(value: number | undefined): number {
return Number.isFinite(value) ? Math.max(0.05, value!) : 1;
}
export function resolveBossBalanceOverride(
dungeonId: string,
entityId: string,
level: number,
overrides: readonly BossBalanceOverride[] = BOSS_BALANCE_OVERRIDES,
): ResolvedBossBalanceOverride {
const sourceId = sourceDungeonId(dungeonId, entityId);
const matching = overrides.filter((entry) => (
entry.dungeonId === sourceId
&& entry.entityId === entityId
&& level >= (entry.minimumLevel ?? Number.NEGATIVE_INFINITY)
&& level <= (entry.maximumLevel ?? Number.POSITIVE_INFINITY)
));
return Object.freeze({
healthScale: matching.reduce((scale, entry) => scale * safeScale(entry.healthScale), 1),
damageScale: matching.reduce((scale, entry) => scale * safeScale(entry.damageScale), 1),
matchedOverrideIds: Object.freeze(matching.map((entry) => entry.id)),
});
}
+91 -3
View File
@@ -211,7 +211,7 @@ describe("shared combat rotations", () => {
{ classId: "priest" as const, specialization: "Discipline", id: "priest:discipline", actions: ["Penance", "Power Word: Shield", "Flash Heal", "Greater Heal", "Renew", "Dispel Magic", "Cure Disease", "Resurrection", "Pain Suppression", "Inner Focus", "Power Infusion", "Shadowfiend"] },
{ classId: "hunter" as const, specialization: "Marksmanship", id: "hunter:marksmanship", actions: ["Call Pet", "Aspect of the Dragonhawk", "Serpent Sting", "Chimera Shot", "Aimed Shot", "Arcane Shot", "Steady Shot", "Kill Shot", "Rapid Fire", "Readiness", "Misdirection", "Feign Death", "Silencing Shot", "Disengage"] },
{ classId: "mage" as const, specialization: "Fire", id: "mage:fire", actions: ["Molten Armor", "Living Bomb", "Fireball", "Scorch", "Fire Blast", "Combustion", "Mirror Image", "Counterspell", "Fire Ward", "Frost Ward", "Ice Block", "Blink"] },
{ classId: "rogue" as const, specialization: "Assassination", id: "rogue:assassination", actions: ["Instant Poison", "Deadly Poison", "Mutilate", "Rupture", "Envenom", "Cold Blood", "Kick", "Tricks of the Trade", "Evasion", "Cloak of Shadows", "Vanish"] },
{ classId: "rogue" as const, specialization: "Assassination", id: "rogue:assassination", actions: ["Instant Poison", "Deadly Poison", "Mutilate", "Shiv", "Rupture", "Envenom", "Cold Blood", "Kick", "Tricks of the Trade", "Evasion", "Cloak of Shadows", "Vanish"] },
];
for (const contract of contracts) {
const profile = rotationProfileFor(contract.classId, contract.id)!;
@@ -263,7 +263,7 @@ describe("shared combat rotations", () => {
const hunter = context({ classId: "hunter", specializationId: "hunter:marksmanship", primaryResource: "mana", resources: { mana: 10_000 }, maximumResources: { mana: 10_000 }, friendlyTargets: [{ id: "tank", health: 1_000, maxHealth: 1_000, role: "tank" }] });
expect(evaluateCombatRotation({ ...hunter, abilities: [misdirection], hostileTarget: { id: "boss", health: 100_000, maxHealth: 100_000, boss: true } }))
.toMatchObject({ ability: { name: "Misdirection" }, targetId: "tank" });
expect(evaluateCombatRotation({ ...hunter, abilities: [disengage], moving: true })?.ability.name).toBe("Disengage");
expect(evaluateCombatRotation({ ...hunter, abilities: [disengage], needsEscape: true })?.ability.name).toBe("Disengage");
});
it("keeps specialization talent actions isolated", () => {
@@ -310,11 +310,83 @@ describe("shared combat rotations", () => {
expect(defensive, `${row.specializationId}:${row.defensive}`).toBeDefined();
const defensiveContext = row.defensive === "Feign Death"
? { ...shared, abilities: [defensive], hasDangerousThreat: true }
: { ...shared, abilities: [defensive], health: 250 };
: row.defensive === "Evasion"
? { ...shared, abilities: [defensive], health: 400, enemyUsesMeleeAttacks: true }
: { ...shared, abilities: [defensive], health: 250 };
expect(evaluateCombatRotation(defensiveContext)?.ability.name).toBe(row.defensive);
}
});
it("prepares emergency healing with Inner Focus before direct or area healing", () => {
const talents = runtimePartyTalentRanksForLevel("priest", "Discipline", 80);
const innerFocus = learnedAbilityForBuild(ability("priest", "Inner Focus"), 80, talents)!;
const priest = context({
classId: "priest",
specializationId: "priest:discipline",
role: "healer",
primaryResource: "mana",
resources: { mana: 10_000 },
maximumResources: { mana: 10_000 },
abilities: [innerFocus],
});
expect(evaluateCombatRotation({
...priest,
friendlyTargets: [{ id: "tank", health: 400, maxHealth: 1_000, role: "tank" }],
})).toMatchObject({ ability: { name: "Inner Focus" }, ruleId: "prepare-emergency-heal" });
expect(evaluateCombatRotation({
...priest,
friendlyTargets: [
{ id: "tank", health: 600, maxHealth: 1_000, role: "tank" },
{ id: "rogue", health: 600, maxHealth: 1_000, role: "damage" },
],
})).toMatchObject({ ability: { name: "Inner Focus" }, ruleId: "prepare-area-heal" });
expect(evaluateCombatRotation({
...priest,
friendlyTargets: [{ id: "tank", health: 800, maxHealth: 1_000, role: "tank" }],
})).toBeNull();
});
it("uses Rogue defenses for boss threat, melee danger, and lethal threat resets", () => {
const talents = runtimePartyTalentRanksForLevel("rogue", "Assassination", 80);
const evasion = learnedAbilityForBuild(ability("rogue", "Evasion"), 80, talents)!;
const vanish = learnedAbilityForBuild(ability("rogue", "Vanish"), 80, talents)!;
const rogue = context({
classId: "rogue",
specializationId: "rogue:assassination",
primaryResource: "energy",
resources: { energy: 100 },
maximumResources: { energy: 100 },
hostileTarget: { id: "boss", health: 100_000, maxHealth: 100_000, boss: true },
});
expect(evaluateCombatRotation({ ...rogue, abilities: [evasion], hasDangerousThreat: true })?.ability.name)
.toBe("Evasion");
expect(evaluateCombatRotation({ ...rogue, abilities: [evasion], health: 400,
enemyUsesMeleeAttacks: true })?.ability.name).toBe("Evasion");
expect(evaluateCombatRotation({ ...rogue, abilities: [evasion], incomingMeleeDanger: true }))
.toMatchObject({ ability: { name: "Evasion" }, ruleId: "preemptive-melee-evasion" });
expect(evaluateCombatRotation({ ...rogue, abilities: [vanish], hasDangerousThreat: true })?.ability.name)
.toBe("Vanish");
expect(evaluateCombatRotation({ ...rogue, abilities: [vanish], health: 200 }))
.toMatchObject({ ability: { name: "Vanish" }, ruleId: "emergency-vanish" });
});
it("uses Blink and Disengage to create distance, while Fire Blast covers movement", () => {
const mage = context({ classId: "mage", specializationId: "mage:fire", primaryResource: "mana",
resources: { mana: 10_000 }, maximumResources: { mana: 10_000 } });
expect(evaluateCombatRotation({ ...mage, abilities: [ability("mage", "Blink")], needsEscape: true })?.ability.name)
.toBe("Blink");
expect(evaluateCombatRotation({ ...mage, abilities: [ability("mage", "Fire Blast")], moving: true })?.ability.name)
.toBe("Fire Blast");
const talents = runtimePartyTalentRanksForLevel("hunter", "Marksmanship", 80);
const disengage = learnedAbilityForBuild(ability("hunter", "Disengage"), 80, talents)!;
const hunter = context({ classId: "hunter", specializationId: "hunter:marksmanship", primaryResource: "mana",
resources: { mana: 10_000 }, maximumResources: { mana: 10_000 }, abilities: [disengage], needsEscape: true });
expect(evaluateCombatRotation(hunter)?.ability.name).toBe("Disengage");
});
it("uses Marksmanship Readiness only on a durable target", () => {
const talents = runtimePartyTalentRanksForLevel("hunter", "Marksmanship", 80);
const readiness = learnedAbilityForBuild(ability("hunter", "Readiness"), 80, talents)!;
@@ -344,6 +416,22 @@ describe("shared combat rotations", () => {
expect(finishing?.ability.id).toBe(finisher.id);
});
it("uses Envenom as the Playerbots Assassination finisher ahead of Rupture", () => {
const talents = runtimePartyTalentRanksForLevel("rogue", "Assassination", 80);
const rupture = learnedAbilityForBuild(ability("rogue", "Rupture"), 80, talents)!;
const envenom = learnedAbilityForBuild(ability("rogue", "Envenom"), 80, talents)!;
const assassination = context({
specializationId: "rogue:assassination",
comboPoints: 5,
comboTargetId: "target",
abilities: [envenom, rupture],
});
expect(evaluateCombatRotation(assassination)?.ability.name).toBe("Envenom");
expect(evaluateCombatRotation({ ...assassination, maintainedAbilityIds: new Set([rupture.id]) })?.ability.name)
.toBe("Envenom");
});
it("reacts before throughput and conserves long offensive cooldowns for durable targets", () => {
const kick = ability("rogue", "Kick");
const strike = ability("rogue", "Sinister Strike");
+56 -2
View File
@@ -12,7 +12,7 @@ import {
type CompanionCooldownPolicy,
} from "./companionCooldownPolicy";
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v5-ready-five";
export const COMBAT_ROTATION_VERSION = "playerbots-inspired-v6-reactive-ready-five";
export interface RotationProfile {
readonly id: string;
@@ -64,6 +64,12 @@ export interface RotationContext {
readonly hostileCount?: number;
readonly targetIsLoose?: boolean;
readonly hasDangerousThreat?: boolean;
/** The current hostile can make ordinary melee attacks against this actor. */
readonly enemyUsesMeleeAttacks?: boolean;
/** A telegraphed high-impact melee attack is about to hit this actor. */
readonly incomingMeleeDanger?: boolean;
/** The actor should create distance immediately rather than keep casting. */
readonly needsEscape?: boolean;
readonly requiredControl?: boolean;
readonly moving?: boolean;
readonly maintainedAbilityIds?: ReadonlySet<string>;
@@ -107,7 +113,7 @@ export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.free
profile("hunter:beast-mastery", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Command", "Bestial Wrath", "Serpent Sting", "Arcane Shot", "Multi-Shot", "Steady Shot"]),
profile("hunter:marksmanship", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Misdirection", "Rapid Fire", "Readiness", "Kill Shot", "Chimera Shot", "Aimed Shot", "Serpent Sting", "Arcane Shot", "Steady Shot", "Silencing Shot", "Disengage", "Feign Death"]),
profile("hunter:survival", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Kill Shot", "Explosive Shot", "Black Arrow", "Serpent Sting", "Aimed Shot", "Arcane Shot", "Steady Shot"]),
profile("rogue:assassination", "rogue", ["Deadly Poison", "Instant Poison", "Tricks of the Trade", "Cold Blood", "Envenom", "Rupture", "Mutilate", "Eviscerate", "Sinister Strike", "Kick", "Evasion", "Cloak of Shadows", "Vanish"]),
profile("rogue:assassination", "rogue", ["Deadly Poison", "Instant Poison", "Tricks of the Trade", "Cold Blood", "Envenom", "Shiv", "Rupture", "Mutilate", "Eviscerate", "Sinister Strike", "Kick", "Evasion", "Cloak of Shadows", "Vanish"]),
profile("rogue:combat", "rogue", ["Killing Spree", "Blade Flurry", "Eviscerate", "Sinister Strike"]),
profile("rogue:subtlety", "rogue", ["Shadow Dance", "Rupture", "Eviscerate", "Hemorrhage", "Backstab"]),
profile("priest:discipline", "priest", ["Inner Fire", "Pain Suppression", "Inner Focus", "Power Infusion", "Shadowfiend", "Divine Hymn", "Penance", "Power Word: Shield", "Flash Heal", "Greater Heal", "Renew", "Dispel Magic", "Cure Disease", "Resurrection"]),
@@ -148,6 +154,7 @@ const DEFENSIVE_COOLDOWN_NAMES = new Set([
]);
const THREAT_REDIRECT_NAMES = new Set(["Misdirection", "Tricks of the Trade"]);
const ESCAPE_MOVEMENT_NAMES = new Set(["Blink", "Disengage"]);
const TARGET_HEALTH_GATES = new Map<string, number>([
["Drain Soul", 0.25],
@@ -410,6 +417,53 @@ function scoreAbility(
if (context.role !== "tank" || !context.targetIsLoose) return null;
return { ability, score: 1_017_000, ruleId: "reactive-taunt", reason: "loose hostile target", targetId: context.hostileTarget?.id ?? null };
}
if (ability.name === "Inner Focus") {
const lowAllies = (context.friendlyTargets ?? []).filter((target) => (
!target.fallen && target.health > 0 && target.health / Math.max(1, target.maxHealth) <= 0.6
));
const shouldPrepareHeal = context.role === "healer"
&& (lowestHealth <= 0.4 || lowAllies.length >= 2);
return shouldPrepareHeal ? {
ability,
score: 1_018_500,
ruleId: lowAllies.length >= 2 ? "prepare-area-heal" : "prepare-emergency-heal",
reason: lowAllies.length >= 2 ? "multiple allies need empowered healing" : "an ally needs empowered healing",
targetId: context.selfId ?? null,
} : null;
}
if (ability.name === "Vanish") {
const bossHasAggro = Boolean(context.hasDangerousThreat && context.hostileTarget?.boss);
const nearDeath = selfHealth <= 0.2;
return bossHasAggro || nearDeath ? {
ability,
score: nearDeath ? 1_017_500 : 1_016_750,
ruleId: nearDeath ? "emergency-vanish" : "boss-threat-vanish",
reason: nearDeath ? "reset threat and escape lethal danger" : "reset boss threat and escape",
targetId: context.selfId ?? null,
} : null;
}
if (ability.name === "Evasion") {
const bossHasAggro = Boolean(context.hasDangerousThreat && context.hostileTarget?.boss);
const lowAgainstMelee = selfHealth <= 0.4 && Boolean(context.enemyUsesMeleeAttacks);
return bossHasAggro || lowAgainstMelee || context.incomingMeleeDanger ? {
ability,
score: context.incomingMeleeDanger ? 1_017_000 : 1_016_500,
ruleId: context.incomingMeleeDanger ? "preemptive-melee-evasion" : "reactive-melee-evasion",
reason: context.incomingMeleeDanger
? "avoid an imminent strong melee attack"
: bossHasAggro ? "avoid boss attacks while holding threat" : "low health against a melee attacker",
targetId: context.selfId ?? null,
} : null;
}
if (ESCAPE_MOVEMENT_NAMES.has(ability.name)) {
return context.needsEscape ? {
ability,
score: 1_016_250,
ruleId: "reactive-danger-escape",
reason: "create distance from immediate danger",
targetId: context.selfId ?? null,
} : null;
}
if (categories.has("threat") && !damaging && !maintenanceBuff) {
if (THREAT_REDIRECT_NAMES.has(ability.name) && context.role !== "tank" && durableTarget) {
return {
+155
View File
@@ -25,6 +25,10 @@ import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import {
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
} from "./roguePoison";
function allocateTalentRanks(name: string, ranks: number): void {
const talent = TALENT_NODES.find((node) => node.name === name);
@@ -667,6 +671,38 @@ describe("combat store", () => {
});
});
it("drops or redirects companion threat and immediately retargets the enemy", () => {
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
const damage = usePartyStore.getState().members.find((member) => member.role === "damage")!;
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
registerPartyRuntimePosition(damage.id, [0, 0, 0]);
useCombatStore.getState().registerMob("threat-effect-boss", {
name: "Threat effect boss", boss: true, maxHealth: 1_000, xpReward: 0,
});
setMobPosition("threat-effect-boss", [2, 0, 0]);
useCombatStore.getState().damageMob("threat-effect-boss", 10, 1_000,
{ actorId: tank.id, role: "tank" });
useCombatStore.getState().damageMob("threat-effect-boss", 100, 1_100,
{ actorId: damage.id, role: "damage" });
useCombatStore.getState().applyActorThreatEffect(damage.id,
{ kind: "threat", mode: "percent", amount: -100 }, null, 1_200);
expect(useCombatStore.getState().mobs["threat-effect-boss"]).toMatchObject({
targetActorId: tank.id,
threatByActor: { [damage.id]: 0 },
});
useCombatStore.getState().damageMob("threat-effect-boss", 80, 1_300,
{ actorId: damage.id, role: "damage" });
const tankThreat = useCombatStore.getState().mobs["threat-effect-boss"].threatByActor[tank.id];
useCombatStore.getState().applyActorThreatEffect(damage.id,
{ kind: "threat", mode: "redirect", amount: 100 }, tank.id, 1_400);
expect(useCombatStore.getState().mobs["threat-effect-boss"].threatByActor).toMatchObject({
[damage.id]: 0,
[tank.id]: tankThreat + 80,
});
});
it("taunts bosses at max plus one, forces immediately, and resumes normal threat after expiry", () => {
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
registerPartyRuntimePosition(tank.id, [0, 0, 0]);
@@ -1518,6 +1554,125 @@ describe("combat store", () => {
expect(useCombatStore.getState()).toMatchObject(beforeFelVitality);
});
it("builds, refreshes, and consumes source-specific Deadly Poison doses", () => {
useCombatStore.getState().initializeCharacter({
classId: "rogue",
level: 80,
specializationId: "rogue:assassination",
talentRanks: runtimePartyTalentRanksForLevel("rogue", "Assassination", 80),
});
useCombatStore.getState().synchronizeActors();
registerTarget("poison-target", [2, 0, 0], 1_000_000, 0);
for (const abilityId of ["rogue-instant-poison", "rogue-deadly-poison"]) {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === abilityId)!;
const effect = ability.effects.find((candidate) => candidate.kind === "apply-aura")!;
expect(useCombatStore.getState().applyActorAura(
PLAYER_AURA_ENTITY_ID,
PLAYER_AURA_ENTITY_ID,
ability,
effect,
1_000,
)).toBe(true);
}
expect(useCombatStore.getState().triggerRogueWeaponPoisons(
PLAYER_AURA_ENTITY_ID,
"poison-target",
"wow335-rogue-shiv",
1,
1_001,
)).toMatchObject({ deadlyApplications: 1, deadlyStacks: 1 });
for (let hit = 0; hit < 200; hit += 1) {
useCombatStore.getState().triggerRogueWeaponPoisons(
PLAYER_AURA_ENTITY_ID,
"poison-target",
`test-hit-${hit}`,
1,
1_002 + hit,
);
}
const stacked = useCombatStore.getState();
expect(stacked.auras.find((aura) => (
aura.targetId === "poison-target"
&& aura.definition.utilityTags?.includes(ROGUE_DEADLY_POISON_DOSE_TAG)
))?.stacks).toBe(5);
expect(stacked.effects.find((effect) => (
effect.targetId === "poison-target" && effect.sourceAbilityId === "rogue-deadly-poison"
))).toMatchObject({ kind: "dot", remainingTicks: 4 });
expect(stacked.combatEvents.some((event) => (
event.abilityId === "rogue-instant-poison" && event.effectiveAmount > 0
))).toBe(true);
expect(useCombatStore.getState().consumeRogueDeadlyPoison(
PLAYER_AURA_ENTITY_ID,
"poison-target",
4,
2_000,
)).toBe(4);
expect(useCombatStore.getState().auras.find((aura) => (
aura.targetId === "poison-target"
&& aura.definition.utilityTags?.includes(ROGUE_DEADLY_POISON_DOSE_TAG)
))?.stacks).toBe(1);
expect(useCombatStore.getState().consumeRogueDeadlyPoison(
PLAYER_AURA_ENTITY_ID,
"poison-target",
5,
2_001,
)).toBe(1);
expect(useCombatStore.getState().effects.some((effect) => (
effect.targetId === "poison-target" && effect.sourceAbilityId === "rogue-deadly-poison"
))).toBe(false);
});
it("casts Envenom with dose-scaled Nature damage and a combo-scaled poison buff", () => {
useCombatStore.getState().initializeCharacter({
classId: "rogue",
level: 80,
specializationId: "rogue:assassination",
talentRanks: runtimePartyTalentRanksForLevel("rogue", "Assassination", 80),
});
useCombatStore.getState().synchronizeActors();
registerTarget("envenom-target", [2, 0, 0], 1_000_000, 0);
for (const abilityId of ["rogue-instant-poison", "rogue-deadly-poison"]) {
const ability = useCombatStore.getState().abilities.find((candidate) => candidate.id === abilityId)!;
const effect = ability.effects.find((candidate) => candidate.kind === "apply-aura")!;
useCombatStore.getState().applyActorAura(
PLAYER_AURA_ENTITY_ID,
PLAYER_AURA_ENTITY_ID,
ability,
effect,
1_000,
);
}
for (let hit = 0; hit < 200; hit += 1) {
useCombatStore.getState().triggerRogueWeaponPoisons(
PLAYER_AURA_ENTITY_ID,
"envenom-target",
`envenom-setup-${hit}`,
1,
1_001 + hit,
);
}
useCombatStore.setState({ comboPoints: 5, comboTargetId: "envenom-target" });
const envenom = useCombatStore.getState().abilities.find((ability) => ability.name === "Envenom")!;
const result = useCombatStore.getState().castAbility(envenom.id, [0, 0, 0], 3_000);
expect(result).toMatchObject({ ok: true, abilityId: envenom.id });
expect(result.damage).toBeGreaterThan(0);
expect(useCombatStore.getState().auras.find((aura) => (
aura.targetId === "envenom-target"
&& aura.definition.utilityTags?.includes(ROGUE_DEADLY_POISON_DOSE_TAG)
))?.stacks).toBe(5);
expect(useCombatStore.getState().auras.find((aura) => (
aura.targetId === PLAYER_AURA_ENTITY_ID
&& aura.definition.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
))?.expiresAt).toBe(9_000);
expect(useCombatStore.getState().combatEvents.some((event) => (
event.abilityId === envenom.id && event.school === "nature" && event.effectiveAmount > 0
))).toBe(true);
});
it("applies Manastorm boss multipliers and a single bonus-loot chance at registration", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 10 });
useCombatStore.getState().registerMob("storm-boss", {
+483 -21
View File
@@ -64,6 +64,7 @@ import {
advanceCombatResourcePool,
spendDeathKnightRunes,
} from "./combatResources";
import { cooldownResetCandidates } from "./cooldownReset";
import { resolveHealing, type HealingResolution } from "./combatHealing";
import {
awardExperience,
@@ -172,6 +173,19 @@ import {
type WotlkAttackKind,
} from "./wotlkStats";
import { healingRecipientIds } from "./healingAbilityRules";
import {
ROGUE_DEADLY_POISON_AURA,
ROGUE_DEADLY_POISON_COATING_TAG,
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
ROGUE_INSTANT_POISON_TAG,
hasActiveAuraTag,
matchingAuraStacks,
rogueDeadlyPoisonTickDamage,
rogueEnvenomPreservesDeadlyPoison,
rogueInstantPoisonDamage,
roguePoisonApplicationChances,
} from "./roguePoison";
import {
combatAttackerFromCharacterStats,
combatDefenderFromCharacterStats,
@@ -583,6 +597,13 @@ export interface DamageResult {
readonly combatResult?: CombatResult;
}
export interface RoguePoisonProcResult {
readonly instantProcs: number;
readonly deadlyApplications: number;
readonly deadlyStacks: number;
readonly damage: number;
}
interface PlayerDamageRollContext {
readonly attackKind: WotlkAttackKind;
readonly seed: string;
@@ -731,7 +752,26 @@ export interface CombatState {
school?: DamageSchool,
rollContext?: PlayerDamageRollContext,
) => DamageResult;
triggerRogueWeaponPoisons: (
sourceActorId: AggroActorId,
targetId: string,
sourceAbilityId: string,
hitCount?: number,
now?: number,
) => RoguePoisonProcResult;
consumeRogueDeadlyPoison: (
sourceActorId: AggroActorId,
targetId: string,
maximumDoses: number,
now?: number,
) => number;
addHealingThreat: (source: ThreatSource, effectiveHealing: number) => void;
applyActorThreatEffect: (
actorId: AggroActorId,
effect: Extract<AbilityEffect, { readonly kind: "threat" }>,
redirectTargetActorId?: AggroActorId | null,
now?: number,
) => void;
tauntMob: (id: string, source: ThreatSource, durationMs: number, now?: number) => boolean;
interruptMob: (id: string, sourceAbilityId: string, lockoutMs: number, now?: number) => boolean;
controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number) => boolean;
@@ -1219,6 +1259,7 @@ function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkA
}
export function abilityDamageSchool(classId: ClassId, ability: AbilityDefinition): DamageSchool {
if (["Envenom", "Instant Poison", "Deadly Poison"].includes(ability.name)) return "nature";
if (abilityAttackKind(classId, ability) !== "spell") return "physical";
const searchable = `${ability.id} ${ability.name} ${ability.description}`.toLowerCase();
if (/\b(?:fire|flame|immolate|incinerate|searing|lava)\b/.test(searchable)) return "fire";
@@ -2797,6 +2838,23 @@ function resolveAndDamageMobInWork(
attackKind,
}, now);
}
if (
combatResult.landed
&& result.amount > 0
&& attackKind === "melee"
&& !periodic
&& !["Envenom", "Instant Poison", "Deadly Poison"].includes(ability?.name ?? "")
) {
const hitCount = ability?.name === "Mutilate" || (rules.isAutoAttack && rules.isDualWielding) ? 2 : 1;
applyRogueWeaponPoisonsInWork(
work,
source.actorId,
id,
ability?.id ?? source.abilityId ?? "melee-hit",
hitCount,
now,
);
}
if (result.killed) {
evaluateProcsInWork(work, {
trigger: "kill",
@@ -3119,9 +3177,7 @@ function executeTriggeredSpellInWork(
};
}
} else if (effect.kind === "cooldown-reset") {
const candidates = effect.abilityName
? work.abilities.filter((candidate) => candidate.name.toLowerCase() === effect.abilityName!.toLowerCase())
: work.abilities;
const candidates = cooldownResetCandidates(work.abilities, ability.id, effect.abilityName);
for (const candidate of candidates) {
if (effect.mode === "reset") work.cooldowns[candidate.id] = 0;
else {
@@ -3223,6 +3279,206 @@ function timedEffect(
};
}
interface RoguePoisonSourceStats {
readonly classId: ClassId;
readonly level: number;
readonly attackPower: number;
readonly spellPenetration: number;
}
function roguePoisonSourceStats(
work: MutableCastWork,
sourceActorId: AggroActorId,
): RoguePoisonSourceStats | null {
if (sourceActorId === PLAYER_AGGRO_ID) {
if (baseClassFor(work.classId) !== "rogue") return null;
const stats = playerCharacterStats(
work.classId,
work.progression.level,
work.gearStats,
work.raceId,
work.secondaryClassId ?? null,
work.talentModifiers,
);
return {
classId: work.classId,
level: work.progression.level,
attackPower: stats.attackPower,
spellPenetration: work.gearStats.spellPenetration,
};
}
const member = usePartyStore.getState().members.find((candidate) => candidate.id === sourceActorId);
if (!member || baseClassFor(member.classId) !== "rogue") return null;
const stats = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
return {
classId: member.classId,
level: member.level,
attackPower: stats.attackPower,
spellPenetration: member.gearStats.spellPenetration,
};
}
function replaceDeadlyPoisonEffect(
work: MutableCastWork,
sourceActorId: AggroActorId,
targetId: string,
stacks: number,
now: number,
refreshDuration: boolean,
): void {
const ability = abilityById("rogue-deadly-poison");
const source = roguePoisonSourceStats(work, sourceActorId);
const existing = work.effects.find((effect) => (
effect.kind === "dot"
&& effect.sourceActorId === sourceActorId
&& effect.targetId === targetId
&& effect.sourceAbilityId === "rogue-deadly-poison"
));
work.effects = work.effects.filter((effect) => effect !== existing);
if (!ability || !source || stacks <= 0) return;
const amount = rogueDeadlyPoisonTickDamage(source.level, source.attackPower, stacks);
if (!refreshDuration && existing) {
work.effects.push({ ...existing, fixedAmount: amount });
return;
}
work.effects.push({
...timedEffect(
"dot",
ability.id,
sourceActorId,
targetId,
{ coefficient: 0 },
4,
3_000,
now,
ROGUE_DEADLY_POISON_DOSE_TAG,
"nature",
source.spellPenetration,
),
fixedAmount: amount,
});
}
function applyRogueWeaponPoisonsInWork(
work: MutableCastWork,
sourceActorId: AggroActorId,
targetId: string,
sourceAbilityId: string,
hitCount: number,
now: number,
): RoguePoisonProcResult {
const source = roguePoisonSourceStats(work, sourceActorId);
const target = work.mobs[targetId];
if (!source || !target || target.dead) {
return { instantProcs: 0, deadlyApplications: 0, deadlyStacks: 0, damage: 0 };
}
const instantCoating = hasActiveAuraTag(work.auras, ROGUE_INSTANT_POISON_TAG, sourceActorId, now);
const deadlyCoating = hasActiveAuraTag(work.auras, ROGUE_DEADLY_POISON_COATING_TAG, sourceActorId, now);
const envenomBuff = hasActiveAuraTag(work.auras, ROGUE_ENVENOM_BUFF_TAG, sourceActorId, now);
const isShiv = abilityById(sourceAbilityId)?.name === "Shiv";
const chances = roguePoisonApplicationChances(envenomBuff);
let instantProcs = 0;
let deadlyApplications = 0;
let damage = 0;
const applyInstant = (): void => {
if (!instantCoating || work.mobs[targetId]?.dead) return;
const result = damageMobInWork(
work.classId,
work,
targetId,
rogueInstantPoisonDamage(source.level, source.attackPower),
now,
threatSourceForActor(work, sourceActorId, "rogue-instant-poison", true),
"nature",
source.spellPenetration,
);
if (result.amount <= 0) return;
instantProcs += 1;
damage += result.amount;
};
for (let hitIndex = 0; hitIndex < Math.max(1, Math.floor(hitCount)); hitIndex += 1) {
if (work.mobs[targetId]?.dead) break;
if (!isShiv && instantCoating && stableCombatUnit(
`${sourceActorId}:${targetId}:${sourceAbilityId}:${now}:${hitIndex}:instant`,
) < chances.instant) {
applyInstant();
}
if (!deadlyCoating || (!isShiv && stableCombatUnit(
`${sourceActorId}:${targetId}:${sourceAbilityId}:${now}:${hitIndex}:deadly`,
) >= chances.deadly)) continue;
const previousStacks = matchingAuraStacks(
work.auras,
ROGUE_DEADLY_POISON_DOSE_TAG,
sourceActorId,
targetId,
now,
);
const application = applyCombatAura(work.auras, {
definition: ROGUE_DEADLY_POISON_AURA,
sourceId: sourceActorId,
targetId,
now,
});
work.auras = [...application.auras];
deadlyApplications += 1;
recordCombatEventInWork(work, {
occurredAt: now,
kind: "aura",
sourceActorId,
targetActorId: targetId,
abilityId: "rogue-deadly-poison",
school: "nature",
rawAmount: 0,
effectiveAmount: application.aura.stacks,
overheal: 0,
absorbed: 0,
critical: false,
});
replaceDeadlyPoisonEffect(work, sourceActorId, targetId, application.aura.stacks, now, true);
// At five doses, another Deadly Poison application triggers the poison on
// the other weapon instead of creating a sixth dose.
if (previousStacks >= 5) applyInstant();
}
return {
instantProcs,
deadlyApplications,
deadlyStacks: matchingAuraStacks(
work.auras,
ROGUE_DEADLY_POISON_DOSE_TAG,
sourceActorId,
targetId,
now,
),
damage,
};
}
function consumeRogueDeadlyPoisonInWork(
work: MutableCastWork,
sourceActorId: AggroActorId,
targetId: string,
maximumDoses: number,
now: number,
): number {
const index = work.auras.findIndex((aura) => (
aura.sourceId === sourceActorId
&& aura.targetId === targetId
&& (aura.expiresAt === null || aura.expiresAt > now)
&& aura.definition.utilityTags?.includes(ROGUE_DEADLY_POISON_DOSE_TAG)
));
if (index < 0) return 0;
const aura = work.auras[index];
const consumed = Math.min(aura.stacks, Math.max(0, Math.floor(maximumDoses)));
const remaining = aura.stacks - consumed;
if (remaining <= 0) work.auras.splice(index, 1);
else work.auras[index] = { ...aura, stacks: remaining };
replaceDeadlyPoisonEffect(work, sourceActorId, targetId, remaining, now, false);
return consumed;
}
function entityAbsorbAmount(auras: readonly ActiveAura[], entityId: string): number {
return aurasForEntity(auras, entityId).reduce((total, aura) => (
total + aura.absorbs.reduce((sum, absorb) => sum + Math.max(0, absorb.remaining), 0)
@@ -4800,20 +5056,57 @@ export const useCombatStore = create<CombatState>((set, get) => ({
grantDamageResourceInWork(work, effect, result.amount, castThreatSource.actorId, ability.id, now);
}
} else if (effect.kind === "finisher-damage") {
const coefficient = effect.baseCoefficient + effect.perComboCoefficient * work.comboPoints;
const amount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(
coefficient,
work.progression.level,
work.gearStats,
state.classId,
ability,
),
modifiers,
"damage-percent",
ability,
);
const comboPoints = work.comboPoints;
for (const id of affectedMobIds) {
const envenomDoses = effect.poison
? Math.min(
comboPoints,
effect.poison.maximumDoses,
matchingAuraStacks(
work.auras,
effect.poison.doseAuraTag,
castThreatSource.actorId,
id,
now,
),
)
: 0;
if (
effect.poison
&& envenomDoses > 0
&& !rogueEnvenomPreservesDeadlyPoison(
work.talentRanks,
`${castThreatSource.actorId}:${id}:${ability.id}:${now}:master-poisoner`,
)
) {
consumeRogueDeadlyPoisonInWork(
work,
castThreatSource.actorId,
id,
envenomDoses,
now,
);
}
const scaling = effect.poison
? {
coefficient: 0,
basePoints: effect.poison.basePointsPerDose * envenomDoses,
dieSides: 1,
attackPowerCoefficient: effect.poison.attackPowerCoefficientPerDose * envenomDoses,
}
: effect.baseCoefficient + effect.perComboCoefficient * comboPoints;
const amount = talentAdjustedAmount(
equipmentAdjustedCombatAmount(
scaling,
work.progression.level,
work.gearStats,
state.classId,
ability,
),
modifiers,
"damage-percent",
ability,
);
resolveAndDamageMobInWork(
state.classId,
work,
@@ -5267,12 +5560,18 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}, ability.id, now);
}
} else if (effect.kind === "apply-aura") {
const sourceDurationMs = holyAuraFriendly && ability.name === "Holy Aura" && effect.aura.durationMs !== null
const sourceDurationMs = ability.name === "Envenom"
&& effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
? (work.comboPoints + 1) * 1_000
: holyAuraFriendly && ability.name === "Holy Aura" && effect.aura.durationMs !== null
? Math.max(1_000, effect.aura.durationMs - 2_000)
: effect.aura.durationMs;
const durationMs = sourceDurationMs === null
? null
: talentAdjustedDuration(sourceDurationMs, ability, modifiers);
: ability.name === "Envenom"
&& effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
? sourceDurationMs
: talentAdjustedDuration(sourceDurationMs, ability, modifiers);
const auraDefinition = auraDefinitionForAbility(effect.aura, ability, durationMs);
const recipients = effect.scope === "party"
? [PLAYER_AURA_ENTITY_ID, ...usePartyStore.getState().members.filter((member) => member.health > 0).map((member) => member.id)]
@@ -5681,9 +5980,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
}
for (const adjustment of cooldownAdjustments) {
const candidates = adjustment.abilityName
? state.abilities.filter((candidate) => candidate.name.toLowerCase() === adjustment.abilityName!.toLowerCase())
: state.abilities;
const candidates = cooldownResetCandidates(state.abilities, ability.id, adjustment.abilityName);
for (const candidate of candidates) {
if (adjustment.mode === "reset") cooldowns[candidate.id] = 0;
else {
@@ -5861,6 +6158,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
);
set({
mobs: work.mobs,
effects: work.effects,
auras: work.auras,
combatEvents: work.combatEvents,
actors: work.actors,
@@ -5896,6 +6194,134 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return result;
},
triggerRogueWeaponPoisons: (
sourceActorId,
targetId,
sourceAbilityId,
hitCount = 1,
now = combatNow(),
) => {
const state = get();
const progression = progressionFromState(state);
const work: MutableCastWork = {
classId: state.classId,
abilities: state.abilities,
mobs: { ...state.mobs },
effects: [...state.effects],
auras: [...state.auras],
summons: [...state.summons],
areaEffects: [...state.areaEffects],
combatEvents: [...state.combatEvents],
actors: { ...state.actors },
cooldowns: { ...state.cooldowns },
health: state.health,
maxHealth: state.maxHealth,
shield: state.shield,
shieldExpiresAt: state.shieldExpiresAt,
resource: state.resource,
maxResource: state.maxResource,
resourcePools: { ...state.resourcePools },
maxResourcePools: { ...state.maxResourcePools },
lastResourceSpentAt: { ...state.lastResourceSpentAt },
comboPoints: state.comboPoints,
comboTargetId: state.comboTargetId,
deathKnightRunes: state.deathKnightRunes,
progression,
totalDamage: 0,
totalHealing: 0,
combatResults: [],
talentRanks: state.talentRanks,
talentModifiers: state.talentModifiers,
gearStats: state.gearStats,
raceId: state.raceId,
secondaryClassId: state.secondaryClassId,
};
const result = applyRogueWeaponPoisonsInWork(
work,
sourceActorId,
targetId,
sourceAbilityId,
hitCount,
now,
);
set({
mobs: work.mobs,
effects: work.effects,
auras: work.auras,
combatEvents: work.combatEvents,
actors: work.actors,
cooldowns: work.cooldowns,
level: progression.level,
xp: progression.xp,
xpToNext: progression.xpToNext,
health: progression.health,
maxHealth: progression.maxHealth,
resource: clamp(work.resource, 0, work.maxResource),
maxResource: work.maxResource,
selectedTargetId: work.mobs[targetId]?.dead && state.selectedTargetId === targetId
? null
: state.selectedTargetId,
autoAttackTargetId: work.mobs[targetId]?.dead && state.autoAttackTargetId === targetId
? null
: state.autoAttackTargetId,
nextAutoAttackAt: work.mobs[targetId]?.dead && state.autoAttackTargetId === targetId
? 0
: state.nextAutoAttackAt,
});
return result;
},
consumeRogueDeadlyPoison: (
sourceActorId,
targetId,
maximumDoses,
now = combatNow(),
) => {
const state = get();
const work: MutableCastWork = {
classId: state.classId,
abilities: state.abilities,
mobs: { ...state.mobs },
effects: [...state.effects],
auras: [...state.auras],
summons: [...state.summons],
areaEffects: [...state.areaEffects],
combatEvents: [...state.combatEvents],
actors: { ...state.actors },
cooldowns: { ...state.cooldowns },
health: state.health,
maxHealth: state.maxHealth,
shield: state.shield,
shieldExpiresAt: state.shieldExpiresAt,
resource: state.resource,
maxResource: state.maxResource,
resourcePools: { ...state.resourcePools },
maxResourcePools: { ...state.maxResourcePools },
lastResourceSpentAt: { ...state.lastResourceSpentAt },
comboPoints: state.comboPoints,
comboTargetId: state.comboTargetId,
deathKnightRunes: state.deathKnightRunes,
progression: progressionFromState(state),
totalDamage: 0,
totalHealing: 0,
combatResults: [],
talentRanks: state.talentRanks,
talentModifiers: state.talentModifiers,
gearStats: state.gearStats,
raceId: state.raceId,
secondaryClassId: state.secondaryClassId,
};
const consumed = consumeRogueDeadlyPoisonInWork(
work,
sourceActorId,
targetId,
maximumDoses,
now,
);
if (consumed > 0) set({ auras: work.auras, effects: work.effects });
return consumed;
},
addHealingThreat: (source, effectiveHealing) => set((state) => {
const engagedIds = Object.values(state.mobs)
.filter((mob) => mob.engaged && !mob.dead)
@@ -5913,6 +6339,42 @@ export const useCombatStore = create<CombatState>((set, get) => ({
return { mobs };
}),
applyActorThreatEffect: (actorId, effect, redirectTargetActorId = null, now = combatNow()) => set((state) => {
const mobs: Record<string, MobCombatState> = { ...state.mobs };
let changed = false;
for (const [id, mob] of Object.entries(mobs)) {
if (!mob.engaged || mob.dead) continue;
const currentThreat = mob.threatByActor[actorId] ?? 0;
const nextThreat = effect.mode === "redirect"
? 0
: effect.mode === "percent"
? currentThreat * Math.max(0, 1 + effect.amount / 100)
: Math.max(0, currentThreat + effect.amount);
const threatByActor = {
...mob.threatByActor,
[actorId]: nextThreat,
...(effect.mode === "redirect" && redirectTargetActorId
? { [redirectTargetActorId]: (mob.threatByActor[redirectTargetActorId] ?? 0) + currentThreat }
: {}),
};
const resolution = resolveAggroTarget(
threatByActor,
eligibleAggroActorIds(state, mob),
mob.targetActorId,
mob.forcedTarget,
now,
);
mobs[id] = {
...mob,
threatByActor,
targetActorId: resolution.targetActorId,
forcedTarget: resolution.forcedTarget,
};
changed = true;
}
return changed ? { mobs } : state;
}),
tauntMob: (id, source, durationMs, now = combatNow()) => {
const target = get().mobs[id];
if (!target || target.dead || durationMs <= 0 || (target.navigationEvadeUntil ?? 0) > now) return false;
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { abilitiesForClass } from "./abilityCatalog";
import { cooldownResetCandidates } from "./cooldownReset";
describe("cooldown resets", () => {
it("resets other abilities without resetting the source ability", () => {
const abilities = abilitiesForClass("hunter");
const readiness = abilities.find((ability) => ability.name === "Readiness")!;
const rapidFire = abilities.find((ability) => ability.name === "Rapid Fire")!;
const candidates = cooldownResetCandidates([readiness, rapidFire], readiness.id);
expect(candidates.map((ability) => ability.name)).toEqual(["Rapid Fire"]);
});
it("honors an explicitly named reset target", () => {
const abilities = abilitiesForClass("hunter");
const readiness = abilities.find((ability) => ability.name === "Readiness")!;
const rapidFire = abilities.find((ability) => ability.name === "Rapid Fire")!;
const candidates = cooldownResetCandidates([readiness, rapidFire], readiness.id, "Readiness");
expect(candidates.map((ability) => ability.name)).toEqual(["Readiness"]);
});
});
+18
View File
@@ -0,0 +1,18 @@
import type { AbilityDefinition } from "./abilityCatalog";
/**
* Resolves the abilities affected by a cooldown reset. An untargeted reset is
* the WoW "other abilities" form, so it must never make its source ability
* immediately available again.
*/
export function cooldownResetCandidates(
abilities: readonly AbilityDefinition[],
sourceAbilityId: string,
abilityName?: string,
): readonly AbilityDefinition[] {
if (abilityName) {
const normalizedName = abilityName.toLowerCase();
return abilities.filter((candidate) => candidate.name.toLowerCase() === normalizedName);
}
return abilities.filter((candidate) => candidate.id !== sourceAbilityId);
}
+7 -3
View File
@@ -5,6 +5,7 @@ import { finalDungeonBoss } from "./dungeonRunStore";
import type { DungeonDefinition } from "./dungeonTypes";
import type { PopulationEntityDefinition } from "./mobPopulation";
import type { ContentCategoryId } from "../app/contentCategories";
import { resolveBossBalanceOverride } from "./bossBalanceOverrides";
const WOW_BOSS_HEALTH_FLOOR: readonly (readonly [level: number, health: number])[] = [
// The starter anchors include enough room for authentic presence, imbue,
@@ -96,7 +97,10 @@ export function dungeonEnemyScaling(dungeon: DungeonDefinition, entity: Populati
const testingDamageScale = testing && characterSource === "wow"
? interpolatedFloor(level, WOW_BOSS_TEST_DAMAGE_SCALE)
: 1;
return { level, maxHealth: scaledHealth * multipliers.health,
healthMultiplier: authoredHealth * multipliers.health,
damageMultiplier: authoredDamage * multipliers.damage * testingDamageScale };
const bossOverride = boss
? resolveBossBalanceOverride(dungeon.id, entity.id, level)
: { healthScale: 1, damageScale: 1 };
return { level, maxHealth: scaledHealth * multipliers.health * bossOverride.healthScale,
healthMultiplier: authoredHealth * multipliers.health * bossOverride.healthScale,
damageMultiplier: authoredDamage * multipliers.damage * testingDamageScale * bossOverride.damageScale };
}
+145
View File
@@ -81,6 +81,26 @@ describe("catalog-driven party rotations", () => {
expect(committed.globalCooldownEndsAt).toBeGreaterThan(2_000);
});
it("makes Discipline absorbs and emergency mitigation executable", () => {
const member = {
...memberFor("priest", "healer"),
specialization: "Discipline",
specializationId: "priest:discipline" as const,
talentRanks: runtimePartyTalentRanksForLevel("priest", "Discipline", 80),
};
const chooseNamed = (name: string, health: number) => choosePartyAbility(member, {
now: 2_000,
target: null,
injuredAllies: 1,
lowestHealthFraction: health / 10_000,
friendlyTargets: [{ id: "tank", health, maxHealth: 10_000, role: "tank" }],
canUseAbility: ability => ability.name === name,
});
expect(chooseNamed("Power Word: Shield", 7_000)?.name).toBe("Power Word: Shield");
expect(chooseNamed("Pain Suppression", 4_000)?.name).toBe("Pain Suppression");
});
it("will not cast a mana spell after mana exhaustion", () => {
const member = { ...memberFor("priest", "healer"), resource: 0, resourcePools: { mana: 0 } };
const heal = abilitiesForClass("priest")
@@ -89,6 +109,18 @@ describe("catalog-driven party rotations", () => {
expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false);
});
it("lets an Inner Focus-prepared direct heal ignore its mana cost", () => {
const member = { ...memberFor("priest", "healer"), resource: 0, resourcePools: { mana: 0 } };
const heal = abilitiesForClass("priest")
.map((ability) => abilityAtLevel(ability, 80))
.find((ability) => ability.name === "Greater Heal")!;
expect(partyAbilityIsReady(member, heal, 2_000)).toBe(false);
expect(partyAbilityIsReady(member, heal, 2_000, true)).toBe(true);
expect(commitPartyAbilityUse(member, heal, 2_000, { ignoreResourceCosts: true }))
.toMatchObject({ resource: 0, resourcePools: { mana: 0 } });
});
it("banks Rage for a higher-priority signature strike instead of starving it with Slam", () => {
const talents = runtimePartyTalentRanksForLevel("warrior", "Arms", 80);
const base = {
@@ -131,6 +163,99 @@ describe("catalog-driven party rotations", () => {
})?.name).toBe("Bloodrage");
});
it("uses Revenge during its post-avoidance trigger window", () => {
const talents = runtimePartyTalentRanksForLevel("warrior", "Protection", 80);
const member = {
...memberFor("warrior", "tank"),
specialization: "Protection",
specializationId: "warrior:protection" as const,
talentRanks: talents,
resource: 100,
resourcePools: { rage: 100 },
};
const revenge = abilitiesForClass("warrior").find((ability) => ability.name === "Revenge")!;
const target = { ...useCombatStore.getState().mobs.caster, activeCast: null };
const onlyRevenge = (ability: typeof revenge) => ability.id === revenge.id;
expect(choosePartyAbility(member, { now: 2_000, target, canUseAbility: onlyRevenge })).toBeNull();
expect(choosePartyAbility(member, {
now: 2_000,
target,
availableConditionalAbilityNames: new Set(["Revenge"]),
canUseAbility: onlyRevenge,
})?.name).toBe("Revenge");
});
it("banks Energy for Mutilate instead of starving it with Sinister Strike", () => {
const talents = runtimePartyTalentRanksForLevel("rogue", "Assassination", 80);
const base = {
...memberFor("rogue", "damage"),
specialization: "Assassination",
specializationId: "rogue:assassination" as const,
talentRanks: talents,
};
const target = { ...useCombatStore.getState().mobs.caster, activeCast: null };
const allowed = (ability: { name: string }) => ["Mutilate", "Sinister Strike"].includes(ability.name);
expect(choosePartyAbility({ ...base, resource: 50, resourcePools: { energy: 50 } }, {
now: 2_000,
target,
canUseAbility: allowed,
})).toBeNull();
expect(choosePartyAbility({ ...base, resource: 60, resourcePools: { energy: 60 } }, {
now: 2_000,
target,
canUseAbility: allowed,
})?.name).toBe("Mutilate");
});
it("banks Energy for Envenom instead of spending combo points on Rupture", () => {
const talents = runtimePartyTalentRanksForLevel("rogue", "Assassination", 80);
const base = {
...memberFor("rogue", "damage"),
specialization: "Assassination",
specializationId: "rogue:assassination" as const,
talentRanks: talents,
comboPoints: 5,
comboTargetId: "caster",
};
const target = { ...useCombatStore.getState().mobs.caster, activeCast: null };
const allowed = (ability: { name: string }) => ["Envenom", "Rupture"].includes(ability.name);
expect(choosePartyAbility({ ...base, resource: 30, resourcePools: { energy: 30 } }, {
now: 2_000,
target,
canUseAbility: allowed,
})).toBeNull();
expect(choosePartyAbility({ ...base, resource: 35, resourcePools: { energy: 35 } }, {
now: 2_000,
target,
canUseAbility: allowed,
})?.name).toBe("Envenom");
});
it("uses Shiv instead of Rupture when an Assassination finisher needs more poison doses", () => {
const talents = runtimePartyTalentRanksForLevel("rogue", "Assassination", 80);
const member = {
...memberFor("rogue", "damage"),
specialization: "Assassination",
specializationId: "rogue:assassination" as const,
talentRanks: talents,
comboPoints: 4,
comboTargetId: "caster",
resource: 100,
resourcePools: { energy: 100 },
};
const target = { ...useCombatStore.getState().mobs.caster, activeCast: null };
const allowed = (ability: { name: string }) => ["Shiv", "Rupture"].includes(ability.name);
expect(choosePartyAbility(member, {
now: 2_000,
target,
canUseAbility: allowed,
})?.name).toBe("Shiv");
});
it("opens Arcane combat with the learned Arcane Barrage signature", () => {
const member = {
...memberFor("mage", "damage"),
@@ -154,6 +279,26 @@ describe("catalog-driven party rotations", () => {
})).toBe(17);
});
it("scales Envenom from consumed Deadly Poison doses", () => {
const member = {
...memberFor("rogue", "damage"),
specialization: "Assassination",
specializationId: "rogue:assassination" as const,
talentRanks: runtimePartyTalentRanksForLevel("rogue", "Assassination", 80),
comboPoints: 5,
};
const ability = abilityAtLevel(
abilitiesForClass("rogue").find((candidate) => candidate.name === "Envenom")!,
80,
);
const effect = ability.effects.find((candidate) => candidate.kind === "finisher-damage")!;
const oneDose = partyAbilityAmount(member, ability, effect, 1);
const fiveDoses = partyAbilityAmount(member, ability, effect, 5);
expect(oneDose).toBeGreaterThan(0);
expect(fiveDoses).toBeCloseTo(oneDose * 5, -1);
expect(partyAbilityAmount(member, ability, effect, 0)).toBe(0);
});
it("requires a learned CoA tree entry before the NPC can cast it", () => {
const member = { ...memberFor("pyromancer", "damage"), talentRanks: {} };
const ability = abilitiesForClass(member.classId).find((candidate) => candidate.talentEntryId !== undefined && !candidate.passive)!;
+51 -7
View File
@@ -24,6 +24,10 @@ export interface PartyAbilityContext {
readonly target: MobCombatState | null;
readonly targetIsLoose?: boolean;
readonly hasDangerousThreat?: boolean;
readonly enemyUsesMeleeAttacks?: boolean;
readonly incomingMeleeDanger?: boolean;
readonly needsEscape?: boolean;
readonly moving?: boolean;
readonly requiredControl?: boolean;
readonly allyNeedsDispel?: boolean;
readonly fallenAlly?: boolean;
@@ -35,8 +39,11 @@ export interface PartyAbilityContext {
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
readonly activeSummonAbilityIds?: ReadonlySet<string>;
readonly hasActivePet?: boolean;
readonly availableConditionalAbilityNames?: ReadonlySet<string>;
readonly canUseAbility?: (ability: AbilityDefinition) => boolean;
readonly incomingDefensiveMechanic?: CompanionCooldownMechanicRule | null;
/** Inner Focus-style setup lets the next direct healing spell ignore resource costs. */
readonly nextHealingSpellFree?: boolean;
}
function currentResource(member: PartyMember, type: ResourceType): number {
@@ -52,12 +59,17 @@ function abilityCosts(member: PartyMember, ability: AbilityDefinition): readonly
}));
}
export function partyAbilityIsReady(member: PartyMember, ability: AbilityDefinition, now: number): boolean {
export function partyAbilityIsReady(
member: PartyMember,
ability: AbilityDefinition,
now: number,
ignoreResourceCosts = false,
): boolean {
if (ability.passive || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) return false;
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
if (abilityCosts(member, ability).some(cost => cost.resource === "health"
if (!ignoreResourceCosts && abilityCosts(member, ability).some(cost => cost.resource === "health"
? member.health <= cost.amount : currentResource(member, cost.resource) < cost.amount)) return false;
const runeCost = member.classId === "death-knight" ? runeCostForAbility(ability) : null;
return !runeCost || spendDeathKnightRunes(member.runes, runeCost, now) !== null;
@@ -74,7 +86,7 @@ function isComboFinisher(ability: AbilityDefinition): boolean {
|| (effect.kind === "combo" && effect.amount < 0));
}
const RESERVED_SIGNATURE_ACTIONS = new Set(["Mortal Strike"]);
const RESERVED_SIGNATURE_ACTIONS = new Set(["Mortal Strike", "Mutilate", "Envenom"]);
function shouldReserveForHigherPriority(
member: PartyMember,
@@ -87,8 +99,10 @@ function shouldReserveForHigherPriority(
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 (!candidate || candidate.passive) return false;
if (!RESERVED_SIGNATURE_ACTIONS.has(candidate.name)) return false;
if (isComboFinisher(candidate)
&& (member.comboPoints < 4 || member.comboTargetId !== decision.targetId)) 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;
@@ -141,7 +155,12 @@ export function choosePartyRotationDecision(
comboPoints: member.comboPoints,
comboTargetId: member.comboTargetId,
runes: member.runes,
abilities: learned.filter((ability) => partyAbilityIsReady(member, ability, context.now)),
abilities: learned.filter((ability) => partyAbilityIsReady(
member,
ability,
context.now,
Boolean(context.nextHealingSpellFree && ability.effects.some((effect) => effect.kind === "heal")),
)),
knownDamagingAbilityNames: new Set(learned.filter((ability) => {
const categories = aiHintsForAbility(ability).categories;
return categories.includes("damage") || categories.includes("execute");
@@ -151,11 +170,16 @@ export function choosePartyRotationDecision(
hostileCount: context.hostileCount,
targetIsLoose: context.targetIsLoose,
hasDangerousThreat: context.hasDangerousThreat,
enemyUsesMeleeAttacks: context.enemyUsesMeleeAttacks,
incomingMeleeDanger: context.incomingMeleeDanger,
needsEscape: context.needsEscape,
moving: context.moving,
requiredControl: context.requiredControl,
maintainedAbilityIds: context.maintainedAbilityIds,
activeAuraExclusiveGroups: context.activeAuraExclusiveGroups,
activeSummonAbilityIds: context.activeSummonAbilityIds,
hasActivePet: context.hasActivePet,
availableConditionalAbilityNames: context.availableConditionalAbilityNames,
canUseAbility: context.canUseAbility,
cooldownPolicy: currentCompanionCooldownPolicy(),
incomingDefensiveMechanic: context.incomingDefensiveMechanic,
@@ -176,13 +200,14 @@ export function commitPartyAbilityUse(
member: PartyMember,
ability: AbilityDefinition,
now: number,
options: { readonly ignoreResourceCosts?: boolean } = {},
): PartyMember | null {
if (!partyAbilityIsReady(member, ability, now)) return null;
if (!partyAbilityIsReady(member, ability, now, options.ignoreResourceCosts)) return null;
const resourcePools = { ...member.resourcePools };
const lastResourceSpentAt = { ...member.lastResourceSpentAt };
let resource = member.resource;
let health = member.health;
for (const cost of abilityCosts(member, ability)) {
for (const cost of options.ignoreResourceCosts ? [] : abilityCosts(member, ability)) {
const amount = cost.amount;
if (cost.resource === "health") { health -= amount; continue; }
const current = currentResource(member, cost.resource);
@@ -214,7 +239,26 @@ export function partyAbilityAmount(
member: PartyMember,
ability: AbilityDefinition,
effect: AbilityEffect,
poisonDoses = 0,
): number {
if (effect.kind === "finisher-damage" && effect.poison) {
const consumedDoses = Math.max(0, Math.min(
effect.poison.maximumDoses,
Math.floor(poisonDoses),
));
return equipmentAdjustedCombatAmount(
{
coefficient: 0,
basePoints: effect.poison.basePointsPerDose * consumedDoses,
dieSides: 1,
attackPowerCoefficient: effect.poison.attackPowerCoefficientPerDose * consumedDoses,
},
member.level,
member.gearStats,
member.classId,
ability,
);
}
const coefficient = "coefficient" in effect
? effect.coefficient
: effect.kind === "finisher-damage"
+127 -2
View File
@@ -25,6 +25,7 @@ import { subscribeCombatPresentation, type CombatPresentationEvent } from "./com
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import { abilitiesForClass } from "./abilityCatalog";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { baseClassFor } from "../app/characterCatalog";
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
@@ -145,6 +146,65 @@ describe("party combat runtime", () => {
expect(useCombatStore.getState().mobs["feral-spirit-boss"].health).toBeLessThan(beforeSummonAttack);
});
it("lets an Assassination companion stack poisons and scale Envenom from them", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const rogue = createConfiguredPartyMember({
name: "Assassination",
classId: "rogue",
specialization: "Assassination",
specializationId: "rogue:assassination",
talentTreeId: "rogue-assassination",
talentRanks: runtimePartyTalentRanksForLevel("rogue", "Assassination", 80),
role: "damage",
}, 80, 0, "assassination-poison-runtime");
usePartyStore.getState().initializeParty(
"assassination-poison-runtime",
80,
useCombatStore.getState().health,
"damage",
);
usePartyStore.setState({ members: [rogue] });
registerPartyRuntimePosition(rogue.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
useCombatStore.getState().applyPrecombatPreparation(500);
expect(useCombatStore.getState().auras.filter((aura) => (
aura.targetId === rogue.id && aura.definition.utilityTags?.includes("weapon-enchant")
))).toHaveLength(2);
registerEngagedMob("poison-runtime-boss", 10_000_000);
const events: StructuredCombatEvent[] = [];
const stop = subscribeCombatEvents((event) => events.push(event));
for (let now = 1_000; now <= 60_000; now += 100) {
useCombatStore.getState().tick(0.1, now);
advanceDungeonPartyCombat(now);
}
stop();
expect(events.some((event) => (
event.sourceActorId === rogue.id && event.abilityId === "rogue-instant-poison"
))).toBe(true);
expect(events.some((event) => (
event.sourceActorId === rogue.id && event.abilityId === "rogue-deadly-poison"
))).toBe(true);
expect(events.some((event) => (
event.sourceActorId === rogue.id
&& event.abilityId === "wow335-rogue-envenom"
&& event.school === "nature"
&& event.effectiveAmount > 0
))).toBe(true);
expect(events.some((event) => (
event.sourceActorId === rogue.id && event.abilityId === "wow335-rogue-shiv"
))).toBe(true);
expect(events.some((event) => (
event.sourceActorId === rogue.id && event.abilityId === "wow335-rogue-rupture"
))).toBe(false);
expect(useCombatStore.getState().auras.find((aura) => (
aura.sourceId === rogue.id
&& aura.targetId === "poison-runtime-boss"
&& aura.definition.utilityTags?.includes("rogue-deadly-poison-dose")
))?.stacks).toBe(5);
});
it("gives companion Hunter and Warlock pets a hostile target when summoned from self", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const hunter = createConfiguredPartyMember({
@@ -237,6 +297,63 @@ describe("party combat runtime", () => {
expect(applied?.absorbs.reduce((total, absorb) => total + absorb.remaining, 0)).toBeGreaterThan(0);
});
it("casts Inner Focus before an emergency direct heal and makes that heal free", () => {
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, "inner-focus-runtime");
const healer = createConfiguredPartyMember({
name: "Discipline",
classId: "priest",
specialization: "Discipline",
specializationId: "priest:discipline",
talentTreeId: "priest-discipline",
talentRanks: runtimePartyTalentRanksForLevel("priest", "Discipline", 80),
role: "healer",
}, 80, 1, "inner-focus-runtime");
const priestAbilities = abilitiesForClass("priest");
const innerFocus = priestAbilities.find((ability) => ability.name === "Inner Focus")!;
const greaterHeal = priestAbilities.find((ability) => ability.name === "Greater Heal")!;
const startingHealth = Math.round(tank.maxHealth * 0.3);
usePartyStore.getState().initializeParty("inner-focus-runtime", 80, useCombatStore.getState().health, "damage");
usePartyStore.setState({
members: [
{ ...tank, health: startingHealth },
{
...healer,
resource: 0,
maxResource: 0,
resourcePools: { mana: 0 },
cooldowns: Object.fromEntries(priestAbilities
.filter((ability) => ![innerFocus.id, greaterHeal.id].includes(ability.id))
.map((ability) => [ability.id, 60_000])),
},
],
});
for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
registerEngagedMob("inner-focus-boss", 100_000);
const events: CombatPresentationEvent[] = [];
const stop = subscribeCombatPresentation((message) => {
if (message.kind === "event" && message.event.sourceActorId === healer.id) events.push(message.event);
});
for (const now of [1_000, 2_500, 6_000]) advanceDungeonPartyCombat(now);
stop();
const releases = events.filter((event) => event.phase === "release").map((event) => event.name);
expect(releases).toEqual(expect.arrayContaining(["Inner Focus", "Greater Heal"]));
expect(releases.indexOf("Inner Focus")).toBeLessThan(releases.indexOf("Greater Heal"));
expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.resource).toBe(0);
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.health)
.toBeGreaterThan(startingHealth);
});
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")!;
@@ -393,9 +510,14 @@ describe("party combat runtime", () => {
it("gives damage dealers a higher baseline hit than the tank", () => {
const [tank, damage] = usePartyStore.getState().members;
expect(partyAttackDamage(damage)).toBeGreaterThan(partyAttackDamage(tank));
const attackStat = baseClassFor(damage.classId) === "mage"
? { spellPower: 126 }
: baseClassFor(damage.classId) === "hunter"
? { rangedAttackPower: 126 }
: { attackPower: 126 };
expect(partyAttackDamage({
...damage,
gearStats: { ...EMPTY_ITEM_STATS, attackPower: 126 },
gearStats: { ...EMPTY_ITEM_STATS, ...attackStat },
})).toBeGreaterThan(partyAttackDamage(damage));
});
@@ -541,7 +663,10 @@ describe("party combat runtime", () => {
advanceDungeonPartyCombat(1_000);
expect(useCombatStore.getState().mobs["pack:a"].health).toBe(before);
expect(usePartyStore.getState().members.every((member) => member.status === "advancing")).toBe(true);
expect(usePartyStore.getState().members.every((member) => (
member.status === "advancing"
|| ((member.status === "attacking" || member.status === "guarding") && member.nextActionAt > 1_000)
))).toBe(true);
});
it("does not deal remote damage when a target has no world position", () => {
+246 -15
View File
@@ -71,6 +71,13 @@ import {
isSourceBehindTarget,
} from "./combatSpatial";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { cooldownResetCandidates } from "./cooldownReset";
import {
matchingAuraStacks,
rogueEnvenomPreservesDeadlyPoison,
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
} from "./roguePoison";
const OUT_OF_COMBAT_REGEN_FRACTION = 0.06;
const ATTACK_ACQUISITION_RANGE = 30;
@@ -81,6 +88,7 @@ const TANK_TAUNT_DURATION_MS = 3_000;
let lastAdvanceAt = 0;
let nextRegenAt = 0;
const nextWeaponAttackAt = new Map<string, number>();
const pendingInnerFocus = new Set<string>();
export interface PartyPresentationIdentity {
readonly spellId: number;
@@ -168,6 +176,7 @@ export function resetPartyRuntimeClock(): void {
lastAdvanceAt = 0;
nextRegenAt = 0;
nextWeaponAttackAt.clear();
pendingInnerFocus.clear();
}
export function activePartyTargetId(
@@ -709,6 +718,9 @@ function advancePartyWeaponAttack(member: PartyMember, targetId: string, now: nu
const abilityId = "party-auto-attack";
const result = useCombatStore.getState().damageMob(targetId, partyAttackDamage(member), now,
{ actorId: member.id, role: member.role, abilityId }, "physical");
if (result.amount > 0 && baseClassFor(member.classId) === "rogue") {
useCombatStore.getState().triggerRogueWeaponPoisons(member.id, targetId, abilityId, 1, now);
}
const identity = partyAttackPresentation(member);
for (const phase of ["release", "impact"] as const) {
emitCombatPresentation({ occurredAt: now, phase, actorGroup: "party", sourceActorId: member.id,
@@ -878,6 +890,11 @@ interface PartySupportIntent {
readonly targetId: string;
}
function abilityHasDirectHealing(ability: AbilityDefinition): boolean {
return ability.effects.some((effect) => effect.kind === "heal")
|| channelAmountEffects(ability, 1).some(({ effect }) => effect.kind === "heal");
}
function choosePartySupportIntent(
member: PartyMember,
members: readonly PartyMember[],
@@ -963,17 +980,102 @@ function choosePartySupportIntent(
|| (right.maxHealth - right.health) - (left.maxHealth - left.health)
|| left.id.localeCompare(right.id)
));
const friendlyTargets = injured.map((candidate) => ({
id: candidate.id,
health: candidate.health,
maxHealth: candidate.maxHealth,
role: candidate.role,
}));
const lowAllies = injured.filter((candidate) => (
candidate.health / Math.max(1, candidate.maxHealth) <= 0.6
));
const lowestHealthFraction = injured[0]
? injured[0].health / Math.max(1, injured[0].maxHealth)
: 1;
if (!pendingInnerFocus.has(member.id)
&& (lowestHealthFraction <= 0.4 || lowAllies.length >= 2)) {
const innerFocus = choosePartyAbility(member, {
now,
target: null,
injuredAllies: injured.length,
lowestHealthFraction,
friendlyTargets,
canUseAbility: ability => ability.name === "Inner Focus"
&& partyAbilityTargetIsValid(member, ability, member.id),
});
if (innerFocus) return { ability: innerFocus, targetId: member.id };
}
for (const target of injured) {
const maintainedAbilityIds = new Set(combat.auras
.filter((aura) => aura.sourceId === member.id
&& aura.targetId === target.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)));
if (pendingInnerFocus.has(member.id)) {
const empoweredHeal = choosePartyAbility(member, {
now,
target: null,
injuredAllies: injured.length,
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
friendlyTargets,
nextHealingSpellFree: true,
canUseAbility: ability => abilityHasDirectHealing(ability)
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
&& partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id),
});
if (empoweredHeal) {
return { ability: empoweredHeal, targetId: empoweredHeal.target === "self" ? member.id : target.id };
}
}
const mitigation = choosePartyAbility(member, {
now,
target: null,
injuredAllies: injured.length,
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
friendlyTargets,
maintainedAbilityIds,
canUseAbility: ability => aiHintsForAbility(ability).categories.includes("mitigation")
&& (ability.target === "friendly" || ability.target === "any" || ability.target === "self")
&& ability.effects.some((effect) => effect.kind === "shield"
|| (effect.kind === "apply-aura" && (
Boolean(effect.aura.absorbs?.length)
|| effect.aura.modifiers?.some((modifier) => modifier.kind === "damage"
&& modifier.direction === "taken" && modifier.value < 0)
)))
&& partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id)
&& !ability.effects.some((effect) => effect.kind === "shield"
&& (combat.actors[target.id]?.absorb ?? 0) > 0)
&& !ability.effects.some((effect) => effect.kind === "apply-aura"
&& combat.auras.some((aura) => aura.sourceId === member.id
&& aura.targetId === target.id && aura.definition.id === effect.aura.id)),
});
if (mitigation) return { ability: mitigation, targetId: mitigation.target === "self" ? member.id : target.id };
const targetHealthFraction = target.health / Math.max(1, target.maxHealth);
const periodic = targetHealthFraction >= 0.55 ? choosePartyAbility(member, {
now,
target: null,
injuredAllies: injured.length,
lowestHealthFraction: targetHealthFraction,
friendlyTargets,
maintainedAbilityIds,
canUseAbility: ability => ability.effects.some((effect) => effect.kind === "hot")
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
&& !healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
&& partyAbilityTargetIsValid(member, ability, ability.target === "self" ? member.id : target.id),
}) : null;
if (periodic) return { ability: periodic, targetId: periodic.target === "self" ? member.id : target.id };
const ability = choosePartyAbility(member, {
now,
target: null,
injuredAllies: injured.length,
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
friendlyTargets: injured.map((candidate) => ({
id: candidate.id,
health: candidate.health,
maxHealth: candidate.maxHealth,
})),
friendlyTargets,
maintainedAbilityIds,
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind)
|| (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length)))
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
@@ -1009,12 +1111,40 @@ function executePartyAbility(
now,
))
: false;
const consumesInnerFocus = pendingInnerFocus.has(member.id)
&& ability.name !== "Inner Focus"
&& abilityHasDirectHealing(ability);
if (consumesInnerFocus) pendingInnerFocus.delete(member.id);
const criticalChance = Math.max(0, ratingPercent(
member.gearStats.criticalStrikeRating,
"spellCrit",
member.level,
member.classId,
) / 100);
) / 100 + (consumesInnerFocus ? 0.25 : 0));
if (ability.name === "Inner Focus") pendingInnerFocus.add(member.id);
const envenomDosesByTarget = new Map<string, number>();
if (ability.name === "Envenom") {
for (const hostileTargetId of hostileTargets) {
const doses = Math.min(
member.comboPoints,
5,
matchingAuraStacks(
combat.auras,
ROGUE_DEADLY_POISON_DOSE_TAG,
member.id,
hostileTargetId,
now,
),
);
envenomDosesByTarget.set(hostileTargetId, doses);
if (doses > 0 && !rogueEnvenomPreservesDeadlyPoison(
member.talentRanks,
`${member.id}:${hostileTargetId}:${ability.id}:${now}:master-poisoner`,
)) {
combat.consumeRogueDeadlyPoison(member.id, hostileTargetId, doses, now);
}
}
}
let runtimeMember = member;
let effectiveAmount = 0;
for (const effect of ability.effects) {
@@ -1025,12 +1155,32 @@ function executePartyAbility(
} else {
const result = combat.damageMob(
hostileTargetId,
partyAbilityAmount(runtimeMember, ability, effect),
partyAbilityAmount(
runtimeMember,
ability,
effect,
envenomDosesByTarget.get(hostileTargetId) ?? 0,
),
now,
{ actorId: member.id, role: member.role, abilityId: ability.id },
spellSchool,
);
effectiveAmount += result.amount;
if (
result.amount > 0
&& effect.kind === "damage"
&& baseClassFor(member.classId) === "rogue"
&& spellSchool === "physical"
&& ability.range.max <= 5
) {
combat.triggerRogueWeaponPoisons(
member.id,
hostileTargetId,
ability.id,
ability.name === "Mutilate" ? 2 : 1,
now,
);
}
if (effect.kind === "damage" && effect.onDamageResource && result.amount > 0) {
combat.restoreActorResource(member.id, effect.onDamageResource.resource, effect.onDamageResource.amount,
false, member.id, ability.id, now);
@@ -1106,6 +1256,7 @@ function executePartyAbility(
combat.controlMob(hostileTargetId, ability.id, effect.status, effect.durationMs, now);
}
} else if (effect.kind === "apply-aura") {
if (ability.name === "Inner Focus") continue;
const recipients = effect.scope === "party"
? [
...(simulatedPartyExcludesPlayer() ? [] : [PLAYER_AGGRO_ID]),
@@ -1115,7 +1266,14 @@ function executePartyAbility(
? [member.id]
: combat.mobs[targetId] ? hostileTargets : friendlyTargets;
for (const recipientId of recipients) {
useCombatStore.getState().applyActorAura(member.id, recipientId, ability, effect, now);
const appliedEffect = ability.name === "Envenom"
&& effect.aura.utilityTags?.includes(ROGUE_ENVENOM_BUFF_TAG)
? {
...effect,
aura: { ...effect.aura, durationMs: (runtimeMember.comboPoints + 1) * 1_000 },
}
: effect;
useCombatStore.getState().applyActorAura(member.id, recipientId, ability, appliedEffect, now);
if (effect.control && useCombatStore.getState().mobs[recipientId]) {
useCombatStore.getState().controlMob(recipientId, ability.id, effect.control.kind, effect.aura.durationMs ?? 1_000, now);
}
@@ -1186,14 +1344,45 @@ function executePartyAbility(
useCombatStore.getState().summonActor(member.id, ability, summonEffect, origin, summonTargetId, now);
} else if (effect.kind === "movement") {
const targetPosition = combat.mobs[targetId] ? getMobPosition(targetId) : partyFriendlyPosition(targetId);
if (targetPosition && effect.movement !== "knockback" && effect.toward !== "away") {
if (effect.toward === "away") {
const danger = Object.values(combat.mobs)
.filter((mob) => mob.engaged && !mob.dead)
.sort((left, right) => (
Number(right.targetActorId === member.id) - Number(left.targetActorId === member.id)
|| Number(right.boss) - Number(left.boss)
|| left.id.localeCompare(right.id)
))[0];
const dangerPosition = danger ? getMobPosition(danger.id) : null;
if (dangerPosition) {
const dx = origin[0] - dangerPosition[0];
const dz = origin[2] - dangerPosition[2];
const length = Math.max(0.001, Math.hypot(dx, dz));
const distance = effect.movement === "teleport" ? 15 : 10;
updatePartyRuntimePosition(
member.id,
origin[0] + dx / length * distance,
origin[1],
origin[2] + dz / length * distance,
);
}
} else if (targetPosition && effect.movement !== "knockback") {
updatePartyRuntimePosition(member.id, targetPosition[0], targetPosition[1], targetPosition[2]);
}
} else if (effect.kind === "threat") {
combat.applyActorThreatEffect(
member.id,
effect,
effect.mode === "redirect" && !combat.mobs[targetId] ? targetId : null,
now,
);
} else if (effect.kind === "cooldown-reset") {
const candidates = Object.keys(runtimeMember.cooldowns).filter((id) => {
const candidate = abilityById(id);
return candidate && (!effect.abilityName || candidate.name === effect.abilityName);
});
const candidates = cooldownResetCandidates(
Object.keys(runtimeMember.cooldowns)
.map((id) => abilityById(id))
.filter((candidate): candidate is AbilityDefinition => candidate !== null),
ability.id,
effect.abilityName,
).map((candidate) => candidate.id);
const cooldowns = { ...runtimeMember.cooldowns };
for (const id of candidates) {
const readyAt = cooldowns[id] ?? now;
@@ -1478,7 +1667,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = { ...member, status: "advancing", nextActionAt: now + 250 };
continue;
}
const committed = commitPartyAbilityUse(member, supportIntent.ability, now);
const committed = commitPartyAbilityUse(member, supportIntent.ability, now, {
ignoreResourceCosts: pendingInnerFocus.has(member.id)
&& abilityHasDirectHealing(supportIntent.ability),
});
if (!committed) {
nextMembers[index] = { ...member, status: "ready", nextActionAt: now + 250 };
continue;
@@ -1565,12 +1757,34 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = runtimeMember;
continue;
}
const hasDangerousThreat = member.role !== "tank" && targetMob?.targetActorId === member.id;
const enemyUsesMeleeAttacks = targetMob?.attacks.some((attack) => (
attack.delivery === "melee" || attack.range <= 5
)) ?? false;
const incomingMeleeDanger = Boolean(targetMob?.activeCast
&& targetMob.activeCast.targetActorId === member.id
&& targetMob.activeCast.completesAt >= now
&& targetMob.activeCast.completesAt - now <= 3_000
&& targetMob.attacks.some((attack) => attack.id === targetMob.activeCast?.attackId
&& (attack.delivery === "melee" || attack.range <= 5)
&& attack.damageMultiplier >= 1.25));
const selectedDecision = choosePartyRotationDecision(runtimeMember, {
now,
target: targetMob ?? null,
targetIsLoose: member.role === "tank" && targetMob?.targetActorId !== member.id,
hasDangerousThreat: member.role !== "tank" && targetMob?.targetActorId === member.id,
hasDangerousThreat,
enemyUsesMeleeAttacks,
incomingMeleeDanger,
needsEscape: hasDangerousThreat
|| member.health / Math.max(1, member.maxHealth) <= 0.4
|| incomingMeleeDanger,
moving: runtimeMember.status === "advancing",
incomingDefensiveMechanic: imminentCooldownMechanic(targetMob ?? null, member.role, now),
availableConditionalAbilityNames: currentCombat.combatEvents.some((event) => (
event.targetActorId === member.id
&& event.occurredAt > now - 5_000
&& ["dodge", "parry", "block"].includes(event.outcome ?? "")
)) ? new Set(["Revenge"]) : undefined,
hostileCount: Object.values(currentCombat.mobs).filter((mob) => mob.engaged && !mob.dead).length,
friendlyTargets: nextMembers.filter((candidate) => candidate.health > 0).map((candidate) => ({
id: candidate.id,
@@ -1611,6 +1825,23 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
? member.id
: memberTargetId;
return partyAbilityTargetIsValid(runtimeMember, ability, abilityTargetId)
&& (ability.name !== "Envenom" || matchingAuraStacks(
currentCombat.auras,
ROGUE_DEADLY_POISON_DOSE_TAG,
member.id,
abilityTargetId,
now,
) >= Math.min(5, runtimeMember.comboPoints))
&& (ability.name !== "Shiv" || (
runtimeMember.comboPoints >= 4
&& matchingAuraStacks(
currentCombat.auras,
ROGUE_DEADLY_POISON_DOSE_TAG,
member.id,
abilityTargetId,
now,
) < Math.min(5, runtimeMember.comboPoints)
))
// Let maintained effects tick instead of resetting their first tick on every GCD.
&& !currentCombat.effects.some((effect) => effect.sourceActorId === member.id
&& effect.sourceAbilityId === ability.id && effect.targetId === abilityTargetId && effect.remainingTicks > 0)
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
rogueDeadlyPoisonTickDamage,
rogueEnvenomPreservationChance,
rogueEnvenomPreservesDeadlyPoison,
rogueInstantPoisonDamage,
roguePoisonApplicationChances,
} from "./roguePoison";
describe("Rogue weapon poisons", () => {
it("uses the learned poison ranks and attack-power scaling", () => {
expect(rogueInstantPoisonDamage(20, 0)).toBe(22);
expect(rogueInstantPoisonDamage(80, 1_000)).toBe(450);
expect(rogueDeadlyPoisonTickDamage(80, 1_000, 1)).toBe(104);
expect(rogueDeadlyPoisonTickDamage(80, 1_000, 5)).toBe(520);
});
it("applies Envenom's poison-frequency bonuses without exceeding the stack cap", () => {
expect(roguePoisonApplicationChances(false)).toEqual({ instant: 0.2, deadly: 0.3 });
expect(roguePoisonApplicationChances(true).instant).toBeCloseTo(0.35);
expect(roguePoisonApplicationChances(true).deadly).toBeCloseTo(0.45);
expect(rogueDeadlyPoisonTickDamage(80, 1_000, 99)).toBe(520);
});
it("honors Master Poisoner's Envenom dose-preservation ranks", () => {
expect(rogueEnvenomPreservationChance({})).toBe(0);
expect(rogueEnvenomPreservationChance({ "talent-1715": 1 })).toBe(0.33);
expect(rogueEnvenomPreservationChance({ "talent-1715": 2 })).toBe(0.66);
expect(rogueEnvenomPreservationChance({ "talent-1715": 3 })).toBe(1);
expect(rogueEnvenomPreservesDeadlyPoison({ "talent-1715": 3 }, "any-cast")).toBe(true);
expect(rogueEnvenomPreservesDeadlyPoison({}, "any-cast")).toBe(false);
});
});
+122
View File
@@ -0,0 +1,122 @@
import type { ActiveAura, AuraDefinition } from "./combatAuras";
export const ROGUE_INSTANT_POISON_TAG = "rogue-instant-poison";
export const ROGUE_DEADLY_POISON_COATING_TAG = "rogue-deadly-poison-coating";
export const ROGUE_DEADLY_POISON_DOSE_TAG = "rogue-deadly-poison-dose";
export const ROGUE_ENVENOM_BUFF_TAG = "rogue-envenom-poison-buff";
export const ROGUE_DEADLY_POISON_AURA: AuraDefinition = Object.freeze({
id: "rogue-deadly-poison-doses",
name: "Deadly Poison",
description: "Deals Nature damage every 3 sec. Stacks up to 5 times.",
disposition: "debuff",
durationMs: 12_000,
maxStacks: 5,
stackBehavior: "add",
uniqueBySource: true,
utilityTags: [ROGUE_DEADLY_POISON_DOSE_TAG],
dispelCategory: "poison",
});
export interface RoguePoisonFinisherScaling {
readonly doseAuraTag: string;
readonly maximumDoses: number;
readonly basePointsPerDose: number;
readonly attackPowerCoefficientPerDose: number;
}
export interface RoguePoisonApplicationChances {
readonly instant: number;
readonly deadly: number;
}
const MASTER_POISONER_TALENT_ID = "talent-1715";
const INSTANT_POISON_BASE_BY_LEVEL: readonly (readonly [level: number, amount: number])[] = [
[20, 22], [28, 43], [36, 79], [44, 121], [52, 170], [60, 225], [68, 284], [73, 350],
];
const DEADLY_POISON_TICK_BY_LEVEL: readonly (readonly [level: number, amount: number])[] = [
[30, 12], [38, 20], [46, 28], [54, 36], [60, 44], [62, 52], [70, 62], [76, 74],
];
function rankAmount(level: number, ranks: readonly (readonly [level: number, amount: number])[]): number {
let amount = ranks[0]?.[1] ?? 1;
for (const [requiredLevel, rankAmountValue] of ranks) {
if (level < requiredLevel) break;
amount = rankAmountValue;
}
return amount;
}
export function rogueInstantPoisonDamage(level: number, attackPower: number): number {
return Math.max(1, Math.round(rankAmount(level, INSTANT_POISON_BASE_BY_LEVEL) + Math.max(0, attackPower) * 0.1));
}
export function rogueDeadlyPoisonTickDamage(level: number, attackPower: number, stacks: number): number {
const boundedStacks = Math.max(0, Math.min(5, Math.floor(stacks)));
return Math.max(0, Math.round(
(rankAmount(level, DEADLY_POISON_TICK_BY_LEVEL) + Math.max(0, attackPower) * 0.03) * boundedStacks,
));
}
export function roguePoisonApplicationChances(envenomBuffActive: boolean): RoguePoisonApplicationChances {
return Object.freeze({
// Envenom increases Instant Poison's application frequency by 75% and
// adds 15 percentage points to Deadly Poison's application chance.
instant: 0.2 * (envenomBuffActive ? 1.75 : 1),
deadly: 0.3 + (envenomBuffActive ? 0.15 : 0),
});
}
export function rogueEnvenomPreservationChance(
talentRanks: Readonly<Record<string, number>>,
): number {
const rank = Math.max(0, Math.min(3, Math.floor(talentRanks[MASTER_POISONER_TALENT_ID] ?? 0)));
return [0, 0.33, 0.66, 1][rank] ?? 0;
}
function stableRogueUnit(seed: string): number {
let hash = 2166136261;
for (let index = 0; index < seed.length; index += 1) {
hash ^= seed.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 0x1_0000_0000;
}
export function rogueEnvenomPreservesDeadlyPoison(
talentRanks: Readonly<Record<string, number>>,
seed: string,
): boolean {
const chance = rogueEnvenomPreservationChance(talentRanks);
return chance >= 1 || (chance > 0 && stableRogueUnit(seed) < chance);
}
export function matchingAuraStacks(
auras: readonly ActiveAura[],
tag: string,
sourceId: string,
targetId: string,
now: number,
): number {
return auras.find((aura) => (
aura.sourceId === sourceId
&& aura.targetId === targetId
&& (aura.expiresAt === null || aura.expiresAt > now)
&& aura.definition.utilityTags?.includes(tag)
))?.stacks ?? 0;
}
export function hasActiveAuraTag(
auras: readonly ActiveAura[],
tag: string,
targetId: string,
now: number,
): boolean {
return auras.some((aura) => (
aura.targetId === targetId
&& (aura.expiresAt === null || aura.expiresAt > now)
&& aura.definition.utilityTags?.includes(tag)
));
}
+21
View File
@@ -20,6 +20,27 @@ function effectsOfKind<K extends AbilityEffect["kind"]>(
}
describe("WoW 3.3.5 executable effect mapping", () => {
it("maps Envenom to Deadly Poison dose damage and its poison-frequency buff", () => {
const envenom = abilityAtLevel(byId("wow335-rogue-envenom"), 80);
expect(effectsOfKind(envenom, "finisher-damage")).toEqual([
expect.objectContaining({
poison: expect.objectContaining({
maximumDoses: 5,
basePointsPerDose: 216,
attackPowerCoefficientPerDose: 0.09,
}),
}),
]);
expect(effectsOfKind(envenom, "apply-aura")).toEqual([
expect.objectContaining({
recipient: "caster",
aura: expect.objectContaining({
utilityTags: expect.arrayContaining(["rogue-envenom-poison-buff"]),
}),
}),
]);
});
it("gives every imported classic rank executable or explicit utility semantics", () => {
const effects = Object.values(WOW335_ABILITIES_BY_CLASS)
.flat()
+62 -1
View File
@@ -21,6 +21,10 @@ import type {
} from "./combatAuras";
import generatedData from "./wow335AbilityData.generated.json";
import { playerCombatPower } from "./progression";
import {
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
} from "./roguePoison";
import { talentNodeById } from "./talentCatalog";
import { baseResourcesFor } from "./wotlkStats";
@@ -796,6 +800,25 @@ function classScriptEffects(
// 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:Envenom": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:rogue:envenom-poison-buff",
name: abilityName,
description: "Increases Deadly Poison application chance by 15% and Instant Poison application frequency by 75%.",
disposition: "buff",
// Runtime shortens this to one second plus one second per combo point.
durationMs: rank.durationMs > 0 ? rank.durationMs : 6_000,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: [ROGUE_ENVENOM_BUFF_TAG],
},
sourceEffectType: 6,
sourceAuraType: 108,
miscValue: 26,
triggerSpellId: 0,
}];
case "rogue:Garrote": return [{ kind: "status", status: "silence", durationMs: 3_000 }];
case "rogue:Cold Blood": return [{
kind: "apply-aura",
@@ -842,6 +865,10 @@ function classScriptEffects(
triggerSpellId: 0,
},
];
case "rogue:Vanish": return [
{ kind: "threat", mode: "percent", amount: -100 },
{ kind: "movement", movement: "leap", toward: "away" },
];
// Both spells place a channel aura whose actual pulses live in non-player
// trigger spells. Keep the imported cast/cost/rank data and supply only the
// missing executable payload.
@@ -851,6 +878,23 @@ function classScriptEffects(
{ kind: "summon", creatureId: 19668, coefficient: 0.5, durationMs: 15_000 },
{ kind: "resource", resource: "mana", amount: 30, percentage: true },
];
case "priest:Inner Focus": return [{
kind: "apply-aura",
recipient: "caster",
aura: {
id: "wow335:priest:inner-focus",
name: abilityName,
disposition: "buff",
durationMs: 30_000,
maxStacks: 1,
stackBehavior: "refresh",
utilityTags: ["next-heal-free-critical"],
},
sourceEffectType: 6,
sourceAuraType: 107,
miscValue: 0,
triggerSpellId: 0,
}];
case "priest:Prayer of Mending": return [{
kind: "apply-aura",
aura: {
@@ -1241,6 +1285,9 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|| (classId === "druid" && abilityName === "Swiftmend" && effect.kind === "heal")
|| (classId === "warrior" && abilityName === "Bloodrage"
&& (effect.kind === "resource" || effect.kind === "heal"))
|| (classId === "priest" && abilityName === "Inner Focus" && effect.kind === "apply-aura")
|| (classId === "rogue" && abilityName === "Envenom" && effect.kind === "apply-aura")
|| (classId === "rogue" && abilityName === "Vanish" && effect.kind === "movement")
))
.map((effect) => effect.kind === "utility" ? effect : effect);
const result = scriptedFallbacks.length
@@ -1248,9 +1295,23 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
: executable;
let finalized = result;
if (DIRECT_COMBO_FINISHERS.has(abilityName)) {
const directDamage = rank.effects.find((effect) => effect.effectType === 2);
const finisher: Extract<AbilityEffect, { kind: "finisher-damage" }> = abilityName === "Envenom"
? {
kind: "finisher-damage",
baseCoefficient: 0,
perComboCoefficient: 0,
poison: {
doseAuraTag: ROGUE_DEADLY_POISON_DOSE_TAG,
maximumDoses: 5,
basePointsPerDose: Math.max(1, directDamage?.basePoints ?? 1),
attackPowerCoefficientPerDose: 0.09,
},
}
: { kind: "finisher-damage", baseCoefficient: 0.55, perComboCoefficient: 0.45 };
finalized = [
...result.filter((effect) => effect.kind !== "damage" && effect.kind !== "finisher-damage"),
{ kind: "finisher-damage", baseCoefficient: 0.55, perComboCoefficient: 0.45 },
finisher,
];
}
if (COMBO_FINISHERS.has(abilityName) && !finalized.some((effect) => effect.kind === "finisher-damage")) {
+25 -1
View File
@@ -93,6 +93,26 @@ function buildIdentity(build: SelectableBenchmarkBuildCatalogEntry): string {
return `${build.source}:${build.classId}:${build.role}:${build.orientation}:${build.talentBuildId ?? "default"}`;
}
function randomPartyCapacity(builds: readonly SelectableBenchmarkBuildCatalogEntry[]): number {
const defaults = builds.filter((build) => build.defaultTalentBuild);
const pool = defaults.length > 0 ? defaults : builds;
const tanks = pool.filter((build) => build.role === "tank").length;
const healers = pool.filter((build) => build.role === "healer").length;
const damage = pool.filter((build) => build.role === "damage").length;
return damage < 3 ? 0 : tanks * healers * (damage * (damage - 1) * (damage - 2)) / 6;
}
function cappedRandomPartyCount(
builds: readonly SelectableBenchmarkBuildCatalogEntry[],
requestedPerSource: number,
): number {
const bySource = new Map<string, SelectableBenchmarkBuildCatalogEntry[]>();
for (const build of builds) bySource.set(build.source, [...(bySource.get(build.source) ?? []), build]);
return [...bySource.values()].reduce((total, rows) => (
total + Math.min(requestedPerSource, randomPartyCapacity(rows))
), 0);
}
export function BalanceLabScreen({
onClose,
executionTarget = "server",
@@ -178,8 +198,9 @@ export function BalanceLabScreen({
? selectedBossKeys.length
: BOSS_TESTING_CATALOG.filter((boss) => bossScope === "all" || boss.category === bossScope).length;
const coverageBuilds = specSelection === "all" ? automaticBuilds : selectedBuilds;
const requestedRandomPartyCount = partyConfigurations * (source === "all" ? 3 : 1);
const partyCount = specSelection === "random"
? partyConfigurations * (source === "all" ? 3 : 1)
? cappedRandomPartyCount(automaticBuilds, partyConfigurations)
: coveragePartyCount(coverageBuilds);
const coveredSpecCount = specSelection === "all" ? automaticBuilds.length : selectedBuilds.length;
const estimatedRunsPerLevel = partyCount * (seatSweep ? 5 : 1) * bossCount * simulationsPerParty;
@@ -409,6 +430,9 @@ export function BalanceLabScreen({
? `${coveredSpecCount} class/spec builds will be covered at least once across the minimum valid party set.`
: `${coveredSpecCount} selected class/spec builds will be covered; unselected specs fill required party roles.`}
</p>}
{specSelection === "random" && partyCount < requestedRandomPartyCount && <p className="balance-lab-coverage-note">
The eligible pools provide {partyCount.toLocaleString()} unique party configuration{partyCount === 1 ? "" : "s"}. Each will run once; identical parties will not be duplicated to reach the requested {requestedRandomPartyCount.toLocaleString()}.
</p>}
{specSelection === "specific" && (
<section className="balance-lab-boss-picker balance-lab-spec-picker">
<header>