Update Healer Man server
This commit is contained in:
1 parent
15a5254380
commit
9d7e0e2f9e
71 files changed
+3490
-81
No files matched your search
@@ -16,6 +16,7 @@ import {
|
||||
import { useCompanionCooldownPolicyStore } from "./game/companionCooldownPolicyStore";
|
||||
import { useCompanionSpecReadinessStore } from "./game/companionSpecReadinessStore";
|
||||
import { usePartyBuffPolicyStore } from "./game/partyBuffPolicyStore";
|
||||
import { useBiteSizedLayoutsStore } from "./game/biteSizedLayoutsStore";
|
||||
|
||||
const GameRuntime = lazy(loadGameRuntime);
|
||||
const CharacterCreateScreen = lazy(() => import("./ui/CharacterCreateScreen").then((module) => ({ default: module.CharacterCreateScreen })));
|
||||
@@ -24,6 +25,7 @@ const ClassHallScreen = lazy(() => import("./ui/ClassHallScreen").then((module)
|
||||
const DungeonSelectScreen = lazy(() => import("./ui/DungeonSelectScreen").then((module) => ({ default: module.DungeonSelectScreen })));
|
||||
const MainMenuScreen = lazy(() => import("./ui/MainMenuScreen").then((module) => ({ default: module.MainMenuScreen })));
|
||||
const DungeonSimulationAdminScreen = lazy(() => import("./ui/DungeonSimulationAdminScreen").then((module) => ({ default: module.DungeonSimulationAdminScreen })));
|
||||
const BiteSizedEditor = lazy(() => import("./ui/BiteSizedEditor").then((module) => ({ default: module.BiteSizedEditor })));
|
||||
function ShellTransition() {
|
||||
return <div className="world-transition"><span>HM</span><strong>Opening expedition</strong><small>Preparing the next screen...</small></div>;
|
||||
}
|
||||
@@ -84,6 +86,7 @@ export default function App() {
|
||||
useEffect(() => { void refreshCooldownPolicy(session); }, [refreshCooldownPolicy, session]);
|
||||
useEffect(() => { void refreshSpecReadiness(session); }, [refreshSpecReadiness, session]);
|
||||
useEffect(() => { void refreshPartyBuffPolicy(session); }, [refreshPartyBuffPolicy, session]);
|
||||
useEffect(() => { void useBiteSizedLayoutsStore.getState().refresh(); }, [session?.ownerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!SHELVED_MODES_ENABLED && isShelvedShellPhase(phase)) {
|
||||
@@ -100,6 +103,7 @@ export default function App() {
|
||||
else if (phase === "class-hall") screen = <Suspense fallback={<ShellTransition />}><ClassHallScreen /></Suspense>;
|
||||
else if (phase === "dungeons") screen = <Suspense fallback={<ShellTransition />}><DungeonSelectScreen /></Suspense>;
|
||||
else if (phase === "gm-admin") screen = <Suspense fallback={<ShellTransition />}><DungeonSimulationAdminScreen /></Suspense>;
|
||||
else if (phase === "bite-sized-editor") screen = <Suspense fallback={<ShellTransition />}><BiteSizedEditor /></Suspense>;
|
||||
else if (isShelvedShellPhase(phase)) {
|
||||
screen = <Suspense fallback={<ShellTransition />}><ShelvedModeRoute phase={phase} session={session} /></Suspense>;
|
||||
}
|
||||
|
||||
+6
-1
@@ -41,6 +41,8 @@ import { useDungeonRouteStore } from "./game/dungeonRouteStore";
|
||||
import { installCombatClock } from "./game/combatClock";
|
||||
import { dungeonSimulationNow } from "./game/dungeonSimulationStore";
|
||||
import { useDungeonRaceStore } from "./app/dungeonRaceStore";
|
||||
import { BiteSizedEditor } from "./ui/BiteSizedEditor";
|
||||
import { useBiteSizedLayoutsStore } from "./game/biteSizedLayoutsStore";
|
||||
|
||||
function InputBridge() {
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
@@ -49,6 +51,7 @@ function InputBridge() {
|
||||
useEffect(() => installInput({
|
||||
desktopActionBarsActive: () => currentUiDevice() === "pc" && activeUiLayout().wowBars,
|
||||
inputSuspended: () => useDungeonSimulationStore.getState().sessionMode !== "play"
|
||||
|| useBiteSizedLayoutsStore.getState().panelFocused
|
||||
|| useGameStore.getState().dungeonIntroActive
|
||||
|| useGameStore.getState().raceStartBlocked
|
||||
|| ["ui-layout", "keybinds", "dungeon-results", "boss-test-review", "report-issue"].includes(useGameStore.getState().overlay ?? ""),
|
||||
@@ -93,7 +96,7 @@ function InputBridge() {
|
||||
cancelGroundTarget: () => useCombatStore.getState().cancelGroundTarget(),
|
||||
gameplayActionsBlocked: () => {
|
||||
const game = useGameStore.getState();
|
||||
return game.overlay !== null || game.companionOpen;
|
||||
return game.overlay !== null || game.companionOpen || useBiteSizedLayoutsStore.getState().editorMode === "edit";
|
||||
},
|
||||
setCameraLookActive: (active) => useGameStore.getState().setCameraLookActive(active),
|
||||
setInputMode: (mode) => useGameStore.getState().setInputMode(mode),
|
||||
@@ -126,6 +129,7 @@ export default function GameRuntime() {
|
||||
useShellStore.setState({ activeDungeonId: sourceDungeonId });
|
||||
}
|
||||
clearBossTestingDungeons();
|
||||
if (useShellStore.getState().phase !== "game") useBiteSizedLayoutsStore.setState({ editorMode: null, panelFocused: false });
|
||||
}, []);
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
@@ -154,6 +158,7 @@ export default function GameRuntime() {
|
||||
{!dungeonSimulation && <DungeonRunResults />}
|
||||
{!dungeonSimulation && <UiLayoutEditor />}
|
||||
{!dungeonSimulation && <KeybindsPanel />}
|
||||
<BiteSizedEditor inGame />
|
||||
</main>
|
||||
}
|
||||
bottom={<LocalCompanionPanel />}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
|
||||
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
|
||||
import { dungeonContentPackId, isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource";
|
||||
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
|
||||
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||
|
||||
const GROUP_POLL_MS = 2_500;
|
||||
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
|
||||
@@ -60,7 +61,8 @@ export async function enterOnlineGroupActivity(
|
||||
): Promise<boolean> {
|
||||
let entered: boolean;
|
||||
if (activity.type === "dungeon") {
|
||||
await dependencies.prepareDungeonContent(activity.contentId);
|
||||
if (activity.biteSizedSelection) await prepareBiteSizedLayoutContent(activity.contentId, activity.id, activity.biteSizedSelection);
|
||||
await dependencies.prepareDungeonContent(activity.biteSizedSelection?.spot.sourceDungeonId ?? activity.contentId);
|
||||
if (isRandomizedBiteSizedDungeonId(activity.contentId)) {
|
||||
await dependencies.prepareRandomizedDungeonContent(activity.id, activity.contentId);
|
||||
}
|
||||
@@ -70,6 +72,7 @@ export async function enterOnlineGroupActivity(
|
||||
bossDifficulty: activity.bossDifficulty,
|
||||
freshRun: true,
|
||||
runId: activity.id,
|
||||
...(activity.biteSizedSelection ? { biteSizedSelection: activity.biteSizedSelection } : {}),
|
||||
});
|
||||
} else {
|
||||
if (!SHELVED_MODES_ENABLED) return false;
|
||||
|
||||
@@ -75,12 +75,12 @@ describe("bite-sized replay", () => {
|
||||
expect(canStartAnotherBiteSizedDungeon()).toBe(true);
|
||||
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
|
||||
expect(saveCheckpoint).toHaveBeenCalledWith(null);
|
||||
expect(enterDungeon).toHaveBeenCalledWith(expect.stringMatching(/^bite-sized-/), "healer", {
|
||||
expect(enterDungeon).toHaveBeenCalledWith(expect.stringMatching(/^bite-sized-/), "healer", expect.objectContaining({
|
||||
difficultyId: "normal",
|
||||
bossDifficulty: 1,
|
||||
freshRun: true,
|
||||
runId: expect.any(String),
|
||||
});
|
||||
}));
|
||||
expect(enterDungeon).not.toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useOnlineSessionStore } from "./onlineSessionStore";
|
||||
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
|
||||
import { loadDungeonQueuePolicy } from "./dungeonQueuePolicyStore";
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
export function canStartAnotherBiteSizedDungeon(): boolean {
|
||||
const shell = useShellStore.getState();
|
||||
@@ -19,6 +21,7 @@ export function canStartAnotherBiteSizedDungeon(): boolean {
|
||||
}
|
||||
|
||||
interface BiteSizedReplayDependencies {
|
||||
readonly prepareBiteSized?: (id: string, seed: string) => Promise<BiteSizedRunSelection | undefined>;
|
||||
readonly random: () => number;
|
||||
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
|
||||
readonly createRunId: () => string;
|
||||
@@ -27,6 +30,7 @@ interface BiteSizedReplayDependencies {
|
||||
}
|
||||
|
||||
const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
|
||||
prepareBiteSized: prepareBiteSizedLayoutContent,
|
||||
random: Math.random,
|
||||
loadPolicy: loadDungeonQueuePolicy,
|
||||
createRunId: () => globalThis.crypto.randomUUID(),
|
||||
@@ -93,6 +97,9 @@ export async function startAnotherBiteSizedDungeon(
|
||||
return false;
|
||||
}
|
||||
const runId = dependencies.createRunId();
|
||||
let biteSizedSelection: BiteSizedRunSelection | undefined;
|
||||
try { biteSizedSelection = await dependencies.prepareBiteSized?.(next.id, runId); }
|
||||
catch (caught) { shell.setNotice(caught instanceof Error ? caught.message : "The spot content could not be prepared."); return false; }
|
||||
if (randomized) {
|
||||
try {
|
||||
await dependencies.prepareRandomizedContent(runId, next.id);
|
||||
@@ -107,6 +114,7 @@ export async function startAnotherBiteSizedDungeon(
|
||||
bossDifficulty: progress.bossDifficulty,
|
||||
freshRun: true,
|
||||
runId,
|
||||
...(biteSizedSelection ? { biteSizedSelection } : {}),
|
||||
});
|
||||
return entered;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { selectBiteSizedRun } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { touchCharacter } from "./accountRepository";
|
||||
import { sessionCanUseGmTools } from "./releaseFeatures";
|
||||
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||
import { activateDungeonSession } from "../game/dungeonSession";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { defaultPartyRoleForClass } from "../game/partyRoles";
|
||||
import { useOnlineGroupStore } from "./onlineGroupStore";
|
||||
import { useDungeonRaceStore } from "./dungeonRaceStore";
|
||||
|
||||
export async function enterBiteSizedEditorSpot(spotId: string, mode: "edit" | "test" = "edit"): Promise<boolean> {
|
||||
const shell = useShellStore.getState();
|
||||
const layouts = useBiteSizedLayoutsStore.getState();
|
||||
const spot = layouts.draft.spots.find((s) => s.id === spotId);
|
||||
try {
|
||||
if (!sessionCanUseGmTools(shell.session)) throw new Error("Game Master access is required.");
|
||||
if (useOnlineGroupStore.getState().activeActivity) throw new Error("Leave the shared activity before opening an isolated editor session.");
|
||||
if (["queued", "preparing", "countdown", "active"].includes(useDungeonRaceStore.getState().status)) throw new Error("Leave the Dungeon Race before opening an editor session.");
|
||||
const character = shell.selectedCharacterId ? touchCharacter(shell.session!.ownerId, shell.selectedCharacterId)
|
||||
?? shell.characters.find((c) => c.id === shell.selectedCharacterId) : null;
|
||||
if (!spot || !character) throw new Error("Choose a spot and a character first.");
|
||||
const seed = `gm-spot-${globalThis.crypto.randomUUID()}`;
|
||||
const selection = selectBiteSizedRun(layouts.draft, spot.dungeonId, seed, { spotId });
|
||||
await prepareBiteSizedLayoutContent(spot.dungeonId, seed, selection);
|
||||
if (!activateDungeonSession(spot.dungeonId, undefined, { gmTestMode: true, runId: seed, biteSizedSelection: selection })) throw new Error("The selected dungeon could not be opened.");
|
||||
useBiteSizedLayoutsStore.setState({ editingSpotId: spotId, editingPointId: "party", editorMode: mode, panelFocused: false, error: null });
|
||||
useShellStore.setState({ phase: "game", activeCharacter: character, activeDungeonId: spot.dungeonId,
|
||||
activeDungeonRole: defaultPartyRoleForClass(character.classId), notice: "" });
|
||||
return true;
|
||||
} catch (error) {
|
||||
useBiteSizedLayoutsStore.setState({ error: error instanceof Error ? error.message : "Could not open the spot." });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function leaveBiteSizedEditor() {
|
||||
useBiteSizedLayoutsStore.setState({ editorMode: null, panelFocused: false });
|
||||
useGameStore.getState().closeOverlay();
|
||||
useShellStore.setState({ phase: "bite-sized-editor", activeCharacter: null });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BITE_SIZED_DEFAULT_CONFIG, normalizeBiteSizedConfig, selectBiteSizedRun } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { prepareBiteSizedLayoutContent, preparedBiteSizedRunSelection } from "./biteSizedLayoutContent";
|
||||
|
||||
const id = "randomized-bite-sized-stormwind-stockade";
|
||||
const selection = selectBiteSizedRun(normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG), id, "content-selected-map", { eligibleDungeonIds: ["bite-sized-forsaken-abbey-ancalon"] });
|
||||
describe("pinned spot content preparation", () => {
|
||||
it("installs the actual parent map and every rolled creature before entry", async () => {
|
||||
let usable = false;
|
||||
const installPack = vi.fn(async () => { usable = true; });
|
||||
const installUrls = vi.fn(async (_urls: readonly string[]) => undefined), refresh = vi.fn(async () => undefined);
|
||||
const result = await prepareBiteSizedLayoutContent(id, "install-map", selection, { development: false, installPack, installUrls,
|
||||
refresh, packIsUsable: () => usable, packHasUpdate: () => false });
|
||||
expect(installPack).toHaveBeenCalledExactlyOnceWith("dungeon:forsaken-abbey");
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(installUrls.mock.calls[0]![0].length).toBeGreaterThan(0);
|
||||
expect(result).toEqual(selection);
|
||||
});
|
||||
it("keeps an installed pinned snapshot usable offline without a layout refresh or download", async () => {
|
||||
const installPack = vi.fn(async () => { throw new Error("Offline"); }), refresh = vi.fn(async () => { throw new Error("Offline"); });
|
||||
const result = await prepareBiteSizedLayoutContent(id, "offline-resume", selection, { development: false, installPack,
|
||||
refresh, packIsUsable: () => true, packHasUpdate: () => false, installUrls: vi.fn(async () => undefined) });
|
||||
expect(result).toEqual(selection); expect(refresh).not.toHaveBeenCalled(); expect(installPack).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects unavailable maps and creatures without caching a partially prepared run", async () => {
|
||||
const deps = { development: false, refresh: vi.fn(async () => undefined), packIsUsable: () => false,
|
||||
packHasUpdate: () => false, installPack: vi.fn(async () => undefined), installUrls: vi.fn(async () => undefined) };
|
||||
await expect(prepareBiteSizedLayoutContent(id, "missing-map", selection, deps)).rejects.toThrow(/verified/);
|
||||
expect(preparedBiteSizedRunSelection(id, "missing-map")).toBeUndefined();
|
||||
await expect(prepareBiteSizedLayoutContent(id, "missing-creature", selection, { ...deps, packIsUsable: () => true,
|
||||
installUrls: vi.fn(async () => { throw new Error("Creature unavailable"); }) })).rejects.toThrow(/Creature unavailable/);
|
||||
expect(preparedBiteSizedRunSelection(id, "missing-creature")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { BITE_SIZED_LAYOUT_CATALOG, isLayoutBiteSizedDungeon, normalizeBiteSizedSelection, selectBiteSizedRun, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { contentPackIsUsable, contentPackHasUpdate, installContentPack, installContentForLogicalUrls } from "../content/contentManager";
|
||||
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
|
||||
import { useDungeonQueuePolicyStore } from "./dungeonQueuePolicyStore";
|
||||
|
||||
const prepared = new Map<string, BiteSizedRunSelection>();
|
||||
interface LayoutContentDependencies {
|
||||
readonly development: boolean;
|
||||
readonly refresh: () => Promise<void>;
|
||||
readonly packIsUsable: (id: string) => boolean;
|
||||
readonly packHasUpdate: (id: string) => boolean;
|
||||
readonly installPack: (id: string) => Promise<void>;
|
||||
readonly installUrls: (urls: readonly string[]) => Promise<void>;
|
||||
}
|
||||
const DEFAULT_DEPENDENCIES: LayoutContentDependencies = { development: import.meta.env.DEV,
|
||||
refresh: () => useBiteSizedLayoutsStore.getState().refresh(), packIsUsable: contentPackIsUsable,
|
||||
packHasUpdate: contentPackHasUpdate, installPack: installContentPack, installUrls: installContentForLogicalUrls };
|
||||
export function preparedBiteSizedRunSelection(id: string, seed: string): BiteSizedRunSelection | undefined { return prepared.get(`${id}:${seed}`); }
|
||||
export async function prepareBiteSizedLayoutContent(id: string, seed: string, pinned?: BiteSizedRunSelection, dependencies = DEFAULT_DEPENDENCIES): Promise<BiteSizedRunSelection | undefined> {
|
||||
if (!isLayoutBiteSizedDungeon(id)) return undefined;
|
||||
if (!pinned && !preparedBiteSizedRunSelection(id, seed)) await dependencies.refresh();
|
||||
const selection = pinned ? normalizeBiteSizedSelection(pinned) : preparedBiteSizedRunSelection(id, seed)
|
||||
?? selectBiteSizedRun(useBiteSizedLayoutsStore.getState().config, id, seed, {
|
||||
eligibleDungeonIds: BITE_SIZED_LAYOUT_CATALOG.dungeons.filter((d) => !useDungeonQueuePolicyStore.getState().config.disabled.biteSized.includes(d.id)).map((d) => d.id),
|
||||
});
|
||||
if (selection.dungeonId !== id) throw new Error("The run selection belongs to another activity.");
|
||||
if (!dependencies.development) {
|
||||
const pack = `dungeon:${selection.spot.sourceDungeonId}`;
|
||||
if (!dependencies.packIsUsable(pack) || dependencies.packHasUpdate(pack)) await dependencies.installPack(pack);
|
||||
if (!dependencies.packIsUsable(pack)) throw new Error("The selected spot's dungeon download could not be verified.");
|
||||
const refs = [...selection.mobs, selection.boss];
|
||||
const urls = [...new Set(refs.flatMap((ref) => {
|
||||
const creature = BITE_SIZED_LAYOUT_CATALOG.creatures.find((c) => c.sourceDungeonId === ref.sourceDungeonId && c.entityId === ref.entityId);
|
||||
return creature?.modelUrl ? [creature.modelUrl] : [];
|
||||
}))];
|
||||
await dependencies.installUrls(urls);
|
||||
}
|
||||
prepared.set(`${id}:${seed}`, selection);
|
||||
if (prepared.size > 24) prepared.delete(prepared.keys().next().value!);
|
||||
return selection;
|
||||
}
|
||||
@@ -4,6 +4,14 @@ import { dungeonLeaderboardCatalog as catalog } from "../game/dungeonLeaderboard
|
||||
import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard,
|
||||
type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes";
|
||||
import { isLeaderboardFullClear, leaderboardFinalBossIds, leaderboardRunBossId } from "../game/dungeonLeaderboardRules";
|
||||
import { defaultBiteSizedBoardScope } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
export interface BiteSizedBoardFilter { readonly spotId: string; readonly spotRevision: number }
|
||||
function matchesSpot(entry: DungeonRunRecord, spotId?: string, spotRevision?: number): boolean {
|
||||
const scope = entry.biteSizedSelection ? { spotId: entry.biteSizedSelection.spot.id, spotRevision: entry.biteSizedSelection.spot.revision } : defaultBiteSizedBoardScope(entry.dungeonId);
|
||||
const defaults = defaultBiteSizedBoardScope(entry.dungeonId);
|
||||
return !defaults.spotId || (scope.spotId === (spotId ?? defaults.spotId) && scope.spotRevision === (spotRevision ?? defaults.spotRevision));
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "healer-man:dungeon-runs:v1";
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -44,7 +52,7 @@ export function saveLocalDungeonRun(record: DungeonRunRecord): void {
|
||||
}
|
||||
|
||||
export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
|
||||
filter: DungeonClearFilter, offset = 0, bossDifficulty = 0, bossId?: string): DungeonLeaderboard {
|
||||
filter: DungeonClearFilter, offset = 0, bossDifficulty = 0, bossId?: string, spotId?: string, spotRevision?: number): DungeonLeaderboard {
|
||||
const difficulty = catalog.dungeons.find((entry) => entry.id === dungeonId)?.difficulties
|
||||
.find((entry) => entry.id === difficultyId);
|
||||
const selectedBossId = difficulty?.finalBossIds?.length
|
||||
@@ -52,6 +60,7 @@ export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
|
||||
const runs = localRuns().filter((entry) => entry.dungeonId === dungeonId && entry.difficultyId === difficultyId
|
||||
&& (entry.bossDifficulty ?? 0) === bossDifficulty
|
||||
&& (!selectedBossId || entry.killedBossIds.includes(selectedBossId))
|
||||
&& matchesSpot(entry, spotId, spotRevision)
|
||||
&& (filter === "final" || entry.fullClear)).sort((a, b) => a.durationMs - b.durationMs
|
||||
|| a.finishedAt - b.finishedAt || a.id.localeCompare(b.id));
|
||||
return { total: runs.length, entries: runs.slice(offset, offset + DUNGEON_LEADERBOARD_PAGE_SIZE)
|
||||
@@ -63,11 +72,13 @@ export function localDungeonRunPlacement(record: DungeonRunRecord): DungeonRunPl
|
||||
.find((entry) => entry.id === record.difficultyId);
|
||||
const bossId = difficulty?.finalBossIds?.length
|
||||
? leaderboardRunBossId(difficulty, record.killedBossIds) ?? undefined : undefined;
|
||||
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final", 0, record.bossDifficulty ?? 0, bossId);
|
||||
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full", 0, record.bossDifficulty ?? 0, bossId);
|
||||
const spotId = record.biteSizedSelection?.spot.id;
|
||||
const spotRevision = record.biteSizedSelection?.spot.revision;
|
||||
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final", 0, record.bossDifficulty ?? 0, bossId, spotId, spotRevision);
|
||||
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full", 0, record.bossDifficulty ?? 0, bossId, spotId, spotRevision);
|
||||
const runs = localRuns().filter((entry) => entry.dungeonId === record.dungeonId && entry.difficultyId === record.difficultyId
|
||||
&& (entry.bossDifficulty ?? 0) === (record.bossDifficulty ?? 0)
|
||||
&& (!bossId || entry.killedBossIds.includes(bossId)));
|
||||
&& (!bossId || entry.killedBossIds.includes(bossId)) && matchesSpot(entry, spotId, spotRevision));
|
||||
return { finalRank: 1 + runs.filter((entry) => entry.durationMs < record.durationMs).length,
|
||||
finalTotal: final.total, fullTotal: full.total,
|
||||
fullRank: record.fullClear ? 1 + runs.filter((entry) => entry.fullClear && entry.durationMs < record.durationMs).length : null };
|
||||
@@ -75,10 +86,11 @@ export function localDungeonRunPlacement(record: DungeonRunRecord): DungeonRunPl
|
||||
|
||||
export async function fetchDungeonLeaderboard(dungeonId: string, difficultyId: string,
|
||||
filter: DungeonClearFilter, offset: number, signal?: AbortSignal, bossDifficulty = 0,
|
||||
bossId?: string): Promise<DungeonLeaderboard> {
|
||||
bossId?: string, spotId?: string, spotRevision?: number): Promise<DungeonLeaderboard> {
|
||||
const query = new URLSearchParams({ dungeon: dungeonId, difficulty: difficultyId, clear: filter,
|
||||
offset: String(offset), limit: String(DUNGEON_LEADERBOARD_PAGE_SIZE), bossDifficulty: String(bossDifficulty) });
|
||||
if (bossId) query.set("boss", bossId);
|
||||
if (spotId) { query.set("spot", spotId); query.set("spotRevision", String(spotRevision ?? 0)); }
|
||||
return requestOnlineJson(`/api/dungeon-leaderboards?${query}`, { signal });
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { finishRaceCombatLog, startRaceCombatLog, stopRaceCombatLog,
|
||||
type RaceCombatLog } from "../game/dungeonRaceCombatLog";
|
||||
import { rollDungeonRaceAffixes, validDungeonRaceAffixes, type DungeonRaceAffixPair } from "../game/dungeonRaceAffixes";
|
||||
import { cleanupDungeonRaceAffixes } from "../game/dungeonRaceAffixLifecycle";
|
||||
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
export type RacePool = "authored" | "randomized";
|
||||
export type RaceStatus = "idle" | "queued" | "preparing" | "countdown" | "active" | "settled" | "void";
|
||||
@@ -22,6 +24,7 @@ export interface RaceHumanOpponent { readonly type: "human"; readonly name: stri
|
||||
readonly source: "wow" | "rom"; readonly ready: boolean; readonly finished: boolean }
|
||||
export type RaceOpponent = RaceHumanOpponent | RaceBot;
|
||||
export interface RaceServerState {
|
||||
readonly biteSizedSelection?: BiteSizedRunSelection;
|
||||
readonly status: RaceStatus;
|
||||
readonly serverTime: number;
|
||||
readonly joinedAt?: number;
|
||||
@@ -41,6 +44,7 @@ export interface RaceServerState {
|
||||
}
|
||||
|
||||
interface RaceState {
|
||||
biteSizedSelection: BiteSizedRunSelection | null;
|
||||
mode: "online" | "practice" | null;
|
||||
status: RaceStatus;
|
||||
matchId: string | null;
|
||||
@@ -91,10 +95,12 @@ 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> {
|
||||
async function prepareContent(dungeonId: string, seed: string, pinned?: BiteSizedRunSelection) {
|
||||
const selection = await prepareBiteSizedLayoutContent(dungeonId, seed, pinned);
|
||||
if (dungeonId.startsWith("randomized-bite-sized-")) await ensureRandomizedBiteSizedRunContent(seed, undefined, dungeonId);
|
||||
else if (!dungeonContentIsUsable(dungeonId)) await installContentPack(dungeonContentPackId(dungeonId));
|
||||
if (!dungeonContentIsUsable(dungeonId)) throw new Error("The dungeon content could not be verified.");
|
||||
if (!dungeonContentIsUsable(selection?.spot.sourceDungeonId ?? dungeonId)) throw new Error("The dungeon content could not be verified.");
|
||||
return selection;
|
||||
}
|
||||
|
||||
export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
@@ -104,7 +110,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
preparingSeed = seed;
|
||||
set({ preparingContent: true, error: "" });
|
||||
try {
|
||||
await prepareContent(dungeonId, seed);
|
||||
const biteSizedSelection = await prepareContent(dungeonId, seed, get().biteSizedSelection ?? undefined);
|
||||
if (raceRevision !== revision || get().seed !== seed || get().status === "void") return;
|
||||
if (!validDungeonRaceAffixes(get().affixIds)) {
|
||||
throw new Error("This race uses mechanics your client does not support. Update the game to race.");
|
||||
@@ -114,10 +120,11 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
installDungeonShellRuntime();
|
||||
const entered = useShellStore.getState().enterDungeon(dungeonId, selectedRole, {
|
||||
freshRun: true, runId: seed, difficultyId, bossDifficulty: 1, raceStartBlocked: true,
|
||||
...(biteSizedSelection ? { biteSizedSelection } : {}),
|
||||
});
|
||||
if (!entered) throw new Error(useShellStore.getState().notice || "Could not enter the race dungeon.");
|
||||
startRaceCombatLog(seed, dungeonId);
|
||||
set({ inGame: true, preparingContent: false });
|
||||
set({ inGame: true, preparingContent: false, biteSizedSelection: biteSizedSelection ?? null });
|
||||
} catch (error) {
|
||||
stopRaceCombatLog();
|
||||
if (raceRevision === revision) {
|
||||
@@ -146,6 +153,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
}
|
||||
set({ status: response.status, matchId: response.matchId ?? null, seed: response.seed ?? null,
|
||||
dungeonId: response.dungeonId ?? null, difficultyId: response.difficultyId ?? null,
|
||||
biteSizedSelection: response.biteSizedSelection ?? previous.biteSizedSelection,
|
||||
affixIds: validDungeonRaceAffixes(response.affixIds) ? response.affixIds : null,
|
||||
startAt: response.startAt ?? null,
|
||||
serverOffset: offset, opponent: response.opponent ?? null,
|
||||
@@ -158,7 +166,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
if (["settled", "void", "idle"].includes(response.status)) stopPolling();
|
||||
}
|
||||
return {
|
||||
mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null,
|
||||
mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null, biteSizedSelection: null,
|
||||
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
|
||||
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "",
|
||||
queueAt: null, botAt: null,
|
||||
@@ -306,7 +314,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
cleanupDungeonRaceAffixes();
|
||||
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false; finishPromise = null;
|
||||
stopRaceCombatLog(); pendingCombatLog = null;
|
||||
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null,
|
||||
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null, biteSizedSelection: null,
|
||||
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
|
||||
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "", queueAt: null, botAt: null });
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import { registerDungeonShellRuntime, type DungeonEntrySettings } from "./dungeo
|
||||
import { touchCharacter } from "./accountRepository";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
import { preparedBiteSizedRunSelection } from "./biteSizedLayoutContent";
|
||||
|
||||
export function installDungeonShellRuntime(): void {
|
||||
registerDungeonShellRuntime({ enterDungeon });
|
||||
@@ -23,10 +24,6 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
|
||||
useShellStore.setState({ notice: "That dungeon is not available yet." });
|
||||
return false;
|
||||
}
|
||||
if (!dungeonContentIsUsable(dungeonId)) {
|
||||
useShellStore.setState({ notice: "Install this expedition from Downloads before entering." });
|
||||
return false;
|
||||
}
|
||||
const { session, selectedCharacterId, characters } = useShellStore.getState();
|
||||
if (!session || !selectedCharacterId) {
|
||||
useShellStore.setState({ notice: "Select or create a character first." });
|
||||
@@ -62,6 +59,12 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
|
||||
&& (settings?.bossDifficulty === undefined || checkpoint.run?.bossDifficulty === settings.bossDifficulty)
|
||||
? activeCharacter.dungeonRunCheckpoint
|
||||
: null;
|
||||
const biteSizedSelection = settings?.biteSizedSelection ?? resume?.run?.biteSizedSelection
|
||||
?? (settings?.runId ? preparedBiteSizedRunSelection(dungeonId, settings.runId) : undefined);
|
||||
if (!dungeonContentIsUsable(biteSizedSelection?.spot.sourceDungeonId ?? dungeonId)) {
|
||||
useShellStore.setState({ notice: "Install this expedition from Downloads before entering." });
|
||||
return false;
|
||||
}
|
||||
if (!activateDungeonSession(dungeonId, settings?.difficultyId ?? resume?.difficultyId, {
|
||||
bossDifficulty: settings?.bossDifficulty ?? resume?.run?.bossDifficulty,
|
||||
spawn: resume?.spawn,
|
||||
@@ -69,6 +72,7 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
|
||||
runProgress: resume?.run,
|
||||
legacyResume: Boolean(resume && resume.run?.bossDifficulty === undefined),
|
||||
runId: settings?.runId,
|
||||
biteSizedSelection,
|
||||
})) {
|
||||
useShellStore.setState({
|
||||
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface DungeonShellRuntime {
|
||||
}
|
||||
|
||||
export interface DungeonEntrySettings {
|
||||
readonly biteSizedSelection?: import("../../shared/bite-sized-layouts.mjs").BiteSizedRunSelection;
|
||||
readonly difficultyId?: string;
|
||||
readonly bossDifficulty?: number;
|
||||
readonly freshRun?: boolean;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface OnlineGroupMember {
|
||||
}
|
||||
|
||||
export interface OnlineGroupActivity {
|
||||
readonly biteSizedSelection?: import("../../shared/bite-sized-layouts.mjs").BiteSizedRunSelection;
|
||||
readonly difficultyId?: string;
|
||||
readonly bossDifficulty?: number;
|
||||
readonly id: string;
|
||||
|
||||
@@ -9,6 +9,8 @@ import { dungeonDefinitionById } from "../game/dungeonRegistry";
|
||||
import { classCanFillPartyRole, type PartyRole } from "../game/partyRoles";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
|
||||
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
export type RandomDungeonQueueMode = "regular" | "bite-sized";
|
||||
export type RandomDungeonQueueStage = "rolling" | "downloading" | "entering";
|
||||
@@ -29,6 +31,7 @@ export interface RandomDungeonQueueOptions {
|
||||
}
|
||||
|
||||
interface RandomDungeonQueueDependencies {
|
||||
readonly prepareBiteSized?: (id: string, seed: string) => Promise<BiteSizedRunSelection | undefined>;
|
||||
readonly random: () => number;
|
||||
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
|
||||
readonly contentIsUsable: (dungeonId: string) => boolean;
|
||||
@@ -37,6 +40,7 @@ interface RandomDungeonQueueDependencies {
|
||||
}
|
||||
|
||||
const DEFAULT_DEPENDENCIES: RandomDungeonQueueDependencies = {
|
||||
prepareBiteSized: prepareBiteSizedLayoutContent,
|
||||
random: Math.random,
|
||||
loadPolicy: loadDungeonQueuePolicy,
|
||||
contentIsUsable: dungeonContentIsUsable,
|
||||
@@ -107,10 +111,13 @@ export async function queueRandomDungeon(
|
||||
}
|
||||
|
||||
await dependencies.installDungeonRuntime();
|
||||
const runId = globalThis.crypto.randomUUID();
|
||||
const biteSizedSelection = options.mode === "bite-sized" ? await dependencies.prepareBiteSized?.(dungeon.id, runId) : undefined;
|
||||
const entered = options.enterDungeon(dungeon.id, role, {
|
||||
difficultyId,
|
||||
bossDifficulty: 1,
|
||||
freshRun: true,
|
||||
...(biteSizedSelection ? { runId, biteSizedSelection } : {}),
|
||||
});
|
||||
return entered
|
||||
? { status: "entered", dungeonTitle: dungeon.title }
|
||||
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
dungeonContentPackId,
|
||||
} from "../game/dungeonContentSource";
|
||||
import { randomizedBiteSizedDefinitionForSeed } from "../game/dungeonRegistry";
|
||||
import { biteSizedDefinitionForSelection } from "../game/dungeonRegistry";
|
||||
import { preparedBiteSizedRunSelection, prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||
import { selectBiteSizedRun } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
|
||||
|
||||
export function randomizedBiteSizedModelUrls(seed: string, dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID): readonly string[] {
|
||||
const definition = randomizedBiteSizedDefinitionForSeed(seed, dungeonId);
|
||||
const selection = preparedBiteSizedRunSelection(dungeonId, seed) ?? selectBiteSizedRun(useBiteSizedLayoutsStore.getState().config, dungeonId, seed);
|
||||
const definition = biteSizedDefinitionForSelection(selection, seed);
|
||||
return [...new Set(Object.values(definition.entities).flatMap((entity) => (
|
||||
entity.visual.model?.url ? [entity.visual.model.url] : []
|
||||
)))].sort();
|
||||
@@ -39,6 +43,7 @@ export async function ensureRandomizedBiteSizedRunContent(
|
||||
dependencies: RandomizedContentDependencies = DEFAULT_DEPENDENCIES,
|
||||
dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
): Promise<void> {
|
||||
if (dependencies === DEFAULT_DEPENDENCIES) { await prepareBiteSizedLayoutContent(dungeonId, seed); return; }
|
||||
if (dependencies.development) return;
|
||||
const basePackId = dungeonContentPackId(dungeonId);
|
||||
if (!dependencies.packIsUsable(basePackId) || dependencies.packHasUpdate(basePackId)) {
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import type { ManastormProgress } from "../game/manastormProgress";
|
||||
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
|
||||
import type { BossCardCollection } from "../game/bossCards";
|
||||
|
||||
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "game";
|
||||
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "bite-sized-editor" | "game";
|
||||
export type SessionKind = "account" | "offline";
|
||||
export type AccountRole = "gm";
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
import { advanceDungeonRaceAffixes, resetDungeonRaceAffixEffects, resumeDungeonRaceAffixes,
|
||||
suspendDungeonRaceAffixes } from "./dungeonRaceAffixRuntime";
|
||||
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
|
||||
import {
|
||||
DUNGEON_SIMULATION_FIXED_STEP_MS,
|
||||
useDungeonSimulationStore,
|
||||
@@ -192,7 +193,7 @@ export function CombatBridge() {
|
||||
return;
|
||||
}
|
||||
const game = useGameStore.getState();
|
||||
if (game.dungeonIntroActive || game.raceStartBlocked) return;
|
||||
if (game.dungeonIntroActive || game.raceStartBlocked || useBiteSizedLayoutsStore.getState().editorMode === "edit") return;
|
||||
const sharedOnline = onlineSessionIsActive();
|
||||
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
|
||||
advanceOneTick(delta, Date.now(), false);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useGameStore } from "./store";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
import type { DungeonRunMember } from "./dungeonRunTypes";
|
||||
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
|
||||
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
|
||||
|
||||
function currentRunParty(): readonly DungeonRunMember[] {
|
||||
const shell = useShellStore.getState();
|
||||
@@ -36,7 +37,8 @@ export function DungeonRunBridge() {
|
||||
const advance = () => {
|
||||
const game = useGameStore.getState();
|
||||
const shell = useShellStore.getState();
|
||||
if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode || game.dungeonIntroActive
|
||||
const editorTest = useBiteSizedLayoutsStore.getState().editorMode === "test";
|
||||
if (shell.phase !== "game" || game.gameMode !== "dungeon" || (game.gmTestMode && !editorTest) || game.dungeonIntroActive
|
||||
|| game.raceStartBlocked
|
||||
|| !["ready", "proxy"].includes(game.assetStatus)) return;
|
||||
const run = useDungeonRunStore.getState();
|
||||
@@ -55,7 +57,7 @@ export function DungeonRunBridge() {
|
||||
Object.values(useCombatStore.getState().mobs).filter((mob) => mob.dead && !mob.despawned).map((mob) => mob.id),
|
||||
Date.now(), party);
|
||||
const progress = useDungeonRunStore.getState().progress;
|
||||
if (progress?.finishedAt !== null && progress && announcedId !== progress.id) {
|
||||
if (progress?.finishedAt !== null && progress && announcedId !== progress.id && !editorTest) {
|
||||
announcedId = progress.id;
|
||||
useGameStore.getState().openOverlay("dungeon-results");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { BITE_SIZED_DEFAULT_CONFIG, biteSizedGameplayKey, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
|
||||
import type { PopulationEntityDefinition, StaticMobSpawnDefinition } from "./mobPopulation";
|
||||
|
||||
export function materializeBiteSizedLayout(
|
||||
source: DungeonDefinition, baseline: DungeonDefinition, selection: BiteSizedRunSelection,
|
||||
sources: Readonly<Record<string, DungeonDefinition>>, difficultyId?: string,
|
||||
): DungeonDefinition {
|
||||
const { spot } = selection;
|
||||
const randomized = selection.dungeonId.startsWith("randomized-bite-sized-");
|
||||
const original = BITE_SIZED_DEFAULT_CONFIG.spots.find((s) => s.id === spot.id);
|
||||
if (!randomized && spot.legacyLayout && spot.revision === 0 && original && biteSizedGameplayKey(original) === biteSizedGameplayKey(spot) && baseline.bosses[0]?.id === selection.boss.objectiveId) {
|
||||
return { ...baseline, contentSourceDungeonId: spot.sourceDungeonId, biteSizedSelection: selection };
|
||||
}
|
||||
const entities: Record<string, PopulationEntityDefinition> = {};
|
||||
const resolve = (sourceDungeonId: string, entityId: string) => {
|
||||
const original = sources[sourceDungeonId]?.entities[entityId];
|
||||
if (!original) throw new Error(`Creature unavailable: ${sourceDungeonId}/${entityId}`);
|
||||
const id = `placed:${sourceDungeonId}:${entityId}`;
|
||||
entities[id] = { ...original, ...(randomized && original.combat ? { combat: { ...original.combat, healthMultiplier: 1, damageMultiplier: 1 } } : {}) };
|
||||
return id;
|
||||
};
|
||||
const difficulty = source.difficultyVariants.find((d) => d.id === difficultyId) ?? source.difficultyVariants.find((d) => d.id === source.defaultDifficultyId)!;
|
||||
const allMasks = source.difficultyVariants.reduce((mask, d) => mask | d.spawnMask, 0);
|
||||
const mobs: StaticMobSpawnDefinition[] = spot.mobs.flatMap((placement) => {
|
||||
if (((placement.spawnMask ?? allMasks) & difficulty.spawnMask) === 0) return [];
|
||||
const ref = selection.mobs.find((m) => m.placementId === placement.id)!;
|
||||
return [{ id: placement.id, entityId: resolve(ref.sourceDungeonId, ref.entityId), position: placement.position,
|
||||
yaw: placement.yaw, spawnMask: placement.spawnMask ?? allMasks, spawnChance: 1 }];
|
||||
});
|
||||
const bossSpawn: StaticMobSpawnDefinition = { id: selection.boss.objectiveId, entityId: resolve(selection.boss.sourceDungeonId, selection.boss.entityId),
|
||||
position: spot.bossSpawn.footPosition, yaw: spot.bossSpawn.yaw, spawnMask: allMasks };
|
||||
const held = spot.bossActivation === "after-mobs" && mobs.length > 0;
|
||||
const groupId = `${spot.id}:boss`;
|
||||
const encounter: DungeonEncounterDefinition = {
|
||||
id: `${spot.id}:progression`, dungeonId: selection.dungeonId, initialPhase: held ? "mobs" : "boss",
|
||||
initialState: { flags: { "dungeon-complete": false } }, resetPolicy: { bossWipePhase: held ? "mobs" : "boss", checkpointPhase: held ? "mobs" : "boss" },
|
||||
rules: [
|
||||
...(held ? [{ id: "mobs-cleared", trigger: { kind: "entities-dead" as const, entityIds: mobs.map((m) => m.id) },
|
||||
guards: [{ kind: "phase" as const, phases: ["mobs"] }], actions: [
|
||||
{ id: "spawn-boss", kind: "activate-spawn-group" as const, spawnGroupId: groupId },
|
||||
{ id: "boss-phase", kind: "set-phase" as const, phase: "boss" },
|
||||
] }] : []),
|
||||
{ id: "boss-defeated", trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] }, guards: [{ kind: "phase", phases: ["boss"] }], actions: [
|
||||
{ id: "objective", kind: "complete-objective", objectiveId: bossSpawn.id, persistence: "run" },
|
||||
{ id: "checkpoint", kind: "set-checkpoint", checkpointId: bossSpawn.id, persistence: "checkpoint" },
|
||||
{ id: "complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
|
||||
{ id: "complete-phase", kind: "set-phase", phase: "complete", persistence: "run" },
|
||||
] },
|
||||
],
|
||||
};
|
||||
const objective = { id: bossSpawn.id, name: selection.boss.name, position: bossSpawn.position, eventOnly: held };
|
||||
return { ...source, id: selection.dungeonId, contentSourceDungeonId: source.id, biteSizedSelection: selection,
|
||||
title: `${source.title}: ${spot.name}`, lfgDungeonIds: [],
|
||||
entrance: { ...source.entrance, name: spot.name, ...spot.partySpawn, forward: [Math.sin(spot.partySpawn.yaw), 0, Math.cos(spot.partySpawn.yaw)] },
|
||||
presentation: { ...baseline.presentation, location: `${source.title} · ${spot.name}`, theme: "Bite-Sized Dungeon", loadingMessage: `Entering ${spot.name}...` },
|
||||
areas: [{ id: "spot", name: spot.name, center: spot.partySpawn.footPosition, radius: 100 }],
|
||||
entities, staticSpawns: held ? mobs : [...mobs, bossSpawn], roamingPacks: [], bosses: [objective], objectiveOrder: [objective],
|
||||
finalBossDurability: "standard", encounters: [encounter], encounterSpawnGroups: held ? [{ id: groupId, spawns: [bossSpawn] }] : [],
|
||||
serverGameObjects: spot.legacyLayout ? baseline.serverGameObjects : [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { GLTFLoader, MeshoptDecoder } from "three-stdlib";
|
||||
import { resolveContentUrl } from "../content/contentManager";
|
||||
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
|
||||
import { resolveDungeonAssets } from "./dungeonAssets";
|
||||
import { cachedPartyNavigationGraph, preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
||||
import { BITE_SIZED_LAYOUT_CATALOG, biteSizedGameplayKey, type BiteSizedSpot } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { validateBiteSizedSpotNavigation } from "./biteSizedSpotNavigation";
|
||||
export { validateBiteSizedSpotNavigation } from "./biteSizedSpotNavigation";
|
||||
|
||||
export async function validateChangedBiteSizedSpots(spots: readonly BiteSizedSpot[], previous: readonly BiteSizedSpot[]): Promise<void> {
|
||||
for (const spot of spots) {
|
||||
const old = previous.find((s) => s.id === spot.id);
|
||||
if (old && biteSizedGameplayKey(old) === biteSizedGameplayKey(spot)) continue;
|
||||
const source = DUNGEON_REGISTRY[spot.sourceDungeonId];
|
||||
const url = resolveDungeonAssets(source).navigation;
|
||||
if (!url) throw new Error(`${spot.name}: the parent dungeon has no navigation surface.`);
|
||||
let graph = cachedPartyNavigationGraph(url, source.navigationLinks, source.traversalRamps, true);
|
||||
if (!graph) {
|
||||
const gltf = await new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).loadAsync(resolveContentUrl(url));
|
||||
graph = preparePartyNavigationGraph(url, gltf.scene, source.navigationLinks, source.traversalRamps, 1_000_000, true);
|
||||
gltf.scene.traverse((node) => { if ("geometry" in node) (node as import("three").Mesh).geometry.dispose(); });
|
||||
}
|
||||
// Off-mesh routes are already included in the retained connected node
|
||||
// component; triangle-only A* would incorrectly reject bridged floors.
|
||||
const blockers = spot.legacyLayout ? BITE_SIZED_LAYOUT_CATALOG.dungeons.find((d) => d.id === spot.dungeonId)!.legacyBlockers : [];
|
||||
validateBiteSizedSpotNavigation(spot, graph, graph.triangleNodes || blockers.length ? undefined : graph.triangles!.map(() => 0), blockers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { BITE_SIZED_DEFAULT_CONFIG, BITE_SIZED_LAYOUT_CATALOG, biteSizedGameplayKey, normalizeBiteSizedConfig, normalizeBiteSizedSelection, selectBiteSizedRun } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { biteSizedDefinitionForSelection, dungeonDefinitionById, legacyBiteSizedRunSelection, randomizedBiteSizedDefinitionForSeed } from "./dungeonRegistry";
|
||||
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
|
||||
import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "./dungeonEncounterRuntime";
|
||||
import { normalizeDungeonRunCheckpoint, DUNGEON_RUN_CHECKPOINT_VERSION } from "./dungeonRunCheckpoint";
|
||||
|
||||
const config = () => ({ schemaVersion: 1 as const, spots: [...normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG).spots] });
|
||||
const parent = config().spots.find((s) => s.sourceDungeonId === "stormwind-stockade")!;
|
||||
|
||||
describe("published Bite-Sized spots", () => {
|
||||
beforeEach(() => useBiteSizedLayoutsStore.setState({ config: config(), draft: config(), dirty: false, editorMode: null }));
|
||||
it("permits repeated creatures, imported references, and different stable placement ids", () => {
|
||||
const next = config();
|
||||
const imported = BITE_SIZED_LAYOUT_CATALOG.creatures.find((c) => c.kind === "mob" && c.sourceDungeonId !== parent.sourceDungeonId)!;
|
||||
const duplicate = { ...parent, id: "another-spot", legacyLayout: false, mobs: [
|
||||
{ ...parent.mobs[0], id: "one", sourceDungeonId: imported.sourceDungeonId, entityId: imported.entityId },
|
||||
{ ...parent.mobs[0], id: "two", sourceDungeonId: imported.sourceDungeonId, entityId: imported.entityId },
|
||||
] };
|
||||
next.spots.push(duplicate);
|
||||
const selection = selectBiteSizedRun(normalizeBiteSizedConfig(next), parent.dungeonId, "duplicate", { spotId: duplicate.id });
|
||||
const definition = biteSizedDefinitionForSelection(selection, "duplicate")!;
|
||||
expect(definition.staticSpawns.filter((s) => s.id === "one" || s.id === "two")).toHaveLength(2);
|
||||
expect(definition.serverGameObjects).toEqual([]);
|
||||
expect(normalizeBiteSizedSelection(JSON.parse(JSON.stringify(selection)))).toEqual(selection);
|
||||
expect(() => normalizeBiteSizedConfig({ ...next, spots: next.spots.map((s) => s.id === duplicate.id ? { ...s, mobs: [s.mobs[0], s.mobs[0]] } : s) })).toThrow(/unique/);
|
||||
expect(() => normalizeBiteSizedConfig({ ...next, spots: next.spots.filter((s) => s.sourceDungeonId !== parent.sourceDungeonId) })).toThrow(/between 1/);
|
||||
expect(biteSizedGameplayKey(parent)).toBe(biteSizedGameplayKey({ ...parent, name: "Renamed", revision: 5 }));
|
||||
});
|
||||
it("pins the same spot, boss, and mobs through checkpoints, publication, and retries", () => {
|
||||
const selection = selectBiteSizedRun(config(), parent.dungeonId, "pinned");
|
||||
expect(activateDungeonSession(parent.dungeonId, undefined, { runId: "pinned", biteSizedSelection: selection })).toBe(true);
|
||||
const run = useDungeonRunStore.getState().progress!;
|
||||
const checkpoint = normalizeDungeonRunCheckpoint(JSON.parse(JSON.stringify({ version: DUNGEON_RUN_CHECKPOINT_VERSION,
|
||||
dungeonId: parent.dungeonId, difficultyId: run.difficultyId, checkpointId: "entrance", spawn: parent.partySpawn,
|
||||
encounters: [], savedAt: Date.now(), run })));
|
||||
expect(checkpoint?.run?.biteSizedSelection).toEqual(selection);
|
||||
const changed = config();
|
||||
changed.spots = changed.spots.map((s) => s.id === parent.id ? { ...s, revision: 1, mobs: [], name: "Published later" } : s);
|
||||
useBiteSizedLayoutsStore.setState({ config: changed });
|
||||
expect(restartActiveDungeonSession()).toBe(true);
|
||||
expect(dungeonDefinitionById(parent.dungeonId)?.biteSizedSelection).toEqual(selection);
|
||||
});
|
||||
it("uses independent deterministic rolls and multiple parent maps for both randomized pools", () => {
|
||||
for (const id of Object.keys(BITE_SIZED_LAYOUT_CATALOG.pools)) {
|
||||
const maps = new Set<string>();
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const seed = `random-${i}`;
|
||||
const selection = selectBiteSizedRun(config(), id, seed);
|
||||
maps.add(selection.spot.sourceDungeonId);
|
||||
expect(selection).toEqual(selectBiteSizedRun(config(), id, seed));
|
||||
expect(normalizeBiteSizedSelection(selection)).toEqual(selection);
|
||||
expect(biteSizedDefinitionForSelection(selection, seed)?.contentSourceDungeonId).toBe(selection.spot.sourceDungeonId);
|
||||
}
|
||||
expect(maps.size).toBe(12);
|
||||
}
|
||||
});
|
||||
it.each(["immediate", "after-mobs"] as const)("completes an edited %s encounter", (activation) => {
|
||||
const next = config();
|
||||
next.spots = next.spots.map((s) => s.id === parent.id ? { ...s, revision: 1, bossActivation: activation } : s);
|
||||
const selection = selectBiteSizedRun(next, parent.dungeonId, "completion");
|
||||
const definition = biteSizedDefinitionForSelection(selection, "completion")!;
|
||||
const encounter = definition.encounters![0];
|
||||
let state = createDungeonEncounterSnapshot(encounter);
|
||||
state = reduceDungeonEncounter(encounter, state, { kind: "tick", now: 1000, facts: {
|
||||
registeredEntityIds: selection.spot.mobs.map((m) => m.id), deadEntityIds: selection.spot.mobs.map((m) => m.id),
|
||||
} }).snapshot;
|
||||
expect(state.phase).toBe("boss");
|
||||
state = reduceDungeonEncounter(encounter, state, { kind: "tick", now: 2000, facts: {
|
||||
registeredEntityIds: [selection.boss.objectiveId], deadEntityIds: [selection.boss.objectiveId],
|
||||
} }).snapshot;
|
||||
expect(state.phase).toBe("complete");
|
||||
expect(state.flags["dungeon-complete"]).toBe(true);
|
||||
expect(definition.encounterSpawnGroups?.length ?? 0).toBe(activation === "after-mobs" ? 1 : 0);
|
||||
});
|
||||
it("activates a boss-only after-mobs layout immediately", () => {
|
||||
const next = config();
|
||||
next.spots = next.spots.map((s) => s.id === parent.id ? { ...s, revision: 1, mobs: [], bossActivation: "after-mobs" } : s);
|
||||
const definition = biteSizedDefinitionForSelection(selectBiteSizedRun(next, parent.dungeonId, "solo"), "solo")!;
|
||||
expect(definition.staticSpawns).toHaveLength(1);
|
||||
expect(definition.encounterSpawnGroups).toEqual([]);
|
||||
expect(definition.encounters![0].initialPhase).toBe("boss");
|
||||
});
|
||||
it("preserves the original Stockades map and creature deal for legacy randomized checkpoints", () => {
|
||||
const id = "randomized-bite-sized-stormwind-stockade", seed = "legacy-checkpoint";
|
||||
const legacy = randomizedBiteSizedDefinitionForSeed(seed, id);
|
||||
const selection = legacyBiteSizedRunSelection(id, seed)!;
|
||||
expect(selection.legacyRandomized).toBe(true);
|
||||
expect(selection.spot.sourceDungeonId).toBe("stormwind-stockade");
|
||||
expect(biteSizedDefinitionForSelection(selection, seed).staticSpawns).toEqual(legacy.staticSpawns);
|
||||
expect(biteSizedDefinitionForSelection(normalizeBiteSizedSelection(JSON.parse(JSON.stringify(selection))), seed).staticSpawns).toEqual(legacy.staticSpawns);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BITE_SIZED_DEFAULT_CONFIG, normalizeBiteSizedConfig } from "../../shared/bite-sized-layouts.mjs";
|
||||
const request = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../app/onlineAccountClient", () => ({ requestOnlineJson: request }));
|
||||
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
|
||||
|
||||
describe("GM spot drafts", () => {
|
||||
beforeEach(() => { request.mockReset(); const config = normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG);
|
||||
useBiteSizedLayoutsStore.setState({ config, draft: config, revision: 0, dirty: false, saving: false, error: null }); });
|
||||
it("preserves unsaved placements on a conflict and on refresh, then permits discard", async () => {
|
||||
const store = useBiteSizedLayoutsStore.getState();
|
||||
const draft = { ...store.draft.spots[0], name: "Unsaved name" };
|
||||
store.updateSpot(draft);
|
||||
request.mockRejectedValueOnce(new Error("Revision conflict"));
|
||||
expect(await store.publish({ kind: "account", ownerId: "gm", displayName: "GM", accessToken: "test-token", roles: ["gm"] })).toBe(false);
|
||||
expect(useBiteSizedLayoutsStore.getState()).toMatchObject({ dirty: true, saving: false, error: "Revision conflict" });
|
||||
expect(useBiteSizedLayoutsStore.getState().draft.spots[0]).toEqual(draft);
|
||||
request.mockResolvedValueOnce({ revision: 1, updatedAt: 1, config: store.config });
|
||||
await store.refresh();
|
||||
expect(useBiteSizedLayoutsStore.getState().draft.spots[0]).toEqual(draft);
|
||||
store.discard(); expect(useBiteSizedLayoutsStore.getState().dirty).toBe(false);
|
||||
expect(useBiteSizedLayoutsStore.getState().draft.spots[0].name).toBe(store.config.spots[0].name);
|
||||
});
|
||||
it("requires GM credentials and atomically replaces a successfully published draft", async () => {
|
||||
const store = useBiteSizedLayoutsStore.getState(); store.updateSpot({ ...store.draft.spots[0], name: "Draft" });
|
||||
expect(await store.publish(null)).toBe(false); expect(request).not.toHaveBeenCalled();
|
||||
const config = useBiteSizedLayoutsStore.getState().draft;
|
||||
request.mockResolvedValueOnce({ config, revision: 1, updatedAt: 1 });
|
||||
expect(await store.publish({ kind: "account", ownerId: "gm", displayName: "GM", accessToken: "test-token", roles: ["gm"] })).toBe(true);
|
||||
expect(useBiteSizedLayoutsStore.getState()).toMatchObject({ dirty: false, revision: 1, saving: false, config });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { create } from "zustand";
|
||||
import { requestOnlineJson } from "../app/onlineAccountClient";
|
||||
import type { PlayerSession } from "../app/types";
|
||||
import { BITE_SIZED_DEFAULT_CONFIG, normalizeBiteSizedConfig, normalizeBiteSizedSpot, type BiteSizedConfig, type BiteSizedSpot, type PublishedBiteSizedConfig } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
const CACHE_KEY = "healer-man.bite-sized-layouts.v1";
|
||||
function cached(): PublishedBiteSizedConfig {
|
||||
try {
|
||||
const payload = JSON.parse(localStorage.getItem(CACHE_KEY) ?? "null");
|
||||
if (!Number.isSafeInteger(payload?.revision) || payload.revision < 0) throw new Error("Missing cache");
|
||||
return { revision: payload.revision, updatedAt: payload.updatedAt ?? null, config: normalizeBiteSizedConfig(payload.config) };
|
||||
} catch { return { revision: 0, updatedAt: null, config: normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG) }; }
|
||||
}
|
||||
function cache(payload: PublishedBiteSizedConfig) {
|
||||
try { localStorage.setItem(CACHE_KEY, JSON.stringify(payload)); } catch { /* Cached defaults remain usable without storage. */ }
|
||||
}
|
||||
interface LayoutState extends PublishedBiteSizedConfig {
|
||||
readonly draft: BiteSizedConfig; readonly dirty: boolean; readonly saving: boolean; readonly loading: boolean; readonly error: string | null;
|
||||
readonly editingSpotId: string | null; readonly editingPointId: string; readonly editorMode: "edit" | "test" | null;
|
||||
readonly panelFocused: boolean; readonly historicalSpots: readonly BiteSizedSpot[];
|
||||
refresh: () => Promise<void>; loadHistory: () => Promise<void>; updateSpot: (spot: BiteSizedSpot) => void;
|
||||
replaceDraft: (draft: BiteSizedConfig) => void; discard: () => void; publish: (session: PlayerSession | null) => Promise<boolean>;
|
||||
}
|
||||
const initial = cached();
|
||||
export const useBiteSizedLayoutsStore = create<LayoutState>((set, get) => ({
|
||||
...initial, draft: initial.config, dirty: false, saving: false, loading: false, error: null,
|
||||
editingSpotId: null, editingPointId: "party", editorMode: null, panelFocused: false, historicalSpots: [],
|
||||
refresh: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const payload = await requestOnlineJson<PublishedBiteSizedConfig>("/api/bite-sized-layouts");
|
||||
const normalized = { ...payload, config: normalizeBiteSizedConfig(payload.config) };
|
||||
cache(normalized);
|
||||
set((state) => ({ ...normalized, loading: false, error: null, ...(state.dirty ? {} : { draft: normalized.config }) }));
|
||||
} catch (error) { set({ loading: false, error: error instanceof Error ? error.message : "Could not load layouts." }); }
|
||||
},
|
||||
loadHistory: async () => {
|
||||
try { const entries = await requestOnlineJson<readonly BiteSizedSpot[]>("/api/bite-sized-layouts?history=1"); set({ historicalSpots: entries.map(normalizeBiteSizedSpot) }); }
|
||||
catch { set({ historicalSpots: get().config.spots }); }
|
||||
},
|
||||
replaceDraft: (draft) => set({ draft, dirty: true, error: null }),
|
||||
updateSpot: (spot) => set((state) => ({ draft: { schemaVersion: 1, spots: state.draft.spots.map((s) => s.id === spot.id ? spot : s) }, dirty: true, error: null })),
|
||||
discard: () => 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 config = normalizeBiteSizedConfig(get().draft);
|
||||
const payload = await requestOnlineJson<PublishedBiteSizedConfig>("/api/gm/bite-sized-layouts", {
|
||||
method: "PUT", body: JSON.stringify({ expectedRevision: get().revision, config }),
|
||||
}, session.accessToken);
|
||||
const normalized = { ...payload, config: normalizeBiteSizedConfig(payload.config) };
|
||||
cache(normalized);
|
||||
set({ ...normalized, draft: normalized.config, dirty: false, saving: false });
|
||||
return true;
|
||||
} catch (error) { set({ saving: false, error: error instanceof Error ? error.message : "Could not publish layouts." }); return false; }
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { biteSizedRouteSegmentClear } from "./biteSizedSpotNavigation";
|
||||
|
||||
describe("Bite-Sized branch blocker clearance", () => {
|
||||
const wall = { position: [0, 2, 0] as const, halfExtents: [4, 3, 0.5] as const, yaw: 0 };
|
||||
it("blocks crossing and occupied points while allowing routes around the wall", () => {
|
||||
expect(biteSizedRouteSegmentClear([wall], [0, 0, -4], [0, 0, 4])).toBe(false);
|
||||
expect(biteSizedRouteSegmentClear([wall], [0, 0, 0], [0, 0, 0])).toBe(false);
|
||||
expect(biteSizedRouteSegmentClear([wall], [5, 0, -4], [5, 0, 4])).toBe(true);
|
||||
expect(biteSizedRouteSegmentClear([wall], [0, 8, -4], [0, 8, 4])).toBe(true);
|
||||
expect(biteSizedRouteSegmentClear([], [0, 0, -4], [0, 0, 4])).toBe(true);
|
||||
});
|
||||
it("checks rotated boxes in their local frame", () => {
|
||||
const rotated = { ...wall, yaw: Math.PI / 2 };
|
||||
expect(biteSizedRouteSegmentClear([rotated], [-4, 0, 0], [4, 0, 0])).toBe(false);
|
||||
expect(biteSizedRouteSegmentClear([rotated], [-4, 0, 5], [4, 0, 5])).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { findNearestPartyNavigationSurface, findPartyNavigationPath, type PartyNavigationGraph } from "./partyNavigation";
|
||||
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||
import type { BiteSizedLayoutBlocker, BiteSizedSpot, LayoutVector } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
export function biteSizedRouteSegmentClear(blockers: readonly BiteSizedLayoutBlocker[], from: LayoutVector, to: LayoutVector): boolean {
|
||||
return blockers.every((blocker) => {
|
||||
const sin = Math.sin(blocker.yaw), cos = Math.cos(blocker.yaw);
|
||||
const local = (point: LayoutVector) => {
|
||||
const x = point[0] - blocker.position[0], z = point[2] - blocker.position[2];
|
||||
return [x * cos - z * sin, point[1] - blocker.position[1], x * sin + z * cos];
|
||||
};
|
||||
const start = local(from), end = local(to);
|
||||
let enter = 0, leave = 1;
|
||||
for (let axis = 0; axis < 3; axis++) {
|
||||
const extent = blocker.halfExtents[axis] + (axis === 1 ? 1 : 0.34);
|
||||
const delta = end[axis] - start[axis];
|
||||
if (Math.abs(delta) < 1e-9) { if (Math.abs(start[axis]) > extent) return true; continue; }
|
||||
const first = (-extent - start[axis]) / delta, second = (extent - start[axis]) / delta;
|
||||
enter = Math.max(enter, Math.min(first, second));
|
||||
leave = Math.min(leave, Math.max(first, second));
|
||||
if (enter > leave) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateBiteSizedSpotNavigation(spot: BiteSizedSpot, graph: PartyNavigationGraph, regions?: readonly number[], blockers: readonly BiteSizedLayoutBlocker[] = []): void {
|
||||
const start = findNearestPartyNavigationSurface(graph, spot.partySpawn.footPosition, 0.75);
|
||||
if (!start) throw new Error(`${spot.name}: Party anchor is off the walkable floor. Move its spawn point.`);
|
||||
const points: { label: string; position: readonly [number, number, number]; snapDistance?: number }[] = [
|
||||
...derivePartySpawnSlots(spot.partySpawn).map((s, i) => ({ label: `Party member ${i + 1}`, position: s.footPosition, snapDistance: i === 0 ? 0.75 : 6 })),
|
||||
...spot.mobs.map((m, i) => ({ label: `Mob ${i + 1}`, position: m.position })),
|
||||
{ label: "Boss", position: spot.bossSpawn.footPosition },
|
||||
];
|
||||
for (const point of points) {
|
||||
// Companions project their formation seats onto the floor within six metres,
|
||||
// exactly as PartyPopulation does. Authored mob/boss points never auto-snap.
|
||||
const match = findNearestPartyNavigationSurface(graph, point.position, point.snapDistance ?? 0.75);
|
||||
if (!match) throw new Error(`${spot.name}: ${point.label} is off the walkable floor. Move its spawn point.`);
|
||||
if (!biteSizedRouteSegmentClear(blockers, match.position, match.position)) throw new Error(`${spot.name}: ${point.label} is inside a route blocker.`);
|
||||
const reachable = regions ? regions[start.triangleId] === regions[match.triangleId]
|
||||
: findPartyNavigationPath(graph, start.position, match.position, { maxSnapDistance: 0.75,
|
||||
routeSegmentAllowed: (from, to) => biteSizedRouteSegmentClear(blockers, from, to) });
|
||||
if (!reachable) throw new Error(`${spot.name}: ${point.label} cannot be reached from the party start.`);
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ describe("bite-sized Stormwind Stockades", () => {
|
||||
.toBe(first.staticSpawns.find((spawn) => spawn.id === first.bosses[0].id)?.entityId);
|
||||
});
|
||||
|
||||
it("uses ordinary boss durability for every level-17 roll", () => {
|
||||
it("retains the shorter Bite-Sized durability curve for every level-17 roll", () => {
|
||||
const variants = new Map<string, ReturnType<typeof createBiteSizedStockadesDefinition>>();
|
||||
for (let index = 0; index < 1_000 && variants.size < BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length; index += 1) {
|
||||
const definition = createBiteSizedStockadesDefinition(source, `level-17-run-${index}`);
|
||||
@@ -95,9 +95,17 @@ describe("bite-sized Stormwind Stockades", () => {
|
||||
const boss = definition.bosses[0];
|
||||
const spawn = definition.staticSpawns.find((entry) => entry.id === boss.id)!;
|
||||
const entity = definition.entities[spawn.entityId];
|
||||
expect(dungeonEnemyScaling(
|
||||
const health = dungeonEnemyScaling(
|
||||
definition, entity, boss.id, 17, "normal", 1, characterSource,
|
||||
).maxHealth, `${characterSource}:${boss.name}`).toBeCloseTo(ordinaryHealth);
|
||||
).maxHealth;
|
||||
expect(health, `${characterSource}:${boss.name}`).toBeGreaterThan(0);
|
||||
expect(health, `${characterSource}:${boss.name}`).toBeLessThan(ordinaryHealth);
|
||||
// The established leveling curve interpolates a 0.685–0.835 WoW
|
||||
// floor/ceiling at level 17; editor work must not restore campaign HP.
|
||||
if (characterSource === "wow") {
|
||||
expect(health).toBeGreaterThanOrEqual(ordinaryHealth * 0.685 - 0.001);
|
||||
expect(health).toBeLessThanOrEqual(ordinaryHealth * 0.835 + 0.001);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ const runewakerMaps = runewakerCatalog.dungeons as unknown as Readonly<Record<st
|
||||
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[dungeonContentSourceId(dungeon.id)];
|
||||
const source = runewakerMaps[dungeon.contentSourceDungeonId ?? dungeonContentSourceId(dungeon.id)];
|
||||
if (source?.mapId !== dungeon.mapId
|
||||
|| source.coordinateTransform !== dungeon.provenance.coordinateTransform
|
||||
|| source.visualChecksum !== dungeon.assets.visual[0]?.checksum
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface ResolvedDungeonAssets {
|
||||
}
|
||||
|
||||
export function resolveDungeonAssets(definition: DungeonDefinition): ResolvedDungeonAssets {
|
||||
const sourceId = dungeonContentSourceId(definition.id);
|
||||
const sourceId = definition.contentSourceDungeonId ?? dungeonContentSourceId(definition.id);
|
||||
return {
|
||||
visual: definition.assets.visual.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
|
||||
collision: definition.assets.collision.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import { materializeBiteSizedLayout } from "./biteSizedLayoutRuntime";
|
||||
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
|
||||
import { BITE_SIZED_DEFAULT_CONFIG, BITE_SIZED_LAYOUT_CATALOG, isLayoutBiteSizedDungeon, normalizeBiteSizedSelection, selectBiteSizedRun, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { FORSAKEN_ABBEY_DEFINITION } from "./dungeons/forsakenAbbey";
|
||||
import { WAILING_CAVERNS_DEFINITION } from "./dungeons/wailingCaverns";
|
||||
import availability from "./generated/dungeonAvailability.json";
|
||||
@@ -353,7 +356,7 @@ export function clearBossTestingDungeons(): void {
|
||||
}
|
||||
|
||||
/** Materializes seeded variants after a run id exists; ordinary dungeons keep their catalog definition. */
|
||||
export function prepareDungeonSessionDefinition(id: string, seed: string): DungeonDefinition | null {
|
||||
function legacyBiteSizedDefinition(id: string, seed: string): DungeonDefinition | null {
|
||||
let definition: DungeonDefinition;
|
||||
if (id === BITE_SIZED_STOCKADES_DUNGEON_ID) definition = createBiteSizedStockadesDefinition(stockadesDefinition, seed);
|
||||
else if (isRandomizedBiteSizedDungeonId(id)) definition = randomizedBiteSizedDefinitionForSeed(seed, id);
|
||||
@@ -369,7 +372,40 @@ export function prepareDungeonSessionDefinition(id: string, seed: string): Dunge
|
||||
else if (id === BITE_SIZED_DEADMINES_DUNGEON_ID) definition = createBiteSizedDeadminesDefinition(deadminesDefinition, seed);
|
||||
else if (id === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID) definition = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition, seed);
|
||||
else return dungeonDefinitionById(id);
|
||||
sessionDungeonDefinitions.set(id, definition);
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function biteSizedDefinitionForSelection(selection: BiteSizedRunSelection, seed: string, difficultyId?: string): DungeonDefinition {
|
||||
const validated = normalizeBiteSizedSelection(selection);
|
||||
if (validated.legacyRandomized) return { ...legacyBiteSizedDefinition(validated.dungeonId, seed)!, contentSourceDungeonId: validated.spot.sourceDungeonId, biteSizedSelection: validated };
|
||||
const source = adaptedSourceRegistry[validated.spot.sourceDungeonId];
|
||||
const baseline = legacyBiteSizedDefinition(validated.spot.dungeonId, seed);
|
||||
if (!source || !baseline) throw new Error("The selected Bite-Sized parent is unavailable.");
|
||||
return materializeBiteSizedLayout(source, baseline, validated, adaptedSourceRegistry, difficultyId);
|
||||
}
|
||||
|
||||
/** Checkpoints made before the editor retain the original map and creature deal. */
|
||||
export function legacyBiteSizedRunSelection(id: string, seed: string): BiteSizedRunSelection | undefined {
|
||||
if (!isLayoutBiteSizedDungeon(id)) return undefined;
|
||||
const randomized = id.startsWith("randomized-");
|
||||
const selection = selectBiteSizedRun(BITE_SIZED_DEFAULT_CONFIG, id, seed, {
|
||||
eligibleDungeonIds: [randomized ? "bite-sized-stormwind-stockade" : id],
|
||||
});
|
||||
if (!randomized) return selection;
|
||||
const definition = legacyBiteSizedDefinition(id, seed)!;
|
||||
const pool = BITE_SIZED_LAYOUT_CATALOG.pools[id];
|
||||
return normalizeBiteSizedSelection({ ...selection, legacyRandomized: true, mobs: selection.mobs.map((m) => {
|
||||
const spawn = definition.staticSpawns.find((s) => s.id === m.placementId)!;
|
||||
const ref = pool.find((c) => c.objectiveId === spawn.entityId)!;
|
||||
return { placementId: m.placementId, sourceDungeonId: ref.sourceDungeonId, entityId: ref.entityId };
|
||||
}) });
|
||||
}
|
||||
|
||||
export function prepareDungeonSessionDefinition(id: string, seed: string, selection?: BiteSizedRunSelection, difficultyId?: string): DungeonDefinition | null {
|
||||
const definition = isLayoutBiteSizedDungeon(id)
|
||||
? biteSizedDefinitionForSelection(selection ?? selectBiteSizedRun(useBiteSizedLayoutsStore.getState().config, id, seed), seed, difficultyId)
|
||||
: dungeonDefinitionById(id);
|
||||
if (definition) sessionDungeonDefinitions.set(id, definition);
|
||||
return definition;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export function completedDungeonRun(progress: DungeonRunProgress): DungeonRunRec
|
||||
const definition = dungeonDefinitionById(progress.dungeonId);
|
||||
if (!definition || !progress.timed || progress.finishedAt === null || progress.durationMs === null) return null;
|
||||
return { id: progress.id, dungeonId: progress.dungeonId, difficultyId: progress.difficultyId,
|
||||
...(progress.biteSizedSelection ? { biteSizedSelection: progress.biteSizedSelection } : {}),
|
||||
bossDifficulty: progress.bossDifficulty,
|
||||
finishedAt: progress.finishedAt, durationMs: progress.durationMs, killedBossIds: progress.killedBossIds,
|
||||
party: progress.party, fullClear: dungeonBossesForDifficulty(definition, progress.difficultyId)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { validBossDifficulty } from "./dungeonDifficulty";
|
||||
import { normalizeBiteSizedSelection, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
export interface DungeonRunMember {
|
||||
readonly name: string;
|
||||
@@ -10,6 +11,7 @@ export interface DungeonRunMember {
|
||||
}
|
||||
|
||||
export interface DungeonRunProgress {
|
||||
readonly biteSizedSelection?: BiteSizedRunSelection;
|
||||
readonly id: string;
|
||||
readonly dungeonId: string;
|
||||
readonly difficultyId: string;
|
||||
@@ -23,6 +25,7 @@ export interface DungeonRunProgress {
|
||||
}
|
||||
|
||||
export interface DungeonRunRecord {
|
||||
readonly biteSizedSelection?: BiteSizedRunSelection;
|
||||
readonly id: string;
|
||||
readonly dungeonId: string;
|
||||
readonly difficultyId: string;
|
||||
@@ -74,7 +77,12 @@ export function normalizeDungeonRunProgress(value: unknown): DungeonRunProgress
|
||||
|| (input.durationMs !== null && (input.finishedAt === null || input.durationMs < 1))
|
||||
|| (input.finishedAt !== null && (input.durationMs === null || input.party.length === 0
|
||||
|| input.durationMs !== Math.max(1, Math.round(input.finishedAt - input.startedAt!))))) return null;
|
||||
let biteSizedSelection: BiteSizedRunSelection | undefined;
|
||||
try { if (input.biteSizedSelection !== undefined) biteSizedSelection = normalizeBiteSizedSelection(input.biteSizedSelection); }
|
||||
catch { return null; }
|
||||
if (biteSizedSelection && biteSizedSelection.dungeonId !== input.dungeonId) return null;
|
||||
return { id: input.id, dungeonId: input.dungeonId, difficultyId: input.difficultyId,
|
||||
...(biteSizedSelection ? { biteSizedSelection } : {}),
|
||||
...(input.bossDifficulty !== undefined ? { bossDifficulty: input.bossDifficulty } : {}),
|
||||
startedAt: input.startedAt, finishedAt: input.finishedAt, durationMs: input.durationMs,
|
||||
timed: input.timed, killedBossIds: [...new Set(input.killedBossIds)],
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { preparedBiteSizedRunSelection } from "../app/biteSizedLayoutContent";
|
||||
import { releaseDungeonGltfCache } from "./dungeonAssetCache";
|
||||
import { clearDungeonRuntimeCaches } from "./dungeonRuntimeReset";
|
||||
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
dungeonCanEnter,
|
||||
dungeonDefinitionById,
|
||||
prepareDungeonSessionDefinition,
|
||||
legacyBiteSizedRunSelection,
|
||||
type DungeonId,
|
||||
} from "./dungeonRegistry";
|
||||
import type { WorldSpawn } from "./dungeonTypes";
|
||||
@@ -32,6 +35,7 @@ import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
import { dungeonRouteDefinition } from "./dungeonRoutes";
|
||||
|
||||
export interface DungeonSessionOptions {
|
||||
readonly biteSizedSelection?: BiteSizedRunSelection;
|
||||
readonly bossDifficulty?: number;
|
||||
readonly gameMode?: GameMode;
|
||||
readonly spawn?: WorldSpawn;
|
||||
@@ -63,8 +67,11 @@ export function activateDungeonSession(
|
||||
const testingSource = bossTestingSourceDungeonId(dungeonId);
|
||||
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
|
||||
const dungeonSimulation = Boolean(options.dungeonSimulation && options.gmTestMode && dungeonRouteDefinition(dungeonId));
|
||||
const suppliedSelection = options.biteSizedSelection ?? options.runProgress?.biteSizedSelection
|
||||
?? (options.preserveCharacter ? useDungeonRunStore.getState().progress?.biteSizedSelection : undefined)
|
||||
?? (options.runId ? preparedBiteSizedRunSelection(dungeonId, options.runId) : undefined);
|
||||
if (!definition || (!testing && !dungeonSimulation && !dungeonCanEnter(dungeonId))
|
||||
|| !dungeonContentIsUsable(testing ? testingSource! : dungeonId)) {
|
||||
|| !dungeonContentIsUsable(testing ? testingSource! : suppliedSelection?.spot.sourceDungeonId ?? dungeonId)) {
|
||||
return false;
|
||||
}
|
||||
if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId))
|
||||
@@ -78,7 +85,15 @@ export function activateDungeonSession(
|
||||
options.runProgress, options.legacyResume, bossDifficulty, options.runId);
|
||||
}
|
||||
const runSeed = useDungeonRunStore.getState().progress?.id ?? options.runId ?? "catalog-template";
|
||||
definition = prepareDungeonSessionDefinition(dungeonId, runSeed) ?? definition;
|
||||
const legacySelection = options.runProgress && !options.runProgress.biteSizedSelection
|
||||
? legacyBiteSizedRunSelection(dungeonId, runSeed) : undefined;
|
||||
definition = prepareDungeonSessionDefinition(dungeonId, runSeed,
|
||||
suppliedSelection ?? legacySelection ?? preparedBiteSizedRunSelection(dungeonId, runSeed),
|
||||
difficultyId ?? definition.defaultDifficultyId) ?? definition;
|
||||
const progress = useDungeonRunStore.getState().progress;
|
||||
if (progress && definition.biteSizedSelection) useDungeonRunStore.setState({ progress: {
|
||||
...progress, biteSizedSelection: definition.biteSizedSelection, ...(options.gmTestMode ? { timed: false } : {}),
|
||||
} });
|
||||
activateDungeonEncounterDefinitions(
|
||||
dungeonId,
|
||||
definition.encounters ?? [],
|
||||
@@ -159,5 +174,6 @@ export function restartActiveDungeonSession(): boolean {
|
||||
preserveCharacter: true,
|
||||
bossDifficulty: game.activeBossDifficulty,
|
||||
encounterSnapshots,
|
||||
biteSizedSelection: useDungeonRunStore.getState().progress?.biteSizedSelection,
|
||||
});
|
||||
}
|
||||
@@ -242,6 +242,8 @@ export interface DungeonValidationStatus {
|
||||
}
|
||||
|
||||
export interface DungeonDefinition {
|
||||
readonly contentSourceDungeonId?: string;
|
||||
readonly biteSizedSelection?: import("../../shared/bite-sized-layouts.mjs").BiteSizedRunSelection;
|
||||
readonly schemaVersion: typeof DUNGEON_DEFINITION_SCHEMA_VERSION;
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface PartyNavigationGraph {
|
||||
}
|
||||
|
||||
export interface PartyNavigationBuildOptions {
|
||||
/** Validation may retain floor triangles while gameplay routes off-mesh links through nodes. */
|
||||
readonly retainSurfaceTriangles?: boolean;
|
||||
readonly weldTolerance?: number;
|
||||
/** Defensive runtime ceiling; checked before graph allocations begin. */
|
||||
readonly maxSourceTriangles?: number;
|
||||
@@ -167,7 +169,7 @@ function connectedComponents(nodes: readonly MutableWeldedNode[]): number[][] {
|
||||
return components;
|
||||
}
|
||||
|
||||
function buildPartyNavigationTriangleNodes(
|
||||
export function buildPartyNavigationTriangleNodes(
|
||||
triangles: readonly PartyNavigationTriangle[],
|
||||
positions: readonly PartyNavigationPosition[],
|
||||
): readonly PartyNavigationTriangleNode[] {
|
||||
@@ -432,7 +434,7 @@ export function buildPartyNavigationGraph(
|
||||
|
||||
return Object.freeze({
|
||||
nodes: Object.freeze(nodes),
|
||||
...(usesOffMeshLinks ? {} : {
|
||||
...(usesOffMeshLinks && !options.retainSurfaceTriangles ? {} : {
|
||||
triangles: Object.freeze(triangles),
|
||||
triangleNodes,
|
||||
}),
|
||||
|
||||
@@ -10,6 +10,10 @@ import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPol
|
||||
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
|
||||
const graphCache = new Map<string, PartyNavigationGraph>();
|
||||
|
||||
export function cachedPartyNavigationGraph(url: string, links: readonly DungeonNavigationLink[], ramps: readonly DungeonTraversalRamp[] = [], retainSurfaceTriangles = false): PartyNavigationGraph | null {
|
||||
return graphCache.get(graphCacheKey(url, links, ramps) + (retainSurfaceTriangles ? ":floor-validation" : "")) ?? null;
|
||||
}
|
||||
|
||||
function graphCacheKey(
|
||||
url: string,
|
||||
offMeshLinks: readonly DungeonNavigationLink[],
|
||||
@@ -60,8 +64,10 @@ export function preparePartyNavigationGraph(
|
||||
scene: Group,
|
||||
offMeshLinks: readonly DungeonNavigationLink[],
|
||||
ramps: readonly DungeonTraversalRamp[] = [],
|
||||
maxSourceTriangles = MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
||||
retainSurfaceTriangles = false,
|
||||
): PartyNavigationGraph {
|
||||
const cacheKey = graphCacheKey(url, offMeshLinks, ramps);
|
||||
const cacheKey = graphCacheKey(url, offMeshLinks, ramps) + (retainSurfaceTriangles ? ":floor-validation" : "");
|
||||
const cached = graphCache.get(cacheKey);
|
||||
if (cached) {
|
||||
graphCache.delete(cacheKey);
|
||||
@@ -79,7 +85,8 @@ export function preparePartyNavigationGraph(
|
||||
}
|
||||
const bridgedLinks = new Set(ramps.flatMap((ramp) => dungeonRampNavigationBridges(ramp).map((bridge) => bridge.linkId)));
|
||||
graph = buildPartyNavigationGraph(geometry, {
|
||||
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
||||
maxSourceTriangles,
|
||||
retainSurfaceTriangles,
|
||||
offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -112,7 +112,10 @@ describe("Randomized Bite-Sized Stockades", () => {
|
||||
expect(observedTrashLineups.size).toBeGreaterThan(1_900);
|
||||
|
||||
expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run" })).toBe(true);
|
||||
expect(dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)?.staticSpawns).toEqual(first.staticSpawns);
|
||||
const selected = dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)!;
|
||||
expect(selected.biteSizedSelection?.dungeonId).toBe(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
|
||||
expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run", biteSizedSelection: selected.biteSizedSelection })).toBe(true);
|
||||
expect(dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)?.staticSpawns).toEqual(selected.staticSpawns);
|
||||
});
|
||||
|
||||
it("uses the selected cross-dungeon boss for combat and completion", () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface WowDungeonMap {
|
||||
const maps = catalog.dungeons as unknown as Readonly<Record<string, WowDungeonMap>>;
|
||||
|
||||
export function wowDungeonMapFor(dungeon: DungeonDefinition): WowDungeonMap | null {
|
||||
const map = maps[dungeon.id];
|
||||
const map = maps[dungeon.contentSourceDungeonId ?? dungeon.id];
|
||||
// A replacement environment must be reimported, never silently misaligned.
|
||||
return map?.mapId === dungeon.mapId
|
||||
&& map.coordinateTransform === dungeon.provenance.coordinateTransform
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMemo } from "react";
|
||||
import { Html } from "@react-three/drei";
|
||||
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
|
||||
import { derivePartySpawnSlots } from "../game/partySpawnSlots";
|
||||
import { biteSizedDefinitionForSelection } from "../game/dungeonRegistry";
|
||||
import { selectBiteSizedRun, type LayoutVector } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { MobPopulation } from "./MobPopulation";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { resolveDungeonAssets } from "../game/dungeonAssets";
|
||||
import { cachedPartyNavigationGraph } from "../game/partyNavigationAssetCache";
|
||||
import { findNearestPartyNavigationSurface } from "../game/partyNavigation";
|
||||
|
||||
function Marker({ id, position, yaw, color, label, selected }: {
|
||||
readonly id: string; readonly position: LayoutVector; readonly yaw: number; readonly color: string; readonly label: string; readonly selected: boolean;
|
||||
}) {
|
||||
const choose = () => useBiteSizedLayoutsStore.setState({ editingPointId: id, panelFocused: false });
|
||||
return <group position={position} rotation={[0, yaw, 0]}>
|
||||
<mesh position={[0, .08, 0]} rotation={[-Math.PI / 2, 0, 0]} onClick={(e) => { e.stopPropagation(); choose(); }}>
|
||||
<ringGeometry args={[selected ? .7 : .55, selected ? 1 : .8, 24]} /><meshBasicMaterial color={color} depthTest={false} transparent opacity={.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, .65, 0]} onClick={(e) => { e.stopPropagation(); choose(); }}><coneGeometry args={[.2, 1.1, 8]} /><meshBasicMaterial color={color} transparent opacity={.6} depthTest={false} /></mesh>
|
||||
<mesh position={[0, .1, 1.15]}><boxGeometry args={[.13, .13, .9]} /><meshBasicMaterial color={color} depthTest={false} /></mesh>
|
||||
{selected && <Html position={[0, 1.6, 0]} center style={{ pointerEvents: "none", color, fontSize: 12, whiteSpace: "nowrap", background: "#17171ddd", padding: "4px 8px", borderRadius: 5 }}>{label}</Html>}
|
||||
</group>;
|
||||
}
|
||||
export function BiteSizedEditorMarkers() {
|
||||
const layouts = useBiteSizedLayoutsStore();
|
||||
const difficultyId = useGameStore((s) => s.activeDifficultyId);
|
||||
const spot = layouts.draft.spots.find((s) => s.id === layouts.editingSpotId);
|
||||
const preview = useMemo(() => {
|
||||
if (!spot || layouts.editorMode !== "edit") return null;
|
||||
try { return biteSizedDefinitionForSelection(selectBiteSizedRun(layouts.draft, spot.dungeonId, "gm-preview", { spotId: spot.id }), "gm-preview", difficultyId); }
|
||||
catch { return null; }
|
||||
}, [layouts.draft, layouts.editorMode, spot, difficultyId]);
|
||||
if (!spot || layouts.editorMode !== "edit") return null;
|
||||
const navigationUrl = preview && resolveDungeonAssets(preview).navigation;
|
||||
const graph = navigationUrl && preview ? cachedPartyNavigationGraph(navigationUrl, preview.navigationLinks, preview.traversalRamps, true)
|
||||
?? cachedPartyNavigationGraph(navigationUrl, preview.navigationLinks, preview.traversalRamps) : null;
|
||||
return <group name="bite-sized-editor-markers">
|
||||
{preview && <MobPopulation active={false} showLabels={false} entities={preview.entities} roamingPacks={[]}
|
||||
staticSpawns={[...preview.staticSpawns, ...(preview.encounterSpawnGroups ?? []).flatMap((g) => g.spawns)]} />}
|
||||
{derivePartySpawnSlots(spot.partySpawn).map((p, i) => <Marker key={`party-${i}`} id="party" position={i > 0 && graph ? findNearestPartyNavigationSurface(graph, p.footPosition, 6)?.position ?? p.footPosition : p.footPosition} yaw={p.yaw} color="#55e8ff" label={`Party start · seat ${i + 1}`} selected={layouts.editingPointId === "party" && i === 0} />)}
|
||||
<Marker id="boss" position={spot.bossSpawn.footPosition} yaw={spot.bossSpawn.yaw} color="#ff5570" label="Random boss spawn" selected={layouts.editingPointId === "boss"} />
|
||||
{spot.mobs.map((m, i) => <Marker key={m.id} id={m.id} position={m.position} yaw={m.yaw} color="#ffd255" label={`Mob point ${i + 1}`} selected={layouts.editingPointId === m.id} />)}
|
||||
</group>;
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { Canvas, useThree } from "@react-three/fiber";
|
||||
import { BiteSizedEditorMarkers } from "./BiteSizedEditorMarkers";
|
||||
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
|
||||
import { Physics } from "@react-three/rapier";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { spawnMatchesDifficulty } from "../game/mobPopulation";
|
||||
@@ -173,6 +175,7 @@ export function GameScene() {
|
||||
];
|
||||
const roamingPacks = sessionPopulation.roamingPacks;
|
||||
const paused = useGameStore((state) => state.paused);
|
||||
const editingBiteSized = useBiteSizedLayoutsStore((state) => state.editorMode === "edit");
|
||||
const dungeonIntroActive = useGameStore((state) => state.dungeonIntroActive);
|
||||
const raceStartBlocked = useGameStore((state) => state.raceStartBlocked);
|
||||
const mapOpen = useGameStore((state) => state.mapOpen);
|
||||
@@ -407,7 +410,7 @@ export function GameScene() {
|
||||
{simulationSessionMode === "play" ? <PlayerRig /> : <DungeonSpectatorCamera />}
|
||||
{simulationSessionMode === "play" && <OnlinePlayerPopulation active={!simulationBlocked} />}
|
||||
<PartyPopulation
|
||||
active={!simulationBlocked}
|
||||
active={!simulationBlocked && !editingBiteSized}
|
||||
simulate={simulationSessionMode !== "spectate-replay" && (!sharedOnline || onlineAuthority)}
|
||||
/>
|
||||
<DungeonSimulationSceneBridge />
|
||||
@@ -416,13 +419,14 @@ export function GameScene() {
|
||||
{gameMode === "dungeon" && (
|
||||
<DungeonWorldObjects definitions={dungeon.serverGameObjects} />
|
||||
)}
|
||||
<MobPopulation
|
||||
{!editingBiteSized && <MobPopulation
|
||||
active={!simulationBlocked}
|
||||
entities={stagePopulation.entities}
|
||||
roamingPacks={roamingPacks}
|
||||
staticSpawns={staticSpawns}
|
||||
allowedRuntimeIds={allowedRuntimeIds}
|
||||
/>
|
||||
/>}
|
||||
<BiteSizedEditorMarkers />
|
||||
<CombatSummonPopulation active={!simulationBlocked} />
|
||||
<WailingNaralexEvent />
|
||||
<ShelvedGameSceneFeatures />
|
||||
|
||||
@@ -21,6 +21,10 @@ export const COMBAT_EFFECT_SYSTEM_LIMIT: Readonly<Record<CombatVfxQuality, numbe
|
||||
export function combatEffectVisualTuning(
|
||||
event: Pick<CombatPresentationEvent, "abilityId" | "phase">,
|
||||
): { readonly scale: number; readonly opacity: number } {
|
||||
if ((event.abilityId === "priest-flash-heal" || event.abilityId === "wow335-priest-flash-heal")
|
||||
&& event.phase === "impact") {
|
||||
return { scale: 0.6, opacity: 0.4 };
|
||||
}
|
||||
return event.abilityId === "wow335-priest-dispel-magic" && event.phase === "impact"
|
||||
? { scale: 0.45, opacity: 0.4 }
|
||||
: { scale: 1, opacity: 1 };
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { isLayoutBiteSizedDungeon } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
|
||||
|
||||
export function useBiteSizedBoardSelection(dungeonId: string) {
|
||||
const config = useBiteSizedLayoutsStore((s) => s.config);
|
||||
const history = useBiteSizedLayoutsStore((s) => s.historicalSpots);
|
||||
const [spotId, setSpotId] = useState("");
|
||||
const [revision, setRevision] = useState<number | null>(null);
|
||||
const enabled = isLayoutBiteSizedDungeon(dungeonId);
|
||||
useEffect(() => { if (enabled) void useBiteSizedLayoutsStore.getState().loadHistory(); }, [enabled, dungeonId]);
|
||||
const spots = [...config.spots, ...history].filter((s) => dungeonId.startsWith("randomized-") || s.dungeonId === dungeonId)
|
||||
.filter((s, i, all) => all.findIndex((other) => other.id === s.id) === i);
|
||||
const spot = spots.find((s) => s.id === spotId) ?? spots[0];
|
||||
const revisions = [...new Set([...history, ...config.spots].filter((s) => s.id === spot?.id).map((s) => s.revision))].sort((a, b) => b - a);
|
||||
const spotRevision = revision !== null && revisions.includes(revision) ? revision : spot?.revision;
|
||||
return { enabled, spots, spotId: enabled ? spot?.id : undefined, spotRevision: enabled ? spotRevision : undefined,
|
||||
revisions, selectSpot: (id: string) => { setSpotId(id); setRevision(null); }, selectRevision: setRevision };
|
||||
}
|
||||
|
||||
export function BiteSizedBoardFilters({ selection }: { readonly selection: ReturnType<typeof useBiteSizedBoardSelection> }) {
|
||||
if (!selection.enabled) return null;
|
||||
return <div className="bite-sized-board-filters" style={{ display: "flex", gap: 12, padding: "12px 0", flexWrap: "wrap" }}>
|
||||
<label>Spot<select aria-label="Leaderboard spot" value={selection.spotId} onChange={(e) => selection.selectSpot(e.target.value)}>
|
||||
{selection.spots.map((s) => <option key={s.id} value={s.id}>{s.name} · {s.sourceDungeonId}</option>)}
|
||||
</select></label>
|
||||
<label>Gameplay revision<select aria-label="Leaderboard revision" value={selection.spotRevision} onChange={(e) => selection.selectRevision(Number(e.target.value))}>
|
||||
{selection.revisions.map((r) => <option key={r} value={r}>Revision {r}</option>)}
|
||||
</select></label>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { BITE_SIZED_LAYOUT_CATALOG, biteSizedGameplayKey, normalizeBiteSizedConfig, selectBiteSizedRun, type BiteSizedSpot, type LayoutVector } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { sessionCanUseGmTools } from "../app/releaseFeatures";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { enterBiteSizedEditorSpot, leaveBiteSizedEditor } from "../app/biteSizedEditorRuntime";
|
||||
import { prepareBiteSizedLayoutContent } from "../app/biteSizedLayoutContent";
|
||||
import { validateChangedBiteSizedSpots } from "../game/biteSizedLayoutValidation";
|
||||
import { FrontSurface } from "./FrontSurface";
|
||||
import { releasePointerLock } from "../game/inputManager";
|
||||
import { useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import "./biteSizedEditor.css";
|
||||
|
||||
export function BiteSizedEditor({ inGame = false }: { readonly inGame?: boolean }) {
|
||||
const layouts = useBiteSizedLayoutsStore();
|
||||
const session = useShellStore((s) => s.session);
|
||||
const grounded = useGameStore((s) => s.playerGrounded);
|
||||
const testComplete = useDungeonRunStore((s) => s.progress?.finishedAt != null);
|
||||
const [dungeonId, setDungeonId] = useState(BITE_SIZED_LAYOUT_CATALOG.dungeons[0].id);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [crossDungeon, setCrossDungeon] = useState(false);
|
||||
const [creatureKey, setCreatureKey] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const current = layouts.draft.spots.find((s) => s.id === layouts.editingSpotId);
|
||||
const activeDungeon = inGame && current ? current.dungeonId : dungeonId;
|
||||
const spots = layouts.draft.spots.filter((s) => s.dungeonId === activeDungeon);
|
||||
const spot = current?.dungeonId === activeDungeon ? current : spots[0];
|
||||
const selectedPoint = layouts.editingPointId;
|
||||
const mob = spot?.mobs.find((m) => m.id === selectedPoint);
|
||||
const position = mob?.position ?? (selectedPoint === "boss" ? spot?.bossSpawn.footPosition : spot?.partySpawn.footPosition);
|
||||
const yaw = mob?.yaw ?? (selectedPoint === "boss" ? spot?.bossSpawn.yaw : spot?.partySpawn.yaw) ?? 0;
|
||||
const creatures = useMemo(() => BITE_SIZED_LAYOUT_CATALOG.creatures.filter((c) => c.kind === "mob"
|
||||
&& (crossDungeon || c.sourceDungeonId === spot?.sourceDungeonId)
|
||||
&& `${c.name} ${c.dungeonTitle}`.toLowerCase().includes(search.toLowerCase())).sort((a, b) => a.name.localeCompare(b.name)), [crossDungeon, search, spot?.sourceDungeonId]);
|
||||
const creature = creatures.find((c) => `${c.sourceDungeonId}:${c.entityId}` === creatureKey) ?? creatures[0];
|
||||
useEffect(() => { if (!inGame) void useBiteSizedLayoutsStore.getState().refresh(); }, [inGame]);
|
||||
useEffect(() => () => { useBiteSizedLayoutsStore.setState({ panelFocused: false }); }, []);
|
||||
if (!sessionCanUseGmTools(session) || (inGame && !layouts.editorMode)) return null;
|
||||
const attempt = async (action: () => Promise<unknown>) => {
|
||||
setBusy(true); setMessage("");
|
||||
try { await action(); } catch (error) { setMessage(error instanceof Error ? error.message : "Action failed."); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const select = (id: string) => useBiteSizedLayoutsStore.setState({ editingSpotId: id, editingPointId: "party" });
|
||||
const updatePoint = (next: LayoutVector, facing = yaw) => {
|
||||
if (!spot) return;
|
||||
const changed = selectedPoint === "boss" ? { ...spot, bossSpawn: { footPosition: next, yaw: facing } }
|
||||
: mob ? { ...spot, mobs: spot.mobs.map((m) => m.id === mob.id ? { ...m, position: next, yaw: facing } : m) }
|
||||
: { ...spot, partySpawn: { footPosition: next, yaw: facing } };
|
||||
layouts.updateSpot(changed);
|
||||
};
|
||||
const capture = () => { const game = useGameStore.getState(); if (game.playerGrounded) updatePoint([...game.playerPosition], game.cameraYaw); };
|
||||
const preparePreview = async (next: BiteSizedSpot) => {
|
||||
const currentDraft = useBiteSizedLayoutsStore.getState().draft;
|
||||
const draft = { ...currentDraft, spots: currentDraft.spots.map((s) => s.id === next.id ? next : s) };
|
||||
const selection = selectBiteSizedRun(draft, next.dungeonId, "gm-preview", { spotId: next.id });
|
||||
await prepareBiteSizedLayoutContent(next.dungeonId, "gm-preview", selection);
|
||||
};
|
||||
const addMob = () => {
|
||||
if (!spot || !creature) return;
|
||||
const game = useGameStore.getState();
|
||||
const id = `mob-${crypto.randomUUID()}`;
|
||||
const next = { ...spot, mobs: [...spot.mobs, { id, sourceDungeonId: creature.sourceDungeonId, entityId: creature.entityId,
|
||||
position: inGame ? [...game.playerPosition] as LayoutVector : spot.partySpawn.footPosition, yaw: inGame ? game.cameraYaw : 0 }] };
|
||||
void attempt(async () => { await preparePreview(next); layouts.updateSpot(next); useBiteSizedLayoutsStore.setState({ editingPointId: id }); });
|
||||
};
|
||||
const createSpot = (duplicate: boolean) => {
|
||||
if (!spot) return;
|
||||
const id = `spot-${crypto.randomUUID()}`;
|
||||
const game = useGameStore.getState();
|
||||
const next: BiteSizedSpot = duplicate ? { ...spot, id, revision: 0, name: `${spot.name.slice(0, 90)} copy`, mobs: spot.mobs.map((m) => ({ ...m, id: `mob-${crypto.randomUUID()}` })) }
|
||||
: { ...spot, id, revision: 0, name: "New spot", legacyLayout: false, mobs: [], bossActivation: "after-mobs",
|
||||
partySpawn: inGame ? { footPosition: [...game.playerPosition], yaw: game.cameraYaw } : spot.partySpawn,
|
||||
bossSpawn: inGame ? { footPosition: [...game.playerPosition], yaw: game.cameraYaw } : spot.partySpawn };
|
||||
layouts.replaceDraft({ schemaVersion: 1, spots: [...layouts.draft.spots, next] }); select(id);
|
||||
if (inGame) void attempt(() => enterBiteSizedEditorSpot(id));
|
||||
};
|
||||
const publish = () => void attempt(async () => {
|
||||
const draft = normalizeBiteSizedConfig(useBiteSizedLayoutsStore.getState().draft);
|
||||
for (const changed of draft.spots) {
|
||||
const previous = layouts.config.spots.find((s) => s.id === changed.id);
|
||||
if (!previous || biteSizedGameplayKey(previous) !== biteSizedGameplayKey(changed)) await preparePreview(changed);
|
||||
}
|
||||
await validateChangedBiteSizedSpots(draft.spots, layouts.config.spots);
|
||||
if (await layouts.publish(session)) setMessage("Published. New runs use these layouts; active runs keep their saved spot.");
|
||||
});
|
||||
const content = <section className={`bite-sized-editor ${inGame ? "bite-sized-editor--world" : ""} ${collapsed ? "is-collapsed" : ""}`}
|
||||
aria-label="Bite-Sized Editor" onPointerDown={() => releasePointerLock()}
|
||||
onFocusCapture={() => { releasePointerLock(); useBiteSizedLayoutsStore.setState({ panelFocused: true }); }}
|
||||
onBlurCapture={(event) => { if (!event.currentTarget.contains(event.relatedTarget)) useBiteSizedLayoutsStore.setState({ panelFocused: false }); }}>
|
||||
<header><div><p className="eyebrow">GM world authoring</p><h1>Bite-Sized Editor</h1></div>
|
||||
{inGame && <button onClick={() => { setCollapsed(!collapsed); useBiteSizedLayoutsStore.setState({ panelFocused: false }); }} aria-label={collapsed ? "Expand editor" : "Collapse editor"}>{collapsed ? "+" : "−"}</button>}
|
||||
<button onClick={() => inGame ? leaveBiteSizedEditor() : useShellStore.getState().returnToMainMenu()}>{inGame ? "Spot browser" : "Main menu"}</button></header>
|
||||
{!collapsed && <div className="bite-sized-editor__body">
|
||||
<p className="bite-sized-editor__status">{layouts.dirty ? "Unsaved draft" : `Published revision ${layouts.revision}`} · {layouts.editorMode === "test" ? `${testComplete ? "Test complete" : "Test run"} — rewards disabled` : inGame ? "Combat disabled · walk to place points" : "Choose a spot to visit"}</p>
|
||||
{!inGame && <label>Dungeon<select aria-label="Dungeon" value={activeDungeon} onChange={(e) => { setDungeonId(e.target.value); useBiteSizedLayoutsStore.setState({ editingSpotId: null }); }}>{BITE_SIZED_LAYOUT_CATALOG.dungeons.map((d) => <option key={d.id} value={d.id}>{d.title}</option>)}</select></label>}
|
||||
<label>Spot<select aria-label="Spot" value={spot?.id ?? ""} disabled={busy} onChange={(e) => { select(e.target.value); if (inGame) void attempt(() => enterBiteSizedEditorSpot(e.target.value)); }}>{spots.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}</select></label>
|
||||
{spot && <>
|
||||
<div className="bite-sized-editor__actions"><button disabled={busy} onClick={() => void attempt(() => enterBiteSizedEditorSpot(spot.id))}>{layouts.editorMode === "test" ? "Return to editing" : "Go to spot"}</button>
|
||||
<button disabled={busy} onClick={() => void attempt(() => enterBiteSizedEditorSpot(spot.id, "test"))}>Test run</button></div>
|
||||
{layouts.editorMode !== "test" && <>
|
||||
<label>Spot name<input value={spot.name} maxLength={100} onChange={(e) => layouts.updateSpot({ ...spot, name: e.target.value })} /></label>
|
||||
<div className="bite-sized-editor__actions"><button disabled={busy || spots.length >= 80} onClick={() => createSpot(false)}>New spot</button><button disabled={busy || spots.length >= 80} onClick={() => createSpot(true)}>Duplicate spot</button>
|
||||
<button disabled={busy || spots.length <= 1} onClick={() => { layouts.replaceDraft({ schemaVersion: 1, spots: layouts.draft.spots.filter((s) => s.id !== spot.id) }); const next = spots.find((s) => s.id !== spot.id)!; select(next.id); if (inGame) void attempt(() => enterBiteSizedEditorSpot(next.id)); }}>Remove spot</button></div>
|
||||
<label>Boss arrival<select aria-label="Boss arrival" value={spot.bossActivation} onChange={(e) => layouts.updateSpot({ ...spot, bossActivation: e.target.value as BiteSizedSpot["bossActivation"] })}><option value="after-mobs">After all mobs die</option><option value="immediate">Present from start</option></select></label>
|
||||
<div className="bite-sized-editor__points"><button aria-pressed={selectedPoint === "party"} onClick={() => useBiteSizedLayoutsStore.setState({ editingPointId: "party" })}>Party start</button><button aria-pressed={selectedPoint === "boss"} onClick={() => useBiteSizedLayoutsStore.setState({ editingPointId: "boss" })}>Random boss spawn</button></div>
|
||||
<label>Mob points ({spot.mobs.length})<select value={mob?.id ?? ""} onChange={(e) => useBiteSizedLayoutsStore.setState({ editingPointId: e.target.value || "party" })}><option value="">Select a mob point</option>{spot.mobs.map((m, i) => <option key={m.id} value={m.id}>{i + 1}. {BITE_SIZED_LAYOUT_CATALOG.creatures.find((c) => c.sourceDungeonId === m.sourceDungeonId && c.entityId === m.entityId)?.name ?? m.entityId}</option>)}</select></label>
|
||||
{position && <fieldset><legend>{mob ? "Mob spawn" : selectedPoint === "boss" ? "Boss spawn" : "Party anchor"}</legend>
|
||||
<div className="bite-sized-editor__coordinates">{["X", "Y", "Z"].map((axis, i) => <label key={axis}>{axis}<input aria-label={`Spawn ${axis}`} type="number" step="0.1" value={position[i]} onChange={(e) => { const n = e.currentTarget.valueAsNumber; if (Number.isFinite(n)) { const next = [...position] as [number, number, number]; next[i] = n; updatePoint(next); } }} /></label>)}</div>
|
||||
<label>Facing<input aria-label="Spawn facing" type="number" step="0.05" value={yaw} onChange={(e) => { if (Number.isFinite(e.currentTarget.valueAsNumber)) updatePoint(position, e.currentTarget.valueAsNumber); }} /></label>
|
||||
<div className="bite-sized-editor__actions"><button disabled={!inGame || !grounded} onClick={capture}>Set here</button><button disabled={!inGame} onClick={() => { useBiteSizedLayoutsStore.setState({ panelFocused: false }); useGameStore.getState().relocatePlayer({ footPosition: position, yaw }); }}>Go to point</button></div>
|
||||
</fieldset>}
|
||||
<h2>Dungeon creatures</h2><label>Search mobs<input aria-label="Search mobs" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Name or source dungeon" /></label>
|
||||
<label className="bite-sized-editor__check"><input type="checkbox" checked={crossDungeon} onChange={(e) => setCrossDungeon(e.target.checked)} />Other dungeons</label>
|
||||
<label>Mob type<select aria-label="Mob type" value={creature ? `${creature.sourceDungeonId}:${creature.entityId}` : ""} onChange={(e) => setCreatureKey(e.target.value)}>{creatures.map((c) => <option key={`${c.sourceDungeonId}:${c.entityId}`} value={`${c.sourceDungeonId}:${c.entityId}`}>{c.name}{crossDungeon ? ` · ${c.dungeonTitle}` : ""}</option>)}</select></label>
|
||||
<div className="bite-sized-editor__actions"><button disabled={busy || !creature || (inGame && !grounded) || spot.mobs.length >= 500} onClick={addMob}>{inGame ? "Add mob here" : "Add mob point"}</button>
|
||||
{mob && <button disabled={busy || !creature} onClick={() => void attempt(async () => { const next = { ...spot, mobs: spot.mobs.map((m) => m.id === mob.id ? { ...m, sourceDungeonId: creature!.sourceDungeonId, entityId: creature!.entityId } : m) }; await preparePreview(next); layouts.updateSpot(next); })}>Change mob type</button>}</div>
|
||||
{mob && <div className="bite-sized-editor__actions"><button disabled={busy} onClick={() => { const id = `mob-${crypto.randomUUID()}`; layouts.updateSpot({ ...spot, mobs: [...spot.mobs, { ...mob, id, position: [mob.position[0] + 1, mob.position[1], mob.position[2]] }] }); useBiteSizedLayoutsStore.setState({ editingPointId: id }); }}>Duplicate mob point</button><button onClick={() => { layouts.updateSpot({ ...spot, mobs: spot.mobs.filter((m) => m.id !== mob.id) }); useBiteSizedLayoutsStore.setState({ editingPointId: "party" }); }}>Remove mob point</button></div>}
|
||||
<p className="bite-sized-editor__hint">Bosses are rolled per run. Randomized mode uses these points with creatures from its selected pool.</p>
|
||||
</>}
|
||||
</>}
|
||||
{(message || layouts.error) && <p role="status">{message || layouts.error}</p>}
|
||||
<footer><button disabled={!layouts.dirty || busy || layouts.saving} onClick={() => { layouts.discard(); setMessage("Draft discarded."); }}>Discard</button><button disabled={busy || layouts.loading} onClick={() => void layouts.refresh()}>Refresh published</button><button disabled={!layouts.dirty || busy || layouts.saving} onClick={publish}>{busy || layouts.saving ? "Working…" : "Save & publish"}</button></footer>
|
||||
</div>}
|
||||
</section>;
|
||||
return inGame ? content : <FrontSurface className="bite-sized-editor-browser" ariaLabel="Bite-Sized spot browser">{content}</FrontSurface>;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/p
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
import "./dungeonRace.css";
|
||||
import { BiteSizedBoardFilters, useBiteSizedBoardSelection } from "./BiteSizedBoardFilters";
|
||||
|
||||
interface BoardEntry { readonly accountId: string; readonly username: string; readonly rank: number;
|
||||
readonly wins?: number; readonly losses?: number; readonly durationMs?: number;
|
||||
@@ -46,18 +47,20 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
|
||||
const bossOptions = boardDungeon?.difficulties.find((entry) => entry.id === boardDungeon.defaultDifficultyId)
|
||||
?.finalBossIds?.map((id) => boardDungeon.bosses.find((boss) => boss.id === id)!) ?? [];
|
||||
const selectedBoardBossId = bossOptions.find((boss) => boss.id === boardBossId)?.id ?? bossOptions[0]?.id;
|
||||
const spotBoard = useBiteSizedBoardSelection(boardDungeonId);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const query = new URLSearchParams({ view: boardScope === "overall" ? "wins" : boardView,
|
||||
period, ...(boardScope === "dungeon" && boardDungeonId ? { dungeon: boardDungeonId,
|
||||
...(selectedBoardBossId ? { boss: selectedBoardBossId } : {}) } : {}) });
|
||||
...(selectedBoardBossId ? { boss: selectedBoardBossId } : {}),
|
||||
...(boardView === "times" && spotBoard.spotId ? { spot: spotBoard.spotId, spotRevision: String(spotBoard.spotRevision) } : {}) } : {}) });
|
||||
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, selectedBoardBossId]);
|
||||
}, [boardDungeonId, boardScope, boardView, period, selectedBoardBossId, spotBoard.spotId, spotBoard.spotRevision]);
|
||||
const specialization = specializationById(character?.specializationId);
|
||||
const roles = character?.categoryId === "rom" ? partyRolesForClass(character.classId)
|
||||
: specialization ? [specialization.role] : [];
|
||||
@@ -121,6 +124,7 @@ export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void })
|
||||
</section>
|
||||
<section className="dungeon-race-screen__board" aria-label="Dungeon Race leaderboards">
|
||||
<h2>Leaderboards</h2>
|
||||
{boardScope === "dungeon" && boardView === "times" && <BiteSizedBoardFilters selection={spotBoard} />}
|
||||
<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>
|
||||
|
||||
@@ -68,6 +68,7 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
|
||||
<header><p className="front-kicker">{fullClear ? "Full dungeon clear" : "Final boss clear"}</p>
|
||||
<h2 id="dungeon-results-title">{definition?.title ?? progress.dungeonId} complete</h2>
|
||||
<p>The final boss is defeated. Your expedition is complete.</p>
|
||||
{progress.biteSizedSelection && <p>{progress.biteSizedSelection.spot.name} · layout revision {progress.biteSizedSelection.spot.revision}</p>}
|
||||
</header>
|
||||
<div className="dungeon-results__score">
|
||||
<div><small>Time to final boss</small><strong>{progress.timed && progress.durationMs !== null
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { BiteSizedBoardFilters, useBiteSizedBoardSelection } from "./BiteSizedBoardFilters";
|
||||
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
|
||||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import { classById, raceById } from "../app/characterCatalog";
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
type DungeonCategoryId,
|
||||
type DungeonId,
|
||||
} from "../game/dungeonCatalog";
|
||||
import { dungeonDefinitionById } from "../game/dungeonRegistry";
|
||||
import { dungeonDefinitionById, legacyBiteSizedRunSelection } from "../game/dungeonRegistry";
|
||||
import {
|
||||
partyRolesForClass,
|
||||
PARTY_ROLE_LABELS,
|
||||
@@ -38,6 +39,7 @@ import { useCompanionPartyPreferences } from "../game/companionPartyPreferences"
|
||||
import { CompanionPoolControls } from "./CompanionPoolControls";
|
||||
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
||||
import { dungeonContentPackId } from "../game/dungeonContentSource";
|
||||
import { prepareBiteSizedLayoutContent } from "../app/biteSizedLayoutContent";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
@@ -81,6 +83,7 @@ export function DungeonSelectScreen() {
|
||||
const [pendingDownload, setPendingDownload] = useState<PendingDungeonDownload | null>(null);
|
||||
const [downloadError, setDownloadError] = useState("");
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [preparingSpot, setPreparingSpot] = useState(false);
|
||||
const categoryDungeons = dungeonsByCategory(selectedCategory);
|
||||
const firstCategoryDungeon = categoryDungeons.find((dungeon) => dungeon.available) ?? categoryDungeons[0];
|
||||
const selectedDungeon = categoryDungeons.find((dungeon) => dungeon.id === selectedDungeonId)
|
||||
@@ -105,8 +108,9 @@ export function DungeonSelectScreen() {
|
||||
const index = bossOptions.findIndex((boss) => boss.id === selectedBoss?.id);
|
||||
if (hasBossBoards && bossOptions.length) setLeaderboardBossId(bossOptions[(index + 1) % bossOptions.length].id);
|
||||
};
|
||||
const spotBoard = useBiteSizedBoardSelection(selectedDungeon?.id ?? "");
|
||||
const leaderboard = useDungeonLeaderboard(selectedDungeon?.id ?? "", leaderboardDifficulty?.id ?? "normal",
|
||||
clearFilter, bossDifficulty, selectedBoss?.id);
|
||||
clearFilter, bossDifficulty, selectedBoss?.id, spotBoard.spotId, spotBoard.spotRevision);
|
||||
const toggleRun = (id: string) => setExpandedRuns((current) => {
|
||||
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
|
||||
});
|
||||
@@ -140,9 +144,20 @@ export function DungeonSelectScreen() {
|
||||
};
|
||||
|
||||
const continueAsRole = async (role: PartyRole) => {
|
||||
if (!selectedDungeon?.available || !character) return;
|
||||
if (!selectedDungeon?.available || !character || preparingSpot) return;
|
||||
if (!onlineGroup || !session || session.kind !== "account") {
|
||||
enterDungeon(selectedDungeon.id, role, { difficultyId: leaderboardDifficulty?.id, bossDifficulty });
|
||||
setPreparingSpot(true);
|
||||
useShellStore.getState().setNotice("Preparing dungeon and creature downloads...");
|
||||
try {
|
||||
const checkpoint = character.dungeonRunCheckpoint;
|
||||
const resume = checkpoint?.dungeonId === selectedDungeon.id && checkpoint.run?.finishedAt == null
|
||||
&& (leaderboardDifficulty?.id === undefined || checkpoint.difficultyId === leaderboardDifficulty.id)
|
||||
&& checkpoint.run?.bossDifficulty === bossDifficulty ? checkpoint.run : undefined;
|
||||
const runId = resume?.id ?? crypto.randomUUID();
|
||||
const biteSizedSelection = await prepareBiteSizedLayoutContent(selectedDungeon.id, runId, resume ? resume.biteSizedSelection ?? legacyBiteSizedRunSelection(selectedDungeon.id, runId) : undefined);
|
||||
enterDungeon(selectedDungeon.id, role, { difficultyId: leaderboardDifficulty?.id, bossDifficulty, runId, ...(biteSizedSelection ? { biteSizedSelection } : {}) });
|
||||
} catch (error) { useShellStore.getState().setNotice(error instanceof Error ? error.message : "Could not prepare the spot."); }
|
||||
finally { setPreparingSpot(false); }
|
||||
return;
|
||||
}
|
||||
if (!await updateOnlineMember(session, character, role)) return;
|
||||
@@ -240,6 +255,7 @@ export function DungeonSelectScreen() {
|
||||
? availableRoles.map((role) => ({
|
||||
id: `dungeon-role-${role}`,
|
||||
run: () => enterAsRole(role),
|
||||
enabled: !preparingSpot,
|
||||
}))
|
||||
: [{
|
||||
id: "choose-dungeon-role",
|
||||
@@ -251,7 +267,7 @@ export function DungeonSelectScreen() {
|
||||
run: () => setRolePickerOpen(false),
|
||||
}] : []),
|
||||
{ id: "dungeon-back", run: returnToMainMenu },
|
||||
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, leaderboardOpen, expandedRuns, leaderboardDifficulty, bossDifficulty, hasBossBoards, selectedBoss]);
|
||||
], [availableRoles, categoryDungeons, downloading, preparingSpot, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, leaderboardOpen, expandedRuns, leaderboardDifficulty, bossDifficulty, hasBossBoards, selectedBoss]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
|
||||
onBack: pendingDownload
|
||||
@@ -389,6 +405,7 @@ export function DungeonSelectScreen() {
|
||||
select={controller.select}
|
||||
className={`dungeon-role-option dungeon-role-option--${role}`}
|
||||
type="button"
|
||||
disabled={preparingSpot}
|
||||
onClick={() => enterAsRole(role)}
|
||||
>
|
||||
<b aria-hidden="true">{ROLE_DETAILS[role].sigil}</b>
|
||||
@@ -433,6 +450,7 @@ export function DungeonSelectScreen() {
|
||||
</section>
|
||||
{leaderboardOpen && (
|
||||
<div id="dungeon-select-leaderboard" className="dungeon-select-leaderboard-column">
|
||||
<BiteSizedBoardFilters selection={spotBoard} />
|
||||
<DungeonLeaderboard dungeonTitle={selectedDungeon.title} data={leaderboard.data} scope={leaderboard.scope}
|
||||
loading={leaderboard.loading} filter={clearFilter} onFilter={setClearFilter}
|
||||
expanded={expandedRuns} onExpand={toggleRun} offset={leaderboard.offset} onPage={leaderboard.setOffset}
|
||||
|
||||
@@ -225,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={() => useShellStore.setState({ phase: "bite-sized-editor" })}>Bite-Sized Editor</button>
|
||||
<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>
|
||||
|
||||
@@ -130,6 +130,7 @@ export function GmAdminScreen() {
|
||||
<BrandMark compact />
|
||||
<div><span>Server authority / revision {revision}</span><strong>Manastorm GM Tools</strong></div>
|
||||
<div className="gm-admin-header__actions">
|
||||
<button className="front-button" type="button" onClick={() => useShellStore.setState({ phase: "bite-sized-editor" })}>Bite-Sized Editor</button>
|
||||
<button className="front-button" type="button" disabled={!dirty || saving} onClick={resetDraft}>Discard</button>
|
||||
<button className="front-button front-button--primary" type="button" disabled={!dirty || saving} onClick={() => { void publish(session); }}>
|
||||
{saving ? "Publishing…" : "Save & publish"}
|
||||
|
||||
@@ -222,6 +222,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
{ id: "random-queue-cancel", run: closeRandomQueue, enabled: !queueing },
|
||||
] : gmMenuOpen ? [
|
||||
...(isGm ? [{ id: "gm-menu-gm-tools", run: openGmAdmin, neighbors: { up: "gm-menu-close" } }] : []),
|
||||
...(isGm ? [{ id: "gm-menu-bite-sized", run: () => useShellStore.setState({ phase: "bite-sized-editor" }) }] : []),
|
||||
{ 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 }] : []),
|
||||
@@ -445,6 +446,10 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={openGmAdmin}>
|
||||
<b aria-hidden="true">GM</b><span><strong>GM Tools</strong><small>Run dungeon simulations, inspect findings, and review recordings.</small></span><em>{isGm ? "Authorized" : "GM account required"}</em>
|
||||
</ControllerButton>
|
||||
{isGm && <ControllerButton controlId="gm-menu-bite-sized" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--gm" type="button" onClick={() => useShellStore.setState({ phase: "bite-sized-editor" })}>
|
||||
<b aria-hidden="true">BS</b><span><strong>Bite-Sized Editor</strong><small>Visit spots and place party, mob, and boss spawns.</small></span><em>Author</em>
|
||||
</ControllerButton>}
|
||||
<ControllerButton controlId="gm-menu-boss-testing" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--gm" type="button" onClick={onOpenBossTesting}>
|
||||
<b aria-hidden="true">BT</b><span><strong>Boss Testing</strong><small>Fight a selected or random encounter in an empty testing room.</small></span><em>Open</em>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { CompanionPoolControls } from "./CompanionPoolControls";
|
||||
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
||||
import { ensureRandomizedBiteSizedRunContent } from "../app/randomizedBiteSizedContent";
|
||||
import { BITE_SIZED_LAYOUT_CATALOG } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
@@ -50,7 +51,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
|
||||
const dungeonId = pool === "all" ? RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID : RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID;
|
||||
const DUNGEON = dungeonById(dungeonId);
|
||||
const CANDIDATES = pool === "all" ? AVAILABLE_RANDOMIZED_CREATURE_CATALOG : BITE_SIZED_RANDOMIZED_CREATURE_CATALOG;
|
||||
const installed = dungeonContentIsUsable(dungeonId);
|
||||
const installed = BITE_SIZED_LAYOUT_CATALOG.dungeons.some((d) => dungeonContentIsUsable(d.sourceDungeonId));
|
||||
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));
|
||||
@@ -124,7 +125,7 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
|
||||
<section className="randomized-bite-sized-screen__hero">
|
||||
<p className="front-kicker">A different lineup every run</p>
|
||||
<h1>Randomized Bite-Sized</h1>
|
||||
<p>Cellblock Shuffle deals a different mob into every corridor slot and one final boss. Both come from the creature pool you choose.</p>
|
||||
<p>Visit a random saved spot in an eligible dungeon. Each mob point and the final boss draw creatures from the pool you choose.</p>
|
||||
<div className="randomized-bite-sized-screen__odds">
|
||||
<div><strong>{mobCount}</strong><span>mob types in the shuffle</span></div>
|
||||
<div><strong>{bossCount}</strong><span>possible final bosses</span></div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
.bite-sized-editor { --spot-accent: #e8c580; color: #f4ebd8; background: rgba(22, 21, 26, .96); border: 1px solid #635443; border-radius: 12px; padding: 14px; width: min(680px, 94vw); margin: 20px auto; box-shadow: 0 12px 40px #0008; }
|
||||
.bite-sized-editor--world { position: absolute; z-index: 45; top: 12px; right: 12px; width: min(330px, 30vw); max-height: calc(100dvh - 76px); margin: 0; display: flex; flex-direction: column; }
|
||||
.bite-sized-editor--world header { flex-shrink: 0; }
|
||||
.bite-sized-editor--world .bite-sized-editor__body { min-height: 0; max-height: none; flex: 1 1 auto; }
|
||||
.bite-sized-editor header { display: flex; gap: 8px; align-items: center; }
|
||||
.bite-sized-editor header > div { flex: 1; }
|
||||
.bite-sized-editor h1 { font-size: 19px; margin: 2px 0 8px; }
|
||||
.bite-sized-editor h2 { font-size: 15px; margin: 14px 0 5px; color: var(--spot-accent); }
|
||||
.bite-sized-editor__body { overflow-y: auto; max-height: calc(100dvh - 110px); display: grid; gap: 9px; padding-right: 3px; }
|
||||
.bite-sized-editor label { display: grid; gap: 4px; font-size: 12px; }
|
||||
.bite-sized-editor input, .bite-sized-editor select { min-width: 0; width: 100%; box-sizing: border-box; padding: 7px; color: #f4ebd8; background: #17171d; border: 1px solid #615747; border-radius: 5px; }
|
||||
.bite-sized-editor button { color: #f4ebd8; background: #3b332a; border: 1px solid #74614c; border-radius: 5px; padding: 7px 9px; font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.bite-sized-editor button:disabled { opacity: .45; cursor: default; }
|
||||
.bite-sized-editor button[aria-pressed="true"] { border-color: var(--spot-accent); background: #55412b; }
|
||||
.bite-sized-editor fieldset { border: 1px solid #615747; border-radius: 8px; display: grid; gap: 8px; padding: 10px; }
|
||||
.bite-sized-editor__coordinates { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||
.bite-sized-editor__actions, .bite-sized-editor__points, .bite-sized-editor footer { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.bite-sized-editor__status, .bite-sized-editor__hint { font-size: 12px; color: #cfbda1; margin: 3px 0; }
|
||||
.bite-sized-editor .bite-sized-editor__check { display: flex; gap: 8px; align-items: center; }
|
||||
.bite-sized-editor__check input { width: auto; }
|
||||
.bite-sized-editor footer { border-top: 1px solid #615747; padding-top: 10px; }
|
||||
.bite-sized-editor footer button:last-child { background: #70562d; }
|
||||
.bite-sized-editor--world.is-collapsed { width: 235px; }
|
||||
@media (max-width: 760px) {
|
||||
.bite-sized-editor--world { width: min(300px, 83vw); max-height: 72dvh; }
|
||||
.bite-sized-editor--world .bite-sized-editor__body { max-height: 58dvh; }
|
||||
.bite-sized-editor--world.is-collapsed { width: 210px; }
|
||||
}
|
||||
@@ -4,13 +4,13 @@ import { fetchDungeonLeaderboard, localDungeonLeaderboard, subscribeDungeonLeade
|
||||
import type { DungeonClearFilter, DungeonLeaderboard } from "../game/dungeonRunTypes";
|
||||
|
||||
export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, filter: DungeonClearFilter,
|
||||
bossDifficulty = 1, bossId?: string) {
|
||||
bossDifficulty = 1, bossId?: string, spotId?: string, spotRevision?: number) {
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [state, setState] = useState<{ data: DungeonLeaderboard; scope: LeaderboardScope; loading: boolean }>({
|
||||
data: { entries: [], total: 0 }, scope: "world", loading: true,
|
||||
});
|
||||
useEffect(() => { setOffset(0); }, [dungeonId, difficultyId, filter, bossDifficulty, bossId]);
|
||||
useEffect(() => { setOffset(0); }, [dungeonId, difficultyId, filter, bossDifficulty, bossId, spotId, spotRevision]);
|
||||
useEffect(() => subscribeDungeonLeaderboard(() => setRevision((value) => value + 1)), []);
|
||||
useEffect(() => {
|
||||
const refresh = () => setRevision((value) => value + 1);
|
||||
@@ -22,12 +22,12 @@ export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, f
|
||||
let live = true;
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
setState({ data: { entries: [], total: 0 }, scope: "world", loading: true });
|
||||
if (dungeonId) void fetchDungeonLeaderboard(dungeonId, difficultyId, filter, offset, controller.signal, bossDifficulty, bossId)
|
||||
if (dungeonId) void fetchDungeonLeaderboard(dungeonId, difficultyId, filter, offset, controller.signal, bossDifficulty, bossId, spotId, spotRevision)
|
||||
.then((data) => { if (live) setState({ data, scope: "world", loading: false }); })
|
||||
.catch(() => { if (live) setState({ data: localDungeonLeaderboard(dungeonId, difficultyId, filter, offset, bossDifficulty, bossId),
|
||||
.catch(() => { if (live) setState({ data: localDungeonLeaderboard(dungeonId, difficultyId, filter, offset, bossDifficulty, bossId, spotId, spotRevision),
|
||||
scope: "device", loading: false }); });
|
||||
else setState({ data: { entries: [], total: 0 }, scope: "world", loading: false });
|
||||
return () => { live = false; controller.abort(); clearTimeout(timeout); };
|
||||
}, [dungeonId, difficultyId, filter, offset, revision, bossDifficulty, bossId]);
|
||||
}, [dungeonId, difficultyId, filter, offset, revision, bossDifficulty, bossId, spotId, spotRevision]);
|
||||
return { ...state, offset, setOffset, refresh: () => setRevision((value) => value + 1) };
|
||||
}
|
||||
Reference in new issue
Block a user