Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 22:02:18 -04:00
1 parent 8a98797fcf
commit 11934a3979
44 files changed
+3183 -329

No files matched your search

+1 -1
View File
@@ -64,7 +64,7 @@ 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).toHaveBeenCalledWith(activity.id, activity.contentId);
expect(dependencies.prepareRandomizedDungeonContent.mock.invocationCallOrder[0])
.toBeLessThan(dependencies.enterDungeon.mock.invocationCallOrder[0]);
});
+6 -6
View File
@@ -12,7 +12,7 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
import { dungeonContentPackId, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource";
import { dungeonContentPackId, isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource";
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
const GROUP_POLL_MS = 2_500;
@@ -20,7 +20,7 @@ const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
interface OnlineGroupActivityEntryDependencies {
readonly prepareDungeonContent: (dungeonId: string) => Promise<void>;
readonly prepareRandomizedDungeonContent: (seed: string) => Promise<void>;
readonly prepareRandomizedDungeonContent: (seed: string, dungeonId: string) => Promise<void>;
readonly installDungeonRuntime: () => Promise<void>;
readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean;
readonly installManastormRuntime: () => Promise<void>;
@@ -33,9 +33,9 @@ const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies
await installContentPack(dungeonContentPackId(dungeonId));
if (!dungeonContentIsUsable(dungeonId)) throw new Error("The group dungeon download could not be verified.");
},
prepareRandomizedDungeonContent: async (seed) => {
prepareRandomizedDungeonContent: async (seed, dungeonId) => {
const { ensureRandomizedBiteSizedRunContent } = await import("./randomizedBiteSizedContent");
await ensureRandomizedBiteSizedRunContent(seed);
await ensureRandomizedBiteSizedRunContent(seed, undefined, dungeonId);
},
installDungeonRuntime: async () => {
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
@@ -61,8 +61,8 @@ export async function enterOnlineGroupActivity(
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);
if (isRandomizedBiteSizedDungeonId(activity.contentId)) {
await dependencies.prepareRandomizedDungeonContent(activity.id, activity.contentId);
}
await dependencies.installDungeonRuntime();
entered = dependencies.enterDungeon(activity.contentId, role, {
+1 -1
View File
@@ -105,7 +105,7 @@ describe("bite-sized replay", () => {
prepareRandomizedContent,
})).resolves.toBe(true);
expect(prepareRandomizedContent).toHaveBeenCalledWith("next-randomized-seed");
expect(prepareRandomizedContent).toHaveBeenCalledWith("next-randomized-seed", RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
expect(saveCheckpoint).toHaveBeenCalledWith(null);
expect(enterDungeon).toHaveBeenCalledWith(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, "healer", {
difficultyId: "normal",
+5 -4
View File
@@ -23,7 +23,7 @@ interface BiteSizedReplayDependencies {
readonly loadPolicy: () => Promise<DungeonQueuePolicy>;
readonly createRunId: () => string;
readonly contentIsUsable: (dungeonId: string) => boolean;
readonly prepareRandomizedContent: (seed: string) => Promise<void>;
readonly prepareRandomizedContent: (seed: string, dungeonId: string) => Promise<void>;
}
const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
@@ -31,7 +31,7 @@ const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
loadPolicy: loadDungeonQueuePolicy,
createRunId: () => globalThis.crypto.randomUUID(),
contentIsUsable: dungeonContentIsUsable,
prepareRandomizedContent: ensureRandomizedBiteSizedRunContent,
prepareRandomizedContent: (seed, dungeonId) => ensureRandomizedBiteSizedRunContent(seed, undefined, dungeonId),
};
/** Starts a fresh random run locally, or publishes one shared activity for the whole online party. */
@@ -52,7 +52,8 @@ export async function startAnotherBiteSizedDungeon(
const policy = await dependencies.loadPolicy();
const next = randomized
? randomRandomizedBiteSizedDungeon(progress.dungeonId, dependencies.random,
(dungeon) => dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon))
(dungeon) => dungeon.id === progress.dungeonId
&& dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon))
: randomDungeonByCategory("bite-sized", progress.dungeonId, dependencies.random,
(dungeon) => dependencies.contentIsUsable(dungeon.id) && dungeonEnabledForQueue(policy, dungeon));
if (!next) {
@@ -94,7 +95,7 @@ export async function startAnotherBiteSizedDungeon(
const runId = dependencies.createRunId();
if (randomized) {
try {
await dependencies.prepareRandomizedContent(runId);
await dependencies.prepareRandomizedContent(runId, next.id);
} catch (caught) {
shell.setNotice(caught instanceof Error ? caught.message : "The randomized creature download failed.");
return false;
+17
View File
@@ -58,7 +58,24 @@ describe("offline dungeon leaderboard", () => {
};
saveLocalDungeonRun(run);
expect(localDungeonLeaderboard(biteSized.id, difficulty.id, "full").entries)
.toEqual([]);
expect(localDungeonLeaderboard(biteSized.id, difficulty.id, "full", 0, 0, rolledBossId).entries)
.toEqual([expect.objectContaining({ id: run.id, killedBossIds: [rolledBossId], fullClear: true })]);
expect(localDungeonRunPlacement(run)).toEqual({ finalRank: 1, finalTotal: 1, fullRank: 1, fullTotal: 1 });
});
it("keeps local RoM boss times on separate boards", () => {
const rom = catalog.dungeons.find((entry) => entry.id === "bite-sized-paspers-shrine-hafiz")!;
const bossIds = leaderboardFinalBossIds(rom.difficulties[0]);
const first = { ...record("rom-first-boss", 3000), dungeonId: rom.id,
difficultyId: rom.defaultDifficultyId, killedBossIds: [bossIds[0]] };
const second = { ...record("rom-second-boss", 4000), dungeonId: rom.id,
difficultyId: rom.defaultDifficultyId, killedBossIds: [bossIds[1]] };
saveLocalDungeonRun(first); saveLocalDungeonRun(second);
expect(localDungeonLeaderboard(rom.id, rom.defaultDifficultyId, "final", 0, 0, bossIds[0]).entries
.map((entry) => entry.id)).toEqual([first.id]);
expect(localDungeonLeaderboard(rom.id, rom.defaultDifficultyId, "final", 0, 0, bossIds[1]).entries
.map((entry) => entry.id)).toEqual([second.id]);
expect(localDungeonRunPlacement(second)).toEqual({ finalRank: 1, finalTotal: 1, fullRank: 1, fullTotal: 1 });
});
it("retains the local time after a failed upload and uses the same run id when retried", async () => {
const run = record("offline-retry-test", 4321);
+20 -7
View File
@@ -1,9 +1,9 @@
import { requestOnlineJson } from "./onlineAccountClient";
import type { PlayerSession } from "./types";
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
import { dungeonLeaderboardCatalog as catalog } from "../game/dungeonLeaderboardCatalog";
import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard,
type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes";
import { isLeaderboardFullClear, leaderboardFinalBossIds } from "../game/dungeonLeaderboardRules";
import { isLeaderboardFullClear, leaderboardFinalBossIds, leaderboardRunBossId } from "../game/dungeonLeaderboardRules";
const STORAGE_KEY = "healer-man:dungeon-runs:v1";
const listeners = new Set<() => void>();
@@ -27,6 +27,7 @@ function localRuns(): DungeonRunRecord[] {
const finalBossIds = difficulty ? leaderboardFinalBossIds(difficulty) : [];
if (!progress || progress.finishedAt === null || progress.durationMs === null || !dungeon
|| !difficulty || !finalBossIds.some((id) => progress.killedBossIds.includes(id))
|| (difficulty.finalBossIds?.length && finalBossIds.filter((id) => progress.killedBossIds.includes(id)).length !== 1)
|| progress.killedBossIds.some((id) => !difficulty.bossIds.includes(id))) return [];
return [{ ...value, ...progress, durationMs: progress.durationMs, finishedAt: progress.finishedAt,
fullClear: isLeaderboardFullClear(difficulty, progress.killedBossIds) }];
@@ -43,9 +44,14 @@ export function saveLocalDungeonRun(record: DungeonRunRecord): void {
}
export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
filter: DungeonClearFilter, offset = 0, bossDifficulty = 0): DungeonLeaderboard {
filter: DungeonClearFilter, offset = 0, bossDifficulty = 0, bossId?: string): DungeonLeaderboard {
const difficulty = catalog.dungeons.find((entry) => entry.id === dungeonId)?.difficulties
.find((entry) => entry.id === difficultyId);
const selectedBossId = difficulty?.finalBossIds?.length
? bossId ?? leaderboardFinalBossIds(difficulty)[0] : null;
const runs = localRuns().filter((entry) => entry.dungeonId === dungeonId && entry.difficultyId === difficultyId
&& (entry.bossDifficulty ?? 0) === bossDifficulty
&& (!selectedBossId || entry.killedBossIds.includes(selectedBossId))
&& (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)
@@ -53,19 +59,26 @@ export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
}
export function localDungeonRunPlacement(record: DungeonRunRecord): DungeonRunPlacement {
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final", 0, record.bossDifficulty ?? 0);
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full", 0, record.bossDifficulty ?? 0);
const difficulty = catalog.dungeons.find((entry) => entry.id === record.dungeonId)?.difficulties
.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 runs = localRuns().filter((entry) => entry.dungeonId === record.dungeonId && entry.difficultyId === record.difficultyId
&& (entry.bossDifficulty ?? 0) === (record.bossDifficulty ?? 0));
&& (entry.bossDifficulty ?? 0) === (record.bossDifficulty ?? 0)
&& (!bossId || entry.killedBossIds.includes(bossId)));
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 };
}
export async function fetchDungeonLeaderboard(dungeonId: string, difficultyId: string,
filter: DungeonClearFilter, offset: number, signal?: AbortSignal, bossDifficulty = 0): Promise<DungeonLeaderboard> {
filter: DungeonClearFilter, offset: number, signal?: AbortSignal, bossDifficulty = 0,
bossId?: string): 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);
return requestOnlineJson(`/api/dungeon-leaderboards?${query}`, { signal });
}
+1 -1
View File
@@ -85,7 +85,7 @@ function choosePracticeDungeon(pool: RacePool): string | null {
return entries.filter((entry) => entry.available)[0]?.id ?? null;
}
async function prepareContent(dungeonId: string, seed: string): Promise<void> {
if (dungeonId.startsWith("randomized-bite-sized-")) await ensureRandomizedBiteSizedRunContent(seed);
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.");
}
@@ -3,6 +3,7 @@ import {
ensureRandomizedBiteSizedRunContent,
randomizedBiteSizedModelUrls,
} from "./randomizedBiteSizedContent";
import { RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID } from "../game/dungeonContentSource";
describe("randomized bite-sized run content", () => {
it("derives only the distinct model URLs selected by the shared run seed", () => {
@@ -14,6 +15,13 @@ describe("randomized bite-sized run content", () => {
expect(first.every((url) => url.startsWith("/assets/") && url.endsWith(".glb"))).toBe(true);
});
it("prepares the selected Bite-Sized source pool for the same shared seed", () => {
const urls = randomizedBiteSizedModelUrls("asset-seed", RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID);
expect(urls).toEqual(randomizedBiteSizedModelUrls("asset-seed", RANDOMIZED_BITE_SIZED_AVAILABLE_DUNGEON_ID));
expect(urls.length).toBeGreaterThan(1);
expect(new Set(urls).size).toBe(urls.length);
});
it("installs the base location and only the rolled creature model packs before entry", async () => {
const installPack = vi.fn(async () => undefined);
const installUrls = vi.fn(async () => undefined);
+5 -4
View File
@@ -10,8 +10,8 @@ import {
} from "../game/dungeonContentSource";
import { randomizedBiteSizedDefinitionForSeed } from "../game/dungeonRegistry";
export function randomizedBiteSizedModelUrls(seed: string): readonly string[] {
const definition = randomizedBiteSizedDefinitionForSeed(seed);
export function randomizedBiteSizedModelUrls(seed: string, dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID): readonly string[] {
const definition = randomizedBiteSizedDefinitionForSeed(seed, dungeonId);
return [...new Set(Object.values(definition.entities).flatMap((entity) => (
entity.visual.model?.url ? [entity.visual.model.url] : []
)))].sort();
@@ -37,11 +37,12 @@ const DEFAULT_DEPENDENCIES: RandomizedContentDependencies = {
export async function ensureRandomizedBiteSizedRunContent(
seed: string,
dependencies: RandomizedContentDependencies = DEFAULT_DEPENDENCIES,
dungeonId = RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
): Promise<void> {
if (dependencies.development) return;
const basePackId = dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
const basePackId = dungeonContentPackId(dungeonId);
if (!dependencies.packIsUsable(basePackId) || dependencies.packHasUpdate(basePackId)) {
await dependencies.installPack(basePackId);
}
await dependencies.installUrls(randomizedBiteSizedModelUrls(seed));
await dependencies.installUrls(randomizedBiteSizedModelUrls(seed, dungeonId));
}