Implement the 023-clear-encounter feature that adds a clear encounter button with confirmation dialog to remove all combatants and reset round/turn counters, with the cleared state persisting across page refreshes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ export function App() {
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
clearEncounter,
|
||||
removeCombatant,
|
||||
editCombatant,
|
||||
setInitiative,
|
||||
@@ -94,6 +95,7 @@ export function App() {
|
||||
encounter={encounter}
|
||||
onAdvanceTurn={advanceTurn}
|
||||
onRetreatTurn={retreatTurn}
|
||||
onClearEncounter={clearEncounter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { Encounter } from "@initiative/domain";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Trash2 } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
interface TurnNavigationProps {
|
||||
encounter: Encounter;
|
||||
onAdvanceTurn: () => void;
|
||||
onRetreatTurn: () => void;
|
||||
onClearEncounter: () => void;
|
||||
}
|
||||
|
||||
export function TurnNavigation({
|
||||
encounter,
|
||||
onAdvanceTurn,
|
||||
onRetreatTurn,
|
||||
onClearEncounter,
|
||||
}: TurnNavigationProps) {
|
||||
const hasCombatants = encounter.combatants.length > 0;
|
||||
const isAtStart = encounter.roundNumber === 1 && encounter.activeIndex === 0;
|
||||
@@ -44,16 +46,27 @@ export function TurnNavigation({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-muted"
|
||||
onClick={onAdvanceTurn}
|
||||
disabled={!hasCombatants}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hover:bg-muted"
|
||||
onClick={onAdvanceTurn}
|
||||
disabled={!hasCombatants}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={onClearEncounter}
|
||||
disabled={!hasCombatants}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
addCombatantUseCase,
|
||||
adjustHpUseCase,
|
||||
advanceTurnUseCase,
|
||||
clearEncounterUseCase,
|
||||
editCombatantUseCase,
|
||||
removeCombatantUseCase,
|
||||
retreatTurnUseCase,
|
||||
@@ -223,6 +224,21 @@ export function useEncounter() {
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const clearEncounter = useCallback(() => {
|
||||
if (!window.confirm("Clear the entire encounter? This cannot be undone.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = clearEncounterUseCase(makeStore());
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextId.current = 0;
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore]);
|
||||
|
||||
const addFromBestiary = useCallback(
|
||||
(creature: Creature) => {
|
||||
const store = makeStore();
|
||||
@@ -280,6 +296,7 @@ export function useEncounter() {
|
||||
advanceTurn,
|
||||
retreatTurn,
|
||||
addCombatant,
|
||||
clearEncounter,
|
||||
removeCombatant,
|
||||
editCombatant,
|
||||
setInitiative,
|
||||
|
||||
@@ -97,12 +97,17 @@ describe("loadEncounter", () => {
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for invalid encounter data (empty combatants)", () => {
|
||||
it("returns empty encounter for cleared state (empty combatants)", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
|
||||
);
|
||||
expect(loadEncounter()).toBeNull();
|
||||
const result = loadEncounter();
|
||||
expect(result).toEqual({
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for out-of-bounds activeIndex", () => {
|
||||
@@ -198,12 +203,17 @@ describe("loadEncounter", () => {
|
||||
expect(loadEncounter()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for zero combatants (domain validation)", () => {
|
||||
it("returns empty encounter for zero combatants (cleared state)", () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
JSON.stringify({ combatants: [], activeIndex: 0, roundNumber: 1 }),
|
||||
);
|
||||
expect(loadEncounter()).toBeNull();
|
||||
const result = loadEncounter();
|
||||
expect(result).toEqual({
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("round-trip preserves combatant AC value", () => {
|
||||
|
||||
@@ -35,6 +35,16 @@ export function loadEncounter(): Encounter | null {
|
||||
if (typeof obj.roundNumber !== "number") return null;
|
||||
|
||||
const combatants = obj.combatants as unknown[];
|
||||
|
||||
// Handle empty encounter (cleared state) directly — createEncounter rejects empty arrays
|
||||
if (combatants.length === 0) {
|
||||
return {
|
||||
combatants: [],
|
||||
activeIndex: 0,
|
||||
roundNumber: 1,
|
||||
};
|
||||
}
|
||||
|
||||
for (const c of combatants) {
|
||||
if (typeof c !== "object" || c === null || Array.isArray(c)) return null;
|
||||
const entry = c as Record<string, unknown>;
|
||||
|
||||
Reference in New Issue
Block a user