From 856a1a6e7ec62f34bf7d335a85c027847969bd9b Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Thu, 2 Jul 2026 17:51:33 +1000 Subject: [PATCH] blackjack: bigger bet ladder, sticky backs, low-stack re-buy, doubled tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend the main bet ladder to 10→25→50→100→250→500→1000→2500→5000→10000 (the ×2.5/×2/×2 climb repeated each decade), and grow the Perfect Pairs / behind side stakes to match so side action can scale with the main bet. - Re-buy a seat once it can no longer cover the minimum stake (chips < betTiers[0]), not only at zero — a 1–9 chip stack was soft-locked in betting (Confirm requires chips >= 10) and never reached the re-buy path again. - Make behind / their-pairs bets sticky between rounds like the main bet and own pairs: carryBacks prunes backs on departed targets and clears last round's per-back results; clampBacks lowers carried stakes a thinned stack can no longer afford. - Flag a doubled hand with a " DBL" tag on its value line (single hand and stacked split) so onlookers can see who doubled down. Co-Authored-By: Claude Opus 4.8 --- games/bcook/blackjack/layout.go | 13 ++++- games/bcook/blackjack/room.go | 50 ++++++++++++++--- games/bcook/blackjack/room_test.go | 86 ++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 10 deletions(-) diff --git a/games/bcook/blackjack/layout.go b/games/bcook/blackjack/layout.go index cd0ca19..7a61228 100644 --- a/games/bcook/blackjack/layout.go +++ b/games/bcook/blackjack/layout.go @@ -229,7 +229,7 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act } drawCardsAnim(f, seatCardRow, col, h.cards, -1, rm.seatResolver(s.p, hi, h)) col += w + 1 - vals = append(vals, valueLabel(h.cards)) + vals = append(vals, valueLabel(h.cards)+dblTag(h)) } // During results the value line doubles as the ready indicator: a readied // seat shows READY where its hand total was, so who's holding up the table @@ -783,7 +783,7 @@ func compactHandLine(h *phand, active bool) (string, kit.Style) { cards.WriteString(c.r.boxLabel()) cards.WriteRune(c.s.pip()) } - total := valueLabel(h.cards) + total := valueLabel(h.cards) + dblTag(h) marker, st := " ", stCard if h.cards.isBust() { st = stLose @@ -802,6 +802,15 @@ func compactHandLine(h *phand, active bool) (string, kit.Style) { return marker + string(cr) + " " + total, st } +// dblTag is the " DBL" flag appended to a doubled hand's total, so a hand that +// doubled down reads as such on the felt (its stake was doubled at the deal). +func dblTag(h *phand) string { + if h.doubled { + return " DBL" + } + return "" +} + func valueLabel(h hand) string { if h.isBlackjack() { return "BJ" diff --git a/games/bcook/blackjack/room.go b/games/bcook/blackjack/room.go index 0f79ace..d62a5ba 100644 --- a/games/bcook/blackjack/room.go +++ b/games/bcook/blackjack/room.go @@ -41,12 +41,15 @@ const ( keyPeak = "peak" ) -// betTiers are the selectable stakes, lowest first. -var betTiers = []int{10, 25, 50, 100} +// betTiers are the selectable stakes, lowest first. The ×2.5/×2/×2 climb repeats +// each decade (10→100, 100→1000, 1000→10000) so the ladder runs from a 10-chip +// minimum up to a 10k high roller. +var betTiers = []int{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000} -// pairsTiers are the Perfect Pairs side-bet stakes, lowest first; index 0 is -// "off" (no side bet). Adjusted on the Left/Right axis during betting. -var pairsTiers = []int{0, 10, 25, 50, 100} +// pairsTiers are the Perfect Pairs / behind side-bet stakes, lowest first; index +// 0 is "off" (no side bet). Adjusted on the Left/Right axis during betting, and +// mirrors betTiers' upper reaches so side action can scale with the main bet. +var pairsTiers = []int{0, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000} // phand is one hand a seat plays (a seat holds more than one after a split). type phand struct { @@ -334,12 +337,13 @@ func (rm *room) enterBetting(r kit.Room) { s.result = "" s.pairsKind = "" s.pairsWin = 0 - s.focus = "" // re-open editing on the seat's own bet - s.backs = nil // backs are round-specific to particular opponents; never carried + s.focus = "" // re-open editing on the seat's own bet + rm.carryBacks(s) // behind/their-pairs stakes are sticky between rounds if s.bet > s.chips { s.bet = clampBet(s.chips) } rm.clampPairs(s) // a thinned stack may no longer afford the carried side bet + rm.clampBacks(s) // nor its carried backs } rm.deadline = r.Now().Add(bettingDur) r.SetInputContext(kit.CtxNav) // bet up/down + confirm @@ -439,6 +443,32 @@ func (rm *room) clampPairs(s *seat) { } } +// carryBacks makes a seat's backs sticky between rounds: it prunes any back on a +// target that has left the table (keying by account id, so a still-seated target +// is kept even if it sits the round out — resolveBackPairs voids that round's +// stakes) and clears last round's per-back result fields so they re-settle fresh. +func (rm *room) carryBacks(s *seat) { + for tid, b := range s.backs { + if rm.seats[tid] == nil { + delete(s.backs, tid) + continue + } + b.pairsKind, b.pairsWin, b.behindWin = "", 0, 0 + } +} + +// clampBacks lowers each carried back's stakes to the highest tiers the seat can +// still afford alongside its main bet, own pairs, and the other backs (down to +// off). Visits backs in a deterministic order so the clamp never depends on Go's +// map iteration order. +func (rm *room) clampBacks(s *seat) { + for _, tid := range sortedBackIDs(s) { + b := s.backs[tid] + b.pairs = affordTier(pairsTiers, b.pairs, s.chips-(s.committed()-b.pairs)) + b.behind = affordTier(pairsTiers, b.behind, s.chips-(s.committed()-b.behind)) + } +} + // affordTier returns the highest tier not exceeding `want` that still fits // `budget` (the chips left for this stake after the seat's other commitments). // tiers must be ascending and start at 0, so a zero/negative budget yields the @@ -1083,7 +1113,11 @@ func (rm *room) settle(r kit.Room) { net += s.pairsWin - s.pairsBet net += rm.settleBacks(s, dbj) s.result = resultText(net) - if s.chips <= 0 { + // A seat that can no longer cover the minimum stake is staked back to the + // re-buy stack — otherwise it would be soft-locked in betting, unable to + // place any bet (Confirm requires chips >= betTiers[0]) and never reaching + // this re-buy path again. + if s.chips < betTiers[0] { s.chips = rebuyChips s.result = "BUST - re-buy" } diff --git a/games/bcook/blackjack/room_test.go b/games/bcook/blackjack/room_test.go index 347dd46..d50ffaa 100644 --- a/games/bcook/blackjack/room_test.go +++ b/games/bcook/blackjack/room_test.go @@ -44,6 +44,7 @@ func TestPairsSideBetLoopsOnP(t *testing.T) { if s.pairsBet != 0 { t.Fatalf("pairs side bet defaults to %d, want 0 (off)", s.pairsBet) } + s.chips = 100000 // deep enough to afford every tier, so the loop wraps only at the top rm.OnInput(tr, a, runeInput('p')) // P advances one tier if s.pairsBet != pairsTiers[1] { t.Fatalf("after P, pairsBet = %d, want %d", s.pairsBet, pairsTiers[1]) @@ -494,6 +495,71 @@ func TestBustRebuysAndKeepsHighScore(t *testing.T) { } } +func TestRebuyWhenBelowMinimumBet(t *testing.T) { + a := mkPlayer("a") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + s.placed = true + s.chips = 5 // above zero but can't cover the 10-chip minimum -> soft-lock without a re-buy + s.hands = []*phand{{cards: hand{{10, suitSpade}, {8, suitHeart}}, bet: 5}} + rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // dealer 19 beats 18 + + rm.settle(tr) + + if s.chips != rebuyChips { + t.Errorf("chips = %d, want re-buy to %d (a stack under the minimum bet must re-buy)", s.chips, rebuyChips) + } +} + +func TestBehindBetsAreStickyAcrossRounds(t *testing.T) { + a, b := mkPlayer("a"), mkPlayer("b") + rm, tr := newGame(t, a, b) + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + sa := rm.seats[a.AccountID] + sa.chips = 1000 + sa.backs = map[string]*backBet{b.AccountID: {behind: 50, pairs: 10}} + + rm.enterBetting(tr) // a fresh betting window must carry a's back on b + + back := sa.backs[b.AccountID] + if back == nil { + t.Fatalf("back on b was dropped; behind bets should be sticky") + } + if back.behind != 50 || back.pairs != 10 { + t.Fatalf("carried back = behind %d pairs %d, want behind 50 pairs 10", back.behind, back.pairs) + } +} + +func TestStickyBackPrunedWhenTargetLeaves(t *testing.T) { + a, b := mkPlayer("a"), mkPlayer("b") + rm, tr := newGame(t, a, b) + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + sa := rm.seats[a.AccountID] + sa.chips = 1000 + sa.backs = map[string]*backBet{b.AccountID: {behind: 50}} + + rm.OnLeave(tr, b) // b leaves the table + rm.enterBetting(tr) // a's carried back on the now-absent b must be pruned + + if _, ok := sa.backs[b.AccountID]; ok { + t.Fatalf("a back on a departed target should be pruned, not carried") + } +} + +func TestDoubledHandTagged(t *testing.T) { + doubled := &phand{cards: hand{{5, suitSpade}, {6, suitHeart}, {10, suitClub}}, bet: 100, doubled: true} + if got := dblTag(doubled); got != " DBL" { + t.Errorf("dblTag(doubled) = %q, want %q", got, " DBL") + } + plain := &phand{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50} + if got := dblTag(plain); got != "" { + t.Errorf("dblTag(plain) = %q, want empty", got) + } +} + func TestBlackjackPays3to2(t *testing.T) { a := mkPlayer("a") rm, tr := newGame(t, a) @@ -723,6 +789,26 @@ func TestHibernationStableDealReplays(t *testing.T) { } } +// TestDoubledHandRendersDBL confirms a doubled hand's value line carries the DBL +// flag on the frame, so onlookers can see who doubled down. +func TestDoubledHandRendersDBL(t *testing.T) { + a := mkPlayer("a") + rm, tr := newGame(t, a) + rm.what = pendNone + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + s.placed = true + s.hands = []*phand{{cards: hand{{5, suitSpade}, {6, suitHeart}, {10, suitClub}}, bet: 200, doubled: true, resolved: true}} // 21 after doubling + rm.phase = phTurns + rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} + + rm.render(tr) + + if row := kittest.String(tr.LastFrame(a), seatValRow); !strings.Contains(row, "DBL") { + t.Fatalf("doubled hand value row = %q, want it to contain DBL", row) + } +} + // dealerReady puts the table at the moment the last player has resolved, with a // live (non-bust) player so the dealer will reveal and draw. The dealer's hand // and the shoe's next draws are caller-supplied so the reveal is deterministic.