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.
) : (
{encounter.combatants.map((c, idx) => (
mutate((e) => updateCombatant(e, c.id, patch))}
onDamage={(amt) => mutate((e) => updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }))}
onHeal={(amt) => mutate((e) => updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }))}
onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))}
onRemove={() => mutate((e) => removeCombatant(e, c.id))}
/>
))}
)}
);
}
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('');
}
}}
/>
);
}