diff --git a/internal/games/holdem/holdem.go b/internal/games/holdem/holdem.go index b069770..f4d9919 100644 --- a/internal/games/holdem/holdem.go +++ b/internal/games/holdem/holdem.go @@ -49,6 +49,7 @@ var ( ErrUnknownTier = errors.New("holdem: no such table") ErrBadBuyIn = errors.New("holdem: that isn't a legal buy-in") 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 @@ -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. // SoloSeats is exported for the handler that still opens a solo table. See New. func SoloSeats(t Tier, bots int, buyIn int64) []SeatConfig { - seats := []SeatConfig{{Name: "You", Stack: buyIn}} - for i := 0; i < bots; i++ { + return TableSeats(t, "You", bots, buyIn) +} + +// 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}) } 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 // 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 diff --git a/internal/storage/tables.go b/internal/storage/tables.go index e795f6f..263a611 100644 --- a/internal/storage/tables.go +++ b/internal/storage/tables.go @@ -140,19 +140,108 @@ func OpenTable(t Table, seats []Seat) error { 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. +// +// 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 { var user any if s.MatrixUser != "" { user = s.MatrixUser } + seen := s.LastSeen + if seen == 0 { + seen = now + } if _, err := tx.Exec( `INSERT INTO game_seats (table_id, seat, matrix_user, name, staked, away, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(table_id, seat) DO UPDATE SET matrix_user = excluded.matrix_user, name = excluded.name, 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 { return fmt.Errorf("games: seat: %w", err) } @@ -498,6 +587,22 @@ func LeaveTable(l Leave) error { 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 // occupancy claim, so it agrees with the cash-out check by construction. func TableOf(user string) (string, error) { @@ -548,6 +653,124 @@ func CloseTable(id string) error { 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 -------------------------------------------------------- // TableRef is a table the clock has found expired: which one, and at what version diff --git a/internal/web/games_clock.go b/internal/web/games_clock.go index ae2c36c..56d72e4 100644 --- a/internal/web/games_clock.go +++ b/internal/web/games_clock.go @@ -67,6 +67,7 @@ func (s *Server) StartTableClock(ctx context.Context) { } go s.runTableClock(ctx) go s.runSessionReaper(ctx) + go s.runTableReaper(ctx) } 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) + if errors.Is(err, errNotDue) { + return nil // the race resolved without us; nothing to act on + } if err != nil { slog.Error("games: clock timeout", "table", t.ID, "err", err) 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 // 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}) } diff --git a/internal/web/games_clock_test.go b/internal/web/games_clock_test.go index 5aa714d..f550e9c 100644 --- a/internal/web/games_clock_test.go +++ b/internal/web/games_clock_test.go @@ -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 } +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 // fresh DB. Enough to drive the clock, nothing else. func clockTestServer(t *testing.T, g tableGame) *Server { diff --git a/internal/web/games_holdem.go b/internal/web/games_holdem.go index 82fbc26..280f407 100644 --- a/internal/web/games_holdem.go +++ b/internal/web/games_holdem.go @@ -5,6 +5,8 @@ import ( "errors" "log/slog" "net/http" + "strings" + "time" "pete/internal/games/blackjack" "pete/internal/games/holdem" @@ -44,10 +46,6 @@ type holdemSeatView struct { 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", @@ -57,10 +55,15 @@ var seatStates = map[holdem.SeatState]string{ // 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"` + 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"` @@ -98,6 +101,7 @@ type holdemView struct { 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(), @@ -190,10 +194,49 @@ func viewHoldemEvents(evs []holdem.Event, viewer int) []holdemEventView { } 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 -// same statement that checks they exist, so two sit-downs fired at once cannot -// buy in with the same chip. +// 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 { @@ -203,50 +246,207 @@ func (s *Server) handleHoldemSit(w http.ResponseWriter, r *http.Request) { 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 } - 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 { 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", - }) + 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 req.Bots < 1 || req.Bots > holdem.MaxBots { + if bots < 1 || 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) + 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 } - 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) + 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.persistHoldem(w, user, g, evs, seed1, seed2, true) + s.writeHoldemTable(w, user, nil) } -// 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. +// 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 { @@ -257,113 +457,270 @@ func (s *Server) handleHoldemMove(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad json", http.StatusBadRequest) 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) { 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) + slog.Error("games: holdem move seat", "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 + 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 } - slog.Error("games: holdem top-up", "user", user, "err", 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 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() + + 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 { - 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}) + slog.Error("games: holdem move", "user", user, "table", tableID, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) 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. -// -// 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" +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 done && g.Payout == 0: - outcome = "busted" - case done && g.Payout > g.BoughtIn: - outcome = "up" - case done && g.Payout < g.BoughtIn: - outcome = "down" + case errors.Is(err, storage.ErrStaleTable), errors.Is(err, storage.ErrNoSuchTable): + return http.StatusConflict + default: + return http.StatusBadRequest } +} - 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, - }) +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 } - // 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 + 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) + } } - v.HoldemEvents = viewHoldemEvents(evs, soloViewer) writeJSON(w, v) } diff --git a/internal/web/games_holdem_multiplayer_test.go b/internal/web/games_holdem_multiplayer_test.go new file mode 100644 index 0000000..b789a00 --- /dev/null +++ b/internal/web/games_holdem_multiplayer_test.go @@ -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) + } +} diff --git a/internal/web/games_holdem_table.go b/internal/web/games_holdem_table.go new file mode 100644 index 0000000..8892e5a --- /dev/null +++ b/internal/web/games_holdem_table.go @@ -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} +} diff --git a/internal/web/games_pages.go b/internal/web/games_pages.go index 04744b9..f88bb9e 100644 --- a/internal/web/games_pages.go +++ b/internal/web/games_pages.go @@ -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/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. diff --git a/internal/web/games_play.go b/internal/web/games_play.go index 1cb5dc5..0b67f7f 100644 --- a/internal/web/games_play.go +++ b/internal/web/games_play.go @@ -297,11 +297,30 @@ func (s *Server) table(user string) (tableView, error) { uv := viewUno(g) v.Uno = &uv 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 - if err := json.Unmarshal(live.State, &g); err != nil { + if err := json.Unmarshal(t.State, &g); err != nil { return s.dropUnreadable(user, v, err) } - hv := viewHoldem(g, soloViewer) + hv := viewHoldem(g, seat) v.Holdem = &hv default: return s.dropUnreadable(user, v, fmt.Errorf("unknown game %q", live.Game)) diff --git a/internal/web/games_runtime.go b/internal/web/games_runtime.go index 135c563..1f54207 100644 --- a/internal/web/games_runtime.go +++ b/internal/web/games_runtime.go @@ -1,6 +1,18 @@ 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. // @@ -69,4 +81,9 @@ type tableGame interface { // landed in the same instant the clock fired and the version had not yet been // bumped when the clock scanned. 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) } diff --git a/internal/web/games_table.go b/internal/web/games_table.go new file mode 100644 index 0000000..4723608 --- /dev/null +++ b/internal/web/games_table.go @@ -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) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 9c1e506..1677f2f 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -144,7 +144,7 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo 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 // provider is unreachable at boot we log and serve anonymously rather than