aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/blackjack_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/casino/blackjack_test.go')
-rw-r--r--internal/casino/blackjack_test.go233
1 files changed, 233 insertions, 0 deletions
diff --git a/internal/casino/blackjack_test.go b/internal/casino/blackjack_test.go
new file mode 100644
index 0000000..e8381fb
--- /dev/null
+++ b/internal/casino/blackjack_test.go
@@ -0,0 +1,233 @@
+package casino
+
+import (
+ "math/rand"
+ "strings"
+ "testing"
+)
+
+func TestBlackjackHandTreatsAceAsElevenWhenPossible(t *testing.T) {
+ hand := &blackjackHand{cards: []blackjackCard{{rank: "A"}, {rank: "6"}}}
+ total, soft := hand.total()
+ if total != 17 || !soft {
+ t.Fatalf("total=%d soft=%v, want soft 17", total, soft)
+ }
+ hand.cards = append(hand.cards, blackjackCard{rank: "K"})
+ total, soft = hand.total()
+ if total != 17 || soft {
+ t.Fatalf("total=%d soft=%v, want hard 17", total, soft)
+ }
+}
+
+func TestBlackjackSettlementRules(t *testing.T) {
+ rules := (BlackjackConfig{}).Normalize()
+ natural := &blackjackHand{bet: 10, cards: []blackjackCard{{rank: "A"}, {rank: "K"}}}
+ if result, payout := settleHand(natural, 20, false, rules); result != "blackjack" || payout != 25 {
+ t.Fatalf("natural result=%s payout=%d", result, payout)
+ }
+ dealerNatural := &blackjackHand{bet: 10, cards: []blackjackCard{{rank: "A"}, {rank: "Q"}}}
+ if result, payout := settleHand(dealerNatural, 21, true, rules); result != "push" || payout != 10 {
+ t.Fatalf("dealer natural result=%s payout=%d", result, payout)
+ }
+ if result, payout := settleHand(&blackjackHand{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}, 21, true, rules); result != "dealer blackjack" || payout != 0 {
+ t.Fatalf("dealer blackjack result=%s payout=%d", result, payout)
+ }
+ if result, payout := settleHand(&blackjackHand{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}, 22, false, rules); result != "win" || payout != 20 {
+ t.Fatalf("dealer bust result=%s payout=%d", result, payout)
+ }
+}
+
+func TestBlackjackTableStartsAfterAllPlayersBet(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2}, newTestRand())
+ table.join("alice")
+ table.join("bob")
+ wa, wb := &testWallet{credits: 100}, &testWallet{credits: 100}
+ if events := table.bet("alice", 10, wa); hasMessage(events, "round begins") {
+ t.Fatal("round started before all players bet")
+ }
+ events := table.bet("bob", 10, wb)
+ if !hasMessage(events, "New blackjack hand") {
+ t.Fatalf("round did not start after all players bet: %+v", events)
+ }
+}
+
+func TestSoloBlackjackTurnDoesNotTimeout(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, Blackjack: BlackjackConfig{ActionTicks: 1}}, newTestRand())
+ table.participants["alice"] = &blackjackPlayer{
+ Participant: Participant{Name: "alice", HasBet: true, Connected: true},
+ hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "6"}}}},
+ }
+ table.order = []string{"alice"}
+ table.phase = blackjackPlaying
+ if events := table.tick(); len(events) != 0 {
+ t.Fatalf("solo action timed out: %+v", events)
+ }
+ if table.phase != blackjackPlaying {
+ t.Fatalf("solo table phase changed to %s", table.phase)
+ }
+}
+
+func TestBlackjackRebetKeepsLastWager(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MinBet: 5, MaxBet: 100}, newTestRand())
+ table.participants["alice"] = &blackjackPlayer{Participant: Participant{Name: "alice"}, lastBet: 25}
+ if got := table.rebetAmount("alice"); got != 25 {
+ t.Fatalf("rebet=%d, want 25", got)
+ }
+}
+
+func TestBlackjackPromptNamesActiveHandAndOnlyOffersAvailableActions(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ table.participants["alice"] = &blackjackPlayer{Participant: Participant{Name: "alice", HasBet: true, Connected: true}, hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "K"}, {rank: "6"}}}}}
+ table.order = []string{"alice"}
+ table.activePlayer = 0
+ table.activeHand = 0
+ table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}}
+ event := table.prompt()[0]
+ if event.Message != "Your turn (K,6).\nActions: hit, stand, double" {
+ t.Fatalf("prompt=%q", event.Message)
+ }
+ if event.Menu == nil || event.Menu.Kind != MenuBlackjackTurn || event.Menu.Title != "Your turn (K,6)" ||
+ len(event.Menu.Actions) != 3 || event.Menu.Actions[0] != "hit" || event.Menu.Actions[1] != "stand" || event.Menu.Actions[2] != "double" {
+ t.Fatalf("prompt menu=%+v", event.Menu)
+ }
+}
+
+func TestBlackjackMenuEventCarriesStructuredMenu(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ event := table.menuEvent("alice")
+ if event.Menu == nil || event.Menu.Kind != MenuBlackjackBet {
+ t.Fatalf("menu event=%+v", event.Menu)
+ }
+}
+
+func TestBlackjackInsurancePaysOnDealerBlackjack(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ w := &testWallet{credits: 100}
+ table.participants["alice"] = &blackjackPlayer{
+ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}},
+ hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}},
+ }
+ table.order = []string{"alice"}
+ table.dealer.cards = []blackjackCard{{rank: "K"}, {rank: "A"}}
+ table.phase = blackjackInsurance
+ events := table.action("alice", "insurance")
+ if w.credits != 95 {
+ t.Fatalf("insurance stake credits=%d, want 95", w.credits)
+ }
+ if w.paid != 15 {
+ t.Fatalf("insurance payout=%d, want 15 (stake + 2x)", w.paid)
+ }
+ if !hasMessage(events, "Insurance pays 15 credits.") {
+ t.Fatalf("missing insurance payout message: %+v", events)
+ }
+ if table.phase != blackjackWaiting {
+ t.Fatalf("phase=%s, want waiting after dealer blackjack settles", table.phase)
+ }
+}
+
+func TestBlackjackInsuranceDeclineContinuesRound(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ w := &testWallet{credits: 100}
+ table.participants["alice"] = &blackjackPlayer{
+ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}},
+ hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}},
+ }
+ table.order = []string{"alice"}
+ table.dealer.cards = []blackjackCard{{rank: "9"}, {rank: "A"}}
+ table.phase = blackjackInsurance
+ events := table.action("alice", "stand")
+ if !hasMessage(events, "You decline insurance.") {
+ t.Fatalf("missing decline message: %+v", events)
+ }
+ if table.phase != blackjackPlaying {
+ t.Fatalf("phase=%s, want playing after insurance declined", table.phase)
+ }
+ if !hasMessage(events, "Your turn (10,8).") {
+ t.Fatalf("round did not continue after insurance: %+v", events)
+ }
+}
+
+func TestBlackjackDoubleSetsBust(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ // decks 0 disables the shuffle threshold so the forced card is drawn.
+ table.shoe = &blackjackShoe{cards: []blackjackCard{{rank: "K"}}}
+ w := &testWallet{credits: 100}
+ table.participants["alice"] = &blackjackPlayer{
+ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}},
+ hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "6"}}}},
+ }
+ table.order = []string{"alice"}
+ table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}}
+ table.phase = blackjackPlaying
+ hand := table.participants["alice"].hands[0]
+ events := table.action("alice", "double")
+ if !hand.bust {
+ t.Fatal("doubling into 26 did not set the bust flag")
+ }
+ if !hasMessage(events, "loses") {
+ t.Fatalf("busted double did not settle as a loss: %+v", events)
+ }
+}
+
+func TestBlackjackSplitAcesOnlyAllowResplit(t *testing.T) {
+ resplit := true
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, Blackjack: BlackjackConfig{ResplitAces: &resplit}}, newTestRand())
+ w := &testWallet{credits: 100}
+ table.participants["alice"] = &blackjackPlayer{
+ Participant: Participant{Name: "alice", HasBet: true, Connected: true, Wallet: w, Wager: Wager{Total: 10}},
+ hands: []*blackjackHand{{bet: 10, fromSplit: true, cards: []blackjackCard{{rank: "A"}, {rank: "A"}}}},
+ }
+ table.order = []string{"alice"}
+ table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}}
+ table.phase = blackjackPlaying
+ p := table.participants["alice"]
+ actions := table.availableActions(p, p.hands[0])
+ if len(actions) != 2 || actions[0] != "stand" || actions[1] != "split" {
+ t.Fatalf("split ace actions=%v, want [stand split]", actions)
+ }
+ if events := table.action("alice", "hit"); !hasMessage(events, "Split aces cannot be hit.") {
+ t.Fatalf("hit on split aces not rejected: %+v", events)
+ }
+}
+
+func TestBlackjackBettingIgnoresDisconnected(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack, MaxPlayers: 2}, newTestRand())
+ table.join("alice")
+ table.join("bob")
+ table.markDisconnected("bob")
+ w := &testWallet{credits: 100}
+ events := table.bet("alice", 10, w)
+ if !hasMessage(events, "New blackjack hand") {
+ t.Fatalf("disconnected player stalled the round: %+v", events)
+ }
+}
+
+func TestBlackjackReconnectShowsTableSnapshot(t *testing.T) {
+ table := newBlackjackTable(1, TableConfig{ID: "blackjack", Game: GameBlackjack}, newTestRand())
+ table.participants["alice"] = &blackjackPlayer{
+ Participant: Participant{Name: "alice", HasBet: true, Connected: true},
+ hands: []*blackjackHand{{bet: 10, cards: []blackjackCard{{rank: "10"}, {rank: "8"}}}},
+ }
+ table.order = []string{"alice"}
+ table.dealer.cards = []blackjackCard{{rank: "10"}, {rank: "7"}}
+ table.phase = blackjackPlaying
+ events := table.join("alice")
+ if len(events) != 1 || events[0].Blackjack == nil {
+ t.Fatalf("reconnect during play lacked a table snapshot: %+v", events)
+ }
+}
+
+func hasMessage(events []Event, fragment string) bool {
+ for _, event := range events {
+ if contains(event.Message, fragment) || contains(event.PrivateMessage, fragment) {
+ return true
+ }
+ }
+ return false
+}
+
+func contains(value, fragment string) bool {
+ return strings.Contains(value, fragment)
+}
+
+func newTestRand() *rand.Rand { return rand.New(rand.NewSource(7)) }