From 25accab5c0265a5e25ac06130cf25a4ceb40148e Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 14 May 2026 21:19:31 -0700 Subject: [PATCH] UX S1: sell-all guard against magic-item deletion (B1) `!adventure sell all` and `!adventure sell ` previously treated Type=="magic_item" rows as bulk loot, silently deleting bonded curios for their base coin Value. Mirror the existing MasterworkGear/Arena/card branch and reroute the player to `!adventure equip-magic`. Tally kept curios in the sell-all summary; surface a dedicated empty-rejection message when the only contents are curios; add the same guard to the single-item path. Tests cover: mixed bag (magic item preserved, junk sells), curio-only bag (no-op + reroute), explicit sell (refused + reroute). --- internal/plugin/adventure_shop.go | 18 +++ internal/plugin/adventure_shop_sell_test.go | 119 ++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 internal/plugin/adventure_shop_sell_test.go diff --git a/internal/plugin/adventure_shop.go b/internal/plugin/adventure_shop.go index f5125b5..bd42acf 100644 --- a/internal/plugin/adventure_shop.go +++ b/internal/plugin/adventure_shop.go @@ -841,11 +841,19 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string { var sold int var keptSpecial int var keptConsumable int + var keptMagic int for _, item := range items { if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" { keptSpecial++ continue } + if item.Type == "magic_item" { + // B1: magic items are bonded curios, not bulk loot — sell-all must + // never silently delete them. Route the player to !adventure + // equip-magic instead. + keptMagic++ + continue + } if item.Type == "consumable" { keptConsumable++ continue @@ -859,6 +867,10 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string { if sold == 0 { switch { + case keptMagic > 0 && (keptSpecial > 0 || keptConsumable > 0): + return "Your inventory only contains special gear, curios, and/or consumables. The merchant won't touch any of it. Use `!adventure equip-magic` for curios, `!adventure equip` for gear, or `!adventure sell ` for individual consumables." + case keptMagic > 0: + return "Your inventory only contains curios (magic items). The merchant doesn't deal in those. Use `!adventure equip-magic` instead." case keptSpecial > 0 && keptConsumable > 0: return "Your inventory only contains special gear and consumables. The merchant won't touch any of it. Use `!adventure equip` for gear or `!adventure sell ` for individual consumables." case keptSpecial > 0: @@ -886,6 +898,9 @@ func (p *AdventurePlugin) advSellAll(userID id.UserID) string { if keptConsumable > 0 { msg += fmt.Sprintf("\n(%d consumable(s) kept — `sell all` doesn't touch them. Sell explicitly with `!adventure sell `.)", keptConsumable) } + if keptMagic > 0 { + msg += fmt.Sprintf("\n(%d curio(s) kept — `!adventure equip-magic` to bond them. The merchant wouldn't know what to do with them.)", keptMagic) + } return msg } @@ -901,6 +916,9 @@ func (p *AdventurePlugin) advSellItem(userID id.UserID, itemName string) string if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" { return fmt.Sprintf("**%s** is special gear. The merchant won't touch it. Use `!adventure equip` to equip it.", item.Name) } + if item.Type == "magic_item" { + return fmt.Sprintf("**%s** is a curio — the merchant doesn't deal in those. Use `!adventure equip-magic` to bond it instead.", item.Name) + } if err := removeAdvInventoryItem(item.ID); err != nil { return "Failed to sell that item." } diff --git a/internal/plugin/adventure_shop_sell_test.go b/internal/plugin/adventure_shop_sell_test.go new file mode 100644 index 0000000..74a2a23 --- /dev/null +++ b/internal/plugin/adventure_shop_sell_test.go @@ -0,0 +1,119 @@ +package plugin + +import ( + "strings" + "testing" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// freshShopTestDB spins up an empty DB in a tempdir for the sell-path tests. +// Distinct from setupAuditTestDB, which clones the prod snapshot. +func freshShopTestDB(t *testing.T) { + t.Helper() + dir := t.TempDir() + db.Close() + if err := db.Init(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(db.Close) +} + +// TestSellAll_PreservesMagicItems — B1 (data-loss blocker): `sell all` must +// route around curios (Type=="magic_item") so a careless liquidation can't +// silently delete a Legendary Ring. Junk in the same bag still sells. +func TestSellAll_PreservesMagicItems(t *testing.T) { + freshShopTestDB(t) + uid := id.UserID("@sellall_magic:test") + + curio := AdvItem{Name: "Ring of Protection", Type: "magic_item", Tier: 3, Value: 500} + junk := AdvItem{Name: "Iron Ore", Type: "material", Tier: 1, Value: 40} + if err := addAdvInventoryItem(uid, curio); err != nil { + t.Fatal(err) + } + if err := addAdvInventoryItem(uid, junk); err != nil { + t.Fatal(err) + } + + euro := &EuroPlugin{} + euro.ensureBalance(uid) + p := &AdventurePlugin{euro: euro} + + msg := p.advSellAll(uid) + if !strings.Contains(msg, "Sold 1 item") { + t.Errorf("expected exactly one item sold, msg=%q", msg) + } + if !strings.Contains(strings.ToLower(msg), "curio") { + t.Errorf("sell-all summary should call out kept curios, msg=%q", msg) + } + + left, err := loadAdvInventory(uid) + if err != nil { + t.Fatal(err) + } + if len(left) != 1 || left[0].Type != "magic_item" { + t.Fatalf("magic item not preserved: %+v", left) + } + + // Payout reflects only the junk's €40 (5% pot cut → ~€38 credited). + bal := euro.GetBalance(uid) + if bal <= 0 || bal > 40 { + t.Errorf("payout %v outside expected (0,40] for €40 junk", bal) + } +} + +// TestSellAll_OnlyMagicItems — when the bag is curios only, sell-all reroutes +// to !adventure equip-magic and touches nothing. +func TestSellAll_OnlyMagicItems(t *testing.T) { + freshShopTestDB(t) + uid := id.UserID("@sellall_only_magic:test") + + if err := addAdvInventoryItem(uid, AdvItem{Name: "Cloak of Elvenkind", Type: "magic_item", Value: 300}); err != nil { + t.Fatal(err) + } + + euro := &EuroPlugin{} + euro.ensureBalance(uid) + p := &AdventurePlugin{euro: euro} + + msg := p.advSellAll(uid) + if !strings.Contains(msg, "equip-magic") { + t.Errorf("expected reroute to equip-magic, msg=%q", msg) + } + if euro.GetBalance(uid) != 0 { + t.Errorf("balance moved on curio-only sell-all: %v", euro.GetBalance(uid)) + } + left, _ := loadAdvInventory(uid) + if len(left) != 1 { + t.Errorf("curio deleted by sell-all: %+v", left) + } +} + +// TestSellItem_MagicItemRerouted — explicit `sell ` must refuse +// and point the player at the bonding command. +func TestSellItem_MagicItemRerouted(t *testing.T) { + freshShopTestDB(t) + uid := id.UserID("@sellone_magic:test") + + curio := AdvItem{Name: "Amulet of Health", Type: "magic_item", Value: 750} + if err := addAdvInventoryItem(uid, curio); err != nil { + t.Fatal(err) + } + + euro := &EuroPlugin{} + euro.ensureBalance(uid) + p := &AdventurePlugin{euro: euro} + + msg := p.advSellItem(uid, "amulet") + if !strings.Contains(msg, "equip-magic") { + t.Errorf("expected reroute to equip-magic, msg=%q", msg) + } + if euro.GetBalance(uid) != 0 { + t.Errorf("balance moved on refused curio sale: %v", euro.GetBalance(uid)) + } + left, _ := loadAdvInventory(uid) + if len(left) != 1 { + t.Errorf("curio deleted by refused sale: %+v", left) + } +}