aboutsummaryrefslogtreecommitdiff
path: root/internal/game/casino.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-18 21:36:36 -0400
committerhistoria <[not public]>2026-09-18 21:36:36 -0400
commita8a67482bbff8297a96adff65d2fd7029ed998cc (patch)
treee84f8ba636c8f2edb4d2ff8b45d7cff7298fa7cc /internal/game/casino.go
parentff04d26ce90d6b411a04a2fb59f7c24ceefb161f (diff)
downloadthehouseoficarus-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.go323
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
}