Files
gogobee/internal/plugin/lottery_db.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

237 lines
7.8 KiB
Go
Raw 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 (
"encoding/json"
"fmt"
"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 the Monday of the lottery week currently
// accepting tickets. MonFri returns this week's Monday; SatSun returns
// next week's Monday — the Friday 23:59 UTC draw resets the week boundary,
// so Saturday onward, ticket purchases count toward the next draw.
func lotteryCurrentWeekStart() string {
now := time.Now().UTC()
wd := now.Weekday() // Sunday=0, Monday=1, ..., Saturday=6
var monday time.Time
switch wd {
case time.Sunday:
monday = now.AddDate(0, 0, 1)
case time.Saturday:
monday = now.AddDate(0, 0, 2)
default:
monday = now.AddDate(0, 0, -(int(wd) - 1))
}
return monday.Format("2006-01-02")
}
// ── Ticket CRUD ─────────────────────────────────────────────────────────────
func lotteryTicketCount(userID id.UserID, weekStart string) int {
d := db.Get()
var count int
if err := d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE user_id = ? AND week_start = ?`,
string(userID), weekStart).Scan(&count); err != nil {
slog.Error("lottery: ticket count query failed", "user", userID, "err", err)
}
return count
}
func lotteryTotalTicketCount(weekStart string) int {
d := db.Get()
var count int
if err := d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE week_start = ?`, weekStart).Scan(&count); err != nil {
slog.Error("lottery: total ticket count query failed", "err", err)
}
return count
}
func lotteryInsertTickets(userID id.UserID, weekStart string, tickets [][]int) error {
d := db.Get()
tx, err := d.Begin()
if err != nil {
slog.Error("lottery: begin tx", "err", err)
return err
}
defer tx.Rollback()
// Re-check ticket count inside transaction to prevent TOCTOU race.
var existing int
if err := tx.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE user_id = ? AND week_start = ?`,
string(userID), weekStart).Scan(&existing); err != nil {
return fmt.Errorf("lottery: count tickets in tx: %w", err)
}
if existing+len(tickets) > 100 {
return fmt.Errorf("lottery: ticket limit exceeded (have %d, buying %d)", existing, len(tickets))
}
for _, nums := range tickets {
data, _ := json.Marshal(nums)
_, err := tx.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)
return err
}
}
// Each ticket costs €1 — add to community pot (same transaction).
_, err = tx.Exec(
`INSERT INTO community_pot (id, balance, updated_at)
VALUES (1, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP`,
len(tickets), len(tickets))
if err != nil {
slog.Error("lottery: failed to add to community pot", "err", err)
return err
}
return tx.Commit()
}
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
}
if err := json.Unmarshal([]byte(winJSON), &h.WinningNumbers); err != nil {
slog.Warn("lottery: corrupt winning_numbers JSON", "draw", h.DrawDate, "err", err)
}
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
}
if err := json.Unmarshal([]byte(numsJSON), &t.Numbers); err != nil {
slog.Warn("lottery: corrupt ticket numbers JSON", "id", t.ID, "err", err)
}
t.MatchCount = matchCount
t.Prize = prize
tickets = append(tickets, t)
}
return tickets, rows.Err()
}