mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
`--target @user` has been advertised by !help and swallowed by the parser since SP2 — "reserved for SP3, accept and ignore". §1 wired ally heals inside a fight; out of combat the cleric still could not put a hit point on anybody but himself, which is most of where a party is actually hurt: between the rooms, not in them. The target set is the expedition, not the world. In a fight splitCastTarget resolves against the people in the fight; the standing-around equivalent is the people you are travelling with, so both `!cast` paths answer the same question. Only a heal may name somebody else. UTILITY resolves on the caster and everything else queues as a PendingCast for the *caster's* next fight, where an ally target has nothing to mean — so a target on those is refused outright rather than silently dropped, which is exactly what the old parse did. The ally's row is mutated with one guarded UPDATE inside a transaction, not a read-modify-write under a second lock (gifting sets the precedent). Two clerics healing each other at the same instant would otherwise take their advUserLocks in opposite orders and deadlock the pair of them. The max-HP clamp lives in the SQL for the same reason. Refunds the slot on every path that heals nobody — full-HP ally, downed ally, no sheet, stranger. A slot spent for zero HP is the kind of thing players do not forgive. All four are pinned end-to-end through the real handler. A heal is still not a resurrection: it will not raise the dead, same rule the combat path holds. Claude-Session: https://claude.ai/code/session_01J5SQZWoLmL3M3mw2XmHHdy
810 lines
27 KiB
Go
810 lines
27 KiB
Go
package plugin
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"math/rand/v2"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// Phase 9 SP2 — !cast / !spells / !prepare commands and out-of-combat
|
||
// resolution. Combat-time resolution of pending_cast lives in dnd_combat.go
|
||
// (SP3).
|
||
//
|
||
// Casting model:
|
||
// - Cantrips: no slot cost. Damage cantrips queue as pending_cast for
|
||
// next combat. Utility cantrips (Mending, Message, Minor Illusion,
|
||
// Guidance) resolve immediately with a flavorful response.
|
||
// - Leveled spells: consume a slot at cast time (audit-style save-then-
|
||
// debit). DAMAGE_*, CONTROL, BUFF_SELF/ALLY queue as pending_cast.
|
||
// HEAL and UTILITY resolve immediately.
|
||
// - Reaction spells (Shield, Counterspell, Absorb Elements) refuse with
|
||
// a "Phase 11" note — they require the boss turn-based engine.
|
||
//
|
||
// Cross-player targeting (`--target @user`, or a trailing `@user`) heals a human
|
||
// on your expedition — see dnd_cast_target.go. Only heals may name somebody else
|
||
// out of combat: UTILITY resolves on the caster, and everything else queues as a
|
||
// PendingCast for the *caster's* next fight, where an ally target has nothing to
|
||
// mean. In-combat targeting is splitCastTarget (combat_cmd.go).
|
||
|
||
// PendingCast is the shape stored in dnd_character.pending_cast (JSON blob).
|
||
// Keep this minimal — the combat layer resolves the actual numbers on fire.
|
||
type PendingCast struct {
|
||
SpellID string `json:"spell"`
|
||
SlotLevel int `json:"slot"` // 0 for cantrips
|
||
Target string `json:"target,omitempty"`
|
||
}
|
||
|
||
func encodePendingCast(p PendingCast) string {
|
||
b, _ := json.Marshal(p)
|
||
return string(b)
|
||
}
|
||
|
||
func decodePendingCast(s string) (PendingCast, bool) {
|
||
if s == "" {
|
||
return PendingCast{}, false
|
||
}
|
||
var p PendingCast
|
||
if err := json.Unmarshal([]byte(s), &p); err != nil {
|
||
return PendingCast{}, false
|
||
}
|
||
if p.SpellID == "" {
|
||
return PendingCast{}, false
|
||
}
|
||
return p, true
|
||
}
|
||
|
||
// ── !cast command ────────────────────────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) handleDnDCastCmd(ctx MessageContext, args string) error {
|
||
// In a turn-based Elite/Boss fight, !cast resolves as the player's turn
|
||
// for the round rather than queuing for "next combat". handleCombatCastCmd
|
||
// takes the fight's locks itself and re-checks the session under them.
|
||
//
|
||
// activeCombatSessionFor, not getActiveCombatSession: a seated party member
|
||
// owns no session row, and would otherwise fall through to the out-of-combat
|
||
// path and queue a PendingCast in the middle of their own boss fight.
|
||
if sess, _ := activeCombatSessionFor(ctx.Sender); sess != nil {
|
||
return p.handleCombatCastCmd(ctx, args)
|
||
}
|
||
|
||
userMu := p.advUserLock(ctx.Sender)
|
||
userMu.Lock()
|
||
defer userMu.Unlock()
|
||
|
||
args = strings.TrimSpace(args)
|
||
advChar, _ := loadAdvCharacter(ctx.Sender)
|
||
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
|
||
if err != nil || c == nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
|
||
}
|
||
if !isSpellcaster(c) {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"%s doesn't cast spells.", titleClass(c.Class)))
|
||
}
|
||
|
||
if args == "" {
|
||
return p.SendDM(ctx.Sender, renderCastHelp(c))
|
||
}
|
||
|
||
// Parse: --drop is a special form ("!cast --drop" or "!cast drop").
|
||
lower := strings.ToLower(args)
|
||
if lower == "--drop" || lower == "drop" {
|
||
return p.dndCastDrop(ctx, c)
|
||
}
|
||
|
||
// An ally target — `--target @alex`, or a trailing `@alex` — comes off the
|
||
// string before the spell parser sees it, exactly as in combat. It is
|
||
// resolved (and refused) further down, once we know the spell is a heal: a
|
||
// target on anything else is a mistake, and refusing it early would cost us
|
||
// the "which spell?" half of the error message.
|
||
args, targetName := splitOutOfCombatTarget(args)
|
||
|
||
// Parse spell name + optional --upcast N.
|
||
tokens := strings.Fields(args)
|
||
upcast := 0
|
||
var spellTokens []string
|
||
for i := 0; i < len(tokens); i++ {
|
||
switch tokens[i] {
|
||
case "--upcast":
|
||
if i+1 < len(tokens) {
|
||
n, err := strconv.Atoi(tokens[i+1])
|
||
if err == nil {
|
||
upcast = n
|
||
}
|
||
i++
|
||
}
|
||
default:
|
||
spellTokens = append(spellTokens, tokens[i])
|
||
}
|
||
}
|
||
if len(spellTokens) == 0 {
|
||
return p.SendDM(ctx.Sender, "Usage: `!cast <spell> [--upcast N]`. Run `!spells` to list options.")
|
||
}
|
||
spell, ok := parseSpell(strings.Join(spellTokens, " "))
|
||
if !ok {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"Unknown spell %q. Run `!spells` to list what you know.", strings.Join(spellTokens, " ")))
|
||
}
|
||
|
||
// Class gate. Arcane Trickster Rogues use the Mage spell list (Phase 10
|
||
// SUB2-AT), so accept Mage-tagged spells when the player has that subclass.
|
||
effectiveClass := c.Class
|
||
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
|
||
effectiveClass = ClassMage
|
||
}
|
||
classOK := false
|
||
for _, cl := range spell.Classes {
|
||
if cl == effectiveClass {
|
||
classOK = true
|
||
break
|
||
}
|
||
}
|
||
if !classOK {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"%s is not on the %s spell list.", spell.Name, titleClass(c.Class)))
|
||
}
|
||
// AT slot ceiling — capped at L1 until L7 (then L2), tracks third-caster
|
||
// progression. Refuse upcasting beyond what the subclass actually has.
|
||
if c.Class == ClassRogue && c.Subclass == SubclassArcaneTrickster {
|
||
if max := highestAvailableSlotForChar(c); spell.Level > max {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"At level %d, your Arcane Trickster magic only reaches level-%d spells.", c.Level, max))
|
||
}
|
||
}
|
||
|
||
// Reaction spells deferred — combat is one-shot SimulateCombat, no
|
||
// per-turn windows where a reaction could trigger. Will land alongside
|
||
// turn-based combat when that arrives.
|
||
if spell.Effect == EffectReaction {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"%s is a reaction spell — those aren't usable yet (combat resolves in one beat, no reaction window). Pick a different spell.", spell.Name))
|
||
}
|
||
|
||
// Known + prepared check.
|
||
known, prepared, err := playerKnowsSpell(ctx.Sender, spell.ID)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't check your spell list.")
|
||
}
|
||
if !known {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"You don't know %s yet.", spell.Name))
|
||
}
|
||
if !prepared {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"%s isn't prepared today. Run `!prepare %s` first.", spell.Name, spell.ID))
|
||
}
|
||
|
||
// Determine slot level.
|
||
slotLevel := spell.Level
|
||
if upcast > slotLevel && spell.Level > 0 {
|
||
slotLevel = upcast
|
||
}
|
||
if slotLevel < 0 || slotLevel > 5 {
|
||
return p.SendDM(ctx.Sender, "Slot level out of range (1–5).")
|
||
}
|
||
|
||
// Concentration conflict: if spell needs concentration and player has
|
||
// another active, warn (we'll supersede on the actual cast).
|
||
supersedes := ""
|
||
if spell.Concentration {
|
||
if active := concentrationActive(c); active != "" && active != spell.ID {
|
||
supersedes = active
|
||
}
|
||
}
|
||
|
||
// Resolve an ally target now — BEFORE anything is spent. Out of combat only a
|
||
// heal can land on somebody else: UTILITY resolves on the caster and
|
||
// everything else queues as a PendingCast for *this* caster's next fight, so
|
||
// a target on those would be accepted and then silently dropped.
|
||
var targetUser id.UserID
|
||
if targetName != "" {
|
||
if spell.Effect != EffectSpellHeal {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"%s isn't a healing spell — outside a fight you can only cast those on someone else. Drop the target to cast it on yourself.", spell.Name))
|
||
}
|
||
uid, errMsg := resolveCastTargetOnExpedition(ctx.Sender, targetName)
|
||
if errMsg != "" {
|
||
return p.SendDM(ctx.Sender, errMsg)
|
||
}
|
||
targetUser = uid // empty when they named themselves — self-heal as usual
|
||
}
|
||
|
||
// Material cost (Revivify, Raise Dead).
|
||
if spell.MaterialCost > 0 {
|
||
if p.euro == nil || !p.euro.Debit(ctx.Sender, float64(spell.MaterialCost), "dnd_spell_component") {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"%s needs a %d-coin diamond component you can't afford.", spell.Name, spell.MaterialCost))
|
||
}
|
||
}
|
||
|
||
// Slot consumption (cantrips skip).
|
||
if spell.Level > 0 {
|
||
ok, err := consumeSpellSlot(ctx.Sender, slotLevel)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't consume slot: "+err.Error())
|
||
}
|
||
if !ok {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"You're out of level-%d energy. %s", slotLevel, renderSlotsBrief(ctx.Sender)))
|
||
}
|
||
}
|
||
|
||
// Resolve. Audit pattern: for queueing effects, save state BEFORE we
|
||
// would otherwise consider the action committed. The slot is already
|
||
// debited above; if SaveDnDCharacter fails we refund.
|
||
switch spell.Effect {
|
||
case EffectSpellHeal:
|
||
if targetUser != "" {
|
||
return p.resolveAllyHealOutOfCombat(ctx, c, targetUser, spell, slotLevel)
|
||
}
|
||
return p.resolveHealOutOfCombat(ctx, c, spell, slotLevel)
|
||
case EffectUtility:
|
||
return p.resolveUtility(ctx, c, spell, slotLevel)
|
||
default:
|
||
// DAMAGE_*, CONTROL, BUFF_SELF/ALLY → queue for next combat.
|
||
return p.queuePendingCast(ctx, c, spell, slotLevel, supersedes)
|
||
}
|
||
}
|
||
|
||
// ── Out-of-combat: HEAL ──────────────────────────────────────────────────────
|
||
|
||
// rollOutOfCombatHeal is what the spell is worth in the caster's hands. It is a
|
||
// property of the caster (their WIS, their domain), never of the body it lands
|
||
// on, so an ally heal rolls exactly the same as a self-heal.
|
||
func rollOutOfCombatHeal(c *DnDCharacter, spell SpellDefinition, slotLevel int) int {
|
||
dice, faces, _ := parseDamageDice(spell.DamageDice)
|
||
if dice == 0 {
|
||
dice, faces = 1, 8 // safety fallback
|
||
}
|
||
// Upcast scales healing. Most heal spells are "+1d8 per slot above 1st".
|
||
extra := slotLevel - spell.Level
|
||
if extra < 0 {
|
||
extra = 0
|
||
}
|
||
totalDice := dice + extra
|
||
heal := 0
|
||
supreme := lifeDomainSupremeHealing(c)
|
||
for i := 0; i < totalDice; i++ {
|
||
if supreme {
|
||
heal += faces
|
||
} else {
|
||
heal += 1 + rand.IntN(faces)
|
||
}
|
||
}
|
||
heal += abilityModifier(c.WIS)
|
||
heal += lifeDomainHealBonus(c, spell, slotLevel)
|
||
return heal
|
||
}
|
||
|
||
func (p *AdventurePlugin) resolveHealOutOfCombat(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
||
heal := rollOutOfCombatHeal(c, spell, slotLevel)
|
||
|
||
before := c.HPCurrent
|
||
c.HPCurrent = min(c.HPMax, c.HPCurrent+heal)
|
||
delta := c.HPCurrent - before
|
||
|
||
if err := SaveDnDCharacter(c); err != nil {
|
||
// Refund the slot.
|
||
if spell.Level > 0 {
|
||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||
}
|
||
return p.SendDM(ctx.Sender, "Couldn't apply heal: "+err.Error())
|
||
}
|
||
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"🩹 **%s** — restored %d HP (%d → %d / %d). %s",
|
||
spell.Name, delta, before, c.HPCurrent, c.HPMax,
|
||
renderSlotsBrief(ctx.Sender)))
|
||
}
|
||
|
||
// resolveAllyHealOutOfCombat puts the heal on somebody else's sheet. The slot is
|
||
// already spent, so every failure below refunds it: a heal that lands on nobody
|
||
// must not cost the caster anything.
|
||
func (p *AdventurePlugin) resolveAllyHealOutOfCombat(ctx MessageContext, c *DnDCharacter, target id.UserID, spell SpellDefinition, slotLevel int) error {
|
||
refund := func(msg string) error {
|
||
if spell.Level > 0 {
|
||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||
}
|
||
return p.SendDM(ctx.Sender, msg)
|
||
}
|
||
|
||
heal := rollOutOfCombatHeal(c, spell, slotLevel)
|
||
before, after, maxHP, err := healPartyMember(target, heal)
|
||
|
||
targetName := charName(target)
|
||
if targetName == "" {
|
||
targetName = target.Localpart()
|
||
}
|
||
casterName := charName(ctx.Sender)
|
||
if casterName == "" {
|
||
casterName = ctx.Sender.Localpart()
|
||
}
|
||
|
||
switch {
|
||
case errors.Is(err, errHealTargetFull):
|
||
return refund(fmt.Sprintf("**%s** is already at full health (%d/%d). Slot kept.", targetName, before, maxHP))
|
||
case errors.Is(err, errHealTargetDown):
|
||
// The same rule the combat path holds: a heal is not a resurrection.
|
||
return refund(fmt.Sprintf("**%s** is down — a heal won't bring them back. Slot kept.", targetName))
|
||
case errors.Is(err, sql.ErrNoRows):
|
||
return refund(fmt.Sprintf("**%s** hasn't run `!setup` yet, so there's no sheet to heal.", targetName))
|
||
case err != nil:
|
||
return refund("Couldn't apply the heal: " + err.Error())
|
||
}
|
||
|
||
_ = p.SendDM(target, fmt.Sprintf(
|
||
"🩹 **%s** casts **%s** on you — %d HP back (%d → %d / %d).",
|
||
casterName, spell.Name, after-before, before, after, maxHP))
|
||
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"🩹 **%s** on **%s** — restored %d HP (%d → %d / %d). %s",
|
||
spell.Name, targetName, after-before, before, after, maxHP,
|
||
renderSlotsBrief(ctx.Sender)))
|
||
}
|
||
|
||
// ── Out-of-combat: UTILITY ──────────────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) resolveUtility(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int) error {
|
||
// Most utility spells are pure narrative — they produce a flavor line
|
||
// and a confirmation. A few (Mage Armor, Hunter's Mark, etc.) are
|
||
// concentration buffs that fire as pending_cast in combat. Those are
|
||
// classified as BUFF_* in the registry, not UTILITY, so we don't see
|
||
// them here. Concentration UTILITY spells (Detect Magic, Beast Sense)
|
||
// merely set the concentration flag; combat doesn't care.
|
||
supersedes := ""
|
||
if spell.Concentration {
|
||
supersedes = setConcentration(c, spell.ID, utilityConcentrationDuration(spell))
|
||
}
|
||
|
||
if err := SaveDnDCharacter(c); err != nil {
|
||
if spell.Level > 0 {
|
||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||
}
|
||
return p.SendDM(ctx.Sender, "Couldn't save state: "+err.Error())
|
||
}
|
||
|
||
msg := fmt.Sprintf("✨ **%s** — %s", spell.Name, spell.Description)
|
||
if supersedes != "" {
|
||
msg += fmt.Sprintf("\n_(Concentration shifts: %s ended.)_", displaySpellName(supersedes))
|
||
}
|
||
if spell.Level > 0 {
|
||
msg += "\n" + renderSlotsBrief(ctx.Sender)
|
||
}
|
||
return p.SendDM(ctx.Sender, msg)
|
||
}
|
||
|
||
func utilityConcentrationDuration(spell SpellDefinition) time.Duration {
|
||
// Phase 9 keeps durations narrative — anything that lasts past a long
|
||
// rest is reset by long rest anyway. Use a generous default.
|
||
switch spell.ID {
|
||
case "detect_magic", "beast_sense":
|
||
return 10 * time.Minute
|
||
}
|
||
return 1 * time.Hour
|
||
}
|
||
|
||
// ── Queue for next combat ───────────────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) queuePendingCast(ctx MessageContext, c *DnDCharacter, spell SpellDefinition, slotLevel int, supersedes string) error {
|
||
if c.PendingCast != "" {
|
||
// Refund the slot we just consumed.
|
||
if spell.Level > 0 {
|
||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||
}
|
||
prev, _ := decodePendingCast(c.PendingCast)
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"You already have **%s** queued. Cast `!cast --drop` to clear it first.",
|
||
displaySpellName(prev.SpellID)))
|
||
}
|
||
|
||
pc := PendingCast{SpellID: spell.ID, SlotLevel: slotLevel}
|
||
c.PendingCast = encodePendingCast(pc)
|
||
if spell.Concentration {
|
||
setConcentration(c, spell.ID, 1*time.Hour)
|
||
}
|
||
|
||
// Audit pattern (Fix C): save-then-the-rest. Slot already debited; on
|
||
// save failure, refund slot and concentration.
|
||
if err := SaveDnDCharacter(c); err != nil {
|
||
if spell.Level > 0 {
|
||
_ = refundSpellSlot(ctx.Sender, slotLevel)
|
||
}
|
||
c.ConcentrationSpell = ""
|
||
c.ConcentrationExpiresAt = nil
|
||
return p.SendDM(ctx.Sender, "Couldn't queue spell: "+err.Error())
|
||
}
|
||
|
||
msg := fmt.Sprintf("✨ **%s** queued for next combat.", spell.Name)
|
||
if slotLevel > spell.Level {
|
||
msg += " _(empowered)_"
|
||
}
|
||
if supersedes != "" {
|
||
msg += fmt.Sprintf("\n_(Concentration shifts: %s ended.)_", displaySpellName(supersedes))
|
||
}
|
||
if spell.Level > 0 {
|
||
msg += "\n" + renderSlotsBrief(ctx.Sender)
|
||
}
|
||
return p.SendDM(ctx.Sender, msg)
|
||
}
|
||
|
||
// ── !cast --drop ─────────────────────────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) dndCastDrop(ctx MessageContext, c *DnDCharacter) error {
|
||
if c.PendingCast == "" && c.ConcentrationSpell == "" {
|
||
return p.SendDM(ctx.Sender, "Nothing queued and no concentration active.")
|
||
}
|
||
|
||
// Refund the queued slot (if any) — voluntary drop is the player's
|
||
// choice; restore the slot rather than forfeit it.
|
||
slotRefunded := false
|
||
if pc, ok := decodePendingCast(c.PendingCast); ok && pc.SlotLevel > 0 {
|
||
_ = refundSpellSlot(ctx.Sender, pc.SlotLevel)
|
||
slotRefunded = true
|
||
}
|
||
|
||
dropped := []string{}
|
||
if pc, ok := decodePendingCast(c.PendingCast); ok {
|
||
dropped = append(dropped, displaySpellName(pc.SpellID)+" (queued)")
|
||
}
|
||
if c.ConcentrationSpell != "" {
|
||
dropped = append(dropped, displaySpellName(c.ConcentrationSpell)+" (concentration)")
|
||
}
|
||
|
||
c.PendingCast = ""
|
||
c.ConcentrationSpell = ""
|
||
c.ConcentrationExpiresAt = nil
|
||
if err := SaveDnDCharacter(c); err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't drop: "+err.Error())
|
||
}
|
||
msg := "Dropped: " + strings.Join(dropped, ", ") + "."
|
||
if slotRefunded {
|
||
msg += " _(slot refunded)_"
|
||
}
|
||
return p.SendDM(ctx.Sender, msg)
|
||
}
|
||
|
||
// ── !spells command ─────────────────────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) handleDnDSpellsCmd(ctx MessageContext, args string) error {
|
||
userMu := p.advUserLock(ctx.Sender)
|
||
userMu.Lock()
|
||
defer userMu.Unlock()
|
||
|
||
advChar, _ := loadAdvCharacter(ctx.Sender)
|
||
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
|
||
if err != nil || c == nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
|
||
}
|
||
if !isSpellcaster(c) {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"%s isn't a caster class.", titleClass(c.Class)))
|
||
}
|
||
|
||
args = strings.TrimSpace(args)
|
||
if strings.HasPrefix(strings.ToLower(args), "learn ") {
|
||
return p.handleSpellsLearn(ctx, c, strings.TrimSpace(args[6:]))
|
||
}
|
||
|
||
return p.SendDM(ctx.Sender, renderSpellsList(c))
|
||
}
|
||
|
||
func renderSpellsList(c *DnDCharacter) string {
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("**Spellbook — %s L%d**\n\n",
|
||
titleClass(c.Class), c.Level))
|
||
|
||
known, _ := listKnownSpells(c.UserID)
|
||
if len(known) == 0 {
|
||
b.WriteString("_No spells known. Run `!spells learn <name>` to add one._\n")
|
||
} else {
|
||
// Group by spell level.
|
||
byLevel := map[int][]knownSpellRow{}
|
||
for _, k := range known {
|
||
s, ok := lookupSpell(k.SpellID)
|
||
if !ok {
|
||
continue
|
||
}
|
||
byLevel[s.Level] = append(byLevel[s.Level], k)
|
||
}
|
||
levels := []int{}
|
||
for lvl := range byLevel {
|
||
levels = append(levels, lvl)
|
||
}
|
||
sort.Ints(levels)
|
||
for _, lvl := range levels {
|
||
label := fmt.Sprintf("Level %d", lvl)
|
||
if lvl == 0 {
|
||
label = "Cantrips"
|
||
}
|
||
b.WriteString(fmt.Sprintf("__%s__\n", label))
|
||
for _, k := range byLevel[lvl] {
|
||
s, _ := lookupSpell(k.SpellID)
|
||
prepStr := ""
|
||
if c.Class == ClassCleric && s.Level > 0 {
|
||
if k.Prepared {
|
||
prepStr = " ✅"
|
||
} else {
|
||
prepStr = " ⬜"
|
||
}
|
||
}
|
||
b.WriteString(fmt.Sprintf(" • **%s**%s — %s\n", s.Name, prepStr, s.Description))
|
||
}
|
||
}
|
||
}
|
||
|
||
slots, _ := getSpellSlots(c.UserID)
|
||
b.WriteString("\n**Slots:** " + renderSlotLine(slots) + "\n")
|
||
|
||
if c.Class == ClassMage {
|
||
count, _ := mageLeveledKnownCount(c.UserID)
|
||
cap := mageKnownSpellsCap(c.Level)
|
||
b.WriteString(fmt.Sprintf("**Spellbook:** %d / %d leveled spells learned\n", count, cap))
|
||
}
|
||
|
||
if pc, ok := decodePendingCast(c.PendingCast); ok {
|
||
b.WriteString(fmt.Sprintf("\n_Queued: **%s**", displaySpellName(pc.SpellID)))
|
||
if s, ok := lookupSpell(pc.SpellID); ok && pc.SlotLevel > s.Level {
|
||
b.WriteString(" (empowered)")
|
||
}
|
||
b.WriteString(" — fires next combat._\n")
|
||
}
|
||
if c.ConcentrationSpell != "" && concentrationActive(c) != "" {
|
||
b.WriteString(fmt.Sprintf("_Concentrating on **%s**._\n",
|
||
displaySpellName(c.ConcentrationSpell)))
|
||
}
|
||
b.WriteString("\nSlots refresh on long rest.")
|
||
return b.String()
|
||
}
|
||
|
||
// ── !spells learn (Mage spellbook) ───────────────────────────────────────────
|
||
|
||
func (p *AdventurePlugin) handleSpellsLearn(ctx MessageContext, c *DnDCharacter, raw string) error {
|
||
if c.Class != ClassMage {
|
||
return p.SendDM(ctx.Sender, "`!spells learn` is for Mage. "+spellRouteHintFor(c)+
|
||
" Run `!spells` to see what you already know.")
|
||
}
|
||
if raw == "" {
|
||
return p.SendDM(ctx.Sender, "Usage: `!spells learn <spell name>`")
|
||
}
|
||
spell, ok := parseSpell(raw)
|
||
if !ok {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf("Unknown spell %q.", raw))
|
||
}
|
||
classOK := false
|
||
for _, cl := range spell.Classes {
|
||
if cl == ClassMage {
|
||
classOK = true
|
||
}
|
||
}
|
||
if !classOK {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s isn't on the Mage spell list.", spell.Name))
|
||
}
|
||
if spell.Level > highestAvailableSlot(ClassMage, c.Level) && spell.Level > 0 {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"L%d spells require Mage level %d+.", spell.Level, requiredMageLevelFor(spell.Level)))
|
||
}
|
||
known, _, err := playerKnowsSpell(ctx.Sender, spell.ID)
|
||
if err == nil && known {
|
||
return p.SendDM(ctx.Sender, "You already know "+spell.Name+".")
|
||
}
|
||
// Cap applies to leveled spells only — cantrips are unbounded.
|
||
if spell.Level > 0 {
|
||
count, err := mageLeveledKnownCount(ctx.Sender)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't check your spellbook.")
|
||
}
|
||
cap := mageKnownSpellsCap(c.Level)
|
||
if count >= cap {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"Spellbook is full (%d/%d). Level up to learn more leveled spells.",
|
||
count, cap))
|
||
}
|
||
}
|
||
if err := addKnownSpell(ctx.Sender, spell.ID, "class", true); err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't learn: "+err.Error())
|
||
}
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf("📖 Learned **%s**.", spell.Name))
|
||
}
|
||
|
||
func requiredMageLevelFor(slotLevel int) int {
|
||
switch slotLevel {
|
||
case 1:
|
||
return 1
|
||
case 2:
|
||
return 3
|
||
case 3:
|
||
return 5
|
||
case 4:
|
||
return 7
|
||
case 5:
|
||
return 9
|
||
}
|
||
return 1
|
||
}
|
||
|
||
// ── !prepare command (Cleric stub — full SP4) ───────────────────────────────
|
||
|
||
func (p *AdventurePlugin) handleDnDPrepareCmd(ctx MessageContext, args string) error {
|
||
userMu := p.advUserLock(ctx.Sender)
|
||
userMu.Lock()
|
||
defer userMu.Unlock()
|
||
|
||
advChar, _ := loadAdvCharacter(ctx.Sender)
|
||
c, err := p.ensureCharForDnDCmd(ctx.Sender, advChar)
|
||
if err != nil || c == nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't load your Adv 2.0 sheet.")
|
||
}
|
||
if c.Class != ClassCleric {
|
||
return p.SendDM(ctx.Sender, "`!prepare` is for Cleric. "+spellRouteHintFor(c)+
|
||
" Run `!spells` to see what you already know.")
|
||
}
|
||
args = strings.TrimSpace(args)
|
||
if args == "" {
|
||
return p.SendDM(ctx.Sender, renderClericPrepStatus(c))
|
||
}
|
||
|
||
clear := false
|
||
if strings.HasPrefix(strings.ToLower(args), "clear ") {
|
||
clear = true
|
||
args = strings.TrimSpace(args[6:])
|
||
}
|
||
|
||
spell, ok := parseSpell(args)
|
||
if !ok {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf("Unknown spell %q.", args))
|
||
}
|
||
if spell.Level == 0 {
|
||
return p.SendDM(ctx.Sender, "Cantrips are always prepared.")
|
||
}
|
||
known, _, err := playerKnowsSpell(ctx.Sender, spell.ID)
|
||
if err != nil || !known {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf("%s isn't on your known list.", spell.Name))
|
||
}
|
||
|
||
if clear {
|
||
if err := setSpellPrepared(ctx.Sender, spell.ID, false); err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't unprepare: "+err.Error())
|
||
}
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf("Unprepared **%s**.", spell.Name))
|
||
}
|
||
|
||
// Cap = WIS mod + Cleric level. Cantrips don't count.
|
||
cap := abilityModifier(c.WIS) + c.Level
|
||
if cap < 1 {
|
||
cap = 1
|
||
}
|
||
rows, _ := listKnownSpells(ctx.Sender)
|
||
prepCount := 0
|
||
for _, r := range rows {
|
||
s, ok := lookupSpell(r.SpellID)
|
||
if !ok || s.Level == 0 {
|
||
continue
|
||
}
|
||
if r.Prepared && r.SpellID != spell.ID {
|
||
prepCount++
|
||
}
|
||
}
|
||
if prepCount+1 > cap {
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf(
|
||
"Your prepared list is full (%d/%d). Unprepare one first: `!prepare clear <name>`.",
|
||
prepCount, cap))
|
||
}
|
||
if err := setSpellPrepared(ctx.Sender, spell.ID, true); err != nil {
|
||
return p.SendDM(ctx.Sender, "Couldn't prepare: "+err.Error())
|
||
}
|
||
return p.SendDM(ctx.Sender, fmt.Sprintf("📖 Prepared **%s** (%d/%d).",
|
||
spell.Name, prepCount+1, cap))
|
||
}
|
||
|
||
func renderClericPrepStatus(c *DnDCharacter) string {
|
||
cap := abilityModifier(c.WIS) + c.Level
|
||
if cap < 1 {
|
||
cap = 1
|
||
}
|
||
rows, _ := listKnownSpells(c.UserID)
|
||
prepCount := 0
|
||
for _, r := range rows {
|
||
s, ok := lookupSpell(r.SpellID)
|
||
if !ok || s.Level == 0 {
|
||
continue
|
||
}
|
||
if r.Prepared {
|
||
prepCount++
|
||
}
|
||
}
|
||
return fmt.Sprintf("**Prepared spells:** %d/%d. Run `!prepare <spell>` to add, `!prepare clear <spell>` to remove. Long rest re-opens choices.",
|
||
prepCount, cap)
|
||
}
|
||
|
||
// spellRouteHintFor returns a one-sentence pointer telling the player which
|
||
// command actually advances their spellbook, scoped to their class. Used by
|
||
// the !spells learn / !prepare wrong-class error messages so the player isn't
|
||
// left guessing after we reject them.
|
||
func spellRouteHintFor(c *DnDCharacter) string {
|
||
if c == nil {
|
||
return ""
|
||
}
|
||
switch c.Class {
|
||
case ClassMage:
|
||
return "Mage learns spells with `!spells learn <name>`."
|
||
case ClassCleric:
|
||
return "Cleric picks today's spells with `!prepare <name>`."
|
||
case ClassRogue:
|
||
if c.Subclass == SubclassArcaneTrickster {
|
||
return "Arcane Trickster learns spells with `!spells learn <name>` once it unlocks."
|
||
}
|
||
return ""
|
||
case ClassDruid, ClassBard, ClassSorcerer, ClassWarlock, ClassPaladin, ClassRanger:
|
||
return "Your class learns spells automatically as you level — nothing to prepare."
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
func renderCastHelp(c *DnDCharacter) string {
|
||
var b strings.Builder
|
||
b.WriteString("**Cast a spell**\n\n")
|
||
b.WriteString("Usage: `!cast <spell> [--upcast N]`\n")
|
||
b.WriteString(" `!cast <heal> @friend` to heal someone on your expedition.\n")
|
||
b.WriteString(" `!cast --drop` to clear queued/concentration.\n\n")
|
||
b.WriteString("Run `!spells` for your full list.\n\n")
|
||
if pc, ok := decodePendingCast(c.PendingCast); ok {
|
||
b.WriteString(fmt.Sprintf("_Currently queued: **%s**._\n", displaySpellName(pc.SpellID)))
|
||
}
|
||
if c.ConcentrationSpell != "" && concentrationActive(c) != "" {
|
||
b.WriteString(fmt.Sprintf("_Concentrating on **%s**._\n",
|
||
displaySpellName(c.ConcentrationSpell)))
|
||
}
|
||
b.WriteString("\n" + renderSlotsBrief(c.UserID))
|
||
return b.String()
|
||
}
|
||
|
||
func renderSlotsBrief(userID id.UserID) string {
|
||
slots, err := getSpellSlots(userID)
|
||
if err != nil {
|
||
slog.Warn("dnd: getSpellSlots", "user", userID, "err", err)
|
||
}
|
||
return "**Slots:** " + renderSlotLine(slots)
|
||
}
|
||
|
||
// parseDamageDice extracts (count, faces, flatBonus) from "3d6", "1d8",
|
||
// "3d4+3" style strings. Returns (0,0,0) on failure.
|
||
func parseDamageDice(s string) (int, int, int) {
|
||
s = strings.TrimSpace(strings.ToLower(s))
|
||
if s == "" {
|
||
return 0, 0, 0
|
||
}
|
||
// Optional flat bonus: split on +.
|
||
flat := 0
|
||
if i := strings.Index(s, "+"); i > 0 {
|
||
f, err := strconv.Atoi(strings.TrimSpace(s[i+1:]))
|
||
if err == nil {
|
||
flat = f
|
||
}
|
||
s = strings.TrimSpace(s[:i])
|
||
}
|
||
i := strings.Index(s, "d")
|
||
if i <= 0 {
|
||
return 0, 0, flat
|
||
}
|
||
count, err := strconv.Atoi(s[:i])
|
||
if err != nil {
|
||
return 0, 0, flat
|
||
}
|
||
faces, err := strconv.Atoi(s[i+1:])
|
||
if err != nil {
|
||
return 0, 0, flat
|
||
}
|
||
return count, faces, flat
|
||
}
|