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:
prosolis
2026-07-13 23:20:42 -07:00
parent cb84e1d549
commit c69fbb63db
17 changed files with 1949 additions and 18 deletions

View File

@@ -0,0 +1,84 @@
package web
import (
"net/http"
"pete/internal/games/blackjack"
"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 Pete's bots know how to play."},
{Name: "UNO", Emoji: "🎴", Blurb: "Normal rules, or no mercy."},
{Name: "Trivia", Emoji: "🧠", Blurb: "Faster answers score higher."},
{Name: "Hangman", Emoji: "🪢", Blurb: "Guess the phrase before Pete finishes drawing."},
}
// betDenominations are the chips you build a bet out of.
var betDenominations = []int64{5, 25, 100, 500}
type gamesPage struct {
pageData
Cap int64
RakePct int
Soon []gameTeaser
Denominations []int64
}
// 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 {
base := s.base(r)
base.NoIndex = true // the casino is for players, not for search engines
return gamesPage{
pageData: base,
Cap: storage.MaxChipsOnTable,
RakePct: int(blackjack.DefaultRules().RakePct * 100),
Soon: comingSoon,
Denominations: betDenominations,
}
}
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))
}