Release Healer Man 0.1.39
This commit is contained in:
1 parent
b417b7c3c3
commit
ed0d2fea3d
51 files changed
+17811
-49
No files matched your search
@@ -0,0 +1,44 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { localDungeonLeaderboard, localDungeonRunPlacement, saveLocalDungeonRun, submitDungeonRun } from "./dungeonLeaderboardClient";
|
||||
import type { DungeonRunRecord } from "../game/dungeonRunTypes";
|
||||
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
|
||||
const dungeon = catalog.dungeons.find((entry) => entry.id === "deadmines")!;
|
||||
const record = (id: string, durationMs: number, fullClear = true): DungeonRunRecord => ({
|
||||
id, dungeonId: dungeon.id, difficultyId: dungeon.defaultDifficultyId, durationMs, finishedAt: 100000,
|
||||
party: [{ name: "Tester", classId: "priest", level: 20, role: "healer", npc: false }],
|
||||
killedBossIds: fullClear ? dungeon.bosses.map((boss) => boss.id) : [dungeon.finalBossId], fullClear,
|
||||
});
|
||||
beforeEach(() => {
|
||||
const values = new Map<string, string>();
|
||||
vi.stubGlobal("localStorage", { getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value) });
|
||||
});
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("offline dungeon leaderboard", () => {
|
||||
it("persists full/final filters, ties, pages, and the placement of a run outside the first page", () => {
|
||||
for (let index = 0; index < 25; index += 1) saveLocalDungeonRun(record(`offline-${index}`, 1000 + index * 100));
|
||||
const fastest = record("offline-fastest", 500, false);
|
||||
saveLocalDungeonRun(fastest); saveLocalDungeonRun(fastest);
|
||||
saveLocalDungeonRun(record("offline-tie", 1000));
|
||||
const all = localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "final");
|
||||
expect(all.total).toBe(27); expect(all.entries[0].id).toBe(fastest.id);
|
||||
expect(all.entries.slice(0, 3).map((entry) => entry.rank)).toEqual([1, 2, 2]);
|
||||
expect(localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "full").total).toBe(26);
|
||||
const page = localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "final", 20);
|
||||
expect(page.entries).toHaveLength(7);
|
||||
expect(localDungeonRunPlacement(record("offline-24", 3400))).toEqual({ finalRank: 27, finalTotal: 27, fullRank: 26, fullTotal: 26 });
|
||||
});
|
||||
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);
|
||||
saveLocalDungeonRun(run);
|
||||
const fetch = vi.fn().mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ placement: { finalRank: 2, finalTotal: 3, fullRank: 1, fullTotal: 2 } }), { status: 201 }));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await expect(submitDungeonRun(run, null)).rejects.toThrow();
|
||||
expect(localDungeonLeaderboard(dungeon.id, dungeon.defaultDifficultyId, "final").entries[0].durationMs).toBe(4321);
|
||||
await expect(submitDungeonRun(run, null)).resolves.toMatchObject({ finalRank: 2 });
|
||||
expect(fetch.mock.calls.map((call) => JSON.parse(call[1].body).id)).toEqual([run.id, run.id]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { requestOnlineJson } from "./onlineAccountClient";
|
||||
import type { PlayerSession } from "./types";
|
||||
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard,
|
||||
type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes";
|
||||
|
||||
const STORAGE_KEY = "healer-man:dungeon-runs:v1";
|
||||
const listeners = new Set<() => void>();
|
||||
let memory: DungeonRunRecord[] = [];
|
||||
export const DUNGEON_LEADERBOARD_PAGE_SIZE = 20;
|
||||
export type LeaderboardScope = "world" | "device";
|
||||
|
||||
export function subscribeDungeonLeaderboard(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
function localRuns(): DungeonRunRecord[] {
|
||||
try {
|
||||
const input: unknown = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
|
||||
if (Array.isArray(input)) memory = input.flatMap((value: DungeonRunRecord) => {
|
||||
if (!value || typeof value !== "object") return [];
|
||||
const progress = normalizeDungeonRunProgress({ ...value, startedAt: value.finishedAt - value.durationMs, timed: true });
|
||||
const dungeon = catalog.dungeons.find((entry) => entry.id === progress?.dungeonId);
|
||||
const difficulty = dungeon?.difficulties.find((entry) => entry.id === progress?.difficultyId);
|
||||
if (!progress || progress.finishedAt === null || progress.durationMs === null || !dungeon
|
||||
|| !difficulty || !progress.killedBossIds.includes(difficulty.finalBossId)
|
||||
|| progress.killedBossIds.some((id) => !difficulty.bossIds.includes(id))) return [];
|
||||
return [{ ...value, ...progress, durationMs: progress.durationMs, finishedAt: progress.finishedAt,
|
||||
fullClear: difficulty.bossIds.every((id) => progress.killedBossIds.includes(id)) }];
|
||||
});
|
||||
} catch { /* In-memory results remain available if storage is disabled. */ }
|
||||
return memory;
|
||||
}
|
||||
|
||||
export function saveLocalDungeonRun(record: DungeonRunRecord): void {
|
||||
memory = [record, ...localRuns().filter((entry) => entry.id !== record.id)]
|
||||
.sort((a, b) => b.finishedAt - a.finishedAt).slice(0, 1000);
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(memory)); } catch { /* Keep the current session's results. */ }
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function localDungeonLeaderboard(dungeonId: string, difficultyId: string,
|
||||
filter: DungeonClearFilter, offset = 0): DungeonLeaderboard {
|
||||
const runs = localRuns().filter((entry) => entry.dungeonId === dungeonId && entry.difficultyId === difficultyId
|
||||
&& (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)
|
||||
.map((entry) => ({ ...entry, rank: 1 + runs.filter((other) => other.durationMs < entry.durationMs).length })) };
|
||||
}
|
||||
|
||||
export function localDungeonRunPlacement(record: DungeonRunRecord): DungeonRunPlacement {
|
||||
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final");
|
||||
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full");
|
||||
const runs = localRuns().filter((entry) => entry.dungeonId === record.dungeonId && entry.difficultyId === record.difficultyId);
|
||||
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): Promise<DungeonLeaderboard> {
|
||||
const query = new URLSearchParams({ dungeon: dungeonId, difficulty: difficultyId, clear: filter,
|
||||
offset: String(offset), limit: String(DUNGEON_LEADERBOARD_PAGE_SIZE) });
|
||||
return requestOnlineJson(`/api/dungeon-leaderboards?${query}`, { signal });
|
||||
}
|
||||
|
||||
const submissions = new Map<string, Promise<DungeonRunPlacement>>();
|
||||
export function submitDungeonRun(record: DungeonRunRecord, session: PlayerSession | null): Promise<DungeonRunPlacement> {
|
||||
const existing = submissions.get(record.id);
|
||||
if (existing) return existing;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
const submission = requestOnlineJson<{ placement: DungeonRunPlacement }>("/api/dungeon-runs", {
|
||||
method: "POST", body: JSON.stringify(record), signal: controller.signal,
|
||||
}, session?.accessToken).then((result) => {
|
||||
listeners.forEach((listener) => listener());
|
||||
return result.placement;
|
||||
}).catch((error: unknown) => { submissions.delete(record.id); throw error; })
|
||||
.finally(() => clearTimeout(timeout));
|
||||
submissions.set(record.id, submission);
|
||||
return submission;
|
||||
}
|
||||
|
||||
/** Group followers read the leader's single shared run instead of submitting duplicate entries. */
|
||||
export async function awaitSharedDungeonRunPlacement(id: string): Promise<DungeonRunPlacement> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
try {
|
||||
const response = await requestOnlineJson<{ placement: DungeonRunPlacement }>(`/api/dungeon-runs/${encodeURIComponent(id)}`,
|
||||
{ signal: controller.signal });
|
||||
return response.placement;
|
||||
} catch (error) {
|
||||
if (attempt === 7 || controller.signal.aborted) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
throw new Error("The shared run is not recorded yet.");
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
@@ -61,6 +61,8 @@ function enterDungeon(dungeonId: string, requestedRole?: PartyRole): boolean {
|
||||
if (!activateDungeonSession(dungeonId, resume?.difficultyId, {
|
||||
spawn: resume?.spawn,
|
||||
encounterSnapshots: resume?.encounters,
|
||||
runProgress: resume?.run,
|
||||
legacyResume: Boolean(resume && !resume.run),
|
||||
})) {
|
||||
useShellStore.setState({
|
||||
notice: dungeon.availabilityReason ?? "That dungeon failed its latest validation gate.",
|
||||
|
||||
Reference in new issue
Block a user