mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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:
@@ -393,6 +393,14 @@ func runMigrations(d *sql.DB) error {
|
||||
// character save, so a monotonic false→true flag can't be clobbered.
|
||||
// DEFAULT 0 correct for every pre-existing row, so no bootstrap.
|
||||
`ALTER TABLE player_meta ADD COLUMN epilogue_cleared INTEGER NOT NULL DEFAULT 0`,
|
||||
// N7/B2 Renown (gogobee_engagement_plan.md §B2). At the L20 cap, overflow
|
||||
// XP that grantDnDXP used to drop instead accumulates here as cumulative
|
||||
// prestige XP. renown_xp is monotonic and written by an atomic += (the
|
||||
// journal_pages pattern), never the bulk character save; renown_level is
|
||||
// DERIVED from it (renown_xp / renownXPPerLevel) rather than stored, so
|
||||
// there is no read-modify-write race and no second field to disagree.
|
||||
// DEFAULT 0 == "no renown", correct for every pre-existing row, no bootstrap.
|
||||
`ALTER TABLE player_meta ADD COLUMN renown_xp INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
for _, stmt := range columnMigrations {
|
||||
if _, err := d.Exec(stmt); err != nil {
|
||||
|
||||
@@ -646,6 +646,7 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holName)
|
||||
@@ -796,6 +797,7 @@ func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
|
||||
ForagingSkill: c.ForagingSkill,
|
||||
FishingSkill: c.FishingSkill,
|
||||
CurrentStreak: c.CurrentStreak,
|
||||
Renown: renownLevelForUser(c.UserID),
|
||||
})
|
||||
}
|
||||
text := renderAdvLeaderboard(entries)
|
||||
|
||||
@@ -118,6 +118,18 @@ type AdventureCharacter struct {
|
||||
// N5/D1c the finale reward-once flag. True after the first finale clear;
|
||||
// overlay-read, written by the atomic markEpilogueClearedDB.
|
||||
EpilogueCleared bool
|
||||
// N7/B2 Renown — cumulative overflow XP earned past the L20 cap. Overlay-read
|
||||
// from player_meta.renown_xp, written by the atomic addRenownXP, never the
|
||||
// bulk character save. RenownLevel() derives the prestige level from it.
|
||||
RenownXP int
|
||||
}
|
||||
|
||||
// RenownLevel is the derived prestige level (renown_xp / renownXPPerLevel).
|
||||
func (c *AdventureCharacter) RenownLevel() int {
|
||||
if c == nil {
|
||||
return 0
|
||||
}
|
||||
return renownLevelFor(c.RenownXP)
|
||||
}
|
||||
|
||||
type AdvEquipment struct {
|
||||
|
||||
@@ -974,6 +974,7 @@ type AdvLeaderboardEntry struct {
|
||||
ForagingSkill int
|
||||
FishingSkill int
|
||||
CurrentStreak int
|
||||
Renown int // N7/B2 — prestige level, cosmetic marker only
|
||||
}
|
||||
|
||||
func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
@@ -987,16 +988,21 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
Score int
|
||||
Levels string
|
||||
Streak int
|
||||
Renown int
|
||||
}
|
||||
var entries []entry
|
||||
for _, c := range chars {
|
||||
// Renown adds to the ranking score so a capped, prestigious player still
|
||||
// climbs — it's the only progression they have left past L20.
|
||||
score := (c.Level + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
|
||||
score += c.Renown * 10
|
||||
name, _ := loadDisplayName(c.UserID)
|
||||
entries = append(entries, entry{
|
||||
Name: name,
|
||||
Score: score,
|
||||
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.Level, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
|
||||
Streak: c.CurrentStreak,
|
||||
Renown: c.Renown,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1028,7 +1034,11 @@ func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string {
|
||||
if e.Streak >= 7 {
|
||||
streak = fmt.Sprintf(" 🔥%d", e.Streak)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s **%s** — %s (score: %d%s)\n", prefix, e.Name, e.Levels, e.Score, streak))
|
||||
renownBadge := ""
|
||||
if m := renownMarker(e.Renown); m != "" {
|
||||
renownBadge = " " + m
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s **%s**%s — %s (score: %d%s)\n", prefix, e.Name, renownBadge, e.Levels, e.Score, streak))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
|
||||
249
internal/plugin/adventure_renown.go
Normal file
249
internal/plugin/adventure_renown.go
Normal 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)
|
||||
}
|
||||
212
internal/plugin/adventure_renown_test.go
Normal file
212
internal/plugin/adventure_renown_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gogobee/internal/db"
|
||||
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
func newRenownTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db.Close()
|
||||
if err := db.Init(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(db.Close)
|
||||
}
|
||||
|
||||
func TestRenownLevelFor(t *testing.T) {
|
||||
cases := []struct{ xp, want int }{
|
||||
{0, 0},
|
||||
{-100, 0},
|
||||
{renownXPPerLevel - 1, 0},
|
||||
{renownXPPerLevel, 1},
|
||||
{renownXPPerLevel*3 + 12, 3},
|
||||
{renownXPPerLevel * 30, 30},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := renownLevelFor(c.xp); got != c.want {
|
||||
t.Errorf("renownLevelFor(%d) = %d, want %d", c.xp, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownXPIntoLevel(t *testing.T) {
|
||||
into, cost := renownXPIntoLevel(renownXPPerLevel + 500)
|
||||
if cost != renownXPPerLevel {
|
||||
t.Errorf("cost = %d, want %d", cost, renownXPPerLevel)
|
||||
}
|
||||
if into != 500 {
|
||||
t.Errorf("into = %d, want 500", into)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownRankLadderMonotonic(t *testing.T) {
|
||||
// Rank thresholds must strictly increase so renownRankFor is well-defined.
|
||||
for i := 1; i < len(renownRanks); i++ {
|
||||
if renownRanks[i].MinLevel <= renownRanks[i-1].MinLevel {
|
||||
t.Errorf("rank %d threshold %d not > %d", i, renownRanks[i].MinLevel, renownRanks[i-1].MinLevel)
|
||||
}
|
||||
}
|
||||
if renownRankFor(0) != "" {
|
||||
t.Errorf("rank at 0 should be empty")
|
||||
}
|
||||
if renownRankFor(1) != "Renowned" {
|
||||
t.Errorf("rank at 1 = %q, want Renowned", renownRankFor(1))
|
||||
}
|
||||
if renownRankFor(4) != "Storied" {
|
||||
t.Errorf("rank at 4 = %q, want Storied (threshold 3)", renownRankFor(4))
|
||||
}
|
||||
if renownRankFor(1000) != "Eternal" {
|
||||
t.Errorf("top rank = %q, want Eternal", renownRankFor(1000))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenownMarker(t *testing.T) {
|
||||
if renownMarker(0) != "" {
|
||||
t.Errorf("marker at 0 should be empty")
|
||||
}
|
||||
if got := renownMarker(7); got != "✦7" {
|
||||
t.Errorf("marker(7) = %q, want ✦7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyRenownBonuses_CombatNeutralAndCapped — renown grants only the two
|
||||
// combat-neutral economy levers (loot/XP), capped at +15%/+20%, and must never
|
||||
// touch a lever that combat_stats.go maps to a combat stat. §B2.
|
||||
func TestApplyRenownBonuses_CombatNeutralAndCapped(t *testing.T) {
|
||||
// Below the first step: no effect.
|
||||
b := &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 2)
|
||||
if b.XPMultiplier != 0 || b.LootQuality != 0 {
|
||||
t.Errorf("renown < step should be inert, got %+v", b)
|
||||
}
|
||||
|
||||
// One step at renown 3.
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 3)
|
||||
if b.XPMultiplier != 2 || b.LootQuality != 1.5 {
|
||||
t.Errorf("one step wrong: %+v", b)
|
||||
}
|
||||
|
||||
// At renown 30 and beyond, capped at +20% XP / +15% loot.
|
||||
for _, lvl := range []int{30, 45, 300} {
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, lvl)
|
||||
if b.XPMultiplier != 20 || b.LootQuality != 15 {
|
||||
t.Errorf("renown %d not capped: %+v", lvl, b)
|
||||
}
|
||||
}
|
||||
|
||||
// Never combat-stat inflation: the levers combat_stats.go reads
|
||||
// (DeathModifier→Defense, SuccessBonus→Attack, ExceptionalBonus→CritRate)
|
||||
// and the skill/combat levers must all stay at zero at any renown level.
|
||||
b = &AdvBonusSummary{}
|
||||
applyRenownBonuses(b, 300)
|
||||
if b.DeathModifier != 0 || b.SuccessBonus != 0 || b.ExceptionalBonus != 0 {
|
||||
t.Errorf("renown leaked into a combat-stat lever: %+v", b)
|
||||
}
|
||||
if b.CombatBonus != 0 || b.MiningBonus != 0 || b.ForagingBonus != 0 || b.FishingBonus != 0 {
|
||||
t.Errorf("renown leaked into combat/skill levers: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddRenownXP_Accumulates — cumulative += with correct before/after.
|
||||
func TestAddRenownXP_Accumulates(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_add:example")
|
||||
if err := createAdvCharacter(uid, "Renowner"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
before, after, err := addRenownXP(uid, 10000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != 0 || after != 10000 {
|
||||
t.Errorf("first add: before=%d after=%d, want 0/10000", before, after)
|
||||
}
|
||||
|
||||
before, after, err = addRenownXP(uid, 20000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if before != 10000 || after != 30000 {
|
||||
t.Errorf("second add: before=%d after=%d, want 10000/30000", before, after)
|
||||
}
|
||||
|
||||
got, err := loadRenownXP(uid)
|
||||
if err != nil || got != 30000 {
|
||||
t.Errorf("loadRenownXP = %d (err %v), want 30000", got, err)
|
||||
}
|
||||
if renownLevelForUser(uid) != 1 {
|
||||
t.Errorf("renownLevelForUser = %d, want 1", renownLevelForUser(uid))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenownOverlay — applyPlayerMetaOverlay populates RenownXP and RenownLevel.
|
||||
func TestRenownOverlay(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_overlay:example")
|
||||
if err := createAdvCharacter(uid, "Overlaid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := addRenownXP(uid, renownXPPerLevel*5+7); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := loadAdvCharacter(uid)
|
||||
if err != nil || c == nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if c.RenownXP != renownXPPerLevel*5+7 {
|
||||
t.Errorf("overlay RenownXP = %d, want %d", c.RenownXP, renownXPPerLevel*5+7)
|
||||
}
|
||||
if c.RenownLevel() != 5 {
|
||||
t.Errorf("RenownLevel() = %d, want 5", c.RenownLevel())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantDnDXP_OverflowBecomesRenown — at the cap, grantDnDXP routes overflow
|
||||
// into renown_xp and zeroes dnd_xp (the pre-N7 cap invariant is preserved).
|
||||
func TestGrantDnDXP_OverflowBecomesRenown(t *testing.T) {
|
||||
newRenownTestDB(t)
|
||||
uid := id.UserID("@renown_grant:example")
|
||||
if err := createAdvCharacter(uid, "Capped"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := &DnDCharacter{
|
||||
UserID: uid, Race: RaceHuman, Class: ClassFighter, Level: dndMaxLevel,
|
||||
STR: 16, DEX: 13, CON: 14, INT: 8, WIS: 10, CHA: 12, ArmorClass: 16,
|
||||
}
|
||||
c.HPMax = 100
|
||||
c.HPCurrent = 100
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
p := &AdventurePlugin{}
|
||||
events, err := p.grantDnDXP(uid, renownXPPerLevel+5000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 0 {
|
||||
t.Errorf("got %d level-up events at cap, want 0", len(events))
|
||||
}
|
||||
|
||||
got, _ := LoadDnDCharacter(uid)
|
||||
if got.Level != dndMaxLevel {
|
||||
t.Errorf("level = %d, want %d", got.Level, dndMaxLevel)
|
||||
}
|
||||
if got.XP != 0 {
|
||||
t.Errorf("dnd_xp = %d, want 0 (overflow moved to renown)", got.XP)
|
||||
}
|
||||
if xp, _ := loadRenownXP(uid); xp != renownXPPerLevel+5000 {
|
||||
t.Errorf("renown_xp = %d, want %d", xp, renownXPPerLevel+5000)
|
||||
}
|
||||
if renownLevelForUser(uid) != 1 {
|
||||
t.Errorf("renown level = %d, want 1", renownLevelForUser(uid))
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
||||
treasures, _ := loadAdvTreasureBonuses(char.UserID)
|
||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||
applyRenownBonuses(bonuses, char.RenownLevel()) // N7/B2 — activity path only, never combat
|
||||
balance := p.euro.GetBalance(char.UserID)
|
||||
|
||||
holidayLabel := ""
|
||||
|
||||
@@ -171,7 +171,12 @@ func (p *AdventurePlugin) loadCombatBonuses(userID id.UserID, char *AdventureCha
|
||||
treasures, _ := loadAdvTreasureBonuses(userID)
|
||||
buffs, _ := loadAdvActiveBuffs(userID)
|
||||
hasGrudge := char.GrudgeLocation != ""
|
||||
return computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||
b := computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
|
||||
// N7/B2 — renown pays out on loot/XP here too. Safe despite this feeding
|
||||
// combat_stats: applyRenownBonuses touches only LootQuality/XPMultiplier,
|
||||
// which combat stat derivation never reads (see adventure_renown.go).
|
||||
applyRenownBonuses(b, char.RenownLevel())
|
||||
return b
|
||||
}
|
||||
|
||||
// loadConsumableInventory scans inventory for items matching consumable definitions.
|
||||
|
||||
@@ -332,8 +332,22 @@ func LoadDnDCharacter(userID id.UserID) (*DnDCharacter, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// sqlExecer is the subset of *sql.DB / *sql.Tx that saveDnDCharacterExec needs,
|
||||
// so the upsert can run either standalone or inside a caller's transaction.
|
||||
type sqlExecer interface {
|
||||
Exec(query string, args ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
// SaveDnDCharacter upserts the row.
|
||||
func SaveDnDCharacter(c *DnDCharacter) error {
|
||||
return saveDnDCharacterExec(db.Get(), c)
|
||||
}
|
||||
|
||||
// saveDnDCharacterExec runs the dnd_character upsert against any executor. Used
|
||||
// directly by SaveDnDCharacter and inside grantDnDXP's overflow→renown
|
||||
// transaction (adventure_renown.go), so the character save and the renown
|
||||
// credit commit atomically.
|
||||
func saveDnDCharacterExec(ex sqlExecer, c *DnDCharacter) error {
|
||||
pending := 0
|
||||
if c.PendingSetup {
|
||||
pending = 1
|
||||
@@ -346,7 +360,7 @@ func SaveDnDCharacter(c *DnDCharacter) error {
|
||||
if c.OnboardingSent {
|
||||
onboard = 1
|
||||
}
|
||||
_, err := db.Get().Exec(`
|
||||
_, err := ex.Exec(`
|
||||
INSERT INTO dnd_character (user_id, race, class, dnd_level, dnd_xp,
|
||||
str_score, dex_score, con_score, int_score, wis_score, cha_score,
|
||||
hp_current, hp_max, temp_hp, armor_class,
|
||||
|
||||
@@ -161,6 +161,16 @@ func (p *AdventurePlugin) handleDnDLevelCmd(ctx MessageContext) error {
|
||||
b.WriteString(fmt.Sprintf("⚔️ Level **%d** %s %s\n", c.Level, ri.Display, ci.Display))
|
||||
if c.Level >= dndMaxLevel {
|
||||
b.WriteString("XP: capped at L20.")
|
||||
if rl := advChar.RenownLevel(); rl > 0 {
|
||||
into, cost := renownXPIntoLevel(advChar.RenownXP)
|
||||
b.WriteString(fmt.Sprintf("\n✦ **Renown %d**", rl))
|
||||
if rank := renownRankFor(rl); rank != "" {
|
||||
b.WriteString(fmt.Sprintf(" — _%s_", rank))
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("\nRenown XP: %d / %d to Renown %d", into, cost, rl+1))
|
||||
} else {
|
||||
b.WriteString("\n_Overflow XP now earns **Renown** — prestige past the cap._")
|
||||
}
|
||||
} else {
|
||||
next := dndXPToNextLevel(c.Level)
|
||||
pct := int(100.0 * float64(c.XP) / float64(next))
|
||||
|
||||
@@ -79,7 +79,20 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
|
||||
if adv != nil && adv.DisplayName != "" {
|
||||
name = adv.DisplayName
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", name, c.Level, ri.Display, ci.Display))
|
||||
renownLevel := 0
|
||||
if adv != nil {
|
||||
renownLevel = adv.RenownLevel()
|
||||
}
|
||||
nameLine := name
|
||||
if m := renownMarker(renownLevel); m != "" {
|
||||
nameLine = fmt.Sprintf("%s %s", name, m)
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("⚔️ **%s** — Level %d %s %s\n", nameLine, c.Level, ri.Display, ci.Display))
|
||||
if renownLevel > 0 {
|
||||
if rank := renownRankFor(renownLevel); rank != "" {
|
||||
b.WriteString(fmt.Sprintf(" _Renown %d — %s_\n", renownLevel, rank))
|
||||
}
|
||||
}
|
||||
if c.Subclass != "" {
|
||||
if si, ok := subclassInfo(c.Subclass); ok {
|
||||
b.WriteString(fmt.Sprintf(" _%s_\n", si.Display))
|
||||
|
||||
@@ -127,15 +127,26 @@ func (p *AdventurePlugin) grantDnDXP(userID id.UserID, amount int) ([]LevelUpEve
|
||||
events = append(events, LevelUpEvent{NewLevel: c.Level, HPGain: gain})
|
||||
}
|
||||
|
||||
// Cap at L20 — overflow XP is silently dropped.
|
||||
if c.Level >= dndMaxLevel {
|
||||
// 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
|
||||
}
|
||||
|
||||
if err := SaveDnDCharacter(c); err != nil {
|
||||
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
|
||||
|
||||
@@ -1349,6 +1349,9 @@ func applyPlayerMetaOverlay(c *AdventureCharacter) {
|
||||
if cleared, err := loadEpilogueCleared(uid); err == nil {
|
||||
c.EpilogueCleared = cleared
|
||||
}
|
||||
if xp, err := loadRenownXP(uid); err == nil {
|
||||
c.RenownXP = xp
|
||||
}
|
||||
if s, err := loadHouseState(uid); err == nil {
|
||||
c.HouseTier = s.Tier
|
||||
c.HouseLoanBalance = s.LoanBalance
|
||||
|
||||
Reference in New Issue
Block a user