import { beforeEach, describe, expect, it } from "vitest"; import { createCharacter, createOfflineSession, listCharacters, updateCharacterManastormProgress, type StorageLike, } from "../app/accountRepository"; import { getManastormCatalog, setManastormCatalog, } from "./manastorm"; import { INSTALLED_MANASTORM_RUNTIME_CATALOG } from "./manastormInstalledCatalog"; import { createEmptyManastormProgress, hasClaimedManastormReward, manastormModeProgress, manastormPartyProgress, recordManastormFailure, type ManastormProgress, } from "./manastormProgress"; import { synchronizeManastormProgress, type ManastormProgressRuntimeSnapshot, } from "./manastormProgressSync"; import { useManastormStore } from "./manastormStore"; class MemoryStorage implements StorageLike { private values = new Map(); 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); } } function runtimeSnapshot(): ManastormProgressRuntimeSnapshot { return useManastormStore.getState(); } function saveRuntimeDelta( progress: ManastormProgress, previous: ManastormProgressRuntimeSnapshot, ): ManastormProgress { return synchronizeManastormProgress( progress, previous, runtimeSnapshot(), getManastormCatalog(), ).progress; } function clearCurrentLevel(progress: ManastormProgress): ManastormProgress { useManastormStore.getState().enterStage(); useManastormStore.getState().observeTrash(true); let previous = runtimeSnapshot(); useManastormStore.getState().observeBoss(true, true); let nextProgress = saveRuntimeDelta(progress, previous); previous = runtimeSnapshot(); expect(useManastormStore.getState().beginTransition()).toBe(true); expect(useManastormStore.getState().prepareNextEncounter(() => 0.31)).not.toBeNull(); nextProgress = saveRuntimeDelta(nextProgress, previous); useManastormStore.getState().finishTransition(); return nextProgress; } describe("Manastorm checkpoint and reward persistence", () => { beforeEach(() => { setManastormCatalog(INSTALLED_MANASTORM_RUNTIME_CATALOG); useManastormStore.getState().resetRun(); }); it("survives five clears, profile reload, a pre-10 wipe, and checkpoint resume", () => { const storage = new MemoryStorage(); const session = createOfflineSession(); const created = createCharacter({ ownerId: session.ownerId, name: "Stormkeeper", raceId: "human", classId: "priest", gender: "female", appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 }, }, storage).character!; let progress = created.manastormProgress!; useManastormStore.getState().startRun({ partySize: 3, startingLevel: 1, cacheMeter: progress.cacheMeter, rewardCaches: progress.rewardCaches, random: () => 0.31, }); for (let level = 1; level <= 5; level += 1) { expect(useManastormStore.getState().level).toBe(level); progress = clearCurrentLevel(progress); expect(updateCharacterManastormProgress( session.ownerId, created.id, progress, storage, )).not.toBeNull(); } const afterFive = listCharacters(session.ownerId, storage)[0]!.manastormProgress!; expect(manastormPartyProgress(afterFive, 3)).toEqual({ highestLevel: 5, checkpointLevel: 6, firstClearLevels: [1, 2, 3, 4, 5], }); expect(afterFive.currencies.bonzoBolts).toBeGreaterThan(0); expect(afterFive.currencies.bedlamBullions).toBeGreaterThan(0); expect(afterFive.rewardCaches).toHaveLength(5); expect(afterFive.rewardCaches).toEqual(expect.arrayContaining([ expect.objectContaining({ cacheId: "leveling-manastorm-cache", level: 5, }), ])); expect(hasClaimedManastormReward(afterFive, "manastorm:unlocks:0:3:5", 0)).toBe(true); expect(hasClaimedManastormReward( afterFive, "manastorm:first-clear:0:3:1:installed-leveling-first-clear", 0, )).toBe(true); expect(afterFive.unlockedContent).toEqual(expect.arrayContaining([ expect.stringMatching(/Moonbrook/), ])); // Simulate a full runtime wipe and profile reconstruction. useManastormStore.getState().resetRun(); let reloaded = listCharacters(session.ownerId, storage)[0]!.manastormProgress!; useManastormStore.getState().startRun({ partySize: 3, startingLevel: manastormPartyProgress(reloaded, 3).checkpointLevel, cacheMeter: reloaded.cacheMeter, rewardCaches: reloaded.rewardCaches, random: () => 0.31, }); expect(useManastormStore.getState()).toMatchObject({ level: 6, checkpointLevel: 6, rewardCache: expect.arrayContaining([ expect.objectContaining({ cacheId: "leveling-manastorm-cache" }), ]), }); // Clear 6-9, then wipe on level 10 before earning its clear. for (let level = 6; level <= 9; level += 1) { expect(useManastormStore.getState().level).toBe(level); reloaded = clearCurrentLevel(reloaded); expect(updateCharacterManastormProgress( session.ownerId, created.id, reloaded, storage, )).not.toBeNull(); } expect(useManastormStore.getState().level).toBe(10); expect(useManastormStore.getState().failRun()).toBe(true); const beforeWipe = listCharacters(session.ownerId, storage)[0]!.manastormProgress!; const failure = recordManastormFailure(beforeWipe, 3, 10); expect(failure.resumeLevel).toBe(6); expect(manastormPartyProgress(failure.progress, 3)).toMatchObject({ highestLevel: 9, checkpointLevel: 6, firstClearLevels: [1, 2, 3, 4, 5, 6, 7, 8, 9], }); expect(failure.progress.currencies.bonzoBolts).toBeGreaterThan(afterFive.currencies.bonzoBolts); expect(failure.progress.currencies.bedlamBullions).toBe(afterFive.currencies.bedlamBullions); expect(failure.progress.cacheMeter).toBe(0); expect(failure.progress.rewardCaches).toHaveLength(9); expect(failure.progress.rewardCaches.slice(0, 5)).toEqual(afterFive.rewardCaches); expect(hasClaimedManastormReward(failure.progress, "manastorm:unlocks:0:3:5", 0)).toBe(true); expect(hasClaimedManastormReward(failure.progress, "manastorm:unlocks:0:3:6", 0)).toBe(true); for (const partySize of [1, 2, 4, 5] as const) { expect(manastormPartyProgress(failure.progress, partySize)).toEqual({ highestLevel: 0, checkpointLevel: 1, firstClearLevels: [], }); } const reconstructed = listCharacters(session.ownerId, storage)[0]!.manastormProgress!; const failedRuntime = runtimeSnapshot(); useManastormStore.getState().resetRun(); const resetSync = synchronizeManastormProgress( reconstructed, failedRuntime, runtimeSnapshot(), getManastormCatalog(), ); expect(resetSync.progress.rewardCaches).toEqual(reconstructed.rewardCaches); expect(manastormModeProgress(resetSync.progress, 2).rewardCaches).toEqual([]); useManastormStore.getState().startRun({ partySize: failure.partySize, startingLevel: failure.resumeLevel, cacheMeter: resetSync.progress.cacheMeter, rewardCaches: resetSync.progress.rewardCaches, random: () => 0.31, }); expect(useManastormStore.getState()).toMatchObject({ level: 6, checkpointLevel: 6, cacheMeter: 0, rewardCache: expect.arrayContaining([ expect.objectContaining({ cacheId: "leveling-manastorm-cache" }), ]), }); }); it("syncs an end-game run only into mode 2 while currencies remain shared", () => { const previous: ManastormProgressRuntimeSnapshot = { status: "fighting", completed: 100, partySize: 2, modeId: 2, currency: { bedlamBullions: 4, bonzoBolts: 10 }, cacheMeter: 1, rewardCache: [], }; const state: ManastormProgressRuntimeSnapshot = { status: "portal-ready", completed: 101, partySize: 2, modeId: 2, currency: { bedlamBullions: 7, bonzoBolts: 12 }, cacheMeter: 2, rewardCache: [{ id: "endgame-cache-instance", cacheId: "endgame-cache", rewardId: "endgame-clear", stageId: "manastorm:stage:2:409:4229", level: 101, }], }; const catalog = { messages: [ { modeId: 0, progressionValue: 101, title: "Leveling unlock", description: "Must not cross modes.", }, { modeId: 2, progressionValue: 101, title: "End-game unlock", description: "Mode-local.", }, ], }; const synced = synchronizeManastormProgress( createEmptyManastormProgress(), previous, state, catalog, ).progress; expect(manastormPartyProgress(synced, 2, 0)).toEqual({ highestLevel: 0, checkpointLevel: 1, firstClearLevels: [], }); expect(manastormPartyProgress(synced, 2, 2)).toEqual({ highestLevel: 101, checkpointLevel: 1, firstClearLevels: [101], }); expect(manastormModeProgress(synced, 0)).toMatchObject({ unlockedContent: [], cacheMeter: 0, rewardCaches: [], claimedRewards: [], }); expect(manastormModeProgress(synced, 2)).toMatchObject({ unlockedContent: [expect.stringMatching(/End-game unlock/)], cacheMeter: 2, rewardCaches: [expect.objectContaining({ id: "endgame-cache-instance", modeId: 2, })], claimedRewards: ["manastorm:unlocks:2:2:101"], }); expect(synced.currencies).toEqual({ bedlamBullions: 3, bonzoBolts: 2 }); }); });