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