games: the hand that becomes two, and the bet that has to follow it
Blackjack has a split. It was the last rule missing from a game that has been live for a week, and it is the only move in blackjack that takes chips out of your stack *after* the cards are out — which is most of what there is to get wrong about it. So the state stops pretending. State.Player is gone; there is a slice of Hands, each with its own cards, its own bet, its own outcome and its own payout, and an Active index the player works left to right. Settle runs per hand and rakes per hand: netting them against each other first would mean a player who won one and lost one paid no rake at all, which is not a rake, it's a discount for splitting. The web layer takes the second bet before the move and hands it straight back if the engine refuses — the same shape double already used, except double was staking st.Bet, the whole table's stake, which was the same number as the hand's until today and is now emphatically not. DoubleCost/SplitCost are the active hand's, and the felt would have found this by charging you 300 to double the third hand of a split. The rules that cost money if you guess them: split aces get one card each and no say (a pair of aces is otherwise the best hand in the game, forever), 21 on a split hand is twenty-one and not a natural (it does not pay 3:2 — the test that pins this is the most expensive one in the file), same rank rather than same value (a king and a queen are not a pair), four hands maximum, double after split allowed, and if every hand busts the dealer does not turn over. A live hand outlives a deploy, so State.UnmarshalJSON still reads the old blobs: "player" with no "hands" becomes one hand holding the whole stake. Without it, a player mid-hand at restart is a player whose cards vanished — which is not a decode error, and would not have looked like one. On the felt a hand is now a box with its own spot, and a split is a card lifting out of one hand into a new one with a second stack of chips flying after it from your pile. Verified in a browser against a real pair: chips 4738 -> 4638 on the split, two hands played out, one push and one loss, "Down on the deal. -100", 4738 back. Three hands stack without collision at 390px. Settled hands come back to full brightness — dimming means "not your turn", and when the deal is over they are the thing you are reading. Claude-Session: https://claude.ai/code/session_013M5nD7PgUboJXoDcYHzpuJ
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
package blackjack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
@@ -29,15 +30,21 @@ var (
|
||||
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
|
||||
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
|
||||
)
|
||||
@@ -72,37 +79,77 @@ type Rules struct {
|
||||
|
||||
// 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.
|
||||
// — 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.
|
||||
// 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
|
||||
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"`
|
||||
// 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"`
|
||||
Outcome Outcome `json:"outcome"` // the deal as a whole; per-hand outcomes live on the hands
|
||||
|
||||
// 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.
|
||||
// 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" | "reveal" | "settle"
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -113,6 +160,7 @@ 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
|
||||
@@ -161,36 +209,52 @@ func New(bet int64, r Rules, rng *rand.Rand) (State, []Event, error) {
|
||||
deck := cards.NewDeck(r.Decks)
|
||||
deck.Shuffle(rng)
|
||||
|
||||
s := State{Rules: r, Deck: deck, Bet: bet, Phase: PhasePlayer}
|
||||
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.draw(&s.Player, "player_card", &evs); err != nil {
|
||||
if err := s.hit(0, &evs); err != nil {
|
||||
return State{}, nil, err
|
||||
}
|
||||
if err := s.draw(&s.Dealer, "dealer_card", &evs); err != nil {
|
||||
if err := s.drawDealer(&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) {
|
||||
if s.Hands[0].Natural() || 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 {
|
||||
// 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
|
||||
}
|
||||
*hand = append(*hand, c)
|
||||
s.Hands[i].Cards = append(s.Hands[i].Cards, c)
|
||||
card := c
|
||||
*evs = append(*evs, Event{Kind: kind, Card: &card})
|
||||
*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
|
||||
}
|
||||
|
||||
@@ -210,41 +274,122 @@ func ApplyMove(s State, m Move) (State, []Event, error) {
|
||||
if s.Phase != PhasePlayer {
|
||||
return s, nil, ErrNotYourTurn
|
||||
}
|
||||
if m == Double && len(s.Player) != 2 {
|
||||
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 != Hit && m != Stand && m != Double {
|
||||
return s, nil, ErrUnknownMove
|
||||
if m == Split && !s.CanSplit() {
|
||||
return s, nil, ErrCantSplit
|
||||
}
|
||||
|
||||
i := s.Active
|
||||
evs := []Event{}
|
||||
|
||||
if m == Double {
|
||||
s.Bet *= 2
|
||||
s.Doubled = true
|
||||
}
|
||||
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
|
||||
|
||||
if m == Hit || m == Double {
|
||||
if err := s.draw(&s.Player, "player_card", &evs); err != nil {
|
||||
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 v, _ := HandValue(s.Player); v > 21 {
|
||||
s.settle(&evs) // bust; the dealer never has to play
|
||||
return s, evs, nil
|
||||
if err := s.hit(i+1, &evs); err != nil {
|
||||
return s, nil, err
|
||||
}
|
||||
if m == Hit {
|
||||
return s, evs, nil // still the player's turn
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Stand, or a double that survived its card: the dealer draws out.
|
||||
s.Phase = PhaseDealer
|
||||
s.dealerPlay(&evs)
|
||||
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) {
|
||||
@@ -255,74 +400,104 @@ func (s *State) dealerPlay(evs *[]Event) {
|
||||
if v >= 17 && !hitSoft17 {
|
||||
break
|
||||
}
|
||||
if err := s.draw(&s.Dealer, "dealer_card", evs); err != nil {
|
||||
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 the outcome and the payout, and is the only place chips are
|
||||
// computed.
|
||||
// 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) {
|
||||
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
|
||||
s.Payout, s.Rake = 0, 0
|
||||
for i := range s.Hands {
|
||||
h := &s.Hands[i]
|
||||
playerVal, _ := h.Value()
|
||||
|
||||
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
|
||||
}
|
||||
// profit is what this hand wins on top of its stake. Negative means the
|
||||
// stake is gone.
|
||||
var profit int64
|
||||
|
||||
if profit > 0 {
|
||||
s.Rake = int64(math.Floor(float64(profit) * s.Rules.RakePct))
|
||||
if s.Rake < 0 {
|
||||
s.Rake = 0
|
||||
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
|
||||
}
|
||||
profit -= s.Rake
|
||||
}
|
||||
if profit < 0 {
|
||||
s.Payout = 0 // stake is lost; nothing comes back
|
||||
} else {
|
||||
s.Payout = s.Bet + profit
|
||||
|
||||
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)})
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
@@ -330,17 +505,85 @@ func (s State) Net() int64 {
|
||||
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 {
|
||||
return s.Phase == PhasePlayer && len(s.Player) == 2
|
||||
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.Player = append([]cards.Card(nil), s.Player...)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestIsBlackjack_OnlyOnTwoCards(t *testing.T) {
|
||||
// deal so the payout math can be checked case by case.
|
||||
func settleWith(t *testing.T, r Rules, bet int64, player, dealer []cards.Card) State {
|
||||
t.Helper()
|
||||
s := State{Rules: r, Bet: bet, Player: player, Dealer: dealer}
|
||||
s := State{Rules: r, Bet: bet, Hands: []Hand{{Cards: player, Bet: bet}}, Dealer: dealer}
|
||||
evs := []Event{}
|
||||
s.settle(&evs)
|
||||
if s.Phase != PhaseDone {
|
||||
@@ -164,8 +164,8 @@ func TestNew_DealsFourCardsAndAskThePlayer(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Player) != 2 || len(s.Dealer) != 2 {
|
||||
t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Player), len(s.Dealer))
|
||||
if len(s.Hands[0].Cards) != 2 || len(s.Dealer) != 2 {
|
||||
t.Fatalf("dealt %d/%d cards, want 2/2", len(s.Hands[0].Cards), len(s.Dealer))
|
||||
}
|
||||
if len(s.Deck) != 6*52-4 {
|
||||
t.Fatalf("shoe has %d cards left, want %d", len(s.Deck), 6*52-4)
|
||||
@@ -174,7 +174,7 @@ func TestNew_DealsFourCardsAndAskThePlayer(t *testing.T) {
|
||||
t.Fatal("no deal event")
|
||||
}
|
||||
// Unless somebody was dealt a natural, it's the player's move.
|
||||
if !IsBlackjack(s.Player) && !IsBlackjack(s.Dealer) && s.Phase != PhasePlayer {
|
||||
if !IsBlackjack(s.Hands[0].Cards) && !IsBlackjack(s.Dealer) && s.Phase != PhasePlayer {
|
||||
t.Fatalf("phase = %q, want %q", s.Phase, PhasePlayer)
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func TestNew_NaturalSettlesImmediately(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if IsBlackjack(s.Player) {
|
||||
if IsBlackjack(s.Hands[0].Cards) {
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("seed %d: player has a natural but phase = %q", seed, s.Phase)
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func TestApplyMove_HitUntilBustSettles(t *testing.T) {
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatal("hitting a dozen times never ended the hand")
|
||||
}
|
||||
if v, _ := HandValue(s.Player); v <= 21 {
|
||||
if v, _ := HandValue(s.Hands[0].Cards); v <= 21 {
|
||||
t.Fatalf("player stopped at %d without busting — the loop should have gone over", v)
|
||||
}
|
||||
if s.Outcome != OutcomeBust || s.Payout != 0 {
|
||||
@@ -285,11 +285,11 @@ func TestApplyMove_DoubleTakesOneCardThenStands(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.Doubled || s.Bet != 200 {
|
||||
t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Doubled)
|
||||
if !s.Hands[0].Doubled || s.Bet != 200 {
|
||||
t.Fatalf("bet = %d doubled = %v, want 200/true", s.Bet, s.Hands[0].Doubled)
|
||||
}
|
||||
if len(s.Player) != 3 {
|
||||
t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Player))
|
||||
if len(s.Hands[0].Cards) != 3 {
|
||||
t.Fatalf("player has %d cards after a double, want exactly 3", len(s.Hands[0].Cards))
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatal("a double must end the player's turn")
|
||||
@@ -378,9 +378,9 @@ func TestNew_IsReproducibleFromItsSeed(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cards.Hand(a.Player) != cards.Hand(b.Player) || cards.Hand(a.Dealer) != cards.Hand(b.Dealer) {
|
||||
if cards.Hand(a.Hands[0].Cards) != cards.Hand(b.Hands[0].Cards) || cards.Hand(a.Dealer) != cards.Hand(b.Dealer) {
|
||||
t.Fatalf("same seed dealt different hands: %s/%s vs %s/%s",
|
||||
cards.Hand(a.Player), cards.Hand(a.Dealer), cards.Hand(b.Player), cards.Hand(b.Dealer))
|
||||
cards.Hand(a.Hands[0].Cards), cards.Hand(a.Dealer), cards.Hand(b.Hands[0].Cards), cards.Hand(b.Dealer))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -395,21 +395,21 @@ func TestApplyMove_DerivedStatesDoNotShareCards(t *testing.T) {
|
||||
if s.Phase == PhaseDone {
|
||||
t.Skip("dealt a natural")
|
||||
}
|
||||
before := cards.Hand(s.Player)
|
||||
before := cards.Hand(s.Hands[0].Cards)
|
||||
|
||||
a, _, err := ApplyMove(s, Hit)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
aHand := cards.Hand(a.Player)
|
||||
aHand := cards.Hand(a.Hands[0].Cards)
|
||||
|
||||
if _, _, err := ApplyMove(s, Hit); err != nil { // same start, applied again
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cards.Hand(a.Player); got != aHand {
|
||||
if got := cards.Hand(a.Hands[0].Cards); got != aHand {
|
||||
t.Fatalf("the first hand changed under us: %q became %q", aHand, got)
|
||||
}
|
||||
if got := cards.Hand(s.Player); got != before {
|
||||
if got := cards.Hand(s.Hands[0].Cards); got != before {
|
||||
t.Fatalf("ApplyMove mutated the state it was given: %q became %q", before, got)
|
||||
}
|
||||
}
|
||||
|
||||
374
internal/games/blackjack/split_test.go
Normal file
374
internal/games/blackjack/split_test.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package blackjack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"pete/internal/games/cards"
|
||||
)
|
||||
|
||||
// Split is the only move in blackjack that takes chips out of a player's stack
|
||||
// after the cards are already out, so most of what can go wrong with it is money
|
||||
// rather than cards. These tests are mostly about the money.
|
||||
|
||||
// dealt builds a state mid-hand: the player holding `player`, the dealer showing
|
||||
// `dealer`, and a shoe stacked with `shoe` so the next cards are known. It skips
|
||||
// New() because a split needs a pair, and waiting for one out of a shuffled shoe
|
||||
// is not a test, it's a slot machine.
|
||||
func dealt(bet int64, player, dealer, shoe []cards.Card) State {
|
||||
return State{
|
||||
Rules: DefaultRules(),
|
||||
Deck: cards.Deck(shoe),
|
||||
Dealer: dealer,
|
||||
Hands: []Hand{{Cards: player, Bet: bet}},
|
||||
Bet: bet,
|
||||
Phase: PhasePlayer,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitDealsTwoHandsAndTakesASecondBet(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(8, 8),
|
||||
hand(cards.King, 6),
|
||||
hand(3, 9, 5, 5), // one card to each hand, then whatever the dealer needs
|
||||
)
|
||||
if !s.CanSplit() {
|
||||
t.Fatal("CanSplit says no to a pair of eights")
|
||||
}
|
||||
if s.SplitCost() != 100 {
|
||||
t.Fatalf("SplitCost = %d, want the same bet again (100)", s.SplitCost())
|
||||
}
|
||||
|
||||
s, evs, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Hands) != 2 {
|
||||
t.Fatalf("split made %d hands, want 2", len(s.Hands))
|
||||
}
|
||||
for i, h := range s.Hands {
|
||||
if len(h.Cards) != 2 {
|
||||
t.Errorf("hand %d holds %d cards after the split, want 2", i, len(h.Cards))
|
||||
}
|
||||
if h.Cards[0].Rank != 8 {
|
||||
t.Errorf("hand %d didn't keep an eight: %s", i, cards.Hand(h.Cards))
|
||||
}
|
||||
if h.Bet != 100 {
|
||||
t.Errorf("hand %d carries a bet of %d, want 100", i, h.Bet)
|
||||
}
|
||||
if !h.Split {
|
||||
t.Errorf("hand %d isn't marked as split", i)
|
||||
}
|
||||
}
|
||||
// The whole point, in one line: there is twice as much on the table as there
|
||||
// was, and the shell has to have taken it.
|
||||
if s.Bet != 200 {
|
||||
t.Fatalf("total stake = %d after splitting a 100 hand, want 200", s.Bet)
|
||||
}
|
||||
if s.Active != 0 {
|
||||
t.Fatalf("active hand = %d, want the left one first", s.Active)
|
||||
}
|
||||
|
||||
var split, cardsDealt int
|
||||
for _, e := range evs {
|
||||
switch e.Kind {
|
||||
case "split":
|
||||
split++
|
||||
case "player_card":
|
||||
cardsDealt++
|
||||
}
|
||||
}
|
||||
if split != 1 || cardsDealt != 2 {
|
||||
t.Fatalf("the split emitted %d split and %d card events, want 1 and 2", split, cardsDealt)
|
||||
}
|
||||
}
|
||||
|
||||
// Split aces get one card each and no further say. Without that rule a pair of
|
||||
// aces is the best hand in the game every single time.
|
||||
func TestSplitAcesGetOneCardEachAndNoMore(t *testing.T) {
|
||||
s := dealt(50,
|
||||
hand(cards.Ace, cards.Ace),
|
||||
hand(9, 7), // dealer sits on 16, has to draw
|
||||
hand(cards.King, 9, 5),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, h := range s.Hands {
|
||||
if len(h.Cards) != 2 {
|
||||
t.Errorf("split ace %d holds %d cards, want exactly 2", i, len(h.Cards))
|
||||
}
|
||||
if !h.Done {
|
||||
t.Errorf("split ace %d is still being asked for a decision", i)
|
||||
}
|
||||
}
|
||||
// Both hands are finished the moment they're dealt, so the dealer plays and
|
||||
// the deal settles without the player ever acting again.
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q after splitting aces, want the deal to have run to the end", s.Phase)
|
||||
}
|
||||
}
|
||||
|
||||
// An ace and a ten on a split hand is twenty-one. It is not a blackjack, and the
|
||||
// house does not pay 3:2 for it — which is the single most expensive rule in this
|
||||
// file, because splitting aces makes 21s for a living.
|
||||
func TestTwentyOneOnASplitHandIsNotANatural(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(cards.Ace, cards.Ace),
|
||||
hand(cards.King, 7), // dealer stands on 17
|
||||
hand(cards.King, cards.Queen),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q, want done", s.Phase)
|
||||
}
|
||||
for i, h := range s.Hands {
|
||||
if v, _ := h.Value(); v != 21 {
|
||||
t.Fatalf("hand %d is %d, want the 21 this test is about", i, v)
|
||||
}
|
||||
if h.Natural() {
|
||||
t.Errorf("hand %d counts as a natural — a split 21 must not pay 3:2", i)
|
||||
}
|
||||
if h.Outcome != OutcomeWin {
|
||||
t.Errorf("hand %d settled as %q, want a plain win", i, h.Outcome)
|
||||
}
|
||||
// 100 staked, 100 profit, 5% rake off the profit: 195 back, not 245.
|
||||
if h.Payout != 195 {
|
||||
t.Errorf("hand %d paid %d, want 195 (a 1:1 win less the rake), not a 3:2 payout", i, h.Payout)
|
||||
}
|
||||
}
|
||||
if s.Payout != 390 {
|
||||
t.Fatalf("the deal paid %d, want 390", s.Payout)
|
||||
}
|
||||
}
|
||||
|
||||
// Same rank, not same value. A king and a queen are both worth ten and are not a
|
||||
// pair.
|
||||
func TestSplitNeedsAPairOfTheSameRank(t *testing.T) {
|
||||
s := dealt(100, hand(cards.King, cards.Queen), hand(9, 9), hand(5))
|
||||
if s.CanSplit() {
|
||||
t.Error("CanSplit says a king and a queen are a pair")
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Split); err != ErrCantSplit {
|
||||
t.Errorf("splitting K+Q gave %v, want ErrCantSplit", err)
|
||||
}
|
||||
|
||||
// And you cannot split a hand you have already hit.
|
||||
s = dealt(100, hand(8, 8, 5), hand(9, 9), hand(5))
|
||||
if s.CanSplit() {
|
||||
t.Error("CanSplit says yes to a three-card hand")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplittingStopsAtFourHands(t *testing.T) {
|
||||
// Every card an eight, so every hand splits again for as long as it's allowed.
|
||||
shoe := make([]cards.Card, 0, 12)
|
||||
for i := 0; i < 12; i++ {
|
||||
shoe = append(shoe, cards.Card{Rank: 8, Suit: cards.Spades})
|
||||
}
|
||||
s := dealt(100, hand(8, 8), hand(9, 7), shoe)
|
||||
|
||||
for i := 0; i < MaxHands-1; i++ {
|
||||
var err error
|
||||
if s, _, err = ApplyMove(s, Split); err != nil {
|
||||
t.Fatalf("split %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if len(s.Hands) != MaxHands {
|
||||
t.Fatalf("ended with %d hands, want %d", len(s.Hands), MaxHands)
|
||||
}
|
||||
if s.CanSplit() {
|
||||
t.Fatalf("CanSplit says yes at %d hands", MaxHands)
|
||||
}
|
||||
if _, _, err := ApplyMove(s, Split); err != ErrCantSplit {
|
||||
t.Errorf("the fifth split gave %v, want ErrCantSplit", err)
|
||||
}
|
||||
if s.Bet != 400 {
|
||||
t.Errorf("four hands of 100 = %d staked, want 400", s.Bet)
|
||||
}
|
||||
}
|
||||
|
||||
// Each hand is raked on its own winnings. Netting the hands against each other
|
||||
// first would mean a player who wins one and loses one pays no rake at all, which
|
||||
// is not a rake, it's a discount for splitting.
|
||||
func TestEachSplitHandIsSettledAndRakedOnItsOwn(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(8, 8),
|
||||
hand(cards.King, 9), // dealer stands on 19
|
||||
// left hand gets a 2 (10, then it hits to 20 and stands);
|
||||
// right hand gets a 3 (11) and will bust on a king.
|
||||
hand(2, 3, cards.King, cards.King),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // left: 8+2+K = 20
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v, _ := s.Hands[0].Value(); v != 20 {
|
||||
t.Fatalf("left hand is %d, want 20", v)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Stand); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Active != 1 {
|
||||
t.Fatalf("standing on the left hand left the active hand at %d, want 1", s.Active)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // right: 8+3+K = 21... no: 8+3=11, +K = 21
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q, want done", s.Phase)
|
||||
}
|
||||
|
||||
left, right := s.Hands[0], s.Hands[1]
|
||||
if left.Outcome != OutcomeWin { // 20 beats 19
|
||||
t.Errorf("left hand settled %q, want a win", left.Outcome)
|
||||
}
|
||||
if right.Outcome != OutcomeWin { // 21 beats 19
|
||||
t.Errorf("right hand settled %q, want a win", right.Outcome)
|
||||
}
|
||||
// Two 100 hands, each winning 100, each raked 5 on its own profit.
|
||||
if left.Rake != 5 || right.Rake != 5 || s.Rake != 10 {
|
||||
t.Errorf("rake = %d/%d (total %d), want 5/5 (10) — each hand raked on its own winnings", left.Rake, right.Rake, s.Rake)
|
||||
}
|
||||
if s.Payout != 390 || s.Net() != 190 {
|
||||
t.Errorf("payout = %d net = %d, want 390 and 190", s.Payout, s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
// A hand that busts loses its own bet and nothing else. The other hand is a
|
||||
// separate bet and settles on its own merits.
|
||||
func TestBustingOneSplitHandDoesNotTouchTheOther(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(9, 9),
|
||||
hand(cards.King, 8), // dealer stands on 18
|
||||
hand(5, 2, cards.King, cards.King),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split) // left: 9+5=14, right: 9+2=11
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // left: 14+K = 24, bust
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Hands[0].Outcome != "" && s.Hands[0].Outcome != OutcomeBust {
|
||||
t.Fatalf("left hand outcome %q before settling", s.Hands[0].Outcome)
|
||||
}
|
||||
if !s.Hands[0].Done || s.Active != 1 {
|
||||
t.Fatalf("a bust hand didn't hand over: done=%v active=%d", s.Hands[0].Done, s.Active)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // right: 11+K = 21
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if s.Hands[0].Outcome != OutcomeBust || s.Hands[0].Payout != 0 {
|
||||
t.Errorf("left hand = %q paying %d, want a bust paying nothing", s.Hands[0].Outcome, s.Hands[0].Payout)
|
||||
}
|
||||
if s.Hands[1].Outcome != OutcomeWin || s.Hands[1].Payout != 195 {
|
||||
t.Errorf("right hand = %q paying %d, want a win paying 195", s.Hands[1].Outcome, s.Hands[1].Payout)
|
||||
}
|
||||
// Staked 200, got 195 back: down 5 on the deal, and the word for that is lose.
|
||||
if s.Payout != 195 || s.Net() != -5 || s.Outcome != OutcomeLose {
|
||||
t.Errorf("deal settled %q paying %d (net %d), want lose/195/-5", s.Outcome, s.Payout, s.Net())
|
||||
}
|
||||
}
|
||||
|
||||
// If every hand busts there is nothing left to beat, and the dealer does not turn
|
||||
// over — same as it always was with one hand.
|
||||
func TestTheDealerDoesNotPlayWhenEveryHandIsBust(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(9, 9),
|
||||
hand(cards.King, 6),
|
||||
hand(8, 8, cards.King, cards.King, 4), // both hands reach 17 then bust on a king
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // left 9+8+K = 27
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s, _, err = ApplyMove(s, Hit); err != nil { // right 9+8+K = 27
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Phase != PhaseDone {
|
||||
t.Fatalf("phase = %q, want done", s.Phase)
|
||||
}
|
||||
if len(s.Dealer) != 2 {
|
||||
t.Errorf("the dealer drew to %d cards with every hand already bust", len(s.Dealer))
|
||||
}
|
||||
if s.Payout != 0 {
|
||||
t.Errorf("two bust hands paid %d, want nothing", s.Payout)
|
||||
}
|
||||
}
|
||||
|
||||
// Doubling after a split doubles *that hand's* bet, not the whole table's.
|
||||
func TestDoublingAfterASplitOnlyDoublesThatHand(t *testing.T) {
|
||||
s := dealt(100,
|
||||
hand(5, 5),
|
||||
hand(cards.King, 7),
|
||||
hand(6, 4, 9, cards.King),
|
||||
)
|
||||
s, _, err := ApplyMove(s, Split) // left: 5+6=11, right: 5+4=9
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.DoubleCost() != 100 {
|
||||
t.Fatalf("DoubleCost = %d on a split hand of 100, want 100", s.DoubleCost())
|
||||
}
|
||||
s, _, err = ApplyMove(s, Double) // left doubles: 11 + 9 = 20
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Hands[0].Bet != 200 || s.Hands[1].Bet != 100 {
|
||||
t.Fatalf("bets are %d/%d after doubling the left hand, want 200/100", s.Hands[0].Bet, s.Hands[1].Bet)
|
||||
}
|
||||
if s.Bet != 300 {
|
||||
t.Fatalf("total staked = %d, want 300 (100 + a doubled 100 + 100)", s.Bet)
|
||||
}
|
||||
if !s.Hands[0].Done || s.Active != 1 {
|
||||
t.Fatal("a doubled hand takes one card and hands over")
|
||||
}
|
||||
}
|
||||
|
||||
// A live hand outlives a deploy. The blobs written before split existed have a
|
||||
// "player" array and no "hands", and a state that decodes to no hands at all is a
|
||||
// player whose cards vanished mid-deal.
|
||||
func TestALiveHandDealtBeforeSplitExistedStillLoads(t *testing.T) {
|
||||
legacy := []byte(`{
|
||||
"rules": {"decks": 6, "blackjack_pays": 1.5, "dealer_hits_soft17": true, "rake_pct": 0.05},
|
||||
"deck": [],
|
||||
"player": [{"r": 10, "s": 1}, {"r": 7, "s": 2}],
|
||||
"dealer": [{"r": 9, "s": 0}, {"r": 5, "s": 3}],
|
||||
"bet": 250,
|
||||
"doubled": false,
|
||||
"phase": "player"
|
||||
}`)
|
||||
|
||||
var s State
|
||||
if err := json.Unmarshal(legacy, &s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Hands) != 1 {
|
||||
t.Fatalf("an old live hand decoded to %d hands, want 1 — the player's cards went missing", len(s.Hands))
|
||||
}
|
||||
if len(s.Hands[0].Cards) != 2 {
|
||||
t.Fatalf("the revived hand holds %d cards, want 2", len(s.Hands[0].Cards))
|
||||
}
|
||||
if s.Hands[0].Bet != 250 || s.Bet != 250 {
|
||||
t.Fatalf("the revived hand carries %d of the %d staked, want all of it", s.Hands[0].Bet, s.Bet)
|
||||
}
|
||||
if v, _ := s.Hands[0].Value(); v != 17 {
|
||||
t.Fatalf("the revived hand is worth %d, want 17", v)
|
||||
}
|
||||
// And it can still be played to the end.
|
||||
if _, _, err := ApplyMove(s, Stand); err != nil {
|
||||
t.Fatalf("the revived hand could not be stood on: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user