Release Healer Man 0.1.59

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

No files matched your search

+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);
});
+40 -10
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) => [
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[]>>;
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),
])));
}).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")');