mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 08:32:41 +00:00
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>
1264 lines
32 KiB
Go
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,
|
|
)
|
|
}
|
|
}
|