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:
@@ -51,13 +51,13 @@ func (s *State) post(seat int, amount int64, which string, evs *[]Event) {
|
||||
// in the hand with no chips and no say — and handing the action to a seat that
|
||||
// cannot act wedges the table.
|
||||
func (s *State) firstPreFlop(bb int) int {
|
||||
if s.dealt() == 2 && s.Seats[s.Button].State == Active {
|
||||
if s.dealt() != 2 {
|
||||
return s.nextCanAct(bb)
|
||||
}
|
||||
if s.Seats[s.Button].State == Active {
|
||||
return s.Button
|
||||
}
|
||||
if s.dealt() == 2 {
|
||||
return s.nextCanAct(s.Button)
|
||||
}
|
||||
return s.nextCanAct(bb)
|
||||
return s.nextCanAct(s.Button)
|
||||
}
|
||||
|
||||
// firstPostFlop is the first seat left of the button, on every street after the
|
||||
|
||||
@@ -258,14 +258,14 @@ func (s State) spotKey(seat int, eq Equity) string {
|
||||
|
||||
// ---- choosing --------------------------------------------------------------
|
||||
|
||||
// Hits and Misses count how often a bot finds itself in the trained policy.
|
||||
// hits and misses count how often a bot finds itself in the trained policy.
|
||||
//
|
||||
// They exist because the way this can break is silently. A key the policy has
|
||||
// never seen is not an error — the bot shrugs and plays pot odds — so a policy
|
||||
// that has stopped matching the game looks exactly like a policy that is working.
|
||||
// gogobee's never matched once, for the whole life of the game, and nobody could
|
||||
// have known by watching it play. Now a test reads these and fails.
|
||||
var Hits, Misses atomic.Int64
|
||||
var hits, misses atomic.Int64
|
||||
|
||||
// botActs plays one bot's turn: work out where it stands, look up what it does
|
||||
// there, throw out anything illegal, and roll for it.
|
||||
@@ -274,9 +274,9 @@ func (s *State) botActs(seat int, evs *[]Event, rng *rand.Rand) {
|
||||
|
||||
probs, ok := loadPolicy()[key]
|
||||
if ok {
|
||||
Hits.Add(1)
|
||||
hits.Add(1)
|
||||
} else {
|
||||
Misses.Add(1)
|
||||
misses.Add(1)
|
||||
probs = potOdds(eq, s, seat)
|
||||
}
|
||||
probs = legal(probs, s, seat)
|
||||
|
||||
@@ -78,6 +78,24 @@ 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.
|
||||
|
||||
@@ -357,7 +357,7 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// Step plays a move for whoever is to act — bot or player — and advances only as
|
||||
// step plays a move for whoever is to act — bot or player — and advances only as
|
||||
// far as the next decision, whoever's it is. Nobody's turn is taken for them.
|
||||
//
|
||||
// This is the seam the trainer plays through, and it exists so that the trainer
|
||||
@@ -365,7 +365,7 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
// same side pots, the same money. The alternative is a second, simplified model
|
||||
// of poker written for the trainer alone — which is what gogobee had, and it is
|
||||
// why its policy encoded a game nobody was dealing.
|
||||
func Step(s State, m Move) (State, []Event, error) {
|
||||
func step(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase != PhaseBetting {
|
||||
return s, nil, ErrNoHand
|
||||
}
|
||||
@@ -381,9 +381,9 @@ func Step(s State, m Move) (State, []Event, error) {
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// Open deals one heads-up hand at the given stacks, stopping at the first
|
||||
// open deals one heads-up hand at the given stacks, stopping at the first
|
||||
// decision. For the trainer: a table with no history and no button rotation.
|
||||
func Open(t Tier, stack0, stack1 int64, seed1, seed2 uint64) (State, error) {
|
||||
func open(t Tier, stack0, stack1 int64, seed1, seed2 uint64) (State, error) {
|
||||
if stack0 <= 0 || stack1 <= 0 {
|
||||
return State{}, ErrBadBuyIn
|
||||
}
|
||||
@@ -405,9 +405,9 @@ func Open(t Tier, stack0, stack1 int64, seed1, seed2 uint64) (State, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Clone deep-copies the table. CFR walks a tree of what-ifs, and a shallow copy
|
||||
// clone deep-copies the table. CFR walks a tree of what-ifs, and a shallow copy
|
||||
// would have every branch writing into the same deck.
|
||||
func (s State) Clone() State {
|
||||
func (s State) clone() State {
|
||||
out := s
|
||||
out.Seats = append([]Seat(nil), s.Seats...)
|
||||
out.Deck = append(cards.Deck(nil), s.Deck...)
|
||||
@@ -741,6 +741,17 @@ func (s State) onlyActor() int {
|
||||
return s.ToAct
|
||||
}
|
||||
|
||||
// anyAllIn reports whether anybody is in the hand with no chips left, which is
|
||||
// the only thing that makes side pots necessary.
|
||||
func (s State) anyAllIn() bool {
|
||||
for i := range s.Seats {
|
||||
if s.Seats[i].State == AllIn {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// canBet reports whether there is anybody left to bet *into*. With one player
|
||||
// still holding chips and the rest all-in, a raise is chips nobody can call, so
|
||||
// no raise is offered — to the player or to a bot.
|
||||
|
||||
@@ -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 ---------------------------------------------------------------
|
||||
|
||||
@@ -158,7 +158,10 @@ func Train(n, workers int, t Tier, minBB, maxBB int64, seed uint64, progress fun
|
||||
}
|
||||
for i := 0; i < share; i++ {
|
||||
tr.iterate(uint64(w)<<40 | uint64(i))
|
||||
if progress != nil && i%2000 == 0 {
|
||||
// i+1, not i: the check fired on the very first pass and credited two
|
||||
// thousand hands before a single one had been walked, which with thirty
|
||||
// workers made the first ETA sixty thousand hands optimistic.
|
||||
if progress != nil && (i+1)%2000 == 0 {
|
||||
done.Lock()
|
||||
completed += 2000
|
||||
c := completed
|
||||
@@ -253,7 +256,7 @@ func (tr *trainer) iterate(id uint64) {
|
||||
t := tr.tier
|
||||
t.RakePct = 0
|
||||
|
||||
s, err := Open(t, stack, stack, id, tr.rng.Uint64())
|
||||
s, err := open(t, stack, stack, id, tr.rng.Uint64())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -262,7 +265,7 @@ func (tr *trainer) iterate(id uint64) {
|
||||
tr.have = [2][4]bool{} // one deal, one set of boards, one set of equities
|
||||
|
||||
for me := 0; me < 2; me++ {
|
||||
tr.walk(s.Clone(), me, start, 0)
|
||||
tr.walk(s.clone(), me, start, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,11 +340,11 @@ func (tr *trainer) walk(s State, me int, start [2]int64, depth int) float64 {
|
||||
|
||||
// play applies one abstract action through the real reducer.
|
||||
func (tr *trainer) play(s State, seat, action int) State {
|
||||
next, _, err := Step(s.Clone(), s.moveFor(action, seat))
|
||||
next, _, err := step(s.clone(), s.moveFor(action, seat))
|
||||
if err != nil {
|
||||
// The mask and the rules disagreed, which is a bug in one of them. Fold and
|
||||
// carry on rather than poison the whole run.
|
||||
next, _, err = Step(s.Clone(), Move{Kind: Fold})
|
||||
next, _, err = step(s.clone(), Move{Kind: Fold})
|
||||
if err != nil {
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ type holdemEventView struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func viewHoldemEvents(evs []holdem.Event, g holdem.State) []holdemEventView {
|
||||
func viewHoldemEvents(evs []holdem.Event) []holdemEventView {
|
||||
out := make([]holdemEventView, 0, len(evs))
|
||||
for _, e := range evs {
|
||||
v := holdemEventView{Kind: e.Kind, Seat: e.Seat, Amount: e.Amount, Total: e.Total, Text: e.Text}
|
||||
@@ -344,6 +344,6 @@ func (s *Server) persistHoldem(w http.ResponseWriter, user string, g holdem.Stat
|
||||
hv := viewHoldem(g)
|
||||
v.Holdem = &hv
|
||||
}
|
||||
v.HoldemEvents = viewHoldemEvents(evs, g)
|
||||
v.HoldemEvents = viewHoldemEvents(evs)
|
||||
writeJSON(w, v)
|
||||
}
|
||||
|
||||
@@ -245,8 +245,13 @@
|
||||
}
|
||||
}
|
||||
if (view.phase === "handover") {
|
||||
topupBtn.disabled = !view.max_topup;
|
||||
topupBtn.textContent = view.max_topup ? "Top up " + money(view.max_topup) : "Top up";
|
||||
// The table has room for max_topup, but the button must not promise chips the
|
||||
// wallet cannot cover — clicking it would only earn a refusal.
|
||||
var wallet = window.PeteGames.view();
|
||||
var can = Math.min(view.max_topup, wallet ? wallet.chips : 0);
|
||||
topupBtn.disabled = can <= 0;
|
||||
topupBtn.dataset.amount = can;
|
||||
topupBtn.textContent = can > 0 ? "Top up " + money(can) : "Top up";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +274,10 @@
|
||||
// happening happens here; render() is the state it settles into.
|
||||
function play(events, final) {
|
||||
var chain = Promise.resolve();
|
||||
// No table to settle into — the session closed and storage has already cleared
|
||||
// it. There is nothing to animate onto, and render() would walk seats that
|
||||
// aren't there.
|
||||
if (!final) { render0(); return Promise.resolve(); }
|
||||
if (!events || !events.length) { render(final); return chain; }
|
||||
|
||||
events.forEach(function (e) {
|
||||
@@ -562,8 +571,10 @@
|
||||
b.addEventListener("click", function () {
|
||||
var which = b.dataset.raisePreset;
|
||||
var to;
|
||||
// A pot-sized raise is: call what's owed, then bet what the pot would then be.
|
||||
// So the total is twice what you owe, plus the pot as it stands.
|
||||
if (which === "max") to = view.max_raise_to;
|
||||
else to = view.owed + view.pot * Number(which) + (view.pot > 0 ? view.owed : 0);
|
||||
else to = 2 * view.owed + view.pot * Number(which);
|
||||
to = Math.max(view.min_raise_to, Math.min(view.max_raise_to, Math.round(to)));
|
||||
slider.value = to;
|
||||
showRaise();
|
||||
@@ -573,7 +584,7 @@
|
||||
if (dealBtn) dealBtn.addEventListener("click", function () { send({ move: "deal" }, betweenMsg); });
|
||||
if (leaveBtn) leaveBtn.addEventListener("click", function () { send({ move: "leave" }, betweenMsg); });
|
||||
if (topupBtn) topupBtn.addEventListener("click", function () {
|
||||
send({ move: "topup", amount: view.max_topup }, betweenMsg);
|
||||
send({ move: "topup", amount: Number(topupBtn.dataset.amount || 0) }, betweenMsg);
|
||||
});
|
||||
|
||||
// ---- sitting down ----------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user