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
This commit is contained in:
prosolis
2026-07-10 19:48:49 -07:00
parent 401b17c3bb
commit 38a3693832
13 changed files with 559 additions and 9 deletions

View File

@@ -0,0 +1,249 @@
package plugin
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"maunium.net/go/mautrix/id"
"gogobee/internal/db"
)
// N7/B2 — Renown: prestige past the level cap (gogobee_engagement_plan.md §B2).
//
// A confirmed D&D character caps at L20 (dndMaxLevel). Before N7, grantDnDXP
// silently dropped any XP earned past the cap. Renown reclaims that overflow as
// a prestige track: every renownXPPerLevel of overflow is one Renown level.
//
// Storage is a single cumulative column, player_meta.renown_xp, written by an
// atomic INSERT…ON CONFLICT … += (the journal_pages pattern) so there is no
// read-modify-write race. renown_level is DERIVED (renownLevelFor) rather than
// stored, so nothing can disagree with the XP total.
//
// The reward is deliberately prestige-only — a derived rank title, a cosmetic
// marker on the sheet/leaderboard, and a small capped bundle of *activity*
// bonuses (loot quality, XP, death avoidance). It NEVER grants combat stats:
// the balance corpus (SimulateCombat / the golden) must stay valid, so renown
// touches only the AdvBonusSummary activity levers and only at the activity
// call sites, never loadCombatBonuses.
// renownXPPerLevel is the overflow XP that buys one Renown level. Steep by
// design (§B2): a capped L20 player earns ~750 XP per T5 dungeon win, so a
// Renown level is dozens of endgame clears.
const renownXPPerLevel = 25000
// Renown perk ladder. One small step every renownPerkStepLevels Renown levels,
// capped at renownPerkMaxSteps steps. At the cap the perks total +20% XP / +15%
// loot, matching a streak-30 grant's economic half — §B2's ceiling in total
// power.
//
// The perks are deliberately ONLY the two combat-neutral levers of the
// AdvBonusSummary: combat_stats.go maps DeathModifier→Defense,
// SuccessBonus→Attack and ExceptionalBonus→CritRate, so those *are* combat
// stats and are off-limits (§B2: never combat-stat inflation, the balance
// corpus must stay valid). LootQuality and XPMultiplier are read only by the
// loot/XP economy, never by combat stat derivation — so renown can pay them out
// even through loadCombatBonuses without moving the golden. That is why §B2's
// suggested "death penalty" perk is intentionally NOT granted: it would inflate
// Defense.
const (
renownPerkStepLevels = 3
renownPerkMaxSteps = 10
)
// renownLevelFor derives the Renown level from cumulative overflow XP.
func renownLevelFor(renownXP int) int {
if renownXP <= 0 {
return 0
}
return renownXP / renownXPPerLevel
}
// renownXPIntoLevel returns (progress, cost) toward the next Renown level, for
// display. cost is always renownXPPerLevel.
func renownXPIntoLevel(renownXP int) (int, int) {
if renownXP < 0 {
renownXP = 0
}
return renownXP % renownXPPerLevel, renownXPPerLevel
}
// renownRank is one rung of the derived title ladder: the first Renown level at
// which the rank applies, and its name. A rank promotion (crossing into a new
// rung) is what gets announced in the games room; plain level-ups only DM.
type renownRank struct {
MinLevel int
Name string
}
var renownRanks = []renownRank{
{1, "Renowned"},
{3, "Storied"},
{5, "Illustrious"},
{10, "Fabled"},
{15, "Mythic"},
{20, "Ascendant"},
{30, "Eternal"},
}
// renownRankFor returns the rank name for a Renown level, or "" below level 1.
func renownRankFor(level int) string {
name := ""
for _, r := range renownRanks {
if level >= r.MinLevel {
name = r.Name
}
}
return name
}
// renownMarker is the cosmetic prestige badge shown next to a name on the sheet
// and leaderboard. Empty below Renown 1.
func renownMarker(level int) string {
if level < 1 {
return ""
}
return fmt.Sprintf("✦%d", level)
}
// applyRenownBonuses folds the capped renown perks into an AdvBonusSummary. It
// touches ONLY LootQuality and XPMultiplier — the combat-neutral economy levers
// — so it is safe to call anywhere the summary is built, including
// loadCombatBonuses, without changing any combat stat (see the const block).
func applyRenownBonuses(b *AdvBonusSummary, renownLevel int) {
if b == nil || renownLevel < renownPerkStepLevels {
return
}
steps := renownLevel / renownPerkStepLevels
if steps > renownPerkMaxSteps {
steps = renownPerkMaxSteps
}
b.XPMultiplier += float64(steps) * 2 // → +20% at the cap
b.LootQuality += float64(steps) * 1.5 // → +15% at the cap
}
// ── Persistence ──────────────────────────────────────────────────────────────
// renownAccrueSQL is the atomic cumulative += on player_meta.renown_xp (the
// journal_pages pattern), returning the post-update total. Shared by the
// standalone and transactional accrual paths so the SQL lives in one place.
const renownAccrueSQL = `INSERT INTO player_meta (user_id, renown_xp) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET renown_xp = renown_xp + excluded.renown_xp
RETURNING renown_xp`
// sqlQueryer is the subset of *sql.DB / *sql.Tx that accrueRenownXP needs, so
// the accrual can run standalone or inside grantDnDXP's save transaction.
type sqlQueryer interface {
QueryRow(query string, args ...any) *sql.Row
}
// accrueRenownXP adds delta (> 0) to renown_xp against any queryer and returns
// the cumulative totals before and after. A single statement, safe against
// concurrent grants — no lost update.
func accrueRenownXP(q sqlQueryer, userID id.UserID, delta int) (before, after int, err error) {
if err = q.QueryRow(renownAccrueSQL, string(userID), delta).Scan(&after); err != nil {
return 0, 0, fmt.Errorf("accrue renown_xp: %w", err)
}
return after - delta, after, nil
}
// addRenownXP is the standalone accrual (its own DB write). delta <= 0 is a
// no-op read. grantDnDXP uses the transactional saveDnDCharacterWithOverflow
// instead, so this is the API for callers not already saving a character.
func addRenownXP(userID id.UserID, delta int) (before, after int, err error) {
if delta <= 0 {
cur, e := loadRenownXP(userID)
return cur, cur, e
}
return accrueRenownXP(db.Get(), userID, delta)
}
// saveDnDCharacterWithOverflow persists the character and converts overflow XP
// (> 0) to Renown in one transaction, so a crash can neither drop the overflow
// nor double-credit it. Returns the cumulative renown totals before/after.
func saveDnDCharacterWithOverflow(c *DnDCharacter, overflow int) (before, after int, err error) {
tx, err := db.Get().Begin()
if err != nil {
return 0, 0, err
}
defer tx.Rollback()
if err = saveDnDCharacterExec(tx, c); err != nil {
return 0, 0, err
}
if before, after, err = accrueRenownXP(tx, c.UserID, overflow); err != nil {
return 0, 0, err
}
if err = tx.Commit(); err != nil {
return 0, 0, err
}
return before, after, nil
}
// loadRenownXP reads the cumulative overflow XP. Absent row == 0.
func loadRenownXP(userID id.UserID) (int, error) {
var xp int
err := db.Get().QueryRow(
`SELECT renown_xp FROM player_meta WHERE user_id = ?`,
string(userID),
).Scan(&xp)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return xp, err
}
// renownLevelForUser is the per-user Renown level, for callers (leaderboard)
// that don't already hold the overlay. Errors resolve to 0.
func renownLevelForUser(userID id.UserID) int {
xp, err := loadRenownXP(userID)
if err != nil {
return 0
}
return renownLevelFor(xp)
}
// ── Announce ─────────────────────────────────────────────────────────────────
// announceRenown notifies the player of Renown level-ups (DM) and, when the
// gain crossed into a new rank rung, the games room. Event-driven off a grant,
// not a scheduled recap. from/to are Renown levels before/after the grant.
func (p *AdventurePlugin) announceRenown(userID id.UserID, from, to int) {
if p == nil || to <= from {
return
}
gained := to - from
rank := renownRankFor(to)
if p.Client != nil {
msg := fmt.Sprintf("✦ **Renown %d** ✦\n\nYou've earned %s past the level cap — your legend grows.",
to, pluralLevels(gained))
if rank != "" {
msg += fmt.Sprintf("\nRank: **%s**.", rank)
}
if err := p.SendDM(userID, msg); err != nil {
slog.Error("renown: level-up DM failed", "user", userID, "err", err)
}
}
// Games-room shout only on a rank promotion, to keep the room quiet.
if renownRankFor(from) == rank {
return
}
gr := gamesRoom()
if gr == "" {
return
}
name, _ := loadDisplayName(userID)
if name == "" {
name = string(userID)
}
p.SendMessage(id.RoomID(gr), fmt.Sprintf(
"✦ **%s** reached **Renown %d — %s.** A legend of the realm.", name, to, rank))
}
func pluralLevels(n int) string {
if n == 1 {
return "a Renown level"
}
return fmt.Sprintf("%d Renown levels", n)
}