diff --git a/src/features/characters/sheet/SpellcastingSection.tsx b/src/features/characters/sheet/SpellcastingSection.tsx index 64c9140..9e48edf 100644 --- a/src/features/characters/sheet/SpellcastingSection.tsx +++ b/src/features/characters/sheet/SpellcastingSection.tsx @@ -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>({}); + 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 )} - + {s.level === 0 ? ( + + ) : (() => { + const levels = availableSlotLevels(sc, s.level); + if (levels.length === 0) return no slots; + const chosen = castLevel[s.id] && levels.includes(castLevel[s.id]!) ? castLevel[s.id]! : levels[0]!; + return ( +
+ {levels.length > 1 && ( + + )} + +
+ ); + })()} ))} diff --git a/src/features/player/MyCharacterPanel.tsx b/src/features/player/MyCharacterPanel.tsx index d19fe55..3f492b4 100644 --- a/src/features/player/MyCharacterPanel.tsx +++ b/src/features/player/MyCharacterPanel.tsx @@ -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(null); + const [castLevel, setCastLevel] = useState>({}); + 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 (
@@ -118,9 +123,28 @@ export function MyCharacterPanel({ character: c, onPatch }: { character: Charact {s.name} {s.concentration && } - + {s.level === 0 ? ( + + ) : (() => { + const levels = availableSlotLevels(c.spellcasting, s.level); + if (levels.length === 0) return no slots; + const chosen = castLevel[s.id] && levels.includes(castLevel[s.id]!) ? castLevel[s.id]! : levels[0]!; + return ( +
+ {levels.length > 1 && ( + + )} + +
+ ); + })()} ))} diff --git a/src/lib/mechanics/cast.ts b/src/lib/mechanics/cast.ts index e201b6e..50ae16b 100644 --- a/src/lib/mechanics/cast.ts +++ b/src/lib/mechanics/cast.ts @@ -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(); + 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 diff --git a/src/lib/mechanics/enforce.test.ts b/src/lib/mechanics/enforce.test.ts index 6d53bad..3b19519 100644 --- a/src/lib/mechanics/enforce.test.ts +++ b/src/lib/mechanics/enforce.test.ts @@ -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 }); diff --git a/src/lib/mechanics/index.ts b/src/lib/mechanics/index.ts index a4c709d..bf0d28b 100644 --- a/src/lib/mechanics/index.ts +++ b/src/lib/mechanics/index.ts @@ -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';