games: the short stack that could win money it never matched
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
This commit is contained in:
@@ -238,6 +238,56 @@ func TestSidePotsPayInLayers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A covered all-in player can only ever win what they matched, and the hand does
|
||||
// not have to end in a run-out for that to be true.
|
||||
//
|
||||
// This one got through everything. The side pots were only ever cut in runout(),
|
||||
// which happens when the betting stops because *nobody* can bet — so a short stack
|
||||
// who shoves and gets called by two players who still have chips behind, and who
|
||||
// then keep betting past them all the way to the river, reached a showdown with the
|
||||
// pots never cut. One pot, everybody eligible, and the short stack takes the lot.
|
||||
//
|
||||
// Chip conservation never saw it: the chips balance perfectly, they just land in
|
||||
// the wrong seat. And every browser session went through runout(), because the
|
||||
// player shoving is what ends the betting. It took reading the code.
|
||||
func TestACoveredAllInCannotWinTheSidePot(t *testing.T) {
|
||||
c := func(r cards.Rank, s cards.Suit) cards.Card { return cards.Card{Rank: r, Suit: s} }
|
||||
|
||||
s := State{
|
||||
Tier: Tiers[1],
|
||||
Phase: PhaseBetting,
|
||||
Street: River,
|
||||
Community: []cards.Card{
|
||||
c(2, cards.Clubs), c(7, cards.Diamonds), c(9, cards.Spades),
|
||||
c(cards.Jack, cards.Hearts), c(4, cards.Clubs),
|
||||
},
|
||||
Seats: []Seat{
|
||||
{Name: "You", Stack: 500, Hole: [2]cards.Card{c(3, cards.Clubs), c(5, cards.Diamonds)}},
|
||||
// All-in for 100, and holding the best hand at the table.
|
||||
{Name: "Short", Bot: true, Hole: [2]cards.Card{c(cards.Ace, cards.Clubs), c(cards.Ace, cards.Diamonds)}},
|
||||
{Name: "Deep", Bot: true, Stack: 500, Hole: [2]cards.Card{c(cards.King, cards.Clubs), c(cards.Queen, cards.Diamonds)}},
|
||||
},
|
||||
}
|
||||
s.Seats[0].Total, s.Seats[0].State = 500, Active
|
||||
s.Seats[1].Total, s.Seats[1].State = 100, AllIn
|
||||
s.Seats[2].Total, s.Seats[2].State = 500, Active
|
||||
s.Pot = 1100 // 100 + 500 + 500
|
||||
|
||||
var evs []Event
|
||||
s.showdown(&evs)
|
||||
|
||||
// The main pot is 100 from each of the three. The other 800 is between the two
|
||||
// who were still betting, and the short stack cannot touch it.
|
||||
if s.Seats[1].Won != 300 {
|
||||
t.Errorf("all-in for 100 against two players, and won %d — the most that can ever "+
|
||||
"be won is the 300 main pot. The side pot was not cut.", s.Seats[1].Won)
|
||||
}
|
||||
if s.Seats[0].Won+s.Seats[2].Won != 800 {
|
||||
t.Errorf("the 800 side pot paid out %d between the two players who were "+
|
||||
"actually contesting it", s.Seats[0].Won+s.Seats[2].Won)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFoldedChipsStayInThePotButWinNothing(t *testing.T) {
|
||||
s := State{
|
||||
Tier: Tiers[1],
|
||||
@@ -599,8 +649,8 @@ func TestThePolicyLoads(t *testing.T) {
|
||||
// was trained on. A six-handed table is a documented approximation of it and
|
||||
// drops off as seats are added, which is why this only asserts on the duel.
|
||||
func TestTheBotsAreActuallyTrained(t *testing.T) {
|
||||
Hits.Store(0)
|
||||
Misses.Store(0)
|
||||
hits.Store(0)
|
||||
misses.Store(0)
|
||||
|
||||
rng := rand.New(rand.NewPCG(11, 12))
|
||||
for game := 0; game < 40; game++ {
|
||||
@@ -623,18 +673,18 @@ func TestTheBotsAreActuallyTrained(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
hits, misses := Hits.Load(), Misses.Load()
|
||||
if hits+misses < 100 {
|
||||
t.Fatalf("the bots only made %d decisions — this test isn't measuring anything", hits+misses)
|
||||
h, m := hits.Load(), misses.Load()
|
||||
if h+m < 100 {
|
||||
t.Fatalf("the bots only made %d decisions — this test isn't measuring anything", h+m)
|
||||
}
|
||||
rate := float64(hits) / float64(hits+misses)
|
||||
rate := float64(h) / float64(h+m)
|
||||
if rate < 0.6 {
|
||||
t.Fatalf("heads-up, the bots found themselves in the trained policy %.0f%% of the time "+
|
||||
"(%d of %d decisions). They are playing the pot-odds fallback, which means the key the "+
|
||||
"trainer writes and the key the table reads have drifted apart. See infoSet.",
|
||||
rate*100, hits, hits+misses)
|
||||
rate*100, h, h+m)
|
||||
}
|
||||
t.Logf("heads-up policy hit rate: %.0f%% (%d of %d decisions)", rate*100, hits, hits+misses)
|
||||
t.Logf("heads-up policy hit rate: %.0f%% (%d of %d decisions)", rate*100, h, h+m)
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user