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
|
package game
// Settlement/refund regressions for the durable-money audit: a settlement
// whose save failed is journaled as owed money, so a shutdown refund must
// NOT also return the round's stake — recovery re-applies the owed payout,
// which already contains the stake's return (table hands) or the round's
// winnings (slots, where the stake was consumed by playing the round).
import (
"path/filepath"
"testing"
"thehouseoficarus/internal/casino"
"thehouseoficarus/internal/player"
)
// failingSaveDuring swaps the account store for one that cannot save,
// simulating a character-save failure, and restores it afterwards.
func failingSaveDuring(g *Game, dir string, fn func()) {
realStore := g.AccountStore
g.AccountStore = player.NewAccountStore(filepath.Join(dir, "unwritable"))
defer func() { g.AccountStore = realStore }()
fn()
}
// TestFailedTableSettlementDoesNotDoubleReturnStake: a won hand whose payout
// save fails leaves the settlement journaled and owed. A graceful-shutdown
// refund must not ALSO return the stake; recovery re-applies the payout
// (which returns the stake inside it) exactly once. 100 - 10 + 20 = 110,
// never 120.
func TestFailedTableSettlementDoesNotDoubleReturnStake(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
h.g.RunOnOwner(func() {
p.Credits = 100
w := casinoWallet{g: h.g, p: p}
wager, err := w.Wager(10)
if err != nil {
t.Error(err)
return
}
failingSaveDuring(h.g, h.dir, func() {
if err := w.PayStakeCover(20, []string{wager.ID}); err == nil {
t.Error("expected the settlement save to fail")
return
}
// The shutdown refund path must not return the covered stake.
if chips, credits, err := w.RefundOutstanding(); err != nil || chips != 0 || credits != 0 {
t.Errorf("shutdown refund returned a covered stake: chips=%d credits=%d err=%v", chips, credits, err)
return
}
})
h.g.recoverEconomy()
loaded, err := h.g.AccountStore.LoadCharacter("alice")
if err != nil {
t.Error(err)
return
}
if loaded.Credits != 110 {
t.Errorf("balance after recovery = %d, want 110 (stake returned exactly once, inside the re-applied payout)", loaded.Credits)
}
if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
t.Errorf("records left open after recovery: %+v", open)
}
})
}
// TestFailedSlotsPayoutDoesNotDoubleReturnStake: a slot spin's payout is
// winnings only — the stake is consumed by playing the round. When the
// payout save fails, the journaled winnings prove the round resolved: the
// shutdown refund must not return the stake on top of the owed payout.
func TestFailedSlotsPayoutDoesNotDoubleReturnStake(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
h.g.RunOnOwner(func() {
p.Credits = 100
w := casinoWallet{g: h.g, p: p}
if _, err := w.Wager(10); err != nil {
t.Error(err)
return
}
failingSaveDuring(h.g, h.dir, func() {
// Machine payouts carry no stake linkage (nil covers): the
// journaled settlement still resolves the round.
if err := w.PayCredits(20); err == nil {
t.Error("expected the payout save to fail")
return
}
if chips, credits, err := w.RefundOutstanding(); err != nil || chips != 0 || credits != 0 {
t.Errorf("shutdown refund paid a resolved round's stake: chips=%d credits=%d err=%v", chips, credits, err)
return
}
})
h.g.recoverEconomy()
loaded, err := h.g.AccountStore.LoadCharacter("alice")
if err != nil {
t.Error(err)
return
}
if loaded.Credits != 110 {
t.Errorf("balance after recovery = %d, want 110 (stake consumed by the round, winnings paid once)", loaded.Credits)
}
})
}
// TestUncoveredStakeStillRefunds: without a journaled settlement, the
// shutdown refund path keeps returning outstanding stakes (the documented
// refund-on-shutdown policy) — a regression guard that the coverage skip
// never over-triggers.
func TestUncoveredStakeStillRefunds(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
h.g.RunOnOwner(func() {
p.Credits = 100
w := casinoWallet{g: h.g, p: p}
if _, err := w.Wager(10); err != nil {
t.Error(err)
return
}
chips, credits, err := w.RefundOutstanding()
if err != nil || chips != 0 || credits != 10 {
t.Errorf("uncovered stake refund: chips=%d credits=%d err=%v", chips, credits, err)
return
}
})
}
// TestCasinoManagerRefundPathUsesCoverage: the same rule through the live
// manager path (baccarat settle → RefundAll), exercised with a probe wallet
// so the settlement save fails mid-round.
func TestCasinoManagerFailedSettlementRefundIsExact(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
h.g.RunOnOwner(func() {
p.Credits = 1000
h.g.Casino.Join(1, casino.TableConfig{ID: "b", Game: casino.GameBaccarat}, "alice")
var totalPaid int
w := casinoWallet{g: h.g, p: p}
wallet := &fallbackProbeWallet{harness: h, inner: w, onPay: func(credits int, failedCall bool) {
totalPaid += credits
}}
// One bet: a lone player's bet deals and settles immediately. The
// wallet fails the first settlement's journal-free save via a
// broken SAVE (not journal) so the record stays open and owed.
h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet)
h.g.emitCasinoEvents(h.g.Casino.RefundAll())
h.g.recoverEconomy()
loaded, err := h.g.AccountStore.LoadCharacter("alice")
if err != nil {
t.Error(err)
return
}
// The stake was committed durably (990); RefundAll must not return
// it while the settlement payout is owed; recovery re-applies the
// payout: 1000 - 10 + totalPaid = final.
if want := 1000 - 10 + totalPaid; loaded.Credits != want {
t.Errorf("balance after refund+recovery = %d, want %d (wager 10, paid %d)", loaded.Credits, want, totalPaid)
}
})
}
|