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
370 lines
13 KiB
Go
370 lines
13 KiB
Go
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"`
|
|
}
|
|
|
|
// 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",
|
|
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"`
|
|
}
|
|
|
|
// 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,
|
|
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
|
|
}
|
|
|
|
// 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, 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)
|
|
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, soloViewer, 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, soloViewer)
|
|
v.Holdem = &hv
|
|
}
|
|
v.HoldemEvents = viewHoldemEvents(evs, soloViewer)
|
|
writeJSON(w, v)
|
|
}
|