WIP: Preserve selected WoW rank cooldown categories (#3) #5

Draft
Codex Bot wants to merge 2 commits from fix/3-wow-gcd-categories into main
pull from: fix/3-wow-gcd-categories
15 changed files with 601 additions and 22 deletions

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
# WoW rank-specific global cooldown categories
Shared source [issue #3](https://git.whoagland.com/phenom/healer-man/issues/3)
owns this combat correction. Native integration belongs to
[Codex/healer-man-godot#114](https://git.whoagland.com/Codex/healer-man-godot/issues/114).
## Imported rank identity
`scripts/generate-wow335-abilities.mjs` imports Spell.dbc
StartRecoveryCategory alongside StartRecoveryTime. The expanded 239-field
local enUS layout shifts semantic fields 205/206 to raw columns 211/212.
The extracted DBC SHA-256 is
`9f0088da35c5ed0b5106cd20e422611489e435b74043e520e02221e0ce1b1af1`.
The additive import joins all 2,229 selected learned rank IDs with no missing
rows and preserves every original generated value and byte after removing
only the inserted `gcdCategoryId` fields. This is an additive metadata import,
not a regeneration of spell effects, costs, stats or equipment.
`wow335AbilityCatalog.ts` maps the category into every rank and initial ability;
`abilityCatalog.ts` selects `gcdCategoryId` with the learned rank's spell ID,
time and effects. Devotion Aura 48941 is category 38/time 1500 at levels
74–78; 48942 is category 133/time 1500 from level 79 through 80. Categories
are not derived from name, character level or the first rank of the chain.
## Combat ownership
`wowGlobalCooldown.ts` owns matching-category admission and timer creation.
Player state, PartyMember and CombatActorState carry optional
`globalCooldownEndsByCategory` keyed by decimal category string. For WoW,
`globalCooldownEndsAt` is the category-133 projection. Starting category 38
or 0 preserves the independent category-133 scalar and timer.
An exact category is checked even when the attempted spell's own duration is
zero. A zero-duration action creates no timer. Explicit category 0 is a real
key: a positive-duration fixture starts key `"0"` and other categories remain
available. None of the current 2,229 learned rows has positive duration in
category 0, so ordinary learned category-0 actions continue working during
category 133. Legacy entries lacking a category use 133 when duration is
positive; a missing-category zero-duration entry retains its bypass.
Player `castAbility` and party readiness/commit call the same helper.
Cast completion and Sudden Doom's internal free Death Coil keep their existing
execution exemptions. Actor authority adapters carry category maps in both
directions; initialization, reset and native player switches preserve actor
ownership. NPC next-action scheduling remains its existing rotation cadence.
Raw WoW durations below 1000 ms remain unchanged, including five 500 ms
Shadowfury rows and Backdraft/party haste paths. Positive non-133 WoW timers
retain their raw duration. Ordinary category-133 timers retain their existing
haste policy and one-second floor. Runewaker/RoM timing remains scalar-only;
this change does not merge stat formulas, allocation, gear pools or rigs.
The matching-category behavior follows the pinned primary
[AzerothCore category manager](https://github.com/azerothcore/azerothcore-wotlk/blob/35aaa5e722093b4f57b59bc234c0311467081b49/src/server/game/Entities/Unit/CharmInfo.cpp#L367-L390)
and [Spell admission/creation](https://github.com/azerothcore/azerothcore-wotlk/blob/35aaa5e722093b4f57b59bc234c0311467081b49/src/server/game/Spells/Spell.cpp#L8471-L8527).
This is category policy evidence; complete original-client hit tables,
spell-attribute haste parity and companion rotation parity remain separate.
## Validation and publication
Focused regressions live in `src/game/wowGlobalCooldown.test.ts`. They include
exact rank selection and public source character initialization/casting.
Native evidence covers the public Ready Discipline cast path and separately
labeled category-38/133/0 UI fixtures. The offline native diagnostic profile
catalog currently fixes its characters at level 80, so its GPU fixture is not
a live level-74 profile claim or additional Paladin certification. Balance
certification remains the existing five Ready WoW specializations.
The published source checkout and installed mirror's combatStore/partyRuntime
are equal after CRLF normalization. The native byte-guard manifest instead
patches the exact pre-114 installed mirror and preserves its mixed line endings.
Native source pins identify installed bytes, not a claim that source Git HEAD
alone recreates every asset/export in the native installation. See the owning
issues for final commands, validation counts, evidence and review status.
+3 -1
View File
@@ -190,6 +190,7 @@ function parseSpells(buffer, castTimes, durations, radii, ranges, icons) {
.replace(/\.(?:blp|tga)$/i, "")
.toLowerCase(),
powerCostPercentage: unsigned(shifted(204)),
startRecoveryCategory: unsigned(shifted(205)),
startRecoveryTimeMs: unsigned(shifted(206)),
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
index: effectIndex,
@@ -293,8 +294,9 @@ function slimRank(spell, spells) {
description: resolveDescription(spell, spells),
castTimeMs: spell.castTimeMs,
cooldownMs: Math.max(spell.recoveryTimeMs, spell.categoryRecoveryTimeMs),
// Zero is the DBC signal for abilities that do not start a global cooldown.
// Keep the category key independent from its StartRecoveryTime duration.
gcdMs: spell.startRecoveryTimeMs,
gcdCategoryId: spell.startRecoveryCategory,
// SpellDuration.dbc uses negative durations for permanent/toggled effects.
// Preserve those as zero so the runtime converts them to a non-expiring aura
// instead of a one-millisecond aura that the AI immediately recasts.
+5
View File
@@ -336,6 +336,8 @@ export interface AbilityRankDefinition {
readonly channelPulses?: number;
readonly channelStartsWithPulse?: boolean;
readonly cooldownMs: number;
/** Raw Spell.dbc StartRecoveryCategory; category 0 is a real key. */
readonly gcdCategoryId?: number;
readonly gcdMs: number;
readonly cost: AbilityCost;
readonly costs?: readonly AbilityCost[];
@@ -369,6 +371,8 @@ export interface AbilityDefinition {
readonly channelStartsWithPulse?: boolean;
readonly cooldownMs: number;
readonly cooldownGroup?: string;
/** Raw selected-rank Spell.dbc StartRecoveryCategory; category 0 is a real key. */
readonly gcdCategoryId?: number;
readonly gcdMs: number;
readonly cost: AbilityCost;
readonly costs?: readonly AbilityCost[];
@@ -1271,6 +1275,7 @@ export function abilityAtLevel(
channelPulses: rank.channelPulses,
channelStartsWithPulse: rank.channelStartsWithPulse,
cooldownMs: rank.cooldownMs,
gcdCategoryId: rank.gcdCategoryId,
gcdMs: rank.gcdMs,
cost: rank.cost,
costs: rank.costs ?? [rank.cost],
+3
View File
@@ -97,6 +97,7 @@ export function companionActorFromMember(
resources,
cooldowns: actor?.cooldowns ?? member.cooldowns,
globalCooldownEndsAt: actor?.globalCooldownEndsAt ?? member.globalCooldownEndsAt,
globalCooldownEndsByCategory: actor?.globalCooldownEndsByCategory ?? member.globalCooldownEndsByCategory,
cast: actor?.cast ?? member.activeCast,
controlledUntil: actor?.controlledUntil ?? member.controlledUntil,
schoolLockedUntil: actor?.schoolLockedUntil ?? member.schoolLockedUntil,
@@ -128,6 +129,7 @@ export function memberFromCompanionActor(member: PartyMember, actor?: CombatActo
comboTargetId: actor.comboTargetId,
cooldowns: actor.cooldowns,
globalCooldownEndsAt: actor.globalCooldownEndsAt,
globalCooldownEndsByCategory: actor.globalCooldownEndsByCategory,
activeCast: actor.cast,
activeCastTargetId: actor.cast?.targetId ?? member.activeCastTargetId,
schoolLockedUntil: actor.schoolLockedUntil,
@@ -158,6 +160,7 @@ export function companionActorWithMemberRuntime(
resources,
cooldowns: member.cooldowns,
globalCooldownEndsAt: member.globalCooldownEndsAt,
globalCooldownEndsByCategory: member.globalCooldownEndsByCategory,
cast: member.activeCast ? { ...member.activeCast, targetId: member.activeCastTargetId } : null,
controlledUntil: member.controlledUntil,
schoolLockedUntil: member.schoolLockedUntil,
+1
View File
@@ -69,6 +69,7 @@ export interface CombatActorState {
readonly resources: Readonly<Record<CombatResourceId, CombatResourcePoolState>>;
readonly cooldowns: Readonly<Record<string, number>>;
readonly globalCooldownEndsAt: number;
readonly globalCooldownEndsByCategory?: Readonly<Record<string, number>>;
readonly cast: CombatActorCastState | null;
readonly controlledUntil: number;
readonly schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
+16 -6
View File
@@ -11,6 +11,7 @@ import { DEFAULT_GRAPHICS_SETTINGS, normalizeGraphicsSettings, type GraphicsSett
import { permanentBuffEffects } from "./partyBuffPolicy";
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
import { combatNow } from "./combatClock";
import { abilityGcdCategory, abilityGlobalCooldownActive, startAbilityGlobalCooldown } from "./wowGlobalCooldown";
import { selectEnemyChainTargets } from "./enemyChainTargeting";
import { mergePassiveAuraResourceModifiers } from "./classPassiveResourceModifiers";
import { enemyPeriodicDamageSource, enemyPeriodicDamageTickAmount } from "./enemyPeriodicDamage";
@@ -1599,6 +1600,7 @@ export interface CombatState {
abilities: readonly AbilityDefinition[];
cooldowns: Readonly<Record<string, number>>;
globalCooldownEndsAt: number;
globalCooldownEndsByCategory?: Readonly<Record<string, number>>;
activeCast: ActiveCast | null;
schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
playerAnimationEvent: CharacterCombatAnimationEvent | null;
@@ -4285,10 +4287,13 @@ function talentAdjustedGcd(
statRuleset: CharacterStatRuleset = "wow",
): number {
if (ability.gcdMs <= 0) return 0;
const normalizedStatRuleset = normalizeCharacterStatRuleset(statRuleset, classId);
if (normalizedStatRuleset === "wow" && (ability.gcdMs < 1_000
|| ability.source !== "runewaker" && abilityGcdCategory({ statRuleset }, ability) !== 133)) return ability.gcdMs;
const hasteMultiplier = applyTalentModifierValue(1, modifiers, "haste-percent", abilityTalentContext(ability));
const duration = hastedDuration(
ability.gcdMs,
normalizeCharacterStatRuleset(statRuleset, classId) === "rom"
normalizedStatRuleset === "rom"
? 0
: ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
1_000,
@@ -11379,6 +11384,7 @@ function mirrorCompanionActor(actor: CombatActorState): void {
lastResourceSpentAt: mirrored.lastResourceSpentAt,
cooldowns: mirrored.cooldowns,
globalCooldownEndsAt: mirrored.globalCooldownEndsAt,
globalCooldownEndsByCategory: mirrored.globalCooldownEndsByCategory,
activeCast: mirrored.activeCast,
activeCastTargetId: mirrored.activeCastTargetId,
schoolLockedUntil: mirrored.schoolLockedUntil,
@@ -11716,6 +11722,7 @@ function combatActorSnapshots(state: CombatState): Readonly<Record<string, Comba
resources: playerResources,
cooldowns: state.cooldowns,
globalCooldownEndsAt: state.globalCooldownEndsAt,
globalCooldownEndsByCategory: state.globalCooldownEndsByCategory,
cast: state.activeCast ? {
abilityId: state.activeCast.abilityId,
school: state.activeCast.school,
@@ -11754,6 +11761,7 @@ function combatActorSnapshots(state: CombatState): Readonly<Record<string, Comba
: {},
cooldowns: mob.abilityReadyAt,
globalCooldownEndsAt: 0,
globalCooldownEndsByCategory: {},
cast: mob.activeCast ? {
abilityId: mob.activeCast.attackId,
school: mob.activeCast.school,
@@ -11834,6 +11842,7 @@ function initialState() {
abilities,
cooldowns: {},
globalCooldownEndsAt: 0,
globalCooldownEndsByCategory: {},
activeCast: null,
schoolLockedUntil: {},
playerAnimationEvent: null,
@@ -13614,7 +13623,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
) failure = "player-controlled";
else if (state.activeCast && !completingCast) failure = "already-casting";
else if (!completingCast && !suddenDoomFreeDeathCoil
&& ability.gcdMs > 0 && state.globalCooldownEndsAt > now) failure = "global-cooldown";
&& abilityGlobalCooldownActive(state, ability, now)) failure = "global-cooldown";
else if (!completingCast && !consumesHunterLockAndLoad && (state.cooldowns[abilityId] ?? 0) > now) failure = "cooldown";
else if ((state.schoolLockedUntil[spellSchool] ?? 0) > now) failure = "school-locked";
else if (ability.id === "rom-mage-primary-55" && !hasRuneWakerState("rom-charged")) failure = "condition-not-met";
@@ -14185,7 +14194,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
state.classId,
state.statRuleset,
);
const gcdMs = warlockBackdraftEffects && gcdBeforeBackdraftMs > 0
const gcdMs = warlockBackdraftEffects && gcdBeforeBackdraftMs >= 1_000
? Math.max(1_000, Math.round(gcdBeforeBackdraftMs * (1 - warlockBackdraftEffects.reductionPercent / 100)))
: gcdBeforeBackdraftMs;
const improvedRuneTapRank = state.classId === "death-knight" && ability.name === "Rune Tap"
@@ -14401,7 +14410,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
critical: false,
outcome: "started",
}),
globalCooldownEndsAt: gcdMs > 0 ? now + gcdMs : state.globalCooldownEndsAt,
...startAbilityGlobalCooldown(state, ability, now, gcdMs),
selectedTargetId: effectiveTarget === "hostile" ? targetId : state.selectedTargetId,
feedback: nextFeedback(
"success",
@@ -17865,8 +17874,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
areaEffects: work.areaEffects,
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
cooldowns,
globalCooldownEndsAt: completingCast || suddenDoomFreeDeathCoil || gcdMs <= 0
? state.globalCooldownEndsAt : now + gcdMs,
...startAbilityGlobalCooldown(state, ability, now,
completingCast || suddenDoomFreeDeathCoil ? 0 : gcdMs),
activeCast: null,
...autoAttackUpdate,
...autoShotUpdate,
@@ -26550,6 +26559,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
deathKnightRunes: state.classId === "death-knight" ? createDeathKnightRunes() : [],
cooldowns: {},
globalCooldownEndsAt: 0,
globalCooldownEndsByCategory: {},
activeCast: null,
schoolLockedUntil: {},
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
+7 -2
View File
@@ -16,6 +16,10 @@ import {
type RotationFriendlyTarget,
} from "./combatRotation";
import { spendDeathKnightRunes } from "./combatResources";
import {
abilityGlobalCooldownActive,
startAbilityGlobalCooldown,
} from "./wowGlobalCooldown";
import { resolveResourceValue } from "./combatAuras";
import { combatHasLineOfSight } from "./combatSpatial";
import { equipmentAdjustedCombatAmount, useCombatStore, type MobCombatState } from "./combatStore";
@@ -345,7 +349,7 @@ export function partyAbilityIsReady(
if (!romPriestWarriorStanceUseAllowed(ability, activeAuraTags)) return false;
}
const hasRomPriestChampionCooldownOverride = partyRomPriestChampionCooldownOverride(member, ability) !== null;
if ((ability.gcdMs > 0 && member.globalCooldownEndsAt > now)
if (abilityGlobalCooldownActive(member, ability, now)
|| (!partyHunterLockAndLoadReady(member, ability, now)
&& !hasRomPriestChampionCooldownOverride
&& (member.cooldowns[ability.id] ?? 0) > now)) return false;
@@ -586,6 +590,7 @@ export function commitPartyAbilityUse(
const adjustedCooldownMs = Math.max(0,
cooldownMs - knightWarriorCooldownReductionMs - warriorDruidCooldownReductionMs - knightCompassionCooldownReductionMs);
const cooldowns = { ...member.cooldowns };
const globalCooldown = startAbilityGlobalCooldown(member, ability, now, ability.gcdMs);
if (romCooldownOverride !== null) delete cooldowns[ability.id];
if (adjustedCooldownMs > 0) cooldowns[ability.id] = now + adjustedCooldownMs;
return {
@@ -596,7 +601,7 @@ export function commitPartyAbilityUse(
lastResourceSpentAt,
runes,
cooldowns,
globalCooldownEndsAt: ability.gcdMs > 0 ? now + ability.gcdMs : member.globalCooldownEndsAt,
...globalCooldown,
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
? now + Math.max(8_000, ability.cooldownMs)
: member.tauntReadyAt,
+13 -8
View File
@@ -47,6 +47,7 @@ import {
useCombatStore,
} from "./combatStore";
import { raceAffixHasteMultiplier } from "./dungeonRaceAffixBuffs";
import { abilityGcdCategory, startAbilityGlobalCooldown } from "./wowGlobalCooldown";
import { activeRaceOrbId } from "./dungeonRaceAffixRuntime";
import {
WOW335_PALADIN_ILLUMINATION_TALENT_ID,
@@ -969,9 +970,9 @@ function partyWarlockBackdraftCast(
const haste = raceAffixHasteMultiplier(now);
const reduction = effects.reductionPercent / 100;
const castTimeMs = Math.max(0, Math.round(Math.round(ability.castTimeMs / haste) * (1 - reduction)));
const gcdMs = ability.gcdMs > 0
const gcdMs = ability.gcdMs >= 1_000 && abilityGcdCategory(member, ability) === 133
? Math.max(1_000, Math.round(Math.round(ability.gcdMs / haste) * (1 - reduction)))
: 0;
: ability.gcdMs;
return {
ability: { ...ability, castTimeMs, gcdMs },
auraInstanceId: aura.instanceId,
@@ -5646,12 +5647,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
continue;
}
applyPartyRomChampionRunePulseOnUse(member, castAbility, now);
if (castAbility.gcdMs > 0 && castingHastePercent > 0) {
if (castingHastePercent > 0 && castAbility.gcdMs > 0
&& (abilityGcdCategory(committed, castAbility) === null
|| abilityGcdCategory(committed, castAbility) === 133 && castAbility.gcdMs >= 1_000)) {
committed = {
...committed,
globalCooldownEndsAt: now + Math.max(1_000, Math.round(
...startAbilityGlobalCooldown(committed, castAbility, now, Math.max(1_000, Math.round(
castAbility.gcdMs / (1 + castingHastePercent / 100),
)),
))),
};
}
applyPartyDeathKnightBladeBarrier(member, committed, castAbility, now);
@@ -5950,12 +5953,14 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
continue;
}
applyPartyRomChampionRunePulseOnUse(runtimeMember, castAbility, now);
if (castAbility.gcdMs > 0 && castingHastePercent > 0) {
if (castingHastePercent > 0 && castAbility.gcdMs > 0
&& (abilityGcdCategory(committed, castAbility) === null
|| abilityGcdCategory(committed, castAbility) === 133 && castAbility.gcdMs >= 1_000)) {
committed = {
...committed,
globalCooldownEndsAt: now + Math.max(1_000, Math.round(
...startAbilityGlobalCooldown(committed, castAbility, now, Math.max(1_000, Math.round(
castAbility.gcdMs / (1 + castingHastePercent / 100),
)),
))),
};
}
applyPartyDeathKnightBladeBarrier(runtimeMember, committed, castAbility, now);
+3
View File
@@ -118,6 +118,7 @@ export interface PartyMember {
readonly comboTargetId: string | null;
readonly cooldowns: Readonly<Record<string, number>>;
readonly globalCooldownEndsAt: number;
readonly globalCooldownEndsByCategory?: Readonly<Record<string, number>>;
readonly activeCast: PartyActiveCast | null;
readonly activeCastTargetId: string | null;
readonly schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
@@ -502,6 +503,7 @@ function createMember(
comboTargetId: null,
cooldowns: Object.freeze({}),
globalCooldownEndsAt: 0,
globalCooldownEndsByCategory: {},
activeCast: null,
activeCastTargetId: null,
schoolLockedUntil: Object.freeze({}),
@@ -694,6 +696,7 @@ export interface PartyState {
| "lastResourceSpentAt"
| "cooldowns"
| "globalCooldownEndsAt"
| "globalCooldownEndsByCategory"
| "activeCast"
| "activeCastTargetId"
| "schoolLockedUntil"
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { preloadRuntimeTalentCatalog, runtimeTalentNodeById } from "./talentRuntimeCatalog";
import generatedData from "./wow335AbilityData.generated.json";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
const legalNodeIds = new Set(WOW335_TALENT_NODES.map((node) => node.id));
const sourceTalentChains = Object.values(generatedData.classes)
.flat()
.filter((chain) => chain.talentNodeId !== undefined);
const absentSourceNodeIds = sourceTalentChains
.map((chain) => chain.talentNodeId!)
.filter((nodeId) => !legalNodeIds.has(nodeId))
.sort();
assert.equal(runtimeTalentNodeById("talent-484"), null, "native bridge starts without the asynchronous talent snapshot");
assert.equal(sourceTalentChains.length, 145);
assert.equal(sourceTalentChains.filter((chain) => legalNodeIds.has(chain.talentNodeId!)).length, 142);
assert.deepEqual(absentSourceNodeIds, ["talent-1666", "talent-1714", "talent-2249"]);
function assertCatalogTalentGates(): void {
const abilitiesById = new Map(Object.values(WOW335_ABILITIES_BY_CLASS)
.flat()
.map((ability) => [ability.id, ability]));
for (const chain of sourceTalentChains) {
const ability = abilitiesById.get(chain.id);
assert.ok(ability, `source talent chain ${chain.id} remains exported`);
assert.equal(
ability.talentNodeId,
legalNodeIds.has(chain.talentNodeId!) ? chain.talentNodeId : undefined,
`source talent gate for ${chain.id}`,
);
}
const priestVe = abilitiesById.get("wow335-priest-vampiric-embrace");
const warriorDevastate = abilitiesById.get("wow335-warrior-devastate");
const rogueShadowstep = abilitiesById.get("wow335-rogue-shadowstep");
const shamanLavaLash = abilitiesById.get("wow335-shaman-lava-lash");
assert.ok(priestVe);
assert.ok(warriorDevastate);
assert.ok(rogueShadowstep);
assert.ok(shamanLavaLash);
assert.equal(priestVe.talentNodeId, "talent-484");
assert.equal(warriorDevastate.talentNodeId, undefined);
assert.equal(rogueShadowstep.talentNodeId, undefined);
assert.equal(shamanLavaLash.talentNodeId, undefined);
}
assertCatalogTalentGates();
await preloadRuntimeTalentCatalog();
assert.ok(runtimeTalentNodeById("talent-484"));
assert.equal(runtimeTalentNodeById("talent-1666"), null);
assertCatalogTalentGates();
+83
View File
@@ -1,8 +1,12 @@
import { execFileSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
abilityAtLevel,
abilityById,
isAbilityUnlocked,
learnedAbilityForBuild,
preloadAbilityCatalogsForClasses,
type AbilityDefinition,
type AbilityEffect,
@@ -183,6 +187,85 @@ describe("WoW 3.3.5 executable effect mapping", () => {
expect(livingBomb?.ranks.find((rank) => rank.spellId === 55_359)?.description).toContain("512 Fire damage");
});
it("keeps DBC aura and trigger healing tooltips out of direct-heal semantics", () => {
const vampiricEmbrace = byId("wow335-priest-vampiric-embrace");
const sourceVampiricEmbrace = generatedData.classes.priest.find((chain) => chain.name === "Vampiric Embrace");
const sourceRank = sourceVampiricEmbrace?.ranks.find((rank) => rank.spellId === 15_286);
expect(sourceRank?.effects).toContainEqual(expect.objectContaining({
effectType: 6,
auraType: 4,
implicitTargetA: 6,
triggerSpellId: 0,
}));
expect(vampiricEmbrace).toMatchObject({ target: "hostile", talentNodeId: "talent-484" });
expect(effectsOfKind(vampiricEmbrace, "heal")).toEqual([]);
expect(effectsOfKind(vampiricEmbrace, "damage")).toEqual([]);
expect(effectsOfKind(vampiricEmbrace, "apply-aura")).toContainEqual(expect.objectContaining({
sourceEffectType: 6,
sourceAuraType: 4,
aura: expect.objectContaining({ disposition: "debuff" }),
}));
const judgementOfLight = byId("wow335-paladin-judgement-of-light");
const sourceJudgementOfLight = generatedData.classes.paladin.find((chain) => chain.name === "Judgement of Light");
expect(sourceJudgementOfLight?.ranks[0]?.effects).toContainEqual(expect.objectContaining({
effectType: 6,
auraType: 4,
triggerSpellId: 0,
}));
expect(effectsOfKind(judgementOfLight, "heal")).toEqual([]);
expect(effectsOfKind(judgementOfLight, "damage")).toEqual([]);
expect(effectsOfKind(judgementOfLight, "apply-aura")).toEqual(expect.arrayContaining([
expect.objectContaining({
aura: expect.objectContaining({
procs: expect.arrayContaining([expect.objectContaining({
action: expect.objectContaining({ kind: "heal" }),
})]),
}),
}),
]));
});
it("preserves source-backed direct and party healing alongside aura tooltip filtering", () => {
const flashHeal = byId("wow335-priest-flash-heal");
const rawFlashHeal = generatedData.classes.priest.find((chain) => chain.name === "Flash Heal");
expect(rawFlashHeal?.ranks.every((rank) => rank.effects.some((effect) => effect.effectType === 10))).toBe(true);
expect(flashHeal.ranks?.every((rank) => effectsOfKind(rank, "heal").length === 1)).toBe(true);
const divineHymn = byId("wow335-priest-divine-hymn");
expect(effectsOfKind(divineHymn, "heal")).toEqual([
expect.objectContaining({ scope: "party", maximumTargets: 3, radius: 40 }),
]);
const divineStorm = byId("wow335-paladin-divine-storm");
expect(effectsOfKind(divineStorm, "heal")).toContainEqual(expect.objectContaining({ scope: "party" }));
});
it("preserves talent gates across a cold native import and a later talent preload", () => {
const projectRoot = process.cwd();
const viteNode = path.join(projectRoot, "node_modules", "vite-node", "vite-node.mjs");
const fixture = fileURLToPath(new URL("./wow335AbilityCatalog.cold-start.fixture.ts", import.meta.url));
expect(() => execFileSync(process.execPath, [viteNode, "--mode", "godot-bridge", fixture], {
cwd: projectRoot,
stdio: "pipe",
})).not.toThrow();
});
it("uses legal talent gates for learned builds and leaves source-only nodes unlocked", () => {
const vampiricEmbrace = byId("wow335-priest-vampiric-embrace");
expect(learnedAbilityForBuild(vampiricEmbrace, 80, {})).toBeNull();
expect(learnedAbilityForBuild(vampiricEmbrace, 80, { "talent-484": 1 })).not.toBeNull();
for (const id of [
"wow335-warrior-devastate",
"wow335-rogue-shadowstep",
"wow335-shaman-lava-lash",
]) {
const ability = byId(id);
expect(ability.talentNodeId).toBeUndefined();
expect(learnedAbilityForBuild(ability, 80, {})).not.toBeNull();
}
});
it("uses the same legal talent-node set as the full catalog", () => {
const talentNodeIds = new Set(Object.values(generatedData.classes)
.flatMap((chains) => chains.flatMap((chain) => chain.talentNodeId ? [chain.talentNodeId] : [])));
+20 -4
View File
@@ -32,7 +32,6 @@ import {
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
} from "./roguePoison";
import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
import { baseResourcesFor } from "./wotlkStats";
import {
WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITIES,
@@ -43,6 +42,9 @@ import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
import { WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS } from "./wow335DeterrenceRuntime";
import { WOW335_DETERRENCE_SOURCE_SIGNATURES } from "./wow335Deterrence";
// This generated source list is available synchronously when the abilities below are constructed.
const SOURCE_WOW335_TALENT_NODE_IDS = new Set(WOW335_TALENT_NODES.map((node) => node.id));
export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10;
interface Wow335Effect {
@@ -73,6 +75,7 @@ interface Wow335Rank {
readonly description: string;
readonly castTimeMs: number;
readonly cooldownMs: number;
readonly gcdCategoryId?: number;
readonly gcdMs: number;
readonly durationMs: number;
readonly channelDurationMs: number;
@@ -1851,6 +1854,13 @@ export function hunterExplosiveTrapDirectScaling(
};
}
function hasAuraOrTriggeredSourceEffect(rank: Wow335Rank): boolean {
return rank.effects.some((effect) => (
(APPLY_AURA_EFFECTS.has(effect.effectType) && effect.auraType > 0)
|| (TRIGGER_SPELL_EFFECTS.has(effect.effectType) && effect.triggerSpellId > 0)
));
}
function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
const effects: AbilityEffect[] = [];
for (const [effectIndex, effect] of rank.effects.entries()) {
@@ -2150,15 +2160,19 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
}
const auraOnlyStance = classId === "death-knight"
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
// Tooltip prose can describe later aura/proc payloads; DBC aura/trigger rows
// are not evidence of direct damage or healing from the cast itself.
const hasAuraOrTriggeredEffect = hasAuraOrTriggeredSourceEffect(rank);
if (!auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
&& !(classId === "mage" && abilityName === "Molten Armor")
&& !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
if (classId === "death-knight" && abilityName === "Death Coil") {
effects.unshift({ kind: "damage", coefficient: 1 });
} else if (!/^Create\b/i.test(abilityName)
} else if (!hasAuraOrTriggeredEffect && !/^Create\b/i.test(abilityName)
&& /\b(?:heal(?:s|ed|ing)?|restore(?:s|d)?|mend(?:s|ed)?|rejuvenat\w*)\b/i.test(rank.description)) {
effects.unshift({ kind: "heal", coefficient: 1 });
} else if (/\b(?:deals?|causes?|inflicts?)\b[^.]*\bdamage\b/i.test(rank.description)) {
} else if (!hasAuraOrTriggeredEffect
&& /\b(?:deals?|causes?|inflicts?)\b[^.]*\bdamage\b/i.test(rank.description)) {
effects.unshift({ kind: "damage", coefficient: 1 });
}
}
@@ -2367,6 +2381,7 @@ function rankDefinition(
: classId === "shaman" && abilityName === "Nature's Swiftness"
? Math.max(180_000, rank.cooldownMs)
: rank.cooldownMs,
gcdCategoryId: rank.gcdCategoryId,
gcdMs: rank.gcdMs,
cost: {
resource: rankResource,
@@ -2576,6 +2591,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
...(classicCooldownGroup(classId, chain.name)
? { cooldownGroup: classicCooldownGroup(classId, chain.name) }
: {}),
gcdCategoryId: first.gcdCategoryId,
gcdMs: first.gcdMs,
cost: first.cost,
effects: first.effects,
@@ -2586,7 +2602,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
// Lash). They cannot be allocated by any legal local build, so expose
// those retained class actions at their source level instead of making
// them permanently unlearnable behind a dangling talent ID.
talentNodeId: chain.talentNodeId && runtimeTalentNodeById(chain.talentNodeId)
talentNodeId: chain.talentNodeId && SOURCE_WOW335_TALENT_NODE_IDS.has(chain.talentNodeId)
? chain.talentNodeId
: undefined,
ranks: mappedRanks,
+1 -1
View File
@@ -1 +1 @@
{"source":{"product":"World of Warcraft","build":"3.3.5a.12340","archive":"../wow335a/Data/enUS/patch-enUS.MPQ","spellTableRecords":46583,"generatedAt":"2026-10-06T16:30:57.969Z"},"classes":{"warrior":[{"id":"wow335-warrior-battle-shout","name":"Battle Shout","iconBasename":"ability_warrior_battleshout","iconPath":"/assets/ui/spells/ability_warrior_battleshout.png","sourceUnlockLevel":1,"ranks":[{"spellId":6673,"rank":1,"level":1,"sourceLevel":1,"maxScalingLevel":11,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 15. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":15,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":15,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":5242,"rank":2,"level":12,"sourceLevel":12,"maxScalingLevel":21,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 35. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":35,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":35,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":6192,"rank":3,"level":22,"sourceLevel":22,"maxScalingLevel":31,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 55. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":55,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":55,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11549,"rank":4,"level":32,"sourceLevel":32,"maxScalingLevel":41,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 85. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":85,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":85,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11550,"rank":5,"level":42,"sourceLevel":42,"maxScalingLevel":51,"description":"The warrior shouts, increasing attack power of all raid and party members within thLine truncated
{"source":{"product":"World of Warcraft","build":"3.3.5a.12340","archive":"../wow335a/Data/enUS/patch-enUS.MPQ","spellTableRecords":46583,"generatedAt":"2026-10-06T16:30:57.969Z"},"classes":{"warrior":[{"id":"wow335-warrior-battle-shout","name":"Battle Shout","iconBasename":"ability_warrior_battleshout","iconPath":"/assets/ui/spells/ability_warrior_battleshout.png","sourceUnlockLevel":1,"ranks":[{"spellId":6673,"rank":1,"level":1,"sourceLevel":1,"maxScalingLevel":11,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 15. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"gcdCategoryId":133,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":15,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":15,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":5242,"rank":2,"level":12,"sourceLevel":12,"maxScalingLevel":21,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 35. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"gcdCategoryId":133,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":35,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":35,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":6192,"rank":3,"level":22,"sourceLevel":22,"maxScalingLevel":31,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 55. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"gcdCategoryId":133,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":55,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":55,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11549,"rank":4,"level":32,"sourceLevel":32,"maxScalingLevel":41,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 85. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"gcdCategoryId":133,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":85,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":85,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11550,"rank":5,"level":42,"sourceLevel":42,"maxScalingLevel":51,"description":"TheLine truncated
+225
View File
@@ -0,0 +1,225 @@
import { describe, expect, it } from "vitest";
import { commitPartyAbilityUse, partyAbilityIsReady } from "./partyAbilityAi";
import {
abilityAtLevel,
abilitiesForClass,
preloadAbilityCatalogsForClasses,
} from "./abilityCatalog";
import { useCombatStore } from "./combatStore";
import { createConfiguredPartyMember } from "./partyStore";
import {
abilityGcdCategory,
abilityGlobalCooldownActive,
globalCooldownForAbility,
startAbilityGlobalCooldown,
type GlobalCooldownActor,
} from "./wowGlobalCooldown";
const paladinAbility = (name: string) => {
const ability = abilitiesForClass("paladin").find((candidate) => candidate.name === name);
if (!ability) throw new Error(`Missing source Paladin ability: ${name}`);
return ability;
};
describe("source-derived WoW global cooldown categories", () => {
it.each([
{ level: 74, spellId: 48941, categoryId: 38 },
{ level: 78, spellId: 48941, categoryId: 38 },
{ level: 79, spellId: 48942, categoryId: 133 },
{ level: 80, spellId: 48942, categoryId: 133 },
])("selects Devotion Aura source spell $spellId and category $categoryId at level $level", ({ level, spellId, categoryId }) => {
const selected = abilityAtLevel(paladinAbility("Devotion Aura"), level);
expect(selected.dbcSpellId).toBe(spellId);
expect(selected.gcdCategoryId).toBe(categoryId);
expect(selected.gcdMs).toBe(1_500);
});
it("keeps independent source categories while projecting category 133 to the legacy scalar", () => {
const devotion = abilityAtLevel(paladinAbility("Devotion Aura"), 74);
const blessing = abilityAtLevel(paladinAbility("Blessing of Might"), 74);
let actor: GlobalCooldownActor = {
statRuleset: "wow",
globalCooldownEndsAt: 0,
globalCooldownEndsByCategory: {},
};
actor = { ...actor, ...startAbilityGlobalCooldown(actor, devotion, 1_000, 1_500) };
expect(actor.globalCooldownEndsAt).toBe(0);
expect(actor.globalCooldownEndsByCategory?.["38"]).toBe(2_500);
expect(globalCooldownForAbility(actor, blessing)).toBe(0);
expect(abilityGlobalCooldownActive(actor, blessing, 1_001)).toBe(false);
actor = { ...actor, ...startAbilityGlobalCooldown(actor, blessing, 1_100, 1_500) };
expect(actor.globalCooldownEndsByCategory).toEqual({ "38": 2_500, "133": 2_600 });
expect(actor.globalCooldownEndsAt).toBe(2_600);
expect(abilityGlobalCooldownActive(actor, devotion, 1_200)).toBe(true);
expect(abilityGlobalCooldownActive(actor, blessing, 1_200)).toBe(true);
});
it("checks a populated category for zero-duration abilities without starting or overwriting a timer", () => {
const zeroTime133 = {
id: "fixture-zero-time-category-133",
source: "wow335-progression" as const,
gcdCategoryId: 133,
gcdMs: 0,
};
const category38 = {
id: "fixture-category-38",
source: "wow335-progression" as const,
gcdCategoryId: 38,
gcdMs: 1_500,
};
const actor = {
statRuleset: "wow",
globalCooldownEndsAt: 2_500,
globalCooldownEndsByCategory: { "38": 1_500, "133": 2_500 },
};
expect(abilityGlobalCooldownActive(actor, zeroTime133, 2_000)).toBe(true);
expect(abilityGlobalCooldownActive(actor, zeroTime133, 2_500)).toBe(false);
expect(abilityGlobalCooldownActive(actor, category38, 2_000)).toBe(false);
expect(startAbilityGlobalCooldown(actor, zeroTime133, 2_000, 0)).toEqual({
globalCooldownEndsAt: 2_500,
globalCooldownEndsByCategory: actor.globalCooldownEndsByCategory,
});
});
it("uses category zero as its own real key and never aliases it to 133", () => {
const categoryZero = {
id: "fixture-positive-time-category-zero",
source: "wow335-progression" as const,
gcdCategoryId: 0,
gcdMs: 1_500,
};
const zeroDurationCategoryZero = { ...categoryZero, gcdMs: 0 };
const actor = { statRuleset: "wow", globalCooldownEndsAt: 0, globalCooldownEndsByCategory: {} };
const next = { ...actor, ...startAbilityGlobalCooldown(actor, categoryZero, 500, 900) };
expect(abilityGcdCategory(actor, zeroDurationCategoryZero)).toBe(0);
expect(next.globalCooldownEndsByCategory).toEqual({ "0": 1_400 });
expect(next.globalCooldownEndsAt).toBe(0);
expect(abilityGlobalCooldownActive(next, zeroDurationCategoryZero, 1_000)).toBe(true);
expect(abilityGlobalCooldownActive(next, { ...categoryZero, gcdCategoryId: 133 }, 1_000)).toBe(false);
});
it("keeps the legacy WoW fallback while leaving Runewaker scalar timers unchanged", () => {
const legacyPositive = { source: "wow335-progression" as const, gcdMs: 1_500 };
const legacyZero = { source: "wow335-progression" as const, gcdMs: 0 };
const wow = { statRuleset: "wow", globalCooldownEndsAt: 2_000, globalCooldownEndsByCategory: {} };
expect(abilityGcdCategory(wow, legacyPositive)).toBe(133);
expect(globalCooldownForAbility(wow, legacyPositive)).toBe(2_000);
expect(abilityGcdCategory(wow, legacyZero)).toBeNull();
expect(globalCooldownForAbility(wow, legacyZero)).toBe(0);
const romAbility = { source: "runewaker" as const, gcdCategoryId: 38, gcdMs: 700 };
const rom = { statRuleset: "rom", globalCooldownEndsAt: 3_000, globalCooldownEndsByCategory: { "38": 8_000 } };
expect(abilityGcdCategory(rom, romAbility)).toBeNull();
expect(globalCooldownForAbility(rom, romAbility)).toBe(3_000);
expect(startAbilityGlobalCooldown(rom, romAbility, 4_000, 700)).toEqual({
globalCooldownEndsAt: 4_700,
globalCooldownEndsByCategory: rom.globalCooldownEndsByCategory,
});
});
it("admits the real category-38 rank beside category 133, then enforces each timer", async () => {
await preloadAbilityCatalogsForClasses(["paladin"]);
const devotion = paladinAbility("Devotion Aura");
const blessing = paladinAbility("Blessing of Might");
for (const level of [74, 78, 79, 80]) {
useCombatStore.getState().initializeCharacter({
classId: "paladin",
level,
role: "tank",
specializationId: "paladin:protection",
raceId: "human",
gender: "male",
talentRanks: {},
inventory: [],
equipment: {},
});
const selected = abilityAtLevel(devotion, level);
const expectedCategory = level < 79 ? 38 : 133;
const expectedSpell = level < 79 ? 48941 : 48942;
expect(selected.dbcSpellId).toBe(expectedSpell);
expect(selected.gcdCategoryId).toBe(expectedCategory);
const first = useCombatStore.getState().castAbility(devotion.id, { now: 1_000 });
expect(first.ok, `Devotion Aura should cast at level ${level}: ${JSON.stringify(first)}`).toBe(true);
let state = useCombatStore.getState();
expect((state.globalCooldownEndsByCategory ?? {})[String(expectedCategory)]).toBe(2_500);
expect(state.globalCooldownEndsAt).toBe(expectedCategory === 133 ? 2_500 : 0);
const secondId = level < 79 ? blessing.id : devotion.id;
const second = state.castAbility(secondId, { now: 1_100 });
if (level < 79) {
expect(second.ok, "category 133 remains available while category 38 is active").toBe(true);
state = useCombatStore.getState();
expect((state.globalCooldownEndsByCategory ?? {})["38"]).toBe(2_500);
expect((state.globalCooldownEndsByCategory ?? {})["133"]).toBe(2_600);
expect(state.globalCooldownEndsAt).toBe(2_600);
expect(state.castAbility(devotion.id, { now: 1_200 })).toMatchObject({ ok: false, reason: "global-cooldown" });
} else {
expect(second).toMatchObject({ ok: false, reason: "global-cooldown" });
expect((useCombatStore.getState().globalCooldownEndsByCategory ?? {})["133"]).toBe(2_500);
}
}
});
it("keeps configured Paladin companions on independent source categories", async () => {
await preloadAbilityCatalogsForClasses(["paladin"]);
const devotion = paladinAbility("Devotion Aura");
const blessing = paladinAbility("Blessing of Might");
for (const level of [74, 79]) {
useCombatStore.getState().initializeCharacter({
classId: "paladin",
level,
role: "tank",
specializationId: "paladin:protection",
raceId: "human",
gender: "male",
talentRanks: {},
inventory: [],
equipment: {},
});
const member = createConfiguredPartyMember({
name: `Paladin GCD diagnostic ${level}`,
classId: "paladin",
raceId: "human",
gender: "male",
specialization: "Protection",
specializationId: "paladin:protection",
talentTreeId: "paladin:protection",
talentRanks: {},
role: "tank",
}, level, 0, `issue-114-paladin-${level}`);
const selectedDevotion = abilityAtLevel(devotion, level);
const selectedBlessing = abilityAtLevel(blessing, level);
expect(partyAbilityIsReady(member, selectedDevotion, 1_000)).toBe(true);
const afterDevotion = commitPartyAbilityUse(member, selectedDevotion, 1_000);
expect(afterDevotion).not.toBeNull();
if (!afterDevotion) throw new Error("The ready Devotion Aura should commit for the configured Paladin.");
if (level === 74) {
expect(afterDevotion.globalCooldownEndsByCategory?.["38"]).toBe(2_500);
expect(afterDevotion.globalCooldownEndsAt).toBe(0);
expect(partyAbilityIsReady(afterDevotion, selectedBlessing, 1_100),
"category 133 is ready while only category 38 is active").toBe(true);
const afterBlessing = commitPartyAbilityUse(afterDevotion, selectedBlessing, 1_100);
expect(afterBlessing?.globalCooldownEndsByCategory).toEqual({ "38": 2_500, "133": 2_600 });
expect(afterBlessing?.globalCooldownEndsAt).toBe(2_600);
expect(afterBlessing).not.toBeNull();
if (!afterBlessing) throw new Error("Category 133 readiness should permit Blessing of Might after category 38.");
expect(partyAbilityIsReady(afterBlessing, selectedDevotion, 1_200)).toBe(false);
expect(commitPartyAbilityUse(afterBlessing, selectedDevotion, 1_200)).toBeNull();
} else {
expect(afterDevotion.globalCooldownEndsByCategory?.["133"]).toBe(2_500);
expect(afterDevotion.globalCooldownEndsAt).toBe(2_500);
expect(partyAbilityIsReady(afterDevotion, selectedBlessing, 1_100)).toBe(false);
expect(commitPartyAbilityUse(afterDevotion, selectedBlessing, 1_100)).toBeNull();
}
}
});
});
+94
View File
@@ -0,0 +1,94 @@
export interface GlobalCooldownActor {
readonly statRuleset?: string;
readonly globalCooldownEndsAt: number;
readonly globalCooldownEndsByCategory?: Readonly<Record<string, number>>;
}
export interface GlobalCooldownAbility {
readonly source?: string;
readonly gcdCategoryId?: number;
readonly gcdMs: number;
}
function isRunewakerActor(actor: Pick<GlobalCooldownActor, "statRuleset">, ability: Pick<GlobalCooldownAbility, "source">): boolean {
return actor.statRuleset === "rom" || ability.source === "runewaker";
}
function hasCategory(value: number | undefined): value is number {
return value !== undefined && Number.isSafeInteger(value) && value >= 0;
}
/** Return the matching WotLK recovery category, or null for scalar-only behavior. */
export function abilityGcdCategory(
actor: Pick<GlobalCooldownActor, "statRuleset">,
ability: GlobalCooldownAbility,
): number | null {
if (isRunewakerActor(actor, ability)) return null;
if (hasCategory(ability.gcdCategoryId)) return ability.gcdCategoryId;
return ability.gcdMs > 0 ? 133 : null;
}
/** Return the active end time for this ability's category or legacy scalar timer. */
export function globalCooldownForAbility(actor: GlobalCooldownActor, ability: GlobalCooldownAbility): number {
if (isRunewakerActor(actor, ability)) return ability.gcdMs > 0 ? actor.globalCooldownEndsAt : 0;
const category = abilityGcdCategory(actor, ability);
if (category === null) return 0;
const key = String(category);
const categorizedEnd = actor.globalCooldownEndsByCategory?.[key];
if (categorizedEnd !== undefined) return categorizedEnd;
return category === 133 ? actor.globalCooldownEndsAt : 0;
}
export function abilityGlobalCooldownActive(
actor: GlobalCooldownActor,
ability: GlobalCooldownAbility,
now: number,
): boolean {
return globalCooldownForAbility(actor, ability) > now;
}
/** Start a category timer without changing unrelated categories or RoM state. */
export function startAbilityGlobalCooldown(
actor: GlobalCooldownActor,
ability: GlobalCooldownAbility,
now: number,
durationMs: number,
): Pick<GlobalCooldownActor, "globalCooldownEndsAt" | "globalCooldownEndsByCategory"> {
if (!(durationMs > 0) || !Number.isFinite(durationMs)) {
return {
globalCooldownEndsAt: actor.globalCooldownEndsAt,
...(actor.globalCooldownEndsByCategory
? { globalCooldownEndsByCategory: actor.globalCooldownEndsByCategory }
: {}),
};
}
if (isRunewakerActor(actor, ability)) {
return {
globalCooldownEndsAt: now + durationMs,
...(actor.globalCooldownEndsByCategory
? { globalCooldownEndsByCategory: actor.globalCooldownEndsByCategory }
: {}),
};
}
const category = abilityGcdCategory(actor, ability);
if (category === null) {
return {
globalCooldownEndsAt: actor.globalCooldownEndsAt,
...(actor.globalCooldownEndsByCategory
? { globalCooldownEndsByCategory: actor.globalCooldownEndsByCategory }
: {}),
};
}
const endTime = now + durationMs;
return {
globalCooldownEndsAt: category === 133 ? endTime : actor.globalCooldownEndsAt,
globalCooldownEndsByCategory: {
...actor.globalCooldownEndsByCategory,
[String(category)]: endTime,
},
};
}