Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions games/bcook/blackjack/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
50 changes: 42 additions & 8 deletions games/bcook/blackjack/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
}
Expand Down
86 changes: 86 additions & 0 deletions games/bcook/blackjack/room_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
Loading