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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user