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

901 lines
36 KiB
TypeScript

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<string, string>();
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<ReturnType<typeof listCharacters>[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<typeof character>).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);
});
});