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
|
package game
import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"testing"
"thehouseoficarus/internal/casino"
"thehouseoficarus/internal/persistence"
"thehouseoficarus/internal/player"
)
// Exercise every wallet mutation with both pre-replacement and post-replacement
// failures. The latter writes the actual character file, then reports an
// uncertain result, matching a failed directory fsync or post-save chmod.
func TestCasinoWalletSaveFailureConsistency(t *testing.T) {
type balance struct {
credits, chips int
pending map[string]player.PendingWager
}
state := func(p *player.Player) balance {
b := balance{credits: p.Credits, chips: countChips(p), pending: make(map[string]player.PendingWager)}
for _, stake := range p.PendingWagers {
b.pending[stake.ID] = stake
}
return b
}
tests := []struct {
name string
run func(casinoWallet) error
reportsError bool
credits, chips, stakes int
}{
{"wager", func(w casinoWallet) error {
_, err := w.Wager(4)
return err
}, true, 68, 0, 4},
{"payout", func(w casinoWallet) error { return w.PayCredits(40, "a", "b") }, true, 110, 2, 1},
{"unlinked payout", func(w casinoWallet) error { return w.PayCredits(20) }, true, 90, 2, 3},
{"resolve all", func(w casinoWallet) error { w.ResolveStakes(); return nil }, false, 70, 2, 0},
{"resolve one", func(w casinoWallet) error { w.ResolveStake("a"); return nil }, false, 70, 2, 2},
{"resolve batch", func(w casinoWallet) error { w.ResolveStakesByIDs("a", "b"); return nil }, false, 70, 2, 1},
{"refund", func(w casinoWallet) error {
chips, credits, err := w.RefundOutstanding()
if err == nil && (chips != 5 || credits != 30) {
return fmt.Errorf("refund = %d chips, %d credits; want 5, 30", chips, credits)
}
return err
}, true, 100, 7, 0},
}
for _, tt := range tests {
for _, replaced := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/replaced=%t", tt.name, replaced), func(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "players", "characters"), 0700); err != nil {
t.Fatal(err)
}
store := player.NewAccountStore(dir)
g := &Game{Deps: Deps{AccountStore: store}}
p := player.New("alice")
p.Credits = 70
p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 2})
p.PendingWagers = []player.PendingWager{
{ID: "a", Credits: 10, Chips: 5},
{ID: "b", Credits: 10},
{ID: "c", Credits: 10},
}
if err := store.SaveCharacter(p); err != nil {
t.Fatal(err)
}
before := state(p)
calls := 0
w := casinoWallet{g: g, p: p, saveCharacter: func(p *player.Player) error {
calls++
if !replaced {
return errors.New("injected failure before replacement")
}
if err := store.SaveCharacter(p); err != nil {
t.Fatal(err)
}
return fmt.Errorf("injected directory sync failure: %w", persistence.ErrDurabilityUncertain)
}}
err := tt.run(w)
if calls != 1 {
t.Fatalf("save calls = %d, want 1", calls)
}
if !replaced && tt.reportsError {
if !errors.Is(err, casino.ErrWalletPersistence) {
t.Fatalf("error = %v, want ErrWalletPersistence", err)
}
} else if err != nil {
t.Fatal(err)
}
if replaced {
if p.Credits != tt.credits || countChips(p) != tt.chips || len(p.PendingWagers) != tt.stakes {
t.Fatalf("applied state = %+v; want credits=%d chips=%d stakes=%d", state(p), tt.credits, tt.chips, tt.stakes)
}
} else if !reflect.DeepEqual(state(p), before) {
t.Fatalf("failed replacement changed money: got %+v, want %+v", state(p), before)
}
disk, err := store.LoadCharacter(p.Name)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(state(p), state(disk)) {
t.Fatalf("memory = %+v; visible file = %+v", state(p), state(disk))
}
// A later ordinary save must not erase the payout or resurrect
// its stakes. Recovery refunds only the remaining ledger, once.
wantCredits, wantChips := disk.Credits, countChips(disk)
for _, stake := range disk.PendingWagers {
wantCredits += stake.Credits
wantChips += stake.Chips
}
if err := store.SaveCharacter(p); err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
g.recoverPendingWagers()
disk, err = store.LoadCharacter(p.Name)
if err != nil {
t.Fatal(err)
}
if disk.Credits != wantCredits || countChips(disk) != wantChips || len(disk.PendingWagers) != 0 {
t.Fatalf("recovery %d = %+v; want credits=%d chips=%d no stakes", i, state(disk), wantCredits, wantChips)
}
}
})
}
}
}
|