diff --git a/internal/db/db.go b/internal/db/db.go index f52028a..f439887 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1268,6 +1268,19 @@ CREATE TABLE IF NOT EXISTS adventure_treasures ( ); CREATE INDEX IF NOT EXISTS idx_adv_treasure_user ON adventure_treasures(user_id); +-- Magic items equipped into the D&D 10-slot scheme (Open5e SRD registry). +-- One row per (user, DnDSlot). item_id references magicItemRegistry. Effects +-- are applied in combat from a codified Rarity+Kind formula; attunement gates +-- whether the item's effect counts (see magic_items_gameplay.go). +CREATE TABLE IF NOT EXISTS magic_item_equipped ( + user_id TEXT NOT NULL, + slot TEXT NOT NULL, + item_id TEXT NOT NULL, + attuned INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, slot) +); +CREATE INDEX IF NOT EXISTS idx_magic_item_equipped_user ON magic_item_equipped(user_id); + CREATE TABLE IF NOT EXISTS adventure_buffs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index ff1e4e5..a2289a8 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -447,6 +447,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { return p.handleBuyCmd(ctx, strings.TrimSpace(args[4:])) case lower == "equip": return p.handleEquipCmd(ctx) + case lower == "equip-magic" || lower == "equipmagic" || lower == "equip magic": + return p.handleEquipMagicCmd(ctx) case lower == "inventory" || lower == "inv": return p.handleInventoryCmd(ctx) case lower == "leaderboard" || lower == "lb": @@ -488,9 +490,10 @@ const advHelpText = `**Adventure Commands** ` + "`!adventure status`" + ` — View your character sheet ` + "`!expedition`" + ` — Adventure: pick a zone, advance through rooms, harvest as you go ` + "`!adventure shop`" + ` — Browse equipment categories -` + "`!adventure shop `" + ` — View a category (weapon, armor, helmet, boots, tool) +` + "`!adventure shop `" + ` — View a category (weapon, armor, helmet, boots, tool, supplies, curios) ` + "`!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 sell `" + ` — Sell an inventory item (or ` + "`sell all`" + `) ` + "`!adventure inventory`" + ` — View your inventory ` + "`!adventure leaderboard`" + ` — View the leaderboard @@ -824,6 +827,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact return p.handleTreasureDiscard(ctx, interaction) case "masterwork_equip": return p.handleMasterworkEquipReply(ctx, interaction) + case "magic_equip": + return p.resolveMagicEquipReply(ctx, interaction) case "masterwork_equip_confirm": return p.handleMasterworkEquipConfirm(ctx, interaction) case "rival_rps": @@ -838,6 +843,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact return p.resolveShopItemChoice(ctx, interaction) case "shop_supply": return p.resolveShopSupplyChoice(ctx, interaction) + case "shop_curio": + return p.resolveShopCurioChoice(ctx, interaction) case "shop_confirm": return p.resolveShopConfirm(ctx, interaction) case "hospital_pay": diff --git a/internal/plugin/adventure_consumables.go b/internal/plugin/adventure_consumables.go index 2ace364..7fffe48 100644 --- a/internal/plugin/adventure_consumables.go +++ b/internal/plugin/adventure_consumables.go @@ -65,14 +65,17 @@ var consumableDefs = []ConsumableDef{ {Name: "Voidstone Shard", Effect: EffectReflect, Value: 0.50, Tier: 5, Buyable: false, Slot: "defensive"}, } -// consumableDefByName returns the definition for a consumable by name. +// consumableDefByName returns the definition for a consumable by name. Falls +// through to the magic-item registry so SRD potions/scrolls picked up as loot +// or bought from Luigi's Curios shelf resolve in combat without being added +// to the hardcoded consumableDefs table (see magic_items_gameplay.go). func consumableDefByName(name string) *ConsumableDef { for i := range consumableDefs { if consumableDefs[i].Name == name { return &consumableDefs[i] } } - return nil + return magicItemConsumableDefByName(name) } // ── Threat Assessment ──────────────────────────────────────────────────────── diff --git a/internal/plugin/adventure_shop.go b/internal/plugin/adventure_shop.go index c80e58d..f5125b5 100644 --- a/internal/plugin/adventure_shop.go +++ b/internal/plugin/adventure_shop.go @@ -5,6 +5,7 @@ import ( "log/slog" "math" "math/rand/v2" + "sort" "strings" "time" @@ -195,7 +196,8 @@ func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, sb.WriteString("⚔️ Weapons 🛡️ Armor\n") sb.WriteString("🪖 Helmets 👢 Boots\n") - sb.WriteString("⛏️ Tools 🧪 Supplies\n\n") + sb.WriteString("⛏️ Tools 🧪 Supplies\n") + sb.WriteString("🔮 Curios\n\n") if showAll { flavor, _ := advPickFlavor(luigiShowAllComment, userID, "luigi_showall") @@ -381,11 +383,25 @@ func (p *AdventurePlugin) resolveShopCategoryChoice(ctx MessageContext, interact return p.SendDM(ctx.Sender, text) } + // Curios — Open5e magic items, daily rotating stock. + if reply == "curios" || reply == "curio" || reply == "magic" || reply == "trinkets" { + balance := p.euro.GetBalance(ctx.Sender) + text := p.luigiCuriosView(ctx.Sender, balance) + p.pending.Store(string(ctx.Sender), &advPendingInteraction{ + Type: "shop_curio", + Data: data, + ExpiresAt: time.Now().Add(advDMResponseWindow), + }) + p.advMarkMenuSent(ctx.Sender) + p.shopScheduleBrowseNudge(ctx.Sender) + return p.SendDM(ctx.Sender, text) + } + slot := advParseShopCategory(reply) if slot == "" { // Re-store pending and reprompt. p.pending.Store(string(ctx.Sender), interaction) - return p.SendDM(ctx.Sender, "I didn't catch that. Reply with a category: weapons, armor, helmets, boots, tools, or supplies.") + return p.SendDM(ctx.Sender, "I didn't catch that. Reply with a category: weapons, armor, helmets, boots, tools, supplies, or curios.") } _, equip, err := p.ensureCharacter(ctx.Sender) @@ -1048,3 +1064,138 @@ func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interactio return p.SendDM(ctx.Sender, fmt.Sprintf("Purchased **%s** for €%.0f. Added to inventory.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.", match.Name, consumablePrice, newBalance)) } + +// ── Curios (Magic Items) ──────────────────────────────────────────────────── + +// curiosStockSize — how many registry magic items Luigi stocks per day. +const curiosStockSize = 8 + +// dailyCuriosStock returns the deterministic per-day rotation of magic items +// on Luigi's Curios shelf. Seeded by UTC date so the slate is stable for the +// day and identical for every player. 237 registry items is far too many to +// list, so a rotating shelf keeps the menu legible and gives players a reason +// to check back. +func dailyCuriosStock() []MagicItem { + ids := make([]string, 0, len(magicItemRegistry)) + for id := range magicItemRegistry { + ids = append(ids, id) + } + sort.Strings(ids) + + // FNV-1a over the UTC date string → PCG seed. + day := time.Now().UTC().Format("2006-01-02") + var seed uint64 = 1469598103934665603 + for _, b := range []byte(day) { + seed ^= uint64(b) + seed *= 1099511628211 + } + rng := rand.New(rand.NewPCG(seed, seed^0x9e3779b97f4a7c15)) + rng.Shuffle(len(ids), func(i, j int) { ids[i], ids[j] = ids[j], ids[i] }) + + n := curiosStockSize + if n > len(ids) { + n = len(ids) + } + stock := make([]MagicItem, 0, n) + for _, id := range ids[:n] { + stock = append(stock, magicItemRegistry[id]) + } + // Stable display order: rarity ascending, then name. + sort.Slice(stock, func(i, j int) bool { + ri, rj := rarityPowerScalar(stock[i].Rarity), rarityPowerScalar(stock[j].Rarity) + if ri != rj { + return ri < rj + } + return stock[i].Name < stock[j].Name + }) + return stock +} + +func (p *AdventurePlugin) luigiCuriosView(userID id.UserID, balance float64) string { + var sb strings.Builder + sb.WriteString("🔮 **Curios** — Open5e magic items, daily rotating stock\n") + sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance)) + + factor := p.shopSessionPriceFactor(userID) + for _, mi := range dailyCuriosStock() { + price := float64(mi.Value) * factor + tag := string(mi.Rarity) + if mi.Attunement { + tag += ", attunement" + } + afford := "✅" + if balance < price { + afford = "💸" + } + sb.WriteString(fmt.Sprintf("**%s** (%s %s) — €%.0f %s\n", mi.Name, mi.Kind, tag, price, afford)) + if mi.Desc != "" { + sb.WriteString(fmt.Sprintf(" _%s_\n", mi.Desc)) + } + } + + sb.WriteString("\nReply with an item name to buy, or `back` to return.\n") + sb.WriteString("Equippable items go to your inventory — wear them with `!adventure equip-magic`. Potions & scrolls auto-use in combat.") + return sb.String() +} + +func (p *AdventurePlugin) resolveShopCurioChoice(ctx MessageContext, interaction *advPendingInteraction) error { + reply := strings.TrimSpace(ctx.Body) + lower := strings.ToLower(reply) + + if lower == "back" { + data := interaction.Data.(*advPendingShopCategory) + _, equip, err := p.ensureCharacter(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Failed to load your character.") + } + balance := p.euro.GetBalance(ctx.Sender) + text := luigiShopGreeting(ctx.Sender, equip, balance, data.ShowAll, p.chatLevel(ctx.Sender)) + p.pending.Store(string(ctx.Sender), &advPendingInteraction{ + Type: "shop_category", + Data: &advPendingShopCategory{ShowAll: data.ShowAll}, + ExpiresAt: time.Now().Add(advDMResponseWindow), + }) + return p.SendDM(ctx.Sender, text) + } + if lower == "exit" || lower == "cancel" { + p.shopSessionEnd(ctx.Sender) + return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*") + } + + // Match against today's stock. + var match *MagicItem + for _, mi := range dailyCuriosStock() { + if strings.EqualFold(mi.Name, reply) || containsFold(mi.Name, reply) { + m := mi + match = &m + break + } + } + if match == nil { + p.pending.Store(string(ctx.Sender), interaction) + return p.SendDM(ctx.Sender, "That's not on the shelf today. Reply with an item name from the list, or `back` to return.") + } + + price := float64(match.Value) * p.shopSessionPriceFactor(ctx.Sender) + balance := p.euro.GetBalance(ctx.Sender) + if balance < price { + p.pending.Store(string(ctx.Sender), interaction) + return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%.0f for %s but only have €%.0f.", price, match.Name, balance)) + } + + p.euro.Debit(ctx.Sender, price, "shop_curio") + if potCut := int(math.Round(price * 0.05)); potCut > 0 { + communityPotAdd(potCut) + trackTaxPaid(ctx.Sender, potCut) + } + item := magicItemSell(*match) + item.Value = int64(match.Value) / 2 // resale at half, like gear/consumables + item.SkillSource = "magic_item:" + match.ID + _ = addAdvInventoryItem(ctx.Sender, item) + + // Stay in the Curios view for more purchases. + p.pending.Store(string(ctx.Sender), interaction) + newBalance := p.euro.GetBalance(ctx.Sender) + return p.SendDM(ctx.Sender, fmt.Sprintf("Purchased **%s** for €%.0f. Added to inventory.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.", + match.Name, price, newBalance)) +} diff --git a/internal/plugin/combat_bridge.go b/internal/plugin/combat_bridge.go index 5244874..88d948c 100644 --- a/internal/plugin/combat_bridge.go +++ b/internal/plugin/combat_bridge.go @@ -85,6 +85,7 @@ func (p *AdventurePlugin) runDungeonCombat( applyClassPassives(&playerStats, &playerMods, dndChar) applyRacePassives(&playerStats, &playerMods, dndChar) applySubclassPassives(&playerStats, &playerMods, dndChar) + applyMagicItemEffects(&playerStats, &playerMods, userID) if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired { slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName) } diff --git a/internal/plugin/combat_session_build.go b/internal/plugin/combat_session_build.go index 3b29e4d..f953ec1 100644 --- a/internal/plugin/combat_session_build.go +++ b/internal/plugin/combat_session_build.go @@ -63,6 +63,7 @@ func (p *AdventurePlugin) buildZoneCombatants( applyClassPassives(&playerStats, &playerMods, dndChar) applyRacePassives(&playerStats, &playerMods, dndChar) applySubclassPassives(&playerStats, &playerMods, dndChar) + applyMagicItemEffects(&playerStats, &playerMods, userID) if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired { slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName) } diff --git a/internal/plugin/dnd_equipment.go b/internal/plugin/dnd_equipment.go index 1c89890..2f605a4 100644 --- a/internal/plugin/dnd_equipment.go +++ b/internal/plugin/dnd_equipment.go @@ -78,7 +78,7 @@ func rarityIcon(r DnDRarity) string { return "🟩" case RarityRare: return "🟦" - case RarityEpic: + case RarityEpic, RarityVeryRare: return "🟪" case RarityLegendary: return "🟧" diff --git a/internal/plugin/dnd_sheet.go b/internal/plugin/dnd_sheet.go index 034897b..4a33bfc 100644 --- a/internal/plugin/dnd_sheet.go +++ b/internal/plugin/dnd_sheet.go @@ -64,11 +64,12 @@ func (p *AdventurePlugin) handleDnDSheetCmd(ctx MessageContext) error { treasures, _ := loadAdvTreasureBonuses(ctx.Sender) meta, _ := loadPlayerMeta(ctx.Sender) house, _ := loadHouseState(ctx.Sender) + magicEquip, _ := loadEquippedMagicItems(ctx.Sender) - return p.SendDM(ctx.Sender, renderDnDSheet(c, advChar, meta, house, equip, treasures)) + return p.SendDM(ctx.Sender, renderDnDSheet(c, advChar, meta, house, equip, treasures, magicEquip)) } -func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta, house HouseState, equip map[EquipmentSlot]*AdvEquipment, treasures []AdvTreasureBonus) string { +func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta, house HouseState, equip map[EquipmentSlot]*AdvEquipment, treasures []AdvTreasureBonus, magicEquip map[DnDSlot]EquippedMagicItem) string { ri, _ := raceInfo(c.Race) ci, _ := classInfo(c.Class) mods := c.Modifiers() @@ -137,6 +138,29 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta, b.WriteString(" _(none equipped)_\n") } + // Magic items — registry items worn in the D&D slots (separate from the + // legacy tier-gear above). Attunement items show whether they're active. + if len(magicEquip) > 0 { + b.WriteString("\n**Magic Items**\n") + for _, ds := range dndSlotOrder { + e, ok := magicEquip[ds] + if !ok || e.Item.ID == "" { + continue + } + status := "" + if e.Item.Attunement { + if e.Attuned { + status = " — attuned" + } else { + status = " — _inert (unattuned)_" + } + } + b.WriteString(fmt.Sprintf(" %s %-9s %s _(%s)_%s\n %s\n", + rarityIcon(e.Item.Rarity), string(ds), e.Item.Name, e.Item.Rarity, + status, magicItemEffectSummary(e.Item))) + } + } + // Attunements (re-using adventure_treasures per v1.1 §7.4) if len(treasures) > 0 { b.WriteString("\n**Attunements** (treasures)\n") diff --git a/internal/plugin/dnd_subclass_test.go b/internal/plugin/dnd_subclass_test.go index c9c532c..efb97a3 100644 --- a/internal/plugin/dnd_subclass_test.go +++ b/internal/plugin/dnd_subclass_test.go @@ -311,7 +311,7 @@ func TestSheet_ShowsSubclassWhenChosen(t *testing.T) { STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, Subclass: SubclassBattleMaster, } - out := renderDnDSheet(c, nil, nil, HouseState{}, nil, nil) + out := renderDnDSheet(c, nil, nil, HouseState{}, nil, nil, nil) if !strings.Contains(out, "Battle Master") { t.Errorf("sheet missing subclass name:\n%s", out) } @@ -323,7 +323,7 @@ func TestSheet_PromptsWhenUnchosenAtL5(t *testing.T) { Level: 5, HPMax: 40, HPCurrent: 40, ArmorClass: 16, STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10, } - out := renderDnDSheet(c, nil, nil, HouseState{}, nil, nil) + out := renderDnDSheet(c, nil, nil, HouseState{}, nil, nil, nil) if !strings.Contains(out, "!subclass") { t.Errorf("sheet should nudge `!subclass` when unchosen at L5:\n%s", out) } diff --git a/internal/plugin/dnd_zone_loot.go b/internal/plugin/dnd_zone_loot.go index 81a4a68..a51841d 100644 --- a/internal/plugin/dnd_zone_loot.go +++ b/internal/plugin/dnd_zone_loot.go @@ -376,11 +376,25 @@ func rngIntN(rng *rand.Rand, n int) int { // it into adventure_inventory if one fired, and returns a narration block // (empty when no drop). Reuses §5 LootDropCommon/Uncommon/Rare/Legendary // flavor pools — no new flavor file (per feedback_reuse_existing_flavor). +// magicItemDropChance — once a drop fires, the chance it is substituted with +// an Open5e SRD registry magic item of the rolled tier's rarity instead of +// the biome slate item. Keeps magic items a treat, not the norm. +const magicItemDropChance = 0.15 + func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster DnDMonsterTemplate, isBoss bool) string { entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil) if !ok { return "" } + + // Magic-item substitution: swap the biome slate item for a registry + // magic item of the same rarity tier (see magic_items_gameplay.go). + if rngFloat(nil) < magicItemDropChance { + if mi, miOK := pickMagicItemForRarity(tier.rarity(), nil); miOK { + return p.dropMagicItemLoot(userID, mi, tier) + } + } + tierVal := tier zoneTier := zoneTierFromID(zoneID) item := AdvItem{ @@ -410,6 +424,33 @@ func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster return b.String() } +// dropMagicItemLoot deposits a registry magic item as a combat-victory drop +// and returns its narration block. Potions/scrolls land as "consumable" so +// the combat pipeline auto-uses them; everything else is a "magic_item" +// sellable/equippable via the Curios shelf and `!adventure equip-magic`. +func (p *AdventurePlugin) dropMagicItemLoot(userID id.UserID, mi MagicItem, tier LootTier) string { + item := magicItemSell(mi) + item.SkillSource = "magic_item:" + mi.ID + if err := addAdvInventoryItem(userID, item); err != nil { + return fmt.Sprintf("_(Loot drop persistence error: %v.)_", err) + } + var b strings.Builder + if line := lootFlavorLine(tier); line != "" { + b.WriteString(line) + b.WriteString("\n") + } + tag := string(mi.Rarity) + if mi.Attunement { + tag += ", attunement" + } + b.WriteString(fmt.Sprintf("✨ **%s** (%s %s) — %d coin baseline.", + mi.Name, mi.Kind, tag, mi.Value)) + if mi.Desc != "" { + b.WriteString(fmt.Sprintf(" _%s_", mi.Desc)) + } + return b.String() +} + // lootFlavorLine maps a tier to one of the existing TwinBee loot pools. // Epic falls through to the Rare pool (next-best fit); Legendary uses its // own pool. No new flavor file written here. diff --git a/internal/plugin/magic_items_gameplay.go b/internal/plugin/magic_items_gameplay.go new file mode 100644 index 0000000..d26022f --- /dev/null +++ b/internal/plugin/magic_items_gameplay.go @@ -0,0 +1,529 @@ +package plugin + +import ( + "fmt" + "log/slog" + "math/rand/v2" + "sort" + "strings" + "time" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// Magic-item gameplay layer — wires the vendored Open5e SRD registry +// (see magic_items.go) into three live surfaces: +// +// 1. Zone loot drops — dnd_zone_loot.go rolls registry items by rarity. +// 2. Luigi's "Curios" — adventure_shop.go sells a rotating slate. +// 3. Combat effects — equippable items grant CombatModifiers from a +// codified Rarity+Kind formula; potion/scroll items route through the +// existing consumable pipeline. +// +// The effects are *formulaic*, not hand-authored per item — 237 items is too +// many to tune by hand, and this mirrors the bestiary tuning pass: a codified +// formula fills the gap, and magicItemEffectOverlay is the hand-authored +// refinement path that wins on ID collision. + +// ── Rarity / loot indexing ────────────────────────────────────────────────── + +// magicItemsByRarity buckets the registry by rarity. Built once, lazily. +var magicItemsByRarityCache map[DnDRarity][]MagicItem + +func magicItemsByRarity() map[DnDRarity][]MagicItem { + if magicItemsByRarityCache != nil { + return magicItemsByRarityCache + } + idx := make(map[DnDRarity][]MagicItem) + ids := make([]string, 0, len(magicItemRegistry)) + for id := range magicItemRegistry { + ids = append(ids, id) + } + sort.Strings(ids) // deterministic bucket order + for _, id := range ids { + mi := magicItemRegistry[id] + idx[mi.Rarity] = append(idx[mi.Rarity], mi) + } + magicItemsByRarityCache = idx + return idx +} + +// pickMagicItemForRarity returns a uniform-random registry item of the given +// rarity. The §5 loot tiers only span Common..Legendary, so VeryRare items +// fold into the Epic bucket. Returns ok=false when no item matches. +func pickMagicItemForRarity(r DnDRarity, rng *rand.Rand) (MagicItem, bool) { + idx := magicItemsByRarity() + pool := append([]MagicItem(nil), idx[r]...) + if r == RarityEpic { + pool = append(pool, idx[RarityVeryRare]...) + } + if len(pool) == 0 { + return MagicItem{}, false + } + return pool[rngIntN(rng, len(pool))], true +} + +// magicItemSell builds the AdvItem an inventory deposit uses for a magic item. +// Potions and scrolls land as "consumable" so the combat pipeline picks them +// up (see magicItemConsumableDefByName); everything else is a "magic_item". +func magicItemSell(mi MagicItem) AdvItem { + typ := "magic_item" + if mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll { + typ = "consumable" + } + return AdvItem{ + Name: mi.Name, + Type: typ, + Tier: rarityLootTierNum(mi.Rarity), + Value: int64(mi.Value), + } +} + +// rarityLootTierNum maps a rarity onto the 1–5 tier scale used by AdvItem.Tier +// and the inventory display, so magic items sort sensibly next to gear. +func rarityLootTierNum(r DnDRarity) int { + switch r { + case RarityCommon: + return 1 + case RarityUncommon: + return 2 + case RarityRare: + return 3 + case RarityVeryRare, RarityEpic: + return 4 + case RarityLegendary: + return 5 + } + return 1 +} + +// ── Codified combat-effect formula ────────────────────────────────────────── + +// magicItemEffect is the combat delta a single equipped magic item grants. +// DamageReductMult is multiplicative (1.0 = neutral); everything else is +// additive. Potion/scroll items carry a zero effect — they are consumables. +type magicItemEffect struct { + DamageBonus float64 + DamageReductMult float64 + FlatDmgStart int + InitiativeBias float64 + MaxHP int +} + +// magicItemEffectOverlay — hand-authored per-item effects that win over the +// codified formula. Empty for now; corrections land here rather than being +// folded into the formula, mirroring magicItemOverlay in magic_items.go. +var magicItemEffectOverlay = map[string]magicItemEffect{} + +// rarityPowerScalar is the codified power axis: rarer item, bigger delta. +func rarityPowerScalar(r DnDRarity) float64 { + switch r { + case RarityCommon: + return 1 + case RarityUncommon: + return 2 + case RarityRare: + return 3 + case RarityVeryRare, RarityEpic: + return 4 + case RarityLegendary: + return 5 + } + return 1 +} + +// magicItemEffectFor returns the combat delta for an item: the hand-authored +// overlay if present, else the codified Rarity+Kind formula. +func magicItemEffectFor(mi MagicItem) magicItemEffect { + if eff, ok := magicItemEffectOverlay[mi.ID]; ok { + return eff + } + s := rarityPowerScalar(mi.Rarity) + eff := magicItemEffect{DamageReductMult: 1.0} + switch mi.Kind { + case MagicItemWeapon: + eff.DamageBonus = 0.05 * s + case MagicItemStaff, MagicItemWand, MagicItemRod: + eff.DamageBonus = 0.03 * s + eff.FlatDmgStart = int(s) + case MagicItemArmor: + eff.DamageReductMult = 1.0 - 0.04*s + case MagicItemRing: + eff.DamageReductMult = 1.0 - 0.02*s + eff.DamageBonus = 0.02 * s + case MagicItemWondrous: + eff.InitiativeBias = 0.5 * s + eff.MaxHP = 2 * int(s) + default: + // potion / scroll — consumables, no equip effect. + } + return eff +} + +// ── Equipped-item persistence ─────────────────────────────────────────────── + +// EquippedMagicItem is one row of magic_item_equipped, resolved against the +// registry. Item is the registry entry; Attuned mirrors the DB column. +type EquippedMagicItem struct { + Slot DnDSlot + Item MagicItem + Attuned bool +} + +// dndMagicItemAttuneLimit — 5e's three-attunement cap. Items that require +// attunement only grant their effect while attuned, and a player may hold at +// most this many attunements at once. +const dndMagicItemAttuneLimit = 3 + +func loadEquippedMagicItems(userID id.UserID) (map[DnDSlot]EquippedMagicItem, error) { + d := db.Get() + rows, err := d.Query(`SELECT slot, item_id, attuned FROM magic_item_equipped WHERE user_id = ?`, + string(userID)) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[DnDSlot]EquippedMagicItem) + for rows.Next() { + var slot, itemID string + var attuned int + if err := rows.Scan(&slot, &itemID, &attuned); err != nil { + return nil, err + } + mi, ok := magicItemRegistry[itemID] + if !ok { + continue // registry shrank under us — skip stale rows + } + out[DnDSlot(slot)] = EquippedMagicItem{ + Slot: DnDSlot(slot), + Item: mi, + Attuned: attuned == 1, + } + } + return out, rows.Err() +} + +// equipMagicItem writes (or replaces) the item in its slot. attuned is only +// honoured when the item requires attunement. +func equipMagicItem(userID id.UserID, slot DnDSlot, itemID string, attuned bool) error { + d := db.Get() + a := 0 + if attuned { + a = 1 + } + _, err := d.Exec(` + INSERT INTO magic_item_equipped (user_id, slot, item_id, attuned) + VALUES (?, ?, ?, ?) + ON CONFLICT(user_id, slot) DO UPDATE SET item_id = excluded.item_id, attuned = excluded.attuned`, + string(userID), string(slot), itemID, a) + return err +} + +func unequipMagicItem(userID id.UserID, slot DnDSlot) error { + d := db.Get() + _, err := d.Exec(`DELETE FROM magic_item_equipped WHERE user_id = ? AND slot = ?`, + string(userID), string(slot)) + return err +} + +// countAttunedMagicItems returns how many attunement slots the player is +// currently using — callers gate new attunements on dndMagicItemAttuneLimit. +func countAttunedMagicItems(equipped map[DnDSlot]EquippedMagicItem) int { + n := 0 + for _, e := range equipped { + if e.Attuned { + n++ + } + } + return n +} + +// ── Combat hook ───────────────────────────────────────────────────────────── + +// applyMagicItemEffects layers the player's equipped magic items onto their +// combat stats/modifiers. Called from the combat bridges right after the +// class/race/subclass passives, so its deltas compose on top of them. +// +// Items that require attunement only count while attuned; non-attunement +// items always count once equipped. +func applyMagicItemEffects(stats *CombatStats, mods *CombatModifiers, userID id.UserID) { + equipped, err := loadEquippedMagicItems(userID) + if err != nil || len(equipped) == 0 { + return + } + for _, e := range equipped { + if e.Item.Attunement && !e.Attuned { + continue // unattuned attunement item is inert + } + eff := magicItemEffectFor(e.Item) + mods.DamageBonus += eff.DamageBonus + if eff.DamageReductMult != 0 && eff.DamageReductMult != 1.0 { + mods.DamageReduct *= eff.DamageReductMult + } + mods.FlatDmgStart += eff.FlatDmgStart + mods.InitiativeBias += eff.InitiativeBias + if eff.MaxHP != 0 { + stats.MaxHP += eff.MaxHP + if stats.StartHP > 0 { + stats.StartHP += eff.MaxHP + } + } + } +} + +// ── Consumable bridge (potions / scrolls) ─────────────────────────────────── + +// magicItemConsumableDef classifies a potion/scroll magic item onto the +// existing ConsumableDef pipeline so it auto-resolves in combat. Effect is +// name-sniffed; value scales with rarity. Returns nil for non-consumable +// kinds. Buyable is false — these come from loot and the Curios shelf, not +// the Supplies menu. +func magicItemConsumableDef(mi MagicItem) *ConsumableDef { + if mi.Kind != MagicItemPotion && mi.Kind != MagicItemScroll { + return nil + } + s := rarityPowerScalar(mi.Rarity) + name := strings.ToLower(mi.Name) + def := &ConsumableDef{ + Name: mi.Name, + Tier: rarityLootTierNum(mi.Rarity), + Buyable: false, + Price: int64(mi.Value), + } + switch { + case strings.Contains(name, "healing"), strings.Contains(name, "heal"), + strings.Contains(name, "vitality"), strings.Contains(name, "life"): + def.Effect = EffectHeal + def.Value = 20 + 15*s + def.Slot = "defensive" + case strings.Contains(name, "fire"), strings.Contains(name, "lightning"), + strings.Contains(name, "acid"), strings.Contains(name, "flame"): + def.Effect = EffectFlatDmg + def.Value = 6 * s + def.Slot = "offensive" + case strings.Contains(name, "protection"), strings.Contains(name, "shield"), + strings.Contains(name, "warding"), strings.Contains(name, "resistance"): + def.Effect = EffectWard + def.Value = 1 + def.Slot = "defensive" + case strings.Contains(name, "speed"), strings.Contains(name, "haste"), + strings.Contains(name, "flying"): + def.Effect = EffectSpeedBoost + def.Value = 0.10 + 0.05*s + def.Slot = "offensive" + case strings.Contains(name, "heroism"), strings.Contains(name, "strength"), + strings.Contains(name, "rage"), strings.Contains(name, "giant"): + def.Effect = EffectAtkBoost + def.Value = 0.05 * s + def.Slot = "offensive" + case strings.Contains(name, "stoneskin"), strings.Contains(name, "barkskin"), + strings.Contains(name, "iron"): + def.Effect = EffectDefBoost + def.Value = 0.05 * s + def.Slot = "defensive" + default: + // Generic potion → modest heal; generic scroll → modest pre-damage. + if mi.Kind == MagicItemScroll { + def.Effect = EffectFlatDmg + def.Value = 4 * s + def.Slot = "offensive" + } else { + def.Effect = EffectHeal + def.Value = 15 + 10*s + def.Slot = "defensive" + } + } + return def +} + +// magicItemConsumableDefByName resolves a consumable magic item by name. Used +// by consumableDefByName as a fall-through so loot/shop magic potions resolve +// in combat without being added to the hardcoded consumableDefs table. +func magicItemConsumableDefByName(name string) *ConsumableDef { + for _, mi := range magicItemRegistry { + if mi.Name == name { + return magicItemConsumableDef(mi) + } + } + return nil +} + +// ── Equip command (`!adventure equip-magic`) ──────────────────────────────── + +// advPendingMagicEquip is the pending-interaction payload for the numbered +// equip-magic picker. +type advPendingMagicEquip struct { + Items []AdvItem +} + +// magicItemFromAdvItem resolves the registry entry behind an inventory row. +// Inventory rows carry "magic_item:" in SkillSource; name lookup is the +// fallback for rows written before that convention or by other paths. +func magicItemFromAdvItem(it AdvItem) (MagicItem, bool) { + if strings.HasPrefix(it.SkillSource, "magic_item:") { + if mi, ok := magicItemRegistry[strings.TrimPrefix(it.SkillSource, "magic_item:")]; ok { + return mi, true + } + } + for _, mi := range magicItemRegistry { + if mi.Name == it.Name { + return mi, true + } + } + return MagicItem{}, false +} + +// magicItemEffectSummary renders the codified combat delta as a short, +// player-facing string (accessibility goal — surface the outcome, not math). +func magicItemEffectSummary(mi MagicItem) string { + eff := magicItemEffectFor(mi) + var parts []string + if eff.DamageBonus > 0 { + parts = append(parts, fmt.Sprintf("+%.0f%% damage", eff.DamageBonus*100)) + } + if eff.DamageReductMult > 0 && eff.DamageReductMult < 1.0 { + parts = append(parts, fmt.Sprintf("-%.0f%% damage taken", (1.0-eff.DamageReductMult)*100)) + } + if eff.FlatDmgStart > 0 { + parts = append(parts, fmt.Sprintf("%d opening damage", eff.FlatDmgStart)) + } + if eff.MaxHP > 0 { + parts = append(parts, fmt.Sprintf("+%d HP", eff.MaxHP)) + } + if eff.InitiativeBias > 0 { + parts = append(parts, "faster to act") + } + if len(parts) == 0 { + return "no combat effect" + } + return strings.Join(parts, ", ") +} + +func (p *AdventurePlugin) handleEquipMagicCmd(ctx MessageContext) error { + items, err := loadAdvInventory(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Failed to access your inventory.") + } + var magic []AdvItem + for _, it := range items { + if it.Type != "magic_item" { + continue + } + mi, ok := magicItemFromAdvItem(it) + if !ok || mi.Slot == "" { + continue // unslotted curios can't be worn + } + magic = append(magic, it) + } + if len(magic) == 0 { + return p.SendDM(ctx.Sender, + "You have no equippable magic items. Slotted curios drop from zones and Luigi's Curios shelf; "+ + "potions and scrolls auto-use in combat instead.") + } + + equipped, _ := loadEquippedMagicItems(ctx.Sender) + var sb strings.Builder + sb.WriteString("🔮 **Equippable magic items:**\n\n") + for i, it := range magic { + mi, _ := magicItemFromAdvItem(it) + curDesc := "empty" + if cur, ok := equipped[mi.Slot]; ok && cur.Item.ID != "" { + curDesc = cur.Item.Name + } + att := "" + if mi.Attunement { + att = " _(needs attunement)_" + } + sb.WriteString(fmt.Sprintf("%d. **%s** (%s, %s) → %s slot — currently: %s%s\n %s\n", + i+1, mi.Name, mi.Kind, mi.Rarity, mi.Slot, curDesc, att, magicItemEffectSummary(mi))) + } + sb.WriteString(fmt.Sprintf("\nAttunements in use: %d/%d\n", + countAttunedMagicItems(equipped), dndMagicItemAttuneLimit)) + sb.WriteString("Reply with a number to equip, or \"cancel\".") + + p.pending.Store(string(ctx.Sender), &advPendingInteraction{ + Type: "magic_equip", + Data: &advPendingMagicEquip{Items: magic}, + ExpiresAt: time.Now().Add(advDMResponseWindow), + }) + return p.SendDM(ctx.Sender, sb.String()) +} + +func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction *advPendingInteraction) error { + data := interaction.Data.(*advPendingMagicEquip) + reply := strings.TrimSpace(ctx.Body) + if strings.EqualFold(reply, "cancel") { + return p.SendDM(ctx.Sender, "Equip cancelled.") + } + + idx := 0 + parsed := false + for _, c := range reply { + if c >= '0' && c <= '9' { + idx = idx*10 + int(c-'0') + parsed = true + } else { + break + } + } + idx-- // 1-indexed → 0-indexed + if !parsed || idx < 0 || idx >= len(data.Items) { + p.pending.Store(string(ctx.Sender), interaction) + return p.SendDM(ctx.Sender, "Invalid selection. Reply with a number from the list, or \"cancel\".") + } + + it := data.Items[idx] + mi, ok := magicItemFromAdvItem(it) + if !ok || mi.Slot == "" { + return p.SendDM(ctx.Sender, "That item can't be equipped anymore.") + } + + equipped, err := loadEquippedMagicItems(ctx.Sender) + if err != nil { + return p.SendDM(ctx.Sender, "Failed to load your equipped magic items.") + } + + // Auto-attune when the item needs it and an attunement slot is free. + // Otherwise it equips inert until the player frees a slot. + attune := false + atCap := false + if mi.Attunement { + if countAttunedMagicItems(equipped) >= dndMagicItemAttuneLimit { + atCap = true + } else { + attune = true + } + } + + // Return whatever currently occupies that slot to inventory. + if prev, exists := equipped[mi.Slot]; exists && prev.Item.ID != "" { + back := magicItemSell(prev.Item) + back.Value = int64(prev.Item.Value) / 2 + back.SkillSource = "magic_item:" + prev.Item.ID + if err := addAdvInventoryItem(ctx.Sender, back); err != nil { + return p.SendDM(ctx.Sender, "Failed to return your currently-equipped item to inventory.") + } + } + if err := equipMagicItem(ctx.Sender, mi.Slot, mi.ID, attune); err != nil { + return p.SendDM(ctx.Sender, "Failed to equip that item.") + } + if err := removeAdvInventoryItem(it.ID); err != nil { + slog.Error("magic-item: failed to remove equipped item from inventory", + "user", ctx.Sender, "item", mi.ID, "err", err) + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("✨ **%s** equipped in your %s slot — %s.", + mi.Name, mi.Slot, magicItemEffectSummary(mi))) + switch { + case mi.Attunement && attune: + sb.WriteString(fmt.Sprintf("\nAttuned (%d/%d attunement slots used).", + countAttunedMagicItems(equipped)+1, dndMagicItemAttuneLimit)) + case mi.Attunement && atCap: + sb.WriteString(fmt.Sprintf("\n⚠️ All %d attunement slots are full — it's worn but **inert** until you free one (`!adventure equip-magic` to swap).", + dndMagicItemAttuneLimit)) + } + return p.SendDM(ctx.Sender, sb.String()) +} diff --git a/internal/plugin/magic_items_gameplay_test.go b/internal/plugin/magic_items_gameplay_test.go new file mode 100644 index 0000000..c75e8ce --- /dev/null +++ b/internal/plugin/magic_items_gameplay_test.go @@ -0,0 +1,216 @@ +package plugin + +import ( + "testing" + + "gogobee/internal/db" + "maunium.net/go/mautrix/id" +) + +// TestMagicItemsByRarityComplete — every registry item lands in exactly one +// rarity bucket and the buckets sum back to the registry size. +func TestMagicItemsByRarityComplete(t *testing.T) { + idx := magicItemsByRarity() + total := 0 + for _, items := range idx { + total += len(items) + } + if total != len(magicItemRegistry) { + t.Errorf("rarity buckets sum to %d, registry has %d", total, len(magicItemRegistry)) + } + for r, items := range idx { + for _, mi := range items { + if mi.Rarity != r { + t.Errorf("item %s (rarity %s) bucketed under %s", mi.ID, mi.Rarity, r) + } + } + } +} + +// TestPickMagicItemForRarity — picks respect the requested rarity, and the +// Epic bucket folds in VeryRare items (the §5 loot tiers never emit VeryRare). +func TestPickMagicItemForRarity(t *testing.T) { + for _, r := range []DnDRarity{RarityCommon, RarityUncommon, RarityRare, RarityEpic, RarityLegendary} { + mi, ok := pickMagicItemForRarity(r, nil) + if !ok { + continue // a rarity may legitimately be empty + } + if r == RarityEpic { + if mi.Rarity != RarityEpic && mi.Rarity != RarityVeryRare { + t.Errorf("Epic pick returned rarity %s", mi.Rarity) + } + } else if mi.Rarity != r { + t.Errorf("pick for %s returned rarity %s", r, mi.Rarity) + } + } +} + +// TestMagicItemEffectFormula — the codified Rarity+Kind formula produces the +// expected shape of delta per kind, and rarer items hit harder. +func TestMagicItemEffectFormula(t *testing.T) { + weakWeapon := MagicItem{Kind: MagicItemWeapon, Rarity: RarityCommon} + strongWeapon := MagicItem{Kind: MagicItemWeapon, Rarity: RarityLegendary} + we, se := magicItemEffectFor(weakWeapon), magicItemEffectFor(strongWeapon) + if we.DamageBonus <= 0 || se.DamageBonus <= we.DamageBonus { + t.Errorf("weapon DamageBonus not monotonic: common=%v legendary=%v", we.DamageBonus, se.DamageBonus) + } + + armor := magicItemEffectFor(MagicItem{Kind: MagicItemArmor, Rarity: RarityRare}) + if armor.DamageReductMult >= 1.0 || armor.DamageReductMult <= 0 { + t.Errorf("armor DamageReductMult should be in (0,1), got %v", armor.DamageReductMult) + } + + staff := magicItemEffectFor(MagicItem{Kind: MagicItemStaff, Rarity: RarityRare}) + if staff.FlatDmgStart <= 0 { + t.Errorf("staff should grant FlatDmgStart, got %d", staff.FlatDmgStart) + } + + potion := magicItemEffectFor(MagicItem{Kind: MagicItemPotion, Rarity: RarityRare}) + if potion != (magicItemEffect{DamageReductMult: 1.0}) { + t.Errorf("potion should have a neutral equip effect, got %+v", potion) + } +} + +// TestMagicItemEffectOverlayWins — the hand-authored overlay beats the formula. +func TestMagicItemEffectOverlayWins(t *testing.T) { + saved := magicItemEffectOverlay + defer func() { magicItemEffectOverlay = saved }() + + magicItemEffectOverlay = map[string]magicItemEffect{ + "test_item": {DamageBonus: 9.99, DamageReductMult: 1.0}, + } + got := magicItemEffectFor(MagicItem{ID: "test_item", Kind: MagicItemWeapon, Rarity: RarityCommon}) + if got.DamageBonus != 9.99 { + t.Errorf("overlay did not win: got DamageBonus %v", got.DamageBonus) + } +} + +// TestMagicItemConsumableBridge — potion/scroll items classify onto the +// consumable pipeline; everything else returns nil. +func TestMagicItemConsumableBridge(t *testing.T) { + healPotion := MagicItem{Name: "Potion of Greater Healing", Kind: MagicItemPotion, Rarity: RarityUncommon, Value: 100} + def := magicItemConsumableDef(healPotion) + if def == nil || def.Effect != EffectHeal || def.Value <= 0 || def.Buyable { + t.Errorf("healing potion classified wrong: %+v", def) + } + + scroll := MagicItem{Name: "Scroll of Fireball", Kind: MagicItemScroll, Rarity: RarityRare, Value: 200} + if d := magicItemConsumableDef(scroll); d == nil || d.Effect != EffectFlatDmg { + t.Errorf("fireball scroll should be FlatDmg: %+v", d) + } + + if d := magicItemConsumableDef(MagicItem{Kind: MagicItemWeapon, Rarity: RarityRare}); d != nil { + t.Errorf("weapon should not produce a ConsumableDef, got %+v", d) + } +} + +// TestConsumableDefByNameFallsThrough — a real registry potion resolves +// through consumableDefByName even though it's not in consumableDefs. +func TestConsumableDefByNameFallsThrough(t *testing.T) { + var sample MagicItem + for _, mi := range magicItemRegistry { + if mi.Kind == MagicItemPotion { + sample = mi + break + } + } + if sample.Name == "" { + t.Skip("no potion in registry to exercise the fall-through") + } + if def := consumableDefByName(sample.Name); def == nil { + t.Errorf("consumableDefByName(%q) returned nil — fall-through broken", sample.Name) + } +} + +// TestDailyCuriosStock — the shelf is the right size, deterministic within a +// run, and only holds real registry items. +func TestDailyCuriosStock(t *testing.T) { + a := dailyCuriosStock() + b := dailyCuriosStock() + if len(a) != curiosStockSize { + t.Errorf("curios stock size = %d, want %d", len(a), curiosStockSize) + } + for i := range a { + if a[i].ID != b[i].ID { + t.Errorf("curios stock not deterministic at %d: %s vs %s", i, a[i].ID, b[i].ID) + } + if _, ok := magicItemRegistry[a[i].ID]; !ok { + t.Errorf("curios stock item %s not in registry", a[i].ID) + } + } +} + +// TestMagicItemSellTyping — potions/scrolls land as "consumable", wearables as +// "magic_item", and Value carries the rarity-bracket coin baseline. +func TestMagicItemSellTyping(t *testing.T) { + potion := magicItemSell(MagicItem{Name: "P", Kind: MagicItemPotion, Rarity: RarityCommon, Value: 50}) + if potion.Type != "consumable" { + t.Errorf("potion AdvItem type = %q, want consumable", potion.Type) + } + ring := magicItemSell(MagicItem{Name: "R", Kind: MagicItemRing, Rarity: RarityRare, Value: 500}) + if ring.Type != "magic_item" || ring.Value != 500 { + t.Errorf("ring AdvItem wrong: %+v", ring) + } +} + +// TestEquippedMagicItemRoundTrip exercises the DB persistence layer: equip, +// load, attunement counting, unequip. +func TestEquippedMagicItemRoundTrip(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("@magic:test.invalid") + + // Pick a real attunement item and a real non-attunement item. + var attItem, plainItem MagicItem + for _, mi := range magicItemRegistry { + if mi.Slot == "" { + continue + } + if mi.Attunement && attItem.ID == "" { + attItem = mi + } + if !mi.Attunement && plainItem.ID == "" { + plainItem = mi + } + } + if attItem.ID == "" || plainItem.ID == "" { + t.Skip("registry lacks both an attunement and a non-attunement slotted item") + } + + if err := equipMagicItem(user, attItem.Slot, attItem.ID, true); err != nil { + t.Fatalf("equip attunement item: %v", err) + } + // Equip the plain item into a distinct slot so it doesn't overwrite the + // attunement item (the classifier may give both the same slot). + plainSlot := DnDSlotRing1 + if plainSlot == attItem.Slot { + plainSlot = DnDSlotRing2 + } + if err := equipMagicItem(user, plainSlot, plainItem.ID, false); err != nil { + t.Fatalf("equip plain item: %v", err) + } + + equipped, err := loadEquippedMagicItems(user) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(equipped) != 2 { + t.Fatalf("expected 2 equipped items, got %d", len(equipped)) + } + if countAttunedMagicItems(equipped) != 1 { + t.Errorf("attuned count = %d, want 1", countAttunedMagicItems(equipped)) + } + + if err := unequipMagicItem(user, attItem.Slot); err != nil { + t.Fatalf("unequip: %v", err) + } + equipped, _ = loadEquippedMagicItems(user) + if countAttunedMagicItems(equipped) != 0 { + t.Errorf("attuned count after unequip = %d, want 0", countAttunedMagicItems(equipped)) + } +}