import { useRef, useState } from 'react'; import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, Dices, Heart, Hourglass, Play, ScrollText, Shield, Skull, Sword, Undo2, X, } from 'lucide-react'; import type { Campaign, Character, Combatant, Condition, 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, getConditions, type SystemId } from '@/lib/rules'; import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget'; import { useCharacters } from '@/features/characters/hooks'; import { useConditionGlossary } from './useConditionGlossary'; import { addCombatant, applyDamage, applyHealing, applyInitiatives, currentCombatant, endEncounter, logEvent, moveCombatant, nextTurn, previousTurn, removeCombatant, startEncounter, updateCombatant, } from '@/lib/combat/engine'; import { cn } from '@/lib/cn'; import { Button } from '@/components/ui/Button'; import { Input, Select } from '@/components/ui/Input'; import { NumberField } from '@/components/ui/NumberField'; import { Badge, Meter, Avatar } from '@/components/ui/Codex'; import { EncounterTipCard } from '@/features/assistant/EncounterTipCard'; export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) { const characters = useCharacters(campaign.id); const glossary = useConditionGlossary(campaign.system); const undoStack = useRef([]); const mutate = (fn: (e: Encounter) => Encounter) => { undoStack.current.push(encounter); if (undoStack.current.length > 50) undoStack.current.shift(); // Transactional read-modify-write so rapid mutations can't clobber each other. void encountersRepo.mutate(encounter.id, fn); }; const undo = () => { const prev = undoStack.current.pop(); if (prev) void encountersRepo.save(prev); }; const rollAllInitiative = () => { const rolls: Record = {}; for (const c of encounter.combatants) rolls[c.id] = rollDice('1d20', createRng()).total + c.initBonus; mutate((e) => logEvent(applyInitiatives(e, rolls), 'Rolled initiative for all combatants')); }; // Difficulty budget from monster combatants vs the campaign's PCs. Once combat // has started, rate against the levels captured at that time (snapshot) so the // reading — and the assistant's history — reflects the party as it was. const currentLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level); const partyLevels = encounter.partyLevelsSnapshot?.length ? encounter.partyLevelsSnapshot : currentLevels; const monsters = encounter.combatants .filter((c) => c.kind === 'monster') .map((c) => ({ cr: c.cr, level: c.level })); const budget = monsters.length > 0 && partyLevels.length > 0 ? computeBudget(campaign.system, partyLevels, monsters) : null; const current = currentCombatant(encounter); const isActive = encounter.status === 'active'; return (
{/* Control bar */}
Encounter · {encounter.name}
Initiative
{isActive ? `Round ${encounter.round}` : encounter.status === 'ended' ? 'Ended' : 'Not started'} {current && isActive ? ` · ${current.name}'s turn` : ''}
{isActive && (
Round {encounter.round}
)} {budget && (
{budget.difficulty}
{budget.totalXp.toLocaleString()} XP · {budget.awardPerCharacter.toLocaleString()}/PC
)}
{undoStack.current.length > 0 && ( )} {encounter.combatants.length > 0 && ( )} {!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.

) : ( <>
Turn order · {encounter.combatants.length} combatants
    {encounter.combatants.map((c, idx) => ( mutate((e) => updateCombatant(e, c.id, patch))} onDamage={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }), `${c.name} takes ${amt} damage`))} onHeal={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))} onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))} onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))} /> ))}
)} mutate((e) => ({ ...e, log: [] }))} />
); } function CombatLog({ log, onClear }: { log: { round: number; text: string }[]; onClear: () => void }) { if (log.length === 0) return null; const recent = log.slice(-40).reverse(); return (

Combat Log


    {recent.map((l, i) => (
  • R{l.round} {l.text}
  • ))}
); } 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 [qty, setQty] = useState(1); const addCustom = () => { if (name.trim() === '') return; const n = Math.max(1, qty); for (let i = 0; i < n; i++) { onAdd({ id: newId(), name: name.trim(), // engine auto-numbers duplicates kind: 'monster', initiative: init, initBonus: 0, ac, hp: { current: hp, max: hp, temp: 0 }, conditions: [], notes: '', }); } setName(''); setQty(1); }; 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), initBonus: sys.initiativeModifier({ level: ch.level, abilities: ch.abilities, perceptionRank: ch.perceptionRank }), 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, system, glossary, isCurrent, onChange, onDamage, onHeal, onMove, onRemove, }: { combatant: Combatant; system: SystemId; glossary: Map; 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 dead = c.hp.current <= 0; const foe = c.kind === 'monster'; const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0; const hpTone = ratio < 0.35 ? 'var(--app-danger)' : ratio < 0.7 ? 'var(--app-accent)' : 'var(--app-verdigris)'; const avatarTone = foe ? 'var(--app-danger)' : 'var(--app-accent)'; return (
  • onChange({ initiative })} aria-label={`${c.name} initiative`} /> init
    {foe && ( )}
    {c.name} {isCurrent && ( Active )} {c.kind} {dead && ( DOWN )}
    {c.hp.current}/{c.hp.max} {c.hp.temp > 0 && +{c.hp.temp}}
    {c.conditions.length > 0 && (
    {c.conditions.map((cond, i) => ( ))}
    )}
    {/* HP controls */}
    AC
    onChange({ ac })} aria-label={`${c.name} AC`} />
    onChange({ conditions: [...c.conditions, cond] })} />
  • ); } function ConditionPicker({ system, existing, onAdd, }: { system: SystemId; existing: Condition[]; onAdd: (cond: Condition) => void; }) { const [sel, setSel] = useState(''); const [value, setValue] = useState(1); const [dur, setDur] = useState(0); const [custom, setCustom] = useState(''); const conditions = getConditions(system); const taken = new Set(existing.map((c) => c.name)); const selected = conditions.find((c) => c.name === sel); const isCustom = sel === '__custom__'; const reset = () => { setSel(''); setValue(1); setDur(0); setCustom(''); }; const withDuration = (cond: Condition): Condition => (dur > 0 ? { ...cond, duration: dur } : cond); const addCustom = () => { if (custom.trim() === '') return; onAdd(withDuration({ name: custom.trim() })); reset(); }; const addValued = () => { if (!selected) return; onAdd(withDuration({ name: selected.name, value })); reset(); }; // Non-valued presets add immediately on selection for a snappy "tag" feel. const onSelect = (name: string) => { setSel(name); if (name === '' || name === '__custom__') return; const def = conditions.find((c) => c.name === name); if (def && !def.valued) { onAdd(withDuration({ name: def.name })); reset(); } }; return (
    {selected?.valued && ( <> )} {isCustom && ( <> setCustom(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addCustom()} /> )}
    ); }