Files
healer-man/src/app/accountRepository.test.ts
T
2026-08-14 15:56:39 -04:00

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);
});
});