import { beforeEach, describe, expect, it } from "vitest"; import { DUNGEON_DEFINITIONS, requireDungeonDefinition } from "./dungeonRegistry"; import { completedDungeonRun, finalDungeonBoss, useDungeonRunStore } from "./dungeonRunStore"; import { normalizeDungeonRunProgress, formatDungeonDuration, type DungeonRunMember } from "./dungeonRunTypes"; import { activateDungeonEncounterDefinitions, activeDungeonCompletedObjectiveIds, activeDungeonRunIsComplete, advanceDungeonEncounters, createDungeonEncounterSnapshot, exportDungeonEncounterCheckpointSnapshots, importDungeonEncounterSnapshots, reduceDungeonEncounter, resetDungeonEncounters } from "./dungeonEncounterRuntime"; const party: readonly DungeonRunMember[] = [{ name: "Tester", classId: "rom-priest", level: 1, role: "healer", npc: false }]; beforeEach(() => { useDungeonRunStore.setState({ progress: null }); }); describe("dungeon completion and run timing", () => { it("preserves the selected difficulty on resume and starts a new timer when it changes", () => { const run = useDungeonRunStore.getState(); run.begin("deadmines", "normal", undefined, false, 7); run.start(1000, party); const saved = normalizeDungeonRunProgress(JSON.parse(JSON.stringify(useDungeonRunStore.getState().progress)))!; run.begin("deadmines", "normal", saved, false, 7); expect(useDungeonRunStore.getState().progress).toEqual(saved); run.begin("deadmines", "normal", saved, false, 8); expect(useDungeonRunStore.getState().progress).toMatchObject({ bossDifficulty: 8, startedAt: null }); expect(useDungeonRunStore.getState().progress!.id).not.toBe(saved.id); expect(normalizeDungeonRunProgress({ ...saved, bossDifficulty: 11 })).toBeNull(); expect(normalizeDungeonRunProgress({ ...saved, bossDifficulty: 1.5 })).toBeNull(); }); it("completes Barren Caves when Anglo dies, records its real spawn id, and survives checkpoint hydration", () => { const dungeon = requireDungeonDefinition("barren-caves"); activateDungeonEncounterDefinitions(dungeon.id, dungeon.encounters ?? [], "normal"); resetDungeonEncounters("dungeon", dungeon.id); const run = useDungeonRunStore.getState(); run.begin(dungeon.id, "normal"); run.start(1000, party); advanceDungeonEncounters(dungeon.id, { kind: "tick", now: 1000, facts: { registeredEntityIds: [], deadEntityIds: [] } }); expect(activeDungeonRunIsComplete(dungeon.id)).toBe(false); const boss = finalDungeonBoss(dungeon)!; expect(boss.name).toBe("Anglo"); advanceDungeonEncounters(dungeon.id, { kind: "tick", now: 12456, facts: { registeredEntityIds: [boss.id], deadEntityIds: [boss.id] } }); run.advance(activeDungeonCompletedObjectiveIds(dungeon.id), [boss.id], 12456, party); expect(activeDungeonRunIsComplete(dungeon.id)).toBe(true); expect(completedDungeonRun(useDungeonRunStore.getState().progress!)).toMatchObject({ durationMs: 11456, fullClear: true, killedBossIds: [boss.id], party, }); const snapshots = exportDungeonEncounterCheckpointSnapshots(dungeon.id, 0); resetDungeonEncounters("dungeon", dungeon.id); importDungeonEncounterSnapshots(dungeon.id, snapshots, { authoritative: true }); expect(activeDungeonRunIsComplete(dungeon.id)).toBe(true); const result = useDungeonRunStore.getState().progress; run.advance([boss.id], [boss.id], 99999, []); run.start(99999, []); expect(useDungeonRunStore.getState().progress).toBe(result); }); it("adds durable completion bookkeeping to every RoM dungeon with configured bosses, without awarding missing bosses", () => { for (const dungeon of DUNGEON_DEFINITIONS.filter((entry) => entry.expansion === "runewaker" && entry.bosses.length > 0)) { const baseline = dungeon.encounters!.find((entry) => entry.id === `${dungeon.id}:completion-baseline`)!; expect(baseline, dungeon.id).toBeDefined(); let snapshot = createDungeonEncounterSnapshot(baseline, 0); snapshot = reduceDungeonEncounter(baseline, snapshot, { kind: "tick", now: 10, facts: { registeredEntityIds: [], deadEntityIds: [] } }).snapshot; expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(false); const ordinary = [...new Set([...dungeon.objectiveOrder, ...dungeon.bosses] .filter((boss) => !boss.eventOnly).map((boss) => boss.id))]; snapshot = reduceDungeonEncounter(baseline, snapshot, { kind: "tick", now: 20, facts: { registeredEntityIds: ordinary, deadEntityIds: ordinary } }).snapshot; expect(snapshot.completedObjectiveIds, dungeon.id).toEqual(expect.arrayContaining(ordinary)); if (!dungeon.objectiveOrder.some((boss) => !boss.optional && boss.eventOnly)) { expect(snapshot.flags["dungeon-complete"], dungeon.id).toBe(true); } } }); it("finishes a final boss clear even when earlier bosses were skipped, but keeps it out of full clears", () => { const dungeon = requireDungeonDefinition("deadmines"); const final = finalDungeonBoss(dungeon)!; const run = useDungeonRunStore.getState(); run.begin(dungeon.id, dungeon.defaultDifficultyId); run.start(1000, party); run.advance([], [final.id], 2000, party); expect(useDungeonRunStore.getState().progress?.finishedAt).toBeNull(); run.advance([final.id], [final.id], 3000, party); expect(completedDungeonRun(useDungeonRunStore.getState().progress!)).toMatchObject({ durationMs: 2000, fullClear: false }); }); it("keeps the original wall-clock timer and boss history through a wipe and a saved resume", () => { const dungeon = requireDungeonDefinition("deadmines"); const run = useDungeonRunStore.getState(); run.begin(dungeon.id, "normal"); run.start(1000, party); run.advance([], [dungeon.bosses[0].id], 2000, party); const saved = normalizeDungeonRunProgress(JSON.parse(JSON.stringify(useDungeonRunStore.getState().progress)))!; run.begin(dungeon.id, "normal", saved); run.start(5000, party); run.advance([finalDungeonBoss(dungeon)!.id], [], 9000, party); expect(useDungeonRunStore.getState().progress).toMatchObject({ id: saved.id, startedAt: 1000, durationMs: 8000, killedBossIds: expect.arrayContaining([dungeon.bosses[0].id]) }); }); it("announces legacy resumed runs without inventing a leaderboard time", () => { const run = useDungeonRunStore.getState(); run.begin("barren-caves", "normal", undefined, true); run.start(1000, party); run.advance([finalDungeonBoss(requireDungeonDefinition("barren-caves"))!.id], [], 2000, party); expect(useDungeonRunStore.getState().progress?.finishedAt).toBe(2000); expect(completedDungeonRun(useDungeonRunStore.getState().progress!)).toBeNull(); }); it("rejects corrupted saved timers and impossible party levels", () => { useDungeonRunStore.getState().begin("barren-caves", "normal"); const progress = useDungeonRunStore.getState().progress!; expect(normalizeDungeonRunProgress({ ...progress, startedAt: NaN })).toBeNull(); expect(normalizeDungeonRunProgress({ ...progress, startedAt: 100, finishedAt: 50, durationMs: 10 })).toBeNull(); expect(normalizeDungeonRunProgress({ ...progress, party: [{ ...party[0], level: 81 }] })).toBeNull(); expect(formatDungeonDuration(123456)).toBe("02:03.4"); }); });