diff --git a/internal/db/db.go b/internal/db/db.go index f7be0a7..1e376d9 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -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 { diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 3541582..990cf41 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -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) diff --git a/internal/plugin/adventure_character.go b/internal/plugin/adventure_character.go index 8ed4d2c..f95d8c6 100644 --- a/internal/plugin/adventure_character.go +++ b/internal/plugin/adventure_character.go @@ -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 { diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index 669e4a5..ed1d11c 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -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() diff --git a/internal/plugin/adventure_renown.go b/internal/plugin/adventure_renown.go new file mode 100644 index 0000000..3377583 --- /dev/null +++ b/internal/plugin/adventure_renown.go @@ -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) +} diff --git a/internal/plugin/adventure_renown_test.go b/internal/plugin/adventure_renown_test.go new file mode 100644 index 0000000..bc9f05a --- /dev/null +++ b/internal/plugin/adventure_renown_test.go @@ -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)) + } +} diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 7f753a7..c787cad 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -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 := "" diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index cd6b37b..c97376d 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -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. diff --git a/internal/plugin/dnd.go b/internal/plugin/dnd.go index 6cecd3a..70de3df 100644 --- a/internal/plugin/dnd.go +++ b/internal/plugin/dnd.go @@ -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, diff --git a/internal/plugin/dnd_misc_cmds.go b/internal/plugin/dnd_misc_cmds.go index 303a1bb..6eaf992 100644 --- a/internal/plugin/dnd_misc_cmds.go +++ b/internal/plugin/dnd_misc_cmds.go @@ -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)) diff --git a/internal/plugin/dnd_sheet.go b/internal/plugin/dnd_sheet.go index 52fe135..3fb5d88 100644 --- a/internal/plugin/dnd_sheet.go +++ b/internal/plugin/dnd_sheet.go @@ -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)) diff --git a/internal/plugin/dnd_xp.go b/internal/plugin/dnd_xp.go index bc104ae..c9a3759 100644 --- a/internal/plugin/dnd_xp.go +++ b/internal/plugin/dnd_xp.go @@ -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 diff --git a/internal/plugin/player_meta.go b/internal/plugin/player_meta.go index e9bc747..04243df 100644 --- a/internal/plugin/player_meta.go +++ b/internal/plugin/player_meta.go @@ -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