132 lines
8.1 KiB
TypeScript
132 lines
8.1 KiB
TypeScript
import { requestOnlineJson } from "./onlineAccountClient";
|
|
import type { PlayerSession } from "./types";
|
|
import { dungeonLeaderboardCatalog as catalog } from "../game/dungeonLeaderboardCatalog";
|
|
import { normalizeDungeonRunProgress, type DungeonClearFilter, type DungeonLeaderboard,
|
|
type DungeonRunPlacement, type DungeonRunRecord } from "../game/dungeonRunTypes";
|
|
import { isLeaderboardFullClear, leaderboardFinalBossIds, leaderboardRunBossId } from "../game/dungeonLeaderboardRules";
|
|
import { defaultBiteSizedBoardScope } from "../../shared/bite-sized-layouts.mjs";
|
|
|
|
export interface BiteSizedBoardFilter { readonly spotId: string; readonly spotRevision: number }
|
|
function matchesSpot(entry: DungeonRunRecord, spotId?: string, spotRevision?: number): boolean {
|
|
const scope = entry.biteSizedSelection ? { spotId: entry.biteSizedSelection.spot.id, spotRevision: entry.biteSizedSelection.spot.revision } : defaultBiteSizedBoardScope(entry.dungeonId);
|
|
const defaults = defaultBiteSizedBoardScope(entry.dungeonId);
|
|
return !defaults.spotId || (scope.spotId === (spotId ?? defaults.spotId) && scope.spotRevision === (spotRevision ?? defaults.spotRevision));
|
|
}
|
|
|
|
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);
|
|
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) }];
|
|
});
|
|
} 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, bossDifficulty = 0, bossId?: string, spotId?: string, spotRevision?: number): 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))
|
|
&& matchesSpot(entry, spotId, spotRevision)
|
|
&& (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 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 spotId = record.biteSizedSelection?.spot.id;
|
|
const spotRevision = record.biteSizedSelection?.spot.revision;
|
|
const final = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "final", 0, record.bossDifficulty ?? 0, bossId, spotId, spotRevision);
|
|
const full = localDungeonLeaderboard(record.dungeonId, record.difficultyId, "full", 0, record.bossDifficulty ?? 0, bossId, spotId, spotRevision);
|
|
const runs = localRuns().filter((entry) => entry.dungeonId === record.dungeonId && entry.difficultyId === record.difficultyId
|
|
&& (entry.bossDifficulty ?? 0) === (record.bossDifficulty ?? 0)
|
|
&& (!bossId || entry.killedBossIds.includes(bossId)) && matchesSpot(entry, spotId, spotRevision));
|
|
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,
|
|
bossId?: string, spotId?: string, spotRevision?: number): 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);
|
|
if (spotId) { query.set("spot", spotId); query.set("spotRevision", String(spotRevision ?? 0)); }
|
|
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); }
|
|
}
|