games: a blackjack table you can actually sit down at
The engine, the escrow and the wire were all in place; nothing had a browser on the end of it. This is that end: a lobby, a table, and the five endpoints between them. The browser holds no game. It sends intents and gets back a view — the cards it is entitled to see, and the script of how they arrived, one event per card off the shoe. The dealer's hole card is not in the payload at all until the reveal, because a field the client is told to ignore is a field somebody reads in devtools. The shoe lives in game_live_hands, which also means a redeploy mid-hand no longer costs a player their stake: the hand is still there when they come back. The money is ordered so nothing can be spent twice. The stake leaves the stack in the same statement that checks it exists, before a card is dealt. Every new hand is seated with a plain INSERT, so a double-clicked Deal is decided by the primary key rather than by a read that raced — it loses, gets its chips back, and the hand in progress is untouched. A double takes its raise up front and hands it straight back if the engine refuses the move. Cards are dealt rather than swapped in — they fly out of the shoe and turn over, which was a requirement and not a flourish. The faces and the chips are still plain; that's next.
This commit is contained in:
@@ -9,6 +9,7 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -81,12 +82,13 @@ type Server struct {
|
||||
// gets its own template set sharing layout.html + _card.html, which avoids
|
||||
// `main` define collisions between pages.
|
||||
func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled bool, adv config.AdventureConfig, advPost PriorityPoster) (*Server, error) {
|
||||
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story"}
|
||||
pages := []string{"index", "channel", "weather", "bookmarks", "for-you", "status", "story", "games", "blackjack"}
|
||||
tpls := make(map[string]*template.Template, len(pages))
|
||||
for _, p := range pages {
|
||||
t, err := template.New("").Funcs(funcs).ParseFS(templateFS,
|
||||
"templates/layout.html",
|
||||
"templates/_card.html",
|
||||
"templates/_chipbar.html",
|
||||
"templates/"+p+".html",
|
||||
)
|
||||
if err != nil {
|
||||
@@ -210,6 +212,19 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
|
||||
mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)
|
||||
|
||||
// The casino. Signed-in only — there is money in it — so these hang off the
|
||||
// auth block, and gamesReady() also insists on a Matrix server name: without
|
||||
// one, no player can be named to gogobee's ledger and the tables stay shut.
|
||||
if s.gamesReady() {
|
||||
mux.HandleFunc("GET /games", s.handleLobby)
|
||||
mux.HandleFunc("GET /games/blackjack", s.handleBlackjack)
|
||||
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)
|
||||
}
|
||||
|
||||
if s.auth != nil {
|
||||
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
|
||||
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)
|
||||
@@ -232,12 +247,56 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
|
||||
|
||||
s.srv = &http.Server{
|
||||
Addr: cfg.ListenAddr,
|
||||
Handler: mux,
|
||||
Handler: s.hostRouter(mux),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// hostRouter puts the casino at the root of its own hostname without giving it
|
||||
// its own mux. games.parodia.dev and news.parodia.dev are the same process on
|
||||
// the same port — Caddy sends both here — so a request that arrives on the games
|
||||
// host has /games prefixed onto its path, and "/" lands on the lobby.
|
||||
//
|
||||
// Shared plumbing (the API, sign-in, static files, health) is left alone: it is
|
||||
// the same plumbing whichever door you came in by, and the login round-trip in
|
||||
// particular has to keep working on both hosts.
|
||||
func (s *Server) hostRouter(mux http.Handler) http.Handler {
|
||||
host := strings.ToLower(strings.TrimSpace(s.cfg.Games.Host))
|
||||
if host == "" || !s.gamesReady() {
|
||||
return mux
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.EqualFold(hostOnly(r.Host), host) || isSharedPath(r.URL.Path) {
|
||||
mux.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Rewrite a copy: the request's URL is shared with anything that logged it.
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = "/games" + strings.TrimSuffix(r.URL.Path, "/")
|
||||
mux.ServeHTTP(w, r2)
|
||||
})
|
||||
}
|
||||
|
||||
// isSharedPath marks the paths that mean the same thing on every host and must
|
||||
// not be shuffled under /games.
|
||||
func isSharedPath(p string) bool {
|
||||
for _, prefix := range []string{"/api/", "/auth/", "/static/", "/img/", "/games"} {
|
||||
if strings.HasPrefix(p, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return p == "/healthz" || p == "/sw.js" || p == "/manifest.webmanifest"
|
||||
}
|
||||
|
||||
// hostOnly strips the port from a Host header.
|
||||
func hostOnly(host string) string {
|
||||
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||
return host[:i]
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// Start runs the HTTP server and blocks until ctx is canceled.
|
||||
func (s *Server) Start(ctx context.Context) {
|
||||
go func() {
|
||||
|
||||
Reference in New Issue
Block a user