706 lines
26 KiB
TypeScript
706 lines
26 KiB
TypeScript
import type { CharacterProfile, PlayerSession } from "./types";
|
|
import {
|
|
contentCategoryForClass,
|
|
isRomClassId,
|
|
type RomClassId,
|
|
} from "./characterCatalog";
|
|
import {
|
|
isValidRomClassPair,
|
|
orderedClassLoadoutKey,
|
|
ROM_SECONDARY_UNLOCK_LEVEL,
|
|
} from "./romDualClass";
|
|
import { normalizeInventory, type InventoryItem } from "../game/lootTypes";
|
|
import { normalizeEquipment, type EquipmentAssignments } from "../game/equipment";
|
|
import type { GameplaySettings } from "../game/combatStore";
|
|
import {
|
|
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
|
normalizeCombatPresentationSettings,
|
|
type CombatPresentationSettings,
|
|
} from "../game/combatPresentation";
|
|
import {
|
|
createEmptyManastormProgress,
|
|
normalizeManastormProgress,
|
|
type ManastormProgress,
|
|
} from "../game/manastormProgress";
|
|
|
|
const DATABASE_KEY = "healer-man.profile-database.v1";
|
|
const SESSION_KEY = "healer-man.session.v1";
|
|
const OFFLINE_OWNER_ID = "offline-roster";
|
|
const MAX_CHARACTERS = 10;
|
|
const PBKDF2_ITERATIONS = 120_000;
|
|
const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
|
|
export interface StorageLike {
|
|
getItem(key: string): string | null;
|
|
setItem(key: string, value: string): void;
|
|
removeItem(key: string): void;
|
|
}
|
|
|
|
interface PasswordVerifier {
|
|
salt: string;
|
|
digest: string;
|
|
iterations: number;
|
|
}
|
|
|
|
interface LocalAccount {
|
|
id: string;
|
|
username: string;
|
|
usernameKey: string;
|
|
verifier: PasswordVerifier;
|
|
createdAt: number;
|
|
}
|
|
|
|
interface ProfileDatabase {
|
|
version: 3;
|
|
accounts: LocalAccount[];
|
|
rosters: Record<string, CharacterProfile[]>;
|
|
presentationSettingsByOwner: Record<string, CombatPresentationSettings>;
|
|
}
|
|
|
|
export interface AccountResult {
|
|
ok: boolean;
|
|
session?: PlayerSession;
|
|
error?: string;
|
|
}
|
|
|
|
export interface CharacterResult {
|
|
ok: boolean;
|
|
character?: CharacterProfile;
|
|
error?: string;
|
|
}
|
|
|
|
export type RosterChangeListener = (ownerId: string, characters: readonly CharacterProfile[]) => void;
|
|
let rosterChangeListener: RosterChangeListener | null = null;
|
|
|
|
export function setRosterChangeListener(listener: RosterChangeListener | null): void {
|
|
rosterChangeListener = listener;
|
|
}
|
|
|
|
function browserLocalStorage(): StorageLike | null {
|
|
try {
|
|
return typeof window !== "undefined" ? window.localStorage : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function browserSessionStorage(): StorageLike | null {
|
|
try {
|
|
return typeof window !== "undefined" ? window.sessionStorage : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function emptyDatabase(): ProfileDatabase {
|
|
return { version: 3, accounts: [], rosters: {}, presentationSettingsByOwner: {} };
|
|
}
|
|
|
|
function isCharacterProfile(value: unknown): value is CharacterProfile {
|
|
if (!value || typeof value !== "object") return false;
|
|
const record = value as Partial<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 legacy = value as CharacterProfile & {
|
|
actionBindings?: unknown;
|
|
experience?: unknown;
|
|
talentRanks?: unknown;
|
|
level?: unknown;
|
|
inventory?: unknown;
|
|
equipment?: unknown;
|
|
manastormProgress?: unknown;
|
|
settings?: unknown;
|
|
secondaryClassId?: unknown;
|
|
actionLoadouts?: unknown;
|
|
};
|
|
const level = typeof legacy.level === "number" && Number.isFinite(legacy.level)
|
|
? Math.max(1, Math.min(80, Math.floor(legacy.level)))
|
|
: 1;
|
|
const experience = typeof legacy.experience === "number" && Number.isFinite(legacy.experience)
|
|
? Math.max(0, Math.floor(legacy.experience))
|
|
: 0;
|
|
const talentRanks = legacy.talentRanks && typeof legacy.talentRanks === "object" && !Array.isArray(legacy.talentRanks)
|
|
? Object.fromEntries(Object.entries(legacy.talentRanks as Record<string, unknown>)
|
|
.filter(([, rank]) => typeof rank === "number" && Number.isFinite(rank) && rank > 0)
|
|
.map(([id, rank]) => [id, Math.floor(rank as number)]))
|
|
: {};
|
|
const actionBindings = legacy.actionBindings && typeof legacy.actionBindings === "object" && !Array.isArray(legacy.actionBindings)
|
|
? Object.fromEntries(Object.entries(legacy.actionBindings as Record<string, unknown>)
|
|
.filter(([, abilityId]) => abilityId === null || typeof abilityId === "string")
|
|
.map(([bindingId, abilityId]) => [bindingId, abilityId as string | null]))
|
|
: {};
|
|
const categoryId = contentCategoryForClass(value.classId);
|
|
const secondaryClassId = categoryId === "rom"
|
|
&& isRomClassId(value.classId)
|
|
&& typeof legacy.secondaryClassId === "string"
|
|
&& isValidRomClassPair(value.raceId, value.classId, legacy.secondaryClassId)
|
|
? legacy.secondaryClassId
|
|
: null;
|
|
const rawLoadouts = legacy.actionLoadouts && typeof legacy.actionLoadouts === "object" && !Array.isArray(legacy.actionLoadouts)
|
|
? legacy.actionLoadouts as Record<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 inventory = normalizeInventory(legacy.inventory);
|
|
const equipment = normalizeEquipment(legacy.equipment, inventory);
|
|
const manastormProgress = normalizeManastormProgress(legacy.manastormProgress);
|
|
const settings = legacy.settings && typeof legacy.settings === "object" && !Array.isArray(legacy.settings)
|
|
? { ...(legacy.settings as Partial<GameplaySettings>) }
|
|
: undefined;
|
|
return {
|
|
...value,
|
|
level,
|
|
experience,
|
|
talentRanks,
|
|
actionBindings,
|
|
categoryId,
|
|
secondaryClassId,
|
|
actionLoadouts,
|
|
inventory,
|
|
equipment,
|
|
manastormProgress,
|
|
settings,
|
|
};
|
|
}
|
|
|
|
function readDatabase(storage: StorageLike | null = browserLocalStorage()): ProfileDatabase {
|
|
if (!storage) return emptyDatabase();
|
|
try {
|
|
const parsed = JSON.parse(storage.getItem(DATABASE_KEY) ?? "null") as ({
|
|
version?: number;
|
|
accounts?: unknown;
|
|
rosters?: unknown;
|
|
presentationSettingsByOwner?: unknown;
|
|
}) | null;
|
|
if (!parsed || ![1, 2, 3].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
|
return emptyDatabase();
|
|
}
|
|
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
|
|
account
|
|
&& typeof account.id === "string"
|
|
&& typeof account.username === "string"
|
|
&& typeof account.usernameKey === "string"
|
|
&& account.verifier
|
|
&& typeof account.verifier.salt === "string"
|
|
&& typeof account.verifier.digest === "string",
|
|
));
|
|
const rosters = Object.fromEntries(Object.entries(parsed.rosters as Record<string, unknown>).map(([ownerId, roster]) => [
|
|
ownerId,
|
|
Array.isArray(roster)
|
|
? roster.filter(isCharacterProfile).map(normalizeCharacterProfile).slice(0, MAX_CHARACTERS)
|
|
: [],
|
|
]));
|
|
const rawPresentationSettings = parsed.presentationSettingsByOwner
|
|
&& typeof parsed.presentationSettingsByOwner === "object"
|
|
&& !Array.isArray(parsed.presentationSettingsByOwner)
|
|
? parsed.presentationSettingsByOwner as Record<string, unknown>
|
|
: {};
|
|
const presentationSettingsByOwner = Object.fromEntries(
|
|
Object.entries(rawPresentationSettings).map(([ownerId, settings]) => [
|
|
ownerId,
|
|
normalizeCombatPresentationSettings(settings),
|
|
]),
|
|
);
|
|
const database: ProfileDatabase = { version: 3, accounts, rosters, presentationSettingsByOwner };
|
|
if (parsed.version !== 3) writeDatabase(database, storage);
|
|
return database;
|
|
} catch {
|
|
return emptyDatabase();
|
|
}
|
|
}
|
|
|
|
function writeDatabase(database: ProfileDatabase, storage: StorageLike | null = browserLocalStorage()): void {
|
|
storage?.setItem(DATABASE_KEY, JSON.stringify(database));
|
|
if (!storage || !rosterChangeListener) return;
|
|
for (const [ownerId, characters] of Object.entries(database.rosters)) {
|
|
rosterChangeListener(ownerId, characters);
|
|
}
|
|
}
|
|
|
|
function bytesToBase64(bytes: Uint8Array): string {
|
|
let result = "";
|
|
for (let index = 0; index < bytes.length; index += 3) {
|
|
const a = bytes[index] ?? 0;
|
|
const b = bytes[index + 1] ?? 0;
|
|
const c = bytes[index + 2] ?? 0;
|
|
const group = (a << 16) | (b << 8) | c;
|
|
result += BASE64[(group >> 18) & 63];
|
|
result += BASE64[(group >> 12) & 63];
|
|
result += index + 1 < bytes.length ? BASE64[(group >> 6) & 63] : "=";
|
|
result += index + 2 < bytes.length ? BASE64[group & 63] : "=";
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function base64ToBytes(value: string): Uint8Array {
|
|
const cleaned = value.replace(/=+$/, "");
|
|
const output: number[] = [];
|
|
let buffer = 0;
|
|
let bits = 0;
|
|
for (const character of cleaned) {
|
|
const digit = BASE64.indexOf(character);
|
|
if (digit < 0) continue;
|
|
buffer = (buffer << 6) | digit;
|
|
bits += 6;
|
|
if (bits >= 8) {
|
|
bits -= 8;
|
|
output.push((buffer >> bits) & 255);
|
|
}
|
|
}
|
|
return new Uint8Array(output);
|
|
}
|
|
|
|
function randomBytes(length: number): Uint8Array {
|
|
const value = new Uint8Array(length);
|
|
globalThis.crypto.getRandomValues(value);
|
|
return value;
|
|
}
|
|
|
|
function makeId(prefix: string): string {
|
|
if (typeof globalThis.crypto?.randomUUID === "function") return `${prefix}-${globalThis.crypto.randomUUID()}`;
|
|
return `${prefix}-${Date.now().toString(36)}-${bytesToBase64(randomBytes(9)).replace(/[^A-Za-z0-9]/g, "")}`;
|
|
}
|
|
|
|
async function derivePassword(password: string, salt: Uint8Array, iterations: number): Promise<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 = browserSessionStorage()): void {
|
|
storage?.setItem(SESSION_KEY, JSON.stringify(session));
|
|
}
|
|
|
|
export function restoreSession(storage: StorageLike | null = browserSessionStorage()): PlayerSession | null {
|
|
if (!storage) return null;
|
|
try {
|
|
const session = JSON.parse(storage.getItem(SESSION_KEY) ?? "null") as Partial<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 clearSession(storage: StorageLike | null = browserSessionStorage()): void {
|
|
storage?.removeItem(SESSION_KEY);
|
|
}
|
|
|
|
export function listCharacters(ownerId: string, storage: StorageLike | null = browserLocalStorage()): CharacterProfile[] {
|
|
return [...(readDatabase(storage).rosters[ownerId] ?? [])].sort((left, right) => right.lastPlayedAt - left.lastPlayedAt);
|
|
}
|
|
|
|
export function replaceCharactersForOwner(
|
|
ownerId: string,
|
|
characters: unknown,
|
|
storage: StorageLike | null = browserLocalStorage(),
|
|
): CharacterProfile[] {
|
|
if (!storage) return [];
|
|
const database = readDatabase(storage);
|
|
database.rosters[ownerId] = Array.isArray(characters)
|
|
? characters.filter(isCharacterProfile).slice(0, MAX_CHARACTERS).map((character) => normalizeCharacterProfile({
|
|
...character,
|
|
ownerId,
|
|
}))
|
|
: [];
|
|
writeDatabase(database, storage);
|
|
return listCharacters(ownerId, storage);
|
|
}
|
|
|
|
export function createCharacter(
|
|
profile: Omit<CharacterProfile, "id" | "createdAt" | "lastPlayedAt" | "level" | "experience" | "talentRanks" | "actionBindings" | "inventory" | "equipment" | "manastormProgress" | "location">,
|
|
storage: StorageLike | null = browserLocalStorage(),
|
|
): CharacterResult {
|
|
if (!storage) return { ok: false, error: "Local storage is unavailable on this device." };
|
|
const database = readDatabase(storage);
|
|
const roster = database.rosters[profile.ownerId] ?? [];
|
|
if (roster.length >= MAX_CHARACTERS) return { ok: false, error: `Character limit reached (${MAX_CHARACTERS}).` };
|
|
if (roster.some((character) => character.name.toLowerCase() === profile.name.toLowerCase())) {
|
|
return { ok: false, error: "That character name is already used in this roster." };
|
|
}
|
|
const now = Date.now();
|
|
const character = normalizeCharacterProfile({
|
|
...profile,
|
|
id: makeId("character"),
|
|
level: 1,
|
|
experience: 0,
|
|
talentRanks: {},
|
|
actionBindings: {},
|
|
inventory: [],
|
|
equipment: {},
|
|
manastormProgress: createEmptyManastormProgress(),
|
|
location: "Dungeon Entrance",
|
|
createdAt: now,
|
|
lastPlayedAt: now,
|
|
});
|
|
database.rosters[profile.ownerId] = [...roster, character];
|
|
writeDatabase(database, storage);
|
|
return { ok: true, character };
|
|
}
|
|
|
|
export function touchCharacter(
|
|
ownerId: string,
|
|
characterId: string,
|
|
storage: StorageLike | null = browserLocalStorage(),
|
|
): CharacterProfile | null {
|
|
if (!storage) return null;
|
|
const database = readDatabase(storage);
|
|
let touched: CharacterProfile | null = null;
|
|
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
|
if (character.id !== characterId) return character;
|
|
touched = { ...character, lastPlayedAt: Date.now() };
|
|
return touched;
|
|
});
|
|
writeDatabase(database, storage);
|
|
return touched;
|
|
}
|
|
|
|
export function updateCharacterProgression(
|
|
ownerId: string,
|
|
characterId: string,
|
|
progression: Pick<CharacterProfile, "level" | "experience" | "talentRanks">,
|
|
storage: StorageLike | null = browserLocalStorage(),
|
|
): CharacterProfile | null {
|
|
if (!storage) return null;
|
|
const database = readDatabase(storage);
|
|
let updated: CharacterProfile | null = null;
|
|
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
|
if (character.id !== characterId) return character;
|
|
updated = normalizeCharacterProfile({
|
|
...character,
|
|
level: progression.level,
|
|
experience: progression.experience,
|
|
talentRanks: { ...progression.talentRanks },
|
|
});
|
|
return updated;
|
|
});
|
|
if (updated) writeDatabase(database, storage);
|
|
return updated;
|
|
}
|
|
|
|
export function updateCharacterActionBindings(
|
|
ownerId: string,
|
|
characterId: string,
|
|
actionBindings: Readonly<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;
|
|
const database = readDatabase(storage);
|
|
let updated: CharacterProfile | null = null;
|
|
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
|
if (character.id !== characterId) return character;
|
|
updated = normalizeCharacterProfile({
|
|
...character,
|
|
inventory: [...inventory],
|
|
});
|
|
return updated;
|
|
});
|
|
if (updated) writeDatabase(database, storage);
|
|
return updated;
|
|
}
|
|
|
|
export function updateCharacterEquipment(
|
|
ownerId: string,
|
|
characterId: string,
|
|
equipment: EquipmentAssignments,
|
|
storage: StorageLike | null = browserLocalStorage(),
|
|
): CharacterProfile | null {
|
|
if (!storage) return null;
|
|
const database = readDatabase(storage);
|
|
let updated: CharacterProfile | null = null;
|
|
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
|
if (character.id !== characterId) return character;
|
|
updated = normalizeCharacterProfile({
|
|
...character,
|
|
equipment,
|
|
});
|
|
return updated;
|
|
});
|
|
if (updated) writeDatabase(database, storage);
|
|
return updated;
|
|
}
|
|
|
|
export function updateCharacterSettings(
|
|
ownerId: string,
|
|
characterId: string,
|
|
settings: GameplaySettings,
|
|
storage: StorageLike | null = browserLocalStorage(),
|
|
): CharacterProfile | null {
|
|
if (!storage) return null;
|
|
const database = readDatabase(storage);
|
|
let updated: CharacterProfile | null = null;
|
|
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
|
if (character.id !== characterId) return character;
|
|
updated = normalizeCharacterProfile({
|
|
...character,
|
|
settings: {
|
|
...settings,
|
|
threatMeterPosition: { ...settings.threatMeterPosition },
|
|
},
|
|
});
|
|
return updated;
|
|
});
|
|
if (updated) writeDatabase(database, storage);
|
|
return updated;
|
|
}
|
|
|
|
export function loadCombatPresentationSettings(
|
|
ownerId: string,
|
|
storage: StorageLike | null = browserLocalStorage(),
|
|
): CombatPresentationSettings {
|
|
if (!storage) return DEFAULT_COMBAT_PRESENTATION_SETTINGS;
|
|
return readDatabase(storage).presentationSettingsByOwner[ownerId]
|
|
?? DEFAULT_COMBAT_PRESENTATION_SETTINGS;
|
|
}
|
|
|
|
export function updateCombatPresentationSettings(
|
|
ownerId: string,
|
|
settings: CombatPresentationSettings,
|
|
storage: StorageLike | null = browserLocalStorage(),
|
|
): CombatPresentationSettings {
|
|
const normalized = normalizeCombatPresentationSettings(settings);
|
|
if (!storage) return normalized;
|
|
const database = readDatabase(storage);
|
|
database.presentationSettingsByOwner[ownerId] = normalized;
|
|
writeDatabase(database, storage);
|
|
return normalized;
|
|
}
|
|
|
|
export function updateCharacterManastormProgress(
|
|
ownerId: string,
|
|
characterId: string,
|
|
manastormProgress: ManastormProgress,
|
|
storage: StorageLike | null = browserLocalStorage(),
|
|
): CharacterProfile | null {
|
|
if (!storage) return null;
|
|
const database = readDatabase(storage);
|
|
let updated: CharacterProfile | null = null;
|
|
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
|
if (character.id !== characterId) return character;
|
|
updated = normalizeCharacterProfile({
|
|
...character,
|
|
manastormProgress: normalizeManastormProgress(manastormProgress),
|
|
});
|
|
return updated;
|
|
});
|
|
if (updated) writeDatabase(database, storage);
|
|
return updated;
|
|
}
|
|
|
|
export function deleteCharacter(
|
|
ownerId: string,
|
|
characterId: string,
|
|
storage: StorageLike | null = browserLocalStorage(),
|
|
): void {
|
|
if (!storage) return;
|
|
const database = readDatabase(storage);
|
|
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).filter((character) => character.id !== characterId);
|
|
writeDatabase(database, storage);
|
|
}
|
|
|
|
export const PROFILE_STORAGE_KEYS = { database: DATABASE_KEY, session: SESSION_KEY } as const;
|