forked from phenom/healer-man
1213 lines
48 KiB
TypeScript
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;
|