Phase 7: class feature & choice engine — every unlockable option is surfaced
A character's class choices were never prompted (the user's example: a Warlock must choose a Pact Boon + Eldritch Invocations). Now: - CLASS_PROGRESSION_5E authors all 12 classes' features by level AND every decision point: Fighting Style, Pact Boon, Eldritch Invocations (count grows by level), Metamagic, Expertise, Favored Enemy/Terrain, with full option lists. - collectChoices5e / collectFeatures5e compute, across a (multiclass) character's classes, exactly which choices are unlocked (and how many to pick at the current level) and which features are gained. Subclass selection (classes editor) and ASI/feat (builder/level-up) are handled elsewhere and excluded. Unit-tested incl. the Warlock pact example. - New 'Class Features & Choices' sheet section lists features by level and presents a picker per unlocked choice, with a 'N to choose' badge so nothing is missed. Resolved choices persist (character.choices, v17 migration). Verified in-app: Fighter 3 / Warlock 5 surfaces Fighting Style (1), Pact Boon (1), Eldritch Invocations (3) + Pact Magic feature; selecting one drops the pending count. 346 tests green (7 new), build OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { collectChoices5e, collectFeatures5e } 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 (Fighting Style, Pact Boon,
|
||||
* Eldritch Invocations, Metamagic, Expertise, …) so no unlock-able option is missed.
|
||||
* 5e only — driven by CLASS_PROGRESSION_5E.
|
||||
*/
|
||||
export function ClassFeaturesSection({ c, update }: SectionProps) {
|
||||
if (c.system !== '5e') return null;
|
||||
const classes: ClassEntry[] = c.classes.length ? c.classes : c.className ? [{ className: c.className, level: c.level }] : [];
|
||||
const features = collectFeatures5e(classes);
|
||||
const choices = collectChoices5e(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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user