package web import ( "encoding/json" "errors" "log/slog" "net/http" "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"` 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"` } func viewHoldem(g holdem.State) holdemView { v := holdemView{ Tier: g.Tier, Button: g.Button, HandNo: g.HandNo, Street: g.Street.String(), Pot: g.Total(), ToAct: g.ToAct, Phase: string(g.Phase), Stack: g.Seats[holdem.You].Stack, BoughtIn: g.BoughtIn, Rake: g.Paid, // the part you 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. 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. shown := g.Street == holdem.Showdown for i, p := range g.Seats { seat := holdemSeatView{ Name: p.Name, Bot: p.Bot, You: i == holdem.You, Stack: p.Stack, Bet: p.Bet, State: seatStates[p.State], Pos: g.Position(i), Won: p.Won, } mine := i == holdem.You 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 == holdem.You { v.Owed = g.Owed(holdem.You) v.CanCheck = v.Owed == 0 v.CanRaise = g.CanRaise(holdem.You) v.MinRaise = g.MinRaiseTo(holdem.You) v.MaxRaise = g.MaxRaiseTo(holdem.You) } if top := g.Tier.MaxBuy - g.Seats[holdem.You].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"` } func viewHoldemEvents(evs []holdem.Event, g holdem.State) []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" { v.Cards = nil } out = append(out, v) } return out } // handleHoldemSit buys chips onto a table. The chips are staked first, in the // same statement that checks they exist, so two sit-downs fired at once cannot // buy in with the same chip. 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"` } if err := decodeJSON(r, &req); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } tier, err := holdem.TierBySlug(req.Tier) if err != nil { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"}) return } if req.BuyIn < tier.MinBuy || req.BuyIn > tier.MaxBuy { writeJSONStatus(w, http.StatusBadRequest, map[string]string{ "error": "that isn't a legal buy-in for this table", }) return } if req.Bots < 1 || req.Bots > holdem.MaxBots { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick some opponents"}) return } if err := storage.Stake(user, req.BuyIn); err != nil { if 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 } slog.Error("games: holdem buy-in", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } seed1, seed2 := newSeeds() g, evs, err := holdem.New(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) http.Error(w, "internal error", http.StatusInternalServerError) return } s.persistHoldem(w, user, g, evs, seed1, seed2, true) } // handleHoldemMove plays one move: an action in a hand, or the three that are // about the session — deal the next hand, put more chips on the table, get up. 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 } live, err := storage.LoadLiveHand(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 load", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if live.Game != gameHoldem { writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "finish the game you're in first"}) return } var g holdem.State if err := json.Unmarshal(live.State, &g); err != nil { slog.Error("games: unreadable holdem table", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } // A top-up is real money crossing the border, so the chips come off the stack // before the engine is asked — and go straight back if it says no. 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) { writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you don't have those chips"}) return } slog.Error("games: holdem top-up", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } topped = move.Amount } next, evs, err := holdem.ApplyMove(g, move) if err != nil { if topped > 0 { _ = storage.Award(user, topped) // the top-up didn't happen } msg := "that move isn't legal here" switch { case errors.Is(err, holdem.ErrHandLive): msg = "finish the hand first" case errors.Is(err, holdem.ErrNotYourTurn): msg = "it isn't your turn" case errors.Is(err, holdem.ErrCantCheck): msg = "there's a bet to you" case errors.Is(err, holdem.ErrTooSmall): msg = "that's under the minimum raise" case errors.Is(err, holdem.ErrTooBig): msg = "you don't have that many chips" case errors.Is(err, holdem.ErrBadBuyIn): msg = "that would put you over the table maximum" } writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": msg}) return } s.persistHoldem(w, user, next, evs, live.Seed1, live.Seed2, false) } // persistHoldem writes the table back and answers the browser. // // The session settles exactly once — when the player gets up, or when they have // nothing left to get up with. Until then Done is false and `commit` moves no // chips at all, which is what makes a hundred hands of poker a single trip across // the border rather than a hundred. func (s *Server) persistHoldem(w http.ResponseWriter, user string, g holdem.State, evs []holdem.Event, seed1, seed2 uint64, fresh bool) { blob, err := json.Marshal(g) if err != nil { slog.Error("games: marshal holdem", "user", user, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } done := g.Phase == holdem.PhaseDone outcome := "left" switch { case done && g.Payout == 0: outcome = "busted" case done && g.Payout > g.BoughtIn: outcome = "up" case done && g.Payout < g.BoughtIn: outcome = "down" } v, ok := s.commit(w, user, finished{ Game: gameHoldem, Blob: blob, // Paid, not Rake: the audit log is the house's income, and the house only // makes money off the player. What it lifts off a bot's pot is not income. Bet: g.BoughtIn, Payout: g.Payout, Rake: g.Paid, Outcome: outcome, Done: done, Seed1: seed1, Seed2: seed2, Fresh: fresh, }) if !ok { return } // 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) v.Holdem = &hv } v.HoldemEvents = viewHoldemEvents(evs, g) writeJSON(w, v) }