import { describe, expect, it } from "vitest"; import { PROFILE_STORAGE_KEYS, createCharacter, createLocalAccount, createOfflineSession, listCharacters, loginLocalAccount, updateCharacterActionBindings, updateCharacterEquipment, updateCharacterInventory, updateCharacterManastormProgress, updateCharacterProgression, updateCharacterSettings, type StorageLike, } from "./accountRepository"; import { DEFAULT_GAMEPLAY_SETTINGS } from "../game/combatStore"; import { EMPTY_ITEM_STATS } from "../game/itemStats"; import { MANASTORM_PROGRESS_VERSION, applyManastormRewards, createEmptyManastormProgress, manastormModeProgress, recordManastormCheckpoint, recordManastormFailure, setManastormLoadout, } from "../game/manastormProgress"; 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); } } describe("local profile repository", () => { 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("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("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 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); }); });