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
+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';