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>
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user