Files
ttrpg_manager/src/features/combat/EncounterTracker.tsx
T
NilsBriggen 3e5bdc06e2 Phase 3: combat depth
- Condition durations + auto-expiry: timed conditions tick down on the affected
  combatant's turn and drop at 0 (engine tickConditions; UI rounds field + (Nr) chip)
- Initiative: Roll-all (d20 + per-combatant bonus, re-sort anchored) + group/quantity add
- Combat log: per-round event feed (turns, round changes, damage/heal, expiries,
  removals) + in-memory Undo of the last change
- Encounter difficulty budget: 5e XP thresholds + PF2e level budget (pure lib +
  tests); live difficulty/XP-award readout from monster combatants vs the party
- Combatants carry initBonus + cr/level; compendium add-to-combat passes them

18 new unit tests (durations, applyInitiatives, budget), new combat-depth e2e.

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

489 lines
17 KiB
TypeScript

import { useRef, useState } from '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';
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
const characters = useCharacters(campaign.id);
const glossary = useConditionGlossary(campaign.system);
const undoStack = useRef<Encounter[]>([]);
const mutate = (fn: (e: Encounter) => Encounter) => {
undoStack.current.push(encounter);
if (undoStack.current.length > 50) undoStack.current.shift();
void encountersRepo.save(fn(encounter));
};
const undo = () => {
const prev = undoStack.current.pop();
if (prev) void encountersRepo.save(prev);
};
const rollAllInitiative = () => {
const rolls: Record<string, number> = {};
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.
const partyLevels = characters.filter((c) => c.kind === 'pc').map((c) => c.level);
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 (
<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>
{budget && (
<div className="rounded-md border border-line bg-surface px-3 py-1 text-center text-xs">
<div className={cn('font-display text-sm font-semibold capitalize', DIFFICULTY_COLOR[budget.difficulty])}>
{budget.difficulty}
</div>
<div className="text-muted">
{budget.totalXp.toLocaleString()} XP · {budget.awardPerCharacter.toLocaleString()}/PC
</div>
</div>
)}
<div className="ml-auto flex flex-wrap gap-2">
{undoStack.current.length > 0 && (
<Button variant="ghost" onClick={undo} title="Undo last change"> Undo</Button>
)}
{encounter.combatants.length > 0 && (
<Button variant="secondary" onClick={rollAllInitiative} title="Roll initiative for everyone">
🎲 Roll all
</Button>
)}
{!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}
system={campaign.system}
glossary={glossary}
isCurrent={isActive && idx === encounter.turnIndex}
onChange={(patch) => 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`))}
/>
))}
</ul>
)}
<CombatLog log={encounter.log ?? []} onClear={() => mutate((e) => ({ ...e, log: [] }))} />
</div>
);
}
function CombatLog({ log, onClear }: { log: { round: number; text: string }[]; onClear: () => void }) {
if (log.length === 0) return null;
const recent = log.slice(-40).reverse();
return (
<div className="mt-6">
<div className="mb-1 flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted">Combat Log</h3>
<Button size="sm" variant="ghost" onClick={onClear}>Clear</Button>
</div>
<ul className="max-h-48 space-y-0.5 overflow-auto rounded-md border border-line bg-surface p-2 text-xs">
{recent.map((l, i) => (
<li key={i} className="text-muted">
<span className="mr-2 text-accent/70">R{l.round}</span>
{l.text}
</li>
))}
</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 [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: n > 1 ? `${name.trim()} ${i + 1}` : name.trim(),
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 (
<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>
<label className="text-xs text-muted">
Qty
<NumberField className="w-14" value={qty} min={1} onChange={setQty} aria-label="Quantity" />
</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,
system,
glossary,
isCurrent,
onChange,
onDamage,
onHeal,
onMove,
onRemove,
}: {
combatant: Combatant;
system: SystemId;
glossary: Map<string, string>;
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 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={glossary.get(cond.name.toLowerCase()) || 'Click to remove'}
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
>
{cond.name}
{cond.value ? ` ${cond.value}` : ''}
{cond.duration ? ` (${cond.duration}r)` : ''}
</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 amount`} />
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta); setDelta(0); }} title="Apply damage">
Dmg
</Button>
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
Heal
</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">
<ConditionPicker
system={system}
existing={c.conditions}
onAdd={(cond) => onChange({ conditions: [...c.conditions, cond] })}
/>
</div>
</li>
);
}
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 (
<div className="flex flex-wrap items-center gap-1.5">
<Select
className="h-8 w-auto py-0 text-xs"
aria-label="Add condition"
value={sel}
onChange={(e) => onSelect(e.target.value)}
>
<option value="">+ Condition</option>
{conditions.map((cd) => (
<option key={cd.name} value={cd.name} disabled={taken.has(cd.name)}>
{cd.name}
{cd.valued ? ' (#)' : ''}
{taken.has(cd.name) ? ' ✓' : ''}
</option>
))}
<option value="__custom__">Custom</option>
</Select>
<label className="flex items-center gap-1 text-[11px] text-muted" title="Auto-expires after N rounds (0 = indefinite)">
<NumberField className="w-12" value={dur} min={0} onChange={setDur} aria-label="Condition duration in rounds" />
rds
</label>
{selected?.valued && (
<>
<NumberField className="w-14" value={value} min={1} onChange={setValue} aria-label="Condition value" />
<Button size="sm" variant="primary" onClick={addValued}>Add</Button>
</>
)}
{isCustom && (
<>
<Input
className="h-8 w-40 text-xs"
autoFocus
value={custom}
placeholder="Condition name"
onChange={(e) => setCustom(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addCustom()}
/>
<Button size="sm" variant="primary" onClick={addCustom}>Add</Button>
</>
)}
</div>
);
}