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:
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user