Phase D backend: UNO is now a session like hold'em, not a single stake. You sit
with a buy-in stack, ante into a pot each hand, and leave with what's in front of
you. The engine lost its `You` constant and its measured multiples: ApplyMove
takes the acting seat, New takes a seat list, a Tier carries an ante instead of a
Base, and a hand settles by moving the pot to the winner (less rake, and never
when a bot takes it) rather than paying a multiple. A mercy kill puts a seat out
of the hand, not out of the game — the last one standing takes the pot.
The redaction moved to the web layer, where hold'em's already lives: the engine
now stamps every seat's hand onto its events, and viewUno/viewUnoEvents strip
everything that isn't the viewer's own. TestUnoViewNeverLeaksAnotherSeatsCards is
the wall. unoTable implements tableGame; /uno/{sit,move,leave,tables,stream,chat,
say} mirror hold'em, with stream/chat/say now shared game-agnostic handlers.
The frontend is not done: uno.js still calls the retired solo endpoint, so the
page renders but is not yet playable. All engine and web tests are green.
Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
300 lines
9.4 KiB
Go
300 lines
9.4 KiB
Go
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 and handleUnoLobby list the tables of their game 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) {
|
|
s.lobby(w, r, gameHoldem)
|
|
}
|
|
|
|
func (s *Server) handleUnoLobby(w http.ResponseWriter, r *http.Request) {
|
|
s.lobby(w, r, gameUno)
|
|
}
|
|
|
|
func (s *Server) lobby(w http.ResponseWriter, r *http.Request, game string) {
|
|
if _, ok := s.player(w, r); !ok {
|
|
return
|
|
}
|
|
tables, err := storage.LobbyTables(game, 50)
|
|
if err != nil {
|
|
slog.Error("games: lobby", "game", game, "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) handleTableStream(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) handleTableChat(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) handleTableSay(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)
|
|
}
|
|
}
|