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