forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
4957b69e86
commit
8a98797fcf
30 files changed
+469
-39
No files matched your search
@@ -286,7 +286,8 @@ function slimRank(spell) {
|
||||
description: resolveDescription(spell),
|
||||
castTimeMs: spell.castTimeMs,
|
||||
cooldownMs: Math.max(spell.recoveryTimeMs, spell.categoryRecoveryTimeMs),
|
||||
gcdMs: spell.startRecoveryTimeMs || 1500,
|
||||
// Zero is the DBC signal for abilities that do not start a global cooldown.
|
||||
gcdMs: spell.startRecoveryTimeMs,
|
||||
// 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.
|
||||
|
||||
@@ -65,7 +65,9 @@ describe("random dungeon queue", () => {
|
||||
installPack,
|
||||
installDungeonRuntime: vi.fn(async () => undefined),
|
||||
})).resolves.toMatchObject({ status: "entered" });
|
||||
expect(options.enterDungeon).toHaveBeenCalledWith("bite-sized-stormwind-stockade", "healer", expect.any(Object));
|
||||
const selectedId = vi.mocked(options.enterDungeon).mock.calls[0][0];
|
||||
expect(dungeonById(selectedId)?.category).toBe("bite-sized");
|
||||
expect(options.enterDungeon).toHaveBeenCalledWith(selectedId, "healer", expect.any(Object));
|
||||
expect(installPack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ const rankEffectsForSpell = (spellId: number): readonly AbilityEffect[] => ranks
|
||||
.find(({ rank }) => rank.spellId === spellId)?.rank.effects ?? [];
|
||||
|
||||
describe("Ascension Conquest ability catalog", () => {
|
||||
it("preserves zero DBC start recovery times on playable abilities", () => {
|
||||
const offGcdRanks = ranks.filter(({ ability, rank }) => (
|
||||
!ability.passive && coaSpellById(rank.spellId)?.dbc?.startRecoveryTimeMs === 0
|
||||
));
|
||||
expect(offGcdRanks.length).toBeGreaterThan(10);
|
||||
for (const { rank } of offGcdRanks) expect(rank.gcdMs, `spell ${rank.spellId}`).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps Rejuvenating Venom healing in its cast proc", () => {
|
||||
const venom = byName("venomancer", "Rejuvenating Venom");
|
||||
expect(venom.effects.some(effect => effect.kind === "heal")).toBe(false);
|
||||
|
||||
@@ -1246,7 +1246,7 @@ function rankDefinition(
|
||||
mechanicsTooltip,
|
||||
Math.max(dbc?.recoveryTimeMs ?? 0, dbc?.categoryRecoveryTimeMs ?? 0),
|
||||
),
|
||||
gcdMs: dbc?.startRecoveryTimeMs || 1_500,
|
||||
gcdMs: dbc?.startRecoveryTimeMs ?? 1_500,
|
||||
cost: liveTooltipCost(resourceForSpell(resource, dbc), mechanicsTooltip, rawCost),
|
||||
effects,
|
||||
};
|
||||
|
||||
@@ -542,7 +542,8 @@ describe("combat store", () => {
|
||||
aura.definition.utilityTags?.includes("next-heal-free-critical")
|
||||
))).toBe(true);
|
||||
|
||||
const heal = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", undefined, 3_000);
|
||||
expect(useCombatStore.getState().globalCooldownEndsAt).toBe(0);
|
||||
const heal = useCombatStore.getState().castAbility("wow335-priest-lesser-heal", undefined, 1_000);
|
||||
expect(heal.ok).toBe(true);
|
||||
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeGreaterThan(0);
|
||||
const completion = useCombatStore.getState().activeCast!.completesAt;
|
||||
@@ -560,6 +561,21 @@ describe("combat store", () => {
|
||||
.toMatchObject({ ok: false, reason: "not-enough-resource" });
|
||||
});
|
||||
|
||||
it("uses Inner Focus during another spell's global cooldown without clearing it", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
const talent = TALENT_NODES.find((node) => node.name === "Inner Focus")!;
|
||||
useCombatStore.setState({ talentRanks: { [talent.id]: 1 } });
|
||||
const innerFocus = abilitiesForClass("priest").find((ability) => ability.name === "Inner Focus")!;
|
||||
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-power-word-shield", undefined, 1_000).ok).toBe(true);
|
||||
const gcdEnd = useCombatStore.getState().globalCooldownEndsAt;
|
||||
expect(gcdEnd).toBeGreaterThan(1_000);
|
||||
expect(useCombatStore.getState().castAbility(innerFocus.id, undefined, 1_100).ok).toBe(true);
|
||||
expect(useCombatStore.getState().globalCooldownEndsAt).toBe(gcdEnd);
|
||||
expect(useCombatStore.getState().castAbility("wow335-priest-renew", undefined, 1_100))
|
||||
.toMatchObject({ ok: false, reason: "global-cooldown" });
|
||||
});
|
||||
|
||||
it("ticks damage over time and awards kill experience exactly once", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 1, xp: 0 });
|
||||
registerTarget("ooze", [10, 0, 0], 5, 100);
|
||||
|
||||
@@ -4983,7 +4983,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
&& aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID).some((aura) => aura.definition.utilityTags?.includes("pacified"))
|
||||
) failure = "player-controlled";
|
||||
else if (state.activeCast && !completingCast) failure = "already-casting";
|
||||
else if (!completingCast && state.globalCooldownEndsAt > now) failure = "global-cooldown";
|
||||
else if (!completingCast && ability.gcdMs > 0 && state.globalCooldownEndsAt > now) failure = "global-cooldown";
|
||||
else if (!completingCast && (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";
|
||||
@@ -5285,7 +5285,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
critical: false,
|
||||
outcome: "started",
|
||||
}),
|
||||
globalCooldownEndsAt: now + gcdMs,
|
||||
globalCooldownEndsAt: gcdMs > 0 ? now + gcdMs : state.globalCooldownEndsAt,
|
||||
selectedTargetId: effectiveTarget === "hostile" ? targetId : state.selectedTargetId,
|
||||
feedback: nextFeedback(
|
||||
"success",
|
||||
@@ -6587,7 +6587,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
areaEffects: work.areaEffects,
|
||||
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
|
||||
cooldowns,
|
||||
globalCooldownEndsAt: completingCast ? state.globalCooldownEndsAt : now + gcdMs,
|
||||
globalCooldownEndsAt: completingCast || gcdMs <= 0 ? state.globalCooldownEndsAt : now + gcdMs,
|
||||
activeCast: null,
|
||||
...autoAttackUpdate,
|
||||
playerAnimationEvent: nextAnimationEvent("ability-release", ability.id),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { dungeonArtworkContains, dungeonArtworkFloorFor, dungeonArtworkHeading,
|
||||
describe("RuneWaker dungeon maps", () => {
|
||||
it("packages a checksummed, position-aware map with every RuneWaker dungeon", () => {
|
||||
const dungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion === "runewaker");
|
||||
expect(dungeons).toHaveLength(30);
|
||||
expect(dungeons).toHaveLength(33);
|
||||
for (const dungeon of dungeons) {
|
||||
const map = dungeonArtworkMapFor(dungeon);
|
||||
expect(map, dungeon.id).not.toBeNull();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import runewakerCatalog from "./generated/runewakerDungeonMaps.generated.json";
|
||||
import { dungeonContentSourceId } from "./dungeonContentSource";
|
||||
import type { DungeonDefinition, Vector3Tuple } from "./dungeonTypes";
|
||||
import {
|
||||
wowDungeonMapFor,
|
||||
@@ -38,7 +39,7 @@ const runewakerMaps = runewakerCatalog.dungeons as unknown as Readonly<Record<st
|
||||
export function dungeonArtworkMapFor(dungeon: DungeonDefinition): DungeonArtworkMap | null {
|
||||
const wow = wowDungeonMapFor(dungeon);
|
||||
if (wow) return { kind: "wow", source: wow, floors: wow.floors, width: 1002, height: 668 };
|
||||
const source = runewakerMaps[dungeon.id];
|
||||
const source = runewakerMaps[dungeonContentSourceId(dungeon.id)];
|
||||
if (source?.mapId !== dungeon.mapId
|
||||
|| source.coordinateTransform !== dungeon.provenance.coordinateTransform
|
||||
|| source.visualChecksum !== dungeon.assets.visual[0]?.checksum
|
||||
|
||||
@@ -72,7 +72,7 @@ const WDT_PLACEMENT_DUNGEON_IDS = [
|
||||
describe("generated dungeon campaign catalog", () => {
|
||||
it("covers only reviewed five-player instances with loadable assets and combat content", () => {
|
||||
expect(generated.definitions).toHaveLength(54);
|
||||
expect(DUNGEON_DEFINITIONS).toHaveLength(89);
|
||||
expect(DUNGEON_DEFINITIONS).toHaveLength(92);
|
||||
expect(generated.definitions.map((dungeon) => dungeon.id))
|
||||
.toEqual(campaign.dungeons.map((dungeon) => dungeon.slug));
|
||||
for (const dungeon of generated.definitions) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import queuePolicyCatalog from "./generated/dungeonLeaderboardCatalog.generated.
|
||||
|
||||
describe("dungeon catalog", () => {
|
||||
it("lists the complete campaign and bite-sized variants", () => {
|
||||
expect(DUNGEON_CATALOG).toHaveLength(89);
|
||||
expect(DUNGEON_CATALOG).toHaveLength(92);
|
||||
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({
|
||||
id: "stormwind-stockade",
|
||||
releaseStage: "beta",
|
||||
@@ -53,8 +53,10 @@ describe("dungeon catalog", () => {
|
||||
);
|
||||
expect(dungeonsByCategory("rom")).toHaveLength(30);
|
||||
expect(dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id).sort())
|
||||
.toEqual(["bite-sized-deadmines-ship", "bite-sized-stormwind-stockade",
|
||||
"bite-sized-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns"]);
|
||||
.toEqual(["bite-sized-deadmines-ship", "bite-sized-forsaken-abbey-ancalon",
|
||||
"bite-sized-mystic-altar-golem", "bite-sized-paspers-shrine-hafiz",
|
||||
"bite-sized-stormwind-stockade", "bite-sized-utgarde-pinnacle-skadi",
|
||||
"bite-sized-wailing-caverns"]);
|
||||
});
|
||||
|
||||
it("selects another available category dungeon", () => {
|
||||
@@ -105,6 +107,9 @@ describe("dungeon catalog", () => {
|
||||
it("keeps all Stockades experiences in Beta and alphabetizes each release group", () => {
|
||||
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.releaseStage === "beta").map((dungeon) => dungeon.id))
|
||||
.toEqual([
|
||||
"bite-sized-forsaken-abbey-ancalon",
|
||||
"bite-sized-mystic-altar-golem",
|
||||
"bite-sized-paspers-shrine-hafiz",
|
||||
"randomized-bite-sized-stormwind-stockade",
|
||||
"stormwind-stockade",
|
||||
"bite-sized-stormwind-stockade",
|
||||
@@ -134,7 +139,8 @@ describe("dungeon catalog", () => {
|
||||
|
||||
it("shares the complete dungeon identity list used to validate reports on the server", () => {
|
||||
expect(DUNGEON_CATALOG.map((dungeon) => dungeon.id).sort())
|
||||
.toEqual(["bite-sized-deadmines-ship", "bite-sized-stormwind-stockade",
|
||||
.toEqual(["bite-sized-deadmines-ship", "bite-sized-forsaken-abbey-ancalon",
|
||||
"bite-sized-mystic-altar-golem", "bite-sized-paspers-shrine-hafiz", "bite-sized-stormwind-stockade",
|
||||
"bite-sized-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns",
|
||||
"randomized-bite-sized-stormwind-stockade", "forsaken-abbey",
|
||||
...Object.keys(availability.enabled)].sort());
|
||||
|
||||
@@ -4,6 +4,9 @@ export const RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID = "randomized-bite-sized
|
||||
export const BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID = "bite-sized-wailing-caverns";
|
||||
export const BITE_SIZED_DEADMINES_DUNGEON_ID = "bite-sized-deadmines-ship";
|
||||
export const BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID = "bite-sized-utgarde-pinnacle-skadi";
|
||||
export const BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID = "bite-sized-forsaken-abbey-ancalon";
|
||||
export const BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID = "bite-sized-paspers-shrine-hafiz";
|
||||
export const BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID = "bite-sized-mystic-altar-golem";
|
||||
export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-sized";
|
||||
|
||||
const BITE_SIZED_DUNGEON_IDS: ReadonlySet<string> = new Set([
|
||||
@@ -12,6 +15,9 @@ const BITE_SIZED_DUNGEON_IDS: ReadonlySet<string> = new Set([
|
||||
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
|
||||
BITE_SIZED_DEADMINES_DUNGEON_ID,
|
||||
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
|
||||
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
|
||||
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
|
||||
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
|
||||
]);
|
||||
|
||||
export function isBiteSizedDungeonId(dungeonId: string): boolean {
|
||||
@@ -29,6 +35,9 @@ const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.free
|
||||
[BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID]: "wailing-caverns",
|
||||
[BITE_SIZED_DEADMINES_DUNGEON_ID]: "deadmines",
|
||||
[BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID]: "utgarde-pinnacle",
|
||||
[BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID]: "forsaken-abbey",
|
||||
[BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID]: "paspers-shrine",
|
||||
[BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID]: "mystic-altar",
|
||||
});
|
||||
|
||||
export function isDerivedStockadesDungeonId(dungeonId: string): boolean {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("dungeon leaderboard catalog", () => {
|
||||
})).sort((a, b) => a.id.localeCompare(b.id)),
|
||||
classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })),
|
||||
};
|
||||
expect(catalog.dungeons).toHaveLength(89);
|
||||
expect(catalog.dungeons).toHaveLength(92);
|
||||
expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]);
|
||||
const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`);
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("dungeon pipeline runtime foundation", () => {
|
||||
});
|
||||
|
||||
it("registers every reviewed instanced dungeon with WC first", () => {
|
||||
expect(DUNGEON_DEFINITIONS).toHaveLength(89);
|
||||
expect(DUNGEON_DEFINITIONS).toHaveLength(92);
|
||||
expect(DUNGEON_DEFINITIONS[0].id).toBe("wailing-caverns");
|
||||
expect(DUNGEON_DEFINITIONS.some((definition) => definition.id === "deadmines")).toBe(true);
|
||||
expect(DUNGEON_DEFINITIONS.some((definition) => definition.id === "forsaken-abbey")).toBe(true);
|
||||
|
||||
@@ -33,6 +33,11 @@ import {
|
||||
createBiteSizedUtgardePinnacleDefinition,
|
||||
createBiteSizedWailingCavernsDefinition,
|
||||
} from "./dungeons/biteSizedWoWDungeons";
|
||||
import {
|
||||
createBiteSizedForsakenAbbeyDefinition,
|
||||
createBiteSizedMysticAltarDefinition,
|
||||
createBiteSizedPaspersShrineDefinition,
|
||||
} from "./dungeons/biteSizedRoMDungeons";
|
||||
import {
|
||||
createRandomizedBiteSizedStockadesDefinition,
|
||||
randomizedBiteSizedCreatureCatalog,
|
||||
@@ -231,6 +236,15 @@ const randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSee
|
||||
const biteSizedWailingCaverns = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition);
|
||||
const biteSizedDeadmines = createBiteSizedDeadminesDefinition(deadminesDefinition);
|
||||
const biteSizedUtgardePinnacle = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition);
|
||||
const forsakenAbbeyDefinition = adaptedSourceRegistry["forsaken-abbey"];
|
||||
const paspersShrineDefinition = adaptedSourceRegistry["paspers-shrine"];
|
||||
const mysticAltarDefinition = adaptedSourceRegistry["mystic-altar"];
|
||||
if (!forsakenAbbeyDefinition || !paspersShrineDefinition || !mysticAltarDefinition) {
|
||||
throw new Error("The three RoM source dungeons are required for their bite-sized variants.");
|
||||
}
|
||||
const biteSizedForsakenAbbey = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition);
|
||||
const biteSizedPaspersShrine = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition);
|
||||
const biteSizedMysticAltar = createBiteSizedMysticAltarDefinition(mysticAltarDefinition);
|
||||
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
|
||||
...adaptedSourceEntries,
|
||||
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
|
||||
@@ -238,6 +252,9 @@ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[]
|
||||
[biteSizedWailingCaverns.id, biteSizedWailingCaverns],
|
||||
[biteSizedDeadmines.id, biteSizedDeadmines],
|
||||
[biteSizedUtgardePinnacle.id, biteSizedUtgardePinnacle],
|
||||
[biteSizedForsakenAbbey.id, biteSizedForsakenAbbey],
|
||||
[biteSizedPaspersShrine.id, biteSizedPaspersShrine],
|
||||
[biteSizedMysticAltar.id, biteSizedMysticAltar],
|
||||
];
|
||||
|
||||
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
|
||||
import { isBiteSizedDungeonId } from "./dungeonContentSource";
|
||||
import { completedDungeonRun, finalDungeonBoss, useDungeonRunStore } from "./dungeonRunStore";
|
||||
import { normalizeDungeonRunProgress, formatDungeonDuration, type DungeonRunMember } from "./dungeonRunTypes";
|
||||
import { activateDungeonEncounterDefinitions, activeDungeonCompletedObjectiveIds, activeDungeonRunIsComplete,
|
||||
@@ -49,7 +50,8 @@ describe("dungeon completion and run timing", () => {
|
||||
});
|
||||
|
||||
it("adds durable completion bookkeeping to every RoM dungeon with configured bosses, without awarding missing bosses", () => {
|
||||
for (const dungeon of DUNGEON_DEFINITIONS.filter((entry) => entry.expansion === "runewaker" && entry.bosses.length > 0)) {
|
||||
for (const dungeon of DUNGEON_DEFINITIONS.filter((entry) => entry.expansion === "runewaker"
|
||||
&& !isBiteSizedDungeonId(entry.id) && entry.bosses.length > 0)) {
|
||||
const baseline = dungeon.encounters!.find((entry) => entry.id === `${dungeon.id}:completion-baseline`)!;
|
||||
expect(baseline, dungeon.id).toBeDefined();
|
||||
let snapshot = createDungeonEncounterSnapshot(baseline, 0);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { NodeIO } from "@gltf-transform/core";
|
||||
import { BufferGeometry, Float32BufferAttribute, Matrix4, Vector3 } from "three";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { dungeonContentPackId, dungeonContentSourceId } from "../dungeonContentSource";
|
||||
import { DUNGEON_REGISTRY } from "../dungeonRegistry";
|
||||
import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "../dungeonEncounterRuntime";
|
||||
import { buildPartyNavigationGraph, findNearestPartyNavigationSurface, findPartyNavigationPath } from "../partyNavigation";
|
||||
|
||||
const sliceIds = [
|
||||
"bite-sized-forsaken-abbey-ancalon",
|
||||
"bite-sized-paspers-shrine-hafiz",
|
||||
"bite-sized-mystic-altar-golem",
|
||||
] as const;
|
||||
|
||||
describe("RoM bite-sized finales", () => {
|
||||
it.each(sliceIds)("gates %s behind its retained mobs and completes on its boss", (id) => {
|
||||
const slice = DUNGEON_REGISTRY[id];
|
||||
const source = DUNGEON_REGISTRY[dungeonContentSourceId(id)];
|
||||
expect(slice).toBeDefined();
|
||||
expect(source).toBeDefined();
|
||||
expect(dungeonContentPackId(id)).toBe(`dungeon:${source.id}`);
|
||||
expect(slice.assets).toBe(source.assets);
|
||||
expect(slice.staticSpawns).toHaveLength(6);
|
||||
expect(slice.staticSpawns.every((spawn) => source.staticSpawns.includes(spawn))).toBe(true);
|
||||
expect(slice.bosses).toHaveLength(1);
|
||||
expect(slice.bosses[0].eventOnly).toBe(true);
|
||||
expect(slice.staticSpawns).not.toContainEqual(expect.objectContaining({ id: slice.bosses[0].id }));
|
||||
expect(source.staticSpawns).toContainEqual(expect.objectContaining({ id: slice.bosses[0].id }));
|
||||
|
||||
const encounter = slice.encounters![0];
|
||||
const mobIds = slice.staticSpawns.map((spawn) => spawn.id);
|
||||
const initial = createDungeonEncounterSnapshot(encounter);
|
||||
const beforeClear = reduceDungeonEncounter(encounter, initial, { kind: "tick", now: 100,
|
||||
facts: { registeredEntityIds: mobIds, deadEntityIds: mobIds.slice(0, -1) } }).snapshot;
|
||||
expect(beforeClear.activeSpawnGroupIds).toEqual([]);
|
||||
const afterClear = reduceDungeonEncounter(encounter, beforeClear, { kind: "tick", now: 200,
|
||||
facts: { registeredEntityIds: mobIds, deadEntityIds: mobIds } }).snapshot;
|
||||
expect(afterClear.activeSpawnGroupIds).toEqual([slice.encounterSpawnGroups![0].id]);
|
||||
const completed = reduceDungeonEncounter(encounter, afterClear, { kind: "tick", now: 300,
|
||||
facts: { registeredEntityIds: [slice.bosses[0].id], deadEntityIds: [slice.bosses[0].id] } }).snapshot;
|
||||
expect(completed.completedObjectiveIds).toContain(slice.bosses[0].id);
|
||||
expect(completed.flags["dungeon-complete"]).toBe(true);
|
||||
expect(completed.phase).toBe("complete");
|
||||
// The complete source remains available with its original population and objectives.
|
||||
expect(source.bosses.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it.each(sliceIds)("keeps the %s start and boss reachable on shipped navigation", async (id) => {
|
||||
const slice = DUNGEON_REGISTRY[id];
|
||||
const sourceId = dungeonContentSourceId(id);
|
||||
const navigation = slice.assets.navigation!;
|
||||
const file = path.resolve("src/assets/game/dungeons", sourceId, navigation.fileName);
|
||||
expect(readFileSync(file).length).toBeGreaterThan(0);
|
||||
const document = await new NodeIO().read(file);
|
||||
const positions: number[] = [];
|
||||
const vertex = new Vector3();
|
||||
for (const node of document.getRoot().listNodes()) {
|
||||
const mesh = node.getMesh();
|
||||
if (!mesh) continue;
|
||||
const world = new Matrix4().fromArray(node.getWorldMatrix());
|
||||
for (const primitive of mesh.listPrimitives()) {
|
||||
const source = primitive.getAttribute("POSITION")?.getArray();
|
||||
if (!source) continue;
|
||||
const indices = primitive.getIndices()?.getArray() ?? Array.from({ length: source.length / 3 }, (_, i) => i);
|
||||
for (const index of indices) {
|
||||
vertex.fromArray(source, Number(index) * 3).applyMatrix4(world);
|
||||
positions.push(vertex.x, vertex.y, vertex.z);
|
||||
}
|
||||
}
|
||||
}
|
||||
const geometry = new BufferGeometry();
|
||||
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
|
||||
const graph = buildPartyNavigationGraph(geometry);
|
||||
const start = findNearestPartyNavigationSurface(graph, slice.entrance.footPosition, 3);
|
||||
const boss = findNearestPartyNavigationSurface(graph, slice.bosses[0].position, 6);
|
||||
expect(start, `${id} start`).not.toBeNull();
|
||||
expect(boss, `${id} boss`).not.toBeNull();
|
||||
expect(findPartyNavigationPath(graph, start!.position, boss!.position, {
|
||||
maxStartSnapDistance: 3, maxEndSnapDistance: 6,
|
||||
}), `${id} start to boss`).not.toBeNull();
|
||||
for (const spawn of slice.staticSpawns) {
|
||||
const mob = findNearestPartyNavigationSurface(graph, spawn.position, 3);
|
||||
expect(mob, `${id} ${spawn.id}`).not.toBeNull();
|
||||
expect(findPartyNavigationPath(graph, start!.position, mob!.position, {
|
||||
maxStartSnapDistance: 3, maxEndSnapDistance: 3,
|
||||
}), `${id} route to ${spawn.id}`).not.toBeNull();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
DungeonBossObjective,
|
||||
DungeonDefinition,
|
||||
DungeonEncounterSpawnGroupDefinition,
|
||||
Vector3Tuple,
|
||||
} from "../dungeonTypes";
|
||||
import type { DungeonEncounterDefinition } from "../dungeonEncounterTypes";
|
||||
import {
|
||||
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
|
||||
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
|
||||
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
|
||||
} from "../dungeonContentSource";
|
||||
|
||||
interface RoMSlice {
|
||||
readonly sourceId: string;
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly location: string;
|
||||
readonly entranceName: string;
|
||||
readonly start: Vector3Tuple;
|
||||
readonly forward: Vector3Tuple;
|
||||
readonly bossName: string;
|
||||
readonly mobSpawnIds: readonly string[];
|
||||
readonly summary: string;
|
||||
readonly adaptation: string;
|
||||
}
|
||||
|
||||
const ROM_SLICES: readonly RoMSlice[] = [
|
||||
{
|
||||
sourceId: "forsaken-abbey",
|
||||
id: BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
|
||||
title: "Forsaken Abbey: Ancalon's Approach",
|
||||
location: "Forsaken Abbey · Ancalon's Passage",
|
||||
entranceName: "Ancalon's Passage",
|
||||
// The omitted source bat occupied this floor-projected point before the last packs.
|
||||
start: [330.022888, -19.847076, -34.424903],
|
||||
forward: [1, 0, 0],
|
||||
bossName: "Demon Witch Ancalon",
|
||||
mobSpawnIds: [
|
||||
"rw-fa-spawn-7855", "rw-fa-spawn-7856",
|
||||
"rw-fa-spawn-12074", "rw-fa-spawn-12081",
|
||||
"rw-fa-spawn-12071", "rw-fa-spawn-12072",
|
||||
],
|
||||
summary: "Clear the shadows and followers in the final passage, then defeat Demon Witch Ancalon.",
|
||||
adaptation: "The earlier keepers, side rooms, patrol script, and deferred keeper doors are outside this short run.",
|
||||
},
|
||||
{
|
||||
sourceId: "paspers-shrine",
|
||||
id: BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
|
||||
title: "Paspers' Shrine: Hafiz's Hall",
|
||||
location: "Paspers' Shrine · Hafiz's Hall",
|
||||
entranceName: "Hafiz's Hall",
|
||||
// The omitted source axe soldier occupied this projected point before the final corridor.
|
||||
start: [95.618457, 0.348065, -285.473267],
|
||||
forward: [1, 0, 0],
|
||||
bossName: "Blackhorn Hafiz",
|
||||
mobSpawnIds: [
|
||||
"rw-paspers-shrine-spawn-5943704", "rw-paspers-shrine-spawn-5943709",
|
||||
"rw-paspers-shrine-spawn-5943711", "rw-paspers-shrine-spawn-5943712",
|
||||
"rw-paspers-shrine-spawn-5943715", "rw-paspers-shrine-spawn-5943716",
|
||||
],
|
||||
summary: "Defeat the Blackhorn guards along the last corridor, then confront Blackhorn Hafiz.",
|
||||
adaptation: "The other Blackhorn bosses and noncombat source interactions are outside this short run.",
|
||||
},
|
||||
{
|
||||
sourceId: "mystic-altar",
|
||||
id: BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
|
||||
title: "Mystic Altar: Golem's Antechamber",
|
||||
location: "Mystic Altar · Golem's Antechamber",
|
||||
entranceName: "Golem's Antechamber",
|
||||
start: [-392.475647, -17.665024, 329],
|
||||
forward: [-1, 0, 0],
|
||||
bossName: "Cursed lron Golem",
|
||||
mobSpawnIds: [
|
||||
"rw-mystic-altar-spawn-13463", "rw-mystic-altar-spawn-13465",
|
||||
"rw-mystic-altar-spawn-13244", "rw-mystic-altar-spawn-13245",
|
||||
"rw-mystic-altar-spawn-13248", "rw-mystic-altar-spawn-13332",
|
||||
],
|
||||
summary: "Clear the guards, beetles, and Morcus beasts before the Cursed Iron Golem.",
|
||||
adaptation: "Dorlos, the earlier altar route, and the remaining beetle swarm are outside this short run.",
|
||||
},
|
||||
];
|
||||
|
||||
function createRoMSlice(source: DungeonDefinition, slice: RoMSlice): DungeonDefinition {
|
||||
if (source.id !== slice.sourceId) throw new Error(`${slice.id} requires ${slice.sourceId}.`);
|
||||
const boss = source.objectiveOrder.find((objective) => objective.name === slice.bossName);
|
||||
const bossSpawn = boss && source.staticSpawns.find((spawn) => spawn.id === boss.id);
|
||||
if (!boss || !bossSpawn || source.entities[bossSpawn.entityId]?.kind !== "boss") {
|
||||
throw new Error(`${slice.sourceId} is missing ${slice.bossName}.`);
|
||||
}
|
||||
const mobSpawns = slice.mobSpawnIds.map((id) => {
|
||||
const spawn = source.staticSpawns.find((candidate) => candidate.id === id);
|
||||
if (!spawn || source.entities[spawn.entityId]?.kind === "boss" || !source.entities[spawn.entityId]) {
|
||||
throw new Error(`${slice.sourceId} is missing source mob ${id}.`);
|
||||
}
|
||||
return spawn;
|
||||
});
|
||||
const bossGroup: DungeonEncounterSpawnGroupDefinition = {
|
||||
id: `${slice.id}:boss`,
|
||||
spawns: [bossSpawn],
|
||||
};
|
||||
const encounter: DungeonEncounterDefinition = {
|
||||
id: `${slice.id}:progression`,
|
||||
dungeonId: slice.id,
|
||||
initialPhase: "approach-mobs",
|
||||
initialState: { flags: { "dungeon-complete": false } },
|
||||
resetPolicy: { bossWipePhase: "approach-mobs", checkpointPhase: "approach-mobs" },
|
||||
source: {
|
||||
fidelity: "healer-man-authored",
|
||||
scope: "boss-progression",
|
||||
files: ["src/game/dungeons/biteSizedRoMDungeons.ts"],
|
||||
reviewedBehavior: slice.summary,
|
||||
adaptations: [slice.adaptation, "The boss appears after the retained source mobs are defeated."],
|
||||
remainingGaps: ["The start, party route, combat, boss activation, and completion need an in-game pass."],
|
||||
},
|
||||
rules: [
|
||||
{
|
||||
id: "approach-cleared",
|
||||
trigger: { kind: "entities-dead", entityIds: mobSpawns.map((spawn) => spawn.id) },
|
||||
guards: [{ kind: "phase", phases: ["approach-mobs"] }],
|
||||
actions: [
|
||||
{ id: "activate-boss", kind: "activate-spawn-group", spawnGroupId: bossGroup.id },
|
||||
{ id: "enter-boss-phase", kind: "set-phase", phase: "boss" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "boss-defeated",
|
||||
trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] },
|
||||
guards: [{ kind: "phase", phases: ["boss"] }],
|
||||
actions: [
|
||||
{ id: "complete-objective", kind: "complete-objective", objectiveId: boss.id, persistence: "run" },
|
||||
{ id: "checkpoint", kind: "set-checkpoint", checkpointId: boss.id, persistence: "checkpoint" },
|
||||
{ id: "complete-run", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
|
||||
{ id: "complete-phase", kind: "set-phase", phase: "complete", persistence: "run" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const objective: DungeonBossObjective = { ...boss, eventOnly: true };
|
||||
const entityIds = new Set([...mobSpawns, bossSpawn].map((spawn) => spawn.entityId));
|
||||
const entities = Object.fromEntries(Object.entries(source.entities).filter(([id]) => entityIds.has(id)));
|
||||
const areas = [
|
||||
{ id: "entrance", name: slice.entranceName, center: slice.start, radius: 22 },
|
||||
{ id: "boss-room", name: slice.bossName, center: boss.position, radius: 32 },
|
||||
];
|
||||
|
||||
return {
|
||||
...source,
|
||||
id: slice.id,
|
||||
title: slice.title,
|
||||
lfgDungeonIds: [],
|
||||
entrance: { name: slice.entranceName, footPosition: slice.start, forward: slice.forward,
|
||||
yaw: Math.atan2(slice.forward[0], slice.forward[2]) },
|
||||
presentation: { ...source.presentation, location: slice.location, theme: "Bite-Sized Dungeon",
|
||||
summary: slice.summary, loadingMessage: `Entering ${slice.entranceName}...`,
|
||||
unchartedAreaName: slice.entranceName },
|
||||
areas,
|
||||
entities,
|
||||
staticSpawns: mobSpawns,
|
||||
roamingPacks: [],
|
||||
bosses: [objective],
|
||||
objectiveOrder: [objective],
|
||||
finalBossDurability: "standard",
|
||||
encounters: [encounter],
|
||||
encounterSpawnGroups: [bossGroup],
|
||||
serverGameObjects: [],
|
||||
validation: { ...source.validation, warnings: [...source.validation.warnings,
|
||||
"This short RoM route is Healer Man-authored from imported population and awaits live traversal review."] },
|
||||
};
|
||||
}
|
||||
|
||||
export function createBiteSizedForsakenAbbeyDefinition(source: DungeonDefinition): DungeonDefinition {
|
||||
return createRoMSlice(source, ROM_SLICES[0]);
|
||||
}
|
||||
|
||||
export function createBiteSizedPaspersShrineDefinition(source: DungeonDefinition): DungeonDefinition {
|
||||
return createRoMSlice(source, ROM_SLICES[1]);
|
||||
}
|
||||
|
||||
export function createBiteSizedMysticAltarDefinition(source: DungeonDefinition): DungeonDefinition {
|
||||
return createRoMSlice(source, ROM_SLICES[2]);
|
||||
}
|
||||
@@ -243,6 +243,72 @@
|
||||
],
|
||||
"defaultDifficultyId": "normal"
|
||||
},
|
||||
{
|
||||
"id": "bite-sized-forsaken-abbey-ancalon",
|
||||
"title": "Forsaken Abbey: Ancalon's Approach",
|
||||
"finalBossId": "rw-fa-spawn-7854",
|
||||
"bosses": [
|
||||
{
|
||||
"id": "rw-fa-spawn-7854",
|
||||
"name": "Demon Witch Ancalon"
|
||||
}
|
||||
],
|
||||
"difficulties": [
|
||||
{
|
||||
"id": "normal",
|
||||
"name": "normal",
|
||||
"bossIds": [
|
||||
"rw-fa-spawn-7854"
|
||||
],
|
||||
"finalBossId": "rw-fa-spawn-7854"
|
||||
}
|
||||
],
|
||||
"defaultDifficultyId": "normal"
|
||||
},
|
||||
{
|
||||
"id": "bite-sized-mystic-altar-golem",
|
||||
"title": "Mystic Altar: Golem's Antechamber",
|
||||
"finalBossId": "rw-mystic-altar-spawn-13506",
|
||||
"bosses": [
|
||||
{
|
||||
"id": "rw-mystic-altar-spawn-13506",
|
||||
"name": "Cursed lron Golem"
|
||||
}
|
||||
],
|
||||
"difficulties": [
|
||||
{
|
||||
"id": "normal",
|
||||
"name": "normal",
|
||||
"bossIds": [
|
||||
"rw-mystic-altar-spawn-13506"
|
||||
],
|
||||
"finalBossId": "rw-mystic-altar-spawn-13506"
|
||||
}
|
||||
],
|
||||
"defaultDifficultyId": "normal"
|
||||
},
|
||||
{
|
||||
"id": "bite-sized-paspers-shrine-hafiz",
|
||||
"title": "Paspers' Shrine: Hafiz's Hall",
|
||||
"finalBossId": "rw-paspers-shrine-spawn-5942136",
|
||||
"bosses": [
|
||||
{
|
||||
"id": "rw-paspers-shrine-spawn-5942136",
|
||||
"name": "Blackhorn Hafiz"
|
||||
}
|
||||
],
|
||||
"difficulties": [
|
||||
{
|
||||
"id": "normal",
|
||||
"name": "normal",
|
||||
"bossIds": [
|
||||
"rw-paspers-shrine-spawn-5942136"
|
||||
],
|
||||
"finalBossId": "rw-paspers-shrine-spawn-5942136"
|
||||
}
|
||||
],
|
||||
"defaultDifficultyId": "normal"
|
||||
},
|
||||
{
|
||||
"id": "bite-sized-stormwind-stockade",
|
||||
"title": "Stormwind Stockade: Cellblock Rush",
|
||||
|
||||
@@ -108,7 +108,7 @@ describe("looped mob path sampling", () => {
|
||||
});
|
||||
|
||||
it("keeps every packaged dungeon patrol on a usable loop", () => {
|
||||
expect(DUNGEON_DEFINITIONS).toHaveLength(89);
|
||||
expect(DUNGEON_DEFINITIONS).toHaveLength(92);
|
||||
for (const dungeon of DUNGEON_DEFINITIONS) {
|
||||
for (const pack of dungeon.roamingPacks) {
|
||||
expect(hasUsableLoopedMobPath(pack.waypoints), `${dungeon.id}:${pack.id}`).toBe(true);
|
||||
|
||||
@@ -81,6 +81,27 @@ describe("catalog-driven party rotations", () => {
|
||||
expect(committed.globalCooldownEndsAt).toBeGreaterThan(2_000);
|
||||
});
|
||||
|
||||
it("lets a companion use Inner Focus during a GCD while preserving that GCD", () => {
|
||||
const member = {
|
||||
...memberFor("priest", "healer"),
|
||||
talentRanks: runtimePartyTalentRanksForLevel("priest", "Discipline", 80),
|
||||
globalCooldownEndsAt: 5_000,
|
||||
};
|
||||
const innerFocus = abilityAtLevel(
|
||||
abilitiesForClass("priest").find((ability) => ability.name === "Inner Focus")!,
|
||||
80,
|
||||
);
|
||||
const shield = abilityAtLevel(
|
||||
abilitiesForClass("priest").find((ability) => ability.name === "Power Word: Shield")!,
|
||||
80,
|
||||
);
|
||||
|
||||
expect(partyAbilityIsReady(member, innerFocus, 2_000)).toBe(true);
|
||||
const committed = commitPartyAbilityUse(member, innerFocus, 2_000)!;
|
||||
expect(committed.globalCooldownEndsAt).toBe(5_000);
|
||||
expect(partyAbilityIsReady(committed, shield, 2_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("makes Discipline absorbs and emergency mitigation executable", () => {
|
||||
const member = {
|
||||
...memberFor("priest", "healer"),
|
||||
|
||||
@@ -75,7 +75,7 @@ export function partyAbilityIsReady(
|
||||
ignoreResourceCosts = false,
|
||||
): boolean {
|
||||
if (ability.passive || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) return false;
|
||||
if (member.globalCooldownEndsAt > now || (member.cooldowns[ability.id] ?? 0) > now) return false;
|
||||
if ((ability.gcdMs > 0 && member.globalCooldownEndsAt > now) || (member.cooldowns[ability.id] ?? 0) > now) return false;
|
||||
if ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
||||
if (!ignoreResourceCosts && abilityCosts(member, ability).some(cost => cost.resource === "health"
|
||||
@@ -237,7 +237,7 @@ export function commitPartyAbilityUse(
|
||||
cooldowns: ability.cooldownMs > 0
|
||||
? { ...member.cooldowns, [ability.id]: now + ability.cooldownMs }
|
||||
: member.cooldowns,
|
||||
globalCooldownEndsAt: now + ability.gcdMs,
|
||||
globalCooldownEndsAt: ability.gcdMs > 0 ? now + ability.gcdMs : member.globalCooldownEndsAt,
|
||||
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
|
||||
? now + Math.max(8_000, ability.cooldownMs)
|
||||
: member.tauntReadyAt,
|
||||
|
||||
@@ -85,7 +85,7 @@ describe("party navigation fleet safety", () => {
|
||||
const enabled = [];
|
||||
const fallback = [];
|
||||
|
||||
expect(DUNGEON_DEFINITIONS).toHaveLength(89);
|
||||
expect(DUNGEON_DEFINITIONS).toHaveLength(92);
|
||||
for (const dungeon of DUNGEON_DEFINITIONS) {
|
||||
expect(dungeonCanEnter(dungeon.id), dungeon.id).toBe(true);
|
||||
const navigation = dungeon.assets.navigation;
|
||||
@@ -120,7 +120,7 @@ describe("party navigation fleet safety", () => {
|
||||
// Stockades has a reviewed navigation override without claiming that its
|
||||
// broader encounter/reset validation is complete.
|
||||
expect(enabled).toContain("stormwind-stockade");
|
||||
expect(enabled).toHaveLength(33);
|
||||
expect(enabled).toHaveLength(36);
|
||||
expect(fallback).toHaveLength(56);
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
sampleMobCombatFallback,
|
||||
} from "./mobAnimation";
|
||||
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
|
||||
import { isBiteSizedDungeonId } from "./dungeonContentSource";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const publicRoot = path.join(projectRoot, "public");
|
||||
@@ -89,7 +90,7 @@ async function readGlbAnimationNames(url: string): Promise<readonly string[]> {
|
||||
describe("RuneWaker dungeon creature animation coverage", () => {
|
||||
it("gives every creature a working native or procedural state path", async () => {
|
||||
const dungeons = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion === "runewaker");
|
||||
expect(dungeons).toHaveLength(30);
|
||||
expect(dungeons).toHaveLength(33);
|
||||
|
||||
for (const dungeon of dungeons) {
|
||||
if (dungeon.staticSpawns.length === 0) {
|
||||
@@ -160,7 +161,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
|
||||
);
|
||||
const dungeons = new Map(
|
||||
DUNGEON_DEFINITIONS
|
||||
.filter((definition) => definition.expansion === "runewaker")
|
||||
.filter((definition) => definition.expansion === "runewaker" && !isBiteSizedDungeonId(definition.id))
|
||||
.map((definition) => [definition.id, definition]),
|
||||
);
|
||||
const counts = {
|
||||
|
||||
@@ -5,13 +5,15 @@ import {
|
||||
dungeonPackagePassesOfflineGate,
|
||||
} from "./dungeonRegistry";
|
||||
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
|
||||
import { isBiteSizedDungeonId } from "./dungeonContentSource";
|
||||
|
||||
describe("RuneWaker dungeon registry", () => {
|
||||
it("registers every configured instance exactly once", () => {
|
||||
expect(RUNEWAKER_DUNGEON_DEFINITIONS).toHaveLength(29);
|
||||
const runewaker = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion === "runewaker");
|
||||
expect(runewaker).toHaveLength(30);
|
||||
expect(new Set(runewaker.map((definition) => definition.id)).size).toBe(30);
|
||||
expect(runewaker).toHaveLength(33);
|
||||
expect(runewaker.filter((definition) => !isBiteSizedDungeonId(definition.id))).toHaveLength(30);
|
||||
expect(new Set(runewaker.map((definition) => definition.id)).size).toBe(33);
|
||||
expect(runewaker.some((definition) => definition.id === "forsaken-abbey")).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,13 @@ function effectsOfKind<K extends AbilityEffect["kind"]>(
|
||||
}
|
||||
|
||||
describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
it("preserves source spells that do not trigger a global cooldown", () => {
|
||||
expect(byId("wow335-priest-inner-focus").gcdMs).toBe(0);
|
||||
expect(byId("wow335-rogue-cold-blood").gcdMs).toBe(0);
|
||||
expect(byId("wow335-warrior-shield-block").gcdMs).toBe(0);
|
||||
expect(byId("wow335-priest-power-word-shield").gcdMs).toBe(1_500);
|
||||
});
|
||||
|
||||
it("combines each Holy Nova damage rank with its matching healing payload", () => {
|
||||
const nova = byId("wow335-priest-holy-nova");
|
||||
expect(nova.ranks).toHaveLength(9);
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"source":{"product":"World of Warcraft","build":"3.3.5a.12340","archive":"../wow335a/Data/enUS/patch-enUS.MPQ","spellTableRecords":46583,"generatedAt":"2026-09-17T04:24:22.392Z"},"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":0,"equippedItemSubclassMask":-1,"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":0,"equippedItemSubclassMask":-1,"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":0,"equippedItemSubclassMask":-1,"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":0,"equippedItemSubclassMask":-1,"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 the listed amount yards by 130. 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":0,"equippedItemSubclassMask":-1,"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":130,"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":130,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11551,"rank":6,"level":52,"sourceLevel":52,"maxScalingLevel":61,"description":"The warrioLine truncated
|
||||
{"source":{"product":"World of Warcraft","build":"3.3.5a.12340","archive":"../wow335a/Data/enUS/patch-enUS.MPQ","spellTableRecords":46583,"generatedAt":"2026-09-27T00:12:04.167Z"},"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":0,"equippedItemSubclassMask":-1,"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":0,"equippedItemSubclassMask":-1,"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":0,"equippedItemSubclassMask":-1,"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":0,"equippedItemSubclassMask":-1,"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 the listed amount yards by 130. 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":0,"equippedItemSubclassMask":-1,"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":130,"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":130,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11551,"rank":6,"level":52,"sourceLevel":52,"maxScalingLevel":61,"description":"The warrioLine truncated
|
||||
@@ -246,8 +246,9 @@ export function currentCompanionSnapshot(): CompanionSnapshot {
|
||||
? combat.resource
|
||||
: combat.resourcePools[cost.resource] ?? 0) < cost.amount);
|
||||
const personalCooldownRemaining = Math.max(0, (combat.cooldowns[ability.id] ?? 0) - now);
|
||||
const remainingMs = Math.max(personalCooldownRemaining, globalCooldownRemaining);
|
||||
const durationMs = personalCooldownRemaining >= globalCooldownRemaining
|
||||
const abilityGlobalCooldownRemaining = ability.gcdMs > 0 ? globalCooldownRemaining : 0;
|
||||
const remainingMs = Math.max(personalCooldownRemaining, abilityGlobalCooldownRemaining);
|
||||
const durationMs = personalCooldownRemaining >= abilityGlobalCooldownRemaining
|
||||
? ability.cooldownMs
|
||||
: ability.gcdMs;
|
||||
return [{
|
||||
|
||||
@@ -30,11 +30,7 @@ import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dunge
|
||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||
import { derivePartySpawnSlots } from "../game/partySpawnSlots";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import {
|
||||
BITE_SIZED_DEADMINES_DUNGEON_ID,
|
||||
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
|
||||
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
|
||||
} from "../game/dungeonContentSource";
|
||||
import { isBiteSizedDungeonId } from "../game/dungeonContentSource";
|
||||
import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes";
|
||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||
import {
|
||||
@@ -1603,9 +1599,7 @@ export function PartyPopulation({
|
||||
.filter((group) => activeSpawnGroups.has(group.id))
|
||||
.flatMap((group) => group.spawns.map((spawn) => spawn.id)));
|
||||
const gateEventObjectives = activeDungeonId === "wailing-caverns"
|
||||
|| activeDungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID
|
||||
|| activeDungeonId === BITE_SIZED_DEADMINES_DUNGEON_ID
|
||||
|| activeDungeonId === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID;
|
||||
|| isBiteSizedDungeonId(activeDungeonId);
|
||||
const availableObjectives = dungeon.objectiveOrder.filter((objective) => (
|
||||
!objective.optional
|
||||
&& (!objective.eventOnly || !gateEventObjectives
|
||||
|
||||
@@ -32,7 +32,10 @@ export function ArenaActionBar({ character, actor, layer, inputMode, blocked, on
|
||||
: [{ columns: 8, slots: PC_ACTION_CONTROLS.map((control) => ({ abilityId: bindings[actionBindingId(layer, control)], label: bindingLabel("pc", `ability:${layer}:${control}`) })) }];
|
||||
const items = Object.fromEntries(abilities.map((ability): [string, ActionBarItem] => {
|
||||
const learned = actor?.abilityIds ? actor.abilityIds.includes(ability.id) : arenaAbilityIsLearned(ability, character, level);
|
||||
const remaining = Math.max(actor?.cooldowns[ability.id] ?? 0, actor?.gcdRemainingMs ?? 0) / 1000;
|
||||
const remaining = Math.max(
|
||||
actor?.cooldowns[ability.id] ?? 0,
|
||||
ability.gcdMs > 0 ? actor?.gcdRemainingMs ?? 0 : 0,
|
||||
) / 1000;
|
||||
return [ability.id, {
|
||||
id: ability.id, name: ability.name, iconUrl: ability.icon, shortLabel: ability.sigil,
|
||||
description: ability.description, target: ability.target,
|
||||
|
||||
+1
-1
@@ -264,7 +264,7 @@ export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onR
|
||||
const ability = abilityAtLevel(catalogAbility, level, learnedTalentRank);
|
||||
const talentLocked = Boolean(abilityTalentNodeId(ability)) && !learnedTalentRank;
|
||||
const personalRemaining = Math.max(0, (cooldowns[ability.id] ?? 0) - clock);
|
||||
const globalRemaining = Math.max(0, globalCooldownEndsAt - clock);
|
||||
const globalRemaining = ability.gcdMs > 0 ? Math.max(0, globalCooldownEndsAt - clock) : 0;
|
||||
const remaining = Math.max(personalRemaining, globalRemaining);
|
||||
const abilityCosts = resolvedAbilityCosts(ability, talentModifiers, {
|
||||
classId,
|
||||
|
||||
Reference in new issue
Block a user