import { createHunterSave } from "./data"; import { createClassInventory } from "../game/healers"; import { AVAILABLE_BOSS_IDS, BOSS_DEFINITIONS } from "../game/bossCatalog"; import { createDefaultGearProgress, GEAR_OWNER_ORDER, GEAR_SLOT_ORDER, MAX_GEAR_LEVEL, type GearProgress } from "../game/progression/gear"; import { normalizeActiveInfusionId, normalizePassiveInfusionId } from "../game/progression/infusions"; import { GROUP_DROP_TABLES, type CollectionLog, type MaterialStack } from "../game/progression/loot"; import type { BossId, HealerClassId } from "../game/types"; import type { HunterSave, SaveSlotId, SaveSlotState } from "./types"; export interface StorageAdapter { getItem(key: string): string | null; setItem(key: string, value: string): void; } type SaveMap = Partial>; const LOCAL_KEY = "i-want-to-heal:saves:local:v1"; const CLOUD_KEY = (accountId: string) => `i-want-to-heal:saves:cloud:v1:${accountId.toLowerCase()}`; const SLOT_IDS: SaveSlotId[] = [1, 2, 3]; 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; } interface LegacyHunterSave { schemaVersion?: number; slotId?: SaveSlotId; hunterName?: string; activeClassId?: HealerClassId; healers?: HunterSave["healers"]; level?: number; location?: string; playSeconds?: number; updatedAt?: string; stats?: HunterSave["stats"]; materials?: MaterialStack[]; collectionLog?: CollectionLog; gearProgress?: GearProgress; } const HEALER_IDS: HealerClassId[] = ["priest", "druid", "shaman"]; function positiveCounts(value: unknown): Record { if (!value || typeof value !== "object") return {}; return Object.fromEntries(Object.entries(value as Record).flatMap(([id, rawQuantity]) => { const quantity = Math.max(0, Math.floor(Number(rawQuantity) || 0)); return quantity > 0 ? [[id, quantity]] : []; })); } function normalizeCollectionLog(candidate: LegacyHunterSave): CollectionLog { return { dropsFound: positiveCounts(candidate.collectionLog?.dropsFound), petsFound: positiveCounts(candidate.collectionLog?.petsFound), }; } function knownMaterial(id: string) { for (const table of Object.values(GROUP_DROP_TABLES)) { const drop = Object.values(table.drops).find((candidate) => candidate.id === id); if (drop) return drop; } return undefined; } function normalizeMaterials(value: unknown, collectionLog: CollectionLog): MaterialStack[] { const quantities = new Map(); if (Array.isArray(value)) { for (const raw of value) { if (!raw || typeof raw !== "object") continue; const item = raw as Partial; if (!item.id) continue; const quantity = Math.max(0, Math.floor(Number(item.quantity) || 0)); if (quantity > 0) quantities.set(item.id, (quantities.get(item.id) ?? 0) + quantity); } } else { for (const [id, quantity] of Object.entries(collectionLog.dropsFound)) quantities.set(id, quantity); } return [...quantities].flatMap(([id, quantity]) => { const drop = knownMaterial(id); return drop ? [{ id, quantity, name: drop.name, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }] : []; }); } function normalizeGearProgress(value: unknown): GearProgress { const defaults = createDefaultGearProgress(); if (!value || typeof value !== "object") return defaults; const candidate = value as Partial; for (const ownerId of GEAR_OWNER_ORDER) { for (const slotId of GEAR_SLOT_ORDER) { const level = Math.max(0, Math.min(MAX_GEAR_LEVEL, Math.floor(Number(candidate[ownerId]?.slots?.[slotId]?.level) || 0))); defaults[ownerId].slots[slotId].level = level as GearProgress[typeof ownerId]["slots"][typeof slotId]["level"]; } defaults[ownerId].infusionAbilityId = normalizeActiveInfusionId(ownerId, candidate[ownerId]?.infusionAbilityId); defaults[ownerId].passiveInfusionId = normalizePassiveInfusionId(ownerId, candidate[ownerId]?.passiveInfusionId); } return defaults; } function normalizeBossKills(value: unknown): Record { const source = positiveCounts(value); const result: Record = {}; for (const [key, quantity] of Object.entries(source)) { const bossId = AVAILABLE_BOSS_IDS.find((id) => id === key || BOSS_DEFINITIONS[id].name === key); result[bossId ?? key] = (result[bossId ?? key] ?? 0) + quantity; } return result; } function normalizeSave(value: unknown): HunterSave | null { if (!value || typeof value !== "object") return null; const candidate = value as LegacyHunterSave; if (!candidate.slotId || !candidate.hunterName) return null; if (candidate.schemaVersion !== 5) { try { return createHunterSave(candidate.slotId, typeof candidate.updatedAt === "string" ? candidate.updatedAt : new Date(0).toISOString(), candidate.hunterName); } catch { return null; } } const collectionLog = normalizeCollectionLog(candidate); const bossKills = normalizeBossKills(candidate.stats?.bossKills); const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest"; return { schemaVersion: 5, slotId: candidate.slotId, hunterName: candidate.hunterName, activeClassId, healers: Object.fromEntries(HEALER_IDS.map((classId) => [classId, { level: Math.max(1, candidate.healers?.[classId]?.level ?? (classId === "priest" ? candidate.level ?? 1 : 1)), inventory: candidate.healers?.[classId]?.inventory ?? createClassInventory(classId), }])) as HunterSave["healers"], location: candidate.location ?? "Ember Vault Approach", playSeconds: Math.max(0, candidate.playSeconds ?? 0), updatedAt: candidate.updatedAt ?? new Date(0).toISOString(), stats: { totalBossKills: Math.max(0, candidate.stats?.totalBossKills ?? Object.values(bossKills).reduce((sum, count) => sum + count, 0)), flawlessClears: Math.max(0, candidate.stats?.flawlessClears ?? 0), alliesSaved: Math.max(0, candidate.stats?.alliesSaved ?? 0), healingDone: Math.max(0, candidate.stats?.healingDone ?? 0), bossKills, }, materials: normalizeMaterials(candidate.materials, collectionLog), collectionLog, gearProgress: normalizeGearProgress(candidate.gearProgress), }; } function parseSaveMap(raw: string | null): SaveMap { if (!raw) return {}; try { const parsed = JSON.parse(raw) as Record; if (!parsed || typeof parsed !== "object") return {}; return Object.fromEntries(Object.entries(parsed).flatMap(([id, value]) => { const save = normalizeSave(value); return save ? [[id, save]] : []; })) as SaveMap; } catch { return {}; } } function cloneSave(save: HunterSave): HunterSave { return structuredClone(save); } export class SaveRepository { constructor( private readonly storage: StorageAdapter = browserStorage(), private readonly now: () => string = () => new Date().toISOString(), ) {} list(accountId: string | null): SaveSlotState[] { const local = this.read(LOCAL_KEY); const online = accountId ? this.read(CLOUD_KEY(accountId)) : {}; return SLOT_IDS.map((id) => ({ id, local: local[id] ?? null, online: online[id] ?? null })); } create(slotId: SaveSlotId, hunterName: string): HunterSave { const save = createHunterSave(slotId, this.now(), hunterName); this.setLocal(save); return save; } touch(slotId: SaveSlotId): HunterSave | null { return this.updateLocal(slotId, (save) => ({ ...save, updatedAt: this.now() })); } updateLocal(slotId: SaveSlotId, update: (save: HunterSave) => HunterSave): HunterSave | null { const saves = this.read(LOCAL_KEY); const source = saves[slotId]; if (!source) return null; const next = { ...update(cloneSave(source)), slotId, updatedAt: this.now() }; saves[slotId] = next; this.write(LOCAL_KEY, saves); return next; } deleteLocal(slotId: SaveSlotId): void { const saves = this.read(LOCAL_KEY); delete saves[slotId]; this.write(LOCAL_KEY, saves); } copyLocal(sourceId: SaveSlotId, targetId: SaveSlotId): HunterSave | null { const saves = this.read(LOCAL_KEY); const source = saves[sourceId]; if (!source || sourceId === targetId) return null; const copy = { ...cloneSave(source), slotId: targetId, updatedAt: this.now() }; saves[targetId] = copy; this.write(LOCAL_KEY, saves); return copy; } upload(slotId: SaveSlotId, accountId: string): HunterSave | null { const local = this.read(LOCAL_KEY)[slotId]; if (!local) return null; const cloud = this.read(CLOUD_KEY(accountId)); const uploaded = { ...cloneSave(local), updatedAt: this.now() }; cloud[slotId] = uploaded; this.write(CLOUD_KEY(accountId), cloud); this.setLocal(uploaded); return uploaded; } download(slotId: SaveSlotId, accountId: string): HunterSave | null { const cloud = this.read(CLOUD_KEY(accountId))[slotId]; if (!cloud) return null; const downloaded = { ...cloneSave(cloud), slotId, updatedAt: this.now() }; this.setLocal(downloaded); return downloaded; } private setLocal(save: HunterSave): void { const saves = this.read(LOCAL_KEY); saves[save.slotId] = save; this.write(LOCAL_KEY, saves); } private read(key: string): SaveMap { const raw = this.storage.getItem(key); const saves = parseSaveMap(raw); const normalized = JSON.stringify(saves); if (raw !== normalized) this.storage.setItem(key, normalized); return saves; } private write(key: string, saves: SaveMap): void { this.storage.setItem(key, JSON.stringify(saves)); } } export function formatSaveTimestamp(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return "Unknown time"; return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", }).format(date); } export function formatPlayTime(seconds: number): string { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); return `${hours}h ${String(minutes).padStart(2, "0")}m`; }