diff --git a/internal/games/holdem/holdem.go b/internal/games/holdem/holdem.go index de1a0d6..1690476 100644 --- a/internal/games/holdem/holdem.go +++ b/internal/games/holdem/holdem.go @@ -731,6 +731,24 @@ func (s State) nextIn(from int) int { return from } +// nextDealt is the next seat holding cards this hand, folded or not. Where +// nextIn asks "who is still in the betting", this asks "who was dealt in", and +// the pair is easy to confuse: fold three seats and nextIn walks straight past +// them, so anything counting seats round the table lands somewhere different +// depending on how the hand has gone. Position needs the fixed one — where you +// sit is decided when the button moves and does not change because somebody +// mucked. +func (s State) nextDealt(from int) int { + n := len(s.Seats) + for i := 1; i <= n; i++ { + next := (from + i) % n + if s.Seats[next].State != Out { + return next + } + } + return from +} + // onlyActor is the one seat that can still act. Call it when canActCount is 1. func (s State) onlyActor() int { for i := range s.Seats { @@ -838,8 +856,8 @@ func (s State) Position(seat int) string { return "BB" // heads-up, the other seat is always the big blind } - sb := s.nextIn(s.Button) - bb := s.nextIn(sb) + sb := s.nextDealt(s.Button) + bb := s.nextDealt(sb) switch seat { case sb: return "SB" @@ -847,7 +865,7 @@ func (s State) Position(seat int) string { return "BB" } - utg := s.nextIn(bb) + utg := s.nextDealt(bb) if seat == utg { return "UTG" } @@ -856,7 +874,7 @@ func (s State) Position(seat int) string { // closest to the button is the cutoff. dist, cur := 0, utg for i := 0; i < n; i++ { - cur = s.nextIn(cur) + cur = s.nextDealt(cur) dist++ if cur == seat { break diff --git a/internal/games/holdem/holdem_test.go b/internal/games/holdem/holdem_test.go index d8b8c52..d16085e 100644 --- a/internal/games/holdem/holdem_test.go +++ b/internal/games/holdem/holdem_test.go @@ -726,3 +726,46 @@ func has(evs []Event, kind string) bool { } return false } + +// Where you sit is decided when the button moves, and a fold does not move it. +// Position walked the table with nextIn, which steps over folded seats while the +// seat count still includes them — so as players mucked, the labels slid round +// and a six-handed felt printed CO on three different seats at once. The badge is +// the only thing that reads this, which is exactly why nothing caught it. +func TestPositionsDoNotMoveWhenSeatsFold(t *testing.T) { + s := table(t, Tiers[0], 5, 200) // six-handed + s, _, _ = ApplyMove(s, Move{Kind: Deal}) + + before := make([]string, len(s.Seats)) + for i := range s.Seats { + before[i] = s.Position(i) + } + + // Every seat has its own label, and the ones a six-max table prints are these. + seen := map[string]int{} + for _, p := range before { + seen[p]++ + } + for _, want := range []string{"BTN", "SB", "BB", "UTG", "MP", "CO"} { + if seen[want] != 1 { + t.Errorf("six-handed: %q appears %d times, want exactly once — got %v", + want, seen[want], before) + } + } + + // Now fold seats out of the hand, one at a time. Nobody's position changes by + // mucking — folding is done to the state directly because a fold in the engine + // belongs to whoever is to act, and what is under test is the label, not the turn. + for i := range s.Seats { + if i == You || s.Seats[i].State != Active { + continue + } + s.Seats[i].State = Folded + for j := range s.Seats { + if got := s.Position(j); got != before[j] { + t.Fatalf("seat %d was %q and is now %q after seat %d folded — position is "+ + "where you sit, not who is left", j, before[j], got, i) + } + } + } +}