package holdem import ( "math/rand/v2" "testing" ) // The reshape's own guard: a table with more than one human actually plays, and // the chips still conserve when the person to act is not always seat zero. // // The solo suite proves the engine still behaves as it did; this proves the thing // that changed. Two humans and two bots sit down, and the driver plays whichever // human the action stops on — which is the whole point of the multiway advance: // it runs the bots itself and hands control back at every *human* seat, not just // at seat zero. // randomMoveFor picks a legal move for a specific seat, the multiway sibling of // randomMove. It never folds when it can check, so hands actually develop. func randomMoveFor(s State, seat int, rng *rand.Rand) Move { owed := s.Owed(seat) var legal []Move if owed > 0 { legal = append(legal, Move{Kind: Fold}, Move{Kind: Call}) } else { legal = append(legal, Move{Kind: Check}) } if s.Seats[seat].Stack > owed && s.canBet() { if to := s.MinRaiseTo(seat); to < s.MaxRaiseTo(seat) { legal = append(legal, Move{Kind: Raise, To: to}) } } return legal[rng.IntN(len(legal))] } func TestMultiwayChipsAreConserved(t *testing.T) { for game := 0; game < 100; game++ { rng := rand.New(rand.NewPCG(uint64(game), 71)) tier := Tiers[game%len(Tiers)] // Two humans, two bots. The humans sit at 0 and 2 so the action genuinely // lands on a non-zero human seat, which is the case the old engine could not // have reached. seats := []SeatConfig{ {Name: "Ana", Stack: tier.MaxBuy}, {Name: "Bot A", Bot: true, Stack: tier.MaxBuy}, {Name: "Bo", Stack: tier.MaxBuy}, {Name: "Bot B", Bot: true, Stack: tier.MaxBuy}, } s, _, err := New(tier, seats, tier.RakePct, uint64(game), 7) if err != nil { t.Fatalf("new table: %v", err) } want := chipsAt(s) for hand := 0; hand < 8 && s.Phase == PhaseHandOver; hand++ { var evs []Event s, evs, err = ApplyMove(s, 0, Move{Kind: Deal}) if err != nil { t.Fatalf("game %d hand %d: deal: %v", game, hand, err) } want += reloaded(evs) check(t, s, want, game, hand, "deal") for step := 0; s.Phase == PhaseBetting; step++ { if step > 400 { t.Fatalf("game %d hand %d: the hand will not end", game, hand) } seat := s.ToAct if s.Seats[seat].Bot { t.Fatalf("game %d: advance stopped on bot seat %d — it should run bots itself", game, seat) } s, _, err = ApplyMove(s, seat, randomMoveFor(s, seat, rng)) if err != nil { t.Fatalf("game %d hand %d seat %d: %v", game, hand, seat, err) } check(t, s, want, game, hand, "move") } } } } // TestMultiwayRejectsOutOfTurnMoves pins that a human cannot act when it is // another human's turn — the betting move is legal only from the seat to act. func TestMultiwayRejectsOutOfTurnMoves(t *testing.T) { tier := Tiers[0] seats := []SeatConfig{ {Name: "Ana", Stack: tier.MaxBuy}, {Name: "Bo", Stack: tier.MaxBuy}, } s, _, err := New(tier, seats, tier.RakePct, 3, 9) if err != nil { t.Fatal(err) } s, _, err = ApplyMove(s, 0, Move{Kind: Deal}) if err != nil { t.Fatal(err) } if s.Phase != PhaseBetting { t.Fatalf("want a live hand, got phase %s", s.Phase) } // Whoever is not to act tries to move. It must be refused, and nothing must // change. other := 1 - s.ToAct before := chipsAt(s) if _, _, err := ApplyMove(s, other, Move{Kind: Call}); err != ErrNotYourTurn { t.Fatalf("want ErrNotYourTurn from the seat not to act, got %v", err) } if got := chipsAt(s); got != before { t.Errorf("a refused move moved chips: %d -> %d", before, got) } }