mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 16:42:41 +00:00
Players in a co-op were getting their combat action back every midnight reset and offered the dungeon option in the morning DM. Per spec they should be "off in the Co-op" and unable to also solo-grind dungeons. Fix at the DB layer (so all CanDoCombat() paths agree): - New helper lockCoopCombatActions() sets combat_actions_used=99 for any user in an active coop run. Called immediately after every resetAllAdvDailyActions() — at startup and at midnight. - Render layer: morning DM now shows "combat locked (in Co-op #N, day X/Y)" and replaces the dungeon section with an explanatory line pointing them at !coop status. Combat returns to normal automatically once the run completes/wipes — the next midnight reset finds status != 'active' and skips the lock. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1359 lines
45 KiB
Go
1359 lines
45 KiB
Go
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)
|
||
}
|
||
if err := lockCoopCombatActions(); err != nil {
|
||
slog.Error("adventure: startup coop combat lock 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.")
|
||
}
|
||
|