games: the poker table others can walk up to, and the one that empties when they leave

Phase C's handler cutover: hold'em now runs on the shared-table runtime instead
of the solo game_live_hands blob. Solo is just a table nobody else has joined.

- holdem implements tableGame (name/timeout/stacks). timeout auto-checks-or-folds
  a walked-away seat and marks it away; the audit is per-hand, with each pot's
  rake on the winner's row alone so HouseTake cannot 4x itself.
- New endpoints: sit opens a table (or joins an open bot seat), leave gets you up
  (LeaveTable + CloseTable behind the last human), plus tables (lobby), stream
  (SSE), chat and say. The move path loads the player's table, applies at their
  seat, commits under the version guard, and fans an SSE nudge.
- Engine grows Vacate/Occupy (a human leaving/joining between hands) and
  TableSeats (a named human + bots). The view carries your_seat, since a shared
  table has no seat-zero-is-you convention.
- Storage grows OpenSoloTable (stake+claim+create+seat in one tx), PlayerSeat,
  and the abandoned-table reaper (AbandonedTables/ReapTable) — the seated-player
  counterpart to the session reaper, since a walked-away stack is inside a blob
  the session reaper cannot see. upsertSeat preserves last_seen so an auto-fold
  never refreshes an away player's clock.

Not deployed, and the felt is not rewired yet: the frontend still assumes
seat zero is you, so this is browser-unverified. Solo sit/deal/play/leave and
two-human join/leave/reaper are covered by tests; the whole suite is green.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
prosolis
2026-07-14 16:37:49 -07:00
parent 5b381b03ff
commit 5139385350
12 changed files with 1537 additions and 122 deletions

View File

@@ -49,6 +49,7 @@ var (
ErrUnknownTier = errors.New("holdem: no such table") ErrUnknownTier = errors.New("holdem: no such table")
ErrBadBuyIn = errors.New("holdem: that isn't a legal buy-in") ErrBadBuyIn = errors.New("holdem: that isn't a legal buy-in")
ErrTableFull = errors.New("holdem: too many seats") ErrTableFull = errors.New("holdem: too many seats")
ErrSeatTaken = errors.New("holdem: that seat is taken")
) )
// rakeCapBB caps the rake on any one pot at three big blinds, which is what a // rakeCapBB caps the rake on any one pot at three big blinds, which is what a
@@ -321,13 +322,98 @@ func New(t Tier, seats []SeatConfig, rakePct float64, seed1, seed2 uint64) (Stat
// The human is seat zero and takes buyIn; the bots take the table maximum. // The human is seat zero and takes buyIn; the bots take the table maximum.
// SoloSeats is exported for the handler that still opens a solo table. See New. // SoloSeats is exported for the handler that still opens a solo table. See New.
func SoloSeats(t Tier, bots int, buyIn int64) []SeatConfig { func SoloSeats(t Tier, bots int, buyIn int64) []SeatConfig {
seats := []SeatConfig{{Name: "You", Stack: buyIn}} return TableSeats(t, "You", bots, buyIn)
for i := 0; i < bots; i++ { }
// TableSeats builds a table of one named human and n bots. It is what a player
// opening their own table gets: a chair with their name on it, and the rest of
// the felt filled with the house's regulars so the table is never empty. The
// human takes seat zero and their buy-in; each bot takes the table maximum in
// house chips, which are not real money and get rebought when a bot runs dry.
func TableSeats(t Tier, human string, bots int, buyIn int64) []SeatConfig {
seats := []SeatConfig{{Name: human, Stack: buyIn}}
for i := 0; i < bots && i < len(botNames); i++ {
seats = append(seats, SeatConfig{Name: botNames[i], Bot: true, Stack: t.MaxBuy}) seats = append(seats, SeatConfig{Name: botNames[i], Bot: true, Stack: t.MaxBuy})
} }
return seats return seats
} }
// freeBotName picks a regular not already sitting at the table, so vacating a
// seat never puts two Marjories on the felt. It falls back to a generic name if
// every regular is somehow taken, which a six-max table cannot actually manage.
func (s *State) freeBotName() string {
used := make(map[string]bool, len(s.Seats))
for i := range s.Seats {
used[s.Seats[i].Name] = true
}
for _, n := range botNames {
if !used[n] {
return n
}
}
return "The House"
}
// Vacate turns a human's chair back into the house's and returns the stack that
// goes home with them. It is how a shared table survives a player getting up: the
// seat keeps its place and its chips — which become house money, rebought like
// any bot's when they run low — so the others play on without a hole in the ring.
//
// The chips left in the seat are not a leak. The only real money at the table is
// in the human seats; the moment a seat is a bot's, its stack is house chips that
// nobody's balance is counting. What the player actually takes home is the
// returned stack, credited by the runtime in the same transaction that saves this
// state — see storage.LeaveTable.
//
// It refuses while a hand is live, because a seat with chips in the pot cannot be
// emptied without stranding them. PhaseHandOver is the only phase Leave was ever
// legal from.
func (s *State) Vacate(seat int) (int64, error) {
if seat < 0 || seat >= len(s.Seats) {
return 0, ErrUnknownMove
}
if s.Phase == PhaseBetting {
return 0, ErrHandLive
}
p := &s.Seats[seat]
if p.Bot {
return 0, ErrUnknownMove
}
home := p.Stack
p.Bot = true
p.Name = s.freeBotName()
return home, nil
}
// Occupy seats a human in a chair a bot was keeping warm, with the buy-in they
// brought. It is the join half of Vacate: a player sitting down at somebody
// else's table takes an open seat rather than opening a felt of their own.
//
// Like Vacate it is a between-hands move — you cannot sit into a live hand — and
// the buy-in has to be legal for the table, because the chips are already off the
// player's stack by the time this runs and an illegal seat would strand them.
func (s *State) Occupy(seat int, name string, buyIn int64) error {
if seat < 0 || seat >= len(s.Seats) {
return ErrUnknownMove
}
if s.Phase == PhaseBetting {
return ErrHandLive
}
if !s.Seats[seat].Bot {
return ErrSeatTaken
}
if buyIn < s.Tier.MinBuy || buyIn > s.Tier.MaxBuy {
return ErrBadBuyIn
}
p := &s.Seats[seat]
p.Bot = false
p.Name = name
p.Stack = buyIn
p.State = Out // between hands; the next deal brings them in
s.BoughtIn += buyIn
return nil
}
// ApplyMove is the whole engine. It plays the acting seat's move, then every bot // ApplyMove is the whole engine. It plays the acting seat's move, then every bot
// behind them, deals whatever streets that finishes, and stops when the action // behind them, deals whatever streets that finishes, and stops when the action
// reaches a human — the same one again, or another at the table — or when the // reaches a human — the same one again, or another at the table — or when the

View File

@@ -140,19 +140,108 @@ func OpenTable(t Table, seats []Seat) error {
return nil return nil
} }
// OpenSoloTable opens a table with the player already sitting at it — the "solo
// is just a table nobody else has joined yet" path. It is SitDown and OpenTable
// fused into one transaction: stake the buy-in, claim the occupancy row, create
// the table, and seat everyone (the human, and the bots filling the rest of the
// ring). Any step failing rolls the buy-in back with it, so a crash never leaves
// a player charged for a felt that does not exist.
//
// The occupancy claim is the same primary key that stops a second solo hand, so a
// player already at a table (or in another game) is refused here with
// ErrHandInProgress and their buy-in returned untouched.
func OpenSoloTable(t Table, seats []Seat, buyIn int64) error {
if buyIn <= 0 {
return ErrBadAmount
}
// The human seat is the one row that is not a bot; its player claims the table.
var user, name string
for _, s := range seats {
if !s.Bot() {
user, name = s.MatrixUser, s.Name
break
}
}
if user == "" {
return ErrBadAmount // a solo table with no human is a bug, not a table
}
now := nowUnix()
tx, err := Get().Begin()
if err != nil {
return fmt.Errorf("games: begin open solo: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
res, err := tx.Exec(
`UPDATE game_chips SET chips = chips - ?, last_played = ?, updated_at = ?
WHERE matrix_user = ? AND chips >= ?`,
buyIn, now, now, user, buyIn,
)
if err != nil {
return fmt.Errorf("games: stake solo buy-in: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrInsufficientChips
}
res, err = tx.Exec(
`INSERT INTO game_live_hands (matrix_user, game, state, seed1, seed2, table_id, updated_at)
VALUES (?, ?, '', 0, 0, ?, ?)
ON CONFLICT(matrix_user) DO NOTHING`,
user, t.Game, t.ID, now,
)
if err != nil {
return fmt.Errorf("games: claim solo seat: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrHandInProgress
}
_ = name
if _, err := tx.Exec(
`INSERT INTO game_tables (id, game, tier, state, seed1, seed2, phase, hand_no, version, deadline, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)`,
t.ID, t.Game, t.Tier, string(t.State), int64(t.Seed1), int64(t.Seed2),
t.Phase, t.HandNo, t.Deadline, now, now,
); err != nil {
return fmt.Errorf("games: open solo table: %w", err)
}
for _, sc := range seats {
if err := upsertSeat(tx, t.ID, sc, now); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("games: commit open solo: %w", err)
}
return nil
}
// upsertSeat writes a seat row inside an open transaction, bots included. // upsertSeat writes a seat row inside an open transaction, bots included.
//
// last_seen is the caller's if they set one, and only falls back to now when they
// did not. That distinction is load-bearing: the turn clock rewrites a seat to
// mark it away, and it must carry the seat's *existing* last_seen through
// unchanged — otherwise every auto-fold refreshes the away player's clock, and
// the abandoned-table reaper (which keys on how long ago a human last acted for
// themselves) could never fire.
func upsertSeat(tx *sql.Tx, tableID string, s Seat, now int64) error { func upsertSeat(tx *sql.Tx, tableID string, s Seat, now int64) error {
var user any var user any
if s.MatrixUser != "" { if s.MatrixUser != "" {
user = s.MatrixUser user = s.MatrixUser
} }
seen := s.LastSeen
if seen == 0 {
seen = now
}
if _, err := tx.Exec( if _, err := tx.Exec(
`INSERT INTO game_seats (table_id, seat, matrix_user, name, staked, away, last_seen) `INSERT INTO game_seats (table_id, seat, matrix_user, name, staked, away, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(table_id, seat) DO UPDATE SET ON CONFLICT(table_id, seat) DO UPDATE SET
matrix_user = excluded.matrix_user, name = excluded.name, matrix_user = excluded.matrix_user, name = excluded.name,
staked = excluded.staked, away = excluded.away, last_seen = excluded.last_seen`, staked = excluded.staked, away = excluded.away, last_seen = excluded.last_seen`,
tableID, s.Seat, user, s.Name, s.Staked, boolInt(s.Away), now, tableID, s.Seat, user, s.Name, s.Staked, boolInt(s.Away), seen,
); err != nil { ); err != nil {
return fmt.Errorf("games: seat: %w", err) return fmt.Errorf("games: seat: %w", err)
} }
@@ -498,6 +587,22 @@ func LeaveTable(l Leave) error {
return nil return nil
} }
// PlayerSeat reports the table and chair a player is sitting at. It reads the
// seat row, which sit and leave keep in lockstep with the occupancy claim in one
// transaction, so a row here means a live-hand row there and vice versa.
func PlayerSeat(user string) (tableID string, seat int, err error) {
err = Get().QueryRow(
`SELECT table_id, seat FROM game_seats WHERE matrix_user = ?`, user,
).Scan(&tableID, &seat)
if errors.Is(err, sql.ErrNoRows) {
return "", 0, ErrNoLiveHand
}
if err != nil {
return "", 0, fmt.Errorf("games: player seat: %w", err)
}
return tableID, seat, nil
}
// TableOf reports which table a player is sitting at, if any. Read off the // TableOf reports which table a player is sitting at, if any. Read off the
// occupancy claim, so it agrees with the cash-out check by construction. // occupancy claim, so it agrees with the cash-out check by construction.
func TableOf(user string) (string, error) { func TableOf(user string) (string, error) {
@@ -548,6 +653,124 @@ func CloseTable(id string) error {
return nil return nil
} }
// AbandonedTables lists tables everyone walked away from: every human seat is
// away, and the most recent one acted for themselves longer ago than the cutoff.
//
// It is the seated-player half of the reaper. The session reaper cashes out loose
// chips on a game_chips stack; it cannot see a player whose chips are inside a
// table blob, and those are exactly the chips a walked-away poker player has. So
// this finds the tables where nobody is coming back and hands them to ReapTable.
//
// A table with a live hand is never abandoned in this sense — the turn clock is
// still folding it forward — so only tables parked between hands qualify. Like
// DueTables it closes its rows before returning, because the caller is about to
// take a lock and open a transaction against the one connection.
func AbandonedTables(cutoff int64) ([]TableRef, error) {
rows, err := Get().Query(
`SELECT t.id, t.version FROM game_tables t
WHERE t.phase = 'handover'
AND EXISTS (SELECT 1 FROM game_seats s
WHERE s.table_id = t.id AND s.matrix_user IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM game_seats s
WHERE s.table_id = t.id AND s.matrix_user IS NOT NULL
AND (s.away = 0 OR s.last_seen >= ?))`,
cutoff,
)
if err != nil {
return nil, fmt.Errorf("games: abandoned tables: %w", err)
}
defer rows.Close()
var out []TableRef
for rows.Next() {
var r TableRef
if err := rows.Scan(&r.ID, &r.Version); err != nil {
return nil, fmt.Errorf("games: scan abandoned table: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// Reap is one abandoned table being cashed out and closed. Stacks is what each
// human seat has in front of it, read from the engine blob by the caller — the
// only game-specific fact the reaper needs, since the chips-home number lives
// inside a state only the engine can decode.
type Reap struct {
TableID string
Version int64
// Humans is the seats to cash out, each paired with the stack going home. A
// seat's Amount may be zero (they busted and never got up), which still has to
// close their occupancy row so they can play again.
Humans []ReapSeat
}
// ReapSeat is one human being sent home from an abandoned table.
type ReapSeat struct {
Seat int
MatrixUser string
Amount int64
}
// ReapTable cashes out every human at an abandoned table and deletes it, in one
// transaction, conditional on the version so it cannot race a player who came
// back to the felt in the same instant.
//
// It is LeaveTable and CloseTable fused: award each stack, release each occupancy
// claim, then drop the seats, chat and table. The version guard is what makes it
// safe against a returning player — if their sit or move bumped the version
// between the scan and here, every row matches zero and the whole thing rolls
// back, leaving the table exactly as the returning player left it.
func ReapTable(r Reap) error {
now := nowUnix()
tx, err := Get().Begin()
if err != nil {
return fmt.Errorf("games: begin reap: %w", err)
}
defer tx.Rollback() //nolint:errcheck // no-op once committed
// Bump the version first, and refuse if it moved. Nothing below is conditional,
// so this one check has to stand for the whole reap.
res, err := tx.Exec(
`UPDATE game_tables SET version = version + 1, updated_at = ? WHERE id = ? AND version = ?`,
now, r.TableID, r.Version,
)
if err != nil {
return fmt.Errorf("games: reap bump version: %w", err)
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrStaleTable
}
for _, h := range r.Humans {
if h.Amount > 0 {
if err := award(tx, h.MatrixUser, h.Amount, now); err != nil {
return err
}
}
if _, err := tx.Exec(
`DELETE FROM game_live_hands WHERE matrix_user = ? AND table_id = ?`,
h.MatrixUser, r.TableID,
); err != nil {
return fmt.Errorf("games: reap release claim: %w", err)
}
}
for _, q := range []string{
`DELETE FROM game_seats WHERE table_id = ?`,
`DELETE FROM game_chat WHERE table_id = ?`,
`DELETE FROM game_tables WHERE id = ?`,
} {
if _, err := tx.Exec(q, r.TableID); err != nil {
return fmt.Errorf("games: reap close: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("games: commit reap: %w", err)
}
return nil
}
// ---- the turn clock -------------------------------------------------------- // ---- the turn clock --------------------------------------------------------
// TableRef is a table the clock has found expired: which one, and at what version // TableRef is a table the clock has found expired: which one, and at what version

View File

@@ -67,6 +67,7 @@ func (s *Server) StartTableClock(ctx context.Context) {
} }
go s.runTableClock(ctx) go s.runTableClock(ctx)
go s.runSessionReaper(ctx) go s.runSessionReaper(ctx)
go s.runTableReaper(ctx)
} }
func (s *Server) runTableClock(ctx context.Context) { func (s *Server) runTableClock(ctx context.Context) {
@@ -131,6 +132,9 @@ func (s *Server) runClockTable(ref storage.TableRef) {
} }
st, newSeats, err := game.timeout(t.State, seats) st, newSeats, err := game.timeout(t.State, seats)
if errors.Is(err, errNotDue) {
return nil // the race resolved without us; nothing to act on
}
if err != nil { if err != nil {
slog.Error("games: clock timeout", "table", t.ID, "err", err) slog.Error("games: clock timeout", "table", t.ID, "err", err)
return nil return nil
@@ -174,7 +178,9 @@ func (s *Server) publishTable(tableID string) {
} }
// The frame is just a nudge carrying the version: a subscriber that sees a gap // The frame is just a nudge carrying the version: a subscriber that sees a gap
// refetches the authoritative, per-seat table. So the payload can be minimal. // refetches the authoritative, per-seat table. So the payload can be minimal.
data, _ := json.Marshal(map[string]any{"version": t.Version, "phase": t.Phase}) // The type tells the browser a table changed (come and look) from a chat line
// (render it in place) — both ride the one stream.
data, _ := json.Marshal(map[string]any{"type": "table", "version": t.Version, "phase": t.Phase})
s.hub.publish(tableID, hubFrame{Version: t.Version, Data: data}) s.hub.publish(tableID, hubFrame{Version: t.Version, Data: data})
} }

View File

@@ -23,6 +23,8 @@ func (g *fakeGame) timeout(state []byte, seats []storage.Seat) (step, []storage.
return step{State: []byte(`{"acted":true}`), Phase: "handover", HandNo: 2, Deadline: 0}, seats, nil return step{State: []byte(`{"acted":true}`), Phase: "handover", HandNo: 2, Deadline: 0}, seats, nil
} }
func (g *fakeGame) stacks(state []byte) ([]int64, error) { return []int64{0, 0}, nil }
// clockTestServer stands up a Server with just the table machinery wired and a // clockTestServer stands up a Server with just the table machinery wired and a
// fresh DB. Enough to drive the clock, nothing else. // fresh DB. Enough to drive the clock, nothing else.
func clockTestServer(t *testing.T, g tableGame) *Server { func clockTestServer(t *testing.T, g tableGame) *Server {

View File

@@ -5,6 +5,8 @@ import (
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"time"
"pete/internal/games/blackjack" "pete/internal/games/blackjack"
"pete/internal/games/holdem" "pete/internal/games/holdem"
@@ -44,10 +46,6 @@ type holdemSeatView struct {
Won int64 `json:"won,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{ var seatStates = map[holdem.SeatState]string{
holdem.Active: "active", holdem.Active: "active",
holdem.Folded: "folded", holdem.Folded: "folded",
@@ -58,6 +56,11 @@ var seatStates = map[holdem.SeatState]string{
// holdemView is the table as its player may see it. // holdemView is the table as its player may see it.
type holdemView struct { type holdemView struct {
Tier holdem.Tier `json:"tier"` 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"` Seats []holdemSeatView `json:"seats"`
Button int `json:"button"` Button int `json:"button"`
HandNo int `json:"hand_no"` HandNo int `json:"hand_no"`
@@ -98,6 +101,7 @@ type holdemView struct {
func viewHoldem(g holdem.State, viewer int) holdemView { func viewHoldem(g holdem.State, viewer int) holdemView {
v := holdemView{ v := holdemView{
Tier: g.Tier, Tier: g.Tier,
YourSeat: viewer,
Button: g.Button, Button: g.Button,
HandNo: g.HandNo, HandNo: g.HandNo,
Street: g.Street.String(), Street: g.Street.String(),
@@ -190,10 +194,49 @@ func viewHoldemEvents(evs []holdem.Event, viewer int) []holdemEventView {
} }
return out return out
} }
// ---- sitting down: a table of your own, or somebody else's -----------------
// handleHoldemSit buys chips onto a table. The chips are staked first, in the // displayName is what goes on the felt. It is the player's session name if they
// same statement that checks they exist, so two sit-downs fired at once cannot // have one, the local part of their Matrix id otherwise — never empty, which
// buy in with the same chip. // 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) { func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r) user, ok := s.player(w, r)
if !ok { if !ok {
@@ -203,50 +246,207 @@ func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) {
Tier string `json:"tier"` Tier string `json:"tier"`
Bots int `json:"bots"` Bots int `json:"bots"`
BuyIn int64 `json:"buyin"` 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 { if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest) http.Error(w, "bad json", http.StatusBadRequest)
return return
} }
tier, err := holdem.TierBySlug(req.Tier) 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 { if err != nil {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"}) writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick a table"})
return return
} }
if req.BuyIn < tier.MinBuy || req.BuyIn > tier.MaxBuy { if buyIn < tier.MinBuy || buyIn > tier.MaxBuy {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{ writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that isn't a legal buy-in for this table"})
"error": "that isn't a legal buy-in for this table",
})
return return
} }
if req.Bots < 1 || req.Bots > holdem.MaxBots { if bots < 1 || bots > holdem.MaxBots {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick some opponents"}) writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "pick some opponents"})
return return
} }
if err := storage.Stake(user, req.BuyIn); err != nil { name := s.displayName(r, user)
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) { 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"}) writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to sit down"})
return return
} case errors.Is(err, storage.ErrHandInProgress):
slog.Error("games: holdem buy-in", "user", user, "err", err) 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) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
s.writeHoldemTable(w, user, nil)
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 // pickOpenSeat chooses a chair to join: the one the caller asked for if it is a
// about the session — deal the next hand, put more chips on the table, get up. // 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) { func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r) user, ok := s.player(w, r)
if !ok { if !ok {
@@ -257,113 +457,270 @@ func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad json", http.StatusBadRequest) http.Error(w, "bad json", http.StatusBadRequest)
return return
} }
if move.Kind == holdem.Leave {
s.leaveHoldem(w, user)
return
}
live, err := storage.LoadLiveHand(user) tableID, seat, err := storage.PlayerSeat(user)
if errors.Is(err, storage.ErrNoLiveHand) { if errors.Is(err, storage.ErrNoLiveHand) {
writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"}) writeJSONStatus(w, http.StatusConflict, map[string]string{"error": "you're not at a table"})
return return
} }
if err != nil { if err != nil {
slog.Error("games: holdem load", "user", user, "err", err) slog.Error("games: holdem move seat", "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) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
// A top-up is real money crossing the border, so the chips come off the stack var respErr error
// before the engine is asked — and go straight back if it says no. Same order, var respEvents []holdem.Event
// and the same reason, as doubling down at blackjack. 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) topped := int64(0)
if move.Kind == holdem.TopUp { if move.Kind == holdem.TopUp {
if err := storage.Stake(user, move.Amount); err != nil { if err := storage.Stake(user, move.Amount); err != nil {
if errors.Is(err, storage.ErrInsufficientChips) || errors.Is(err, storage.ErrBadAmount) { 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"}) respErr = holdem.ErrTooBig
return return nil
} }
slog.Error("games: holdem top-up", "user", user, "err", err) return err
http.Error(w, "internal error", http.StatusInternalServerError)
return
} }
topped = move.Amount topped = move.Amount
} }
next, evs, err := holdem.ApplyMove(g, soloViewer, move) next, evs, aerr := holdem.ApplyMove(g, seat, move)
if err != nil { if aerr != nil {
if topped > 0 { if topped > 0 {
_ = storage.Award(user, topped) // the top-up didn't happen _ = storage.Award(user, topped) // the top-up didn't happen
} }
msg := "that move isn't legal here" respErr = aerr
switch { return nil
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})
// 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 return
} }
s.persistHoldem(w, user, next, evs, live.Seed1, live.Seed2, false) if respErr != nil {
writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)})
return
}
s.writeHoldemTable(w, user, respEvents)
} }
// persistHoldem writes the table back and answers the browser. func moveStatus(err error) int {
// // A 409 is a concurrency verdict: the table is not where the caller thought, so
// The session settles exactly once — when the player gets up, or when they have // reload and look again. Everything else — an illegal move for this state — is
// nothing left to get up with. Until then Done is false and `commit` moves no // the caller's mistake, a 400. Leaving mid-hand or acting out of turn is a 400:
// chips at all, which is what makes a hundred hands of poker a single trip across // the request was simply not allowed, not raced.
// the border rather than a hundred. switch {
func (s *Server) persistHoldem(w http.ResponseWriter, user string, g holdem.State, evs []holdem.Event, seed1, seed2 uint64, fresh bool) { case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable):
blob, err := json.Marshal(g) 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 { if err != nil {
slog.Error("games: marshal holdem", "user", user, "err", err) slog.Error("games: holdem leave seat", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
done := g.Phase == holdem.PhaseDone var respErr error
outcome := "left" err = s.tableLocks.withTable(tableID, func() error {
switch { t, _, err := storage.LoadTable(tableID)
case done && g.Payout == 0: if errors.Is(err, storage.ErrNoSuchTable) {
outcome = "busted" respErr = storage.ErrNoSuchTable
case done && g.Payout > g.BoughtIn: return nil
outcome = "up"
case done && g.Payout < g.BoughtIn:
outcome = "down"
} }
if err != nil {
v, ok := s.commit(w, user, finished{ return err
Game: gameHoldem, Blob: blob, }
// Paid, not Rake: the audit log is the house's income, and the house only var g holdem.State
// makes money off the player. What it lifts off a bot's pot is not income. if err := json.Unmarshal(t.State, &g); err != nil {
Bet: g.BoughtIn, Payout: g.Payout, Rake: g.Paid, return err
Outcome: outcome, Done: done, }
Seed1: seed1, Seed2: seed2, Fresh: fresh, 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 !ok { if err != nil {
slog.Error("games: holdem leave", "user", user, "table", tableID, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
// A closed session is gone from storage, so the table view has none to show — if respErr != nil {
// but the browser still needs the last board to land the verdict on. writeJSONStatus(w, moveStatus(respErr), map[string]string{"error": moveMessage(respErr)})
if done { return
hv := viewHoldem(g, soloViewer) }
v.Holdem = &hv 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)
}
} }
v.HoldemEvents = viewHoldemEvents(evs, soloViewer)
writeJSON(w, v) writeJSON(w, v)
} }

View File

@@ -0,0 +1,200 @@
package web
import (
"testing"
"time"
"pete/internal/storage"
)
const bobPlayer = "@bob:parodia.dev"
// fundUser puts chips in front of a named player the way the border really does.
func fundUser(t *testing.T, user string, chips int64) {
t.Helper()
e, err := storage.RequestBuyIn(user, chips)
if err != nil {
t.Fatal(err)
}
if _, err := storage.ClaimEscrow(e.GUID); err != nil {
t.Fatal(err)
}
if _, err := storage.SettleEscrow(e.GUID, true, "", 0); err != nil {
t.Fatal(err)
}
}
func chipsOf(t *testing.T, user string) int64 {
t.Helper()
st, err := storage.Chips(user)
if err != nil {
t.Fatal(err)
}
return st.Chips
}
// A second person can sit down at a table somebody else opened, taking a chair a
// bot was keeping warm — which is the whole point of the thing being multiplayer.
func TestHoldemJoinTakesAnOpenSeat(t *testing.T) {
s := newCasino(t)
fund(t, 5000) // reala
fundUser(t, bobPlayer, 5000)
if _, code := call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 500})); code != 200 {
t.Fatalf("reala sit = %d, want 200", code)
}
tableID, err := storage.TableOf(testPlayer)
if err != nil {
t.Fatal(err)
}
v, code := call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
map[string]any{"table": tableID, "buyin": 500}))
if code != 200 {
t.Fatalf("bob join = %d, want 200", code)
}
if v.Chips != 4500 {
t.Errorf("bob's chips after a 500 buy-in = %d, want 4500", v.Chips)
}
if v.Holdem == nil {
t.Fatal("join returned no table")
}
_, bobSeat, err := storage.PlayerSeat(bobPlayer)
if err != nil {
t.Fatal(err)
}
if bobSeat == 0 {
t.Errorf("bob took reala's seat %d", bobSeat)
}
if v.Holdem.YourSeat != bobSeat {
t.Errorf("the view says bob is at seat %d, storage says %d", v.Holdem.YourSeat, bobSeat)
}
_, seats, err := storage.LoadTable(tableID)
if err != nil {
t.Fatal(err)
}
humans := 0
for _, seat := range seats {
if seat.MatrixUser != "" {
humans++
}
}
if humans != 2 {
t.Errorf("two people at the table, storage says %d humans", humans)
}
}
// Leaving a shared table gives you your stack and hands your chair back to the
// house; the table stays open for the people still on it. Only the last human out
// closes the felt.
func TestHoldemLeavingSharedTableKeepsItOpen(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
fundUser(t, bobPlayer, 5000)
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
tableID, _ := storage.TableOf(testPlayer)
call(t, s, s.handleHoldemSit, as(t, s, "bob", "POST", "/api/games/holdem/sit",
map[string]any{"table": tableID, "buyin": 500}))
// Bob gets up. His 500 comes home and his chair goes back to a bot.
if _, code := call(t, s, s.handleHoldemLeave, as(t, s, "bob", "POST", "/api/games/holdem/leave", nil)); code != 200 {
t.Fatalf("bob leave = %d, want 200", code)
}
if got := chipsOf(t, bobPlayer); got != 5000 {
t.Errorf("bob left with %d, want his 5000 back", got)
}
if _, _, err := storage.PlayerSeat(bobPlayer); err != storage.ErrNoLiveHand {
t.Errorf("bob still holds a seat after leaving: %v", err)
}
if _, _, err := storage.LoadTable(tableID); err != nil {
t.Errorf("the table closed under reala when bob left: %v", err)
}
// Reala is the last one out, so the felt closes behind them.
if _, code := call(t, s, s.handleHoldemLeave, as(t, s, "reala", "POST", "/api/games/holdem/leave", nil)); code != 200 {
t.Fatalf("reala leave = %d, want 200", code)
}
if _, _, err := storage.LoadTable(tableID); err != storage.ErrNoSuchTable {
t.Errorf("an empty table survived the last human: %v", err)
}
}
// A table everyone has walked away from is cashed out and closed by the reaper —
// the chips inside a walked-away poker session are not left in limbo.
func TestHoldemReaperCashesOutAbandonedTable(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
tableID, _ := storage.TableOf(testPlayer)
// The seat has been away, and last acted for itself longer ago than the idle
// cutoff — the state the reaper is meant to find.
tbl, seats, err := storage.LoadTable(tableID)
if err != nil {
t.Fatal(err)
}
seats[0].Away = true
seats[0].LastSeen = time.Now().Unix() - int64(storage.SessionIdleAfter.Seconds()) - 60
if err := storage.CommitTable(storage.TableCommit{Table: tbl, Seats: []storage.Seat{seats[0]}}); err != nil {
t.Fatal(err)
}
s.reapAbandonedTables()
if got := chipsOf(t, testPlayer); got != 5000 {
t.Errorf("the reaper sent home %d, want the whole 5000 back (buy-in and all)", got)
}
if _, _, err := storage.LoadTable(tableID); err != storage.ErrNoSuchTable {
t.Errorf("the reaper left the table standing: %v", err)
}
if _, err := storage.TableOf(testPlayer); err != storage.ErrNoLiveHand {
t.Errorf("the reaper left the occupancy claim behind: %v", err)
}
}
// A fresh table is not abandoned, and the reaper leaves it alone.
func TestHoldemReaperSparesALiveTable(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 2, "buyin": 500}))
tableID, _ := storage.TableOf(testPlayer)
s.reapAbandonedTables()
if _, _, err := storage.LoadTable(tableID); err != nil {
t.Errorf("the reaper closed a table someone is sitting at: %v", err)
}
if got := chipsOf(t, testPlayer); got != 4500 {
t.Errorf("chips = %d — the reaper should not have moved a live table's money", got)
}
}
// The lobby lists a table with a seat going spare, and drops it once it is full.
func TestHoldemLobbyListsJoinableTables(t *testing.T) {
s := newCasino(t)
fund(t, 5000)
// A one-bot table: two seats, one human, one open.
call(t, s, s.handleHoldemSit, as(t, s, "reala", "POST", "/api/games/holdem/sit",
map[string]any{"tier": "low", "bots": 1, "buyin": 500}))
tables, err := storage.LobbyTables(gameHoldem, 50)
if err != nil {
t.Fatal(err)
}
if len(tables) != 1 {
t.Fatalf("lobby has %d tables, want 1", len(tables))
}
if tables[0].Humans != 1 || tables[0].Seats != 2 {
t.Errorf("lobby table = %d/%d humans/seats, want 1/2", tables[0].Humans, tables[0].Seats)
}
}

View File

@@ -0,0 +1,209 @@
package web
import (
"encoding/json"
"time"
"pete/internal/games/holdem"
"pete/internal/storage"
)
// Hold'em as a shared table: the seam the runtime drives it through, and the two
// facts about a hand that only the engine can tell the runtime — when the clock
// must next act, and what a finished hand owes the audit trail.
//
// Everything about *playing* poker is still in the engine. This file is the
// translation layer between a holdem.State and the game-agnostic table runtime:
// it decodes the blob, asks the engine to act, and re-derives the deadline and
// the audit from the state that comes back.
// holdemTable is the tableGame for poker. It holds no state — the table's state
// is the blob in game_tables — so a single value in s.tableGames serves every
// felt in the room.
type holdemTable struct{}
func (holdemTable) name() string { return gameHoldem }
// timeout acts for the human whose clock ran out. At a card table the standing
// courtesy is check if you can, fold if you cannot: a walked-away player never
// puts more chips in, and folding keeps the hand moving for everyone still there.
//
// It marks the seat away so the runtime stops waiting a full clock on it next
// time — an absent human auto-folds on sight after this, rather than holding three
// other people hostage for thirty seconds an orbit.
//
// If, on decode, the seat to act is not a waiting human, the scan raced a real
// move that had not yet bumped the version: errNotDue, and the clock steps aside.
func (holdemTable) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) {
var g holdem.State
if err := json.Unmarshal(state, &g); err != nil {
return step{}, nil, err
}
if g.Phase != holdem.PhaseBetting {
return step{}, seats, errNotDue
}
seat := g.ToAct
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
return step{}, seats, errNotDue
}
move := holdem.Move{Kind: holdem.Fold}
if g.Owed(seat) == 0 {
move.Kind = holdem.Check
}
next, evs, err := holdem.ApplyMove(g, seat, move)
if err != nil {
return step{}, nil, err
}
changed := markAway(seats, seat)
st, err := holdemStep(g, next, evs, seats)
return st, changed, err
}
// stacks reports the chips in front of each seat, index-aligned with the table's
// seat rows. The abandoned-table reaper reads it to know what to send each
// walked-away human home with, without having to understand poker.
func (holdemTable) stacks(state []byte) ([]int64, error) {
var g holdem.State
if err := json.Unmarshal(state, &g); err != nil {
return nil, err
}
out := make([]int64, len(g.Seats))
for i := range g.Seats {
out[i] = g.Seats[i].Stack
}
return out, nil
}
// holdemStep packages a played-out state for the runtime: the blob to persist,
// the deadline the clock must honour next, and the audit of any hand that just
// ended. prev is the state before the move, which is what lets it work out how
// much rake this one hand took.
func holdemStep(prev, next holdem.State, evs []holdem.Event, seats []storage.Seat) (step, error) {
blob, err := json.Marshal(next)
if err != nil {
return step{}, err
}
st := step{
State: blob,
Phase: string(next.Phase),
HandNo: int64(next.HandNo),
Deadline: holdemDeadline(next, seats),
Audit: holdemAudit(prev, next, evs, seats),
}
return st, nil
}
// holdemDeadline is when the clock must next act, or 0 for never. A clock is only
// ever set on a *present* human whose turn it is: a bot resolves inside the move
// and never waits, and an away human is auto-acted the moment the clock sees them
// rather than waited on. Between hands there is no per-seat clock at all — an
// abandoned table is the reaper's job, not the turn clock's.
func holdemDeadline(g holdem.State, seats []storage.Seat) int64 {
if g.Phase != holdem.PhaseBetting {
return 0
}
seat := g.ToAct
if seat < 0 || seat >= len(g.Seats) || g.Seats[seat].Bot {
return 0
}
if seatAway(seats, seat) {
return 0 // an away human doesn't get waited on; the clock acts next tick
}
return time.Now().Unix() + turnSeconds
}
// holdemAudit is the per-hand record of a finished hand — one row per human who
// was in it. Empty until a hand actually ends, which is exactly when the engine
// emits an "end" beat.
//
// The rake is the trap the plan flagged. game_hands.rake is summed into the
// house's income (HouseTake), so a pot's rake must be recorded once and once
// only. It rides on the winner's row alone; every other seat carries zero. And it
// is this hand's rake, not the session's: next.Paid is cumulative, so the hand's
// take is the amount it climbed by since prev — the part of *this* pot that came
// out of a human's winnings.
func holdemAudit(prev, next holdem.State, evs []holdem.Event, seats []storage.Seat) []storage.Hand {
if !handEnded(evs) {
return nil
}
handRake := next.Paid - prev.Paid
// The human who won the most is who the rake is attributed to: rake only ever
// comes out of a pot a human won, so when handRake is positive there is one.
winner, best := -1, int64(0)
for i := range next.Seats {
if next.Seats[i].Bot {
continue
}
if next.Seats[i].Won > best {
winner, best = i, next.Seats[i].Won
}
}
var audit []storage.Hand
for i := range next.Seats {
p := next.Seats[i]
if p.Bot || i >= len(seats) || seats[i].MatrixUser == "" {
continue
}
if p.Total == 0 && p.Won == 0 {
continue // dealt out, or never in the hand — nothing to record
}
outcome := "lost"
switch {
case p.Won > p.Total:
outcome = "won"
case p.Won == p.Total:
outcome = "push"
}
rake := int64(0)
if i == winner {
rake = handRake
}
audit = append(audit, storage.Hand{
MatrixUser: seats[i].MatrixUser,
Game: gameHoldem,
Bet: p.Total,
Payout: p.Won,
Rake: rake,
Outcome: outcome,
Seed1: next.Seed1,
Seed2: next.Seed2,
})
}
return audit
}
// handEnded reports whether a hand finished in this batch of events. endHand is
// the only thing that emits "end", and it emits exactly one, so this is the clean
// signal that Won and Total on the seats are this hand's final numbers.
func handEnded(evs []holdem.Event) bool {
for _, e := range evs {
if e.Kind == "end" {
return true
}
}
return false
}
// ---- seat bookkeeping ------------------------------------------------------
// seatAway reports whether the seat at an index is a human who has walked away.
func seatAway(seats []storage.Seat, seat int) bool {
return seat >= 0 && seat < len(seats) && seats[seat].Away
}
// markAway returns the one seat row the clock needs to write back: the timed-out
// seat, flagged away, with its last_seen left exactly as it was. Preserving
// last_seen is the whole point — the reaper measures abandonment from when a
// human last acted *for themselves*, and an auto-fold is not that.
func markAway(seats []storage.Seat, seat int) []storage.Seat {
if seat < 0 || seat >= len(seats) {
return nil
}
s := seats[seat]
s.Away = true
return []storage.Seat{s}
}

View File

@@ -129,6 +129,11 @@ func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/games/holdem/sit", s.handleHoldemSit) mux.HandleFunc("POST /api/games/holdem/sit", s.handleHoldemSit)
mux.HandleFunc("POST /api/games/holdem/move", s.handleHoldemMove) mux.HandleFunc("POST /api/games/holdem/move", s.handleHoldemMove)
mux.HandleFunc("POST /api/games/holdem/leave", s.handleHoldemLeave)
mux.HandleFunc("GET /api/games/holdem/tables", s.handleHoldemLobby)
mux.HandleFunc("GET /api/games/holdem/stream", s.handleHoldemStream)
mux.HandleFunc("GET /api/games/holdem/chat", s.handleHoldemChat)
mux.HandleFunc("POST /api/games/holdem/say", s.handleHoldemSay)
} }
// requirePlayer sends an anonymous visitor to sign in and comes back here after. // requirePlayer sends an anonymous visitor to sign in and comes back here after.

View File

@@ -297,11 +297,30 @@ func (s *Server) table(user string) (tableView, error) {
uv := viewUno(g) uv := viewUno(g)
v.Uno = &uv v.Uno = &uv
case gameHoldem: case gameHoldem:
// A seated hold'em player's cards are in game_tables, not here — this row is
// only their occupancy claim, so its state is empty. Load the table and render
// it as their own seat sees it.
if live.TableID == "" {
return s.dropUnreadable(user, v, fmt.Errorf("holdem row with no table"))
}
t, _, err := storage.LoadTable(live.TableID)
if errors.Is(err, storage.ErrNoSuchTable) {
// The table closed under them (reaped, or the last hand cashed them out).
// Their claim is stale; clear it so they can sit down again.
return s.dropUnreadable(user, v, fmt.Errorf("holdem table %s gone", live.TableID))
}
if err != nil {
return tableView{}, err
}
_, seat, err := storage.PlayerSeat(user)
if err != nil {
return tableView{}, err
}
var g holdem.State var g holdem.State
if err := json.Unmarshal(live.State, &g); err != nil { if err := json.Unmarshal(t.State, &g); err != nil {
return s.dropUnreadable(user, v, err) return s.dropUnreadable(user, v, err)
} }
hv := viewHoldem(g, soloViewer) hv := viewHoldem(g, seat)
v.Holdem = &hv v.Holdem = &hv
default: default:
return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game)) return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game))

View File

@@ -1,6 +1,18 @@
package web package web
import "pete/internal/storage" import (
"errors"
"pete/internal/storage"
)
// errNotDue is a timeout that turned out to have nothing to do. The clock scanned
// a table as expired, took the lock, and found — on decoding the state — that the
// seat to act is not in fact a waiting human: a real move landed in the instant
// between the scan and the lock and had not yet bumped the version the clock
// checked. It is not an error, it is the race resolving the right way, so the
// clock swallows it silently rather than logging.
var errNotDue = errors.New("games: nothing to time out")
// The table runtime: the game-agnostic half of a shared table. // The table runtime: the game-agnostic half of a shared table.
// //
@@ -69,4 +81,9 @@ type tableGame interface {
// landed in the same instant the clock fired and the version had not yet been // landed in the same instant the clock fired and the version had not yet been
// bumped when the clock scanned. // bumped when the clock scanned.
timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error) timeout(state []byte, seats []storage.Seat) (step, []storage.Seat, error)
// stacks reports the chips in front of each seat, index-aligned with the
// table's seat rows, so the abandoned-table reaper can cash out a walked-away
// player without knowing how their game is played.
stacks(state []byte) ([]int64, error)
} }

291
internal/web/games_table.go Normal file
View File

@@ -0,0 +1,291 @@
package web
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"pete/internal/storage"
)
// The runtime's public surface: the lobby a player finds a table in, the stream
// that keeps their felt live while other people play on it, and the chat that
// runs along the rail. None of this knows poker from UNO — it is keyed on table
// id and moves opaque frames — which is what lets it serve every shared table.
// ---- the lobby -------------------------------------------------------------
// handleHoldemLobby lists the hold'em tables with a seat going spare. A table
// with every chair taken is not shown, because a lobby you cannot join from is
// just a list; bots keep every open table populated, so there is always
// something to sit down at.
func (s *Server) handleHoldemLobby(w http.ResponseWriter, r *http.Request) {
if _, ok := s.player(w, r); !ok {
return
}
tables, err := storage.LobbyTables(gameHoldem, 50)
if err != nil {
slog.Error("games: holdem lobby", "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
open := make([]storage.TableSummary, 0, len(tables))
for _, t := range tables {
if t.Humans < t.Seats {
open = append(open, t)
}
}
writeJSON(w, map[string]any{"tables": open})
}
// ---- the live stream -------------------------------------------------------
// streamPing is how often the stream sends a comment down an idle connection.
// EventSource reconnects itself, but a proxy will hang up a stream that has gone
// quiet, and a heartbeat well under the usual idle timeout keeps it open.
const streamPing = 25 * time.Second
// handleHoldemStream is the player's live view of their table: a Server-Sent
// Events stream that carries a nudge every time the table changes and every line
// of chat as it is said.
//
// It obeys the one rule that keeps a stream from bricking the app (rule from
// games_hub.go): it touches the database exactly once, at the top, to find which
// table the player is at. After that it only ever reads its channel. Holding a
// query open for the life of a stream would hold the single pooled connection for
// the life of a stream, and one idle subscriber would take the whole site down.
func (s *Server) handleHoldemStream(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
tableID, err := storage.TableOf(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: stream table of", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // tell nginx-likes not to buffer us
w.WriteHeader(http.StatusOK)
ch, unsubscribe := s.hub.subscribe(tableID)
defer unsubscribe()
// Nudge the client to fetch straight away, so a stream that opens after a change
// already missed does not sit blank until the next one.
fmt.Fprint(w, "event: sync\ndata: {}\n\n")
flusher.Flush()
ping := time.NewTicker(streamPing)
defer ping.Stop()
ctx := r.Context()
for {
select {
case <-ctx.Done():
return
case <-ping.C:
fmt.Fprint(w, ": ping\n\n")
flusher.Flush()
case f, open := <-ch:
if !open {
return
}
fmt.Fprintf(w, "data: %s\n\n", f.Data)
flusher.Flush()
}
}
}
// ---- chat ------------------------------------------------------------------
// handleHoldemChat reads the recent rail of a player's table, oldest first, with
// their own lines flagged so the felt can lay them out on the right.
func (s *Server) handleHoldemChat(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
tableID, err := storage.TableOf(user)
if errors.Is(err, storage.ErrNoLiveHand) {
writeJSON(w, map[string]any{"chat": []storage.ChatLine{}})
return
}
if err != nil {
slog.Error("games: chat table of", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
lines, err := storage.Chat(tableID, 50)
if err != nil {
slog.Error("games: chat", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
for i := range lines {
lines[i].Mine = false // filled below; the DB does not know who is asking
}
name := s.displayName(r, user)
for i := range lines {
lines[i].Mine = lines[i].Name == name
}
writeJSON(w, map[string]any{"chat": lines})
}
// handleHoldemSay records a line of chat and fans it to the table. The line is
// stamped with the hand it was said during — the one question chat at a money
// table ever really raises — and pushed to every open stream so it lands on the
// rail in real time.
func (s *Server) handleHoldemSay(w http.ResponseWriter, r *http.Request) {
user, ok := s.player(w, r)
if !ok {
return
}
var req struct {
Body string `json:"body"`
}
if err := decodeJSON(r, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
tableID, err := storage.TableOf(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: say table of", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
name := s.displayName(r, user)
line, err := storage.Say(tableID, user, name, req.Body)
if errors.Is(err, storage.ErrBadAmount) {
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "say something"})
return
}
if err != nil {
slog.Error("games: say", "user", user, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
s.publishChat(tableID, line)
line.Mine = true
writeJSON(w, line)
}
// publishChat fans a chat line to everyone watching a table. Like publishTable it
// is a non-blocking send after the database work is done — the line is already
// saved, this only delivers it — so a slow subscriber costs itself a missed line
// (which its next chat fetch recovers), never the lock.
func (s *Server) publishChat(tableID string, line storage.ChatLine) {
if s.hub.watchers(tableID) == 0 {
return
}
data, err := json.Marshal(map[string]any{"type": "chat", "line": line})
if err != nil {
return
}
s.hub.publish(tableID, hubFrame{Data: data})
}
// ---- the abandoned-table reaper --------------------------------------------
// runTableReaper cashes out tables that everyone has walked away from, on the
// same slow timer as the session reaper. It is the seated-player counterpart to
// that loop: a walked-away poker player's chips are inside a table blob, where
// the session reaper (which reads the game_chips stack) cannot see them, so
// without this they would sit in limbo until the player happened to come back.
func (s *Server) runTableReaper(ctx context.Context) {
ticker := time.NewTicker(reaperInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.reapAbandonedTables()
}
}
}
// reapAbandonedTables finds the tables nobody is coming back to and closes each,
// sending every seated human home with whatever is in front of them.
func (s *Server) reapAbandonedTables() {
cutoff := time.Now().Unix() - int64(storage.SessionIdleAfter.Seconds())
refs, err := storage.AbandonedTables(cutoff)
if err != nil {
slog.Error("games: abandoned tables", "err", err)
return
}
for _, ref := range refs {
s.reapTable(ref)
}
}
// reapTable cashes out and closes one abandoned table, under its lock and only if
// it is still the version the scan saw — so a player who wandered back to the
// felt in the same instant keeps their seat and their chips.
func (s *Server) reapTable(ref storage.TableRef) {
err := s.tableLocks.withTable(ref.ID, func() error {
t, seats, err := storage.LoadTable(ref.ID)
if errors.Is(err, storage.ErrNoSuchTable) {
return nil
}
if err != nil {
return err
}
if t.Version != ref.Version {
return nil // somebody came back between the scan and here
}
game := s.games()[t.Game]
if game == nil {
slog.Error("games: reaper over unknown game", "game", t.Game, "table", t.ID)
return nil
}
stacks, err := game.stacks(t.State)
if err != nil {
return err
}
var humans []storage.ReapSeat
for _, seat := range seats {
if seat.MatrixUser == "" {
continue
}
amount := int64(0)
if seat.Seat >= 0 && seat.Seat < len(stacks) {
amount = stacks[seat.Seat]
}
humans = append(humans, storage.ReapSeat{
Seat: seat.Seat, MatrixUser: seat.MatrixUser, Amount: amount,
})
}
if err := storage.ReapTable(storage.Reap{TableID: t.ID, Version: t.Version, Humans: humans}); err != nil {
if errors.Is(err, storage.ErrStaleTable) {
return nil
}
return err
}
slog.Info("games: reaped abandoned table", "table", t.ID, "humans", len(humans))
return nil
})
if err != nil {
slog.Error("games: reap table", "table", ref.ID, "err", err)
}
}

View File

@@ -144,7 +144,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
live = append(live, ch) live = append(live, ch)
} }
s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks()} s := &Server{cfg: cfg, sources: infos, postingEnabled: postingEnabled, tpls: tpls, adminSubs: adminSubs, adv: adv, advPost: advPost, channels: live, hub: newGamesHub(), tableLocks: newStripedLocks(), tableGames: []tableGame{holdemTable{}}}
// Optional OIDC sign-in (Authentik). Discovery is a network call; if the // Optional OIDC sign-in (Authentik). Discovery is a network call; if the
// provider is unreachable at boot we log and serve anonymously rather than // provider is unreachable at boot we log and serve anonymously rather than