Files
gogobee/internal/plugin/adventure_shop.go
T
prosolis 2ce3e682ea zones: stop the tools/backtrack path losing rooms and inventory
Review fallout from the locked-doors commit. Five defects, all in the
seams that commit opened:

backtrackFromDeadFork stepped to VisitedNodes[idx-1]. That slice is a
first-entry ordered *set*, not a path stack (see appendVisited), so once
a run has doubled back once the entry before CurrentNode can sit on a
different branch entirely — the party teleports across the map to a room
no edge connects. `!revisit` refuses exactly that move via
adjacentNodes; the autopilot has no business doing what the player is
forbidden from doing. Now routed through backtrackTarget, which also
refuses to fall back into a corridor whose only exit is the sealed fork:
the lock rolls are seeded per (run, edge), so walking back in gets the
same answer every time, forever.

The autopilot spent the player's thieves' tools *before* committing the
move, so an advanceZoneRunNode failure left them charged and then had
the caller's backtrack clear the fork they had just paid to open. The
tools are now reported by autoPickWithTools and only removed once the
party is actually through the door.

`sell all` never learned the new "tool" type and turned a €600 set into
€300 of loot — the same silent deletion Robbie was taught to avoid two
commits ago. It was already doing this to "key" quest tokens, so both
now sit with the special gear.

The shop's tools branch asked "is the reply a substring of Thieves'
Tools", which is true for a bare "s", for "to", and for an empty message
body — and it runs ahead of the consumable list, so a stray keystroke in
the Supplies view bought a set. isThievesToolsReply matches on the
item's own words instead.

Plus two cleanups in the same code: Robbie built his haul gifts by
calling consumableCache(tier, 1) in a loop when it already takes a
count, and the unlock flow read the whole inventory three times per
command.
2026-07-21 00:20:24 -07:00

1403 lines
49 KiB
Go

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 "<number> <category>" or "<number><category>".
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 {
// Keys and tools ride along with the special gear here for the same
// reason Robbie won't take them: both are bought or found precisely so a
// door can be opened *later*, and `sell all` is a bulk-loot verb the
// player fires after every haul without reading the list. Turning one
// into €300 silently deletes the unlock it was carried for.
switch item.Type {
case "MasterworkGear", "ArenaGear", "card", "key", thievesToolsItemType:
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 <name>` 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 <name>` 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 <name>` 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 <name>`.)", 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 <item>`")
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(fmt.Sprintf("**%s** — €%d\n Opens one dungeon path a failed check closed (`!zone unlock <n>`). Not used in combat.\n\n",
thievesToolsName, thievesToolsPrice))
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.*")
}
// Thieves' tools sit on the supplies shelf but are not a ConsumableDef:
// the combat engine scans inventory against that table and would happily
// spend them mid-fight. They get their own branch and their own item type.
if isThievesToolsReply(reply) {
return p.buyThievesTools(ctx, interaction)
}
// 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))
}
// buyThievesTools sells one set off the supplies shelf. Mirrors the consumable
// purchase beside it — same session price factor, same 5% pot cut — and leaves
// the player in the supplies view so they can buy a second.
func (p *AdventurePlugin) buyThievesTools(ctx MessageContext, interaction *advPendingInteraction) error {
price := float64(thievesToolsPrice) * 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, thievesToolsName, balance))
}
p.euro.Debit(ctx.Sender, price, "shop_thieves_tools")
if potCut := int(math.Round(price * 0.05)); potCut > 0 {
communityPotAdd(potCut)
trackTaxPaid(ctx.Sender, potCut)
}
_ = addAdvInventoryItem(ctx.Sender, AdvItem{
Name: thievesToolsName,
Type: thievesToolsItemType,
Tier: 1,
Value: thievesToolsPrice / 2,
})
p.pending.Store(string(ctx.Sender), interaction)
return p.SendDM(ctx.Sender, fmt.Sprintf(
"Purchased **%s** for €%.0f. You carry %d.\n💰 Balance: €%.0f\n\nReply with another item name or `back` to return.",
thievesToolsName, price, countThievesTools(ctx.Sender), p.euro.GetBalance(ctx.Sender)))
}
// ── 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))
}