import { describe, expect, it } from "vitest"; import { SaveRepository, type StorageAdapter } from "./saveRepository"; import { groupDrop } from "../game/progression/loot"; import { RUN_BUFF_ORDER } from "../game/roguelike"; function memoryStorage(): StorageAdapter { const data = new Map(); return { getItem: (key) => data.get(key) ?? null, setItem: (key, value) => { data.set(key, value); }, }; } describe("SaveRepository", () => { it("creates exactly three offline-first slot views with timestamps", () => { const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z"); repository.create(2, "Seraphine"); const slots = repository.listLocal(); expect(slots.map((slot) => slot.id)).toEqual([1, 2, 3]); expect(slots[1].local?.updatedAt).toBe("2026-07-10T12:00:00.000Z"); expect(slots[1].local?.hunterName).toBe("Seraphine"); expect(slots[1].online).toBeNull(); }); it("copies a local save into another slot without sharing nested state", () => { let now = "2026-07-10T12:00:00.000Z"; const repository = new SaveRepository(memoryStorage(), () => now); repository.create(1, "Aelia"); now = "2026-07-10T13:00:00.000Z"; repository.copyLocal(1, 3); repository.updateLocal(3, (save) => ({ ...save, healers: { ...save.healers, druid: { ...save.healers.druid, level: 99 } }, })); const slots = repository.listLocal(); expect(slots[0].local?.healers.druid.level).toBe(1); expect(slots[2].local?.healers.druid.level).toBe(99); expect(slots[2].local?.slotId).toBe(3); }); it("replaces a local slot with a downloaded server snapshot", () => { let now = "2026-07-10T12:00:00.000Z"; const repository = new SaveRepository(memoryStorage(), () => now); const serverSave = repository.create(1, "Aelia"); serverSave.healers.priest.level = 40; now = "2026-07-10T14:00:00.000Z"; repository.replaceLocal(serverSave); expect(repository.listLocal()[0].local?.healers.priest.level).toBe(40); expect(repository.listLocal()[0].local?.updatedAt).toBe(now); }); it("deletes the local copy without inventing an online record", () => { const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z"); repository.create(1, "Aelia"); repository.deleteLocal(1); const slot = repository.listLocal()[0]; expect(slot.local).toBeNull(); expect(slot.online).toBeNull(); }); it("keeps class progression and inventories independent under one hunter name", () => { const repository = new SaveRepository(memoryStorage(), () => "2026-07-10T12:00:00.000Z"); repository.create(1, "Aelia"); repository.updateLocal(1, (save) => ({ ...save, activeClassId: "druid", healers: { ...save.healers, druid: { level: 8, inventory: [...save.healers.druid.inventory, { ...save.healers.druid.inventory[0], id: "druid-drop" }] }, }, })); const save = repository.listLocal()[0].local!; expect(save.hunterName).toBe("Aelia"); expect(save.activeClassId).toBe("druid"); expect(save.healers.druid.level).toBe(8); expect(save.healers.priest.level).toBe(1); expect(save.healers.druid.inventory).toHaveLength(4); expect(save.healers.priest.inventory).toHaveLength(4); }); it("resets every legacy save into fresh v5 progression while preserving identity and timestamp", () => { const storage = memoryStorage(); const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); const created = repository.create(1, "Legacy"); const legacy = { ...created, schemaVersion: 4, activeClassId: "druid", playSeconds: 999, healers: Object.fromEntries(Object.entries(created.healers).map(([id, healer]) => [id, { ...healer, level: 27 }])), stats: { totalBossKills: 22, flawlessClears: 9, alliesSaved: 4, healingDone: 1200, bossKills: { bulldrome: 22 }, highestRoguelikeRound: 12 }, materials: [{ id: "legacy-boss-coin", name: "Legacy coin", quantity: 99, rarity: "common", itemLevel: 1, glyph: "R" }], collectionLog: { dropsFound: { "legacy-boss-coin": 99 }, petsFound: { "bulldrome-pet": 1 } }, gearProgress: Object.fromEntries(Object.entries(created.gearProgress).map(([id, owner]) => [id, { ...owner, slots: Object.fromEntries(Object.entries(owner.slots).map(([slotId]) => [slotId, { level: 10 }])), infusionAbilityId: "priest-sanctuary", }])), } as Record; storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: legacy })); const migrated = repository.listLocal()[0].local!; expect(migrated.schemaVersion).toBe(5); expect(migrated.hunterName).toBe("Legacy"); expect(migrated.activeClassId).toBe("priest"); expect(migrated.playSeconds).toBe(0); expect(Object.values(migrated.healers).every((healer) => healer.level === 1 && healer.inventory.length > 0)).toBe(true); expect(migrated.stats).toEqual({ totalBossKills: 0, flawlessClears: 0, alliesSaved: 0, healingDone: 0, bossKills: {}, highestRoguelikeRound: 0, highestRogueTrialsEndlessKills: 0 }); expect(migrated.materials).toEqual([]); expect(migrated.collectionLog).toEqual({ dropsFound: {}, petsFound: {} }); expect(Object.values(migrated.gearProgress).every((owner) => owner.infusionAbilityId === null && owner.passiveInfusionId === null && Object.values(owner.slots).every((slot) => slot.level === 0))).toBe(true); expect(JSON.parse(storage.getItem("i-want-to-heal:saves:local:v1") ?? "{}")["1"].schemaVersion).toBe(5); }); it("preserves valid v5 progression and group-drop inventory", () => { const storage = memoryStorage(); const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); const created = repository.create(1, "Veteran"); const drop = groupDrop("charge", "veteran"); created.healers.priest.level = 8; created.stats = { ...created.stats, totalBossKills: 2, bossKills: { bulldrome: 2 } }; created.stats.highestRogueTrialsEndlessKills = 14; created.materials = [{ id: drop.id, name: drop.name, quantity: 4, rarity: drop.rarity, itemLevel: drop.itemLevel, glyph: drop.glyph }]; created.collectionLog = { dropsFound: { [drop.id]: 4 }, petsFound: { "bulldrome-pet": 1 } }; storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); const migrated = repository.listLocal()[0].local!; expect(migrated.schemaVersion).toBe(5); expect(migrated.healers.priest.level).toBe(8); expect(migrated.stats.bossKills).toEqual({ bulldrome: 2 }); expect(migrated.stats.highestRogueTrialsEndlessKills).toBe(14); expect(migrated.materials[0]).toMatchObject({ id: drop.id, quantity: 4 }); expect(migrated.collectionLog).toEqual(created.collectionLog); }); it("normalizes valid v5 infusion choices and discards stale ids", () => { const storage = memoryStorage(); const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); const created = repository.create(1, "Infused"); created.gearProgress.priest.infusionAbilityId = "priest-sanctuary"; created.gearProgress.priest.passiveInfusionId = "restoring-grace" as never; created.gearProgress.druid.passiveInfusionId = "mend-echo"; created.gearProgress.brann.infusionAbilityId = "removed-infusion"; created.gearProgress.brann.passiveInfusionId = "deep-wells" as never; storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); const migrated = repository.listLocal()[0].local!; expect(migrated.schemaVersion).toBe(5); expect(migrated.gearProgress.priest.infusionAbilityId).toBe("priest-sanctuary"); expect(migrated.gearProgress.priest.passiveInfusionId).toBeNull(); expect(migrated.gearProgress.druid.passiveInfusionId).toBe("mend-echo"); expect(migrated.gearProgress.brann.infusionAbilityId).toBeNull(); expect(migrated.gearProgress.brann.passiveInfusionId).toBeNull(); }); it("persists every new roguelike buff as a healer passive infusion", () => { for (const passiveId of RUN_BUFF_ORDER) { const storage = memoryStorage(); const repository = new SaveRepository(storage, () => "2026-07-10T12:00:00.000Z"); const created = repository.create(1, "Infused"); created.gearProgress.priest.passiveInfusionId = passiveId; storage.setItem("i-want-to-heal:saves:local:v1", JSON.stringify({ 1: created })); expect(repository.listLocal()[0].local?.gearProgress.priest.passiveInfusionId).toBe(passiveId); } }); });