Combat: condition dropdown picker with tag chips

- System-aware condition list (5e PHB / pf2e) in src/lib/rules/conditions.ts
- Dropdown replaces free-text input; non-valued conditions add instantly,
  valued ones (Exhaustion, Frightened N, etc.) show a value field, plus Custom
- Already-applied conditions disabled in the list; chips remain click-to-remove
- New e2e covering preset + valued add and tag removal

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 00:44:37 +02:00
parent 866f1e1bf1
commit 0b356adf82
5 changed files with 194 additions and 19 deletions
+26
View File
@@ -37,6 +37,32 @@ test('combat HP damage button reduces hit points', async ({ page }) => {
await expect(page.getByText('8/10')).toBeVisible();
});
test('combat condition picker adds preset and valued conditions as tags', async ({ page }) => {
await makeCampaign(page, 'Conditions');
await page.getByRole('link', { name: 'Combat' }).click();
await page.getByRole('button', { name: '+ New encounter' }).first().click();
await page.locator('input[data-autofocus]').fill('Fight');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByLabel('Name').fill('Goblin');
await page.getByRole('button', { name: 'Add', exact: true }).click();
const row = page.locator('li', { hasText: 'Goblin' });
// Non-valued condition adds immediately on selection
await row.getByLabel('Add condition').selectOption('Prone');
await expect(row.getByRole('button', { name: /Prone/ })).toBeVisible();
// Valued condition (5e Exhaustion) shows a value field, then adds a tag
await row.getByLabel('Add condition').selectOption('Exhaustion');
await row.getByLabel('Condition value').fill('3');
await row.getByRole('button', { name: 'Add', exact: true }).click();
await expect(row.getByRole('button', { name: /Exhaustion 3/ })).toBeVisible();
// Clicking a tag removes it
await row.getByRole('button', { name: /Prone/ }).click();
await expect(row.getByRole('button', { name: /Prone/ })).toHaveCount(0);
});
test('character can be deleted', async ({ page }) => {
await makeCampaign(page, 'Delete Test');
await page.getByRole('link', { name: 'Characters' }).click();
+100 -18
View File
@@ -1,10 +1,10 @@
import { useState } from 'react';
import type { Campaign, Character, Combatant, Encounter } from '@/lib/schemas';
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 } from '@/lib/rules';
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
import { useCharacters } from '@/features/characters/hooks';
import {
addCombatant,
@@ -86,6 +86,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
<CombatantRow
key={c.id}
combatant={c}
system={campaign.system}
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 }))}
@@ -204,6 +205,7 @@ function AddCombatantBar({
function CombatantRow({
combatant: c,
system,
isCurrent,
onChange,
onDamage,
@@ -212,6 +214,7 @@ function CombatantRow({
onRemove,
}: {
combatant: Combatant;
system: SystemId;
isCurrent: boolean;
onChange: (patch: Partial<Combatant>) => void;
onDamage: (amt: number) => void;
@@ -220,7 +223,6 @@ function CombatantRow({
onRemove: () => void;
}) {
const [delta, setDelta] = useState(0);
const [conditionText, setConditionText] = useState('');
const dead = c.hp.current <= 0;
return (
@@ -290,23 +292,103 @@ function CombatantRow({
<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 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 [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);
setCustom('');
};
const addCustom = () => {
if (custom.trim() === '') return;
onAdd({ name: custom.trim() });
reset();
};
const addValued = () => {
if (!selected) return;
onAdd({ 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({ 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>
{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>
);
}
+66
View File
@@ -0,0 +1,66 @@
import type { SystemId } from './types';
export interface ConditionDef {
name: string;
/** true for conditions that carry a numeric value (e.g. PF2e Frightened 2). */
valued: boolean;
}
/** D&D 5e conditions (PHB). Exhaustion is the only valued one. */
export const CONDITIONS_5E: readonly ConditionDef[] = [
{ name: 'Blinded', valued: false },
{ name: 'Charmed', valued: false },
{ name: 'Concentrating', valued: false },
{ name: 'Deafened', valued: false },
{ name: 'Exhaustion', valued: true },
{ name: 'Frightened', valued: false },
{ name: 'Grappled', valued: false },
{ name: 'Incapacitated', valued: false },
{ name: 'Invisible', valued: false },
{ name: 'Paralyzed', valued: false },
{ name: 'Petrified', valued: false },
{ name: 'Poisoned', valued: false },
{ name: 'Prone', valued: false },
{ name: 'Restrained', valued: false },
{ name: 'Stunned', valued: false },
{ name: 'Unconscious', valued: false },
];
/** Pathfinder 2e conditions. Many carry a value. */
export const CONDITIONS_PF2E: readonly ConditionDef[] = [
{ name: 'Blinded', valued: false },
{ name: 'Clumsy', valued: true },
{ name: 'Concealed', valued: false },
{ name: 'Confused', valued: false },
{ name: 'Controlled', valued: false },
{ name: 'Dazzled', valued: false },
{ name: 'Deafened', valued: false },
{ name: 'Doomed', valued: true },
{ name: 'Drained', valued: true },
{ name: 'Dying', valued: true },
{ name: 'Enfeebled', valued: true },
{ name: 'Fascinated', valued: false },
{ name: 'Fatigued', valued: false },
{ name: 'Fleeing', valued: false },
{ name: 'Frightened', valued: true },
{ name: 'Grabbed', valued: false },
{ name: 'Immobilized', valued: false },
{ name: 'Invisible', valued: false },
{ name: 'Off-Guard', valued: false },
{ name: 'Paralyzed', valued: false },
{ name: 'Persistent Damage', valued: true },
{ name: 'Petrified', valued: false },
{ name: 'Prone', valued: false },
{ name: 'Quickened', valued: false },
{ name: 'Restrained', valued: false },
{ name: 'Sickened', valued: true },
{ name: 'Slowed', valued: true },
{ name: 'Stunned', valued: true },
{ name: 'Stupefied', valued: true },
{ name: 'Unconscious', valued: false },
{ name: 'Wounded', valued: true },
];
export function getConditions(system: SystemId): readonly ConditionDef[] {
return system === 'pf2e' ? CONDITIONS_PF2E : CONDITIONS_5E;
}
+1
View File
@@ -19,3 +19,4 @@ export const SYSTEM_OPTIONS: { id: SystemId; label: string }[] = [
export * from './types';
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
export { applyRest } from './rest';
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/dice/DicePage.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/dice/DicePage.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}