forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
8a98797fcf
commit
11934a3979
44 files changed
+3183
-329
No files matched your search
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user