From 79123fd30528b3f64f37d160e3c73f1ccbe9735c Mon Sep 17 00:00:00 2001 From: Brandon Date: Fri, 3 Jul 2026 04:55:22 +0000 Subject: [PATCH 1/2] blackjack: fix pairs spoiler, insurance summary, and 5+ card overflow (#feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three player-reported issues at the table: - Perfect Pairs showed its verdict ("pairs lost" / the win) the instant the hand was dealt, before the second card had animated in — spoiling the reveal. Hold the verdict until both of the seat's first two cards are face up (new seatCardFaceUp helper mirrors dealerCardFaceUp). - Insurance read as broken: its stake/payout were never folded into the results net, so an insured loss to a dealer blackjack showed LOSE while the chip stack broke even. Track the payout and fold its delta into the settlement net (a fully-covered loss now reads PUSH). Also spell the side bet out in the offer prompt — "Stake N that dealer has blackjack - pays 2:1" — instead of just naming it. - A hand of 5+ cards has a card box wider than the 15-col seat slot, so it spilled into the neighbouring seat. Fall back to the compact one-line rendering (as split hands already do) once the box no longer fits. Regression tests cover all three. Co-Authored-By: Claude Opus 4.8 --- games/bcook/blackjack/layout.go | 49 +++++++++++--- games/bcook/blackjack/room.go | 10 ++- games/bcook/blackjack/room_test.go | 100 ++++++++++++++++++++++++++++- 3 files changed, 147 insertions(+), 12 deletions(-) diff --git a/games/bcook/blackjack/layout.go b/games/bcook/blackjack/layout.go index 7a61228..cfe5da6 100644 --- a/games/bcook/blackjack/layout.go +++ b/games/bcook/blackjack/layout.go @@ -202,8 +202,11 @@ 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) @@ -272,16 +275,27 @@ 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) } - 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 @@ -369,7 +383,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 { @@ -502,6 +519,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 diff --git a/games/bcook/blackjack/room.go b/games/bcook/blackjack/room.go index d62a5ba..1f01ee5 100644 --- a/games/bcook/blackjack/room.go +++ b/games/bcook/blackjack/room.go @@ -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 @@ -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 = "" @@ -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) @@ -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 diff --git a/games/bcook/blackjack/room_test.go b/games/bcook/blackjack/room_test.go index d50ffaa..b6c7e86 100644 --- a/games/bcook/blackjack/room_test.go +++ b/games/bcook/blackjack/room_test.go @@ -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]) @@ -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 @@ -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) @@ -1270,3 +1270,97 @@ func TestReadyUpWaitsOnOtherPlayers(t *testing.T) { t.Fatalf("phase = %q, want betting once everyone is ready", rm.phase) } } + +// 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)) + } + } + } +} From af49d2189e7c9c917377af0ba86a16ca26d0da8d Mon Sep 17 00:00:00 2001 From: Brandon Date: Fri, 3 Jul 2026 05:14:13 +0000 Subject: [PATCH 2/2] blackjack: dim the pairs stake, clarify split aces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up feedback on the same table: - The Perfect Pairs stake ("+pairs N") during betting was drawn in the bright own-seat cyan, louder than the dim "bet N" beside it. Match the bet colour so the side stake reads as part of the same quiet bet block. - Split aces keep the one-card rule (each split ace takes exactly one card and stands), but two things made that read as a bug: a split ace that drew a ten showed "BJ" as if it were a natural blackjack, and the turn passed on with no explanation. Now a split two-card 21 reads as a plain "21" (it pays even money, not 3:2), and the seat names the rule — "aces: 1 card" — beneath the locked hands so the skipped turn reads as intended. Regression tests cover the label and the note. Co-Authored-By: Claude Opus 4.8 --- games/bcook/blackjack/layout.go | 52 +++++++++++++++++++++++------- games/bcook/blackjack/room_test.go | 47 +++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/games/bcook/blackjack/layout.go b/games/bcook/blackjack/layout.go index cfe5da6..a0d216c 100644 --- a/games/bcook/blackjack/layout.go +++ b/games/bcook/blackjack/layout.go @@ -212,6 +212,12 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, v kit.Player, own, act 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 { @@ -232,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 @@ -277,7 +283,10 @@ 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 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) } return } @@ -445,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 { @@ -816,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 @@ -844,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 { diff --git a/games/bcook/blackjack/room_test.go b/games/bcook/blackjack/room_test.go index b6c7e86..40121a0 100644 --- a/games/bcook/blackjack/room_test.go +++ b/games/bcook/blackjack/room_test.go @@ -1271,6 +1271,53 @@ func TestReadyUpWaitsOnOtherPlayers(t *testing.T) { } } +// 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