aboutsummaryrefslogtreecommitdiff
path: root/internal/game/casino_wallet.go
blob: 2ec0dea76a825d5045392af863daaf066c7f90d5 (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
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
package game

import (
	"errors"
	"fmt"
	"os"

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

type casinoWallet struct {
	g *Game
	p *player.Player
	// Optional per-wallet persistence seam for fault-injection tests.
	saveCharacter func(*player.Player) error
}

// save treats a completed replacement as applied, even if its durability is
// uncertain. Reverting memory then would disagree with the visible file and
// could overwrite a paid win or resurrect a resolved stake on the next save.
// Report the storage fault, but do not tell the game to retry/refund an
// already-applied mutation. Only pre-replacement failures require rollback.
func (w casinoWallet) save(operation string) error {
	save := w.saveCharacter
	if save == nil {
		save = w.g.AccountStore.SaveCharacter
	}
	err := save(w.p)
	if errors.Is(err, persistence.ErrDurabilityUncertain) {
		fmt.Fprintf(os.Stderr, "casino %s: %s save durability uncertain: %v — keeping applied state\n", operation, w.p.Name, err)
		return nil
	}
	return err
}

// Wager debits the stake and records it in PendingWagers in the SAME atomic
// character save. The entry stays until the round resolves: a settlement
// removes the stakes it returns (PayCredits) or consumes them
// (ResolveStake/ResolveStakes), and startup refunds anything still listed
// (see recoverPendingWagers). A pre-replacement save failure reverts the
// debit and ledger entry; an uncertain replacement keeps both (see save).
func (w casinoWallet) Wager(amount int) (casino.Wager, error) {
	if amount <= 0 {
		return casino.Wager{}, casino.ErrInsufficientFunds
	}
	creditsBefore := w.p.Credits
	chips := countChips(w.p)
	if chips > amount {
		chips = amount
	}
	credits := amount - chips
	if creditsBefore < credits {
		return casino.Wager{}, casino.ErrInsufficientFunds
	}
	if chips > 0 {
		w.p.RemoveItem("chips", chips)
	}
	w.p.Credits -= credits
	pending := player.PendingWager{ID: newPendingWagerID(), Chips: chips, Credits: credits}
	w.p.PendingWagers = append(w.p.PendingWagers, pending)
	if err := w.save("wager"); err != nil {
		// Revert the in-memory mutation: the play must not proceed on a
		// stake that could not be made durable.
		if chips > 0 {
			if !w.g.giveChips(w.p, chips) {
				// Cannot happen (the debit just freed or reduced the chips
				// stack), but surface it instead of silently losing chips.
				fmt.Fprintf(os.Stderr, "casino wager revert: no inventory room for %d chips\n", chips)
			}
		}
		w.p.Credits = creditsBefore
		removePendingWager(w.p, pending.ID)
		return casino.Wager{}, casino.ErrWalletPersistence
	}
	return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: pending.ID}, nil
}

// PayCredits banks a payout durably and, in the SAME save, resolves the
// listed stakes. stakeIDs may be empty for a payout that returns no stake (a
// free-spin bank already refunded by RefundOutstanding). A table settlement
// passes the hand's stake IDs so the credit and the stake return are one
// atomic step; a slot win passes its triggering stake for the same reason.
// Either way a crash can never pay the payout AND refund the same stake. A
// pre-replacement failure reverts both the credit and the ledger and reports
// failure. An uncertain replacement is applied and logged (see save).
func (w casinoWallet) PayCredits(amount int, stakeIDs ...string) error {
	return w.payCredits(amount, stakeIDs)
}

// payCredits credits the payout and, when stakeIDs is non-empty, removes
// those stakes from the pending ledger in the same atomic save. On a
// pre-replacement failure the credit and the ledger are reverted together.
func (w casinoWallet) payCredits(amount int, stakeIDs []string) error {
	if amount <= 0 && len(stakeIDs) == 0 {
		return nil
	}
	creditsBefore := w.p.Credits
	sum, capErr := addCreditsChecked(creditsBefore, amount)
	if capErr != nil {
		return capErr
	}
	var removed []player.PendingWager
	if len(stakeIDs) > 0 {
		removed = takePendingWagers(w.p, stakeIDs)
		// Fail closed like PayPoker: a payout resolves the stakes it names. If
		// any stake is already gone (or duplicated away), crediting anyway
		// would double-pay with no ledger entry for startup recovery to see.
		if len(removed) != countUniqueNonEmpty(stakeIDs) {
			w.p.PendingWagers = append(w.p.PendingWagers, removed...)
			return casino.ErrWalletPersistence
		}
	}
	w.p.Credits = sum
	if err := w.save("payout"); err != nil {
		w.p.Credits = creditsBefore
		w.p.PendingWagers = append(w.p.PendingWagers, removed...)
		return casino.ErrWalletPersistence
	}
	return nil
}

// ResolveStake consumes ONE pending stake by wallet handle: its hand lost or
// was paid by a cover, so it must never be refunded. A settlement that
// already removed it (PayCredits) makes this a no-op, so no redundant
// save is paid.
func (w casinoWallet) ResolveStake(stakeID string) {
	if stakeID == "" {
		return
	}
	removed := takePendingWagers(w.p, []string{stakeID})
	if len(removed) == 0 {
		return
	}
	if err := w.save("resolve stake"); err != nil {
		// The durable file still lists the stake, so restore it in memory to
		// keep the two records consistent for a later retry/recovery.
		w.p.PendingWagers = append(w.p.PendingWagers, removed...)
		fmt.Fprintf(os.Stderr, "casino resolve stake: save for %s: %v (ledger restored in memory)\n", w.p.Name, err)
	}
}

// ResolveStakesByIDs consumes the listed pending stakes in one durable save.
// A losing split/doubled hand used to call ResolveStake once per handle, each
// with its own full character save; batching removes that repeated fsync and
// the crash window between saves.
func (w casinoWallet) ResolveStakesByIDs(stakeIDs ...string) {
	if len(stakeIDs) == 0 {
		return
	}
	removed := takePendingWagers(w.p, stakeIDs)
	if len(removed) == 0 {
		return
	}
	if err := w.save("resolve stakes"); err != nil {
		// The durable file still lists the stakes, so restore them in memory to
		// keep the two records consistent for a later retry/recovery.
		w.p.PendingWagers = append(w.p.PendingWagers, removed...)
		fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v (ledger restored in memory)\n", w.p.Name, err)
	}
}

// RefundOutstanding returns every pending stake to the player — chips in
// kind, credits as credits — and clears the ledger in one save. It is used
// by graceful shutdown/eviction and by a pre-deal disconnect. A pre-replacement
// failure reverts the return and ledger for retry; an uncertain replacement
// keeps both applied (see save).
func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) {
	before := append([]player.PendingWager(nil), w.p.PendingWagers...)
	for _, pending := range before {
		chips += pending.Chips
		credits += pending.Credits
	}
	if chips == 0 && credits == 0 {
		return 0, 0, nil
	}
	creditsBefore := w.p.Credits
	w.p.PendingWagers = nil
	if chips > 0 {
		if !w.g.giveChips(w.p, chips) {
			w.p.PendingWagers = before
			return 0, 0, casino.ErrWalletPersistence
		}
	}
	sum, capErr := addCreditsChecked(creditsBefore, credits)
	if capErr != nil {
		if chips > 0 {
			w.p.RemoveItem("chips", chips)
		}
		w.p.PendingWagers = before
		return 0, 0, casino.ErrWalletPersistence
	}
	w.p.Credits = sum
	if err := w.save("refund"); err != nil {
		w.p.Credits = creditsBefore
		if chips > 0 {
			w.p.RemoveItem("chips", chips)
		}
		w.p.PendingWagers = before
		return 0, 0, casino.ErrWalletPersistence
	}
	return chips, credits, nil
}

// RefundStakes returns the named pending stakes in kind — chips as chips,
// credits as credits — and clears them from the ledger in one save. It is the
// game-side take-back path: using PayCredits here would convert casino-only
// chips into spendable credits.
func (w casinoWallet) RefundStakes(stakeIDs ...string) error {
	if len(stakeIDs) == 0 {
		return nil
	}
	removed := takePendingWagers(w.p, stakeIDs)
	if len(removed) == 0 {
		return nil
	}
	var chips, credits int
	for _, pending := range removed {
		chips += pending.Chips
		credits += pending.Credits
	}
	creditsBefore := w.p.Credits
	if chips > 0 {
		if !w.g.giveChips(w.p, chips) {
			w.p.PendingWagers = append(w.p.PendingWagers, removed...)
			return casino.ErrWalletPersistence
		}
	}
	sum, capErr := addCreditsChecked(creditsBefore, credits)
	if capErr != nil {
		if chips > 0 {
			w.p.RemoveItem("chips", chips)
		}
		w.p.PendingWagers = append(w.p.PendingWagers, removed...)
		return casino.ErrWalletPersistence
	}
	w.p.Credits = sum
	if err := w.save("refund stakes"); err != nil {
		w.p.Credits = creditsBefore
		if chips > 0 {
			w.p.RemoveItem("chips", chips)
		}
		w.p.PendingWagers = append(w.p.PendingWagers, removed...)
		return casino.ErrWalletPersistence
	}
	return nil
}

// casinoAffordable is the most a player can wager: chips are spent first,
// then credits.
func casinoAffordable(p *player.Player) int { return p.Credits + countChips(p) }