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
101 changes: 82 additions & 19 deletions games/bcook/blackjack/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,22 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act
// seat rows, so every hand's cards stay visible and the hand on turn is
// marked — the 15-col slot cannot fit multiple card boxes side by side, which
// is why the old layout collapsed later hands to a bare "+". A single hand
// keeps the full card box below.
if len(s.hands) >= 2 {
// keeps the full card box below — UNLESS a hit-heavy hand has grown too wide
// to fit (a 5+ card box is 16+ cols but the slot is 15), in which case it also
// falls back to the compact line so its cards never spill into the next seat.
tooWide := len(s.hands) == 1 && cardsWidth(len(s.hands[0].cards)) > slotW-1
if len(s.hands) >= 2 || tooWide {
_, ah := rm.firstUnresolved()
for hi, h := range s.hands {
line, st := compactHandLine(h, active && ah == h)
centerSlot(f, seatCardRow+hi, slot, line, st)
}
// Split aces take exactly one card each and stand — so both hands lock the
// moment they're split and the turn passes on. Name the rule beneath them,
// so a locked "can't hit, turn moved on" reads as intended, not broken.
if splitAces(s) {
centerSlot(f, seatValRow, slot, "aces: 1 card", stDim)
}
if rm.phase == phResults && s.result != "" {
centerSlot(f, seatChipRow, slot, s.result, resultStyle(s.result))
} else {
Expand All @@ -229,7 +238,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)+dblTag(h))
vals = append(vals, valueLabel(h.cards, h.fromSplit)+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 @@ -272,16 +281,30 @@ func pairsMult(kind string) int {
// or a quiet "pairs lost".
func (rm *room) drawPairsLine(f *kit.Frame, slot int, s *seat, own bool) {
ch := kit.CharacterCell(s.p.Character) // the placing player's face, beside their side bet
switch {
case rm.phase == phBetting:
if rm.phase == phBetting {
if s.pairsBet > 0 && (s.placed || own) {
centerSlotChar(f, seatCardRow+2, slot, ch, fmt.Sprintf("+pairs %d", s.pairsBet), stOwn)
// Match the seat's bet line (stDim) rather than the bright own-seat
// cyan — the side stake reads as part of the same quiet bet block,
// not a highlight competing with the active-seat and prompt colours.
centerSlotChar(f, seatCardRow+2, slot, ch, fmt.Sprintf("+pairs %d", s.pairsBet), stDim)
}
case s.pairsKind != "":
return
}
if s.pairsBet <= 0 {
return
}
// Hold the Perfect Pairs verdict until BOTH of the seat's first two cards are
// face up. The result is fixed at the deal, but revealing "pairs lost" (or a
// win) while the second card is still sliding/flipping in would spoil it — the
// side bet reads off exactly those two cards, so wait for them to land.
if len(s.hands) == 0 || !rm.seatCardFaceUp(s.p, 0, 0) || !rm.seatCardFaceUp(s.p, 0, 1) {
return
}
if s.pairsKind != "" {
centerSlotChar(f, seatPairRow, slot, ch, fmt.Sprintf("%s %d:1", strings.ToUpper(s.pairsKind), pairsMult(s.pairsKind)), stWin)
case s.pairsBet > 0:
centerSlotChar(f, seatPairRow, slot, ch, "pairs lost", stDim)
return
}
centerSlotChar(f, seatPairRow, slot, ch, "pairs lost", stDim)
}

// drawBackersLine renders, on a seat's dedicated backers row, a token per player
Expand Down Expand Up @@ -369,7 +392,10 @@ func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) {
case phInsurance:
switch n := rm.insuranceUndecidedCount(); {
case s.placed && !s.insuranceDecided:
msg = "Dealer shows an Ace - Insurance? [Y]es [N]o"
// Spell out the side bet rather than just naming it: a stake of half the
// main bet that the dealer's hole card completes a blackjack, paying 2:1
// (so it exactly offsets the main-bet loss if the dealer does have it).
msg = fmt.Sprintf("Insurance? Stake %d that dealer has blackjack - pays 2:1 [Y]es [N]o", s.bet/2)
case n > 0:
noun := "player"
if n != 1 {
Expand Down Expand Up @@ -428,6 +454,21 @@ func (rm *room) unplacedCount() int {
return n
}

// splitAces reports whether a seat's hands are a split pair of aces — two or more
// hands, all formed by splitting, each led by an ace. Such hands take one card
// each and cannot be played on, so the felt names the rule beside them.
func splitAces(s *seat) bool {
if len(s.hands) < 2 {
return false
}
for _, h := range s.hands {
if !h.fromSplit || len(h.cards) == 0 || h.cards[0].r != rankAce {
return false
}
}
return true
}

// legalActions lists the action prompts available for hand h.
func legalActions(s *seat, h *phand) string {
if h == nil {
Expand Down Expand Up @@ -502,6 +543,22 @@ func (rm *room) seatResolver(p kit.Player, handIdx int, h *phand) func(i int) ca
}
}

// seatCardFaceUp reports whether a seat's card currently shows its face at the
// latest composed instant: a settled (or unscheduled) card always does, and an
// animating card only once its slide has landed and its reveal flip has turned
// far enough to expose the face. It mirrors dealerCardFaceUp for seat hands.
func (rm *room) seatCardFaceUp(p kit.Player, handIdx, cardIdx int) bool {
a, ok := rm.animFor(animSeat, p, handIdx, cardIdx)
if !ok {
return true // settled or no animation -> face up
}
if a.slideProgress(rm.lastNow) < 1 {
return false // still gliding in
}
frame, _ := a.flipFrame(rm.lastNow)
return frame == 2 // face exposed only on the final flip frame
}

// dealerCardFaceUp reports whether dealer card i currently shows its face at the
// latest composed instant: the concealed hole card never does, a settled (or
// unscheduled) card always does, and an animating card only once its slide has
Expand Down Expand Up @@ -783,7 +840,7 @@ func compactHandLine(h *phand, active bool) (string, kit.Style) {
cards.WriteString(c.r.boxLabel())
cards.WriteRune(c.s.pip())
}
total := valueLabel(h.cards) + dblTag(h)
total := valueLabel(h.cards, h.fromSplit) + dblTag(h)
marker, st := " ", stCard
if h.cards.isBust() {
st = stLose
Expand Down Expand Up @@ -811,18 +868,24 @@ func dblTag(h *phand) string {
return ""
}

func valueLabel(h hand) string {
if h.isBlackjack() {
return "BJ"
}
// valueLabel formats a hand's total for the felt. Only a NATURAL two-card 21
// (not one formed by splitting) reads as "BJ"; a split two-card 21 — the kind a
// split ace hitting a ten makes — reads as a plain "21", since it is a plain 21
// (even money, not a 3:2 blackjack) and labelling it "BJ" would mislead.
func valueLabel(h hand, fromSplit bool) string {
total, soft := h.value()
if total > 21 {
switch {
case total > 21:
return "BUST"
}
if soft {
case total == 21 && len(h) == 2 && !fromSplit:
return "BJ"
case total == 21:
return "21" // any other 21 (split-ace 21, multi-card 21) reads plainly
case soft:
return fmt.Sprintf("s%d", total)
default:
return fmt.Sprintf("%d", total)
}
return fmt.Sprintf("%d", total)
}

func valueStyle(s *seat) kit.Style {
Expand Down
10 changes: 9 additions & 1 deletion games/bcook/blackjack/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type seat struct {
focus string // betting UI: "" edits own bet, else the account id whose backs are being edited
backs map[string]*backBet // wagers on other seats, keyed by target account id (iterate via rm.order)
insurance int
insuranceWin int // chips credited on the insurance side bet this round (0 = lost/none)
insuranceDecided bool
hands []*phand
joinOrder int
Expand Down Expand Up @@ -333,6 +334,7 @@ func (rm *room) enterBetting(r kit.Room) {
s.hands = nil
s.placed = false
s.insurance = 0
s.insuranceWin = 0
s.insuranceDecided = false
s.result = ""
s.pairsKind = ""
Expand Down Expand Up @@ -866,7 +868,8 @@ func (rm *room) resolveInsurance(r kit.Room) {
if s == nil || s.insurance <= 0 {
continue
}
s.chips += insuranceCredit(dbj, s.insurance)
s.insuranceWin = insuranceCredit(dbj, s.insurance)
s.chips += s.insuranceWin
}
if dbj {
rm.revealAndSettle(r)
Expand Down Expand Up @@ -1111,6 +1114,11 @@ func (rm *room) settle(r kit.Room) {
// credited there); fold its delta into the round net so the seat's
// WIN/LOSE summary reconciles with the chips that actually changed hands.
net += s.pairsWin - s.pairsBet
// The insurance side bet resolved when the offer closed (stake deducted at
// takeInsurance, any 2:1 payout credited in resolveInsurance). Fold its
// delta in too, so a hand fully covered by insurance against a dealer
// blackjack reads as the PUSH it actually is rather than a phantom loss.
net += s.insuranceWin - s.insurance
net += rm.settleBacks(s, dbj)
s.result = resultText(net)
// A seat that can no longer cover the minimum stake is staked back to the
Expand Down
147 changes: 144 additions & 3 deletions games/bcook/blackjack/room_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +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
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 @@ -275,7 +275,7 @@ func TestSettleBehindRefundsWhenTargetLeft(t *testing.T) {
rm.OnJoin(tr, a)
rm.OnJoin(tr, b)
sa := rm.seats[a.AccountID]
sa.placed, sa.bet, sa.chips = true, 25, 900 // behind 50 already deducted at deal
sa.placed, sa.bet, sa.chips = true, 25, 900 // behind 50 already deducted at deal
sa.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 25}} // 19, pushes
sa.backs = map[string]*backBet{b.AccountID: {behind: 50}}
delete(rm.seats, b.AccountID) // b left mid-round
Expand All @@ -302,7 +302,7 @@ func TestSettleFoldsPairsResultIntoNet(t *testing.T) {
s.bet = 50
s.chips = 1000
s.pairsBet = 10
s.pairsWin = 70 // a mixed pair already paid at deal
s.pairsWin = 70 // a mixed pair already paid at deal
s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 50}} // 19
rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} // 19 -> hand pushes
rm.settle(tr)
Expand Down Expand Up @@ -1270,3 +1270,144 @@ func TestReadyUpWaitsOnOtherPlayers(t *testing.T) {
t.Fatalf("phase = %q, want betting once everyone is ready", rm.phase)
}
}

// TestValueLabelSplit21NotBlackjack asserts a two-card 21 formed by splitting
// reads as a plain "21", while a natural two-card 21 still reads "BJ" — a split
// 21 pays even money, not 3:2, so labelling it "BJ" would mislead.
func TestValueLabelSplit21NotBlackjack(t *testing.T) {
twentyOne := hand{{rankAce, suitSpade}, {rankKing, suitHeart}}
if got := valueLabel(twentyOne, false); got != "BJ" {
t.Errorf("natural two-card 21 label = %q, want BJ", got)
}
if got := valueLabel(twentyOne, true); got != "21" {
t.Errorf("split two-card 21 label = %q, want 21 (not BJ)", got)
}
}

// TestSplitAcesShowOneCardNote asserts a split pair of aces — which take one card
// each and lock, passing the turn on immediately — names that rule on the felt,
// so the player understands why the hands can't be played rather than reading it
// as a bug.
func TestSplitAcesShowOneCardNote(t *testing.T) {
a := mkPlayer("alice")
rm, tr := newGame(t, a)
rm.what = pendNone
rm.OnJoin(tr, a)
s := rm.seats[a.AccountID]
s.placed = true
s.chips = 900
s.hands = []*phand{
{cards: hand{{rankAce, suitSpade}, {rankKing, suitHeart}}, bet: 50, fromSplit: true, resolved: true}, // 21
{cards: hand{{rankAce, suitClub}, {6, suitDiamond}}, bet: 50, fromSplit: true, resolved: true}, // soft 17
}
rm.dealer = hand{{10, suitSpade}, {7, suitHeart}}
rm.phase = phTurns
rm.render(tr)

f := tr.LastFrame(a)
if row := kittest.String(f, seatValRow); !strings.Contains(row, "aces") {
t.Fatalf("split-aces seat missing the one-card note on row %d: %q", seatValRow, row)
}
// And the split 21 must not read as a blackjack on the compact hand line.
var hands string
for _, row := range []int{seatCardRow, seatCardRow + 1} {
hands += kittest.String(f, row) + "\n"
}
if strings.Contains(hands, "BJ") {
t.Fatalf("split-ace 21 mislabeled BJ on the felt:\n%s", hands)
}
}

// TestPairsVerdictHeldUntilCardsLand is the regression guard for the reported
// bug: the Perfect Pairs result must not appear while the seat's second card is
// still animating in. The outcome is fixed at the deal, but revealing it early
// spoils the reveal — so "pairs lost" (or a win) waits for both first cards to
// land face up.
func TestPairsVerdictHeldUntilCardsLand(t *testing.T) {
a := mkPlayer("alice")
rm, tr := newGame(t, a)
rm.what = pendNone
rm.OnJoin(tr, a)
s := rm.seats[a.AccountID]
s.placed = true
s.bet = 50
s.chips = 1000
s.pairsBet = 25
// A non-pair hand: the side bet loses, and that must stay hidden mid-deal.
s.hands = []*phand{{cards: hand{{9, suitSpade}, {4, suitHeart}}, bet: 50}}
s.pairsKind = "" // resolved at the deal: no pair
rm.dealer = hand{{10, suitClub}, {7, suitDiamond}}
rm.phase = phTurns
rm.recordDeal(tr) // stagger the initial deal's slide + flip
rm.render(tr)

if row := kittest.String(tr.LastFrame(a), seatPairRow); strings.Contains(row, "pairs lost") {
t.Fatalf("pairs verdict shown before the second card landed: %q", row)
}

pump(rm, tr, 5*time.Second) // let the whole deal settle
rm.render(tr)
if row := kittest.String(tr.LastFrame(a), seatPairRow); !strings.Contains(row, "pairs lost") {
t.Fatalf("pairs verdict not shown after the cards landed: %q", row)
}
}

// TestInsuranceFoldsIntoResultNet asserts an insured loss to a dealer blackjack
// reconciles in the results summary: the 2:1 insurance payout exactly offsets
// the main-bet loss, so the seat reads PUSH (net 0) and its chips are unchanged,
// rather than a phantom LOSE that the chip stack never reflected.
func TestInsuranceFoldsIntoResultNet(t *testing.T) {
a := mkPlayer("alice")
rm, tr := newGame(t, a)
rm.what = pendNone
rm.OnJoin(tr, a)
s := rm.seats[a.AccountID]
s.placed = true
s.bet = 100
s.chips = 900 // the main bet was already deducted at the deal
s.hands = []*phand{{cards: hand{{10, suitSpade}, {9, suitHeart}}, bet: 100}} // 19, loses to BJ
rm.dealer = hand{{rankAce, suitSpade}, {rankKing, suitHeart}} // dealer blackjack
rm.dealerHole = true

rm.takeInsurance(s, true) // stake 50 -> chips 850
rm.resolveInsurance(tr) // dealer BJ pays 2:1 (+150) -> chips 1000, defers settle
pump(rm, tr, 5*time.Second)

if s.chips != 1000 {
t.Fatalf("chips = %d, want 1000 (insured loss breaks even)", s.chips)
}
if s.result != "PUSH" {
t.Fatalf("result = %q, want PUSH (insurance offsets the main-bet loss in the net)", s.result)
}
}

// TestManyCardHandStaysInItsSlot is the regression guard for the reported bug: a
// hit-heavy hand of five or more cards has a card box wider than the 15-col seat
// slot, so it used to spill past the slot's right edge into the neighbouring
// seat. It must fall back to the compact one-line rendering and stay in its slot.
func TestManyCardHandStaysInItsSlot(t *testing.T) {
a := mkPlayer("alice")
rm, tr := newGame(t, a)
rm.what = pendNone
rm.OnJoin(tr, a)
s := rm.seats[a.AccountID]
s.placed = true
s.chips = 800
s.hands = []*phand{{cards: hand{{2, suitSpade}, {2, suitHeart}, {3, suitClub}, {4, suitDiamond}, {5, suitSpade}}, bet: 50}}
rm.dealer = hand{{10, suitClub}, {7, suitDiamond}}
rm.phase = phTurns
rm.render(tr)

// One centred seat: anything drawn at or past its right edge is an overflow
// into where the neighbouring seat's slot would sit.
slot := (kit.Cols - slotW) / 2
f := tr.LastFrame(a)
for _, row := range []int{seatCardRow, seatCardRow + 1, seatCardRow + 2} {
line := []rune(kittest.String(f, row))
for c := slot + slotW; c < kit.Cols-1; c++ {
if c < len(line) && line[c] != ' ' && line[c] != 0 {
t.Fatalf("seat card row %d spills past its slot at col %d: %q", row, c, string(line))
}
}
}
}
Loading