A review pass, and it found the one that would have cost somebody real chips. Side pots were only ever cut in runout() — the path taken when the betting stops because nobody is left able to bet. But a hand reaches a showdown with an all-in player in it and the betting having finished perfectly normally: a short stack shoves, two players who still have chips behind call, and then keep betting past them street after street to the river. Nothing was cut. One pot, everybody eligible, and the short stack takes the lot — every chip the deep players put in after they were already all-in, money that could never have been lost to them. All-in for 100 against two players who each put in 500, and the best hand collects 1,100 instead of the 300 it was playing for. Chip conservation never saw it. The chips balance perfectly; they just land in the wrong seat. And every browser session went through runout(), because a player shoving is what ends the betting. It took reading the code. Also from the review: play() dereferenced a table it had just been handed as null, the top-up button offered chips the wallet could not cover, and the trainer's ETA was sixty thousand hands optimistic on the first line it printed. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
280 lines
7.3 KiB
Go
280 lines
7.3 KiB
Go
package holdem
|
|
|
|
import "sort"
|
|
|
|
// The betting rules. These are the fiddly ones — min-raise, short all-ins that
|
|
// don't reopen the action, side pots — and they came over from gogobee, where
|
|
// they had no tests at all. They have some now.
|
|
|
|
// blinds posts the small and the big. Heads-up is the exception every poker
|
|
// implementation gets wrong once: with two players the button *is* the small
|
|
// blind and acts first before the flop, and last after it.
|
|
func (s *State) blinds(evs *[]Event) (bb int) {
|
|
var sb int
|
|
if s.dealt() == 2 {
|
|
sb, bb = s.Button, s.nextIn(s.Button)
|
|
} else {
|
|
sb = s.nextIn(s.Button)
|
|
bb = s.nextIn(sb)
|
|
}
|
|
|
|
s.post(sb, s.Tier.SB, "small", evs)
|
|
s.post(bb, s.Tier.BB, "big", evs)
|
|
|
|
s.Bet = s.Tier.BB
|
|
s.MinRaise = s.Tier.BB
|
|
s.Aggressor = bb // the big blind has the option to raise their own blind
|
|
return bb
|
|
}
|
|
|
|
// post puts a blind up. A player too short to cover it is all-in for what they
|
|
// have, which is legal and is why the amount is clamped rather than refused.
|
|
func (s *State) post(seat int, amount int64, which string, evs *[]Event) {
|
|
p := &s.Seats[seat]
|
|
if amount > p.Stack {
|
|
amount = p.Stack
|
|
}
|
|
p.Stack -= amount
|
|
p.Bet = amount
|
|
p.Total = amount
|
|
if p.Stack == 0 {
|
|
p.State = AllIn
|
|
}
|
|
*evs = append(*evs, Event{Kind: "blind", Seat: seat, Amount: amount, Text: which})
|
|
}
|
|
|
|
// firstPreFlop is under the gun: the seat after the big blind, or the button
|
|
// itself when the table is heads-up.
|
|
//
|
|
// The button only gets it if the button can still act. A short stack can be
|
|
// all-in on its own blind — post a small blind of 1 with 1 chip left and you are
|
|
// in the hand with no chips and no say — and handing the action to a seat that
|
|
// cannot act wedges the table.
|
|
func (s *State) firstPreFlop(bb int) int {
|
|
if s.dealt() != 2 {
|
|
return s.nextCanAct(bb)
|
|
}
|
|
if s.Seats[s.Button].State == Active {
|
|
return s.Button
|
|
}
|
|
return s.nextCanAct(s.Button)
|
|
}
|
|
|
|
// firstPostFlop is the first seat left of the button, on every street after the
|
|
// flop. The button acts last from here on, which is the whole point of it.
|
|
func (s *State) firstPostFlop() int { return s.nextCanAct(s.Button) }
|
|
|
|
// ---- the five things a seat can do ----------------------------------------
|
|
|
|
func (s *State) fold(seat int, evs *[]Event) {
|
|
p := &s.Seats[seat]
|
|
p.State = Folded
|
|
p.Acted = true
|
|
s.History += "f"
|
|
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "fold"})
|
|
}
|
|
|
|
func (s *State) check(seat int, evs *[]Event) error {
|
|
p := &s.Seats[seat]
|
|
if p.Bet < s.Bet {
|
|
return ErrCantCheck
|
|
}
|
|
p.Acted = true
|
|
s.History += "c"
|
|
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "check"})
|
|
return nil
|
|
}
|
|
|
|
func (s *State) call(seat int, evs *[]Event) error {
|
|
p := &s.Seats[seat]
|
|
owed := s.Bet - p.Bet
|
|
if owed <= 0 {
|
|
return ErrNothingToCall
|
|
}
|
|
if owed > p.Stack {
|
|
owed = p.Stack // a call for less than the bet is a call all-in
|
|
}
|
|
|
|
p.Stack -= owed
|
|
p.Bet += owed
|
|
p.Total += owed
|
|
p.Acted = true
|
|
|
|
text := "call"
|
|
if p.Stack == 0 {
|
|
p.State = AllIn
|
|
text = "allin"
|
|
s.History += "a"
|
|
} else {
|
|
s.History += "c"
|
|
}
|
|
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: text, Amount: owed, Total: p.Bet})
|
|
return nil
|
|
}
|
|
|
|
// raise raises *to* a total, not *by* an amount. Every poker interface in the
|
|
// world means the total, and a browser that means the other thing bets wrong.
|
|
func (s *State) raise(seat int, to int64, evs *[]Event) error {
|
|
p := &s.Seats[seat]
|
|
most := p.Bet + p.Stack
|
|
|
|
if to > most {
|
|
return ErrTooBig
|
|
}
|
|
if to < s.Bet+s.MinRaise && to < most {
|
|
return ErrTooSmall // only a shove may be smaller than a legal raise
|
|
}
|
|
|
|
added := to - p.Bet
|
|
over := to - s.Bet
|
|
|
|
p.Stack -= added
|
|
p.Bet = to
|
|
p.Total += added
|
|
p.Acted = true
|
|
|
|
if over > 0 {
|
|
s.MinRaise = over
|
|
}
|
|
s.Bet = to
|
|
s.Aggressor = seat
|
|
|
|
text := "raise"
|
|
if p.Stack == 0 {
|
|
p.State = AllIn
|
|
text = "allin"
|
|
s.History += "a"
|
|
} else {
|
|
// The policy was trained against a tree with two raise sizes in it, so the
|
|
// history it reads has to say which one this was: R for a pot-sized raise
|
|
// or bigger, r for anything smaller.
|
|
if pot := s.inPlay(); pot > 0 && float64(over) >= float64(pot)*0.75 {
|
|
s.History += "R"
|
|
} else {
|
|
s.History += "r"
|
|
}
|
|
}
|
|
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: text, Amount: added, Total: to})
|
|
return nil
|
|
}
|
|
|
|
// allin pushes the lot.
|
|
//
|
|
// A short all-in does not reopen the betting. If a player shoves for less than
|
|
// a full raise over the current bet, players who have already acted may call it
|
|
// but may not raise again — otherwise a tiny stack could be used to reopen the
|
|
// action for a partner, which is the oldest collusion trick there is.
|
|
func (s *State) allin(seat int, evs *[]Event) error {
|
|
p := &s.Seats[seat]
|
|
if p.Stack <= 0 {
|
|
return ErrNoChips
|
|
}
|
|
added := p.Stack
|
|
to := p.Bet + added
|
|
|
|
p.Stack = 0
|
|
p.Bet = to
|
|
p.Total += added
|
|
p.State = AllIn
|
|
p.Acted = true
|
|
|
|
if to > s.Bet {
|
|
if over := to - s.Bet; over >= s.MinRaise {
|
|
s.MinRaise = over
|
|
s.Aggressor = seat
|
|
}
|
|
s.Bet = to
|
|
}
|
|
|
|
s.History += "a"
|
|
*evs = append(*evs, Event{Kind: "action", Seat: seat, Text: "allin", Amount: added, Total: to})
|
|
return nil
|
|
}
|
|
|
|
// ---- when is a street over ------------------------------------------------
|
|
|
|
// streetDone reports whether the betting round is finished, given the seat the
|
|
// action would pass to next.
|
|
//
|
|
// The "has acted" check is the load-bearing half. The big blind has money in
|
|
// front of them without having chosen to put it there, so a round where
|
|
// everybody merely limps in has all bets matched while the blind has never had
|
|
// a say. Without this, they never get their option.
|
|
func (s *State) streetDone(next int) bool {
|
|
if s.canActCount() == 0 {
|
|
return true
|
|
}
|
|
for i := range s.Seats {
|
|
p := &s.Seats[i]
|
|
if p.State != Active {
|
|
continue
|
|
}
|
|
if p.Bet != s.Bet || !p.Acted {
|
|
return false
|
|
}
|
|
}
|
|
// The last aggressor being all-in means the action can't get back to them:
|
|
// everyone left has matched the bet above, so there is nothing more to do.
|
|
if s.Seats[s.Aggressor].State == AllIn {
|
|
return true
|
|
}
|
|
return next == s.Aggressor
|
|
}
|
|
|
|
// ---- side pots -------------------------------------------------------------
|
|
|
|
// sidePots slices the pot into layers, one per distinct all-in level. A player
|
|
// can only win the part of the pot they could have lost, so each layer is
|
|
// contested by exactly the players who paid into it.
|
|
//
|
|
// Folded players' chips stay in the pot — they paid for the right to fold — but
|
|
// they are eligible for nothing.
|
|
func (s *State) sidePots() {
|
|
s.collect()
|
|
|
|
var levels []int64
|
|
for i := range s.Seats {
|
|
p := &s.Seats[i]
|
|
if p.State == Folded || p.State == Out || p.Total == 0 {
|
|
continue
|
|
}
|
|
levels = append(levels, p.Total)
|
|
}
|
|
if len(levels) == 0 {
|
|
return
|
|
}
|
|
sort.Slice(levels, func(i, j int) bool { return levels[i] < levels[j] })
|
|
|
|
var pots []Pot
|
|
var prev int64
|
|
for _, level := range levels {
|
|
if level <= prev {
|
|
continue
|
|
}
|
|
var amount int64
|
|
var eligible []int
|
|
for i := range s.Seats {
|
|
p := &s.Seats[i]
|
|
paid := p.Total - prev
|
|
if paid > level-prev {
|
|
paid = level - prev
|
|
}
|
|
if paid > 0 {
|
|
amount += paid // folded money counts toward the pot...
|
|
}
|
|
if p.State != Folded && p.State != Out && p.Total >= level {
|
|
eligible = append(eligible, i) // ...but wins no part of it
|
|
}
|
|
}
|
|
if amount > 0 {
|
|
pots = append(pots, Pot{Amount: amount, Eligible: eligible})
|
|
}
|
|
prev = level
|
|
}
|
|
|
|
if len(pots) > 0 {
|
|
s.Side = pots
|
|
s.Pot = 0
|
|
}
|
|
}
|