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: 5000, 3: 500, } // ── 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: %s. "+ "Tickets €1 each. Max 100 per player. `!lottery buy [N]` to enter.", fmtEuro(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 >= 3 && 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 >= 3; 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 >= 3; 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)) } // Update ticket with actual prorated prize (not nominal) lotteryUpdateTicketResult(t.ID, *t.MatchCount, amount) } } } // 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: 0, Match1Winners: 0, PotTotal: initialPot, RolledOver: rolledOver, } lotteryInsertHistory(h) // Room announcement. gr := gamesRoom() if gr != "" { announcement := p.buildDrawAnnouncement(winning, h, jackpotWinners) p.SendMessage(gr, announcement) } // DM each winner with their tickets and results. p.dmWinners(winning, tickets) // Cleanup old tickets. lotteryCleanupOldTickets() slog.Info("lottery: draw completed", "winning", winning, "tickets", len(tickets), "pot", initialPot, "jackpot_winners", len(jackpotWinners)) } func (p *LotteryPlugin) dmWinners(winning []int, tickets []lotteryTicket) { // Group winning tickets by user. byUser := make(map[id.UserID][]lotteryTicket) for _, t := range tickets { if t.MatchCount == nil || *t.MatchCount < 3 { continue } byUser[t.UserID] = append(byUser[t.UserID], t) } for uid, userTickets := range byUser { var sb strings.Builder sb.WriteString(fmt.Sprintf("🎟️ **Lottery Results** — Winning numbers: **%s**\n\n", formatLotteryNumbers(winning))) totalWon := 0 for _, t := range userTickets { matches := *t.MatchCount prize := 0 if t.Prize != nil { prize = *t.Prize } totalWon += prize // Show ticket numbers with matches highlighted sb.WriteString(fmt.Sprintf("🎫 %s — **%d match** → %s\n", formatTicketWithMatches(t.Numbers, winning), matches, fmtEuro(prize))) } sb.WriteString(fmt.Sprintf("\n💰 **Total: %s**", fmtEuro(totalWon))) if err := p.SendDM(uid, sb.String()); err != nil { slog.Error("lottery: failed to DM winner", "user", uid, "err", err) } } } func formatTicketWithMatches(ticket, winning []int) string { winSet := make(map[int]bool, len(winning)) for _, n := range winning { winSet[n] = true } parts := make([]string, len(ticket)) for i, n := range ticket { if winSet[n] { parts[i] = fmt.Sprintf("**%d**", n) } else { parts[i] = fmt.Sprintf("%d", n) } } return strings.Join(parts, " · ") } // ── 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** — %s 🎉\n", names[0], fmtEuro(h.JackpotAmount))) } else { sb.WriteString(fmt.Sprintf("Jackpot (5 match): Split between %s. %s each. 🎉\n", joinNames(names), fmtEuro(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) — €5,000 each\n", h.Match4Winners)) sb.WriteString(fmt.Sprintf("3 match: %d winner(s) — €500 each\n", h.Match3Winners)) distributed := h.PotTotal - h.RolledOver sb.WriteString(fmt.Sprintf("\nPot distributed: %s. Next draw: Friday. Tickets on sale now.", fmtEuro(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] }