Adv 2.0 D&D layer: Phases 1-8 + Phase 9 SP1+SP2

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>
This commit is contained in:
prosolis
2026-05-08 08:21:44 -07:00
parent 8e0fe0230c
commit 9e1a1f606c
80 changed files with 19088 additions and 210 deletions

View File

@@ -0,0 +1,170 @@
package plugin
import (
"fmt"
"math/rand/v2"
"regexp"
"strconv"
"strings"
)
// Tabletop conveniences — !roll, !stats, !level. None of these mutate
// state; they're pure display/RNG commands.
// ── !roll <dice> ─────────────────────────────────────────────────────────────
var diceNotation = regexp.MustCompile(`^(\d*)d(\d+)([+-]\d+)?$`)
const (
dndRollMaxCount = 100
dndRollMaxSides = 1000
)
// parseDice parses standard tabletop notation: "2d6+3", "d20", "4d6-1".
// Returns (count, sides, modifier, ok).
func parseDice(s string) (int, int, int, bool) {
s = strings.ToLower(strings.TrimSpace(s))
m := diceNotation.FindStringSubmatch(s)
if m == nil {
return 0, 0, 0, false
}
count := 1
if m[1] != "" {
n, err := strconv.Atoi(m[1])
if err != nil {
return 0, 0, 0, false
}
count = n
}
sides, err := strconv.Atoi(m[2])
if err != nil || sides < 2 {
return 0, 0, 0, false
}
mod := 0
if m[3] != "" {
n, err := strconv.Atoi(m[3])
if err != nil {
return 0, 0, 0, false
}
mod = n
}
if count < 1 || count > dndRollMaxCount || sides > dndRollMaxSides {
return 0, 0, 0, false
}
return count, sides, mod, true
}
// rollDice returns the individual rolls and the total.
func rollDice(count, sides, mod int) ([]int, int) {
rolls := make([]int, count)
total := mod
for i := 0; i < count; i++ {
r := 1 + rand.IntN(sides)
rolls[i] = r
total += r
}
return rolls, total
}
func (p *AdventurePlugin) handleDnDRollCmd(ctx MessageContext, args string) error {
args = strings.TrimSpace(args)
if args == "" {
return p.SendDM(ctx.Sender,
"`!roll <dice>` — example: `!roll 2d6+3`, `!roll d20`, `!roll 4d6-1`. Max 100 dice, max 1000 sides.")
}
count, sides, mod, ok := parseDice(args)
if !ok {
return p.SendDM(ctx.Sender,
"Couldn't parse dice. Use NdN+M format (e.g. `2d6+3`, `d20`, `4d6-1`).")
}
rolls, total := rollDice(count, sides, mod)
var b strings.Builder
b.WriteString(fmt.Sprintf("🎲 **%dd%d", count, sides))
if mod > 0 {
b.WriteString(fmt.Sprintf("+%d", mod))
} else if mod < 0 {
b.WriteString(fmt.Sprintf("%d", mod))
}
b.WriteString("**\n")
if count <= 20 {
strs := make([]string, len(rolls))
for i, r := range rolls {
strs[i] = strconv.Itoa(r)
}
b.WriteString("Rolls: " + strings.Join(strs, ", "))
if mod != 0 {
if mod > 0 {
b.WriteString(fmt.Sprintf(" (+%d)", mod))
} else {
b.WriteString(fmt.Sprintf(" (%d)", mod))
}
}
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("**Total: %d**", total))
// Highlight nat-20 / nat-1 on a single d20
if count == 1 && sides == 20 {
switch rolls[0] {
case 20:
b.WriteString(" ✨ _critical!_")
case 1:
b.WriteString(" 💀 _fumble!_")
}
}
return p.SendDM(ctx.Sender, b.String())
}
// ── !stats ───────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleDnDStatsCmd(ctx MessageContext) 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 character.")
}
mods := c.Modifiers()
ri, _ := raceInfo(c.Race)
ci, _ := classInfo(c.Class)
var b strings.Builder
b.WriteString(fmt.Sprintf("**%s %s — Ability Scores**\n", ri.Display, ci.Display))
b.WriteString(fmt.Sprintf("STR %2d (%+d) DEX %2d (%+d) CON %2d (%+d)\n",
c.STR, mods[0], c.DEX, mods[1], c.CON, mods[2]))
b.WriteString(fmt.Sprintf("INT %2d (%+d) WIS %2d (%+d) CHA %2d (%+d)\n",
c.INT, mods[3], c.WIS, mods[4], c.CHA, mods[5]))
b.WriteString(fmt.Sprintf("\nProficiency: +%d AC: %d", proficiencyBonus(c.Level), c.ArmorClass))
return p.SendDM(ctx.Sender, b.String())
}
// ── !level ───────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleDnDLevelCmd(ctx MessageContext) 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 character.")
}
ri, _ := raceInfo(c.Race)
ci, _ := classInfo(c.Class)
var b strings.Builder
b.WriteString(fmt.Sprintf("⚔️ Level **%d** %s %s\n", c.Level, ri.Display, ci.Display))
if c.Level >= dndMaxLevel {
b.WriteString("XP: capped at L20.")
} else {
next := dndXPToNextLevel(c.Level)
pct := int(100.0 * float64(c.XP) / float64(next))
b.WriteString(fmt.Sprintf("XP: %d / %d (%d%% to L%d)", c.XP, next, pct, c.Level+1))
}
return p.SendDM(ctx.Sender, b.String())
}