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

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)
}
}