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