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) {