mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Adv 2.0 L4f: render/twinbee reader-port off AdvCharacter to dndLevelForUser
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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_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.
|
- `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.
|
**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.
|
**Exit criteria L4 overall:** `grep -l 'AdventureCharacter\|CombatLevel' internal/plugin/adventure_{hospital,rival,masterwork,pets,housing,mortgage,render,twinbee}.go` empty.
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ func (p *AdventurePlugin) catchUpRespawns(chars []AdventureCharacter) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
slog.Info("adventure: catch-up revived player", "user", char.UserID)
|
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 {
|
if err := p.SendDM(char.UserID, text); err != nil {
|
||||||
slog.Error("adventure: catch-up respawn DM failed", "user", char.UserID, "err", err)
|
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 {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
|
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
|
||||||
} else {
|
} else {
|
||||||
text := renderAdvRespawnDM(char)
|
text := renderAdvRespawnDM(char.UserID)
|
||||||
p.SendDM(ctx.Sender, text)
|
p.SendDM(ctx.Sender, text)
|
||||||
// Fall through to show menu
|
// Fall through to show menu
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !char.Alive {
|
if !char.Alive {
|
||||||
text := renderAdvDeathStatusDM(char)
|
text := renderAdvDeathStatusDM(char.UserID)
|
||||||
return p.SendDM(ctx.Sender, text)
|
return p.SendDM(ctx.Sender, text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -569,7 +569,7 @@ func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
|
|||||||
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
|
||||||
balance := p.euro.GetBalance(char.UserID)
|
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)
|
p.advMarkMenuSent(ctx.Sender)
|
||||||
return p.SendDM(ctx.Sender, text)
|
return p.SendDM(ctx.Sender, text)
|
||||||
}
|
}
|
||||||
@@ -587,7 +587,7 @@ func (p *AdventurePlugin) handleStatus(ctx MessageContext) error {
|
|||||||
if err := saveAdvCharacter(char); err != nil {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
|
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
|
||||||
} else {
|
} 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)
|
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)
|
return p.SendDM(ctx.Sender, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,7 +705,18 @@ func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.")
|
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)
|
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.")
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to revive.")
|
||||||
}
|
}
|
||||||
|
|
||||||
p.SendDM(targetID, renderAdvRespawnDM(char))
|
p.SendDM(targetID, renderAdvRespawnDM(targetID))
|
||||||
if p.achievements != nil {
|
if p.achievements != nil {
|
||||||
p.achievements.GrantAchievement(targetID, "adv_revived")
|
p.achievements.GrantAchievement(targetID, "adv_revived")
|
||||||
}
|
}
|
||||||
@@ -890,11 +901,11 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
|
|||||||
if err := saveAdvCharacter(char); err != nil {
|
if err := saveAdvCharacter(char); err != nil {
|
||||||
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
|
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
|
||||||
} else {
|
} else {
|
||||||
p.SendDM(ctx.Sender, renderAdvRespawnDM(char))
|
p.SendDM(ctx.Sender, renderAdvRespawnDM(char.UserID))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !char.Alive {
|
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)
|
p.registerDMRoom(userID)
|
||||||
|
|
||||||
// Send onboarding
|
// Send onboarding
|
||||||
text := renderAdvOnboardingDM(char)
|
text := renderAdvOnboardingDM(userID)
|
||||||
p.SendDM(userID, text)
|
p.SendDM(userID, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ func (p *AdventurePlugin) handleArenaMenu(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !char.Alive {
|
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
|
// Clear any pending entry when viewing menu
|
||||||
@@ -156,7 +156,7 @@ func (p *AdventurePlugin) confirmAndStartArenaRun(ctx MessageContext) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !char.Alive {
|
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)
|
// Re-check active run (could have changed since entry prompt)
|
||||||
|
|||||||
@@ -254,11 +254,7 @@ func TestRenderCraftingTeaser_BracketBoundaries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
char := &AdventureCharacter{
|
got := craftingTeaserText(tc.foraging, tc.craftsSucceeded, "")
|
||||||
ForagingSkill: tc.foraging,
|
|
||||||
CraftsSucceeded: tc.craftsSucceeded,
|
|
||||||
}
|
|
||||||
got := renderCraftingTeaser(char)
|
|
||||||
if tc.wantEmpty {
|
if tc.wantEmpty {
|
||||||
if got != "" {
|
if got != "" {
|
||||||
t.Errorf("expected empty, got %q", got)
|
t.Errorf("expected empty, got %q", got)
|
||||||
|
|||||||
@@ -89,15 +89,21 @@ func advSubstituteFlavor(template string, vars map[string]string) string {
|
|||||||
|
|
||||||
// ── Character Sheet ──────────────────────────────────────────────────────────
|
// ── 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
|
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))
|
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("📊 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(" 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(" 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)))
|
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).
|
// Rival status — read from player_meta (Adv 2.0 Phase L4b).
|
||||||
rivalPoolFlag, _, _ := loadRivalState(char.UserID)
|
rivalPoolFlag, _, _ := loadRivalState(userID)
|
||||||
if rivalPoolFlag == 1 {
|
if rivalPoolFlag == 1 {
|
||||||
records, _ := loadAllRivalRecords(char.UserID)
|
records, _ := loadAllRivalRecords(userID)
|
||||||
sb.WriteString("\n⚔️ Rivals: Unlocked")
|
sb.WriteString("\n⚔️ Rivals: Unlocked")
|
||||||
if len(records) > 0 {
|
if len(records) > 0 {
|
||||||
totalW, totalL := 0, 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
|
// 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
|
// never type !adventure recipes. Returns "" when the teaser shouldn't fire
|
||||||
// today (out of pre-unlock window, or already deep into post-unlock).
|
// today (out of pre-unlock window, or already deep into post-unlock).
|
||||||
func renderCraftingTeaser(char *AdventureCharacter) string {
|
func renderCraftingTeaser(userID id.UserID) string {
|
||||||
const unlock = 10
|
char, err := loadAdvCharacter(userID)
|
||||||
if char.ForagingSkill < unlock {
|
if err != nil || char == nil {
|
||||||
// Pre-unlock teaser: only when within 3 levels.
|
|
||||||
if char.ForagingSkill < unlock-3 {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
levelsToGo := unlock - char.ForagingSkill
|
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 foragingSkill < unlock {
|
||||||
|
// Pre-unlock teaser: only when within 3 levels.
|
||||||
|
if foragingSkill < unlock-3 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
levelsToGo := unlock - foragingSkill
|
||||||
return fmt.Sprintf("🧪 **Crafting unlocks in %d Foraging level%s** — auto-craft consumables from gathered ingredients (Berry Poultice, Herb Salve, etc.).",
|
return fmt.Sprintf("🧪 **Crafting unlocks in %d Foraging level%s** — auto-craft consumables from gathered ingredients (Berry Poultice, Herb Salve, etc.).",
|
||||||
levelsToGo, plural(levelsToGo))
|
levelsToGo, plural(levelsToGo))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Post-unlock: nudge when no successful crafts yet (gentle), or once a
|
// Post-unlock: nudge when no successful crafts yet (gentle), or once a
|
||||||
// week (loose periodicity via day-of-year %).
|
// 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."
|
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
|
if int(time.Now().UTC().Weekday()) == craftingReminderWeekday(userID, time.Now().UTC()) {
|
||||||
// weekday for this player. Spreads the cohort across the week instead
|
return "🧪 *Crafting reminder* — `!adventure recipes` shows what's available at Foraging Lv." + fmt.Sprintf("%d.", foragingSkill)
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -267,8 +280,8 @@ func plural(n int) string {
|
|||||||
// players who missed or ignored the dramatic challenge DM see a reminder
|
// players who missed or ignored the dramatic challenge DM see a reminder
|
||||||
// before it expires. Returns "" if there's no pending challenge against
|
// before it expires. Returns "" if there's no pending challenge against
|
||||||
// this player.
|
// this player.
|
||||||
func renderRivalNudge(char *AdventureCharacter) string {
|
func renderRivalNudge(userID id.UserID) string {
|
||||||
c := pendingRivalChallengeForChallenged(char.UserID)
|
c := pendingRivalChallengeForChallenged(userID)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -284,9 +297,14 @@ func renderRivalNudge(char *AdventureCharacter) string {
|
|||||||
rivalName, c.Round, c.Stake, hours)
|
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
|
var sb strings.Builder
|
||||||
|
|
||||||
|
char, err := loadAdvCharacter(userID)
|
||||||
|
if err != nil || char == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// Holiday notice (before greeting). Today's perks: TwinBee starts new
|
// Holiday notice (before greeting). Today's perks: TwinBee starts new
|
||||||
// runs in a slightly better mood (+5), expedition outfitting includes a
|
// runs in a slightly better mood (+5), expedition outfitting includes a
|
||||||
// complimentary standard pack, and every harvest yields one extra unit.
|
// 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
|
// Pick a morning greeting
|
||||||
greeting, _ := advPickFlavor(MorningDM, char.UserID, "morning_dm")
|
greeting, _ := advPickFlavor(MorningDM, userID, "morning_dm")
|
||||||
displayName, _ := loadDisplayName(char.UserID)
|
displayName, _ := loadDisplayName(userID)
|
||||||
|
dndLevel := dndLevelForUser(userID)
|
||||||
vars := map[string]string{
|
vars := map[string]string{
|
||||||
"{name}": displayName,
|
"{name}": displayName,
|
||||||
"{character_sheet}": fmt.Sprintf(
|
"{character_sheet}": fmt.Sprintf(
|
||||||
" ⚔️ Combat Lv.%d ⛏️ Mining Lv.%d 🌿 Foraging Lv.%d 🎣 Fishing Lv.%d\n 💰 €%.0f",
|
" ⚔️ 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(advSubstituteFlavor(greeting, vars))
|
||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
|
|
||||||
// Active buffs
|
// Active buffs
|
||||||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
buffs, _ := loadAdvActiveBuffs(userID)
|
||||||
if len(buffs) > 0 {
|
if len(buffs) > 0 {
|
||||||
sb.WriteString("✨ **Active buffs:**\n")
|
sb.WriteString("✨ **Active buffs:**\n")
|
||||||
for _, b := range buffs {
|
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")
|
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.
|
// Rival nudge — a pending challenge waits for action.
|
||||||
if line := renderRivalNudge(char); line != "" {
|
if line := renderRivalNudge(userID); line != "" {
|
||||||
sb.WriteString(line)
|
sb.WriteString(line)
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crafting teaser — surface the system when it's near unlock or post-unlock.
|
// 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(line)
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
@@ -373,7 +392,7 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
|
|||||||
|
|
||||||
// ── Resolution DM ────────────────────────────────────────────────────────────
|
// ── Resolution DM ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) string {
|
func renderAdvResolutionDM(result *AdvActionResult, userID id.UserID) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
sb.WriteString(result.FlavorText)
|
sb.WriteString(result.FlavorText)
|
||||||
@@ -384,7 +403,7 @@ func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) st
|
|||||||
|
|
||||||
if result.Outcome == AdvOutcomeDeath {
|
if result.Outcome == AdvOutcomeDeath {
|
||||||
sb.WriteString("💀 **You died.**\n")
|
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")))
|
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 ──────────────────────────────────────────────────────────
|
// ── Death Status DM ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderAdvDeathStatusDM(char *AdventureCharacter) string {
|
func renderAdvDeathStatusDM(userID id.UserID) string {
|
||||||
cost := int64(char.CombatLevel) * 25_000
|
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"
|
remaining := "unknown"
|
||||||
if char.DeadUntil != nil {
|
if char.DeadUntil != nil {
|
||||||
remaining = char.DeadUntil.Format("15:04")
|
remaining = char.DeadUntil.Format("15:04")
|
||||||
@@ -504,9 +528,9 @@ func renderAdvDeathStatusDM(char *AdventureCharacter) string {
|
|||||||
|
|
||||||
// ── Respawn DM ───────────────────────────────────────────────────────────────
|
// ── Respawn DM ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderAdvRespawnDM(char *AdventureCharacter) string {
|
func renderAdvRespawnDM(userID id.UserID) string {
|
||||||
text, _ := advPickFlavor(RespawnDM, char.UserID, "respawn_dm")
|
text, _ := advPickFlavor(RespawnDM, userID, "respawn_dm")
|
||||||
displayName, _ := loadDisplayName(char.UserID)
|
displayName, _ := loadDisplayName(userID)
|
||||||
return advSubstituteFlavor(text, map[string]string{
|
return advSubstituteFlavor(text, map[string]string{
|
||||||
"{name}": displayName,
|
"{name}": displayName,
|
||||||
})
|
})
|
||||||
@@ -666,9 +690,9 @@ func masteryBar(value, total int) string {
|
|||||||
|
|
||||||
// ── Idle Shame DM ────────────────────────────────────────────────────────────
|
// ── Idle Shame DM ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderAdvIdleShameDM(char *AdventureCharacter) string {
|
func renderAdvIdleShameDM(userID id.UserID) string {
|
||||||
text, _ := advPickFlavor(IdleShameDM, char.UserID, "idle_shame")
|
text, _ := advPickFlavor(IdleShameDM, userID, "idle_shame")
|
||||||
displayName, _ := loadDisplayName(char.UserID)
|
displayName, _ := loadDisplayName(userID)
|
||||||
return advSubstituteFlavor(text, map[string]string{
|
return advSubstituteFlavor(text, map[string]string{
|
||||||
"{name}": displayName,
|
"{name}": displayName,
|
||||||
})
|
})
|
||||||
@@ -676,9 +700,9 @@ func renderAdvIdleShameDM(char *AdventureCharacter) string {
|
|||||||
|
|
||||||
// ── Onboarding DM ────────────────────────────────────────────────────────────
|
// ── Onboarding DM ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func renderAdvOnboardingDM(char *AdventureCharacter) string {
|
func renderAdvOnboardingDM(userID id.UserID) string {
|
||||||
text, _ := advPickFlavor(OnboardingDM, char.UserID, "onboarding")
|
text, _ := advPickFlavor(OnboardingDM, userID, "onboarding")
|
||||||
displayName, _ := loadDisplayName(char.UserID)
|
displayName, _ := loadDisplayName(userID)
|
||||||
return advSubstituteFlavor(text, map[string]string{
|
return advSubstituteFlavor(text, map[string]string{
|
||||||
"{name}": displayName,
|
"{name}": displayName,
|
||||||
})
|
})
|
||||||
@@ -688,7 +712,7 @@ func renderAdvOnboardingDM(char *AdventureCharacter) string {
|
|||||||
|
|
||||||
type AdvPlayerDaySummary struct {
|
type AdvPlayerDaySummary struct {
|
||||||
DisplayName string
|
DisplayName string
|
||||||
CombatLevel int
|
Level int
|
||||||
MiningSkill int
|
MiningSkill int
|
||||||
ForagingSkill int
|
ForagingSkill int
|
||||||
FishingSkill int
|
FishingSkill int
|
||||||
@@ -786,7 +810,7 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
|||||||
icon = "⚔️"
|
icon = "⚔️"
|
||||||
}
|
}
|
||||||
sb.WriteString(fmt.Sprintf("%s **%s** — Combat Lv.%d | Mining Lv.%d | Forage Lv.%d | Fishing Lv.%d\n",
|
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(" Went to: %s\n", p.Location))
|
||||||
sb.WriteString(fmt.Sprintf(" Outcome: %s\n", p.SummaryLine))
|
sb.WriteString(fmt.Sprintf(" Outcome: %s\n", p.SummaryLine))
|
||||||
if !diedOnAdventure {
|
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",
|
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 != "" {
|
if p.Location != "" {
|
||||||
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
|
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
|
||||||
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
|
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
|
||||||
@@ -881,7 +905,19 @@ func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewa
|
|||||||
|
|
||||||
// ── Leaderboard ──────────────────────────────────────────────────────────────
|
// ── 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 {
|
if len(chars) == 0 {
|
||||||
return "No adventurers registered yet. Type `!adventure` to begin."
|
return "No adventurers registered yet. Type `!adventure` to begin."
|
||||||
}
|
}
|
||||||
@@ -895,12 +931,12 @@ func renderAdvLeaderboard(chars []AdventureCharacter) string {
|
|||||||
}
|
}
|
||||||
var entries []entry
|
var entries []entry
|
||||||
for _, c := range chars {
|
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)
|
name, _ := loadDisplayName(c.UserID)
|
||||||
entries = append(entries, entry{
|
entries = append(entries, entry{
|
||||||
Name: name,
|
Name: name,
|
||||||
Score: score,
|
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,
|
Streak: c.CurrentStreak,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send respawn DM
|
// Send respawn DM
|
||||||
text := renderAdvRespawnDM(&char)
|
text := renderAdvRespawnDM(char.UserID)
|
||||||
if err := p.SendDM(char.UserID, text); err != nil {
|
if err := p.SendDM(char.UserID, text); err != nil {
|
||||||
slog.Error("adventure: failed to send respawn DM", "user", char.UserID, "err", err)
|
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 still dead, send death status
|
||||||
if !char.Alive {
|
if !char.Alive {
|
||||||
text := renderAdvDeathStatusDM(&char)
|
text := renderAdvDeathStatusDM(char.UserID)
|
||||||
if err := p.SendDM(char.UserID, text); err != nil {
|
if err := p.SendDM(char.UserID, text); err != nil {
|
||||||
slog.Error("adventure: failed to send death status DM", "user", char.UserID, "err", err)
|
slog.Error("adventure: failed to send death status DM", "user", char.UserID, "err", err)
|
||||||
}
|
}
|
||||||
@@ -144,7 +144,7 @@ func (p *AdventurePlugin) sendMorningDMs() {
|
|||||||
if isHol {
|
if isHol {
|
||||||
holidayLabel = holName
|
holidayLabel = holName
|
||||||
}
|
}
|
||||||
text := renderAdvMorningDM(&char, equip, balance, bonuses, holidayLabel)
|
text := renderAdvMorningDM(char.UserID, equip, balance, bonuses, holidayLabel)
|
||||||
if petEvent != "" {
|
if petEvent != "" {
|
||||||
text = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, text)
|
text = fmt.Sprintf("🐾 *%s*\n\n%s", petEvent, text)
|
||||||
}
|
}
|
||||||
@@ -237,7 +237,7 @@ func (p *AdventurePlugin) postDailySummary() {
|
|||||||
dispName, _ := loadDisplayName(c.UserID)
|
dispName, _ := loadDisplayName(c.UserID)
|
||||||
ps := AdvPlayerDaySummary{
|
ps := AdvPlayerDaySummary{
|
||||||
DisplayName: dispName,
|
DisplayName: dispName,
|
||||||
CombatLevel: c.CombatLevel,
|
Level: dndLevelForUser(c.UserID),
|
||||||
MiningSkill: c.MiningSkill,
|
MiningSkill: c.MiningSkill,
|
||||||
ForagingSkill: c.ForagingSkill,
|
ForagingSkill: c.ForagingSkill,
|
||||||
FishingSkill: c.FishingSkill,
|
FishingSkill: c.FishingSkill,
|
||||||
@@ -376,7 +376,7 @@ func (p *AdventurePlugin) midnightReset() error {
|
|||||||
dmsSent++
|
dmsSent++
|
||||||
|
|
||||||
// Idle shame DM
|
// Idle shame DM
|
||||||
text := renderAdvIdleShameDM(&char)
|
text := renderAdvIdleShameDM(char.UserID)
|
||||||
if char.CurrentStreak > 0 {
|
if char.CurrentStreak > 0 {
|
||||||
oldStreak := char.CurrentStreak
|
oldStreak := char.CurrentStreak
|
||||||
char.CurrentStreak /= 2
|
char.CurrentStreak /= 2
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ var twinBeeWeights = []twinBeeActionWeight{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// twinBeeMaxTier returns the highest tier TwinBee should visit,
|
// 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 {
|
func twinBeeMaxTier() int {
|
||||||
chars, err := loadAllAdvCharacters()
|
chars, err := loadAllAdvCharacters()
|
||||||
if err != nil || len(chars) == 0 {
|
if err != nil || len(chars) == 0 {
|
||||||
@@ -39,7 +40,7 @@ func twinBeeMaxTier() int {
|
|||||||
if !c.Alive {
|
if !c.Alive {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
combined := c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
combined := dndLevelForUser(c.UserID) + c.MiningSkill + c.ForagingSkill + c.FishingSkill
|
||||||
if combined > bestLevel {
|
if combined > bestLevel {
|
||||||
bestLevel = combined
|
bestLevel = combined
|
||||||
}
|
}
|
||||||
@@ -282,7 +283,7 @@ func (p *AdventurePlugin) distributeTwinBeeRewards(result *TwinBeeResult) TwinBe
|
|||||||
weight := 4 // minimum (level 1 in all 4 skills)
|
weight := 4 // minimum (level 1 in all 4 skills)
|
||||||
for _, c := range chars {
|
for _, c := range chars {
|
||||||
if c.UserID == uid {
|
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 {
|
if weight < 4 {
|
||||||
weight = 4
|
weight = 4
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -340,6 +340,24 @@ func dndLevelFromCombatLevel(combatLevel int) int {
|
|||||||
return lvl
|
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.
|
// applyRaceMods adds the race's ability modifiers to a base score block.
|
||||||
func applyRaceMods(race DnDRace, scores [6]int) [6]int {
|
func applyRaceMods(race DnDRace, scores [6]int) [6]int {
|
||||||
ri, ok := raceInfo(race)
|
ri, ok := raceInfo(race)
|
||||||
|
|||||||
Reference in New Issue
Block a user