import { describe, expect, it } from "vitest"; import { PROFILE_STORAGE_KEYS, createCharacter, createLocalAccount, createOfflineSession, clearSession, listCharacters, loadAccountBossCards, saveAccountBossCards, replaceCharactersForOwner, setRosterChangeListener, deleteCharacter, loadCombatPresentationSettings, loginLocalAccount, restoreSession, saveSession, updateCharacterActionBindings, updateCharacterEquipment, updateCharacterHunterPetFamily, updateCharacterDungeonRunCheckpoint, updateCharacterInventory, updateCharacterManastormProgress, updateCharacterProgression, updateCharacterStatRuleset, updateCharacterSettings, updateCharacterSpecialization, updateCombatPresentationSettings, type StorageLike, } from "./accountRepository"; import { DEFAULT_GAMEPLAY_SETTINGS } from "../game/combatStore"; import { DEFAULT_COMBAT_PRESENTATION_SETTINGS } from "../game/combatPresentation"; import { EMPTY_ITEM_STATS } from "../game/itemStats"; import { MANASTORM_PROGRESS_VERSION, applyManastormRewards, createEmptyManastormProgress, manastormModeProgress, recordManastormCheckpoint, recordManastormFailure, setManastormLoadout, } from "../game/manastormProgress"; import { abilitiesForClass } from "../game/abilityCatalog"; import { DUNGEON_RUN_CHECKPOINT_VERSION } from "../game/dungeonRunCheckpoint"; import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "../game/wow335GlyphCatalog"; class MemoryStorage implements StorageLike { private values = new Map(); getItem(key: string) { return this.values.get(key) ?? null; } setItem(key: string, value: string) { this.values.set(key, value); } removeItem(key: string) { this.values.delete(key); } } class FailingWriteStorage extends MemoryStorage { failWrites = false; setItem(key: string, value: string) { if (this.failWrites) throw new Error("Storage quota exceeded."); super.setItem(key, value); } } describe("local profile repository", () => { it("normalizes legacy Hunter family data to Wolf and persists implemented family choices", () => { const storage = new MemoryStorage(); const hunter = createCharacter({ ownerId: "offline-roster", name: "Shellguard", categoryId: "wow", raceId: "human", classId: "hunter", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; expect(hunter.hunterPetFamily).toBe("wolf"); const cat = updateCharacterHunterPetFamily(hunter.ownerId, hunter.id, "cat", storage)!; expect(cat.hunterPetFamily).toBe("cat"); expect(listCharacters(hunter.ownerId, storage)[0]?.hunterPetFamily).toBe("cat"); expect(updateCharacterHunterPetFamily(hunter.ownerId, hunter.id, "bear", storage)).toBeNull(); expect(listCharacters(hunter.ownerId, storage)[0]?.hunterPetFamily).toBe("cat"); const invalidStoredFamily = { ...cat, hunterPetFamily: "bear" } as unknown as typeof cat; const [normalizedLegacy] = replaceCharactersForOwner(hunter.ownerId, [invalidStoredFamily], storage); expect(normalizedLegacy?.hunterPetFamily).toBe("wolf"); }); it("defaults legacy saved characters to WoW stat rules and new RoM characters to native RoM", () => { const storage = new MemoryStorage(); const legacyRom = { id: "legacy-rom", ownerId: "offline-roster", name: "Old Healer", categoryId: "rom", raceId: "rom-human", classId: "rom-priest", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, level: 4, location: "Dungeon Entrance", createdAt: 1, lastPlayedAt: 1, }; storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({ version: 7, accounts: [], rosters: { "offline-roster": [legacyRom] }, bossCardsByOwner: {}, presentationSettingsByOwner: {}, })); expect(listCharacters("offline-roster", storage)[0]?.statRuleset).toBe("wow"); expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).rosters["offline-roster"][0].statRuleset) .toBe("wow"); const created = createCharacter({ ownerId: "offline-roster", name: "New Healer", categoryId: "rom", raceId: "rom-human", classId: "rom-priest", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; expect(created.statRuleset).toBe("rom"); }); it("keeps a legacy roster readable when its ruleset migration cannot be written", () => { const storage = new FailingWriteStorage(); const character = createCharacter({ ownerId: "offline-roster", name: "Legacy Healer", classId: "rom-priest", raceId: "rom-human", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; const raw = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!); delete raw.rosters[character.ownerId][0].statRuleset; const legacySave = JSON.stringify(raw); storage.setItem(PROFILE_STORAGE_KEYS.database, legacySave); storage.failWrites = true; expect(listCharacters(character.ownerId, storage)[0]).toMatchObject({ id: character.id, statRuleset: "wow", }); expect(updateCharacterStatRuleset(character.ownerId, character.id, "rom", storage)).toBeNull(); expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(legacySave); expect(listCharacters(character.ownerId, storage)[0]?.statRuleset).toBe("wow"); storage.failWrites = false; expect(listCharacters(character.ownerId, storage)[0]?.statRuleset).toBe("wow"); expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).rosters[character.ownerId][0].statRuleset) .toBe("wow"); }); it("switches one RoM profile while preserving its build and other characters", () => { const storage = new MemoryStorage(); const first = createCharacter({ ownerId: "offline-roster", name: "First", categoryId: "rom", raceId: "rom-human", classId: "rom-priest", secondaryClassId: "rom-mage", actionLoadouts: { "rom-priest:rom-mage": { "primary:face-top": "rom-priest-heal" } }, gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; const item = { instanceId: "kept-item", itemId: 10412, itemKey: "belt-of-the-fang", name: "Belt of the Fang", quality: "rare" as const, inventorySlot: "waist", armorType: "leather", weaponType: null, itemLevel: 17, sourceItemLevel: 21, stats: { ...EMPTY_ITEM_STATS, agility: 4, stamina: 4, armor: 15 }, quantity: 1, sourceBossKey: "lady-anacondra", sourceBossName: "Lady Anacondra", acquiredAt: 5_000, }; updateCharacterActionBindings(first.ownerId, first.id, { "primary:face-top": "rom-priest-heal" }, storage); updateCharacterInventory(first.ownerId, first.id, [item], storage); const second = createCharacter({ ownerId: "offline-roster", name: "Second", categoryId: "rom", raceId: "rom-human", classId: "rom-mage", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; const wowCharacter = createCharacter({ ownerId: "offline-roster", name: "WoW Character", categoryId: "wow", raceId: "human", classId: "priest", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; let cloudSnapshot: ReadonlyArray[number]> = []; setRosterChangeListener((ownerId, characters) => { if (ownerId === first.ownerId) cloudSnapshot = characters; }); const updated = updateCharacterStatRuleset(first.ownerId, first.id, "wow", storage)!; setRosterChangeListener(null); expect(updated).toMatchObject({ statRuleset: "wow", secondaryClassId: "rom-mage", actionBindings: { "primary:face-top": "rom-priest-heal" }, actionLoadouts: { "rom-priest:rom-mage": { "primary:face-top": "rom-priest-heal" } }, inventory: [item], }); expect(listCharacters(first.ownerId, storage).find((entry) => entry.id === first.id)?.statRuleset).toBe("wow"); expect(listCharacters(second.ownerId, storage).find((entry) => entry.id === second.id)?.statRuleset).toBe("rom"); expect(updateCharacterStatRuleset(first.ownerId, first.id, "rom", storage)?.statRuleset).toBe("rom"); expect(cloudSnapshot.find((entry) => entry.id === first.id)?.statRuleset).toBe("wow"); expect(updateCharacterDungeonRunCheckpoint(first.ownerId, first.id, { version: DUNGEON_RUN_CHECKPOINT_VERSION, dungeonId: "wailing-caverns", difficultyId: "normal", checkpointId: "entrance", spawn: { footPosition: [-158, -82, 123], yaw: 0 }, encounters: [], savedAt: 42, }, storage)?.statRuleset).toBe("rom"); expect(updateCharacterStatRuleset(first.ownerId, second.id, "invalid", storage)).toBeNull(); expect(updateCharacterStatRuleset(first.ownerId, wowCharacter.id, "rom", storage)).toBeNull(); expect(updateCharacterStatRuleset(first.ownerId, "missing", "wow", storage)).toBeNull(); }); it("leaves the saved stat ruleset unchanged when storage rejects a switch", () => { const storage = new FailingWriteStorage(); const character = createCharacter({ ownerId: "offline-roster", name: "Steady", categoryId: "rom", raceId: "rom-human", classId: "rom-priest", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; storage.failWrites = true; expect(updateCharacterStatRuleset(character.ownerId, character.id, "wow", storage)).toBeNull(); storage.failWrites = false; expect(listCharacters(character.ownerId, storage).find((entry) => entry.id === character.id)?.statRuleset) .toBe("rom"); }); it("defaults new glyph loadouts to empty and persists explicit glyph choices", () => { const storage = new MemoryStorage(); const character = createCharacter({ ownerId: "offline-roster", name: "Leafkeeper", categoryId: "wow", raceId: "night-elf", classId: "druid", specializationId: "druid:restoration", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; expect(character.glyphIds).toEqual([]); const legacyProfile = { ...character }; delete (legacyProfile as Partial).glyphIds; const [migrated] = replaceCharactersForOwner("offline-roster", [legacyProfile], storage); expect(migrated?.glyphIds).toEqual([]); const updated = updateCharacterProgression("offline-roster", migrated.id, { level: migrated.level, experience: migrated.experience, talentRanks: migrated.talentRanks, glyphIds: [WOW335_GLYPH_OF_SWIFTMEND_ID, WOW335_GLYPH_OF_SWIFTMEND_ID, " "], }, storage)!; expect(updated.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]); expect(listCharacters("offline-roster", storage)[0]?.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]); const progressionOnly = updateCharacterProgression("offline-roster", migrated.id, { level: updated.level, experience: updated.experience, talentRanks: updated.talentRanks, }, storage)!; expect(progressionOnly.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]); }); it("persists a safe dungeon checkpoint per character", () => { const storage = new MemoryStorage(); const character = createCharacter({ ownerId: "offline-roster", name: "Delver", categoryId: "wow", raceId: "human", classId: "priest", specializationId: "priest:holy", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; const updated = updateCharacterDungeonRunCheckpoint("offline-roster", character.id, { version: DUNGEON_RUN_CHECKPOINT_VERSION, dungeonId: "wailing-caverns", difficultyId: "normal", checkpointId: "entrance", spawn: { footPosition: [-158, -82, 123], yaw: 0 }, encounters: [], savedAt: 42, }, storage); expect(updated?.dungeonRunCheckpoint).toMatchObject({ dungeonId: "wailing-caverns", checkpointId: "entrance", }); expect(listCharacters("offline-roster", storage)[0]?.dungeonRunCheckpoint?.savedAt).toBe(42); }); it("persists specializations and supports Class Hall keep/reset choices", () => { const storage = new MemoryStorage(); const talentAbility = abilitiesForClass("stormbringer").find((ability) => ability.talentEntryId !== undefined)!; const created = createCharacter({ ownerId: "offline-roster", name: "Tempest", categoryId: "coa", raceId: "human", classId: "stormbringer", specializationId: "stormbringer:lightning", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; updateCharacterProgression("offline-roster", created.id, { level: 20, experience: 0, talentRanks: { [`coa-entry-${talentAbility.talentEntryId}`]: 1 }, }, storage); updateCharacterActionBindings("offline-roster", created.id, { "primary:face-top": talentAbility.id }, storage); const kept = updateCharacterSpecialization("offline-roster", created.id, "stormbringer:wind", false, storage).character!; expect(kept.specializationId).toBe("stormbringer:wind"); expect(kept.talentRanks).not.toEqual({}); expect(kept.actionBindings["primary:face-top"]).toBe(talentAbility.id); const reset = updateCharacterSpecialization("offline-roster", created.id, "stormbringer:maelstrom", true, storage).character!; expect(reset.specializationId).toBe("stormbringer:maelstrom"); expect(reset.talentRanks).toEqual({}); expect(reset.actionBindings["primary:face-top"]).toBeNull(); }); it("creates and verifies a local account without storing its raw password", async () => { const storage = new MemoryStorage(); const created = await createLocalAccount("CaveHealer", "serpent-pass", storage); expect(created.ok).toBe(true); expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).not.toContain("serpent-pass"); expect((await loginLocalAccount("cavehealer", "serpent-pass", storage)).ok).toBe(true); expect((await loginLocalAccount("cavehealer", "wrong-pass", storage)).ok).toBe(false); }); it("keeps account and offline rosters isolated", async () => { const storage = new MemoryStorage(); const account = (await createLocalAccount("PartyLead", "eight-plus", storage)).session!; const offline = createOfflineSession(); const base = { name: "Remedy", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 8, face: 13, hairStyle: 22, hairColor: 7, feature: 5 }, }; expect(createCharacter({ ...base, ownerId: account.ownerId }, storage).ok).toBe(true); const saved = listCharacters(account.ownerId, storage); expect(saved).toHaveLength(1); expect(saved[0]?.appearance).toEqual(base.appearance); expect(saved[0]).toMatchObject({ level: 1, experience: 0, talentRanks: {}, actionBindings: {}, inventory: [], equipment: {}, manastormProgress: { version: MANASTORM_PROGRESS_VERSION, unlockedContent: [], currencies: { bedlamBullions: 0, bonzoBolts: 0 }, cacheMeter: 0, loadout: [], claimedRewards: [], }, }); expect(listCharacters(offline.ownerId, storage)).toHaveLength(0); }); it("recovers safely from corrupt local data", () => { const storage = new MemoryStorage(); storage.setItem(PROFILE_STORAGE_KEYS.database, "not-json"); expect(listCharacters("offline-roster", storage)).toEqual([]); }); it("drops spoofed GM roles from offline sessions", () => { const storage = new MemoryStorage(); storage.setItem(PROFILE_STORAGE_KEYS.session, JSON.stringify({ kind: "offline", ownerId: "offline-roster", displayName: "Offline", roles: ["gm"], })); expect(restoreSession(storage)?.roles).toEqual([]); }); it("keeps a login across browser sessions and clears it on sign-out", () => { const persistent = new MemoryStorage(); const firstTab = new MemoryStorage(); const nextTab = new MemoryStorage(); const session = { kind: "account" as const, ownerId: "account-1", displayName: "Healer", accessToken: "server-token", roles: ["gm" as const] }; saveSession(session, persistent, firstTab); expect(firstTab.getItem(PROFILE_STORAGE_KEYS.session)).toBeNull(); expect(restoreSession(persistent, nextTab)).toMatchObject({ ...session, roles: [] }); clearSession(persistent, nextTab); expect(restoreSession(persistent, nextTab)).toBeNull(); }); it("moves a previous tab-scoped session into persistent storage", () => { const persistent = new MemoryStorage(); const previousTab = new MemoryStorage(); const session = { kind: "offline" as const, ownerId: "offline-roster", displayName: "Offline" }; previousTab.setItem(PROFILE_STORAGE_KEYS.session, JSON.stringify(session)); expect(restoreSession(persistent, previousTab)).toMatchObject(session); expect(previousTab.getItem(PROFILE_STORAGE_KEYS.session)).toBeNull(); expect(restoreSession(persistent, new MemoryStorage())).toMatchObject(session); }); it("normalizes legacy profiles and persists progression atomically", () => { const storage = new MemoryStorage(); storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({ version: 1, accounts: [], rosters: { "offline-roster": [{ id: "legacy-one", ownerId: "offline-roster", name: "Legacy", raceId: "human", classId: "priest", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, level: 3, location: "Dungeon Entrance", createdAt: 1, lastPlayedAt: 1, }], }, })); expect(listCharacters("offline-roster", storage)[0]).toMatchObject({ level: 3, experience: 0, talentRanks: {}, actionBindings: {}, inventory: [], equipment: {}, manastormProgress: createEmptyManastormProgress(), }); expect(updateCharacterProgression("offline-roster", "legacy-one", { level: 4, experience: 17, talentRanks: { "priest-discipline-focus": 2 }, }, storage)).toMatchObject({ level: 4, experience: 17, talentRanks: { "priest-discipline-focus": 2 }, }); }); it("migrates legacy databases and stores combat presentation settings by owner", async () => { for (const version of [1, 2]) { const storage = new MemoryStorage(); storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({ version, accounts: [], rosters: { "offline-roster": [] }, })); expect(loadCombatPresentationSettings("offline-roster", storage)) .toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS); expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7); } const legacyV3Storage = new MemoryStorage(); legacyV3Storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({ version: 3, accounts: [], rosters: { "offline-roster": [] }, presentationSettingsByOwner: { "offline-roster": { vfxMode: "original", vfxQuality: "low", vfxEnabled: { player: true, party: false, enemy: true }, audioEnabled: { player: true, party: true, enemy: false }, audioVolume: { player: 0.75, party: 0.4, enemy: 0.2 }, }, }, })); expect(loadCombatPresentationSettings("offline-roster", legacyV3Storage)).toMatchObject({ vfxMode: "original", vfxQuality: "low", }); expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!) .presentationSettingsByOwner["offline-roster"].vfxMode).toBe("original"); expect(JSON.parse(legacyV3Storage.getItem(PROFILE_STORAGE_KEYS.database)!) .presentationSettingsByOwner["offline-roster"].originalMediaEnabled).toBe(true); const storage = new MemoryStorage(); const firstOwner = (await createLocalAccount("EffectsOne", "eight-plus", storage)).session!.ownerId; const secondOwner = (await createLocalAccount("EffectsTwo", "eight-plus", storage)).session!.ownerId; const customized = updateCombatPresentationSettings(firstOwner, { ...DEFAULT_COMBAT_PRESENTATION_SETTINGS, originalMediaEnabled: true, vfxMode: "original", vfxQuality: "low", vfxEnabled: { player: true, party: false, enemy: true }, audioEnabled: { player: true, party: true, enemy: false }, audioVolume: { player: 0.75, party: 0.4, enemy: 0.2 }, }, storage); expect(loadCombatPresentationSettings(firstOwner, storage)).toEqual(customized); expect(loadCombatPresentationSettings(secondOwner, storage)).toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS); expect(loadCombatPresentationSettings(createOfflineSession().ownerId, storage)) .toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS); }); it("migrates the previous media default once while preserving combat controls", () => { const storage = new MemoryStorage(); storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({ version: 5, accounts: [], rosters: { "offline-roster": [] }, presentationSettingsByOwner: { "offline-roster": { ...DEFAULT_COMBAT_PRESENTATION_SETTINGS, originalMediaEnabled: false, vfxMode: "generated", vfxQuality: "low", audioEnabled: { player: true, party: false, enemy: true }, }, }, })); expect(loadCombatPresentationSettings("offline-roster", storage)).toMatchObject({ originalMediaEnabled: true, vfxMode: "original", vfxQuality: "low", audioEnabled: { player: true, party: false, enemy: true }, }); updateCombatPresentationSettings("offline-roster", { ...DEFAULT_COMBAT_PRESENTATION_SETTINGS, originalMediaEnabled: false, vfxMode: "generated", }, storage); expect(loadCombatPresentationSettings("offline-roster", storage)).toMatchObject({ originalMediaEnabled: false, vfxMode: "generated", }); }); it("migrates custom-class profiles into the CoA category", () => { const storage = new MemoryStorage(); const offline = createOfflineSession(); const character = createCharacter({ ownerId: offline.ownerId, name: "Conqueror", categoryId: "wow", raceId: "human", classId: "barbarian", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; expect(character.categoryId).toBe("coa"); expect(listCharacters(offline.ownerId, storage)[0]?.categoryId).toBe("coa"); }); it("persists controller ability bindings per character without changing progression", () => { const storage = new MemoryStorage(); const offline = createOfflineSession(); const created = createCharacter({ ownerId: offline.ownerId, name: "Bindings", raceId: "human", classId: "priest", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; const otherCharacter = createCharacter({ ownerId: offline.ownerId, name: "OtherBindings", raceId: "human", classId: "priest", gender: "female", appearance: { skinColor: 1, face: 1, hairStyle: 1, hairColor: 1, feature: 1 }, }, storage).character!; const updated = updateCharacterActionBindings(offline.ownerId, created.id, { "secondary:face-top": "priest-flash-heal", "tertiary:r2": null, }, storage); expect(updated).toMatchObject({ level: 1, experience: 0, actionBindings: { "secondary:face-top": "priest-flash-heal", "tertiary:r2": null, }, }); const saved = listCharacters(offline.ownerId, storage); expect(saved.find((character) => character.id === created.id)?.actionBindings).toEqual(updated?.actionBindings); expect(saved.find((character) => character.id === otherCharacter.id)?.actionBindings).toEqual({}); }); it("persists generated loot inventory per character", () => { const storage = new MemoryStorage(); const offline = createOfflineSession(); const character = createCharacter({ ownerId: offline.ownerId, name: "Collector", raceId: "human", classId: "priest", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; const item = { instanceId: "lady-anacondra:5000:1", itemId: 10412, itemKey: "belt-of-the-fang", name: "Belt of the Fang", quality: "rare" as const, inventorySlot: "waist", armorType: "leather", weaponType: null, itemLevel: 17, sourceItemLevel: 21, stats: { ...EMPTY_ITEM_STATS, agility: 4, stamina: 4, armor: 15 }, quantity: 1, sourceBossKey: "lady-anacondra", sourceBossName: "Lady Anacondra", acquiredAt: 5_000, }; expect(updateCharacterInventory(offline.ownerId, character.id, [item], storage)?.inventory).toEqual([item]); expect(updateCharacterEquipment(offline.ownerId, character.id, { player: { waist: item.instanceId }, }, storage)?.equipment).toEqual({ player: { waist: item.instanceId } }); expect(listCharacters(offline.ownerId, storage)[0]?.inventory).toEqual([item]); expect(listCharacters(offline.ownerId, storage)[0]?.equipment).toEqual({ player: { waist: item.instanceId }, }); }); it("persists account card copies independently of character creation and deletion", () => { const storage = new MemoryStorage(); const offline = createOfflineSession(); const draft = { ownerId: offline.ownerId, name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } }; const character = createCharacter(draft, storage).character!; const other = createCharacter({ ...draft, name: "Other" }, storage).character!; const bossCards = { counts: { "wow:lady anacondra": { normal: 7, epic: 1 } }, lastClaims: { spawn: "kill:7" } }; expect(saveAccountBossCards(offline.ownerId, bossCards, storage)).toEqual(bossCards); expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards); expect(loadAccountBossCards("different-account", storage)).toEqual({ counts: {}, lastClaims: {} }); const roster = listCharacters(offline.ownerId, storage); expect(roster.find((entry) => entry.id === character.id)?.bossCards).toBeUndefined(); expect(roster.find((entry) => entry.id === other.id)?.bossCards).toBeUndefined(); expect(roster.find((entry) => entry.id === character.id)?.inventory).toEqual([]); deleteCharacter(offline.ownerId, character.id, storage); deleteCharacter(offline.ownerId, other.id, storage); expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards); createCharacter({ ...draft, name: "Newcomer" }, storage); expect(loadAccountBossCards(offline.ownerId, storage)).toEqual(bossCards); }); it("migrates and sums all legacy character logs once while retaining the newest receipts", () => { const storage = new MemoryStorage(); const draft = { ownerId: "one", name: "Collector", raceId: "human" as const, classId: "priest" as const, gender: "female" as const, appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 } }; const first = createCharacter(draft, storage).character!; const second = createCharacter({ ...draft, name: "Other" }, storage).character!; const separate = createCharacter({ ...draft, ownerId: "two" }, storage).character!; const legacyCharacters = [ { ...first, bossCards: { counts: { boss: { normal: 7, epic: 1 } }, lastClaims: { spawn: "100:1:spawn" } } }, { ...second, bossCards: { counts: { boss: { normal: 3, rare: 2 } }, lastClaims: { spawn: "200:2:spawn" } } }, ]; storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({ version: 6, accounts: [], rosters: { one: legacyCharacters, two: [{ ...separate, bossCards: { counts: { boss: { normal: 4 } }, lastClaims: {} } }] }, })); const expected = { counts: { boss: { normal: 10, rare: 2, epic: 1 } }, lastClaims: { spawn: "200:2:spawn" } }; expect(loadAccountBossCards("one", storage)).toEqual(expected); expect(loadAccountBossCards("one", storage)).toEqual(expected); expect(loadAccountBossCards("two", storage).counts.boss?.normal).toBe(4); const database = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!); expect(database.version).toBe(7); expect(database.rosters.one.every((entry: { bossCards?: unknown }) => entry.bossCards === undefined)).toBe(true); // An old cloud roster can be downloaded again without repeating the migration. replaceCharactersForOwner("one", legacyCharacters, storage); replaceCharactersForOwner("one", legacyCharacters, storage); expect(loadAccountBossCards("one", storage)).toEqual(expected); }); it("restores account logs from cloud without overwriting higher cached counts", () => { const storage = new MemoryStorage(); saveAccountBossCards("one", { counts: { boss: { normal: 8 } }, lastClaims: { spawn: "200:2:spawn" } }, storage); replaceCharactersForOwner("one", [], storage, { counts: { boss: { normal: 7, rare: 1 } }, lastClaims: { spawn: "100:1:spawn" } }); expect(loadAccountBossCards("one", storage)).toEqual({ counts: { boss: { normal: 8, rare: 1 } }, lastClaims: { spawn: "200:2:spawn" } }); }); it("persists threat-meter visibility and position per character", () => { const storage = new MemoryStorage(); const offline = createOfflineSession(); const character = createCharacter({ ownerId: offline.ownerId, name: "Interface", raceId: "human", classId: "priest", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; const settings = { ...DEFAULT_GAMEPLAY_SETTINGS, showThreatMeter: true, threatMeterPosition: { x: 0.31, y: 0.72 }, }; expect(updateCharacterSettings(offline.ownerId, character.id, settings, storage)?.settings) .toEqual(settings); expect(listCharacters(offline.ownerId, storage)[0]?.settings).toEqual(settings); }); it("normalizes damaged Manastorm data without discarding valid profile progress", () => { const storage = new MemoryStorage(); storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({ version: 1, accounts: [], rosters: { "offline-roster": [{ id: "damaged-manastorm", ownerId: "offline-roster", name: "Survivor", raceId: "human", classId: "priest", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, level: 18, experience: 321, talentRanks: { "priest-discipline-focus": 2 }, actionBindings: {}, inventory: [], equipment: {}, manastormProgress: { version: "bad", partySizes: { 1: { highestLevel: 9.8, checkpointLevel: 99, firstClearLevels: [1, 5, 5, -4, "7"], }, 2: "corrupt", }, unlockedContent: ["power-a", "power-a", 3, " power-b "], currencies: { bedlamBullions: 14.9, bonzoBolts: -50 }, cacheMeter: "full", loadout: ["power-a", "locked-power", "power-a"], claimedRewards: ["clear-1", "clear-1", null], }, location: "Dungeon Entrance", createdAt: 1, lastPlayedAt: 2, }], }, })); const profile = listCharacters("offline-roster", storage)[0]!; expect(profile).toMatchObject({ level: 18, experience: 321, talentRanks: { "priest-discipline-focus": 2 }, inventory: [], equipment: {}, }); expect(profile.manastormProgress).toMatchObject({ version: MANASTORM_PROGRESS_VERSION, endgameHandoffApplied: false, currencies: { bedlamBullions: 14, bonzoBolts: 0 }, loadout: ["power-a"], }); expect(manastormModeProgress(profile.manastormProgress!, 0)).toEqual({ partySizes: { 1: { highestLevel: 9, checkpointLevel: 10, firstClearLevels: [1, 5] }, 2: { highestLevel: 0, checkpointLevel: 1, firstClearLevels: [] }, 3: { highestLevel: 0, checkpointLevel: 1, firstClearLevels: [] }, 4: { highestLevel: 0, checkpointLevel: 1, firstClearLevels: [] }, 5: { highestLevel: 0, checkpointLevel: 1, firstClearLevels: [] }, }, unlockedContent: ["power-a", "power-b"], cacheMeter: 0, rewardCaches: [], claimedRewards: ["clear-1"], }); expect(manastormModeProgress(profile.manastormProgress!, 2)) .toEqual(manastormModeProgress(createEmptyManastormProgress(), 2)); }); it("records independent checkpoints and idempotent rewards with validated loadouts", () => { const empty = createEmptyManastormProgress(); const cleared = recordManastormCheckpoint(empty, 3, 7, 5); expect(empty.partySizes[3]).toEqual({ highestLevel: 0, checkpointLevel: 1, firstClearLevels: [], }); expect(cleared.partySizes[3]).toEqual({ highestLevel: 7, checkpointLevel: 5, firstClearLevels: [7], }); expect(cleared.partySizes[1]).toEqual(empty.partySizes[1]); expect(recordManastormFailure(cleared, 3, 9)).toMatchObject({ partySize: 3, failedLevel: 9, resumeLevel: 5, }); const milestone = recordManastormCheckpoint(cleared, 3, 10, 11); expect(milestone.partySizes[3]).toMatchObject({ highestLevel: 10, checkpointLevel: 11, }); expect(recordManastormFailure(milestone, 3, 14)).toMatchObject({ resumeLevel: 11 }); const rewarded = applyManastormRewards(cleared, { id: "first-3p-7", bedlamBullions: 12, bonzoBolts: 4, cacheMeter: 2, unlockedContent: ["power-a", "power-b"], }); expect(applyManastormRewards(rewarded, { id: "first-3p-7", bedlamBullions: 999, })).toEqual(rewarded); expect(rewarded).toMatchObject({ unlockedContent: ["power-a", "power-b"], currencies: { bedlamBullions: 12, bonzoBolts: 4 }, cacheMeter: 2, claimedRewards: ["first-3p-7"], }); const validLoadout = setManastormLoadout(rewarded, ["power-b", "power-a"], 2); expect(validLoadout).toMatchObject({ ok: true }); expect(validLoadout.progress.loadout).toEqual(["power-b", "power-a"]); const invalidLoadout = setManastormLoadout( validLoadout.progress, ["power-a", "locked-power"], 2, ); expect(invalidLoadout.ok).toBe(false); expect(invalidLoadout.progress.loadout).toEqual(["power-b", "power-a"]); }); it("persists Manastorm progress without changing advancement or inventory", () => { const storage = new MemoryStorage(); const offline = createOfflineSession(); const character = createCharacter({ ownerId: offline.ownerId, name: "Stormkeeper", raceId: "human", classId: "priest", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; updateCharacterProgression(offline.ownerId, character.id, { level: 12, experience: 45, talentRanks: { "priest-discipline-focus": 2 }, }, storage); const progress = recordManastormCheckpoint(character.manastormProgress!, 5, 4, 3); const updated = updateCharacterManastormProgress( offline.ownerId, character.id, progress, storage, ); expect(updated).toMatchObject({ level: 12, experience: 45, talentRanks: { "priest-discipline-focus": 2 }, inventory: [], equipment: {}, }); expect(updated?.manastormProgress?.partySizes[5]).toEqual({ highestLevel: 4, checkpointLevel: 3, firstClearLevels: [4], }); expect(listCharacters(offline.ownerId, storage)[0]?.manastormProgress) .toEqual(updated?.manastormProgress); }); });