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