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:
@@ -67,6 +67,7 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
||||
- N/A (no storage changes -- purely derived state, existing localStorage persistence unchanged) (013-hp-status-indicators)
|
||||
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + jscpd (new dev dependency), Lefthook (existing), Biome 2.0 (existing), Knip (existing) (015-add-jscpd-gate)
|
||||
- N/A (no storage changes) (015-add-jscpd-gate)
|
||||
- Browser localStorage (existing adapter, transparent JSON serialization) (016-combatant-ac)
|
||||
|
||||
## Recent Changes
|
||||
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
|
||||
|
||||
@@ -14,6 +14,7 @@ export function App() {
|
||||
setInitiative,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setAc,
|
||||
} = useEncounter();
|
||||
|
||||
return (
|
||||
@@ -49,6 +50,7 @@ export function App() {
|
||||
onRemove={removeCombatant}
|
||||
onSetHp={setHp}
|
||||
onAdjustHp={adjustHp}
|
||||
onSetAc={setAc}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type CombatantId, deriveHpStatus } from "@initiative/domain";
|
||||
import { X } from "lucide-react";
|
||||
import { Shield, X } from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { QuickHpInput } from "./quick-hp-input";
|
||||
@@ -12,6 +12,7 @@ interface Combatant {
|
||||
readonly initiative?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly currentHp?: number;
|
||||
readonly ac?: number;
|
||||
}
|
||||
|
||||
interface CombatantRowProps {
|
||||
@@ -22,6 +23,7 @@ interface CombatantRowProps {
|
||||
onRemove: (id: CombatantId) => void;
|
||||
onSetHp: (id: CombatantId, maxHp: number | undefined) => void;
|
||||
onAdjustHp: (id: CombatantId, delta: number) => void;
|
||||
onSetAc: (id: CombatantId, value: number | undefined) => void;
|
||||
}
|
||||
|
||||
function EditableName({
|
||||
@@ -173,6 +175,53 @@ function CurrentHpInput({
|
||||
);
|
||||
}
|
||||
|
||||
function AcInput({
|
||||
ac,
|
||||
onCommit,
|
||||
}: {
|
||||
ac: number | undefined;
|
||||
onCommit: (value: number | undefined) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(ac?.toString() ?? "");
|
||||
const prev = useRef(ac);
|
||||
|
||||
if (ac !== prev.current) {
|
||||
prev.current = ac;
|
||||
setDraft(ac?.toString() ?? "");
|
||||
}
|
||||
|
||||
const commit = useCallback(() => {
|
||||
if (draft === "") {
|
||||
onCommit(undefined);
|
||||
return;
|
||||
}
|
||||
const n = Number.parseInt(draft, 10);
|
||||
if (!Number.isNaN(n) && n >= 0) {
|
||||
onCommit(n);
|
||||
} else {
|
||||
setDraft(ac?.toString() ?? "");
|
||||
}
|
||||
}, [draft, ac, onCommit]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Shield size={14} className="text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={draft}
|
||||
placeholder="AC"
|
||||
className="h-7 w-[6ch] text-center text-sm tabular-nums"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CombatantRow({
|
||||
combatant,
|
||||
isActive,
|
||||
@@ -181,6 +230,7 @@ export function CombatantRow({
|
||||
onRemove,
|
||||
onSetHp,
|
||||
onAdjustHp,
|
||||
onSetAc,
|
||||
}: CombatantRowProps) {
|
||||
const { id, name, initiative, maxHp, currentHp } = combatant;
|
||||
const status = deriveHpStatus(currentHp, maxHp);
|
||||
@@ -188,7 +238,7 @@ export function CombatantRow({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-[3rem_1fr_auto_2rem] items-center gap-3 rounded-md px-3 py-2 transition-colors",
|
||||
"grid grid-cols-[3rem_1fr_auto_auto_2rem] items-center gap-3 rounded-md px-3 py-2 transition-colors",
|
||||
isActive
|
||||
? "border-l-2 border-l-accent bg-accent/10"
|
||||
: "border-l-2 border-l-transparent",
|
||||
@@ -218,6 +268,9 @@ export function CombatantRow({
|
||||
{/* Name */}
|
||||
<EditableName name={name} combatantId={id} onRename={onRename} />
|
||||
|
||||
{/* AC */}
|
||||
<AcInput ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
|
||||
|
||||
{/* HP */}
|
||||
<div className="flex items-center gap-1">
|
||||
<CurrentHpInput
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
editCombatantUseCase,
|
||||
removeCombatantUseCase,
|
||||
retreatTurnUseCase,
|
||||
setAcUseCase,
|
||||
setHpUseCase,
|
||||
setInitiativeUseCase,
|
||||
} from "@initiative/application";
|
||||
@@ -171,6 +172,19 @@ export function useEncounter() {
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const setAc = useCallback(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = setAcUseCase(makeStore(), id, value);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
return {
|
||||
encounter,
|
||||
events,
|
||||
@@ -182,5 +196,6 @@ export function useEncounter() {
|
||||
setInitiative,
|
||||
setHp,
|
||||
adjustHp,
|
||||
setAc,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -206,6 +206,63 @@ describe("loadEncounter", () => {
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("round-trip preserves combatant AC value", () => {
|
||||
const result = createEncounter(
|
||||
[{ id: combatantId("1"), name: "Aria", ac: 18 }],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error("unreachable");
|
||||
saveEncounter(result);
|
||||
const loaded = loadEncounter();
|
||||
expect(loaded?.combatants[0].ac).toBe(18);
|
||||
});
|
||||
|
||||
it("round-trip preserves combatant without AC", () => {
|
||||
const result = createEncounter(
|
||||
[{ id: combatantId("1"), name: "Aria" }],
|
||||
0,
|
||||
1,
|
||||
);
|
||||
if (isDomainError(result)) throw new Error("unreachable");
|
||||
saveEncounter(result);
|
||||
const loaded = loadEncounter();
|
||||
expect(loaded?.combatants[0].ac).toBeUndefined();
|
||||
});
|
||||
|
||||
it("discards invalid AC values during rehydration", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: [
|
||||
{ id: "1", name: "Neg", ac: -1 },
|
||||
{ id: "2", name: "Float", ac: 3.5 },
|
||||
{ id: "3", name: "Str", ac: "high" },
|
||||
],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}),
|
||||
);
|
||||
const loaded = loadEncounter();
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.combatants[0].ac).toBeUndefined();
|
||||
expect(loaded?.combatants[1].ac).toBeUndefined();
|
||||
expect(loaded?.combatants[2].ac).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves AC of 0 during rehydration", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
combatants: [{ id: "1", name: "Aria", ac: 0 }],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
}),
|
||||
);
|
||||
const loaded = loadEncounter();
|
||||
expect(loaded?.combatants[0].ac).toBe(0);
|
||||
});
|
||||
|
||||
it("saving after modifications persists the latest state", () => {
|
||||
const encounter = makeEncounter();
|
||||
saveEncounter(encounter);
|
||||
|
||||
@@ -48,6 +48,13 @@ export function loadEncounter(): Encounter | null {
|
||||
typeof entry.initiative === "number" ? entry.initiative : undefined,
|
||||
};
|
||||
|
||||
// Validate AC field
|
||||
const ac = entry.ac;
|
||||
const validAc =
|
||||
typeof ac === "number" && Number.isInteger(ac) && ac >= 0
|
||||
? ac
|
||||
: undefined;
|
||||
|
||||
// Validate and attach HP fields if valid
|
||||
const maxHp = entry.maxHp;
|
||||
const currentHp = entry.currentHp;
|
||||
@@ -59,12 +66,13 @@ export function loadEncounter(): Encounter | null {
|
||||
currentHp <= maxHp;
|
||||
return {
|
||||
...base,
|
||||
ac: validAc,
|
||||
maxHp,
|
||||
currentHp: validCurrentHp ? currentHp : maxHp,
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
return { ...base, ac: validAc };
|
||||
});
|
||||
|
||||
const result = createEncounter(
|
||||
|
||||
@@ -5,5 +5,6 @@ export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
||||
export type { EncounterStore } from "./ports.js";
|
||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||
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";
|
||||
|
||||
24
packages/application/src/set-ac-use-case.ts
Normal file
24
packages/application/src/set-ac-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type DomainError,
|
||||
type DomainEvent,
|
||||
isDomainError,
|
||||
setAc,
|
||||
} from "@initiative/domain";
|
||||
import type { EncounterStore } from "./ports.js";
|
||||
|
||||
export function setAcUseCase(
|
||||
store: EncounterStore,
|
||||
combatantId: CombatantId,
|
||||
value: number | undefined,
|
||||
): DomainEvent[] | DomainError {
|
||||
const encounter = store.get();
|
||||
const result = setAc(encounter, combatantId, value);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
store.save(result.encounter);
|
||||
return result.events;
|
||||
}
|
||||
143
packages/domain/src/__tests__/set-ac.test.ts
Normal file
143
packages/domain/src/__tests__/set-ac.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
56
packages/domain/src/set-ac.ts
Normal file
56
packages/domain/src/set-ac.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export interface Combatant {
|
||||
readonly initiative?: number;
|
||||
readonly maxHp?: number;
|
||||
readonly currentHp?: number;
|
||||
readonly ac?: number;
|
||||
}
|
||||
|
||||
export interface Encounter {
|
||||
|
||||
34
specs/016-combatant-ac/checklists/requirements.md
Normal file
34
specs/016-combatant-ac/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Combatant Armor Class Display
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-06
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- All items pass. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||
58
specs/016-combatant-ac/data-model.md
Normal file
58
specs/016-combatant-ac/data-model.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# Data Model: Combatant Armor Class Display
|
||||
|
||||
**Feature**: 016-combatant-ac | **Date**: 2026-03-06
|
||||
|
||||
## Entity Changes
|
||||
|
||||
### Combatant (modified)
|
||||
|
||||
| Field | Type | Required | Validation | Notes |
|
||||
|-------|------|----------|------------|-------|
|
||||
| id | CombatantId (branded string) | Yes | Non-empty | Existing — unchanged |
|
||||
| name | string | Yes | Non-empty after trim | Existing — unchanged |
|
||||
| initiative | number \| undefined | No | Integer | Existing — unchanged |
|
||||
| maxHp | number \| undefined | No | Integer >= 1 | Existing — unchanged |
|
||||
| currentHp | number \| undefined | No | Integer >= 0, <= maxHp | Existing — unchanged |
|
||||
| **ac** | **number \| undefined** | **No** | **Integer >= 0** | **NEW — Armor Class** |
|
||||
|
||||
### Key Differences from Other Optional Fields
|
||||
|
||||
- Unlike `maxHp`/`currentHp`, AC has no paired or derived field — it is a single standalone value.
|
||||
- Unlike `initiative`, AC does not affect combatant sort order.
|
||||
- AC validation is `>= 0` (not `>= 1` like `maxHp`), because AC 0 is valid in tabletop RPGs.
|
||||
|
||||
## Domain Events
|
||||
|
||||
### AcSet (new)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| type | `"AcSet"` | Event discriminant |
|
||||
| combatantId | CombatantId | Target combatant |
|
||||
| previousAc | number \| undefined | AC before the change |
|
||||
| newAc | number \| undefined | AC after the change |
|
||||
|
||||
## State Transitions
|
||||
|
||||
### setAc(encounter, combatantId, ac)
|
||||
|
||||
**Input**: Encounter, CombatantId, ac: number | undefined
|
||||
|
||||
**Validation**:
|
||||
- Combatant must exist in encounter (error: `"combatant-not-found"`)
|
||||
- If `ac` is defined: must be a non-negative integer (error: `"invalid-ac"`)
|
||||
|
||||
**Behavior**:
|
||||
- Replaces the combatant's `ac` field with the new value (or `undefined` to clear)
|
||||
- No side effects on other fields (unlike `setHp` which initializes `currentHp`)
|
||||
- No reordering (unlike `setInitiative` which re-sorts)
|
||||
|
||||
**Output**: `{ encounter: Encounter, events: [AcSet] }` or `DomainError`
|
||||
|
||||
## Persistence Format
|
||||
|
||||
### localStorage JSON (unchanged key: `"initiative:encounter"`)
|
||||
|
||||
Combatant objects are serialized as plain JSON. The `ac` field is included when defined, omitted when `undefined` (standard JSON behavior).
|
||||
|
||||
**Rehydration validation**: `typeof entry.ac === "number" && Number.isInteger(entry.ac) && entry.ac >= 0` — invalid values are silently discarded as `undefined`.
|
||||
81
specs/016-combatant-ac/plan.md
Normal file
81
specs/016-combatant-ac/plan.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Implementation Plan: Combatant Armor Class Display
|
||||
|
||||
**Branch**: `016-combatant-ac` | **Date**: 2026-03-06 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/016-combatant-ac/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Add an optional Armor Class (AC) field to the Combatant domain type and display it in the encounter list as a shield icon with the numeric value next to each combatant's name. The feature follows the exact same patterns as existing optional fields (initiative, maxHp) through all layers: domain pure function, application use case, React UI component, and localStorage persistence.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax)
|
||||
**Primary Dependencies**: React 19, Vite 6, Tailwind CSS v4, shadcn/ui-style components, Lucide React (icons)
|
||||
**Storage**: Browser localStorage (existing adapter, transparent JSON serialization)
|
||||
**Testing**: Vitest (pure domain function tests + storage round-trip tests)
|
||||
**Target Platform**: Browser (local-first, single-user)
|
||||
**Project Type**: Web application (monorepo: packages/domain, packages/application, apps/web)
|
||||
**Performance Goals**: N/A (trivial data addition, no performance concerns)
|
||||
**Constraints**: Offline-capable (localStorage only), no external dependencies added
|
||||
**Scale/Scope**: Single optional field addition across 3 layers
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Status | Evidence |
|
||||
|-----------|--------|----------|
|
||||
| I. Deterministic Domain Core | PASS | `setAc` is a pure function: same input encounter + combatantId + ac value always produces the same output. No I/O, randomness, or clocks. |
|
||||
| II. Layered Architecture | PASS | Domain defines `setAc` (pure). Application defines `setAcUseCase` (orchestration via EncounterStore port). Web implements UI + persistence adapter. No reverse dependencies. |
|
||||
| III. Agent Boundary | N/A | No agent layer involvement. |
|
||||
| IV. Clarification-First | PASS | No non-trivial assumptions — feature follows established patterns for optional combatant fields. |
|
||||
| V. Escalation Gates | PASS | All functionality is within spec scope (FR-001 through FR-007). |
|
||||
| VI. MVP Baseline Language | PASS | Spec uses "MVP baseline does not include AC-based calculations." |
|
||||
| VII. No Gameplay Rules | PASS | AC is stored and displayed only — no combat resolution logic. |
|
||||
|
||||
**Gate result**: PASS — no violations.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/016-combatant-ac/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
packages/domain/src/
|
||||
├── types.ts # Add optional `ac` field to Combatant interface
|
||||
├── set-ac.ts # NEW: pure function to set/clear AC
|
||||
├── events.ts # Add AcSet event type
|
||||
├── index.ts # Re-export new function + event type
|
||||
└── __tests__/
|
||||
└── set-ac.test.ts # NEW: domain tests for setAc
|
||||
|
||||
packages/application/src/
|
||||
├── set-ac-use-case.ts # NEW: orchestration use case
|
||||
└── index.ts # Re-export new use case
|
||||
|
||||
apps/web/src/
|
||||
├── components/
|
||||
│ └── combatant-row.tsx # Add AC display (shield icon + value) + onSetAc callback
|
||||
├── hooks/
|
||||
│ └── use-encounter.ts # Add setAc action callback
|
||||
└── persistence/
|
||||
└── encounter-storage.ts # Add AC validation in loadEncounter rehydration
|
||||
└── __tests__/
|
||||
└── encounter-storage.test.ts # Add AC round-trip tests
|
||||
```
|
||||
|
||||
**Structure Decision**: Follows the existing monorepo layered architecture (domain → application → web). No new packages or structural changes needed.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No violations — table not applicable.
|
||||
64
specs/016-combatant-ac/quickstart.md
Normal file
64
specs/016-combatant-ac/quickstart.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Quickstart: Combatant Armor Class Display
|
||||
|
||||
**Feature**: 016-combatant-ac | **Date**: 2026-03-06
|
||||
|
||||
## Overview
|
||||
|
||||
This feature adds an optional Armor Class (AC) field to combatants, displayed as a shield icon with the numeric value in the encounter list. It follows the exact patterns established by initiative and HP fields.
|
||||
|
||||
## Layer-by-Layer Implementation Order
|
||||
|
||||
### 1. Domain Layer (`packages/domain/src/`)
|
||||
|
||||
**Pattern to follow**: `set-initiative.ts` (single optional numeric field)
|
||||
|
||||
1. Add `readonly ac?: number` to `Combatant` interface in `types.ts`
|
||||
2. Create `set-ac.ts` with a `setAc` pure function:
|
||||
- Find combatant by ID (error if not found)
|
||||
- Validate AC is non-negative integer when defined
|
||||
- Return updated encounter + `AcSet` event
|
||||
3. Add `AcSet` event to `events.ts` and the `DomainEvent` union
|
||||
4. Export from `index.ts`
|
||||
5. Write tests in `__tests__/set-ac.test.ts`
|
||||
|
||||
### 2. Application Layer (`packages/application/src/`)
|
||||
|
||||
**Pattern to follow**: `set-initiative-use-case.ts`
|
||||
|
||||
1. Create `set-ac-use-case.ts`: get encounter from store, call `setAc`, save result
|
||||
2. Export from `index.ts`
|
||||
|
||||
### 3. Web Layer (`apps/web/src/`)
|
||||
|
||||
**Pattern to follow**: Initiative input in `combatant-row.tsx` + `MaxHpInput` editing pattern
|
||||
|
||||
1. **Persistence** (`persistence/encounter-storage.ts`): Add AC validation in rehydration logic
|
||||
2. **Hook** (`hooks/use-encounter.ts`): Add `setAc` callback using `setAcUseCase`
|
||||
3. **UI** (`components/combatant-row.tsx`):
|
||||
- Add `onSetAc` to `CombatantRowProps`
|
||||
- Add AC display: Lucide `Shield` icon + value, between name and HP
|
||||
- Inline editable input (same pattern as `MaxHpInput`)
|
||||
- Hidden when AC is `undefined`
|
||||
|
||||
## Key Files to Read First
|
||||
|
||||
| File | Why |
|
||||
|------|-----|
|
||||
| `packages/domain/src/set-initiative.ts` | Closest pattern for single optional field |
|
||||
| `packages/domain/src/events.ts` | Event type definitions |
|
||||
| `apps/web/src/components/combatant-row.tsx` | UI layout and inline input patterns |
|
||||
| `apps/web/src/persistence/encounter-storage.ts` | Rehydration validation pattern |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Domain**: Pure function tests for `setAc` (set, clear, validation errors, combatant not found)
|
||||
- **Persistence**: Round-trip test for AC field in localStorage
|
||||
- **No UI tests**: Consistent with existing approach (no component tests in codebase)
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm test # Run all tests
|
||||
pnpm vitest run packages/domain/src/__tests__/set-ac.test.ts # Run AC domain tests
|
||||
pnpm check # Full merge gate (must pass before commit)
|
||||
```
|
||||
57
specs/016-combatant-ac/research.md
Normal file
57
specs/016-combatant-ac/research.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Research: Combatant Armor Class Display
|
||||
|
||||
**Feature**: 016-combatant-ac | **Date**: 2026-03-06
|
||||
|
||||
## Research Summary
|
||||
|
||||
No NEEDS CLARIFICATION items existed in the Technical Context. Research focused on confirming existing patterns and making design decisions for consistency.
|
||||
|
||||
### Decision 1: AC Field Type and Validation
|
||||
|
||||
**Decision**: Optional non-negative integer (`ac?: number`), validated as `Number.isInteger(ac) && ac >= 0`.
|
||||
|
||||
**Rationale**: Matches the existing pattern for optional numeric combatant fields. AC 0 is valid in tabletop RPGs (e.g., unarmored creatures). Non-negative aligns with D&D 5e rules where AC is always >= 0.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Positive integer only (>= 1): Rejected — AC 0 is valid in edge cases.
|
||||
- String field for custom values like "17 (with shield)": Rejected — out of MVP scope, adds complexity.
|
||||
|
||||
### Decision 2: Domain Function Design
|
||||
|
||||
**Decision**: Create a standalone `setAc` pure function following the `setInitiative` pattern (not `setHp`).
|
||||
|
||||
**Rationale**: `setInitiative` is the closest analog — a single optional numeric field with no derived state. `setHp` is more complex because it manages the `maxHp`/`currentHp` relationship. AC has no such derived relationship.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Extend `editCombatant` to accept AC: Rejected — `editCombatant` only handles name changes; mixing concerns violates single-responsibility.
|
||||
- Accept AC during `addCombatant`: Rejected — existing pattern adds combatant with name only, then sets optional fields separately. Consistent UX.
|
||||
|
||||
### Decision 3: UI Placement
|
||||
|
||||
**Decision**: Display AC as a Lucide `Shield` icon + numeric value in the combatant row, positioned between the name and HP columns.
|
||||
|
||||
**Rationale**: AC is a static defensive stat best placed near the combatant identity (name) rather than the dynamic HP section. The shield icon from Lucide React is semantically appropriate and consistent with the existing icon library.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Before the name (left of initiative): Rejected — initiative is the primary sort key and belongs leftmost.
|
||||
- After HP section: Rejected — AC is more of an identity attribute than a health attribute.
|
||||
- Emoji shield character: Rejected — Lucide icons are the project standard.
|
||||
|
||||
### Decision 4: AC Editing UX
|
||||
|
||||
**Decision**: Inline editable field in the combatant row, following the `MaxHpInput` pattern (click to edit, blur/Enter to commit, empty clears).
|
||||
|
||||
**Rationale**: Consistent with how initiative and max HP are edited inline. No separate edit dialog needed.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Read-only display with separate edit modal: Rejected — inconsistent with existing inline editing patterns.
|
||||
- Always-visible input field: Rejected — would add visual noise for combatants without AC.
|
||||
|
||||
### Decision 5: Persistence Validation
|
||||
|
||||
**Decision**: Validate AC during localStorage rehydration as `typeof entry.ac === "number" && Number.isInteger(entry.ac) && entry.ac >= 0`. Discard invalid values silently (set to `undefined`).
|
||||
|
||||
**Rationale**: Matches the defensive deserialization pattern used for `initiative`, `maxHp`, and `currentHp`. Gracefully handles corrupted or manually edited localStorage data.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Strict validation that rejects entire encounter: Rejected — too aggressive for a single optional field.
|
||||
93
specs/016-combatant-ac/spec.md
Normal file
93
specs/016-combatant-ac/spec.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Feature Specification: Combatant Armor Class Display
|
||||
|
||||
**Feature Branch**: `016-combatant-ac`
|
||||
**Created**: 2026-03-06
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Display Armor Class for each combatant using a small shield icon with the AC value (e.g., shield 17) next to the name in the encounter list."
|
||||
|
||||
## User Scenarios & Testing
|
||||
|
||||
### User Story 1 - Set AC for a Combatant (Priority: P1)
|
||||
|
||||
A game master sets an Armor Class value on a combatant using the inline AC input in the encounter list so it is stored and visible in the tracker.
|
||||
|
||||
**Why this priority**: AC must exist on the combatant before it can be displayed. This is the foundational data entry path.
|
||||
|
||||
**Independent Test**: Can be tested by setting an AC value on an existing combatant and confirming the value persists in the encounter state.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a combatant exists in the encounter, **When** the user clicks the AC area next to the combatant's name and enters 17, **Then** the combatant's AC is set to 17 and the shield icon with "17" appears.
|
||||
2. **Given** a combatant exists in the encounter without an AC value, **When** the encounter list is displayed, **Then** no AC input or shield icon is visible until the user activates the AC field.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Display AC in the Encounter List (Priority: P1)
|
||||
|
||||
A game master views the encounter list and sees each combatant's Armor Class displayed as a shield icon with the numeric value (e.g., shield icon followed by 17) next to the combatant's name.
|
||||
|
||||
**Why this priority**: This is the core visual feature requested by the user, tied with Story 1 since both are needed for the feature to be useful.
|
||||
|
||||
**Independent Test**: Can be tested by loading an encounter with combatants that have AC values and verifying the shield icon and number appear next to each name.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a combatant has AC 17, **When** the encounter list is displayed, **Then** a shield icon and the value "17" appear next to the combatant's name.
|
||||
2. **Given** a combatant has no AC set, **When** the encounter list is displayed, **Then** no shield icon or AC value is shown for that combatant.
|
||||
3. **Given** multiple combatants with different AC values, **When** the encounter list is displayed, **Then** each combatant shows its own correct AC value.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Edit AC for an Existing Combatant (Priority: P2)
|
||||
|
||||
A game master edits an existing combatant to add, change, or remove their Armor Class value.
|
||||
|
||||
**Why this priority**: Editing supports corrections and mid-session changes but is secondary to initial entry and display.
|
||||
|
||||
**Independent Test**: Can be tested by editing a combatant's AC and confirming the updated value appears in the encounter list.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a combatant has AC 15, **When** the user edits the combatant and changes AC to 18, **Then** the displayed AC updates to 18.
|
||||
2. **Given** a combatant has AC 15, **When** the user edits the combatant and clears the AC field, **Then** no AC is displayed for that combatant.
|
||||
3. **Given** a combatant has no AC, **When** the user edits the combatant and sets AC to 12, **Then** the shield icon and "12" appear next to the combatant's name.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when AC is 0? The system displays the shield icon with "0" (AC 0 is a valid value in tabletop RPGs).
|
||||
- What happens when AC is a very large number (e.g., 99)? The display accommodates up to two-digit values without layout issues.
|
||||
- What happens when AC is negative? The system does not accept negative AC values; AC must be a non-negative integer.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The combatant data model MUST support an optional Armor Class field that holds a non-negative integer.
|
||||
- **FR-002**: The system MUST allow users to set an AC value on any combatant via an inline input in the encounter list.
|
||||
- **FR-003**: The system MUST allow users to edit the AC value for an existing combatant (add, change, or remove).
|
||||
- **FR-004**: The encounter list MUST display each combatant's AC as a shield icon followed by the numeric value, positioned next to the combatant's name.
|
||||
- **FR-005**: The encounter list MUST NOT display a shield icon or AC value for combatants without an AC set.
|
||||
- **FR-006**: The system MUST persist AC values through the existing storage mechanism (same as other combatant fields).
|
||||
- **FR-007**: AC values MUST be validated as non-negative integers (0 or greater).
|
||||
|
||||
### Key Entities
|
||||
|
||||
- **Combatant**: Gains an optional Armor Class attribute, a non-negative integer. Relates to existing combatant fields (name, initiative, HP).
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Users can set an AC value for any combatant within the existing add/edit workflows with no additional steps beyond filling in the field.
|
||||
- **SC-002**: AC values are visible at a glance in the encounter list without expanding or hovering over any element.
|
||||
- **SC-003**: Combatants without AC display no visual clutter — the shield icon only appears when AC is set.
|
||||
- **SC-004**: All existing functionality (HP tracking, initiative, turn navigation) continues to work without regression.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- AC is always a non-negative integer (consistent with D&D 5e and most tabletop RPG systems).
|
||||
- The shield icon is rendered using the project's existing icon library (Lucide React), not an emoji character.
|
||||
- The AC display is read-only in the encounter list row; editing AC uses the existing combatant edit flow.
|
||||
- MVP baseline does not include AC-based calculations (e.g., to-hit comparisons) or conditional formatting based on AC thresholds.
|
||||
152
specs/016-combatant-ac/tasks.md
Normal file
152
specs/016-combatant-ac/tasks.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Tasks: Combatant Armor Class Display
|
||||
|
||||
**Input**: Design documents from `/specs/016-combatant-ac/`
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
|
||||
|
||||
**Tests**: Domain tests and persistence round-trip tests are included (consistent with existing testing patterns in the codebase).
|
||||
|
||||
**Organization**: Tasks are grouped by user story. US1 (set AC) and US2 (display AC) are both P1 and share foundational work, so they are combined into a single phase. US3 (edit AC) is P2 but is naturally fulfilled by the same inline input from US1/US2.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundational (Domain + Application Layer)
|
||||
|
||||
**Purpose**: Add AC to the domain model, create the `setAc` pure function, event type, and application use case. All user stories depend on this phase.
|
||||
|
||||
**⚠️ CRITICAL**: No UI or persistence work can begin until this phase is complete.
|
||||
|
||||
- [x] T001 [P] Add `readonly ac?: number` to `Combatant` interface and add `AcSet` event interface to the `DomainEvent` union in `packages/domain/src/types.ts` and `packages/domain/src/events.ts`
|
||||
- [x] T002 [P] Create `setAc` pure function in `packages/domain/src/set-ac.ts` following the `set-initiative.ts` pattern: find combatant by ID, validate AC is non-negative integer when defined (>= 0), return updated encounter with `AcSet` event or `DomainError`
|
||||
- [x] T003 Write domain tests in `packages/domain/src/__tests__/set-ac.test.ts`: set AC to valid value, set AC to 0, clear AC (undefined), combatant not found error, negative AC error, non-integer AC error, AC unchanged preserves other fields
|
||||
- [x] T004 Export `setAc`, `SetAcSuccess` type, and `AcSet` event type from `packages/domain/src/index.ts`
|
||||
- [x] T005 Create `setAcUseCase` in `packages/application/src/set-ac-use-case.ts` following `set-initiative-use-case.ts` pattern, and export from `packages/application/src/index.ts`
|
||||
|
||||
**Checkpoint**: Domain function and use case ready. Run `pnpm test` — all tests should pass including new `set-ac.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: User Story 1 + 2 — Set and Display AC (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Users can set AC for a combatant and see it displayed as a shield icon with the numeric value in the encounter list.
|
||||
|
||||
**Independent Test**: Add a combatant, set its AC via the inline input, and verify the shield icon + value appears next to the name. Verify combatants without AC show no icon.
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T006 [P] [US1] Add AC rehydration validation in `apps/web/src/persistence/encounter-storage.ts`: validate `typeof entry.ac === "number" && Number.isInteger(entry.ac) && entry.ac >= 0`, attach to combatant object during deserialization (follow the `initiative` validation pattern)
|
||||
- [x] T007 [P] [US1] Add AC round-trip tests in `apps/web/src/persistence/__tests__/encounter-storage.test.ts`: persist and load combatant with AC, persist without AC, reject invalid AC values (negative, non-integer, string)
|
||||
- [x] T008 [US1] Add `setAc` callback to `apps/web/src/hooks/use-encounter.ts` following the `setInitiative` callback pattern: call `setAcUseCase`, check for domain error, append events
|
||||
- [x] T009 [US1] [US2] Add `onSetAc` prop to `CombatantRowProps` and implement AC display + inline editable input in `apps/web/src/components/combatant-row.tsx`: Lucide `Shield` icon + numeric value between name and HP columns, inline input following `MaxHpInput` pattern (click to edit, blur/Enter to commit, empty clears), hidden when AC is `undefined`
|
||||
- [x] T010 [US2] Wire `setAc` callback from `useEncounter` hook to `CombatantRow` `onSetAc` prop in the encounter list parent component (likely `apps/web/src/components/encounter-tracker.tsx` or equivalent)
|
||||
|
||||
**Checkpoint**: US1 + US2 fully functional. Run `pnpm check` — full merge gate should pass. Manually verify: add combatant, set AC inline, see shield icon + value. Clear AC, icon disappears.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 3 — Edit AC for Existing Combatant (Priority: P2)
|
||||
|
||||
**Goal**: Users can change or remove an existing combatant's AC value.
|
||||
|
||||
**Independent Test**: Set a combatant's AC to 15, then change it to 18 and verify the display updates. Clear the AC field and verify the shield icon disappears.
|
||||
|
||||
**Note**: This story is naturally fulfilled by the inline editable input implemented in Phase 2 (T009). This phase is a verification checkpoint — no additional code tasks are needed unless the inline input from T009 does not yet support editing existing values.
|
||||
|
||||
- [x] T011 [US3] Verify and confirm that the inline AC input in `apps/web/src/components/combatant-row.tsx` handles all US3 acceptance scenarios: changing AC from 15 to 18 updates display, clearing AC field removes shield icon, setting AC on a combatant that previously had none shows shield icon
|
||||
|
||||
**Checkpoint**: All user stories functional. Run `pnpm check` — full merge gate must pass.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final validation and cleanup.
|
||||
|
||||
- [x] T012 Run `pnpm check` to verify full merge gate passes (knip + format + lint + typecheck + test)
|
||||
- [x] T013 Verify no regression in existing functionality: HP tracking, initiative sorting, turn navigation, combatant add/remove/rename all work with AC field present
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Foundational (Phase 1)**: No dependencies — can start immediately
|
||||
- **US1+US2 (Phase 2)**: Depends on Phase 1 completion (domain function + use case must exist)
|
||||
- **US3 (Phase 3)**: Depends on Phase 2 completion (inline input must exist)
|
||||
- **Polish (Phase 4)**: Depends on Phase 3 completion
|
||||
|
||||
### Within Phase 1 (Foundational)
|
||||
|
||||
```
|
||||
T001 (types + events) ──┐
|
||||
├──→ T003 (tests) ──→ T004 (exports) ──→ T005 (use case)
|
||||
T002 (setAc function) ──┘
|
||||
```
|
||||
|
||||
T001 and T002 can run in parallel (different files). T003 depends on both. T004 depends on T002. T005 depends on T004.
|
||||
|
||||
### Within Phase 2 (US1+US2)
|
||||
|
||||
```
|
||||
T006 (persistence) ──┐
|
||||
T007 (storage tests) ┤
|
||||
├──→ T009 (combatant row UI) ──→ T010 (wire to parent)
|
||||
T008 (hook callback) ─┘
|
||||
```
|
||||
|
||||
T006, T007, T008 can run in parallel (different files). T009 depends on T008. T010 depends on T009.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- **Phase 1**: T001 and T002 in parallel (types.ts/events.ts vs set-ac.ts)
|
||||
- **Phase 2**: T006, T007, and T008 in parallel (persistence vs tests vs hook)
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: Phase 1
|
||||
|
||||
```bash
|
||||
# Launch in parallel (different files):
|
||||
Task: "Add ac field to Combatant interface in packages/domain/src/types.ts and AcSet event in packages/domain/src/events.ts"
|
||||
Task: "Create setAc pure function in packages/domain/src/set-ac.ts"
|
||||
|
||||
# Then sequentially:
|
||||
Task: "Write domain tests in packages/domain/src/__tests__/set-ac.test.ts"
|
||||
Task: "Export from packages/domain/src/index.ts"
|
||||
Task: "Create use case in packages/application/src/set-ac-use-case.ts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (US1 + US2)
|
||||
|
||||
1. Complete Phase 1: Foundational (domain + application)
|
||||
2. Complete Phase 2: US1 + US2 (persistence + hook + UI)
|
||||
3. **STOP and VALIDATE**: Set AC on combatants, verify shield icon display
|
||||
4. Run `pnpm check` — merge gate must pass
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Phase 1 → Domain model and logic ready
|
||||
2. Phase 2 → AC visible and settable in encounter list (MVP!)
|
||||
3. Phase 3 → Verify editing works (should be automatic from Phase 2)
|
||||
4. Phase 4 → Final polish and regression check
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- [P] tasks = different files, no dependencies
|
||||
- [Story] label maps task to specific user story for traceability
|
||||
- US1 and US2 are combined because they share all implementation work and are both P1
|
||||
- US3 requires no new code — the inline input from US1/US2 inherently supports editing
|
||||
- Follow `set-initiative.ts` as the primary pattern reference throughout
|
||||
- Commit after each phase or logical group of tasks
|
||||
Reference in New Issue
Block a user