From f8b07d8e6c3c7fe45bb1f10ddcbfc59b21810b90 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:17:25 -0700 Subject: [PATCH] games: the buy-in and the rake each player sees are their own, not the table's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-browser pass found it: at a table two humans share, the felt quoted each of them the pair's total. "Bought in for 200" to a player who put in 100, and a session-rake line that climbed on a pot the other one won. Both were table totals the view read straight off the engine — correct while a table had one human, wrong the moment it had two. Fixed along the border it already draws: bought_in is border accounting, so it comes from the viewer's own game_seats.staked; session rake is a within-table event, so it rides a new per-seat Seat.Paid beside the audit's table-total s.Paid. And top-up never grew game_seats.staked, so the storage invariant drifted by every top-up and the felt under-reported the buy-in — it does now. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ --- internal/games/holdem/eval.go | 5 +++ internal/games/holdem/holdem.go | 1 + internal/games/holdem/holdem_test.go | 34 +++++++++++++++++++ internal/web/games_holdem.go | 9 +++-- internal/web/games_holdem_multiplayer_test.go | 30 ++++++++++++++++ internal/web/games_play.go | 11 +++++- 6 files changed, 87 insertions(+), 3 deletions(-) diff --git a/internal/games/holdem/eval.go b/internal/games/holdem/eval.go index d14ac11..306bb51 100644 --- a/internal/games/holdem/eval.go +++ b/internal/games/holdem/eval.go @@ -187,7 +187,12 @@ func (s *State) payPot(pot Pot, live []ranked, evs *[]Event) { // climbed while every human folded would be telling them it had. for _, w := range winners { if !s.Seats[w.seat].Bot { + // The table total (for the audit's delta) and the winner's own + // running tally (for the ledger line the felt shows them). At a + // table with two humans these are different numbers: each player is + // only ever quoted the rake that came out of a pot they won. s.Paid += rake / int64(len(winners)) + s.Seats[w.seat].Paid += rake / int64(len(winners)) } } *evs = append(*evs, Event{Kind: "rake", Seat: -1, Amount: rake}) diff --git a/internal/games/holdem/holdem.go b/internal/games/holdem/holdem.go index f4d9919..6e99a1e 100644 --- a/internal/games/holdem/holdem.go +++ b/internal/games/holdem/holdem.go @@ -101,6 +101,7 @@ type Seat struct { Bet int64 `json:"bet"` // put in on this street Total int64 `json:"total"` // put in across this hand Won int64 `json:"won"` // taken out of the pot this hand + Paid int64 `json:"paid"` // rake lifted from pots this seat has won, all session State SeatState `json:"state"` Acted bool `json:"acted"` // has chosen to do something this street } diff --git a/internal/games/holdem/holdem_test.go b/internal/games/holdem/holdem_test.go index 47c72db..fb4ba18 100644 --- a/internal/games/holdem/holdem_test.go +++ b/internal/games/holdem/holdem_test.go @@ -458,6 +458,40 @@ func TestYouOnlyPayRakeOnPotsYouWin(t *testing.T) { } } +// At a table with two humans the session-rake line each is shown is their own, +// not the table's: the house took it out of a pot one of them won, and quoting it +// to the other says the house has taken money off them that it has not. So the +// per-seat tally only ever moves on the seat that won the pot. +func TestRakeIsChargedToTheSeatThatWonIt(t *testing.T) { + s := State{Tier: Tiers[1], Flopped: true, + Seats: []Seat{{Name: "Reala"}, {Name: "Bob"}}} + var evs []Event + + // Reala wins a 400 pot. The rake on it (5%, 20) is Reala's to have paid. + s.payPot(Pot{Amount: 400, Eligible: []int{0}}, []ranked{{seat: 0}}, &evs) + if s.Seats[0].Paid != 20 { + t.Errorf("the seat that won the pot paid %d in rake, want 20", s.Seats[0].Paid) + } + if s.Seats[1].Paid != 0 { + t.Errorf("the other player was charged %d in rake for a pot they were not in", s.Seats[1].Paid) + } + if s.Paid != 20 { + t.Errorf("the table total is %d, want 20 — it is still the sum for the audit", s.Paid) + } + + // Bob wins the next one. His tally moves; Reala's stays where it was. + s.payPot(Pot{Amount: 200, Eligible: []int{1}}, []ranked{{seat: 1}}, &evs) + if s.Seats[0].Paid != 20 { + t.Errorf("Reala's tally moved on a pot Bob won: %d, want 20", s.Seats[0].Paid) + } + if s.Seats[1].Paid != 10 { + t.Errorf("Bob paid %d in rake on a 200 pot he won, want 10", s.Seats[1].Paid) + } + if s.Paid != 30 { + t.Errorf("the table total is %d, want 30", s.Paid) + } +} + func TestASplitPotSplits(t *testing.T) { s := State{Tier: Tiers[1], Seats: []Seat{{Name: "You"}, {Name: "A", Bot: true}}} var evs []Event diff --git a/internal/web/games_holdem.go b/internal/web/games_holdem.go index 280f407..d3b8dc5 100644 --- a/internal/web/games_holdem.go +++ b/internal/web/games_holdem.go @@ -109,8 +109,8 @@ func viewHoldem(g holdem.State, viewer int) holdemView { ToAct: g.ToAct, Phase: string(g.Phase), Stack: g.Seats[viewer].Stack, - BoughtIn: g.BoughtIn, - Rake: g.Paid, // the part players actually paid, not the part the table lifted + BoughtIn: g.BoughtIn, // a table total; the caller overrides it with this seat's own stake + Rake: g.Seats[viewer].Paid, // this seat's rake alone, not the table's — see the ledger line on the felt Payout: g.Payout, } for _, p := range g.Side { @@ -543,6 +543,11 @@ func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) { acting := seats[seat] acting.Away = false acting.LastSeen = time.Now().Unix() + // A top-up is more real money crossing the border, so the seat's staked row — + // the record of what they brought and can still take home — has to grow with + // it. Without this the storage invariant (stacks + pot == staked - rake) drifts + // by every top-up, and the felt under-reports what they bought in for. + acting.Staked += topped t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline if err := storage.CommitTable(storage.TableCommit{ diff --git a/internal/web/games_holdem_multiplayer_test.go b/internal/web/games_holdem_multiplayer_test.go index b789a00..6369363 100644 --- a/internal/web/games_holdem_multiplayer_test.go +++ b/internal/web/games_holdem_multiplayer_test.go @@ -87,6 +87,36 @@ func TestHoldemJoinTakesAnOpenSeat(t *testing.T) { } } +// The felt tells each player "you bought in for X" — their own stake, not the +// table's. The engine's BoughtIn is the sum across every human (the audit wants +// that), so a two-human table would quote both of them the pair's total if the +// view read it straight. It reads each seat's own staked row from storage instead. +func TestBoughtInIsPerPlayerNotTheTableTotal(t *testing.T) { + s := newCasino(t) + fund(t, 5000) // reala + fundUser(t, bobPlayer, 5000) + + call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit", + map[string]any{"tier": "low", "bots": 2, "buyin": 500})) + tableID, _ := storage.TableOf(testPlayer) + call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit", + map[string]any{"table": tableID, "buyin": 500})) + + for _, who := range []string{testPlayer, bobPlayer} { + v, err := s.table(who) + if err != nil { + t.Fatal(err) + } + if v.Holdem == nil { + t.Fatalf("%s has no table", who) + } + if v.Holdem.BoughtIn != 500 { + t.Errorf("%s is shown bought-in %d, want their own 500 — not the table's 1000", + who, v.Holdem.BoughtIn) + } + } +} + // Leaving a shared table gives you your stack and hands your chair back to the // house; the table stays open for the people still on it. Only the last human out // closes the felt. diff --git a/internal/web/games_play.go b/internal/web/games_play.go index 0b67f7f..cf7ab64 100644 --- a/internal/web/games_play.go +++ b/internal/web/games_play.go @@ -303,7 +303,7 @@ func (s *Server) table(user string) (tableView, error) { if live.TableID == "" { return s.dropUnreadable(user, v, fmt.Errorf("holdem row with no table")) } - t, _, err := storage.LoadTable(live.TableID) + t, tableSeats, err := storage.LoadTable(live.TableID) if errors.Is(err, storage.ErrNoSuchTable) { // The table closed under them (reaped, or the last hand cashed them out). // Their claim is stale; clear it so they can sit down again. @@ -321,6 +321,15 @@ func (s *Server) table(user string) (tableView, error) { return s.dropUnreadable(user, v, err) } hv := viewHoldem(g, seat) + // bought_in is a per-player figure — "you bought in for X" — but the engine's + // BoughtIn is the table's total across every human. The player's own stake is + // border accounting, which lives in storage; take it from their seat row. + for _, ts := range tableSeats { + if ts.Seat == seat { + hv.BoughtIn = ts.Staked + break + } + } v.Holdem = &hv default: return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))