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 -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";