3d2c68a1a1
The "buttons, not chatbot" core: the app recognizes a situation and offers the fix; the user never has to formulate a request. - src/lib/assistant/signals.ts: buildSignals() aggregates the existing advisor detectors with new kernel-aware ones — quest-complete (all objectives done → "Mark complete"), caster out-of-slots → "Short rest", concentrating-while- bloodied warning — deduped and severity-sorted. 6 unit tests. - extended SuggestAction with shortRest + completeQuest; one shared dispatcher (useSignalAction) used by both surfaces so behaviour can't drift. - SignalsBell: an ambient bell + popover in the top bar, visible from every screen, with a count badge, one-click actions, and per-signal/clear-all dismiss. AssistantPage refactored onto the same buildSignals + dispatcher. - the AI cards were already actionable (NpcGen → npcsRepo, EncounterTip → add to encounter, Assistant encounter builder → combat). Also fixed a pre-existing invalid-HTML bug: ConditionPicker rendered a <Check> SVG inside <option> (React hydration error spam) → plain "✓" text. Build + 277 unit tests green; bell verified live (popover, actions, no errors). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
647 lines
25 KiB
TypeScript
647 lines
25 KiB
TypeScript
import { 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(nextTurn);
|
|
const upcoming = currentCombatant(nextTurn(encounter));
|
|
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';
|
|
|
|
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)}>
|
|
<ChevronLeft size={15} aria-hidden /> Prev
|
|
</Button>
|
|
<Button variant="primary" onClick={advanceTurn}>
|
|
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);
|
|
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>
|
|
|
|
{/* 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) => (
|
|
<option key={t} value={t}>{def!.immune.includes(t) ? `${t} (immune)` : def!.resist.includes(t) ? `${t} (resist)` : def!.vulnerable.includes(t) ? `${t} (vuln)` : t}</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 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>
|
|
);
|
|
}
|