mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-16 17:02:42 +00:00
D&D: wire the Open5e magic-item registry into live gameplay
Magic items now reach players through three surfaces: zone loot drops, Luigi's "Curios" shelf, and combat effects. Effects are formulaic (Rarity scalar x Kind), mirroring the bestiary tuning pass, with an empty magicItemEffectOverlay as the hand-authored refinement path. - magic_items_gameplay.go: rarity index, magic_item_equipped persistence (new table, DnDSlot-keyed), codified effect formula, applyMagicItemEffects combat hook, potion/scroll -> ConsumableDef bridge, !adventure equip-magic - dropZoneLoot: 15% magic-item substitution roll by tier rarity - Luigi's Curios category: daily UTC-seeded 8-item rotation - combat_bridge / combat_session_build: applyMagicItemEffects after passives - consumableDefByName falls through so loot/shop potions auto-resolve - renderDnDSheet: new Magic Items section Equippable items live entirely in the DnDSlot scheme, separate from the legacy tier-gear. Attunement items equip inert until attuned (3-slot cap).
This commit is contained in:
@@ -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);
|
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 (
|
CREATE TABLE IF NOT EXISTS adventure_buffs (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
|
|||||||
@@ -447,6 +447,8 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
|
|||||||
return p.handleBuyCmd(ctx, strings.TrimSpace(args[4:]))
|
return p.handleBuyCmd(ctx, strings.TrimSpace(args[4:]))
|
||||||
case lower == "equip":
|
case lower == "equip":
|
||||||
return p.handleEquipCmd(ctx)
|
return p.handleEquipCmd(ctx)
|
||||||
|
case lower == "equip-magic" || lower == "equipmagic" || lower == "equip magic":
|
||||||
|
return p.handleEquipMagicCmd(ctx)
|
||||||
case lower == "inventory" || lower == "inv":
|
case lower == "inventory" || lower == "inv":
|
||||||
return p.handleInventoryCmd(ctx)
|
return p.handleInventoryCmd(ctx)
|
||||||
case lower == "leaderboard" || lower == "lb":
|
case lower == "leaderboard" || lower == "lb":
|
||||||
@@ -488,9 +490,10 @@ const advHelpText = `**Adventure Commands**
|
|||||||
` + "`!adventure status`" + ` — View your character sheet
|
` + "`!adventure status`" + ` — View your character sheet
|
||||||
` + "`!expedition`" + ` — Adventure: pick a zone, advance through rooms, harvest as you go
|
` + "`!expedition`" + ` — Adventure: pick a zone, advance through rooms, harvest as you go
|
||||||
` + "`!adventure shop`" + ` — Browse equipment categories
|
` + "`!adventure shop`" + ` — Browse equipment categories
|
||||||
` + "`!adventure shop <category>`" + ` — View a category (weapon, armor, helmet, boots, tool)
|
` + "`!adventure shop <category>`" + ` — View a category (weapon, armor, helmet, boots, tool, supplies, curios)
|
||||||
` + "`!adventure buy <item>`" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `)
|
` + "`!adventure buy <item>`" + ` — Buy equipment (e.g. ` + "`buy Enchanted Blade`" + ` or ` + "`buy 4 sword`" + `)
|
||||||
` + "`!adventure equip`" + ` — Equip Masterwork gear from inventory
|
` + "`!adventure equip`" + ` — Equip Masterwork gear from inventory
|
||||||
|
` + "`!adventure equip-magic`" + ` — Equip magic items (curios) into your D&D slots
|
||||||
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
|
` + "`!adventure sell <item>`" + ` — Sell an inventory item (or ` + "`sell all`" + `)
|
||||||
` + "`!adventure inventory`" + ` — View your inventory
|
` + "`!adventure inventory`" + ` — View your inventory
|
||||||
` + "`!adventure leaderboard`" + ` — View the leaderboard
|
` + "`!adventure leaderboard`" + ` — View the leaderboard
|
||||||
@@ -824,6 +827,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
|||||||
return p.handleTreasureDiscard(ctx, interaction)
|
return p.handleTreasureDiscard(ctx, interaction)
|
||||||
case "masterwork_equip":
|
case "masterwork_equip":
|
||||||
return p.handleMasterworkEquipReply(ctx, interaction)
|
return p.handleMasterworkEquipReply(ctx, interaction)
|
||||||
|
case "magic_equip":
|
||||||
|
return p.resolveMagicEquipReply(ctx, interaction)
|
||||||
case "masterwork_equip_confirm":
|
case "masterwork_equip_confirm":
|
||||||
return p.handleMasterworkEquipConfirm(ctx, interaction)
|
return p.handleMasterworkEquipConfirm(ctx, interaction)
|
||||||
case "rival_rps":
|
case "rival_rps":
|
||||||
@@ -838,6 +843,8 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
|
|||||||
return p.resolveShopItemChoice(ctx, interaction)
|
return p.resolveShopItemChoice(ctx, interaction)
|
||||||
case "shop_supply":
|
case "shop_supply":
|
||||||
return p.resolveShopSupplyChoice(ctx, interaction)
|
return p.resolveShopSupplyChoice(ctx, interaction)
|
||||||
|
case "shop_curio":
|
||||||
|
return p.resolveShopCurioChoice(ctx, interaction)
|
||||||
case "shop_confirm":
|
case "shop_confirm":
|
||||||
return p.resolveShopConfirm(ctx, interaction)
|
return p.resolveShopConfirm(ctx, interaction)
|
||||||
case "hospital_pay":
|
case "hospital_pay":
|
||||||
|
|||||||
@@ -65,14 +65,17 @@ var consumableDefs = []ConsumableDef{
|
|||||||
{Name: "Voidstone Shard", Effect: EffectReflect, Value: 0.50, Tier: 5, Buyable: false, Slot: "defensive"},
|
{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 {
|
func consumableDefByName(name string) *ConsumableDef {
|
||||||
for i := range consumableDefs {
|
for i := range consumableDefs {
|
||||||
if consumableDefs[i].Name == name {
|
if consumableDefs[i].Name == name {
|
||||||
return &consumableDefs[i]
|
return &consumableDefs[i]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return magicItemConsumableDefByName(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Threat Assessment ────────────────────────────────────────────────────────
|
// ── Threat Assessment ────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -195,7 +196,8 @@ func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment,
|
|||||||
|
|
||||||
sb.WriteString("⚔️ Weapons 🛡️ Armor\n")
|
sb.WriteString("⚔️ Weapons 🛡️ Armor\n")
|
||||||
sb.WriteString("🪖 Helmets 👢 Boots\n")
|
sb.WriteString("🪖 Helmets 👢 Boots\n")
|
||||||
sb.WriteString("⛏️ Tools 🧪 Supplies\n\n")
|
sb.WriteString("⛏️ Tools 🧪 Supplies\n")
|
||||||
|
sb.WriteString("🔮 Curios\n\n")
|
||||||
|
|
||||||
if showAll {
|
if showAll {
|
||||||
flavor, _ := advPickFlavor(luigiShowAllComment, userID, "luigi_showall")
|
flavor, _ := advPickFlavor(luigiShowAllComment, userID, "luigi_showall")
|
||||||
@@ -381,11 +383,25 @@ func (p *AdventurePlugin) resolveShopCategoryChoice(ctx MessageContext, interact
|
|||||||
return p.SendDM(ctx.Sender, text)
|
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)
|
slot := advParseShopCategory(reply)
|
||||||
if slot == "" {
|
if slot == "" {
|
||||||
// Re-store pending and reprompt.
|
// Re-store pending and reprompt.
|
||||||
p.pending.Store(string(ctx.Sender), interaction)
|
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)
|
_, 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.",
|
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))
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ func (p *AdventurePlugin) runDungeonCombat(
|
|||||||
applyClassPassives(&playerStats, &playerMods, dndChar)
|
applyClassPassives(&playerStats, &playerMods, dndChar)
|
||||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||||
|
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||||
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ func (p *AdventurePlugin) buildZoneCombatants(
|
|||||||
applyClassPassives(&playerStats, &playerMods, dndChar)
|
applyClassPassives(&playerStats, &playerMods, dndChar)
|
||||||
applyRacePassives(&playerStats, &playerMods, dndChar)
|
applyRacePassives(&playerStats, &playerMods, dndChar)
|
||||||
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
applySubclassPassives(&playerStats, &playerMods, dndChar)
|
||||||
|
applyMagicItemEffects(&playerStats, &playerMods, userID)
|
||||||
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
if firedName, fired := applyArmedAbility(dndChar, &playerMods); fired {
|
||||||
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
|
slog.Info("dnd: armed ability fired (turn-based build)", "user", userID, "ability", firedName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ func rarityIcon(r DnDRarity) string {
|
|||||||
return "🟩"
|
return "🟩"
|
||||||
case RarityRare:
|
case RarityRare:
|
||||||
return "🟦"
|
return "🟦"
|
||||||
case RarityEpic:
|
case RarityEpic, RarityVeryRare:
|
||||||
return "🟪"
|
return "🟪"
|
||||||
case RarityLegendary:
|
case RarityLegendary:
|
||||||
return "🟧"
|
return "🟧"
|
||||||
|
|||||||
@@ -64,11 +64,12 @@ func (p *AdventurePlugin) handleDnDSheetCmd(ctx MessageContext) error {
|
|||||||
treasures, _ := loadAdvTreasureBonuses(ctx.Sender)
|
treasures, _ := loadAdvTreasureBonuses(ctx.Sender)
|
||||||
meta, _ := loadPlayerMeta(ctx.Sender)
|
meta, _ := loadPlayerMeta(ctx.Sender)
|
||||||
house, _ := loadHouseState(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)
|
ri, _ := raceInfo(c.Race)
|
||||||
ci, _ := classInfo(c.Class)
|
ci, _ := classInfo(c.Class)
|
||||||
mods := c.Modifiers()
|
mods := c.Modifiers()
|
||||||
@@ -137,6 +138,29 @@ func renderDnDSheet(c *DnDCharacter, adv *AdventureCharacter, meta *PlayerMeta,
|
|||||||
b.WriteString(" _(none equipped)_\n")
|
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)
|
// Attunements (re-using adventure_treasures per v1.1 §7.4)
|
||||||
if len(treasures) > 0 {
|
if len(treasures) > 0 {
|
||||||
b.WriteString("\n**Attunements** (treasures)\n")
|
b.WriteString("\n**Attunements** (treasures)\n")
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ func TestSheet_ShowsSubclassWhenChosen(t *testing.T) {
|
|||||||
STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10,
|
STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10,
|
||||||
Subclass: SubclassBattleMaster,
|
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") {
|
if !strings.Contains(out, "Battle Master") {
|
||||||
t.Errorf("sheet missing subclass name:\n%s", out)
|
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,
|
Level: 5, HPMax: 40, HPCurrent: 40, ArmorClass: 16,
|
||||||
STR: 16, DEX: 12, CON: 14, INT: 8, WIS: 10, CHA: 10,
|
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") {
|
if !strings.Contains(out, "!subclass") {
|
||||||
t.Errorf("sheet should nudge `!subclass` when unchosen at L5:\n%s", out)
|
t.Errorf("sheet should nudge `!subclass` when unchosen at L5:\n%s", out)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,11 +376,25 @@ func rngIntN(rng *rand.Rand, n int) int {
|
|||||||
// it into adventure_inventory if one fired, and returns a narration block
|
// it into adventure_inventory if one fired, and returns a narration block
|
||||||
// (empty when no drop). Reuses §5 LootDropCommon/Uncommon/Rare/Legendary
|
// (empty when no drop). Reuses §5 LootDropCommon/Uncommon/Rare/Legendary
|
||||||
// flavor pools — no new flavor file (per feedback_reuse_existing_flavor).
|
// 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 {
|
func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster DnDMonsterTemplate, isBoss bool) string {
|
||||||
entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil)
|
entry, tier, ok := rollZoneLoot(zoneID, monster.CR, isBoss, nil)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
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
|
tierVal := tier
|
||||||
zoneTier := zoneTierFromID(zoneID)
|
zoneTier := zoneTierFromID(zoneID)
|
||||||
item := AdvItem{
|
item := AdvItem{
|
||||||
@@ -410,6 +424,33 @@ func (p *AdventurePlugin) dropZoneLoot(userID id.UserID, zoneID ZoneID, monster
|
|||||||
return b.String()
|
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.
|
// 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
|
// Epic falls through to the Rare pool (next-best fit); Legendary uses its
|
||||||
// own pool. No new flavor file written here.
|
// own pool. No new flavor file written here.
|
||||||
|
|||||||
529
internal/plugin/magic_items_gameplay.go
Normal file
529
internal/plugin/magic_items_gameplay.go
Normal file
@@ -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:<id>" 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())
|
||||||
|
}
|
||||||
216
internal/plugin/magic_items_gameplay_test.go
Normal file
216
internal/plugin/magic_items_gameplay_test.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user