package web import ( "testing" "pete/internal/games/holdem" "pete/internal/storage" ) // Sitting down is the only time chips leave your stack at this table, and getting // up is the only time they come back. Everything in between is inside the engine. func TestHoldemSitTakesTheBuyInAndNothingElse(t *testing.T) { s := newCasino(t) fund(t, 5000) v, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit", map[string]any{"tier": "low", "bots": 2, "buyin": 600})) if code != 200 { t.Fatalf("sit = %d, want 200", code) } if v.Chips != 4400 { t.Fatalf("chips after a 600 buy-in = %d, want 4400", v.Chips) } if v.Game != gameHoldem || v.Holdem == nil { t.Fatalf("sit returned no table: game=%q", v.Game) } g := v.Holdem if g.Stack != 600 { t.Errorf("you sat down with %d, want the 600 you bought in for", g.Stack) } if len(g.Seats) != 3 { t.Fatalf("two bots and you is three seats, got %d", len(g.Seats)) } if g.Phase != "handover" { t.Errorf("phase %q — a table you just sat at has no hand on it yet", g.Phase) } // Play a whole hand, then check that not one chip has crossed the border. deal, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", map[string]any{"move": "deal"})) if code != 200 { t.Fatalf("deal = %d, want 200", code) } for i := 0; deal.Holdem != nil && deal.Holdem.Phase == "betting" && i < 60; i++ { move := "check" if deal.Holdem.Owed > 0 { move = "fold" } deal, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", map[string]any{"move": move})) if code != 200 { t.Fatalf("%s = %d, want 200", move, code) } } if deal.Chips != 4400 { t.Errorf("chips moved during a hand: %d, want the 4400 that were left after the buy-in — "+ "a pot is settled inside the engine, not across the border", deal.Chips) } } // The wall. A bot's hole cards are the game; a browser that held them would make // this unplayable, and the payload is where that has to be true. func TestHoldemNeverSendsABotsCards(t *testing.T) { s := newCasino(t) fund(t, 5000) v, _ := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit", map[string]any{"tier": "micro", "bots": 3, "buyin": 200})) if v.Holdem == nil { t.Fatal("no table") } // Play hands until one of them ends without a showdown, checking every payload // on the way. Folding is the case that matters: nobody has earned the right to // see anybody's cards, so nobody's may be in there. for hand := 0; hand < 8; hand++ { v, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", map[string]any{"move": "deal"})) if code != 200 { t.Fatalf("deal = %d", code) } noBotCards(t, v) for i := 0; v.Holdem != nil && v.Holdem.Phase == "betting" && i < 60; i++ { move := "check" if v.Holdem.Owed > 0 { move = "call" } v, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", map[string]any{"move": move})) if code != 200 { t.Fatalf("%s = %d", move, code) } noBotCards(t, v) } if v.Holdem == nil || v.Holdem.Phase == "done" { return // busted out; the wall held all the way } } } // noBotCards checks a payload. A bot's cards may appear in exactly one place: a // seat that is being shown down, on a board that reached a showdown. func noBotCards(t *testing.T, v tableView) { t.Helper() g := v.Holdem if g == nil { return } shown := g.Street == "showdown" for i, seat := range g.Seats { if i == 0 || len(seat.Cards) == 0 { continue } if !shown { t.Fatalf("seat %d (%s) sent %d cards on the %s — nobody has shown down", i, seat.Name, len(seat.Cards), g.Street) } if seat.State == "folded" { t.Fatalf("seat %d (%s) folded and its cards were sent anyway", i, seat.Name) } } for _, e := range v.HoldemEvents { if e.Seat > 0 && len(e.Cards) > 0 && e.Kind != "show" { t.Fatalf("a %q event carries seat %d's cards — that's a bot's hand", e.Kind, e.Seat) } } } // Getting up is what pays. Everything the session did lands in one movement. func TestHoldemLeavingBringsTheStackBack(t *testing.T) { s := newCasino(t) fund(t, 5000) v, _ := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit", map[string]any{"tier": "low", "bots": 1, "buyin": 500})) if v.Chips != 4500 || v.Holdem == nil { t.Fatalf("sit: chips=%d holdem=%v", v.Chips, v.Holdem) } stack := v.Holdem.Stack v, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", map[string]any{"move": "leave"})) if code != 200 { t.Fatalf("leave = %d, want 200", code) } if v.Chips != 4500+stack { t.Errorf("chips after getting up = %d, want %d (the %d that was in front of us)", v.Chips, 4500+stack, stack) } if v.Game != "" { t.Errorf("still at a table after getting up: %q", v.Game) } // And there is no table left to play at. _, code = call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", map[string]any{"move": "deal"})) if code != 409 { t.Errorf("dealt a hand at a table we got up from: %d, want 409", code) } } // You cannot walk out on a hand you have chips riding on. func TestHoldemCannotLeaveMidHand(t *testing.T) { s := newCasino(t) fund(t, 5000) call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit", map[string]any{"tier": "low", "bots": 2, "buyin": 500})) v, _ := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", map[string]any{"move": "deal"})) if v.Holdem == nil || v.Holdem.Phase != "betting" { t.Skip("the hand ended before we could act") } _, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", map[string]any{"move": "leave"})) if code != 400 { t.Errorf("left in the middle of a hand: %d, want 400", code) } // And the chips are still on the table, not back on the stack. after, err := storage.Chips("@reala:parodia.dev") if err != nil { t.Fatal(err) } if after.Chips != 4500 { t.Errorf("chips = %d, want 4500 — the buy-in is still on the table", after.Chips) } } // A buy-in outside the table's range is not a buy-in, and it must not take chips. func TestHoldemRefusesABadBuyIn(t *testing.T) { s := newCasino(t) fund(t, 5000) tier, _ := holdem.TierBySlug("low") for _, amount := range []int64{tier.MinBuy - 1, tier.MaxBuy + 1, 0} { _, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit", map[string]any{"tier": "low", "bots": 2, "buyin": amount})) if code != 400 { t.Errorf("buy-in of %d at a %d–%d table = %d, want 400", amount, tier.MinBuy, tier.MaxBuy, code) } } st, err := storage.Chips("@reala:parodia.dev") if err != nil { t.Fatal(err) } if st.Chips != 5000 { t.Errorf("a refused buy-in took %d chips", 5000-st.Chips) } } // A top-up is real money crossing the border. If the engine refuses it, the chips // come straight back — the same order, and the same reason, as doubling down. func TestHoldemTopUpRefundsWhenRefused(t *testing.T) { s := newCasino(t) fund(t, 5000) // Sitting down at the maximum means there is no room to top up into. call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit", map[string]any{"tier": "low", "bots": 1, "buyin": 1000})) _, code := call(t, s, s.handleHoldemMove, as(t, s, "reala", "POST", "/api/games/holdem/move", map[string]any{"move": "topup", "amount": 100})) if code != 400 { t.Errorf("topped up over the table maximum: %d, want 400", code) } st, err := storage.Chips("@reala:parodia.dev") if err != nil { t.Fatal(err) } if st.Chips != 4000 { t.Errorf("chips = %d, want 4000 — a refused top-up must give the chips back", st.Chips) } }