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>
This commit is contained in:
@@ -1,18 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
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,
|
||||
@@ -20,6 +23,7 @@ import {
|
||||
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';
|
||||
@@ -28,9 +32,32 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
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';
|
||||
@@ -46,7 +73,25 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
{current && isActive ? ` · ${current.name}'s turn` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
{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"
|
||||
@@ -92,14 +137,37 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
||||
glossary={glossary}
|
||||
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 }))}
|
||||
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) => removeCombatant(e, c.id))}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -126,20 +194,26 @@ function AddCombatantBar({
|
||||
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;
|
||||
onAdd({
|
||||
id: newId(),
|
||||
name: name.trim(),
|
||||
kind: 'monster',
|
||||
initiative: init,
|
||||
ac,
|
||||
hp: { current: hp, max: hp, temp: 0 },
|
||||
conditions: [],
|
||||
notes: '',
|
||||
});
|
||||
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) => {
|
||||
@@ -152,6 +226,7 @@ function AddCombatantBar({
|
||||
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: [],
|
||||
@@ -178,6 +253,10 @@ function AddCombatantBar({
|
||||
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>
|
||||
@@ -260,7 +339,8 @@ function CombatantRow({
|
||||
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
|
||||
>
|
||||
{cond.name}
|
||||
{cond.value ? ` ${cond.value}` : ''} ✕
|
||||
{cond.value ? ` ${cond.value}` : ''}
|
||||
{cond.duration ? ` (${cond.duration}r)` : ''} ✕
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -319,6 +399,7 @@ function ConditionPicker({
|
||||
}) {
|
||||
const [sel, setSel] = useState('');
|
||||
const [value, setValue] = useState(1);
|
||||
const [dur, setDur] = useState(0);
|
||||
const [custom, setCustom] = useState('');
|
||||
|
||||
const conditions = getConditions(system);
|
||||
@@ -329,18 +410,21 @@ function ConditionPicker({
|
||||
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({ name: custom.trim() });
|
||||
onAdd(withDuration({ name: custom.trim() }));
|
||||
reset();
|
||||
};
|
||||
|
||||
const addValued = () => {
|
||||
if (!selected) return;
|
||||
onAdd({ name: selected.name, value });
|
||||
onAdd(withDuration({ name: selected.name, value }));
|
||||
reset();
|
||||
};
|
||||
|
||||
@@ -350,7 +434,7 @@ function ConditionPicker({
|
||||
if (name === '' || name === '__custom__') return;
|
||||
const def = conditions.find((c) => c.name === name);
|
||||
if (def && !def.valued) {
|
||||
onAdd({ name: def.name });
|
||||
onAdd(withDuration({ name: def.name }));
|
||||
reset();
|
||||
}
|
||||
};
|
||||
@@ -374,6 +458,11 @@ function ConditionPicker({
|
||||
<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" />
|
||||
|
||||
Reference in New Issue
Block a user