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.
This commit is contained in:
346
internal/games/blackjack/blackjack.go
Normal file
346
internal/games/blackjack/blackjack.go
Normal file
@@ -0,0 +1,346 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user