aboutsummaryrefslogtreecommitdiff
path: root/internal/casino/hardening_casino_refund_test.go
blob: 7b98be85c117edabfbf3ef715236428879c9dfcd (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
package casino

import (
	"math/rand"
	"testing"
)

// RefundAll (graceful shutdown / room eviction) must not leave a refunded
// round alive: a round that settles after its stake was returned pays the
// same round twice and mints credits.

// TestRefundAllDiscardsTheRefundedMachineRound: the review's repro — bet,
// refund everything, then keep ticking. The machine must not pay the spin
// whose stake the refund returned, and a second RefundAll must not refund
// again.
func TestRefundAllDiscardsTheRefundedMachineRound(t *testing.T) {
	m := NewManager(1)
	cfg := MachineConfig{ID: "slots", Game: GameSlots, SpinTicks: 50}
	m.JoinMachine(1, cfg, "alice")
	wallet := &testWallet{credits: 100}
	if events := m.SetMachineBet(1, "slots", "alice", 10, wallet); len(events) == 0 {
		t.Fatal("setting a bet produced no events")
	}
	// Force a paying spin, so a live round WOULD pay after the refund.
	machine := m.machines[tableKey(1, "slots")]
	machine.mu.Lock()
	machine.spin = &SlotSpin{Payout: 20}
	machine.mu.Unlock()

	m.RefundAll()
	if wallet.paid != 10 {
		t.Fatalf("refund paid %d, want the 10-credit stake", wallet.paid)
	}
	// Drive the machine to (what used to be) the end of its spin.
	for i := 0; i < 60; i++ {
		m.Tick()
	}
	if got := wallet.credits + wallet.paid; got != 100 {
		t.Fatalf("refunded spin paid again: balance %d, want 100 (10 refunded once, no 20-credit payout)", got)
	}
	if machine.phase != machineReady {
		t.Fatalf("refunded machine kept its round: phase=%s", machine.phase)
	}

	// A second shutdown (or eviction) must not double-pay either.
	m.RefundAll()
	if got := wallet.credits + wallet.paid; got != 100 {
		t.Fatalf("second RefundAll double-paid: balance %d", got)
	}
}

// TestRefundAllDiscardsTheRefundedTableRound: same rule at a card table. The
// refunded round is reset, so the later settlement/split path cannot pay the
// stake the refund already returned.
func TestRefundAllDiscardsTheRefundedTableRound(t *testing.T) {
	m := NewManager(1)
	cfg := TableConfig{ID: "bj", Game: GameBlackjack, MaxPlayers: 2, BettingTicks: 5}
	m.Join(1, cfg, "alice")
	m.Join(1, cfg, "bob")
	wallet := &testWallet{credits: 100}
	// Only alice bets: the window stays open with no cards dealt.
	m.Bet(1, "bj", "alice", "", 10, wallet)

	m.RefundAll()
	table := m.Table(1, "bj").game.(*blackjackTable)
	if table.phase != blackjackWaiting || table.players.get("alice").HasBet {
		t.Fatalf("refunded table kept its round: phase=%s hasBet=%v", table.phase, table.players.get("alice").HasBet)
	}
	// The window can no longer deal (the wager is gone) and nothing pays.
	for i := 0; i < 20; i++ {
		m.Tick()
	}
	if got := wallet.credits + wallet.paid; got != 100 {
		t.Fatalf("refunded table round paid again: balance %d, want 100", got)
	}
}

// Free-spin bookkeeping: recovery reads ANY journaled payout for the player
// as proof that the round resolved (Wallet.PayCredits carries no stake
// linkage), so a feature that paid each spin as it landed would make a crash
// mid-bonus silently keep the triggering stake. The machine must journal
// nothing until the feature — and with it the round — completes.

// TestSlotsFeatureWinningsAreBankedNotJournaled: the triggering stake stays
// outstanding and no payout at all is made while free spins remain.
func TestSlotsFeatureWinningsAreBankedNotJournaled(t *testing.T) {
	m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7)))
	wallet := &testWallet{credits: 100}
	m.join("alice")
	m.beginSpinLocked(10, wallet, "", "")
	m.spin = &SlotSpin{Payout: 25, ScatterCount: 3}
	events := m.finishSpin()
	if !hasMessage(events, "trigger") {
		t.Fatalf("setup: feature did not trigger: %+v", events)
	}
	if wallet.paid != 0 {
		t.Fatalf("a payout was journaled mid-feature (%d credits): recovery would read it as the round resolving", wallet.paid)
	}
	if len(wallet.open) != 1 {
		t.Fatalf("the triggering stake must stay outstanding through the feature, open=%+v", wallet.open)
	}
	if m.featureBank != 25 {
		t.Fatalf("featureBank=%v, want the trigger spin's 25 credits banked", m.featureBank)
	}

	// A free spin wins too and is banked as well.
	m.spin = &SlotSpin{Payout: 40}
	m.freeSpins = 1
	m.finishSpin()
	if wallet.paid != 0 || len(wallet.open) != 1 {
		t.Fatalf("mid-feature spin paid or resolved the stake: paid=%d open=%+v", wallet.paid, wallet.open)
	}
	if m.featureBank != 65 {
		t.Fatalf("featureBank=%v, want 65 banked", m.featureBank)
	}
}

// TestSlotsFeaturePaysOneSettlementThenResolvesTheStake: the banked feature
// winnings are paid in a single settlement when the last free spin stops, and
// only then is the triggering stake resolved.
func TestSlotsFeaturePaysOneSettlementThenResolvesTheStake(t *testing.T) {
	m := newMachine(1, MachineConfig{ID: "slots", Game: GameSlots}, rand.New(rand.NewSource(7)))
	wallet := &testWallet{credits: 100}
	m.join("alice")
	m.beginSpinLocked(10, wallet, "", "")
	// The last free spin: the feature (and the round) resolves with it.
	m.inFreeSpins = true
	m.freeSpins = 0
	m.featureBank = 25
	m.spin = &SlotSpin{Payout: 40, ScatterCount: 3}

	events := m.finishSpin()
	if wallet.paid != 65 {
		t.Fatalf("feature settlement paid %d, want one 65-credit payout", wallet.paid)
	}
	if !hasMessage(events, "The free spins pay 65 credits.") {
		t.Fatalf("missing feature settlement message: %+v", events)
	}
	if len(wallet.open) != 0 {
		t.Fatalf("stake still outstanding after the feature completed: %+v", wallet.open)
	}
	if m.inFreeSpins || m.freeSpins != 0 || m.featureBank != 0 {
		t.Fatalf("feature state leaked: inFreeSpins=%v freeSpins=%d bank=%v", m.inFreeSpins, m.freeSpins, m.featureBank)
	}
}