From ab1434a3b8f1aa7c9ecde7ecbd39a0fa7ee70251 Mon Sep 17 00:00:00 2001 From: phenom Date: Sun, 27 Sep 2026 10:16:42 -0400 Subject: [PATCH] Release Healer Man 0.1.59 --- PLAYTEST_NOTES.md | 54 +++ package-lock.json | 4 +- package.json | 2 +- release-version.json | 4 +- scripts/playtest-bite-sized-spawns.cjs | 7 +- .../export-player-catalog.mjs | 2 + server/dungeon-leaderboards.test.mjs | 18 +- server/dungeon-race.test.mjs | 4 +- src/App.tsx | 16 +- src/GameRuntime.tsx | 2 +- src/app/memoryReset.test.ts | 35 ++ src/app/memoryReset.ts | 63 +++ src/app/shellStore.ts | 40 +- src/game/abilityCatalog.ts | 24 +- src/game/coaAbilityCatalog.ts | 16 + src/game/combatChannelAmounts.test.ts | 42 +- src/game/combatChannelAmounts.ts | 18 +- src/game/combatStore.test.ts | 100 ++++- src/game/combatStore.ts | 32 +- src/game/dungeonArtworkMap.test.ts | 2 +- src/game/dungeonCatalog.test.ts | 17 +- src/game/dungeonContentSource.ts | 22 +- src/game/dungeonLeaderboardCatalog.test.ts | 2 +- src/game/dungeonRegistry.ts | 38 +- .../dungeons/biteSizedRoMDungeons.test.ts | 10 +- src/game/dungeons/biteSizedRoMDungeons.ts | 119 +++++- .../dungeonLeaderboardCatalog.generated.json | 361 +++++++++++++++++- src/game/partyDamageAudit.test.ts | 4 + src/game/partyRuntime.test.ts | 42 ++ src/game/partyRuntime.ts | 14 +- src/game/romBaseStatGrowth.generated.json | 1 + src/game/romPlayerCatalog.test.ts | 5 + src/game/romStats.ts | 4 +- src/game/runewakerDungeonAnimation.test.ts | 2 +- src/game/runewakerDungeonRegistry.test.ts | 4 +- src/game/talentRuntimeCatalog.ts | 50 ++- src/game/wow335AbilityCatalog.test.ts | 41 +- src/game/wow335AbilityCatalog.ts | 94 ++++- src/styles.css | 6 + src/ui/DungeonRaceHud.tsx | 6 +- src/ui/EncounterTimeline.tsx | 7 + src/ui/Hud.tsx | 2 + src/ui/dungeonRace.css | 4 +- src/ui/gameplayPanelsIntegration.test.ts | 5 +- 44 files changed, 1242 insertions(+), 103 deletions(-) create mode 100644 src/app/memoryReset.test.ts create mode 100644 src/app/memoryReset.ts create mode 100644 src/game/romBaseStatGrowth.generated.json 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")');