mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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
277 lines
8.7 KiB
Go
277 lines
8.7 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
|
|
}
|
|
// Level-up grants one extra hit-dice charge (matches max=Level cap).
|
|
c.ShortRestCharges++
|
|
if c.ShortRestCharges > c.Level {
|
|
c.ShortRestCharges = c.Level
|
|
}
|
|
// 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})
|
|
}
|
|
|
|
// N7/B2 Renown — persist the character; at the cap, overflow XP that used to
|
|
// be dropped converts to Renown in the SAME transaction as the save, so a
|
|
// crash can neither lose the overflow nor double-credit it on the next grant.
|
|
// Renown is title + cosmetic + capped loot/XP perks, never combat stats, so
|
|
// the balance corpus stays valid.
|
|
var renownFrom, renownTo int
|
|
if c.Level >= dndMaxLevel && c.XP > 0 {
|
|
overflow := c.XP
|
|
c.XP = 0
|
|
from, to, err := saveDnDCharacterWithOverflow(c, overflow)
|
|
if err != nil {
|
|
return events, err
|
|
}
|
|
renownFrom, renownTo = renownLevelFor(from), renownLevelFor(to)
|
|
} else if err := SaveDnDCharacter(c); err != nil {
|
|
return events, err
|
|
}
|
|
|
|
p.announceRenown(userID, renownFrom, renownTo)
|
|
|
|
if len(events) > 0 {
|
|
// Phase 10 SUB3a-ii — Battle Master Relentless (L15) raises the
|
|
// superiority cap from 4 → 5; reconcile any level-gated subclass pool
|
|
// growth here. Idempotent and non-fatal: a stale pool just means the
|
|
// player reaches their full cap on the next long rest at the latest.
|
|
if c.Subclass != "" {
|
|
_ = initSubclassResources(userID, c.Subclass, c.Level)
|
|
}
|
|
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
|
|
}
|
|
if err := p.SendDM(userID, buildLevelUpMessage(c, events, dndLevelUpFlavorLine())); err != nil {
|
|
slog.Error("dnd: level-up DM failed", "user", userID, "err", err)
|
|
}
|
|
}
|
|
|
|
// buildLevelUpMessage formats the level-up DM. flavor is the optional flavor
|
|
// line (pass "" to skip). Pure function — exposed for tests.
|
|
func buildLevelUpMessage(c *DnDCharacter, events []LevelUpEvent, flavor string) string {
|
|
ri, _ := raceInfo(c.Race)
|
|
ci, _ := classInfo(c.Class)
|
|
var b strings.Builder
|
|
b.WriteString("✨ **LEVEL UP** ✨\n\n")
|
|
if flavor != "" {
|
|
b.WriteString("_" + flavor + "_\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))
|
|
}
|
|
|
|
// Mage spellbook budget: every level-up grants +2 leveled spells. Surface
|
|
// the headroom so the player knows to run `!spells learn`.
|
|
if c.Class == ClassMage {
|
|
count, _ := mageLeveledKnownCount(c.UserID)
|
|
cap := mageKnownSpellsCap(c.Level)
|
|
if avail := cap - count; avail > 0 {
|
|
b.WriteString(fmt.Sprintf("\n📖 _Spells available to learn: **%d** (run `!spells learn <name>`)._\n",
|
|
avail))
|
|
}
|
|
}
|
|
|
|
// Phase 10 — subclass selection cue. Fires when the player crosses L5
|
|
// without already having a subclass (rare path: a multi-level grant
|
|
// that crosses L5 keeps prompting until they pick). Existing-subclass
|
|
// cases stay quiet on later level-ups.
|
|
if c.Subclass == "" && c.Level >= dndSubclassMinLevel {
|
|
for _, ev := range events {
|
|
if ev.NewLevel >= dndSubclassMinLevel {
|
|
b.WriteString("\n🎯 ")
|
|
b.WriteString(renderSubclassPrompt(c.Class))
|
|
b.WriteString("\n")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// ── 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)
|
|
}
|