Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1622f0893c | ||
|
|
8a7e44cc14 | ||
|
|
fc21cbca9b |
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.
|
||||
@@ -0,0 +1,46 @@
|
||||
# WoW talent-gate Set typing — 2026-10-09
|
||||
|
||||
This narrow source correction addresses shared-source issue [#4](https://git.whoagland.com/phenom/healer-man/issues/4), coordinated with native issue [#236](https://git.whoagland.com/Codex/healer-man-godot/issues/236). It changes only the inferred TypeScript type of the legal talent-node ID set in `src/game/wow335AbilityCatalog.ts`:
|
||||
|
||||
```ts
|
||||
const SOURCE_WOW335_TALENT_NODE_IDS = new Set<string>(WOW335_TALENT_NODES.map((node) => node.id));
|
||||
```
|
||||
|
||||
The runtime membership check and its inputs are unchanged. The explicit `string` type lets the imported talent-node ID pass strict `Set.has` checking without changing any legal node, exemption, ability rank, rotation, or gameplay value.
|
||||
|
||||
## Before and after
|
||||
|
||||
- Baseline catalog SHA-256: `6a7cf9b934026ec4b5beb0de3fe64eda4872efad561d63d25b2c911676d249a4`
|
||||
- Corrected catalog SHA-256: `3ea007068d8a8b6cd0c49d2d4b780cf176dc567e23fe20537e40f49fded14314`
|
||||
- The focused strict baseline emitted exactly one expected `TS2345` at `wow335AbilityCatalog.ts(2605,75)`, on `SOURCE_WOW335_TALENT_NODE_IDS.has(chain.talentNodeId)`.
|
||||
- The corrected source passed strict TypeScript 5.8.3 checking with zero diagnostics.
|
||||
- Three focused regressions passed: cold native import followed by talent preload, legal talent gates plus source-only-node exemptions, and equality with the full legal talent-node set. The targeted Vitest invocation selected these three tests; 41 other tests in that file were skipped.
|
||||
|
||||
The review run is recorded in `../diagnostics/strict-types/after-regression-tool-final2.json`. The sparse authoritative mirror strict check is recorded in `../diagnostics/strict-types/actual-mirror-types-final.json`; that run used TypeScript 5.8.3 with zero diagnostics and skipped runtime tests because the mirror does not contain the runtime fixture/dependencies. The baseline proof is `../diagnostics/strict-types/baseline-regression-tool.json`.
|
||||
|
||||
## Reproduction checker
|
||||
|
||||
`../scripts/check-wow-talent-gate-types.mjs` checks the catalog and its imported TypeScript dependency graph in strict mode. It can also run the three focused regressions when the project root has `vite.config.ts` and the cold-start fixture, and the toolchain root provides TypeScript, Vite, and Vitest.
|
||||
|
||||
From the source review checkout, run both type and runtime checks with:
|
||||
|
||||
```sh
|
||||
node scripts/check-wow-talent-gate-types.mjs \
|
||||
--project-root /path/to/source-review \
|
||||
--toolchain-root /path/to/source-review
|
||||
```
|
||||
|
||||
For a sparse source mirror with an external dependency tree, point the roots separately and omit only the runtime tests:
|
||||
|
||||
```sh
|
||||
node /path/to/source-mirror/scripts/check-wow-talent-gate-types.mjs \
|
||||
--project-root /path/to/source-mirror \
|
||||
--toolchain-root /path/to/toolchain \
|
||||
--skip-runtime
|
||||
```
|
||||
|
||||
`--expect baseline` is available to verify the recorded pre-fix `TS2345`; the default `--expect fixed` requires a clean strict check. The checker is focused evidence, not a full project build, runtime gameplay validation, balance certification, or GPU validation.
|
||||
|
||||
## Scope record
|
||||
|
||||
Coordinator: `/root`, session `01a11cd3-fa7c-7510-ba8f-bc7a3d0310a6`. The Set typing correction was the source-owned change; independent strict-type and focused-regression validation was performed by the validation worker. All workers used GPT-6 Luna with xhigh reasoning for this scoped work.
|
||||
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
function parseArgs(args) {
|
||||
const result = {
|
||||
projectRoot: process.cwd(),
|
||||
toolchainRoot: null,
|
||||
expectation: "fixed",
|
||||
skipRuntime: false,
|
||||
};
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const argument = args[index];
|
||||
if (argument === "--project-root") result.projectRoot = args[++index];
|
||||
else if (argument.startsWith("--project-root=")) result.projectRoot = argument.slice("--project-root=".length);
|
||||
else if (argument === "--toolchain-root") result.toolchainRoot = args[++index];
|
||||
else if (argument.startsWith("--toolchain-root=")) result.toolchainRoot = argument.slice("--toolchain-root=".length);
|
||||
else if (argument === "--expect") result.expectation = args[++index];
|
||||
else if (argument.startsWith("--expect=")) result.expectation = argument.slice("--expect=".length);
|
||||
else if (argument === "--skip-runtime") result.skipRuntime = true;
|
||||
else if (argument === "--help" || argument === "-h") {
|
||||
console.log("Usage: node scripts/check-wow-talent-gate-types.mjs [--project-root DIR] [--toolchain-root DIR] [--expect baseline|fixed] [--skip-runtime]");
|
||||
process.exit(0);
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
}
|
||||
assert.ok(["baseline", "fixed"].includes(result.expectation), `invalid --expect value: ${result.expectation}`);
|
||||
result.projectRoot = path.resolve(result.projectRoot);
|
||||
result.toolchainRoot = path.resolve(result.toolchainRoot ?? result.projectRoot);
|
||||
return result;
|
||||
}
|
||||
|
||||
function typecheckTalentCatalog(projectRoot, toolchainRoot) {
|
||||
const requireFromToolchain = createRequire(path.join(toolchainRoot, "package.json"));
|
||||
const ts = requireFromToolchain("typescript");
|
||||
const catalogPath = path.join(projectRoot, "src", "game", "wow335AbilityCatalog.ts");
|
||||
const viteClientPathCandidates = [
|
||||
path.join(projectRoot, "node_modules", "vite", "client.d.ts"),
|
||||
path.join(toolchainRoot, "node_modules", "vite", "client.d.ts"),
|
||||
];
|
||||
const viteClientPath = viteClientPathCandidates.find(existsSync);
|
||||
assert.ok(viteClientPath, "strict catalog check requires Vite's client.d.ts in the project or toolchain dependency tree");
|
||||
const configPath = path.join(projectRoot, "tsconfig.app.json");
|
||||
let parsedOptions;
|
||||
if (existsSync(configPath)) {
|
||||
const configText = readFileSync(configPath, "utf8");
|
||||
const parsedConfig = ts.parseConfigFileTextToJson(configPath, configText);
|
||||
assert.equal(parsedConfig.error, undefined, `could not parse ${configPath}`);
|
||||
const parsed = ts.parseJsonConfigFileContent(parsedConfig.config, ts.sys, projectRoot, undefined, configPath);
|
||||
assert.equal(parsed.errors.length, 0, `could not resolve ${configPath}: ${parsed.errors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("; ")}`);
|
||||
parsedOptions = parsed.options;
|
||||
} else {
|
||||
parsedOptions = {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
resolveJsonModule: true,
|
||||
allowImportingTsExtensions: true,
|
||||
paths: {},
|
||||
};
|
||||
}
|
||||
|
||||
const shimDirectory = mkdtempSync(path.join(os.tmpdir(), "wow-talent-gate-types-"));
|
||||
const aliasDeclarationPath = path.join(shimDirectory, "dungeonAssetModules.d.ts");
|
||||
writeFileSync(aliasDeclarationPath, [
|
||||
'declare module "@dungeonAssetModules" {',
|
||||
' export const dungeonModules: Readonly<Record<string, string>>;',
|
||||
'}',
|
||||
'',
|
||||
].join("\n"));
|
||||
|
||||
try {
|
||||
const compilerOptions = {
|
||||
...parsedOptions,
|
||||
strict: true,
|
||||
noEmit: true,
|
||||
skipLibCheck: true,
|
||||
incremental: false,
|
||||
composite: false,
|
||||
types: ["node"],
|
||||
baseUrl: projectRoot,
|
||||
typeRoots: [
|
||||
path.join(projectRoot, "node_modules", "@types"),
|
||||
path.join(toolchainRoot, "node_modules", "@types"),
|
||||
],
|
||||
paths: { ...(parsedOptions.paths ?? {}) },
|
||||
};
|
||||
delete compilerOptions.paths["@dungeonAssetModules"];
|
||||
const rootNames = [catalogPath, viteClientPath, aliasDeclarationPath];
|
||||
const program = ts.createProgram({ rootNames, options: compilerOptions });
|
||||
const diagnostics = ts.getPreEmitDiagnostics(program).filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
|
||||
const simplified = diagnostics.map((diagnostic) => {
|
||||
const location = diagnostic.file && diagnostic.start !== undefined
|
||||
? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start)
|
||||
: undefined;
|
||||
const sourceLine = diagnostic.file && location
|
||||
? diagnostic.file.text.split(/\r?\n/u)[location.line]
|
||||
: undefined;
|
||||
return {
|
||||
code: diagnostic.code,
|
||||
file: diagnostic.file ? path.relative(projectRoot, diagnostic.file.fileName).split(path.sep).join("/") : null,
|
||||
line: location ? location.line + 1 : null,
|
||||
message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
||||
sourceLine: sourceLine?.trim() ?? null,
|
||||
};
|
||||
});
|
||||
return { typescriptVersion: ts.version, diagnostics: simplified };
|
||||
} finally {
|
||||
rmSync(shimDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runTalentRuntimeRegressions(projectRoot, toolchainRoot) {
|
||||
assert.ok(existsSync(path.join(projectRoot, "vite.config.ts")), "focused runtime checks require vite.config.ts; use --skip-runtime for a source-only mirror without it");
|
||||
assert.ok(existsSync(path.join(projectRoot, "src", "game", "wow335AbilityCatalog.cold-start.fixture.ts")), "focused runtime checks require the cold-start fixture; use --skip-runtime when it is not present");
|
||||
const requireFromToolchain = createRequire(path.join(toolchainRoot, "package.json"));
|
||||
const vitestPath = requireFromToolchain.resolve("vitest/vitest.mjs");
|
||||
const testPath = path.join("src", "game", "wow335AbilityCatalog.test.ts");
|
||||
const matchingTests = [
|
||||
"preserves talent gates across a cold native import and a later talent preload",
|
||||
"uses legal talent gates for learned builds and leaves source-only nodes unlocked",
|
||||
"uses the same legal talent-node set as the full catalog",
|
||||
];
|
||||
const testPattern = matchingTests.join("|");
|
||||
try {
|
||||
const output = execFileSync(process.execPath, [
|
||||
vitestPath,
|
||||
"run",
|
||||
testPath,
|
||||
"--config",
|
||||
"vite.config.ts",
|
||||
"--maxWorkers=1",
|
||||
"--testTimeout=15000",
|
||||
"--testNamePattern",
|
||||
testPattern,
|
||||
], {
|
||||
cwd: projectRoot,
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
const passedMatch = output.match(/Tests\s+(\d+)\s+passed/u);
|
||||
assert.ok(passedMatch, `Vitest did not report a passing test count:\n${output}`);
|
||||
assert.equal(Number(passedMatch[1]), matchingTests.length, `expected all focused talent gate tests to run:\n${output}`);
|
||||
return { passed: Number(passedMatch[1]), tests: matchingTests, output: output.trim() };
|
||||
} catch (error) {
|
||||
const stdout = error.stdout?.toString() ?? "";
|
||||
const stderr = error.stderr?.toString() ?? "";
|
||||
throw new Error(`focused talent runtime tests failed\n${stdout}\n${stderr}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const projectRoot = args.projectRoot;
|
||||
const toolchainRoot = args.toolchainRoot;
|
||||
const catalogPath = path.join(projectRoot, "src", "game", "wow335AbilityCatalog.ts");
|
||||
const catalogText = readFileSync(catalogPath, "utf8");
|
||||
assert.match(catalogText, /SOURCE_WOW335_TALENT_NODE_IDS\.has\(chain\.talentNodeId\)/u, "the actual source catalog must retain the legal-node gate");
|
||||
|
||||
const typecheck = typecheckTalentCatalog(projectRoot, toolchainRoot);
|
||||
const expectedDiagnostic = typecheck.diagnostics.length === 1
|
||||
&& typecheck.diagnostics[0].code === 2345
|
||||
&& typecheck.diagnostics[0].file === "src/game/wow335AbilityCatalog.ts"
|
||||
&& typecheck.diagnostics[0].sourceLine?.includes("SOURCE_WOW335_TALENT_NODE_IDS.has(chain.talentNodeId)");
|
||||
if (args.expectation === "baseline") {
|
||||
assert.ok(expectedDiagnostic, `expected exactly the pre-fix TS2345 at the actual talent Set gate; got ${JSON.stringify(typecheck.diagnostics, null, 2)}`);
|
||||
} else {
|
||||
assert.deepEqual(typecheck.diagnostics, [], `the corrected catalog and its focused dependency graph must type-check cleanly`);
|
||||
}
|
||||
|
||||
const runtime = args.skipRuntime ? null : runTalentRuntimeRegressions(projectRoot, toolchainRoot);
|
||||
const result = {
|
||||
projectRoot,
|
||||
toolchainRoot,
|
||||
catalogSha256: createHash("sha256").update(catalogText).digest("hex"),
|
||||
expectation: args.expectation,
|
||||
typecheck: {
|
||||
typescriptVersion: typecheck.typescriptVersion,
|
||||
strict: true,
|
||||
diagnostics: typecheck.diagnostics,
|
||||
result: expectedDiagnostic ? "expected-baseline-ts2345" : "clean",
|
||||
},
|
||||
runtime,
|
||||
limits: [
|
||||
"Strict-checks the actual catalog and its imported TypeScript dependency graph, with only the bundler's dungeon asset glob import given its declared Readonly<Record<string, string>> type.",
|
||||
"Runs only the three legal talent gate, source-only exemption, and cold/warm preload regressions; it does not run a full project build or balance sweep.",
|
||||
],
|
||||
};
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
@@ -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] : [])));
|
||||
|
||||
@@ -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<string>(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 @@
|
||||
{"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