Files
gogobee/internal/plugin/dnd_misc_cmds.go
prosolis 38a3693832 N7/B2: Renown — prestige past the L20 cap
Overflow XP that grantDnDXP used to drop at L20 now accumulates as Renown
on player_meta.renown_xp (cumulative, atomic +=; renown_level derived as
renown_xp/25000). The reward is prestige-only: a derived rank ladder
(Renowned→…→Eternal), a cosmetic ✦N marker on the sheet and leaderboard,
a games-room shout on rank promotion, and !level progress once capped.

Renown perks are the two combat-neutral economy levers only — +loot / +XP,
capped at a streak-30 grant's economic half (+15% / +20%). combat_stats.go
reads DeathModifier/SuccessBonus/ExceptionalBonus (which map to Defense/
Attack/CritRate) but never LootQuality/XPMultiplier, so renown pays out even
through loadCombatBonuses without moving the golden or the balance corpus.
The plan's "-death penalty" perk is deliberately dropped (it would inflate
Defense).

The overflow→renown conversion and the character save commit in one
transaction (saveDnDCharacterExec now takes an executor), so a crash can
neither drop the overflow nor double-credit it on the next grant.

Schema: renown_xp column, DEFAULT 0 correct for every existing row, no
bootstrap (journal_pages/epilogue_cleared pattern).

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 19:48:49 -07:00

181 lines
5.4 KiB
Go

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.")
if rl := advChar.RenownLevel(); rl > 0 {
into, cost := renownXPIntoLevel(advChar.RenownXP)
b.WriteString(fmt.Sprintf("\n✦ **Renown %d**", rl))
if rank := renownRankFor(rl); rank != "" {
b.WriteString(fmt.Sprintf(" — _%s_", rank))
}
b.WriteString(fmt.Sprintf("\nRenown XP: %d / %d to Renown %d", into, cost, rl+1))
} else {
b.WriteString("\n_Overflow XP now earns **Renown** — prestige past the cap._")
}
} 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())
}