Implement the 026-roll-initiative feature that adds d20 roll buttons for bestiary combatants' initiative using a click-to-edit pattern (d20 icon when empty, plain text when set), plus a Roll All button in the top bar that batch-rolls for all unrolled bestiary combatants, with randomness confined to the adapter layer and the domain receiving pre-resolved dice values

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-10 16:29:09 +01:00
parent 5b0bac880d
commit d5f7b6ee36
20 changed files with 926 additions and 27 deletions

View File

@@ -77,6 +77,8 @@ The constitution (`.specify/memory/constitution.md`) governs all feature work:
- Browser localStorage (existing adapter, updated to handle empty encounters) (023-clear-encounter)
- N/A (no storage changes — purely presentational fix) (024-fix-hp-popover-overflow)
- N/A (no storage changes — purely derived from existing bestiary data) (025-display-initiative)
- TypeScript 5.8 (strict mode, verbatimModuleSyntax) + React 19, Tailwind CSS v4, Lucide React (icons), Vite 6 (026-roll-initiative)
- N/A (no storage changes — existing localStorage persistence handles initiative via `setInitiativeUseCase`) (026-roll-initiative)
## Recent Changes
- 003-remove-combatant: Added TypeScript 5.x (strict mode, verbatimModuleSyntax) + React 19, Vite

View File

@@ -1,4 +1,8 @@
import type { Creature } from "@initiative/domain";
import {
rollAllInitiativeUseCase,
rollInitiativeUseCase,
} from "@initiative/application";
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
import { useCallback, useEffect, useRef, useState } from "react";
import { ActionBar } from "./components/action-bar";
import { CombatantRow } from "./components/combatant-row";
@@ -7,6 +11,10 @@ import { TurnNavigation } from "./components/turn-navigation";
import { useBestiary } from "./hooks/use-bestiary";
import { useEncounter } from "./hooks/use-encounter";
function rollDice(): number {
return Math.floor(Math.random() * 20) + 1;
}
export function App() {
const {
encounter,
@@ -23,6 +31,7 @@ export function App() {
toggleCondition,
toggleConcentration,
addFromBestiary,
makeStore,
} = useEncounter();
const { search, getCreature, isLoaded } = useBestiary();
@@ -46,9 +55,7 @@ export function App() {
const handleCombatantStatBlock = useCallback(
(creatureId: string) => {
const creature = getCreature(
creatureId as import("@initiative/domain").CreatureId,
);
const creature = getCreature(creatureId as CreatureId);
if (creature) setSelectedCreature(creature);
},
[getCreature],
@@ -65,6 +72,17 @@ export function App() {
[isLoaded, search],
);
const handleRollInitiative = useCallback(
(id: CombatantId) => {
rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature);
},
[makeStore, getCreature],
);
const handleRollAllInitiative = useCallback(() => {
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
}, [makeStore, getCreature]);
// Auto-scroll to the active combatant when the turn changes
const activeRowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -80,9 +98,7 @@ export function App() {
if (!window.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return;
const creature = getCreature(
active.creatureId as import("@initiative/domain").CreatureId,
);
const creature = getCreature(active.creatureId as CreatureId);
if (creature) setSelectedCreature(creature);
}, [encounter.activeIndex, encounter.combatants, getCreature, isLoaded]);
@@ -96,6 +112,7 @@ export function App() {
onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn}
onClearEncounter={clearEncounter}
onRollAllInitiative={handleRollAllInitiative}
/>
</div>
@@ -132,6 +149,9 @@ export function App() {
? () => handleCombatantStatBlock(c.creatureId as string)
: undefined
}
onRollInitiative={
c.creatureId ? handleRollInitiative : undefined
}
/>
))
)}

View File

@@ -8,6 +8,7 @@ import { type Ref, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags";
import { D20Icon } from "./d20-icon";
import { HpAdjustPopover } from "./hp-adjust-popover";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
@@ -35,6 +36,7 @@ interface CombatantRowProps {
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
onToggleConcentration: (id: CombatantId) => void;
onShowStatBlock?: () => void;
onRollInitiative?: (id: CombatantId) => void;
}
function EditableName({
@@ -266,6 +268,100 @@ function AcDisplay({
);
}
function InitiativeDisplay({
initiative,
combatantId,
dimmed,
onSetInitiative,
onRollInitiative,
}: {
initiative: number | undefined;
combatantId: CombatantId;
dimmed: boolean;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRollInitiative?: (id: CombatantId) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(initiative?.toString() ?? "");
const inputRef = useRef<HTMLInputElement>(null);
const commit = useCallback(() => {
if (draft === "") {
onSetInitiative(combatantId, undefined);
} else {
const n = Number.parseInt(draft, 10);
if (!Number.isNaN(n)) {
onSetInitiative(combatantId, n);
}
}
setEditing(false);
}, [draft, combatantId, onSetInitiative]);
const startEditing = useCallback(() => {
setDraft(initiative?.toString() ?? "");
setEditing(true);
requestAnimationFrame(() => inputRef.current?.select());
}, [initiative]);
if (editing) {
return (
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={draft}
placeholder="--"
className={cn(
"h-7 w-[6ch] text-center text-sm tabular-nums",
dimmed && "opacity-50",
)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
);
}
// Empty + bestiary creature → d20 roll button
if (initiative === undefined && onRollInitiative) {
return (
<button
type="button"
onClick={() => onRollInitiative(combatantId)}
className={cn(
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-primary",
dimmed && "opacity-50",
)}
title="Roll initiative"
aria-label="Roll initiative"
>
<D20Icon className="h-5 w-5" />
</button>
);
}
// Has value → bold number, click to edit
// Empty + manual → "--" placeholder, click to edit
return (
<button
type="button"
onClick={startEditing}
className={cn(
"h-7 w-full text-center text-sm leading-7 tabular-nums transition-colors",
initiative !== undefined
? "font-medium text-foreground hover:text-primary"
: "text-muted-foreground hover:text-primary",
dimmed && "opacity-50",
)}
>
{initiative ?? "--"}
</button>
);
}
export function CombatantRow({
ref,
combatant,
@@ -279,6 +375,7 @@ export function CombatantRow({
onToggleCondition,
onToggleConcentration,
onShowStatBlock,
onRollInitiative,
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
const { id, name, initiative, maxHp, currentHp } = combatant;
const status = deriveHpStatus(currentHp, maxHp);
@@ -345,26 +442,12 @@ export function CombatantRow({
</button>
{/* Initiative */}
<Input
type="text"
inputMode="numeric"
value={initiative ?? ""}
placeholder="--"
className={cn(
"h-7 w-[6ch] text-center text-sm tabular-nums",
dimmed && "opacity-50",
)}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
onSetInitiative(id, undefined);
} else {
const n = Number.parseInt(raw, 10);
if (!Number.isNaN(n)) {
onSetInitiative(id, n);
}
}
}}
<InitiativeDisplay
initiative={initiative}
combatantId={id}
dimmed={dimmed}
onSetInitiative={onSetInitiative}
onRollInitiative={onRollInitiative}
/>
{/* Name */}

View File

@@ -0,0 +1,29 @@
interface D20IconProps {
readonly className?: string;
}
export function D20Icon({ className }: D20IconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
width="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<polygon points="20.22 16.76 20.22 7.26 12.04 2.51 3.85 7.26 3.85 16.76 12.04 21.51 20.22 16.76" />
<line x1="7.29" y1="9.26" x2="3.85" y2="7.26" />
<line x1="20.22" y1="7.26" x2="16.79" y2="9.26" />
<line x1="12.04" y1="17.44" x2="12.04" y2="21.51" />
<polygon points="12.04 17.44 20.22 16.76 16.79 9.26 12.04 17.44" />
<polygon points="12.04 17.44 7.29 9.26 3.85 16.76 12.04 17.44" />
<polygon points="12.04 2.51 7.29 9.26 16.79 9.26 12.04 2.51" />
</svg>
);
}

View File

@@ -1,5 +1,6 @@
import type { Encounter } from "@initiative/domain";
import { ChevronLeft, ChevronRight, Trash2 } from "lucide-react";
import { D20Icon } from "./d20-icon";
import { Button } from "./ui/button";
interface TurnNavigationProps {
@@ -7,6 +8,7 @@ interface TurnNavigationProps {
onAdvanceTurn: () => void;
onRetreatTurn: () => void;
onClearEncounter: () => void;
onRollAllInitiative: () => void;
}
export function TurnNavigation({
@@ -14,6 +16,7 @@ export function TurnNavigation({
onAdvanceTurn,
onRetreatTurn,
onClearEncounter,
onRollAllInitiative,
}: TurnNavigationProps) {
const hasCombatants = encounter.combatants.length > 0;
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
@@ -57,6 +60,16 @@ export function TurnNavigation({
Next
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-primary"
onClick={onRollAllInitiative}
title="Roll all initiative"
aria-label="Roll all initiative"
>
<D20Icon className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"

View File

@@ -306,5 +306,6 @@ export function useEncounter() {
toggleCondition,
toggleConcentration,
addFromBestiary,
makeStore,
} as const;
}

19
d20.svg Normal file
View File

@@ -0,0 +1,19 @@
<svg
xmlns="http://www.w3.org/2000/svg"
height="24"
width="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
>
<polygon points="20.22 16.76 20.22 7.26 12.04 2.51 3.85 7.26 3.85 16.76 12.04 21.51 20.22 16.76"/>
<line x1="7.29" y1="9.26" x2="3.85" y2="7.26"/>
<line x1="20.22" y1="7.26" x2="16.79" y2="9.26"/>
<line x1="12.04" y1="17.44" x2="12.04" y2="21.51"/>
<polygon points="12.04 17.44 20.22 16.76 16.79 9.26 12.04 17.44"/>
<polygon points="12.04 17.44 7.29 9.26 3.85 16.76 12.04 17.44"/>
<polygon points="12.04 2.51 7.29 9.26 16.79 9.26 12.04 2.51"/>
</svg>

After

Width:  |  Height:  |  Size: 667 B

View File

@@ -6,6 +6,8 @@ export { editCombatantUseCase } from "./edit-combatant-use-case.js";
export type { EncounterStore } from "./ports.js";
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
export { setAcUseCase } from "./set-ac-use-case.js";
export { setHpUseCase } from "./set-hp-use-case.js";
export { setInitiativeUseCase } from "./set-initiative-use-case.js";

View File

@@ -0,0 +1,51 @@
import {
type Creature,
type CreatureId,
calculateInitiative,
type DomainError,
type DomainEvent,
isDomainError,
rollInitiative,
setInitiative,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function rollAllInitiativeUseCase(
store: EncounterStore,
rollDice: () => number,
getCreature: (id: CreatureId) => Creature | undefined,
): DomainEvent[] | DomainError {
let encounter = store.get();
const allEvents: DomainEvent[] = [];
for (const combatant of encounter.combatants) {
if (!combatant.creatureId) continue;
if (combatant.initiative !== undefined) continue;
const creature = getCreature(combatant.creatureId);
if (!creature) continue;
const { modifier } = calculateInitiative({
dexScore: creature.abilities.dex,
cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency,
});
const value = rollInitiative(rollDice(), modifier);
if (isDomainError(value)) {
return value;
}
const result = setInitiative(encounter, combatant.id, value);
if (isDomainError(result)) {
return result;
}
encounter = result.encounter;
allEvents.push(...result.events);
}
store.save(encounter);
return allEvents;
}

View File

@@ -0,0 +1,67 @@
import {
type CombatantId,
type Creature,
type CreatureId,
calculateInitiative,
type DomainError,
type DomainEvent,
isDomainError,
rollInitiative,
setInitiative,
} from "@initiative/domain";
import type { EncounterStore } from "./ports.js";
export function rollInitiativeUseCase(
store: EncounterStore,
combatantId: CombatantId,
diceRoll: number,
getCreature: (id: CreatureId) => Creature | undefined,
): DomainEvent[] | DomainError {
const encounter = store.get();
const combatant = encounter.combatants.find((c) => c.id === combatantId);
if (!combatant) {
return {
kind: "domain-error",
code: "combatant-not-found",
message: `No combatant found with ID "${combatantId}"`,
};
}
if (!combatant.creatureId) {
return {
kind: "domain-error",
code: "no-creature-link",
message: `Combatant "${combatant.name}" has no linked creature`,
};
}
const creature = getCreature(combatant.creatureId);
if (!creature) {
return {
kind: "domain-error",
code: "creature-not-found",
message: `Creature not found for ID "${combatant.creatureId}"`,
};
}
const { modifier } = calculateInitiative({
dexScore: creature.abilities.dex,
cr: creature.cr,
initiativeProficiency: creature.initiativeProficiency,
});
const value = rollInitiative(diceRoll, modifier);
if (isDomainError(value)) {
return value;
}
const result = setInitiative(encounter, combatantId, value);
if (isDomainError(result)) {
return result;
}
store.save(result.encounter);
return result.events;
}

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { rollInitiative } from "../roll-initiative.js";
import { isDomainError } from "../types.js";
describe("rollInitiative", () => {
describe("valid rolls", () => {
it("normal roll: 15 + modifier 7 = 22", () => {
expect(rollInitiative(15, 7)).toBe(22);
});
it("boundary: roll 1 + modifier 0 = 1", () => {
expect(rollInitiative(1, 0)).toBe(1);
});
it("boundary: roll 20 + modifier 0 = 20", () => {
expect(rollInitiative(20, 0)).toBe(20);
});
it("negative modifier: roll 1 + (3) = 2", () => {
expect(rollInitiative(1, -3)).toBe(-2);
});
it("zero modifier: roll 10 + 0 = 10", () => {
expect(rollInitiative(10, 0)).toBe(10);
});
it("large positive modifier: roll 20 + 12 = 32", () => {
expect(rollInitiative(20, 12)).toBe(32);
});
});
describe("invalid dice rolls", () => {
it("rejects 0", () => {
const result = rollInitiative(0, 5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
});
it("rejects 21", () => {
const result = rollInitiative(21, 5);
expect(isDomainError(result)).toBe(true);
if (isDomainError(result)) {
expect(result.code).toBe("invalid-dice-roll");
}
});
it("rejects non-integer (3.5)", () => {
const result = rollInitiative(3.5, 0);
expect(isDomainError(result)).toBe(true);
});
it("rejects negative dice roll", () => {
const result = rollInitiative(-1, 0);
expect(isDomainError(result)).toBe(true);
});
it("rejects NaN", () => {
const result = rollInitiative(Number.NaN, 0);
expect(isDomainError(result)).toBe(true);
});
});
describe("determinism", () => {
it("same input produces same output", () => {
expect(rollInitiative(10, 5)).toBe(rollInitiative(10, 5));
});
});
});

View File

@@ -57,6 +57,7 @@ export {
removeCombatant,
} from "./remove-combatant.js";
export { retreatTurn } from "./retreat-turn.js";
export { rollInitiative } from "./roll-initiative.js";
export { type SetAcSuccess, setAc } from "./set-ac.js";
export { type SetHpSuccess, setHp } from "./set-hp.js";
export {

View File

@@ -0,0 +1,21 @@
import type { DomainError } from "./types.js";
/**
* Pure function that computes initiative from a resolved dice roll and modifier.
* The dice roll must be an integer in [1, 20].
* Returns the sum (diceRoll + modifier) or a DomainError for invalid inputs.
*/
export function rollInitiative(
diceRoll: number,
modifier: number,
): number | DomainError {
if (!Number.isInteger(diceRoll) || diceRoll < 1 || diceRoll > 20) {
return {
kind: "domain-error",
code: "invalid-dice-roll",
message: `Dice roll must be an integer between 1 and 20, got ${diceRoll}`,
};
}
return diceRoll + modifier;
}

View File

@@ -0,0 +1,34 @@
# Specification Quality Checklist: Roll Initiative
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-03-10
**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`.

View File

@@ -0,0 +1,68 @@
# Data Model: Roll Initiative
## Existing Entities (no changes)
### Combatant
| Field | Type | Notes |
|-------|------|-------|
| id | CombatantId | Branded string identifier |
| name | string | Display name |
| initiative | number \| undefined | The initiative value (set by manual input OR roll) |
| creatureId | CreatureId \| undefined | Link to bestiary creature — presence determines roll eligibility |
| maxHp | number \| undefined | From bestiary or manual |
| currentHp | number \| undefined | Current hit points |
| ac | number \| undefined | Armor class |
| conditions | ConditionId[] | Active conditions |
| isConcentrating | boolean | Concentration flag |
### Creature (from bestiary)
| Field | Type | Notes |
|-------|------|-------|
| id | CreatureId | Branded string identifier |
| dexScore | number | Dexterity ability score (used for initiative modifier) |
| cr | string | Challenge rating (determines proficiency bonus) |
| initiativeProficiency | number | 0 = none, 1 = proficiency, 2 = expertise |
### Initiative Modifier (derived, not stored)
Calculated on demand via `calculateInitiative(creature)`:
- `modifier = Math.floor((dexScore - 10) / 2) + (initiativeProficiency × proficiencyBonus(cr))`
- `passive = 10 + modifier`
## New Concepts (no new stored entities)
### Roll Result (transient)
Not persisted — computed at the adapter boundary and immediately applied:
- `diceRoll`: integer 120 (generated via Math.random at adapter layer)
- `initiativeValue = diceRoll + modifier` (computed by domain function, stored as combatant's `initiative`)
## State Transitions
### Single Roll
```
Combatant(initiative: any | undefined)
→ rollInitiative(diceRoll, modifier)
→ Combatant(initiative: diceRoll + modifier)
→ encounter re-sorted by initiative descending
```
### Batch Roll
```
Encounter(combatants: [...])
→ for each combatant with creatureId:
→ rollInitiative(diceRoll_i, modifier_i)
→ setInitiative(encounter, id, value)
→ single save with final sorted state
```
## Validation Rules
- `diceRoll` must be an integer in range [1, 20]
- `modifier` is any integer (can be negative)
- Final `initiative` value is any integer (can be negative, e.g., 1 + (3) = 2)
- Only combatants with a non-undefined `creatureId` are eligible for rolling

View File

@@ -0,0 +1,79 @@
# Implementation Plan: Roll Initiative
**Branch**: `026-roll-initiative` | **Date**: 2026-03-10 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/026-roll-initiative/spec.md`
## Summary
Add a "roll initiative" feature that generates random initiative values (1d20 + modifier) for combatants linked to bestiary creatures. The initiative column uses a click-to-edit pattern: bestiary combatants without a value show a d20 icon (click to roll), combatants with a value show it as plain text (click to edit), and manual combatants show "--" (click to type). A "Roll All" button in the turn navigation bar batch-rolls for all eligible combatants that don't already have initiative. The domain layer receives pre-resolved dice values (never generates randomness itself), and the existing `setInitiative` domain function handles persistence and re-sorting.
## Technical Context
**Language/Version**: TypeScript 5.8 (strict mode, verbatimModuleSyntax)
**Primary Dependencies**: React 19, Tailwind CSS v4, Lucide React (icons), Vite 6
**Storage**: N/A (no storage changes — existing localStorage persistence handles initiative via `setInitiativeUseCase`)
**Testing**: Vitest
**Target Platform**: Browser (single-user, local-first)
**Project Type**: Web application (monorepo: domain → application → web adapter)
**Performance Goals**: Instant — single click produces immediate result
**Constraints**: Domain layer must remain pure (no randomness, no I/O)
**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 | Dice rolls are resolved at the adapter boundary and passed as inputs to domain/application functions. Domain never calls Math.random(). |
| II. Layered Architecture | PASS | New domain function receives pre-rolled values. Application use case orchestrates. React adapter generates random numbers and wires UI. |
| III. Agent Boundary | N/A | No agent features involved. |
| IV. Clarification-First | PASS | Spec is clear; no ambiguities remain. |
| V. Escalation Gates | PASS | All functionality is within spec scope. |
| VI. MVP Baseline Language | PASS | No permanent bans introduced. |
| VII. No Gameplay Rules | PASS | Initiative rolling mechanics are in the spec, not the constitution. |
## Project Structure
### Documentation (this feature)
```text
specs/026-roll-initiative/
├── 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/
├── roll-initiative.ts # NEW — pure function: (diceRoll, modifier) → initiative value
├── initiative.ts # EXISTING — calculateInitiative (reused for modifier)
├── set-initiative.ts # EXISTING — reused to apply rolled values
├── types.ts # EXISTING — no changes needed
├── events.ts # EXISTING — no new event types needed (reuses InitiativeSet)
└── __tests__/
└── roll-initiative.test.ts # NEW — tests for the pure roll function
packages/application/src/
├── roll-initiative-use-case.ts # NEW — single combatant roll orchestration
├── roll-all-initiative-use-case.ts # NEW — batch roll orchestration
└── set-initiative-use-case.ts # EXISTING — called by the new use cases
apps/web/src/
├── components/
│ ├── combatant-row.tsx # MODIFIED — add d20 roll button next to initiative input
│ ├── turn-navigation.tsx # MODIFIED — add "Roll All" button
│ └── d20-icon.tsx # NEW — inline SVG component (paths copied from root d20.svg)
└── hooks/
└── use-encounter.ts # EXISTING — no changes needed (roll callbacks live in App.tsx)
```
**Structure Decision**: Follows the existing monorepo layering. New domain function is minimal (compute initiative from dice roll + modifier). Application use cases orchestrate the domain call and persistence. React components handle randomness generation and UI.
## Complexity Tracking
No constitution violations — table not needed.

View File

@@ -0,0 +1,54 @@
# Quickstart: Roll Initiative
## Overview
This feature adds dice-rolling to the encounter tracker. The initiative column uses a click-to-edit pattern: bestiary combatants without initiative show a d20 icon that rolls 1d20 + modifier on click; once set, the value displays as plain text (click to edit/clear). Manual combatants show "--" (click to type). A "Roll All" button in the top bar batch-rolls for all bestiary combatants that don't yet have initiative.
## Key Files to Touch
### Domain Layer (`packages/domain/src/`)
1. **`roll-initiative.ts`** (NEW) — Pure function: `rollInitiative(diceRoll: number, modifier: number) → number`. Validates dice roll range [1,20], returns `diceRoll + modifier`.
2. **`__tests__/roll-initiative.test.ts`** (NEW) — Tests for the pure function covering normal, boundary (1, 20), and negative modifier cases.
### Application Layer (`packages/application/src/`)
3. **`roll-initiative-use-case.ts`** (NEW) — Single combatant roll. Receives `(store, combatantId, diceRoll, getCreature)`. Looks up creature via `creatureId`, computes modifier via `calculateInitiative`, computes final value via `rollInitiative`, delegates to `setInitiative` domain function.
4. **`roll-all-initiative-use-case.ts`** (NEW) — Batch roll. Receives `(store, diceRolls: Map<CombatantId, number>, getCreature)`. Iterates eligible combatants, applies `setInitiative` in sequence on evolving encounter state, saves once.
### Web Adapter (`apps/web/src/`)
5. **`components/d20-icon.tsx`** (NEW) — React component rendering the d20.svg inline. Accepts className for sizing.
6. **`components/combatant-row.tsx`** (MODIFY) — Add d20 button next to initiative input. Only shown when `combatant.creatureId` is defined. New prop: `onRollInitiative: (id: CombatantId) => void`.
7. **`components/turn-navigation.tsx`** (MODIFY) — Add "Roll All Initiative" d20 button in right section. New prop: `onRollAllInitiative: () => void`.
8. **`hooks/use-encounter.ts`** (MODIFY) — Add `rollInitiative(id)` and `rollAllInitiative()` callbacks that generate dice rolls via `Math.floor(Math.random() * 20) + 1` and call the respective use cases.
9. **`App.tsx`** (MODIFY) — Wire new callbacks to components.
## Architecture Pattern
```
[Browser Math.random()] → diceRoll (number 1-20)
[Application Use Case] → looks up creature, computes modifier, calls domain
[Domain rollInitiative()] → diceRoll + modifier = initiative value
[Domain setInitiative()] → updates combatant, re-sorts encounter
[Store.save()] → persists to localStorage
```
## Running
```bash
pnpm --filter web dev # Dev server at localhost:5173
pnpm test # Run all tests
pnpm vitest run packages/domain/src/__tests__/roll-initiative.test.ts # Single test
pnpm check # Full merge gate
```

View File

@@ -0,0 +1,71 @@
# Research: Roll Initiative
## Decision 1: Where to generate random dice rolls
**Decision**: Generate random numbers at the adapter (React/browser) layer and pass resolved values into application use cases.
**Rationale**: Constitution Principle I (Deterministic Domain Core) requires all domain logic to be pure. Random values must be injected at the boundary. The adapter layer calls `Math.floor(Math.random() * 20) + 1` and passes the result as a parameter.
**Alternatives considered**:
- Generate in domain layer — violates constitution
- Generate in application layer — still impure; application should only orchestrate
- Inject a `DiceRoller` port — over-engineered for Math.random(); would need mocking in tests for no real benefit
## Decision 2: New domain function vs. inline calculation
**Decision**: Create a minimal `rollInitiative` pure function in the domain that computes `diceRoll + modifier` and returns the final initiative value. The modifier is obtained from the existing `calculateInitiative` function.
**Rationale**: Keeps the formula explicit and testable. Even though it's simple arithmetic, having it as a named function documents the intent and makes tests self-describing.
**Alternatives considered**:
- Inline the addition in the use case — less testable, mixes concerns
- Extend `calculateInitiative` to accept a dice roll — conflates two different calculations (passive vs. rolled)
## Decision 3: Reuse existing `setInitiativeUseCase` vs. new use case
**Decision**: Create new use cases (`rollInitiativeUseCase` and `rollAllInitiativeUseCase`) that internally call `setInitiativeUseCase` (or the domain `setInitiative` directly) after computing the rolled value.
**Rationale**: The roll use cases add the dice-roll-to-modifier step, then delegate to the existing set-initiative pipeline for persistence and sorting. This avoids duplicating sort/persist logic.
**Alternatives considered**:
- Call `setInitiativeUseCase` directly from the React hook — would leak initiative modifier calculation into the adapter layer
## Decision 4: Event type for rolled initiative
**Decision**: Reuse the existing `InitiativeSet` event. No new event type needed.
**Rationale**: From the domain's perspective, rolling initiative is indistinguishable from manually setting it — the combatant's initiative field is updated to a new value. The UI doesn't need to distinguish "rolled" from "typed" initiative values for any current feature.
**Alternatives considered**:
- New `InitiativeRolled` event with dice details — adds complexity with no current consumer; can be added later if roll history/animation is needed
## Decision 5: d20.svg integration approach
**Decision**: Move `d20.svg` into `apps/web/src/assets/` and create a thin React component (`D20Icon`) that renders it as an inline SVG, inheriting `currentColor` for theming.
**Rationale**: The SVG already uses `stroke="currentColor"` and `fill="none"`, making it theme-compatible. An inline SVG component allows sizing via className props, consistent with how Lucide React icons work in the project.
**Alternatives considered**:
- Use as `<img>` tag — loses currentColor theming
- Import as Vite asset URL — same limitation as img tag
- Keep in project root and reference — clutters root, not idiomatic for web assets
## Decision 6: Roll-all button placement
**Decision**: Add the "Roll All Initiative" button to the turn navigation bar (top bar), in the right section alongside the existing clear/trash button.
**Rationale**: The turn navigation bar is the encounter-level action area. Rolling all initiative is an encounter-level action, not per-combatant. Placing it here follows the existing pattern (clear encounter button is already there).
**Alternatives considered**:
- Separate toolbar row — adds visual clutter for a single button
- Floating action button — inconsistent with existing UI patterns
## Decision 7: Batch roll — multiple setInitiative calls vs. single bulk operation
**Decision**: The batch roll use case iterates over eligible combatants and calls `setInitiative` for each one sequentially within a single store transaction (get once, apply all, save once).
**Rationale**: Calling `setInitiativeUseCase` per combatant would cause N store reads/writes and N re-sorts. Instead, the batch use case reads the encounter once, applies all initiative values via the domain `setInitiative` function in a loop (each call returns a new encounter), and saves once at the end. This is both more efficient and produces correct final sort order.
**Alternatives considered**:
- Call `setInitiativeUseCase` N times — N persists and N sorts (wasteful)
- New domain `setMultipleInitiatives` function — unnecessary; looping `setInitiative` on the evolving encounter state achieves the same result

View File

@@ -0,0 +1,84 @@
# Feature Specification: Roll Initiative
**Feature Branch**: `026-roll-initiative`
**Created**: 2026-03-10
**Status**: Draft
**Input**: User description: "Roll initiative feature — d20 button per combatant and a roll-all button in the top bar"
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Roll Initiative for a Single Combatant (Priority: P1)
As a DM, I want to click a d20 icon button next to a combatant's initiative field to randomly roll initiative (1d20 + initiative modifier from its stat block) so that the result is immediately placed into the initiative field and the tracker re-sorts.
**Why this priority**: This is the core interaction — rolling initiative for individual creatures is the most frequent use case and the atomic building block for the roll-all feature.
**Independent Test**: Can be fully tested by adding a bestiary creature, clicking the d20 button, and verifying a valid initiative value appears and the list re-sorts.
**Acceptance Scenarios**:
1. **Given** a combatant linked to a bestiary creature (e.g., Aboleth with initiative modifier +7) with no initiative value, **When** I click the d20 icon in the initiative slot, **Then** a random value between 8 and 27 (1-20 + 7) is placed into the initiative field, shown as plain text, and the encounter list re-sorts by initiative descending.
2. **Given** a combatant that is NOT linked to a bestiary creature (manually added), **When** I look at its combatant row, **Then** the initiative slot shows "--" (clickable to type a value manually) instead of a d20 button.
3. **Given** a combatant with a bestiary creature whose initiative modifier is negative (e.g., 2), **When** I click the d20 button, **Then** the result can range from 1 to 18 (1-20 + (2)).
4. **Given** a combatant that already has an initiative value, **When** I look at its row, **Then** the initiative slot shows the value as plain text (no d20 button). Clicking the value opens an inline editor to change or clear it.
---
### User Story 2 - Roll Initiative for All Bestiary Combatants (Priority: P2)
As a DM, I want a button in the top bar that rolls initiative for all combatants that have a linked stat block, so I can quickly set up initiative order at the start of combat without clicking each one individually.
**Why this priority**: Speeds up the common workflow of rolling initiative for all monsters at once. Depends on the single-roll mechanism from P1.
**Independent Test**: Can be tested by adding multiple bestiary creatures and one manual combatant, clicking the roll-all button, and verifying all bestiary combatants get initiative values while manual combatants remain unchanged.
**Acceptance Scenarios**:
1. **Given** an encounter with 3 bestiary combatants (no initiative yet) and 1 manually-added combatant, **When** I click the "Roll All Initiative" button in the top bar, **Then** all 3 bestiary combatants receive randomly rolled initiative values (1d20 + their respective modifiers) and the manual combatant's initiative field remains unchanged.
2. **Given** an encounter with bestiary combatants that already have initiative values, **When** I click the roll-all button, **Then** those combatants are skipped (their existing values are preserved). Only bestiary combatants without initiative are rolled.
3. **Given** an encounter with no bestiary combatants (all manually added), **When** I look at the top bar, **Then** the roll-all button is still visible but rolling produces no changes (no combatants are eligible).
4. **Given** an empty encounter, **When** I look at the top bar, **Then** the roll-all button is present but has no effect when clicked.
---
### Edge Cases
- What happens when a combatant's initiative modifier produces a result of 0 or negative? The value is stored as-is — negative initiative is valid in D&D.
- What happens if multiple combatants roll the same initiative? Existing auto-sort behavior handles ties by preserving relative insertion order among tied values.
- What happens if the active combatant's position changes after rolling? Existing turn-tracking-by-ID mechanism preserves the active combatant's turn through re-sorts.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST display a d20 icon button in the initiative slot for every combatant that has a linked bestiary creature and does not yet have an initiative value.
- **FR-002**: System MUST NOT display the d20 icon button for combatants without a linked bestiary creature (they see a "--" placeholder that is clickable to type a value).
- **FR-003**: When the d20 button is clicked for a combatant, the system MUST generate a random integer between 1 and 20 (inclusive, uniform distribution), add the creature's initiative modifier, and set the result as the combatant's initiative value.
- **FR-004**: The initiative modifier MUST be calculated from the creature's bestiary data using the existing initiative calculation logic (DEX modifier + initiative proficiency multiplier × proficiency bonus).
- **FR-005**: The d20 icon button MUST use the d20.svg asset from the project root.
- **FR-006**: System MUST provide a "Roll All Initiative" button in the top bar area of the encounter tracker.
- **FR-007**: When the roll-all button is clicked, the system MUST roll initiative (1d20 + modifier) for every combatant that has a linked bestiary creature and does not already have an initiative value. Combatants without a linked creature or with an existing initiative value MUST NOT be modified.
- **FR-008**: After any initiative roll (single or batch), the encounter list MUST re-sort by initiative in descending order per existing behavior.
- **FR-009**: Once a combatant has an initiative value, the d20 button is replaced by the value displayed as plain text. Clicking the value opens an inline editor to manually change or clear it. The initiative column uses a click-to-edit pattern consistent with other fields (name, AC, HP).
### Key Entities
- **Initiative Modifier**: A signed integer derived from a creature's bestiary data (DEX modifier + proficiency contribution). Calculated on demand from creature stats, not stored separately.
- **Initiative Roll Result**: The final integer value (1d20 + modifier) stored as the combatant's initiative value.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: A single combatant's initiative can be rolled with one click (the d20 button).
- **SC-002**: All eligible combatants' initiative can be rolled with one click (the roll-all button).
- **SC-003**: After rolling, the encounter list is correctly sorted by initiative descending.
- **SC-004**: Manual combatants (no stat block) are never affected by roll actions.
- **SC-005**: Rolled values fall within the valid range (1 + modifier to 20 + modifier) for each combatant's initiative modifier.
## Assumptions
- The passive initiative value shown in parentheses in the stat block is not used for rolling — only the active modifier (e.g., +7) is used.
- The d20.svg file in the project root is suitable for use as an icon at small sizes (inline with the initiative input field).
- The "top bar" refers to the turn navigation area pinned at the top of the encounter tracker (feature 022).
- Random number generation uses standard browser randomness — cryptographic randomness is not required for dice rolls.

View File

@@ -0,0 +1,130 @@
# Tasks: Roll Initiative
**Input**: Design documents from `/specs/026-roll-initiative/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, quickstart.md
**Tests**: Tests are included for the domain layer (pure functions are trivially testable and the project convention includes domain tests).
**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**: Create the d20 icon component and domain roll function shared by both user stories
- [x] T001 [P] Create D20Icon React component in `apps/web/src/components/d20-icon.tsx` — inline SVG component (copy path data from `d20.svg` in project root) accepting className prop, using `stroke="currentColor"` and `fill="none"`. Follow Lucide icon conventions (size via className). The project root `d20.svg` remains as the source asset and is not moved or deleted.
- [x] T002 [P] Create `rollInitiative` pure domain function in `packages/domain/src/roll-initiative.ts` — accepts `(diceRoll: number, modifier: number)`, validates diceRoll is integer in [1, 20], returns `diceRoll + modifier`. Return `DomainError` for invalid dice roll values.
- [x] T003 [P] Create domain tests in `packages/domain/src/__tests__/roll-initiative.test.ts` — test normal rolls (e.g., roll 15 + modifier 7 = 22), boundary values (roll 1, roll 20), negative modifiers (roll 1 + (3) = 2), zero modifier, and invalid dice roll values (0, 21, non-integer). Follow existing test patterns from `set-initiative.test.ts`.
- [x] T004 Export `rollInitiative` from `packages/domain/src/index.ts` (add to existing barrel export)
**Checkpoint**: D20 icon component exists, domain roll function passes all tests
---
## Phase 2: User Story 1 — Roll Initiative for a Single Combatant (Priority: P1) 🎯 MVP
**Goal**: A d20 button next to each bestiary combatant's initiative field rolls 1d20 + modifier and sets the result.
**Independent Test**: Add a bestiary creature to the encounter, click its d20 button, verify initiative value appears and list re-sorts.
### Implementation for User Story 1
- [x] T005 [US1] Create `rollInitiativeUseCase` in `packages/application/src/roll-initiative-use-case.ts` — signature: `(store: EncounterStore, combatantId: CombatantId, diceRoll: number, getCreature: (id: CreatureId) => Creature | undefined)`. Looks up combatant's `creatureId`, calls `getCreature` to get creature data, computes modifier via `calculateInitiative`, computes final value via `rollInitiative`, then calls domain `setInitiative` to apply and persist. Returns `DomainEvent[] | DomainError`. Export from `packages/application/src/index.ts`.
- [x] T006 [US1] Add `rollInitiative` callback in `apps/web/src/App.tsx` — new function `rollInitiative(id: CombatantId)` that generates `Math.floor(Math.random() * 20) + 1`, calls `rollInitiativeUseCase` with the store, combatant ID, dice roll, and `getCreature` from `useBestiary`. Defined in App.tsx where both `useEncounter` and `useBestiary` are composed.
- [x] T007 [US1] Add `onRollInitiative` prop to `CombatantRow` in `apps/web/src/components/combatant-row.tsx` — new optional prop `onRollInitiative?: (id: CombatantId) => void`. When defined (combatant has `creatureId`), render a d20 icon button adjacent to the initiative input field (left of the input, within the same grid cell or an expanded cell). Use the `D20Icon` component. Button should be small (matching initiative input height), with hover/active states consistent with existing icon buttons.
- [x] T008 [US1] Wire `rollInitiative` callback in `apps/web/src/App.tsx` — pass `onRollInitiative` to each `CombatantRow`. Only provide the callback for combatants that have a `creatureId` (i.e., pass `onRollInitiative={c.creatureId ? rollInitiative : undefined}`). The callback is already defined in App.tsx from T006.
**Checkpoint**: Bestiary combatants show d20 button, clicking it rolls initiative and re-sorts. Manual combatants have no d20 button.
---
## Phase 3: User Story 2 — Roll All Initiative (Priority: P2)
**Goal**: A button in the turn navigation bar batch-rolls initiative for all bestiary combatants in one click.
**Independent Test**: Add mix of bestiary and manual combatants, click Roll All, verify only bestiary combatants get initiative values.
### Implementation for User Story 2
- [x] T009 [US2] Create `rollAllInitiativeUseCase` in `packages/application/src/roll-all-initiative-use-case.ts` — signature: `(store: EncounterStore, rollDice: () => number, getCreature: (id: CreatureId) => Creature | undefined)`. Reads encounter once from store, iterates combatants with `creatureId`, for each: calls `rollDice()` to get a d20 value, computes modifier via `calculateInitiative`, computes final value via domain `rollInitiative`, then applies via domain `setInitiative` (pure function, not the use case) to evolve the encounter state. After all rolls are applied, calls `store.save(encounter)` once. Collects and returns all `DomainEvent[]` or first `DomainError`. Export from `packages/application/src/index.ts`.
- [x] T010 [US2] Add `rollAllInitiative` callback in `apps/web/src/App.tsx` — new function `rollAllInitiative()` that calls `rollAllInitiativeUseCase` with `() => Math.floor(Math.random() * 20) + 1` as the dice roller and `getCreature` from `useBestiary`. Defined in App.tsx alongside the single-roll callback.
- [x] T011 [US2] Add Roll All button to `TurnNavigation` in `apps/web/src/components/turn-navigation.tsx` — new prop `onRollAllInitiative: () => void`. Render a d20 icon button in the right section (alongside existing clear/trash button). Use `D20Icon` component. Include a tooltip or aria-label "Roll all initiative".
- [x] T012 [US2] Wire `rollAllInitiative` callback in `apps/web/src/App.tsx` — pass `onRollAllInitiative` to `TurnNavigation` component.
**Checkpoint**: Roll All button in top bar rolls initiative for all bestiary combatants; manual combatants untouched.
---
## Phase 4: Polish & Cross-Cutting Concerns
**Purpose**: Final validation and cleanup
- [x] T013 Run `pnpm check` (knip + format + lint + typecheck + test) and fix any issues
- [ ] T014 (removed — d20.svg stays in project root as source asset; D20Icon inlines the SVG paths)
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — can start immediately
- **US1 (Phase 2)**: Depends on T002 (domain function) and T001 (icon component)
- **US2 (Phase 3)**: Depends on T002 (domain function) and T001 (icon component). Can run in parallel with US1 since they touch different use case files, but US2's hook callback (T010) depends on the same hook file as T006, so they should be sequenced.
- **Polish (Phase 4)**: Depends on all previous phases
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Phase 1 — no dependencies on US2
- **User Story 2 (P2)**: Can start after Phase 1 — shares hook file with US1, so best done sequentially after US1
### Parallel Opportunities
- T001, T002, T003 can all run in parallel (different files, no dependencies)
- T005 can start as soon as T002 completes (different layer)
- T007 and T011 touch different component files and can run in parallel
- T009 can start as soon as T002 completes (different layer from T005)
---
## Parallel Example: Phase 1
```bash
# Launch all setup tasks together (3 different files):
Task T001: "Create D20Icon component in apps/web/src/components/d20-icon.tsx"
Task T002: "Create rollInitiative domain function in packages/domain/src/roll-initiative.ts"
Task T003: "Create domain tests in packages/domain/src/__tests__/roll-initiative.test.ts"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup (T001T004)
2. Complete Phase 2: User Story 1 (T005T008)
3. **STOP and VALIDATE**: Click d20 button on a bestiary combatant, verify initiative rolls and sorts
4. Deploy/demo if ready
### Incremental Delivery
1. Phase 1 → Shared components ready
2. Add US1 (Phase 2) → Single combatant rolling works → Demo
3. Add US2 (Phase 3) → Batch rolling works → Demo
4. Phase 4 → Polish and final checks
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Randomness (Math.random) stays in the adapter layer — domain receives resolved dice values
- Reuses existing `setInitiative` domain function and `InitiativeSet` event — no new event types
- Batch roll reads encounter once, applies all rolls, saves once (efficient single-persist strategy)