diff options
| author | historia <[not public]> | 2026-09-18 21:36:36 -0400 |
|---|---|---|
| committer | historia <[not public]> | 2026-09-18 21:36:36 -0400 |
| commit | a8a67482bbff8297a96adff65d2fd7029ed998cc (patch) | |
| tree | e84f8ba636c8f2edb4d2ff8b45d7cff7298fa7cc /internal/game/casino.go | |
| parent | ff04d26ce90d6b411a04a2fb59f7c24ceefb161f (diff) | |
| download | thehouseoficarus-a8a67482bbff8297a96adff65d2fd7029ed998cc.tar.gz | |
refactor: replace economy journal with single-file pending-wager wallet
Diffstat (limited to 'internal/game/casino.go')
| -rw-r--r-- | internal/game/casino.go | 323 |
1 files changed, 81 insertions, 242 deletions
diff --git a/internal/game/casino.go b/internal/game/casino.go index f111ed0..f6592ac 100644 --- a/internal/game/casino.go +++ b/internal/game/casino.go @@ -21,24 +21,18 @@ type casinoWallet struct { p *player.Player } -// Wager debits the stake durably. Order of operations: durable journal -// record FIRST (with balance checkpoints), then the in-memory mutation, -// then the character save, then the record is registered as an OUTSTANDING -// STAKE. It is NOT settled here: it stays open until the round resolves -// (casino.Wallet.ResolveStakes) or is refunded (RefundOutstanding). An open -// record at startup recovery means the round state was lost in a crash and -// the stake is refunded — the documented refund-on-crash policy. -// -// A failed save reverts the in-memory mutation and voids the record: no -// charge is kept in memory, and the error distinguishes persistence failure -// from insufficient funds. +// 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 (PayStakeCover) or consumes them +// (ResolveStake/ResolveStakes), and startup refunds anything still listed +// (see recoverPendingWagers). A failed save reverts the in-memory debit and +// ledger entry, so a player is never charged for a stake that is not durable. func (w casinoWallet) Wager(amount int) (casino.Wager, error) { if amount <= 0 { return casino.Wager{}, casino.ErrInsufficientFunds } creditsBefore := w.p.Credits - chipsBefore := countChips(w.p) - chips := chipsBefore + chips := countChips(w.p) if chips > amount { chips = amount } @@ -46,26 +40,21 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) { if creditsBefore < credits { return casino.Wager{}, casino.ErrInsufficientFunds } - rec, err := w.g.Economy.start("wager", w.p, chips, credits, - creditsBefore, creditsBefore-credits, chipsBefore, chipsBefore-chips) - if err != nil { - return casino.Wager{}, casino.ErrWalletPersistence - } 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.g.AccountStore.SaveCharacter(w.p); err != nil { if errors.Is(err, persistence.ErrDurabilityUncertain) { - // The character file was replaced but the directory sync failed: - // the debit may be durable. Keep the in-memory debit and the - // journal record OPEN instead of voiding it, so recovery compares - // checkpoints against whatever file survived and either voids or - // refunds consistently. Proceeding is safe: the record stays open - // (refundable) until the round resolves. - fmt.Fprintf(os.Stderr, "casino wager: %s save durability uncertain: %v — record kept open\n", w.p.Name, err) - w.g.Economy.registerStake(rec) - return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: rec.ID}, nil + // The character file was replaced but its directory entry may not + // be durable. The debit and the pending entry are in the visible + // file, so keep them: a surviving save refunds the stake at + // startup, and a lost one means the player still has the money. + // Either way the stake is never charged twice. + fmt.Fprintf(os.Stderr, "casino wager: %s save durability uncertain: %v — stake kept pending\n", w.p.Name, err) + return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: pending.ID}, nil } // Revert the in-memory mutation: the play must not proceed on a // stake that could not be made durable. @@ -76,270 +65,120 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) { fmt.Fprintf(os.Stderr, "casino wager revert: no inventory room for %d chips\n", chips) } } - w.p.Credits += credits - w.g.Economy.void(rec) + w.p.Credits = creditsBefore + removePendingWager(w.p, pending.ID) return casino.Wager{}, casino.ErrWalletPersistence } - // The record stays open as an outstanding stake until the round - // resolves; recovery refunds open stakes after a crash. - w.g.Economy.registerStake(rec) - return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: rec.ID}, nil + return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: pending.ID}, nil } -// PayCredits applies a payout durably. The journal record is written first; -// if the save fails, the record stays open and startup recovery re-pays it -// (the player is owed the settlement). +// PayCredits applies winnings that do not return a stake (a slot spin's +// payout: the stake was consumed by playing the round and is resolved +// separately). The credit and the save are the commit point; a failed save +// reverts the credit and reports persistence failure so the caller does not +// announce a win that is not durable. func (w casinoWallet) PayCredits(amount int) error { - return w.payCreditsFlagged(amount, false, nil) + return w.payCredits(amount, nil) } -// PayStakeCover applies a settlement that INCLUDES the return of the listed -// stake wager IDs (a table hand pays its stake back inside the payout). The -// record is journaled with that linkage, so a shutdown refund never returns -// those stakes a second time; recovery re-applies the owed payout instead. +// PayStakeCover applies a settlement whose amount INCLUDES the return of the +// listed stake wager IDs (a table hand pays its stake back inside the +// payout). The credit and the removal of those stakes happen in ONE atomic +// save, so a crash can never pay the payout AND refund the returned stake. func (w casinoWallet) PayStakeCover(amount int, stakeIDs []string) error { - return w.payCreditsFlagged(amount, false, stakeIDs) + return w.payCredits(amount, stakeIDs) } -// payCreditsFlagged journals and applies a credit payout; a settlement -// payout (refund=false) proves the round resolved during recovery, while a -// refund payout (refund=true) marks a stake RETURNED at shutdown/eviction -// (its chip part returns in chips, not through this credit payout). -// stakeIDs, when non-empty, records WHICH open stakes this settlement -// returns (table hands); empty means the payout resolves the round without -// a linked stake return (slots winnings). -func (w casinoWallet) payCreditsFlagged(amount int, refund bool, stakeIDs []string) error { - if amount <= 0 { +// payCredits credits the payout and, when stakeIDs is non-empty, removes +// those stakes from the pending ledger in the same atomic save. On any +// 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 - chipsBefore := countChips(w.p) - creditsAfter, capErr := addCreditsChecked(creditsBefore, amount) - // The journal record is written FIRST, even when the economy cap - // blocks the payout: a won settlement must never be silently dropped. - // With the cap blocking, the balance is unchanged and the open record - // keeps the owed amount durable; startup recovery re-applies it once - // the balance has room below the cap. - rec, err := w.g.Economy.startKind("payout", refund, stakeIDs, w.p, 0, amount, - creditsBefore, creditsAfter, chipsBefore, chipsBefore) - if err != nil { - // The journal is unavailable (persistent append failure). Last - // resort: pay through the character file only. The save below is - // the commit point — the money is durable even without a journal - // record, so the settlement may be announced as paid. The stake - // resolution evidence (recordStakeEvidence) recorded in that same - // file keeps recovery from treating the round as unresolved; - // without a payout record, an open wager can no longer be - // distinguished from a lost one, so the balance itself is the only - // record of what happened. - if capErr != nil { - return capErr - } - w.p.Credits = creditsAfter - // The append failed, but the record may still be IN the journal: a - // failed rollback (a truncate, or the sync that would make the - // truncation durable) leaves the bytes behind, and appendRetry can - // leave bytes from an earlier attempt even when its final error is - // definite. Record the attempted record's ID as open-payout evidence - // in the SAME save that makes this payout durable, so recovery - // recognizes a surviving record as already paid instead of - // re-applying it (a double-pay). When no record survived, the stale - // ID is pruned at the next startup. - attemptID := "" - if rec != nil { - attemptID = rec.ID - } - if attemptID != "" { - w.p.OpenPayouts = append(w.p.OpenPayouts, attemptID) - } - // The character file is the only durable record, so it must also - // carry the resolution evidence for the stakes this settlement - // resolves: a crash before the games' own ResolveStakes call would - // otherwise leave the wager records open with checkpoint balances - // that no longer match, and recovery would refund chip stakes the - // paid settlement had already consumed. Only the settled stakes are - // recorded (see recordStakeEvidence), so a sibling hand's stake stays - // refundable. The single save below is the commit point for the - // payout AND the resolution. - resolved := w.recordStakeEvidence(stakeIDs) - if serr := w.g.AccountStore.SaveCharacter(w.p); serr != nil { - w.p.Credits = creditsBefore - if attemptID != "" { - removeOpenPayout(w.p, attemptID) - } - if resolved > 0 { - w.p.ResolvedStakes = w.p.ResolvedStakes[:len(w.p.ResolvedStakes)-resolved] - } - return casino.ErrWalletPersistence - } - kind := "settlement" - if refund { - kind = "refund" - } - if errors.Is(err, errJournalAppendUncertain) { - fmt.Fprintf(os.Stderr, "economy: %s payout of %d credits for %s could not be journaled and the append could not be rolled back; recorded as open-payout evidence so recovery will not re-apply it\n", kind, amount, w.p.Name) - } else { - fmt.Fprintf(os.Stderr, "economy: %s payout of %d credits for %s could not be journaled; the character file is the only durable record\n", kind, amount, w.p.Name) - } - return nil - } + sum, capErr := addCreditsChecked(creditsBefore, amount) if capErr != nil { return capErr } - w.p.Credits = creditsAfter - // Record the payout in the character's open-payout evidence list: the - // save below is the commit point, and the file itself then proves the - // amount is included in the balance. Recovery reads this list instead - // of guessing from balance checkpoints, which unrelated credit changes - // (a shop sale, a bank deposit) can replicate by coincidence. - w.p.OpenPayouts = append(w.p.OpenPayouts, rec.ID) + var removed []player.PendingWager + if len(stakeIDs) > 0 { + removed = takePendingWagers(w.p, stakeIDs) + } + w.p.Credits = sum if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { w.p.Credits = creditsBefore - removeOpenPayout(w.p, rec.ID) - // Keep the record open: recovery re-pays on next startup, since a - // reported win must be durable. + w.p.PendingWagers = append(w.p.PendingWagers, removed...) return casino.ErrWalletPersistence } - // The evidence entry may only be dropped once the settled marker is - // durable; while the marker is still open, the file's membership is - // what proves durability to recovery. - if w.g.Economy.settleDurable(rec) { - removeOpenPayout(w.p, rec.ID) - } return nil } -// ResolveStakes marks every outstanding stake for the player settled: the -// round resolved (won, lost, pushed, or forfeited), so startup recovery -// must no longer refund it. When the journal cannot persist the settled -// markers, the record IDs are recorded in the character file as durable -// resolution evidence (player.Player.ResolvedStakes) so recovery still -// honors the resolution instead of refunding an already-resolved round. +// ResolveStakes consumes every stake still pending for the player: the round +// resolved as a loss, a forfeit, or a slots outcome whose payout carried no +// stake linkage. The pending ledger and the save are the commit point. func (w casinoWallet) ResolveStakes() { - unresolved := w.g.Economy.ResolveStakes(economyKey(w.p)) - w.recordResolutionEvidence(unresolved) -} - -// recordResolutionEvidence persists file-side resolution evidence for stake -// records whose settled markers could not be journaled. The character file -// then proves the round resolved even though the journal could not record -// its marker, so recovery still honors the resolution instead of refunding -// an already-resolved round. -func (w casinoWallet) recordResolutionEvidence(unresolved []*economyRecord) { - if len(unresolved) == 0 { - return - } - changed := false - for _, rec := range unresolved { - if !resolvedStakeRecorded(w.p, rec.ID) { - w.p.ResolvedStakes = append(w.p.ResolvedStakes, rec.ID) - changed = true - } - } - if !changed { + if len(w.p.PendingWagers) == 0 { return } + w.p.PendingWagers = nil if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { - fmt.Fprintf(os.Stderr, "casino resolve stakes: evidence save for %s: %v\n", w.p.Name, err) + fmt.Fprintf(os.Stderr, "casino resolve stakes: save for %s: %v\n", w.p.Name, err) } } -// ResolveStake settles ONE outstanding stake by wallet handle: the hand it -// belongs to was paid (its return is inside the settlement) or lost, so it -// must never be refunded. Unresolvable markers fall back to the same file- -// side evidence the bulk ResolveStakes uses. +// 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 (PayStakeCover) makes this a no-op, so no redundant +// save is paid. func (w casinoWallet) ResolveStake(stakeID string) { - if stakeID == "" { + if stakeID == "" || !removePendingWager(w.p, stakeID) { return } - unresolved := w.g.Economy.ResolveStakeByID(economyKey(w.p), stakeID) - w.recordResolutionEvidence(unresolved) -} - -// recordStakeEvidence adds the stakes the current settlement resolves to the -// file-side resolution evidence list and returns how many IDs were newly -// recorded. Used when the economy journal is unavailable: the open wager -// record IDs in the character file prove the round resolved even though no -// settled marker (or payout record) could ever be journaled. The caller -// must persist the character file for the evidence to count. -// -// stakeIDs are the stakes the payout settles (a table hand lists its own -// hand's stakes). Only those are recorded: a sibling hand's stake is settled -// by its own payout, so marking it resolved here would suppress the refund a -// crash before that payout must still produce. An empty list (a slots payout -// links no stake) settles the round that consumed the stake, so every open -// stake of the player is evidence. -func (w casinoWallet) recordStakeEvidence(stakeIDs []string) int { - added := 0 - for _, rec := range w.g.Economy.openStakes(economyKey(w.p)) { - if len(stakeIDs) > 0 && !containsString(stakeIDs, rec.ID) { - continue - } - if !resolvedStakeRecorded(w.p, rec.ID) { - w.p.ResolvedStakes = append(w.p.ResolvedStakes, rec.ID) - added++ - } + if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + fmt.Fprintf(os.Stderr, "casino resolve stake: save for %s: %v\n", w.p.Name, err) } - return added } -// RefundOutstanding returns every outstanding stake to the player: chips -// come back as chips, credits through the durable payout path. The stakes -// are resolved only once the return is durable; on failure the chip return -// is rolled back out of memory and the open records drive recovery instead. -// -// Stakes already accounted for by an open settlement payout are NOT -// refunded: a journaled table settlement includes its hand's stake return -// and recovery re-applies that owed payout, so refunding the stake here -// would pay the round twice (the returned credits then arrive on top of -// the stake). The stakes are resolved; their money arrives with the -// re-applied settlement. +// 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. On a failed +// save the in-memory return and ledger are reverted so a later attempt (or +// startup recovery) retries cleanly. func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) { - for _, rec := range w.g.Economy.openStakes(economyKey(w.p)) { - if resolvedStakeRecorded(w.p, rec.ID) { - // Already resolved through file-side evidence: recovery - // settles it, never refunds it. - continue - } - if w.g.Economy.stakeCovered(rec) { - // The covering settlement is journaled and owed: resolving - // the stake defers its return to the payout re-application. - continue - } - chips += rec.Chips - credits += rec.Credit + before := append([]player.PendingWager(nil), w.p.PendingWagers...) + for _, pending := range before { + chips += pending.Chips + credits += pending.Credits } - if chips <= 0 && credits <= 0 { - w.ResolveStakes() + 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) { - // No inventory room for the chip return: nothing was refunded. - // The stakes stay open so recovery (or a later attempt once the - // player has inventory space) can complete the refund; callers - // report the stake as recorded rather than refunded, never as - // silently dropped. + w.p.PendingWagers = before return 0, 0, casino.ErrWalletPersistence } } - if credits > 0 { - if err := w.payCreditsFlagged(credits, true, nil); err != nil { - if chips > 0 { - // Roll the chip return back out of memory: the durable - // refund will re-apply both amounts from the open records. - w.p.RemoveItem("chips", chips) - } - return chips, credits, err + sum, capErr := addCreditsChecked(creditsBefore, credits) + if capErr != nil { + if chips > 0 { + w.p.RemoveItem("chips", chips) } - } else if chips > 0 { - // A chips-only refund needs its own durable save. - if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + w.p.PendingWagers = before + return 0, 0, casino.ErrWalletPersistence + } + w.p.Credits = sum + if err := w.g.AccountStore.SaveCharacter(w.p); err != nil { + w.p.Credits = creditsBefore + if chips > 0 { w.p.RemoveItem("chips", chips) - return chips, 0, casino.ErrWalletPersistence } + w.p.PendingWagers = before + return 0, 0, casino.ErrWalletPersistence } - w.ResolveStakes() return chips, credits, nil } |
