aboutsummaryrefslogtreecommitdiff
path: root/internal/game/journal_failure_regression_test.go
blob: 683b99664d4625b75c0646cd1e86df5458ed4ba9 (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
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
package game

// Journal-failure regressions: a broken economy journal (persistent append
// failure) must never lose money silently. Settlements pay through the
// character file, stake resolutions are recorded as file evidence, and an
// unreadable journal aborts recovery/compaction instead of erasing records.

import (
	"os"
	"strings"
	"testing"
	"time"

	"thehouseoficarus/internal/casino"
	"thehouseoficarus/internal/player"
)

// failJournalDuring redirects the economy journal path for the duration of
// fn, simulating a persistent append failure, and restores it afterwards.
func failJournalDuring(h *harness, fn func()) {
	path := h.g.Economy.path
	h.g.Economy.path = h.dir
	defer func() { h.g.Economy.path = path }()
	fn()
}

// TestSettlementFallbackKeepsWholeBalance: a full table round whose payout
// journal append fails still ends with the player whole — the settlement is
// paid through the character file, the stake resolution is recorded as
// evidence, and recovery leaves the balance exact.
func TestSettlementFallbackKeepsWholeBalance(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
		failed := false
		w := casinoWallet{g: h.g, p: p}
		// Wrap the wallet so exactly one settlement hits a broken journal.
		wallet := &fallbackProbeWallet{harness: h, inner: w, onPay: func(credits int, failedCall bool) {
			totalPaid += credits
			if failedCall {
				failed = true
			}
		}}
		rounds := 0
		for rounds < 100 && !failed {
			rounds++
			h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet)
		}
		if !failed {
			t.Fatal("probe never observed a settlement payout")
			return
		}
		// One more full round after the failure: the table keeps working.
		rounds++
		h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet)
		h.g.recoverEconomy()
		loaded, err := h.g.AccountStore.LoadCharacter("alice")
		if err != nil {
			t.Error(err)
			return
		}
		want := 1000 - 10*rounds + totalPaid
		if loaded.Credits != want {
			t.Errorf("balance after journal-failure round: credits=%d, want %d (wagers %d, paid %d)",
				loaded.Credits, want, 10*rounds, totalPaid)
		}
		if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
			t.Errorf("records left open: %+v", open)
		}
	})
}

// fallbackProbeWallet routes PayCredits through the real wallet once with a
// broken journal (the first settlement), then normally.
type fallbackProbeWallet struct {
	harness *harness
	inner   casinoWallet
	onPay   func(credits int, failedCall bool)
	broke   bool
}

func (w *fallbackProbeWallet) Wager(amount int) (casino.Wager, error) { return w.inner.Wager(amount) }

func (w *fallbackProbeWallet) PayCredits(amount int) error {
	if w.broke {
		w.onPay(amount, false)
		return w.inner.PayCredits(amount)
	}
	w.broke = true
	w.onPay(amount, true)
	failJournalDuring(w.harness, func() {
		_ = w.inner.PayCredits(amount)
	})
	return nil
}

func (w *fallbackProbeWallet) ResolveStakes() { w.inner.ResolveStakes() }
func (w *fallbackProbeWallet) RefundOutstanding() (int, int, error) {
	return w.inner.RefundOutstanding()
}

// TestChipStakeSettlementFallbackNoDoublePay: a chip-funded stake whose win
// is paid through the character-file fallback must not ALSO get its chips
// returned in kind by recovery — the settlement returned the stake as
// credits inside the payout.
func TestChipStakeSettlementFallbackNoDoublePay(t *testing.T) {
	h := newHarness(t)
	h.seedAccount("alice")
	p := h.seedCharacter("alice", "alice")
	h.g.RunOnOwner(func() {
		p.Credits = 100
		p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10})
		w := casinoWallet{g: h.g, p: p}
		if _, err := w.Wager(10); err != nil {
			t.Error(err)
			return
		}
		// The journal is broken for BOTH the settlement and the resolution:
		// the win lands in the character file, the resolution in evidence.
		failJournalDuring(h, func() {
			if err := w.PayCredits(20); err != nil {
				t.Errorf("fallback settlement failed: %v", err)
				return
			}
			w.ResolveStakes()
		})
		h.g.recoverEconomy()
		loaded, err := h.g.AccountStore.LoadCharacter("alice")
		if err != nil {
			t.Error(err)
			return
		}
		if loaded.Credits != 120 || countChips(loaded) != 0 {
			t.Errorf("after recovery: %d credits / %d chips, want 120 / 0 (chip stake debited, win of 20 paid once in credits; no in-kind chip return on top)",
				loaded.Credits, countChips(loaded))
		}
		if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
			t.Errorf("open records: %+v", open)
		}
	})
}

// TestCorruptJournalAbortsRecoveryWithoutErasure: an unreadable journal
// region must abort compensation and compaction — the owed payout record
// beyond the corruption stays in the file for operator inspection instead
// of being compacted away.
func TestCorruptJournalAbortsRecoveryWithoutErasure(t *testing.T) {
	h := newHarness(t)
	h.seedAccount("alice")
	p := h.seedCharacter("alice", "alice")
	h.g.RunOnOwner(func() {
		p.Credits = 100
		if err := h.g.AccountStore.SaveCharacter(p); err != nil {
			t.Error(err)
			return
		}
		// Corrupt the journal with an oversized line, then journal an owed
		// payout BEYOND it (as appends would after the corruption).
		f, err := os.OpenFile(h.g.Economy.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
		if err != nil {
			t.Error(err)
			return
		}
		_, werr := f.WriteString(strings.Repeat("x", 1024*1024+1) + "\n")
		f.Close()
		if werr != nil {
			t.Error(werr)
			return
		}
		if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil {
			t.Errorf("payout could not be journaled: %v", err)
			return
		}
		h.g.recoverEconomy()
		// The owed payout record is preserved verbatim: no compensation ran
		// (credits unchanged) and no compaction erased the record.
		loaded, err := h.g.AccountStore.LoadCharacter("alice")
		if err != nil {
			t.Error(err)
			return
		}
		data, err := os.ReadFile(h.g.Economy.path)
		if err != nil {
			t.Fatal(err)
		}
		if loaded.Credits != 100 {
			t.Errorf("aborted recovery paid the payout: credits=%d, want 100 (preserved for inspection)", loaded.Credits)
		}
		if !strings.Contains(string(data), `"kind":"payout"`) {
			t.Error("corrupt journal recovery erased the owed payout record instead of preserving it")
		}
	})
}

// TestBankWithdrawalHonorsCreditCap: a withdrawal that would push the
// on-hand balance past the shared economy cap is rejected; neither balance
// changes.
func TestBankWithdrawalHonorsCreditCap(t *testing.T) {
	h := newHarness(t)
	h.seedAccount("alice")
	p := h.seedCharacter("alice", "alice")
	p.Credits = casino.MaxCredits
	p.BankedCredits = 10
	if err := h.g.AccountStore.SaveCharacter(p); err != nil {
		t.Fatal(err)
	}
	sess, conn := h.connect("alice")
	h.g.RunOnOwner(func() {
		h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"})
	})
	if sess.Player.Credits != casino.MaxCredits || sess.Player.BankedCredits != 10 {
		t.Errorf("rejected withdrawal changed balances: credits=%d banked=%d",
			sess.Player.Credits, sess.Player.BankedCredits)
	}
	// The session writer goroutine flushes asynchronously; wait for the
	// rejection to arrive instead of racing it.
	deadline := time.Now().Add(2 * time.Second)
	for !conn.Contains("cannot hold") && time.Now().Before(deadline) {
		time.Sleep(time.Millisecond)
	}
	if !conn.Contains("cannot hold") {
		t.Errorf("withdrawal rejection not reported; output=%s", conn.Bytes())
	}
}