import { useState } from 'react'; import type { Campaign, Character, Combatant, Encounter } from '@/lib/schemas'; import { encountersRepo } from '@/lib/db/repositories'; import { newId } from '@/lib/ids'; import { createRng } from '@/lib/rng'; import { rollDice } from '@/lib/dice/notation'; import { getSystem } from '@/lib/rules'; import { useCharacters } from '@/features/characters/hooks'; import { addCombatant, applyDamage, applyHealing, currentCombatant, endEncounter, moveCombatant, nextTurn, previousTurn, removeCombatant, startEncounter, updateCombatant, } from '@/lib/combat/engine'; import { Button } from '@/components/ui/Button'; import { Input, Select } from '@/components/ui/Input'; import { NumberField } from '@/components/ui/NumberField'; export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) { const characters = useCharacters(campaign.id); const mutate = (fn: (e: Encounter) => Encounter) => { void encountersRepo.save(fn(encounter)); }; const current = currentCombatant(encounter); const isActive = encounter.status === 'active'; return (
{/* Control bar */}
{encounter.name}
{isActive ? `Round ${encounter.round}` : encounter.status === 'ended' ? 'Ended' : 'Not started'} {current && isActive ? ` · ${current.name}'s turn` : ''}
{!isActive && encounter.status !== 'ended' && ( )} {isActive && ( <> )} {encounter.status === 'ended' && ( )}
mutate((e) => addCombatant(e, c))} /> {/* Combatant list */} {encounter.combatants.length === 0 ? (

No combatants yet. Add characters or monsters above.

) : ( )}
); } function rollInitiativeFor(character: Character | undefined): number { if (!character) return rollDice('1d20', createRng()).total; const mod = getSystem(character.system).initiativeModifier({ level: character.level, abilities: character.abilities, perceptionRank: character.perceptionRank, }); return rollDice('1d20', createRng()).total + mod; } function AddCombatantBar({ characters, onAdd, }: { encounter: Encounter; characters: Character[]; onAdd: (c: Combatant) => void; }) { const [name, setName] = useState(''); const [init, setInit] = useState(10); const [ac, setAc] = useState(12); const [hp, setHp] = useState(10); const addCustom = () => { if (name.trim() === '') return; onAdd({ id: newId(), name: name.trim(), kind: 'monster', initiative: init, ac, hp: { current: hp, max: hp, temp: 0 }, conditions: [], notes: '', }); setName(''); }; const addCharacter = (charId: string) => { const ch = characters.find((c) => c.id === charId); if (!ch) return; const sys = getSystem(ch.system); onAdd({ id: newId(), name: ch.name, kind: ch.kind, characterId: ch.id, initiative: rollInitiativeFor(ch), ac: sys.baseArmorClass({ level: ch.level, abilities: ch.abilities, armorBonus: ch.armorBonus }), hp: { ...ch.hp }, conditions: [], notes: '', }); }; return (
{characters.length > 0 && (
Add character (rolls initiative):
)}
); } function CombatantRow({ combatant: c, isCurrent, onChange, onDamage, onHeal, onMove, onRemove, }: { combatant: Combatant; isCurrent: boolean; onChange: (patch: Partial) => void; onDamage: (amt: number) => void; onHeal: (amt: number) => void; onMove: (dir: -1 | 1) => void; onRemove: () => void; }) { const [delta, setDelta] = useState(0); const [conditionText, setConditionText] = useState(''); const dead = c.hp.current <= 0; return (
  • onChange({ initiative })} aria-label={`${c.name} initiative`} /> init
    {c.name} {c.kind} {dead && DOWN}
    {c.conditions.length > 0 && (
    {c.conditions.map((cond, i) => ( ))}
    )}
    {/* HP */}
    {c.hp.current} /{c.hp.max} {c.hp.temp > 0 && +{c.hp.temp}}
    AC
    onChange({ ac })} aria-label={`${c.name} AC`} />
    setConditionText(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter' && conditionText.trim()) { const m = /^(.*?)(?:\s+(\d+))?$/.exec(conditionText.trim()); const nameOnly = (m?.[1] ?? conditionText).trim(); const value = m?.[2] ? Number(m[2]) : undefined; onChange({ conditions: [...c.conditions, { name: nameOnly, ...(value ? { value } : {}) }] }); setConditionText(''); } }} />
  • ); }