Release Healer Man 0.1.21
This commit is contained in:
1 parent
699fc858c9
commit
f6f2375145
210 files changed
+976039
-6247
No files matched your search
@@ -0,0 +1,273 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user