Update Healer Man server
This commit is contained in:
1 parent
7449abdad2
commit
a641a6e948
9 files changed
+363
-47
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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." };
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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: () => {
|
||||
|
||||
@@ -2402,6 +2402,15 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
|
||||
.main-menu-gm-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.main-menu-gm-grid .main-menu-mode { min-height: 78px; border-color: rgba(210,177,109,.34); background: linear-gradient(135deg, rgba(103,76,39,.28), rgba(29,39,40,.58)); }
|
||||
.main-menu-gm-grid .main-menu-mode:disabled { cursor: not-allowed; opacity: .48; filter: saturate(.35); }
|
||||
.main-menu-random-modal { width: min(450px, 100%); max-height: 100%; overflow-y: auto; padding: clamp(18px, 3vw, 28px); border: 1px solid rgba(159,217,223,.48); border-radius: 8px; background: linear-gradient(150deg, rgba(21,45,48,.98), rgba(5,15,20,.99)); box-shadow: 0 30px 80px #000, inset 3px 0 0 var(--main-menu-ice); }
|
||||
.main-menu-random-modal h2 { margin: 5px 0 10px; color: #f4eddd; font: 28px Georgia, serif; font-weight: 400; }
|
||||
.main-menu-random-modal > p:not(.front-kicker) { color: #b9c9c8; font-size: 12px; line-height: 1.5; }
|
||||
.main-menu-random-modal__roles { display: grid; grid-template-columns: repeat(auto-fit, minmax(105px, 1fr)); gap: 8px; margin: 18px 0; }
|
||||
.main-menu-random-modal__role { min-height: 48px; justify-content: center; border-color: rgba(159,217,223,.35); }
|
||||
.main-menu-random-modal__role[aria-pressed="true"] { border-color: var(--main-menu-ice); background: rgba(64,116,121,.5); color: #fff; }
|
||||
.main-menu-random-modal__actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 18px; }
|
||||
.main-menu-random-modal__actions .front-button { justify-content: center; min-height: 44px; }
|
||||
.main-menu-random-modal__progress { min-height: 18px; }
|
||||
|
||||
/* Dungeon browser */
|
||||
.dungeon-select-screen {
|
||||
|
||||
@@ -68,15 +68,12 @@ export function DungeonSelectScreen() {
|
||||
const enterDungeon = useShellStore((state) => state.enterDungeon);
|
||||
const returnToMainMenu = useShellStore((state) => state.returnToMainMenu);
|
||||
const notice = useShellStore((state) => state.notice);
|
||||
const dungeonSelectionId = useShellStore((state) => state.dungeonSelectionId);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
|
||||
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
|
||||
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
|
||||
const character = activeCharacter ?? rosterCharacter;
|
||||
const firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.id === dungeonSelectionId)
|
||||
?? DUNGEON_CATALOG.find((dungeon) => dungeon.available)
|
||||
?? DUNGEON_CATALOG[0];
|
||||
const firstAvailable = DUNGEON_CATALOG.find((dungeon) => dungeon.available) ?? DUNGEON_CATALOG[0];
|
||||
const [selectedCategory, setSelectedCategory] = useState<DungeonCategoryId>(firstAvailable?.category ?? "wow");
|
||||
const [selectedDungeonId, setSelectedDungeonId] = useState<DungeonId | "">(firstAvailable?.id ?? "");
|
||||
const [rolePickerOpen, setRolePickerOpen] = useState(false);
|
||||
|
||||
+100
-6
@@ -1,7 +1,12 @@
|
||||
import { lazy, Suspense, useCallback, useMemo, useState } from "react";
|
||||
import { classById } from "../app/characterCatalog";
|
||||
import { queueRandomDungeon, type RandomDungeonQueueMode, type RandomDungeonQueueStage } from "../app/randomDungeonQueue";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||||
import { useCompanionPartyPreferences } from "../game/companionPartyPreferences";
|
||||
import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore";
|
||||
import { specializationById, specializationIconUrl } from "../game/specializationCatalog";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
@@ -125,23 +130,78 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
readonly onOpenEditor: (editor: MainMenuEditor) => void;
|
||||
}) {
|
||||
const [gmMenuOpen, setGmMenuOpen] = useState(false);
|
||||
const [randomQueueMode, setRandomQueueMode] = useState<RandomDungeonQueueMode | null>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<PartyRole | null>(null);
|
||||
const [queueing, setQueueing] = useState(false);
|
||||
const [queueStage, setQueueStage] = useState<RandomDungeonQueueStage>("rolling");
|
||||
const [queuedDungeonTitle, setQueuedDungeonTitle] = useState("");
|
||||
const [queueError, setQueueError] = useState("");
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const rosterCharacter = useShellStore(selectedCharacter);
|
||||
const openDungeons = useShellStore((state) => state.openDungeons);
|
||||
const openRandomDungeon = useCallback(() => openDungeons("random"), [openDungeons]);
|
||||
const openRandomBiteSizedDungeon = useCallback(() => openDungeons("random-bite-sized"), [openDungeons]);
|
||||
const openRandomDungeon = useCallback(() => { setRandomQueueMode("regular"); setSelectedRole(null); setQueueError(""); }, []);
|
||||
const openRandomBiteSizedDungeon = useCallback(() => { setRandomQueueMode("bite-sized"); setSelectedRole(null); setQueueError(""); }, []);
|
||||
const openManastorms = useShellStore((state) => state.openManastorms);
|
||||
const openArenas = useShellStore((state) => state.openArenas);
|
||||
const openClassHall = useShellStore((state) => state.openClassHall);
|
||||
const returnToCharacters = useShellStore((state) => state.returnToCharacters);
|
||||
const notice = useShellStore((state) => state.notice);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const enterDungeon = useShellStore((state) => state.enterDungeon);
|
||||
const openGmAdmin = useShellStore((state) => state.openGmAdmin);
|
||||
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
|
||||
const fillWithAi = useOnlineGroupStore((state) => state.fillWithAi);
|
||||
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
|
||||
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
|
||||
const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
|
||||
const contentState = useContentManagerSnapshot();
|
||||
const character = activeCharacter ?? rosterCharacter;
|
||||
const isRom = character?.categoryId === "rom";
|
||||
const isGm = sessionCanUseGmTools(session);
|
||||
const savedSpecialization = specializationById(character?.specializationId);
|
||||
const availableRoles = character
|
||||
? isRom ? partyRolesForClass(character.classId) : savedSpecialization ? [savedSpecialization.role] : []
|
||||
: [];
|
||||
|
||||
const actions = useMemo<MenuAction[]>(() => gmMenuOpen ? [
|
||||
const closeRandomQueue = useCallback(() => {
|
||||
if (!queueing) setRandomQueueMode(null);
|
||||
}, [queueing]);
|
||||
const startRandomQueue = useCallback(async () => {
|
||||
if (!randomQueueMode || !selectedRole || queueing) return;
|
||||
setQueueing(true);
|
||||
setQueueStage("rolling");
|
||||
setQueueError("");
|
||||
setQueuedDungeonTitle("");
|
||||
const result = await queueRandomDungeon({
|
||||
mode: randomQueueMode,
|
||||
role: selectedRole,
|
||||
character,
|
||||
session,
|
||||
group: onlineGroup,
|
||||
includeBetaSpecs,
|
||||
fillWithAi,
|
||||
onProgress: (stage, dungeon) => { setQueueStage(stage); setQueuedDungeonTitle(dungeon?.title ?? ""); },
|
||||
enterDungeon: (dungeonId, role, settings) => enterDungeon(dungeonId, role, settings),
|
||||
updateOnlineMember,
|
||||
startOnlineActivity: (groupSession, input) => startOnlineActivity(groupSession, input),
|
||||
failureNotice: () => useOnlineGroupStore.getState().error || useShellStore.getState().notice,
|
||||
});
|
||||
if (result.status === "error") setQueueError(result.message);
|
||||
else if (result.status === "waiting") {
|
||||
useShellStore.getState().setNotice(`Ready as ${PARTY_ROLE_LABELS[selectedRole]}. Waiting for the group leader to start.`);
|
||||
setRandomQueueMode(null);
|
||||
} else if (result.status === "started") {
|
||||
useShellStore.getState().setNotice(`Starting ${result.dungeonTitle} with your group.`);
|
||||
setRandomQueueMode(null);
|
||||
}
|
||||
setQueueing(false);
|
||||
}, [character, enterDungeon, fillWithAi, includeBetaSpecs, onlineGroup, queueing, randomQueueMode, selectedRole, session, startOnlineActivity, updateOnlineMember]);
|
||||
|
||||
const actions = useMemo<MenuAction[]>(() => randomQueueMode ? [
|
||||
...availableRoles.map((role) => ({ id: `random-queue-role-${role}`, run: () => setSelectedRole(role), enabled: !queueing })),
|
||||
{ id: "random-queue-confirm", run: () => void startRandomQueue(), enabled: Boolean(selectedRole) && !queueing },
|
||||
{ id: "random-queue-cancel", run: closeRandomQueue, enabled: !queueing },
|
||||
] : gmMenuOpen ? [
|
||||
...(isGm ? [{ id: "gm-menu-gm-tools", run: openGmAdmin, neighbors: { up: "gm-menu-close" } }] : []),
|
||||
{ id: "gm-menu-boss-testing", run: onOpenBossTesting, ...(!isGm ? { neighbors: { up: "gm-menu-close" } } : {}) },
|
||||
{ id: "gm-menu-companion-readiness", run: onOpenReadiness },
|
||||
@@ -184,11 +244,11 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
] : []),
|
||||
{ id: "mode-downloads", run: onOpenDownloads },
|
||||
{ id: "back-to-roster", run: returnToCharacters, neighbors: { down: "mode-dungeons" } },
|
||||
], [character, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, openRandomBiteSizedDungeon, openRandomDungeon, returnToCharacters]);
|
||||
], [availableRoles, character, closeRandomQueue, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, openRandomBiteSizedDungeon, openRandomDungeon, queueing, randomQueueMode, returnToCharacters, selectedRole, startRandomQueue]);
|
||||
const controller = useMenuController(actions, {
|
||||
columns: 2,
|
||||
initialId: gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
|
||||
onBack: gmMenuOpen ? () => setGmMenuOpen(false) : returnToCharacters,
|
||||
initialId: randomQueueMode ? `random-queue-role-${availableRoles[0]}` : gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
|
||||
onBack: randomQueueMode ? closeRandomQueue : gmMenuOpen ? () => setGmMenuOpen(false) : returnToCharacters,
|
||||
});
|
||||
|
||||
const characterClass = character ? classById(character.classId) : null;
|
||||
@@ -315,6 +375,40 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
</main>
|
||||
|
||||
<ControllerLegend back />
|
||||
{randomQueueMode && (
|
||||
<div className="front-modal-backdrop">
|
||||
<section className="main-menu-random-modal" role="dialog" aria-modal="true" aria-labelledby="random-queue-title">
|
||||
<p className="front-kicker">{randomQueueMode === "regular" ? "Random Dungeon" : "Random Regular Bite-Sized Dungeon"}</p>
|
||||
<h2 id="random-queue-title">Choose your role</h2>
|
||||
<p>Pick how you want to play, then queue. A dungeon will be rolled and downloaded before you enter.</p>
|
||||
<div className="main-menu-random-modal__roles" aria-label="Party role">
|
||||
{availableRoles.map((role, index) => (
|
||||
<ControllerButton key={role} controlId={`random-queue-role-${role}`}
|
||||
selectedId={controller.selectedId} select={controller.select} autoFocus={index === 0}
|
||||
className="front-button main-menu-random-modal__role" type="button"
|
||||
aria-pressed={selectedRole === role} disabled={queueing} onClick={() => setSelectedRole(role)}>
|
||||
{PARTY_ROLE_LABELS[role]}
|
||||
</ControllerButton>
|
||||
))}
|
||||
</div>
|
||||
{!availableRoles.length && <p className="front-notice">Choose a specialization in your Class Hall before queuing.</p>}
|
||||
{queueing && <p className="main-menu-random-modal__progress" role="status" aria-live="polite">
|
||||
{queueStage === "rolling" ? "Rolling a dungeon…" : queueStage === "downloading"
|
||||
? `Downloading ${queuedDungeonTitle}${contentState.installingPackId && contentState.totalBytes > 0
|
||||
? ` — ${Math.round((contentState.downloadedBytes / contentState.totalBytes) * 100)}%` : "…"}`
|
||||
: `Entering ${queuedDungeonTitle}…`}
|
||||
</p>}
|
||||
{queueError && <p className="front-notice" role="alert">{queueError}</p>}
|
||||
<div className="main-menu-random-modal__actions">
|
||||
<ControllerButton controlId="random-queue-cancel" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button" type="button" disabled={queueing} onClick={closeRandomQueue}>Cancel</ControllerButton>
|
||||
<ControllerButton controlId="random-queue-confirm" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button front-button--primary" type="button" disabled={!selectedRole || queueing}
|
||||
onClick={() => void startRandomQueue()}>{queueing ? "Queuing…" : "Queue"}</ControllerButton>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
{gmMenuOpen && (
|
||||
<div className="front-modal-backdrop">
|
||||
<section className="main-menu-gm-modal" role="dialog" aria-modal="true" aria-labelledby="gm-menu-title">
|
||||
|
||||
Reference in new issue
Block a user