Files
gogobee/internal/plugin/dnd_xp.go
prosolis 9e1a1f606c 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>
2026-05-09 14:25:21 -07:00

233 lines
6.9 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"strings"
"maunium.net/go/mautrix/id"
)
// Phase 3 — XP, level-up, and progression.
//
// XP curve anchors come from the design doc (v1.0 §4.1). Intermediate levels
// are interpolated to give a smooth ramp. Cumulative XP — the value stored
// in dnd_character.dnd_xp resets to 0 on level-up (carrying over surplus).
//
// Two design choices worth flagging:
// 1. XP is *segment-based*. dnd_xp tracks XP toward the next level, not
// total XP earned across all levels. This keeps the math simple and
// means a player who migrated in at L8 starts with 0/[L9-cost], no
// adjustment needed for the levels they "didn't earn."
// 2. Combat XP only — no XP from gathering activities. Mining/foraging/
// fishing keep their own legacy skill XP tracks.
// dndXPTable[L] = cumulative XP needed to reach level L from level 1.
// L=0 is unused (D&D characters start at L1).
var dndXPTable = [...]int{
0, // L1 (start)
300, // L2
900, // L3
1700, // L4
2700, // L5 (anchor: doc)
4300, // L6
6500, // L7 (anchor: doc)
9000, // L8
11500, // L9
14000, // L10 (anchor: doc)
18000, // L11
22000, // L12
26000, // L13
30000, // L14
34000, // L15 (anchor: doc)
44000, // L16
54000, // L17
64000, // L18
74000, // L19
85000, // L20 (anchor: doc)
}
const dndMaxLevel = 20
// dndXPToNextLevel returns the segment cost to advance from currentLevel to
// currentLevel+1. Returns 0 at L20 (already capped).
func dndXPToNextLevel(currentLevel int) int {
if currentLevel < 1 {
currentLevel = 1
}
if currentLevel >= dndMaxLevel {
return 0
}
return dndXPTable[currentLevel] - dndXPTable[currentLevel-1]
}
// LevelUpEvent describes one level threshold crossed. A single grantDnDXP
// call can produce multiple events (cascading level-ups).
type LevelUpEvent struct {
NewLevel int
HPGain int // hp_max delta
}
// grantDnDXP adds XP to the player's D&D character and processes any
// level-ups that result. Returns the level-up events (empty if none).
//
// Side effects:
// - dnd_xp incremented (with carry-over on level-up)
// - dnd_level incremented for each level threshold crossed
// - hp_max recomputed and hp_current bumped by the gain
// - DM sent for each level-up
// - dnd_character row persisted
//
// No-op if the player has no D&D character (auto-migration normally
// guarantees one before any combat, so this is paranoia).
func (p *AdventurePlugin) grantDnDXP(userID id.UserID, amount int) ([]LevelUpEvent, error) {
if amount <= 0 {
return nil, nil
}
c, err := LoadDnDCharacter(userID)
if err != nil {
return nil, err
}
if c == nil || c.PendingSetup {
return nil, nil
}
c.XP += amount
var events []LevelUpEvent
for c.Level < dndMaxLevel {
cost := dndXPToNextLevel(c.Level)
if c.XP < cost {
break
}
c.XP -= cost
// Recompute HP max for the new level. HPCurrent gains the same delta.
conMod := abilityModifier(c.CON)
oldMax := c.HPMax
c.Level++
c.HPMax = computeMaxHP(c.Class, conMod, c.Level)
gain := c.HPMax - oldMax
if gain < 1 {
gain = 1 // floor — even with very negative CON, give at least 1
c.HPMax = oldMax + 1
}
c.HPCurrent += gain
if c.HPCurrent > c.HPMax {
c.HPCurrent = c.HPMax
}
// AC may change too if DEX-derived (unlikely without item changes).
c.ArmorClass = computeAC(c.Class, abilityModifier(c.DEX))
events = append(events, LevelUpEvent{NewLevel: c.Level, HPGain: gain})
}
// Cap at L20 — overflow XP is silently dropped.
if c.Level >= dndMaxLevel {
c.XP = 0
}
if err := SaveDnDCharacter(c); err != nil {
return events, err
}
if len(events) > 0 {
p.sendLevelUpDM(userID, c, events, amount)
}
return events, nil
}
func (p *AdventurePlugin) sendLevelUpDM(userID id.UserID, c *DnDCharacter, events []LevelUpEvent, xpThisGrant int) {
if p == nil || p.Client == nil {
return // tests construct AdventurePlugin{} without a Matrix client
}
ri, _ := raceInfo(c.Race)
ci, _ := classInfo(c.Class)
var b strings.Builder
b.WriteString("✨ **LEVEL UP** ✨\n\n")
if line := dndLevelUpFlavorLine(); line != "" {
b.WriteString("_" + line + "_\n\n")
}
if len(events) == 1 {
ev := events[0]
b.WriteString(fmt.Sprintf("You are now a **Level %d %s %s**.\n", ev.NewLevel, ri.Display, ci.Display))
b.WriteString(fmt.Sprintf(" HP: %d → %d (+%d)\n", c.HPMax-ev.HPGain, c.HPMax, ev.HPGain))
} else {
b.WriteString(fmt.Sprintf("You crossed **%d** levels in one go!\n", len(events)))
for _, ev := range events {
b.WriteString(fmt.Sprintf(" → Level %d (+%d HP)\n", ev.NewLevel, ev.HPGain))
}
b.WriteString(fmt.Sprintf("\nFinal: **Level %d %s %s** with %d HP.\n",
c.Level, ri.Display, ci.Display, c.HPMax))
}
if c.Level >= dndMaxLevel {
b.WriteString("\n_You've reached the level cap._")
} else {
next := dndXPToNextLevel(c.Level)
b.WriteString(fmt.Sprintf("\nNext level: %d / %d XP.\n", c.XP, next))
}
// Subclass selection cue: design doc specs a prompt at L5. Mechanics
// arrive in a future phase; for now the level-up DM mentions it so the
// player isn't surprised when it lands.
for _, ev := range events {
if ev.NewLevel == 5 {
b.WriteString("\n🎯 _Subclass selection unlocks at L5 — coming in a future update._")
break
}
}
if err := p.SendDM(userID, b.String()); err != nil {
slog.Error("dnd: level-up DM failed", "user", userID, "err", err)
}
}
// ── XP grant amounts ─────────────────────────────────────────────────────────
// Combat XP formulas. Tuned so a L1 player needs ~3 wins per level early
// and many more at high levels.
const (
dndArenaXPPerThreat = 12 // arena win XP = threat * this
dndArenaXPMin = 30 // arena win XP floor (low-threat fights still pay)
dndDungeonXPPerTier = 60 // dungeon win XP = tier^1.4 * this, roughly
dndLossXPFraction = 0.25
dndNearDeathXPBonus = 1.25 // multiplier for clutch wins
)
// arenaCombatXP returns the D&D XP to grant for an arena combat outcome.
func arenaCombatXP(result CombatResult, threatLevel int) int {
base := dndArenaXPPerThreat * threatLevel
if base < dndArenaXPMin {
base = dndArenaXPMin
}
if result.PlayerWon {
if result.NearDeath {
return int(float64(base) * dndNearDeathXPBonus)
}
return base
}
return int(float64(base) * dndLossXPFraction)
}
// dungeonCombatXP returns the D&D XP to grant for a dungeon combat outcome.
// Quadratic-ish scaling so high-tier dungeons feel meaningfully more rewarding.
func dungeonCombatXP(result CombatResult, tier int) int {
if tier < 1 {
tier = 1
}
base := dndDungeonXPPerTier * tier * tier / 2 // T1=30, T3=270, T5=750
if base < dndArenaXPMin {
base = dndArenaXPMin
}
if result.PlayerWon {
if result.NearDeath {
return int(float64(base) * dndNearDeathXPBonus)
}
return base
}
return int(float64(base) * dndLossXPFraction)
}