394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
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<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); }
|
|
}
|
|
|
|
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);
|
|
});
|
|
});
|