forked from phenom/healer-man
901 lines
36 KiB
TypeScript
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);
|
|
});
|
|
});
|