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:
prosolis
2026-07-14 13:54:55 -07:00
parent 7ca1f7a030
commit 6f34a89622
9 changed files with 1116 additions and 225 deletions

View File

@@ -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