Files
gogobee/internal/plugin/holdem.go
prosolis e22d4f8362 Hold'em: route sender-private replies to DM origin + jitter broadcasts
Two changes that were running in prod but hadn't been committed:

1. Sender-private DM routing. When a player invokes a holdem command from
   their DM, the dispatcher rewrites ctx.RoomID to the game room so
   actions affect the right table. But sender-private replies (errors,
   help, status, "unknown command") would then post to the public games
   room instead of back to the DM that asked. Now: the dispatcher saves
   ctx.OriginRoomID = the DM, and a new reply() helper routes sender-
   private messages to OriginRoomID when set, falling back to in-thread
   reply otherwise. (OriginRoomID was added to MessageContext earlier.)

2. broadcastDM jitter. 150-400ms sleep between sends to avoid Matrix
   rate-limit bursts when a game has 4+ players each in their own DM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 22:37:19 -07:00

1292 lines
32 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
import (
"fmt"
"log/slog"
"math/rand/v2"
"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.
// Save the original DM room into OriginRoomID so sender-private replies (errors,
// help, status) route back via DM rather than broadcasting to the games room.
if isDM {
p.mu.Lock()
gameRoom := p.findGameRoom(ctx.Sender)
p.mu.Unlock()
if gameRoom != "" {
ctx.OriginRoomID = ctx.RoomID
ctx.RoomID = gameRoom
ctx.EventID = ""
}
}
// Room-only commands need games room check.
if !isDM && !isGamesRoom(ctx.RoomID) {
gr := gamesRoom()
if gr != "" {
return p.reply(ctx, "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.reply(ctx, renderHelpMessage())
case args == "addbot":
return p.handleAddBot(ctx)
default:
return p.reply(ctx, "Unknown command. Try `!holdem help`.")
}
}
// reply routes sender-private responses to the originating DM when the command
// came from a DM (ctx.OriginRoomID set), otherwise replies in-thread.
func (p *HoldemPlugin) reply(ctx MessageContext, msg string) error {
if ctx.OriginRoomID != "" {
return p.SendMessage(ctx.OriginRoomID, msg)
}
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}
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.reply(ctx, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.")
}
if p.IsAdmin(ctx.Sender) {
return p.reply(ctx, "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.reply(ctx, 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.reply(ctx, "Tips enabled. You'll receive coaching on your turn.")
case "off":
saveTipsPref(ctx.Sender, false)
return p.reply(ctx, "Tips disabled.")
default:
return p.reply(ctx, "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.reply(ctx, "You're already at the table.")
}
// Max 9 players.
if len(game.Players) >= 9 {
return p.reply(ctx, "Table is full (9 players max).")
}
// Check balance.
balance := p.euro.GetBalance(ctx.Sender)
if balance < float64(p.cfg.MinBuyin) {
return p.reply(ctx,
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.reply(ctx, "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.reply(ctx,
fmt.Sprintf("Usage: `!holdem join [amount]` — amount between €%d and €%d.", p.cfg.MinBuyin, p.cfg.MaxBuyin))
}
if requested < p.cfg.MinBuyin {
return p.reply(ctx,
fmt.Sprintf("Minimum buy-in is €%d.", p.cfg.MinBuyin))
}
if requested > p.cfg.MaxBuyin {
return p.reply(ctx,
fmt.Sprintf("Maximum buy-in is €%d.", p.cfg.MaxBuyin))
}
if requested > int64(balance) {
return p.reply(ctx,
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.reply(ctx, "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.reply(ctx, "No active game here.")
}
player := game.playerByUserID(ctx.Sender)
if player == nil {
return p.reply(ctx, "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.reply(ctx, "No players seated. Use `!holdem join` first.")
}
if game.HandInProgress {
return p.reply(ctx, "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.reply(ctx, "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.reply(ctx, "Usage: `!holdem raise <amount>`")
}
amtStr := strings.TrimPrefix(parts[1], "€")
amount, err := strconv.ParseInt(amtStr, 10, 64)
if err != nil || amount <= 0 {
return p.reply(ctx, "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.reply(ctx, "No hand in progress.")
}
seatIdx := game.playerIdx(ctx.Sender)
if seatIdx < 0 {
return p.reply(ctx, "You're not at this table.")
}
if seatIdx != game.ActionIdx {
return p.reply(ctx, "It's not your turn.")
}
player := game.Players[seatIdx]
if player.State != PlayerActive {
return p.reply(ctx, "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.reply(ctx, 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.reply(ctx, "No active game here.")
}
seatIdx := game.playerIdx(ctx.Sender)
if seatIdx < 0 {
return p.reply(ctx, "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.reply(ctx, "An AI opponent is already at the table.")
}
}
if len(game.Players) >= 9 {
return p.reply(ctx, "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 and DM each player.
endAnn := renderEndAnnouncement(results, game)
p.SendMessage(game.RoomID, endAnn)
p.broadcastDM(game, 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)
p.broadcastDM(game, 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
playerID := string(actionPlayer.UserID)
safeGo("holdem-tip", func() {
tipCtx := buildTipContext(snap)
tip := generateTip(tipCtx)
p.SendMessage(dmRoom, tip)
logTipAudit(playerID, tipCtx, tip)
})
}
}
func (p *HoldemPlugin) broadcastDM(game *HoldemGame, msg string) {
first := true
for _, pl := range game.Players {
if pl.IsNPC || pl.State == PlayerSatOut {
continue
}
// Skip players whose DM room IS the game room — they already saw
// the message via the public-room post (solo-vs-bot case, where
// game.RoomID == player's DM).
if pl.DMRoomID == game.RoomID {
continue
}
if !first {
// Jitter 150400ms between sends to avoid bursts on the Matrix server.
time.Sleep(150*time.Millisecond + time.Duration(rand.IntN(250))*time.Millisecond)
}
first = false
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,
)
}
}