diff --git a/games/bcook/blackjack/layout.go b/games/bcook/blackjack/layout.go index 34c4344..74cc112 100644 --- a/games/bcook/blackjack/layout.go +++ b/games/bcook/blackjack/layout.go @@ -15,12 +15,12 @@ const ( feltBottom = 19 dealerRow = 4 // dealer card group occupies dealerRow..dealerRow+2 dealerValRow = 7 // dealer total / verdict, centred just below the cards - taglineRow = 9 seatNameRow = 11 seatCardRow = 12 // seat card group occupies seatCardRow..seatCardRow+2 seatValRow = 15 seatChipRow = 16 + seatPairRow = 17 // Perfect Pairs side-bet line (stake while betting, result once dealt) actionRow = 18 slotW = 15 maxSeats = 5 @@ -70,12 +70,14 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) { drawFelt(f, feltTop, feltBottom) center(f, feltTop, " B L A C K J A C K ", stFelt) - // Dealer, centred near the top of the felt. + // Dealer, centred near the top of the felt, with the table rules as subtle + // signage flanking the DEALER label (left + right) rather than a banner + // across the middle of the felt — keeping the centre clear for the cards. + f.Text(dealerRow-1, 2, "blackjack pays 3:2", stDim) + f.TextRight(dealerRow-1, kit.Cols-3, "dealer stands on 17", stDim) center(f, dealerRow-1, "D E A L E R", stTitle) rm.drawDealer(f) - center(f, taglineRow, "~ blackjack pays 3:2 · dealer stands on 17 ~", stFelt) - // Seats along the rail, centred as a group. n := len(rm.order) if n > maxSeats { @@ -167,6 +169,8 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, own, active bool) { f.Set(seatNameRow, nameCol, kit.CharacterCell(s.p.Character)) f.Text(seatNameRow, nameCol+2, name, nameSt) + rm.drawPairsLine(f, slot, s, own) + if rm.phase == phBetting { status := "--" if s.placed { @@ -189,6 +193,25 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, own, active bool) { return } + // A split seat (2+ hands) renders one compact line per hand, stacked down the + // 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 { + _, ah := rm.firstUnresolved() + for hi, h := range s.hands { + line, st := compactHandLine(h, active && ah == h) + centerSlot(f, seatCardRow+hi, slot, line, st) + } + if rm.phase == phResults && s.result != "" { + centerSlot(f, seatChipRow, slot, s.result, resultStyle(s.result)) + } else { + centerSlot(f, seatChipRow, slot, fmt.Sprintf("$%d", s.chips), stDim) + } + return + } + // Draw the seat's hand(s) as joined card groups, left to right within the slot. col := slot + 1 limit := slot + slotW @@ -221,6 +244,41 @@ func (rm *room) drawSeat(f *kit.Frame, slot int, s *seat, own, active bool) { } } +// pairsMult maps a Perfect Pairs result kind to its payout multiplier (X:1), +// for the result label; 0 for no pair. +func pairsMult(kind string) int { + switch kind { + case "perfect": + return 25 + case "colored": + return 12 + case "mixed": + return 6 + } + return 0 +} + +// drawPairsLine renders the seat's Perfect Pairs side-bet line. While betting it +// sits directly beneath that seat's main bet (seatCardRow+2), so each seat's +// bet+pairs read as one contiguous block and whose side bet is whose is never +// ambiguous; it shows only once placed, or for the seat's owner, matching how +// the main bet stays private until placed. Once the cards are dealt it moves to +// seatPairRow below the seat's hand, showing the win label (e.g. "COLORED 12:1") +// 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 s.pairsBet > 0 && (s.placed || own) { + centerSlotChar(f, seatCardRow+2, slot, ch, fmt.Sprintf("+pairs %d", s.pairsBet), stOwn) + } + case 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) + } +} + func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) { s := rm.seats[v.AccountID] if s == nil { @@ -233,7 +291,7 @@ func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) { 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 = "PLACE YOUR BET - Up/Down stake SPACE bet", stPrompt + msg, st = "PLACE YOUR BET - Up/Down stake Left/Right pairs SPACE bet", stPrompt } else if n := rm.unplacedCount(); n > 0 { noun := "player" if n != 1 { @@ -620,6 +678,21 @@ func centerSlot(f *kit.Frame, row, slot int, s string, st kit.Style) { f.Text(row, slot+(slotW-n)/2, s, st) } +// centerSlotChar centres " " within a slotW-wide column: +// the styled character cell (width 1) plus a space precede the text, tying the +// line to a specific player by face. The text is clamped so the tile + text +// never overflow the slot. +func centerSlotChar(f *kit.Frame, row, slot int, ch kit.Cell, text string, st kit.Style) { + tr := []rune(text) + if len(tr) > slotW-2 { + tr = tr[:slotW-2] + } + w := 2 + len(tr) + col := slot + (slotW-w)/2 + f.Set(row, col, ch) + f.Text(row, col+2, string(tr), st) +} + func (rm *room) remaining() int { if rm.deadline.IsZero() || rm.lastNow.IsZero() { return 0 @@ -633,6 +706,37 @@ func (rm *room) remaining() int { func clock(secs int) string { return fmt.Sprintf("0:%02d", secs) } +// compactHandLine formats one split hand as a single slot-wide line — +// " ", e.g. "►8♠3♥ 11" — for the stacked split layout. +// The on-turn hand carries a ► marker (degrades to > on non-UTF-8) and the +// active style; a busted hand reads red. The card tokens are truncated with an +// ellipsis if a long (hit-heavy) hand would otherwise overflow the slot, so the +// total always stays visible. +func compactHandLine(h *phand, active bool) (string, kit.Style) { + var cards strings.Builder + for _, c := range h.cards { + cards.WriteString(c.r.boxLabel()) + cards.WriteRune(c.s.pip()) + } + total := valueLabel(h.cards) + marker, st := " ", stCard + if h.cards.isBust() { + st = stLose + } + if active { + marker, st = "►", stActive + } + budget := slotW - len([]rune(marker)) - 1 - len([]rune(total)) // marker + space + total + if budget < 1 { + budget = 1 + } + cr := []rune(cards.String()) + if len(cr) > budget { + cr = append(cr[:budget-1:budget-1], '…') + } + return marker + string(cr) + " " + total, st +} + func valueLabel(h hand) string { if h.isBlackjack() { return "BJ" diff --git a/games/bcook/blackjack/room.go b/games/bcook/blackjack/room.go index 5afed0b..a9088f0 100644 --- a/games/bcook/blackjack/room.go +++ b/games/bcook/blackjack/room.go @@ -43,6 +43,10 @@ const ( // betTiers are the selectable stakes, lowest first. var betTiers = []int{10, 25, 50, 100} +// 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} + // phand is one hand a seat plays (a seat holds more than one after a split). type phand struct { cards hand @@ -62,6 +66,9 @@ type seat struct { postedPeak int // last peak Posted to the board (post only on increase) bet int // currently selected/placed stake placed bool + pairsBet int // Perfect Pairs side stake (0 = off), carried between rounds like bet + pairsKind string // this round's pairs result: "" | "mixed" | "colored" | "perfect" + pairsWin int // chips credited on the pairs side bet this round (0 = lost/none) insurance int insuranceDecided bool hands []*phand @@ -310,9 +317,12 @@ func (rm *room) enterBetting(r kit.Room) { s.insurance = 0 s.insuranceDecided = false s.result = "" + s.pairsKind = "" + s.pairsWin = 0 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.deadline = r.Now().Add(bettingDur) r.SetInputContext(kit.CtxNav) // bet up/down + confirm @@ -397,6 +407,39 @@ func tierIndex(bet int) int { return 0 } +// adjustPairs steps the Perfect Pairs side stake through pairsTiers, clamped so +// the main bet plus the side bet never exceeds the seat's chips (the side bet +// shrinks to the highest affordable tier, down to off). +func (rm *room) adjustPairs(s *seat, dir int) { + i := pairsTierIndex(s.pairsBet) + dir + if i < 0 { + i = 0 + } + if i >= len(pairsTiers) { + i = len(pairsTiers) - 1 + } + s.pairsBet = pairsTiers[i] + rm.clampPairs(s) +} + +// clampPairs lowers the side bet to the highest tier the seat can still afford +// alongside its main bet (down to off), so a raised main bet or a thin stack can +// never leave an unaffordable side bet placed. +func (rm *room) clampPairs(s *seat) { + for s.pairsBet > 0 && s.bet+s.pairsBet > s.chips { + s.pairsBet = pairsTiers[pairsTierIndex(s.pairsBet)-1] + } +} + +func pairsTierIndex(bet int) int { + for i, t := range pairsTiers { + if t == bet { + return i + } + } + return 0 +} + // --- dealing --------------------------------------------------------------- func (rm *room) deal(r kit.Room) { @@ -420,6 +463,7 @@ func (rm *room) deal(r kit.Room) { h.resolved = true } s.hands = []*phand{h} + rm.resolvePairs(s, h.cards) } rm.recordDeal(r) @@ -439,6 +483,24 @@ func (rm *room) deal(r kit.Room) { } } +// resolvePairs settles a seat's Perfect Pairs side bet against its dealt cards: +// the stake is deducted (the main bet was already taken above) and any winning +// pair is credited immediately — the casino way, where the side bet stands apart +// from how the hand goes on to play out. +func (rm *room) resolvePairs(s *seat, dealt hand) { + if s.pairsBet <= 0 || len(dealt) < 2 { + return + } + if s.pairsBet > s.chips { + s.pairsBet = s.chips // defensive: never deduct more than the seat has left + } + s.chips -= s.pairsBet + kind, mult := perfectPairsOutcome(dealt[0], dealt[1]) + s.pairsKind = kind + s.pairsWin = pairsCreditFor(mult, s.pairsBet) + s.chips += s.pairsWin +} + // --- animation schedule ---------------------------------------------------- // clearSchedule drops any pending card animation. Safe to call when none is @@ -857,6 +919,10 @@ func (rm *room) settle(r kit.Room) { s.chips += credit net += credit - h.bet } + // The Perfect Pairs side bet was settled at deal (stake deducted, any win + // 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 s.result = resultText(net) if s.chips <= 0 { s.chips = rebuyChips @@ -922,13 +988,19 @@ func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { switch kit.Resolve(in, kit.CtxNav) { case kit.ActUp: rm.adjustBet(s, +1) + rm.clampPairs(s) // a raised main bet may crowd out the side bet case kit.ActDown: rm.adjustBet(s, -1) + case kit.ActRight: + rm.adjustPairs(s, +1) // raise the Perfect Pairs side bet + case kit.ActLeft: + rm.adjustPairs(s, -1) // lower it (down to off) case kit.ActConfirm: if s.chips >= betTiers[0] { if s.bet > s.chips { s.bet = clampBet(s.chips) } + rm.clampPairs(s) s.placed = true rm.maybeCloseEarly(r) // deal early once every seat has bet } diff --git a/games/bcook/blackjack/room_test.go b/games/bcook/blackjack/room_test.go index 0793d7e..81e90a2 100644 --- a/games/bcook/blackjack/room_test.go +++ b/games/bcook/blackjack/room_test.go @@ -34,6 +34,92 @@ func pump(rm *room, tr *kittest.Room, d time.Duration) { func runeInput(r rune) kit.Input { return kit.Input{Kind: kit.InputRune, Rune: r} } +func keyInput(k kit.Key) kit.Input { return kit.Input{Kind: kit.InputKey, Key: k} } + +func TestPairsSideBetAdjustsOnLeftRight(t *testing.T) { + a := mkPlayer("a") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + if s.pairsBet != 0 { + t.Fatalf("pairs side bet defaults to %d, want 0 (off)", s.pairsBet) + } + rm.OnInput(tr, a, keyInput(kit.KeyRight)) // raise to the first tier + if s.pairsBet != pairsTiers[1] { + t.Fatalf("after Right, pairsBet = %d, want %d", s.pairsBet, pairsTiers[1]) + } + rm.OnInput(tr, a, keyInput(kit.KeyLeft)) // back to off + if s.pairsBet != 0 { + t.Fatalf("after Left, pairsBet = %d, want 0 (off)", s.pairsBet) + } +} + +func TestPairsSideBetClampedToChips(t *testing.T) { + a := mkPlayer("a") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + s.bet = 100 + s.chips = 105 // can afford the 100 main bet + at most a 5-chip side bet, so only "off" + for i := 0; i < len(pairsTiers); i++ { + rm.OnInput(tr, a, keyInput(kit.KeyRight)) + } + if s.bet+s.pairsBet > s.chips { + t.Fatalf("main %d + pairs %d exceeds chips %d (clamp failed)", s.bet, s.pairsBet, s.chips) + } +} + +func TestDealResolvesPerfectPairsSideBet(t *testing.T) { + a := mkPlayer("a") + rm, tr := newGame(t, a) + rm.what = pendNone + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + s.bet = 50 + s.placed = true + s.pairsBet = 10 + s.chips = 1000 + // Stack the shoe: dealer up + hole, then the seat's two cards — a mixed pair. + rm.sh.cards = hand{ + {10, suitClub}, {9, suitDiamond}, // dealer 19 + {8, suitSpade}, {8, suitHeart}, // seat: mixed pair of 8s + {2, suitClub}, {3, suitClub}, {4, suitClub}, // filler draws + } + rm.sh.pos = 0 + rm.sh.roundStart = 0 + rm.deal(tr) + + if s.pairsKind != "mixed" { + t.Fatalf("pairsKind = %q, want mixed", s.pairsKind) + } + if s.pairsWin != 70 { // mixed 6:1 on 10 -> 10 + 60 + t.Fatalf("pairsWin = %d, want 70", s.pairsWin) + } + // 1000 - 50 (bet) - 10 (pairs stake) + 70 (mixed payout) = 1010. + if s.chips != 1010 { + t.Fatalf("chips = %d, want 1010 (bet + pairs deducted at deal, mixed pair paid 70)", s.chips) + } +} + +func TestSettleFoldsPairsResultIntoNet(t *testing.T) { + a := mkPlayer("a") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + s.placed = true + s.bet = 50 + s.chips = 1000 + s.pairsBet = 10 + 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) + // Hand pushes (net 0); the pairs win folds in: net = (70 - 10) = +60. + if s.result != "WIN +60" { + t.Fatalf("result = %q, want WIN +60 (pairs win folded into the round net)", s.result) + } +} + func TestJoinSeatsPlayer(t *testing.T) { p := mkPlayer("alice") rm, tr := newGame(t, p) @@ -682,6 +768,146 @@ func TestReadyUpSkipsTheResultsWait(t *testing.T) { } } +// TestBettingShowsPairsSideBet asserts a seat's selected Perfect Pairs side +// stake is shown during betting directly beneath that seat's main bet — so the +// two lines form one contiguous per-seat block and it's unambiguous whose side +// bet is whose at a multi-seat table. +func TestBettingShowsPairsSideBet(t *testing.T) { + a := mkPlayer("alice") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + s.bet = 50 + s.pairsBet = 25 + rm.render(tr) + f := tr.LastFrame(a) + + betRow := kittest.String(f, seatCardRow+1) // where the "bet N" status sits + pairRow := kittest.String(f, seatCardRow+2) // pairs must sit immediately below it + if !strings.Contains(betRow, "bet 50") { + t.Fatalf("expected the bet status on row %d: %q", seatCardRow+1, betRow) + } + if !strings.Contains(pairRow, "+pairs 25") { + t.Fatalf("expected the pairs side bet directly below the bet on row %d: %q", seatCardRow+2, pairRow) + } + // The two lines must align under the same seat slot. + if colIndex(betRow, "bet 50") < 0 || colIndex(pairRow, "+pairs 25") < 0 { + t.Fatalf("bet and pairs lines not aligned in the seat slot:\n%q\n%q", betRow, pairRow) + } +} + +// TestPairsLineCarriesCharacterTile asserts the Perfect Pairs side-bet line is +// prefixed with the placing player's arcade character tile, so whose side bet is +// whose reads from the face beside it, not just the column. +func TestPairsLineCarriesCharacterTile(t *testing.T) { + a := mkPlayer("alice") + a.Character = kit.Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, Fallback: 'L'} + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + rm.seats[a.AccountID].pairsBet = 25 + rm.render(tr) + f := tr.LastFrame(a) + + row := kittest.String(f, seatCardRow+2) + idx := colIndex(row, "+pairs 25") + if idx < 2 { + t.Fatalf("pairs line not found (or no room for a tile) on row %d: %q", seatCardRow+2, row) + } + if got, want := f.Cells[seatCardRow+2][idx-2], kit.CharacterCell(a.Character); got != want { + t.Errorf("cell before the pairs bet = %+v, want the character tile %+v", got, want) + } + if sp := f.Cells[seatCardRow+2][idx-1].Rune; sp != ' ' && sp != 0 { + t.Errorf("no space between the character tile and the pairs bet (got %q)", sp) + } +} + +// TestResultsShowsPerfectPairsWin asserts a winning Perfect Pairs side bet is +// surfaced on the seat with its category and multiplier during results. +func TestResultsShowsPerfectPairsWin(t *testing.T) { + a := mkPlayer("alice") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + s.placed = true + s.bet = 50 + s.chips = 1000 + s.pairsBet = 25 + s.pairsKind = "colored" + s.pairsWin = 325 + s.hands = []*phand{{cards: hand{{8, suitHeart}, {8, suitDiamond}}, bet: 50}} + rm.dealer = hand{{10, suitClub}, {9, suitDiamond}} + rm.settle(tr) // -> results phase + rm.render(tr) + + row := kittest.String(tr.LastFrame(a), seatPairRow) + if !strings.Contains(row, "COLORED 12:1") { + t.Fatalf("results row %d does not show the pairs win: %q", seatPairRow, row) + } +} + +// TestRulesTaglineFlanksTheDealer asserts the rules signage moved out of the +// mid-felt row up to the dealer's label row, split into a left and a right +// label, leaving the old mid-felt tagline row clear. +func TestRulesTaglineFlanksTheDealer(t *testing.T) { + a := mkPlayer("alice") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + rm.render(tr) + f := tr.LastFrame(a) + + dealerLabelRow := kittest.String(f, dealerRow-1) + if !strings.Contains(dealerLabelRow, "blackjack pays 3:2") { + t.Errorf("payout rule not on the dealer label row: %q", dealerLabelRow) + } + if !strings.Contains(dealerLabelRow, "dealer stands on 17") { + t.Errorf("dealer rule not on the dealer label row: %q", dealerLabelRow) + } + if !strings.Contains(dealerLabelRow, "D E A L E R") { + t.Errorf("DEALER label should remain centred between the rules: %q", dealerLabelRow) + } + // The old mid-felt tagline (row 9) must be clear now. + if mid := kittest.String(f, 9); strings.Contains(mid, "blackjack pays") { + t.Errorf("rules tagline still sits mid-felt on row 9: %q", mid) + } +} + +// TestSplitSeatShowsEveryHandsCards is the regression guard for the reported +// bug: a seat split into two hands must render BOTH hands' cards (each on its +// own compact line), not collapse the second hand to a bare "+". The active +// hand is marked so the player can see which one they are acting on. +func TestSplitSeatShowsEveryHandsCards(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{{8, suitSpade}, {3, suitHeart}}, bet: 50, fromSplit: true}, + {cards: hand{{8, suitClub}, {10, suitDiamond}}, bet: 50, fromSplit: true}, + } + rm.dealer = hand{{10, suitSpade}, {7, suitHeart}} + rm.phase = phTurns + rm.render(tr) + + f := tr.LastFrame(a) + // Gather the seat's content rows into one blob. + var blob string + for _, row := range []int{seatCardRow, seatCardRow + 1, seatCardRow + 2, seatCardRow + 3} { + blob += kittest.String(f, row) + "\n" + } + // Both hands' cards must be present — second hand included. + for _, tok := range []string{"8♠", "3♥", "8♣", "T♦"} { + if !strings.Contains(blob, tok) { + t.Fatalf("split seat is missing card %q from its hands:\n%s", tok, blob) + } + } + if strings.Contains(blob, "+") { + t.Fatalf("split seat collapsed a hand to \"+\" instead of showing its cards:\n%s", blob) + } +} + // TestReadyUpWaitsOnOtherPlayers asserts one player readying up does not skip // the wait while another seated player is still not ready. func TestReadyUpWaitsOnOtherPlayers(t *testing.T) { diff --git a/games/bcook/blackjack/round.go b/games/bcook/blackjack/round.go index 7db21d5..8da766e 100644 --- a/games/bcook/blackjack/round.go +++ b/games/bcook/blackjack/round.go @@ -87,3 +87,32 @@ func insuranceCredit(dealerBlackjack bool, ins int) int { } return 0 } + +// perfectPairsOutcome classifies a player's first two cards for the Perfect +// Pairs side bet and returns the result kind and its payout multiplier (the X +// in X:1), or ("", 0) when the cards are not a pair. A perfect pair is the same +// rank and suit (25:1), a colored pair is the same rank and colour but different +// suit (12:1), and a mixed pair is the same rank in different colours (6:1). +func perfectPairsOutcome(a, b card) (kind string, mult int) { + switch { + case a.r != b.r: + return "", 0 + case a.s == b.s: + return "perfect", 25 + case a.s.red() == b.s.red(): + return "colored", 12 + default: + return "mixed", 6 + } +} + +// pairsCreditFor is the chips returned to a player on a Perfect Pairs side bet of +// `bet` at multiplier `mult` (the X in X:1). The stake was deducted when the bet +// was placed, so a winning pair returns stake + mult×stake and a loss (mult 0) +// returns nothing. +func pairsCreditFor(mult, bet int) int { + if mult <= 0 { + return 0 + } + return bet * (mult + 1) +} diff --git a/games/bcook/blackjack/round_test.go b/games/bcook/blackjack/round_test.go index d4d4ebe..9a7b184 100644 --- a/games/bcook/blackjack/round_test.go +++ b/games/bcook/blackjack/round_test.go @@ -89,3 +89,45 @@ func TestInsuranceCredit(t *testing.T) { t.Errorf("insurance with no dealer blackjack = %d, want 0", got) } } + +func TestPerfectPairsOutcome(t *testing.T) { + cases := []struct { + name string + a, b card + wantKind string + wantMult int + }{ + {"perfect: same rank and suit", card{8, suitHeart}, card{8, suitHeart}, "perfect", 25}, + {"colored: same rank, both red", card{8, suitHeart}, card{8, suitDiamond}, "colored", 12}, + {"colored: same rank, both black", card{rankKing, suitSpade}, card{rankKing, suitClub}, "colored", 12}, + {"mixed: same rank, different colour", card{8, suitSpade}, card{8, suitHeart}, "mixed", 6}, + {"no pair: different rank", card{8, suitSpade}, card{9, suitSpade}, "", 0}, + {"no pair: ten and face are not a pair", card{10, suitSpade}, card{rankKing, suitSpade}, "", 0}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + kind, mult := perfectPairsOutcome(c.a, c.b) + if kind != c.wantKind || mult != c.wantMult { + t.Fatalf("perfectPairsOutcome = (%q, %d), want (%q, %d)", kind, mult, c.wantKind, c.wantMult) + } + }) + } +} + +func TestPairsCreditFor(t *testing.T) { + cases := []struct { + mult int + bet int + want int + }{ + {0, 10, 0}, // no pair: side stake lost + {6, 10, 70}, // mixed 6:1: stake 10 + 60 + {12, 25, 325}, // colored 12:1: stake 25 + 300 + {25, 50, 1300}, // perfect 25:1: stake 50 + 1250 + } + for _, c := range cases { + if got := pairsCreditFor(c.mult, c.bet); got != c.want { + t.Errorf("pairsCreditFor(%d, %d) = %d, want %d", c.mult, c.bet, got, c.want) + } + } +} diff --git a/games/bcook/blackjack/smoke.yaml b/games/bcook/blackjack/smoke.yaml index 4d46b4b..d68c06b 100644 --- a/games/bcook/blackjack/smoke.yaml +++ b/games/bcook/blackjack/smoke.yaml @@ -1,57 +1,65 @@ -seed: 42 +seed: 58 seats: 2 heartbeat: 50ms steps: - # (a) Initial table: both seats parked at the bet prompt before anyone bets. + # (a) Initial table: both seats parked at the bet prompt. The table rules now + # flank the dealer (out of the mid-felt), and the betting prompt offers the + # Left/Right Perfect Pairs axis alongside the Up/Down stake. - shot: bet - # Seat 0 raises to 50 and bets; seat 1 bets the default stake (25). Once both - # seated players have placed, a 1s grace beat elapses and the table deals. + # Seat 0 turns on a Perfect Pairs side bet (Right -> 10) and bets the default + # stake (25); seat 1 sets a larger side bet (Right Right -> 25) and bets. Once + # both seated players have placed, a 1s grace beat elapses and the table deals. - seat: 0 - - key: up + - key: right - key: space - seat: 1 + - key: right + - key: right - key: space + # (b) Both placed: each seat shows its side stake as "+pairs N" beside the bet. + - shot: sidebet + # Grace beat (1s) -> deal fires. Sweep past the staggered slide+flip deal - # animation: 6 cards at 120ms stagger, each slide 260ms + flip 240ms; the last - # card settles < 1s after the deal fires, so ~2.4s clears it comfortably. + # animation (6 cards), as the original scenario did. - advance: 1200ms - advance: 1200ms - # (b) Deal complete: dealer shows one card + hole, each seat sees its own hand - # highlighted and its own action/chips line (seat0 "YOUR TURN", seat1 waits). + # (c) Deal complete: seed 58 deals BOTH seats a pair, so Perfect Pairs pays out + # at the deal (mixed 6:1 each); seat 0 (a pair of tens) is first on turn. - shot: dealt - # (c) Seat 0 (first in join order) stands. A short sweep lets the turn pass to - # seat 1 cleanly (no dealer animation yet, since seat 1 is still to act). Views - # now differ the other way: seat1 is on turn, seat0 is resolved/waiting. + # Seat 0 splits its pair -> two hands, rendered as stacked compact lines with + # the hand on turn marked. Sweep the split deal animation before the shot. - seat: 0 + - rune: "p" + - advance: 700ms + - shot: split + + # Seat 0 stands both split hands; the turn walks hand to hand, then to seat 1. - rune: "s" - - advance: 100ms - - shot: decided + - advance: 200ms + - rune: "s" + - advance: 200ms - # Seat 1 hits its 19 — the bust hand is part of the story — which resolves the - # last live hand. Sweep the card slide so the third card is face up in shot. + # Seat 1 stands on its 20. - seat: 1 - - rune: "h" - - advance: 600ms - - shot: hit + - rune: "s" + - advance: 200ms - # Both hands resolved -> the dealer turns its hole card and draws, now paced - # deliberately: a beat on the revealed total, then each hit one unhurried card - # at a time, then a final beat. Sweep the whole reveal into the results flash. - - advance: 1600ms + # All hands resolved -> the dealer reveals its hole card and stands; sweep the + # paced reveal into the results flash. - advance: 1600ms - advance: 1600ms - advance: 1600ms - # (d) Dealer hole revealed (total/verdict centred below its cards), dealer drew - # out, settlement/payout shown per seat, each seat invited to ready up. + # (d) Settlement: per-seat result, with the Perfect Pairs win folded into each + # seat's net and the winning category shown beneath the seat. - shot: payout - # (e) Ready-up: seat 0 confirms and reads READY while the table still waits on - # seat 1; once seat 1 confirms too the round skips the flash and deals again. + # (e) Ready-up: seat 0 confirms and reads READY while the table waits on seat 1; + # once seat 1 confirms too the round skips the flash and deals again. - seat: 0 - key: space - shot: ready