Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 20:27:07 -04:00
1 parent 4957b69e86
commit 8a98797fcf
30 files changed
+469 -39

No files matched your search

+2 -1
View File
@@ -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.
+3 -1
View File
@@ -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();
});
+8
View File
@@ -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);
+1 -1
View File
@@ -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,
};
+17 -1
View File
@@ -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);
+3 -3
View File
@@ -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),
+1 -1
View File
@@ -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();
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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) {
+10 -4
View File
@@ -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());
+9
View File
@@ -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 {
+1 -1
View File
@@ -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`);
+1 -1
View File
@@ -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);
+17
View File
@@ -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(
+3 -1
View File
@@ -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);
});
+182
View File
@@ -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",
+1 -1
View File
@@ -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);
+21
View File
@@ -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"),
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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);
});
+3 -2
View File
@@ -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 = {
+4 -2
View File
@@ -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);
});
+7
View File
@@ -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
View File
@@ -1 +1 @@
{"source":{"product":"World of Warcraft","build":"3.3.5a.12340","archive":"../wow335a/Data/enUS/patch-enUS.MPQ","spellTableRecords":46583,"generatedAt":"2026-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
+3 -2
View File
@@ -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 [{
+2 -8
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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,