From 0b356adf82b9fb89b7a764a1b6d2461b2e003199 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 00:44:37 +0200 Subject: [PATCH] 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) --- e2e/fixes.spec.ts | 26 +++++ src/features/combat/EncounterTracker.tsx | 118 +++++++++++++++++++---- src/lib/rules/conditions.ts | 66 +++++++++++++ src/lib/rules/index.ts | 1 + tsconfig.app.tsbuildinfo | 2 +- 5 files changed, 194 insertions(+), 19 deletions(-) create mode 100644 src/lib/rules/conditions.ts diff --git a/e2e/fixes.spec.ts b/e2e/fixes.spec.ts index 5a37f1d..f3415ba 100644 --- a/e2e/fixes.spec.ts +++ b/e2e/fixes.spec.ts @@ -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(); diff --git a/src/features/combat/EncounterTracker.tsx b/src/features/combat/EncounterTracker.tsx index e1f02bc..47a45ce 100644 --- a/src/features/combat/EncounterTracker.tsx +++ b/src/features/combat/EncounterTracker.tsx @@ -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 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) => 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({ -
- 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(''); - } - }} +
+ onChange({ conditions: [...c.conditions, cond] })} />
); } + +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 ( +
+ + + {selected?.valued && ( + <> + + + + )} + + {isCustom && ( + <> + setCustom(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addCustom()} + /> + + + )} +
+ ); +} diff --git a/src/lib/rules/conditions.ts b/src/lib/rules/conditions.ts new file mode 100644 index 0000000..452bb63 --- /dev/null +++ b/src/lib/rules/conditions.ts @@ -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; +} diff --git a/src/lib/rules/index.ts b/src/lib/rules/index.ts index 83264ab..7a285c9 100644 --- a/src/lib/rules/index.ts +++ b/src/lib/rules/index.ts @@ -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'; diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo index 0ebef53..aa615de 100644 --- a/tsconfig.app.tsbuildinfo +++ b/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file