mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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>
233 lines
6.9 KiB
Go
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)
|
|
}
|