mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Adv 2.0 D&D Phase 10 SUB1: subclass selection system
Implements the identity layer of gogobee_subclass_system.md: 15 subclasses (3 per class) selectable at L5 via !subclass. - Schema: new subclass + last_subclass_respec_at columns on dnd_character. - Registry (dnd_subclass.go): 15 entries with class, display, tagline, and TwinBee flavor line; helpers for prompt rendering and input resolution (number, id, or display-name; case/space/hyphen-insensitive). - !subclass command: bare form prints prompt or current; !subclass <name> selects (free first time) or changes (500 euros + 30-day cooldown via separate timestamp from the full !respec wipe). Save-then-debit ordering mirrors !respec audit fix B. - Level-up DM at L5+ now embeds the real selection prompt instead of the Phase 9 placeholder; suppressed once a subclass is set. - Sheet shows the subclass below the class line, or nudges !subclass when unchosen at L5+. - Existing !respec full-wipe also clears subclass + cooldown timestamp. Mechanical effects of L5/7/10/15 subclass abilities deferred to SUB2/SUB3.
This commit is contained in:
145
internal/plugin/dnd_subclass_cmd.go
Normal file
145
internal/plugin/dnd_subclass_cmd.go
Normal file
@@ -0,0 +1,145 @@
|
||||
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())
|
||||
}
|
||||
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())
|
||||
}
|
||||
Reference in New Issue
Block a user