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 L5–6, 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, " · ") }