Files
gogobee/internal/plugin/adventure_renown.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

250 lines
8.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}