aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/money_regression_test.go
blob: 2f56e0f6e494ada67d73d7b8fbe32684cb4163bb (plain)
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
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
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, ...string) 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, "could not process your payout") {
			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)
	}
}

// TestSlotsLeaveAfterFailedPayoutKeepsStakeOpen: a spin whose payout could
// not be persisted leaves its stake record OPEN as recovery evidence for
// the owed settlement. Standing up afterwards must not resolve (forfeit)
// that stake — doing so would erase the refund evidence and silently drop
// the owed win.
func TestSlotsLeaveAfterFailedPayoutKeepsStakeOpen(t *testing.T) {
	machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand())
	wallet := &failingPayoutWallet{testWallet{credits: 100}}
	machine.join("alice")
	machine.setBet("alice", 10, wallet)
	machine.spin = &SlotSpin{Payout: 20}
	for i := 0; i < 20 && machine.phase != machineReady; i++ {
		machine.tick()
	}
	if len(wallet.open) != 1 {
		t.Fatal("setup: a failed payout must leave the stake open")
	}
	machine.leave("alice")
	if len(wallet.open) != 1 {
		t.Errorf("leaving after a failed payout erased the outstanding stake; want it preserved for recovery/refund")
	}
}

// TestSlotsShutdownDuringBonusRefundsStake: free spins keep the triggering
// stake OUTSTANDING until the bonus completes. A graceful shutdown mid-feature
// therefore refunds the stake; the remaining feature EV is forfeit, but the
// player never loses the wager they paid for the round.
func TestSlotsShutdownDuringBonusRefundsStake(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{ScatterCount: 3} // deterministic bonus trigger
	for i := 0; i < 20 && !machine.inFreeSpins; i++ {
		machine.tick()
	}
	if !machine.inFreeSpins || machine.freeSpins != 7 {
		t.Fatalf("setup: expected the first of eight free spins (inFreeSpins=%v freeSpins=%d)", machine.inFreeSpins, machine.freeSpins)
	}
	machine.refundAll()
	if got := wallet.credits + wallet.paid; got != 100 {
		t.Errorf("shutdown during the bonus left %d of the 100-credit balance; want the stake refunded", got)
	}
}

// TestSlotsStakeResolvesOnlyAfterFullRound: without any failure and without
// a feature, a completed spin resolves its stake (no shutdown refund), and a
// bonus that RUNS TO COMPLETION also resolves the stake at the end.
func TestSlotsStakeResolvesOnlyAfterFullRound(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{ScatterCount: 3} // trigger, then run all free spins
	for i := 0; i < 200 && (machine.phase != machineReady || machine.inFreeSpins); i++ {
		machine.tick()
	}
	if machine.inFreeSpins || machine.freeSpins != 0 {
		t.Fatalf("setup: bonus must have completed (inFreeSpins=%v freeSpins=%d)", machine.inFreeSpins, machine.freeSpins)
	}
	if len(wallet.open) != 0 {
		t.Fatalf("stake still outstanding after the feature completed: %+v", wallet.open)
	}
	// A completed round has nothing outstanding: the shutdown refund must
	// not pay anything extra.
	paidBefore := wallet.paid
	machine.refundAll()
	if wallet.paid != paidBefore {
		t.Errorf("refundAll double-paid a completed round: paid %d -> %d", paidBefore, wallet.paid)
	}
}

// TestSlotsSecondSpinDoesNotResolveFailedPayoutStake: a spin whose payout
// could not be persisted leaves its stake OPEN as recovery evidence. The
// next round must resolve only its own stake — a player-wide ResolveStakes
// would silently erase the failed round's evidence and drop the refund.
func TestSlotsSecondSpinDoesNotResolveFailedPayoutStake(t *testing.T) {
	machine := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 1}, newTestRand())
	wallet := &recoveringSettlementWallet{testWallet: testWallet{credits: 100}, fail: true}
	machine.join("alice")
	machine.setBet("alice", 10, wallet)
	machine.spin = &SlotSpin{Payout: 20} // winning spin the wallet refuses to pay
	for i := 0; i < 20 && machine.phase != machineReady; i++ {
		machine.tick()
	}
	if len(wallet.open) != 1 {
		t.Fatal("setup: a failed payout must leave the stake open")
	}

	// Storage recovers. A second, losing round must atomically resolve only
	// its own stake, not the first (still-open) stake.
	wallet.fail = false
	machine.setBet("alice", 10, wallet)
	machine.spin = &SlotSpin{}
	for i := 0; i < 20 && machine.phase != machineReady; i++ {
		machine.tick()
	}
	if len(wallet.open) != 1 {
		t.Fatalf("the second spin resolved the failed-payout stake; open=%d, want 1", len(wallet.open))
	}

	// The preserved stake is still refundable at shutdown.
	machine.refundAll()
	if len(wallet.open) != 0 {
		t.Fatalf("failed-payout stake was not refunded: open=%+v", wallet.open)
	}
	if got := wallet.credits + wallet.paid; got != 90 {
		t.Fatalf("balance %d, want 90 (first stake refunded, second leg lost)", got)
	}
}