Update Healer Man server
This commit is contained in:
1 parent
15a5254380
commit
9d7e0e2f9e
71 files changed
+3490
-81
No files matched your search
@@ -14,6 +14,7 @@ import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
|
||||
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
|
||||
import { dungeonContentPackId, isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource";
|
||||
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
|
||||
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||
|
||||
const GROUP_POLL_MS = 2_500;
|
||||
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
|
||||
@@ -60,7 +61,8 @@ export async function enterOnlineGroupActivity(
|
||||
): Promise<boolean> {
|
||||
let entered: boolean;
|
||||
if (activity.type === "dungeon") {
|
||||
await dependencies.prepareDungeonContent(activity.contentId);
|
||||
if (activity.biteSizedSelection) await prepareBiteSizedLayoutContent(activity.contentId, activity.id, activity.biteSizedSelection);
|
||||
await dependencies.prepareDungeonContent(activity.biteSizedSelection?.spot.sourceDungeonId ?? activity.contentId);
|
||||
if (isRandomizedBiteSizedDungeonId(activity.contentId)) {
|
||||
await dependencies.prepareRandomizedDungeonContent(activity.id, activity.contentId);
|
||||
}
|
||||
@@ -70,6 +72,7 @@ export async function enterOnlineGroupActivity(
|
||||
bossDifficulty: activity.bossDifficulty,
|
||||
freshRun: true,
|
||||
runId: activity.id,
|
||||
...(activity.biteSizedSelection ? { biteSizedSelection: activity.biteSizedSelection } : {}),
|
||||
});
|
||||
} else {
|
||||
if (!SHELVED_MODES_ENABLED) return false;
|
||||
|
||||
@@ -75,12 +75,12 @@ describe("bite-sized replay", () => {
|
||||
expect(canStartAnotherBiteSizedDungeon()).toBe(true);
|
||||
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
|
||||
expect(saveCheckpoint).toHaveBeenCalledWith(null);
|
||||
expect(enterDungeon).toHaveBeenCalledWith(expect.stringMatching(/^bite-sized-/), "healer", {
|
||||
expect(enterDungeon).toHaveBeenCalledWith(expect.stringMatching(/^bite-sized-/), "healer", expect.objectContaining({
|
||||
difficultyId: "normal",
|
||||
bossDifficulty: 1,
|
||||
freshRun: true,
|
||||
runId: expect.any(String),
|
||||
});
|
||||
}));
|
||||
expect(enterDungeon).not.toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useOnlineSessionStore } from "./onlineSessionStore";
|
||||
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
|
||||
import { loadDungeonQueuePolicy } from "./dungeonQueuePolicyStore";
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
export function canStartAnotherBiteSizedDungeon(): boolean {
|
||||
const shell = useShellStore.getState();
|
||||
@@ -19,6 +21,7 @@ export function canStartAnotherBiteSizedDungeon(): boolean {
|
||||
}
|
||||
|
||||
interface BiteSizedReplayDependencies {
|
||||
readonly prepareBiteSized?: (id: string, seed: string) => Promise<BiteSizedRunSelection | undefined>;
|
||||
readonly random: () => number;
|
||||
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
|
||||
readonly createRunId: () => string;
|
||||
@@ -27,6 +30,7 @@ interface BiteSizedReplayDependencies {
|
||||
}
|
||||
|
||||
const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
|
||||
prepareBiteSized: prepareBiteSizedLayoutContent,
|
||||
random: Math.random,
|
||||
loadPolicy: loadDungeonQueuePolicy,
|
||||
createRunId: () => globalThis.crypto.randomUUID(),
|
||||
@@ -93,6 +97,9 @@ export async function startAnotherBiteSizedDungeon(
|
||||
return false;
|
||||
}
|
||||
const runId = dependencies.createRunId();
|
||||
let biteSizedSelection: BiteSizedRunSelection | undefined;
|
||||
try { biteSizedSelection = await dependencies.prepareBiteSized?.(next.id, runId); }
|
||||
catch (caught) { shell.setNotice(caught instanceof Error ? caught.message : "The spot content could not be prepared."); return false; }
|
||||
if (randomized) {
|
||||
try {
|
||||
await dependencies.prepareRandomizedContent(runId, next.id);
|
||||
@@ -107,6 +114,7 @@ export async function startAnotherBiteSizedDungeon(
|
||||
bossDifficulty: progress.bossDifficulty,
|
||||
freshRun: true,
|
||||
runId,
|
||||
...(biteSizedSelection ? { biteSizedSelection } : {}),
|
||||
});
|
||||
return entered;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { selectBiteSizedRun } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
|
||||
import { useShellStore } from "./shellStore";
|
||||
import { touchCharacter } from "./accountRepository";
|
||||
import { sessionCanUseGmTools } from "./releaseFeatures";
|
||||
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||
import { activateDungeonSession } from "../game/dungeonSession";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { defaultPartyRoleForClass } from "../game/partyRoles";
|
||||
import { useOnlineGroupStore } from "./onlineGroupStore";
|
||||
import { useDungeonRaceStore } from "./dungeonRaceStore";
|
||||
|
||||
export async function enterBiteSizedEditorSpot(spotId: string, mode: "edit" | "test" = "edit"): Promise<boolean> {
|
||||
const shell = useShellStore.getState();
|
||||
const layouts = useBiteSizedLayoutsStore.getState();
|
||||
const spot = layouts.draft.spots.find((s) => s.id === spotId);
|
||||
try {
|
||||
if (!sessionCanUseGmTools(shell.session)) throw new Error("Game Master access is required.");
|
||||
if (useOnlineGroupStore.getState().activeActivity) throw new Error("Leave the shared activity before opening an isolated editor session.");
|
||||
if (["queued", "preparing", "countdown", "active"].includes(useDungeonRaceStore.getState().status)) throw new Error("Leave the Dungeon Race before opening an editor session.");
|
||||
const character = shell.selectedCharacterId ? touchCharacter(shell.session!.ownerId, shell.selectedCharacterId)
|
||||
?? shell.characters.find((c) => c.id === shell.selectedCharacterId) : null;
|
||||
if (!spot || !character) throw new Error("Choose a spot and a character first.");
|
||||
const seed = `gm-spot-${globalThis.crypto.randomUUID()}`;
|
||||
const selection = selectBiteSizedRun(layouts.draft, spot.dungeonId, seed, { spotId });
|
||||
await prepareBiteSizedLayoutContent(spot.dungeonId, seed, selection);
|
||||
if (!activateDungeonSession(spot.dungeonId, undefined, { gmTestMode: true, runId: seed, biteSizedSelection: selection })) throw new Error("The selected dungeon could not be opened.");
|
||||
useBiteSizedLayoutsStore.setState({ editingSpotId: spotId, editingPointId: "party", editorMode: mode, panelFocused: false, error: null });
|
||||
useShellStore.setState({ phase: "game", activeCharacter: character, activeDungeonId: spot.dungeonId,
|
||||
activeDungeonRole: defaultPartyRoleForClass(character.classId), notice: "" });
|
||||
return true;
|
||||
} catch (error) {
|
||||
useBiteSizedLayoutsStore.setState({ error: error instanceof Error ? error.message : "Could not open the spot." });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export function leaveBiteSizedEditor() {
|
||||
useBiteSizedLayoutsStore.setState({ editorMode: null, panelFocused: false });
|
||||
useGameStore.getState().closeOverlay();
|
||||
useShellStore.setState({ phase: "bite-sized-editor", activeCharacter: null });
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BITE_SIZED_DEFAULT_CONFIG, normalizeBiteSizedConfig, selectBiteSizedRun } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { prepareBiteSizedLayoutContent, preparedBiteSizedRunSelection } from "./biteSizedLayoutContent";
|
||||
|
||||
const id = "randomized-bite-sized-stormwind-stockade";
|
||||
const selection = selectBiteSizedRun(normalizeBiteSizedConfig(BITE_SIZED_DEFAULT_CONFIG), id, "content-selected-map", { eligibleDungeonIds: ["bite-sized-forsaken-abbey-ancalon"] });
|
||||
describe("pinned spot content preparation", () => {
|
||||
it("installs the actual parent map and every rolled creature before entry", async () => {
|
||||
let usable = false;
|
||||
const installPack = vi.fn(async () => { usable = true; });
|
||||
const installUrls = vi.fn(async (_urls: readonly string[]) => undefined), refresh = vi.fn(async () => undefined);
|
||||
const result = await prepareBiteSizedLayoutContent(id, "install-map", selection, { development: false, installPack, installUrls,
|
||||
refresh, packIsUsable: () => usable, packHasUpdate: () => false });
|
||||
expect(installPack).toHaveBeenCalledExactlyOnceWith("dungeon:forsaken-abbey");
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
expect(installUrls.mock.calls[0]![0].length).toBeGreaterThan(0);
|
||||
expect(result).toEqual(selection);
|
||||
});
|
||||
it("keeps an installed pinned snapshot usable offline without a layout refresh or download", async () => {
|
||||
const installPack = vi.fn(async () => { throw new Error("Offline"); }), refresh = vi.fn(async () => { throw new Error("Offline"); });
|
||||
const result = await prepareBiteSizedLayoutContent(id, "offline-resume", selection, { development: false, installPack,
|
||||
refresh, packIsUsable: () => true, packHasUpdate: () => false, installUrls: vi.fn(async () => undefined) });
|
||||
expect(result).toEqual(selection); expect(refresh).not.toHaveBeenCalled(); expect(installPack).not.toHaveBeenCalled();
|
||||
});
|
||||
it("rejects unavailable maps and creatures without caching a partially prepared run", async () => {
|
||||
const deps = { development: false, refresh: vi.fn(async () => undefined), packIsUsable: () => false,
|
||||
packHasUpdate: () => false, installPack: vi.fn(async () => undefined), installUrls: vi.fn(async () => undefined) };
|
||||
await expect(prepareBiteSizedLayoutContent(id, "missing-map", selection, deps)).rejects.toThrow(/verified/);
|
||||
expect(preparedBiteSizedRunSelection(id, "missing-map")).toBeUndefined();
|
||||
await expect(prepareBiteSizedLayoutContent(id, "missing-creature", selection, { ...deps, packIsUsable: () => true,
|
||||
installUrls: vi.fn(async () => { throw new Error("Creature unavailable"); }) })).rejects.toThrow(/Creature unavailable/);
|
||||
expect(preparedBiteSizedRunSelection(id, "missing-creature")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { BITE_SIZED_LAYOUT_CATALOG, isLayoutBiteSizedDungeon, normalizeBiteSizedSelection, selectBiteSizedRun, type BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { contentPackIsUsable, contentPackHasUpdate, installContentPack, installContentForLogicalUrls } from "../content/contentManager";
|
||||
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
|
||||
import { useDungeonQueuePolicyStore } from "./dungeonQueuePolicyStore";
|
||||
|
||||
const prepared = new Map<string, BiteSizedRunSelection>();
|
||||
interface LayoutContentDependencies {
|
||||
readonly development: boolean;
|
||||
readonly refresh: () => Promise<void>;
|
||||
readonly packIsUsable: (id: string) => boolean;
|
||||
readonly packHasUpdate: (id: string) => boolean;
|
||||
readonly installPack: (id: string) => Promise<void>;
|
||||
readonly installUrls: (urls: readonly string[]) => Promise<void>;
|
||||
}
|
||||
const DEFAULT_DEPENDENCIES: LayoutContentDependencies = { development: import.meta.env.DEV,
|
||||
refresh: () => useBiteSizedLayoutsStore.getState().refresh(), packIsUsable: contentPackIsUsable,
|
||||
packHasUpdate: contentPackHasUpdate, installPack: installContentPack, installUrls: installContentForLogicalUrls };
|
||||
export function preparedBiteSizedRunSelection(id: string, seed: string): BiteSizedRunSelection | undefined { return prepared.get(`${id}:${seed}`); }
|
||||
export async function prepareBiteSizedLayoutContent(id: string, seed: string, pinned?: BiteSizedRunSelection, dependencies = DEFAULT_DEPENDENCIES): Promise<BiteSizedRunSelection | undefined> {
|
||||
if (!isLayoutBiteSizedDungeon(id)) return undefined;
|
||||
if (!pinned && !preparedBiteSizedRunSelection(id, seed)) await dependencies.refresh();
|
||||
const selection = pinned ? normalizeBiteSizedSelection(pinned) : preparedBiteSizedRunSelection(id, seed)
|
||||
?? selectBiteSizedRun(useBiteSizedLayoutsStore.getState().config, id, seed, {
|
||||
eligibleDungeonIds: BITE_SIZED_LAYOUT_CATALOG.dungeons.filter((d) => !useDungeonQueuePolicyStore.getState().config.disabled.biteSized.includes(d.id)).map((d) => d.id),
|
||||
});
|
||||
if (selection.dungeonId !== id) throw new Error("The run selection belongs to another activity.");
|
||||
if (!dependencies.development) {
|
||||
const pack = `dungeon:${selection.spot.sourceDungeonId}`;
|
||||
if (!dependencies.packIsUsable(pack) || dependencies.packHasUpdate(pack)) await dependencies.installPack(pack);
|
||||
if (!dependencies.packIsUsable(pack)) throw new Error("The selected spot's dungeon download could not be verified.");
|
||||
const refs = [...selection.mobs, selection.boss];
|
||||
const urls = [...new Set(refs.flatMap((ref) => {
|
||||
const creature = BITE_SIZED_LAYOUT_CATALOG.creatures.find((c) => c.sourceDungeonId === ref.sourceDungeonId && c.entityId === ref.entityId);
|
||||
return creature?.modelUrl ? [creature.modelUrl] : [];
|
||||
}))];
|
||||
await dependencies.installUrls(urls);
|
||||
}
|
||||
prepared.set(`${id}:${seed}`, selection);
|
||||
if (prepared.size > 24) prepared.delete(prepared.keys().next().value!);
|
||||
return selection;
|
||||
}
|
||||
@@ -4,6 +4,14 @@ import { dungeonLeaderboardCatalog as catalog } from "../game/dungeonLeaderboard
|
||||
import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard,
|
||||
type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes";
|
||||
import { isLeaderboardFullClear, leaderboardFinalBossIds, leaderboardRunBossId } from "../game/dungeonLeaderboardRules";
|
||||
import { defaultBiteSizedBoardScope } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
export interface BiteSizedBoardFilter { readonly spotId: string; readonly spotRevision: number }
|
||||
function matchesSpot(entry: DungeonRunRecord, spotId?: string, spotRevision?: number): boolean {
|
||||
const scope = entry.biteSizedSelection ? { spotId: entry.biteSizedSelection.spot.id, spotRevision: entry.biteSizedSelection.spot.revision } : defaultBiteSizedBoardScope(entry.dungeonId);
|
||||
const defaults = defaultBiteSizedBoardScope(entry.dungeonId);
|
||||
return !defaults.spotId || (scope.spotId === (spotId ?? defaults.spotId) && scope.spotRevision === (spotRevision ?? defaults.spotRevision));
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "healer-man:dungeon-runs:v1";
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -44,7 +52,7 @@ export function saveLocalDungeonRun(record: DungeonRunRecord): void {
|
||||
}
|
||||
|
||||
export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
|
||||
filter: DungeonClearFilter, offset = 0, bossDifficulty = 0, bossId?: string): DungeonLeaderboard {
|
||||
filter: DungeonClearFilter, offset = 0, bossDifficulty = 0, bossId?: string, spotId?: string, spotRevision?: number): DungeonLeaderboard {
|
||||
const difficulty = catalog.dungeons.find((entry) => entry.id === dungeonId)?.difficulties
|
||||
.find((entry) => entry.id === difficultyId);
|
||||
const selectedBossId = difficulty?.finalBossIds?.length
|
||||
@@ -52,6 +60,7 @@ export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
|
||||
const runs = localRuns().filter((entry) => entry.dungeonId === dungeonId && entry.difficultyId === difficultyId
|
||||
&& (entry.bossDifficulty ?? 0) === bossDifficulty
|
||||
&& (!selectedBossId || entry.killedBossIds.includes(selectedBossId))
|
||||
&& matchesSpot(entry, spotId, spotRevision)
|
||||
&& (filter === "final" || entry.fullClear)).sort((a, b) => a.durationMs - b.durationMs
|
||||
|| a.finishedAt - b.finishedAt || a.id.localeCompare(b.id));
|
||||
return { total: runs.length, entries: runs.slice(offset, offset + DUNGEON_LEADERBOARD_PAGE_SIZE)
|
||||
@@ -63,11 +72,13 @@ export function localDungeonRunPlacement(record: DungeonRunRecord): DungeonRunPl
|
||||
.find((entry) => entry.id === record.difficultyId);
|
||||
const bossId = difficulty?.finalBossIds?.length
|
||||
? leaderboardRunBossId(difficulty, record.killedBossIds) ?? undefined : undefined;
|
||||
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final", 0, record.bossDifficulty ?? 0, bossId);
|
||||
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full", 0, record.bossDifficulty ?? 0, bossId);
|
||||
const spotId = record.biteSizedSelection?.spot.id;
|
||||
const spotRevision = record.biteSizedSelection?.spot.revision;
|
||||
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final", 0, record.bossDifficulty ?? 0, bossId, spotId, spotRevision);
|
||||
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full", 0, record.bossDifficulty ?? 0, bossId, spotId, spotRevision);
|
||||
const runs = localRuns().filter((entry) => entry.dungeonId === record.dungeonId && entry.difficultyId === record.difficultyId
|
||||
&& (entry.bossDifficulty ?? 0) === (record.bossDifficulty ?? 0)
|
||||
&& (!bossId || entry.killedBossIds.includes(bossId)));
|
||||
&& (!bossId || entry.killedBossIds.includes(bossId)) && matchesSpot(entry, spotId, spotRevision));
|
||||
return { finalRank: 1 + runs.filter((entry) => entry.durationMs < record.durationMs).length,
|
||||
finalTotal: final.total, fullTotal: full.total,
|
||||
fullRank: record.fullClear ? 1 + runs.filter((entry) => entry.fullClear && entry.durationMs < record.durationMs).length : null };
|
||||
@@ -75,10 +86,11 @@ export function localDungeonRunPlacement(record: DungeonRunRecord): DungeonRunPl
|
||||
|
||||
export async function fetchDungeonLeaderboard(dungeonId: string, difficultyId: string,
|
||||
filter: DungeonClearFilter, offset: number, signal?: AbortSignal, bossDifficulty = 0,
|
||||
bossId?: string): Promise<DungeonLeaderboard> {
|
||||
bossId?: string, spotId?: string, spotRevision?: number): Promise<DungeonLeaderboard> {
|
||||
const query = new URLSearchParams({ dungeon: dungeonId, difficulty: difficultyId, clear: filter,
|
||||
offset: String(offset), limit: String(DUNGEON_LEADERBOARD_PAGE_SIZE), bossDifficulty: String(bossDifficulty) });
|
||||
if (bossId) query.set("boss", bossId);
|
||||
if (spotId) { query.set("spot", spotId); query.set("spotRevision", String(spotRevision ?? 0)); }
|
||||
return requestOnlineJson(`/api/dungeon-leaderboards?${query}`, { signal });
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { finishRaceCombatLog, startRaceCombatLog, stopRaceCombatLog,
|
||||
type RaceCombatLog } from "../game/dungeonRaceCombatLog";
|
||||
import { rollDungeonRaceAffixes, validDungeonRaceAffixes, type DungeonRaceAffixPair } from "../game/dungeonRaceAffixes";
|
||||
import { cleanupDungeonRaceAffixes } from "../game/dungeonRaceAffixLifecycle";
|
||||
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
export type RacePool = "authored" | "randomized";
|
||||
export type RaceStatus = "idle" | "queued" | "preparing" | "countdown" | "active" | "settled" | "void";
|
||||
@@ -22,6 +24,7 @@ export interface RaceHumanOpponent { readonly type: "human"; readonly name: stri
|
||||
readonly source: "wow" | "rom"; readonly ready: boolean; readonly finished: boolean }
|
||||
export type RaceOpponent = RaceHumanOpponent | RaceBot;
|
||||
export interface RaceServerState {
|
||||
readonly biteSizedSelection?: BiteSizedRunSelection;
|
||||
readonly status: RaceStatus;
|
||||
readonly serverTime: number;
|
||||
readonly joinedAt?: number;
|
||||
@@ -41,6 +44,7 @@ export interface RaceServerState {
|
||||
}
|
||||
|
||||
interface RaceState {
|
||||
biteSizedSelection: BiteSizedRunSelection | null;
|
||||
mode: "online" | "practice" | null;
|
||||
status: RaceStatus;
|
||||
matchId: string | null;
|
||||
@@ -91,10 +95,12 @@ function choosePracticeDungeon(pool: RacePool): string | null {
|
||||
const entries = pool === "randomized" ? randomizedBiteSizedDungeons() : dungeonsByCategory("bite-sized");
|
||||
return entries.filter((entry) => entry.available)[0]?.id ?? null;
|
||||
}
|
||||
async function prepareContent(dungeonId: string, seed: string): Promise<void> {
|
||||
async function prepareContent(dungeonId: string, seed: string, pinned?: BiteSizedRunSelection) {
|
||||
const selection = await prepareBiteSizedLayoutContent(dungeonId, seed, pinned);
|
||||
if (dungeonId.startsWith("randomized-bite-sized-")) await ensureRandomizedBiteSizedRunContent(seed, undefined, dungeonId);
|
||||
else if (!dungeonContentIsUsable(dungeonId)) await installContentPack(dungeonContentPackId(dungeonId));
|
||||
if (!dungeonContentIsUsable(dungeonId)) throw new Error("The dungeon content could not be verified.");
|
||||
if (!dungeonContentIsUsable(selection?.spot.sourceDungeonId ?? dungeonId)) throw new Error("The dungeon content could not be verified.");
|
||||
return selection;
|
||||
}
|
||||
|
||||
export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
@@ -104,7 +110,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
preparingSeed = seed;
|
||||
set({ preparingContent: true, error: "" });
|
||||
try {
|
||||
await prepareContent(dungeonId, seed);
|
||||
const biteSizedSelection = await prepareContent(dungeonId, seed, get().biteSizedSelection ?? undefined);
|
||||
if (raceRevision !== revision || get().seed !== seed || get().status === "void") return;
|
||||
if (!validDungeonRaceAffixes(get().affixIds)) {
|
||||
throw new Error("This race uses mechanics your client does not support. Update the game to race.");
|
||||
@@ -114,10 +120,11 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
installDungeonShellRuntime();
|
||||
const entered = useShellStore.getState().enterDungeon(dungeonId, selectedRole, {
|
||||
freshRun: true, runId: seed, difficultyId, bossDifficulty: 1, raceStartBlocked: true,
|
||||
...(biteSizedSelection ? { biteSizedSelection } : {}),
|
||||
});
|
||||
if (!entered) throw new Error(useShellStore.getState().notice || "Could not enter the race dungeon.");
|
||||
startRaceCombatLog(seed, dungeonId);
|
||||
set({ inGame: true, preparingContent: false });
|
||||
set({ inGame: true, preparingContent: false, biteSizedSelection: biteSizedSelection ?? null });
|
||||
} catch (error) {
|
||||
stopRaceCombatLog();
|
||||
if (raceRevision === revision) {
|
||||
@@ -146,6 +153,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
}
|
||||
set({ status: response.status, matchId: response.matchId ?? null, seed: response.seed ?? null,
|
||||
dungeonId: response.dungeonId ?? null, difficultyId: response.difficultyId ?? null,
|
||||
biteSizedSelection: response.biteSizedSelection ?? previous.biteSizedSelection,
|
||||
affixIds: validDungeonRaceAffixes(response.affixIds) ? response.affixIds : null,
|
||||
startAt: response.startAt ?? null,
|
||||
serverOffset: offset, opponent: response.opponent ?? null,
|
||||
@@ -158,7 +166,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
if (["settled", "void", "idle"].includes(response.status)) stopPolling();
|
||||
}
|
||||
return {
|
||||
mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null,
|
||||
mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null, biteSizedSelection: null,
|
||||
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
|
||||
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "",
|
||||
queueAt: null, botAt: null,
|
||||
@@ -306,7 +314,7 @@ export const useDungeonRaceStore = create<RaceState>((set, get) => {
|
||||
cleanupDungeonRaceAffixes();
|
||||
stopPolling(); sessionToken = null; preparingSeed = null; readyPending = false; finishPromise = null;
|
||||
stopRaceCombatLog(); pendingCombatLog = null;
|
||||
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null,
|
||||
set({ mode: null, status: "idle", matchId: null, seed: null, dungeonId: null, difficultyId: null, affixIds: null, startAt: null, biteSizedSelection: null,
|
||||
serverOffset: 0, opponent: null, opponentProgress: null, result: null, clearDurationMs: null,
|
||||
ready: false, inGame: false, preparingContent: false, toastAt: null, error: "", queueAt: null, botAt: null });
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import { registerDungeonShellRuntime, type DungeonEntrySettings } from "./dungeo
|
||||
import { touchCharacter } from "./accountRepository";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
import { preparedBiteSizedRunSelection } from "./biteSizedLayoutContent";
|
||||
|
||||
export function installDungeonShellRuntime(): void {
|
||||
registerDungeonShellRuntime({ enterDungeon });
|
||||
@@ -23,10 +24,6 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
|
||||
useShellStore.setState({ notice: "That dungeon is not available yet." });
|
||||
return false;
|
||||
}
|
||||
if (!dungeonContentIsUsable(dungeonId)) {
|
||||
useShellStore.setState({ notice: "Install this expedition from Downloads before entering." });
|
||||
return false;
|
||||
}
|
||||
const { session, selectedCharacterId, characters } = useShellStore.getState();
|
||||
if (!session || !selectedCharacterId) {
|
||||
useShellStore.setState({ notice: "Select or create a character first." });
|
||||
@@ -62,6 +59,12 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
|
||||
&& (settings?.bossDifficulty === undefined || checkpoint.run?.bossDifficulty === settings.bossDifficulty)
|
||||
? activeCharacter.dungeonRunCheckpoint
|
||||
: null;
|
||||
const biteSizedSelection = settings?.biteSizedSelection ?? resume?.run?.biteSizedSelection
|
||||
?? (settings?.runId ? preparedBiteSizedRunSelection(dungeonId, settings.runId) : undefined);
|
||||
if (!dungeonContentIsUsable(biteSizedSelection?.spot.sourceDungeonId ?? dungeonId)) {
|
||||
useShellStore.setState({ notice: "Install this expedition from Downloads before entering." });
|
||||
return false;
|
||||
}
|
||||
if (!activateDungeonSession(dungeonId, settings?.difficultyId ?? resume?.difficultyId, {
|
||||
bossDifficulty: settings?.bossDifficulty ?? resume?.run?.bossDifficulty,
|
||||
spawn: resume?.spawn,
|
||||
@@ -69,6 +72,7 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole, settings?: D
|
||||
runProgress: resume?.run,
|
||||
legacyResume: Boolean(resume && resume.run?.bossDifficulty === undefined),
|
||||
runId: settings?.runId,
|
||||
biteSizedSelection,
|
||||
})) {
|
||||
useShellStore.setState({
|
||||
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface DungeonShellRuntime {
|
||||
}
|
||||
|
||||
export interface DungeonEntrySettings {
|
||||
readonly biteSizedSelection?: import("../../shared/bite-sized-layouts.mjs").BiteSizedRunSelection;
|
||||
readonly difficultyId?: string;
|
||||
readonly bossDifficulty?: number;
|
||||
readonly freshRun?: boolean;
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface OnlineGroupMember {
|
||||
}
|
||||
|
||||
export interface OnlineGroupActivity {
|
||||
readonly biteSizedSelection?: import("../../shared/bite-sized-layouts.mjs").BiteSizedRunSelection;
|
||||
readonly difficultyId?: string;
|
||||
readonly bossDifficulty?: number;
|
||||
readonly id: string;
|
||||
|
||||
@@ -9,6 +9,8 @@ import { dungeonDefinitionById } from "../game/dungeonRegistry";
|
||||
import { classCanFillPartyRole, type PartyRole } from "../game/partyRoles";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
|
||||
import { prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||
import type { BiteSizedRunSelection } from "../../shared/bite-sized-layouts.mjs";
|
||||
|
||||
export type RandomDungeonQueueMode = "regular" | "bite-sized";
|
||||
export type RandomDungeonQueueStage = "rolling" | "downloading" | "entering";
|
||||
@@ -29,6 +31,7 @@ export interface RandomDungeonQueueOptions {
|
||||
}
|
||||
|
||||
interface RandomDungeonQueueDependencies {
|
||||
readonly prepareBiteSized?: (id: string, seed: string) => Promise<BiteSizedRunSelection | undefined>;
|
||||
readonly random: () => number;
|
||||
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
|
||||
readonly contentIsUsable: (dungeonId: string) => boolean;
|
||||
@@ -37,6 +40,7 @@ interface RandomDungeonQueueDependencies {
|
||||
}
|
||||
|
||||
const DEFAULT_DEPENDENCIES: RandomDungeonQueueDependencies = {
|
||||
prepareBiteSized: prepareBiteSizedLayoutContent,
|
||||
random: Math.random,
|
||||
loadPolicy: loadDungeonQueuePolicy,
|
||||
contentIsUsable: dungeonContentIsUsable,
|
||||
@@ -107,10 +111,13 @@ export async function queueRandomDungeon(
|
||||
}
|
||||
|
||||
await dependencies.installDungeonRuntime();
|
||||
const runId = globalThis.crypto.randomUUID();
|
||||
const biteSizedSelection = options.mode === "bite-sized" ? await dependencies.prepareBiteSized?.(dungeon.id, runId) : undefined;
|
||||
const entered = options.enterDungeon(dungeon.id, role, {
|
||||
difficultyId,
|
||||
bossDifficulty: 1,
|
||||
freshRun: true,
|
||||
...(biteSizedSelection ? { runId, biteSizedSelection } : {}),
|
||||
});
|
||||
return entered
|
||||
? { status: "entered", dungeonTitle: dungeon.title }
|
||||
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
dungeonContentPackId,
|
||||
} from "../game/dungeonContentSource";
|
||||
import { randomizedBiteSizedDefinitionForSeed } from "../game/dungeonRegistry";
|
||||
import { biteSizedDefinitionForSelection } from "../game/dungeonRegistry";
|
||||
import { preparedBiteSizedRunSelection, prepareBiteSizedLayoutContent } from "./biteSizedLayoutContent";
|
||||
import { selectBiteSizedRun } from "../../shared/bite-sized-layouts.mjs";
|
||||
import { useBiteSizedLayoutsStore } from "../game/biteSizedLayoutsStore";
|
||||
|
||||
export function randomizedBiteSizedModelUrls(seed: string, dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID): readonly string[] {
|
||||
const definition = randomizedBiteSizedDefinitionForSeed(seed, dungeonId);
|
||||
const selection = preparedBiteSizedRunSelection(dungeonId, seed) ?? selectBiteSizedRun(useBiteSizedLayoutsStore.getState().config, dungeonId, seed);
|
||||
const definition = biteSizedDefinitionForSelection(selection, seed);
|
||||
return [...new Set(Object.values(definition.entities).flatMap((entity) => (
|
||||
entity.visual.model?.url ? [entity.visual.model.url] : []
|
||||
)))].sort();
|
||||
@@ -39,6 +43,7 @@ export async function ensureRandomizedBiteSizedRunContent(
|
||||
dependencies: RandomizedContentDependencies = DEFAULT_DEPENDENCIES,
|
||||
dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
): Promise<void> {
|
||||
if (dependencies === DEFAULT_DEPENDENCIES) { await prepareBiteSizedLayoutContent(dungeonId, seed); return; }
|
||||
if (dependencies.development) return;
|
||||
const basePackId = dungeonContentPackId(dungeonId);
|
||||
if (!dependencies.packIsUsable(basePackId) || dependencies.packHasUpdate(basePackId)) {
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import type { ManastormProgress } from "../game/manastormProgress";
|
||||
import type { DungeonRunCheckpoint } from "../game/dungeonRunCheckpoint";
|
||||
import type { BossCardCollection } from "../game/bossCards";
|
||||
|
||||
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "game";
|
||||
export type ShellPhase = "login" | "characters" | "create-character" | "main-menu" | "class-hall" | "dungeons" | "manastorms" | "arenas" | "arena-game" | "gm-admin" | "bite-sized-editor" | "game";
|
||||
export type SessionKind = "account" | "offline";
|
||||
export type AccountRole = "gm";
|
||||
|
||||
|
||||
Reference in new issue
Block a user