From c0c3dd77d4a23ae62d968c92a0aa8a76fdfeb1ac Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Fri, 10 Jul 2026 14:01:00 +1000 Subject: [PATCH 1/2] bcook/blackjack + blackjack-challenge: hint card with the book play (?) Pressing ? (or /, where ? needs shift) toggles a per-seat hint card: on your turn a line under the dealer's cards reads the book play for the hand as it stands - "hint: HIT - hard 14 vs 10". The state is render-only and per seat, so only the player who turned hints on sees them; a HINT touch chip makes the rune reachable on deck, and the turn prompt ends with [?]hint. Each table gets advice for ITS rules (strategy.go). Blackjack is standard six-deck basic strategy (S17, DAS, late surrender), degrading correctly when the ideal cell is unavailable: a 3-card 16 vs 10 hits (surrender window passed), a broke soft 18 vs 5 stands (can't double), capped 8,8 plays as hard 16. The insurance window answers too: the book never takes insurance. Blackjack Challenge adapts the chart to the variant: no surrender cells, doubles live on 3-card hands, pairs split by point value (K+10 is a pair of 10s), and the Five Card Trick dominates 4-card hands - a soft 4-card hand can't bust so hitting is a guaranteed instant win, hard 4-card 15-and-under chases the trick, and 4-card 17 vs 9/10/A hits because ties lose. Co-Authored-By: Claude Fable 5 --- games/bcook/blackjack-challenge/game.go | 2 + games/bcook/blackjack-challenge/layout.go | 24 +++ games/bcook/blackjack-challenge/room.go | 9 + games/bcook/blackjack-challenge/strategy.go | 184 ++++++++++++++++++ .../blackjack-challenge/strategy_test.go | 112 +++++++++++ games/bcook/blackjack/game.go | 2 + games/bcook/blackjack/layout.go | 32 +++ games/bcook/blackjack/room.go | 9 + games/bcook/blackjack/strategy.go | 170 ++++++++++++++++ games/bcook/blackjack/strategy_test.go | 119 +++++++++++ 10 files changed, 663 insertions(+) create mode 100644 games/bcook/blackjack-challenge/strategy.go create mode 100644 games/bcook/blackjack-challenge/strategy_test.go create mode 100644 games/bcook/blackjack/strategy.go create mode 100644 games/bcook/blackjack/strategy_test.go diff --git a/games/bcook/blackjack-challenge/game.go b/games/bcook/blackjack-challenge/game.go index 5e3e567..08c9785 100644 --- a/games/bcook/blackjack-challenge/game.go +++ b/games/bcook/blackjack-challenge/game.go @@ -68,6 +68,8 @@ func (Game) Meta() kit.GameMeta { kit.RuneControl('p', "SPLIT/PAIRS"), // Betting only: B loops the behind bet on the focused seat. kit.RuneControl('b', "BEHIND"), + // ? toggles the hint card (the book play for the hand on turn). + kit.RuneControl('?', "HINT"), }, } } diff --git a/games/bcook/blackjack-challenge/layout.go b/games/bcook/blackjack-challenge/layout.go index 080c3e7..311176b 100644 --- a/games/bcook/blackjack-challenge/layout.go +++ b/games/bcook/blackjack-challenge/layout.go @@ -15,6 +15,7 @@ const ( feltBottom = 19 dealerRow = 4 // dealer card group occupies dealerRow..dealerRow+2 dealerValRow = 7 // dealer total / verdict, centred just below the cards + hintRow = 8 // the viewer's hint card (?): the book play for the hand on turn // The seat block sits one row higher than the dealer-only layout used to // allow: relocating the rules tagline (it now flanks the dealer) freed rows @@ -110,6 +111,7 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) { } rm.drawActionBar(f, v, active) + rm.drawHint(f, v, active) f.Text(kit.Rows-1, 1, "Esc leave", stDim) if s := rm.seats[v.AccountID]; s != nil { @@ -446,6 +448,27 @@ func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) { } } +// drawHint renders the viewer's hint card (toggled with ?): the book play for +// their hand on turn, centred on the free row under the dealer verdict, in the +// viewer's own colour — hints are per-seat state, so only the player who +// turned them on sees them. It waits out the card animations (the hand isn't +// readable until the cards have landed). +func (rm *room) drawHint(f *kit.Frame, v kit.Player, active *seat) { + s := rm.seats[v.AccountID] + if s == nil || !s.hint || rm.phase != phTurns { + return + } + if active == nil || active.p.AccountID != v.AccountID || rm.dealingActive() || len(rm.dealer) == 0 { + return + } + _, h := rm.firstUnresolved() + if h == nil { + return + } + act, why := recommend(s, h, rm.dealer[0]) + center(f, hintRow, "hint: "+act+" - "+why, stOwn) +} + // unplacedCount is how many seated players have not yet placed a bet. func (rm *room) unplacedCount() int { n := 0 @@ -470,6 +493,7 @@ func legalActions(s *seat, h *phand) string { s.bal >= h.bet && len(s.hands) < maxHands { parts = append(parts, "[P]split") } + parts = append(parts, "[?]hint") return strings.Join(parts, " ") } diff --git a/games/bcook/blackjack-challenge/room.go b/games/bcook/blackjack-challenge/room.go index 683fecd..1f5204f 100644 --- a/games/bcook/blackjack-challenge/room.go +++ b/games/bcook/blackjack-challenge/room.go @@ -102,6 +102,7 @@ type seat struct { joinOrder int result string // settlement summary for the results phase ready bool // readied up during results to skip the wait + hint bool // hint card toggled on (?): show the book play on turn } // pending names the deferred one-shot the room is waiting on, replacing the @@ -1174,6 +1175,14 @@ func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { if s == nil { return } + // ? toggles the seat's hint card in ANY phase (personal render-only state, + // so it works even while a deal animation is in flight). / rides along for + // keyboards where ? needs shift. + if in.Kind == kit.InputRune && (in.Rune == '?' || in.Rune == '/') { + s.hint = !s.hint + rm.render(r) + return + } switch rm.phase { case phBetting: // Up/Down set your own main stake; Left/Right change which seat you're diff --git a/games/bcook/blackjack-challenge/strategy.go b/games/bcook/blackjack-challenge/strategy.go new file mode 100644 index 0000000..c2ef668 --- /dev/null +++ b/games/bcook/blackjack-challenge/strategy.go @@ -0,0 +1,184 @@ +package main + +import "strconv" + +// The hint card (toggled with ?): what the book says to do with the hand on +// turn, adapted to THIS table's rules. The base is six-deck basic strategy +// (dealer stands on all 17), adjusted for the Challenge variant: +// +// - no surrender and no insurance exist here, so those cells fall back to +// their hit lines; +// - doubling is open on two OR three cards (legalActions), so the double +// cells stay live one card longer; +// - pairs split by POINT VALUE (K+10 is a pair of 10s); +// - the Five Card Trick dominates four-card hands: a soft four-card hand +// can never bust, so hitting is a GUARANTEED instant even-money win, and +// a hard four-card 15 or less wins on the spot often enough that hitting +// beats standing on every cell; +// - ties lose, which drags down standing on made hands against strong up +// cards — enough to flip the four-card hard 17 vs 9/10/A cell to HIT +// (standing loses the frequent dealer ties; a safe hit wins instantly). +const ( + sayHit = "HIT" + sayStand = "STAND" + sayDouble = "DOUBLE" + saySplit = "SPLIT" +) + +// recommend returns the book play for hand h against the dealer's face-up +// card, plus the situation it read ("hard 14 vs 10"), mirroring legalActions' +// availability rules so it never suggests a move the table would reject. +func recommend(s *seat, h *phand, up card) (act, why string) { + canDouble := !h.doubled && len(h.cards) <= 3 && s.bal >= h.bet + canSplit := len(h.cards) == 2 && h.cards[0].r.points() == h.cards[1].r.points() && + s.bal >= h.bet && len(s.hands) < maxHands + u := up.r.points() // 2..10, A = 11 + vs := " vs " + upLabel(up) + total, soft := h.cards.value() + + // Four cards: the Five Card Trick takes over (see the header note). An + // unresolved hand never holds five (a safe fifth card auto-wins). + if len(h.cards) >= 4 { + switch { + case soft: + return sayHit, "soft 4 cards - the trick is safe" + case total <= 15: + return sayHit, "4 cards - a safe hit wins" + case total == 17 && u >= 9: + return sayHit, "4-card 17" + vs + " - ties lose" + } + // 4-card 16 and made 17+ hands: fall through to the total tables. + } + + if canSplit { + if a, ok := pairPlay(h.cards[0].r.points(), h.cards[0].r == rankAce, u); ok { + return a, "pair of " + pairName(h.cards[0]) + vs + } + } + if soft { + return softPlay(total, u, canDouble), "soft " + strconv.Itoa(total) + vs + } + return hardPlay(total, u, canDouble), "hard " + strconv.Itoa(total) + vs +} + +// pairName names a split pair by what this table pairs on: rank for A-9, the +// shared point VALUE for the ten cards (K+10 reads "pair of 10s"). +func pairName(c card) string { + if c.r >= 10 { + return "10s" + } + return c.r.label() + "s" +} + +// pairPlay is the split table, keyed by the pair's point value (aces flagged +// apart from other 11-point math). ok=false hands the pair back to the total +// tables (5,5 plays as hard 10; 10-value pairs and non-split cells stand/hit +// by total). +func pairPlay(v int, aces bool, u int) (string, bool) { + switch { + case aces: + return saySplit, true + case v == 9: + if u == 7 || u >= 10 { + return sayStand, true + } + return saySplit, true + case v == 8: + return saySplit, true + case v == 7: + if u <= 7 { + return saySplit, true + } + case v == 6: + if u <= 6 { + return saySplit, true + } + case v == 4: + if u == 5 || u == 6 { + return saySplit, true + } + case v == 2 || v == 3: + if u <= 7 { + return saySplit, true + } + } + return "", false // 10s, 5s, and the chart's non-split cells: play the total +} + +// softPlay is the soft-total table (an ace still counting 11). +func softPlay(total, u int, canDouble bool) string { + switch { + case total >= 19: + return sayStand + case total == 18: + switch { + case u <= 6: + if canDouble { + return sayDouble + } + return sayStand + case u <= 8: + return sayStand + default: + return sayHit + } + case total == 17: + if canDouble && u >= 3 && u <= 6 { + return sayDouble + } + case total >= 15: // soft 15-16 + if canDouble && u >= 4 && u <= 6 { + return sayDouble + } + default: // soft 13-14 + if canDouble && (u == 5 || u == 6) { + return sayDouble + } + } + return sayHit +} + +// hardPlay is the hard-total table. The base chart's surrender cells (16 vs +// 9/10/A, 15 vs 10) read HIT here — this table has no surrender. +func hardPlay(total, u int, canDouble bool) string { + switch { + case total >= 17: + return sayStand + case total >= 13: // 13-16 + if u <= 6 { + return sayStand + } + return sayHit + case total == 12: + if u >= 4 && u <= 6 { + return sayStand + } + return sayHit + case total == 11: + if canDouble && u <= 10 { + return sayDouble + } + return sayHit + case total == 10: + if canDouble && u <= 9 { + return sayDouble + } + return sayHit + case total == 9: + if canDouble && u >= 3 && u <= 6 { + return sayDouble + } + return sayHit + default: + return sayHit + } +} + +// upLabel names the dealer's face-up card the way the chart reads: A, or the +// point value (every face reads 10). +func upLabel(up card) string { + if up.r == rankAce { + return "A" + } + return strconv.Itoa(up.r.points()) +} diff --git a/games/bcook/blackjack-challenge/strategy_test.go b/games/bcook/blackjack-challenge/strategy_test.go new file mode 100644 index 0000000..1eeb5ac --- /dev/null +++ b/games/bcook/blackjack-challenge/strategy_test.go @@ -0,0 +1,112 @@ +package main + +import ( + "strings" + "testing" + + "github.com/shellcade/kit/v2/kittest" +) + +// hintSeat builds a seat holding one hand with enough bankroll for any +// double/split, so availability defaults to the chart's ideal cells. +func hintSeat(cards ...card) (*seat, *phand) { + h := &phand{cards: cards, bet: 50} + return &seat{bal: 10000, hands: []*phand{h}}, h +} + +func TestRecommendChallengeCells(t *testing.T) { + up := func(r rank) card { return card{r, suitSpade} } + cases := []struct { + name string + cards hand + up rank + want string + why string + }{ + // Variant twists first: value pairs, no surrender, 3-card doubles. + {"K+10 pairs by value, stands", hand{{rankKing, suitHeart}, {10, suitSpade}}, 6, sayStand, "hard 20 vs 6"}, + {"K+Q splits like 8s? no - stands as 20", hand{{rankKing, suitHeart}, {rankQueen, suitSpade}}, 6, sayStand, "hard 20 vs 6"}, + {"hard 16 vs 10 hits (no surrender)", hand{{10, suitHeart}, {6, suitSpade}}, 10, sayHit, "hard 16 vs 10"}, + {"3-card 11 still doubles", hand{{2, suitHeart}, {4, suitSpade}, {5, suitClub}}, 6, sayDouble, "hard 11 vs 6"}, + // Five Card Trick cells. + {"4-card soft always hits", hand{{rankAce, suitHeart}, {2, suitSpade}, {2, suitClub}, {4, suitDiamond}}, 10, sayHit, "soft 4 cards - the trick is safe"}, + {"4-card hard 14 chases the trick", hand{{2, suitHeart}, {3, suitSpade}, {4, suitClub}, {5, suitDiamond}}, 5, sayHit, "4 cards - a safe hit wins"}, + {"4-card hard 17 vs 10 hits (ties lose)", hand{{2, suitHeart}, {3, suitSpade}, {4, suitClub}, {8, suitDiamond}}, 10, sayHit, "4-card 17 vs 10 - ties lose"}, + {"4-card hard 17 vs 6 stands", hand{{2, suitHeart}, {3, suitSpade}, {4, suitClub}, {8, suitDiamond}}, 6, sayStand, "hard 17 vs 6"}, + {"4-card hard 16 vs 5 keeps the book stand", hand{{2, suitHeart}, {3, suitSpade}, {4, suitClub}, {7, suitDiamond}}, 5, sayStand, "hard 16 vs 5"}, + // Shared basic-strategy spine. + {"aces always split", hand{{rankAce, suitHeart}, {rankAce, suitSpade}}, 10, saySplit, "pair of As vs 10"}, + {"eights always split", hand{{8, suitHeart}, {8, suitSpade}}, 10, saySplit, "pair of 8s vs 10"}, + {"soft 18 doubles vs 5", hand{{rankAce, suitHeart}, {7, suitSpade}}, 5, sayDouble, "soft 18 vs 5"}, + {"hard 12 vs 4 stands", hand{{10, suitHeart}, {2, suitSpade}}, 4, sayStand, "hard 12 vs 4"}, + } + for _, c := range cases { + s, h := hintSeat(c.cards...) + act, why := recommend(s, h, up(c.up)) + if act != c.want || why != c.why { + t.Errorf("%s: recommend = %s (%s), want %s (%s)", c.name, act, why, c.want, c.why) + } + } +} + +// TestRecommendDegradesWithAvailability asserts the fallback rules: the ideal +// cell is only suggested while the table would actually accept it. +func TestRecommendDegradesWithAvailability(t *testing.T) { + up := func(r rank) card { return card{r, suitSpade} } + + // A fourth card closes the double (challenge doubles on 2-3 cards only): + // hard 11 with four cards chases the trick instead. + s, h := hintSeat(card{2, suitHeart}, card{2, suitSpade}, card{3, suitClub}, card{4, suitDiamond}) + if act, why := recommend(s, h, up(6)); act != sayHit || why != "4 cards - a safe hit wins" { + t.Fatalf("4-card 11 vs 6 = %s (%s), want the trick HIT", act, why) + } + + // A thin bankroll closes the double: soft 18 vs 5 falls back to STAND. + s, h = hintSeat(card{rankAce, suitHeart}, card{7, suitSpade}) + s.bal = 0 + if act, _ := recommend(s, h, up(5)); act != sayStand { + t.Fatalf("broke soft 18 vs 5 = %s, want STAND (can't double)", act) + } + + // The hand cap closes the split: 8,8 at maxHands plays as hard 16. + s, h = hintSeat(card{8, suitHeart}, card{8, suitSpade}) + for len(s.hands) < maxHands { + s.hands = append(s.hands, &phand{cards: hand{{2, suitClub}, {3, suitClub}}, bet: 50}) + } + if act, _ := recommend(s, h, up(6)); act != sayStand { + t.Fatalf("capped 8,8 vs 6 = %s, want STAND (plays as hard 16)", act) + } +} + +// TestHintToggleAndRender asserts ? flips the seat's hint card and the book +// line renders for the active viewer on their turn. +func TestHintToggleAndRender(t *testing.T) { + a := mkPlayer("alice") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + rm.OnInput(tr, a, runeInput('?')) + if !s.hint { + t.Fatal("? did not toggle the hint card on") + } + // Hand-craft a turn: hard 16 vs the dealer's face-up 10 says HIT here + // (this table has no surrender). + s.placed = true + s.hands = []*phand{{cards: hand{{10, suitHeart}, {6, suitSpade}}, bet: 50}} + rm.dealer = hand{{10, suitClub}} + rm.phase = phTurns + rm.render(tr) + row := kittest.String(tr.LastFrame(a), hintRow) + if !strings.Contains(row, "hint: HIT - hard 16 vs 10") { + t.Fatalf("hint line not rendered on row %d: %q", hintRow, row) + } + // Toggling off clears it. + rm.OnInput(tr, a, runeInput('/')) // the unshifted alias + if s.hint { + t.Fatal("/ did not toggle the hint card off") + } + rm.render(tr) + if row := kittest.String(tr.LastFrame(a), hintRow); strings.Contains(row, "hint:") { + t.Fatalf("hint line still rendered after toggle-off: %q", row) + } +} diff --git a/games/bcook/blackjack/game.go b/games/bcook/blackjack/game.go index 49c2660..f33bc46 100644 --- a/games/bcook/blackjack/game.go +++ b/games/bcook/blackjack/game.go @@ -71,6 +71,8 @@ func (Game) Meta() kit.GameMeta { // Betting only: B loops the behind bet on the focused seat (Left/Right // pick the seat); P (above) loops that seat's Perfect Pairs. kit.RuneControl('b', "BEHIND"), + // ? toggles the hint card (the book play for the hand on turn). + kit.RuneControl('?', "HINT"), }, } } diff --git a/games/bcook/blackjack/layout.go b/games/bcook/blackjack/layout.go index fc7d46b..324ed6d 100644 --- a/games/bcook/blackjack/layout.go +++ b/games/bcook/blackjack/layout.go @@ -15,6 +15,7 @@ const ( feltBottom = 19 dealerRow = 4 // dealer card group occupies dealerRow..dealerRow+2 dealerValRow = 7 // dealer total / verdict, centred just below the cards + hintRow = 8 // the viewer's hint card (?): the book play for the hand on turn // The seat block sits one row higher than the dealer-only layout used to // allow: relocating the rules tagline (it now flanks the dealer) freed rows @@ -110,6 +111,7 @@ func (rm *room) compose(f *kit.Frame, v kit.Player) { } rm.drawActionBar(f, v, active) + rm.drawHint(f, v, active) f.Text(kit.Rows-1, 1, "Esc leave", stDim) if s := rm.seats[v.AccountID]; s != nil { @@ -469,6 +471,35 @@ func (rm *room) drawActionBar(f *kit.Frame, v kit.Player, active *seat) { } } +// drawHint renders the viewer's hint card (toggled with ?): the book play for +// their hand on turn, centred on the free row under the dealer verdict, in the +// viewer's own colour — hints are per-seat state, so only the player who +// turned them on sees them. It waits out the card animations (the hand isn't +// readable until the cards have landed) and also covers the insurance +// decision, where the book's answer never varies. +func (rm *room) drawHint(f *kit.Frame, v kit.Player, active *seat) { + s := rm.seats[v.AccountID] + if s == nil || !s.hint { + return + } + switch rm.phase { + case phTurns: + if active == nil || active.p.AccountID != v.AccountID || rm.dealingActive() || len(rm.dealer) == 0 { + return + } + _, h := rm.firstUnresolved() + if h == nil { + return + } + act, why := recommend(s, h, rm.dealer[0]) + center(f, hintRow, "hint: "+act+" - "+why, stOwn) + case phInsurance: + if s.placed && !s.insuranceDecided { + center(f, hintRow, "hint: NO - insurance loses money in the long run", stOwn) + } + } +} + // unplacedCount is how many seated players have not yet placed a bet. func (rm *room) unplacedCount() int { n := 0 @@ -511,6 +542,7 @@ func legalActions(s *seat, h *phand) string { if first && len(s.hands) == 1 { parts = append(parts, "[R]surrender") } + parts = append(parts, "[?]hint") return strings.Join(parts, " ") } diff --git a/games/bcook/blackjack/room.go b/games/bcook/blackjack/room.go index d3240a8..cfcc2a6 100644 --- a/games/bcook/blackjack/room.go +++ b/games/bcook/blackjack/room.go @@ -103,6 +103,7 @@ type seat struct { joinOrder int result string // settlement summary for the results phase ready bool // readied up during results to skip the wait + hint bool // hint card toggled on (?): show the book play on turn } // pending names the deferred one-shot the room is waiting on, replacing the @@ -1292,6 +1293,14 @@ func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { if s == nil { return } + // ? toggles the seat's hint card in ANY phase (personal render-only state, + // so it works even while a deal animation is in flight). / rides along for + // keyboards where ? needs shift. + if in.Kind == kit.InputRune && (in.Rune == '?' || in.Rune == '/') { + s.hint = !s.hint + rm.render(r) + return + } switch rm.phase { case phBetting: // Up/Down set your own main stake; Left/Right change which seat you're diff --git a/games/bcook/blackjack/strategy.go b/games/bcook/blackjack/strategy.go new file mode 100644 index 0000000..f53d9c2 --- /dev/null +++ b/games/bcook/blackjack/strategy.go @@ -0,0 +1,170 @@ +package main + +import "strconv" + +// The hint card (toggled with ?): what the book says to do with the hand on +// turn. The table is standard six-deck basic strategy for THIS table's rules — +// dealer stands on all 17, double after split allowed, late surrender offered +// — and recommend degrades correctly when the ideal action is unavailable (no +// double after a hit, no surrender past the first decision, no split past the +// hand cap or the bankroll). + +const ( + sayHit = "HIT" + sayStand = "STAND" + sayDouble = "DOUBLE" + saySplit = "SPLIT" + saySurrender = "SURRENDER" +) + +// recommend returns the book play for hand h against the dealer up card, plus +// the situation it read ("hard 14 vs 10"), mirroring legalActions' availability +// rules so it never suggests a move the table would reject. +func recommend(s *seat, h *phand, up card) (act, why string) { + first := len(h.cards) == 2 && !h.doubled + canDouble := first && s.bal >= h.bet + canSplit := first && h.cards[0].r == h.cards[1].r && s.bal >= h.bet && len(s.hands) < maxHands + canSurrender := first && len(s.hands) == 1 + u := up.r.points() // 2..10, A = 11 + vs := " vs " + upLabel(up) + + if canSplit { + if a, ok := pairPlay(h.cards[0].r.points(), h.cards[0].r == rankAce, u); ok { + return a, "pair of " + h.cards[0].r.label() + "s" + vs + } + } + + total, soft := h.cards.value() + if soft { + return softPlay(total, u, canDouble), "soft " + strconv.Itoa(total) + vs + } + return hardPlay(total, u, canDouble, canSurrender), "hard " + strconv.Itoa(total) + vs +} + +// pairPlay is the split table, keyed by the pair's point value (aces flagged +// apart from other 11-point math). ok=false hands the pair back to the total +// tables (5,5 plays as hard 10; 10s and off-chart pairs stand/hit by total). +func pairPlay(v int, aces bool, u int) (string, bool) { + switch { + case aces: + return saySplit, true + case v == 9: + if u == 7 || u >= 10 { + return sayStand, true + } + return saySplit, true + case v == 8: + return saySplit, true + case v == 7: + if u <= 7 { + return saySplit, true + } + case v == 6: + if u <= 6 { + return saySplit, true + } + case v == 4: + if u == 5 || u == 6 { + return saySplit, true + } + case v == 2 || v == 3: + if u <= 7 { + return saySplit, true + } + } + return "", false // 10s, 5s, and the chart's non-split cells: play the total +} + +// softPlay is the soft-total table (an ace still counting 11). +func softPlay(total, u int, canDouble bool) string { + switch { + case total >= 19: + return sayStand + case total == 18: + switch { + case u <= 6: + if canDouble { + return sayDouble + } + return sayStand + case u <= 8: + return sayStand + default: + return sayHit + } + case total == 17: + if canDouble && u >= 3 && u <= 6 { + return sayDouble + } + case total >= 15: // soft 15-16 + if canDouble && u >= 4 && u <= 6 { + return sayDouble + } + default: // soft 13-14 + if canDouble && (u == 5 || u == 6) { + return sayDouble + } + } + return sayHit +} + +// hardPlay is the hard-total table, with the late-surrender cells falling back +// to their hit lines once the surrender window has passed. +func hardPlay(total, u int, canDouble, canSurrender bool) string { + switch { + case total >= 17: + return sayStand + case total == 16: + if u >= 9 && canSurrender { + return saySurrender + } + if u <= 6 { + return sayStand + } + return sayHit + case total == 15: + if u == 10 && canSurrender { + return saySurrender + } + if u <= 6 { + return sayStand + } + return sayHit + case total >= 13: // 13-14 + if u <= 6 { + return sayStand + } + return sayHit + case total == 12: + if u >= 4 && u <= 6 { + return sayStand + } + return sayHit + case total == 11: + if canDouble && u <= 10 { + return sayDouble + } + return sayHit + case total == 10: + if canDouble && u <= 9 { + return sayDouble + } + return sayHit + case total == 9: + if canDouble && u >= 3 && u <= 6 { + return sayDouble + } + return sayHit + default: + return sayHit + } +} + +// upLabel names the dealer up card the way the chart reads: A, or the point +// value (every face reads 10). +func upLabel(up card) string { + if up.r == rankAce { + return "A" + } + return strconv.Itoa(up.r.points()) +} diff --git a/games/bcook/blackjack/strategy_test.go b/games/bcook/blackjack/strategy_test.go new file mode 100644 index 0000000..a26185e --- /dev/null +++ b/games/bcook/blackjack/strategy_test.go @@ -0,0 +1,119 @@ +package main + +import ( + "strings" + "testing" + + "github.com/shellcade/kit/v2/kittest" +) + +// hintSeat builds a seat holding one hand with enough bankroll for any +// double/split, so availability defaults to the chart's ideal cells. +func hintSeat(cards ...card) (*seat, *phand) { + h := &phand{cards: cards, bet: 50} + return &seat{bal: 10000, hands: []*phand{h}}, h +} + +func TestRecommendBasicStrategyCells(t *testing.T) { + up := func(r rank) card { return card{r, suitSpade} } + cases := []struct { + name string + cards hand + up rank + want string + why string + }{ + {"aces always split", hand{{rankAce, suitHeart}, {rankAce, suitSpade}}, 6, saySplit, "pair of As vs 6"}, + {"eights always split", hand{{8, suitHeart}, {8, suitSpade}}, 10, saySplit, "pair of 8s vs 10"}, + {"nines stand vs seven", hand{{9, suitHeart}, {9, suitSpade}}, 7, sayStand, "pair of 9s vs 7"}, + {"tens never split", hand{{rankKing, suitHeart}, {rankKing, suitSpade}}, 6, sayStand, "hard 20 vs 6"}, + {"fives play as hard ten", hand{{5, suitHeart}, {5, suitSpade}}, 6, sayDouble, "hard 10 vs 6"}, + {"hard 16 vs 10 surrenders", hand{{10, suitHeart}, {6, suitSpade}}, 10, saySurrender, "hard 16 vs 10"}, + {"hard 15 vs 10 surrenders", hand{{10, suitHeart}, {5, suitSpade}}, 10, saySurrender, "hard 15 vs 10"}, + {"hard 16 vs 6 stands", hand{{10, suitHeart}, {6, suitSpade}}, 6, sayStand, "hard 16 vs 6"}, + {"hard 12 vs 2 hits", hand{{10, suitHeart}, {2, suitSpade}}, 2, sayHit, "hard 12 vs 2"}, + {"hard 12 vs 4 stands", hand{{10, suitHeart}, {2, suitSpade}}, 4, sayStand, "hard 12 vs 4"}, + {"eleven doubles vs 10", hand{{6, suitHeart}, {5, suitSpade}}, 10, sayDouble, "hard 11 vs 10"}, + {"eleven hits vs ace", hand{{6, suitHeart}, {5, suitSpade}}, rankAce, sayHit, "hard 11 vs A"}, + {"soft 18 doubles vs 5", hand{{rankAce, suitHeart}, {7, suitSpade}}, 5, sayDouble, "soft 18 vs 5"}, + {"soft 18 stands vs 8", hand{{rankAce, suitHeart}, {7, suitSpade}}, 8, sayStand, "soft 18 vs 8"}, + {"soft 18 hits vs 10", hand{{rankAce, suitHeart}, {7, suitSpade}}, 10, sayHit, "soft 18 vs 10"}, + {"soft 17 doubles vs 4", hand{{rankAce, suitHeart}, {6, suitSpade}}, 4, sayDouble, "soft 17 vs 4"}, + {"faces read as 10", hand{{rankQueen, suitHeart}, {7, suitSpade}}, rankJack, sayStand, "hard 17 vs 10"}, + } + for _, c := range cases { + s, h := hintSeat(c.cards...) + act, why := recommend(s, h, up(c.up)) + if act != c.want || why != c.why { + t.Errorf("%s: recommend = %s (%s), want %s (%s)", c.name, act, why, c.want, c.why) + } + } +} + +// TestRecommendDegradesWithAvailability asserts the fallback rules: the ideal +// cell is only suggested while the table would actually accept it. +func TestRecommendDegradesWithAvailability(t *testing.T) { + up := func(r rank) card { return card{r, suitSpade} } + + // A third card closes doubling and surrendering: hard 16 vs 10 hits. + s, h := hintSeat(card{10, suitHeart}, card{4, suitSpade}, card{2, suitClub}) + if act, _ := recommend(s, h, up(10)); act != sayHit { + t.Fatalf("3-card 16 vs 10 = %s, want HIT (surrender window passed)", act) + } + + // A thin bankroll closes the double: soft 18 vs 5 falls back to STAND. + s, h = hintSeat(card{rankAce, suitHeart}, card{7, suitSpade}) + s.bal = 0 + if act, _ := recommend(s, h, up(5)); act != sayStand { + t.Fatalf("broke soft 18 vs 5 = %s, want STAND (can't double)", act) + } + + // Split hands (2+) lose the surrender cell: hard 16 vs 10 hits. + s, h = hintSeat(card{10, suitHeart}, card{6, suitSpade}) + s.hands = append(s.hands, &phand{cards: hand{{2, suitClub}, {3, suitClub}}, bet: 50}) + if act, _ := recommend(s, h, up(10)); act != sayHit { + t.Fatalf("split-table 16 vs 10 = %s, want HIT (no surrender)", act) + } + + // The hand cap closes the split: 8,8 at maxHands plays as hard 16. + s, h = hintSeat(card{8, suitHeart}, card{8, suitSpade}) + for len(s.hands) < maxHands { + s.hands = append(s.hands, &phand{cards: hand{{2, suitClub}, {3, suitClub}}, bet: 50}) + } + if act, _ := recommend(s, h, up(6)); act != sayStand { + t.Fatalf("capped 8,8 vs 6 = %s, want STAND (plays as hard 16)", act) + } +} + +// TestHintToggleAndRender asserts ? flips the seat's hint card and the book +// line renders for the active viewer on their turn. +func TestHintToggleAndRender(t *testing.T) { + a := mkPlayer("alice") + rm, tr := newGame(t, a) + rm.OnJoin(tr, a) + s := rm.seats[a.AccountID] + rm.OnInput(tr, a, runeInput('?')) + if !s.hint { + t.Fatal("? did not toggle the hint card on") + } + // Hand-craft a turn: hard 14 vs dealer 10 says HIT. + s.placed = true + s.hands = []*phand{{cards: hand{{10, suitHeart}, {4, suitSpade}}, bet: 50}} + rm.dealer = hand{{10, suitClub}, {7, suitDiamond}} + rm.dealerHole = true + rm.phase = phTurns + rm.render(tr) + row := kittest.String(tr.LastFrame(a), hintRow) + if !strings.Contains(row, "hint: HIT - hard 14 vs 10") { + t.Fatalf("hint line not rendered on row %d: %q", hintRow, row) + } + // Toggling off clears it. + rm.OnInput(tr, a, runeInput('/')) // the unshifted alias + if s.hint { + t.Fatal("/ did not toggle the hint card off") + } + rm.render(tr) + if row := kittest.String(tr.LastFrame(a), hintRow); strings.Contains(row, "hint:") { + t.Fatalf("hint line still rendered after toggle-off: %q", row) + } +} From d84ad65d92b89d9406575393af9ddd4fc881512c Mon Sep 17 00:00:00 2001 From: Brandon Cook Date: Fri, 10 Jul 2026 14:03:16 +1000 Subject: [PATCH 2/2] blackjack + blackjack-challenge: smoke-shot the hint card Both scripts flip the hint card on right after the deal and capture it: the seeded hands read "hint: STAND - hard 20 vs 6" (the pair of tens plays as its total) and "hint: SPLIT - pair of 8s vs 4", verified by native replays of both seeds. The card toggles back off after the shot so every frame that follows is unchanged. Co-Authored-By: Claude Fable 5 --- games/bcook/blackjack-challenge/smoke.yaml | 8 ++++++++ games/bcook/blackjack/smoke.yaml | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/games/bcook/blackjack-challenge/smoke.yaml b/games/bcook/blackjack-challenge/smoke.yaml index 87b72c0..81e8281 100644 --- a/games/bcook/blackjack-challenge/smoke.yaml +++ b/games/bcook/blackjack-challenge/smoke.yaml @@ -44,6 +44,14 @@ steps: # turn with [P]split offered on the pair. - shot: dealt + # Seat 0 flips its hint card on (?): the book play for the hand as it stands + # reads under the dealer's card ("hint: SPLIT - pair of 8s vs 4"). Toggled + # back off after the shot so the frames that follow are unchanged. + - seat: 0 + - rune: "?" + - shot: hint + - rune: "?" + # Seat 0 splits its eights -> two hands stacked, each drawing a second card # (split is by POINT VALUE on this table, so K+10 would split too); the hand # on turn is marked. diff --git a/games/bcook/blackjack/smoke.yaml b/games/bcook/blackjack/smoke.yaml index cb88566..79bb8c8 100644 --- a/games/bcook/blackjack/smoke.yaml +++ b/games/bcook/blackjack/smoke.yaml @@ -37,6 +37,15 @@ steps: # back-pairs on seat 1 pay too; seat 0 (a pair of tens) is first on turn. - shot: dealt + # Seat 0 flips its hint card on (?): the book play for the hand as it stands + # reads under the dealer's cards - the pair of tens plays as its total + # ("hint: STAND - hard 20 vs 6"). Toggled back off after the shot so the + # frames that follow are unchanged. + - seat: 0 + - rune: "?" + - shot: hint + - rune: "?" + # Seat 0 splits its pair -> two hands stacked, the hand on turn marked. - seat: 0 - rune: "p"