Files
gogobee/internal/plugin/lottery_draw.go
prosolis a00987e75c Coop lockout fix, holidays full list, !fx convert, lottery cleanup
- Co-op: when a run wipes or completes, clear the 99-sentinel combat
  lock for participants and pin combat_actions_used to maxCombatActions.
  Previously the lock only released on the next midnight reset, so
  members were stuck unable to combat for the rest of the day after
  their run ended.
- Holidays: !holidays now prints the full list. Previously it called
  the same featured-only formatter as TwinBee's morning auto-post,
  so users saw the same truncated message twice with a misleading
  "Use !holidays for the full list" hint that pointed at itself.
- Forex: add currency conversion. Auto-detects when the first arg is
  numeric (`!fx 1500 USD EUR`) and also accepts an explicit `convert`
  subcommand. Tolerant parsing: "to"/"into"/"in"/"=" filters, comma
  and k/m suffix support, and any of `1500 USD EUR`, `1500 USD/EUR`,
  `USD/EUR 1500` orderings. Includes parser + comma-formatter tests.
- Lottery: drop 2-match prize tier. At 1/16 odds × €25 it was +EV per
  ticket on its own and bled the community pot (~€775/wk recently),
  preventing jackpot rollover from accumulating. Removed from scoring,
  payout loops, draw announcement, !lottery odds, and !lottery history.
  Match2Winners DB column kept (history rows preserved); always written
  as 0 going forward.
- Lottery: shift week boundary so Sat/Sun return next Monday. Friday's
  23:59 draw used to leave players locked at their 100-ticket cap until
  next Monday rolled in; now the cap resets immediately after the draw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:25:21 -07:00

326 lines
9.2 KiB
Go

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