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
+1240 -101

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