Add spell slot-level picker: warlocks can cast, any caster can upcast

Both cast UIs (SpellcastingSection + MyCharacterPanel) called castSpell without a level,
so it always tried the spell's base level — a warlock (only a pact slot at the pact
level) could never cast, and upcasting was impossible. New availableSlotLevels() lists
the castable levels (regular slots ≥ spell level with a charge, plus the pact level); the
UI shows a level selector and passes the chosen level. Adds a regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:30:01 +02:00
parent ea60b16385
commit 3e39e44a8a
5 changed files with 81 additions and 13 deletions
@@ -4,7 +4,7 @@ import { newId } from '@/lib/ids';
import { getSystem } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
import { type SpellEntry, newSpellEntry } from '@/lib/schemas';
import { castSpell, dropConcentration } from '@/lib/mechanics';
import { castSpell, availableSlotLevels, dropConcentration } from '@/lib/mechanics';
import { multiclassSlots } from '@/lib/rules/dnd5e/progression';
import { formatModifier } from '@/lib/format';
import { Button } from '@/components/ui/Button';
@@ -57,8 +57,10 @@ export function SpellcastingSection({ c, update }: SectionProps) {
// Casting a spell enforces slot consumption + concentration via the kernel.
const [castMsg, setCastMsg] = useState<{ text: string; ok: boolean } | null>(null);
const cast = (id: string) => {
const res = castSpell(c, id);
// Chosen slot level per spell (for warlock pact slots / upcasting); defaults to lowest.
const [castLevel, setCastLevel] = useState<Record<string, number>>({});
const cast = (id: string, atLevel?: number) => {
const res = castSpell(c, id, atLevel);
if (res.ok) {
update(res.patch);
setCastMsg({ text: res.log.join(' '), ok: true });
@@ -179,9 +181,28 @@ export function SpellcastingSection({ c, update }: SectionProps) {
Prepared
</label>
)}
<Button size="sm" variant="ghost" onClick={() => cast(s.id)} aria-label={`Cast ${s.name}`}>
<Sparkles size={13} aria-hidden /> Cast
</Button>
{s.level === 0 ? (
<Button size="sm" variant="ghost" onClick={() => cast(s.id)} aria-label={`Cast ${s.name}`}>
<Sparkles size={13} aria-hidden /> Cast
</Button>
) : (() => {
const levels = availableSlotLevels(sc, s.level);
if (levels.length === 0) return <span className="text-xs text-muted" title="No available slot">no slots</span>;
const chosen = castLevel[s.id] && levels.includes(castLevel[s.id]!) ? castLevel[s.id]! : levels[0]!;
return (
<div className="flex items-center gap-1">
{levels.length > 1 && (
<Select className="w-auto py-0.5 text-xs" value={chosen} aria-label={`Slot level for ${s.name}`}
onChange={(e) => setCastLevel((p) => ({ ...p, [s.id]: Number(e.target.value) }))}>
{levels.map((l) => <option key={l} value={l}>{`L${l}`}</option>)}
</Select>
)}
<Button size="sm" variant="ghost" onClick={() => cast(s.id, chosen)} aria-label={`Cast ${s.name} at level ${chosen}`}>
<Sparkles size={13} aria-hidden /> Cast{chosen !== s.level ? ` L${chosen}` : ''}
</Button>
</div>
);
})()}
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}><X size={14} aria-hidden /></Button>
</li>
))}