Files
Pete/internal/web/games_pages.go
prosolis e6c1bd3b54 games: the poker table opens, and the bots go back to school
Phase 4. Hold'em, and it's the only table in the casino that is a session
rather than a game: you buy in, play as many hands as you like, and leave with
what's in front of you. So the live row spans hands and chips cross the border
exactly twice. Everything in between is inside the engine.

The bots move inside ApplyMove, as UNO's do, which is what keeps poker off a
socket: shove all-in and the flop, turn, river, showdown and payout all come
back in one response, as a script the felt plays back.

The CFR policy the plan called "the single highest-value asset in either repo"
was never read. Not once, in the whole life of the game: the trainer wrote its
info-set keys under IP/OOP and the runtime looked them up under BTN/SB/BB, so
every lookup missed and fell silently through to a pot-odds heuristic. Nothing
looked broken, because a policy miss is not an error. And it was the wrong
policy anyway — ten big blinds deep, trained on a tree where a call always ends
the street, which is not poker. So the trainer is rewritten to play the real
engine through the real reducer, at every stack depth the table deals, and the
trainer and the table now build the key with the same function so they cannot
drift apart again. A test fails if the bots stop finding themselves in it.

Three money bugs, and the tests earned their keep. Chip conservation across a
hundred sessions caught an uncalled bet that minted chips. A var-init ordering
trap meant every card was identical, every showdown tied and every bot believed
it held exactly 50% equity. And the browser caught the rake being silently
zero — the tier said 5 meaning percent, the casino handed it 0.05 meaning a
fraction, and integer division took the house's cut down to nothing.

Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
2026-07-14 09:08:59 -07:00

209 lines
7.2 KiB
Go

package web
import (
"net/http"
"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
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
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/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)
}
// 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
}
func (s *Server) gamesPage(r *http.Request) gamesPage {
return gamesPage{
Room: roomAt(time.Now().Hour()),
User: s.auth.userFromRequest(r), // requirePlayer ran first, so this is non-nil
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,
Stakes: holdem.Tiers,
MaxBots: holdem.MaxBots,
}
}
func (s *Server) handleLobby(w http.ResponseWriter, r *http.Request) {
if !s.requirePlayer(w, 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))
}