mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
Surface social data the game already stores as three read-only boards: - !town — civic pride (top tax_ledger contributors), housing street (name + tier), pet showcase (name/type/level/barding). - !graveyard — recent deaths across the guild (death_source/location, still-resting vs recovered), St. Guildmore's caretaker voice. - !rivals board — room-wide duel standings aggregated from the directed adventure_rival_records ledger; bare !rivals keeps the per-user view. All read-only: no schema, no combat, golden byte-identical. Enumerate off player_meta / tax_ledger / adventure_rival_records with COALESCE'd display names. Render layer is client-free and unit-tested; a DB-backed loader test exercises the real schema (caught a MAX(datetime) affinity issue — parsed by hand via parseSQLiteTime). Leak-check (engagement plan §E3): the civic board ranks tax_ledger.total_paid, which is gambling/shop/arena rake only — Misty/Arina donations go through euro.Debit with their own reason strings and their counters live in separate player_meta columns, so no board can correlate donations with the hidden buffs. Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
1597 lines
59 KiB
Go
1597 lines
59 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
|
||
expLocks sync.Map // expedition ID 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)
|
||
treasureUndo sync.Map // userID string -> *advTreasureUndoToken (10-min auto-swap reversal)
|
||
morningHour int
|
||
summaryHour int
|
||
|
||
// stopCh is closed by Stop() to signal all background tickers
|
||
// (morning/summary/midnight/event/rival/robbie/hospital/mortgage/
|
||
// expedition briefing+recap) to exit. Each ticker selects
|
||
// on its time.Ticker channel and stopCh; the second branch returns.
|
||
stopCh chan struct{}
|
||
}
|
||
|
||
// Stop signals all background tickers to exit and waits for them via
|
||
// the closed channel. Idempotent — closing a closed channel would
|
||
// panic, so we guard with a sync.Once-style nil check.
|
||
func (p *AdventurePlugin) Stop() {
|
||
p.mu.Lock()
|
||
defer p.mu.Unlock()
|
||
if p.stopCh == nil {
|
||
return
|
||
}
|
||
select {
|
||
case <-p.stopCh:
|
||
// already closed
|
||
default:
|
||
close(p.stopCh)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// advExpeditionLock returns a per-expedition mutex, for state a whole party
|
||
// shares rather than one player. advUserLock cannot stand in: it is keyed by
|
||
// sender, so two members racing the same expedition row take two different
|
||
// mutexes and exclude nobody. Handlers run one goroutine per event, so any
|
||
// read-modify-write of the shared supply pool needs this.
|
||
func (p *AdventurePlugin) advExpeditionLock(expID string) *sync.Mutex {
|
||
val, _ := p.expLocks.LoadOrStore(expID, &sync.Mutex{})
|
||
return val.(*sync.Mutex)
|
||
}
|
||
|
||
// advDMResponseWindow is the default window for active state-holding
|
||
// prompts (shop/blacksmith/hospital/masterwork/treasure confirms).
|
||
// Passive overnight-tolerant prompts (pet arrival, flavor DMs) use
|
||
// advDMResponseWindowLow so a player who gets DM'd at 1am can still
|
||
// reply when they wake up.
|
||
const advDMResponseWindow = 3 * time.Hour
|
||
const advDMResponseWindowLow = 12 * time.Hour
|
||
const advTreasureUndoWindow = 10 * time.Minute
|
||
|
||
// advTreasureUndoToken tracks a recent auto-swap so the player can undo it
|
||
// within the window by replying "undo" in DM. Holds the discarded def so
|
||
// the swap can be reversed exactly. AnnounceTimer is the deferred room
|
||
// announcement — fired once the undo window closes if the swap stands,
|
||
// stopped on undo so reversed swaps don't get publicly announced.
|
||
type advTreasureUndoToken struct {
|
||
Discarded *AdvTreasureDef
|
||
Kept *AdvTreasureDef
|
||
ExpiresAt time.Time
|
||
AnnounceTimer *time.Timer
|
||
}
|
||
|
||
// 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: "Town services menu (expeditions: see !expedition)", 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"},
|
||
{Name: "setup", Description: "Create or continue your character (race, class, stats)", Usage: "!setup", Category: "Games"},
|
||
{Name: "sheet", Description: "View your character sheet", Usage: "!sheet", Category: "Games"},
|
||
{Name: "abilities", Description: "List your class and race abilities", Usage: "!abilities", Category: "Games"},
|
||
{Name: "respec", Description: "Wipe and rebuild your character (5000 euros, 7-day cooldown)", Usage: "!respec", Category: "Games"},
|
||
{Name: "subclass", Description: "View or choose your subclass (unlocks at level 5; change costs 500 euros, 30-day cooldown)", Usage: "!subclass [name|number]", Category: "Games"},
|
||
{Name: "check", Description: "Try a skill — see if you pull it off", Usage: "!check <skill> [difficulty]", Category: "Games"},
|
||
{Name: "rest", Description: "Rest up (`short`: quick breather, 1h cooldown — `long`: full night, 24h, needs housing or inn)", Usage: "!rest short|long", Category: "Games"},
|
||
{Name: "arm", Description: "Ready an ability so it fires the moment your next fight starts", Usage: "!arm <ability>", Category: "Games"},
|
||
{Name: "roll", Description: "Roll dice (NdN+M format, e.g. 2d6+3, d20, 4d6-1)", Usage: "!roll <dice>", Category: "Games"},
|
||
{Name: "stats", Description: "Show your ability scores", Usage: "!stats", Category: "Games"},
|
||
{Name: "level", Description: "Show your level and XP progress", Usage: "!level", Category: "Games"},
|
||
{Name: "cast", Description: "Cast a spell (queues for next combat, or resolves now if out of combat)", Usage: "!cast <spell> [--upcast N] [--target @user]", Category: "Games"},
|
||
{Name: "spells", Description: "List your known spells, prepared spells, and remaining casts", Usage: "!spells [learn <spell>]", Category: "Games"},
|
||
{Name: "prepare", Description: "Cleric: prepare a spell for the day", Usage: "!prepare <spell> | !prepare clear <spell>", Category: "Games"},
|
||
{Name: "resources", Description: "List what's harvestable in your current expedition region", Usage: "!resources", Category: "Games"},
|
||
{Name: "explore", Description: "Autopilot: walk through expedition rooms until something needs your attention (fork, elite/boss, low HP/supplies)", Usage: "!explore", Category: "Games"},
|
||
{Name: "sell", Description: "Sell your hauled materials, fish, and items to Thom Krooke after an expedition (a smooth pitch can land a better price)", Usage: "!sell [list|all|<item>]", Category: "Games"},
|
||
{Name: "craft", Description: "Craft a discovered recipe at Thom Krooke (consumes ingredients)", Usage: "!craft [list|<recipe>]", Category: "Games"},
|
||
{Name: "lore", Description: "Dig through Thom Krooke's lore stacks for a new recipe (sharp minds turn up more)", Usage: "!lore", Category: "Games"},
|
||
{Name: "give", Description: "Gift a consumable or non-magic item to another adventurer (5% handling fee to the community pot; 3/day)", Usage: "!give <item> @user", Category: "Games"},
|
||
{Name: "town", Description: "Town registry — civic pride, housing street, and the pet showcase", Usage: "!town", Category: "Games"},
|
||
{Name: "graveyard", Description: "Recent deaths across the guild", Usage: "!graveyard", Category: "Games"},
|
||
{Name: "rivals", Description: "Your rival duel record, or `!rivals board` for room-wide standings", Usage: "!rivals [board]", Category: "Games"},
|
||
}
|
||
}
|
||
|
||
func (p *AdventurePlugin) Init() error {
|
||
// Bootstrap: migrate any legacy adventure_characters rows that don't yet
|
||
// have a player_meta row. Idempotent. Required for DBs that didn't go
|
||
// through the L4-L5h dual-write soak (fresh deploys, restored backups).
|
||
bootstrapPlayerMetaFromLegacy()
|
||
bootstrapRestoreExpeditionStreakDecay()
|
||
bootstrapRoomsTraversed()
|
||
|
||
// 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 := resetAllPlayerMetaDailyActions(); err != nil {
|
||
slog.Error("adventure: startup daily reset failed", "err", err)
|
||
}
|
||
// Phase L3 — cancel any open/active legacy coop dungeon runs and
|
||
// refund member contributions + unsettled bets. Idempotent.
|
||
closeAndRefundLegacyCoopRuns(p.euro)
|
||
// Phase L5 close-out — drop the now-cold adventure_characters table when
|
||
// the operator sets GOGOBEE_LEGACY_PURGE=1. Default off; idempotent.
|
||
purgeLegacyAdvCharacterTable()
|
||
// Phase G9 close-out — drop the now-cold current_room / room_seq_json
|
||
// columns from dnd_zone_run when the operator sets
|
||
// GOGOBEE_BRANCHING_PURGE=1. Default off; idempotent.
|
||
purgeLegacyZoneRunColumns()
|
||
// 2026-05-10 fun-pump: floor existing characters' DEX at 14 and
|
||
// recompute armor_class so armor upgrades actually translate into
|
||
// AC. One-shot, idempotent (WHERE dex_score < 14).
|
||
bumpDexFloorForExistingCharacters()
|
||
// 2026-05-10 immersion: seed short rest charges = dnd_level for any
|
||
// character not yet on the new charge system. One-shot, idempotent.
|
||
seedShortRestChargesForExistingCharacters()
|
||
// 2026-05-15 Phase 5-B: refresh hp_max for existing characters
|
||
// after the Phase 5-B HP-floor multiplier (phase5BHPMult in dnd.go)
|
||
// shipped. Without this, existing characters keep their pre-Phase-5-B
|
||
// hp_max until the next computeMaxHP recall (level-up / reset). The
|
||
// migration walks dnd_character once at startup; idempotent via
|
||
// JobCompleted gate.
|
||
bootstrapPhase5BHPRefresh()
|
||
// J3 D8-d-fix: caster HP lift (casterHPMult in dnd.go). Refresh
|
||
// existing caster rows once at startup so the lift reaches live
|
||
// players without waiting for level-up.
|
||
bootstrapCasterHPRefresh()
|
||
// 2026-06-18 caster-aid: backfill default spells that postdate a
|
||
// character's roll (ensureSpellsForCharacter only seeds an empty book),
|
||
// and a one-off pet gift for an endgame player who never got the morning
|
||
// arrival roll. Both idempotent via JobCompleted gates.
|
||
bootstrapCasterSpellBackfill()
|
||
bootstrapGrantStarterPet()
|
||
// Phase R1 orphan-archive used to run here on every Init, but it
|
||
// over-archived: it treats any active dnd_zone_run row not linked to
|
||
// an active expedition as a legacy `!adventure dungeon` orphan, which
|
||
// also kills legitimate `!zone enter` single-session runs every time
|
||
// the bot restarts. That migration's job was done on its first
|
||
// execution; leaving it on Init was eating live runs. The function is
|
||
// preserved for one-off invocation if a future schema change ever
|
||
// needs it again.
|
||
// Revive any characters whose DeadUntil has expired
|
||
p.catchUpRespawns(chars)
|
||
|
||
// Start schedulers — single shared stopCh allows graceful shutdown.
|
||
if p.stopCh == nil {
|
||
p.stopCh = make(chan struct{})
|
||
}
|
||
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.expeditionBriefingTicker()
|
||
go p.expeditionRecapTicker()
|
||
go p.expeditionAmbientTicker()
|
||
go p.expeditionAutoRunTicker()
|
||
go p.expeditionExtractionSweepTicker()
|
||
|
||
// Auto-cashout any arena runs left in 'awaiting' from a prior restart
|
||
p.arenaCleanupStaleRuns()
|
||
// Extractions that lapsed while the bot was down: the roster they hold is
|
||
// blocking those members right now, not an hour from now.
|
||
p.sweepLapsedExtractions(time.Now().UTC())
|
||
|
||
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.UserID)
|
||
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 {
|
||
// D4-b: lazy morning briefing. When the 06:00 UTC ticker skips an idle
|
||
// player's event-anchored expedition, the briefing fires here on their
|
||
// next inbound message. Fast-paths to a no-op for users with no active
|
||
// expedition.
|
||
p.maybeDeliverDeferredBriefing(ctx.Sender, time.Now().UTC())
|
||
|
||
// 0. D&D layer commands (Phase 1 — work in rooms and DMs)
|
||
if p.IsCommand(ctx.Body, "setup") {
|
||
return p.handleDnDSetupCmd(ctx, p.GetArgs(ctx.Body, "setup"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "sheet") {
|
||
return p.handleDnDSheetCmd(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "abilities") {
|
||
return p.handleDnDAbilitiesCmd(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "respec") {
|
||
return p.handleDnDRespecCmd(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "subclass") {
|
||
return p.handleDnDSubclassCmd(ctx, p.GetArgs(ctx.Body, "subclass"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "check") {
|
||
return p.handleDnDCheckCmd(ctx, p.GetArgs(ctx.Body, "check"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "rest") {
|
||
return p.handleDnDRestCmd(ctx, p.GetArgs(ctx.Body, "rest"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "arm") {
|
||
return p.handleDnDArmCmd(ctx, p.GetArgs(ctx.Body, "arm"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "cast") {
|
||
return p.handleDnDCastCmd(ctx, p.GetArgs(ctx.Body, "cast"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "spells") {
|
||
return p.handleDnDSpellsCmd(ctx, p.GetArgs(ctx.Body, "spells"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "prepare") {
|
||
return p.handleDnDPrepareCmd(ctx, p.GetArgs(ctx.Body, "prepare"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "roll") {
|
||
return p.handleDnDRollCmd(ctx, p.GetArgs(ctx.Body, "roll"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "stats") {
|
||
return p.handleDnDStatsCmd(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "level") {
|
||
return p.handleDnDLevelCmd(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "zone") {
|
||
return p.handleDnDZoneCmd(ctx, p.GetArgs(ctx.Body, "zone"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "revisit") {
|
||
return p.handleRevisitCmd(ctx, p.GetArgs(ctx.Body, "revisit"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "fight") {
|
||
return p.handleFightCmd(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "attack") {
|
||
return p.handleAttackCmd(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "flee") {
|
||
return p.handleFleeCmd(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "consume") {
|
||
return p.handleConsumeCmd(ctx, p.GetArgs(ctx.Body, "consume"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "expedition") {
|
||
return p.handleDnDExpeditionCmd(ctx, p.GetArgs(ctx.Body, "expedition"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "camp") {
|
||
return p.handleCampCmd(ctx, p.GetArgs(ctx.Body, "camp"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "threat") {
|
||
return p.handleThreatCmd(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "region") {
|
||
return p.handleRegionCmd(ctx, p.GetArgs(ctx.Body, "region"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "extract") {
|
||
return p.handleExtractCmd(ctx, p.GetArgs(ctx.Body, "extract"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "resume") {
|
||
return p.handleResumeCmd(ctx, p.GetArgs(ctx.Body, "resume"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "map") {
|
||
return p.handleExpeditionMapCmd(ctx, p.GetArgs(ctx.Body, "map"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "explore") {
|
||
return p.expeditionCmdRun(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "forage") ||
|
||
p.IsCommand(ctx.Body, "mine") ||
|
||
p.IsCommand(ctx.Body, "scavenge") ||
|
||
p.IsCommand(ctx.Body, "essence") ||
|
||
p.IsCommand(ctx.Body, "commune") ||
|
||
p.IsCommand(ctx.Body, "fish") {
|
||
return p.SendDM(ctx.Sender, "Harvest is automatic now — just walk. (`!expedition run` or `!zone advance`)")
|
||
}
|
||
if p.IsCommand(ctx.Body, "resources") {
|
||
return p.handleResourcesCmd(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "sell") {
|
||
return p.handleResourceSellCmd(ctx, p.GetArgs(ctx.Body, "sell"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "craft") {
|
||
return p.handleCraftCmd(ctx, p.GetArgs(ctx.Body, "craft"))
|
||
}
|
||
if p.IsCommand(ctx.Body, "lore") {
|
||
return p.handleLoreCmd(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "give") {
|
||
return p.handleGiveCmd(ctx)
|
||
}
|
||
|
||
// 0b. Town registries (E3) — read-only social boards (rooms and DMs)
|
||
if p.IsCommand(ctx.Body, "town") {
|
||
return p.handleTownCmd(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "graveyard") {
|
||
return p.handleGraveyardCmd(ctx)
|
||
}
|
||
if p.IsCommand(ctx.Body, "rivals") {
|
||
return p.handleRivalsTopCmd(ctx, p.GetArgs(ctx.Body, "rivals"))
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 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 == "equip-magic" || lower == "equipmagic" || lower == "equip magic":
|
||
return p.handleEquipMagicCmd(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 == "temper":
|
||
return p.handleTemperCmd(ctx)
|
||
case lower == "boost":
|
||
return p.handleBoostCmd(ctx)
|
||
case lower == "recipes":
|
||
return p.handleRecipesCmd(ctx)
|
||
case lower == "mastery":
|
||
return p.handleMasteryCmd(ctx)
|
||
case lower == "treasures" || strings.HasPrefix(lower, "treasures "):
|
||
return p.handleTreasuresCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "treasures")))
|
||
case lower == "vault" || strings.HasPrefix(lower, "vault "):
|
||
return p.handleVaultCmd(ctx, strings.TrimSpace(args[len("vault"):]))
|
||
}
|
||
|
||
return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.")
|
||
}
|
||
|
||
const advHelpText = `**Adventure Commands**
|
||
|
||
` + "`!adventure`" + ` — Show town services menu (expeditions are run via ` + "`!expedition`" + `)
|
||
` + "`!adventure status`" + ` — View your character sheet
|
||
` + "`!expedition`" + ` — Adventure: pick a zone, advance through rooms, harvest as you go
|
||
` + "`!adventure shop`" + ` — Browse equipment categories
|
||
` + "`!adventure shop <category>`" + ` — View a category (weapon, armor, helmet, boots, tool, supplies, curios)
|
||
` + "`!adventure buy <item>`" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `)
|
||
` + "`!adventure equip`" + ` — Equip Masterwork gear from inventory
|
||
` + "`!adventure equip-magic`" + ` — Equip magic items (curios) into your D&D slots
|
||
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
|
||
` + "`!adventure inventory`" + ` — View your inventory
|
||
` + "`!adventure vault`" + ` — Store items safely (Tier-4 Established home; ` + "`vault store/take <item>`" + `)
|
||
` + "`!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 babysit focus <skill>`" + ` — Pick the skill auto-babysit trains (mining/fishing/foraging)
|
||
` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs)
|
||
` + "`!adventure repair all`" + ` — Repair all damaged equipment
|
||
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
|
||
` + "`!adventure temper`" + ` — Push a magic item one rarity step higher (blacksmith)
|
||
` + "`!adventure recipes`" + ` — List crafting recipes known at your current Foraging level
|
||
` + "`!adventure mastery`" + ` — Per-slot equipment mastery progress and active bonus
|
||
` + "`!adventure treasures`" + ` — List your treasures · ` + "`treasures lock`" + ` to refuse swaps
|
||
` + "`!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.UserID)
|
||
p.SendDM(ctx.Sender, text)
|
||
// Fall through to show menu
|
||
}
|
||
}
|
||
if !char.Alive {
|
||
text := renderAdvDeathStatusDM(char.UserID)
|
||
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.UserID, 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.UserID))
|
||
}
|
||
}
|
||
|
||
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.UserID, 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))
|
||
if disc := p.shopSessionAnnounceDiscount(ctx.Sender); disc != "" {
|
||
text += "\n\n" + disc
|
||
}
|
||
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)
|
||
}
|
||
err = p.SendDM(ctx.Sender, result)
|
||
// N1/A6 — a sale at Thom's is a moment the player is present and reading.
|
||
p.maybeFireAnchoredEvent(ctx.Sender, advEventChanceSell)
|
||
return err
|
||
}
|
||
|
||
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.")
|
||
}
|
||
entries := make([]AdvLeaderboardEntry, 0, len(chars))
|
||
for _, c := range chars {
|
||
entries = append(entries, AdvLeaderboardEntry{
|
||
UserID: c.UserID,
|
||
Level: dndLevelForUser(c.UserID),
|
||
MiningSkill: c.MiningSkill,
|
||
ForagingSkill: c.ForagingSkill,
|
||
FishingSkill: c.FishingSkill,
|
||
CurrentStreak: c.CurrentStreak,
|
||
})
|
||
}
|
||
text := renderAdvLeaderboard(entries)
|
||
return p.SendReply(ctx.RoomID, ctx.EventID, text)
|
||
}
|
||
|
||
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.")
|
||
}
|
||
|
||
displayName, _ := loadDisplayName(targetID)
|
||
if char.Alive {
|
||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s is already alive.", 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(targetID))
|
||
if p.achievements != nil {
|
||
p.achievements.GrantAchievement(targetID, "adv_revived")
|
||
}
|
||
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("✅ %s has been revived.", 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)
|
||
}
|
||
|
||
// "undo" reverts a recent treasure auto-swap. Checked ahead of the
|
||
// pending-interaction block so it doesn't get consumed by an unrelated
|
||
// pending prompt.
|
||
if lower == "undo" {
|
||
if p.tryTreasureUndo(ctx.Sender) {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// 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 "magic_equip":
|
||
return p.resolveMagicEquipReply(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 "temper_pick":
|
||
return p.resolveTemperPick(ctx, interaction)
|
||
case "temper_confirm":
|
||
return p.resolveTemperConfirm(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_curio":
|
||
return p.resolveShopCurioChoice(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, interaction)
|
||
}
|
||
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.UserID))
|
||
}
|
||
}
|
||
if !char.Alive {
|
||
return p.SendDM(ctx.Sender, renderAdvDeathStatusDM(char.UserID))
|
||
}
|
||
}
|
||
|
||
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, "")
|
||
}
|
||
|
||
// Phase R1 — legacy activity loop (1/dungeon, 2/mine, 3/forage, 4/fish + tier-or-name) is
|
||
// deprecated. The new path is the expedition system. Anything that parses as a legacy
|
||
// activity is intercepted here and routed to a deprecation notice; non-matches still
|
||
// fall through to the unknown-command response so other plugins can pick it up.
|
||
if isLegacyActivityInput(lower, char) {
|
||
return p.SendDM(ctx.Sender, renderLegacyActivityDeprecation(char))
|
||
}
|
||
|
||
return p.SendDM(ctx.Sender, "I didn't understand that. Type `!adventure help` for commands, or `!expedition` to head into a zone.")
|
||
}
|
||
|
||
// isLegacyActivityInput reports whether `input` looks like a legacy activity-loop
|
||
// dispatch (1/dungeon, 2/mine, 3/forage, 4/fish, with or without tier/location).
|
||
// Used by the Phase R1 deprecation gate to redirect the player to !expedition
|
||
// without swallowing arbitrary unrelated DMs.
|
||
func isLegacyActivityInput(input string, char *AdventureCharacter) bool {
|
||
if input == "" {
|
||
return false
|
||
}
|
||
parts := strings.SplitN(input, " ", 2)
|
||
first := parts[0]
|
||
switch first {
|
||
case "1", "2", "3", "4",
|
||
"d", "m", "f",
|
||
"dungeon", "mine", "forage", "forest", "fish", "fishing":
|
||
return true
|
||
}
|
||
for _, act := range []AdvActivityType{AdvActivityDungeon, AdvActivityMining, AdvActivityForaging, AdvActivityFishing} {
|
||
if findAdvLocation(act, input) != nil {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// renderLegacyActivityDeprecation is the Phase R1 deprecation DM. The standalone
|
||
// daily activity loop is gone; players now run expeditions and harvest inside
|
||
// rooms instead.
|
||
func renderLegacyActivityDeprecation(char *AdventureCharacter) string {
|
||
var sb strings.Builder
|
||
sb.WriteString("⚠️ **The daily adventure loop has retired.**\n\n")
|
||
sb.WriteString("Solo dungeon runs, mining, foraging, and fishing are now part of the **expedition system** — pick a zone, advance through rooms, harvest as you go.\n\n")
|
||
sb.WriteString("Get started:\n")
|
||
sb.WriteString("• `!expedition` — overview & open expeditions\n")
|
||
sb.WriteString("• `!expedition start <zone>` — begin a new run\n")
|
||
sb.WriteString("• Harvest is automatic — yields land as you walk through cleared rooms.\n")
|
||
sb.WriteString("• `!resources` — list nodes in your current room\n\n")
|
||
sb.WriteString("Shop, blacksmith, rest, and Thom Krooke are still here on `!adventure`.")
|
||
return sb.String()
|
||
}
|
||
|
||
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.")
|
||
}
|
||
_ = upsertPlayerMetaLifecycleState(char.UserID, lifecycleStateFromAdvChar(char))
|
||
|
||
logAdvActivity(char.UserID, string(AdvActivityRest), "", "rest", 0, 0, "")
|
||
|
||
dispName, _ := loadDisplayName(char.UserID)
|
||
|
||
// 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`",
|
||
dispName, 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 ─────────────────────────────────────────────────────
|
||
|
||
// checkTreasureDrop rolls the treasure table for one earned moment. weight
|
||
// scales the drop rate by how big that moment was (see advTreasureWeight*).
|
||
func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCharacter, loc *AdvLocation, weight float64) {
|
||
drop, roll, rate := rollAdvTreasureDropDetailed(loc.Tier, userID, p.chatLevel(userID), weight)
|
||
if drop == nil {
|
||
// Near-miss feedback: when the roll was within 2× of the drop rate,
|
||
// tell the player they almost got it. Treasure rates are 0.15–1.5%
|
||
// so without this signal the system feels invisible.
|
||
//
|
||
// Only for weighted moments (boss/elite/zone clear). A standard kill
|
||
// fires dozens of times a day on autopilot, and a near-miss DM on 3%
|
||
// of them turns the signal into noise.
|
||
if weight > 1 && rate > 0 && roll < rate*2 {
|
||
p.SendDM(userID, fmt.Sprintf("🎁 *Treasure: just missed* — rolled %.2f%% against %.2f%% drop chance.",
|
||
roll*100, rate*100))
|
||
}
|
||
return
|
||
}
|
||
|
||
// Check treasure count
|
||
count, err := advCountTreasures(userID)
|
||
if err != nil {
|
||
return
|
||
}
|
||
|
||
if count < maxTreasuresForTier(char.HouseTier) {
|
||
// 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 — try the auto-swap path before falling back to the manual
|
||
// discard prompt. The prompt was the wound; most players don't want
|
||
// agency at the rare moment of finding a treasure, they want the swap.
|
||
existing, err := advUserTreasures(userID)
|
||
if err != nil || len(existing) == 0 {
|
||
return
|
||
}
|
||
|
||
// Lock honors the player's "I'm done curating" state — refuse the drop
|
||
// instead of churning their set.
|
||
if char.TreasuresLocked {
|
||
p.SendDM(userID, fmt.Sprintf("💎 *Treasure refused* — found **%s** (Tier %d) but your treasures are locked. Unlock with `!adventure treasures unlock` to allow swaps.",
|
||
drop.Def.Name, drop.Def.Tier))
|
||
return
|
||
}
|
||
|
||
// Pick the worst replaceable existing treasure as the swap candidate.
|
||
// Irreplaceable treasures (special_* bonuses) are protected — never
|
||
// auto-discarded, even by a higher-tier drop.
|
||
worstIdx := -1
|
||
var worstRank advTreasureRankKey
|
||
for i := range existing {
|
||
ex := existing[i]
|
||
if advTreasureIrreplaceable(&ex) {
|
||
continue
|
||
}
|
||
r := advTreasureRank(&ex)
|
||
if worstIdx < 0 || advTreasureRankBetter(worstRank, r) {
|
||
worstIdx, worstRank = i, r
|
||
}
|
||
}
|
||
|
||
newRank := advTreasureRank(drop.Def)
|
||
|
||
// All existing are irreplaceable — fall back to the manual prompt so
|
||
// the player consciously chooses what to give up.
|
||
if worstIdx < 0 {
|
||
p.pending.Store(string(userID), &advPendingInteraction{
|
||
Type: "treasure_discard",
|
||
Data: &advPendingTreasureDiscard{
|
||
NewTreasure: drop.Def,
|
||
Existing: existing,
|
||
},
|
||
ExpiresAt: time.Now().Add(advDMResponseWindow),
|
||
})
|
||
p.SendDM(userID, renderAdvTreasureDiscardPrompt(drop.Def, existing))
|
||
return
|
||
}
|
||
|
||
// New treasure is irreplaceable but would force out a replaceable —
|
||
// auto-swap is fine here, the protection rule is about not removing
|
||
// existing irreplaceables.
|
||
if !advTreasureRankBetter(newRank, worstRank) {
|
||
// New is no better than the worst we'd give up. Don't churn —
|
||
// just tell the player about the find passively.
|
||
p.SendDM(userID, fmt.Sprintf("💎 *Found %s (Tier %d)* — left behind: your current set ranks higher.",
|
||
drop.Def.Name, drop.Def.Tier))
|
||
return
|
||
}
|
||
|
||
// Swap: discard worst, save new, store undo token.
|
||
discarded := existing[worstIdx]
|
||
if err := advDiscardTreasure(userID, discarded.Key); err != nil {
|
||
slog.Error("adventure: failed to discard for auto-swap", "user", userID, "err", err)
|
||
return
|
||
}
|
||
if err := advSaveTreasure(userID, drop.Def); err != nil {
|
||
slog.Error("adventure: failed to save auto-swapped treasure", "user", userID, "err", err)
|
||
// Best-effort recovery — restore the discarded one.
|
||
_ = advSaveTreasure(userID, &discarded)
|
||
return
|
||
}
|
||
|
||
// High-tier story items earn a public moment even via auto-swap, but
|
||
// defer the announce until after the undo window so reversed swaps
|
||
// don't get publicly announced. Capture the data we need on close
|
||
// over so the timer doesn't reach back into mutable state.
|
||
announceChar := *char
|
||
announceDef := *drop.Def
|
||
announceLoc := *loc
|
||
announceTimer := time.AfterFunc(advTreasureUndoWindow, func() {
|
||
// Atomically claim the token so a concurrent `undo` reply
|
||
// can't also fire the announcement (or vice-versa).
|
||
if _, ok := p.treasureUndo.LoadAndDelete(string(userID)); !ok {
|
||
return
|
||
}
|
||
p.announceTreasureToRoom(&announceChar, &announceDef, &announceLoc)
|
||
})
|
||
|
||
p.treasureUndo.Store(string(userID), &advTreasureUndoToken{
|
||
Discarded: &discarded,
|
||
Kept: drop.Def,
|
||
ExpiresAt: time.Now().Add(advTreasureUndoWindow),
|
||
AnnounceTimer: announceTimer,
|
||
})
|
||
|
||
p.SendDM(userID, fmt.Sprintf(
|
||
"💎 **Found %s** (Tier %d)\nAuto-swapped for **%s** (Tier %d, lowest in your set).\n_Reply `undo` within %d min to revert, or `!adventure treasures` to review._",
|
||
drop.Def.Name, drop.Def.Tier,
|
||
discarded.Name, discarded.Tier,
|
||
int(advTreasureUndoWindow.Minutes())))
|
||
}
|
||
|
||
// tryTreasureUndo reverses a recent auto-swap. Returns true when an undo
|
||
// was applied. Stale or missing tokens return false so the caller falls
|
||
// through to the regular DM dispatch.
|
||
func (p *AdventurePlugin) tryTreasureUndo(userID id.UserID) bool {
|
||
// LoadAndDelete races against the announce timer's claim — only
|
||
// one side wins, the other no-ops.
|
||
val, ok := p.treasureUndo.LoadAndDelete(string(userID))
|
||
if !ok {
|
||
return false
|
||
}
|
||
tok := val.(*advTreasureUndoToken)
|
||
// Cancel the deferred room announcement — a reversed swap shouldn't
|
||
// produce a public "X recovered Y" line.
|
||
if tok.AnnounceTimer != nil {
|
||
tok.AnnounceTimer.Stop()
|
||
}
|
||
if time.Now().After(tok.ExpiresAt) {
|
||
p.SendDM(userID, "⌛ The undo window expired. Your treasure swap stands.")
|
||
return true
|
||
}
|
||
|
||
// Reverse: discard the kept one, restore the discarded one.
|
||
if err := advDiscardTreasure(userID, tok.Kept.Key); err != nil {
|
||
slog.Error("adventure: failed to discard kept on undo", "user", userID, "err", err)
|
||
return true
|
||
}
|
||
if err := advSaveTreasure(userID, tok.Discarded); err != nil {
|
||
slog.Error("adventure: failed to restore on undo", "user", userID, "err", err)
|
||
return true
|
||
}
|
||
p.SendDM(userID, fmt.Sprintf("↩️ Reverted. Your **%s** is back, **%s** released.",
|
||
tok.Discarded.Name, tok.Kept.Name))
|
||
return true
|
||
}
|
||
|
||
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)
|
||
|
||
p.announceTreasureToRoom(char, def, loc)
|
||
}
|
||
|
||
// announceTreasureToRoom posts the public "X recovered Y from Z" notice for
|
||
// treasures that carry a RoomAnnounce string (typically tier 5 / story
|
||
// items). Called from both the normal discovery path and the auto-swap
|
||
// path so high-tier finds keep their public moment regardless of whether
|
||
// they arrived under or at the treasure cap.
|
||
func (p *AdventurePlugin) announceTreasureToRoom(char *AdventureCharacter, def *AdvTreasureDef, loc *AdvLocation) {
|
||
if def == nil || def.RoomAnnounce == "" {
|
||
return
|
||
}
|
||
gr := gamesRoom()
|
||
if gr == "" {
|
||
return
|
||
}
|
||
displayName, _ := loadDisplayName(char.UserID)
|
||
announce := advSubstituteFlavor(def.RoomAnnounce, map[string]string{
|
||
"{name}": 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
|
||
displayName, _ := loadDisplayName(char.UserID)
|
||
vars := map[string]string{
|
||
"{name}": 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(userID)
|
||
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) handleRecipesCmd(ctx MessageContext) error {
|
||
char, _, err := p.ensureCharacter(ctx.Sender)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||
}
|
||
return p.SendDM(ctx.Sender, renderRecipesKnown(char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier)))
|
||
}
|
||
|
||
func (p *AdventurePlugin) handleTreasuresCmd(ctx MessageContext, arg string) error {
|
||
userMu := p.advUserLock(ctx.Sender)
|
||
userMu.Lock()
|
||
defer userMu.Unlock()
|
||
|
||
char, err := loadAdvCharacter(ctx.Sender)
|
||
if err != nil || char == nil {
|
||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||
}
|
||
|
||
switch arg {
|
||
case "lock":
|
||
char.TreasuresLocked = true
|
||
if err := saveAdvCharacter(char); err != nil {
|
||
slog.Error("adventure: failed to save treasures_locked", "user", ctx.Sender, "err", err)
|
||
return p.SendDM(ctx.Sender, "Something went wrong. Try again.")
|
||
}
|
||
return p.SendDM(ctx.Sender, "🔒 **Treasures locked.** Drops at cap will be refused — no auto-swap. Unlock with `!adventure treasures unlock`.")
|
||
case "unlock":
|
||
char.TreasuresLocked = false
|
||
if err := saveAdvCharacter(char); err != nil {
|
||
slog.Error("adventure: failed to save treasures_locked", "user", ctx.Sender, "err", err)
|
||
return p.SendDM(ctx.Sender, "Something went wrong. Try again.")
|
||
}
|
||
return p.SendDM(ctx.Sender, "🔓 **Treasures unlocked.** Higher-tier drops will auto-swap your lowest replaceable treasure (with 10-min undo).")
|
||
case "":
|
||
treasures, err := advUserTreasures(ctx.Sender)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Failed to load your treasures.")
|
||
}
|
||
return p.SendDM(ctx.Sender, renderAdvTreasureList(treasures, char.TreasuresLocked))
|
||
default:
|
||
return p.SendDM(ctx.Sender, "Usage: `!adventure treasures` (list) · `!adventure treasures lock` · `!adventure treasures unlock`")
|
||
}
|
||
}
|
||
|
||
func (p *AdventurePlugin) handleMasteryCmd(ctx MessageContext) error {
|
||
if _, _, err := p.ensureCharacter(ctx.Sender); err != nil {
|
||
return p.SendDM(ctx.Sender, "Failed to load your character.")
|
||
}
|
||
equip, err := loadAdvEquipment(ctx.Sender)
|
||
if err != nil {
|
||
return p.SendDM(ctx.Sender, "Failed to load your equipment.")
|
||
}
|
||
return p.SendDM(ctx.Sender, renderAdvMasteryView(equip))
|
||
}
|
||
|
||
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.")
|
||
}
|