Cap dying (4), doomed (3), wounded (3), and slowed (3) at their rule-defined maximums. The domain clamps values in setConditionValue and the condition picker disables the [+] button at the cap. Closes #31 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
262 lines
8.2 KiB
TypeScript
262 lines
8.2 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { ConditionEntry, ConditionId } from "../conditions.js";
|
|
import { CONDITION_DEFINITIONS } from "../conditions.js";
|
|
import {
|
|
decrementCondition,
|
|
setConditionValue,
|
|
toggleCondition,
|
|
} from "../toggle-condition.js";
|
|
import type { Combatant, Encounter } from "../types.js";
|
|
import { combatantId, isDomainError } from "../types.js";
|
|
import { expectDomainError } from "./test-helpers.js";
|
|
|
|
function makeCombatant(
|
|
name: string,
|
|
conditions?: readonly ConditionEntry[],
|
|
): Combatant {
|
|
return conditions
|
|
? { id: combatantId(name), name, conditions }
|
|
: { id: combatantId(name), name };
|
|
}
|
|
|
|
function enc(combatants: Combatant[]): Encounter {
|
|
return { combatants, activeIndex: 0, roundNumber: 1 };
|
|
}
|
|
|
|
function success(encounter: Encounter, id: string, condition: ConditionId) {
|
|
const result = toggleCondition(encounter, combatantId(id), condition);
|
|
if (isDomainError(result)) {
|
|
throw new Error(`Expected success, got error: ${result.message}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
describe("toggleCondition", () => {
|
|
it("adds a condition when not present", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const { encounter, events } = success(e, "A", "blinded");
|
|
|
|
expect(encounter.combatants[0].conditions).toEqual([{ id: "blinded" }]);
|
|
expect(events).toEqual([
|
|
{
|
|
type: "ConditionAdded",
|
|
combatantId: combatantId("A"),
|
|
condition: "blinded",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("removes a condition when already present", () => {
|
|
const e = enc([makeCombatant("A", [{ id: "blinded" }])]);
|
|
const { encounter, events } = success(e, "A", "blinded");
|
|
|
|
expect(encounter.combatants[0].conditions).toBeUndefined();
|
|
expect(events).toEqual([
|
|
{
|
|
type: "ConditionRemoved",
|
|
combatantId: combatantId("A"),
|
|
condition: "blinded",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("maintains definition order when adding conditions", () => {
|
|
const e = enc([makeCombatant("A", [{ id: "poisoned" }])]);
|
|
const { encounter } = success(e, "A", "blinded");
|
|
|
|
expect(encounter.combatants[0].conditions).toEqual([
|
|
{ id: "blinded" },
|
|
{ id: "poisoned" },
|
|
]);
|
|
});
|
|
|
|
it("prevents duplicate conditions", () => {
|
|
const e = enc([makeCombatant("A", [{ id: "blinded" }])]);
|
|
// Toggling blinded again removes it, not duplicates
|
|
const { encounter } = success(e, "A", "blinded");
|
|
expect(encounter.combatants[0].conditions).toBeUndefined();
|
|
});
|
|
|
|
it("rejects unknown condition", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const result = toggleCondition(
|
|
e,
|
|
combatantId("A"),
|
|
"flying" as ConditionId,
|
|
);
|
|
|
|
expectDomainError(result, "unknown-condition");
|
|
});
|
|
|
|
it("returns error for nonexistent combatant", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const result = toggleCondition(e, combatantId("missing"), "blinded");
|
|
|
|
expectDomainError(result, "combatant-not-found");
|
|
});
|
|
|
|
it("does not mutate input encounter", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const original = JSON.parse(JSON.stringify(e));
|
|
toggleCondition(e, combatantId("A"), "blinded");
|
|
expect(e).toEqual(original);
|
|
});
|
|
|
|
it("normalizes empty array to undefined on removal", () => {
|
|
const e = enc([makeCombatant("A", [{ id: "charmed" }])]);
|
|
const { encounter } = success(e, "A", "charmed");
|
|
|
|
expect(encounter.combatants[0].conditions).toBeUndefined();
|
|
});
|
|
|
|
it("preserves order across all conditions", () => {
|
|
const order = CONDITION_DEFINITIONS.map((d) => d.id);
|
|
// Add in reverse order
|
|
let e = enc([makeCombatant("A")]);
|
|
for (const cond of [...order].reverse()) {
|
|
const result = success(e, "A", cond);
|
|
e = result.encounter;
|
|
}
|
|
expect(e.combatants[0].conditions).toEqual(order.map((id) => ({ id })));
|
|
});
|
|
});
|
|
|
|
describe("setConditionValue", () => {
|
|
it("adds a valued condition at the specified value", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const result = setConditionValue(e, combatantId("A"), "frightened", 2);
|
|
if (isDomainError(result)) throw new Error(result.message);
|
|
|
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
|
{ id: "frightened", value: 2 },
|
|
]);
|
|
expect(result.events).toEqual([
|
|
{
|
|
type: "ConditionAdded",
|
|
combatantId: combatantId("A"),
|
|
condition: "frightened",
|
|
value: 2,
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("updates the value of an existing condition", () => {
|
|
const e = enc([makeCombatant("A", [{ id: "frightened", value: 1 }])]);
|
|
const result = setConditionValue(e, combatantId("A"), "frightened", 3);
|
|
if (isDomainError(result)) throw new Error(result.message);
|
|
|
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
|
{ id: "frightened", value: 3 },
|
|
]);
|
|
});
|
|
|
|
it("removes condition when value is 0", () => {
|
|
const e = enc([makeCombatant("A", [{ id: "frightened", value: 2 }])]);
|
|
const result = setConditionValue(e, combatantId("A"), "frightened", 0);
|
|
if (isDomainError(result)) throw new Error(result.message);
|
|
|
|
expect(result.encounter.combatants[0].conditions).toBeUndefined();
|
|
expect(result.events[0].type).toBe("ConditionRemoved");
|
|
});
|
|
|
|
it("rejects unknown condition", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const result = setConditionValue(
|
|
e,
|
|
combatantId("A"),
|
|
"flying" as ConditionId,
|
|
1,
|
|
);
|
|
expectDomainError(result, "unknown-condition");
|
|
});
|
|
|
|
it("clamps value to maxValue for capped conditions", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const result = setConditionValue(e, combatantId("A"), "dying", 6);
|
|
if (isDomainError(result)) throw new Error(result.message);
|
|
|
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
|
{ id: "dying", value: 4 },
|
|
]);
|
|
expect(result.events[0]).toMatchObject({
|
|
type: "ConditionAdded",
|
|
value: 4,
|
|
});
|
|
});
|
|
|
|
it("allows value at exactly the max", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const result = setConditionValue(e, combatantId("A"), "doomed", 3);
|
|
if (isDomainError(result)) throw new Error(result.message);
|
|
|
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
|
{ id: "doomed", value: 3 },
|
|
]);
|
|
});
|
|
|
|
it("allows value below the max", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const result = setConditionValue(e, combatantId("A"), "wounded", 2);
|
|
if (isDomainError(result)) throw new Error(result.message);
|
|
|
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
|
{ id: "wounded", value: 2 },
|
|
]);
|
|
});
|
|
|
|
it("does not cap conditions without a maxValue", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const result = setConditionValue(e, combatantId("A"), "frightened", 10);
|
|
if (isDomainError(result)) throw new Error(result.message);
|
|
|
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
|
{ id: "frightened", value: 10 },
|
|
]);
|
|
});
|
|
|
|
it("clamps when updating an existing capped condition", () => {
|
|
const e = enc([makeCombatant("A", [{ id: "slowed-pf2e", value: 2 }])]);
|
|
const result = setConditionValue(e, combatantId("A"), "slowed-pf2e", 5);
|
|
if (isDomainError(result)) throw new Error(result.message);
|
|
|
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
|
{ id: "slowed-pf2e", value: 3 },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("decrementCondition", () => {
|
|
it("decrements value by 1", () => {
|
|
const e = enc([makeCombatant("A", [{ id: "frightened", value: 3 }])]);
|
|
const result = decrementCondition(e, combatantId("A"), "frightened");
|
|
if (isDomainError(result)) throw new Error(result.message);
|
|
|
|
expect(result.encounter.combatants[0].conditions).toEqual([
|
|
{ id: "frightened", value: 2 },
|
|
]);
|
|
});
|
|
|
|
it("removes condition when value reaches 0", () => {
|
|
const e = enc([makeCombatant("A", [{ id: "frightened", value: 1 }])]);
|
|
const result = decrementCondition(e, combatantId("A"), "frightened");
|
|
if (isDomainError(result)) throw new Error(result.message);
|
|
|
|
expect(result.encounter.combatants[0].conditions).toBeUndefined();
|
|
expect(result.events[0].type).toBe("ConditionRemoved");
|
|
});
|
|
|
|
it("removes non-valued condition (value undefined treated as 1)", () => {
|
|
const e = enc([makeCombatant("A", [{ id: "blinded" }])]);
|
|
const result = decrementCondition(e, combatantId("A"), "blinded");
|
|
if (isDomainError(result)) throw new Error(result.message);
|
|
|
|
expect(result.encounter.combatants[0].conditions).toBeUndefined();
|
|
});
|
|
|
|
it("returns error for inactive condition", () => {
|
|
const e = enc([makeCombatant("A")]);
|
|
const result = decrementCondition(e, combatantId("A"), "frightened");
|
|
expectDomainError(result, "condition-not-active");
|
|
});
|
|
});
|