forked from phenom/healer-man
Release Healer Man 0.1.22
This commit is contained in:
1 parent
f6f2375145
commit
7f6001723a
144 files changed
+54107
-690
No files matched your search
@@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activateDungeonEncounterDefinitions,
|
||||
activeDungeonEncounterRevision,
|
||||
activeDungeonRunIsComplete,
|
||||
createDungeonEncounterSnapshot,
|
||||
dispatchActiveDungeonEntityEvent,
|
||||
dispatchActiveDungeonInteraction,
|
||||
dispatchDungeonEncounterSignal,
|
||||
drainDungeonEncounterEffects,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
importDungeonEncounterSnapshots,
|
||||
reduceDungeonEncounter,
|
||||
registerDungeonEncounterEffectHandler,
|
||||
resetDungeonEncounters,
|
||||
resetDungeonEncounterSnapshot,
|
||||
subscribeDungeonEncounterSnapshots,
|
||||
validateDungeonEncounterDefinition,
|
||||
@@ -104,14 +107,32 @@ const MANAGER_B = {
|
||||
{ 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", () => {
|
||||
@@ -195,6 +216,57 @@ describe("dungeon encounter reducer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -210,9 +282,173 @@ describe("dungeon encounter reducer", () => {
|
||||
}],
|
||||
})).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);
|
||||
@@ -224,8 +460,34 @@ describe("active dungeon encounter manager", () => {
|
||||
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);
|
||||
@@ -238,10 +500,9 @@ describe("active dungeon encounter manager", () => {
|
||||
actionId: "switch-cue",
|
||||
signalId: "test:switch-opened",
|
||||
});
|
||||
expect(effectHandler).toHaveBeenCalled();
|
||||
expect(effectHandler).toHaveBeenCalledTimes(3);
|
||||
expect(subscriber).toHaveBeenCalled();
|
||||
expect(activeDungeonEncounterRevision()).toBeGreaterThan(revisionBefore);
|
||||
expect(drainDungeonEncounterEffects("manager-b")).toHaveLength(3);
|
||||
expect(drainDungeonEncounterEffects("manager-b")).toEqual([]);
|
||||
|
||||
const exported = exportDungeonEncounterSnapshots("manager-b");
|
||||
@@ -267,7 +528,44 @@ describe("active dungeon encounter manager", () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user