From 27c2b48007ceea4509829ea846f910035a07aa9f Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:41:01 -0700 Subject: [PATCH] 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. --- internal/plugin/adventure.go | 5 + internal/plugin/magic_items_gameplay.go | 163 ++++++++++++++++++- internal/plugin/magic_items_gameplay_test.go | 146 +++++++++++++++++ 3 files changed, 311 insertions(+), 3 deletions(-) diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index b43a561..c38cc17 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -533,6 +533,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { return p.handleEquipCmd(ctx) case lower == "equip-magic" || lower == "equipmagic" || lower == "equip magic": return p.handleEquipMagicCmd(ctx) + case lower == "unequip-magic" || lower == "unequipmagic" || lower == "unequip magic": + return p.handleUnequipMagicCmd(ctx) case lower == "inventory" || lower == "inv": return p.handleInventoryCmd(ctx) case lower == "leaderboard" || lower == "lb": @@ -594,6 +596,7 @@ const advHelpText = `**Adventure Commands** ` + "`!adventure buy `" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `) ` + "`!adventure equip`" + ` — Equip Masterwork gear from inventory ` + "`!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 `" + ` — Sell an inventory item (or ` + "`sell all`" + `) ` + "`!adventure inventory`" + ` — View your inventory ` + "`!adventure vault`" + ` — Store items safely (Tier-4 Established home; ` + "`vault store/take `" + `) @@ -940,6 +943,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact return p.handleMasterworkEquipReply(ctx, interaction) case "magic_equip": return p.resolveMagicEquipReply(ctx, interaction) + case "magic_unequip": + return p.resolveMagicUnequipReply(ctx, interaction) case "masterwork_equip_confirm": return p.handleMasterworkEquipConfirm(ctx, interaction) case "rival_rps": diff --git a/internal/plugin/magic_items_gameplay.go b/internal/plugin/magic_items_gameplay.go index 73069a0..8510ab3 100644 --- a/internal/plugin/magic_items_gameplay.go +++ b/internal/plugin/magic_items_gameplay.go @@ -328,6 +328,42 @@ func countAttunedMagicItems(equipped map[DnDSlot]EquippedMagicItem) int { 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 ───────────────────────────────────────────────────────────── // 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 { + // 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) if err != nil { 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) } + 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 { - return p.SendDM(ctx.Sender, - "No curios to equip yet — they drop from zones or Luigi's 🔮 shelf.") + msg := "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) var sb strings.Builder + sb.WriteString(healNote) sb.WriteString("🔮 **Equippable magic items:**\n\n") for i, it := range magic { base, _ := magicItemFromAdvItem(it) @@ -656,8 +708,113 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction sb.WriteString(fmt.Sprintf("\nBonded (%d/%d bond slots used).", countAttunedMagicItems(equipped)+1, dndMagicItemAttuneLimit)) 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)) } + + // 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()) } + +// ── 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) +} diff --git a/internal/plugin/magic_items_gameplay_test.go b/internal/plugin/magic_items_gameplay_test.go index 329be55..01a3e37 100644 --- a/internal/plugin/magic_items_gameplay_test.go +++ b/internal/plugin/magic_items_gameplay_test.go @@ -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, // load, attunement counting, unequip. func TestEquippedMagicItemRoundTrip(t *testing.T) {