Files
Pete/internal/web/games_holdem.go
prosolis f8b07d8e6c games: the buy-in and the rake each player sees are their own, not the table's
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
2026-07-14 17:17:25 -07:00

732 lines
24 KiB
Go

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, // 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 {
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()
// 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{
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)
}