// 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 ( "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") ErrDeckExhausted = errors.New("blackjack: the shoe is empty") ErrBadBet = errors.New("blackjack: bet must be positive") ) // Phase is whose turn it is. type Phase string const ( PhasePlayer Phase = "player" // the player is acting 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} } // State is one hand of heads-up blackjack: one player, one dealer. Splitting // isn't in v1, so there's exactly one player hand. type State struct { Rules Rules `json:"rules"` Deck cards.Deck `json:"deck"` // the shoe, top card first — never shown to the browser Player []cards.Card `json:"player"` Dealer []cards.Card `json:"dealer"` Bet int64 `json:"bet"` // chips at risk; doubles on a double-down Doubled bool `json:"doubled"` Phase Phase `json:"phase"` Outcome Outcome `json:"outcome"` // Payout is what returns to the player's chip stack when the hand is done: // stake plus winnings, net of rake. Zero on a loss. Rake is the house's cut, // recorded so the ledger can account for every chip that left the table. 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. type Event struct { Kind string `json:"kind"` // "deal" | "player_card" | "dealer_card" | "reveal" | "settle" Card *cards.Card `json:"card,omitempty"` Text string `json:"text,omitempty"` } // Move is a player action. type Move string const ( Hit Move = "hit" Stand Move = "stand" Double Move = "double" ) // 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, Bet: bet, Phase: PhasePlayer} evs := []Event{{Kind: "deal"}} for i := 0; i < 2; i++ { if err := s.draw(&s.Player, "player_card", &evs); err != nil { return State{}, nil, err } if err := s.draw(&s.Dealer, "dealer_card", &evs); err != nil { return State{}, nil, err } } // A natural on either side ends it before the player ever acts. if IsBlackjack(s.Player) || IsBlackjack(s.Dealer) { s.settle(&evs) } return s, evs, nil } // draw takes one card off the shoe onto the given hand and records the event. // 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) draw(hand *[]cards.Card, kind string, evs *[]Event) error { c, ok := s.Deck.Draw() if !ok { return ErrDeckExhausted } *hand = append(*hand, c) card := c *evs = append(*evs, Event{Kind: kind, 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 } if m == Double && len(s.Player) != 2 { // 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 != Hit && m != Stand && m != Double { return s, nil, ErrUnknownMove } evs := []Event{} if m == Double { s.Bet *= 2 s.Doubled = true } if m == Hit || m == Double { if err := s.draw(&s.Player, "player_card", &evs); err != nil { return s, nil, err } if v, _ := HandValue(s.Player); v > 21 { s.settle(&evs) // bust; the dealer never has to play return s, evs, nil } if m == Hit { return s, evs, nil // still the player's turn } } // Stand, or a double that survived its card: the dealer draws out. s.Phase = PhaseDealer s.dealerPlay(&evs) return s, evs, nil } // 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.draw(&s.Dealer, "dealer_card", evs); err != nil { break // shoe ran dry mid-draw; settle on what's on the table } } s.settle(evs) } // settle decides the outcome and the payout, and 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. func (s *State) settle(evs *[]Event) { playerVal, _ := HandValue(s.Player) dealerVal, _ := HandValue(s.Dealer) playerBJ := IsBlackjack(s.Player) dealerBJ := IsBlackjack(s.Dealer) // profit is what the player wins on top of their stake. Negative means the // stake is gone. var profit int64 switch { case playerVal > 21: s.Outcome = OutcomeBust profit = -s.Bet case playerBJ && dealerBJ: s.Outcome = OutcomePush case playerBJ: s.Outcome = OutcomeBlackjack profit = int64(math.Floor(float64(s.Bet) * s.Rules.BlackjackPays)) case dealerBJ: s.Outcome = OutcomeLose profit = -s.Bet case dealerVal > 21: s.Outcome = OutcomeDealerBust profit = s.Bet case playerVal > dealerVal: s.Outcome = OutcomeWin profit = s.Bet case playerVal == dealerVal: s.Outcome = OutcomePush default: s.Outcome = OutcomeLose profit = -s.Bet } if profit > 0 { s.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct)) if s.Rake < 0 { s.Rake = 0 } profit -= s.Rake } if profit < 0 { s.Payout = 0 // stake is lost; nothing comes back } else { s.Payout = s.Bet + profit } s.Phase = PhaseDone *evs = append(*evs, Event{Kind: "settle", Text: string(s.Outcome)}) } // Net is what the hand did to the player's chip stack: payout minus the stake // they put up. Negative on a loss, zero on a push. func (s State) Net() int64 { if s.Phase != PhaseDone { return 0 } return s.Payout - s.Bet } // 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 { return s.Phase == PhasePlayer && len(s.Player) == 2 } // 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.Player = append([]cards.Card(nil), s.Player...) s.Dealer = append([]cards.Card(nil), s.Dealer...) return s }