Update Healer Man server
This commit is contained in:
1 parent
9c5fb4c614
commit
bd073fb00f
113 files changed
+4902
-273
No files matched your search
@@ -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)) {
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface DungeonEntrySettings {
|
||||
readonly bossDifficulty?: number;
|
||||
readonly freshRun?: boolean;
|
||||
readonly runId?: string;
|
||||
readonly raceStartBlocked?: boolean;
|
||||
}
|
||||
|
||||
let installedRuntime: DungeonShellRuntime | null = null;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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[]],
|
||||
} };
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
})}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] });
|
||||
|
||||
@@ -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">,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)`}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>}
|
||||
</>;
|
||||
}
|
||||
@@ -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 & 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>;
|
||||
}
|
||||
@@ -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();
|
||||
})(); }} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 & 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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
Reference in new issue
Block a user