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>
)}
{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>
))}
+27 -3
View File
@@ -1,14 +1,14 @@
import { useState } from 'react';
import { Sparkles, BrainCircuit, Minus, Plus, X } from 'lucide-react';
import type { Character, Defenses } from '@/lib/schemas';
import { castSpell, dropConcentration } from '@/lib/mechanics';
import { castSpell, availableSlotLevels, dropConcentration } from '@/lib/mechanics';
import { ActionGuide } from './ActionGuide';
import { rollDice, applyRollMode } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { useRollStore } from '@/stores/rollStore';
import { sendPlayerRoll } from '@/lib/sync/wsSync';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Input, Select } from '@/components/ui/Input';
import { Meter } from '@/components/ui/Codex';
import { cn } from '@/lib/cn';
@@ -24,6 +24,11 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
const hpPct = c.hp.max > 0 ? clamp((c.hp.current / c.hp.max) * 100, 0, 100) : 0;
const slots = c.spellcasting.slots.filter((s) => s.max > 0);
const [castMsg, setCastMsg] = useState<string | null>(null);
const [castLevel, setCastLevel] = useState<Record<string, number>>({});
const doCast = (id: string, atLevel?: number) => {
const r = castSpell(c, id, atLevel);
if (r.ok) { onPatch(r.patch); setCastMsg(r.log.join(' ')); } else setCastMsg(r.reason);
};
return (
<section data-testid="my-character" className="mb-6 rounded-xl border border-accent/40 bg-accent/5 p-4">
@@ -118,9 +123,28 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact
{s.name}
{s.concentration && <BrainCircuit size={11} className="ml-1 inline text-accent" aria-label="Concentration" />}
</span>
<Button size="sm" variant="ghost" onClick={() => { const r = castSpell(c, s.id); if (r.ok) { onPatch(r.patch); setCastMsg(r.log.join(' ')); } else setCastMsg(r.reason); }} aria-label={`Cast ${s.name}`}>
{s.level === 0 ? (
<Button size="sm" variant="ghost" onClick={() => doCast(s.id)} aria-label={`Cast ${s.name}`}>
<Sparkles size={12} aria-hidden /> Cast
</Button>
) : (() => {
const levels = availableSlotLevels(c.spellcasting, s.level);
if (levels.length === 0) return <span className="text-xs text-muted">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={() => doCast(s.id, chosen)} aria-label={`Cast ${s.name} at level ${chosen}`}>
<Sparkles size={12} aria-hidden /> Cast{chosen !== s.level ? ` L${chosen}` : ''}
</Button>
</div>
);
})()}
</div>
))}
</div>
+14
View File
@@ -20,6 +20,20 @@ function consumeSlot(sc: Spellcasting, level: number): { spellcasting: Spellcast
return null;
}
/**
* The slot levels at which a leveled spell can currently be cast: any regular slot
* level ≥ the spell's level that still has a charge, plus the pact-magic level when
* it qualifies. Returns [] for cantrips (no slot needed). Used by the cast UI to
* offer a slot-level picker so warlocks can spend pact slots and any caster can upcast.
*/
export function availableSlotLevels(sc: Spellcasting, spellLevel: number): number[] {
if (spellLevel <= 0) return [];
const levels = new Set<number>();
for (const s of sc.slots) if (s.level >= spellLevel && s.current > 0) levels.add(s.level);
if (sc.pact && sc.pact.level >= spellLevel && sc.pact.current > 0) levels.add(sc.pact.level);
return [...levels].sort((a, b) => a - b);
}
/**
* Cast a spell the character knows, at a chosen slot level (≥ the spell's own
* level, to allow upcasting). Pure: decrements a slot, sets/replaces
+10 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { characterDefaults, newSpellEntry, type Character } from '@/lib/schemas';
import { castSpell, dropConcentration } from './cast';
import { castSpell, availableSlotLevels, dropConcentration } from './cast';
import { spendResource, regainResource } from './resources';
import { rollDeathSave } from './deathSaves';
@@ -77,6 +77,15 @@ describe('castSpell', () => {
expect(r.patch.spellcasting!.pact!.current).toBe(1);
});
it('availableSlotLevels offers the pact level for a warlock and upcast levels for a caster', () => {
// Warlock: a level-1 spell can be cast with the level-3 pact slot (the only option).
expect(availableSlotLevels({ slots: [], pact: { level: 3, max: 2, current: 2 }, spells: [] }, 1)).toEqual([3]);
// Wizard: a level-1 spell can go in the level-1 or level-3 slot (upcast), but not empties.
expect(availableSlotLevels({ slots: [{ level: 1, max: 4, current: 4 }, { level: 2, max: 2, current: 0 }, { level: 3, max: 3, current: 2 }], spells: [] }, 1)).toEqual([1, 3]);
// Cantrips need no slot.
expect(availableSlotLevels({ slots: [{ level: 1, max: 4, current: 4 }], spells: [] }, 0)).toEqual([]);
});
it('dropConcentration clears the slot', () => {
const concentrating = mk({ concentration: { spellId: 's2', spellName: 'Haste' } });
expect(dropConcentration(concentrating)).toEqual({ concentration: null });
+1 -1
View File
@@ -15,7 +15,7 @@ export type { EnforceResult } from './result';
export { normalizeSpell5e, normalizeSpellPf2e } from './normalize/spell';
export { normalizeArmor5e } from './normalize/armor';
export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefenses } from './normalize/monsterDefenses';
export { castSpell, dropConcentration, concentrationDC } from './cast';
export { castSpell, availableSlotLevels, dropConcentration, concentrationDC } from './cast';
export { spendResource, regainResource } from './resources';
export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect, type PenaltyTarget } from './conditionEffects';