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"; import { SourceManager } from "./components/source-manager"; import { StatBlockPanel } from "./components/stat-block-panel"; import { TurnNavigation } from "./components/turn-navigation"; import { type SearchResult, 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, advanceTurn, retreatTurn, addCombatant, clearEncounter, removeCombatant, editCombatant, setInitiative, setHp, adjustHp, setAc, toggleCondition, toggleConcentration, addFromBestiary, makeStore, } = useEncounter(); const { search, getCreature, isLoaded, isSourceCached, fetchAndCacheSource, uploadAndCacheSource, refreshCache, } = useBestiary(); const [selectedCreatureId, setSelectedCreatureId] = useState(null); const [sourceManagerOpen, setSourceManagerOpen] = useState(false); const selectedCreature: Creature | null = selectedCreatureId ? (getCreature(selectedCreatureId) ?? null) : null; const handleAddFromBestiary = useCallback( (result: SearchResult) => { addFromBestiary(result); // Derive the creature ID so stat block panel can try to show it const slug = result.name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); setSelectedCreatureId( `${result.source.toLowerCase()}:${slug}` as CreatureId, ); }, [addFromBestiary], ); const handleCombatantStatBlock = useCallback((creatureId: string) => { setSelectedCreatureId(creatureId as CreatureId); }, []); 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(null); useEffect(() => { activeRowRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth", }); }, [encounter.activeIndex]); // Auto-show stat block for the active combatant when turn changes, // but only when the viewport is wide enough to show it alongside the tracker. // Only react to activeIndex changes — not combatant reordering (e.g. Roll All). const prevActiveIndexRef = useRef(encounter.activeIndex); useEffect(() => { if (prevActiveIndexRef.current === encounter.activeIndex) return; prevActiveIndexRef.current = encounter.activeIndex; if (!window.matchMedia("(min-width: 1024px)").matches) return; const active = encounter.combatants[encounter.activeIndex]; if (!active?.creatureId || !isLoaded) return; setSelectedCreatureId(active.creatureId as CreatureId); }, [encounter.activeIndex, encounter.combatants, isLoaded]); return (
{/* Turn Navigation — fixed at top */}
setSourceManagerOpen((o) => !o)} />
{sourceManagerOpen && (
)} {/* Scrollable area — combatant list */}
{encounter.combatants.length === 0 ? (

No combatants yet — add one to get started

) : ( encounter.combatants.map((c, i) => ( handleCombatantStatBlock(c.creatureId as string) : undefined } onRollInitiative={ c.creatureId ? handleRollInitiative : undefined } /> )) )}
{/* Action Bar — fixed at bottom */}
{/* Stat Block Panel */} setSelectedCreatureId(null)} />
); }