Files
healer-man/src/game/dungeonEncounterRuntime.test.ts
T
2026-09-09 11:21:33 -04:00

572 lines
22 KiB
TypeScript

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([]);
});
});