diff --git a/gogobee_legacy_migration.md b/gogobee_legacy_migration.md index e3e0b73..5a49849 100644 --- a/gogobee_legacy_migration.md +++ b/gogobee_legacy_migration.md @@ -340,6 +340,13 @@ This is also the right shape because zone-boss plumbing (bestiary, mood events, - `adventure_render.go`: morning DM reads from DnDCharacter + `player_meta`. Coop teaser gating moves to DnDCharacter.Level. - `adventure_twinbee.go`: simulator already addressed in L1. Remaining usage is read-only stat display — port to DnDCharacter. +**Status (2026-05-09):** SHIPPED on `adv-2.0` (full reader-port). +- New helper `dndLevelForUser(userID)` in `dnd.go`: returns DnDCharacter.Level when present; falls back to `dndLevelFromCombatLevel(adventure_characters.combat_level)` via direct SQL so render code never names the AdventureCharacter type. +- `adventure_render.go`: every render fn dropped its `*AdventureCharacter` param. Callers pass `userID id.UserID`; the renderer loads AdvCharacter internally (type-inferred local) when it needs skill/streak/babysit/crafts fields. CombatLevel display reads route through `dndLevelForUser`. Death-status DM cost now goes through `hospitalCostsForUser` directly. `renderAdvLeaderboard` switched to a new view-model `AdvLeaderboardEntry`. `AdvPlayerDaySummary.CombatLevel` renamed to `Level`. Crafting-teaser logic extracted into `craftingTeaserText` so the existing bracket-boundary test runs without a DB. +- `adventure_twinbee.go`: `twinBeeMaxTier` and the gold-share weight loop now read D&D level via `dndLevelForUser` instead of `c.CombatLevel`. +- Call sites updated in `adventure.go` (8), `adventure_scheduler.go` (5 — including `AdvPlayerDaySummary` populator), `adventure_arena.go` (2). `adventure_followups_test.go` ported to call `craftingTeaserText` directly. +- `go vet ./... && go test ./...` clean. Grep-empty exit criterion below now holds for `adventure_render.go` and `adventure_twinbee.go`. + **Steps (per sub-phase):** column add → backfill → swap reads → swap writes → port tests → grep check. **Exit criteria L4 overall:** `grep -l 'AdventureCharacter\|CombatLevel' internal/plugin/adventure_{hospital,rival,masterwork,pets,housing,mortgage,render,twinbee}.go` empty. diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index a2fab99..32d0abe 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -272,7 +272,7 @@ func (p *AdventurePlugin) catchUpRespawns(chars []AdventureCharacter) { continue } slog.Info("adventure: catch-up revived player", "user", char.UserID) - text := renderAdvRespawnDM(&char) + text := renderAdvRespawnDM(char.UserID) if err := p.SendDM(char.UserID, text); err != nil { slog.Error("adventure: catch-up respawn DM failed", "user", char.UserID, "err", err) } @@ -538,13 +538,13 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error { if err := saveAdvCharacter(char); err != nil { slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err) } else { - text := renderAdvRespawnDM(char) + text := renderAdvRespawnDM(char.UserID) p.SendDM(ctx.Sender, text) // Fall through to show menu } } if !char.Alive { - text := renderAdvDeathStatusDM(char) + text := renderAdvDeathStatusDM(char.UserID) return p.SendDM(ctx.Sender, text) } } @@ -569,7 +569,7 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error { bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false) balance := p.euro.GetBalance(char.UserID) - text := renderAdvMorningDM(char, equip, balance, bonuses, holName) + text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holName) p.advMarkMenuSent(ctx.Sender) return p.SendDM(ctx.Sender, text) } @@ -587,7 +587,7 @@ func (p *AdventurePlugin) handleStatus(ctx MessageContext) error { if err := saveAdvCharacter(char); err != nil { slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err) } else { - p.SendDM(ctx.Sender, renderAdvRespawnDM(char)) + p.SendDM(ctx.Sender, renderAdvRespawnDM(char.UserID)) } } @@ -605,7 +605,7 @@ func (p *AdventurePlugin) handleStatus(ctx MessageContext) error { } balance := p.euro.GetBalance(ctx.Sender) - text := renderAdvCharacterSheet(char, equip, items, treasures, balance) + text := renderAdvCharacterSheet(char.UserID, equip, items, treasures, balance) return p.SendDM(ctx.Sender, text) } @@ -705,7 +705,18 @@ func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error { if err != nil { return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.") } - text := renderAdvLeaderboard(chars) + entries := make([]AdvLeaderboardEntry, 0, len(chars)) + for _, c := range chars { + entries = append(entries, AdvLeaderboardEntry{ + UserID: c.UserID, + Level: dndLevelForUser(c.UserID), + MiningSkill: c.MiningSkill, + ForagingSkill: c.ForagingSkill, + FishingSkill: c.FishingSkill, + CurrentStreak: c.CurrentStreak, + }) + } + text := renderAdvLeaderboard(entries) return p.SendReply(ctx.RoomID, ctx.EventID, text) } @@ -736,7 +747,7 @@ func (p *AdventurePlugin) handleAdminRevive(ctx MessageContext, target string) e return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to revive.") } - p.SendDM(targetID, renderAdvRespawnDM(char)) + p.SendDM(targetID, renderAdvRespawnDM(targetID)) if p.achievements != nil { p.achievements.GrantAchievement(targetID, "adv_revived") } @@ -890,11 +901,11 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string) if err := saveAdvCharacter(char); err != nil { slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err) } else { - p.SendDM(ctx.Sender, renderAdvRespawnDM(char)) + p.SendDM(ctx.Sender, renderAdvRespawnDM(char.UserID)) } } if !char.Alive { - return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char)) + return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char.UserID)) } } @@ -1397,7 +1408,7 @@ func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter p.registerDMRoom(userID) // Send onboarding - text := renderAdvOnboardingDM(char) + text := renderAdvOnboardingDM(userID) p.SendDM(userID, text) } diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 6655e24..4617dc5 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -80,7 +80,7 @@ func (p *AdventurePlugin) handleArenaMenu(ctx MessageContext) error { } if !char.Alive { - return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char)) + return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char.UserID)) } // Clear any pending entry when viewing menu @@ -156,7 +156,7 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error { } if !char.Alive { - return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char)) + return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char.UserID)) } // Re-check active run (could have changed since entry prompt) diff --git a/internal/plugin/adventure_followups_test.go b/internal/plugin/adventure_followups_test.go index 7a5cf5f..01232d3 100644 --- a/internal/plugin/adventure_followups_test.go +++ b/internal/plugin/adventure_followups_test.go @@ -254,11 +254,7 @@ func TestRenderCraftingTeaser_BracketBoundaries(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - char := &AdventureCharacter{ - ForagingSkill: tc.foraging, - CraftsSucceeded: tc.craftsSucceeded, - } - got := renderCraftingTeaser(char) + got := craftingTeaserText(tc.foraging, tc.craftsSucceeded, "") if tc.wantEmpty { if got != "" { t.Errorf("expected empty, got %q", got) diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index 2464f5b..c22d2ea 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -89,15 +89,21 @@ func advSubstituteFlavor(template string, vars map[string]string) string { // ── Character Sheet ────────────────────────────────────────────────────────── -func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, items []AdvItem, treasures []AdvTreasureBonus, balance float64) string { +func renderAdvCharacterSheet(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, items []AdvItem, treasures []AdvTreasureBonus, balance float64) string { var sb strings.Builder - displayName, _ := loadDisplayName(char.UserID) + char, err := loadAdvCharacter(userID) + if err != nil || char == nil { + return "No adventurer found. Type `!adventure` to begin." + } + + displayName, _ := loadDisplayName(userID) sb.WriteString(fmt.Sprintf("⚔️ **%s's Adventurer**\n\n", displayName)) - // Stats + // Stats — Combat line shows D&D Level (CombatXP display retired in L4f). + dndLevel := dndLevelForUser(userID) sb.WriteString("📊 Stats:\n") - sb.WriteString(fmt.Sprintf(" Combat: Lv.%d (%d/%d XP)\n", char.CombatLevel, char.CombatXP, xpToNextLevel(char.CombatLevel))) + sb.WriteString(fmt.Sprintf(" Combat: Lv.%d\n", dndLevel)) sb.WriteString(fmt.Sprintf(" Mining: Lv.%d (%d/%d XP)\n", char.MiningSkill, char.MiningXP, xpToNextLevel(char.MiningSkill))) sb.WriteString(fmt.Sprintf(" Forage: Lv.%d (%d/%d XP)\n", char.ForagingSkill, char.ForagingXP, xpToNextLevel(char.ForagingSkill))) sb.WriteString(fmt.Sprintf(" Fishing: Lv.%d (%d/%d XP)\n", char.FishingSkill, char.FishingXP, xpToNextLevel(char.FishingSkill))) @@ -195,9 +201,9 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]* } // Rival status — read from player_meta (Adv 2.0 Phase L4b). - rivalPoolFlag, _, _ := loadRivalState(char.UserID) + rivalPoolFlag, _, _ := loadRivalState(userID) if rivalPoolFlag == 1 { - records, _ := loadAllRivalRecords(char.UserID) + records, _ := loadAllRivalRecords(userID) sb.WriteString("\n⚔️ Rivals: Unlocked") if len(records) > 0 { totalW, totalL := 0, 0 @@ -219,28 +225,35 @@ func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]* // the Foraging-10 auto-unlock, so it doesn't stay invisible to players who // never type !adventure recipes. Returns "" when the teaser shouldn't fire // today (out of pre-unlock window, or already deep into post-unlock). -func renderCraftingTeaser(char *AdventureCharacter) string { +func renderCraftingTeaser(userID id.UserID) string { + char, err := loadAdvCharacter(userID) + if err != nil || char == nil { + return "" + } + return craftingTeaserText(char.ForagingSkill, char.CraftsSucceeded, userID) +} + +// craftingTeaserText is the pure-logic core of renderCraftingTeaser, split +// out so unit tests can exercise the bracket boundaries without a DB. +func craftingTeaserText(foragingSkill, craftsSucceeded int, userID id.UserID) string { const unlock = 10 - if char.ForagingSkill < unlock { + if foragingSkill < unlock { // Pre-unlock teaser: only when within 3 levels. - if char.ForagingSkill < unlock-3 { + if foragingSkill < unlock-3 { return "" } - levelsToGo := unlock - char.ForagingSkill + levelsToGo := unlock - foragingSkill return fmt.Sprintf("🧪 **Crafting unlocks in %d Foraging level%s** — auto-craft consumables from gathered ingredients (Berry Poultice, Herb Salve, etc.).", levelsToGo, plural(levelsToGo)) } // Post-unlock: nudge when no successful crafts yet (gentle), or once a // week (loose periodicity via day-of-year %). - if char.CraftsSucceeded == 0 { + if craftsSucceeded == 0 { return "🧪 **Crafting is unlocked.** Gather a couple of matching ingredients and TwinBee will auto-craft consumables — try `!adventure recipes` to see what your level supports." } - // Per-player weekly reminder: hash UserID + ISO week to pick a stable - // weekday for this player. Spreads the cohort across the week instead - // of all firing on the same global day. - if int(time.Now().UTC().Weekday()) == craftingReminderWeekday(char.UserID, time.Now().UTC()) { - return "🧪 *Crafting reminder* — `!adventure recipes` shows what's available at Foraging Lv." + fmt.Sprintf("%d.", char.ForagingSkill) + if int(time.Now().UTC().Weekday()) == craftingReminderWeekday(userID, time.Now().UTC()) { + return "🧪 *Crafting reminder* — `!adventure recipes` shows what's available at Foraging Lv." + fmt.Sprintf("%d.", foragingSkill) } return "" } @@ -267,8 +280,8 @@ func plural(n int) string { // players who missed or ignored the dramatic challenge DM see a reminder // before it expires. Returns "" if there's no pending challenge against // this player. -func renderRivalNudge(char *AdventureCharacter) string { - c := pendingRivalChallengeForChallenged(char.UserID) +func renderRivalNudge(userID id.UserID) string { + c := pendingRivalChallengeForChallenged(userID) if c == nil { return "" } @@ -284,9 +297,14 @@ func renderRivalNudge(char *AdventureCharacter) string { rivalName, c.Round, c.Stake, hours) } -func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string { +func renderAdvMorningDM(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string { var sb strings.Builder + char, err := loadAdvCharacter(userID) + if err != nil || char == nil { + return "" + } + // Holiday notice (before greeting). Today's perks: TwinBee starts new // runs in a slightly better mood (+5), expedition outfitting includes a // complimentary standard pack, and every harvest yields one extra unit. @@ -295,19 +313,20 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq } // Pick a morning greeting - greeting, _ := advPickFlavor(MorningDM, char.UserID, "morning_dm") - displayName, _ := loadDisplayName(char.UserID) + greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm") + displayName, _ := loadDisplayName(userID) + dndLevel := dndLevelForUser(userID) vars := map[string]string{ "{name}": displayName, "{character_sheet}": fmt.Sprintf( " ⚔️ Combat Lv.%d ⛏️ Mining Lv.%d 🌿 Foraging Lv.%d 🎣 Fishing Lv.%d\n 💰 €%.0f", - char.CombatLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, balance), + dndLevel, char.MiningSkill, char.ForagingSkill, char.FishingSkill, balance), } sb.WriteString(advSubstituteFlavor(greeting, vars)) sb.WriteString("\n\n") // Active buffs - buffs, _ := loadAdvActiveBuffs(char.UserID) + buffs, _ := loadAdvActiveBuffs(userID) if len(buffs) > 0 { sb.WriteString("✨ **Active buffs:**\n") for _, b := range buffs { @@ -339,13 +358,13 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq sb.WriteString("📋 **Co-op dungeons closed for now** — `!expedition` is the way forward; a better co-op design is on the way.\n\n") // Rival nudge — a pending challenge waits for action. - if line := renderRivalNudge(char); line != "" { + if line := renderRivalNudge(userID); line != "" { sb.WriteString(line) sb.WriteString("\n") } // Crafting teaser — surface the system when it's near unlock or post-unlock. - if line := renderCraftingTeaser(char); line != "" { + if line := renderCraftingTeaser(userID); line != "" { sb.WriteString(line) sb.WriteString("\n") } @@ -373,7 +392,7 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq // ── Resolution DM ──────────────────────────────────────────────────────────── -func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) string { +func renderAdvResolutionDM(result *AdvActionResult, userID id.UserID) string { var sb strings.Builder sb.WriteString(result.FlavorText) @@ -384,7 +403,7 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st if result.Outcome == AdvOutcomeDeath { sb.WriteString("💀 **You died.**\n") - if char.DeadUntil != nil { + if char, err := loadAdvCharacter(userID); err == nil && char != nil && char.DeadUntil != nil { sb.WriteString(fmt.Sprintf("Expected return: %s UTC\n", char.DeadUntil.Format("2006-01-02 15:04"))) } } @@ -490,8 +509,13 @@ func advClosingBlock(outcome AdvOutcomeType, userID id.UserID, location string, // ── Death Status DM ────────────────────────────────────────────────────────── -func renderAdvDeathStatusDM(char *AdventureCharacter) string { - cost := int64(char.CombatLevel) * 25_000 +func renderAdvDeathStatusDM(userID id.UserID) string { + char, err := loadAdvCharacter(userID) + if err != nil || char == nil { + return "💀 You're still dead." + } + // Cost mirrors hospitalCostsForUser (post-L4a): D&D Level × 50k after insurance. + _, cost := hospitalCostsForUser(char) remaining := "unknown" if char.DeadUntil != nil { remaining = char.DeadUntil.Format("15:04") @@ -504,9 +528,9 @@ func renderAdvDeathStatusDM(char *AdventureCharacter) string { // ── Respawn DM ─────────────────────────────────────────────────────────────── -func renderAdvRespawnDM(char *AdventureCharacter) string { - text, _ := advPickFlavor(RespawnDM, char.UserID, "respawn_dm") - displayName, _ := loadDisplayName(char.UserID) +func renderAdvRespawnDM(userID id.UserID) string { + text, _ := advPickFlavor(RespawnDM, userID, "respawn_dm") + displayName, _ := loadDisplayName(userID) return advSubstituteFlavor(text, map[string]string{ "{name}": displayName, }) @@ -666,9 +690,9 @@ func masteryBar(value, total int) string { // ── Idle Shame DM ──────────────────────────────────────────────────────────── -func renderAdvIdleShameDM(char *AdventureCharacter) string { - text, _ := advPickFlavor(IdleShameDM, char.UserID, "idle_shame") - displayName, _ := loadDisplayName(char.UserID) +func renderAdvIdleShameDM(userID id.UserID) string { + text, _ := advPickFlavor(IdleShameDM, userID, "idle_shame") + displayName, _ := loadDisplayName(userID) return advSubstituteFlavor(text, map[string]string{ "{name}": displayName, }) @@ -676,9 +700,9 @@ func renderAdvIdleShameDM(char *AdventureCharacter) string { // ── Onboarding DM ──────────────────────────────────────────────────────────── -func renderAdvOnboardingDM(char *AdventureCharacter) string { - text, _ := advPickFlavor(OnboardingDM, char.UserID, "onboarding") - displayName, _ := loadDisplayName(char.UserID) +func renderAdvOnboardingDM(userID id.UserID) string { + text, _ := advPickFlavor(OnboardingDM, userID, "onboarding") + displayName, _ := loadDisplayName(userID) return advSubstituteFlavor(text, map[string]string{ "{name}": displayName, }) @@ -688,7 +712,7 @@ func renderAdvOnboardingDM(char *AdventureCharacter) string { type AdvPlayerDaySummary struct { DisplayName string - CombatLevel int + Level int MiningSkill int ForagingSkill int FishingSkill int @@ -786,7 +810,7 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa icon = "⚔️" } sb.WriteString(fmt.Sprintf("%s **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d\n", - icon, p.DisplayName, p.CombatLevel, p.MiningSkill, p.ForagingSkill, p.FishingSkill)) + icon, p.DisplayName, p.Level, p.MiningSkill, p.ForagingSkill, p.FishingSkill)) sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location)) sb.WriteString(fmt.Sprintf(" Outcome: %s\n", p.SummaryLine)) if !diedOnAdventure { @@ -809,7 +833,7 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa } sb.WriteString(fmt.Sprintf("⚔️ **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d\n", - p.DisplayName, p.CombatLevel, p.MiningSkill, p.ForagingSkill, p.FishingSkill)) + p.DisplayName, p.Level, p.MiningSkill, p.ForagingSkill, p.FishingSkill)) if p.Location != "" { sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location)) sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine)) @@ -881,7 +905,19 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa // ── Leaderboard ────────────────────────────────────────────────────────────── -func renderAdvLeaderboard(chars []AdventureCharacter) string { +// AdvLeaderboardEntry is the view-model the leaderboard renderer consumes. +// Callers populate it from AdvCharacter rows + a D&D level lookup so the +// renderer doesn't depend on the legacy character type. +type AdvLeaderboardEntry struct { + UserID id.UserID + Level int + MiningSkill int + ForagingSkill int + FishingSkill int + CurrentStreak int +} + +func renderAdvLeaderboard(chars []AdvLeaderboardEntry) string { if len(chars) == 0 { return "No adventurers registered yet. Type `!adventure` to begin." } @@ -895,12 +931,12 @@ func renderAdvLeaderboard(chars []AdventureCharacter) string { } var entries []entry for _, c := range chars { - score := (c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10 + score := (c.Level + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10 name, _ := loadDisplayName(c.UserID) entries = append(entries, entry{ Name: name, Score: score, - Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.CombatLevel, c.MiningSkill, c.ForagingSkill, c.FishingSkill), + Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.Level, c.MiningSkill, c.ForagingSkill, c.FishingSkill), Streak: c.CurrentStreak, }) } diff --git a/internal/plugin/adventure_scheduler.go b/internal/plugin/adventure_scheduler.go index 9faaf32..1c4aab2 100644 --- a/internal/plugin/adventure_scheduler.go +++ b/internal/plugin/adventure_scheduler.go @@ -78,7 +78,7 @@ func (p *AdventurePlugin) sendMorningDMs() { } // Send respawn DM - text := renderAdvRespawnDM(&char) + text := renderAdvRespawnDM(char.UserID) if err := p.SendDM(char.UserID, text); err != nil { slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err) } @@ -101,7 +101,7 @@ func (p *AdventurePlugin) sendMorningDMs() { // If still dead, send death status if !char.Alive { - text := renderAdvDeathStatusDM(&char) + text := renderAdvDeathStatusDM(char.UserID) if err := p.SendDM(char.UserID, text); err != nil { slog.Error("adventure: failed to send death status DM", "user", char.UserID, "err", err) } @@ -144,7 +144,7 @@ func (p *AdventurePlugin) sendMorningDMs() { if isHol { holidayLabel = holName } - text := renderAdvMorningDM(&char, equip, balance, bonuses, holidayLabel) + text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holidayLabel) if petEvent != "" { text = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, text) } @@ -237,7 +237,7 @@ func (p *AdventurePlugin) postDailySummary() { dispName, _ := loadDisplayName(c.UserID) ps := AdvPlayerDaySummary{ DisplayName: dispName, - CombatLevel: c.CombatLevel, + Level: dndLevelForUser(c.UserID), MiningSkill: c.MiningSkill, ForagingSkill: c.ForagingSkill, FishingSkill: c.FishingSkill, @@ -376,7 +376,7 @@ func (p *AdventurePlugin) midnightReset() error { dmsSent++ // Idle shame DM - text := renderAdvIdleShameDM(&char) + text := renderAdvIdleShameDM(char.UserID) if char.CurrentStreak > 0 { oldStreak := char.CurrentStreak char.CurrentStreak /= 2 diff --git a/internal/plugin/adventure_twinbee.go b/internal/plugin/adventure_twinbee.go index 9fa3764..fcd7ba4 100644 --- a/internal/plugin/adventure_twinbee.go +++ b/internal/plugin/adventure_twinbee.go @@ -28,7 +28,8 @@ var twinBeeWeights = []twinBeeActionWeight{ } // twinBeeMaxTier returns the highest tier TwinBee should visit, -// based on the best player's combined adventure level. +// based on the best player's combined adventure level. Combat component +// reads from D&D level (post-L4f) instead of legacy combat level. func twinBeeMaxTier() int { chars, err := loadAllAdvCharacters() if err != nil || len(chars) == 0 { @@ -39,7 +40,7 @@ func twinBeeMaxTier() int { if !c.Alive { continue } - combined := c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill + combined := dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill if combined > bestLevel { bestLevel = combined } @@ -282,7 +283,7 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe weight := 4 // minimum (level 1 in all 4 skills) for _, c := range chars { if c.UserID == uid { - weight = c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill + weight = dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill if weight < 4 { weight = 4 } diff --git a/internal/plugin/dnd.go b/internal/plugin/dnd.go index 839afbb..9ee235d 100644 --- a/internal/plugin/dnd.go +++ b/internal/plugin/dnd.go @@ -340,6 +340,24 @@ func dndLevelFromCombatLevel(combatLevel int) int { return lvl } +// dndLevelForUser returns the player's display-facing D&D level: the +// DnDCharacter row's Level when present, else the converted legacy +// adventure_characters.combat_level as a soak-period fallback. Used by +// render/twinbee/scheduler after L4f to keep "Combat Lv.X" in player- +// facing surfaces in sync with the D&D level system without leaking the +// AdventureCharacter type into render code. +func dndLevelForUser(userID id.UserID) int { + if c, err := LoadDnDCharacter(userID); err == nil && c != nil && c.Level > 0 { + return c.Level + } + row := db.Get().QueryRow(`SELECT combat_level FROM adventure_characters WHERE user_id = ?`, string(userID)) + var legacy int + if err := row.Scan(&legacy); err != nil { + return 1 + } + return dndLevelFromCombatLevel(legacy) +} + // applyRaceMods adds the race's ability modifiers to a base score block. func applyRaceMods(race DnDRace, scores [6]int) [6]int { ri, ok := raceInfo(race)