forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
f864b5aea7
commit
fdb6739f8d
10 files changed
+236
-16
No files matched your search
@@ -5,14 +5,14 @@ import type {
|
||||
AbilityTargetingRules,
|
||||
} from "./abilityCatalog";
|
||||
|
||||
const CHAIN_ABILITIES = new Set(["chain heal", "chain lightning", "prayer of mending"]);
|
||||
const CHAIN_ABILITIES = new Set(["chain heal", "chain lightning", "multi-shot", "prayer of mending"]);
|
||||
const GROUND_ABILITIES = new Set([
|
||||
"blizzard", "rain of fire", "flamestrike", "death and decay", "consecration",
|
||||
"volley", "hurricane", "earthquake", "meteor", "healing rain",
|
||||
]);
|
||||
const CONE_ABILITIES = new Set([
|
||||
"cone of cold", "dragon's breath", "dragons breath", "shockwave", "cleave",
|
||||
"swipe", "swipe (cat)", "fan of knives", "whirlwind",
|
||||
"swipe", "swipe (cat)", "whirlwind",
|
||||
]);
|
||||
const BEHIND_ABILITIES = new Set(["backstab", "ambush", "garrote", "ravage", "shred", "pounce"]);
|
||||
const MOVEMENT_CAST_ABILITIES = new Set(["scorch", "steady shot", "spiritwalker's grace"]);
|
||||
@@ -62,7 +62,7 @@ export function targetingRulesForAbility(ability: AbilityDefinition): AbilityTar
|
||||
requiresBehindTarget: hostile && BEHIND_ABILITIES.has(name),
|
||||
attackArcRadians: shape === "cone" ? Math.PI / 2 : Math.PI,
|
||||
...(ability.radius ? { radius: ability.radius } : {}),
|
||||
...(shape === "chain" ? { maximumTargets: 3, jumpRange: 12 } : {}),
|
||||
...(shape === "chain" ? { maximumTargets: 3, jumpRange: name === "multi-shot" ? 10 : 12 } : {}),
|
||||
...(shape === "ground" ? { groundRange: ability.range.max > 0 ? ability.range.max : name.endsWith(" trap") || name === "consecration" ? 0 : 30, radius: ability.radius ?? 8 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -209,9 +209,9 @@ describe("shared combat rotations", () => {
|
||||
const contracts = [
|
||||
{ classId: "warrior" as const, specialization: "Protection", id: "warrior:protection", actions: ["Defensive Stance", "Taunt", "Shield Bash", "Shield Slam", "Revenge", "Devastate", "Thunder Clap", "Shield Block", "Shield Wall", "Last Stand", "Enraged Regeneration"] },
|
||||
{ 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", "Shiv", "Rupture", "Envenom", "Cold Blood", "Kick", "Tricks of the Trade", "Evasion", "Cloak of Shadows", "Vanish"] },
|
||||
{ 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", "Multi-Shot", "Volley", "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", "Flamestrike", "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", "Fan of Knives", "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)!;
|
||||
@@ -432,6 +432,45 @@ describe("shared combat rotations", () => {
|
||||
.toBe("Envenom");
|
||||
});
|
||||
|
||||
it("uses Ready DPS area actions only at their authored pack thresholds", () => {
|
||||
const multiShot = ability("hunter", "Multi-Shot");
|
||||
const volley = ability("hunter", "Volley");
|
||||
const hunter = context({
|
||||
classId: "hunter",
|
||||
specializationId: "hunter:marksmanship",
|
||||
primaryResource: "mana",
|
||||
resources: { mana: 10_000 },
|
||||
maximumResources: { mana: 10_000 },
|
||||
abilities: [volley, multiShot],
|
||||
});
|
||||
expect(evaluateCombatRotation({ ...hunter, hostileCount: 1 })).toBeNull();
|
||||
expect(evaluateCombatRotation({ ...hunter, hostileCount: 2 })?.ability.name).toBe("Multi-Shot");
|
||||
expect(evaluateCombatRotation({ ...hunter, hostileCount: 3 })?.ability.name).toBe("Volley");
|
||||
|
||||
const flamestrike = ability("mage", "Flamestrike");
|
||||
const fire = context({
|
||||
classId: "mage",
|
||||
specializationId: "mage:fire",
|
||||
primaryResource: "mana",
|
||||
resources: { mana: 10_000 },
|
||||
maximumResources: { mana: 10_000 },
|
||||
abilities: [flamestrike],
|
||||
});
|
||||
expect(evaluateCombatRotation({ ...fire, hostileCount: 2 })).toBeNull();
|
||||
expect(evaluateCombatRotation({ ...fire, hostileCount: 3 })?.ability.name).toBe("Flamestrike");
|
||||
|
||||
const fan = ability("rogue", "Fan of Knives");
|
||||
const assassination = context({
|
||||
specializationId: "rogue:assassination",
|
||||
primaryResource: "energy",
|
||||
resources: { energy: 100 },
|
||||
maximumResources: { energy: 100 },
|
||||
abilities: [fan],
|
||||
});
|
||||
expect(evaluateCombatRotation({ ...assassination, hostileCount: 2 })).toBeNull();
|
||||
expect(evaluateCombatRotation({ ...assassination, hostileCount: 3 })?.ability.name).toBe("Fan of Knives");
|
||||
});
|
||||
|
||||
it("reacts before throughput and conserves long offensive cooldowns for durable targets", () => {
|
||||
const kick = ability("rogue", "Kick");
|
||||
const strike = ability("rogue", "Sinister Strike");
|
||||
|
||||
@@ -111,9 +111,9 @@ export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.free
|
||||
profile("paladin:protection", "paladin", ["Devotion Aura", "Blessing of Might", "Seal of Vengeance", "Seal of Corruption", "Seal of Wisdom", "Seal of Righteousness", "Hammer of the Righteous", "Shield of Righteousness", "Holy Shield", "Consecration", "Judgement of Wisdom"]),
|
||||
profile("paladin:retribution", "paladin", ["Retribution Aura", "Devotion Aura", "Blessing of Might", "Seal of Command", "Seal of Vengeance", "Seal of Corruption", "Seal of Righteousness", "Divine Storm", "Crusader Strike", "Judgement of Wisdom", "Consecration", "Exorcism", "Hammer of Wrath"]),
|
||||
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:marksmanship", "hunter", ["Aspect of the Dragonhawk", "Aspect of the Hawk", "Call Pet", "Misdirection", "Rapid Fire", "Readiness", "Kill Shot", "Volley", "Multi-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", "Shiv", "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", "Fan of Knives", "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"]),
|
||||
@@ -126,7 +126,7 @@ export const CLASSIC_ROTATION_PROFILES: readonly RotationProfile[] = Object.free
|
||||
profile("shaman:enhancement", "shaman", ["Windfury Weapon", "Rockbiter Weapon", "Lightning Shield", "Feral Spirit", "Stormstrike", "Lava Lash", "Earth Shock", "Lightning Bolt"]),
|
||||
profile("shaman:restoration", "shaman", ["Water Shield", "Rockbiter Weapon", "Lightning Shield", "Riptide", "Chain Heal", "Lesser Healing Wave", "Healing Wave", "Earth Shield"]),
|
||||
profile("mage:arcane", "mage", ["Mage Armor", "Ice Armor", "Frost Armor", "Arcane Barrage", "Arcane Blast", "Arcane Missiles"]),
|
||||
profile("mage:fire", "mage", ["Molten Armor", "Combustion", "Mirror Image", "Living Bomb", "Fireball", "Scorch", "Fire Blast", "Counterspell", "Blink", "Ice Block", "Fire Ward", "Frost Ward"]),
|
||||
profile("mage:fire", "mage", ["Molten Armor", "Combustion", "Mirror Image", "Flamestrike", "Living Bomb", "Fireball", "Scorch", "Fire Blast", "Counterspell", "Blink", "Ice Block", "Fire Ward", "Frost Ward"]),
|
||||
profile("mage:frost", "mage", ["Frost Armor", "Ice Armor", "Mage Armor", "Deep Freeze", "Frostbolt", "Ice Lance", "Cone of Cold"]),
|
||||
profile("warlock:affliction", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felhunter", "Summon Voidwalker", "Summon Imp", "Haunt", "Unstable Affliction", "Corruption", "Curse of Agony", "Drain Soul", "Shadow Bolt"]),
|
||||
profile("warlock:demonology", "warlock", ["Fel Armor", "Demon Armor", "Demon Skin", "Summon Felguard", "Summon Voidwalker", "Summon Imp", "Metamorphosis", "Immolate", "Corruption", "Shadow Bolt"]),
|
||||
@@ -162,6 +162,17 @@ const TARGET_HEALTH_GATES = new Map<string, number>([
|
||||
["Kill Shot", 0.2],
|
||||
]);
|
||||
|
||||
const MINIMUM_HOSTILE_TARGETS = new Map<string, number>([
|
||||
["Multi-Shot", 2],
|
||||
["Volley", 3],
|
||||
["Flamestrike", 3],
|
||||
["Fan of Knives", 3],
|
||||
]);
|
||||
|
||||
export function minimumHostileTargetsForAbility(abilityName: string): number {
|
||||
return MINIMUM_HOSTILE_TARGETS.get(abilityName) ?? 1;
|
||||
}
|
||||
|
||||
const BEAR_FORM_ACTION_NAMES = new Set([
|
||||
"Bash",
|
||||
"Challenging Roar",
|
||||
@@ -387,6 +398,7 @@ function scoreAbility(
|
||||
|
||||
const targetHealthGate = TARGET_HEALTH_GATES.get(ability.name);
|
||||
if (targetHealthGate !== undefined && targetHealth > targetHealthGate) return null;
|
||||
if ((context.hostileCount ?? 1) < minimumHostileTargetsForAbility(ability.name)) return null;
|
||||
if (CONDITIONAL_ABILITY_NAMES.has(ability.name)
|
||||
&& !context.availableConditionalAbilityNames?.has(ability.name)) return null;
|
||||
if (context.classId === "druid") {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { clearPartyRuntimeRegistry, registerPartyRuntimePosition, updatePartyRun
|
||||
import { advanceDungeonPartyCombat, partyUsesRangedAttacks, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { subscribeCombatPresentation } from "./combatPresentation";
|
||||
import { abilitiesForClass } from "./abilityCatalog";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
|
||||
function partyTemplates(level: number): PartyMember[] {
|
||||
const templates = new Map<string, PartyMember>();
|
||||
@@ -76,6 +77,66 @@ describe("NPC sustained damage audit", () => {
|
||||
expect(useCombatStore.getState().mobs["area:c"].health).toBe(1_000);
|
||||
});
|
||||
|
||||
for (const row of [
|
||||
{ classId: "hunter" as const, specialization: "Marksmanship", specializationId: "hunter:marksmanship", abilityName: "Multi-Shot", targets: 2 },
|
||||
{ classId: "hunter" as const, specialization: "Marksmanship", specializationId: "hunter:marksmanship", abilityName: "Volley", targets: 3 },
|
||||
{ classId: "mage" as const, specialization: "Fire", specializationId: "mage:fire", abilityName: "Flamestrike", targets: 3 },
|
||||
{ classId: "rogue" as const, specialization: "Assassination", specializationId: "rogue:assassination", abilityName: "Fan of Knives", targets: 3 },
|
||||
]) {
|
||||
it(`${row.specialization} ${row.abilityName} damages its ${row.targets}-target Ready threshold`, () => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
const member = createConfiguredPartyMember({
|
||||
name: `${row.specialization} area test`,
|
||||
classId: row.classId,
|
||||
specialization: row.specialization,
|
||||
specializationId: row.specializationId,
|
||||
talentTreeId: `${row.classId}-${row.specialization.toLowerCase()}`,
|
||||
talentRanks: runtimePartyTalentRanksForLevel(row.classId, row.specialization, 80),
|
||||
role: "damage",
|
||||
}, 80, 0, `${row.classId}-${row.abilityName}-area`);
|
||||
const ability = abilitiesForClass(row.classId).find((candidate) => candidate.name === row.abilityName)!;
|
||||
usePartyStore.getState().initializeParty(
|
||||
`${row.classId}-${row.abilityName}-area`,
|
||||
80,
|
||||
useCombatStore.getState().health,
|
||||
"damage",
|
||||
);
|
||||
usePartyStore.setState({
|
||||
members: [{
|
||||
...member,
|
||||
cooldowns: Object.fromEntries(abilitiesForClass(row.classId)
|
||||
.filter((candidate) => candidate.id !== ability.id)
|
||||
.map((candidate) => [candidate.id, 60_000])),
|
||||
}],
|
||||
});
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
const targetIds = Array.from({ length: row.targets }, (_, index) => `area-ready-${index}`);
|
||||
for (const [index, id] of targetIds.entries()) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0 });
|
||||
setMobPosition(id, [index * 2, 0, 0]);
|
||||
useCombatStore.getState().damageMob(id, 1, 100);
|
||||
}
|
||||
updatePartyRuntimeTargetVisibility(member.id, targetIds[0], true);
|
||||
|
||||
for (let now = 1_000; now <= 12_000; now += 100) {
|
||||
useCombatStore.getState().tick(0.1, now);
|
||||
advanceDungeonPartyCombat(now);
|
||||
}
|
||||
|
||||
const hitTargets = new Set(useCombatStore.getState().combatEvents
|
||||
.filter((event) => event.kind === "damage"
|
||||
&& event.sourceActorId === member.id
|
||||
&& event.abilityId === ability.id
|
||||
&& event.effectiveAmount > 0)
|
||||
.map((event) => event.targetActorId));
|
||||
expect(hitTargets).toEqual(new Set(targetIds));
|
||||
});
|
||||
}
|
||||
|
||||
it("generates Bear-form Rage from weapon hits and regenerates Cat-form Energy", () => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
|
||||
@@ -479,6 +479,52 @@ describe("party combat runtime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("taunts a loose add off an ally while the rest of the party stays on the boss", () => {
|
||||
const party = usePartyStore.getState();
|
||||
const tank = party.members.find((member) => member.role === "tank")!;
|
||||
const damage = party.members.find((member) => member.role === "damage")!;
|
||||
for (const member of party.members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
|
||||
useCombatStore.getState().registerMob("boss-with-add", {
|
||||
name: "Boss with add",
|
||||
boss: true,
|
||||
maxHealth: 10_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
useCombatStore.getState().registerMob("boss-loose-add", {
|
||||
name: "Loose add",
|
||||
maxHealth: 1_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition("boss-with-add", [2, 0, 0]);
|
||||
setMobPosition("boss-loose-add", [8, 0, 0]);
|
||||
useCombatStore.getState().damageMob("boss-with-add", 1, 100, {
|
||||
actorId: tank.id,
|
||||
role: "tank",
|
||||
});
|
||||
useCombatStore.getState().damageMob("boss-loose-add", 1, 100, {
|
||||
actorId: damage.id,
|
||||
role: "damage",
|
||||
});
|
||||
useCombatStore.getState().selectMob("boss-with-add");
|
||||
for (const member of party.members) {
|
||||
updatePartyRuntimeTargetVisibility(member.id, "boss-with-add", true);
|
||||
updatePartyRuntimeTargetVisibility(member.id, "boss-loose-add", true);
|
||||
}
|
||||
useCombatStore.getState().advanceMobCombat(200);
|
||||
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
const runtime = usePartyStore.getState();
|
||||
expect(runtime.memberTargetIds[tank.id]).toBe("boss-loose-add");
|
||||
expect(runtime.memberTargetIds[damage.id]).toBe("boss-with-add");
|
||||
expect(useCombatStore.getState().mobs["boss-loose-add"].forcedTarget).toMatchObject({
|
||||
actorId: tank.id,
|
||||
endsAt: 4_000,
|
||||
});
|
||||
expect(runtime.members.find((member) => member.id === tank.id)?.tauntReadyAt).toBe(9_000);
|
||||
});
|
||||
|
||||
it("shares an eight-second tank taunt cooldown across loose mobs", () => {
|
||||
const party = usePartyStore.getState();
|
||||
const tank = party.members.find((member) => member.role === "tank")!;
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
commitPartyAbilityUse,
|
||||
partyAbilityAmount,
|
||||
} from "./partyAbilityAi";
|
||||
import { minimumHostileTargetsForAbility } from "./combatRotation";
|
||||
import { emitRotationDecision } from "./rotationTelemetry";
|
||||
import { imminentCooldownMechanic } from "./companionCooldownPolicy";
|
||||
import { normalizeActorResourcePool } from "./combatActors";
|
||||
@@ -799,7 +800,7 @@ function partyHostileTargets(
|
||||
for (const id of ordered) {
|
||||
if (selected.length >= (targeting.maximumTargets ?? 3)) break;
|
||||
const position = getMobPosition(id);
|
||||
if (!position || combat.mobs[id]?.dead) continue;
|
||||
if (!position || !combat.mobs[id]?.engaged || combat.mobs[id]?.dead) continue;
|
||||
if (selected.length > 0 && distanceSquared(previous, position) > (targeting.jumpRange ?? 12) ** 2) continue;
|
||||
if (!combatHasLineOfSight(previous, position)) continue;
|
||||
selected.push(id);
|
||||
@@ -812,7 +813,7 @@ function partyHostileTargets(
|
||||
return Object.values(combat.mobs)
|
||||
.filter((mob) => {
|
||||
const position = getMobPosition(mob.id);
|
||||
return !mob.dead && Boolean(position
|
||||
return mob.engaged && !mob.dead && Boolean(position
|
||||
&& distanceSquared(origin, position) <= (targeting.radius ?? ability.radius ?? ability.range.max) ** 2
|
||||
&& combatHasLineOfSight(origin, position)
|
||||
&& (!memberSpatial || isPositionInFacingArc(memberSpatial, position, targeting.attackArcRadians ?? Math.PI / 2)));
|
||||
@@ -826,7 +827,7 @@ function partyHostileTargets(
|
||||
return Object.values(combat.mobs)
|
||||
.filter((mob) => {
|
||||
const position = getMobPosition(mob.id);
|
||||
return !mob.dead && Boolean(position
|
||||
return mob.engaged && !mob.dead && Boolean(position
|
||||
&& distanceSquared(center, position) <= radius * radius
|
||||
&& combatHasLineOfSight(center, position));
|
||||
})
|
||||
@@ -1824,7 +1825,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
const abilityTargetId = (ability.target === "self" && !hostileSelfArea) || ability.target === "friendly"
|
||||
? member.id
|
||||
: memberTargetId;
|
||||
const minimumHostiles = minimumHostileTargetsForAbility(ability.name);
|
||||
return partyAbilityTargetIsValid(runtimeMember, ability, abilityTargetId)
|
||||
&& (minimumHostiles <= 1 || partyHostileTargets(
|
||||
ability,
|
||||
abilityTargetId,
|
||||
memberPosition,
|
||||
member.id,
|
||||
).length >= minimumHostiles)
|
||||
&& (ability.name !== "Envenom" || matchingAuraStacks(
|
||||
currentCombat.auras,
|
||||
ROGUE_DEADLY_POISON_DOSE_TAG,
|
||||
@@ -1845,6 +1853,8 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
// 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)
|
||||
&& !currentCombat.areaEffects.some((effect) => effect.sourceActorId === member.id
|
||||
&& effect.sourceAbilityId === ability.id && effect.expiresAt > now)
|
||||
&& !ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& currentCombat.auras.some((aura) => aura.sourceId === member.id
|
||||
&& aura.definition.id === effect.aura.id && aura.targetId === abilityTargetId));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilityAtLevel, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
|
||||
import { aiHintsForAbility, targetingRulesForAbility } from "./abilityRules";
|
||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||
|
||||
function byId(id: string): AbilityDefinition {
|
||||
@@ -41,6 +42,28 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps the Ready DPS area actions to executable multi-target shapes", () => {
|
||||
const multiShot = abilityAtLevel(byId("wow335-hunter-multi-shot"), 80);
|
||||
expect(targetingRulesForAbility(multiShot)).toMatchObject({
|
||||
shape: "chain",
|
||||
maximumTargets: 3,
|
||||
jumpRange: 10,
|
||||
});
|
||||
expect(aiHintsForAbility(multiShot).categories).toContain("area-damage");
|
||||
|
||||
const volley = abilityAtLevel(byId("wow335-hunter-volley"), 80);
|
||||
expect(volley).toMatchObject({ target: "hostile", castMode: "channel", castTimeMs: 6_000, radius: 8 });
|
||||
expect(targetingRulesForAbility(volley).shape).toBe("ground");
|
||||
expect(effectsOfKind(volley, "dot")).toEqual([
|
||||
expect.objectContaining({ ticks: 6, intervalMs: 1_000, tag: "volley" }),
|
||||
]);
|
||||
|
||||
const fan = abilityAtLevel(byId("wow335-rogue-fan-of-knives"), 80);
|
||||
expect(fan).toMatchObject({ target: "self", radius: 8 });
|
||||
expect(targetingRulesForAbility(fan).shape).toBe("circle");
|
||||
expect(aiHintsForAbility(fan).categories).toContain("area-damage");
|
||||
});
|
||||
|
||||
it("gives every imported classic rank executable or explicit utility semantics", () => {
|
||||
const effects = Object.values(WOW335_ABILITIES_BY_CLASS)
|
||||
.flat()
|
||||
|
||||
@@ -793,6 +793,20 @@ function classScriptEffects(
|
||||
case "hunter:Freezing Arrow": return [{ kind: "status", status: "freeze", durationMs: 10_000 }];
|
||||
case "hunter:Feign Death": return [{ kind: "threat", mode: "percent", amount: -100 }];
|
||||
case "hunter:Readiness": return [{ kind: "cooldown-reset", mode: "reset" }];
|
||||
// Volley's player-facing spell is a six-second channel aura whose Arcane
|
||||
// pulses live in same-rank triggered child rows. Keep the channel/cost but
|
||||
// make the six ground-area pulses executable by players and companions.
|
||||
case "hunter:Volley": {
|
||||
const pulse = rank.effects.find((effect) => effect.effectType === 27);
|
||||
return pulse ? [{
|
||||
kind: "dot",
|
||||
coefficient: fallbackCoefficient(pulse, rank),
|
||||
...amountScaling(pulse, rank),
|
||||
ticks: 6,
|
||||
intervalMs: 1_000,
|
||||
tag: "volley",
|
||||
}] : [];
|
||||
}
|
||||
case "rogue:Shiv": return [{ kind: "damage", coefficient: 1 }];
|
||||
case "rogue:Killing Spree": return [{
|
||||
kind: "dot", coefficient: 0.5, ticks: 5, intervalMs: 500, tag: "killing-spree",
|
||||
@@ -1286,6 +1300,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
||||
|| (classId === "warrior" && abilityName === "Bloodrage"
|
||||
&& (effect.kind === "resource" || effect.kind === "heal"))
|
||||
|| (classId === "priest" && abilityName === "Inner Focus" && effect.kind === "apply-aura")
|
||||
|| (classId === "hunter" && abilityName === "Volley" && effect.kind === "apply-aura")
|
||||
|| (classId === "rogue" && abilityName === "Envenom" && effect.kind === "apply-aura")
|
||||
|| (classId === "rogue" && abilityName === "Vanish" && effect.kind === "movement")
|
||||
))
|
||||
@@ -1489,7 +1504,8 @@ function abilityNameRadius(abilityName: string): number {
|
||||
}
|
||||
|
||||
function abilityNameTarget(abilityName: string): AbilityTarget | undefined {
|
||||
if (["Bladestorm", "Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead"].includes(abilityName)) return "self";
|
||||
if (["Bladestorm", "Divine Hymn", "Tranquility", "Holy Nova", "Hellfire", "Army of the Dead", "Fan of Knives"].includes(abilityName)) return "self";
|
||||
if (abilityName === "Volley") return "hostile";
|
||||
if (abilityName === "Penance") return "friendly";
|
||||
if (abilityName === "Death Coil") return "hostile";
|
||||
return undefined;
|
||||
|
||||
Reference in new issue
Block a user