mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
`!lottery pot` has always claimed "pot is funded by ... ticket sales", and the draw debits prizes from that pot -- but handleLotteryBuy only debited the player, so ticket euros were burned and the lottery ran as a pure drain on rake income from elsewhere. Credit the pot after lotteryInsertTickets succeeds, so the refund path can't strand money in it. Watch pot-drain-vs-rake per project_lottery_economics: if weekly pot growth jumps, tune the prize tiers rather than the ticket price.
307 lines
9.4 KiB
Go
307 lines
9.4 KiB
Go
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.")
|
||
}
|
||
|
||
// Ticket euros fund the pot, which is what `!lottery pot` has always
|
||
// claimed and what the draw assumes — prizes debit the pot, so without
|
||
// this the lottery is a pure drain on rake income from elsewhere.
|
||
// Credited only after the tickets persist, so the refund path above
|
||
// can't strand money in the pot.
|
||
communityPotAdd(int(cost))
|
||
|
||
// 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 |
|
||
|
||
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\n\n",
|
||
h.Match4Winners, h.Match3Winners))
|
||
}
|
||
|
||
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)
|
||
}
|