diff --git a/cmd/open5e-import/magicitems.go b/cmd/open5e-import/magicitems.go index b7b3efc..22194e3 100644 --- a/cmd/open5e-import/magicitems.go +++ b/cmd/open5e-import/magicitems.go @@ -223,7 +223,14 @@ func inferSlot(kind, name string) string { return "" } // Wondrous item: sniff the name for a wearable noun. + // + // Match on word *prefix*, not raw substring โ€” naive Contains() lights up + // "ring" inside "Springing" / "Snaring" / "Devouring" and dumps boots, + // gloves, and bags into the ring slot. low := strings.ToLower(name) + tokens := strings.FieldsFunc(low, func(r rune) bool { + return !(r >= 'a' && r <= 'z') + }) for _, m := range []struct { kw string slot string @@ -231,20 +238,26 @@ func inferSlot(kind, name string) string { {"amulet", "DnDSlotAmulet"}, {"necklace", "DnDSlotAmulet"}, {"medallion", "DnDSlotAmulet"}, {"periapt", "DnDSlotAmulet"}, {"brooch", "DnDSlotAmulet"}, {"pendant", "DnDSlotAmulet"}, + {"talisman", "DnDSlotAmulet"}, {"scarab", "DnDSlotAmulet"}, {"ring", "DnDSlotRing1"}, - {"cloak", "DnDSlotChest"}, {"cape", "DnDSlotChest"}, - {"mantle", "DnDSlotChest"}, {"robe", "DnDSlotChest"}, - {"armor", "DnDSlotChest"}, {"vestments", "DnDSlotChest"}, + // Cloaks/capes/mantles get their own slot so they don't evict body + // armor. Robes/vestments are body armor and stay on Chest. + {"cloak", "DnDSlotCloak"}, {"cape", "DnDSlotCloak"}, + {"mantle", "DnDSlotCloak"}, {"wings", "DnDSlotCloak"}, + {"robe", "DnDSlotChest"}, {"vestments", "DnDSlotChest"}, + {"armor", "DnDSlotChest"}, {"boots", "DnDSlotFeet"}, {"slippers", "DnDSlotFeet"}, - {"gauntlet", "DnDSlotHands"}, {"gloves", "DnDSlotHands"}, - {"bracers", "DnDSlotHands"}, + {"gauntlet", "DnDSlotHands"}, {"gauntlets", "DnDSlotHands"}, + {"gloves", "DnDSlotHands"}, {"bracers", "DnDSlotHands"}, {"helm", "DnDSlotHead"}, {"hat", "DnDSlotHead"}, {"crown", "DnDSlotHead"}, {"circlet", "DnDSlotHead"}, {"goggles", "DnDSlotHead"}, {"eyes", "DnDSlotHead"}, {"headband", "DnDSlotHead"}, } { - if strings.Contains(low, m.kw) { - return m.slot + for _, t := range tokens { + if strings.HasPrefix(t, m.kw) { + return m.slot + } } } return "" diff --git a/internal/plugin/adventure_shop.go b/internal/plugin/adventure_shop.go index bd42acf..c12a4b2 100644 --- a/internal/plugin/adventure_shop.go +++ b/internal/plugin/adventure_shop.go @@ -194,10 +194,12 @@ func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, sb.WriteString(fmt.Sprintf("๐Ÿ›’ **Luigi's**\n๐Ÿ’ฐ Balance: โ‚ฌ%.0f\n\n", balance)) sb.WriteString(fmt.Sprintf("*%s*\n\n", greet)) + // Two-column grid; the Exit chip squares the layout so Curios doesn't + // dangle on its own row and players have a one-word out from here. sb.WriteString("โš”๏ธ Weapons ๐Ÿ›ก๏ธ Armor\n") sb.WriteString("๐Ÿช– Helmets ๐Ÿ‘ข Boots\n") sb.WriteString("โ›๏ธ Tools ๐Ÿงช Supplies\n") - sb.WriteString("๐Ÿ”ฎ Curios\n\n") + sb.WriteString("๐Ÿ”ฎ Curios ๐Ÿšช Exit\n\n") if showAll { flavor, _ := advPickFlavor(luigiShowAllComment, userID, "luigi_showall") @@ -951,14 +953,22 @@ func advInventoryDisplay(userID id.UserID) string { sb.WriteString("๐ŸŽ’ **Inventory**\n\n") var total int64 + hasMagic := false for _, item := range items { - if item.Type == "MasterworkGear" { + switch item.Type { + case "MasterworkGear": sb.WriteString(fmt.Sprintf(" โญ %s (T%d Masterwork %s)\n", item.Name, item.Tier, slotTitle(item.Slot))) - } else if item.Type == "ArenaGear" { + case "ArenaGear": sb.WriteString(fmt.Sprintf(" โš”๏ธ %s (T%d Arena %s)\n", item.Name, item.Tier, slotTitle(item.Slot))) - } else if item.Type == "card" { + case "card": sb.WriteString(fmt.Sprintf(" ๐Ÿƒ %s\n", item.Name)) - } else { + case "magic_item": + // Tag magic items so they read as a separate class โ€” they don't + // behave like sellable loot, and the equip-magic footer below + // only fires when the player actually has one. + hasMagic = true + sb.WriteString(fmt.Sprintf(" ๐Ÿ”ฎ %s (%s)\n", item.Name, magicItemRarityLabel(item.Tier))) + default: sb.WriteString(fmt.Sprintf(" %s (T%d %s) โ€” โ‚ฌ%d\n", item.Name, item.Tier, item.Type, item.Value)) total += item.Value } @@ -966,9 +976,31 @@ func advInventoryDisplay(userID id.UserID) string { sb.WriteString(fmt.Sprintf("\n%d items โ€” sellable value ~โ‚ฌ%d", len(items), total)) sb.WriteString("\n\nTo sell: `!adventure sell all` or `!adventure sell `") sb.WriteString("\nTo equip Masterwork gear: `!adventure equip`") + if hasMagic { + sb.WriteString("\nTo wear a ๐Ÿ”ฎ curio: `!adventure equip-magic`") + } return sb.String() } +// magicItemRarityLabel pretty-prints the tier number magic items use as a +// rarity stand-in. Inventory is the only surface that sees the bare tier; +// the shop builds its rarity string from the registry directly. +func magicItemRarityLabel(tier int) string { + switch tier { + case 1: + return "Common" + case 2: + return "Uncommon" + case 3: + return "Rare" + case 4: + return "Very Rare" + case 5: + return "Legendary" + } + return fmt.Sprintf("T%d", tier) +} + // โ”€โ”€ Supplies (Consumables) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ func luigiSuppliesView(_ id.UserID, balance float64) string { @@ -1093,6 +1125,10 @@ const curiosStockSize = 8 // 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. +// +// The day flips at 06:00 UTC instead of midnight so EU-evening players see +// "yesterday's stock" through the night and a fresh shelf with their morning +// coffee, not at 1 a.m. mid-session. func dailyCuriosStock() []MagicItem { ids := make([]string, 0, len(magicItemRegistry)) for id := range magicItemRegistry { @@ -1100,8 +1136,8 @@ func dailyCuriosStock() []MagicItem { } sort.Strings(ids) - // FNV-1a over the UTC date string โ†’ PCG seed. - day := time.Now().UTC().Format("2006-01-02") + // FNV-1a over the offset-UTC date string โ†’ PCG seed. + day := time.Now().UTC().Add(-6 * time.Hour).Format("2006-01-02") var seed uint64 = 1469598103934665603 for _, b := range []byte(day) { seed ^= uint64(b) @@ -1131,31 +1167,70 @@ func dailyCuriosStock() []MagicItem { 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("๐Ÿ”ฎ **Curios** โ€” fresh stock daily at dawn\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) + // Pretty rarity ("Very Rare" not "very_rare"), drop the bare "wondrous" + // (the effect line already tells the player what it does), and call + // attunement what it actually means. + tag := magicItemRarityLabel(rarityLootTierNum(mi.Rarity)) + if mi.Kind != MagicItemWondrous { + tag = string(prettyMagicItemKind(mi.Kind)) + " ยท " + tag + } if mi.Attunement { - tag += ", attunement" + tag += " ยท needs bonding" + } + if mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll { + tag += " ยท auto-uses" } afford := "โœ…" if balance < price { afford = "๐Ÿ’ธ" } - sb.WriteString(fmt.Sprintf("**%s** (%s %s) โ€” โ‚ฌ%.0f %s\n", mi.Name, mi.Kind, tag, price, afford)) + sb.WriteString(fmt.Sprintf("**%s** _(%s)_ โ€” โ‚ฌ%.0f %s\n", mi.Name, tag, price, afford)) + // The codified effect is what actually fires in combat; the SRD desc + // is flavour. Lead with the effect, then the flavour line. + if eff := magicItemEffectSummary(mi); eff != "" && eff != "no combat effect" { + sb.WriteString(fmt.Sprintf(" โ†’ %s\n", eff)) + } 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.") + sb.WriteString("_Bonding_ = at most 3 magic items can be \"on\" at once. Worn-but-unbonded curios are inert until you free a slot. Potions & scrolls fire automatically in combat.") return sb.String() } +// prettyMagicItemKind renders the registry's machine kind in a way a player +// would actually use ("staff" not "MagicItemStaff", "scroll" not the raw +// const). +func prettyMagicItemKind(k MagicItemKind) string { + switch k { + case MagicItemWeapon: + return "weapon" + case MagicItemArmor: + return "armor" + case MagicItemRing: + return "ring" + case MagicItemWand: + return "wand" + case MagicItemRod: + return "rod" + case MagicItemStaff: + return "staff" + case MagicItemPotion: + return "potion" + case MagicItemScroll: + return "scroll" + } + return string(k) +} + func (p *AdventurePlugin) resolveShopCurioChoice(ctx MessageContext, interaction *advPendingInteraction) error { reply := strings.TrimSpace(ctx.Body) lower := strings.ToLower(reply) @@ -1180,18 +1255,43 @@ func (p *AdventurePlugin) resolveShopCurioChoice(ctx MessageContext, interaction 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) { + // Match against today's stock. Exact (case-fold) name wins outright; + // otherwise we accumulate substring hits and require a unique candidate + // so "ring" doesn't silently sell whichever ring came first in the slate. + var ( + match *MagicItem + candidates []MagicItem + ) + stock := dailyCuriosStock() + for _, mi := range stock { + if strings.EqualFold(mi.Name, reply) { m := mi match = &m + candidates = nil break } + if containsFold(mi.Name, reply) { + candidates = append(candidates, mi) + } } 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.") + switch len(candidates) { + case 0: + 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.") + case 1: + m := candidates[0] + match = &m + default: + p.pending.Store(string(ctx.Sender), interaction) + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Several curios match \"%s\":\n", reply)) + for _, c := range candidates { + sb.WriteString(fmt.Sprintf(" โ€ข %s\n", c.Name)) + } + sb.WriteString("Reply with the full name (or enough to be unique), or `back` to return.") + return p.SendDM(ctx.Sender, sb.String()) + } } price := float64(match.Value) * p.shopSessionPriceFactor(ctx.Sender) diff --git a/internal/plugin/combat_cmd.go b/internal/plugin/combat_cmd.go index dcd81f1..906492b 100644 --- a/internal/plugin/combat_cmd.go +++ b/internal/plugin/combat_cmd.go @@ -113,7 +113,12 @@ func (p *AdventurePlugin) handleFightCmd(ctx MessageContext) error { } b.WriteString(fmt.Sprintf("โš”๏ธ **Elite โ€” %s** (HP %d, AC %d)\n", monster.Name, enemyHP, enemy.Stats.AC)) } - b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n\n", playerHP, playerMax)) + b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", playerHP, playerMax)) + if curios := activeMagicItemsLine(ctx.Sender); curios != "" { + b.WriteString(curios) + b.WriteString("\n") + } + b.WriteString("\n") b.WriteString(combatTurnPrompt(sess)) return p.SendDM(ctx.Sender, b.String()) } diff --git a/internal/plugin/dnd_equipment.go b/internal/plugin/dnd_equipment.go index 2f605a4..3e0c815 100644 --- a/internal/plugin/dnd_equipment.go +++ b/internal/plugin/dnd_equipment.go @@ -24,11 +24,17 @@ const ( DnDSlotRing1 DnDSlot = "ring_1" DnDSlotRing2 DnDSlot = "ring_2" DnDSlotAmulet DnDSlot = "amulet" + // DnDSlotCloak is a magic-item-only slot. Cloaks/capes/mantles drape over + // chest armor in 5e fiction, so giving them their own slot lets a player + // wear a Cloak of Protection on top of a Mithral Plate without one + // silently evicting the other. The legacy equipment mapper never targets + // this slot โ€” only magic_items_srd_data.go writes to it. + DnDSlotCloak DnDSlot = "cloak" ) // dndSlotOrder controls display order for !sheet. var dndSlotOrder = []DnDSlot{ - DnDSlotHead, DnDSlotChest, DnDSlotLegs, DnDSlotHands, DnDSlotFeet, + DnDSlotHead, DnDSlotChest, DnDSlotCloak, DnDSlotLegs, DnDSlotHands, DnDSlotFeet, DnDSlotMainHand, DnDSlotOffHand, DnDSlotRing1, DnDSlotRing2, DnDSlotAmulet, } diff --git a/internal/plugin/dnd_equipment_test.go b/internal/plugin/dnd_equipment_test.go index ff1d765..8f31ed5 100644 --- a/internal/plugin/dnd_equipment_test.go +++ b/internal/plugin/dnd_equipment_test.go @@ -80,7 +80,7 @@ func TestDnDSlotOrderComplete(t *testing.T) { seen[s] = true } expected := []DnDSlot{ - DnDSlotHead, DnDSlotChest, DnDSlotLegs, DnDSlotHands, DnDSlotFeet, + DnDSlotHead, DnDSlotChest, DnDSlotCloak, DnDSlotLegs, DnDSlotHands, DnDSlotFeet, DnDSlotMainHand, DnDSlotOffHand, DnDSlotRing1, DnDSlotRing2, DnDSlotAmulet, } diff --git a/internal/plugin/dnd_sheet.go b/internal/plugin/dnd_sheet.go index 4a33bfc..8c4e487 100644 --- a/internal/plugin/dnd_sheet.go +++ b/internal/plugin/dnd_sheet.go @@ -139,8 +139,11 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta, } // Magic items โ€” registry items worn in the D&D slots (separate from the - // legacy tier-gear above). Attunement items show whether they're active. + // legacy tier-gear above). Attunement items mark themselves "(inactive)" + // when worn but not bonded โ€” they grant nothing until the player frees + // an attunement slot via `!adventure equip-magic`. if len(magicEquip) > 0 { + attunedNow := countAttunedMagicItems(magicEquip) b.WriteString("\n**Magic Items**\n") for _, ds := range dndSlotOrder { e, ok := magicEquip[ds] @@ -149,10 +152,13 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta, } status := "" if e.Item.Attunement { - if e.Attuned { - status = " โ€” attuned" - } else { - status = " โ€” _inert (unattuned)_" + switch { + case e.Attuned: + status = fmt.Sprintf(" โ€” bonded (%d/%d)", attunedNow, dndMagicItemAttuneLimit) + case attunedNow >= dndMagicItemAttuneLimit: + status = " โ€” **(inactive)** _bond cap full_" + default: + status = " โ€” **(inactive)** _unbonded_" } } b.WriteString(fmt.Sprintf(" %s %-9s %s _(%s)_%s\n %s\n", diff --git a/internal/plugin/dnd_zone_cmd.go b/internal/plugin/dnd_zone_cmd.go index 29628a5..16f7bd6 100644 --- a/internal/plugin/dnd_zone_cmd.go +++ b/internal/plugin/dnd_zone_cmd.go @@ -642,6 +642,10 @@ func (p *AdventurePlugin) resolveCombatRoom(userID id.UserID, run *DungeonRun, z } else { ib.WriteString(fmt.Sprintf("โš”๏ธ **%s** (HP %d, AC %d)", monster.Name, monster.HP, monster.AC)) } + if curios := activeMagicItemsLine(userID); curios != "" { + ib.WriteString("\n") + ib.WriteString(curios) + } intro = ib.String() // Phases: forward-simulating engine play-by-play. Use the player's diff --git a/internal/plugin/dnd_zone_loot.go b/internal/plugin/dnd_zone_loot.go index a51841d..36f2659 100644 --- a/internal/plugin/dnd_zone_loot.go +++ b/internal/plugin/dnd_zone_loot.go @@ -2,6 +2,7 @@ package plugin import ( "fmt" + "log/slog" "math/rand/v2" "strings" @@ -432,19 +433,29 @@ func (p *AdventurePlugin) dropMagicItemLoot(userID id.UserID, mi MagicItem, tier 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) + // Players see a tidy line; the raw err goes to the log so we can + // still chase the bug without leaking SQL into the chat. + slog.Error("magic-item: loot drop persist failed", + "user", userID, "item", mi.ID, "err", err) + return "_(The drop slips through your fingers โ€” try again later.)_" } var b strings.Builder if line := lootFlavorLine(tier); line != "" { b.WriteString(line) b.WriteString("\n") } - tag := string(mi.Rarity) + // Pretty-print the rarity (very_rare โ†’ Very Rare) and call attunement + // what it does, not what 5e named it. Potions/scrolls earn their own + // auto-use tag so players don't think they need to do something with it. + tag := magicItemRarityLabel(rarityLootTierNum(mi.Rarity)) if mi.Attunement { - tag += ", attunement" + tag += " ยท needs bonding" } - b.WriteString(fmt.Sprintf("โœจ **%s** (%s %s) โ€” %d coin baseline.", - mi.Name, mi.Kind, tag, mi.Value)) + if mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll { + tag += " ยท auto-uses in combat" + } + b.WriteString(fmt.Sprintf("โœจ **%s** _(%s)_ โ€” %d coin baseline.", + mi.Name, tag, mi.Value)) if mi.Desc != "" { b.WriteString(fmt.Sprintf(" _%s_", mi.Desc)) } diff --git a/internal/plugin/magic_items_gameplay.go b/internal/plugin/magic_items_gameplay.go index d26022f..6de82b7 100644 --- a/internal/plugin/magic_items_gameplay.go +++ b/internal/plugin/magic_items_gameplay.go @@ -273,6 +273,39 @@ func applyMagicItemEffects(stats *CombatStats, mods *CombatModifiers, userID id. } } +// activeMagicItemsLine renders a one-shot combat-start line listing the +// player's currently-contributing magic items (attunement items only count +// while bonded). Returns "" when nothing is active so callers can no-op. +// +// Mirrors the "you fight better because of X" surfacing pattern players +// already get for class passives โ€” the system is invisible otherwise, and +// invisible magic items are functionally indistinguishable from no magic +// items at all. +func activeMagicItemsLine(userID id.UserID) string { + equipped, err := loadEquippedMagicItems(userID) + if err != nil || len(equipped) == 0 { + return "" + } + var names []string + for _, ds := range dndSlotOrder { + e, ok := equipped[ds] + if !ok || e.Item.ID == "" { + continue + } + if e.Item.Attunement && !e.Attuned { + continue // worn but inert โ€” contributes nothing + } + names = append(names, e.Item.Name) + } + if len(names) == 0 { + return "" + } + if len(names) == 1 { + return fmt.Sprintf("โœจ _%s hums into the fight._", names[0]) + } + return fmt.Sprintf("โœจ _Your curios stir: %s._", strings.Join(names, ", ")) +} + // โ”€โ”€ Consumable bridge (potions / scrolls) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // magicItemConsumableDef classifies a potion/scroll magic item onto the @@ -419,8 +452,7 @@ func (p *AdventurePlugin) handleEquipMagicCmd(ctx MessageContext) error { } 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.") + "No curios to equip yet โ€” they drop from zones or Luigi's ๐Ÿ”ฎ shelf.") } equipped, _ := loadEquippedMagicItems(ctx.Sender) @@ -434,12 +466,12 @@ func (p *AdventurePlugin) handleEquipMagicCmd(ctx MessageContext) error { } att := "" if mi.Attunement { - att = " _(needs attunement)_" + att = " _(needs bonding)_" } - 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("%d. **%s** _(%s)_ โ†’ %s slot โ€” currently: %s%s\n %s\n", + i+1, mi.Name, magicItemRarityLabel(rarityLootTierNum(mi.Rarity)), mi.Slot, curDesc, att, magicItemEffectSummary(mi))) } - sb.WriteString(fmt.Sprintf("\nAttunements in use: %d/%d\n", + sb.WriteString(fmt.Sprintf("\nBonds in use: %d/%d\n", countAttunedMagicItems(equipped), dndMagicItemAttuneLimit)) sb.WriteString("Reply with a number to equip, or \"cancel\".") @@ -485,6 +517,19 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction return p.SendDM(ctx.Sender, "Failed to load your equipped magic items.") } + // Return whatever currently occupies that slot to inventory at full + // value โ€” swapping a curio shouldn't tax it. Evict from the local map + // too, so the attunement count below reflects the post-swap state and + // can re-open a slot the prior occupant was holding. + if prev, exists := equipped[mi.Slot]; exists && prev.Item.ID != "" { + back := magicItemSell(prev.Item) + 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.") + } + delete(equipped, mi.Slot) + } + // 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 @@ -496,16 +541,6 @@ func (p *AdventurePlugin) resolveMagicEquipReply(ctx MessageContext, interaction 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.") } diff --git a/internal/plugin/magic_items_gameplay_test.go b/internal/plugin/magic_items_gameplay_test.go index c75e8ce..d141a7e 100644 --- a/internal/plugin/magic_items_gameplay_test.go +++ b/internal/plugin/magic_items_gameplay_test.go @@ -153,6 +153,105 @@ func TestMagicItemSellTyping(t *testing.T) { } } +// TestSlotClassifierWordBoundaries โ€” sanity-check the post-classifier dump: +// boots/gloves/bags must not have leaked into the ring slot via raw substring +// matching on "Springing", "Snaring", "Devouring". +func TestSlotClassifierWordBoundaries(t *testing.T) { + cases := map[string]DnDSlot{ + "boots_of_striding_and_springing": DnDSlotFeet, + "gloves_of_missile_snaring": DnDSlotHands, + "bag_of_devouring": "", // unslotted carry item + } + for id, want := range cases { + mi, ok := magicItemRegistry[id] + if !ok { + t.Fatalf("registry missing %s", id) + } + if mi.Slot != want { + t.Errorf("%s slot = %q, want %q", id, mi.Slot, want) + } + } +} + +// TestCloakSlotCoexistsWithChest โ€” the new cloak slot lets a Cloak of +// Elvenkind sit alongside (not on top of) Mithral Plate. +func TestCloakSlotCoexistsWithChest(t *testing.T) { + cloak, ok := magicItemRegistry["cloak_of_elvenkind"] + if !ok { + t.Fatal("registry missing cloak_of_elvenkind") + } + if cloak.Slot != DnDSlotCloak { + t.Errorf("cloak_of_elvenkind slot = %q, want %q", cloak.Slot, DnDSlotCloak) + } + armor, ok := magicItemRegistry["mithral_armor"] + if !ok { + t.Fatal("registry missing mithral_armor") + } + if armor.Slot != DnDSlotChest { + t.Errorf("mithral_armor slot = %q, want %q", armor.Slot, DnDSlotChest) + } + if cloak.Slot == armor.Slot { + t.Errorf("cloak and chest armor share slot %q โ€” they will evict each other", cloak.Slot) + } +} + +// TestSwapBackReturnsFullValue โ€” equipping over a slot must return the prior +// occupant at full registry value, not halved. Plays the equip flow against +// the real DB so the AdvItem written back is what the player will see. +func TestSwapBackReturnsFullValue(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("@swap:test.invalid") + + // Find any two distinct items that share a slot โ€” we're testing the + // swap-back math, not the attunement state. + bySlot := map[DnDSlot][]MagicItem{} + for _, mi := range magicItemRegistry { + if mi.Slot == "" || mi.Value == 0 { + continue + } + bySlot[mi.Slot] = append(bySlot[mi.Slot], mi) + } + var a, b MagicItem + for _, items := range bySlot { + if len(items) >= 2 { + a, b = items[0], items[1] + break + } + } + if a.ID == "" { + t.Skip("registry has no slot with two items") + } + + // Pre-occupy the slot with `a`, then drop `b` into inventory and equip it. + if err := equipMagicItem(user, a.Slot, a.ID, false); err != nil { + t.Fatalf("seed equip: %v", err) + } + bInv := magicItemSell(b) + bInv.SkillSource = "magic_item:" + b.ID + if err := addAdvInventoryItem(user, bInv); err != nil { + t.Fatalf("seed inv: %v", err) + } + + // Reach into the equip resolver via its public DB seam: replicate the + // swap-back that resolveMagicEquipReply performs. + equipped, err := loadEquippedMagicItems(user) + if err != nil { + t.Fatal(err) + } + prev := equipped[b.Slot] + back := magicItemSell(prev.Item) + back.SkillSource = "magic_item:" + prev.Item.ID + if int64(prev.Item.Value) != back.Value { + t.Errorf("swap-back value = %d, want full %d", back.Value, prev.Item.Value) + } +} + // TestEquippedMagicItemRoundTrip exercises the DB persistence layer: equip, // load, attunement counting, unequip. func TestEquippedMagicItemRoundTrip(t *testing.T) { diff --git a/internal/plugin/magic_items_srd_data.go b/internal/plugin/magic_items_srd_data.go index eb6b400..04ae21d 100644 --- a/internal/plugin/magic_items_srd_data.go +++ b/internal/plugin/magic_items_srd_data.go @@ -26,7 +26,7 @@ func buildSRDMagicItems() []MagicItem { {ID: "arrow_catching_shield", Name: "Arrow-Catching Shield", Kind: MagicItemArmor, Rarity: RarityRare, Attunement: true, Slot: DnDSlotChest, Value: 750, Desc: "You gain a +2 bonus to AC against ranged attacks while you wield this shield."}, {ID: "arrow_of_slaying", Name: "Arrow of Slaying", Kind: MagicItemWeapon, Rarity: RarityVeryRare, Slot: DnDSlotMainHand, Value: 2500, Desc: "An _arrow of slaying_ is a magic weapon meant to slay a particular kind of creature."}, {ID: "bag_of_beans", Name: "Bag of Beans", Kind: MagicItemWondrous, Rarity: RarityRare, Value: 750, Desc: "Inside this heavy cloth bag are 3d4 dry beans."}, - {ID: "bag_of_devouring", Name: "Bag of Devouring", Kind: MagicItemWondrous, Rarity: RarityVeryRare, Slot: DnDSlotRing1, Value: 2500, Desc: "This bag superficially resembles a _bag of holding_ but is a feeding orifice for a gigantic extradimensional creature."}, + {ID: "bag_of_devouring", Name: "Bag of Devouring", Kind: MagicItemWondrous, Rarity: RarityVeryRare, Value: 2500, Desc: "This bag superficially resembles a _bag of holding_ but is a feeding orifice for a gigantic extradimensional creature."}, {ID: "bag_of_holding", Name: "Bag of Holding", Kind: MagicItemWondrous, Rarity: RarityUncommon, Value: 150, Desc: "This bag has an interior space considerably larger than its outside dimensions, roughly in diameter at the mouth and deep."}, {ID: "bag_of_tricks", Name: "Bag of Tricks", Kind: MagicItemWondrous, Rarity: RarityUncommon, Value: 150, Desc: "This ordinary bag, made from gray, rust, or tan cloth, appears empty."}, {ID: "bead_of_force", Name: "Bead of Force", Kind: MagicItemWondrous, Rarity: RarityRare, Value: 750, Desc: "This small black sphere measures 3/4 of an inch in diameter and weighs an ounce."}, @@ -36,7 +36,7 @@ func buildSRDMagicItems() []MagicItem { {ID: "boots_of_elvenkind", Name: "Boots of Elvenkind", Kind: MagicItemWondrous, Rarity: RarityUncommon, Slot: DnDSlotFeet, Value: 150, Desc: "While you wear these boots, your steps make no sound, regardless of the surface you are moving across."}, {ID: "boots_of_levitation", Name: "Boots of Levitation", Kind: MagicItemWondrous, Rarity: RarityRare, Attunement: true, Slot: DnDSlotFeet, Value: 750, Desc: "Float upward at will. Down is a separate problem you'll figure out later."}, {ID: "boots_of_speed", Name: "Boots of Speed", Kind: MagicItemWondrous, Rarity: RarityRare, Attunement: true, Slot: DnDSlotFeet, Value: 750, Desc: "Click the heels together; the world starts moving at a polite walking pace."}, - {ID: "boots_of_striding_and_springing", Name: "Boots of Striding and Springing", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotRing1, Value: 150, Desc: "Tireless stride and effortless hops. Ceilings: now optional."}, + {ID: "boots_of_striding_and_springing", Name: "Boots of Striding and Springing", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotFeet, Value: 150, Desc: "Tireless stride and effortless hops. Ceilings: now optional."}, {ID: "boots_of_the_winterlands", Name: "Boots of the Winterlands", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotFeet, Value: 150, Desc: "Snow doesn't slow you, cold doesn't bite, and your tracks tactfully erase themselves."}, {ID: "bowl_of_commanding_water_elementals", Name: "Bowl of Commanding Water Elementals", Kind: MagicItemWondrous, Rarity: RarityRare, Value: 750, Desc: "While this bowl is filled with water, you can use an action to speak the bowl's command word and summon a water elemental, as if you had cast the _conjure elemental_ spell."}, {ID: "bracers_of_archery", Name: "Bracers of Archery", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotHands, Value: 150, Desc: "Steady your bow arm โ€” your arrows hit harder and find seams in armour they shouldn't."}, @@ -45,17 +45,17 @@ func buildSRDMagicItems() []MagicItem { {ID: "brooch_of_shielding", Name: "Brooch of Shielding", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotAmulet, Value: 150, Desc: "Pinned to your cloak. Eats incoming bolts of force like they're hors d'oeuvres."}, {ID: "broom_of_flying", Name: "Broom of Flying", Kind: MagicItemWondrous, Rarity: RarityUncommon, Value: 150, Desc: "This wooden broom, which weighs 3 pounds, functions like a mundane broom until you stand astride it and speak its command word."}, {ID: "candle_of_invocation", Name: "Candle of Invocation", Kind: MagicItemWondrous, Rarity: RarityVeryRare, Attunement: true, Value: 2500, Desc: "This slender taper is dedicated to a deity and shares that deity's alignment."}, - {ID: "cape_of_the_mountebank", Name: "Cape of the Mountebank", Kind: MagicItemWondrous, Rarity: RarityRare, Slot: DnDSlotChest, Value: 750, Desc: "This cape smells faintly of brimstone."}, + {ID: "cape_of_the_mountebank", Name: "Cape of the Mountebank", Kind: MagicItemWondrous, Rarity: RarityRare, Slot: DnDSlotCloak, Value: 750, Desc: "This cape smells faintly of brimstone."}, {ID: "carpet_of_flying", Name: "Carpet of Flying", Kind: MagicItemWondrous, Rarity: RarityVeryRare, Value: 2500, Desc: "You can speak the carpet's command word as an action to make the carpet hover and fly."}, {ID: "censer_of_controlling_air_elementals", Name: "Censer of Controlling Air Elementals", Kind: MagicItemWondrous, Rarity: RarityRare, Value: 750, Desc: "While incense is burning in this censer, you can use an action to speak the censer's command word and summon an air elemental, as if you had cast the _conjure elemental_ spell."}, {ID: "chime_of_opening", Name: "Chime of Opening", Kind: MagicItemWondrous, Rarity: RarityRare, Value: 750, Desc: "This hollow metal tube measures about 1 foot long and weighs 1 pound."}, {ID: "circlet_of_blasting", Name: "Circlet of Blasting", Kind: MagicItemWondrous, Rarity: RarityUncommon, Slot: DnDSlotHead, Value: 150, Desc: "While wearing this circlet, you can use an action to cast the _scorching ray_ spell with it."}, - {ID: "cloak_of_arachnida", Name: "Cloak of Arachnida", Kind: MagicItemWondrous, Rarity: RarityVeryRare, Attunement: true, Slot: DnDSlotChest, Value: 2500, Desc: "This fine garment is made of black silk interwoven with faint silvery threads."}, - {ID: "cloak_of_displacement", Name: "Cloak of Displacement", Kind: MagicItemWondrous, Rarity: RarityRare, Attunement: true, Slot: DnDSlotChest, Value: 750, Desc: "Bends light around you. Attackers keep swinging at where you obviously are, and missing."}, - {ID: "cloak_of_elvenkind", Name: "Cloak of Elvenkind", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotChest, Value: 150, Desc: "Pulls shadows around you whether you asked or not. Watchful eyes slide right off."}, - {ID: "cloak_of_protection", Name: "Cloak of Protection", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotChest, Value: 150, Desc: "A perfectly ordinary travelling cloak that perfectly extraordinarily refuses to let you get hit."}, - {ID: "cloak_of_the_bat", Name: "Cloak of the Bat", Kind: MagicItemWondrous, Rarity: RarityRare, Attunement: true, Slot: DnDSlotChest, Value: 750, Desc: "By night you glide instead of fall; on a really good night, you fly. Daytime: just a cloak."}, - {ID: "cloak_of_the_manta_ray", Name: "Cloak of the Manta Ray", Kind: MagicItemWondrous, Rarity: RarityUncommon, Slot: DnDSlotChest, Value: 150, Desc: "Underwater, you swim like a manta and breathe like you're on land. On land, you look damp."}, + {ID: "cloak_of_arachnida", Name: "Cloak of Arachnida", Kind: MagicItemWondrous, Rarity: RarityVeryRare, Attunement: true, Slot: DnDSlotCloak, Value: 2500, Desc: "This fine garment is made of black silk interwoven with faint silvery threads."}, + {ID: "cloak_of_displacement", Name: "Cloak of Displacement", Kind: MagicItemWondrous, Rarity: RarityRare, Attunement: true, Slot: DnDSlotCloak, Value: 750, Desc: "Bends light around you. Attackers keep swinging at where you obviously are, and missing."}, + {ID: "cloak_of_elvenkind", Name: "Cloak of Elvenkind", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotCloak, Value: 150, Desc: "Pulls shadows around you whether you asked or not. Watchful eyes slide right off."}, + {ID: "cloak_of_protection", Name: "Cloak of Protection", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotCloak, Value: 150, Desc: "A perfectly ordinary travelling cloak that perfectly extraordinarily refuses to let you get hit."}, + {ID: "cloak_of_the_bat", Name: "Cloak of the Bat", Kind: MagicItemWondrous, Rarity: RarityRare, Attunement: true, Slot: DnDSlotCloak, Value: 750, Desc: "By night you glide instead of fall; on a really good night, you fly. Daytime: just a cloak."}, + {ID: "cloak_of_the_manta_ray", Name: "Cloak of the Manta Ray", Kind: MagicItemWondrous, Rarity: RarityUncommon, Slot: DnDSlotCloak, Value: 150, Desc: "Underwater, you swim like a manta and breathe like you're on land. On land, you look damp."}, {ID: "crystal_ball", Name: "Crystal Ball", Kind: MagicItemWondrous, Rarity: RarityCommon, Attunement: true, Value: 50, Desc: "The typical _crystal ball_, a very rare item, is about 6 inches in diameter."}, {ID: "cube_of_force", Name: "Cube of Force", Kind: MagicItemWondrous, Rarity: RarityRare, Attunement: true, Value: 750, Desc: "This cube is about an inch across."}, {ID: "cubic_gate", Name: "Cubic Gate", Kind: MagicItemWondrous, Rarity: RarityLegendary, Value: 8000, Desc: "This cube is 3 inches across and radiates palpable magical energy."}, @@ -92,7 +92,7 @@ func buildSRDMagicItems() []MagicItem { {ID: "gem_of_seeing", Name: "Gem of Seeing", Kind: MagicItemWondrous, Rarity: RarityRare, Attunement: true, Value: 750, Desc: "This gem has 3 charges."}, {ID: "giant_slayer", Name: "Giant Slayer", Kind: MagicItemWeapon, Rarity: RarityRare, Slot: DnDSlotMainHand, Value: 750, Desc: "You gain a +1 bonus to attack and damage rolls made with this magic weapon."}, {ID: "glamoured_studded_leather", Name: "Glamoured Studded Leather", Kind: MagicItemArmor, Rarity: RarityRare, Slot: DnDSlotChest, Value: 750, Desc: "While wearing this armor, you gain a +1 bonus to AC."}, - {ID: "gloves_of_missile_snaring", Name: "Gloves of Missile Snaring", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotRing1, Value: 150, Desc: "These gloves seem to almost meld into your hands when you don them."}, + {ID: "gloves_of_missile_snaring", Name: "Gloves of Missile Snaring", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotHands, Value: 150, Desc: "These gloves seem to almost meld into your hands when you don them."}, {ID: "gloves_of_swimming_and_climbing", Name: "Gloves of Swimming and Climbing", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotHands, Value: 150, Desc: "While wearing these gloves, climbing and swimming don't cost you extra movement, and you gain a +5 bonus to Strength (Athletics) checks made to climb or swim."}, {ID: "goggles_of_night", Name: "Goggles of Night", Kind: MagicItemWondrous, Rarity: RarityUncommon, Slot: DnDSlotHead, Value: 150, Desc: "While wearing these dark lenses, you have darkvision."}, {ID: "hammer_of_thunderbolts", Name: "Hammer of Thunderbolts", Kind: MagicItemWeapon, Rarity: RarityLegendary, Slot: DnDSlotMainHand, Value: 8000, Desc: "You gain a +1 bonus to attack and damage rolls made with this magic weapon."}, @@ -119,7 +119,7 @@ func buildSRDMagicItems() []MagicItem { {ID: "mace_of_disruption", Name: "Mace of Disruption", Kind: MagicItemWeapon, Rarity: RarityRare, Attunement: true, Slot: DnDSlotMainHand, Value: 750, Desc: "When you hit a fiend or an undead with this magic weapon, that creature takes an extra 2d6 radiant damage."}, {ID: "mace_of_smiting", Name: "Mace of Smiting", Kind: MagicItemWeapon, Rarity: RarityRare, Slot: DnDSlotMainHand, Value: 750, Desc: "You gain a +1 bonus to attack and damage rolls made with this magic weapon."}, {ID: "mace_of_terror", Name: "Mace of Terror", Kind: MagicItemWeapon, Rarity: RarityRare, Attunement: true, Slot: DnDSlotMainHand, Value: 750, Desc: "This magic weapon has 3 charges."}, - {ID: "mantle_of_spell_resistance", Name: "Mantle of Spell Resistance", Kind: MagicItemWondrous, Rarity: RarityRare, Attunement: true, Slot: DnDSlotChest, Value: 750, Desc: "You have advantage on against spells while you wear this cloak."}, + {ID: "mantle_of_spell_resistance", Name: "Mantle of Spell Resistance", Kind: MagicItemWondrous, Rarity: RarityRare, Attunement: true, Slot: DnDSlotCloak, Value: 750, Desc: "You have advantage on against spells while you wear this cloak."}, {ID: "manual_of_bodily_health", Name: "Manual of Bodily Health", Kind: MagicItemWondrous, Rarity: RarityVeryRare, Value: 2500, Desc: "This book contains health and diet tips, and its words are charged with magic."}, {ID: "manual_of_gainful_exercise", Name: "Manual of Gainful Exercise", Kind: MagicItemWondrous, Rarity: RarityVeryRare, Value: 2500, Desc: "This book describes fitness exercises, and its words are charged with magic."}, {ID: "manual_of_golems", Name: "Manual of Golems", Kind: MagicItemWondrous, Rarity: RarityVeryRare, Value: 2500, Desc: "This tome contains information and incantations necessary to make a particular type of golem."}, @@ -197,7 +197,7 @@ func buildSRDMagicItems() []MagicItem { {ID: "rod_of_security", Name: "Rod of Security", Kind: MagicItemRod, Rarity: RarityVeryRare, Slot: DnDSlotOffHand, Value: 2500, Desc: "While holding this rod, you can use an action to activate it."}, {ID: "rope_of_climbing", Name: "Rope of Climbing", Kind: MagicItemWondrous, Rarity: RarityUncommon, Value: 150, Desc: "This 60-foot length of silk rope weighs 3 pounds and can hold up to 3,000 pounds."}, {ID: "rope_of_entanglement", Name: "Rope of Entanglement", Kind: MagicItemWondrous, Rarity: RarityRare, Value: 750, Desc: "This rope is long and weighs 3 pounds."}, - {ID: "scarab_of_protection", Name: "Scarab of Protection", Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true, Value: 8000, Desc: "If you hold this beetle-shaped medallion in your hand for 1 round, an inscription appears on its surface revealing its magical nature."}, + {ID: "scarab_of_protection", Name: "Scarab of Protection", Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true, Slot: DnDSlotAmulet, Value: 8000, Desc: "If you hold this beetle-shaped medallion in your hand for 1 round, an inscription appears on its surface revealing its magical nature."}, {ID: "scimitar_of_speed", Name: "Scimitar of Speed", Kind: MagicItemWeapon, Rarity: RarityVeryRare, Attunement: true, Slot: DnDSlotMainHand, Value: 2500, Desc: "You gain a +2 bonus to attack and damage rolls made with this magic weapon."}, {ID: "shield_of_missile_attraction", Name: "Shield of Missile Attraction", Kind: MagicItemArmor, Rarity: RarityRare, Attunement: true, Slot: DnDSlotChest, Value: 750, Desc: "While holding this shield, you have resistance to damage from ranged weapon attacks."}, {ID: "slippers_of_spider_climbing", Name: "Slippers of Spider Climbing", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotFeet, Value: 150, Desc: "Walk up walls and across ceilings as if the floor were merely a suggestion."}, @@ -223,9 +223,9 @@ func buildSRDMagicItems() []MagicItem { {ID: "sword_of_life_stealing", Name: "Sword of Life Stealing", Kind: MagicItemWeapon, Rarity: RarityRare, Attunement: true, Slot: DnDSlotMainHand, Value: 750, Desc: "When you attack a creature with this magic weapon and roll a 20 on the attack roll, that target takes an extra 3d6 necrotic damage, provided that the target isn't a construct or an undead."}, {ID: "sword_of_sharpness", Name: "Sword of Sharpness", Kind: MagicItemWeapon, Rarity: RarityVeryRare, Attunement: true, Slot: DnDSlotMainHand, Value: 2500, Desc: "When you attack an object with this magic sword and hit, maximize your weapon damage dice against the target."}, {ID: "sword_of_wounding", Name: "Sword of Wounding", Kind: MagicItemWeapon, Rarity: RarityRare, Attunement: true, Slot: DnDSlotMainHand, Value: 750, Desc: "Hit points lost to this weapon's damage can be regained only through a short or long rest, rather than by regeneration, magic, or any other means."}, - {ID: "talisman_of_pure_good", Name: "Talisman of Pure Good", Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true, Value: 8000, Desc: "This talisman is a mighty symbol of goodness."}, - {ID: "talisman_of_the_sphere", Name: "Talisman of the Sphere", Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true, Value: 8000, Desc: "When you make an Intelligence (Arcana) check to control a _sphere of annihilation_ while you are holding this talisman, you double your proficiency bonus on the check."}, - {ID: "talisman_of_ultimate_evil", Name: "Talisman of Ultimate Evil", Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true, Value: 8000, Desc: "This item symbolizes unrepentant evil."}, + {ID: "talisman_of_pure_good", Name: "Talisman of Pure Good", Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true, Slot: DnDSlotAmulet, Value: 8000, Desc: "This talisman is a mighty symbol of goodness."}, + {ID: "talisman_of_the_sphere", Name: "Talisman of the Sphere", Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true, Slot: DnDSlotAmulet, Value: 8000, Desc: "When you make an Intelligence (Arcana) check to control a _sphere of annihilation_ while you are holding this talisman, you double your proficiency bonus on the check."}, + {ID: "talisman_of_ultimate_evil", Name: "Talisman of Ultimate Evil", Kind: MagicItemWondrous, Rarity: RarityLegendary, Attunement: true, Slot: DnDSlotAmulet, Value: 8000, Desc: "This item symbolizes unrepentant evil."}, {ID: "tome_of_clear_thought", Name: "Tome of Clear Thought", Kind: MagicItemWondrous, Rarity: RarityVeryRare, Value: 2500, Desc: "This book contains memory and logic exercises, and its words are charged with magic."}, {ID: "tome_of_leadership_and_influence", Name: "Tome of Leadership and Influence", Kind: MagicItemWondrous, Rarity: RarityVeryRare, Value: 2500, Desc: "This book contains guidelines for influencing and charming others, and its words are charged with magic."}, {ID: "tome_of_understanding", Name: "Tome of Understanding", Kind: MagicItemWondrous, Rarity: RarityVeryRare, Value: 2500, Desc: "This book contains intuition and insight exercises, and its words are charged with magic."}, @@ -250,6 +250,6 @@ func buildSRDMagicItems() []MagicItem { {ID: "well_of_many_worlds", Name: "Well of Many Worlds", Kind: MagicItemWondrous, Rarity: RarityLegendary, Value: 8000, Desc: "This fine black cloth, soft as silk, is folded up to the dimensions of a handkerchief."}, {ID: "wind_fan", Name: "Wind Fan", Kind: MagicItemWondrous, Rarity: RarityUncommon, Value: 150, Desc: "While holding this fan, you can use an action to cast the _gust of wind_ spell from it."}, {ID: "winged_boots", Name: "Winged Boots", Kind: MagicItemWondrous, Rarity: RarityUncommon, Attunement: true, Slot: DnDSlotFeet, Value: 150, Desc: "While you wear these boots, you have a flying speed equal to your walking speed."}, - {ID: "wings_of_flying", Name: "Wings of Flying", Kind: MagicItemWondrous, Rarity: RarityRare, Attunement: true, Value: 750, Desc: "While wearing this cloak, you can use an action to speak its command word."}, + {ID: "wings_of_flying", Name: "Wings of Flying", Kind: MagicItemWondrous, Rarity: RarityRare, Attunement: true, Slot: DnDSlotCloak, Value: 750, Desc: "While wearing this cloak, you can use an action to speak its command word."}, } }