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.
347 lines
11 KiB
Go
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
|
|
}
|