1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
|
package casino
// Casino money-movement regressions: shutdown/eviction refunds cover every
// committed stake (bet, double, split, insurance) exactly once, completed
// spins are not refunded, failed payouts are never announced as paid, and
// the card shoe reshuffles between rounds only.
import (
"strings"
"testing"
)
// failingPayoutWallet rejects every payout with a persistence failure.
type failingPayoutWallet struct {
testWallet
}
func (w *failingPayoutWallet) PayCredits(int) error { return ErrWalletPersistence }
// TestBlackjackRefundCoversAllStakes: splitting an eligible pair commits a
// second stake; the shutdown refund must return both, and a completed round
// (stakes resolved) must not be refunded at all.
func TestBlackjackRefundCoversAllStakes(t *testing.T) {
table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand())
table.shoe = tableauShoe(
card{rank: "10"}, card{rank: "7"}, // dealer
card{rank: "8"}, card{rank: "8"}, // alice's splittable pair
card{rank: "2"}, card{rank: "3"}, // split draws
)
wallet := &testWallet{credits: 100}
table.join("alice")
table.bet("alice", "", 10, wallet)
table.action("alice", "split")
if wallet.credits != 80 {
t.Fatalf("setup: split did not commit a second stake (credits=%d)", wallet.credits)
}
// The round is in flight: the refund must return every committed stake.
table.refundAll()
if total := wallet.credits + wallet.paid; total != 100 {
t.Fatalf("split-then-refund returned %d of 100 committed credits", total)
}
// A refund must be idempotent: the stakes are resolved after the first.
table.refundAll()
if total := wallet.credits + wallet.paid; total != 100 {
t.Fatalf("second refund double-paid: %d of 100", total)
}
}
// TestBlackjackDoubleRefundCoversStake: doubling commits a second stake that
// the shutdown refund must return while the round is still in flight.
func TestBlackjackDoubleRefundCoversStake(t *testing.T) {
table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand())
table.shoe = tableauShoe(
card{rank: "10"}, card{rank: "7"}, // dealer: 17
card{rank: "5"}, card{rank: "6"}, // alice: 11, doubles to 15
card{rank: "10"}, card{rank: "6"}, // bob: 16, still to act
card{rank: "4"}, // alice's double draw: 15, stands
)
walletA := &testWallet{credits: 100}
walletB := &testWallet{credits: 100}
table.join("alice")
table.join("bob")
table.bet("alice", "", 10, walletA)
table.bet("bob", "", 10, walletB)
table.action("alice", "double")
if walletA.credits != 80 {
t.Fatalf("setup: double did not commit a second stake (credits=%d)", walletA.credits)
}
// The round is still in flight (bob has not acted).
table.refundAll()
if got := walletA.credits + walletA.paid; got != 100 {
t.Fatalf("alice's double-then-refund returned %d of 100", got)
}
if got := walletB.credits + walletB.paid; got != 100 {
t.Fatalf("bob's refund returned %d of 100", got)
}
}
// TestSlotsRefundOnlyOutstandingStake: a completed losing spin has no
// outstanding stake; refunding a seated player must not pay it again. An
// in-flight spin's stake is refunded exactly once.
func TestSlotsRefundOnlyOutstandingStake(t *testing.T) {
machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand())
wallet := &testWallet{credits: 100}
machine.join("alice")
machine.setBet("alice", 10, wallet)
machine.spin = &SlotSpin{} // deterministic losing spin
for i := 0; i < 20 && machine.phase != machineReady; i++ {
machine.tick()
}
if machine.phase != machineReady || wallet.paid != 0 {
t.Fatal("setup: the spin must have ended without payout")
}
// The completed spin's stake was resolved with the round: no refund.
machine.refundAll()
if wallet.paid != 0 {
t.Fatalf("completed losing spin refunded %d credits at shutdown; want 0", wallet.paid)
}
// An in-flight spin's stake IS refunded.
wallet2 := &testWallet{credits: 100}
machine2 := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 50}, newTestRand())
machine2.join("alice")
machine2.setBet("alice", 10, wallet2)
machine2.refundAll()
if got := wallet2.credits + wallet2.paid; got != 100 {
t.Fatalf("in-flight spin refund returned %d of 100", got)
}
}
// TestBlackjackFailedPayoutNotAnnouncedAsPaid: when the wallet cannot make
// the payout durable, the table must not tell the player they were paid.
func TestBlackjackFailedPayoutNotAnnouncedAsPaid(t *testing.T) {
table := newBlackjackTable(1, TableConfig{ID: "bj", Game: GameBlackjack}, newTestRand())
table.shoe = tableauShoe(
card{rank: "10"}, card{rank: "7"}, // dealer: 17
card{rank: "10"}, card{rank: "8"}, // alice: 18, stands, wins
)
wallet := &failingPayoutWallet{testWallet{credits: 100}}
table.join("alice")
table.bet("alice", "", 10, wallet)
events := table.action("alice", "stand")
for _, e := range events {
if strings.Contains(e.PrivateMessage, "You win! +") {
t.Fatalf("announced a successful payout after the wallet rejected it: %q", e.PrivateMessage)
}
if strings.Contains(e.PrivateMessage, "persistence failure") {
return // the failure was explained to the player
}
}
t.Fatal("failed payout was neither announced as paid (good) nor explained (bad)")
}
// TestBaccaratFailedPayoutNotAnnouncedAsPaid: same rule for baccarat.
func TestBaccaratFailedPayoutNotAnnouncedAsPaid(t *testing.T) {
table := newBaccaratTable(1, TableConfig{ID: "bac", Game: GameBaccarat}, newTestRand())
// Player 9 (natural), banker 0: no tableau draws, player wins.
table.shoe = tableauShoe(
card{rank: "9"}, card{rank: "K"}, // player: 9
card{rank: "8"}, card{rank: "2"}, // banker: 0
)
wallet := &failingPayoutWallet{testWallet{credits: 100}}
table.join("alice")
// With a lone player, the bet starts and deals the round immediately.
events := table.bet("alice", "player", 10, wallet)
for _, e := range events {
if strings.Contains(e.PrivateMessage, "You win! +") {
t.Fatalf("announced a successful payout after the wallet rejected it: %q", e.PrivateMessage)
}
}
if got := wallet.credits + wallet.paid; got != 90 {
t.Fatalf("failed payout changed the balance: %d, want 90", got)
}
}
// TestShoeReshufflesOnlyAtRoundBoundaries: crossing the shuffle threshold
// between individual draws must not replace the shoe mid-hand.
func TestShoeReshufflesOnlyAtRoundBoundaries(t *testing.T) {
shoe := newShoe(1, .25, newTestRand())
for len(shoe.cards) > 14 {
shoe.draw()
}
shoe.draw()
shoe.draw()
if got := len(shoe.cards); got != 12 {
t.Fatalf("mid-hand draws reshuffled the shoe: %d cards remain, want 12", got)
}
// At a round boundary the cut-card threshold applies.
shoe.shuffleIfDue()
if got := len(shoe.cards); got <= 12 {
t.Fatalf("shuffleIfDue did not reshuffle at the boundary (%d cards)", got)
}
}
|