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:
2026-06-10 01:51:23 +02:00
parent ce6e2f430f
commit 82a2aef42f
10 changed files with 485 additions and 2 deletions
@@ -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>
);
}