Files
gogobee/internal/plugin/holdem.go
prosolis 42e6e23900 Overhaul hold'em tips: solver-backed scenarios, equity ranges, validation suite
Replaces hardcoded tip scenarios with solver-frequency-backed decisions, adds
equity range display, fixes bet-size matching tolerance (25% threshold), and
adds comprehensive test coverage for scenario validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 01:06:50 -07:00

1264 lines
32 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"time"
"gogobee/internal/db"
"github.com/chehsunliu/poker"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
)
// holdemConfig holds configurable parameters.
type holdemConfig struct {
SmallBlind int64
BigBlind int64
MinBuyin int64
MaxBuyin int64
TimeoutSeconds int
NPCName string
NPCHouseBalance int64
}
func loadHoldemConfig() holdemConfig {
return holdemConfig{
SmallBlind: int64(envInt("HOLDEM_SMALL_BLIND", 10)),
BigBlind: int64(envInt("HOLDEM_BIG_BLIND", 20)),
MinBuyin: int64(envInt("HOLDEM_MIN_BUYIN", 200)),
MaxBuyin: int64(envInt("HOLDEM_MAX_BUYIN", 2000)),
TimeoutSeconds: envInt("HOLDEM_TIMEOUT_SECONDS", 90),
NPCName: envOrDefault("HOLDEM_NPC_NAME", "TwinBee"),
NPCHouseBalance: int64(envInt("HOLDEM_NPC_HOUSE_BALANCE", 10000)),
}
}
// HoldemPlugin is the Texas Hold'em game plugin.
type HoldemPlugin struct {
Base
euro *EuroPlugin
cfg holdemConfig
mu sync.Mutex
games map[id.RoomID]*HoldemGame
policy PolicyTable
}
// NewHoldemPlugin creates a new Texas Hold'em plugin.
func NewHoldemPlugin(client *mautrix.Client, euro *EuroPlugin) *HoldemPlugin {
return &HoldemPlugin{
Base: NewBase(client),
euro: euro,
cfg: loadHoldemConfig(),
games: make(map[id.RoomID]*HoldemGame),
}
}
func (p *HoldemPlugin) Name() string { return "holdem" }
func (p *HoldemPlugin) Version() string { return "2.1.0" }
func (p *HoldemPlugin) Commands() []CommandDef {
return []CommandDef{
{Name: "holdem", Description: "Texas Hold'em poker", Usage: "!holdem join/start/fold/check/call/raise/allin/leave/help", Category: "Games"},
}
}
func (p *HoldemPlugin) Init() error {
// Try to load pre-trained CFR policy.
policyPath := envOrDefault("HOLDEM_CFR_POLICY", "data/policy.gob")
policy, err := LoadPolicy(policyPath)
if err != nil {
slog.Warn("holdem: no CFR policy loaded, NPC will use fallback strategy", "err", err)
p.policy = make(PolicyTable)
} else {
p.policy = policy
}
return nil
}
func (p *HoldemPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
if !p.IsCommand(ctx.Body, "holdem") {
return nil
}
args := strings.TrimSpace(p.GetArgs(ctx.Body, "holdem"))
// Check if this is a DM command.
isDM := p.isDMRoom(ctx.RoomID)
// DM-only commands.
if strings.HasPrefix(args, "tips") {
return p.handleTipsToggle(ctx, args, isDM)
}
// If this is a DM, resolve the player's game room so actions route correctly.
// Clear EventID so replies go as plain messages — the room can't resolve DM events.
if isDM {
p.mu.Lock()
gameRoom := p.findGameRoom(ctx.Sender)
p.mu.Unlock()
if gameRoom != "" {
ctx.RoomID = gameRoom
ctx.EventID = ""
}
}
// Room-only commands need games room check.
if !isDM && !isGamesRoom(ctx.RoomID) {
gr := gamesRoom()
if gr != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Games are only available in the games channel!")
}
}
switch {
case args == "play" || strings.HasPrefix(args, "play "):
return p.handlePlay(ctx, strings.TrimSpace(strings.TrimPrefix(args, "play")))
case args == "join" || strings.HasPrefix(args, "join "):
return p.handleJoin(ctx, strings.TrimSpace(strings.TrimPrefix(args, "join")))
case args == "leave":
return p.handleLeave(ctx)
case args == "start":
return p.handleStart(ctx)
case args == "fold":
return p.handleAction(ctx, "fold", 0)
case args == "check":
return p.handleAction(ctx, "check", 0)
case args == "call":
return p.handleAction(ctx, "call", 0)
case strings.HasPrefix(args, "raise"):
return p.handleRaise(ctx, args)
case args == "allin":
return p.handleAction(ctx, "allin", 0)
case args == "status":
return p.handleStatus(ctx)
case args == "help":
return p.SendReply(ctx.RoomID, ctx.EventID, renderHelpMessage())
case args == "addbot":
return p.handleAddBot(ctx)
default:
return p.SendReply(ctx.RoomID, ctx.EventID, "Unknown command. Try `!holdem help`.")
}
}
func (p *HoldemPlugin) isDMRoom(roomID id.RoomID) bool {
// DM rooms typically have exactly 2 members.
members := p.RoomMembers(roomID)
return len(members) <= 2
}
// findGameRoom returns the game room ID for a player, or empty if not found.
// Must be called with p.mu held.
func (p *HoldemPlugin) findGameRoom(userID id.UserID) id.RoomID {
for _, game := range p.games {
if game.playerByUserID(userID) != nil {
return game.RoomID
}
}
return ""
}
func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bool) error {
if !isDM {
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.")
}
if p.IsAdmin(ctx.Sender) {
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips are always on for admins. 🐝")
}
parts := strings.Fields(args)
if len(parts) < 2 {
pref := loadTipsPref(ctx.Sender)
status := "on"
if !pref {
status = "off"
}
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Tips are currently **%s**. Use `!holdem tips on/off` to change.", status))
}
switch parts[1] {
case "on":
saveTipsPref(ctx.Sender, true)
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips enabled. You'll receive coaching on your turn.")
case "off":
saveTipsPref(ctx.Sender, false)
return p.SendReply(ctx.RoomID, ctx.EventID, "Tips disabled.")
default:
return p.SendReply(ctx.RoomID, ctx.EventID, "Use `!holdem tips on` or `!holdem tips off`.")
}
}
// handlePlay is a shortcut that joins the player, adds the bot, and starts dealing.
func (p *HoldemPlugin) handlePlay(ctx MessageContext, amountStr string) error {
p.mu.Lock()
// If a game already exists with a hand in progress, treat as a regular join.
if game := p.games[ctx.RoomID]; game != nil && game.HandInProgress {
p.mu.Unlock()
return p.handleJoin(ctx, amountStr)
}
// If player is already seated, just try to add bot + start.
if game := p.games[ctx.RoomID]; game != nil && game.playerByUserID(ctx.Sender) != nil {
p.mu.Unlock()
if err := p.handleAddBot(ctx); err != nil {
slog.Warn("holdem: addbot failed", "room", ctx.RoomID, "err", err)
}
return p.handleStart(ctx)
}
p.mu.Unlock()
// Join the table.
if err := p.handleJoin(ctx, amountStr); err != nil {
return err
}
if err := p.handleAddBot(ctx); err != nil {
slog.Warn("holdem: addbot failed", "room", ctx.RoomID, "err", err)
}
// Start dealing.
return p.handleStart(ctx)
}
func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
p.mu.Lock()
defer p.mu.Unlock()
game := p.games[ctx.RoomID]
if game == nil {
// Create a new game lobby.
game = &HoldemGame{
RoomID: ctx.RoomID,
SmallBlind: p.cfg.SmallBlind,
BigBlind: p.cfg.BigBlind,
DealerIdx: -1, // so first nextActiveIdx lands on seat 0
WaitingForPlayers: true,
}
p.games[ctx.RoomID] = game
p.startIdleTimer(game)
}
// Check if already seated.
if game.playerByUserID(ctx.Sender) != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're already at the table.")
}
// Max 9 players.
if len(game.Players) >= 9 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full (9 players max).")
}
// Check balance.
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(p.cfg.MinBuyin) {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Minimum buy-in is €%d. Your balance: €%.0f.", p.cfg.MinBuyin, balance))
}
// Resolve DM room.
dmRoom, err := p.GetDMRoom(ctx.Sender)
if err != nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't open a DM with you. Check your privacy settings.")
}
// Get display name.
name := p.DisplayName(ctx.Sender)
// Determine buy-in: player-specified, or max buy-in, capped by balance.
var buyin int64
if amountStr != "" {
// Strip € prefix if present.
amountStr = strings.TrimPrefix(amountStr, "€")
requested, err := strconv.ParseInt(amountStr, 10, 64)
if err != nil || requested <= 0 {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Usage: `!holdem join [amount]` — amount between €%d and €%d.", p.cfg.MinBuyin, p.cfg.MaxBuyin))
}
if requested < p.cfg.MinBuyin {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Minimum buy-in is €%d.", p.cfg.MinBuyin))
}
if requested > p.cfg.MaxBuyin {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("Maximum buy-in is €%d.", p.cfg.MaxBuyin))
}
if requested > int64(balance) {
return p.SendReply(ctx.RoomID, ctx.EventID,
fmt.Sprintf("You only have €%.0f.", balance))
}
buyin = requested
} else {
buyin = p.cfg.MaxBuyin
if int64(balance) < buyin {
buyin = int64(balance)
}
}
// Debit buy-in immediately to prevent double-spending across tables.
if !p.euro.Debit(ctx.Sender, float64(buyin), "holdem_buyin") {
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reserve buy-in.")
}
state := PlayerActive
sittingOut := false
if game.HandInProgress {
state = PlayerSatOut
sittingOut = true
}
player := &HoldemPlayer{
UserID: ctx.Sender,
DisplayName: name,
Stack: buyin,
OpeningStack: buyin,
State: state,
TipsEnabled: loadTipsPref(ctx.Sender) || p.IsAdmin(ctx.Sender),
SittingOut: sittingOut,
DMRoomID: dmRoom,
}
game.Players = append(game.Players, player)
if sittingOut {
return p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** will join the next hand. (€%d buy-in)", name, buyin))
}
return p.SendMessage(ctx.RoomID,
fmt.Sprintf("**%s** joined the table. (€%d buy-in) — %d players seated. Use `!holdem start` when ready.",
name, buyin, len(game.Players)))
}
func (p *HoldemPlugin) handleLeave(ctx MessageContext) error {
p.mu.Lock()
defer p.mu.Unlock()
game := p.games[ctx.RoomID]
if game == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No active game here.")
}
player := game.playerByUserID(ctx.Sender)
if player == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at the table.")
}
if !game.HandInProgress || player.SittingOut {
// Credit remaining stack back (buy-in was debited at join).
cashout := player.Stack
if !player.IsNPC && cashout > 0 {
net, _ := communityTax(player.UserID, float64(cashout), 0.05)
p.euro.Credit(player.UserID, net, "holdem_cashout")
}
// Remove immediately.
p.removePlayer(game, ctx.Sender)
p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** has left the table. (€%d returned)", player.DisplayName, cashout))
if len(game.Players) == 0 {
delete(p.games, ctx.RoomID)
}
return nil
}
idx := game.playerIdx(ctx.Sender)
if idx == game.ActionIdx {
// It's their turn — fold and remove.
p.stopActionTimer(game)
game.doFold(idx)
p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** folds and leaves the table.", player.DisplayName))
// Credit remaining stack back (buy-in was debited at join).
if player.Stack > 0 {
net, _ := communityTax(player.UserID, float64(player.Stack), 0.05)
p.euro.Credit(player.UserID, net, "holdem_cashout")
}
p.removePlayer(game, ctx.Sender)
if game.activeCount() <= 1 {
p.finishHand(game)
} else {
p.advanceAction(game)
}
return nil
}
// Not their turn — flag for removal.
player.WantsLeave = true
return p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** will leave after this hand.", player.DisplayName))
}
func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
p.mu.Lock()
defer p.mu.Unlock()
game := p.games[ctx.RoomID]
if game == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No players seated. Use `!holdem join` first.")
}
if game.HandInProgress {
return p.SendReply(ctx.RoomID, ctx.EventID, "A hand is already in progress.")
}
// Count non-sitting-out players.
ready := 0
for _, pl := range game.Players {
if !pl.SittingOut {
ready++
}
}
if ready < 2 {
hint := " Use `!holdem join`."
if ready == 1 {
hint = " Use `!holdem addbot` to add an AI opponent."
}
return p.SendReply(ctx.RoomID, ctx.EventID, "Need at least 2 players to start."+hint)
}
p.startHand(game)
return nil
}
func (p *HoldemPlugin) handleRaise(ctx MessageContext, args string) error {
parts := strings.Fields(args)
if len(parts) < 2 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!holdem raise <amount>`")
}
amtStr := strings.TrimPrefix(parts[1], "€")
amount, err := strconv.ParseInt(amtStr, 10, 64)
if err != nil || amount <= 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Invalid amount. Usage: `!holdem raise <amount>`")
}
return p.handleAction(ctx, "raise", amount)
}
func (p *HoldemPlugin) handleAction(ctx MessageContext, action string, amount int64) error {
p.mu.Lock()
defer p.mu.Unlock()
game := p.games[ctx.RoomID]
if game == nil || !game.HandInProgress {
return p.SendReply(ctx.RoomID, ctx.EventID, "No hand in progress.")
}
seatIdx := game.playerIdx(ctx.Sender)
if seatIdx < 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at this table.")
}
if seatIdx != game.ActionIdx {
return p.SendReply(ctx.RoomID, ctx.EventID, "It's not your turn.")
}
player := game.Players[seatIdx]
if player.State != PlayerActive {
return p.SendReply(ctx.RoomID, ctx.EventID, "You can't act right now.")
}
var result ActionResult
var errMsg string
switch action {
case "fold":
result = game.doFold(seatIdx)
case "check":
result, errMsg = game.doCheck(seatIdx)
case "call":
result, errMsg = game.doCall(seatIdx)
case "raise":
result, errMsg = game.doRaise(seatIdx, amount)
case "allin":
result = game.doAllIn(seatIdx)
}
if errMsg != "" {
return p.SendReply(ctx.RoomID, ctx.EventID, errMsg)
}
// Reset action timer.
p.stopActionTimer(game)
// Broadcast action to all players via DM.
p.broadcastDM(game, result.Announcement)
if result.HandOver {
p.finishHand(game)
return nil
}
if result.AllAllIn {
p.runOutBoard(game)
return nil
}
if result.StreetOver {
p.advanceStreet(game)
return nil
}
// Advance to next player.
game.ActionIdx = game.nextCanActIdx(seatIdx)
p.sendTurnNotifications(game)
p.startActionTimer(game)
return nil
}
func (p *HoldemPlugin) handleStatus(ctx MessageContext) error {
p.mu.Lock()
defer p.mu.Unlock()
game := p.games[ctx.RoomID]
if game == nil {
return p.SendReply(ctx.RoomID, ctx.EventID, "No active game here.")
}
seatIdx := game.playerIdx(ctx.Sender)
if seatIdx < 0 {
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not at this table.")
}
player := game.Players[seatIdx]
hand := ""
if game.HandInProgress && player.State != PlayerSatOut {
hand = renderPrivateHand(game, seatIdx) + "\n"
}
view := renderTableView(game, seatIdx)
return p.SendMessage(player.DMRoomID, hand+view)
}
func (p *HoldemPlugin) handleAddBot(ctx MessageContext) error {
p.mu.Lock()
defer p.mu.Unlock()
game := p.games[ctx.RoomID]
if game == nil {
game = &HoldemGame{
RoomID: ctx.RoomID,
SmallBlind: p.cfg.SmallBlind,
BigBlind: p.cfg.BigBlind,
DealerIdx: -1,
WaitingForPlayers: true,
}
p.games[ctx.RoomID] = game
}
// Check if NPC already exists.
for _, pl := range game.Players {
if pl.IsNPC {
return p.SendReply(ctx.RoomID, ctx.EventID, "An AI opponent is already at the table.")
}
}
if len(game.Players) >= 9 {
return p.SendReply(ctx.RoomID, ctx.EventID, "Table is full.")
}
// Get NPC house balance.
npcBalance := p.getNPCBalance()
buyin := npcBalance
if buyin > p.cfg.MaxBuyin {
buyin = p.cfg.MaxBuyin
}
if buyin < p.cfg.MinBuyin {
buyin = p.cfg.MinBuyin
}
npc := &HoldemPlayer{
UserID: id.UserID(fmt.Sprintf("@npc_%s:local", strings.ToLower(p.cfg.NPCName))),
DisplayName: p.cfg.NPCName,
Stack: buyin,
OpeningStack: buyin,
State: PlayerActive,
IsNPC: true,
}
if game.HandInProgress {
npc.State = PlayerSatOut
npc.SittingOut = true
}
game.Players = append(game.Players, npc)
msg := fmt.Sprintf("🤖 **%s** is an AI opponent using a trained poker solver. (€%d buy-in)", p.cfg.NPCName, buyin)
return p.SendMessage(ctx.RoomID, msg)
}
// --- Game lifecycle ---
func (p *HoldemPlugin) startHand(game *HoldemGame) {
p.stopIdleTimer(game)
// Unsit players who were waiting.
for _, pl := range game.Players {
if pl.SittingOut {
pl.SittingOut = false
pl.State = PlayerActive
}
}
// Remove players who want to leave.
// Balance was already settled by settleNetDeltas at end of previous hand,
// and OpeningStack was reset to Stack in endHand, so no settlement needed here.
for i := len(game.Players) - 1; i >= 0; i-- {
if game.Players[i].WantsLeave {
game.Players = append(game.Players[:i], game.Players[i+1:]...)
}
}
if len(game.Players) < 2 {
p.SendMessage(game.RoomID, "Not enough players to start a hand.")
return
}
// Reset game state.
game.HandInProgress = true
game.Street = StreetPreFlop
game.Community = nil
game.SidePots = nil
game.Pot = 0
game.Deck = newShuffledDeck()
game.DeckPos = 0
// Reset player state.
for _, pl := range game.Players {
pl.State = PlayerActive
pl.Bet = 0
pl.TotalBet = 0
pl.HasActed = false
pl.Hole = [2]poker.Card{}
}
// Advance dealer.
game.DealerIdx = game.nextActiveIdx(game.DealerIdx)
// Post blinds.
_, bbIdx := game.postBlinds()
// Deal hole cards (no burn before initial deal — burns are before flop/turn/river only).
for _, pl := range game.Players {
if pl.State != PlayerSatOut {
pl.Hole[0] = game.drawCard()
pl.Hole[1] = game.drawCard()
}
}
// Set first to act.
game.ActionIdx = game.firstToActPreflop(bbIdx)
// Announce to room.
p.SendMessage(game.RoomID, renderStartAnnouncement(game))
// Send hole cards to all players via DM.
// Skip the first-to-act player — they'll get hand + table from sendTurnNotifications.
for i, pl := range game.Players {
if pl.IsNPC || pl.State == PlayerSatOut || i == game.ActionIdx {
continue
}
view := renderPrivateHand(game, i)
p.SendMessage(pl.DMRoomID, view)
}
// Send turn notification.
p.sendTurnNotifications(game)
p.startActionTimer(game)
}
func (p *HoldemPlugin) advanceStreet(game *HoldemGame) {
game.collectPot()
game.resetStreetBets()
switch game.Street {
case StreetPreFlop:
game.Street = StreetFlop
game.burnCard()
game.Community = append(game.Community, game.drawCard(), game.drawCard(), game.drawCard())
case StreetFlop:
game.Street = StreetTurn
game.burnCard()
game.Community = append(game.Community, game.drawCard())
case StreetTurn:
game.Street = StreetRiver
game.burnCard()
game.Community = append(game.Community, game.drawCard())
case StreetRiver:
game.Street = StreetShowdown
p.doShowdown(game)
return
}
// Set action to first active player after dealer.
game.ActionIdx = game.firstToActPostflop()
game.LastAggressorIdx = game.ActionIdx
// Notify all players of new board.
for i, pl := range game.Players {
if pl.IsNPC || pl.State == PlayerSatOut || pl.State == PlayerFolded {
continue
}
view := renderTableView(game, i)
p.SendMessage(pl.DMRoomID, view)
}
// Check if all players are all-in.
if game.canActCount() <= 1 {
if game.canActCount() == 0 {
p.runOutBoard(game)
return
}
// Only 1 player can act — if they're the only non-allin, auto-advance.
if game.activeCount()-game.canActCount() > 0 {
// There are all-in players, run out.
p.runOutBoard(game)
return
}
}
p.sendTurnNotifications(game)
p.startActionTimer(game)
}
func (p *HoldemPlugin) advanceAction(game *HoldemGame) {
if game.activeCount() <= 1 {
p.finishHand(game)
return
}
if game.canActCount() == 0 {
p.runOutBoard(game)
return
}
// Find next active player.
game.ActionIdx = game.nextCanActIdx(game.ActionIdx)
if game.isStreetComplete(game.ActionIdx) {
p.advanceStreet(game)
return
}
p.sendTurnNotifications(game)
p.startActionTimer(game)
}
func (p *HoldemPlugin) runOutBoard(game *HoldemGame) {
game.collectPot()
// Build side pots if needed.
hasAllIn := false
for _, pl := range game.Players {
if pl.State == PlayerAllIn {
hasAllIn = true
break
}
}
if hasAllIn {
game.buildSidePots()
}
// Deal remaining community cards.
for game.Street < StreetShowdown {
switch game.Street {
case StreetPreFlop:
game.Street = StreetFlop
game.burnCard()
game.Community = append(game.Community, game.drawCard(), game.drawCard(), game.drawCard())
case StreetFlop:
game.Street = StreetTurn
game.burnCard()
game.Community = append(game.Community, game.drawCard())
case StreetTurn:
game.Street = StreetRiver
game.burnCard()
game.Community = append(game.Community, game.drawCard())
case StreetRiver:
game.Street = StreetShowdown
}
// Brief notification with new board.
boardMsg := fmt.Sprintf("**%s** — Board: %s", game.Street.String(), renderCards(game.Community))
p.broadcastDM(game, boardMsg)
}
p.doShowdown(game)
}
func (p *HoldemPlugin) doShowdown(game *HoldemGame) {
game.collectPot()
// Build side pots if needed.
hasAllIn := false
for _, pl := range game.Players {
if pl.State == PlayerAllIn {
hasAllIn = true
break
}
}
if hasAllIn && len(game.SidePots) == 0 {
game.buildSidePots()
}
results, winnings := runShowdown(game)
// Track bot defeats.
for _, pl := range game.Players {
if pl.IsNPC {
continue
}
won := winnings[pl.UserID]
if won == 0 && pl.State != PlayerFolded {
// Lost at showdown — check if any NPC won.
for _, npl := range game.Players {
if npl.IsNPC && winnings[npl.UserID] > 0 {
recordBotDefeat(pl.UserID, "holdem")
break
}
}
}
}
// Post end announcement to room.
endAnn := renderEndAnnouncement(results, game)
p.SendMessage(game.RoomID, endAnn)
// Settle balances.
settleNetDeltas(game, p.euro)
// Update NPC balance.
for _, pl := range game.Players {
if pl.IsNPC {
delta := pl.Stack - pl.OpeningStack
p.updateNPCBalance(delta)
}
}
// Record scores.
p.recordScores(game, winnings)
p.endHand(game)
}
func (p *HoldemPlugin) finishHand(game *HoldemGame) {
p.stopActionTimer(game)
// Return uncalled bet.
name, amount := game.returnUncalledBet()
if amount > 0 {
msg := renderUncalledBetReturn(name, amount)
p.broadcastDM(game, msg)
}
// Award pot to last remaining player.
ann, winnerID := awardPotToLastPlayer(game)
if ann != "" {
p.SendMessage(game.RoomID, ann)
}
// Track bot defeats (if NPC won by everyone folding).
for _, pl := range game.Players {
if pl.IsNPC {
continue
}
if pl.UserID != winnerID && pl.State == PlayerFolded {
for _, npl := range game.Players {
if npl.IsNPC && npl.UserID == winnerID {
recordBotDefeat(pl.UserID, "holdem")
break
}
}
}
}
// Settle balances.
settleNetDeltas(game, p.euro)
// Update NPC balance.
for _, pl := range game.Players {
if pl.IsNPC {
delta := pl.Stack - pl.OpeningStack
p.updateNPCBalance(delta)
}
}
p.endHand(game)
}
func (p *HoldemPlugin) endHand(game *HoldemGame) {
p.stopActionTimer(game)
game.HandInProgress = false
game.Street = StreetPreFlop
// Remove players who want to leave or busted out.
// Credit their remaining stack (buy-in was debited at join).
for i := len(game.Players) - 1; i >= 0; i-- {
pl := game.Players[i]
if pl.WantsLeave || pl.Stack <= 0 {
if !pl.IsNPC {
if pl.Stack > 0 {
net, _ := communityTax(pl.UserID, float64(pl.Stack), 0.05)
p.euro.Credit(pl.UserID, net, "holdem_cashout")
}
p.SendMessage(game.RoomID, fmt.Sprintf("**%s** has left the table.", pl.DisplayName))
}
game.Players = append(game.Players[:i], game.Players[i+1:]...)
}
}
// Reset opening stacks for next hand.
for _, pl := range game.Players {
pl.OpeningStack = pl.Stack
}
if len(game.Players) < 2 {
// Cash out remaining players.
for _, pl := range game.Players {
if !pl.IsNPC && pl.Stack > 0 {
net, _ := communityTax(pl.UserID, float64(pl.Stack), 0.05)
p.euro.Credit(pl.UserID, net, "holdem_cashout")
}
}
p.SendMessage(game.RoomID, "Not enough players for another hand. Game over.")
delete(p.games, game.RoomID)
return
}
p.SendMessage(game.RoomID, fmt.Sprintf("Hand complete. %d players at the table. Type `!holdem start` for the next hand.", len(game.Players)))
p.startIdleTimer(game)
}
// --- Notifications ---
func (p *HoldemPlugin) sendTurnNotifications(game *HoldemGame) {
actionPlayer := game.Players[game.ActionIdx]
if actionPlayer.IsNPC {
// NPC acts automatically.
go p.npcAct(game)
return
}
// Send table view + private hand to action player.
view := renderTableView(game, game.ActionIdx)
hand := renderPrivateHand(game, game.ActionIdx)
p.SendMessage(actionPlayer.DMRoomID, hand+"\n"+view)
// Generate tip asynchronously — snapshot under lock, compute equity + generate outside.
if actionPlayer.TipsEnabled {
snap := snapshotForTip(game, game.ActionIdx)
dmRoom := actionPlayer.DMRoomID
safeGo("holdem-tip", func() {
tipCtx := buildTipContext(snap)
tip := generateTip(tipCtx)
p.SendMessage(dmRoom, tip)
})
}
}
func (p *HoldemPlugin) broadcastDM(game *HoldemGame, msg string) {
for _, pl := range game.Players {
if pl.IsNPC || pl.State == PlayerSatOut {
continue
}
p.SendMessage(pl.DMRoomID, msg)
}
}
// --- NPC ---
func (p *HoldemPlugin) npcAct(game *HoldemGame) {
// Compute action under lock (reads game state safely).
p.mu.Lock()
if !game.HandInProgress {
p.mu.Unlock()
return
}
npcIdx := game.ActionIdx
action, delay := NPCChooseAction(p.policy, game, npcIdx)
p.mu.Unlock()
// Human-like delay outside the lock.
time.Sleep(delay)
p.mu.Lock()
defer p.mu.Unlock()
// Verify game state hasn't changed.
if !game.HandInProgress || game.ActionIdx != npcIdx {
return
}
// Stop the action timer so it can't fire while we process.
p.stopActionTimer(game)
actionStr, amount := cfrActionToGameAction(action, game, npcIdx)
var result ActionResult
switch actionStr {
case "fold":
result = game.doFold(npcIdx)
case "check":
result, _ = game.doCheck(npcIdx)
case "call":
result, _ = game.doCall(npcIdx)
case "raise":
result, _ = game.doRaise(npcIdx, amount)
case "allin":
result = game.doAllIn(npcIdx)
}
p.broadcastDM(game, result.Announcement)
if result.HandOver {
p.finishHand(game)
return
}
if result.AllAllIn {
p.runOutBoard(game)
return
}
if result.StreetOver {
p.advanceStreet(game)
return
}
game.ActionIdx = game.nextCanActIdx(npcIdx)
p.sendTurnNotifications(game)
p.startActionTimer(game)
}
// --- Timers ---
func (p *HoldemPlugin) startActionTimer(game *HoldemGame) {
p.stopActionTimer(game)
timeout := time.Duration(p.cfg.TimeoutSeconds) * time.Second
actionIdx := game.ActionIdx
roomID := game.RoomID
// Warning at 30s before timeout (if timeout > 35s).
if p.cfg.TimeoutSeconds > 35 {
warningDelay := timeout - 30*time.Second
player := game.Players[actionIdx]
dmRoom := player.DMRoomID
game.warningTimer = time.AfterFunc(warningDelay, func() {
p.mu.Lock()
defer p.mu.Unlock()
g := p.games[roomID]
if g == nil || !g.HandInProgress || g.ActionIdx != actionIdx {
return
}
pl := g.Players[actionIdx]
if pl.IsNPC {
return
}
p.SendMessage(dmRoom, "⏰ **30 seconds** remaining to act!")
})
}
game.actionTimer = time.AfterFunc(timeout, func() {
p.mu.Lock()
defer p.mu.Unlock()
g := p.games[roomID]
if g == nil || !g.HandInProgress || g.ActionIdx != actionIdx {
return
}
player := g.Players[actionIdx]
if player.IsNPC {
return
}
// Auto-action: check if possible, otherwise fold.
toCall := g.CurrentBet - player.Bet
if toCall <= 0 {
result, _ := g.doCheck(actionIdx)
p.broadcastDM(g, "⏰ "+result.Announcement+" (timeout)")
} else {
result := g.doFold(actionIdx)
p.broadcastDM(g, "⏰ "+result.Announcement+" (timeout)")
}
if g.activeCount() <= 1 {
p.finishHand(g)
return
}
if g.canActCount() == 0 {
p.runOutBoard(g)
return
}
g.ActionIdx = g.nextCanActIdx(actionIdx)
if g.isStreetComplete(g.ActionIdx) {
p.advanceStreet(g)
return
}
p.sendTurnNotifications(g)
p.startActionTimer(g)
})
}
func (p *HoldemPlugin) stopActionTimer(game *HoldemGame) {
if game.warningTimer != nil {
game.warningTimer.Stop()
game.warningTimer = nil
}
if game.actionTimer != nil {
game.actionTimer.Stop()
game.actionTimer = nil
}
}
// startIdleTimer starts a 1-hour timer that closes the table if no hand is started.
// A warning is sent at the 45-minute mark.
// Call when a game lobby is created or when a hand ends.
func (p *HoldemPlugin) startIdleTimer(game *HoldemGame) {
p.stopIdleTimer(game)
roomID := game.RoomID
// Warning at 45 minutes.
game.idleWarningTimer = time.AfterFunc(45*time.Minute, func() {
p.mu.Lock()
defer p.mu.Unlock()
g := p.games[roomID]
if g == nil || g.HandInProgress {
return
}
p.SendMessage(roomID, "⏰ Table will close in **15 minutes** due to inactivity. Type `!holdem start` to play or `!holdem leave` to cash out.")
})
game.idleTimer = time.AfterFunc(1*time.Hour, func() {
p.mu.Lock()
defer p.mu.Unlock()
g := p.games[roomID]
if g == nil || g.HandInProgress {
return
}
// Refund all players and close the table.
for _, pl := range g.Players {
if !pl.IsNPC && pl.Stack > 0 {
p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_idle_refund")
}
}
p.SendMessage(roomID, "Table closed — 1 hour of inactivity. All buy-ins have been refunded.")
delete(p.games, roomID)
})
}
func (p *HoldemPlugin) stopIdleTimer(game *HoldemGame) {
if game.idleWarningTimer != nil {
game.idleWarningTimer.Stop()
game.idleWarningTimer = nil
}
if game.idleTimer != nil {
game.idleTimer.Stop()
game.idleTimer = nil
}
}
// --- Helpers ---
func (p *HoldemPlugin) removePlayer(game *HoldemGame, uid id.UserID) {
for i, pl := range game.Players {
if pl.UserID == uid {
game.Players = append(game.Players[:i], game.Players[i+1:]...)
// Adjust seat indices that reference positions after the removed player.
// Only decrement when strictly greater — if equal, the next player
// slides into the same index so no adjustment is needed.
if game.DealerIdx > i {
game.DealerIdx--
}
if game.ActionIdx > i {
game.ActionIdx--
}
if game.LastAggressorIdx > i {
game.LastAggressorIdx--
}
// Wrap indices if they exceed the new length.
if n := len(game.Players); n > 0 {
game.DealerIdx %= n
game.ActionIdx %= n
game.LastAggressorIdx %= n
}
return
}
}
}
func (p *HoldemPlugin) getNPCBalance() int64 {
d := db.Get()
var balance int64
err := d.QueryRow(`SELECT balance FROM holdem_npc_balance WHERE npc_name = ?`, p.cfg.NPCName).Scan(&balance)
if err != nil {
// Initialize.
db.Exec("holdem: init npc balance",
`INSERT OR IGNORE INTO holdem_npc_balance (npc_name, balance) VALUES (?, ?)`,
p.cfg.NPCName, p.cfg.NPCHouseBalance)
return p.cfg.NPCHouseBalance
}
return balance
}
func (p *HoldemPlugin) updateNPCBalance(delta int64) {
db.Exec("holdem: update npc balance",
`UPDATE holdem_npc_balance SET balance = balance + ?, hands_played = hands_played + 1 WHERE npc_name = ?`,
delta, p.cfg.NPCName)
}
// resetNPCHouseBalance resets all NPC balances to the configured house balance.
// Called daily at midnight.
func resetNPCHouseBalance() {
balance := int64(envInt("HOLDEM_NPC_HOUSE_BALANCE", 10000))
db.Exec("holdem: reset npc balance",
`UPDATE holdem_npc_balance SET balance = ?`, balance)
}
func (p *HoldemPlugin) recordScores(game *HoldemGame, winnings map[id.UserID]int64) {
for _, pl := range game.Players {
if pl.IsNPC {
continue
}
won := winnings[pl.UserID]
lost := int64(0)
delta := pl.Stack - pl.OpeningStack
if delta < 0 {
lost = -delta
}
biggestPot := game.Pot
for _, sp := range game.SidePots {
if sp.Amount > biggestPot {
biggestPot = sp.Amount
}
}
db.Exec("holdem: record player score",
`INSERT INTO holdem_scores (user_id, hands_played, total_won, total_lost, biggest_pot)
VALUES (?, 1, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
hands_played = hands_played + 1,
total_won = total_won + ?,
total_lost = total_lost + ?,
biggest_pot = MAX(biggest_pot, ?)`,
string(pl.UserID), won, lost, biggestPot, won, lost, biggestPot,
)
}
}