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
215 lines
7.9 KiB
Go
215 lines
7.9 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"pete/internal/storage"
|
|
)
|
|
|
|
// The turn clock: the first goroutine in Pete that has ever mutated game state.
|
|
//
|
|
// Every other background loop here reads, refills or prunes. This one plays. When
|
|
// a human sits at a felt and walks away mid-hand, three other people are waiting
|
|
// on a decision that is never coming, and something has to make it for them. That
|
|
// something is this.
|
|
//
|
|
// It is built on one discipline and one guard.
|
|
//
|
|
// The discipline is rule 1: **collect the due tables and close the rows before
|
|
// taking any lock.** The scan reads *sql.Rows from the one-connection pool; a lock
|
|
// taken while those rows are open would hold the connection the rows need, and the
|
|
// process would wedge. So DueTables returns a plain slice and the connection is
|
|
// free before the first table is touched.
|
|
//
|
|
// The guard is the version. The scan records each table's version; the clock acts
|
|
// on a table only if it is *still* that version by the time it holds the lock and
|
|
// has reloaded. Without that check the clock and a real move race and both land:
|
|
// Bob raises in the same second his clock expires, the action moves to Cara, and
|
|
// the clock — still believing the seat that ran out of time is to act — folds
|
|
// Cara, who had twenty-five seconds left. The version check turns that into a
|
|
// no-op: Bob's move bumped the version, the reload shows the new one, and the
|
|
// clock steps aside.
|
|
|
|
// clockInterval is how often the clock looks for expired turns. Sub-second
|
|
// precision buys nothing at a card table and would only spin the CPU.
|
|
const clockInterval = time.Second
|
|
|
|
// reaperInterval is how often idle sessions are cashed out. The reaper is a
|
|
// slow-moving safety net — a player who wandered off half an hour ago is not in a
|
|
// hurry — so it runs far less often than the clock.
|
|
const reaperInterval = time.Minute
|
|
|
|
// games returns the multiplayer engines by their storage key. It is the registry
|
|
// the clock and the handlers both dispatch through. Empty until an engine is
|
|
// wired; a clock over no games is a loop that finds nothing, which is correct.
|
|
func (s *Server) games() map[string]tableGame {
|
|
out := make(map[string]tableGame)
|
|
for _, g := range s.tableGames {
|
|
out[g.name()] = g
|
|
}
|
|
return out
|
|
}
|
|
|
|
// StartTableClock launches the turn clock and the session reaper if the casino is
|
|
// on. Safe to call unconditionally.
|
|
func (s *Server) StartTableClock(ctx context.Context) {
|
|
if !s.gamesReady() {
|
|
return
|
|
}
|
|
// A deploy just took every table's clock down with it. Shove the live deadlines
|
|
// out so the first tick does not auto-act the whole room at once.
|
|
if err := storage.PushDeadlines(bootGrace); err != nil {
|
|
slog.Error("games: push deadlines on boot", "err", err)
|
|
}
|
|
go s.runTableClock(ctx)
|
|
go s.runSessionReaper(ctx)
|
|
go s.runTableReaper(ctx)
|
|
}
|
|
|
|
func (s *Server) runTableClock(ctx context.Context) {
|
|
slog.Info("games: turn clock started", "interval", clockInterval)
|
|
ticker := time.NewTicker(clockInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.tickClock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// tickClock finds the tables whose turn has expired and acts on each. The scan is
|
|
// done and the connection released before any table is locked — rule 1.
|
|
func (s *Server) tickClock() {
|
|
due, err := storage.DueTables(time.Now().Unix())
|
|
if err != nil {
|
|
slog.Error("games: due tables", "err", err)
|
|
return
|
|
}
|
|
for _, ref := range due {
|
|
s.runClockTable(ref)
|
|
}
|
|
}
|
|
|
|
// runClockTable acts for the walked-away player at one table, but only if the
|
|
// table is still at the version the scan saw.
|
|
//
|
|
// The whole read-modify-write is done under the table's stripe, which is what
|
|
// keeps the clock from racing a second copy of itself — but the stripe is only an
|
|
// optimisation. The version check inside CommitTable is the real thing: even
|
|
// across a redeploy, when two processes hold two different stripes over this row,
|
|
// the one whose write lands first bumps the version and the other's write finds
|
|
// zero rows and rolls back.
|
|
func (s *Server) runClockTable(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 // closed out from under us; nothing to do
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Somebody moved between the scan and now. Their move set a fresh deadline (or
|
|
// cleared it), so this expiry is stale — step aside.
|
|
if t.Version != ref.Version {
|
|
return nil
|
|
}
|
|
// A deadline in the future means the scan is looking at an old view; leave it.
|
|
if t.Deadline == 0 || t.Deadline > time.Now().Unix() {
|
|
return nil
|
|
}
|
|
|
|
game := s.games()[t.Game]
|
|
if game == nil {
|
|
slog.Error("games: clock over unknown game", "game", t.Game, "table", t.ID)
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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: newSeats, Audit: st.Audit}); err != nil {
|
|
if errors.Is(err, storage.ErrStaleTable) {
|
|
return nil // lost the race after all; the winner handled it
|
|
}
|
|
return err
|
|
}
|
|
s.publishTable(ref.ID)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
slog.Error("games: clock table", "table", ref.ID, "err", err)
|
|
}
|
|
}
|
|
|
|
// publishTable pushes the current table view to everyone watching it. It reads
|
|
// the table fresh (the authoritative state) and fans an opaque frame out through
|
|
// the hub. Called after every committed write, under no lock the hub cares about
|
|
// — the hub's sends are non-blocking, so this never stalls a caller.
|
|
//
|
|
// A view here is deliberately seat-blind: it carries only what every seat may see
|
|
// (the version and the public table shape), and each subscriber's own stream
|
|
// redacts and re-renders for the seat that is watching. That keeps a hole card
|
|
// from ever entering a frame that fans to the whole table.
|
|
func (s *Server) publishTable(tableID string) {
|
|
if s.hub.watchers(tableID) == 0 {
|
|
return
|
|
}
|
|
t, _, err := storage.LoadTable(tableID)
|
|
if errors.Is(err, storage.ErrNoSuchTable) {
|
|
return
|
|
}
|
|
if err != nil {
|
|
slog.Error("games: publish table load", "table", tableID, "err", err)
|
|
return
|
|
}
|
|
// 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.
|
|
// 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})
|
|
}
|
|
|
|
// runSessionReaper cashes out players who wandered off, on a timer. This is the
|
|
// loop the plan noted never existed: ReapIdleSessions has always been safe to run
|
|
// and nothing ever ran it, so chips in abandoned *solo* sessions sat in limbo.
|
|
//
|
|
// A seated player is invisible to it — their chips are inside a table blob, not on
|
|
// their game_chips stack — so it only ever reaps a player who is genuinely idle
|
|
// with loose chips. Getting up from a table returns them to the stack, and then
|
|
// this is what eventually sends them home.
|
|
func (s *Server) runSessionReaper(ctx context.Context) {
|
|
slog.Info("games: session reaper started", "interval", reaperInterval, "idle", storage.SessionIdleAfter)
|
|
ticker := time.NewTicker(reaperInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
n, err := storage.ReapIdleSessions(storage.SessionIdleAfter)
|
|
if err != nil {
|
|
slog.Error("games: reap idle sessions", "err", err)
|
|
continue
|
|
}
|
|
if n > 0 {
|
|
slog.Info("games: reaped idle sessions", "count", n)
|
|
}
|
|
}
|
|
}
|
|
}
|