Files
gogobee/internal/plugin/adventure_masterwork.go
prosolis 476384206e N4/E2 review fixes: close a gift-enabled equip dupe; harden vault writes
A high-effort code review of the gifting/vault work turned up three issues:

1. Item duplication (real exploit). The masterwork/arena equip confirmation
   captures an item at prompt time and equips it on "yes" without re-checking
   ownership. Since MasterworkGear/ArenaGear are now giftable, a player could
   !give the item away in the window, then confirm — minting a copy onto
   themselves while the recipient kept theirs. Fixed by taking the user lock
   (making gift-vs-confirm atomic) and re-loading the inventory to refuse an
   item that is no longer ours. Magic items are exempt (not giftable); other
   delete paths load-and-remove within one locked invocation.

2. clearAdvInventory read the vault-filtered list but its DELETE wiped every
   row including vaulted ones — no live caller today, but it would break the
   vault's "safe from !sell all" promise the instant sell-all routed through
   it. Delete now matches the read (in_vault = 0).

3. vault store/take took no per-user lock, so two near-simultaneous stores
   could both pass the capacity check and overfill the 10-slot vault. Now
   serialized on the same user lock the gift path uses.

go test ./... green; golden byte-identical.

Claude-Session: https://claude.ai/code/session_017mEwUmmS7aQTP2NQXj6rUa
2026-07-10 14:05:24 -07:00

601 lines
25 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strings"
"time"
"maunium.net/go/mautrix/id"
)
// ── Masterwork Skill Drop Definitions ──────────────────────────────────────
type MasterworkDef struct {
Slot EquipmentSlot
Activity AdvActivityType
SkillSource string // "mining", "fishing", "foraging"
Tier int // 1-5, matches location tier
Name string
Description string // character sheet / trade listing
DropRate float64 // per-tier: 0.05, 0.04, 0.03, 0.02, 0.005
}
var masterworkDefs = []MasterworkDef{
// ── Mining → Weapon (sword) ────────────────────────────────────────────
{
Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 1,
Name: "The Ore You Know", DropRate: 0.05,
Description: "Found wedged in a copper seam. Pre-sharpened by geological pressure over approximately four million years. Somehow better than what the shop sells for twice the effort.",
},
{
Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 2,
Name: "Shaft Splitter", DropRate: 0.04,
Description: "This came out of an iron vein looking exactly like this. You didn't forge it. You found it. The iron vein did all the work. You're going to take full credit.",
},
{
Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 3,
Name: "The Vein Attempt", DropRate: 0.03,
Description: "Named after the desperate prayer you said when your pickaxe hit something that wasn't ore. It wasn't a god that answered. It was a sword. Close enough.",
},
{
Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 4,
Name: "Lode Bearer", DropRate: 0.02,
Description: "A serious blade pulled from a serious depth. The edge was formed under pressure that would have killed you. Something down there was planning ahead.",
},
{
Slot: SlotWeapon, Activity: AdvActivityMining, SkillSource: "mining", Tier: 5,
Name: "The Motherload", DropRate: 0.005,
Description: "There is no good explanation for why this was in the rock. The rock isn't talking. You're not asking. You've decided to stop thinking about it and just use it.",
},
// ── Fishing → Armor (chest) ────────────────────────────────────────────
{
Slot: SlotArmor, Activity: AdvActivityFishing, SkillSource: "fishing", Tier: 1,
Name: "Mudscale Plate", DropRate: 0.05,
Description: "Assembled from scales shed by something that lives in the Muddy Pond and apparently grew very large doing it. Smells like the pond. Always will.",
},
{
Slot: SlotArmor, Activity: AdvActivityFishing, SkillSource: "fishing", Tier: 2,
Name: "Riverglass Mail", DropRate: 0.04,
Description: "The links are formed from some kind of calcified river growth — not metal, exactly, but harder than it has any right to be. It came up on your line. You're choosing not to investigate further.",
},
{
Slot: SlotArmor, Activity: AdvActivityFishing, SkillSource: "fishing", Tier: 3,
Name: "Tidal Cuirass", DropRate: 0.03,
Description: "Whatever this came from, it lived in tidal zones and learned to take a beating. The surface is worn smooth from years of wave impact. It fits like it was meant for something your size. It wasn't.",
},
{
Slot: SlotArmor, Activity: AdvActivityFishing, SkillSource: "fishing", Tier: 4,
Name: "Pelagic Plate", DropRate: 0.02,
Description: "Deep sea armor that ended up in Sable Cove via a process you would rather not reconstruct. The plating is layered like fish scales but heavier. Cold to the touch even in direct sun. You've stopped noticing.",
},
{
Slot: SlotArmor, Activity: AdvActivityFishing, SkillSource: "fishing", Tier: 5,
Name: "The Deepforged Carapace", DropRate: 0.005,
Description: "This came up on the line from the Abyssal Trench and the line almost didn't hold. It is not from a fish. It is not from anything you have seen or want to see. It fits perfectly. You find this more unsettling than the alternative.",
},
// ── Foraging <20><> Boots ───────────────────────────────────────────────────
{
Slot: SlotBoots, Activity: AdvActivityForaging, SkillSource: "foraging", Tier: 1,
Name: "Bark Treads", DropRate: 0.05,
Description: "Woven from bark that was evidently done with being a tree. Lightweight, grippy, and somehow waterproof. Found under a log in the Sunlit Meadow where they had no business being. Better than the shop's offering, which tells you something about the shop.",
},
{
Slot: SlotBoots, Activity: AdvActivityForaging, SkillSource: "foraging", Tier: 2,
Name: "Thornweave Boots", DropRate: 0.04,
Description: "Made entirely from briars that have been worked into something wearable, which should not be possible. They don't scratch. They don't catch. They move like they know where you're going before you do.",
},
{
Slot: SlotBoots, Activity: AdvActivityForaging, SkillSource: "foraging", Tier: 3,
Name: "The Wandering Sole", DropRate: 0.03,
Description: "These have been places. The sole is worn in the pattern of a specific path you've never walked. Whoever wore them before you covered serious ground. They handed off the work to you without being asked.",
},
{
Slot: SlotBoots, Activity: AdvActivityForaging, SkillSource: "foraging", Tier: 4,
Name: "Deepwood Striders", DropRate: 0.02,
Description: "The forest didn't make these. The forest let these happen. There's a difference and you feel it when you put them on — like being tolerated by something very old that has decided you're not worth stopping.",
},
{
Slot: SlotBoots, Activity: AdvActivityForaging, SkillSource: "foraging", Tier: 5,
Name: "The Last Step", DropRate: 0.005,
Description: "Found at the edge of the Fungal Dark where the light stops. You almost didn't go that far. Something about wearing them suggests you will always go that far, from now on. This is either good news or the other kind.",
},
}
// masterworkDefFor returns the masterwork definition for a given activity and tier.
func masterworkDefFor(activity AdvActivityType, tier int) *MasterworkDef {
for i := range masterworkDefs {
if masterworkDefs[i].Activity == activity && masterworkDefs[i].Tier == tier {
return &masterworkDefs[i]
}
}
return nil
}
// masterworkSlotActivities are the three activity lines the catalog covers,
// one equipment slot each: mining→weapon, fishing→armor, foraging→boots.
var masterworkSlotActivities = []AdvActivityType{
AdvActivityMining, AdvActivityFishing, AdvActivityForaging,
}
// masterworkDefForZone picks a masterwork for a dungeon kill. The catalog is
// keyed by gathering activity — there is no dungeon line — so a zone drop
// rolls across all three slots and hands back that line's item at the zone's
// tier. The item keeps its own SkillSource: a masterwork blade found in a
// crypt still helps you mine.
func masterworkDefForZone(tier int) *MasterworkDef {
act := masterworkSlotActivities[rand.IntN(len(masterworkSlotActivities))]
return masterworkDefFor(act, tier)
}
// ── Drop Flavor Text (DM) ─────────────────────────────────────────────────
func masterworkDropFlavorText(activity AdvActivityType, tier int) string {
switch activity {
case AdvActivityDungeon:
switch {
case tier <= 2:
return "The thing you killed was carrying it, which raises the question of where it got it. You check the body for a maker's mark and find none. You check the body for anything else and find nothing. You take the gear and you do not think about the question."
case tier == 3:
return "It's propped against the wall behind the corpse, upright, deliberate. Not dropped. Placed. Someone came down here, set this down carefully, and did not come back for it. You pick it up. It fits your hand better than anything you paid for."
case tier == 4:
return "There's a pile of gear in the corner, most of it ruined, all of it belonging to people who came this far and no further. One piece is untouched. The rust stopped at its edge, as if the rust knew better. You add it to your kit and you leave the rest as you found it."
default:
return "It is the only thing in the room that isn't broken. Whatever lived here kept it, and kept it well, and cleaned it, and did not use it. You take it because you won. That is the arrangement. On the way out you do not turn around, and you tell yourself that is a choice."
}
case AdvActivityMining:
switch {
case tier <= 2:
return "Your pickaxe hits something that rings differently. Metallic but not ore. You dig around it. It is a sword. It was in the rock. You decide this is fine and put it in your bag."
case tier == 3:
return "The vein opens up and there it is. A blade, fully formed, embedded in silver ore like it grew there. Your mining handbook does not cover this. You take it anyway."
case tier == 4:
return "Deep in the forge level, your pickaxe sparks against something that sparks back. You spend twenty minutes clearing the rock around it. When you pull it free it catches the lamplight in a way that makes the other miners stop working. You pretend not to notice."
default:
return "The deepest seam in the Abyssal Mine gives up something it was keeping. No drama. No earthquake. The rock just opens and there it is. A blade that looks like it was waiting. You take it. You don't ask how long it's been there. You don't want to know."
}
case AdvActivityFishing:
switch {
case tier <= 2:
return "You reel in something heavy that turns out not to be a fish. It is armor, or something shaped like armor, and it is in better condition than anything at the shop. You rinse it off in the water and put it on. The pond smells follow it everywhere."
case tier == 3:
return "The line goes dead-weight and you pull up a cuirass that was clearly not in the catch plan. It's heavier than it looks and colder than the water should account for. You add it to your haul. The fish you didn't catch would have been worth less anyway."
case tier == 4:
return "Something takes your bait and doesn't run. It just sinks. You reel against it for ten minutes before the line comes up with armor attached and nothing else. No creature. No explanation. The armor is exceptional. You decide this is the important part."
default:
return "The Abyssal Trench gives up the carapace without a fight, which is somehow worse than if it had fought. You didn't hook it. It was just there on the line when you pulled up. It fits. You start fishing again immediately because thinking about it isn't an option you're entertaining."
}
case AdvActivityForaging:
switch {
case tier <= 2:
return "Under a root cluster in the foraging area, half-buried in moss: a pair of boots. They fit. You don't know what to do with that information so you put them on and keep foraging. They are noticeably better than what you were wearing."
case tier == 3:
return "You push through a dense section of the Ancient Forest and find a clearing that wasn't on any route you've taken before. In the center, on a flat rock, a pair of boots. No tracks around the rock. No sign anyone left them. They're your size. You take them and don't mention the clearing to anyone."
case tier == 4:
return "The Verdant Depths foraging run turns up the usual haul plus one thing that isn't usual: boots, hanging from a branch at eye level, laced and ready. The branch is forty feet up. The boots are at eye level. You decide to focus on the boots and not the branch."
default:
return "The Fungal Dark produces your haul and then, at the very last moment before you turn back, a pair of boots you didn't put there. They are better than anything in the shop. The fungi around them are growing in an outward pattern, like they were avoiding something. You put the boots on. The fungi don't react. You don't find this reassuring."
}
}
return ""
}
// ── Drop Logic ─────────────────────────────────────────────────────────────
func (p *AdventurePlugin) checkMasterworkDrop(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, loc *AdvLocation, outcome AdvOutcomeType) {
// Only full-success outcomes drop masterwork (success or exceptional)
if outcome != AdvOutcomeSuccess && outcome != AdvOutcomeExceptional {
return
}
// Dungeons have no catalog line of their own; they roll across all three.
var def *MasterworkDef
if loc.Activity == AdvActivityDungeon {
def = masterworkDefForZone(loc.Tier)
} else {
def = masterworkDefFor(loc.Activity, loc.Tier)
}
if def == nil {
return // no masterwork available for this activity+tier
}
// Roll for drop (chat level rare bonus applied additively)
dropRate := def.DropRate + chatLevelRareBonus(p.chatLevel(userID))
if rand.Float64() >= dropRate {
return
}
// Check current equipment in target slot
existing := equip[def.Slot]
// Determine: auto-equip, inventory, or silent discard.
// Compare effective tiers: Masterwork = tier*1.25, Arena = tier*1.5, Shop = tier.
// Only auto-equip if the new Masterwork drop is strictly better.
newEffective := float64(def.Tier) * 1.25 // incoming masterwork
existingEffective := advEffectiveTier(existing)
autoEquip := false
if existing == nil {
autoEquip = true
} else if existing.Masterwork && def.Tier <= existing.Tier {
return // silent discard: same or lower tier masterwork already equipped
} else if newEffective > existingEffective {
autoEquip = true // genuinely better than what they have
}
// First-drop detection. Counter persists via saveAdvCharacter fan-out.
priorDrops, err := loadMasterworkDrops(userID)
if err != nil {
slog.Error("adventure: failed to load masterwork counter", "user", userID, "err", err)
}
isFirstDrop := priorDrops == 0
newDrops := priorDrops + 1
if char, lerr := loadAdvCharacter(userID); lerr == nil {
char.MasterworkDropsReceived = newDrops
if err := saveAdvCharacter(char); err != nil {
slog.Error("adventure: failed to save masterwork counter", "user", userID, "err", err)
}
}
if autoEquip {
// Equip directly
eq := equip[def.Slot]
if eq == nil {
eq = &AdvEquipment{Slot: def.Slot}
equip[def.Slot] = eq
}
eq.Tier = def.Tier
eq.Condition = 100
eq.Name = def.Name
eq.ActionsUsed = 0
eq.ArenaTier = 0
eq.ArenaSet = ""
eq.Masterwork = true
eq.SkillSource = def.SkillSource
if err := saveAdvEquipment(userID, eq); err != nil {
slog.Error("adventure: failed to save masterwork drop", "user", userID, "slot", def.Slot, "err", err)
return
}
} else {
// Send to inventory
item := AdvItem{
Name: def.Name,
Type: "MasterworkGear",
Tier: def.Tier,
Value: 0,
Slot: def.Slot,
SkillSource: def.SkillSource,
}
if err := addAdvInventoryItem(userID, item); err != nil {
slog.Error("adventure: failed to add masterwork to inventory", "user", userID, "err", err)
return
}
}
// Build DM text
var sb strings.Builder
if isFirstDrop {
sb.WriteString("_This doesn't come from the shop._\n\n")
}
// Flavor follows where the item was found, not which catalog line it came
// from — a dungeon drop must not open with a pickaxe.
flavor := masterworkDropFlavorText(loc.Activity, def.Tier)
if flavor != "" {
sb.WriteString(fmt.Sprintf("_%s_\n\n", flavor))
}
sb.WriteString(fmt.Sprintf("⭐ **Masterwork Drop: %s** (T%d)\n\n", def.Name, def.Tier))
sb.WriteString(fmt.Sprintf("_%s_\n\n", def.Description))
sb.WriteString(fmt.Sprintf("Masterwork %s — 1.25x effectiveness, +5%% %s success.\n", slotTitle(def.Slot), def.SkillSource))
if autoEquip {
sb.WriteString("Equipped automatically.")
} else {
sb.WriteString("Added to inventory. Use `!adventure equip` to equip it.")
}
p.SendDM(userID, sb.String())
// Room announcement (tiered)
displayName, _ := loadDisplayName(userID)
p.postMasterworkAnnouncement(displayName, def, loc.Name)
}
// ── Room Announcements ─────────────────────────────────────────────────────
func (p *AdventurePlugin) postMasterworkAnnouncement(playerName string, def *MasterworkDef, locName string) {
if def.Tier <= 2 {
return // DM only, no room post
}
gr := gamesRoom()
if gr == "" {
return
}
verb := "working"
switch def.Activity {
case AdvActivityMining:
verb = "mining"
case AdvActivityFishing:
verb = "fishing"
case AdvActivityForaging:
verb = "foraging"
}
var msg string
switch {
case def.Tier == 3:
msg = fmt.Sprintf("%s found something unexpected while %s. The %s suggests they've been at this longer than most.",
playerName, verb, def.Name)
case def.Tier == 4:
domain := "mine"
switch def.Activity {
case AdvActivityFishing:
domain = "sea"
case AdvActivityForaging:
domain = "forest"
}
msg = fmt.Sprintf("%s pulled %s out of %s. ⭐ Masterwork %s. The shop doesn't sell this. The %s apparently does.",
playerName, def.Name, locName, slotTitle(def.Slot), domain)
case def.Tier == 5:
msg = fmt.Sprintf("%s has %s. ⭐⭐ Tier 5 Masterwork %s, found in %s. Nobody else has this. The circumstances of its discovery have been described as \"unclear\" and left at that.",
playerName, def.Name, slotTitle(def.Slot), locName)
}
if msg != "" {
p.SendMessage(gr, msg)
}
}
// ── Equip Command ──────────────────────────────────────────────────────────
type advPendingMasterworkEquip struct {
Items []AdvItem
}
type advPendingMasterworkConfirm struct {
Item AdvItem
}
func (p *AdventurePlugin) handleEquipCmd(ctx MessageContext) error {
_, err := loadAdvCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "No adventurer found. Use `!adventure` to start.")
}
items, err := loadAdvInventory(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load inventory.")
}
// Filter to special gear (Masterwork + Arena) items only
var mwItems []AdvItem
for _, it := range items {
if it.Type == "MasterworkGear" || it.Type == "ArenaGear" {
mwItems = append(mwItems, it)
}
}
if len(mwItems) == 0 {
return p.SendDM(ctx.Sender, "You have no special gear waiting to be equipped. Go find some.")
}
equip, err := loadAdvEquipment(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load equipment.")
}
// Build listing
var sb strings.Builder
sb.WriteString("⭐ **Your unequipped special gear:**\n\n")
for i, it := range mwItems {
current := equip[it.Slot]
currentDesc := "None"
if current != nil {
tag := fmt.Sprintf("T%d shop", current.Tier)
if current.Masterwork {
tag = fmt.Sprintf("T%d ⭐", current.Tier)
} else if current.ArenaTier > 0 {
tag = fmt.Sprintf("T%d ⚔️ arena", current.Tier)
}
currentDesc = fmt.Sprintf("%s (%s)", current.Name, tag)
}
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\".")
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "masterwork_equip",
Data: &advPendingMasterworkEquip{Items: mwItems},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(ctx.Sender, sb.String())
}
func (p *AdventurePlugin) handleMasterworkEquipReply(ctx MessageContext, interaction *advPendingInteraction) error {
data := interaction.Data.(*advPendingMasterworkEquip)
reply := strings.TrimSpace(ctx.Body)
if strings.EqualFold(reply, "cancel") {
return p.SendDM(ctx.Sender, "Equip cancelled.")
}
idx, ok := parseMenuIndex(reply, len(data.Items))
if !ok {
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
}
selected := data.Items[idx]
equip, err := loadAdvEquipment(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load equipment.")
}
current := equip[selected.Slot]
// Warn if downgrading
warning := ""
if current != nil && current.ArenaTier > 0 {
warning = fmt.Sprintf("\n⚠ This will replace your **%s** (Arena gear). Are you sure?\n", current.Name)
} else if current != nil && current.Masterwork && current.Tier > selected.Tier {
warning = fmt.Sprintf("\n⚠ %s is Tier %d. Your current %s is Tier %d. You'll be downgrading tier.\n",
selected.Name, selected.Tier, current.Name, current.Tier)
}
currentName := "nothing"
if current != nil {
currentName = current.Name
}
// Store confirmation pending
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "masterwork_equip_confirm",
Data: &advPendingMasterworkConfirm{Item: selected},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
bonusDesc := ""
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)?%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 {
data := interaction.Data.(*advPendingMasterworkConfirm)
reply := strings.TrimSpace(strings.ToLower(ctx.Body))
if reply != "yes" {
return p.SendDM(ctx.Sender, "Equip cancelled.")
}
// Serialize against the sender's own !give: MasterworkGear/ArenaGear are
// giftable (N4/E2), so the item captured at prompt time may have changed
// hands before this confirmation. The lock makes gift-vs-confirm atomic,
// and the re-load below refuses an item that is no longer ours — otherwise
// equipping the captured copy would mint a duplicate the gift already
// handed to the recipient.
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
selected := data.Item
inv, err := loadAdvInventory(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your inventory.")
}
stillOwned := false
for _, it := range inv {
if it.ID == selected.ID {
stillOwned = true
break
}
}
if !stillOwned {
return p.SendDM(ctx.Sender, fmt.Sprintf("**%s** isn't in your inventory anymore. Equip cancelled.", selected.Name))
}
equip, err := loadAdvEquipment(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load equipment.")
}
current := equip[selected.Slot]
// If current equipment is special, move it to inventory
if current != nil && (current.Masterwork || current.ArenaTier > 0) {
oldItem := AdvItem{
Name: current.Name,
Type: "MasterworkGear",
Tier: current.Tier,
Value: 0,
Slot: current.Slot,
SkillSource: current.SkillSource,
}
if current.ArenaTier > 0 {
oldItem.Type = "ArenaGear"
}
if err := addAdvInventoryItem(ctx.Sender, oldItem); err != nil {
slog.Error("adventure: failed to move old equip to inventory", "user", ctx.Sender, "err", err)
}
}
// Equip the selected item
eq := equip[selected.Slot]
if eq == nil {
eq = &AdvEquipment{Slot: selected.Slot}
equip[selected.Slot] = eq
}
eq.Tier = selected.Tier
eq.Condition = 100
eq.Name = selected.Name
eq.ActionsUsed = 0
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.")
}
// Remove from inventory
if err := removeAdvInventoryItem(selected.ID); err != nil {
slog.Error("adventure: failed to remove equipped item from inventory", "user", ctx.Sender, "err", err)
}
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.
func slotToActivity(s EquipmentSlot) AdvActivityType {
switch s {
case SlotWeapon:
return AdvActivityMining
case SlotArmor:
return AdvActivityFishing
case SlotBoots:
return AdvActivityForaging
}
return ""
}