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 { NumberField } from '@/components/ui/NumberField';
|
||||||
import { SheetSection, type SectionProps } from './common';
|
import { SheetSection, type SectionProps } from './common';
|
||||||
import { rankLabel } from './labels';
|
import { rankLabel } from './labels';
|
||||||
|
import { WeaponPickerModal } from './WeaponPickerModal';
|
||||||
|
|
||||||
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||||
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
|
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) {
|
export function AttacksSection({ c, update }: SectionProps) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [picking, setPicking] = useState(false);
|
||||||
const sys = getSystem(c.system);
|
const sys = getSystem(c.system);
|
||||||
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
|
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
|
||||||
const rulesInput: CharacterRulesInput = { level: c.level, abilities: c.abilities };
|
const rulesInput: CharacterRulesInput = { level: c.level, abilities: c.abilities };
|
||||||
@@ -47,8 +49,10 @@ export function AttacksSection({ c, update }: SectionProps) {
|
|||||||
New attack
|
New attack
|
||||||
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Longsword, Shortbow…" />
|
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Longsword, Shortbow…" />
|
||||||
</label>
|
</label>
|
||||||
|
{c.system === '5e' && <Button variant="secondary" onClick={() => setPicking(true)}>From weapon…</Button>}
|
||||||
<Button variant="primary" onClick={add}>Add</Button>
|
<Button variant="primary" onClick={add}>Add</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{picking && <WeaponPickerModal onPick={(atk) => update({ attacks: [...c.attacks, atk] })} onClose={() => setPicking(false)} />}
|
||||||
|
|
||||||
{c.attacks.length === 0 ? (
|
{c.attacks.length === 0 ? (
|
||||||
<p className="text-sm text-muted">No attacks defined.</p>
|
<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 { Button } from '@/components/ui/Button';
|
||||||
import { Input, Textarea } from '@/components/ui/Input';
|
import { Input, Textarea } from '@/components/ui/Input';
|
||||||
import { SheetSection, type SectionProps } from './common';
|
import { SheetSection, type SectionProps } from './common';
|
||||||
|
import { FeatPickerModal } from './FeatPickerModal';
|
||||||
|
|
||||||
/** Feats tracked as first-class records (name + effect) rather than buried in notes. */
|
/** Feats tracked as first-class records (name + effect) rather than buried in notes. */
|
||||||
export function FeatsSection({ c, update }: SectionProps) {
|
export function FeatsSection({ c, update }: SectionProps) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
|
const [picking, setPicking] = useState(false);
|
||||||
|
|
||||||
const add = () => {
|
const add = () => {
|
||||||
if (name.trim() === '') return;
|
if (name.trim() === '') return;
|
||||||
@@ -27,8 +29,10 @@ export function FeatsSection({ c, update }: SectionProps) {
|
|||||||
Add feat / feature
|
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…" />
|
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Great Weapon Master, Oath of Devotion…" />
|
||||||
</label>
|
</label>
|
||||||
|
{c.system === '5e' && <Button variant="secondary" onClick={() => setPicking(true)}>Browse feats…</Button>}
|
||||||
<Button variant="primary" onClick={add}>Add</Button>
|
<Button variant="primary" onClick={add}>Add</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{picking && <FeatPickerModal onPick={(feat) => update({ feats: [...c.feats, feat] })} onClose={() => setPicking(false)} />}
|
||||||
|
|
||||||
{c.feats.length === 0 ? (
|
{c.feats.length === 0 ? (
|
||||||
<p className="text-sm text-muted">No feats or features tracked yet.</p>
|
<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