Implement the 018-combatant-concentration feature that adds a per-combatant concentration toggle with Brain icon, purple border accent, and damage pulse animation in the encounter tracker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-06 14:34:28 +01:00
parent febe892e15
commit e59fd83292
19 changed files with 779 additions and 7 deletions

View File

@@ -8,4 +8,5 @@ export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
export { setAcUseCase } from "./set-ac-use-case.js";
export { setHpUseCase } from "./set-hp-use-case.js";
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
export { toggleConcentrationUseCase } from "./toggle-concentration-use-case.js";
export { toggleConditionUseCase } from "./toggle-condition-use-case.js";

View File

@@ -0,0 +1,23 @@
import {
type CombatantId,
type DomainError,
type DomainEvent,
isDomainError,
toggleConcentration,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function toggleConcentrationUseCase(
store: EncounterStore,
combatantId: CombatantId,
): DomainEvent[] | DomainError {
const encounter = store.get();
const result = toggleConcentration(encounter, combatantId);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { toggleConcentration } from "../toggle-concentration.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
function makeCombatant(name: string, isConcentrating?: boolean): Combatant {
return isConcentrating
? { id: combatantId(name), name, isConcentrating }
: { id: combatantId(name), name };
}
function enc(combatants: Combatant[]): Encounter {
return { combatants, activeIndex: 0, roundNumber: 1 };
}
function success(encounter: Encounter, id: string) {
const result = toggleConcentration(encounter, combatantId(id));
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("toggleConcentration", () => {
it("toggles concentration on when falsy", () => {
const e = enc([makeCombatant("A")]);
const { encounter, events } = success(e, "A");
expect(encounter.combatants[0].isConcentrating).toBe(true);
expect(events).toEqual([
{ type: "ConcentrationStarted", combatantId: combatantId("A") },
]);
});
it("toggles concentration off when true", () => {
const e = enc([makeCombatant("A", true)]);
const { encounter, events } = success(e, "A");
expect(encounter.combatants[0].isConcentrating).toBeUndefined();
expect(events).toEqual([
{ type: "ConcentrationEnded", combatantId: combatantId("A") },
]);
});
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = toggleConcentration(e, combatantId("missing"));
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));
toggleConcentration(e, combatantId("A"));
expect(e).toEqual(original);
});
it("does not affect other combatants", () => {
const e = enc([makeCombatant("A"), makeCombatant("B", true)]);
const { encounter } = success(e, "A");
expect(encounter.combatants[0].isConcentrating).toBe(true);
expect(encounter.combatants[1].isConcentrating).toBe(true);
});
});

View File

@@ -88,6 +88,16 @@ export interface ConditionRemoved {
readonly condition: ConditionId;
}
export interface ConcentrationStarted {
readonly type: "ConcentrationStarted";
readonly combatantId: CombatantId;
}
export interface ConcentrationEnded {
readonly type: "ConcentrationEnded";
readonly combatantId: CombatantId;
}
export type DomainEvent =
| TurnAdvanced
| RoundAdvanced
@@ -101,4 +111,6 @@ export type DomainEvent =
| RoundRetreated
| AcSet
| ConditionAdded
| ConditionRemoved;
| ConditionRemoved
| ConcentrationStarted
| ConcentrationEnded;

View File

@@ -16,6 +16,8 @@ export type {
CombatantAdded,
CombatantRemoved,
CombatantUpdated,
ConcentrationEnded,
ConcentrationStarted,
ConditionAdded,
ConditionRemoved,
CurrentHpAdjusted,
@@ -39,6 +41,10 @@ export {
type SetInitiativeSuccess,
setInitiative,
} from "./set-initiative.js";
export {
type ToggleConcentrationSuccess,
toggleConcentration,
} from "./toggle-concentration.js";
export {
type ToggleConditionSuccess,
toggleCondition,

View File

@@ -0,0 +1,44 @@
import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js";
export interface ToggleConcentrationSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
export function toggleConcentration(
encounter: Encounter,
combatantId: CombatantId,
): ToggleConcentrationSuccess | DomainError {
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 wasConcentrating = target.isConcentrating === true;
const event: DomainEvent = wasConcentrating
? { type: "ConcentrationEnded", combatantId }
: { type: "ConcentrationStarted", combatantId };
const updatedCombatants = encounter.combatants.map((c) =>
c.id === combatantId
? { ...c, isConcentrating: wasConcentrating ? undefined : true }
: c,
);
return {
encounter: {
combatants: updatedCombatants,
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
},
events: [event],
};
}

View File

@@ -15,6 +15,7 @@ export interface Combatant {
readonly currentHp?: number;
readonly ac?: number;
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
}
export interface Encounter {