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
|
package game
// Persistence/economy durability regressions: the economy journal's directory
// entry must be synced, recovery must order payout evidence by the record's
// creation order (not by its last-written line), the recovery checkpoint
// re-anchor must respect the economy cap, the journal-unavailable settlement
// fallback must only resolve the stakes it settles, and a bank credit move
// whose save failed must be refused instead of leaving memory ahead of the
// durable file.
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"thehouseoficarus/internal/casino"
"thehouseoficarus/internal/player"
"gopkg.in/yaml.v3"
)
// waitForConnText blocks until the session's writer flushes text containing
// substr; the writer goroutine is asynchronous.
func waitForConnText(t *testing.T, conn *chanConn, substr string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for !conn.Contains(substr) && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if !conn.Contains(substr) {
t.Errorf("expected %q in session output; got=%s", substr, conn.Bytes())
}
}
// TestRecoveryIgnoresRewrittenOlderPayoutAsStakeEvidence: payout evidence for
// a stake is ordered by the payout's CREATION sequence, not by the line of its
// latest write. A payout created before the stake that is rewritten afterwards
// (a settle marker, or a recovery checkpoint re-anchor) must not be read as
// the stake's settlement — otherwise recovery settles the stake without
// refunding it and the wager is silently lost.
func TestRecoveryIgnoresRewrittenOlderPayoutAsStakeEvidence(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
h.g.RunOnOwner(func() {
p.Credits = 1000
if err := h.g.AccountStore.SaveCharacter(p); err != nil {
t.Error(err)
return
}
// An older payout record (created first) ...
old, err := h.g.Economy.start("payout", p, 0, 20, 1000, 1020, 0, 0)
if err != nil {
t.Error(err)
return
}
// ... then the stake ...
if _, err := h.g.Economy.start("wager", p, 0, 50, 1000, 950, 0, 0); err != nil {
t.Error(err)
return
}
// ... and finally a later write of the OLD payout lands after the
// stake's line, so line order alone would call it the stake's
// settlement.
if !h.g.Economy.settleDurable(old) {
t.Error("the older payout's settle marker could not be journaled")
return
}
// The stake's debit is durable; the crashed round is recoverable.
p.Credits = 950
if err := h.g.AccountStore.SaveCharacter(p); err != nil {
t.Error(err)
return
}
h.g.recoverEconomy()
})
loaded, err := h.g.AccountStore.LoadCharacter("alice")
if err != nil {
t.Fatal(err)
}
if loaded.Credits != 1000 {
t.Errorf("balance after recovery = %d, want 1000: a payout created before the stake must not settle it (the stake is refunded, not silently kept)",
loaded.Credits)
}
if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
t.Errorf("records left open after recovery: %+v", open)
}
}
// TestRecoveryReanchorKeepsCheckpointsWithinCreditCap: when the economy cap
// blocks a payout's re-application, the re-anchored checkpoints must stay
// within the cap. An impossible post-state above the cap can never be read as
// durability, and the record is owed at the current balance: it must keep
// reading that way across repeated recoveries instead of being settled as
// "already durable". Legacy records without the tracked-evidence flag take
// this checkpoint-compensating path.
func TestRecoveryReanchorKeepsCheckpointsWithinCreditCap(t *testing.T) {
dir := t.TempDir()
seedWorld(t, dir)
hash, _ := player.HashPassword("pw")
accData, _ := yaml.Marshal(&player.Account{Name: "alice", PasswordHash: hash})
writeSeedFile(t, filepath.Join(dir, "players", "accounts", "alice.yaml"), accData)
p := player.New("alice")
p.Credits = casino.MaxCredits - 30
p.Account = "alice"
pData, _ := yaml.Marshal(p)
writeSeedFile(t, filepath.Join(dir, "players", "characters", "alice.yaml"), pData)
// Two identical open payouts of 20 at the cap: the first can be
// re-applied, the second cannot. Each record is its own journal line.
var journal []byte
for i := 0; i < 2; i++ {
line, err := json.Marshal(economyRecord{
ID: fmt.Sprintf("unpaid-%d", i), Player: "alice", Kind: "payout",
Credit: 20, Status: "open",
CreditsBefore: casino.MaxCredits - 30, CreditsAfter: casino.MaxCredits - 10,
})
if err != nil {
t.Fatal(err)
}
journal = append(journal, line...)
journal = append(journal, '\n')
}
journalDir := filepath.Join(dir, "economy")
if err := os.MkdirAll(journalDir, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(journalDir, "journal.jsonl"), journal, 0600); err != nil {
t.Fatal(err)
}
// The first recovery runs in New.
h := harnessFromDir(t, dir)
for round := 1; round <= 3; round++ {
if round > 1 {
h.g.recoverEconomy()
}
open := h.g.Economy.mustLoadOpen(t)
if len(open) != 1 {
t.Fatalf("recovery %d: the unpaid liability must stay open exactly once, open=%d: %+v", round, len(open), open)
}
for _, rec := range open {
if rec.CreditsBefore > casino.MaxCredits || rec.CreditsAfter > casino.MaxCredits {
t.Fatalf("recovery %d re-anchored %s outside the economy cap: before=%d after=%d cap=%d",
round, rec.ID, rec.CreditsBefore, rec.CreditsAfter, casino.MaxCredits)
}
}
}
loaded, err := h.g.AccountStore.LoadCharacter("alice")
if err != nil {
t.Fatal(err)
}
if loaded.Credits != casino.MaxCredits-10 {
t.Fatalf("balance after repeated recovery = %d, want %d (one 20-credit payout applied, the other still owed)",
loaded.Credits, casino.MaxCredits-10)
}
}
// TestJournalUnavailableSettlementSettlesOnlyItsOwnStakes: when the economy
// journal is unavailable, the character-file fallback records resolution
// evidence only for the stakes the payout settles. A sibling hand's stake is
// settled by its own payout, so a crash before that payout must still refund
// it.
func TestJournalUnavailableSettlementSettlesOnlyItsOwnStakes(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}
// Two sibling stakes (a split, or two spots). The sibling is placed
// first so its post-debit balance is the durable one.
if _, err := w.Wager(10); err != nil {
t.Error(err)
return
}
settled, err := w.Wager(10)
if err != nil {
t.Error(err)
return
}
// The journal breaks for the second hand's push: the payout lands in
// the character file only, and may record evidence only for the
// stake it settles.
failJournalDuring(h, func() {
if err := w.PayStakeCover(10, []string{settled.ID}); err != nil {
t.Errorf("fallback settlement failed: %v", err)
return
}
})
if p.Credits != 90 {
t.Errorf("after the fallback settlement: credits=%d, want 90", p.Credits)
return
}
h.g.recoverEconomy()
})
loaded, err := h.g.AccountStore.LoadCharacter("alice")
if err != nil {
t.Fatal(err)
}
if loaded.Credits != 100 {
t.Errorf("balance after recovery = %d, want 100: the sibling hand's stake must be refunded, not resolved by the other hand's settlement",
loaded.Credits)
}
if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
t.Errorf("records left open after recovery: %+v", open)
}
}
// TestBankCreditTransferRefusedWhenSaveFails: a credit move between the wallet
// and the bank that cannot be made durable is reverted and refused. Leaving it
// in memory only would put the balance ahead of the character file, and a
// wager taken from that phantom balance journals checkpoints recovery can
// never match.
func TestBankCreditTransferRefusedWhenSaveFails(t *testing.T) {
h := newHarness(t)
h.seedAccount("alice")
p := h.seedCharacter("alice", "alice")
p.Credits = 100
p.BankedCredits = 50
if err := h.g.AccountStore.SaveCharacter(p); err != nil {
t.Fatal(err)
}
sess, conn := h.connect("alice")
h.g.RunOnOwner(func() {
failingSaveDuring(h.g, h.dir, func() {
h.g.doBankDepositCredits(sess, []string{"deposit", "credits", "40"})
})
})
if sess.Player.Credits != 100 || sess.Player.BankedCredits != 50 {
t.Errorf("a deposit whose save failed must be reverted in memory: credits=%d banked=%d, want 100/50",
sess.Player.Credits, sess.Player.BankedCredits)
}
waitForConnText(t, conn, "nothing was moved")
if loaded, err := h.g.AccountStore.LoadCharacter("alice"); err != nil {
t.Error(err)
} else if loaded.Credits != 100 || loaded.BankedCredits != 50 {
t.Errorf("durable balances after a refused deposit: credits=%d banked=%d, want 100/50",
loaded.Credits, loaded.BankedCredits)
}
h.g.RunOnOwner(func() {
failingSaveDuring(h.g, h.dir, func() {
h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"})
})
})
if sess.Player.Credits != 100 || sess.Player.BankedCredits != 50 {
t.Errorf("a withdrawal whose save failed must be reverted in memory: credits=%d banked=%d, want 100/50",
sess.Player.Credits, sess.Player.BankedCredits)
}
waitForConnText(t, conn, "nothing was moved")
// A durable move still works.
h.g.RunOnOwner(func() {
h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"})
})
if sess.Player.Credits != 110 || sess.Player.BankedCredits != 40 {
t.Errorf("successful withdrawal: credits=%d banked=%d, want 110/40",
sess.Player.Credits, sess.Player.BankedCredits)
}
if loaded, err := h.g.AccountStore.LoadCharacter("alice"); err != nil {
t.Error(err)
} else if loaded.Credits != 110 || loaded.BankedCredits != 40 {
t.Errorf("durable balances after a successful withdrawal: credits=%d banked=%d, want 110/40",
loaded.Credits, loaded.BankedCredits)
}
}
|