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
293 lines
9.4 KiB
Go
293 lines
9.4 KiB
Go
package holdem
|
|
|
|
import (
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/chehsunliu/poker"
|
|
|
|
"pete/internal/games/cards"
|
|
)
|
|
|
|
// The bridge to the evaluator.
|
|
//
|
|
// The engine deals Pete's own cards.Card — the same one blackjack, solitaire and
|
|
// the felt already speak — and converts at the door. Hand strength is the one
|
|
// thing in this package that is genuinely hard to get right (7-card best-of-5,
|
|
// 7,462 distinct hands), so it is not homegrown: github.com/chehsunliu/poker is
|
|
// a lookup table and it is correct.
|
|
//
|
|
// The conversion is a table built once. Doing it per evaluation would matter:
|
|
// a bot's equity estimate is a thousand seven-card evaluations, and it makes
|
|
// several of those per hand.
|
|
|
|
var (
|
|
pokerRanks = [14]string{"", "A", "2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K"}
|
|
pokerSuits = [4]string{"s", "h", "d", "c"} // cards.Spades, Hearts, Diamonds, Clubs
|
|
|
|
// A var initializer, not an init(). Go builds package-level variables before
|
|
// it runs init functions, so anything else in this package that is itself a
|
|
// var built out of this table — equity.go's deck52 is — would otherwise be
|
|
// built out of an empty one. It was, briefly: every card came out identical,
|
|
// every showdown tied, and every bot believed it held exactly 50% equity.
|
|
pokerOf = func() (t [4][14]poker.Card) {
|
|
for s := cards.Spades; s <= cards.Clubs; s++ {
|
|
for r := cards.Ace; r <= cards.King; r++ {
|
|
t[s][r] = poker.NewCard(pokerRanks[r] + pokerSuits[s])
|
|
}
|
|
}
|
|
return t
|
|
}()
|
|
)
|
|
|
|
// toPoker converts one card for the evaluator.
|
|
func toPoker(c cards.Card) poker.Card { return pokerOf[c.Suit][c.Rank] }
|
|
|
|
func toPokerAll(cs []cards.Card) []poker.Card {
|
|
out := make([]poker.Card, len(cs))
|
|
for i, c := range cs {
|
|
out[i] = toPoker(c)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// rankOf evaluates a seat's best five from its hole cards and the board. Lower
|
|
// is better — 1 is a royal flush — which is the evaluator's convention and not
|
|
// worth inverting, since nothing outside this file ever sees the number.
|
|
func rankOf(hole [2]cards.Card, board []cards.Card) (int32, string) {
|
|
seven := make([]poker.Card, 0, 7)
|
|
seven = append(seven, toPoker(hole[0]), toPoker(hole[1]))
|
|
seven = append(seven, toPokerAll(board)...)
|
|
r := poker.Evaluate(seven)
|
|
return r, strings.ToLower(poker.RankString(r))
|
|
}
|
|
|
|
// ---- showdown -------------------------------------------------------------
|
|
|
|
type ranked struct {
|
|
seat int
|
|
rank int32
|
|
desc string
|
|
}
|
|
|
|
// showdown turns the cards over, splits the pots, and pays. Every player still
|
|
// in the hand shows, in the order the felt should turn them over: best hand
|
|
// first, so the winner is the first card face the player sees.
|
|
func (s *State) showdown(evs *[]Event) {
|
|
s.collect()
|
|
s.Street = Showdown
|
|
|
|
// Cut the side pots, if nobody has cut them yet.
|
|
//
|
|
// runout() does this, but runout only happens when the betting stops because
|
|
// there is nobody left able to bet. A hand can reach a showdown with an all-in
|
|
// player in it and the betting having finished perfectly normally: a short stack
|
|
// shoves, and two players who both have chips behind keep betting past them,
|
|
// street after street, all the way to the river. Nothing has been cut, and the
|
|
// short stack is sitting in a single pot marked eligible for all of it.
|
|
//
|
|
// Which means they can win every chip the deep players put in *after* they were
|
|
// already all-in — money they could never have lost. All-in for 100 against two
|
|
// players who each put in 500, and the best hand takes 1,100 instead of the 300
|
|
// they were playing for. The chips still balance, so conservation says nothing;
|
|
// they just go to the wrong seat.
|
|
if len(s.Side) == 0 && s.anyAllIn() {
|
|
s.sidePots()
|
|
}
|
|
|
|
// Say so. The last street's bets are still sitting in front of the seats that
|
|
// made them, as far as the felt knows, and nothing else in the script is going
|
|
// to tell it they have been swept in.
|
|
*evs = append(*evs, Event{Kind: "pot", Seat: -1, Amount: s.Total()})
|
|
|
|
var live []ranked
|
|
for i := range s.Seats {
|
|
p := &s.Seats[i]
|
|
if p.State == Folded || p.State == Out {
|
|
continue
|
|
}
|
|
r, desc := rankOf(p.Hole, s.Community)
|
|
live = append(live, ranked{seat: i, rank: r, desc: desc})
|
|
}
|
|
sort.Slice(live, func(i, j int) bool { return live[i].rank < live[j].rank })
|
|
|
|
for _, e := range live {
|
|
*evs = append(*evs, Event{
|
|
Kind: "show", Seat: e.seat,
|
|
Cards: []cards.Card{s.Seats[e.seat].Hole[0], s.Seats[e.seat].Hole[1]},
|
|
Text: e.desc,
|
|
})
|
|
}
|
|
|
|
pots := s.Side
|
|
if len(pots) == 0 {
|
|
all := make([]int, 0, len(live))
|
|
for _, e := range live {
|
|
all = append(all, e.seat)
|
|
}
|
|
pots = []Pot{{Amount: s.Pot, Eligible: all}}
|
|
s.Pot = 0
|
|
}
|
|
s.Side = nil
|
|
|
|
for _, pot := range pots {
|
|
s.payPot(pot, live, evs)
|
|
}
|
|
s.endHand(evs)
|
|
}
|
|
|
|
// payPot rakes a pot and splits it between the best eligible hands.
|
|
//
|
|
// The rake comes out of the pot before it is split, which is what a cardroom
|
|
// does and is also the only thing consistent with the rest of this casino: a
|
|
// player pays it out of a pot they *win*, never out of a bet they lose. A hand
|
|
// that dies before the flop is not raked at all — no flop, no drop — so folding
|
|
// your blind round after round costs you exactly the blinds and no fee.
|
|
func (s *State) payPot(pot Pot, live []ranked, evs *[]Event) {
|
|
if pot.Amount <= 0 {
|
|
return
|
|
}
|
|
|
|
eligible := make(map[int]bool, len(pot.Eligible))
|
|
for _, seat := range pot.Eligible {
|
|
eligible[seat] = true
|
|
}
|
|
|
|
var winners []ranked
|
|
best := int32(0)
|
|
for _, e := range live {
|
|
if !eligible[e.seat] {
|
|
continue
|
|
}
|
|
if len(winners) == 0 || e.rank < best {
|
|
best, winners = e.rank, []ranked{e}
|
|
} else if e.rank == best {
|
|
winners = append(winners, e)
|
|
}
|
|
}
|
|
if len(winners) == 0 {
|
|
return
|
|
}
|
|
|
|
amount := pot.Amount
|
|
if s.Flopped {
|
|
rake := int64(math.Floor(float64(amount) * s.Tier.RakePct))
|
|
if cap := s.Tier.BB * rakeCapBB; rake > cap {
|
|
rake = cap
|
|
}
|
|
if rake > 0 {
|
|
amount -= rake
|
|
s.Rake += rake // every chip of it, so the table still balances
|
|
|
|
// But only the part that came out of *your* winnings is money the house
|
|
// actually made, and it is the only part the felt should quote you. The
|
|
// bots' chips are not real — the only real money at this table is yours —
|
|
// so raking a pot a bot won costs you nothing, and a counter that climbed
|
|
// while you folded would be telling you it had.
|
|
for _, w := range winners {
|
|
if w.seat == You {
|
|
s.Paid += rake / int64(len(winners))
|
|
}
|
|
}
|
|
*evs = append(*evs, Event{Kind: "rake", Seat: -1, Amount: rake})
|
|
}
|
|
}
|
|
|
|
share := amount / int64(len(winners))
|
|
odd := amount % int64(len(winners)) // the odd chip goes to the first seat left of the button
|
|
for i, w := range winners {
|
|
won := share
|
|
if i == 0 {
|
|
won += odd
|
|
}
|
|
s.Seats[w.seat].Stack += won
|
|
s.Seats[w.seat].Won += won
|
|
*evs = append(*evs, Event{Kind: "win", Seat: w.seat, Amount: won, Text: w.desc})
|
|
}
|
|
}
|
|
|
|
// takeit ends a hand nobody contested: everyone else folded, so the last player
|
|
// standing takes the pot without showing. Their own uncalled bet comes back
|
|
// first — it was never called, so it was never really in the pot.
|
|
func (s *State) takeit(evs *[]Event) {
|
|
s.uncalled(evs)
|
|
s.collect()
|
|
*evs = append(*evs, Event{Kind: "pot", Seat: -1, Amount: s.Total()})
|
|
|
|
winner := -1
|
|
for i := range s.Seats {
|
|
if s.Seats[i].State != Folded && s.Seats[i].State != Out {
|
|
winner = i
|
|
break
|
|
}
|
|
}
|
|
if winner < 0 {
|
|
s.endHand(evs)
|
|
return
|
|
}
|
|
|
|
// There are never side pots here: they are only cut once the betting is over
|
|
// because everybody is all-in, and a table where everybody is all-in is a table
|
|
// where nobody is left to fold.
|
|
pot := Pot{Amount: s.Pot, Eligible: []int{winner}}
|
|
s.Pot = 0
|
|
s.payPot(pot, []ranked{{seat: winner, rank: 0}}, evs)
|
|
s.endHand(evs)
|
|
}
|
|
|
|
// uncalled returns the unmatched top of a bet. If you shove 500 into a player
|
|
// with 200 behind, 300 of that was never contested and comes straight back.
|
|
//
|
|
// It must run *before* the bets are swept into the pot, and the matched level it
|
|
// measures against counts the players who folded. Their chips are in the pot —
|
|
// they paid to see the bet and then gave up — so the money they put in is money
|
|
// that called. Miss that and a bet folded to on the river comes back whole,
|
|
// including the part that was called on the flop, which mints chips out of air.
|
|
//
|
|
// The rake is the other reason this matters at all. When everybody folds, the
|
|
// winner takes the pot back either way and the arithmetic looks the same — but a
|
|
// pot with an uncalled bet still in it is a pot the house rakes, and it would be
|
|
// raking the player on their own money that nobody ever contested.
|
|
func (s *State) uncalled(evs *[]Event) {
|
|
top, topSeat := int64(-1), -1
|
|
for i := range s.Seats {
|
|
p := &s.Seats[i]
|
|
if p.State == Folded || p.State == Out {
|
|
continue
|
|
}
|
|
if p.Total > top {
|
|
top, topSeat = p.Total, i
|
|
}
|
|
}
|
|
if topSeat < 0 {
|
|
return
|
|
}
|
|
|
|
var matched int64 // the most anybody else put in, whether or not they're still in
|
|
for i := range s.Seats {
|
|
if i == topSeat || s.Seats[i].State == Out {
|
|
continue
|
|
}
|
|
if s.Seats[i].Total > matched {
|
|
matched = s.Seats[i].Total
|
|
}
|
|
}
|
|
|
|
excess := top - matched
|
|
p := &s.Seats[topSeat]
|
|
if excess <= 0 || excess > p.Bet {
|
|
// An uncalled bet is always part of the street it was made on, so it cannot
|
|
// be bigger than what that seat has in front of them right now.
|
|
return
|
|
}
|
|
|
|
p.Stack += excess
|
|
p.Total -= excess
|
|
p.Bet -= excess
|
|
if p.State == AllIn && p.Stack > 0 {
|
|
p.State = Active // they were never really all-in against anybody
|
|
}
|
|
*evs = append(*evs, Event{Kind: "uncalled", Seat: topSeat, Amount: excess})
|
|
}
|