Update Healer Man server

This commit is contained in:
phenom committed 2026-09-27 21:48:00 -04:00
1 parent 15a5254380
commit 9d7e0e2f9e
71 files changed
+3490 -81

No files matched your search

+4
View File
@@ -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
View File
@@ -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 />}
+4 -1
View File
@@ -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;
+2 -2
View File
@@ -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());
});
+8
View File
@@ -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;
}
+41
View File
@@ -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 });
}
+34
View File
@@ -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();
});
});
+41
View File
@@ -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;
}
+17 -5
View File
@@ -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 });
}
+14 -6
View File
@@ -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 });
},
+8 -4
View File
@@ -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.",
+1
View File
@@ -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;
+1
View File
@@ -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;
+7
View File
@@ -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 }
+7 -2
View File
@@ -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
View File
@@ -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";
+2 -1
View File
@@ -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);
+4 -2
View File
@@ -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");
}
+63
View File
@@ -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 : [],
};
}
+28
View File
@@ -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);
}
}
+95
View File
@@ -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);
});
});
+32
View File
@@ -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 });
});
});
+58
View File
@@ -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; }
},
}));
+18
View File
@@ -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);
});
});
+46
View File
@@ -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.`);
}
}
+11 -3
View File
@@ -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);
}
}
}
});
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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) ?? []),
+38 -2
View File
@@ -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;
}
+1
View File
@@ -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)
+8
View File
@@ -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)],
+18 -2
View File
@@ -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,
});
}
+2
View File
@@ -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;
+4 -2
View File
@@ -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,
}),
+9 -2
View File
@@ -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", () => {
+1 -1
View File
@@ -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
+46
View File
@@ -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>;
}
+7 -3
View File
@@ -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 />
+4
View File
@@ -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 };
+31
View File
@@ -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>;
}
+130
View File
@@ -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>;
}
+6 -2
View File
@@ -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 &amp; losses</button>
+1
View File
@@ -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
+23 -5
View File
@@ -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}
+1
View File
@@ -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>
+1
View File
@@ -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"}
+5
View File
@@ -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>
+3 -2
View File
@@ -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>
+28
View File
@@ -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; }
}
+5 -5
View File
@@ -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) };
}