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:
@@ -91,35 +91,62 @@ func viewCard(c cards.Card) cardView {
|
||||
// genuinely not in the payload. A field the browser is told to ignore is a field
|
||||
// somebody reads in devtools.
|
||||
type handView struct {
|
||||
Phase string `json:"phase"`
|
||||
Phase string `json:"phase"`
|
||||
Bet int64 `json:"bet"` // everything staked this deal, across every hand
|
||||
Hands []spotView `json:"hands"`
|
||||
Active int `json:"active"`
|
||||
Dealer []cardView `json:"dealer"`
|
||||
Hole bool `json:"hole"` // true: the dealer has a face-down card
|
||||
DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to
|
||||
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
Rake int64 `json:"rake,omitempty"`
|
||||
Net int64 `json:"net"`
|
||||
Double bool `json:"can_double"`
|
||||
Split bool `json:"can_split"`
|
||||
}
|
||||
|
||||
// spotView is one of the player's hands: its cards, its own chips, its own
|
||||
// verdict. Before split there was only ever one and it was flattened into the
|
||||
// hand itself; a split is the moment that stops being true.
|
||||
type spotView struct {
|
||||
Cards []cardView `json:"cards"`
|
||||
Bet int64 `json:"bet"`
|
||||
Player []cardView `json:"player"`
|
||||
Dealer []cardView `json:"dealer"`
|
||||
Hole bool `json:"hole"` // true: the dealer has a face-down card
|
||||
Total int `json:"total"`
|
||||
Soft bool `json:"soft"`
|
||||
DTotal int `json:"dealer_total"` // what the *shown* dealer cards add to
|
||||
Doubled bool `json:"doubled"`
|
||||
Done bool `json:"done"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Payout int64 `json:"payout,omitempty"`
|
||||
Rake int64 `json:"rake,omitempty"`
|
||||
Net int64 `json:"net"`
|
||||
Double bool `json:"can_double"`
|
||||
}
|
||||
|
||||
func viewHand(st blackjack.State) handView {
|
||||
v := handView{
|
||||
Phase: string(st.Phase),
|
||||
Bet: st.Bet,
|
||||
Active: st.Active,
|
||||
Outcome: string(st.Outcome),
|
||||
Payout: st.Payout,
|
||||
Rake: st.Rake,
|
||||
Net: st.Net(),
|
||||
Double: st.CanDouble(),
|
||||
Split: st.CanSplit(),
|
||||
}
|
||||
for _, c := range st.Player {
|
||||
v.Player = append(v.Player, viewCard(c))
|
||||
for _, h := range st.Hands {
|
||||
s := spotView{
|
||||
Bet: h.Bet,
|
||||
Doubled: h.Doubled,
|
||||
Done: h.Done,
|
||||
Outcome: string(h.Outcome),
|
||||
Payout: h.Payout,
|
||||
}
|
||||
for _, c := range h.Cards {
|
||||
s.Cards = append(s.Cards, viewCard(c))
|
||||
}
|
||||
s.Total, s.Soft = h.Value()
|
||||
v.Hands = append(v.Hands, s)
|
||||
}
|
||||
v.Total, v.Soft = blackjack.HandValue(st.Player)
|
||||
|
||||
dealer := st.Dealer
|
||||
if st.Phase == blackjack.PhasePlayer && len(dealer) > 1 {
|
||||
@@ -139,6 +166,7 @@ func viewHand(st blackjack.State) handView {
|
||||
type eventView struct {
|
||||
Kind string `json:"kind"`
|
||||
Card *cardView `json:"card,omitempty"`
|
||||
Hand int `json:"hand"` // which of the player's hands the card landed on
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
@@ -149,7 +177,7 @@ func viewEvents(evs []blackjack.Event, phase blackjack.Phase) []eventView {
|
||||
out := make([]eventView, 0, len(evs))
|
||||
dealerCards := 0
|
||||
for _, e := range evs {
|
||||
v := eventView{Kind: e.Kind, Text: e.Text}
|
||||
v := eventView{Kind: e.Kind, Text: e.Text, Hand: e.Hand}
|
||||
if e.Card != nil {
|
||||
c := viewCard(*e.Card)
|
||||
v.Card = &c
|
||||
@@ -492,31 +520,45 @@ func (s *Server) handleMove(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
move := blackjack.Move(req.Move)
|
||||
|
||||
// A double doubles the stake, so the extra chips have to be taken before the
|
||||
// move is applied — and if they aren't there, the move simply isn't legal.
|
||||
// Take them first: if the engine then refuses the move, they go straight back.
|
||||
doubled := false
|
||||
if move == blackjack.Double {
|
||||
// Double and split are the two moves that put more chips on the table *after*
|
||||
// the cards are out, so the money has to move before the move does — and if the
|
||||
// chips aren't there, the move simply isn't legal. Take them first: if the
|
||||
// engine then refuses the move, they go straight back.
|
||||
//
|
||||
// Both cost the active hand's bet, not the whole stake. Once a hand can be
|
||||
// split those are different numbers, and doubling the third hand of a split for
|
||||
// the total of all three would be quite a thing to discover in production.
|
||||
var staked int64
|
||||
switch move {
|
||||
case blackjack.Double:
|
||||
if !st.CanDouble() {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards"})
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only double on the first two cards of a hand"})
|
||||
return
|
||||
}
|
||||
if err := storage.Stake(user, st.Bet); err != nil {
|
||||
staked = st.DoubleCost()
|
||||
case blackjack.Split:
|
||||
if !st.CanSplit() {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "you can only split two cards of the same rank, and only up to four hands"})
|
||||
return
|
||||
}
|
||||
staked = st.SplitCost()
|
||||
}
|
||||
if staked > 0 {
|
||||
if err := storage.Stake(user, staked); err != nil {
|
||||
if errors.Is(err, storage.ErrInsufficientChips) {
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips to double"})
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "not enough chips for that"})
|
||||
return
|
||||
}
|
||||
slog.Error("games: stake double", "user", user, "err", err)
|
||||
slog.Error("games: stake raise", "user", user, "move", move, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
doubled = true
|
||||
}
|
||||
|
||||
next, evs, err := blackjack.ApplyMove(st, move)
|
||||
if err != nil {
|
||||
if doubled {
|
||||
_ = storage.Award(user, st.Bet) // the move didn't happen; neither did the raise
|
||||
if staked > 0 {
|
||||
_ = storage.Award(user, staked) // the move didn't happen; neither did the raise
|
||||
}
|
||||
writeJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "that move isn't legal here"})
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user