Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a7e44cc14 | ||
|
|
fc21cbca9b |
No files matched your search
@@ -1,11 +0,0 @@
|
||||
# Account Boss Card checkpoints
|
||||
|
||||
`PATCH /api/cloud-save` adds an account-scoped checkpoint for Boss Cards without sending a replacement cloud document. The server derives the owner from the authenticated bearer session; request fields cannot choose an account. The body is `{ "bossCards": <raw collection>, "expectedRevision": <nonnegative safe integer> }`.
|
||||
|
||||
The server serializes the compare and write in a SQLite `BEGIN IMMEDIATE` transaction. A missing save has revision `0`. A matching request merges the submitted collection with that account’s saved cards, changes only `data.bossCards`, increments the cloud revision, and returns `{ revision, updatedAt, data }`. A stale revision returns HTTP `409` with `code: "revision_conflict"` and writes nothing. The client should reload the current save before retrying. Missing collections and invalid revisions return a client error; malformed collection values normalize to empty under the source policy without removing existing cards; an unauthenticated request is rejected before the body is used.
|
||||
|
||||
Normalization and merge behavior match `src/game/bossCards.ts`: the allowed rarities are Normal, Rare, Epic, and Legendary; positive finite counts are floored and bounded by `Number.MAX_SAFE_INTEGER`; invalid/unknown entries are dropped; repeated account copies keep maximum counts. Numeric `time:revision` claim IDs keep the later time and then higher revision; other claim strings use lexical maximum. Map-like output uses null-prototype count/claim records so names such as `__proto__` remain ordinary data keys.
|
||||
|
||||
Legacy `PUT /api/cloud-save` remains a full replacement for non-card fields. When either the stored or incoming document has a Boss Cards field, the server max-merges those collections into the incoming document. This keeps old clients that omit or resend stale card snapshots from deleting earned account cards, while their other fields retain the existing replacement behavior. PUT does not gain optimistic concurrency; PATCH is the revision-checked card-only route.
|
||||
|
||||
The focused loopback test uses the pinned source server factory, authenticated SQLite accounts, and a temporary database. It covers account isolation, cross-origin PATCH preflight, authorization, normalization, conflict/no-write and retry, concurrent revision checks, stale and omitted-card PUTs, deduplication, and server restart persistence. It does not contact the hosted service or exercise native UI integration.
|
||||
@@ -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.
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import test from "node:test";
|
||||
import { createGameServer } from "./server.mjs";
|
||||
|
||||
function fixtureUsername() {
|
||||
return `card${process.pid.toString(36)}${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
async function startServer(dataDir) {
|
||||
const root = path.join(dataDir, "http");
|
||||
const staticDir = path.join(root, "static");
|
||||
const contentDir = path.join(root, "content");
|
||||
mkdirSync(staticDir, { recursive: true });
|
||||
mkdirSync(contentDir, { recursive: true });
|
||||
const runtime = createGameServer({
|
||||
staticDir, contentDir, dataDir, enableShelvedModes: true, corsOrigins: "https://native-test.invalid",
|
||||
});
|
||||
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
|
||||
return { runtime, origin: `http://127.0.0.1:${runtime.server.address().port}` };
|
||||
}
|
||||
|
||||
async function stopServer(runtime) {
|
||||
if (!runtime?.server.listening) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
runtime.server.close((error) => error ? reject(error) : resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function request(origin, route, { method = "GET", token, body } = {}) {
|
||||
const response = await fetch(`${origin}${route}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
|
||||
async function register(origin, username, password) {
|
||||
const result = await request(origin, "/api/auth/register", {
|
||||
method: "POST", body: { username, password },
|
||||
});
|
||||
assert.equal(result.status, 201);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
test("cloud card PATCH checkpoints atomically per authenticated account and legacy PUT cannot erase cards", async () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "healer-boss-card-checkpoint-"));
|
||||
const firstPassword = `A${randomBytes(18).toString("base64url")}`;
|
||||
const secondPassword = `B${randomBytes(18).toString("base64url")}`;
|
||||
const firstUsername = fixtureUsername();
|
||||
const secondUsername = fixtureUsername();
|
||||
let runtime;
|
||||
|
||||
try {
|
||||
let service = await startServer(tempDir);
|
||||
runtime = service.runtime;
|
||||
const unauthenticated = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", body: { expectedRevision: 0, bossCards: {} },
|
||||
});
|
||||
assert.equal(unauthenticated.status, 401);
|
||||
const preflight = await fetch(`${service.origin}/api/cloud-save`, {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
Origin: "https://native-test.invalid",
|
||||
"Access-Control-Request-Method": "PATCH",
|
||||
"Access-Control-Request-Headers": "authorization,content-type",
|
||||
},
|
||||
});
|
||||
assert.equal(preflight.status, 204);
|
||||
assert.equal(preflight.headers.get("access-control-allow-origin"), "https://native-test.invalid");
|
||||
assert.match(preflight.headers.get("access-control-allow-methods"), /\bPATCH\b/);
|
||||
const unauthorizedMalformed = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: "not-a-valid-session-token",
|
||||
body: { expectedRevision: 0 },
|
||||
});
|
||||
assert.equal(unauthorizedMalformed.status, 401);
|
||||
|
||||
const firstAccount = await register(service.origin, firstUsername, firstPassword);
|
||||
const secondAccount = await register(service.origin, secondUsername, secondPassword);
|
||||
assert.notEqual(firstAccount.account.id, secondAccount.account.id);
|
||||
assert.ok(firstAccount.token);
|
||||
assert.ok(secondAccount.token);
|
||||
const missingCards = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: firstAccount.token, body: { expectedRevision: 0 },
|
||||
});
|
||||
assert.equal(missingCards.status, 400);
|
||||
assert.equal(missingCards.body.code, "invalid_boss_cards");
|
||||
|
||||
const initialFirst = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
|
||||
const initialSecond = await request(service.origin, "/api/cloud-save", { token: secondAccount.token });
|
||||
assert.equal(initialFirst.body.revision, 0);
|
||||
assert.deepEqual(initialFirst.body.data, { version: 1, characters: [] });
|
||||
assert.equal(initialSecond.body.revision, 0);
|
||||
|
||||
const rawCards = JSON.parse(
|
||||
'{"counts":{"__proto__":{"normal":9},"wow:55":{"normal":2.9,"rare":0,"epic":1.7,"legendary":9007199254740992,"unknown":50},"":{"normal":7}},"lastClaims":{"__proto__":"200:1","spawn:a":"100:2","ignored":42}}',
|
||||
);
|
||||
const firstCheckpoint = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH",
|
||||
token: firstAccount.token,
|
||||
body: { accountId: secondAccount.account.id, expectedRevision: 0, bossCards: rawCards },
|
||||
});
|
||||
assert.equal(firstCheckpoint.status, 200);
|
||||
assert.equal(firstCheckpoint.body.revision, 1);
|
||||
assert.deepEqual(firstCheckpoint.body.data.version, 1);
|
||||
assert.deepEqual(firstCheckpoint.body.data.characters, []);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].normal, 2);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].epic, 1);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].legendary, Number.MAX_SAFE_INTEGER);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].rare, undefined);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].unknown, undefined);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts[""], undefined);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts["__proto__"].normal, 9);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims["__proto__"], "200:1");
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims["spawn:a"], "100:2");
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims.ignored, undefined);
|
||||
|
||||
const conflict = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: firstAccount.token,
|
||||
body: { expectedRevision: 0, bossCards: { counts: { "wow:55": { normal: 50 } } } },
|
||||
});
|
||||
assert.equal(conflict.status, 409);
|
||||
assert.equal(conflict.body.code, "revision_conflict");
|
||||
const unchangedAfterConflict = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
|
||||
assert.deepEqual(unchangedAfterConflict.body, firstCheckpoint.body);
|
||||
|
||||
const invalidRevision = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: firstAccount.token,
|
||||
body: { expectedRevision: 1.5, bossCards: {} },
|
||||
});
|
||||
assert.equal(invalidRevision.status, 400);
|
||||
assert.equal(invalidRevision.body.code, "invalid_revision");
|
||||
|
||||
const retried = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: firstAccount.token,
|
||||
body: {
|
||||
expectedRevision: 1,
|
||||
bossCards: {
|
||||
counts: { "wow:55": { normal: 5, rare: 1 }, "wow:new": { epic: 1 } },
|
||||
lastClaims: { "spawn:a": "100:1", "spawn:b": "alpha" },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(retried.status, 200);
|
||||
assert.equal(retried.body.revision, 2);
|
||||
assert.equal(retried.body.data.bossCards.counts["wow:55"].normal, 5);
|
||||
assert.equal(retried.body.data.bossCards.counts["wow:55"].rare, 1);
|
||||
assert.equal(retried.body.data.bossCards.counts["wow:new"].epic, 1);
|
||||
assert.equal(retried.body.data.bossCards.lastClaims["spawn:a"], "100:2");
|
||||
|
||||
const otherCheckpoint = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: secondAccount.token,
|
||||
body: { expectedRevision: 0, bossCards: { counts: { "rom:9": { rare: 3 } } } },
|
||||
});
|
||||
assert.equal(otherCheckpoint.status, 200);
|
||||
assert.equal(otherCheckpoint.body.revision, 1);
|
||||
assert.deepEqual(Object.keys(otherCheckpoint.body.data.bossCards.counts), ["rom:9"]);
|
||||
const firstStillIsolated = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
|
||||
assert.equal(firstStillIsolated.body.revision, 2);
|
||||
assert.equal(firstStillIsolated.body.data.bossCards.counts["rom:9"], undefined);
|
||||
|
||||
const stalePut = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PUT", token: firstAccount.token,
|
||||
body: {
|
||||
data: {
|
||||
version: 2,
|
||||
characters: [{ id: "character-a" }],
|
||||
uiPreferences: { theme: "night" },
|
||||
futureField: { preservedDuringThisWrite: true },
|
||||
bossCards: {
|
||||
counts: { "wow:55": { normal: 1 }, "wow:legacy": { legendary: 1 } },
|
||||
lastClaims: { "spawn:a": "99:9", "spawn:c": "claim-c" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(stalePut.status, 200);
|
||||
assert.equal(stalePut.body.revision, 3);
|
||||
const stalePutExpectedData = JSON.parse(
|
||||
'{"version":2,"characters":[{"id":"character-a"}],"uiPreferences":{"theme":"night"},"futureField":{"preservedDuringThisWrite":true},"bossCards":{"counts":{"__proto__":{"normal":9},"wow:55":{"normal":5,"rare":1,"epic":1,"legendary":9007199254740991},"wow:new":{"epic":1},"wow:legacy":{"legendary":1}},"lastClaims":{"__proto__":"200:1","spawn:a":"100:2","spawn:b":"alpha","spawn:c":"claim-c"}}}',
|
||||
);
|
||||
assert.deepEqual(stalePut.body.data, stalePutExpectedData);
|
||||
assert.equal(stalePut.body.data.bossCards.counts["wow:55"].normal, 5);
|
||||
assert.equal(stalePut.body.data.bossCards.counts["wow:55"].rare, 1);
|
||||
assert.equal(stalePut.body.data.bossCards.counts["wow:legacy"].legendary, 1);
|
||||
assert.equal(stalePut.body.data.bossCards.lastClaims["spawn:a"], "100:2");
|
||||
|
||||
const omittedCardPut = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PUT", token: firstAccount.token,
|
||||
body: { data: { version: 3, characters: [], custom: "legacy replacement" } },
|
||||
});
|
||||
assert.equal(omittedCardPut.status, 200);
|
||||
assert.equal(omittedCardPut.body.revision, 4);
|
||||
assert.deepEqual(omittedCardPut.body.data, {
|
||||
version: 3,
|
||||
characters: [],
|
||||
custom: "legacy replacement",
|
||||
bossCards: stalePut.body.data.bossCards,
|
||||
});
|
||||
|
||||
await stopServer(runtime);
|
||||
runtime = null;
|
||||
service = await startServer(tempDir);
|
||||
runtime = service.runtime;
|
||||
const firstLogin = await request(service.origin, "/api/auth/login", {
|
||||
method: "POST", body: { username: firstUsername, password: firstPassword },
|
||||
});
|
||||
const secondLogin = await request(service.origin, "/api/auth/login", {
|
||||
method: "POST", body: { username: secondUsername, password: secondPassword },
|
||||
});
|
||||
assert.equal(firstLogin.status, 200);
|
||||
assert.equal(secondLogin.status, 200);
|
||||
const reopenedFirst = await request(service.origin, "/api/cloud-save", { token: firstLogin.body.token });
|
||||
const reopenedSecond = await request(service.origin, "/api/cloud-save", { token: secondLogin.body.token });
|
||||
assert.deepEqual(reopenedFirst.body.data, omittedCardPut.body.data);
|
||||
assert.deepEqual(reopenedSecond.body.data.bossCards.counts, { "rom:9": { rare: 3 } });
|
||||
|
||||
const concurrent = await Promise.all([0, 1].map(() => request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: firstLogin.body.token,
|
||||
body: { expectedRevision: 4, bossCards: stalePut.body.data.bossCards },
|
||||
})));
|
||||
assert.deepEqual(concurrent.map((result) => result.status).sort(), [200, 409]);
|
||||
assert.equal(concurrent.find((result) => result.status === 409).body.code, "revision_conflict");
|
||||
const afterConcurrentCheckpoint = await request(service.origin, "/api/cloud-save", { token: firstLogin.body.token });
|
||||
assert.equal(afterConcurrentCheckpoint.body.revision, 5);
|
||||
assert.deepEqual(afterConcurrentCheckpoint.body.data, omittedCardPut.body.data);
|
||||
const duplicateCheckpoint = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: firstLogin.body.token,
|
||||
body: { expectedRevision: 5, bossCards: stalePut.body.data.bossCards },
|
||||
});
|
||||
assert.equal(duplicateCheckpoint.status, 200);
|
||||
assert.equal(duplicateCheckpoint.body.revision, 6);
|
||||
assert.deepEqual(duplicateCheckpoint.body.data.bossCards, omittedCardPut.body.data.bossCards);
|
||||
} finally {
|
||||
await stopServer(runtime);
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
const BOSS_CARD_RARITIES = ["normal", "rare", "epic", "legendary"];
|
||||
|
||||
export function emptyBossCardCollection() {
|
||||
return { counts: {}, lastClaims: {} };
|
||||
}
|
||||
|
||||
/** Keep this normalization in step with src/game/bossCards.ts. */
|
||||
export function normalizeBossCardCollection(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return emptyBossCardCollection();
|
||||
const raw = value;
|
||||
const counts = Object.create(null);
|
||||
if (raw.counts && typeof raw.counts === "object" && !Array.isArray(raw.counts)) {
|
||||
for (const [key, candidate] of Object.entries(raw.counts)) {
|
||||
if (!key || !candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
|
||||
const entry = {};
|
||||
for (const rarity of BOSS_CARD_RARITIES) {
|
||||
const count = candidate[rarity];
|
||||
if (typeof count === "number" && Number.isFinite(count) && count >= 1) {
|
||||
entry[rarity] = Math.min(Number.MAX_SAFE_INTEGER, Math.floor(count));
|
||||
}
|
||||
}
|
||||
if (Object.keys(entry).length) counts[key] = entry;
|
||||
}
|
||||
}
|
||||
const lastClaims = raw.lastClaims && typeof raw.lastClaims === "object" && !Array.isArray(raw.lastClaims)
|
||||
? Object.fromEntries(Object.entries(raw.lastClaims).filter(([key, claim]) => key && typeof claim === "string"))
|
||||
: {};
|
||||
return { counts, lastClaims };
|
||||
}
|
||||
|
||||
/** Max-reconcile copies of one account collection, matching source mergeBossCardCollections(..., "max"). */
|
||||
export function mergeBossCardCollections(collections) {
|
||||
const counts = Object.create(null);
|
||||
const lastClaims = Object.create(null);
|
||||
for (const value of collections) {
|
||||
const collection = normalizeBossCardCollection(value);
|
||||
for (const [bossKey, entry] of Object.entries(collection.counts)) {
|
||||
const merged = counts[bossKey] ??= {};
|
||||
for (const rarity of BOSS_CARD_RARITIES) {
|
||||
const count = entry[rarity];
|
||||
if (count === undefined) continue;
|
||||
merged[rarity] = Math.max(merged[rarity] ?? 0, count);
|
||||
}
|
||||
}
|
||||
for (const [spawnId, claim] of Object.entries(collection.lastClaims)) {
|
||||
const previous = lastClaims[spawnId];
|
||||
if (!previous) { lastClaims[spawnId] = claim; continue; }
|
||||
if (/^\d+:/.test(previous) && /^\d+:/.test(claim)) {
|
||||
const [previousTime, previousRevision] = previous.split(":").map(Number);
|
||||
const [time, revision] = claim.split(":").map(Number);
|
||||
if (time > previousTime || (time === previousTime && revision > previousRevision)) lastClaims[spawnId] = claim;
|
||||
} else if (claim > previous) lastClaims[spawnId] = claim;
|
||||
}
|
||||
}
|
||||
return { counts, lastClaims };
|
||||
}
|
||||
+3
-70
@@ -26,7 +26,6 @@ import { createBiteSizedLayoutRepository } from "./bite-sized-layouts.mjs";
|
||||
import { BITE_SIZED_LAYOUT_CATALOG, isLayoutBiteSizedDungeon } from "../shared/bite-sized-layouts.mjs";
|
||||
import { createDungeonRaceRepository } from "./dungeon-races.mjs";
|
||||
import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs";
|
||||
import { mergeBossCardCollections } from "./boss-card-collections.mjs";
|
||||
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
@@ -403,78 +402,13 @@ export function openGameDatabase(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function cloudSaveDataForWrite(row) {
|
||||
if (!row) return null;
|
||||
try {
|
||||
const data = JSON.parse(row.data_json);
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("Cloud save data is not an object.");
|
||||
return data;
|
||||
} catch {
|
||||
throw new GameDatabaseError("The cloud save is unreadable.", 500, "cloud_save_corrupt");
|
||||
}
|
||||
}
|
||||
|
||||
function previousCloudSaveForPut(row) {
|
||||
try {
|
||||
return cloudSaveDataForWrite(row);
|
||||
} catch (error) {
|
||||
// Keep legacy PUT's ability to replace an unreadable save document.
|
||||
if (error instanceof GameDatabaseError && error.code === "cloud_save_corrupt") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function saveCloudData(accountId, data) {
|
||||
const now = Date.now();
|
||||
const row = statements.upsertCloudSave.get(accountId, JSON.stringify(data), now);
|
||||
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
|
||||
}
|
||||
|
||||
function transactCloudSave(operation) {
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = operation();
|
||||
database.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function putCloudSave(accountId, data) {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
throw new GameDatabaseError("Cloud save data must be an object.");
|
||||
}
|
||||
return transactCloudSave(() => {
|
||||
const previous = previousCloudSaveForPut(statements.cloudSave.get(accountId));
|
||||
const nextData = { ...data };
|
||||
const hasPreviousCards = previous !== null && Object.hasOwn(previous, "bossCards");
|
||||
const hasIncomingCards = Object.hasOwn(data, "bossCards");
|
||||
if (hasPreviousCards || hasIncomingCards) {
|
||||
nextData.bossCards = mergeBossCardCollections([previous?.bossCards, data.bossCards]);
|
||||
}
|
||||
return saveCloudData(accountId, nextData);
|
||||
});
|
||||
}
|
||||
|
||||
function patchCloudSaveBossCards(accountId, bossCards, expectedRevision) {
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
||||
throw new GameDatabaseError("Expected cloud save revision must be a nonnegative integer.", 400, "invalid_revision");
|
||||
}
|
||||
return transactCloudSave(() => {
|
||||
const row = statements.cloudSave.get(accountId);
|
||||
const currentRevision = row ? Number(row.revision) : 0;
|
||||
if (currentRevision !== expectedRevision) {
|
||||
throw new GameDatabaseError("Cloud save revision changed. Reload before retrying.", 409, "revision_conflict");
|
||||
}
|
||||
const previous = cloudSaveDataForWrite(row) ?? { version: 1, characters: [] };
|
||||
const nextData = {
|
||||
...previous,
|
||||
bossCards: mergeBossCardCollections([previous.bossCards, bossCards]),
|
||||
};
|
||||
return saveCloudData(accountId, nextData);
|
||||
});
|
||||
const now = Date.now();
|
||||
const row = statements.upsertCloudSave.get(accountId, JSON.stringify(data), now);
|
||||
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
|
||||
}
|
||||
|
||||
function isGameMaster(account) {
|
||||
@@ -1279,7 +1213,6 @@ export function openGameDatabase(options = {}) {
|
||||
logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); },
|
||||
getCloudSave,
|
||||
putCloudSave,
|
||||
patchCloudSaveBossCards,
|
||||
isGameMaster,
|
||||
getManastormConfig,
|
||||
putManastormConfig,
|
||||
|
||||
+1
-10
@@ -209,7 +209,7 @@ export function createGameServer(options = {}) {
|
||||
response.writeHead(204, {
|
||||
...cors,
|
||||
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
||||
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
});
|
||||
response.end();
|
||||
@@ -445,15 +445,6 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/cloud-save" && request.method === "PATCH") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
const body = await readJson(request, bodyLimit);
|
||||
if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "bossCards")) {
|
||||
throw new GameDatabaseError("Boss Card collection is required.", 400, "invalid_boss_cards");
|
||||
}
|
||||
json(response, 200, gameDatabase.patchCloudSaveBossCards(account.id, body.bossCards, body.expectedRevision));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/online-group" && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, gameDatabase.onlineGroupState(account.id));
|
||||
|
||||
@@ -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(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