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
732 lines
24 KiB
Go
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)
|
|
}
|