Update Healer Man server
This commit is contained in:
1 parent
15a5254380
commit
9d7e0e2f9e
71 files changed
+3490
-81
No files matched your search
@@ -25,6 +25,7 @@ import {
|
||||
import { combatSimulationTimerShouldRun } from "./combatSimulationLifecycle";
|
||||
import { advanceDungeonRaceAffixes, resetDungeonRaceAffixEffects, resumeDungeonRaceAffixes,
|
||||
suspendDungeonRaceAffixes } from "./dungeonRaceAffixRuntime";
|
||||
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
|
||||
import {
|
||||
DUNGEON_SIMULATION_FIXED_STEP_MS,
|
||||
useDungeonSimulationStore,
|
||||
@@ -192,7 +193,7 @@ export function CombatBridge() {
|
||||
return;
|
||||
}
|
||||
const game = useGameStore.getState();
|
||||
if (game.dungeonIntroActive || game.raceStartBlocked) return;
|
||||
if (game.dungeonIntroActive || game.raceStartBlocked || useBiteSizedLayoutsStore.getState().editorMode === "edit") return;
|
||||
const sharedOnline = onlineSessionIsActive();
|
||||
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
|
||||
advanceOneTick(delta, Date.now(), false);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useGameStore } from "./store";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
import type { DungeonRunMember } from "./dungeonRunTypes";
|
||||
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
|
||||
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
|
||||
|
||||
function currentRunParty(): readonly DungeonRunMember[] {
|
||||
const shell = useShellStore.getState();
|
||||
@@ -36,7 +37,8 @@ export function DungeonRunBridge() {
|
||||
const advance = () => {
|
||||
const game = useGameStore.getState();
|
||||
const shell = useShellStore.getState();
|
||||
if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode || game.dungeonIntroActive
|
||||
const editorTest = useBiteSizedLayoutsStore.getState().editorMode === "test";
|
||||
if (shell.phase !== "game" || game.gameMode !== "dungeon" || (game.gmTestMode && !editorTest) || game.dungeonIntroActive
|
||||
|| game.raceStartBlocked
|
||||
|| !["ready", "proxy"].includes(game.assetStatus)) return;
|
||||
const run = useDungeonRunStore.getState();
|
||||
@@ -55,7 +57,7 @@ export function DungeonRunBridge() {
|
||||
Object.values(useCombatStore.getState().mobs).filter((mob) => mob.dead && !mob.despawned).map((mob) => mob.id),
|
||||
Date.now(), party);
|
||||
const progress = useDungeonRunStore.getState().progress;
|
||||
if (progress?.finishedAt !== null && progress && announcedId !== progress.id) {
|
||||
if (progress?.finishedAt !== null && progress && announcedId !== progress.id && !editorTest) {
|
||||
announcedId = progress.id;
|
||||
useGameStore.getState().openOverlay("dungeon-results");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { BITE_SIZED_DEFAULT_CONFIG, biteSizedGameplayKey, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes";
|
||||
import type { PopulationEntityDefinition, StaticMobSpawnDefinition } from "./mobPopulation";
|
||||
|
||||
export function materializeBiteSizedLayout(
|
||||
source: DungeonDefinition, baseline: DungeonDefinition, selection: BiteSizedRunSelection,
|
||||
sources: Readonly<Record<string, DungeonDefinition>>, difficultyId?: string,
|
||||
): DungeonDefinition {
|
||||
const { spot } = selection;
|
||||
const randomized = selection.dungeonId.startsWith("randomized-bite-sized-");
|
||||
const original = BITE_SIZED_DEFAULT_CONFIG.spots.find((s) => s.id === spot.id);
|
||||
if (!randomized && spot.legacyLayout && spot.revision === 0 && original && biteSizedGameplayKey(original) === biteSizedGameplayKey(spot) && baseline.bosses[0]?.id === selection.boss.objectiveId) {
|
||||
return { ...baseline, contentSourceDungeonId: spot.sourceDungeonId, biteSizedSelection: selection };
|
||||
}
|
||||
const entities: Record<string, PopulationEntityDefinition> = {};
|
||||
const resolve = (sourceDungeonId: string, entityId: string) => {
|
||||
const original = sources[sourceDungeonId]?.entities[entityId];
|
||||
if (!original) throw new Error(`Creature unavailable: ${sourceDungeonId}/${entityId}`);
|
||||
const id = `placed:${sourceDungeonId}:${entityId}`;
|
||||
entities[id] = { ...original, ...(randomized && original.combat ? { combat: { ...original.combat, healthMultiplier: 1, damageMultiplier: 1 } } : {}) };
|
||||
return id;
|
||||
};
|
||||
const difficulty = source.difficultyVariants.find((d) => d.id === difficultyId) ?? source.difficultyVariants.find((d) => d.id === source.defaultDifficultyId)!;
|
||||
const allMasks = source.difficultyVariants.reduce((mask, d) => mask | d.spawnMask, 0);
|
||||
const mobs: StaticMobSpawnDefinition[] = spot.mobs.flatMap((placement) => {
|
||||
if (((placement.spawnMask ?? allMasks) & difficulty.spawnMask) === 0) return [];
|
||||
const ref = selection.mobs.find((m) => m.placementId === placement.id)!;
|
||||
return [{ id: placement.id, entityId: resolve(ref.sourceDungeonId, ref.entityId), position: placement.position,
|
||||
yaw: placement.yaw, spawnMask: placement.spawnMask ?? allMasks, spawnChance: 1 }];
|
||||
});
|
||||
const bossSpawn: StaticMobSpawnDefinition = { id: selection.boss.objectiveId, entityId: resolve(selection.boss.sourceDungeonId, selection.boss.entityId),
|
||||
position: spot.bossSpawn.footPosition, yaw: spot.bossSpawn.yaw, spawnMask: allMasks };
|
||||
const held = spot.bossActivation === "after-mobs" && mobs.length > 0;
|
||||
const groupId = `${spot.id}:boss`;
|
||||
const encounter: DungeonEncounterDefinition = {
|
||||
id: `${spot.id}:progression`, dungeonId: selection.dungeonId, initialPhase: held ? "mobs" : "boss",
|
||||
initialState: { flags: { "dungeon-complete": false } }, resetPolicy: { bossWipePhase: held ? "mobs" : "boss", checkpointPhase: held ? "mobs" : "boss" },
|
||||
rules: [
|
||||
...(held ? [{ id: "mobs-cleared", trigger: { kind: "entities-dead" as const, entityIds: mobs.map((m) => m.id) },
|
||||
guards: [{ kind: "phase" as const, phases: ["mobs"] }], actions: [
|
||||
{ id: "spawn-boss", kind: "activate-spawn-group" as const, spawnGroupId: groupId },
|
||||
{ id: "boss-phase", kind: "set-phase" as const, phase: "boss" },
|
||||
] }] : []),
|
||||
{ id: "boss-defeated", trigger: { kind: "entities-dead", entityIds: [bossSpawn.id] }, guards: [{ kind: "phase", phases: ["boss"] }], actions: [
|
||||
{ id: "objective", kind: "complete-objective", objectiveId: bossSpawn.id, persistence: "run" },
|
||||
{ id: "checkpoint", kind: "set-checkpoint", checkpointId: bossSpawn.id, persistence: "checkpoint" },
|
||||
{ id: "complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" },
|
||||
{ id: "complete-phase", kind: "set-phase", phase: "complete", persistence: "run" },
|
||||
] },
|
||||
],
|
||||
};
|
||||
const objective = { id: bossSpawn.id, name: selection.boss.name, position: bossSpawn.position, eventOnly: held };
|
||||
return { ...source, id: selection.dungeonId, contentSourceDungeonId: source.id, biteSizedSelection: selection,
|
||||
title: `${source.title}: ${spot.name}`, lfgDungeonIds: [],
|
||||
entrance: { ...source.entrance, name: spot.name, ...spot.partySpawn, forward: [Math.sin(spot.partySpawn.yaw), 0, Math.cos(spot.partySpawn.yaw)] },
|
||||
presentation: { ...baseline.presentation, location: `${source.title} · ${spot.name}`, theme: "Bite-Sized Dungeon", loadingMessage: `Entering ${spot.name}...` },
|
||||
areas: [{ id: "spot", name: spot.name, center: spot.partySpawn.footPosition, radius: 100 }],
|
||||
entities, staticSpawns: held ? mobs : [...mobs, bossSpawn], roamingPacks: [], bosses: [objective], objectiveOrder: [objective],
|
||||
finalBossDurability: "standard", encounters: [encounter], encounterSpawnGroups: held ? [{ id: groupId, spawns: [bossSpawn] }] : [],
|
||||
serverGameObjects: spot.legacyLayout ? baseline.serverGameObjects : [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { GLTFLoader, MeshoptDecoder } from "three-stdlib";
|
||||
import { resolveContentUrl } from "../content/contentManager";
|
||||
import { DUNGEON_REGISTRY } from "./dungeonRegistry";
|
||||
import { resolveDungeonAssets } from "./dungeonAssets";
|
||||
import { cachedPartyNavigationGraph, preparePartyNavigationGraph } from "./partyNavigationAssetCache";
|
||||
import { BITE_SIZED_LAYOUT_CATALOG, biteSizedGameplayKey, type BiteSizedSpot } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { validateBiteSizedSpotNavigation } from "./biteSizedSpotNavigation";
|
||||
export { validateBiteSizedSpotNavigation } from "./biteSizedSpotNavigation";
|
||||
|
||||
export async function validateChangedBiteSizedSpots(spots: readonly BiteSizedSpot[], previous: readonly BiteSizedSpot[]): Promise<void> {
|
||||
for (const spot of spots) {
|
||||
const old = previous.find((s) => s.id === spot.id);
|
||||
if (old && biteSizedGameplayKey(old) === biteSizedGameplayKey(spot)) continue;
|
||||
const source = DUNGEON_REGISTRY[spot.sourceDungeonId];
|
||||
const url = resolveDungeonAssets(source).navigation;
|
||||
if (!url) throw new Error(`${spot.name}: the parent dungeon has no navigation surface.`);
|
||||
let graph = cachedPartyNavigationGraph(url, source.navigationLinks, source.traversalRamps, true);
|
||||
if (!graph) {
|
||||
const gltf = await new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).loadAsync(resolveContentUrl(url));
|
||||
graph = preparePartyNavigationGraph(url, gltf.scene, source.navigationLinks, source.traversalRamps, 1_000_000, true);
|
||||
gltf.scene.traverse((node) => { if ("geometry" in node) (node as import("three").Mesh).geometry.dispose(); });
|
||||
}
|
||||
// Off-mesh routes are already included in the retained connected node
|
||||
// component; triangle-only A* would incorrectly reject bridged floors.
|
||||
const blockers = spot.legacyLayout ? BITE_SIZED_LAYOUT_CATALOG.dungeons.find((d) => d.id === spot.dungeonId)!.legacyBlockers : [];
|
||||
validateBiteSizedSpotNavigation(spot, graph, graph.triangleNodes || blockers.length ? undefined : graph.triangles!.map(() => 0), blockers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { BITE_SIZED_DEFAULT_CONFIG, BITE_SIZED_LAYOUT_CATALOG, biteSizedGameplayKey, normalizeBiteSizedConfig, normalizeBiteSizedSelection, selectBiteSizedRun } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { biteSizedDefinitionForSelection, dungeonDefinitionById, legacyBiteSizedRunSelection, randomizedBiteSizedDefinitionForSeed } from "./dungeonRegistry";
|
||||
import { activateDungeonSession, restartActiveDungeonSession } from "./dungeonSession";
|
||||
import { useDungeonRunStore } from "./dungeonRunStore";
|
||||
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
|
||||
import { createDungeonEncounterSnapshot, reduceDungeonEncounter } from "./dungeonEncounterRuntime";
|
||||
import { normalizeDungeonRunCheckpoint, DUNGEON_RUN_CHECKPOINT_VERSION } from "./dungeonRunCheckpoint";
|
||||
|
||||
const config = () => ({ schemaVersion: 1 as const, spots: [...normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG).spots] });
|
||||
const parent = config().spots.find((s) => s.sourceDungeonId === "stormwind-stockade")!;
|
||||
|
||||
describe("published Bite-Sized spots", () => {
|
||||
beforeEach(() => useBiteSizedLayoutsStore.setState({ config: config(), draft: config(), dirty: false, editorMode: null }));
|
||||
it("permits repeated creatures, imported references, and different stable placement ids", () => {
|
||||
const next = config();
|
||||
const imported = BITE_SIZED_LAYOUT_CATALOG.creatures.find((c) => c.kind === "mob" && c.sourceDungeonId !== parent.sourceDungeonId)!;
|
||||
const duplicate = { ...parent, id: "another-spot", legacyLayout: false, mobs: [
|
||||
{ ...parent.mobs[0], id: "one", sourceDungeonId: imported.sourceDungeonId, entityId: imported.entityId },
|
||||
{ ...parent.mobs[0], id: "two", sourceDungeonId: imported.sourceDungeonId, entityId: imported.entityId },
|
||||
] };
|
||||
next.spots.push(duplicate);
|
||||
const selection = selectBiteSizedRun(normalizeBiteSizedConfig(next), parent.dungeonId, "duplicate", { spotId: duplicate.id });
|
||||
const definition = biteSizedDefinitionForSelection(selection, "duplicate")!;
|
||||
expect(definition.staticSpawns.filter((s) => s.id === "one" || s.id === "two")).toHaveLength(2);
|
||||
expect(definition.serverGameObjects).toEqual([]);
|
||||
expect(normalizeBiteSizedSelection(JSON.parse(JSON.stringify(selection)))).toEqual(selection);
|
||||
expect(() => normalizeBiteSizedConfig({ ...next, spots: next.spots.map((s) => s.id === duplicate.id ? { ...s, mobs: [s.mobs[0], s.mobs[0]] } : s) })).toThrow(/unique/);
|
||||
expect(() => normalizeBiteSizedConfig({ ...next, spots: next.spots.filter((s) => s.sourceDungeonId !== parent.sourceDungeonId) })).toThrow(/between 1/);
|
||||
expect(biteSizedGameplayKey(parent)).toBe(biteSizedGameplayKey({ ...parent, name: "Renamed", revision: 5 }));
|
||||
});
|
||||
it("pins the same spot, boss, and mobs through checkpoints, publication, and retries", () => {
|
||||
const selection = selectBiteSizedRun(config(), parent.dungeonId, "pinned");
|
||||
expect(activateDungeonSession(parent.dungeonId, undefined, { runId: "pinned", biteSizedSelection: selection })).toBe(true);
|
||||
const run = useDungeonRunStore.getState().progress!;
|
||||
const checkpoint = normalizeDungeonRunCheckpoint(JSON.parse(JSON.stringify({ version: DUNGEON_RUN_CHECKPOINT_VERSION,
|
||||
dungeonId: parent.dungeonId, difficultyId: run.difficultyId, checkpointId: "entrance", spawn: parent.partySpawn,
|
||||
encounters: [], savedAt: Date.now(), run })));
|
||||
expect(checkpoint?.run?.biteSizedSelection).toEqual(selection);
|
||||
const changed = config();
|
||||
changed.spots = changed.spots.map((s) => s.id === parent.id ? { ...s, revision: 1, mobs: [], name: "Published later" } : s);
|
||||
useBiteSizedLayoutsStore.setState({ config: changed });
|
||||
expect(restartActiveDungeonSession()).toBe(true);
|
||||
expect(dungeonDefinitionById(parent.dungeonId)?.biteSizedSelection).toEqual(selection);
|
||||
});
|
||||
it("uses independent deterministic rolls and multiple parent maps for both randomized pools", () => {
|
||||
for (const id of Object.keys(BITE_SIZED_LAYOUT_CATALOG.pools)) {
|
||||
const maps = new Set<string>();
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const seed = `random-${i}`;
|
||||
const selection = selectBiteSizedRun(config(), id, seed);
|
||||
maps.add(selection.spot.sourceDungeonId);
|
||||
expect(selection).toEqual(selectBiteSizedRun(config(), id, seed));
|
||||
expect(normalizeBiteSizedSelection(selection)).toEqual(selection);
|
||||
expect(biteSizedDefinitionForSelection(selection, seed)?.contentSourceDungeonId).toBe(selection.spot.sourceDungeonId);
|
||||
}
|
||||
expect(maps.size).toBe(12);
|
||||
}
|
||||
});
|
||||
it.each(["immediate", "after-mobs"] as const)("completes an edited %s encounter", (activation) => {
|
||||
const next = config();
|
||||
next.spots = next.spots.map((s) => s.id === parent.id ? { ...s, revision: 1, bossActivation: activation } : s);
|
||||
const selection = selectBiteSizedRun(next, parent.dungeonId, "completion");
|
||||
const definition = biteSizedDefinitionForSelection(selection, "completion")!;
|
||||
const encounter = definition.encounters![0];
|
||||
let state = createDungeonEncounterSnapshot(encounter);
|
||||
state = reduceDungeonEncounter(encounter, state, { kind: "tick", now: 1000, facts: {
|
||||
registeredEntityIds: selection.spot.mobs.map((m) => m.id), deadEntityIds: selection.spot.mobs.map((m) => m.id),
|
||||
} }).snapshot;
|
||||
expect(state.phase).toBe("boss");
|
||||
state = reduceDungeonEncounter(encounter, state, { kind: "tick", now: 2000, facts: {
|
||||
registeredEntityIds: [selection.boss.objectiveId], deadEntityIds: [selection.boss.objectiveId],
|
||||
} }).snapshot;
|
||||
expect(state.phase).toBe("complete");
|
||||
expect(state.flags["dungeon-complete"]).toBe(true);
|
||||
expect(definition.encounterSpawnGroups?.length ?? 0).toBe(activation === "after-mobs" ? 1 : 0);
|
||||
});
|
||||
it("activates a boss-only after-mobs layout immediately", () => {
|
||||
const next = config();
|
||||
next.spots = next.spots.map((s) => s.id === parent.id ? { ...s, revision: 1, mobs: [], bossActivation: "after-mobs" } : s);
|
||||
const definition = biteSizedDefinitionForSelection(selectBiteSizedRun(next, parent.dungeonId, "solo"), "solo")!;
|
||||
expect(definition.staticSpawns).toHaveLength(1);
|
||||
expect(definition.encounterSpawnGroups).toEqual([]);
|
||||
expect(definition.encounters![0].initialPhase).toBe("boss");
|
||||
});
|
||||
it("preserves the original Stockades map and creature deal for legacy randomized checkpoints", () => {
|
||||
const id = "randomized-bite-sized-stormwind-stockade", seed = "legacy-checkpoint";
|
||||
const legacy = randomizedBiteSizedDefinitionForSeed(seed, id);
|
||||
const selection = legacyBiteSizedRunSelection(id, seed)!;
|
||||
expect(selection.legacyRandomized).toBe(true);
|
||||
expect(selection.spot.sourceDungeonId).toBe("stormwind-stockade");
|
||||
expect(biteSizedDefinitionForSelection(selection, seed).staticSpawns).toEqual(legacy.staticSpawns);
|
||||
expect(biteSizedDefinitionForSelection(normalizeBiteSizedSelection(JSON.parse(JSON.stringify(selection))), seed).staticSpawns).toEqual(legacy.staticSpawns);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BITE_SIZED_DEFAULT_CONFIG, normalizeBiteSizedConfig } from "../../shared/bite-sized-layouts.mjs";
|
||||
const request = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../app/onlineAccountClient", () => ({ requestOnlineJson: request }));
|
||||
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
|
||||
|
||||
describe("GM spot drafts", () => {
|
||||
beforeEach(() => { request.mockReset(); const config = normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG);
|
||||
useBiteSizedLayoutsStore.setState({ config, draft: config, revision: 0, dirty: false, saving: false, error: null }); });
|
||||
it("preserves unsaved placements on a conflict and on refresh, then permits discard", async () => {
|
||||
const store = useBiteSizedLayoutsStore.getState();
|
||||
const draft = { ...store.draft.spots[0], name: "Unsaved name" };
|
||||
store.updateSpot(draft);
|
||||
request.mockRejectedValueOnce(new Error("Revision conflict"));
|
||||
expect(await store.publish({ kind: "account", ownerId: "gm", displayName: "GM", accessToken: "test-token", roles: ["gm"] })).toBe(false);
|
||||
expect(useBiteSizedLayoutsStore.getState()).toMatchObject({ dirty: true, saving: false, error: "Revision conflict" });
|
||||
expect(useBiteSizedLayoutsStore.getState().draft.spots[0]).toEqual(draft);
|
||||
request.mockResolvedValueOnce({ revision: 1, updatedAt: 1, config: store.config });
|
||||
await store.refresh();
|
||||
expect(useBiteSizedLayoutsStore.getState().draft.spots[0]).toEqual(draft);
|
||||
store.discard(); expect(useBiteSizedLayoutsStore.getState().dirty).toBe(false);
|
||||
expect(useBiteSizedLayoutsStore.getState().draft.spots[0].name).toBe(store.config.spots[0].name);
|
||||
});
|
||||
it("requires GM credentials and atomically replaces a successfully published draft", async () => {
|
||||
const store = useBiteSizedLayoutsStore.getState(); store.updateSpot({ ...store.draft.spots[0], name: "Draft" });
|
||||
expect(await store.publish(null)).toBe(false); expect(request).not.toHaveBeenCalled();
|
||||
const config = useBiteSizedLayoutsStore.getState().draft;
|
||||
request.mockResolvedValueOnce({ config, revision: 1, updatedAt: 1 });
|
||||
expect(await store.publish({ kind: "account", ownerId: "gm", displayName: "GM", accessToken: "test-token", roles: ["gm"] })).toBe(true);
|
||||
expect(useBiteSizedLayoutsStore.getState()).toMatchObject({ dirty: false, revision: 1, saving: false, config });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { create } from "zustand";
|
||||
import { requestOnlineJson } from "../app/onlineAccountClient";
|
||||
import type { PlayerSession } from "../app/types";
|
||||
import { BITE_SIZED_DEFAULT_CONFIG, normalizeBiteSizedConfig, normalizeBiteSizedSpot, type BiteSizedConfig, type BiteSizedSpot, type PublishedBiteSizedConfig } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
const CACHE_KEY = "healer-man.bite-sized-layouts.v1";
|
||||
function cached(): PublishedBiteSizedConfig {
|
||||
try {
|
||||
const payload = JSON.parse(localStorage.getItem(CACHE_KEY) ?? "null");
|
||||
if (!Number.isSafeInteger(payload?.revision) || payload.revision < 0) throw new Error("Missing cache");
|
||||
return { revision: payload.revision, updatedAt: payload.updatedAt ?? null, config: normalizeBiteSizedConfig(payload.config) };
|
||||
} catch { return { revision: 0, updatedAt: null, config: normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG) }; }
|
||||
}
|
||||
function cache(payload: PublishedBiteSizedConfig) {
|
||||
try { localStorage.setItem(CACHE_KEY, JSON.stringify(payload)); } catch { /* Cached defaults remain usable without storage. */ }
|
||||
}
|
||||
interface LayoutState extends PublishedBiteSizedConfig {
|
||||
readonly draft: BiteSizedConfig; readonly dirty: boolean; readonly saving: boolean; readonly loading: boolean; readonly error: string | null;
|
||||
readonly editingSpotId: string | null; readonly editingPointId: string; readonly editorMode: "edit" | "test" | null;
|
||||
readonly panelFocused: boolean; readonly historicalSpots: readonly BiteSizedSpot[];
|
||||
refresh: () => Promise<void>; loadHistory: () => Promise<void>; updateSpot: (spot: BiteSizedSpot) => void;
|
||||
replaceDraft: (draft: BiteSizedConfig) => void; discard: () => void; publish: (session: PlayerSession | null) => Promise<boolean>;
|
||||
}
|
||||
const initial = cached();
|
||||
export const useBiteSizedLayoutsStore = create<LayoutState>((set, get) => ({
|
||||
...initial, draft: initial.config, dirty: false, saving: false, loading: false, error: null,
|
||||
editingSpotId: null, editingPointId: "party", editorMode: null, panelFocused: false, historicalSpots: [],
|
||||
refresh: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const payload = await requestOnlineJson<PublishedBiteSizedConfig>("/api/bite-sized-layouts");
|
||||
const normalized = { ...payload, config: normalizeBiteSizedConfig(payload.config) };
|
||||
cache(normalized);
|
||||
set((state) => ({ ...normalized, loading: false, error: null, ...(state.dirty ? {} : { draft: normalized.config }) }));
|
||||
} catch (error) { set({ loading: false, error: error instanceof Error ? error.message : "Could not load layouts." }); }
|
||||
},
|
||||
loadHistory: async () => {
|
||||
try { const entries = await requestOnlineJson<readonly BiteSizedSpot[]>("/api/bite-sized-layouts?history=1"); set({ historicalSpots: entries.map(normalizeBiteSizedSpot) }); }
|
||||
catch { set({ historicalSpots: get().config.spots }); }
|
||||
},
|
||||
replaceDraft: (draft) => set({ draft, dirty: true, error: null }),
|
||||
updateSpot: (spot) => set((state) => ({ draft: { schemaVersion: 1, spots: state.draft.spots.map((s) => s.id === spot.id ? spot : s) }, dirty: true, error: null })),
|
||||
discard: () => set((state) => ({ draft: state.config, dirty: false, error: null })),
|
||||
publish: async (session) => {
|
||||
if (!session?.accessToken || !session.roles?.includes("gm")) { set({ error: "Game Master access is required." }); return false; }
|
||||
set({ saving: true, error: null });
|
||||
try {
|
||||
const config = normalizeBiteSizedConfig(get().draft);
|
||||
const payload = await requestOnlineJson<PublishedBiteSizedConfig>("/api/gm/bite-sized-layouts", {
|
||||
method: "PUT", body: JSON.stringify({ expectedRevision: get().revision, config }),
|
||||
}, session.accessToken);
|
||||
const normalized = { ...payload, config: normalizeBiteSizedConfig(payload.config) };
|
||||
cache(normalized);
|
||||
set({ ...normalized, draft: normalized.config, dirty: false, saving: false });
|
||||
return true;
|
||||
} catch (error) { set({ saving: false, error: error instanceof Error ? error.message : "Could not publish layouts." }); return false; }
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { biteSizedRouteSegmentClear } from "./biteSizedSpotNavigation";
|
||||
|
||||
describe("Bite-Sized branch blocker clearance", () => {
|
||||
const wall = { position: [0, 2, 0] as const, halfExtents: [4, 3, 0.5] as const, yaw: 0 };
|
||||
it("blocks crossing and occupied points while allowing routes around the wall", () => {
|
||||
expect(biteSizedRouteSegmentClear([wall], [0, 0, -4], [0, 0, 4])).toBe(false);
|
||||
expect(biteSizedRouteSegmentClear([wall], [0, 0, 0], [0, 0, 0])).toBe(false);
|
||||
expect(biteSizedRouteSegmentClear([wall], [5, 0, -4], [5, 0, 4])).toBe(true);
|
||||
expect(biteSizedRouteSegmentClear([wall], [0, 8, -4], [0, 8, 4])).toBe(true);
|
||||
expect(biteSizedRouteSegmentClear([], [0, 0, -4], [0, 0, 4])).toBe(true);
|
||||
});
|
||||
it("checks rotated boxes in their local frame", () => {
|
||||
const rotated = { ...wall, yaw: Math.PI / 2 };
|
||||
expect(biteSizedRouteSegmentClear([rotated], [-4, 0, 0], [4, 0, 0])).toBe(false);
|
||||
expect(biteSizedRouteSegmentClear([rotated], [-4, 0, 5], [4, 0, 5])).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { findNearestPartyNavigationSurface, findPartyNavigationPath, type PartyNavigationGraph } from "./partyNavigation";
|
||||
import { derivePartySpawnSlots } from "./partySpawnSlots";
|
||||
import type { BiteSizedLayoutBlocker, BiteSizedSpot, LayoutVector } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
export function biteSizedRouteSegmentClear(blockers: readonly BiteSizedLayoutBlocker[], from: LayoutVector, to: LayoutVector): boolean {
|
||||
return blockers.every((blocker) => {
|
||||
const sin = Math.sin(blocker.yaw), cos = Math.cos(blocker.yaw);
|
||||
const local = (point: LayoutVector) => {
|
||||
const x = point[0] - blocker.position[0], z = point[2] - blocker.position[2];
|
||||
return [x * cos - z * sin, point[1] - blocker.position[1], x * sin + z * cos];
|
||||
};
|
||||
const start = local(from), end = local(to);
|
||||
let enter = 0, leave = 1;
|
||||
for (let axis = 0; axis < 3; axis++) {
|
||||
const extent = blocker.halfExtents[axis] + (axis === 1 ? 1 : 0.34);
|
||||
const delta = end[axis] - start[axis];
|
||||
if (Math.abs(delta) < 1e-9) { if (Math.abs(start[axis]) > extent) return true; continue; }
|
||||
const first = (-extent - start[axis]) / delta, second = (extent - start[axis]) / delta;
|
||||
enter = Math.max(enter, Math.min(first, second));
|
||||
leave = Math.min(leave, Math.max(first, second));
|
||||
if (enter > leave) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function validateBiteSizedSpotNavigation(spot: BiteSizedSpot, graph: PartyNavigationGraph, regions?: readonly number[], blockers: readonly BiteSizedLayoutBlocker[] = []): void {
|
||||
const start = findNearestPartyNavigationSurface(graph, spot.partySpawn.footPosition, 0.75);
|
||||
if (!start) throw new Error(`${spot.name}: Party anchor is off the walkable floor. Move its spawn point.`);
|
||||
const points: { label: string; position: readonly [number, number, number]; snapDistance?: number }[] = [
|
||||
...derivePartySpawnSlots(spot.partySpawn).map((s, i) => ({ label: `Party member ${i + 1}`, position: s.footPosition, snapDistance: i === 0 ? 0.75 : 6 })),
|
||||
...spot.mobs.map((m, i) => ({ label: `Mob ${i + 1}`, position: m.position })),
|
||||
{ label: "Boss", position: spot.bossSpawn.footPosition },
|
||||
];
|
||||
for (const point of points) {
|
||||
// Companions project their formation seats onto the floor within six metres,
|
||||
// exactly as PartyPopulation does. Authored mob/boss points never auto-snap.
|
||||
const match = findNearestPartyNavigationSurface(graph, point.position, point.snapDistance ?? 0.75);
|
||||
if (!match) throw new Error(`${spot.name}: ${point.label} is off the walkable floor. Move its spawn point.`);
|
||||
if (!biteSizedRouteSegmentClear(blockers, match.position, match.position)) throw new Error(`${spot.name}: ${point.label} is inside a route blocker.`);
|
||||
const reachable = regions ? regions[start.triangleId] === regions[match.triangleId]
|
||||
: findPartyNavigationPath(graph, start.position, match.position, { maxSnapDistance: 0.75,
|
||||
routeSegmentAllowed: (from, to) => biteSizedRouteSegmentClear(blockers, from, to) });
|
||||
if (!reachable) throw new Error(`${spot.name}: ${point.label} cannot be reached from the party start.`);
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ describe("bite-sized Stormwind Stockades", () => {
|
||||
.toBe(first.staticSpawns.find((spawn) => spawn.id === first.bosses[0].id)?.entityId);
|
||||
});
|
||||
|
||||
it("uses ordinary boss durability for every level-17 roll", () => {
|
||||
it("retains the shorter Bite-Sized durability curve for every level-17 roll", () => {
|
||||
const variants = new Map<string, ReturnType<typeof createBiteSizedStockadesDefinition>>();
|
||||
for (let index = 0; index < 1_000 && variants.size < BITE_SIZED_STOCKADES_BOSS_CANDIDATES.length; index += 1) {
|
||||
const definition = createBiteSizedStockadesDefinition(source, `level-17-run-${index}`);
|
||||
@@ -95,9 +95,17 @@ describe("bite-sized Stormwind Stockades", () => {
|
||||
const boss = definition.bosses[0];
|
||||
const spawn = definition.staticSpawns.find((entry) => entry.id === boss.id)!;
|
||||
const entity = definition.entities[spawn.entityId];
|
||||
expect(dungeonEnemyScaling(
|
||||
const health = dungeonEnemyScaling(
|
||||
definition, entity, boss.id, 17, "normal", 1, characterSource,
|
||||
).maxHealth, `${characterSource}:${boss.name}`).toBeCloseTo(ordinaryHealth);
|
||||
).maxHealth;
|
||||
expect(health, `${characterSource}:${boss.name}`).toBeGreaterThan(0);
|
||||
expect(health, `${characterSource}:${boss.name}`).toBeLessThan(ordinaryHealth);
|
||||
// The established leveling curve interpolates a 0.685–0.835 WoW
|
||||
// floor/ceiling at level 17; editor work must not restore campaign HP.
|
||||
if (characterSource === "wow") {
|
||||
expect(health).toBeGreaterThanOrEqual(ordinaryHealth * 0.685 - 0.001);
|
||||
expect(health).toBeLessThanOrEqual(ordinaryHealth * 0.835 + 0.001);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ const runewakerMaps = runewakerCatalog.dungeons as unknown as Readonly<Record<st
|
||||
export function dungeonArtworkMapFor(dungeon: DungeonDefinition): DungeonArtworkMap | null {
|
||||
const wow = wowDungeonMapFor(dungeon);
|
||||
if (wow) return { kind: "wow", source: wow, floors: wow.floors, width: 1002, height: 668 };
|
||||
const source = runewakerMaps[dungeonContentSourceId(dungeon.id)];
|
||||
const source = runewakerMaps[dungeon.contentSourceDungeonId ?? dungeonContentSourceId(dungeon.id)];
|
||||
if (source?.mapId !== dungeon.mapId
|
||||
|| source.coordinateTransform !== dungeon.provenance.coordinateTransform
|
||||
|| source.visualChecksum !== dungeon.assets.visual[0]?.checksum
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface ResolvedDungeonAssets {
|
||||
}
|
||||
|
||||
export function resolveDungeonAssets(definition: DungeonDefinition): ResolvedDungeonAssets {
|
||||
const sourceId = dungeonContentSourceId(definition.id);
|
||||
const sourceId = definition.contentSourceDungeonId ?? dungeonContentSourceId(definition.id);
|
||||
return {
|
||||
visual: definition.assets.visual.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
|
||||
collision: definition.assets.collision.flatMap((chunk) => findAsset(sourceId, chunk) ?? []),
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { DungeonDefinition } from "./dungeonTypes";
|
||||
import { materializeBiteSizedLayout } from "./biteSizedLayoutRuntime";
|
||||
import { useBiteSizedLayoutsStore } from "./biteSizedLayoutsStore";
|
||||
import { BITE_SIZED_DEFAULT_CONFIG, BITE_SIZED_LAYOUT_CATALOG, isLayoutBiteSizedDungeon, normalizeBiteSizedSelection, selectBiteSizedRun, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { FORSAKEN_ABBEY_DEFINITION } from "./dungeons/forsakenAbbey";
|
||||
import { WAILING_CAVERNS_DEFINITION } from "./dungeons/wailingCaverns";
|
||||
import availability from "./generated/dungeonAvailability.json";
|
||||
@@ -353,7 +356,7 @@ export function clearBossTestingDungeons(): void {
|
||||
}
|
||||
|
||||
/** Materializes seeded variants after a run id exists; ordinary dungeons keep their catalog definition. */
|
||||
export function prepareDungeonSessionDefinition(id: string, seed: string): DungeonDefinition | null {
|
||||
function legacyBiteSizedDefinition(id: string, seed: string): DungeonDefinition | null {
|
||||
let definition: DungeonDefinition;
|
||||
if (id === BITE_SIZED_STOCKADES_DUNGEON_ID) definition = createBiteSizedStockadesDefinition(stockadesDefinition, seed);
|
||||
else if (isRandomizedBiteSizedDungeonId(id)) definition = randomizedBiteSizedDefinitionForSeed(seed, id);
|
||||
@@ -369,7 +372,40 @@ export function prepareDungeonSessionDefinition(id: string, seed: string): Dunge
|
||||
else if (id === BITE_SIZED_DEADMINES_DUNGEON_ID) definition = createBiteSizedDeadminesDefinition(deadminesDefinition, seed);
|
||||
else if (id === BITE_SIZED_UTGARDE_PINNACLE_DUNGEON_ID) definition = createBiteSizedUtgardePinnacleDefinition(utgardePinnacleDefinition, seed);
|
||||
else return dungeonDefinitionById(id);
|
||||
sessionDungeonDefinitions.set(id, definition);
|
||||
return definition;
|
||||
}
|
||||
|
||||
export function biteSizedDefinitionForSelection(selection: BiteSizedRunSelection, seed: string, difficultyId?: string): DungeonDefinition {
|
||||
const validated = normalizeBiteSizedSelection(selection);
|
||||
if (validated.legacyRandomized) return { ...legacyBiteSizedDefinition(validated.dungeonId, seed)!, contentSourceDungeonId: validated.spot.sourceDungeonId, biteSizedSelection: validated };
|
||||
const source = adaptedSourceRegistry[validated.spot.sourceDungeonId];
|
||||
const baseline = legacyBiteSizedDefinition(validated.spot.dungeonId, seed);
|
||||
if (!source || !baseline) throw new Error("The selected Bite-Sized parent is unavailable.");
|
||||
return materializeBiteSizedLayout(source, baseline, validated, adaptedSourceRegistry, difficultyId);
|
||||
}
|
||||
|
||||
/** Checkpoints made before the editor retain the original map and creature deal. */
|
||||
export function legacyBiteSizedRunSelection(id: string, seed: string): BiteSizedRunSelection | undefined {
|
||||
if (!isLayoutBiteSizedDungeon(id)) return undefined;
|
||||
const randomized = id.startsWith("randomized-");
|
||||
const selection = selectBiteSizedRun(BITE_SIZED_DEFAULT_CONFIG, id, seed, {
|
||||
eligibleDungeonIds: [randomized ? "bite-sized-stormwind-stockade" : id],
|
||||
});
|
||||
if (!randomized) return selection;
|
||||
const definition = legacyBiteSizedDefinition(id, seed)!;
|
||||
const pool = BITE_SIZED_LAYOUT_CATALOG.pools[id];
|
||||
return normalizeBiteSizedSelection({ ...selection, legacyRandomized: true, mobs: selection.mobs.map((m) => {
|
||||
const spawn = definition.staticSpawns.find((s) => s.id === m.placementId)!;
|
||||
const ref = pool.find((c) => c.objectiveId === spawn.entityId)!;
|
||||
return { placementId: m.placementId, sourceDungeonId: ref.sourceDungeonId, entityId: ref.entityId };
|
||||
}) });
|
||||
}
|
||||
|
||||
export function prepareDungeonSessionDefinition(id: string, seed: string, selection?: BiteSizedRunSelection, difficultyId?: string): DungeonDefinition | null {
|
||||
const definition = isLayoutBiteSizedDungeon(id)
|
||||
? biteSizedDefinitionForSelection(selection ?? selectBiteSizedRun(useBiteSizedLayoutsStore.getState().config, id, seed), seed, difficultyId)
|
||||
: dungeonDefinitionById(id);
|
||||
if (definition) sessionDungeonDefinitions.set(id, definition);
|
||||
return definition;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export function completedDungeonRun(progress: DungeonRunProgress): DungeonRunRec
|
||||
const definition = dungeonDefinitionById(progress.dungeonId);
|
||||
if (!definition || !progress.timed || progress.finishedAt === null || progress.durationMs === null) return null;
|
||||
return { id: progress.id, dungeonId: progress.dungeonId, difficultyId: progress.difficultyId,
|
||||
...(progress.biteSizedSelection ? { biteSizedSelection: progress.biteSizedSelection } : {}),
|
||||
bossDifficulty: progress.bossDifficulty,
|
||||
finishedAt: progress.finishedAt, durationMs: progress.durationMs, killedBossIds: progress.killedBossIds,
|
||||
party: progress.party, fullClear: dungeonBossesForDifficulty(definition, progress.difficultyId)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { validBossDifficulty } from "./dungeonDifficulty";
|
||||
import { normalizeBiteSizedSelection, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
export interface DungeonRunMember {
|
||||
readonly name: string;
|
||||
@@ -10,6 +11,7 @@ export interface DungeonRunMember {
|
||||
}
|
||||
|
||||
export interface DungeonRunProgress {
|
||||
readonly biteSizedSelection?: BiteSizedRunSelection;
|
||||
readonly id: string;
|
||||
readonly dungeonId: string;
|
||||
readonly difficultyId: string;
|
||||
@@ -23,6 +25,7 @@ export interface DungeonRunProgress {
|
||||
}
|
||||
|
||||
export interface DungeonRunRecord {
|
||||
readonly biteSizedSelection?: BiteSizedRunSelection;
|
||||
readonly id: string;
|
||||
readonly dungeonId: string;
|
||||
readonly difficultyId: string;
|
||||
@@ -74,7 +77,12 @@ export function normalizeDungeonRunProgress(value: unknown): DungeonRunProgress
|
||||
|| (input.durationMs !== null && (input.finishedAt === null || input.durationMs < 1))
|
||||
|| (input.finishedAt !== null && (input.durationMs === null || input.party.length === 0
|
||||
|| input.durationMs !== Math.max(1, Math.round(input.finishedAt - input.startedAt!))))) return null;
|
||||
let biteSizedSelection: BiteSizedRunSelection | undefined;
|
||||
try { if (input.biteSizedSelection !== undefined) biteSizedSelection = normalizeBiteSizedSelection(input.biteSizedSelection); }
|
||||
catch { return null; }
|
||||
if (biteSizedSelection && biteSizedSelection.dungeonId !== input.dungeonId) return null;
|
||||
return { id: input.id, dungeonId: input.dungeonId, difficultyId: input.difficultyId,
|
||||
...(biteSizedSelection ? { biteSizedSelection } : {}),
|
||||
...(input.bossDifficulty !== undefined ? { bossDifficulty: input.bossDifficulty } : {}),
|
||||
startedAt: input.startedAt, finishedAt: input.finishedAt, durationMs: input.durationMs,
|
||||
timed: input.timed, killedBossIds: [...new Set(input.killedBossIds)],
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { preparedBiteSizedRunSelection } from "../app/biteSizedLayoutContent";
|
||||
import { releaseDungeonGltfCache } from "./dungeonAssetCache";
|
||||
import { clearDungeonRuntimeCaches } from "./dungeonRuntimeReset";
|
||||
import { clearMobRuntimeRegistry } from "./mobRuntimeRegistry";
|
||||
@@ -11,6 +13,7 @@ import {
|
||||
dungeonCanEnter,
|
||||
dungeonDefinitionById,
|
||||
prepareDungeonSessionDefinition,
|
||||
legacyBiteSizedRunSelection,
|
||||
type DungeonId,
|
||||
} from "./dungeonRegistry";
|
||||
import type { WorldSpawn } from "./dungeonTypes";
|
||||
@@ -32,6 +35,7 @@ import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
import { dungeonRouteDefinition } from "./dungeonRoutes";
|
||||
|
||||
export interface DungeonSessionOptions {
|
||||
readonly biteSizedSelection?: BiteSizedRunSelection;
|
||||
readonly bossDifficulty?: number;
|
||||
readonly gameMode?: GameMode;
|
||||
readonly spawn?: WorldSpawn;
|
||||
@@ -63,8 +67,11 @@ export function activateDungeonSession(
|
||||
const testingSource = bossTestingSourceDungeonId(dungeonId);
|
||||
const testing = Boolean(testingSource && options.gmTestMode && options.bossTest?.sourceDungeonId === testingSource);
|
||||
const dungeonSimulation = Boolean(options.dungeonSimulation && options.gmTestMode && dungeonRouteDefinition(dungeonId));
|
||||
const suppliedSelection = options.biteSizedSelection ?? options.runProgress?.biteSizedSelection
|
||||
?? (options.preserveCharacter ? useDungeonRunStore.getState().progress?.biteSizedSelection : undefined)
|
||||
?? (options.runId ? preparedBiteSizedRunSelection(dungeonId, options.runId) : undefined);
|
||||
if (!definition || (!testing && !dungeonSimulation && !dungeonCanEnter(dungeonId))
|
||||
|| !dungeonContentIsUsable(testing ? testingSource! : dungeonId)) {
|
||||
|| !dungeonContentIsUsable(testing ? testingSource! : suppliedSelection?.spot.sourceDungeonId ?? dungeonId)) {
|
||||
return false;
|
||||
}
|
||||
if ((difficultyId !== undefined && !definition.difficultyVariants.some((variant) => variant.id === difficultyId))
|
||||
@@ -78,7 +85,15 @@ export function activateDungeonSession(
|
||||
options.runProgress, options.legacyResume, bossDifficulty, options.runId);
|
||||
}
|
||||
const runSeed = useDungeonRunStore.getState().progress?.id ?? options.runId ?? "catalog-template";
|
||||
definition = prepareDungeonSessionDefinition(dungeonId, runSeed) ?? definition;
|
||||
const legacySelection = options.runProgress && !options.runProgress.biteSizedSelection
|
||||
? legacyBiteSizedRunSelection(dungeonId, runSeed) : undefined;
|
||||
definition = prepareDungeonSessionDefinition(dungeonId, runSeed,
|
||||
suppliedSelection ?? legacySelection ?? preparedBiteSizedRunSelection(dungeonId, runSeed),
|
||||
difficultyId ?? definition.defaultDifficultyId) ?? definition;
|
||||
const progress = useDungeonRunStore.getState().progress;
|
||||
if (progress && definition.biteSizedSelection) useDungeonRunStore.setState({ progress: {
|
||||
...progress, biteSizedSelection: definition.biteSizedSelection, ...(options.gmTestMode ? { timed: false } : {}),
|
||||
} });
|
||||
activateDungeonEncounterDefinitions(
|
||||
dungeonId,
|
||||
definition.encounters ?? [],
|
||||
@@ -159,5 +174,6 @@ export function restartActiveDungeonSession(): boolean {
|
||||
preserveCharacter: true,
|
||||
bossDifficulty: game.activeBossDifficulty,
|
||||
encounterSnapshots,
|
||||
biteSizedSelection: useDungeonRunStore.getState().progress?.biteSizedSelection,
|
||||
});
|
||||
}
|
||||
@@ -242,6 +242,8 @@ export interface DungeonValidationStatus {
|
||||
}
|
||||
|
||||
export interface DungeonDefinition {
|
||||
readonly contentSourceDungeonId?: string;
|
||||
readonly biteSizedSelection?: import("../../shared/bite-sized-layouts.mjs").BiteSizedRunSelection;
|
||||
readonly schemaVersion: typeof DUNGEON_DEFINITION_SCHEMA_VERSION;
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface PartyNavigationGraph {
|
||||
}
|
||||
|
||||
export interface PartyNavigationBuildOptions {
|
||||
/** Validation may retain floor triangles while gameplay routes off-mesh links through nodes. */
|
||||
readonly retainSurfaceTriangles?: boolean;
|
||||
readonly weldTolerance?: number;
|
||||
/** Defensive runtime ceiling; checked before graph allocations begin. */
|
||||
readonly maxSourceTriangles?: number;
|
||||
@@ -167,7 +169,7 @@ function connectedComponents(nodes: readonly MutableWeldedNode[]): number[][] {
|
||||
return components;
|
||||
}
|
||||
|
||||
function buildPartyNavigationTriangleNodes(
|
||||
export function buildPartyNavigationTriangleNodes(
|
||||
triangles: readonly PartyNavigationTriangle[],
|
||||
positions: readonly PartyNavigationPosition[],
|
||||
): readonly PartyNavigationTriangleNode[] {
|
||||
@@ -432,7 +434,7 @@ export function buildPartyNavigationGraph(
|
||||
|
||||
return Object.freeze({
|
||||
nodes: Object.freeze(nodes),
|
||||
...(usesOffMeshLinks ? {} : {
|
||||
...(usesOffMeshLinks && !options.retainSurfaceTriangles ? {} : {
|
||||
triangles: Object.freeze(triangles),
|
||||
triangleNodes,
|
||||
}),
|
||||
|
||||
@@ -10,6 +10,10 @@ import { MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES } from "./partyNavigationPol
|
||||
const MAX_CACHED_NAVIGATION_GRAPHS = 8;
|
||||
const graphCache = new Map<string, PartyNavigationGraph>();
|
||||
|
||||
export function cachedPartyNavigationGraph(url: string, links: readonly DungeonNavigationLink[], ramps: readonly DungeonTraversalRamp[] = [], retainSurfaceTriangles = false): PartyNavigationGraph | null {
|
||||
return graphCache.get(graphCacheKey(url, links, ramps) + (retainSurfaceTriangles ? ":floor-validation" : "")) ?? null;
|
||||
}
|
||||
|
||||
function graphCacheKey(
|
||||
url: string,
|
||||
offMeshLinks: readonly DungeonNavigationLink[],
|
||||
@@ -60,8 +64,10 @@ export function preparePartyNavigationGraph(
|
||||
scene: Group,
|
||||
offMeshLinks: readonly DungeonNavigationLink[],
|
||||
ramps: readonly DungeonTraversalRamp[] = [],
|
||||
maxSourceTriangles = MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
||||
retainSurfaceTriangles = false,
|
||||
): PartyNavigationGraph {
|
||||
const cacheKey = graphCacheKey(url, offMeshLinks, ramps);
|
||||
const cacheKey = graphCacheKey(url, offMeshLinks, ramps) + (retainSurfaceTriangles ? ":floor-validation" : "");
|
||||
const cached = graphCache.get(cacheKey);
|
||||
if (cached) {
|
||||
graphCache.delete(cacheKey);
|
||||
@@ -79,7 +85,8 @@ export function preparePartyNavigationGraph(
|
||||
}
|
||||
const bridgedLinks = new Set(ramps.flatMap((ramp) => dungeonRampNavigationBridges(ramp).map((bridge) => bridge.linkId)));
|
||||
graph = buildPartyNavigationGraph(geometry, {
|
||||
maxSourceTriangles: MAX_SYNCHRONOUS_PARTY_NAVIGATION_TRIANGLES,
|
||||
maxSourceTriangles,
|
||||
retainSurfaceTriangles,
|
||||
offMeshLinks: offMeshLinks.filter((link) => !bridgedLinks.has(link.id)),
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -112,7 +112,10 @@ describe("Randomized Bite-Sized Stockades", () => {
|
||||
expect(observedTrashLineups.size).toBeGreaterThan(1_900);
|
||||
|
||||
expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run" })).toBe(true);
|
||||
expect(dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)?.staticSpawns).toEqual(first.staticSpawns);
|
||||
const selected = dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)!;
|
||||
expect(selected.biteSizedSelection?.dungeonId).toBe(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
|
||||
expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run", biteSizedSelection: selected.biteSizedSelection })).toBe(true);
|
||||
expect(dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)?.staticSpawns).toEqual(selected.staticSpawns);
|
||||
});
|
||||
|
||||
it("uses the selected cross-dungeon boss for combat and completion", () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ export interface WowDungeonMap {
|
||||
const maps = catalog.dungeons as unknown as Readonly<Record<string, WowDungeonMap>>;
|
||||
|
||||
export function wowDungeonMapFor(dungeon: DungeonDefinition): WowDungeonMap | null {
|
||||
const map = maps[dungeon.id];
|
||||
const map = maps[dungeon.contentSourceDungeonId ?? dungeon.id];
|
||||
// A replacement environment must be reimported, never silently misaligned.
|
||||
return map?.mapId === dungeon.mapId
|
||||
&& map.coordinateTransform === dungeon.provenance.coordinateTransform
|
||||
|
||||
Reference in new issue
Block a user