Release Healer Man 0.1.39

This commit is contained in:
phenom committed 2026-09-13 11:07:56 -04:00
1 parent b417b7c3c3
commit ed0d2fea3d
51 files changed
+17811 -49

No files matched your search

+102
View File
@@ -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); }
}