Files
Pete/internal/web/games_pages.go
prosolis 927ed84163 games: UNO becomes a table you sit at, and the pot that pays whoever goes out first
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
2026-07-14 18:37:51 -07:00

271 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/sit", s.handleUnoSit)
mux.HandleFunc("POST /api/games/uno/move", s.handleUnoMove)
mux.HandleFunc("POST /api/games/uno/leave", s.handleUnoLeave)
mux.HandleFunc("GET /api/games/uno/tables", s.handleUnoLobby)
mux.HandleFunc("GET /api/games/uno/stream", s.handleTableStream)
mux.HandleFunc("GET /api/games/uno/chat", s.handleTableChat)
mux.HandleFunc("POST /api/games/uno/say", s.handleTableSay)
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.handleTableStream)
mux.HandleFunc("GET /api/games/holdem/chat", s.handleTableChat)
mux.HandleFunc("POST /api/games/holdem/say", s.handleTableSay)
}
// 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))
}