import type { CharacterGearRecovery, CharacterProfile, GearRecoveryEquipmentReference, GearRecoveryItem, GearRecoveryReason, PlayerSession, } from "./types"; import { mergeBossCardCollections, normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards"; import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout"; import { contentCategoryForClass, isRomClassId, type RomClassId, } from "./characterCatalog"; import { isValidRomClassPair, orderedClassLoadoutKey, ROM_SECONDARY_UNLOCK_LEVEL, } from "./romDualClass"; import { inventoryItemMatchesCategory, inventoryItemUsableForCategory, normalizeInventory, type InventoryItem, } from "../game/lootTypes"; import { normalizeEquipment, type EquipmentAssignments } from "../game/equipment"; import type { GameplaySettings } from "../game/combatStore"; import { DEFAULT_COMBAT_PRESENTATION_SETTINGS, normalizeCombatPresentationSettings, type CombatPresentationSettings, } from "../game/combatPresentation"; import { createEmptyManastormProgress, normalizeManastormProgress, type ManastormProgress, } from "../game/manastormProgress"; import { abilitiesForClass, abilityTalentNodeId } from "../game/abilityCatalog"; import { normalizeSpecializationId, specializationById, } from "../game/specializationCatalog"; import { normalizeDungeonRunCheckpoint, type DungeonRunCheckpoint, } from "../game/dungeonRunCheckpoint"; import { isWow335HunterPetFamily, normalizeWow335HunterPetFamily, } from "../game/wow335HunterPetFamilySelection"; import { isCharacterStatRuleset, normalizeCharacterStatRuleset, } from "../game/characterStatRuleset"; const DATABASE_KEY = "healer-man.profile-database.v1"; const SESSION_KEY = "healer-man.session.v1"; const OFFLINE_OWNER_ID = "offline-roster"; const MAX_CHARACTERS = 10; const PBKDF2_ITERATIONS = 120_000; const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; export interface StorageLike { getItem(key: string): string | null; setItem(key: string, value: string): void; removeItem(key: string): void; } interface PasswordVerifier { salt: string; digest: string; iterations: number; } interface LocalAccount { id: string; username: string; usernameKey: string; verifier: PasswordVerifier; createdAt: number; } interface ProfileDatabase { version: 8; accounts: LocalAccount[]; rosters: Record; bossCardsByOwner: Record; presentationSettingsByOwner: Record; uiPreferencesByOwner?: Record; } export interface AccountResult { ok: boolean; session?: PlayerSession; error?: string; } export interface CharacterResult { ok: boolean; character?: CharacterProfile; error?: string; } export type RosterChangeListener = (ownerId: string, characters: readonly CharacterProfile[]) => void; let rosterChangeListener: RosterChangeListener | null = null; export function setRosterChangeListener(listener: RosterChangeListener | null): void { rosterChangeListener = listener; } function browserLocalStorage(): StorageLike | null { try { return typeof window !== "undefined" ? window.localStorage : null; } catch { return null; } } function browserSessionStorage(): StorageLike | null { try { return typeof window !== "undefined" ? window.sessionStorage : null; } catch { return null; } } function emptyDatabase(): ProfileDatabase { return { version: 8, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} }; } const CONTENT_CATEGORY_IDS = new Set(["wow", "rom", "coa"]); function isGearRecoveryReason(value: unknown): value is GearRecoveryReason { return value === "missing-category" || value === "foreign-category" || value === "invalid-native-payload"; } function normalizeGearRecovery(value: unknown): CharacterGearRecovery { if (!value || typeof value !== "object" || Array.isArray(value)) return { items: [], equipment: [] }; const record = value as { items?: unknown; equipment?: unknown }; const items: GearRecoveryItem[] = Array.isArray(record.items) ? record.items.flatMap((entry): GearRecoveryItem[] => { if (!entry || typeof entry !== "object" || Array.isArray(entry) || !Object.prototype.hasOwnProperty.call(entry, "item")) return []; const row = entry as { item: unknown; reason?: unknown }; return [{ item: row.item, reason: isGearRecoveryReason(row.reason) ? row.reason : "missing-category", }]; }) : []; const equipment: GearRecoveryEquipmentReference[] = Array.isArray(record.equipment) ? record.equipment.flatMap((entry): GearRecoveryEquipmentReference[] => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; const row = entry as Partial; if (typeof row.ownerId !== "string" || typeof row.slot !== "string" || typeof row.instanceId !== "string") return []; return [{ ownerId: row.ownerId, slot: row.slot, instanceId: row.instanceId, reason: isGearRecoveryReason(row.reason) ? row.reason : "missing-category", }]; }) : []; return { items, equipment }; } function jsonIdentity(value: unknown): string { try { return JSON.stringify(value) ?? "undefined"; } catch { return "[unserializable]"; } } /** Merges repeat cloud/profile normalization without duplicating the same recovered source row. */ function mergeGearRecovery( ...values: readonly unknown[] ): CharacterGearRecovery | undefined { const mergedItems: GearRecoveryItem[] = []; const mergedEquipment: GearRecoveryEquipmentReference[] = []; const itemCounts = new Map(); const equipmentCounts = new Map(); for (const value of values) { const recovery = normalizeGearRecovery(value); const currentItemCounts = new Map(); for (const entry of recovery.items) { const key = `${entry.reason}\n${jsonIdentity(entry.item)}`; const count = (currentItemCounts.get(key) ?? 0) + 1; currentItemCounts.set(key, count); if (count > (itemCounts.get(key) ?? 0)) mergedItems.push(entry); } for (const [key, count] of currentItemCounts) { itemCounts.set(key, Math.max(itemCounts.get(key) ?? 0, count)); } for (const entry of recovery.equipment) { const key = jsonIdentity(entry); if (equipmentCounts.has(key)) continue; equipmentCounts.set(key, 1); mergedEquipment.push(entry); } } return mergedItems.length || mergedEquipment.length ? { items: mergedItems, equipment: mergedEquipment } : undefined; } function recoveryReasonForCategory( category: unknown, ownerCategory: CharacterProfile["categoryId"], normalizedItem?: InventoryItem, ): GearRecoveryReason { if (CONTENT_CATEGORY_IDS.has(String(category)) && category !== ownerCategory) return "foreign-category"; if (category === "rom" && ownerCategory === "rom" && normalizedItem && !inventoryItemUsableForCategory(normalizedItem, ownerCategory)) return "invalid-native-payload"; return "missing-category"; } function incomingInventoryMatchesCategory( inventory: readonly InventoryItem[], ownerCategory: NonNullable, ): boolean { for (const candidate of inventory as readonly unknown[]) { if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false; const item = candidate as Record; const category = item.contentCategory; if (!(category === undefined && ownerCategory === "wow") && !inventoryItemMatchesCategory(item as Pick, ownerCategory)) return false; const normalized = normalizeInventory([candidate], { ownerCategory })[0]; if (!normalized || !inventoryItemUsableForCategory(normalized, ownerCategory)) return false; } return true; } function equipmentReferencesRecoveredItem( assignments: EquipmentAssignments, recovery: CharacterGearRecovery | undefined, ): boolean { if (!assignments || typeof assignments !== "object" || Array.isArray(assignments)) return false; if (!recovery?.items.length && !recovery?.equipment.length) return false; const recoveredIds = new Set(); for (const entry of recovery.items) { if (entry.item && typeof entry.item === "object" && !Array.isArray(entry.item)) { const instanceId = (entry.item as { instanceId?: unknown }).instanceId; if (typeof instanceId === "string") recoveredIds.add(instanceId); } } for (const entry of recovery.equipment) recoveredIds.add(entry.instanceId); return Object.values(assignments).some((loadout) => ( loadout && typeof loadout === "object" && Object.values(loadout).some((instanceId) => typeof instanceId === "string" && recoveredIds.has(instanceId)) )); } function equipmentReferencesUsableInventory( assignments: EquipmentAssignments, profile: CharacterProfile, ): boolean { if (!assignments || typeof assignments !== "object" || Array.isArray(assignments)) return false; const itemById = new Map(profile.inventory.map((item) => [item.instanceId, item])); for (const loadout of Object.values(assignments as Record)) { if (!loadout || typeof loadout !== "object" || Array.isArray(loadout)) return false; for (const rawId of Object.values(loadout as Record)) { if (typeof rawId !== "string") continue; const item = itemById.get(rawId); if (!item || !inventoryItemUsableForCategory(item, profile.categoryId)) return false; } } return true; } interface ProfileGearNormalization { inventory: InventoryItem[]; equipment: EquipmentAssignments; gearRecovery?: CharacterGearRecovery; } function normalizeProfileGear( rawInventory: unknown, rawEquipment: unknown, ownerCategory: NonNullable, existingRecovery: unknown, ): ProfileGearNormalization { const usableInventory: InventoryItem[] = []; const recoveredItems: GearRecoveryItem[] = []; const rejectedInstanceIds = new Map(); const candidates = Array.isArray(rawInventory) ? rawInventory : []; for (const candidate of candidates) { if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; const normalized = normalizeInventory([candidate], { ownerCategory })[0]; if (normalized && inventoryItemUsableForCategory(normalized, ownerCategory)) { usableInventory.push(normalized); continue; } const raw = candidate as Record; // Keep the old invalid-row behavior for an item that already claims this // profile's namespace but cannot be normalized as an InventoryItem. if (!normalized && raw.contentCategory === ownerCategory) continue; const reason = recoveryReasonForCategory(raw.contentCategory, ownerCategory, normalized); recoveredItems.push({ item: candidate, reason }); if (typeof raw.instanceId === "string" && !rejectedInstanceIds.has(raw.instanceId)) { rejectedInstanceIds.set(raw.instanceId, reason); } } const recoveredEquipment: GearRecoveryEquipmentReference[] = []; if (rawEquipment && typeof rawEquipment === "object" && !Array.isArray(rawEquipment)) { for (const [ownerId, rawLoadout] of Object.entries(rawEquipment as Record)) { if (!ownerId || !rawLoadout || typeof rawLoadout !== "object" || Array.isArray(rawLoadout)) continue; for (const [slot, rawInstanceId] of Object.entries(rawLoadout as Record)) { if (typeof rawInstanceId !== "string") continue; const reason = rejectedInstanceIds.get(rawInstanceId); if (reason) recoveredEquipment.push({ ownerId, slot, instanceId: rawInstanceId, reason }); } } } const equipment = normalizeEquipment(rawEquipment, usableInventory, ownerCategory); return { inventory: usableInventory, equipment, gearRecovery: mergeGearRecovery(existingRecovery, { items: recoveredItems, equipment: recoveredEquipment }), }; } function isCharacterProfile(value: unknown): value is CharacterProfile { if (!value || typeof value !== "object") return false; const record = value as Partial; return typeof record.id === "string" && typeof record.ownerId === "string" && typeof record.name === "string" && typeof record.raceId === "string" && typeof record.classId === "string" && typeof record.gender === "string" && typeof record.appearance === "object"; } function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile { const profile = { ...value }; profile.statRuleset = normalizeCharacterStatRuleset(value.statRuleset, value.classId); if (value.classId === "hunter") { profile.hunterPetFamily = normalizeWow335HunterPetFamily(value.hunterPetFamily); } else { delete profile.hunterPetFamily; } // Cards now belong to the owner; legacy fields are consumed before normalization. delete profile.bossCards; const legacy = value as CharacterProfile & { actionBindings?: unknown; experience?: unknown; talentRanks?: unknown; glyphIds?: unknown; level?: unknown; inventory?: unknown; equipment?: unknown; gearRecovery?: unknown; manastormProgress?: unknown; settings?: unknown; secondaryClassId?: unknown; actionLoadouts?: unknown; specializationId?: unknown; dungeonRunCheckpoint?: unknown; }; const level = typeof legacy.level === "number" && Number.isFinite(legacy.level) ? Math.max(1, Math.min(80, Math.floor(legacy.level))) : 1; const experience = typeof legacy.experience === "number" && Number.isFinite(legacy.experience) ? Math.max(0, Math.floor(legacy.experience)) : 0; const talentRanks = legacy.talentRanks && typeof legacy.talentRanks === "object" && !Array.isArray(legacy.talentRanks) ? Object.fromEntries(Object.entries(legacy.talentRanks as Record) .filter(([, rank]) => typeof rank === "number" && Number.isFinite(rank) && rank > 0) .map(([id, rank]) => [id, Math.floor(rank as number)])) : {}; const glyphIds = Array.isArray(legacy.glyphIds) ? [...new Set(legacy.glyphIds.filter((glyphId): glyphId is string => ( typeof glyphId === "string" && glyphId.trim().length > 0 )))] : []; const actionBindings = legacy.actionBindings && typeof legacy.actionBindings === "object" && !Array.isArray(legacy.actionBindings) ? Object.fromEntries(Object.entries(legacy.actionBindings as Record) .filter(([, abilityId]) => abilityId === null || typeof abilityId === "string") .map(([bindingId, abilityId]) => [bindingId, abilityId as string | null])) : {}; const categoryId = contentCategoryForClass(value.classId); const specializationId = categoryId === "rom" ? null : normalizeSpecializationId(value.classId, legacy.specializationId); const secondaryClassId = categoryId === "rom" && isRomClassId(value.classId) && typeof legacy.secondaryClassId === "string" && isValidRomClassPair(value.raceId, value.classId, legacy.secondaryClassId) ? legacy.secondaryClassId : null; const rawLoadouts = legacy.actionLoadouts && typeof legacy.actionLoadouts === "object" && !Array.isArray(legacy.actionLoadouts) ? legacy.actionLoadouts as Record : {}; const actionLoadouts = Object.fromEntries(Object.entries(rawLoadouts).flatMap(([key, bindings]) => { if (!bindings || typeof bindings !== "object" || Array.isArray(bindings)) return []; return [[key, Object.fromEntries(Object.entries(bindings as Record) .filter(([, abilityId]) => abilityId === null || typeof abilityId === "string") .map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))]]; })) as Record>; const activeLoadoutKey = orderedClassLoadoutKey(value.classId, secondaryClassId); actionLoadouts[activeLoadoutKey] = { ...(actionLoadouts[activeLoadoutKey] ?? actionBindings) }; const normalizedGear = normalizeProfileGear( legacy.inventory, legacy.equipment, categoryId, legacy.gearRecovery, ); const { inventory, equipment, gearRecovery } = normalizedGear; const manastormProgress = normalizeManastormProgress(legacy.manastormProgress); const settings = legacy.settings && typeof legacy.settings === "object" && !Array.isArray(legacy.settings) ? { ...(legacy.settings as Partial) } : undefined; const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint); const normalizedProfile: CharacterProfile = { ...profile, level, experience, talentRanks, glyphIds, actionBindings, categoryId, specializationId, secondaryClassId, actionLoadouts, inventory, equipment, manastormProgress, dungeonRunCheckpoint, settings, }; if (gearRecovery) normalizedProfile.gearRecovery = gearRecovery; else delete normalizedProfile.gearRecovery; return normalizedProfile; } function readDatabase( storage: StorageLike | null = browserLocalStorage(), persistMigrations = true, ): ProfileDatabase { if (!storage) return emptyDatabase(); try { const parsed = JSON.parse(storage.getItem(DATABASE_KEY) ?? "null") as ({ version?: number; accounts?: unknown; rosters?: unknown; bossCardsByOwner?: unknown; presentationSettingsByOwner?: unknown; uiPreferencesByOwner?: unknown; }) | null; if (!parsed || ![1, 2, 3, 4, 5, 6, 7, 8].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") { return emptyDatabase(); } const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean( account && typeof account.id === "string" && typeof account.username === "string" && typeof account.usernameKey === "string" && account.verifier && typeof account.verifier.salt === "string" && typeof account.verifier.digest === "string", )); const rawCards = parsed.bossCardsByOwner && typeof parsed.bossCardsByOwner === "object" && !Array.isArray(parsed.bossCardsByOwner) ? parsed.bossCardsByOwner : {}; const bossCardsByOwner = Object.fromEntries(Object.entries(rawCards).map(([owner, cards]) => [owner, normalizeBossCardCollection(cards)])); let cardsMigrated = false; let statRulesetsMigrated = false; const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record).map(([ownerId, roster]) => { const characters = Array.isArray(roster) ? roster.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : []; if (characters.some((character) => ( !isCharacterStatRuleset(character.statRuleset) || normalizeCharacterStatRuleset(character.statRuleset, character.classId) !== character.statRuleset ))) statRulesetsMigrated = true; if (characters.some((character) => character.bossCards !== undefined)) { const legacyCards = mergeBossCardCollections(characters.map((character) => character.bossCards), "sum"); bossCardsByOwner[ownerId] = mergeBossCardCollections([bossCardsByOwner[ownerId], legacyCards]); cardsMigrated = true; } return [ownerId, characters.map(normalizeCharacterProfile)]; })); const rawPresentationSettings = parsed.presentationSettingsByOwner && typeof parsed.presentationSettingsByOwner === "object" && !Array.isArray(parsed.presentationSettingsByOwner) ? parsed.presentationSettingsByOwner as Record : {}; let presentationSettingsMigrated = false; const presentationSettingsByOwner = Object.fromEntries( Object.entries(rawPresentationSettings).map(([ownerId, settings]) => { const storedMode = settings && typeof settings === "object" && !Array.isArray(settings) ? (settings as { vfxMode?: unknown }).vfxMode : undefined; const storedOriginalMediaEnabled = settings && typeof settings === "object" && !Array.isArray(settings) ? (settings as { originalMediaEnabled?: unknown }).originalMediaEnabled : undefined; // Move saves using the former generated-only default to the new default // once, retaining their quality, visibility, mute and volume preferences. const migrateMediaDefault = (parsed.version ?? 0) < 6 && storedMode === "generated" && storedOriginalMediaEnabled !== true; const normalized = normalizeCombatPresentationSettings(migrateMediaDefault ? { ...(settings as Record), originalMediaEnabled: true, vfxMode: "original", } : settings); if ( storedMode !== normalized.vfxMode || storedOriginalMediaEnabled !== normalized.originalMediaEnabled ) presentationSettingsMigrated = true; return [ownerId, normalized]; }), ); const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {}; const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)])); const database: ProfileDatabase = { version: 8, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner }; if (persistMigrations && (parsed.version !== 8 || presentationSettingsMigrated || cardsMigrated || statRulesetsMigrated)) { // A full or read-only device must still expose its existing roster. // Retry migration on the next read when persistence is available. try { writeDatabase(database, storage); } catch { /* Keep the normalized in-memory profiles. */ } } return database; } catch { return emptyDatabase(); } } function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void { storage?.setItem(DATABASE_KEY, JSON.stringify(database)); if (!storage || !rosterChangeListener) return; for (const ownerId of new Set([...Object.keys(database.rosters), ...Object.keys(database.bossCardsByOwner), ...Object.keys(database.uiPreferencesByOwner ?? {})])) { rosterChangeListener(ownerId, database.rosters[ownerId] ?? []); } } export function loadUiPreferences(ownerId: string, storage: StorageLike | null = browserLocalStorage()): UiPreferences { return normalizeUiPreferences(readDatabase(storage).uiPreferencesByOwner?.[ownerId]); } export function saveUiPreferences(ownerId: string, value: unknown, storage: StorageLike | null = browserLocalStorage()): UiPreferences { const database = readDatabase(storage); const preferences = normalizeUiPreferences(value); database.uiPreferencesByOwner = { ...database.uiPreferencesByOwner, [ownerId]: preferences }; writeDatabase(database, storage); return preferences; } function bytesToBase64(bytes: Uint8Array): string { let result = ""; for (let index = 0; index < bytes.length; index += 3) { const a = bytes[index] ?? 0; const b = bytes[index + 1] ?? 0; const c = bytes[index + 2] ?? 0; const group = (a << 16) | (b << 8) | c; result += BASE64[(group >> 18) & 63]; result += BASE64[(group >> 12) & 63]; result += index + 1 < bytes.length ? BASE64[(group >> 6) & 63] : "="; result += index + 2 < bytes.length ? BASE64[group & 63] : "="; } return result; } function base64ToBytes(value: string): Uint8Array { const cleaned = value.replace(/=+$/, ""); const output: number[] = []; let buffer = 0; let bits = 0; for (const character of cleaned) { const digit = BASE64.indexOf(character); if (digit < 0) continue; buffer = (buffer << 6) | digit; bits += 6; if (bits >= 8) { bits -= 8; output.push((buffer >> bits) & 255); } } return new Uint8Array(output); } function randomBytes(length: number): Uint8Array { const value = new Uint8Array(length); globalThis.crypto.getRandomValues(value); return value; } function makeId(prefix: string): string { if (typeof globalThis.crypto?.randomUUID === "function") return `${prefix}-${globalThis.crypto.randomUUID()}`; return `${prefix}-${Date.now().toString(36)}-${bytesToBase64(randomBytes(9)).replace(/[^A-Za-z0-9]/g, "")}`; } async function derivePassword(password: string, salt: Uint8Array, iterations: number): Promise { const key = await globalThis.crypto.subtle.importKey( "raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"], ); const bits = await globalThis.crypto.subtle.deriveBits( { name: "PBKDF2", hash: "SHA-256", salt: salt as BufferSource, iterations }, key, 256, ); return new Uint8Array(bits); } function constantTimeEqual(left: Uint8Array, right: Uint8Array): boolean { if (left.length !== right.length) return false; let difference = 0; for (let index = 0; index < left.length; index += 1) difference |= left[index] ^ right[index]; return difference === 0; } export function normalizeUsername(value: string): string { return value.trim(); } export function validateCredentials(username: string, password: string): string | null { if (!/^[A-Za-z0-9_]{3,20}$/.test(normalizeUsername(username))) { return "Username must be 3-20 letters, numbers, or underscores."; } if (password.length < 8) return "Password must be at least 8 characters."; return null; } export async function createLocalAccount( usernameInput: string, password: string, storage: StorageLike | null = browserLocalStorage(), ): Promise { if (!storage) return { ok: false, error: "Local storage is unavailable on this device." }; const username = normalizeUsername(usernameInput); const credentialError = validateCredentials(username, password); if (credentialError) return { ok: false, error: credentialError }; const database = readDatabase(storage); const usernameKey = username.toLowerCase(); if (database.accounts.some((account) => account.usernameKey === usernameKey)) { return { ok: false, error: "That account already exists on this device." }; } const salt = randomBytes(16); const digest = await derivePassword(password, salt, PBKDF2_ITERATIONS); const account: LocalAccount = { id: makeId("account"), username, usernameKey, verifier: { salt: bytesToBase64(salt), digest: bytesToBase64(digest), iterations: PBKDF2_ITERATIONS }, createdAt: Date.now(), }; database.accounts.push(account); database.rosters[account.id] = []; writeDatabase(database, storage); return { ok: true, session: { kind: "account", ownerId: account.id, displayName: account.username } }; } export async function loginLocalAccount( usernameInput: string, password: string, storage: StorageLike | null = browserLocalStorage(), ): Promise { if (!storage) return { ok: false, error: "Local storage is unavailable on this device." }; const username = normalizeUsername(usernameInput); if (!username || !password) return { ok: false, error: "Enter your username and password." }; const account = readDatabase(storage).accounts.find((entry) => entry.usernameKey === username.toLowerCase()); if (!account) return { ok: false, error: "Account not found on this device." }; const actual = await derivePassword(password, base64ToBytes(account.verifier.salt), account.verifier.iterations); if (!constantTimeEqual(actual, base64ToBytes(account.verifier.digest))) { return { ok: false, error: "Incorrect password." }; } return { ok: true, session: { kind: "account", ownerId: account.id, displayName: account.username } }; } export function createOfflineSession(): PlayerSession { return { kind: "offline", ownerId: OFFLINE_OWNER_ID, displayName: "Offline" }; } export function saveSession( session: PlayerSession, storage: StorageLike | null = browserLocalStorage(), transientStorage: StorageLike | null = browserSessionStorage(), ): void { const serialized = JSON.stringify(session); try { if (storage) { storage.setItem(SESSION_KEY, serialized); if (transientStorage !== storage) { try { transientStorage?.removeItem(SESSION_KEY); } catch { /* Storage may be disabled. */ } } return; } } catch { /* Fall back to tab-scoped storage when persistent storage is unavailable. */ } try { transientStorage?.setItem(SESSION_KEY, serialized); } catch { /* Storage may be disabled. */ } } function readStoredSession(storage: StorageLike | null): PlayerSession | null { if (!storage) return null; try { const session = JSON.parse(storage.getItem(SESSION_KEY) ?? "null") as Partial | null; if (!session || (session.kind !== "account" && session.kind !== "offline") || typeof session.ownerId !== "string" || typeof session.displayName !== "string") return null; if (session.kind === "account" && typeof session.accessToken !== "string" && !readDatabase().accounts.some((account) => account.id === session.ownerId)) return null; return { ...(session as PlayerSession), // Session storage is client-controlled. /api/me re-derives online roles. roles: [], }; } catch { return null; } } export function restoreSession( storage: StorageLike | null = browserLocalStorage(), transientStorage: StorageLike | null = browserSessionStorage(), ): PlayerSession | null { const persistent = readStoredSession(storage); if (persistent) return persistent; if (transientStorage === storage) return null; const transient = readStoredSession(transientStorage); if (transient) saveSession(transient, storage, transientStorage); return transient; } export function clearSession( storage: StorageLike | null = browserLocalStorage(), transientStorage: StorageLike | null = browserSessionStorage(), ): void { try { storage?.removeItem(SESSION_KEY); } catch { /* Storage may be disabled. */ } if (transientStorage !== storage) { try { transientStorage?.removeItem(SESSION_KEY); } catch { /* Storage may be disabled. */ } } } export function listCharacters(ownerId: string, storage: StorageLike | null = browserLocalStorage()): CharacterProfile[] { return [...(readDatabase(storage).rosters[ownerId] ?? [])].sort((left, right) => right.lastPlayedAt - left.lastPlayedAt); } export function replaceCharactersForOwner( ownerId: string, characters: unknown, storage: StorageLike | null = browserLocalStorage(), accountBossCards?: unknown, ): CharacterProfile[] { if (!storage) return []; const database = readDatabase(storage); const roster = Array.isArray(characters) ? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS) : []; const importedCards = accountBossCards === undefined ? mergeBossCardCollections(roster.map((character) => character.bossCards), "sum") : normalizeBossCardCollection(accountBossCards); database.bossCardsByOwner[ownerId] = mergeBossCardCollections([database.bossCardsByOwner[ownerId], importedCards]); const existingRecoveryByCharacterId = new Map( (database.rosters[ownerId] ?? []).map((character) => [character.id, character.gearRecovery]), ); database.rosters[ownerId] = roster.map((character) => { const gearRecovery = mergeGearRecovery( existingRecoveryByCharacterId.get(character.id), character.gearRecovery, ); return normalizeCharacterProfile({ ...character, ownerId, ...(gearRecovery ? { gearRecovery } : {}), }); }); writeDatabase(database, storage); return listCharacters(ownerId, storage); } export function createCharacter( profile: Omit & { specializationId?: string | null; }, storage: StorageLike | null = browserLocalStorage(), ): CharacterResult { if (!storage) return { ok: false, error: "Local storage is unavailable on this device." }; const database = readDatabase(storage); const roster = database.rosters[profile.ownerId] ?? []; if (roster.length >= MAX_CHARACTERS) return { ok: false, error: `Character limit reached (${MAX_CHARACTERS}).` }; if (roster.some((character) => character.name.toLowerCase() === profile.name.toLowerCase())) { return { ok: false, error: "That character name is already used in this roster." }; } const now = Date.now(); const character = normalizeCharacterProfile({ ...profile, statRuleset: profile.statRuleset ?? (contentCategoryForClass(profile.classId) === "rom" ? "rom" : "wow"), id: makeId("character"), level: 1, experience: 0, talentRanks: {}, glyphIds: [], actionBindings: {}, inventory: [], equipment: {}, manastormProgress: createEmptyManastormProgress(), location: "Dungeon Entrance", createdAt: now, lastPlayedAt: now, }); database.rosters[profile.ownerId] = [...roster, character]; writeDatabase(database, storage); return { ok: true, character }; } export function updateCharacterStatRuleset( ownerId: string, characterId: string, value: unknown, storage: StorageLike | null = browserLocalStorage(), ): CharacterProfile | null { if (!storage || !isCharacterStatRuleset(value)) return null; const database = readDatabase(storage); let updated: CharacterProfile | null = null; database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { if (character.id !== characterId || contentCategoryForClass(character.classId) !== "rom") return character; updated = normalizeCharacterProfile({ ...character, statRuleset: value }); return updated; }); if (!updated) return null; try { writeDatabase(database, storage); } catch { return null; } return updated; } export function touchCharacter( ownerId: string, characterId: string, storage: StorageLike | null = browserLocalStorage(), ): CharacterProfile | null { if (!storage) return null; const database = readDatabase(storage); let touched: CharacterProfile | null = null; database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { if (character.id !== characterId) return character; touched = { ...character, lastPlayedAt: Date.now() }; return touched; }); writeDatabase(database, storage); return touched; } export function updateCharacterProgression( ownerId: string, characterId: string, progression: Pick & Partial>, storage: StorageLike | null = browserLocalStorage(), ): CharacterProfile | null { if (!storage) return null; const database = readDatabase(storage); let updated: CharacterProfile | null = null; database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { if (character.id !== characterId) return character; updated = normalizeCharacterProfile({ ...character, level: progression.level, experience: progression.experience, talentRanks: { ...progression.talentRanks }, glyphIds: progression.glyphIds === undefined ? [...(character.glyphIds ?? [])] : [...progression.glyphIds], }); return updated; }); if (updated) writeDatabase(database, storage); return updated; } export function updateCharacterSpecialization( ownerId: string, characterId: string, specializationId: string, resetTalents: boolean, storage: StorageLike | null = browserLocalStorage(), ): CharacterResult { if (!storage) return { ok: false, error: "Local storage is unavailable on this device." }; const database = readDatabase(storage); let result: CharacterResult = { ok: false, error: "That character is no longer available." }; database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { if (character.id !== characterId) return character; if (character.categoryId === "rom") { result = { ok: false, error: "RoM gear follows the active primary class and activity role." }; return character; } const specialization = specializationById(specializationId); if (!specialization || specialization.classId !== character.classId) { result = { ok: false, error: "That specialization is not available to this class." }; return character; } const talentAbilityIds = resetTalents ? new Set(abilitiesForClass(character.classId) .filter((ability) => abilityTalentNodeId(ability) !== null || ability.source === "coa-tree") .map((ability) => ability.id)) : new Set(); const actionBindings = resetTalents ? Object.fromEntries(Object.entries(character.actionBindings) .map(([bindingId, abilityId]) => [bindingId, abilityId && talentAbilityIds.has(abilityId) ? null : abilityId])) : character.actionBindings; const loadoutKey = orderedClassLoadoutKey(character.classId, character.secondaryClassId); const updated = normalizeCharacterProfile({ ...character, specializationId: specialization.id, talentRanks: resetTalents ? {} : character.talentRanks, actionBindings, actionLoadouts: { ...(character.actionLoadouts ?? {}), [loadoutKey]: { ...actionBindings }, }, }); result = { ok: true, character: updated }; return updated; }); if (result.ok) writeDatabase(database, storage); return result; } export function updateCharacterActionBindings( ownerId: string, characterId: string, actionBindings: Readonly>, storage: StorageLike | null = browserLocalStorage(), ): CharacterProfile | null { if (!storage) return null; const database = readDatabase(storage); let updated: CharacterProfile | null = null; database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { if (character.id !== characterId) return character; updated = normalizeCharacterProfile({ ...character, actionBindings: { ...actionBindings }, actionLoadouts: { ...(character.actionLoadouts ?? {}), [orderedClassLoadoutKey(character.classId, character.secondaryClassId)]: { ...actionBindings }, }, }); return updated; }); if (updated) writeDatabase(database, storage); return updated; } export function assignRomSecondaryClass( ownerId: string, characterId: string, secondaryClassId: RomClassId, storage: StorageLike | null = browserLocalStorage(), ): CharacterResult { if (!storage) return { ok: false, error: "Local storage is unavailable on this device." }; const database = readDatabase(storage); let result: CharacterResult = { ok: false, error: "That character is no longer available." }; database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { if (character.id !== characterId) return character; if (character.categoryId !== "rom" || !isRomClassId(character.classId)) { result = { ok: false, error: "Only RoM characters can learn a secondary class." }; return character; } if (character.level < ROM_SECONDARY_UNLOCK_LEVEL) { result = { ok: false, error: `The Class Hall unlocks at level ${ROM_SECONDARY_UNLOCK_LEVEL}.` }; return character; } if (character.secondaryClassId) { result = { ok: false, error: "This character already chose a permanent secondary class." }; return character; } if (!isValidRomClassPair(character.raceId, character.classId, secondaryClassId)) { result = { ok: false, error: "That secondary class is not available to this race." }; return character; } const key = orderedClassLoadoutKey(character.classId, secondaryClassId); const updated = normalizeCharacterProfile({ ...character, secondaryClassId, actionBindings: {}, actionLoadouts: { ...(character.actionLoadouts ?? {}), [key]: {} }, }); result = { ok: true, character: updated }; return updated; }); if (result.ok) writeDatabase(database, storage); return result; } export function swapRomClasses( ownerId: string, characterId: string, storage: StorageLike | null = browserLocalStorage(), ): CharacterResult { if (!storage) return { ok: false, error: "Local storage is unavailable on this device." }; const database = readDatabase(storage); let result: CharacterResult = { ok: false, error: "That character is no longer available." }; database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { if (character.id !== characterId) return character; const secondary = character.secondaryClassId; if (character.categoryId !== "rom" || !secondary || !isRomClassId(character.classId)) { result = { ok: false, error: "Choose a secondary RoM class before swapping." }; return character; } const nextSecondary = character.classId; const nextKey = orderedClassLoadoutKey(secondary, nextSecondary); const currentKey = orderedClassLoadoutKey(character.classId, secondary); const actionLoadouts = { ...(character.actionLoadouts ?? {}), [currentKey]: { ...character.actionBindings }, }; const updated = normalizeCharacterProfile({ ...character, classId: secondary, secondaryClassId: nextSecondary, actionBindings: { ...(actionLoadouts[nextKey] ?? {}) }, actionLoadouts, }); result = { ok: true, character: updated }; return updated; }); if (result.ok) writeDatabase(database, storage); return result; } export function updateCharacterInventory( ownerId: string, characterId: string, inventory: readonly InventoryItem[], storage: StorageLike | null = browserLocalStorage(), ): CharacterProfile | null { if (!storage) return null; // Do not persist a migration if this mutation is rejected. Existing profile // bytes (including legacy gear and its quarantine state) remain untouched. const database = readDatabase(storage, false); const current = (database.rosters[ownerId] ?? []).find((character) => character.id === characterId); if (!current?.categoryId || !incomingInventoryMatchesCategory(inventory, current.categoryId)) return null; let updated: CharacterProfile | null = null; database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { if (character.id !== characterId) return character; updated = normalizeCharacterProfile({ ...character, inventory: [...inventory], }); return updated; }); if (updated) writeDatabase(database, storage); return updated; } export function updateCharacterEquipment( ownerId: string, characterId: string, equipment: EquipmentAssignments, storage: StorageLike | null = browserLocalStorage(), ): CharacterProfile | null { if (!storage) return null; // Equipment updates are all-or-nothing: never silently erase assignments to // foreign or quarantined inventory during normalization. const database = readDatabase(storage, false); const current = (database.rosters[ownerId] ?? []).find((character) => character.id === characterId); if ( !current || equipmentReferencesRecoveredItem(equipment, current.gearRecovery) || !equipmentReferencesUsableInventory(equipment, current) ) return null; let updated: CharacterProfile | null = null; database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { if (character.id !== characterId) return character; updated = normalizeCharacterProfile({ ...character, equipment, }); return updated; }); if (updated) writeDatabase(database, storage); return updated; } export function updateCharacterHunterPetFamily( ownerId: string, characterId: string, family: unknown, storage: StorageLike | null = browserLocalStorage(), ): CharacterProfile | null { if (!storage || !isWow335HunterPetFamily(family)) return null; const database = readDatabase(storage); let updated: CharacterProfile | null = null; database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { if (character.id !== characterId || character.classId !== "hunter") return character; updated = normalizeCharacterProfile({ ...character, hunterPetFamily: family }); return updated; }); if (updated) writeDatabase(database, storage); return updated; } export function loadAccountBossCards( ownerId: string, storage: StorageLike | null = browserLocalStorage(), ): BossCardCollection { return normalizeBossCardCollection(readDatabase(storage).bossCardsByOwner[ownerId]); } export function saveAccountBossCards( ownerId: string, bossCards: BossCardCollection, storage: StorageLike | null = browserLocalStorage(), ): BossCardCollection { const normalized = normalizeBossCardCollection(bossCards); if (!storage) return normalized; const database = readDatabase(storage); database.bossCardsByOwner[ownerId] = normalized; writeDatabase(database, storage); return normalized; } export function updateCharacterSettings( ownerId: string, characterId: string, settings: GameplaySettings, storage: StorageLike | null = browserLocalStorage(), ): CharacterProfile | null { if (!storage) return null; const database = readDatabase(storage); let updated: CharacterProfile | null = null; database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { if (character.id !== characterId) return character; updated = normalizeCharacterProfile({ ...character, settings: { ...settings, threatMeterPosition: { ...settings.threatMeterPosition }, }, }); return updated; }); if (updated) writeDatabase(database, storage); return updated; } export function loadCombatPresentationSettings( ownerId: string, storage: StorageLike | null = browserLocalStorage(), ): CombatPresentationSettings { if (!storage) return DEFAULT_COMBAT_PRESENTATION_SETTINGS; return readDatabase(storage).presentationSettingsByOwner[ownerId] ?? DEFAULT_COMBAT_PRESENTATION_SETTINGS; } export function updateCombatPresentationSettings( ownerId: string, settings: CombatPresentationSettings, storage: StorageLike | null = browserLocalStorage(), ): CombatPresentationSettings { const normalized = normalizeCombatPresentationSettings(settings); if (!storage) return normalized; const database = readDatabase(storage); database.presentationSettingsByOwner[ownerId] = normalized; writeDatabase(database, storage); return normalized; } export function updateCharacterManastormProgress( ownerId: string, characterId: string, manastormProgress: ManastormProgress, storage: StorageLike | null = browserLocalStorage(), ): CharacterProfile | null { if (!storage) return null; const database = readDatabase(storage); let updated: CharacterProfile | null = null; database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { if (character.id !== characterId) return character; updated = normalizeCharacterProfile({ ...character, manastormProgress: normalizeManastormProgress(manastormProgress), }); return updated; }); if (updated) writeDatabase(database, storage); return updated; } export function updateCharacterDungeonRunCheckpoint( ownerId: string, characterId: string, checkpoint: DungeonRunCheckpoint | null, storage: StorageLike | null = browserLocalStorage(), ): CharacterProfile | null { if (!storage) return null; const database = readDatabase(storage); const normalized = checkpoint === null ? null : normalizeDungeonRunCheckpoint(checkpoint); if (checkpoint !== null && !normalized) return null; let updated: CharacterProfile | null = null; database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => { if (character.id !== characterId) return character; updated = normalizeCharacterProfile({ ...character, dungeonRunCheckpoint: normalized, }); return updated; }); if (updated) writeDatabase(database, storage); return updated; } export function deleteCharacter( ownerId: string, characterId: string, storage: StorageLike | null = browserLocalStorage(), ): void { if (!storage) return; const database = readDatabase(storage); database.rosters[ownerId] = (database.rosters[ownerId] ?? []).filter((character) => character.id !== characterId); writeDatabase(database, storage); } export const PROFILE_STORAGE_KEYS = { database: DATABASE_KEY, session: SESSION_KEY } as const;