Files
gogobee/internal/plugin/dnd_subclass_cmd.go
prosolis 4a5159ee14 Adv 2.0 D&D Phase 10 SUB2-AT: Arcane Trickster spellcasting
Slots into the Phase 9 spell pipeline so an L5+ Rogue who picks Arcane
Trickster becomes a third-caster on the Mage list, INT-based.

  - isSpellcaster(c) gate: full casters by class plus AT Rogue at L5+.
    !cast / !spells now consult this; the cast class-gate also accepts
    Mage-tagged spells when the player is an AT Rogue.
  - subclassSpellSlots() / slotsForCharacter() / setSpellSlotsForCharacter()
    layer subclass slots on top of the class baseline. AT pool: 3 L1 at
    L5–6, 4 L1 + 2 L2 at L7+, +2 L3 at L13+ (third-caster table).
  - spellcastingMod returns INT for AT Rogue.
  - L7 Magical Ambush proxy: spellSaveDC bumps +2 once Rogue+AT hits L7,
    standing in for "targets have disadvantage when casting from hidden"
    since the one-shot combat layer doesn't model hidden state.
  - ensureSpellsForCharacter bootstraps a Mage-list starter spellbook
    (minor_illusion, shocking_grasp, magic_missile, shield, sleep at L5;
    mirror_image + misty_step at L7+; hypnotic_pattern at L13+) and
    refreshes the slot pool on level/subclass change.
  - applySubclassChoice runs ensureSpellsForCharacter for AT so the
    spellbook is provisioned at selection time (idempotent).
  - !cast clamps upcasts to highestAvailableSlotForChar when AT, since
    third-caster's slot ceiling lags behind the spell list.

Tests added: AT spellcaster gate (L5 cutoff, non-AT Rogue rejected), INT
mod, subclass slot pool by tier, L7 ambush DC bump, ensureSpells default
list (all on Mage list, slots correct), end-to-end !cast queues
magic_missile for AT Rogue, !cast rejects plain Thief Rogue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00

157 lines
5.4 KiB
Go

package plugin
import (
"fmt"
"strings"
"time"
)
// !subclass — Phase 10 SUB1.
//
// Usage:
//
// !subclass → show selection prompt for player's class
// !subclass <name|number> → choose / change subclass
//
// Rules:
// - L<5: blocked with hint message.
// - First selection (Subclass == ""): free, no cooldown.
// - Subsequent change: 30-day cooldown + 500-coin cost. Same surface
// command — no separate `respec` subcommand. The full-character-wipe
// `!respec` command is unchanged.
//
// Mechanical effects of L5/7/10/15 subclass abilities arrive in SUB2/SUB3.
const (
dndSubclassMinLevel = 5
dndSubclassRespecCost = 500
dndSubclassRespecCooldown = 30 * 24 * time.Hour
)
func (p *AdventurePlugin) handleDnDSubclassCmd(ctx MessageContext, args string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
c, err := LoadDnDCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
}
if c == nil || c.PendingSetup {
return p.SendDM(ctx.Sender,
"No Adv 2.0 character yet — run `!setup` (or just enter combat and we'll auto-build one).")
}
if c.Level < dndSubclassMinLevel {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Subclass selection unlocks at **Level %d**. You're currently L%d.",
dndSubclassMinLevel, c.Level))
}
if len(subclassesForClass(c.Class)) == 0 {
return p.SendDM(ctx.Sender, "Your class has no subclasses registered yet.")
}
args = strings.TrimSpace(args)
// Bare `!subclass` → prompt or current.
if args == "" {
if c.Subclass == "" {
return p.SendDM(ctx.Sender, renderSubclassPrompt(c.Class))
}
info, _ := subclassInfo(c.Subclass)
var b strings.Builder
b.WriteString(fmt.Sprintf("**Current subclass:** %s — _%s_\n\n", info.Display, info.Tagline))
b.WriteString(fmt.Sprintf(
"To change: `!subclass <name>` — costs **%d euros**, **%d-day** cooldown.\n",
dndSubclassRespecCost, int(dndSubclassRespecCooldown/(24*time.Hour))))
return p.SendDM(ctx.Sender, b.String())
}
// `!subclass <name|number>` → set or change.
chosen, ok := resolveSubclassInput(c.Class, args)
if !ok {
var b strings.Builder
b.WriteString("Unknown subclass for your class. Options:\n\n")
b.WriteString(renderSubclassPrompt(c.Class))
return p.SendDM(ctx.Sender, b.String())
}
// First selection — free.
if c.Subclass == "" {
return p.applySubclassChoice(ctx, c, chosen, false)
}
// Same subclass already chosen — no-op.
if c.Subclass == chosen {
info, _ := subclassInfo(chosen)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You're already a **%s**. No change made.", info.Display))
}
// Change — gated by cooldown + cost.
if c.LastSubclassRespecAt != nil {
elapsed := time.Since(*c.LastSubclassRespecAt)
if elapsed < dndSubclassRespecCooldown {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Subclass change is on cooldown — %s remaining.",
formatRespecDuration(dndSubclassRespecCooldown-elapsed)))
}
}
if p.euro == nil || p.euro.GetBalance(ctx.Sender) < float64(dndSubclassRespecCost) {
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Changing subclass costs **%d euros** — you don't have enough.",
dndSubclassRespecCost))
}
return p.applySubclassChoice(ctx, c, chosen, true)
}
// applySubclassChoice writes the new subclass to the character and (if
// charged) debits the cost. Saves before debiting (audit pattern from
// !respec): if the save fails the player keeps their euros.
func (p *AdventurePlugin) applySubclassChoice(
ctx MessageContext, c *DnDCharacter, chosen DnDSubclass, charged bool,
) error {
prev := c.Subclass
c.Subclass = chosen
if charged {
now := time.Now().UTC()
c.LastSubclassRespecAt = &now
}
if err := SaveDnDCharacter(c); err != nil {
return p.SendDM(ctx.Sender, "Couldn't save subclass choice: "+err.Error())
}
// Phase 10 SUB2a-ii — provision subclass-specific resource pools (e.g.
// Battle Master superiority dice). Idempotent. Failure is non-fatal —
// the player still gets the subclass; resources can be re-initialized
// next long rest.
_ = initSubclassResources(ctx.Sender, chosen)
// Phase 10 SUB2-AT — Arcane Trickster bootstraps a Mage-list spellbook
// + third-caster slot pool on selection. Idempotent and non-fatal: the
// next `!cast`/`!spells` call also runs ensureSpellsForCharacter.
if chosen == SubclassArcaneTrickster {
_ = ensureSpellsForCharacter(c)
}
if charged {
// Debit last; race vs. balance change is rare and strictly safer
// to favor the player than risk taking euros without applying the
// change (matches the !respec ordering).
// Race vs. balance change is rare; if Debit fails the save
// already landed. Mirrors the !respec ordering in dnd_setup.go.
_ = p.euro.Debit(ctx.Sender, float64(dndSubclassRespecCost), "dnd subclass change")
}
info, _ := subclassInfo(chosen)
var b strings.Builder
if prev == "" {
b.WriteString(fmt.Sprintf("🎯 **Subclass selected: %s**\n\n", info.Display))
} else {
prevInfo, _ := subclassInfo(prev)
b.WriteString(fmt.Sprintf("🎯 **Subclass changed: %s → %s**\n\n",
prevInfo.Display, info.Display))
b.WriteString(fmt.Sprintf("_%d euros spent. Cooldown: %dd before next change._\n\n",
dndSubclassRespecCost, int(dndSubclassRespecCooldown/(24*time.Hour))))
}
b.WriteString("_" + info.Flavor + "_\n\n")
b.WriteString("_(Mechanical L5/7/10/15 subclass abilities land in upcoming phases.)_")
return p.SendDM(ctx.Sender, b.String())
}