Phase 4 (part 1): backed feat & weapon pickers

- FeatPickerModal: browse the 104-feat compendium (PHB/XGtE/TCE) and add a real feat with
  its source + description, instead of typing a name. Wired into FeatsSection (5e).
- WeaponPickerModal: pick a 5e weapon and get a ready-to-roll attack with the correct
  damage dice/type and a sensible ability default (ranged/finesse → DEX, else STR).
  Wired into AttacksSection (5e).

Directly addresses 'feats are missing' and 'selectable weapons are missing'. Verified
in-app (Rapier→finesse attack; Great Weapon Master + XGtE/TCE feats listed). 339 tests
green, build OK. (Subclass/background mechanical effects + higher-level auto-build are the
remaining Phase 4 depth.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 01:31:27 +02:00
parent 4534865e74
commit f8ad8cb681
5 changed files with 124 additions and 1 deletions
@@ -12,6 +12,7 @@ import { Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { SheetSection, type SectionProps } from './common';
import { rankLabel } from './labels';
import { WeaponPickerModal } from './WeaponPickerModal';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
@@ -19,6 +20,7 @@ const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master
export function AttacksSection({ c, update }: SectionProps) {
const [name, setName] = useState('');
const [picking, setPicking] = useState(false);
const sys = getSystem(c.system);
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
const rulesInput: CharacterRulesInput = { level: c.level, abilities: c.abilities };
@@ -47,8 +49,10 @@ export function AttacksSection({ c, update }: SectionProps) {
New attack
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Longsword, Shortbow…" />
</label>
{c.system === '5e' && <Button variant="secondary" onClick={() => setPicking(true)}>From weapon</Button>}
<Button variant="primary" onClick={add}>Add</Button>
</div>
{picking && <WeaponPickerModal onPick={(atk) => update({ attacks: [...c.attacks, atk] })} onClose={() => setPicking(false)} />}
{c.attacks.length === 0 ? (
<p className="text-sm text-muted">No attacks defined.</p>
@@ -0,0 +1,54 @@
import { useEffect, useMemo, useState } from 'react';
import { loadFeats5e } from '@/lib/compendium';
import type { Feat5e } from '@/lib/compendium/types';
import { sourceLabel } from '@/lib/compendium/mpmb';
import { newId } from '@/lib/ids';
import type { Feat } from '@/lib/schemas';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
/** Browse the 5e feat compendium (PHB/XGtE/TCE) and add one as a tracked feat. */
export function FeatPickerModal({ onPick, onClose }: { onPick: (f: Feat) => void; onClose: () => void }) {
const [feats, setFeats] = useState<Feat5e[]>([]);
const [q, setQ] = useState('');
useEffect(() => {
let on = true;
void loadFeats5e().then((f) => on && setFeats([...f].sort((a, b) => a.name.localeCompare(b.name))));
return () => { on = false; };
}, []);
const results = useMemo(() => {
const s = q.trim().toLowerCase();
return feats.filter((f) => !s || f.name.toLowerCase().includes(s)).slice(0, 80);
}, [feats, q]);
const pick = (f: Feat5e) => {
onPick({
id: newId(),
name: f.name,
source: f.source?.[0]?.source ? sourceLabel(f.source[0].source) : '',
description: f.description ?? '',
});
onClose();
};
return (
<Modal open onClose={onClose} title="Add a feat" className="max-w-lg"
footer={<Button variant="ghost" onClick={onClose}>Close</Button>}>
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search feats…" className="mb-2" aria-label="Search feats" data-autofocus />
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
{feats.length === 0 && <p className="text-sm text-muted">Loading</p>}
{results.map((f) => (
<button key={f.name} onClick={() => pick(f)}
className="block w-full rounded-md border border-line bg-surface px-2 py-1.5 text-left hover:border-accent/60">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-ink">{f.name}</span>
{f.source?.[0]?.source && <span className="shrink-0 text-[10px] text-muted">{sourceLabel(f.source[0].source)}</span>}
</div>
{f.description && <p className="line-clamp-2 text-[11px] text-muted">{f.description}</p>}
</button>
))}
</div>
</Modal>
);
}
@@ -5,10 +5,12 @@ import type { Feat } from '@/lib/schemas';
import { Button } from '@/components/ui/Button';
import { Input, Textarea } from '@/components/ui/Input';
import { SheetSection, type SectionProps } from './common';
import { FeatPickerModal } from './FeatPickerModal';
/** Feats tracked as first-class records (name + effect) rather than buried in notes. */
export function FeatsSection({ c, update }: SectionProps) {
const [name, setName] = useState('');
const [picking, setPicking] = useState(false);
const add = () => {
if (name.trim() === '') return;
@@ -27,8 +29,10 @@ export function FeatsSection({ c, update }: SectionProps) {
Add feat / feature
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Great Weapon Master, Oath of Devotion…" />
</label>
{c.system === '5e' && <Button variant="secondary" onClick={() => setPicking(true)}>Browse feats</Button>}
<Button variant="primary" onClick={add}>Add</Button>
</div>
{picking && <FeatPickerModal onPick={(feat) => update({ feats: [...c.feats, feat] })} onClose={() => setPicking(false)} />}
{c.feats.length === 0 ? (
<p className="text-sm text-muted">No feats or features tracked yet.</p>
@@ -0,0 +1,61 @@
import { useEffect, useMemo, useState } from 'react';
import { loadWeapons5e } from '@/lib/compendium';
import type { Weapon5e } from '@/lib/compendium/types';
import { newId } from '@/lib/ids';
import type { Attack } from '@/lib/schemas';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
/** Default attack ability for a weapon: ranged/finesse → DEX, else STR. */
function defaultAbility(w: Weapon5e): Attack['ability'] {
const props = (w.properties ?? []).map((p) => p.toLowerCase());
const ranged = /ranged/i.test(w.category ?? '') || props.some((p) => p.startsWith('ammunition') || p.startsWith('thrown'));
if (ranged || props.some((p) => p.startsWith('finesse'))) return 'dex';
return 'str';
}
function toAttack(w: Weapon5e): Attack {
return {
id: newId(),
name: w.name,
ability: defaultAbility(w),
rank: 'trained',
damageDice: w.damage_dice ?? '1d4',
damageType: w.damage_type ?? '',
itemBonus: 0,
addAbilityToDamage: true,
};
}
/** Pick a 5e weapon from the compendium; creates a ready-to-roll attack with correct dice. */
export function WeaponPickerModal({ onPick, onClose }: { onPick: (a: Attack) => void; onClose: () => void }) {
const [weapons, setWeapons] = useState<Weapon5e[]>([]);
const [q, setQ] = useState('');
useEffect(() => {
let on = true;
void loadWeapons5e().then((w) => on && setWeapons([...w].sort((a, b) => a.name.localeCompare(b.name))));
return () => { on = false; };
}, []);
const results = useMemo(() => {
const s = q.trim().toLowerCase();
return weapons.filter((w) => !s || w.name.toLowerCase().includes(s)).slice(0, 80);
}, [weapons, q]);
return (
<Modal open onClose={onClose} title="Add a weapon" className="max-w-lg"
footer={<Button variant="ghost" onClick={onClose}>Close</Button>}>
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search weapons…" className="mb-2" aria-label="Search weapons" data-autofocus />
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
{weapons.length === 0 && <p className="text-sm text-muted">Loading</p>}
{results.map((w) => (
<button key={w.slug} onClick={() => { onPick(toAttack(w)); onClose(); }}
className="flex w-full items-center justify-between gap-2 rounded-md border border-line bg-surface px-2 py-1.5 text-left text-sm hover:border-accent/60">
<span className="truncate text-ink">{w.name}</span>
<span className="shrink-0 text-xs text-muted">{w.damage_dice} {w.damage_type}{(w.properties ?? []).some((p) => /finesse/i.test(p)) ? ' · finesse' : ''}</span>
</button>
))}
</div>
</Modal>
);
}