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
2 changes: 2 additions & 0 deletions games/bcook/blackjack-challenge/game.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
}
}
Expand Down
24 changes: 24 additions & 0 deletions games/bcook/blackjack-challenge/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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, " ")
}

Expand Down
9 changes: 9 additions & 0 deletions games/bcook/blackjack-challenge/room.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions games/bcook/blackjack-challenge/smoke.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
184 changes: 184 additions & 0 deletions games/bcook/blackjack-challenge/strategy.go
Original file line number Diff line number Diff line change
@@ -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())
}
112 changes: 112 additions & 0 deletions games/bcook/blackjack-challenge/strategy_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading