Files
gogobee/internal/plugin/adventure_housing.go
2026-05-09 14:25:22 -07:00

775 lines
27 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"strconv"
"strings"
"time"
)
// ── Housing Tier Definitions ───────────────────────────────────────────────
type HouseTierDef struct {
Tier int
Name string
Description string
BasePrice int
}
// HouseTier 0 = no house. Purchasable tiers are 1-4.
var houseTiers = []HouseTierDef{
{Tier: 1, Name: "Base House", Description: "Four walls and a roof. 'Livable' is a strong word.", BasePrice: 75000},
{Tier: 2, Name: "Livable", Description: "A place you can sit down in without questioning your life choices.", BasePrice: 150000},
{Tier: 3, Name: "Comfortable", Description: "Walls that don't leak. A door that locks. Luxuries of the modern age.", BasePrice: 300000},
{Tier: 4, Name: "Established", Description: "This is a house that says things about you. Good things. Mostly.", BasePrice: 600000},
}
// houseTierByTier returns the tier definition for a given tier number, or nil.
func houseTierByTier(tier int) *HouseTierDef {
for i := range houseTiers {
if houseTiers[i].Tier == tier {
return &houseTiers[i]
}
}
return nil
}
// houseNextTier returns the next purchasable tier definition, or nil if maxed.
func houseNextTier(currentTier int) *HouseTierDef {
for i := range houseTiers {
if houseTiers[i].Tier == currentTier+1 {
return &houseTiers[i]
}
}
return nil
}
// houseClosingCosts returns 5% closing + 6% realtor fees = 11% of base price.
func houseClosingCosts(basePrice int) int {
return int(float64(basePrice) * 0.11)
}
// houseTotalCost returns base price + closing costs + realtor fees.
func houseTotalCost(basePrice int) int {
return basePrice + houseClosingCosts(basePrice)
}
// houseWeeklyPayment calculates weekly payment based on balance and rate.
// Uses a simple amortization: interest + fixed principal portion.
// Rate is annual percentage (e.g., 6.5 means 6.5%).
func houseWeeklyPayment(balance int, annualRate float64) int {
if balance <= 0 || annualRate <= 0 {
return 0
}
weeklyRate := annualRate / 100.0 / 52.0
interest := float64(balance) * weeklyRate
// Principal portion: 0.5% of balance per week (~26% of balance per year)
// Ensures loans pay off in roughly 2-4 years of active play.
principal := float64(balance) * 0.005
payment := int(interest + principal)
if payment < 1 {
payment = 1
}
return payment
}
// houseMissedPenalty returns the penalty for a missed payment (5% of weekly payment).
func houseMissedPenalty(weeklyPayment int) int {
penalty := int(float64(weeklyPayment) * 0.05)
if penalty < 1 {
penalty = 1
}
return penalty
}
// ── Pending Interaction Types ──────────────────────────────────────────────
type advPendingHouseConfirm struct {
Tier int
TotalCost int
DownPayment int
}
type advPendingHouseDownPayment struct {
Tier int
TotalCost int
}
type advPendingHouseAutopay struct{}
type advPendingPetArrival struct{}
type advPendingPetType struct{}
type advPendingPetName struct {
PetType string
}
// ── Thom Krooke Greeting ───────────────────────────────────────────────────
var thomGreetingsPreAdoption = []string{
"Welcome in! Great timing. I've got something perfect for you. I say that to everyone and I mean it every time.",
"You don't look like someone ready to make a smart investment. I can work with that. Sit down. Don't touch anything.",
"Thom Krooke, Krooke Realty. No relation to that other guy. Mostly.",
}
var thomGreetingsPostAdoption = []string{
"You're back. Good. I've got things.",
"Oh. You. Sure. What do you need.",
"Come in. Wipe your feet. How's the animal.",
}
var thomGreetingsPostLevel10 = []string{
"Hello, {pet_name}'s caretaker. The usual?",
"Ah. {pet_name}'s caretaker. I've been expecting you. I have things {pet_name} needs.",
"Come in, {pet_name} and the caretaker! Wipe your feet! How are things? *You open your mouth to answer but quickly notice Thom is speaking to your pet.*",
}
var thomHouseSellingLines = []string{
"Charming property. Full of character. 'Character' means different things to different people and that's what makes real estate exciting.",
"Minor structural considerations that a positive attitude will absolutely address. Price reflects the opportunity.",
"Previous owner left in a hurry. Didn't say why. The chalk outlines in the living room are almost certainly decorative. Kids do that now apparently.",
}
const thomAnimalLine = "I heard an animal got in your house. Luckily for you, it's a sweet one that will give you the world in exchange for a tiny bit of kindness. ...that's what I heard anyway."
var thomMissedPaymentNoPet = "Missed payment. Penalty applied. Balance is now €%d. Sort it out."
var thomMissedPaymentPet = "Missed payment. Penalty applied. Balance is now €%d. %s doesn't need to know about this. Keep it together."
var thomFreezeNoPet = "Ten missed payments. I've stopped trying for now. The house is yours. The debt is yours. Come see me when you're ready to talk about it."
var thomFreezePet = "Ten missed payments. I've stopped trying for now. The house is yours. The debt is yours. Come see me when you're ready to talk about it. %s is fine. You should work on being fine."
var thomPaidOffNoPet = "Paid off. Noted. The next tier is available when you're ready. I'll be here."
var thomPaidOffPet = "Paid off. Good. Come in when you're ready for the next tier. I've been thinking about what %s would need in a better space. I have thoughts."
// thomGreeting returns an appropriate greeting based on pet state.
func thomGreeting(char *AdventureCharacter) string {
if char.HasPet() && char.PetLevel >= 10 {
line := thomGreetingsPostLevel10[rand.IntN(len(thomGreetingsPostLevel10))]
return strings.ReplaceAll(line, "{pet_name}", char.PetName)
}
if char.HasPet() {
return thomGreetingsPostAdoption[rand.IntN(len(thomGreetingsPostAdoption))]
}
return thomGreetingsPreAdoption[rand.IntN(len(thomGreetingsPreAdoption))]
}
// ── Thom Shop Display ──────────────────────────────────────────────────────
func thomShopView(char *AdventureCharacter, balance float64) string {
var sb strings.Builder
sb.WriteString("🏠 **Krooke Realty**\n")
sb.WriteString(fmt.Sprintf("💰 Balance: €%.0f\n\n", balance))
sb.WriteString(fmt.Sprintf("*%s*\n\n", thomGreeting(char)))
// Check for animal line (fires once after pet appears)
if char.HasPet() && !char.ThomAnimalLineFired {
sb.WriteString(fmt.Sprintf("*%s*\n\n", thomAnimalLine))
}
// Current housing status
if !char.HasHouse() {
sb.WriteString("You don't own a house yet.\n\n")
} else {
tierDef := houseTierByTier(char.HouseTier)
tierName := "Unknown"
if tierDef != nil {
tierName = tierDef.Name
}
sb.WriteString(fmt.Sprintf("🏡 Your house: **%s** (Tier %d)\n", tierName, char.HouseTier))
if char.HouseLoanBalance > 0 {
weekly := houseWeeklyPayment(char.HouseLoanBalance, char.HouseCurrentRate)
if char.HouseAutopay {
weekly = int(float64(weekly) * 0.98) // 2% autopay discount
}
sb.WriteString(fmt.Sprintf("💳 Loan balance: €%d | Weekly: €%d", char.HouseLoanBalance, weekly))
if char.HouseAutopay {
sb.WriteString(" (autopay)")
}
if char.HouseLoanFrozen {
sb.WriteString(" ❄️ FROZEN")
}
sb.WriteString(fmt.Sprintf(" | Rate: %.2f%%\n", char.HouseCurrentRate))
}
sb.WriteString("\n")
}
// Available upgrades
if char.HouseLoanBalance > 0 {
sb.WriteString("Pay off your current loan before upgrading.\n")
sb.WriteString(fmt.Sprintf("\nTo pay off early: `!thom payoff`\n"))
if !char.HouseAutopay {
sb.WriteString("To enable autopay (2% discount): `!thom autopay`\n")
}
} else {
// Can purchase next tier
nextDef := houseNextTier(char.HouseTier)
if nextDef == nil {
sb.WriteString("✨ Max tier reached. There is nothing left to sell you. This has never happened before.\n")
} else {
def := *nextDef
total := houseTotalCost(def.BasePrice)
selling := thomHouseSellingLines[rand.IntN(len(thomHouseSellingLines))]
sb.WriteString(fmt.Sprintf("*%s*\n\n", selling))
sb.WriteString(fmt.Sprintf("📋 **%s** (Tier %d)\n", def.Name, def.Tier))
sb.WriteString(fmt.Sprintf(" Base price: €%d\n", def.BasePrice))
sb.WriteString(fmt.Sprintf(" Closing costs (5%%): €%d\n", int(float64(def.BasePrice)*0.05)))
sb.WriteString(fmt.Sprintf(" Realtor fees (6%%): €%d\n", int(float64(def.BasePrice)*0.06)))
sb.WriteString(fmt.Sprintf(" **Total: €%d**\n\n", total))
sb.WriteString(fmt.Sprintf("`!thom buy` to purchase outright, or `!thom buy <amount>` for a down payment.\n"))
}
}
// Pet supply shop (unlocks 1 week after pet level 10)
if char.PetSupplyShopUnlocked && char.HasPet() {
sb.WriteString("\n---\n")
sb.WriteString(fmt.Sprintf("🐾 **Pet Supplies** — for %s\n\n", char.PetName))
sb.WriteString(petArmorShopView(char))
}
return sb.String()
}
// ── Thom Shop Command Handler ──────────────────────────────────────────────
func (p *AdventurePlugin) handleThomCmd(ctx MessageContext) error {
args := strings.TrimSpace(p.GetArgs(ctx.Body, "thom"))
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance := p.euro.GetBalance(ctx.Sender)
lower := strings.ToLower(args)
switch {
case args == "" || lower == "shop":
text := thomShopView(char, balance)
// Mark animal line as fired if applicable
if char.HasPet() && !char.ThomAnimalLineFired {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
char.ThomAnimalLineFired = true
_ = saveAdvCharacter(char)
userMu.Unlock()
}
return p.SendDM(ctx.Sender, text)
case lower == "payoff":
return p.handleThomPayoff(ctx, char)
case strings.HasPrefix(lower, "pay "):
return p.handleThomPay(ctx, char, strings.TrimSpace(lower[4:]))
case lower == "autopay":
return p.handleThomAutopay(ctx, char)
case lower == "buy" || strings.HasPrefix(lower, "buy "):
return p.handleThomBuy(ctx, char, balance, strings.TrimSpace(strings.TrimPrefix(lower, "buy")))
case strings.HasPrefix(lower, "petbuy "):
return p.handlePetArmorBuy(ctx, char, strings.TrimSpace(args[7:]))
default:
return p.SendDM(ctx.Sender, "Unknown command. Type `!thom` to visit Krooke Realty.")
}
}
// ── Buy House ──────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleThomBuy(ctx MessageContext, char *AdventureCharacter, balance float64, downPaymentStr string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
// Reload fresh
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
balance = p.euro.GetBalance(ctx.Sender)
if char.HouseLoanBalance > 0 {
return p.SendDM(ctx.Sender, "You still have an outstanding loan. Pay it off first with `!thom payoff`.")
}
nextDef := houseNextTier(char.HouseTier)
if nextDef == nil {
return p.SendDM(ctx.Sender, "You've reached max tier. There is nothing left to sell you.")
}
def := *nextDef
totalCost := houseTotalCost(def.BasePrice)
// Parse optional down payment
downPayment := 0
if downPaymentStr != "" {
dp := 0
for _, c := range downPaymentStr {
if c < '0' || c > '9' {
return p.SendDM(ctx.Sender, "Down payment must be a number. Example: `!thom buy 10000`")
}
dp = dp*10 + int(c-'0')
}
downPayment = dp
}
if downPayment > 0 && float64(downPayment) > balance {
return p.SendDM(ctx.Sender, fmt.Sprintf("You can't afford a €%d down payment. Balance: €%.0f.", downPayment, balance))
}
if downPayment >= totalCost {
// Full purchase, no loan
if !p.euro.Debit(ctx.Sender, float64(totalCost), "house_purchase_full") {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
char.HouseTier = def.Tier
if err := saveAdvCharacter(char); err != nil {
p.euro.Credit(ctx.Sender, float64(totalCost), "house_purchase_refund")
return p.SendDM(ctx.Sender, "Failed to save. You've been refunded.")
}
// L4e dual-write: mirror house state into player_meta.
_ = upsertPlayerMetaHouseState(char.UserID, houseStateFromAdvChar(char))
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 **%s** purchased outright for €%d.\n\nNo loan. No weekly payments. Thom is visibly disappointed.", def.Name, totalCost))
}
// Loan purchase
loanAmount := totalCost - downPayment
// Get current mortgage rate
rate := getCurrentMortgageRate()
if rate <= 0 {
rate = 6.5 // fallback default
}
// Debit down payment if any
if downPayment > 0 {
if !p.euro.Debit(ctx.Sender, float64(downPayment), "house_down_payment") {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
}
char.HouseTier = def.Tier
char.HouseLoanBalance = loanAmount
char.HouseCurrentRate = rate
char.HouseMissedPayments = 0
char.HouseLoanFrozen = false
if err := saveAdvCharacter(char); err != nil {
if downPayment > 0 {
p.euro.Credit(ctx.Sender, float64(downPayment), "house_dp_refund")
}
return p.SendDM(ctx.Sender, "Failed to save. You've been refunded.")
}
// L4e dual-write: mirror house state into player_meta.
_ = upsertPlayerMetaHouseState(char.UserID, houseStateFromAdvChar(char))
weekly := houseWeeklyPayment(loanAmount, rate)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("🏠 **%s** purchased!\n\n", def.Name))
if downPayment > 0 {
sb.WriteString(fmt.Sprintf("Down payment: €%d\n", downPayment))
}
sb.WriteString(fmt.Sprintf("Loan balance: €%d\n", loanAmount))
sb.WriteString(fmt.Sprintf("Weekly payment: ~€%d (%.2f%% ARM rate)\n", weekly, rate))
sb.WriteString("\nPayments are collected weekly. Enable autopay for a 2% discount: `!thom autopay`")
return p.SendDM(ctx.Sender, sb.String())
}
// ── Payoff ─────────────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleThomPayoff(ctx MessageContext, char *AdventureCharacter) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
// Reload fresh
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if char.HouseLoanBalance <= 0 {
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
}
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(char.HouseLoanBalance) {
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d to pay off the loan. Balance: €%.0f.", char.HouseLoanBalance, balance))
}
payoffAmount := float64(char.HouseLoanBalance)
if !p.euro.Debit(ctx.Sender, payoffAmount, "house_payoff") {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
char.HouseLoanBalance = 0
char.HouseLoanFrozen = false
char.HouseMissedPayments = 0
if err := saveAdvCharacter(char); err != nil {
p.euro.Credit(ctx.Sender, payoffAmount, "house_payoff_refund")
return p.SendDM(ctx.Sender, "Failed to save. Your money has been refunded.")
}
// L4e dual-write: mirror house state into player_meta.
_ = upsertPlayerMetaHouseState(char.UserID, houseStateFromAdvChar(char))
if char.HasPet() {
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, char.PetName)))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
}
// ── Extra Payment ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleThomPay(ctx MessageContext, char *AdventureCharacter, amountStr string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if char.HouseLoanBalance <= 0 {
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
}
amount, err := strconv.Atoi(amountStr)
if err != nil || amount <= 0 {
return p.SendDM(ctx.Sender, "Usage: `!thom pay <amount>` — e.g. `!thom pay 5000`")
}
// Cap at remaining balance
if amount > char.HouseLoanBalance {
amount = char.HouseLoanBalance
}
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(amount) {
return p.SendDM(ctx.Sender, fmt.Sprintf("You need €%d but only have €%.0f.", amount, balance))
}
if !p.euro.Debit(ctx.Sender, float64(amount), "house_extra_payment") {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
char.HouseLoanBalance -= amount
if char.HouseLoanBalance <= 0 {
char.HouseLoanBalance = 0
char.HouseLoanFrozen = false
char.HouseMissedPayments = 0
}
if err := saveAdvCharacter(char); err != nil {
p.euro.Credit(ctx.Sender, float64(amount), "house_extra_payment_refund")
return p.SendDM(ctx.Sender, "Failed to save. Your money has been refunded.")
}
// L4e dual-write: mirror house state into player_meta.
_ = upsertPlayerMetaHouseState(char.UserID, houseStateFromAdvChar(char))
if char.HouseLoanBalance == 0 {
if char.HasPet() {
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. %s", amount, fmt.Sprintf(thomPaidOffPet, char.PetName)))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. %s", amount, thomPaidOffNoPet))
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🏠 Payment of €%d received. Remaining balance: €%d.", amount, char.HouseLoanBalance))
}
// ── Autopay Toggle ─────────────────────────────────────────────────────────
func (p *AdventurePlugin) handleThomAutopay(ctx MessageContext, char *AdventureCharacter) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if char.HouseLoanBalance <= 0 {
return p.SendDM(ctx.Sender, "You don't have an outstanding loan.")
}
char.HouseAutopay = !char.HouseAutopay
if err := saveAdvCharacter(char); err != nil {
return p.SendDM(ctx.Sender, "Failed to save.")
}
// L4e dual-write: mirror house state into player_meta.
_ = upsertPlayerMetaHouseState(char.UserID, houseStateFromAdvChar(char))
if char.HouseAutopay {
return p.SendDM(ctx.Sender, "✅ Autopay enabled. 2% discount on weekly payments. Thom calls this a favour. It is not.")
}
return p.SendDM(ctx.Sender, "Autopay disabled.")
}
// ── Weekly Mortgage Payment Processing ─────────────────────────────────────
func (p *AdventurePlugin) processMortgagePayments() {
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("mortgage: failed to load characters", "err", err)
return
}
for _, char := range chars {
if char.HouseLoanBalance <= 0 {
continue
}
if char.HouseLoanFrozen {
continue
}
userMu := p.advUserLock(char.UserID)
userMu.Lock()
// Reload fresh inside lock
freshChar, err := loadAdvCharacter(char.UserID)
if err != nil || freshChar.HouseLoanBalance <= 0 || freshChar.HouseLoanFrozen {
userMu.Unlock()
continue
}
weekly := houseWeeklyPayment(freshChar.HouseLoanBalance, freshChar.HouseCurrentRate)
if freshChar.HouseAutopay {
weekly = int(float64(weekly) * 0.98)
}
if p.euro.Debit(freshChar.UserID, float64(weekly), "mortgage_weekly") {
freshChar.HouseLoanBalance -= weekly
if freshChar.HouseLoanBalance <= 0 {
freshChar.HouseLoanBalance = 0
freshChar.HouseMissedPayments = 0
_ = saveAdvCharacter(freshChar)
_ = upsertPlayerMetaHouseState(freshChar.UserID, houseStateFromAdvChar(freshChar))
userMu.Unlock()
// Paid off!
if freshChar.HasPet() {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomPaidOffPet, freshChar.PetName)))
} else {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomPaidOffNoPet))
}
continue
}
freshChar.HouseMissedPayments = 0
_ = saveAdvCharacter(freshChar)
_ = upsertPlayerMetaHouseState(freshChar.UserID, houseStateFromAdvChar(freshChar))
userMu.Unlock()
} else {
// Missed payment
penalty := houseMissedPenalty(weekly)
freshChar.HouseLoanBalance += penalty
freshChar.HouseMissedPayments++
if freshChar.HouseMissedPayments >= 10 {
freshChar.HouseLoanFrozen = true
_ = saveAdvCharacter(freshChar)
_ = upsertPlayerMetaHouseState(freshChar.UserID, houseStateFromAdvChar(freshChar))
userMu.Unlock()
if freshChar.HasPet() {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomFreezePet, freshChar.PetName)))
} else {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", thomFreezeNoPet))
}
} else {
_ = saveAdvCharacter(freshChar)
_ = upsertPlayerMetaHouseState(freshChar.UserID, houseStateFromAdvChar(freshChar))
userMu.Unlock()
if freshChar.HasPet() {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomMissedPaymentPet, freshChar.HouseLoanBalance, freshChar.PetName)))
} else {
_ = p.SendDM(freshChar.UserID, fmt.Sprintf("🏠 %s", fmt.Sprintf(thomMissedPaymentNoPet, freshChar.HouseLoanBalance)))
}
}
}
}
}
// ── Mortgage Rate Change DMs ───────────────────────────────────────────────
func (p *AdventurePlugin) sendMortgageRateChangeDMs(oldRate, newRate float64) {
if oldRate == newRate {
return
}
chars, err := loadAllAdvCharacters()
if err != nil {
slog.Error("mortgage: failed to load characters for rate DMs", "err", err)
return
}
increased := newRate > oldRate
for _, char := range chars {
if char.HouseLoanBalance <= 0 {
continue
}
// Update rate
userMu := p.advUserLock(char.UserID)
userMu.Lock()
freshChar, err := loadAdvCharacter(char.UserID)
if err != nil || freshChar.HouseLoanBalance <= 0 {
userMu.Unlock()
continue
}
freshChar.HouseCurrentRate = newRate
_ = saveAdvCharacter(freshChar)
_ = upsertPlayerMetaHouseState(freshChar.UserID, houseStateFromAdvChar(freshChar))
userMu.Unlock()
newWeekly := houseWeeklyPayment(freshChar.HouseLoanBalance, newRate)
amount := fmt.Sprintf("%d", newWeekly)
var pool []string
if freshChar.HasPet() {
if increased {
pool = ThomRateIncreasePet
} else {
pool = ThomRateDecreasePet
}
} else {
if increased {
pool = ThomRateIncrease
} else {
pool = ThomRateDecrease
}
}
line := pool[rand.IntN(len(pool))]
line = strings.ReplaceAll(line, "{amount}", amount)
if freshChar.HasPet() {
line = strings.ReplaceAll(line, "{pet_name}", freshChar.PetName)
}
_ = p.SendDM(char.UserID, fmt.Sprintf("🏠 %s", line))
// Jitter
time.Sleep(time.Duration(500+rand.IntN(1500)) * time.Millisecond)
}
}
// ── Pet Armor Shop ─────────────────────────────────────────────────────────
type PetArmorDef struct {
Tier int
Name string
DeflectBonus float64
Price int
}
var petDogArmor = []PetArmorDef{
{Tier: 1, Name: "Leather Dog Barding", DeflectBonus: 0.015, Price: 225},
{Tier: 2, Name: "Chain Dog Barding", DeflectBonus: 0.03, Price: 675},
{Tier: 3, Name: "Plate Dog Barding", DeflectBonus: 0.05, Price: 2250},
{Tier: 4, Name: "Enchanted Dog Barding", DeflectBonus: 0.075, Price: 11250},
{Tier: 5, Name: "Dragonscale Dog Barding", DeflectBonus: 0.105, Price: 45000},
}
var petCatArmor = []PetArmorDef{
{Tier: 1, Name: "Leather Cat Armor", DeflectBonus: 0.015, Price: 225},
{Tier: 2, Name: "Chain Cat Armor", DeflectBonus: 0.03, Price: 675},
{Tier: 3, Name: "Plate Cat Armor", DeflectBonus: 0.05, Price: 2250},
{Tier: 4, Name: "Enchanted Cat Armor", DeflectBonus: 0.075, Price: 11250},
{Tier: 5, Name: "Dragonscale Cat Armor", DeflectBonus: 0.105, Price: 45000},
}
func petArmorDefs(petType string) []PetArmorDef {
if petType == "dog" {
return petDogArmor
}
return petCatArmor
}
func petArmorShopView(char *AdventureCharacter) string {
var sb strings.Builder
defs := petArmorDefs(char.PetType)
if char.PetArmorTier >= 5 {
sb.WriteString("✨ Max pet armor tier reached. There is nothing left to buy.\n")
return sb.String()
}
for _, def := range defs {
if def.Tier <= char.PetArmorTier {
if def.Tier == char.PetArmorTier {
sb.WriteString(fmt.Sprintf("🟢 %s (T%d) — Currently equipped\n", def.Name, def.Tier))
}
continue
}
indicator := "⬆️"
sb.WriteString(fmt.Sprintf("%s %s (T%d) — €%d — +%.1f%% deflect\n", indicator, def.Name, def.Tier, def.Price, def.DeflectBonus*100))
}
sb.WriteString(fmt.Sprintf("\nTo buy: `!thom petbuy <tier>` (e.g., `!thom petbuy %d`)", char.PetArmorTier+1))
return sb.String()
}
func (p *AdventurePlugin) handlePetArmorBuy(ctx MessageContext, char *AdventureCharacter, tierStr string) error {
userMu := p.advUserLock(ctx.Sender)
userMu.Lock()
defer userMu.Unlock()
char, _, err := p.ensureCharacter(ctx.Sender)
if err != nil {
return p.SendDM(ctx.Sender, "Failed to load your character.")
}
if !char.HasPet() {
return p.SendDM(ctx.Sender, "You don't have a pet.")
}
if !char.PetSupplyShopUnlocked {
return p.SendDM(ctx.Sender, "Pet supplies aren't available yet.")
}
tier := 0
for _, c := range tierStr {
if c < '0' || c > '9' {
return p.SendDM(ctx.Sender, "Usage: `!thom petbuy <tier>`")
}
tier = tier*10 + int(c-'0')
}
if tier != char.PetArmorTier+1 {
if tier <= char.PetArmorTier {
return p.SendDM(ctx.Sender, "That's not an upgrade.")
}
return p.SendDM(ctx.Sender, fmt.Sprintf("You need to buy tier %d first.", char.PetArmorTier+1))
}
defs := petArmorDefs(char.PetType)
var def *PetArmorDef
for i := range defs {
if defs[i].Tier == tier {
def = &defs[i]
break
}
}
if def == nil {
return p.SendDM(ctx.Sender, "Invalid tier.")
}
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(def.Price) {
return p.SendDM(ctx.Sender, fmt.Sprintf("%s deserves better, but you can't afford €%d. Balance: €%.0f.", char.PetName, def.Price, balance))
}
if !p.euro.Debit(ctx.Sender, float64(def.Price), "pet_armor_"+tierStr) {
return p.SendDM(ctx.Sender, "Transaction failed.")
}
char.PetArmorTier = tier
if err := saveAdvCharacter(char); err != nil {
p.euro.Credit(ctx.Sender, float64(def.Price), "pet_armor_refund")
return p.SendDM(ctx.Sender, "Failed to save. Refunded.")
}
return p.SendDM(ctx.Sender, fmt.Sprintf("🐾 **%s** equipped on %s.\n\n+%.1f%% deflect bonus. %s looks... objectively better than you.", def.Name, char.PetName, def.DeflectBonus*100, char.PetName))
}