Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 17:51:56 -04:00
1 parent 9c5fb4c614
commit bd073fb00f
113 files changed
+4902 -273

No files matched your search

+3
View File
@@ -15,6 +15,7 @@ import {
} from "./ui/gameplayResources";
import { useCompanionCooldownPolicyStore } from "./game/companionCooldownPolicyStore";
import { useCompanionSpecReadinessStore } from "./game/companionSpecReadinessStore";
import { usePartyBuffPolicyStore } from "./game/partyBuffPolicyStore";
const GameRuntime = lazy(loadGameRuntime);
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
@@ -76,9 +77,11 @@ export default function App() {
const PreloadedGameRuntime = getLoadedGameRuntime();
const refreshCooldownPolicy = useCompanionCooldownPolicyStore((state) => state.refresh);
const refreshSpecReadiness = useCompanionSpecReadinessStore((state) => state.refresh);
const refreshPartyBuffPolicy = usePartyBuffPolicyStore((state) => state.refresh);
useEffect(() => { void refreshCooldownPolicy(session); }, [refreshCooldownPolicy, session]);
useEffect(() => { void refreshSpecReadiness(session); }, [refreshSpecReadiness, session]);
useEffect(() => { void refreshPartyBuffPolicy(session); }, [refreshPartyBuffPolicy, session]);
useEffect(() => {
if (!SHELVED_MODES_ENABLED && isShelvedShellPhase(phase)) {
+8
View File
@@ -23,6 +23,8 @@ import { SceneErrorBoundary } from "./ui/SceneErrorBoundary";
import { OnlineSessionBridge } from "./game/OnlineSessionBridge";
import { DungeonRunCheckpointBridge } from "./game/DungeonRunCheckpointBridge";
import { DungeonRunBridge } from "./game/DungeonRunBridge";
import { DungeonRaceBridge } from "./game/DungeonRaceBridge";
import { DungeonRaceHud } from "./ui/DungeonRaceHud";
import { DungeonRunResults } from "./ui/DungeonRunResults";
import { DungeonEncounterEffectBridge } from "./game/DungeonEncounterEffectBridge";
import { interactWithActiveDungeonObject } from "./game/dungeonWorldInteraction";
@@ -38,6 +40,7 @@ import { DungeonBossIntro } from "./ui/DungeonBossIntro";
import { useDungeonRouteStore } from "./game/dungeonRouteStore";
import { installCombatClock } from "./game/combatClock";
import { dungeonSimulationNow } from "./game/dungeonSimulationStore";
import { useDungeonRaceStore } from "./app/dungeonRaceStore";
function InputBridge() {
const overlay = useGameStore((state) => state.overlay);
@@ -47,6 +50,7 @@ function InputBridge() {
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
inputSuspended: () => useDungeonSimulationStore.getState().sessionMode !== "play"
|| useGameStore.getState().dungeonIntroActive
|| useGameStore.getState().raceStartBlocked
|| ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
getBindings: (device) => {
const preferences = useUiLayoutStore.getState().preferences;
@@ -110,6 +114,8 @@ export default function GameRuntime() {
const editingControls = useGameStore((state) => state.overlay === "keybinds" || state.overlay === "ui-layout");
const dungeonSimulation = useDungeonSimulationStore((state) => state.sessionMode !== "play");
useEffect(() => () => {
const race = useDungeonRaceStore.getState();
if (race.inGame && ["preparing", "countdown", "active"].includes(race.status)) void race.leave();
const activeDungeon = requireDungeonDefinition(useGameStore.getState().activeDungeonId);
clearDungeonRuntimeCaches();
releaseDungeonGltfCache(activeDungeon);
@@ -135,10 +141,12 @@ export default function GameRuntime() {
<CombatBridge />
<PartyBridge />
<DungeonRunBridge />
<DungeonRaceBridge />
<BossTestingBridge />
<ShelvedGameBridges />
<SceneErrorBoundary><GameScene /></SceneErrorBoundary>
{dungeonSimulation ? <DungeonSimulationHud /> : <Hud />}
{!dungeonSimulation && <DungeonRaceHud />}
{!dungeonSimulation && <DungeonBossIntro />}
{!dungeonSimulation && <MapPanel />}
{!dungeonSimulation && <GameplayPanels />}
+30 -2
View File
@@ -4,6 +4,7 @@ import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
} from "../game/dungeonContentSource";
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
import { DEFAULT_DUNGEON_QUEUE_POLICY } from "../game/dungeonQueuePolicy";
import { canStartAnotherBiteSizedDungeon, startAnotherBiteSizedDungeon } from "./biteSizedDungeonRuntime";
import { useOnlineGroupStore } from "./onlineGroupStore";
import { useOnlineSessionStore } from "./onlineSessionStore";
@@ -38,6 +39,29 @@ afterEach(() => {
});
describe("bite-sized replay", () => {
it("does not replay a disabled randomized location", async () => {
const enterDungeon = vi.fn(() => true);
useShellStore.setState({
session: { kind: "offline", ownerId: "local-owner", displayName: "Tester" },
activeDungeonRole: "healer",
enterDungeon,
setNotice: vi.fn(),
});
await expect(startAnotherBiteSizedDungeon(
{ ...progress, dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID },
{
random: () => 0,
loadPolicy: async () => ({ schemaVersion: 1, disabled: {
regular: [], biteSized: [], randomizedBiteSized: [RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID],
} }),
createRunId: () => "unused",
contentIsUsable: () => true,
prepareRandomizedContent: vi.fn(),
},
)).resolves.toBe(false);
expect(enterDungeon).not.toHaveBeenCalled();
});
it("starts a fresh local run and treats the local player as party leader", async () => {
const enterDungeon = vi.fn(() => true);
const saveCheckpoint = vi.fn();
@@ -51,12 +75,14 @@ describe("bite-sized replay", () => {
expect(canStartAnotherBiteSizedDungeon()).toBe(true);
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
expect(saveCheckpoint).toHaveBeenCalledWith(null);
expect(enterDungeon).toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID, "healer", {
expect(enterDungeon).toHaveBeenCalledWith(expect.stringMatching(/^bite-sized-/), "healer", {
difficultyId: "normal",
bossDifficulty: 1,
freshRun: true,
runId: expect.any(String),
});
expect(enterDungeon).not.toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID,
expect.anything(), expect.anything());
});
it("replays randomized mode with a new seed and prepares that seed's creature assets", async () => {
@@ -73,6 +99,7 @@ describe("bite-sized replay", () => {
await expect(startAnotherBiteSizedDungeon(randomizedProgress, {
random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
createRunId: () => "next-randomized-seed",
contentIsUsable: () => true,
prepareRandomizedContent,
@@ -114,7 +141,7 @@ describe("bite-sized replay", () => {
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
expect(startActivity).toHaveBeenCalledWith(expect.objectContaining({ ownerId: "leader" }), {
type: "dungeon",
contentId: BITE_SIZED_STOCKADES_DUNGEON_ID,
contentId: expect.stringMatching(/^bite-sized-/),
difficultyId: "normal",
bossDifficulty: 1,
partySize: 4,
@@ -140,6 +167,7 @@ describe("bite-sized replay", () => {
await expect(startAnotherBiteSizedDungeon(
{ ...progress, dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID },
{ random: () => 0, createRunId: () => "unused-local-seed", contentIsUsable: () => true,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
prepareRandomizedContent: vi.fn() },
)).resolves.toBe(true);
+7 -2
View File
@@ -1,11 +1,13 @@
import { dungeonContentIsUsable } from "../content/contentManager";
import { randomDungeonByCategory, randomRandomizedBiteSizedDungeon } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue, type DungeonQueuePolicy } from "../game/dungeonQueuePolicy";
import { isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource";
import { dungeonDefinitionById } from "../game/dungeonRegistry";
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
import { useOnlineGroupStore } from "./onlineGroupStore";
import { useOnlineSessionStore } from "./onlineSessionStore";
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
import { loadDungeonQueuePolicy } from "./dungeonQueuePolicyStore";
import { useShellStore } from "./shellStore";
export function canStartAnotherBiteSizedDungeon(): boolean {
@@ -18,6 +20,7 @@ export function canStartAnotherBiteSizedDungeon(): boolean {
interface BiteSizedReplayDependencies {
readonly random: () => number;
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
readonly createRunId: () => string;
readonly contentIsUsable: (dungeonId: string) => boolean;
readonly prepareRandomizedContent: (seed: string) => Promise<void>;
@@ -25,6 +28,7 @@ interface BiteSizedReplayDependencies {
const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
random: Math.random,
loadPolicy: loadDungeonQueuePolicy,
createRunId: () => globalThis.crypto.randomUUID(),
contentIsUsable: dungeonContentIsUsable,
prepareRandomizedContent: ensureRandomizedBiteSizedRunContent,
@@ -45,11 +49,12 @@ export async function startAnotherBiteSizedDungeon(
}
const randomized = isRandomizedBiteSizedDungeonId(progress.dungeonId);
const policy = await dependencies.loadPolicy();
const next = randomized
? randomRandomizedBiteSizedDungeon(progress.dungeonId, dependencies.random,
(dungeon) => dependencies.contentIsUsable(dungeon.id))
(dungeon) => dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon))
: randomDungeonByCategory("bite-sized", progress.dungeonId, dependencies.random,
(dungeon) => dependencies.contentIsUsable(dungeon.id));
(dungeon) => dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon));
if (!next) {
shell.setNotice(randomized
? "No Randomized Bite-Sized location is ready to enter."
+113
View File
@@ -0,0 +1,113 @@
import { create } from "zustand";
import { requestOnlineJson } from "./onlineAccountClient";
import type { PlayerSession } from "./types";
import {
DEFAULT_DUNGEON_QUEUE_POLICY,
normalizeDungeonQueuePolicy,
type DungeonQueueMode,
type DungeonQueuePolicy,
} from "../game/dungeonQueuePolicy";
interface PublishedPolicy {
readonly revision: number;
readonly updatedAt: number | null;
readonly updatedBy?: string | null;
readonly config: DungeonQueuePolicy;
}
interface PolicyState extends PublishedPolicy {
readonly draft: DungeonQueuePolicy;
readonly loading: boolean;
readonly saving: boolean;
readonly dirty: boolean;
readonly error: string | null;
refresh: (session: PlayerSession | null) => Promise<void>;
setEnabled: (mode: DungeonQueueMode, dungeonId: string, enabled: boolean) => void;
resetDraft: () => void;
publish: (session: PlayerSession | null) => Promise<boolean>;
}
const CACHE_KEY = "healer-man.dungeon-queue-policy.v1";
function cached(): PublishedPolicy {
try {
const value = JSON.parse(globalThis.localStorage?.getItem(CACHE_KEY) ?? "null") as PublishedPolicy;
if (!Number.isInteger(value.revision)) throw new Error("missing");
return { ...value, config: normalizeDungeonQueuePolicy(value.config) };
} catch {
return { revision: 0, updatedAt: null, config: DEFAULT_DUNGEON_QUEUE_POLICY };
}
}
function cache(value: PublishedPolicy): void {
try { globalThis.localStorage?.setItem(CACHE_KEY, JSON.stringify(value)); } catch { /* offline cache is optional */ }
}
function normalized(value: PublishedPolicy): PublishedPolicy {
return { ...value, config: normalizeDungeonQueuePolicy(value.config) };
}
/** Fetch for each random roll so a newly published GM setting is used without a reload. */
export async function loadDungeonQueuePolicy(): Promise<DungeonQueuePolicy> {
try {
const value = normalized(await requestOnlineJson<PublishedPolicy>("/api/dungeon-queue-policy"));
cache(value);
useDungeonQueuePolicyStore.setState((state) => state.dirty ? state : { ...value, draft: value.config });
return value.config;
} catch {
return cached().config;
}
}
const initial = cached();
export const useDungeonQueuePolicyStore = create<PolicyState>((set, get) => ({
...initial,
draft: initial.config,
loading: false,
saving: false,
dirty: false,
error: null,
refresh: async (session) => {
set({ loading: true, error: null });
try {
const gm = Boolean(session?.accessToken && session.roles?.includes("gm"));
const value = normalized(await requestOnlineJson<PublishedPolicy>(
gm ? "/api/gm/dungeon-queue-policy" : "/api/dungeon-queue-policy",
{}, gm ? session?.accessToken : undefined,
));
cache(value);
set({ ...value, draft: value.config, loading: false, dirty: false });
} catch (error) {
set({ loading: false, error: error instanceof Error ? error.message : "Could not load dungeon queue settings." });
}
},
setEnabled: (mode, dungeonId, enabled) => set((state) => {
const ids = new Set(state.draft.disabled[mode]);
if (enabled) ids.delete(dungeonId);
else ids.add(dungeonId);
return {
draft: { schemaVersion: 1, disabled: { ...state.draft.disabled, [mode]: [...ids].sort() } },
dirty: true,
error: null,
};
}),
resetDraft: () => set((state) => ({ draft: state.config, dirty: false, error: null })),
publish: async (session) => {
if (!session?.accessToken || !session.roles?.includes("gm")) {
set({ error: "Game Master access is required." });
return false;
}
set({ saving: true, error: null });
try {
const value = normalized(await requestOnlineJson<PublishedPolicy>("/api/gm/dungeon-queue-policy", {
method: "PUT", body: JSON.stringify({ expectedRevision: get().revision, config: get().draft }),
}, session.accessToken));
cache(value);
set({ ...value, draft: value.config, saving: false, dirty: false });
return true;
} catch (error) {
set({ saving: false, error: error instanceof Error ? error.message : "Could not publish dungeon queue settings." });
return false;
}
},
}));
+39
View File
@@ -0,0 +1,39 @@
import catalog from "../game/dungeonRaceBotCatalog.json";
export type RaceBotTier = "novice" | "regular" | "veteran" | "elite";
export type RaceBotSource = "wow" | "rom";
export interface RaceBot {
readonly type: "bot";
readonly name: string;
readonly tier: RaceBotTier;
readonly source: RaceBotSource;
readonly roster: readonly { readonly name: string; readonly classId: string; readonly role: string }[];
readonly finishMs: number;
readonly milestones: readonly { readonly atMs: number; readonly progress: number; readonly label: string }[];
}
export function hashRaceSeed(value: string): number {
let hash = 2166136261;
for (const char of value) { hash ^= char.charCodeAt(0); hash = Math.imul(hash, 16777619); }
return hash >>> 0;
}
export function createRaceBot(seed: string, tierId: RaceBotTier, source: RaceBotSource): RaceBot {
const tier = catalog.tiers.find((entry) => entry.id === tierId) ?? catalog.tiers[1];
const variance = 0.92 + (hashRaceSeed(`${seed}:pace`) % 161) / 1000;
return { type: "bot", name: `${tier.label} ${source === "rom" ? "RuneWaker" : "Azeroth"} Team`,
tier: tier.id as RaceBotTier, source, roster: catalog.teams[source],
finishMs: Math.round(catalog.baselineMs * tier.multiplier * variance),
milestones: catalog.milestones.map((entry) => ({ ...entry,
atMs: Math.round(catalog.baselineMs * tier.multiplier * variance * entry.at) })) };
}
export function raceBotProgress(elapsedMs: number, bot: RaceBot): number {
if (elapsedMs <= 0) return 0;
const previous = [{ atMs: 0, progress: 0 }, ...bot.milestones]
.filter((entry) => entry.atMs <= elapsedMs).at(-1)!;
const next = bot.milestones.find((entry) => entry.atMs > elapsedMs);
if (!next) return 100;
return Math.floor(previous.progress + (next.progress - previous.progress)
* (elapsedMs - previous.atMs) / (next.atMs - previous.atMs));
}
+232
View File
@@ -0,0 +1,232 @@
import { create } from "zustand";
import { requestOnlineJson } from "./onlineAccountClient";
import { useShellStore } from "./shellStore";
import type { CharacterProfile, PlayerSession } from "./types";
import { dungeonById, dungeonsByCategory, randomizedBiteSizedDungeons } from "../game/dungeonCatalog";
import { dungeonContentPackId } from "../game/dungeonContentSource";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
import { createRaceBot, raceBotProgress, type RaceBot, type RaceBotSource, type RaceBotTier } from "./dungeonRaceBot";
import type { PartyRole } from "../game/partyRoles";
export type RacePool = "authored" | "randomized";
export type RaceStatus = "idle" | "queued" | "preparing" | "countdown" | "active" | "settled" | "void";
export interface RaceHumanOpponent { readonly type: "human"; readonly name: string;
readonly source: "wow" | "rom"; readonly ready: boolean; readonly finished: boolean }
export type RaceOpponent = RaceHumanOpponent | RaceBot;
export interface RaceServerState {
readonly status: RaceStatus;
readonly serverTime: number;
readonly joinedAt?: number;
readonly botAt?: number;
readonly matchId?: string;
readonly seed?: string;
readonly dungeonId?: string | null;
readonly difficultyId?: string;
readonly startAt?: number | null;
readonly prepareDeadline?: number;
readonly ready?: boolean;
readonly opponent?: RaceOpponent | null;
readonly opponentProgress?: number | null;
readonly result?: "win" | "loss" | null;
readonly clearDurationMs?: number | null;
}
interface RaceState {
mode: "online" | "practice" | null;
status: RaceStatus;
matchId: string | null;
seed: string | null;
dungeonId: string | null;
difficultyId: string | null;
startAt: number | null;
serverOffset: number;
opponent: RaceOpponent | null;
opponentProgress: number | null;
result: "win" | "loss" | null;
clearDurationMs: number | null;
ready: boolean;
inGame: boolean;
preparingContent: boolean;
toastAt: number | null;
error: string;
queueAt: number | null;
botAt: number | null;
startOnline: (session: PlayerSession, character: CharacterProfile, role: PartyRole,
pool: RacePool, dungeonId?: string) => Promise<void>;
startPractice: (character: CharacterProfile, role: PartyRole, dungeonId: string,
tier: RaceBotTier, source: RaceBotSource) => Promise<void>;
sync: () => Promise<void>;
readyUp: () => Promise<void>;
reportClear: (bossIds: readonly string[]) => Promise<void>;
tick: () => void;
leave: () => Promise<void>;
reset: () => void;
}
let sessionToken: string | null = null;
let selectedRole: PartyRole = "healer";
let pollTimer: ReturnType<typeof setInterval> | null = null;
let preparingSeed: string | null = null;
let readyPending = false;
let finishPromise: Promise<void> | null = null;
function stopPolling() { if (pollTimer) clearInterval(pollTimer); pollTimer = null; }
function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "The race could not continue."; }
function choosePracticeDungeon(pool: RacePool): string | null {
const entries = pool === "randomized" ? randomizedBiteSizedDungeons() : dungeonsByCategory("bite-sized");
return entries.filter((entry) => entry.available)[0]?.id ?? null;
}
async function prepareContent(dungeonId: string, seed: string): Promise<void> {
if (dungeonId.startsWith("randomized-bite-sized-")) await ensureRandomizedBiteSizedRunContent(seed);
else if (!dungeonContentIsUsable(dungeonId)) await installContentPack(dungeonContentPackId(dungeonId));
if (!dungeonContentIsUsable(dungeonId)) throw new Error("The dungeon content could not be verified.");
}
export const useDungeonRaceStore = create<RaceState>((set, get) => {
async function enterPreparedRun(dungeonId: string, seed: string, difficultyId?: string) {
if (get().inGame || preparingSeed === seed) return;
preparingSeed = seed;
set({ preparingContent: true, error: "" });
try {
await prepareContent(dungeonId, seed);
if (get().seed !== seed || get().status === "void") return;
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
installDungeonShellRuntime();
const entered = useShellStore.getState().enterDungeon(dungeonId, selectedRole, {
freshRun: true, runId: seed, difficultyId, bossDifficulty: 1, raceStartBlocked: true,
});
if (!entered) throw new Error(useShellStore.getState().notice || "Could not enter the race dungeon.");
set({ inGame: true, preparingContent: false });
} catch (error) {
set({ error: errorMessage(error), preparingContent: false });
if (get().mode === "online") await get().leave();
} finally { preparingSeed = null; }
}
function applyServerState(response: RaceServerState) {
const previous = get();
if (response.status === "idle" && previous.inGame) {
set({ status: "void", error: "The race server restarted. You can finish this dungeon for its rewards, but the race is unranked." });
stopPolling();
return;
}
const result = response.result ?? null;
const offset = response.serverTime - Date.now();
set({ status: response.status, matchId: response.matchId ?? null, seed: response.seed ?? null,
dungeonId: response.dungeonId ?? null, difficultyId: response.difficultyId ?? null,
startAt: response.startAt ?? null,
serverOffset: offset, opponent: response.opponent ?? null,
opponentProgress: response.opponentProgress ?? null, result,
clearDurationMs: response.clearDurationMs ?? null, ready: response.ready ?? false,
queueAt: response.joinedAt ?? null, botAt: response.botAt ?? null,
toastAt: result && result !== previous.result ? Date.now() : previous.toastAt });
if (response.matchId && response.seed && response.dungeonId && response.status !== "void")
void enterPreparedRun(response.dungeonId, response.seed, response.difficultyId);
if (["settled", "void", "idle"].includes(response.status)) stopPolling();
}
return {
mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, startAt: null,
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "",
queueAt: null, botAt: null,
startOnline: async (session, character, role, pool, dungeonId) => {
if (!session.accessToken) throw new Error("Sign in online to enter a ranked race.");
get().reset();
selectedRole = role;
sessionToken = session.accessToken;
set({ mode: "online", status: "queued" });
try {
const response = await requestOnlineJson<RaceServerState>("/api/dungeon-races/queue", {
method: "POST", body: JSON.stringify({ characterId: character.id, role, pool, ...(dungeonId ? { dungeonId } : {}) }),
}, sessionToken);
applyServerState(response);
pollTimer = setInterval(() => { void get().sync(); }, 750);
} catch (error) { set({ status: "idle", error: errorMessage(error) }); }
},
startPractice: async (character, role, dungeonId, tier, source) => {
get().reset();
if (!["wow", "rom"].includes(character.categoryId ?? "wow")) {
set({ error: "Choose a WoW or RoM character for practice." }); return;
}
selectedRole = role;
const resolvedDungeon = dungeonId || choosePracticeDungeon("authored");
if (!resolvedDungeon || !(resolvedDungeon.startsWith("bite-sized-")
|| resolvedDungeon.startsWith("randomized-bite-sized-")) || !dungeonById(resolvedDungeon)?.available) {
set({ error: "No Bite-Sized dungeon is available for practice." }); return;
}
const seed = `practice-${globalThis.crypto.randomUUID()}`;
set({ mode: "practice", status: "preparing", matchId: seed, seed, dungeonId: resolvedDungeon,
difficultyId: null,
opponent: createRaceBot(seed, tier, source) });
await enterPreparedRun(resolvedDungeon, seed);
},
sync: async () => {
if (get().mode !== "online" || !sessionToken) return;
try { applyServerState(await requestOnlineJson<RaceServerState>("/api/dungeon-races/state", {}, sessionToken)); }
catch (error) { set({ error: errorMessage(error) }); }
},
readyUp: async () => {
const state = get();
if (!state.inGame || state.ready || readyPending || !state.matchId) return;
if (state.mode === "practice") {
set({ ready: true, status: "countdown", startAt: Date.now() + 3_000 }); return;
}
if (!sessionToken) return;
readyPending = true;
try { applyServerState(await requestOnlineJson<RaceServerState>(
`/api/dungeon-races/${encodeURIComponent(state.matchId)}/ready`, { method: "POST" }, sessionToken)); }
catch (error) { set({ error: errorMessage(error) }); }
finally { readyPending = false; }
},
reportClear: (bossIds) => {
const state = get();
if (finishPromise) return finishPromise;
if (!state.matchId || !state.startAt || ["void", "idle"].includes(state.status)
|| state.clearDurationMs != null) return Promise.resolve();
if (state.mode === "practice") {
const now = Date.now();
const result = state.result ?? (now < state.startAt + (state.opponent as RaceBot).finishMs ? "win" : "loss");
set({ status: "settled", result, toastAt: state.result ? state.toastAt : now,
clearDurationMs: Math.max(1, now - state.startAt) });
return Promise.resolve();
}
if (!sessionToken) return Promise.resolve();
const matchId = state.matchId;
finishPromise = requestOnlineJson<RaceServerState>(
`/api/dungeon-races/${encodeURIComponent(state.matchId)}/finish`, {
method: "POST", body: JSON.stringify({ runId: state.seed, bossIds }),
}, sessionToken)
.then((response) => { if (get().matchId === matchId) applyServerState(response); })
.catch((error: unknown) => { if (get().matchId === matchId) set({ error: errorMessage(error) }); })
.finally(() => { finishPromise = null; });
return finishPromise;
},
tick: () => {
const state = get();
if (state.mode !== "practice" || !state.startAt || state.result || state.opponent?.type !== "bot") return;
const now = Date.now();
if (now >= state.startAt + state.opponent.finishMs) set({ status: "settled", result: "loss", toastAt: now,
opponentProgress: 100 });
else if (now >= state.startAt) set({ status: "active",
opponentProgress: raceBotProgress(now - state.startAt, state.opponent) });
},
leave: async () => {
stopPolling();
if (get().mode === "online" && sessionToken) {
try { await requestOnlineJson("/api/dungeon-races/queue", { method: "DELETE" }, sessionToken); }
catch (error) { set({ error: errorMessage(error) }); }
}
if (!get().inGame) {
const message = get().error;
get().reset();
set({ error: message });
}
},
reset: () => {
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false;
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, startAt: null,
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "", queueAt: null, botAt: null });
},
};
});
+1
View File
@@ -75,6 +75,7 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
});
return false;
}
if (settings?.raceStartBlocked) useGameStore.setState({ raceStartBlocked: true });
useGameStore.getState().setCompanionOpen(false);
useShellStore.setState({
phase: "game",
+1
View File
@@ -9,6 +9,7 @@ export interface DungeonEntrySettings {
readonly bossDifficulty?: number;
readonly freshRun?: boolean;
readonly runId?: string;
readonly raceStartBlocked?: boolean;
}
let installedRuntime: DungeonShellRuntime | null = null;
+31 -1
View File
@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from "vitest";
import { createDefaultAppearance } from "./characterCatalog";
import type { CharacterProfile, PlayerSession } from "./types";
import type { OnlineGroup } from "./onlineGroupTypes";
import { dungeonById } from "../game/dungeonCatalog";
import { dungeonById, dungeonsByCategory, randomRegularDungeon } from "../game/dungeonCatalog";
import { DEFAULT_DUNGEON_QUEUE_POLICY } from "../game/dungeonQueuePolicy";
import { dungeonContentPackId } from "../game/dungeonContentSource";
import { queueRandomDungeon, type RandomDungeonQueueOptions } from "./randomDungeonQueue";
@@ -37,6 +38,7 @@ describe("random dungeon queue", () => {
const options = queueOptions();
const result = await queueRandomDungeon(options, {
random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => installed,
installPack,
installDungeonRuntime,
@@ -58,6 +60,7 @@ describe("random dungeon queue", () => {
const options = queueOptions({ mode: "bite-sized" });
await expect(queueRandomDungeon(options, {
random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => true,
installPack,
installDungeonRuntime: vi.fn(async () => undefined),
@@ -66,10 +69,35 @@ describe("random dungeon queue", () => {
expect(installPack).not.toHaveBeenCalled();
});
it("skips a disabled full dungeon and rejects a queue with no enabled Bite-Sized locations", async () => {
const first = randomRegularDungeon(undefined, () => 0)!;
const options = queueOptions();
await expect(queueRandomDungeon(options, {
random: () => 0,
loadPolicy: async () => ({ schemaVersion: 1, disabled: { regular: [first.id], biteSized: [], randomizedBiteSized: [] } }),
contentIsUsable: () => true,
installPack: vi.fn(),
installDungeonRuntime: vi.fn(),
})).resolves.toMatchObject({ status: "entered" });
expect(options.enterDungeon).not.toHaveBeenCalledWith(first.id, expect.anything(), expect.anything());
const biteOptions = queueOptions({ mode: "bite-sized" });
await expect(queueRandomDungeon(biteOptions, {
random: () => 0,
loadPolicy: async () => ({ schemaVersion: 1, disabled: { regular: [],
biteSized: dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id), randomizedBiteSized: [] } }),
contentIsUsable: () => true,
installPack: vi.fn(),
installDungeonRuntime: vi.fn(),
})).resolves.toMatchObject({ status: "error", message: expect.stringMatching(/no dungeon/i) });
expect(biteOptions.enterDungeon).not.toHaveBeenCalled();
});
it("does not enter when the downloaded pack cannot be verified", async () => {
const options = queueOptions();
const result = await queueRandomDungeon(options, {
random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => false,
installPack: vi.fn(async () => undefined),
installDungeonRuntime: vi.fn(async () => undefined),
@@ -83,6 +111,7 @@ describe("random dungeon queue", () => {
const options = queueOptions({ session: accountSession, group });
const result = await queueRandomDungeon(options, {
random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => installed,
installPack: vi.fn(async () => { installed = true; }),
installDungeonRuntime: vi.fn(async () => undefined),
@@ -100,6 +129,7 @@ describe("random dungeon queue", () => {
const installPack = vi.fn(async () => undefined);
await expect(queueRandomDungeon(options, {
random: () => 0,
loadPolicy: async () => DEFAULT_DUNGEON_QUEUE_POLICY,
contentIsUsable: () => false,
installPack,
installDungeonRuntime: vi.fn(async () => undefined),
+7 -2
View File
@@ -3,6 +3,8 @@ import type { OnlineGroup, StartOnlineGroupActivityInput } from "./onlineGroupTy
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { dungeonContentPackId } from "../game/dungeonContentSource";
import { randomDungeonByCategory, randomRegularDungeon, type DungeonCatalogEntry } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue, type DungeonQueuePolicy } from "../game/dungeonQueuePolicy";
import { loadDungeonQueuePolicy } from "./dungeonQueuePolicyStore";
import { dungeonDefinitionById } from "../game/dungeonRegistry";
import { classCanFillPartyRole, type PartyRole } from "../game/partyRoles";
import { specializationById } from "../game/specializationCatalog";
@@ -28,6 +30,7 @@ export interface RandomDungeonQueueOptions {
interface RandomDungeonQueueDependencies {
readonly random: () => number;
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
readonly contentIsUsable: (dungeonId: string) => boolean;
readonly installPack: (packId: string) => Promise<void>;
readonly installDungeonRuntime: () => Promise<void>;
@@ -35,6 +38,7 @@ interface RandomDungeonQueueDependencies {
const DEFAULT_DEPENDENCIES: RandomDungeonQueueDependencies = {
random: Math.random,
loadPolicy: loadDungeonQueuePolicy,
contentIsUsable: dungeonContentIsUsable,
installPack: installContentPack,
installDungeonRuntime: async () => {
@@ -72,9 +76,10 @@ export async function queueRandomDungeon(
}
options.onProgress("rolling", null);
const policy = await dependencies.loadPolicy();
const dungeon = options.mode === "regular"
? randomRegularDungeon(undefined, dependencies.random)
: randomDungeonByCategory("bite-sized", undefined, dependencies.random);
? randomRegularDungeon(undefined, dependencies.random, (entry) => dungeonEnabledForQueue(policy, entry))
: randomDungeonByCategory("bite-sized", undefined, dependencies.random, (entry) => dungeonEnabledForQueue(policy, entry));
if (!dungeon) return { status: "error", message: "No dungeon is currently ready for this queue." };
if (!dependencies.contentIsUsable(dungeon.id)) {
options.onProgress("downloading", dungeon);
+1 -1
View File
@@ -185,7 +185,7 @@ export function CombatBridge() {
return;
}
const game = useGameStore.getState();
if (game.dungeonIntroActive) return;
if (game.dungeonIntroActive || game.raceStartBlocked) return;
const sharedOnline = onlineSessionIsActive();
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
advanceOneTick(delta, Date.now(), false);
+32
View File
@@ -0,0 +1,32 @@
import { useEffect } from "react";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { useDungeonRunStore } from "./dungeonRunStore";
import { useGameStore } from "./store";
/** Keeps match readiness and settlement separate from the local boss and loot lifecycle. */
export function DungeonRaceBridge() {
useEffect(() => {
let reportingRun: string | null = null;
const interval = window.setInterval(() => {
const race = useDungeonRaceStore.getState();
if (!race.inGame || !race.seed || !race.dungeonId) return;
const game = useGameStore.getState();
if (game.activeDungeonId !== race.dungeonId) return;
race.tick();
const ready = !game.dungeonIntroActive && ["ready", "proxy"].includes(game.assetStatus);
if (ready && !race.ready && race.status === "preparing") void race.readyUp();
const started = race.startAt !== null && Date.now() + race.serverOffset >= race.startAt;
if (game.raceStartBlocked && started && ready) useGameStore.setState({ raceStartBlocked: false });
if (game.raceStartBlocked && ["void", "idle"].includes(race.status))
useGameStore.setState({ raceStartBlocked: false });
const progress = useDungeonRunStore.getState().progress;
if (started && !["void", "idle"].includes(race.status) && progress?.id === race.seed && progress.finishedAt !== null
&& race.clearDurationMs === null && reportingRun !== race.seed) {
reportingRun = race.seed;
void race.reportClear(progress.killedBossIds).finally(() => { reportingRun = null; });
}
}, 100);
return () => window.clearInterval(interval);
}, []);
return null;
}
+5 -1
View File
@@ -8,6 +8,7 @@ import { usePartyStore } from "./partyStore";
import { useGameStore } from "./store";
import { useDungeonRunStore } from "./dungeonRunStore";
import type { DungeonRunMember } from "./dungeonRunTypes";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
function currentRunParty(): readonly DungeonRunMember[] {
const shell = useShellStore.getState();
@@ -36,6 +37,7 @@ export function DungeonRunBridge() {
const game = useGameStore.getState();
const shell = useShellStore.getState();
if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode || game.dungeonIntroActive
|| game.raceStartBlocked
|| !["ready", "proxy"].includes(game.assetStatus)) return;
const run = useDungeonRunStore.getState();
if (run.progress?.dungeonId !== game.activeDungeonId) return;
@@ -46,7 +48,9 @@ export function DungeonRunBridge() {
useDungeonRunStore.setState({ progress: { ...run.progress, id: activity.id,
startedAt: game.dungeonIntroCompletedAt ?? activity.startedAt, party } });
}
run.start(game.dungeonIntroCompletedAt ?? Date.now(), party);
const race = useDungeonRaceStore.getState();
run.start(race.inGame && race.startAt ? race.startAt - race.serverOffset
: game.dungeonIntroCompletedAt ?? Date.now(), party);
run.advance(activeDungeonCompletedObjectiveIds(game.activeDungeonId),
Object.values(useCombatStore.getState().mobs).filter((mob) => mob.dead && !mob.despawned).map((mob) => mob.id),
Date.now(), party);
+10
View File
@@ -9,11 +9,13 @@ import { onlineGroupAiPartySize, useOnlineGroupStore } from "../app/onlineGroupS
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useCompanionSpecReadinessStore } from "./companionSpecReadinessStore";
import { useCompanionPartyPreferences } from "./companionPartyPreferences";
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "./dungeonContentSource";
/** Owns party lifecycle; the combat interval itself remains in CombatBridge. */
export function PartyBridge() {
const character = useShellStore((state) => state.activeCharacter);
const session = useShellStore((state) => state.session);
const playerRole = useShellStore((state) => state.activeDungeonRole);
const gameMode = useGameStore((state) => state.gameMode);
const manastormPartySize = useManastormStore((state) => state.partySize);
@@ -26,8 +28,11 @@ export function PartyBridge() {
const readinessPolicy = useCompanionSpecReadinessStore((state) => state.config);
const readinessPolicyRevision = useCompanionSpecReadinessStore((state) => state.revision);
const deviceIncludeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
const partyBuffPolicy = usePartyBuffPolicyStore((state) => state.config);
const mountedResetRevision = useRef(resetRevision);
useEffect(() => { void usePartyBuffPolicyStore.getState().refresh(session); }, [session]);
useEffect(() => {
if (!character) return;
const combat = useCombatStore.getState();
@@ -97,6 +102,11 @@ export function PartyBridge() {
resetPartyRuntimeClock();
}, [resetRevision]);
useEffect(() => {
if (!character) return;
useCombatStore.getState().applyDefaultPartyBuffs();
}, [partyBuffPolicy]);
useEffect(() => useCombatStore.subscribe((state, previous) => {
if (state.selectedTargetId && state.selectedTargetId !== previous.selectedTargetId) {
usePartyStore.getState().clearSelection();
+40 -21
View File
@@ -8,6 +8,8 @@ import { clearPartyRuntimeRegistry } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
import { DEFAULT_PARTY_BUFF_POLICY } from "./partyBuffPolicy";
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
function registerTarget(id = "target", distance = 2): void {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 1_000, xpReward: 0 });
@@ -17,6 +19,7 @@ function registerTarget(id = "target", distance = 2): void {
describe("named aura combat runtime", () => {
beforeEach(() => {
usePartyBuffPolicyStore.setState({ config: DEFAULT_PARTY_BUFF_POLICY, draft: DEFAULT_PARTY_BUFF_POLICY });
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
usePartyStore.getState().initializeParty("aura-runtime", 80, 2_000);
@@ -61,7 +64,7 @@ describe("named aura combat runtime", () => {
expect(useCombatStore.getState()).toMatchObject({ health: baseHealth, maxHealth: baseMaxHealth });
});
it("grants learned long-term stat buffs to the full party on load", () => {
it("maintains only GM-selected buffs on the chosen party roles", () => {
useCombatStore.getState().initializeCharacter({
classId: "priest",
specializationId: "priest:holy",
@@ -75,31 +78,47 @@ describe("named aura combat runtime", () => {
const memberMaxHealth = new Map(usePartyStore.getState().members.map((member) => [member.id, member.maxHealth]));
expect(playerMaxHealth).toBe(407);
useCombatStore.getState().applyDefaultPartyBuffs(20_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.exclusiveGroup?.startsWith("gm-party-buff:"))).toBe(false);
usePartyBuffPolicyStore.setState({ config: { schemaVersion: 1, rules: [
{ abilityId: "wow335-priest-power-word-fortitude", scope: "tank" },
] } });
useCombatStore.getState().applyDefaultPartyBuffs(20_000);
const state = useCombatStore.getState();
const targetIds = [PLAYER_AGGRO_ID, ...usePartyStore.getState().members.map((member) => member.id)];
for (const targetId of targetIds) {
expect(state.auras).toContainEqual(expect.objectContaining({
targetId,
definition: expect.objectContaining({ exclusiveGroup: "default-party-buff:fortitude" }),
}));
}
expect(state.maxHealth).toBe(437);
const tank = usePartyStore.getState().members.find((member) => member.role === "tank");
expect(tank).toBeDefined();
expect(state.auras.filter((aura) => aura.definition.exclusiveGroup === "gm-party-buff:fortitude")
.map((aura) => aura.targetId)).toEqual([tank!.id]);
expect(state.maxHealth).toBe(playerMaxHealth);
for (const member of usePartyStore.getState().members) {
expect(member.maxHealth).toBeGreaterThan(memberMaxHealth.get(member.id)!);
expect(member.maxHealth > memberMaxHealth.get(member.id)!).toBe(member.role === "tank");
}
const buffedPlayerMaxHealth = state.maxHealth;
expect(state.auras.filter((aura) => (
aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.exclusiveGroup === "default-party-buff:fortitude"
))).toHaveLength(1);
expect(state.castAbility("wow335-priest-power-word-fortitude", undefined, 21_000).ok).toBe(true);
expect(useCombatStore.getState().maxHealth).toBe(buffedPlayerMaxHealth);
expect(useCombatStore.getState().auras.filter((aura) => (
aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.exclusiveGroup === "default-party-buff:fortitude"
))).toHaveLength(1);
usePartyBuffPolicyStore.setState({ config: { schemaVersion: 1, rules: [
{ abilityId: "wow335-priest-power-word-fortitude", scope: "party" },
] } });
useCombatStore.getState().applyDefaultPartyBuffs(21_000);
const targetIds = [PLAYER_AGGRO_ID, ...usePartyStore.getState().members.map((member) => member.id)];
expect(useCombatStore.getState().auras.filter((aura) => aura.definition.exclusiveGroup === "gm-party-buff:fortitude")
.map((aura) => aura.targetId).sort()).toEqual(targetIds.sort());
expect(useCombatStore.getState().maxHealth).toBe(437);
expect(state.castAbility("wow335-priest-power-word-fortitude", undefined, 21_500).ok).toBe(true);
expect(useCombatStore.getState().auras.filter((aura) => aura.targetId === PLAYER_AGGRO_ID
&& aura.definition.exclusiveGroup === "gm-party-buff:fortitude")).toMatchObject([{ expiresAt: null }]);
usePartyBuffPolicyStore.setState({ config: { schemaVersion: 1, rules: [
{ abilityId: "wow335-priest-power-word-fortitude", scope: "healer" },
] } });
useCombatStore.getState().applyDefaultPartyBuffs(21_750);
const healerTargets = useCombatStore.getState().auras.filter((aura) => aura.definition.exclusiveGroup === "gm-party-buff:fortitude")
.map((aura) => aura.targetId);
expect(healerTargets).toEqual([
...(useCombatStore.getState().gearRole === "healer" ? [PLAYER_AGGRO_ID] : []),
...usePartyStore.getState().members.filter((member) => member.role === "healer").map((member) => member.id),
]);
usePartyBuffPolicyStore.setState({ config: DEFAULT_PARTY_BUFF_POLICY });
useCombatStore.getState().applyDefaultPartyBuffs(22_000);
expect(useCombatStore.getState().auras.some((aura) => aura.definition.exclusiveGroup?.startsWith("gm-party-buff:"))).toBe(false);
expect(useCombatStore.getState().maxHealth).toBe(playerMaxHealth);
});
it("enters the dungeon with spec maintenance and permanent pets already prepared", () => {
+56 -16
View File
@@ -1,4 +1,6 @@
import { create } from "zustand";
import { permanentBuffEffects } from "./partyBuffPolicy";
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
import { combatNow } from "./combatClock";
import { useDungeonSimulationStore } from "./dungeonSimulationStore";
import { useGameStore } from "./store";
@@ -2166,7 +2168,7 @@ function auraDefinitionForAbility(
description: definition.description ?? ability.description,
icon: definition.icon ?? ability.icon,
durationMs,
...(defaultPartyBuffFamily ? {
...(defaultPartyBuffFamily && !definition.exclusiveGroup ? {
uniqueBySource: false,
exclusiveGroup: `default-party-buff:${defaultPartyBuffFamily}`,
} : {}),
@@ -4699,19 +4701,50 @@ export const useCombatStore = create<CombatState>((set, get) => ({
},
applyDefaultPartyBuffs: (now = combatNow()) => {
get().synchronizeActors();
const state = get();
const partyMembers = usePartyStore.getState().members;
set((state) => {
const auras = state.auras.filter((aura) => !aura.definition.exclusiveGroup?.startsWith("gm-party-buff:"));
if (auras.length === state.auras.length) return state;
const maxHealth = auraAdjustedPlayerMaxHealth(state.classId, state.level, state.talentModifiers,
state.gearStats, state.raceId, state.secondaryClassId, auras);
const health = state.health <= 0 ? 0 : clamp(state.health + maxHealth - state.maxHealth, 1, maxHealth);
const actors = { ...state.actors };
if (actors[PLAYER_AURA_ENTITY_ID]) actors[PLAYER_AURA_ENTITY_ID] = {
...actors[PLAYER_AURA_ENTITY_ID], health, maxHealth,
};
for (const member of partyMembers) {
const actor = actors[member.id];
if (!actor) continue;
const base = deriveCharacterStats(member.classId, member.level, member.gearStats, member.raceId);
const delta = auraMaximumHealthBonus(base, auras, member.id)
- auraMaximumHealthBonus(base, state.auras, member.id);
const memberMaxHealth = Math.max(1, actor.maxHealth + delta);
actors[member.id] = {
...actor,
maxHealth: memberMaxHealth,
health: actor.health <= 0 ? 0 : clamp(actor.health + delta, 1, memberMaxHealth),
};
}
return { auras, health, maxHealth, actors };
});
get().synchronizeActors();
const state = get();
const rules = usePartyBuffPolicyStore.getState().config.rules;
const includePlayer = useDungeonSimulationStore.getState().sessionMode === "play";
const sources = [
...(includePlayer ? [{
id: PLAYER_AURA_ENTITY_ID,
classId: state.classId,
role: state.gearRole,
level: state.level,
abilities: state.abilities,
}] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => ({
id: member.id,
classId: member.classId,
role: member.role,
level: member.level,
abilities: abilitiesForClass(member.classId),
abilities: abilitiesForCharacter(member.classId, member.secondaryClassId),
})),
];
const selected = new Map<string, {
@@ -4719,34 +4752,33 @@ export const useCombatStore = create<CombatState>((set, get) => ({
ability: AbilityDefinition;
effects: readonly Extract<AbilityEffect, { kind: "apply-aura" }>[];
potency: number;
scope: "party" | "tank" | "healer";
}>();
for (const source of sources) {
for (const catalogAbility of source.abilities) {
const family = DEFAULT_PARTY_BUFF_FAMILY.get(catalogAbility.name.toLowerCase());
if (!family || catalogAbility.passive || !isAbilityUnlocked(catalogAbility, source.level)) continue;
for (const rule of rules) {
for (const source of sources) {
const catalogAbility = source.abilities.find((ability) => ability.id === rule.abilityId);
if (!catalogAbility || !isAbilityUnlocked(catalogAbility, source.level)) continue;
const ability = abilityAtLevel(catalogAbility, source.level);
const effects = ability.effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
effect.kind === "apply-aura" && effect.aura.disposition === "buff"
));
const effects = permanentBuffEffects(ability);
if (!effects.length) continue;
const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id;
const potency = defaultPartyBuffPotency(effects);
if ((selected.get(family)?.potency ?? Number.NEGATIVE_INFINITY) >= potency) continue;
selected.set(family, { sourceId: source.id, ability, effects, potency });
selected.set(family, { sourceId: source.id, ability, effects, potency, scope: rule.scope });
}
}
const recipients = [
...(includePlayer ? [PLAYER_AURA_ENTITY_ID] : []),
...partyMembers.filter((member) => member.health > 0).map((member) => member.id),
];
for (const [family, buff] of selected) {
const recipients = sources.filter((source) => buff.scope === "party" || source.role === buff.scope)
.map((source) => source.id);
for (const recipientId of recipients) {
for (const effect of buff.effects) {
get().applyActorAura(buff.sourceId, recipientId, buff.ability, {
...effect,
aura: {
...effect.aura,
durationMs: null,
uniqueBySource: false,
exclusiveGroup: `default-party-buff:${family}`,
exclusiveGroup: `gm-party-buff:${family}`,
},
}, now);
}
@@ -6040,6 +6072,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
: [targetId ?? PLAYER_AURA_ENTITY_ID];
for (const recipientId of recipients.filter((id) => !effect.excludeCaster || id !== PLAYER_AURA_ENTITY_ID)) {
const previousAuras = work.auras;
const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id;
if (previousAuras.some((aura) => aura.targetId === recipientId
&& aura.definition.exclusiveGroup === `gm-party-buff:${family}`)) continue;
if (effect.control && work.mobs[recipientId]?.characterDefender && work.mobs[recipientId].mechanicImmunities.includes(effect.control.kind as AzerothCoreMechanic)) continue;
const auraResult = applyCombatAura(previousAuras, {
definition: auraDefinition,
@@ -7396,6 +7431,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
const state = get();
if (!state.actors[sourceActorId] || (!state.actors[targetActorId] && !state.mobs[targetActorId])) return false;
if (ability.name === "Power Word: Shield" && hasWeakenedSoul(state.auras, targetActorId, now)) return false;
const family = DEFAULT_PARTY_BUFF_FAMILY.get(ability.name.toLowerCase()) ?? ability.id;
const gmGroup = `gm-party-buff:${family}`;
if (effect.aura.exclusiveGroup !== gmGroup && state.auras.some((aura) => (
aura.targetId === targetActorId && aura.definition.exclusiveGroup === gmGroup
))) return true;
const durationMs = effect.aura.durationMs;
const definition = auraDefinitionForAbility(effect.aura, ability, durationMs);
const applied = applyCombatAura(state.auras, {
+32
View File
@@ -0,0 +1,32 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry";
import { dungeonArtworkContains, dungeonArtworkFloorFor, dungeonArtworkHeading, dungeonArtworkMapFor, dungeonArtworkPoint } from "./dungeonArtworkMap";
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(30);
for (const dungeon of dungeons) {
const map = dungeonArtworkMapFor(dungeon);
expect(map, dungeon.id).not.toBeNull();
expect(map?.kind, dungeon.id).toBe("runewaker");
if (!map) continue;
const floor = dungeonArtworkFloorFor(map, dungeon.entrance.footPosition);
const image = readFileSync(`public${floor.image}`);
expect(image.toString("ascii", 1, 4), dungeon.id).toBe("PNG");
expect([image.readUInt32BE(16), image.readUInt32BE(20)]).toEqual([1002, 668]);
expect(createHash("sha256").update(image).digest("hex")).toBe(floor.checksum);
expect(dungeonArtworkContains(map, floor, dungeon.entrance.footPosition), dungeon.id).toBe(true);
expect(dungeonArtworkPoint(map, floor, dungeon.entrance.footPosition).every(Number.isFinite)).toBe(true);
expect(Math.hypot(...dungeonArtworkHeading(map, floor, dungeon.entrance.footPosition, 0.7))).toBeCloseTo(23, 5);
}
});
it("rejects stale maps when a dungeon environment changes", () => {
const dungeon = requireDungeonDefinition("forsaken-abbey");
expect(dungeonArtworkMapFor({ ...dungeon, provenance: { ...dungeon.provenance, coordinateTransform: "changed" } })).toBeNull();
expect(dungeonArtworkMapFor({ ...dungeon, assets: { ...dungeon.assets, visual: [] } })).toBeNull();
});
});
+78
View File
@@ -0,0 +1,78 @@
import runewakerCatalog from "./generated/runewakerDungeonMaps.generated.json";
import type { DungeonDefinition, Vector3Tuple } from "./dungeonTypes";
import {
wowDungeonMapFor,
wowMapFloorFor,
wowMapPoint,
type WowDungeonMap,
type WowMapFloor,
} from "./wowDungeonMap";
export interface RunewakerDungeonMap {
readonly mapId: number;
readonly coordinateTransform: string;
readonly visualChecksum: string;
readonly kind: "client" | "navigation";
readonly image: string;
readonly checksum: string;
readonly sourceHash: string;
/** Two affine rows converting runtime XYZ directly to image pixels. */
readonly runtimeToMap: readonly number[];
readonly width: number;
readonly height: number;
}
export interface ArtworkFloor {
readonly level: number;
readonly name: string;
readonly image: string;
readonly checksum: string;
}
export type DungeonArtworkMap =
| { readonly kind: "wow"; readonly source: WowDungeonMap; readonly floors: readonly WowMapFloor[]; readonly width: 1002; readonly height: 668 }
| { readonly kind: "runewaker"; readonly source: RunewakerDungeonMap; readonly floors: readonly ArtworkFloor[]; readonly width: number; readonly height: number };
const runewakerMaps = runewakerCatalog.dungeons as unknown as Readonly<Record<string, RunewakerDungeonMap>>;
export function dungeonArtworkMapFor(dungeon: DungeonDefinition): DungeonArtworkMap | null {
const wow = wowDungeonMapFor(dungeon);
if (wow) return { kind: "wow", source: wow, floors: wow.floors, width: 1002, height: 668 };
const source = runewakerMaps[dungeon.id];
if (source?.mapId !== dungeon.mapId
|| source.coordinateTransform !== dungeon.provenance.coordinateTransform
|| source.visualChecksum !== dungeon.assets.visual[0]?.checksum
|| source.runtimeToMap.length !== 8) return null;
return {
kind: "runewaker",
source,
floors: [{ level: 0, name: source.kind === "client" ? "Original dungeon map" : "Dungeon floorplan",
image: source.image, checksum: source.checksum }],
width: source.width,
height: source.height,
};
}
export function dungeonArtworkFloorFor(map: DungeonArtworkMap, position: Vector3Tuple, level?: number | null): ArtworkFloor {
return map.kind === "wow" ? wowMapFloorFor(map.source, position, level) : map.floors[0];
}
export function dungeonArtworkPoint(map: DungeonArtworkMap, floor: ArtworkFloor, position: Vector3Tuple): readonly [number, number] {
if (map.kind === "wow") return wowMapPoint(map.source, floor as WowMapFloor, position);
const matrix = map.source.runtimeToMap;
return [matrix[0] * position[0] + matrix[1] * position[1] + matrix[2] * position[2] + matrix[3],
matrix[4] * position[0] + matrix[5] * position[1] + matrix[6] * position[2] + matrix[7]];
}
export function dungeonArtworkContains(map: DungeonArtworkMap, floor: ArtworkFloor, position: Vector3Tuple): boolean {
const [x, y] = dungeonArtworkPoint(map, floor, position);
return Number.isFinite(x) && Number.isFinite(y) && x >= 0 && x <= map.width && y >= 0 && y <= map.height;
}
export function dungeonArtworkHeading(map: DungeonArtworkMap, floor: ArtworkFloor, position: Vector3Tuple, yaw: number): readonly [number, number] {
const [x, y] = dungeonArtworkPoint(map, floor, position);
const [targetX, targetY] = dungeonArtworkPoint(map, floor,
[position[0] + Math.sin(yaw), position[1], position[2] + Math.cos(yaw)]);
const length = Math.hypot(targetX - x, targetY - y) || 1;
return [(targetX - x) / length * 23, (targetY - y) / length * 23];
}
+22 -8
View File
@@ -13,10 +13,11 @@ import { DUNGEON_MANIFEST } from "./dungeonManifest";
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
import { compareDungeonReleaseOrder } from "./dungeonReleaseStage";
import availability from "./generated/dungeonAvailability.json";
import queuePolicyCatalog from "./generated/dungeonLeaderboardCatalog.generated.json";
describe("dungeon catalog", () => {
it("lists the complete campaign plus both bite-sized Stockades variants", () => {
expect(DUNGEON_CATALOG).toHaveLength(86);
it("lists the complete campaign and bite-sized variants", () => {
expect(DUNGEON_CATALOG).toHaveLength(89);
expect(DUNGEON_CATALOG.find((dungeon) => dungeon.id === "stormwind-stockade")).toMatchObject({
id: "stormwind-stockade",
releaseStage: "beta",
@@ -51,13 +52,14 @@ describe("dungeon catalog", () => {
expect.objectContaining({ id: "forsaken-abbey" }),
);
expect(dungeonsByCategory("rom")).toHaveLength(30);
expect(dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id))
.toEqual(["bite-sized-stormwind-stockade"]);
expect(dungeonsByCategory("bite-sized").map((dungeon) => dungeon.id).sort())
.toEqual(["bite-sized-deadmines-ship", "bite-sized-stormwind-stockade",
"bite-sized-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns"]);
});
it("selects another available category dungeon and falls back to the sole bite-sized entry", () => {
it("selects another available category dungeon", () => {
expect(randomDungeonByCategory("bite-sized", "bite-sized-stormwind-stockade", () => 0)?.id)
.toBe("bite-sized-stormwind-stockade");
.not.toBe("bite-sized-stormwind-stockade");
expect(randomDungeonByCategory("wow", "stormwind-stockade", () => 0)?.id)
.not.toBe("stormwind-stockade");
});
@@ -87,6 +89,11 @@ describe("dungeon catalog", () => {
expect(dungeonById("missing-dungeon")).toBeNull();
});
it("keeps every queue candidate in the server policy validation catalog", () => {
const policyIds = new Set(queuePolicyCatalog.dungeons.map((dungeon) => dungeon.id));
expect(DUNGEON_CATALOG.every((dungeon) => policyIds.has(dungeon.id))).toBe(true);
});
it("provides centralized display copy for every entry", () => {
for (const dungeon of DUNGEON_CATALOG) {
expect(dungeon.location.trim()).not.toBe("");
@@ -101,8 +108,12 @@ describe("dungeon catalog", () => {
"randomized-bite-sized-stormwind-stockade",
"stormwind-stockade",
"bite-sized-stormwind-stockade",
"bite-sized-deadmines-ship",
"bite-sized-utgarde-pinnacle-skadi",
"bite-sized-wailing-caverns",
]);
expect(DUNGEON_CATALOG.filter((dungeon) => !dungeon.id.includes("stormwind-stockade"))
expect(DUNGEON_CATALOG.filter((dungeon) => !dungeon.id.includes("stormwind-stockade")
&& !dungeon.id.startsWith("bite-sized-"))
.every((dungeon) => dungeon.releaseStage === "alpha"))
.toBe(true);
for (const category of DUNGEON_CATEGORIES) {
@@ -123,6 +134,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-stormwind-stockade", "randomized-bite-sized-stormwind-stockade", "forsaken-abbey", ...Object.keys(availability.enabled)].sort());
.toEqual(["bite-sized-deadmines-ship", "bite-sized-stormwind-stockade",
"bite-sized-utgarde-pinnacle-skadi", "bite-sized-wailing-caverns",
"randomized-bite-sized-stormwind-stockade", "forsaken-abbey",
...Object.keys(availability.enabled)].sort());
});
});
+2 -4
View File
@@ -2,9 +2,8 @@ import { DUNGEON_DEFINITIONS, dungeonCanEnter, type DungeonId } from "./dungeonR
import { contentCategoryById, type ContentCategoryId } from "../app/contentCategories";
import { compareDungeonReleaseOrder, dungeonReleaseStage, type DungeonReleaseStage } from "./dungeonReleaseStage";
import {
BITE_SIZED_STOCKADES_DUNGEON_ID,
isBiteSizedDungeonId,
isRandomizedBiteSizedDungeonId,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
} from "./dungeonContentSource";
const SOURCE_DUNGEON_CATEGORY_IDS = ["wow", "rom"] as const satisfies readonly ContentCategoryId[];
@@ -43,8 +42,7 @@ export interface DungeonCatalogEntry {
export const DUNGEON_CATALOG: readonly DungeonCatalogEntry[] = Object.freeze(
DUNGEON_DEFINITIONS.map<DungeonCatalogEntry>((definition) => ({
id: definition.id as DungeonId,
category: definition.id === BITE_SIZED_STOCKADES_DUNGEON_ID
|| definition.id === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
category: isBiteSizedDungeonId(definition.id)
? "bite-sized"
: definition.expansion === "runewaker" ? "rom" : "wow",
title: definition.title,
+18
View File
@@ -1,8 +1,23 @@
export const STORMWIND_STOCKADE_DUNGEON_ID = "stormwind-stockade";
export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade";
export const RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade";
export const BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID = "bite-sized-wailing-caverns";
export const BITE_SIZED_DEADMINES_DUNGEON_ID = "bite-sized-deadmines-ship";
export const BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID = "bite-sized-utgarde-pinnacle-skadi";
export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-sized";
const BITE_SIZED_DUNGEON_IDS: ReadonlySet<string> = new Set([
BITE_SIZED_STOCKADES_DUNGEON_ID,
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
]);
export function isBiteSizedDungeonId(dungeonId: string): boolean {
return BITE_SIZED_DUNGEON_IDS.has(dungeonId);
}
/** Randomized Bite-Sized locations share this id namespace as the mode expands. */
export function isRandomizedBiteSizedDungeonId(dungeonId: string): boolean {
return dungeonId.startsWith("randomized-bite-sized-");
@@ -11,6 +26,9 @@ export function isRandomizedBiteSizedDungeonId(dungeonId: string): boolean {
const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({
[BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
[RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
[BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID]: "wailing-caverns",
[BITE_SIZED_DEADMINES_DUNGEON_ID]: "deadmines",
[BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID]: "utgarde-pinnacle",
});
export function isDerivedStockadesDungeonId(dungeonId: string): boolean {
+1 -1
View File
@@ -35,7 +35,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(86);
expect(catalog.dungeons).toHaveLength(89);
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`);
+40
View File
@@ -0,0 +1,40 @@
import type { DungeonCatalogEntry } from "./dungeonCatalog";
export type DungeonQueueMode = "regular" | "biteSized" | "randomizedBiteSized";
export interface DungeonQueuePolicy {
readonly schemaVersion: 1;
readonly disabled: Readonly<Record<DungeonQueueMode, readonly string[]>>;
}
export const DEFAULT_DUNGEON_QUEUE_POLICY: DungeonQueuePolicy = {
schemaVersion: 1,
disabled: { regular: [], biteSized: [], randomizedBiteSized: [] },
};
export function queueModeForDungeon(dungeon: DungeonCatalogEntry): DungeonQueueMode {
if (dungeon.category !== "bite-sized") return "regular";
return dungeon.id.startsWith("randomized-bite-sized-") ? "randomizedBiteSized" : "biteSized";
}
export function dungeonEnabledForQueue(policy: DungeonQueuePolicy, dungeon: DungeonCatalogEntry): boolean {
return !policy.disabled[queueModeForDungeon(dungeon)].includes(dungeon.id);
}
export function normalizeDungeonQueuePolicy(value: unknown): DungeonQueuePolicy {
if (!value || typeof value !== "object" || !("schemaVersion" in value) || value.schemaVersion !== 1
|| !("disabled" in value) || !value.disabled || typeof value.disabled !== "object") {
throw new Error("Invalid dungeon queue policy.");
}
const disabled = value.disabled as Record<string, unknown>;
for (const mode of ["regular", "biteSized", "randomizedBiteSized"] as const) {
if (!Array.isArray(disabled[mode]) || !disabled[mode].every((id: unknown) => typeof id === "string")) {
throw new Error("Invalid dungeon queue policy.");
}
}
return { schemaVersion: 1, disabled: {
regular: [...disabled.regular as string[]],
biteSized: [...disabled.biteSized as string[]],
randomizedBiteSized: [...disabled.randomizedBiteSized as string[]],
} };
}
+32
View File
@@ -0,0 +1,32 @@
{
"baselineMs": 240000,
"milestones": [
{ "at": 0.18, "progress": 20, "label": "First pack cleared" },
{ "at": 0.44, "progress": 48, "label": "Midpoint reached" },
{ "at": 0.72, "progress": 77, "label": "Final approach" },
{ "at": 0.9, "progress": 93, "label": "Boss engaged" },
{ "at": 1, "progress": 100, "label": "Boss defeated" }
],
"tiers": [
{ "id": "novice", "label": "Novice", "multiplier": 1.35 },
{ "id": "regular", "label": "Regular", "multiplier": 1.1 },
{ "id": "veteran", "label": "Veteran", "multiplier": 0.9 },
{ "id": "elite", "label": "Elite", "multiplier": 0.75 }
],
"teams": {
"wow": [
{ "name": "Darin", "classId": "warrior", "role": "tank" },
{ "name": "Mara", "classId": "priest", "role": "healer" },
{ "name": "Ilyra", "classId": "mage", "role": "damage" },
{ "name": "Rowan", "classId": "hunter", "role": "damage" },
{ "name": "Voss", "classId": "rogue", "role": "damage" }
],
"rom": [
{ "name": "Aldric", "classId": "rom-warrior", "role": "tank" },
{ "name": "Liora", "classId": "rom-priest", "role": "healer" },
{ "name": "Kael", "classId": "rom-scout", "role": "damage" },
{ "name": "Morgha", "classId": "rom-rogue", "role": "damage" },
{ "name": "Elyra", "classId": "rom-mage", "role": "damage" }
]
}
}
+17
View File
@@ -28,6 +28,11 @@ import {
dungeonContentSourceId,
} from "./dungeonContentSource";
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
import {
createBiteSizedDeadminesDefinition,
createBiteSizedUtgardePinnacleDefinition,
createBiteSizedWailingCavernsDefinition,
} from "./dungeons/biteSizedWoWDungeons";
import {
createRandomizedBiteSizedStockadesDefinition,
randomizedBiteSizedCreatureCatalog,
@@ -203,6 +208,12 @@ const adaptedSourceEntries = sourceDungeonRegistryEntries.map(
const adaptedSourceRegistry = Object.fromEntries(adaptedSourceEntries) as Readonly<Record<string, DungeonDefinition>>;
const stockadesDefinition = adaptedSourceRegistry[STORMWIND_STOCKADE_DUNGEON_ID];
if (!stockadesDefinition) throw new Error("Stormwind Stockade is required for its bite-sized variant.");
const wailingCavernsDefinition = adaptedSourceRegistry["wailing-caverns"];
const deadminesDefinition = adaptedSourceRegistry["deadmines"];
const utgardePinnacleDefinition = adaptedSourceRegistry["utgarde-pinnacle"];
if (!wailingCavernsDefinition || !deadminesDefinition || !utgardePinnacleDefinition) {
throw new Error("Wailing Caverns, The Deadmines, and Utgarde Pinnacle are required for their bite-sized variants.");
}
export const RANDOMIZED_BITE_SIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(adaptedSourceEntries.map(([, definition]) => definition)),
@@ -217,10 +228,16 @@ export function randomizedBiteSizedDefinitionForSeed(seed: string): DungeonDefin
}
const biteSizedStockadesTemplate = createBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template");
const randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template");
const biteSizedWailingCaverns = createBiteSizedWailingCavernsDefinition(wailingCavernsDefinition);
const biteSizedDeadmines = createBiteSizedDeadminesDefinition(deadminesDefinition);
const biteSizedUtgardePinnacle = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition);
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
...adaptedSourceEntries,
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
[randomizedBiteSizedStockadesTemplate.id, randomizedBiteSizedStockadesTemplate],
[biteSizedWailingCaverns.id, biteSizedWailingCaverns],
[biteSizedDeadmines.id, biteSizedDeadmines],
[biteSizedUtgardePinnacle.id, biteSizedUtgardePinnacle],
];
export const DUNGEON_REGISTRY: Readonly<Record<string, DungeonDefinition>> = Object.freeze(
+3 -2
View File
@@ -1,3 +1,5 @@
import { isBiteSizedDungeonId } from "./dungeonContentSource";
export const DUNGEON_RELEASE_STAGES = ["full-release", "beta", "alpha"] as const;
export type DungeonReleaseStage = (typeof DUNGEON_RELEASE_STAGES)[number];
@@ -9,8 +11,7 @@ export const DUNGEON_RELEASE_STAGE_LABELS: Readonly<Record<DungeonReleaseStage,
export function dungeonReleaseStage(id: string): DungeonReleaseStage {
return id === "stormwind-stockade"
|| id === "bite-sized-stormwind-stockade"
|| id === "randomized-bite-sized-stormwind-stockade"
|| isBiteSizedDungeonId(id)
? "beta"
: "alpha";
}
+485
View File
@@ -0,0 +1,485 @@
import type {
DungeonBossObjective,
DungeonDefinition,
DungeonEncounterSpawnGroupDefinition,
Vector3Tuple,
} from "../dungeonTypes";
import type {
DungeonEncounterAction,
DungeonEncounterDefinition,
DungeonEncounterRule,
} from "../dungeonEncounterTypes";
import type {
MobPackDefinition,
PopulationEntityDefinition,
StaticMobSpawnDefinition,
} from "../mobPopulation";
import type { DungeonWorldObjectDefinition } from "../dungeonWorldObjects";
import {
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
} from "../dungeonContentSource";
import {
WAILING_NARALEX_ENCOUNTER,
WAILING_NARALEX_SPAWN_GROUPS,
WAILING_NARALEX_START_POSITION,
WAILING_NARALEX_ESCORT_WAYPOINTS,
} from "../wailingCavernsEncounter";
const WAILING_ROUTE_WIDTH = 36;
const DEADMINES_ROUTE_WIDTH = 34;
const SKADI_ROUTE_WIDTH = 32;
function normalizedName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
}
function distanceToSegment(point: Vector3Tuple, start: Vector3Tuple, end: Vector3Tuple): number {
const direction = end.map((value, axis) => value - start[axis]) as unknown as Vector3Tuple;
const offset = point.map((value, axis) => value - start[axis]) as unknown as Vector3Tuple;
const lengthSquared = direction.reduce((total, value) => total + value * value, 0);
const projection = lengthSquared === 0
? 0
: Math.max(0, Math.min(1, offset.reduce((total, value, axis) => total + value * direction[axis], 0) / lengthSquared));
return Math.hypot(...point.map((value, axis) => value - start[axis] - direction[axis] * projection));
}
function distanceToRoute(point: Vector3Tuple, route: readonly Vector3Tuple[]): number {
if (route.length < 2) return Number.POSITIVE_INFINITY;
return Math.min(...route.slice(1).map((end, index) => distanceToSegment(point, route[index], end)));
}
function routeSpawns(
source: DungeonDefinition,
route: readonly Vector3Tuple[],
width: number,
): readonly StaticMobSpawnDefinition[] {
return source.staticSpawns.filter((spawn) => (
source.entities[spawn.entityId]?.kind !== "boss"
&& distanceToRoute(spawn.position, route) <= width
));
}
function routeRoamingPacks(
source: DungeonDefinition,
route: readonly Vector3Tuple[],
width: number,
): readonly MobPackDefinition[] {
return source.roamingPacks.flatMap((pack) => {
const members = pack.members.filter((member) => source.entities[member.entityId]?.kind !== "boss");
return members.length > 0 && pack.waypoints.every((point) => distanceToRoute(point, route) <= width * 1.7)
? [{ ...pack, members }]
: [];
});
}
function entitiesFor(
source: DungeonDefinition,
staticSpawns: readonly StaticMobSpawnDefinition[],
roamingPacks: readonly MobPackDefinition[],
groups: readonly DungeonEncounterSpawnGroupDefinition[],
additionalEntityIds: readonly string[] = [],
): Readonly<Record<string, PopulationEntityDefinition>> {
const usedIds = new Set([
...staticSpawns.map((spawn) => spawn.entityId),
...roamingPacks.flatMap((pack) => pack.members.map((member) => member.entityId)),
...groups.flatMap((group) => group.spawns.map((spawn) => spawn.entityId)),
...additionalEntityIds,
]);
const entities: Record<string, PopulationEntityDefinition> = {};
for (const entityId of usedIds) {
const entity = source.entities[entityId];
if (!entity) throw new Error(`Bite-sized route references missing entity: ${entityId}`);
entities[entityId] = entity;
}
return entities;
}
function bossByName(source: DungeonDefinition, name: string): {
readonly objective: DungeonBossObjective;
readonly spawn: StaticMobSpawnDefinition;
readonly entity: PopulationEntityDefinition;
} {
const normalized = normalizedName(name);
const objective = source.objectiveOrder.find((candidate) => normalizedName(candidate.name) === normalized);
const spawn = objective && source.staticSpawns.find((candidate) => candidate.id === objective.id);
const entity = spawn && source.entities[spawn.entityId];
if (!objective || !spawn || !entity || entity.kind !== "boss") {
throw new Error(`${source.id} is missing its ${name} boss source.`);
}
return { objective, spawn, entity };
}
function routeArea(id: string, name: string, center: Vector3Tuple, radius = 36) {
return { id, name, center, radius };
}
function runCompletionActions(prefix: string, objectiveId: string): readonly DungeonEncounterAction[] {
return [
{ id: `${prefix}-complete-objective`, kind: "complete-objective", objectiveId, persistence: "run" },
{ id: `${prefix}-checkpoint`, kind: "set-checkpoint", checkpointId: objectiveId, persistence: "checkpoint" },
{ id: `${prefix}-mark-dungeon-complete`, kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: `${prefix}-complete-phase`, kind: "set-phase", phase: "complete", persistence: "run" },
];
}
function appendPrototypeWarning(source: DungeonDefinition, note: string): DungeonDefinition["validation"] {
return {
...source.validation,
warnings: [...source.validation.warnings, note],
};
}
export function createBiteSizedWailingCavernsDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "wailing-caverns") throw new Error("The Wailing Caverns slice requires its source dungeon.");
const sourceFinalBoss = bossByName(source, "Mutanus the Devourer");
const route = [WAILING_NARALEX_START_POSITION, ...WAILING_NARALEX_ESCORT_WAYPOINTS];
const staticSpawns = routeSpawns(source, route, WAILING_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, WAILING_ROUTE_WIDTH);
const mutanusSpawn: StaticMobSpawnDefinition = {
...sourceFinalBoss.spawn,
position: sourceFinalBoss.objective.position,
};
const mutanusGroupId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:mutanus`;
const mutanusGroup: DungeonEncounterSpawnGroupDefinition = {
id: mutanusGroupId,
spawns: [mutanusSpawn],
};
const encounterId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-escort`;
const encounter: DungeonEncounterDefinition = {
...WAILING_NARALEX_ENCOUNTER,
id: encounterId,
dungeonId: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
initialPhase: "ready",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "ready", checkpointPhase: "ready" },
source: {
...WAILING_NARALEX_ENCOUNTER.source,
reviewedBehavior: "A standalone run begins at the source escort start, follows the Naralex waypoint route, defends the Disciple through the source waves, and finishes with Mutanus.",
adaptations: [
...WAILING_NARALEX_ENCOUNTER.source.adaptations,
"The four Fanglords are skipped; the bite-sized run begins at the actual Naralex escort start.",
"Mutanus is activated by the encounter runtime as the final event spawn in this derived definition.",
],
},
rules: WAILING_NARALEX_ENCOUNTER.rules
.filter((rule) => rule.id !== "fanglords-defeated" && rule.id !== "restore-ready-after-checkpoint")
.map((rule) => {
if (rule.id === "start-escort") {
return {
...rule,
trigger: { kind: "signal", signalId: `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-start` },
};
}
if (rule.id === "nightmare-wave-cleared") {
return {
...rule,
actions: [
...rule.actions,
{ id: "activate-mutanus", kind: "activate-spawn-group", spawnGroupId: mutanusGroupId },
],
};
}
return rule;
}),
};
const encounterGroups = [
...WAILING_NARALEX_SPAWN_GROUPS,
mutanusGroup,
];
const finalObjective: DungeonBossObjective = {
...sourceFinalBoss.objective,
eventOnly: true,
};
return {
...source,
id: BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
title: "Wailing Caverns: Naralex’s Rescue",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Disciple of Naralex’s Camp",
footPosition: WAILING_NARALEX_START_POSITION,
forward: [-0.433, 0, 0.902],
yaw: -0.448,
},
presentation: {
...source.presentation,
location: "Wailing Caverns · Naralex’s Route",
theme: "Bite-Sized Dungeon",
summary: "Follow the winding escort route, defend Naralex’s Disciple through the awakening waves, and defeat Mutanus.",
loadingMessage: "Opening the path to the Dreamer’s Rock...",
unchartedAreaName: "Naralex’s Route",
},
areas: [
routeArea("entrance", "Disciple of Naralex’s Camp", WAILING_NARALEX_START_POSITION, 26),
routeArea("naralex-route", "The Winding Caverns", WAILING_NARALEX_ESCORT_WAYPOINTS[12], 64),
routeArea("mutanus", "Mutanus’s Chamber", sourceFinalBoss.objective.position, 42),
],
entities: entitiesFor(source, staticSpawns, roamingPacks, encounterGroups),
staticSpawns,
roamingPacks,
bosses: [finalObjective],
objectiveOrder: [finalObjective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: encounterGroups,
serverGameObjects: source.serverGameObjects,
validation: appendPrototypeWarning(
source,
"Naralex’s route corridor and bite-sized objective list are Healer Man-authored; the source cave environment, escort waypoints, wave rosters, and Mutanus combat data are shared with Wailing Caverns.",
),
};
}
export function createBiteSizedDeadminesDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "deadmines") throw new Error("The Deadmines slice requires its source dungeon.");
const captain = bossByName(source, "Captain Greenskin");
const finalBoss = bossByName(source, "Edwin VanCleef");
const route: readonly Vector3Tuple[] = [
[-785, 17, -28],
[-797.2147, 20.4973, -22.8489],
[-807, 30, -42],
captain.objective.position,
finalBoss.objective.position,
];
const routeTrash = routeSpawns(source, route, DEADMINES_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, DEADMINES_ROUTE_WIDTH);
const vanCleefSpawn: StaticMobSpawnDefinition = {
...finalBoss.spawn,
position: finalBoss.objective.position,
};
const vanCleefGroupId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:van-cleef`;
const vanCleefGroup: DungeonEncounterSpawnGroupDefinition = {
id: vanCleefGroupId,
spawns: [vanCleefSpawn],
};
const encounterId = `${BITE_SIZED_DEADMINES_DUNGEON_ID}:ship-progression`;
const encounter: DungeonEncounterDefinition = {
id: encounterId,
dungeonId: BITE_SIZED_DEADMINES_DUNGEON_ID,
initialPhase: "greenskin",
initialState: { flags: { "dungeon-complete": false } },
resetPolicy: { bossWipePhase: "greenskin", checkpointPhase: "greenskin" },
source: {
fidelity: "healer-man-authored",
scope: "boss-progression",
files: ["src/game/dungeons/biteSizedWoWDungeons.ts", "src/game/generated/dungeonCampaignCatalog.compressed.ts"],
reviewedBehavior: "Start at the ship ramp, clear the Defias crew, defeat Captain Greenskin, then confront Edwin VanCleef in his cabin.",
adaptations: [
"The mine, foundry, Cookie branch, and other Deadmines bosses are omitted from this run.",
"VanCleef is held in an encounter spawn group until Greenskin is defeated.",
"Source boss IDs are retained for objective records and loot lookup.",
],
remainingGaps: [
"The ship route boundary and the optional Cookie branch need live traversal review; this first pass filters population by a spatial corridor.",
"Original boss-specific scripted actions beyond their imported combat profiles are not included in this two-boss slice.",
],
},
rules: [
{
id: "ship-greenskin-defeated",
trigger: { kind: "entities-dead", entityIds: [captain.objective.id] },
guards: [{ kind: "phase", phases: ["greenskin"] }],
actions: [
{ id: "ship-complete-greenskin", kind: "complete-objective", objectiveId: captain.objective.id, persistence: "run" },
{ id: "ship-checkpoint-greenskin", kind: "set-checkpoint", checkpointId: captain.objective.id, persistence: "checkpoint" },
{ id: "ship-activate-vancleef", kind: "activate-spawn-group", spawnGroupId: vanCleefGroupId },
{ id: "ship-enter-vancleef-phase", kind: "set-phase", phase: "van-cleef" },
],
},
{
id: "ship-vancleef-defeated",
trigger: { kind: "entities-dead", entityIds: [vanCleefSpawn.id] },
guards: [{ kind: "phase", phases: ["van-cleef"] }],
actions: runCompletionActions("ship-complete-vancleef", finalBoss.objective.id),
},
],
};
const encounterGroups = [vanCleefGroup];
const objectives: readonly DungeonBossObjective[] = [
captain.objective,
{ ...finalBoss.objective, eventOnly: true },
];
const startPosition: Vector3Tuple = [-785, 17, -28];
return {
...source,
id: BITE_SIZED_DEADMINES_DUNGEON_ID,
title: "The Deadmines: Take the Ship",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Mr. Smite’s Ship Ramp",
footPosition: startPosition,
forward: [-0.5, 0, -0.866],
yaw: -2.618,
},
presentation: {
...source.presentation,
location: "The Deadmines · The Defias Ship",
theme: "Bite-Sized Dungeon",
summary: "Board the Defias ship, push through the crew, defeat Captain Greenskin, and finish VanCleef in the cabin.",
loadingMessage: "Boarding the Defias ship...",
unchartedAreaName: "Defias Ship",
},
areas: [
routeArea("entrance", "Mr. Smite’s Ship Ramp", startPosition, 25),
routeArea("captain-greenskin", "Greenskin’s Deck", captain.objective.position, 32),
routeArea("edwin-vancleef", "VanCleef’s Cabin", finalBoss.objective.position, 28),
],
entities: entitiesFor(source, routeTrash, roamingPacks, encounterGroups, [captain.spawn.entityId]),
staticSpawns: [...routeTrash, captain.spawn],
roamingPacks,
bosses: objectives,
objectiveOrder: objectives,
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: encounterGroups,
serverGameObjects: source.serverGameObjects.filter((object) => (
distanceToRoute(object.position, route) <= DEADMINES_ROUTE_WIDTH * 1.7
)),
validation: appendPrototypeWarning(
source,
"The Deadmines ship route and two-boss progression are Healer Man-authored; source ship geometry, crew, Greenskin, and VanCleef assets are shared with The Deadmines.",
),
};
}
const SKADI_GROUP_IDS = new Set([
"utgarde-pinnacle:skadi-first-wave",
"utgarde-pinnacle:skadi-reinforcement-one",
"utgarde-pinnacle:skadi-reinforcement-two",
"utgarde-pinnacle:skadi-ground",
]);
const SKADI_RULE_IDS = new Set([
"utgarde-pinnacle-start-skadi",
"utgarde-pinnacle-first-wave-cleared",
"utgarde-pinnacle-fire-harpoon-one",
"utgarde-pinnacle-reinforcement-one-cleared",
"utgarde-pinnacle-fire-harpoon-two",
"utgarde-pinnacle-reinforcement-two-cleared",
"utgarde-pinnacle-fire-harpoon-three",
"utgarde-pinnacle-skadi-defeated",
]);
function skadiSliceRule(rule: DungeonEncounterRule, objectiveId: string): DungeonEncounterRule {
if (rule.id !== "utgarde-pinnacle-skadi-defeated") return rule;
return {
...rule,
trigger: { kind: "entities-dead", entityIds: [objectiveId], timeoutMs: 300_000 },
actions: [
...rule.actions.filter((action) => (
action.kind === "deactivate-spawn-group"
|| action.kind === "complete-objective"
|| action.kind === "set-checkpoint"
)),
{ id: "utgarde-pinnacle-skadi-slice-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
{ id: "utgarde-pinnacle-skadi-slice-finish", kind: "set-phase", phase: "complete", persistence: "run" },
],
};
}
export function createBiteSizedUtgardePinnacleDefinition(source: DungeonDefinition): DungeonDefinition {
if (source.id !== "utgarde-pinnacle") throw new Error("The Skadi slice requires Utgarde Pinnacle.");
const skadi = source.objectiveOrder.find((objective) => normalizedName(objective.name) === "skaditheruthless");
const sourceEncounter = source.encounters?.find((encounter) => (
encounter.rules.some((rule) => rule.id === "utgarde-pinnacle-start-skadi")
));
const sourceSkadiGroup = source.encounterSpawnGroups?.find((group) => group.id === "utgarde-pinnacle:skadi-ground");
const skadiStart = source.serverGameObjects.find((object) => object.id === "utgarde-pinnacle:skadi-start");
const harpoons = [1, 2, 3].map((index) => source.serverGameObjects.find((object) => (
object.id === `utgarde-pinnacle:harpoon-${index}`
)));
if (!skadi || !sourceEncounter || !sourceSkadiGroup || !skadiStart || harpoons.some((object) => !object)) {
throw new Error("Utgarde Pinnacle is missing the reviewed Skadi gauntlet content.");
}
const route: readonly Vector3Tuple[] = [skadiStart.position, skadi.position];
const staticSpawns = routeSpawns(source, route, SKADI_ROUTE_WIDTH);
const roamingPacks = routeRoamingPacks(source, route, SKADI_ROUTE_WIDTH);
const skadiSpawn: StaticMobSpawnDefinition = {
...sourceSkadiGroup.spawns[0],
id: skadi.id,
position: skadi.position,
};
const skadiGroup: DungeonEncounterSpawnGroupDefinition = {
...sourceSkadiGroup,
spawns: [skadiSpawn],
};
const groups = source.encounterSpawnGroups!.filter((group) => SKADI_GROUP_IDS.has(group.id))
.map((group) => group.id === skadiGroup.id ? skadiGroup : group);
if (!groups.some((group) => group.id === skadiGroup.id)) groups.push(skadiGroup);
const encounter: DungeonEncounterDefinition = {
...sourceEncounter,
id: `${BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID}:skadi-gauntlet`,
dungeonId: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
initialPhase: "skadi-ready",
initialState: {
flags: { "dungeon-complete": false },
counters: { "harpoon-hits": 0 },
worldObjectStates: {
[skadiStart.id]: "ready",
...Object.fromEntries(harpoons.map((object) => [object!.id, "unavailable"])),
},
},
resetPolicy: { bossWipePhase: "skadi-ready", checkpointPhase: "skadi-ready" },
source: {
...sourceEncounter.source!,
reviewedBehavior: "Begin at Skadi’s gauntlet, clear the source first wave, fire three harpoons through the bounded reinforcement packs, then defeat Skadi.",
adaptations: [
...sourceEncounter.source!.adaptations,
"The Svala and Gortok stages are skipped; the source Skadi event begins ready at its gauntlet trigger.",
"Skadi’s event actor uses the source boss objective ID so party targeting, loot, and completion stay aligned.",
],
},
rules: sourceEncounter.rules.filter((rule) => SKADI_RULE_IDS.has(rule.id))
.map((rule) => skadiSliceRule(rule, skadi.id)),
};
const objective: DungeonBossObjective = { ...skadi, eventOnly: true };
const routeNames = new Map<string, DungeonWorldObjectDefinition>([
[skadiStart.id, skadiStart],
...harpoons.map((object) => [object!.id, object!] as const),
]);
return {
...source,
id: BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
title: "Utgarde Pinnacle: Skadi’s Gauntlet",
lfgDungeonIds: [],
entrance: {
...source.entrance,
name: "Eagle’s Eye Gauntlet",
footPosition: skadiStart.position,
forward: [-0.167, 0, -0.986],
yaw: -2.974,
},
presentation: {
...source.presentation,
location: "Utgarde Pinnacle · Eagle’s Eye",
theme: "Bite-Sized Dungeon",
summary: "Enter Skadi’s gauntlet, push through the moving waves, fire the harpoons, and finish the fight on the ground.",
loadingMessage: "Preparing Skadi’s gauntlet...",
unchartedAreaName: "Eagle’s Eye",
},
areas: [
routeArea("entrance", "Eagle’s Eye Gauntlet", skadiStart.position, 30),
routeArea("skadi", "Skadi’s Arena", skadi.position, 34),
],
entities: entitiesFor(source, staticSpawns, roamingPacks, groups),
staticSpawns,
roamingPacks,
bosses: [objective],
objectiveOrder: [objective],
finalBossDurability: "standard",
encounters: [encounter],
encounterSpawnGroups: groups,
serverGameObjects: [...routeNames.values()],
validation: appendPrototypeWarning(
source,
"The standalone Skadi route is Healer Man-authored; source gauntlet waves, harpoon controls, Skadi combat, and the Utgarde Pinnacle environment are reused. Grauf’s flying vehicle phase remains adapted to ground-based harpoon interactions.",
),
};
}
@@ -216,6 +216,33 @@
],
"defaultDifficultyId": "normal"
},
{
"id": "bite-sized-deadmines-ship",
"title": "The Deadmines: Take the Ship",
"finalBossId": "creature-79336",
"bosses": [
{
"id": "creature-79333",
"name": "Captain Greenskin"
},
{
"id": "creature-79336",
"name": "Edwin VanCleef"
}
],
"difficulties": [
{
"id": "normal",
"name": "normal",
"bossIds": [
"creature-79333",
"creature-79336"
],
"finalBossId": "creature-79336"
}
],
"defaultDifficultyId": "normal"
},
{
"id": "bite-sized-stormwind-stockade",
"title": "Stormwind Stockade: Cellblock Rush",
@@ -265,6 +292,58 @@
],
"defaultDifficultyId": "normal"
},
{
"id": "bite-sized-utgarde-pinnacle-skadi",
"title": "Utgarde Pinnacle: Skadi’s Gauntlet",
"finalBossId": "creature-126103",
"bosses": [
{
"id": "creature-126103",
"name": "Skadi the Ruthless"
}
],
"difficulties": [
{
"id": "normal",
"name": "normal",
"bossIds": [
"creature-126103"
],
"finalBossId": "creature-126103"
},
{
"id": "heroic",
"name": "heroic",
"bossIds": [
"creature-126103"
],
"finalBossId": "creature-126103"
}
],
"defaultDifficultyId": "normal"
},
{
"id": "bite-sized-wailing-caverns",
"title": "Wailing Caverns: Naralex’s Rescue",
"finalBossId": "mutanus-spawn",
"bosses": [
{
"id": "mutanus-spawn",
"name": "Mutanus the Devourer"
}
],
"difficulties": [
{
"id": "normal",
"name": "normal",
"bossIds": [
"mutanus-spawn"
],
"finalBossId": "mutanus-spawn"
}
],
"defaultDifficultyId": "normal"
},
{
"id": "black-morass",
"title": "The Black Morass",
@@ -0,0 +1,635 @@
{
"schemaVersion": 1,
"dungeons": {
"aeternal-circle": {
"mapId": 144,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "de1ba06b7e3a9237fcff75aff2b00093287975e755cc7e98e77de1b7af6a9c14",
"kind": "client",
"image": "/assets/game/dungeons/aeternal-circle/maps/runewaker-map.png",
"checksum": "18e3f483734f5322a36b1e682480534b51875d1b99f8384d5d1fd45d7bc2c22f",
"sourceHash": "a555d52a8669d2aa5990ee55a71f476e063592d7140e6e934ac433d02273050d",
"runtimeToMap": [
0.94305882,
0.0,
0.0,
496.56034639,
0.0,
0.0,
0.94305882,
408.53672013
],
"width": 1002,
"height": 668
},
"barren-caves": {
"mapId": 110,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "e6dc49e6aa6fd20cee1b2afa43324a714f8f8284a6becef8dc514509b432f9bb",
"kind": "client",
"image": "/assets/game/dungeons/barren-caves/maps/runewaker-map.png",
"checksum": "0695bbba846094cd54a01edede7eae8c9ecd6c071ba6cc6fd2125c592fb82d00",
"sourceHash": "2ae51435420b81ae9cf423bff9bd2a8251d00d399a641c51c4403c8060a5423e",
"runtimeToMap": [
2.31008646,
0.0,
0.0,
318.25748741,
0.0,
0.0,
2.31008646,
320.74642415
],
"width": 1002,
"height": 668
},
"cave-water-dragon": {
"mapId": 118,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "dd1887f88d6ee9102da0e34ea2b4087742e37a2b8ce779c722bc124effe1faca",
"kind": "client",
"image": "/assets/game/dungeons/cave-water-dragon/maps/runewaker-map.png",
"checksum": "587745b4a040cefc1eb3e585825861d85ddaa6522dff60d5d8175a5895e8d2a2",
"sourceHash": "20526f42f0a71c1e038633e7a6f6ee767120782966d3f576ff249079e5c08bb3",
"runtimeToMap": [
1.01725888,
0.0,
0.0,
372.47804085,
0.0,
0.0,
1.01725888,
590.52902634
],
"width": 1002,
"height": 668
},
"demon-stronghold-125": {
"mapId": 125,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "e2106115dcc216999d2e37d26eeb52844ce4f41b44e19594699f37a54d7f19b9",
"kind": "navigation",
"image": "/assets/game/dungeons/demon-stronghold-125/maps/runewaker-map.png",
"checksum": "4816bface105c5bfccb579f1adbd602576c5e68691b0b594e0c3fb0ad93225ed",
"sourceHash": "a19dbc7ddd5e84ffedac76889a8673d8238c8a2240d7c7427886be5088b9863f",
"runtimeToMap": [
0.75675676,
0.0,
0.0,
448.97233788,
0.0,
0.0,
-0.75675676,
202.75896062
],
"width": 1002,
"height": 668
},
"demon-stronghold": {
"mapId": 122,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "664181dc790b9e68cdc7451dbd0b1170a033a71ca19375743b6e66ca25889723",
"kind": "navigation",
"image": "/assets/game/dungeons/demon-stronghold/maps/runewaker-map.png",
"checksum": "ce2e98b267e6b54559bd43c33f6d0c378e2c4e4721f7292610abc7e962bce72a",
"sourceHash": "0d10681eec4a2a1648b4d2699371672fea4736e5bc38457ea360916a32f1ba39",
"runtimeToMap": [
0.64494899,
0.0,
0.0,
456.65921583,
0.0,
0.0,
-0.64494899,
265.58661492
],
"width": 1002,
"height": 668
},
"dungeon-of-dalanis": {
"mapId": 127,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "45cad819bc348dec8926f7de237c625e9d18697b4200804e0440dd16b8976959",
"kind": "client",
"image": "/assets/game/dungeons/dungeon-of-dalanis/maps/runewaker-map.png",
"checksum": "674203d27fa37fbb3e8e01642995b1c1e5e31526cf05533ff8c904b354ffd632",
"sourceHash": "c74e6acccad66e905a746816d1c979b6e8ef766c18ceecd966bfc84196872e96",
"runtimeToMap": [
1.86418605,
0.0,
0.0,
134.43856543,
0.0,
0.0,
1.86418605,
169.54665566
],
"width": 1002,
"height": 668
},
"forsaken-abbey": {
"mapId": 102,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "b534f66c693ac57f560e8a7aeeff59ee955b8de4bf97dd8f0aa368e10d43e79f",
"kind": "client",
"image": "/assets/game/dungeons/forsaken-abbey/maps/runewaker-map.png",
"checksum": "004fb7c759eacdbb81bcc66586f33578c332511e33cc0daf53bdcf1cc418b15d",
"sourceHash": "fd5e150607472b0dc718900aa5cebbf8ed7da32860003e3416f11536bc08efd5",
"runtimeToMap": [
1.84417178,
0.0,
0.0,
143.07589407,
0.0,
0.0,
1.84417178,
309.91862641
],
"width": 1002,
"height": 668
},
"grafu-castle": {
"mapId": 137,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "b775778f457d639e3f21982aaa6bd3ee1b0eba951c711d1ba319b0588e24a38f",
"kind": "navigation",
"image": "/assets/game/dungeons/grafu-castle/maps/runewaker-map.png",
"checksum": "5db7bfe6de91a60321f1d46a42d3d7540570dcd0944125a1dbe6434d21068b1d",
"sourceHash": "f3925c7005706d5a11dfd9fc6e41c4f8af6993d22b4c84c62419346b16c4556e",
"runtimeToMap": [
1.44970412,
0.0,
0.0,
521.18524298,
0.0,
0.0,
-1.44970412,
278.88786182
],
"width": 1002,
"height": 668
},
"hall-of-survivors": {
"mapId": 117,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "96b91b24462b8428e8808295dcea29d26de9be8431ea6667dde92cdded4ddf2a",
"kind": "client",
"image": "/assets/game/dungeons/hall-of-survivors/maps/runewaker-map.png",
"checksum": "672593945d1f7938590d11f4612a49c0bd82e3e2adec04ca165677101e8e00ce",
"sourceHash": "2dcb8c2b86319314403da3643ffc9aeecc00e3a01e807bc928b5e9ce0960bd86",
"runtimeToMap": [
0.87702407,
0.0,
0.0,
470.48853677,
0.0,
0.0,
0.87702407,
327.67835904
],
"width": 1002,
"height": 668
},
"hoto": {
"mapId": 115,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "beff3a180c55166d5ebd7e3610e65b59f8104edfd7596aa22ac3a49c578fcdc0",
"kind": "navigation",
"image": "/assets/game/dungeons/hoto/maps/runewaker-map.png",
"checksum": "7acb38dc1a3c3a557e8b3a8e661221f7d16d1517d5ab287f3651332156b24963",
"sourceHash": "a2bde2e4b8ed0d0bac8c4669a05192ff0a35ae9c64df0c0a29fe3735b65cbf4b",
"runtimeToMap": [
1.75313054,
0.0,
0.0,
534.5109662,
0.0,
0.0,
-1.75313054,
310.51824005
],
"width": 1002,
"height": 668
},
"ice-dwarf-kingdom": {
"mapId": 114,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "41cef8ce1bf6dbf7df0c4e2797a325e4d50a3870d435d46684600894d4aa92e7",
"kind": "navigation",
"image": "/assets/game/dungeons/ice-dwarf-kingdom/maps/runewaker-map.png",
"checksum": "c1f48800e8e29b3a137664bc6bcdcd46e806784490daa416349ef930e6bb57f9",
"sourceHash": "05c1a2a9541b5a463b7032b47e509f66bb4db6466b279d3612b7f6add9cd0c46",
"runtimeToMap": [
1.27853874,
0.0,
0.0,
529.59821325,
0.0,
0.0,
-1.27853874,
220.50323225
],
"width": 1002,
"height": 668
},
"kal-turok-hive": {
"mapId": 105,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "75c0c596218cec07003e2def1d74c8e1f5ca3010ed370db558a1786b003f4f1c",
"kind": "client",
"image": "/assets/game/dungeons/kal-turok-hive/maps/runewaker-map.png",
"checksum": "09e9b2ae4d6fac6c96dc12d7bf1da3cc276031450f7810a6f8bef855e6373487",
"sourceHash": "b03787597089a676b1f81144f3e9602fc760fcf7e207269d2820d450cd69b3d3",
"runtimeToMap": [
3.48521739,
0.0,
0.0,
490.60879608,
0.0,
0.0,
3.48521739,
509.76018772
],
"width": 1002,
"height": 668
},
"kalins-shrine": {
"mapId": 107,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "023c43a56253e97fa0526185c61f76e0e9fa8398b7a35771728566c331cd22a9",
"kind": "navigation",
"image": "/assets/game/dungeons/kalins-shrine/maps/runewaker-map.png",
"checksum": "3660de8c80eb07f0612c24586f51149ff03a14ae82b43abf9a11db60bcf59275",
"sourceHash": "6b77496f7b46890d401f955f9254ae37d25d9a1287a3bc9e787e9a254f9d9071",
"runtimeToMap": [
0.8824853,
0.0,
0.0,
632.14257291,
0.0,
0.0,
-0.8824853,
59.90035597
],
"width": 1002,
"height": 668
},
"kawaks-tomb": {
"mapId": 134,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "3f1755780540c54a1ba9b528e646ebd39b12ab127e41fa2d497fd7749d202018",
"kind": "client",
"image": "/assets/game/dungeons/kawaks-tomb/maps/runewaker-map.png",
"checksum": "6fedf7501b2560b06bc8bd8d474bec5530231318ef6c0a1aa19cd2cb61766843",
"sourceHash": "dd8f5e3e95a3df505e891a6477d40f4f011311ff6fd6bde9ef6d703a29a66ac9",
"runtimeToMap": [
0.91090909,
0.0,
0.0,
514.89942508,
0.0,
0.0,
0.91090909,
608.00390825
],
"width": 1002,
"height": 668
},
"kulech-bones-nest": {
"mapId": 147,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "b06b42b3c7f19abf9d645eec6be78f868cf6966e0b472e2ca4a1c36d3cf4d2d1",
"kind": "client",
"image": "/assets/game/dungeons/kulech-bones-nest/maps/runewaker-map.png",
"checksum": "b3246989b267d600bc6d6185697ecf02771928779d31bd4594920bb8b3a76760",
"sourceHash": "066ee37932c543e6715243fcef3446f70a8eb9aa2a90758d7346262e46823df5",
"runtimeToMap": [
1.59363817,
0.0,
0.0,
508.5092438,
0.0,
0.0,
1.59363817,
342.87627637
],
"width": 1002,
"height": 668
},
"lair-demon-dragon": {
"mapId": 119,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "b371465e96ef978915c9a515846ecf9dccb98607fd549617a1c71f45468fa5a7",
"kind": "navigation",
"image": "/assets/game/dungeons/lair-demon-dragon/maps/runewaker-map.png",
"checksum": "cf2a67781897d772cb1178aa77539ad4700622699dc99954c5abdafc6fedd562",
"sourceHash": "3bb62814056d133d0f5942f6b5fb7c4c4f4178ea9df311f0363298beb0c6a8f9",
"runtimeToMap": [
1.55186061,
0.0,
0.0,
558.97494417,
0.0,
0.0,
-1.55186061,
292.85074742
],
"width": 1002,
"height": 668
},
"mystic-altar": {
"mapId": 104,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "c37f27643598c8fe6401828be2950bc08f299dfe491df37736fda4662c70a96e",
"kind": "navigation",
"image": "/assets/game/dungeons/mystic-altar/maps/runewaker-map.png",
"checksum": "245f4414aa6daa7251cdeae277800a1d03566fd9ee8aea7e08c62205bc4d191b",
"sourceHash": "3dbf8ce85b83f0d515b6925a784a36191f4b9e760b56205a8b20bb927e389ed1",
"runtimeToMap": [
0.62499999,
0.0,
0.0,
627.42226927,
0.0,
0.0,
-0.62499999,
339.38813581
],
"width": 1002,
"height": 668
},
"paspers-shrine": {
"mapId": 106,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "bcf6443e8adb8b7f7dd81f4a97b79aac1c99756a83289bbada31853d2d9adb7c",
"kind": "navigation",
"image": "/assets/game/dungeons/paspers-shrine/maps/runewaker-map.png",
"checksum": "d353dd3e3e7bbea7985fa24efe167bbf7eb88f71890ebdf27a5d56c10cbba7b7",
"sourceHash": "4ebba3fb97a99df49c7301dbfcdb8154634cf4e56a85c5ed587c7515221d0a10",
"runtimeToMap": [
1.19512195,
0.0,
0.0,
462.10446651,
0.0,
0.0,
-1.19512195,
248.31707243
],
"width": 1002,
"height": 668
},
"raksha-temple": {
"mapId": 130,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "1fad5149acfce870be822f5355369f15dd76fba280bbeda31ff7f371c442082f",
"kind": "client",
"image": "/assets/game/dungeons/raksha-temple/maps/runewaker-map.png",
"checksum": "5f46c29a4fcd7b3a537d72996a785b41db8d3dd7b52485292ea8135b2effba68",
"sourceHash": "7bc925e7f463099c596ffceebfc26969b1f8549952da81688f18da8b80070f7d",
"runtimeToMap": [
0.49975062,
0.0,
0.0,
592.25597645,
0.0,
0.0,
0.49975062,
354.44431688
],
"width": 1002,
"height": 668
},
"sand-vortex": {
"mapId": 121,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "48be6befbd24870a948a5a0f49f22098b44a5a3502c4749e292c42bde1df7baa",
"kind": "navigation",
"image": "/assets/game/dungeons/sand-vortex/maps/runewaker-map.png",
"checksum": "5cf410dbfab40f4337dad936ebf4db9a7bbe161a0e8d46674b678b8e3a4aa0e7",
"sourceHash": "8dab93dfda9145a6d10c1526281bee6d531e80426300c704b348ab562c7843ef",
"runtimeToMap": [
6.87719298,
0.0,
0.0,
508.32039709,
0.0,
0.0,
-6.87719298,
151.2326885
],
"width": 1002,
"height": 668
},
"sardo-castle": {
"mapId": 139,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "191396625e301a41afab38e168848a198a9be5918a071dadda841683a7c6261e",
"kind": "client",
"image": "/assets/game/dungeons/sardo-castle/maps/runewaker-map.png",
"checksum": "b3f418fd1e8a539e475a2823387791b8ae87cc7c2ec56d8590beb12c63d398f0",
"sourceHash": "f435dae1726d80a9903dc278bd1933a4f6a9afe59ca213846366c63e3a8c41af",
"runtimeToMap": [
1.52106262,
0.0,
0.0,
493.75955465,
0.0,
0.0,
1.52106262,
322.77658515
],
"width": 1002,
"height": 668
},
"the-origin": {
"mapId": 116,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "1f96c2b57a24470a003834a8c91cb0ceda5b01824d5bfa31904afd247c404e88",
"kind": "navigation",
"image": "/assets/game/dungeons/the-origin/maps/runewaker-map.png",
"checksum": "42b4240eae2118a030bd91145218f4be28c2a5a72b9cdbbbbcf5e1249fc35bbc",
"sourceHash": "70c0a78073d7c3cd7a95e0dc3531c25c0de9815376001f9b80cfbb59a72596cd",
"runtimeToMap": [
0.63947793,
0.0,
0.0,
554.13766371,
0.0,
0.0,
-0.63947793,
351.65470791
],
"width": 1002,
"height": 668
},
"tomb-seven-heroes": {
"mapId": 142,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "09250a1fbabf8a03f922c9a3950aee3daa1edc54215cd03ddc9bf5c96e1429bd",
"kind": "navigation",
"image": "/assets/game/dungeons/tomb-seven-heroes/maps/runewaker-map.png",
"checksum": "81260997140c71fc7c903e70325143eb4c113cafdf4dbe5fef9c7f44f3c5aaad",
"sourceHash": "617cac4b74b580ca214c241a9ffa9574aa19d1cd59efac71e4f9c4899709df51",
"runtimeToMap": [
1.23192956,
0.0,
0.0,
461.29778231,
0.0,
0.0,
-1.23192956,
281.72970787
],
"width": 1002,
"height": 668
},
"treasure-trove": {
"mapId": 108,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "c56d429187fd2ffbe20e11d5e7bbfd2128a779c9251fa2ab4b12286caf0de0b6",
"kind": "navigation",
"image": "/assets/game/dungeons/treasure-trove/maps/runewaker-map.png",
"checksum": "3d0a053648aa902ac2d6677f281590aaf783ab3f8116751b6ce70d410d1939b9",
"sourceHash": "73a70fcf482f7d13c9140901d8dd66c5b339a300f0aece69b00e863b83652657",
"runtimeToMap": [
0.74686104,
0.0,
0.0,
520.62262966,
0.0,
0.0,
-0.74686104,
296.69564946
],
"width": 1002,
"height": 668
},
"warnorken-arena": {
"mapId": 129,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "ee81c9e7c94d624977143297ca6c070c40799fdda1c51b92044c70dff53987ea",
"kind": "client",
"image": "/assets/game/dungeons/warnorken-arena/maps/runewaker-map.png",
"checksum": "74506c606c6fc4b2f5fe08b2147de60f8258932e3ca3d473b0299983e9ed0111",
"sourceHash": "6756f969314429818c12aedcf74ae399d1b8505a3c9ea65fb0b04b8014876265",
"runtimeToMap": [
1.13115715,
0.0,
-1.26195978,
515.13353291,
1.26195978,
0.0,
1.13115715,
335.10954034
],
"width": 1002,
"height": 668
},
"ystra-labyrinth-boss1": {
"mapId": 109,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "5e35eeccf5e207477bfb3a38dee75d53a5c5467433a90270b79a9ba8619a6260",
"kind": "client",
"image": "/assets/game/dungeons/ystra-labyrinth-boss1/maps/runewaker-map.png",
"checksum": "bee49409ca78c29ef428f841f7dfbbe70e7a1ea85e61a248209f269d6d54d661",
"sourceHash": "ce8efa65bf8d1e28557f36270551a6089567c6e76f743d30a501d83c1d44c374",
"runtimeToMap": [
4.175,
0.0,
0.0,
499.74712348,
0.0,
0.0,
4.175,
339.02184443
],
"width": 1002,
"height": 668
},
"ystra-labyrinth-boss2": {
"mapId": 111,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a",
"kind": "client",
"image": "/assets/game/dungeons/ystra-labyrinth-boss2/maps/runewaker-map.png",
"checksum": "18210f5f16aa2ed7c0f5d2356cb3a8bb4382bf5043fe396f103fbb38464b0e97",
"sourceHash": "02c2e7792242aedaa15075d24a1e6e571533f5e57e38cf94e810c2db35977022",
"runtimeToMap": [
4.175,
0.0,
0.0,
499.74712348,
0.0,
0.0,
4.175,
339.02184443
],
"width": 1002,
"height": 668
},
"ystra-labyrinth-boss3": {
"mapId": 112,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a",
"kind": "client",
"image": "/assets/game/dungeons/ystra-labyrinth-boss3/maps/runewaker-map.png",
"checksum": "0397004fbf5d52a9f887d350dc9d52f0974228fc9bd8b28664d1848a5c6d8a63",
"sourceHash": "f4552058b24988dfe546bee975f815f007872cd2ed09838fbda4f014da609328",
"runtimeToMap": [
4.175,
0.0,
0.0,
499.74712348,
0.0,
0.0,
4.175,
339.02184443
],
"width": 1002,
"height": 668
},
"zurhidon-stronghold-124": {
"mapId": 124,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "58be5be365b14e42772a4560c5c1d10fceb8aa8abb2c1771cc21ba2b2e02872a",
"kind": "navigation",
"image": "/assets/game/dungeons/zurhidon-stronghold-124/maps/runewaker-map.png",
"checksum": "e9fe448187cf87f46317b36967bc6eeb97f7b56be41a495aca2616c0d4b9cbee",
"sourceHash": "e57509105e15e67b1e4f3075c1c98f082552001a566e01a81fc1c8be9af41f29",
"runtimeToMap": [
0.86457869,
0.0,
0.0,
412.42758975,
0.0,
0.0,
-0.86457869,
55.43724674
],
"width": 1002,
"height": 668
},
"zurhidon-stronghold": {
"mapId": 120,
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": "58be5be365b14e42772a4560c5c1d10fceb8aa8abb2c1771cc21ba2b2e02872a",
"kind": "navigation",
"image": "/assets/game/dungeons/zurhidon-stronghold/maps/runewaker-map.png",
"checksum": "e9fe448187cf87f46317b36967bc6eeb97f7b56be41a495aca2616c0d4b9cbee",
"sourceHash": "e57509105e15e67b1e4f3075c1c98f082552001a566e01a81fc1c8be9af41f29",
"runtimeToMap": [
0.86457869,
0.0,
0.0,
412.42758975,
0.0,
0.0,
-0.86457869,
55.43724674
],
"width": 1002,
"height": 668
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { preloadAbilityCatalogsForClasses, sourceAbilitiesForClass } from "./abilityCatalog";
import { permanentBuffEffects } from "./partyBuffPolicy";
describe("permanent party buff catalog", () => {
it("offers learned active stat buffs without treating passive or hostile auras as GM candidates", async () => {
await preloadAbilityCatalogsForClasses(["priest"]);
const priest = sourceAbilitiesForClass("priest");
const fortitude = priest.find((ability) => ability.id === "wow335-priest-power-word-fortitude");
expect(fortitude).toBeDefined();
expect(permanentBuffEffects(fortitude!)).not.toHaveLength(0);
expect(priest.filter((ability) => ability.passive || ability.target === "hostile")
.every((ability) => permanentBuffEffects(ability).length === 0)).toBe(true);
});
});
+33
View File
@@ -0,0 +1,33 @@
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
export type PartyBuffScope = "party" | "tank" | "healer";
export interface PartyBuffRule { readonly abilityId: string; readonly scope: PartyBuffScope }
export interface PartyBuffPolicy { readonly schemaVersion: 1; readonly rules: readonly PartyBuffRule[] }
export const DEFAULT_PARTY_BUFF_POLICY: PartyBuffPolicy = { schemaVersion: 1, rules: [] };
export function normalizePartyBuffPolicy(value: unknown): PartyBuffPolicy {
if (!value || typeof value !== "object") return DEFAULT_PARTY_BUFF_POLICY;
const policy = value as Partial<PartyBuffPolicy>;
if (policy.schemaVersion !== 1 || !Array.isArray(policy.rules)) return DEFAULT_PARTY_BUFF_POLICY;
const seen = new Set<string>();
return {
schemaVersion: 1,
rules: policy.rules.flatMap((rule) => {
if (!rule || typeof rule.abilityId !== "string" || !/^[a-z0-9][a-z0-9:._-]{0,127}$/i.test(rule.abilityId)
|| !["party", "tank", "healer"].includes(rule.scope) || seen.has(rule.abilityId)) return [];
seen.add(rule.abilityId);
return [{ abilityId: rule.abilityId, scope: rule.scope }];
}).slice(0, 500),
};
}
export function permanentBuffEffects(ability: AbilityDefinition): readonly Extract<AbilityEffect, { kind: "apply-aura" }>[] {
if (ability.passive || ability.target === "hostile" || ability.talentNodeId || ability.talentEntryId !== undefined) return [];
if (ability.effects.some((effect) => effect.kind !== "apply-aura" && effect.kind !== "unsupported")) return [];
return ability.effects.filter((effect): effect is Extract<AbilityEffect, { kind: "apply-aura" }> => (
effect.kind === "apply-aura" && effect.aura.disposition === "buff"
&& !effect.aura.procs?.length && !effect.aura.controlImmunities?.length
&& Boolean(effect.aura.modifiers?.length)
));
}
+81
View File
@@ -0,0 +1,81 @@
import { create } from "zustand";
import type { PlayerSession } from "../app/types";
import { requestOnlineJson } from "../app/onlineAccountClient";
import { DEFAULT_PARTY_BUFF_POLICY, normalizePartyBuffPolicy, type PartyBuffPolicy } from "./partyBuffPolicy";
interface PublishedPolicy {
readonly revision: number;
readonly updatedAt: number | null;
readonly updatedBy?: string | null;
readonly config: PartyBuffPolicy;
}
interface PolicyState extends PublishedPolicy {
readonly draft: PartyBuffPolicy;
readonly loading: boolean;
readonly saving: boolean;
readonly dirty: boolean;
readonly error: string | null;
refresh: (session?: PlayerSession | null) => Promise<void>;
replaceDraft: (draft: PartyBuffPolicy) => void;
resetDraft: () => void;
publish: (session: PlayerSession | null) => Promise<boolean>;
}
const CACHE_KEY = "healer-man.party-buff-policy.v1";
function cached(): PublishedPolicy {
try {
const value = JSON.parse(window.localStorage.getItem(CACHE_KEY) ?? "null") as PublishedPolicy;
if (!Number.isInteger(value.revision)) throw new Error("missing");
return { ...value, config: normalizePartyBuffPolicy(value.config) };
} catch {
return { revision: 0, updatedAt: null, config: DEFAULT_PARTY_BUFF_POLICY };
}
}
function cache(payload: PublishedPolicy): void {
try { window.localStorage.setItem(CACHE_KEY, JSON.stringify(payload)); } catch { /* optional cache */ }
}
const initial = cached();
export const usePartyBuffPolicyStore = create<PolicyState>((set, get) => ({
...initial,
draft: initial.config,
loading: false,
saving: false,
dirty: false,
error: null,
refresh: async (session = null) => {
set({ loading: true, error: null });
try {
const gm = Boolean(session?.accessToken && session.roles?.includes("gm"));
const payload = await requestOnlineJson<PublishedPolicy>(
gm ? "/api/gm/party-buff-policy" : "/api/party-buff-policy", {}, gm ? session?.accessToken : undefined,
);
const normalized = { ...payload, config: normalizePartyBuffPolicy(payload.config) };
cache(normalized);
set({ ...normalized, draft: normalized.config, loading: false, dirty: false });
} catch (error) {
set({ loading: false, error: error instanceof Error ? error.message : "Could not load party buff policy." });
}
},
replaceDraft: (draft) => set({ draft: normalizePartyBuffPolicy(draft), dirty: true, error: null }),
resetDraft: () => set((state) => ({ draft: state.config, dirty: false, error: null })),
publish: async (session) => {
if (!session?.accessToken || !session.roles?.includes("gm")) {
set({ error: "Game Master access is required." });
return false;
}
set({ saving: true, error: null });
try {
const payload = await requestOnlineJson<PublishedPolicy>("/api/gm/party-buff-policy", {
method: "PUT", body: JSON.stringify({ expectedRevision: get().revision, config: get().draft }),
}, session.accessToken);
const normalized = { ...payload, config: normalizePartyBuffPolicy(payload.config) };
cache(normalized);
set({ ...normalized, draft: normalized.config, saving: false, dirty: false });
return true;
} catch (error) {
set({ saving: false, error: error instanceof Error ? error.message : "Could not publish party buff policy." });
return false;
}
},
}));
+3
View File
@@ -38,6 +38,7 @@ export interface GameState {
sessionRevision: number;
dungeonIntroActive: boolean;
dungeonIntroCompletedAt: number | null;
raceStartBlocked: boolean;
overlay: GameOverlay;
paused: boolean;
mapOpen: boolean;
@@ -108,6 +109,7 @@ export const useGameStore = create<GameState>((set) => ({
sessionRevision: 0,
dungeonIntroActive: false,
dungeonIntroCompletedAt: null,
raceStartBlocked: false,
overlay: null,
paused: false,
mapOpen: false,
@@ -148,6 +150,7 @@ export const useGameStore = create<GameState>((set) => ({
sessionRevision: state.sessionRevision + 1,
dungeonIntroActive: options?.showDungeonIntro ?? false,
dungeonIntroCompletedAt: null,
raceStartBlocked: false,
overlay: null,
paused: false,
mapOpen: false,
+11 -7
View File
@@ -48,6 +48,10 @@ interface WailingReferenceShape {
}
const REFERENCE = (referenceData as unknown as WailingReferenceShape).wailingCaverns;
export const WAILING_NARALEX_START_POSITION = REFERENCE.disciple.position;
export const WAILING_NARALEX_ESCORT_WAYPOINTS = Object.freeze(
REFERENCE.disciple.escortWaypoints.map((waypoint) => waypoint.position),
);
const FANG_LORD_RUNTIME_IDS = Object.freeze([
"lady-anacondra-spawn",
"lord-cobrahn-spawn",
@@ -343,34 +347,34 @@ export function wailingDisciplePosition(
phaseStartedAt: number,
now = Date.now(),
): MobVector3 {
const waypoints = REFERENCE.disciple.escortWaypoints;
const waypoints = WAILING_NARALEX_ESCORT_WAYPOINTS;
if (!waypoints.length || phase === "locked" || phase === "ready") {
return REFERENCE.disciple.position;
return WAILING_NARALEX_START_POSITION;
}
let start = 0;
let end = 4;
let duration = 8_000;
if (phase === "ravager-wave") return waypoints[4]?.position ?? REFERENCE.disciple.position;
if (phase === "ravager-wave") return waypoints[4] ?? WAILING_NARALEX_START_POSITION;
if (phase === "escort-serpents") {
start = 5;
end = 11;
duration = 12_000;
} else if (phase === "serpent-wave") {
return waypoints[11]?.position ?? REFERENCE.disciple.position;
return waypoints[11] ?? WAILING_NARALEX_START_POSITION;
} else if (phase === "cleansing") {
start = 12;
end = waypoints.length - 1;
duration = 32_500;
} else {
return waypoints.at(-1)?.position ?? REFERENCE.disciple.position;
return waypoints.at(-1) ?? WAILING_NARALEX_START_POSITION;
}
const progress = Math.max(0, Math.min(1, (now - phaseStartedAt) / duration));
const scaled = start + (end - start) * progress;
const lower = Math.floor(scaled);
const upper = Math.min(end, lower + 1);
const alpha = scaled - lower;
const left = waypoints[lower]?.position ?? REFERENCE.disciple.position;
const right = waypoints[upper]?.position ?? left;
const left = waypoints[lower] ?? WAILING_NARALEX_START_POSITION;
const right = waypoints[upper] ?? left;
return [
left[0] + (right[0] - left[0]) * alpha,
left[1] + (right[1] - left[1]) * alpha,
+7 -5
View File
@@ -34,6 +34,7 @@ import {
advanceCombatEffectFrame,
addOriginalInstantCastEffect,
capCombatEffects,
combatEffectVisualTuning,
updateCombatEffectQueue,
originalInstantCastEvent,
originalInstantTerminalEvent,
@@ -151,6 +152,7 @@ const CombatEffectPrimitive = memo(function CombatEffectPrimitive({
const elapsedRef = useRef(0);
const doneRef = useRef(false);
const style = useMemo(() => combatEffectStyle(event), [event]);
const tuning = combatEffectVisualTuning(event);
const particleCount = QUALITY_PARTICLE_COUNT[quality];
const positions = useMemo(
() => deterministicParticles(event.id, particleCount),
@@ -182,21 +184,21 @@ const CombatEffectPrimitive = memo(function CombatEffectPrimitive({
if (projectile) {
groupRef.current.position.lerpVectors(origin, target, eased);
groupRef.current.position.y += 1.05 + Math.sin(progress * Math.PI) * 0.28;
groupRef.current.scale.setScalar(0.72 + Math.sin(progress * Math.PI) * 0.32);
groupRef.current.scale.setScalar((0.72 + Math.sin(progress * Math.PI) * 0.32) * tuning.scale);
} else {
groupRef.current.rotation.y += frame.motionDeltaSeconds * (event.school === "physical" ? 4 : 1.8);
const scale = event.phase === "cast-start"
? 0.85 + Math.sin(progress * Math.PI * 3) * 0.08
: 0.35 + eased * (event.delivery === "area" ? Math.max(1.2, event.radius ?? 2.4) : 1.75);
groupRef.current.scale.setScalar(scale);
groupRef.current.scale.setScalar(scale * tuning.scale);
if (!isGround && event.delivery !== "melee") groupRef.current.position.y = initialY + progress * 0.55;
}
const opacity = event.phase === "cast-start"
? Math.min(1, (1 - progress) * 1.8)
: Math.max(0, 1 - progress);
if (pointsMaterialRef.current) opacity >= 0 && (pointsMaterialRef.current.opacity = opacity * 0.9);
if (primaryMaterialRef.current) primaryMaterialRef.current.opacity = opacity * 0.72;
if (secondaryMaterialRef.current) secondaryMaterialRef.current.opacity = opacity * 0.55;
if (pointsMaterialRef.current) pointsMaterialRef.current.opacity = opacity * 0.9 * tuning.opacity;
if (primaryMaterialRef.current) primaryMaterialRef.current.opacity = opacity * 0.72 * tuning.opacity;
if (secondaryMaterialRef.current) secondaryMaterialRef.current.opacity = opacity * 0.55 * tuning.opacity;
if (frame.done) {
doneRef.current = true;
onDone();
+2
View File
@@ -172,6 +172,7 @@ export function GameScene() {
const roamingPacks = sessionPopulation.roamingPacks;
const paused = useGameStore((state) => state.paused);
const dungeonIntroActive = useGameStore((state) => state.dungeonIntroActive);
const raceStartBlocked = useGameStore((state) => state.raceStartBlocked);
const mapOpen = useGameStore((state) => state.mapOpen);
const companionOpen = useGameStore((state) => state.companionOpen);
const simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode);
@@ -193,6 +194,7 @@ export function GameScene() {
const simulationBlocked = graphicsRecovery !== null
|| !worldReady
|| dungeonIntroActive
|| raceStartBlocked
|| (!documentVisible && !sharedOnline)
|| (!sharedOnline && (paused || mapOpen || companionOpen));
const frameloop = sceneFrameLoop({
+8 -2
View File
@@ -119,6 +119,9 @@ const lootLabelStyle: CSSProperties = {
};
const POPULATION_ACTIVATION_DISTANCE = 95;
// Scholomance stacks rooms on several floors around its entrance. Its standard
// radius mounts nearly 200 mobs at once, including creatures behind walls.
const SCHOLOMANCE_ACTIVATION_DISTANCE = 48;
function mobTargetPosition(
targetActorId: string | null | undefined,
@@ -808,7 +811,10 @@ function isPlayerOrPartyNear(points: readonly MobVector3[], maxDistance: number)
* so the player's 10 Hz HUD snapshot does not re-render the population tree.
*/
function usePopulationProximity(points: readonly MobVector3[]): boolean {
const initial = isPlayerOrPartyNear(points, POPULATION_ACTIVATION_DISTANCE);
const activationDistance = useGameStore.getState().activeDungeonId === "scholomance"
? SCHOLOMANCE_ACTIVATION_DISTANCE
: POPULATION_ACTIVATION_DISTANCE;
const initial = isPlayerOrPartyNear(points, activationDistance);
const nearbyRef = useRef(initial);
const [nearby, setNearby] = useState(initial);
const elapsedRef = useRef(0);
@@ -819,7 +825,7 @@ function usePopulationProximity(points: readonly MobVector3[]): boolean {
elapsedRef.current = 0;
// Small hysteresis prevents mount/unmount churn at the boundary.
const distance = POPULATION_ACTIVATION_DISTANCE + (nearbyRef.current ? 18 : 0);
const distance = activationDistance + (nearbyRef.current ? 18 : 0);
const next = isPlayerOrPartyNear(points, distance);
if (next === nearbyRef.current) return;
nearbyRef.current = next;
+8 -4
View File
@@ -51,7 +51,7 @@ import {
} from "./originalCombatMeshRuntime";
import { combatEffectTargetVisualSize, updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry";
import {
projectileTravelDurationMs, risingTideImpactPlacement, shouldUseGeneratedOriginalFallback,
combatEffectVisualTuning, projectileTravelDurationMs, risingTideImpactPlacement, shouldUseGeneratedOriginalFallback,
} from "./combatEffectRuntime";
import { OriginalWowModel } from "./OriginalWowModel";
import { OriginalParticleGeometry } from "./OriginalParticleGeometry";
@@ -329,6 +329,7 @@ export const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
modelGraph,
sourceTextures,
timelineRef,
opacity = 1,
}: {
readonly graph: OriginalCombatEffectGraph;
readonly particle: OriginalCombatEffectParticle;
@@ -341,6 +342,7 @@ export const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
readonly modelGraph?: OriginalWowModelGraph;
readonly sourceTextures?: ReadonlyMap<string, Texture>;
readonly timelineRef?: MutableRefObject<number>;
readonly opacity?: number;
}) {
const elapsedRef = useRef(0);
const sourcePose = useMemo(() => ({
@@ -485,7 +487,7 @@ export const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
const scaleVariation = 1 + (sourceRandom(cycleSeed + 8) * 2 - 1)
* (particle.scaleVariation[0] + particle.scaleVariation[1]) * 0.5;
colors.setXYZ(index, color[0] / 255, color[1] / 255, color[2] / 255);
alphas.setX(index, Math.min(1, Math.max(0, alpha / 32_767)));
alphas.setX(index, Math.min(1, Math.max(0, alpha / 32_767)) * opacity);
sizes.setX(index, Math.max(0.002, (scale[0] + scale[1]) * 0.5 * Math.max(0.05, scaleVariation)));
const frameSamples = particle.samples.headCell.length
? particle.samples.headCell
@@ -729,6 +731,7 @@ function LoadedOriginalCombatEffect({
? event.origin
: event.targetPosition ?? event.origin;
const loopTimeline = event.phase === "cast-start" || event.phase === "aura-start";
const tuning = combatEffectVisualTuning(event);
useFrame((_state, delta) => {
if (!active || doneRef.current) return;
@@ -796,7 +799,7 @@ function LoadedOriginalCombatEffect({
&& item.asset.model?.toLowerCase() === "model/fx/skill/ice/act_riptide_t_freeze_hit.ros"
? risingTideImpactPlacement(event, combatEffectTargetVisualSize(event.targetActorId))
: null;
const scale = effectScale(item.asset) * (risingTideTarget?.scale ?? 1);
const scale = effectScale(item.asset) * (risingTideTarget?.scale ?? 1) * tuning.scale;
return (
<group
key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`}
@@ -806,7 +809,7 @@ function LoadedOriginalCombatEffect({
scale={scale}
>
{graph.kind === "wow-m2-model" && <OriginalWowModel graph={graph} textures={item.textures}
active={active} playbackRef={staticMeshPlaybackRef} loop={loopTimeline} />}
active={active} playbackRef={staticMeshPlaybackRef} loop={loopTimeline} opacity={tuning.opacity} />}
{graph.kind === "runewaker-ros-particle-archive" ? (
<RunewakerParticleEffect graph={graph} textures={item.sourceTextures}
phase={event.phase} quality={quality} active={active} eventId={event.id}
@@ -835,6 +838,7 @@ function LoadedOriginalCombatEffect({
eventId={event.id}
emitterIndex={loadedIndex * 4_096 + particleIndex}
loopTimeline={loopTimeline}
opacity={tuning.opacity}
/>
) : null;
})}
+7 -5
View File
@@ -11,12 +11,13 @@ import { updateWowBoneMatrices, wowModelFrame, type OriginalWowModelGraph, type
const blend = (mode: number) => [3, 4, 7].includes(mode) ? AdditiveBlending
: [5, 6].includes(mode) ? MultiplyBlending : NormalBlending;
export const OriginalWowModel = memo(function OriginalWowModel({ graph, textures, active, playbackRef, loop }: {
export const OriginalWowModel = memo(function OriginalWowModel({ graph, textures, active, playbackRef, loop, opacity = 1 }: {
readonly graph: OriginalWowModelGraph;
readonly textures: ReadonlyMap<number, Texture>;
readonly active: boolean;
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
readonly loop: boolean;
readonly opacity?: number;
}) {
const root = useRef<Group>(null);
const scratch = useMemo(() => ({
@@ -111,7 +112,7 @@ export const OriginalWowModel = memo(function OriginalWowModel({ graph, textures
const rgb = color ? sampleOriginalCombatMeshTrack(graph, color.color, frame) : undefined;
material.color.setRGB(...(rgb ?? [1, 1, 1]));
material.opacity = Math.max(0, Math.min(1, (color ? sampleOriginalCombatMeshTrack(graph, color.alpha, frame) ?? 32767 : 32767)
* (weight ? sampleOriginalCombatMeshTrack(graph, weight, frame) ?? 32767 : 32767) / (32767 * 32767)));
* (weight ? sampleOriginalCombatMeshTrack(graph, weight, frame) ?? 32767 : 32767) / (32767 * 32767))) * opacity;
maps.forEach((texture, slot) => {
const transform = graph.textureTransforms[layer.textureTransformIndices[slot]];
if (!transform) return;
@@ -127,14 +128,15 @@ export const OriginalWowModel = memo(function OriginalWowModel({ graph, textures
{geometry && <mesh name="original-wow-animated-mesh" geometry={geometry}
material={materials.map((entry) => entry.material)} frustumCulled={false} />}
{graph.ribbons.map((ribbon, index) => <OriginalWowRibbon key={index} graph={graph} ribbon={ribbon}
textures={textures} active={active} bones={scratch.bones} playbackRef={playbackRef} loop={loop} />)}
textures={textures} active={active} bones={scratch.bones} playbackRef={playbackRef} loop={loop} opacity={opacity} />)}
</group>;
});
const OriginalWowRibbon = memo(function OriginalWowRibbon({ graph, ribbon, textures, active, bones, playbackRef, loop }: {
const OriginalWowRibbon = memo(function OriginalWowRibbon({ graph, ribbon, textures, active, bones, playbackRef, loop, opacity }: {
readonly graph: OriginalWowModelGraph; readonly ribbon: WowRibbon; readonly textures: ReadonlyMap<number, Texture>;
readonly active: boolean; readonly bones: readonly Matrix4[];
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>; readonly loop: boolean;
readonly opacity: number;
}) {
const root = useRef<Group>(null);
const capacity = Math.min(128, Math.max(4, Math.ceil(ribbon.edgesPerSecond * ribbon.edgeLifetime) + 2));
@@ -194,7 +196,7 @@ const OriginalWowRibbon = memo(function OriginalWowRibbon({ graph, ribbon, textu
}
});
material.color.setRGB(...(sampleOriginalCombatMeshTrack(graph, ribbon.tracks.color, frame) ?? [1, 1, 1]));
material.opacity = Math.max(0, Math.min(1, (sample("alpha") ?? 32767) / 32767));
material.opacity = Math.max(0, Math.min(1, (sample("alpha") ?? 32767) / 32767)) * opacity;
positions.needsUpdate = true; uv.needsUpdate = true;
geometry.setDrawRange(0, Math.max(0, history.edges.length - 1) * 6);
});
+34 -6
View File
@@ -10,6 +10,7 @@ import {
useMemo,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type ErrorInfo,
type ReactNode,
@@ -29,6 +30,11 @@ import { resolveDungeonAssets, resolveManastormStageAssets } from "../game/dunge
import { activeManastormStageAssetPackage } from "../game/manastormStageLoader";
import { derivePartySpawnSlots } from "../game/partySpawnSlots";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import {
BITE_SIZED_DEADMINES_DUNGEON_ID,
BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID,
BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID,
} from "../game/dungeonContentSource";
import type { DungeonBossObjective, DungeonTraversalRamp } from "../game/dungeonTypes";
import { getMobPosition } from "../game/mobRuntimeRegistry";
import {
@@ -82,6 +88,11 @@ import {
import { partyCombatRangeBand } from "../game/partyRuntime";
import { partyRejoinMovement } from "../game/partyRejoin";
import { useWailingEncounterStore } from "../game/wailingCavernsEncounter";
import {
activeDungeonEncounterRevision,
activeDungeonEncounterSpawnGroupIds,
subscribeDungeonEncounterSnapshots,
} from "../game/dungeonEncounterRuntime";
import {
clearPartyRuntimeAttackCorridor,
consumePartyRuntimeRejoin,
@@ -1578,10 +1589,31 @@ export function PartyPopulation({
const gameMode = useGameStore((state) => state.gameMode);
const sessionRevision = useGameStore((state) => state.sessionRevision);
const wailingPhase = useWailingEncounterStore((state) => state.phase);
useSyncExternalStore(
subscribeDungeonEncounterSnapshots,
activeDungeonEncounterRevision,
activeDungeonEncounterRevision,
);
const manastormEncounter = useManastormStore((state) => state.currentEncounter);
const simulationSessionMode = useDungeonSimulationStore((state) => state.sessionMode);
useDungeonRouteStore((state) => state.approvals);
const dungeon = requireDungeonDefinition(activeDungeonId);
const activeSpawnGroups = new Set(activeDungeonEncounterSpawnGroupIds());
const eventObjectiveIds = new Set((dungeon.encounterSpawnGroups ?? [])
.filter((group) => activeSpawnGroups.has(group.id))
.flatMap((group) => group.spawns.map((spawn) => spawn.id)));
const gateEventObjectives = activeDungeonId === "wailing-caverns"
|| activeDungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID
|| activeDungeonId === BITE_SIZED_DEADMINES_DUNGEON_ID
|| activeDungeonId === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID;
const availableObjectives = dungeon.objectiveOrder.filter((objective) => (
!objective.optional
&& (!objective.eventOnly || !gateEventObjectives
|| eventObjectiveIds.has(objective.id)
|| Boolean(useCombatStore.getState().mobs[objective.id])
|| (activeDungeonId === "wailing-caverns"
&& (wailingPhase === "mutanus" || wailingPhase === "complete")))
));
const navigationReviewed = dungeon.validation.status === "green"
|| dungeon.validation.navigation === "green";
const manastormPackage = gameMode === "manastorm"
@@ -1598,13 +1630,9 @@ export function PartyPopulation({
// reviewed links make navigation green, the player leads and the party
// follows/engages locally instead of displaying a false blocked route.
: simulationSessionMode === "spectate-live" || Boolean(routeForActiveDungeon(activeDungeonId))
? dungeon.objectiveOrder.filter((objective) => !objective.optional)
? availableObjectives
: navigationReviewed
? activeDungeonId === "wailing-caverns"
&& wailingPhase !== "mutanus"
&& wailingPhase !== "complete"
? dungeon.objectiveOrder.filter((objective) => !objective.eventOnly)
: dungeon.objectiveOrder.filter((objective) => !objective.optional)
? availableObjectives
: [];
const assets = useMemo(
() => manastormPackage
+22
View File
@@ -6,6 +6,8 @@ import {
DoubleSide,
Float32BufferAttribute,
Group,
InterleavedBuffer,
InterleavedBufferAttribute,
Mesh,
MeshBasicMaterial,
MeshStandardMaterial,
@@ -75,6 +77,26 @@ describe("streamed dungeon collision preparation", () => {
expect(Array.from(trimesh.indices)).toEqual([0, 1, 2]);
});
it("unpacks interleaved position buffers before building collision trimeshes", () => {
const geometry = new BufferGeometry();
geometry.setAttribute("position", new InterleavedBufferAttribute(
new InterleavedBuffer(new Float32Array([
0, 0, 0, 101,
2, 0, 0, 102,
0, 2, 0, 103,
]), 4),
3,
0,
));
geometry.setIndex([0, 1, 2]);
const root = new Group();
root.add(new Mesh(geometry, new MeshBasicMaterial()));
const [trimesh] = collisionTrimeshes(prepareCollision(root));
expect(Array.from(trimesh.vertices)).toEqual([0, 0, 0, 2, 0, 0, 0, 2, 0]);
expect(Array.from(trimesh.indices)).toEqual([0, 1, 2]);
});
it("builds sight collision from walls while excluding ground and ramps", () => {
const geometry = new BufferGeometry();
geometry.setAttribute(
+59 -3
View File
@@ -53,7 +53,9 @@ function prepareVisual(root: Object3D, definition: DungeonDefinition) {
instanced.computeBoundingSphere();
}
});
instanceRepeatedStaticMeshes(clone);
// Scholomance's compact visual has thousands of opaque repetitions. Grouping
// them across the level saves draw calls without expanding a large triangle set.
instanceRepeatedStaticMeshes(clone, 3, undefined, definition.id === "scholomance");
const cells = createStaticVisibilityCells(clone);
cacheStaticMatrices(clone);
return { visual: clone, cells };
@@ -163,6 +165,46 @@ function CollisionChunk({
);
}
function NavigationGround({
url,
definition,
registerPrepared,
}: {
readonly url: string;
readonly definition: DungeonDefinition;
readonly registerPrepared: (url: string, ready: boolean) => void;
}) {
const gltf = useGameGLTF(url);
const ground = useMemo(() => prepareCollision(gltf.scene), [gltf.scene]);
const trimeshes = useMemo(() => collisionTrimeshes(ground), [ground]);
useEffect(() => () => {
disposeObject3DResources(ground, { materials: false, textures: false, skeletons: false });
}, [ground]);
useEffect(() => {
registerPrepared(url, trimeshes.length > 0);
return () => registerPrepared(url, false);
}, [registerPrepared, trimeshes.length, url]);
const { position, rotation, scale } = definition.transform;
return (
<RigidBody
type="fixed"
colliders={false}
name={`${definition.id}-navigation-ground`}
position={position}
rotation={rotation}
scale={scale}
>
{trimeshes.map((trimesh) => (
<TrimeshCollider
key={trimesh.id}
args={[trimesh.vertices, trimesh.indices]}
collisionGroups={DUNGEON_PHYSICAL_COLLISION_GROUPS}
/>
))}
</RigidBody>
);
}
export function ProductionDungeon({
definition,
assets,
@@ -175,6 +217,11 @@ export function ProductionDungeon({
if (!assets.visual.length || !assets.collision.length) {
throw new Error(`${definition.title} mounted without its required visual and collision GLBs.`);
}
// Raksha's WDB collision omits the walkable entrance floor present in its navmesh.
const navigationGroundUrl = definition.id === "raksha-temple" ? assets.navigation : null;
if (definition.id === "raksha-temple" && !navigationGroundUrl) {
throw new Error("Raksha Temple mounted without its required navigation ground GLB.");
}
const [loadedVisualCount, setLoadedVisualCount] = useState(0);
const [loadedCollisionCount, setLoadedCollisionCount] = useState(0);
const preparedCollisions = useRef(new Set<string>());
@@ -229,10 +276,10 @@ export function ProductionDungeon({
}
preparedCollisions.current.add(url);
if (signalledFirstFrame.current) return;
if (preparedCollisions.current.size < assets.collision.length) return;
if (preparedCollisions.current.size < assets.collision.length + (navigationGroundUrl ? 1 : 0)) return;
signalledFirstFrame.current = true;
onReady?.();
}, [assets.collision.length, onReady]);
}, [assets.collision.length, navigationGroundUrl, onReady]);
return (
<group name={`${definition.id}-production`}>
@@ -247,6 +294,15 @@ export function ProductionDungeon({
registerPrepared={registerPrepared}
/>
))}
{navigationGroundUrl && (
<Suspense fallback={null}>
<NavigationGround
url={navigationGroundUrl}
definition={definition}
registerPrepared={registerPrepared}
/>
</Suspense>
)}
<AssetBatchLoader
key={`${loadedVisualCount}:${loadedCollisionCount}:${pendingUrls.join("|")}`}
urls={pendingUrls}
+45 -10
View File
@@ -1,10 +1,17 @@
import { useFrame } from "@react-three/fiber";
import { useRef } from "react";
import { useRef, useSyncExternalStore } from "react";
import type { Group } from "three";
import { BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID } from "../game/dungeonContentSource";
import {
dispatchDungeonEncounterSignal,
dungeonEncounterSnapshot,
subscribeDungeonEncounterSnapshots,
} from "../game/dungeonEncounterRuntime";
import { useGameStore } from "../game/store";
import {
useWailingEncounterStore,
wailingDisciplePosition,
type WailingEncounterPhase,
} from "../game/wailingCavernsEncounter";
import { WorldNpcPrompt } from "./WorldNpcPrompt";
import { dungeonSimulationNow } from "../game/dungeonSimulationStore";
@@ -12,23 +19,44 @@ import { dungeonSimulationNow } from "../game/dungeonSimulationStore";
export function WailingNaralexEvent() {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const gameMode = useGameStore((state) => state.gameMode);
const phase = useWailingEncounterStore((state) => state.phase);
const phaseStartedAt = useWailingEncounterStore((state) => state.phaseStartedAt);
const sourcePhase = useWailingEncounterStore((state) => state.phase);
const sourcePhaseStartedAt = useWailingEncounterStore((state) => state.phaseStartedAt);
const biteEncounterId = `${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-escort`;
useSyncExternalStore(
subscribeDungeonEncounterSnapshots,
() => dungeonEncounterSnapshot(biteEncounterId)?.revision ?? 0,
() => 0,
);
const biteSnapshot = dungeonEncounterSnapshot(biteEncounterId);
const phase = activeDungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID
? biteSnapshot?.phase ?? "ready"
: sourcePhase;
const phaseStartedAt = activeDungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID
? biteSnapshot?.phaseStartedAt ?? 0
: sourcePhaseStartedAt;
const isBiteSized = activeDungeonId === BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID;
const groupRef = useRef<Group>(null);
useFrame(() => {
if (activeDungeonId !== "wailing-caverns" || gameMode !== "dungeon") return;
if (
(activeDungeonId !== "wailing-caverns" && !isBiteSized)
|| gameMode !== "dungeon"
) return;
const liveSnapshot = isBiteSized ? dungeonEncounterSnapshot(biteEncounterId) : null;
const now = dungeonSimulationNow();
const position = wailingDisciplePosition(
useWailingEncounterStore.getState().phase,
useWailingEncounterStore.getState().phaseStartedAt,
(liveSnapshot?.phase ?? useWailingEncounterStore.getState().phase) as Parameters<typeof wailingDisciplePosition>[0],
liveSnapshot?.phaseStartedAt ?? useWailingEncounterStore.getState().phaseStartedAt,
now,
);
groupRef.current?.position.set(...position);
});
if (activeDungeonId !== "wailing-caverns" || gameMode !== "dungeon") return null;
const initialPosition = wailingDisciplePosition(phase, phaseStartedAt);
if (
(activeDungeonId !== "wailing-caverns" && !isBiteSized)
|| gameMode !== "dungeon"
) return null;
const initialPosition = wailingDisciplePosition(phase as WailingEncounterPhase, phaseStartedAt);
return (
<group ref={groupRef} name="disciple-of-naralex-event" position={initialPosition}>
<mesh position={[0, 0.9, 0]} castShadow>
@@ -48,14 +76,21 @@ export function WailingNaralexEvent() {
type="button"
onClick={(event) => {
event.stopPropagation();
useWailingEncounterStore.getState().start();
if (isBiteSized) {
dispatchDungeonEncounterSignal(
`${BITE_SIZED_WAILING_CAVERNS_DUNGEON_ID}:naralex-start`,
dungeonSimulationNow(),
);
} else {
useWailingEncounterStore.getState().start();
}
}}
>
Begin the awakening
</button>
) : null}
{phase === "cleansing" ? <small>Channeling Serpentine Cleansing…</small> : null}
{phase === "complete" ? <small>Naralex has awakened</small> : null}
{phase === "complete" ? <small>{isBiteSized ? "Naralex has been rescued" : "Naralex has awakened"}</small> : null}
</div>
</WorldNpcPrompt>
</group>
+10
View File
@@ -5,6 +5,7 @@ import {
addOriginalInstantCastEffect,
addOriginalOnlyEffect,
advanceCombatEffectFrame,
combatEffectVisualTuning,
originalInstantCastEvent,
originalInstantTerminalEvent,
projectileTravelDurationMs,
@@ -49,6 +50,15 @@ function enqueue(
}
describe("combat-effect runtime budget", () => {
it("contains the Dispel Magic impact without shrinking Mass Dispel", () => {
expect(combatEffectVisualTuning(event(1, { abilityId: "wow335-priest-dispel-magic" })))
.toEqual({ scale: 0.45, opacity: 0.4 });
expect(combatEffectVisualTuning(event(2, { abilityId: "wow335-priest-dispel-magic", phase: "cast-start" })))
.toEqual({ scale: 1, opacity: 1 });
expect(combatEffectVisualTuning(event(3, { abilityId: "wow335-priest-mass-dispel" })))
.toEqual({ scale: 1, opacity: 1 });
});
it("delays only the Rising Tide target arrival by the projectile's travel time", () => {
const release = event(1, { source: "runewaker", sourceSpellId: 490256,
phase: "release", origin: [0, 0, 0], targetPosition: [14, 0, 0] });
+9
View File
@@ -16,6 +16,15 @@ export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, numbe
high: 32,
};
/** Keep the single-target Dispel Magic flash within the target silhouette. */
export function combatEffectVisualTuning(
event: Pick<CombatPresentationEvent, "abilityId" | "phase">,
): { readonly scale: number; readonly opacity: number } {
return event.abilityId === "wow335-priest-dispel-magic" && event.phase === "impact"
? { scale: 0.45, opacity: 0.4 }
: { scale: 1, opacity: 1 };
}
/** Keep source projectile travel and its arrival cue on the same presentation clock. */
export function projectileTravelDurationMs(
event: Pick<CombatPresentationEvent, "origin" | "targetPosition" | "delivery">,
+6 -1
View File
@@ -1,6 +1,7 @@
import {
Float32BufferAttribute,
Group,
InterleavedBufferAttribute,
Mesh,
type Object3D,
} from "three";
@@ -27,7 +28,11 @@ export function prepareCollision(root: Object3D, ramps: readonly DungeonTraversa
if (!mesh.isMesh) return;
const geometry = mesh.geometry.clone();
const position = geometry.getAttribute("position");
if (position.normalized || !(position.array instanceof Float32Array)) {
if (
position instanceof InterleavedBufferAttribute
|| position.normalized
|| !(position.array instanceof Float32Array)
) {
const dequantized = new Float32Array(position.count * position.itemSize);
for (let vertex = 0; vertex < position.count; vertex += 1) {
const offset = vertex * position.itemSize;
+21
View File
@@ -153,4 +153,25 @@ describe("static scene instancing", () => {
});
expect(batchesIn(root).map((batch) => batch.count)).toEqual([3, 3]);
});
it("can combine distant opaque repetitions while retaining transparent cells", () => {
const root = new Group();
const geometry = new BoxGeometry();
const opaque = new MeshBasicMaterial();
const transparent = new MeshBasicMaterial({ transparent: true });
for (const x of [1, 2, 3, 385, 386, 387]) {
for (const material of [opaque, transparent]) {
const mesh = new Mesh(geometry, material);
mesh.position.x = x;
root.add(mesh);
}
}
expect(instanceRepeatedStaticMeshes(root, 3, 192, true)).toEqual({
batches: 3,
instances: 12,
removedMeshes: 12,
});
expect(batchesIn(root).map((batch) => batch.count)).toEqual([6, 3, 3]);
});
});
+10 -1
View File
@@ -47,6 +47,11 @@ function materialKey(material: Material | readonly Material[]): string {
.join(",");
}
function hasTransparency(material: Material | readonly Material[]): boolean {
return (Array.isArray(material) ? material : [material])
.some((entry) => entry.transparent);
}
function candidateKey(mesh: Mesh): string {
return [
mesh.geometry.uuid,
@@ -93,6 +98,7 @@ export function instanceRepeatedStaticMeshes(
root: Object3D,
minimumInstances = 3,
cellSize = STATIC_INSTANCE_CELL_SIZE,
globalOpaque = false,
): StaticSceneInstancingReport {
root.updateWorldMatrix(true, true);
const safeCellSize = Number.isFinite(cellSize) && cellSize > 0
@@ -102,7 +108,10 @@ export function instanceRepeatedStaticMeshes(
const groups = new Map<string, Mesh[]>();
root.traverse((node) => {
if (!isStaticInstancingCandidate(root, node)) return;
const key = `${candidateKey(node)}|cell:${candidateCell(node, safeCellSize, center)}`;
const cell = globalOpaque && !hasTransparency(node.material)
? "all"
: candidateCell(node, safeCellSize, center);
const key = `${candidateKey(node)}|cell:${cell}`;
const group = groups.get(key);
if (group) group.push(node);
else groups.set(key, [node]);
+52
View File
@@ -1992,6 +1992,58 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.companion-readiness__actions { flex-wrap: wrap; }
}
.balance-tuning { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; gap: 12px; }
.party-buff-policy { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; gap: 12px; }
.party-buff-policy__header { display: flex; align-items: center; gap: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--front-line); }
.party-buff-policy__header > div { display: grid; gap: 3px; flex: 1; }
.party-buff-policy__header span { color: var(--front-dim); font-size: 8px; letter-spacing: .13em; text-transform: uppercase; }
.party-buff-policy__header strong { font: 20px Georgia, serif; }
.party-buff-policy__intro { max-width: 900px; margin: 0; color: var(--front-dim); font-size: 10px; line-height: 1.5; }
.party-buff-policy__filters { display: flex; align-items: end; gap: 10px; }
.party-buff-policy__filters label { display: grid; gap: 4px; flex: 1; color: var(--front-dim); font-size: 8px; text-transform: uppercase; }
.party-buff-policy input, .party-buff-policy select { min-height: 34px; padding: 6px 8px; border: 1px solid var(--front-line); border-radius: 6px; background: #08140f; color: var(--front-cream); }
.party-buff-policy__filters > span { color: var(--front-gold); font-size: 9px; white-space: nowrap; }
.party-buff-policy__list { min-height: 0; overflow: auto; border: 1px solid var(--front-line); border-radius: 10px; background: rgba(5, 14, 11, .9); }
.party-buff-policy__list > p { padding: 16px; color: var(--front-dim); font-size: 10px; }
.party-buff-policy__row { display: grid; grid-template-columns: 36px minmax(0, 1fr) 170px; align-items: center; gap: 12px; padding: 10px 12px; border-bottom: 1px solid rgba(204,224,180,.1); }
.party-buff-policy__row img { width: 36px; height: 36px; border: 1px solid var(--front-line); border-radius: 6px; object-fit: cover; }
.party-buff-policy__row strong, .party-buff-policy__row small { display: block; }
.party-buff-policy__row strong { font-size: 11px; }
.party-buff-policy__row small { margin-top: 3px; color: var(--front-dim); font-size: 8px; line-height: 1.4; }
.party-buff-policy__row label { display: grid; gap: 4px; color: var(--front-dim); font-size: 8px; text-transform: uppercase; }
.party-buff-policy__actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; border-top: 1px solid var(--front-line); padding-top: 9px; }
.party-buff-policy__actions > span { margin-right: auto; color: var(--front-gold); font-size: 9px; }
.dungeon-queue-policy { display: grid; grid-template-rows: auto auto auto minmax(0, 1fr) auto; gap: 12px; }
.dungeon-queue-policy__header { display: flex; align-items: center; gap: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--front-line); }
.dungeon-queue-policy__header > div { display: grid; gap: 3px; flex: 1; }
.dungeon-queue-policy__header span { color: var(--front-dim); font-size: 8px; letter-spacing: .13em; text-transform: uppercase; }
.dungeon-queue-policy__header strong { font: 20px Georgia, serif; }
.dungeon-queue-policy__intro { max-width: 900px; margin: 0; color: var(--front-dim); font-size: 10px; line-height: 1.5; }
.dungeon-queue-policy__filters { display: flex; align-items: end; gap: 10px; }
.dungeon-queue-policy__filters label { display: grid; gap: 4px; flex: 1; color: var(--front-dim); font-size: 8px; text-transform: uppercase; }
.dungeon-queue-policy input:not([type="checkbox"]), .dungeon-queue-policy select { min-height: 34px; padding: 6px 8px; border: 1px solid var(--front-line); border-radius: 6px; background: #08140f; color: var(--front-cream); }
.dungeon-queue-policy__filters > span { color: var(--front-gold); font-size: 9px; white-space: nowrap; }
.dungeon-queue-policy__list { min-height: 0; overflow: auto; border: 1px solid var(--front-line); border-radius: 10px; background: rgba(5, 14, 11, .9); }
.dungeon-queue-policy__list > p { padding: 16px; color: var(--front-dim); font-size: 10px; }
.dungeon-queue-policy__row { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 10px 12px; border-bottom: 1px solid rgba(204,224,180,.1); cursor: pointer; }
.dungeon-queue-policy__row input { width: 17px; height: 17px; accent-color: var(--front-gold); }
.dungeon-queue-policy__row strong, .dungeon-queue-policy__row small { display: block; }
.dungeon-queue-policy__row strong { font-size: 11px; }
.dungeon-queue-policy__row small { margin-top: 3px; color: var(--front-dim); font-size: 8px; }
.dungeon-queue-policy__row em { color: var(--front-gold); font-size: 9px; font-style: normal; }
.dungeon-queue-policy__actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; border-top: 1px solid var(--front-line); padding-top: 9px; }
.dungeon-queue-policy__actions > span { margin-right: auto; color: var(--front-gold); font-size: 9px; }
@media (max-width: 640px) {
.dungeon-queue-policy__header, .dungeon-queue-policy__filters, .dungeon-queue-policy__actions { flex-wrap: wrap; }
.dungeon-queue-policy__filters label { min-width: 140px; }
.dungeon-queue-policy__actions > span { width: 100%; }
.party-buff-policy__header { flex-wrap: wrap; }
.party-buff-policy__filters { flex-wrap: wrap; }
.party-buff-policy__filters label { min-width: 140px; }
.party-buff-policy__row { grid-template-columns: 36px minmax(0, 1fr); }
.party-buff-policy__row label { grid-column: 2; }
.party-buff-policy__actions { flex-wrap: wrap; }
.party-buff-policy__actions > span { width: 100%; }
}
.balance-tuning__header { display: flex; align-items: center; gap: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--front-line); }
.balance-tuning__header > div:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; }
.balance-tuning__header > div:nth-child(2) span, .balance-tuning__details header span { color: var(--front-dim); font-size: 8px; letter-spacing: .13em; text-transform: uppercase; }
+10 -4
View File
@@ -30,11 +30,17 @@ describe("dungeon map artwork", () => {
it("shows explicitly selected floor artwork", () => {
expect(render("deadmines", { floorLevel: 2 })).toContain("deadmines/maps/floor-2.png");
});
it("preserves the survey fallback for RuneWaker dungeons", () => {
it("shows original RuneWaker artwork with live markers", () => {
const html = render("forsaken-abbey");
expect(html).toContain('viewBox="0 0 430 300"');
expect(html).not.toContain("<image");
expect(html).toContain('data-artwork="unavailable"');
expect(html).toContain('viewBox="0 0 1002 668"');
expect(html).toContain("forsaken-abbey/maps/runewaker-map.png");
expect(html).toContain("map-player");
expect(html).toContain("Dungeon entrance");
});
it("uses a calibrated navigation floorplan when the client has no usable artwork", () => {
const html = render("demon-stronghold");
expect(html).toContain("demon-stronghold/maps/runewaker-map.png");
expect(html).toContain("map-player");
});
it("only draws patrol overlays on request", () => {
expect(render("wailing-caverns", { showPatrols: true })).toContain("map-patrol");
+10 -10
View File
@@ -2,7 +2,7 @@ import { useEffect, useId, useState } from "react";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { useGameStore } from "../game/store";
import { resolveContentUrl } from "../content/contentManager";
import { wowDungeonMapFor, wowMapContains, wowMapFloorFor, wowMapHeading, wowMapPoint } from "../game/wowDungeonMap";
import { dungeonArtworkContains, dungeonArtworkFloorFor, dungeonArtworkHeading, dungeonArtworkMapFor, dungeonArtworkPoint } from "../game/dungeonArtworkMap";
export type MapArtworkStatus = "loading" | "ready" | "unavailable";
@@ -17,30 +17,30 @@ export function DungeonMapGraphic({ position, yaw, compact = false, dungeonId, f
}) {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const dungeon = requireDungeonDefinition(dungeonId ?? activeDungeonId);
const map = wowDungeonMapFor(dungeon);
const floor = map ? wowMapFloorFor(map, position, floorLevel) : null;
const map = dungeonArtworkMapFor(dungeon);
const floor = map ? dungeonArtworkFloorFor(map, position, floorLevel) : null;
const [failedImage, setFailedImage] = useState<string | null>(null);
const [loadedImage, setLoadedImage] = useState<string | null>(null);
const artwork = map && floor && failedImage !== floor.image ? floor : null;
const status = !artwork ? "unavailable" : loadedImage === artwork.image ? "ready" : "loading";
useEffect(() => { onArtworkStatus?.(status); }, [onArtworkStatus, status]);
const presentation = dungeon.presentation.map;
const viewWidth = artwork ? 1002 : presentation.width;
const viewHeight = artwork ? 668 : presentation.height;
const viewWidth = artwork && map ? map.width : presentation.width;
const viewHeight = artwork && map ? map.height : presentation.height;
const { padding, contours } = presentation;
const width = Math.max(1, dungeon.bounds.max[0] - dungeon.bounds.min[0]);
const depth = Math.max(1, dungeon.bounds.max[2] - dungeon.bounds.min[2]);
const project = (point: readonly [number, number, number]): readonly [number, number] => artwork && map
? wowMapPoint(map, artwork, point)
? dungeonArtworkPoint(map, artwork, point)
: [padding + ((point[0] - dungeon.bounds.min[0]) / width) * (viewWidth - padding * 2),
padding + ((point[2] - dungeon.bounds.min[2]) / depth) * (viewHeight - padding * 2)];
const visible = (point: readonly [number, number, number]) => !artwork || !map || wowMapContains(map, artwork, point);
const visible = (point: readonly [number, number, number]) => !artwork || !map || dungeonArtworkContains(map, artwork, point);
const [playerX, playerY] = project(position);
const [headingX, headingY] = artwork && map ? wowMapHeading(map, artwork, position, yaw) : [Math.sin(yaw) * 12, Math.cos(yaw) * 12];
const [headingX, headingY] = artwork && map ? dungeonArtworkHeading(map, artwork, position, yaw) : [Math.sin(yaw) * 12, Math.cos(yaw) * 12];
const markerScale = artwork ? 2 : 1;
const id = `dungeon-map-${useId().replace(/:/g, "")}`;
return (
<svg viewBox={`0 0 ${viewWidth} ${viewHeight}`} role="img" aria-label={`${dungeon.title}${artwork ? ` — ${artwork.name}, original game map` : " survey map"}`} data-artwork={status} data-floor={artwork?.level}>
<svg viewBox={`0 0 ${viewWidth} ${viewHeight}`} role="img" aria-label={`${dungeon.title}${artwork ? ` — ${artwork.name}, ${map?.kind === "runewaker" && map.source.kind === "navigation" ? "navigation floorplan" : "original game map"}` : " survey map"}`} data-artwork={status} data-floor={artwork?.level}>
<defs>
<filter id={`${id}-glow`}><feGaussianBlur stdDeviation="2.5" result="blur" /></filter>
<pattern id={`${id}-grid`} width="28" height="28" patternUnits="userSpaceOnUse">
@@ -49,7 +49,7 @@ export function DungeonMapGraphic({ position, yaw, compact = false, dungeonId, f
<clipPath id={`${id}-clip`}><rect width={viewWidth} height={viewHeight} /></clipPath>
</defs>
<rect width={viewWidth} height={viewHeight} fill={`url(#${id}-grid)`} />
{artwork ? <image key={artwork.image} href={resolveContentUrl(artwork.image)} width="1002" height="668" preserveAspectRatio="none"
{artwork ? <image key={artwork.image} href={resolveContentUrl(artwork.image)} width={viewWidth} height={viewHeight} preserveAspectRatio="none"
onLoad={() => setLoadedImage(artwork.image)} onError={() => setFailedImage(artwork.image)} />
: contours.map((path, index) => <path key={path} className={index === 0 ? "map-contour" : "map-contour map-contour--thin"} d={path} />)}
<g clipPath={`url(#${id}-clip)`}>
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useMemo, useState } from "react";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useShellStore } from "../app/shellStore";
import { useDungeonQueuePolicyStore } from "../app/dungeonQueuePolicyStore";
import { DUNGEON_CATALOG } from "../game/dungeonCatalog";
import { queueModeForDungeon, type DungeonQueueMode } from "../game/dungeonQueuePolicy";
import { BrandMark, FrontSurface } from "./FrontSurface";
const MODES: readonly { id: DungeonQueueMode; label: string }[] = [
{ id: "regular", label: "Random Dungeon" },
{ id: "biteSized", label: "Random Regular Bite-Sized" },
{ id: "randomizedBiteSized", label: "Randomized Bite-Sized" },
];
export function DungeonQueuePolicyScreen({ onClose }: { readonly onClose: () => void }) {
const session = useShellStore((state) => state.session);
const [mode, setMode] = useState<DungeonQueueMode>("regular");
const [query, setQuery] = useState("");
const [notice, setNotice] = useState("");
const draft = useDungeonQueuePolicyStore((state) => state.draft);
const revision = useDungeonQueuePolicyStore((state) => state.revision);
const loading = useDungeonQueuePolicyStore((state) => state.loading);
const saving = useDungeonQueuePolicyStore((state) => state.saving);
const dirty = useDungeonQueuePolicyStore((state) => state.dirty);
const error = useDungeonQueuePolicyStore((state) => state.error);
const refresh = useDungeonQueuePolicyStore((state) => state.refresh);
const setEnabled = useDungeonQueuePolicyStore((state) => state.setEnabled);
const resetDraft = useDungeonQueuePolicyStore((state) => state.resetDraft);
const publish = useDungeonQueuePolicyStore((state) => state.publish);
useEffect(() => { if (sessionCanUseGmTools(session)) void refresh(session); }, [refresh, session]);
const dungeons = useMemo(() => DUNGEON_CATALOG
.filter((dungeon) => queueModeForDungeon(dungeon) === mode)
.filter((dungeon) => dungeon.title.toLowerCase().includes(query.trim().toLowerCase()))
.sort((left, right) => left.title.localeCompare(right.title)), [mode, query]);
const total = DUNGEON_CATALOG.filter((dungeon) => queueModeForDungeon(dungeon) === mode).length;
const enabledCount = total - draft.disabled[mode].length;
if (!sessionCanUseGmTools(session)) return (
<FrontSurface className="dungeon-queue-policy" ariaLabel="GM access denied">
<header className="dungeon-queue-policy__header"><BrandMark compact /><strong>GM access required</strong><button className="front-button" type="button" onClick={onClose}>Back</button></header>
</FrontSurface>
);
return (
<FrontSurface className="dungeon-queue-policy" ariaLabel="Random dungeon queue settings">
<header className="dungeon-queue-policy__header">
<BrandMark compact />
<div><span>Server policy · revision {revision}</span><strong>Random Dungeon Queues</strong></div>
<button className="front-button front-button--quiet" type="button" onClick={onClose}>Back</button>
</header>
<p className="dungeon-queue-policy__intro">Choose which locations can be rolled in each random mode. This affects random queue selection and the next Bite-Sized run. Players can still choose a dungeon directly.</p>
<div className="dungeon-queue-policy__filters">
<label>Queue<select value={mode} onChange={(event) => { setMode(event.target.value as DungeonQueueMode); setQuery(""); }}>
{MODES.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}
</select></label>
<label>Find dungeon<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search this queue" /></label>
<span>{enabledCount} of {total} enabled</span>
</div>
<main className="dungeon-queue-policy__list">
{loading && <p role="status">Loading queue settings…</p>}
{!loading && dungeons.length === 0 && <p>No dungeons match this search.</p>}
{dungeons.map((dungeon) => <label className="dungeon-queue-policy__row" key={dungeon.id}>
<input type="checkbox" checked={!draft.disabled[mode].includes(dungeon.id)}
onChange={(event) => { setEnabled(mode, dungeon.id, event.target.checked); setNotice(""); }} />
<span><strong>{dungeon.title}</strong><small>{dungeon.location} · {dungeon.available ? "Enterable" : "Not yet enterable"}</small></span>
<em>{draft.disabled[mode].includes(dungeon.id) ? "Disabled" : "Enabled"}</em>
</label>)}
</main>
<footer className="dungeon-queue-policy__actions">
{error && <span role="alert">{error}</span>}{notice && <span role="status">{notice}</span>}
<button className="front-button" type="button" disabled={!dirty || saving} onClick={() => { resetDraft(); setNotice("Changes discarded."); }}>Discard changes</button>
<button className="front-button front-button--primary" type="button" disabled={!dirty || saving} onClick={() => void publish(session).then((saved) => { if (saved) setNotice("Queue settings published."); })}>{saving ? "Publishing…" : "Publish settings"}</button>
</footer>
</FrontSurface>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { formatDungeonDuration } from "../game/dungeonRunTypes";
import "./dungeonRace.css";
export function DungeonRaceHud() {
const race = useDungeonRaceStore();
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const interval = window.setInterval(() => setNow(Date.now()), 100);
return () => window.clearInterval(interval);
}, []);
if (!race.inGame || !race.opponent) return null;
const serverNow = now + race.serverOffset;
const remaining = race.startAt ? Math.max(0, Math.ceil((race.startAt - serverNow) / 1000)) : null;
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 <>
<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>
{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>}
{toast && <div className={`dungeon-race-toast dungeon-race-toast--${race.result}`} role="status" aria-live="polite">
{race.result === "win" ? "You win" : "You lose"}
{race.result === "loss" ? <small>Keep going to claim the boss card.</small> : null}
</div>}
</>;
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useMemo, useState } from "react";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { useDungeonRaceStore, type RacePool } from "../app/dungeonRaceStore";
import { loadDungeonQueuePolicy, useDungeonQueuePolicyStore } from "../app/dungeonQueuePolicyStore";
import { requestOnlineJson } from "../app/onlineAccountClient";
import { type RaceBotSource, type RaceBotTier } from "../app/dungeonRaceBot";
import { dungeonsByCategory, randomizedBiteSizedDungeons } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy";
import { formatDungeonDuration } from "../game/dungeonRunTypes";
import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore";
import { specializationById } from "../game/specializationCatalog";
import { BrandMark, FrontSurface } from "./FrontSurface";
import "./dungeonRace.css";
interface BoardEntry { readonly accountId: string; readonly username: string; readonly rank: number;
readonly wins?: number; readonly losses?: number; readonly durationMs?: number }
interface BoardResponse { readonly entries: readonly BoardEntry[]; readonly total: number }
export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void }) {
const character = useShellStore((state) => state.activeCharacter) ?? useShellStore(selectedCharacter);
const session = useShellStore((state) => state.session);
const race = useDungeonRaceStore();
const policy = useDungeonQueuePolicyStore((state) => state.config);
const refreshPolicy = useDungeonQueuePolicyStore((state) => state.refresh);
const [pool, setPool] = useState<RacePool>("authored");
const [dungeonId, setDungeonId] = useState("");
const [practice, setPractice] = useState(!session?.accessToken);
const [tier, setTier] = useState<RaceBotTier>("regular");
const [source, setSource] = useState<RaceBotSource>("wow");
const [role, setRole] = useState<PartyRole | null>(null);
const [period, setPeriod] = useState<"30d" | "all">("30d");
const [boardScope, setBoardScope] = useState<"overall" | "dungeon">("overall");
const [boardView, setBoardView] = useState<"wins" | "times">("wins");
const [board, setBoard] = useState<BoardResponse | null>(null);
const [boardError, setBoardError] = useState("");
useEffect(() => { void refreshPolicy(session); }, [refreshPolicy, session]);
const allEntries = useMemo(() => (pool === "randomized" ? randomizedBiteSizedDungeons()
: dungeonsByCategory("bite-sized")).filter((entry) => entry.available && dungeonEnabledForQueue(policy, entry)),
[pool, policy]);
const boardDungeonId = dungeonId || allEntries[0]?.id || "";
useEffect(() => {
const controller = new AbortController();
const query = new URLSearchParams({ view: boardScope === "overall" ? "wins" : boardView,
period, ...(boardScope === "dungeon" && boardDungeonId ? { dungeon: boardDungeonId } : {}) });
if (boardScope === "dungeon" && !boardDungeonId) { setBoard(null); return; }
void requestOnlineJson<BoardResponse>(`/api/dungeon-races/leaderboard?${query}`, { signal: controller.signal })
.then((value) => { setBoard(value); setBoardError(""); })
.catch((error: unknown) => { if (!controller.signal.aborted) { setBoard(null);
setBoardError(error instanceof Error ? error.message : "Leaderboard unavailable."); } });
return () => controller.abort();
}, [boardDungeonId, boardScope, boardView, period]);
const specialization = specializationById(character?.specializationId);
const roles = character?.categoryId === "rom" ? partyRolesForClass(character.classId)
: specialization ? [specialization.role] : [];
const eligibleCharacter = character && ["wow", "rom"].includes(character.categoryId ?? "wow");
const selectedRole = role && roles.includes(role) ? role : roles[0];
const busy = ["queued", "preparing", "countdown", "active"].includes(race.status) || race.preparingContent;
const start = async () => {
if (!character || !eligibleCharacter || !selectedRole || !allEntries.length) return;
const latest = await loadDungeonQueuePolicy();
const candidates = allEntries.filter((entry) => dungeonEnabledForQueue(latest, entry));
if (!candidates.length) { useDungeonRaceStore.setState({ error: "No dungeon is enabled for this race queue." }); return; }
if (practice) await race.startPractice(character, selectedRole,
dungeonId || candidates[Math.floor(Math.random() * candidates.length)].id, tier, source);
else if (session?.accessToken) await race.startOnline(session, character, selectedRole, pool, dungeonId || undefined);
};
return <FrontSurface className="dungeon-race-screen" ariaLabel="Bite-Sized Dungeon Race">
<header className="dungeon-race-screen__header"><BrandMark compact /><div><small>Main menu / Dungeon Race</small>
<h1>Bite-Sized Dungeon Race</h1></div><button className="front-button" type="button" onClick={() => { void race.leave(); onClose(); }}>Main menu</button></header>
<main className="dungeon-race-screen__layout">
<section className="dungeon-race-screen__setup">
<p>Race another player or a full NPC party through matching short dungeons. First final boss kill wins. You can still finish and earn its card after a loss.</p>
<div className="dungeon-race-screen__choices" role="group" aria-label="Race type">
<button className="front-button" type="button" aria-pressed={!practice} disabled={!session?.accessToken || busy} onClick={() => setPractice(false)}>Online ranked</button>
<button className="front-button" type="button" aria-pressed={practice} disabled={busy} onClick={() => setPractice(true)}>Offline practice</button>
</div>
<div className="dungeon-race-screen__choices" role="group" aria-label="Dungeon pool">
<button className="front-button" type="button" aria-pressed={pool === "authored"} disabled={busy}
onClick={() => { setPool("authored"); setDungeonId(""); }}>Bite-Sized</button>
<button className="front-button" type="button" aria-pressed={pool === "randomized"} disabled={busy}
onClick={() => { setPool("randomized"); setDungeonId(""); }}>Randomized Bite-Sized</button>
</div>
<label>Dungeon<select value={dungeonId} disabled={busy} onChange={(event) => setDungeonId(event.target.value)}>
<option value="">Random eligible dungeon</option>
{allEntries.map((entry) => <option key={entry.id} value={entry.id}>{entry.title}</option>)}
</select></label>
<div className="dungeon-race-screen__choices" role="group" aria-label="Your role">
{roles.map((candidate) => <button key={candidate} className="front-button" type="button"
aria-pressed={selectedRole === candidate} disabled={busy} onClick={() => setRole(candidate)}>{PARTY_ROLE_LABELS[candidate]}</button>)}
</div>
{practice && <div className="dungeon-race-screen__practice">
<label>NPC skill<select value={tier} onChange={(event) => setTier(event.target.value as RaceBotTier)}>
<option value="novice">Novice</option><option value="regular">Regular</option>
<option value="veteran">Veteran</option><option value="elite">Elite</option>
</select></label>
<label>NPC party<select value={source} onChange={(event) => setSource(event.target.value as RaceBotSource)}>
<option value="wow">WoW</option><option value="rom">RoM</option>
</select></label>
<small>Practice awards local boss cards and never enters a leaderboard.</small>
</div>}
{!eligibleCharacter && <p role="status">Select a WoW or RoM character to race. CoA characters will be added later.</p>}
{!session?.accessToken && !practice && <p role="status">Sign in online for ranked races.</p>}
{!allEntries.length && <p role="status">No Bite-Sized dungeon is enabled for this queue.</p>}
{race.status === "queued" && <p role="status">Searching for a player. An NPC team joins after five seconds.</p>}
{race.status === "preparing" && <p role="status">{race.preparingContent ? "Installing race content…" : "Preparing both parties…"}</p>}
{race.error && <p role="alert">{race.error}</p>}
<div className="dungeon-race-screen__choices"><button className="front-button front-button--primary" type="button"
disabled={busy || !eligibleCharacter || !selectedRole || !allEntries.length} onClick={() => void start()}>
{practice ? "Start practice" : "Enter race queue"}</button>
{busy && <button className="front-button" type="button" onClick={() => void race.leave()}>Cancel race</button>}
</div>
</section>
<section className="dungeon-race-screen__board" aria-label="Dungeon Race leaderboards">
<h2>Leaderboards</h2>
<div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardScope === "overall"} onClick={() => setBoardScope("overall")}>Overall wins</button>
<button className="front-button" type="button" aria-pressed={boardScope === "dungeon"} onClick={() => setBoardScope("dungeon")}>This dungeon</button></div>
{boardScope === "dungeon" && <div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardView === "wins"} onClick={() => setBoardView("wins")}>Wins &amp; losses</button>
<button className="front-button" type="button" aria-pressed={boardView === "times"} onClick={() => setBoardView("times")}>Fastest clears</button></div>}
<div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={period === "30d"} onClick={() => setPeriod("30d")}>Last 30 days</button>
<button className="front-button" type="button" aria-pressed={period === "all"} onClick={() => setPeriod("all")}>All time</button></div>
{boardError && <p role="status">{boardError}</p>}
{!boardError && board?.entries.length === 0 && <p>No results yet.</p>}
{board?.entries.length ? <table><thead><tr><th>Rank</th><th>Player</th>
<th>{boardScope === "dungeon" && boardView === "times" ? "Best time" : "Wins"}</th>
{!(boardScope === "dungeon" && boardView === "times") && <th>Losses</th>}</tr></thead><tbody>
{board.entries.map((entry) => <tr key={entry.accountId}><td>#{entry.rank}</td><td>{entry.username}</td>
<td>{entry.durationMs !== undefined ? formatDungeonDuration(entry.durationMs) : entry.wins}</td>
{entry.durationMs === undefined && <td>{entry.losses}</td>}</tr>)}</tbody></table> : null}
</section>
</main>
</FrontSurface>;
}
+30 -8
View File
@@ -15,11 +15,14 @@ import { ControllerButton } from "./ControllerButton";
import { DungeonRunDetails } from "./DungeonRunDetails";
import "./dungeonRuns.css";
import { isLeaderboardFullClear } from "../game/dungeonLeaderboardRules";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
export interface RunPlacementState {
readonly scope: LeaderboardScope;
readonly scope: LeaderboardScope | "race";
readonly placement: DungeonRunPlacement | null;
readonly loading: boolean;
readonly raceResult?: "win" | "loss" | null;
readonly raceTimeMs?: number | null;
}
export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, onRetry, onAnother,
@@ -66,14 +69,17 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
<div className="dungeon-results__score">
<div><small>Time to final boss</small><strong>{progress.timed && progress.durationMs !== null
? formatDungeonDuration(progress.durationMs) : "Untimed"}</strong></div>
<div aria-live="polite"><small>{ranking.scope === "world" ? "World leaderboard" : "This device leaderboard"}</small>
<strong>{ranking.loading ? "Recording…" : ranking.placement ? `#${ranking.placement.finalRank}` : "Unranked"}</strong>
{ranking.placement ? <span>of {ranking.placement.finalTotal} final boss clears</span> : null}
<div aria-live="polite"><small>{ranking.scope === "race" ? "Dungeon Race" : ranking.scope === "world" ? "World leaderboard" : "This device leaderboard"}</small>
<strong>{ranking.scope === "race" ? ranking.loading ? "Recording…" : ranking.raceResult === "win" ? "You win" : ranking.raceResult === "loss" ? "You lose" : "No result"
: ranking.loading ? "Recording…" : ranking.placement ? `#${ranking.placement.finalRank}` : "Unranked"}</strong>
{ranking.scope === "race" && ranking.raceTimeMs != null ? <span>Race clear: {formatDungeonDuration(ranking.raceTimeMs)}</span> : null}
{ranking.scope !== "race" && ranking.placement ? <span>of {ranking.placement.finalTotal} final boss clears</span> : null}
</div>
</div>
{ranking.placement?.fullRank ? <p className="dungeon-results__full-rank">Full dungeon clears: <strong>#{ranking.placement.fullRank}</strong> of {ranking.placement.fullTotal}</p> : null}
{!progress.timed ? <p className="dungeon-results__note">This resumed run has no saved timer. Start a new expedition to record a leaderboard time.</p> : null}
{ranking.scope === "device" ? <p className="dungeon-results__note">Your time is saved on this device. Reconnect to submit it to the world leaderboard.</p> : null}
{ranking.scope === "race" && ranking.raceResult === "loss" ? <p className="dungeon-results__note">Your boss card and loot are still yours.</p> : null}
{anotherMessage ? <p className="dungeon-results__note" role="status">{anotherMessage}</p> : null}
<div className="dungeon-results__details"><DungeonRunDetails dungeonId={progress.dungeonId}
difficultyId={progress.difficultyId} bossDifficulty={progress.bossDifficulty} party={progress.party} killedBossIds={progress.killedBossIds} /></div>
@@ -104,8 +110,16 @@ export function DungeonRunResults() {
const sessionOwnerId = useShellStore((state) => state.session?.ownerId ?? null);
const online = useOnlineSessionStore((state) => state.snapshot);
const [ranking, setRanking] = useState<RunPlacementState>({ scope: "world", placement: null, loading: false });
const race = useDungeonRaceStore();
const raceRun = Boolean(record && (race.seed === record.id
|| record.id.startsWith("race-") || record.id.startsWith("practice-")));
useEffect(() => {
if (!record) { setRanking({ scope: "world", placement: null, loading: false }); return; }
if (raceRun) {
setRanking({ scope: "race", placement: null, loading: race.mode === "online" && race.result === null,
raceResult: race.result, raceTimeMs: race.clearDurationMs });
return;
}
let live = true;
saveLocalDungeonRun(record);
setRanking({ scope: "world", placement: null, loading: true });
@@ -116,11 +130,11 @@ export function DungeonRunResults() {
.then((placement) => { if (live) setRanking({ scope: "world", placement, loading: false }); })
.catch(() => { if (live) setRanking({ scope: "device", placement: localDungeonRunPlacement(record), loading: false }); });
return () => { live = false; };
}, [record, retry]);
}, [record, retry, raceRun, race.mode, race.result, race.clearDurationMs]);
if (overlay !== "dungeon-results" || !progress || progress.finishedAt === null) return null;
const biteSized = dungeonById(progress.dungeonId)?.category === "bite-sized";
const partyLeader = group ? group.leaderAccountId === sessionOwnerId : !online || online.authority;
const canStartAnother = biteSized && partyLeader;
const canStartAnother = biteSized && partyLeader && !raceRun;
const leaderMessage = biteSized && !partyLeader
? "The party leader can start the next random Bite-Sized dungeon."
: "";
@@ -134,9 +148,17 @@ export function DungeonRunResults() {
} : undefined}
startingAnother={startingAnother || groupBusy}
anotherMessage={anotherError || leaderMessage}
onLoot={() => useGameStore.getState().closeOverlay()} onFinish={() => {
onLoot={() => useGameStore.getState().closeOverlay()} onFinish={() => { void (async () => {
if (raceRun) await useDungeonRaceStore.getState().reportClear(progress.killedBossIds);
const currentRace = useDungeonRaceStore.getState();
if (raceRun && currentRace.mode === "online" && currentRace.status !== "void"
&& currentRace.clearDurationMs === null) {
setAnotherError(currentRace.error || "The race clear has not synced yet. Try again in a moment.");
return;
}
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
useGameStore.getState().closeOverlay();
useShellStore.getState().returnToMainMenu();
}} />;
if (raceRun) useDungeonRaceStore.getState().reset();
})(); }} />;
}
+4
View File
@@ -26,6 +26,7 @@ import { BrandMark, FrontSurface } from "./FrontSurface";
import { CompanionPoolControls } from "./CompanionPoolControls";
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
import { RandomizedCreatureReadinessScreen } from "./RandomizedCreatureReadinessScreen";
import { DungeonQueuePolicyScreen } from "./DungeonQueuePolicyScreen";
const COOLDOWN_MECHANICS = BOSS_TESTING_CATALOG.flatMap((boss) => {
const entity = requireDungeonDefinition(boss.dungeonId).entities[boss.entityId];
@@ -64,6 +65,7 @@ export function DungeonSimulationAdminScreen() {
const [message, setMessage] = useState("");
const [readinessOpen, setReadinessOpen] = useState(false);
const [creatureReadinessOpen, setCreatureReadinessOpen] = useState(false);
const [queuePolicyOpen, setQueuePolicyOpen] = useState(false);
const [selectedCooldownMechanic, setSelectedCooldownMechanic] = useState(COOLDOWN_MECHANICS[0]?.key ?? "");
const cooldownDraft = useCompanionCooldownPolicyStore((state) => state.draft);
const cooldownRevision = useCompanionCooldownPolicyStore((state) => state.revision);
@@ -215,6 +217,7 @@ export function DungeonSimulationAdminScreen() {
if (readinessOpen) return <CompanionReadinessScreen onClose={() => setReadinessOpen(false)} />;
if (creatureReadinessOpen) return <RandomizedCreatureReadinessScreen onClose={() => setCreatureReadinessOpen(false)} />;
if (queuePolicyOpen) return <DungeonQueuePolicyScreen onClose={() => setQueuePolicyOpen(false)} />;
return (
<FrontSurface className="dungeon-sim-admin" ariaLabel="Dungeon simulation route review">
@@ -222,6 +225,7 @@ export function DungeonSimulationAdminScreen() {
<BrandMark />
<div><p className="eyebrow">Game Master tools</p><h1>Dungeon route simulations</h1></div>
<div className="dungeon-sim-admin__header-actions">
<button className="front-button" type="button" onClick={() => setQueuePolicyOpen(true)}>Random queues</button>
<button className="front-button" type="button" onClick={() => setCreatureReadinessOpen(true)}>Creature readiness</button>
<button className="front-button" type="button" onClick={() => void refresh()} disabled={busy}>Refresh</button>
<button className="front-button" type="button" onClick={returnToMainMenu}>Main menu</button>
+39 -7
View File
@@ -21,8 +21,11 @@ const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen })));
const BalanceLabScreen = lazy(() => import("./BalanceLabScreen").then((module) => ({ default: module.BalanceLabScreen })));
const BalanceTuningScreen = lazy(() => import("./BalanceTuningScreen").then((module) => ({ default: module.BalanceTuningScreen })));
const PartyBuffPolicyScreen = lazy(() => import("./PartyBuffPolicyScreen").then((module) => ({ default: module.PartyBuffPolicyScreen })));
const DungeonQueuePolicyScreen = lazy(() => import("./DungeonQueuePolicyScreen").then((module) => ({ default: module.DungeonQueuePolicyScreen })));
const CompanionReadinessScreen = lazy(() => import("./CompanionReadinessScreen").then((module) => ({ default: module.CompanionReadinessScreen })));
const RandomizedBiteSizedScreen = lazy(() => import("./RandomizedBiteSizedScreen").then((module) => ({ default: module.RandomizedBiteSizedScreen })));
const DungeonRaceScreen = lazy(() => import("./DungeonRaceScreen").then((module) => ({ default: module.DungeonRaceScreen })));
const GAME_MODES = [
{
@@ -53,6 +56,13 @@ const GAME_MODES = [
description: "Run a short dungeon with every mob and the final boss rerolled.",
available: true,
},
{
id: "dungeon-race",
number: "05",
name: "Dungeon Race",
description: "Race a player or NPC party through matching Bite-Sized dungeons.",
available: true,
},
...(SHELVED_MODES_ENABLED ? [{
id: "manastorms" as const,
number: "02",
@@ -103,30 +113,39 @@ export function MainMenuScreen() {
const [bossTestingOpen, setBossTestingOpen] = useState(false);
const [balanceLabOpen, setBalanceLabOpen] = useState(false);
const [balanceTuningOpen, setBalanceTuningOpen] = useState(false);
const [partyBuffPolicyOpen, setPartyBuffPolicyOpen] = useState(false);
const [dungeonQueuePolicyOpen, setDungeonQueuePolicyOpen] = useState(false);
const [readinessOpen, setReadinessOpen] = useState(false);
const [randomizedBiteSizedOpen, setRandomizedBiteSizedOpen] = useState(false);
const [dungeonRaceOpen, setDungeonRaceOpen] = useState(false);
const [editor, setEditor] = useState<MainMenuEditor | null>(null);
if (editor) return <MainMenuCharacterEditor editor={editor} onClose={() => setEditor(null)} />;
if (bossTestingOpen) return <Suspense fallback={<p role="status">Opening Boss Testing…</p>}><BossTestingScreen onClose={() => setBossTestingOpen(false)} /></Suspense>;
if (balanceLabOpen) return <Suspense fallback={<p role="status">Opening Balance Lab…</p>}><BalanceLabScreen onClose={() => setBalanceLabOpen(false)} /></Suspense>;
if (balanceTuningOpen) return <Suspense fallback={<p role="status">Opening balance tuning…</p>}><BalanceTuningScreen onClose={() => setBalanceTuningOpen(false)} /></Suspense>;
if (partyBuffPolicyOpen) return <Suspense fallback={<p role="status">Opening party buffs…</p>}><PartyBuffPolicyScreen onClose={() => setPartyBuffPolicyOpen(false)} /></Suspense>;
if (dungeonQueuePolicyOpen) return <Suspense fallback={<p role="status">Opening queue settings…</p>}><DungeonQueuePolicyScreen onClose={() => setDungeonQueuePolicyOpen(false)} /></Suspense>;
if (readinessOpen) return <Suspense fallback={<p role="status">Opening Companion Readiness…</p>}><CompanionReadinessScreen onClose={() => setReadinessOpen(false)} /></Suspense>;
if (randomizedBiteSizedOpen) return <Suspense fallback={<p role="status">Opening Randomized Bite-Sized…</p>}><RandomizedBiteSizedScreen onClose={() => setRandomizedBiteSizedOpen(false)} /></Suspense>;
if (dungeonRaceOpen) return <Suspense fallback={<p role="status">Opening Dungeon Race…</p>}><DungeonRaceScreen onClose={() => setDungeonRaceOpen(false)} /></Suspense>;
if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenBalanceTuning={() => setBalanceTuningOpen(true)} onOpenReadiness={() => setReadinessOpen(true)} onOpenRandomizedBiteSized={() => setRandomizedBiteSizedOpen(true)} onOpenEditor={setEditor} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenBalanceTuning={() => setBalanceTuningOpen(true)} onOpenPartyBuffPolicy={() => setPartyBuffPolicyOpen(true)} onOpenDungeonQueuePolicy={() => setDungeonQueuePolicyOpen(true)} onOpenReadiness={() => setReadinessOpen(true)} onOpenRandomizedBiteSized={() => setRandomizedBiteSizedOpen(true)} onOpenDungeonRace={() => setDungeonRaceOpen(true)} onOpenEditor={setEditor} />;
}
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenBalanceTuning, onOpenReadiness, onOpenRandomizedBiteSized, onOpenEditor }: {
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenBalanceTuning, onOpenPartyBuffPolicy, onOpenDungeonQueuePolicy, onOpenReadiness, onOpenRandomizedBiteSized, onOpenDungeonRace, onOpenEditor }: {
readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void;
readonly onOpenCards: () => void;
readonly onOpenBossTesting: () => void;
readonly onOpenBalanceLab: () => void;
readonly onOpenBalanceTuning: () => void;
readonly onOpenPartyBuffPolicy: () => void;
readonly onOpenDungeonQueuePolicy: () => void;
readonly onOpenReadiness: () => void;
readonly onOpenRandomizedBiteSized: () => void;
readonly onOpenDungeonRace: () => void;
readonly onOpenEditor: (editor: MainMenuEditor) => void;
}) {
const [gmMenuOpen, setGmMenuOpen] = useState(false);
@@ -206,6 +225,8 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
{ id: "gm-menu-boss-testing", run: onOpenBossTesting, ...(!isGm ? { neighbors: { up: "gm-menu-close" } } : {}) },
{ id: "gm-menu-companion-readiness", run: onOpenReadiness },
...(isGm ? [{ id: "gm-menu-balance-tuning", run: onOpenBalanceTuning }] : []),
...(isGm ? [{ id: "gm-menu-party-buffs", run: onOpenPartyBuffPolicy }] : []),
...(isGm ? [{ id: "gm-menu-dungeon-queues", run: onOpenDungeonQueuePolicy }] : []),
...(isGm ? [{ id: "gm-menu-balance-lab", run: onOpenBalanceLab }] : []),
{ id: "gm-menu-reports", run: onOpenReports },
{ id: "gm-menu-close", run: () => setGmMenuOpen(false), neighbors: { down: isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing" } },
@@ -223,13 +244,15 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
{
id: "mode-random-bite-sized",
run: openRandomBiteSizedDungeon,
neighbors: { up: "mode-dungeons", right: "mode-randomized-bite-sized" },
neighbors: { up: "mode-dungeons", right: "mode-randomized-bite-sized", down: "mode-dungeon-race" },
},
{
id: "mode-randomized-bite-sized",
run: onOpenRandomizedBiteSized,
neighbors: { up: "mode-random-dungeon", left: "mode-random-bite-sized" },
neighbors: { up: "mode-random-dungeon", left: "mode-random-bite-sized", down: "mode-dungeon-race" },
},
{ id: "mode-dungeon-race", run: onOpenDungeonRace,
neighbors: { up: "mode-random-bite-sized" } },
...(character ? [
{ id: "mode-class-hall", run: openClassHall },
{ id: "mode-talents", run: () => onOpenEditor("talents") },
@@ -244,7 +267,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
] : []),
{ id: "mode-downloads", run: onOpenDownloads },
{ id: "back-to-roster", run: returnToCharacters, neighbors: { down: "mode-dungeons" } },
], [availableRoles, character, closeRandomQueue, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, openRandomBiteSizedDungeon, openRandomDungeon, queueing, randomQueueMode, returnToCharacters, selectedRole, startRandomQueue]);
], [availableRoles, character, closeRandomQueue, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenPartyBuffPolicy, onOpenDungeonQueuePolicy, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenDungeonRace, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, openRandomBiteSizedDungeon, openRandomDungeon, queueing, randomQueueMode, returnToCharacters, selectedRole, startRandomQueue]);
const controller = useMenuController(actions, {
columns: 2,
initialId: randomQueueMode ? `random-queue-role-${availableRoles[0]}` : gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
@@ -261,6 +284,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
if (id === "random-dungeon") return openRandomDungeon;
if (id === "random-bite-sized") return openRandomBiteSizedDungeon;
if (id === "randomized-bite-sized") return onOpenRandomizedBiteSized;
if (id === "dungeon-race") return onOpenDungeonRace;
return () => openDungeons();
};
const renderGameMode = (mode: (typeof GAME_MODES)[number]) => mode.available ? (
@@ -334,7 +358,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
<section className="main-menu-card main-menu-card--adventures" aria-labelledby="main-menu-adventures-title">
<header><span>01</span><strong id="main-menu-adventures-title">Adventures</strong></header>
<div className="main-menu-card__grid">
{GAME_MODES.filter((mode) => ["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized"].includes(mode.id)).map(renderGameMode)}
{GAME_MODES.filter((mode) => ["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized", "dungeon-race"].includes(mode.id)).map(renderGameMode)}
</div>
</section>
@@ -367,7 +391,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm-menu" type="button" onClick={() => setGmMenuOpen(true)}>
<b aria-hidden="true">GM</b><span><strong>GM Menu</strong><small>Testing, readiness, reports, and balance tools.</small></span><em>Open</em>
</ControllerButton>
{GAME_MODES.filter((mode) => !["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized"].includes(mode.id)).map(renderGameMode)}
{GAME_MODES.filter((mode) => !["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized", "dungeon-race"].includes(mode.id)).map(renderGameMode)}
</div>
</section>
</nav>
@@ -433,6 +457,14 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={onOpenBalanceTuning}>
<b aria-hidden="true">AT</b><span><strong>Ability &amp; Talent Tuning</strong><small>Adjust local cooldowns, costs, damage, healing, durations, and talent modifiers.</small></span><em>{isGm ? "Local" : "GM account required"}</em>
</ControllerButton>
<ControllerButton controlId="gm-menu-party-buffs" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={onOpenPartyBuffPolicy}>
<b aria-hidden="true">PB</b><span><strong>Permanent Party Buffs</strong><small>Choose class buffs and their party, tank, or healer targets.</small></span><em>{isGm ? "Server" : "GM account required"}</em>
</ControllerButton>
<ControllerButton controlId="gm-menu-dungeon-queues" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={onOpenDungeonQueuePolicy}>
<b aria-hidden="true">DQ</b><span><strong>Random Dungeon Queues</strong><small>Enable or disable individual dungeons in all three random modes.</small></span><em>{isGm ? "Server" : "GM account required"}</em>
</ControllerButton>
<ControllerButton controlId="gm-menu-balance-lab" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={onOpenBalanceLab}>
<b aria-hidden="true">BL</b><span><strong>Balance Lab</strong><small>Queue automatic party and boss simulations on the server.</small></span><em>{isGm ? "Server" : "GM account required"}</em>
+10 -9
View File
@@ -8,7 +8,7 @@ import { DungeonMapGraphic, type MapArtworkStatus } from "./DungeonMapGraphic";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { resolveStagePresentation } from "../game/manastormStagePresentation";
import { useManastormStore } from "../game/manastormStore";
import { wowDungeonMapFor, wowMapContains, wowMapFloorFor, wowMapPoint } from "../game/wowDungeonMap";
import { dungeonArtworkContains, dungeonArtworkFloorFor, dungeonArtworkMapFor, dungeonArtworkPoint } from "../game/dungeonArtworkMap";
export function closeMapForKey(event: Pick<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey" | "repeat" | "defaultPrevented" | "preventDefault" | "stopPropagation">, close: () => void) {
if (event.repeat || event.defaultPrevented || isCapturingInput()) return;
@@ -31,13 +31,14 @@ function MapDialog() {
const encounter = useManastormStore((state) => state.currentEncounter);
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const dungeon = requireDungeonDefinition(activeDungeonId);
const map = wowDungeonMapFor(dungeon);
const map = dungeonArtworkMapFor(dungeon);
const navigationPlan = map?.kind === "runewaker" && map.source.kind === "navigation";
const [floorLevel, setFloorLevel] = useState<number | null>(null);
const [showPatrols, setShowPatrols] = useState(false);
const [artworkStatus, setArtworkStatus] = useState<MapArtworkStatus>(map ? "loading" : "unavailable");
const floor = map ? wowMapFloorFor(map, position, floorLevel) : null;
const mapPosition = map && floor ? wowMapPoint(map, floor, position) : null;
const positionOnMap = Boolean(map && floor && wowMapContains(map, floor, position));
const floor = map ? dungeonArtworkFloorFor(map, position, floorLevel) : null;
const mapPosition = map && floor ? dungeonArtworkPoint(map, floor, position) : null;
const positionOnMap = Boolean(map && floor && dungeonArtworkContains(map, floor, position));
const stagePresentation = resolveStagePresentation(gameMode, encounter, {
title: dungeon.title,
mapId: dungeon.mapId,
@@ -49,7 +50,7 @@ function MapDialog() {
<aside className="map-panel" role="dialog" aria-modal="true" aria-labelledby="map-title" onKeyDown={(event) => closeMapForKey(event, close)}>
<header>
<div>
<p className="eyebrow">{artworkStatus === "ready" ? "Dungeon atlas" : "Expedition survey"}</p>
<p className="eyebrow">{artworkStatus === "ready" ? navigationPlan ? "Dungeon floorplan" : "Dungeon atlas" : "Expedition survey"}</p>
<h2 id="map-title">{stagePresentation.title}</h2>
</div>
<button type="button" className="icon-button" onClick={close} aria-label="Close map">X</button>
@@ -71,15 +72,15 @@ function MapDialog() {
<div className="map-panel__readout">
<div><span>Current area</span><strong>{stagePresentation.areaName}</strong></div>
<div><span>{artworkStatus === "ready" ? floor?.name : "Position"}</span><strong>{artworkStatus === "ready" && mapPosition
? positionOnMap ? `${(mapPosition[0] / 1002 * 100).toFixed(1)}%, ${(mapPosition[1] / 668 * 100).toFixed(1)}%` : "Outside selected floor"
? positionOnMap ? `${(mapPosition[0] / map!.width * 100).toFixed(1)}%, ${(mapPosition[1] / map!.height * 100).toFixed(1)}%` : "Outside selected floor"
: position.map((value) => Math.round(value)).join(" / ")}</strong></div>
</div>
<p className="map-panel__note">
{stagePresentation.usingFallbackEnvironment
? "Stage location is authoritative; the survey artwork is temporary fallback geometry. "
: artworkStatus === "ready"
? "Original game artwork. White: you and camera direction. Green: entrance. Gold: numbered encounters. "
: artworkStatus === "loading" ? "Loading original game artwork. " : "Original artwork unavailable; showing the survey fallback. "}
? `${navigationPlan ? "Navigation floorplan" : "Original game artwork"}. White: you and camera direction. Green: entrance. Gold: numbered encounters. `
: artworkStatus === "loading" ? navigationPlan ? "Loading dungeon floorplan. " : "Loading original game artwork. " : "Original artwork unavailable; showing the survey fallback. "}
{map && map.floors.length > 1 && "Choose a floor when rooms overlap. "}
Press {bindingLabel("pc", "map")} to close or {bindingLabel("controller", "menu-back")} to return to Pause.
</p>
+3 -3
View File
@@ -4,7 +4,7 @@ import { DungeonMapGraphic } from "./DungeonMapGraphic";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useGameStore } from "../game/store";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { wowDungeonMapFor, wowMapFloorFor, wowMapHeading } from "../game/wowDungeonMap";
import { dungeonArtworkFloorFor, dungeonArtworkHeading, dungeonArtworkMapFor } from "../game/dungeonArtworkMap";
export interface MinimapProps {
position: Vector3Tuple;
@@ -26,8 +26,8 @@ export function Minimap({
const bindingLabel = useInputBindingLabels();
const inputMode = useGameStore((state) => state.inputMode);
const dungeonId = useGameStore((state) => state.activeDungeonId);
const map = wowDungeonMapFor(requireDungeonDefinition(dungeonId));
const direction = map ? wowMapHeading(map, wowMapFloorFor(map, position), position, yaw) : null;
const map = dungeonArtworkMapFor(requireDungeonDefinition(dungeonId));
const direction = map ? dungeonArtworkHeading(map, dungeonArtworkFloorFor(map, position), position, yaw) : null;
const compassYaw = direction ? Math.atan2(direction[0], -direction[1]) : yaw;
const mapKey = bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "map");
const heading = ((Math.round((compassYaw * 180) / Math.PI) % 360) + 360) % 360;
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useMemo, useState } from "react";
import { CLASSES, type ClassId } from "../app/characterCatalog";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useShellStore } from "../app/shellStore";
import { preloadAbilityCatalogsForClasses, sourceAbilitiesForClass } from "../game/abilityCatalog";
import { permanentBuffEffects, type PartyBuffScope } from "../game/partyBuffPolicy";
import { usePartyBuffPolicyStore } from "../game/partyBuffPolicyStore";
import { BrandMark, FrontSurface } from "./FrontSurface";
export function PartyBuffPolicyScreen({ onClose }: { readonly onClose: () => void }) {
const session = useShellStore((state) => state.session);
const [classId, setClassId] = useState<ClassId>("priest");
const [query, setQuery] = useState("");
const [loadedClassId, setLoadedClassId] = useState<ClassId | null>(null);
const [catalogError, setCatalogError] = useState("");
const [notice, setNotice] = useState("");
const draft = usePartyBuffPolicyStore((state) => state.draft);
const revision = usePartyBuffPolicyStore((state) => state.revision);
const dirty = usePartyBuffPolicyStore((state) => state.dirty);
const saving = usePartyBuffPolicyStore((state) => state.saving);
const error = usePartyBuffPolicyStore((state) => state.error);
const replaceDraft = usePartyBuffPolicyStore((state) => state.replaceDraft);
const resetDraft = usePartyBuffPolicyStore((state) => state.resetDraft);
const publish = usePartyBuffPolicyStore((state) => state.publish);
useEffect(() => {
let active = true;
setLoadedClassId(null);
setCatalogError("");
void preloadAbilityCatalogsForClasses([classId]).then(() => {
if (active) setLoadedClassId(classId);
}).catch((failure) => {
if (active) setCatalogError(failure instanceof Error ? failure.message : "Could not load this class.");
});
return () => { active = false; };
}, [classId]);
const abilities = useMemo(() => loadedClassId !== classId ? [] : sourceAbilitiesForClass(classId)
.filter((ability) => permanentBuffEffects(ability).length > 0)
.filter((ability) => ability.name.toLowerCase().includes(query.trim().toLowerCase()))
.sort((left, right) => left.name.localeCompare(right.name)), [classId, loadedClassId, query]);
if (!sessionCanUseGmTools(session)) return (
<FrontSurface className="party-buff-policy" ariaLabel="GM access denied">
<header className="party-buff-policy__header"><BrandMark compact /><strong>GM access required</strong><button className="front-button" type="button" onClick={onClose}>Back</button></header>
</FrontSurface>
);
const setScope = (abilityId: string, scope: PartyBuffScope | "off") => {
replaceDraft({ schemaVersion: 1, rules: [
...draft.rules.filter((rule) => rule.abilityId !== abilityId),
...(scope === "off" ? [] : [{ abilityId, scope }]),
] });
setNotice("");
};
return (
<FrontSurface className="party-buff-policy" ariaLabel="Permanent party buffs">
<header className="party-buff-policy__header">
<BrandMark compact />
<div><span>Server policy · revision {revision}</span><strong>Permanent Party Buffs</strong></div>
<button className="front-button front-button--quiet" type="button" onClick={onClose}>Back</button>
</header>
<p className="party-buff-policy__intro">Choose which active stat buffs are maintained when a party forms or resets. A selected buff needs a member of its class who has learned it. Party includes the player; Tank and Healer target only those roles. If related buffs share a family, the strongest learned selection applies. Unselected spells use their normal cast and duration rules.</p>
<div className="party-buff-policy__filters">
<label>Class<select value={classId} onChange={(event) => setClassId(event.currentTarget.value as ClassId)}>{CLASSES.map((definition) => <option key={definition.id} value={definition.id}>{definition.name}</option>)}</select></label>
<label>Find buff<input type="search" value={query} onChange={(event) => setQuery(event.currentTarget.value)} placeholder="Search this class" /></label>
<span>{draft.rules.length} selected</span>
</div>
<main className="party-buff-policy__list">
{catalogError && <p role="alert">{catalogError}</p>}
{!catalogError && loadedClassId !== classId && <p role="status">Loading class buffs…</p>}
{loadedClassId === classId && !abilities.length && <p>No eligible buffs match this class and search.</p>}
{abilities.map((ability) => {
const scope = draft.rules.find((rule) => rule.abilityId === ability.id)?.scope ?? "off";
return <article key={ability.id} className="party-buff-policy__row">
<img src={ability.icon} alt="" loading="lazy" />
<div><strong>{ability.name}</strong><small>Level {ability.unlockLevel} · {ability.description}</small></div>
<label>Permanent target<select aria-label={`${ability.name} permanent target`} value={scope} onChange={(event) => setScope(ability.id, event.currentTarget.value as PartyBuffScope | "off")}>
<option value="off">Off</option><option value="party">Whole party</option><option value="tank">Tank only</option><option value="healer">Healer only</option>
</select></label>
</article>;
})}
</main>
<footer className="party-buff-policy__actions">
{error && <span role="alert">{error}</span>}{notice && <span role="status">{notice}</span>}
<button className="front-button" type="button" disabled={!draft.rules.length || saving} onClick={() => { replaceDraft({ schemaVersion: 1, rules: [] }); setNotice(""); }}>Clear selections</button>
<button className="front-button" type="button" disabled={!dirty || saving} onClick={() => { resetDraft(); setNotice("Draft reverted."); }}>Discard changes</button>
<button className="front-button front-button--primary" type="button" disabled={!dirty || saving} onClick={() => void publish(session).then((saved) => { if (saved) setNotice("Party buff policy published."); })}>{saving ? "Publishing…" : "Publish policy"}</button>
</footer>
</FrontSurface>
);
}
+23 -9
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { installDungeonShellRuntime } from "../app/dungeonShellRuntime";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
@@ -10,7 +10,9 @@ import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
dungeonContentPackId,
} from "../game/dungeonContentSource";
import { dungeonById } from "../game/dungeonCatalog";
import { dungeonById, randomRandomizedBiteSizedDungeon } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy";
import { loadDungeonQueuePolicy, useDungeonQueuePolicyStore } from "../app/dungeonQueuePolicyStore";
import { RANDOMIZED_BITE_SIZED_CREATURE_CATALOG, requireDungeonDefinition } from "../game/dungeonRegistry";
import { BrandMark, FrontSurface } from "./FrontSurface";
import { DungeonDifficultySlider } from "./DungeonDifficultySlider";
@@ -38,6 +40,9 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState("");
const [readinessOpen, setReadinessOpen] = useState(false);
const queuePolicy = useDungeonQueuePolicyStore((state) => state.config);
const refreshQueuePolicy = useDungeonQueuePolicyStore((state) => state.refresh);
useEffect(() => { void refreshQueuePolicy(session); }, [refreshQueuePolicy, session]);
const character = activeCharacter ?? rosterCharacter;
const specialization = specializationById(character?.specializationId);
const roles = character
@@ -46,16 +51,24 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
const installed = dungeonContentIsUsable(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
const mobCount = CANDIDATES.filter((candidate) => candidate.kind === "mob").length;
const bossCount = CANDIDATES.filter((candidate) => candidate.kind === "boss").length;
const queueEnabled = Boolean(DUNGEON && dungeonEnabledForQueue(queuePolicy, DUNGEON));
const launch = async (role: PartyRole) => {
if (!character || !DUNGEON?.available) return;
const policy = await loadDungeonQueuePolicy();
const selectedDungeon = randomRandomizedBiteSizedDungeon(undefined, Math.random,
(candidate) => dungeonEnabledForQueue(policy, candidate));
if (!selectedDungeon) {
setError("No Randomized Bite-Sized location is enabled for this queue.");
return;
}
if (!onlineGroup || !session || session.kind !== "account") {
const runId = globalThis.crypto.randomUUID();
setDownloading(true); setError("");
try {
await ensureRandomizedBiteSizedRunContent(runId);
enterDungeon(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, role, {
difficultyId: requireDungeonDefinition(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID).defaultDifficultyId,
enterDungeon(selectedDungeon.id, role, {
difficultyId: requireDungeonDefinition(selectedDungeon.id).defaultDifficultyId,
bossDifficulty,
freshRun: true,
runId,
@@ -74,8 +87,8 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
}
await startOnlineActivity(session, {
type: "dungeon",
contentId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
difficultyId: requireDungeonDefinition(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID).defaultDifficultyId,
contentId: selectedDungeon.id,
difficultyId: requireDungeonDefinition(selectedDungeon.id).defaultDifficultyId,
bossDifficulty,
partySize: 5,
fillWithAi: true,
@@ -122,17 +135,18 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
</section>
<section className="randomized-bite-sized-screen__launch">
<p className="front-kicker">Run setup</p>
<h2>{DUNGEON?.title ?? "Cellblock Shuffle"}</h2>
<h2>{queueEnabled ? DUNGEON?.title ?? "Cellblock Shuffle" : "No enabled location"}</h2>
<DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty} selectedId="" select={() => undefined} />
{!installed ? (
{!queueEnabled ? null : !installed ? (
<button className="front-button front-button--primary" type="button" disabled={downloading} onClick={() => void install()}>{downloading ? "Installing…" : "Install randomized dungeon content"}</button>
) : roles.length ? (
<div className="randomized-bite-sized-screen__roles">
<strong>Enter as</strong>
{roles.map((role) => <button className="front-button front-button--primary" key={role} type="button" disabled={!DUNGEON?.available || downloading} onClick={() => void launch(role)}>{downloading ? "Preparing run…" : PARTY_ROLE_LABELS[role]}</button>)}
{roles.map((role) => <button className="front-button front-button--primary" key={role} type="button" disabled={!DUNGEON?.available || !queueEnabled || downloading} onClick={() => void launch(role)}>{downloading ? "Preparing run…" : PARTY_ROLE_LABELS[role]}</button>)}
</div>
) : <p className="front-notice">Choose a specialization in your Class Hall before entering.</p>}
{!DUNGEON?.available && <p className="front-notice">This randomized route is not enterable in the current content build.</p>}
{!queueEnabled && <p className="front-notice">No Randomized Bite-Sized location is enabled in GM queue settings.</p>}
{(error || notice) && <p className="front-notice" role="status">{error || notice}</p>}
</section>
</main>
+20
View File
@@ -0,0 +1,20 @@
.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 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}
.dungeon-race-wait,.dungeon-race-countdown{position:absolute;z-index:55;top:38%;left:50%;transform:translateX(-50%);padding:14px 22px;border:1px solid #ab945f;border-radius:12px;background:#091217ef;color:#f5e8ca;text-align:center;pointer-events:none}
.dungeon-race-countdown strong{display:block;font-size:42px}
.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}}
.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}
.dungeon-race-screen__setup,.dungeon-race-screen__board{padding:14px;border:1px solid #65583f;border-radius:10px;background:#081217cf}
.dungeon-race-screen__setup p{margin:0 0 12px}.dungeon-race-screen__choices{display:flex;flex-wrap:wrap;gap:7px;margin:9px 0}.dungeon-race-screen__choices .front-button{min-width:0;padding:8px 10px}
.dungeon-race-screen [aria-pressed=true]{border-color:#e4be70;background:#44371f;color:#ffe4a3}
.dungeon-race-screen label{display:flex;flex-direction:column;gap:4px;margin:8px 0;font-size:12px}.dungeon-race-screen select{max-width:100%;padding:8px;border:1px solid #766748;border-radius:6px;background:#121d23;color:#f5e8ca}
.dungeon-race-screen__practice{display:grid;grid-template-columns:1fr 1fr;gap:5px 10px}.dungeon-race-screen__practice small{grid-column:1/-1}
.dungeon-race-screen__board{overflow:auto}.dungeon-race-screen__board h2{margin:0 0 10px}.dungeon-race-screen__board table{width:100%;border-collapse:collapse;font-size:12px}.dungeon-race-screen__board th,.dungeon-race-screen__board td{padding:5px;text-align:left;border-bottom:1px solid #4d4a3b}
@media(max-width:760px){.dungeon-race-screen__layout{grid-template-columns:1fr}.dungeon-race-screen__header h1{font-size:19px}}