Files
Pete/internal/games/holdem/eval.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

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