mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Combines all uncommitted D&D work into one checkpoint: Phases 1-8 (per gogobee_dnd_session_summary.md): - Race/class/stats system, !setup flow, !sheet, !respec - d20-vs-AC combat with race/class passives, auto-migration - XP/level-up, skill checks, NPC refund hooks - Equipment slot mapping, rarity, !rest short/long - Pre-arm active abilities, resource pools - Bestiary, !roll/!stats/!level, onboarding DM - Audit fixes A-I, flavor wiring under internal/flavor/ - Phase 8 weapon dice + armor AC tables (gogobee_equipment_appendix) Phase 9 SP1 — spell system foundations: - 79 SpellDefinitions (76 in-scope + 3 reaction-deferred) - dnd_known_spells, dnd_spell_slots tables - pending_cast / concentration_* columns on dnd_character - Slot tables for Mage/Cleric/Ranger, DC + attack-bonus math - Long-rest refreshes slots; respec wipes spell state - Auto-grant default known list on !setup confirm and auto-migration Phase 9 SP2 — !cast / !spells / !prepare commands: - Out-of-combat HEAL and UTILITY resolve immediately - Damage/control/buff queue as pending_cast for next combat - Audit-style save-then-debit, slot refund on save failure - !cast --drop, concentration supersession, prep-cap enforcement - Reaction spells refused with Phase 11 note SP3 (combat-time resolution of pending_cast) and SP4 (full prep/learn tests) are next. Build clean, full test suite green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
610 lines
18 KiB
Go
610 lines
18 KiB
Go
package plugin
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"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 ─────────────────────────────────────────────────────────────────
|
|
|
|
var dndSpellRegistry = func() map[string]SpellDefinition {
|
|
out := make(map[string]SpellDefinition, 80)
|
|
for _, s := range buildSpellList() {
|
|
out[s.ID] = s
|
|
}
|
|
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:
|
|
return mageSlots(level)
|
|
case ClassCleric:
|
|
return clericSlots(level)
|
|
case ClassRanger:
|
|
return rangerSlots(level)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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.
|
|
func mageSlots(level int) map[int]int {
|
|
// Standard 5e full caster table (mage = wizard).
|
|
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])
|
|
}
|
|
|
|
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:
|
|
return abilityModifier(c.WIS)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// spellSaveDC = 8 + proficiency bonus + spellcasting modifier.
|
|
func spellSaveDC(c *DnDCharacter) int {
|
|
return 8 + proficiencyBonus(c.Level) + spellcastingMod(c)
|
|
}
|
|
|
|
// spellAttackBonus = proficiency bonus + spellcasting modifier.
|
|
func spellAttackBonus(c *DnDCharacter) int {
|
|
return proficiencyBonus(c.Level) + spellcastingMod(c)
|
|
}
|
|
|
|
// classIsCaster returns true for the three caster classes Phase 9 covers.
|
|
func classIsCaster(class DnDClass) bool {
|
|
switch class {
|
|
case ClassMage, ClassCleric, ClassRanger:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ── Slot persistence ─────────────────────────────────────────────────────────
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// ── 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 c == nil || !classIsCaster(c.Class) {
|
|
return nil
|
|
}
|
|
existing, err := listKnownSpells(c.UserID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(existing) > 0 {
|
|
// Refresh the slot pool in case level has changed since the prior
|
|
// grant (covers level-up between sessions).
|
|
return setSpellSlotsForLevel(c.UserID, c.Class, c.Level)
|
|
}
|
|
defaults := defaultKnownSpells(c.Class, 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 setSpellSlotsForLevel(c.UserID, c.Class, c.Level)
|
|
}
|
|
|
|
// 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 {
|
|
out = append(out, "magic_missile", "mage_armor", "shield", "burning_hands", "detect_magic")
|
|
}
|
|
if maxSlot >= 2 {
|
|
out = append(out, "scorching_ray", "misty_step", "mirror_image")
|
|
}
|
|
if maxSlot >= 3 {
|
|
out = append(out, "fireball", "counterspell")
|
|
}
|
|
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_spell", "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
|
|
}
|
|
return 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
|
|
}
|
|
|
|
// ── 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++ {
|
|
if pair, ok := slots[lvl]; ok {
|
|
parts = append(parts, fmt.Sprintf("L%d %d/%d", lvl, pair[0]-pair[1], pair[0]))
|
|
}
|
|
}
|
|
return strings.Join(parts, " · ")
|
|
}
|