diff --git a/internal/games/holdem/betting.go b/internal/games/holdem/betting.go index 7390941..7ae1f85 100644 --- a/internal/games/holdem/betting.go +++ b/internal/games/holdem/betting.go @@ -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 diff --git a/internal/games/holdem/cfr.go b/internal/games/holdem/cfr.go index 471d574..0340759 100644 --- a/internal/games/holdem/cfr.go +++ b/internal/games/holdem/cfr.go @@ -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) diff --git a/internal/games/holdem/eval.go b/internal/games/holdem/eval.go index 4d26360..177f054 100644 --- a/internal/games/holdem/eval.go +++ b/internal/games/holdem/eval.go @@ -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. diff --git a/internal/games/holdem/holdem.go b/internal/games/holdem/holdem.go index 3b8b61e..de1a0d6 100644 --- a/internal/games/holdem/holdem.go +++ b/internal/games/holdem/holdem.go @@ -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. diff --git a/internal/games/holdem/holdem_test.go b/internal/games/holdem/holdem_test.go index 0707567..d8b8c52 100644 --- a/internal/games/holdem/holdem_test.go +++ b/internal/games/holdem/holdem_test.go @@ -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 --------------------------------------------------------------- diff --git a/internal/games/holdem/train.go b/internal/games/holdem/train.go index 3c5930c..3a31a07 100644 --- a/internal/games/holdem/train.go +++ b/internal/games/holdem/train.go @@ -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 } diff --git a/internal/web/games_holdem.go b/internal/web/games_holdem.go index d60c31b..dd0f324 100644 --- a/internal/web/games_holdem.go +++ b/internal/web/games_holdem.go @@ -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) } diff --git a/internal/web/static/js/holdem.js b/internal/web/static/js/holdem.js index 6e15c6d..4fa8e3c 100644 --- a/internal/web/static/js/holdem.js +++ b/internal/web/static/js/holdem.js @@ -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 ----------------------------------------------------------