Update Healer Man server

This commit is contained in:
phenom committed 2026-09-23 21:14:46 -04:00
1 parent 7449abdad2
commit a641a6e948
9 files changed
+363 -47

No files matched your search

+15
View File
@@ -26,6 +26,7 @@ const MANASTORM_ACTIVITY: OnlineGroupActivity = {
function entryDependencies(enterDungeon: () => boolean) {
return {
prepareDungeonContent: vi.fn(async () => undefined),
prepareRandomizedDungeonContent: vi.fn(async () => undefined),
installDungeonRuntime: vi.fn(async () => undefined),
enterDungeon: vi.fn(enterDungeon),
@@ -45,6 +46,9 @@ describe("online group activity entry", () => {
const dependencies = entryDependencies(() => true);
await enterOnlineGroupActivity({ ...DUNGEON_ACTIVITY, difficultyId: "heroic", bossDifficulty: 9 },
"damage", vi.fn(), dependencies);
expect(dependencies.prepareDungeonContent).toHaveBeenCalledWith("deadmines");
expect(dependencies.prepareDungeonContent.mock.invocationCallOrder[0])
.toBeLessThan(dependencies.enterDungeon.mock.invocationCallOrder[0]);
expect(dependencies.enterDungeon).toHaveBeenCalledWith("deadmines", "damage", {
difficultyId: "heroic", bossDifficulty: 9, freshRun: true, runId: DUNGEON_ACTIVITY.id,
});
@@ -59,10 +63,21 @@ describe("online group activity entry", () => {
await enterOnlineGroupActivity(activity, "healer", vi.fn(), dependencies);
expect(dependencies.prepareDungeonContent).toHaveBeenCalledWith(activity.contentId);
expect(dependencies.prepareRandomizedDungeonContent).toHaveBeenCalledWith(activity.id);
expect(dependencies.prepareRandomizedDungeonContent.mock.invocationCallOrder[0])
.toBeLessThan(dependencies.enterDungeon.mock.invocationCallOrder[0]);
});
it("does not enter a shared dungeon when its content download fails", async () => {
const dependencies = entryDependencies(() => true);
dependencies.prepareDungeonContent.mockRejectedValueOnce(new Error("Download failed"));
const consumeActivity = vi.fn();
await expect(enterOnlineGroupActivity(DUNGEON_ACTIVITY, "healer", consumeActivity, dependencies))
.rejects.toThrow("Download failed");
expect(dependencies.enterDungeon).not.toHaveBeenCalled();
expect(consumeActivity).not.toHaveBeenCalled();
});
it("leaves a dungeon activity unconsumed when dungeon entry fails", async () => {
const consumeActivity = vi.fn();
const dependencies = entryDependencies(() => false);
+9 -1
View File
@@ -12,12 +12,14 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource";
import { dungeonContentPackId, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
const GROUP_POLL_MS = 2_500;
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
interface OnlineGroupActivityEntryDependencies {
readonly prepareDungeonContent: (dungeonId: string) => Promise<void>;
readonly prepareRandomizedDungeonContent: (seed: string) => Promise<void>;
readonly installDungeonRuntime: () => Promise<void>;
readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean;
@@ -26,6 +28,11 @@ interface OnlineGroupActivityEntryDependencies {
}
const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies = {
prepareDungeonContent: async (dungeonId) => {
if (dungeonContentIsUsable(dungeonId)) return;
await installContentPack(dungeonContentPackId(dungeonId));
if (!dungeonContentIsUsable(dungeonId)) throw new Error("The group dungeon download could not be verified.");
},
prepareRandomizedDungeonContent: async (seed) => {
const { ensureRandomizedBiteSizedRunContent } = await import("./randomizedBiteSizedContent");
await ensureRandomizedBiteSizedRunContent(seed);
@@ -53,6 +60,7 @@ export async function enterOnlineGroupActivity(
): Promise<boolean> {
let entered: boolean;
if (activity.type === "dungeon") {
await dependencies.prepareDungeonContent(activity.contentId);
if (activity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) {
await dependencies.prepareRandomizedDungeonContent(activity.id);
}
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it, vi } from "vitest";
import { createDefaultAppearance } from "./characterCatalog";
import type { CharacterProfile, PlayerSession } from "./types";
import type { OnlineGroup } from "./onlineGroupTypes";
import { dungeonById } from "../game/dungeonCatalog";
import { dungeonContentPackId } from "../game/dungeonContentSource";
import { queueRandomDungeon, type RandomDungeonQueueOptions } from "./randomDungeonQueue";
const character: CharacterProfile = {
id: "character-1", ownerId: "player-1", name: "Remedy", raceId: "human", classId: "priest",
specializationId: "priest:holy", categoryId: "wow", gender: "female",
appearance: createDefaultAppearance(), level: 1, experience: 0, talentRanks: {},
actionBindings: {}, inventory: [], equipment: {}, location: "", createdAt: 1, lastPlayedAt: 1,
};
const offlineSession: PlayerSession = { kind: "offline", ownerId: "player-1", displayName: "Offline" };
const accountSession: PlayerSession = { kind: "account", ownerId: "player-1", displayName: "Remedy", accessToken: "test-token" };
const group: OnlineGroup = {
id: "group-1", leaderAccountId: "player-1", members: [], activity: null,
activityRevision: 0, createdAt: 1, updatedAt: 1,
};
function queueOptions(overrides: Partial<RandomDungeonQueueOptions> = {}): RandomDungeonQueueOptions {
return {
mode: "regular", role: "healer", character, session: offlineSession, group: null,
includeBetaSpecs: false, fillWithAi: true, onProgress: vi.fn(),
enterDungeon: vi.fn(() => true), updateOnlineMember: vi.fn(async () => true),
startOnlineActivity: vi.fn(async () => true), failureNotice: () => "",
...overrides,
};
}
describe("random dungeon queue", () => {
it("downloads a rolled full dungeon before entering a fresh local run", async () => {
let installed = false;
const installPack = vi.fn(async () => { installed = true; });
const installDungeonRuntime = vi.fn(async () => undefined);
const options = queueOptions();
const result = await queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => installed,
installPack,
installDungeonRuntime,
});
expect(result.status).toBe("entered");
const dungeonId = vi.mocked(options.enterDungeon).mock.calls[0][0];
expect(["wow", "rom"]).toContain(dungeonById(dungeonId)?.category);
expect(installPack).toHaveBeenCalledWith(dungeonContentPackId(dungeonId));
expect(installPack.mock.invocationCallOrder[0]).toBeLessThan(installDungeonRuntime.mock.invocationCallOrder[0]);
expect(installDungeonRuntime.mock.invocationCallOrder[0])
.toBeLessThan(vi.mocked(options.enterDungeon).mock.invocationCallOrder[0]);
expect(options.enterDungeon).toHaveBeenCalledWith(dungeonId, "healer", expect.objectContaining({ freshRun: true }));
expect(options.onProgress).toHaveBeenCalledWith("downloading", expect.objectContaining({ id: dungeonId }));
});
it("keeps regular Bite-Sized separate from the creature-randomized mode", async () => {
const installPack = vi.fn(async () => undefined);
const options = queueOptions({ mode: "bite-sized" });
await expect(queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => true,
installPack,
installDungeonRuntime: vi.fn(async () => undefined),
})).resolves.toMatchObject({ status: "entered" });
expect(options.enterDungeon).toHaveBeenCalledWith("bite-sized-stormwind-stockade", "healer", expect.any(Object));
expect(installPack).not.toHaveBeenCalled();
});
it("does not enter when the downloaded pack cannot be verified", async () => {
const options = queueOptions();
const result = await queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => false,
installPack: vi.fn(async () => undefined),
installDungeonRuntime: vi.fn(async () => undefined),
});
expect(result).toMatchObject({ status: "error", message: expect.stringMatching(/verified/i) });
expect(options.enterDungeon).not.toHaveBeenCalled();
});
it("publishes the rolled dungeon for the online leader after installation", async () => {
let installed = false;
const options = queueOptions({ session: accountSession, group });
const result = await queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => installed,
installPack: vi.fn(async () => { installed = true; }),
installDungeonRuntime: vi.fn(async () => undefined),
});
expect(result.status).toBe("started");
expect(options.updateOnlineMember).toHaveBeenCalledWith(accountSession, character, "healer");
expect(options.startOnlineActivity).toHaveBeenCalledWith(accountSession, expect.objectContaining({
type: "dungeon", partySize: 5, fillWithAi: true,
}));
expect(options.enterDungeon).not.toHaveBeenCalled();
});
it("marks a group member ready and waits for the leader's roll", async () => {
const options = queueOptions({ session: accountSession, group: { ...group, leaderAccountId: "player-2" } });
const installPack = vi.fn(async () => undefined);
await expect(queueRandomDungeon(options, {
random: () => 0,
contentIsUsable: () => false,
installPack,
installDungeonRuntime: vi.fn(async () => undefined),
})).resolves.toMatchObject({ status: "waiting" });
expect(installPack).not.toHaveBeenCalled();
expect(options.startOnlineActivity).not.toHaveBeenCalled();
});
});
+116
View File
@@ -0,0 +1,116 @@
import type { CharacterProfile, PlayerSession } from "./types";
import type { OnlineGroup, StartOnlineGroupActivityInput } from "./onlineGroupTypes";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
import { dungeonContentPackId } from "../game/dungeonContentSource";
import { randomDungeonByCategory, randomRegularDungeon, type DungeonCatalogEntry } from "../game/dungeonCatalog";
import { dungeonDefinitionById } from "../game/dungeonRegistry";
import { classCanFillPartyRole, type PartyRole } from "../game/partyRoles";
import { specializationById } from "../game/specializationCatalog";
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
export type RandomDungeonQueueMode = "regular" | "bite-sized";
export type RandomDungeonQueueStage = "rolling" | "downloading" | "entering";
export interface RandomDungeonQueueOptions {
readonly mode: RandomDungeonQueueMode;
readonly role: PartyRole;
readonly character: CharacterProfile | null;
readonly session: PlayerSession | null;
readonly group: OnlineGroup | null;
readonly includeBetaSpecs: boolean;
readonly fillWithAi: boolean;
readonly onProgress: (stage: RandomDungeonQueueStage, dungeon: DungeonCatalogEntry | null) => void;
readonly enterDungeon: (dungeonId: string, role: PartyRole, settings: DungeonEntrySettings) => boolean;
readonly updateOnlineMember: (session: PlayerSession, character: CharacterProfile, role: PartyRole) => Promise<boolean>;
readonly startOnlineActivity: (session: PlayerSession, input: StartOnlineGroupActivityInput) => Promise<boolean>;
readonly failureNotice: () => string;
}
interface RandomDungeonQueueDependencies {
readonly random: () => number;
readonly contentIsUsable: (dungeonId: string) => boolean;
readonly installPack: (packId: string) => Promise<void>;
readonly installDungeonRuntime: () => Promise<void>;
}
const DEFAULT_DEPENDENCIES: RandomDungeonQueueDependencies = {
random: Math.random,
contentIsUsable: dungeonContentIsUsable,
installPack: installContentPack,
installDungeonRuntime: async () => {
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
installDungeonShellRuntime();
},
};
export type RandomDungeonQueueResult =
| { readonly status: "entered" | "waiting" | "started"; readonly dungeonTitle?: string }
| { readonly status: "error"; readonly message: string };
/** Rolls only after Queue is pressed; downloads the selected map before local or shared entry. */
export async function queueRandomDungeon(
options: RandomDungeonQueueOptions,
dependencies: RandomDungeonQueueDependencies = DEFAULT_DEPENDENCIES,
): Promise<RandomDungeonQueueResult> {
const { character, session, group, role } = options;
if (!character || !session) return { status: "error", message: "Select a character before queuing." };
const specialization = specializationById(character.specializationId);
if ((character.categoryId !== "rom" && specialization?.role !== role)
|| !classCanFillPartyRole(character.classId, role)) {
return { status: "error", message: "That role is unavailable for this character." };
}
if (group && (session.kind !== "account" || !session.accessToken)) {
return { status: "error", message: "Reconnect before queuing with your group." };
}
try {
if (group) {
if (!await options.updateOnlineMember(session, character, role)) {
return { status: "error", message: options.failureNotice() || "The group role could not be updated." };
}
if (group.leaderAccountId !== session.ownerId) return { status: "waiting" };
}
options.onProgress("rolling", null);
const dungeon = options.mode === "regular"
? randomRegularDungeon(undefined, dependencies.random)
: randomDungeonByCategory("bite-sized", undefined, dependencies.random);
if (!dungeon) return { status: "error", message: "No dungeon is currently ready for this queue." };
if (!dependencies.contentIsUsable(dungeon.id)) {
options.onProgress("downloading", dungeon);
await dependencies.installPack(dungeonContentPackId(dungeon.id));
if (!dependencies.contentIsUsable(dungeon.id)) {
return { status: "error", message: `${dungeon.title} could not be verified after downloading.` };
}
}
const difficultyId = dungeonDefinitionById(dungeon.id)?.defaultDifficultyId;
options.onProgress("entering", dungeon);
if (group) {
const started = await options.startOnlineActivity(session, {
type: "dungeon",
contentId: dungeon.id,
difficultyId,
bossDifficulty: 1,
partySize: 5,
fillWithAi: options.fillWithAi,
includeBetaSpecs: options.includeBetaSpecs,
});
return started
? { status: "started", dungeonTitle: dungeon.title }
: { status: "error", message: options.failureNotice() || "The group dungeon could not be started." };
}
await dependencies.installDungeonRuntime();
const entered = options.enterDungeon(dungeon.id, role, {
difficultyId,
bossDifficulty: 1,
freshRun: true,
});
return entered
? { status: "entered", dungeonTitle: dungeon.title }
: { status: "error", message: options.failureNotice() || `Could not enter ${dungeon.title}.` };
} catch (error) {
return { status: "error", message: error instanceof Error ? error.message : "The random dungeon queue failed." };
}
}
-20
View File
@@ -5,7 +5,6 @@ import { installDungeonShellRuntime } from "./dungeonShellRuntime";
import { installManastormShellRuntime } from "./manastormShellRuntime";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import { DUNGEON_MANIFEST } from "../game/dungeonManifest";
import { dungeonById } from "../game/dungeonCatalog";
import { useGameStore } from "../game/store";
import { useManastormStore } from "../game/manastormStore";
import { createEmptyManastormProgress, recordManastormCheckpoint } from "../game/manastormProgress";
@@ -95,7 +94,6 @@ describe("shell store", () => {
selectedCharacterId: null,
activeCharacter: null,
activeDungeonRole: "healer",
dungeonSelectionId: null,
notice: "",
});
useGameStore.getState().resetAtEntrance();
@@ -137,24 +135,6 @@ describe("shell store", () => {
expect(useGameStore.getState().distanceTravelled).toBe(42);
});
it("opens random full and authored Bite-Sized dungeons in the existing selector", () => {
prepareSelectedCharacter();
useShellStore.getState().openMainMenu();
expect(useShellStore.getState().openDungeons("random")).toBe(true);
const fullDungeon = dungeonById(useShellStore.getState().dungeonSelectionId ?? "");
expect(fullDungeon?.available).toBe(true);
expect(["wow", "rom"]).toContain(fullDungeon?.category);
useShellStore.getState().returnToMainMenu();
expect(useShellStore.getState().openDungeons("random-bite-sized")).toBe(true);
expect(useShellStore.getState().dungeonSelectionId).toBe("bite-sized-stormwind-stockade");
useShellStore.getState().returnToMainMenu();
expect(useShellStore.getState().openDungeons()).toBe(true);
expect(useShellStore.getState().dungeonSelectionId).toBeNull();
});
it("mounts the game and resets it only after choosing the available dungeon", () => {
prepareSelectedCharacter();
useShellStore.getState().openMainMenu();
+3 -16
View File
@@ -38,7 +38,6 @@ import {
import type { ContentCategoryId } from "./contentCategories";
import type { CharacterDraft, CharacterProfile, PlayerSession, ShellPhase } from "./types";
import type { DungeonId } from "../game/dungeonRegistry";
import { randomDungeonByCategory, randomRegularDungeon } from "../game/dungeonCatalog";
import { DEFAULT_DUNGEON_ID } from "../game/dungeonDefaults";
import type { InventoryItem } from "../game/lootTypes";
import { emptyBossCardCollection, type BossCardCollection } from "../game/bossCards";
@@ -78,7 +77,6 @@ export interface ShellState {
draft: CharacterDraft;
notice: string;
activeDungeonId: DungeonId;
dungeonSelectionId: DungeonId | null;
activeDungeonRole: PartyRole;
combatPresentationSettings: CombatPresentationSettings;
beginSession: (session: PlayerSession) => void;
@@ -92,7 +90,7 @@ export interface ShellState {
createDraftCharacter: () => boolean;
deleteSelectedCharacter: () => void;
openMainMenu: () => boolean;
openDungeons: (selection?: "random" | "random-bite-sized") => boolean;
openDungeons: () => boolean;
openManastorms: () => boolean;
openArenas: () => boolean;
openGmAdmin: () => boolean;
@@ -168,7 +166,6 @@ const restored = restoredState();
export const useShellStore = create<ShellState>((set, get) => ({
...restored,
activeDungeonId: DEFAULT_DUNGEON_ID,
dungeonSelectionId: null,
activeDungeonRole: "healer",
activeCharacter: null,
draft: defaultDraft(),
@@ -322,7 +319,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ phase: "main-menu", activeCharacter: null, notice: "" });
return true;
},
openDungeons: (selection) => {
openDungeons: () => {
const { session, selectedCharacterId, characters } = get();
if (!session || !selectedCharacterId || !characters.some((character) => character.id === selectedCharacterId)) {
set({ phase: session ? "characters" : "login", notice: "Select or create a character first." });
@@ -333,17 +330,7 @@ export const useShellStore = create<ShellState>((set, get) => ({
set({ phase: "class-hall", activeCharacter: null, notice: "Choose a specialization before entering activities." });
return false;
}
const dungeon = selection === "random" ? randomRegularDungeon()
: selection === "random-bite-sized" ? randomDungeonByCategory("bite-sized")
: null;
if (selection && !dungeon) {
set({ notice: selection === "random"
? "No regular dungeon is ready to enter."
: "No regular Bite-Sized dungeon is ready to enter." });
return false;
}
set({ phase: "dungeons", activeCharacter: null, dungeonSelectionId: dungeon?.id ?? null,
notice: dungeon ? `Randomly selected ${dungeon.title}.` : "" });
set({ phase: "dungeons", activeCharacter: null, notice: "" });
return true;
},
openManastorms: () => {