diff options
Diffstat (limited to 'internal/game/casino_wallet.go')
| -rw-r--r-- | internal/game/casino_wallet.go | 252 |
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) } |
