diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md
index e2bac015..f3fa4820 100644
--- a/PLAYTEST_NOTES.md
+++ b/PLAYTEST_NOTES.md
@@ -4033,3 +4033,57 @@ Vite's existing large-chunk advisory remains informational.
at z=370, 390, 410, 430, 445, and 460 also stood on collision in the live
client; the source guard points and boss position have collision and
navigation surfaces.
+
+## Paused browser memory (2026-09-26)
+
+- A fresh offline Wailing Caverns Bite-Sized run in headless Edge entered the
+ dungeon, opened the pause menu, and held it for 60 seconds. The paused scene
+ used R3F's `demand` frame loop and reported no browser page errors. Before
+ the classic talent lookup change, forced-GC JavaScript heap remained at
+ 266 MiB across 0, 15, 30, and 60 seconds; DOM node count also stayed flat.
+- `src/game/wow335AbilityCatalog.ts` now validates classic talent IDs through
+ the already loaded compact runtime talent catalog. The built classic ability
+ chunk fell from 41.4 MiB to 2.1 MiB, because it no longer includes the
+ 36.6 MiB CoA source catalog. The same paused browser run measured 155, 155,
+ 155, and 156 MiB after forced GC at 0, 15, 30, and 60 seconds. The paired
+ screenshots and samples are in `playtest-artifacts/pause-memory/`.
+- The one-minute sample did not show a growing JavaScript heap. It does not
+ measure GPU allocations or rule out growth in a different dungeon or over
+ a longer idle period.
+
+## Character-specific catalog loading (2026-09-26)
+
+- The production build now imports `src/game/romBaseStatGrowth.generated.json`
+ (4.5 KB) for shared stat calculations. The full 1.7 MB
+ `romPlayerCatalog.generated.json` remains in the RoM ability chunk and is
+ requested only when a RoM class needs its abilities.
+- `src/game/talentRuntimeCatalog.ts` loads its generated WoW/CoA talent data
+ after a non-RoM class requests combat catalogs. A fresh RoM Rogue main-menu
+ session fetched `romAbilityCatalog-*.js` without fetching either the
+ `wow335AbilityCatalog-*.js` or the talent-data chunk. A fresh WoW Priest
+ fetched the classic ability and talent-data chunks without fetching the
+ RoM ability chunk. Both sessions had no browser page errors.
+- The RoM Rogue roster and Wailing Caverns dungeon smoke test passed with the
+ WoW/CoA talent data absent from the initial RoM page load. Browser request
+ results and menu screenshots are in `playtest-artifacts/catalog-loading/`;
+ dungeon screenshots are in `artifacts/playtest/rom-five/`.
+
+## Browser memory reset at shell boundaries (2026-09-26)
+
+- Browser ES modules cannot be unloaded individually from an active page.
+ Changing to a different character now reloads the page with a one-use
+ `sessionStorage` handoff to restore the selected character and destination.
+ Leaving a gameplay mode for the roster or main menu also reloads after the
+ run's normal save path. A live online group activity suppresses this reset
+ to avoid re-entering the same activity on reconnect.
+- The main-menu catalog gate no longer reloads the full gameplay runtime or
+ retains talent art up front; it preloads those resources when gameplay starts.
+- In a production Edge playtest, switching from a WoW Priest to a RoM Rogue
+ produced one reload and returned to the RoM main menu. The WoW talent editor
+ opened on demand before that switch. The new RoM page loaded
+ `romAbilityCatalog-*.js` without a WoW ability or `GameRuntime` chunk.
+ Leaving a RoM Wailing Caverns run produced a second reload and returned to
+ the same character's main menu without a `GameRuntime` chunk. Switching to
+ another RoM character also reloaded. The one-use handoffs were consumed,
+ and the page reported no errors. Results and screenshots are in
+ `playtest-artifacts/memory-reset/`.
diff --git a/package-lock.json b/package-lock.json
index bf34ccc3..26fb914c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "healer-man",
- "version": "0.1.58",
+ "version": "0.1.59",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "healer-man",
- "version": "0.1.58",
+ "version": "0.1.59",
"dependencies": {
"@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1",
diff --git a/package.json b/package.json
index 82ab5d36..0c285de5 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "healer-man",
"private": true,
- "version": "0.1.58",
+ "version": "0.1.59",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
diff --git a/release-version.json b/release-version.json
index a03301ee..429e638a 100644
--- a/release-version.json
+++ b/release-version.json
@@ -1,4 +1,4 @@
{
- "versionName": "0.1.58",
- "versionCode": 1058
+ "versionName": "0.1.59",
+ "versionCode": 1059
}
diff --git a/scripts/playtest-bite-sized-spawns.cjs b/scripts/playtest-bite-sized-spawns.cjs
index f560d2be..50e53c1b 100644
--- a/scripts/playtest-bite-sized-spawns.cjs
+++ b/scripts/playtest-bite-sized-spawns.cjs
@@ -3,12 +3,15 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
-const out = path.resolve('playtest-artifacts/bite-sized-spawns');
+const out = path.resolve(process.env.BITE_SIZED_PLAYTEST_DIR || 'playtest-artifacts/bite-sized-spawns');
fs.mkdirSync(out, { recursive: true });
-const locations = [
+const defaultLocations = [
{ id: 'bite-sized-wailing-caverns', name: 'wailing' },
{ id: 'bite-sized-utgarde-pinnacle-skadi', name: 'utgarde' },
];
+const locations = process.env.BITE_SIZED_PLAYTEST_IDS
+ ? process.env.BITE_SIZED_PLAYTEST_IDS.split(',').map((id) => ({ id, name: id }))
+ : defaultLocations;
async function gameState(page) {
return page.evaluate(async () => {
diff --git a/scripts/runewaker-pipeline/export-player-catalog.mjs b/scripts/runewaker-pipeline/export-player-catalog.mjs
index 5373201c..600fd74b 100644
--- a/scripts/runewaker-pipeline/export-player-catalog.mjs
+++ b/scripts/runewaker-pipeline/export-player-catalog.mjs
@@ -25,6 +25,7 @@ const files = {
objectStruct: path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "ObjectStruct.h"),
};
const outputFile = path.join(projectRoot, "src", "game", "romPlayerCatalog.generated.json");
+const growthOutputFile = path.join(projectRoot, "src", "game", "romBaseStatGrowth.generated.json");
const auditFile = path.join(scriptDirectory, "snapshots", "runewaker-player-skill-audit.json");
const interfaceFdb = path.join(resourceRoot, "fdb", "interface.fdb");
const iconOutputDirectory = path.join(projectRoot, "public", "assets", "ui", "rom-spells");
@@ -598,6 +599,7 @@ await mkdir(path.dirname(outputFile), { recursive: true });
await mkdir(path.dirname(auditFile), { recursive: true });
await Promise.all([
writeFile(outputFile, `${JSON.stringify(catalog)}\n`),
+ writeFile(growthOutputFile, `${JSON.stringify(catalog.baseStatGrowth)}\n`),
writeFile(auditFile, `${JSON.stringify(audit, null, 2)}\n`),
]);
console.log(`Generated ${ordinarySkills.length} ordinary families and ${eliteSkills.length} ordered-pair elite skills.`);
diff --git a/server/dungeon-leaderboards.test.mjs b/server/dungeon-leaderboards.test.mjs
index 30acdd67..1e354ebb 100644
--- a/server/dungeon-leaderboards.test.mjs
+++ b/server/dungeon-leaderboards.test.mjs
@@ -62,7 +62,8 @@ test("separates each rolled boss's board within its dungeon", () => withDatabase
for (const dungeonId of ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available",
"bite-sized-wailing-caverns", "bite-sized-deadmines-ship", "bite-sized-utgarde-pinnacle-skadi",
- "bite-sized-forsaken-abbey-ancalon", "bite-sized-paspers-shrine-hafiz", "bite-sized-mystic-altar-golem"]) {
+ "bite-sized-forsaken-abbey-ancalon", "bite-sized-paspers-shrine-hafiz", "bite-sized-mystic-altar-golem",
+ "bite-sized-grafu-castle-barkud", "bite-sized-hoto-teeth", "bite-sized-hall-of-survivors-pursuer"]) {
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
const difficulty = dungeon.difficulties[0];
assert.equal(difficulty.finalBossIds.length, dungeon.bosses.length);
@@ -87,6 +88,21 @@ test("separates each rolled boss's board within its dungeon", () => withDatabase
const bosses = catalog.dungeons.find((entry) => entry.id === id).difficulties[0].finalBossIds;
assert.equal(database.listDungeonLeaderboard({ dungeonId: id, bossId: bosses[1] }).total, 1);
}));
+test("the one-boss RoM slices have their own final-boss boards", () => withDatabase((database) => {
+ for (const [dungeonId, sourceId] of [
+ ["bite-sized-barren-caves-anglo", "barren-caves"],
+ ["bite-sized-kawaks-tomb-linobia", "kawaks-tomb"],
+ ]) {
+ const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
+ const bossId = dungeon.difficulties[0].finalBossIds[0];
+ assert.equal(dungeon.difficulties[0].finalBossIds.length, 1);
+ const payload = run(`${dungeonId}-run`, 2000, false, dungeonId);
+ payload.killedBossIds = [bossId];
+ assert.equal(database.submitDungeonRun(null, payload).placement.finalRank, 1);
+ assert.equal(database.listDungeonLeaderboard({ dungeonId, bossId }).total, 1);
+ assert.equal(database.listDungeonLeaderboard({ dungeonId: sourceId, bossId }).total, 0);
+ }
+}));
test("public HTTP leaderboards accept completion records and return results with rank while rejecting unfinished runs", async () => {
const directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-http-"));
const { server } = createGameServer({ dataDir: directory, enableShelvedModes: false });
diff --git a/server/dungeon-race.test.mjs b/server/dungeon-race.test.mjs
index 88d5033c..35c771d5 100644
--- a/server/dungeon-race.test.mjs
+++ b/server/dungeon-race.test.mjs
@@ -48,7 +48,9 @@ test("migrates existing race matches onto their seeded boss boards", () => {
const dungeonIds = ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available",
"bite-sized-wailing-caverns", "bite-sized-deadmines-ship", "bite-sized-utgarde-pinnacle-skadi",
- "bite-sized-forsaken-abbey-ancalon", "bite-sized-paspers-shrine-hafiz", "bite-sized-mystic-altar-golem"];
+ "bite-sized-forsaken-abbey-ancalon", "bite-sized-paspers-shrine-hafiz", "bite-sized-mystic-altar-golem",
+ "bite-sized-grafu-castle-barkud", "bite-sized-barren-caves-anglo",
+ "bite-sized-hoto-teeth", "bite-sized-kawaks-tomb-linobia", "bite-sized-hall-of-survivors-pursuer"];
for (const dungeonId of dungeonIds) insert.run(`race-old-${dungeonId}`, dungeonId,
`race-old-${dungeonId}`, dungeon.defaultDifficultyId);
createDungeonRaceRepository(database, { ErrorType: Error });
diff --git a/src/App.tsx b/src/App.tsx
index 41d55de5..c8fb8742 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -28,7 +28,7 @@ function ShellTransition() {
return
HMOpening expeditionPreparing the next screen...
;
}
-function RuntimeCatalogGate({ children }: { readonly children: ReactNode }) {
+function RuntimeCatalogGate({ children, preloadGameplay }: { readonly children: ReactNode; readonly preloadGameplay: boolean }) {
const character = useShellStore((state) => (
state.characters.find((candidate) => candidate.id === state.selectedCharacterId) ?? null
));
@@ -41,8 +41,10 @@ function RuntimeCatalogGate({ children }: { readonly children: ReactNode }) {
const talentClassId = character && !character.classId.startsWith("rom-")
? character.classId
: null;
- const requestKey = classIds.join(":");
const originalMediaEnabled = useShellStore((state) => state.combatPresentationSettings.originalMediaEnabled);
+ const requestKey = classIds.length
+ ? `${classIds.join(":")}|${preloadGameplay ? `game:${originalMediaEnabled}` : "shell"}`
+ : "";
const [loadedKey, setLoadedKey] = useState("");
const [failedKey, setFailedKey] = useState("");
@@ -52,15 +54,15 @@ function RuntimeCatalogGate({ children }: { readonly children: ReactNode }) {
setFailedKey("");
void Promise.all([
preloadAbilityCatalogsForClasses(classIds),
- talentClassId ? preloadTalentGameplayResources(talentClassId) : Promise.resolve(),
- preloadSharedGameplayResources(originalMediaEnabled),
+ preloadGameplay && talentClassId ? preloadTalentGameplayResources(talentClassId) : Promise.resolve(),
+ preloadGameplay ? preloadSharedGameplayResources(originalMediaEnabled) : Promise.resolve(),
]).then(() => {
if (active) setLoadedKey(requestKey);
}).catch(() => {
if (active) setFailedKey(requestKey);
});
return () => { active = false; };
- }, [classIds, originalMediaEnabled, requestKey, talentClassId]);
+ }, [classIds, originalMediaEnabled, preloadGameplay, requestKey, talentClassId]);
if (!requestKey || loadedKey === requestKey) return children;
if (failedKey === requestKey) {
@@ -105,5 +107,7 @@ export default function App() {
?
: }>;
const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase);
- return <>{requiresRuntimeCatalog ? {screen} : screen}>;
+ return <>{requiresRuntimeCatalog
+ ? {screen}
+ : screen}>;
}
diff --git a/src/GameRuntime.tsx b/src/GameRuntime.tsx
index 12b03403..4717ef3d 100644
--- a/src/GameRuntime.tsx
+++ b/src/GameRuntime.tsx
@@ -146,7 +146,7 @@ export default function GameRuntime() {
{dungeonSimulation ? : }
- {!dungeonSimulation && }
+ {!dungeonSimulation && }
{!dungeonSimulation && }
{!dungeonSimulation && }
{!dungeonSimulation && }
diff --git a/src/app/memoryReset.test.ts b/src/app/memoryReset.test.ts
new file mode 100644
index 00000000..be037a15
--- /dev/null
+++ b/src/app/memoryReset.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { consumeMemoryReset } from "./memoryReset";
+
+const HANDOFF_KEY = "healer-man.memory-reset.v1";
+
+function storageWith(value: unknown) {
+ const entries = new Map([[HANDOFF_KEY, JSON.stringify(value)]]);
+ return {
+ getItem: (key: string) => entries.get(key) ?? null,
+ setItem: (key: string, item: string) => { entries.set(key, item); },
+ removeItem: (key: string) => { entries.delete(key); },
+ };
+}
+
+describe("memory reset handoff", () => {
+ it("restores the selected character and destination once", () => {
+ const storage = storageWith({ ownerId: "owner", characterId: "rom-rogue", phase: "main-menu" });
+ expect(consumeMemoryReset("owner", ["rom-rogue"], storage)).toEqual({
+ ownerId: "owner", characterId: "rom-rogue", phase: "main-menu",
+ });
+ expect(consumeMemoryReset("owner", ["rom-rogue"], storage)).toBeNull();
+ });
+
+ it("does not navigate to another account or a deleted character", () => {
+ const otherOwner = storageWith({ ownerId: "other", characterId: "rom-rogue", phase: "main-menu" });
+ expect(consumeMemoryReset("owner", ["rom-rogue"], otherOwner)).toBeNull();
+ const deleted = storageWith({ ownerId: "owner", characterId: "deleted", phase: "class-hall" });
+ expect(consumeMemoryReset("owner", ["rom-rogue"], deleted)).toBeNull();
+ });
+
+ it("rejects a destination outside the safe shell screens", () => {
+ const storage = storageWith({ ownerId: "owner", characterId: "rom-rogue", phase: "game" });
+ expect(consumeMemoryReset("owner", ["rom-rogue"], storage)).toBeNull();
+ });
+});
diff --git a/src/app/memoryReset.ts b/src/app/memoryReset.ts
new file mode 100644
index 00000000..afae74f2
--- /dev/null
+++ b/src/app/memoryReset.ts
@@ -0,0 +1,63 @@
+import type { ShellPhase } from "./types";
+
+const HANDOFF_KEY = "healer-man.memory-reset.v1";
+
+export interface MemoryResetHandoff {
+ readonly ownerId: string;
+ readonly characterId: string;
+ readonly phase: Extract;
+}
+
+interface HandoffStorage {
+ getItem(key: string): string | null;
+ setItem(key: string, value: string): void;
+ removeItem(key: string): void;
+}
+
+function browserStorage(): HandoffStorage | null {
+ try {
+ return typeof window === "undefined" ? null : window.sessionStorage;
+ } catch {
+ return null;
+ }
+}
+
+/** A full page reload is the browser's boundary for releasing imported modules. */
+export function requestMemoryReset(handoff: MemoryResetHandoff): boolean {
+ if (import.meta.env.MODE === "test") return false;
+ const storage = browserStorage();
+ if (!storage) return false;
+ try {
+ storage.setItem(HANDOFF_KEY, JSON.stringify(handoff));
+ window.location.reload();
+ return true;
+ } catch {
+ try { storage.removeItem(HANDOFF_KEY); } catch { /* Storage may be disabled. */ }
+ return false;
+ }
+}
+
+/** Consume once so a later ordinary reload never replays an old navigation. */
+export function consumeMemoryReset(
+ ownerId: string,
+ characterIds: readonly string[],
+ storage: HandoffStorage | null = browserStorage(),
+): MemoryResetHandoff | null {
+ if (!storage) return null;
+ let raw: string | null;
+ try {
+ raw = storage.getItem(HANDOFF_KEY);
+ storage.removeItem(HANDOFF_KEY);
+ } catch {
+ return null;
+ }
+ if (!raw) return null;
+ try {
+ const value = JSON.parse(raw) as Partial;
+ if (value.ownerId !== ownerId || !value.characterId || !characterIds.includes(value.characterId)) return null;
+ if (value.phase !== "characters" && value.phase !== "main-menu" && value.phase !== "class-hall") return null;
+ return value as MemoryResetHandoff;
+ } catch {
+ return null;
+ }
+}
diff --git a/src/app/shellStore.ts b/src/app/shellStore.ts
index d817a488..f197874c 100644
--- a/src/app/shellStore.ts
+++ b/src/app/shellStore.ts
@@ -25,6 +25,7 @@ import {
clampAppearance,
classById,
classesForRace,
+ contentCategoryForClass,
createDefaultAppearance,
normalizeCharacterName,
randomAppearance,
@@ -53,6 +54,8 @@ import type { ManastormPartySize, ManastormProgress } from "../game/manastormPro
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
import { dungeonShellRuntime, type DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { manastormShellRuntime } from "./manastormShellRuntimeBridge";
+import { consumeMemoryReset, requestMemoryReset } from "./memoryReset";
+import { useOnlineGroupStore } from "./onlineGroupStore";
import {
activateOnlineSession,
hydrateOnlineSession,
@@ -152,17 +155,27 @@ function restoredState(): Pick character.id));
return {
- phase: "characters",
+ phase: handoff?.phase ?? "characters",
session,
characters,
bossCards: loadAccountBossCards(session.ownerId),
- selectedCharacterId: characters[0]?.id ?? null,
+ selectedCharacterId: handoff?.characterId ?? characters[0]?.id ?? null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
};
}
const restored = restoredState();
+let enteredCharacterId: string | null = restored.phase === "main-menu" || restored.phase === "class-hall"
+ ? restored.selectedCharacterId
+ : null;
+
+function canResetMemory(): boolean {
+ // A reload while a group activity is live would forget its consumed ID and
+ // could re-enter the same run when the group snapshot reconnects.
+ return !useOnlineGroupStore.getState().snapshot.group?.activity;
+}
export const useShellStore = create((set, get) => ({
...restored,
@@ -172,6 +185,7 @@ export const useShellStore = create((set, get) => ({
draft: defaultDraft(),
notice: "",
beginSession: (session) => {
+ enteredCharacterId = null;
activateOnlineSession(session);
saveSession(session);
const characters = listCharacters(session.ownerId);
@@ -187,6 +201,7 @@ export const useShellStore = create((set, get) => ({
});
},
signOut: () => {
+ enteredCharacterId = null;
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
const session = get().session;
void logoutOnlineSession(session);
@@ -313,7 +328,13 @@ export const useShellStore = create((set, get) => ({
return false;
}
const character = characters.find((entry) => entry.id === selectedCharacterId)!;
- if (character.categoryId !== "rom" && !character.specializationId) {
+ const categoryId = contentCategoryForClass(character.classId);
+ const nextPhase = categoryId !== "rom" && !character.specializationId ? "class-hall" : "main-menu";
+ if (enteredCharacterId && enteredCharacterId !== character.id && canResetMemory()) {
+ if (requestMemoryReset({ ownerId: session.ownerId, characterId: character.id, phase: nextPhase })) return true;
+ }
+ enteredCharacterId = character.id;
+ if (categoryId !== "rom" && !character.specializationId) {
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before continuing." });
return true;
}
@@ -447,6 +468,7 @@ export const useShellStore = create((set, get) => ({
return true;
},
returnToMainMenu: () => {
+ const wasInGame = get().phase === "game";
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame();
const { session, characters, activeCharacter, selectedCharacterId } = get();
@@ -460,6 +482,7 @@ export const useShellStore = create((set, get) => ({
return;
}
set({ phase: "main-menu", selectedCharacterId: currentId, activeCharacter: null, notice: "" });
+ if (wasInGame && canResetMemory()) requestMemoryReset({ ownerId: session.ownerId, characterId: currentId, phase: "main-menu" });
},
enterDungeon: (dungeonId, requestedRole, settings) => {
const runtime = dungeonShellRuntime();
@@ -486,6 +509,7 @@ export const useShellStore = create((set, get) => ({
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
returnToCharacters: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
+ const wasInGame = get().phase === "game";
const session = get().session;
if (!session) {
set({ phase: "login", activeCharacter: null });
@@ -493,17 +517,21 @@ export const useShellStore = create((set, get) => ({
}
const characters = listCharacters(session.ownerId);
const currentId = get().activeCharacter?.id ?? get().selectedCharacterId;
+ const selectedCharacterId = characters.some((character) => character.id === currentId)
+ ? currentId
+ : characters[0]?.id ?? null;
set({
phase: "characters",
characters,
bossCards: loadAccountBossCards(session.ownerId),
- selectedCharacterId: characters.some((character) => character.id === currentId)
- ? currentId
- : characters[0]?.id ?? null,
+ selectedCharacterId,
activeCharacter: null,
notice: "",
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
});
+ if (wasInGame && selectedCharacterId && canResetMemory()) requestMemoryReset({
+ ownerId: session.ownerId, characterId: selectedCharacterId, phase: "characters",
+ });
},
saveActiveProgression: (level, experience, talentRanks) => {
const { session, activeCharacter } = get();
diff --git a/src/game/abilityCatalog.ts b/src/game/abilityCatalog.ts
index 8606feb8..82915231 100644
--- a/src/game/abilityCatalog.ts
+++ b/src/game/abilityCatalog.ts
@@ -11,6 +11,7 @@ import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader";
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras";
import { applyAbilityBalanceTuning, currentBalanceTuningPolicy } from "./balanceTuning";
+import { preloadRuntimeTalentCatalog } from "./talentRuntimeCatalog";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
@@ -268,6 +269,9 @@ export interface AbilityRankDefinition {
readonly description: string;
readonly castMode: AbilityCastMode;
readonly castTimeMs: number;
+ readonly channelTickMs?: number;
+ readonly channelPulses?: number;
+ readonly channelStartsWithPulse?: boolean;
readonly cooldownMs: number;
readonly gcdMs: number;
readonly cost: AbilityCost;
@@ -293,6 +297,9 @@ export interface AbilityDefinition {
readonly radius?: number;
readonly castMode: AbilityCastMode;
readonly castTimeMs: number;
+ readonly channelTickMs?: number;
+ readonly channelPulses?: number;
+ readonly channelStartsWithPulse?: boolean;
readonly cooldownMs: number;
readonly cooldownGroup?: string;
readonly gcdMs: number;
@@ -900,6 +907,7 @@ const abilityCatalog: Record = {};
let romEliteAbilities: readonly AbilityDefinition[] = [];
let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null;
const coaTriggeredAbilities = new Map();
+const classicChannelTriggeredAbilities = new Map();
let classicLoad: Promise | null = null;
let romLoad: Promise | null = null;
const coaLoads = new Map>();
@@ -940,13 +948,19 @@ for (const classId of BASE_CLASS_IDS) {
async function preloadClassicAbilityCatalog(): Promise {
if (classicLoad) return classicLoad;
- classicLoad = import("./wow335AbilityCatalog").then(({ WOW335_ABILITIES_BY_CLASS }) => {
+ classicLoad = import("./wow335AbilityCatalog").then(({
+ WOW335_ABILITIES_BY_CLASS,
+ WOW335_CHANNEL_TRIGGERED_ABILITIES_BY_SPELL_ID,
+ }) => {
for (const classId of BASE_CLASS_IDS) {
registerClassAbilities(
classId,
mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[classId]),
);
}
+ for (const [spellId, ability] of Object.entries(WOW335_CHANNEL_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
+ classicChannelTriggeredAbilities.set(Number(spellId), ability);
+ }
}).catch((error: unknown) => {
classicLoad = null;
throw error;
@@ -994,6 +1008,10 @@ async function preloadCoaAbilityCatalog(classId: CoaClassId): Promise {
*/
export async function preloadAbilityCatalogsForClasses(classIds: readonly ClassId[]): Promise {
const requested = new Set(classIds);
+ if (BASE_CLASS_IDS.some((classId) => requested.has(classId))
+ || COA_CLASS_IDS.some((classId) => requested.has(classId))) {
+ await preloadRuntimeTalentCatalog();
+ }
const loads: Promise[] = [];
if (BASE_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadClassicAbilityCatalog());
if (ROM_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadRomAbilityCatalog());
@@ -1008,6 +1026,7 @@ export function triggeredAbilityBySpellId(
includeCoa = true,
): AbilityDefinition | null {
return romTriggeredAbilityResolver?.(spellId)
+ ?? classicChannelTriggeredAbilities.get(spellId)
?? (includeCoa ? coaTriggeredAbilities.get(spellId) : undefined)
?? null;
}
@@ -1104,6 +1123,9 @@ export function abilityAtLevel(
description: rank.description || ability.description,
castMode: rank.castMode,
castTimeMs: rank.castTimeMs,
+ channelTickMs: rank.channelTickMs,
+ channelPulses: rank.channelPulses,
+ channelStartsWithPulse: rank.channelStartsWithPulse,
cooldownMs: rank.cooldownMs,
gcdMs: rank.gcdMs,
cost: rank.cost,
diff --git a/src/game/coaAbilityCatalog.ts b/src/game/coaAbilityCatalog.ts
index a6dbcc8b..847b871f 100644
--- a/src/game/coaAbilityCatalog.ts
+++ b/src/game/coaAbilityCatalog.ts
@@ -1234,6 +1234,10 @@ function rankDefinition(
const effects = effectsForSpell(dbc, description, liveSpell?.name ?? dbc?.name ?? `Spell ${spellId}`);
const castTimeMs = liveTooltipCastTime(mechanicsTooltip, dbc?.castTimeMs ?? 0);
const channeling = Boolean(dbc?.channelInterruptFlags && dbc.durationMs > 0);
+ const channelTickMs = channeling ? dbc?.effects
+ .map((effect) => effect.periodMs)
+ .filter((period) => period > 0)
+ .sort((left, right) => left - right)[0] : undefined;
const rawCost = costForSpell(resource, dbc);
return {
rank,
@@ -1242,6 +1246,10 @@ function rankDefinition(
description,
castMode: channeling ? "channel" : castTimeMs > 0 ? "cast" : "instant",
castTimeMs: channeling ? Math.max(castTimeMs, dbc?.durationMs ?? 0) : castTimeMs,
+ ...(channelTickMs ? { channelTickMs } : {}),
+ ...(channelTickMs
+ ? { channelPulses: Math.max(1, Math.round(Math.max(castTimeMs, dbc?.durationMs ?? 0) / channelTickMs)) }
+ : {}),
cooldownMs: liveTooltipCooldown(
mechanicsTooltip,
Math.max(dbc?.recoveryTimeMs ?? 0, dbc?.categoryRecoveryTimeMs ?? 0),
@@ -1292,6 +1300,8 @@ export function ascensionProgressionAbility(
...(radius > 0 ? { radius } : {}),
castMode: first.castMode,
castTimeMs: first.castTimeMs,
+ channelTickMs: first.channelTickMs,
+ channelPulses: first.channelPulses,
cooldownMs: first.cooldownMs,
...(cooldownGroup ? { cooldownGroup } : {}),
gcdMs: first.gcdMs,
@@ -1357,6 +1367,8 @@ function treeAbility(entry: CoaTreeEntry): AbilityDefinition | null {
...(radius > 0 ? { radius } : {}),
castMode: first.castMode,
castTimeMs: first.castTimeMs,
+ channelTickMs: first.channelTickMs,
+ channelPulses: first.channelPulses,
cooldownMs: first.cooldownMs,
...(cooldownGroup ? { cooldownGroup } : {}),
gcdMs: first.gcdMs,
@@ -1415,6 +1427,8 @@ export const COA_ABILITIES_BY_CLASS: Readonly 0 ? { radius } : {}),
castMode: liveRank.castMode,
castTimeMs: liveRank.castTimeMs,
+ channelTickMs: liveRank.channelTickMs,
+ channelPulses: liveRank.channelPulses,
cooldownMs: liveRank.cooldownMs,
...(cooldownGroup ? { cooldownGroup } : {}),
gcdMs: liveRank.gcdMs,
@@ -1437,6 +1451,8 @@ export const COA_ABILITIES_BY_CLASS: Readonly {
it("spreads a normal channel's full amount across its pulses", () => {
@@ -32,4 +32,44 @@ describe("channel amount expansion", () => {
{ effect: ability.effects.find((effect) => effect.kind === "damage"), multiplier: 1 },
]);
});
+
+ it("uses source intervals for classic channels and starts Penance immediately", () => {
+ const cases = [
+ ["priest", "Penance", 3, 0, 1_000],
+ ["priest", "Mind Flay", 3, 1_000, 1_000],
+ ["priest", "Divine Hymn", 4, 2_000, 2_000],
+ ["hunter", "Volley", 6, 1_000, 1_000],
+ ["mage", "Blizzard", 8, 1_000, 1_000],
+ ["warlock", "Rain of Fire", 4, 2_000, 2_000],
+ ["druid", "Tranquility", 4, 2_000, 2_000],
+ ["druid", "Hurricane", 10, 1_000, 1_000],
+ ] as const;
+ for (const [classId, name, pulses, firstAt, interval] of cases) {
+ const ability = abilitiesForClass(classId).find((candidate) => candidate.name === name)!;
+ expect(ability.castMode, name).toBe("channel");
+ expect(channelPulseCount(ability), name).toBe(pulses);
+ expect(channelPulseAt(ability, 0, ability.castTimeMs, 0), name).toBe(firstAt);
+ expect(channelPulseAt(ability, 0, ability.castTimeMs, 1), name).toBe(firstAt + interval);
+ }
+ const penance = abilitiesForClass("priest").find((candidate) => candidate.name === "Penance")!;
+ const accelerated = { ...penance, castTimeMs: 1_000 };
+ expect(channelPulseCount(accelerated)).toBe(3);
+ expect(channelPulseAt(accelerated, 0, 1_000, 1)).toBe(500);
+ });
+
+ it("gives damaging and healing classic channels a pulse payload", () => {
+ const expected = [
+ ["priest", "Penance"], ["priest", "Mind Flay"], ["priest", "Mind Sear"],
+ ["priest", "Divine Hymn"], ["hunter", "Volley"],
+ ["mage", "Arcane Missiles"], ["mage", "Blizzard"],
+ ["warlock", "Drain Life"], ["warlock", "Drain Soul"],
+ ["warlock", "Health Funnel"], ["warlock", "Rain of Fire"], ["warlock", "Hellfire"],
+ ["druid", "Tranquility"], ["druid", "Hurricane"],
+ ] as const;
+ const missing = expected.filter(([classId, name]) => {
+ const ability = abilitiesForClass(classId).find((candidate) => candidate.name === name)!;
+ return channelAmountEffects(ability, channelPulseCount(ability)).length === 0;
+ });
+ expect(missing).toEqual([]);
+ });
});
diff --git a/src/game/combatChannelAmounts.ts b/src/game/combatChannelAmounts.ts
index 16a57ec2..18f70e88 100644
--- a/src/game/combatChannelAmounts.ts
+++ b/src/game/combatChannelAmounts.ts
@@ -2,8 +2,18 @@ import { triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect }
type ChannelAmountEffect = Extract;
-export function channelPulseCount(ability: Pick): number {
- return ability.name === "Divine Hymn" || ability.dbcSpellId === 491164 ? 4 : 5;
+export function channelPulseCount(ability: AbilityDefinition): number {
+ if (ability.channelPulses && ability.channelPulses > 0) return Math.max(1, Math.round(ability.channelPulses));
+ if (ability.channelTickMs && ability.channelTickMs > 0) {
+ return Math.max(1, Math.round(ability.castTimeMs / ability.channelTickMs)
+ + (ability.channelStartsWithPulse ? 1 : 0));
+ }
+ return ability.dbcSpellId === 491164 ? 4 : 5;
+}
+
+export function channelPulseAt(ability: AbilityDefinition, startedAt: number, durationMs: number, pulseIndex: number): number {
+ const intervals = Math.max(1, channelPulseCount(ability) - (ability.channelStartsWithPulse ? 1 : 0));
+ return startedAt + (pulseIndex + (ability.channelStartsWithPulse ? 0 : 1)) * durationMs / intervals;
}
/** Direct channel amounts span the cast; periodic trigger payloads fire each pulse. */
@@ -15,7 +25,9 @@ export function channelAmountEffects(ability: AbilityDefinition, totalTicks: num
const amounts: { effect: ChannelAmountEffect; multiplier: number }[] = [];
for (const effect of ability.effects) {
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "dot" || effect.kind === "hot") {
- amounts.push({ effect, multiplier: ability.dbcSpellId === 491164 && effect.kind === "damage"
+ amounts.push({ effect, multiplier: ability.name === "Penance"
+ ? 1
+ : ability.dbcSpellId === 491164 && effect.kind === "damage"
? 1
: (effect.kind === "dot" || effect.kind === "hot" ? effect.ticks : 1) / ticks });
} else if (effect.kind === "apply-aura") {
diff --git a/src/game/combatStore.test.ts b/src/game/combatStore.test.ts
index e8f017d0..c3c510ad 100644
--- a/src/game/combatStore.test.ts
+++ b/src/game/combatStore.test.ts
@@ -1940,32 +1940,124 @@ describe("combat store", () => {
&& aura.definition.name.startsWith("Rupture"))?.expiresAt).toBe(19_000);
});
- it("lets Penance damage an enemy and continue pulsing", () => {
+ it("channels Penance damage immediately and at one-second intervals without healing its caster", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
unlockReadySpell("wow335-priest-penance");
registerTarget("penance-target", [8, 0, 0], 100_000, 0);
+ useCombatStore.getState().damagePlayer(500);
+ const casterHealth = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
+ expect(useCombatStore.getState().activeCast).toMatchObject({ mode: "channel", totalTicks: 3,
+ completesAt: 3_000, nextTickAt: 1_000 });
+ expect(useCombatStore.getState().cooldowns["wow335-priest-penance"]).toBeGreaterThan(1_000);
+ expect(useCombatStore.getState().mobs["penance-target"].health).toBe(100_000);
+ useCombatStore.getState().tick(0, 1_000);
const first = useCombatStore.getState().mobs["penance-target"].health;
expect(first).toBeLessThan(100_000);
+ expect(useCombatStore.getState().health).toBe(casterHealth);
+ useCombatStore.getState().tick(0, 1_999);
+ expect(useCombatStore.getState().mobs["penance-target"].health).toBe(first);
useCombatStore.getState().tick(0, 2_000);
expect(useCombatStore.getState().mobs["penance-target"].health).toBeLessThan(first);
useCombatStore.getState().tick(0, 3_000);
- expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === "wow335-priest-penance")).toBe(false);
+ expect(useCombatStore.getState().activeCast).toBeNull();
+ const after = useCombatStore.getState().mobs["penance-target"].health;
+ useCombatStore.getState().tick(0, 4_000);
+ expect(useCombatStore.getState().mobs["penance-target"].health).toBe(after);
});
- it("lets Penance heal a friendly target immediately and twice afterward", () => {
+ it("channels Penance healing and stops future pulses when interrupted", () => {
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
unlockReadySpell("wow335-priest-penance");
usePartyStore.getState().selectSelf();
useCombatStore.getState().damagePlayer(2_000);
const before = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true);
+ useCombatStore.getState().tick(0, 1_000);
const first = useCombatStore.getState().health;
expect(first).toBeGreaterThan(before);
useCombatStore.getState().tick(0, 2_000);
expect(useCombatStore.getState().health).toBeGreaterThan(first);
+ const interruptedAt = useCombatStore.getState().health;
+ expect(useCombatStore.getState().cancelCast()).toBe(true);
useCombatStore.getState().tick(0, 3_000);
- expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === "wow335-priest-penance")).toBe(false);
+ expect(useCombatStore.getState().health).toBe(interruptedAt);
+ expect(useCombatStore.getState().activeCast).toBeNull();
+ });
+
+ it("channels ground-area Volley over its selected point without a target or post-channel damage", () => {
+ useCombatStore.getState().initializeCharacter({ classId: "hunter", level: 80 });
+ registerTarget("volley-a", [10, 0, 0], 100_000, 0);
+ registerTarget("volley-b", [12, 0, 0], 100_000, 0);
+ registerTarget("volley-outside", [25, 0, 0], 100_000, 0);
+ expect(useCombatStore.getState().castAbility("wow335-hunter-volley", {
+ now: 1_000, origin: [0, 0, 0], targetActorId: null, groundPosition: [10, 0, 0],
+ }).ok).toBe(true);
+ expect(useCombatStore.getState().activeCast).toMatchObject({ mode: "channel", totalTicks: 6,
+ nextTickAt: 2_000, completesAt: 7_000 });
+ useCombatStore.getState().tick(0, 2_000);
+ expect(useCombatStore.getState().mobs["volley-a"].health).toBeLessThan(100_000);
+ expect(useCombatStore.getState().mobs["volley-b"].health).toBeLessThan(100_000);
+ expect(useCombatStore.getState().mobs["volley-outside"].health).toBe(100_000);
+ useCombatStore.getState().tick(0, 7_000);
+ expect(useCombatStore.getState().activeCast).toBeNull();
+ expect(useCombatStore.getState().areaEffects.some((effect) => effect.sourceAbilityId === "wow335-hunter-volley"))
+ .toBe(false);
+ const finalHealth = useCombatStore.getState().mobs["volley-a"].health;
+ useCombatStore.getState().tick(0, 9_000);
+ expect(useCombatStore.getState().mobs["volley-a"].health).toBe(finalHealth);
+ });
+
+ it("applies Blizzard damage during its eight source-timed channel pulses", () => {
+ useCombatStore.getState().initializeCharacter({ classId: "mage", level: 80 });
+ registerTarget("blizzard-target", [10, 0, 0], 100_000, 0);
+ expect(useCombatStore.getState().castAbility("wow335-mage-blizzard", {
+ now: 1_000, origin: [0, 0, 0], targetActorId: null, groundPosition: [10, 0, 0],
+ }).ok).toBe(true);
+ expect(useCombatStore.getState().activeCast).toMatchObject({ mode: "channel", totalTicks: 8 });
+ useCombatStore.getState().tick(0, 2_000);
+ expect(useCombatStore.getState().mobs["blizzard-target"].health).toBeLessThan(100_000);
+ useCombatStore.getState().tick(0, 9_000);
+ expect(useCombatStore.getState().activeCast).toBeNull();
+ expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
+ && event.abilityId === "wow335-mage-blizzard" && event.targetActorId === "blizzard-target"))
+ .toHaveLength(8);
+ expect(useCombatStore.getState().areaEffects.some((effect) => effect.sourceAbilityId === "wow335-mage-blizzard"))
+ .toBe(false);
+ expect(useCombatStore.getState().auras.some((aura) => aura.definition.name === "Blizzard"))
+ .toBe(false);
+ const finalHealth = useCombatStore.getState().mobs["blizzard-target"].health;
+ useCombatStore.getState().tick(0, 11_000);
+ expect(useCombatStore.getState().mobs["blizzard-target"].health).toBe(finalHealth);
+ });
+
+ it.each([
+ ["warlock", "wow335-warlock-rain-of-fire", 4, 2_000],
+ ["druid", "wow335-druid-hurricane", 10, 1_000],
+ ] as const)("applies %s ground-channel damage only during its pulses", (classId, abilityId, pulses, intervalMs) => {
+ useCombatStore.getState().initializeCharacter({ classId, level: 80 });
+ expect(useCombatStore.getState().abilities.find((ability) => ability.id === abilityId))
+ .toMatchObject({ castMode: "channel", castTimeMs: pulses * intervalMs, channelPulses: pulses });
+ registerTarget("channel-target", [10, 0, 0], 100_000, 0);
+ expect(abilityAtLevel(useCombatStore.getState().abilities.find((ability) => ability.id === abilityId)!, 80))
+ .toMatchObject({ castMode: "channel", castTimeMs: pulses * intervalMs, channelPulses: pulses });
+ expect(useCombatStore.getState().castAbility(abilityId, {
+ now: 1_000, origin: [0, 0, 0], targetActorId: null, groundPosition: [10, 0, 0],
+ }).ok).toBe(true);
+ expect(useCombatStore.getState().activeCast).toMatchObject({ mode: "channel", totalTicks: pulses,
+ nextTickAt: 1_000 + intervalMs });
+ useCombatStore.getState().tick(0, 1_000 + intervalMs);
+ expect(useCombatStore.getState().mobs["channel-target"].health).toBeLessThan(100_000);
+ useCombatStore.getState().tick(0, 1_000 + pulses * intervalMs);
+ expect(useCombatStore.getState().activeCast).toBeNull();
+ expect(useCombatStore.getState().combatEvents.filter((event) => event.kind === "damage"
+ && event.abilityId === abilityId && event.targetActorId === "channel-target"))
+ .toHaveLength(pulses);
+ expect(useCombatStore.getState().areaEffects.some((effect) => effect.sourceAbilityId === abilityId))
+ .toBe(false);
+ const finalHealth = useCombatStore.getState().mobs["channel-target"].health;
+ useCombatStore.getState().tick(0, 2_000 + pulses * intervalMs);
+ expect(useCombatStore.getState().mobs["channel-target"].health).toBe(finalHealth);
});
it("selects only the three lowest-health nearby Divine Hymn targets per two-second pulse", () => {
diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts
index d466101e..17b7a213 100644
--- a/src/game/combatStore.ts
+++ b/src/game/combatStore.ts
@@ -141,7 +141,7 @@ import { rollBossLoot } from "./lootCatalog";
import { bossCardForSource } from "./bossCardCatalog";
import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards";
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
-import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts";
+import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
import { normalizeInventory, type InventoryItem } from "./lootTypes";
import {
PLAYER_EQUIPMENT_OWNER_ID,
@@ -5251,7 +5251,7 @@ export const useCombatStore = create((set, get) => ({
groundPosition,
totalTicks: ability.castMode === "channel" ? channelPulseCount(ability) : 0,
ticksCompleted: 0,
- nextTickAt: ability.castMode === "channel" ? now + castTimeMs / channelPulseCount(ability) : 0,
+ nextTickAt: ability.castMode === "channel" ? channelPulseAt(ability, now, castTimeMs, 0) : 0,
};
const channelCooldowns: Record = { ...state.cooldowns };
if (activeCast.mode === "channel" && cooldownMs > 0) {
@@ -5346,7 +5346,7 @@ export const useCombatStore = create((set, get) => ({
}
const castThreatSource = playerThreatSource(state, ability.id);
- if (targeting?.shape === "ground" && groundPosition) {
+ if (targeting?.shape === "ground" && groundPosition && completingCast?.mode !== "channel") {
const periodic = ability.effects.filter((effect) => effect.kind === "dot" || effect.kind === "hot");
const durationMs = periodic.reduce((maximum, effect) => (
Math.max(maximum, effect.ticks * effect.intervalMs)
@@ -9803,7 +9803,9 @@ export const useCombatStore = create((set, get) => ({
: null;
let ticksCompleted = cast.ticksCompleted;
let nextTickAt = cast.nextTickAt;
- while (ability && ticksCompleted < cast.totalTicks && nextTickAt <= now) {
+ while (ability && ticksCompleted < cast.totalTicks && nextTickAt <= now && nextTickAt <= cast.completesAt) {
+ const channelTargeting = targetingRulesForAbility(ability);
+ const groundChannel = channelTargeting.shape === "ground";
const target = cast.targetId ? get().mobs[cast.targetId] : null;
const friendlyTarget = cast.targetId
? usePartyStore.getState().members.find((member) => member.id === cast.targetId)
@@ -9812,14 +9814,14 @@ export const useCombatStore = create((set, get) => ({
? getMobPosition(cast.targetId) ?? getPartyRuntimePosition(cast.targetId)
: get().playerPosition;
const channelHostile = ability.target === "hostile" || (ability.target === "any" && Boolean(target));
- const distance = cast.targetId
+ const distance = groundChannel ? 0 : cast.targetId
? channelHostile
? Math.sqrt(mobRuntimeDistanceSquared(cast.targetId, cast.origin))
: Math.sqrt(partyRuntimeDistanceSquared(cast.targetId, cast.origin))
: 0;
const minimumRange = ability ? talentAdjustedRange(ability.range.min, ability, get().talentModifiers) : 0;
const maximumRange = ability ? talentAdjustedRange(ability.range.max, ability, get().talentModifiers) : 0;
- const targetUnavailable = channelHostile
+ const targetUnavailable = groundChannel ? !cast.groundPosition : channelHostile
? !target || target.dead || !targetPosition
: (ability.target === "friendly" || ability.target === "any") && cast.targetId
? !friendlyTarget || friendlyTarget.health <= 0 || !targetPosition
@@ -9856,11 +9858,15 @@ export const useCombatStore = create((set, get) => ({
0,
);
if (damageAmount > 0) {
- const channelCenter = channelHostile ? targetPosition ?? cast.origin : get().playerPosition;
- const channelTargets = ability.radius === undefined
+ const channelCenter = groundChannel
+ ? cast.groundPosition!
+ : channelHostile ? targetPosition ?? cast.origin : get().playerPosition;
+ const channelRadius = groundChannel ? channelTargeting.radius ?? ability.radius ?? 8 : ability.radius;
+ const channelTargets = channelRadius === undefined
? (cast.targetId && channelHostile ? [cast.targetId] : [])
- : nearbyMobRuntimeIds(channelCenter, talentAdjustedRange(ability.radius, ability, get().talentModifiers))
- .filter((id) => !get().mobs[id]?.dead);
+ : nearbyMobRuntimeIds(channelCenter, talentAdjustedRange(channelRadius, ability, get().talentModifiers))
+ .filter((id) => !get().mobs[id]?.dead && Boolean(getMobPosition(id)
+ && combatHasLineOfSight(channelCenter, getMobPosition(id)!)));
for (const channelTargetId of channelTargets) get().damageMob(
channelTargetId,
talentAdjustedAmount(
@@ -9887,7 +9893,7 @@ export const useCombatStore = create((set, get) => ({
},
);
}
- if (healingAmount > 0) {
+ if (healingAmount > 0 && !(ability.target === "any" && channelHostile)) {
const perTargetHealing = talentAdjustedAmount(
healingAmount,
get().talentModifiers,
@@ -10002,10 +10008,10 @@ export const useCombatStore = create((set, get) => ({
nextTickAt,
get().playerPosition,
cast.targetId,
- cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition,
+ cast.groundPosition ?? (cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition),
);
ticksCompleted += 1;
- nextTickAt += (cast.completesAt - cast.startedAt) / cast.totalTicks;
+ nextTickAt = channelPulseAt(ability, cast.startedAt, cast.originalDurationMs, ticksCompleted);
}
if (get().activeCast?.id !== cast.id) return;
if (ticksCompleted >= cast.totalTicks || now >= cast.completesAt) {
diff --git a/src/game/dungeonArtworkMap.test.ts b/src/game/dungeonArtworkMap.test.ts
index 88b9cd6d..eb5e7436 100644
--- a/src/game/dungeonArtworkMap.test.ts
+++ b/src/game/dungeonArtworkMap.test.ts
@@ -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(33);
+ expect(dungeons).toHaveLength(38);
for (const dungeon of dungeons) {
const map = dungeonArtworkMapFor(dungeon);
expect(map, dungeon.id).not.toBeNull();
diff --git a/src/game/dungeonCatalog.test.ts b/src/game/dungeonCatalog.test.ts
index dd2fa227..5a067991 100644
--- a/src/game/dungeonCatalog.test.ts
+++ b/src/game/dungeonCatalog.test.ts
@@ -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(93);
+ expect(DUNGEON_CATALOG).toHaveLength(98);
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({
id: "stormwind-stockade",
releaseStage: "beta",
@@ -53,8 +53,9 @@ describe("dungeon catalog", () => {
);
expect(dungeonsByCategory("rom")).toHaveLength(30);
expect(dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id).sort())
- .toEqual(["bite-sized-deadmines-ship", "bite-sized-forsaken-abbey-ancalon",
- "bite-sized-mystic-altar-golem", "bite-sized-paspers-shrine-hafiz",
+ .toEqual(["bite-sized-barren-caves-anglo", "bite-sized-deadmines-ship", "bite-sized-forsaken-abbey-ancalon",
+ "bite-sized-grafu-castle-barkud", "bite-sized-hall-of-survivors-pursuer", "bite-sized-hoto-teeth",
+ "bite-sized-kawaks-tomb-linobia", "bite-sized-mystic-altar-golem", "bite-sized-paspers-shrine-hafiz",
"bite-sized-stormwind-stockade", "bite-sized-utgarde-pinnacle-skadi",
"bite-sized-wailing-caverns"]);
});
@@ -107,7 +108,12 @@ 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-barren-caves-anglo",
"bite-sized-forsaken-abbey-ancalon",
+ "bite-sized-grafu-castle-barkud",
+ "bite-sized-hall-of-survivors-pursuer",
+ "bite-sized-hoto-teeth",
+ "bite-sized-kawaks-tomb-linobia",
"bite-sized-mystic-altar-golem",
"bite-sized-paspers-shrine-hafiz",
"randomized-bite-sized-stormwind-stockade",
@@ -140,8 +146,9 @@ 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-forsaken-abbey-ancalon",
- "bite-sized-mystic-altar-golem", "bite-sized-paspers-shrine-hafiz", "bite-sized-stormwind-stockade",
+ .toEqual(["bite-sized-barren-caves-anglo", "bite-sized-deadmines-ship", "bite-sized-forsaken-abbey-ancalon",
+ "bite-sized-grafu-castle-barkud", "bite-sized-hall-of-survivors-pursuer", "bite-sized-hoto-teeth",
+ "bite-sized-kawaks-tomb-linobia", "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", "randomized-bite-sized-stormwind-stockade-available", "forsaken-abbey",
...Object.keys(availability.enabled)].sort());
diff --git a/src/game/dungeonContentSource.ts b/src/game/dungeonContentSource.ts
index 44b28c8a..ceb51f90 100644
--- a/src/game/dungeonContentSource.ts
+++ b/src/game/dungeonContentSource.ts
@@ -8,6 +8,11 @@ export const BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID = "bite-sized-utgarde-pinnac
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 BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID = "bite-sized-grafu-castle-barkud";
+export const BITE_SIZED_BARREN_CAVES_DUNGEON_ID = "bite-sized-barren-caves-anglo";
+export const BITE_SIZED_HOTO_DUNGEON_ID = "bite-sized-hoto-teeth";
+export const BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID = "bite-sized-kawaks-tomb-linobia";
+export const BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID = "bite-sized-hall-of-survivors-pursuer";
export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-sized";
const BITE_SIZED_DUNGEON_IDS: ReadonlySet = new Set([
@@ -20,6 +25,11 @@ const BITE_SIZED_DUNGEON_IDS: ReadonlySet = new Set([
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
+ BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID,
+ BITE_SIZED_BARREN_CAVES_DUNGEON_ID,
+ BITE_SIZED_HOTO_DUNGEON_ID,
+ BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID,
+ BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID,
]);
export function isBiteSizedDungeonId(dungeonId: string): boolean {
@@ -41,6 +51,11 @@ const DUNGEON_CONTENT_SOURCE_IDS: Readonly> = Object.free
[BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID]: "forsaken-abbey",
[BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID]: "paspers-shrine",
[BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID]: "mystic-altar",
+ [BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID]: "grafu-castle",
+ [BITE_SIZED_BARREN_CAVES_DUNGEON_ID]: "barren-caves",
+ [BITE_SIZED_HOTO_DUNGEON_ID]: "hoto",
+ [BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID]: "kawaks-tomb",
+ [BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID]: "hall-of-survivors",
});
export function isDerivedStockadesDungeonId(dungeonId: string): boolean {
@@ -55,7 +70,12 @@ export function isDerivedRandomBossDungeonId(dungeonId: string): boolean {
|| dungeonId === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID
|| dungeonId === BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID
|| dungeonId === BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID
- || dungeonId === BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID;
+ || dungeonId === BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID
+ || dungeonId === BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID
+ || dungeonId === BITE_SIZED_BARREN_CAVES_DUNGEON_ID
+ || dungeonId === BITE_SIZED_HOTO_DUNGEON_ID
+ || dungeonId === BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID
+ || dungeonId === BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID;
}
/** Derived dungeon variants share the installed assets of their source dungeon. */
diff --git a/src/game/dungeonLeaderboardCatalog.test.ts b/src/game/dungeonLeaderboardCatalog.test.ts
index 623d1377..e4de9ba0 100644
--- a/src/game/dungeonLeaderboardCatalog.test.ts
+++ b/src/game/dungeonLeaderboardCatalog.test.ts
@@ -50,7 +50,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(93);
+ expect(catalog.dungeons).toHaveLength(98);
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`);
diff --git a/src/game/dungeonRegistry.ts b/src/game/dungeonRegistry.ts
index 2e1fc314..564bf9d1 100644
--- a/src/game/dungeonRegistry.ts
+++ b/src/game/dungeonRegistry.ts
@@ -29,6 +29,11 @@ import {
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
+ BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID,
+ BITE_SIZED_BARREN_CAVES_DUNGEON_ID,
+ BITE_SIZED_HOTO_DUNGEON_ID,
+ BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID,
+ BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
@@ -45,6 +50,11 @@ import {
createBiteSizedForsakenAbbeyDefinition,
createBiteSizedMysticAltarDefinition,
createBiteSizedPaspersShrineDefinition,
+ createBiteSizedGrafuCastleDefinition,
+ createBiteSizedBarrenCavesDefinition,
+ createBiteSizedHotoDefinition,
+ createBiteSizedKawaksTombDefinition,
+ createBiteSizedHallOfSurvivorsDefinition,
} from "./dungeons/biteSizedRoMDungeons";
import {
createRandomizedBiteSizedStockadesDefinition,
@@ -235,6 +245,8 @@ export const RANDOMIZED_BITE_SIZED_CREATURE_CATALOG = Object.freeze(
const AVAILABLE_BITE_SIZED_SOURCE_IDS = new Set([
"stormwind-stockade", "wailing-caverns", "deadmines", "utgarde-pinnacle",
"forsaken-abbey", "paspers-shrine", "mystic-altar",
+ "grafu-castle", "barren-caves",
+ "hoto", "kawaks-tomb", "hall-of-survivors",
]);
function sourceIsAvailable(source: DungeonDefinition): boolean {
@@ -276,12 +288,24 @@ const biteSizedUtgardePinnacle = createBiteSizedUtgardePinnacleDefinition(utgard
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 grafuCastleDefinition = adaptedSourceRegistry["grafu-castle"];
+const barrenCavesDefinition = adaptedSourceRegistry["barren-caves"];
+const hotoDefinition = adaptedSourceRegistry["hoto"];
+const kawaksTombDefinition = adaptedSourceRegistry["kawaks-tomb"];
+const hallOfSurvivorsDefinition = adaptedSourceRegistry["hall-of-survivors"];
+if (!forsakenAbbeyDefinition || !paspersShrineDefinition || !mysticAltarDefinition
+ || !grafuCastleDefinition || !barrenCavesDefinition || !hotoDefinition
+ || !kawaksTombDefinition || !hallOfSurvivorsDefinition) {
+ throw new Error("The eight RoM source dungeons are required for their bite-sized variants.");
}
const biteSizedForsakenAbbey = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition, "catalog-template");
const biteSizedPaspersShrine = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition, "catalog-template");
const biteSizedMysticAltar = createBiteSizedMysticAltarDefinition(mysticAltarDefinition, "catalog-template");
+const biteSizedGrafuCastle = createBiteSizedGrafuCastleDefinition(grafuCastleDefinition, "catalog-template");
+const biteSizedBarrenCaves = createBiteSizedBarrenCavesDefinition(barrenCavesDefinition, "catalog-template");
+const biteSizedHoto = createBiteSizedHotoDefinition(hotoDefinition, "catalog-template");
+const biteSizedKawaksTomb = createBiteSizedKawaksTombDefinition(kawaksTombDefinition, "catalog-template");
+const biteSizedHallOfSurvivors = createBiteSizedHallOfSurvivorsDefinition(hallOfSurvivorsDefinition, "catalog-template");
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
...adaptedSourceEntries,
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
@@ -293,6 +317,11 @@ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[]
[biteSizedForsakenAbbey.id, biteSizedForsakenAbbey],
[biteSizedPaspersShrine.id, biteSizedPaspersShrine],
[biteSizedMysticAltar.id, biteSizedMysticAltar],
+ [biteSizedGrafuCastle.id, biteSizedGrafuCastle],
+ [biteSizedBarrenCaves.id, biteSizedBarrenCaves],
+ [biteSizedHoto.id, biteSizedHoto],
+ [biteSizedKawaksTomb.id, biteSizedKawaksTomb],
+ [biteSizedHallOfSurvivors.id, biteSizedHallOfSurvivors],
];
export const DUNGEON_REGISTRY: Readonly> = Object.freeze(
@@ -331,6 +360,11 @@ export function prepareDungeonSessionDefinition(id: string, seed: string): Dunge
else if (id === BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID) definition = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition, seed);
else if (id === BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID) definition = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition, seed);
else if (id === BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID) definition = createBiteSizedMysticAltarDefinition(mysticAltarDefinition, seed);
+ else if (id === BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID) definition = createBiteSizedGrafuCastleDefinition(grafuCastleDefinition, seed);
+ else if (id === BITE_SIZED_BARREN_CAVES_DUNGEON_ID) definition = createBiteSizedBarrenCavesDefinition(barrenCavesDefinition, seed);
+ else if (id === BITE_SIZED_HOTO_DUNGEON_ID) definition = createBiteSizedHotoDefinition(hotoDefinition, seed);
+ else if (id === BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID) definition = createBiteSizedKawaksTombDefinition(kawaksTombDefinition, seed);
+ else if (id === BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID) definition = createBiteSizedHallOfSurvivorsDefinition(hallOfSurvivorsDefinition, seed);
else if (id === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID) definition = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition, seed);
else if (id === BITE_SIZED_DEADMINES_DUNGEON_ID) definition = createBiteSizedDeadminesDefinition(deadminesDefinition, seed);
else if (id === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID) definition = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition, seed);
diff --git a/src/game/dungeons/biteSizedRoMDungeons.test.ts b/src/game/dungeons/biteSizedRoMDungeons.test.ts
index 9bfcc77c..e83cd8fe 100644
--- a/src/game/dungeons/biteSizedRoMDungeons.test.ts
+++ b/src/game/dungeons/biteSizedRoMDungeons.test.ts
@@ -12,6 +12,11 @@ const sliceIds = [
"bite-sized-forsaken-abbey-ancalon",
"bite-sized-paspers-shrine-hafiz",
"bite-sized-mystic-altar-golem",
+ "bite-sized-grafu-castle-barkud",
+ "bite-sized-barren-caves-anglo",
+ "bite-sized-hoto-teeth",
+ "bite-sized-kawaks-tomb-linobia",
+ "bite-sized-hall-of-survivors-pursuer",
] as const;
describe("RoM bite-sized finales", () => {
@@ -43,7 +48,8 @@ describe("RoM bite-sized finales", () => {
expect(source).toBeDefined();
expect(dungeonContentPackId(id)).toBe(`dungeon:${source.id}`);
expect(slice.assets).toBe(source.assets);
- expect(slice.staticSpawns).toHaveLength(6);
+ expect(slice.staticSpawns).toHaveLength(id === "bite-sized-barren-caves-anglo" ? 4
+ : id === "bite-sized-kawaks-tomb-linobia" || id === "bite-sized-hall-of-survivors-pursuer" ? 5 : 6);
expect(slice.staticSpawns.every((spawn) => source.staticSpawns.includes(spawn))).toBe(true);
expect(slice.bosses).toHaveLength(1);
expect(slice.bosses[0].eventOnly).toBe(true);
@@ -65,7 +71,7 @@ describe("RoM bite-sized finales", () => {
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);
+ expect(source.bosses.length).toBeGreaterThanOrEqual(1);
expect(slice.presentation.ambientLight.intensity).toBeGreaterThan(source.presentation.ambientLight.intensity);
expect(slice.presentation.hemisphereLight.intensity).toBeGreaterThan(source.presentation.hemisphereLight.intensity);
});
diff --git a/src/game/dungeons/biteSizedRoMDungeons.ts b/src/game/dungeons/biteSizedRoMDungeons.ts
index e4dac2ea..c9a66994 100644
--- a/src/game/dungeons/biteSizedRoMDungeons.ts
+++ b/src/game/dungeons/biteSizedRoMDungeons.ts
@@ -9,6 +9,11 @@ import {
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
+ BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID,
+ BITE_SIZED_BARREN_CAVES_DUNGEON_ID,
+ BITE_SIZED_HOTO_DUNGEON_ID,
+ BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID,
+ BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID,
} from "../dungeonContentSource";
import { seededBossIndex } from "./seededBossSelection";
@@ -24,6 +29,7 @@ interface RoMSlice {
readonly mobSpawnIds: readonly string[];
readonly summary: string;
readonly adaptation: string;
+ readonly lighting?: { readonly ambient: number; readonly hemisphere: number; readonly directional: number };
}
const ROM_SLICES: readonly RoMSlice[] = [
@@ -80,6 +86,91 @@ const ROM_SLICES: readonly RoMSlice[] = [
summary: "Clear the guards, beetles, and Morcus beasts before one surprise Altar boss.",
adaptation: "Dorlos, the earlier altar route, and the remaining beetle swarm are outside this short run.",
},
+ {
+ sourceId: "grafu-castle",
+ id: BITE_SIZED_GRAFU_CASTLE_DUNGEON_ID,
+ title: "Grafu Castle: Late Gallery",
+ location: "Grafu Castle · Barkud's Gallery",
+ entranceName: "Barkud's Gallery",
+ start: [-78.5, -40.2, -133.98],
+ forward: [1, 0, 0],
+ finaleBossName: "Larvanger Barkud",
+ mobSpawnIds: [
+ "rw-grafu-castle-spawn-41927461", "rw-grafu-castle-spawn-41927502",
+ "rw-grafu-castle-spawn-41927501", "rw-grafu-castle-spawn-41927460",
+ "rw-grafu-castle-spawn-41927499", "rw-grafu-castle-spawn-41927498",
+ ],
+ summary: "Clear the clowns and steel soldiers in the late gallery, then defeat one surprise Castle boss.",
+ adaptation: "Chapeaunoir, earlier bosses, side branches, and patrol scripts are outside this late-gallery run.",
+ },
+ {
+ sourceId: "barren-caves",
+ id: BITE_SIZED_BARREN_CAVES_DUNGEON_ID,
+ title: "Barren Caves: Last Bend",
+ location: "Barren Caves · Anglo's Bend",
+ entranceName: "Anglo's Bend",
+ start: [-20, -0.1, 75],
+ forward: [0, 0, -1],
+ finaleBossName: "Anglo",
+ mobSpawnIds: [
+ "rw-barren-caves-spawn-5951185", "rw-barren-caves-spawn-5951182",
+ "rw-barren-caves-spawn-5951181", "rw-barren-caves-spawn-5951183",
+ ],
+ summary: "Clear the last kobold bend, then defeat Anglo.",
+ adaptation: "The earlier cave loop is outside this short run; Anglo is the source dungeon's only boss.",
+ lighting: { ambient: 0.48, hemisphere: 0.65, directional: 0.85 },
+ },
+ {
+ sourceId: "hoto",
+ id: BITE_SIZED_HOTO_DUNGEON_ID,
+ title: "Hoto: Reef Approach",
+ location: "Hoto · Teeth of the Reef Approach",
+ entranceName: "Reef Approach",
+ start: [114.160107, -33.598026, 64.647491],
+ forward: [0.70710678, 0, -0.70710678],
+ finaleBossName: "Teeth of the Reef",
+ mobSpawnIds: [
+ "rw-hoto-spawn-5978485", "rw-hoto-spawn-5978483",
+ "rw-hoto-spawn-5978486", "rw-hoto-spawn-5978484",
+ "rw-hoto-spawn-5978631", "rw-hoto-spawn-5979808",
+ ],
+ summary: "Clear the Ayam guards, casters, and defenders, then defeat one surprise Hoto boss.",
+ adaptation: "Jiasha, earlier boss rooms, and unrelated Hoto population are outside this short run.",
+ },
+ {
+ sourceId: "kawaks-tomb",
+ id: BITE_SIZED_KAWAKS_TOMB_DUNGEON_ID,
+ title: "Kawak's Tomb: Scorpion Passage",
+ location: "Kawak's Tomb · Linobia's Passage",
+ entranceName: "Linobia's Passage",
+ start: [-85, -9.4, -243],
+ forward: [-1, 0, 0],
+ finaleBossName: "Linobia",
+ mobSpawnIds: [
+ "rw-kawaks-tomb-spawn-41889142", "rw-kawaks-tomb-spawn-41889139",
+ "rw-kawaks-tomb-spawn-41892423", "rw-kawaks-tomb-spawn-41890637",
+ "rw-kawaks-tomb-spawn-41889112",
+ ],
+ summary: "Clear the scorpions, beetles, and scarab, then defeat Linobia.",
+ adaptation: "The earlier tomb route is outside this short run; Linobia is the source dungeon's only boss.",
+ },
+ {
+ sourceId: "hall-of-survivors",
+ id: BITE_SIZED_HALL_OF_SURVIVORS_DUNGEON_ID,
+ title: "Hall of Survivors: Pursuer Ward",
+ location: "Hall of Survivors · Pursuer Ward",
+ entranceName: "Pursuer Ward",
+ start: [-155, -17.55, 35],
+ forward: [-1, 0, 0],
+ finaleBossName: "Divine Punishment Pursuer",
+ mobSpawnIds: [
+ "rw-hall-of-survivors-spawn-11962803", "rw-hall-of-survivors-spawn-11962817",
+ "rw-hall-of-survivors-spawn-5991940", "rw-hall-of-survivors-spawn-5991941",
+ "rw-hall-of-survivors-spawn-5991939",
+ ],
+ summary: "Clear the hunters and Rune Guardians, then defeat one surprise Hall boss.",
+ adaptation: "The other Pursuer and Seeker bosses, earlier rooms, and their combined source scripts are outside this short run.",
+ },
];
function createRoMSlice(source: DungeonDefinition, slice: RoMSlice, seed: string): DungeonDefinition {
@@ -161,9 +252,11 @@ function createRoMSlice(source: DungeonDefinition, slice: RoMSlice, seed: string
presentation: { ...source.presentation, location: slice.location, theme: "Bite-Sized Dungeon",
summary: slice.summary, loadingMessage: `Entering ${slice.entranceName}...`,
unchartedAreaName: slice.entranceName,
- ambientLight: { color: "#e2dccc", intensity: 0.9 },
- hemisphereLight: { skyColor: "#ded9d0", groundColor: "#5a5350", intensity: 1.2 },
- directionalLight: { ...source.presentation.directionalLight, color: "#fff0d9", intensity: 1.35 } },
+ ambientLight: { color: "#e2dccc", intensity: slice.lighting?.ambient ?? 0.9 },
+ hemisphereLight: { skyColor: "#ded9d0", groundColor: "#5a5350", intensity: slice.lighting?.hemisphere ?? 1.2 },
+ directionalLight: { ...source.presentation.directionalLight,
+ color: slice.lighting ? source.presentation.directionalLight.color : "#fff0d9",
+ intensity: slice.lighting?.directional ?? 1.35 } },
areas,
entities,
staticSpawns: mobSpawns,
@@ -190,3 +283,23 @@ export function createBiteSizedPaspersShrineDefinition(source: DungeonDefinition
export function createBiteSizedMysticAltarDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[2], seed);
}
+
+export function createBiteSizedGrafuCastleDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
+ return createRoMSlice(source, ROM_SLICES[3], seed);
+}
+
+export function createBiteSizedBarrenCavesDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
+ return createRoMSlice(source, ROM_SLICES[4], seed);
+}
+
+export function createBiteSizedHotoDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
+ return createRoMSlice(source, ROM_SLICES[5], seed);
+}
+
+export function createBiteSizedKawaksTombDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
+ return createRoMSlice(source, ROM_SLICES[6], seed);
+}
+
+export function createBiteSizedHallOfSurvivorsDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
+ return createRoMSlice(source, ROM_SLICES[7], seed);
+}
diff --git a/src/game/generated/dungeonLeaderboardCatalog.generated.json b/src/game/generated/dungeonLeaderboardCatalog.generated.json
index 39b3f5ea..e7ae86c7 100644
--- a/src/game/generated/dungeonLeaderboardCatalog.generated.json
+++ b/src/game/generated/dungeonLeaderboardCatalog.generated.json
@@ -216,6 +216,31 @@
],
"defaultDifficultyId": "normal"
},
+ {
+ "id": "bite-sized-barren-caves-anglo",
+ "title": "Barren Caves: Last Bend",
+ "finalBossId": "rw-barren-caves-spawn-5951180",
+ "bosses": [
+ {
+ "id": "rw-barren-caves-spawn-5951180",
+ "name": "Anglo"
+ }
+ ],
+ "difficulties": [
+ {
+ "id": "normal",
+ "name": "normal",
+ "bossIds": [
+ "rw-barren-caves-spawn-5951180"
+ ],
+ "finalBossId": "rw-barren-caves-spawn-5951180",
+ "finalBossIds": [
+ "rw-barren-caves-spawn-5951180"
+ ]
+ }
+ ],
+ "defaultDifficultyId": "normal"
+ },
{
"id": "bite-sized-deadmines-ship",
"title": "The Deadmines: Take the Ship",
@@ -344,6 +369,202 @@
],
"defaultDifficultyId": "normal"
},
+ {
+ "id": "bite-sized-grafu-castle-barkud",
+ "title": "Grafu Castle: Late Gallery",
+ "finalBossId": "rw-grafu-castle-scripted-cayus",
+ "bosses": [
+ {
+ "id": "rw-grafu-castle-scripted-cayus",
+ "name": "Cayus"
+ },
+ {
+ "id": "rw-grafu-castle-scripted-ardmond",
+ "name": "Ardmond and Issac"
+ },
+ {
+ "id": "rw-grafu-castle-scripted-boro-boro-brothers",
+ "name": "Boro-Boro Brothers"
+ },
+ {
+ "id": "rw-grafu-castle-scripted-conjurer-chapeaunoir",
+ "name": "Conjurer Chapeaunoir"
+ },
+ {
+ "id": "rw-grafu-castle-scripted-larvanger-barkud",
+ "name": "Larvanger Barkud"
+ }
+ ],
+ "difficulties": [
+ {
+ "id": "normal",
+ "name": "normal",
+ "bossIds": [
+ "rw-grafu-castle-scripted-cayus",
+ "rw-grafu-castle-scripted-ardmond",
+ "rw-grafu-castle-scripted-boro-boro-brothers",
+ "rw-grafu-castle-scripted-conjurer-chapeaunoir",
+ "rw-grafu-castle-scripted-larvanger-barkud"
+ ],
+ "finalBossId": "rw-grafu-castle-scripted-cayus",
+ "finalBossIds": [
+ "rw-grafu-castle-scripted-cayus",
+ "rw-grafu-castle-scripted-ardmond",
+ "rw-grafu-castle-scripted-boro-boro-brothers",
+ "rw-grafu-castle-scripted-conjurer-chapeaunoir",
+ "rw-grafu-castle-scripted-larvanger-barkud"
+ ]
+ }
+ ],
+ "defaultDifficultyId": "normal"
+ },
+ {
+ "id": "bite-sized-hall-of-survivors-pursuer",
+ "title": "Hall of Survivors: Pursuer Ward",
+ "finalBossId": "rw-hall-of-survivors-spawn-5991781",
+ "bosses": [
+ {
+ "id": "rw-hall-of-survivors-spawn-5991781",
+ "name": "Andaphelmor"
+ },
+ {
+ "id": "rw-hall-of-survivors-spawn-41850487",
+ "name": "Andriol"
+ },
+ {
+ "id": "rw-hall-of-survivors-spawn-11962818",
+ "name": "Divine Punishment Pursuer"
+ },
+ {
+ "id": "rw-hall-of-survivors-spawn-11962813",
+ "name": "Prison Pursuer"
+ },
+ {
+ "id": "rw-hall-of-survivors-spawn-11962804",
+ "name": "Horror Seeker"
+ },
+ {
+ "id": "rw-hall-of-survivors-spawn-5995573",
+ "name": "Glamo"
+ },
+ {
+ "id": "rw-hall-of-survivors-spawn-11962041",
+ "name": "Mantarick"
+ },
+ {
+ "id": "rw-hall-of-survivors-spawn-11962386",
+ "name": "Guldamor"
+ },
+ {
+ "id": "rw-hall-of-survivors-spawn-5995182",
+ "name": "Zygro"
+ }
+ ],
+ "difficulties": [
+ {
+ "id": "normal",
+ "name": "normal",
+ "bossIds": [
+ "rw-hall-of-survivors-spawn-5991781",
+ "rw-hall-of-survivors-spawn-41850487",
+ "rw-hall-of-survivors-spawn-11962818",
+ "rw-hall-of-survivors-spawn-11962813",
+ "rw-hall-of-survivors-spawn-11962804",
+ "rw-hall-of-survivors-spawn-5995573",
+ "rw-hall-of-survivors-spawn-11962041",
+ "rw-hall-of-survivors-spawn-11962386",
+ "rw-hall-of-survivors-spawn-5995182"
+ ],
+ "finalBossId": "rw-hall-of-survivors-spawn-5991781",
+ "finalBossIds": [
+ "rw-hall-of-survivors-spawn-5991781",
+ "rw-hall-of-survivors-spawn-41850487",
+ "rw-hall-of-survivors-spawn-11962818",
+ "rw-hall-of-survivors-spawn-11962813",
+ "rw-hall-of-survivors-spawn-11962804",
+ "rw-hall-of-survivors-spawn-5995573",
+ "rw-hall-of-survivors-spawn-11962041",
+ "rw-hall-of-survivors-spawn-11962386",
+ "rw-hall-of-survivors-spawn-5995182"
+ ]
+ }
+ ],
+ "defaultDifficultyId": "normal"
+ },
+ {
+ "id": "bite-sized-hoto-teeth",
+ "title": "Hoto: Reef Approach",
+ "finalBossId": "rw-hoto-spawn-5979062",
+ "bosses": [
+ {
+ "id": "rw-hoto-spawn-5979062",
+ "name": "Medusa"
+ },
+ {
+ "id": "rw-hoto-spawn-5979861",
+ "name": "Geba"
+ },
+ {
+ "id": "rw-hoto-spawn-5979036",
+ "name": "Bloodthirsty Claw"
+ },
+ {
+ "id": "rw-hoto-spawn-5979022",
+ "name": "Jiasha"
+ },
+ {
+ "id": "rw-hoto-spawn-5979747",
+ "name": "Teeth of the Reef"
+ }
+ ],
+ "difficulties": [
+ {
+ "id": "normal",
+ "name": "normal",
+ "bossIds": [
+ "rw-hoto-spawn-5979062",
+ "rw-hoto-spawn-5979861",
+ "rw-hoto-spawn-5979036",
+ "rw-hoto-spawn-5979022",
+ "rw-hoto-spawn-5979747"
+ ],
+ "finalBossId": "rw-hoto-spawn-5979062",
+ "finalBossIds": [
+ "rw-hoto-spawn-5979062",
+ "rw-hoto-spawn-5979861",
+ "rw-hoto-spawn-5979036",
+ "rw-hoto-spawn-5979022",
+ "rw-hoto-spawn-5979747"
+ ]
+ }
+ ],
+ "defaultDifficultyId": "normal"
+ },
+ {
+ "id": "bite-sized-kawaks-tomb-linobia",
+ "title": "Kawak's Tomb: Scorpion Passage",
+ "finalBossId": "rw-kawaks-tomb-spawn-41888272",
+ "bosses": [
+ {
+ "id": "rw-kawaks-tomb-spawn-41888272",
+ "name": "Linobia"
+ }
+ ],
+ "difficulties": [
+ {
+ "id": "normal",
+ "name": "normal",
+ "bossIds": [
+ "rw-kawaks-tomb-spawn-41888272"
+ ],
+ "finalBossId": "rw-kawaks-tomb-spawn-41888272",
+ "finalBossIds": [
+ "rw-kawaks-tomb-spawn-41888272"
+ ]
+ }
+ ],
+ "defaultDifficultyId": "normal"
+ },
{
"id": "bite-sized-mystic-altar-golem",
"title": "Mystic Altar: Final Antechamber",
@@ -4327,8 +4548,24 @@
{
"id": "randomized-bite-sized-stormwind-stockade-available",
"title": "Randomized Bite-Sized: Bite-Sized Sources",
- "finalBossId": "randomized:wow:boss:1716",
+ "finalBossId": "randomized:rom:boss:102430",
"bosses": [
+ {
+ "id": "randomized:rom:boss:102430",
+ "name": "Andaphelmor"
+ },
+ {
+ "id": "randomized:rom:boss:102421",
+ "name": "Andriol"
+ },
+ {
+ "id": "randomized:rom:boss:101462",
+ "name": "Anglo"
+ },
+ {
+ "id": "randomized:rom:boss:105542",
+ "name": "Ardmond"
+ },
{
"id": "randomized:wow:boss:1716",
"name": "Bazil Thredd"
@@ -4353,10 +4590,26 @@
"id": "randomized:rom:boss:101020",
"name": "Blackhorn Nisorn"
},
+ {
+ "id": "randomized:rom:boss:102046",
+ "name": "Bloodthirsty Claw"
+ },
+ {
+ "id": "randomized:rom:boss:105224",
+ "name": "Boro-Boro Brothers"
+ },
{
"id": "randomized:wow:boss:647",
"name": "Captain Greenskin"
},
+ {
+ "id": "randomized:rom:boss:105616",
+ "name": "Cayus"
+ },
+ {
+ "id": "randomized:rom:boss:105204",
+ "name": "Conjurer Chapeaunoir"
+ },
{
"id": "randomized:wow:boss:645",
"name": "Cookie"
@@ -4381,6 +4634,10 @@
"id": "randomized:wow:boss:1663",
"name": "Dextren Ward"
},
+ {
+ "id": "randomized:rom:boss:102476",
+ "name": "Divine Punishment Pursuer"
+ },
{
"id": "randomized:rom:boss:100611",
"name": "Dorlos"
@@ -4393,14 +4650,26 @@
"id": "randomized:rom:boss:100561",
"name": "Flowing Chaos"
},
+ {
+ "id": "randomized:rom:boss:102063",
+ "name": "Geba"
+ },
{
"id": "randomized:wow:boss:1763",
"name": "Gilnid"
},
+ {
+ "id": "randomized:rom:boss:102475",
+ "name": "Glamo"
+ },
{
"id": "randomized:wow:boss:26687",
"name": "Gortok Palehoof"
},
+ {
+ "id": "randomized:rom:boss:102395",
+ "name": "Guldamor"
+ },
{
"id": "randomized:wow:boss:1717",
"name": "Hamhock"
@@ -4409,6 +4678,18 @@
"id": "randomized:rom:boss:100563",
"name": "Hollow Shell"
},
+ {
+ "id": "randomized:rom:boss:102643",
+ "name": "Horror Seeker"
+ },
+ {
+ "id": "randomized:rom:boss:105543",
+ "name": "Issac"
+ },
+ {
+ "id": "randomized:rom:boss:102021",
+ "name": "Jiasha"
+ },
{
"id": "randomized:wow:boss:1666",
"name": "Kam Deepfury"
@@ -4425,6 +4706,14 @@
"id": "randomized:wow:boss:3671",
"name": "Lady Anacondra"
},
+ {
+ "id": "randomized:rom:boss:105351",
+ "name": "Larvanger Barkud"
+ },
+ {
+ "id": "randomized:rom:boss:104228",
+ "name": "Linobia"
+ },
{
"id": "randomized:wow:boss:3669",
"name": "Lord Cobrahn"
@@ -4437,6 +4726,14 @@
"id": "randomized:wow:boss:3673",
"name": "Lord Serpentis"
},
+ {
+ "id": "randomized:rom:boss:102452",
+ "name": "Mantarick"
+ },
+ {
+ "id": "randomized:rom:boss:102040",
+ "name": "Medusa"
+ },
{
"id": "randomized:wow:boss:646",
"name": "Mr. Smite"
@@ -4449,6 +4746,10 @@
"id": "randomized:rom:boss:100070",
"name": "Necromage"
},
+ {
+ "id": "randomized:rom:boss:102560",
+ "name": "Prison Pursuer"
+ },
{
"id": "randomized:rom:boss:100154",
"name": "Razeela"
@@ -4477,9 +4778,17 @@
"id": "randomized:wow:boss:1696",
"name": "Targorr the Dread"
},
+ {
+ "id": "randomized:rom:boss:102041",
+ "name": "Teeth of the Reef"
+ },
{
"id": "randomized:wow:boss:5775",
"name": "Verdan the Everliving"
+ },
+ {
+ "id": "randomized:rom:boss:102425",
+ "name": "Zygro"
}
],
"difficulties": [
@@ -4487,36 +4796,56 @@
"id": "normal",
"name": "normal",
"bossIds": [
+ "randomized:rom:boss:102430",
+ "randomized:rom:boss:102421",
+ "randomized:rom:boss:101462",
+ "randomized:rom:boss:105542",
"randomized:wow:boss:1716",
"randomized:rom:boss:101018",
"randomized:rom:boss:101019",
"randomized:rom:boss:101021",
"randomized:rom:boss:100794",
"randomized:rom:boss:101020",
+ "randomized:rom:boss:102046",
+ "randomized:rom:boss:105224",
"randomized:wow:boss:647",
+ "randomized:rom:boss:105616",
+ "randomized:rom:boss:105204",
"randomized:wow:boss:645",
"randomized:rom:boss:100562",
"randomized:rom:boss:100138",
"randomized:rom:boss:100074",
"randomized:wow:boss:5912",
"randomized:wow:boss:1663",
+ "randomized:rom:boss:102476",
"randomized:rom:boss:100611",
"randomized:wow:boss:639",
"randomized:rom:boss:100561",
+ "randomized:rom:boss:102063",
"randomized:wow:boss:1763",
+ "randomized:rom:boss:102475",
"randomized:wow:boss:26687",
+ "randomized:rom:boss:102395",
"randomized:wow:boss:1717",
"randomized:rom:boss:100563",
+ "randomized:rom:boss:102643",
+ "randomized:rom:boss:105543",
+ "randomized:rom:boss:102021",
"randomized:wow:boss:1666",
"randomized:wow:boss:26861",
"randomized:wow:boss:3653",
"randomized:wow:boss:3671",
+ "randomized:rom:boss:105351",
+ "randomized:rom:boss:104228",
"randomized:wow:boss:3669",
"randomized:wow:boss:3670",
"randomized:wow:boss:3673",
+ "randomized:rom:boss:102452",
+ "randomized:rom:boss:102040",
"randomized:wow:boss:646",
"randomized:wow:boss:3654",
"randomized:rom:boss:100070",
+ "randomized:rom:boss:102560",
"randomized:rom:boss:100154",
"randomized:wow:boss:644",
"randomized:wow:boss:26693",
@@ -4524,40 +4853,62 @@
"randomized:wow:boss:642",
"randomized:wow:boss:26668",
"randomized:wow:boss:1696",
- "randomized:wow:boss:5775"
+ "randomized:rom:boss:102041",
+ "randomized:wow:boss:5775",
+ "randomized:rom:boss:102425"
],
- "finalBossId": "randomized:wow:boss:1716",
+ "finalBossId": "randomized:rom:boss:102430",
"finalBossIds": [
+ "randomized:rom:boss:102430",
+ "randomized:rom:boss:102421",
+ "randomized:rom:boss:101462",
+ "randomized:rom:boss:105542",
"randomized:wow:boss:1716",
"randomized:rom:boss:101018",
"randomized:rom:boss:101019",
"randomized:rom:boss:101021",
"randomized:rom:boss:100794",
"randomized:rom:boss:101020",
+ "randomized:rom:boss:102046",
+ "randomized:rom:boss:105224",
"randomized:wow:boss:647",
+ "randomized:rom:boss:105616",
+ "randomized:rom:boss:105204",
"randomized:wow:boss:645",
"randomized:rom:boss:100562",
"randomized:rom:boss:100138",
"randomized:rom:boss:100074",
"randomized:wow:boss:5912",
"randomized:wow:boss:1663",
+ "randomized:rom:boss:102476",
"randomized:rom:boss:100611",
"randomized:wow:boss:639",
"randomized:rom:boss:100561",
+ "randomized:rom:boss:102063",
"randomized:wow:boss:1763",
+ "randomized:rom:boss:102475",
"randomized:wow:boss:26687",
+ "randomized:rom:boss:102395",
"randomized:wow:boss:1717",
"randomized:rom:boss:100563",
+ "randomized:rom:boss:102643",
+ "randomized:rom:boss:105543",
+ "randomized:rom:boss:102021",
"randomized:wow:boss:1666",
"randomized:wow:boss:26861",
"randomized:wow:boss:3653",
"randomized:wow:boss:3671",
+ "randomized:rom:boss:105351",
+ "randomized:rom:boss:104228",
"randomized:wow:boss:3669",
"randomized:wow:boss:3670",
"randomized:wow:boss:3673",
+ "randomized:rom:boss:102452",
+ "randomized:rom:boss:102040",
"randomized:wow:boss:646",
"randomized:wow:boss:3654",
"randomized:rom:boss:100070",
+ "randomized:rom:boss:102560",
"randomized:rom:boss:100154",
"randomized:wow:boss:644",
"randomized:wow:boss:26693",
@@ -4565,7 +4916,9 @@
"randomized:wow:boss:642",
"randomized:wow:boss:26668",
"randomized:wow:boss:1696",
- "randomized:wow:boss:5775"
+ "randomized:rom:boss:102041",
+ "randomized:wow:boss:5775",
+ "randomized:rom:boss:102425"
]
}
],
diff --git a/src/game/partyDamageAudit.test.ts b/src/game/partyDamageAudit.test.ts
index 1d85a16f..51adf8c3 100644
--- a/src/game/partyDamageAudit.test.ts
+++ b/src/game/partyDamageAudit.test.ts
@@ -134,6 +134,10 @@ describe("NPC sustained damage audit", () => {
&& event.effectiveAmount > 0)
.map((event) => event.targetActorId));
expect(hitTargets).toEqual(new Set(targetIds));
+ if (row.abilityName === "Volley") {
+ expect(useCombatStore.getState().areaEffects.some((effect) => effect.sourceActorId === member.id
+ && effect.sourceAbilityId === ability.id)).toBe(false);
+ }
});
}
diff --git a/src/game/partyRuntime.test.ts b/src/game/partyRuntime.test.ts
index 80e5f800..fabef2c0 100644
--- a/src/game/partyRuntime.test.ts
+++ b/src/game/partyRuntime.test.ts
@@ -354,6 +354,48 @@ describe("party combat runtime", () => {
.toBeGreaterThan(startingHealth);
});
+ it("keeps companion Penance active for three healing pulses", () => {
+ useCombatStore.getState().initializeCharacter({ classId: "mage", role: "damage", level: 80 });
+ const tank = createConfiguredPartyMember({
+ name: "Tank", classId: "warrior", specialization: "Protection",
+ specializationId: "warrior:protection", talentTreeId: "warrior-protection", role: "tank",
+ }, 80, 0, "penance-runtime");
+ const healer = createConfiguredPartyMember({
+ name: "Discipline", classId: "priest", specialization: "Discipline",
+ specializationId: "priest:discipline", talentTreeId: "priest-discipline",
+ talentRanks: runtimePartyTalentRanksForLevel("priest", "Discipline", 80), role: "healer",
+ }, 80, 1, "penance-runtime");
+ const priestAbilities = abilitiesForClass("priest");
+ const penance = priestAbilities.find((ability) => ability.name === "Penance")!;
+ const startingHealth = Math.round(tank.maxHealth * 0.2);
+ usePartyStore.getState().initializeParty("penance-runtime", 80, useCombatStore.getState().health, "damage");
+ usePartyStore.setState({ members: [
+ { ...tank, health: startingHealth },
+ { ...healer, cooldowns: Object.fromEntries(priestAbilities
+ .filter((ability) => ability.id !== penance.id)
+ .map((ability) => [ability.id, 60_000])) },
+ ] });
+ for (const member of usePartyStore.getState().members) registerPartyRuntimePosition(member.id, [0, 0, 0]);
+ useCombatStore.getState().synchronizeActors();
+ registerEngagedMob("penance-boss", 100_000);
+
+ advanceDungeonPartyCombat(1_000);
+ expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast)
+ .toMatchObject({ mode: "channel", startedAt: 1_000, completesAt: 3_000 });
+ expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.health).toBe(startingHealth);
+ advanceDungeonPartyCombat(1_001);
+ const first = usePartyStore.getState().members.find((member) => member.id === tank.id)!.health;
+ expect(first).toBeGreaterThan(startingHealth);
+ advanceDungeonPartyCombat(1_999);
+ expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.health).toBe(first);
+ advanceDungeonPartyCombat(2_000);
+ expect(usePartyStore.getState().members.find((member) => member.id === tank.id)?.health).toBeGreaterThan(first);
+ advanceDungeonPartyCombat(3_000);
+ expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.activeCast).toBeNull();
+ expect(usePartyStore.getState().members.find((member) => member.id === healer.id)?.nextActionAt).toBe(3_250);
+ expect(useCombatStore.getState().effects.some((effect) => effect.sourceAbilityId === penance.id)).toBe(false);
+ });
+
it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
registerEngagedMob();
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
diff --git a/src/game/partyRuntime.ts b/src/game/partyRuntime.ts
index f7ffb638..da546f21 100644
--- a/src/game/partyRuntime.ts
+++ b/src/game/partyRuntime.ts
@@ -65,7 +65,7 @@ import { imminentCooldownMechanic } from "./companionCooldownPolicy";
import { normalizeActorResourcePool } from "./combatActors";
import { enemyControlPreventsActions } from "./enemyAttacks";
import { advanceCombatResourcePool } from "./combatResources";
-import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts";
+import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingRecipientIds } from "./healingAbilityRules";
import {
combatHasLineOfSight,
@@ -1173,6 +1173,7 @@ function executePartyAbility(
&& combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("fire-crit-stacking"));
const groundAreaCreated = targetingRulesForAbility(ability).shape === "ground"
+ && !(castStartAlreadyEmitted && ability.castMode === "channel")
? Boolean(useCombatStore.getState().createActorAreaEffect(
member.id,
ability,
@@ -1813,9 +1814,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
continue;
}
const totalTicks = channelPulseCount(castingAbility);
- const interval = Math.max(1, cast.originalDurationMs / totalTicks);
let completed = cast.channelTicksCompleted ?? 0;
- while (completed < totalTicks && cast.startedAt + (completed + 1) * interval <= now) {
+ while (completed < totalTicks) {
+ const pulseAt = channelPulseAt(castingAbility, cast.startedAt, cast.originalDurationMs, completed);
+ if (pulseAt > now || pulseAt > cast.completesAt) break;
const effects: AbilityEffect[] = channelAmountEffects(castingAbility, totalTicks).map(({ effect, multiplier }) => {
const amount = Math.round(partyAbilityAmount(member, castingAbility, effect) * multiplier);
if (effect.kind === "heal" || effect.kind === "hot") return {
@@ -1830,7 +1832,7 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
return { kind: "damage", coefficient: 0, basePoints: amount, sourceLevel: member.level };
});
const execution = executePartyAbility(member, { ...castingAbility, effects }, castTargetId,
- cast.startedAt + (completed + 1) * interval, nextMembers, 0, true);
+ pulseAt, nextMembers, 0, true);
member = execution.member;
completed += 1;
}
@@ -1863,7 +1865,9 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = {
...execution.member,
status: desiredStatus,
- nextActionAt: now + Math.max(250, castingAbility.gcdMs),
+ nextActionAt: activeCast.mode === "channel"
+ ? Math.max(now + 250, execution.member.globalCooldownEndsAt)
+ : now + Math.max(250, castingAbility.gcdMs),
};
} else {
nextMembers[index] = {
diff --git a/src/game/romBaseStatGrowth.generated.json b/src/game/romBaseStatGrowth.generated.json
new file mode 100644
index 00000000..d2e05ce8
--- /dev/null
+++ b/src/game/romBaseStatGrowth.generated.json
@@ -0,0 +1 @@
+{"rom-warrior":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":6,"flatGrowth":2.1,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-scout":{"strength":{"levelOne":11,"flatGrowth":3.85,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"wisdom":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"agility":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-rogue":{"strength":{"levelOne":12,"flatGrowth":4.15,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"intellect":{"levelOne":7,"flatGrowth":2.6,"percentGrowth":0.02},"wisdom":{"levelOne":7,"flatGrowth":2.4,"percentGrowth":0.02},"agility":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-mage":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-priest":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":10,"flatGrowth":3.6,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-knight":{"strength":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"stamina":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"intellect":{"levelOne":11,"flatGrowth":3.75,"percentGrowth":0.02},"wisdom":{"levelOne":11,"flatGrowth":4.1,"percentGrowth":0.02},"agility":{"levelOne":11,"flatGrowth":3.9,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warden":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":12,"flatGrowth":4.2,"percentGrowth":0.02},"intellect":{"levelOne":10,"flatGrowth":3.8,"percentGrowth":0.02},"wisdom":{"levelOne":10,"flatGrowth":4,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-druid":{"strength":{"levelOne":7,"flatGrowth":2.5,"percentGrowth":0.02},"stamina":{"levelOne":11,"flatGrowth":3.7,"percentGrowth":0.02},"intellect":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"wisdom":{"levelOne":13,"flatGrowth":4.9,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.65,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-warlock":{"strength":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"stamina":{"levelOne":9,"flatGrowth":3.4,"percentGrowth":0.02},"intellect":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"wisdom":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"agility":{"levelOne":10,"flatGrowth":3.5,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}},"rom-champion":{"strength":{"levelOne":14,"flatGrowth":5,"percentGrowth":0.02},"stamina":{"levelOne":13,"flatGrowth":4.5,"percentGrowth":0.02},"intellect":{"levelOne":5,"flatGrowth":1.9,"percentGrowth":0.02},"wisdom":{"levelOne":6,"flatGrowth":2.1,"percentGrowth":0.02},"agility":{"levelOne":12,"flatGrowth":4.25,"percentGrowth":0.02},"health":{"levelOne":60,"flatGrowth":2,"percentGrowth":0.02},"mana":{"levelOne":20,"flatGrowth":2,"percentGrowth":0.02}}}
diff --git a/src/game/romPlayerCatalog.test.ts b/src/game/romPlayerCatalog.test.ts
index a3bd912e..6dc3bb92 100644
--- a/src/game/romPlayerCatalog.test.ts
+++ b/src/game/romPlayerCatalog.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import generated from "./romPlayerCatalog.generated.json";
+import baseStatGrowth from "./romBaseStatGrowth.generated.json";
import audit from "../../scripts/runewaker-pipeline/snapshots/runewaker-player-skill-audit.json";
import {
abilitiesForCharacter,
@@ -19,6 +20,10 @@ const RACE_MATRIX = {
const ELITE_LEVELS = [15, 20, 25, 30, 35, 40, 45, 50, 60, 70];
describe("generated RuneWaker player catalog", () => {
+ it("keeps the small stat-growth pack aligned with the source catalog", () => {
+ expect(baseStatGrowth).toEqual(generated.baseStatGrowth);
+ });
+
it("imports the exact released roster and race eligibility without Duelist", () => {
expect(generated.canonicalClasses.map((entry) => entry.name)).toEqual([
"Warrior", "Scout", "Rogue", "Mage", "Priest", "Knight", "Warden", "Druid", "Warlock", "Champion",
diff --git a/src/game/romStats.ts b/src/game/romStats.ts
index 72776d97..c6cdbd66 100644
--- a/src/game/romStats.ts
+++ b/src/game/romStats.ts
@@ -1,11 +1,11 @@
import { isRomClassId, type ClassId, type RomClassId } from "../app/characterCatalog";
-import catalog from "./romPlayerCatalog.generated.json";
+import growth from "./romBaseStatGrowth.generated.json";
type SourceStatName = "strength" | "stamina" | "intellect" | "wisdom" | "agility" | "health" | "mana";
interface GrowthRow { readonly levelOne: number; readonly flatGrowth: number; readonly percentGrowth: number }
type GrowthTable = Readonly>>>;
-const GROWTH = catalog.baseStatGrowth as unknown as GrowthTable;
+const GROWTH = growth as unknown as GrowthTable;
function sourceValue(row: GrowthRow, level: number): number {
const safeLevel = Math.max(1, Math.min(80, Math.trunc(level)));
diff --git a/src/game/runewakerDungeonAnimation.test.ts b/src/game/runewakerDungeonAnimation.test.ts
index d38aed02..6be556ed 100644
--- a/src/game/runewakerDungeonAnimation.test.ts
+++ b/src/game/runewakerDungeonAnimation.test.ts
@@ -90,7 +90,7 @@ async function readGlbAnimationNames(url: string): Promise {
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(33);
+ expect(dungeons).toHaveLength(38);
for (const dungeon of dungeons) {
if (dungeon.staticSpawns.length === 0) {
diff --git a/src/game/runewakerDungeonRegistry.test.ts b/src/game/runewakerDungeonRegistry.test.ts
index 1012d595..5bcdbf5d 100644
--- a/src/game/runewakerDungeonRegistry.test.ts
+++ b/src/game/runewakerDungeonRegistry.test.ts
@@ -11,9 +11,9 @@ 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(33);
+ expect(runewaker).toHaveLength(38);
expect(runewaker.filter((definition) => !isBiteSizedDungeonId(definition.id))).toHaveLength(30);
- expect(new Set(runewaker.map((definition) => definition.id)).size).toBe(33);
+ expect(new Set(runewaker.map((definition) => definition.id)).size).toBe(38);
expect(runewaker.some((definition) => definition.id === "forsaken-abbey")).toBe(true);
});
diff --git a/src/game/talentRuntimeCatalog.ts b/src/game/talentRuntimeCatalog.ts
index 126fe312..a29512b6 100644
--- a/src/game/talentRuntimeCatalog.ts
+++ b/src/game/talentRuntimeCatalog.ts
@@ -4,7 +4,6 @@ import type {
TalentAllocationFailure,
TalentAllocationResult,
} from "./talentCatalog";
-import generated from "./generated/talentRuntimeCatalog.json";
export type RuntimeTalentRanks = Readonly>;
@@ -42,15 +41,41 @@ interface RuntimeTalentSnapshot {
readonly nodes: readonly RuntimeTalentNode[];
}
-const snapshot = generated as unknown as RuntimeTalentSnapshot;
-export const RUNTIME_TALENT_TREES: readonly RuntimeTalentTree[] = snapshot.trees;
-export const RUNTIME_TALENT_NODES: readonly RuntimeTalentNode[] = snapshot.nodes;
-const treeById = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [tree.id, tree]))) as Readonly>;
-const nodeById = Object.freeze(Object.fromEntries(snapshot.nodes.map((node) => [node.id, node]))) as Readonly>;
-const nodesByTree = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [
- tree.id,
- snapshot.nodes.filter((node) => node.treeId === tree.id).sort((left, right) => left.row - right.row || left.index - right.index),
-]))) as Readonly>;
+let snapshot: RuntimeTalentSnapshot = {
+ schemaVersion: 1,
+ maximumCoaLevel: 80,
+ coaBudgetsByClass: {} as RuntimeTalentSnapshot["coaBudgetsByClass"],
+ trees: [],
+ nodes: [],
+};
+export let RUNTIME_TALENT_TREES: readonly RuntimeTalentTree[] = snapshot.trees;
+export let RUNTIME_TALENT_NODES: readonly RuntimeTalentNode[] = snapshot.nodes;
+let treeById: Readonly> = {};
+let nodeById: Readonly> = {};
+let nodesByTree: Readonly> = {};
+let catalogLoad: Promise | null = null;
+
+/** The authored WoW/CoA talent snapshot is unnecessary for a RoM-only session. */
+export function preloadRuntimeTalentCatalog(): Promise {
+ if (catalogLoad) return catalogLoad;
+ catalogLoad = import("./generated/talentRuntimeCatalog.json").then(({ default: generated }) => {
+ const loaded = generated as unknown as RuntimeTalentSnapshot;
+ if (loaded.schemaVersion !== 1) throw new Error("Unsupported runtime talent catalog.");
+ snapshot = loaded;
+ RUNTIME_TALENT_TREES = snapshot.trees;
+ RUNTIME_TALENT_NODES = snapshot.nodes;
+ treeById = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [tree.id, tree])));
+ nodeById = Object.freeze(Object.fromEntries(snapshot.nodes.map((node) => [node.id, node])));
+ nodesByTree = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [
+ tree.id,
+ snapshot.nodes.filter((node) => node.treeId === tree.id).sort((left, right) => left.row - right.row || left.index - right.index),
+ ])));
+ }).catch((error: unknown) => {
+ catalogLoad = null;
+ throw error;
+ });
+ return catalogLoad;
+}
function isCoaClassId(classId: ClassId): classId is CoaClassId {
return CLASSES.find((definition) => definition.id === classId)?.mode === "conquest";
@@ -104,6 +129,7 @@ export function runtimeTalentPointsForLevel(level: number): number {
function coaBudget(classId: CoaClassId, level: number) {
const definition = snapshot.coaBudgetsByClass[classId];
+ if (!definition) throw new Error(`Talent catalog for ${classId} was used before it was loaded.`);
const normalizedLevel = Math.min(snapshot.maximumCoaLevel, Math.max(1, clampPlayerLevel(level)));
const grantedPoints = Math.max(0, normalizedLevel - 9);
return {
@@ -285,3 +311,7 @@ export function runtimeResetTalentRanks(ranks: RuntimeTalentRanks = {}, classId?
return !node || treeById[node.treeId]?.classId !== classId;
}));
}
+
+if (import.meta.env.MODE === "test" || import.meta.env.MODE === "shelved") {
+ await preloadRuntimeTalentCatalog();
+}
diff --git a/src/game/wow335AbilityCatalog.test.ts b/src/game/wow335AbilityCatalog.test.ts
index 97372a41..7b4743e9 100644
--- a/src/game/wow335AbilityCatalog.test.ts
+++ b/src/game/wow335AbilityCatalog.test.ts
@@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest";
import { abilityAtLevel, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
import { aiHintsForAbility, targetingRulesForAbility } from "./abilityRules";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
+import generatedData from "./wow335AbilityData.generated.json";
+import { talentNodeById } from "./talentCatalog";
+import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
function byId(id: string): AbilityDefinition {
const ability = Object.values(WOW335_ABILITIES_BY_CLASS)
@@ -21,6 +24,14 @@ function effectsOfKind(
}
describe("WoW 3.3.5 executable effect mapping", () => {
+ it("uses the same legal talent-node set as the full catalog", () => {
+ const talentNodeIds = new Set(Object.values(generatedData.classes)
+ .flatMap((chains) => chains.flatMap((chain) => chain.talentNodeId ? [chain.talentNodeId] : [])));
+ for (const nodeId of talentNodeIds) {
+ expect(Boolean(runtimeTalentNodeById(nodeId)), nodeId).toBe(Boolean(talentNodeById(nodeId)));
+ }
+ });
+
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);
@@ -213,19 +224,37 @@ describe("WoW 3.3.5 executable effect mapping", () => {
it("models Penance as three healing or damaging pulses", () => {
const penance = byId("wow335-priest-penance");
+ expect(penance).toMatchObject({ castMode: "channel", castTimeMs: 2_000, channelTickMs: 1_000,
+ channelStartsWithPulse: true });
expect(penance.target).toBe("any");
expect(effectsOfKind(penance, "heal")).toEqual([
expect.objectContaining({ coefficient: 0.6 }),
]);
- expect(effectsOfKind(penance, "hot")).toEqual([
- expect.objectContaining({ coefficient: 0.6, ticks: 2, intervalMs: 1_000 }),
- ]);
+ expect(effectsOfKind(penance, "hot")).toEqual([]);
expect(effectsOfKind(penance, "damage")).toEqual([
expect.objectContaining({ coefficient: 0.6 }),
]);
- expect(effectsOfKind(penance, "dot")).toEqual([
- expect.objectContaining({ coefficient: 0.6, ticks: 2, intervalMs: 1_000 }),
- ]);
+ expect(effectsOfKind(penance, "dot")).toEqual([]);
+ });
+
+ it("keeps ground-channel damage children out of learned cast ranks", () => {
+ for (const id of ["wow335-mage-blizzard", "wow335-warlock-rain-of-fire", "wow335-druid-hurricane"]) {
+ const ability = byId(id);
+ expect(ability.ranks?.every((rank) => rank.castMode === "channel"), id).toBe(true);
+ expect(abilityAtLevel(ability, 80).castMode, id).toBe("channel");
+ }
+ const hurricane = abilityAtLevel(byId("wow335-druid-hurricane"), 80);
+ expect(hurricane).toMatchObject({ dbcSpellId: 48467, castTimeMs: 10_000,
+ channelTickMs: 1_000, channelPulses: 10 });
+ expect(hurricane.cost.amount).toBeGreaterThan(0);
+ });
+
+ it("keeps every rank of imported channels in channel mode", () => {
+ const mismatches = Object.values(WOW335_ABILITIES_BY_CLASS).flat()
+ .filter((ability) => ability.castMode === "channel" && ability.ranks?.some((rank) => rank.castMode !== "channel"))
+ .map((ability) => `${ability.name}: ${ability.ranks?.filter((rank) => rank.castMode !== "channel")
+ .map((rank) => rank.spellId).join(", ")}`);
+ expect(mismatches).toEqual([]);
});
it("maps the Ready-five missing spell payloads without cast-time damage on self buffs", () => {
diff --git a/src/game/wow335AbilityCatalog.ts b/src/game/wow335AbilityCatalog.ts
index 643ba488..a63fc1c7 100644
--- a/src/game/wow335AbilityCatalog.ts
+++ b/src/game/wow335AbilityCatalog.ts
@@ -25,7 +25,7 @@ import {
ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG,
} from "./roguePoison";
-import { talentNodeById } from "./talentCatalog";
+import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
import { baseResourcesFor } from "./wotlkStats";
export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10;
@@ -930,9 +930,7 @@ function classScriptEffects(
case "priest:Mind Flay": return [{ kind: "damage", coefficient: 0.9 }];
case "priest:Penance": return [
{ kind: "heal", coefficient: 0.6 },
- { kind: "hot", coefficient: 0.6, ticks: 2, intervalMs: 1_000 },
{ kind: "damage", coefficient: 0.6 },
- { kind: "dot", coefficient: 0.6, ticks: 2, intervalMs: 1_000, tag: "penance" },
];
case "priest:Divine Hymn": return [{
kind: "heal", coefficient: 1, scope: "party", center: "caster", radius: 40, maximumTargets: 3,
@@ -1478,7 +1476,16 @@ function rankDefinition(
abilityName: string,
): AbilityRankDefinition {
const effects = effectsForRank(classId, rank, abilityName);
- const channeling = rank.channelDurationMs > 0;
+ // Penance's learnable spell is a dummy wrapper; its triggered heal/damage
+ // children own the channel in the client DBC.
+ const penance = classId === "priest" && abilityName === "Penance";
+ const channeling = rank.channelDurationMs > 0 || penance;
+ const channelDurationMs = penance ? 2_000 : rank.channelDurationMs;
+ const channelTickMs = penance ? 1_000 : rank.effects
+ .map((effect) => effect.periodMs)
+ .filter((period) => period > 0)
+ .sort((left, right) => left - right)[0];
+ const castTimeMs = channeling ? Math.max(rank.castTimeMs, channelDurationMs) : rank.castTimeMs;
const rankResource = resourceForRank(resource, rank);
return {
rank: rank.rank || index + 1,
@@ -1486,7 +1493,12 @@ function rankDefinition(
level: gameplaySourceLevel(classId, rank.level),
description: rank.description,
castMode: channeling ? "channel" : rank.castTimeMs > 0 ? "cast" : "instant",
- castTimeMs: channeling ? Math.max(rank.castTimeMs, rank.channelDurationMs) : rank.castTimeMs,
+ castTimeMs,
+ ...(channeling && channelTickMs ? { channelTickMs } : {}),
+ ...(channeling && channelTickMs
+ ? { channelPulses: Math.max(1, Math.round(castTimeMs / channelTickMs) + (penance ? 1 : 0)) }
+ : {}),
+ ...(penance ? { channelStartsWithPulse: true } : {}),
cooldownMs: rank.cooldownMs,
gcdMs: rank.gcdMs,
cost: {
@@ -1517,17 +1529,41 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
const sourceRanks = chain.ranks.some((rank) => rank.rank > 0)
? chain.ranks.filter((rank) => rank.rank > 0)
: chain.ranks;
+ const channelRanks = sourceRanks[0]?.channelDurationMs
+ ? sourceRanks.filter((rank) => rank.channelDurationMs > 0)
+ : sourceRanks;
+ // The imported progression includes Hurricane's rank-five damage child
+ // (48466), but omits its player-cast wrapper (48467). Preserve the previous
+ // wrapper's channel/cost fields and point its periodic trigger at that child.
+ const hurricaneRankFive = classId === "druid" && chain.name === "Hurricane"
+ ? sourceRanks.find((rank) => rank.rank === 5 && rank.spellId === 48466)
+ : undefined;
+ const previousHurricaneRank = channelRanks.at(-1);
+ const playerRanks = hurricaneRankFive && previousHurricaneRank?.rank === 4
+ ? [...channelRanks, {
+ ...previousHurricaneRank,
+ spellId: 48467,
+ rank: 5,
+ level: hurricaneRankFive.level,
+ sourceLevel: hurricaneRankFive.sourceLevel,
+ maxScalingLevel: hurricaneRankFive.maxScalingLevel,
+ description: hurricaneRankFive.description,
+ effects: previousHurricaneRank.effects.map((effect) => effect.auraType === 23
+ ? { ...effect, triggerSpellId: hurricaneRankFive.spellId }
+ : effect),
+ }]
+ : channelRanks;
// Holy Nova's DBC chain stores damage and healing as separate same-rank
// spells. The damage spell is the cast (and pays mana); its healing partner
// is a second payload of that same cast, not another selectable rank.
const castRanks = classId === "priest" && chain.name === "Holy Nova"
- ? sourceRanks.filter((rank) => rank.effects.some((effect) => effect.effectType === 2))
+ ? playerRanks.filter((rank) => rank.effects.some((effect) => effect.effectType === 2))
.map((rank) => {
const healing = sourceRanks.find((candidate) => candidate.rank === rank.rank
&& candidate.effects.some((effect) => effect.effectType === 10));
return healing ? { ...rank, effects: [...rank.effects, ...healing.effects] } : rank;
})
- : sourceRanks;
+ : playerRanks;
const ranks = castRanks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name));
const first = ranks[0];
const firstRaw = castRanks[0];
@@ -1558,6 +1594,9 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
...(radius > 0 ? { radius } : {}),
castMode: first.castMode,
castTimeMs: first.castTimeMs,
+ channelTickMs: first.channelTickMs,
+ channelPulses: first.channelPulses,
+ channelStartsWithPulse: first.channelStartsWithPulse,
cooldownMs: first.cooldownMs,
...(classicCooldownGroup(classId, chain.name)
? { cooldownGroup: classicCooldownGroup(classId, chain.name) }
@@ -1572,7 +1611,7 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
// Lash). They cannot be allocated by any legal local build, so expose
// those retained class actions at their source level instead of making
// them permanently unlearnable behind a dangling talent ID.
- talentNodeId: chain.talentNodeId && talentNodeById(chain.talentNodeId)
+ talentNodeId: chain.talentNodeId && runtimeTalentNodeById(chain.talentNodeId)
? chain.talentNodeId
: undefined,
ranks,
@@ -1612,3 +1651,42 @@ export const WOW335_ABILITIES_BY_CLASS: Readonly Boolean(ability))
.sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name)),
]))) as unknown as Readonly>;
+
+// These ground channels store their actual damage in same-name triggered
+// child spells. Resolve the child once so each channel pulse can use its
+// rank-specific DBC amount rather than creating a delayed area effect.
+const GROUND_CHANNEL_CHILD_NAMES = new Set(["Blizzard", "Rain of Fire", "Hurricane"]);
+export const WOW335_CHANNEL_TRIGGERED_ABILITIES_BY_SPELL_ID: Readonly> =
+ Object.freeze(Object.fromEntries(BASE_CLASS_IDS.flatMap((classId) => data.classes[classId]
+ .filter((chain) => GROUND_CHANNEL_CHILD_NAMES.has(chain.name))
+ .flatMap((chain) => {
+ const ranksById = new Map(chain.ranks.map((rank) => [rank.spellId, rank]));
+ const childIds = new Set(chain.ranks.filter((rank) => rank.channelDurationMs === 0
+ && rank.effects.some((effect) => effect.effectType === 2))
+ .map((rank) => rank.spellId));
+ return [...childIds].flatMap((spellId): Array<[number, AbilityDefinition]> => {
+ const child = ranksById.get(spellId);
+ if (!child) return [];
+ const effects = effectsForRank(classId, child, chain.name)
+ .filter((effect) => effect.kind === "damage" || effect.kind === "heal");
+ if (!effects.length) return [];
+ return [[spellId, {
+ id: `wow335-trigger-${spellId}`,
+ classId,
+ dbcSpellId: spellId,
+ name: `${chain.name} pulse`,
+ unlockLevel: 1,
+ icon: chain.iconPath ?? `/assets/ui/spells/${chain.iconBasename}.png`,
+ sigil: sigil(chain.name),
+ description: child.description,
+ target: "hostile",
+ range: { min: 0, max: 40 },
+ castMode: "instant",
+ castTimeMs: 0,
+ cooldownMs: 0,
+ gcdMs: 0,
+ cost: { resource: RESOURCE_BY_CLASS[classId], amount: 0 },
+ effects,
+ }]];
+ });
+ }))));
diff --git a/src/styles.css b/src/styles.css
index f20299cf..1b371add 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -990,6 +990,12 @@ kbd { color: var(--moss-bright); font: inherit; font-size: 0.64rem; }
.player-status-tooltip p { margin: 5px 0 7px; color: #c3cebb; font-size: 8px; line-height: 1.42; }
.player-status-tooltip footer { display: flex; flex-wrap: wrap; gap: 3px 7px; padding-top: 5px; border-top: 1px solid rgba(200,220,177,.12); color: #dfcb83; font-size: 7px; text-transform: capitalize; }
.encounter-timeline { width: 100%; min-width: 0; overflow: hidden; border: 1px solid rgba(225,185,91,.38); border-radius: 6px 2px 9px 2px; background: linear-gradient(115deg, rgba(24,17,8,.94), rgba(8,18,13,.94)); box-shadow: 0 7px 22px rgba(0,0,0,.42), inset 0 0 18px rgba(233,187,75,.04); color: #edf2e6; pointer-events: auto; backdrop-filter: blur(5px); }
+.encounter-timeline__boss-health { padding: 6px; border-bottom: 1px solid rgba(225,185,91,.18); background: rgba(95,31,23,.2); }
+.encounter-timeline__boss-health > div:first-child { display: flex; justify-content: space-between; gap: 4px; color: #efad98; font-size: 7px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; }
+.encounter-timeline__boss-health > div:first-child strong { color: #ffe1d1; font-variant-numeric: tabular-nums; }
+.encounter-timeline__health-track { height: 6px; margin-top: 4px; overflow: hidden; border-radius: 3px; background: rgba(0,0,0,.65); }
+.encounter-timeline__health-track span { display: block; height: 100%; background: linear-gradient(90deg, #8f352e, #df7254); transition: width 160ms ease; }
+.encounter-timeline__boss-health small { display: block; margin-top: 2px; color: #ddc1b5; font-size: 6px; font-variant-numeric: tabular-nums; text-align: right; }
.encounter-timeline > header { display: grid; padding: 5px 6px 4px; border-bottom: 1px solid rgba(225,185,91,.18); }
.encounter-timeline > header span { color: #d8ba69; font-size: 6px; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
.encounter-timeline > header strong { overflow: hidden; margin-top: 1px; font: 600 9px Georgia, serif; text-overflow: ellipsis; white-space: nowrap; }
diff --git a/src/ui/DungeonRaceHud.tsx b/src/ui/DungeonRaceHud.tsx
index d258b4ef..a02910ce 100644
--- a/src/ui/DungeonRaceHud.tsx
+++ b/src/ui/DungeonRaceHud.tsx
@@ -4,7 +4,7 @@ import { formatDungeonDuration } from "../game/dungeonRunTypes";
import { useGameStore } from "../game/store";
import "./dungeonRace.css";
-export function DungeonRaceHud() {
+export function DungeonRaceHud({ region }: { readonly region: "status" | "overlays" }) {
const race = useDungeonRaceStore();
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const [now, setNow] = useState(() => Date.now());
@@ -18,13 +18,15 @@ export function DungeonRaceHud() {
const elapsed = race.startAt ? Math.max(0, serverNow - race.startAt) : 0;
const shownTime = race.clearDurationMs ?? elapsed;
const toast = race.result && race.toastAt && now - race.toastAt < 4_000;
- return <>
+ if (region === "status") return (
Dungeon Race · {race.mode === "practice" ? "Practice" : "Ranked"}
{formatDungeonDuration(shownTime)}
vs {race.opponent.name}{race.opponent.type === "bot" ? ` · ${race.opponent.tier}` : ""}
{race.opponentProgress !== null ? ` · ${race.opponentProgress}%` : ""}
+ );
+ return <>
{race.status === "preparing" && Waiting for the rival party to be ready…
}
{remaining !== null && remaining > 0 && race.status === "countdown" &&
Race starts in {remaining}
}
diff --git a/src/ui/EncounterTimeline.tsx b/src/ui/EncounterTimeline.tsx
index 78414d8b..5b919d99 100644
--- a/src/ui/EncounterTimeline.tsx
+++ b/src/ui/EncounterTimeline.tsx
@@ -13,8 +13,15 @@ export function EncounterTimeline({ now }: { readonly now: number }) {
const mobs = useCombatStore((state) => state.mobs);
const model = useMemo(() => encounterTimeline(mobs, now), [mobs, now]);
if (!model) return null;
+ const boss = mobs[model.bossId];
+ const healthPercent = Math.max(0, Math.min(100, boss.health / Math.max(1, boss.maxHealth) * 100));
return (
+
+
Boss health{Math.ceil(healthPercent)}%
+
+
{Math.ceil(boss.health)} / {boss.maxHealth}
+
Mechanics{model.bossName}
{model.rows.map((row) => (
diff --git a/src/ui/Hud.tsx b/src/ui/Hud.tsx
index 5a207007..090e37f4 100644
--- a/src/ui/Hud.tsx
+++ b/src/ui/Hud.tsx
@@ -30,6 +30,7 @@ import { ExperienceBar } from "./ExperienceBar";
import { Minimap } from "./Minimap";
import { PlayerStatusPanel } from "./PlayerStatusPanel";
import { EncounterTimeline } from "./EncounterTimeline";
+import { DungeonRaceHud } from "./DungeonRaceHud";
import { PartyFrames } from "./PartyFrames";
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
import type { ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
@@ -441,6 +442,7 @@ export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onR
) : null}
+
{
- it("loads talent resources behind the main-menu catalog gate, not during gameplay", () => {
+ it("loads talent resources at gameplay entry and on demand in the menu editor", () => {
expect(appSource).toContain("preloadTalentGameplayResources(talentClassId)");
+ expect(appSource).toContain('preloadGameplay={phase === "game"}');
expect(talentResourcesSource).toContain("loadTalentUiCatalog(classId)");
expect(talentResourcesSource).toContain("loadTalentGameplayPanel()");
expect(talentResourcesSource).toContain("image.decode()");
@@ -44,7 +45,7 @@ describe("gameplay panel loading integration", () => {
expect(gameplayPanelsSource).not.toContain("");
});
- it("warms the shared runtime and first-open panels before showing the main menu", () => {
+ it("warms the shared runtime and first-open panels before gameplay mounts", () => {
expect(appSource).toContain("preloadSharedGameplayResources(originalMediaEnabled)");
expect(appSource).toContain("getLoadedGameRuntime()");
expect(gameplayResourcesSource).toContain('import("../GameRuntime")');