Add blacksmith repair system, community lottery, and audit fixes

Blacksmith: equipment repair with tier-based pricing, DM confirmation flow,
masterwork/arena surcharges, full flavor text pools. Added to main adventure
menu as option 6 (rest bumped to 7).

Lottery: weekly draw (Friday 23:59 UTC), ticket purchases with 100/week cap,
Fisher-Yates number generation, fixed+jackpot prize tiers, community pot
funding, Thursday reminders, draw history.

Audit fixes: TOCTOU in blacksmith repair costs (recompute from fresh equipment),
user lock in DM slot selection, partial repair refund tracking, error logging
on save failures, Fisher-Yates bias correction, communityPotDebit return value
checks in draw execution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
prosolis
2026-04-04 09:37:21 -07:00
parent ad6e652755
commit 6c6d74fb1b
10 changed files with 1300 additions and 4 deletions

View File

@@ -0,0 +1,190 @@
package plugin
import (
"encoding/json"
"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 Monday of the current week as "2006-01-02".
func lotteryCurrentWeekStart() string {
now := time.Now().UTC()
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7 // Sunday = 7
}
monday := now.AddDate(0, 0, -(weekday - 1))
return monday.Format("2006-01-02")
}
// ── Ticket CRUD ─────────────────────────────────────────────────────────────
func lotteryTicketCount(userID id.UserID, weekStart string) int {
d := db.Get()
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE user_id = ? AND week_start = ?`,
string(userID), weekStart).Scan(&count)
return count
}
func lotteryTotalTicketCount(weekStart string) int {
d := db.Get()
var count int
_ = d.QueryRow(`SELECT COUNT(*) FROM lottery_tickets WHERE week_start = ?`, weekStart).Scan(&count)
return count
}
func lotteryInsertTickets(userID id.UserID, weekStart string, tickets [][]int) {
d := db.Get()
for _, nums := range tickets {
data, _ := json.Marshal(nums)
_, err := d.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)
}
}
// Each ticket costs €1 — add to community pot.
communityPotAdd(len(tickets))
}
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
}
_ = json.Unmarshal([]byte(winJSON), &h.WinningNumbers)
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
}
_ = json.Unmarshal([]byte(numsJSON), &t.Numbers)
t.MatchCount = matchCount
t.Prize = prize
tickets = append(tickets, t)
}
return tickets, rows.Err()
}