UNO, played for chips. You stake once, sit down against one to three bots, and going out first pays the table: 2.2x heads up, 3.6x against a full house. Anybody else going out first takes the stake. The table size is the tier, because it is the only dial UNO has. The bots move inside ApplyMove. A game with opponents is normally where you reach for a socket, and the plan says solo UNO must not — so one request plays your move and every bot turn behind it, and hands back the whole lap as a script the felt plays in order. The RNG is in the state rather than an argument to it: the bots choose and a spent deck reshuffles, so the engine needs randomness mid-game, and there is no generator alive across requests to pass in. The seed rides in the state and each step derives its own. The game still replays exactly as it fell. The zero value of Color is Wild, and that is the whole point of it: a wild played with the colour field missing from the JSON must be refused, not quietly played as a red one. It was red for an hour. The browser never sees a bot's card — not the deck, not a hand, not the face of a card a bot drew, which is most of the deck. Seats cross the wire as a name and a count. The multiples are measured, not guessed: playing the first legal card you hold wins 43/32/27% of the time against these bots, so the tiers price that to lose about 8% a game and leave good play worth roughly the house's edge. PeteFX.flyNode is the throw with the chip taken out of it, so a card can be thrown across the felt the same way. fly() is now that with a chip in it. Not yet driven in a browser, which in this room means not yet finished. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
192 lines
6.5 KiB
Go
192 lines
6.5 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"time"
|
|
|
|
"pete/internal/games/blackjack"
|
|
"pete/internal/games/hangman"
|
|
"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
|
|
}
|
|
|
|
var comingSoon = []gameTeaser{
|
|
{Name: "Hold'em", Emoji: "♠️", Blurb: "Six seats, and the house bots know how to play."},
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 /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)
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
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))
|
|
}
|