From 72195e90f6b7277b9498703c7082fd10ffabc2ba Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 13 Mar 2026 18:58:25 +0100 Subject: [PATCH] Show toast when roll-all-initiative skips combatants without loaded sources Previously the button silently did nothing for creatures whose bestiary source wasn't loaded. Now it reports how many were skipped and why. Also keeps the roll-all button visible (but disabled) when there's nothing left to roll, and moves toasts to the bottom-left corner. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/App.tsx | 33 ++++++++++++++++--- apps/web/src/components/action-bar.tsx | 3 ++ apps/web/src/components/toast.tsx | 2 +- packages/application/src/index.ts | 5 ++- .../src/roll-all-initiative-use-case.ts | 15 +++++++-- 5 files changed, 48 insertions(+), 10 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f08446e..c94768d 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,7 +2,12 @@ import { rollAllInitiativeUseCase, rollInitiativeUseCase, } from "@initiative/application"; -import type { CombatantId, Creature, CreatureId } from "@initiative/domain"; +import { + type CombatantId, + type Creature, + type CreatureId, + isDomainError, +} from "@initiative/domain"; import { useCallback, useEffect, @@ -109,6 +114,8 @@ export function App() { const bulkImport = useBulkImport(); + const [rollSkippedCount, setRollSkippedCount] = useState(0); + const [selectedCreatureId, setSelectedCreatureId] = useState(null); const [bulkImportMode, setBulkImportMode] = useState(false); @@ -158,7 +165,10 @@ export function App() { ); const handleRollAllInitiative = useCallback(() => { - rollAllInitiativeUseCase(makeStore(), rollDice, getCreature); + const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature); + if (!isDomainError(result) && result.skippedNoSource > 0) { + setRollSkippedCount(result.skippedNoSource); + } }, [makeStore, getCreature]); const handleViewStatBlock = useCallback((result: SearchResult) => { @@ -252,7 +262,10 @@ export function App() { }, [encounter.activeIndex, encounter.combatants, isLoaded]); const isEmpty = encounter.combatants.length === 0; - const showRollAllInitiative = encounter.combatants.some( + const hasCreatureCombatants = encounter.combatants.some( + (c) => c.creatureId != null, + ); + const canRollAllInitiative = encounter.combatants.some( (c) => c.creatureId != null && c.initiative == null, ); @@ -293,7 +306,8 @@ export function App() { onAddFromPlayerCharacter={addFromPlayerCharacter} onManagePlayers={() => setManagementOpen(true)} onRollAllInitiative={handleRollAllInitiative} - showRollAllInitiative={showRollAllInitiative} + showRollAllInitiative={hasCreatureCombatants} + rollAllInitiativeDisabled={!canRollAllInitiative} onOpenSourceManager={handleOpenSourceManager} autoFocus /> @@ -349,7 +363,8 @@ export function App() { onAddFromPlayerCharacter={addFromPlayerCharacter} onManagePlayers={() => setManagementOpen(true)} onRollAllInitiative={handleRollAllInitiative} - showRollAllInitiative={showRollAllInitiative} + showRollAllInitiative={hasCreatureCombatants} + rollAllInitiativeDisabled={!canRollAllInitiative} onOpenSourceManager={handleOpenSourceManager} /> @@ -427,6 +442,14 @@ export function App() { /> )} + {rollSkippedCount > 0 && ( + setRollSkippedCount(0)} + autoDismissMs={4000} + /> + )} + { diff --git a/apps/web/src/components/action-bar.tsx b/apps/web/src/components/action-bar.tsx index c5773a9..41c9416 100644 --- a/apps/web/src/components/action-bar.tsx +++ b/apps/web/src/components/action-bar.tsx @@ -40,6 +40,7 @@ interface ActionBarProps { onManagePlayers?: () => void; onRollAllInitiative?: () => void; showRollAllInitiative?: boolean; + rollAllInitiativeDisabled?: boolean; onOpenSourceManager?: () => void; autoFocus?: boolean; } @@ -262,6 +263,7 @@ export function ActionBar({ onManagePlayers, onRollAllInitiative, showRollAllInitiative, + rollAllInitiativeDisabled, onOpenSourceManager, autoFocus, }: ActionBarProps) { @@ -575,6 +577,7 @@ export function ActionBar({ variant="ghost" className="text-muted-foreground hover:text-hover-action" onClick={onRollAllInitiative} + disabled={rollAllInitiativeDisabled} title="Roll all initiative" aria-label="Roll all initiative" > diff --git a/apps/web/src/components/toast.tsx b/apps/web/src/components/toast.tsx index dd248fd..39b4985 100644 --- a/apps/web/src/components/toast.tsx +++ b/apps/web/src/components/toast.tsx @@ -23,7 +23,7 @@ export function Toast({ }, [autoDismissMs, onDismiss]); return createPortal( -
+
{message} {progress !== undefined && ( diff --git a/packages/application/src/index.ts b/packages/application/src/index.ts index 895dfef..77ae634 100644 --- a/packages/application/src/index.ts +++ b/packages/application/src/index.ts @@ -13,7 +13,10 @@ export type { } 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 { + type RollAllResult, + 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"; diff --git a/packages/application/src/roll-all-initiative-use-case.ts b/packages/application/src/roll-all-initiative-use-case.ts index a0c9e3a..b3fd646 100644 --- a/packages/application/src/roll-all-initiative-use-case.ts +++ b/packages/application/src/roll-all-initiative-use-case.ts @@ -10,20 +10,29 @@ import { } from "@initiative/domain"; import type { EncounterStore } from "./ports.js"; +export interface RollAllResult { + events: DomainEvent[]; + skippedNoSource: number; +} + export function rollAllInitiativeUseCase( store: EncounterStore, rollDice: () => number, getCreature: (id: CreatureId) => Creature | undefined, -): DomainEvent[] | DomainError { +): RollAllResult | DomainError { let encounter = store.get(); const allEvents: DomainEvent[] = []; + let skippedNoSource = 0; for (const combatant of encounter.combatants) { if (!combatant.creatureId) continue; if (combatant.initiative !== undefined) continue; const creature = getCreature(combatant.creatureId); - if (!creature) continue; + if (!creature) { + skippedNoSource++; + continue; + } const { modifier } = calculateInitiative({ dexScore: creature.abilities.dex, @@ -47,5 +56,5 @@ export function rollAllInitiativeUseCase( } store.save(encounter); - return allEvents; + return { events: allEvents, skippedNoSource }; }