Files
gogobee/internal/plugin/dnd_spells.go
prosolis dd25de71f0 H5 follow-up: short-rest at full HP can still refresh slots
handleDnDShortRest early-returned when HPCurrent >= HPMax, which dates
from when short rest was HP-only. After H5 (1cd53eb) added the partial
slot refresh, that gate left casters who hadn't been hit unable to
recover slots between fights — the most common slot-attrition scenario.

Loosen the gate: short rest is allowed when HP can heal OR any spell
slot has used > 0. New helper casterHasUsedSlots gates the slot branch.
Martial behavior unchanged (still bails at full HP). Two new tests.
2026-05-17 16:06:46 -07:00

1039 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"database/sql"
"errors"
"fmt"
"sort"
"strings"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix/id"
)
// Phase 9 — spell system.
//
// Implements the registry side of gogobee_spell_system.md: 76 in-scope spells
// (3 reaction spells deferred to Phase 11), three casters (Mage/Cleric/Ranger),
// spell slots, spell save DC, spell attack bonus, and the migration helper
// that auto-grants a sensible known list to existing players.
//
// SP2/SP3 (in dnd_spells_cast.go and dnd_combat.go) handle !cast and the
// pending-cast resolution at combat time.
// ── Effect categories ────────────────────────────────────────────────────────
type SpellEffectKind string
const (
// In-combat / pre-combat (queued via pending_cast)
EffectDamageAttack SpellEffectKind = "damage_attack" // spell attack roll vs AC
EffectDamageSave SpellEffectKind = "damage_save" // target saves; half on success
EffectDamageAuto SpellEffectKind = "damage_auto" // no save, no attack (Magic Missile)
EffectControl SpellEffectKind = "control" // save vs DC; failure → condition
EffectBuffSelf SpellEffectKind = "buff_self" // AC/attack/HP self-buff for next fight
EffectBuffAlly SpellEffectKind = "buff_ally" // same, on a target player
// Out-of-combat / immediate-resolution
EffectSpellHeal SpellEffectKind = "spell_heal"
EffectUtility SpellEffectKind = "utility"
// Deferred to Phase 11 (turn-based bosses)
EffectReaction SpellEffectKind = "reaction"
)
// ── Spell timing & damage type ───────────────────────────────────────────────
type SpellCastTime string
const (
CastAction SpellCastTime = "action"
CastBonusAction SpellCastTime = "bonus_action"
CastReaction SpellCastTime = "reaction"
CastRitual SpellCastTime = "ritual" // 10 min, no slot
)
// SpellDefinition is the static description of a spell. Not stored per-player;
// known spells are tracked in dnd_known_spells, slot pool in dnd_spell_slots,
// and the queued cast lives in dnd_character.pending_cast as a JSON blob.
type SpellDefinition struct {
ID string
Name string
Level int // 0 = cantrip
School string
Classes []DnDClass
Effect SpellEffectKind
CastTime SpellCastTime
Concentration bool
// SaveStat — empty for attack-roll or auto-effect spells.
SaveStat string // "STR" | "DEX" | "CON" | "INT" | "WIS" | "CHA"
// AttackRoll — true for spell attack rolls (Fire Bolt, Inflict Wounds).
AttackRoll bool
// DamageDice — descriptive only ("3d6", "1d10"). Roll bounds tested in
// dnd_spells_test.go via simple regex on this field.
DamageDice string
DamageType string
Description string
// Upcast describes scaling at higher slots ("+1d6 per slot above 3rd").
Upcast string
// MaterialCost is in coins; non-zero spells (Revivify, Raise Dead) debit
// at cast time and refuse if balance is short.
MaterialCost int
// AOE — true when the spell hits all enemies in the encounter.
AOE bool
}
// ── Registry ─────────────────────────────────────────────────────────────────
// dndSpellRegistry merges the vendored Open5e SRD dump with the hand-authored
// spell list. SRD loads first as the broad baseline; buildSpellList() overlays
// it and wins on any ID collision — the hand-authored entries carry the tuned
// Effect/DamageDice/Upcast values, the SRD entry is the fallback shape.
//
// One special-case on collision: the Classes slice is *unioned* across both
// sources rather than replaced. The hand-authored tables were written before
// the Open5e caster scaffolds (Druid/Bard/Sorcerer/Warlock/Paladin) landed,
// so most entries are tagged Mage-only; the SRD knows the broader 5e class
// list. Unioning means a Sorcerer can actually cast a `magic_missile` they
// were auto-granted, without us having to hand-fix every overlay entry.
var dndSpellRegistry = func() map[string]SpellDefinition {
out := make(map[string]SpellDefinition, 320)
for _, s := range buildSRDSpellList() {
out[s.ID] = s
}
for _, s := range buildSpellList() {
if prev, ok := out[s.ID]; ok {
s.Classes = mergeClassList(prev.Classes, s.Classes)
}
out[s.ID] = s
}
return out
}()
// mergeClassList returns the union of two class slices, preserving the order
// of the first (SRD baseline) and appending any classes only in the second.
// Order-stability matters for any callers that iterate Classes deterministically.
func mergeClassList(a, b []DnDClass) []DnDClass {
seen := make(map[DnDClass]bool, len(a)+len(b))
out := make([]DnDClass, 0, len(a)+len(b))
for _, c := range a {
if !seen[c] {
seen[c] = true
out = append(out, c)
}
}
for _, c := range b {
if !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out
}
func lookupSpell(id string) (SpellDefinition, bool) {
s, ok := dndSpellRegistry[strings.ToLower(strings.TrimSpace(id))]
return s, ok
}
// parseSpell accepts loose user input ("Fire Bolt", "fire-bolt", "fire_bolt").
func parseSpell(s string) (SpellDefinition, bool) {
key := strings.ToLower(strings.TrimSpace(s))
key = strings.ReplaceAll(key, " ", "_")
key = strings.ReplaceAll(key, "-", "_")
if def, ok := dndSpellRegistry[key]; ok {
return def, true
}
for _, def := range dndSpellRegistry {
if strings.EqualFold(def.Name, s) {
return def, true
}
}
return SpellDefinition{}, false
}
func spellsForClass(class DnDClass, levelMax int) []SpellDefinition {
var out []SpellDefinition
for _, s := range dndSpellRegistry {
if s.Level > levelMax {
continue
}
for _, c := range s.Classes {
if c == class {
out = append(out, s)
break
}
}
}
return out
}
// ── Slot tables ──────────────────────────────────────────────────────────────
// slotsForClassLevel returns the spell slot pool a caster of this class+level
// should have at full rest. Returned map is slot_level → total. Non-casters
// return an empty map.
func slotsForClassLevel(class DnDClass, level int) map[int]int {
if level < 1 {
return nil
}
switch class {
case ClassMage, ClassDruid, ClassBard, ClassSorcerer:
// All 5e full casters share one slot progression.
return fullCasterSlots(level)
case ClassCleric:
return clericSlots(level)
case ClassRanger, ClassPaladin:
// Half-casters share the Ranger progression: no slots until L2.
return rangerSlots(level)
case ClassWarlock:
return warlockSlots(level)
}
return nil
}
// subclassSpellSlots returns a slot pool granted by a subclass on top of the
// class baseline. Phase 10 SUB2-AT: Arcane Trickster is a third-caster that
// unlocks at Rogue L5 (3 L1 slots), expanding modestly with level. Returns
// nil for subclasses without spellcasting.
func subclassSpellSlots(sub DnDSubclass, level int) map[int]int {
if sub == SubclassArcaneTrickster && level >= 5 {
// 5e third-caster table, simplified to the levels the engine cares
// about: 3 L1 at L56, 4 L1 + 2 L2 at L7+, expanding into L3 at
// rogue L13. Matches the design doc's "L5 → 3 L1 slots" anchor.
switch {
case level >= 13:
return map[int]int{1: 4, 2: 3, 3: 2}
case level >= 7:
return map[int]int{1: 4, 2: 2}
default:
return map[int]int{1: 3}
}
}
return nil
}
// slotsForCharacter returns the combined class+subclass slot pool. Use this
// for any character-level decision (display, slot reset, !cast gating);
// slotsForClassLevel is the class-only base.
func slotsForCharacter(c *DnDCharacter) map[int]int {
if c == nil {
return nil
}
out := map[int]int{}
for lvl, n := range slotsForClassLevel(c.Class, c.Level) {
out[lvl] += n
}
for lvl, n := range subclassSpellSlots(c.Subclass, c.Level) {
out[lvl] += n
}
if len(out) == 0 {
return nil
}
return out
}
// Tables transcribed from gogobee_spell_system.md §1. We interpolate
// between the doc's milestone rows so every level 1..20 has a defined pool.
// fullCasterSlots is the standard 5e full-caster slot progression, shared by
// Mage, Druid, Bard and Sorcerer. (Was mageSlots — generalized when the
// Open5e caster classes landed; output is byte-identical for Mage.)
func fullCasterSlots(level int) map[int]int {
rows := [][6]int{
// L1, L2, L3, L4, L5
{2, 0, 0, 0, 0}, // 1
{3, 0, 0, 0, 0}, // 2
{4, 2, 0, 0, 0}, // 3
{4, 3, 0, 0, 0}, // 4
{4, 3, 2, 0, 0}, // 5
{4, 3, 3, 0, 0}, // 6
{4, 3, 3, 1, 0}, // 7
{4, 3, 3, 2, 0}, // 8
{4, 3, 3, 3, 1}, // 9
{4, 3, 3, 3, 2}, // 10
{4, 3, 3, 3, 2}, // 11
{4, 3, 3, 3, 2}, // 12
{4, 3, 3, 3, 2}, // 13
{4, 3, 3, 3, 2}, // 14
{4, 3, 3, 3, 2}, // 15
{4, 3, 3, 3, 2}, // 16
{4, 3, 3, 3, 2}, // 17
{4, 3, 3, 3, 3}, // 18
{4, 3, 3, 3, 3}, // 19
{4, 3, 3, 3, 3}, // 20
}
if level > len(rows) {
level = len(rows)
}
r := rows[level-1]
return packSlots(r[0], r[1], r[2], r[3], r[4])
}
func clericSlots(level int) map[int]int {
rows := [][6]int{
{2, 0, 0, 0, 0}, // 1
{3, 0, 0, 0, 0}, // 2
{4, 2, 0, 0, 0}, // 3
{4, 3, 0, 0, 0}, // 4
{4, 3, 2, 0, 0}, // 5
{4, 3, 3, 0, 0}, // 6
{4, 3, 3, 1, 0}, // 7
{4, 3, 3, 2, 0}, // 8
{4, 3, 3, 3, 1}, // 9
{4, 3, 3, 3, 2}, // 10
{4, 3, 3, 3, 2}, // 11
{4, 3, 3, 3, 2}, // 12
{4, 3, 3, 3, 2}, // 13
{4, 3, 3, 3, 2}, // 14
{4, 3, 3, 3, 2}, // 15
{4, 3, 3, 3, 2}, // 16
{4, 3, 3, 3, 3}, // 17
{4, 3, 3, 3, 3}, // 18
{4, 3, 3, 3, 3}, // 19
{4, 3, 3, 3, 3}, // 20
}
if level > len(rows) {
level = len(rows)
}
r := rows[level-1]
return packSlots(r[0], r[1], r[2], r[3], r[4])
}
func rangerSlots(level int) map[int]int {
// Half-caster — no slots until L2, max 3rd-level.
rows := [][6]int{
{0, 0, 0, 0, 0}, // 1
{2, 0, 0, 0, 0}, // 2
{3, 0, 0, 0, 0}, // 3
{3, 0, 0, 0, 0}, // 4
{3, 0, 0, 0, 0}, // 5
{3, 0, 0, 0, 0}, // 6
{3, 1, 0, 0, 0}, // 7 (doc table)
{3, 2, 0, 0, 0}, // 8
{3, 2, 0, 0, 0}, // 9 (doc has 3/2 known; slots step at 9 too)
{3, 2, 0, 0, 0}, // 10
{3, 2, 0, 0, 0}, // 11
{3, 2, 0, 0, 0}, // 12
{3, 2, 1, 0, 0}, // 13
{3, 2, 1, 0, 0}, // 14
{3, 2, 1, 0, 0}, // 15
{3, 2, 1, 0, 0}, // 16
{3, 2, 2, 0, 0}, // 17
{3, 2, 2, 0, 0}, // 18
{3, 3, 2, 0, 0}, // 19
{3, 3, 2, 0, 0}, // 20
}
if level > len(rows) {
level = len(rows)
}
r := rows[level-1]
return packSlots(r[0], r[1], r[2], r[3], r[4])
}
// warlockSlots — simplified pool. Real 5e Pact Magic gives a tiny number of
// slots that all sit at the highest level and recharge on a short rest; we
// deliberately don't model that (see the Open5e class-scaffold decision —
// "simplified pool, long-rest reset like everyone else"). Instead the Warlock
// gets a modest long-rest pool that tops out at 4th-level slots.
func warlockSlots(level int) map[int]int {
rows := [][6]int{
{1, 0, 0, 0, 0}, // 1
{2, 0, 0, 0, 0}, // 2
{2, 2, 0, 0, 0}, // 3
{2, 2, 0, 0, 0}, // 4
{2, 2, 2, 0, 0}, // 5
{2, 2, 2, 0, 0}, // 6
{2, 2, 2, 1, 0}, // 7
{2, 2, 2, 1, 0}, // 8
{2, 2, 2, 2, 0}, // 9
{2, 2, 2, 2, 0}, // 10
{3, 2, 2, 2, 0}, // 11
{3, 2, 2, 2, 0}, // 12
{3, 3, 2, 2, 0}, // 13
{3, 3, 2, 2, 0}, // 14
{3, 3, 3, 2, 0}, // 15
{3, 3, 3, 2, 0}, // 16
{4, 3, 3, 2, 0}, // 17
{4, 3, 3, 2, 0}, // 18
{4, 3, 3, 3, 0}, // 19
{4, 3, 3, 3, 0}, // 20
}
if level > len(rows) {
level = len(rows)
}
r := rows[level-1]
return packSlots(r[0], r[1], r[2], r[3], r[4])
}
func packSlots(l1, l2, l3, l4, l5 int) map[int]int {
out := map[int]int{}
for i, n := range []int{l1, l2, l3, l4, l5} {
if n > 0 {
out[i+1] = n
}
}
return out
}
// ── DC math ──────────────────────────────────────────────────────────────────
// spellcastingMod returns the ability modifier used for spell DCs and attacks.
func spellcastingMod(c *DnDCharacter) int {
if c == nil {
return 0
}
switch c.Class {
case ClassMage:
return abilityModifier(c.INT)
case ClassCleric, ClassRanger, ClassDruid:
return abilityModifier(c.WIS)
case ClassBard, ClassSorcerer, ClassWarlock, ClassPaladin:
return abilityModifier(c.CHA)
case ClassRogue:
// Phase 10 SUB2-AT: Arcane Trickster is INT-based.
if c.Subclass == SubclassArcaneTrickster {
return abilityModifier(c.INT)
}
}
return 0
}
// spellSaveDC = 8 + proficiency bonus + spellcasting modifier.
//
// Phase 10 SUB2-AT: Arcane Trickster L7 Magical Ambush adds +2. 5e gives
// targets disadvantage on the save when the AT casts while hidden — we
// don't model "hidden" in the one-shot combat layer, so the bump bakes the
// expected-value effect in as a flat DC nudge whenever the AT casts.
func spellSaveDC(c *DnDCharacter) int {
dc := 8 + proficiencyBonus(c.Level) + spellcastingMod(c)
if c != nil && c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster && c.Level >= 7 {
dc += 2
}
return dc
}
// spellAttackBonus = proficiency bonus + spellcasting modifier.
func spellAttackBonus(c *DnDCharacter) int {
return proficiencyBonus(c.Level) + spellcastingMod(c)
}
// classIsCaster returns true for every spellcasting class. The Open5e
// scaffold classes (Druid/Bard/Sorcerer/Warlock/Paladin) are casters by the
// engine's reckoning even while Playable=false — they have slot tables and a
// spellcasting ability; they just have no spell list to know yet.
func classIsCaster(class DnDClass) bool {
switch class {
case ClassMage, ClassCleric, ClassRanger,
ClassDruid, ClassBard, ClassSorcerer, ClassWarlock, ClassPaladin:
return true
}
return false
}
// isSpellcaster is the character-level gate: full casters by class plus the
// Arcane Trickster subclass once it unlocks at Rogue L5. Use this for !cast,
// !spells and any spellbook bootstrap path.
func isSpellcaster(c *DnDCharacter) bool {
if c == nil {
return false
}
if classIsCaster(c.Class) {
return true
}
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster && c.Level >= 5 {
return true
}
return false
}
// ── Slot persistence ─────────────────────────────────────────────────────────
// setSpellSlotsForCharacter writes the combined class+subclass slot pool
// (Phase 10 SUB2-AT covers Arcane Trickster's third-caster pool). Mirrors
// setSpellSlotsForLevel's reset-and-insert flow.
func setSpellSlotsForCharacter(c *DnDCharacter) error {
if c == nil {
return nil
}
pool := slotsForCharacter(c)
tx, err := db.Get().Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM dnd_spell_slots WHERE user_id = ?`, string(c.UserID)); err != nil {
return err
}
for lvl, total := range pool {
if _, err := tx.Exec(`
INSERT INTO dnd_spell_slots (user_id, slot_level, total, used)
VALUES (?, ?, ?, 0)`,
string(c.UserID), lvl, total); err != nil {
return err
}
}
return tx.Commit()
}
// setSpellSlotsForLevel writes the slot pool for a class+level, fully
// resetting any prior pool. Used by setup/migration/respec.
func setSpellSlotsForLevel(userID id.UserID, class DnDClass, level int) error {
pool := slotsForClassLevel(class, level)
tx, err := db.Get().Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec(`DELETE FROM dnd_spell_slots WHERE user_id = ?`, string(userID)); err != nil {
return err
}
for lvl, total := range pool {
if _, err := tx.Exec(`
INSERT INTO dnd_spell_slots (user_id, slot_level, total, used)
VALUES (?, ?, ?, 0)`,
string(userID), lvl, total); err != nil {
return err
}
}
return tx.Commit()
}
// getSpellSlots returns slot_level → (total, used) for a player.
func getSpellSlots(userID id.UserID) (map[int][2]int, error) {
rows, err := db.Get().Query(
`SELECT slot_level, total, used FROM dnd_spell_slots WHERE user_id = ? ORDER BY slot_level`,
string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
out := map[int][2]int{}
for rows.Next() {
var lvl, total, used int
if err := rows.Scan(&lvl, &total, &used); err != nil {
return nil, err
}
out[lvl] = [2]int{total, used}
}
return out, rows.Err()
}
// consumeSpellSlot decrements used+1 if a slot is available at slotLevel.
// Returns true on success.
func consumeSpellSlot(userID id.UserID, slotLevel int) (bool, error) {
res, err := db.Get().Exec(`
UPDATE dnd_spell_slots
SET used = used + 1
WHERE user_id = ? AND slot_level = ? AND used < total`,
string(userID), slotLevel)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// refundSpellSlot re-credits one slot at slotLevel (used in audit-style
// rollback paths and voluntary `!cast --drop`).
func refundSpellSlot(userID id.UserID, slotLevel int) error {
_, err := db.Get().Exec(`
UPDATE dnd_spell_slots
SET used = MAX(0, used - 1)
WHERE user_id = ? AND slot_level = ?`,
string(userID), slotLevel)
return err
}
// casterHasUsedSlots reports whether any spell slot for userID has used>0.
// Used by the short-rest gate so casters at full HP can still rest to
// recover slots (partialRefreshSpellSlots is otherwise unreachable for
// them).
func casterHasUsedSlots(userID id.UserID) (bool, error) {
var n int
err := db.Get().QueryRow(
`SELECT COUNT(*) FROM dnd_spell_slots WHERE user_id = ? AND used > 0`,
string(userID)).Scan(&n)
return n > 0, err
}
// refreshSpellSlots resets used=0 across all of a player's slots. Called
// on long rest.
func refreshSpellSlots(userID id.UserID) error {
_, err := db.Get().Exec(
`UPDATE dnd_spell_slots SET used = 0 WHERE user_id = ?`,
string(userID))
return err
}
// partialRefreshSpellSlots restores spell slots on short rest. All L1 slots
// come back, plus floor(charLevel/4) additional slots distributed
// lowest-first across tiers ≥2. Returns slot_level→count restored so the
// caller can render a footer.
func partialRefreshSpellSlots(userID id.UserID, charLevel int) (map[int]int, error) {
slots, err := getSpellSlots(userID)
if err != nil {
return nil, err
}
restored := map[int]int{}
if pair, ok := slots[1]; ok && pair[1] > 0 {
if _, err := db.Get().Exec(
`UPDATE dnd_spell_slots SET used = 0 WHERE user_id = ? AND slot_level = 1`,
string(userID)); err != nil {
return nil, err
}
restored[1] = pair[1]
}
budget := charLevel / 4
if budget <= 0 {
if len(restored) == 0 {
return nil, nil
}
return restored, nil
}
levels := make([]int, 0, len(slots))
for lvl, pair := range slots {
if lvl >= 2 && pair[1] > 0 {
levels = append(levels, lvl)
}
}
sort.Ints(levels)
for _, lvl := range levels {
if budget <= 0 {
break
}
used := slots[lvl][1]
take := used
if take > budget {
take = budget
}
if _, err := db.Get().Exec(
`UPDATE dnd_spell_slots SET used = MAX(0, used - ?) WHERE user_id = ? AND slot_level = ?`,
take, string(userID), lvl); err != nil {
return restored, err
}
restored[lvl] = take
budget -= take
}
if len(restored) == 0 {
return nil, nil
}
return restored, nil
}
// ── Known spells ─────────────────────────────────────────────────────────────
func addKnownSpell(userID id.UserID, spellID string, source string, prepared bool) error {
prep := 1
if !prepared {
prep = 0
}
_, err := db.Get().Exec(`
INSERT INTO dnd_known_spells (user_id, spell_id, source, prepared)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, spell_id) DO UPDATE SET source=excluded.source`,
string(userID), spellID, source, prep)
return err
}
func setSpellPrepared(userID id.UserID, spellID string, prepared bool) error {
prep := 0
if prepared {
prep = 1
}
_, err := db.Get().Exec(`
UPDATE dnd_known_spells SET prepared = ?
WHERE user_id = ? AND spell_id = ?`,
prep, string(userID), spellID)
return err
}
type knownSpellRow struct {
SpellID string
Source string
Prepared bool
}
func listKnownSpells(userID id.UserID) ([]knownSpellRow, error) {
rows, err := db.Get().Query(
`SELECT spell_id, source, prepared FROM dnd_known_spells
WHERE user_id = ? ORDER BY spell_id`,
string(userID))
if err != nil {
return nil, err
}
defer rows.Close()
var out []knownSpellRow
for rows.Next() {
var r knownSpellRow
var prep int
if err := rows.Scan(&r.SpellID, &r.Source, &prep); err != nil {
return nil, err
}
r.Prepared = prep == 1
out = append(out, r)
}
return out, rows.Err()
}
func playerKnowsSpell(userID id.UserID, spellID string) (bool, bool, error) {
var prep int
err := db.Get().QueryRow(
`SELECT prepared FROM dnd_known_spells WHERE user_id = ? AND spell_id = ?`,
string(userID), spellID).Scan(&prep)
if errors.Is(err, sql.ErrNoRows) {
return false, false, nil
}
if err != nil {
return false, false, err
}
return true, prep == 1, nil
}
// wipeSpellsForUser clears known + slot rows. Used by !respec.
func wipeSpellsForUser(userID id.UserID) error {
if _, err := db.Get().Exec(`DELETE FROM dnd_known_spells WHERE user_id = ?`, string(userID)); err != nil {
return err
}
if _, err := db.Get().Exec(`DELETE FROM dnd_spell_slots WHERE user_id = ?`, string(userID)); err != nil {
return err
}
return nil
}
// ── Concentration ────────────────────────────────────────────────────────────
// setConcentration stamps a new concentration spell on the character. Returns
// the previous spell id (empty if none) so the caller can narrate the
// supersession. Caller is responsible for SaveDnDCharacter.
func setConcentration(c *DnDCharacter, spellID string, duration time.Duration) string {
prev := c.ConcentrationSpell
c.ConcentrationSpell = spellID
if duration > 0 {
exp := time.Now().Add(duration)
c.ConcentrationExpiresAt = &exp
} else {
c.ConcentrationExpiresAt = nil
}
return prev
}
// concentrationActive returns the active concentration spell id, or "" if
// none / expired. Mutates the caller's DnDCharacter copy when expiry is hit
// (caller should Save afterwards if it cares).
func concentrationActive(c *DnDCharacter) string {
if c == nil || c.ConcentrationSpell == "" {
return ""
}
if c.ConcentrationExpiresAt != nil && time.Now().After(*c.ConcentrationExpiresAt) {
c.ConcentrationSpell = ""
c.ConcentrationExpiresAt = nil
return ""
}
return c.ConcentrationSpell
}
// ── Migration / auto-grant ───────────────────────────────────────────────────
// ensureSpellsForCharacter populates a sensible known-spell list and slot
// pool for a caster who has none. Idempotent: skips if any spells are
// already known for the user. Called from !setup confirm and from
// ensureDnDCharacterForCombat (auto-migration path).
//
// Defaults are conservative — a caster always has at least cantrips and
// enough leveled options to be functional. Players can swap via
// !spells learn (Mage) or !prepare (Cleric) once SP4 lands.
func ensureSpellsForCharacter(c *DnDCharacter) error {
if !isSpellcaster(c) {
return nil
}
existing, err := listKnownSpells(c.UserID)
if err != nil {
return err
}
if len(existing) > 0 {
// Refresh the slot pool in case level/subclass has changed since the
// prior grant (covers level-up between sessions and AT subclass pick).
return setSpellSlotsForCharacter(c)
}
defaults := defaultKnownSpells(c.Class, c.Level)
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
defaults = arcaneTricksterDefaultSpells(c.Level)
}
for _, sid := range defaults {
// Cleric "prepares" daily — but prep system is SP4. Until then,
// every default cleric spell is auto-prepared so !cast works.
if err := addKnownSpell(c.UserID, sid, "class", true); err != nil {
return err
}
}
return setSpellSlotsForCharacter(c)
}
// arcaneTricksterDefaultSpells returns the Mage-list starter spells granted
// when a Rogue picks Arcane Trickster. Picks lean on illusion + control +
// single-target combat utility from spells already in the registry; mage_hand
// and disguise_self/invisibility are flavored AT staples but not modelled
// yet, so we substitute the closest available analogues.
func arcaneTricksterDefaultSpells(level int) []string {
out := []string{"minor_illusion", "shocking_grasp"}
if level >= 5 {
out = append(out, "magic_missile", "shield", "sleep")
}
if level >= 7 {
out = append(out, "mirror_image", "misty_step")
}
if level >= 13 {
out = append(out, "hypnotic_pattern")
}
return out
}
// defaultKnownSpells returns a sensible starter list for class+level. Tuned
// to give each caster at least a damage option, a buff/utility, and a heal
// where applicable. Cantrips first, then leveled spells up to the player's
// max slot level. Picks are deterministic so tests can lock them down.
func defaultKnownSpells(class DnDClass, level int) []string {
maxSlot := highestAvailableSlot(class, level)
switch class {
case ClassMage:
out := []string{"fire_bolt", "minor_illusion", "mending"}
if level >= 1 {
// No `shield` here — it's a reaction (EffectReaction) and combat
// has no reaction window yet, so an auto-granted shield is just
// a dead entry in the player's spellbook. Reinstated when SP-Reactions ships.
out = append(out, "magic_missile", "mage_armor", "burning_hands", "detect_magic", "sleep")
}
if maxSlot >= 2 {
out = append(out, "scorching_ray", "misty_step", "mirror_image")
}
if maxSlot >= 3 {
// `counterspell` deferred for the same reason as `shield`.
out = append(out, "fireball", "fly")
}
if maxSlot >= 4 {
out = append(out, "ice_storm", "greater_invisibility")
}
if maxSlot >= 5 {
out = append(out, "cone_of_cold", "wall_of_force")
}
return out
case ClassCleric:
out := []string{"sacred_flame", "guidance", "mending"}
if level >= 1 {
out = append(out, "cure_wounds", "healing_word", "bless", "guiding_bolt", "shield_of_faith")
}
if maxSlot >= 2 {
out = append(out, "spiritual_weapon", "lesser_restoration", "aid")
}
if maxSlot >= 3 {
out = append(out, "spirit_guardians", "revivify", "mass_healing_word")
}
if maxSlot >= 4 {
out = append(out, "guardian_of_faith", "death_ward")
}
if maxSlot >= 5 {
out = append(out, "mass_cure_wounds", "flame_strike")
}
return out
case ClassRanger:
// Rangers have no spells until level 2.
if level < 2 {
return []string{"guidance", "thorn_whip"}
}
out := []string{"guidance", "thorn_whip"}
out = append(out, "hunters_mark", "cure_wounds")
if maxSlot >= 2 {
out = append(out, "pass_without_trace", "spike_growth")
}
if maxSlot >= 3 {
out = append(out, "lightning_arrow", "conjure_barrage")
}
return out
// ── Open5e caster scaffold classes ───────────────────────────────────
// Picks are SRD spell IDs (dnd_spells_srd_data.go); a handful resolve to
// the hand-authored overlay on ID collision, which is fine. Each list
// gives the class a damage option, control, and a heal/buff where the
// class fantasy expects one.
case ClassDruid:
// `shillelagh` deferred — the overlay flags it ranger/cleric only and
// it has no real attack profile in the engine yet (BuffSelf, no damage
// dice). Reinstated when weapon-bonus casts wire up.
out := []string{"produce_flame", "guidance", "mending"}
if level >= 1 {
out = append(out, "cure_wounds", "healing_word", "faerie_fire", "thunderwave", "entangle")
}
if maxSlot >= 2 {
out = append(out, "flaming_sphere", "heat_metal", "hold_person", "lesser_restoration")
}
if maxSlot >= 3 {
out = append(out, "call_lightning", "conjure_animals", "dispel_magic")
}
if maxSlot >= 4 {
out = append(out, "ice_storm", "polymorph")
}
if maxSlot >= 5 {
out = append(out, "insect_plague", "mass_cure_wounds")
}
return out
case ClassBard:
out := []string{"vicious_mockery", "minor_illusion", "message"}
if level >= 1 {
out = append(out, "cure_wounds", "healing_word", "heroism", "hideous_laughter", "faerie_fire")
}
if maxSlot >= 2 {
out = append(out, "hold_person", "shatter", "invisibility", "lesser_restoration")
}
if maxSlot >= 3 {
out = append(out, "hypnotic_pattern", "dispel_magic", "fear")
}
if maxSlot >= 4 {
out = append(out, "greater_invisibility", "polymorph")
}
if maxSlot >= 5 {
out = append(out, "hold_monster", "mass_cure_wounds")
}
return out
case ClassSorcerer:
out := []string{"fire_bolt", "ray_of_frost", "shocking_grasp", "mending"}
if level >= 1 {
// `shield` skipped — reaction spell, no usable window in current combat.
out = append(out, "magic_missile", "burning_hands", "mage_armor", "thunderwave", "sleep")
}
if maxSlot >= 2 {
out = append(out, "scorching_ray", "mirror_image", "misty_step", "hold_person")
}
if maxSlot >= 3 {
// `counterspell` skipped — reaction.
out = append(out, "fireball", "lightning_bolt", "haste", "fly")
}
if maxSlot >= 4 {
out = append(out, "ice_storm", "greater_invisibility")
}
if maxSlot >= 5 {
out = append(out, "cone_of_cold", "hold_monster")
}
return out
case ClassWarlock:
out := []string{"eldritch_blast", "chill_touch", "minor_illusion"}
if level >= 1 {
// `hellish_rebuke` skipped — reaction spell, no usable window
// in current combat. Substituting `burning_hands` as the L1
// damage staple (SRD: Mage/Cleric/Sorcerer/Warlock).
out = append(out, "burning_hands", "command", "charm_person", "hideous_laughter")
}
if maxSlot >= 2 {
out = append(out, "scorching_ray", "misty_step", "hold_person", "invisibility")
}
if maxSlot >= 3 {
// `counterspell` skipped — reaction.
out = append(out, "fly", "vampiric_touch", "hypnotic_pattern")
}
if maxSlot >= 4 {
out = append(out, "banishment", "greater_invisibility")
}
if maxSlot >= 5 {
out = append(out, "hold_monster", "scrying")
}
return out
case ClassPaladin:
// Half-caster — no cantrips, no spells until level 2.
if level < 2 {
return nil
}
out := []string{"bless", "cure_wounds", "divine_favor", "shield_of_faith", "command"}
if maxSlot >= 2 {
out = append(out, "aid", "find_steed", "magic_weapon", "lesser_restoration")
}
if maxSlot >= 3 {
out = append(out, "revivify", "dispel_magic", "haste")
}
return out
}
return nil
}
// mageKnownSpellsCap returns the maximum number of *leveled* spells a Mage
// of the given level may have in their spellbook. 5e standard: 6 starting
// spells + 2 per level after L1 (so 6 at L1, 8 at L2, …, 44 at L20).
// Cantrips don't count against this cap.
func mageKnownSpellsCap(level int) int {
if level < 1 {
level = 1
}
return 6 + 2*(level-1)
}
// mageLeveledKnownCount counts leveled (Level ≥ 1) spells in the player's
// spellbook. Cantrips and unknown registry entries are skipped.
func mageLeveledKnownCount(userID id.UserID) (int, error) {
rows, err := listKnownSpells(userID)
if err != nil {
return 0, err
}
n := 0
for _, r := range rows {
s, ok := lookupSpell(r.SpellID)
if !ok || s.Level == 0 {
continue
}
n++
}
return n, nil
}
func highestAvailableSlot(class DnDClass, level int) int {
pool := slotsForClassLevel(class, level)
high := 0
for lvl := range pool {
if lvl > high {
high = lvl
}
}
return high
}
// highestAvailableSlotForChar — like highestAvailableSlot but consults the
// combined class+subclass pool. Phase 10 SUB2-AT uses this to clamp Arcane
// Trickster upcasts to their actual subclass slot ceiling.
func highestAvailableSlotForChar(c *DnDCharacter) int {
high := 0
for lvl := range slotsForCharacter(c) {
if lvl > high {
high = lvl
}
}
return high
}
// ── Display helpers ──────────────────────────────────────────────────────────
func displaySpellName(spellID string) string {
if s, ok := lookupSpell(spellID); ok {
return s.Name
}
return spellID
}
func renderSlotLine(slots map[int][2]int) string {
if len(slots) == 0 {
return "_(no spell slots)_"
}
var parts []string
for lvl := 1; lvl <= 5; lvl++ {
pair, ok := slots[lvl]
if !ok {
continue
}
total := pair[0]
left := pair[0] - pair[1]
if left < 0 {
left = 0
}
// Filled bullets = still available, hollow = spent. Player-friendly
// at a glance; no fractions, no "L1" jargon.
bullets := strings.Repeat("●", left) + strings.Repeat("○", total-left)
parts = append(parts, fmt.Sprintf("Level %d %s", lvl, bullets))
}
return strings.Join(parts, " · ")
}