aboutsummaryrefslogtreecommitdiff
path: root/internal/game/casino_wallet.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/casino_wallet.go')
-rw-r--r--internal/game/casino_wallet.go252
1 files changed, 252 insertions, 0 deletions
diff --git a/internal/game/casino_wallet.go b/internal/game/casino_wallet.go
new file mode 100644
index 0000000..2ec0dea
--- /dev/null
+++ b/internal/game/casino_wallet.go
@@ -0,0 +1,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) }