Files
gogobee/internal/plugin/lottery.go
prosolis b15c13cde7 Add version system, tax tracking, lottery winner DMs, fmtEuro, audit fixes
- Version system: per-plugin versions via Versioned interface, crash_log and
  version_history DB tables, !version command, crash stats in !botinfo
- Tax tracking: tax_ledger table, trackTaxPaid across all communityTax and
  communityPotAdd sites, tax paid display in !stats and !superstatsexplusalpha
- Lottery: DM winners with winning ticket details and matched numbers bolded
- fmtEuro: generic currency formatter with thousand separators across all
  economy display paths
- Audit fixes: streak_decayed column for Streak Survivor achievement,
  combat_narrative empty-phase guard, crafting success rate integer division,
  RecordCrash nil-DB guard

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 01:07:25 -07:00

301 lines
9.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) Version() string { return "1.1.0" }
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()
}
if err := lotteryInsertTickets(ctx.Sender, weekStart, tickets); err != nil {
// Refund — tickets didn't persist.
p.euro.Credit(ctx.Sender, cost, "lottery_refund")
return p.SendReply(ctx.RoomID, ctx.EventID, "Something went wrong buying tickets. You've been refunded.")
}
// 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 | €5,000 (fixed) | 1 in 3,062 |
| 3 of 5 | €500 (fixed) | 1 in 141 |
| 2 of 5 | €25 (fixed) | 1 in 16 |
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\n\n",
h.Match4Winners, h.Match3Winners, h.Match2Winners))
}
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)
}