Files
gogobee/internal/plugin/holdem_game.go
prosolis c7c1b76589 Add adventure plugin, holdem CFR fixes, and wordle plugin
Adventure: Complete v1 daily idle RPG with DM-driven gameplay, equipment
shop, treasure system, TwinBee NPC, streak/grudge/party mechanics,
flavor text, and scheduled morning/evening/midnight tickers.

Holdem CFR: Fix three critical training bugs (fold not forfeiting pot,
free calls after raises, training/runtime key mismatch). Add performance
optimizations (preflop lookup table, zero-alloc equity, integer keys,
raise cap, regret pruning). Enrich abstraction with 12 equity buckets,
board texture dimension, and 6-char action history. Replace validation
with proper multi-street simulation.

Also includes wordle plugin, holdem seed tooling, and schema additions.

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

278 lines
5.5 KiB
Go

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