import type { LeaderboardEntry, LeaderboardResult } from "./onlineRepository"; import type { ProfileStatId, SaveSlotId } from "./types"; interface StorageAdapter { getItem(key: string): string | null; setItem(key: string, value: string): void; } export interface CachedLeaderboard { accountId: string; hunterName: string; slotId: SaveSlotId; statId: ProfileStatId; updatedAt: string; result: LeaderboardResult; } const CACHE_KEY = "i-want-to-heal:leaderboards:cache:v1"; const fallbackMemory = new Map(); const fallbackStorage: StorageAdapter = { getItem: (key) => fallbackMemory.get(key) ?? null, setItem: (key, value) => { fallbackMemory.set(key, value); }, }; function browserStorage(): StorageAdapter { try { if (typeof localStorage !== "undefined") return localStorage; } catch { // Android WebView can deny storage before its host is ready. } return fallbackStorage; } function cacheId(slotId: SaveSlotId, statId: ProfileStatId) { return `${slotId}:${statId}`; } function leaderboardEntry(value: unknown): LeaderboardEntry | null { if (!value || typeof value !== "object") return null; const candidate = value as Partial; if (!Number.isInteger(candidate.rank) || Number(candidate.rank) < 1) return null; if (typeof candidate.username !== "string" || typeof candidate.hunterName !== "string") return null; if (candidate.slotId !== 1 && candidate.slotId !== 2 && candidate.slotId !== 3) return null; if (!Number.isFinite(candidate.value) || Number(candidate.value) < 0) return null; if (candidate.secondaryValue !== undefined && (!Number.isFinite(candidate.secondaryValue) || Number(candidate.secondaryValue) < 0)) return null; return { rank: Number(candidate.rank), username: candidate.username, hunterName: candidate.hunterName, slotId: candidate.slotId, value: Number(candidate.value), ...(candidate.secondaryValue === undefined ? {} : { secondaryValue: Number(candidate.secondaryValue) }), }; } function leaderboardResult(value: unknown): LeaderboardResult | null { if (!value || typeof value !== "object") return null; const candidate = value as Partial; if (candidate.kind !== "boss" && candidate.kind !== "roguelike" && candidate.kind !== "rogue-trials-endless" && candidate.kind !== "hockey-healing" && candidate.kind !== "hockey-pvp-wins" && candidate.kind !== "hockey-pvp-boss-kills" && candidate.kind !== "blockbreaker-bricks" && candidate.kind !== "blockbreaker-time" && candidate.kind !== "blockbreaker-score" && candidate.kind !== "aether-assault") return null; if (!Array.isArray(candidate.top)) return null; const top = candidate.top.map(leaderboardEntry); if (top.some((entry) => !entry)) return null; const current = candidate.current === null ? null : leaderboardEntry(candidate.current); if (candidate.current !== null && !current) return null; return { kind: candidate.kind, ...(candidate.kind === "boss" && typeof candidate.bossId === "string" ? { bossId: candidate.bossId } : {}), top: top as LeaderboardEntry[], current, }; } function cachedLeaderboard(value: unknown): CachedLeaderboard | null { if (!value || typeof value !== "object") return null; const candidate = value as Partial; if (typeof candidate.accountId !== "string" || typeof candidate.hunterName !== "string") return null; if (candidate.slotId !== 1 && candidate.slotId !== 2 && candidate.slotId !== 3) return null; if (typeof candidate.statId !== "string" || typeof candidate.updatedAt !== "string") return null; if (Number.isNaN(Date.parse(candidate.updatedAt))) return null; const result = leaderboardResult(candidate.result); if (!result) return null; return { ...candidate, result } as CachedLeaderboard; } export class LeaderboardCache { constructor( private readonly storage: StorageAdapter = browserStorage(), private readonly now: () => string = () => new Date().toISOString(), ) {} read(slotId: SaveSlotId, statId: ProfileStatId, hunterName: string, accountId: string | null): CachedLeaderboard | null { const entry = this.readAll()[cacheId(slotId, statId)]; if (!entry || entry.slotId !== slotId || entry.statId !== statId || entry.hunterName !== hunterName) return null; if (accountId && entry.accountId !== accountId) return null; return structuredClone(entry); } write(accountId: string, hunterName: string, slotId: SaveSlotId, statId: ProfileStatId, result: LeaderboardResult): CachedLeaderboard { const entries = this.readAll(); const entry: CachedLeaderboard = { accountId, hunterName, slotId, statId, updatedAt: this.now(), result: structuredClone(result), }; entries[cacheId(slotId, statId)] = entry; this.storage.setItem(CACHE_KEY, JSON.stringify(entries)); return structuredClone(entry); } clearSlot(slotId: SaveSlotId) { const entries = this.readAll(); for (const key of Object.keys(entries)) { if (entries[key].slotId === slotId) delete entries[key]; } this.storage.setItem(CACHE_KEY, JSON.stringify(entries)); } private readAll(): Record { try { const raw = this.storage.getItem(CACHE_KEY); const parsed = raw ? JSON.parse(raw) as Record : {}; if (!parsed || typeof parsed !== "object") return {}; return Object.fromEntries(Object.entries(parsed).flatMap(([key, value]) => { const entry = cachedLeaderboard(value); return entry ? [[key, entry]] : []; })); } catch { return {}; } } } export const leaderboardCache = new LeaderboardCache();