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();
|
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 }) => {
|
test('character can be deleted', async ({ page }) => {
|
||||||
await makeCampaign(page, 'Delete Test');
|
await makeCampaign(page, 'Delete Test');
|
||||||
await page.getByRole('link', { name: 'Characters' }).click();
|
await page.getByRole('link', { name: 'Characters' }).click();
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from 'react';
|
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 { encountersRepo } from '@/lib/db/repositories';
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { createRng } from '@/lib/rng';
|
import { createRng } from '@/lib/rng';
|
||||||
import { rollDice } from '@/lib/dice/notation';
|
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 { useCharacters } from '@/features/characters/hooks';
|
||||||
import {
|
import {
|
||||||
addCombatant,
|
addCombatant,
|
||||||
@@ -86,6 +86,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
<CombatantRow
|
<CombatantRow
|
||||||
key={c.id}
|
key={c.id}
|
||||||
combatant={c}
|
combatant={c}
|
||||||
|
system={campaign.system}
|
||||||
isCurrent={isActive && idx === encounter.turnIndex}
|
isCurrent={isActive && idx === encounter.turnIndex}
|
||||||
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))}
|
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))}
|
||||||
onDamage={(amt) => mutate((e) => updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }))}
|
onDamage={(amt) => mutate((e) => updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }))}
|
||||||
@@ -204,6 +205,7 @@ function AddCombatantBar({
|
|||||||
|
|
||||||
function CombatantRow({
|
function CombatantRow({
|
||||||
combatant: c,
|
combatant: c,
|
||||||
|
system,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
onChange,
|
onChange,
|
||||||
onDamage,
|
onDamage,
|
||||||
@@ -212,6 +214,7 @@ function CombatantRow({
|
|||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
combatant: Combatant;
|
combatant: Combatant;
|
||||||
|
system: SystemId;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
onChange: (patch: Partial<Combatant>) => void;
|
onChange: (patch: Partial<Combatant>) => void;
|
||||||
onDamage: (amt: number) => void;
|
onDamage: (amt: number) => void;
|
||||||
@@ -220,7 +223,6 @@ function CombatantRow({
|
|||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [delta, setDelta] = useState(0);
|
const [delta, setDelta] = useState(0);
|
||||||
const [conditionText, setConditionText] = useState('');
|
|
||||||
const dead = c.hp.current <= 0;
|
const dead = c.hp.current <= 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -290,23 +292,103 @@ function CombatantRow({
|
|||||||
<Button size="icon" variant="ghost" className="text-danger" onClick={onRemove} aria-label={`Remove ${c.name}`}>✕</Button>
|
<Button size="icon" variant="ghost" className="text-danger" onClick={onRemove} aria-label={`Remove ${c.name}`}>✕</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2">
|
||||||
<Input
|
<ConditionPicker
|
||||||
className="h-8 max-w-48 text-xs"
|
system={system}
|
||||||
value={conditionText}
|
existing={c.conditions}
|
||||||
placeholder="Add condition (e.g. Prone, Frightened 2)"
|
onAdd={(cond) => onChange({ conditions: [...c.conditions, cond] })}
|
||||||
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>
|
</div>
|
||||||
</li>
|
</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 * from './types';
|
||||||
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
|
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
|
||||||
export { applyRest } from './rest';
|
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