mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
UX S1: sell-all guard against magic-item deletion (B1)
`!adventure sell all` and `!adventure sell <name>` 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 <curio name> (refused + reroute).
This commit is contained in:
@@ -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 <name>` 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 <name>` 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 <name>`.)", 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."
|
||||
}
|
||||
|
||||
119
internal/plugin/adventure_shop_sell_test.go
Normal file
119
internal/plugin/adventure_shop_sell_test.go
Normal file
@@ -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 <curio name>` 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user