Release Healer Man 0.1.22

This commit is contained in:
phenom committed 2026-09-09 11:21:33 -04:00
1 parent f6f2375145
commit 7f6001723a
144 files changed
+54107 -690

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from "vitest";
import type { OnlineGroupActivity } from "./onlineGroupTypes";
import { enterOnlineGroupActivity } from "./OnlineGroupCoordinator";
const DUNGEON_ACTIVITY: OnlineGroupActivity = {
id: "activity-dungeon-1",
type: "dungeon",
contentId: "deadmines",
fillWithAi: true,
partySize: 5,
startingLevel: null,
startedAt: 1,
};
function entryDependencies(enterDungeon: () => boolean) {
return {
installDungeonRuntime: vi.fn(async () => undefined),
enterDungeon: vi.fn(enterDungeon),
installManastormRuntime: vi.fn(async () => undefined),
enterManastorm: vi.fn(() => true),
};
}
describe("online group activity entry", () => {
it("leaves a dungeon activity unconsumed when dungeon entry fails", async () => {
const consumeActivity = vi.fn();
const dependencies = entryDependencies(() => false);
await expect(enterOnlineGroupActivity(
DUNGEON_ACTIVITY,
"healer",
consumeActivity,
dependencies,
)).resolves.toBe(false);
expect(dependencies.installDungeonRuntime).toHaveBeenCalledOnce();
expect(dependencies.enterDungeon).toHaveBeenCalledWith("deadmines", "healer");
expect(consumeActivity).not.toHaveBeenCalled();
});
it("can retry the same dungeon activity and consumes it only after entry succeeds", async () => {
const consumeActivity = vi.fn();
const enterDungeon = vi.fn()
.mockReturnValueOnce(false)
.mockReturnValueOnce(true);
const dependencies = entryDependencies(enterDungeon);
await enterOnlineGroupActivity(DUNGEON_ACTIVITY, "tank", consumeActivity, dependencies);
expect(consumeActivity).not.toHaveBeenCalled();
await expect(enterOnlineGroupActivity(
DUNGEON_ACTIVITY,
"tank",
consumeActivity,
dependencies,
)).resolves.toBe(true);
expect(dependencies.enterDungeon).toHaveBeenCalledTimes(2);
expect(consumeActivity).toHaveBeenCalledOnce();
expect(consumeActivity).toHaveBeenCalledWith(DUNGEON_ACTIVITY.id);
});
it("does not consume the activity when loading the dungeon runtime throws", async () => {
const consumeActivity = vi.fn();
const dependencies = entryDependencies(() => true);
dependencies.installDungeonRuntime.mockRejectedValueOnce(new Error("runtime unavailable"));
await expect(enterOnlineGroupActivity(
DUNGEON_ACTIVITY,
"damage",
consumeActivity,
dependencies,
)).rejects.toThrow("runtime unavailable");
expect(dependencies.enterDungeon).not.toHaveBeenCalled();
expect(consumeActivity).not.toHaveBeenCalled();
});
});
+59 -22
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { selectedCharacter, useShellStore } from "./shellStore";
import { useOnlineGroupStore } from "./onlineGroupStore";
import {
@@ -6,11 +6,56 @@ import {
defaultPartyRoleForClass,
type PartyRole,
} from "../game/partyRoles";
import { isManastormPartySize } from "../game/manastormProgress";
import { isManastormPartySize, type ManastormPartySize } from "../game/manastormProgress";
import type { OnlineGroupActivity } from "./onlineGroupTypes";
import { useContentManagerSnapshot } from "../content/useContentManager";
const GROUP_POLL_MS = 2_500;
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
interface OnlineGroupActivityEntryDependencies {
readonly installDungeonRuntime: () => Promise<void>;
readonly enterDungeon: (dungeonId: string, role?: PartyRole) => boolean;
readonly installManastormRuntime: () => Promise<void>;
readonly enterManastorm: (role?: PartyRole, partySize?: ManastormPartySize, startingLevel?: number) => boolean;
}
const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies = {
installDungeonRuntime: async () => {
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
installDungeonShellRuntime();
},
enterDungeon: (dungeonId, role) => useShellStore.getState().enterDungeon(dungeonId, role),
installManastormRuntime: async () => {
const { installManastormShellRuntime } = await import("./manastormShellRuntime");
installManastormShellRuntime();
},
enterManastorm: (role, partySize, startingLevel) => useShellStore.getState().enterManastorm(
role,
partySize,
startingLevel,
),
};
export async function enterOnlineGroupActivity(
activity: OnlineGroupActivity,
role: PartyRole,
consumeActivity: (activityId: string) => void,
dependencies: OnlineGroupActivityEntryDependencies = DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES,
): Promise<boolean> {
let entered: boolean;
if (activity.type === "dungeon") {
await dependencies.installDungeonRuntime();
entered = dependencies.enterDungeon(activity.contentId, role);
} else {
await dependencies.installManastormRuntime();
const partySize = isManastormPartySize(activity.partySize) ? activity.partySize : 5;
entered = dependencies.enterManastorm(role, partySize, activity.startingLevel ?? undefined);
}
if (entered) consumeActivity(activity.id);
return entered;
}
export function OnlineGroupCoordinator() {
const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter);
@@ -21,6 +66,8 @@ export function OnlineGroupCoordinator() {
const updateMember = useOnlineGroupStore((state) => state.updateMember);
const consumeActivity = useOnlineGroupStore((state) => state.consumeActivity);
const reset = useOnlineGroupStore((state) => state.reset);
const { installedRevision } = useContentManagerSnapshot();
const pendingActivityIds = useRef(new Set<string>());
useEffect(() => {
if (!session || session.kind !== "account" || !session.accessToken) {
@@ -60,26 +107,16 @@ export function OnlineGroupCoordinator() {
if (Date.now() - activity.startedAt > ACTIVITY_JOIN_WINDOW_MS) return;
const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId);
if (!member?.character || !member.role) return;
consumeActivity(activity.id);
void (async () => {
if (activity.type === "dungeon") {
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
installDungeonShellRuntime();
useShellStore.getState().enterDungeon(activity.contentId, member.role ?? undefined);
return;
}
const { installManastormShellRuntime } = await import("./manastormShellRuntime");
installManastormShellRuntime();
const partySize = isManastormPartySize(activity.partySize) ? activity.partySize : 5;
useShellStore.getState().enterManastorm(
member.role ?? undefined,
partySize,
activity.startingLevel ?? undefined,
);
})().catch((error) => {
useShellStore.getState().setNotice(error instanceof Error ? error.message : "The group activity could not be opened.");
});
}, [consumeActivity, consumedActivityIds, phase, session?.ownerId, snapshot.group?.activity?.id]);
if (pendingActivityIds.current.has(activity.id)) return;
pendingActivityIds.current.add(activity.id);
void enterOnlineGroupActivity(activity, member.role, consumeActivity)
.catch((error) => {
useShellStore.getState().setNotice(error instanceof Error ? error.message : "The group activity could not be opened.");
})
.finally(() => {
pendingActivityIds.current.delete(activity.id);
});
}, [consumeActivity, consumedActivityIds, installedRevision, phase, session?.ownerId, snapshot.group?.activity?.id]);
return null;
}
+5
View File
@@ -11,6 +11,7 @@ import { useShellStore } from "./shellStore";
import { registerDungeonShellRuntime } from "./dungeonShellRuntimeBridge";
import { touchCharacter } from "./accountRepository";
import { specializationById } from "../game/specializationCatalog";
import { dungeonContentIsUsable } from "../content/contentManager";
export function installDungeonShellRuntime(): void {
registerDungeonShellRuntime({ enterDungeon });
@@ -22,6 +23,10 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
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." });
+16 -1
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createDefaultAppearance } from "./characterCatalog";
import { useShellStore } from "./shellStore";
import { installDungeonShellRuntime } from "./dungeonShellRuntime";
@@ -79,6 +79,10 @@ function prepareSelectedCharacter(): void {
}
describe("shell store", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
beforeEach(() => {
useShellStore.setState({
phase: "login",
@@ -167,6 +171,17 @@ describe("shell store", () => {
expect(useGameStore.getState().distanceTravelled).toBe(42);
});
it("rejects a shipped dungeon whose production content pack is not usable", () => {
vi.stubEnv("DEV", false);
prepareSelectedCharacter();
useShellStore.getState().openMainMenu();
useShellStore.getState().openDungeons();
expect(useShellStore.getState().enterDungeon("deadmines", "healer")).toBe(false);
expect(useShellStore.getState().phase).toBe("dungeons");
expect(useShellStore.getState().notice).toMatch(/downloads/i);
});
it("returns from gameplay to the main menu with the same character selected", () => {
prepareSelectedCharacter();
expect(useShellStore.getState().enterDungeon(DUNGEON_MANIFEST.id)).toBe(true);