146 lines
5.6 KiB
TypeScript
146 lines
5.6 KiB
TypeScript
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<string, string>();
|
|
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<LeaderboardEntry>;
|
|
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<LeaderboardResult>;
|
|
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<CachedLeaderboard>;
|
|
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<string, CachedLeaderboard> {
|
|
try {
|
|
const raw = this.storage.getItem(CACHE_KEY);
|
|
const parsed = raw ? JSON.parse(raw) as Record<string, unknown> : {};
|
|
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();
|