Files
Pete/internal/web/games_pages.go
prosolis 5139385350 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
2026-07-14 16:37:49 -07:00

266 lines
10 KiB
Go

package web
import (
"net/http"
"strings"
"time"
"pete/internal/games/blackjack"
"pete/internal/games/hangman"
"pete/internal/games/holdem"
"pete/internal/games/klondike"
"pete/internal/games/trivia"
"pete/internal/games/uno"
"pete/internal/storage"
)
// The casino's two pages. Both require a signed-in visitor — there is money in
// here, and a player has to be somebody gogobee's ledger can name.
//
// Neither page renders any game state server-side. The felt is drawn by the
// browser from /api/games/table, because a hand is a thing that *happens*: cards
// are dealt one at a time and the table plays that back. A server-rendered hand
// would arrive fully formed, which is the one thing a card table must never do.
// gameTeaser is a table that isn't open yet. They're on the lobby because an
// empty casino with one game reads as broken, and this reads as early.
type gameTeaser struct {
Name string
Emoji string
Blurb string
}
// comingSoon is empty, and that is the point: every game the plan named is now on
// the felt. Leave it here — the lobby renders nothing for an empty list, and the
// next game to be dreamed up goes in it.
var comingSoon = []gameTeaser{}
// betDenominations are the chips you build a bet out of.
var betDenominations = []int64{5, 25, 100, 500}
// The casino is not called Pete — the news app is Pete's, and this is somewhere
// you go. It has two names, and which one is over the door depends on the hour:
// the lights come on at six and the place turns into Casino Night Zone until
// dawn. Same tables, different room.
type room struct {
Slug string // drives the palette: html[data-room="…"]
Name string // what's on the sign
}
var (
roomDay = room{Slug: "casinopolis", Name: "Casinopolis"}
roomNight = room{Slug: "casino-night", Name: "Casino Night Zone"}
)
// roomAt picks the room for an hour of the day. Daylight is 6am to 6pm; the rest
// belongs to the neon. The browser re-runs this same rule against its own clock
// (games_layout.html), so a player in another timezone sees their own evening —
// this server-side pick only exists so the first paint isn't the wrong room.
func roomAt(hour int) room {
if hour >= 6 && hour < 18 {
return roomDay
}
return roomNight
}
// gamesPage is deliberately *not* pageData. The casino shares Pete's design
// language and nothing else — no channels, no weather, no sources, no push.
// Giving it its own page struct is what stops the news app's furniture drifting
// back in one convenient field at a time.
type gamesPage struct {
Room room
// URL and OGImage are here for the share card, and they are absolute because
// Open Graph will not resolve a relative one: the thing reading those tags is
// a chat server, and it has no page to resolve against. See casinoURL.
URL string // this page, at the address a player would type
OGImage string // the share card, at the same
User *SessionUser
Cap int64
RakePct int
Soon []gameTeaser
Denominations []int64
Tiers []hangman.Tier // hangman's three lengths, and what each pays
MaxWrong int
Deals []klondike.Tier // solitaire's three deals
FullDeck int
Quizzes []trivia.Tier // trivia's three difficulties
Rungs int // how long the trivia ladder is
Tables []uno.Tier // uno's three tables, and how many bots sit at each
NoMercy []uno.Tier // the same three, playing the other rules
MercyLimit int // the hand that ends you in No Mercy
Stakes []holdem.Tier // hold'em's three tables, by blinds
MaxBots int // how many seats hold'em will fill with bots
}
// casinoRoutes hangs every table off the mux.
//
// It exists so there is exactly one list of them. The dev rig (devcasino_test.go)
// has to wire its own mux — New() decides whether the casino exists before the
// rig has signed anybody in — and a second copy of this list is a list that
// silently stops including the newest game.
func (s *Server) casinoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /games", s.handleLobby)
mux.HandleFunc("GET /games/og.png", s.handleGamesOG) // the share card, and the one games page with no door on it
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
mux.HandleFunc("GET /games/hangman", s.handleHangman)
mux.HandleFunc("GET /games/solitaire", s.handleSolitaire)
mux.HandleFunc("GET /games/trivia", s.handleTrivia)
mux.HandleFunc("GET /games/uno", s.handleUno)
mux.HandleFunc("GET /games/holdem", s.handleHoldem)
mux.HandleFunc("GET /api/games/table", s.handleTable)
mux.HandleFunc("POST /api/games/buyin", s.handleBuyIn)
mux.HandleFunc("POST /api/games/cashout", s.handleCashOut)
mux.HandleFunc("POST /api/games/blackjack/deal", s.handleDeal)
mux.HandleFunc("POST /api/games/blackjack/move", s.handleMove)
mux.HandleFunc("POST /api/games/hangman/start", s.handleHangmanStart)
mux.HandleFunc("POST /api/games/hangman/guess", s.handleHangmanGuess)
mux.HandleFunc("POST /api/games/solitaire/start", s.handleSolitaireStart)
mux.HandleFunc("POST /api/games/solitaire/move", s.handleSolitaireMove)
mux.HandleFunc("POST /api/games/trivia/start", s.handleTriviaStart)
mux.HandleFunc("POST /api/games/trivia/answer", s.handleTriviaAnswer)
mux.HandleFunc("POST /api/games/uno/start", s.handleUnoStart)
mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove)
mux.HandleFunc("POST /api/games/holdem/sit", s.handleHoldemSit)
mux.HandleFunc("POST /api/games/holdem/move", s.handleHoldemMove)
mux.HandleFunc("POST /api/games/holdem/leave", s.handleHoldemLeave)
mux.HandleFunc("GET /api/games/holdem/tables", s.handleHoldemLobby)
mux.HandleFunc("GET /api/games/holdem/stream", s.handleHoldemStream)
mux.HandleFunc("GET /api/games/holdem/chat", s.handleHoldemChat)
mux.HandleFunc("POST /api/games/holdem/say", s.handleHoldemSay)
}
// requirePlayer sends an anonymous visitor to sign in and comes back here after.
// Anyone who is signed in but carries a session from before the casino existed
// has no username in it, so they get sent through sign-in too — which mints one.
func (s *Server) requirePlayer(w http.ResponseWriter, r *http.Request) bool {
if !s.gamesReady() {
http.NotFound(w, r)
return false
}
u := s.auth.userFromRequest(r)
if u != nil && u.MatrixUser(s.cfg.Games.MatrixServer) != "" {
return true
}
http.Redirect(w, r, "/auth/login?next="+r.URL.Path, http.StatusFound)
return false
}
// casinoURL turns a route on the mux ("/games/uno") into the absolute address a
// player would actually type, which is what an unfurl bot has to be handed.
//
// The two halves of it are the same fact seen twice. On the games host the casino
// sits at the root and hostRouter puts the /games prefix back on the way in, so
// the public address of a page is its route with that prefix taken off. Without a
// games host — development, and only development — there is no rewrite and no
// prefix to remove, and the route *is* the address. Getting this backwards points
// og:image at a URL that 404s, which is exactly as visible as no og:image at all.
func (s *Server) casinoURL(r *http.Request, route string) string {
if h := strings.TrimSpace(s.cfg.Games.Host); h != "" {
path := strings.TrimPrefix(route, "/games")
if path == "" {
path = "/"
}
return "https://" + h + path
}
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
return scheme + "://" + r.Host + route
}
func (s *Server) gamesPage(r *http.Request) gamesPage {
return gamesPage{
Room: roomAt(time.Now().Hour()),
URL: s.casinoURL(r, r.URL.Path),
OGImage: s.casinoURL(r, "/games/og.png"),
// Nil on the front door, and only there — every other page ran requirePlayer
// first. A template that reads .User has to guard it.
User: s.auth.userFromRequest(r),
Cap: storage.MaxChipsOnTable,
RakePct: int(blackjack.DefaultRules().RakePct * 100),
Soon: comingSoon,
Denominations: betDenominations,
Tiers: hangman.Tiers,
MaxWrong: hangman.MaxWrong,
Deals: klondike.Tiers,
FullDeck: klondike.FullDeck,
Quizzes: trivia.Tiers,
Rungs: trivia.Rungs,
Tables: uno.Tiers,
NoMercy: uno.NoMercyTiers,
MercyLimit: uno.MercyLimit,
Stakes: holdem.Tiers,
MaxBots: holdem.MaxBots,
}
}
// handleLobby is the one page in the casino that answers a stranger.
//
// Every table bounces an anonymous visitor straight to sign-in, which is right:
// there is money on them. But the front door cannot do that, because the front
// door is what people paste into a chat window, and a 302 to an auth screen has
// nothing in it to make a preview out of — the casino unfurled as the bare word
// "parodia.dev" for as long as it has existed. So the door is a real page, served
// to anybody, carrying the share card and a way in. You still can't play from it.
func (s *Server) handleLobby(w http.ResponseWriter, r *http.Request) {
if !s.gamesReady() {
http.NotFound(w, r)
return
}
if u := s.auth.userFromRequest(r); u == nil || u.MatrixUser(s.cfg.Games.MatrixServer) == "" {
s.render(w, "games_door", s.gamesPage(r))
return
}
s.render(w, "games", s.gamesPage(r))
}
func (s *Server) handleBlackjack(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "blackjack", s.gamesPage(r))
}
func (s *Server) handleHangman(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "hangman", s.gamesPage(r))
}
func (s *Server) handleSolitaire(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "solitaire", s.gamesPage(r))
}
func (s *Server) handleTrivia(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "trivia", s.gamesPage(r))
}
func (s *Server) handleUno(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "uno", s.gamesPage(r))
}
func (s *Server) handleHoldem(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, r) {
return
}
s.render(w, "holdem", s.gamesPage(r))
}