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
298 lines
9.8 KiB
Go
298 lines
9.8 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 a *human's* winnings is money the
|
|
// house actually made, and it is the only part worth quoting. The bots'
|
|
// chips are not real — the only real money at the table is the players' —
|
|
// so raking a pot a bot won costs nobody anything, and a counter that
|
|
// climbed while every human folded would be telling them it had.
|
|
for _, w := range winners {
|
|
if !s.Seats[w.seat].Bot {
|
|
// The table total (for the audit's delta) and the winner's own
|
|
// running tally (for the ledger line the felt shows them). At a
|
|
// table with two humans these are different numbers: each player is
|
|
// only ever quoted the rake that came out of a pot they won.
|
|
s.Paid += rake / int64(len(winners))
|
|
s.Seats[w.seat].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})
|
|
}
|