mirror of
https://github.com/prosolis/gogobee.git
synced 2026-09-14 10:51:09 +00:00
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.
1403 lines
49 KiB
Go
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))
|
|
}
|