From be76973fd226443c48984bd217c0be791914943a Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:23:58 -0700 Subject: [PATCH] Fix arena gear lockout, extend DM window, scrub mining flavor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arena helmet auto-equip now preserves the existing helmet to inventory (or stashes the new drop if the existing piece is strictly higher tier), and arena gear can be re-equipped via !adventure equip. Shop only blocks swaps that aren't an upgrade over current arena tier. DM response window bumped from 15m to 3h so NPC/treasure/shop prompts don't expire while players are AFK. MiningSuccess flavor pools no longer name specific ores in prose — uses {ore}/{location}/{tool} placeholders so the narration matches the actual loot. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/plugin/adventure.go | 4 +- internal/plugin/adventure_arena.go | 54 +++++++++++++++++-- internal/plugin/adventure_flavor_mining.go | 53 +++++++++---------- internal/plugin/adventure_housing.go | 2 +- internal/plugin/adventure_masterwork.go | 60 +++++++++++++++------- internal/plugin/adventure_npcs.go | 2 +- internal/plugin/adventure_shop.go | 21 ++++---- 7 files changed, 135 insertions(+), 61 deletions(-) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index b470ad1..6b9f6fc 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -46,7 +46,7 @@ func (p *AdventurePlugin) advUserLock(userID id.UserID) *sync.Mutex { return val.(*sync.Mutex) } -const advDMResponseWindow = 15 * time.Minute +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. @@ -1108,7 +1108,7 @@ func (p *AdventurePlugin) checkTreasureDrop(userID id.UserID, char *AdventureCha NewTreasure: drop.Def, Existing: existing, }, - ExpiresAt: time.Now().Add(5 * time.Minute), + ExpiresAt: time.Now().Add(advDMResponseWindow), }) text := renderAdvTreasureDiscardPrompt(drop.Def, existing) diff --git a/internal/plugin/adventure_arena.go b/internal/plugin/adventure_arena.go index 255b9b6..6ebfb14 100644 --- a/internal/plugin/adventure_arena.go +++ b/internal/plugin/adventure_arena.go @@ -1040,6 +1040,15 @@ func arenaGearByTier(tier int) *ArenaGearSet { return &arenaGearSets[tier-1] } +func arenaGearByName(name string) *ArenaGearSet { + for i := range arenaGearSets { + if arenaGearSets[i].HelmetName == name { + return &arenaGearSets[i] + } + } + return nil +} + // arenaRollHelmetDrop checks if a helmet should drop and equips it if the player // doesn't already have an arena helmet at this tier or higher. If they do, the // drop is silently discarded (no duplicate drops). @@ -1063,14 +1072,49 @@ func (p *AdventurePlugin) arenaRollHelmetDrop(userID id.UserID, tier int) *Arena } helmet, hasHelmet := equip[SlotHelmet] - if hasHelmet && (helmet.ArenaTier >= tier || helmet.Tier > tier) { - // Already has same-or-better arena helmet, or a higher-tier normal helmet — discard + hasRealHelmet := hasHelmet && helmet.Tier > 0 + + // Discard only if existing is a same-or-better arena helmet (duplicate suppression). + if hasRealHelmet && helmet.ArenaTier >= tier { return nil } - // Equip the arena helmet + // If existing is a strictly better non-arena helmet, stash the drop in inventory + // so the player can equip it later via !adventure equip rather than overwriting. + if hasRealHelmet && helmet.ArenaTier == 0 && helmet.Tier > tier { + if err := addAdvInventoryItem(userID, AdvItem{ + Name: gear.HelmetName, + Type: "ArenaGear", + Tier: tier, + Slot: SlotHelmet, + }); err != nil { + slog.Error("arena: failed to stash arena helmet drop", "user", userID, "err", err) + return nil + } + return gear + } + + // Auto-equip: move the old helmet to inventory first (preserving its flavor) + // so the player doesn't silently lose it. + if hasRealHelmet { + oldType := "ShopGear" + if helmet.Masterwork { + oldType = "MasterworkGear" + } else if helmet.ArenaTier > 0 { + oldType = "ArenaGear" + } + if err := addAdvInventoryItem(userID, AdvItem{ + Name: helmet.Name, + Type: oldType, + Tier: helmet.Tier, + Slot: SlotHelmet, + SkillSource: helmet.SkillSource, + }); err != nil { + slog.Error("arena: failed to move old helmet to inventory", "user", userID, "err", err) + } + } + if !hasHelmet { - // Shouldn't happen (all slots created at character creation), but be safe helmet = &AdvEquipment{Slot: SlotHelmet} } helmet.Tier = tier @@ -1079,6 +1123,8 @@ func (p *AdventurePlugin) arenaRollHelmetDrop(userID id.UserID, tier int) *Arena helmet.ActionsUsed = 0 helmet.ArenaTier = tier helmet.ArenaSet = gear.SetKey + helmet.Masterwork = false + helmet.SkillSource = "" if err := saveAdvEquipment(userID, helmet); err != nil { slog.Error("arena: failed to save arena helmet drop", "user", userID, "err", err) diff --git a/internal/plugin/adventure_flavor_mining.go b/internal/plugin/adventure_flavor_mining.go index af00906..73a958d 100644 --- a/internal/plugin/adventure_flavor_mining.go +++ b/internal/plugin/adventure_flavor_mining.go @@ -363,9 +363,9 @@ var MiningSuccess = map[int][]string{ "In and out. {ore}, €{value}, zero cave-ins, one bat that thought about it " + "and decided not to. Your {tool} is slightly worse. Everything else is better. " + - "By Surface Pits standards, a professional operation.", + "By {location} standards, a professional operation.", - "The copper vein ran further than the map suggested. Further and richer. " + + "The seam ran further than the map suggested. Further and richer. " + "You took {ore} worth €{value} and left more than you could carry. " + "This is the correct response to a rich vein. Come back tomorrow. " + "Bring a bigger bag. Bring a better pickaxe.", @@ -380,68 +380,69 @@ var MiningSuccess = map[int][]string{ "and the rock rewarded you for it. This is the entire transaction. " + "It is, when it works, genuinely satisfying.", - "The tin seam yielded more than expected and the coal beneath it " + - "turned out to have a copper pocket behind it " + + "The upper seam yielded more than expected and the layer beneath it " + + "turned out to have a pocket behind it " + "that wasn't on any map because nobody had gone that far before. " + "You went that far. {ore} total, €{value}. " + "The {tool} is worse. The discovery is better.", }, 2: { - "Iron Ridge delivered today. {ore} extracted, €{value} assessed, " + + "{location} delivered today. {ore} extracted, €{value} assessed, " + "cave troll acknowledged and navigated around with something approaching grace. " + "The {tool} performed admirably. Your back has filed a formal complaint. " + "The back's complaint is noted and will not change the outcome.", - "A good seam. A real seam — proper iron, " + - "the kind where the rock splits cleanly and the ore sits in it " + - "like it's been waiting. {ore} worth €{value}. {xp} XP. " + + "A good seam. A real seam — the kind where the rock splits cleanly " + + "and the ore sits in it like it's been waiting. " + + "{ore} worth €{value}. {xp} XP. " + "This is what mining is supposed to feel like. " + "Remember it for the days it doesn't.", - "The lead pocket was deeper than the iron, which meant going deeper " + - "than planned, which meant more exposure to everything Iron Ridge contains. " + + "The deeper pocket was further in than the surface yield suggested, " + + "which meant going deeper than planned, " + + "which meant more exposure to everything {location} contains. " + "You made it. {ore} worth €{value}. The depth was worth it. " + "The {tool} has opinions about the depth. The opinions are structural.", }, 3: { - "Silver. Real silver, properly embedded, not quartz, not illusion, " + - "not iron-coloured rock lying about its nature. Silver. " + + "Whatever you came down here for, you took it. No illusions, no " + + "iron-coloured rock lying about its nature. " + "{ore} worth €{value}. {xp} XP. Your {tool} is a real tool " + - "and this was a real seam and you extracted the silver. " + + "and this was a real seam and you extracted what was in it. " + "That's the whole job. Today the whole job got done.", - "The Silver Seam at depth, past the quartz formations, " + + "{location} at depth, past the decorative formations, " + "past the section that looks better than it is, " + - "down to where the silver sits in the rock like an argument for effort. " + + "down to where the yield sits in the rock like an argument for effort. " + "{ore}, €{value}, {xp} XP, home by sunset. " + "A good day. In a mine. Those exist.", }, 4: { - "Deeprock gold. Actual gold, not pyrite, not gold-coloured copper, " + - "not optimism in mineral form. Gold. {ore} worth €{value}. " + - "The Deeprock tried to discourage you with atmosphere and pressure. " + - "You brought a Mithril Pickaxe and ignored the atmosphere.", + "Proper haul. No pyrite, no gold-coloured copper, " + + "no optimism in mineral form. {ore} worth €{value}. " + + "{location} tried to discourage you with atmosphere and pressure. " + + "You brought a {tool} and ignored the atmosphere.", - "You went to the fourth level of the Deeprock and you came back " + + "You went to the fourth level of {location} and you came back " + "with {ore} worth €{value} and {xp} XP and a story " + "that mostly involves how far down the fourth level is. " + "It is very far down. The ore was worth being very far down. Today.", }, 5: { - "Mythril. You found mythril and extracted mythril " + - "and brought mythril home and mythril is worth €{value} per unit. " + - "The {ore} you're carrying is worth €{value} total. " + - "{xp} XP. The Diamond Pickaxe is a Diamond Pickaxe for a reason. " + + "You found the seam and you extracted the seam " + + "and brought the seam home. " + + "{ore} worth €{value} total. " + + "{xp} XP. The {tool} is a {tool} for a reason. " + "Today you proved the reason.", "The Voidstone was present but passive today. " + "The Dragon Crystals were warm but not hostile. " + - "The mythril seam was open and you took {ore} worth €{value} from it. " + - "{xp} XP. The Mythril Caverns had a good day at the same time you did. " + + "The seam was open and you took {ore} worth €{value} from it. " + + "{xp} XP. {location} had a good day at the same time you did. " + "This does not happen often. Don't examine it. Take the ore home.", }, } diff --git a/internal/plugin/adventure_housing.go b/internal/plugin/adventure_housing.go index cc9c83f..f7db09d 100644 --- a/internal/plugin/adventure_housing.go +++ b/internal/plugin/adventure_housing.go @@ -217,7 +217,7 @@ func thomShopView(char *AdventureCharacter, balance float64) string { sb.WriteString(fmt.Sprintf(" Closing costs (5%%): €%d\n", int(float64(def.BasePrice)*0.05))) sb.WriteString(fmt.Sprintf(" Realtor fees (6%%): €%d\n", int(float64(def.BasePrice)*0.06))) sb.WriteString(fmt.Sprintf(" **Total: €%d**\n\n", total)) - sb.WriteString(fmt.Sprintf("Reply `buy` to purchase, or `buy ` for a down payment.\n")) + sb.WriteString(fmt.Sprintf("`!thom buy` to purchase outright, or `!thom buy ` for a down payment.\n")) } } diff --git a/internal/plugin/adventure_masterwork.go b/internal/plugin/adventure_masterwork.go index 1d842a6..b0fc9df 100644 --- a/internal/plugin/adventure_masterwork.go +++ b/internal/plugin/adventure_masterwork.go @@ -333,16 +333,16 @@ func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error { return p.SendDM(ctx.Sender, "Failed to load inventory.") } - // Filter to masterwork items only + // Filter to special gear (Masterwork + Arena) items only var mwItems []AdvItem for _, it := range items { - if it.Type == "MasterworkGear" { + if it.Type == "MasterworkGear" || it.Type == "ArenaGear" { mwItems = append(mwItems, it) } } if len(mwItems) == 0 { - return p.SendDM(ctx.Sender, "You have no Masterwork gear waiting to be equipped. Go find some.") + return p.SendDM(ctx.Sender, "You have no special gear waiting to be equipped. Go find some.") } equip, err := loadAdvEquipment(ctx.Sender) @@ -352,7 +352,7 @@ func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error { // Build listing var sb strings.Builder - sb.WriteString("⭐ **Your unequipped Masterwork gear:**\n\n") + sb.WriteString("⭐ **Your unequipped special gear:**\n\n") for i, it := range mwItems { current := equip[it.Slot] @@ -366,8 +366,12 @@ func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error { } currentDesc = fmt.Sprintf("%s (%s)", current.Name, tag) } - sb.WriteString(fmt.Sprintf("%d. %s (T%d %s) — currently: %s\n", - i+1, it.Name, it.Tier, slotTitle(it.Slot), currentDesc)) + marker := "⭐" + if it.Type == "ArenaGear" { + marker = "⚔️" + } + sb.WriteString(fmt.Sprintf("%d. %s %s (T%d %s) — currently: %s\n", + i+1, marker, it.Name, it.Tier, slotTitle(it.Slot), currentDesc)) } sb.WriteString("\nReply with a number to equip, or \"cancel\".") @@ -375,7 +379,7 @@ func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error { p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "masterwork_equip", Data: &advPendingMasterworkEquip{Items: mwItems}, - ExpiresAt: time.Now().Add(5 * time.Minute), + ExpiresAt: time.Now().Add(advDMResponseWindow), }) return p.SendDM(ctx.Sender, sb.String()) @@ -430,17 +434,22 @@ func (p *AdventurePlugin) handleMasterworkEquipReply(ctx MessageContext, interac p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "masterwork_equip_confirm", Data: &advPendingMasterworkConfirm{Item: selected}, - ExpiresAt: time.Now().Add(5 * time.Minute), + ExpiresAt: time.Now().Add(advDMResponseWindow), }) - def := masterworkDefFor(slotToActivity(selected.Slot), selected.Tier) bonusDesc := "" - if def != nil { - bonusDesc = fmt.Sprintf("\n%s gives 1.25x %s effectiveness and +5%% %s success.\n", selected.Name, slotTitle(selected.Slot), def.SkillSource) + marker := "⭐" + if selected.Type == "ArenaGear" { + marker = "⚔️" + } else { + def := masterworkDefFor(slotToActivity(selected.Slot), selected.Tier) + if def != nil { + bonusDesc = fmt.Sprintf("\n%s gives 1.25x %s effectiveness and +5%% %s success.\n", selected.Name, slotTitle(selected.Slot), def.SkillSource) + } } - return p.SendDM(ctx.Sender, fmt.Sprintf("Equip **%s** (T%d ⭐ %s)?%s\nThis replaces your %s. The old item goes to inventory.%s\nReply \"yes\" to confirm.", - selected.Name, selected.Tier, slotTitle(selected.Slot), warning, currentName, bonusDesc)) + return p.SendDM(ctx.Sender, fmt.Sprintf("Equip **%s** (T%d %s %s)?%s\nThis replaces your %s. The old item goes to inventory.%s\nReply \"yes\" to confirm.", + selected.Name, selected.Tier, marker, slotTitle(selected.Slot), warning, currentName, bonusDesc)) } func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, interaction *advPendingInteraction) error { @@ -488,10 +497,21 @@ func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, inter eq.Condition = 100 eq.Name = selected.Name eq.ActionsUsed = 0 - eq.ArenaTier = 0 - eq.ArenaSet = "" - eq.Masterwork = true - eq.SkillSource = selected.SkillSource + + if selected.Type == "ArenaGear" { + eq.Masterwork = false + eq.SkillSource = "" + eq.ArenaTier = selected.Tier + eq.ArenaSet = "" + if gs := arenaGearByName(selected.Name); gs != nil { + eq.ArenaSet = gs.SetKey + } + } else { + eq.ArenaTier = 0 + eq.ArenaSet = "" + eq.Masterwork = true + eq.SkillSource = selected.SkillSource + } if err := saveAdvEquipment(ctx.Sender, eq); err != nil { return p.SendDM(ctx.Sender, "Failed to save equipment. Try again.") @@ -502,7 +522,11 @@ func (p *AdventurePlugin) handleMasterworkEquipConfirm(ctx MessageContext, inter slog.Error("adventure: failed to remove equipped item from inventory", "user", ctx.Sender, "err", err) } - return p.SendDM(ctx.Sender, fmt.Sprintf("**%s** equipped. ⭐ Masterwork %s, Tier %d.", selected.Name, slotTitle(selected.Slot), selected.Tier)) + kind := "⭐ Masterwork" + if selected.Type == "ArenaGear" { + kind = "⚔️ Arena" + } + return p.SendDM(ctx.Sender, fmt.Sprintf("**%s** equipped. %s %s, Tier %d.", selected.Name, kind, slotTitle(selected.Slot), selected.Tier)) } // slotToActivity converts an EquipmentSlot to the activity that drops masterwork for it. diff --git a/internal/plugin/adventure_npcs.go b/internal/plugin/adventure_npcs.go index 42d196d..c4d4005 100644 --- a/internal/plugin/adventure_npcs.go +++ b/internal/plugin/adventure_npcs.go @@ -158,7 +158,7 @@ func (p *AdventurePlugin) npcFireEncounter(userID id.UserID, npc string) { p.pending.Store(string(userID), &advPendingInteraction{ Type: "npc_encounter", Data: npc, - ExpiresAt: time.Now().Add(5 * time.Minute), + ExpiresAt: time.Now().Add(advDMResponseWindow), }) if err := p.SendDM(userID, prompt); err != nil { diff --git a/internal/plugin/adventure_shop.go b/internal/plugin/adventure_shop.go index 6d6c80d..1fcaebd 100644 --- a/internal/plugin/adventure_shop.go +++ b/internal/plugin/adventure_shop.go @@ -417,10 +417,10 @@ func (p *AdventurePlugin) resolveShopItemChoice(ctx MessageContext, interaction // Check Arena/Masterwork block — only block if shop item isn't clearly better. // Arena gear always blocks (1.5x multiplier, earned through combat). // Masterwork blocks only if the shop item's tier doesn't exceed the MW effective tier. - if current != nil && current.ArenaTier > 0 { + if current != nil && current.ArenaTier > 0 && def.Tier <= current.ArenaTier { p.pending.Store(string(ctx.Sender), interaction) - return p.SendDM(ctx.Sender, fmt.Sprintf("You have **%s** (Arena gear) in that slot. A shop item can't replace that. You earned it.\n\nPick something else, or \"back\" to return.", - current.Name)) + return p.SendDM(ctx.Sender, fmt.Sprintf("You have **%s** (Arena gear, T%d) in that slot. That T%d shop item isn't an upgrade.\n\nPick something else, or \"back\" to return.", + current.Name, current.ArenaTier, def.Tier)) } if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) { p.pending.Store(string(ctx.Sender), interaction) @@ -479,8 +479,8 @@ func (p *AdventurePlugin) resolveShopConfirm(ctx MessageContext, interaction *ad current := equip[data.Slot] // Re-check Arena/Masterwork block. - if current != nil && current.ArenaTier > 0 { - return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** is Arena gear. Luigi won't overwrite it.", current.Name)) + if current != nil && current.ArenaTier > 0 && def.Tier <= current.ArenaTier { + return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** (Arena gear T%d) is not worse than that T%d shop item.", current.Name, current.ArenaTier, def.Tier)) } if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) { return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** (Masterwork ⭐) is better than that T%d shop item.", current.Name, def.Tier)) @@ -510,6 +510,8 @@ func (p *AdventurePlugin) resolveShopConfirm(ctx MessageContext, interaction *ad if current.Masterwork { itemType = "MasterworkGear" // Masterwork has no resale value (it's special, not shop-priced) + } else if current.ArenaTier > 0 { + itemType = "ArenaGear" } else if current.Tier < len(equipmentTiers[data.Slot]) { resaleValue = int64(equipmentTiers[data.Slot][current.Tier].Price * 0.3) } @@ -682,10 +684,9 @@ func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, } // Block shop purchases that would overwrite arena gear (always) or // masterwork gear (only if shop item isn't clearly better). - if current != nil && current.ArenaTier > 0 { - return fmt.Sprintf("You already have **%s** (Arena gear). A shop item cannot replace this. "+ - "You earned that. Don't throw it away.", - current.Name) + if current != nil && current.ArenaTier > 0 && def.Tier <= current.ArenaTier { + return fmt.Sprintf("You already have **%s** (Arena gear T%d). That T%d shop item isn't an upgrade.", + current.Name, current.ArenaTier, def.Tier) } if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) { return fmt.Sprintf("You already have **%s** (Masterwork ⭐ T%d). That T%d shop item isn't an upgrade.", @@ -714,6 +715,8 @@ func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, var resaleValue int64 if current.Masterwork { itemType = "MasterworkGear" + } else if current.ArenaTier > 0 { + itemType = "ArenaGear" } else if current.Tier < len(equipmentTiers[slot]) { resaleValue = int64(equipmentTiers[slot][current.Tier].Price * 0.3) }