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
36 changes: 23 additions & 13 deletions games/alan/roulette/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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:
Expand Down
28 changes: 28 additions & 0 deletions games/alan/roulette/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
38 changes: 38 additions & 0 deletions games/alan/roulette/room_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion games/bcook/blackjack/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions games/bcook/blackjack/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
37 changes: 37 additions & 0 deletions games/bcook/blackjack/room_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading