// Package blackjack is a pure blackjack engine. // // It knows nothing about HTTP, sockets, timers, euros or players' names. You // hand it a state and a move, it hands you back a new state and the list of // things that just happened. Everything else — who is sitting there, what their // chips are, when their clock runs out — belongs to the shell in internal/games/table. // // That seam is the one thing gogobee's blackjack never had: there, the engine // *was* the message sender, so an "error" meant a Matrix send had failed rather // than that a player had tried something illegal. Here an error means exactly // one thing: the move was not legal in this state. // // The state is a plain value. It serializes, so a hand survives a redeploy, and // it replays, so a disputed hand can be dealt again from its seed. package blackjack import ( "encoding/json" "errors" "math" "math/rand/v2" "pete/internal/games/cards" ) // Errors an illegal move can produce. Callers can match on these to tell a // player "not now" rather than "something broke". var ( ErrHandOver = errors.New("blackjack: the hand is already over") ErrNotYourTurn = errors.New("blackjack: it is not the player's turn to act") ErrUnknownMove = errors.New("blackjack: unknown move") ErrCantDouble = errors.New("blackjack: double is only allowed on the opening two cards") ErrCantSplit = errors.New("blackjack: split is only allowed on two cards of the same rank") ErrDeckExhausted = errors.New("blackjack: the shoe is empty") ErrBadBet = errors.New("blackjack: bet must be positive") ) // MaxHands is how many hands one deal can turn into: the opening hand, plus // three splits. Four is the usual house limit and it is also the point past // which the felt runs out of room. const MaxHands = 4 // Phase is whose turn it is. type Phase string const ( PhasePlayer Phase = "player" // the player is acting on the active hand PhaseDealer Phase = "dealer" // transient: the dealer is drawing out PhaseDone Phase = "done" // settled, Outcome and Payout are final ) // Outcome is how a finished hand finished, from the player's point of view. type Outcome string const ( OutcomeNone Outcome = "" OutcomeBlackjack Outcome = "blackjack" // natural 21, paid 3:2 OutcomeWin Outcome = "win" OutcomeLose Outcome = "lose" OutcomePush Outcome = "push" // tie, stake returned OutcomeBust Outcome = "bust" // player went over 21 OutcomeDealerBust Outcome = "dealer_bust" ) // Won reports whether this outcome pays the player more than their stake back. func (o Outcome) Won() bool { return o == OutcomeWin || o == OutcomeBlackjack || o == OutcomeDealerBust } // Rules are the table's terms. They're part of the state rather than a global, // so a hand always settles under the rules it was dealt under — even if the // house changes them mid-session. type Rules struct { Decks int `json:"decks"` // shoe size BlackjackPays float64 `json:"blackjack_pays"` // 1.5 = the honest 3:2 DealerHitsSoft17 bool `json:"dealer_hits_soft17"` // gogobee's dealer does RakePct float64 `json:"rake_pct"` // house cut, taken from winnings only } // DefaultRules match the blackjack gogobee has been dealing in Matrix for years: // six decks, 3:2 on a natural, dealer hits soft 17. The rake is the one new term // — see settle for exactly what it touches. func DefaultRules() Rules { return Rules{Decks: 6, BlackjackPays: 1.5, DealerHitsSoft17: true, RakePct: 0.05} } // Hand is one hand the player is holding, with the chips that are on it. // // A deal starts with one. A split turns one into two, and the new hand carries a // bet of its own — which is the whole reason split is not just a card trick: it // is the only move in the game that takes more chips out of a player's stack // *after* the cards are out. type Hand struct { Cards []cards.Card `json:"cards"` Bet int64 `json:"bet"` Doubled bool `json:"doubled"` // Split marks a hand that came out of a split. It exists for one rule: 21 on // a split hand is twenty-one, not a natural, and is paid 1:1 like any other. // Otherwise splitting aces would print money. Split bool `json:"split"` // Done means this hand will not be acted on again: it stood, it busted, it // doubled, or it is a split ace, which gets exactly one card and no say. Done bool `json:"done"` Outcome Outcome `json:"outcome"` Payout int64 `json:"payout"` // stake plus winnings, net of rake. Zero on a loss. Rake int64 `json:"rake"` } // Value totals the hand. See HandValue. func (h Hand) Value() (int, bool) { return HandValue(h.Cards) } // Natural reports a blackjack: 21 on the opening two cards of a hand that was // dealt, not split. func (h Hand) Natural() bool { return !h.Split && IsBlackjack(h.Cards) } // State is one deal of heads-up blackjack: the player's hands against the // dealer's one. type State struct { Rules Rules `json:"rules"` Deck cards.Deck `json:"deck"` // the shoe, top card first — never shown to the browser Dealer []cards.Card `json:"dealer"` // Hands is always at least one, and Active indexes the one being played. The // player works left to right: a hand is finished before the next is looked at, // which is both how a real table does it and what keeps the felt legible. Hands []Hand `json:"hands"` Active int `json:"active"` Phase Phase `json:"phase"` Outcome Outcome `json:"outcome"` // the deal as a whole; per-hand outcomes live on the hands // Bet, Payout and Rake are the totals across every hand: what the player has // staked, what comes back, and what the house kept. The ledger and the chip // stack only ever deal in these. Bet int64 `json:"bet"` Payout int64 `json:"payout"` Rake int64 `json:"rake"` } // Event is something the table can narrate or animate. The engine emits them // instead of drawing anything itself. // // Hand is which of the player's hands an event landed on — meaningless for the // 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" Card *cards.Card `json:"card,omitempty"` Hand int `json:"hand"` Text string `json:"text,omitempty"` } // Move is a player action. type Move string const ( Hit Move = "hit" Stand Move = "stand" Double Move = "double" Split Move = "split" ) // HandValue totals a hand, counting each ace as 11 until that would bust, then // demoting them one at a time. soft reports whether an ace is still counting as // 11 — which is what makes "soft 17" a different thing from 17. func HandValue(hand []cards.Card) (total int, soft bool) { aces := 0 for _, c := range hand { switch { case c.Rank == cards.Ace: aces++ total += 11 case c.Rank >= 10: total += 10 default: total += int(c.Rank) } } for total > 21 && aces > 0 { total -= 10 // demote an ace from 11 to 1 aces-- } return total, aces > 0 } // IsBlackjack reports a natural: 21 on the opening two cards. A 21 assembled // from three cards is not one, and does not get paid 3:2. func IsBlackjack(hand []cards.Card) bool { if len(hand) != 2 { return false } v, _ := HandValue(hand) return v == 21 } // New deals a fresh hand: two to the player, two to the dealer. If either side // has a natural the hand is already over and the returned State is settled — a // player with blackjack never gets asked whether they'd like to hit. func New(bet int64, r Rules, rng *rand.Rand) (State, []Event, error) { if bet <= 0 { return State{}, nil, ErrBadBet } if r.Decks < 1 { r.Decks = 1 } deck := cards.NewDeck(r.Decks) deck.Shuffle(rng) s := State{ Rules: r, Deck: deck, Hands: []Hand{{Bet: bet}}, Bet: bet, Phase: PhasePlayer, } evs := []Event{{Kind: "deal"}} for i := 0; i < 2; i++ { if err := s.hit(0, &evs); err != nil { return State{}, nil, err } if err := s.drawDealer(&evs); err != nil { return State{}, nil, err } } // A natural on either side ends it before the player ever acts. if s.Hands[0].Natural() || IsBlackjack(s.Dealer) { s.settle(&evs) } return s, evs, nil } // hit puts one card on a player hand. Pointer receiver: it mutates the deck and // the hand together, and neither may end up applied to a stale copy of the state. func (s *State) hit(i int, evs *[]Event) error { c, ok := s.Deck.Draw() if !ok { return ErrDeckExhausted } s.Hands[i].Cards = append(s.Hands[i].Cards, c) card := c *evs = append(*evs, Event{Kind: "player_card", Card: &card, Hand: i}) return nil } func (s *State) drawDealer(evs *[]Event) error { c, ok := s.Deck.Draw() if !ok { return ErrDeckExhausted } s.Dealer = append(s.Dealer, c) card := c *evs = append(*evs, Event{Kind: "dealer_card", Card: &card}) return nil } // ApplyMove is the whole engine: a legal move in, a new state and the events it // produced out. An error means the move was illegal and the state is unchanged. // // s is taken by value, so the caller's state is only replaced on success. func ApplyMove(s State, m Move) (State, []Event, error) { if s.Phase == PhaseDone { return s, nil, ErrHandOver } // A copied State still shares its slices' backing arrays with the original. // Two moves applied from the same starting state would then append cards over // each other. Clone first: the caller's state is genuinely untouched, and a // state can be replayed as many times as we like. s = s.clone() if s.Phase != PhasePlayer { return s, nil, ErrNotYourTurn } switch m { case Hit, Stand, Double, Split: default: return s, nil, ErrUnknownMove } if m == Double && !s.CanDouble() { // Doubling means doubling the stake for exactly one more card. Only ever // legal on the opening two — after that you're just describing a hit. return s, nil, ErrCantDouble } if m == Split && !s.CanSplit() { return s, nil, ErrCantSplit } i := s.Active evs := []Event{} switch m { case Split: // The second card moves to a hand of its own, carrying a bet the same size // as the one it came from, and both hands are topped up to two cards. h := &s.Hands[i] moved := h.Cards[1] h.Cards = h.Cards[:1] h.Split = true fresh := Hand{Cards: []cards.Card{moved}, Bet: h.Bet, Split: true} s.Hands = append(s.Hands, Hand{}) copy(s.Hands[i+2:], s.Hands[i+1:]) // the new hand sits immediately to the right s.Hands[i+1] = fresh s.Bet += fresh.Bet evs = append(evs, Event{Kind: "split", Hand: i}) if err := s.hit(i, &evs); err != nil { return s, nil, err } if err := s.hit(i+1, &evs); err != nil { return s, nil, err } // Split aces get one card each and no say in it. Without this rule a pair // of aces is the best hand in the game and everybody would split them // forever; with it, splitting aces is a gamble like everything else. if moved.Rank == cards.Ace { s.Hands[i].Done = true s.Hands[i+1].Done = true } // A hand that has just been dealt a card can still be sitting on 21, and a // 21 has nothing left to decide. s.finishIfDone(i) s.finishIfDone(i + 1) case Double: h := &s.Hands[i] s.Bet += h.Bet h.Bet *= 2 h.Doubled = true // Announced before the card, because that is the order it happens in: the // chips go down, and *then* you find out what you bought with them. evs = append(evs, Event{Kind: "double", Hand: i}) if err := s.hit(i, &evs); err != nil { return s, nil, err } h.Done = true // one card, and that is the deal you made case Hit: if err := s.hit(i, &evs); err != nil { return s, nil, err } s.finishIfDone(i) case Stand: s.Hands[i].Done = true } s.advance(&evs) return s, evs, nil } // finishIfDone closes a hand that has nothing left to decide: it busted, or it // is sitting on 21 and would only be hitting it to be polite. func (s *State) finishIfDone(i int) { if v, _ := s.Hands[i].Value(); v >= 21 { s.Hands[i].Done = true } } // 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. func (s *State) advance(evs *[]Event) { for i := s.Active; i < len(s.Hands); i++ { if !s.Hands[i].Done { s.Active = i return } } s.Active = len(s.Hands) - 1 if s.allBust() { s.settle(evs) return } s.Phase = PhaseDealer s.dealerPlay(evs) } func (s *State) allBust() bool { for _, h := range s.Hands { if v, _ := h.Value(); v <= 21 { return false } } return true } // dealerPlay draws the dealer out to the house rule, then settles. The dealer // has no choices to make — that's the game — so this needs no move. func (s *State) dealerPlay(evs *[]Event) { *evs = append(*evs, Event{Kind: "reveal"}) // the hole card turns over for { v, soft := HandValue(s.Dealer) hitSoft17 := s.Rules.DealerHitsSoft17 && v == 17 && soft if v >= 17 && !hitSoft17 { break } if err := s.drawDealer(evs); err != nil { break // shoe ran dry mid-draw; settle on what's on the table } } s.settle(evs) } // settle decides every hand against the dealer and adds up what comes back. It // is the only place chips are computed. // // The rake comes off winnings, never off the stake: a player who pushes gets // exactly their bet back, and a player who loses is never charged for the // privilege. The house only takes a cut of money the house was going to hand // over anyway. That's a rake, as opposed to a fee for showing up. // // Each hand is raked on its own winnings. Netting the hands against each other // first would let a player who won one and lost one pay no rake at all, which is // not a rake, it's a discount for splitting. func (s *State) settle(evs *[]Event) { dealerVal, _ := HandValue(s.Dealer) dealerBJ := IsBlackjack(s.Dealer) s.Payout, s.Rake = 0, 0 for i := range s.Hands { h := &s.Hands[i] playerVal, _ := h.Value() // profit is what this hand wins on top of its stake. Negative means the // stake is gone. var profit int64 switch { case playerVal > 21: h.Outcome = OutcomeBust profit = -h.Bet case h.Natural() && dealerBJ: h.Outcome = OutcomePush case h.Natural(): h.Outcome = OutcomeBlackjack profit = int64(math.Floor(float64(h.Bet) * s.Rules.BlackjackPays)) case dealerBJ: h.Outcome = OutcomeLose profit = -h.Bet case dealerVal > 21: h.Outcome = OutcomeDealerBust profit = h.Bet case playerVal > dealerVal: h.Outcome = OutcomeWin profit = h.Bet case playerVal == dealerVal: h.Outcome = OutcomePush default: h.Outcome = OutcomeLose profit = -h.Bet } if profit > 0 { h.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct)) if h.Rake < 0 { h.Rake = 0 } profit -= h.Rake } if profit < 0 { h.Payout = 0 // stake is lost; nothing comes back } else { h.Payout = h.Bet + profit } s.Payout += h.Payout s.Rake += h.Rake } s.Outcome = s.overall() s.Phase = PhaseDone *evs = append(*evs, Event{Kind: "settle", Text: string(s.Outcome)}) } // overall is the deal's outcome as one word, which is what the ledger and the // history line want. With one hand it is simply that hand's. With several there // is no honest single word for "won one, lost one", so it reports what the deal // did to the player's chips, which is the thing anybody actually means. func (s State) overall() Outcome { if len(s.Hands) == 1 { return s.Hands[0].Outcome } switch net := s.Payout - s.Bet; { case net > 0: return OutcomeWin case net < 0: return OutcomeLose default: return OutcomePush } } // Net is what the deal did to the player's chip stack: everything paid out minus // everything staked. Negative on a loss, zero on a push. func (s State) Net() int64 { if s.Phase != PhaseDone { return 0 } return s.Payout - s.Bet } // Hand returns the hand being played. There is always one. func (s State) Hand() Hand { if s.Active < 0 || s.Active >= len(s.Hands) { return Hand{} } return s.Hands[s.Active] } // CanDouble reports whether Double is legal right now — the shell asks this to // decide whether to light the button up. func (s State) CanDouble() bool { h := s.Hand() return s.Phase == PhasePlayer && !h.Done && len(h.Cards) == 2 } // CanSplit reports whether Split is legal: two cards of the same rank, and room // at the table for another hand. // // Same *rank*, not same value: a king and a queen are both worth ten and are not // a pair, which is the stricter of the two house rules and the one that doesn't // need explaining on the felt. func (s State) CanSplit() bool { h := s.Hand() if s.Phase != PhasePlayer || h.Done || len(h.Cards) != 2 || len(s.Hands) >= MaxHands { return false } return h.Cards[0].Rank == h.Cards[1].Rank } // SplitCost is what splitting the active hand would take out of the player's // stack: another bet the same size as the one already on it. The shell has to // take these chips before the move, because a split the player cannot cover is // not a legal move. func (s State) SplitCost() int64 { return s.Hand().Bet } // DoubleCost is the same idea for a double: the stake again. func (s State) DoubleCost() int64 { return s.Hand().Bet } // clone deep-copies the slices so a derived state shares no backing array with // the one it came from. func (s State) clone() State { s.Deck = append(cards.Deck(nil), s.Deck...) s.Dealer = append([]cards.Card(nil), s.Dealer...) hands := make([]Hand, len(s.Hands)) copy(hands, s.Hands) for i := range hands { hands[i].Cards = append([]cards.Card(nil), hands[i].Cards...) } s.Hands = hands return s } // UnmarshalJSON reads a state, including one written before split existed. // // A live hand outlives a deploy: it is a blob in the database, and somebody is // mid-hand when the new binary starts. Those blobs have a "player" array and no // "hands", and without this they would come back as a state with no hands at all // — which is not a decoding error, it's a player whose cards vanished. So the old // shape is read as what it always was: one hand, holding the whole stake. func (s *State) UnmarshalJSON(b []byte) error { type state State // shed the method, or this recurses forever var v struct { state Player []cards.Card `json:"player"` } if err := json.Unmarshal(b, &v); err != nil { return err } *s = State(v.state) if len(s.Hands) == 0 && len(v.Player) > 0 { s.Hands = []Hand{{ Cards: v.Player, Bet: s.Bet, Outcome: s.Outcome, Payout: s.Payout, Rake: s.Rake, Done: s.Phase == PhaseDone, }} s.Active = 0 } return nil }