Files
Pete/internal/games/blackjack/blackjack.go
prosolis 8310b30439 games: a deck you can seed and a blackjack you can replay
The first two pieces of games.parodia.dev, both pure: no HTTP, no timers, no
euros, nothing that knows a player's name.

cards/ is the shared deck gogobee never had — blackjack carried its own, UNO
carried another, hold'em leaned on a third-party one. The RNG is threaded rather
than the package global, so a hand is reproducible from its seed. That's what
makes the engine testable, and what lets a disputed hand be dealt again exactly
as it fell.

blackjack/ is ApplyMove(state, move) -> (state, events, error), where an error
means the move was illegal and nothing else. gogobee's engine *was* the message
sender, so its errors meant "the send failed"; there was no seam to test against.
State is a plain value, so a hand survives a redeploy.

House terms match the Matrix table — six decks, 3:2, dealer hits soft 17 — plus a
5% rake. The rake comes off winnings only: a push returns the stake untouched and
a loss is never charged for the privilege.
2026-07-13 22:44:45 -07:00

347 lines
11 KiB
Go

// 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
}