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

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