package web import ( "encoding/json" "errors" "log/slog" "net/http" "strings" "time" "pete/internal/games/blackjack" "pete/internal/games/holdem" "pete/internal/storage" ) // Texas hold'em, played for chips against the trained bots. // // This is the only table in the casino that is a *session* rather than a game. // Everywhere else you stake, you play once, and a multiple pays: the hand is the // unit and the money moves at both ends of it. Poker is not that shape. You buy // chips onto the table, you play as many hands as you feel like, and you leave // with whatever is in front of you — so the live row lives across hands, and the // chips move exactly twice: once when you sit down, once when you get up. // // Which means there is no "payout" until you stand up, and `commit` is only ever // told the game is Done when you do (or when you have nothing left to sit with). // In between, every pot won and lost is inside the engine, and storage sees none // of it. That is the honest model, and it is also the safe one: a hand that dies // halfway leaves the chips where they were, on the table, in the live row. // // What the browser is allowed to see: your two cards, the board, everybody's // stacks and bets, and nothing else. Not the deck, and not a bot's hand — until // a showdown turns it over, which is the moment it stops being a secret. // holdemSeatView is one seat. Cards are present only when the viewer is entitled // to them: yours always, a bot's never, until the hand is shown down. type holdemSeatView struct { Name string `json:"name"` Bot bool `json:"bot"` You bool `json:"you"` Stack int64 `json:"stack"` Bet int64 `json:"bet"` State string `json:"state"` // active | folded | allin | out Pos string `json:"pos"` // BTN, SB, BB, UTG… Cards []cardView `json:"cards,omitempty"` Won int64 `json:"won,omitempty"` } var seatStates = map[holdem.SeatState]string{ holdem.Active: "active", holdem.Folded: "folded", holdem.AllIn: "allin", holdem.Out: "out", } // holdemView is the table as its player may see it. type holdemView struct { Tier holdem.Tier `json:"tier"` // YourSeat is which chair in Seats is the viewer's. It used to be a convention // (seat zero is you) that the felt hardcoded; at a shared table it is whatever // chair you took, so it rides in the view and the browser reads it rather than // assuming it. YourSeat int `json:"your_seat"` Seats []holdemSeatView `json:"seats"` Button int `json:"button"` HandNo int `json:"hand_no"` Board []cardView `json:"board"` Street string `json:"street"` Pot int64 `json:"pot"` Side []int64 `json:"side,omitempty"` ToAct int `json:"to_act"` Phase string `json:"phase"` // What you may do, decided here rather than in the browser — the felt should // never offer a button the table would refuse. Owed int64 `json:"owed"` CanCheck bool `json:"can_check"` CanRaise bool `json:"can_raise"` MinRaise int64 `json:"min_raise_to"` MaxRaise int64 `json:"max_raise_to"` Stack int64 `json:"stack"` // what's in front of you BoughtIn int64 `json:"bought_in"` Rake int64 `json:"rake"` // what the house has taken this session MaxTopUp int64 `json:"max_topup"` Payout int64 `json:"payout,omitempty"` } // 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, YourSeat: viewer, Button: g.Button, HandNo: g.HandNo, Street: g.Street.String(), Pot: g.Total(), 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 Payout: g.Payout, } for _, p := range g.Side { v.Side = append(v.Side, p.Amount) } // An empty board is an empty board, not null. A Go slice with nothing in it // marshals to null, and a browser that has to write `(board || [])` everywhere // is a browser one forgotten guard away from a crash on every preflop. v.Board = []cardView{} for _, c := range g.Community { v.Board = append(v.Board, viewCard(c)) } // 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 == viewer, Stack: p.Stack, Bet: p.Bet, State: seatStates[p.State], Pos: g.Position(i), Won: p.Won, } 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])} } v.Seats = append(v.Seats, seat) } if g.Phase == holdem.PhaseBetting && g.ToAct == viewer { v.Owed = g.Owed(viewer) v.CanCheck = v.Owed == 0 v.CanRaise = g.CanRaise(viewer) v.MinRaise = g.MinRaiseTo(viewer) v.MaxRaise = g.MaxRaiseTo(viewer) } if top := g.Tier.MaxBuy - g.Seats[viewer].Stack; top > 0 { v.MaxTopUp = top } return v } // holdemEventView is one beat of the script the felt plays back. The engine only // ever attaches a bot's cards to a showdown; this drops them again anywhere else, // which is the second of the two walls. type holdemEventView struct { Kind string `json:"kind"` Seat int `json:"seat"` Cards []cardView `json:"cards,omitempty"` Amount int64 `json:"amount,omitempty"` Total int64 `json:"total,omitempty"` Text string `json:"text,omitempty"` } // 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)) } if len(v.Cards) > 0 && e.Seat >= 0 && e.Seat != viewer && e.Kind != "show" { v.Cards = nil } out = append(out, v) } return out } // ---- sitting down: a table of your own, or somebody else's ----------------- // displayName is what goes on the felt. It is the player's session name if they // have one, the local part of their Matrix id otherwise — never empty, which // would sit a nameless chair at the table. func (s *Server) displayName(r *http.Request, user string) string { if u := s.auth.userFromRequest(r); u != nil { if u.Name != "" { return u.Name } if u.Username != "" { return u.Username } } name := strings.TrimPrefix(user, "@") if i := strings.IndexByte(name, ':'); i > 0 { name = name[:i] } return name } // seatRows mirrors the engine's seats into the storage rows that shadow them, // index for index — seat i in the blob is seat i in game_seats — which is the // alignment the view redacts by and the audit attributes by. A human's staked is // the buy-in that actually crossed the border; a bot's is zero, because the only // real money at the table is in the human seats and staked is where the border // accounting lives. func seatRows(g holdem.State, human string, buyIn int64) []storage.Seat { rows := make([]storage.Seat, len(g.Seats)) for i := range g.Seats { p := g.Seats[i] row := storage.Seat{Seat: i, Name: p.Name} if !p.Bot { row.MatrixUser = human row.Staked = buyIn } rows[i] = row } return rows } // handleHoldemSit seats a player: at a fresh table of their own (solo is just a // table nobody else has joined yet), or at an open chair on somebody else's. func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } var req struct { Tier string `json:"tier"` Bots int `json:"bots"` BuyIn int64 `json:"buyin"` Table string `json:"table"` // set to join an existing table rather than open one Seat *int `json:"seat"` // which chair to take when joining; optional } if err := decodeJSON(r, &req); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if req.Table != "" { s.joinHoldem(w, r, user, req.Table, req.Seat, req.BuyIn) return } s.openHoldem(w, r, user, req.Tier, req.Bots, req.BuyIn) } // openHoldem opens a fresh table with the player in seat zero and bots in the // rest. It is the old solo flow, now a real shared table that simply has no other // humans on it yet. func (s *Server) openHoldem(w http.ResponseWriter, r *http.Request, user, tierSlug string, bots int, buyIn int64) { tier, err := holdem.TierBySlug(tierSlug) if err != nil { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"}) return } if buyIn < tier.MinBuy || buyIn > tier.MaxBuy { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that isn't a legal buy-in for this table"}) return } if bots < 1 || bots > holdem.MaxBots { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick some opponents"}) return } name := s.displayName(r, user) seed1, seed2 := newSeeds() g, _, err := holdem.New(tier, holdem.TableSeats(tier, name, bots, buyIn), blackjack.DefaultRules().RakePct, seed1, seed2) if err != nil { slog.Error("games: holdem open", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } blob, err := json.Marshal(g) if err != nil { slog.Error("games: marshal new holdem", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } id, err := storage.NewTableID() if err != nil { slog.Error("games: mint table id", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } t := storage.Table{ ID: id, Game: gameHoldem, Tier: tier.Slug, State: blob, Seed1: seed1, Seed2: seed2, Phase: string(g.Phase), HandNo: int64(g.HandNo), } err = storage.OpenSoloTable(t, seatRows(g, user, buyIn), buyIn) switch { case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount): writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"}) return case errors.Is(err, storage.ErrHandInProgress): writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"}) return case err != nil: slog.Error("games: open solo table", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } s.writeHoldemTable(w, user, nil) } // pickOpenSeat chooses a chair to join: the one the caller asked for if it is a // bot's, otherwise the first bot seat. Returns -1 if there is nowhere to sit. func pickOpenSeat(g holdem.State, want *int) int { if want != nil { i := *want if i >= 0 && i < len(g.Seats) && g.Seats[i].Bot { return i } return -1 } for i := range g.Seats { if g.Seats[i].Bot { return i } } return -1 } // joinHoldem sits a player down at an open chair on an existing table. It runs // under the table lock, and every step of the sit-down is one transaction in // SitDown — stake, claim, take the chair out of a bot's hands, save the state — // so two people racing for the last seat cannot both win it. func (s *Server) joinHoldem(w http.ResponseWriter, r *http.Request, user, tableID string, wantSeat *int, buyIn int64) { name := s.displayName(r, user) var respErr error err := s.tableLocks.withTable(tableID, func() error { t, _, err := storage.LoadTable(tableID) if errors.Is(err, storage.ErrNoSuchTable) { respErr = storage.ErrNoSuchTable return nil } if err != nil { return err } if t.Game != gameHoldem { respErr = holdem.ErrUnknownMove return nil } var g holdem.State if err := json.Unmarshal(t.State, &g); err != nil { return err } if g.Phase == holdem.PhaseBetting { respErr = holdem.ErrHandLive // you join between hands, not into one return nil } seat := pickOpenSeat(g, wantSeat) if seat < 0 { respErr = holdem.ErrTableFull return nil } if err := g.Occupy(seat, name, buyIn); err != nil { respErr = err return nil } blob, err := json.Marshal(g) if err != nil { return err } t.State, t.Phase, t.HandNo = blob, string(g.Phase), int64(g.HandNo) err = storage.SitDown(storage.Sit{ Table: t, Seat: storage.Seat{Seat: seat, MatrixUser: user, Name: name, Staked: buyIn}, BuyIn: buyIn, }) switch { case errors.Is(err, storage.ErrInsufficientChips), errors.Is(err, storage.ErrBadAmount): respErr = storage.ErrInsufficientChips return nil case errors.Is(err, storage.ErrHandInProgress): respErr = storage.ErrHandInProgress return nil case errors.Is(err, storage.ErrSeatTaken), errors.Is(err, storage.ErrStaleTable): respErr = storage.ErrSeatTaken return nil case err != nil: return err } s.publishTable(tableID) return nil }) if err != nil { slog.Error("games: join holdem", "user", user, "table", tableID, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if respErr != nil { writeJSONStatus(w, joinStatus(respErr), map[string]string{"error": joinMessage(respErr)}) return } s.writeHoldemTable(w, user, nil) } func joinStatus(err error) int { switch { case errors.Is(err, storage.ErrNoSuchTable), errors.Is(err, holdem.ErrTableFull), errors.Is(err, storage.ErrSeatTaken), errors.Is(err, holdem.ErrHandLive): return http.StatusConflict default: return http.StatusBadRequest } } func joinMessage(err error) string { switch { case errors.Is(err, storage.ErrNoSuchTable): return "that table has closed" case errors.Is(err, holdem.ErrTableFull), errors.Is(err, storage.ErrSeatTaken): return "that seat is taken" case errors.Is(err, holdem.ErrHandLive): return "a hand is in play — sit down when it's over" case errors.Is(err, holdem.ErrBadBuyIn): return "that isn't a legal buy-in for this table" case errors.Is(err, storage.ErrInsufficientChips): return "not enough chips to sit down" case errors.Is(err, storage.ErrHandInProgress): return "finish the game you're in first" default: return "you can't sit there" } } // ---- playing a hand -------------------------------------------------------- // handleHoldemMove plays one move at the player's table: a betting action, or the // two session moves that are not leaving (deal the next hand, top up between // them). Leaving is its own endpoint, because it is a storage operation rather // than an engine one. func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } var move holdem.Move if err := decodeJSON(r, &move); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if move.Kind == holdem.Leave { s.leaveHoldem(w, user) return } tableID, seat, err := storage.PlayerSeat(user) if errors.Is(err, storage.ErrNoLiveHand) { writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"}) return } if err != nil { slog.Error("games: holdem move seat", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } var respErr error var respEvents []holdem.Event err = s.tableLocks.withTable(tableID, func() error { t, seats, err := storage.LoadTable(tableID) if errors.Is(err, storage.ErrNoSuchTable) { respErr = storage.ErrNoSuchTable return nil } if err != nil { return err } var g holdem.State if err := json.Unmarshal(t.State, &g); err != nil { return err } // A top-up is real chips crossing the border, so it comes off the stack // before the engine is asked, and goes straight back if the engine says no — // the same order, and the same reason, as doubling down at blackjack. topped := int64(0) if move.Kind == holdem.TopUp { if err := storage.Stake(user, move.Amount); err != nil { if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) { respErr = holdem.ErrTooBig return nil } return err } topped = move.Amount } next, evs, aerr := holdem.ApplyMove(g, seat, move) if aerr != nil { if topped > 0 { _ = storage.Award(user, topped) // the top-up didn't happen } respErr = aerr return nil } // A solo session that just ended (the one human busted) is not a table any // more: cash the seat out — for nothing, but the claim still has to be // released — and close the felt. Only a one-human bust reaches PhaseDone; a // shared table sits the busted seat Out and plays on. if next.Phase == holdem.PhaseDone { if err := s.settleLeave(t, next, seat, user); err != nil { if topped > 0 { _ = storage.Award(user, topped) } if errors.Is(err, storage.ErrStaleTable) { respErr = storage.ErrStaleTable return nil } return err } respEvents = evs s.publishTable(tableID) return nil } st, err := holdemStep(g, next, evs, seats) if err != nil { if topped > 0 { _ = storage.Award(user, topped) } return err } acting := seats[seat] acting.Away = false acting.LastSeen = time.Now().Unix() t.State, t.Phase, t.HandNo, t.Deadline = st.State, st.Phase, st.HandNo, st.Deadline if err := storage.CommitTable(storage.TableCommit{ Table: t, Seats: []storage.Seat{acting}, Audit: st.Audit, }); err != nil { if errors.Is(err, storage.ErrStaleTable) { if topped > 0 { _ = storage.Award(user, topped) } respErr = storage.ErrStaleTable return nil } return err } respEvents = evs s.publishTable(tableID) return nil }) if err != nil { slog.Error("games: holdem move", "user", user, "table", tableID, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if respErr != nil { writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)}) return } s.writeHoldemTable(w, user, respEvents) } func moveStatus(err error) int { // A 409 is a concurrency verdict: the table is not where the caller thought, so // reload and look again. Everything else — an illegal move for this state — is // the caller's mistake, a 400. Leaving mid-hand or acting out of turn is a 400: // the request was simply not allowed, not raced. switch { case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable): return http.StatusConflict default: return http.StatusBadRequest } } func moveMessage(err error) string { switch { case errors.Is(err, storage.ErrStaleTable): return "the table moved on — take another look" case errors.Is(err, storage.ErrNoSuchTable): return "that table has closed" case errors.Is(err, holdem.ErrHandLive): return "finish the hand first" case errors.Is(err, holdem.ErrNotYourTurn): return "it isn't your turn" case errors.Is(err, holdem.ErrCantCheck): return "there's a bet to you" case errors.Is(err, holdem.ErrTooSmall): return "that's under the minimum raise" case errors.Is(err, holdem.ErrTooBig): return "you don't have that many chips" case errors.Is(err, holdem.ErrBadBuyIn): return "that would put you over the table maximum" default: return "that move isn't legal here" } } // ---- getting up ------------------------------------------------------------ // handleHoldemLeave is the get-up endpoint. Leaving is its own route because it // is a storage operation, not an engine move — the chips cross the border and the // felt may close — even though the felt also lets you send it as a "leave" move. func (s *Server) handleHoldemLeave(w http.ResponseWriter, r *http.Request) { user, ok := s.player(w, r) if !ok { return } s.leaveHoldem(w, user) } // leaveHoldem gets a player up from their table, turning what is in front of them // back into chips. It refuses mid-hand — you cannot walk out on chips you have in // the pot — and closes the felt behind the last human to leave. func (s *Server) leaveHoldem(w http.ResponseWriter, user string) { tableID, seat, err := storage.PlayerSeat(user) if errors.Is(err, storage.ErrNoLiveHand) { writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"}) return } if err != nil { slog.Error("games: holdem leave seat", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } var respErr error err = s.tableLocks.withTable(tableID, func() error { t, _, err := storage.LoadTable(tableID) if errors.Is(err, storage.ErrNoSuchTable) { respErr = storage.ErrNoSuchTable return nil } if err != nil { return err } var g holdem.State if err := json.Unmarshal(t.State, &g); err != nil { return err } if g.Phase == holdem.PhaseBetting { respErr = holdem.ErrHandLive return nil } if err := s.settleLeave(t, g, seat, user); err != nil { if errors.Is(err, storage.ErrStaleTable) { respErr = storage.ErrStaleTable return nil } return err } s.publishTable(tableID) return nil }) if err != nil { slog.Error("games: holdem leave", "user", user, "table", tableID, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if respErr != nil { writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)}) return } s.writeHoldemTable(w, user, nil) } // settleLeave vacates a seat, credits the stack home, and closes the table if // nobody human is left — all inside the caller's lock. The engine's Vacate turns // the chair back into the house's (its chips become house money, rebought like // any bot's), and storage.LeaveTable does the border crossing in one transaction // with the state write, so a crash can never pay a player and then leave their // seat sitting there to be cashed out again. func (s *Server) settleLeave(t storage.Table, g holdem.State, seat int, user string) error { home, err := g.Vacate(seat) if err != nil { return err } blob, err := json.Marshal(g) if err != nil { return err } t.State, t.Phase, t.HandNo, t.Deadline = blob, string(g.Phase), int64(g.HandNo), 0 if err := storage.LeaveTable(storage.Leave{ Table: t, Seat: seat, MatrixUser: user, Bot: g.Seats[seat].Name, Amount: home, }); err != nil { return err } if err := storage.CloseTable(t.ID); err != nil { return err } return nil } // ---- the response ---------------------------------------------------------- // writeHoldemTable answers with the whole page state — the money and the table as // the player's own seat may see it — plus, when a move produced one, the redacted // event script for that seat to animate. A player who has just got up has no seat // and no table; the money view carries the leftover verdict and the felt clears. func (s *Server) writeHoldemTable(w http.ResponseWriter, user string, evs []holdem.Event) { v, err := s.table(user) if err != nil { slog.Error("games: holdem table", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if len(evs) > 0 { if _, seat, serr := storage.PlayerSeat(user); serr == nil { v.HoldemEvents = viewHoldemEvents(evs, seat) } } writeJSON(w, v) }