Files
ttrpg_manager/src/features/combat/EncounterTracker.tsx
T
NilsBriggen 1a9e5e2c18 Build MVP: campaigns, characters, combat, dice, compendium
- Rules abstraction (5e + pf2e) behind one interface, fully tested
- Pure combat engine: turn-order safe across add/remove/reorder/init-change
- Dexie storage with real cascade deletes + Zod validation on write
- Seedable dice engine with notation parser
- Lazy SRD compendium (code-split), bestiary -> combat
- Strict TS, 54 unit tests, Playwright e2e smoke (all green)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 00:09:42 +02:00

313 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<div>
{/* Control bar */}
<div className="mb-4 flex flex-wrap items-center gap-3 rounded-lg border border-line bg-panel p-3">
<div>
<div className="font-display text-lg font-semibold text-ink">{encounter.name}</div>
<div className="text-xs text-muted">
{isActive ? `Round ${encounter.round}` : encounter.status === 'ended' ? 'Ended' : 'Not started'}
{current && isActive ? ` · ${current.name}'s turn` : ''}
</div>
</div>
<div className="ml-auto flex gap-2">
{!isActive && encounter.status !== 'ended' && (
<Button
variant="primary"
disabled={encounter.combatants.length === 0}
onClick={() => mutate(startEncounter)}
>
Start combat
</Button>
)}
{isActive && (
<>
<Button variant="secondary" onClick={() => mutate(previousTurn)}>
Prev
</Button>
<Button variant="primary" onClick={() => mutate(nextTurn)}>
Next turn
</Button>
<Button variant="ghost" className="text-danger" onClick={() => mutate(endEncounter)}>
End
</Button>
</>
)}
{encounter.status === 'ended' && (
<Button variant="secondary" onClick={() => mutate((e) => ({ ...e, status: 'planning', round: 0, turnIndex: 0 }))}>
Reopen
</Button>
)}
</div>
</div>
<AddCombatantBar encounter={encounter} characters={characters} onAdd={(c) => mutate((e) => addCombatant(e, c))} />
{/* Combatant list */}
{encounter.combatants.length === 0 ? (
<p className="mt-6 text-sm text-muted">No combatants yet. Add characters or monsters above.</p>
) : (
<ul className="mt-4 space-y-2">
{encounter.combatants.map((c, idx) => (
<CombatantRow
key={c.id}
combatant={c}
isCurrent={isActive && idx === encounter.turnIndex}
onChange={(patch) => 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))}
/>
))}
</ul>
)}
</div>
);
}
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 (
<div className="space-y-2 rounded-lg border border-line bg-panel/60 p-3">
<div className="flex flex-wrap items-end gap-2">
<label className="flex-1 min-w-40 text-xs text-muted">
Name
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Goblin Boss" onKeyDown={(e) => e.key === 'Enter' && addCustom()} />
</label>
<label className="text-xs text-muted">
Init
<NumberField className="w-16" value={init} onChange={setInit} aria-label="Initiative" />
</label>
<label className="text-xs text-muted">
AC
<NumberField className="w-16" value={ac} min={0} onChange={setAc} aria-label="Armor class" />
</label>
<label className="text-xs text-muted">
HP
<NumberField className="w-20" value={hp} min={0} onChange={setHp} aria-label="Hit points" />
</label>
<Button variant="primary" onClick={addCustom}>
Add
</Button>
</div>
{characters.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Add character (rolls initiative):</span>
<Select
className="w-auto"
value=""
onChange={(e) => {
if (e.target.value) addCharacter(e.target.value);
e.target.value = '';
}}
>
<option value="">Choose</option>
{characters.map((c) => (
<option key={c.id} value={c.id}>
{c.name} (Lv {c.level})
</option>
))}
</Select>
</div>
)}
</div>
);
}
function CombatantRow({
combatant: c,
isCurrent,
onChange,
onDamage,
onHeal,
onMove,
onRemove,
}: {
combatant: Combatant;
isCurrent: boolean;
onChange: (patch: Partial<Combatant>) => 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 (
<li
className={
'rounded-lg border bg-panel p-3 ' +
(isCurrent ? 'border-accent ring-1 ring-accent/40' : 'border-line') +
(dead ? ' opacity-60' : '')
}
>
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col items-center">
<NumberField className="w-14" value={c.initiative} onChange={(initiative) => onChange({ initiative })} aria-label={`${c.name} initiative`} />
<span className="mt-0.5 text-[10px] uppercase text-muted">init</span>
</div>
<div className="min-w-32 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-ink">{c.name}</span>
<span className="rounded bg-elevated px-1.5 text-[10px] uppercase text-muted">{c.kind}</span>
{dead && <span className="text-xs font-semibold text-danger">DOWN</span>}
</div>
{c.conditions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => (
<button
key={`${cond.name}-${i}`}
className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:line-through"
title="Click to remove"
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
>
{cond.name}
{cond.value ? ` ${cond.value}` : ''}
</button>
))}
</div>
)}
</div>
{/* HP */}
<div className="text-center">
<div className="text-sm">
<span className={dead ? 'text-danger' : 'text-ink'}>{c.hp.current}</span>
<span className="text-muted">/{c.hp.max}</span>
{c.hp.temp > 0 && <span className="text-info"> +{c.hp.temp}</span>}
</div>
<div className="mt-1 flex items-center gap-1">
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP change`} />
<Button size="sm" variant="danger" onClick={() => { onDamage(delta); setDelta(0); }}>
</Button>
<Button size="sm" variant="secondary" onClick={() => { onHeal(delta); setDelta(0); }}>
+
</Button>
</div>
</div>
<div className="text-center text-sm">
<div className="text-muted">AC</div>
<NumberField className="w-12" value={c.ac} min={0} onChange={(ac) => onChange({ ac })} aria-label={`${c.name} AC`} />
</div>
<div className="flex flex-col gap-1">
<Button size="icon" variant="ghost" onClick={() => onMove(-1)} aria-label="Move up" title="Move up"></Button>
<Button size="icon" variant="ghost" onClick={() => onMove(1)} aria-label="Move down" title="Move down"></Button>
</div>
<Button size="icon" variant="ghost" className="text-danger" onClick={onRemove} aria-label={`Remove ${c.name}`}></Button>
</div>
<div className="mt-2 flex items-center gap-2">
<Input
className="h-8 max-w-48 text-xs"
value={conditionText}
placeholder="Add condition (e.g. Prone, Frightened 2)"
onChange={(e) => 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('');
}
}}
/>
</div>
</li>
);
}