Implement the 017-combat-conditions feature that adds D&D 5e status conditions to combatants with icon tags, color coding, and a compact toggle picker in the encounter tracker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-06 11:29:39 +01:00
parent 78c6591973
commit febe892e15
22 changed files with 1301 additions and 62 deletions

View File

@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import type { ConditionId } from "../conditions.js";
import { CONDITION_DEFINITIONS } from "../conditions.js";
import { toggleCondition } from "../toggle-condition.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
function makeCombatant(
name: string,
conditions?: readonly ConditionId[],
): 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(["blinded"]);
expect(events).toEqual([
{
type: "ConditionAdded",
combatantId: combatantId("A"),
condition: "blinded",
},
]);
});
it("removes a condition when already present", () => {
const e = enc([makeCombatant("A", ["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", ["poisoned"])]);
const { encounter } = success(e, "A", "blinded");
expect(encounter.combatants[0].conditions).toEqual(["blinded", "poisoned"]);
});
it("prevents duplicate conditions", () => {
const e = enc([makeCombatant("A", ["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,
);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("unknown-condition");
}
});
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = toggleCondition(e, combatantId("missing"), "blinded");
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("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", ["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);
});
});