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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,12 @@ import {
|
|||||||
rollAllInitiativeUseCase,
|
rollAllInitiativeUseCase,
|
||||||
rollInitiativeUseCase,
|
rollInitiativeUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type Creature,
|
||||||
|
type CreatureId,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -109,6 +114,8 @@ export function App() {
|
|||||||
|
|
||||||
const bulkImport = useBulkImport();
|
const bulkImport = useBulkImport();
|
||||||
|
|
||||||
|
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||||
|
|
||||||
const [selectedCreatureId, setSelectedCreatureId] =
|
const [selectedCreatureId, setSelectedCreatureId] =
|
||||||
useState<CreatureId | null>(null);
|
useState<CreatureId | null>(null);
|
||||||
const [bulkImportMode, setBulkImportMode] = useState(false);
|
const [bulkImportMode, setBulkImportMode] = useState(false);
|
||||||
@@ -158,7 +165,10 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRollAllInitiative = useCallback(() => {
|
const handleRollAllInitiative = useCallback(() => {
|
||||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||||
|
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||||
|
setRollSkippedCount(result.skippedNoSource);
|
||||||
|
}
|
||||||
}, [makeStore, getCreature]);
|
}, [makeStore, getCreature]);
|
||||||
|
|
||||||
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
||||||
@@ -252,7 +262,10 @@ export function App() {
|
|||||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
||||||
|
|
||||||
const isEmpty = encounter.combatants.length === 0;
|
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,
|
(c) => c.creatureId != null && c.initiative == null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -293,7 +306,8 @@ export function App() {
|
|||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
onManagePlayers={() => setManagementOpen(true)}
|
onManagePlayers={() => setManagementOpen(true)}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
onRollAllInitiative={handleRollAllInitiative}
|
||||||
showRollAllInitiative={showRollAllInitiative}
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
onOpenSourceManager={handleOpenSourceManager}
|
onOpenSourceManager={handleOpenSourceManager}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -349,7 +363,8 @@ export function App() {
|
|||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
onManagePlayers={() => setManagementOpen(true)}
|
onManagePlayers={() => setManagementOpen(true)}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
onRollAllInitiative={handleRollAllInitiative}
|
||||||
showRollAllInitiative={showRollAllInitiative}
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
onOpenSourceManager={handleOpenSourceManager}
|
onOpenSourceManager={handleOpenSourceManager}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,6 +442,14 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{rollSkippedCount > 0 && (
|
||||||
|
<Toast
|
||||||
|
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
||||||
|
onDismiss={() => setRollSkippedCount(0)}
|
||||||
|
autoDismissMs={4000}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<CreatePlayerModal
|
<CreatePlayerModal
|
||||||
open={createPlayerOpen}
|
open={createPlayerOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ interface ActionBarProps {
|
|||||||
onManagePlayers?: () => void;
|
onManagePlayers?: () => void;
|
||||||
onRollAllInitiative?: () => void;
|
onRollAllInitiative?: () => void;
|
||||||
showRollAllInitiative?: boolean;
|
showRollAllInitiative?: boolean;
|
||||||
|
rollAllInitiativeDisabled?: boolean;
|
||||||
onOpenSourceManager?: () => void;
|
onOpenSourceManager?: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
}
|
}
|
||||||
@@ -262,6 +263,7 @@ export function ActionBar({
|
|||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
onRollAllInitiative,
|
onRollAllInitiative,
|
||||||
showRollAllInitiative,
|
showRollAllInitiative,
|
||||||
|
rollAllInitiativeDisabled,
|
||||||
onOpenSourceManager,
|
onOpenSourceManager,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
}: ActionBarProps) {
|
}: ActionBarProps) {
|
||||||
@@ -575,6 +577,7 @@ export function ActionBar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-muted-foreground hover:text-hover-action"
|
className="text-muted-foreground hover:text-hover-action"
|
||||||
onClick={onRollAllInitiative}
|
onClick={onRollAllInitiative}
|
||||||
|
disabled={rollAllInitiativeDisabled}
|
||||||
title="Roll all initiative"
|
title="Roll all initiative"
|
||||||
aria-label="Roll all initiative"
|
aria-label="Roll all initiative"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function Toast({
|
|||||||
}, [autoDismissMs, onDismiss]);
|
}, [autoDismissMs, onDismiss]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2">
|
<div className="fixed bottom-4 left-4 z-50">
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
||||||
<span className="text-sm text-foreground">{message}</span>
|
<span className="text-sm text-foreground">{message}</span>
|
||||||
{progress !== undefined && (
|
{progress !== undefined && (
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export type {
|
|||||||
} from "./ports.js";
|
} from "./ports.js";
|
||||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||||
export { retreatTurnUseCase } from "./retreat-turn-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 { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
|
|||||||
@@ -10,20 +10,29 @@ import {
|
|||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export interface RollAllResult {
|
||||||
|
events: DomainEvent[];
|
||||||
|
skippedNoSource: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function rollAllInitiativeUseCase(
|
export function rollAllInitiativeUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
rollDice: () => number,
|
rollDice: () => number,
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => Creature | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
): RollAllResult | DomainError {
|
||||||
let encounter = store.get();
|
let encounter = store.get();
|
||||||
const allEvents: DomainEvent[] = [];
|
const allEvents: DomainEvent[] = [];
|
||||||
|
let skippedNoSource = 0;
|
||||||
|
|
||||||
for (const combatant of encounter.combatants) {
|
for (const combatant of encounter.combatants) {
|
||||||
if (!combatant.creatureId) continue;
|
if (!combatant.creatureId) continue;
|
||||||
if (combatant.initiative !== undefined) continue;
|
if (combatant.initiative !== undefined) continue;
|
||||||
|
|
||||||
const creature = getCreature(combatant.creatureId);
|
const creature = getCreature(combatant.creatureId);
|
||||||
if (!creature) continue;
|
if (!creature) {
|
||||||
|
skippedNoSource++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const { modifier } = calculateInitiative({
|
const { modifier } = calculateInitiative({
|
||||||
dexScore: creature.abilities.dex,
|
dexScore: creature.abilities.dex,
|
||||||
@@ -47,5 +56,5 @@ export function rollAllInitiativeUseCase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
store.save(encounter);
|
store.save(encounter);
|
||||||
return allEvents;
|
return { events: allEvents, skippedNoSource };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user