Implement the 016-combatant-ac feature that adds an optional Armor Class field to combatants with shield icon display and inline editing in the encounter tracker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-06 10:41:56 +01:00
parent 2793a66672
commit 78c6591973
20 changed files with 914 additions and 4 deletions

View File

@@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import { setAc } from "../set-ac.js";
import type { Combatant, Encounter } from "../types.js";
import { combatantId, isDomainError } from "../types.js";
function makeCombatant(name: string, ac?: number): Combatant {
return ac === undefined
? { id: combatantId(name), name }
: { id: combatantId(name), name, ac };
}
function enc(
combatants: Combatant[],
activeIndex = 0,
roundNumber = 1,
): Encounter {
return { combatants, activeIndex, roundNumber };
}
function successResult(
encounter: Encounter,
id: string,
value: number | undefined,
) {
const result = setAc(encounter, combatantId(id), value);
if (isDomainError(result)) {
throw new Error(`Expected success, got error: ${result.message}`);
}
return result;
}
describe("setAc", () => {
it("sets AC to a valid value", () => {
const e = enc([makeCombatant("A"), makeCombatant("B")]);
const { encounter, events } = successResult(e, "A", 15);
expect(encounter.combatants[0].ac).toBe(15);
expect(events).toEqual([
{
type: "AcSet",
combatantId: combatantId("A"),
previousAc: undefined,
newAc: 15,
},
]);
});
it("sets AC to 0", () => {
const e = enc([makeCombatant("A")]);
const { encounter } = successResult(e, "A", 0);
expect(encounter.combatants[0].ac).toBe(0);
});
it("clears AC with undefined", () => {
const e = enc([makeCombatant("A", 15)]);
const { encounter, events } = successResult(e, "A", undefined);
expect(encounter.combatants[0].ac).toBeUndefined();
expect(events[0]).toMatchObject({
previousAc: 15,
newAc: undefined,
});
});
it("returns error for nonexistent combatant", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("nonexistent"), 10);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("combatant-not-found");
}
});
it("returns error for negative AC", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), -1);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
});
it("returns error for non-integer AC", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), 3.5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-ac");
}
});
it("returns error for NaN", () => {
const e = enc([makeCombatant("A")]);
const result = setAc(e, combatantId("A"), Number.NaN);
expect(isDomainError(result)).toBe(true);
});
it("preserves other fields when setting AC", () => {
const combatant: Combatant = {
id: combatantId("A"),
name: "Aria",
initiative: 15,
maxHp: 20,
currentHp: 18,
};
const e = enc([combatant]);
const { encounter } = successResult(e, "A", 16);
const updated = encounter.combatants[0];
expect(updated.ac).toBe(16);
expect(updated.name).toBe("Aria");
expect(updated.initiative).toBe(15);
expect(updated.maxHp).toBe(20);
expect(updated.currentHp).toBe(18);
});
it("does not reorder combatants", () => {
const e = enc([makeCombatant("A"), makeCombatant("B")]);
const { encounter } = successResult(e, "B", 18);
expect(encounter.combatants[0].id).toBe(combatantId("A"));
expect(encounter.combatants[1].id).toBe(combatantId("B"));
});
it("preserves activeIndex and roundNumber", () => {
const e = enc([makeCombatant("A"), makeCombatant("B")], 1, 5);
const { encounter } = successResult(e, "A", 14);
expect(encounter.activeIndex).toBe(1);
expect(encounter.roundNumber).toBe(5);
});
it("does not mutate input encounter", () => {
const e = enc([makeCombatant("A")]);
const original = JSON.parse(JSON.stringify(e));
setAc(e, combatantId("A"), 10);
expect(e).toEqual(original);
});
});

View File

@@ -68,6 +68,13 @@ export interface RoundRetreated {
readonly newRoundNumber: number;
}
export interface AcSet {
readonly type: "AcSet";
readonly combatantId: CombatantId;
readonly previousAc: number | undefined;
readonly newAc: number | undefined;
}
export type DomainEvent =
| TurnAdvanced
| RoundAdvanced
@@ -78,4 +85,5 @@ export type DomainEvent =
| MaxHpSet
| CurrentHpAdjusted
| TurnRetreated
| RoundRetreated;
| RoundRetreated
| AcSet;

View File

@@ -6,6 +6,7 @@ export {
editCombatant,
} from "./edit-combatant.js";
export type {
AcSet,
CombatantAdded,
CombatantRemoved,
CombatantUpdated,
@@ -24,6 +25,7 @@ export {
removeCombatant,
} from "./remove-combatant.js";
export { retreatTurn } from "./retreat-turn.js";
export { type SetAcSuccess, setAc } from "./set-ac.js";
export { type SetHpSuccess, setHp } from "./set-hp.js";
export {
type SetInitiativeSuccess,

View File

@@ -0,0 +1,56 @@
import type { DomainEvent } from "./events.js";
import type { CombatantId, DomainError, Encounter } from "./types.js";
export interface SetAcSuccess {
readonly encounter: Encounter;
readonly events: DomainEvent[];
}
export function setAc(
encounter: Encounter,
combatantId: CombatantId,
value: number | undefined,
): SetAcSuccess | 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}"`,
};
}
if (value !== undefined) {
if (!Number.isInteger(value) || value < 0) {
return {
kind: "domain-error",
code: "invalid-ac",
message: `AC must be a non-negative integer, got ${value}`,
};
}
}
const target = encounter.combatants[targetIdx];
const previousAc = target.ac;
const updatedCombatants = encounter.combatants.map((c) =>
c.id === combatantId ? { ...c, ac: value } : c,
);
return {
encounter: {
combatants: updatedCombatants,
activeIndex: encounter.activeIndex,
roundNumber: encounter.roundNumber,
},
events: [
{
type: "AcSet",
combatantId,
previousAc,
newAc: value,
},
],
};
}

View File

@@ -11,6 +11,7 @@ export interface Combatant {
readonly initiative?: number;
readonly maxHp?: number;
readonly currentHp?: number;
readonly ac?: number;
}
export interface Encounter {