mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Retires the standalone !adventure dungeon/mine/forage/fish daily loop in favor of !expedition. Gate intercepts legacy 1/2/3/4 + word-form input and DMs a deprecation notice; town services (shop/blacksmith/rest/thom) stay on !adventure. - Delete dead resolveActivity (269 lines) + parseActivityLocation; add isLegacyActivityInput + renderLegacyActivityDeprecation. - Morning-DM menu rewritten to point at !expedition and the !forage etc. harvest commands; writeAdvLocationLines removed (sole caller). - New dnd_r1_migration.go runs archiveOrphanZoneRuns at Init: archives any active dnd_zone_run not linked to an active expedition (via exp.run_id or region_state.region_runs). Idempotent. - Pre-existing flake in TestPickEveningRecap_BossOverridesAll fixed (E6c pool added entries without literal "boss"; widen substring set). - Internal helpers (resolveAdvAction, advEligibleLocations, AdvLocation tier data, consumable drops) preserved — babysit/scheduler still use them passively. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1194 lines
39 KiB
Go
1194 lines
39 KiB
Go
package plugin
|
||
|
||
import (
|
||
"fmt"
|
||
"hash/fnv"
|
||
"math/rand/v2"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"maunium.net/go/mautrix/id"
|
||
)
|
||
|
||
// ── Flavor Text Selection ────────────────────────────────────────────────────
|
||
|
||
// flavorHistory tracks last N used indices per player per category to avoid repetition.
|
||
var flavorHistory sync.Map // key: "userID:category" -> []int
|
||
|
||
const flavorHistorySize = 5
|
||
|
||
func advPickFlavor(pool []string, userID id.UserID, category string) (string, int) {
|
||
if len(pool) == 0 {
|
||
return "", 0
|
||
}
|
||
if len(pool) == 1 {
|
||
return pool[0], 0
|
||
}
|
||
|
||
key := string(userID) + ":" + category
|
||
|
||
// Load history
|
||
var history []int
|
||
if val, ok := flavorHistory.Load(key); ok {
|
||
history = val.([]int)
|
||
}
|
||
|
||
// Try to pick an index not in recent history
|
||
var idx int
|
||
for attempts := 0; attempts < 20; attempts++ {
|
||
idx = rand.IntN(len(pool))
|
||
if !intSliceContains(history, idx) {
|
||
break
|
||
}
|
||
}
|
||
|
||
// Update history
|
||
history = append(history, idx)
|
||
if len(history) > flavorHistorySize {
|
||
history = history[len(history)-flavorHistorySize:]
|
||
}
|
||
flavorHistory.Store(key, history)
|
||
|
||
return pool[idx], idx
|
||
}
|
||
|
||
func intSliceContains(s []int, v int) bool {
|
||
for _, x := range s {
|
||
if x == v {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// advClearFlavorHistory resets the dedup tracker. Called at midnight reset.
|
||
func advClearFlavorHistory() {
|
||
flavorHistory.Range(func(key, _ any) bool {
|
||
flavorHistory.Delete(key)
|
||
return true
|
||
})
|
||
}
|
||
|
||
// advSubstituteFlavor replaces {var} placeholders in a flavor text string.
|
||
func advSubstituteFlavor(template string, vars map[string]string) string {
|
||
pairs := make([]string, 0, len(vars)*2)
|
||
for k, v := range vars {
|
||
// Prevent "The The Soggy Cellar" — if the value starts with "The "
|
||
// and the template says "The {location}", replace that as a unit first.
|
||
if strings.HasPrefix(v, "The ") {
|
||
theKey := "The " + k
|
||
if strings.Contains(template, theKey) {
|
||
pairs = append(pairs, theKey, v)
|
||
}
|
||
}
|
||
pairs = append(pairs, k, v)
|
||
}
|
||
return strings.NewReplacer(pairs...).Replace(template)
|
||
}
|
||
|
||
// ── Character Sheet ──────────────────────────────────────────────────────────
|
||
|
||
func renderAdvCharacterSheet(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, items []AdvItem, treasures []AdvTreasureBonus, balance float64) string {
|
||
var sb strings.Builder
|
||
|
||
sb.WriteString(fmt.Sprintf("⚔️ **%s's Adventurer**\n\n", char.DisplayName))
|
||
|
||
// Stats
|
||
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(" 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)))
|
||
|
||
// Status
|
||
if char.Alive {
|
||
sb.WriteString(" Status: Alive\n")
|
||
} else if char.DeadUntil != nil {
|
||
sb.WriteString(fmt.Sprintf(" Status: 💀 Dead — revives %s UTC\n", char.DeadUntil.Format("15:04")))
|
||
}
|
||
|
||
// Streak
|
||
if char.CurrentStreak > 0 {
|
||
sb.WriteString(fmt.Sprintf(" 🔥 Streak: %d days", char.CurrentStreak))
|
||
if char.CurrentStreak >= 30 {
|
||
sb.WriteString(" 🏆")
|
||
} else if char.CurrentStreak >= 14 {
|
||
sb.WriteString(" ⭐")
|
||
} else if char.CurrentStreak >= 7 {
|
||
sb.WriteString(" 🔥")
|
||
}
|
||
sb.WriteString(fmt.Sprintf(" (best: %d)\n", char.BestStreak))
|
||
}
|
||
|
||
// Crafting (only show once they've actually crafted something)
|
||
if char.CraftsSucceeded > 0 {
|
||
sb.WriteString(fmt.Sprintf("🧪 Crafts: %d successful (Foraging Lv.%d — `!adventure recipes`)\n",
|
||
char.CraftsSucceeded, char.ForagingSkill))
|
||
}
|
||
|
||
// Equipment
|
||
sb.WriteString("\n🛡️ Equipment:\n")
|
||
eqScore := advEquipmentScore(equip)
|
||
for _, slot := range allSlots {
|
||
eq := equip[slot]
|
||
if eq == nil {
|
||
continue
|
||
}
|
||
marker := ""
|
||
if eq.Masterwork {
|
||
marker = " ⭐"
|
||
} else if eq.ArenaTier > 0 {
|
||
marker = " ⚔️"
|
||
}
|
||
// Mastery segment is appended as a third inline field after
|
||
// condition only when there's progress — keeps rows tight for
|
||
// freshly-equipped gear and grows with use.
|
||
mastery := ""
|
||
if seg := advMasteryRowSegment(eq.ActionsUsed); seg != "" {
|
||
mastery = " | " + seg
|
||
}
|
||
sb.WriteString(fmt.Sprintf(" %s %s: %s%s (Tier %d | %d%% cond%s)\n",
|
||
slotEmoji(slot), slotTitle(slot), eq.Name, marker, eq.Tier, eq.Condition, mastery))
|
||
}
|
||
sb.WriteString(fmt.Sprintf(" Equipment Score: %.1f\n", eqScore))
|
||
if line := renderAdvMasteryAggregate(equip); line != "" {
|
||
sb.WriteString(" " + line + "\n")
|
||
}
|
||
|
||
// Treasures
|
||
if len(treasures) > 0 {
|
||
seen := make(map[string]bool)
|
||
sb.WriteString("\n💎 Treasures:\n")
|
||
for _, t := range treasures {
|
||
if seen[t.TreasureKey] {
|
||
continue
|
||
}
|
||
seen[t.TreasureKey] = true
|
||
if def := lookupAdvTreasureDef(t.TreasureKey); def != nil {
|
||
sb.WriteString(fmt.Sprintf(" %s\n", def.InventoryDesc))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Inventory summary
|
||
var invValue int64
|
||
for _, item := range items {
|
||
invValue += item.Value
|
||
}
|
||
sb.WriteString(fmt.Sprintf("\n🎒 Inventory: %d items (total value ~€%d)\n", len(items), invValue))
|
||
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n", balance))
|
||
|
||
// Babysit status
|
||
if char.BabysitActive {
|
||
remaining := "active"
|
||
if char.BabysitExpiresAt != nil {
|
||
days := int(time.Until(*char.BabysitExpiresAt).Hours() / 24)
|
||
if days < 1 {
|
||
remaining = "less than a day left"
|
||
} else {
|
||
remaining = fmt.Sprintf("%d days left", days)
|
||
}
|
||
}
|
||
sb.WriteString(fmt.Sprintf("\n🍼 Babysitting: %s (focus: %s)\n", remaining, char.BabysitSkillFocus))
|
||
}
|
||
|
||
// Rival status
|
||
if char.RivalPool == 1 {
|
||
records, _ := loadAllRivalRecords(char.UserID)
|
||
sb.WriteString("\n⚔️ Rivals: Unlocked")
|
||
if len(records) > 0 {
|
||
totalW, totalL := 0, 0
|
||
for _, r := range records {
|
||
totalW += r.Wins
|
||
totalL += r.Losses
|
||
}
|
||
sb.WriteString(fmt.Sprintf(" (%dW / %dL)", totalW, totalL))
|
||
}
|
||
sb.WriteString(" — `!adventure rivals` for details\n")
|
||
}
|
||
|
||
// Today's actions
|
||
isHolSheet, _ := isHolidayToday()
|
||
combatMax := maxCombatActions
|
||
harvestMax := maxHarvestActions
|
||
if isHolSheet {
|
||
combatMax++
|
||
harvestMax++
|
||
}
|
||
combatRemaining := combatMax - char.CombatActionsUsed
|
||
if combatRemaining < 0 {
|
||
combatRemaining = 0
|
||
}
|
||
harvestRemaining := harvestMax - char.HarvestActionsUsed
|
||
if harvestRemaining < 0 {
|
||
harvestRemaining = 0
|
||
}
|
||
if char.HasActedToday() {
|
||
sb.WriteString(fmt.Sprintf("\n📅 Today: %d combat, %d harvest actions remaining", combatRemaining, harvestRemaining))
|
||
} else {
|
||
sb.WriteString(fmt.Sprintf("\n📅 Today: %d combat, %d harvest actions available", combatRemaining, harvestRemaining))
|
||
}
|
||
|
||
return sb.String()
|
||
}
|
||
|
||
// ── Morning DM ───────────────────────────────────────────────────────────────
|
||
|
||
// renderCoopTeaser surfaces an open co-op run the player could join, so the
|
||
// system isn't a footnote in help text. Returns "" when there's nothing to
|
||
// surface (no open runs, or the player is already locked into one). When
|
||
// runs exist that the player is under-levelled for, we still hint at them
|
||
// as a stretch goal — joining as a liability is a real choice.
|
||
func renderCoopTeaser(char *AdventureCharacter) string {
|
||
runs, err := loadOpenCoopRuns()
|
||
if err != nil || len(runs) == 0 {
|
||
return ""
|
||
}
|
||
|
||
pick, stretch := pickCoopTeaserCandidate(runs, char)
|
||
tag := ""
|
||
if pick == nil {
|
||
pick = stretch
|
||
tag = " *(below recommended level — would join as liability)*"
|
||
}
|
||
if pick == nil {
|
||
return ""
|
||
}
|
||
|
||
leaderName := string(pick.LeaderID)
|
||
if c, err := loadAdvCharacter(pick.LeaderID); err == nil && c != nil && c.DisplayName != "" {
|
||
leaderName = c.DisplayName
|
||
}
|
||
def := coopTierTable[pick.Tier]
|
||
return fmt.Sprintf("🛡️ **Open Co-op #%d** — Tier %d (%s) led by %s · `!coop join %d`%s",
|
||
pick.ID, pick.Tier, def.difficulty, leaderName, pick.ID, tag)
|
||
}
|
||
|
||
// pickCoopTeaserCandidate scans the open coop runs and returns up to two
|
||
// picks: a primary (where the player meets the level gate) and a stretch
|
||
// (any other run, joined as a liability). Skips runs the player already
|
||
// leads. Pure function — testable without DB.
|
||
func pickCoopTeaserCandidate(runs []*CoopRun, char *AdventureCharacter) (match, stretch *CoopRun) {
|
||
for _, r := range runs {
|
||
if r.LeaderID == char.UserID {
|
||
continue
|
||
}
|
||
def := coopTierTable[r.Tier]
|
||
if char.CombatLevel >= def.minLevel && match == nil {
|
||
match = r
|
||
} else if stretch == nil {
|
||
stretch = r
|
||
}
|
||
}
|
||
return match, stretch
|
||
}
|
||
|
||
// renderCraftingTeaser surfaces the crafting system before and just after
|
||
// 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 {
|
||
const unlock = 10
|
||
if char.ForagingSkill < unlock {
|
||
// Pre-unlock teaser: only when within 3 levels.
|
||
if char.ForagingSkill < unlock-3 {
|
||
return ""
|
||
}
|
||
levelsToGo := unlock - char.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 {
|
||
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)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// craftingReminderWeekday picks the day-of-week (0=Sunday..6=Saturday) on
|
||
// which a given player gets the weekly crafting nudge during the given
|
||
// week. Stable within an ISO week (predictable, not annoying), rotates
|
||
// across weeks (not always the same day long-term).
|
||
func craftingReminderWeekday(userID id.UserID, now time.Time) int {
|
||
year, week := now.ISOWeek()
|
||
h := fnv.New32a()
|
||
fmt.Fprintf(h, "%s|%d|%d", string(userID), year, week)
|
||
return int(h.Sum32() % 7)
|
||
}
|
||
|
||
func plural(n int) string {
|
||
if n == 1 {
|
||
return ""
|
||
}
|
||
return "s"
|
||
}
|
||
|
||
// renderRivalNudge surfaces a pending rival challenge in the morning DM so
|
||
// 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)
|
||
if c == nil {
|
||
return ""
|
||
}
|
||
hours := int(time.Until(c.ExpiresAt).Hours())
|
||
if hours < 1 {
|
||
hours = 1 // floor — "<1h" feels worse than "1h"
|
||
}
|
||
rivalName := string(c.ChallengerID)
|
||
if rc, err := loadAdvCharacter(c.ChallengerID); err == nil && rc != nil && rc.DisplayName != "" {
|
||
rivalName = rc.DisplayName
|
||
}
|
||
return fmt.Sprintf("⚔️ **Rival challenge open** — %s, round %d/3, €%d on the line · expires in %dh · reply **rock**, **paper**, or **scissors**",
|
||
rivalName, c.Round, c.Stake, hours)
|
||
}
|
||
|
||
func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEquipment, balance float64, bonuses *AdvBonusSummary, holidayName string) string {
|
||
var sb strings.Builder
|
||
|
||
// Holiday notice (before greeting)
|
||
if holidayName != "" {
|
||
sb.WriteString(fmt.Sprintf("🎉 Happy %s! In recognition of %s, you get an extra combat and harvest action today.\n\n", holidayName, holidayName))
|
||
}
|
||
|
||
// Pick a morning greeting
|
||
greeting, _ := advPickFlavor(MorningDM, char.UserID, "morning_dm")
|
||
vars := map[string]string{
|
||
"{name}": char.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),
|
||
}
|
||
sb.WriteString(advSubstituteFlavor(greeting, vars))
|
||
sb.WriteString("\n\n")
|
||
|
||
// Active buffs
|
||
buffs, _ := loadAdvActiveBuffs(char.UserID)
|
||
if len(buffs) > 0 {
|
||
sb.WriteString("✨ **Active buffs:**\n")
|
||
for _, b := range buffs {
|
||
remaining := time.Until(b.ExpiresAt).Truncate(time.Hour)
|
||
sb.WriteString(fmt.Sprintf(" %s (%.0fh remaining)\n", b.BuffName, remaining.Hours()))
|
||
}
|
||
sb.WriteString("\n")
|
||
}
|
||
|
||
// Streak info
|
||
if char.CurrentStreak >= 3 {
|
||
sb.WriteString(fmt.Sprintf("🔥 **Streak: %d days** — ", char.CurrentStreak))
|
||
switch {
|
||
case char.CurrentStreak >= 30:
|
||
sb.WriteString("+20% XP, +15% loot, -5% death")
|
||
case char.CurrentStreak >= 14:
|
||
sb.WriteString("+15% XP, +10% loot, -3% death")
|
||
case char.CurrentStreak >= 7:
|
||
sb.WriteString("+10% XP, +5% loot")
|
||
case char.CurrentStreak >= 3:
|
||
sb.WriteString("+5% XP")
|
||
}
|
||
sb.WriteString("\n\n")
|
||
}
|
||
|
||
// Action budget
|
||
isHol := holidayName != ""
|
||
combatMax := maxCombatActions
|
||
harvestMax := maxHarvestActions
|
||
if isHol {
|
||
combatMax++
|
||
harvestMax++
|
||
}
|
||
combatLeft := combatMax - char.CombatActionsUsed
|
||
if combatLeft < 0 {
|
||
combatLeft = 0
|
||
}
|
||
harvestLeft := harvestMax - char.HarvestActionsUsed
|
||
|
||
// Co-op participants have combat locked for the duration of the run.
|
||
coopRun, _ := loadCoopRunForUser(char.UserID)
|
||
inCoop := coopRun != nil && coopRun.Status == "active"
|
||
|
||
if inCoop {
|
||
sb.WriteString(fmt.Sprintf("📋 **Actions:** combat locked (in Co-op #%d, day %d/%d) · %d/%d harvest\n\n",
|
||
coopRun.ID, coopRun.CurrentDay, coopRun.TotalDays, harvestLeft, harvestMax))
|
||
} else {
|
||
sb.WriteString(fmt.Sprintf("📋 **Actions:** %d/%d combat · %d/%d harvest\n\n", combatLeft, combatMax, harvestLeft, harvestMax))
|
||
|
||
// Co-op teaser — show open runs the player could join.
|
||
if line := renderCoopTeaser(char); line != "" {
|
||
sb.WriteString(line)
|
||
sb.WriteString("\n")
|
||
}
|
||
}
|
||
|
||
// Rival nudge — a pending challenge waits for action.
|
||
if line := renderRivalNudge(char); 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 != "" {
|
||
sb.WriteString(line)
|
||
sb.WriteString("\n")
|
||
}
|
||
|
||
// Phase R1 — the daily activity loop (dungeon/mine/forage/fish) has been
|
||
// retired in favor of the expedition system. The menu now shows the new
|
||
// entry points for adventuring and keeps the still-active town services.
|
||
sb.WriteString("**🗺️ Adventure** — head into a zone:\n")
|
||
sb.WriteString("• `!expedition` — overview & open expeditions\n")
|
||
sb.WriteString("• `!expedition start <zone>` — begin a new run\n")
|
||
sb.WriteString("• `!forage` · `!mine` · `!scavenge` · `!fish` · `!essence` · `!commune` — harvest in cleared rooms\n")
|
||
if inCoop {
|
||
sb.WriteString(fmt.Sprintf("_(combat is locked while you're in Co-op #%d — `!coop status` for run state)_\n", coopRun.ID))
|
||
}
|
||
sb.WriteString("\n")
|
||
|
||
sb.WriteString("**🏘️ In town:**\n")
|
||
sb.WriteString("• `5` / `shop` — buy/sell gear and loot\n")
|
||
sb.WriteString("• `6` / `blacksmith` — repair damaged equipment\n")
|
||
sb.WriteString("• `7` / `rest` — skip today, bank your luck\n")
|
||
sb.WriteString("• `!thom` — visit Krooke Realty 🏠\n\n")
|
||
|
||
sb.WriteString("Reply with a number for in-town services, or run an expedition command directly.\n")
|
||
sb.WriteString("You have until midnight UTC to rest.")
|
||
|
||
return sb.String()
|
||
}
|
||
|
||
// ── Resolution DM ────────────────────────────────────────────────────────────
|
||
|
||
func renderAdvResolutionDM(result *AdvActionResult, char *AdventureCharacter) string {
|
||
var sb strings.Builder
|
||
|
||
sb.WriteString(result.FlavorText)
|
||
sb.WriteString("\n\n")
|
||
|
||
// Summary line
|
||
sb.WriteString("───────────────────\n")
|
||
|
||
if result.Outcome == AdvOutcomeDeath {
|
||
sb.WriteString("💀 **You died.**\n")
|
||
if char.DeadUntil != nil {
|
||
sb.WriteString(fmt.Sprintf("Expected return: %s UTC\n", char.DeadUntil.Format("2006-01-02 15:04")))
|
||
}
|
||
}
|
||
|
||
if result.NearDeath && result.Outcome != AdvOutcomeDeath {
|
||
sb.WriteString("😰 **Near death!** You survived by the thinnest margin. +15% XP bonus.\n")
|
||
}
|
||
|
||
if len(result.LootItems) > 0 {
|
||
sb.WriteString(fmt.Sprintf("💰 Loot: %s total\n", fmtEuro(result.TotalLootValue)))
|
||
for _, item := range result.LootItems {
|
||
sb.WriteString(fmt.Sprintf(" • %s — %s\n", item.Name, fmtEuro(item.Value)))
|
||
}
|
||
}
|
||
|
||
sb.WriteString(fmt.Sprintf("✨ +%d %s XP", result.XPGained, result.XPSkill))
|
||
if result.XPBreakdown != "" {
|
||
sb.WriteString(fmt.Sprintf(" (%s)", result.XPBreakdown))
|
||
}
|
||
if result.LeveledUp {
|
||
sb.WriteString(fmt.Sprintf(" — **LEVEL UP! %s Lv.%d!** 🎉", titleCase(result.XPSkill), result.NewLevel))
|
||
}
|
||
sb.WriteString("\n")
|
||
|
||
// Equipment damage
|
||
if len(result.EquipDamage) > 0 {
|
||
sb.WriteString("🔧 Equipment damage:\n")
|
||
for slot, dmg := range result.EquipDamage {
|
||
if dmg > 0 {
|
||
sb.WriteString(fmt.Sprintf(" • %s: -%d condition\n", slotTitle(slot), dmg))
|
||
}
|
||
}
|
||
}
|
||
|
||
// Equipment broken
|
||
if len(result.EquipBroken) > 0 {
|
||
for _, slot := range result.EquipBroken {
|
||
breakPool, ok := EquipmentBreaking[string(slot)]
|
||
if ok && len(breakPool) > 0 {
|
||
text := breakPool[rand.IntN(len(breakPool))]
|
||
replacement := tier0Equipment(slot)
|
||
text = advSubstituteFlavor(text, map[string]string{
|
||
"{item}": string(slot),
|
||
"{replacement}": replacement,
|
||
})
|
||
sb.WriteString("\n" + text + "\n")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Streak
|
||
if result.StreakBonus > 0 {
|
||
sb.WriteString(fmt.Sprintf("\n🔥 Streak: %d days\n", result.StreakBonus))
|
||
}
|
||
|
||
return sb.String()
|
||
}
|
||
|
||
// advClosingBlock selects and formats a closing block based on outcome.
|
||
func advClosingBlock(outcome AdvOutcomeType, userID id.UserID, location string, morningHour, summaryHour int) string {
|
||
var pool []string
|
||
var category string
|
||
|
||
switch outcome {
|
||
case AdvOutcomeExceptional:
|
||
pool = ClosingExceptional
|
||
category = "closing_exceptional"
|
||
case AdvOutcomeSuccess:
|
||
pool = ClosingSuccess
|
||
category = "closing_success"
|
||
case AdvOutcomeDeath:
|
||
pool = ClosingDeath
|
||
category = "closing_death"
|
||
default:
|
||
// Empty, cave-in, hornets, bear, river — all failure closings
|
||
pool = ClosingFailure
|
||
category = "closing_failure"
|
||
}
|
||
|
||
if len(pool) == 0 {
|
||
return ""
|
||
}
|
||
|
||
text, _ := advPickFlavor(pool, userID, category)
|
||
|
||
// Compute reset time (next midnight UTC)
|
||
now := time.Now().UTC()
|
||
midnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
||
remaining := midnight.Sub(now)
|
||
hours := int(remaining.Hours())
|
||
minutes := int(remaining.Minutes()) % 60
|
||
|
||
timeUntil := fmt.Sprintf("%dh %dm", hours, minutes)
|
||
|
||
return advSubstituteFlavor(text, map[string]string{
|
||
"{location}": location,
|
||
"{reset_time}": "00:00 UTC",
|
||
"{time_until}": timeUntil,
|
||
"{morning_time}": fmt.Sprintf("%02d:00", morningHour),
|
||
"{summary_time}": fmt.Sprintf("%02d:00", summaryHour),
|
||
})
|
||
}
|
||
|
||
// ── Death Status DM ──────────────────────────────────────────────────────────
|
||
|
||
func renderAdvDeathStatusDM(char *AdventureCharacter) string {
|
||
cost := int64(char.CombatLevel) * 25_000
|
||
remaining := "unknown"
|
||
if char.DeadUntil != nil {
|
||
remaining = char.DeadUntil.Format("15:04")
|
||
}
|
||
return fmt.Sprintf("💀 You're still dead.\n\n"+
|
||
"🏥 Type `!hospital` for same-day revival (€%d after insurance)\n"+
|
||
"⏳ Or wait — natural respawn at %s UTC",
|
||
cost, remaining)
|
||
}
|
||
|
||
// ── Respawn DM ───────────────────────────────────────────────────────────────
|
||
|
||
func renderAdvRespawnDM(char *AdventureCharacter) string {
|
||
text, _ := advPickFlavor(RespawnDM, char.UserID, "respawn_dm")
|
||
return advSubstituteFlavor(text, map[string]string{
|
||
"{name}": char.DisplayName,
|
||
})
|
||
}
|
||
|
||
// ── Treasure List ───────────────────────────────────────────────────────────
|
||
|
||
// lookupAdvTreasureDef returns the canonical definition for a treasure key,
|
||
// or nil if unknown. Walks the tiered table; cheap because the total count
|
||
// is small.
|
||
func lookupAdvTreasureDef(key string) *AdvTreasureDef {
|
||
for _, defs := range advAllTreasures {
|
||
for i := range defs {
|
||
if defs[i].Key == key {
|
||
d := defs[i]
|
||
return &d
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// renderAdvTreasureList renders the standalone treasure listing for
|
||
// `!adventure treasures`. Shows each treasure, marks irreplaceable ones,
|
||
// and surfaces the lock state so players know whether new drops will
|
||
// auto-swap or be refused.
|
||
func renderAdvTreasureList(treasures []AdvTreasureDef, locked bool) string {
|
||
var sb strings.Builder
|
||
sb.WriteString("💎 **Your Treasures**")
|
||
if locked {
|
||
sb.WriteString(" 🔒 _(locked — drops at cap refused)_")
|
||
}
|
||
sb.WriteString("\n\n")
|
||
|
||
if len(treasures) == 0 {
|
||
sb.WriteString("_No treasures yet. Higher-tier locations have higher drop rates._")
|
||
return sb.String()
|
||
}
|
||
|
||
for _, t := range treasures {
|
||
t := t
|
||
marker := ""
|
||
if advTreasureIrreplaceable(&t) {
|
||
marker = " 🛡️"
|
||
}
|
||
sb.WriteString(fmt.Sprintf(" • Tier %d · %s%s\n _%s_\n", t.Tier, t.Name, marker, t.InventoryDesc))
|
||
}
|
||
|
||
if locked {
|
||
sb.WriteString("\n_Unlock with `!adventure treasures unlock` to allow higher-tier auto-swaps._")
|
||
} else {
|
||
sb.WriteString("\n_Higher-tier drops auto-swap your lowest replaceable treasure (10-min undo). 🛡️ = irreplaceable, never auto-discarded._\n_Lock with `!adventure treasures lock` to refuse drops at cap._")
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
// renderAdvMasteryAggregate produces the one-line summary shown under the
|
||
// equipment block on the character sheet. Three states:
|
||
// - Bonus active: shows percent, threshold count, cap-reached when at +10%.
|
||
// - No bonus but some progress: hints that a threshold is approaching.
|
||
// - No progress: empty (don't bloat sheets for new players).
|
||
func renderAdvMasteryAggregate(equip map[EquipmentSlot]*AdvEquipment) string {
|
||
r := advMasteryRollup(equip)
|
||
if !r.AnyProgress {
|
||
return ""
|
||
}
|
||
if r.Bonus > 0 {
|
||
base := fmt.Sprintf("🎯 Mastery: +%.0f%% loot quality · %d threshold%s crossed",
|
||
r.Bonus, r.ThresholdsCrossed, plural(r.ThresholdsCrossed))
|
||
if r.MaxedSlots > 0 {
|
||
base += fmt.Sprintf(" · %d slot%s maxed", r.MaxedSlots, plural(r.MaxedSlots))
|
||
}
|
||
// Cap is +10%; the raw count keeps rising past it. Flag when the
|
||
// player is paying threshold tax for nothing.
|
||
if r.Bonus >= 10 {
|
||
base += " (cap reached)"
|
||
}
|
||
base += " · `!adventure mastery`"
|
||
return base
|
||
}
|
||
// Some progress, no thresholds crossed yet — surface the next gate.
|
||
return "🎯 Mastery: building up — first threshold at 50 actions/slot · `!adventure mastery`"
|
||
}
|
||
|
||
// ── Equipment Mastery View ──────────────────────────────────────────────────
|
||
|
||
// renderAdvMasteryView renders a per-slot mastery readout: progress bar,
|
||
// tier marker, and next threshold so players can see "where am I" between
|
||
// the discrete celebration DMs that fire at threshold crossings.
|
||
func renderAdvMasteryView(equip map[EquipmentSlot]*AdvEquipment) string {
|
||
var sb strings.Builder
|
||
sb.WriteString("🎯 **Equipment Mastery**\n\n")
|
||
|
||
any := false
|
||
for _, slot := range allSlots {
|
||
eq := equip[slot]
|
||
if eq == nil {
|
||
sb.WriteString(fmt.Sprintf(" %s %s — _empty_\n", slotEmoji(slot), slotTitle(slot)))
|
||
continue
|
||
}
|
||
any = true
|
||
tier, next := advMasteryTier(eq.ActionsUsed)
|
||
marker := advMasteryMarker(eq.ActionsUsed)
|
||
if marker == "" {
|
||
marker = "·"
|
||
}
|
||
|
||
// Bar against the next threshold (or against 250 when at max so the
|
||
// bar visually maxes out rather than disappearing).
|
||
var barTotal int
|
||
if next > 0 {
|
||
barTotal = next
|
||
} else {
|
||
barTotal = advMasteryThresholds[len(advMasteryThresholds)-1]
|
||
}
|
||
bar := masteryBar(eq.ActionsUsed, barTotal)
|
||
_ = tier
|
||
|
||
if next > 0 {
|
||
sb.WriteString(fmt.Sprintf(" %s %s · %s · %d/%d %s %s\n",
|
||
slotEmoji(slot), slotTitle(slot), eq.Name, eq.ActionsUsed, next, bar, marker))
|
||
} else {
|
||
sb.WriteString(fmt.Sprintf(" %s %s · %s · %d (max) %s %s\n",
|
||
slotEmoji(slot), slotTitle(slot), eq.Name, eq.ActionsUsed, bar, marker))
|
||
}
|
||
}
|
||
|
||
bonus := advEquipmentMasteryBonus(equip)
|
||
sb.WriteString("\n")
|
||
if !any {
|
||
sb.WriteString("_No equipment yet — gear up first._")
|
||
return sb.String()
|
||
}
|
||
if bonus > 0 {
|
||
sb.WriteString(fmt.Sprintf("**Active mastery bonus: +%.0f%% loot quality** (cap +10%%)\n", bonus))
|
||
} else {
|
||
sb.WriteString("_No mastery bonus yet — first threshold at 50 actions per slot._\n")
|
||
}
|
||
sb.WriteString("Thresholds: 50 ✦ · 100 ✦✦ · 250 ✦✦✦. Each crossed threshold per slot adds +1% loot quality.")
|
||
return sb.String()
|
||
}
|
||
|
||
// masteryBar builds a 10-cell progress bar for the mastery view.
|
||
func masteryBar(value, total int) string {
|
||
if total <= 0 {
|
||
return "▱▱▱▱▱▱▱▱▱▱"
|
||
}
|
||
filled := value * 10 / total
|
||
if filled > 10 {
|
||
filled = 10
|
||
}
|
||
if filled < 0 {
|
||
filled = 0
|
||
}
|
||
return strings.Repeat("▰", filled) + strings.Repeat("▱", 10-filled)
|
||
}
|
||
|
||
// ── Auto-Babysit DM ─────────────────────────────────────────────────────────
|
||
|
||
// renderAutoBabysitDM builds the morning notification when auto-babysit
|
||
// covered an idle day. Surfaces what the babysitter actually accomplished
|
||
// (skill focus, gold/XP, items) plus a flavor highlight on lucky days, so
|
||
// the system feels like an active companion instead of a silent debit.
|
||
func renderAutoBabysitDM(daily int, streak int, res AutoBabysitDayResult) string {
|
||
var sb strings.Builder
|
||
sb.WriteString(fmt.Sprintf("🍼 **Auto-babysit activated** — €%d deducted. Your streak is safe at %d days.\n", daily, streak))
|
||
if res.Skill != "" {
|
||
sb.WriteString(fmt.Sprintf("Focus: %s · €%d earned · %d XP", res.Skill, res.Gold, res.XP))
|
||
if len(res.Items) > 0 {
|
||
sb.WriteString(fmt.Sprintf(" · items: %s", strings.Join(res.Items, ", ")))
|
||
}
|
||
sb.WriteString("\n")
|
||
}
|
||
if res.Highlight && res.Skill != "" {
|
||
line := pickBabysitFlavor(babysitHighlightLines)
|
||
if line != "" {
|
||
sb.WriteString("\n_" + fmt.Sprintf(line, res.Skill) + "_")
|
||
}
|
||
}
|
||
return strings.TrimRight(sb.String(), "\n")
|
||
}
|
||
|
||
// ── Idle Shame DM ────────────────────────────────────────────────────────────
|
||
|
||
func renderAdvIdleShameDM(char *AdventureCharacter) string {
|
||
text, _ := advPickFlavor(IdleShameDM, char.UserID, "idle_shame")
|
||
return advSubstituteFlavor(text, map[string]string{
|
||
"{name}": char.DisplayName,
|
||
})
|
||
}
|
||
|
||
// ── Onboarding DM ────────────────────────────────────────────────────────────
|
||
|
||
func renderAdvOnboardingDM(char *AdventureCharacter) string {
|
||
text, _ := advPickFlavor(OnboardingDM, char.UserID, "onboarding")
|
||
return advSubstituteFlavor(text, map[string]string{
|
||
"{name}": char.DisplayName,
|
||
})
|
||
}
|
||
|
||
// ── Daily Summary ────────────────────────────────────────────────────────────
|
||
|
||
type AdvPlayerDaySummary struct {
|
||
DisplayName string
|
||
CombatLevel int
|
||
MiningSkill int
|
||
ForagingSkill int
|
||
FishingSkill int
|
||
Activity string
|
||
Location string
|
||
Outcome string
|
||
LootValue int64
|
||
IsDead bool
|
||
DeadUntil string
|
||
IsResting bool
|
||
SummaryLine string
|
||
HolidayActions int // 0 = not holiday or no action; 1 = took one; 2 = took both
|
||
DeathSource string
|
||
DeathLocation string
|
||
}
|
||
|
||
func renderAdvDailySummary(date string, tb *TwinBeeResult, tbRewards TwinBeeRewardSummary, players []AdvPlayerDaySummary, holidayName string) string {
|
||
var sb strings.Builder
|
||
|
||
sb.WriteString(fmt.Sprintf("📜 **ADVENTURER DAILY REPORT**\n%s\n\n", date))
|
||
|
||
if holidayName != "" {
|
||
sb.WriteString(fmt.Sprintf("🎉 In recognition of **%s**, adventurers had two actions today.\n\n", holidayName))
|
||
}
|
||
|
||
// TwinBee section
|
||
if tb != nil {
|
||
sb.WriteString("🐝 **TwinBee's Daily Report**\n")
|
||
sb.WriteString(fmt.Sprintf("Went to: %s (Tier %d)\n", tb.Location.Name, tb.Location.Tier))
|
||
|
||
// One-liner for TwinBee
|
||
var tbSummaryPool []string
|
||
switch tb.Outcome {
|
||
case AdvOutcomeSuccess, AdvOutcomeExceptional:
|
||
tbSummaryPool = TwinBeeSummarySuccess
|
||
case AdvOutcomeEmpty, AdvOutcomeHornets, AdvOutcomeCaveIn, AdvOutcomeBear, AdvOutcomeRiver:
|
||
tbSummaryPool = TwinBeeSummaryWithdrawal
|
||
default:
|
||
tbSummaryPool = TwinBeeSummaryEmpty
|
||
}
|
||
if len(tbSummaryPool) > 0 {
|
||
line := tbSummaryPool[rand.IntN(len(tbSummaryPool))]
|
||
line = advSubstituteFlavor(line, map[string]string{
|
||
"{location}": tb.Location.Name,
|
||
"{loot}": tb.LootDesc,
|
||
"{value}": fmt.Sprintf("%d", tb.LootValue),
|
||
})
|
||
sb.WriteString(fmt.Sprintf("Outcome: %s\n", line))
|
||
}
|
||
|
||
if tb.LootValue > 0 {
|
||
sb.WriteString(fmt.Sprintf("Haul: €%d in %s\n", tb.LootValue, tb.LootDesc))
|
||
} else {
|
||
sb.WriteString("Haul: Strategic withdrawal. No haul.\n")
|
||
}
|
||
|
||
sb.WriteString("\n")
|
||
if tbRewards.Eligible > 0 {
|
||
hasRewards := tbRewards.GoldShare > 0 || tbRewards.GiftCount > 0
|
||
if hasRewards {
|
||
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers (scaled by level):\n", tbRewards.Eligible))
|
||
if tbRewards.GoldShare > 0 {
|
||
sb.WriteString(fmt.Sprintf(" 💰 ~€%d avg\n", tbRewards.GoldShare))
|
||
}
|
||
if tbRewards.GiftCount > 0 {
|
||
sb.WriteString(fmt.Sprintf(" ⭐ %d players received a gift item\n", tbRewards.GiftCount))
|
||
}
|
||
} else {
|
||
sb.WriteString(fmt.Sprintf("Rewards distributed to %d participating adventurers: jackshit.\n", tbRewards.Eligible))
|
||
}
|
||
}
|
||
sb.WriteString("\n(Players who rested today received nothing. Fallen adventurers still earn their share. TwinBee noticed.)\n\n")
|
||
sb.WriteString("───────────────────\n\n")
|
||
}
|
||
|
||
// Player summaries
|
||
var dead []AdvPlayerDaySummary
|
||
var resting []AdvPlayerDaySummary
|
||
var bestPlayer *AdvPlayerDaySummary
|
||
var worstPlayer *AdvPlayerDaySummary
|
||
|
||
for i := range players {
|
||
p := &players[i]
|
||
if p.IsDead {
|
||
dead = append(dead, *p)
|
||
// Dead players who acted today still show in the main section.
|
||
// If they died OUTSIDE the adventure (e.g. Arena), the adventure
|
||
// itself was successful — show the alive icon for that block and
|
||
// append a "later fell" note. Empty DeathSource is legacy and
|
||
// treated as adventure-death (current behavior).
|
||
if p.Location != "" {
|
||
diedOnAdventure := p.DeathSource == "" || p.DeathSource == "adventure"
|
||
icon := "💀"
|
||
if !diedOnAdventure {
|
||
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))
|
||
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
|
||
sb.WriteString(fmt.Sprintf(" Outcome: %s\n", p.SummaryLine))
|
||
if !diedOnAdventure {
|
||
where := p.DeathLocation
|
||
if where == "" {
|
||
where = "elsewhere"
|
||
}
|
||
sb.WriteString(fmt.Sprintf(" 💀 Later fell in %s.\n", where))
|
||
}
|
||
sb.WriteString("\n")
|
||
if worstPlayer == nil {
|
||
worstPlayer = p
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
if p.IsResting {
|
||
resting = append(resting, *p)
|
||
continue
|
||
}
|
||
|
||
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))
|
||
if p.Location != "" {
|
||
sb.WriteString(fmt.Sprintf(" Went to: %s\n", p.Location))
|
||
sb.WriteString(fmt.Sprintf(" Outcome: %s\n\n", p.SummaryLine))
|
||
} else {
|
||
sb.WriteString(" Acted today — no log recorded.\n\n")
|
||
}
|
||
|
||
if bestPlayer == nil || p.LootValue > bestPlayer.LootValue {
|
||
bestPlayer = p
|
||
}
|
||
}
|
||
|
||
// Dead list (players who didn't act today but are still dead from before)
|
||
var deadNoAction []AdvPlayerDaySummary
|
||
for _, d := range dead {
|
||
if d.Location == "" {
|
||
deadNoAction = append(deadNoAction, d)
|
||
}
|
||
}
|
||
if len(deadNoAction) > 0 {
|
||
sb.WriteString("💀 **Currently Dead:**\n")
|
||
for _, d := range deadNoAction {
|
||
sb.WriteString(fmt.Sprintf(" %s — returns %s\n", d.DisplayName, d.DeadUntil))
|
||
}
|
||
sb.WriteString("\n")
|
||
}
|
||
|
||
// Resting list
|
||
if len(resting) > 0 {
|
||
sb.WriteString("😴 **Resting/Idle:**\n")
|
||
for _, r := range resting {
|
||
sb.WriteString(fmt.Sprintf(" %s\n", r.DisplayName))
|
||
}
|
||
sb.WriteString("\n")
|
||
}
|
||
|
||
// Holiday stats
|
||
if holidayName != "" {
|
||
tookBoth := 0
|
||
totalActive := 0
|
||
for _, p := range players {
|
||
if p.IsDead || p.IsResting {
|
||
if p.HolidayActions > 0 {
|
||
totalActive++
|
||
}
|
||
if p.HolidayActions >= 2 {
|
||
tookBoth++
|
||
}
|
||
continue
|
||
}
|
||
if p.Activity != "" {
|
||
totalActive++
|
||
}
|
||
if p.HolidayActions >= 2 {
|
||
tookBoth++
|
||
}
|
||
}
|
||
if totalActive > 0 {
|
||
sb.WriteString(fmt.Sprintf("🎉 %s double-action day — %d of %d adventurers took both actions.\n\n", holidayName, tookBoth, totalActive))
|
||
}
|
||
|
||
// Note players who died before their second action
|
||
for _, d := range dead {
|
||
if d.HolidayActions == 1 {
|
||
sb.WriteString(fmt.Sprintf("• %s — died in %s before their second action. Rough holiday.\n", d.DisplayName, d.Location))
|
||
}
|
||
}
|
||
if len(dead) > 0 {
|
||
// Check if any had HolidayActions == 1
|
||
for _, d := range dead {
|
||
if d.HolidayActions == 1 {
|
||
sb.WriteString("\n")
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Standout
|
||
if bestPlayer != nil && bestPlayer.LootValue > 0 {
|
||
pool := SummaryStandoutGood
|
||
if len(pool) > 0 {
|
||
line := pool[rand.IntN(len(pool))]
|
||
line = advSubstituteFlavor(line, map[string]string{
|
||
"{name}": bestPlayer.DisplayName,
|
||
"{item}": "",
|
||
"{value}": fmt.Sprintf("%d", bestPlayer.LootValue),
|
||
"{location}": bestPlayer.Location,
|
||
})
|
||
sb.WriteString(fmt.Sprintf("🏆 **Today's standout:** %s\n", line))
|
||
}
|
||
} else if worstPlayer != nil {
|
||
pool := SummaryStandoutDeath
|
||
if len(pool) > 0 {
|
||
line := pool[rand.IntN(len(pool))]
|
||
lossLoc := worstPlayer.DeathLocation
|
||
if lossLoc == "" {
|
||
lossLoc = worstPlayer.Location
|
||
}
|
||
line = advSubstituteFlavor(line, map[string]string{
|
||
"{name}": worstPlayer.DisplayName,
|
||
"{location}": lossLoc,
|
||
})
|
||
sb.WriteString(fmt.Sprintf("🏆 **Today's standout:** %s\n", line))
|
||
}
|
||
}
|
||
|
||
return sb.String()
|
||
}
|
||
|
||
// ── Leaderboard ──────────────────────────────────────────────────────────────
|
||
|
||
func renderAdvLeaderboard(chars []AdventureCharacter) string {
|
||
if len(chars) == 0 {
|
||
return "No adventurers registered yet. Type `!adventure` to begin."
|
||
}
|
||
|
||
// Sort by score
|
||
type entry struct {
|
||
Name string
|
||
Score int
|
||
Levels string
|
||
Streak int
|
||
}
|
||
var entries []entry
|
||
for _, c := range chars {
|
||
score := (c.CombatLevel + c.MiningSkill + c.ForagingSkill + c.FishingSkill) * 10
|
||
entries = append(entries, entry{
|
||
Name: c.DisplayName,
|
||
Score: score,
|
||
Levels: fmt.Sprintf("⚔️%d ⛏️%d 🌿%d 🎣%d", c.CombatLevel, c.MiningSkill, c.ForagingSkill, c.FishingSkill),
|
||
Streak: c.CurrentStreak,
|
||
})
|
||
}
|
||
|
||
// Simple sort (small list)
|
||
for i := range entries {
|
||
for j := i + 1; j < len(entries); j++ {
|
||
if entries[j].Score > entries[i].Score {
|
||
entries[i], entries[j] = entries[j], entries[i]
|
||
}
|
||
}
|
||
}
|
||
|
||
var sb strings.Builder
|
||
sb.WriteString("🏆 **Adventure Leaderboard**\n\n")
|
||
|
||
limit := 10
|
||
if len(entries) < limit {
|
||
limit = len(entries)
|
||
}
|
||
|
||
medals := []string{"🥇", "🥈", "🥉"}
|
||
for i := 0; i < limit; i++ {
|
||
e := entries[i]
|
||
prefix := fmt.Sprintf("%2d.", i+1)
|
||
if i < len(medals) {
|
||
prefix = medals[i]
|
||
}
|
||
streak := ""
|
||
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))
|
||
}
|
||
|
||
return sb.String()
|
||
}
|
||
|
||
// ── Treasure Discard Prompt ──────────────────────────────────────────────────
|
||
|
||
func renderAdvTreasureDiscardPrompt(newTreasure *AdvTreasureDef, existing []AdvTreasureDef) string {
|
||
if len(TreasureInventoryCap) == 0 {
|
||
return "You found a treasure but your inventory is full. Reply 1, 2, or 3 to discard, or `keep`."
|
||
}
|
||
|
||
// Build substitution map with existing treasure info
|
||
subs := map[string]string{
|
||
"{treasure_name}": newTreasure.Name,
|
||
"{location}": "",
|
||
}
|
||
for i := 0; i < 3; i++ {
|
||
key := fmt.Sprintf("%d", i+1)
|
||
if i < len(existing) {
|
||
subs["{treasure_"+key+"}"] = existing[i].Name
|
||
subs["{bonus_"+key+"}"] = existing[i].InventoryDesc
|
||
} else {
|
||
subs["{treasure_"+key+"}"] = "(empty)"
|
||
subs["{bonus_"+key+"}"] = ""
|
||
}
|
||
}
|
||
|
||
text := TreasureInventoryCap[rand.IntN(len(TreasureInventoryCap))]
|
||
return advSubstituteFlavor(text, subs)
|
||
}
|
||
|
||
// ── Summary One-Liners ───────────────────────────────────────────────────────
|
||
|
||
func advSummaryOneLiner(userID id.UserID, activity AdvActivityType, outcome AdvOutcomeType, lootValue int64, location string) string {
|
||
var pool []string
|
||
|
||
switch activity {
|
||
case AdvActivityDungeon:
|
||
switch outcome {
|
||
case AdvOutcomeDeath:
|
||
pool = SummaryDungeonDeath
|
||
case AdvOutcomeEmpty:
|
||
pool = SummaryDungeonEmpty
|
||
case AdvOutcomeSuccess:
|
||
pool = SummaryDungeonSuccess
|
||
case AdvOutcomeExceptional:
|
||
pool = SummaryDungeonExceptional
|
||
}
|
||
case AdvActivityMining:
|
||
switch outcome {
|
||
case AdvOutcomeDeath:
|
||
pool = SummaryMiningDeath
|
||
case AdvOutcomeEmpty, AdvOutcomeCaveIn:
|
||
pool = SummaryMiningEmpty
|
||
case AdvOutcomeSuccess, AdvOutcomeExceptional:
|
||
pool = SummaryMiningSuccess
|
||
}
|
||
case AdvActivityForaging:
|
||
switch outcome {
|
||
case AdvOutcomeDeath:
|
||
pool = SummaryForagingDeath
|
||
case AdvOutcomeHornets:
|
||
pool = SummaryForagingHornets
|
||
case AdvOutcomeBear:
|
||
pool = SummaryForagingBear
|
||
case AdvOutcomeRiver:
|
||
pool = SummaryForagingRiver
|
||
case AdvOutcomeEmpty:
|
||
pool = SummaryForagingEmpty
|
||
case AdvOutcomeSuccess, AdvOutcomeExceptional:
|
||
pool = SummaryForagingSuccess
|
||
}
|
||
case AdvActivityFishing:
|
||
switch outcome {
|
||
case AdvOutcomeDeath:
|
||
pool = SummaryFishingDeath
|
||
case AdvOutcomeEmpty:
|
||
pool = SummaryFishingEmpty
|
||
case AdvOutcomeSuccess, AdvOutcomeExceptional:
|
||
pool = SummaryFishingSuccess
|
||
}
|
||
}
|
||
|
||
if len(pool) == 0 {
|
||
return fmt.Sprintf("Went to %s. Things happened.", location)
|
||
}
|
||
|
||
text, _ := advPickFlavor(pool, userID, fmt.Sprintf("summary_%s_%s", activity, outcome))
|
||
return advSubstituteFlavor(text, map[string]string{
|
||
"{name}": "",
|
||
"{item}": "",
|
||
"{value}": fmt.Sprintf("%d", lootValue),
|
||
"{location}": location,
|
||
"{hours}": "24",
|
||
"{ore}": "",
|
||
"{tool}": "",
|
||
"{xp}": "",
|
||
})
|
||
}
|