import { afterEach, describe, expect, it, vi } from "vitest"; import { activateDungeonEncounterDefinitions, activeDungeonEncounterRevision, activeDungeonRunIsComplete, createDungeonEncounterSnapshot, dispatchActiveDungeonEntityEvent, dispatchActiveDungeonInteraction, dispatchDungeonEncounterSignal, drainDungeonEncounterEffects, dungeonEncounterSnapshot, exportDungeonEncounterCheckpointSnapshots, exportDungeonEncounterSnapshots, importDungeonEncounterSnapshots, reduceDungeonEncounter, registerDungeonEncounterEffectHandler, resetDungeonEncounters, 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" }, ], }, { id: "observe-boss-death", trigger: { kind: "entity-death", entityId: "boss" }, actions: [{ id: "mark-boss-dead", kind: "set-flag", flagId: "boss-dead", value: true }], }, ], } as const satisfies DungeonEncounterDefinition; const MANAGER_DIFFICULTY = { id: "test:manager-difficulty", dungeonId: "manager-difficulty", initialPhase: "idle", rules: [{ id: "heroic-signal", trigger: { kind: "signal", signalId: "test:heroic" }, guards: [{ kind: "difficulty", difficultyIds: ["heroic"] }], actions: [{ id: "mark-live-heroic", kind: "set-flag", flagId: "heroic", value: true }], }], } as const satisfies DungeonEncounterDefinition; afterEach(() => { activateDungeonEncounterDefinitions("wailing-caverns", [WAILING_NARALEX_ENCOUNTER]); useWailingEncounterStore.getState().reset(); drainDungeonEncounterEffects("manager-a"); drainDungeonEncounterEffects("manager-b"); drainDungeonEncounterEffects("manager-difficulty"); }); 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("reconstructs persisted state instead of retaining a temporary overwrite on reset", () => { const definition = { id: "test:persistent-reconstruction", dungeonId: "test-dungeon", initialPhase: "locked", initialState: { worldObjectStates: { herald: "locked" } }, resetPolicy: { bossWipePhase: "ready" }, rules: [ { id: "unlock-herald", trigger: { kind: "signal", signalId: "unlock" }, actions: [{ id: "persist-herald-ready", kind: "set-world-object-state", objectId: "herald", state: "ready", persistence: "run", }], }, { id: "start-attempt", trigger: { kind: "interact", objectId: "herald" }, guards: [{ kind: "world-object-state", objectId: "herald", states: ["ready"] }], actions: [{ id: "temporarily-activate-herald", kind: "set-world-object-state", objectId: "herald", state: "active", }], }, ], } as const satisfies DungeonEncounterDefinition; const unlocked = reduceDungeonEncounter( definition, createDungeonEncounterSnapshot(definition), { kind: "signal", signalId: "unlock", now: 1 }, ).snapshot; const active = reduceDungeonEncounter( definition, unlocked, { kind: "interact", objectId: "herald", now: 2 }, ).snapshot; expect(active.worldObjectStates.herald).toBe("active"); const reset = resetDungeonEncounterSnapshot(definition, active, "boss-wipe", 3); expect(reset).toMatchObject({ phase: "ready", worldObjectStates: { herald: "ready" }, }); }); 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/); }); it("rejects empty difficulty guards at definition validation", () => { expect(() => validateDungeonEncounterDefinition({ id: "test:invalid-difficulty", dungeonId: "test-dungeon", initialPhase: "idle", rules: [{ id: "empty-difficulty", trigger: { kind: "tick" }, guards: [{ kind: "difficulty", difficultyIds: [] }], actions: [{ id: "should-not-fire", kind: "set-phase", phase: "invalid" }], }], })).toThrow(/empty difficulty guard/); }); it("evaluates difficulty guards from ephemeral runtime context", () => { const definition = { id: "test:difficulty", dungeonId: "test-dungeon", initialPhase: "idle", rules: [{ id: "heroic-branch", trigger: { kind: "signal", signalId: "start" }, guards: [{ kind: "difficulty", difficultyIds: ["heroic"] }], actions: [{ id: "mark-heroic", kind: "set-flag", flagId: "heroic", value: true }], }], } as const satisfies DungeonEncounterDefinition; const initial = createDungeonEncounterSnapshot(definition); const event = { kind: "signal", signalId: "start", now: 1 } as const; expect(reduceDungeonEncounter(definition, initial, event, { difficultyId: "normal" }).snapshot).toBe(initial); expect(reduceDungeonEncounter(definition, initial, event, { difficultyId: "heroic" }).snapshot.flags.heroic).toBe(true); }); it("never turns an entities-dead safety timeout into boss or dungeon credit", () => { const completion = { id: "test:timeout-completion", dungeonId: "test-dungeon", initialPhase: "boss-active", initialState: { flags: { "dungeon-complete": false } }, rules: [{ id: "boss-cleared", trigger: { kind: "entities-dead", entityIds: ["boss"], timeoutMs: 1_000 }, actions: [ { id: "complete-timeout-boss", kind: "complete-objective", objectiveId: "boss", persistence: "run" }, { id: "checkpoint-timeout-boss", kind: "set-checkpoint", checkpointId: "boss", persistence: "run" }, { id: "complete-timeout-dungeon", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run" }, ], }], } as const satisfies DungeonEncounterDefinition; const initial = createDungeonEncounterSnapshot(completion, 0); const idled = reduceDungeonEncounter(completion, initial, { kind: "tick", now: 1_001, facts: { registeredEntityIds: ["boss"], deadEntityIds: [] }, }).snapshot; const missing = reduceDungeonEncounter(completion, initial, { kind: "tick", now: 1_001, facts: { registeredEntityIds: [], deadEntityIds: [] }, }).snapshot; expect(idled).toBe(initial); expect(missing).toBe(initial); const killed = reduceDungeonEncounter(completion, initial, { kind: "tick", now: 1_001, facts: { registeredEntityIds: ["boss"], deadEntityIds: ["boss"] }, }).snapshot; expect(killed.completedObjectiveIds).toEqual(["boss"]); expect(killed.flags["dungeon-complete"]).toBe(true); const recovery = { id: "test:timeout-recovery", dungeonId: "test-dungeon", initialPhase: "wave-active", rules: [{ id: "missing-wave-recovery", trigger: { kind: "entities-dead", entityIds: ["missing-add"], timeoutMs: 1_000 }, actions: [ { id: "despawn-missing-wave", kind: "deactivate-spawn-group", spawnGroupId: "missing-wave" }, { id: "leave-missing-wave", kind: "set-phase", phase: "recovered" }, ], }], } as const satisfies DungeonEncounterDefinition; const recovered = reduceDungeonEncounter( recovery, createDungeonEncounterSnapshot(recovery, 0), { kind: "tick", now: 1_001, facts: { registeredEntityIds: [], deadEntityIds: [] } }, ).snapshot; expect(recovered.phase).toBe("recovered"); const surrogateProgress = { id: "test:timeout-surrogate-progress", dungeonId: "test-dungeon", initialPhase: "wave-active", initialState: { flags: { "escort-complete": false } }, rules: [{ id: "missing-wave-cannot-persist", trigger: { kind: "entities-dead", entityIds: ["missing-add"], timeoutMs: 1_000 }, actions: [ { id: "leave-missing-persistent-wave", kind: "set-phase", phase: "recovered" }, { id: "persist-fake-escort-credit", kind: "set-flag", flagId: "escort-complete", value: true, persistence: "run", }, ], }], } as const satisfies DungeonEncounterDefinition; const surrogateInitial = createDungeonEncounterSnapshot(surrogateProgress, 0); const refused = reduceDungeonEncounter( surrogateProgress, surrogateInitial, { kind: "tick", now: 1_001, facts: { registeredEntityIds: [], deadEntityIds: [] } }, ).snapshot; expect(refused).toBe(surrogateInitial); }); }); describe("active dungeon encounter manager", () => { it("requires every declared completion authority before finishing a dungeon", () => { const baseline = { id: "test:completion-baseline", dungeonId: "completion-dungeon", initialPhase: "active", initialState: { flags: { "dungeon-complete": false } }, rules: [{ id: "complete-baseline", trigger: { kind: "signal", signalId: "baseline-complete" }, actions: [{ id: "mark-baseline-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run", }], }], } as const satisfies DungeonEncounterDefinition; const instance = { id: "test:completion-instance", dungeonId: "completion-dungeon", initialPhase: "active", initialState: { flags: { "dungeon-complete": false } }, rules: [{ id: "complete-instance", trigger: { kind: "signal", signalId: "instance-complete" }, actions: [{ id: "mark-instance-complete", kind: "set-flag", flagId: "dungeon-complete", value: true, persistence: "run", }], }], } as const satisfies DungeonEncounterDefinition; activateDungeonEncounterDefinitions("completion-dungeon", [baseline, instance]); expect(activeDungeonRunIsComplete()).toBe(false); dispatchDungeonEncounterSignal("baseline-complete", 1); expect(activeDungeonRunIsComplete()).toBe(false); dispatchDungeonEncounterSignal("instance-complete", 2); expect(activeDungeonRunIsComplete()).toBe(true); }); 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("broadcasts live combat entity edges to the active dungeon", () => { activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]); expect(dispatchActiveDungeonEntityEvent({ kind: "entity-death", entityId: "boss", now: 2_500, })).toEqual([]); expect(dungeonEncounterSnapshot(MANAGER_B.id)?.flags["boss-dead"]).toBe(true); const managerBRevision = dungeonEncounterSnapshot(MANAGER_B.id)?.revision; activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]); dispatchActiveDungeonEntityEvent({ kind: "entity-death", entityId: "boss", now: 2_600 }); expect(dungeonEncounterSnapshot(MANAGER_B.id)?.revision).toBe(managerBRevision); }); it("updates difficulty context when an existing dungeon provider is reactivated", () => { activateDungeonEncounterDefinitions("manager-difficulty", [MANAGER_DIFFICULTY], "normal"); dispatchDungeonEncounterSignal("test:heroic", 1); expect(dungeonEncounterSnapshot(MANAGER_DIFFICULTY.id)?.flags.heroic).not.toBe(true); activateDungeonEncounterDefinitions("manager-difficulty", [MANAGER_DIFFICULTY], "heroic"); dispatchDungeonEncounterSignal("test:heroic", 2); expect(dungeonEncounterSnapshot(MANAGER_DIFFICULTY.id)?.flags.heroic).toBe(true); }); it("dispatches interactions, exposes effects, snapshots, hydration, and subscriptions", () => { activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]); resetDungeonEncounters("dungeon", "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).toHaveBeenCalledTimes(3); expect(subscriber).toHaveBeenCalled(); expect(activeDungeonEncounterRevision()).toBeGreaterThan(revisionBefore); 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"); const olderAuthority = { ...hydrated, phase: "authority-restored", revision: Math.max(0, hydrated.revision - 1), }; expect(importDungeonEncounterSnapshots("manager-b", [olderAuthority])).toEqual([]); expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("restored"); expect(importDungeonEncounterSnapshots( "manager-b", [olderAuthority], { authoritative: true }, )).toEqual([MANAGER_B.id]); expect(dungeonEncounterSnapshot(MANAGER_B.id)?.phase).toBe("authority-restored"); unregisterEffect(); unsubscribe(); }); it("keeps effects drainable without a handler and clears queued effects on reset", () => { activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]); resetDungeonEncounters("dungeon", "manager-b"); expect(dispatchActiveDungeonInteraction("switch", "player", 4_000)).toHaveLength(3); expect(drainDungeonEncounterEffects("manager-b")).toHaveLength(3); resetDungeonEncounters("dungeon", "manager-b"); expect(dispatchActiveDungeonInteraction("switch", "player", 4_100)).toHaveLength(3); resetDungeonEncounters("dungeon", "manager-b"); expect(drainDungeonEncounterEffects("manager-b")).toEqual([]); }); it("discards undrained effects when the active dungeon changes", () => { activateDungeonEncounterDefinitions("manager-b", [MANAGER_B]); resetDungeonEncounters("dungeon", "manager-b"); expect(dispatchActiveDungeonInteraction("switch", "player", 5_000)).toHaveLength(3); activateDungeonEncounterDefinitions("manager-a", [MANAGER_A]); expect(drainDungeonEncounterEffects("manager-b")).toEqual([]); }); });