Compare commits
10 Commits
42a07a07ff
...
c4a90c9982
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4a90c9982 | ||
|
|
0bbd6f27f9 | ||
|
|
fea2bfe39d | ||
|
|
a9df826fef | ||
|
|
aed234de7b | ||
|
|
9d7b174867 | ||
|
|
0de68100c8 | ||
|
|
187f98fc52 | ||
|
|
2f7b4b82c1 | ||
|
|
4c2e0a47e6 |
61
CLAUDE.md
Normal file
61
CLAUDE.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm check # Merge gate — must pass before every commit (knip + format + lint + typecheck + test)
|
||||||
|
pnpm knip # Unused code detection (Knip)
|
||||||
|
pnpm test # Run all tests (Vitest)
|
||||||
|
pnpm test:watch # Tests in watch mode
|
||||||
|
pnpm typecheck # tsc --build (project references)
|
||||||
|
pnpm lint # Biome lint
|
||||||
|
pnpm format # Biome format (writes)
|
||||||
|
pnpm --filter web dev # Vite dev server (localhost:5173)
|
||||||
|
pnpm --filter web build # Production build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a single test file: `pnpm vitest run packages/domain/src/__tests__/advance-turn.test.ts`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Strict layered architecture with ports/adapters and enforced dependency direction:
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web (React 19 + Vite) → packages/application (use cases) → packages/domain (pure logic)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Domain** — Pure functions, no I/O, no framework imports. All state transitions are deterministic. Errors returned as values (`DomainError`), never thrown. Adapters may throw only for programmer errors.
|
||||||
|
- **Application** — Orchestrates domain calls via port interfaces (e.g., `EncounterStore`). No business logic here.
|
||||||
|
- **Web** — React adapter. Implements ports using hooks/state.
|
||||||
|
|
||||||
|
Layer boundaries are enforced by `scripts/check-layer-boundaries.mjs`, which runs as a Vitest test. Domain and application must never import from React, Vite, or upper layers.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Biome 2.0** for formatting and linting (no Prettier, no ESLint). Tab indentation, 80-char lines. Imports are auto-organized alphabetically.
|
||||||
|
- **TypeScript strict mode** with `verbatimModuleSyntax`. Use `.js` extensions in relative imports when required by the repo's ESM settings (e.g., `./types.js`).
|
||||||
|
- **Branded types** for identity values (e.g., `CombatantId`). Prefer immutability/`readonly` where practical.
|
||||||
|
- **Domain events** are plain data objects with a `type` discriminant — no classes.
|
||||||
|
- **Tests** live in `packages/*/src/__tests__/*.test.ts`. Test pure functions directly; map acceptance scenarios and invariants from specs to individual `it()` blocks.
|
||||||
|
- **Feature specs** live in `specs/<feature>/` with spec.md, plan.md, tasks.md. The project constitution is at `.specify/memory/constitution.md`.
|
||||||
|
|
||||||
|
## Constitution (key principles)
|
||||||
|
|
||||||
|
The constitution (`.specify/memory/constitution.md`) governs all feature work:
|
||||||
|
|
||||||
|
1. **Deterministic Domain Core** — Pure state transitions only; no I/O, randomness, or clocks in domain.
|
||||||
|
2. **Layered Architecture** — Domain → Application → Adapters. Never skip layers or reverse dependencies.
|
||||||
|
3. **Clarification-First** — Ask before making non-trivial assumptions.
|
||||||
|
4. **MVP Baseline** — Say "MVP baseline does not include X", never permanent bans.
|
||||||
|
5. **Every feature begins with a spec** — Spec → Plan → Tasks → Implementation.
|
||||||
|
|
||||||
|
## Active Technologies
|
||||||
|
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite (003-remove-combatant)
|
||||||
|
- In-memory React state (local-first, single-user MVP) (003-remove-combatant)
|
||||||
|
- TypeScript 5.x (project), Go binary via npm (Lefthook) + `lefthook` (npm devDependency) (006-pre-commit-gate)
|
||||||
|
- TypeScript 5.x (strict mode, verbatimModuleSyntax) + Knip v5 (new), Biome 2.0, Vitest, Vite 6, React 19 (007-add-knip)
|
||||||
|
|
||||||
|
## Recent Changes
|
||||||
|
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite
|
||||||
28
README.md
Normal file
28
README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Initiative Tracker
|
||||||
|
|
||||||
|
A turn-based initiative tracker for tabletop RPG encounters. Click "Next Turn" to cycle through combatants and advance rounds.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 22
|
||||||
|
- pnpm 10.6+
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install
|
||||||
|
pnpm --filter web dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the URL printed in your terminal (typically `http://localhost:5173`).
|
||||||
|
|
||||||
|
The app starts with a 3-combatant demo encounter (Aria, Brak, Cael). Click **Next Turn** to advance through the initiative order. When the last combatant finishes their turn, the round number increments and the cycle restarts.
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `pnpm --filter web dev` | Start the dev server |
|
||||||
|
| `pnpm --filter web build` | Production build |
|
||||||
|
| `pnpm test` | Run all tests |
|
||||||
|
| `pnpm check` | Full merge gate (format, lint, typecheck, test) |
|
||||||
@@ -1,3 +1,163 @@
|
|||||||
export function App() {
|
import type { CombatantId } from "@initiative/domain";
|
||||||
return <div>Initiative Tracker</div>;
|
import { type FormEvent, useCallback, useRef, useState } from "react";
|
||||||
|
import { useEncounter } from "./hooks/use-encounter";
|
||||||
|
|
||||||
|
function formatEvent(e: ReturnType<typeof useEncounter>["events"][number]) {
|
||||||
|
switch (e.type) {
|
||||||
|
case "TurnAdvanced":
|
||||||
|
return `Turn: ${e.previousCombatantId} → ${e.newCombatantId} (round ${e.roundNumber})`;
|
||||||
|
case "RoundAdvanced":
|
||||||
|
return `Round advanced to ${e.newRoundNumber}`;
|
||||||
|
case "CombatantAdded":
|
||||||
|
return `Added combatant: ${e.name}`;
|
||||||
|
case "CombatantRemoved":
|
||||||
|
return `Removed combatant: ${e.name}`;
|
||||||
|
case "CombatantUpdated":
|
||||||
|
return `Renamed combatant: ${e.oldName} → ${e.newName}`;
|
||||||
|
case "InitiativeSet":
|
||||||
|
return `Initiative: ${e.combatantId} ${e.previousValue ?? "unset"} → ${e.newValue ?? "unset"}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditableName({
|
||||||
|
name,
|
||||||
|
combatantId,
|
||||||
|
isActive,
|
||||||
|
onRename,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
combatantId: CombatantId;
|
||||||
|
isActive: boolean;
|
||||||
|
onRename: (id: CombatantId, newName: string) => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState(name);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const commit = useCallback(() => {
|
||||||
|
const trimmed = draft.trim();
|
||||||
|
if (trimmed !== "" && trimmed !== name) {
|
||||||
|
onRename(combatantId, trimmed);
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
}, [draft, name, combatantId, onRename]);
|
||||||
|
|
||||||
|
const startEditing = useCallback(() => {
|
||||||
|
setDraft(name);
|
||||||
|
setEditing(true);
|
||||||
|
requestAnimationFrame(() => inputRef.current?.select());
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
onBlur={commit}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commit();
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={startEditing}>
|
||||||
|
{isActive ? `▶ ${name}` : name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const {
|
||||||
|
encounter,
|
||||||
|
events,
|
||||||
|
advanceTurn,
|
||||||
|
addCombatant,
|
||||||
|
removeCombatant,
|
||||||
|
editCombatant,
|
||||||
|
setInitiative,
|
||||||
|
} = useEncounter();
|
||||||
|
const activeCombatant = encounter.combatants[encounter.activeIndex];
|
||||||
|
const [nameInput, setNameInput] = useState("");
|
||||||
|
|
||||||
|
const handleAdd = (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (nameInput.trim() === "") return;
|
||||||
|
addCombatant(nameInput);
|
||||||
|
setNameInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1>Initiative Tracker</h1>
|
||||||
|
|
||||||
|
{activeCombatant && (
|
||||||
|
<p>
|
||||||
|
Round {encounter.roundNumber} — Current: {activeCombatant.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{encounter.combatants.map((c, i) => (
|
||||||
|
<li key={c.id}>
|
||||||
|
<EditableName
|
||||||
|
name={c.name}
|
||||||
|
combatantId={c.id}
|
||||||
|
isActive={i === encounter.activeIndex}
|
||||||
|
onRename={editCombatant}
|
||||||
|
/>{" "}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={c.initiative ?? ""}
|
||||||
|
placeholder="Init"
|
||||||
|
style={{ width: "4em" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value;
|
||||||
|
if (raw === "") {
|
||||||
|
setInitiative(c.id, undefined);
|
||||||
|
} else {
|
||||||
|
const n = Number.parseInt(raw, 10);
|
||||||
|
if (!Number.isNaN(n)) {
|
||||||
|
setInitiative(c.id, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>{" "}
|
||||||
|
<button type="button" onClick={() => removeCombatant(c.id)}>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form onSubmit={handleAdd}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={nameInput}
|
||||||
|
onChange={(e) => setNameInput(e.target.value)}
|
||||||
|
placeholder="Combatant name"
|
||||||
|
/>
|
||||||
|
<button type="submit">Add Combatant</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<button type="button" onClick={advanceTurn}>
|
||||||
|
Next Turn
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{events.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h2>Events</h2>
|
||||||
|
<ul>
|
||||||
|
{events.map((e, i) => (
|
||||||
|
<li key={`${e.type}-${i}`}>{formatEvent(e)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
118
apps/web/src/hooks/use-encounter.ts
Normal file
118
apps/web/src/hooks/use-encounter.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import type { EncounterStore } from "@initiative/application";
|
||||||
|
import {
|
||||||
|
addCombatantUseCase,
|
||||||
|
advanceTurnUseCase,
|
||||||
|
editCombatantUseCase,
|
||||||
|
removeCombatantUseCase,
|
||||||
|
setInitiativeUseCase,
|
||||||
|
} from "@initiative/application";
|
||||||
|
import type { CombatantId, DomainEvent, Encounter } from "@initiative/domain";
|
||||||
|
import {
|
||||||
|
combatantId,
|
||||||
|
createEncounter,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
|
function createDemoEncounter(): Encounter {
|
||||||
|
const result = createEncounter([
|
||||||
|
{ id: combatantId("1"), name: "Aria" },
|
||||||
|
{ id: combatantId("2"), name: "Brak" },
|
||||||
|
{ id: combatantId("3"), name: "Cael" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Failed to create demo encounter: ${result.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useEncounter() {
|
||||||
|
const [encounter, setEncounter] = useState<Encounter>(createDemoEncounter);
|
||||||
|
const [events, setEvents] = useState<DomainEvent[]>([]);
|
||||||
|
const encounterRef = useRef(encounter);
|
||||||
|
encounterRef.current = encounter;
|
||||||
|
|
||||||
|
const makeStore = useCallback((): EncounterStore => {
|
||||||
|
return {
|
||||||
|
get: () => encounterRef.current,
|
||||||
|
save: (e) => setEncounter(e),
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const advanceTurn = useCallback(() => {
|
||||||
|
const result = advanceTurnUseCase(makeStore());
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
}, [makeStore]);
|
||||||
|
|
||||||
|
const nextId = useRef(0);
|
||||||
|
|
||||||
|
const addCombatant = useCallback(
|
||||||
|
(name: string) => {
|
||||||
|
const id = combatantId(`c-${++nextId.current}`);
|
||||||
|
const result = addCombatantUseCase(makeStore(), id, name);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeCombatant = useCallback(
|
||||||
|
(id: CombatantId) => {
|
||||||
|
const result = removeCombatantUseCase(makeStore(), id);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editCombatant = useCallback(
|
||||||
|
(id: CombatantId, newName: string) => {
|
||||||
|
const result = editCombatantUseCase(makeStore(), id, newName);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setInitiative = useCallback(
|
||||||
|
(id: CombatantId, value: number | undefined) => {
|
||||||
|
const result = setInitiativeUseCase(makeStore(), id, value);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEvents((prev) => [...prev, ...result]);
|
||||||
|
},
|
||||||
|
[makeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounter,
|
||||||
|
events,
|
||||||
|
advanceTurn,
|
||||||
|
addCombatant,
|
||||||
|
removeCombatant,
|
||||||
|
editCombatant,
|
||||||
|
setInitiative,
|
||||||
|
} as const;
|
||||||
|
}
|
||||||
10
knip.json
Normal file
10
knip.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||||
|
"workspaces": {
|
||||||
|
".": {
|
||||||
|
"entry": ["scripts/*.mjs"]
|
||||||
|
},
|
||||||
|
"packages/*": {},
|
||||||
|
"apps/*": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
lefthook.yml
Normal file
4
lefthook.yml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pre-commit:
|
||||||
|
jobs:
|
||||||
|
- name: check
|
||||||
|
run: pnpm check
|
||||||
@@ -3,10 +3,13 @@
|
|||||||
"packageManager": "pnpm@10.6.0",
|
"packageManager": "pnpm@10.6.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.0.0",
|
"@biomejs/biome": "2.0.0",
|
||||||
|
"knip": "^5.85.0",
|
||||||
|
"lefthook": "^1.11.0",
|
||||||
"typescript": "^5.8.0",
|
"typescript": "^5.8.0",
|
||||||
"vitest": "^3.0.0"
|
"vitest": "^3.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"prepare": "lefthook install",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "biome format .",
|
"format:check": "biome format .",
|
||||||
"lint": "biome lint .",
|
"lint": "biome lint .",
|
||||||
@@ -14,6 +17,7 @@
|
|||||||
"typecheck": "tsc --build",
|
"typecheck": "tsc --build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"check": "biome check . && tsc --build && vitest run"
|
"knip": "knip",
|
||||||
|
"check": "knip && biome check . && tsc --build && vitest run"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
packages/application/src/add-combatant-use-case.ts
Normal file
24
packages/application/src/add-combatant-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
addCombatant,
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function addCombatantUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
id: CombatantId,
|
||||||
|
name: string,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = addCombatant(encounter, id, name);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
21
packages/application/src/advance-turn-use-case.ts
Normal file
21
packages/application/src/advance-turn-use-case.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
advanceTurn,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function advanceTurnUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = advanceTurn(encounter);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
24
packages/application/src/edit-combatant-use-case.ts
Normal file
24
packages/application/src/edit-combatant-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
editCombatant,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function editCombatantUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
id: CombatantId,
|
||||||
|
newName: string,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = editCombatant(encounter, id, newName);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
@@ -1 +1,6 @@
|
|||||||
export {};
|
export { addCombatantUseCase } from "./add-combatant-use-case.js";
|
||||||
|
export { advanceTurnUseCase } from "./advance-turn-use-case.js";
|
||||||
|
export { editCombatantUseCase } from "./edit-combatant-use-case.js";
|
||||||
|
export type { EncounterStore } from "./ports.js";
|
||||||
|
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||||
|
export { setInitiativeUseCase } from "./set-initiative-use-case.js";
|
||||||
|
|||||||
6
packages/application/src/ports.ts
Normal file
6
packages/application/src/ports.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import type { Encounter } from "@initiative/domain";
|
||||||
|
|
||||||
|
export interface EncounterStore {
|
||||||
|
get(): Encounter;
|
||||||
|
save(encounter: Encounter): void;
|
||||||
|
}
|
||||||
23
packages/application/src/remove-combatant-use-case.ts
Normal file
23
packages/application/src/remove-combatant-use-case.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
removeCombatant,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function removeCombatantUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
id: CombatantId,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = removeCombatant(encounter, id);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
24
packages/application/src/set-initiative-use-case.ts
Normal file
24
packages/application/src/set-initiative-use-case.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type DomainError,
|
||||||
|
type DomainEvent,
|
||||||
|
isDomainError,
|
||||||
|
setInitiative,
|
||||||
|
} from "@initiative/domain";
|
||||||
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export function setInitiativeUseCase(
|
||||||
|
store: EncounterStore,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
value: number | undefined,
|
||||||
|
): DomainEvent[] | DomainError {
|
||||||
|
const encounter = store.get();
|
||||||
|
const result = setInitiative(encounter, combatantId, value);
|
||||||
|
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.save(result.encounter);
|
||||||
|
return result.events;
|
||||||
|
}
|
||||||
200
packages/domain/src/__tests__/add-combatant.test.ts
Normal file
200
packages/domain/src/__tests__/add-combatant.test.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { addCombatant } from "../add-combatant.js";
|
||||||
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function makeCombatant(name: string): Combatant {
|
||||||
|
return { id: combatantId(name), name };
|
||||||
|
}
|
||||||
|
|
||||||
|
const A = makeCombatant("A");
|
||||||
|
const B = makeCombatant("B");
|
||||||
|
const C = makeCombatant("C");
|
||||||
|
|
||||||
|
function enc(
|
||||||
|
combatants: Combatant[],
|
||||||
|
activeIndex = 0,
|
||||||
|
roundNumber = 1,
|
||||||
|
): Encounter {
|
||||||
|
return { combatants, activeIndex, roundNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
function successResult(encounter: Encounter, id: string, name: string) {
|
||||||
|
const result = addCombatant(encounter, combatantId(id), name);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Acceptance Scenarios ---
|
||||||
|
|
||||||
|
describe("addCombatant", () => {
|
||||||
|
describe("acceptance scenarios", () => {
|
||||||
|
it("scenario 1: add to empty encounter", () => {
|
||||||
|
const e = enc([], 0, 1);
|
||||||
|
const { encounter, events } = successResult(e, "gandalf", "Gandalf");
|
||||||
|
|
||||||
|
expect(encounter.combatants).toEqual([
|
||||||
|
{ id: combatantId("gandalf"), name: "Gandalf" },
|
||||||
|
]);
|
||||||
|
expect(encounter.activeIndex).toBe(0);
|
||||||
|
expect(encounter.roundNumber).toBe(1);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "CombatantAdded",
|
||||||
|
combatantId: combatantId("gandalf"),
|
||||||
|
name: "Gandalf",
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 2: add to encounter with [A, B]", () => {
|
||||||
|
const e = enc([A, B], 0, 1);
|
||||||
|
const { encounter, events } = successResult(e, "C", "C");
|
||||||
|
|
||||||
|
expect(encounter.combatants).toEqual([
|
||||||
|
A,
|
||||||
|
B,
|
||||||
|
{ id: combatantId("C"), name: "C" },
|
||||||
|
]);
|
||||||
|
expect(encounter.activeIndex).toBe(0);
|
||||||
|
expect(encounter.roundNumber).toBe(1);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "CombatantAdded",
|
||||||
|
combatantId: combatantId("C"),
|
||||||
|
name: "C",
|
||||||
|
position: 2,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 3: add during mid-round does not change active combatant", () => {
|
||||||
|
const e = enc([A, B, C], 2, 3);
|
||||||
|
const { encounter, events } = successResult(e, "D", "D");
|
||||||
|
|
||||||
|
expect(encounter.combatants).toHaveLength(4);
|
||||||
|
expect(encounter.combatants[3]).toEqual({
|
||||||
|
id: combatantId("D"),
|
||||||
|
name: "D",
|
||||||
|
});
|
||||||
|
expect(encounter.activeIndex).toBe(2);
|
||||||
|
expect(encounter.roundNumber).toBe(3);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "CombatantAdded",
|
||||||
|
combatantId: combatantId("D"),
|
||||||
|
name: "D",
|
||||||
|
position: 3,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 4: two sequential adds preserve order", () => {
|
||||||
|
const e = enc([A]);
|
||||||
|
const first = successResult(e, "B", "B");
|
||||||
|
const second = successResult(first.encounter, "C", "C");
|
||||||
|
|
||||||
|
expect(second.encounter.combatants).toEqual([
|
||||||
|
A,
|
||||||
|
{ id: combatantId("B"), name: "B" },
|
||||||
|
{ id: combatantId("C"), name: "C" },
|
||||||
|
]);
|
||||||
|
expect(first.events).toHaveLength(1);
|
||||||
|
expect(second.events).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 5: empty name returns error", () => {
|
||||||
|
const e = enc([A, B]);
|
||||||
|
const result = addCombatant(e, combatantId("x"), "");
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("invalid-name");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 6: whitespace-only name returns error", () => {
|
||||||
|
const e = enc([A, B]);
|
||||||
|
const result = addCombatant(e, combatantId("x"), " ");
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("invalid-name");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invariants", () => {
|
||||||
|
it("INV-1: encounter may have zero combatants (adding to empty is valid)", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const result = addCombatant(e, combatantId("a"), "A");
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("INV-2: activeIndex remains valid after adding", () => {
|
||||||
|
const scenarios: Encounter[] = [
|
||||||
|
enc([], 0, 1),
|
||||||
|
enc([A], 0, 1),
|
||||||
|
enc([A, B, C], 2, 3),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const e of scenarios) {
|
||||||
|
const result = successResult(e, "new", "New");
|
||||||
|
const { combatants, activeIndex } = result.encounter;
|
||||||
|
if (combatants.length > 0) {
|
||||||
|
expect(activeIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(activeIndex).toBeLessThan(combatants.length);
|
||||||
|
} else {
|
||||||
|
expect(activeIndex).toBe(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("INV-3: roundNumber is preserved (never decreases)", () => {
|
||||||
|
const e = enc([A, B], 1, 5);
|
||||||
|
const { encounter } = successResult(e, "C", "C");
|
||||||
|
expect(encounter.roundNumber).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("INV-4: determinism — same input produces same output", () => {
|
||||||
|
const e = enc([A, B], 1, 3);
|
||||||
|
const result1 = addCombatant(e, combatantId("x"), "X");
|
||||||
|
const result2 = addCombatant(e, combatantId("x"), "X");
|
||||||
|
expect(result1).toEqual(result2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("INV-5: every success emits exactly one CombatantAdded event", () => {
|
||||||
|
const scenarios: Encounter[] = [enc([]), enc([A]), enc([A, B, C], 2, 5)];
|
||||||
|
|
||||||
|
for (const e of scenarios) {
|
||||||
|
const result = successResult(e, "z", "Z");
|
||||||
|
expect(result.events).toHaveLength(1);
|
||||||
|
expect(result.events[0].type).toBe("CombatantAdded");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("INV-6: addCombatant does not change activeIndex or roundNumber", () => {
|
||||||
|
const e = enc([A, B, C], 2, 7);
|
||||||
|
const { encounter } = successResult(e, "D", "D");
|
||||||
|
expect(encounter.activeIndex).toBe(2);
|
||||||
|
expect(encounter.roundNumber).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("INV-7: new combatant is always appended at the end", () => {
|
||||||
|
const e = enc([A, B]);
|
||||||
|
const { encounter } = successResult(e, "C", "C");
|
||||||
|
expect(encounter.combatants[encounter.combatants.length - 1]).toEqual({
|
||||||
|
id: combatantId("C"),
|
||||||
|
name: "C",
|
||||||
|
});
|
||||||
|
// Existing combatants preserve order
|
||||||
|
expect(encounter.combatants[0]).toEqual(A);
|
||||||
|
expect(encounter.combatants[1]).toEqual(B);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
163
packages/domain/src/__tests__/edit-combatant.test.ts
Normal file
163
packages/domain/src/__tests__/edit-combatant.test.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { editCombatant } from "../edit-combatant.js";
|
||||||
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function makeCombatant(name: string): Combatant {
|
||||||
|
return { id: combatantId(name), name };
|
||||||
|
}
|
||||||
|
|
||||||
|
const Alice = makeCombatant("Alice");
|
||||||
|
const Bob = makeCombatant("Bob");
|
||||||
|
|
||||||
|
function enc(
|
||||||
|
combatants: Combatant[],
|
||||||
|
activeIndex = 0,
|
||||||
|
roundNumber = 1,
|
||||||
|
): Encounter {
|
||||||
|
return { combatants, activeIndex, roundNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
function successResult(encounter: Encounter, id: string, newName: string) {
|
||||||
|
const result = editCombatant(encounter, combatantId(id), newName);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Acceptance Scenarios (T004) ---
|
||||||
|
|
||||||
|
describe("editCombatant", () => {
|
||||||
|
describe("acceptance scenarios", () => {
|
||||||
|
it("scenario 1: rename succeeds with correct event containing combatantId, oldName, newName", () => {
|
||||||
|
const e = enc([Alice, Bob]);
|
||||||
|
const { encounter, events } = successResult(e, "Bob", "Robert");
|
||||||
|
|
||||||
|
expect(encounter.combatants[1]).toEqual({
|
||||||
|
id: combatantId("Bob"),
|
||||||
|
name: "Robert",
|
||||||
|
});
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "CombatantUpdated",
|
||||||
|
combatantId: combatantId("Bob"),
|
||||||
|
oldName: "Bob",
|
||||||
|
newName: "Robert",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 2: activeIndex and roundNumber preserved when renaming the active combatant", () => {
|
||||||
|
const e = enc([Alice, Bob], 1, 3);
|
||||||
|
const { encounter } = successResult(e, "Bob", "Robert");
|
||||||
|
|
||||||
|
expect(encounter.activeIndex).toBe(1);
|
||||||
|
expect(encounter.roundNumber).toBe(3);
|
||||||
|
expect(encounter.combatants[1].name).toBe("Robert");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 3: combatant list order preserved", () => {
|
||||||
|
const Cael = makeCombatant("Cael");
|
||||||
|
const e = enc([Alice, Bob, Cael]);
|
||||||
|
const { encounter } = successResult(e, "Bob", "Robert");
|
||||||
|
|
||||||
|
expect(encounter.combatants.map((c) => c.name)).toEqual([
|
||||||
|
"Alice",
|
||||||
|
"Robert",
|
||||||
|
"Cael",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scenario 4: renaming to same name still emits event", () => {
|
||||||
|
const e = enc([Alice, Bob]);
|
||||||
|
const { encounter, events } = successResult(e, "Bob", "Bob");
|
||||||
|
|
||||||
|
expect(encounter.combatants[1].name).toBe("Bob");
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0]).toEqual({
|
||||||
|
type: "CombatantUpdated",
|
||||||
|
combatantId: combatantId("Bob"),
|
||||||
|
oldName: "Bob",
|
||||||
|
newName: "Bob",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Invariant Tests (T005) ---
|
||||||
|
|
||||||
|
describe("invariants", () => {
|
||||||
|
it("INV-1: determinism — same inputs produce same outputs", () => {
|
||||||
|
const e = enc([Alice, Bob], 1, 3);
|
||||||
|
const result1 = editCombatant(e, combatantId("Alice"), "Aria");
|
||||||
|
const result2 = editCombatant(e, combatantId("Alice"), "Aria");
|
||||||
|
expect(result1).toEqual(result2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("INV-2: exactly one event emitted on success", () => {
|
||||||
|
const e = enc([Alice, Bob]);
|
||||||
|
const { events } = successResult(e, "Alice", "Aria");
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe("CombatantUpdated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("INV-3: original encounter is not mutated", () => {
|
||||||
|
const e = enc([Alice, Bob], 0, 1);
|
||||||
|
const originalCombatants = [...e.combatants];
|
||||||
|
const originalActiveIndex = e.activeIndex;
|
||||||
|
const originalRoundNumber = e.roundNumber;
|
||||||
|
|
||||||
|
successResult(e, "Alice", "Aria");
|
||||||
|
|
||||||
|
expect(e.combatants).toEqual(originalCombatants);
|
||||||
|
expect(e.activeIndex).toBe(originalActiveIndex);
|
||||||
|
expect(e.roundNumber).toBe(originalRoundNumber);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Error Scenarios (T011) ---
|
||||||
|
|
||||||
|
describe("error scenarios", () => {
|
||||||
|
it("non-existent id returns combatant-not-found error", () => {
|
||||||
|
const e = enc([Alice, Bob]);
|
||||||
|
const result = editCombatant(e, combatantId("nonexistent"), "NewName");
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("combatant-not-found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty name returns invalid-name error", () => {
|
||||||
|
const e = enc([Alice, Bob]);
|
||||||
|
const result = editCombatant(e, combatantId("Alice"), "");
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("invalid-name");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("whitespace-only name returns invalid-name error", () => {
|
||||||
|
const e = enc([Alice, Bob]);
|
||||||
|
const result = editCombatant(e, combatantId("Alice"), " ");
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("invalid-name");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty encounter returns combatant-not-found for any id", () => {
|
||||||
|
const e = enc([]);
|
||||||
|
const result = editCombatant(e, combatantId("any"), "Name");
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("combatant-not-found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
142
packages/domain/src/__tests__/remove-combatant.test.ts
Normal file
142
packages/domain/src/__tests__/remove-combatant.test.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { removeCombatant } from "../remove-combatant.js";
|
||||||
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function makeCombatant(name: string): Combatant {
|
||||||
|
return { id: combatantId(name), name };
|
||||||
|
}
|
||||||
|
|
||||||
|
const A = makeCombatant("A");
|
||||||
|
const B = makeCombatant("B");
|
||||||
|
const C = makeCombatant("C");
|
||||||
|
const D = makeCombatant("D");
|
||||||
|
|
||||||
|
function enc(
|
||||||
|
combatants: Combatant[],
|
||||||
|
activeIndex = 0,
|
||||||
|
roundNumber = 1,
|
||||||
|
): Encounter {
|
||||||
|
return { combatants, activeIndex, roundNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
function successResult(encounter: Encounter, id: string) {
|
||||||
|
const result = removeCombatant(encounter, combatantId(id));
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Acceptance Scenarios ---
|
||||||
|
|
||||||
|
describe("removeCombatant", () => {
|
||||||
|
describe("acceptance scenarios", () => {
|
||||||
|
it("AS-1: remove combatant after active — activeIndex unchanged", () => {
|
||||||
|
// [A*, B, C] remove C → [A*, B], activeIndex stays 0
|
||||||
|
const e = enc([A, B, C], 0, 2);
|
||||||
|
const { encounter, events } = successResult(e, "C");
|
||||||
|
|
||||||
|
expect(encounter.combatants).toEqual([A, B]);
|
||||||
|
expect(encounter.activeIndex).toBe(0);
|
||||||
|
expect(encounter.roundNumber).toBe(2);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "CombatantRemoved",
|
||||||
|
combatantId: combatantId("C"),
|
||||||
|
name: "C",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-2: remove combatant before active — activeIndex decrements", () => {
|
||||||
|
// [A, B, C*] remove A → [B, C*], activeIndex 2→1
|
||||||
|
const e = enc([A, B, C], 2, 3);
|
||||||
|
const { encounter } = successResult(e, "A");
|
||||||
|
|
||||||
|
expect(encounter.combatants).toEqual([B, C]);
|
||||||
|
expect(encounter.activeIndex).toBe(1);
|
||||||
|
expect(encounter.roundNumber).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-3: remove active combatant mid-list — next slides in", () => {
|
||||||
|
// [A, B*, C, D] remove B → [A, C*, D], activeIndex stays 1
|
||||||
|
const e = enc([A, B, C, D], 1, 1);
|
||||||
|
const { encounter } = successResult(e, "B");
|
||||||
|
|
||||||
|
expect(encounter.combatants).toEqual([A, C, D]);
|
||||||
|
expect(encounter.activeIndex).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-4: remove active combatant at end — wraps to 0", () => {
|
||||||
|
// [A, B, C*] remove C → [A, B], activeIndex wraps to 0
|
||||||
|
const e = enc([A, B, C], 2, 1);
|
||||||
|
const { encounter } = successResult(e, "C");
|
||||||
|
|
||||||
|
expect(encounter.combatants).toEqual([A, B]);
|
||||||
|
expect(encounter.activeIndex).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-5: remove only combatant — empty list, activeIndex 0", () => {
|
||||||
|
const e = enc([A], 0, 5);
|
||||||
|
const { encounter } = successResult(e, "A");
|
||||||
|
|
||||||
|
expect(encounter.combatants).toEqual([]);
|
||||||
|
expect(encounter.activeIndex).toBe(0);
|
||||||
|
expect(encounter.roundNumber).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-6: ID not found — returns DomainError", () => {
|
||||||
|
const e = enc([A, B], 0, 1);
|
||||||
|
const result = removeCombatant(e, combatantId("nonexistent"));
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("combatant-not-found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("invariants", () => {
|
||||||
|
it("event shape includes combatantId and name", () => {
|
||||||
|
const e = enc([A, B], 0, 1);
|
||||||
|
const { events } = successResult(e, "B");
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0]).toEqual({
|
||||||
|
type: "CombatantRemoved",
|
||||||
|
combatantId: combatantId("B"),
|
||||||
|
name: "B",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("roundNumber never changes on removal", () => {
|
||||||
|
const e = enc([A, B, C], 1, 7);
|
||||||
|
const { encounter } = successResult(e, "A");
|
||||||
|
expect(encounter.roundNumber).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("determinism — same input produces same output", () => {
|
||||||
|
const e = enc([A, B, C], 1, 3);
|
||||||
|
const result1 = removeCombatant(e, combatantId("B"));
|
||||||
|
const result2 = removeCombatant(e, combatantId("B"));
|
||||||
|
expect(result1).toEqual(result2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every success emits exactly one CombatantRemoved event", () => {
|
||||||
|
const scenarios: [Encounter, string][] = [
|
||||||
|
[enc([A]), "A"],
|
||||||
|
[enc([A, B], 1), "A"],
|
||||||
|
[enc([A, B, C], 2, 5), "C"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [e, id] of scenarios) {
|
||||||
|
const { events } = successResult(e, id);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe("CombatantRemoved");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
314
packages/domain/src/__tests__/set-initiative.test.ts
Normal file
314
packages/domain/src/__tests__/set-initiative.test.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { setInitiative } from "../set-initiative.js";
|
||||||
|
import type { Combatant, Encounter } from "../types.js";
|
||||||
|
import { combatantId, isDomainError } from "../types.js";
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
function makeCombatant(name: string, initiative?: number): Combatant {
|
||||||
|
return initiative === undefined
|
||||||
|
? { id: combatantId(name), name }
|
||||||
|
: { id: combatantId(name), name, initiative };
|
||||||
|
}
|
||||||
|
|
||||||
|
const A = makeCombatant("A");
|
||||||
|
const B = makeCombatant("B");
|
||||||
|
const C = makeCombatant("C");
|
||||||
|
function enc(
|
||||||
|
combatants: Combatant[],
|
||||||
|
activeIndex = 0,
|
||||||
|
roundNumber = 1,
|
||||||
|
): Encounter {
|
||||||
|
return { combatants, activeIndex, roundNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
function successResult(
|
||||||
|
encounter: Encounter,
|
||||||
|
id: string,
|
||||||
|
value: number | undefined,
|
||||||
|
) {
|
||||||
|
const result = setInitiative(encounter, combatantId(id), value);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
throw new Error(`Expected success, got error: ${result.message}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function names(encounter: Encounter): string[] {
|
||||||
|
return encounter.combatants.map((c) => c.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- US1: Set Initiative ---
|
||||||
|
|
||||||
|
describe("setInitiative", () => {
|
||||||
|
describe("US1: set initiative value", () => {
|
||||||
|
it("AS-1: set initiative on combatant with no initiative", () => {
|
||||||
|
const e = enc([A, B], 0);
|
||||||
|
const { encounter, events } = successResult(e, "A", 15);
|
||||||
|
|
||||||
|
expect(encounter.combatants[0].initiative).toBe(15);
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "InitiativeSet",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousValue: undefined,
|
||||||
|
newValue: 15,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-2: change existing initiative value", () => {
|
||||||
|
const e = enc([makeCombatant("A", 15), B], 0);
|
||||||
|
const { encounter, events } = successResult(e, "A", 8);
|
||||||
|
|
||||||
|
const a = encounter.combatants.find((c) => c.id === combatantId("A"));
|
||||||
|
expect(a?.initiative).toBe(8);
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
previousValue: 15,
|
||||||
|
newValue: 8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-3: reject non-integer initiative value", () => {
|
||||||
|
const e = enc([A, B], 0);
|
||||||
|
const result = setInitiative(e, combatantId("A"), 3.5);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("invalid-initiative");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-3b: reject NaN", () => {
|
||||||
|
const e = enc([A, B], 0);
|
||||||
|
const result = setInitiative(e, combatantId("A"), Number.NaN);
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-3c: reject Infinity", () => {
|
||||||
|
const e = enc([A, B], 0);
|
||||||
|
const result = setInitiative(
|
||||||
|
e,
|
||||||
|
combatantId("A"),
|
||||||
|
Number.POSITIVE_INFINITY,
|
||||||
|
);
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-4: clear initiative moves combatant to end", () => {
|
||||||
|
const e = enc([makeCombatant("A", 15), makeCombatant("B", 10)], 0);
|
||||||
|
const { encounter } = successResult(e, "A", undefined);
|
||||||
|
|
||||||
|
const a = encounter.combatants.find((c) => c.id === combatantId("A"));
|
||||||
|
expect(a?.initiative).toBeUndefined();
|
||||||
|
// A should be after B now
|
||||||
|
expect(names(encounter)).toEqual(["B", "A"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns error for nonexistent combatant", () => {
|
||||||
|
const e = enc([A, B], 0);
|
||||||
|
const result = setInitiative(e, combatantId("nonexistent"), 10);
|
||||||
|
|
||||||
|
expect(isDomainError(result)).toBe(true);
|
||||||
|
if (isDomainError(result)) {
|
||||||
|
expect(result.code).toBe("combatant-not-found");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- US2: Automatic Ordering ---
|
||||||
|
|
||||||
|
describe("US2: automatic ordering by initiative", () => {
|
||||||
|
it("AS-1: orders combatants descending by initiative", () => {
|
||||||
|
// Start with A(20), B(5), C(15) → should be A(20), C(15), B(5)
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", 20),
|
||||||
|
makeCombatant("B", 5),
|
||||||
|
makeCombatant("C", 15),
|
||||||
|
]);
|
||||||
|
// Set C's initiative to trigger reorder (no-op change to force sort)
|
||||||
|
const { encounter } = successResult(e, "C", 15);
|
||||||
|
expect(names(encounter)).toEqual(["A", "C", "B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-2: changing initiative reorders correctly", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", 20),
|
||||||
|
makeCombatant("C", 15),
|
||||||
|
makeCombatant("B", 5),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "B", 25);
|
||||||
|
expect(names(encounter)).toEqual(["B", "A", "C"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-3: stable sort for equal initiative values", () => {
|
||||||
|
const e = enc([makeCombatant("A", 10), makeCombatant("B", 10)]);
|
||||||
|
// Set A's initiative to same value to confirm stable sort
|
||||||
|
const { encounter } = successResult(e, "A", 10);
|
||||||
|
expect(names(encounter)).toEqual(["A", "B"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- US3: Combatants Without Initiative ---
|
||||||
|
|
||||||
|
describe("US3: combatants without initiative", () => {
|
||||||
|
it("AS-1: unset combatants appear after those with initiative", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", 15),
|
||||||
|
B, // no initiative
|
||||||
|
makeCombatant("C", 10),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "A", 15);
|
||||||
|
expect(names(encounter)).toEqual(["A", "C", "B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-2: multiple unset combatants preserve relative order", () => {
|
||||||
|
const e = enc([A, B]); // both no initiative
|
||||||
|
const { encounter } = successResult(e, "A", undefined);
|
||||||
|
expect(names(encounter)).toEqual(["A", "B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-3: setting initiative moves combatant to correct position", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", 20),
|
||||||
|
B, // no initiative
|
||||||
|
makeCombatant("C", 10),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "B", 12);
|
||||||
|
expect(names(encounter)).toEqual(["A", "B", "C"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- US4: Active Turn Preservation ---
|
||||||
|
|
||||||
|
describe("US4: active turn preservation during reorder", () => {
|
||||||
|
it("AS-1: reorder preserves active turn on different combatant", () => {
|
||||||
|
// B is active (index 1), change A's initiative
|
||||||
|
const e = enc(
|
||||||
|
[makeCombatant("A", 10), makeCombatant("B", 15), makeCombatant("C", 5)],
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// Change A's initiative to 20, causing reorder
|
||||||
|
const { encounter } = successResult(e, "A", 20);
|
||||||
|
// New order: A(20), B(15), C(5)
|
||||||
|
expect(names(encounter)).toEqual(["A", "B", "C"]);
|
||||||
|
// B should still be active
|
||||||
|
expect(encounter.combatants[encounter.activeIndex].id).toBe(
|
||||||
|
combatantId("B"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AS-2: active combatant's own initiative change preserves turn", () => {
|
||||||
|
const e = enc(
|
||||||
|
[makeCombatant("A", 20), makeCombatant("B", 15), makeCombatant("C", 5)],
|
||||||
|
0, // A is active
|
||||||
|
);
|
||||||
|
// Change A's initiative to 1, causing it to move to the end
|
||||||
|
const { encounter } = successResult(e, "A", 1);
|
||||||
|
// New order: B(15), C(5), A(1)
|
||||||
|
expect(names(encounter)).toEqual(["B", "C", "A"]);
|
||||||
|
// A should still be active
|
||||||
|
expect(encounter.combatants[encounter.activeIndex].id).toBe(
|
||||||
|
combatantId("A"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Invariants ---
|
||||||
|
|
||||||
|
describe("invariants", () => {
|
||||||
|
it("determinism — same input produces same output", () => {
|
||||||
|
const e = enc([A, B, C], 1, 3);
|
||||||
|
const result1 = setInitiative(e, combatantId("A"), 10);
|
||||||
|
const result2 = setInitiative(e, combatantId("A"), 10);
|
||||||
|
expect(result1).toEqual(result2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("immutability — input encounter is not mutated", () => {
|
||||||
|
const e = enc([A, B], 0, 2);
|
||||||
|
const original = JSON.parse(JSON.stringify(e));
|
||||||
|
setInitiative(e, combatantId("A"), 10);
|
||||||
|
expect(e).toEqual(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("event shape includes all required fields", () => {
|
||||||
|
const e = enc([makeCombatant("A", 5), B], 0);
|
||||||
|
const { events } = successResult(e, "A", 10);
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0]).toEqual({
|
||||||
|
type: "InitiativeSet",
|
||||||
|
combatantId: combatantId("A"),
|
||||||
|
previousValue: 5,
|
||||||
|
newValue: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("roundNumber is never changed", () => {
|
||||||
|
const e = enc([A, B], 0, 7);
|
||||||
|
const { encounter } = successResult(e, "A", 10);
|
||||||
|
expect(encounter.roundNumber).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every success emits exactly one InitiativeSet event", () => {
|
||||||
|
const scenarios: [Encounter, string, number | undefined][] = [
|
||||||
|
[enc([A]), "A", 10],
|
||||||
|
[enc([A, B], 1), "A", 5],
|
||||||
|
[enc([makeCombatant("A", 10)]), "A", undefined],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [e, id, value] of scenarios) {
|
||||||
|
const { events } = successResult(e, id, value);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].type).toBe("InitiativeSet");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Edge Cases ---
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("zero is a valid initiative value", () => {
|
||||||
|
const e = enc([A, B], 0);
|
||||||
|
const { encounter } = successResult(e, "A", 0);
|
||||||
|
const a = encounter.combatants.find((c) => c.id === combatantId("A"));
|
||||||
|
expect(a?.initiative).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("negative initiative is valid", () => {
|
||||||
|
const e = enc([A, B], 0);
|
||||||
|
const { encounter } = successResult(e, "A", -5);
|
||||||
|
const a = encounter.combatants.find((c) => c.id === combatantId("A"));
|
||||||
|
expect(a?.initiative).toBe(-5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("negative sorts below positive", () => {
|
||||||
|
const e = enc([makeCombatant("A", -3), makeCombatant("B", 10)]);
|
||||||
|
const { encounter } = successResult(e, "A", -3);
|
||||||
|
expect(names(encounter)).toEqual(["B", "A"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all combatants with same initiative preserve order", () => {
|
||||||
|
const e = enc([
|
||||||
|
makeCombatant("A", 10),
|
||||||
|
makeCombatant("B", 10),
|
||||||
|
makeCombatant("C", 10),
|
||||||
|
]);
|
||||||
|
const { encounter } = successResult(e, "B", 10);
|
||||||
|
expect(names(encounter)).toEqual(["A", "B", "C"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clearing initiative on last combatant with initiative", () => {
|
||||||
|
const e = enc([makeCombatant("A", 10), B], 0);
|
||||||
|
const { encounter } = successResult(e, "A", undefined);
|
||||||
|
// Both unset now, preserve relative order
|
||||||
|
expect(names(encounter)).toEqual(["A", "B"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undefined value skips integer validation", () => {
|
||||||
|
const e = enc([A], 0);
|
||||||
|
const result = setInitiative(e, combatantId("A"), undefined);
|
||||||
|
expect(isDomainError(result)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
50
packages/domain/src/add-combatant.ts
Normal file
50
packages/domain/src/add-combatant.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { DomainEvent } from "./events.js";
|
||||||
|
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||||
|
|
||||||
|
export interface AddCombatantSuccess {
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly events: DomainEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function that adds a combatant to the end of an encounter's list.
|
||||||
|
*
|
||||||
|
* FR-001: Accepts an Encounter, CombatantId, and name; returns next state + events.
|
||||||
|
* FR-002: Appends new combatant to end of combatants list.
|
||||||
|
* FR-004: Rejects empty/whitespace-only names with DomainError.
|
||||||
|
* FR-005: Does not alter activeIndex or roundNumber.
|
||||||
|
* FR-006: Events returned as values, not dispatched via side effects.
|
||||||
|
*/
|
||||||
|
export function addCombatant(
|
||||||
|
encounter: Encounter,
|
||||||
|
id: CombatantId,
|
||||||
|
name: string,
|
||||||
|
): AddCombatantSuccess | DomainError {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
|
||||||
|
if (trimmed === "") {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-name",
|
||||||
|
message: "Combatant name must not be empty",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const position = encounter.combatants.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: [...encounter.combatants, { id, name: trimmed }],
|
||||||
|
activeIndex: encounter.activeIndex,
|
||||||
|
roundNumber: encounter.roundNumber,
|
||||||
|
},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "CombatantAdded",
|
||||||
|
combatantId: id,
|
||||||
|
name: trimmed,
|
||||||
|
position,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
62
packages/domain/src/edit-combatant.ts
Normal file
62
packages/domain/src/edit-combatant.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { DomainEvent } from "./events.js";
|
||||||
|
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||||
|
|
||||||
|
export interface EditCombatantSuccess {
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly events: DomainEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function that renames a combatant in an encounter by ID.
|
||||||
|
*
|
||||||
|
* FR-001: Accepts Encounter, CombatantId, and newName; returns next state + events.
|
||||||
|
* FR-002: Emits a CombatantUpdated event with combatantId, oldName, newName.
|
||||||
|
* FR-004: Rejects empty/whitespace-only names with DomainError.
|
||||||
|
* FR-005: Preserves activeIndex and roundNumber.
|
||||||
|
* FR-006: Preserves combatant list order.
|
||||||
|
*/
|
||||||
|
export function editCombatant(
|
||||||
|
encounter: Encounter,
|
||||||
|
id: CombatantId,
|
||||||
|
newName: string,
|
||||||
|
): EditCombatantSuccess | DomainError {
|
||||||
|
const trimmed = newName.trim();
|
||||||
|
|
||||||
|
if (trimmed === "") {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-name",
|
||||||
|
message: "Combatant name must not be empty",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = encounter.combatants.findIndex((c) => c.id === id);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "combatant-not-found",
|
||||||
|
message: `No combatant found with ID "${id}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldName = encounter.combatants[index].name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: encounter.combatants.map((c) =>
|
||||||
|
c.id === id ? { ...c, name: trimmed } : c,
|
||||||
|
),
|
||||||
|
activeIndex: encounter.activeIndex,
|
||||||
|
roundNumber: encounter.roundNumber,
|
||||||
|
},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "CombatantUpdated",
|
||||||
|
combatantId: id,
|
||||||
|
oldName,
|
||||||
|
newName: trimmed,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,4 +12,37 @@ export interface RoundAdvanced {
|
|||||||
readonly newRoundNumber: number;
|
readonly newRoundNumber: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DomainEvent = TurnAdvanced | RoundAdvanced;
|
export interface CombatantAdded {
|
||||||
|
readonly type: "CombatantAdded";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly name: string;
|
||||||
|
readonly position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatantRemoved {
|
||||||
|
readonly type: "CombatantRemoved";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CombatantUpdated {
|
||||||
|
readonly type: "CombatantUpdated";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly oldName: string;
|
||||||
|
readonly newName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitiativeSet {
|
||||||
|
readonly type: "InitiativeSet";
|
||||||
|
readonly combatantId: CombatantId;
|
||||||
|
readonly previousValue: number | undefined;
|
||||||
|
readonly newValue: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DomainEvent =
|
||||||
|
| TurnAdvanced
|
||||||
|
| RoundAdvanced
|
||||||
|
| CombatantAdded
|
||||||
|
| CombatantRemoved
|
||||||
|
| CombatantUpdated
|
||||||
|
| InitiativeSet;
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
|
export { type AddCombatantSuccess, addCombatant } from "./add-combatant.js";
|
||||||
export { advanceTurn } from "./advance-turn.js";
|
export { advanceTurn } from "./advance-turn.js";
|
||||||
|
export {
|
||||||
|
type EditCombatantSuccess,
|
||||||
|
editCombatant,
|
||||||
|
} from "./edit-combatant.js";
|
||||||
export type {
|
export type {
|
||||||
|
CombatantAdded,
|
||||||
|
CombatantRemoved,
|
||||||
|
CombatantUpdated,
|
||||||
DomainEvent,
|
DomainEvent,
|
||||||
|
InitiativeSet,
|
||||||
RoundAdvanced,
|
RoundAdvanced,
|
||||||
TurnAdvanced,
|
TurnAdvanced,
|
||||||
} from "./events.js";
|
} from "./events.js";
|
||||||
|
export {
|
||||||
|
type RemoveCombatantSuccess,
|
||||||
|
removeCombatant,
|
||||||
|
} from "./remove-combatant.js";
|
||||||
|
export {
|
||||||
|
type SetInitiativeSuccess,
|
||||||
|
setInitiative,
|
||||||
|
} from "./set-initiative.js";
|
||||||
export {
|
export {
|
||||||
type Combatant,
|
type Combatant,
|
||||||
type CombatantId,
|
type CombatantId,
|
||||||
|
|||||||
65
packages/domain/src/remove-combatant.ts
Normal file
65
packages/domain/src/remove-combatant.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import type { DomainEvent } from "./events.js";
|
||||||
|
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||||
|
|
||||||
|
export interface RemoveCombatantSuccess {
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly events: DomainEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function that removes a combatant from an encounter by ID.
|
||||||
|
*
|
||||||
|
* Adjusts activeIndex to preserve turn integrity:
|
||||||
|
* - Removed after active → unchanged
|
||||||
|
* - Removed before active → decrement
|
||||||
|
* - Removed is active, mid-list → same index (next slides in)
|
||||||
|
* - Removed is active, at end → wrap to 0
|
||||||
|
* - Only combatant removed → 0
|
||||||
|
*
|
||||||
|
* roundNumber is never changed.
|
||||||
|
*/
|
||||||
|
export function removeCombatant(
|
||||||
|
encounter: Encounter,
|
||||||
|
id: CombatantId,
|
||||||
|
): RemoveCombatantSuccess | DomainError {
|
||||||
|
const removedIdx = encounter.combatants.findIndex((c) => c.id === id);
|
||||||
|
|
||||||
|
if (removedIdx === -1) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "combatant-not-found",
|
||||||
|
message: `No combatant found with ID "${id}"`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = encounter.combatants[removedIdx];
|
||||||
|
const newCombatants = encounter.combatants.filter((_, i) => i !== removedIdx);
|
||||||
|
|
||||||
|
let newActiveIndex: number;
|
||||||
|
if (newCombatants.length === 0) {
|
||||||
|
newActiveIndex = 0;
|
||||||
|
} else if (removedIdx < encounter.activeIndex) {
|
||||||
|
newActiveIndex = encounter.activeIndex - 1;
|
||||||
|
} else if (removedIdx > encounter.activeIndex) {
|
||||||
|
newActiveIndex = encounter.activeIndex;
|
||||||
|
} else {
|
||||||
|
// removedIdx === activeIndex
|
||||||
|
newActiveIndex =
|
||||||
|
removedIdx >= newCombatants.length ? 0 : encounter.activeIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: newCombatants,
|
||||||
|
activeIndex: newActiveIndex,
|
||||||
|
roundNumber: encounter.roundNumber,
|
||||||
|
},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "CombatantRemoved",
|
||||||
|
combatantId: removed.id,
|
||||||
|
name: removed.name,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
101
packages/domain/src/set-initiative.ts
Normal file
101
packages/domain/src/set-initiative.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { DomainEvent } from "./events.js";
|
||||||
|
import type { CombatantId, DomainError, Encounter } from "./types.js";
|
||||||
|
|
||||||
|
export interface SetInitiativeSuccess {
|
||||||
|
readonly encounter: Encounter;
|
||||||
|
readonly events: DomainEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function that sets, changes, or clears a combatant's initiative value.
|
||||||
|
*
|
||||||
|
* After updating the value, combatants are stable-sorted:
|
||||||
|
* 1. Combatants with initiative — descending by value
|
||||||
|
* 2. Combatants without initiative — preserve relative order
|
||||||
|
*
|
||||||
|
* The active combatant's turn is preserved through the reorder
|
||||||
|
* by tracking identity (CombatantId) rather than position.
|
||||||
|
*
|
||||||
|
* roundNumber is never changed.
|
||||||
|
*/
|
||||||
|
export function setInitiative(
|
||||||
|
encounter: Encounter,
|
||||||
|
combatantId: CombatantId,
|
||||||
|
value: number | undefined,
|
||||||
|
): SetInitiativeSuccess | 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 && !Number.isInteger(value)) {
|
||||||
|
return {
|
||||||
|
kind: "domain-error",
|
||||||
|
code: "invalid-initiative",
|
||||||
|
message: `Initiative must be an integer, got ${value}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = encounter.combatants[targetIdx];
|
||||||
|
const previousValue = target.initiative;
|
||||||
|
|
||||||
|
// Record active combatant's id before reorder
|
||||||
|
const activeCombatantId =
|
||||||
|
encounter.combatants.length > 0
|
||||||
|
? encounter.combatants[encounter.activeIndex].id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Create new combatants array with updated initiative
|
||||||
|
const updated = encounter.combatants.map((c) =>
|
||||||
|
c.id === combatantId ? { ...c, initiative: value } : c,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stable sort: initiative descending, undefined last
|
||||||
|
const indexed = updated.map((c, i) => ({ c, i }));
|
||||||
|
indexed.sort((a, b) => {
|
||||||
|
const aHas = a.c.initiative !== undefined;
|
||||||
|
const bHas = b.c.initiative !== undefined;
|
||||||
|
|
||||||
|
if (aHas && bHas) {
|
||||||
|
// biome-ignore lint: both checked above
|
||||||
|
const diff = b.c.initiative! - a.c.initiative!;
|
||||||
|
return diff !== 0 ? diff : a.i - b.i;
|
||||||
|
}
|
||||||
|
if (aHas && !bHas) return -1;
|
||||||
|
if (!aHas && bHas) return 1;
|
||||||
|
// Both undefined — preserve relative order
|
||||||
|
return a.i - b.i;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sorted = indexed.map(({ c }) => c);
|
||||||
|
|
||||||
|
// Find active combatant's new index
|
||||||
|
let newActiveIndex = encounter.activeIndex;
|
||||||
|
if (activeCombatantId !== undefined) {
|
||||||
|
const idx = sorted.findIndex((c) => c.id === activeCombatantId);
|
||||||
|
if (idx !== -1) {
|
||||||
|
newActiveIndex = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounter: {
|
||||||
|
combatants: sorted,
|
||||||
|
activeIndex: newActiveIndex,
|
||||||
|
roundNumber: encounter.roundNumber,
|
||||||
|
},
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "InitiativeSet",
|
||||||
|
combatantId,
|
||||||
|
previousValue,
|
||||||
|
newValue: value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ export function combatantId(id: string): CombatantId {
|
|||||||
export interface Combatant {
|
export interface Combatant {
|
||||||
readonly id: CombatantId;
|
readonly id: CombatantId;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
|
readonly initiative?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Encounter {
|
export interface Encounter {
|
||||||
|
|||||||
606
pnpm-lock.yaml
generated
606
pnpm-lock.yaml
generated
@@ -11,12 +11,18 @@ importers:
|
|||||||
'@biomejs/biome':
|
'@biomejs/biome':
|
||||||
specifier: 2.0.0
|
specifier: 2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
|
knip:
|
||||||
|
specifier: ^5.85.0
|
||||||
|
version: 5.85.0(@types/node@25.3.3)(typescript@5.9.3)
|
||||||
|
lefthook:
|
||||||
|
specifier: ^1.11.0
|
||||||
|
version: 1.13.6
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.2.4
|
version: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)
|
||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -41,10 +47,10 @@ importers:
|
|||||||
version: 19.2.3(@types/react@19.2.14)
|
version: 19.2.3(@types/react@19.2.14)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.3.0
|
specifier: ^4.3.0
|
||||||
version: 4.7.0(vite@6.4.1)
|
version: 4.7.0(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1))
|
||||||
vite:
|
vite:
|
||||||
specifier: ^6.2.0
|
specifier: ^6.2.0
|
||||||
version: 6.4.1
|
version: 6.4.1(@types/node@25.3.3)(jiti@2.6.1)
|
||||||
|
|
||||||
packages/application:
|
packages/application:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -192,6 +198,15 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@emnapi/core@1.8.1':
|
||||||
|
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.8.1':
|
||||||
|
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
|
||||||
|
|
||||||
|
'@emnapi/wasi-threads@1.1.0':
|
||||||
|
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.12':
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -364,6 +379,121 @@ packages:
|
|||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@napi-rs/wasm-runtime@1.1.1':
|
||||||
|
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
|
||||||
|
|
||||||
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
|
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
'@nodelib/fs.stat@2.0.5':
|
||||||
|
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
'@nodelib/fs.walk@1.2.8':
|
||||||
|
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
|
||||||
|
resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-android-arm64@11.19.1':
|
||||||
|
resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-darwin-arm64@11.19.1':
|
||||||
|
resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-darwin-x64@11.19.1':
|
||||||
|
resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-freebsd-x64@11.19.1':
|
||||||
|
resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1':
|
||||||
|
resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-arm-musleabihf@11.19.1':
|
||||||
|
resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-arm64-gnu@11.19.1':
|
||||||
|
resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-arm64-musl@11.19.1':
|
||||||
|
resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-ppc64-gnu@11.19.1':
|
||||||
|
resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-riscv64-gnu@11.19.1':
|
||||||
|
resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-riscv64-musl@11.19.1':
|
||||||
|
resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-s390x-gnu@11.19.1':
|
||||||
|
resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-x64-gnu@11.19.1':
|
||||||
|
resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-x64-musl@11.19.1':
|
||||||
|
resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
|
||||||
|
resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-wasm32-wasi@11.19.1':
|
||||||
|
resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
cpu: [wasm32]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-win32-arm64-msvc@11.19.1':
|
||||||
|
resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-win32-ia32-msvc@11.19.1':
|
||||||
|
resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
|
||||||
|
resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27':
|
'@rolldown/pluginutils@1.0.0-beta.27':
|
||||||
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
|
||||||
|
|
||||||
@@ -492,6 +622,9 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@tybys/wasm-util@0.10.1':
|
||||||
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||||
|
|
||||||
@@ -513,6 +646,9 @@ packages:
|
|||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
|
'@types/node@25.3.3':
|
||||||
|
resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==}
|
||||||
|
|
||||||
'@types/react-dom@19.2.3':
|
'@types/react-dom@19.2.3':
|
||||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -556,6 +692,9 @@ packages:
|
|||||||
'@vitest/utils@3.2.4':
|
'@vitest/utils@3.2.4':
|
||||||
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
|
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
|
||||||
|
|
||||||
|
argparse@2.0.1:
|
||||||
|
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||||
|
|
||||||
assertion-error@2.0.1:
|
assertion-error@2.0.1:
|
||||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -565,6 +704,10 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
braces@3.0.3:
|
||||||
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
browserslist@4.28.1:
|
browserslist@4.28.1:
|
||||||
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
|
resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==}
|
||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
@@ -626,6 +769,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
|
|
||||||
|
fast-glob@3.3.3:
|
||||||
|
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
|
||||||
|
engines: {node: '>=8.6.0'}
|
||||||
|
|
||||||
|
fastq@1.20.1:
|
||||||
|
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||||
|
|
||||||
|
fd-package-json@2.0.0:
|
||||||
|
resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==}
|
||||||
|
|
||||||
fdir@6.5.0:
|
fdir@6.5.0:
|
||||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||||
engines: {node: '>=12.0.0'}
|
engines: {node: '>=12.0.0'}
|
||||||
@@ -635,6 +788,15 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
fill-range@7.1.1:
|
||||||
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
formatly@0.3.0:
|
||||||
|
resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==}
|
||||||
|
engines: {node: '>=18.3.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -644,12 +806,36 @@ packages:
|
|||||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
|
glob-parent@5.1.2:
|
||||||
|
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
is-extglob@2.1.1:
|
||||||
|
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
is-glob@4.0.3:
|
||||||
|
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
is-number@7.0.0:
|
||||||
|
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||||
|
engines: {node: '>=0.12.0'}
|
||||||
|
|
||||||
|
jiti@2.6.1:
|
||||||
|
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
js-tokens@4.0.0:
|
js-tokens@4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
|
|
||||||
js-tokens@9.0.1:
|
js-tokens@9.0.1:
|
||||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||||
|
|
||||||
|
js-yaml@4.1.1:
|
||||||
|
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
jsesc@3.1.0:
|
jsesc@3.1.0:
|
||||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -660,6 +846,68 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
knip@5.85.0:
|
||||||
|
resolution: {integrity: sha512-V2kyON+DZiYdNNdY6GALseiNCwX7dYdpz9Pv85AUn69Gk0UKCts+glOKWfe5KmaMByRjM9q17Mzj/KinTVOyxg==}
|
||||||
|
engines: {node: '>=18.18.0'}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
'@types/node': '>=18'
|
||||||
|
typescript: '>=5.0.4 <7'
|
||||||
|
|
||||||
|
lefthook-darwin-arm64@1.13.6:
|
||||||
|
resolution: {integrity: sha512-m6Lb77VGc84/Qo21Lhq576pEvcgFCnvloEiP02HbAHcIXD0RTLy9u2yAInrixqZeaz13HYtdDaI7OBYAAdVt8A==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lefthook-darwin-x64@1.13.6:
|
||||||
|
resolution: {integrity: sha512-CoRpdzanu9RK3oXR1vbEJA5LN7iB+c7hP+sONeQJzoOXuq4PNKVtEaN84Gl1BrVtCNLHWFAvCQaZPPiiXSy8qg==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
lefthook-freebsd-arm64@1.13.6:
|
||||||
|
resolution: {integrity: sha512-X4A7yfvAJ68CoHTqP+XvQzdKbyd935sYy0bQT6Ajz7FL1g7hFiro8dqHSdPdkwei9hs8hXeV7feyTXbYmfjKQQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
lefthook-freebsd-x64@1.13.6:
|
||||||
|
resolution: {integrity: sha512-ai2m+Sj2kGdY46USfBrCqLKe9GYhzeq01nuyDYCrdGISePeZ6udOlD1k3lQKJGQCHb0bRz4St0r5nKDSh1x/2A==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
lefthook-linux-arm64@1.13.6:
|
||||||
|
resolution: {integrity: sha512-cbo4Wtdq81GTABvikLORJsAWPKAJXE8Q5RXsICFUVznh5PHigS9dFW/4NXywo0+jfFPCT6SYds2zz4tCx6DA0Q==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lefthook-linux-x64@1.13.6:
|
||||||
|
resolution: {integrity: sha512-uJl9vjCIIBTBvMZkemxCE+3zrZHlRO7Oc+nZJ+o9Oea3fu+W82jwX7a7clw8jqNfaeBS+8+ZEQgiMHWCloTsGw==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
lefthook-openbsd-arm64@1.13.6:
|
||||||
|
resolution: {integrity: sha512-7r153dxrNRQ9ytRs2PmGKKkYdvZYFPre7My7XToSTiRu5jNCq++++eAKVkoyWPduk97dGIA+YWiEr5Noe0TK2A==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
lefthook-openbsd-x64@1.13.6:
|
||||||
|
resolution: {integrity: sha512-Z+UhLlcg1xrXOidK3aLLpgH7KrwNyWYE3yb7ITYnzJSEV8qXnePtVu8lvMBHs/myzemjBzeIr/U/+ipjclR06g==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [openbsd]
|
||||||
|
|
||||||
|
lefthook-windows-arm64@1.13.6:
|
||||||
|
resolution: {integrity: sha512-Uxef6qoDxCmUNQwk8eBvddYJKSBFglfwAY9Y9+NnnmiHpWTjjYiObE9gT2mvGVpEgZRJVAatBXc+Ha5oDD/OgQ==}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
lefthook-windows-x64@1.13.6:
|
||||||
|
resolution: {integrity: sha512-mOZoM3FQh3o08M8PQ/b3IYuL5oo36D9ehczIw1dAgp1Ly+Tr4fJ96A+4SEJrQuYeRD4mex9bR7Ps56I73sBSZA==}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
lefthook@1.13.6:
|
||||||
|
resolution: {integrity: sha512-ojj4/4IJ29Xn4drd5emqVgilegAPN3Kf0FQM2p/9+lwSTpU+SZ1v4Ig++NF+9MOa99UKY8bElmVrLhnUUNFh5g==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
loupe@3.2.1:
|
loupe@3.2.1:
|
||||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||||
|
|
||||||
@@ -669,6 +917,17 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
merge2@1.4.1:
|
||||||
|
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||||
|
engines: {node: '>= 8'}
|
||||||
|
|
||||||
|
micromatch@4.0.8:
|
||||||
|
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||||
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
|
minimist@1.2.8:
|
||||||
|
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -680,6 +939,9 @@ packages:
|
|||||||
node-releases@2.0.27:
|
node-releases@2.0.27:
|
||||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||||
|
|
||||||
|
oxc-resolver@11.19.1:
|
||||||
|
resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==}
|
||||||
|
|
||||||
pathe@2.0.3:
|
pathe@2.0.3:
|
||||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
@@ -690,6 +952,10 @@ packages:
|
|||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
picomatch@2.3.1:
|
||||||
|
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
|
||||||
|
engines: {node: '>=8.6'}
|
||||||
|
|
||||||
picomatch@4.0.3:
|
picomatch@4.0.3:
|
||||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -698,6 +964,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
|
|
||||||
|
queue-microtask@1.2.3:
|
||||||
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
react-dom@19.2.4:
|
react-dom@19.2.4:
|
||||||
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -711,11 +980,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
reusify@1.1.0:
|
||||||
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
|
|
||||||
rollup@4.59.0:
|
rollup@4.59.0:
|
||||||
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
|
resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==}
|
||||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
run-parallel@1.2.0:
|
||||||
|
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||||
|
|
||||||
scheduler@0.27.0:
|
scheduler@0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
@@ -726,6 +1002,10 @@ packages:
|
|||||||
siginfo@2.0.0:
|
siginfo@2.0.0:
|
||||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
|
smol-toml@1.6.0:
|
||||||
|
resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -736,6 +1016,10 @@ packages:
|
|||||||
std-env@3.10.0:
|
std-env@3.10.0:
|
||||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
|
strip-json-comments@5.0.3:
|
||||||
|
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
|
||||||
|
engines: {node: '>=14.16'}
|
||||||
|
|
||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
|
||||||
|
|
||||||
@@ -761,11 +1045,21 @@ packages:
|
|||||||
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
|
to-regex-range@5.0.1:
|
||||||
|
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||||
|
engines: {node: '>=8.0'}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
typescript@5.9.3:
|
typescript@5.9.3:
|
||||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||||
engines: {node: '>=14.17'}
|
engines: {node: '>=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
undici-types@7.18.2:
|
||||||
|
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3:
|
update-browserslist-db@1.2.3:
|
||||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -845,6 +1139,10 @@ packages:
|
|||||||
jsdom:
|
jsdom:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
walk-up-path@4.0.0:
|
||||||
|
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
|
||||||
|
engines: {node: 20 || >=22}
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -853,6 +1151,9 @@ packages:
|
|||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
|
zod@4.3.6:
|
||||||
|
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@babel/code-frame@7.29.0':
|
'@babel/code-frame@7.29.0':
|
||||||
@@ -1002,6 +1303,22 @@ snapshots:
|
|||||||
'@biomejs/cli-win32-x64@2.0.0':
|
'@biomejs/cli-win32-x64@2.0.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/core@1.8.1':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/wasi-threads': 1.1.0
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.8.1':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/wasi-threads@1.1.0':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@esbuild/aix-ppc64@0.25.12':
|
'@esbuild/aix-ppc64@0.25.12':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -1099,6 +1416,87 @@ snapshots:
|
|||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
'@napi-rs/wasm-runtime@1.1.1':
|
||||||
|
dependencies:
|
||||||
|
'@emnapi/core': 1.8.1
|
||||||
|
'@emnapi/runtime': 1.8.1
|
||||||
|
'@tybys/wasm-util': 0.10.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@nodelib/fs.scandir@2.1.5':
|
||||||
|
dependencies:
|
||||||
|
'@nodelib/fs.stat': 2.0.5
|
||||||
|
run-parallel: 1.2.0
|
||||||
|
|
||||||
|
'@nodelib/fs.stat@2.0.5': {}
|
||||||
|
|
||||||
|
'@nodelib/fs.walk@1.2.8':
|
||||||
|
dependencies:
|
||||||
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
|
fastq: 1.20.1
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-android-arm64@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-darwin-arm64@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-darwin-x64@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-freebsd-x64@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-arm-musleabihf@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-arm64-gnu@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-arm64-musl@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-ppc64-gnu@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-riscv64-gnu@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-riscv64-musl@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-s390x-gnu@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-x64-gnu@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-linux-x64-musl@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-wasm32-wasi@11.19.1':
|
||||||
|
dependencies:
|
||||||
|
'@napi-rs/wasm-runtime': 1.1.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-win32-arm64-msvc@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-win32-ia32-msvc@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
'@rolldown/pluginutils@1.0.0-beta.27': {}
|
||||||
|
|
||||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||||
@@ -1176,6 +1574,11 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
'@rollup/rollup-win32-x64-msvc@4.59.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@tybys/wasm-util@0.10.1':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@types/babel__core@7.20.5':
|
'@types/babel__core@7.20.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.29.0
|
'@babel/parser': 7.29.0
|
||||||
@@ -1206,6 +1609,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
|
'@types/node@25.3.3':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 7.18.2
|
||||||
|
|
||||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
@@ -1214,7 +1621,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
'@vitejs/plugin-react@4.7.0(vite@6.4.1)':
|
'@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
|
||||||
@@ -1222,7 +1629,7 @@ snapshots:
|
|||||||
'@rolldown/pluginutils': 1.0.0-beta.27
|
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
react-refresh: 0.17.0
|
react-refresh: 0.17.0
|
||||||
vite: 6.4.1
|
vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -1234,13 +1641,13 @@ snapshots:
|
|||||||
chai: 5.3.3
|
chai: 5.3.3
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
'@vitest/mocker@3.2.4(vite@6.4.1)':
|
'@vitest/mocker@3.2.4(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 3.2.4
|
'@vitest/spy': 3.2.4
|
||||||
estree-walker: 3.0.3
|
estree-walker: 3.0.3
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 6.4.1
|
vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1)
|
||||||
|
|
||||||
'@vitest/pretty-format@3.2.4':
|
'@vitest/pretty-format@3.2.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1268,10 +1675,16 @@ snapshots:
|
|||||||
loupe: 3.2.1
|
loupe: 3.2.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
|
|
||||||
|
argparse@2.0.1: {}
|
||||||
|
|
||||||
assertion-error@2.0.1: {}
|
assertion-error@2.0.1: {}
|
||||||
|
|
||||||
baseline-browser-mapping@2.10.0: {}
|
baseline-browser-mapping@2.10.0: {}
|
||||||
|
|
||||||
|
braces@3.0.3:
|
||||||
|
dependencies:
|
||||||
|
fill-range: 7.1.1
|
||||||
|
|
||||||
browserslist@4.28.1:
|
browserslist@4.28.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
baseline-browser-mapping: 2.10.0
|
baseline-browser-mapping: 2.10.0
|
||||||
@@ -1345,23 +1758,125 @@ snapshots:
|
|||||||
|
|
||||||
expect-type@1.3.0: {}
|
expect-type@1.3.0: {}
|
||||||
|
|
||||||
|
fast-glob@3.3.3:
|
||||||
|
dependencies:
|
||||||
|
'@nodelib/fs.stat': 2.0.5
|
||||||
|
'@nodelib/fs.walk': 1.2.8
|
||||||
|
glob-parent: 5.1.2
|
||||||
|
merge2: 1.4.1
|
||||||
|
micromatch: 4.0.8
|
||||||
|
|
||||||
|
fastq@1.20.1:
|
||||||
|
dependencies:
|
||||||
|
reusify: 1.1.0
|
||||||
|
|
||||||
|
fd-package-json@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
walk-up-path: 4.0.0
|
||||||
|
|
||||||
fdir@6.5.0(picomatch@4.0.3):
|
fdir@6.5.0(picomatch@4.0.3):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
fill-range@7.1.1:
|
||||||
|
dependencies:
|
||||||
|
to-regex-range: 5.0.1
|
||||||
|
|
||||||
|
formatly@0.3.0:
|
||||||
|
dependencies:
|
||||||
|
fd-package-json: 2.0.0
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
|
|
||||||
|
glob-parent@5.1.2:
|
||||||
|
dependencies:
|
||||||
|
is-glob: 4.0.3
|
||||||
|
|
||||||
|
is-extglob@2.1.1: {}
|
||||||
|
|
||||||
|
is-glob@4.0.3:
|
||||||
|
dependencies:
|
||||||
|
is-extglob: 2.1.1
|
||||||
|
|
||||||
|
is-number@7.0.0: {}
|
||||||
|
|
||||||
|
jiti@2.6.1: {}
|
||||||
|
|
||||||
js-tokens@4.0.0: {}
|
js-tokens@4.0.0: {}
|
||||||
|
|
||||||
js-tokens@9.0.1: {}
|
js-tokens@9.0.1: {}
|
||||||
|
|
||||||
|
js-yaml@4.1.1:
|
||||||
|
dependencies:
|
||||||
|
argparse: 2.0.1
|
||||||
|
|
||||||
jsesc@3.1.0: {}
|
jsesc@3.1.0: {}
|
||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
knip@5.85.0(@types/node@25.3.3)(typescript@5.9.3):
|
||||||
|
dependencies:
|
||||||
|
'@nodelib/fs.walk': 1.2.8
|
||||||
|
'@types/node': 25.3.3
|
||||||
|
fast-glob: 3.3.3
|
||||||
|
formatly: 0.3.0
|
||||||
|
jiti: 2.6.1
|
||||||
|
js-yaml: 4.1.1
|
||||||
|
minimist: 1.2.8
|
||||||
|
oxc-resolver: 11.19.1
|
||||||
|
picocolors: 1.1.1
|
||||||
|
picomatch: 4.0.3
|
||||||
|
smol-toml: 1.6.0
|
||||||
|
strip-json-comments: 5.0.3
|
||||||
|
typescript: 5.9.3
|
||||||
|
zod: 4.3.6
|
||||||
|
|
||||||
|
lefthook-darwin-arm64@1.13.6:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lefthook-darwin-x64@1.13.6:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lefthook-freebsd-arm64@1.13.6:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lefthook-freebsd-x64@1.13.6:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lefthook-linux-arm64@1.13.6:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lefthook-linux-x64@1.13.6:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lefthook-openbsd-arm64@1.13.6:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lefthook-openbsd-x64@1.13.6:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lefthook-windows-arm64@1.13.6:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lefthook-windows-x64@1.13.6:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
lefthook@1.13.6:
|
||||||
|
optionalDependencies:
|
||||||
|
lefthook-darwin-arm64: 1.13.6
|
||||||
|
lefthook-darwin-x64: 1.13.6
|
||||||
|
lefthook-freebsd-arm64: 1.13.6
|
||||||
|
lefthook-freebsd-x64: 1.13.6
|
||||||
|
lefthook-linux-arm64: 1.13.6
|
||||||
|
lefthook-linux-x64: 1.13.6
|
||||||
|
lefthook-openbsd-arm64: 1.13.6
|
||||||
|
lefthook-openbsd-x64: 1.13.6
|
||||||
|
lefthook-windows-arm64: 1.13.6
|
||||||
|
lefthook-windows-x64: 1.13.6
|
||||||
|
|
||||||
loupe@3.2.1: {}
|
loupe@3.2.1: {}
|
||||||
|
|
||||||
lru-cache@5.1.1:
|
lru-cache@5.1.1:
|
||||||
@@ -1372,18 +1887,52 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
merge2@1.4.1: {}
|
||||||
|
|
||||||
|
micromatch@4.0.8:
|
||||||
|
dependencies:
|
||||||
|
braces: 3.0.3
|
||||||
|
picomatch: 2.3.1
|
||||||
|
|
||||||
|
minimist@1.2.8: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
nanoid@3.3.11: {}
|
nanoid@3.3.11: {}
|
||||||
|
|
||||||
node-releases@2.0.27: {}
|
node-releases@2.0.27: {}
|
||||||
|
|
||||||
|
oxc-resolver@11.19.1:
|
||||||
|
optionalDependencies:
|
||||||
|
'@oxc-resolver/binding-android-arm-eabi': 11.19.1
|
||||||
|
'@oxc-resolver/binding-android-arm64': 11.19.1
|
||||||
|
'@oxc-resolver/binding-darwin-arm64': 11.19.1
|
||||||
|
'@oxc-resolver/binding-darwin-x64': 11.19.1
|
||||||
|
'@oxc-resolver/binding-freebsd-x64': 11.19.1
|
||||||
|
'@oxc-resolver/binding-linux-arm-gnueabihf': 11.19.1
|
||||||
|
'@oxc-resolver/binding-linux-arm-musleabihf': 11.19.1
|
||||||
|
'@oxc-resolver/binding-linux-arm64-gnu': 11.19.1
|
||||||
|
'@oxc-resolver/binding-linux-arm64-musl': 11.19.1
|
||||||
|
'@oxc-resolver/binding-linux-ppc64-gnu': 11.19.1
|
||||||
|
'@oxc-resolver/binding-linux-riscv64-gnu': 11.19.1
|
||||||
|
'@oxc-resolver/binding-linux-riscv64-musl': 11.19.1
|
||||||
|
'@oxc-resolver/binding-linux-s390x-gnu': 11.19.1
|
||||||
|
'@oxc-resolver/binding-linux-x64-gnu': 11.19.1
|
||||||
|
'@oxc-resolver/binding-linux-x64-musl': 11.19.1
|
||||||
|
'@oxc-resolver/binding-openharmony-arm64': 11.19.1
|
||||||
|
'@oxc-resolver/binding-wasm32-wasi': 11.19.1
|
||||||
|
'@oxc-resolver/binding-win32-arm64-msvc': 11.19.1
|
||||||
|
'@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
|
||||||
|
'@oxc-resolver/binding-win32-x64-msvc': 11.19.1
|
||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
pathval@2.0.1: {}
|
pathval@2.0.1: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
picomatch@2.3.1: {}
|
||||||
|
|
||||||
picomatch@4.0.3: {}
|
picomatch@4.0.3: {}
|
||||||
|
|
||||||
postcss@8.5.8:
|
postcss@8.5.8:
|
||||||
@@ -1392,6 +1941,8 @@ snapshots:
|
|||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
source-map-js: 1.2.1
|
source-map-js: 1.2.1
|
||||||
|
|
||||||
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
react-dom@19.2.4(react@19.2.4):
|
react-dom@19.2.4(react@19.2.4):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
@@ -1401,6 +1952,8 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.4: {}
|
react@19.2.4: {}
|
||||||
|
|
||||||
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
rollup@4.59.0:
|
rollup@4.59.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
@@ -1432,18 +1985,26 @@ snapshots:
|
|||||||
'@rollup/rollup-win32-x64-msvc': 4.59.0
|
'@rollup/rollup-win32-x64-msvc': 4.59.0
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
|
run-parallel@1.2.0:
|
||||||
|
dependencies:
|
||||||
|
queue-microtask: 1.2.3
|
||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
siginfo@2.0.0: {}
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
|
smol-toml@1.6.0: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
std-env@3.10.0: {}
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
|
strip-json-comments@5.0.3: {}
|
||||||
|
|
||||||
strip-literal@3.1.0:
|
strip-literal@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 9.0.1
|
js-tokens: 9.0.1
|
||||||
@@ -1463,21 +2024,30 @@ snapshots:
|
|||||||
|
|
||||||
tinyspy@4.0.4: {}
|
tinyspy@4.0.4: {}
|
||||||
|
|
||||||
|
to-regex-range@5.0.1:
|
||||||
|
dependencies:
|
||||||
|
is-number: 7.0.0
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
optional: true
|
||||||
|
|
||||||
typescript@5.9.3: {}
|
typescript@5.9.3: {}
|
||||||
|
|
||||||
|
undici-types@7.18.2: {}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
update-browserslist-db@1.2.3(browserslist@4.28.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.1
|
browserslist: 4.28.1
|
||||||
escalade: 3.2.0
|
escalade: 3.2.0
|
||||||
picocolors: 1.1.1
|
picocolors: 1.1.1
|
||||||
|
|
||||||
vite-node@3.2.4:
|
vite-node@3.2.4(@types/node@25.3.3)(jiti@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
es-module-lexer: 1.7.0
|
es-module-lexer: 1.7.0
|
||||||
pathe: 2.0.3
|
pathe: 2.0.3
|
||||||
vite: 6.4.1
|
vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/node'
|
- '@types/node'
|
||||||
- jiti
|
- jiti
|
||||||
@@ -1492,7 +2062,7 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
vite@6.4.1:
|
vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.25.12
|
esbuild: 0.25.12
|
||||||
fdir: 6.5.0(picomatch@4.0.3)
|
fdir: 6.5.0(picomatch@4.0.3)
|
||||||
@@ -1501,13 +2071,15 @@ snapshots:
|
|||||||
rollup: 4.59.0
|
rollup: 4.59.0
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.3
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
jiti: 2.6.1
|
||||||
|
|
||||||
vitest@3.2.4:
|
vitest@3.2.4(@types/node@25.3.3)(jiti@2.6.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/expect': 3.2.4
|
'@vitest/expect': 3.2.4
|
||||||
'@vitest/mocker': 3.2.4(vite@6.4.1)
|
'@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@25.3.3)(jiti@2.6.1))
|
||||||
'@vitest/pretty-format': 3.2.4
|
'@vitest/pretty-format': 3.2.4
|
||||||
'@vitest/runner': 3.2.4
|
'@vitest/runner': 3.2.4
|
||||||
'@vitest/snapshot': 3.2.4
|
'@vitest/snapshot': 3.2.4
|
||||||
@@ -1525,9 +2097,11 @@ snapshots:
|
|||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
tinypool: 1.1.1
|
tinypool: 1.1.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vite: 6.4.1
|
vite: 6.4.1(@types/node@25.3.3)(jiti@2.6.1)
|
||||||
vite-node: 3.2.4
|
vite-node: 3.2.4(@types/node@25.3.3)(jiti@2.6.1)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 25.3.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- jiti
|
- jiti
|
||||||
- less
|
- less
|
||||||
@@ -1542,9 +2116,13 @@ snapshots:
|
|||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
|
||||||
|
walk-up-path@4.0.0: {}
|
||||||
|
|
||||||
why-is-node-running@2.3.0:
|
why-is-node-running@2.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
siginfo: 2.0.0
|
siginfo: 2.0.0
|
||||||
stackback: 0.0.2
|
stackback: 0.0.2
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
|
zod@4.3.6: {}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
packages:
|
packages:
|
||||||
- "packages/*"
|
- "packages/*"
|
||||||
- "apps/*"
|
- "apps/*"
|
||||||
|
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- lefthook
|
||||||
|
|||||||
@@ -82,11 +82,20 @@ domain events.
|
|||||||
|
|
||||||
### Edge Cases
|
### Edge Cases
|
||||||
|
|
||||||
- Empty combatant list: AdvanceTurn MUST reject with an error.
|
- Empty combatant list: valid aggregate state, but AdvanceTurn MUST
|
||||||
|
return a DomainError (no state change, no events).
|
||||||
- Single combatant: every advance wraps and increments the round.
|
- Single combatant: every advance wraps and increments the round.
|
||||||
- Large round numbers: no overflow or special-case behavior; round
|
- Large round numbers: no overflow or special-case behavior; round
|
||||||
increments uniformly.
|
increments uniformly.
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-03-03
|
||||||
|
|
||||||
|
- Q: Should an encounter with zero combatants be a valid aggregate state? → A: Yes. Empty encounter is valid; AdvanceTurn returns DomainError.
|
||||||
|
- Q: What is activeIndex when combatants list is empty? → A: activeIndex MUST be 0.
|
||||||
|
- Q: Does this change any non-empty encounter behavior? → A: No. All existing acceptance scenarios and event contracts remain unchanged.
|
||||||
|
|
||||||
## Domain Model *(mandatory)*
|
## Domain Model *(mandatory)*
|
||||||
|
|
||||||
### Key Entities
|
### Key Entities
|
||||||
@@ -113,10 +122,13 @@ MUST be verified by tests.
|
|||||||
|
|
||||||
### Invariants
|
### Invariants
|
||||||
|
|
||||||
- **INV-1**: An encounter MUST have at least one combatant.
|
- **INV-1**: An encounter MAY have zero combatants (an empty
|
||||||
Operations on an empty encounter MUST fail.
|
encounter is a valid aggregate state). AdvanceTurn on an empty
|
||||||
- **INV-2**: activeIndex MUST always satisfy
|
encounter MUST return a DomainError with no state change and no
|
||||||
0 <= activeIndex < len(combatants).
|
events.
|
||||||
|
- **INV-2**: If combatants.length > 0, activeIndex MUST satisfy
|
||||||
|
0 <= activeIndex < combatants.length. If combatants.length == 0,
|
||||||
|
activeIndex MUST be 0.
|
||||||
- **INV-3**: roundNumber MUST be a positive integer (>= 1) and MUST
|
- **INV-3**: roundNumber MUST be a positive integer (>= 1) and MUST
|
||||||
only increase (never decrease or reset).
|
only increase (never decrease or reset).
|
||||||
- **INV-4**: AdvanceTurn MUST be a pure function of the current
|
- **INV-4**: AdvanceTurn MUST be a pure function of the current
|
||||||
|
|||||||
@@ -48,11 +48,11 @@
|
|||||||
|
|
||||||
**Goal**: Wire up the application use case and minimal React UI with a "Next Turn" button.
|
**Goal**: Wire up the application use case and minimal React UI with a "Next Turn" button.
|
||||||
|
|
||||||
- [ ] T012 Define port interface in `packages/application/src/ports.ts` — `EncounterStore` port: `get(): Encounter`, `save(e: Encounter)`
|
- [X] T012 Define port interface in `packages/application/src/ports.ts` — `EncounterStore` port: `get(): Encounter`, `save(e: Encounter)`
|
||||||
- [ ] T013 Implement `AdvanceTurnUseCase` in `packages/application/src/advance-turn-use-case.ts` — accepts `EncounterStore`, calls `advanceTurn`, saves result, returns events
|
- [X] T013 Implement `AdvanceTurnUseCase` in `packages/application/src/advance-turn-use-case.ts` — accepts `EncounterStore`, calls `advanceTurn`, saves result, returns events
|
||||||
- [ ] T014 Export public API from `packages/application/src/index.ts` — re-export use case and port types
|
- [X] T014 Export public API from `packages/application/src/index.ts` — re-export use case and port types
|
||||||
- [ ] T015 Implement `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — in-memory `EncounterStore` via React state, exposes encounter state + `advanceTurn` action, hardcoded 3-combatant demo
|
- [X] T015 Implement `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — in-memory `EncounterStore` via React state, exposes encounter state + `advanceTurn` action, hardcoded 3-combatant demo
|
||||||
- [ ] T016 Wire up `apps/web/src/App.tsx` — display current combatant, round number, combatant list with active indicator, "Next Turn" button, emitted events
|
- [X] T016 Wire up `apps/web/src/App.tsx` — display current combatant, round number, combatant list with active indicator, "Next Turn" button, emitted events
|
||||||
|
|
||||||
**Checkpoint (Milestone 2)**: `pnpm check` passes. `vite build` succeeds. Clicking "Next Turn" cycles combatants and increments rounds correctly.
|
**Checkpoint (Milestone 2)**: `pnpm check` passes. `vite build` succeeds. Clicking "Next Turn" cycles combatants and increments rounds correctly.
|
||||||
|
|
||||||
|
|||||||
35
specs/002-add-combatant/checklists/requirements.md
Normal file
35
specs/002-add-combatant/checklists/requirements.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Add Combatant
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-03
|
||||||
|
**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`.
|
||||||
|
- Assumption documented: CombatantId is passed in rather than generated internally, keeping domain pure.
|
||||||
77
specs/002-add-combatant/data-model.md
Normal file
77
specs/002-add-combatant/data-model.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Data Model: Add Combatant
|
||||||
|
|
||||||
|
**Feature**: 002-add-combatant
|
||||||
|
**Date**: 2026-03-03
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### Combatant (existing, unchanged)
|
||||||
|
|
||||||
|
| Field | Type | Constraints |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | CombatantId (branded string) | Unique, required |
|
||||||
|
| name | string | Non-empty after trimming, required |
|
||||||
|
|
||||||
|
### Encounter (existing, unchanged)
|
||||||
|
|
||||||
|
| Field | Type | Constraints |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| combatants | readonly Combatant[] | Ordered list, may be empty |
|
||||||
|
| activeIndex | number | 0 <= activeIndex < combatants.length (or 0 if empty) |
|
||||||
|
| roundNumber | number | Positive integer >= 1, only increases |
|
||||||
|
|
||||||
|
## Domain Events
|
||||||
|
|
||||||
|
### CombatantAdded (new)
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| type | "CombatantAdded" (literal) | Discriminant for the DomainEvent union |
|
||||||
|
| combatantId | CombatantId | Id of the newly added combatant |
|
||||||
|
| name | string | Name of the newly added combatant |
|
||||||
|
| position | number | Zero-based index where the combatant was placed |
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
### AddCombatant
|
||||||
|
|
||||||
|
**Input**: Encounter + CombatantId + name (string)
|
||||||
|
|
||||||
|
**Preconditions**:
|
||||||
|
- Name must be non-empty after trimming
|
||||||
|
|
||||||
|
**Transition**:
|
||||||
|
- New combatant `{ id, name: trimmedName }` appended to end of combatants list
|
||||||
|
- activeIndex unchanged
|
||||||
|
- roundNumber unchanged
|
||||||
|
|
||||||
|
**Postconditions**:
|
||||||
|
- combatants.length increased by 1
|
||||||
|
- New combatant is at index `combatants.length - 1`
|
||||||
|
- All existing combatants preserve their order and index positions
|
||||||
|
- INV-2 satisfied (activeIndex still valid for the now-larger list)
|
||||||
|
|
||||||
|
**Events emitted**: Exactly one `CombatantAdded`
|
||||||
|
|
||||||
|
**Error cases**:
|
||||||
|
- Empty or whitespace-only name → DomainError `{ code: "invalid-name" }`
|
||||||
|
|
||||||
|
## Function Signatures
|
||||||
|
|
||||||
|
### Domain Layer
|
||||||
|
|
||||||
|
```
|
||||||
|
addCombatant(encounter, id, name) → { encounter, events } | DomainError
|
||||||
|
```
|
||||||
|
|
||||||
|
### Application Layer
|
||||||
|
|
||||||
|
```
|
||||||
|
addCombatantUseCase(store, id, name) → DomainEvent[] | DomainError
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
| Rule | Layer | Error Code |
|
||||||
|
|------|-------|------------|
|
||||||
|
| Name non-empty after trim | Domain | invalid-name |
|
||||||
76
specs/002-add-combatant/plan.md
Normal file
76
specs/002-add-combatant/plan.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Implementation Plan: Add Combatant
|
||||||
|
|
||||||
|
**Branch**: `002-add-combatant` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/002-add-combatant/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a pure domain function `addCombatant` that appends a new combatant to the end of an encounter's combatant list without altering the active turn or round. The feature follows the same pattern as `advanceTurn`: a pure function returning updated state plus domain events, with an application-layer use case and a React adapter hook.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax)
|
||||||
|
**Primary Dependencies**: None for domain; React 19 for web adapter
|
||||||
|
**Storage**: In-memory (React state via hook)
|
||||||
|
**Testing**: Vitest
|
||||||
|
**Target Platform**: Browser (Vite dev server)
|
||||||
|
**Project Type**: Monorepo (pnpm workspaces): domain library + application library + web app
|
||||||
|
**Performance Goals**: N/A (pure synchronous function)
|
||||||
|
**Constraints**: Domain must remain pure — no I/O, no randomness
|
||||||
|
**Scale/Scope**: Single-user local app
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Deterministic Domain Core | PASS | `addCombatant` is a pure function. CombatantId is passed in as input, not generated internally. |
|
||||||
|
| II. Layered Architecture | PASS | Domain function in `packages/domain`, use case in `packages/application`, hook in `apps/web`. No reverse imports. |
|
||||||
|
| III. Agent Boundary | PASS | No agent layer involvement in this feature. |
|
||||||
|
| IV. Clarification-First | PASS | Spec has no NEEDS CLARIFICATION markers. Key assumption (id passed in) is documented. |
|
||||||
|
| V. Escalation Gates | PASS | Implementation stays within spec scope. |
|
||||||
|
| VI. MVP Baseline Language | PASS | Out-of-scope items use "MVP baseline does not include". |
|
||||||
|
| VII. No Gameplay Rules | PASS | No gameplay mechanics in constitution. |
|
||||||
|
|
||||||
|
All gates pass. No violations to justify.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/002-add-combatant/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
└── tasks.md # Phase 2 output (via /speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/domain/src/
|
||||||
|
├── types.ts # Encounter, Combatant, CombatantId (existing)
|
||||||
|
├── events.ts # DomainEvent union (add CombatantAdded)
|
||||||
|
├── add-combatant.ts # NEW: addCombatant pure function
|
||||||
|
├── advance-turn.ts # Existing (unchanged)
|
||||||
|
├── index.ts # Re-exports (add new exports)
|
||||||
|
└── __tests__/
|
||||||
|
├── advance-turn.test.ts # Existing (unchanged)
|
||||||
|
└── add-combatant.test.ts # NEW: acceptance + invariant tests
|
||||||
|
|
||||||
|
packages/application/src/
|
||||||
|
├── ports.ts # EncounterStore (unchanged)
|
||||||
|
├── add-combatant-use-case.ts # NEW: orchestrates addCombatant
|
||||||
|
├── advance-turn-use-case.ts # Existing (unchanged)
|
||||||
|
└── index.ts # Re-exports (add new exports)
|
||||||
|
|
||||||
|
apps/web/src/
|
||||||
|
├── App.tsx # Update: add combatant input + button
|
||||||
|
└── hooks/
|
||||||
|
└── use-encounter.ts # Update: expose addCombatant callback
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Follows the established monorepo layout. Each domain operation gets its own file (matching `advance-turn.ts` pattern). No new packages or directories needed beyond the existing structure.
|
||||||
47
specs/002-add-combatant/quickstart.md
Normal file
47
specs/002-add-combatant/quickstart.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Quickstart: Add Combatant
|
||||||
|
|
||||||
|
**Feature**: 002-add-combatant
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test:watch # Watch all tests
|
||||||
|
pnpm vitest run packages/domain/src/__tests__/add-combatant.test.ts # Run feature tests
|
||||||
|
pnpm --filter web dev # Dev server at localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## Merge Gate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm check # Must pass before commit (format + lint + typecheck + test)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Domain event** — Add `CombatantAdded` to `events.ts` and the `DomainEvent` union
|
||||||
|
2. **Domain function** — Create `add-combatant.ts` with the pure `addCombatant` function
|
||||||
|
3. **Domain exports** — Update `index.ts` to re-export new items
|
||||||
|
4. **Domain tests** — Create `add-combatant.test.ts` with all 6 acceptance scenarios + invariant checks
|
||||||
|
5. **Application use case** — Create `add-combatant-use-case.ts`
|
||||||
|
6. **Application exports** — Update `index.ts` to re-export
|
||||||
|
7. **Web hook** — Update `use-encounter.ts` to expose `addCombatant` callback
|
||||||
|
8. **Web UI** — Update `App.tsx` with name input and add button
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `packages/domain/src/events.ts` | Edit | Add CombatantAdded event type |
|
||||||
|
| `packages/domain/src/add-combatant.ts` | Create | Pure addCombatant function |
|
||||||
|
| `packages/domain/src/index.ts` | Edit | Export new items |
|
||||||
|
| `packages/domain/src/__tests__/add-combatant.test.ts` | Create | Acceptance + invariant tests |
|
||||||
|
| `packages/application/src/add-combatant-use-case.ts` | Create | Use case orchestration |
|
||||||
|
| `packages/application/src/index.ts` | Edit | Export new use case |
|
||||||
|
| `apps/web/src/hooks/use-encounter.ts` | Edit | Add combatant hook callback |
|
||||||
|
| `apps/web/src/App.tsx` | Edit | Name input + add button UI |
|
||||||
40
specs/002-add-combatant/research.md
Normal file
40
specs/002-add-combatant/research.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Research: Add Combatant
|
||||||
|
|
||||||
|
**Feature**: 002-add-combatant
|
||||||
|
**Date**: 2026-03-03
|
||||||
|
|
||||||
|
## Research Summary
|
||||||
|
|
||||||
|
No NEEDS CLARIFICATION items existed in the technical context. The feature is straightforward and follows established patterns. Research focused on confirming existing patterns and the one key design decision.
|
||||||
|
|
||||||
|
## Decision 1: CombatantId Generation Strategy
|
||||||
|
|
||||||
|
**Decision**: CombatantId is passed into the domain function as an argument, not generated internally.
|
||||||
|
|
||||||
|
**Rationale**: The domain layer must remain pure and deterministic (Constitution Principle I). Generating IDs internally would require either randomness (UUID) or side effects (counter with mutable state), both of which violate purity. By accepting the id as input, `addCombatant(encounter, id, name)` is a pure function: same inputs always produce the same output.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Generate UUID inside domain: Violates deterministic core principle. Tests would be non-deterministic.
|
||||||
|
- Pass an id-generator function: Adds unnecessary complexity. The application layer can generate the id and pass it in.
|
||||||
|
|
||||||
|
**Who generates the id**: The application layer (use case) or adapter layer (hook) generates the CombatantId before calling the domain function. This matches how `createEncounter` already works — callers construct `Combatant` objects with pre-assigned ids.
|
||||||
|
|
||||||
|
## Decision 2: Function Signature Pattern
|
||||||
|
|
||||||
|
**Decision**: Follow the `advanceTurn` pattern — standalone pure function returning a success result or DomainError.
|
||||||
|
|
||||||
|
**Rationale**: Consistency with the existing codebase. `advanceTurn` returns `AdvanceTurnSuccess | DomainError`, so `addCombatant` will return `AddCombatantSuccess | DomainError` with the same shape: `{ encounter, events }`.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Method on an Encounter class: Project uses plain interfaces and free functions, not classes.
|
||||||
|
- Mutating the encounter in place: Violates immutability convention (all fields are `readonly`).
|
||||||
|
|
||||||
|
## Decision 3: Name Validation Approach
|
||||||
|
|
||||||
|
**Decision**: Trim whitespace, then reject empty strings. The domain function validates the name.
|
||||||
|
|
||||||
|
**Rationale**: Name validation is a domain rule (what constitutes a valid combatant name), so it belongs in the domain layer. Trimming before checking prevents whitespace-only names from slipping through.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Validate in application layer: Would allow invalid data to reach domain if called from a different adapter. Domain should protect its own invariants.
|
||||||
|
- Accept any string: Would allow empty-name combatants, violating spec FR-004.
|
||||||
161
specs/002-add-combatant/spec.md
Normal file
161
specs/002-add-combatant/spec.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# Feature Specification: Add Combatant
|
||||||
|
|
||||||
|
**Feature Branch**: `002-add-combatant`
|
||||||
|
**Created**: 2026-03-03
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "let us add a spec for the option to add a combatant to the encounter. a new combatant is added to the end of the list."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Add Combatant to Encounter (Priority: P1)
|
||||||
|
|
||||||
|
A game master adds a new combatant to an existing encounter. The new
|
||||||
|
combatant is appended to the end of the initiative order. This allows
|
||||||
|
late-joining participants or newly discovered enemies to enter combat.
|
||||||
|
|
||||||
|
**Why this priority**: Adding combatants is the foundational mutation
|
||||||
|
for populating an encounter. Without it, the encounter has no
|
||||||
|
participants and no other feature (turn advancement, removal) is useful.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested as a pure state transition
|
||||||
|
with no I/O, persistence, or UI. Given an Encounter value and an
|
||||||
|
AddCombatant action with a name, assert the resulting Encounter value
|
||||||
|
and emitted domain events.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an empty encounter (no combatants, activeIndex 0,
|
||||||
|
roundNumber 1),
|
||||||
|
**When** AddCombatant with name "Gandalf",
|
||||||
|
**Then** combatants is [Gandalf], activeIndex is 0,
|
||||||
|
roundNumber is 1,
|
||||||
|
and a CombatantAdded event is emitted with the new combatant's
|
||||||
|
id and name "Gandalf" and position 0.
|
||||||
|
|
||||||
|
2. **Given** an encounter with combatants [A, B], activeIndex 0,
|
||||||
|
roundNumber 1,
|
||||||
|
**When** AddCombatant with name "C",
|
||||||
|
**Then** combatants is [A, B, C], activeIndex is 0,
|
||||||
|
roundNumber is 1,
|
||||||
|
and a CombatantAdded event is emitted with position 2.
|
||||||
|
|
||||||
|
3. **Given** an encounter with combatants [A, B, C], activeIndex 2,
|
||||||
|
roundNumber 3,
|
||||||
|
**When** AddCombatant with name "D",
|
||||||
|
**Then** combatants is [A, B, C, D], activeIndex is 2,
|
||||||
|
roundNumber is 3,
|
||||||
|
and a CombatantAdded event is emitted with position 3.
|
||||||
|
The active combatant does not change.
|
||||||
|
|
||||||
|
4. **Given** an encounter with combatants [A],
|
||||||
|
**When** AddCombatant is applied twice with names "B" then "C",
|
||||||
|
**Then** combatants is [A, B, C] in that order.
|
||||||
|
Each operation emits its own CombatantAdded event.
|
||||||
|
|
||||||
|
5. **Given** an encounter with combatants [A, B],
|
||||||
|
**When** AddCombatant with an empty name "",
|
||||||
|
**Then** the operation MUST fail with a validation error.
|
||||||
|
No events are emitted. State is unchanged.
|
||||||
|
|
||||||
|
6. **Given** an encounter with combatants [A, B],
|
||||||
|
**When** AddCombatant with a whitespace-only name " ",
|
||||||
|
**Then** the operation MUST fail with a validation error.
|
||||||
|
No events are emitted. State is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Empty name or whitespace-only name: AddCombatant MUST return a
|
||||||
|
DomainError (no state change, no events).
|
||||||
|
- Adding to an empty encounter: the new combatant becomes the first
|
||||||
|
and only participant; activeIndex remains 0.
|
||||||
|
- Adding during mid-round: the activeIndex must not shift; the
|
||||||
|
currently active combatant stays active.
|
||||||
|
- Duplicate names: allowed. Combatants are distinguished by their
|
||||||
|
unique id, not by name.
|
||||||
|
|
||||||
|
## Domain Model *(mandatory)*
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Combatant**: An identified participant in the encounter with a
|
||||||
|
unique CombatantId (branded string) and a name (non-empty string).
|
||||||
|
- **Encounter**: The aggregate root. Contains an ordered list of
|
||||||
|
combatants, an activeIndex pointing to the current combatant, and
|
||||||
|
a roundNumber (positive integer, starting at 1).
|
||||||
|
|
||||||
|
### Domain Events
|
||||||
|
|
||||||
|
- **CombatantAdded**: Emitted on every successful AddCombatant.
|
||||||
|
Carries: combatantId, name, position (zero-based index where the
|
||||||
|
combatant was inserted).
|
||||||
|
|
||||||
|
### Invariants
|
||||||
|
|
||||||
|
- **INV-1** (preserved): An encounter MAY have zero combatants.
|
||||||
|
- **INV-2** (preserved): If combatants.length > 0, activeIndex MUST
|
||||||
|
satisfy 0 <= activeIndex < combatants.length. If
|
||||||
|
combatants.length == 0, activeIndex MUST be 0.
|
||||||
|
- **INV-3** (preserved): roundNumber MUST be a positive integer
|
||||||
|
(>= 1) and MUST only increase.
|
||||||
|
- **INV-4**: AddCombatant MUST be a pure function of the current
|
||||||
|
encounter state and the input name. Given identical input, output
|
||||||
|
MUST be identical (except for id generation — see Assumptions).
|
||||||
|
- **INV-5**: Every successful AddCombatant MUST emit exactly one
|
||||||
|
CombatantAdded event. No silent state changes.
|
||||||
|
- **INV-6**: AddCombatant MUST NOT change the activeIndex or
|
||||||
|
roundNumber of the encounter.
|
||||||
|
- **INV-7**: The new combatant MUST be appended to the end of the
|
||||||
|
combatants list (last position).
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The domain MUST expose an AddCombatant operation that
|
||||||
|
accepts an Encounter and a combatant name, and returns the updated
|
||||||
|
Encounter state plus emitted domain events.
|
||||||
|
- **FR-002**: AddCombatant MUST append the new combatant to the end
|
||||||
|
of the combatants list.
|
||||||
|
- **FR-003**: AddCombatant MUST assign a unique CombatantId to the
|
||||||
|
new combatant.
|
||||||
|
- **FR-004**: AddCombatant MUST reject empty or whitespace-only names
|
||||||
|
by returning a DomainError without modifying state or emitting
|
||||||
|
events.
|
||||||
|
- **FR-005**: AddCombatant MUST NOT alter the activeIndex or
|
||||||
|
roundNumber of the encounter.
|
||||||
|
- **FR-006**: Domain events MUST be returned as values from the
|
||||||
|
operation, not dispatched via side effects.
|
||||||
|
|
||||||
|
### Out of Scope (MVP baseline does not include)
|
||||||
|
|
||||||
|
- Removing combatants from an encounter
|
||||||
|
- Reordering combatants after adding
|
||||||
|
- Initiative score or automatic sorting
|
||||||
|
- Combatant attributes beyond name (HP, conditions, stats)
|
||||||
|
- Maximum combatant count limits
|
||||||
|
- Persistence, serialization, or storage
|
||||||
|
- UI or any adapter layer
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- CombatantId generation is the caller's responsibility (passed in or
|
||||||
|
generated by the application layer), keeping the domain function
|
||||||
|
pure and deterministic. The domain function will accept a
|
||||||
|
CombatantId as part of its input rather than generating one
|
||||||
|
internally.
|
||||||
|
- Name validation trims whitespace; a name that is empty after
|
||||||
|
trimming is invalid.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: All 6 acceptance scenarios pass as deterministic,
|
||||||
|
pure-function tests with no I/O dependencies.
|
||||||
|
- **SC-002**: Invariants INV-1 through INV-7 are verified by tests.
|
||||||
|
- **SC-003**: The domain module has zero imports from application,
|
||||||
|
adapter, or agent layers (layer boundary compliance).
|
||||||
|
- **SC-004**: Adding a combatant to an encounter preserves all
|
||||||
|
existing combatants and their order unchanged.
|
||||||
129
specs/002-add-combatant/tasks.md
Normal file
129
specs/002-add-combatant/tasks.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# Tasks: Add Combatant
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/002-add-combatant/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, quickstart.md
|
||||||
|
|
||||||
|
**Tests**: Included — spec success criteria SC-001 and SC-002 require all acceptance scenarios and invariants to be verified by tests.
|
||||||
|
|
||||||
|
**Organization**: Single user story (P1). Tasks follow the established `advanceTurn` pattern across all three layers.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Foundational (Domain Event)
|
||||||
|
|
||||||
|
**Purpose**: Add the CombatantAdded event type that all layers depend on
|
||||||
|
|
||||||
|
- [x] T001 Add CombatantAdded event interface and extend DomainEvent union in packages/domain/src/events.ts
|
||||||
|
|
||||||
|
**Checkpoint**: CombatantAdded event type available for import
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 - Add Combatant to Encounter (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: A game master can add a new combatant to an existing encounter. The combatant is appended to the end of the initiative list without changing the active turn or round.
|
||||||
|
|
||||||
|
**Independent Test**: Call `addCombatant` with an Encounter, a CombatantId, and a name. Assert the returned Encounter has the new combatant at the end, activeIndex and roundNumber unchanged, and a CombatantAdded event emitted.
|
||||||
|
|
||||||
|
### Domain Layer
|
||||||
|
|
||||||
|
- [x] T002 [US1] Create addCombatant pure function in packages/domain/src/add-combatant.ts
|
||||||
|
- [x] T003 [US1] Export addCombatant and AddCombatantSuccess from packages/domain/src/index.ts
|
||||||
|
|
||||||
|
### Domain Tests
|
||||||
|
|
||||||
|
- [x] T004 [US1] Create acceptance tests (6 scenarios) and invariant tests (INV-1 through INV-7) in packages/domain/src/__tests__/add-combatant.test.ts
|
||||||
|
|
||||||
|
### Application Layer
|
||||||
|
|
||||||
|
- [x] T005 [P] [US1] Create addCombatantUseCase in packages/application/src/add-combatant-use-case.ts
|
||||||
|
- [x] T006 [US1] Export addCombatantUseCase from packages/application/src/index.ts
|
||||||
|
|
||||||
|
### Web Adapter
|
||||||
|
|
||||||
|
- [x] T007 [US1] Add addCombatant callback to useEncounter hook in apps/web/src/hooks/use-encounter.ts
|
||||||
|
- [x] T008 [US1] Add combatant name input and add button to apps/web/src/App.tsx
|
||||||
|
|
||||||
|
**Checkpoint**: All 6 acceptance scenarios pass. User can type a name and add a combatant via the UI. `pnpm check` passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [x] T009 Run pnpm check (format + lint + typecheck + test) and fix any issues
|
||||||
|
- [x] T010 Verify layer boundary compliance (domain has no outer-layer imports)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Foundational (Phase 1)**: No dependencies — start immediately
|
||||||
|
- **User Story 1 (Phase 2)**: Depends on T001 (CombatantAdded event type)
|
||||||
|
- **Polish (Phase 3)**: Depends on all Phase 2 tasks
|
||||||
|
|
||||||
|
### Within User Story 1
|
||||||
|
|
||||||
|
```
|
||||||
|
T001 (event type)
|
||||||
|
├── T002 (domain function) → T003 (domain exports) → T004 (domain tests)
|
||||||
|
└── T005 (use case) ──────→ T006 (app exports) → T007 (hook) → T008 (UI)
|
||||||
|
```
|
||||||
|
|
||||||
|
- T002 depends on T001 (needs CombatantAdded type)
|
||||||
|
- T003 depends on T002 (exports the new function)
|
||||||
|
- T004 depends on T003 (tests import from index)
|
||||||
|
- T005 depends on T003 (use case imports domain function) — can run in parallel with T004
|
||||||
|
- T006 depends on T005
|
||||||
|
- T007 depends on T006
|
||||||
|
- T008 depends on T007
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- T004 (domain tests) and T005 (use case) can run in parallel after T003
|
||||||
|
- T009 and T010 can run in parallel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: After T003
|
||||||
|
|
||||||
|
```
|
||||||
|
# These two tasks touch different packages and can run in parallel:
|
||||||
|
T004: "Acceptance + invariant tests in packages/domain/src/__tests__/add-combatant.test.ts"
|
||||||
|
T005: "Use case in packages/application/src/add-combatant-use-case.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP (This Feature)
|
||||||
|
|
||||||
|
1. T001: Add event type (foundation)
|
||||||
|
2. T002–T003: Domain function + exports
|
||||||
|
3. T004 + T005 in parallel: Tests + use case
|
||||||
|
4. T006–T008: Application exports → hook → UI
|
||||||
|
5. T009–T010: Verify everything passes
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
After T004: All 6 acceptance scenarios pass as pure-function tests
|
||||||
|
After T008: UI allows adding combatants by name
|
||||||
|
After T009: `pnpm check` passes clean (merge gate)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Follow the `advanceTurn` pattern for function signature, result type, and error handling
|
||||||
|
- CombatantId is passed in as input (generated by caller), not created inside domain
|
||||||
|
- Name is trimmed then validated; empty after trim returns DomainError with code "invalid-name"
|
||||||
|
- Commit after each task or logical group
|
||||||
|
- Total: 10 tasks (1 foundational + 7 US1 + 2 polish)
|
||||||
34
specs/003-remove-combatant/checklists/requirements.md
Normal file
34
specs/003-remove-combatant/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Remove Combatant
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-03
|
||||||
|
**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`.
|
||||||
69
specs/003-remove-combatant/data-model.md
Normal file
69
specs/003-remove-combatant/data-model.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Data Model: Remove Combatant
|
||||||
|
|
||||||
|
**Feature**: 003-remove-combatant
|
||||||
|
**Date**: 2026-03-03
|
||||||
|
|
||||||
|
## Existing Entities (no changes)
|
||||||
|
|
||||||
|
### Encounter
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| combatants | readonly Combatant[] | Ordered list of participants |
|
||||||
|
| activeIndex | number | Index of the combatant whose turn it is |
|
||||||
|
| roundNumber | number | Current round (≥ 1, never changes on removal) |
|
||||||
|
|
||||||
|
### Combatant
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | CombatantId (branded string) | Unique identifier |
|
||||||
|
| name | string | Display name |
|
||||||
|
|
||||||
|
## New Event Type
|
||||||
|
|
||||||
|
### CombatantRemoved
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| type | "CombatantRemoved" (literal) | Event discriminant |
|
||||||
|
| combatantId | CombatantId | ID of the removed combatant |
|
||||||
|
| name | string | Name of the removed combatant |
|
||||||
|
|
||||||
|
Added to the `DomainEvent` discriminated union alongside `TurnAdvanced`, `RoundAdvanced`, and `CombatantAdded`.
|
||||||
|
|
||||||
|
## New Domain Function
|
||||||
|
|
||||||
|
### removeCombatant
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| encounter | Encounter | Current encounter state |
|
||||||
|
| id | CombatantId | ID of combatant to remove |
|
||||||
|
|
||||||
|
**Returns**: `RemoveCombatantSuccess | DomainError`
|
||||||
|
|
||||||
|
### RemoveCombatantSuccess
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| encounter | Encounter | Updated encounter after removal |
|
||||||
|
| events | DomainEvent[] | Exactly one CombatantRemoved event |
|
||||||
|
|
||||||
|
### DomainError (existing, reused)
|
||||||
|
|
||||||
|
Returned with code `"combatant-not-found"` when ID does not match any combatant.
|
||||||
|
|
||||||
|
## State Transition Rules
|
||||||
|
|
||||||
|
### activeIndex Adjustment
|
||||||
|
|
||||||
|
Given removal of combatant at index `removedIdx` with current `activeIndex`:
|
||||||
|
|
||||||
|
| Condition | New activeIndex |
|
||||||
|
|-----------|----------------|
|
||||||
|
| removedIdx > activeIndex | activeIndex (unchanged) |
|
||||||
|
| removedIdx < activeIndex | activeIndex - 1 |
|
||||||
|
| removedIdx === activeIndex, not last in list | activeIndex (next slides in) |
|
||||||
|
| removedIdx === activeIndex, last in list | 0 (wrap) |
|
||||||
|
| Only combatant removed (list becomes empty) | 0 |
|
||||||
71
specs/003-remove-combatant/plan.md
Normal file
71
specs/003-remove-combatant/plan.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Implementation Plan: Remove Combatant
|
||||||
|
|
||||||
|
**Branch**: `003-remove-combatant` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/003-remove-combatant/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add a `removeCombatant` pure domain function that removes a combatant by ID from an Encounter, correctly adjusts `activeIndex` to preserve turn integrity, keeps `roundNumber` unchanged, and emits a `CombatantRemoved` event. Wire through an application-layer use case and expose via a minimal UI remove action per combatant.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax)
|
||||||
|
**Primary Dependencies**: React 19, Vite
|
||||||
|
**Storage**: In-memory React state (local-first, single-user MVP)
|
||||||
|
**Testing**: Vitest
|
||||||
|
**Target Platform**: Web (localhost:5173 dev, production build via Vite)
|
||||||
|
**Project Type**: Web application (monorepo: packages/domain, packages/application, apps/web)
|
||||||
|
**Performance Goals**: N/A (local-first, small data sets)
|
||||||
|
**Constraints**: Domain must be pure (no I/O); layer boundaries enforced by automated script
|
||||||
|
**Scale/Scope**: Single-user, single encounter at a time
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Evidence |
|
||||||
|
|-----------|--------|----------|
|
||||||
|
| I. Deterministic Domain Core | PASS | `removeCombatant` is a pure function: same input → same output, no I/O |
|
||||||
|
| II. Layered Architecture | PASS | Domain function → use case → React hook/UI. No layer violations. |
|
||||||
|
| III. Agent Boundary | N/A | No agent layer involved in this feature |
|
||||||
|
| IV. Clarification-First | PASS | Spec fully specifies all activeIndex adjustment rules; no ambiguity |
|
||||||
|
| V. Escalation Gates | PASS | All functionality is within spec scope |
|
||||||
|
| VI. MVP Baseline Language | PASS | No permanent bans introduced |
|
||||||
|
| VII. No Gameplay Rules | PASS | Removal is encounter management, not gameplay mechanics |
|
||||||
|
|
||||||
|
**Gate result**: PASS — no violations.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/003-remove-combatant/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/domain/src/
|
||||||
|
├── remove-combatant.ts # Pure domain function
|
||||||
|
├── events.ts # Add CombatantRemoved to DomainEvent union
|
||||||
|
├── types.ts # Existing types (no changes expected)
|
||||||
|
├── index.ts # Re-export removeCombatant
|
||||||
|
└── __tests__/
|
||||||
|
└── remove-combatant.test.ts # Acceptance scenarios from spec
|
||||||
|
|
||||||
|
packages/application/src/
|
||||||
|
├── remove-combatant-use-case.ts # Orchestrates store.get → domain → store.save
|
||||||
|
└── index.ts # Re-export use case
|
||||||
|
|
||||||
|
apps/web/src/
|
||||||
|
├── hooks/use-encounter.ts # Add removeCombatant callback
|
||||||
|
└── App.tsx # Add remove button per combatant + event display
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Follows the existing monorepo layered architecture (packages/domain → packages/application → apps/web) exactly mirroring the addCombatant feature's file layout.
|
||||||
39
specs/003-remove-combatant/quickstart.md
Normal file
39
specs/003-remove-combatant/quickstart.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Quickstart: Remove Combatant
|
||||||
|
|
||||||
|
**Feature**: 003-remove-combatant
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+, pnpm
|
||||||
|
- Repository cloned, `pnpm install` run
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout 003-remove-combatant
|
||||||
|
pnpm test:watch # Run tests in watch mode during development
|
||||||
|
pnpm --filter web dev # Dev server at localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm check # Must pass before commit (format + lint + typecheck + test)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Domain**: Add `CombatantRemoved` event type → implement `removeCombatant` pure function → tests
|
||||||
|
2. **Application**: Add `removeCombatantUseCase` → re-export
|
||||||
|
3. **Web**: Add `removeCombatant` to `useEncounter` hook → add remove button in `App.tsx`
|
||||||
|
|
||||||
|
## Key Files
|
||||||
|
|
||||||
|
| Layer | File | Purpose |
|
||||||
|
|-------|------|---------|
|
||||||
|
| Domain | `packages/domain/src/remove-combatant.ts` | Pure removal function |
|
||||||
|
| Domain | `packages/domain/src/events.ts` | CombatantRemoved event type |
|
||||||
|
| Domain | `packages/domain/src/__tests__/remove-combatant.test.ts` | Acceptance tests |
|
||||||
|
| Application | `packages/application/src/remove-combatant-use-case.ts` | Use case orchestration |
|
||||||
|
| Web | `apps/web/src/hooks/use-encounter.ts` | Hook integration |
|
||||||
|
| Web | `apps/web/src/App.tsx` | UI remove button |
|
||||||
48
specs/003-remove-combatant/research.md
Normal file
48
specs/003-remove-combatant/research.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Research: Remove Combatant
|
||||||
|
|
||||||
|
**Feature**: 003-remove-combatant
|
||||||
|
**Date**: 2026-03-03
|
||||||
|
|
||||||
|
## R1: activeIndex Adjustment Strategy on Removal
|
||||||
|
|
||||||
|
**Decision**: Use positional comparison between removed index and activeIndex to determine adjustment.
|
||||||
|
|
||||||
|
**Rationale**: The spec defines five distinct cases based on the relationship between the removed combatant's index and the current activeIndex. These map cleanly to a single conditional:
|
||||||
|
|
||||||
|
1. **Removed index > activeIndex** → no change (combatant was after active)
|
||||||
|
2. **Removed index < activeIndex** → decrement activeIndex by 1 (shift left)
|
||||||
|
3. **Removed index === activeIndex and not last** → keep same index (next combatant slides into position)
|
||||||
|
4. **Removed index === activeIndex and last** → wrap to 0
|
||||||
|
5. **Last remaining combatant removed** → activeIndex = 0
|
||||||
|
|
||||||
|
This mirrors the inverse of addCombatant's "always append, never adjust" approach — removal requires adjustment because positions shift.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Storing active combatant by ID instead of index: Would simplify removal but requires changing the Encounter type (out of scope, breaks existing advanceTurn).
|
||||||
|
- Emitting a TurnAdvanced event on active removal: Rejected — spec explicitly says roundNumber is unchanged, and the next-in-line simply inherits.
|
||||||
|
|
||||||
|
## R2: CombatantRemoved Event Shape
|
||||||
|
|
||||||
|
**Decision**: Follow the existing event pattern with `type` discriminant. Include `combatantId` and `name` fields.
|
||||||
|
|
||||||
|
**Rationale**: Consistent with `CombatantAdded` which carries `combatantId`, `name`, and `position`. For removal, `position` is less meaningful (the combatant is gone), so we include only ID and name.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Including the removed index: Rejected — the index is ephemeral and not useful after the fact.
|
||||||
|
- Including the full Combatant object: Over-engineered for current needs; ID + name suffices.
|
||||||
|
|
||||||
|
## R3: Use Case Pattern
|
||||||
|
|
||||||
|
**Decision**: Mirror `addCombatantUseCase` exactly — `store.get()` → domain function → `store.save()` → return events.
|
||||||
|
|
||||||
|
**Rationale**: No new patterns needed. The existing use case pattern handles the get-transform-save cycle cleanly.
|
||||||
|
|
||||||
|
## R4: UI Pattern for Remove Action
|
||||||
|
|
||||||
|
**Decision**: Add a remove button next to each combatant in the list. The button calls `removeCombatant(id)` from the hook.
|
||||||
|
|
||||||
|
**Rationale**: Minimal UI per spec. No confirmation dialog needed for MVP (spec doesn't require it). Mirrors the simplicity of the existing add form.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Confirmation modal before removal: MVP baseline does not include this; can be added later.
|
||||||
|
- Swipe-to-remove gesture: Not applicable for web MVP.
|
||||||
101
specs/003-remove-combatant/spec.md
Normal file
101
specs/003-remove-combatant/spec.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
# Feature Specification: Remove Combatant
|
||||||
|
|
||||||
|
**Feature Branch**: `003-remove-combatant`
|
||||||
|
**Created**: 2026-03-03
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "RemoveCombatant: allow removing a combatant by id from Encounter (adjust activeIndex correctly, keep roundNumber, emit CombatantRemoved, error if id not found) and wire through application + minimal UI."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Remove a Combatant from an Active Encounter (Priority: P1)
|
||||||
|
|
||||||
|
A game master is running a combat encounter and a combatant is defeated or leaves. The GM removes that combatant by clicking a remove action. The combatant disappears from the initiative order and the turn continues correctly without disruption.
|
||||||
|
|
||||||
|
**Why this priority**: Core functionality — removing combatants is the primary purpose of this feature and must work correctly to maintain encounter integrity.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by adding combatants to an encounter, removing one, and verifying the combatant list, activeIndex, and roundNumber are correct.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an encounter with combatants [A, B, C] and activeIndex 1 (B's turn), **When** the GM removes combatant C (index 2, after active), **Then** the encounter has [A, B], activeIndex remains 1, roundNumber unchanged, and a CombatantRemoved event is emitted.
|
||||||
|
2. **Given** an encounter with combatants [A, B, C] and activeIndex 2 (C's turn), **When** the GM removes combatant A (index 0, before active), **Then** the encounter has [B, C], activeIndex becomes 1 (still C's turn), roundNumber unchanged.
|
||||||
|
3. **Given** an encounter with combatants [A, B, C] and activeIndex 1 (B's turn), **When** the GM removes combatant B (the active combatant), **Then** the encounter has [A, C], activeIndex becomes 1 (C is now active — the next combatant takes over), roundNumber unchanged.
|
||||||
|
4. **Given** an encounter with combatants [A, B, C] and activeIndex 2 (C's turn, last position), **When** the GM removes combatant C (active and last), **Then** the encounter has [A, B], activeIndex wraps to 0 (A is now active), roundNumber unchanged.
|
||||||
|
5. **Given** an encounter with combatants [A] and activeIndex 0, **When** the GM removes combatant A, **Then** the encounter has [], activeIndex is 0, roundNumber unchanged.
|
||||||
|
6. **Given** an encounter with combatants [A, B, C], **When** the GM attempts to remove a combatant with an ID that does not exist, **Then** a domain error is returned with a descriptive error code, and the encounter is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Remove Combatant via UI (Priority: P2)
|
||||||
|
|
||||||
|
A game master sees a list of combatants in the encounter UI. Each combatant has a remove action. Clicking it removes the combatant and the UI updates to reflect the new initiative order.
|
||||||
|
|
||||||
|
**Why this priority**: Provides the user-facing interaction for the core domain functionality. Without UI, the feature is not accessible.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by rendering the encounter UI, clicking the remove action on a combatant, and verifying the combatant disappears from the list.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an encounter with combatants displayed in the UI, **When** the GM clicks the remove action on a combatant, **Then** that combatant is removed from the displayed list.
|
||||||
|
2. **Given** an encounter displayed in the UI, **When** a removal results in a domain error (ID not found), **Then** the removal is silently ignored and the encounter state remains unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when removing the only combatant? The encounter becomes empty with activeIndex 0.
|
||||||
|
- What happens when removing the active combatant who is last in the list? activeIndex wraps to 0.
|
||||||
|
- What happens when removing from an empty encounter? This is covered by the "ID not found" error since no combatant IDs exist.
|
||||||
|
- What happens if the same ID is passed twice in sequence? The first call succeeds; the second returns an error (ID not found).
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST remove a combatant identified by CombatantId from the encounter's combatant list.
|
||||||
|
- **FR-002**: System MUST return a domain error with code `"combatant-not-found"` when the given CombatantId does not match any combatant in the encounter.
|
||||||
|
- **FR-003**: System MUST preserve the roundNumber unchanged after removal.
|
||||||
|
- **FR-004**: System MUST adjust activeIndex so that the same combatant remains active after removal when the removed combatant is before the active one (activeIndex decrements by 1).
|
||||||
|
- **FR-005**: System MUST keep activeIndex unchanged when the removed combatant is after the active one.
|
||||||
|
- **FR-006**: System MUST advance activeIndex to the next combatant (same index position) when the active combatant is removed, allowing the next-in-line to take over.
|
||||||
|
- **FR-007**: System MUST wrap activeIndex to 0 when the active combatant is removed and it was the last in the list.
|
||||||
|
- **FR-008**: System MUST set activeIndex to 0 when the last remaining combatant is removed (empty encounter).
|
||||||
|
- **FR-009**: System MUST emit exactly one CombatantRemoved event on successful removal, containing the removed combatant's ID and name.
|
||||||
|
- **FR-010**: System MUST expose the remove-combatant operation through the application layer via a use case / port interface.
|
||||||
|
- **FR-011**: System MUST provide a UI control for each combatant that triggers removal.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Encounter**: The combat encounter containing an ordered list of combatants, an activeIndex, and a roundNumber.
|
||||||
|
- **Combatant**: A participant in the encounter identified by a unique CombatantId and a name.
|
||||||
|
- **CombatantRemoved** (event): A domain event recording the removal, carrying the removed combatant's ID and name.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Removing a combatant from any position in the initiative order preserves correct turn tracking (the intended combatant remains or becomes active).
|
||||||
|
- **SC-002**: All six acceptance scenarios pass as automated tests.
|
||||||
|
- **SC-003**: The round number never changes as a result of removal.
|
||||||
|
- **SC-004**: The UI reflects combatant removal immediately after the action, with no stale state displayed.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- ID generation and lookup is the caller's responsibility, consistent with the addCombatant pattern.
|
||||||
|
- Removal does not trigger a round advance — roundNumber is always preserved.
|
||||||
|
- The domain function is pure: deterministic given identical inputs, no I/O.
|
||||||
|
- The CombatantRemoved event follows the same plain-data-object pattern as existing domain events.
|
||||||
|
- When the active combatant is removed, the next combatant in order inherits the turn (no automatic turn advance or round increment occurs).
|
||||||
|
- Error feedback for invalid removal is a silent no-op for MVP. MVP baseline does not include user-visible error messages for removal failures.
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
| Principle | Status | Evidence |
|
||||||
|
|-----------|--------|----------|
|
||||||
|
| I. Deterministic Domain Core | PASS | removeCombatant is a pure state transition with no I/O |
|
||||||
|
| II. Layered Architecture | PASS | Domain function → use case → UI adapter |
|
||||||
|
| III. Agent Boundary | N/A | No agent layer involved |
|
||||||
|
| IV. Clarification-First | PASS | All activeIndex rules fully specified; no ambiguity |
|
||||||
|
| V. Escalation Gates | PASS | All requirements within original spec scope |
|
||||||
|
| VI. MVP Baseline Language | PASS | No permanent bans; confirmation dialog excluded via MVP baseline language |
|
||||||
|
| VII. No Gameplay Rules | PASS | Encounter management only, no game mechanics |
|
||||||
117
specs/003-remove-combatant/tasks.md
Normal file
117
specs/003-remove-combatant/tasks.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# Tasks: Remove Combatant
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/003-remove-combatant/`
|
||||||
|
**Prerequisites**: plan.md, spec.md, research.md, data-model.md
|
||||||
|
|
||||||
|
**Tests**: Included — spec requires all six acceptance scenarios as automated tests (SC-002).
|
||||||
|
|
||||||
|
**Organization**: Tasks grouped by user story for independent implementation and testing.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- Exact file paths included in descriptions
|
||||||
|
|
||||||
|
## Phase 1: Foundational (Event Type)
|
||||||
|
|
||||||
|
**Purpose**: Add the CombatantRemoved event type that all subsequent tasks depend on.
|
||||||
|
|
||||||
|
- [x] T001 Add `CombatantRemoved` interface and extend `DomainEvent` union in `packages/domain/src/events.ts`
|
||||||
|
- [x] T002 Export `CombatantRemoved` type from `packages/domain/src/index.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: CombatantRemoved event type available for domain function and UI event display.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 - Remove Combatant Domain Logic (Priority: P1) MVP
|
||||||
|
|
||||||
|
**Goal**: Pure `removeCombatant` domain function that removes a combatant by ID, adjusts activeIndex correctly, preserves roundNumber, and emits CombatantRemoved.
|
||||||
|
|
||||||
|
**Independent Test**: Call `removeCombatant` with various encounter states and verify combatant list, activeIndex, roundNumber, events, and error cases.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||||
|
|
||||||
|
- [x] T003 [US1] Write acceptance tests for `removeCombatant` in `packages/domain/src/__tests__/remove-combatant.test.ts` covering all 6 spec scenarios: remove after active (AS-1), remove before active (AS-2), remove active combatant mid-list (AS-3), remove active combatant at end/wrap (AS-4), remove only combatant (AS-5), ID not found error (AS-6). Also test: event shape (CombatantRemoved with id+name), roundNumber invariance, and determinism.
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T004 [US1] Implement `removeCombatant` pure function and `RemoveCombatantSuccess` type in `packages/domain/src/remove-combatant.ts` — find combatant by ID, compute new activeIndex per data-model rules, filter combatant list, emit CombatantRemoved event, return DomainError for not-found
|
||||||
|
- [x] T005 [US1] Export `removeCombatant` and `RemoveCombatantSuccess` from `packages/domain/src/index.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: All 6 acceptance tests pass. Domain function is complete and independently testable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 2 - Application + UI Wiring (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Wire removeCombatant through application use case and expose via minimal UI with a remove button per combatant.
|
||||||
|
|
||||||
|
**Independent Test**: Render encounter UI, click remove on a combatant, verify it disappears from the list and event log updates.
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T006 [P] [US2] Create `removeCombatantUseCase` in `packages/application/src/remove-combatant-use-case.ts` — follows existing pattern: `store.get()` → `removeCombatant()` → `store.save()` → return events or DomainError
|
||||||
|
- [x] T007 [US2] Export `removeCombatantUseCase` from `packages/application/src/index.ts`
|
||||||
|
- [x] T008 [US2] Add `removeCombatant(id: CombatantId)` callback to `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — call use case, append events to log on success
|
||||||
|
- [x] T009 [US2] Add remove button per combatant and `CombatantRemoved` event display case in `apps/web/src/App.tsx`
|
||||||
|
|
||||||
|
**Checkpoint**: Full vertical slice works — GM can remove combatants from UI, initiative order updates correctly, event log shows removal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
- [x] T010 Run `pnpm check` (format + lint + typecheck + test) and fix any issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1 (Foundational)**: No dependencies — start immediately
|
||||||
|
- **Phase 2 (US1 Domain)**: Depends on Phase 1 (needs CombatantRemoved type)
|
||||||
|
- **Phase 3 (US2 App+UI)**: Depends on Phase 2 (needs domain function)
|
||||||
|
- **Phase 4 (Polish)**: Depends on Phase 3
|
||||||
|
|
||||||
|
### Within Each Phase
|
||||||
|
|
||||||
|
- T001 → T002 (export after defining)
|
||||||
|
- T003 (tests first) → T004 (implement) → T005 (export)
|
||||||
|
- T006 → T007 (export after creating use case file)
|
||||||
|
- T008 depends on T006+T007 (needs use case)
|
||||||
|
- T009 depends on T008 (needs hook callback)
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- Within T003, individual test cases are independent
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Event type (T001–T002)
|
||||||
|
2. Complete Phase 2: Domain tests + function (T003–T005)
|
||||||
|
3. **STOP and VALIDATE**: All 6 acceptance tests pass
|
||||||
|
4. Domain is complete and usable without UI
|
||||||
|
|
||||||
|
### Full Feature
|
||||||
|
|
||||||
|
1. Phase 1 → Phase 2 → Phase 3 → Phase 4
|
||||||
|
2. Each phase adds a testable increment
|
||||||
|
3. Commit after each phase checkpoint
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story
|
||||||
|
- Tests written first (TDD) per spec requirement SC-002
|
||||||
|
- Commit after each phase checkpoint
|
||||||
|
- Total: 10 tasks across 4 phases
|
||||||
34
specs/004-edit-combatant/checklists/requirements.md
Normal file
34
specs/004-edit-combatant/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Edit Combatant
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-03
|
||||||
|
**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`.
|
||||||
37
specs/004-edit-combatant/contracts/domain-contract.md
Normal file
37
specs/004-edit-combatant/contracts/domain-contract.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Domain Contract: editCombatant
|
||||||
|
|
||||||
|
## Function Signature
|
||||||
|
|
||||||
|
```
|
||||||
|
editCombatant(encounter, id, newName) → EditCombatantSuccess | DomainError
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| encounter | Encounter | Current encounter state |
|
||||||
|
| id | CombatantId | Identity of combatant to rename |
|
||||||
|
| newName | string | New name to assign |
|
||||||
|
|
||||||
|
### Success Output
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| encounter | Encounter | Updated encounter with renamed combatant |
|
||||||
|
| events | DomainEvent[] | Exactly one `CombatantUpdated` event |
|
||||||
|
|
||||||
|
### Error Output
|
||||||
|
|
||||||
|
| Code | Condition |
|
||||||
|
|------|-----------|
|
||||||
|
| `"combatant-not-found"` | No combatant with given id exists |
|
||||||
|
| `"invalid-name"` | newName is empty or whitespace-only |
|
||||||
|
|
||||||
|
## Hook Contract
|
||||||
|
|
||||||
|
`useEncounter()` returns an additional action:
|
||||||
|
|
||||||
|
| Method | Signature | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| editCombatant | `(id: CombatantId, newName: string) => void` | Rename combatant, append events on success |
|
||||||
59
specs/004-edit-combatant/data-model.md
Normal file
59
specs/004-edit-combatant/data-model.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Data Model: Edit Combatant
|
||||||
|
|
||||||
|
**Feature**: 004-edit-combatant
|
||||||
|
**Date**: 2026-03-03
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### Combatant (unchanged)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| id | CombatantId (branded string) | Immutable identity |
|
||||||
|
| name | string | Mutable — this feature updates it |
|
||||||
|
|
||||||
|
### Encounter (unchanged structure)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| combatants | readonly Combatant[] | Edit replaces name in-place by mapping |
|
||||||
|
| activeIndex | number | Preserved during edit |
|
||||||
|
| roundNumber | number | Preserved during edit |
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
### CombatantUpdated (new)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
|-------|------|-------|
|
||||||
|
| type | "CombatantUpdated" | Discriminant |
|
||||||
|
| combatantId | CombatantId | Which combatant was renamed |
|
||||||
|
| oldName | string | Name before edit |
|
||||||
|
| newName | string | Name after edit |
|
||||||
|
|
||||||
|
Added to the `DomainEvent` union type.
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
### editCombatant(encounter, id, newName)
|
||||||
|
|
||||||
|
**Preconditions**:
|
||||||
|
- `newName` is non-empty and not whitespace-only
|
||||||
|
- `id` matches a combatant in `encounter.combatants`
|
||||||
|
|
||||||
|
**Postconditions**:
|
||||||
|
- The combatant with matching `id` has `name` set to `newName`
|
||||||
|
- `activeIndex` and `roundNumber` unchanged
|
||||||
|
- Combatant list order unchanged
|
||||||
|
- Exactly one `CombatantUpdated` event emitted
|
||||||
|
|
||||||
|
**Error cases**:
|
||||||
|
- `id` not found → `DomainError { code: "combatant-not-found" }`
|
||||||
|
- `newName` empty/whitespace → `DomainError { code: "invalid-name" }`
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
| Rule | Condition | Error Code |
|
||||||
|
|------|-----------|------------|
|
||||||
|
| Name must be non-empty | `newName.trim().length === 0` | `"invalid-name"` |
|
||||||
|
| Combatant must exist | No combatant with matching `id` | `"combatant-not-found"` |
|
||||||
70
specs/004-edit-combatant/plan.md
Normal file
70
specs/004-edit-combatant/plan.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Implementation Plan: Edit Combatant
|
||||||
|
|
||||||
|
**Branch**: `004-edit-combatant` | **Date**: 2026-03-03 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/004-edit-combatant/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add the ability to rename a combatant by id within an encounter. A pure domain function `editCombatant` validates the id and new name, returns the updated encounter with a `CombatantUpdated` event, or a `DomainError`. Wired through an application use case and exposed via the existing `useEncounter` hook to a minimal UI control.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax)
|
||||||
|
**Primary Dependencies**: React 19, Vite
|
||||||
|
**Storage**: In-memory React state (local-first, single-user MVP)
|
||||||
|
**Testing**: Vitest
|
||||||
|
**Target Platform**: Browser (localhost:5173)
|
||||||
|
**Project Type**: Web application (monorepo: domain → application → web)
|
||||||
|
**Performance Goals**: N/A — single-user local state, instant updates
|
||||||
|
**Constraints**: Pure domain logic, no I/O in domain layer
|
||||||
|
**Scale/Scope**: Single-user encounter tracker
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Deterministic Domain Core | PASS | `editCombatant` is a pure function: same encounter + id + name → same result |
|
||||||
|
| II. Layered Architecture | PASS | Domain function → use case → hook/UI. No layer violations. |
|
||||||
|
| III. Agent Boundary | N/A | No agent layer involvement |
|
||||||
|
| IV. Clarification-First | PASS | Spec is complete, no ambiguities remain |
|
||||||
|
| V. Escalation Gates | PASS | All work is within spec scope |
|
||||||
|
| VI. MVP Baseline Language | PASS | Spec uses "MVP baseline does not include" for out-of-scope items |
|
||||||
|
| VII. No Gameplay Rules | PASS | Constitution contains no gameplay logic |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/004-edit-combatant/
|
||||||
|
├── plan.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/domain/src/
|
||||||
|
├── edit-combatant.ts # New: pure editCombatant function
|
||||||
|
├── events.ts # Modified: add CombatantUpdated event
|
||||||
|
├── types.ts # Unchanged (Combatant, Encounter, DomainError)
|
||||||
|
├── index.ts # Modified: re-export editCombatant
|
||||||
|
└── __tests__/
|
||||||
|
└── edit-combatant.test.ts # New: acceptance + invariant tests
|
||||||
|
|
||||||
|
packages/application/src/
|
||||||
|
├── edit-combatant-use-case.ts # New: use case wiring
|
||||||
|
└── index.ts # Modified: re-export use case
|
||||||
|
|
||||||
|
apps/web/src/
|
||||||
|
├── hooks/use-encounter.ts # Modified: add editCombatant action
|
||||||
|
└── App.tsx # Modified: add rename UI control
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Follows the existing monorepo layout (`packages/domain` → `packages/application` → `apps/web`). Each new file mirrors the pattern established by `add-combatant` and `remove-combatant`.
|
||||||
41
specs/004-edit-combatant/quickstart.md
Normal file
41
specs/004-edit-combatant/quickstart.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Quickstart: Edit Combatant
|
||||||
|
|
||||||
|
**Feature**: 004-edit-combatant
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # Install dependencies (if needed)
|
||||||
|
pnpm check # Verify everything passes before starting
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter web dev # Start dev server at localhost:5173
|
||||||
|
pnpm test:watch # Run tests in watch mode
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. **Domain event** — Add `CombatantUpdated` to `events.ts`
|
||||||
|
2. **Domain function** — Create `edit-combatant.ts` with pure `editCombatant` function
|
||||||
|
3. **Domain tests** — Create `edit-combatant.test.ts` with acceptance scenarios + invariants
|
||||||
|
4. **Domain exports** — Re-export from `index.ts`
|
||||||
|
5. **Application use case** — Create `edit-combatant-use-case.ts`
|
||||||
|
6. **Application exports** — Re-export from `index.ts`
|
||||||
|
7. **Hook** — Add `editCombatant` action to `useEncounter` hook
|
||||||
|
8. **UI** — Add inline name editing to `App.tsx`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm check # Must pass — format + lint + typecheck + test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Files to Reference
|
||||||
|
|
||||||
|
- `packages/domain/src/add-combatant.ts` — Pattern to follow for domain function
|
||||||
|
- `packages/domain/src/remove-combatant.ts` — Pattern for "not found" error handling
|
||||||
|
- `packages/application/src/add-combatant-use-case.ts` — Pattern for use case
|
||||||
|
- `apps/web/src/hooks/use-encounter.ts` — Pattern for hook wiring
|
||||||
40
specs/004-edit-combatant/research.md
Normal file
40
specs/004-edit-combatant/research.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Research: Edit Combatant
|
||||||
|
|
||||||
|
**Feature**: 004-edit-combatant
|
||||||
|
**Date**: 2026-03-03
|
||||||
|
|
||||||
|
## Research Summary
|
||||||
|
|
||||||
|
No unknowns or NEEDS CLARIFICATION items exist in the spec or technical context. The feature follows well-established patterns already present in the codebase.
|
||||||
|
|
||||||
|
## Decision: Domain Function Pattern
|
||||||
|
|
||||||
|
**Decision**: Follow the identical pattern used by `addCombatant` and `removeCombatant` — pure function returning `EditCombatantSuccess | DomainError`.
|
||||||
|
|
||||||
|
**Rationale**: Consistency with existing code. All three existing domain operations use the same signature shape `(encounter, ...args) => { encounter, events } | DomainError`. No reason to deviate.
|
||||||
|
|
||||||
|
**Alternatives considered**: None — the pattern is well-established and fits perfectly.
|
||||||
|
|
||||||
|
## Decision: Event Shape
|
||||||
|
|
||||||
|
**Decision**: `CombatantUpdated` event includes `combatantId`, `oldName`, and `newName` fields.
|
||||||
|
|
||||||
|
**Rationale**: Including both old and new name enables downstream consumers (logging, undo, UI feedback) without needing to diff state. Follows the pattern of `CombatantRemoved` which includes `name` for context.
|
||||||
|
|
||||||
|
**Alternatives considered**: Including only `newName` — rejected because losing the old name makes undo/logging harder with no storage savings.
|
||||||
|
|
||||||
|
## Decision: Name Validation
|
||||||
|
|
||||||
|
**Decision**: Reuse the same validation logic as `addCombatant` (reject empty and whitespace-only strings, same error code `"invalid-name"`).
|
||||||
|
|
||||||
|
**Rationale**: Consistent user experience. The spec explicitly states this assumption.
|
||||||
|
|
||||||
|
**Alternatives considered**: None — spec is explicit.
|
||||||
|
|
||||||
|
## Decision: UI Mechanism
|
||||||
|
|
||||||
|
**Decision**: Minimal inline edit — clicking a combatant name makes it editable via an input field, confirmed on blur or Enter.
|
||||||
|
|
||||||
|
**Rationale**: Simplest interaction that meets FR-007 without adding modals or prompts. Follows MVP baseline.
|
||||||
|
|
||||||
|
**Alternatives considered**: Modal dialog, browser `prompt()` — both rejected as heavier than needed for MVP.
|
||||||
77
specs/004-edit-combatant/spec.md
Normal file
77
specs/004-edit-combatant/spec.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Feature Specification: Edit Combatant
|
||||||
|
|
||||||
|
**Feature Branch**: `004-edit-combatant`
|
||||||
|
**Created**: 2026-03-03
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "EditCombatant: allow updating a combatant's name by id in Encounter (emit CombatantUpdated, error if id not found) and wire through application + minimal UI."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Rename a Combatant (Priority: P1)
|
||||||
|
|
||||||
|
A user running an encounter realizes a combatant's name is misspelled or wants to change it. They select the combatant by its identity, provide a new name, and the system updates the combatant in-place while preserving turn order and round state.
|
||||||
|
|
||||||
|
**Why this priority**: Core feature — without the ability to rename, the entire edit-combatant feature has no value.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by creating an encounter with combatants, editing one combatant's name, and verifying the name is updated while all other encounter state remains unchanged.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an encounter with combatants [Alice, Bob], **When** the user updates Bob's name to "Robert", **Then** the encounter contains [Alice, Robert] and a `CombatantUpdated` event is emitted with the combatant's id, old name, and new name.
|
||||||
|
2. **Given** an encounter with combatants [Alice, Bob] where Bob is the active combatant, **When** the user updates Bob's name to "Robert", **Then** Bob remains the active combatant (active index unchanged) and the round number is preserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Error Feedback on Invalid Edit (Priority: P2)
|
||||||
|
|
||||||
|
A user attempts to edit a combatant that no longer exists (e.g., removed in another action) or provides an invalid name. The system returns a clear error without modifying the encounter.
|
||||||
|
|
||||||
|
**Why this priority**: Error handling ensures data integrity and provides a usable experience when things go wrong.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by attempting to edit a non-existent combatant id and verifying an error is returned with no state change.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update a combatant with a non-existent id, **Then** the system returns a "combatant not found" error and the encounter is unchanged.
|
||||||
|
2. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update Alice's name to an empty string, **Then** the system returns an "invalid name" error and the encounter is unchanged.
|
||||||
|
3. **Given** an encounter with combatants [Alice, Bob], **When** the user attempts to update Alice's name to a whitespace-only string, **Then** the system returns an "invalid name" error and the encounter is unchanged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when the user sets a combatant's name to the same value it already has? The system treats it as a valid update — the encounter state is unchanged but a `CombatantUpdated` event is still emitted.
|
||||||
|
- What happens when the encounter has no combatants? Editing any id returns a "combatant not found" error.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST allow updating a combatant's name by providing the combatant's id and a new name.
|
||||||
|
- **FR-002**: System MUST emit a `CombatantUpdated` event containing the combatant id, old name, and new name upon successful update.
|
||||||
|
- **FR-003**: System MUST return a "combatant not found" error when the provided id does not match any combatant in the encounter.
|
||||||
|
- **FR-004**: System MUST return an "invalid name" error when the new name is empty or whitespace-only.
|
||||||
|
- **FR-005**: System MUST preserve turn order (active index) and round number when a combatant is renamed.
|
||||||
|
- **FR-006**: System MUST preserve the combatant's position in the combatant list (no reordering).
|
||||||
|
- **FR-007**: The user interface MUST provide a way to trigger a name edit for each combatant in the encounter.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Combatant**: Identified by a unique id; has a mutable name. Editing updates only the name, preserving identity and list position.
|
||||||
|
- **CombatantUpdated (event)**: Records that a combatant's name changed. Contains combatant id, old name, and new name.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Users can rename any combatant in the encounter in a single action.
|
||||||
|
- **SC-002**: Renaming a combatant never disrupts turn order, active combatant, or round number.
|
||||||
|
- **SC-003**: Invalid edit attempts (missing combatant, empty name) produce a clear, actionable error message with no side effects.
|
||||||
|
- **SC-004**: The combatant's updated name is immediately visible in the encounter UI after editing.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Name validation follows the same rules as adding a combatant (reject empty and whitespace-only names).
|
||||||
|
- No uniqueness constraint on combatant names — multiple combatants may share the same name.
|
||||||
|
- MVP baseline does not include editing other combatant attributes (e.g., initiative score, HP). Only name editing is in scope.
|
||||||
|
- MVP baseline uses inline editing (click-to-edit input field) as the name editing mechanism. More complex UX (e.g., modal dialogs, undo/redo) is not in the MVP baseline.
|
||||||
147
specs/004-edit-combatant/tasks.md
Normal file
147
specs/004-edit-combatant/tasks.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Tasks: Edit Combatant
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/004-edit-combatant/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||||
|
|
||||||
|
**Tests**: Tests are included as this project follows test-driven patterns established by prior features.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Add the `CombatantUpdated` event type shared by all user stories
|
||||||
|
|
||||||
|
- [x] T001 Add `CombatantUpdated` event interface and add it to the `DomainEvent` union in `packages/domain/src/events.ts`
|
||||||
|
- [x] T002 Add `EditCombatantSuccess` interface and `editCombatant` function signature (stub returning `DomainError`) in `packages/domain/src/edit-combatant.ts`
|
||||||
|
- [x] T003 Re-export `editCombatant` and `EditCombatantSuccess` from `packages/domain/src/index.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: Domain types compile, `editCombatant` exists as a stub
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 - Rename a Combatant (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: A user can rename an existing combatant by id. The encounter state is updated in-place with a `CombatantUpdated` event emitted. Turn order and round number are preserved.
|
||||||
|
|
||||||
|
**Independent Test**: Create an encounter with combatants, edit one name, verify updated name + unchanged activeIndex/roundNumber + correct event.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||||
|
|
||||||
|
- [x] T004 [US1] Write acceptance scenario tests in `packages/domain/src/__tests__/edit-combatant.test.ts`: (1) rename succeeds with correct event containing combatantId, oldName, newName; (2) activeIndex and roundNumber preserved when renaming the active combatant; (3) combatant list order preserved; (4) renaming to same name still emits event
|
||||||
|
- [x] T005 [US1] Write invariant tests in `packages/domain/src/__tests__/edit-combatant.test.ts`: (INV-1) determinism — same inputs produce same outputs; (INV-2) exactly one event emitted on success; (INV-3) original encounter is not mutated
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T006 [US1] Implement `editCombatant` pure function in `packages/domain/src/edit-combatant.ts` — find combatant by id, validate name, return updated encounter with mapped combatants list and `CombatantUpdated` event
|
||||||
|
- [x] T007 [US1] Create `editCombatantUseCase` in `packages/application/src/edit-combatant-use-case.ts` following the pattern in `add-combatant-use-case.ts` (get → call domain → check error → save → return events)
|
||||||
|
- [x] T008 [US1] Re-export `editCombatantUseCase` from `packages/application/src/index.ts`
|
||||||
|
- [x] T009 [US1] Add `editCombatant(id: CombatantId, newName: string)` action to `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts`
|
||||||
|
- [x] T010 [US1] Add inline name editing UI for each combatant in `apps/web/src/App.tsx` — click name to edit via input field, confirm on Enter or blur
|
||||||
|
|
||||||
|
**Checkpoint**: User Story 1 fully functional — renaming works end-to-end, all tests pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 2 - Error Feedback on Invalid Edit (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Invalid edit attempts (non-existent id, empty/whitespace name) return clear errors with no side effects on encounter state.
|
||||||
|
|
||||||
|
**Independent Test**: Attempt to edit a non-existent combatant id and an empty name, verify error returned and encounter unchanged.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [x] T011 [US2] Write error scenario tests in `packages/domain/src/__tests__/edit-combatant.test.ts`: (1) non-existent id returns `"combatant-not-found"` error; (2) empty name returns `"invalid-name"` error; (3) whitespace-only name returns `"invalid-name"` error; (4) empty encounter returns `"combatant-not-found"` for any id
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T012 [US2] Add name validation (empty/whitespace check) to `editCombatant` in `packages/domain/src/edit-combatant.ts` — return `DomainError` with code `"invalid-name"` (should already be partially covered by T006; this task ensures the guard is correct and tested)
|
||||||
|
|
||||||
|
**Checkpoint**: Error paths fully tested, `pnpm check` passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Final validation across all stories
|
||||||
|
|
||||||
|
- [x] T013 Run `pnpm check` (format + lint + typecheck + test) and fix any issues
|
||||||
|
- [x] T014 Verify layer boundaries pass (`packages/domain` has no application/web imports)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — can start immediately
|
||||||
|
- **User Story 1 (Phase 2)**: Depends on Setup (T001–T003)
|
||||||
|
- **User Story 2 (Phase 3)**: Depends on Setup (T001–T003); can run in parallel with US1 for tests, but implementation builds on T006
|
||||||
|
- **Polish (Phase 4)**: Depends on all user stories being complete
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Can start after Setup — no dependencies on other stories
|
||||||
|
- **User Story 2 (P2)**: Error handling is part of the same domain function as US1; tests can be written in parallel, but implementation in T012 refines the function created in T006
|
||||||
|
|
||||||
|
### Within Each User Story
|
||||||
|
|
||||||
|
- Tests MUST be written and FAIL before implementation
|
||||||
|
- Domain function before use case
|
||||||
|
- Use case before hook
|
||||||
|
- Hook before UI
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- T004 and T005 (US1 tests) target the same file — execute sequentially
|
||||||
|
- T007 and T008 (use case + export) are sequential but fast
|
||||||
|
- T011 (US2 tests) can be written in parallel with US1 implementation (T006–T010)
|
||||||
|
- T013 and T014 (polish) can run in parallel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Write both test groups in parallel:
|
||||||
|
Task T004: "Acceptance scenario tests in packages/domain/src/__tests__/edit-combatant.test.ts"
|
||||||
|
Task T005: "Invariant tests in packages/domain/src/__tests__/edit-combatant.test.ts"
|
||||||
|
|
||||||
|
# Then implement sequentially (each depends on prior):
|
||||||
|
Task T006: Domain function → T007: Use case → T008: Export → T009: Hook → T010: UI
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Setup (T001–T003)
|
||||||
|
2. Complete Phase 2: User Story 1 (T004–T010)
|
||||||
|
3. **STOP and VALIDATE**: `pnpm check` passes, rename works in browser
|
||||||
|
4. Deploy/demo if ready
|
||||||
|
|
||||||
|
### Full Feature
|
||||||
|
|
||||||
|
1. Setup (T001–T003) → Foundation ready
|
||||||
|
2. User Story 1 (T004–T010) → Rename works end-to-end (MVP!)
|
||||||
|
3. User Story 2 (T011–T012) → Error handling complete
|
||||||
|
4. Polish (T013–T014) → Final validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- [P] tasks = different files, no dependencies
|
||||||
|
- [Story] label maps task to specific user story for traceability
|
||||||
|
- T004 and T005 both write to the same test file — execute sequentially
|
||||||
|
- Commit after each phase or logical group
|
||||||
|
- Stop at any checkpoint to validate story independently
|
||||||
34
specs/005-set-initiative/checklists/requirements.md
Normal file
34
specs/005-set-initiative/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Set Initiative
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-04
|
||||||
|
**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 validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
|
||||||
57
specs/005-set-initiative/contracts/domain-api.md
Normal file
57
specs/005-set-initiative/contracts/domain-api.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Domain API Contract: Set Initiative
|
||||||
|
|
||||||
|
## Function Signature
|
||||||
|
|
||||||
|
```
|
||||||
|
setInitiative(encounter, combatantId, value) → Success | DomainError
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| encounter | Encounter | Current encounter state |
|
||||||
|
| combatantId | CombatantId | Target combatant to update |
|
||||||
|
| value | integer or undefined | New initiative value, or undefined to clear |
|
||||||
|
|
||||||
|
### Success Result
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| encounter | Encounter | New encounter with updated combatant and reordered list |
|
||||||
|
| events | DomainEvent[] | Array containing one `InitiativeSet` event |
|
||||||
|
|
||||||
|
### Error Codes
|
||||||
|
|
||||||
|
| Code | Condition |
|
||||||
|
|------|-----------|
|
||||||
|
| `combatant-not-found` | No combatant with the given id exists |
|
||||||
|
| `invalid-initiative` | Value is defined but not an integer |
|
||||||
|
|
||||||
|
### Ordering Contract
|
||||||
|
|
||||||
|
After a successful call, `encounter.combatants` is sorted such that:
|
||||||
|
1. All combatants with `initiative !== undefined` come before those with `initiative === undefined`
|
||||||
|
2. Within the "has initiative" group: sorted descending by initiative value
|
||||||
|
3. Within the "no initiative" group: original relative order preserved
|
||||||
|
4. Equal initiative values: original relative order preserved (stable sort)
|
||||||
|
|
||||||
|
### Active Turn Contract
|
||||||
|
|
||||||
|
The combatant who was active before the call remains active after:
|
||||||
|
- `encounter.activeIndex` points to the same combatant (by identity) in the new order
|
||||||
|
- This holds even if the active combatant's own initiative changes
|
||||||
|
|
||||||
|
### Invariants Preserved
|
||||||
|
|
||||||
|
- INV-1: Empty encounters remain valid (0 combatants allowed)
|
||||||
|
- INV-2: `activeIndex` remains in bounds after reorder
|
||||||
|
- INV-3: `roundNumber` is never changed by `setInitiative`
|
||||||
|
|
||||||
|
## Use Case Signature
|
||||||
|
|
||||||
|
```
|
||||||
|
setInitiativeUseCase(store, combatantId, value) → DomainEvent[] | DomainError
|
||||||
|
```
|
||||||
|
|
||||||
|
Follows the standard use case pattern: get encounter from store, call domain function, save on success, return events or error.
|
||||||
63
specs/005-set-initiative/data-model.md
Normal file
63
specs/005-set-initiative/data-model.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Data Model: Set Initiative
|
||||||
|
|
||||||
|
## Entity Changes
|
||||||
|
|
||||||
|
### Combatant (modified)
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| id | CombatantId (branded string) | Yes | Unique identifier |
|
||||||
|
| name | string | Yes | Display name (non-empty, trimmed) |
|
||||||
|
| initiative | integer | No | Initiative value for turn ordering. Unset means "not yet rolled." |
|
||||||
|
|
||||||
|
**Validation rules**:
|
||||||
|
- `initiative` must be an integer when set (no floats, NaN, or Infinity)
|
||||||
|
- Zero and negative integers are valid
|
||||||
|
- Unset (`undefined`) is valid — combatant has not rolled initiative yet
|
||||||
|
|
||||||
|
### Encounter (unchanged structure, new ordering behavior)
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| combatants | readonly Combatant[] | Yes | Ordered list. Now sorted by initiative descending (unset last, stable sort for ties). |
|
||||||
|
| activeIndex | number | Yes | Index of the active combatant. Adjusted to follow the active combatant's identity through reorders. |
|
||||||
|
| roundNumber | number | Yes | Current round (≥ 1). Unchanged by initiative operations. |
|
||||||
|
|
||||||
|
**Ordering invariant**: After any `setInitiative` call, `combatants` is sorted such that:
|
||||||
|
1. Combatants with initiative come first, ordered highest to lowest
|
||||||
|
2. Combatants without initiative come last
|
||||||
|
3. Ties within each group preserve relative insertion order (stable sort)
|
||||||
|
|
||||||
|
## New Domain Event
|
||||||
|
|
||||||
|
### InitiativeSet
|
||||||
|
|
||||||
|
Emitted when a combatant's initiative value is set, changed, or cleared.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| type | "InitiativeSet" | Event discriminant |
|
||||||
|
| combatantId | CombatantId | The combatant whose initiative changed |
|
||||||
|
| previousValue | integer or undefined | The initiative value before the change |
|
||||||
|
| newValue | integer or undefined | The initiative value after the change |
|
||||||
|
|
||||||
|
## State Transitions
|
||||||
|
|
||||||
|
### setInitiative(encounter, combatantId, value)
|
||||||
|
|
||||||
|
**Input**: Current encounter, target combatant id, new initiative value (integer or undefined to clear)
|
||||||
|
|
||||||
|
**Output**: Updated encounter with reordered combatants and adjusted activeIndex, plus events
|
||||||
|
|
||||||
|
**Error conditions**:
|
||||||
|
- `combatant-not-found`: No combatant with the given id exists in the encounter
|
||||||
|
- `invalid-initiative`: Value is not an integer (when defined)
|
||||||
|
|
||||||
|
**Transition logic**:
|
||||||
|
1. Find target combatant by id → error if not found
|
||||||
|
2. Validate value is integer (when defined) → error if invalid
|
||||||
|
3. Record the active combatant's id (for preservation)
|
||||||
|
4. Update the target combatant's initiative value
|
||||||
|
5. Stable-sort combatants: initiative descending, unset last
|
||||||
|
6. Find the active combatant's new index in the sorted array
|
||||||
|
7. Return new encounter + `InitiativeSet` event
|
||||||
83
specs/005-set-initiative/plan.md
Normal file
83
specs/005-set-initiative/plan.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Implementation Plan: Set Initiative
|
||||||
|
|
||||||
|
**Branch**: `005-set-initiative` | **Date**: 2026-03-04 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/005-set-initiative/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add an optional integer initiative property to combatants and a `setInitiative` domain function that sets/changes/clears the value and automatically reorders combatants descending by initiative (unset last, stable sort for ties). The active combatant's turn is preserved through reorders by tracking identity rather than position.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax)
|
||||||
|
**Primary Dependencies**: React 19, Vite
|
||||||
|
**Storage**: In-memory React state (local-first, single-user MVP)
|
||||||
|
**Testing**: Vitest
|
||||||
|
**Target Platform**: Web browser (localhost:5173 dev)
|
||||||
|
**Project Type**: Web application (monorepo: domain → application → web adapter)
|
||||||
|
**Performance Goals**: N/A (local in-memory, trivial data sizes)
|
||||||
|
**Constraints**: Pure domain functions, no I/O in domain layer
|
||||||
|
**Scale/Scope**: Single-user, single encounter
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Deterministic Domain Core | PASS | `setInitiative` is a pure function: same encounter + id + value → same result. No I/O, randomness, or clocks. |
|
||||||
|
| II. Layered Architecture | PASS | Domain function in `packages/domain`, use case in `packages/application`, UI in `apps/web`. Dependency direction preserved. |
|
||||||
|
| III. Agent Boundary | N/A | No agent layer involvement in this feature. |
|
||||||
|
| IV. Clarification-First | PASS | Spec has no NEEDS CLARIFICATION markers. All design decisions are spec-driven. |
|
||||||
|
| V. Escalation Gates | PASS | Feature scope matches spec exactly. No out-of-scope additions. |
|
||||||
|
| VI. MVP Baseline Language | PASS | Spec uses "MVP baseline does not include" for secondary tiebreakers. |
|
||||||
|
| VII. No Gameplay Rules | PASS | Constitution contains no gameplay mechanics; initiative logic is in the spec. |
|
||||||
|
|
||||||
|
All gates pass. No violations to justify.
|
||||||
|
|
||||||
|
**Post-Design Re-check**: All gates still pass. The `setInitiative` domain function is pure, layering is preserved, and no out-of-scope additions were introduced during design.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/005-set-initiative/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
├── contracts/ # Phase 1 output
|
||||||
|
│ └── domain-api.md
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md # Phase 2 output (via /speckit.tasks)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
packages/domain/src/
|
||||||
|
├── types.ts # Modified: add initiative to Combatant
|
||||||
|
├── events.ts # Modified: add InitiativeSet event
|
||||||
|
├── set-initiative.ts # New: setInitiative domain function
|
||||||
|
├── index.ts # Modified: export setInitiative
|
||||||
|
└── __tests__/
|
||||||
|
└── set-initiative.test.ts # New: tests for setInitiative
|
||||||
|
|
||||||
|
packages/application/src/
|
||||||
|
├── set-initiative-use-case.ts # New: setInitiativeUseCase
|
||||||
|
└── index.ts # Modified: export use case
|
||||||
|
|
||||||
|
apps/web/src/
|
||||||
|
├── hooks/
|
||||||
|
│ └── use-encounter.ts # Modified: add setInitiative callback
|
||||||
|
└── App.tsx # Modified: add initiative input field
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Follows existing monorepo layered structure. Each new domain operation gets its own file per established convention.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations. Table not needed.
|
||||||
36
specs/005-set-initiative/quickstart.md
Normal file
36
specs/005-set-initiative/quickstart.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Quickstart: Set Initiative
|
||||||
|
|
||||||
|
## What This Feature Does
|
||||||
|
|
||||||
|
Adds an optional initiative value to combatants. When set, the encounter automatically sorts combatants from highest to lowest initiative. Combatants without initiative appear at the end. The active turn is preserved through reorders.
|
||||||
|
|
||||||
|
## Key Files to Modify
|
||||||
|
|
||||||
|
1. **`packages/domain/src/types.ts`** — Add `initiative?: number` to `Combatant`
|
||||||
|
2. **`packages/domain/src/events.ts`** — Add `InitiativeSet` event to the union
|
||||||
|
3. **`packages/domain/src/set-initiative.ts`** — New domain function (pure, no I/O)
|
||||||
|
4. **`packages/domain/src/index.ts`** — Export new function and types
|
||||||
|
5. **`packages/application/src/set-initiative-use-case.ts`** — New use case
|
||||||
|
6. **`packages/application/src/index.ts`** — Export use case
|
||||||
|
7. **`apps/web/src/hooks/use-encounter.ts`** — Add `setInitiative` callback
|
||||||
|
8. **`apps/web/src/App.tsx`** — Add initiative input next to each combatant
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Domain types + event (foundation)
|
||||||
|
2. Domain function + tests (core logic)
|
||||||
|
3. Application use case (orchestration)
|
||||||
|
4. Web adapter hook + UI (user-facing)
|
||||||
|
|
||||||
|
## How to Verify
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm check # Must pass: format + lint + typecheck + test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Patterns to Follow
|
||||||
|
|
||||||
|
- Domain functions return `{ encounter, events } | DomainError` — never throw
|
||||||
|
- Use `readonly` everywhere, create new objects via spread
|
||||||
|
- Tests live in `packages/domain/src/__tests__/`
|
||||||
|
- Use cases follow get → call → check error → save → return events
|
||||||
49
specs/005-set-initiative/research.md
Normal file
49
specs/005-set-initiative/research.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# Research: Set Initiative
|
||||||
|
|
||||||
|
## R-001: Stable Sort for Initiative Ordering
|
||||||
|
|
||||||
|
**Decision**: Use JavaScript's built-in `Array.prototype.sort()` which is guaranteed stable (ES2019+). Combatants with equal initiative retain their relative order from the original array.
|
||||||
|
|
||||||
|
**Rationale**: All modern browsers and Node.js engines implement stable sort. No external library needed. The existing codebase already relies on insertion-order preservation in array operations.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Custom merge sort implementation — unnecessary since native sort is stable.
|
||||||
|
- Separate "sort key" field — over-engineering for the current requirement.
|
||||||
|
|
||||||
|
## R-002: Active Turn Preservation Through Reorder
|
||||||
|
|
||||||
|
**Decision**: After sorting, find the new index of the combatant who was active before the sort (by `CombatantId` identity). Update `activeIndex` to point to that combatant's new position.
|
||||||
|
|
||||||
|
**Rationale**: The existing `removeCombatant` function already demonstrates the pattern of adjusting `activeIndex` to track a specific combatant through array mutations. This approach is simpler than alternatives since we can look up the active combatant's id before sorting, then find its new index after sorting.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Store active combatant as `activeCombatantId` instead of `activeIndex` — would require changing the `Encounter` type and all downstream consumers. Too broad for this feature.
|
||||||
|
- Compute a position delta — fragile and error-prone with stable sort edge cases.
|
||||||
|
|
||||||
|
## R-003: Initiative as Optional Property on Combatant
|
||||||
|
|
||||||
|
**Decision**: Add `readonly initiative?: number` to the `Combatant` interface. `undefined` means "not yet set."
|
||||||
|
|
||||||
|
**Rationale**: Matches the spec requirement for combatants without initiative (FR-005). Using `undefined` (optional property) rather than `null` aligns with TypeScript conventions and the existing codebase style (no `null` usage in domain types).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Separate `InitiativeMap` keyed by `CombatantId` — breaks co-location, complicates sorting, doesn't match the existing pattern where combatant data lives on the `Combatant` type.
|
||||||
|
- `number | null` — adds a second "empty" representation alongside `undefined`; the codebase has no precedent for `null` in domain types.
|
||||||
|
|
||||||
|
## R-004: Clearing Initiative
|
||||||
|
|
||||||
|
**Decision**: Clearing initiative means setting it to `undefined`. The `setInitiative` function accepts `number | undefined` as the value parameter. When `undefined`, the combatant moves to the end of the order (per FR-003, FR-005).
|
||||||
|
|
||||||
|
**Rationale**: Reuses the same function for set, change, and clear operations. Keeps the API surface minimal.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Separate `clearInitiative` function — unnecessary given the value can simply be `undefined`.
|
||||||
|
|
||||||
|
## R-005: Integer Validation
|
||||||
|
|
||||||
|
**Decision**: Validate that the initiative value is a safe integer using `Number.isInteger()`. Reject `NaN`, `Infinity`, and floating-point values. Accept zero and negative integers (per FR-009).
|
||||||
|
|
||||||
|
**Rationale**: `Number.isInteger()` handles all edge cases: returns false for `NaN`, `Infinity`, `-Infinity`, and non-integer numbers. Allows the full range of safe integers.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Branded `Initiative` type — adds type complexity without significant safety benefit since validation happens at the domain boundary.
|
||||||
116
specs/005-set-initiative/spec.md
Normal file
116
specs/005-set-initiative/spec.md
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
# Feature Specification: Set Initiative
|
||||||
|
|
||||||
|
**Feature Branch**: `005-set-initiative`
|
||||||
|
**Created**: 2026-03-04
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Allow setting an initiative value for combatants; when initiative is set or changed, the encounter automatically orders combatants so the highest initiative acts first."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Set Initiative for a Combatant (Priority: P1)
|
||||||
|
|
||||||
|
As a game master running an encounter, I want to assign an initiative value to a combatant so that the encounter's turn order reflects each combatant's rolled initiative.
|
||||||
|
|
||||||
|
**Why this priority**: Initiative values are the core of this feature — without them, automatic ordering cannot happen.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by setting an initiative value on a combatant and verifying the value is stored and the combatant list is reordered accordingly.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an encounter with combatant "Goblin" (no initiative set), **When** the user sets initiative to 15 for "Goblin", **Then** "Goblin" has initiative value 15.
|
||||||
|
2. **Given** an encounter with combatant "Goblin" (initiative 15), **When** the user changes initiative to 8, **Then** "Goblin" has initiative value 8.
|
||||||
|
3. **Given** an encounter with combatant "Goblin" (no initiative set), **When** the user attempts to set a non-integer initiative value, **Then** the system rejects the input and the combatant's initiative remains unset.
|
||||||
|
4. **Given** an encounter with combatant "Goblin" (initiative 15), **When** the user clears "Goblin"'s initiative, **Then** "Goblin"'s initiative is unset and "Goblin" moves to the end of the turn order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Automatic Ordering by Initiative (Priority: P1)
|
||||||
|
|
||||||
|
As a game master, I want the encounter to automatically sort combatants from highest to lowest initiative so I don't have to manually reorder them.
|
||||||
|
|
||||||
|
**Why this priority**: Automatic ordering is the primary value of initiative — it directly determines turn order.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by setting initiative values on multiple combatants and verifying the combatant list is sorted highest-first.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** combatants A (initiative 20), B (initiative 5), C (initiative 15), **When** all initiatives are set, **Then** the combatant order is A (20), C (15), B (5).
|
||||||
|
2. **Given** combatants in order A (20), C (15), B (5), **When** B's initiative is changed to 25, **Then** the order becomes B (25), A (20), C (15).
|
||||||
|
3. **Given** combatants A (initiative 10) and B (initiative 10) with the same value, **Then** their relative order is preserved (stable sort — the combatant who was added or set first stays ahead).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Combatants Without Initiative (Priority: P2)
|
||||||
|
|
||||||
|
As a game master, I want combatants who haven't had their initiative set yet to appear at the end of the turn order so that the encounter remains usable while I'm still entering initiative values.
|
||||||
|
|
||||||
|
**Why this priority**: This supports the practical workflow of entering initiatives one at a time as players roll.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by having a mix of combatants with and without initiative values and verifying ordering.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** combatants A (initiative 15), B (no initiative), C (initiative 10), **Then** the order is A (15), C (10), B (no initiative).
|
||||||
|
2. **Given** combatants A (no initiative) and B (no initiative), **Then** their relative order is preserved from when they were added.
|
||||||
|
3. **Given** combatant A (no initiative), **When** initiative is set to 12, **Then** A moves to its correct sorted position among combatants that have initiative values.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 4 - Active Turn Preservation During Reorder (Priority: P2)
|
||||||
|
|
||||||
|
As a game master mid-encounter, I want the active combatant's turn to be preserved when initiative changes cause a reorder so that I don't lose track of whose turn it is.
|
||||||
|
|
||||||
|
**Why this priority**: Changing initiative mid-encounter (e.g., due to a delayed action or correction) must not disrupt the current turn.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by setting the active combatant, changing another combatant's initiative, and verifying the active turn still points to the same combatant.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** it is combatant B's turn (activeIndex points to B), **When** combatant A's initiative is changed causing a reorder, **Then** the active turn still points to combatant B.
|
||||||
|
2. **Given** it is combatant A's turn, **When** combatant A's own initiative is changed causing a reorder, **Then** the active turn still points to combatant A.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when a combatant is added without initiative during an ongoing encounter? They appear at the end of the order.
|
||||||
|
- What happens when all combatants have the same initiative value? Their relative order is preserved (insertion order).
|
||||||
|
- What happens when initiative is set to zero? Zero is a valid initiative value and is treated normally in sorting.
|
||||||
|
- What happens when initiative is set to a negative number? Negative values are valid initiative values (some game systems use them).
|
||||||
|
- What happens when initiative is removed/cleared from a combatant? The combatant moves to the end of the order (treated as unset).
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: System MUST allow setting an integer initiative value for any combatant in an encounter.
|
||||||
|
- **FR-002**: System MUST allow changing an existing initiative value for a combatant.
|
||||||
|
- **FR-003**: System MUST allow clearing a combatant's initiative value (returning it to unset).
|
||||||
|
- **FR-004**: System MUST automatically reorder combatants from highest to lowest initiative whenever an initiative value is set, changed, or cleared.
|
||||||
|
- **FR-005**: System MUST place combatants without an initiative value after all combatants that have initiative values.
|
||||||
|
- **FR-006**: System MUST use a stable sort so that combatants with equal initiative (or multiple combatants without initiative) retain their relative order.
|
||||||
|
- **FR-007**: System MUST preserve the active combatant's turn when reordering occurs — the active turn tracks the combatant identity, not the position.
|
||||||
|
- **FR-008**: System MUST reject non-integer initiative values and return an error.
|
||||||
|
- **FR-009**: System MUST accept zero and negative integers as valid initiative values.
|
||||||
|
- **FR-010**: System MUST emit a domain event when a combatant's initiative is set or changed.
|
||||||
|
|
||||||
|
### Key Entities
|
||||||
|
|
||||||
|
- **Combatant**: Gains an optional initiative property (integer or unset). When set, determines the combatant's position in the encounter's turn order.
|
||||||
|
- **Encounter**: Combatant ordering becomes initiative-driven. The `activeIndex` must track the active combatant's identity through reorders.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: Users can set initiative for any combatant in a single action.
|
||||||
|
- **SC-002**: After setting or changing any initiative value, the encounter's combatant order immediately reflects the correct descending initiative sort.
|
||||||
|
- **SC-003**: The active combatant's turn is never lost or shifted to a different combatant due to an initiative-driven reorder.
|
||||||
|
- **SC-004**: Combatants without initiative are always displayed after combatants with initiative values.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Initiative values are integers (no decimals). This matches common tabletop RPG conventions.
|
||||||
|
- There is no initiative "roll" or randomization in the domain — the user provides the final initiative value. Dice rolling is outside scope.
|
||||||
|
- Tiebreaking for equal initiative values uses stable sort (preserves existing relative order). MVP baseline does not include secondary tiebreakers (e.g., Dexterity modifier).
|
||||||
|
- Clearing initiative is supported to allow corrections (e.g., a combatant hasn't rolled yet).
|
||||||
179
specs/005-set-initiative/tasks.md
Normal file
179
specs/005-set-initiative/tasks.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# Tasks: Set Initiative
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/005-set-initiative/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/domain-api.md, quickstart.md
|
||||||
|
|
||||||
|
**Tests**: Tests are included as this project follows TDD conventions (test files exist for all domain functions).
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
|
## 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: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: No new project setup needed — existing monorepo. This phase covers foundational type and event changes shared across all user stories.
|
||||||
|
|
||||||
|
- [x] T001 Add optional `initiative` property to `Combatant` interface in `packages/domain/src/types.ts`
|
||||||
|
- [x] T002 Add `InitiativeSet` event type (with `combatantId`, `previousValue`, `newValue` fields) to `DomainEvent` union in `packages/domain/src/events.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: Types compile, existing tests still pass (`pnpm check`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 + User Story 2 — Set Initiative & Automatic Ordering (Priority: P1) MVP
|
||||||
|
|
||||||
|
**Goal**: Users can set/change/clear initiative values on combatants, and the encounter automatically reorders combatants from highest to lowest initiative.
|
||||||
|
|
||||||
|
**Independent Test**: Set initiative on multiple combatants and verify the combatant list is sorted descending by initiative value.
|
||||||
|
|
||||||
|
### Tests for User Stories 1 & 2
|
||||||
|
|
||||||
|
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
|
||||||
|
|
||||||
|
- [x] T003 [US1] Write acceptance tests for setting initiative (set, change, reject non-integer) in `packages/domain/src/__tests__/set-initiative.test.ts`
|
||||||
|
- [x] T004 [US2] Write acceptance tests for automatic ordering (descending sort, stable sort for ties, reorder on change) in `packages/domain/src/__tests__/set-initiative.test.ts`
|
||||||
|
- [x] T005 Write invariant tests (determinism, immutability, event shape, roundNumber unchanged) in `packages/domain/src/__tests__/set-initiative.test.ts`
|
||||||
|
|
||||||
|
### Implementation for User Stories 1 & 2
|
||||||
|
|
||||||
|
- [x] T006 [US1] [US2] Implement `setInitiative(encounter, combatantId, value)` domain function in `packages/domain/src/set-initiative.ts` — validate combatant exists, validate integer, update initiative, stable-sort descending, emit `InitiativeSet` event
|
||||||
|
- [x] T007 Export `setInitiative` and related types from `packages/domain/src/index.ts`
|
||||||
|
- [x] T008 Implement `setInitiativeUseCase(store, combatantId, value)` in `packages/application/src/set-initiative-use-case.ts` following existing use case pattern (get → call → check error → save → return events)
|
||||||
|
- [x] T009 Export `setInitiativeUseCase` from `packages/application/src/index.ts`
|
||||||
|
|
||||||
|
**Checkpoint**: Domain tests pass, `pnpm check` passes. Core initiative logic is complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 3 — Combatants Without Initiative (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Combatants without initiative appear after all combatants with initiative, preserving their relative order.
|
||||||
|
|
||||||
|
**Independent Test**: Create a mix of combatants with and without initiative and verify ordering (initiative-set first descending, then unset in insertion order).
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [x] T010 [US3] Write acceptance tests for unset-initiative ordering (unset after set, multiple unset preserve order, setting initiative moves combatant up) in `packages/domain/src/__tests__/set-initiative.test.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 3
|
||||||
|
|
||||||
|
- [x] T011 [US3] Verify that sort logic in `packages/domain/src/set-initiative.ts` already handles `undefined` initiative correctly (combatants without initiative sort after those with initiative, stable sort within each group) — add handling if not already present in T006
|
||||||
|
|
||||||
|
**Checkpoint**: All ordering scenarios pass including mixed set/unset combatants.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 4 — Active Turn Preservation During Reorder (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: The active combatant's turn is preserved when initiative changes cause the combatant list to be reordered.
|
||||||
|
|
||||||
|
**Independent Test**: Set active combatant, change another combatant's initiative causing reorder, verify active turn still points to the same combatant.
|
||||||
|
|
||||||
|
### Tests for User Story 4
|
||||||
|
|
||||||
|
- [x] T012 [US4] Write acceptance tests for active turn preservation (reorder doesn't shift active turn, active combatant's own initiative change preserves turn) in `packages/domain/src/__tests__/set-initiative.test.ts`
|
||||||
|
|
||||||
|
### Implementation for User Story 4
|
||||||
|
|
||||||
|
- [x] T013 [US4] Verify that `activeIndex` identity-tracking in `packages/domain/src/set-initiative.ts` works correctly when reordering occurs — the logic (record active id before sort, find new index after sort) should already exist from T006; add or fix if needed
|
||||||
|
|
||||||
|
**Checkpoint**: Active turn is preserved through all reorder scenarios. `pnpm check` passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Web Adapter Integration
|
||||||
|
|
||||||
|
**Purpose**: Wire initiative into the React UI so users can actually set initiative values.
|
||||||
|
|
||||||
|
- [x] T014 Add `setInitiative` callback to `useEncounter` hook in `apps/web/src/hooks/use-encounter.ts` — call `setInitiativeUseCase`, handle errors, append events
|
||||||
|
- [x] T015 Add initiative input field next to each combatant in `apps/web/src/App.tsx` — numeric input, display current value, clear button, call `setInitiative` on change
|
||||||
|
|
||||||
|
**Checkpoint**: Full feature works end-to-end in the browser. `pnpm check` passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Edge case coverage and final validation.
|
||||||
|
|
||||||
|
- [x] T016 Write edge case tests (zero initiative, negative initiative, clearing initiative, all same value) in `packages/domain/src/__tests__/set-initiative.test.ts`
|
||||||
|
- [x] T017 Run `pnpm check` (format + lint + typecheck + test) and fix any issues
|
||||||
|
- [x] T018 Verify layer boundary compliance (domain imports no framework/adapter code)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Phase 1 (Setup)**: No dependencies — types and events first
|
||||||
|
- **Phase 2 (US1+US2 MVP)**: Depends on Phase 1
|
||||||
|
- **Phase 3 (US3)**: Depends on Phase 2 (extends sort logic)
|
||||||
|
- **Phase 4 (US4)**: Depends on Phase 2 (extends activeIndex logic)
|
||||||
|
- **Phase 5 (Web Adapter)**: Depends on Phases 2–4 (needs complete domain + application layer)
|
||||||
|
- **Phase 6 (Polish)**: Depends on all previous phases
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **US1 + US2 (P1)**: Combined because sorting is inherent to setting initiative — they share the same domain function
|
||||||
|
- **US3 (P2)**: Extends the sort logic from US1+US2 to handle `undefined`. Can be developed immediately after Phase 2.
|
||||||
|
- **US4 (P2)**: Extends the `activeIndex` logic from US1+US2. Can be developed in parallel with US3.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- **T001 and T002** can run in parallel (different files)
|
||||||
|
- **T003, T004, T005** can run in parallel (same file but different test groups — practically written together)
|
||||||
|
- **US3 (Phase 3) and US4 (Phase 4)** can run in parallel after Phase 2
|
||||||
|
- **T014 and T015** can run in parallel (different files)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: Phase 2 (MVP)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Tests first (all in same file, written together):
|
||||||
|
T003: Acceptance tests for setting initiative
|
||||||
|
T004: Acceptance tests for automatic ordering
|
||||||
|
T005: Invariant tests
|
||||||
|
|
||||||
|
# Then implementation:
|
||||||
|
T006: Domain function (core logic)
|
||||||
|
T007: Domain exports
|
||||||
|
T008: Application use case (after T006-T007)
|
||||||
|
T009: Application exports
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Stories 1 + 2)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Type + event changes
|
||||||
|
2. Complete Phase 2: Domain function + use case with tests
|
||||||
|
3. **STOP and VALIDATE**: `pnpm check` passes, initiative setting and ordering works
|
||||||
|
4. Optionally wire up UI (Phase 5) for a minimal demo
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Phase 1 → Types ready
|
||||||
|
2. Phase 2 → MVP: set initiative + auto-ordering works
|
||||||
|
3. Phase 3 → Unset combatants handled correctly
|
||||||
|
4. Phase 4 → Active turn preserved through reorders
|
||||||
|
5. Phase 5 → UI wired up, feature usable in browser
|
||||||
|
6. Phase 6 → Edge cases covered, quality verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- US1 and US2 are combined in Phase 2 because the domain function `setInitiative` inherently performs both setting and sorting — they cannot be meaningfully separated
|
||||||
|
- US3 and US4 are separable extensions of the sort and activeIndex logic respectively
|
||||||
|
- All domain tests follow existing patterns: helper functions for test data, acceptance scenarios mapped from spec, invariant tests for determinism/immutability
|
||||||
|
- Commit after each phase checkpoint
|
||||||
34
specs/006-pre-commit-gate/checklists/requirements.md
Normal file
34
specs/006-pre-commit-gate/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Pre-Commit Gate
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-05
|
||||||
|
**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`.
|
||||||
67
specs/006-pre-commit-gate/plan.md
Normal file
67
specs/006-pre-commit-gate/plan.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Implementation Plan: Pre-Commit Gate
|
||||||
|
|
||||||
|
**Branch**: `006-pre-commit-gate` | **Date**: 2026-03-05 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/006-pre-commit-gate/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Enforce a pre-commit quality gate by adding Lefthook as a Git hooks manager. A `lefthook.yml` configuration defines a pre-commit hook that runs `pnpm check`. The hook auto-installs via a `prepare` script in `package.json`, ensuring zero manual setup for developers after `pnpm install`.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.x (project), Go binary via npm (Lefthook)
|
||||||
|
**Primary Dependencies**: `lefthook` (npm devDependency)
|
||||||
|
**Storage**: N/A
|
||||||
|
**Testing**: Manual verification (commit with passing/failing checks)
|
||||||
|
**Target Platform**: macOS, Linux (developer workstations)
|
||||||
|
**Project Type**: Monorepo (pnpm workspaces) -- web application with domain/application/web layers
|
||||||
|
**Performance Goals**: No additional overhead beyond `pnpm check` execution time
|
||||||
|
**Constraints**: Must auto-install on `pnpm install`; must not interfere with CI
|
||||||
|
**Scale/Scope**: 2 files changed (lefthook.yml created, package.json modified)
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Deterministic Domain Core | N/A | No domain logic changes |
|
||||||
|
| II. Layered Architecture | PASS | Tooling-only change, no layer imports affected |
|
||||||
|
| III. Agent Boundary | N/A | No agent layer changes |
|
||||||
|
| IV. Clarification-First | PASS | User explicitly specified Lefthook; no ambiguity |
|
||||||
|
| V. Escalation Gates | PASS | Feature is within spec scope |
|
||||||
|
| VI. MVP Baseline Language | PASS | No permanent bans introduced |
|
||||||
|
| VII. No Gameplay Rules | N/A | Not a gameplay feature |
|
||||||
|
| Development Workflow (merge gate) | PASS | Directly implements the "automated checks must pass" rule |
|
||||||
|
| Layer boundary compliance | N/A | No source code layer changes |
|
||||||
|
|
||||||
|
**Post-design re-check**: All gates still pass. No design decisions introduced new violations.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/006-pre-commit-gate/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
├── spec.md # Feature specification
|
||||||
|
└── checklists/
|
||||||
|
└── requirements.md # Spec quality checklist
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
./
|
||||||
|
├── lefthook.yml # NEW — Lefthook configuration (pre-commit hook)
|
||||||
|
├── package.json # MODIFIED — add lefthook devDep + prepare script
|
||||||
|
└── (all existing files unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: This feature only adds tooling configuration at the repository root. No source code directories are created or modified. The existing monorepo structure (`packages/*`, `apps/*`) is unchanged.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations. Table not applicable.
|
||||||
34
specs/006-pre-commit-gate/quickstart.md
Normal file
34
specs/006-pre-commit-gate/quickstart.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Quickstart: Pre-Commit Gate
|
||||||
|
|
||||||
|
## What This Feature Does
|
||||||
|
|
||||||
|
Adds a Git pre-commit hook (managed by Lefthook) that runs `pnpm check` before every commit. If any check fails, the commit is blocked.
|
||||||
|
|
||||||
|
## Files to Create/Modify
|
||||||
|
|
||||||
|
| File | Action | Purpose |
|
||||||
|
|------|--------|---------|
|
||||||
|
| `lefthook.yml` | Create | Lefthook configuration with pre-commit hook |
|
||||||
|
| `package.json` | Modify | Add `lefthook` devDependency + `prepare` script |
|
||||||
|
|
||||||
|
## Setup After Implementation
|
||||||
|
|
||||||
|
After the feature is implemented, hooks activate automatically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install # installs lefthook + runs `prepare` which calls `lefthook install`
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. Developer runs `git commit`
|
||||||
|
2. Lefthook intercepts via the Git pre-commit hook
|
||||||
|
3. Lefthook runs `pnpm check` (format + lint + typecheck + test)
|
||||||
|
4. If `pnpm check` exits 0 → commit proceeds
|
||||||
|
5. If `pnpm check` exits non-zero → commit is blocked, output shown
|
||||||
|
|
||||||
|
## Bypass
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit --no-verify # skips the pre-commit hook
|
||||||
|
```
|
||||||
45
specs/006-pre-commit-gate/research.md
Normal file
45
specs/006-pre-commit-gate/research.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Research: Pre-Commit Gate
|
||||||
|
|
||||||
|
## R1: Hook Management Tool
|
||||||
|
|
||||||
|
**Decision**: Use [Lefthook](https://github.com/evilmartians/lefthook) (npm package) as the Git hooks manager.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- User explicitly requested Lefthook.
|
||||||
|
- Lightweight, standalone Go binary distributed via npm -- no runtime dependencies.
|
||||||
|
- Simple YAML configuration (`lefthook.yml`).
|
||||||
|
- Auto-installs hooks via npm `postinstall` lifecycle script -- satisfies FR-005 (no manual setup).
|
||||||
|
- Well-maintained, widely adopted (used by n8n, Shopify, and others).
|
||||||
|
- Respects `--no-verify` by default (standard Git behavior) -- satisfies FR-006.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Husky: Popular but heavier configuration, requires `.husky/` directory with shell scripts.
|
||||||
|
- `core.hooksPath`: Native Git, but requires manual setup or custom scripts for auto-install.
|
||||||
|
- Simple `prepare` script copying a shell script: Works but no parallel jobs, no structured config.
|
||||||
|
|
||||||
|
## R2: Auto-Install Mechanism
|
||||||
|
|
||||||
|
**Decision**: Use a `prepare` script in root `package.json` that runs `lefthook install`.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- The `prepare` lifecycle hook runs automatically after `pnpm install`.
|
||||||
|
- This ensures every developer gets hooks installed without extra steps after cloning.
|
||||||
|
- Lefthook's npm package includes a `postinstall` script that can auto-install, but an explicit `prepare` script is more transparent and reliable across package managers.
|
||||||
|
- In CI environments, `CI=true` prevents the `prepare` script from running (standard npm/pnpm behavior), avoiding unnecessary hook installation in CI.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Relying solely on lefthook's built-in `postinstall`: Less transparent; behavior varies with `CI` env var.
|
||||||
|
- Manual `lefthook install` step in README: Violates FR-005.
|
||||||
|
|
||||||
|
## R3: Hook Command Strategy
|
||||||
|
|
||||||
|
**Decision**: The pre-commit hook runs `pnpm check` as a single command.
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- `pnpm check` already orchestrates format, lint, typecheck, and test in sequence.
|
||||||
|
- Running it as one command keeps the hook configuration minimal and consistent with the existing merge-gate workflow.
|
||||||
|
- Output from `pnpm check` already identifies which specific check failed (FR-004, SC-004).
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Running each check as a separate Lefthook job with `parallel: true`: Could be faster but adds configuration complexity and the existing `pnpm check` script already handles sequencing. MVP baseline does not include parallel hook jobs.
|
||||||
|
- Using `{staged_files}` for file-scoped checks: MVP baseline does not include staged-only checking per spec assumptions.
|
||||||
88
specs/006-pre-commit-gate/spec.md
Normal file
88
specs/006-pre-commit-gate/spec.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Feature Specification: Pre-Commit Gate
|
||||||
|
|
||||||
|
**Feature Branch**: `006-pre-commit-gate`
|
||||||
|
**Created**: 2026-03-05
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Enforce a pre-commit gate: block commits unless `pnpm check` passes."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Blocked Commit on Failing Checks (Priority: P1)
|
||||||
|
|
||||||
|
A developer attempts to commit code that does not pass `pnpm check` (format, lint, typecheck, or test failures). The commit is automatically rejected with a clear message indicating what failed, preventing broken code from entering the repository.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core purpose of the feature -- preventing commits that violate project quality standards.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by introducing a deliberate lint or type error, attempting to commit, and verifying the commit is blocked with an informative error message.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a developer has staged changes that fail formatting, **When** they run `git commit`, **Then** the commit is rejected and the output shows the formatting errors.
|
||||||
|
2. **Given** a developer has staged changes that fail linting, **When** they run `git commit`, **Then** the commit is rejected and the output shows the lint errors.
|
||||||
|
3. **Given** a developer has staged changes that fail typechecking, **When** they run `git commit`, **Then** the commit is rejected and the output shows the typecheck errors.
|
||||||
|
4. **Given** a developer has staged changes that fail tests, **When** they run `git commit`, **Then** the commit is rejected and the output shows the test failures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Successful Commit on Passing Checks (Priority: P1)
|
||||||
|
|
||||||
|
A developer commits code that passes all checks. The pre-commit gate runs `pnpm check`, all checks pass, and the commit proceeds normally without extra friction.
|
||||||
|
|
||||||
|
**Why this priority**: Equally critical -- the gate must not block valid commits. A gate that only blocks but never allows is useless.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by making a valid code change, committing, and verifying the commit succeeds after checks pass.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a developer has staged changes that pass all checks, **When** they run `git commit`, **Then** `pnpm check` runs and the commit completes successfully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Bypass Gate in Emergencies (Priority: P2)
|
||||||
|
|
||||||
|
A developer needs to bypass the pre-commit gate in an emergency situation (e.g., a hotfix where the existing codebase already has a known issue). They can use the standard Git `--no-verify` flag to skip the hook.
|
||||||
|
|
||||||
|
**Why this priority**: Important escape hatch, but not the primary use case. Standard Git behavior should be preserved.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by attempting `git commit --no-verify` with failing checks and verifying the commit succeeds.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a developer has staged changes that fail checks, **When** they run `git commit --no-verify`, **Then** the commit proceeds without running the pre-commit gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when `pnpm` is not installed or not in PATH? The hook should fail with a clear error message.
|
||||||
|
- What happens when `node_modules` are not installed? The hook should fail with a clear error message suggesting `pnpm install`.
|
||||||
|
- What happens when the hook is run outside the project root? The hook should resolve the project root correctly.
|
||||||
|
- What happens on a fresh clone? The hook must be automatically available after `pnpm install` without additional manual steps.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The repository MUST include a Git pre-commit hook that runs `pnpm check` before every commit.
|
||||||
|
- **FR-002**: The hook MUST block the commit (exit non-zero) if `pnpm check` fails.
|
||||||
|
- **FR-003**: The hook MUST allow the commit (exit zero) if `pnpm check` succeeds.
|
||||||
|
- **FR-004**: The hook MUST display the output from `pnpm check` so the developer can see what failed.
|
||||||
|
- **FR-005**: The hook MUST be automatically available to all developers after cloning and running `pnpm install` (no manual hook installation steps).
|
||||||
|
- **FR-006**: The hook MUST be bypassable using the standard `git commit --no-verify` flag.
|
||||||
|
- **FR-007**: The hook MUST provide a clear error message if `pnpm` is not available.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: 100% of commits made without `--no-verify` are validated by `pnpm check` before being accepted.
|
||||||
|
- **SC-002**: Developers see check results within the normal `pnpm check` execution time -- the hook adds no meaningful overhead beyond running the checks themselves.
|
||||||
|
- **SC-003**: New contributors can clone the repository, run `pnpm install`, and have the pre-commit gate active without any additional setup steps.
|
||||||
|
- **SC-004**: Developers can identify the specific failing check (format, lint, typecheck, or test) from the hook output alone.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The project already has a working `pnpm check` command that runs format, lint, typecheck, and test checks.
|
||||||
|
- All developers use Git for version control.
|
||||||
|
- The hook management approach uses Lefthook, a lightweight Git hooks manager distributed as an npm package, with a `prepare` script for auto-installation.
|
||||||
|
- MVP baseline does not include partial/staged-only checking (e.g., lint-staged). The full `pnpm check` runs on the entire project.
|
||||||
105
specs/006-pre-commit-gate/tasks.md
Normal file
105
specs/006-pre-commit-gate/tasks.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Tasks: Pre-Commit Gate
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/006-pre-commit-gate/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, quickstart.md
|
||||||
|
|
||||||
|
**Tests**: No test tasks included (not requested in feature specification). Verification is manual.
|
||||||
|
|
||||||
|
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
|
||||||
|
|
||||||
|
## 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: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Install Lefthook and configure auto-install mechanism
|
||||||
|
|
||||||
|
- [x] T001 Add `lefthook` as a devDependency and add a `prepare` script that runs `lefthook install` in `package.json`
|
||||||
|
- [x] T002 Run `pnpm install` to install lefthook and activate the prepare script
|
||||||
|
|
||||||
|
**Checkpoint**: `lefthook` is installed and `pnpm install` triggers `lefthook install` automatically
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Story 1 & 2 - Block Failing / Allow Passing Commits (Priority: P1) MVP
|
||||||
|
|
||||||
|
**Goal**: Create the Lefthook pre-commit hook configuration that runs `pnpm check`, blocking commits on failure and allowing commits on success.
|
||||||
|
|
||||||
|
**Independent Test (US1)**: Introduce a deliberate lint error, run `git commit`, and verify the commit is blocked with visible error output.
|
||||||
|
|
||||||
|
**Independent Test (US2)**: Make a valid change, run `git commit`, and verify the commit succeeds after `pnpm check` passes.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
- [x] T003 [US1] [US2] Create `lefthook.yml` at repository root with a `pre-commit` hook that runs `pnpm check`
|
||||||
|
|
||||||
|
**Checkpoint**: Commits are blocked when `pnpm check` fails (US1) and allowed when it passes (US2). Output from `pnpm check` is visible to the developer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 3 - Bypass Gate in Emergencies (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Ensure the standard `git commit --no-verify` flag bypasses the pre-commit hook.
|
||||||
|
|
||||||
|
**Independent Test**: Stage a change that would fail checks, run `git commit --no-verify`, and verify the commit succeeds without running checks.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
No implementation task needed -- Lefthook respects `--no-verify` by default (standard Git behavior). This phase exists for verification only.
|
||||||
|
|
||||||
|
**Checkpoint**: `git commit --no-verify` bypasses the pre-commit gate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Edge case handling and validation
|
||||||
|
|
||||||
|
- [x] T004 Verify the hook provides a clear error when `pnpm` is not in PATH (FR-007) and when `node_modules` are missing (edge case)
|
||||||
|
- [x] T005 Run quickstart.md validation: clone-install-commit workflow works end-to-end (SC-003)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies -- start immediately
|
||||||
|
- **US1 & US2 (Phase 2)**: Depends on Phase 1 (lefthook must be installed)
|
||||||
|
- **US3 (Phase 3)**: No implementation needed -- verify after Phase 2
|
||||||
|
- **Polish (Phase 4)**: Depends on Phase 2 completion
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- T001 and T003 touch different files (`package.json` vs `lefthook.yml`) but T003 depends on lefthook being installed, so they must be sequential.
|
||||||
|
- T004 and T005 can run in parallel after Phase 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Stories 1 & 2)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Install lefthook + prepare script
|
||||||
|
2. Complete Phase 2: Create `lefthook.yml` with pre-commit hook
|
||||||
|
3. **STOP and VALIDATE**: Test both blocking and allowing commits
|
||||||
|
4. Verify US3 bypass works (no implementation needed)
|
||||||
|
|
||||||
|
### Execution Summary
|
||||||
|
|
||||||
|
Total: **5 tasks** across 4 phases
|
||||||
|
- Phase 1 (Setup): 2 tasks
|
||||||
|
- Phase 2 (US1 + US2): 1 task
|
||||||
|
- Phase 3 (US3): 0 tasks (verification only)
|
||||||
|
- Phase 4 (Polish): 2 tasks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- US1 and US2 are implemented by the same single task (T003) because they are two sides of the same coin: the hook either blocks or allows based on `pnpm check` exit code.
|
||||||
|
- US3 requires no implementation -- `--no-verify` is standard Git behavior that Lefthook respects.
|
||||||
|
- This is a minimal-footprint feature: 1 new file (`lefthook.yml`), 1 modified file (`package.json`).
|
||||||
34
specs/007-add-knip/checklists/requirements.md
Normal file
34
specs/007-add-knip/checklists/requirements.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Specification Quality Checklist: Add Knip Unused Code Detection
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-03-05
|
||||||
|
**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`.
|
||||||
24
specs/007-add-knip/data-model.md
Normal file
24
specs/007-add-knip/data-model.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Data Model: Add Knip Unused Code Detection
|
||||||
|
|
||||||
|
**Date**: 2026-03-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This feature is a developer tooling integration. It introduces no domain entities, state transitions, or persistent data. The only artifacts are configuration files.
|
||||||
|
|
||||||
|
## Configuration Artifacts
|
||||||
|
|
||||||
|
### Knip Configuration (`knip.json`)
|
||||||
|
|
||||||
|
- **Purpose**: Defines workspace scope and any overrides for Knip's auto-detection.
|
||||||
|
- **Location**: Repository root.
|
||||||
|
- **Key fields**: `$schema`, `workspaces` (maps workspace glob patterns to per-workspace config).
|
||||||
|
|
||||||
|
### Root Package Scripts (modification)
|
||||||
|
|
||||||
|
- **Artifact**: `package.json` `scripts` field.
|
||||||
|
- **Change**: Add `knip` script; update `check` script to include Knip in the quality gate chain.
|
||||||
|
|
||||||
|
## No Domain Impact
|
||||||
|
|
||||||
|
This feature does not modify domain types, application use cases, or adapter code. It only adds a static analysis tool to the build/check pipeline.
|
||||||
128
specs/007-add-knip/plan.md
Normal file
128
specs/007-add-knip/plan.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# Implementation Plan: Add Knip Unused Code Detection
|
||||||
|
|
||||||
|
**Branch**: `007-add-knip` | **Date**: 2026-03-05 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/007-add-knip/spec.md`
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Add Knip v5 as a root devDependency to detect unused files, exports, dependencies, devDependencies, and types across the pnpm workspace. Configure it with a workspace-aware `knip.json`, expose a standalone `pnpm knip` command, and integrate it into the existing `pnpm check` quality gate so it runs on every commit via Lefthook.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: TypeScript 5.x (strict mode, verbatimModuleSyntax)
|
||||||
|
**Primary Dependencies**: Knip v5 (new), Biome 2.0, Vitest, Vite 6, React 19
|
||||||
|
**Storage**: N/A
|
||||||
|
**Testing**: Vitest (existing); manual verification of Knip output
|
||||||
|
**Target Platform**: Node.js (developer tooling)
|
||||||
|
**Project Type**: pnpm monorepo (packages/domain, packages/application, apps/web)
|
||||||
|
**Performance Goals**: N/A (developer-time static analysis)
|
||||||
|
**Constraints**: Must pass on the current codebase with zero false positives
|
||||||
|
**Scale/Scope**: 3 workspace packages
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
| Principle | Status | Notes |
|
||||||
|
|-----------|--------|-------|
|
||||||
|
| I. Deterministic Domain Core | PASS | No domain changes. |
|
||||||
|
| II. Layered Architecture | PASS | No layer changes; Knip is root-level tooling only. |
|
||||||
|
| III. Agent Boundary | PASS | No agent layer involved. |
|
||||||
|
| IV. Clarification-First | PASS | Feature is well-defined; no ambiguities. |
|
||||||
|
| V. Escalation Gates | PASS | Implementing per spec. |
|
||||||
|
| VI. MVP Baseline Language | PASS | No scope restrictions introduced. |
|
||||||
|
| VII. No Gameplay Rules | PASS | Tooling feature only. |
|
||||||
|
| Dev Workflow: Automated checks | PASS | Knip becomes part of the merge gate (`pnpm check`). |
|
||||||
|
|
||||||
|
**Post-design re-check**: All gates still pass. No design decisions impact domain, layers, or architectural boundaries.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/007-add-knip/
|
||||||
|
├── spec.md
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
└── checklists/
|
||||||
|
└── requirements.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Files modified
|
||||||
|
package.json # Add knip devDep, add "knip" script, update "check" script
|
||||||
|
|
||||||
|
# Files created
|
||||||
|
knip.json # Root-level Knip workspace configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: No new source directories. This feature only adds a config file and modifies the root `package.json` scripts. The existing monorepo structure (`packages/*`, `apps/*`) is referenced by Knip's workspace config but not changed.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### 1. Install Knip
|
||||||
|
|
||||||
|
Add `knip` as a root devDependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add -Dw knip
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create `knip.json`
|
||||||
|
|
||||||
|
Root-level configuration leveraging auto-detection:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||||
|
"entry": ["scripts/*.mjs"],
|
||||||
|
"workspaces": {
|
||||||
|
"packages/*": {},
|
||||||
|
"apps/*": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Knip auto-detects:
|
||||||
|
- pnpm workspace packages from `pnpm-workspace.yaml`
|
||||||
|
- Entry points from each `package.json` (`main`, `exports`, `types`)
|
||||||
|
- TypeScript config from `tsconfig.json` files (including project references)
|
||||||
|
- Vitest test patterns from `vitest.config.ts`
|
||||||
|
- Vite config and plugins from `vite.config.ts`
|
||||||
|
- Biome config from `biome.json`
|
||||||
|
|
||||||
|
### 3. Update `package.json` Scripts
|
||||||
|
|
||||||
|
Add standalone command and integrate into quality gate:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"knip": "knip",
|
||||||
|
"check": "knip && biome check . && tsc --build && vitest run"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Knip runs first because it's fast and catches structural issues before heavier checks.
|
||||||
|
|
||||||
|
### 4. Validate
|
||||||
|
|
||||||
|
1. Run `pnpm knip` — must pass clean on the current codebase (SC-002).
|
||||||
|
2. If false positives appear, tune `knip.json` with `ignore`, `ignoreDependencies`, or plugin-specific overrides.
|
||||||
|
3. Run `pnpm check` — full gate must pass (SC-001).
|
||||||
|
4. Introduce an intentional unused export → verify `pnpm knip` catches it (SC-001).
|
||||||
|
5. Remove the intentional unused export → verify clean again.
|
||||||
|
|
||||||
|
### 5. Update Agent Context
|
||||||
|
|
||||||
|
Run `.specify/scripts/bash/update-agent-context.sh claude` to register Knip as a project technology.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
No constitution violations. No complexity justifications needed.
|
||||||
29
specs/007-add-knip/quickstart.md
Normal file
29
specs/007-add-knip/quickstart.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Quickstart: Add Knip Unused Code Detection
|
||||||
|
|
||||||
|
**Date**: 2026-03-05
|
||||||
|
|
||||||
|
## What This Feature Does
|
||||||
|
|
||||||
|
Adds Knip to the project to detect unused files, exports, dependencies, devDependencies, and types across the pnpm workspace. Enforces it as part of the `pnpm check` quality gate (which runs on every commit via Lefthook).
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `package.json` | Add `knip` devDependency; add `knip` script; update `check` script |
|
||||||
|
| `knip.json` (new) | Workspace-aware Knip configuration |
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run unused-code check standalone
|
||||||
|
pnpm knip
|
||||||
|
|
||||||
|
# Run full quality gate (now includes Knip)
|
||||||
|
pnpm check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `pnpm check` passes on the current codebase (no false positives).
|
||||||
|
2. Add an unused export to any file → `pnpm knip` reports it → `pnpm check` fails.
|
||||||
41
specs/007-add-knip/research.md
Normal file
41
specs/007-add-knip/research.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Research: Add Knip Unused Code Detection
|
||||||
|
|
||||||
|
**Date**: 2026-03-05
|
||||||
|
|
||||||
|
## Decision 1: Knip Configuration Approach
|
||||||
|
|
||||||
|
**Decision**: Use workspace-aware `knip.json` at the repo root with minimal explicit configuration, relying on Knip's auto-detection for plugins and entry points.
|
||||||
|
|
||||||
|
**Rationale**: Knip v5 auto-detects pnpm workspaces from `pnpm-workspace.yaml` and enables plugins (Vite, Vitest, TypeScript, Biome) based on `package.json` dependencies. The project follows standard conventions, so auto-detection covers most cases. Explicit workspace entries in `knip.json` provide a safety net and clear documentation of scope.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Zero config (no `knip.json`): Works but less explicit; harder for contributors to understand what's scanned.
|
||||||
|
- Per-package configs: Unnecessary complexity; root-level workspace config covers the monorepo.
|
||||||
|
|
||||||
|
## Decision 2: Quality Gate Integration
|
||||||
|
|
||||||
|
**Decision**: Add `knip` as a separate script in root `package.json` and chain it into the existing `check` script.
|
||||||
|
|
||||||
|
**Rationale**: The current `check` script is `biome check . && tsc --build && vitest run`. Adding `knip` to this chain (e.g., `knip && biome check . && ...`) makes it part of the pre-commit gate via Lefthook without any Lefthook config changes. Running Knip first is efficient since it's fast and catches structural issues before heavier checks.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Separate Lefthook job: Adds config complexity; the existing single `pnpm check` job is cleaner.
|
||||||
|
- Only standalone command (not in gate): Doesn't enforce the quality bar on every commit.
|
||||||
|
|
||||||
|
## Decision 3: Handling the `scripts/` Directory
|
||||||
|
|
||||||
|
**Decision**: Configure Knip to recognize `scripts/check-layer-boundaries.mjs` as an entry point so it isn't flagged as unused.
|
||||||
|
|
||||||
|
**Rationale**: This script is imported by Vitest tests but lives outside the standard workspace packages. Knip needs to know it's intentionally referenced. The root workspace can include it as an entry pattern.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Ignoring the scripts directory entirely: Would miss actual unused scripts in the future.
|
||||||
|
|
||||||
|
## Decision 4: Knip Version
|
||||||
|
|
||||||
|
**Decision**: Install latest Knip v5 (`knip@5`).
|
||||||
|
|
||||||
|
**Rationale**: v5 is the current stable major version with full pnpm workspace support and 138+ built-in plugins.
|
||||||
|
|
||||||
|
**Alternatives considered**:
|
||||||
|
- Pinning exact version: Less flexible for patch updates; caret range (`^5`) is standard practice.
|
||||||
79
specs/007-add-knip/spec.md
Normal file
79
specs/007-add-knip/spec.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Feature Specification: Add Knip Unused Code Detection
|
||||||
|
|
||||||
|
**Feature Branch**: `007-add-knip`
|
||||||
|
**Created**: 2026-03-05
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Add Knip to the project to detect unused files, exports, dependencies, devDependencies, and types across the pnpm workspace and enforce it as part of the quality gate."
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Detect Unused Code on Commit (Priority: P1)
|
||||||
|
|
||||||
|
As a developer, I want unused files, exports, dependencies, devDependencies, and types to be automatically detected when I commit, so that dead code never accumulates in the codebase.
|
||||||
|
|
||||||
|
**Why this priority**: This is the core value proposition — catching unused code as part of the existing quality gate ensures every commit keeps the codebase clean without requiring manual effort.
|
||||||
|
|
||||||
|
**Independent Test**: Can be fully tested by introducing an unused export into any workspace package and running the quality gate; the gate should fail with a clear report identifying the unused export.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the quality gate is run, **When** all files, exports, dependencies, and types are in use, **Then** the unused-code check passes successfully.
|
||||||
|
2. **Given** a file contains an unused export, **When** the quality gate is run, **Then** the check fails and reports the specific unused export and its file location.
|
||||||
|
3. **Given** a workspace package lists a dependency that is never imported, **When** the quality gate is run, **Then** the check fails and reports the unused dependency and the package it belongs to.
|
||||||
|
4. **Given** a file exists that is not imported or referenced anywhere, **When** the quality gate is run, **Then** the check fails and reports the unused file.
|
||||||
|
5. **Given** a type or interface is exported but never imported elsewhere, **When** the quality gate is run, **Then** the check fails and reports the unused type.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Run Unused-Code Check Independently (Priority: P2)
|
||||||
|
|
||||||
|
As a developer, I want to run the unused-code detection as a standalone command, so that I can inspect and fix issues before committing.
|
||||||
|
|
||||||
|
**Why this priority**: Developers need a fast feedback loop to discover and address unused code during development, not just at commit time.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by running a dedicated command from the workspace root and verifying it produces output listing any unused items found across all workspace packages.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** the developer is at the workspace root, **When** they run the standalone unused-code command, **Then** it analyzes all workspace packages and reports results.
|
||||||
|
2. **Given** unused items exist across multiple workspace packages, **When** the standalone command is run, **Then** all unused items are reported with their package and file location.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- What happens when a file is only used as an entry point (e.g., `main` or `exports` field in `package.json`)? It must not be falsely reported as unused.
|
||||||
|
- What happens when test files import modules only used in tests? Test-only dependencies and test utilities must not be flagged.
|
||||||
|
- What happens when configuration files (e.g., `vite.config.ts`, `vitest.config.ts`) reference plugins or packages? These must not be flagged as unused.
|
||||||
|
- What happens when workspace packages cross-reference each other via the `workspace:*` protocol? Internal workspace dependencies must be recognized.
|
||||||
|
- What happens when a type is re-exported from a barrel file? The re-export chain must be traced correctly.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST detect unused files across all workspace packages (`packages/domain`, `packages/application`, `apps/web`).
|
||||||
|
- **FR-002**: The system MUST detect unused exports (functions, constants, types, interfaces) across all workspace packages.
|
||||||
|
- **FR-003**: The system MUST detect unused `dependencies` and `devDependencies` listed in each workspace package's `package.json`.
|
||||||
|
- **FR-004**: The system MUST be integrated into the existing quality gate (`pnpm check`) so that any unused code causes the gate to fail.
|
||||||
|
- **FR-005**: The system MUST provide a standalone command runnable from the workspace root to check for unused code independently of the full quality gate.
|
||||||
|
- **FR-006**: The system MUST correctly recognize entry points defined in each package's `package.json` (`main`, `exports`, `types` fields) and not flag them as unused.
|
||||||
|
- **FR-007**: The system MUST correctly handle pnpm workspace cross-references (`workspace:*` protocol) and not flag internal workspace dependencies as unused.
|
||||||
|
- **FR-008**: The system MUST correctly recognize test file patterns and not flag test-only utilities or test dependencies as unused when they are consumed by tests.
|
||||||
|
- **FR-009**: The system MUST correctly recognize configuration files and their plugin/dependency references.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: The quality gate fails when any unused file, export, dependency, or type is present, with zero false negatives for straightforward unused items.
|
||||||
|
- **SC-002**: The quality gate passes on the current codebase without false positives (no legitimate code is flagged as unused).
|
||||||
|
- **SC-003**: A developer can run the standalone unused-code check and receive results covering all workspace packages.
|
||||||
|
- **SC-004**: The unused-code report identifies the specific item (file, export name, dependency name) and its location (package and file path) for each finding.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Knip is the chosen tool for unused-code detection as specified by the user.
|
||||||
|
- The tool will be added as a root-level development dependency since it analyzes the entire workspace.
|
||||||
|
- Knip's built-in support for pnpm workspaces, TypeScript, Vitest, React, and Vite will handle most configuration automatically with minimal manual setup.
|
||||||
|
- The existing Lefthook pre-commit hook runs `pnpm check`, so adding the unused-code check to `pnpm check` automatically enforces it on every commit.
|
||||||
122
specs/007-add-knip/tasks.md
Normal file
122
specs/007-add-knip/tasks.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Tasks: Add Knip Unused Code Detection
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/007-add-knip/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, quickstart.md
|
||||||
|
|
||||||
|
**Tests**: No automated test tasks — this is a tooling feature validated by running `pnpm knip` and `pnpm check`.
|
||||||
|
|
||||||
|
**Organization**: Tasks follow the two user stories (US1: quality gate enforcement, US2: standalone command).
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
- Include exact file paths in descriptions
|
||||||
|
|
||||||
|
## Phase 1: Setup
|
||||||
|
|
||||||
|
**Purpose**: Install Knip and create configuration
|
||||||
|
|
||||||
|
- [x] T001 Install Knip v5 as a root devDependency via `pnpm add -Dw knip`
|
||||||
|
- [x] T002 Create `knip.json` at the repository root with workspace-aware configuration covering `packages/*` and `apps/*`, including `$schema` for editor support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Ensure Knip passes cleanly on the current codebase before integrating into the gate
|
||||||
|
|
||||||
|
**CRITICAL**: Must resolve all false positives before wiring into the quality gate
|
||||||
|
|
||||||
|
- [x] T003 Run `pnpm knip` against the current codebase and capture output
|
||||||
|
- [x] T004 If false positives are reported, tune `knip.json` with `ignore`, `ignoreDependencies`, `entry`, or plugin-specific overrides until the codebase passes cleanly (FR-006 through FR-009)
|
||||||
|
|
||||||
|
**Checkpoint**: `pnpm knip` exits with code 0 on the current codebase (SC-002)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Detect Unused Code on Commit (Priority: P1)
|
||||||
|
|
||||||
|
**Goal**: Integrate Knip into the `pnpm check` quality gate so unused code is caught on every commit via Lefthook.
|
||||||
|
|
||||||
|
**Independent Test**: Introduce an unused export in any workspace package, run `pnpm check`, and confirm it fails with a clear report. Remove the unused export and confirm `pnpm check` passes.
|
||||||
|
|
||||||
|
### Implementation for User Story 1
|
||||||
|
|
||||||
|
- [x] T005 [US1] Update the `check` script in `package.json` to prepend `knip &&` before `biome check .` so unused code is checked as part of the quality gate
|
||||||
|
- [x] T006 [US1] Run `pnpm check` end-to-end and verify it passes on the current codebase (SC-001, SC-002)
|
||||||
|
- [x] T007 [US1] Manually verify detection: (a) add a temporary unused export to `packages/domain/src/index.ts`, run `pnpm check`, confirm it fails with a report identifying the unused export and its file location (SC-001, SC-004), then remove the temporary change; (b) verify that exports re-exported through barrel files (e.g., `index.ts`) are correctly traced and not falsely flagged
|
||||||
|
|
||||||
|
**Checkpoint**: Quality gate enforces unused-code detection on every commit. US1 acceptance scenarios 1–5 are satisfied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Run Unused-Code Check Independently (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: Provide a standalone `pnpm knip` command developers can run without the full quality gate.
|
||||||
|
|
||||||
|
**Independent Test**: Run `pnpm knip` from the workspace root and verify it analyzes all three workspace packages and reports results.
|
||||||
|
|
||||||
|
### Implementation for User Story 2
|
||||||
|
|
||||||
|
- [x] T008 [US2] Add a `"knip": "knip"` script to `package.json` so developers can run `pnpm knip` independently
|
||||||
|
- [x] T009 [US2] Verify `pnpm knip` runs from the workspace root and reports results covering all workspace packages (`packages/domain`, `packages/application`, `apps/web`) (SC-003, SC-004)
|
||||||
|
|
||||||
|
**Checkpoint**: Developers can run `pnpm knip` standalone. US2 acceptance scenarios 1–2 are satisfied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Final validation and documentation
|
||||||
|
|
||||||
|
- [x] T010 Run quickstart.md validation: confirm `pnpm knip` and `pnpm check` both work as documented
|
||||||
|
- [x] T011 Update CLAUDE.md commands section if `pnpm knip` should be listed as a project command
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
|
### Phase Dependencies
|
||||||
|
|
||||||
|
- **Setup (Phase 1)**: No dependencies — start immediately
|
||||||
|
- **Foundational (Phase 2)**: Depends on Phase 1 (T001, T002 must complete first)
|
||||||
|
- **User Story 1 (Phase 3)**: Depends on Phase 2 (clean Knip pass required before wiring into gate)
|
||||||
|
- **User Story 2 (Phase 4)**: Depends on Phase 3 (T008 modifies the same `package.json` as T005, so must follow it)
|
||||||
|
- **Polish (Phase 5)**: Depends on Phases 3 and 4
|
||||||
|
|
||||||
|
### User Story Dependencies
|
||||||
|
|
||||||
|
- **User Story 1 (P1)**: Depends on Foundational phase — needs clean codebase pass before gate integration
|
||||||
|
- **User Story 2 (P2)**: Depends on US1 — T008 modifies the same `package.json` as T005, so must execute after it
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- T001 and T002 are sequential (T002 needs Knip installed for schema validation)
|
||||||
|
- T008 (US2) modifies `package.json` (same as T005), so execute sequentially after T005
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Phase 1: Install Knip, create config
|
||||||
|
2. Complete Phase 2: Ensure clean pass on current codebase
|
||||||
|
3. Complete Phase 3: Wire into quality gate
|
||||||
|
4. **STOP and VALIDATE**: `pnpm check` passes clean; intentional unused code is caught
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
1. Phase 1 + Phase 2 → Knip works locally
|
||||||
|
2. Add US1 (Phase 3) → Quality gate enforced on every commit (MVP!)
|
||||||
|
3. Add US2 (Phase 4) → Standalone `pnpm knip` command available
|
||||||
|
4. Phase 5 → Documentation updated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All tasks modify root-level files only (no domain/application/adapter changes)
|
||||||
|
- The Lefthook pre-commit hook already runs `pnpm check`, so no Lefthook config changes needed
|
||||||
|
- If Knip reports false positives in Phase 2, the most common fixes are `ignoreDependencies` for tooling packages and `entry` patterns for non-standard entry points
|
||||||
Reference in New Issue
Block a user