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:
@@ -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";
|
||||
|
||||
23
packages/application/src/toggle-concentration-use-case.ts
Normal file
23
packages/application/src/toggle-concentration-use-case.ts
Normal 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;
|
||||
}
|
||||
69
packages/domain/src/__tests__/toggle-concentration.test.ts
Normal file
69
packages/domain/src/__tests__/toggle-concentration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
44
packages/domain/src/toggle-concentration.ts
Normal file
44
packages/domain/src/toggle-concentration.ts
Normal 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],
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export interface Combatant {
|
||||
readonly currentHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly isConcentrating?: boolean;
|
||||
}
|
||||
|
||||
export interface Encounter {
|
||||
|
||||
Reference in New Issue
Block a user