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:
@@ -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