Update Healer Man server

This commit is contained in:
phenom committed 2026-09-30 13:25:55 -04:00
1 parent 4fb7a0b9c8
commit 4c768fbec6
24 files changed
+654 -22

No files matched your search

+2 -1
View File
@@ -93,11 +93,12 @@ export function CombatBridge() {
state.level !== previousState.level
|| state.xp !== previousState.xp
|| state.talentRanks !== previousState.talentRanks
|| state.glyphIds !== previousState.glyphIds
) {
// Kills and talent changes are infrequent, so persist immediately. This
// also guarantees a fast return to Character Select cannot outrun a
// deferred save and lose the last level or talent point.
shell.saveActiveProgression(state.level, state.xp, state.talentRanks);
shell.saveActiveProgression(state.level, state.xp, state.talentRanks, state.glyphIds);
if (state.level !== previousState.level) useCombatStore.getState().refreshResolvedGear();
}
+1 -1
View File
@@ -162,7 +162,7 @@ export interface AbilityDamageResource {
}
export type AbilityEffect =
| ({ readonly kind: "damage"; readonly coefficient: number; readonly school?: DamageSchool; readonly falloffPerJump?: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource)
| ({ readonly kind: "damage"; readonly coefficient: number; readonly school?: DamageSchool; readonly weaponHand?: "main-hand" | "off-hand"; readonly falloffPerJump?: number; readonly bonusPerSunderStack?: number; readonly bonusFlat?: number } & AbilityAmountScaling & AbilityDamageResource)
| {
readonly kind: "finisher-damage";
readonly baseCoefficient: number;
+60 -7
View File
@@ -157,6 +157,7 @@ import {
wow335DruidFeralInstinctEnrageAura,
wow335DruidFeralInstinctTigerAura,
} from "./wow335DruidTalentRuntime";
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
import {
WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG,
WOW335_WARLOCK_METAMORPHOSIS_IMMOLATION_TAG,
@@ -922,6 +923,7 @@ export interface CharacterCombatSeed {
/** Backward-compatible profile field accepted by the integration bridge. */
readonly experience?: number;
readonly talentRanks?: TalentRanks;
readonly glyphIds?: readonly string[];
readonly actionBindings?: unknown;
readonly inventory?: unknown;
readonly bossCards?: unknown;
@@ -1084,6 +1086,7 @@ export interface CombatState {
lastCombatResult: CombatResult | null;
bossLootRewards: readonly BossLootReward[];
talentRanks: TalentRanks;
glyphIds: readonly string[];
talentModifiers: readonly TalentModifier[];
actionBindings: ActionBindings;
inventory: readonly InventoryItem[];
@@ -1181,6 +1184,7 @@ export interface CombatState {
readonly preserveDefeatedIds?: readonly string[];
}) => void;
allocateTalent: (treeId: string) => boolean;
setGlyphActive: (glyphId: string, active: boolean) => boolean;
resetTalents: () => void;
setActionBinding: (layer: ActionBindingLayer, control: ActionBindingControl, abilityId: string | null) => boolean;
resetActionBindingLayer: (layer: ActionBindingLayer) => void;
@@ -1763,6 +1767,7 @@ function talentAdjustedMaxHealth(
function abilityAttackKind(classId: ClassId, ability: AbilityDefinition): WotlkAttackKind {
if (romWarriorPhysicalWeaponTooltip(ability)) return "melee";
if (ability.effects.some((effect) => effect.kind === "damage" && effect.weaponHand)) return "melee";
if (ability.source === "runewaker") {
const sourceHit = ability.effects.find((effect) => effect.kind === "damage" || effect.kind === "dot");
if (sourceHit && (sourceHit.kind === "damage" || sourceHit.kind === "dot") && sourceHit.school && sourceHit.school !== "physical") {
@@ -7818,6 +7823,7 @@ function initialState() {
lastCombatResult: null,
bossLootRewards: [] as readonly BossLootReward[],
talentRanks,
glyphIds: [],
talentModifiers,
actionBindings: defaultActionBindings(defaultActionBarForClass(classId)),
inventory: [] as readonly InventoryItem[],
@@ -7843,6 +7849,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const progression = normalizeProgression(profile.level ?? 1, profile.xp ?? profile.experience ?? 0);
const resource = resourceProfileForClass(classId);
const talentRanks = normalizeTalentRanks(classId, progression.level, profile.talentRanks ?? {});
const glyphIds = classId === "druid" && profile.glyphIds?.includes(WOW335_GLYPH_OF_SWIFTMEND_ID)
? [WOW335_GLYPH_OF_SWIFTMEND_ID]
: [];
const talentModifiers = characterStatModifiers(classId, progression.level, talentRanks);
const abilities = abilitiesForCharacter(classId, secondaryClassId);
const inventory = normalizeInventory(profile.inventory);
@@ -7898,6 +7907,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
deathKnightRunes: classId === "death-knight" ? createDeathKnightRunes() : [],
auras: syncPassiveAuras([], abilities, progression.level, talentRanks, combatNow()),
talentRanks,
glyphIds,
talentModifiers,
actionBindings: withoutUnlearnedTalentBindings(
normalizeActionBindings(profile.actionBindings, defaultActionBarForCharacter(classId, secondaryClassId, progression.level)),
@@ -10243,6 +10253,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const shamanLavaLashOffhandSpeedMs = state.classId === "shaman" && ability.name === "Lava Lash"
? playerEquippedMeleeWeapon(state, "off-hand")?.speedMs
: undefined;
const shamanStormstrikeOffhandWeapon = state.classId === "shaman" && ability.name === "Stormstrike"
? playerEquippedMeleeWeapon(state, "off-hand")
: null;
let shamanTidalWavesProcAttempted = false;
const combatEventIdBeforeAbilityEffects = work.combatEvents.at(-1)?.id ?? 0;
let suddenDoomTrigger: ReturnType<typeof wow335DeathKnightSuddenDoomTriggerForLandedHit> = null;
@@ -10267,6 +10280,20 @@ export const useCombatStore = create<CombatState>((set, get) => ({
)
) continue;
if (effect.kind === "damage") {
const weaponHitProfile = effect.weaponHand === "main-hand"
? playerAutoAttackProfile(
state,
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
playerEquippedMeleeWeapon(state, "main-hand") ?? undefined,
)
: effect.weaponHand === "off-hand" && shamanStormstrikeOffhandWeapon
? playerOffhandAutoAttackProfile(
state,
shamanStormstrikeOffhandWeapon,
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID),
)
: null;
if (effect.weaponHand && !weaponHitProfile) continue;
const amount = talentAdjustedAmount(
(() => {
const conflagrateDot = state.classId === "warlock" && ability.name === "Conflagrate"
@@ -10274,7 +10301,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: null;
const sourceAbility = conflagrateDot ? abilityById(conflagrateDot.sourceAbilityId) : null;
const burstSeconds = sourceAbility?.name === "Immolate" ? 12 : 8;
const baseDamage = conflagrateDot && sourceAbility
const baseDamage = weaponHitProfile
? weaponHitProfile.damage * effect.coefficient
: conflagrateDot && sourceAbility
? (conflagrateDot.fixedAmount ?? equipmentAdjustedCombatAmount(
conflagrateDot,
work.progression.level,
@@ -10326,13 +10355,19 @@ export const useCombatStore = create<CombatState>((set, get) => ({
castThreatSource,
effectSchool === "physical" ? attackKind : "spell",
effectSchool,
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct`,
`${castThreatSource.actorId}:${ability.id}:${id}:${now}:direct${effect.weaponHand ? `:${effect.weaponHand}` : ""}`,
ability,
false,
ability.name === "Shiv" || (consumesSurgeOfLight && ability.name === "Smite")
? { canCrit: false } : {},
mageArcanePotencyCriticalChanceBonusPercent,
shamanLavaLashOffhandSpeedMs,
effect.weaponHand === "main-hand"
? playerEquippedMeleeWeapon(state, "main-hand")?.speedMs
?? work.gearStats.meleeWeapon?.speedMs
?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS
: effect.weaponHand === "off-hand"
? shamanStormstrikeOffhandWeapon?.speedMs
: shamanLavaLashOffhandSpeedMs,
);
if (state.classId === "death-knight"
&& effect.kind === "damage"
@@ -10678,7 +10713,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
healingTargetHealth,
healingTargetMaxHealth,
);
if (swiftmendHot) {
if (swiftmendHot && !state.glyphIds.includes(WOW335_GLYPH_OF_SWIFTMEND_ID)) {
work.effects = work.effects.filter((candidate) => candidate.id !== swiftmendHot.id);
}
const riptideAdjustedAmount = shamanRiptideHot
@@ -16405,10 +16440,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
&& (!autoAttackSource || isPositionInFacingArc(autoAttackSource, autoAttackTargetPosition ?? state.playerPosition, Math.PI))
);
if (canAutoAttack) {
const mainHandWeapon = state.classId === "rogue"
const supportsPlayerOffhandSwings = state.classId === "rogue" || state.classId === "shaman";
const mainHandWeapon = supportsPlayerOffhandSwings
? playerEquippedMeleeWeapon(state, "main-hand")
: null;
const offHandWeapon = state.classId === "rogue"
const offHandWeapon = supportsPlayerOffhandSwings
? playerEquippedMeleeWeapon(state, "off-hand")
: null;
const profile = playerAutoAttackProfile(
@@ -16433,6 +16469,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}
if (now >= nextAutoAttackAt) {
const isDualWielding = Boolean(offHandWeapon);
const mainHandSpeedMs = mainHandWeapon?.speedMs
?? state.gearStats.meleeWeapon?.speedMs
?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS;
const autoAttackSchool = mainHandWeapon?.damageSchool
?? (offHandWeapon ? "physical" : state.gearStats.meleeWeapon?.damageSchool)
?? "physical";
@@ -16449,6 +16488,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
undefined,
false,
{ isAutoAttack: true, isDualWielding },
0,
mainHandSpeedMs,
);
let autoAttackKilled = result.killed;
const enchantedThrowActive = work.auras.some((aura) => (
@@ -16542,7 +16583,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
nextOffhandAutoAttackAt = 0;
}
}
if (state.classId !== "rogue" || !offHandWeapon || !autoAttackTargetId) {
if (!supportsPlayerOffhandSwings || !offHandWeapon || !autoAttackTargetId) {
nextOffhandAutoAttackAt = 0;
} else if (now >= nextOffhandAutoAttackAt) {
const offhandProfile = playerOffhandAutoAttackProfile(
@@ -16565,6 +16606,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
undefined,
false,
{ isAutoAttack: true, isDualWielding: true },
0,
offHandWeapon.speedMs,
);
if (result.combatResult?.landed) {
maybeProcRogueCombatPotencyInWork(work, offhandSeed, now);
@@ -17910,6 +17953,16 @@ export const useCombatStore = create<CombatState>((set, get) => ({
};
}),
setGlyphActive: (glyphId, active) => {
const state = get();
if (state.classId !== "druid" || glyphId !== WOW335_GLYPH_OF_SWIFTMEND_ID) return false;
const glyphIds = active
? [WOW335_GLYPH_OF_SWIFTMEND_ID]
: state.glyphIds.filter((id) => id !== WOW335_GLYPH_OF_SWIFTMEND_ID);
if (glyphIds.length !== state.glyphIds.length) set({ glyphIds });
return true;
},
allocateTalent: (treeId) => {
const state = get();
const result = allocateTalentPoint(state.talentRanks, state.classId, treeId, state.level);
+68 -1
View File
@@ -23,10 +23,11 @@ import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog";
import { abilitiesForClass, abilityAtLevel, abilityTalentNodeId } from "./abilityCatalog";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
import { baseClassFor } from "../app/characterCatalog";
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
@@ -130,6 +131,72 @@ describe("party combat runtime", () => {
expect(effects.find(effect => effect.targetId === first.targetId)?.nextTickAt).toBe(first.nextTickAt);
});
it.each([false, true])("companion Swiftmend preserves the HoT only with the configured glyph (%s)", (glyphActive) => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const druidAbilities = abilitiesForClass("druid");
const swiftmendBase = druidAbilities.find((ability) => ability.name === "Swiftmend")!;
const swiftmendNodeId = abilityTalentNodeId(swiftmendBase)!;
const swiftmend = abilityAtLevel(swiftmendBase, 80);
const rejuvenation = abilityAtLevel(druidAbilities.find((ability) => ability.name === "Rejuvenation")!, 80);
const rejuvenationHot = rejuvenation.effects.find((effect) => effect.kind === "hot")!;
const druid = createConfiguredPartyMember({
name: "Configured Druid",
classId: "druid",
specialization: "Restoration",
specializationId: "druid:restoration",
talentTreeId: "druid-restoration",
talentRanks: { [swiftmendNodeId]: 1 },
glyphIds: glyphActive ? [WOW335_GLYPH_OF_SWIFTMEND_ID] : [],
role: "healer",
}, 80, 0, "party-swiftmend-glyph");
const target = createConfiguredPartyMember({
name: "Injured Tank",
classId: "warrior",
specialization: "Protection",
specializationId: "warrior:protection",
talentTreeId: "warrior-protection",
role: "tank",
}, 80, 1, "party-swiftmend-glyph");
const startedAt = 1_000;
usePartyStore.getState().initializeParty("party-swiftmend-glyph", 80,
useCombatStore.getState().health, "damage");
usePartyStore.setState({ members: [
{ ...target, health: 100 },
{
...druid,
activeCast: {
abilityId: swiftmend.id,
school: "nature",
mode: "cast",
startedAt,
completesAt: startedAt,
originalDurationMs: 0,
pushbackHits: 0,
origin: [0, 0, 0],
targetId: target.id,
},
activeCastTargetId: target.id,
status: "healing",
},
] });
registerPartyRuntimePosition(druid.id, [0, 0, 0]);
registerPartyRuntimePosition(target.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
expect(useCombatStore.getState().applyTimedActorEffect(
druid.id, target.id, rejuvenation, rejuvenationHot, startedAt,
)).toBe(true);
const originalHot = useCombatStore.getState().effects.find((effect) => (
effect.kind === "hot" && effect.sourceActorId === druid.id && effect.targetId === target.id
))!;
advanceDungeonPartyCombat(startedAt);
expect(useCombatStore.getState().actors[target.id]!.health).toBeGreaterThan(100);
expect(useCombatStore.getState().effects.some((effect) => effect.id === originalHot.id)).toBe(glyphActive);
expect(usePartyStore.getState().members.find((member) => member.id === druid.id)?.glyphIds)
.toEqual(glyphActive ? [WOW335_GLYPH_OF_SWIFTMEND_ID] : []);
});
it("refreshes a companion Priest's Shadow Word: Pain from its own completed Mind Blast", () => {
useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
const priest = createConfiguredPartyMember({
+39 -1
View File
@@ -37,6 +37,7 @@ import { usePartyStore, type PartyCommand, type PartyMember } from "./partyStore
import { baseClassFor, classById } from "../app/characterCatalog";
import { useGameStore } from "./store";
import { useManastormStore } from "./manastormStore";
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
import { activeDungeonCompletedObjectiveIds } from "./dungeonEncounterRuntime";
import { PLAYER_AGGRO_ID, type ThreatSource } from "./aggro";
import {
@@ -296,6 +297,24 @@ function partyShamanRiptideHotOnTarget(memberId: string, targetId: string, now:
&& effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs > now);
}
function partySwiftmendHotOnTarget(memberId: string, targetId: string, now: number) {
const candidates = useCombatStore.getState().effects.filter((effect) => {
if (effect.kind !== "hot" || (effect.targetId ?? PLAYER_AGGRO_ID) !== targetId
|| effect.remainingTicks <= 0
|| effect.nextTickAt + Math.max(0, effect.remainingTicks - 1) * effect.intervalMs <= now) return false;
const name = abilityById(effect.sourceAbilityId)?.name;
return name === "Rejuvenation" || name === "Regrowth";
});
const own = candidates.filter((effect) => effect.sourceActorId === memberId);
const pool = own.length ? own : candidates;
return pool.reduce<typeof pool[number] | undefined>((shortest, candidate) => {
if (!shortest) return candidate;
const expiry = candidate.nextTickAt + Math.max(0, candidate.remainingTicks - 1) * candidate.intervalMs;
const shortestExpiry = shortest.nextTickAt + Math.max(0, shortest.remainingTicks - 1) * shortest.intervalMs;
return expiry < shortestExpiry ? candidate : shortest;
}, undefined);
}
function maybeProcPartyShamanImprovedWaterShield(
member: PartyMember,
ability: AbilityDefinition,
@@ -1349,6 +1368,8 @@ export function partyAbilityTargetIsValid(
.flatMap((aura) => aura.definition.utilityTags ?? []);
if (!romPriestWarriorStanceUseAllowed(ability, activeAuraTags)) return false;
}
if (member.classId === "druid" && ability.name === "Swiftmend"
&& !partySwiftmendHotOnTarget(member.id, targetId, now)) return false;
if (member.classId === "warlock") {
const metamorphosisActive = combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes(WOW335_WARLOCK_METAMORPHOSIS_AURA_TAG)
@@ -2186,6 +2207,18 @@ function executePartyAbility(
&& targetIndex === 0
? partyShamanRiptideHotOnTarget(member.id, friendlyTargetId, now)
: undefined;
const swiftmendHot = member.classId === "druid" && ability.name === "Swiftmend"
? partySwiftmendHotOnTarget(member.id, friendlyTargetId, now)
: undefined;
const swiftmendHotAbility = swiftmendHot ? abilityById(swiftmendHot.sourceAbilityId) : null;
const swiftmendHotEffect = swiftmendHotAbility?.effects.find((candidate) => candidate.kind === "hot");
const swiftmendTickCount = swiftmendHotAbility?.name === "Rejuvenation" ? 4
: swiftmendHotAbility?.name === "Regrowth" ? 6
: 0;
const swiftmendAmount = swiftmendHot && swiftmendHotAbility && swiftmendHotEffect?.kind === "hot"
? (swiftmendHot.fixedAmount ?? partyAbilityAmount(runtimeMember, swiftmendHotAbility, swiftmendHotEffect))
* swiftmendTickCount
: null;
if (riptideHot) {
useCombatStore.setState((state) => ({
effects: state.effects.filter((candidate) => candidate.id !== riptideHot.id),
@@ -2196,7 +2229,7 @@ function executePartyAbility(
const healingSchool = member.classId === "shaman"
? abilityDamageSchool("shaman", ability)
: spellSchool;
const requested = Math.round((partyAbilityAmount(runtimeMember, ability, effect)
const requested = Math.round(((swiftmendAmount ?? partyAbilityAmount(runtimeMember, ability, effect))
+ tidalWavesBonus
+ Math.round(actor.maxHealth * (effect.percentMaxHealth ?? 0)))
* (riptideHot ? PARTY_SHAMAN_RIPTIDE_CHAIN_HEAL_MULTIPLIER : 1));
@@ -2208,6 +2241,11 @@ function executePartyAbility(
seed: `${member.id}:${ability.id}:${friendlyTargetId}:${now}:heal`,
now,
});
if (swiftmendHot && !runtimeMember.glyphIds?.includes(WOW335_GLYPH_OF_SWIFTMEND_ID)) {
useCombatStore.setState((state) => ({
effects: state.effects.filter((candidate) => candidate.id !== swiftmendHot.id),
}));
}
effectiveAmount += result.effectiveAmount;
directHealingRawAmount += result.rawAmount;
runtimeMember = maybeProcPartyShamanImprovedWaterShield(
+25
View File
@@ -17,6 +17,7 @@ import { spentTalentPoints, talentPointsForLevel, talentTreeById } from "./talen
import type { InventoryItem } from "./lootTypes";
import { EMPTY_ITEM_STATS } from "./itemStats";
import { DEFAULT_COMPANION_SPEC_READINESS_POLICY } from "./companionSpecReadiness";
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
describe("dungeon party composition", () => {
beforeEach(() => usePartyStore.setState({
@@ -183,6 +184,30 @@ describe("dungeon party composition", () => {
expect(cat.resourcePools).toMatchObject({ mana: cat.maxResource, rage: 0, energy: 100 });
});
it("keeps generated companions unglyphed unless a configured Druid has Swiftmend glyph", () => {
const generated = createDungeonParty("unglyphed-defaults", 80);
expect(generated.every((member) => !member.glyphIds?.length)).toBe(true);
const configuredDruid = createConfiguredPartyMember({
name: "Glyphkeeper",
classId: "druid",
specialization: "Restoration",
specializationId: "druid:restoration",
role: "healer",
glyphIds: [WOW335_GLYPH_OF_SWIFTMEND_ID, WOW335_GLYPH_OF_SWIFTMEND_ID, "not-a-glyph"],
}, 80, 0, "configured-glyphs");
const unconfiguredDruid = createConfiguredPartyMember({
name: "Plainleaf",
classId: "druid",
specialization: "Restoration",
specializationId: "druid:restoration",
role: "healer",
}, 80, 1, "configured-glyphs");
expect(configuredDruid.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
expect(unconfiguredDruid.glyphIds).toEqual([]);
});
it("is deterministic per character and scales every ally to the player level", () => {
const first = createDungeonParty("character-42", 18);
const second = createDungeonParty("character-42", 18);
+7
View File
@@ -21,6 +21,7 @@ import {
type DamageSchool,
type ItemStats,
} from "./itemStats";
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
import {
damageAfterEquipmentMitigation,
deriveCharacterStats,
@@ -87,6 +88,8 @@ export interface PartyMember {
readonly talentTreeId: string;
readonly talentProfileId: string | null;
readonly talentRanks: TalentRanks;
/** Explicitly configured WoW glyphs; generated companions have none by default. */
readonly glyphIds?: readonly string[];
readonly role: PartyRole;
readonly level: number;
readonly health: number;
@@ -134,6 +137,7 @@ export interface ConfiguredPartyMemberSeed {
readonly talentTreeId?: string;
readonly talentProfileId?: string | null;
readonly talentRanks?: TalentRanks;
readonly glyphIds?: readonly string[];
readonly role: PartyRole;
}
@@ -438,6 +442,9 @@ export function createConfiguredPartyMember(
...member,
specializationId: configuration.specializationId ?? member.specializationId,
talentRanks: configuration.talentRanks ?? member.talentRanks,
glyphIds: configuration.classId === "druid"
? [...new Set((configuration.glyphIds ?? []).filter((glyphId) => glyphId === WOW335_GLYPH_OF_SWIFTMEND_ID))]
: [],
}));
}
+29
View File
@@ -42,6 +42,35 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect(byId("wow335-priest-power-word-shield").gcdMs).toBe(1_500);
});
it("maps Stormstrike's generated weapon-hit children to their attacking hands", () => {
const stormstrike = abilityAtLevel(byId("wow335-shaman-stormstrike"), 80);
const chain = generatedData.classes.shaman.find((candidate) => candidate.name === "Stormstrike");
expect(stormstrike.dbcSpellId).toBe(17364);
expect(chain).toBeDefined();
const expectedHits = [32175, 32176].map((spellId) => {
const weaponEffect = chain!.ranks.find((rank) => rank.spellId === spellId)!
.effects.find((effect) => effect.effectType === 58)!;
expect(weaponEffect.coefficient).toBe(1);
return {
kind: "damage" as const,
coefficient: weaponEffect.coefficient,
school: "physical" as const,
weaponHand: spellId === 32175 ? "main-hand" as const : "off-hand" as const,
};
});
expect(effectsOfKind(stormstrike, "damage")).toEqual(expectedHits);
expect(stormstrike.effects).not.toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "trigger-spell", spellId: 32175 }),
expect.objectContaining({ kind: "trigger-spell", spellId: 32176 }),
]));
expect(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID[32175]).toBeUndefined();
expect(WOW335_TRIGGERED_ABILITIES_BY_SPELL_ID[32176]).toBeUndefined();
expect(WOW335_ABILITIES_BY_CLASS.shaman.some((ability) => [32175, 32176].includes(ability.dbcSpellId ?? 0)))
.toBe(false);
});
it("keeps Bloodthirst's player cast, next-hit aura, and triggered heal on their source rows", () => {
const bloodthirst = abilityAtLevel(byId("wow335-warrior-bloodthirst"), 80);
expect(bloodthirst).toMatchObject({
+33
View File
@@ -753,6 +753,32 @@ function fallbackCoefficient(effect: Wow335Effect, rank: Wow335Rank): number {
return Math.max(0.08, Math.min(4, average / playerCombatPower(Math.max(1, rank.sourceLevel || rank.level))));
}
function stormstrikeWeaponHitForTrigger(
spellId: number,
): Extract<AbilityEffect, { kind: "damage" }> | null {
const weaponHand = spellId === 32175
? "main-hand"
: spellId === 32176
? "off-hand"
: undefined;
if (!weaponHand) return null;
// Stormstrike's wrapper invokes these two DBC child spells. Their local
// generated SpellEffect rows contain the weapon-damage coefficient.
const childRank = data.classes.shaman
.find((chain) => chain.name === "Stormstrike")
?.ranks.find((candidate) => candidate.spellId === spellId);
const weaponEffect = childRank?.effects.find((effect) => WEAPON_DAMAGE_EFFECTS.has(effect.effectType));
if (!weaponEffect) return null;
const coefficient = weaponEffect.coefficient > 0
? weaponEffect.coefficient
: weaponEffect.effectType === 31
? Math.max(0.1, Math.abs(weaponEffect.basePoints) / 100)
: 1;
return { kind: "damage", coefficient, school: "physical", weaponHand };
}
function utilityAction(effectType: number): Extract<AbilityEffect, { kind: "utility" }>["action"] {
if (effectType === 24) return "create-item";
if (effectType === 33) return "open-lock";
@@ -1877,6 +1903,13 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
continue;
}
if (TRIGGER_SPELL_EFFECTS.has(effect.effectType) && effect.triggerSpellId > 0) {
if (classId === "shaman" && abilityName === "Stormstrike" && rank.spellId === 17364) {
const weaponHit = stormstrikeWeaponHitForTrigger(effect.triggerSpellId);
if (weaponHit) {
effects.push(weaponHit);
continue;
}
}
effects.push({
kind: "trigger-spell",
spellId: effect.triggerSpellId,
+14
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import { PLAYER_AGGRO_ID } from "./aggro";
import { abilityTalentNodeId, type AbilityDefinition } from "./abilityCatalog";
import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "./wow335GlyphCatalog";
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { useCombatStore, type TimedCombatEffect } from "./combatStore";
@@ -102,4 +103,17 @@ describe("WoW Druid active talent runtime", () => {
expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 400);
expect(useCombatStore.getState().effects.map((effect) => effect.id)).toEqual([longerRegrowth.id]);
});
it("preserves the selected HoT when Glyph of Swiftmend is active", () => {
const swiftmend = prepareSwiftmend();
const sourceHot = hot("Rejuvenation", PLAYER_AGGRO_ID, 100, 5, 4_000, "swiftmend-glyph-hot");
useCombatStore.setState({ effects: [sourceHot] });
expect(useCombatStore.getState().setGlyphActive(WOW335_GLYPH_OF_SWIFTMEND_ID, true)).toBe(true);
const event = castSwiftmend(swiftmend);
expectCriticalAdjustedRawHealing(event.rawAmount, event.critical, 400);
expect(useCombatStore.getState().glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]);
expect(useCombatStore.getState().effects).toEqual([sourceHot]);
});
});
+2
View File
@@ -0,0 +1,2 @@
/** Stable character-build ID for the WotLK 3.3.5a Glyph of Swiftmend. */
export const WOW335_GLYPH_OF_SWIFTMEND_ID = "wow335:54824";
@@ -124,6 +124,85 @@ describe("WoW Shaman Maelstrom Weapon combat runtime", () => {
))).toBe(true);
});
it("uses the off-hand speed for Shaman off-hand auto-attack Maelstrom rolls", () => {
setMaelstromRank(3);
useCombatStore.setState((state) => ({
inventory: state.inventory.map((item) => item.instanceId === OFF_HAND_WEAPON_ID
? { ...item, stats: { ...item.stats, meleeWeapon: { ...item.stats.meleeWeapon!, speedMs: 2_000 } } }
: item),
gearStats: { ...state.gearStats, hitRating: 10_000, expertiseRating: 10_000 },
autoAttackTargetId: TARGET_ID,
nextAutoAttackAt: 1_000,
nextOffhandAutoAttackAt: 1_000,
}));
const mainHandRoll = (now: number) => stableCombatUnit(
`${PLAYER_AURA_ENTITY_ID}:auto:${TARGET_ID}:${now}:wow335-shaman-maelstrom-weapon`,
);
const offHandRoll = (now: number) => stableCombatUnit(
`${PLAYER_AURA_ENTITY_ID}:offhand-auto:${TARGET_ID}:${now}:wow335-shaman-maelstrom-weapon`,
);
const now = Array.from({ length: 50_000 }, (_, index) => 1_000 + index)
.find((candidate) => mainHandRoll(candidate) >= 0.2
&& offHandRoll(candidate) >= 0.2 && offHandRoll(candidate) < 0.4)!;
expect(now).toBeDefined();
useCombatStore.getState().tick(0.1, now);
const offhandEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === "player-auto-attack-offhand"
));
expect(offhandEvents, JSON.stringify(offhandEvents)).toHaveLength(1);
expect(mainHandRoll(now)).toBeGreaterThanOrEqual(0.2);
expect(offHandRoll(now)).toBeLessThan(0.4);
expect(maelstromAura(), JSON.stringify({
now,
roll: offHandRoll(now),
rank: useCombatStore.getState().talentRanks["talent-2057"],
weapon: useCombatStore.getState().inventory.find((item) => item.instanceId === OFF_HAND_WEAPON_ID)?.stats.meleeWeapon,
offhandEvents,
})).toMatchObject({ stacks: 1 });
});
it("resolves Stormstrike's hands independently and uses each weapon speed for Maelstrom", () => {
setMaelstromRank(3);
useCombatStore.setState((state) => ({
talentRanks: { ...state.talentRanks, "talent-901": 1 },
inventory: state.inventory.map((item) => item.instanceId === OFF_HAND_WEAPON_ID
? { ...item, stats: { ...item.stats, meleeWeapon: { ...item.stats.meleeWeapon!, speedMs: 2_000 } } }
: item),
gearStats: { ...state.gearStats, hitRating: 10_000, expertiseRating: 10_000 },
}));
const stormstrike = shamanAbility("Stormstrike");
const mainHandRoll = (now: number) => stableCombatUnit(
`${PLAYER_AURA_ENTITY_ID}:${stormstrike.id}:${TARGET_ID}:${now}:direct:main-hand:wow335-shaman-maelstrom-weapon`,
);
const offHandRoll = (now: number) => stableCombatUnit(
`${PLAYER_AURA_ENTITY_ID}:${stormstrike.id}:${TARGET_ID}:${now}:direct:off-hand:wow335-shaman-maelstrom-weapon`,
);
const now = Array.from({ length: 50_000 }, (_, index) => 1_000 + index)
.find((candidate) => mainHandRoll(candidate) >= 0.2
&& offHandRoll(candidate) >= 0.2 && offHandRoll(candidate) < 0.4)!;
expect(now).toBeDefined();
cast(stormstrike.id, now);
const hitEvents = useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === stormstrike.id && event.effectiveAmount > 0
));
expect(hitEvents, JSON.stringify(useCombatStore.getState().combatEvents.filter((event) => (
event.kind === "damage" && event.abilityId === stormstrike.id
)))).toHaveLength(2);
expect(mainHandRoll(now)).toBeGreaterThanOrEqual(0.2);
expect(offHandRoll(now)).toBeLessThan(0.4);
expect(maelstromAura(), JSON.stringify({
now,
roll: offHandRoll(now),
rank: useCombatStore.getState().talentRanks["talent-2057"],
weapon: useCombatStore.getState().inventory.find((item) => item.instanceId === OFF_HAND_WEAPON_ID)?.stats.meleeWeapon,
hitEvents,
})).toMatchObject({ stacks: 1 });
});
it("adds one stack per proc, refreshes the 30-second duration, and caps at five", () => {
setMaelstromRank(3);
const lavaLash = shamanAbility("Lava Lash");