Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 15:05:14 -04:00
1 parent 8b9c95b24f
commit 14e105642c
13 files changed
+355 -12

No files matched your search

+7 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { OnlineGroupActivity } from "./onlineGroupTypes";
import { enterOnlineGroupActivity } from "./OnlineGroupCoordinator";
import { enterOnlineGroupActivity, onlineGroupActivityNeedsEntry } from "./OnlineGroupCoordinator";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
const normalReleaseIt = SHELVED_MODES_ENABLED ? it.skip : it;
@@ -34,6 +34,12 @@ function entryDependencies(enterDungeon: () => boolean) {
}
describe("online group activity entry", () => {
it("recognizes a newly published activity while the previous activity is still active", () => {
const next = { ...DUNGEON_ACTIVITY, id: "activity-dungeon-2", startedAt: 10_000 };
expect(onlineGroupActivityNeedsEntry(next, DUNGEON_ACTIVITY, [DUNGEON_ACTIVITY.id], 10_100)).toBe(true);
expect(onlineGroupActivityNeedsEntry(DUNGEON_ACTIVITY, DUNGEON_ACTIVITY, [], 10_100)).toBe(false);
});
it("enters with the leader's source variant and boss difficulty as a fresh shared run", async () => {
const dependencies = entryDependencies(() => true);
await enterOnlineGroupActivity({ ...DUNGEON_ACTIVITY, difficultyId: "heroic", bossDifficulty: 9 },
+19 -4
View File
@@ -66,11 +66,22 @@ export async function enterOnlineGroupActivity(
return entered;
}
export function onlineGroupActivityNeedsEntry(
activity: OnlineGroupActivity,
activeActivity: OnlineGroupActivity | null,
consumedActivityIds: readonly string[],
now = Date.now(),
): boolean {
return activity.id !== activeActivity?.id
&& !consumedActivityIds.includes(activity.id)
&& now - activity.startedAt <= ACTIVITY_JOIN_WINDOW_MS;
}
export function OnlineGroupCoordinator() {
const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter);
const phase = useShellStore((state) => state.phase);
const snapshot = useOnlineGroupStore((state) => state.snapshot);
const activeActivity = useOnlineGroupStore((state) => state.activeActivity);
const consumedActivityIds = useOnlineGroupStore((state) => state.consumedActivityIds);
const refresh = useOnlineGroupStore((state) => state.refresh);
const updateMember = useOnlineGroupStore((state) => state.updateMember);
@@ -113,8 +124,11 @@ export function OnlineGroupCoordinator() {
useEffect(() => {
const activity = snapshot.group?.activity;
if (!session || !activity || phase === "game" || consumedActivityIds.includes(activity.id)) return;
if (Date.now() - activity.startedAt > ACTIVITY_JOIN_WINDOW_MS) return;
if (!session || !activity || !onlineGroupActivityNeedsEntry(
activity,
activeActivity,
consumedActivityIds,
)) return;
const member = snapshot.group?.members.find((candidate) => candidate.accountId === session.ownerId);
if (!member?.character || !member.role) return;
if (pendingActivityIds.current.has(activity.id)) return;
@@ -126,7 +140,8 @@ export function OnlineGroupCoordinator() {
.finally(() => {
pendingActivityIds.current.delete(activity.id);
});
}, [consumeActivity, consumedActivityIds, installedRevision, phase, session?.ownerId, snapshot.group?.activity?.id]);
}, [activeActivity?.id, consumeActivity, consumedActivityIds, installedRevision, session?.ownerId,
snapshot.group?.activity?.id]);
return null;
}
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource";
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
import { canStartAnotherBiteSizedDungeon, startAnotherBiteSizedDungeon } from "./biteSizedDungeonRuntime";
import { useOnlineGroupStore } from "./onlineGroupStore";
import { useOnlineSessionStore } from "./onlineSessionStore";
import { useShellStore } from "./shellStore";
const progress: DungeonRunProgress = {
id: "completed-bite-sized-run",
dungeonId: BITE_SIZED_STOCKADES_DUNGEON_ID,
difficultyId: "normal",
bossDifficulty: 1,
startedAt: 1_000,
finishedAt: 9_000,
durationMs: 8_000,
timed: true,
killedBossIds: ["creature-84027"],
party: [{ name: "Tester", classId: "priest", level: 17, role: "healer", npc: false }],
};
const originalShell = useShellStore.getState();
const originalGroup = useOnlineGroupStore.getState();
const originalOnline = useOnlineSessionStore.getState();
beforeEach(() => {
useOnlineGroupStore.getState().reset();
useOnlineSessionStore.getState().reset();
});
afterEach(() => {
useShellStore.setState(originalShell, true);
useOnlineGroupStore.setState(originalGroup, true);
useOnlineSessionStore.setState(originalOnline, true);
});
describe("bite-sized replay", () => {
it("starts a fresh local run and treats the local player as party leader", async () => {
const enterDungeon = vi.fn(() => true);
const saveCheckpoint = vi.fn();
useShellStore.setState({
session: { kind: "offline", ownerId: "local-owner", displayName: "Tester" },
activeDungeonRole: "healer",
enterDungeon,
saveActiveDungeonRunCheckpoint: saveCheckpoint,
});
expect(canStartAnotherBiteSizedDungeon()).toBe(true);
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
expect(saveCheckpoint).toHaveBeenCalledWith(null);
expect(enterDungeon).toHaveBeenCalledWith(BITE_SIZED_STOCKADES_DUNGEON_ID, "healer", {
difficultyId: "normal",
bossDifficulty: 1,
freshRun: true,
});
});
it("lets only the online group leader publish the next shared activity", async () => {
const startActivity = vi.fn(async () => true);
const saveCheckpoint = vi.fn();
const activity = {
id: "shared-bite-sized-run",
type: "dungeon" as const,
contentId: BITE_SIZED_STOCKADES_DUNGEON_ID,
fillWithAi: true,
partySize: 4,
startingLevel: null,
startedAt: 1_000,
};
useShellStore.setState({
session: { kind: "account", ownerId: "leader", displayName: "Leader", accessToken: "test-token" },
saveActiveDungeonRunCheckpoint: saveCheckpoint,
});
useOnlineGroupStore.setState({
startActivity,
snapshot: { invitations: [], group: { id: "group", leaderAccountId: "leader", members: [], activity,
activityRevision: 1, createdAt: 1, updatedAt: 1 } },
});
expect(canStartAnotherBiteSizedDungeon()).toBe(true);
await expect(startAnotherBiteSizedDungeon(progress)).resolves.toBe(true);
expect(startActivity).toHaveBeenCalledWith(expect.objectContaining({ ownerId: "leader" }), {
type: "dungeon",
contentId: BITE_SIZED_STOCKADES_DUNGEON_ID,
difficultyId: "normal",
bossDifficulty: 1,
partySize: 4,
});
expect(saveCheckpoint).toHaveBeenCalledWith(null);
useShellStore.setState({ session: { kind: "account", ownerId: "follower", displayName: "Follower", accessToken: "test-token" } });
expect(canStartAnotherBiteSizedDungeon()).toBe(false);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { dungeonContentIsUsable } from "../content/contentManager";
import { randomDungeonByCategory } from "../game/dungeonCatalog";
import { dungeonDefinitionById } from "../game/dungeonRegistry";
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
import { useOnlineGroupStore } from "./onlineGroupStore";
import { useOnlineSessionStore } from "./onlineSessionStore";
import { useShellStore } from "./shellStore";
export function canStartAnotherBiteSizedDungeon(): boolean {
const shell = useShellStore.getState();
const group = useOnlineGroupStore.getState().snapshot.group;
if (group) return group.leaderAccountId === shell.session?.ownerId;
const online = useOnlineSessionStore.getState().snapshot;
return !online || online.authority;
}
/** Starts a fresh random run locally, or publishes one shared activity for the whole online party. */
export async function startAnotherBiteSizedDungeon(progress: DungeonRunProgress): Promise<boolean> {
const shell = useShellStore.getState();
const groupState = useOnlineGroupStore.getState();
const group = groupState.snapshot.group;
const online = useOnlineSessionStore.getState().snapshot;
if (!canStartAnotherBiteSizedDungeon()) {
shell.setNotice("Only the party leader can start the next Bite-Sized dungeon.");
return false;
}
const next = randomDungeonByCategory(
"bite-sized",
progress.dungeonId,
Math.random,
(dungeon) => dungeonContentIsUsable(dungeon.id),
);
if (!next) {
shell.setNotice("No installed Bite-Sized dungeon is ready to enter.");
return false;
}
const definition = dungeonDefinitionById(next.id);
const difficultyId = definition?.difficultyVariants.some((difficulty) => difficulty.id === progress.difficultyId)
? progress.difficultyId
: undefined;
if (group) {
const session = shell.session;
if (!session || session.kind !== "account" || !session.accessToken) {
shell.setNotice("Reconnect before starting the next shared dungeon.");
return false;
}
const started = await groupState.startActivity(session, {
type: "dungeon",
contentId: next.id,
difficultyId,
bossDifficulty: progress.bossDifficulty,
partySize: group.activity?.partySize ?? 5,
});
if (!started) {
shell.setNotice(useOnlineGroupStore.getState().error || "The next shared dungeon could not be started.");
return false;
}
shell.saveActiveDungeonRunCheckpoint(null);
return true;
}
if (online) {
shell.setNotice("Reconnect to the party before starting the next shared dungeon.");
return false;
}
shell.saveActiveDungeonRunCheckpoint(null);
const entered = shell.enterDungeon(next.id, shell.activeDungeonRole, {
difficultyId,
bossDifficulty: progress.bossDifficulty,
freshRun: true,
});
return entered;
}