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 game
// Settlement/refund regressions for single-file money: a settlement credits
// the payout and clears the stakes it returns in ONE character save, so a
// failed settlement leaves the stake pending (and refundable) rather than
// paying it twice; an outstanding stake with no settlement is refunded.
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()
}
// TestFailedSettlementLeavesStakeRefundable: a settlement whose save fails
// changes nothing — the credit is reverted and the stake stays pending. A
// restart refunds the stake; the win was never durable, and it is never paid
// on top of a refund.
func TestFailedSettlementLeavesStakeRefundable(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")
}
})
if p.Credits != 90 || len(p.PendingWagers) != 1 {
t.Errorf("after failed settlement: credits=%d pending=%+v; want 90 and one stake",
p.Credits, p.PendingWagers)
}
})
// A restart refunds the still-pending stake; nothing double-pays.
restarted := harnessFromDir(t, h.dir)
loaded, err := restarted.g.AccountStore.LoadCharacter("alice")
if err != nil {
t.Fatal(err)
}
if loaded.Credits != 100 {
t.Fatalf("balance after restart = %d, want 100 (failed payout not applied; stake refunded)", loaded.Credits)
}
if len(loaded.PendingWagers) != 0 {
t.Fatalf("pending after recovery = %+v, want none", loaded.PendingWagers)
}
}
// TestSuccessfulSettlementClearsPendingDurably: the payout and the removal of
// the stake it returns are one save, so the durable file never holds both.
func TestSuccessfulSettlementClearsPendingDurably(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
}
if err := w.PayStakeCover(20, []string{wager.ID}); err != nil {
t.Error(err)
}
})
if p.Credits != 110 || len(p.PendingWagers) != 0 {
t.Fatalf("after settlement: credits=%d pending=%+v; want 110 and none", p.Credits, p.PendingWagers)
}
loaded, err := h.g.AccountStore.LoadCharacter("alice")
if err != nil {
t.Fatal(err)
}
if loaded.Credits != 110 || len(loaded.PendingWagers) != 0 {
t.Fatalf("durable after settlement: credits=%d pending=%+v; want 110 and none",
loaded.Credits, loaded.PendingWagers)
}
}
// TestUncoveredStakeStillRefunds: without a settlement, the refund path
// returns an outstanding stake (the refund-on-shutdown policy).
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
}
if p.Credits != 100 || len(p.PendingWagers) != 0 {
t.Errorf("after refund: credits=%d pending=%+v; want 100 and none", p.Credits, p.PendingWagers)
}
})
}
// TestCasinoManagerFailedSettlementRefundIsExact drives a full baccarat round
// through the manager with a wallet whose settlement save fails: the stake
// stays pending and is refunded exactly once (never paid on top of a payout).
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")
w := casinoWallet{g: h.g, p: p}
// A lone player's bet deals and settles immediately; break the save
// for that settlement so the stake stays pending.
failingSaveDuring(h.g, h.dir, func() {
h.g.Casino.Bet(1, "b", "alice", "player", 10, w)
})
h.g.emitCasinoEvents(h.g.Casino.RefundAll())
})
if p.Credits != 1000 {
t.Fatalf("balance after failed settlement and refund = %d, want 1000", p.Credits)
}
if len(p.PendingWagers) != 0 {
t.Fatalf("pending after refund = %+v, want none", p.PendingWagers)
}
}
|