Update Healer Man server
This commit is contained in:
1 parent
1fedb9368d
commit
cc3b2fa4d5
27 files changed
+1988
-83
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user