package plugin import ( "fmt" "log/slog" "math" "math/rand/v2" "sort" "strings" "time" "maunium.net/go/mautrix/id" ) // ── Shop Listings ──────────────────────────────────────────────────────────── // slotEmoji returns a display emoji for a slot category. func slotEmoji(slot EquipmentSlot) string { switch slot { case SlotWeapon: return "⚔️" case SlotArmor: return "🛡️" case SlotHelmet: return "🪖" case SlotBoots: return "👢" case SlotTool: return "⛏️" default: return "📦" } } // slotTitle returns a capitalized display name for a slot. func slotTitle(slot EquipmentSlot) string { s := string(slot) if len(s) == 0 { return s } return strings.ToUpper(s[:1]) + s[1:] } // ── Pending Interaction Types ─────────────────────────────────────────────── type advPendingShopCategory struct { ShowAll bool } type advPendingShopItem struct { Slot EquipmentSlot ShowAll bool } type advPendingShopConfirm struct { Slot EquipmentSlot Tier int } // ── Session Tracking ──────────────────────────────────────────────────────── type advShopSession struct { StartedAt time.Time ItemsBought int // Persuasion discount: 0.10 if the player passed Persuasion DC 15 on // session start, else 0. Applied to all purchases this session. // Expires `dndPersuasionDiscountTTL` after StartedAt — players can't // hold a discount session open indefinitely. PersuasionDiscount float64 } // dndPersuasionDiscountTTL — how long after shop entry the Persuasion // discount remains valid. After this, prices return to full. const dndPersuasionDiscountTTL = 30 * time.Minute func (p *AdventurePlugin) shopSessionGet(userID id.UserID) *advShopSession { if val, ok := p.shopSessions.Load(string(userID)); ok { return val.(*advShopSession) } return nil } func (p *AdventurePlugin) shopSessionStart(userID id.UserID) { if p.shopSessionGet(userID) == nil { discount := 0.0 if dndNPCPersuasionDiscount(userID) { discount = 0.10 } p.shopSessions.Store(string(userID), &advShopSession{ StartedAt: time.Now(), PersuasionDiscount: discount, }) } } // shopSessionPriceFactor returns the multiplier to apply to shop prices for // the given user's current session. 1.0 normally, 0.9 if Persuasion passed. // The discount expires after dndPersuasionDiscountTTL to prevent indefinite // session-holding (audit fix G). func (p *AdventurePlugin) shopSessionPriceFactor(userID id.UserID) float64 { sess := p.shopSessionGet(userID) if sess == nil { return 1.0 } if sess.PersuasionDiscount > 0 && time.Since(sess.StartedAt) > dndPersuasionDiscountTTL { return 1.0 } return 1.0 - sess.PersuasionDiscount } // shopSessionAnnounceDiscount returns flavor text to surface a passed // Persuasion check, or empty string if none/expired. func (p *AdventurePlugin) shopSessionAnnounceDiscount(userID id.UserID) string { sess := p.shopSessionGet(userID) if sess == nil || sess.PersuasionDiscount <= 0 { return "" } if time.Since(sess.StartedAt) > dndPersuasionDiscountTTL { return "" } return "_(Persuasion DC 15 passed — 10% discount applied; expires 30 minutes after shop entry.)_" } func (p *AdventurePlugin) shopSessionBump(userID id.UserID) { sess := p.shopSessionGet(userID) if sess != nil { sess.ItemsBought++ } } func (p *AdventurePlugin) shopSessionEnd(userID id.UserID) { p.shopSessions.Delete(string(userID)) } // ── Browse Timeout ────────────────────────────────────────────────────────── // shopNudgeGen tracks a generation counter per user so only the latest nudge fires. // Key: userID string, Value: *int64 (atomic counter) func (p *AdventurePlugin) shopScheduleBrowseNudge(userID id.UserID) { // Bump generation so any prior goroutine becomes stale. var gen int64 if val, ok := p.shopSessions.Load(string(userID) + ":nudge_gen"); ok { gen = val.(int64) + 1 } p.shopSessions.Store(string(userID)+":nudge_gen", gen) capturedGen := gen safeGo("shop-nudge", func() { time.Sleep(2 * time.Minute) // Check if this goroutine is still the latest. if val, ok := p.shopSessions.Load(string(userID) + ":nudge_gen"); !ok || val.(int64) != capturedGen { return } val, ok := p.pending.Load(string(userID)) if !ok { return } pi, ok := val.(*advPendingInteraction) if !ok || !strings.HasPrefix(pi.Type, "shop_") { return } flavor, _ := advPickFlavor(luigiBrowseTimeout, userID, "luigi_browse") p.SendDM(userID, fmt.Sprintf("*%s*", flavor)) }) } // ── Display: Luigi Greeting + Category Menu ───────────────────────────────── func luigiShopGreeting(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool, chatLevel int) string { var sb strings.Builder // Check if fully maxed out. allMaxed := true for _, slot := range allSlots { eq := equip[slot] if eq == nil || eq.Tier < 5 { allMaxed = false break } } if allMaxed { flavor, _ := advPickFlavor(luigiMaxedOut, userID, "luigi_maxed") sb.WriteString(fmt.Sprintf("🛒 **Luigi's**\n\n*%s*", flavor)) return sb.String() } var greet string if chatLevel >= 10 { greet = shopGreeting(chatLevel) } else { greet, _ = advPickFlavor(luigiGreetings, userID, "luigi_greet") } 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 🚪 Exit\n\n") if showAll { flavor, _ := advPickFlavor(luigiShowAllComment, userID, "luigi_showall") sb.WriteString(fmt.Sprintf("*%s*\n\n", flavor)) } sb.WriteString("Reply with a category name to browse.") return sb.String() } // ── Display: Category Item List ───────────────────────────────────────────── func luigiCategoryView(userID id.UserID, slot EquipmentSlot, equip map[EquipmentSlot]*AdvEquipment, balance float64, showAll bool) string { var sb strings.Builder current := equip[slot] currentTier := 0 currentName := "None" isMWOrArena := false if current != nil { currentTier = current.Tier currentName = current.Name isMWOrArena = current.Masterwork || current.ArenaTier > 0 } emoji := slotEmoji(slot) title := strings.ToUpper(string(slot)) // Category intro if intros, ok := luigiCategoryIntros[slot]; ok && len(intros) > 0 { intro, _ := advPickFlavor(intros, userID, "luigi_cat_"+string(slot)) sb.WriteString(fmt.Sprintf("*%s*\n\n", intro)) } sb.WriteString(fmt.Sprintf("%s **%s** — Your current: %s (T%d)", emoji, title, currentName, currentTier)) if isMWOrArena { sb.WriteString(" ⭐") } sb.WriteString("\n\n") if isMWOrArena { ack, _ := advPickFlavor(luigiMasterworkAck, userID, "luigi_mw_ack") sb.WriteString(fmt.Sprintf("*%s*\n\n", ack)) } defs := equipmentTiers[slot] hasItems := false // Show current equipped item first. if current != nil { for _, def := range defs { if def.Tier == currentTier { sb.WriteString(fmt.Sprintf("🟢 %-28s T%d €%.0f Currently equipped\n", def.Name, def.Tier, def.Price)) break } } } for _, def := range defs { if def.Price == 0 { continue // skip tier 0 } if def.Tier == currentTier { continue // already shown as 🟢 } if !showAll && def.Tier < currentTier { continue // skip downgrades unless show all } hasItems = true // Upgrade indicator var indicator string switch { case def.Tier > currentTier: indicator = "⬆️" case def.Tier == currentTier: indicator = "➡️" default: indicator = "⬇️" } // Affordability afford := "✅" if balance < def.Price { afford = "💸" } // One-liner oneLiner := "" if line, ok := luigiItemOneLiners[luigiItemKey{slot, def.Tier}]; ok { oneLiner = fmt.Sprintf(" \"%s\"", line) } sb.WriteString(fmt.Sprintf("%s %-28s T%d €%-8.0f %s%s\n", indicator, def.Name, def.Tier, def.Price, afford, oneLiner)) } // Occasional unprompted Luigi commentary (~30% chance). if hasItems && rand.IntN(100) < 30 { comment, _ := advPickFlavor(luigiCommentary, userID, "luigi_comment") sb.WriteString(fmt.Sprintf("\n*%s*\n", comment)) } if !hasItems { sb.WriteString("✨ You've reached max tier! Nothing left to buy here.\n") } if !showAll && currentTier > 1 { sb.WriteString(fmt.Sprintf("\nTiers below T%d omitted. Use `!adventure shop show all` to see everything.\n", currentTier)) } sb.WriteString("\nReply with an item name to buy, or \"back\" to return to categories.") return sb.String() } // ── Display: Item Confirm ─────────────────────────────────────────────────── func luigiItemConfirm(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, current *AdvEquipment, balance float64) string { var sb strings.Builder currentTier := 0 currentName := "None" if current != nil { currentTier = current.Tier currentName = current.Name } // Upgrade indicator indicator := "⬆️" if def.Tier == currentTier { indicator = "➡️" } else if def.Tier < currentTier { indicator = "⬇️" } sb.WriteString(fmt.Sprintf("%s **%s** — T%d %s — €%.0f\n\n", indicator, def.Name, def.Tier, slotTitle(slot), def.Price)) if def.Tier > currentTier { sb.WriteString(fmt.Sprintf("An upgrade from your %s (T%d).\n", currentName, currentTier)) } else if def.Tier == currentTier { sb.WriteString(fmt.Sprintf("Same tier as your current %s.\n", currentName)) } else { sb.WriteString(fmt.Sprintf("A downgrade from your %s (T%d).\n", currentName, currentTier)) } // Full Luigi description if desc, ok := luigiItemDescriptions[luigiItemKey{slot, def.Tier}]; ok { sb.WriteString(fmt.Sprintf("\n\"%s\"\n", desc)) } afterBalance := balance - def.Price sb.WriteString(fmt.Sprintf("\nYour balance: €%.0f → €%.0f after purchase\n\n", balance, afterBalance)) sb.WriteString("Confirm? (yes / no)") return sb.String() } // ── Resolver: Category Choice ─────────────────────────────────────────────── func (p *AdventurePlugin) resolveShopCategoryChoice(ctx MessageContext, interaction *advPendingInteraction) error { data := interaction.Data.(*advPendingShopCategory) reply := strings.ToLower(strings.TrimSpace(ctx.Body)) if reply == "back" || reply == "exit" || reply == "cancel" { p.shopSessionEnd(ctx.Sender) flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel") return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor)) } // Check for supplies category first if reply == "supplies" || reply == "supply" || reply == "consumables" || reply == "potions" { balance := p.euro.GetBalance(ctx.Sender) text := luigiSuppliesView(ctx.Sender, balance) p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "shop_supply", Data: data, ExpiresAt: time.Now().Add(advDMResponseWindow), }) p.advMarkMenuSent(ctx.Sender) p.shopScheduleBrowseNudge(ctx.Sender) return p.SendDM(ctx.Sender, text) } // Curios — Open5e magic items, daily rotating stock. if reply == "curios" || reply == "curio" || reply == "magic" || reply == "trinkets" { balance := p.euro.GetBalance(ctx.Sender) text := p.luigiCuriosView(ctx.Sender, balance) p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "shop_curio", Data: data, ExpiresAt: time.Now().Add(advDMResponseWindow), }) p.advMarkMenuSent(ctx.Sender) p.shopScheduleBrowseNudge(ctx.Sender) return p.SendDM(ctx.Sender, text) } slot := advParseShopCategory(reply) if slot == "" { // Re-store pending and reprompt. p.pending.Store(string(ctx.Sender), interaction) return p.SendDM(ctx.Sender, "I didn't catch that. Reply with a category: weapons, armor, helmets, boots, tools, supplies, or curios.") } _, 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 := luigiCategoryView(ctx.Sender, slot, equip, balance, data.ShowAll) p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "shop_item", Data: &advPendingShopItem{Slot: slot, ShowAll: data.ShowAll}, ExpiresAt: time.Now().Add(advDMResponseWindow), }) p.advMarkMenuSent(ctx.Sender) p.shopScheduleBrowseNudge(ctx.Sender) return p.SendDM(ctx.Sender, text) } // ── Resolver: Item Choice ─────────────────────────────────────────────────── func (p *AdventurePlugin) resolveShopItemChoice(ctx MessageContext, interaction *advPendingInteraction) error { data := interaction.Data.(*advPendingShopItem) reply := strings.TrimSpace(ctx.Body) lower := strings.ToLower(reply) if lower == "back" { // Return to category menu. _, 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), }) p.advMarkMenuSent(ctx.Sender) return p.SendDM(ctx.Sender, text) } if lower == "exit" || lower == "cancel" { p.shopSessionEnd(ctx.Sender) flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel") return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor)) } // Try to find the item scoped to the current slot first, then globally. slot, def, found := advFindShopItemInSlot(reply, data.Slot) if !found { slot, def, found = advFindShopItem(reply) } if !found { p.pending.Store(string(ctx.Sender), interaction) return p.SendDM(ctx.Sender, fmt.Sprintf("I don't have anything matching \"%s\". Reply with an item name from the list, or \"back\" to return.", reply)) } // Load fresh data. _, 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) current := equip[slot] // Check Arena/Masterwork block — only block if shop item isn't clearly better. // Arena gear always blocks (1.5x multiplier, earned through combat). // Masterwork blocks only if the shop item's tier doesn't exceed the MW effective tier. if current != nil && current.ArenaTier > 0 && def.Tier <= current.ArenaTier { p.pending.Store(string(ctx.Sender), interaction) return p.SendDM(ctx.Sender, fmt.Sprintf("You have **%s** (Arena gear, T%d) in that slot. That T%d shop item isn't an upgrade.\n\nPick something else, or \"back\" to return.", current.Name, current.ArenaTier, def.Tier)) } if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) { p.pending.Store(string(ctx.Sender), interaction) return p.SendDM(ctx.Sender, fmt.Sprintf("You have **%s** (Masterwork ⭐) in that slot. That T%d shop item isn't an upgrade over your T%d Masterwork.\n\nPick something else, or \"back\" to return.", current.Name, def.Tier, current.Tier)) } text := luigiItemConfirm(ctx.Sender, slot, def, current, balance) p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "shop_confirm", Data: &advPendingShopConfirm{Slot: slot, Tier: def.Tier}, ExpiresAt: time.Now().Add(advDMResponseWindow), }) p.advMarkMenuSent(ctx.Sender) p.shopScheduleBrowseNudge(ctx.Sender) return p.SendDM(ctx.Sender, text) } // ── Resolver: Purchase Confirm ────────────────────────────────────────────── func (p *AdventurePlugin) resolveShopConfirm(ctx MessageContext, interaction *advPendingInteraction) error { userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() data := interaction.Data.(*advPendingShopConfirm) reply := strings.ToLower(strings.TrimSpace(ctx.Body)) if reply != "yes" && reply != "y" && reply != "confirm" { p.shopSessionEnd(ctx.Sender) flavor, _ := advPickFlavor(luigiCancellation, ctx.Sender, "luigi_cancel") return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*", flavor)) } // Reload fresh equipment and balance (TOCTOU protection). _, 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) // Find the def fresh. defs := equipmentTiers[data.Slot] var def *EquipmentDef for i := range defs { if defs[i].Tier == data.Tier { def = &defs[i] break } } if def == nil { return p.SendDM(ctx.Sender, "Something went wrong. Item not found.") } current := equip[data.Slot] // Re-check Arena/Masterwork block. if current != nil && current.ArenaTier > 0 && def.Tier <= current.ArenaTier { return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** (Arena gear T%d) is not worse than that T%d shop item.", current.Name, current.ArenaTier, def.Tier)) } if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) { return p.SendDM(ctx.Sender, fmt.Sprintf("Your **%s** (Masterwork ⭐) is better than that T%d shop item.", current.Name, def.Tier)) } // Persuasion-discounted price. price := def.Price * p.shopSessionPriceFactor(ctx.Sender) // Affordability. if balance < price { flavor, _ := advPickFlavor(luigiInsufficientFunds, ctx.Sender, "luigi_broke") return p.SendDM(ctx.Sender, fmt.Sprintf("*%s*\n\nYou have €%.0f. %s costs €%.0f.", flavor, balance, def.Name, price)) } // Debit. if !p.euro.Debit(ctx.Sender, price, "adventure_shop_"+string(data.Slot)) { return p.SendDM(ctx.Sender, "Transaction failed. The economy is having a moment.") } // Community contribution: 5% of purchase price. if potCut := int(price * 0.05); potCut > 0 { communityPotAdd(potCut) trackTaxPaid(ctx.Sender, potCut) } // Move old gear to inventory before replacing. if current != nil && current.Tier > 0 { itemType := "ShopGear" var resaleValue int64 if current.Masterwork { itemType = "MasterworkGear" // Masterwork has no resale value (it's special, not shop-priced) } else if current.ArenaTier > 0 { itemType = "ArenaGear" } else if current.Tier < len(equipmentTiers[data.Slot]) { resaleValue = int64(equipmentTiers[data.Slot][current.Tier].Price * 0.3) } err := addAdvInventoryItem(ctx.Sender, AdvItem{ Name: current.Name, Type: itemType, Tier: current.Tier, Value: resaleValue, Slot: data.Slot, SkillSource: current.SkillSource, }) if err != nil { slog.Error("shop: failed to move old gear to inventory", "user", ctx.Sender, "slot", data.Slot, "err", err) } } // Save new equipment. eq := &AdvEquipment{ Slot: data.Slot, Tier: def.Tier, Condition: 100, Name: def.Name, ActionsUsed: 0, } if err := saveAdvEquipment(ctx.Sender, eq); err != nil { // Refund on DB error. p.euro.Credit(ctx.Sender, def.Price, "adventure_shop_refund") return p.SendDM(ctx.Sender, "Something went wrong saving your equipment. You've been refunded.") } // Build confirmation message. p.shopSessionBump(ctx.Sender) var confirmText string sess := p.shopSessionGet(ctx.Sender) isCombo := sess != nil && sess.ItemsBought >= 3 if isCombo { flavor, _ := advPickFlavor(luigiComboConfirm, ctx.Sender, "luigi_combo") confirmText = fmt.Sprintf(flavor, int(def.Price), def.Name) } else if def.Tier == 5 { flavor, _ := advPickFlavor(luigiTier5Confirm, ctx.Sender, "luigi_t5") confirmText = fmt.Sprintf(flavor, int(def.Price), def.Name) } else { flavor, _ := advPickFlavor(luigiPurchaseConfirm, ctx.Sender, "luigi_buy") confirmText = fmt.Sprintf(flavor, def.Name, int(def.Price)) } var sb strings.Builder sb.WriteString(confirmText) sb.WriteString(fmt.Sprintf("\n\n✅ **%s** equipped.", def.Name)) if current != nil && current.Tier > 0 { sb.WriteString(fmt.Sprintf(" %s moved to inventory.", current.Name)) } sb.WriteString(fmt.Sprintf("\nBalance: €%.0f", balance-def.Price)) return p.SendDM(ctx.Sender, sb.String()) } // ── Find Shop Item ─────────────────────────────────────────────────────────── // normalizeQuotes replaces common Unicode quotes/apostrophes with ASCII equivalents. var quoteReplacer = strings.NewReplacer( "\u2018", "'", "\u2019", "'", // left/right single curly quotes "\u201C", "\"", "\u201D", "\"", // left/right double curly quotes "\u2032", "'", // prime ) func normalizeQuotes(s string) string { return quoteReplacer.Replace(s) } func advFindShopItem(name string) (EquipmentSlot, *EquipmentDef, bool) { name = normalizeQuotes(strings.TrimSpace(name)) // Support tier+category shorthand: "3 sword", "tier 3 weapon", "t3 boots", etc. if slot, def, ok := advFindByTierShorthand(name); ok { return slot, def, true } for _, slot := range allSlots { for i := range equipmentTiers[slot] { def := &equipmentTiers[slot][i] if def.Price == 0 { continue // can't buy tier 0 } if strings.EqualFold(def.Name, name) || containsFold(normalizeQuotes(def.Name), name) { return slot, def, true } } } return "", nil, false } // advFindShopItemInSlot finds an item scoped to a specific slot. func advFindShopItemInSlot(name string, slot EquipmentSlot) (EquipmentSlot, *EquipmentDef, bool) { name = normalizeQuotes(strings.TrimSpace(name)) for i := range equipmentTiers[slot] { def := &equipmentTiers[slot][i] if def.Price == 0 { continue } if strings.EqualFold(def.Name, name) || containsFold(normalizeQuotes(def.Name), name) { return slot, def, true } } return "", nil, false } // advFindByTierShorthand matches patterns like "3 sword", "tier 3 weapon", "t3 boots". func advFindByTierShorthand(input string) (EquipmentSlot, *EquipmentDef, bool) { lower := strings.ToLower(input) // Strip optional "tier " or "t" prefix. lower = strings.TrimPrefix(lower, "tier ") lower = strings.TrimPrefix(lower, "t") // Expect " " or "". parts := strings.SplitN(strings.TrimSpace(lower), " ", 2) if len(parts) < 2 { return "", nil, false } tierStr := strings.TrimSpace(parts[0]) category := strings.TrimSpace(parts[1]) tier := 0 for _, c := range tierStr { if c < '0' || c > '9' { return "", nil, false } tier = tier*10 + int(c-'0') } slot := advParseShopCategory(category) if slot == "" { return "", nil, false } defs := equipmentTiers[slot] for i := range defs { if defs[i].Tier == tier && defs[i].Price > 0 { return slot, &defs[i], true } } return "", nil, false } var shopCategoryAliases = map[string]EquipmentSlot{ "weapon": SlotWeapon, "weapons": SlotWeapon, "sword": SlotWeapon, "swords": SlotWeapon, "armor": SlotArmor, "armour": SlotArmor, "helmet": SlotHelmet, "helm": SlotHelmet, "helmets": SlotHelmet, "boots": SlotBoots, "boot": SlotBoots, "tool": SlotTool, "tools": SlotTool, "pickaxe": SlotTool, } // advParseShopCategory maps user input to an EquipmentSlot. func advParseShopCategory(input string) EquipmentSlot { return shopCategoryAliases[strings.ToLower(strings.TrimSpace(input))] } // ── Buy Equipment (legacy !adventure buy path) ────────────────────────────── func (p *AdventurePlugin) advBuyEquipment(userID id.UserID, slot EquipmentSlot, def *EquipmentDef, equip map[EquipmentSlot]*AdvEquipment) string { current := equip[slot] if current != nil && current.Tier >= def.Tier { return fmt.Sprintf("You already have %s (Tier %d). %s is Tier %d. That's not an upgrade, that's a lateral move at best.", current.Name, current.Tier, def.Name, def.Tier) } // Block shop purchases that would overwrite arena gear (always) or // masterwork gear (only if shop item isn't clearly better). if current != nil && current.ArenaTier > 0 && def.Tier <= current.ArenaTier { return fmt.Sprintf("You already have **%s** (Arena gear T%d). That T%d shop item isn't an upgrade.", current.Name, current.ArenaTier, def.Tier) } if current != nil && current.Masterwork && float64(def.Tier) <= advEffectiveTier(current) { return fmt.Sprintf("You already have **%s** (Masterwork ⭐ T%d). That T%d shop item isn't an upgrade.", current.Name, current.Tier, def.Tier) } price := def.Price * p.shopSessionPriceFactor(userID) balance := p.euro.GetBalance(userID) if balance < price { flavor, _ := advPickFlavor(luigiInsufficientFunds, userID, "luigi_broke") return fmt.Sprintf("*%s*\n\nYou have €%.0f. %s costs €%.0f.", flavor, balance, def.Name, price) } if !p.euro.Debit(userID, price, "adventure_shop_"+string(slot)) { return "Transaction failed. The economy is having a moment." } // Community contribution: 5% of purchase price. if potCut := int(price * 0.05); potCut > 0 { communityPotAdd(potCut) trackTaxPaid(userID, potCut) } // Move old gear to inventory. if current != nil && current.Tier > 0 { itemType := "ShopGear" var resaleValue int64 if current.Masterwork { itemType = "MasterworkGear" } else if current.ArenaTier > 0 { itemType = "ArenaGear" } else if current.Tier < len(equipmentTiers[slot]) { resaleValue = int64(equipmentTiers[slot][current.Tier].Price * 0.3) } err := addAdvInventoryItem(userID, AdvItem{ Name: current.Name, Type: itemType, Tier: current.Tier, Value: resaleValue, Slot: slot, SkillSource: current.SkillSource, }) if err != nil { slog.Error("shop: failed to move old gear to inventory", "user", userID, "slot", slot, "err", err) } } // Update equipment eq := &AdvEquipment{ Slot: slot, Tier: def.Tier, Condition: 100, Name: def.Name, ActionsUsed: 0, } if err := saveAdvEquipment(userID, eq); err != nil { // Refund on DB error p.euro.Credit(userID, def.Price, "adventure_shop_refund") return "Something went wrong saving your equipment. You've been refunded." } equip[slot] = eq var sb strings.Builder if def.Tier == 5 { flavor, _ := advPickFlavor(luigiTier5Confirm, userID, "luigi_t5") sb.WriteString(fmt.Sprintf(flavor, int(def.Price), def.Name)) } else { flavor, _ := advPickFlavor(luigiPurchaseConfirm, userID, "luigi_buy") sb.WriteString(fmt.Sprintf(flavor, def.Name, int(def.Price))) } sb.WriteString(fmt.Sprintf("\n\n✅ **%s** equipped.", def.Name)) if current != nil && current.Tier > 0 { sb.WriteString(fmt.Sprintf(" %s moved to inventory.", current.Name)) } return sb.String() } // ── Sell Items ──────────────────────────────────────────────────────────────── func (p *AdventurePlugin) advSellAll(userID id.UserID) string { items, err := loadAdvInventory(userID) if err != nil { return "Failed to access your inventory. Try again." } if len(items) == 0 { return "Your inventory is empty. There is nothing to sell. This is a metaphor for something but now is not the time." } var total float64 var sold int var keptSpecial int var keptConsumable int var keptMagic int for _, item := range items { if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" { keptSpecial++ continue } if item.Type == "magic_item" { // B1: magic items are bonded curios, not bulk loot — sell-all must // never silently delete them. Route the player to !adventure // equip-magic instead. keptMagic++ continue } if item.Type == "consumable" { keptConsumable++ continue } if err := removeAdvInventoryItem(item.ID); err != nil { continue } total += float64(item.Value) sold++ } if sold == 0 { switch { case keptMagic > 0 && (keptSpecial > 0 || keptConsumable > 0): return "Your inventory only contains special gear, curios, and/or consumables. The merchant won't touch any of it. Use `!adventure equip-magic` for curios, `!adventure equip` for gear, or `!adventure sell ` for individual consumables." case keptMagic > 0: return "Your inventory only contains curios (magic items). The merchant doesn't deal in those. Use `!adventure equip-magic` instead." case keptSpecial > 0 && keptConsumable > 0: return "Your inventory only contains special gear and consumables. The merchant won't touch any of it. Use `!adventure equip` for gear or `!adventure sell ` for individual consumables." case keptSpecial > 0: return "Your inventory only contains Masterwork and Arena gear. The merchant doesn't deal in that. Use `!adventure equip` instead." case keptConsumable > 0: return "Your inventory only contains consumables. `sell all` won't touch them — use `!adventure sell ` to sell a specific consumable." } return "Your inventory is empty. There is nothing to sell. This is a metaphor for something but now is not the time." } potCut := math.Round(total * 0.05) payout := total - potCut p.euro.Credit(userID, payout, "adventure_sell_all") if int(potCut) > 0 { communityPotAdd(int(potCut)) trackTaxPaid(userID, int(potCut)) } msg := fmt.Sprintf("Sold %d items for **%s** (%s after 5%% broker fee → community pot).\n\nThe merchant took everything without comment. "+ "This is the most respect anyone has shown your loot collection. Take the money.", sold, fmtEuro(total), fmtEuro(payout)) if keptSpecial > 0 { msg += fmt.Sprintf("\n\n(%d special gear items kept — the merchant knows better than to touch those.)", keptSpecial) } if keptConsumable > 0 { msg += fmt.Sprintf("\n(%d consumable(s) kept — `sell all` doesn't touch them. Sell explicitly with `!adventure sell `.)", keptConsumable) } if keptMagic > 0 { msg += fmt.Sprintf("\n(%d curio(s) kept — `!adventure equip-magic` to bond them. The merchant wouldn't know what to do with them.)", keptMagic) } return msg } func (p *AdventurePlugin) advSellItem(userID id.UserID, itemName string) string { items, err := loadAdvInventory(userID) if err != nil { return "Failed to access your inventory." } // Fuzzy match for _, item := range items { if containsFold(item.Name, itemName) { if item.Type == "MasterworkGear" || item.Type == "ArenaGear" || item.Type == "card" { return fmt.Sprintf("**%s** is special gear. The merchant won't touch it. Use `!adventure equip` to equip it.", item.Name) } if item.Type == "magic_item" { return fmt.Sprintf("**%s** is a curio — the merchant doesn't deal in those. Use `!adventure equip-magic` to bond it instead.", item.Name) } if err := removeAdvInventoryItem(item.ID); err != nil { return "Failed to sell that item." } potCut := int(math.Round(float64(item.Value) * 0.05)) payout := int(item.Value) - potCut p.euro.Credit(userID, float64(payout), "adventure_sell_"+item.Name) if potCut > 0 { communityPotAdd(potCut) trackTaxPaid(userID, potCut) } return fmt.Sprintf("Sold **%s** for **%s** (%s after 5%% broker fee → community pot). The merchant nodded. That's it. That's the transaction.", item.Name, fmtEuro(item.Value), fmtEuro(payout)) } } return fmt.Sprintf("No item matching '%s' found in your inventory.", itemName) } // ── Inventory Display ──────────────────────────────────────────────────────── func advInventoryDisplay(userID id.UserID) string { items, err := loadAdvInventory(userID) if err != nil { return "Failed to load inventory." } if len(items) == 0 { return "🎒 Your inventory is empty. Go adventure." } var sb strings.Builder sb.WriteString("🎒 **Inventory**\n\n") var total int64 hasMagic := false for _, item := range items { switch item.Type { case "MasterworkGear": sb.WriteString(fmt.Sprintf(" ⭐ %s (T%d Masterwork %s)\n", item.Name, item.Tier, slotTitle(item.Slot))) case "ArenaGear": sb.WriteString(fmt.Sprintf(" ⚔️ %s (T%d Arena %s)\n", item.Name, item.Tier, slotTitle(item.Slot))) case "card": sb.WriteString(fmt.Sprintf(" 🃏 %s\n", item.Name)) 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 } } 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 { var sb strings.Builder sb.WriteString("🧪 **Supplies** — combat consumables (auto-used)\n") sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance)) sb.WriteString("Items are used automatically during combat. The engine picks up to 2 per fight based on threat level — it won't waste them on easy fights.\n\n") defs := BuyableConsumables() if len(defs) == 0 { sb.WriteString("_Nothing in stock right now._\n\n") } else { for _, def := range defs { effectDesc := consumableEffectDescription(def.Effect, def.Value) sb.WriteString(fmt.Sprintf("**%s** (T%d) — €%d\n %s\n\n", def.Name, def.Tier, def.Price, effectDesc)) } } sb.WriteString("Reply with an item name to buy, or `back` to return.\n") sb.WriteString("Stronger consumables drop from foraging, mining, fishing, and dungeons at T2+.") return sb.String() } func consumableEffectDescription(effect ConsumableEffect, value float64) string { switch effect { case EffectHeal: return fmt.Sprintf("Restores %d HP mid-combat (triggers at <50%% HP)", int(value)) case EffectDefBoost: return fmt.Sprintf("+%.0f%% Defense for the fight", value*100) case EffectAtkBoost: return fmt.Sprintf("+%.0f%% Attack for the fight", value*100) case EffectWard: return "Blocks one hit completely" case EffectSpeedBoost: return fmt.Sprintf("+%.0f%% Speed for the fight (evasion boost)", value*100) case EffectCritBoost: return fmt.Sprintf("+%.0f%% Crit Rate for the fight", value*100) case EffectFlatDmg: return fmt.Sprintf("Deals %d damage at fight start", int(value)) case EffectSpore: return fmt.Sprintf("15%% enemy miss chance for %d rounds", int(value)) case EffectReflect: return fmt.Sprintf("Reflects %.0f%% of next hit back at enemy", value*100) case EffectAutoCrit: return fmt.Sprintf("First hit is auto-crit + %.0f%% Attack", value*100) } return "" } func (p *AdventurePlugin) resolveShopSupplyChoice(ctx MessageContext, interaction *advPendingInteraction) error { reply := strings.ToLower(strings.TrimSpace(ctx.Body)) if reply == "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 reply == "exit" || reply == "cancel" { p.shopSessionEnd(ctx.Sender) return p.SendDM(ctx.Sender, "*Luigi nods and gestures toward the main counter.*") } // Find matching consumable var match *ConsumableDef for i := range consumableDefs { if consumableDefs[i].Buyable && containsFold(consumableDefs[i].Name, reply) { match = &consumableDefs[i] break } } if match == nil { p.pending.Store(string(ctx.Sender), interaction) return p.SendDM(ctx.Sender, "I don't have that. Reply with an item name from the list, or `back` to return.") } consumablePrice := float64(match.Price) * p.shopSessionPriceFactor(ctx.Sender) balance := p.euro.GetBalance(ctx.Sender) if balance < consumablePrice { p.pending.Store(string(ctx.Sender), interaction) return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%.0f for %s but only have €%.0f.", consumablePrice, match.Name, balance)) } // Purchase the consumable p.euro.Debit(ctx.Sender, consumablePrice, "shop_consumable") if potCut := int(math.Round(consumablePrice * 0.05)); potCut > 0 { communityPotAdd(potCut) trackTaxPaid(ctx.Sender, potCut) } item := AdvItem{ Name: match.Name, Type: "consumable", Tier: match.Tier, Value: match.Price / 2, } _ = addAdvInventoryItem(ctx.Sender, item) // Stay in supplies 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, 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. // // 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 { ids = append(ids, id) } sort.Strings(ids) // 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) 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] }) target := curiosStockSize if target > len(ids) { target = len(ids) } stock := make([]MagicItem, 0, target) seen := make(map[string]bool) // N7/E4 — a live season features its curated items at the front of the shelf, // displacing the day's rotation. They are existing registry items, so no // net-new power enters the economy for the week; only which items rotate in. if s, ok := activeSeason(); ok { for _, id := range s.CurioIDs { if mi, ok := magicItemRegistry[id]; ok && !seen[id] { stock = append(stock, mi) seen[id] = true } } if len(stock) > target { target = len(stock) } } // Fill the remaining slots from the day's deterministic rotation. for _, id := range ids { if len(stock) >= target { break } if seen[id] { continue } stock = append(stock, magicItemRegistry[id]) seen[id] = true } // 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 if s, ok := activeSeason(); ok { sb.WriteString(fmt.Sprintf("%s **Curios** — %s stock, this week only\n", s.Emoji, s.Name)) } else { 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 // 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 += " · needs bonding" } if mi.Kind == MagicItemPotion || mi.Kind == MagicItemScroll { tag += " · auto-uses" } afford := "✅" if balance < 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("_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) 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. 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 { 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) 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)) } // Guard the Debit return: GetBalance above is a snapshot read, but // the debt-limit check inside Debit (BLACKJACK_DEBT_LIMIT) can refuse // the write if a concurrent debit (blackjack, lottery, etc.) has // slipped the balance across the limit between read and write. // Without this guard the curio would be granted on a refused debit. if !p.euro.Debit(ctx.Sender, price, "shop_curio") { p.pending.Store(string(ctx.Sender), interaction) return p.SendDM(ctx.Sender, fmt.Sprintf( "Tried to charge €%.0f for %s, but the purse refused — your balance moved out from under us. Try again.", price, match.Name)) } 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)) }