games: the poker engine learns there can be more than one of you
Phase C, the engine half: hold'em becomes multiway, and the redaction that was a bug-in-one-handler becomes the security boundary the plan warned it would. - const You is gone. A table is a list of seats and which are human is a per-seat property, not the fixed index zero. New(tier, []SeatConfig, ...) seats the ring; SoloSeats builds the old one-human-plus-bots shape the solo handler still opens. - ApplyMove(state, seat, move) — seat identity enters the engine in exactly one place; every helper below already worked on indices. The advance loop stops at any human (not just seat 0), so one request plays the bots and hands control back at whichever person is next to act. - deal() now emits every seat's hole cards. The engine cannot redact a stream it doesn't know the audience of, so it stops trying: the view layer builds each viewer's redacted copy. viewHoldem/viewHoldemEvents take a viewerSeat. - Rake attributed to Paid whenever a *human* wins, not just seat 0 — real house income is rake off any player's pot, and bot pots are house-vs-house. - Bust is per-seat: at a solo table it still ends the session (PhaseDone), at a shared one a busted human just goes Out and the table plays on. Tests, three ways, all green: - the solo suite unchanged as a regression guard (a test-local You=0 alias); - TestMultiwayChipsAreConserved — 100 games, two humans at seats 0 and 2, chips counted after every move, proving the reshape actually plays; - TestHoldemViewNeverLeaksAnotherSeatsCards — renders every seat's view and event stream at every street and greps for anyone else's cards. Mutation-tested: undo the redaction and it fails on the preflop deal. No handlers rewired yet — the solo path still calls New(SoloSeats(...)) and renders for seat 0, so nothing a player sees has changed. The table cutover is next. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
BIN
holdem-train
Executable file
BIN
holdem-train
Executable file
Binary file not shown.
@@ -180,13 +180,13 @@ func (s *State) payPot(pot Pot, live []ranked, evs *[]Event) {
|
||||
amount -= rake
|
||||
s.Rake += rake // every chip of it, so the table still balances
|
||||
|
||||
// But only the part that came out of *your* winnings is money the house
|
||||
// actually made, and it is the only part the felt should quote you. The
|
||||
// bots' chips are not real — the only real money at this table is yours —
|
||||
// so raking a pot a bot won costs you nothing, and a counter that climbed
|
||||
// while you folded would be telling you it had.
|
||||
// But only the part that came out of a *human's* winnings is money the
|
||||
// house actually made, and it is the only part worth quoting. The bots'
|
||||
// chips are not real — the only real money at the table is the players' —
|
||||
// so raking a pot a bot won costs nobody anything, and a counter that
|
||||
// climbed while every human folded would be telling them it had.
|
||||
for _, w := range winners {
|
||||
if w.seat == You {
|
||||
if !s.Seats[w.seat].Bot {
|
||||
s.Paid += rake / int64(len(winners))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,9 +51,6 @@ var (
|
||||
ErrTableFull = errors.New("holdem: too many seats")
|
||||
)
|
||||
|
||||
// You are always seat zero. The bots are the seats after you.
|
||||
const You = 0
|
||||
|
||||
// rakeCapBB caps the rake on any one pot at three big blinds, which is what a
|
||||
// cardroom does. Without a cap, five percent of a big pot is a lot of money to
|
||||
// take off a player for winning it.
|
||||
@@ -264,46 +261,86 @@ type Move struct {
|
||||
// botNames are the regulars. Six of them so a full table never has two.
|
||||
var botNames = []string{"Dice", "Marjorie", "Ox", "Sunny", "Pinch", "The Reverend"}
|
||||
|
||||
// New sits you down. The buy-in is chips the caller has already taken off the
|
||||
// player's stack; this engine only ever gives them back through Leave.
|
||||
// SeatConfig is one chair the table opens with. A shared table is seated by the
|
||||
// runtime: humans in the chairs people have taken, bots in the rest. Solo play is
|
||||
// just the case where exactly one chair is human, which is why there is no longer
|
||||
// a separate solo constructor and no seat-zero-is-you convention — a table is a
|
||||
// list of seats, and who is human is a property of each seat, not of its index.
|
||||
type SeatConfig struct {
|
||||
Name string
|
||||
Bot bool
|
||||
// Stack is the starting chips: a human's buy-in (already taken off their chip
|
||||
// stack by the caller), or a bot's stake from the house.
|
||||
Stack int64
|
||||
}
|
||||
|
||||
// New opens a table and seats it. No hand is dealt yet — the table opens on
|
||||
// PhaseHandOver, the state a table between hands is in, and the first Deal starts
|
||||
// the first hand.
|
||||
//
|
||||
// No hand is dealt yet — the table opens on PhaseHandOver, which is the state a
|
||||
// table between hands is in, and the first Deal move starts the first hand.
|
||||
func New(t Tier, bots int, buyIn int64, rakePct float64, seed1, seed2 uint64) (State, []Event, error) {
|
||||
if bots < 1 || bots > MaxBots {
|
||||
// The engine gives a human's chips back only by the runtime reading their stack
|
||||
// when they leave; it never credits a chip stack itself. Bot stacks are house
|
||||
// chips and not real money — the only real money at the table is the humans'.
|
||||
func New(t Tier, seats []SeatConfig, rakePct float64, seed1, seed2 uint64) (State, []Event, error) {
|
||||
if len(seats) < 2 || len(seats) > MaxBots+1 {
|
||||
return State{}, nil, ErrTableFull
|
||||
}
|
||||
if buyIn < t.MinBuy || buyIn > t.MaxBuy {
|
||||
return State{}, nil, ErrBadBuyIn
|
||||
}
|
||||
t.RakePct = rakePct
|
||||
|
||||
s := State{
|
||||
Tier: t,
|
||||
Button: 0,
|
||||
Phase: PhaseHandOver,
|
||||
BoughtIn: buyIn,
|
||||
Seed1: seed1,
|
||||
Seed2: seed2,
|
||||
}
|
||||
s.Seats = append(s.Seats, Seat{Name: "You", Stack: buyIn})
|
||||
for i := 0; i < bots; i++ {
|
||||
s.Seats = append(s.Seats, Seat{Name: botNames[i], Bot: true, Stack: t.MaxBuy})
|
||||
var evs []Event
|
||||
for _, sc := range seats {
|
||||
if !sc.Bot && (sc.Stack < t.MinBuy || sc.Stack > t.MaxBuy) {
|
||||
return State{}, nil, ErrBadBuyIn
|
||||
}
|
||||
i := len(s.Seats)
|
||||
s.Seats = append(s.Seats, Seat{Name: sc.Name, Bot: sc.Bot, Stack: sc.Stack})
|
||||
if !sc.Bot {
|
||||
// BoughtIn is the sum of what the humans brought — the audit's idea of the
|
||||
// stake. Per-seat border accounting lives in storage (game_seats.staked),
|
||||
// not here; the engine only ever moves chips within the table.
|
||||
s.BoughtIn += sc.Stack
|
||||
evs = append(evs, Event{Kind: "sit", Seat: i, Amount: sc.Stack, Text: t.Name})
|
||||
}
|
||||
}
|
||||
|
||||
// The button starts to your right, so the first hand deals you the small blind
|
||||
// heads-up and the button on a full table — either way you are in the action
|
||||
// from the first card rather than folding your way in.
|
||||
// The button starts at the last seat, so deal() moves it to the first: a full
|
||||
// table puts the earliest seat on the button, and heads-up puts it on the small
|
||||
// blind, in the action from the first card either way.
|
||||
s.Button = len(s.Seats) - 1
|
||||
|
||||
evs := []Event{{Kind: "sit", Seat: You, Amount: buyIn, Text: t.Name}}
|
||||
return s, evs, nil
|
||||
}
|
||||
|
||||
// ApplyMove is the whole engine. It plays your move, then every bot behind you,
|
||||
// deals whatever streets that finishes, and stops either when it is your turn
|
||||
// again or when the hand is over.
|
||||
func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
// soloSeats builds the seat list for a table of one human and n bots — the shape
|
||||
// every table had before multiplayer, and the one the solo handler still opens.
|
||||
// The human is seat zero and takes buyIn; the bots take the table maximum.
|
||||
// SoloSeats is exported for the handler that still opens a solo table. See New.
|
||||
func SoloSeats(t Tier, bots int, buyIn int64) []SeatConfig {
|
||||
seats := []SeatConfig{{Name: "You", Stack: buyIn}}
|
||||
for i := 0; i < bots; i++ {
|
||||
seats = append(seats, SeatConfig{Name: botNames[i], Bot: true, Stack: t.MaxBuy})
|
||||
}
|
||||
return seats
|
||||
}
|
||||
|
||||
// ApplyMove is the whole engine. It plays the acting seat's move, then every bot
|
||||
// behind them, deals whatever streets that finishes, and stops when the action
|
||||
// reaches a human — the same one again, or another at the table — or when the
|
||||
// hand is over.
|
||||
//
|
||||
// seat is who is acting. A betting move is legal only from the seat whose turn it
|
||||
// is; the session moves (Deal, TopUp, Leave) belong to the seat that sent them.
|
||||
// This is the one place seat identity enters the engine — everything below works
|
||||
// on seat indices already.
|
||||
func ApplyMove(s State, seat int, m Move) (State, []Event, error) {
|
||||
if seat < 0 || seat >= len(s.Seats) {
|
||||
return s, nil, ErrUnknownMove
|
||||
}
|
||||
if s.Phase == PhaseDone {
|
||||
return s, nil, ErrOver
|
||||
}
|
||||
@@ -322,32 +359,36 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase != PhaseHandOver {
|
||||
return s, nil, ErrHandLive
|
||||
}
|
||||
if m.Amount <= 0 || s.Seats[You].Stack+m.Amount > s.Tier.MaxBuy {
|
||||
if m.Amount <= 0 || s.Seats[seat].Stack+m.Amount > s.Tier.MaxBuy {
|
||||
return s, nil, ErrBadBuyIn
|
||||
}
|
||||
s.Seats[You].Stack += m.Amount
|
||||
s.Seats[seat].Stack += m.Amount
|
||||
s.BoughtIn += m.Amount
|
||||
evs = append(evs, Event{Kind: "topup", Seat: You, Amount: m.Amount})
|
||||
evs = append(evs, Event{Kind: "topup", Seat: seat, Amount: m.Amount})
|
||||
|
||||
case Leave:
|
||||
if s.Phase != PhaseHandOver {
|
||||
return s, nil, ErrHandLive
|
||||
}
|
||||
// Getting up at a solo table ends the session and pays the stack out; the
|
||||
// runtime reads Payout and crosses the border. At a shared table leaving is a
|
||||
// storage operation (LeaveTable swaps the seat for a bot in the settle tx),
|
||||
// and this branch is not the path taken — see the handler.
|
||||
s.Phase = PhaseDone
|
||||
s.Payout = s.Seats[You].Stack
|
||||
evs = append(evs, Event{Kind: "leave", Seat: You, Amount: s.Payout})
|
||||
s.Payout = s.Seats[seat].Stack
|
||||
evs = append(evs, Event{Kind: "leave", Seat: seat, Amount: s.Payout})
|
||||
|
||||
case Fold, Check, Call, Raise, Shove:
|
||||
if s.Phase != PhaseBetting {
|
||||
return s, nil, ErrNoHand
|
||||
}
|
||||
if s.ToAct != You {
|
||||
if s.ToAct != seat {
|
||||
return s, nil, ErrNotYourTurn
|
||||
}
|
||||
if err := s.act(You, m, &evs); err != nil {
|
||||
if err := s.act(seat, m, &evs); err != nil {
|
||||
return s, nil, err // nothing happened; the caller keeps the old state
|
||||
}
|
||||
s.ToAct = s.nextCanAct(You)
|
||||
s.ToAct = s.nextCanAct(seat)
|
||||
s.advance(&evs, rng, true)
|
||||
|
||||
default:
|
||||
@@ -485,8 +526,8 @@ func (s *State) advance(evs *[]Event, rng *rand.Rand, bots bool) {
|
||||
continue
|
||||
}
|
||||
|
||||
if s.ToAct == You || !bots {
|
||||
return // a decision that isn't ours to make
|
||||
if !s.Seats[s.ToAct].Bot || !bots {
|
||||
return // a decision a human has to make, or the trainer wants to
|
||||
}
|
||||
|
||||
s.botActs(s.ToAct, evs, rng)
|
||||
@@ -546,10 +587,19 @@ func (s *State) deal(evs *[]Event, rng *rand.Rand) {
|
||||
p.Hole[round] = c
|
||||
}
|
||||
}
|
||||
// Only your cards go into the script. The bots' are in the state, on this side
|
||||
// of the wire, and the only thing that ever turns them over is a showdown.
|
||||
*evs = append(*evs, Event{Kind: "hole", Seat: You,
|
||||
Cards: []cards.Card{s.Seats[You].Hole[0], s.Seats[You].Hole[1]}})
|
||||
// A hole event per dealt seat, each carrying that seat's cards. The engine no
|
||||
// longer decides who may see what — it cannot, now that a table has more than
|
||||
// one human — so it emits every hand and the view layer redacts per viewer,
|
||||
// nulling the cards of every seat but the one watching. That per-seat redaction
|
||||
// is the whole security boundary once these events fan out over SSE, and it has
|
||||
// a test that renders each seat's view and greps for anybody else's cards.
|
||||
for i := range s.Seats {
|
||||
if s.Seats[i].State == Out {
|
||||
continue
|
||||
}
|
||||
*evs = append(*evs, Event{Kind: "hole", Seat: i,
|
||||
Cards: []cards.Card{s.Seats[i].Hole[0], s.Seats[i].Hole[1]}})
|
||||
}
|
||||
|
||||
s.ToAct = s.firstPreFlop(bb)
|
||||
}
|
||||
@@ -627,18 +677,34 @@ func (s *State) endHand(evs *[]Event) {
|
||||
s.Pot = 0
|
||||
s.Side = nil
|
||||
s.Phase = PhaseHandOver
|
||||
*evs = append(*evs, Event{Kind: "end", Seat: -1, Amount: s.Seats[You].Stack})
|
||||
*evs = append(*evs, Event{Kind: "end", Seat: -1})
|
||||
|
||||
// Busting is the end of the session, not the end of a hand. There is nothing
|
||||
// to deal you and nothing to give back, so the table closes and you sit down
|
||||
// again — which is a buy-in, and a buy-in is a decision worth making on purpose.
|
||||
if s.Seats[You].Stack <= 0 {
|
||||
// Busting is the end of a session, not of a hand. At a solo table there is
|
||||
// nothing to deal the one human and nothing to give back, so the table closes
|
||||
// and they sit down again — a buy-in, and a buy-in is a decision worth making
|
||||
// on purpose. At a shared table one human busting is not everyone's business:
|
||||
// their seat simply goes Out and the runtime offers the rebuy while the table
|
||||
// plays on. So the whole-session bust only fires when there is a single human.
|
||||
humans := s.humanSeats()
|
||||
if len(humans) == 1 && s.Seats[humans[0]].Stack <= 0 {
|
||||
s.Phase = PhaseDone
|
||||
s.Payout = 0
|
||||
*evs = append(*evs, Event{Kind: "bust", Seat: You})
|
||||
*evs = append(*evs, Event{Kind: "bust", Seat: humans[0]})
|
||||
}
|
||||
}
|
||||
|
||||
// humanSeats lists the seats a person is sitting in. Bots are the house; the only
|
||||
// real money at the table is in these.
|
||||
func (s State) humanSeats() []int {
|
||||
var out []int
|
||||
for i := range s.Seats {
|
||||
if !s.Seats[i].Bot {
|
||||
out = append(out, i)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- the small stuff -------------------------------------------------------
|
||||
|
||||
// next derives this step's generator and advances the step count.
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestChipsAreConserved(t *testing.T) {
|
||||
bots := 1 + game%MaxBots
|
||||
tier := Tiers[game%len(Tiers)]
|
||||
|
||||
s, _, err := New(tier, bots, tier.MaxBuy, tier.RakePct, uint64(game), 7)
|
||||
s, _, err := New(tier, SoloSeats(tier, bots, tier.MaxBuy), tier.RakePct, uint64(game), 7)
|
||||
if err != nil {
|
||||
t.Fatalf("new table: %v", err)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func TestChipsAreConserved(t *testing.T) {
|
||||
|
||||
for hand := 0; hand < 8 && s.Phase != PhaseDone; hand++ {
|
||||
var evs []Event
|
||||
s, evs, err = ApplyMove(s, Move{Kind: Deal})
|
||||
s, evs, err = apply(s, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatalf("game %d hand %d: deal: %v", game, hand, err)
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func TestChipsAreConserved(t *testing.T) {
|
||||
if step > 200 {
|
||||
t.Fatalf("game %d hand %d: the hand will not end", game, hand)
|
||||
}
|
||||
s, _, err = ApplyMove(s, randomMove(s, rng))
|
||||
s, _, err = apply(s, randomMove(s, rng))
|
||||
if err != nil {
|
||||
t.Fatalf("game %d hand %d: %v", game, hand, err)
|
||||
}
|
||||
@@ -98,6 +98,21 @@ func stacks(s State) []int64 {
|
||||
return out
|
||||
}
|
||||
|
||||
// You is seat zero — the shape every test in this file is written against. The
|
||||
// engine no longer has this constant: a table is a list of seats and which are
|
||||
// human is a per-seat property, not a fixed index. But these tests all seat one
|
||||
// human at zero (the pre-multiplayer solo shape), so a test-local alias keeps
|
||||
// them readable as the regression guard they are. The multiway behaviour has its
|
||||
// own tests, which do not assume it.
|
||||
const You = 0
|
||||
|
||||
// apply plays a move as the seat whose turn it is at a solo table — always the
|
||||
// human at seat zero. It wraps the seat-parameterized ApplyMove so the solo tests
|
||||
// read as they did before the reshape.
|
||||
func apply(s State, m Move) (State, []Event, error) {
|
||||
return ApplyMove(s, You, m)
|
||||
}
|
||||
|
||||
// randomMove picks something legal for the player, without any thought at all.
|
||||
// A bad player is exactly what this test wants: it gets into all-ins, folds,
|
||||
// short stacks and split pots far faster than a good one would.
|
||||
@@ -122,7 +137,7 @@ func randomMove(s State, rng *rand.Rand) Move {
|
||||
|
||||
func TestHeadsUpButtonIsTheSmallBlindAndActsFirst(t *testing.T) {
|
||||
s := table(t, Tiers[0], 1, 200)
|
||||
s, evs, err := ApplyMove(s, Move{Kind: Deal})
|
||||
s, evs, err := apply(s, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -154,13 +169,13 @@ func TestTheBigBlindGetsTheirOption(t *testing.T) {
|
||||
// A table where everyone just calls: the big blind has the bet matched without
|
||||
// ever having chosen anything, and the street must not end until they speak.
|
||||
s := table(t, Tiers[0], 1, 200)
|
||||
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
|
||||
// Find a hand where the player is the big blind. The button alternates, so at
|
||||
// most a couple of deals.
|
||||
for i := 0; i < 6 && s.Position(You) != "BB"; i++ {
|
||||
s = playOut(t, s)
|
||||
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
}
|
||||
if s.Position(You) != "BB" {
|
||||
t.Skip("never dealt the big blind")
|
||||
@@ -170,7 +185,7 @@ func TestTheBigBlindGetsTheirOption(t *testing.T) {
|
||||
}
|
||||
if s.ToAct == You && s.Owed(You) == 0 {
|
||||
// This is the option: nothing to call, but the hand is still ours to act on.
|
||||
if _, _, err := ApplyMove(s, Move{Kind: Check}); err != nil {
|
||||
if _, _, err := apply(s, Move{Kind: Check}); err != nil {
|
||||
t.Errorf("the big blind cannot check their option: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -388,7 +403,7 @@ func TestTheRakeIsFivePercentUnderTheCap(t *testing.T) {
|
||||
// house quietly took nothing from every pot for an afternoon.
|
||||
func TestTheRakeSurvivesTheConstructor(t *testing.T) {
|
||||
tier := Tiers[1] // 5/10, so the cap is 30
|
||||
s, _, err := New(tier, 1, tier.MaxBuy, 0.05, 1, 2)
|
||||
s, _, err := New(tier, SoloSeats(tier, 1, tier.MaxBuy), 0.05, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -462,25 +477,25 @@ func TestASplitPotSplits(t *testing.T) {
|
||||
|
||||
func TestYouCannotWalkOutOfALiveHand(t *testing.T) {
|
||||
s := table(t, Tiers[0], 2, 200)
|
||||
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
if s.Phase != PhaseBetting {
|
||||
t.Skip("the hand ended before the player could act")
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Move{Kind: Leave}); err != ErrHandLive {
|
||||
if _, _, err := apply(s, Move{Kind: Leave}); err != ErrHandLive {
|
||||
t.Errorf("leaving mid-hand gave %v, want ErrHandLive", err)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 10}); err != ErrHandLive {
|
||||
if _, _, err := apply(s, Move{Kind: TopUp, Amount: 10}); err != ErrHandLive {
|
||||
t.Errorf("topping up mid-hand gave %v, want ErrHandLive", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeavingTakesTheStackHome(t *testing.T) {
|
||||
s := table(t, Tiers[0], 1, 200)
|
||||
s, _, _ = ApplyMove(s, Move{Kind: Deal})
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
s = playOut(t, s)
|
||||
|
||||
stack := s.Seats[You].Stack
|
||||
s, _, err := ApplyMove(s, Move{Kind: Leave})
|
||||
s, _, err := apply(s, Move{Kind: Leave})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -490,7 +505,7 @@ func TestLeavingTakesTheStackHome(t *testing.T) {
|
||||
if s.Payout != stack {
|
||||
t.Errorf("payout %d, want the %d that was in front of us", s.Payout, stack)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Move{Kind: Deal}); err != ErrOver {
|
||||
if _, _, err := apply(s, Move{Kind: Deal}); err != ErrOver {
|
||||
t.Errorf("dealt a hand at a table we got up from: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -514,12 +529,12 @@ func TestBustingEndsTheSession(t *testing.T) {
|
||||
|
||||
func TestATopUpCannotGoOverTheTableMax(t *testing.T) {
|
||||
s := table(t, Tiers[0], 1, 200) // max buy is 200, and we're at it
|
||||
if _, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 1}); err != ErrBadBuyIn {
|
||||
if _, _, err := apply(s, Move{Kind: TopUp, Amount: 1}); err != ErrBadBuyIn {
|
||||
t.Errorf("topped up over the table maximum: %v", err)
|
||||
}
|
||||
|
||||
s = table(t, Tiers[0], 1, 100)
|
||||
s, _, err := ApplyMove(s, Move{Kind: TopUp, Amount: 50})
|
||||
s, _, err := apply(s, Move{Kind: TopUp, Amount: 50})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -534,47 +549,45 @@ func TestATopUpCannotGoOverTheTableMax(t *testing.T) {
|
||||
func TestABuyInHasToBeInRange(t *testing.T) {
|
||||
tier := Tiers[0]
|
||||
for _, amount := range []int64{0, tier.MinBuy - 1, tier.MaxBuy + 1} {
|
||||
if _, _, err := New(tier, 1, amount, 5, 1, 2); err != ErrBadBuyIn {
|
||||
if _, _, err := New(tier, SoloSeats(tier, 1, amount), 5, 1, 2); err != ErrBadBuyIn {
|
||||
t.Errorf("buy-in of %d at a %d–%d table: %v", amount, tier.MinBuy, tier.MaxBuy, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- what the player is allowed to know ------------------------------------
|
||||
// ---- what the raw script carries ------------------------------------------
|
||||
|
||||
func TestTheScriptNeverCarriesABotsCards(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(4, 4))
|
||||
s := table(t, Tiers[0], 3, 200)
|
||||
|
||||
for hand := 0; hand < 20 && s.Phase != PhaseDone; hand++ {
|
||||
s, evs, err := ApplyMove(s, Move{Kind: Deal})
|
||||
// The engine no longer redacts the event stream, and this pins why: a shared
|
||||
// table has more than one human, so the engine cannot know who a stream is for.
|
||||
// It emits every seat's hole cards, and the *view* layer builds each viewer's
|
||||
// redacted copy — that per-seat redaction is the security boundary now, and it
|
||||
// has its own test in the web package (TestHoldemViewNeverLeaksAnotherSeatsCards).
|
||||
//
|
||||
// So the engine-level contract flipped: the deal must carry a hole event for
|
||||
// every dealt seat, or a viewer would have no cards of their own to be shown.
|
||||
func TestTheDealScriptCarriesEverySeatsHole(t *testing.T) {
|
||||
s := table(t, Tiers[0], 3, 200) // four seats: one human, three bots
|
||||
s, evs, err := apply(s, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noBotCards(t, s, evs)
|
||||
|
||||
for s.Phase == PhaseBetting {
|
||||
var next []Event
|
||||
s, next, err = ApplyMove(s, randomMove(s, rng))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noBotCards(t, s, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A bot's cards may appear in exactly one kind of event: the showdown that turns
|
||||
// them face up, which is the moment they stop being secret.
|
||||
func noBotCards(t *testing.T, s State, evs []Event) {
|
||||
t.Helper()
|
||||
holes := map[int][]cards.Card{}
|
||||
for _, e := range evs {
|
||||
if len(e.Cards) == 0 || e.Seat < 0 || e.Kind == "show" {
|
||||
if e.Kind == "hole" {
|
||||
holes[e.Seat] = e.Cards
|
||||
}
|
||||
}
|
||||
for i := range s.Seats {
|
||||
if s.Seats[i].State == Out {
|
||||
continue
|
||||
}
|
||||
if e.Seat != You && s.Seats[e.Seat].Bot {
|
||||
t.Fatalf("a %q event carries seat %d's cards (%v) — that's a bot's hand",
|
||||
e.Kind, e.Seat, e.Cards)
|
||||
got := holes[i]
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("seat %d got no hole event; the view has nothing to redact from", i)
|
||||
}
|
||||
if got[0] != s.Seats[i].Hole[0] || got[1] != s.Seats[i].Hole[1] {
|
||||
t.Errorf("seat %d hole event %v disagrees with state %v", i, got, s.Seats[i].Hole)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -655,17 +668,17 @@ func TestTheBotsAreActuallyTrained(t *testing.T) {
|
||||
rng := rand.New(rand.NewPCG(11, 12))
|
||||
for game := 0; game < 40; game++ {
|
||||
tier := Tiers[1]
|
||||
s, _, err := New(tier, 1, tier.MaxBuy, tier.RakePct, uint64(game), 5)
|
||||
s, _, err := New(tier, SoloSeats(tier, 1, tier.MaxBuy), tier.RakePct, uint64(game), 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for hand := 0; hand < 6 && s.Phase != PhaseDone; hand++ {
|
||||
s, _, err = ApplyMove(s, Move{Kind: Deal})
|
||||
s, _, err = apply(s, Move{Kind: Deal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for s.Phase == PhaseBetting {
|
||||
s, _, err = ApplyMove(s, randomMove(s, rng))
|
||||
s, _, err = apply(s, randomMove(s, rng))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -691,7 +704,7 @@ func TestTheBotsAreActuallyTrained(t *testing.T) {
|
||||
|
||||
func table(t *testing.T, tier Tier, bots int, buyIn int64) State {
|
||||
t.Helper()
|
||||
s, _, err := New(tier, bots, buyIn, tier.RakePct, 1, 2)
|
||||
s, _, err := New(tier, SoloSeats(tier, bots, buyIn), tier.RakePct, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("new table: %v", err)
|
||||
}
|
||||
@@ -710,7 +723,7 @@ func playOut(t *testing.T, s State) State {
|
||||
move = Move{Kind: Check}
|
||||
}
|
||||
var err error
|
||||
s, _, err = ApplyMove(s, move)
|
||||
s, _, err = apply(s, move)
|
||||
if err != nil {
|
||||
t.Fatalf("playing out: %v", err)
|
||||
}
|
||||
@@ -734,7 +747,7 @@ func has(evs []Event, kind string) bool {
|
||||
// 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})
|
||||
s, _, _ = apply(s, Move{Kind: Deal})
|
||||
|
||||
before := make([]string, len(s.Seats))
|
||||
for i := range s.Seats {
|
||||
|
||||
112
internal/games/holdem/multiway_test.go
Normal file
112
internal/games/holdem/multiway_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,10 @@ type holdemSeatView struct {
|
||||
Won int64 `json:"won,omitempty"`
|
||||
}
|
||||
|
||||
// soloViewer is the seat the still-solo handler renders for: the one human, at
|
||||
// zero. The shared-table path passes the seat of whoever is actually watching.
|
||||
const soloViewer = 0
|
||||
|
||||
var seatStates = map[holdem.SeatState]string{
|
||||
holdem.Active: "active",
|
||||
holdem.Folded: "folded",
|
||||
@@ -81,7 +85,17 @@ type holdemView struct {
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
}
|
||||
|
||||
func viewHoldem(g holdem.State) holdemView {
|
||||
// viewHoldem renders the table as one seat may see it. viewer is which seat is
|
||||
// looking — their cards are the only hole cards it will ever put in the payload
|
||||
// (until a showdown turns the rest over), and the action panel is filled in only
|
||||
// when it is that seat's turn.
|
||||
//
|
||||
// This is the security boundary. Before SSE a missed check here leaked one bot's
|
||||
// cards to one player through a bug in one handler; now the same view fans to
|
||||
// every subscriber's stream, so a seat that renders anyone else's hole card fans
|
||||
// it to the whole table. TestHoldemViewNeverLeaksAnotherSeatsCards renders every
|
||||
// seat's view at every street and greps for cards that are not theirs.
|
||||
func viewHoldem(g holdem.State, viewer int) holdemView {
|
||||
v := holdemView{
|
||||
Tier: g.Tier,
|
||||
Button: g.Button,
|
||||
@@ -90,9 +104,9 @@ func viewHoldem(g holdem.State) holdemView {
|
||||
Pot: g.Total(),
|
||||
ToAct: g.ToAct,
|
||||
Phase: string(g.Phase),
|
||||
Stack: g.Seats[holdem.You].Stack,
|
||||
Stack: g.Seats[viewer].Stack,
|
||||
BoughtIn: g.BoughtIn,
|
||||
Rake: g.Paid, // the part you actually paid, not the part the table lifted
|
||||
Rake: g.Paid, // the part players actually paid, not the part the table lifted
|
||||
Payout: g.Payout,
|
||||
}
|
||||
for _, p := range g.Side {
|
||||
@@ -106,22 +120,22 @@ func viewHoldem(g holdem.State) holdemView {
|
||||
v.Board = append(v.Board, viewCard(c))
|
||||
}
|
||||
|
||||
// The wall. A bot's hand crosses the wire in exactly one situation — the hand
|
||||
// was shown down and they did not fold — because that is the only situation in
|
||||
// which a player at a real table would be looking at it.
|
||||
// The wall. Another seat's hand crosses the wire in exactly one situation — the
|
||||
// hand was shown down and they did not fold — because that is the only situation
|
||||
// in which a player at a real table would be looking at it.
|
||||
shown := g.Street == holdem.Showdown
|
||||
for i, p := range g.Seats {
|
||||
seat := holdemSeatView{
|
||||
Name: p.Name,
|
||||
Bot: p.Bot,
|
||||
You: i == holdem.You,
|
||||
You: i == viewer,
|
||||
Stack: p.Stack,
|
||||
Bet: p.Bet,
|
||||
State: seatStates[p.State],
|
||||
Pos: g.Position(i),
|
||||
Won: p.Won,
|
||||
}
|
||||
mine := i == holdem.You
|
||||
mine := i == viewer
|
||||
dealt := p.State != holdem.Out && p.Hole[0].Rank != 0
|
||||
if dealt && (mine || (shown && p.State != holdem.Folded)) {
|
||||
seat.Cards = []cardView{viewCard(p.Hole[0]), viewCard(p.Hole[1])}
|
||||
@@ -129,14 +143,14 @@ func viewHoldem(g holdem.State) holdemView {
|
||||
v.Seats = append(v.Seats, seat)
|
||||
}
|
||||
|
||||
if g.Phase == holdem.PhaseBetting && g.ToAct == holdem.You {
|
||||
v.Owed = g.Owed(holdem.You)
|
||||
if g.Phase == holdem.PhaseBetting && g.ToAct == viewer {
|
||||
v.Owed = g.Owed(viewer)
|
||||
v.CanCheck = v.Owed == 0
|
||||
v.CanRaise = g.CanRaise(holdem.You)
|
||||
v.MinRaise = g.MinRaiseTo(holdem.You)
|
||||
v.MaxRaise = g.MaxRaiseTo(holdem.You)
|
||||
v.CanRaise = g.CanRaise(viewer)
|
||||
v.MinRaise = g.MinRaiseTo(viewer)
|
||||
v.MaxRaise = g.MaxRaiseTo(viewer)
|
||||
}
|
||||
if top := g.Tier.MaxBuy - g.Seats[holdem.You].Stack; top > 0 {
|
||||
if top := g.Tier.MaxBuy - g.Seats[viewer].Stack; top > 0 {
|
||||
v.MaxTopUp = top
|
||||
}
|
||||
return v
|
||||
@@ -154,16 +168,22 @@ type holdemEventView struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func viewHoldemEvents(evs []holdem.Event) []holdemEventView {
|
||||
// viewHoldemEvents redacts the engine's script for one viewer. The engine emits
|
||||
// every seat's hole cards now (it cannot know who a shared stream is for), so
|
||||
// this is where the cards that are not the viewer's are stripped — turning a
|
||||
// "deal seat 3 these two cards" beat into "deal seat 3 two face-down cards".
|
||||
//
|
||||
// A card may ride an event only if it is a board card (Seat < 0), the viewer's
|
||||
// own, or a hand being shown down. Everything else is nulled here, and a missed
|
||||
// case is the leak that fans one seat's hole card to every subscriber.
|
||||
func viewHoldemEvents(evs []holdem.Event, viewer int) []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}
|
||||
for _, c := range e.Cards {
|
||||
v.Cards = append(v.Cards, viewCard(c))
|
||||
}
|
||||
// A card may ride an event only if it is the board, your own hand, or a hand
|
||||
// being shown down. Anything else is a bot's business.
|
||||
if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != holdem.You && e.Kind != "show" {
|
||||
if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != viewer && e.Kind != "show" {
|
||||
v.Cards = nil
|
||||
}
|
||||
out = append(out, v)
|
||||
@@ -215,7 +235,7 @@ func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
seed1, seed2 := newSeeds()
|
||||
g, evs, err := holdem.New(tier, req.Bots, req.BuyIn, blackjack.DefaultRules().RakePct, seed1, seed2)
|
||||
g, evs, err := holdem.New(tier, holdem.SoloSeats(tier, req.Bots, req.BuyIn), blackjack.DefaultRules().RakePct, seed1, seed2)
|
||||
if err != nil {
|
||||
_ = storage.Award(user, req.BuyIn) // nobody sat down, so nothing was bought
|
||||
slog.Error("games: holdem sit", "user", user, "err", err)
|
||||
@@ -276,7 +296,7 @@ func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) {
|
||||
topped = move.Amount
|
||||
}
|
||||
|
||||
next, evs, err := holdem.ApplyMove(g, move)
|
||||
next, evs, err := holdem.ApplyMove(g, soloViewer, move)
|
||||
if err != nil {
|
||||
if topped > 0 {
|
||||
_ = storage.Award(user, topped) // the top-up didn't happen
|
||||
@@ -341,9 +361,9 @@ func (s *Server) persistHoldem(w http.ResponseWriter, user string, g holdem.Stat
|
||||
// A closed session is gone from storage, so the table view has none to show —
|
||||
// but the browser still needs the last board to land the verdict on.
|
||||
if done {
|
||||
hv := viewHoldem(g)
|
||||
hv := viewHoldem(g, soloViewer)
|
||||
v.Holdem = &hv
|
||||
}
|
||||
v.HoldemEvents = viewHoldemEvents(evs)
|
||||
v.HoldemEvents = viewHoldemEvents(evs, soloViewer)
|
||||
writeJSON(w, v)
|
||||
}
|
||||
|
||||
143
internal/web/games_holdem_redact_test.go
Normal file
143
internal/web/games_holdem_redact_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/holdem"
|
||||
)
|
||||
|
||||
// The security boundary of the whole multiplayer table, tested the way the plan
|
||||
// insists it must be: render every seat's view at every street, and string-search
|
||||
// the JSON for any card that belongs to another seat. Zero hits, for every seat,
|
||||
// at every point before a showdown turns cards over.
|
||||
//
|
||||
// This is not belt-and-braces. After SSE the view a seat renders fans to that
|
||||
// seat's stream, so a single missed redaction does not leak one card to one
|
||||
// player through one handler bug — it broadcasts a hole card to the whole felt,
|
||||
// continuously. The engine deliberately emits every seat's cards now (it cannot
|
||||
// know who a shared stream is for), which makes this function the only thing
|
||||
// standing between the deck and the network tab.
|
||||
|
||||
// holeLabel is a seat's two cards as they would be rendered — the exact strings a
|
||||
// leak would put in the JSON.
|
||||
func holeLabels(g holdem.State, seat int) []string {
|
||||
h := g.Seats[seat].Hole
|
||||
return []string{viewCard(h[0]).Label, viewCard(h[1]).Label}
|
||||
}
|
||||
|
||||
func TestHoldemViewNeverLeaksAnotherSeatsCards(t *testing.T) {
|
||||
tier := holdem.Tiers[0]
|
||||
// Two humans (0, 2) and two bots (1, 3), so the test covers a seat seeing both
|
||||
// another human and a bot, and a human sitting at a non-zero index.
|
||||
seats := []holdem.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},
|
||||
}
|
||||
g, _, err := holdem.New(tier, seats, tier.RakePct, 5, 6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Deal, then walk the hand to the river without anyone folding — every seat
|
||||
// stays in, so every seat has cards that must not leak to the others. Checking
|
||||
// and calling keeps everyone live; the redaction is checked after every step.
|
||||
g, dealEvs, err := holdem.ApplyMove(g, 0, holdem.Move{Kind: holdem.Deal})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The deal script carries every hole, so it is the sharpest test of event
|
||||
// redaction: for each viewer, no other seat's cards may survive.
|
||||
assertEventsRedacted(t, g, dealEvs)
|
||||
assertViewsRedacted(t, g)
|
||||
|
||||
for step := 0; g.Phase == holdem.PhaseBetting && step < 80; step++ {
|
||||
seat := g.ToAct
|
||||
if g.Seats[seat].Bot {
|
||||
t.Fatalf("advance stopped on a bot at seat %d", seat)
|
||||
}
|
||||
move := holdem.Move{Kind: holdem.Check}
|
||||
if g.Owed(seat) > 0 {
|
||||
move = holdem.Move{Kind: holdem.Call}
|
||||
}
|
||||
var evs []holdem.Event
|
||||
g, evs, err = holdem.ApplyMove(g, seat, move)
|
||||
if err != nil {
|
||||
t.Fatalf("seat %d %s: %v", seat, move.Kind, err)
|
||||
}
|
||||
assertEventsRedacted(t, g, evs)
|
||||
assertViewsRedacted(t, g)
|
||||
}
|
||||
}
|
||||
|
||||
// assertViewsRedacted renders every seat's table view and fails if any of them
|
||||
// carries a card belonging to a seat that is still hiding it.
|
||||
func assertViewsRedacted(t *testing.T, g holdem.State) {
|
||||
t.Helper()
|
||||
shown := g.Street == holdem.Showdown
|
||||
for viewer := range g.Seats {
|
||||
blob, err := json.Marshal(viewHoldem(g, viewer))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := string(blob)
|
||||
for other := range g.Seats {
|
||||
if other == viewer {
|
||||
continue // your own cards are yours to see
|
||||
}
|
||||
// A seat's cards are legitimately visible only at a showdown, and then only
|
||||
// if they did not fold. Everywhere else, a hit is a leak.
|
||||
if shown && g.Seats[other].State != holdem.Folded {
|
||||
continue
|
||||
}
|
||||
if g.Seats[other].State == holdem.Out {
|
||||
continue
|
||||
}
|
||||
for _, label := range holeLabels(g, other) {
|
||||
if strings.Contains(payload, label) {
|
||||
t.Fatalf("seat %d's view (%s street) leaks seat %d's card %q",
|
||||
viewer, g.Street, other, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertEventsRedacted renders the event script for every viewer and fails if a
|
||||
// beat carries another seat's card outside a showdown "show".
|
||||
func assertEventsRedacted(t *testing.T, g holdem.State, evs []holdem.Event) {
|
||||
t.Helper()
|
||||
for viewer := range g.Seats {
|
||||
blob, err := json.Marshal(viewHoldemEvents(evs, viewer))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := string(blob)
|
||||
// The engine only puts a non-viewer's cards in a "show" beat and in the hole
|
||||
// beats it emits for everyone. If any card label of another seat survives the
|
||||
// redaction *and* there is no show event in this batch, it leaked.
|
||||
hasShow := false
|
||||
for _, e := range evs {
|
||||
if e.Kind == "show" {
|
||||
hasShow = true
|
||||
}
|
||||
}
|
||||
if hasShow {
|
||||
continue // a showdown legitimately turns cards over
|
||||
}
|
||||
for other := range g.Seats {
|
||||
if other == viewer || g.Seats[other].State == holdem.Out {
|
||||
continue
|
||||
}
|
||||
for _, label := range holeLabels(g, other) {
|
||||
if strings.Contains(payload, label) {
|
||||
t.Fatalf("seat %d's event stream leaks seat %d's card %q", viewer, other, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,7 @@ func (s *Server) table(user string) (tableView, error) {
|
||||
if err := json.Unmarshal(live.State, &g); err != nil {
|
||||
return s.dropUnreadable(user, v, err)
|
||||
}
|
||||
hv := viewHoldem(g)
|
||||
hv := viewHoldem(g, soloViewer)
|
||||
v.Holdem = &hv
|
||||
default:
|
||||
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))
|
||||
|
||||
Reference in New Issue
Block a user