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);
});
});

View File

@@ -0,0 +1,85 @@
export type ConditionId =
| "blinded"
| "charmed"
| "deafened"
| "exhaustion"
| "frightened"
| "grappled"
| "incapacitated"
| "invisible"
| "paralyzed"
| "petrified"
| "poisoned"
| "prone"
| "restrained"
| "stunned"
| "unconscious";
export interface ConditionDefinition {
readonly id: ConditionId;
readonly label: string;
readonly iconName: string;
readonly color: string;
}
export const CONDITION_DEFINITIONS: readonly ConditionDefinition[] = [
{ id: "blinded", label: "Blinded", iconName: "EyeOff", color: "neutral" },
{ id: "charmed", label: "Charmed", iconName: "Heart", color: "pink" },
{ id: "deafened", label: "Deafened", iconName: "EarOff", color: "neutral" },
{
id: "exhaustion",
label: "Exhaustion",
iconName: "BatteryLow",
color: "amber",
},
{
id: "frightened",
label: "Frightened",
iconName: "Siren",
color: "orange",
},
{ id: "grappled", label: "Grappled", iconName: "Hand", color: "neutral" },
{
id: "incapacitated",
label: "Incapacitated",
iconName: "Ban",
color: "gray",
},
{
id: "invisible",
label: "Invisible",
iconName: "Ghost",
color: "violet",
},
{
id: "paralyzed",
label: "Paralyzed",
iconName: "ZapOff",
color: "yellow",
},
{
id: "petrified",
label: "Petrified",
iconName: "Gem",
color: "slate",
},
{ id: "poisoned", label: "Poisoned", iconName: "Droplet", color: "green" },
{ id: "prone", label: "Prone", iconName: "ArrowDown", color: "neutral" },
{
id: "restrained",
label: "Restrained",
iconName: "Link",
color: "neutral",
},
{ id: "stunned", label: "Stunned", iconName: "Sparkles", color: "yellow" },
{
id: "unconscious",
label: "Unconscious",
iconName: "Moon",
color: "indigo",
},
] as const;
export const VALID_CONDITION_IDS: ReadonlySet<string> = new Set(
CONDITION_DEFINITIONS.map((d) => d.id),
);

View File

@@ -1,3 +1,4 @@
import type { ConditionId } from "./conditions.js";
import type { CombatantId } from "./types.js";
export interface TurnAdvanced {
@@ -75,6 +76,18 @@ export interface AcSet {
readonly newAc: number | undefined;
}
export interface ConditionAdded {
readonly type: "ConditionAdded";
readonly combatantId: CombatantId;
readonly condition: ConditionId;
}
export interface ConditionRemoved {
readonly type: "ConditionRemoved";
readonly combatantId: CombatantId;
readonly condition: ConditionId;
}
export type DomainEvent =
| TurnAdvanced
| RoundAdvanced
@@ -86,4 +99,6 @@ export type DomainEvent =
| CurrentHpAdjusted
| TurnRetreated
| RoundRetreated
| AcSet;
| AcSet
| ConditionAdded
| ConditionRemoved;

View File

@@ -1,6 +1,12 @@
export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js";
export { type AdjustHpSuccess, adjustHp } from "./adjust-hp.js";
export { advanceTurn } from "./advance-turn.js";
export {
CONDITION_DEFINITIONS,
type ConditionDefinition,
type ConditionId,
VALID_CONDITION_IDS,
} from "./conditions.js";
export {
type EditCombatantSuccess,
editCombatant,
@@ -10,6 +16,8 @@ export type {
CombatantAdded,
CombatantRemoved,
CombatantUpdated,
ConditionAdded,
ConditionRemoved,
CurrentHpAdjusted,
DomainEvent,
InitiativeSet,
@@ -31,6 +39,10 @@ export {
type SetInitiativeSuccess,
setInitiative,
} from "./set-initiative.js";
export {
type ToggleConditionSuccess,
toggleCondition,
} from "./toggle-condition.js";
export {
type Combatant,
type CombatantId,

View File

@@ -0,0 +1,65 @@
import type { ConditionId } from "./conditions.js";
import { CONDITION_DEFINITIONS, VALID_CONDITION_IDS } from "./conditions.js";
import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js";
export interface ToggleConditionSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
export function toggleCondition(
encounter: Encounter,
combatantId: CombatantId,
conditionId: ConditionId,
): ToggleConditionSuccess | DomainError {
if (!VALID_CONDITION_IDS.has(conditionId)) {
return {
kind: "domain-error",
code: "unknown-condition",
message: `Unknown condition "${conditionId}"`,
};
}
const targetIdx = encounter.combatants.findIndex((c) => c.id === combatantId);
if (targetIdx === -1) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
const target = encounter.combatants[targetIdx];
const current = target.conditions ?? [];
const isActive = current.includes(conditionId);
let newConditions: readonly ConditionId[] | undefined;
let event: DomainEvent;
if (isActive) {
const filtered = current.filter((c) => c !== conditionId);
newConditions = filtered.length > 0 ? filtered : undefined;
event = { type: "ConditionRemoved", combatantId, condition: conditionId };
} else {
const added = [...current, conditionId];
const order = CONDITION_DEFINITIONS.map((d) => d.id);
added.sort((a, b) => order.indexOf(a) - order.indexOf(b));
newConditions = added;
event = { type: "ConditionAdded", combatantId, condition: conditionId };
}
const updatedCombatants = encounter.combatants.map((c) =>
c.id === combatantId ? { ...c, conditions: newConditions } : c,
);
return {
encounter: {
combatants: updatedCombatants,
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
},
events: [event],
};
}

View File

@@ -5,6 +5,8 @@ export function combatantId(id: string): CombatantId {
return id as CombatantId;
}
import type { ConditionId } from "./conditions.js";
export interface Combatant {
readonly id: CombatantId;
readonly name: string;
@@ -12,6 +14,7 @@ export interface Combatant {
readonly maxHp?: number;
readonly currentHp?: number;
readonly ac?: number;
readonly conditions?: readonly ConditionId[];
}
export interface Encounter {