// 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") ) // You are always seat zero. The bots are the seats after you. const You = 0 // 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 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"` Rake int64 `json:"rake"` // taken by the house across the whole session // 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"} // New sits you down. The buy-in is chips the caller has already taken off the // player's stack; this engine only ever gives them back through Leave. // // No hand is dealt yet — the table opens on PhaseHandOver, which is the state a // table between hands is in, and the first Deal move starts the first hand. func New(t Tier, bots int, buyIn int64, rakePct float64, seed1, seed2 uint64) (State, []Event, error) { if bots < 1 || bots > MaxBots { return State{}, nil, ErrTableFull } if buyIn < t.MinBuy || buyIn > t.MaxBuy { return State{}, nil, ErrBadBuyIn } t.RakePct = rakePct s := State{ Tier: t, Button: 0, Phase: PhaseHandOver, BoughtIn: buyIn, Seed1: seed1, Seed2: seed2, } s.Seats = append(s.Seats, Seat{Name: "You", Stack: buyIn}) for i := 0; i < bots; i++ { s.Seats = append(s.Seats, Seat{Name: botNames[i], Bot: true, Stack: t.MaxBuy}) } // The button starts to your right, so the first hand deals you the small blind // heads-up and the button on a full table — either way you are in the action // from the first card rather than folding your way in. s.Button = len(s.Seats) - 1 evs := []Event{{Kind: "sit", Seat: You, Amount: buyIn, Text: t.Name}} return s, evs, nil } // ApplyMove is the whole engine. It plays your move, then every bot behind you, // deals whatever streets that finishes, and stops either when it is your turn // again or when the hand is over. func ApplyMove(s State, m Move) (State, []Event, error) { 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[You].Stack+m.Amount > s.Tier.MaxBuy { return s, nil, ErrBadBuyIn } s.Seats[You].Stack += m.Amount s.BoughtIn += m.Amount evs = append(evs, Event{Kind: "topup", Seat: You, Amount: m.Amount}) case Leave: if s.Phase != PhaseHandOver { return s, nil, ErrHandLive } s.Phase = PhaseDone s.Payout = s.Seats[You].Stack evs = append(evs, Event{Kind: "leave", Seat: You, Amount: s.Payout}) case Fold, Check, Call, Raise, Shove: if s.Phase != PhaseBetting { return s, nil, ErrNoHand } if s.ToAct != You { return s, nil, ErrNotYourTurn } if err := s.act(You, m, &evs); err != nil { return s, nil, err // nothing happened; the caller keeps the old state } s.ToAct = s.nextCanAct(You) 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.ToAct == You || !bots { return // a decision that isn't ours to make } 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 } } // Only your cards go into the script. The bots' are in the state, on this side // of the wire, and the only thing that ever turns them over is a showdown. *evs = append(*evs, Event{Kind: "hole", Seat: You, Cards: []cards.Card{s.Seats[You].Hole[0], s.Seats[You].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, Amount: s.Seats[You].Stack}) // Busting is the end of the session, not the end of a hand. There is nothing // to deal you and nothing to give back, so the table closes and you sit down // again — which is a buy-in, and a buy-in is a decision worth making on purpose. if s.Seats[You].Stack <= 0 { s.Phase = PhaseDone s.Payout = 0 *evs = append(*evs, Event{Kind: "bust", Seat: You}) } } // ---- 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 } // 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 } // 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.nextIn(s.Button) bb := s.nextIn(sb) switch seat { case sb: return "SB" case bb: return "BB" } utg := s.nextIn(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.nextIn(cur) dist++ if cur == seat { break } } if remaining := n - 4; remaining > 0 && dist >= remaining { return "CO" } return "MP" }