import type { CharacterProfile, PlayerSession } from "./types"; import { contentCategoryForClass, isRomClassId, type RomClassId, } from "./characterCatalog"; import { isValidRomClassPair, orderedClassLoadoutKey, ROM_SECONDARY_UNLOCK_LEVEL, } from "./romDualClass"; import { 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"; 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: 3; accounts: LocalAccount[]; rosters: Record; presentationSettingsByOwner: 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: 3, accounts: [], rosters: {}, presentationSettingsByOwner: {} }; } 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 legacy = value as CharacterProfile & { actionBindings?: unknown; experience?: unknown; talentRanks?: unknown; level?: unknown; inventory?: unknown; equipment?: unknown; manastormProgress?: unknown; settings?: unknown; secondaryClassId?: unknown; actionLoadouts?: 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 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 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 inventory = normalizeInventory(legacy.inventory); const equipment = normalizeEquipment(legacy.equipment, inventory); const manastormProgress = normalizeManastormProgress(legacy.manastormProgress); const settings = legacy.settings && typeof legacy.settings === "object" && !Array.isArray(legacy.settings) ? { ...(legacy.settings as Partial) } : undefined; return { ...value, level, experience, talentRanks, actionBindings, categoryId, secondaryClassId, actionLoadouts, inventory, equipment, manastormProgress, settings, }; } function readDatabase(storage: StorageLike | null = browserLocalStorage()): ProfileDatabase { if (!storage) return emptyDatabase(); try { const parsed = JSON.parse(storage.getItem(DATABASE_KEY) ?? "null") as ({ version?: number; accounts?: unknown; rosters?: unknown; presentationSettingsByOwner?: unknown; }) | null; if (!parsed || ![1, 2, 3].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 rosters = Object.fromEntries(Object.entries(parsed.rosters as Record).map(([ownerId, roster]) => [ ownerId, Array.isArray(roster) ? roster.filter(isCharacterProfile).map(normalizeCharacterProfile).slice(0, MAX_CHARACTERS) : [], ])); const rawPresentationSettings = parsed.presentationSettingsByOwner && typeof parsed.presentationSettingsByOwner === "object" && !Array.isArray(parsed.presentationSettingsByOwner) ? parsed.presentationSettingsByOwner as Record : {}; const presentationSettingsByOwner = Object.fromEntries( Object.entries(rawPresentationSettings).map(([ownerId, settings]) => [ ownerId, normalizeCombatPresentationSettings(settings), ]), ); const database: ProfileDatabase = { version: 3, accounts, rosters, presentationSettingsByOwner }; if (parsed.version !== 3) writeDatabase(database, storage); 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, characters] of Object.entries(database.rosters)) { rosterChangeListener(ownerId, characters); } } 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 = browserSessionStorage()): void { storage?.setItem(SESSION_KEY, JSON.stringify(session)); } export function restoreSession(storage: StorageLike | null = browserSessionStorage()): 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 clearSession(storage: StorageLike | null = browserSessionStorage()): void { storage?.removeItem(SESSION_KEY); } 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(), ): CharacterProfile[] { if (!storage) return []; const database = readDatabase(storage); database.rosters[ownerId] = Array.isArray(characters) ? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS).map((character) => normalizeCharacterProfile({ ...character, ownerId, })) : []; writeDatabase(database, storage); return listCharacters(ownerId, storage); } export function createCharacter( profile: Omit, 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, id: makeId("character"), level: 1, experience: 0, talentRanks: {}, 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 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, 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 }, }); return updated; }); if (updated) writeDatabase(database, storage); return updated; } 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; 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, 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; 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, equipment, }); return updated; }); if (updated) writeDatabase(database, storage); return updated; } 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 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;