Add blacksmith repair system, community lottery, and audit fixes

Blacksmith: equipment repair with tier-based pricing, DM confirmation flow,
masterwork/arena surcharges, full flavor text pools. Added to main adventure
menu as option 6 (rest bumped to 7).

Lottery: weekly draw (Friday 23:59 UTC), ticket purchases with 100/week cap,
Fisher-Yates number generation, fixed+jackpot prize tiers, community pot
funding, Thursday reminders, draw history.

Audit fixes: TOCTOU in blacksmith repair costs (recompute from fresh equipment),
user lock in DM slot selection, partial repair refund tracking, error logging
on save failures, Fisher-Yates bias correction, communityPotDebit return value
checks in draw execution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-04 09:37:21 -07:00
parent ad6e652755
commit 6c6d74fb1b
10 changed files with 1300 additions and 4 deletions

View File

@@ -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); 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. // SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.

View File

@@ -204,6 +204,12 @@ func (p *AdventurePlugin) dispatchCommand(ctx MessageContext) error {
return p.handleRivalsCmd(ctx) return p.handleRivalsCmd(ctx)
case lower == "babysit" || strings.HasPrefix(lower, "babysit "): case lower == "babysit" || strings.HasPrefix(lower, "babysit "):
return p.handleBabysitCmd(ctx, strings.TrimSpace(strings.TrimPrefix(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.") 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 respond`" + ` — Respond to a mid-day event
` + "`!adventure rivals`" + ` — View rival duel records ` + "`!adventure rivals`" + ` — View rival duel records
` + "`!adventure babysit`" + ` — Adventurer Babysitting Service ` + "`!adventure babysit`" + ` — Adventurer Babysitting Service
` + "`!adventure blacksmith`" + ` — Visit the blacksmith (view repair costs)
` + "`!adventure repair all`" + ` — Repair all damaged equipment
` + "`!adventure repair <slot>`" + ` — Repair a specific slot
` + "`!adventure help`" + ` — This message ` + "`!adventure help`" + ` — This message
**Arena:** **Arena:**
@@ -496,6 +505,10 @@ func (p *AdventurePlugin) resolvePendingInteraction(ctx MessageContext, interact
return p.handleMasterworkEquipConfirm(ctx, interaction) return p.handleMasterworkEquipConfirm(ctx, interaction)
case "rival_rps": case "rival_rps":
return p.resolveRivalRPSRound(ctx, interaction) return p.resolveRivalRPSRound(ctx, interaction)
case "blacksmith_slot":
return p.resolveBlacksmithSlotChoice(ctx, interaction)
case "blacksmith_confirm":
return p.resolveBlacksmithConfirm(ctx, interaction)
} }
return nil return nil
} }
@@ -585,11 +598,16 @@ func (p *AdventurePlugin) parseAndResolveChoice(ctx MessageContext, body string)
lower := strings.ToLower(body) lower := strings.ToLower(body)
// Parse "6" or "rest" // Parse "7" or "rest"
if lower == "6" || lower == "rest" { if lower == "7" || lower == "rest" {
return p.resolveRest(ctx, char) return p.resolveRest(ctx, char)
} }
// Parse "6" or "blacksmith"
if lower == "6" || lower == "blacksmith" {
return p.handleBlacksmithCmd(ctx)
}
// Parse "5" or "shop" // Parse "5" or "shop"
if lower == "5" || lower == "shop" { if lower == "5" || lower == "shop" {
equip, _ := loadAdvEquipment(ctx.Sender) equip, _ := loadAdvEquipment(ctx.Sender)

View File

@@ -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))
}

View File

@@ -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))]
}

View File

@@ -297,7 +297,8 @@ func renderAdvMorningDM(char *AdventureCharacter, equip map[EquipmentSlot]*AdvEq
} }
sb.WriteString("**5⃣ Shop** — buy/sell gear and loot\n") sb.WriteString("**5⃣ Shop** — buy/sell gear and loot\n")
sb.WriteString("**6Rest** — skip today, bank your luck\n\n") sb.WriteString("**6Blacksmith** — 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("Reply with the number and location, e.g: `1 Soggy Cellar`\n")
sb.WriteString("You have until midnight UTC to choose.") 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("**5⃣ Shop** — buy/sell gear and loot\n")
sb.WriteString("**6Rest** — skip the second action\n\n") sb.WriteString("**6Blacksmith** — 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`") sb.WriteString("Reply with the number and location, e.g: `1 Soggy Cellar`")
return sb.String() return sb.String()

View File

@@ -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) { func loadExpiredRivalChallenges() ([]advRivalChallenge, error) {
d := db.Get() d := db.Get()
rows, err := d.Query(` rows, err := d.Query(`

297
internal/plugin/lottery.go Normal file
View File

@@ -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 130.
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)
}

View File

@@ -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()
}

View File

@@ -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]
}

View File

@@ -136,6 +136,8 @@ func main() {
registry.Register(adventurePlugin) registry.Register(adventurePlugin)
wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin, dictClient) wordlePlugin := plugin.NewWordlePlugin(client, euroPlugin, dictClient)
registry.Register(wordlePlugin) registry.Register(wordlePlugin)
lotteryPlugin := plugin.NewLotteryPlugin(client, euroPlugin)
registry.Register(lotteryPlugin)
// Community // Community
registry.Register(plugin.NewMilkCartonPlugin(client, ratePlugin)) registry.Register(plugin.NewMilkCartonPlugin(client, ratePlugin))