From f573a9495b5ad7897014c600bc29e287770327f2 Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Sat, 4 Jul 2026 11:00:25 +1000 Subject: [PATCH] casino: betting-screen re-buy affordance for a broke seat (blackjack, roulette) A player whose balance dropped below the minimum stake could get soft-locked at the betting screen with no way to trigger the platform broke-relief re-buy: the auto-rebuy only fires post-hand/post-settle, and the bet action silently no-ops when broke. Since credits are account-wide, a seat can arrive broke (walked over from another casino game, or a rebuy that momentarily hit the daily limit). - blackjack: when broke (bal < betTiers[0]) the betting bar shows "BROKE - press R to re-buy credits" and R triggers the buyback. Test added. - roulette: same class of gap (can't place a chip below the 5 min, re-buy only post-Settle). Adds a broke prompt + B key (R is "ready") that calls Buyback, with a betting-screen test. pokies and scratchies already recover a broke seat from their normal action (a failed spin / ticket-buy auto-triggers the re-buy), so they are unchanged. Complements the platform fix that makes Buyback wait for an in-flight checkpoint instead of refusing (which was stranding the post-hand auto-rebuy). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01W7oJQSTva5xgbAZomdV5Jj --- games/alan/roulette/render.go | 36 ++++++++++++++++++---------- games/alan/roulette/room.go | 28 ++++++++++++++++++++++ games/alan/roulette/room_test.go | 38 ++++++++++++++++++++++++++++++ games/bcook/blackjack/layout.go | 9 ++++++- games/bcook/blackjack/room.go | 4 ++++ games/bcook/blackjack/room_test.go | 37 +++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 14 deletions(-) diff --git a/games/alan/roulette/render.go b/games/alan/roulette/render.go index ec7ab14..fcd47b6 100644 --- a/games/alan/roulette/render.go +++ b/games/alan/roulette/render.go @@ -747,18 +747,24 @@ func (rm *room) drawSidebar(f *kit.Frame, pl *player) { return } if rm.phase == phBetting { - b := masterBets[pl.sel.betIndex()] - // Straight/split labels are bare numbers, so name the family; every other - // label already reads as its bet ("Str 1-3", "Cnr 1-5", "Top line", "RED"). - desc := b.label - switch b.kind { - case kStraight: - desc = "STRAIGHT " + b.label - case kSplit: - desc = "SPLIT " + b.label - } - f.Text(panelRow, 2, "> "+desc+" pays "+strconv.Itoa(b.kind.payout())+":1", stArmed) - f.TextRight(panelRow, kit.Cols-2, "chip "+strconv.Itoa(stakeTiers[pl.stakeIdx]), stHead) + if rm.broke(pl) { + // Below the table minimum: no chip is affordable, so point the seat at + // the re-buy instead of an armed bet it cannot place. + f.Text(panelRow, 2, "BROKE - press B to re-buy credits", stLose) + } else { + b := masterBets[pl.sel.betIndex()] + // Straight/split labels are bare numbers, so name the family; every other + // label already reads as its bet ("Str 1-3", "Cnr 1-5", "Top line", "RED"). + desc := b.label + switch b.kind { + case kStraight: + desc = "STRAIGHT " + b.label + case kSplit: + desc = "SPLIT " + b.label + } + f.Text(panelRow, 2, "> "+desc+" pays "+strconv.Itoa(b.kind.payout())+":1", stArmed) + f.TextRight(panelRow, kit.Cols-2, "chip "+strconv.Itoa(stakeTiers[pl.stakeIdx]), stHead) + } } else if rm.phase == phResults { won, staked := rm.roundNet(pl) if s := roundSummary(won, staked); s != "" { @@ -809,7 +815,11 @@ func (rm *room) drawHelp(f *kit.Frame, pl *player) { var help string switch rm.phase { case phBetting: - help = "arrows move enter place +/- chip bksp undo c clear r ready" + if rm.broke(pl) { + help = "b re-buy credits" + } else { + help = "arrows move enter place +/- chip bksp undo c clear r ready" + } case phSpinning: help = "" // the spinner panel owns this row mid-spin (its result + net land here) case phResults: diff --git a/games/alan/roulette/room.go b/games/alan/roulette/room.go index a561ad8..d3bb434 100644 --- a/games/alan/roulette/room.go +++ b/games/alan/roulette/room.go @@ -463,6 +463,29 @@ func (rm *room) clearBets(pl *player) { rm.refundAll(pl) } func (rm *room) refundAll(pl *player) { pl.bets = nil } +// broke reports whether a seat is below the table minimum with a live economy: +// it can afford no chip (the lowest tier is minBet), so the betting screen must +// offer it the platform Buyback directly. Without that a seat that arrives (or is +// left) below minBet can place no bet, so it never reaches the Settle where the +// only other broke-relief lives — it would be stuck. +func (rm *room) broke(pl *player) bool { + return pl != nil && rm.svc.Credits != nil && !rm.econOff && pl.bal < minBet +} + +// rebuy tops a broke seat up from the platform Buyback during the open betting +// window. It mirrors the post-Settle broke-relief (settle): a solvent or +// limit-reached seat gets ErrInsufficientCredits, which we surface by simply +// leaving the balance as-is — no retry. +func (rm *room) rebuy(pl *player) { + if !rm.broke(pl) { + return + } + if nb, err := rm.svc.Credits.Buyback(pl.p); err == nil { + pl.bal = nb + rm.clampStake(pl) // keep the armed chip valid against the new balance + } +} + // --- spinning & settlement ------------------------------------------------- func (rm *room) startSpin(r kit.Room) { @@ -593,6 +616,11 @@ func (rm *room) handleBetInput(r kit.Room, pl *player, in kit.Input) { rm.clearBets(pl) case 'r', 'R': rm.toggleReady(r, pl) + case 'b', 'B': + // Broke-relief while the window is open: a seat below the table minimum + // can place no chip, so 'b' tops it up from the platform Buyback (the + // same relief that runs post-Settle). A no-op for a solvent seat. + rm.rebuy(pl) } } } diff --git a/games/alan/roulette/room_test.go b/games/alan/roulette/room_test.go index 13bf9b0..d022f03 100644 --- a/games/alan/roulette/room_test.go +++ b/games/alan/roulette/room_test.go @@ -282,6 +282,44 @@ func TestRebuyOnBust(t *testing.T) { } } +// TestRebuyAtBettingScreen covers the betting-window broke affordance: a seat +// that is below the table minimum can place no chip, so it would never reach the +// post-Settle re-buy — pressing 'b' tops it up from the platform Buyback directly. +func TestRebuyAtBettingScreen(t *testing.T) { + r, rm := newGame(t, "p1") + pl := rm.players["p1"] + // Arrive broke: below the table minimum in both the ledger and the cache, so + // no chip is affordable. + r.Credits["p1"] = 3 + pl.bal = 3 + rm.clampStake(pl) + if !rm.broke(pl) { + t.Fatal("setup: a seat below the minimum should read as broke") + } + if int64(stakeTiers[pl.stakeIdx]) <= pl.avail() { + t.Fatalf("setup: broke seat can still afford a chip (avail=%d)", pl.avail()) + } + // Press 'b' at the betting screen -> the platform Buyback tops the seat up. + rm.OnInput(r, pl.p, kit.Input{Kind: kit.InputRune, Rune: 'b'}) + if pl.bal != 1000 { + t.Errorf("re-buy balance = %d, want 1000", pl.bal) + } + if r.CreditsRebuys["p1"] != 1 { + t.Errorf("expected exactly one Buyback, got %d", r.CreditsRebuys["p1"]) + } + // Now solvent, the seat can place the 5 chip. + setCursorNumber(rm, "p1", 7) + rm.placeBet(pl) + if len(pl.bets) != 1 { + t.Errorf("could not bet after re-buy: bets=%d", len(pl.bets)) + } + // A solvent seat pressing 'b' again is a no-op — no extra Buyback. + rm.OnInput(r, pl.p, kit.Input{Kind: kit.InputRune, Rune: 'b'}) + if r.CreditsRebuys["p1"] != 1 { + t.Errorf("solvent re-buy should be a no-op, rebuys=%d", r.CreditsRebuys["p1"]) + } +} + // TestLeaveRefundsDuringBetting checks an open-window departure drops its chips // with no ledger movement (pre-Wager, so there is no escrow to close). func TestLeaveRefundsDuringBetting(t *testing.T) { diff --git a/games/bcook/blackjack/layout.go b/games/bcook/blackjack/layout.go index 47207c4..7f6ca0a 100644 --- a/games/bcook/blackjack/layout.go +++ b/games/bcook/blackjack/layout.go @@ -385,7 +385,14 @@ func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) { centerWithChar(f, actionRow, "BACKING ", kit.CharacterCell(t.p.Character), post, stPrompt) return } - if !s.placed { + if !s.placed && s.bal < betTiers[0] { + // Broke: can't cover the minimum stake, so the bet controls are dead. + // Offer the platform broke-relief re-buy explicitly (the auto-rebuy + // fires post-hand, but a seat that lands here broke — a fresh low + // balance, or an auto-rebuy that was momentarily unavailable — would + // otherwise be stuck with no affordance). ASCII-only. + msg, st = "BROKE - press R to re-buy credits", stPrompt + } else if !s.placed { // Prominent, highlighted call to bet for a viewer who hasn't yet. // ASCII-only so it reads identically on non-UTF-8 sessions. msg, st = "BET: Up/Down stake P pairs Left/Right back a seat SPACE bet", stPrompt diff --git a/games/bcook/blackjack/room.go b/games/bcook/blackjack/room.go index 3d85510..7561dcf 100644 --- a/games/bcook/blackjack/room.go +++ b/games/bcook/blackjack/room.go @@ -1309,6 +1309,10 @@ func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { if s.focus != "" { rm.cycleBackBehind(s) } + case 'r', 'R': // broke-relief re-buy when you can't cover the min bet + if s.bal < betTiers[0] { + rm.buyback(s) // on success s.bal is topped up; the bet controls light back up next render + } } } case phInsurance: diff --git a/games/bcook/blackjack/room_test.go b/games/bcook/blackjack/room_test.go index b05c41b..68de93a 100644 --- a/games/bcook/blackjack/room_test.go +++ b/games/bcook/blackjack/room_test.go @@ -1491,3 +1491,40 @@ func TestManyCardHandStaysInItsSlot(t *testing.T) { } } } + +// A seat that lands in betting below the minimum stake can re-buy with R rather +// than being soft-locked (Confirm no-ops while broke, and the auto-rebuy only +// fires post-hand). Guards the betting-screen broke-relief affordance. +func TestBrokeSeatRebuysOnRInBetting(t *testing.T) { + a := mkPlayer("a") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + + // Broke: below the min bet (betTiers[0]) and the buyback floor. + fund(tr, s, 5) + // Confirm cannot place a bet while broke — the seat is stuck without a rebuy. + rm.OnInput(tr, a, keyInput(kit.KeyEnter)) + if s.placed { + t.Fatal("broke seat placed a bet it cannot afford") + } + + // R triggers the broke-relief re-buy, topping the seat back up. + rm.OnInput(tr, a, runeInput('r')) + if s.bal != 1000 { + t.Fatalf("after re-buy bal = %d, want 1000", s.bal) + } + if tr.CreditsRebuys[a.AccountID] != 1 { + t.Fatalf("re-buy count = %d, want 1", tr.CreditsRebuys[a.AccountID]) + } + // Now solvent: a further R is a no-op (no phantom extra rebuy). + rm.OnInput(tr, a, runeInput('r')) + if tr.CreditsRebuys[a.AccountID] != 1 { + t.Fatalf("solvent R triggered a re-buy: count = %d, want 1", tr.CreditsRebuys[a.AccountID]) + } + // And the seat can now place the minimum bet. + rm.OnInput(tr, a, keyInput(kit.KeyEnter)) + if !s.placed { + t.Fatal("re-bought seat still cannot place a bet") + } +}