Files
healer-man/src/app/accountRepository.ts

1213 lines
48 KiB
TypeScript

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<string, CharacterProfile[]>;
bossCardsByOwner: Record<string, BossCardCollection>;
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
uiPreferencesByOwner?: Record<string, UiPreferences>;
}
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<GearRecoveryEquipmentReference>;
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<string, number>();
const equipmentCounts = new Map<string, number>();
for (const value of values) {
const recovery = normalizeGearRecovery(value);
const currentItemCounts = new Map<string, number>();
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<CharacterProfile["categoryId"]>,
): boolean {
for (const candidate of inventory as readonly unknown[]) {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false;
const item = candidate as Record<string, unknown>;
const category = item.contentCategory;
if (!(category === undefined && ownerCategory === "wow")
&& !inventoryItemMatchesCategory(item as Pick<InventoryItem, "contentCategory">, 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<string>();
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<string, unknown>)) {
if (!loadout || typeof loadout !== "object" || Array.isArray(loadout)) return false;
for (const rawId of Object.values(loadout as Record<string, unknown>)) {
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<CharacterProfile["categoryId"]>,
existingRecovery: unknown,
): ProfileGearNormalization {
const usableInventory: InventoryItem[] = [];
const recoveredItems: GearRecoveryItem[] = [];
const rejectedInstanceIds = new Map<string, GearRecoveryReason>();
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<string, unknown>;
// 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<string, unknown>)) {
if (!ownerId || !rawLoadout || typeof rawLoadout !== "object" || Array.isArray(rawLoadout)) continue;
for (const [slot, rawInstanceId] of Object.entries(rawLoadout as Record<string, unknown>)) {
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<CharacterProfile>;
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<string, unknown>)
.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<string, unknown>)
.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<string, unknown>
: {};
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<string, unknown>)
.filter(([, abilityId]) => abilityId === null || typeof abilityId === "string")
.map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))]];
})) as Record<string, Record<string, string | null>>;
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<GameplaySettings>) }
: 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<string, unknown>).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<string, unknown>
: {};
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<string, unknown>),
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<Uint8Array> {
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<AccountResult> {
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<AccountResult> {
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<PlayerSession> | 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<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "glyphIds" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location" | "specializationId"> & {
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<CharacterProfile, "level" | "experience" | "talentRanks"> & Partial<Pick<CharacterProfile, "glyphIds">>,
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<string>();
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<Record<string, string | null>>,
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;