Files
gogobee/internal/plugin/holdem_game.go
prosolis 9c6ded13fa Add forex plugin, stability fixes, and async HTTP dispatch
- Add forex plugin (Frankfurter v2 API) with rate lookups, analysis,
  DM-based alerts, and daily cron poll. Backfills 1 year of history
  on startup for moving averages and buy signal scoring.

- Fix bot hang caused by SQLite lock contention in reminder polling:
  rows cursor was held open while writing to the same DB. Collect
  results first, close cursor, then process. Same fix in milkcarton.

- Add sync retry loop so the bot reconnects after network drops
  instead of silently exiting. StopSync() for clean Ctrl+C shutdown.

- Add panic recovery to all dispatch, syncer, and cron paths.

- Make all HTTP-calling plugin commands async (goroutines) so a slow
  or dead external API cannot block the message dispatch pipeline.
  Affects: lookup, stocks, forex, anime, movies, concerts, gaming,
  retro, wotd, urls, howami.

- Extract DisplayName to Base, add db.Exec helper, convert silent
  error discards across the codebase.

- Fix UNO mercy-kill bug (eliminated bot continues playing), adventure
  DM nag spam, stats column mismatch, per-call regex/replacer allocs.

- Update README: forex commands, Finance section, 47 plugins.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 09:22:02 -07:00

282 lines
5.7 KiB
Go

package plugin
import (
"math/rand/v2"
"time"
"github.com/chehsunliu/poker"
"maunium.net/go/mautrix/id"
)
// Street represents the current phase of a Hold'em hand.
type Street int
const (
StreetPreFlop Street = iota
StreetFlop
StreetTurn
StreetRiver
StreetShowdown
)
func (s Street) String() string {
switch s {
case StreetPreFlop:
return "Pre-Flop"
case StreetFlop:
return "Flop"
case StreetTurn:
return "Turn"
case StreetRiver:
return "River"
case StreetShowdown:
return "Showdown"
default:
return "Unknown"
}
}
// PlayerState tracks a player's status within a hand.
type PlayerState int
const (
PlayerActive PlayerState = iota
PlayerFolded
PlayerAllIn
PlayerSatOut
)
// SidePot represents a pot with specific eligible players.
type SidePot struct {
Amount int64
Eligible []id.UserID
}
// HoldemPlayer represents a seated player.
type HoldemPlayer struct {
UserID id.UserID
DisplayName string
Stack int64
OpeningStack int64
Hole [2]poker.Card
Bet int64 // committed this street
TotalBet int64 // committed this hand
State PlayerState
HasActed bool // whether the player has voluntarily acted this street
TipsEnabled bool
SittingOut bool
WantsLeave bool
IsNPC bool
DMRoomID id.RoomID
}
// HoldemGame holds all state for one table.
type HoldemGame struct {
RoomID id.RoomID
Players []*HoldemPlayer
Community []poker.Card
Deck []poker.Card
DeckPos int // position in the deck
Pot int64
SidePots []SidePot
Street Street
DealerIdx int
ActionIdx int
CurrentBet int64
MinRaise int64
SmallBlind int64
BigBlind int64
LastAggressorIdx int
WaitingForPlayers bool
HandInProgress bool
StreetHistory string // action chars for current street (f/c/r/R/a) for CFR policy lookup
actionTimer *time.Timer
warningTimer *time.Timer
idleTimer *time.Timer
idleWarningTimer *time.Timer
}
// newShuffledDeck creates a shuffled 52-card deck.
func newShuffledDeck() []poker.Card {
cards := allCards()
rand.Shuffle(len(cards), func(i, j int) {
cards[i], cards[j] = cards[j], cards[i]
})
return cards
}
// drawCard draws the next card from the deck.
func (g *HoldemGame) drawCard() poker.Card {
c := g.Deck[g.DeckPos]
g.DeckPos++
return c
}
// burnCard discards the top card (standard casino burn).
func (g *HoldemGame) burnCard() {
g.DeckPos++
}
// activePlayers returns players who are Active or AllIn.
func (g *HoldemGame) activePlayers() []*HoldemPlayer {
var result []*HoldemPlayer
for _, p := range g.Players {
if p.State == PlayerActive || p.State == PlayerAllIn {
result = append(result, p)
}
}
return result
}
// activeCount returns the number of Active or AllIn players.
func (g *HoldemGame) activeCount() int {
n := 0
for _, p := range g.Players {
if p.State == PlayerActive || p.State == PlayerAllIn {
n++
}
}
return n
}
// canActCount returns the number of players who can still act (Active with stack > 0).
func (g *HoldemGame) canActCount() int {
n := 0
for _, p := range g.Players {
if p.State == PlayerActive {
n++
}
}
return n
}
// nextActiveIdx returns the next seat index after idx with Active or AllIn state.
func (g *HoldemGame) nextActiveIdx(idx int) int {
n := len(g.Players)
for i := 1; i < n; i++ {
next := (idx + i) % n
p := g.Players[next]
if p.State == PlayerActive || p.State == PlayerAllIn {
return next
}
}
return idx
}
// nextCanActIdx returns the next seat with Active state (can take actions).
func (g *HoldemGame) nextCanActIdx(idx int) int {
n := len(g.Players)
for i := 1; i < n; i++ {
next := (idx + i) % n
if g.Players[next].State == PlayerActive {
return next
}
}
return idx
}
// inHandPlayers returns players participating in the current hand (not SatOut).
func (g *HoldemGame) inHandPlayers() []*HoldemPlayer {
var result []*HoldemPlayer
for _, p := range g.Players {
if p.State != PlayerSatOut {
result = append(result, p)
}
}
return result
}
// playerByUserID finds a player by their Matrix user ID.
func (g *HoldemGame) playerByUserID(uid id.UserID) *HoldemPlayer {
for _, p := range g.Players {
if p.UserID == uid {
return p
}
}
return nil
}
// playerIdx returns the seat index for a user ID, or -1.
func (g *HoldemGame) playerIdx(uid id.UserID) int {
for i, p := range g.Players {
if p.UserID == uid {
return i
}
}
return -1
}
// resetStreetBets clears per-street bet tracking.
func (g *HoldemGame) resetStreetBets() {
for _, p := range g.Players {
p.Bet = 0
p.HasActed = false
}
g.CurrentBet = 0
g.MinRaise = g.BigBlind
g.StreetHistory = ""
}
// collectPot moves all bets into the pot.
func (g *HoldemGame) collectPot() {
for _, p := range g.Players {
g.Pot += p.Bet
p.Bet = 0
}
}
// positionLabel returns BTN/SB/BB/UTG/MP/CO for a seat index.
func (g *HoldemGame) positionLabel(seatIdx int) string {
n := len(g.inHandPlayers())
if n <= 1 {
return ""
}
if seatIdx == g.DealerIdx {
return "BTN"
}
headsUp := n == 2
if headsUp {
// In heads-up, dealer is SB; other is BB.
return "BB"
}
sbIdx := g.nextActiveIdx(g.DealerIdx)
bbIdx := g.nextActiveIdx(sbIdx)
if seatIdx == sbIdx {
return "SB"
}
if seatIdx == bbIdx {
return "BB"
}
utgIdx := g.nextActiveIdx(bbIdx)
if seatIdx == utgIdx {
return "UTG"
}
// Rough labeling for remaining seats.
// Count distance from UTG.
dist := 0
cur := utgIdx
for i := 0; i < n; i++ {
cur = g.nextActiveIdx(cur)
dist++
if cur == seatIdx {
break
}
}
remaining := n - 4 // seats after UTG before BTN
if remaining <= 0 {
return "MP"
}
if dist >= remaining {
return "CO"
}
return "MP"
}