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