Phase C, the engine half: hold'em becomes multiway, and the redaction that was a bug-in-one-handler becomes the security boundary the plan warned it would. - const You is gone. A table is a list of seats and which are human is a per-seat property, not the fixed index zero. New(tier, []SeatConfig, ...) seats the ring; SoloSeats builds the old one-human-plus-bots shape the solo handler still opens. - ApplyMove(state, seat, move) — seat identity enters the engine in exactly one place; every helper below already worked on indices. The advance loop stops at any human (not just seat 0), so one request plays the bots and hands control back at whichever person is next to act. - deal() now emits every seat's hole cards. The engine cannot redact a stream it doesn't know the audience of, so it stops trying: the view layer builds each viewer's redacted copy. viewHoldem/viewHoldemEvents take a viewerSeat. - Rake attributed to Paid whenever a *human* wins, not just seat 0 — real house income is rake off any player's pot, and bot pots are house-vs-house. - Bust is per-seat: at a solo table it still ends the session (PhaseDone), at a shared one a busted human just goes Out and the table plays on. Tests, three ways, all green: - the solo suite unchanged as a regression guard (a test-local You=0 alias); - TestMultiwayChipsAreConserved — 100 games, two humans at seats 0 and 2, chips counted after every move, proving the reshape actually plays; - TestHoldemViewNeverLeaksAnotherSeatsCards — renders every seat's view and event stream at every street and greps for anyone else's cards. Mutation-tested: undo the redaction and it fails on the preflop deal. No handlers rewired yet — the solo path still calls New(SoloSeats(...)) and renders for seat 0, so nothing a player sees has changed. The table cutover is next. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
954 lines
30 KiB
Go
954 lines
30 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")
|
|
)
|
|
|
|
// 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"`
|
|
|
|
// 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 {
|
|
seats := []SeatConfig{{Name: "You", Stack: buyIn}}
|
|
for i := 0; i < bots; i++ {
|
|
seats = append(seats, SeatConfig{Name: botNames[i], Bot: true, Stack: t.MaxBuy})
|
|
}
|
|
return seats
|
|
}
|
|
|
|
// 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"
|
|
}
|