diff --git a/internal/db/db.go b/internal/db/db.go index 1ecd47c..225f429 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1155,6 +1155,34 @@ CREATE TABLE IF NOT EXISTS user_archetypes ( ); CREATE INDEX IF NOT EXISTS idx_user_archetypes_user ON user_archetypes(user_id, signal_score DESC); +-- Lottery tickets +CREATE TABLE IF NOT EXISTS lottery_tickets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + week_start DATE NOT NULL, + numbers TEXT NOT NULL, + match_count INTEGER DEFAULT NULL, + prize INTEGER DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_lottery_tickets_week ON lottery_tickets(week_start, user_id); + +-- Lottery draw history +CREATE TABLE IF NOT EXISTS lottery_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + draw_date DATE NOT NULL, + winning_numbers TEXT NOT NULL, + jackpot_winners INTEGER DEFAULT 0, + jackpot_amount INTEGER DEFAULT 0, + match4_winners INTEGER DEFAULT 0, + match3_winners INTEGER DEFAULT 0, + match2_winners INTEGER DEFAULT 0, + match1_winners INTEGER DEFAULT 0, + pot_total INTEGER NOT NULL, + rolled_over INTEGER DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + ` // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist. diff --git a/internal/plugin/adventure.go b/internal/plugin/adventure.go index 007eaa8..1158575 100644 --- a/internal/plugin/adventure.go +++ b/internal/plugin/adventure.go @@ -204,6 +204,12 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error { return p.handleRivalsCmd(ctx) case lower == "babysit" || strings.HasPrefix(lower, "babysit "): return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "babysit"))) + case lower == "blacksmith" || lower == "repair": + return p.handleBlacksmithCmd(ctx) + case lower == "repair all": + return p.handleRepairAllCmd(ctx) + case strings.HasPrefix(lower, "repair "): + return p.handleRepairSlotCmd(ctx, strings.TrimSpace(args[7:])) } return p.SendDM(ctx.Sender, "Unknown command. Type `!adventure help` to see available commands.") @@ -223,6 +229,9 @@ const advHelpText = `**Adventure Commands** ` + "`!adventure respond`" + ` — Respond to a mid-day event ` + "`!adventure rivals`" + ` — View rival duel records ` + "`!adventure babysit`" + ` — Adventurer Babysitting Service +` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs) +` + "`!adventure repair all`" + ` — Repair all damaged equipment +` + "`!adventure repair `" + ` — Repair a specific slot ` + "`!adventure help`" + ` — This message **Arena:** @@ -496,6 +505,10 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact return p.handleMasterworkEquipConfirm(ctx, interaction) case "rival_rps": return p.resolveRivalRPSRound(ctx, interaction) + case "blacksmith_slot": + return p.resolveBlacksmithSlotChoice(ctx, interaction) + case "blacksmith_confirm": + return p.resolveBlacksmithConfirm(ctx, interaction) } return nil } @@ -585,11 +598,16 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string) lower := strings.ToLower(body) - // Parse "6" or "rest" - if lower == "6" || lower == "rest" { + // Parse "7" or "rest" + if lower == "7" || lower == "rest" { return p.resolveRest(ctx, char) } + // Parse "6" or "blacksmith" + if lower == "6" || lower == "blacksmith" { + return p.handleBlacksmithCmd(ctx) + } + // Parse "5" or "shop" if lower == "5" || lower == "shop" { equip, _ := loadAdvEquipment(ctx.Sender) diff --git a/internal/plugin/adventure_blacksmith.go b/internal/plugin/adventure_blacksmith.go new file mode 100644 index 0000000..fb1b2e3 --- /dev/null +++ b/internal/plugin/adventure_blacksmith.go @@ -0,0 +1,395 @@ +package plugin + +import ( + "fmt" + "log/slog" + "math" + "strings" + "time" + + "maunium.net/go/mautrix/id" +) + +// ── Pricing ───────────────────────────────────────────────────────────────── + +var blacksmithBaseRates = [6]int{1, 3, 8, 20, 55, 150} + +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/100.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(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 { + return p.SendDM(userID, "⚒️ All your equipment is at full condition. "+pickBlacksmithFlavor(blacksmithFullCondition)) + } + + 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 { + return p.SendDM(userID, fmt.Sprintf("⚒️ %s %s — %s", slotEmoji(slot), eq.Name, pickBlacksmithFlavor(blacksmithFullCondition))) + } + + 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 { + sb.WriteString(fmt.Sprintf("*%s*\n\n", pickBlacksmithFlavor(blacksmithBrokenCondition))) + } else if eq.Masterwork { + sb.WriteString(fmt.Sprintf("*%s*\n\n", pickBlacksmithFlavor(blacksmithMasterwork))) + } else if eq.ArenaTier > 0 { + sb.WriteString(fmt.Sprintf("*%s*\n\n", pickBlacksmithFlavor(blacksmithArena))) + } else { + sb.WriteString(fmt.Sprintf("*%s*\n\n", pickBlacksmithFlavor(blacksmithInspection))) + } + + 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)) + } + sb.WriteString(fmt.Sprintf("\n*%s*\n\n", pickBlacksmithFlavor(blacksmithPayment))) + sb.WriteString(fmt.Sprintf("*%s*", pickBlacksmithFlavor(blacksmithCompletion))) + + return p.SendDM(userID, sb.String()) +} + +// ── Shop Display ──────────────────────────────────────────────────────────── + +func renderBlacksmithShop(equip map[EquipmentSlot]*AdvEquipment) string { + var sb strings.Builder + + sb.WriteString("⚒️ **The Blacksmith**\n\n") + sb.WriteString(fmt.Sprintf("*%s*\n\n", pickBlacksmithFlavor(blacksmithGreetings))) + 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 { + sb.WriteString(fmt.Sprintf("\n*%s*", pickBlacksmithFlavor(blacksmithFullCondition))) + } 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)) +} diff --git a/internal/plugin/adventure_flavor_blacksmith.go b/internal/plugin/adventure_flavor_blacksmith.go new file mode 100644 index 0000000..c90d095 --- /dev/null +++ b/internal/plugin/adventure_flavor_blacksmith.go @@ -0,0 +1,74 @@ +package plugin + +import "math/rand/v2" + +// ── Blacksmith Flavor Text Pools ──────────────────────────────────────────── + +var blacksmithGreetings = []string{ + "*wipes hands on apron and looks you over slowly* You've seen some action. I can tell. Come inside. Let me experience what you've been working with.", + "*sets down hammer* Don't be shy. I've seen worse. Bring it here. I'll get it back to its full size in no time.", + "I don't judge the condition things arrive in but I *always* know how to make them feel... oh so much better. Weapons and armor too.", + "*gestures broadly at the forge* Everything in here gets my full and undivided attention. You have my word on that. Unless a ball gag is involved. \u2764\ufe0f", + "You look like someone who's been putting their equipment through a lot. That's what I'm here for. Show me everything.", +} + +var blacksmithInspection = []string{ + "Oh. Oh that's seen some use. *runs thumb along the edge* Don't worry. I know exactly what this needs.", + "This one's been neglected. I can tell. People don't appreciate what they have until it's been beaten down until it can't take any more. Then it's my job to *slowly* build you... it... back up completely from bottom to the top.", + "*whistles low* That's a lot of damage. I'm not complaining. I'm just saying I'm going to need some time with this one.", + "Good bones. Good bones. A little beaten up but my skilled fingers have brought worse back from the brink to highs rarely seen nor experienced on Earth! ...Oh I can fix your item too.", + "*holds it up to the light* I've had my hands on a lot of these. This one needs work. The good kind of work. *looks you deeply in the eyes* The only kind of work I know how to do.", +} + +var blacksmithPayment = []string{ + "*pockets the gold without looking at it* Money is fine. The work is the reward.", + "Generous. I'd have done it for less. Don't tell anyone.", + "*nods slowly* Fair. Now let me get to it. I work better when I'm not being watched. Come back in a bit.", + "You're paying for the expertise. Anyone can hit metal. Not everyone knows where to hit it. Or how to hit it to get it quivering after each strike until it's begging for more! *there's an awkward silence* Anyway. Time to get to work.", + "The gold is appreciated. Now. Unless you have need for my services from the *ahem* hidden menu, come back later.", +} + +var blacksmithCompletion = []string{ + "There. *slides it across the counter* Good as new. Better, actually. I know what I'm doing.", + "*breathes heavily* Done. That one took something out of me. Worth it though. Always worth it.", + "You feel that? That's what it's supposed to feel like. Full. Firm. Ready for anything at any time. Just like me.", + "I worked it until it couldn't take any more. Then I worked it a little more. I'm more than happy to give you a demonstration sometime.", + "*wipes forehead* The difficult ones are always the most satisfying. Take it. Come back when it needs attention again. And it will need attention again.", + "It's hot right now. Give it a moment before you put it on. Trust me on that.", + "*stares at the finished piece for a moment longer than necessary* Beautiful. Okay. Take it. Go.", +} + +var blacksmithMasterwork = []string{ + "*pauses* This is Masterwork. *sets everything else down* This gets my complete focus. Nothing else exists right now.", + "I don't rush Masterwork. I don't care how long it takes. Some things deserve to be done properly.", + "*closes eyes briefly* I've been waiting for one of these to come through here. You have no idea how long I've been waiting.", + "Extra charge for Masterwork. Not because it's harder. Because it deserves more of me. It demands more of me. And oh god it will get it, don't you worry at all.", +} + +var blacksmithArena = []string{ + "*goes very still* Is that... Arena gear? *looks up at you* Where did you get this. Never mind. Don't tell me. Just leave it.", + "I've heard about this. I've never had one on my table before. *voice drops* Close the door.", + "The Arena gear gets the good tools. I don't use these for everything. Just for things that matter.", +} + +var blacksmithFullCondition = []string{ + "*looks it over* There's nothing to do here. It doesn't need me. *sounds slightly disappointed* Come back when it does.", + "Full condition. You've been taking care of it. Good. I appreciate that in a person.", + "Nothing to fix. Come back when you've broken something. Or you need me to break you. Special offer. Just for you.", +} + +var blacksmithBrokenCondition = []string{ + "*long pause* ...How. *another pause* You know what. It doesn't matter. I've seen things. Sit down.", + "This is what happens when people don't come see me regularly. *not accusatory. somehow worse than accusatory.*", + "I can fix this. It'll take longer. And cost more. And I'm going to need you to just... not talk while I work. Can you do that.", + "*picks it up gingerly* It's not dead. It just needs someone who knows what they're doing. Lucky for you I always know what to do in *every* situation. \u2764\ufe0f", + "*looks at the condition, looks at you, looks back at the condition* You know it costs more when you let it get like this. Of course you know. You just didn't care. That's fine. I care enough for both of us. It'll cost you.", + "This could have been avoided with regular visits. *slides the cost estimate across the counter without breaking eye contact*", +} + +func pickBlacksmithFlavor(pool []string) string { + if len(pool) == 0 { + return "" + } + return pool[rand.IntN(len(pool))] +} diff --git a/internal/plugin/adventure_render.go b/internal/plugin/adventure_render.go index 2fef7f3..0748847 100644 --- a/internal/plugin/adventure_render.go +++ b/internal/plugin/adventure_render.go @@ -297,7 +297,8 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq } sb.WriteString("**5️⃣ Shop** — buy/sell gear and loot\n") - sb.WriteString("**6️⃣ Rest** — skip today, bank your luck\n\n") + sb.WriteString("**6️⃣ Blacksmith** — repair damaged equipment\n") + sb.WriteString("**7️⃣ Rest** — skip today, bank your luck\n\n") sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`\n") sb.WriteString("You have until midnight UTC to choose.") @@ -349,7 +350,8 @@ func renderAdvHolidaySecondPrompt(char *AdventureCharacter, equip map[EquipmentS } sb.WriteString("**5️⃣ Shop** — buy/sell gear and loot\n") - sb.WriteString("**6️⃣ Rest** — skip the second action\n\n") + sb.WriteString("**6️⃣ Blacksmith** — repair damaged equipment\n") + sb.WriteString("**7️⃣ Rest** — skip the second action\n\n") sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`") return sb.String() diff --git a/internal/plugin/adventure_rival.go b/internal/plugin/adventure_rival.go index 5384f38..4c153fd 100644 --- a/internal/plugin/adventure_rival.go +++ b/internal/plugin/adventure_rival.go @@ -169,6 +169,24 @@ func communityPotAdd(amount int) { ) } +func communityPotBalance() int { + d := db.Get() + var balance int + _ = d.QueryRow(`SELECT COALESCE(balance, 0) FROM community_pot WHERE id = 1`).Scan(&balance) + return balance +} + +func communityPotDebit(amount int) bool { + d := db.Get() + res, err := d.Exec(`UPDATE community_pot SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP + WHERE id = 1 AND balance >= ?`, amount, amount) + if err != nil { + return false + } + n, _ := res.RowsAffected() + return n == 1 +} + func loadExpiredRivalChallenges() ([]advRivalChallenge, error) { d := db.Get() rows, err := d.Query(` diff --git a/internal/plugin/lottery.go b/internal/plugin/lottery.go new file mode 100644 index 0000000..c9bda9a --- /dev/null +++ b/internal/plugin/lottery.go @@ -0,0 +1,297 @@ +package plugin + +import ( + "fmt" + "math/rand/v2" + "sort" + "strconv" + "strings" + "time" + + "maunium.net/go/mautrix" +) + +// ── Plugin ────────────────────────────────────────────────────────────────── + +type LotteryPlugin struct { + Base + euro *EuroPlugin +} + +func NewLotteryPlugin(client *mautrix.Client, euro *EuroPlugin) *LotteryPlugin { + return &LotteryPlugin{ + Base: NewBase(client), + euro: euro, + } +} + +func (p *LotteryPlugin) Name() string { return "lottery" } + +func (p *LotteryPlugin) Commands() []CommandDef { + return []CommandDef{ + {Name: "lottery", Description: "Community lottery — buy tickets, win big", Usage: "!lottery buy [N]", Category: "Games"}, + } +} + +func (p *LotteryPlugin) Init() error { + go p.drawTicker() + go p.reminderTicker() + return nil +} + +func (p *LotteryPlugin) OnReaction(_ ReactionContext) error { return nil } + +func (p *LotteryPlugin) OnMessage(ctx MessageContext) error { + if !p.IsCommand(ctx.Body, "lottery") { + return nil + } + + args := strings.TrimSpace(p.GetArgs(ctx.Body, "lottery")) + lower := strings.ToLower(args) + + switch { + case lower == "" || lower == "help": + return p.handleLotteryHelp(ctx) + case lower == "buy" || strings.HasPrefix(lower, "buy "): + return p.handleLotteryBuy(ctx, strings.TrimSpace(strings.TrimPrefix(lower, "buy"))) + case lower == "tickets": + return p.handleLotteryTickets(ctx) + case lower == "pot": + return p.handleLotteryPot(ctx) + case lower == "odds": + return p.handleLotteryOdds(ctx) + case lower == "history": + return p.handleLotteryHistory(ctx) + } + + return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown lottery command. Try `!lottery help`.") +} + +// ── Commands ──────────────────────────────────────────────────────────────── + +func (p *LotteryPlugin) handleLotteryHelp(ctx MessageContext) error { + text := `🎟️ **Community Lottery** + +` + "`!lottery buy [N]`" + ` — Purchase N tickets (default 1, max 100/week). €1 each. +` + "`!lottery tickets`" + ` — View your tickets for this week's draw +` + "`!lottery pot`" + ` — Current pot balance and draw countdown +` + "`!lottery odds`" + ` — Prize tier table and odds +` + "`!lottery history`" + ` — Last 5 draw results + +Draw: Every Friday at 23:59 UTC` + return p.SendReply(ctx.RoomID, ctx.EventID, text) +} + +func (p *LotteryPlugin) handleLotteryBuy(ctx MessageContext, args string) error { + n := 1 + if args != "" { + parsed, err := strconv.Atoi(args) + if err != nil || parsed < 1 { + return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!lottery buy [N]` where N is 1-100.") + } + n = parsed + } + + weekStart := lotteryCurrentWeekStart() + existing := lotteryTicketCount(ctx.Sender, weekStart) + remaining := 100 - existing + + if remaining <= 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, "You've reached the 100 ticket limit for this week's draw. Come back Friday.") + } + + adjusted := false + if n > remaining { + n = remaining + adjusted = true + } + + cost := float64(n) + if !p.euro.Debit(ctx.Sender, cost, "lottery_tickets") { + balance := p.euro.GetBalance(ctx.Sender) + return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Tickets cost €1 each. You need €%d but have €%.0f.", n, balance)) + } + + // Generate tickets. + tickets := make([][]int, n) + for i := range tickets { + tickets[i] = generateLotteryNumbers() + } + + lotteryInsertTickets(ctx.Sender, weekStart, tickets) + + // Build confirmation. + var sb strings.Builder + sb.WriteString(fmt.Sprintf("🎟️ **%d ticket(s) purchased** — €%d\n\n", n, n)) + + displayLimit := n + if displayLimit > 10 { + displayLimit = 10 + } + for i := 0; i < displayLimit; i++ { + sb.WriteString(fmt.Sprintf(" #%d — %s\n", existing+i+1, formatLotteryNumbers(tickets[i]))) + } + if n > 10 { + sb.WriteString(fmt.Sprintf(" ... and %d more\n", n-10)) + } + + if adjusted { + sb.WriteString(fmt.Sprintf("\nAdjusted to %d (weekly cap reached).", n)) + } + + sb.WriteString(fmt.Sprintf("\nTotal tickets this week: %d/100", existing+n)) + sb.WriteString(fmt.Sprintf("\nDraw: Friday 23:59 UTC (%s)", lotteryDrawCountdown())) + + return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) +} + +func (p *LotteryPlugin) handleLotteryTickets(ctx MessageContext) error { + weekStart := lotteryCurrentWeekStart() + tickets, err := lotteryLoadUserTickets(ctx.Sender, weekStart) + if err != nil || len(tickets) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, "🎟️ You have no tickets for this week's draw. `!lottery buy` to get started.") + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("🎟️ **Your tickets this week** (%d):\n\n", len(tickets))) + + displayLimit := len(tickets) + if displayLimit > 20 { + displayLimit = 20 + } + for i := 0; i < displayLimit; i++ { + sb.WriteString(fmt.Sprintf(" #%d — %s\n", i+1, formatLotteryNumbers(tickets[i].Numbers))) + } + if len(tickets) > 20 { + sb.WriteString(fmt.Sprintf(" ... and %d more\n", len(tickets)-20)) + } + + pot := communityPotBalance() + sb.WriteString(fmt.Sprintf("\nDraw: Friday 23:59 UTC (%s) | Pot: €%d", lotteryDrawCountdown(), pot)) + + return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) +} + +func (p *LotteryPlugin) handleLotteryPot(ctx MessageContext) error { + weekStart := lotteryCurrentWeekStart() + pot := communityPotBalance() + totalTickets := lotteryTotalTicketCount(weekStart) + countdown := lotteryDrawCountdown() + + text := fmt.Sprintf("🎟️ **Lottery Pot**\n\n"+ + "Current pot: **€%d**\n"+ + "Tickets sold this week: %d\n"+ + "Next draw: Friday 23:59 UTC (%s)\n\n"+ + "Pot is funded by rival duel shares and ticket sales.", + pot, totalTickets, countdown) + + return p.SendReply(ctx.RoomID, ctx.EventID, text) +} + +func (p *LotteryPlugin) handleLotteryOdds(ctx MessageContext) error { + text := `🎟️ **Lottery Prize Tiers** + +| Match | Prize | Odds (approx.) | +|-------|-------|----------------| +| 5 of 5 | Jackpot (split among winners) | 1 in 142,506 | +| 4 of 5 | €1,000 (fixed) | 1 in 3,062 | +| 3 of 5 | €100 (fixed) | 1 in 141 | +| 2 of 5 | €10 (fixed) | 1 in 16 | +| 1 of 5 | €2 (fixed) | 1 in 4 | +| 0 of 5 | Nothing | — | + +Tickets: €1 each. Max 100 per week. 5 numbers from 1–30. +Minimum €500 pot required for jackpot payout.` + + return p.SendReply(ctx.RoomID, ctx.EventID, text) +} + +func (p *LotteryPlugin) handleLotteryHistory(ctx MessageContext) error { + history, err := lotteryLoadHistory(5) + if err != nil || len(history) == 0 { + return p.SendReply(ctx.RoomID, ctx.EventID, "🎟️ No draw history yet.") + } + + var sb strings.Builder + sb.WriteString("🎟️ **Recent Draws**\n\n") + + for _, h := range history { + sb.WriteString(fmt.Sprintf("**%s** — %s\n", h.DrawDate, formatLotteryNumbers(h.WinningNumbers))) + sb.WriteString(fmt.Sprintf(" Pot: €%d", h.PotTotal)) + if h.JackpotWinners > 0 { + sb.WriteString(fmt.Sprintf(" | Jackpot: %d winner(s), €%d each", h.JackpotWinners, h.JackpotAmount)) + } + if h.RolledOver > 0 { + sb.WriteString(fmt.Sprintf(" | Rolled over: €%d", h.RolledOver)) + } + sb.WriteString(fmt.Sprintf("\n 4-match: %d | 3-match: %d | 2-match: %d | 1-match: %d\n\n", + h.Match4Winners, h.Match3Winners, h.Match2Winners, h.Match1Winners)) + } + + return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) +} + +// ── Number Generation ─────────────────────────────────────────────────────── + +func generateLotteryNumbers() []int { + // Partial Fisher-Yates on [1..30], take first 5, sort. + pool := make([]int, 30) + for i := range pool { + pool[i] = i + 1 + } + for i := 0; i < 5; i++ { + j := i + rand.IntN(30-i) + pool[i], pool[j] = pool[j], pool[i] + } + nums := make([]int, 5) + copy(nums, pool[:5]) + sort.Ints(nums) + return nums +} + +func countMatches(ticket, winning []int) int { + winSet := make(map[int]bool, len(winning)) + for _, n := range winning { + winSet[n] = true + } + count := 0 + for _, n := range ticket { + if winSet[n] { + count++ + } + } + return count +} + +// ── Formatting Helpers ────────────────────────────────────────────────────── + +func formatLotteryNumbers(nums []int) string { + parts := make([]string, len(nums)) + for i, n := range nums { + parts[i] = strconv.Itoa(n) + } + return strings.Join(parts, " \u00b7 ") +} + +func lotteryDrawCountdown() string { + now := time.Now().UTC() + // Find next Friday 23:59. + daysUntilFriday := (int(time.Friday) - int(now.Weekday()) + 7) % 7 + if daysUntilFriday == 0 && (now.Hour() > 23 || (now.Hour() == 23 && now.Minute() >= 59)) { + daysUntilFriday = 7 + } + nextDraw := time.Date(now.Year(), now.Month(), now.Day()+daysUntilFriday, 23, 59, 0, 0, time.UTC) + d := nextDraw.Sub(now) + + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + mins := int(d.Minutes()) % 60 + + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, mins) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, mins) + } + return fmt.Sprintf("%dm", mins) +} diff --git a/internal/plugin/lottery_db.go b/internal/plugin/lottery_db.go new file mode 100644 index 0000000..f5a9d86 --- /dev/null +++ b/internal/plugin/lottery_db.go @@ -0,0 +1,190 @@ +package plugin + +import ( + "encoding/json" + "log/slog" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// ── Types ─────────────────────────────────────────────────────────────────── + +type lotteryTicket struct { + ID int64 + UserID id.UserID + WeekStart string + Numbers []int + MatchCount *int + Prize *int +} + +type lotteryHistoryRow struct { + DrawDate string + WinningNumbers []int + JackpotWinners int + JackpotAmount int + Match4Winners int + Match3Winners int + Match2Winners int + Match1Winners int + PotTotal int + RolledOver int +} + +// ── Week Helpers ──────────────────────────────────────────────────────────── + +// lotteryCurrentWeekStart returns Monday of the current week as "2006-01-02". +func lotteryCurrentWeekStart() string { + now := time.Now().UTC() + weekday := int(now.Weekday()) + if weekday == 0 { + weekday = 7 // Sunday = 7 + } + monday := now.AddDate(0, 0, -(weekday - 1)) + return monday.Format("2006-01-02") +} + +// ── Ticket CRUD ───────────────────────────────────────────────────────────── + +func lotteryTicketCount(userID id.UserID, weekStart string) int { + d := db.Get() + var count int + _ = d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE user_id = ? AND week_start = ?`, + string(userID), weekStart).Scan(&count) + return count +} + +func lotteryTotalTicketCount(weekStart string) int { + d := db.Get() + var count int + _ = d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE week_start = ?`, weekStart).Scan(&count) + return count +} + +func lotteryInsertTickets(userID id.UserID, weekStart string, tickets [][]int) { + d := db.Get() + for _, nums := range tickets { + data, _ := json.Marshal(nums) + _, err := d.Exec(`INSERT INTO lottery_tickets (user_id, week_start, numbers) VALUES (?, ?, ?)`, + string(userID), weekStart, string(data)) + if err != nil { + slog.Error("lottery: failed to insert ticket", "user", userID, "err", err) + } + } + // Each ticket costs €1 — add to community pot. + communityPotAdd(len(tickets)) +} + +func lotteryLoadUserTickets(userID id.UserID, weekStart string) ([]lotteryTicket, error) { + d := db.Get() + rows, err := d.Query(`SELECT id, user_id, week_start, numbers, match_count, prize + FROM lottery_tickets WHERE user_id = ? AND week_start = ? ORDER BY id`, + string(userID), weekStart) + if err != nil { + return nil, err + } + defer rows.Close() + return scanLotteryTickets(rows) +} + +func lotteryLoadAllWeekTickets(weekStart string) ([]lotteryTicket, error) { + d := db.Get() + rows, err := d.Query(`SELECT id, user_id, week_start, numbers, match_count, prize + FROM lottery_tickets WHERE week_start = ? ORDER BY id`, weekStart) + if err != nil { + return nil, err + } + defer rows.Close() + return scanLotteryTickets(rows) +} + +func lotteryUpdateTicketResult(ticketID int64, matchCount, prize int) { + d := db.Get() + _, err := d.Exec(`UPDATE lottery_tickets SET match_count = ?, prize = ? WHERE id = ?`, + matchCount, prize, ticketID) + if err != nil { + slog.Error("lottery: failed to update ticket result", "id", ticketID, "err", err) + } +} + +// ── History CRUD ──────────────────────────────────────────────────────────── + +func lotteryInsertHistory(h *lotteryHistoryRow) { + d := db.Get() + winJSON, _ := json.Marshal(h.WinningNumbers) + _, err := d.Exec(`INSERT INTO lottery_history + (draw_date, winning_numbers, jackpot_winners, jackpot_amount, + match4_winners, match3_winners, match2_winners, match1_winners, + pot_total, rolled_over) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + h.DrawDate, string(winJSON), h.JackpotWinners, h.JackpotAmount, + h.Match4Winners, h.Match3Winners, h.Match2Winners, h.Match1Winners, + h.PotTotal, h.RolledOver) + if err != nil { + slog.Error("lottery: failed to insert history", "err", err) + } +} + +func lotteryLoadHistory(limit int) ([]lotteryHistoryRow, error) { + d := db.Get() + rows, err := d.Query(`SELECT draw_date, winning_numbers, jackpot_winners, jackpot_amount, + match4_winners, match3_winners, match2_winners, match1_winners, + pot_total, rolled_over + FROM lottery_history ORDER BY draw_date DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var history []lotteryHistoryRow + for rows.Next() { + var h lotteryHistoryRow + var winJSON string + if err := rows.Scan(&h.DrawDate, &winJSON, &h.JackpotWinners, &h.JackpotAmount, + &h.Match4Winners, &h.Match3Winners, &h.Match2Winners, &h.Match1Winners, + &h.PotTotal, &h.RolledOver); err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(winJSON), &h.WinningNumbers) + history = append(history, h) + } + return history, rows.Err() +} + +// ── Cleanup ───────────────────────────────────────────────────────────────── + +func lotteryCleanupOldTickets() { + d := db.Get() + _, err := d.Exec(`DELETE FROM lottery_tickets WHERE week_start < DATE('now', '-30 days')`) + if err != nil { + slog.Error("lottery: failed to cleanup old tickets", "err", err) + } +} + +// ── Scan Helper ───────────────────────────────────────────────────────────── + +type lotteryRows interface { + Next() bool + Scan(dest ...interface{}) error + Err() error +} + +func scanLotteryTickets(rows lotteryRows) ([]lotteryTicket, error) { + var tickets []lotteryTicket + for rows.Next() { + var t lotteryTicket + var numsJSON string + var matchCount, prize *int + if err := rows.Scan(&t.ID, &t.UserID, &t.WeekStart, &numsJSON, &matchCount, &prize); err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(numsJSON), &t.Numbers) + t.MatchCount = matchCount + t.Prize = prize + tickets = append(tickets, t) + } + return tickets, rows.Err() +} diff --git a/internal/plugin/lottery_draw.go b/internal/plugin/lottery_draw.go new file mode 100644 index 0000000..5771a35 --- /dev/null +++ b/internal/plugin/lottery_draw.go @@ -0,0 +1,272 @@ +package plugin + +import ( + "fmt" + "log/slog" + "strings" + "time" + + "gogobee/internal/db" + + "maunium.net/go/mautrix/id" +) + +// ── Prize Tiers (fixed payouts) ───────────────────────────────────────────── + +var lotteryFixedPrizes = map[int]int{ + 4: 1000, + 3: 100, + 2: 10, + 1: 2, +} + +// ── Draw Ticker ───────────────────────────────────────────────────────────── + +func (p *LotteryPlugin) drawTicker() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + now := time.Now().UTC() + if now.Weekday() != time.Friday || now.Hour() != 23 || now.Minute() != 59 { + continue + } + + weekKey := lotteryCurrentWeekStart() + jobName := "lottery_draw" + if db.JobCompleted(jobName, weekKey) { + continue + } + + slog.Info("lottery: executing weekly draw") + p.executeDraw(weekKey) + db.MarkJobCompleted(jobName, weekKey) + } +} + +// ── Reminder Ticker ───────────────────────────────────────────────────────── + +func (p *LotteryPlugin) reminderTicker() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + now := time.Now().UTC() + if now.Weekday() != time.Thursday || now.Hour() != 20 || now.Minute() != 0 { + continue + } + + weekKey := lotteryCurrentWeekStart() + jobName := "lottery_reminder" + if db.JobCompleted(jobName, weekKey) { + continue + } + + gr := gamesRoom() + if gr != "" { + pot := communityPotBalance() + p.SendMessage(gr, fmt.Sprintf( + "🎟️ Lottery draw tomorrow. 23:59 UTC. Current pot: €%d. "+ + "Tickets €1 each. Max 100 per player. `!lottery buy [N]` to enter.", + pot)) + } + + db.MarkJobCompleted(jobName, weekKey) + } +} + +// ── Draw Execution ────────────────────────────────────────────────────────── + +func (p *LotteryPlugin) executeDraw(weekStart string) { + winning := generateLotteryNumbers() + + tickets, err := lotteryLoadAllWeekTickets(weekStart) + if err != nil { + slog.Error("lottery: failed to load tickets for draw", "err", err) + return + } + + if len(tickets) == 0 { + gr := gamesRoom() + if gr != "" { + p.SendMessage(gr, "🎟️ **LOTTERY DRAW** — No tickets were sold this week. The pot rolls over.") + } + return + } + + // Score all tickets. + matchBuckets := make(map[int][]lotteryTicket) // matchCount -> tickets + for i := range tickets { + mc := countMatches(tickets[i].Numbers, winning) + tickets[i].MatchCount = &mc + + prize := 0 + if mc >= 1 && mc <= 4 { + prize = lotteryFixedPrizes[mc] + } + tickets[i].Prize = &prize + + lotteryUpdateTicketResult(tickets[i].ID, mc, prize) + matchBuckets[mc] = append(matchBuckets[mc], tickets[i]) + } + + pot := communityPotBalance() + initialPot := pot + + // Calculate fixed tier payouts. + fixedTotal := 0 + for tier := 4; tier >= 1; tier-- { + count := len(matchBuckets[tier]) + fixedTotal += count * lotteryFixedPrizes[tier] + } + + // If pot can't cover all fixed payouts, prorate top-down. + actualFixed := fixedTotal + if actualFixed > pot { + actualFixed = pot + } + + // Debit fixed payouts from pot. + if actualFixed > 0 { + if !communityPotDebit(actualFixed) { + slog.Error("lottery: failed to debit fixed payouts from pot", "amount", actualFixed) + actualFixed = 0 + } + pot -= actualFixed + } + + // Credit fixed tier winners. + prorateRatio := 1.0 + if fixedTotal > 0 && actualFixed < fixedTotal { + prorateRatio = float64(actualFixed) / float64(fixedTotal) + } + + if actualFixed > 0 { + for tier := 4; tier >= 1; tier-- { + for _, t := range matchBuckets[tier] { + amount := int(float64(lotteryFixedPrizes[tier]) * prorateRatio) + if amount > 0 { + p.euro.Credit(t.UserID, float64(amount), fmt.Sprintf("lottery_%dmatch", tier)) + } + } + } + } + + // Jackpot. + jackpotWinners := matchBuckets[5] + jackpotAmount := 0 + rolledOver := 0 + + if len(jackpotWinners) > 0 && pot >= 500 { + // Split remaining pot among jackpot winners. + perWinner := pot / len(jackpotWinners) + remainder := pot - (perWinner * len(jackpotWinners)) + jackpotAmount = perWinner + + totalJackpotDebit := perWinner * len(jackpotWinners) + if !communityPotDebit(totalJackpotDebit) { + slog.Error("lottery: failed to debit jackpot from pot", "amount", totalJackpotDebit) + // Jackpot rolls over — don't credit winners. + jackpotAmount = 0 + rolledOver = pot + } else { + for _, t := range jackpotWinners { + p.euro.Credit(t.UserID, float64(perWinner), "lottery_jackpot") + lotteryUpdateTicketResult(t.ID, 5, perWinner) + } + rolledOver = remainder + } + } else { + // Jackpot rolls over. + rolledOver = pot + } + + // Insert history. + h := &lotteryHistoryRow{ + DrawDate: time.Now().UTC().Format("2006-01-02"), + WinningNumbers: winning, + JackpotWinners: len(jackpotWinners), + JackpotAmount: jackpotAmount, + Match4Winners: len(matchBuckets[4]), + Match3Winners: len(matchBuckets[3]), + Match2Winners: len(matchBuckets[2]), + Match1Winners: len(matchBuckets[1]), + PotTotal: initialPot, + RolledOver: rolledOver, + } + lotteryInsertHistory(h) + + // Room announcement. + gr := gamesRoom() + if gr != "" { + announcement := p.buildDrawAnnouncement(winning, h, jackpotWinners) + p.SendMessage(gr, announcement) + } + + // Cleanup old tickets. + lotteryCleanupOldTickets() + + slog.Info("lottery: draw completed", + "winning", winning, + "tickets", len(tickets), + "pot", initialPot, + "jackpot_winners", len(jackpotWinners)) +} + +// ── Announcement Builder ──────────────────────────────────────────────────── + +func (p *LotteryPlugin) buildDrawAnnouncement(winning []int, h *lotteryHistoryRow, jackpotWinners []lotteryTicket) string { + var sb strings.Builder + + sb.WriteString("🎟️ **LOTTERY DRAW** — Friday 23:59 UTC\n\n") + sb.WriteString(fmt.Sprintf("This week's numbers: **%s**\n\n", formatLotteryNumbers(winning))) + + // Jackpot line. + if len(jackpotWinners) > 0 && h.JackpotAmount > 0 { + names := p.resolveWinnerNames(jackpotWinners) + if len(jackpotWinners) == 1 { + sb.WriteString(fmt.Sprintf("Jackpot (5 match): **%s** — €%d 🎉\n", names[0], h.JackpotAmount)) + } else { + sb.WriteString(fmt.Sprintf("Jackpot (5 match): Split between %s. €%d each. 🎉\n", + joinNames(names), h.JackpotAmount)) + } + } else if len(jackpotWinners) > 0 && h.JackpotAmount == 0 { + sb.WriteString("Jackpot withheld — pot insufficient. Rolls to next week. Fixed tiers paid as normal.\n") + } else { + sb.WriteString("Jackpot (5 match): No winner this week. Pot rolls over.\n") + } + + // Fixed tiers. + sb.WriteString(fmt.Sprintf("4 match: %d winner(s) — €1,000 each\n", h.Match4Winners)) + sb.WriteString(fmt.Sprintf("3 match: %d winner(s) — €100 each\n", h.Match3Winners)) + sb.WriteString(fmt.Sprintf("2 match: %d winner(s) — €10 each\n", h.Match2Winners)) + sb.WriteString(fmt.Sprintf("1 match: %d winner(s) — €2 each\n", h.Match1Winners)) + + distributed := h.PotTotal - h.RolledOver + sb.WriteString(fmt.Sprintf("\nPot distributed: €%d. Next draw: Friday. Tickets on sale now.", distributed)) + + return sb.String() +} + +func (p *LotteryPlugin) resolveWinnerNames(winners []lotteryTicket) []string { + // Deduplicate by user ID (multiple tickets from same player). + seen := make(map[id.UserID]bool) + var names []string + for _, t := range winners { + if seen[t.UserID] { + continue + } + seen[t.UserID] = true + // Try to get display name from DM room. + name := p.DisplayName(t.UserID) + names = append(names, name) + } + return names +} + +func joinNames(names []string) string { + if len(names) <= 2 { + return strings.Join(names, " and ") + } + return strings.Join(names[:len(names)-1], ", ") + ", and " + names[len(names)-1] +} diff --git a/main.go b/main.go index 786d99c..e9b07d7 100644 --- a/main.go +++ b/main.go @@ -136,6 +136,8 @@ func main() { registry.Register(adventurePlugin) wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin, dictClient) registry.Register(wordlePlugin) + lotteryPlugin := plugin.NewLotteryPlugin(client, euroPlugin) + registry.Register(lotteryPlugin) // Community registry.Register(plugin.NewMilkCartonPlugin(client, ratePlugin))