Files
Pete/internal/games/holdem/holdem.go
prosolis f8b07d8e6c games: the buy-in and the rake each player sees are their own, not the table's
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
2026-07-14 17:17:25 -07:00

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"
}