4ce7a90534
No-shortcuts pass on the class feature/choice engine, and PF2e brought level with 5e. 5e subclass depth (subclass.5e.ts): - SUBCLASS_PROGRESSION_5E: per-level features for ~22 subclasses (SRD cores + popular), and subclass-specific CHOICES — Battle Master maneuvers (3/5/7/9 by level), Arcane Archer shots, Rune Knight runes, Way of Four Elements disciplines, Hunter's selectable features (Hunter's Prey/Defensive Tactics/Multiattack/Superior Defense), Totem Spirit, Dragon Ancestor. collectFeatures5e now expands the chosen subclass into its real features. PF2e feature/choice engine (data.pf2e.ts) — full parity: - CLASS_PROGRESSION_PF2E: signature features by level for all 24 classes. - Universal choices generated for every class: Class feats (per-class levels incl. the 1st-level martial feat), Skill feats (even levels), General feats (3/7/11/15/19), Ancestry feats (1/5/9/13/17), plus the 1st-level subclass (Instinct/Doctrine/Bloodline/ Racket/…) from PF2E_SUBCLASSES with options. Engine generalized: collectChoices(system)/collectFeatures(system) dispatch 5e vs pf2e; ClassFeaturesSection now renders for BOTH systems (pf2e subclass resolves via choices[]). Verified in-app: PF2e Barbarian 5 → Instinct (6 options) + Class(3)/Skill(2)/General(1)/ Ancestry(2) feats = '9 to choose' + Rage/Deny Advantage; selecting Dragon Instinct resolves and shows as a feature. 5e Fighter+Battle Master → Maneuvers (choose 3) + Combat Superiority. 352 tests green (+ pf2e & subclass suites), build OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
93 lines
4.4 KiB
TypeScript
93 lines
4.4 KiB
TypeScript
import { collectChoices, collectFeatures } from '@/lib/rules';
|
|
import type { ClassEntry } from '@/lib/schemas';
|
|
import { Input, Select } from '@/components/ui/Input';
|
|
import { Badge } from '@/components/ui/Codex';
|
|
import { SheetSection, type SectionProps } from './common';
|
|
|
|
/**
|
|
* Class features + every choice the character must make — 5e (Fighting Style, Pact Boon,
|
|
* Invocations, Metamagic, Expertise, subclass maneuvers…) and PF2e (class/skill/general/
|
|
* ancestry feats, instinct/doctrine/bloodline…) — so no unlock-able option is missed.
|
|
*/
|
|
export function ClassFeaturesSection({ c, update }: SectionProps) {
|
|
// 5e multiclasses via classes[]; pf2e is single-class with its subclass resolved in choices[].
|
|
const classes: ClassEntry[] = c.classes.length
|
|
? c.classes
|
|
: c.className
|
|
? [{ className: c.className, level: c.level, ...(c.system === 'pf2e' ? { subclass: c.choices.find((ch) => ch.key.endsWith(':subclass'))?.values[0] } : {}) }]
|
|
: [];
|
|
const features = collectFeatures(c.system, classes);
|
|
const choices = collectChoices(c.system, classes);
|
|
if (features.length === 0 && choices.length === 0) return null;
|
|
|
|
const getVals = (key: string) => c.choices.find((ch) => ch.key === key)?.values ?? [];
|
|
const setVal = (key: string, idx: number, value: string) => {
|
|
const cur = [...getVals(key)];
|
|
while (cur.length <= idx) cur.push('');
|
|
cur[idx] = value;
|
|
update({ choices: [...c.choices.filter((ch) => ch.key !== key), { key, values: cur }] });
|
|
};
|
|
|
|
const pendingTotal = choices.reduce((n, ch) => n + Math.max(0, ch.count - getVals(ch.key).filter(Boolean).length), 0);
|
|
|
|
return (
|
|
<SheetSection title="Class Features & Choices">
|
|
{choices.length > 0 && (
|
|
<div className="mb-4">
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<span className="smallcaps">Choices</span>
|
|
{pendingTotal > 0 && <Badge tone="ember">{pendingTotal} to choose</Badge>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
{choices.map((ch) => {
|
|
const vals = getVals(ch.key);
|
|
const filled = vals.filter(Boolean).length;
|
|
return (
|
|
<div key={ch.key} className="rounded-md border border-line bg-panel p-2">
|
|
<div className="mb-1 flex items-center gap-2 text-sm">
|
|
<span className="font-medium text-ink">{ch.label}</span>
|
|
<span className="text-[10px] uppercase text-muted">{ch.source}</span>
|
|
{filled < ch.count && <Badge tone="ember">choose {ch.count - filled}</Badge>}
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2">
|
|
{Array.from({ length: ch.count }).map((_, i) => {
|
|
const val = vals[i] ?? '';
|
|
return ch.options ? (
|
|
<Select key={i} className="text-xs" value={val} onChange={(e) => setVal(ch.key, i, e.target.value)} aria-label={`${ch.label} ${i + 1}`}>
|
|
<option value="">— choose —</option>
|
|
{val && !ch.options.includes(val) && <option value={val}>{val}</option>}
|
|
{ch.options.map((o) => <option key={o} value={o}>{o}</option>)}
|
|
</Select>
|
|
) : (
|
|
<Input key={i} className="h-8 text-xs" value={val} onChange={(e) => setVal(ch.key, i, e.target.value)} aria-label={`${ch.label} ${i + 1}`} placeholder={`${ch.label} ${i + 1}`} />
|
|
);
|
|
})}
|
|
</div>
|
|
{ch.hint && <p className="mt-1 text-[10px] text-muted">{ch.hint}</p>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{features.length > 0 && (
|
|
<div>
|
|
<div className="mb-2 smallcaps">Features</div>
|
|
<ul className="space-y-1.5">
|
|
{features.map((f, i) => (
|
|
<li key={`${f.source}-${f.name}-${i}`} className="rounded-md border border-line bg-panel px-2 py-1.5 text-sm">
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="font-medium text-ink">{f.name}</span>
|
|
<span className="text-[10px] uppercase text-muted">{f.source} · L{f.level}</span>
|
|
</div>
|
|
{f.text && <p className="mt-0.5 text-xs text-muted">{f.text}</p>}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</SheetSection>
|
|
);
|
|
}
|