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