Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a7e44cc14 |
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -75,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;
|
||||
@@ -2380,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,
|
||||
@@ -2589,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,
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user