From a5b7e419298197934411fbc6ad90ff7900e659a1 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:22:14 -0700 Subject: [PATCH] games: the card the dealer never turned over, and the bet that came back doubled Bust every hand and the dealer doesn't draw, which is right, but it was also not turning over: reveal is only emitted by dealerPlay, and busting out skips the dealer entirely. The browser kept the hole card face down while the settled state printed the dealer's whole total under it. Emit the reveal on that path. And standing your bet back up after a reload read the hand's bet straight off the settled state, which a double has already doubled. Reload, double 200, and the next hand starts with 400 on the spot. Plus: the share card was hand-writing a Content-Length that ServeContent overwrites anyway, and serving a zero-byte 200 for a room with no card. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ --- internal/games/blackjack/blackjack.go | 9 +++++++-- internal/games/blackjack/split_test.go | 28 ++++++++++++++++++++++++++ internal/web/games_og.go | 20 +++++++++++------- internal/web/games_pages.go | 10 +++++---- internal/web/static/js/blackjack.js | 12 ++++++++++- 5 files changed, 65 insertions(+), 14 deletions(-) diff --git a/internal/games/blackjack/blackjack.go b/internal/games/blackjack/blackjack.go index d1a44a3..7ea0e1f 100644 --- a/internal/games/blackjack/blackjack.go +++ b/internal/games/blackjack/blackjack.go @@ -147,7 +147,7 @@ type State struct { // dealer's, and the reason it is here at all is that after a split the browser // has to know which fan a card is flying to. type Event struct { - Kind string `json:"kind"` // "deal" | "player_card" | "dealer_card" | "split" | "reveal" | "settle" + Kind string `json:"kind"` // "deal" | "player_card" | "dealer_card" | "split" | "double" | "reveal" | "settle" Card *cards.Card `json:"card,omitempty"` Hand int `json:"hand"` Text string `json:"text,omitempty"` @@ -363,7 +363,7 @@ func (s *State) finishIfDone(i int) { // advance moves to the next hand still owed a decision. When there are none, the // dealer plays — unless every hand busted, in which case there is nothing to beat -// and the dealer does not bother turning over. +// and the dealer does not draw. func (s *State) advance(evs *[]Event) { for i := s.Active; i < len(s.Hands); i++ { if !s.Hands[i].Done { @@ -374,6 +374,11 @@ func (s *State) advance(evs *[]Event) { s.Active = len(s.Hands) - 1 if s.allBust() { + // The dealer draws no cards, but the hole card still turns over: the + // browser has been showing a face-down card since the deal, and the settled + // state it is about to be handed has the dealer's full total on it. Without + // the reveal you get a nineteen printed under a card nobody has looked at. + *evs = append(*evs, Event{Kind: "reveal"}) s.settle(evs) return } diff --git a/internal/games/blackjack/split_test.go b/internal/games/blackjack/split_test.go index d0fbb84..322717b 100644 --- a/internal/games/blackjack/split_test.go +++ b/internal/games/blackjack/split_test.go @@ -308,6 +308,34 @@ func TestTheDealerDoesNotPlayWhenEveryHandIsBust(t *testing.T) { } } +// The dealer not drawing is not the same as the dealer not turning over. The +// browser has been showing a face-down card since the deal, and the settled state +// it gets handed has the dealer's whole total on it — so a bust-out still owes it +// a reveal, or the felt prints a nineteen under a card nobody has looked at. +func TestBustingOutStillTurnsTheHoleCardOver(t *testing.T) { + s := dealt(100, + hand(cards.King, 6), + hand(cards.King, 9), + hand(cards.King), // 16 + K = 26 + ) + s, evs, err := ApplyMove(s, Hit) + if err != nil { + t.Fatal(err) + } + if s.Phase != PhaseDone || s.Outcome != OutcomeBust { + t.Fatalf("phase/outcome = %q/%q, want done/bust", s.Phase, s.Outcome) + } + var reveal bool + for _, e := range evs { + if e.Kind == "reveal" { + reveal = true + } + } + if !reveal { + t.Error("the player busted out and the hole card was never revealed") + } +} + // Doubling after a split doubles *that hand's* bet, not the whole table's. func TestDoublingAfterASplitOnlyDoublesThatHand(t *testing.T) { s := dealt(100, diff --git a/internal/web/games_og.go b/internal/web/games_og.go index eaabf3b..f48fdb8 100644 --- a/internal/web/games_og.go +++ b/internal/web/games_og.go @@ -8,6 +8,7 @@ import ( "image/color" "image/draw" "image/png" + "log/slog" "math" "net/http" "sync" @@ -85,20 +86,25 @@ func (s *Server) handleGamesOG(w http.ResponseWriter, r *http.Request) { // No requirePlayer here, and that is the whole point: the thing fetching this // is a chat server's link preview, which has never signed in and never will. ogOnce.Do(func() { ogCards, ogErr = drawShareCards() }) - if ogErr != nil { + room := roomAt(time.Now().Hour()) + card := ogCards[room.Slug] + if ogErr != nil || len(card) == 0 { + // A room with no card is a room somebody added to roomAt and not to + // ogPalettes. Say so rather than serving a zero-byte PNG, which an unfurler + // will happily cache as the casino's face. + slog.Error("games: no share card", "room", room.Slug, "err", ogErr) http.Error(w, "share card unavailable", http.StatusInternalServerError) return } - room := roomAt(time.Now().Hour()) - png := ogCards[room.Slug] w.Header().Set("Content-Type", "image/png") // Long enough that a busy channel isn't redrawing it, short enough that the // room actually turns over: the lights come on at six and the card follows // within the hour. w.Header().Set("Cache-Control", "public, max-age=900") - w.Header().Set("Content-Length", fmt.Sprint(len(png))) - http.ServeContent(w, r, "og.png", time.Time{}, bytes.NewReader(png)) + // ServeContent sets Content-Length itself, and gets it right for a range + // request, which a hand-written one would not. + http.ServeContent(w, r, "og.png", time.Time{}, bytes.NewReader(card)) } // drawShareCards renders every room's card up front. If the font is broken every @@ -128,16 +134,16 @@ func drawShareCard(f *opentype.Font, rm room, p ogPalette) (image.Image, error) if err != nil { return nil, err } + defer title.Close() line, err := face(f, 33) if err != nil { return nil, err } + defer line.Close() small, err := face(f, 25) if err != nil { return nil, err } - defer title.Close() - defer line.Close() defer small.Close() img := image.NewRGBA(image.Rect(0, 0, ogWidth, ogHeight)) diff --git a/internal/web/games_pages.go b/internal/web/games_pages.go index a0149fd..04744b9 100644 --- a/internal/web/games_pages.go +++ b/internal/web/games_pages.go @@ -173,10 +173,12 @@ func (s *Server) casinoURL(r *http.Request, route string) string { 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"), - User: s.auth.userFromRequest(r), // requirePlayer ran first, so this is non-nil + 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, diff --git a/internal/web/static/js/blackjack.js b/internal/web/static/js/blackjack.js index 9c5e5bf..b74ed27 100644 --- a/internal/web/static/js/blackjack.js +++ b/internal/web/static/js/blackjack.js @@ -436,7 +436,7 @@ // has to refuse. return settleChips(final) .then(money) - .then(function () { return standing(base || (final.hands[0] && final.hands[0].bet)); }) + .then(function () { return standing(base || unitBet(final.hands[0])); }) .then(function () { setPhase(final); }); }); } @@ -450,6 +450,16 @@ // It's one hand's worth, not the whole deal's: a split cost you four hundred, // and standing four hundred back up on one spot would be betting a stranger's // money on your behalf. + // unitBet is what one hand of a deal actually cost, read back off a settled + // hand. It exists for the reload: `base` only knows what you pressed Deal with, + // and if you reloaded mid-hand nobody pressed Deal. A doubled hand carries twice + // the stake it was dealt with, so standing its bet back up would quietly put you + // on double the number you thought you were playing. + function unitBet(h) { + if (!h) return 0; + return h.doubled ? h.bet / 2 : h.bet; + } + function standing(amount) { var money = window.PeteGames.view(); if (!amount || !money || money.chips < amount) {