adventure: worn magic items bond when a slot frees, and can be taken off

An attunement item equipped while at the 3-bond cap sat permanently inert:
nothing re-bonded it when a slot opened, and the equip picker only lists
inventory, so a slotted item could never be reached again.

- reconcileMagicAttunements bonds worn-but-inert items whenever bond
  capacity is free (bonding is strictly beneficial; inert should only
  exist at the cap). Runs on equip-magic open and after any swap.
- New !adventure unequip-magic picker takes a worn item off and returns
  it to inventory at full value, freeing its bond slot (which then heals
  any straggler). Destructive-op-first ordering mirrors the equip path.
This commit is contained in:
prosolis
2026-07-15 22:41:01 -07:00
parent c9282cb18a
commit 27c2b48007
3 changed files with 311 additions and 3 deletions

View File

@@ -533,6 +533,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.handleEquipCmd(ctx) return p.handleEquipCmd(ctx)
case lower == "equip-magic" || lower == "equipmagic" || lower == "equip magic": case lower == "equip-magic" || lower == "equipmagic" || lower == "equip magic":
return p.handleEquipMagicCmd(ctx) return p.handleEquipMagicCmd(ctx)
case lower == "unequip-magic" || lower == "unequipmagic" || lower == "unequip magic":
return p.handleUnequipMagicCmd(ctx)
case lower == "inventory" || lower == "inv": case lower == "inventory" || lower == "inv":
return p.handleInventoryCmd(ctx) return p.handleInventoryCmd(ctx)
case lower == "leaderboard" || lower == "lb": case lower == "leaderboard" || lower == "lb":
@@ -594,6 +596,7 @@ const advHelpText = `**Adventure Commands**
` + "`!adventure buy <item>`" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `) ` + "`!adventure buy <item>`" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `)
` + "`!adventure equip`" + ` — Equip Masterwork gear from inventory ` + "`!adventure equip`" + ` — Equip Masterwork gear from inventory
` + "`!adventure equip-magic`" + ` — Equip magic items (curios) into your D&D slots ` + "`!adventure equip-magic`" + ` — Equip magic items (curios) into your D&D slots
` + "`!adventure unequip-magic`" + ` — Take a worn magic item off and return it to inventory
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `) ` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
` + "`!adventure inventory`" + ` — View your inventory ` + "`!adventure inventory`" + ` — View your inventory
` + "`!adventure vault`" + ` — Store items safely (Tier-4 Established home; ` + "`vault store/take <item>`" + `) ` + "`!adventure vault`" + ` — Store items safely (Tier-4 Established home; ` + "`vault store/take <item>`" + `)
@@ -940,6 +943,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
return p.handleMasterworkEquipReply(ctx, interaction) return p.handleMasterworkEquipReply(ctx, interaction)
case "magic_equip": case "magic_equip":
return p.resolveMagicEquipReply(ctx, interaction) return p.resolveMagicEquipReply(ctx, interaction)
case "magic_unequip":
return p.resolveMagicUnequipReply(ctx, interaction)
case "masterwork_equip_confirm": case "masterwork_equip_confirm":
return p.handleMasterworkEquipConfirm(ctx, interaction) return p.handleMasterworkEquipConfirm(ctx, interaction)
case "rival_rps": case "rival_rps":

View File

@@ -328,6 +328,42 @@ func countAttunedMagicItems(equipped map[DnDSlot]EquippedMagicItem) int {
return n return n
} }
// reconcileMagicAttunements bonds any worn attunement item that is sitting
// inert while a bond slot is free. Bonding is strictly beneficial (there are no
// cursed items), so the only legitimate reason for a worn attunement item to be
// unbonded is the hard cap — the moment a slot frees, every straggler should
// light up. Without this, an item equipped while at cap stays permanently inert:
// the equip picker only lists inventory, so a slotted item can never be reached
// to re-attempt bonding. Idempotent; returns the names of items it newly bonded.
func reconcileMagicAttunements(userID id.UserID) ([]string, error) {
equipped, err := loadEquippedMagicItems(userID)
if err != nil {
return nil, err
}
used := countAttunedMagicItems(equipped)
if used >= dndMagicItemAttuneLimit {
return nil, nil
}
var bonded []string
for _, ds := range dndSlotOrder { // deterministic order when slots are scarce
if used >= dndMagicItemAttuneLimit {
break
}
e, ok := equipped[ds]
if !ok || e.Item.ID == "" || !e.Item.Attunement || e.Attuned {
continue
}
if _, err := db.Get().Exec(
`UPDATE magic_item_equipped SET attuned = 1 WHERE user_id = ? AND slot = ?`,
string(userID), string(ds)); err != nil {
return bonded, err
}
bonded = append(bonded, e.Item.Name)
used++
}
return bonded, nil
}
// ── Combat hook ───────────────────────────────────────────────────────────── // ── Combat hook ─────────────────────────────────────────────────────────────
// applyMagicItemEffects layers the player's equipped magic items onto their // applyMagicItemEffects layers the player's equipped magic items onto their
@@ -523,6 +559,12 @@ func magicItemEffectSummary(mi MagicItem) string {
} }
func (p *AdventurePlugin) handleEquipMagicCmd(ctx MessageContext) error { func (p *AdventurePlugin) handleEquipMagicCmd(ctx MessageContext) error {
// Self-heal first: bond any worn item stranded inert while a slot is free
// (e.g. equipped at cap, then a bond slot opened). This is the only path a
// slotted-but-inert item can be reached from, since the picker below lists
// inventory only.
healed, _ := reconcileMagicAttunements(ctx.Sender)
items, err := loadAdvInventory(ctx.Sender) items, err := loadAdvInventory(ctx.Sender)
if err != nil { if err != nil {
return p.SendDM(ctx.Sender, "Failed to access your inventory.") return p.SendDM(ctx.Sender, "Failed to access your inventory.")
@@ -538,13 +580,23 @@ func (p *AdventurePlugin) handleEquipMagicCmd(ctx MessageContext) error {
} }
magic = append(magic, it) magic = append(magic, it)
} }
healNote := ""
if len(healed) > 0 {
healNote = fmt.Sprintf("🔗 A bond slot freed up — **%s** bonded and is now active.\n\n",
strings.Join(healed, "**, **"))
}
if len(magic) == 0 { if len(magic) == 0 {
return p.SendDM(ctx.Sender, msg := "No curios to equip yet — they drop from zones or Luigi's 🔮 shelf."
"No curios to equip yet — they drop from zones or Luigi's 🔮 shelf.") if healNote != "" {
msg = strings.TrimSpace(healNote)
}
return p.SendDM(ctx.Sender, msg)
} }
equipped, _ := loadEquippedMagicItems(ctx.Sender) equipped, _ := loadEquippedMagicItems(ctx.Sender)
var sb strings.Builder var sb strings.Builder
sb.WriteString(healNote)
sb.WriteString("🔮 **Equippable magic items:**\n\n") sb.WriteString("🔮 **Equippable magic items:**\n\n")
for i, it := range magic { for i, it := range magic {
base, _ := magicItemFromAdvItem(it) base, _ := magicItemFromAdvItem(it)
@@ -656,8 +708,113 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction
sb.WriteString(fmt.Sprintf("\nBonded (%d/%d bond slots used).", sb.WriteString(fmt.Sprintf("\nBonded (%d/%d bond slots used).",
countAttunedMagicItems(equipped)+1, dndMagicItemAttuneLimit)) countAttunedMagicItems(equipped)+1, dndMagicItemAttuneLimit))
case mi.Attunement && atCap: case mi.Attunement && atCap:
sb.WriteString(fmt.Sprintf("\n⚠ All %d bond slots are full — it's worn but **inert** until you free one (`!adventure equip-magic` to swap).", sb.WriteString(fmt.Sprintf("\n⚠ All %d bond slots are full — it's worn but **inert** until you free one (`!adventure unequip-magic` to take a bonded item off; this one bonds automatically once a slot opens).",
dndMagicItemAttuneLimit)) dndMagicItemAttuneLimit))
} }
// Swapping out the prior occupant may have freed a bond slot — light up any
// item that was stranded inert (including a previously-equipped one the
// picker could never reach).
if healed, _ := reconcileMagicAttunements(ctx.Sender); len(healed) > 0 {
sb.WriteString(fmt.Sprintf("\n🔗 A freed bond slot also activated **%s**.",
strings.Join(healed, "**, **")))
}
return p.SendDM(ctx.Sender, sb.String()) return p.SendDM(ctx.Sender, sb.String())
} }
// ── Unequip command (`!adventure unequip-magic`) ─────────────────────────────
// advPendingMagicUnequip is the pending-interaction payload for the numbered
// unequip picker. It lists slots (not inventory rows) because the item lives in
// magic_item_equipped, not adventure_inventory.
type advPendingMagicUnequip struct {
Slots []DnDSlot
}
func (p *AdventurePlugin) handleUnequipMagicCmd(ctx MessageContext) error {
equipped, err := loadEquippedMagicItems(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your equipped magic items.")
}
var sb strings.Builder
sb.WriteString("🔮 **Worn magic items** — reply with a number to take one off (it returns to your inventory), or \"cancel\".\n\n")
var slots []DnDSlot
for _, ds := range dndSlotOrder { // stable numbering across repeated calls
e, ok := equipped[ds]
if !ok || e.Item.ID == "" {
continue
}
slots = append(slots, ds)
mi := e.Effective()
status := ""
if mi.Attunement {
if e.Attuned {
status = " — bonded"
} else {
status = " — _(inert)_"
}
}
sb.WriteString(fmt.Sprintf("%d. **%s** _(%s)_ → %s slot%s\n",
len(slots), mi.Name, mi.Rarity, ds, status))
}
if len(slots) == 0 {
return p.SendDM(ctx.Sender, "You aren't wearing any magic items. `!adventure equip-magic` to put one on.")
}
p.pending.Store(string(ctx.Sender), &advPendingInteraction{
Type: "magic_unequip",
Data: &advPendingMagicUnequip{Slots: slots},
ExpiresAt: time.Now().Add(advDMResponseWindow),
})
return p.SendDM(ctx.Sender, sb.String())
}
func (p *AdventurePlugin) resolveMagicUnequipReply(ctx MessageContext, interaction *advPendingInteraction) error {
data := interaction.Data.(*advPendingMagicUnequip)
reply := strings.TrimSpace(ctx.Body)
if strings.EqualFold(reply, "cancel") {
return p.SendDM(ctx.Sender, "Unequip cancelled.")
}
idx, ok := parseMenuIndex(reply, len(data.Slots))
if !ok {
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".")
}
slot := data.Slots[idx]
equipped, err := loadEquippedMagicItems(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your equipped magic items.")
}
e, ok := equipped[slot]
if !ok || e.Item.ID == "" {
return p.SendDM(ctx.Sender, "That slot is already empty.")
}
// Clear the slot FIRST, then return the item to inventory at full value.
// This mirrors the equip resolver's ordering (destructive op first, restore
// on failure): the other order could leave the item both worn and in
// inventory — a free duplicate — on a transient DB error.
if err := unequipMagicItem(ctx.Sender, slot); err != nil {
return p.SendDM(ctx.Sender, "Failed to take that item off.")
}
back := magicItemSellAt(e.Item, e.Temper)
back.SkillSource = "magic_item:" + e.Item.ID
if err := addAdvInventoryItem(ctx.Sender, back); err != nil {
// Roll back: re-equip exactly as it was so the item isn't lost.
if rbErr := equipMagicItem(ctx.Sender, slot, e.Item.ID, e.Attuned, e.Temper); rbErr != nil {
slog.Error("magic-item: unequip failed AND re-equip rollback failed",
"user", ctx.Sender, "item", e.Item.ID, "inv_err", err, "rollback_err", rbErr)
}
return p.SendDM(ctx.Sender, "Failed to return that item to your inventory.")
}
mi := e.Effective()
msg := fmt.Sprintf("📦 **%s** taken off your %s slot and returned to inventory.", mi.Name, slot)
// Freeing a bonded slot may let a worn-but-inert item finally bond.
if healed, _ := reconcileMagicAttunements(ctx.Sender); len(healed) > 0 {
msg += fmt.Sprintf("\n🔗 That freed a bond slot — **%s** is now active.",
strings.Join(healed, "**, **"))
}
return p.SendDM(ctx.Sender, msg)
}

View File

@@ -252,6 +252,152 @@ func TestSwapBackReturnsFullValue(t *testing.T) {
} }
} }
// TestReconcileBondsStrandedItem reproduces the player report: an attunement
// item equipped while at the bond cap sits inert, then a bond slot frees up.
// The item is worn (not in inventory) so the equip picker can never reach it —
// reconcile must be what lights it up.
func TestReconcileBondsStrandedItem(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
user := id.UserID("@stranded:test.invalid")
// Gather attunement items in distinct slots — need cap+1 of them.
seen := map[DnDSlot]bool{}
var att []MagicItem
for _, ds := range dndSlotOrder {
for _, mi := range magicItemRegistry {
if mi.Attunement && mi.Slot == ds && !seen[ds] {
att = append(att, mi)
seen[ds] = true
}
}
}
if len(att) < dndMagicItemAttuneLimit+1 {
t.Skipf("registry has only %d attunement slots, need %d", len(att), dndMagicItemAttuneLimit+1)
}
// Bond the first `limit` items, then wear one more inert (attuned=false).
for i := 0; i < dndMagicItemAttuneLimit; i++ {
if err := equipMagicItem(user, att[i].Slot, att[i].ID, true, 0); err != nil {
t.Fatalf("bond seed %d: %v", i, err)
}
}
stranded := att[dndMagicItemAttuneLimit]
if err := equipMagicItem(user, stranded.Slot, stranded.ID, false, 0); err != nil {
t.Fatalf("equip stranded: %v", err)
}
// At cap, reconcile must be a no-op — the stranded item stays inert.
if healed, err := reconcileMagicAttunements(user); err != nil || len(healed) != 0 {
t.Fatalf("reconcile at cap = %v (err %v), want no change", healed, err)
}
// Free a slot (the player swaps out a bonded item), then reconcile.
if err := unequipMagicItem(user, att[0].Slot); err != nil {
t.Fatalf("free slot: %v", err)
}
healed, err := reconcileMagicAttunements(user)
if err != nil {
t.Fatalf("reconcile after free: %v", err)
}
if len(healed) != 1 || healed[0] != stranded.Name {
t.Fatalf("reconcile healed %v, want [%s]", healed, stranded.Name)
}
equipped, _ := loadEquippedMagicItems(user)
if !equipped[stranded.Slot].Attuned {
t.Errorf("stranded item still inert after reconcile")
}
if got := countAttunedMagicItems(equipped); got != dndMagicItemAttuneLimit {
t.Errorf("bonded count = %d, want %d", got, dndMagicItemAttuneLimit)
}
// Idempotent: a second pass changes nothing.
if healed, _ := reconcileMagicAttunements(user); len(healed) != 0 {
t.Errorf("second reconcile healed %v, want none", healed)
}
}
// TestUnequipMagicReturnsToInventoryAndHeals drives the unequip resolver end to
// end: taking a bonded item off must return it to inventory AND free its bond
// slot so a worn-but-inert item lights up.
func TestUnequipMagicReturnsToInventoryAndHeals(t *testing.T) {
dir := t.TempDir()
db.Close()
if err := db.Init(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(db.Close)
user := id.UserID("@unequip:test.invalid")
seen := map[DnDSlot]bool{}
var att []MagicItem
for _, ds := range dndSlotOrder {
for _, mi := range magicItemRegistry {
if mi.Attunement && mi.Slot == ds && !seen[ds] {
att = append(att, mi)
seen[ds] = true
}
}
}
if len(att) < dndMagicItemAttuneLimit+1 {
t.Skipf("registry has only %d attunement slots, need %d", len(att), dndMagicItemAttuneLimit+1)
}
// Bond the cap, then wear one more inert.
for i := 0; i < dndMagicItemAttuneLimit; i++ {
if err := equipMagicItem(user, att[i].Slot, att[i].ID, true, 0); err != nil {
t.Fatalf("bond seed %d: %v", i, err)
}
}
stranded := att[dndMagicItemAttuneLimit]
if err := equipMagicItem(user, stranded.Slot, stranded.ID, false, 0); err != nil {
t.Fatalf("equip stranded: %v", err)
}
p := &AdventurePlugin{} // nil Sink/Client → SendDM is a no-op
interaction := &advPendingInteraction{
Type: "magic_unequip",
Data: &advPendingMagicUnequip{Slots: []DnDSlot{att[0].Slot}},
}
if err := p.resolveMagicUnequipReply(MessageContext{Sender: user, Body: "1"}, interaction); err != nil {
t.Fatalf("resolve unequip: %v", err)
}
// The unequipped item is back in inventory as a curio.
inv, err := loadAdvInventory(user)
if err != nil {
t.Fatal(err)
}
found := false
for _, it := range inv {
if it.SkillSource == "magic_item:"+att[0].ID {
found = true
}
}
if !found {
t.Errorf("unequipped item %q not returned to inventory", att[0].ID)
}
// Its slot is empty, and the freed bond slot bonded the stranded item.
equipped, _ := loadEquippedMagicItems(user)
if _, still := equipped[att[0].Slot]; still {
t.Errorf("slot %s still occupied after unequip", att[0].Slot)
}
if !equipped[stranded.Slot].Attuned {
t.Errorf("stranded item did not bond after a slot freed")
}
if got := countAttunedMagicItems(equipped); got != dndMagicItemAttuneLimit {
t.Errorf("bonded count = %d, want %d", got, dndMagicItemAttuneLimit)
}
}
// TestEquippedMagicItemRoundTrip exercises the DB persistence layer: equip, // TestEquippedMagicItemRoundTrip exercises the DB persistence layer: equip,
// load, attunement counting, unequip. // load, attunement counting, unequip.
func TestEquippedMagicItemRoundTrip(t *testing.T) { func TestEquippedMagicItemRoundTrip(t *testing.T) {