Release Healer Man 0.1.59

This commit is contained in:
phenom committed 2026-09-27 10:16:42 -04:00
1 parent 11934a3979
commit ab1434a3b8
44 files changed
+1242 -103

No files matched your search

+54
View File
@@ -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 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 client; the source guard points and boss position have collision and
navigation surfaces. 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/`.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "healer-man", "name": "healer-man",
"version": "0.1.58", "version": "0.1.59",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "healer-man", "name": "healer-man",
"version": "0.1.58", "version": "0.1.59",
"dependencies": { "dependencies": {
"@capacitor/android": "8.4.1", "@capacitor/android": "8.4.1",
"@capacitor/core": "8.4.1", "@capacitor/core": "8.4.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "healer-man", "name": "healer-man",
"private": true, "private": true,
"version": "0.1.58", "version": "0.1.59",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"versionName": "0.1.58", "versionName": "0.1.59",
"versionCode": 1058 "versionCode": 1059
} }
+5 -2
View File
@@ -3,12 +3,15 @@ const assert = require('node:assert/strict');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); 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 }); fs.mkdirSync(out, { recursive: true });
const locations = [ const defaultLocations = [
{ id: 'bite-sized-wailing-caverns', name: 'wailing' }, { id: 'bite-sized-wailing-caverns', name: 'wailing' },
{ id: 'bite-sized-utgarde-pinnacle-skadi', name: 'utgarde' }, { 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) { async function gameState(page) {
return page.evaluate(async () => { return page.evaluate(async () => {
@@ -25,6 +25,7 @@ const files = {
objectStruct: path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "ObjectStruct.h"), objectStruct: path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "ObjectStruct.h"),
}; };
const outputFile = path.join(projectRoot, "src", "game", "romPlayerCatalog.generated.json"); 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 auditFile = path.join(scriptDirectory, "snapshots", "runewaker-player-skill-audit.json");
const interfaceFdb = path.join(resourceRoot, "fdb", "interface.fdb"); const interfaceFdb = path.join(resourceRoot, "fdb", "interface.fdb");
const iconOutputDirectory = path.join(projectRoot, "public", "assets", "ui", "rom-spells"); 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 mkdir(path.dirname(auditFile), { recursive: true });
await Promise.all([ await Promise.all([
writeFile(outputFile, `${JSON.stringify(catalog)}\n`), writeFile(outputFile, `${JSON.stringify(catalog)}\n`),
writeFile(growthOutputFile, `${JSON.stringify(catalog.baseStatGrowth)}\n`),
writeFile(auditFile, `${JSON.stringify(audit, null, 2)}\n`), writeFile(auditFile, `${JSON.stringify(audit, null, 2)}\n`),
]); ]);
console.log(`Generated ${ordinarySkills.length} ordinary families and ${eliteSkills.length} ordered-pair elite skills.`); console.log(`Generated ${ordinarySkills.length} ordinary families and ${eliteSkills.length} ordered-pair elite skills.`);
+17 -1
View File
@@ -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", for (const dungeonId of ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available", "randomized-bite-sized-stormwind-stockade-available",
"bite-sized-wailing-caverns", "bite-sized-deadmines-ship", "bite-sized-utgarde-pinnacle-skadi", "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 dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
const difficulty = dungeon.difficulties[0]; const difficulty = dungeon.difficulties[0];
assert.equal(difficulty.finalBossIds.length, dungeon.bosses.length); 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; const bosses = catalog.dungeons.find((entry) => entry.id === id).difficulties[0].finalBossIds;
assert.equal(database.listDungeonLeaderboard({ dungeonId: id, bossId: bosses[1] }).total, 1); 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 () => { 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 directory = mkdtempSync(path.join(os.tmpdir(), "healer-man-leaderboard-http-"));
const { server } = createGameServer({ dataDir: directory, enableShelvedModes: false }); const { server } = createGameServer({ dataDir: directory, enableShelvedModes: false });
+3 -1
View File
@@ -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", const dungeonIds = ["bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade",
"randomized-bite-sized-stormwind-stockade-available", "randomized-bite-sized-stormwind-stockade-available",
"bite-sized-wailing-caverns", "bite-sized-deadmines-ship", "bite-sized-utgarde-pinnacle-skadi", "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, for (const dungeonId of dungeonIds) insert.run(`race-old-${dungeonId}`, dungeonId,
`race-old-${dungeonId}`, dungeon.defaultDifficultyId); `race-old-${dungeonId}`, dungeon.defaultDifficultyId);
createDungeonRaceRepository(database, { ErrorType: Error }); createDungeonRaceRepository(database, { ErrorType: Error });
+10 -6
View File
@@ -28,7 +28,7 @@ function ShellTransition() {
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>; return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
} }
function RuntimeCatalogGate({ children }: { readonly children: ReactNode }) { function RuntimeCatalogGate({ children, preloadGameplay }: { readonly children: ReactNode; readonly preloadGameplay: boolean }) {
const character = useShellStore((state) => ( const character = useShellStore((state) => (
state.characters.find((candidate) => candidate.id === state.selectedCharacterId) ?? null 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-") const talentClassId = character && !character.classId.startsWith("rom-")
? character.classId ? character.classId
: null; : null;
const requestKey = classIds.join(":");
const originalMediaEnabled = useShellStore((state) => state.combatPresentationSettings.originalMediaEnabled); const originalMediaEnabled = useShellStore((state) => state.combatPresentationSettings.originalMediaEnabled);
const requestKey = classIds.length
? `${classIds.join(":")}|${preloadGameplay ? `game:${originalMediaEnabled}` : "shell"}`
: "";
const [loadedKey, setLoadedKey] = useState(""); const [loadedKey, setLoadedKey] = useState("");
const [failedKey, setFailedKey] = useState(""); const [failedKey, setFailedKey] = useState("");
@@ -52,15 +54,15 @@ function RuntimeCatalogGate({ children }: { readonly children: ReactNode }) {
setFailedKey(""); setFailedKey("");
void Promise.all([ void Promise.all([
preloadAbilityCatalogsForClasses(classIds), preloadAbilityCatalogsForClasses(classIds),
talentClassId ? preloadTalentGameplayResources(talentClassId) : Promise.resolve(), preloadGameplay && talentClassId ? preloadTalentGameplayResources(talentClassId) : Promise.resolve(),
preloadSharedGameplayResources(originalMediaEnabled), preloadGameplay ? preloadSharedGameplayResources(originalMediaEnabled) : Promise.resolve(),
]).then(() => { ]).then(() => {
if (active) setLoadedKey(requestKey); if (active) setLoadedKey(requestKey);
}).catch(() => { }).catch(() => {
if (active) setFailedKey(requestKey); if (active) setFailedKey(requestKey);
}); });
return () => { active = false; }; return () => { active = false; };
}, [classIds, originalMediaEnabled, requestKey, talentClassId]); }, [classIds, originalMediaEnabled, preloadGameplay, requestKey, talentClassId]);
if (!requestKey || loadedKey === requestKey) return children; if (!requestKey || loadedKey === requestKey) return children;
if (failedKey === requestKey) { if (failedKey === requestKey) {
@@ -105,5 +107,7 @@ export default function App() {
? <PreloadedGameRuntime /> ? <PreloadedGameRuntime />
: <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>; : <Suspense fallback={<ShellTransition />}><GameRuntime /></Suspense>;
const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase); const requiresRuntimeCatalog = !["login", "characters", "create-character"].includes(phase);
return <><OnlineGroupCoordinator />{requiresRuntimeCatalog ? <RuntimeCatalogGate>{screen}</RuntimeCatalogGate> : screen}</>; return <><OnlineGroupCoordinator />{requiresRuntimeCatalog
? <RuntimeCatalogGate preloadGameplay={phase === "game"}>{screen}</RuntimeCatalogGate>
: screen}</>;
} }
+1 -1
View File
@@ -146,7 +146,7 @@ export default function GameRuntime() {
<ShelvedGameBridges /> <ShelvedGameBridges />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary> <SceneErrorBoundary><GameScene /></SceneErrorBoundary>
{dungeonSimulation ? <DungeonSimulationHud /> : <Hud />} {dungeonSimulation ? <DungeonSimulationHud /> : <Hud />}
{!dungeonSimulation && <DungeonRaceHud />} {!dungeonSimulation && <DungeonRaceHud region="overlays" />}
{!dungeonSimulation && <DungeonBossIntro />} {!dungeonSimulation && <DungeonBossIntro />}
{!dungeonSimulation && <MapPanel />} {!dungeonSimulation && <MapPanel />}
{!dungeonSimulation && <GameplayPanels />} {!dungeonSimulation && <GameplayPanels />}
+35
View File
@@ -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();
});
});
+63
View File
@@ -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<ShellPhase, "characters" | "main-menu" | "class-hall">;
}
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<MemoryResetHandoff>;
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;
}
}
+34 -6
View File
@@ -25,6 +25,7 @@ import {
clampAppearance, clampAppearance,
classById, classById,
classesForRace, classesForRace,
contentCategoryForClass,
createDefaultAppearance, createDefaultAppearance,
normalizeCharacterName, normalizeCharacterName,
randomAppearance, randomAppearance,
@@ -53,6 +54,8 @@ import type { ManastormPartySize, ManastormProgress } from "../game/manastormPro
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint"; import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
import { dungeonShellRuntime, type DungeonEntrySettings } from "./dungeonShellRuntimeBridge"; import { dungeonShellRuntime, type DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { manastormShellRuntime } from "./manastormShellRuntimeBridge"; import { manastormShellRuntime } from "./manastormShellRuntimeBridge";
import { consumeMemoryReset, requestMemoryReset } from "./memoryReset";
import { useOnlineGroupStore } from "./onlineGroupStore";
import { import {
activateOnlineSession, activateOnlineSession,
hydrateOnlineSession, hydrateOnlineSession,
@@ -152,17 +155,27 @@ function restoredState(): Pick<ShellState, "phase" | "session" | "characters" |
combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS, combatPresentationSettings: DEFAULT_COMBAT_PRESENTATION_SETTINGS,
}; };
const characters = listCharacters(session.ownerId); const characters = listCharacters(session.ownerId);
const handoff = consumeMemoryReset(session.ownerId, characters.map((character) => character.id));
return { return {
phase: "characters", phase: handoff?.phase ?? "characters",
session, session,
characters, characters,
bossCards: loadAccountBossCards(session.ownerId), bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters[0]?.id ?? null, selectedCharacterId: handoff?.characterId ?? characters[0]?.id ?? null,
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId), combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
}; };
} }
const restored = restoredState(); 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<ShellState>((set, get) => ({ export const useShellStore = create<ShellState>((set, get) => ({
...restored, ...restored,
@@ -172,6 +185,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
draft: defaultDraft(), draft: defaultDraft(),
notice: "", notice: "",
beginSession: (session) => { beginSession: (session) => {
enteredCharacterId = null;
activateOnlineSession(session); activateOnlineSession(session);
saveSession(session); saveSession(session);
const characters = listCharacters(session.ownerId); const characters = listCharacters(session.ownerId);
@@ -187,6 +201,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
}); });
}, },
signOut: () => { signOut: () => {
enteredCharacterId = null;
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false }); useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false });
const session = get().session; const session = get().session;
void logoutOnlineSession(session); void logoutOnlineSession(session);
@@ -313,7 +328,13 @@ export const useShellStore = create<ShellState>((set, get) => ({
return false; return false;
} }
const character = characters.find((entry) => entry.id === selectedCharacterId)!; 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." }); set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before continuing." });
return true; return true;
} }
@@ -447,6 +468,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
return true; return true;
}, },
returnToMainMenu: () => { returnToMainMenu: () => {
const wasInGame = get().phase === "game";
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null }); useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame(); if (SHELVED_MODES_ENABLED) manastormShellRuntime()?.leaveActiveGame();
const { session, characters, activeCharacter, selectedCharacterId } = get(); const { session, characters, activeCharacter, selectedCharacterId } = get();
@@ -460,6 +482,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
return; return;
} }
set({ phase: "main-menu", selectedCharacterId: currentId, activeCharacter: null, notice: "" }); 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) => { enterDungeon: (dungeonId, requestedRole, settings) => {
const runtime = dungeonShellRuntime(); const runtime = dungeonShellRuntime();
@@ -486,6 +509,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID), enterWorld: () => get().enterDungeon(DEFAULT_DUNGEON_ID),
returnToCharacters: () => { returnToCharacters: () => {
useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null }); useGameStore.setState({ activeGmReviewTarget: null, gmTestMode: false, bossTest: null });
const wasInGame = get().phase === "game";
const session = get().session; const session = get().session;
if (!session) { if (!session) {
set({ phase: "login", activeCharacter: null }); set({ phase: "login", activeCharacter: null });
@@ -493,17 +517,21 @@ export const useShellStore = create<ShellState>((set, get) => ({
} }
const characters = listCharacters(session.ownerId); const characters = listCharacters(session.ownerId);
const currentId = get().activeCharacter?.id ?? get().selectedCharacterId; const currentId = get().activeCharacter?.id ?? get().selectedCharacterId;
const selectedCharacterId = characters.some((character) => character.id === currentId)
? currentId
: characters[0]?.id ?? null;
set({ set({
phase: "characters", phase: "characters",
characters, characters,
bossCards: loadAccountBossCards(session.ownerId), bossCards: loadAccountBossCards(session.ownerId),
selectedCharacterId: characters.some((character) => character.id === currentId) selectedCharacterId,
? currentId
: characters[0]?.id ?? null,
activeCharacter: null, activeCharacter: null,
notice: "", notice: "",
combatPresentationSettings: loadCombatPresentationSettings(session.ownerId), combatPresentationSettings: loadCombatPresentationSettings(session.ownerId),
}); });
if (wasInGame && selectedCharacterId && canResetMemory()) requestMemoryReset({
ownerId: session.ownerId, characterId: selectedCharacterId, phase: "characters",
});
}, },
saveActiveProgression: (level, experience, talentRanks) => { saveActiveProgression: (level, experience, talentRanks) => {
const { session, activeCharacter } = get(); const { session, activeCharacter } = get();
+23 -1
View File
@@ -11,6 +11,7 @@ import { loadCoaAbilityCatalogShard } from "./coaAbilityCatalogLoader";
import { registerAbilityAnimationLookup } from "./abilityAnimationLookup"; import { registerAbilityAnimationLookup } from "./abilityAnimationLookup";
import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras"; import type { AuraDefinition, DamageSchool, DispelCategory } from "./combatAuras";
import { applyAbilityBalanceTuning, currentBalanceTuningPolicy } from "./balanceTuning"; import { applyAbilityBalanceTuning, currentBalanceTuningPolicy } from "./balanceTuning";
import { preloadRuntimeTalentCatalog } from "./talentRuntimeCatalog";
export type RomSkillGroup = "primary" | "general" | "elite" | "passive"; export type RomSkillGroup = "primary" | "general" | "elite" | "passive";
@@ -268,6 +269,9 @@ export interface AbilityRankDefinition {
readonly description: string; readonly description: string;
readonly castMode: AbilityCastMode; readonly castMode: AbilityCastMode;
readonly castTimeMs: number; readonly castTimeMs: number;
readonly channelTickMs?: number;
readonly channelPulses?: number;
readonly channelStartsWithPulse?: boolean;
readonly cooldownMs: number; readonly cooldownMs: number;
readonly gcdMs: number; readonly gcdMs: number;
readonly cost: AbilityCost; readonly cost: AbilityCost;
@@ -293,6 +297,9 @@ export interface AbilityDefinition {
readonly radius?: number; readonly radius?: number;
readonly castMode: AbilityCastMode; readonly castMode: AbilityCastMode;
readonly castTimeMs: number; readonly castTimeMs: number;
readonly channelTickMs?: number;
readonly channelPulses?: number;
readonly channelStartsWithPulse?: boolean;
readonly cooldownMs: number; readonly cooldownMs: number;
readonly cooldownGroup?: string; readonly cooldownGroup?: string;
readonly gcdMs: number; readonly gcdMs: number;
@@ -900,6 +907,7 @@ const abilityCatalog: Record<string, AbilityDefinition> = {};
let romEliteAbilities: readonly AbilityDefinition[] = []; let romEliteAbilities: readonly AbilityDefinition[] = [];
let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null; let romTriggeredAbilityResolver: ((spellId: number) => AbilityDefinition | null) | null = null;
const coaTriggeredAbilities = new Map<number, AbilityDefinition>(); const coaTriggeredAbilities = new Map<number, AbilityDefinition>();
const classicChannelTriggeredAbilities = new Map<number, AbilityDefinition>();
let classicLoad: Promise<void> | null = null; let classicLoad: Promise<void> | null = null;
let romLoad: Promise<void> | null = null; let romLoad: Promise<void> | null = null;
const coaLoads = new Map<CoaClassId, Promise<void>>(); const coaLoads = new Map<CoaClassId, Promise<void>>();
@@ -940,13 +948,19 @@ for (const classId of BASE_CLASS_IDS) {
async function preloadClassicAbilityCatalog(): Promise<void> { async function preloadClassicAbilityCatalog(): Promise<void> {
if (classicLoad) return classicLoad; 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) { for (const classId of BASE_CLASS_IDS) {
registerClassAbilities( registerClassAbilities(
classId, classId,
mergeWow335ClassicAbilities(CLASSIC_CORE_ABILITIES[classId], WOW335_ABILITIES_BY_CLASS[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) => { }).catch((error: unknown) => {
classicLoad = null; classicLoad = null;
throw error; throw error;
@@ -994,6 +1008,10 @@ async function preloadCoaAbilityCatalog(classId: CoaClassId): Promise<void> {
*/ */
export async function preloadAbilityCatalogsForClasses(classIds: readonly ClassId[]): Promise<void> { export async function preloadAbilityCatalogsForClasses(classIds: readonly ClassId[]): Promise<void> {
const requested = new Set(classIds); 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<void>[] = []; const loads: Promise<void>[] = [];
if (BASE_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadClassicAbilityCatalog()); if (BASE_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadClassicAbilityCatalog());
if (ROM_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadRomAbilityCatalog()); if (ROM_CLASS_IDS.some((classId) => requested.has(classId))) loads.push(preloadRomAbilityCatalog());
@@ -1008,6 +1026,7 @@ export function triggeredAbilityBySpellId(
includeCoa = true, includeCoa = true,
): AbilityDefinition | null { ): AbilityDefinition | null {
return romTriggeredAbilityResolver?.(spellId) return romTriggeredAbilityResolver?.(spellId)
?? classicChannelTriggeredAbilities.get(spellId)
?? (includeCoa ? coaTriggeredAbilities.get(spellId) : undefined) ?? (includeCoa ? coaTriggeredAbilities.get(spellId) : undefined)
?? null; ?? null;
} }
@@ -1104,6 +1123,9 @@ export function abilityAtLevel(
description: rank.description || ability.description, description: rank.description || ability.description,
castMode: rank.castMode, castMode: rank.castMode,
castTimeMs: rank.castTimeMs, castTimeMs: rank.castTimeMs,
channelTickMs: rank.channelTickMs,
channelPulses: rank.channelPulses,
channelStartsWithPulse: rank.channelStartsWithPulse,
cooldownMs: rank.cooldownMs, cooldownMs: rank.cooldownMs,
gcdMs: rank.gcdMs, gcdMs: rank.gcdMs,
cost: rank.cost, cost: rank.cost,
+16
View File
@@ -1234,6 +1234,10 @@ function rankDefinition(
const effects = effectsForSpell(dbc, description, liveSpell?.name ?? dbc?.name ?? `Spell ${spellId}`); const effects = effectsForSpell(dbc, description, liveSpell?.name ?? dbc?.name ?? `Spell ${spellId}`);
const castTimeMs = liveTooltipCastTime(mechanicsTooltip, dbc?.castTimeMs ?? 0); const castTimeMs = liveTooltipCastTime(mechanicsTooltip, dbc?.castTimeMs ?? 0);
const channeling = Boolean(dbc?.channelInterruptFlags && dbc.durationMs > 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); const rawCost = costForSpell(resource, dbc);
return { return {
rank, rank,
@@ -1242,6 +1246,10 @@ function rankDefinition(
description, description,
castMode: channeling ? "channel" : castTimeMs > 0 ? "cast" : "instant", castMode: channeling ? "channel" : castTimeMs > 0 ? "cast" : "instant",
castTimeMs: channeling ? Math.max(castTimeMs, dbc?.durationMs ?? 0) : castTimeMs, 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( cooldownMs: liveTooltipCooldown(
mechanicsTooltip, mechanicsTooltip,
Math.max(dbc?.recoveryTimeMs ?? 0, dbc?.categoryRecoveryTimeMs ?? 0), Math.max(dbc?.recoveryTimeMs ?? 0, dbc?.categoryRecoveryTimeMs ?? 0),
@@ -1292,6 +1300,8 @@ export function ascensionProgressionAbility(
...(radius > 0 ? { radius } : {}), ...(radius > 0 ? { radius } : {}),
castMode: first.castMode, castMode: first.castMode,
castTimeMs: first.castTimeMs, castTimeMs: first.castTimeMs,
channelTickMs: first.channelTickMs,
channelPulses: first.channelPulses,
cooldownMs: first.cooldownMs, cooldownMs: first.cooldownMs,
...(cooldownGroup ? { cooldownGroup } : {}), ...(cooldownGroup ? { cooldownGroup } : {}),
gcdMs: first.gcdMs, gcdMs: first.gcdMs,
@@ -1357,6 +1367,8 @@ function treeAbility(entry: CoaTreeEntry): AbilityDefinition | null {
...(radius > 0 ? { radius } : {}), ...(radius > 0 ? { radius } : {}),
castMode: first.castMode, castMode: first.castMode,
castTimeMs: first.castTimeMs, castTimeMs: first.castTimeMs,
channelTickMs: first.channelTickMs,
channelPulses: first.channelPulses,
cooldownMs: first.cooldownMs, cooldownMs: first.cooldownMs,
...(cooldownGroup ? { cooldownGroup } : {}), ...(cooldownGroup ? { cooldownGroup } : {}),
gcdMs: first.gcdMs, gcdMs: first.gcdMs,
@@ -1415,6 +1427,8 @@ export const COA_ABILITIES_BY_CLASS: Readonly<Record<CoaClassId, readonly Abilit
...(radius > 0 ? { radius } : {}), ...(radius > 0 ? { radius } : {}),
castMode: liveRank.castMode, castMode: liveRank.castMode,
castTimeMs: liveRank.castTimeMs, castTimeMs: liveRank.castTimeMs,
channelTickMs: liveRank.channelTickMs,
channelPulses: liveRank.channelPulses,
cooldownMs: liveRank.cooldownMs, cooldownMs: liveRank.cooldownMs,
...(cooldownGroup ? { cooldownGroup } : {}), ...(cooldownGroup ? { cooldownGroup } : {}),
gcdMs: liveRank.gcdMs, gcdMs: liveRank.gcdMs,
@@ -1437,6 +1451,8 @@ export const COA_ABILITIES_BY_CLASS: Readonly<Record<CoaClassId, readonly Abilit
radius: ranked.radius, radius: ranked.radius,
castMode: ranked.castMode, castMode: ranked.castMode,
castTimeMs: ranked.castTimeMs, castTimeMs: ranked.castTimeMs,
channelTickMs: ranked.channelTickMs,
channelPulses: ranked.channelPulses,
cooldownMs: ranked.cooldownMs, cooldownMs: ranked.cooldownMs,
...(ranked.cooldownGroup ? { cooldownGroup: ranked.cooldownGroup } : {}), ...(ranked.cooldownGroup ? { cooldownGroup: ranked.cooldownGroup } : {}),
gcdMs: ranked.gcdMs, gcdMs: ranked.gcdMs,
+41 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { abilitiesForClass } from "./abilityCatalog"; import { abilitiesForClass } from "./abilityCatalog";
import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts"; import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
describe("channel amount expansion", () => { describe("channel amount expansion", () => {
it("spreads a normal channel's full amount across its pulses", () => { 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 }, { 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([]);
});
}); });
+15 -3
View File
@@ -2,8 +2,18 @@ import { triggeredAbilityBySpellId, type AbilityDefinition, type AbilityEffect }
type ChannelAmountEffect = Extract<AbilityEffect, { kind: "damage" | "dot" | "heal" | "hot" }>; type ChannelAmountEffect = Extract<AbilityEffect, { kind: "damage" | "dot" | "heal" | "hot" }>;
export function channelPulseCount(ability: Pick<AbilityDefinition, "name" | "dbcSpellId">): number { export function channelPulseCount(ability: AbilityDefinition): number {
return ability.name === "Divine Hymn" || ability.dbcSpellId === 491164 ? 4 : 5; 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. */ /** 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 }[] = []; const amounts: { effect: ChannelAmountEffect; multiplier: number }[] = [];
for (const effect of ability.effects) { for (const effect of ability.effects) {
if (effect.kind === "damage" || effect.kind === "heal" || effect.kind === "dot" || effect.kind === "hot") { 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 ? 1
: (effect.kind === "dot" || effect.kind === "hot" ? effect.ticks : 1) / ticks }); : (effect.kind === "dot" || effect.kind === "hot" ? effect.ticks : 1) / ticks });
} else if (effect.kind === "apply-aura") { } else if (effect.kind === "apply-aura") {
+96 -4
View File
@@ -1940,32 +1940,124 @@ describe("combat store", () => {
&& aura.definition.name.startsWith("Rupture"))?.expiresAt).toBe(19_000); && 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 }); useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
unlockReadySpell("wow335-priest-penance"); unlockReadySpell("wow335-priest-penance");
registerTarget("penance-target", [8, 0, 0], 100_000, 0); 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().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; const first = useCombatStore.getState().mobs["penance-target"].health;
expect(first).toBeLessThan(100_000); 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); useCombatStore.getState().tick(0, 2_000);
expect(useCombatStore.getState().mobs["penance-target"].health).toBeLessThan(first); expect(useCombatStore.getState().mobs["penance-target"].health).toBeLessThan(first);
useCombatStore.getState().tick(0, 3_000); 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 }); useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
unlockReadySpell("wow335-priest-penance"); unlockReadySpell("wow335-priest-penance");
usePartyStore.getState().selectSelf(); usePartyStore.getState().selectSelf();
useCombatStore.getState().damagePlayer(2_000); useCombatStore.getState().damagePlayer(2_000);
const before = useCombatStore.getState().health; const before = useCombatStore.getState().health;
expect(useCombatStore.getState().castAbility("wow335-priest-penance", [0, 0, 0], 1_000).ok).toBe(true); 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; const first = useCombatStore.getState().health;
expect(first).toBeGreaterThan(before); expect(first).toBeGreaterThan(before);
useCombatStore.getState().tick(0, 2_000); useCombatStore.getState().tick(0, 2_000);
expect(useCombatStore.getState().health).toBeGreaterThan(first); expect(useCombatStore.getState().health).toBeGreaterThan(first);
const interruptedAt = useCombatStore.getState().health;
expect(useCombatStore.getState().cancelCast()).toBe(true);
useCombatStore.getState().tick(0, 3_000); 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", () => { it("selects only the three lowest-health nearby Divine Hymn targets per two-second pulse", () => {
+19 -13
View File
@@ -141,7 +141,7 @@ import { rollBossLoot } from "./lootCatalog";
import { bossCardForSource } from "./bossCardCatalog"; import { bossCardForSource } from "./bossCardCatalog";
import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards"; import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards";
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula"; import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts"; import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
import { normalizeInventory, type InventoryItem } from "./lootTypes"; import { normalizeInventory, type InventoryItem } from "./lootTypes";
import { import {
PLAYER_EQUIPMENT_OWNER_ID, PLAYER_EQUIPMENT_OWNER_ID,
@@ -5251,7 +5251,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
groundPosition, groundPosition,
totalTicks: ability.castMode === "channel" ? channelPulseCount(ability) : 0, totalTicks: ability.castMode === "channel" ? channelPulseCount(ability) : 0,
ticksCompleted: 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<string, number> = { ...state.cooldowns }; const channelCooldowns: Record<string, number> = { ...state.cooldowns };
if (activeCast.mode === "channel" && cooldownMs > 0) { if (activeCast.mode === "channel" && cooldownMs > 0) {
@@ -5346,7 +5346,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
} }
const castThreatSource = playerThreatSource(state, ability.id); 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 periodic = ability.effects.filter((effect) => effect.kind === "dot" || effect.kind === "hot");
const durationMs = periodic.reduce((maximum, effect) => ( const durationMs = periodic.reduce((maximum, effect) => (
Math.max(maximum, effect.ticks * effect.intervalMs) Math.max(maximum, effect.ticks * effect.intervalMs)
@@ -9803,7 +9803,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: null; : null;
let ticksCompleted = cast.ticksCompleted; let ticksCompleted = cast.ticksCompleted;
let nextTickAt = cast.nextTickAt; 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 target = cast.targetId ? get().mobs[cast.targetId] : null;
const friendlyTarget = cast.targetId const friendlyTarget = cast.targetId
? usePartyStore.getState().members.find((member) => member.id === cast.targetId) ? usePartyStore.getState().members.find((member) => member.id === cast.targetId)
@@ -9812,14 +9814,14 @@ export const useCombatStore = create<CombatState>((set, get) => ({
? getMobPosition(cast.targetId) ?? getPartyRuntimePosition(cast.targetId) ? getMobPosition(cast.targetId) ?? getPartyRuntimePosition(cast.targetId)
: get().playerPosition; : get().playerPosition;
const channelHostile = ability.target === "hostile" || (ability.target === "any" && Boolean(target)); const channelHostile = ability.target === "hostile" || (ability.target === "any" && Boolean(target));
const distance = cast.targetId const distance = groundChannel ? 0 : cast.targetId
? channelHostile ? channelHostile
? Math.sqrt(mobRuntimeDistanceSquared(cast.targetId, cast.origin)) ? Math.sqrt(mobRuntimeDistanceSquared(cast.targetId, cast.origin))
: Math.sqrt(partyRuntimeDistanceSquared(cast.targetId, cast.origin)) : Math.sqrt(partyRuntimeDistanceSquared(cast.targetId, cast.origin))
: 0; : 0;
const minimumRange = ability ? talentAdjustedRange(ability.range.min, ability, get().talentModifiers) : 0; const minimumRange = ability ? talentAdjustedRange(ability.range.min, ability, get().talentModifiers) : 0;
const maximumRange = ability ? talentAdjustedRange(ability.range.max, 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 ? !target || target.dead || !targetPosition
: (ability.target === "friendly" || ability.target === "any") && cast.targetId : (ability.target === "friendly" || ability.target === "any") && cast.targetId
? !friendlyTarget || friendlyTarget.health <= 0 || !targetPosition ? !friendlyTarget || friendlyTarget.health <= 0 || !targetPosition
@@ -9856,11 +9858,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
0, 0,
); );
if (damageAmount > 0) { if (damageAmount > 0) {
const channelCenter = channelHostile ? targetPosition ?? cast.origin : get().playerPosition; const channelCenter = groundChannel
const channelTargets = ability.radius === undefined ? 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] : []) ? (cast.targetId && channelHostile ? [cast.targetId] : [])
: nearbyMobRuntimeIds(channelCenter, talentAdjustedRange(ability.radius, ability, get().talentModifiers)) : nearbyMobRuntimeIds(channelCenter, talentAdjustedRange(channelRadius, ability, get().talentModifiers))
.filter((id) => !get().mobs[id]?.dead); .filter((id) => !get().mobs[id]?.dead && Boolean(getMobPosition(id)
&& combatHasLineOfSight(channelCenter, getMobPosition(id)!)));
for (const channelTargetId of channelTargets) get().damageMob( for (const channelTargetId of channelTargets) get().damageMob(
channelTargetId, channelTargetId,
talentAdjustedAmount( talentAdjustedAmount(
@@ -9887,7 +9893,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
}, },
); );
} }
if (healingAmount > 0) { if (healingAmount > 0 && !(ability.target === "any" && channelHostile)) {
const perTargetHealing = talentAdjustedAmount( const perTargetHealing = talentAdjustedAmount(
healingAmount, healingAmount,
get().talentModifiers, get().talentModifiers,
@@ -10002,10 +10008,10 @@ export const useCombatStore = create<CombatState>((set, get) => ({
nextTickAt, nextTickAt,
get().playerPosition, get().playerPosition,
cast.targetId, cast.targetId,
cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition, cast.groundPosition ?? (cast.targetId ? getMobPosition(cast.targetId) : get().playerPosition),
); );
ticksCompleted += 1; 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 (get().activeCast?.id !== cast.id) return;
if (ticksCompleted >= cast.totalTicks || now >= cast.completesAt) { if (ticksCompleted >= cast.totalTicks || now >= cast.completesAt) {
+1 -1
View File
@@ -7,7 +7,7 @@ import { dungeonArtworkContains, dungeonArtworkFloorFor, dungeonArtworkHeading,
describe("RuneWaker dungeon maps", () => { describe("RuneWaker dungeon maps", () => {
it("packages a checksummed, position-aware map with every RuneWaker dungeon", () => { it("packages a checksummed, position-aware map with every RuneWaker dungeon", () => {
const dungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion === "runewaker"); const dungeons = DUNGEON_DEFINITIONS.filter((dungeon) => dungeon.expansion === "runewaker");
expect(dungeons).toHaveLength(33); expect(dungeons).toHaveLength(38);
for (const dungeon of dungeons) { for (const dungeon of dungeons) {
const map = dungeonArtworkMapFor(dungeon); const map = dungeonArtworkMapFor(dungeon);
expect(map, dungeon.id).not.toBeNull(); expect(map, dungeon.id).not.toBeNull();
+12 -5
View File
@@ -17,7 +17,7 @@ import queuePolicyCatalog from "./generated/dungeonLeaderboardCatalog.generated.
describe("dungeon catalog", () => { describe("dungeon catalog", () => {
it("lists the complete campaign and bite-sized variants", () => { it("lists the complete campaign and bite-sized variants", () => {
expect(DUNGEON_CATALOG).toHaveLength(93); expect(DUNGEON_CATALOG).toHaveLength(98);
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({ expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({
id: "stormwind-stockade", id: "stormwind-stockade",
releaseStage: "beta", releaseStage: "beta",
@@ -53,8 +53,9 @@ describe("dungeon catalog", () => {
); );
expect(dungeonsByCategory("rom")).toHaveLength(30); expect(dungeonsByCategory("rom")).toHaveLength(30);
expect(dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id).sort()) expect(dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id).sort())
.toEqual(["bite-sized-deadmines-ship", "bite-sized-forsaken-abbey-ancalon", .toEqual(["bite-sized-barren-caves-anglo", "bite-sized-deadmines-ship", "bite-sized-forsaken-abbey-ancalon",
"bite-sized-mystic-altar-golem", "bite-sized-paspers-shrine-hafiz", "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-stormwind-stockade", "bite-sized-utgarde-pinnacle-skadi",
"bite-sized-wailing-caverns"]); "bite-sized-wailing-caverns"]);
}); });
@@ -107,7 +108,12 @@ describe("dungeon catalog", () => {
it("keeps all Stockades experiences in Beta and alphabetizes each release group", () => { it("keeps all Stockades experiences in Beta and alphabetizes each release group", () => {
expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.releaseStage === "beta").map((dungeon) => dungeon.id)) expect(DUNGEON_CATALOG.filter((dungeon) => dungeon.releaseStage === "beta").map((dungeon) => dungeon.id))
.toEqual([ .toEqual([
"bite-sized-barren-caves-anglo",
"bite-sized-forsaken-abbey-ancalon", "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-mystic-altar-golem",
"bite-sized-paspers-shrine-hafiz", "bite-sized-paspers-shrine-hafiz",
"randomized-bite-sized-stormwind-stockade", "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", () => { it("shares the complete dungeon identity list used to validate reports on the server", () => {
expect(DUNGEON_CATALOG.map((dungeon) => dungeon.id).sort()) expect(DUNGEON_CATALOG.map((dungeon) => dungeon.id).sort())
.toEqual(["bite-sized-deadmines-ship", "bite-sized-forsaken-abbey-ancalon", .toEqual(["bite-sized-barren-caves-anglo", "bite-sized-deadmines-ship", "bite-sized-forsaken-abbey-ancalon",
"bite-sized-mystic-altar-golem", "bite-sized-paspers-shrine-hafiz", "bite-sized-stormwind-stockade", "bite-sized-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", "bite-sized-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns",
"randomized-bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade-available", "forsaken-abbey", "randomized-bite-sized-stormwind-stockade", "randomized-bite-sized-stormwind-stockade-available", "forsaken-abbey",
...Object.keys(availability.enabled)].sort()); ...Object.keys(availability.enabled)].sort());
+21 -1
View File
@@ -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_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_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_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"; export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-sized";
const BITE_SIZED_DUNGEON_IDS: ReadonlySet<string> = new Set([ const BITE_SIZED_DUNGEON_IDS: ReadonlySet<string> = new Set([
@@ -20,6 +25,11 @@ const BITE_SIZED_DUNGEON_IDS: ReadonlySet<string> = new Set([
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID, BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID, BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
BITE_SIZED_MYSTIC_ALTAR_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 { export function isBiteSizedDungeonId(dungeonId: string): boolean {
@@ -41,6 +51,11 @@ const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.free
[BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID]: "forsaken-abbey", [BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID]: "forsaken-abbey",
[BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID]: "paspers-shrine", [BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID]: "paspers-shrine",
[BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID]: "mystic-altar", [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 { 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_UTGARDE_PINNACLE_DUNGEON_ID
|| dungeonId === BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID || dungeonId === BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID
|| dungeonId === BITE_SIZED_PASPERS_SHRINE_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. */ /** Derived dungeon variants share the installed assets of their source dungeon. */
+1 -1
View File
@@ -50,7 +50,7 @@ describe("dungeon leaderboard catalog", () => {
}; }).sort((a, b) => a.id.localeCompare(b.id)), }; }).sort((a, b) => a.id.localeCompare(b.id)),
classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })), classes: CLASSES.map((entry) => ({ id: entry.id, name: entry.name })),
}; };
expect(catalog.dungeons).toHaveLength(93); expect(catalog.dungeons).toHaveLength(98);
expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]); expect(catalog.dungeons.filter((dungeon) => dungeon.bosses.length > 0 && !dungeon.finalBossId)).toEqual([]);
const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json"; const path = "src/game/generated/dungeonLeaderboardCatalog.generated.json";
if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`); if (process.env.WRITE_DUNGEON_LEADERBOARD_CATALOG === "1") writeFileSync(path, `${JSON.stringify(catalog, null, 2)}\n`);
+36 -2
View File
@@ -29,6 +29,11 @@ import {
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID, BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID, BITE_SIZED_PASPERS_SHRINE_DUNGEON_ID,
BITE_SIZED_MYSTIC_ALTAR_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_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID, RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID,
@@ -45,6 +50,11 @@ import {
createBiteSizedForsakenAbbeyDefinition, createBiteSizedForsakenAbbeyDefinition,
createBiteSizedMysticAltarDefinition, createBiteSizedMysticAltarDefinition,
createBiteSizedPaspersShrineDefinition, createBiteSizedPaspersShrineDefinition,
createBiteSizedGrafuCastleDefinition,
createBiteSizedBarrenCavesDefinition,
createBiteSizedHotoDefinition,
createBiteSizedKawaksTombDefinition,
createBiteSizedHallOfSurvivorsDefinition,
} from "./dungeons/biteSizedRoMDungeons"; } from "./dungeons/biteSizedRoMDungeons";
import { import {
createRandomizedBiteSizedStockadesDefinition, createRandomizedBiteSizedStockadesDefinition,
@@ -235,6 +245,8 @@ export const RANDOMIZED_BITE_SIZED_CREATURE_CATALOG = Object.freeze(
const AVAILABLE_BITE_SIZED_SOURCE_IDS = new Set([ const AVAILABLE_BITE_SIZED_SOURCE_IDS = new Set([
"stormwind-stockade", "wailing-caverns", "deadmines", "utgarde-pinnacle", "stormwind-stockade", "wailing-caverns", "deadmines", "utgarde-pinnacle",
"forsaken-abbey", "paspers-shrine", "mystic-altar", "forsaken-abbey", "paspers-shrine", "mystic-altar",
"grafu-castle", "barren-caves",
"hoto", "kawaks-tomb", "hall-of-survivors",
]); ]);
function sourceIsAvailable(source: DungeonDefinition): boolean { function sourceIsAvailable(source: DungeonDefinition): boolean {
@@ -276,12 +288,24 @@ const biteSizedUtgardePinnacle = createBiteSizedUtgardePinnacleDefinition(utgard
const forsakenAbbeyDefinition = adaptedSourceRegistry["forsaken-abbey"]; const forsakenAbbeyDefinition = adaptedSourceRegistry["forsaken-abbey"];
const paspersShrineDefinition = adaptedSourceRegistry["paspers-shrine"]; const paspersShrineDefinition = adaptedSourceRegistry["paspers-shrine"];
const mysticAltarDefinition = adaptedSourceRegistry["mystic-altar"]; const mysticAltarDefinition = adaptedSourceRegistry["mystic-altar"];
if (!forsakenAbbeyDefinition || !paspersShrineDefinition || !mysticAltarDefinition) { const grafuCastleDefinition = adaptedSourceRegistry["grafu-castle"];
throw new Error("The three RoM source dungeons are required for their bite-sized variants."); 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 biteSizedForsakenAbbey = createBiteSizedForsakenAbbeyDefinition(forsakenAbbeyDefinition, "catalog-template");
const biteSizedPaspersShrine = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition, "catalog-template"); const biteSizedPaspersShrine = createBiteSizedPaspersShrineDefinition(paspersShrineDefinition, "catalog-template");
const biteSizedMysticAltar = createBiteSizedMysticAltarDefinition(mysticAltarDefinition, "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])[] = [ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
...adaptedSourceEntries, ...adaptedSourceEntries,
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate], [biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
@@ -293,6 +317,11 @@ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[]
[biteSizedForsakenAbbey.id, biteSizedForsakenAbbey], [biteSizedForsakenAbbey.id, biteSizedForsakenAbbey],
[biteSizedPaspersShrine.id, biteSizedPaspersShrine], [biteSizedPaspersShrine.id, biteSizedPaspersShrine],
[biteSizedMysticAltar.id, biteSizedMysticAltar], [biteSizedMysticAltar.id, biteSizedMysticAltar],
[biteSizedGrafuCastle.id, biteSizedGrafuCastle],
[biteSizedBarrenCaves.id, biteSizedBarrenCaves],
[biteSizedHoto.id, biteSizedHoto],
[biteSizedKawaksTomb.id, biteSizedKawaksTomb],
[biteSizedHallOfSurvivors.id, biteSizedHallOfSurvivors],
]; ];
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze( export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = 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_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_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_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_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_DEADMINES_DUNGEON_ID) definition = createBiteSizedDeadminesDefinition(deadminesDefinition, seed);
else if (id === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID) definition = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition, seed); else if (id === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID) definition = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition, seed);
@@ -12,6 +12,11 @@ const sliceIds = [
"bite-sized-forsaken-abbey-ancalon", "bite-sized-forsaken-abbey-ancalon",
"bite-sized-paspers-shrine-hafiz", "bite-sized-paspers-shrine-hafiz",
"bite-sized-mystic-altar-golem", "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; ] as const;
describe("RoM bite-sized finales", () => { describe("RoM bite-sized finales", () => {
@@ -43,7 +48,8 @@ describe("RoM bite-sized finales", () => {
expect(source).toBeDefined(); expect(source).toBeDefined();
expect(dungeonContentPackId(id)).toBe(`dungeon:${source.id}`); expect(dungeonContentPackId(id)).toBe(`dungeon:${source.id}`);
expect(slice.assets).toBe(source.assets); 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.staticSpawns.every((spawn) => source.staticSpawns.includes(spawn))).toBe(true);
expect(slice.bosses).toHaveLength(1); expect(slice.bosses).toHaveLength(1);
expect(slice.bosses[0].eventOnly).toBe(true); 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.flags["dungeon-complete"]).toBe(true);
expect(completed.phase).toBe("complete"); expect(completed.phase).toBe("complete");
// The complete source remains available with its original population and objectives. // 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.ambientLight.intensity).toBeGreaterThan(source.presentation.ambientLight.intensity);
expect(slice.presentation.hemisphereLight.intensity).toBeGreaterThan(source.presentation.hemisphereLight.intensity); expect(slice.presentation.hemisphereLight.intensity).toBeGreaterThan(source.presentation.hemisphereLight.intensity);
}); });
+116 -3
View File
@@ -9,6 +9,11 @@ import {
BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID, BITE_SIZED_FORSAKEN_ABBEY_DUNGEON_ID,
BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID, BITE_SIZED_MYSTIC_ALTAR_DUNGEON_ID,
BITE_SIZED_PASPERS_SHRINE_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"; } from "../dungeonContentSource";
import { seededBossIndex } from "./seededBossSelection"; import { seededBossIndex } from "./seededBossSelection";
@@ -24,6 +29,7 @@ interface RoMSlice {
readonly mobSpawnIds: readonly string[]; readonly mobSpawnIds: readonly string[];
readonly summary: string; readonly summary: string;
readonly adaptation: string; readonly adaptation: string;
readonly lighting?: { readonly ambient: number; readonly hemisphere: number; readonly directional: number };
} }
const ROM_SLICES: readonly RoMSlice[] = [ 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.", 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.", 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 { 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", presentation: { ...source.presentation, location: slice.location, theme: "Bite-Sized Dungeon",
summary: slice.summary, loadingMessage: `Entering ${slice.entranceName}...`, summary: slice.summary, loadingMessage: `Entering ${slice.entranceName}...`,
unchartedAreaName: slice.entranceName, unchartedAreaName: slice.entranceName,
ambientLight: { color: "#e2dccc", intensity: 0.9 }, ambientLight: { color: "#e2dccc", intensity: slice.lighting?.ambient ?? 0.9 },
hemisphereLight: { skyColor: "#ded9d0", groundColor: "#5a5350", intensity: 1.2 }, hemisphereLight: { skyColor: "#ded9d0", groundColor: "#5a5350", intensity: slice.lighting?.hemisphere ?? 1.2 },
directionalLight: { ...source.presentation.directionalLight, color: "#fff0d9", intensity: 1.35 } }, directionalLight: { ...source.presentation.directionalLight,
color: slice.lighting ? source.presentation.directionalLight.color : "#fff0d9",
intensity: slice.lighting?.directional ?? 1.35 } },
areas, areas,
entities, entities,
staticSpawns: mobSpawns, staticSpawns: mobSpawns,
@@ -190,3 +283,23 @@ export function createBiteSizedPaspersShrineDefinition(source: DungeonDefinition
export function createBiteSizedMysticAltarDefinition(source: DungeonDefinition, seed: string): DungeonDefinition { export function createBiteSizedMysticAltarDefinition(source: DungeonDefinition, seed: string): DungeonDefinition {
return createRoMSlice(source, ROM_SLICES[2], seed); 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);
}
@@ -216,6 +216,31 @@
], ],
"defaultDifficultyId": "normal" "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", "id": "bite-sized-deadmines-ship",
"title": "The Deadmines: Take the Ship", "title": "The Deadmines: Take the Ship",
@@ -344,6 +369,202 @@
], ],
"defaultDifficultyId": "normal" "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", "id": "bite-sized-mystic-altar-golem",
"title": "Mystic Altar: Final Antechamber", "title": "Mystic Altar: Final Antechamber",
@@ -4327,8 +4548,24 @@
{ {
"id": "randomized-bite-sized-stormwind-stockade-available", "id": "randomized-bite-sized-stormwind-stockade-available",
"title": "Randomized Bite-Sized: Bite-Sized Sources", "title": "Randomized Bite-Sized: Bite-Sized Sources",
"finalBossId": "randomized:wow:boss:1716", "finalBossId": "randomized:rom:boss:102430",
"bosses": [ "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", "id": "randomized:wow:boss:1716",
"name": "Bazil Thredd" "name": "Bazil Thredd"
@@ -4353,10 +4590,26 @@
"id": "randomized:rom:boss:101020", "id": "randomized:rom:boss:101020",
"name": "Blackhorn Nisorn" "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", "id": "randomized:wow:boss:647",
"name": "Captain Greenskin" "name": "Captain Greenskin"
}, },
{
"id": "randomized:rom:boss:105616",
"name": "Cayus"
},
{
"id": "randomized:rom:boss:105204",
"name": "Conjurer Chapeaunoir"
},
{ {
"id": "randomized:wow:boss:645", "id": "randomized:wow:boss:645",
"name": "Cookie" "name": "Cookie"
@@ -4381,6 +4634,10 @@
"id": "randomized:wow:boss:1663", "id": "randomized:wow:boss:1663",
"name": "Dextren Ward" "name": "Dextren Ward"
}, },
{
"id": "randomized:rom:boss:102476",
"name": "Divine Punishment Pursuer"
},
{ {
"id": "randomized:rom:boss:100611", "id": "randomized:rom:boss:100611",
"name": "Dorlos" "name": "Dorlos"
@@ -4393,14 +4650,26 @@
"id": "randomized:rom:boss:100561", "id": "randomized:rom:boss:100561",
"name": "Flowing Chaos" "name": "Flowing Chaos"
}, },
{
"id": "randomized:rom:boss:102063",
"name": "Geba"
},
{ {
"id": "randomized:wow:boss:1763", "id": "randomized:wow:boss:1763",
"name": "Gilnid" "name": "Gilnid"
}, },
{
"id": "randomized:rom:boss:102475",
"name": "Glamo"
},
{ {
"id": "randomized:wow:boss:26687", "id": "randomized:wow:boss:26687",
"name": "Gortok Palehoof" "name": "Gortok Palehoof"
}, },
{
"id": "randomized:rom:boss:102395",
"name": "Guldamor"
},
{ {
"id": "randomized:wow:boss:1717", "id": "randomized:wow:boss:1717",
"name": "Hamhock" "name": "Hamhock"
@@ -4409,6 +4678,18 @@
"id": "randomized:rom:boss:100563", "id": "randomized:rom:boss:100563",
"name": "Hollow Shell" "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", "id": "randomized:wow:boss:1666",
"name": "Kam Deepfury" "name": "Kam Deepfury"
@@ -4425,6 +4706,14 @@
"id": "randomized:wow:boss:3671", "id": "randomized:wow:boss:3671",
"name": "Lady Anacondra" "name": "Lady Anacondra"
}, },
{
"id": "randomized:rom:boss:105351",
"name": "Larvanger Barkud"
},
{
"id": "randomized:rom:boss:104228",
"name": "Linobia"
},
{ {
"id": "randomized:wow:boss:3669", "id": "randomized:wow:boss:3669",
"name": "Lord Cobrahn" "name": "Lord Cobrahn"
@@ -4437,6 +4726,14 @@
"id": "randomized:wow:boss:3673", "id": "randomized:wow:boss:3673",
"name": "Lord Serpentis" "name": "Lord Serpentis"
}, },
{
"id": "randomized:rom:boss:102452",
"name": "Mantarick"
},
{
"id": "randomized:rom:boss:102040",
"name": "Medusa"
},
{ {
"id": "randomized:wow:boss:646", "id": "randomized:wow:boss:646",
"name": "Mr. Smite" "name": "Mr. Smite"
@@ -4449,6 +4746,10 @@
"id": "randomized:rom:boss:100070", "id": "randomized:rom:boss:100070",
"name": "Necromage" "name": "Necromage"
}, },
{
"id": "randomized:rom:boss:102560",
"name": "Prison Pursuer"
},
{ {
"id": "randomized:rom:boss:100154", "id": "randomized:rom:boss:100154",
"name": "Razeela" "name": "Razeela"
@@ -4477,9 +4778,17 @@
"id": "randomized:wow:boss:1696", "id": "randomized:wow:boss:1696",
"name": "Targorr the Dread" "name": "Targorr the Dread"
}, },
{
"id": "randomized:rom:boss:102041",
"name": "Teeth of the Reef"
},
{ {
"id": "randomized:wow:boss:5775", "id": "randomized:wow:boss:5775",
"name": "Verdan the Everliving" "name": "Verdan the Everliving"
},
{
"id": "randomized:rom:boss:102425",
"name": "Zygro"
} }
], ],
"difficulties": [ "difficulties": [
@@ -4487,36 +4796,56 @@
"id": "normal", "id": "normal",
"name": "normal", "name": "normal",
"bossIds": [ "bossIds": [
"randomized:rom:boss:102430",
"randomized:rom:boss:102421",
"randomized:rom:boss:101462",
"randomized:rom:boss:105542",
"randomized:wow:boss:1716", "randomized:wow:boss:1716",
"randomized:rom:boss:101018", "randomized:rom:boss:101018",
"randomized:rom:boss:101019", "randomized:rom:boss:101019",
"randomized:rom:boss:101021", "randomized:rom:boss:101021",
"randomized:rom:boss:100794", "randomized:rom:boss:100794",
"randomized:rom:boss:101020", "randomized:rom:boss:101020",
"randomized:rom:boss:102046",
"randomized:rom:boss:105224",
"randomized:wow:boss:647", "randomized:wow:boss:647",
"randomized:rom:boss:105616",
"randomized:rom:boss:105204",
"randomized:wow:boss:645", "randomized:wow:boss:645",
"randomized:rom:boss:100562", "randomized:rom:boss:100562",
"randomized:rom:boss:100138", "randomized:rom:boss:100138",
"randomized:rom:boss:100074", "randomized:rom:boss:100074",
"randomized:wow:boss:5912", "randomized:wow:boss:5912",
"randomized:wow:boss:1663", "randomized:wow:boss:1663",
"randomized:rom:boss:102476",
"randomized:rom:boss:100611", "randomized:rom:boss:100611",
"randomized:wow:boss:639", "randomized:wow:boss:639",
"randomized:rom:boss:100561", "randomized:rom:boss:100561",
"randomized:rom:boss:102063",
"randomized:wow:boss:1763", "randomized:wow:boss:1763",
"randomized:rom:boss:102475",
"randomized:wow:boss:26687", "randomized:wow:boss:26687",
"randomized:rom:boss:102395",
"randomized:wow:boss:1717", "randomized:wow:boss:1717",
"randomized:rom:boss:100563", "randomized:rom:boss:100563",
"randomized:rom:boss:102643",
"randomized:rom:boss:105543",
"randomized:rom:boss:102021",
"randomized:wow:boss:1666", "randomized:wow:boss:1666",
"randomized:wow:boss:26861", "randomized:wow:boss:26861",
"randomized:wow:boss:3653", "randomized:wow:boss:3653",
"randomized:wow:boss:3671", "randomized:wow:boss:3671",
"randomized:rom:boss:105351",
"randomized:rom:boss:104228",
"randomized:wow:boss:3669", "randomized:wow:boss:3669",
"randomized:wow:boss:3670", "randomized:wow:boss:3670",
"randomized:wow:boss:3673", "randomized:wow:boss:3673",
"randomized:rom:boss:102452",
"randomized:rom:boss:102040",
"randomized:wow:boss:646", "randomized:wow:boss:646",
"randomized:wow:boss:3654", "randomized:wow:boss:3654",
"randomized:rom:boss:100070", "randomized:rom:boss:100070",
"randomized:rom:boss:102560",
"randomized:rom:boss:100154", "randomized:rom:boss:100154",
"randomized:wow:boss:644", "randomized:wow:boss:644",
"randomized:wow:boss:26693", "randomized:wow:boss:26693",
@@ -4524,40 +4853,62 @@
"randomized:wow:boss:642", "randomized:wow:boss:642",
"randomized:wow:boss:26668", "randomized:wow:boss:26668",
"randomized:wow:boss:1696", "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": [ "finalBossIds": [
"randomized:rom:boss:102430",
"randomized:rom:boss:102421",
"randomized:rom:boss:101462",
"randomized:rom:boss:105542",
"randomized:wow:boss:1716", "randomized:wow:boss:1716",
"randomized:rom:boss:101018", "randomized:rom:boss:101018",
"randomized:rom:boss:101019", "randomized:rom:boss:101019",
"randomized:rom:boss:101021", "randomized:rom:boss:101021",
"randomized:rom:boss:100794", "randomized:rom:boss:100794",
"randomized:rom:boss:101020", "randomized:rom:boss:101020",
"randomized:rom:boss:102046",
"randomized:rom:boss:105224",
"randomized:wow:boss:647", "randomized:wow:boss:647",
"randomized:rom:boss:105616",
"randomized:rom:boss:105204",
"randomized:wow:boss:645", "randomized:wow:boss:645",
"randomized:rom:boss:100562", "randomized:rom:boss:100562",
"randomized:rom:boss:100138", "randomized:rom:boss:100138",
"randomized:rom:boss:100074", "randomized:rom:boss:100074",
"randomized:wow:boss:5912", "randomized:wow:boss:5912",
"randomized:wow:boss:1663", "randomized:wow:boss:1663",
"randomized:rom:boss:102476",
"randomized:rom:boss:100611", "randomized:rom:boss:100611",
"randomized:wow:boss:639", "randomized:wow:boss:639",
"randomized:rom:boss:100561", "randomized:rom:boss:100561",
"randomized:rom:boss:102063",
"randomized:wow:boss:1763", "randomized:wow:boss:1763",
"randomized:rom:boss:102475",
"randomized:wow:boss:26687", "randomized:wow:boss:26687",
"randomized:rom:boss:102395",
"randomized:wow:boss:1717", "randomized:wow:boss:1717",
"randomized:rom:boss:100563", "randomized:rom:boss:100563",
"randomized:rom:boss:102643",
"randomized:rom:boss:105543",
"randomized:rom:boss:102021",
"randomized:wow:boss:1666", "randomized:wow:boss:1666",
"randomized:wow:boss:26861", "randomized:wow:boss:26861",
"randomized:wow:boss:3653", "randomized:wow:boss:3653",
"randomized:wow:boss:3671", "randomized:wow:boss:3671",
"randomized:rom:boss:105351",
"randomized:rom:boss:104228",
"randomized:wow:boss:3669", "randomized:wow:boss:3669",
"randomized:wow:boss:3670", "randomized:wow:boss:3670",
"randomized:wow:boss:3673", "randomized:wow:boss:3673",
"randomized:rom:boss:102452",
"randomized:rom:boss:102040",
"randomized:wow:boss:646", "randomized:wow:boss:646",
"randomized:wow:boss:3654", "randomized:wow:boss:3654",
"randomized:rom:boss:100070", "randomized:rom:boss:100070",
"randomized:rom:boss:102560",
"randomized:rom:boss:100154", "randomized:rom:boss:100154",
"randomized:wow:boss:644", "randomized:wow:boss:644",
"randomized:wow:boss:26693", "randomized:wow:boss:26693",
@@ -4565,7 +4916,9 @@
"randomized:wow:boss:642", "randomized:wow:boss:642",
"randomized:wow:boss:26668", "randomized:wow:boss:26668",
"randomized:wow:boss:1696", "randomized:wow:boss:1696",
"randomized:wow:boss:5775" "randomized:rom:boss:102041",
"randomized:wow:boss:5775",
"randomized:rom:boss:102425"
] ]
} }
], ],
+4
View File
@@ -134,6 +134,10 @@ describe("NPC sustained damage audit", () => {
&& event.effectiveAmount > 0) && event.effectiveAmount > 0)
.map((event) => event.targetActorId)); .map((event) => event.targetActorId));
expect(hitTargets).toEqual(new Set(targetIds)); 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);
}
}); });
} }
+42
View File
@@ -354,6 +354,48 @@ describe("party combat runtime", () => {
.toBeGreaterThan(startingHealth); .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", () => { it("lets a resurrected tank rejoin independently instead of taunting or casting from its checkpoint", () => {
registerEngagedMob(); registerEngagedMob();
const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!; const tank = usePartyStore.getState().members.find((member) => member.role === "tank")!;
+9 -5
View File
@@ -65,7 +65,7 @@ import { imminentCooldownMechanic } from "./companionCooldownPolicy";
import { normalizeActorResourcePool } from "./combatActors"; import { normalizeActorResourcePool } from "./combatActors";
import { enemyControlPreventsActions } from "./enemyAttacks"; import { enemyControlPreventsActions } from "./enemyAttacks";
import { advanceCombatResourcePool } from "./combatResources"; import { advanceCombatResourcePool } from "./combatResources";
import { channelAmountEffects, channelPulseCount } from "./combatChannelAmounts"; import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingRecipientIds } from "./healingAbilityRules"; import { healingAbilityCanAidOthers, healingAbilityHasPendingHot, healingRecipientIds } from "./healingAbilityRules";
import { import {
combatHasLineOfSight, combatHasLineOfSight,
@@ -1173,6 +1173,7 @@ function executePartyAbility(
&& combat.auras.some((aura) => aura.targetId === member.id && combat.auras.some((aura) => aura.targetId === member.id
&& aura.definition.utilityTags?.includes("fire-crit-stacking")); && aura.definition.utilityTags?.includes("fire-crit-stacking"));
const groundAreaCreated = targetingRulesForAbility(ability).shape === "ground" const groundAreaCreated = targetingRulesForAbility(ability).shape === "ground"
&& !(castStartAlreadyEmitted && ability.castMode === "channel")
? Boolean(useCombatStore.getState().createActorAreaEffect( ? Boolean(useCombatStore.getState().createActorAreaEffect(
member.id, member.id,
ability, ability,
@@ -1813,9 +1814,10 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
continue; continue;
} }
const totalTicks = channelPulseCount(castingAbility); const totalTicks = channelPulseCount(castingAbility);
const interval = Math.max(1, cast.originalDurationMs / totalTicks);
let completed = cast.channelTicksCompleted ?? 0; 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 effects: AbilityEffect[] = channelAmountEffects(castingAbility, totalTicks).map(({ effect, multiplier }) => {
const amount = Math.round(partyAbilityAmount(member, castingAbility, effect) * multiplier); const amount = Math.round(partyAbilityAmount(member, castingAbility, effect) * multiplier);
if (effect.kind === "heal" || effect.kind === "hot") return { 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 }; return { kind: "damage", coefficient: 0, basePoints: amount, sourceLevel: member.level };
}); });
const execution = executePartyAbility(member, { ...castingAbility, effects }, castTargetId, const execution = executePartyAbility(member, { ...castingAbility, effects }, castTargetId,
cast.startedAt + (completed + 1) * interval, nextMembers, 0, true); pulseAt, nextMembers, 0, true);
member = execution.member; member = execution.member;
completed += 1; completed += 1;
} }
@@ -1863,7 +1865,9 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
nextMembers[index] = { nextMembers[index] = {
...execution.member, ...execution.member,
status: desiredStatus, 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 { } else {
nextMembers[index] = { nextMembers[index] = {
@@ -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}}}
+5
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import generated from "./romPlayerCatalog.generated.json"; 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 audit from "../../scripts/runewaker-pipeline/snapshots/runewaker-player-skill-audit.json";
import { import {
abilitiesForCharacter, abilitiesForCharacter,
@@ -19,6 +20,10 @@ const RACE_MATRIX = {
const ELITE_LEVELS = [15, 20, 25, 30, 35, 40, 45, 50, 60, 70]; const ELITE_LEVELS = [15, 20, 25, 30, 35, 40, 45, 50, 60, 70];
describe("generated RuneWaker player catalog", () => { 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", () => { it("imports the exact released roster and race eligibility without Duelist", () => {
expect(generated.canonicalClasses.map((entry) => entry.name)).toEqual([ expect(generated.canonicalClasses.map((entry) => entry.name)).toEqual([
"Warrior", "Scout", "Rogue", "Mage", "Priest", "Knight", "Warden", "Druid", "Warlock", "Champion", "Warrior", "Scout", "Rogue", "Mage", "Priest", "Knight", "Warden", "Druid", "Warlock", "Champion",
+2 -2
View File
@@ -1,11 +1,11 @@
import { isRomClassId, type ClassId, type RomClassId } from "../app/characterCatalog"; 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"; type SourceStatName = "strength" | "stamina" | "intellect" | "wisdom" | "agility" | "health" | "mana";
interface GrowthRow { readonly levelOne: number; readonly flatGrowth: number; readonly percentGrowth: number } interface GrowthRow { readonly levelOne: number; readonly flatGrowth: number; readonly percentGrowth: number }
type GrowthTable = Readonly<Record<RomClassId, Readonly<Record<SourceStatName, GrowthRow>>>>; type GrowthTable = Readonly<Record<RomClassId, Readonly<Record<SourceStatName, GrowthRow>>>>;
const GROWTH = catalog.baseStatGrowth as unknown as GrowthTable; const GROWTH = growth as unknown as GrowthTable;
function sourceValue(row: GrowthRow, level: number): number { function sourceValue(row: GrowthRow, level: number): number {
const safeLevel = Math.max(1, Math.min(80, Math.trunc(level))); const safeLevel = Math.max(1, Math.min(80, Math.trunc(level)));
+1 -1
View File
@@ -90,7 +90,7 @@ async function readGlbAnimationNames(url: string): Promise<readonly string[]> {
describe("RuneWaker dungeon creature animation coverage", () => { describe("RuneWaker dungeon creature animation coverage", () => {
it("gives every creature a working native or procedural state path", async () => { it("gives every creature a working native or procedural state path", async () => {
const dungeons = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion === "runewaker"); const dungeons = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion === "runewaker");
expect(dungeons).toHaveLength(33); expect(dungeons).toHaveLength(38);
for (const dungeon of dungeons) { for (const dungeon of dungeons) {
if (dungeon.staticSpawns.length === 0) { if (dungeon.staticSpawns.length === 0) {
+2 -2
View File
@@ -11,9 +11,9 @@ describe("RuneWaker dungeon registry", () => {
it("registers every configured instance exactly once", () => { it("registers every configured instance exactly once", () => {
expect(RUNEWAKER_DUNGEON_DEFINITIONS).toHaveLength(29); expect(RUNEWAKER_DUNGEON_DEFINITIONS).toHaveLength(29);
const runewaker = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion === "runewaker"); const runewaker = DUNGEON_DEFINITIONS.filter((definition) => definition.expansion === "runewaker");
expect(runewaker).toHaveLength(33); expect(runewaker).toHaveLength(38);
expect(runewaker.filter((definition) => !isBiteSizedDungeonId(definition.id))).toHaveLength(30); 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); expect(runewaker.some((definition) => definition.id === "forsaken-abbey")).toBe(true);
}); });
+40 -10
View File
@@ -4,7 +4,6 @@ import type {
TalentAllocationFailure, TalentAllocationFailure,
TalentAllocationResult, TalentAllocationResult,
} from "./talentCatalog"; } from "./talentCatalog";
import generated from "./generated/talentRuntimeCatalog.json";
export type RuntimeTalentRanks = Readonly<Record<string, number>>; export type RuntimeTalentRanks = Readonly<Record<string, number>>;
@@ -42,15 +41,41 @@ interface RuntimeTalentSnapshot {
readonly nodes: readonly RuntimeTalentNode[]; readonly nodes: readonly RuntimeTalentNode[];
} }
const snapshot = generated as unknown as RuntimeTalentSnapshot; let snapshot: RuntimeTalentSnapshot = {
export const RUNTIME_TALENT_TREES: readonly RuntimeTalentTree[] = snapshot.trees; schemaVersion: 1,
export const RUNTIME_TALENT_NODES: readonly RuntimeTalentNode[] = snapshot.nodes; maximumCoaLevel: 80,
const treeById = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [tree.id, tree]))) as Readonly<Record<string, RuntimeTalentTree>>; coaBudgetsByClass: {} as RuntimeTalentSnapshot["coaBudgetsByClass"],
const nodeById = Object.freeze(Object.fromEntries(snapshot.nodes.map((node) => [node.id, node]))) as Readonly<Record<string, RuntimeTalentNode>>; trees: [],
const nodesByTree = Object.freeze(Object.fromEntries(snapshot.trees.map((tree) => [ nodes: [],
tree.id, };
snapshot.nodes.filter((node) => node.treeId === tree.id).sort((left, right) => left.row - right.row || left.index - right.index), export let RUNTIME_TALENT_TREES: readonly RuntimeTalentTree[] = snapshot.trees;
]))) as Readonly<Record<string, readonly RuntimeTalentNode[]>>; export let RUNTIME_TALENT_NODES: readonly RuntimeTalentNode[] = snapshot.nodes;
let treeById: Readonly<Record<string, RuntimeTalentTree>> = {};
let nodeById: Readonly<Record<string, RuntimeTalentNode>> = {};
let nodesByTree: Readonly<Record<string, readonly RuntimeTalentNode[]>> = {};
let catalogLoad: Promise<void> | null = null;
/** The authored WoW/CoA talent snapshot is unnecessary for a RoM-only session. */
export function preloadRuntimeTalentCatalog(): Promise<void> {
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 { function isCoaClassId(classId: ClassId): classId is CoaClassId {
return CLASSES.find((definition) => definition.id === classId)?.mode === "conquest"; 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) { function coaBudget(classId: CoaClassId, level: number) {
const definition = snapshot.coaBudgetsByClass[classId]; 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 normalizedLevel = Math.min(snapshot.maximumCoaLevel, Math.max(1, clampPlayerLevel(level)));
const grantedPoints = Math.max(0, normalizedLevel - 9); const grantedPoints = Math.max(0, normalizedLevel - 9);
return { return {
@@ -285,3 +311,7 @@ export function runtimeResetTalentRanks(ranks: RuntimeTalentRanks = {}, classId?
return !node || treeById[node.treeId]?.classId !== classId; return !node || treeById[node.treeId]?.classId !== classId;
})); }));
} }
if (import.meta.env.MODE === "test" || import.meta.env.MODE === "shelved") {
await preloadRuntimeTalentCatalog();
}
+35 -6
View File
@@ -2,6 +2,9 @@ import { describe, expect, it } from "vitest";
import { abilityAtLevel, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog"; import { abilityAtLevel, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog";
import { aiHintsForAbility, targetingRulesForAbility } from "./abilityRules"; import { aiHintsForAbility, targetingRulesForAbility } from "./abilityRules";
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; 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 { function byId(id: string): AbilityDefinition {
const ability = Object.values(WOW335_ABILITIES_BY_CLASS) const ability = Object.values(WOW335_ABILITIES_BY_CLASS)
@@ -21,6 +24,14 @@ function effectsOfKind<K extends AbilityEffect["kind"]>(
} }
describe("WoW 3.3.5 executable effect mapping", () => { 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", () => { it("preserves source spells that do not trigger a global cooldown", () => {
expect(byId("wow335-priest-inner-focus").gcdMs).toBe(0); expect(byId("wow335-priest-inner-focus").gcdMs).toBe(0);
expect(byId("wow335-rogue-cold-blood").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", () => { it("models Penance as three healing or damaging pulses", () => {
const penance = byId("wow335-priest-penance"); 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(penance.target).toBe("any");
expect(effectsOfKind(penance, "heal")).toEqual([ expect(effectsOfKind(penance, "heal")).toEqual([
expect.objectContaining({ coefficient: 0.6 }), expect.objectContaining({ coefficient: 0.6 }),
]); ]);
expect(effectsOfKind(penance, "hot")).toEqual([ expect(effectsOfKind(penance, "hot")).toEqual([]);
expect.objectContaining({ coefficient: 0.6, ticks: 2, intervalMs: 1_000 }),
]);
expect(effectsOfKind(penance, "damage")).toEqual([ expect(effectsOfKind(penance, "damage")).toEqual([
expect.objectContaining({ coefficient: 0.6 }), expect.objectContaining({ coefficient: 0.6 }),
]); ]);
expect(effectsOfKind(penance, "dot")).toEqual([ expect(effectsOfKind(penance, "dot")).toEqual([]);
expect.objectContaining({ coefficient: 0.6, ticks: 2, intervalMs: 1_000 }), });
]);
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", () => { it("maps the Ready-five missing spell payloads without cast-time damage on self buffs", () => {
+86 -8
View File
@@ -25,7 +25,7 @@ import {
ROGUE_DEADLY_POISON_DOSE_TAG, ROGUE_DEADLY_POISON_DOSE_TAG,
ROGUE_ENVENOM_BUFF_TAG, ROGUE_ENVENOM_BUFF_TAG,
} from "./roguePoison"; } from "./roguePoison";
import { talentNodeById } from "./talentCatalog"; import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
import { baseResourcesFor } from "./wotlkStats"; import { baseResourcesFor } from "./wotlkStats";
export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10; 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:Mind Flay": return [{ kind: "damage", coefficient: 0.9 }];
case "priest:Penance": return [ case "priest:Penance": return [
{ kind: "heal", coefficient: 0.6 }, { kind: "heal", coefficient: 0.6 },
{ kind: "hot", coefficient: 0.6, ticks: 2, intervalMs: 1_000 },
{ kind: "damage", coefficient: 0.6 }, { kind: "damage", coefficient: 0.6 },
{ kind: "dot", coefficient: 0.6, ticks: 2, intervalMs: 1_000, tag: "penance" },
]; ];
case "priest:Divine Hymn": return [{ case "priest:Divine Hymn": return [{
kind: "heal", coefficient: 1, scope: "party", center: "caster", radius: 40, maximumTargets: 3, kind: "heal", coefficient: 1, scope: "party", center: "caster", radius: 40, maximumTargets: 3,
@@ -1478,7 +1476,16 @@ function rankDefinition(
abilityName: string, abilityName: string,
): AbilityRankDefinition { ): AbilityRankDefinition {
const effects = effectsForRank(classId, rank, abilityName); 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); const rankResource = resourceForRank(resource, rank);
return { return {
rank: rank.rank || index + 1, rank: rank.rank || index + 1,
@@ -1486,7 +1493,12 @@ function rankDefinition(
level: gameplaySourceLevel(classId, rank.level), level: gameplaySourceLevel(classId, rank.level),
description: rank.description, description: rank.description,
castMode: channeling ? "channel" : rank.castTimeMs > 0 ? "cast" : "instant", 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, cooldownMs: rank.cooldownMs,
gcdMs: rank.gcdMs, gcdMs: rank.gcdMs,
cost: { cost: {
@@ -1517,17 +1529,41 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
const sourceRanks = chain.ranks.some((rank) => rank.rank > 0) const sourceRanks = chain.ranks.some((rank) => rank.rank > 0)
? chain.ranks.filter((rank) => rank.rank > 0) ? chain.ranks.filter((rank) => rank.rank > 0)
: chain.ranks; : 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 // 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 // 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. // is a second payload of that same cast, not another selectable rank.
const castRanks = classId === "priest" && chain.name === "Holy Nova" 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) => { .map((rank) => {
const healing = sourceRanks.find((candidate) => candidate.rank === rank.rank const healing = sourceRanks.find((candidate) => candidate.rank === rank.rank
&& candidate.effects.some((effect) => effect.effectType === 10)); && candidate.effects.some((effect) => effect.effectType === 10));
return healing ? { ...rank, effects: [...rank.effects, ...healing.effects] } : rank; 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 ranks = castRanks.map((rank, index) => rankDefinition(classId, resource, rank, index, chain.name));
const first = ranks[0]; const first = ranks[0];
const firstRaw = castRanks[0]; const firstRaw = castRanks[0];
@@ -1558,6 +1594,9 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
...(radius > 0 ? { radius } : {}), ...(radius > 0 ? { radius } : {}),
castMode: first.castMode, castMode: first.castMode,
castTimeMs: first.castTimeMs, castTimeMs: first.castTimeMs,
channelTickMs: first.channelTickMs,
channelPulses: first.channelPulses,
channelStartsWithPulse: first.channelStartsWithPulse,
cooldownMs: first.cooldownMs, cooldownMs: first.cooldownMs,
...(classicCooldownGroup(classId, chain.name) ...(classicCooldownGroup(classId, chain.name)
? { cooldownGroup: 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 // Lash). They cannot be allocated by any legal local build, so expose
// those retained class actions at their source level instead of making // those retained class actions at their source level instead of making
// them permanently unlearnable behind a dangling talent ID. // them permanently unlearnable behind a dangling talent ID.
talentNodeId: chain.talentNodeId && talentNodeById(chain.talentNodeId) talentNodeId: chain.talentNodeId && runtimeTalentNodeById(chain.talentNodeId)
? chain.talentNodeId ? chain.talentNodeId
: undefined, : undefined,
ranks, ranks,
@@ -1612,3 +1651,42 @@ export const WOW335_ABILITIES_BY_CLASS: Readonly<Record<BaseClassId, readonly Ab
.filter((ability): ability is AbilityDefinition => Boolean(ability)) .filter((ability): ability is AbilityDefinition => Boolean(ability))
.sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name)), .sort((left, right) => left.unlockLevel - right.unlockLevel || left.name.localeCompare(right.name)),
]))) as unknown as Readonly<Record<BaseClassId, readonly AbilityDefinition[]>>; ]))) as unknown as Readonly<Record<BaseClassId, readonly AbilityDefinition[]>>;
// 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<Record<number, AbilityDefinition>> =
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,
}]];
});
}))));
+6
View File
@@ -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 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; } .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 { 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 { 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 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; } .encounter-timeline > header strong { overflow: hidden; margin-top: 1px; font: 600 9px Georgia, serif; text-overflow: ellipsis; white-space: nowrap; }
+4 -2
View File
@@ -4,7 +4,7 @@ import { formatDungeonDuration } from "../game/dungeonRunTypes";
import { useGameStore } from "../game/store"; import { useGameStore } from "../game/store";
import "./dungeonRace.css"; import "./dungeonRace.css";
export function DungeonRaceHud() { export function DungeonRaceHud({ region }: { readonly region: "status" | "overlays" }) {
const race = useDungeonRaceStore(); const race = useDungeonRaceStore();
const activeDungeonId = useGameStore((state) => state.activeDungeonId); const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const [now, setNow] = useState(() => Date.now()); 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 elapsed = race.startAt ? Math.max(0, serverNow - race.startAt) : 0;
const shownTime = race.clearDurationMs ?? elapsed; const shownTime = race.clearDurationMs ?? elapsed;
const toast = race.result && race.toastAt && now - race.toastAt < 4_000; const toast = race.result && race.toastAt && now - race.toastAt < 4_000;
return <> if (region === "status") return (
<section className="dungeon-race-hud" aria-label="Dungeon race status"> <section className="dungeon-race-hud" aria-label="Dungeon race status">
<span>Dungeon Race · {race.mode === "practice" ? "Practice" : "Ranked"}</span> <span>Dungeon Race · {race.mode === "practice" ? "Practice" : "Ranked"}</span>
<strong>{formatDungeonDuration(shownTime)}</strong> <strong>{formatDungeonDuration(shownTime)}</strong>
<small>vs {race.opponent.name}{race.opponent.type === "bot" ? ` · ${race.opponent.tier}` : ""} <small>vs {race.opponent.name}{race.opponent.type === "bot" ? ` · ${race.opponent.tier}` : ""}
{race.opponentProgress !== null ? ` · ${race.opponentProgress}%` : ""}</small> {race.opponentProgress !== null ? ` · ${race.opponentProgress}%` : ""}</small>
</section> </section>
);
return <>
{race.status === "preparing" && <div className="dungeon-race-wait" role="status">Waiting for the rival party to be ready…</div>} {race.status === "preparing" && <div className="dungeon-race-wait" role="status">Waiting for the rival party to be ready…</div>}
{remaining !== null && remaining > 0 && race.status === "countdown" && {remaining !== null && remaining > 0 && race.status === "countdown" &&
<div className="dungeon-race-countdown" role="status" aria-live="polite">Race starts in <strong>{remaining}</strong></div>} <div className="dungeon-race-countdown" role="status" aria-live="polite">Race starts in <strong>{remaining}</strong></div>}
+7
View File
@@ -13,8 +13,15 @@ export function EncounterTimeline({ now }: { readonly now: number }) {
const mobs = useCombatStore((state) => state.mobs); const mobs = useCombatStore((state) => state.mobs);
const model = useMemo(() => encounterTimeline(mobs, now), [mobs, now]); const model = useMemo(() => encounterTimeline(mobs, now), [mobs, now]);
if (!model) return null; 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 ( return (
<section className="encounter-timeline" aria-label={`${model.bossName} mechanic timeline`}> <section className="encounter-timeline" aria-label={`${model.bossName} mechanic timeline`}>
<div className="encounter-timeline__boss-health" aria-label={`${model.bossName} health: ${Math.ceil(boss.health)} of ${boss.maxHealth}`}>
<div><span>Boss health</span><strong>{Math.ceil(healthPercent)}%</strong></div>
<div className="encounter-timeline__health-track"><span style={{ width: `${healthPercent}%` }} /></div>
<small>{Math.ceil(boss.health)} / {boss.maxHealth}</small>
</div>
<header><span>Mechanics</span><strong>{model.bossName}</strong></header> <header><span>Mechanics</span><strong>{model.bossName}</strong></header>
<div className="encounter-timeline__rows"> <div className="encounter-timeline__rows">
{model.rows.map((row) => ( {model.rows.map((row) => (
+2
View File
@@ -30,6 +30,7 @@ import { ExperienceBar } from "./ExperienceBar";
import { Minimap } from "./Minimap"; import { Minimap } from "./Minimap";
import { PlayerStatusPanel } from "./PlayerStatusPanel"; import { PlayerStatusPanel } from "./PlayerStatusPanel";
import { EncounterTimeline } from "./EncounterTimeline"; import { EncounterTimeline } from "./EncounterTimeline";
import { DungeonRaceHud } from "./DungeonRaceHud";
import { PartyFrames } from "./PartyFrames"; import { PartyFrames } from "./PartyFrames";
import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination"; import { ResurrectionDestination, useResurrectionCheckpoints } from "./ResurrectionDestination";
import type { ResurrectionCheckpoint } from "../game/resurrectionCheckpoints"; import type { ResurrectionCheckpoint } from "../game/resurrectionCheckpoints";
@@ -441,6 +442,7 @@ export function Hud({ surface = "top", onLayoutChange, completion, onFinish, onR
) : null} ) : null}
<div className="hud-top-right"> <div className="hud-top-right">
<DungeonRaceHud region="status" />
<EditableHudElement id="minimap"><Minimap <EditableHudElement id="minimap"><Minimap
position={playerPosition} position={playerPosition}
yaw={cameraYaw} yaw={cameraYaw}
+2 -2
View File
@@ -1,4 +1,4 @@
.dungeon-race-hud{position:absolute;z-index:42;top:8px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:1px;min-width:180px;padding:5px 14px;border:1px solid #a79261;border-radius:9px;background:#091217e8;color:#f5e8ca;text-align:center;pointer-events:none;box-shadow:0 5px 18px #0009} .dungeon-race-hud{position:absolute;z-index:42;top:0;right:calc(100% + 8px);display:flex;flex-direction:column;align-items:center;gap:1px;min-width:180px;padding:5px 14px;border:1px solid #a79261;border-radius:9px;background:#091217e8;color:#f5e8ca;text-align:center;pointer-events:none;box-shadow:0 5px 18px #0009}
.dungeon-race-hud span{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:#c2ac78} .dungeon-race-hud span{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:#c2ac78}
.dungeon-race-hud strong{font-variant-numeric:tabular-nums;font-size:21px;line-height:1.1} .dungeon-race-hud strong{font-variant-numeric:tabular-nums;font-size:21px;line-height:1.1}
.dungeon-race-hud small{font-size:10px;white-space:nowrap} .dungeon-race-hud small{font-size:10px;white-space:nowrap}
@@ -7,7 +7,7 @@
.dungeon-race-toast{position:absolute;z-index:65;top:74px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;padding:9px 18px;border:1px solid #a79261;border-radius:9px;background:#101516f2;color:#ffe4a3;font-size:22px;font-weight:700;pointer-events:none} .dungeon-race-toast{position:absolute;z-index:65;top:74px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;padding:9px 18px;border:1px solid #a79261;border-radius:9px;background:#101516f2;color:#ffe4a3;font-size:22px;font-weight:700;pointer-events:none}
.dungeon-race-toast--loss{border-color:#bd6b63;color:#ffb2a8} .dungeon-race-toast--loss{border-color:#bd6b63;color:#ffb2a8}
.dungeon-race-toast small{font-size:11px;color:#f5e8ca} .dungeon-race-toast small{font-size:11px;color:#f5e8ca}
@media(max-width:650px){.dungeon-race-hud{top:4px;min-width:145px;padding:3px 8px}.dungeon-race-hud strong{font-size:17px}.dungeon-race-toast{top:64px}} @media(max-width:650px){.dungeon-race-hud{min-width:145px;padding:3px 8px}.dungeon-race-hud strong{font-size:17px}.dungeon-race-toast{top:64px}}
.dungeon-race-screen{min-height:100%;padding:16px;color:#f5e8ca} .dungeon-race-screen{min-height:100%;padding:16px;color:#f5e8ca}
.dungeon-race-screen__header{display:flex;align-items:center;gap:18px}.dungeon-race-screen__header h1{margin:2px 0;font-size:25px}.dungeon-race-screen__header button{margin-left:auto} .dungeon-race-screen__header{display:flex;align-items:center;gap:18px}.dungeon-race-screen__header h1{margin:2px 0;font-size:25px}.dungeon-race-screen__header button{margin-left:auto}
.dungeon-race-screen__layout{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(280px,.9fr);gap:18px;margin-top:16px} .dungeon-race-screen__layout{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(280px,.9fr);gap:18px;margin-top:16px}
+3 -2
View File
@@ -31,8 +31,9 @@ const mainMenuSource = readFileSync(
); );
describe("gameplay panel loading integration", () => { describe("gameplay panel loading integration", () => {
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("preloadTalentGameplayResources(talentClassId)");
expect(appSource).toContain('preloadGameplay={phase === "game"}');
expect(talentResourcesSource).toContain("loadTalentUiCatalog(classId)"); expect(talentResourcesSource).toContain("loadTalentUiCatalog(classId)");
expect(talentResourcesSource).toContain("loadTalentGameplayPanel()"); expect(talentResourcesSource).toContain("loadTalentGameplayPanel()");
expect(talentResourcesSource).toContain("image.decode()"); expect(talentResourcesSource).toContain("image.decode()");
@@ -44,7 +45,7 @@ describe("gameplay panel loading integration", () => {
expect(gameplayPanelsSource).not.toContain("<Suspense fallback={null}><TalentGameplayPanel />"); expect(gameplayPanelsSource).not.toContain("<Suspense fallback={null}><TalentGameplayPanel />");
}); });
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("preloadSharedGameplayResources(originalMediaEnabled)");
expect(appSource).toContain("getLoadedGameRuntime()"); expect(appSource).toContain("getLoadedGameRuntime()");
expect(gameplayResourcesSource).toContain('import("../GameRuntime")'); expect(gameplayResourcesSource).toContain('import("../GameRuntime")');