import { afterEach, describe, expect, it, vi } from "vitest"; import { activateDungeonEncounterDefinitions, activeDungeonEncounterRevision, createDungeonEncounterSnapshot, dispatchActiveDungeonInteraction, dispatchDungeonEncounterSignal, drainDungeonEncounterEffects, dungeonEncounterSnapshot, exportDungeonEncounterCheckpointSnapshots, exportDungeonEncounterSnapshots, importDungeonEncounterSnapshots, reduceDungeonEncounter, registerDungeonEncounterEffectHandler, resetDungeonEncounterSnapshot, subscribeDungeonEncounterSnapshots, validateDungeonEncounterDefinition, } from "./dungeonEncounterRuntime"; import type { DungeonEncounterDefinition } from "./dungeonEncounterTypes"; import { WAILING_NARALEX_ENCOUNTER, useWailingEncounterStore, } from "./wailingCavernsEncounter"; const REDUCER_DEFINITION = { id: "test:reducer", dungeonId: "test-dungeon", initialPhase: "idle", initialState: { flags: { temporary: false, persistent: false }, counters: { attempts: 0 }, worldObjectStates: { gate: "closed" }, }, resetPolicy: { bossWipePhase: "idle", checkpointPhase: "checkpoint-ready" }, rules: [ { id: "pull-lever", trigger: { kind: "interact", objectId: "lever" }, guards: [{ kind: "phase", phases: ["idle"] }], actions: [ { id: "enter-active", kind: "set-phase", phase: "active" }, { id: "mark-temporary", kind: "set-flag", flagId: "temporary", value: true }, { id: "mark-persistent", kind: "set-flag", flagId: "persistent", value: true, persistence: "checkpoint" }, { id: "open-gate", kind: "set-world-object-state", objectId: "gate", state: "open", persistence: "checkpoint" }, { id: "enable-route", kind: "set-transition-active", transitionId: "gate-route", active: true, persistence: "checkpoint" }, { id: "spawn-wave", kind: "activate-spawn-group", spawnGroupId: "wave-one" }, { id: "save-checkpoint", kind: "set-checkpoint", checkpointId: "lever", persistence: "checkpoint" }, { id: "move-party", kind: "teleport-party", destination: [1, 2, 3] }, ], }, { id: "count-active-ticks", trigger: { kind: "tick" }, guards: [{ kind: "phase", phases: ["active"] }], once: false, actions: [{ id: "count-attempt", kind: "increment-counter", counterId: "attempts" }], }, { id: "finish-after-delay", trigger: { kind: "elapsed", afterMs: 5_000 }, guards: [ { kind: "phase", phases: ["active"] }, { kind: "flag", flagId: "persistent", equals: true }, ], actions: [ { id: "finish", kind: "set-phase", phase: "complete", persistence: "run" }, { id: "complete-objective", kind: "complete-objective", objectiveId: "lever-boss", persistence: "run" }, ], }, ], } as const satisfies DungeonEncounterDefinition; const MANAGER_A = { id: "test:manager-a", dungeonId: "manager-a", initialPhase: "idle", rules: [{ id: "shared-signal", trigger: { kind: "signal", signalId: "test:shared" }, once: false, actions: [{ id: "increment-a", kind: "increment-counter", counterId: "signals" }], }], } as const satisfies DungeonEncounterDefinition; const MANAGER_B = { id: "test:manager-b", dungeonId: "manager-b", initialPhase: "idle", initialState: { worldObjectStates: { switch: "ready" } }, rules: [ { id: "shared-signal", trigger: { kind: "signal", signalId: "test:shared" }, once: false, actions: [{ id: "increment-b", kind: "increment-counter", counterId: "signals" }], }, { id: "use-switch", trigger: { kind: "interact", objectId: "switch" }, guards: [{ kind: "world-object-state", objectId: "switch", states: ["ready"] }], actions: [ { id: "open-switch", kind: "set-world-object-state", objectId: "switch", state: "open", persistence: "checkpoint" }, { id: "start-group", kind: "activate-spawn-group", spawnGroupId: "switch-wave" }, { id: "switch-cue", kind: "emit-signal", signalId: "test:switch-opened" }, ], }, ], } as const satisfies DungeonEncounterDefinition; afterEach(() => { activateDungeonEncounterDefinitions("wailing-caverns", [WAILING_NARALEX_ENCOUNTER]); useWailingEncounterStore.getState().reset(); drainDungeonEncounterEffects("manager-a"); drainDungeonEncounterEffects("manager-b"); }); describe("dungeon encounter reducer", () => { it("applies stable reusable actions deterministically and remains JSON serializable", () => { const initial = createDungeonEncounterSnapshot(REDUCER_DEFINITION, 100); const event = { kind: "interact", objectId: "lever", actorId: "player", now: 1_000 } as const; const first = reduceDungeonEncounter(REDUCER_DEFINITION, initial, event); const second = reduceDungeonEncounter(REDUCER_DEFINITION, initial, event); expect(second).toEqual(first); expect(first.snapshot).toMatchObject({ phase: "active", phaseStartedAt: 1_000, flags: { temporary: true, persistent: true }, worldObjectStates: { gate: "open" }, transitionStates: { "gate-route": true }, activeSpawnGroupIds: ["wave-one"], checkpointId: "lever", revision: 1, }); expect(first.effects).toEqual(expect.arrayContaining([ { kind: "world-object-state", actionId: "open-gate", objectId: "gate", state: "open" }, { kind: "spawn-group", actionId: "spawn-wave", spawnGroupId: "wave-one" }, { kind: "party-teleport", actionId: "move-party", destination: [1, 2, 3] }, ])); expect(JSON.parse(JSON.stringify(first.snapshot))).toEqual(first.snapshot); const duplicate = reduceDungeonEncounter(REDUCER_DEFINITION, first.snapshot, event); expect(duplicate.snapshot).toBe(first.snapshot); expect(duplicate.effects).toEqual([]); }); it("supports repeatable rules, elapsed transitions, and scoped resets", () => { const started = reduceDungeonEncounter( REDUCER_DEFINITION, createDungeonEncounterSnapshot(REDUCER_DEFINITION), { kind: "interact", objectId: "lever", now: 1_000 }, ).snapshot; const tickOne = reduceDungeonEncounter( REDUCER_DEFINITION, started, { kind: "tick", now: 2_000 }, ).snapshot; const tickTwo = reduceDungeonEncounter( REDUCER_DEFINITION, tickOne, { kind: "tick", now: 3_000 }, ).snapshot; expect(tickTwo.counters.attempts).toBe(2); const completed = reduceDungeonEncounter( REDUCER_DEFINITION, tickTwo, { kind: "tick", now: 6_000 }, ).snapshot; expect(completed.phase).toBe("complete"); expect(completed.completedObjectiveIds).toEqual(["lever-boss"]); const checkpoint = resetDungeonEncounterSnapshot( REDUCER_DEFINITION, completed, "checkpoint", 7_000, ); expect(checkpoint).toMatchObject({ phase: "checkpoint-ready", flags: { temporary: false, persistent: true }, counters: { attempts: 0 }, worldObjectStates: { gate: "open" }, transitionStates: { "gate-route": true }, activeSpawnGroupIds: [], completedObjectiveIds: ["lever-boss"], checkpointId: "lever", }); expect(resetDungeonEncounterSnapshot(REDUCER_DEFINITION, completed, "dungeon")).toMatchObject({ phase: "idle", flags: { temporary: false, persistent: false }, worldObjectStates: { gate: "closed" }, completedObjectiveIds: [], checkpointId: null, }); }); it("rejects duplicate action ids at definition validation", () => { expect(() => validateDungeonEncounterDefinition({ id: "test:invalid", dungeonId: "test-dungeon", initialPhase: "idle", rules: [{ id: "duplicate-actions", trigger: { kind: "tick" }, actions: [ { id: "same", kind: "set-phase", phase: "one" }, { id: "same", kind: "set-phase", phase: "two" }, ], }], })).toThrow(/duplicate action id same/); }); }); describe("active dungeon encounter manager", () => { it("isolates shared signals to the active dungeon", () => { activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]); dispatchDungeonEncounterSignal("test:shared", 1_000); expect(dungeonEncounterSnapshot(MANAGER_A.id)?.counters.signals).toBe(1); activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]); dispatchDungeonEncounterSignal("test:shared", 2_000); expect(dungeonEncounterSnapshot(MANAGER_A.id)?.counters.signals).toBe(1); expect(dungeonEncounterSnapshot(MANAGER_B.id)?.counters.signals).toBe(1); }); it("dispatches interactions, exposes effects, snapshots, hydration, and subscriptions", () => { activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]); const subscriber = vi.fn(); const effectHandler = vi.fn(); const unsubscribe = subscribeDungeonEncounterSnapshots(subscriber); const unregisterEffect = registerDungeonEncounterEffectHandler(effectHandler); const revisionBefore = activeDungeonEncounterRevision(); const effects = dispatchActiveDungeonInteraction("switch", "player", 3_000); expect(effects).toContainEqual({ kind: "signal", actionId: "switch-cue", signalId: "test:switch-opened", }); expect(effectHandler).toHaveBeenCalled(); expect(subscriber).toHaveBeenCalled(); expect(activeDungeonEncounterRevision()).toBeGreaterThan(revisionBefore); expect(drainDungeonEncounterEffects("manager-b")).toHaveLength(3); expect(drainDungeonEncounterEffects("manager-b")).toEqual([]); const exported = exportDungeonEncounterSnapshots("manager-b"); expect(exported).toHaveLength(1); expect(exported[0]).toMatchObject({ encounterId: MANAGER_B.id, worldObjectStates: { switch: "open" }, activeSpawnGroupIds: ["switch-wave"], }); const checkpoint = exportDungeonEncounterCheckpointSnapshots("manager-b", 4_000); expect(checkpoint[0]).toMatchObject({ worldObjectStates: { switch: "open" }, activeSpawnGroupIds: [], timers: {}, }); expect(exportDungeonEncounterSnapshots("manager-b")[0].activeSpawnGroupIds).toEqual(["switch-wave"]); const hydrated = { ...exported[0], phase: "restored", revision: exported[0].revision + 1, }; expect(importDungeonEncounterSnapshots("manager-b", [hydrated])).toEqual([MANAGER_B.id]); expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("restored"); unregisterEffect(); unsubscribe(); }); });