package plugin import ( "fmt" "math" "math/rand/v2" "strings" "sync" "time" "gogobee/internal/db" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" ) // --------------------------------------------------------------------------- // Card types // --------------------------------------------------------------------------- type suit int const ( spades suit = iota hearts diamonds clubs ) var suitSymbols = [4]string{"♠", "♥", "♦", "♣"} type card struct { Rank int // 1=Ace, 2-10, 11=J, 12=Q, 13=K Suit suit } func (c card) String() string { ranks := [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"} return ranks[c.Rank] + suitSymbols[c.Suit] } func (c card) value() int { if c.Rank >= 10 { return 10 } return c.Rank // Ace = 1, handled in handValue } type deck struct { cards []card } func newDeck() *deck { d := &deck{cards: make([]card, 0, 52)} for s := spades; s <= clubs; s++ { for r := 1; r <= 13; r++ { d.cards = append(d.cards, card{r, s}) } } // Shuffle rand.Shuffle(len(d.cards), func(i, j int) { d.cards[i], d.cards[j] = d.cards[j], d.cards[i] }) return d } func (d *deck) draw() card { if len(d.cards) == 0 { // Reshuffle a fresh deck if exhausted (extremely rare) *d = *newDeck() } c := d.cards[0] d.cards = d.cards[1:] return c } func handValue(cards []card) (int, bool) { total := 0 aces := 0 for _, c := range cards { if c.Rank == 1 { aces++ total += 11 } else { total += c.value() } } for total > 21 && aces > 0 { total -= 10 aces-- } return total, aces > 0 } func handStr(cards []card) string { parts := make([]string, len(cards)) for i, c := range cards { parts[i] = c.String() } return strings.Join(parts, " ") } func isBlackjack(cards []card) bool { if len(cards) != 2 { return false } v, _ := handValue(cards) return v == 21 } // --------------------------------------------------------------------------- // Blackjack game state // --------------------------------------------------------------------------- type bjPlayer struct { UserID id.UserID Bet float64 Hand []card Done bool Bust bool } func (p *bjPlayer) value() int { v, _ := handValue(p.Hand) return v } type bjTable struct { players []*bjPlayer dealer []card deck *deck joinTimer *time.Timer turnTimer *time.Timer reminderTimers []*time.Timer phase string // "joining", "playing", "done" roomID id.RoomID } // --------------------------------------------------------------------------- // Blackjack config // --------------------------------------------------------------------------- type bjConfig struct { TimeoutSeconds int AutoplayThreshold int MinBet float64 MaxBet float64 DebtLimit float64 } func loadBJConfig() bjConfig { return bjConfig{ TimeoutSeconds: envInt("BLACKJACK_TIMEOUT_SECONDS", 60), AutoplayThreshold: envInt("BLACKJACK_AUTOPLAY_THRESHOLD", 15), MinBet: envFloat("BLACKJACK_MIN_BET", 1), MaxBet: envFloat("BLACKJACK_MAX_BET", 500), DebtLimit: envFloat("BLACKJACK_DEBT_LIMIT", 1000), } } // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- type BlackjackPlugin struct { Base euro *EuroPlugin cfg bjConfig mu sync.Mutex tables map[id.RoomID]*bjTable } func NewBlackjackPlugin(client *mautrix.Client, euro *EuroPlugin) *BlackjackPlugin { return &BlackjackPlugin{ Base: NewBase(client), euro: euro, cfg: loadBJConfig(), tables: make(map[id.RoomID]*bjTable), } } func (p *BlackjackPlugin) Name() string { return "blackjack" } func (p *BlackjackPlugin) Version() string { return "1.1.0" } func (p *BlackjackPlugin) Commands() []CommandDef { return []CommandDef{ {Name: "blackjack", Description: "Start or join a Blackjack table", Usage: "!blackjack €amount | !blackjack deal | !blackjack leave", Category: "Games"}, {Name: "hit", Description: "Take a card in Blackjack", Usage: "!hit", Category: "Games"}, {Name: "stand", Description: "End your turn in Blackjack", Usage: "!stand", Category: "Games"}, {Name: "bjboard", Description: "Blackjack leaderboard", Usage: "!bjboard", Category: "Games"}, } } func (p *BlackjackPlugin) Init() error { return nil } func (p *BlackjackPlugin) OnReaction(_ ReactionContext) error { return nil } func (p *BlackjackPlugin) OnMessage(ctx MessageContext) error { switch { case p.IsCommand(ctx.Body, "bjboard"): if !isGamesRoom(ctx.RoomID) { return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!") } return p.handleBoard(ctx) case p.IsCommand(ctx.Body, "blackjack"): if !isGamesRoom(ctx.RoomID) { return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!") } return p.handleBlackjack(ctx) case p.IsCommand(ctx.Body, "hit"): if !isGamesRoom(ctx.RoomID) { return nil } return p.handleHit(ctx) case p.IsCommand(ctx.Body, "stand"): if !isGamesRoom(ctx.RoomID) { return nil } return p.handleStand(ctx) } return nil } // --------------------------------------------------------------------------- // Blackjack command handlers // --------------------------------------------------------------------------- func (p *BlackjackPlugin) handleBlackjack(ctx MessageContext) error { args := strings.TrimSpace(p.GetArgs(ctx.Body, "blackjack")) if strings.EqualFold(args, "leave") { return p.handleLeave(ctx) } if strings.EqualFold(args, "deal") { return p.handleDeal(ctx) } // Parse bet amount amountStr := strings.TrimPrefix(args, "€") var bet float64 if _, err := fmt.Sscanf(amountStr, "%f", &bet); err != nil || bet <= 0 { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Usage: `!blackjack €amount` (min €%d, max €%d)", int(p.cfg.MinBet), int(p.cfg.MaxBet))) } if bet < p.cfg.MinBet { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Minimum bet is €%d.", int(p.cfg.MinBet))) } // Check balance balance := p.euro.GetBalance(ctx.Sender) maxAvailable := balance + p.cfg.DebtLimit if maxAvailable <= 0 { return p.SendReply(ctx.RoomID, ctx.EventID, "🚫 You're at your debt limit. Earn some euros before playing.") } maxBet := min(p.cfg.MaxBet, maxAvailable) if bet > maxBet { if maxBet < p.cfg.MaxBet { return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("You can bet up to €%d (balance: €%d).", int(maxBet), int(balance))) } _ = p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Max bet is €%d — capping your bet.", int(p.cfg.MaxBet))) bet = p.cfg.MaxBet } p.mu.Lock() defer p.mu.Unlock() table, exists := p.tables[ctx.RoomID] if exists && table.phase == "joining" { // Join existing table for _, pl := range table.players { if pl.UserID == ctx.Sender { return p.SendReply(ctx.RoomID, ctx.EventID, "You're already at the table!") } } if len(table.players) >= 4 { return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full (max 4 players).") } if !p.euro.Debit(ctx.Sender, bet, "blackjack_bet") { return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to place bet.") } table.players = append(table.players, &bjPlayer{UserID: ctx.Sender, Bet: bet}) name := p.DisplayName(ctx.Sender) if len(table.players) >= 4 { _ = p.SendMessage(ctx.RoomID, fmt.Sprintf("🃏 **%s** joins the table! Bet: €%d\nTable is full — dealing!", name, int(bet))) if table.joinTimer != nil { table.joinTimer.Stop() } p.startRound(ctx.RoomID, table) } else { remaining := len(table.players) _ = p.SendMessage(ctx.RoomID, fmt.Sprintf("🃏 **%s** joins the table! Bet: €%d (%d/4 players — `!blackjack deal` to start now)", name, int(bet), remaining)) } return nil } if exists && table.phase == "playing" { return p.SendReply(ctx.RoomID, ctx.EventID, "A round is in progress. Wait for it to finish.") } // Create new table if !p.euro.Debit(ctx.Sender, bet, "blackjack_bet") { return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to place bet.") } table = &bjTable{ players: []*bjPlayer{{UserID: ctx.Sender, Bet: bet}}, deck: newDeck(), phase: "joining", roomID: ctx.RoomID, } p.tables[ctx.RoomID] = table name := p.DisplayName(ctx.Sender) _ = p.SendMessage(ctx.RoomID, fmt.Sprintf("🃏 **%s** opens a Blackjack table! Bet: €%d\nJoin with `!blackjack €amount` or `!blackjack deal` to start now (60s to join)", name, int(bet))) // Start join timer table.joinTimer = time.AfterFunc(60*time.Second, func() { p.mu.Lock() defer p.mu.Unlock() if table.phase == "joining" { p.startRound(ctx.RoomID, table) } }) return nil } func (p *BlackjackPlugin) handleLeave(ctx MessageContext) error { p.mu.Lock() defer p.mu.Unlock() table, exists := p.tables[ctx.RoomID] if !exists { return p.SendReply(ctx.RoomID, ctx.EventID, "No table to leave.") } if table.phase == "playing" { // Mid-round forfeit — player loses their bet player := p.findPlayer(table, ctx.Sender) if player == nil || player.Done { return p.SendReply(ctx.RoomID, ctx.EventID, "You're not in an active hand.") } player.Bust = true player.Done = true name := p.DisplayName(ctx.Sender) _ = p.SendMessage(ctx.RoomID, fmt.Sprintf("🏳️ **%s** forfeits! Bet lost.", name)) p.checkAllDone(ctx.RoomID, table) return nil } if table.phase != "joining" { return p.SendReply(ctx.RoomID, ctx.EventID, "No table to leave.") } for i, pl := range table.players { if pl.UserID == ctx.Sender { // Return bet p.euro.Credit(ctx.Sender, pl.Bet, "blackjack_leave_refund") table.players = append(table.players[:i], table.players[i+1:]...) if len(table.players) == 0 { if table.joinTimer != nil { table.joinTimer.Stop() } delete(p.tables, ctx.RoomID) return p.SendMessage(ctx.RoomID, "🃏 Table closed — all players left.") } name := p.DisplayName(ctx.Sender) return p.SendMessage(ctx.RoomID, fmt.Sprintf("🃏 **%s** left the table. Bet refunded.", name)) } } return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at the table.") } func (p *BlackjackPlugin) handleDeal(ctx MessageContext) error { p.mu.Lock() defer p.mu.Unlock() table, exists := p.tables[ctx.RoomID] if !exists || table.phase != "joining" { return p.SendReply(ctx.RoomID, ctx.EventID, "No table waiting to deal.") } // Only a player at the table can force-start if p.findPlayer(table, ctx.Sender) == nil { return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at the table.") } if table.joinTimer != nil { table.joinTimer.Stop() } _ = p.SendMessage(ctx.RoomID, "🃏 Dealing!") p.startRound(ctx.RoomID, table) return nil } // startRound must be called with p.mu held. func (p *BlackjackPlugin) startRound(roomID id.RoomID, table *bjTable) { table.phase = "playing" // Deal 2 cards to each player and dealer for range 2 { for _, pl := range table.players { pl.Hand = append(pl.Hand, table.deck.draw()) } table.dealer = append(table.dealer, table.deck.draw()) } // Check for immediate blackjacks for _, pl := range table.players { if isBlackjack(pl.Hand) { pl.Done = true } } // Display initial state _ = p.SendMessage(roomID, p.renderTable(table, false)) // Check if all players already have blackjack allDone := true var activeNames []string for _, pl := range table.players { if !pl.Done { allDone = false activeNames = append(activeNames, p.DisplayName(pl.UserID)) } } if allDone { p.playDealer(roomID, table) return } _ = p.SendMessage(roomID, fmt.Sprintf("👉 **%s** — `!hit` or `!stand` (%ds)", strings.Join(activeNames, "**, **"), p.cfg.TimeoutSeconds)) p.startRoundTimer(roomID, table) } func (p *BlackjackPlugin) findPlayer(table *bjTable, userID id.UserID) *bjPlayer { for _, pl := range table.players { if pl.UserID == userID { return pl } } return nil } func (p *BlackjackPlugin) handleHit(ctx MessageContext) error { p.mu.Lock() table, exists := p.tables[ctx.RoomID] if !exists || table.phase != "playing" { p.mu.Unlock() return nil } player := p.findPlayer(table, ctx.Sender) if player == nil || player.Done { p.mu.Unlock() return nil } player.Hand = append(player.Hand, table.deck.draw()) v := player.value() if v > 21 { player.Bust = true player.Done = true name := p.DisplayName(player.UserID) msgs := []string{fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(player.Hand), v)} allDoneMsgs := p.collectAllDone(ctx.RoomID, table) p.mu.Unlock() for _, m := range append(msgs, allDoneMsgs...) { _ = p.SendMessage(ctx.RoomID, m) } return nil } if v == 21 { player.Done = true name := p.DisplayName(player.UserID) msgs := []string{fmt.Sprintf("**%s** has 21! %s", name, handStr(player.Hand))} allDoneMsgs := p.collectAllDone(ctx.RoomID, table) p.mu.Unlock() for _, m := range append(msgs, allDoneMsgs...) { _ = p.SendMessage(ctx.RoomID, m) } return nil } msg := p.renderTable(table, false) p.mu.Unlock() _ = p.SendMessage(ctx.RoomID, msg) return nil } func (p *BlackjackPlugin) handleStand(ctx MessageContext) error { p.mu.Lock() table, exists := p.tables[ctx.RoomID] if !exists || table.phase != "playing" { p.mu.Unlock() return nil } player := p.findPlayer(table, ctx.Sender) if player == nil || player.Done { p.mu.Unlock() return nil } player.Done = true name := p.DisplayName(player.UserID) msgs := []string{fmt.Sprintf("**%s** stands at %d.", name, player.value())} allDoneMsgs := p.collectAllDone(ctx.RoomID, table) p.mu.Unlock() for _, m := range append(msgs, allDoneMsgs...) { _ = p.SendMessage(ctx.RoomID, m) } return nil } // checkAllDone checks if all players are done and triggers the dealer. Must be called with p.mu held. func (p *BlackjackPlugin) checkAllDone(roomID id.RoomID, table *bjTable) { msgs := p.collectAllDone(roomID, table) for _, m := range msgs { _ = p.SendMessage(roomID, m) } } // collectAllDone is like checkAllDone but returns messages instead of sending them. // This allows callers to release the mutex before sending. Must be called with p.mu held. func (p *BlackjackPlugin) collectAllDone(roomID id.RoomID, table *bjTable) []string { var waiting []string for _, pl := range table.players { if !pl.Done { waiting = append(waiting, p.DisplayName(pl.UserID)) } } if len(waiting) > 0 { return []string{fmt.Sprintf("⏳ Waiting on: **%s**", strings.Join(waiting, "**, **"))} } // All players done — stop timers and go to dealer p.stopRoundTimers(table) return p.collectDealer(roomID, table) } // startRoundTimer starts a shared timeout for the round plus reminder nudges. Must be called with p.mu held. func (p *BlackjackPlugin) startRoundTimer(roomID id.RoomID, table *bjTable) { p.stopRoundTimers(table) timeout := p.cfg.TimeoutSeconds // Schedule reminders at 30s and 10s before timeout remindAts := []int{timeout - 30, timeout - 10} for _, delay := range remindAts { if delay < 5 { continue } remaining := timeout - delay t := time.AfterFunc(time.Duration(delay)*time.Second, func() { p.mu.Lock() defer p.mu.Unlock() tbl, exists := p.tables[roomID] if !exists || tbl != table || tbl.phase != "playing" { return } var waiting []string for _, pl := range tbl.players { if !pl.Done { waiting = append(waiting, p.DisplayName(pl.UserID)) } } if len(waiting) > 0 { _ = p.SendMessage(roomID, fmt.Sprintf("⏳ %ds left — still waiting on: **%s**", remaining, strings.Join(waiting, "**, **"))) } }) table.reminderTimers = append(table.reminderTimers, t) } // Main timeout — auto-play all remaining players table.turnTimer = time.AfterFunc(time.Duration(timeout)*time.Second, func() { p.mu.Lock() defer p.mu.Unlock() t, exists := p.tables[roomID] if !exists || t != table || t.phase != "playing" { return } for _, pl := range t.players { if pl.Done { continue } v := pl.value() name := p.DisplayName(pl.UserID) if v >= p.cfg.AutoplayThreshold { _ = p.SendMessage(roomID, fmt.Sprintf("⏱️ **%s** timed out — auto-playing (stand)", name)) pl.Done = true } else { // Keep hitting until they stand or bust for v < p.cfg.AutoplayThreshold { _ = p.SendMessage(roomID, fmt.Sprintf("⏱️ **%s** timed out — auto-playing (hit)", name)) pl.Hand = append(pl.Hand, t.deck.draw()) v = pl.value() if v > 21 { pl.Bust = true pl.Done = true _ = p.SendMessage(roomID, fmt.Sprintf("💥 **%s** busts with %s (%d)!", name, handStr(pl.Hand), v)) break } } if !pl.Done { _ = p.SendMessage(roomID, fmt.Sprintf("⏱️ **%s** auto-stands at %d.", name, v)) pl.Done = true } } } p.playDealer(roomID, t) }) } func (p *BlackjackPlugin) stopRoundTimers(table *bjTable) { if table.turnTimer != nil { table.turnTimer.Stop() table.turnTimer = nil } for _, t := range table.reminderTimers { t.Stop() } table.reminderTimers = nil } // playDealer plays the dealer hand. Must be called with p.mu held. func (p *BlackjackPlugin) playDealer(roomID id.RoomID, table *bjTable) { msgs := p.collectDealer(roomID, table) for _, m := range msgs { _ = p.SendMessage(roomID, m) } } // collectDealer is like playDealer but returns messages. Must be called with p.mu held. func (p *BlackjackPlugin) collectDealer(roomID id.RoomID, table *bjTable) []string { // Check if all players busted allBust := true for _, pl := range table.players { if !pl.Bust { allBust = false break } } if !allBust { // Dealer plays: hit on soft 17, stand on hard 17+, stand on soft 18+ for { v, soft := handValue(table.dealer) if v < 17 || (v == 17 && soft) { table.dealer = append(table.dealer, table.deck.draw()) } else { break } } } return p.collectResolveRound(roomID, table) } func (p *BlackjackPlugin) resolveRound(roomID id.RoomID, table *bjTable) { msgs := p.collectResolveRound(roomID, table) for _, m := range msgs { _ = p.SendMessage(roomID, m) } } // collectResolveRound resolves the round and returns messages. Must be called with p.mu held. func (p *BlackjackPlugin) collectResolveRound(roomID id.RoomID, table *bjTable) []string { table.phase = "done" dealerValue, _ := handValue(table.dealer) dealerBust := dealerValue > 21 dealerBJ := isBlackjack(table.dealer) var sb strings.Builder sb.WriteString("🃏 **Round over!**\n\n") for _, pl := range table.players { name := p.DisplayName(pl.UserID) playerValue := pl.value() playerBJ := isBlackjack(pl.Hand) var result string var payout float64 switch { case pl.Bust: result = "Bust" payout = 0 // Already deducted case playerBJ && dealerBJ: result = "Push (both Blackjack)" payout = pl.Bet // Return bet p.euro.Credit(pl.UserID, payout, "blackjack_push") case playerBJ: result = "Blackjack!" payout = pl.Bet + math.Floor(pl.Bet*1.5) net, _ := communityTax(pl.UserID, payout, 0.05) p.euro.Credit(pl.UserID, net, "blackjack_win") payout = net case dealerBJ: result = "Dealer Blackjack" payout = 0 case dealerBust: result = "Win (dealer bust)!" payout = pl.Bet * 2 net, _ := communityTax(pl.UserID, payout, 0.05) p.euro.Credit(pl.UserID, net, "blackjack_win") payout = net case playerValue > dealerValue: result = fmt.Sprintf("Win! %d vs %d", playerValue, dealerValue) payout = pl.Bet * 2 net, _ := communityTax(pl.UserID, payout, 0.05) p.euro.Credit(pl.UserID, net, "blackjack_win") payout = net case playerValue == dealerValue: result = "Push" payout = pl.Bet p.euro.Credit(pl.UserID, payout, "blackjack_push") default: result = fmt.Sprintf("Loss. %d vs %d", playerValue, dealerValue) payout = 0 } net := payout - pl.Bet newBalance := p.euro.GetBalance(pl.UserID) var netStr string switch { case net > 0: netStr = fmt.Sprintf("+€%d", int(net)) case net < 0: netStr = fmt.Sprintf("-€%d", int(-net)) default: netStr = "€0" } sb.WriteString(fmt.Sprintf("**%s**: %s %s — %s (balance: €%d)\n", name, handStr(pl.Hand), result, netStr, int(newBalance))) // Record score p.recordBJScore(pl.UserID, net) } sb.WriteString(fmt.Sprintf("\nDealer: %s (%d)\n", handStr(table.dealer), dealerValue)) // Stop any pending timers before cleanup p.stopRoundTimers(table) if table.joinTimer != nil { table.joinTimer.Stop() } delete(p.tables, roomID) return []string{sb.String()} } // --------------------------------------------------------------------------- // Display // --------------------------------------------------------------------------- func (p *BlackjackPlugin) renderTable(table *bjTable, showDealer bool) string { var sb strings.Builder sb.WriteString("🃏 **Blackjack** — Round in progress\n\n") for _, pl := range table.players { name := p.DisplayName(pl.UserID) v := pl.value() extra := "" if isBlackjack(pl.Hand) { extra = " — Blackjack!" } else if v > 21 { extra = " — Bust!" } sb.WriteString(fmt.Sprintf("**%s**: %s (%d%s)\n", name, handStr(pl.Hand), v, extra)) } if showDealer { dv, _ := handValue(table.dealer) sb.WriteString(fmt.Sprintf("Dealer: %s (%d)\n", handStr(table.dealer), dv)) } else { // Show first card, hide second if len(table.dealer) >= 2 { sb.WriteString(fmt.Sprintf("Dealer: %s 🂠 (? — one card hidden)\n", table.dealer[0].String())) } } return sb.String() } // --------------------------------------------------------------------------- // Leaderboard and scoring // --------------------------------------------------------------------------- func (p *BlackjackPlugin) handleBoard(ctx MessageContext) error { d := db.Get() rows, err := d.Query( `SELECT user_id, total_earned, games_played, games_won FROM blackjack_scores ORDER BY total_earned DESC LIMIT 10`, ) if err != nil { return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch leaderboard.") } defer rows.Close() var sb strings.Builder sb.WriteString("🃏 **Blackjack Leaderboard**\n\n") rank := 0 for rows.Next() { var userID string var earned float64 var played, won int rows.Scan(&userID, &earned, &played, &won) rank++ name := p.DisplayName(id.UserID(userID)) sb.WriteString(fmt.Sprintf("%d. **%s** — €%d (%d W in %d games)\n", rank, name, int(earned), won, played)) } if rank == 0 { sb.WriteString("No games played yet.") } return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) } func (p *BlackjackPlugin) recordBJScore(userID id.UserID, net float64) { won := 0 if net > 0 { won = 1 } db.Exec("blackjack: record score", `INSERT INTO blackjack_scores (user_id, total_earned, games_played, games_won) VALUES (?, ?, 1, ?) ON CONFLICT(user_id) DO UPDATE SET total_earned = total_earned + ?, games_played = games_played + 1, games_won = games_won + ?`, string(userID), net, won, net, won, ) if net < 0 { recordBotDefeat(userID, "blackjack") } }