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:
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user