package plugin import ( "fmt" "log/slog" "math" "strings" "time" "maunium.net/go/mautrix/id" ) // ── Pricing ───────────────────────────────────────────────────────────────── var blacksmithBaseRates = [6]int{1, 2, 5, 12, 30, 80} func blacksmithRepairCost(eq *AdvEquipment) int { if eq == nil || eq.Condition >= 100 { return 0 } // Masterwork and Arena items use the next tier's base rate. tier := eq.Tier if eq.Masterwork || eq.ArenaTier > 0 { tier++ if tier > 5 { tier = 5 } } if tier < 0 { tier = 0 } if tier > 5 { tier = 5 } baseRate := float64(blacksmithBaseRates[tier]) damage := float64(100 - eq.Condition) condMult := 1.0 + damage/200.0 costPerPoint := baseRate * condMult return int(math.Ceil(costPerPoint * damage)) } // ── Pending Interaction Types ─────────────────────────────────────────────── type advPendingBlacksmithSlot struct{} type advPendingBlacksmithConfirm struct { Slots []EquipmentSlot Costs map[EquipmentSlot]int Total int } // ── Command Handlers ──────────────────────────────────────────────────────── func (p *AdventurePlugin) handleBlacksmithCmd(ctx MessageContext) error { _, equip, err := p.ensureCharacter(ctx.Sender) if err != nil { return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.") } text := renderBlacksmithShop(ctx.Sender, equip) // Store pending interaction for slot selection. p.pending.Store(string(ctx.Sender), &advPendingInteraction{ Type: "blacksmith_slot", Data: &advPendingBlacksmithSlot{}, ExpiresAt: time.Now().Add(advDMResponseWindow), }) p.advMarkMenuSent(ctx.Sender) return p.SendDM(ctx.Sender, text) } func (p *AdventurePlugin) handleRepairAllCmd(ctx MessageContext) error { userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() _, equip, err := p.ensureCharacter(ctx.Sender) if err != nil { return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.") } return p.buildRepairAllConfirm(ctx.Sender, equip) } func (p *AdventurePlugin) handleRepairSlotCmd(ctx MessageContext, slotName string) error { userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() _, equip, err := p.ensureCharacter(ctx.Sender) if err != nil { return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your character.") } return p.buildRepairSlotConfirm(ctx.Sender, equip, slotName) } // ── Slot Selection Resolution ─────────────────────────────────────────────── func (p *AdventurePlugin) resolveBlacksmithSlotChoice(ctx MessageContext, interaction *advPendingInteraction) error { reply := strings.ToLower(strings.TrimSpace(ctx.Body)) if reply != "all" && parseSlotName(reply) == "" { // Re-store pending and reprompt. p.pending.Store(string(ctx.Sender), interaction) return p.SendDM(ctx.Sender, "Reply with a slot name (weapon, armor, helmet, boots, tool) or \"all\".") } // Lock and reload fresh equipment (the cached data may be stale). userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() _, equip, err := p.ensureCharacter(ctx.Sender) if err != nil { return p.SendDM(ctx.Sender, "Failed to load your character.") } if reply == "all" { return p.buildRepairAllConfirm(ctx.Sender, equip) } return p.buildRepairSlotConfirm(ctx.Sender, equip, reply) } // ── Confirm Resolution ────────────────────────────────────────────────────── func (p *AdventurePlugin) resolveBlacksmithConfirm(ctx MessageContext, interaction *advPendingInteraction) error { userMu := p.advUserLock(ctx.Sender) userMu.Lock() defer userMu.Unlock() data := interaction.Data.(*advPendingBlacksmithConfirm) reply := strings.ToLower(strings.TrimSpace(ctx.Body)) if reply == "yes" || reply == "y" || reply == "confirm" { return p.executeRepair(ctx.Sender, data) } return p.SendDM(ctx.Sender, "Repair cancelled. The blacksmith watches you leave. They'll be here when you're ready.") } // ── Builders ──────────────────────────────────────────────────────────────── func (p *AdventurePlugin) buildRepairAllConfirm(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment) error { var damaged []EquipmentSlot costs := make(map[EquipmentSlot]int) total := 0 for _, slot := range allSlots { eq := equip[slot] if eq == nil { continue } cost := blacksmithRepairCost(eq) if cost > 0 { damaged = append(damaged, slot) costs[slot] = cost total += cost } } if len(damaged) == 0 { flavor, _ := advPickFlavor(blacksmithFullCondition, userID, "bs_full") return p.SendDM(userID, "⚒️ All your equipment is at full condition. "+flavor) } balance := p.euro.GetBalance(userID) if balance < float64(total) { // Can't afford everything — suggest cheapest slot. cheapestSlot := damaged[0] cheapestCost := costs[damaged[0]] for _, s := range damaged[1:] { if costs[s] < cheapestCost { cheapestSlot = s cheapestCost = costs[s] } } return p.SendDM(userID, fmt.Sprintf("⚒️ Repairing everything costs €%d. You have €%.0f.\n\n"+ "The blacksmith suggests starting with your %s (€%d). They say this with some reluctance — they wanted to do everything.\n\n"+ "Use `!adventure repair %s` to repair just that slot.", total, balance, slotTitle(cheapestSlot), cheapestCost, string(cheapestSlot))) } // Build breakdown. var sb strings.Builder sb.WriteString("⚒️ **Repair All — Confirmation**\n\n") for _, slot := range damaged { eq := equip[slot] sb.WriteString(fmt.Sprintf(" %s %s: [%d/100] → [100/100] €%d\n", slotEmoji(slot), slotTitle(slot), eq.Condition, costs[slot])) } sb.WriteString(fmt.Sprintf("\n**Total: €%d**\n\nReply **yes** to confirm or **no** to cancel.", total)) p.pending.Store(string(userID), &advPendingInteraction{ Type: "blacksmith_confirm", Data: &advPendingBlacksmithConfirm{Slots: damaged, Costs: costs, Total: total}, ExpiresAt: time.Now().Add(advDMResponseWindow), }) return p.SendDM(userID, sb.String()) } func (p *AdventurePlugin) buildRepairSlotConfirm(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment, slotName string) error { slot := parseSlotName(slotName) if slot == "" { return p.SendDM(userID, "Unknown slot. Valid slots: weapon, armor, helmet, boots, tool.") } eq := equip[slot] if eq == nil { return p.SendDM(userID, fmt.Sprintf("You don't have any %s equipped.", slotTitle(slot))) } if eq.Condition >= 100 { flavor, _ := advPickFlavor(blacksmithFullCondition, userID, "bs_full") return p.SendDM(userID, fmt.Sprintf("⚒️ %s %s — %s", slotEmoji(slot), eq.Name, flavor)) } cost := blacksmithRepairCost(eq) balance := p.euro.GetBalance(userID) if balance < float64(cost) { return p.SendDM(userID, fmt.Sprintf("⚒️ Repairing your %s costs €%d. You have €%.0f. The blacksmith waits.", slotTitle(slot), cost, balance)) } // Build inspection message. var sb strings.Builder sb.WriteString(fmt.Sprintf("⚒️ %s %s — **%s** [%d/100]\n\n", slotEmoji(slot), slotTitle(slot), eq.Name, eq.Condition)) // Special flavor for masterwork, arena, or broken. if eq.Condition == 0 { f, _ := advPickFlavor(blacksmithBrokenCondition, userID, "bs_broken") sb.WriteString(fmt.Sprintf("*%s*\n\n", f)) } else if eq.Masterwork { f, _ := advPickFlavor(blacksmithMasterwork, userID, "bs_master") sb.WriteString(fmt.Sprintf("*%s*\n\n", f)) } else if eq.ArenaTier > 0 { f, _ := advPickFlavor(blacksmithArena, userID, "bs_arena") sb.WriteString(fmt.Sprintf("*%s*\n\n", f)) } else { f, _ := advPickFlavor(blacksmithInspection, userID, "bs_inspect") sb.WriteString(fmt.Sprintf("*%s*\n\n", f)) } sb.WriteString(fmt.Sprintf("Repair to 100: **€%d**\n\nReply **yes** to confirm or **no** to cancel.", cost)) costs := map[EquipmentSlot]int{slot: cost} p.pending.Store(string(userID), &advPendingInteraction{ Type: "blacksmith_confirm", Data: &advPendingBlacksmithConfirm{Slots: []EquipmentSlot{slot}, Costs: costs, Total: cost}, ExpiresAt: time.Now().Add(advDMResponseWindow), }) return p.SendDM(userID, sb.String()) } // ── Repair Execution ──────────────────────────────────────────────────────── func (p *AdventurePlugin) executeRepair(userID id.UserID, data *advPendingBlacksmithConfirm) error { // Reload equipment fresh and recompute costs to avoid stale-price TOCTOU. equip, err := loadAdvEquipment(userID) if err != nil { return p.SendDM(userID, "Something went wrong loading your equipment.") } // Recompute actual costs from current condition. actualTotal := 0 actualCosts := make(map[EquipmentSlot]int) for _, slot := range data.Slots { eq := equip[slot] if eq == nil { continue } cost := blacksmithRepairCost(eq) if cost > 0 { actualCosts[slot] = cost actualTotal += cost } } if actualTotal == 0 { return p.SendDM(userID, "⚒️ Your equipment is already at full condition. No repair needed.") } // Cap at the quoted price so the user never pays more than they agreed to. if actualTotal > data.Total { actualTotal = data.Total } if !p.euro.Debit(userID, float64(actualTotal), "blacksmith_repair") { return p.SendDM(userID, "Payment failed. You don't have enough gold.") } var repaired []string repairedCost := 0 for _, slot := range data.Slots { eq := equip[slot] if eq == nil || eq.Condition >= 100 { continue } eq.Condition = 100 if err := saveAdvEquipment(userID, eq); err != nil { slog.Error("blacksmith: failed to save repaired equipment", "user", userID, "slot", slot, "err", err) continue } repaired = append(repaired, fmt.Sprintf("%s %s", slotEmoji(slot), eq.Name)) repairedCost += actualCosts[slot] } if len(repaired) == 0 { p.euro.Credit(userID, float64(actualTotal), "blacksmith_refund") return p.SendDM(userID, "Nothing was repaired. Gold refunded.") } // Partial refund if some slots failed to save. if repairedCost < actualTotal { refund := actualTotal - repairedCost p.euro.Credit(userID, float64(refund), "blacksmith_partial_refund") } var sb strings.Builder sb.WriteString(fmt.Sprintf("⚒️ **Repair Complete** — €%d\n\n", repairedCost)) for _, item := range repaired { sb.WriteString(fmt.Sprintf(" %s → [100/100]\n", item)) } payFlavor, _ := advPickFlavor(blacksmithPayment, userID, "bs_pay") doneFlavor, _ := advPickFlavor(blacksmithCompletion, userID, "bs_done") sb.WriteString(fmt.Sprintf("\n*%s*\n\n", payFlavor)) sb.WriteString(fmt.Sprintf("*%s*", doneFlavor)) return p.SendDM(userID, sb.String()) } // ── Shop Display ──────────────────────────────────────────────────────────── func renderBlacksmithShop(userID id.UserID, equip map[EquipmentSlot]*AdvEquipment) string { var sb strings.Builder greet, _ := advPickFlavor(blacksmithGreetings, userID, "bs_greet") sb.WriteString("⚒️ **The Blacksmith**\n\n") sb.WriteString(fmt.Sprintf("*%s*\n\n", greet)) sb.WriteString("Your equipment:\n") hasDamaged := false for _, slot := range allSlots { eq := equip[slot] if eq == nil { continue } marker := "" if eq.Masterwork { marker = " \u2b50" } else if eq.ArenaTier > 0 { marker = " \u2694\ufe0f" } if eq.Condition >= 100 { sb.WriteString(fmt.Sprintf(" %s %s:%s T%d [100/100] \u2713 No repair needed\n", slotEmoji(slot), padRight(eq.Name+marker, 22), "", eq.Tier)) } else { hasDamaged = true cost := blacksmithRepairCost(eq) sb.WriteString(fmt.Sprintf(" %s %s:%s T%d [%d/100] Repair to 100: \u20ac%d\n", slotEmoji(slot), padRight(eq.Name+marker, 22), "", eq.Tier, eq.Condition, cost)) } } if !hasDamaged { fullFlavor, _ := advPickFlavor(blacksmithFullCondition, userID, "bs_full") sb.WriteString(fmt.Sprintf("\n*%s*", fullFlavor)) } else { sb.WriteString("\nReply with the slot name to repair (weapon / armor / helmet / boots / tool) or \"all\" to repair everything.") } return sb.String() } // ── Helpers ───────────────────────────────────────────────────────────────── func parseSlotName(s string) EquipmentSlot { switch strings.ToLower(strings.TrimSpace(s)) { case "weapon", "wep", "sword": return SlotWeapon case "armor", "armour", "chest": return SlotArmor case "helmet", "helm", "hat", "head": return SlotHelmet case "boots", "boot", "shoes", "feet": return SlotBoots case "tool", "pick", "pickaxe": return SlotTool } return "" } func padRight(s string, width int) string { if len(s) >= width { return s } return s + strings.Repeat(" ", width-len(s)) }