Files
ttrpg_manager/src/features/combat/EncounterTracker.tsx
T
NilsBriggen f602c832e6 Fix condition mechanics: per-statistic PF2e penalties, 5e exhaustion, slowed, frightened decay
- PF2e clumsy/enfeebled/drained/stupefied now penalise their correct, independent
  statistic family (Dex/Str/Con/mental) instead of collapsing into one max'd '−N status'.
  Frightened/sickened are 'all checks'. Distinct badges per family.
- Slowed is action loss only (no roll penalty); fatigued shows flat −1 AC/saves —
  neither is value-scaled anymore.
- 5e Exhaustion now derives its cumulative level effects (ability-check disadvantage L1,
  speed halved L2, attack + all-save disadvantage L3, speed 0 L5, dead L6).
- 5e Prone badge is range-qualified (adv. melee / disadv. ranged).
- New tickConditionsEndOfTurn: PF2e Frightened decays by 1 at end of turn (wired into
  nextTurn via the combat tracker), removed at 0.

MechanicalState.statusPenalty (single number) → statusPenalties (per-family map).
Tests updated + added. Deferred: Drained HP/max-HP reduction (needs creature level
threaded into the damage flow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:16:33 +02:00

676 lines
26 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import {
ArrowDown,
ArrowUp,
ChevronLeft,
ChevronRight,
Dices,
Heart,
Hourglass,
Play,
ScrollText,
Shield,
Skull,
Sword,
Undo2,
X,
} from 'lucide-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 { deriveState, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics';
import { useCharacters, useAllPcs } 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';
import { Badge, Meter, Avatar } from '@/components/ui/Codex';
import { EncounterTipCard } from '@/features/assistant/EncounterTipCard';
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
const characters = useCharacters(campaign.id);
// PCs are campaign-agnostic, so any PC can join a fight; NPCs stay campaign-scoped.
const allPcs = useAllPcs();
const addableChars = [...allPcs, ...characters.filter((c) => c.kind === 'npc')];
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();
// Transactional read-modify-write so rapid mutations can't clobber each other.
void encountersRepo.mutate(encounter.id, fn);
};
const undo = () => {
const prev = undoStack.current.pop();
if (prev) void encountersRepo.save(prev);
};
/** The Character backing a PC/NPC combatant, if any (for cross-doc rules state). */
const pcCharacter = (combatant: Combatant): Character | undefined =>
combatant.kind !== 'monster' && combatant.characterId
? [...allPcs, ...characters].find((ch) => ch.id === combatant.characterId)
: undefined;
/**
* After a concentrating PC takes damage, the app computes the Constitution
* save DC and surfaces it — but never rolls. The player rolls their own save
* and, on a failure, uses the Drop button on their sheet/panel. (Auto-rolling
* would take the moment away from the player.)
*/
const noteConcentrationCheck = (combatant: Combatant, damage: number) => {
if (damage <= 0) return;
const ch = pcCharacter(combatant);
if (!ch?.concentration) return;
const dc = concentrationDC(damage);
void encountersRepo.mutate(encounter.id, (e) =>
logEvent(e, `${ch.name}: roll a DC ${dc} Constitution save or lose concentration on ${ch.concentration!.spellName}.`));
};
/** Advance the turn; remind (don't roll) when a downed PC's turn begins. */
const advanceTurn = () => {
mutate((e) => nextTurn(e, campaign.system));
const upcoming = currentCombatant(nextTurn(encounter, campaign.system));
if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) {
void encountersRepo.mutate(encounter.id, (e) => logEvent(e, `${upcoming.name} is down — make a death saving throw.`));
}
};
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. Once combat
// has started, rate against the levels captured at that time (snapshot) so the
// reading — and the assistant's history — reflects the party as it was.
// Rate against the PCs actually IN the fight (so adding a player updates the
// difficulty); fall back to the campaign roster before any PC is added.
const encounterPcLevels = encounter.combatants.filter((c) => c.kind === 'pc' && c.level !== undefined).map((c) => c.level as number);
const currentLevels = encounterPcLevels.length > 0 ? encounterPcLevels : characters.filter((c) => c.kind === 'pc').map((c) => c.level);
const partyLevels = encounter.partyLevelsSnapshot?.length ? encounter.partyLevelsSnapshot : currentLevels;
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';
// Keyboard turn control for a GM running the table hands-free. Guarded so it
// never fires while typing in a field, and only during an active fight.
const advanceRef = useRef(advanceTurn);
advanceRef.current = advanceTurn;
const prevRef = useRef(() => mutate(previousTurn));
prevRef.current = () => mutate(previousTurn);
useEffect(() => {
if (!isActive) return;
const onKey = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
const el = e.target as HTMLElement | null;
if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT' || el.isContentEditable)) return;
if (e.key === 'n' || e.key === 'ArrowRight') { e.preventDefault(); advanceRef.current(); }
else if (e.key === 'p' || e.key === 'ArrowLeft') { e.preventDefault(); prevRef.current(); }
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isActive]);
return (
<div>
{/* Control bar */}
<div className="paper-grain mb-4 flex flex-wrap items-center gap-3 rounded-xl border border-line bg-panel p-3">
<div>
<div className="font-display text-sm italic text-accent">Encounter · {encounter.name}</div>
<div className="font-display text-lg font-semibold text-ink">Initiative</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>
{isActive && (
<div className="flex items-center gap-2 rounded-xl border border-line bg-surface-2 px-3 py-1.5">
<Hourglass size={15} aria-hidden className="text-accent-deep" />
<span className="smallcaps" style={{ fontSize: 10 }}>Round</span>
<span className="font-display text-xl font-semibold text-accent-deep">{encounter.round}</span>
</div>
)}
{budget && (
<div className="rounded-xl border border-line bg-surface-2 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="font-mono 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">
<Undo2 size={15} aria-hidden /> Undo
</Button>
)}
{encounter.combatants.length > 0 && (
<Button variant="secondary" onClick={rollAllInitiative} title="Roll initiative for everyone">
<Dices size={15} aria-hidden /> Roll all
</Button>
)}
{!isActive && encounter.status !== 'ended' && (
<Button
variant="primary"
disabled={encounter.combatants.length === 0}
onClick={() => mutate((e) => ({
...startEncounter(e),
...(currentLevels.length ? { partyLevelsSnapshot: currentLevels } : {}),
}))}
>
Start combat
</Button>
)}
{isActive && (
<>
<Button variant="secondary" onClick={() => mutate(previousTurn)} title="Previous turn (p or ←)">
<ChevronLeft size={15} aria-hidden /> Prev
</Button>
<Button variant="primary" onClick={advanceTurn} title="Next turn (n or →)">
Next turn <ChevronRight size={15} aria-hidden />
</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>
<EncounterTipCard campaign={campaign} encounter={encounter} />
<AddCombatantBar encounter={encounter} characters={addableChars} 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>
) : (
<>
<div className="mt-4 flex items-center justify-between px-1 pb-1.5">
<span className="smallcaps">Turn order · {encounter.combatants.length} combatants</span>
</div>
<ul className="space-y-1.5">
{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, type) => {
const after = applyDamage(c, amt, type);
const hpLoss = (c.hp.current - after.hp.current) + (c.hp.temp - after.hp.temp);
const note = hpLoss === amt
? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage`
: `${c.name} takes ${hpLoss}${type ? ` ${type}` : ''} damage (${amt} before ${hpLoss < amt ? 'resistance' : 'vulnerability'})`;
mutate((e) => logEvent(updateCombatant(e, c.id, { hp: after.hp }), note));
noteConcentrationCheck(c, hpLoss);
}}
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.5 flex items-center justify-between">
<h3 className="smallcaps flex items-center gap-1.5">
<ScrollText size={13} aria-hidden className="text-accent-deep" />
Combat Log
</h3>
<Button size="sm" variant="ghost" onClick={onClear}>Clear</Button>
</div>
<hr className="gilt-rule mb-2" />
<ul className="max-h-48 space-y-1 overflow-auto rounded-xl border border-line bg-surface-2 p-3 text-xs">
{recent.map((l, i) => (
<li key={i} className="flex items-start gap-2 text-ink-soft">
<span className="mt-1.5 inline-block size-1.5 shrink-0 rounded-full bg-accent" aria-hidden />
<span className="font-display leading-snug">
<span className="mr-1.5 font-mono text-faint">R{l.round}</span>
{l.text}
</span>
</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: name.trim(), // engine auto-numbers duplicates
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, ...(ch.equippedArmor ? { equippedArmor: ch.equippedArmor } : {}) }),
hp: { ...ch.hp },
conditions: [],
notes: '',
});
};
return (
<div className="space-y-2 rounded-xl border border-line bg-panel-2 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, type?: string) => void;
onHeal: (amt: number) => void;
onMove: (dir: -1 | 1) => void;
onRemove: () => void;
}) {
const [delta, setDelta] = useState(0);
const [dmgType, setDmgType] = useState('');
// Only show the damage-type picker when this creature actually has typed defenses.
const def = c.damageDefenses;
const hasDefenses = !!def && (def.resist.length > 0 || def.immune.length > 0 || def.vulnerable.length > 0 || (def.resistFlat?.length ?? 0) > 0 || (def.weakness?.length ?? 0) > 0);
const dead = c.hp.current <= 0;
const foe = c.kind === 'monster';
const ratio = c.hp.max > 0 ? c.hp.current / c.hp.max : 0;
const hpTone = ratio < 0.35 ? 'var(--app-danger)' : ratio < 0.7 ? 'var(--app-accent)' : 'var(--app-verdigris)';
const avatarTone = foe ? 'var(--app-danger)' : 'var(--app-accent)';
return (
<li
className={cn(
'rounded-xl border p-3 transition-colors',
isCurrent
? foe
? 'border-danger bg-danger-glow'
: 'border-accent bg-accent-glow'
: 'border-line bg-panel',
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="relative">
<Avatar name={c.name} size={40} tone={avatarTone} />
{foe && (
<span
className="absolute -bottom-0.5 -right-0.5 grid size-4 place-items-center rounded-full border-2 border-panel bg-danger"
aria-hidden
>
<Skull size={9} className="text-white" />
</span>
)}
</div>
<div className="min-w-32 flex-1">
<div className="flex items-center gap-2">
<span className="font-display font-semibold text-ink">{c.name}</span>
{isCurrent && (
<Badge tone={foe ? 'ember' : 'gold'}>
<Play size={11} aria-hidden /> Active
</Badge>
)}
<span className="smallcaps" style={{ fontSize: 9 }}>{c.kind}</span>
{dead && (
<Badge tone="ember">
<Skull size={11} aria-hidden /> DOWN
</Badge>
)}
</div>
<div className="mt-1.5 flex items-center gap-2">
<div className="w-32">
<Meter value={c.hp.current} max={c.hp.max} tone={hpTone} height={6} />
</div>
<span className="font-mono text-xs text-faint">
{c.hp.current}/{c.hp.max}
{c.hp.temp > 0 && <span className="text-info"> +{c.hp.temp}</span>}
</span>
</div>
{c.conditions.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => (
<button
key={`${cond.name}-${i}`}
className="inline-flex items-center gap-1 rounded-full border border-verdigris/45 px-2 py-0.5 text-xs font-medium text-verdigris transition-colors 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)` : ''}
<X size={11} aria-hidden />
</button>
))}
</div>
)}
{/* Mechanical consequences the conditions impose (enforced read-model). */}
{(() => {
const st = deriveState(system, 30, c.conditions);
return st.badges.length > 0 ? (
<div className="mt-1 flex flex-wrap gap-1" aria-label="Condition effects">
{st.badges.map((b) => (
<span key={b} className="rounded border border-line bg-surface px-1.5 py-0.5 text-[10px] font-medium text-muted">{b}</span>
))}
</div>
) : null;
})()}
</div>
{/* Action controls — kept grouped so they wrap together, not scattered, on narrow widths */}
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
{/* HP controls */}
<div className="flex items-center gap-1">
<NumberField className="w-14" value={delta} min={0} onChange={setDelta} aria-label={`${c.name} HP amount`} />
{hasDefenses && (
<Select className="w-auto py-1 text-xs" value={dmgType} onChange={(e) => setDmgType(e.target.value)} aria-label={`${c.name} damage type`}>
<option value="">untyped</option>
{DAMAGE_TYPES.filter((t) => t !== 'untyped').map((t) => {
const rf = def!.resistFlat?.find((r) => r.type === t);
const wk = def!.weakness?.find((w) => w.type === t);
const tag = def!.immune.includes(t) ? ' (immune)'
: def!.vulnerable.includes(t) ? ' (vuln)'
: def!.resist.includes(t) ? ' (resist)'
: rf ? ` (resist ${rf.amount})`
: wk ? ` (weak ${wk.amount})` : '';
return <option key={t} value={t}>{t}{tag}</option>;
})}
</Select>
)}
<Button size="sm" variant="danger" disabled={delta <= 0} onClick={() => { onDamage(delta, dmgType || undefined); setDelta(0); }} title="Apply damage">
<Sword size={14} aria-hidden /> Dmg
</Button>
<Button size="sm" variant="secondary" disabled={delta <= 0} onClick={() => { onHeal(delta); setDelta(0); }} title="Heal">
<Heart size={14} aria-hidden className="text-verdigris" /> Heal
</Button>
</div>
<div className="flex flex-col items-center">
<div className="flex items-center gap-1 text-muted">
<Shield size={13} aria-hidden />
<span className="smallcaps" style={{ fontSize: 9 }}>AC</span>
</div>
<NumberField className="mt-0.5 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">
<ArrowUp size={15} aria-hidden />
</Button>
<Button size="icon" variant="ghost" onClick={() => onMove(1)} aria-label="Move down" title="Move down">
<ArrowDown size={15} aria-hidden />
</Button>
</div>
<Button size="icon" variant="ghost" className="text-danger" onClick={onRemove} aria-label={`Remove ${c.name}`}>
<X size={15} aria-hidden />
</Button>
</div>
</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>
);
}