mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
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>
278 lines
5.5 KiB
Go
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"
|
|
}
|