Files
gogobee/internal/plugin/adventure.go
prosolis 8ad31a0009 Add co-op dungeon system (party runs, voting, betting, gifts, items)
Multi-day party runs with funding decisions, TwinBee-narrated floor events,
spectator parimutuel betting, basket/mimic gift system, and weighted-roll
item distribution including masterwork drops at T4-T5.

Schema (5 tables, 2 fewer than spec by computing helpfulness on-the-fly and
skipping the loot-pending state):
- coop_dungeon_runs / _members (daily funding as JSON column)
- coop_dungeon_events (votes as JSON, used to derive TwinBee helpfulness)
- coop_dungeon_bets / _gifts

Mechanics:
- 2-4 player parties, 24h invite window, locks consume one combat action
- Per-floor success roll: base + sum(funding) + level/pet bonuses + event
  vote modifier + active gift modifiers, clamped to 5..95
- Funding tiers (none/min/std/agg/all_in) with liability cap at +8% for
  under-leveled players
- TwinBee narrates events from existing flavor pool; 20 authored events with
  per-option modifiers and embedded recommendation
- Parimutuel betting with 10% rake; odds line shown on lock/daily/status posts
- Gift modifiers symmetric at 50/50 sender mix so no dominant strategy
- Item drops on success via weight-by-contribution roll; T4 25% / T5 100%
  masterwork chance (random pick from existing T4/T5 defs)

Balance via Monte Carlo (coop_dungeon_balance_test.go):
- All tiers exceed 1.5x solo daily income at average party profile
- Optimal funding strategy walks up tiers correctly (Min/Std/Std/Mixed/Agg)
- All-In stays -EV at every tier (boost lever, not optimal play)

Tests: parsing, liability cap, JSON roundtrips, vote tally with leader
tiebreak, event meta consistency, parimutuel payouts, gift EV symmetry,
weighted-roll distribution (Monte Carlo), masterwork tier gates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 07:58:13 -07:00

1356 lines
45 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"strconv"
"strings"
"sync"
"time"
"gogobee/internal/db"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// ── Plugin ───────────────────────────────────────────────────────────────────
type AdventurePlugin struct {
Base
euro *EuroPlugin
xp *XPPlugin
achievements *AchievementsPlugin
mu sync.Mutex
dmToPlayer map[id.RoomID]id.UserID
pending sync.Map // userID string -> *advPendingInteraction
userLocks sync.Map // userID string -> *sync.Mutex
dmRemindedDate sync.Map // userID string -> "2006-01-02" date string
dmMenuSentAt sync.Map // userID string -> time.Time (last time actionable menu was DM'd)
arenaDeadlines sync.Map // userID string -> time.Time (auto-cashout deadline)
arenaPending sync.Map // userID string -> int (pending tier number awaiting confirmation)
arenaBailCh sync.Map // userID string -> chan struct{} (bail signal for countdown goroutine)
shopSessions sync.Map // userID string -> *advShopSession
hospitalNudges sync.Map // userID string -> time.Time (when to send nudge)
craftResults sync.Map // userID string -> []CraftResult (pending craft results for narrative)
morningHour int
summaryHour int
}
// advUserLock returns a per-user mutex to prevent concurrent action resolution.
func (p *AdventurePlugin) advUserLock(userID id.UserID) *sync.Mutex {
val, _ := p.userLocks.LoadOrStore(string(userID), &sync.Mutex{})
return val.(*sync.Mutex)
}
const advDMResponseWindow = 3 * time.Hour
// advMarkMenuSent records that an actionable adventure menu was DM'd to the user.
// Only bare-number DM replies within this window will be treated as adventure choices.
func (p *AdventurePlugin) advMarkMenuSent(userID id.UserID) {
p.dmMenuSentAt.Store(string(userID), time.Now())
}
// advIsInResponseWindow returns true if the user was recently sent an actionable menu.
func (p *AdventurePlugin) advIsInResponseWindow(userID id.UserID) bool {
val, ok := p.dmMenuSentAt.Load(string(userID))
if !ok {
return false
}
return time.Since(val.(time.Time)) < advDMResponseWindow
}
type advPendingInteraction struct {
Type string // "treasure_discard"
Data interface{}
ExpiresAt time.Time
}
type advPendingTreasureDiscard struct {
NewTreasure *AdvTreasureDef
Existing []AdvTreasureDef
}
func NewAdventurePlugin(client *mautrix.Client, euro *EuroPlugin, xp *XPPlugin) *AdventurePlugin {
return &AdventurePlugin{
Base: NewBase(client),
euro: euro,
xp: xp,
dmToPlayer: make(map[id.RoomID]id.UserID),
morningHour: envInt("ADVENTURE_MORNING_HOUR", 8),
summaryHour: envInt("ADVENTURE_SUMMARY_HOUR", 20),
}
}
// chatLevel returns the user's chat level for perk calculations.
func (p *AdventurePlugin) chatLevel(userID id.UserID) int {
if p.xp == nil {
return 0
}
return p.xp.GetLevel(userID)
}
func (p *AdventurePlugin) Name() string { return "adventure" }
func (p *AdventurePlugin) Version() string { return "2.5.0" }
// SetAchievements wires the achievements plugin after both are initialized.
func (p *AdventurePlugin) SetAchievements(ach *AchievementsPlugin) {
p.achievements = ach
}
func (p *AdventurePlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "adventure", Description: "Daily adventure game — dungeon, mine, forage, or rest", Usage: "!adventure", Category: "Games"},
{Name: "arena", Description: "Arena combat — fight through 5 tiers of increasingly deadly monsters", Usage: "!arena", Category: "Games"},
{Name: "thom", Description: "Visit Thom Krooke — housing and loans", Usage: "!thom", Category: "Games"},
}
}
func (p *AdventurePlugin) Init() error {
// Rehydrate DM room mappings for existing characters
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Warn("adventure: no characters to rehydrate", "err", err)
} else {
for _, c := range chars {
p.registerDMRoom(c.UserID)
}
slog.Info("adventure: rehydrated DM rooms", "count", len(chars))
}
// Always reset daily actions at startup — idempotent (WHERE clause
// only touches characters whose last_action_date < today). This handles
// the case where the old buggy code marked the midnight job as completed
// even though the actual reset failed due to SQLite contention.
if err := resetAllAdvDailyActions(); err != nil {
slog.Error("adventure: startup daily reset failed", "err", err)
}
// Revive any characters whose DeadUntil has expired
p.catchUpRespawns(chars)
// Start schedulers
go p.morningTicker()
go p.summaryTicker()
go p.midnightTicker()
go p.eventTicker()
// Arena auto-cashout ticker removed — replaced by per-session countdown goroutines
go p.rivalChallengeTicker()
go p.robbieTicker()
go p.hospitalNudgeTicker()
go p.mortgageTicker()
go p.coopTicker()
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
p.arenaCleanupStaleRuns()
return nil
}
func (p *AdventurePlugin) catchUpRespawns(chars []AdventureCharacter) {
now := time.Now().UTC()
for _, char := range chars {
if !char.Alive && char.DeadUntil != nil && now.After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(&char); err != nil {
slog.Error("adventure: catch-up revive failed", "user", char.UserID, "err", err)
continue
}
slog.Info("adventure: catch-up revived player", "user", char.UserID)
text := renderAdvRespawnDM(&char)
if err := p.SendDM(char.UserID, text); err != nil {
slog.Error("adventure: catch-up respawn DM failed", "user", char.UserID, "err", err)
}
}
}
}
func (p *AdventurePlugin) OnReaction(_ ReactionContext) error { return nil }
// ── Message Dispatch ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) OnMessage(ctx MessageContext) error {
// 1. Arena commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "bail") {
return p.handleArenaBail(ctx)
}
if p.IsCommand(ctx.Body, "arena") {
return p.dispatchArenaCommand(ctx)
}
// 1b. Hospital commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "hospital") {
return p.handleHospitalCmd(ctx)
}
// 1c. Co-op dungeon commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "coop") {
return p.handleCoopCmd(ctx)
}
// 2. Check if this is a DM reply from a registered player
p.mu.Lock()
playerID, isDM := p.dmToPlayer[ctx.RoomID]
p.mu.Unlock()
if isDM && playerID == ctx.Sender {
return p.handleDMReply(ctx)
}
// 3. NPC encounter tracking — count room messages from adventure players
if !isDM && !strings.HasPrefix(ctx.Body, "!") {
safeGo("npc-track", func() {
p.npcTrackMessage(ctx.Sender)
})
}
// 4. Thom Krooke commands (work in rooms and DMs)
if p.IsCommand(ctx.Body, "thom") {
return p.handleThomCmd(ctx)
}
// 5. Command dispatch
if !p.IsCommand(ctx.Body, "adventure") && !p.IsCommand(ctx.Body, "adv") {
return nil
}
return p.dispatchCommand(ctx)
}
func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "adventure"))
if args == "" && p.IsCommand(ctx.Body, "adv") {
args = strings.TrimSpace(p.GetArgs(ctx.Body, "adv"))
}
lower := strings.ToLower(args)
switch {
case args == "" || lower == "menu":
return p.handleMenu(ctx)
case lower == "status":
return p.handleStatus(ctx)
case strings.HasPrefix(lower, "sell "):
return p.handleSellCmd(ctx, strings.TrimSpace(args[5:]))
case lower == "shop" || strings.HasPrefix(lower, "shop "):
return p.handleShopCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "shop")))
case strings.HasPrefix(lower, "buy "):
return p.handleBuyCmd(ctx, strings.TrimSpace(args[4:]))
case lower == "equip":
return p.handleEquipCmd(ctx)
case lower == "inventory" || lower == "inv":
return p.handleInventoryCmd(ctx)
case lower == "leaderboard" || lower == "lb":
return p.handleLeaderboard(ctx)
case strings.HasPrefix(lower, "revive "):
return p.handleAdminRevive(ctx, strings.TrimSpace(args[7:]))
case lower == "summary":
return p.handleAdminSummary(ctx)
case lower == "respond":
return p.handleEventRespond(ctx)
case lower == "help":
return p.SendDM(ctx.Sender, advHelpText)
case lower == "rivals":
return p.handleRivalsCmd(ctx)
case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit")))
case lower == "blacksmith" || lower == "repair":
return p.handleBlacksmithCmd(ctx)
case lower == "repair all":
return p.handleRepairAllCmd(ctx)
case strings.HasPrefix(lower, "repair "):
return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:]))
case lower == "boost":
return p.handleBoostCmd(ctx)
}
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
}
const advHelpText = `**Adventure Commands**
` + "`!adventure`" + ` — Show today's activity menu
` + "`!adventure status`" + ` — View your character sheet
` + "`!adventure shop`" + ` — Browse equipment categories
` + "`!adventure shop <category>`" + ` — View a category (weapon, armor, helmet, boots, tool)
` + "`!adventure buy <item>`" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `)
` + "`!adventure equip`" + ` — Equip Masterwork gear from inventory
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
` + "`!adventure inventory`" + ` — View your inventory
` + "`!adventure leaderboard`" + ` — View the leaderboard
` + "`!adventure respond`" + ` — Respond to a mid-day event
` + "`!adventure rivals`" + ` — View rival duel records
` + "`!adventure babysit`" + ` — Adventurer Babysitting Service
` + "`!adventure babysit auto`" + ` — Toggle auto-babysit (protects streaks on missed days)
` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs)
` + "`!adventure repair all`" + ` — Repair all damaged equipment
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
` + "`!coop`" + ` — Co-op dungeons (multi-day party runs). See ` + "`!coop help`" + `.
` + "`!hospital`" + ` — Visit St. Guildmore's Memorial Hospital (same-day revival when dead)
` + "`!thom`" + ` — Visit Thom Krooke (housing and loans)
` + "`!adventure help`" + ` — This message
**Arena:**
` + "`!arena`" + ` — Show arena tier menu
` + "`!arena tier <1-5>`" + ` — Enter a tier
` + "`!arena fight`" + ` — Fight current round
` + "`!arena descend`" + ` — Descend to next tier (keep earnings at risk)
` + "`!arena cashout`" + ` — Take earnings and leave
` + "`!arena status`" + ` — Current run state
` + "`!arena leaderboard`" + ` — Top arena players
**Systems:**
• Foraging level 10+ unlocks auto-crafting consumables from loot ingredients before combat.
• A 5% community tax funds the lottery pot. Arena and hospital apply 10%.
**In DM:** Reply with a number (e.g. ` + "`1`" + `) or location name to take your daily action.`
// ── Command Handlers ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleMenu(ctx MessageContext) error {
char, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
}
if !char.Alive {
// On-demand revive if death timer has expired
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(char); err != nil {
slog.Error("adventure: on-demand revive failed", "user", char.UserID, "err", err)
} else {
text := renderAdvRespawnDM(char)
p.SendDM(ctx.Sender, text)
// Fall through to show menu
}
}
if !char.Alive {
text := renderAdvDeathStatusDM(char)
return p.SendDM(ctx.Sender, text)
}
}
isHol, holName := isHolidayToday()
if char.AllActionsUsed(isHol) {
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
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You've used all your actions today. Tomorrow awaits. Try to survive it.\n\n"+
"Next action: 00:00 UTC (%dh %dm from now)\n"+
"Morning DM: %02d:00 UTC\n\n"+
"The Arena is always open: `!arena`",
hours, minutes, p.morningHour))
}
treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
balance := p.euro.GetBalance(char.UserID)
text := renderAdvMorningDM(char, equip, balance, bonuses, holName)
p.advMarkMenuSent(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleStatus(ctx MessageContext) error {
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No adventurer found. Type `!adventure` to create one.")
}
// On-demand revive if death timer has expired
if !char.Alive && char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
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))
}
}
equip, err := loadAdvEquipment(ctx.Sender)
if err != nil {
slog.Error("adventure: failed to load equipment for status", "user", ctx.Sender, "err", err)
}
items, err := loadAdvInventory(ctx.Sender)
if err != nil {
slog.Error("adventure: failed to load inventory for status", "user", ctx.Sender, "err", err)
}
treasures, err := loadAdvTreasureBonuses(ctx.Sender)
if err != nil {
slog.Error("adventure: failed to load treasures for status", "user", ctx.Sender, "err", err)
}
balance := p.euro.GetBalance(ctx.Sender)
text := renderAdvCharacterSheet(char, equip, items, treasures, balance)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleShopCmd(ctx MessageContext, args string) error {
_, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
showAll := strings.Contains(strings.ToLower(args), "show all")
category := strings.TrimSpace(strings.Replace(strings.ToLower(args), "show all", "", 1))
p.shopSessionStart(ctx.Sender)
if category == "" {
text := luigiShopGreeting(ctx.Sender, equip, balance, showAll, p.chatLevel(ctx.Sender))
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_category",
Data: &advPendingShopCategory{ShowAll: showAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
p.shopScheduleBrowseNudge(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
slot := advParseShopCategory(category)
if slot == "" {
return p.SendDM(ctx.Sender, fmt.Sprintf("Unknown category '%s'. Try: weapon, armor, helmet, boots, or tool.", category))
}
text := luigiCategoryView(ctx.Sender, slot, equip, balance, showAll)
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "shop_item",
Data: &advPendingShopItem{Slot: slot, ShowAll: showAll},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
p.advMarkMenuSent(ctx.Sender)
p.shopScheduleBrowseNudge(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleBuyCmd(ctx MessageContext, itemName string) error {
char, equip, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. Shopping can wait until you've respawned.")
}
if itemName == "" {
return p.SendDM(ctx.Sender, "Usage: `!buy <item name>`. Type `!adventure shop` to browse.")
}
slot, def, found := advFindShopItem(itemName)
if !found {
return p.SendDM(ctx.Sender, fmt.Sprintf("No item matching '%s' found in the shop. Type `!adventure shop` to see what's available.", itemName))
}
result := p.advBuyEquipment(ctx.Sender, slot, def, equip)
return p.SendDM(ctx.Sender, result)
}
func (p *AdventurePlugin) handleSellCmd(ctx MessageContext, args string) error {
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character. Try `!adventure` to create one first.")
}
if !char.Alive {
return p.SendDM(ctx.Sender, "You're dead. No haggling from beyond the grave.")
}
var result string
if strings.ToLower(args) == "all" {
result = p.advSellAll(ctx.Sender)
} else {
result = p.advSellItem(ctx.Sender, args)
}
return p.SendDM(ctx.Sender, result)
}
func (p *AdventurePlugin) handleInventoryCmd(ctx MessageContext) error {
if _, _, err := p.ensureCharacter(ctx.Sender); err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.")
}
text := advInventoryDisplay(ctx.Sender)
return p.SendDM(ctx.Sender, text)
}
func (p *AdventurePlugin) handleLeaderboard(ctx MessageContext) error {
chars, err := loadAllAdvCharacters()
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.")
}
text := renderAdvLeaderboard(chars)
return p.SendReply(ctx.RoomID, ctx.EventID, text)
}
func (p *AdventurePlugin) handleAdminRevive(ctx MessageContext, target string) error {
if !p.IsAdmin(ctx.Sender) {
return nil
}
// Resolve user
targetID, found := p.ResolveUser(target, ctx.RoomID)
if !found {
return p.SendReply(ctx.RoomID, ctx.EventID, "Could not find that user.")
}
char, err := loadAdvCharacter(targetID)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "That user has no adventurer.")
}
if char.Alive {
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s is already alive.", char.DisplayName))
}
char.Alive = true
char.DeadUntil = nil
if err := saveAdvCharacter(char); err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to revive.")
}
p.SendDM(targetID, renderAdvRespawnDM(char))
if p.achievements != nil {
p.achievements.GrantAchievement(targetID, "adv_revived")
}
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("✅ %s has been revived.", char.DisplayName))
}
func (p *AdventurePlugin) handleAdminSummary(ctx MessageContext) error {
if !p.IsAdmin(ctx.Sender) {
return nil
}
go p.postDailySummary()
return p.SendReply(ctx.RoomID, ctx.EventID, "Daily summary will be posted shortly.")
}
// ── DM Reply Handling ────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleDMReply(ctx MessageContext) error {
body := strings.TrimSpace(ctx.Body)
// Skip if it looks like a command for another plugin
lower := strings.ToLower(body)
if strings.HasPrefix(body, "!") && !strings.HasPrefix(lower, "!adventure") && !strings.HasPrefix(lower, "!adv") && !strings.HasPrefix(lower, "!thom") {
return nil
}
// Handle !thom in DMs
if strings.HasPrefix(lower, "!thom") {
return p.handleThomCmd(ctx)
}
// Strip !adventure / !adv prefix if present — dispatch directly to avoid recursion
if strings.HasPrefix(lower, "!adventure") || strings.HasPrefix(lower, "!adv") {
return p.dispatchCommand(ctx)
}
// Check for pending interaction first (always honored regardless of window)
if val, ok := p.pending.Load(string(ctx.Sender)); ok {
interaction := val.(*advPendingInteraction)
if time.Now().Before(interaction.ExpiresAt) {
return p.resolvePendingInteraction(ctx, interaction)
}
p.pending.Delete(string(ctx.Sender))
p.shopSessionEnd(ctx.Sender)
p.SendDM(ctx.Sender, "Your previous prompt expired. Moving on.")
}
// Only interpret bare messages as adventure choices if the user was recently
// shown an actionable menu. This prevents "1" typed during UNO (or any other
// DM-based game) from triggering adventure responses.
if !p.advIsInResponseWindow(ctx.Sender) {
return nil
}
// Parse as activity choice
return p.parseAndResolveChoice(ctx, body)
}
func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interaction *advPendingInteraction) error {
p.pending.Delete(string(ctx.Sender))
switch interaction.Type {
case "treasure_discard":
return p.handleTreasureDiscard(ctx, interaction)
case "masterwork_equip":
return p.handleMasterworkEquipReply(ctx, interaction)
case "masterwork_equip_confirm":
return p.handleMasterworkEquipConfirm(ctx, interaction)
case "rival_rps":
return p.resolveRivalRPSRound(ctx, interaction)
case "blacksmith_slot":
return p.resolveBlacksmithSlotChoice(ctx, interaction)
case "blacksmith_confirm":
return p.resolveBlacksmithConfirm(ctx, interaction)
case "shop_category":
return p.resolveShopCategoryChoice(ctx, interaction)
case "shop_item":
return p.resolveShopItemChoice(ctx, interaction)
case "shop_supply":
return p.resolveShopSupplyChoice(ctx, interaction)
case "shop_confirm":
return p.resolveShopConfirm(ctx, interaction)
case "hospital_pay":
return p.resolveHospitalPay(ctx, interaction)
case "npc_encounter":
return p.resolveNPCEncounter(ctx, interaction)
case "pet_arrival":
return p.resolvePetArrival(ctx)
case "pet_type":
return p.resolvePetType(ctx)
case "pet_name":
return p.resolvePetName(ctx)
}
return nil
}
func (p *AdventurePlugin) handleTreasureDiscard(ctx MessageContext, interaction *advPendingInteraction) error {
data := interaction.Data.(*advPendingTreasureDiscard)
body := strings.TrimSpace(strings.ToLower(ctx.Body))
if body == "keep" {
return p.SendDM(ctx.Sender, fmt.Sprintf("You left the %s behind. It will stay where you found it, judging you, forever.", data.NewTreasure.Name))
}
choice, err := strconv.Atoi(body)
if err != nil || choice < 1 || choice > len(data.Existing) {
return p.SendDM(ctx.Sender, "Reply with 1, 2, or 3 to discard, or `keep` to leave the new treasure behind.")
}
// Discard the chosen treasure
discarded := data.Existing[choice-1]
if err := advDiscardTreasure(ctx.Sender, discarded.Key); err != nil {
return p.SendDM(ctx.Sender, "Failed to discard treasure. Try again.")
}
// Save the new treasure
if err := advSaveTreasure(ctx.Sender, data.NewTreasure); err != nil {
return p.SendDM(ctx.Sender, "Failed to save new treasure. Something went wrong.")
}
return p.SendDM(ctx.Sender, fmt.Sprintf("You discarded **%s** and kept **%s**.\n\n_%s_",
discarded.Name, data.NewTreasure.Name, data.NewTreasure.InventoryDesc))
}
// ── Activity Choice Parsing ──────────────────────────────────────────────────
func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string) error {
// Acquire per-user lock to prevent double actions from concurrent DM replies
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return nil // not a registered player
}
if !char.Alive {
// On-demand revive if death timer has expired
if char.DeadUntil != nil && time.Now().UTC().After(*char.DeadUntil) {
char.Alive = true
char.DeadUntil = nil
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))
}
}
if !char.Alive {
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char))
}
}
isHol, _ := isHolidayToday()
if char.AllActionsUsed(isHol) {
// Only send the reminder once per day — subsequent DM messages
// are silently ignored so they can be handled by other plugins (e.g. UNO).
today := time.Now().UTC().Format("2006-01-02")
if prev, ok := p.dmRemindedDate.Load(string(ctx.Sender)); ok && prev.(string) == today {
return nil
}
p.dmRemindedDate.Store(string(ctx.Sender), today)
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
return p.SendDM(ctx.Sender, fmt.Sprintf(
"You've used all your actions today. Rest now. Try again tomorrow.\n\n"+
"Next action: 00:00 UTC (%dh %dm from now)\n\n"+
"The Arena is always open: `!arena`",
hours, minutes))
}
lower := strings.ToLower(body)
// Parse "7" or "rest"
if lower == "7" || lower == "rest" {
return p.resolveRest(ctx, char)
}
// Parse "6" or "blacksmith"
if lower == "6" || lower == "blacksmith" {
return p.handleBlacksmithCmd(ctx)
}
// Parse "5" or "shop"
if lower == "5" || lower == "shop" {
return p.handleShopCmd(ctx, "")
}
// Parse activity + location
activity, loc := p.parseActivityLocation(lower, char)
if loc == nil {
return p.SendDM(ctx.Sender, "I didn't understand that. Reply with a number and location, e.g: `1 Soggy Cellar`, or just `1` for the first available.")
}
return p.resolveActivity(ctx, char, activity, loc)
}
func (p *AdventurePlugin) parseActivityLocation(input string, char *AdventureCharacter) (AdvActivityType, *AdvLocation) {
parts := strings.SplitN(input, " ", 2)
if len(parts) == 0 {
return "", nil
}
first := parts[0]
rest := ""
if len(parts) > 1 {
rest = strings.TrimSpace(parts[1])
}
var activity AdvActivityType
// Parse activity from number or word
switch first {
case "1", "dungeon", "d":
activity = AdvActivityDungeon
case "2", "mine", "m":
activity = AdvActivityMining
case "3", "forage", "f", "forest":
activity = AdvActivityForaging
case "4", "fish", "fishing":
activity = AdvActivityFishing
default:
// Try matching location name directly
for _, act := range []AdvActivityType{AdvActivityDungeon, AdvActivityMining, AdvActivityForaging, AdvActivityFishing} {
if loc := findAdvLocation(act, input); loc != nil {
return act, loc
}
}
return "", nil
}
// If no location specified, pick first eligible
if rest == "" {
equip, _ := loadAdvEquipment(char.UserID)
treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID)
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, false)
eligible := advEligibleLocations(char, equip, activity, bonuses)
if len(eligible) == 0 {
return activity, nil
}
return activity, eligible[0].Location
}
// Try to parse tier number
if tier, err := strconv.Atoi(rest); err == nil {
loc := findAdvLocationByTier(activity, tier)
if loc != nil {
return activity, loc
}
}
// Fuzzy match location name
loc := findAdvLocation(activity, rest)
return activity, loc
}
// ── Activity Resolution ──────────────────────────────────────────────────────
func (p *AdventurePlugin) resolveActivity(ctx MessageContext, char *AdventureCharacter, activity AdvActivityType, loc *AdvLocation) error {
isHol, _ := isHolidayToday()
if isCombatActivity(activity) && !char.CanDoCombat(isHol) {
return p.SendDM(ctx.Sender, "You've used your combat action for the day. Try a harvest activity (mining, fishing, foraging) or rest.")
}
if isHarvestActivity(activity) && !char.CanDoHarvest(isHol) {
return p.SendDM(ctx.Sender, "You've used all your harvest actions for the day. Try combat (dungeon) or rest.")
}
equip, err := loadAdvEquipment(char.UserID)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your equipment.")
}
treasures, _ := loadAdvTreasureBonuses(char.UserID)
buffs, _ := loadAdvActiveBuffs(char.UserID)
// Check grudge
hasGrudge := char.GrudgeLocation == loc.Name
bonuses := computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
// Check eligibility
eligible, inPenaltyZone := advIsEligible(char, equip, loc, bonuses)
if !eligible {
return p.SendDM(ctx.Sender, fmt.Sprintf("You are not eligible for %s. Your level or equipment tier is too low.", loc.Name))
}
// Per-location cooldown: 3 hours after a successful run
if remaining := advLocationCooldown(char.UserID, loc.Name); remaining > 0 {
mins := int(remaining.Minutes())
if mins < 1 {
return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in less than a minute, or pick a different location.", loc.Name))
}
h, m := mins/60, mins%60
if h > 0 {
return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in %dh %dm, or pick a different location.", loc.Name, h, m))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🕐 %s is still being cleared out. Try again in %d minutes, or pick a different location.", loc.Name, m))
}
// Resolve the action — dungeon uses combat engine, others use probability bands
var result *AdvActionResult
if loc.Activity == AdvActivityDungeon {
result = p.resolveDungeonAction(char, equip, loc, bonuses, inPenaltyZone)
} else {
result = resolveAdvAction(char, equip, loc, bonuses, inPenaltyZone)
}
// Select flavor text
result.FlavorText, result.FlavorKey = p.selectFlavorText(char, result)
// Chat level XP bonus
if bonus := chatLevelXPBonus(p.chatLevel(char.UserID)); bonus > 0 {
result.XPGained = int(float64(result.XPGained) * (1.0 + bonus))
}
// Double XP/money boost
advApplyBoost(result)
// Apply XP
switch result.XPSkill {
case "combat":
char.CombatXP += result.XPGained
case "mining":
char.MiningXP += result.XPGained
case "foraging":
char.ForagingXP += result.XPGained
case "fishing":
char.FishingXP += result.XPGained
}
// Check level up
result.LeveledUp, result.NewLevel = checkAdvLevelUp(char, result.XPSkill)
if result.LeveledUp && result.XPSkill == "combat" {
p.checkRivalPoolUnlock(char)
}
engineDeathSaved := result.CombatLog != nil && checkDeathSaveEvent(result.CombatLog.Events)
// Handle death
deathReprieved := false
pardonFired := false
petRecovered := false
if result.Outcome == AdvOutcomeDeath {
dt := transitionDeath(DeathTransitionParams{
Char: char,
Equip: equip,
ChatLevel: p.chatLevel(char.UserID),
Location: loc.Name,
AllowPardon: true,
AllowSovereign: true,
EngineSaved: engineDeathSaved,
})
pardonFired = dt.Pardoned
deathReprieved = dt.Pardoned || dt.Reprieved
petRecovered = dt.PetRecovered
if dt.Pardoned {
result.Outcome = AdvOutcomeEmpty
}
if dt.Reprieved {
nextWindow := time.Now().UTC().Add(168 * time.Hour)
gr := gamesRoom()
if gr != "" {
p.SendMessage(gr, renderArenaDeathReprieve(char.DisplayName, loc.Name, nextWindow))
}
}
} else if hasGrudge && (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) {
// Clear grudge on successful return
char.GrudgeLocation = ""
}
// Add loot to inventory
for _, item := range result.LootItems {
_ = addAdvInventoryItem(char.UserID, item)
}
// Roll for consumable drop on success/exceptional at T2+
if (result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional) && loc.Tier >= 2 {
if drop := RollConsumableDrop(loc.Activity, loc.Tier); drop != nil {
_ = addAdvInventoryItem(char.UserID, *drop)
result.LootItems = append(result.LootItems, *drop)
}
}
// Mark action consumed in the correct bucket
if isCombatActivity(activity) {
char.CombatActionsUsed++
} else if isHarvestActivity(activity) {
char.HarvestActionsUsed++
}
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
// Update streak info
result.StreakBonus = char.CurrentStreak
// Save character
if err := saveAdvCharacter(char); err != nil {
slog.Error("adventure: failed to save character", "user", char.UserID, "err", err)
return p.SendDM(ctx.Sender, "Something went wrong saving your progress. Your action was not recorded. Try again.")
}
// Pet XP
if char.HasPet() && result.Outcome != AdvOutcomeDeath {
if petGrantXP(char) {
_ = saveAdvCharacter(char)
_ = p.SendDM(char.UserID, fmt.Sprintf("🐾 %s leveled up to **Level %d**!", char.PetName, char.PetLevel))
} else {
_ = saveAdvCharacter(char)
}
}
// Save equipment changes
for _, slot := range allSlots {
if eq, ok := equip[slot]; ok {
if err := saveAdvEquipment(char.UserID, eq); err != nil {
slog.Error("adventure: failed to save equipment", "user", char.UserID, "slot", slot, "err", err)
}
}
}
// Log activity BEFORE party bonus check so both visitors can see each other
logAdvActivity(char.UserID, string(activity), loc.Name, string(result.Outcome),
result.TotalLootValue, result.XPGained, result.FlavorKey)
// Party bonus: check if someone else visited the same location today
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
if advCheckPartyBonus(char.UserID, loc.Name) {
// Apply party bonus: +10% loot value
partyBonus := int64(float64(result.TotalLootValue) * 0.10)
if partyBonus > 0 {
result.TotalLootValue += partyBonus
net, _ := communityTax(char.UserID, float64(partyBonus), 0.05)
p.euro.Credit(char.UserID, net, "adventure_party_bonus")
}
}
}
// Send resolution DM with closing block
text := renderAdvResolutionDM(result, char)
if deathReprieved {
nextWindow := char.DeathReprieveLast.Add(168 * time.Hour)
text += fmt.Sprintf("\n\n⚔ **Death's Reprieve activated.** Your Sovereign gear absorbed the killing blow. "+
"You survived — barely. All equipment took heavy damage.\n"+
"Next reprieve window: %s", nextWindow.Format("2006-01-02 15:04 UTC"))
}
closing := advClosingBlock(result.Outcome, char.UserID, loc.Name, p.morningHour, p.summaryHour)
if closing != "" {
text += "\n" + closing
}
// Dungeon combat: send phased combat messages with delays, then resolution DM
if result.CombatLog != nil {
phaseMessages := RenderCombatLog(*result.CombatLog, char.DisplayName, loc.Denizens)
phaseMessages = p.prependCraftNarrative(ctx.Sender, phaseMessages)
done := p.sendCombatMessages(ctx.Sender, phaseMessages, text)
go func() {
<-done
if pardonFired {
p.SendDM(ctx.Sender, "The crowd intervened. A healer pulled you back at the last second. Don't count on this again — 7-day cooldown.")
}
if petRecovered && char.PetName != "" {
p.SendDM(ctx.Sender, fmt.Sprintf("Your pet %s dragged you to safety. Death timer reduced.", char.PetName))
}
if result.Outcome == AdvOutcomeDeath && !deathReprieved {
p.sendHospitalAd(ctx.Sender, char)
}
}()
} else {
if err := p.SendDM(ctx.Sender, text); err != nil {
slog.Error("adventure: failed to send resolution DM", "user", ctx.Sender, "err", err)
}
if pardonFired {
p.SendDM(ctx.Sender, "The crowd intervened. A healer pulled you back at the last second. Don't count on this again — 7-day cooldown.")
}
if petRecovered && char.PetName != "" {
p.SendDM(ctx.Sender, fmt.Sprintf("Your pet %s dragged you to safety. Death timer reduced.", char.PetName))
}
if result.Outcome == AdvOutcomeDeath && !deathReprieved {
p.sendHospitalAd(ctx.Sender, char)
}
}
// Check for treasure drop
if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
p.checkTreasureDrop(ctx.Sender, char, loc)
p.checkMasterworkDrop(ctx.Sender, char, equip, loc, result.Outcome)
}
// If the player still has actions remaining, nudge them
if !char.AllActionsUsed(isHol) && (result.Outcome != AdvOutcomeDeath || deathReprieved) {
remaining := []string{}
if char.CanDoCombat(isHol) {
remaining = append(remaining, "combat")
}
if char.CanDoHarvest(isHol) {
harvestMax := maxHarvestActions
if isHol {
harvestMax++
}
harvestLeft := harvestMax - char.HarvestActionsUsed
remaining = append(remaining, fmt.Sprintf("%d harvest", harvestLeft))
}
p.SendDM(ctx.Sender, fmt.Sprintf("Actions remaining today: %s. Type `!adv` for the menu.", strings.Join(remaining, ", ")))
}
return nil
}
func (p *AdventurePlugin) resolveRest(ctx MessageContext, char *AdventureCharacter) error {
isHol, _ := isHolidayToday()
// Rest consumes all remaining actions
combatMax := maxCombatActions
harvestMax := maxHarvestActions
if isHol {
combatMax++
harvestMax++
}
char.CombatActionsUsed = combatMax
char.HarvestActionsUsed = harvestMax
char.ActionTakenToday = true
char.LastActionDate = time.Now().UTC().Format("2006-01-02")
if err := saveAdvCharacter(char); err != nil {
return p.SendDM(ctx.Sender, "Failed to save. Even resting is broken.")
}
logAdvActivity(char.UserID, string(AdvActivityRest), "", "rest", 0, 0, "")
// Compute reset countdown
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
restMsg := fmt.Sprintf(
"%s, you chose rest. No loot. No XP. No death.\n\n"+
"You sat in your hovel and stared at the wall and achieved absolutely nothing. "+
"Tomorrow awaits. It will probably be the same.\n\n"+
"─────────────────────────────\n"+
"Next action: 00:00 UTC (%dh %dm from now)\n"+
"Morning DM: %02d:00 UTC\n"+
"Evening summary: %02d:00 UTC\n\n"+
"The Arena is always open: `!arena`",
char.DisplayName, hours, minutes, p.morningHour, p.summaryHour)
if err := p.SendDM(ctx.Sender, restMsg); err != nil {
slog.Error("adventure: failed to send rest DM", "user", ctx.Sender, "err", err)
}
return nil
}
// ── Treasure Drop Check ─────────────────────────────────────────────────────
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation) {
drop := rollAdvTreasureDrop(loc.Tier, userID, p.chatLevel(userID))
if drop == nil {
return
}
// Check treasure count
count, err := advCountTreasures(userID)
if err != nil {
return
}
if count < advMaxTreasures {
// Directly save
if err := advSaveTreasure(userID, drop.Def); err != nil {
slog.Error("adventure: failed to save treasure", "user", userID, "err", err)
return
}
// Send discovery flavor
p.sendTreasureDiscoveryDM(userID, char, drop.Def, loc)
return
}
// At cap — prompt for discard
existing, err := advUserTreasures(userID)
if err != nil || len(existing) == 0 {
return
}
// Set pending interaction
p.pending.Store(string(userID), &advPendingInteraction{
Type: "treasure_discard",
Data: &advPendingTreasureDiscard{
NewTreasure: drop.Def,
Existing: existing,
},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
text := renderAdvTreasureDiscardPrompt(drop.Def, existing)
p.SendDM(userID, text)
}
func (p *AdventurePlugin) sendTreasureDiscoveryDM(userID id.UserID, char *AdventureCharacter, def *AdvTreasureDef, loc *AdvLocation) {
// Pick from discovery pool
pool, ok := TreasureDiscovery[def.Tier]
if !ok || len(pool) == 0 {
return
}
text := pool[rand.IntN(len(pool))]
text = advSubstituteFlavor(text, map[string]string{
"{treasure_name}": def.Name,
"{bonus_desc}": def.InventoryDesc,
"{location}": loc.Name,
})
p.SendDM(userID, text)
// Room announcement for tier 5 or special items
if def.RoomAnnounce != "" {
gr := gamesRoom()
if gr != "" {
announce := advSubstituteFlavor(def.RoomAnnounce, map[string]string{
"{name}": char.DisplayName,
"{location}": loc.Name,
})
p.SendMessage(id.RoomID(gr), announce)
}
}
}
// ── Flavor Text Selection ────────────────────────────────────────────────────
func (p *AdventurePlugin) selectFlavorText(char *AdventureCharacter, result *AdvActionResult) (string, string) {
loc := result.Location
vars := map[string]string{
"{name}": char.DisplayName,
"{location}": loc.Name,
"{value}": fmt.Sprintf("%d", result.TotalLootValue),
"{xp}": fmt.Sprintf("%d", result.XPGained),
}
// Add item names
if len(result.LootItems) > 0 {
names := make([]string, len(result.LootItems))
for i, item := range result.LootItems {
names[i] = item.Name
}
vars["{item}"] = joinAdvItems(names)
vars["{ore}"] = names[0]
vars["{item_2}"] = ""
if len(names) > 1 {
vars["{item_2}"] = names[1]
}
} else {
vars["{item}"] = ""
vars["{ore}"] = ""
}
// Equipment names for flavor
equip, _ := loadAdvEquipment(char.UserID)
if eq, ok := equip[SlotTool]; ok {
vars["{tool}"] = eq.Name
}
if eq, ok := equip[SlotArmor]; ok {
vars["{armor}"] = eq.Name
}
var pool []string
category := fmt.Sprintf("%s_%s", loc.Activity, result.Outcome)
switch loc.Activity {
case AdvActivityDungeon:
switch result.Outcome {
case AdvOutcomeDeath:
if tierPool, ok := DungeonDeath[loc.Tier]; ok {
pool = tierPool
}
case AdvOutcomeEmpty:
if tierPool, ok := DungeonEmpty[loc.Tier]; ok {
pool = tierPool
}
case AdvOutcomeSuccess:
if tierPool, ok := DungeonSuccess[loc.Tier]; ok {
pool = tierPool
}
case AdvOutcomeExceptional:
if tierPool, ok := DungeonExceptional[loc.Tier]; ok {
pool = tierPool
}
}
case AdvActivityMining:
switch result.Outcome {
case AdvOutcomeDeath:
if tierPool, ok := MiningDeath[loc.Tier]; ok {
pool = tierPool
}
case AdvOutcomeCaveIn:
pool = MiningCaveIn
case AdvOutcomeEmpty:
if tierPool, ok := MiningEmpty[loc.Tier]; ok {
pool = tierPool
}
case AdvOutcomeSuccess, AdvOutcomeExceptional:
if tierPool, ok := MiningSuccess[loc.Tier]; ok {
pool = tierPool
}
}
case AdvActivityForaging:
switch result.Outcome {
case AdvOutcomeDeath:
pool = ForagingDeath
case AdvOutcomeHornets:
pool = ForagingHornets
case AdvOutcomeBear:
pool = ForagingBear
case AdvOutcomeRiver:
pool = ForagingRiver
case AdvOutcomeSuccess, AdvOutcomeExceptional:
if tierPool, ok := ForagingGoodHaul[loc.Tier]; ok {
pool = tierPool
}
}
case AdvActivityFishing:
switch result.Outcome {
case AdvOutcomeDeath:
if tierPool, ok := FishingDeath[loc.Tier]; ok {
pool = tierPool
}
case AdvOutcomeEmpty:
if tierPool, ok := FishingEmpty[loc.Tier]; ok {
pool = tierPool
}
case AdvOutcomeSuccess:
if tierPool, ok := FishingSuccess[loc.Tier]; ok {
pool = tierPool
}
case AdvOutcomeExceptional:
if tierPool, ok := FishingExceptional[loc.Tier]; ok {
pool = tierPool
}
}
}
if len(pool) == 0 {
return fmt.Sprintf("You went to %s. Things happened.", loc.Name), ""
}
text, idx := advPickFlavor(pool, char.UserID, category)
key := fmt.Sprintf("%s_%d", category, idx)
return advSubstituteFlavor(text, vars), key
}
// ── Character Ensurance ──────────────────────────────────────────────────────
func (p *AdventurePlugin) ensureCharacter(userID id.UserID) (*AdventureCharacter, map[EquipmentSlot]*AdvEquipment, error) {
char, err := loadAdvCharacter(userID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
// Query error (e.g. missing column) — do NOT auto-create over existing data
slog.Error("adventure: loadAdvCharacter failed", "user", userID, "err", err)
return nil, nil, fmt.Errorf("failed to load character: %w", err)
}
// Genuinely new player — auto-create
displayName := p.DisplayName(userID)
if err := createAdvCharacter(userID, displayName); err != nil {
return nil, nil, err
}
char, err = loadAdvCharacter(userID)
if err != nil {
return nil, nil, err
}
// Register DM room
p.registerDMRoom(userID)
// Send onboarding
text := renderAdvOnboardingDM(char)
p.SendDM(userID, text)
}
equip, err := loadAdvEquipment(userID)
if err != nil {
return char, nil, err
}
return char, equip, nil
}
// ── Double XP/Money Boost ───────────────────────────────────────────────────
const advBoostCacheKey = "adv_boost_active"
func advBoostActive() bool {
return db.CacheGet(advBoostCacheKey, 365*86400) == "1"
}
func advSetBoost(active bool) {
v := "0"
if active {
v = "1"
}
db.CacheSet(advBoostCacheKey, v)
}
func advApplyBoost(result *AdvActionResult) {
if !advBoostActive() {
return
}
result.XPGained *= 2
result.TotalLootValue = 0
for i := range result.LootItems {
result.LootItems[i].Value *= 2
result.TotalLootValue += result.LootItems[i].Value
}
}
func (p *AdventurePlugin) handleBoostCmd(ctx MessageContext) error {
if !p.IsAdmin(ctx.Sender) {
return nil
}
if advBoostActive() {
advSetBoost(false)
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **disabled**.")
}
advSetBoost(true)
return p.SendReply(ctx.RoomID, ctx.EventID, "⚡ Double XP/money boost **enabled**! All adventure XP and loot values are doubled.")
}