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

385 lines
9.1 KiB
Go

package plugin
import (
"fmt"
"sort"
"maunium.net/go/mautrix/id"
)
// postBlinds posts SB and BB, handling heads-up edge case.
func (g *HoldemGame) postBlinds() (sbIdx, bbIdx int) {
inHand := g.inHandPlayers()
n := len(inHand)
if n == 2 {
// Heads-up: dealer posts SB, other posts BB.
sbIdx = g.DealerIdx
bbIdx = g.nextActiveIdx(g.DealerIdx)
} else {
sbIdx = g.nextActiveIdx(g.DealerIdx)
bbIdx = g.nextActiveIdx(sbIdx)
}
// Post small blind.
sb := g.Players[sbIdx]
sbAmount := g.SmallBlind
if sbAmount > sb.Stack {
sbAmount = sb.Stack
}
sb.Stack -= sbAmount
sb.Bet = sbAmount
sb.TotalBet = sbAmount
if sb.Stack == 0 {
sb.State = PlayerAllIn
}
// Post big blind.
bb := g.Players[bbIdx]
bbAmount := g.BigBlind
if bbAmount > bb.Stack {
bbAmount = bb.Stack
}
bb.Stack -= bbAmount
bb.Bet = bbAmount
bb.TotalBet = bbAmount
if bb.Stack == 0 {
bb.State = PlayerAllIn
}
g.CurrentBet = g.BigBlind
g.MinRaise = g.BigBlind
g.LastAggressorIdx = bbIdx // BB has option
return sbIdx, bbIdx
}
// firstToActPreflop returns the seat index of the first player to act preflop.
func (g *HoldemGame) firstToActPreflop(bbIdx int) int {
inHand := len(g.inHandPlayers())
if inHand == 2 {
// Heads-up: dealer/SB acts first preflop.
return g.DealerIdx
}
// UTG = next active after BB.
return g.nextCanActIdx(bbIdx)
}
// firstToActPostflop returns the first player to act on post-flop streets.
func (g *HoldemGame) firstToActPostflop() int {
// First active player after dealer.
return g.nextCanActIdx(g.DealerIdx)
}
// ActionResult describes what happened after an action.
type ActionResult struct {
Announcement string
HandOver bool // only 1 player remains
StreetOver bool // street betting is complete
AllAllIn bool // all remaining players are all-in
}
// doFold processes a fold action.
func (g *HoldemGame) doFold(seatIdx int) ActionResult {
p := g.Players[seatIdx]
p.State = PlayerFolded
p.HasActed = true
g.StreetHistory += "f"
ann := renderActionAnnouncement(p.DisplayName, "fold", 0)
if g.activeCount() == 1 {
return ActionResult{Announcement: ann, HandOver: true}
}
return ActionResult{
Announcement: ann,
StreetOver: g.isStreetComplete(g.nextCanActIdx(seatIdx)),
AllAllIn: g.canActCount() == 0,
}
}
// doCheck processes a check action. Returns error string if invalid.
func (g *HoldemGame) doCheck(seatIdx int) (ActionResult, string) {
p := g.Players[seatIdx]
if p.Bet < g.CurrentBet {
return ActionResult{}, "You must call, raise, or fold — there's a bet to you."
}
p.HasActed = true
g.StreetHistory += "c"
ann := renderActionAnnouncement(p.DisplayName, "check", 0)
return ActionResult{
Announcement: ann,
StreetOver: g.isStreetComplete(g.nextCanActIdx(seatIdx)),
}, ""
}
// doCall processes a call action. Returns error string if nothing to call.
func (g *HoldemGame) doCall(seatIdx int) (ActionResult, string) {
p := g.Players[seatIdx]
toCall := g.CurrentBet - p.Bet
if toCall <= 0 {
return ActionResult{}, "Nothing to call. Use `!holdem check` instead."
}
if toCall > p.Stack {
toCall = p.Stack
}
p.Stack -= toCall
p.Bet += toCall
p.TotalBet += toCall
p.HasActed = true
action := "call"
if p.Stack == 0 {
p.State = PlayerAllIn
action = "allin"
g.StreetHistory += "a"
} else {
g.StreetHistory += "c"
}
ann := renderActionAnnouncement(p.DisplayName, action, toCall)
if g.activeCount() == 1 {
return ActionResult{Announcement: ann, HandOver: true}, ""
}
return ActionResult{
Announcement: ann,
StreetOver: g.isStreetComplete(g.nextCanActIdx(seatIdx)),
AllAllIn: g.canActCount() == 0,
}, ""
}
// doRaise processes a raise action. raiseTo is the total bet amount.
func (g *HoldemGame) doRaise(seatIdx int, raiseTo int64) (ActionResult, string) {
p := g.Players[seatIdx]
minRaiseTo := g.CurrentBet + g.MinRaise
maxRaiseTo := p.Bet + p.Stack
if raiseTo < minRaiseTo && raiseTo < maxRaiseTo {
return ActionResult{}, fmt.Sprintf("Minimum raise is to €%d.", minRaiseTo)
}
if raiseTo > maxRaiseTo {
return ActionResult{}, fmt.Sprintf("You can raise to at most €%d (your stack).", maxRaiseTo)
}
raiseAmount := raiseTo - p.Bet
actualRaise := raiseTo - g.CurrentBet
p.Stack -= raiseAmount
p.Bet = raiseTo
p.TotalBet += raiseAmount
p.HasActed = true
if actualRaise > 0 {
g.MinRaise = actualRaise
}
g.CurrentBet = raiseTo
g.LastAggressorIdx = seatIdx
action := "raise"
if p.Stack == 0 {
p.State = PlayerAllIn
action = "allin"
g.StreetHistory += "a"
} else {
// Approximate: >=75% of pot is a pot-size raise ('R'), otherwise half-pot ('r').
totalPot := g.Pot
for _, pp := range g.Players {
totalPot += pp.Bet
}
if totalPot > 0 && float64(actualRaise) >= float64(totalPot)*0.75 {
g.StreetHistory += "R"
} else {
g.StreetHistory += "r"
}
}
ann := renderActionAnnouncement(p.DisplayName, action, raiseTo)
return ActionResult{
Announcement: ann,
AllAllIn: g.canActCount() == 0,
}, ""
}
// doAllIn processes an all-in action.
func (g *HoldemGame) doAllIn(seatIdx int) ActionResult {
p := g.Players[seatIdx]
allInAmount := p.Stack
totalBet := p.Bet + allInAmount
p.Stack = 0
p.Bet = totalBet
p.TotalBet += allInAmount
p.State = PlayerAllIn
p.HasActed = true
if totalBet > g.CurrentBet {
actualRaise := totalBet - g.CurrentBet
// Only reopen action if the raise meets the minimum.
// A short all-in (under-raise) does NOT reopen betting.
if actualRaise >= g.MinRaise {
g.MinRaise = actualRaise
g.LastAggressorIdx = seatIdx
}
g.CurrentBet = totalBet
}
g.StreetHistory += "a"
ann := renderActionAnnouncement(p.DisplayName, "allin", totalBet)
if g.activeCount() == 1 {
return ActionResult{Announcement: ann, HandOver: true}
}
return ActionResult{
Announcement: ann,
StreetOver: g.isStreetComplete(g.nextCanActIdx(seatIdx)),
AllAllIn: g.canActCount() == 0,
}
}
// isStreetComplete checks if the betting round is done.
func (g *HoldemGame) isStreetComplete(nextIdx int) bool {
// All active players have matched the current bet and action has returned to the last aggressor.
if g.canActCount() == 0 {
return true
}
// Check if all Active players have matched the bet AND have acted at least once.
// The acted check is critical for preflop: BB posts a blind but hasn't voluntarily
// acted yet, so the street can't end before BB gets their option.
for _, p := range g.Players {
if p.State == PlayerActive && p.Bet != g.CurrentBet {
return false
}
if p.State == PlayerActive && !p.HasActed {
return false
}
}
// If the last aggressor is all-in (can't act), the street is done when
// all active players have matched the bet (already checked above).
if g.Players[g.LastAggressorIdx].State == PlayerAllIn {
return true
}
// Action must have gone around to the last aggressor.
return nextIdx == g.LastAggressorIdx
}
// buildSidePots creates side pots when all-ins are present.
func (g *HoldemGame) buildSidePots() {
// Collect all bets.
g.collectPot()
type betEntry struct {
uid id.UserID
bet int64
}
var entries []betEntry
for _, p := range g.Players {
if p.State != PlayerFolded && p.State != PlayerSatOut {
entries = append(entries, betEntry{p.UserID, p.TotalBet})
}
}
if len(entries) == 0 {
return
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].bet < entries[j].bet
})
var sidePots []SidePot
prevLevel := int64(0)
for _, e := range entries {
if e.bet <= prevLevel {
continue
}
level := e.bet
potSlice := int64(0)
// All players who bet >= level contribute (level - prevLevel) each.
var eligible []id.UserID
for _, p := range g.Players {
if p.State == PlayerFolded || p.State == PlayerSatOut {
// Folded players still contributed up to their TotalBet.
contrib := p.TotalBet - prevLevel
if contrib > level-prevLevel {
contrib = level - prevLevel
}
if contrib > 0 {
potSlice += contrib
}
continue
}
contrib := p.TotalBet - prevLevel
if contrib > level-prevLevel {
contrib = level - prevLevel
}
if contrib > 0 {
potSlice += contrib
}
if p.TotalBet >= level {
eligible = append(eligible, p.UserID)
}
}
if potSlice > 0 {
sidePots = append(sidePots, SidePot{Amount: potSlice, Eligible: eligible})
}
prevLevel = level
}
if len(sidePots) > 0 {
g.SidePots = sidePots
g.Pot = 0
}
}
// returnUncalledBet returns any unmatched portion of a bet to the player.
func (g *HoldemGame) returnUncalledBet() (name string, amount int64) {
// Find the highest and second-highest bets among non-folded players.
var highest, secondHighest int64
var highestIdx int
for i, p := range g.Players {
if p.State == PlayerFolded || p.State == PlayerSatOut {
continue
}
if p.TotalBet > highest {
secondHighest = highest
highest = p.TotalBet
highestIdx = i
} else if p.TotalBet > secondHighest {
secondHighest = p.TotalBet
}
}
excess := highest - secondHighest
if excess > 0 && secondHighest > 0 {
p := g.Players[highestIdx]
p.Stack += excess
p.TotalBet -= excess
// Don't adjust per-street Bet — it may be smaller than the hand-total
// excess, and it's irrelevant since the hand is ending.
return p.DisplayName, excess
}
return "", 0
}