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),
|
description: resolveDescription(spell),
|
||||||
castTimeMs: spell.castTimeMs,
|
castTimeMs: spell.castTimeMs,
|
||||||
cooldownMs: Math.max(spell.recoveryTimeMs, spell.categoryRecoveryTimeMs),
|
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.
|
// SpellDuration.dbc uses negative durations for permanent/toggled effects.
|
||||||
// Preserve those as zero so the runtime converts them to a non-expiring aura
|
// 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.
|
// instead of a one-millisecond aura that the AI immediately recasts.
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ describe("random dungeon queue", () => {
|
|||||||
installPack,
|
installPack,
|
||||||
installDungeonRuntime: vi.fn(async () => undefined),
|
installDungeonRuntime: vi.fn(async () => undefined),
|
||||||
})).resolves.toMatchObject({ status: "entered" });
|
})).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();
|
expect(installPack).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ const rankEffectsForSpell = (spellId: number): readonly AbilityEffect[] => ranks
|
|||||||
.find(({ rank }) => rank.spellId === spellId)?.rank.effects ?? [];
|
.find(({ rank }) => rank.spellId === spellId)?.rank.effects ?? [];
|
||||||
|
|
||||||
describe("Ascension Conquest ability catalog", () => {
|
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", () => {
|
it("keeps Rejuvenating Venom healing in its cast proc", () => {
|
||||||
const venom = byName("venomancer", "Rejuvenating Venom");
|
const venom = byName("venomancer", "Rejuvenating Venom");
|
||||||
expect(venom.effects.some(effect => effect.kind === "heal")).toBe(false);
|
expect(venom.effects.some(effect => effect.kind === "heal")).toBe(false);
|
||||||
|
|||||||
@@ -1246,7 +1246,7 @@ function rankDefinition(
|
|||||||
mechanicsTooltip,
|
mechanicsTooltip,
|
||||||
Math.max(dbc?.recoveryTimeMs ?? 0, dbc?.categoryRecoveryTimeMs ?? 0),
|
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),
|
cost: liveTooltipCost(resourceForSpell(resource, dbc), mechanicsTooltip, rawCost),
|
||||||
effects,
|
effects,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -542,7 +542,8 @@ describe("combat store", () => {
|
|||||||
aura.definition.utilityTags?.includes("next-heal-free-critical")
|
aura.definition.utilityTags?.includes("next-heal-free-critical")
|
||||||
))).toBe(true);
|
))).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(heal.ok).toBe(true);
|
||||||
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeGreaterThan(0);
|
expect(useCombatStore.getState().activeCast?.originalDurationMs).toBeGreaterThan(0);
|
||||||
const completion = useCombatStore.getState().activeCast!.completesAt;
|
const completion = useCombatStore.getState().activeCast!.completesAt;
|
||||||
@@ -560,6 +561,21 @@ describe("combat store", () => {
|
|||||||
.toMatchObject({ ok: false, reason: "not-enough-resource" });
|
.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", () => {
|
it("ticks damage over time and awards kill experience exactly once", () => {
|
||||||
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 1, xp: 0 });
|
useCombatStore.getState().initializeCharacter({ classId: "warlock", level: 1, xp: 0 });
|
||||||
registerTarget("ooze", [10, 0, 0], 5, 100);
|
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"))
|
&& aurasForEntity(state.auras, PLAYER_AURA_ENTITY_ID).some((aura) => aura.definition.utilityTags?.includes("pacified"))
|
||||||
) failure = "player-controlled";
|
) failure = "player-controlled";
|
||||||
else if (state.activeCast && !completingCast) failure = "already-casting";
|
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 (!completingCast && (state.cooldowns[abilityId] ?? 0) > now) failure = "cooldown";
|
||||||
else if ((state.schoolLockedUntil[spellSchool] ?? 0) > now) failure = "school-locked";
|
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";
|
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,
|
critical: false,
|
||||||
outcome: "started",
|
outcome: "started",
|
||||||
}),
|
}),
|
||||||
globalCooldownEndsAt: now + gcdMs,
|
globalCooldownEndsAt: gcdMs > 0 ? now + gcdMs : state.globalCooldownEndsAt,
|
||||||
selectedTargetId: effectiveTarget === "hostile" ? targetId : state.selectedTargetId,
|
selectedTargetId: effectiveTarget === "hostile" ? targetId : state.selectedTargetId,
|
||||||
feedback: nextFeedback(
|
feedback: nextFeedback(
|
||||||
"success",
|
"success",
|
||||||
@@ -6587,7 +6587,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
|||||||
areaEffects: work.areaEffects,
|
areaEffects: work.areaEffects,
|
||||||
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
|
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
|
||||||
cooldowns,
|
cooldowns,
|
||||||
globalCooldownEndsAt: completingCast ? state.globalCooldownEndsAt : now + gcdMs,
|
globalCooldownEndsAt: completingCast || gcdMs <= 0 ? state.globalCooldownEndsAt : now + gcdMs,
|
||||||
activeCast: null,
|
activeCast: null,
|
||||||
...autoAttackUpdate,
|
...autoAttackUpdate,
|
||||||
playerAnimationEvent: nextAnimationEvent("ability-release", ability.id),
|
playerAnimationEvent: nextAnimationEvent("ability-release", ability.id),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { dungeonArtworkContains, dungeonArtworkFloorFor, dungeonArtworkHeading,
|
|||||||
describe("RuneWaker dungeon maps", () => {
|
describe("RuneWaker dungeon maps", () => {
|
||||||
it("packages a checksummed, position-aware map with every RuneWaker dungeon", () => {
|
it("packages a checksummed, position-aware map with every RuneWaker dungeon", () => {
|
||||||
const dungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion === "runewaker");
|
const dungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion === "runewaker");
|
||||||
expect(dungeons).toHaveLength(30);
|
expect(dungeons).toHaveLength(33);
|
||||||
for (const dungeon of dungeons) {
|
for (const dungeon of dungeons) {
|
||||||
const map = dungeonArtworkMapFor(dungeon);
|
const map = dungeonArtworkMapFor(dungeon);
|
||||||
expect(map, dungeon.id).not.toBeNull();
|
expect(map, dungeon.id).not.toBeNull();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import runewakerCatalog from "./generated/runewakerDungeonMaps.generated.json";
|
import runewakerCatalog from "./generated/runewakerDungeonMaps.generated.json";
|
||||||
|
import { dungeonContentSourceId } from "./dungeonContentSource";
|
||||||
import type { DungeonDefinition, Vector3Tuple } from "./dungeonTypes";
|
import type { DungeonDefinition, Vector3Tuple } from "./dungeonTypes";
|
||||||
import {
|
import {
|
||||||
wowDungeonMapFor,
|
wowDungeonMapFor,
|
||||||
@@ -38,7 +39,7 @@ const runewakerMaps = runewakerCatalog.dungeons as unknown as Readonly<Record<st
|
|||||||
export function dungeonArtworkMapFor(dungeon: DungeonDefinition): DungeonArtworkMap | null {
|
export function dungeonArtworkMapFor(dungeon: DungeonDefinition): DungeonArtworkMap | null {
|
||||||
const wow = wowDungeonMapFor(dungeon);
|
const wow = wowDungeonMapFor(dungeon);
|
||||||
if (wow) return { kind: "wow", source: wow, floors: wow.floors, width: 1002, height: 668 };
|
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
|
if (source?.mapId !== dungeon.mapId
|
||||||
|| source.coordinateTransform !== dungeon.provenance.coordinateTransform
|
|| source.coordinateTransform !== dungeon.provenance.coordinateTransform
|
||||||
|| source.visualChecksum !== dungeon.assets.visual[0]?.checksum
|
|| source.visualChecksum !== dungeon.assets.visual[0]?.checksum
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const WDT_PLACEMENT_DUNGEON_IDS = [
|
|||||||
describe("generated dungeon campaign catalog", () => {
|
describe("generated dungeon campaign catalog", () => {
|
||||||
it("covers only reviewed five-player instances with loadable assets and combat content", () => {
|
it("covers only reviewed five-player instances with loadable assets and combat content", () => {
|
||||||
expect(generated.definitions).toHaveLength(54);
|
expect(generated.definitions).toHaveLength(54);
|
||||||
expect(DUNGEON_DEFINITIONS).toHaveLength(89);
|
expect(DUNGEON_DEFINITIONS).toHaveLength(92);
|
||||||
expect(generated.definitions.map((dungeon) => dungeon.id))
|
expect(generated.definitions.map((dungeon) => dungeon.id))
|
||||||
.toEqual(campaign.dungeons.map((dungeon) => dungeon.slug));
|
.toEqual(campaign.dungeons.map((dungeon) => dungeon.slug));
|
||||||
for (const dungeon of generated.definitions) {
|
for (const dungeon of generated.definitions) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import queuePolicyCatalog from "./generated/dungeonLeaderboardCatalog.generated.
|
|||||||
|
|
||||||
describe("dungeon catalog", () => {
|
describe("dungeon catalog", () => {
|
||||||
it("lists the complete campaign and bite-sized variants", () => {
|
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({
|
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({
|
||||||
id: "stormwind-stockade",
|
id: "stormwind-stockade",
|
||||||
releaseStage: "beta",
|
releaseStage: "beta",
|
||||||
@@ -53,8 +53,10 @@ describe("dungeon catalog", () => {
|
|||||||
);
|
);
|
||||||
expect(dungeonsByCategory("rom")).toHaveLength(30);
|
expect(dungeonsByCategory("rom")).toHaveLength(30);
|
||||||
expect(dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id).sort())
|
expect(dungeonsByCategory("bite-sized").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-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns"]);
|
"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", () => {
|
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", () => {
|
it("keeps all Stockades experiences in Beta and alphabetizes each release group", () => {
|
||||||
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.releaseStage === "beta").map((dungeon) => dungeon.id))
|
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.releaseStage === "beta").map((dungeon) => dungeon.id))
|
||||||
.toEqual([
|
.toEqual([
|
||||||
|
"bite-sized-forsaken-abbey-ancalon",
|
||||||
|
"bite-sized-mystic-altar-golem",
|
||||||
|
"bite-sized-paspers-shrine-hafiz",
|
||||||
"randomized-bite-sized-stormwind-stockade",
|
"randomized-bite-sized-stormwind-stockade",
|
||||||
"stormwind-stockade",
|
"stormwind-stockade",
|
||||||
"bite-sized-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", () => {
|
it("shares the complete dungeon identity list used to validate reports on the server", () => {
|
||||||
expect(DUNGEON_CATALOG.map((dungeon) => dungeon.id).sort())
|
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",
|
"bite-sized-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns",
|
||||||
"randomized-bite-sized-stormwind-stockade", "forsaken-abbey",
|
"randomized-bite-sized-stormwind-stockade", "forsaken-abbey",
|
||||||
...Object.keys(availability.enabled)].sort());
|
...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_WAILING_CAVERNS_DUNGEON_ID = "bite-sized-wailing-caverns";
|
||||||
export const BITE_SIZED_DEADMINES_DUNGEON_ID = "bite-sized-deadmines-ship";
|
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_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";
|
export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-sized";
|
||||||
|
|
||||||
const BITE_SIZED_DUNGEON_IDS: ReadonlySet<string> = new Set([
|
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_WAILING_CAVERNS_DUNGEON_ID,
|
||||||
BITE_SIZED_DEADMINES_DUNGEON_ID,
|
BITE_SIZED_DEADMINES_DUNGEON_ID,
|
||||||
BITE_SIZED_UTGARDE_PINNACLE_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 {
|
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_WAILING_CAVERNS_DUNGEON_ID]: "wailing-caverns",
|
||||||
[BITE_SIZED_DEADMINES_DUNGEON_ID]: "deadmines",
|
[BITE_SIZED_DEADMINES_DUNGEON_ID]: "deadmines",
|
||||||
[BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID]: "utgarde-pinnacle",
|
[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 {
|
export function isDerivedStockadesDungeonId(dungeonId: string): boolean {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe("dungeon leaderboard catalog", () => {
|
|||||||
})).sort((a, b) => a.id.localeCompare(b.id)),
|
})).sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })),
|
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([]);
|
expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]);
|
||||||
const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json";
|
const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||||
if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`);
|
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", () => {
|
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[0].id).toBe("wailing-caverns");
|
||||||
expect(DUNGEON_DEFINITIONS.some((definition) => definition.id === "deadmines")).toBe(true);
|
expect(DUNGEON_DEFINITIONS.some((definition) => definition.id === "deadmines")).toBe(true);
|
||||||
expect(DUNGEON_DEFINITIONS.some((definition) => definition.id === "forsaken-abbey")).toBe(true);
|
expect(DUNGEON_DEFINITIONS.some((definition) => definition.id === "forsaken-abbey")).toBe(true);
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ import {
|
|||||||
createBiteSizedUtgardePinnacleDefinition,
|
createBiteSizedUtgardePinnacleDefinition,
|
||||||
createBiteSizedWailingCavernsDefinition,
|
createBiteSizedWailingCavernsDefinition,
|
||||||
} from "./dungeons/biteSizedWoWDungeons";
|
} from "./dungeons/biteSizedWoWDungeons";
|
||||||
|
import {
|
||||||
|
createBiteSizedForsakenAbbeyDefinition,
|
||||||
|
createBiteSizedMysticAltarDefinition,
|
||||||
|
createBiteSizedPaspersShrineDefinition,
|
||||||
|
} from "./dungeons/biteSizedRoMDungeons";
|
||||||
import {
|
import {
|
||||||
createRandomizedBiteSizedStockadesDefinition,
|
createRandomizedBiteSizedStockadesDefinition,
|
||||||
randomizedBiteSizedCreatureCatalog,
|
randomizedBiteSizedCreatureCatalog,
|
||||||
@@ -231,6 +236,15 @@ const randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSee
|
|||||||
const biteSizedWailingCaverns = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition);
|
const biteSizedWailingCaverns = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition);
|
||||||
const biteSizedDeadmines = createBiteSizedDeadminesDefinition(deadminesDefinition);
|
const biteSizedDeadmines = createBiteSizedDeadminesDefinition(deadminesDefinition);
|
||||||
const biteSizedUtgardePinnacle = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition);
|
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])[] = [
|
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
|
||||||
...adaptedSourceEntries,
|
...adaptedSourceEntries,
|
||||||
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
|
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
|
||||||
@@ -238,6 +252,9 @@ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[]
|
|||||||
[biteSizedWailingCaverns.id, biteSizedWailingCaverns],
|
[biteSizedWailingCaverns.id, biteSizedWailingCaverns],
|
||||||
[biteSizedDeadmines.id, biteSizedDeadmines],
|
[biteSizedDeadmines.id, biteSizedDeadmines],
|
||||||
[biteSizedUtgardePinnacle.id, biteSizedUtgardePinnacle],
|
[biteSizedUtgardePinnacle.id, biteSizedUtgardePinnacle],
|
||||||
|
[biteSizedForsakenAbbey.id, biteSizedForsakenAbbey],
|
||||||
|
[biteSizedPaspersShrine.id, biteSizedPaspersShrine],
|
||||||
|
[biteSizedMysticAltar.id, biteSizedMysticAltar],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
|
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
|
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
|
||||||
|
import { isBiteSizedDungeonId } from "./dungeonContentSource";
|
||||||
import { completedDungeonRun, finalDungeonBoss, useDungeonRunStore } from "./dungeonRunStore";
|
import { completedDungeonRun, finalDungeonBoss, useDungeonRunStore } from "./dungeonRunStore";
|
||||||
import { normalizeDungeonRunProgress, formatDungeonDuration, type DungeonRunMember } from "./dungeonRunTypes";
|
import { normalizeDungeonRunProgress, formatDungeonDuration, type DungeonRunMember } from "./dungeonRunTypes";
|
||||||
import { activateDungeonEncounterDefinitions, activeDungeonCompletedObjectiveIds, activeDungeonRunIsComplete,
|
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", () => {
|
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`)!;
|
const baseline = dungeon.encounters!.find((entry) => entry.id === `${dungeon.id}:completion-baseline`)!;
|
||||||
expect(baseline, dungeon.id).toBeDefined();
|
expect(baseline, dungeon.id).toBeDefined();
|
||||||
let snapshot = createDungeonEncounterSnapshot(baseline, 0);
|
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"
|
"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",
|
"id": "bite-sized-stormwind-stockade",
|
||||||
"title": "Stormwind Stockade: Cellblock Rush",
|
"title": "Stormwind Stockade: Cellblock Rush",
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ describe("looped mob path sampling", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps every packaged dungeon patrol on a usable loop", () => {
|
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 dungeon of DUNGEON_DEFINITIONS) {
|
||||||
for (const pack of dungeon.roamingPacks) {
|
for (const pack of dungeon.roamingPacks) {
|
||||||
expect(hasUsableLoopedMobPath(pack.waypoints), `${dungeon.id}:${pack.id}`).toBe(true);
|
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);
|
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", () => {
|
it("makes Discipline absorbs and emergency mitigation executable", () => {
|
||||||
const member = {
|
const member = {
|
||||||
...memberFor("priest", "healer"),
|
...memberFor("priest", "healer"),
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function partyAbilityIsReady(
|
|||||||
ignoreResourceCosts = false,
|
ignoreResourceCosts = false,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (ability.passive || !learnedAbilityForBuild(ability, member.level, member.talentRanks)) return false;
|
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 ((member.schoolLockedUntil[ability.castRules?.school ?? "physical"] ?? 0) > now) return false;
|
||||||
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
if (aiHintsForAbility(ability).categories.includes("taunt") && member.tauntReadyAt > now) return false;
|
||||||
if (!ignoreResourceCosts && abilityCosts(member, ability).some(cost => cost.resource === "health"
|
if (!ignoreResourceCosts && abilityCosts(member, ability).some(cost => cost.resource === "health"
|
||||||
@@ -237,7 +237,7 @@ export function commitPartyAbilityUse(
|
|||||||
cooldowns: ability.cooldownMs > 0
|
cooldowns: ability.cooldownMs > 0
|
||||||
? { ...member.cooldowns, [ability.id]: now + ability.cooldownMs }
|
? { ...member.cooldowns, [ability.id]: now + ability.cooldownMs }
|
||||||
: member.cooldowns,
|
: member.cooldowns,
|
||||||
globalCooldownEndsAt: now + ability.gcdMs,
|
globalCooldownEndsAt: ability.gcdMs > 0 ? now + ability.gcdMs : member.globalCooldownEndsAt,
|
||||||
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
|
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
|
||||||
? now + Math.max(8_000, ability.cooldownMs)
|
? now + Math.max(8_000, ability.cooldownMs)
|
||||||
: member.tauntReadyAt,
|
: member.tauntReadyAt,
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ describe("party navigation fleet safety", () => {
|
|||||||
const enabled = [];
|
const enabled = [];
|
||||||
const fallback = [];
|
const fallback = [];
|
||||||
|
|
||||||
expect(DUNGEON_DEFINITIONS).toHaveLength(89);
|
expect(DUNGEON_DEFINITIONS).toHaveLength(92);
|
||||||
for (const dungeon of DUNGEON_DEFINITIONS) {
|
for (const dungeon of DUNGEON_DEFINITIONS) {
|
||||||
expect(dungeonCanEnter(dungeon.id), dungeon.id).toBe(true);
|
expect(dungeonCanEnter(dungeon.id), dungeon.id).toBe(true);
|
||||||
const navigation = dungeon.assets.navigation;
|
const navigation = dungeon.assets.navigation;
|
||||||
@@ -120,7 +120,7 @@ describe("party navigation fleet safety", () => {
|
|||||||
// Stockades has a reviewed navigation override without claiming that its
|
// Stockades has a reviewed navigation override without claiming that its
|
||||||
// broader encounter/reset validation is complete.
|
// broader encounter/reset validation is complete.
|
||||||
expect(enabled).toContain("stormwind-stockade");
|
expect(enabled).toContain("stormwind-stockade");
|
||||||
expect(enabled).toHaveLength(33);
|
expect(enabled).toHaveLength(36);
|
||||||
expect(fallback).toHaveLength(56);
|
expect(fallback).toHaveLength(56);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
sampleMobCombatFallback,
|
sampleMobCombatFallback,
|
||||||
} from "./mobAnimation";
|
} from "./mobAnimation";
|
||||||
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
|
import { DUNGEON_DEFINITIONS } from "./dungeonRegistry";
|
||||||
|
import { isBiteSizedDungeonId } from "./dungeonContentSource";
|
||||||
|
|
||||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
const publicRoot = path.join(projectRoot, "public");
|
const publicRoot = path.join(projectRoot, "public");
|
||||||
@@ -89,7 +90,7 @@ async function readGlbAnimationNames(url: string): Promise<readonly string[]> {
|
|||||||
describe("RuneWaker dungeon creature animation coverage", () => {
|
describe("RuneWaker dungeon creature animation coverage", () => {
|
||||||
it("gives every creature a working native or procedural state path", async () => {
|
it("gives every creature a working native or procedural state path", async () => {
|
||||||
const dungeons = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion === "runewaker");
|
const dungeons = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion === "runewaker");
|
||||||
expect(dungeons).toHaveLength(30);
|
expect(dungeons).toHaveLength(33);
|
||||||
|
|
||||||
for (const dungeon of dungeons) {
|
for (const dungeon of dungeons) {
|
||||||
if (dungeon.staticSpawns.length === 0) {
|
if (dungeon.staticSpawns.length === 0) {
|
||||||
@@ -160,7 +161,7 @@ describe("RuneWaker dungeon creature animation coverage", () => {
|
|||||||
);
|
);
|
||||||
const dungeons = new Map(
|
const dungeons = new Map(
|
||||||
DUNGEON_DEFINITIONS
|
DUNGEON_DEFINITIONS
|
||||||
.filter((definition) => definition.expansion === "runewaker")
|
.filter((definition) => definition.expansion === "runewaker" && !isBiteSizedDungeonId(definition.id))
|
||||||
.map((definition) => [definition.id, definition]),
|
.map((definition) => [definition.id, definition]),
|
||||||
);
|
);
|
||||||
const counts = {
|
const counts = {
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import {
|
|||||||
dungeonPackagePassesOfflineGate,
|
dungeonPackagePassesOfflineGate,
|
||||||
} from "./dungeonRegistry";
|
} from "./dungeonRegistry";
|
||||||
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
|
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
|
||||||
|
import { isBiteSizedDungeonId } from "./dungeonContentSource";
|
||||||
|
|
||||||
describe("RuneWaker dungeon registry", () => {
|
describe("RuneWaker dungeon registry", () => {
|
||||||
it("registers every configured instance exactly once", () => {
|
it("registers every configured instance exactly once", () => {
|
||||||
expect(RUNEWAKER_DUNGEON_DEFINITIONS).toHaveLength(29);
|
expect(RUNEWAKER_DUNGEON_DEFINITIONS).toHaveLength(29);
|
||||||
const runewaker = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion === "runewaker");
|
const runewaker = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion === "runewaker");
|
||||||
expect(runewaker).toHaveLength(30);
|
expect(runewaker).toHaveLength(33);
|
||||||
expect(new Set(runewaker.map((definition) => definition.id)).size).toBe(30);
|
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);
|
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", () => {
|
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", () => {
|
it("combines each Holy Nova damage rank with its matching healing payload", () => {
|
||||||
const nova = byId("wow335-priest-holy-nova");
|
const nova = byId("wow335-priest-holy-nova");
|
||||||
expect(nova.ranks).toHaveLength(9);
|
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.resource
|
||||||
: combat.resourcePools[cost.resource] ?? 0) < cost.amount);
|
: combat.resourcePools[cost.resource] ?? 0) < cost.amount);
|
||||||
const personalCooldownRemaining = Math.max(0, (combat.cooldowns[ability.id] ?? 0) - now);
|
const personalCooldownRemaining = Math.max(0, (combat.cooldowns[ability.id] ?? 0) - now);
|
||||||
const remainingMs = Math.max(personalCooldownRemaining, globalCooldownRemaining);
|
const abilityGlobalCooldownRemaining = ability.gcdMs > 0 ? globalCooldownRemaining : 0;
|
||||||
const durationMs = personalCooldownRemaining >= globalCooldownRemaining
|
const remainingMs = Math.max(personalCooldownRemaining, abilityGlobalCooldownRemaining);
|
||||||
|
const durationMs = personalCooldownRemaining >= abilityGlobalCooldownRemaining
|
||||||
? ability.cooldownMs
|
? ability.cooldownMs
|
||||||
: ability.gcdMs;
|
: ability.gcdMs;
|
||||||
return [{
|
return [{
|
||||||
|
|||||||
@@ -30,11 +30,7 @@ import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dunge
|
|||||||
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
|
||||||
import { derivePartySpawnSlots } from "../game/partySpawnSlots";
|
import { derivePartySpawnSlots } from "../game/partySpawnSlots";
|
||||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||||
import {
|
import { isBiteSizedDungeonId } from "../game/dungeonContentSource";
|
||||||
BITE_SIZED_DEADMINES_DUNGEON_ID,
|
|
||||||
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
|
|
||||||
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
|
|
||||||
} from "../game/dungeonContentSource";
|
|
||||||
import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes";
|
import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes";
|
||||||
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
import { getMobPosition } from "../game/mobRuntimeRegistry";
|
||||||
import {
|
import {
|
||||||
@@ -1603,9 +1599,7 @@ export function PartyPopulation({
|
|||||||
.filter((group) => activeSpawnGroups.has(group.id))
|
.filter((group) => activeSpawnGroups.has(group.id))
|
||||||
.flatMap((group) => group.spawns.map((spawn) => spawn.id)));
|
.flatMap((group) => group.spawns.map((spawn) => spawn.id)));
|
||||||
const gateEventObjectives = activeDungeonId === "wailing-caverns"
|
const gateEventObjectives = activeDungeonId === "wailing-caverns"
|
||||||
|| activeDungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID
|
|| isBiteSizedDungeonId(activeDungeonId);
|
||||||
|| activeDungeonId === BITE_SIZED_DEADMINES_DUNGEON_ID
|
|
||||||
|| activeDungeonId === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID;
|
|
||||||
const availableObjectives = dungeon.objectiveOrder.filter((objective) => (
|
const availableObjectives = dungeon.objectiveOrder.filter((objective) => (
|
||||||
!objective.optional
|
!objective.optional
|
||||||
&& (!objective.eventOnly || !gateEventObjectives
|
&& (!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}`) })) }];
|
: [{ 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 items = Object.fromEntries(abilities.map((ability): [string, ActionBarItem] => {
|
||||||
const learned = actor?.abilityIds ? actor.abilityIds.includes(ability.id) : arenaAbilityIsLearned(ability, character, level);
|
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, {
|
return [ability.id, {
|
||||||
id: ability.id, name: ability.name, iconUrl: ability.icon, shortLabel: ability.sigil,
|
id: ability.id, name: ability.name, iconUrl: ability.icon, shortLabel: ability.sigil,
|
||||||
description: ability.description, target: ability.target,
|
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 ability = abilityAtLevel(catalogAbility, level, learnedTalentRank);
|
||||||
const talentLocked = Boolean(abilityTalentNodeId(ability)) && !learnedTalentRank;
|
const talentLocked = Boolean(abilityTalentNodeId(ability)) && !learnedTalentRank;
|
||||||
const personalRemaining = Math.max(0, (cooldowns[ability.id] ?? 0) - clock);
|
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 remaining = Math.max(personalRemaining, globalRemaining);
|
||||||
const abilityCosts = resolvedAbilityCosts(ability, talentModifiers, {
|
const abilityCosts = resolvedAbilityCosts(ability, talentModifiers, {
|
||||||
classId,
|
classId,
|
||||||
|
|||||||
Reference in new issue
Block a user