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) }