The two-browser pass found it: at a table two humans share, the felt quoted each of them the pair's total. "Bought in for 200" to a player who put in 100, and a session-rake line that climbed on a pot the other one won. Both were table totals the view read straight off the engine — correct while a table had one human, wrong the moment it had two. Fixed along the border it already draws: bought_in is border accounting, so it comes from the viewer's own game_seats.staked; session rake is a within-table event, so it rides a new per-seat Seat.Paid beside the audit's table-total s.Paid. And top-up never grew game_seats.staked, so the storage invariant drifted by every top-up and the felt under-reported the buy-in — it does now. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
1041 lines
34 KiB
Go
1041 lines
34 KiB
Go
// Package holdem is a pure Texas Hold'em engine, played for chips against bots.
|
|
//
|
|
// Same seam as every other table in the casino: ApplyMove(state, move) (state,
|
|
// events, error), where an error means the move was illegal and nothing else.
|
|
// No HTTP, no timers, no sockets. The state is a plain value, so a hand survives
|
|
// a redeploy and replays from its seed.
|
|
//
|
|
// Three things make hold'em different from the five tables already on the felt.
|
|
//
|
|
// It is a cash game, not a stake. Every other game here takes a bet, plays once
|
|
// and pays a multiple. Poker isn't that: you buy chips onto the table, you play
|
|
// as many hands as you like, and you leave with whatever is in front of you. So
|
|
// the session is the unit, not the hand — the live row lives across hands, the
|
|
// buy-in is the only chip movement at the start, and the stack going home is the
|
|
// only one at the end. In between the money is entirely inside this engine.
|
|
//
|
|
// The bots move inside ApplyMove, as they do in UNO. One call plays the player's
|
|
// action and every bot action behind it, deals whatever streets that completes,
|
|
// and hands the lot back as a script of events. Poker is where you would reach
|
|
// for a socket, and this is what not reaching for one costs.
|
|
//
|
|
// The bots are the trained ones. gogobee spent a long time running CFR against
|
|
// this game and the policy it converged on is the best asset in either repo; it
|
|
// is embedded here whole (see cfr.go). What that means for the player: the house
|
|
// edge at this table is not a rule, it is an opponent. There is no 3:2 and no
|
|
// multiple. If you beat the bots you win, and the only thing the house takes is
|
|
// the rake on the pots you win.
|
|
package holdem
|
|
|
|
import (
|
|
"errors"
|
|
"math/rand/v2"
|
|
|
|
"pete/internal/games/cards"
|
|
)
|
|
|
|
// Errors an illegal move can produce. An error means nothing happened.
|
|
var (
|
|
ErrOver = errors.New("holdem: you've left the table")
|
|
ErrNotYourTurn = errors.New("holdem: it isn't your turn")
|
|
ErrHandLive = errors.New("holdem: finish the hand first")
|
|
ErrNoHand = errors.New("holdem: no hand in progress")
|
|
ErrCantCheck = errors.New("holdem: there's a bet to you")
|
|
ErrNothingToCall = errors.New("holdem: nothing to call")
|
|
ErrTooSmall = errors.New("holdem: that's under the minimum raise")
|
|
ErrTooBig = errors.New("holdem: you don't have that many chips")
|
|
ErrNoChips = errors.New("holdem: you have no chips left")
|
|
ErrUnknownMove = errors.New("holdem: unknown move")
|
|
ErrUnknownTier = errors.New("holdem: no such table")
|
|
ErrBadBuyIn = errors.New("holdem: that isn't a legal buy-in")
|
|
ErrTableFull = errors.New("holdem: too many seats")
|
|
ErrSeatTaken = errors.New("holdem: that seat is taken")
|
|
)
|
|
|
|
// rakeCapBB caps the rake on any one pot at three big blinds, which is what a
|
|
// cardroom does. Without a cap, five percent of a big pot is a lot of money to
|
|
// take off a player for winning it.
|
|
const rakeCapBB = 3
|
|
|
|
// Street is how far the board has come.
|
|
type Street uint8
|
|
|
|
const (
|
|
PreFlop Street = iota
|
|
Flop
|
|
Turn
|
|
River
|
|
Showdown
|
|
)
|
|
|
|
var streetNames = [5]string{"preflop", "flop", "turn", "river", "showdown"}
|
|
|
|
func (s Street) String() string {
|
|
if int(s) >= len(streetNames) {
|
|
return "?"
|
|
}
|
|
return streetNames[s]
|
|
}
|
|
|
|
// SeatState is where a player stands in the hand being played.
|
|
type SeatState uint8
|
|
|
|
const (
|
|
Active SeatState = iota // still has chips and a say
|
|
Folded // out of this hand
|
|
AllIn // in the hand, but has nothing left to bet
|
|
Out // not dealt in at all
|
|
)
|
|
|
|
// Seat is one player at the table — you, or one of the bots.
|
|
//
|
|
// Hole is on the server and stays there. The view layer sends your two cards to
|
|
// you and sends nobody else's to anybody, right up until a showdown turns them
|
|
// over. A bot's cards are most of the information in this game; a browser that
|
|
// held them would make counting cards a matter of reading the network tab.
|
|
type Seat struct {
|
|
Name string `json:"name"`
|
|
Bot bool `json:"bot"`
|
|
Stack int64 `json:"stack"`
|
|
Hole [2]cards.Card `json:"hole"`
|
|
Bet int64 `json:"bet"` // put in on this street
|
|
Total int64 `json:"total"` // put in across this hand
|
|
Won int64 `json:"won"` // taken out of the pot this hand
|
|
Paid int64 `json:"paid"` // rake lifted from pots this seat has won, all session
|
|
State SeatState `json:"state"`
|
|
Acted bool `json:"acted"` // has chosen to do something this street
|
|
}
|
|
|
|
// Pot is a pot and the seats that may win it. A hand with no all-in has exactly
|
|
// one; every all-in at a distinct level adds another.
|
|
type Pot struct {
|
|
Amount int64 `json:"amount"`
|
|
Eligible []int `json:"eligible"`
|
|
}
|
|
|
|
// Tier is a table you can sit at. The dial is the stakes, and the stakes are
|
|
// what make a chip mean something: at 25/50 a careless call costs more than a
|
|
// whole session at 1/2.
|
|
//
|
|
// The buy-in range is the standard 20 to 100 big blinds. Sitting down short is
|
|
// a real strategy (fewer decisions, less to lose) and sitting down deep is the
|
|
// other one, so the range is a choice and not a formality.
|
|
type Tier struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
SB int64 `json:"sb"`
|
|
BB int64 `json:"bb"`
|
|
MinBuy int64 `json:"min_buy"`
|
|
MaxBuy int64 `json:"max_buy"`
|
|
RakePct float64 `json:"rake_pct"`
|
|
Blurb string `json:"blurb"`
|
|
}
|
|
|
|
// Tiers are the three tables. The rake is the casino's five percent everywhere,
|
|
// capped at three big blinds a pot, and taken only from a pot that saw a flop.
|
|
//
|
|
// RakePct is a *fraction*, 0.05, because that is what it is everywhere else in
|
|
// the casino — blackjack's DefaultRules says 0.05 and New() takes its word for it.
|
|
// It was 5 here for an afternoon, meaning percent, and since New overwrites the
|
|
// tier's value with the one it is handed, every rake worked out to five percent of
|
|
// a hundredth of the pot, which integer division rounded to nothing. The house took
|
|
// nothing at all and no test noticed, because every test set the tier up by hand.
|
|
var Tiers = []Tier{
|
|
{Slug: "micro", Name: "The Kitchen Table", SB: 1, BB: 2, MinBuy: 40, MaxBuy: 200, RakePct: 0.05,
|
|
Blurb: "1/2 blinds. Cheap enough to learn what the bots do to you."},
|
|
{Slug: "low", Name: "The Back Room", SB: 5, BB: 10, MinBuy: 200, MaxBuy: 1000, RakePct: 0.05,
|
|
Blurb: "5/10. A bluff here costs real chips, which is the only reason a bluff works."},
|
|
{Slug: "high", Name: "The High Roller", SB: 25, BB: 50, MinBuy: 1000, MaxBuy: 5000, RakePct: 0.05,
|
|
Blurb: "25/50. Three streets of this and you know whether you can play."},
|
|
}
|
|
|
|
// TierBySlug finds a table by the name the browser sent.
|
|
func TierBySlug(slug string) (Tier, error) {
|
|
for _, t := range Tiers {
|
|
if t.Slug == slug {
|
|
return t, nil
|
|
}
|
|
}
|
|
return Tier{}, ErrUnknownTier
|
|
}
|
|
|
|
// MaxBots is five, which with you makes a six-handed table — the size most
|
|
// online poker is actually played at, and as many opponents as the felt can
|
|
// show without the cards getting too small to read.
|
|
const MaxBots = 5
|
|
|
|
// Phase is what the table is waiting for.
|
|
type Phase string
|
|
|
|
const (
|
|
PhaseBetting Phase = "betting" // a hand is live and it's your turn
|
|
PhaseHandOver Phase = "handover" // the hand is paid; deal, top up, or leave
|
|
PhaseDone Phase = "done" // you're up from the table; the stack goes home
|
|
)
|
|
|
|
// State is the whole table. It never leaves the server: the deck is in here,
|
|
// and so is every bot's hand.
|
|
type State struct {
|
|
Tier Tier `json:"tier"`
|
|
Seats []Seat `json:"seats"`
|
|
Button int `json:"button"`
|
|
HandNo int `json:"hand_no"`
|
|
|
|
Deck cards.Deck `json:"deck"`
|
|
Community []cards.Card `json:"community"`
|
|
Street Street `json:"street"`
|
|
Flopped bool `json:"flopped"` // this hand saw a flop, so its pot is rakeable
|
|
|
|
Pot int64 `json:"pot"`
|
|
Side []Pot `json:"side,omitempty"`
|
|
Bet int64 `json:"bet"` // the bet to match on this street
|
|
MinRaise int64 `json:"min_raise"` // the smallest legal raise over it
|
|
Aggressor int `json:"aggressor"`
|
|
ToAct int `json:"to_act"`
|
|
|
|
// History is the action so far on this street, as the f/c/r/R/a characters
|
|
// the CFR policy was trained to read. It is the bots' memory and nothing else.
|
|
History string `json:"history"`
|
|
|
|
Phase Phase `json:"phase"`
|
|
|
|
// The money that crosses the border. BoughtIn is every chip staked onto this
|
|
// table; Payout is the stack that goes home, and is only set once you're up.
|
|
BoughtIn int64 `json:"bought_in"`
|
|
Payout int64 `json:"payout"`
|
|
|
|
// Two rakes, and they are different numbers.
|
|
//
|
|
// Rake is every chip the house has lifted off this table. It exists so the
|
|
// chips balance: a pot that is raked is a pot that pays out less than it holds,
|
|
// and the difference has to be somewhere.
|
|
//
|
|
// Paid is the part of that which came out of a pot *you* won — the only part
|
|
// that is real money, and the only part worth quoting you. Rake a pot a bot
|
|
// wins and you have paid nothing; a counter that climbed anyway while you sat
|
|
// folding would be lying to you.
|
|
Rake int64 `json:"rake"`
|
|
Paid int64 `json:"paid"`
|
|
|
|
// The seed rides in the state for the same reason it does in UNO: the bots
|
|
// choose and the deck is reshuffled every hand, so the engine needs randomness
|
|
// mid-session — and there is no generator alive between two HTTP requests to
|
|
// hand it. Each step derives its own from the seed and the step count, so the
|
|
// session still replays exactly as it fell.
|
|
Seed1 uint64 `json:"seed1"`
|
|
Seed2 uint64 `json:"seed2"`
|
|
Step uint64 `json:"step"`
|
|
}
|
|
|
|
// Event is one beat of the script the felt plays back. Seat is -1 when the beat
|
|
// belongs to the table rather than a player.
|
|
type Event struct {
|
|
Kind string `json:"kind"`
|
|
Seat int `json:"seat"`
|
|
Cards []cards.Card `json:"cards,omitempty"`
|
|
Amount int64 `json:"amount,omitempty"`
|
|
Total int64 `json:"total,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
}
|
|
|
|
// The moves a player can make. The betting five, plus the three that are about
|
|
// the session rather than the hand.
|
|
const (
|
|
Fold = "fold"
|
|
Check = "check"
|
|
Call = "call"
|
|
Raise = "raise"
|
|
Shove = "allin" // the move; AllIn is the seat state it puts you in
|
|
|
|
Deal = "deal" // next hand
|
|
TopUp = "topup" // put more chips on the table, between hands
|
|
Leave = "leave" // get up; the stack goes back to your stack
|
|
)
|
|
|
|
// Move is what the browser sends. To is the total a raise raises *to*, and
|
|
// Amount is chips added in a top-up.
|
|
type Move struct {
|
|
Kind string `json:"move"`
|
|
To int64 `json:"to,omitempty"`
|
|
Amount int64 `json:"amount,omitempty"`
|
|
}
|
|
|
|
// botNames are the regulars. Six of them so a full table never has two.
|
|
var botNames = []string{"Dice", "Marjorie", "Ox", "Sunny", "Pinch", "The Reverend"}
|
|
|
|
// SeatConfig is one chair the table opens with. A shared table is seated by the
|
|
// runtime: humans in the chairs people have taken, bots in the rest. Solo play is
|
|
// just the case where exactly one chair is human, which is why there is no longer
|
|
// a separate solo constructor and no seat-zero-is-you convention — a table is a
|
|
// list of seats, and who is human is a property of each seat, not of its index.
|
|
type SeatConfig struct {
|
|
Name string
|
|
Bot bool
|
|
// Stack is the starting chips: a human's buy-in (already taken off their chip
|
|
// stack by the caller), or a bot's stake from the house.
|
|
Stack int64
|
|
}
|
|
|
|
// New opens a table and seats it. No hand is dealt yet — the table opens on
|
|
// PhaseHandOver, the state a table between hands is in, and the first Deal starts
|
|
// the first hand.
|
|
//
|
|
// The engine gives a human's chips back only by the runtime reading their stack
|
|
// when they leave; it never credits a chip stack itself. Bot stacks are house
|
|
// chips and not real money — the only real money at the table is the humans'.
|
|
func New(t Tier, seats []SeatConfig, rakePct float64, seed1, seed2 uint64) (State, []Event, error) {
|
|
if len(seats) < 2 || len(seats) > MaxBots+1 {
|
|
return State{}, nil, ErrTableFull
|
|
}
|
|
t.RakePct = rakePct
|
|
|
|
s := State{
|
|
Tier: t,
|
|
Phase: PhaseHandOver,
|
|
Seed1: seed1,
|
|
Seed2: seed2,
|
|
}
|
|
var evs []Event
|
|
for _, sc := range seats {
|
|
if !sc.Bot && (sc.Stack < t.MinBuy || sc.Stack > t.MaxBuy) {
|
|
return State{}, nil, ErrBadBuyIn
|
|
}
|
|
i := len(s.Seats)
|
|
s.Seats = append(s.Seats, Seat{Name: sc.Name, Bot: sc.Bot, Stack: sc.Stack})
|
|
if !sc.Bot {
|
|
// BoughtIn is the sum of what the humans brought — the audit's idea of the
|
|
// stake. Per-seat border accounting lives in storage (game_seats.staked),
|
|
// not here; the engine only ever moves chips within the table.
|
|
s.BoughtIn += sc.Stack
|
|
evs = append(evs, Event{Kind: "sit", Seat: i, Amount: sc.Stack, Text: t.Name})
|
|
}
|
|
}
|
|
|
|
// The button starts at the last seat, so deal() moves it to the first: a full
|
|
// table puts the earliest seat on the button, and heads-up puts it on the small
|
|
// blind, in the action from the first card either way.
|
|
s.Button = len(s.Seats) - 1
|
|
return s, evs, nil
|
|
}
|
|
|
|
// soloSeats builds the seat list for a table of one human and n bots — the shape
|
|
// every table had before multiplayer, and the one the solo handler still opens.
|
|
// The human is seat zero and takes buyIn; the bots take the table maximum.
|
|
// SoloSeats is exported for the handler that still opens a solo table. See New.
|
|
func SoloSeats(t Tier, bots int, buyIn int64) []SeatConfig {
|
|
return TableSeats(t, "You", bots, buyIn)
|
|
}
|
|
|
|
// TableSeats builds a table of one named human and n bots. It is what a player
|
|
// opening their own table gets: a chair with their name on it, and the rest of
|
|
// the felt filled with the house's regulars so the table is never empty. The
|
|
// human takes seat zero and their buy-in; each bot takes the table maximum in
|
|
// house chips, which are not real money and get rebought when a bot runs dry.
|
|
func TableSeats(t Tier, human string, bots int, buyIn int64) []SeatConfig {
|
|
seats := []SeatConfig{{Name: human, Stack: buyIn}}
|
|
for i := 0; i < bots && i < len(botNames); i++ {
|
|
seats = append(seats, SeatConfig{Name: botNames[i], Bot: true, Stack: t.MaxBuy})
|
|
}
|
|
return seats
|
|
}
|
|
|
|
// freeBotName picks a regular not already sitting at the table, so vacating a
|
|
// seat never puts two Marjories on the felt. It falls back to a generic name if
|
|
// every regular is somehow taken, which a six-max table cannot actually manage.
|
|
func (s *State) freeBotName() string {
|
|
used := make(map[string]bool, len(s.Seats))
|
|
for i := range s.Seats {
|
|
used[s.Seats[i].Name] = true
|
|
}
|
|
for _, n := range botNames {
|
|
if !used[n] {
|
|
return n
|
|
}
|
|
}
|
|
return "The House"
|
|
}
|
|
|
|
// Vacate turns a human's chair back into the house's and returns the stack that
|
|
// goes home with them. It is how a shared table survives a player getting up: the
|
|
// seat keeps its place and its chips — which become house money, rebought like
|
|
// any bot's when they run low — so the others play on without a hole in the ring.
|
|
//
|
|
// The chips left in the seat are not a leak. The only real money at the table is
|
|
// in the human seats; the moment a seat is a bot's, its stack is house chips that
|
|
// nobody's balance is counting. What the player actually takes home is the
|
|
// returned stack, credited by the runtime in the same transaction that saves this
|
|
// state — see storage.LeaveTable.
|
|
//
|
|
// It refuses while a hand is live, because a seat with chips in the pot cannot be
|
|
// emptied without stranding them. PhaseHandOver is the only phase Leave was ever
|
|
// legal from.
|
|
func (s *State) Vacate(seat int) (int64, error) {
|
|
if seat < 0 || seat >= len(s.Seats) {
|
|
return 0, ErrUnknownMove
|
|
}
|
|
if s.Phase == PhaseBetting {
|
|
return 0, ErrHandLive
|
|
}
|
|
p := &s.Seats[seat]
|
|
if p.Bot {
|
|
return 0, ErrUnknownMove
|
|
}
|
|
home := p.Stack
|
|
p.Bot = true
|
|
p.Name = s.freeBotName()
|
|
return home, nil
|
|
}
|
|
|
|
// Occupy seats a human in a chair a bot was keeping warm, with the buy-in they
|
|
// brought. It is the join half of Vacate: a player sitting down at somebody
|
|
// else's table takes an open seat rather than opening a felt of their own.
|
|
//
|
|
// Like Vacate it is a between-hands move — you cannot sit into a live hand — and
|
|
// the buy-in has to be legal for the table, because the chips are already off the
|
|
// player's stack by the time this runs and an illegal seat would strand them.
|
|
func (s *State) Occupy(seat int, name string, buyIn int64) error {
|
|
if seat < 0 || seat >= len(s.Seats) {
|
|
return ErrUnknownMove
|
|
}
|
|
if s.Phase == PhaseBetting {
|
|
return ErrHandLive
|
|
}
|
|
if !s.Seats[seat].Bot {
|
|
return ErrSeatTaken
|
|
}
|
|
if buyIn < s.Tier.MinBuy || buyIn > s.Tier.MaxBuy {
|
|
return ErrBadBuyIn
|
|
}
|
|
p := &s.Seats[seat]
|
|
p.Bot = false
|
|
p.Name = name
|
|
p.Stack = buyIn
|
|
p.State = Out // between hands; the next deal brings them in
|
|
s.BoughtIn += buyIn
|
|
return nil
|
|
}
|
|
|
|
// ApplyMove is the whole engine. It plays the acting seat's move, then every bot
|
|
// behind them, deals whatever streets that finishes, and stops when the action
|
|
// reaches a human — the same one again, or another at the table — or when the
|
|
// hand is over.
|
|
//
|
|
// seat is who is acting. A betting move is legal only from the seat whose turn it
|
|
// is; the session moves (Deal, TopUp, Leave) belong to the seat that sent them.
|
|
// This is the one place seat identity enters the engine — everything below works
|
|
// on seat indices already.
|
|
func ApplyMove(s State, seat int, m Move) (State, []Event, error) {
|
|
if seat < 0 || seat >= len(s.Seats) {
|
|
return s, nil, ErrUnknownMove
|
|
}
|
|
if s.Phase == PhaseDone {
|
|
return s, nil, ErrOver
|
|
}
|
|
rng := s.next()
|
|
evs := []Event{}
|
|
|
|
switch m.Kind {
|
|
case Deal:
|
|
if s.Phase != PhaseHandOver {
|
|
return s, nil, ErrHandLive
|
|
}
|
|
s.deal(&evs, rng)
|
|
s.advance(&evs, rng, true)
|
|
|
|
case TopUp:
|
|
if s.Phase != PhaseHandOver {
|
|
return s, nil, ErrHandLive
|
|
}
|
|
if m.Amount <= 0 || s.Seats[seat].Stack+m.Amount > s.Tier.MaxBuy {
|
|
return s, nil, ErrBadBuyIn
|
|
}
|
|
s.Seats[seat].Stack += m.Amount
|
|
s.BoughtIn += m.Amount
|
|
evs = append(evs, Event{Kind: "topup", Seat: seat, Amount: m.Amount})
|
|
|
|
case Leave:
|
|
if s.Phase != PhaseHandOver {
|
|
return s, nil, ErrHandLive
|
|
}
|
|
// Getting up at a solo table ends the session and pays the stack out; the
|
|
// runtime reads Payout and crosses the border. At a shared table leaving is a
|
|
// storage operation (LeaveTable swaps the seat for a bot in the settle tx),
|
|
// and this branch is not the path taken — see the handler.
|
|
s.Phase = PhaseDone
|
|
s.Payout = s.Seats[seat].Stack
|
|
evs = append(evs, Event{Kind: "leave", Seat: seat, Amount: s.Payout})
|
|
|
|
case Fold, Check, Call, Raise, Shove:
|
|
if s.Phase != PhaseBetting {
|
|
return s, nil, ErrNoHand
|
|
}
|
|
if s.ToAct != seat {
|
|
return s, nil, ErrNotYourTurn
|
|
}
|
|
if err := s.act(seat, m, &evs); err != nil {
|
|
return s, nil, err // nothing happened; the caller keeps the old state
|
|
}
|
|
s.ToAct = s.nextCanAct(seat)
|
|
s.advance(&evs, rng, true)
|
|
|
|
default:
|
|
return s, nil, ErrUnknownMove
|
|
}
|
|
|
|
return s, evs, nil
|
|
}
|
|
|
|
// step plays a move for whoever is to act — bot or player — and advances only as
|
|
// far as the next decision, whoever's it is. Nobody's turn is taken for them.
|
|
//
|
|
// This is the seam the trainer plays through, and it exists so that the trainer
|
|
// is playing *this* game: the same betting rules, the same street completion, the
|
|
// same side pots, the same money. The alternative is a second, simplified model
|
|
// of poker written for the trainer alone — which is what gogobee had, and it is
|
|
// why its policy encoded a game nobody was dealing.
|
|
func step(s State, m Move) (State, []Event, error) {
|
|
if s.Phase != PhaseBetting {
|
|
return s, nil, ErrNoHand
|
|
}
|
|
rng := s.next()
|
|
evs := []Event{}
|
|
|
|
seat := s.ToAct
|
|
if err := s.act(seat, m, &evs); err != nil {
|
|
return s, nil, err
|
|
}
|
|
s.ToAct = s.nextCanAct(seat)
|
|
s.advance(&evs, rng, false)
|
|
return s, evs, nil
|
|
}
|
|
|
|
// open deals one heads-up hand at the given stacks, stopping at the first
|
|
// decision. For the trainer: a table with no history and no button rotation.
|
|
func open(t Tier, stack0, stack1 int64, seed1, seed2 uint64) (State, error) {
|
|
if stack0 <= 0 || stack1 <= 0 {
|
|
return State{}, ErrBadBuyIn
|
|
}
|
|
s := State{
|
|
Tier: t,
|
|
Phase: PhaseHandOver,
|
|
Seats: []Seat{
|
|
{Name: "0", Stack: stack0},
|
|
{Name: "1", Stack: stack1, Bot: true},
|
|
},
|
|
Button: 1, // deal() moves it, so seat 0 takes the button and the small blind
|
|
Seed1: seed1,
|
|
Seed2: seed2,
|
|
}
|
|
rng := s.next()
|
|
evs := []Event{}
|
|
s.deal(&evs, rng)
|
|
s.advance(&evs, rng, false)
|
|
return s, nil
|
|
}
|
|
|
|
// clone deep-copies the table. CFR walks a tree of what-ifs, and a shallow copy
|
|
// would have every branch writing into the same deck.
|
|
func (s State) clone() State {
|
|
out := s
|
|
out.Seats = append([]Seat(nil), s.Seats...)
|
|
out.Deck = append(cards.Deck(nil), s.Deck...)
|
|
out.Community = append([]cards.Card(nil), s.Community...)
|
|
out.Side = make([]Pot, len(s.Side))
|
|
for i, p := range s.Side {
|
|
out.Side[i] = Pot{Amount: p.Amount, Eligible: append([]int(nil), p.Eligible...)}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// act applies one seat's action, whoever it belongs to.
|
|
func (s *State) act(seat int, m Move, evs *[]Event) error {
|
|
switch m.Kind {
|
|
case Fold:
|
|
s.fold(seat, evs)
|
|
return nil
|
|
case Check:
|
|
return s.check(seat, evs)
|
|
case Call:
|
|
return s.call(seat, evs)
|
|
case Raise:
|
|
return s.raise(seat, m.To, evs)
|
|
case Shove:
|
|
return s.allin(seat, evs)
|
|
}
|
|
return ErrUnknownMove
|
|
}
|
|
|
|
// advance runs the table forward until you have a decision to make, or until
|
|
// there is nothing left to decide.
|
|
//
|
|
// This is the loop the whole design turns on. Every other engine here returns
|
|
// after one move because there is nobody else at the table; this one keeps going
|
|
// — bot, bot, flop, bot, turn — and only stops when the answer has to come from
|
|
// the player. Which is why one HTTP request can be a whole hand: shove all-in
|
|
// and the board runs out and the pot is paid inside a single call.
|
|
//
|
|
// With bots false it stops at every decision instead of playing the bots' for
|
|
// them. That is the trainer's way in: it wants to choose both seats' moves.
|
|
func (s *State) advance(evs *[]Event, rng *rand.Rand, bots bool) {
|
|
for {
|
|
// Everyone else folded. Nobody shows; the last one standing takes it.
|
|
if s.liveCount() <= 1 {
|
|
s.takeit(evs)
|
|
return
|
|
}
|
|
|
|
// Nobody left with a decision to make: the rest of the board is a formality,
|
|
// so deal it and turn the cards over.
|
|
//
|
|
// The subtle half is the lone player who still has chips. They only have a
|
|
// decision if there is a bet to them — call it or fold. If there isn't, they
|
|
// have nobody left to bet *into*, because everyone else is already all-in,
|
|
// and poker does not let you put chips in a pot nobody can contest.
|
|
switch s.canActCount() {
|
|
case 0:
|
|
s.runout(evs)
|
|
return
|
|
case 1:
|
|
lone := s.onlyActor()
|
|
if s.Owed(lone) == 0 {
|
|
s.runout(evs)
|
|
return
|
|
}
|
|
s.ToAct = lone
|
|
}
|
|
|
|
if s.streetDone(s.ToAct) {
|
|
if s.Street == River {
|
|
s.showdown(evs)
|
|
return
|
|
}
|
|
s.street(evs)
|
|
continue
|
|
}
|
|
|
|
if !s.Seats[s.ToAct].Bot || !bots {
|
|
return // a decision a human has to make, or the trainer wants to
|
|
}
|
|
|
|
s.botActs(s.ToAct, evs, rng)
|
|
s.ToAct = s.nextCanAct(s.ToAct)
|
|
}
|
|
}
|
|
|
|
// deal starts a hand: rebuy the broke bots, move the button, shuffle, post the
|
|
// blinds, and put two cards in front of everybody.
|
|
func (s *State) deal(evs *[]Event, rng *rand.Rand) {
|
|
s.HandNo++
|
|
|
|
for i := range s.Seats {
|
|
p := &s.Seats[i]
|
|
// A bot that has been ground down to nothing reloads. It has to: a table
|
|
// where you have taken everybody's chips is a table with no game left in it,
|
|
// and their chips were never real anyway — the only real money at this table
|
|
// is yours, and the only thing the house takes is the rake.
|
|
if p.Bot && p.Stack < s.Tier.BB {
|
|
add := s.Tier.MaxBuy - p.Stack
|
|
p.Stack = s.Tier.MaxBuy
|
|
*evs = append(*evs, Event{Kind: "rebuy", Seat: i, Amount: add, Total: p.Stack})
|
|
}
|
|
p.Bet, p.Total, p.Won, p.Acted = 0, 0, 0, false
|
|
p.Hole = [2]cards.Card{}
|
|
p.State = Active
|
|
if p.Stack <= 0 {
|
|
p.State = Out
|
|
}
|
|
}
|
|
|
|
s.Community = nil
|
|
s.Side = nil
|
|
s.Pot = 0
|
|
s.Street = PreFlop
|
|
s.Flopped = false
|
|
s.History = ""
|
|
s.Phase = PhaseBetting
|
|
|
|
s.Deck = cards.NewDeck(1)
|
|
s.Deck.Shuffle(rng)
|
|
|
|
s.Button = s.nextIn(s.Button)
|
|
*evs = append(*evs, Event{Kind: "hand", Seat: s.Button, Amount: int64(s.HandNo)})
|
|
|
|
bb := s.blinds(evs)
|
|
|
|
// Two cards each, one at a time round the table, as they are actually dealt.
|
|
for round := 0; round < 2; round++ {
|
|
for i := 0; i < len(s.Seats); i++ {
|
|
seat := (s.Button + 1 + i) % len(s.Seats)
|
|
p := &s.Seats[seat]
|
|
if p.State == Out {
|
|
continue
|
|
}
|
|
c, _ := s.Deck.Draw()
|
|
p.Hole[round] = c
|
|
}
|
|
}
|
|
// A hole event per dealt seat, each carrying that seat's cards. The engine no
|
|
// longer decides who may see what — it cannot, now that a table has more than
|
|
// one human — so it emits every hand and the view layer redacts per viewer,
|
|
// nulling the cards of every seat but the one watching. That per-seat redaction
|
|
// is the whole security boundary once these events fan out over SSE, and it has
|
|
// a test that renders each seat's view and greps for anybody else's cards.
|
|
for i := range s.Seats {
|
|
if s.Seats[i].State == Out {
|
|
continue
|
|
}
|
|
*evs = append(*evs, Event{Kind: "hole", Seat: i,
|
|
Cards: []cards.Card{s.Seats[i].Hole[0], s.Seats[i].Hole[1]}})
|
|
}
|
|
|
|
s.ToAct = s.firstPreFlop(bb)
|
|
}
|
|
|
|
// street burns one and deals the next board card or three.
|
|
func (s *State) street(evs *[]Event) {
|
|
s.collect()
|
|
s.resetBets()
|
|
|
|
s.Deck.Draw() // the burn card, as printed in the rules and as dealt in a casino
|
|
|
|
switch s.Street {
|
|
case PreFlop:
|
|
s.Street = Flop
|
|
s.Flopped = true
|
|
for i := 0; i < 3; i++ {
|
|
c, _ := s.Deck.Draw()
|
|
s.Community = append(s.Community, c)
|
|
}
|
|
case Flop:
|
|
s.Street = Turn
|
|
c, _ := s.Deck.Draw()
|
|
s.Community = append(s.Community, c)
|
|
case Turn:
|
|
s.Street = River
|
|
c, _ := s.Deck.Draw()
|
|
s.Community = append(s.Community, c)
|
|
}
|
|
|
|
// Total, not Pot: by the time a board runs out behind an all-in the money has
|
|
// already been cut into side pots, and s.Pot is zero.
|
|
*evs = append(*evs, Event{Kind: s.Street.String(), Seat: -1,
|
|
Cards: s.Community[len(s.Community)-cardsOn(s.Street):], Amount: s.Total()})
|
|
|
|
s.ToAct = s.firstPostFlop()
|
|
s.Aggressor = s.ToAct // nobody has bet yet, so the option ends where it starts
|
|
}
|
|
|
|
func cardsOn(st Street) int {
|
|
if st == Flop {
|
|
return 3
|
|
}
|
|
return 1
|
|
}
|
|
|
|
// runout deals the rest of the board with no more betting, because there is no
|
|
// longer anybody able to bet. The side pots are built first: once the chips stop
|
|
// moving, who can win what is already decided.
|
|
func (s *State) runout(evs *[]Event) {
|
|
allIn := false
|
|
for i := range s.Seats {
|
|
if s.Seats[i].State == AllIn {
|
|
allIn = true
|
|
break
|
|
}
|
|
}
|
|
if allIn {
|
|
// A shove nobody could cover comes back first — while it is still a bet in
|
|
// front of a seat, and before the pots are cut around it.
|
|
s.uncalled(evs)
|
|
}
|
|
s.collect()
|
|
if allIn {
|
|
s.sidePots()
|
|
}
|
|
|
|
for s.Street < River {
|
|
s.street(evs)
|
|
}
|
|
s.showdown(evs)
|
|
}
|
|
|
|
// endHand pays out and parks the table between hands.
|
|
func (s *State) endHand(evs *[]Event) {
|
|
s.Pot = 0
|
|
s.Side = nil
|
|
s.Phase = PhaseHandOver
|
|
*evs = append(*evs, Event{Kind: "end", Seat: -1})
|
|
|
|
// Busting is the end of a session, not of a hand. At a solo table there is
|
|
// nothing to deal the one human and nothing to give back, so the table closes
|
|
// and they sit down again — a buy-in, and a buy-in is a decision worth making
|
|
// on purpose. At a shared table one human busting is not everyone's business:
|
|
// their seat simply goes Out and the runtime offers the rebuy while the table
|
|
// plays on. So the whole-session bust only fires when there is a single human.
|
|
humans := s.humanSeats()
|
|
if len(humans) == 1 && s.Seats[humans[0]].Stack <= 0 {
|
|
s.Phase = PhaseDone
|
|
s.Payout = 0
|
|
*evs = append(*evs, Event{Kind: "bust", Seat: humans[0]})
|
|
}
|
|
}
|
|
|
|
// humanSeats lists the seats a person is sitting in. Bots are the house; the only
|
|
// real money at the table is in these.
|
|
func (s State) humanSeats() []int {
|
|
var out []int
|
|
for i := range s.Seats {
|
|
if !s.Seats[i].Bot {
|
|
out = append(out, i)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---- the small stuff -------------------------------------------------------
|
|
|
|
// next derives this step's generator and advances the step count.
|
|
func (s *State) next() *rand.Rand {
|
|
s.Step++
|
|
return cards.NewRNG(s.Seed1, s.Seed2^s.Step)
|
|
}
|
|
|
|
// collect sweeps the street's bets into the pot.
|
|
func (s *State) collect() {
|
|
for i := range s.Seats {
|
|
s.Pot += s.Seats[i].Bet
|
|
s.Seats[i].Bet = 0
|
|
}
|
|
}
|
|
|
|
// resetBets opens a new street: nothing to call, and nobody has spoken.
|
|
func (s *State) resetBets() {
|
|
for i := range s.Seats {
|
|
s.Seats[i].Bet = 0
|
|
s.Seats[i].Acted = false
|
|
}
|
|
s.Bet = 0
|
|
s.MinRaise = s.Tier.BB
|
|
s.History = ""
|
|
}
|
|
|
|
// inPlay is the pot plus everything bet on this street — what a bot is actually
|
|
// deciding against, and what a pot-sized raise is a size of.
|
|
func (s State) inPlay() int64 {
|
|
total := s.Pot
|
|
for i := range s.Seats {
|
|
total += s.Seats[i].Bet
|
|
}
|
|
return total
|
|
}
|
|
|
|
// Pot returns the money on the table, however it is currently sliced.
|
|
func (s State) Total() int64 {
|
|
total := s.inPlay()
|
|
for _, p := range s.Side {
|
|
total += p.Amount
|
|
}
|
|
return total
|
|
}
|
|
|
|
// liveCount is the seats still in the hand, whether or not they can bet.
|
|
func (s State) liveCount() int {
|
|
n := 0
|
|
for i := range s.Seats {
|
|
if st := s.Seats[i].State; st == Active || st == AllIn {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// canActCount is the seats that still have chips and a decision.
|
|
func (s State) canActCount() int {
|
|
n := 0
|
|
for i := range s.Seats {
|
|
if s.Seats[i].State == Active {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// dealt is the seats in this hand at all.
|
|
func (s State) dealt() int {
|
|
n := 0
|
|
for i := range s.Seats {
|
|
if s.Seats[i].State != Out {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// nextIn is the next seat that was dealt in — used to move the button, which
|
|
// moves past a seat that is sitting out rather than landing on it.
|
|
func (s State) nextIn(from int) int {
|
|
n := len(s.Seats)
|
|
for i := 1; i <= n; i++ {
|
|
next := (from + i) % n
|
|
if st := s.Seats[next].State; st == Active || st == AllIn {
|
|
return next
|
|
}
|
|
}
|
|
return from
|
|
}
|
|
|
|
// nextDealt is the next seat holding cards this hand, folded or not. Where
|
|
// nextIn asks "who is still in the betting", this asks "who was dealt in", and
|
|
// the pair is easy to confuse: fold three seats and nextIn walks straight past
|
|
// them, so anything counting seats round the table lands somewhere different
|
|
// depending on how the hand has gone. Position needs the fixed one — where you
|
|
// sit is decided when the button moves and does not change because somebody
|
|
// mucked.
|
|
func (s State) nextDealt(from int) int {
|
|
n := len(s.Seats)
|
|
for i := 1; i <= n; i++ {
|
|
next := (from + i) % n
|
|
if s.Seats[next].State != Out {
|
|
return next
|
|
}
|
|
}
|
|
return from
|
|
}
|
|
|
|
// onlyActor is the one seat that can still act. Call it when canActCount is 1.
|
|
func (s State) onlyActor() int {
|
|
for i := range s.Seats {
|
|
if s.Seats[i].State == Active {
|
|
return i
|
|
}
|
|
}
|
|
return s.ToAct
|
|
}
|
|
|
|
// anyAllIn reports whether anybody is in the hand with no chips left, which is
|
|
// the only thing that makes side pots necessary.
|
|
func (s State) anyAllIn() bool {
|
|
for i := range s.Seats {
|
|
if s.Seats[i].State == AllIn {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// canBet reports whether there is anybody left to bet *into*. With one player
|
|
// still holding chips and the rest all-in, a raise is chips nobody can call, so
|
|
// no raise is offered — to the player or to a bot.
|
|
func (s State) canBet() bool { return s.canActCount() > 1 }
|
|
|
|
// nextCanAct is the next seat with a decision to make.
|
|
func (s State) nextCanAct(from int) int {
|
|
n := len(s.Seats)
|
|
for i := 1; i <= n; i++ {
|
|
next := (from + i) % n
|
|
if s.Seats[next].State == Active {
|
|
return next
|
|
}
|
|
}
|
|
return from
|
|
}
|
|
|
|
// Owed is what the seat must put in to call.
|
|
func (s State) Owed(seat int) int64 {
|
|
owed := s.Bet - s.Seats[seat].Bet
|
|
if owed < 0 {
|
|
return 0
|
|
}
|
|
if owed > s.Seats[seat].Stack {
|
|
return s.Seats[seat].Stack
|
|
}
|
|
return owed
|
|
}
|
|
|
|
// MinRaiseTo is the smallest total a raise may raise to, clamped to a shove when
|
|
// the seat cannot cover a full one.
|
|
func (s State) MinRaiseTo(seat int) int64 {
|
|
to := s.Bet + s.MinRaise
|
|
if most := s.Seats[seat].Bet + s.Seats[seat].Stack; to > most {
|
|
return most
|
|
}
|
|
return to
|
|
}
|
|
|
|
// MaxRaiseTo is everything the seat has.
|
|
func (s State) MaxRaiseTo(seat int) int64 {
|
|
return s.Seats[seat].Bet + s.Seats[seat].Stack
|
|
}
|
|
|
|
// CanRaise reports whether the seat may raise: they need chips behind the call,
|
|
// and somebody left to bet into. The felt asks so it never offers a button the
|
|
// table would refuse.
|
|
func (s State) CanRaise(seat int) bool {
|
|
return s.mask(seat)[actRaiseHalf]
|
|
}
|
|
|
|
// InPosition reports whether the seat acts last after the flop, which is the
|
|
// only thing about position the trained bots actually know.
|
|
//
|
|
// The postflop order runs from the seat left of the button all the way round to
|
|
// the button itself, so the player in position is simply the last one still in
|
|
// the hand — the button, or whoever is nearest to it once the button has folded.
|
|
// The policy was trained heads-up, where this is exactly the button; applying it
|
|
// to a six-handed table is an approximation, and this is where the approximation
|
|
// lives.
|
|
func (s State) InPosition(seat int) bool {
|
|
last := -1
|
|
for i := 1; i <= len(s.Seats); i++ {
|
|
at := (s.Button + i) % len(s.Seats)
|
|
if st := s.Seats[at].State; st == Active || st == AllIn {
|
|
last = at
|
|
}
|
|
}
|
|
return seat == last
|
|
}
|
|
|
|
// Position is the seat's label at this table — BTN, SB, BB, and so on. It is for
|
|
// the felt to print. The bots do not use it: see InPosition, and the note on
|
|
// infoSet about what happens when you confuse the two.
|
|
func (s State) Position(seat int) string {
|
|
n := s.dealt()
|
|
if n < 2 {
|
|
return ""
|
|
}
|
|
if seat == s.Button {
|
|
return "BTN"
|
|
}
|
|
if n == 2 {
|
|
return "BB" // heads-up, the other seat is always the big blind
|
|
}
|
|
|
|
sb := s.nextDealt(s.Button)
|
|
bb := s.nextDealt(sb)
|
|
switch seat {
|
|
case sb:
|
|
return "SB"
|
|
case bb:
|
|
return "BB"
|
|
}
|
|
|
|
utg := s.nextDealt(bb)
|
|
if seat == utg {
|
|
return "UTG"
|
|
}
|
|
|
|
// Everyone between UTG and the button is somewhere in the middle; the seat
|
|
// closest to the button is the cutoff.
|
|
dist, cur := 0, utg
|
|
for i := 0; i < n; i++ {
|
|
cur = s.nextDealt(cur)
|
|
dist++
|
|
if cur == seat {
|
|
break
|
|
}
|
|
}
|
|
if remaining := n - 4; remaining > 0 && dist >= remaining {
|
|
return "CO"
|
|
}
|
|
return "MP"
|
|
}
|