Release Healer Man 0.1.59
This commit is contained in:
1 parent
11934a3979
commit
ab1434a3b8
44 files changed
+1242
-103
No files matched your search
+10
-6
@@ -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
@@ -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 />}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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")!;
|
||||
|
||||
@@ -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}}}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
}]];
|
||||
});
|
||||
}))));
|
||||
@@ -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,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>}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")');
|
||||
|
||||
Reference in new issue
Block a user