From a8a67482bbff8297a96adff65d2fd7029ed998cc Mon Sep 17 00:00:00 2001 From: historia <[not public]> Date: Fri, 18 Sep 2026 21:36:36 -0400 Subject: refactor: replace economy journal with single-file pending-wager wallet --- .gitignore | 2 - building_guide/concurrency.md | 139 +- internal/casino/baccarat.go | 6 +- internal/casino/blackjack.go | 14 +- internal/casino/core.go | 13 +- internal/casino/correct_casino_refund_bank_test.go | 2 +- internal/casino/credits.go | 11 +- internal/casino/machine.go | 118 +- internal/casino/manager.go | 8 +- internal/casino/types.go | 49 +- internal/game/account_txn.go | 13 +- internal/game/casino.go | 323 ++--- internal/game/casino_forfeit_regression_test.go | 2 +- internal/game/casino_refund_regression_test.go | 141 +- internal/game/cmd_bank.go | 7 +- internal/game/economy.go | 1466 -------------------- internal/game/economy_journal_test_helpers_test.go | 14 - internal/game/economy_open_payout_list_test.go | 120 -- internal/game/economy_reanchor_test.go | 31 - internal/game/economy_recovery_identity_test.go | 128 -- internal/game/economy_recovery_regression_test.go | 546 -------- internal/game/economy_rollback_regression_test.go | 89 -- internal/game/economy_seq_test.go | 24 - internal/game/economy_shutdown_test.go | 273 ---- internal/game/game.go | 24 +- internal/game/hardening_economy_durability_test.go | 272 ---- internal/game/hardening_fixes_batch2_test.go | 40 +- internal/game/journal_damage_regression_test.go | 123 -- internal/game/journal_failure_regression_test.go | 243 ---- internal/game/money.go | 182 +++ internal/game/payout_evidence_regression_test.go | 76 - internal/game/quit_casino_test.go | 3 +- internal/game/readiness_regression_test.go | 216 +-- internal/game/wallet_test.go | 240 ++++ internal/player/player.go | 31 +- 35 files changed, 760 insertions(+), 4229 deletions(-) delete mode 100644 internal/game/economy.go delete mode 100644 internal/game/economy_journal_test_helpers_test.go delete mode 100644 internal/game/economy_open_payout_list_test.go delete mode 100644 internal/game/economy_reanchor_test.go delete mode 100644 internal/game/economy_recovery_identity_test.go delete mode 100644 internal/game/economy_recovery_regression_test.go delete mode 100644 internal/game/economy_rollback_regression_test.go delete mode 100644 internal/game/economy_seq_test.go delete mode 100644 internal/game/economy_shutdown_test.go delete mode 100644 internal/game/hardening_economy_durability_test.go delete mode 100644 internal/game/journal_damage_regression_test.go delete mode 100644 internal/game/journal_failure_regression_test.go create mode 100644 internal/game/money.go delete mode 100644 internal/game/payout_evidence_regression_test.go create mode 100644 internal/game/wallet_test.go diff --git a/.gitignore b/.gitignore index 358ebe1..f9ab5c2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,8 +2,6 @@ !/data/players/accounts/.gitkeep /data/players/characters/* !/data/players/characters/.gitkeep -# Durable money journal: runtime state, never commit. -/data/economy/ # Admin undo history (password hashes are redacted, but never commit it). /data/.admin_history.json /data/.admin_history.json.bak diff --git a/building_guide/concurrency.md b/building_guide/concurrency.md index 081952f..f0b8b06 100644 --- a/building_guide/concurrency.md +++ b/building_guide/concurrency.md @@ -1,7 +1,7 @@ # Concurrency & durability This guide documents the runtime architecture decisions that keep live game -state safe: the single-owner loop, the durable money journal, the account +state safe: the single-owner loop, single-file durable money, the account lifecycle transactions, and the graceful-shutdown sequence. ## Single-owner game state @@ -114,94 +114,55 @@ operations for live renumbering, and stop the game for external bulk edits. Some definition editors still need the in-game `reload` command; serialization does not add missing cache invalidations or migrate existing spawned entities. -## Durable money (casino wallet) - -Every wager and payout is journaled before the balance mutation -(`internal/game/economy.go`, `data/economy/journal.jsonl`): - -1. A durable journal record with balance checkpoints (before/after credits - and chips) is appended and fsynced. -2. The in-memory balance mutates. -3. The character save makes the balance durable. - -Two record kinds exist: - -- **wager** — a committed stake. The record stays OPEN while the stake is - outstanding (round in flight) and is resolved when the round settles: - won, lost, pushed, forfeited (disconnect), or refunded (shutdown). Games - resolve stakes through `casino.Wallet.ResolveStakes` after settlement - (or per hand via `ResolveStake`); `casino.Wallet.RefundOutstanding` - returns every outstanding stake (chips as chips, credits durably) at - shutdown/eviction, exactly once. A stake already covered by an open - settlement payout is resolved WITHOUT a refund: a table settlement - includes its hand's stake return and a slots payout proves the round - resolved, so refunding would double-pay the round — recovery re-applies - the owed payout instead, and the covered stakes receive their money - through it. -- **payout** — money movement (winnings, pushes, refunds). The record - closes when the balance mutation is durable. - -A failed save reverts the in-memory mutation and voids the record — the -game must not proceed on a stake that could not be made durable, and the -player is told the house could not process the transaction (distinct from -an insufficient-funds message). A failed payout keeps its record open (the -reported win is never announced as paid) and startup recovery re-pays the -owed settlement. - -If the JOURNAL ITSELF cannot be appended (persistent write failure, e.g. a -deleted or unwritable `data/economy`), appends are retried and then the -wallet degrades safely: - -- A **wager** is never charged without a journal record — the bet is - rejected with the persistence message (unchanged). -- A **payout** falls back to paying through the character file alone (the - save is the commit point), so the settlement is real, announced, and - durable even with no journal record. `ResolveStakes` then records the - unresolved stake IDs in the character file (`player.Player - .ResolvedStakes`) as durable resolution evidence, and recovery settles - those stakes without refunding them (no double pay, and a settlement's - chip stake is not returned in kind on top of credits that already - returned it). If the character save fails too, no evidence exists and - the open wager record drives the ordinary refund-on-lost-state recovery. - The same save records the attempted payout record's ID in the character - file's `open_payouts` evidence list. A failed append is not proof that no - record was written — a failed rollback (the truncate, or the sync that - would make the truncation durable) can leave the record bytes behind, and - a retry can leave bytes from an earlier attempt even when the final error - is definite. Recording the ID means a surviving open payout is settled by - recovery from the file evidence instead of being re-applied (a - double-pay); when no record survived, the stale ID is pruned at the next - startup. -- A journal that cannot be fully READ (oversized or unreadable line) or - that contains a malformed record with later records present aborts - recovery and compaction entirely: records are preserved verbatim for - operator inspection instead of compensating over a partial fold or - truncating the file. A torn FINAL line (crash mid-append) is still - tolerated and skipped. - -At startup (`game.New`), open records are compensated in two phases: - -1. Open **payout** records (append order, evolving balance): file at the - pre-payout balance → re-apply (the player is owed it); at the post-payout - balance → already durable, settle; anything else stays open and is - surfaced for operator inspection — a liability is never silently - dropped. Payouts written by current code are **Tracked**: the record ID - is stored in the character file's `open_payouts` evidence list, and - recovery proves durability from that evidence rather than from the - balance checkpoints (which remain only as a legacy fallback). -2. Open **wager** records (newest first, so each refund restores the - balance the previous record's checkpoints refer to): file at the - pre-debit state (credits AND chips) → void (nothing was charged); at - the post-debit state → the stake is **refunded** (the round state was - lost in the crash) — unless a later payout record proves the round - resolved (the win/push/refund already returned the stake inside that - payout), in which case only the lost resolution marker is re-applied. - Settlement payouts may carry an explicit `covers` list naming the stake - IDs they return (a table hand's payout): recovery then resolves - exactly those stakes. A payout without a list (legacy records, slots - winnings) resolves every open stake of its player — a journaled - settlement means the round resolved, and its re-application delivers - the money. Ambiguous records stay open for operator inspection. +## Durable money (single-file wallet) + +Money lives in the character file and **nowhere else**. Every casino money +mutation is one atomic `AccountStore.SaveCharacter` (`persistence.WriteFile`: +temp file → fsync → rename → directory fsync) that carries both the balance +and the `Player.PendingWagers` ledger. There is no second durable store, so +there is no cross-file crash window to reconcile. + +- **Wager** — debits chips then credits and appends a `PendingWager` (its + wallet handle plus the exact chips/credits debited) in the SAME save. The + entry stays until the round resolves. +- **Settlement** — `PayStakeCover` credits the payout and removes the stakes + it returns in the same save (a table hand includes its stake return in the + payout). `PayCredits` credits winnings that carry no stake linkage (a slot + spin: the stake was consumed by the round) and the game then resolves the + stake with `ResolveStake`/`ResolveStakes`. +- **Loss/forfeit** — `ResolveStake`/`ResolveStakes` consume the pending + stakes in one save; the round resolved and the stake is gone. +- **Refund** — `RefundOutstanding` returns every pending stake (chips in + kind, credits as credits) and clears the ledger in one save. Graceful + shutdown (`Game.runShutdown` → `Casino.RefundAll`) and eviction use it. + +A failed save reverts the in-memory balance and ledger together, so a player +is never charged for, credited with, or refunded a stake that is not durable. +On `persistence.ErrDurabilityUncertain` a wager keeps its in-memory debit and +pending entry, because the visible file already carries both. + +At startup (`game.New` → `recoverPendingWagers`, `internal/game/money.go`) +any stake still listed in a character's `PendingWagers` is refunded: a listed +stake means the process died before its round resolved. That is the entire +recovery story — no ordering, checkpoints, evidence, or operator queue. + +### Two-phase journal recovery is out of scope + +This game deliberately does **not** implement a durable money journal or +two-phase crash recovery. That design was built and then removed: it required +a second file (`data/economy/journal.jsonl`) plus balance checkpoints, record +ordering (`Seq`), payout `Covers` linkage, `open_payouts` evidence, and a +startup recovery pass with per-record ambiguity handling. Its entire purpose +was to make an ungraceful stop mid-round produce zero credit discrepancies, +at a cost of ~1,400 lines of engine code and ~2,000 lines of tests. + +With the crash-consistency bar deliberately lowered to "a rare ungraceful +stop may lose or refund one in-flight stake, and must never double-pay", the +single atomic character save is sufficient: a listed pending stake is +refunded, and a settlement whose save fails simply leaves the stake pending +and the win unannounced (never paid on top of the refund). Do not reintroduce +a second money store, per-record ordering, or an operator-inspection queue +without an explicit decision to restore bank-grade guarantees. Currency arithmetic is checked (`internal/casino/credits.go`): explicit `MaxCredits` cap, bounded `DefaultMaxBet` default (the explicit unlimited diff --git a/internal/casino/baccarat.go b/internal/casino/baccarat.go index cf8a289..2b2b060 100644 --- a/internal/casino/baccarat.go +++ b/internal/casino/baccarat.go @@ -168,9 +168,9 @@ func (t *baccaratTable) deal() []Event { continue } result, payout, settleErr := settleBaccaratBet(p.lastSpot, p.Wager.Total, playerTotal, bankerTotal, t.Rules) - // The shared settlement path pays before announcing, leaves the - // stake open on any failure (recovery re-applies the owed payout), - // and resolves it only once durable. + // The shared settlement path pays before announcing and leaves the + // stake pending on any failure, so a refund can never be paid on top + // of a payout that was not durable. payoutFailed := settleStakeOutcome(p.Wallet, payout, []string{p.stakeID}, settleErr) publicResult, privateResult := baccaratResultMessages(p.Name, result, payout) if payoutFailed { diff --git a/internal/casino/blackjack.go b/internal/casino/blackjack.go index 55ee702..3b08ba5 100644 --- a/internal/casino/blackjack.go +++ b/internal/casino/blackjack.go @@ -596,8 +596,8 @@ func (t *blackjackTable) settle() []Event { for _, hand := range p.hands { result, payout, settleErr := settleHand(hand, dealerTotal, dealerBlackjack, t.Rules) // One shared settlement path for every game: pay before - // announcing, leave the stakes open on any failure, and resolve - // them only once the payout is durable. + // announcing, and leave the stakes pending on any failure so a + // refund can never be paid on top of a payout that was not durable. if settleStakeOutcome(p.Wallet, payout, hand.stakes, settleErr) { payoutFailed = true publicResult, _ := blackjackResultMessages(p.Name, result, payout) @@ -627,8 +627,8 @@ func (t *blackjackTable) settle() []Event { } } if p.Wallet != nil && !payoutFailed { - // The round resolved; the stakes are no longer outstanding and - // startup recovery must not refund them. + // The round resolved; the stakes are no longer pending and must + // never be refunded. p.Wallet.ResolveStakes() } } @@ -781,8 +781,8 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { } if p := t.players.get(name); p != nil && p.HasBet && p.Wallet != nil { if t.phase == blackjackPlaying || t.phase == blackjackInsurance { - // A disconnected player forfeits the round in flight: resolve the - // stakes now so startup recovery does not refund them later. + // A disconnected player forfeits the round in flight: consume the + // stakes now so a later refund cannot return them. p.Wallet.ResolveStakes() } else { // No card has been dealt for this stake yet, so refund it: nothing @@ -812,7 +812,7 @@ func (t *blackjackTable) markDisconnectedLocked(name string) []Event { } // refundAll refunds every committed wager at this table (graceful -// shutdown/eviction): the wallet returns every outstanding stake — opening +// shutdown/eviction): the wallet returns every pending stake — opening // bet, doubles, splits, and insurance — exactly once. It then RESETS the // round, so a refunded round can never settle (and pay) afterwards: calling // it twice, or letting the table keep running, cannot pay the same round diff --git a/internal/casino/core.go b/internal/casino/core.go index a0ac1c8..4177094 100644 --- a/internal/casino/core.go +++ b/internal/casino/core.go @@ -116,7 +116,7 @@ func (c *tableCore[P]) refundAllLocked() []Event { refund := chips + credits if err != nil || refund <= 0 { if err != nil { - events = append(events, c.private(name, "Shutdown refund could not be persisted; the stake is recorded for recovery.")) + events = append(events, c.private(name, "Shutdown refund could not be persisted; the stake stays pending for the next startup.")) } continue } @@ -131,11 +131,12 @@ func (c *tableCore[P]) refundAllLocked() []Event { // reports whether the settlement could NOT be made durable. It carries the // money-critical ordering shared by every table game (documented in // building_guide/concurrency.md): compute the payout, pay it via -// PayStakeCover BEFORE announcing the win, and only then resolve the stakes -// the settlement returned. On a persistence failure the stakes are left open -// so recovery re-applies the owed payout and a shutdown refund never returns -// them a second time; the caller must not announce a paid win and must not -// run the player-wide ResolveStakes. +// PayStakeCover BEFORE announcing the win (that one save both credits the +// payout and removes the stakes it returns), and only then stand the hand +// down. On a persistence failure the stakes stay pending, so a later shutdown +// refund or startup recovery returns them exactly once — the payout was never +// durable, so it can never be paid on top of the refund. The caller must not +// announce a paid win and must not run the player-wide ResolveStakes. // // payoutErr is a payout-computation error (for example an overflowed // multiplier): it is treated exactly like a persistence failure so a diff --git a/internal/casino/correct_casino_refund_bank_test.go b/internal/casino/correct_casino_refund_bank_test.go index 4262997..497b5c2 100644 --- a/internal/casino/correct_casino_refund_bank_test.go +++ b/internal/casino/correct_casino_refund_bank_test.go @@ -95,7 +95,7 @@ func TestMachineRefundAllFailedRefundKeepsStakeOpenForRecovery(t *testing.T) { if wallet.paid != 0 { t.Fatalf("a failed refund paid %d; want 0", wallet.paid) } - if !hasMessage(events, "recorded for recovery") { + if !hasMessage(events, "stake stays pending") { t.Fatalf("the failed refund was not reported: %+v", events) } } diff --git a/internal/casino/credits.go b/internal/casino/credits.go index 19364da..8ef8868 100644 --- a/internal/casino/credits.go +++ b/internal/casino/credits.go @@ -73,13 +73,10 @@ func walletErrorMessage(err error, context ...string) string { return "You don't have enough chips and credits for that bet." } -// payoutFailedMessage explains a settlement the wallet could not persist: -// the journal record for the payout stays open, so startup recovery will -// re-apply the owed amount. Games must NOT announce such a payout as paid. // payoutFailedMessage explains a settlement the wallet could not apply right -// now. This covers both a persistence failure and a credit-cap block (the -// journal record stays open either way), so it must not assert a persistence -// failure as the cause. Recovery re-applies the owed amount. +// now: a persistence failure or a credit-cap block. Games must NOT announce +// such a payout as paid. The stake stays pending, so it is returned by a +// shutdown refund or at the next startup rather than paid as winnings. func payoutFailedMessage() string { - return "The house could not process your payout right now. Your settlement is recorded and will be restored when you return." + return "The house could not process your payout right now. Your stake is safe and will be returned if the settlement cannot be completed." } diff --git a/internal/casino/machine.go b/internal/casino/machine.go index 3e1e0af..5c73b6f 100644 --- a/internal/casino/machine.go +++ b/internal/casino/machine.go @@ -32,13 +32,13 @@ type Machine struct { payoutRemainder float64 // stakeID is the wallet handle of the wager committed to the current // round (the triggering wager for a free-spin feature). Resolving by - // handle — not the player-wide ResolveStakes — keeps an older - // failed-payout stake open as recovery evidence when a new round starts. + // handle — not the player-wide ResolveStakes — consumes only this + // round's stake, leaving an older failed-payout stake pending. stakeID string // featureBank holds the winnings of a paid round whose free-spin feature // is still running. They are paid as ONE settlement when the feature - // ends: see finishSpin for why no payout may be journaled while the - // triggering stake is outstanding. + // ends: see finishSpin for why the triggering stake stays pending until + // then. featureBank float64 profile SlotProfile rng *rand.Rand @@ -46,8 +46,9 @@ type Machine struct { autoBet bool autoCountdown int // stakeFailed marks the in-flight stake whose payout could not be - // persisted: the stake record stays OPEN as recovery evidence, so the - // seat must not resolve (forfeit) it when the player leaves. + // persisted: the stake stays pending, so the seat must not consume + // (forfeit) it when the player leaves — a refund or startup recovery + // returns it. stakeFailed bool } @@ -238,13 +239,11 @@ func (m *Machine) autoBetTick() []Event { // triggering stake once the whole round (feature included) is over. // // A free-spin feature pays out as ONE settlement when it ends. While the -// feature runs its winnings are banked in memory and nothing is journaled: -// the triggering stake stays OUTSTANDING until the feature completes, and -// recovery reads ANY payout record for the player as proof that the round -// resolved (a PayCredits record carries no stake linkage, see the Wallet -// contract). Paying a free spin as it landed would therefore make a crash -// mid-feature settle the stake instead of refunding it, contradicting the -// refund-on-crash contract that a graceful shutdown honors. +// feature runs its winnings are banked in memory and the triggering stake +// stays PENDING (nothing is resolved): paying a free spin as it landed would +// consume the stake early, so a crash mid-feature would eat it instead of +// refunding it. Banking the winnings keeps the refund-on-lost-round behavior +// intact until the feature completes. func (m *Machine) finishSpin() []Event { events := []Event{} result := m.spin.Payout + m.payoutRemainder @@ -279,11 +278,10 @@ func (m *Machine) finishSpin() []Event { payFailed := false if payNow > 0 && m.player.Wallet != nil { if err := m.player.Wallet.PayCredits(payNow); err != nil { - // The payout could not be made durable; recovery will re-apply - // the owed amount from the open journal record. Do not announce - // the win as paid, stop any autobet chain, and keep the stake - // OPEN (stakeFailed): resolving it here would erase the refund - // evidence for the owed settlement. + // The payout could not be made durable. Do not announce the win as + // paid, stop any autobet chain, and keep the stake PENDING + // (stakeFailed): the settlement was never durable, so the stake is + // refunded rather than paid. payFailed = true m.stakeFailed = true m.autoBet, m.autoCountdown = false, 0 @@ -324,10 +322,10 @@ func (m *Machine) finishSpin() []Event { events = append(events, m.public(fmt.Sprintf("Three scatter symbols trigger %d free spins!", m.profile.FreeSpinCount))...) } if m.freeSpins > 0 { - // A feature is in progress: the triggering stake stays OUTSTANDING - // until the bonus completes, so a graceful shutdown (or crash) while - // free spins remain refunds the stake through RefundOutstanding / - // startup recovery instead of silently eating the feature. + // A feature is in progress: the triggering stake stays PENDING until + // the bonus completes, so a graceful shutdown (or crash) while free + // spins remain refunds the stake through RefundOutstanding / startup + // recovery instead of silently eating the feature. m.freeSpins-- m.phase = machineSpinning m.spin = generateSlotSpinWithProfile(m.rng, m.spinBet, m.profile) @@ -337,9 +335,9 @@ func (m *Machine) finishSpin() []Event { m.inFreeSpins = false if !payFailed && m.player.Wallet != nil { // The paid round fully resolved (the feature either never - // started or just completed; free spins wager nothing): resolve - // ONLY this round's stake, so an older failed-payout stake stays - // open as recovery evidence. + // started or just completed; free spins wager nothing): consume + // ONLY this round's stake, leaving an older failed-payout stake + // pending. m.player.Wallet.ResolveStake(m.stakeID) m.stakeID = "" m.stakeFailed = false @@ -367,10 +365,9 @@ func (m *Machine) leave(name string) []Event { // resetSeat frees the machine, discarding any in-flight spin and autobet // state. A wager already taken for a spin in progress is forfeited: its -// stake is resolved so startup recovery does not refund it. The exception -// is a stake whose payout could not be persisted (stakeFailed): the open -// stake record is the only evidence of the owed settlement, so it is left -// unresolved for recovery (or the shutdown refund) to complete. +// stake is consumed so it is not refunded. The exception is a stake whose +// payout could not be persisted (stakeFailed): it is left pending for a +// shutdown refund or startup recovery. func (m *Machine) resetSeat() { if m.player != nil && m.player.Wallet != nil && !m.stakeFailed { m.player.Wallet.ResolveStake(m.stakeID) @@ -394,11 +391,10 @@ func (m *Machine) resetSeat() { // in-flight wager, so the seat is immediately free for someone else. // // Already-landed free-spin winnings (featureBank) are NOT part of the -// in-flight wager: they are settled after resetSeat resolves the triggering -// stake, so the unlinked settlement cannot be read at recovery as covering -// that stake. If the round's payout could not be persisted (stakeFailed), the -// stake is left open for recovery and the bank is reported as forfeited -// rather than silently destroyed. +// in-flight wager: they are settled after resetSeat consumes the triggering +// stake. If the round's payout could not be persisted (stakeFailed), the +// stake is left pending and the bank is reported as forfeited rather than +// silently destroyed. func (m *Machine) markDisconnected(name string) []Event { m.mu.Lock() defer m.mu.Unlock() @@ -423,7 +419,7 @@ func (m *Machine) markDisconnected(name string) []Event { case wallet.PayCredits(bank) != nil: events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: name, Player: name, - PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they are recorded for recovery.", bank), + PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they were forfeited.", bank), Color: "casino_action", Public: false}) default: events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, @@ -508,19 +504,16 @@ func (m *Machine) privateMenu(prefix string) []Event { return events } -// refundAll refunds any outstanding stake at graceful shutdown/eviction and -// then DISCARDS the round (the machine closes with the process). The wallet -// resolves every outstanding stake exactly once; a completed spin has no -// outstanding stake and is not refunded again. A stake whose payout failed -// to persist (stakeFailed) is NOT returned here when an open payout record -// covers it — the journaled winnings prove the round resolved, so the stake -// was consumed by playing it and recovery re-applies the owed payout instead -// of double-paying the round. A bonus in progress keeps its triggering stake -// outstanding with no payout journaled, so a shutdown during the feature -// returns the stake (only the still-unplayed free spins' EV is forfeit); the -// winnings that have already landed are banked in featureBank and are paid -// through the wallet as a settlement, so a graceful shutdown preserves them -// exactly as the pre-banking code did. Locked. +// refundAll refunds any pending stake at graceful shutdown/eviction and then +// DISCARDS the round (the machine closes with the process). RefundOutstanding +// returns every pending stake exactly once; a completed spin has none and is +// not refunded again. A stake whose payout could not be persisted +// (stakeFailed) is left pending by RefundOutstanding and returned here, since +// the settlement never became durable. A bonus in progress keeps its +// triggering stake pending, so a shutdown during the feature returns the +// stake (only the still-unplayed free spins' EV is forfeit); winnings that +// have already landed are banked in featureBank and paid through the wallet, +// so a graceful shutdown preserves them. Locked. // // Discarding the round is what makes the call safe to repeat: a refunded // round can never settle (and pay its spin) afterwards, so neither a second @@ -535,28 +528,27 @@ func (m *Machine) refundAll() []Event { // Free-spin winnings that have already landed live only in featureBank // until the feature ends (see finishSpin), so discarding the round must // settle them or a graceful shutdown silently destroys them. Refund the - // outstanding triggering stake FIRST: the settlement that pays the bank - // below carries no stake linkage, so a journaled settlement with the - // stake still open would read at recovery as covering (and consuming) - // that stake, suppressing its refund. + // pending triggering stake FIRST, then pay the bank: the two are separate + // (the stake is returned because the feature never completed, the bank is + // winnings already won). chips, credits, err := wallet.RefundOutstanding() refund := chips + credits bank := int(m.featureBank) bankFailed := false if err == nil && bank > 0 { - // Only settle the bank once the refund is settled: on a failed refund - // the stake stays open for recovery, and a settlement payout would - // make recovery treat it as consumed instead of refunded. + // Only settle the bank once the refund is durable: on a failed refund + // the stake stays pending, and paying the bank first would leave the + // two obligations tangled. if payErr := wallet.PayCredits(bank); payErr != nil { bankFailed = true } } - // A refund that could not be made durable leaves the stakes open for - // recovery to complete; the round is still discarded, and marking the - // stake as failed keeps resetSeat from resolving (forfeiting) it. This is - // LOAD-BEARING, not dead code: on the RefundOutstanding error path the - // stakes are left open, and resetSeat's guard is the only thing that - // stops it from resolving them and erasing the recovery evidence. + // A refund that could not be made durable leaves the stakes pending; the + // round is still discarded, and marking the stake as failed keeps + // resetSeat from consuming (forfeiting) it. This is LOAD-BEARING, not + // dead code: on the RefundOutstanding error path the stakes are left + // pending, and resetSeat's guard is the only thing that stops it from + // consuming them. m.stakeFailed = err != nil m.resetSeat() var events []Event @@ -564,7 +556,7 @@ func (m *Machine) refundAll() []Event { case err != nil: events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: name, Player: name, - PrivateMessage: "Shutdown refund could not be persisted; the stake is recorded for recovery.", + PrivateMessage: "Shutdown refund could not be persisted; the stake stays pending for the next startup.", Color: "casino_action", Public: false}) case refund > 0: events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, @@ -583,7 +575,7 @@ func (m *Machine) refundAll() []Event { case bankFailed: events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, PrivateTo: name, Player: name, - PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they are recorded for recovery.", bank), + PrivateMessage: fmt.Sprintf("Your %d credits of free-spin winnings could not be persisted; they were forfeited.", bank), Color: "casino_action", Public: false}) case err != nil && bank > 0: events = append(events, Event{RoomID: m.RoomID, TableID: m.Config.ID, Type: EventResult, diff --git a/internal/casino/manager.go b/internal/casino/manager.go index d9b2c3e..f880463 100644 --- a/internal/casino/manager.go +++ b/internal/casino/manager.go @@ -329,8 +329,8 @@ func (m *Manager) MachineBusy(name string) bool { // RefundAll resolves every outstanding wager by refunding committed stakes // to their players and then closing each game's round. Graceful shutdown and // room eviction use this: rounds cannot settle, so no player may lose a -// stake to the shutdown itself. Refunds go through the wallet (durable, -// journaled) and the refund events are returned for delivery. +// stake to the shutdown itself. Refunds go through the wallet (durable +// character saves) and the refund events are returned for delivery. // // It is deliberately NOT a way to clear live tables: every game discards // the round it refunded (seats keep their last bet only for "rebet", but @@ -414,8 +414,8 @@ func (m *Manager) RekeyRooms(idMap map[int]int) { func (m *Manager) EvictRoom(roomID int) []Event { prefix := fmt.Sprintf("%d:", roomID) // Snapshot the games under the manager lock, then refund OUTSIDE it: - // refundAll appends to the money journal and saves character files, and - // holding m.mu across that I/O blocks every casino command and Tick. + // refundAll saves character files, and holding m.mu across that I/O + // blocks every casino command and Tick. type evictable interface{ refundAll() []Event } var evicted []evictable m.mu.Lock() diff --git a/internal/casino/types.go b/internal/casino/types.go index 980769c..a5e1415 100644 --- a/internal/casino/types.go +++ b/internal/casino/types.go @@ -191,44 +191,39 @@ type Wager struct { Total int Chips int Credits int - // ID is the wallet's opaque handle for the journaled stake record: - // table settlements pay through PayStakeCover with the hand's stake - // IDs so the journal can prove which wager a settlement returns. + // ID is the wallet's opaque handle for the committed stake. Table + // settlements pass the hand's stake IDs to PayStakeCover so the wallet + // removes exactly the stakes the payout returns. ID string } type Wallet interface { - // Wager debits the stake durably. ErrInsufficientFunds is a normal - // rejected bet; ErrWalletPersistence means the debit could not be made - // durable and the game must not proceed as if the bet succeeded. + // Wager debits the stake and records it as pending. ErrInsufficientFunds + // is a normal rejected bet; ErrWalletPersistence means the debit could + // not be made durable and the game must not proceed as if it succeeded. Wager(amount int) (Wager, error) - // PayCredits pays a settlement durably, returning an error when the - // credit could not be persisted (the game must not report a win that - // is not durable). The journaled record carries no stake linkage: - // recovery treats it as resolving the round (a slots payout is - // winnings only; the stake was consumed by playing the round). + // PayCredits pays winnings durably, returning an error when the credit + // could not be persisted (the game must not report a win that is not + // durable). The payout carries no stake linkage: the stake was consumed + // by playing the round, so the game resolves it separately. PayCredits(amount int) error // PayStakeCover pays a settlement whose amount INCLUDES the return of // the listed stake wager IDs (a table hand pays back its stake inside - // the payout). When the payout cannot be made durable, the open - // journal record still proves those stakes were returned inside it, so - // a shutdown refund must not return them a second time and recovery - // re-applies the owed payout instead. + // the payout). The credit and the removal of those stakes are one + // durable step, so a failed settlement leaves the stakes pending — + // refunded at the next startup, never paid on top of the refund. PayStakeCover(amount int, stakeIDs []string) error - // ResolveStakes marks every outstanding stake for this player as - // settled: the round resolved (won, lost, pushed, or forfeited), so - // startup recovery must no longer refund it. A stake whose wager - // record remains open at restart is refunded by recovery. + // ResolveStakes consumes every pending stake for this player: the round + // resolved as a loss, a forfeit, or an unlinked payout, so the stakes + // must not be refunded. ResolveStakes() - // ResolveStake settles ONE outstanding stake by wallet handle (a - // single hand paid or lost while a sibling hand's payout is still in - // flight): its return is proven, so it must never be refunded. + // ResolveStake consumes ONE pending stake by wallet handle (a single + // hand paid or lost while a sibling hand's round is still in flight). ResolveStake(stakeID string) - // RefundOutstanding returns every outstanding stake to the player - // (chips as chips, credits through the durable payout path) and - // resolves the stakes. It reports the amounts returned. An error - // means the refund could not be made durable: the stakes remain open - // and startup recovery completes the refund. + // RefundOutstanding returns every pending stake to the player (chips as + // chips, credits as credits) and clears the ledger. An error means the + // refund could not be made durable: nothing was returned and the stakes + // remain pending for a later attempt or startup recovery. RefundOutstanding() (chips, credits int, err error) } diff --git a/internal/game/account_txn.go b/internal/game/account_txn.go index 13f7695..6e1e2d1 100644 --- a/internal/game/account_txn.go +++ b/internal/game/account_txn.go @@ -48,10 +48,10 @@ func (g *Game) adoptCharacterIdentity(p *player.Player, accountName string) { p.Account = accountName } if err := g.AccountStore.SaveCharacter(p); err != nil { - // Identity backfill is best-effort, but a NON-DURABLE ID must never - // become an economy key: a later startup's backfill would assign a - // different ID and strand every journal record written under this - // one. Fall back to the durable character name instead. + // Identity backfill is best-effort, but a NON-DURABLE ID must not + // survive in memory: a later startup's backfill would assign a + // different one, and any reference cached under this ID would point + // at nothing. Fall back to the durable character name instead. if assignedID != "" { p.ID = "" } @@ -201,8 +201,9 @@ func (g *Game) renameCharacterTxn(accountName, oldName, newName string) error { } p.Name = newName if p.ID == "" { - // Assign the stable identity at rename time so journal records - // written after this point resolve the renamed character by ID. + // Assign the stable identity at rename time so cached references + // (and the owner/membership checks) resolve the renamed character + // by ID rather than the new filename. p.ID = newCharacterID() } data, err := g.AccountStore.MarshalCharacter(p) 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 } diff --git a/internal/game/casino_forfeit_regression_test.go b/internal/game/casino_forfeit_regression_test.go index d29187d..c064712 100644 --- a/internal/game/casino_forfeit_regression_test.go +++ b/internal/game/casino_forfeit_regression_test.go @@ -54,7 +54,7 @@ func TestDisconnectForfeitsBlackjackStake(t *testing.T) { p.Credits = 100 h.g.Casino.Join(1, casino.TableConfig{ID: "bj", Game: casino.GameBlackjack, MaxPlayers: 2}, "alice") h.g.Casino.Bet(1, "bj", "alice", "", 10, casinoWallet{g: h.g, p: p}) - committed = len(h.g.Economy.openStakes("alice")) == 1 + committed = len(p.PendingWagers) == 1 }) h.barrier() } diff --git a/internal/game/casino_refund_regression_test.go b/internal/game/casino_refund_regression_test.go index 90864e2..6842c32 100644 --- a/internal/game/casino_refund_regression_test.go +++ b/internal/game/casino_refund_regression_test.go @@ -1,10 +1,9 @@ package game -// Settlement/refund regressions for the durable-money audit: a settlement -// whose save failed is journaled as owed money, so a shutdown refund must -// NOT also return the round's stake — recovery re-applies the owed payout, -// which already contains the stake's return (table hands) or the round's -// winnings (slots, where the stake was consumed by playing the round). +// Settlement/refund regressions for single-file money: a settlement credits +// the payout and clears the stakes it returns in ONE character save, so a +// failed settlement leaves the stake pending (and refundable) rather than +// paying it twice; an outstanding stake with no settlement is refunded. import ( "path/filepath" @@ -23,12 +22,11 @@ func failingSaveDuring(g *Game, dir string, fn func()) { fn() } -// TestFailedTableSettlementDoesNotDoubleReturnStake: a won hand whose payout -// save fails leaves the settlement journaled and owed. A graceful-shutdown -// refund must not ALSO return the stake; recovery re-applies the payout -// (which returns the stake inside it) exactly once. 100 - 10 + 20 = 110, -// never 120. -func TestFailedTableSettlementDoesNotDoubleReturnStake(t *testing.T) { +// TestFailedSettlementLeavesStakeRefundable: a settlement whose save fails +// changes nothing — the credit is reverted and the stake stays pending. A +// restart refunds the stake; the win was never durable, and it is never paid +// on top of a refund. +func TestFailedSettlementLeavesStakeRefundable(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") @@ -43,72 +41,61 @@ func TestFailedTableSettlementDoesNotDoubleReturnStake(t *testing.T) { failingSaveDuring(h.g, h.dir, func() { if err := w.PayStakeCover(20, []string{wager.ID}); err == nil { t.Error("expected the settlement save to fail") - return - } - // The shutdown refund path must not return the covered stake. - if chips, credits, err := w.RefundOutstanding(); err != nil || chips != 0 || credits != 0 { - t.Errorf("shutdown refund returned a covered stake: chips=%d credits=%d err=%v", chips, credits, err) - return } }) - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - if loaded.Credits != 110 { - t.Errorf("balance after recovery = %d, want 110 (stake returned exactly once, inside the re-applied payout)", loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Errorf("records left open after recovery: %+v", open) + if p.Credits != 90 || len(p.PendingWagers) != 1 { + t.Errorf("after failed settlement: credits=%d pending=%+v; want 90 and one stake", + p.Credits, p.PendingWagers) } }) + + // A restart refunds the still-pending stake; nothing double-pays. + restarted := harnessFromDir(t, h.dir) + loaded, err := restarted.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 100 { + t.Fatalf("balance after restart = %d, want 100 (failed payout not applied; stake refunded)", loaded.Credits) + } + if len(loaded.PendingWagers) != 0 { + t.Fatalf("pending after recovery = %+v, want none", loaded.PendingWagers) + } } -// TestFailedSlotsPayoutDoesNotDoubleReturnStake: a slot spin's payout is -// winnings only — the stake is consumed by playing the round. When the -// payout save fails, the journaled winnings prove the round resolved: the -// shutdown refund must not return the stake on top of the owed payout. -func TestFailedSlotsPayoutDoesNotDoubleReturnStake(t *testing.T) { +// TestSuccessfulSettlementClearsPendingDurably: the payout and the removal of +// the stake it returns are one save, so the durable file never holds both. +func TestSuccessfulSettlementClearsPendingDurably(t *testing.T) { h := newHarness(t) h.seedAccount("alice") p := h.seedCharacter("alice", "alice") h.g.RunOnOwner(func() { p.Credits = 100 w := casinoWallet{g: h.g, p: p} - if _, err := w.Wager(10); err != nil { - t.Error(err) - return - } - failingSaveDuring(h.g, h.dir, func() { - // Machine payouts carry no stake linkage (nil covers): the - // journaled settlement still resolves the round. - if err := w.PayCredits(20); err == nil { - t.Error("expected the payout save to fail") - return - } - if chips, credits, err := w.RefundOutstanding(); err != nil || chips != 0 || credits != 0 { - t.Errorf("shutdown refund paid a resolved round's stake: chips=%d credits=%d err=%v", chips, credits, err) - return - } - }) - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") + wager, err := w.Wager(10) if err != nil { t.Error(err) return } - if loaded.Credits != 110 { - t.Errorf("balance after recovery = %d, want 110 (stake consumed by the round, winnings paid once)", loaded.Credits) + if err := w.PayStakeCover(20, []string{wager.ID}); err != nil { + t.Error(err) } }) + if p.Credits != 110 || len(p.PendingWagers) != 0 { + t.Fatalf("after settlement: credits=%d pending=%+v; want 110 and none", p.Credits, p.PendingWagers) + } + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 110 || len(loaded.PendingWagers) != 0 { + t.Fatalf("durable after settlement: credits=%d pending=%+v; want 110 and none", + loaded.Credits, loaded.PendingWagers) + } } -// TestUncoveredStakeStillRefunds: without a journaled settlement, the -// shutdown refund path keeps returning outstanding stakes (the documented -// refund-on-shutdown policy) — a regression guard that the coverage skip -// never over-triggers. +// TestUncoveredStakeStillRefunds: without a settlement, the refund path +// returns an outstanding stake (the refund-on-shutdown policy). func TestUncoveredStakeStillRefunds(t *testing.T) { h := newHarness(t) h.seedAccount("alice") @@ -125,12 +112,15 @@ func TestUncoveredStakeStillRefunds(t *testing.T) { t.Errorf("uncovered stake refund: chips=%d credits=%d err=%v", chips, credits, err) return } + if p.Credits != 100 || len(p.PendingWagers) != 0 { + t.Errorf("after refund: credits=%d pending=%+v; want 100 and none", p.Credits, p.PendingWagers) + } }) } -// TestCasinoManagerRefundPathUsesCoverage: the same rule through the live -// manager path (baccarat settle → RefundAll), exercised with a probe wallet -// so the settlement save fails mid-round. +// TestCasinoManagerFailedSettlementRefundIsExact drives a full baccarat round +// through the manager with a wallet whose settlement save fails: the stake +// stays pending and is refunded exactly once (never paid on top of a payout). func TestCasinoManagerFailedSettlementRefundIsExact(t *testing.T) { h := newHarness(t) h.seedAccount("alice") @@ -138,27 +128,18 @@ func TestCasinoManagerFailedSettlementRefundIsExact(t *testing.T) { h.g.RunOnOwner(func() { p.Credits = 1000 h.g.Casino.Join(1, casino.TableConfig{ID: "b", Game: casino.GameBaccarat}, "alice") - var totalPaid int w := casinoWallet{g: h.g, p: p} - wallet := &fallbackProbeWallet{harness: h, inner: w, onPay: func(credits int, failedCall bool) { - totalPaid += credits - }} - // One bet: a lone player's bet deals and settles immediately. The - // wallet fails the first settlement's journal-free save via a - // broken SAVE (not journal) so the record stays open and owed. - h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet) + // A lone player's bet deals and settles immediately; break the save + // for that settlement so the stake stays pending. + failingSaveDuring(h.g, h.dir, func() { + h.g.Casino.Bet(1, "b", "alice", "player", 10, w) + }) h.g.emitCasinoEvents(h.g.Casino.RefundAll()) - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - // The stake was committed durably (990); RefundAll must not return - // it while the settlement payout is owed; recovery re-applies the - // payout: 1000 - 10 + totalPaid = final. - if want := 1000 - 10 + totalPaid; loaded.Credits != want { - t.Errorf("balance after refund+recovery = %d, want %d (wager 10, paid %d)", loaded.Credits, want, totalPaid) - } }) + if p.Credits != 1000 { + t.Fatalf("balance after failed settlement and refund = %d, want 1000", p.Credits) + } + if len(p.PendingWagers) != 0 { + t.Fatalf("pending after refund = %+v, want none", p.PendingWagers) + } } diff --git a/internal/game/cmd_bank.go b/internal/game/cmd_bank.go index 1ec0b7c..deddf0b 100644 --- a/internal/game/cmd_bank.go +++ b/internal/game/cmd_bank.go @@ -417,10 +417,9 @@ func (g *Game) doBankWithdrawCredits(sess *net.Session, parts []string) { // persistBankCredits makes a credit move between the on-hand wallet and the // bank durable, reverting it in memory when the save fails. A best-effort -// save here would leave the in-memory balance ahead of the durable file, and -// a wager taken from that phantom balance journals checkpoints the file can -// never match — recovery then leaves the stake permanently ambiguous instead -// of refunding or settling it. Reports whether the move may be announced. +// save here would leave the in-memory balance ahead of the durable file, so +// a wager taken from that phantom balance would be charged against money the +// player does not durably have. Reports whether the move may be announced. func (g *Game) persistBankCredits(p *player.Player, creditsBefore, bankedBefore int) bool { if err := g.AccountStore.SaveCharacter(p); err != nil { log.Printf("bank credit transfer %s: %v", p.Name, err) diff --git a/internal/game/economy.go b/internal/game/economy.go deleted file mode 100644 index 28e2880..0000000 --- a/internal/game/economy.go +++ /dev/null @@ -1,1466 +0,0 @@ -package game - -// The economy journal: durable, idempotent money movement. Every casino -// wager and payout writes a journal record BEFORE the balance mutation, -// then the character save makes the balance durable. -// -// Two kinds of records exist: -// -// - "wager": a committed stake. The record stays OPEN while the stake is -// outstanding (the round is in flight); the game resolves it with a -// settled marker when the round resolves (won, lost, pushed, or -// forfeited). At startup, open wager records are refunded — the round -// state was lost in the crash, so the stake returns to the player. -// - "payout": money movement (winnings, pushes, refunds). The record -// closes when the balance mutation is durable; an open payout at -// startup is owed money and is re-applied. Durability is proven by -// the character file itself, not by balance checkpoints: the same -// save that includes the payout in the balance records the record's -// ID in the character's open_payouts evidence list, and the entry is -// dropped once the settled marker is durable. Checkpoints alone are -// ambiguous — an unrelated credit change (shop sale, bank deposit) -// can coincidentally land on the record's CreditsAfter balance, and -// settling on that coincidence silently erases an owed payout. -// -// A crash at any point therefore leaves either: -// - an open record with the character file at its pre-transaction state -// (crash before save) — recovery settles it as void (debit) or applies -// it (payout, which the player is owed); or -// - an open wager with the file at its post-transaction state and no -// later payout evidence (the round state was lost in the crash) — the -// stake is refunded; or -// - an open payout (crash after the journal write, before the save) — -// the owed amount is re-applied. -// -// Records whose checkpoints match neither balance state after compensating -// earlier records are ambiguous: they are kept open and surfaced for -// operator inspection rather than silently settled or voided. -// -// The reducer folds journal lines by record ID (later lines win), so -// settlement markers are idempotent appends. Recovery runs once at startup -// before any session can join; compaction rewrites the journal keeping only -// open records (outstanding stakes and unresolved payouts). - -import ( - "bufio" - "bytes" - "crypto/rand" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "thehouseoficarus/internal/casino" - "thehouseoficarus/internal/player" -) - -type economyRecord struct { - ID string `json:"id"` - Player string `json:"player"` - // PlayerID is the character's stable UUID (player.Player.ID), assigned - // at creation and backfilled at first startup. Records resolve their - // subject by ID at recovery so a character RENAME can never strand an - // owed payout or outstanding stake; Player (the name) is kept for - // operator inspection and for legacy records written before IDs. - PlayerID string `json:"player_id,omitempty"` - Kind string `json:"kind"` // "wager" or "payout" - Chips int `json:"chips"` - Credit int `json:"credits"` - Status string `json:"status"` // "open", "settled", "void" - TS time.Time `json:"ts"` - // Seq is the record's monotonic creation order within the journal. It - // lets coverage checks distinguish a settlement that predates a stake - // (and therefore cannot return it) from one written after it. - Seq int64 `json:"seq,omitempty"` - // Refund marks a payout record as a SHUTDOWN/EVICTION REFUND (the - // stake being returned), not a game settlement. Recovery must not - // treat the chip part of a refund as paid in credits: a refund's chip - // stake returns in kind, so a crash mid-refund still owes the chips. - // Settlement payouts (a win, push, or surrender return) instead prove - // the round resolved, closing the stake without any chip return. - Refund bool `json:"refund,omitempty"` - - // Tracked marks a payout written under the character-file evidence - // scheme: the record's ID is recorded in the character's open_payouts - // list by the same save that includes its amount in the balance, and - // recovery uses that membership — not balance checkpoints — to decide - // whether the payout became durable. - Tracked bool `json:"tracked,omitempty"` - - // Balance checkpoints used to decide recovery compensation. - CreditsBefore int `json:"credits_before"` - CreditsAfter int `json:"credits_after"` - ChipsBefore int `json:"chips_before"` - ChipsAfter int `json:"chips_after"` - - // Covers lists the stake wager IDs a settlement payout RETURNS (a - // table hand pays its stake back inside the payout). Empty means no - // explicit linkage: recovery treats such a settlement as resolving - // every open stake of its player (the historical behavior, and the - // right reading for a slots payout whose stake was consumed by - // playing the round). A REFUND payout never covers: its credit part - // is the stake return being re-applied, not a new settlement. - Covers []string `json:"covers,omitempty"` -} - -type economyJournal struct { - path string - // dirSynced records whether the directory entry of path has been made - // durable. A file's own fsync does not persist the entry that names it, - // so the first append must sync the directory (as persistence.WriteFile - // does) or a crash can lose the whole journal — including the record of - // a debit that is already durable in the character file. - dirSynced bool - // tailChecked records whether the journal tail has been verified to end on - // a newline before the first append of this process. A torn final line - // (short write) must be truncated before a new record is appended, or the - // two merge into one malformed mid-file line that permanently aborts - // recovery. - tailChecked bool - // stakes indexes outstanding wager records per player identity key - // (economyKey: the character's stable ID when known, else its name). - // All journal mutations happen on the game owner (or during startup - // recovery before any session connects), so no lock is needed. - stakes map[string][]*economyRecord - // openPayouts indexes payout records that are still open (journaled but - // not provably durable). It drives evidence pruning: a character-file - // ID absent from this set is settled or void and must not be trusted as - // durability evidence. - openPayouts map[string]bool - // openPayoutRecs indexes open payout records per player key so a - // refund can check which stakes an owed settlement already returns - // (stakeCovered). Same ownership discipline as stakes: all mutations - // happen on the game owner or during startup recovery. - openPayoutRecs map[string][]*economyRecord - // seq is the monotonic record counter (see economyRecord.Seq). It is - // advanced by startKind and raised to the journal maximum on every fold. - seq int64 -} - -func newEconomyJournal(dataDir string) *economyJournal { - dir := filepath.Join(dataDir, "economy") - _ = os.MkdirAll(dir, 0700) - return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool), openPayoutRecs: make(map[string][]*economyRecord)} -} - -// syncDir fsyncs a directory so an entry created or replaced in it survives a -// crash (the same durability step persistence.WriteFile performs). -func syncDir(dir string) error { - d, err := os.Open(dir) - if err != nil { - return err - } - defer d.Close() - return d.Sync() -} - -func newEconomyRecordID() string { - var b [8]byte - if _, err := rand.Read(b[:]); err != nil { - panic(fmt.Sprintf("economy record id: %v", err)) - } - return hex.EncodeToString(b[:]) -} - -// errJournalAppendUncertain reports that a journal append failed AND the -// record's bytes could not be proven removed: the rollback truncate failed, -// or the sync that would make that truncation durable failed, or the -// pre-write size was unknown. The record MAY therefore still be present in -// the journal. Callers must not treat the transaction as definitely absent: -// the wallet's journal-unavailable payout fallback records the attempted -// record's ID as open-payout evidence so a surviving record is settled by -// recovery instead of being re-applied (a double-pay). -var errJournalAppendUncertain = errors.New("economy journal append may have left the record in the journal") - -// append writes one durable line. Errors are returned to the caller, which -// must treat the transaction as failed. An error wrapping -// errJournalAppendUncertain additionally means the record may have survived -// in the journal. -func (j *economyJournal) append(rec economyRecord) error { - rec.TS = time.Now() - data, err := json.Marshal(rec) - if err != nil { - return err - } - if !j.tailChecked { - if err := j.repairTornTail(); err != nil { - return fmt.Errorf("repair economy journal tail: %w", err) - } - j.tailChecked = true - } - f, err := os.OpenFile(j.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) - if err != nil { - return fmt.Errorf("open economy journal: %w", err) - } - defer f.Close() - // Record the pre-write size so a failed write/sync rolls the record back. - // "append failed" must mean "no record bytes remain": the wallet's - // journal-unavailable fallback commits the payout through the character - // file only, and a later recovery must not see the same payout record as - // still owed (that double-pays). It also keeps a short write from merging - // into the next append as a malformed mid-file line. - startSize := int64(-1) - if fi, statErr := f.Stat(); statErr == nil { - startSize = fi.Size() - } - rollback := func(cause error) error { - if startSize < 0 { - // The pre-write size is unknown, so the record cannot be truncated - // away with confidence: it may remain in the journal. - return fmt.Errorf("%w: %v (pre-write size unknown)", errJournalAppendUncertain, cause) - } - if terr := f.Truncate(startSize); terr != nil { - fmt.Fprintf(os.Stderr, "economy journal: rollback truncate after %v: %v\n", cause, terr) - return fmt.Errorf("%w: %v (rollback truncate: %v)", errJournalAppendUncertain, cause, terr) - } - if serr := f.Sync(); serr != nil { - // The truncation is not durable: a crash can restore the record - // bytes, so the append outcome is still unknown. - fmt.Fprintf(os.Stderr, "economy journal: rollback sync after %v: %v\n", cause, serr) - return fmt.Errorf("%w: %v (rollback sync: %v)", errJournalAppendUncertain, cause, serr) - } - return cause - } - if _, err := f.Write(append(data, '\n')); err != nil { - return rollback(fmt.Errorf("append economy journal: %w", err)) - } - if err := f.Sync(); err != nil { - return rollback(fmt.Errorf("sync economy journal: %w", err)) - } - if !j.dirSynced { - if err := syncDir(filepath.Dir(j.path)); err != nil { - // The record bytes are in the file but the directory entry is not - // guaranteed durable. Roll the record back so the caller's failure - // is truthful, and leave dirSynced false so a later append retries - // the directory sync. - return rollback(fmt.Errorf("sync economy journal dir: %w", err)) - } - j.dirSynced = true - } - return nil -} - -// repairTornTail guarantees the journal ends on a newline before a new record -// is appended. A short write (ENOSPC, a flaky filesystem) can leave a partial -// JSON suffix; appending a full record after it merges the two into one -// malformed mid-file line, which loadFolded treats as fatal and which then -// permanently disables recovery and compaction. Truncating the torn suffix is -// safe: a partial record was never a committed transaction. This runs at most -// once per process (append is otherwise hot). -func (j *economyJournal) repairTornTail() error { - f, err := os.OpenFile(j.path, os.O_RDWR, 0600) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - defer f.Close() - - fi, err := f.Stat() - if err != nil { - return err - } - size := fi.Size() - if size == 0 { - return nil - } - - last := make([]byte, 1) - if _, err := f.ReadAt(last, size-1); err != nil { - return err - } - if last[0] == '\n' { - return nil - } - - // Scan backwards in bounded chunks for the last newline and truncate the - // torn suffix after it. If none exists the whole file is one torn line. - const chunk = 64 * 1024 - buf := make([]byte, chunk) - for end := size; end > 0; { - start := end - chunk - if start < 0 { - start = 0 - } - n := int(end - start) - if _, err := f.ReadAt(buf[:n], start); err != nil { - return err - } - if idx := bytes.LastIndexByte(buf[:n], '\n'); idx >= 0 { - return f.Truncate(start + int64(idx) + 1) - } - end = start - } - return f.Truncate(0) -} - -// appendRetry appends one durable line, retrying a bounded number of -// times: a transient write failure must not permanently lose a marker -// that money correctness depends on. -func (j *economyJournal) appendRetry(rec economyRecord, attempts int) error { - var err error - uncertain := false - for i := 0; i < max(attempts, 1); i++ { - if err = j.append(rec); err == nil { - return nil - } - if errors.Is(err, errJournalAppendUncertain) { - uncertain = true - } - } - // An earlier attempt whose rollback could not be proven may have left the - // record bytes behind even when the final attempt's error is definite, so - // the aggregate must still report that the record MAY be present. - if uncertain && !errors.Is(err, errJournalAppendUncertain) { - err = fmt.Errorf("%w: %v", errJournalAppendUncertain, err) - } - return err -} - -// start opens a durable record for the in-flight transaction. -func (j *economyJournal) start(kind string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) { - return j.startKind(kind, false, nil, p, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter) -} - -// startRefund opens a refund payout record (stake returned at shutdown or -// eviction; see economyRecord.Refund). -func (j *economyJournal) startRefund(p *player.Player, credits, creditsBefore, creditsAfter int) (*economyRecord, error) { - return j.startKind("payout", true, nil, p, 0, credits, creditsBefore, creditsAfter, 0, 0) -} - -func (j *economyJournal) startKind(kind string, refund bool, covers []string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) { - rec := &economyRecord{ - ID: newEconomyRecordID(), - Player: p.Name, - PlayerID: p.ID, - Kind: kind, - Refund: refund, - Covers: covers, - Tracked: kind == "payout", - Chips: chips, - Credit: credits, - Status: "open", - CreditsBefore: creditsBefore, - CreditsAfter: creditsAfter, - ChipsBefore: chipsBefore, - ChipsAfter: chipsAfter, - } - // Retry a bounded number of times: a transient append failure must not - // drop the record that makes the wager or owed payout durable. - j.seq++ - rec.Seq = j.seq - if err := j.appendRetry(*rec, 5); err != nil { - // Return the attempted record with the error. It is NOT durable, but - // its ID is what a surviving (uncertain) append would carry, so the - // wallet's journal-unavailable payout fallback can record it as - // open-payout evidence and stop recovery re-applying the record. - return rec, err - } - if rec.Tracked { - j.openPayouts[rec.ID] = true - j.openPayoutRecs[rec.key()] = append(j.openPayoutRecs[rec.key()], rec) - } - return rec, nil -} - -// closePayoutTracking drops rec from the open-payout indexes (its status -// changed to settled or void). -func (j *economyJournal) closePayoutTracking(rec *economyRecord) { - delete(j.openPayouts, rec.ID) - kept := j.openPayoutRecs[rec.key()][:0] - for _, open := range j.openPayoutRecs[rec.key()] { - if open != rec { - kept = append(kept, open) - } - } - if len(kept) == 0 { - delete(j.openPayoutRecs, rec.key()) - } else { - j.openPayoutRecs[rec.key()] = kept - } -} - -// settle marks a record settled (the balance mutation is durable). The -// marker is retried and the record is left OPEN (with its tracking index -// intact) if no append succeeds: a non-durable settle must never drop the -// evidence a later recovery needs, or the liability is paid twice. -func (j *economyJournal) settle(rec *economyRecord) { - rec.Status = "settled" - if err := j.appendRetry(*rec, 3); err != nil { - rec.Status = "open" - return - } - j.closePayoutTracking(rec) -} - -// settleDurable marks a record settled and persists the marker with retries, -// reporting whether the marker became durable. A false return leaves the -// record open in the journal: callers keeping file-side evidence for this -// record must NOT drop that evidence until this returns true. -func (j *economyJournal) settleDurable(rec *economyRecord) bool { - rec.Status = "settled" - if err := j.appendRetry(*rec, 3); err != nil { - rec.Status = "open" - return false - } - j.closePayoutTracking(rec) - return true -} - -// void marks a record void (the mutation was reverted in memory and never -// became durable; recovery must not compensate). Like settle, the marker is -// retried and the record is left open on failure so its index/evidence is -// preserved for a later attempt. -func (j *economyJournal) void(rec *economyRecord) { - rec.Status = "void" - if err := j.appendRetry(*rec, 3); err != nil { - rec.Status = "open" - return - } - j.closePayoutTracking(rec) -} - -// registerStake tracks an outstanding wager record for its player. Wager -// records stay open (refundable) until the round resolves. -func (j *economyJournal) registerStake(rec *economyRecord) { - j.stakes[rec.key()] = append(j.stakes[rec.key()], rec) -} - -// openStakes returns the outstanding stake records for a player identity -// key (see economyKey). -func (j *economyJournal) openStakes(player string) []*economyRecord { - return j.stakes[player] -} - -// ResolveStakes closes every outstanding stake for a player with a settled -// marker per record: the round resolved, so recovery must not refund them. -// A marker whose append cannot be made durable leaves that stake OPEN -// (never dropped from the index): recovery then settles it from later -// payout evidence, or — if the round was a loss with no settlement -// payout — refunds it under the documented refund-on-crash policy. A -// stake is only forgotten once its marker is durable. -// -// It returns the records whose settled marker could NOT be made durable. -// Callers holding the player should record those IDs as resolved-stake -// evidence (player.Player.ResolvedStakes) so recovery still honors the -// resolution even though the journal could not persist its marker. -func (j *economyJournal) ResolveStakes(player string) []*economyRecord { - var unresolved []*economyRecord - remaining := j.stakes[player][:0] - for _, rec := range j.stakes[player] { - rec.Status = "settled" - if err := j.appendRetry(*rec, 3); err != nil { - // The marker is not durable: the stake stays open so recovery - // (or a later attempt) still resolves it. - rec.Status = "open" - remaining = append(remaining, rec) - unresolved = append(unresolved, rec) - } - } - if len(remaining) == 0 { - delete(j.stakes, player) - } else { - j.stakes[player] = remaining - } - return unresolved -} - -// ResolveStakeByID closes ONE outstanding stake (identified by its wallet -// handle) with a settled marker: the hand it belongs to was paid or lost, -// so it must never be refunded even while sibling stakes of the same round -// remain open. On success the stake leaves the index; a marker whose append -// cannot be made durable leaves the stake OPEN (the covering settlement's -// journal record, or file-side resolution evidence, still protects it from -// being refunded twice). -func (j *economyJournal) ResolveStakeByID(player, id string) []*economyRecord { - recs := j.stakes[player] - remaining := make([]*economyRecord, 0, len(recs)) - var unresolved []*economyRecord - for _, rec := range recs { - if rec.ID != id { - remaining = append(remaining, rec) - continue - } - rec.Status = "settled" - if err := j.appendRetry(*rec, 3); err != nil { - rec.Status = "open" - remaining = append(remaining, rec) - unresolved = append(unresolved, rec) - } - } - if len(remaining) == 0 { - delete(j.stakes, player) - } else { - j.stakes[player] = remaining - } - return unresolved -} - -// stakeCovered reports whether an OPEN payout record already accounts for -// stake's return. A table settlement lists the stake IDs it covers; a -// legacy (or slots) settlement without an explicit list is treated as -// resolving every open stake of its player — a journaled settlement means -// the round resolved, and its recovery re-application delivers the money. -// A REFUND payout never covers: its credit part is the stake return being -// re-applied, not a settlement. -func (j *economyJournal) stakeCovered(stake *economyRecord) bool { - for _, payout := range j.openPayoutRecs[stake.key()] { - if payout.Refund { - continue - } - // A payout written BEFORE the stake cannot be its settlement: without - // this ordering check an old open payout (for example a failed slots - // win) would mask a later, unrelated stake and RefundOutstanding - // would resolve it without returning the money. - if payout.Seq <= stake.Seq { - continue - } - if len(payout.Covers) == 0 || containsString(payout.Covers, stake.ID) { - return true - } - } - return false -} - -func containsString(list []string, s string) bool { - for _, item := range list { - if item == s { - return true - } - } - return false -} - -// economyKey is the identity a player's journal records are indexed by: -// the stable character ID when known, else the (renameable) name. -func economyKey(p *player.Player) string { - if p.ID != "" { - return p.ID - } - return p.Name -} - -// economyRecordKey mirrors economyKey for a raw record. -func (rec *economyRecord) key() string { - if rec.PlayerID != "" { - return rec.PlayerID - } - return rec.Player -} - -// resolvedStakeRecorded reports whether the character's durable evidence -// list claims rec's stake was resolved by the session (its settled marker -// could not be journaled; see player.Player.ResolvedStakes). -func resolvedStakeRecorded(p *player.Player, id string) bool { - for _, existing := range p.ResolvedStakes { - if existing == id { - return true - } - } - return false -} - -// pruneResolvedStakes drops resolved-stake evidence IDs that no longer -// correspond to an open wager record (settled or void). It reports whether -// anything changed, so callers only pay for a save when the file would -// actually differ. -func pruneResolvedStakes(openStakeIDs map[string]bool, p *player.Player) bool { - if len(p.ResolvedStakes) == 0 { - return false - } - kept := p.ResolvedStakes[:0] - changed := false - for _, id := range p.ResolvedStakes { - if openStakeIDs[id] { - kept = append(kept, id) - } else { - changed = true - } - } - if changed { - p.ResolvedStakes = kept - } - return changed -} - -// payoutIdentifies reports whether two journal records belong to the same -// character identity. ID-carrying records match only on the stable ID: a -// different character that reused the display name must never supply (or -// consume) another character's resolution evidence. Legacy records without -// IDs keep the historical name-based match among themselves. -func payoutIdentifies(a, b economyRecord) bool { - if a.PlayerID != "" && b.PlayerID != "" { - return a.PlayerID == b.PlayerID - } - if a.PlayerID != "" || b.PlayerID != "" { - // Mixed legacy/ID records: refuse to match on name alone so an ID - // can never be resolved through a name that another character may - // have reused. - return false - } - return strings.EqualFold(a.Player, b.Player) -} - -// reloadStakes rebuilds the outstanding-stake index from the journal file: -// open wager records remain tracked (and refundable/resolvable) after -// recovery compaction. A read failure leaves the previous indexes intact -// (never a wiped index over a live journal); the caller logs loudly. -func (j *economyJournal) reloadStakes() error { - folded, err := j.loadFolded() - if err != nil { - return err - } - j.stakes = make(map[string][]*economyRecord) - j.openPayouts = make(map[string]bool) - j.openPayoutRecs = make(map[string][]*economyRecord) - for _, fr := range folded { - if fr.rec.Kind == "wager" && fr.rec.Status == "open" { - rec := fr.rec - j.registerStake(&rec) - } - if fr.rec.Kind == "payout" && fr.rec.Status == "open" { - rec := fr.rec - j.openPayouts[fr.rec.ID] = true - j.openPayoutRecs[rec.key()] = append(j.openPayoutRecs[rec.key()], &rec) - } - } - return nil -} - -// loadOpen folds the journal and returns the still-open records. -func (j *economyJournal) loadOpen() ([]*economyRecord, error) { - var open []*economyRecord - folded, err := j.loadFolded() - if err != nil { - return nil, err - } - for _, fr := range folded { - if fr.rec.Status == "open" { - rec := fr.rec - open = append(open, &rec) - } - } - return open, nil -} - -// foldedRecord is the latest state of one record ID. Ordering decisions -// (which record came first) are made from economyRecord.Seq — the record's -// creation order — never from the record's line position, which a later -// rewrite of the same record moves. -type foldedRecord struct { - rec economyRecord -} - -// loadFolded folds the journal (latest line per record ID wins) and -// returns the surviving records in journal append order. A read failure -// (oversized or unreadable region) is an ERROR, never a silent truncation: -// callers must not compact or compensate over a partially-read journal, -// because compaction would permanently erase every record beyond the -// unreadable point — including owed payouts and outstanding stakes. -func (j *economyJournal) loadFolded() ([]foldedRecord, error) { - f, err := os.Open(j.path) - if err != nil { - if os.IsNotExist(err) { - return nil, nil // no journal yet: nothing to fold - } - // Any other open failure (permissions, symlink loops, I/O errors) - // is NOT "an empty healthy journal": callers must not compensate - // or compact over a journal whose contents are unknown. - return nil, fmt.Errorf("open economy journal: %w", err) - } - defer f.Close() - latest := map[string]*foldedRecord{} - var order []*foldedRecord - corruptPending := false - corruptSeq := 0 - scanner := bufio.NewScanner(f) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - for seq := 0; scanner.Scan(); seq++ { - line := scanner.Bytes() - if len(line) == 0 { - continue - } - var rec economyRecord - if err := json.Unmarshal(line, &rec); err != nil { - // A malformed line is only tolerable as a torn FINAL line (a - // crash mid-append; appends are small and single-line, so a - // torn write can also merge with a later append onto one - // mid-file line). A malformed record followed by ANY other - // record means the fold cannot be trusted: surface the - // failure instead of skipping the line — compensating or - // compacting over a damaged journal could erase owed money. - if !corruptPending { - corruptPending = true - corruptSeq = seq - } - continue - } - if corruptPending { - return nil, fmt.Errorf("economy journal line %d is malformed; later records exist and may be owed money (journal preserved for inspection)", corruptSeq) - } - if rec.Seq > j.seq { - j.seq = rec.Seq - } - if existing, ok := latest[rec.ID]; ok { - existing.rec = rec - } else { - fr := &foldedRecord{rec: rec} - latest[rec.ID] = fr - order = append(order, fr) - } - } - if err := scanner.Err(); err != nil { - // A scanner error means the fold is INCOMPLETE (a line exceeded the - // buffer or the file became unreadable mid-scan). Returning a - // partial fold would let compaction erase valid records; surface - // the failure instead. - return nil, fmt.Errorf("scan economy journal: %w", err) - } - folded := make([]foldedRecord, 0, len(order)) - for _, fr := range order { - folded = append(folded, *fr) - } - return folded, nil -} - -// compact rewrites the journal keeping only still-open records. A fold -// failure aborts the rewrite: compacting over a journal that could not be -// fully read would silently erase every record beyond the unreadable point. -func (j *economyJournal) compact() { - open, err := j.loadOpen() - if err != nil { - fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err) - return - } - tmp := j.path + ".compact" - f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) - if err != nil { - fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err) - return - } - defer os.Remove(tmp) - w := bufio.NewWriter(f) - for _, rec := range open { - rec.TS = time.Now() - data, err := json.Marshal(rec) - if err != nil { - continue - } - if _, err := w.Write(data); err != nil { - f.Close() - fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err) - return - } - w.WriteByte('\n') - } - if err := w.Flush(); err != nil { - f.Close() - return - } - if err := f.Sync(); err != nil { - f.Close() - return - } - f.Close() - // Best-effort atomic-ish replacement; a torn compaction leaves the - // original file intact. The rename is made durable like the file's own - // sync, so a crash cannot lose the compacted journal entirely. - if err := os.Rename(tmp, j.path); err != nil { - os.Remove(tmp) - return - } - if err := syncDir(filepath.Dir(j.path)); err != nil { - fmt.Fprintf(os.Stderr, "economy compaction: sync journal dir: %v\n", err) - } -} - -// giveChips re-credits chips to a player's inventory (used by refund and -// recovery paths). It stacks onto an existing chips slot when one exists. -// It reports false when the chips do NOT fit (no existing stack and no free -// slot): the caller must then treat the chips as undelivered — they are -// never silently dropped; the money record stays open instead. -func (g *Game) giveChips(p *player.Player, qty int) bool { - for _, slot := range p.Inventory { - if slot != nil && slot.ItemID == "chips" { - slot.Quantity += qty - return true - } - } - slot := p.FirstFreeSlot() - if slot < 0 { - return false - } - p.SetInvSlot(slot, &player.InventorySlot{ItemID: "chips", Quantity: qty}) - return true -} - -// payCreditsDurable applies a checked credit increase and persists it. -// A failed save reverts the in-memory mutation: recovery must keep -// comparing later records against the DURABLE balance. -func (g *Game) payCreditsDurable(p *player.Player, amount int) error { - if amount < 0 { - return fmt.Errorf("negative payout %d", amount) - } - sum, err := addCreditsChecked(p.Credits, amount) - if err != nil { - return err - } - before := p.Credits - p.Credits = sum - if err := g.AccountStore.SaveCharacter(p); err != nil { - p.Credits = before - return err - } - return nil -} - -// applyOpenPayout re-applies an open TRACKED payout record during recovery: -// checked credit increase plus a character save that records the record ID -// in the file's open_payouts evidence list. A failed save reverts the -// in-memory mutation (credits and evidence) so the record stays open and a -// later recovery attempt retries cleanly. -func (g *Game) applyOpenPayout(p *player.Player, rec *economyRecord) error { - if rec.Credit < 0 { - return fmt.Errorf("negative payout %d", rec.Credit) - } - sum, err := addCreditsChecked(p.Credits, rec.Credit) - if err != nil { - return err - } - before := p.Credits - p.Credits = sum - p.OpenPayouts = append(p.OpenPayouts, rec.ID) - if err := g.AccountStore.SaveCharacter(p); err != nil { - p.Credits = before - removeOpenPayout(p, rec.ID) - return err - } - return nil -} - -// grantCredits adds credits from ordinary gameplay (shop sales, credit -// pickups, activity payouts). The economy cap is enforced by clamping: the -// cap is far above any reachable balance, so hitting it means a data bug — -// clamp (never wrap) and log loudly for the operator. -func (g *Game) grantCredits(p *player.Player, amount int) { - if amount <= 0 { - return - } - sum, err := addCreditsChecked(p.Credits, amount) - if err != nil { - fmt.Fprintf(os.Stderr, "credit grant for %s clamped at the economy cap: %v\n", p.Name, err) - p.Credits = casino.MaxCredits - return - } - p.Credits = sum -} - -// addCreditsChecked is the game-side checked addition against the shared -// economy cap (casino.MaxCredits, mirrored by the casino wallet: a second, -// independently drifting cap would let the two sides of the money path -// disagree about what a balance may hold). -func addCreditsChecked(a, b int) (int, error) { - if b > 0 && a > casino.MaxCredits-b { - return casino.MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, casino.MaxCredits) - } - if b < 0 { - return 0, fmt.Errorf("negative credit add %d", b) - } - return a + b, nil -} - -// openPayoutRecorded reports whether the character's durable evidence list -// claims rec's amount is included in the balance. -func openPayoutRecorded(p *player.Player, id string) bool { - for _, existing := range p.OpenPayouts { - if existing == id { - return true - } - } - return false -} - -// removeOpenPayout drops id from the in-memory evidence list; the on-disk -// copy is corrected by the next character save. -func removeOpenPayout(p *player.Player, id string) { - kept := p.OpenPayouts[:0] - for _, existing := range p.OpenPayouts { - if existing != id { - kept = append(kept, existing) - } - } - p.OpenPayouts = kept -} - -// pruneOpenPayouts drops evidence IDs that no longer correspond to an open -// payout record (settled or void). It reports whether anything changed, so -// callers only pay for a save when the file would actually differ. -func pruneOpenPayouts(j *economyJournal, p *player.Player) bool { - if j == nil || len(p.OpenPayouts) == 0 { - return false - } - if j.openPayouts == nil { - j.openPayouts = make(map[string]bool) - } - kept := p.OpenPayouts[:0] - changed := false - for _, id := range p.OpenPayouts { - if j.openPayouts[id] { - kept = append(kept, id) - } else { - changed = true - } - } - if changed { - p.OpenPayouts = kept - } - return changed -} - -// reanchorOwedPayout rewrites an open payout record's checkpoints to the -// current durable balance so a later recovery reads it as still owed (the -// balance sits at the record's before-checkpoint). It is used when a recovery -// run could not pay every sibling in an identical-signature group: the paid -// siblings moved the durable balance, so leaving the unpaid remainder at its -// original checkpoints would let a later run mistake the balance for its own -// durability and settle the liability without paying it. The post-checkpoint -// respects the economy cap: when the credit cannot fit, before == after, so -// the record keeps reading as owed at this balance instead of claiming an -// impossible post-state. -func reanchorOwedPayout(rec *economyRecord, credits, credit int) { - rec.CreditsBefore = credits - rec.CreditsAfter = credits - if after, err := addCreditsChecked(credits, credit); err == nil { - rec.CreditsAfter = after - } -} - -// recoverEconomy compensates open records from the last crash. It is called -// once from New, before any session connects. -// -// Recovery runs in two phases so checkpoints stay meaningful: -// -// 1. Open PAYOUT records are processed in append order against the evolving -// durable balance: file at the pre-payout balance → the payout is -// re-applied (the player is owed it); at the post-payout balance → it is -// already durable and settles — UNLESS an earlier recovery run (or the -// original settlement) already settled a same-checkpoint sibling, in -// which case the balance is explained by THAT record and this open -// record is still owed and re-applied; anything else stays open and is -// surfaced for operator inspection (a liability is never silently -// dropped). -// 2. Open WAGER records are processed NEWEST FIRST (each refund restores -// the balance an earlier record's checkpoints refer to). A record at -// its pre-debit state (credits AND chips) never became durable → void; -// at its post-debit state → the stake was charged but the round state -// was lost in the crash → the stake is refunded, unless a payout record -// written later proves the round resolved (a win/push/refund already -// returned the stake inside that payout) → settled with no refund. -// Anything else stays open for operator inspection. -func (g *Game) recoverEconomy() { - if g.Economy == nil { - return - } - folded, err := g.Economy.loadFolded() - if err != nil { - // The journal could not be fully read. Compensation over a partial - // fold could double-pay or erase liabilities, and compaction would - // truncate the file. Preserve everything for operator inspection. - fmt.Fprintf(os.Stderr, "economy recovery aborted: %v (journal and character files preserved for inspection)\n", err) - return - } - if g.Economy.openPayouts == nil { - g.Economy.openPayouts = make(map[string]bool) - } else { - for id := range g.Economy.openPayouts { - delete(g.Economy.openPayouts, id) - } - } - for i := range folded { - rec := &folded[i].rec - if rec.Kind == "payout" && rec.Status == "open" { - g.Economy.openPayouts[rec.ID] = true - } - } - // Player records resolve by the STABLE character ID (a rename must - // never strand an owed payout); the name lookup remains for legacy - // records written before IDs existed. Every character file is loaded - // up front so records resolve by stable ID without filename guessing. - playersByID := make(map[string]*player.Player) - playersByName := make(map[string]*player.Player) - if names, err := g.AccountStore.ListCharacterNames(); err == nil { - for _, name := range names { - if p, err := g.AccountStore.LoadCharacter(name); err == nil { - if p.ID != "" { - playersByID[p.ID] = p - } - playersByName[strings.ToLower(p.Name)] = p - } - } - } - // finalizeLoaded prunes stale open-payout evidence and resolved-stake - // evidence the first time a character file is touched: IDs the journal - // shows as settled or void cannot prove anything and are dropped (with - // a durable save) so the lists stay small and meaningful. - openStakeIDs := make(map[string]bool) - for i := range folded { - rec := &folded[i].rec - if rec.Kind == "wager" && rec.Status == "open" { - openStakeIDs[rec.ID] = true - } - } - finalizeLoaded := func(p *player.Player) *player.Player { - prunedPayouts := pruneOpenPayouts(g.Economy, p) - prunedStakes := pruneResolvedStakes(openStakeIDs, p) - if prunedPayouts || prunedStakes { - if err := g.AccountStore.SaveCharacter(p); err != nil { - fmt.Fprintf(os.Stderr, "economy recovery: evidence prune save %s: %v\n", p.Name, err) - } - } - return p - } - loadPlayer := func(rec *economyRecord) *player.Player { - if rec.PlayerID != "" { - // A record carrying a stable ID resolves to THAT identity only. - // Never fall back to the display name: a different character - // that later reused the name would collect the original - // character's payout and the original liability would be - // settled as paid. See TestRecoveryDoesNotPayReusedCharacterName. - if p, ok := playersByID[rec.PlayerID]; ok { - return finalizeLoaded(p) - } - // A character file added since the index was built: load every - // file once and match by stable ID. - if names, err := g.AccountStore.ListCharacterNames(); err == nil { - for _, name := range names { - if p, err := g.AccountStore.LoadCharacter(name); err == nil && p.ID == rec.PlayerID { - playersByID[p.ID] = p - playersByName[strings.ToLower(p.Name)] = p - return finalizeLoaded(p) - } - } - } - // Identity not found (character deleted or unreadable): leave - // the record open so a later attempt (or operator inspection) - // can resolve it; do not silently drop or reassign money. - fmt.Fprintf(os.Stderr, "economy recovery: load %s (id %q): character not found\n", rec.Player, rec.PlayerID) - return nil - } - // Legacy record written before stable IDs existed: the (renameable) - // name is the only identity available. - if rec.Player != "" { - if p, ok := playersByName[strings.ToLower(rec.Player)]; ok { - return finalizeLoaded(p) - } - } - fmt.Fprintf(os.Stderr, "economy recovery: load %s: character not found\n", rec.Player) - return nil - } - // stakeEvidence reports whether any non-void payout record was - // journaled for this stake after seq — evidence the round resolved and - // its settlement was recorded. A settlement that lists explicit stake - // IDs (a table hand) only proves THIS stake when it covers it; a - // legacy (or slots) payout without a list proves the round resolved - // (the historical, and slots-correct, reading: the stake was consumed - // by playing the round). - // - // settlement separately reports whether a SETTLEMENT payout (not a - // refund) was journaled later: a settlement — even a partial one like - // a surrendered hand or the first hand of a multi-hand win — proves - // the round resolved, so the stake must NOT be refunded and its chip - // part must NOT return in kind on top of the settlement (the chips - // were lost at settlement, by the rules of the game). A REFUND payout - // instead marks a refund in progress: its credit part is re-applied by - // phase 1 and the chip part (if the debit was durable) still comes - // back in chips. - stakeEvidence := func(rec *economyRecord) (evidence bool, settlement bool) { - for _, fr := range folded { - // Ordering is by the record's CREATION Seq, not by the line of - // its latest write: a payout created before this stake is not - // its settlement even when a later rewrite of that payout - // (a settle marker, or a recovery checkpoint re-anchor) lands - // after the stake's line. Line order would let an old payout - // mask a later stake and silently erase its refund (see - // stakeCovered, which orders the same way). - if fr.rec.Kind != "payout" || fr.rec.Status == "void" || fr.rec.Seq <= rec.Seq || - !payoutIdentifies(fr.rec, *rec) { - continue - } - if len(fr.rec.Covers) > 0 && !containsString(fr.rec.Covers, rec.ID) { - continue - } - evidence = true - if !fr.rec.Refund { - settlement = true - } - } - return evidence, settlement - } - compensated := 0 - // skipCompact is set when a recovery-time append that a later run depends - // on could not be made durable; compaction must then be skipped so the - // evidence it would erase is preserved. - skipCompact := false - - // Phase 1 — payouts, in append order. Records with identical - // checkpoints are decided as one group: k sibling records all journaled - // against balance `before` were all written against the same in-memory - // balance (a failed save reverts it), so either none became durable - // (the file is still at `before`: all k are owed) or at most one did - // (the file is at `after` and k == 1). Re-applying identical siblings - // one at a time would let the second record misread the first - // re-application as its own durability and silently drop the owed - // payout — the group is settled together instead. - type payoutSig struct { - // player is the record's stable identity key (PlayerID when known, - // else the name): grouping by display name alone would merge two - // different characters that reused the same name. - player string - before, after int - credit int - } - siblings := make(map[payoutSig][]*economyRecord) - for i := range folded { - rec := &folded[i].rec - if rec.Kind == "payout" && rec.Status == "open" { - sig := payoutSig{rec.key(), rec.CreditsBefore, rec.CreditsAfter, rec.Credit} - siblings[sig] = append(siblings[sig], rec) - } - } - settled := make(map[string]bool) - // settledSiblings counts SETTLED payout records (from earlier recovery - // runs; visible in the folded journal) with the same signature as rec. - // Each one already explains one durable credit unit of the current - // balance: a record moved the balance from `before` to `after` and was - // settled, so any other open record with the SAME checkpoints is still - // owed — it must never be settled as "already durable" because the - // balance happens to sit at `after`. - settledSiblings := func(sig payoutSig, selfID string) int { - n := 0 - for j := range folded { - other := &folded[j].rec - if other.ID == selfID || other.Kind != "payout" || other.Status != "settled" { - continue - } - if other.key() == sig.player && other.CreditsBefore == sig.before && - other.CreditsAfter == sig.after && other.Credit == sig.credit { - n++ - } - } - return n - } - // reapplyGroup re-applies every identical open sibling: they were all - // journaled against the same in-memory balance and are all owed. Only - // the siblings whose re-application actually became durable are - // settled; a save failure (e.g. the credit cap) leaves the unpaid - // remainder open for operator inspection — a liability is never erased. - reapplyGroup := func(group []*economyRecord, p *player.Player, credit int) { - // A mixed tracked/untracked group can contain a record already settled - // earlier in this pass; paying it again would create credits. Filter - // to the still-unsettled members. - active := make([]*economyRecord, 0, len(group)) - for _, rec := range group { - if !settled[rec.ID] { - active = append(active, rec) - } - } - group = active - if len(group) == 0 { - return - } - paid := 0 - capBlocked := false - for m := 0; m < len(group); m++ { - if _, err := addCreditsChecked(p.Credits, credit); err != nil { - capBlocked = true - break - } - if err := g.payCreditsDurable(p, credit); err != nil { - fmt.Fprintf(os.Stderr, "economy recovery: payout save %s: %v\n", p.Name, err) - break - } - paid++ - compensated++ - } - for idx, other := range group { - if idx < paid { - g.Economy.settle(other) - settled[other.ID] = true - continue - } - if capBlocked || paid > 0 { - // Either the credit cap blocked re-application, or an - // earlier sibling WAS paid before this record's save failed - // transiently. In both cases the durable balance already - // moved away from this record's original before-checkpoint, - // so re-anchor the checkpoints to the current durable balance - // — without this, the balance a PAID sibling moved reads as - // this record's own durability and the liability is silently - // settled instead of paid (and compaction then erases the - // settled sibling evidence, so a later run sees len(group)==1 - // and settles the remainder as "already durable"). - reanchorOwedPayout(other, p.Credits, other.Credit) - settled[other.ID] = true // handled for the rest of this run - if err := g.Economy.appendRetry(*other, 3); err != nil { - // The re-anchor is the evidence the next run needs to avoid - // silently settling this liability. If it is not durable, - // skip compaction so the settled sibling evidence survives. - fmt.Fprintf(os.Stderr, "economy recovery: re-anchor append %s failed: %v — skipping compaction this run\n", other.ID, err) - skipCompact = true - } - continue - } - // A TRANSIENT save failure blocked the FIRST re-application - // (paid == 0) and the balance still sits at the original - // before-checkpoint: keep the record open with its ORIGINAL - // checkpoints so a later recovery reads it as owed and retries - // cleanly. Re-anchoring here would make the record unreadable - // against the unchanged durable balance (before-checkpoint - // mismatch) and permanently ambiguous. - settled[other.ID] = true // handled for the rest of this run - } - } - for i := range folded { - rec := &folded[i].rec - if rec.Kind != "payout" || rec.Status != "open" || settled[rec.ID] { - continue - } - p := loadPlayer(rec) - if p == nil { - continue - } - if rec.Tracked { - // Tracked records carry durable evidence in the character file - // itself: the same save that includes the amount in the balance - // records the record ID in open_payouts. Membership decides. - // Balance checkpoints are NEVER consulted for tracked records: - // unrelated credit changes (shop sales, bank deposits, other - // games) can coincidentally land on CreditsAfter, and settling - // on that coincidence silently erases an owed payout. - if openPayoutRecorded(p, rec.ID) { - // The save landed; the amount is already in the balance. - g.Economy.settle(rec) - settled[rec.ID] = true - continue - } - if err := g.applyOpenPayout(p, rec); err != nil { - // Cap-blocked or transiently failed: the record stays open - // (no evidence entry was added) and a later recovery - // retries. A liability is never silently dropped. - fmt.Fprintf(os.Stderr, "economy recovery: tracked payout %s for %s not re-applied (%v) — kept open for operator inspection\n", - rec.ID, rec.Player, err) - } else { - if g.Economy.settleDurable(rec) { - removeOpenPayout(p, rec.ID) - } - compensated++ - } - settled[rec.ID] = true - continue - } - sig := payoutSig{rec.key(), rec.CreditsBefore, rec.CreditsAfter, rec.Credit} - group := siblings[sig] - switch { - case p.Credits == rec.CreditsBefore: - // The payout (and every identical sibling) was lost in the - // crash: the player is owed them all. - reapplyGroup(group, p, rec.Credit) - case p.Credits == rec.CreditsAfter && len(group) == 1 && settledSiblings(sig, rec.ID) == 0: - // The only record with these checkpoints sits at its - // post-payout balance: it is already durable, settle it. - g.Economy.settle(rec) - settled[rec.ID] = true - case p.Credits == rec.CreditsAfter && settledSiblings(sig, rec.ID) > 0: - // A SETTLED sibling with identical checkpoints explains the - // balance: an earlier recovery run already re-applied (and - // settled) it, or its original settlement moved the balance - // here. This open record is still OWED — settling it as - // "already durable" because the balance reads `after` would - // silently drop the liability (see the - // partially-paid-identical-siblings regression). - reapplyGroup(group, p, rec.Credit) - default: - fmt.Fprintf(os.Stderr, "economy recovery: ambiguous payout %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n", - rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) - } - } - - // Phase 2 — outstanding wagers, newest first. - var openWagers []int - for i := range folded { - if folded[i].rec.Kind == "wager" && folded[i].rec.Status == "open" { - openWagers = append(openWagers, i) - } - } - for k := len(openWagers) - 1; k >= 0; k-- { - rec := &folded[openWagers[k]].rec - p := loadPlayer(rec) - if p == nil { - continue - } - // File-side resolution evidence: the session resolved this stake - // but its settled marker could not be journaled (e.g. a broken - // economy path), so the character file carries the resolution ID. - // The round resolved — the stake must not be refunded, and its chip - // part must not return in kind on top of a settlement that already - // returned it as credits (a refund, by contrast, already gave the - // chips back in the file). - if resolvedStakeRecorded(p, rec.ID) { - g.Economy.settle(rec) - compensated++ - continue - } - resolvedByPayout, resolvedBySettlement := stakeEvidence(rec) - // Checkpoint comparisons use the DURABLE state, captured before any - // in-memory chip restoration below. - creditsNow, chipsNow := p.Credits, countChips(p) - // Chips are recovered independently of the credit checkpoints. Any - // state where the chip debit is durable but the chips have not come - // back (post-debit chip balance) still owes the chips — unless a - // SETTLEMENT payout was journaled later: a settlement proves the - // round resolved, so the chips were lost at settlement by the rules - // of the game, and returning them in kind on top would double-pay - // the round (e.g. a surrendered hand paying half the stake must not - // ALSO return the full chip stake). A REFUND payout instead marks a - // refund in progress: its credit part was re-applied by phase 1 and - // the chip part still returns in chips. - chipsOwed := rec.Chips > 0 && chipsNow == rec.ChipsAfter && - !resolvedBySettlement - if chipsOwed && !g.giveChips(p, rec.Chips) { - // No inventory room for the chip return: keep the record open - // for operator inspection rather than silently dropping the - // chips; a later startup (or operator action) retries. - fmt.Fprintf(os.Stderr, "economy recovery: wager %s for %s: no inventory room for %d chips — kept open for operator inspection\n", - rec.ID, rec.Player, rec.Chips) - continue - } - saveChipReturn := func() error { - if !chipsOwed { - return nil - } - if err := g.AccountStore.SaveCharacter(p); err != nil { - p.RemoveItem("chips", rec.Chips) - fmt.Fprintf(os.Stderr, "economy recovery: chip return save %s: %v\n", p.Name, err) - return err - } - return nil - } - switch { - case creditsNow == rec.CreditsBefore && chipsNow == rec.ChipsBefore: - // The debit never became durable: void it. - g.Economy.void(rec) - case creditsNow == rec.CreditsAfter: - if resolvedByPayout { - // A later payout record proves the round resolved (the win, - // push, or refund already returned the credit stake inside - // that payout). Only the resolution marker was lost; the - // chip stake may still be outstanding. - if saveChipReturn() == nil { - g.Economy.settle(rec) - } - continue - } - // The debit is durable but the round was lost in the crash: - // refund the stake (approved refund-on-recovery policy). The - // chips (if owed) are already restored in memory; one durable - // save covers both the chip return and the credit refund. The - // credit refund is checked: a record can never push the - // balance past the economy cap. - sum, capErr := addCreditsChecked(creditsNow, rec.Credit) - if capErr != nil { - if chipsOwed { - p.RemoveItem("chips", rec.Chips) - } - fmt.Fprintf(os.Stderr, "economy recovery: refund %s for %s blocked (%v) — kept open for operator inspection\n", - rec.ID, rec.Player, capErr) - continue - } - p.Credits = sum - if err := g.AccountStore.SaveCharacter(p); err != nil { - // Revert the in-memory refund so later records for this - // player still compare against the durable balance; the - // record stays open and a later startup can retry. - p.Credits = creditsNow - if chipsOwed { - p.RemoveItem("chips", rec.Chips) - } - fmt.Fprintf(os.Stderr, "economy recovery: refund save %s: %v\n", p.Name, err) - continue - } - g.Economy.settle(rec) - compensated++ - default: - // Persist an owed chip return even when the credit side stays - // ambiguous: the record remains open either way, but the chips - // must not be silently lost. A failed save reverts them. - if saveChipReturn() != nil { - continue - } - if resolvedByPayout { - g.Economy.settle(rec) - continue - } - // The chips were returned in kind and persisted; clear the - // record's chip obligation durably so a later session refund - // (RefundOutstanding) cannot return the same chips again. The - // record stays open for the credit side. - if chipsOwed { - rec.Chips = 0 - rec.ChipsBefore = chipsNow - rec.ChipsAfter = chipsNow - if err := g.Economy.appendRetry(*rec, 3); err != nil { - fmt.Fprintf(os.Stderr, "economy recovery: chip-obligation clear %s failed: %v\n", rec.ID, err) - skipCompact = true - } - } - fmt.Fprintf(os.Stderr, "economy recovery: ambiguous wager %s for %s (credits %d, want before %d or after %d) — kept open for operator inspection\n", - rec.ID, rec.Player, p.Credits, rec.CreditsBefore, rec.CreditsAfter) - } - } - if skipCompact { - fmt.Fprintln(os.Stderr, "economy recovery: compaction skipped to preserve payout evidence") - } else { - g.Economy.compact() - } - // Final evidence prune: the phases above settled or voided records, so - // evidence IDs that no longer correspond to an open record are stale - // (a settled stake's resolution evidence, an applied payout's entry). - // Drop them now so the character files stay small and meaningful; a - // failed save only defers the prune to the next startup (finalizeLoaded). - stillOpen := make(map[string]bool) - for i := range folded { - rec := &folded[i].rec - if rec.Kind == "wager" && rec.Status == "open" { - stillOpen[rec.ID] = true - } - } - pruneSeen := make(map[*player.Player]bool) - prunePlayer := func(p *player.Player) { - if p == nil || pruneSeen[p] { - return - } - pruneSeen[p] = true - if pruneOpenPayouts(g.Economy, p) || pruneResolvedStakes(stillOpen, p) { - if err := g.AccountStore.SaveCharacter(p); err != nil { - fmt.Fprintf(os.Stderr, "economy recovery: final evidence prune save %s: %v\n", p.Name, err) - } - } - } - for _, p := range playersByID { - prunePlayer(p) - } - for _, p := range playersByName { - prunePlayer(p) - } - // Rebuild the outstanding-stake index from whatever open wager records - // remain (normally none; ambiguous ones stay refundable/resolvable). A - // fold failure leaves the previous index intact and was already logged. - if err := g.Economy.reloadStakes(); err != nil { - fmt.Fprintf(os.Stderr, "economy recovery: stake index rebuild failed: %v\n", err) - } -} diff --git a/internal/game/economy_journal_test_helpers_test.go b/internal/game/economy_journal_test_helpers_test.go deleted file mode 100644 index e65c622..0000000 --- a/internal/game/economy_journal_test_helpers_test.go +++ /dev/null @@ -1,14 +0,0 @@ -package game - -import "testing" - -// mustLoadOpen is the test-side loadOpen: a journal fold failure is a test -// failure, not a nil slice to accidentally treat as "nothing open". -func (j *economyJournal) mustLoadOpen(t *testing.T) []*economyRecord { - t.Helper() - open, err := j.loadOpen() - if err != nil { - t.Fatalf("loadOpen: %v", err) - } - return open -} diff --git a/internal/game/economy_open_payout_list_test.go b/internal/game/economy_open_payout_list_test.go deleted file mode 100644 index a16f73b..0000000 --- a/internal/game/economy_open_payout_list_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package game - -// Regressions for the character-file payout-evidence scheme: an owed payout -// must never be erased because an unrelated credit change coincidentally -// lands on the record's post-payout balance, and a payout whose save became -// durable must never be re-applied (double-paid) just because its settled -// journal marker was lost. - -import ( - "os" - "testing" - - "thehouseoficarus/internal/casino" - "thehouseoficarus/internal/player" -) - -// newEvidenceHarness builds a minimal Game with just the account store and -// economy journal — enough for casinoWallet and recoverEconomy. -func newEvidenceHarness(t *testing.T) (*Game, *player.Player) { - t.Helper() - dir := t.TempDir() - if err := os.MkdirAll(dir+"/players/characters", 0o700); err != nil { - t.Fatal(err) - } - g := &Game{ - Deps: Deps{AccountStore: player.NewAccountStore(dir)}, - Economy: newEconomyJournal(dir), - } - p := player.New("alice") - p.ID = "stable-identity" - p.Credits = 90 - if err := g.AccountStore.SaveCharacter(p); err != nil { - t.Fatal(err) - } - return g, p -} - -// TestRecoveryReappliesPayoutWhenUnrelatedCreditsMaskTheBalance: a payout -// whose save failed leaves an OPEN record at balance 90 (want 110). A later, -// unrelated 20-credit gain brings the durable balance to 110 — the record's -// CreditsAfter. Recovery must read the character-file evidence (absent: the -// save never landed) and re-apply the payout, not settle it as "already -// durable" and silently erase the 20 owed credits. -func TestRecoveryReappliesPayoutWhenUnrelatedCreditsMaskTheBalance(t *testing.T) { - g, p := newEvidenceHarness(t) - - // Force the payout save to fail without touching its journal. - path := g.AccountStore.CharPath(p.Name) - backup := path + ".backup" - if err := os.Rename(path, backup); err != nil { - t.Fatal(err) - } - if err := os.Mkdir(path, 0o700); err != nil { - t.Fatal(err) - } - w := casinoWallet{g: g, p: p} - if err := w.PayCredits(20); err != casino.ErrWalletPersistence { - t.Fatalf("PayCredits error = %v, want ErrWalletPersistence", err) - } - if err := os.Remove(path); err != nil { - t.Fatal(err) - } - if err := os.Rename(backup, path); err != nil { - t.Fatal(err) - } - if p.Credits != 90 { - t.Fatalf("failed payout was paid: credits=%d, want 90", p.Credits) - } - - // The unrelated credit change: a shop sale, a bank withdrawal, anything. - p.Credits += 20 - if err := g.AccountStore.SaveCharacter(p); err != nil { - t.Fatal(err) - } - - g.recoverEconomy() - - loaded, err := g.AccountStore.LoadCharacter(p.Name) - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 130 { - t.Fatalf("balance after recovery = %d, want 130 (owed payout re-applied once)", loaded.Credits) - } - if open := g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Fatalf("records left open after recovery: %+v", open) - } -} - -// TestRecoverySettlesDurablePayoutWithoutReapplying: the payout save landed -// (the character file records the record ID in open_payouts) but the settled -// journal marker was lost in the crash. Recovery must settle from the file -// evidence — re-applying would double-pay the win. -func TestRecoverySettlesDurablePayoutWithoutReapplying(t *testing.T) { - g, p := newEvidenceHarness(t) - - rec, err := g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0) - if err != nil { - t.Fatal(err) - } - // The save landed: balance and evidence both durable. - p.Credits = 110 - p.OpenPayouts = append(p.OpenPayouts, rec.ID) - if err := g.AccountStore.SaveCharacter(p); err != nil { - t.Fatal(err) - } - - g.recoverEconomy() - - loaded, err := g.AccountStore.LoadCharacter(p.Name) - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 110 { - t.Fatalf("balance after recovery = %d, want 110 (durable payout not re-applied)", loaded.Credits) - } - if open := g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Fatalf("records left open after recovery: %+v", open) - } -} diff --git a/internal/game/economy_reanchor_test.go b/internal/game/economy_reanchor_test.go deleted file mode 100644 index 86d7bf1..0000000 --- a/internal/game/economy_reanchor_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package game - -import ( - "testing" - - "thehouseoficarus/internal/casino" -) - -// TestReanchorOwedPayoutReadsAsOwed: when recovery cannot pay every sibling in -// an identical-signature payout group, the unpaid remainder must be re-anchored -// to the current durable balance. Otherwise a later run (after compaction drops -// the settled sibling evidence) reads the balance as the record's own -// durability and settles the liability without paying it. -func TestReanchorOwedPayoutReadsAsOwed(t *testing.T) { - rec := &economyRecord{Credit: 20, CreditsBefore: 90, CreditsAfter: 110} - // One sibling was paid: the durable balance now sits at 110. - reanchorOwedPayout(rec, 110, rec.Credit) - if rec.CreditsBefore != 110 || rec.CreditsAfter != 130 { - t.Fatalf("re-anchored checkpoints = before %d after %d, want before 110 after 130", rec.CreditsBefore, rec.CreditsAfter) - } - - // At the economy cap the post-checkpoint must stay reachable: before == - // after so the record keeps reading as owed instead of claiming an - // impossible balance that a coincidental balance could be mistaken for. - capped := &economyRecord{Credit: 20} - reanchorOwedPayout(capped, casino.MaxCredits, capped.Credit) - if capped.CreditsBefore != casino.MaxCredits || capped.CreditsAfter != casino.MaxCredits { - t.Fatalf("cap re-anchor = before %d after %d, want both %d", - capped.CreditsBefore, capped.CreditsAfter, casino.MaxCredits) - } -} diff --git a/internal/game/economy_recovery_identity_test.go b/internal/game/economy_recovery_identity_test.go deleted file mode 100644 index 04d6fed..0000000 --- a/internal/game/economy_recovery_identity_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package game - -// Identity-resolution regressions for economy recovery: a record carrying a -// stable character ID must never be resolved — paid or resolved — through a -// display name that a different character later reused, and money recovery -// must run only AFTER lifecycle-journal rollback (a rolled-back rename -// restores an older character before-image that must not erase compensation -// already applied). - -import ( - "os" - "path/filepath" - "testing" - - "thehouseoficarus/internal/persistence" -) - -// TestRecoveryDoesNotPayReusedCharacterName: an owed payout journaled for -// identity A (named "alice") must not be paid to identity B after B reuses -// the name, and A's unresolved liability must stay open for operator -// inspection instead of being silently settled against B's balance. -func TestRecoveryDoesNotPayReusedCharacterName(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.ID = "original-character" - p.Credits = 90 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - // A failed payout save left the owed money journaled for recovery. - if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { - t.Error(err) - return - } - // Delete the original character, then reuse its name for a - // different identity. - p.ID = "replacement-character" - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - h.g.recoverEconomy() - }) - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 90 { - t.Errorf("replacement identity received someone else's payout: credits=%d, want 90", loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 1 { - t.Errorf("original identity's unresolved liability was discarded: open records=%d", len(open)) - } -} - -// TestEconomyRecoveryRunsAfterLifecycleRollback: a leftover lifecycle -// transaction (a rename that never reached its commit point) restores the -// character's before-image at startup, and must do so BEFORE money -// compensation — otherwise the compensation's settled/compacted records are -// gone and the rollback silently erases a payout that was already applied. -func TestEconomyRecoveryRunsAfterLifecycleRollback(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.ID = "stable-character" - p.Credits = 90 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - // A failed payout save left the owed money journaled for recovery. - if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { - t.Error(err) - return - } - // Simulate a crash mid-rename: apply the move and the renamed - // character file directly, then leave the lifecycle journal on - // disk so the next startup rolls it back. - oldPath := h.g.AccountStore.CharPath("alice") - newPath := h.g.AccountStore.CharPath("renamed") - txn := persistence.NewTransaction(h.g.journalDir()) - if err := txn.Move(oldPath, newPath); err != nil { - t.Error(err) - return - } - p.Name = "renamed" - data, err := h.g.AccountStore.MarshalCharacter(p) - if err != nil { - t.Error(err) - return - } - if err := txn.Replace(newPath, data, 0600); err != nil { - t.Error(err) - return - } - journal, err := txn.MarshalJournal() - if err != nil { - t.Error(err) - return - } - if err := os.WriteFile(filepath.Join(h.g.journalDir(), "txn-audit.json"), journal, 0600); err != nil { - t.Error(err) - return - } - if err := os.Rename(oldPath, newPath); err != nil { - t.Error(err) - return - } - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - }) - // A fresh Game over the saved files simulates the restart. - restarted := harnessFromDir(t, h.dir) - loaded, err := restarted.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 110 { - t.Errorf("lifecycle rollback erased recovered payout: credits=%d, want 110; open liabilities=%d", - loaded.Credits, len(restarted.g.Economy.mustLoadOpen(t))) - } -} diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go deleted file mode 100644 index 207e01d..0000000 --- a/internal/game/economy_recovery_regression_test.go +++ /dev/null @@ -1,546 +0,0 @@ -package game - -// Economy recovery regressions for the outstanding-stake ledger: a crash -// with a round in flight refunds the stake, chip stakes recover in chips, -// a failed payout is re-applied or preserved for operator inspection, and -// a resolved round (payout evidence) is never refunded twice. - -import ( - "testing" - - "thehouseoficarus/internal/casino" - "thehouseoficarus/internal/player" -) - -// TestRecoveryRefundsInFlightMachineWager: a wager debited durably with the -// round still in flight (no graceful shutdown, no resolution) is refunded by -// startup recovery — the documented refund-on-crash policy. -func TestRecoveryRefundsInFlightMachineWager(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - h.g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots}, "alice") - h.g.Casino.SetMachineBet(1, "slots", "alice", 10, casinoWallet{g: h.g, p: p}) - }) - h.barrier() - - // A fresh Game over the saved files simulates losing all live round - // state in a crash. The stake record is open, so recovery refunds it. - restarted := harnessFromDir(t, h.dir) - loaded, err := restarted.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 100 { - t.Fatalf("crash with an active 10-credit spin: recovered %d credits, want 100", loaded.Credits) - } - if open := restarted.g.Economy.openStakes("alice"); len(open) != 0 { - t.Fatalf("refunded stake left open: %+v", open) - } -} - -// TestRecoveryRefundsChipOnlyWager: a chip-only debit must be recognized by -// comparing chip balances, not credits alone (the credit checkpoints are -// identical before and after a chip-only wager). -func TestRecoveryRefundsChipOnlyWager(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 20}) - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - } - // Simulate the crash window: the chip debit is durable in the - // character file but the stake record never resolved. - p.RemoveItem("chips", 10) - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - } - if _, err := h.g.Economy.start("wager", p, 10, 0, 100, 100, 20, 10); err != nil { - t.Error(err) - } - h.g.recoverEconomy() - }) - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if got := countChips(loaded); got != 20 { - t.Fatalf("chip stake after recovery = %d, want 20 (refunded in chips)", got) - } -} - -// TestRecoveryReappliesLostPayoutWithEvidence: a won hand whose payout -// failed to save leaves an open payout record AND an open stake record. -// Recovery re-applies the payout (which includes the stake return) and -// settles the stake from that evidence — exactly once, no double refund. -func TestRecoveryReappliesLostPayoutWithEvidence(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 90 // post-debit balance: the 10-credit stake was charged - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - } - // Open stake for the lost round (before=100, after=90). - if _, err := h.g.Economy.start("wager", p, 0, 10, 100, 90, 0, 0); err != nil { - t.Error(err) - } - // Open payout for the won hand (2x bet = 20, before=90, after=110). - if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { - t.Error(err) - } - h.g.recoverEconomy() - }) - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 110 { - t.Fatalf("balance after recovery = %d, want 110 (payout re-applied once, no double stake refund)", loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Fatalf("records left open after recovery: %+v", open) - } -} - -// TestRecoveryRefundsSequentialStakes: a bet plus a double (two open stake -// records, newest processed first) both refund after a crash. -func TestRecoveryRefundsSequentialStakes(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 80 // bet 10 then doubled: 100 -> 90 -> 80 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - } - if _, err := h.g.Economy.start("wager", p, 0, 10, 100, 90, 0, 0); err != nil { - t.Error(err) - } - if _, err := h.g.Economy.start("wager", p, 0, 10, 90, 80, 0, 0); err != nil { - t.Error(err) - } - h.g.recoverEconomy() - }) - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 100 { - t.Fatalf("balance after recovery = %d, want 100 (both stakes refunded)", loaded.Credits) - } -} - -// TestRecoveryReappliesIdenticalFailedPayouts: two won hands whose payout -// saves both failed leave two OPEN payout records with identical -// checkpoints (the in-memory balance reverts on save failure, so both were -// journaled against balance 90). Recovery must re-apply BOTH — settling -// the second as "already durable" because the first re-application moved -// the balance to `after` would silently drop 20 owed credits. -func TestRecoveryReappliesIdenticalFailedPayouts(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 90 // post-debit balance: both payouts failed to save - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - } - for i := 0; i < 2; i++ { - if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { - t.Error(err) - } - } - h.g.recoverEconomy() - }) - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 130 { - t.Fatalf("balance after recovery = %d, want 130 (both identical failed payouts re-applied)", loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Fatalf("records left open after recovery: %+v", open) - } -} - -// TestRefundOutstandingKeepsStakesOpenWithoutInventoryRoom: a chip refund -// into a FULL inventory must be refused (stake records stay open) instead -// of silently dropping the chips and marking the refund complete. Once the -// player frees a slot, the same outstanding stake refunds successfully. -func TestRefundOutstandingKeepsStakesOpenWithoutInventoryRoom(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10}) - w := casinoWallet{g: h.g, p: p} - if _, err := w.Wager(10); err != nil { - t.Error(err) - return - } - // The debit emptied the chips slot; fill EVERY slot (including the - // one the wager just freed) so the chip return has nowhere to go. - for p.FirstFreeSlot() >= 0 { - p.SetInvSlot(p.FirstFreeSlot(), &player.InventorySlot{ItemID: "filler", Quantity: 1}) - } - chips, credits, err := w.RefundOutstanding() - if err != casino.ErrWalletPersistence { - t.Errorf("refund error = %v, want ErrWalletPersistence (chips=%d credits=%d)", err, chips, credits) - } - if got := countChips(p); got != 0 { - t.Errorf("chips after refused refund = %d, want 0 (nothing delivered; tracked in the open stake)", got) - } - if open := h.g.Economy.openStakes(p.Name); len(open) != 1 { - t.Errorf("stake records after refused refund = %d, want 1 (kept open for a later attempt)", len(open)) - } - - // Free a slot: the SAME outstanding stake now refunds in full. - p.RemoveItem("filler", 1) - chips, credits, err = w.RefundOutstanding() - if err != nil || chips != 10 || credits != 0 { - t.Errorf("retry refund = (%d chips, %d credits, %v), want (10, 0, nil)", chips, credits, err) - } - if got := countChips(p); got != 10 { - t.Errorf("chips after retried refund = %d, want 10", got) - } - if open := h.g.Economy.openStakes(p.Name); len(open) != 0 { - t.Errorf("stake records after successful refund = %d, want 0", len(open)) - } - }) -} - -// TestRecoveryKeepsWagerOpenWhenChipsDoNotFit: recovery must not settle a -// chip stake as refunded when the character's inventory cannot hold the -// chips — the record stays open for operator inspection. -func TestRecoveryKeepsWagerOpenWhenChipsDoNotFit(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - // Durable state at crash time: the 10-chip debit landed (10 -> 0 - // chips on disk), credits untouched, and the inventory is FULL of - // non-chip filler, so recovery has nowhere to put the refund. - p.Credits = 100 - for i := 0; i < 28; i++ { - p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1}) - } - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - } - if _, err := h.g.Economy.start("wager", p, 10, 0, 100, 100, 10, 0); err != nil { - t.Error(err) - } - h.g.recoverEconomy() - }) - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if got := countChips(loaded); got != 0 { - t.Fatalf("chips after recovery = %d, want 0 (no inventory room; nothing delivered)", got) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 1 { - t.Fatalf("chip wager must stay open for operator inspection, open records: %+v", open) - } -} - -// TestRecoveryPreservesAmbiguousPayout: a payout whose checkpoints match -// neither balance state stays open and is surfaced for operator inspection — -// a liability is never silently dropped. -func TestRecoveryPreservesAmbiguousPayout(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - // A payout owed at balance 100 failed to save; the player then - // spent 5 credits elsewhere before the crash. - p.Credits = 95 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - } - rec, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0) - if err != nil { - t.Error(err) - return - } - // A LEGACY record (no character-file evidence, as written before the - // open_payouts scheme existed): checkpoint matching is the only - // evidence, balance 95 matches neither checkpoint, and the record - // stays open for operator inspection. Tracked records decide by - // file evidence instead — see economy_open_payout_list_test.go. - rec.Tracked = false - h.g.Economy.append(*rec) - h.g.recoverEconomy() - }) - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 95 { - t.Fatalf("ambiguous recovery mutated the balance: %d, want 95", loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 1 { - t.Fatalf("ambiguous payout must stay open for operator inspection: %+v", open) - } -} - -// TestRecoveryKeepsUnpaidSiblingsOpenAtCreditCap: two identical open payout -// records where re-applying the second would exceed the economy cap must -// settle only the sibling that was actually paid. The unpaid 20-credit -// liability is never erased: its record stays open for operator inspection. -func TestRecoveryKeepsUnpaidSiblingsOpenAtCreditCap(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = casino.MaxCredits - 30 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - for i := 0; i < 2; i++ { - if _, err := h.g.Economy.start("payout", p, 0, 20, p.Credits, p.Credits+20, 0, 0); err != nil { - t.Error(err) - return - } - } - h.g.recoverEconomy() - }) - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != casino.MaxCredits-10 { - t.Fatalf("balance after capped recovery = %d, want %d (one payout applied)", loaded.Credits, casino.MaxCredits-10) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 1 { - t.Fatalf("unpaid liability must stay open for operator inspection, open records: %+v", open) - } -} - -// TestRecoveryRestoresChipsFromMixedRefundCrash: a mixed chips+credits -// refund whose credit side is resolved by a later payout record but whose -// chip side was never saved must still return the chips. The wager record -// may be settled only once nothing is owed. -func TestRecoveryRestoresChipsFromMixedRefundCrash(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10}) - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - // Durable 20-credit (10 chips + 10 credits) wager: after the debit - // the file holds 90 credits and no chips. - w := casinoWallet{g: h.g, p: p} - if _, err := w.Wager(20); err != nil { - t.Error(err) - return - } - // Crash during RefundOutstanding, after the credit-side REFUND - // payout was journaled but before anything beyond the debit was - // durable. The record is refund-flagged: recovery re-applies the - // refund credit AND returns the chip part of the stake in chips. - if _, err := h.g.Economy.startRefund(p, 10, 90, 100); err != nil { - t.Error(err) - return - } - h.g.recoverEconomy() - }) - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 100 || countChips(loaded) != 10 { - t.Fatalf("mixed refund after recovery = (%d credits, %d chips), want (100, 10); open records: %+v", - loaded.Credits, countChips(loaded), h.g.Economy.mustLoadOpen(t)) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Fatalf("nothing may stay owed after recovery: %+v", open) - } -} - -// TestRecoveryDoesNotReturnPaidChipStakeInKind: a chip wager whose round -// resolved with a durable payout (a win returns the stake inside the -// payout) must not ALSO get the chips back in kind on recovery — the -// resolving payout is the refund. Only a payout that does not cover the -// stake's full value (e.g. the credit side of a crashed mixed refund) -// leaves the chip side outstanding. -func TestRecoveryDoesNotReturnPaidChipStakeInKind(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10}) - w := casinoWallet{g: h.g, p: p} - if _, err := w.Wager(10); err != nil { - t.Error(err) - return - } - // The round resolved: a 2x payout (20) is durable, then the crash - // lands before the stake's resolution marker. - if err := w.PayCredits(20); err != nil { - t.Error(err) - return - } - h.g.recoverEconomy() - }) - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 120 || countChips(loaded) != 0 { - t.Fatalf("won chip bet after recovery = (%d credits, %d chips), want (120, 0) — no double refund", - loaded.Credits, countChips(loaded)) - } -} - -// TestRecoveryUnpaidLiabilitySurvivesLaterRecoveries: an unpaid payout -// liability (the credit cap blocked re-application) must survive repeated -// recoveries. Without checkpoint re-anchoring, a later recovery mistakes -// the balance a paid sibling moved for the unpaid record's own durability -// and silently erases the owed amount. -func TestRecoveryUnpaidLiabilitySurvivesLaterRecoveries(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = casino.MaxCredits - 30 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - for i := 0; i < 2; i++ { - if _, err := h.g.Economy.start("payout", p, 0, 20, p.Credits, p.Credits+20, 0, 0); err != nil { - t.Error(err) - return - } - } - h.g.recoverEconomy() - if len(h.g.Economy.mustLoadOpen(t)) != 1 { - t.Errorf("setup: expected one unpaid record after capped recovery, got %d", len(h.g.Economy.mustLoadOpen(t))) - return - } - // A later startup over the same files: the liability stays owed. - h.g.recoverEconomy() - if got := len(h.g.Economy.mustLoadOpen(t)); got != 1 { - t.Errorf("second recovery erased the unpaid 20-credit liability: open=%d, want 1", got) - } - }) - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != casino.MaxCredits-10 { - t.Fatalf("balance after repeated recovery = %d, want %d (one payout applied)", loaded.Credits, casino.MaxCredits-10) - } -} - -// TestCapBlockedPayoutIsJournaled: a won payout at the economy cap must be -// recorded as an owed (open) payout record even though it cannot be -// applied — a reported win is never silently dropped. -func TestCapBlockedPayoutIsJournaled(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = casino.MaxCredits - w := casinoWallet{g: h.g, p: p} - if _, err := w.Wager(10); err != nil { - t.Error(err) - return - } - if err := w.PayCredits(20); err == nil { - t.Error("payout at the credit cap must fail, not silently clamp") - return - } - if p.Credits != casino.MaxCredits-10 { - t.Errorf("cap-blocked payout mutated the balance: %d", p.Credits) - } - }) - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Fatal(err) - } - restarted := harnessFromDir(t, h.dir) - loaded, err := restarted.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - // Recovery: the owed win cannot be applied (cap), so the liability - // stays open for operator inspection, never erased; the consumed - // stake is settled against the payout evidence. - if got := len(restarted.g.Economy.mustLoadOpen(t)); got != 1 { - t.Fatalf("owed cap-blocked payout must stay open after recovery, open records: %+v", restarted.g.Economy.mustLoadOpen(t)) - } - if loaded.Credits != casino.MaxCredits-10 { - t.Fatalf("recovery mutated a capped balance: %d", loaded.Credits) - } -} - -// TestRecoveryReappliesPartiallyPaidIdenticalPayouts: two identical payout -// records were open and an earlier recovery run re-applied (and settled) -// only the FIRST before crashing again — the second save failed transiently -// (see TestRecoverySaveFailureRetriesOnNextStartup for the single-record -// case). The next recovery must NOT settle the remaining record as -// "already durable" merely because the balance now sits at its -// post-payout checkpoint: the settled sibling explains that balance, and -// the open record is still owed. -func TestRecoveryReappliesPartiallyPaidIdenticalPayouts(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 90 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - // Two identical owed payouts were journaled against balance 90. - if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { - t.Error(err) - return - } - second, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0) - if err != nil { - t.Error(err) - return - } - // Simulate the prior recovery run: the first record was re-applied - // and settled durably; the second's save failed transiently. - if err := h.g.payCreditsDurable(p, 20); err != nil { - t.Error(err) - return - } - first := h.g.Economy.mustLoadOpen(t)[0] - if first.ID == second.ID { - t.Fatal("setup: expected two distinct records") - } - h.g.Economy.settle(first) - // Recovery over this state: the remaining owed payout must be - // re-applied, never settled as "already durable". - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - if loaded.Credits != 130 { - t.Errorf("recovered balance = %d, want 130 (remaining owed payout re-applied)", loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Errorf("records left open after recovery: %+v", open) - } - }) -} diff --git a/internal/game/economy_rollback_regression_test.go b/internal/game/economy_rollback_regression_test.go deleted file mode 100644 index bceb351..0000000 --- a/internal/game/economy_rollback_regression_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package game - -// Rollback-failure regressions for the durable money journal. When a journal -// append fails, the invariant was historically "append failed means no record -// bytes remain". A failed rollback (the truncate, or the sync that makes it -// durable) breaks that invariant: the record can survive while the caller -// believes the append failed. The wallet's journal-unavailable payout fallback -// must then still record the attempted record's ID as open-payout evidence, or -// recovery re-applies the surviving payout and pays the player twice. - -import ( - "encoding/json" - "errors" - "os" - "path/filepath" - "testing" -) - -// TestAppendRollbackFailureIsUncertain: /dev/full accepts the open but fails -// the write with ENOSPC and the rollback truncate with EINVAL, so the append -// must report that the record MAY have survived. -func TestAppendRollbackFailureIsUncertain(t *testing.T) { - if _, err := os.Stat("/dev/full"); err != nil { - t.Skipf("/dev/full is not available: %v", err) - } - j := newEconomyJournal(t.TempDir()) - j.path = "/dev/full" - - err := j.append(economyRecord{ID: "uncertain", Kind: "payout", Status: "open"}) - if !errors.Is(err, errJournalAppendUncertain) { - t.Fatalf("append rollback failure = %v, want errJournalAppendUncertain", err) - } -} - -// TestFallbackPayoutRecordsEvidenceForSurvivingRecord: a payout whose journal -// append fails and whose rollback cannot be proven must leave open-payout -// evidence in the character file. A surviving record is then settled from that -// evidence at the next startup instead of being re-applied. -func TestFallbackPayoutRecordsEvidenceForSurvivingRecord(t *testing.T) { - g, p := newEvidenceHarness(t) - - // Break the journal append: point it at its own directory, which cannot - // be opened for append or truncation. The wallet takes the file-only - // fallback. - journalPath := g.Economy.path - g.Economy.path = filepath.Dir(journalPath) - w := casinoWallet{g: g, p: p} - if err := w.PayCredits(20); err != nil { - t.Fatalf("fallback payout failed: %v", err) - } - if p.Credits != 110 { - t.Fatalf("fallback payout not applied: credits=%d, want 110", p.Credits) - } - if len(p.OpenPayouts) != 1 { - t.Fatalf("fallback payout recorded %d open-payout evidence IDs, want 1: %v", - len(p.OpenPayouts), p.OpenPayouts) - } - id := p.OpenPayouts[0] - - // Simulate the failed append having survived in the journal (the exact - // state a failed rollback produces). Recovery must use the file evidence - // and settle it, not re-apply it. - g.Economy.path = journalPath - rec := economyRecord{ - ID: id, Player: p.Name, PlayerID: p.ID, Kind: "payout", Tracked: true, - Credit: 20, Status: "open", CreditsBefore: 90, CreditsAfter: 110, - } - line, err := json.Marshal(rec) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(journalPath, append(line, '\n'), 0o600); err != nil { - t.Fatal(err) - } - - g.recoverEconomy() - - loaded, err := g.AccountStore.LoadCharacter(p.Name) - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 110 { - t.Fatalf("balance after recovery = %d, want 110 (surviving payout settled from evidence, not re-applied)", - loaded.Credits) - } - if open := g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Fatalf("records left open after recovery: %+v", open) - } -} diff --git a/internal/game/economy_seq_test.go b/internal/game/economy_seq_test.go deleted file mode 100644 index 2c5bbdb..0000000 --- a/internal/game/economy_seq_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package game - -import "testing" - -// TestStakeCoveredRespectsCreationOrder: an open payout created BEFORE a stake -// must not be treated as that stake's settlement, or RefundOutstanding would -// resolve the later stake without returning its money. -func TestStakeCoveredRespectsCreationOrder(t *testing.T) { - j := newEconomyJournal(t.TempDir()) - stake := &economyRecord{ID: "stake", Player: "alice", Kind: "wager", Status: "open", Seq: 2} - j.registerStake(stake) - - older := &economyRecord{ID: "older", Player: "alice", Kind: "payout", Status: "open", Seq: 1, Tracked: true} - j.openPayoutRecs[stake.key()] = []*economyRecord{older} - if j.stakeCovered(stake) { - t.Fatal("a payout created before the stake must not cover it") - } - - newer := &economyRecord{ID: "newer", Player: "alice", Kind: "payout", Status: "open", Seq: 3, Tracked: true} - j.openPayoutRecs[stake.key()] = []*economyRecord{newer} - if !j.stakeCovered(stake) { - t.Fatal("a payout created after the stake should cover it") - } -} diff --git a/internal/game/economy_shutdown_test.go b/internal/game/economy_shutdown_test.go deleted file mode 100644 index 27190fb..0000000 --- a/internal/game/economy_shutdown_test.go +++ /dev/null @@ -1,273 +0,0 @@ -package game - -// Durable-money and graceful-shutdown regressions: journal-first wagers -// with revert on save failure, startup recovery that refunds lost wagers -// and re-pays lost payouts, checked currency arithmetic, and a shutdown -// that persists online characters. - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - "time" - - "thehouseoficarus/internal/casino" - "thehouseoficarus/internal/config" - "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" - - "gopkg.in/yaml.v3" -) - -// harnessFromDir builds a Game over an existing directory (used when tests -// must pre-seed character files and journal records before startup). -func harnessFromDir(t *testing.T, dir string) *harness { - t.Helper() - colors := &config.ColorsConfig{} - g := New(dir, colors, colors, config.ValidationConfig{}, 1) - hub := net.NewHub() - g.SetHub(hub) - stopTick := make(chan struct{}) - done := make(chan struct{}) - go func() { - defer close(done) - tick := time.NewTicker(2 * time.Millisecond) - defer tick.Stop() - for { - select { - case <-tick.C: - g.Ticks.Tick() - case <-stopTick: - return - } - } - }() - h := &harness{ - t: t, dir: dir, g: g, hub: hub, - sessions: make(map[string]*net.Session), - conns: make(map[string]*chanConn), - } - h.stop = func() { close(stopTick); <-done } - t.Cleanup(h.stop) - return h -} - -func writeSeedFile(t *testing.T, path string, data []byte) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, data, 0600); err != nil { - t.Fatal(err) - } -} - -func writeEconomyJournal(t *testing.T, dir string, records []economyRecord) { - t.Helper() - if err := os.MkdirAll(filepath.Join(dir, "economy"), 0700); err != nil { - t.Fatal(err) - } - var data []byte - for _, rec := range records { - line, err := json.Marshal(rec) - if err != nil { - t.Fatal(err) - } - data = append(data, line...) - } - if err := os.WriteFile(filepath.Join(dir, "economy", "journal.jsonl"), data, 0600); err != nil { - t.Fatal(err) - } -} - -func TestEconomyWagerDebitsDurallyAndSettles(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - }) - w := casinoWallet{g: h.g, p: p} - - var wagerErr error - h.g.RunOnOwner(func() { - _, wagerErr = w.Wager(10) - }) - h.barrier() - if wagerErr != nil { - t.Fatalf("wager failed: %v", wagerErr) - } - if p.Credits != 90 { - t.Fatalf("credits after wager = %d, want 90", p.Credits) - } - // The debit is durable, and the record stays OPEN: the stake is - // outstanding until the round resolves (recovery refunds open stakes - // after a crash). - if open := h.g.Economy.openStakes("alice"); len(open) != 1 || open[0].Credit != 10 { - t.Fatalf("outstanding stake after a successful wager: %+v", open) - } - // Resolution (round resolved or refunded) closes it. - h.g.RunOnOwner(func() { - w.ResolveStakes() - }) - h.barrier() - if open := h.g.Economy.openStakes("alice"); len(open) != 0 { - t.Fatalf("open stakes remain after resolution: %v", open) - } -} - -func TestEconomyFailedSaveRevertsAndVoids(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - }) - - // Make character saves fail: read-only characters directory. - charDir := filepath.Join(h.dir, "players", "characters") - if err := os.Chmod(charDir, 0500); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { os.Chmod(charDir, 0700) }) - - var wagerErr error - h.g.RunOnOwner(func() { - _, wagerErr = casinoWallet{g: h.g, p: p}.Wager(10) - }) - h.barrier() - if wagerErr != casino.ErrWalletPersistence { - t.Fatalf("wager error = %v, want ErrWalletPersistence", wagerErr) - } - // The in-memory mutation must have been reverted: nothing was charged. - if p.Credits != 100 { - t.Fatalf("credits after failed wager = %d, want 100 (reverted)", p.Credits) - } - // The record is void, not open: recovery must not compensate. - os.Chmod(charDir, 0700) - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Fatalf("void record remained open: %v", open) - } -} - -func TestEconomyRecoveryRefundsDurableLostWager(t *testing.T) { - dir := t.TempDir() - seedWorld(t, dir) - - // Character with the debit already durable (crash after save). - hash, _ := player.HashPassword("pw") - accData, _ := yaml.Marshal(&player.Account{Name: "alice", PasswordHash: hash}) - writeSeedFile(t, filepath.Join(dir, "players", "accounts", "alice.yaml"), accData) - p := player.New("alice") - p.Credits = 90 // post-debit balance - p.Account = "alice" - pData, _ := yaml.Marshal(p) - writeSeedFile(t, filepath.Join(dir, "players", "characters", "alice.yaml"), pData) - - // Open wager record: 10 credits debited (before=100, after=90). - writeEconomyJournal(t, dir, []economyRecord{{ - ID: "lost-wager", Player: "alice", Kind: "wager", - Chips: 0, Credit: 10, Status: "open", - CreditsBefore: 100, CreditsAfter: 90, - ChipsBefore: 0, ChipsAfter: 0, - }}) - - h := harnessFromDir(t, dir) - // Recovery must refund the stake: credits back to 100. - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 100 { - t.Fatalf("credits after recovery = %d, want 100 (refund)", loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Fatalf("recovered record left open: %v", open) - } -} - -func TestEconomyRecoveryAppliesLostPayout(t *testing.T) { - dir := t.TempDir() - seedWorld(t, dir) - - // Character at pre-payout balance; the payout was journaled but the - // save never happened (crash before save). The player is owed it. - hash, _ := player.HashPassword("pw") - accData, _ := yaml.Marshal(&player.Account{Name: "bob", PasswordHash: hash}) - writeSeedFile(t, filepath.Join(dir, "players", "accounts", "bob.yaml"), accData) - p := player.New("bob") - p.Credits = 50 - p.Account = "bob" - pData, _ := yaml.Marshal(p) - writeSeedFile(t, filepath.Join(dir, "players", "characters", "bob.yaml"), pData) - - writeEconomyJournal(t, dir, []economyRecord{{ - ID: "lost-payout", Player: "bob", Kind: "payout", - Chips: 0, Credit: 25, Status: "open", - CreditsBefore: 50, CreditsAfter: 75, - ChipsBefore: 0, ChipsAfter: 0, - }}) - - h := harnessFromDir(t, dir) - loaded, err := h.g.AccountStore.LoadCharacter("bob") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 75 { - t.Fatalf("credits after recovery = %d, want 75 (payout re-applied)", loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Fatalf("recovered record left open: %v", open) - } -} - -func TestShutdownSavesOnlineCharactersAndRefundsWagers(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - h.seedAccount("bob") - h.seedCharacter("alice", "alice") - h.seedCharacter("bob", "bob") - sA, _ := h.connect("alice") - pA := sA.Player - sB, _ := h.connect("bob") - pB := sB.Player - - // bob sits at a machine with a committed stake; shutdown must refund. - h.g.RunOnOwner(func() { - pB.Credits = 100 - h.g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots, MinBet: 1}, "bob") - h.g.Casino.SetMachineBet(1, "slots", "bob", 20, casinoWallet{g: h.g, p: pB}) - }) - h.barrier() - if pB.Credits != 80 { - t.Fatalf("bob's credits after wager = %d, want 80", pB.Credits) - } - - transportClosed := make(chan struct{}) - h.g.SetTransportStop(func() { close(transportClosed) }) - h.g.RequestShutdown("test") - // The shutdown goroutine closes the transports at the end of the - // ordered sequence; block for it. - select { - case <-transportClosed: - case <-time.After(5 * time.Second): - t.Fatal("transport stop was never invoked during shutdown") - } - - // Both characters must be saved with the wager refunded. - loadedA, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loadedA.Credits != pA.Credits { - t.Fatalf("alice save mismatch: file %d, memory %d", loadedA.Credits, pA.Credits) - } - loadedB, err := h.g.AccountStore.LoadCharacter("bob") - if err != nil { - t.Fatal(err) - } - if loadedB.Credits != 100 { - t.Fatalf("bob's file = %d, want 100 (stake refunded at shutdown)", loadedB.Credits) - } -} diff --git a/internal/game/game.go b/internal/game/game.go index c0c6488..5aad784 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -107,9 +107,6 @@ type Game struct { shutdownSaveMu sync.Mutex shutdownSaveErr error - // Economy is the durable money journal; see economy.go. - Economy *economyJournal - // loginThrottle applies an escalating per-(account, IP) delay to failed // interactive password attempts. Lazily created so Game literals built // directly in tests work without it. @@ -166,19 +163,16 @@ func New(dataDir string, colorConfig *config.ColorsConfig, constantColorConfig * // which statically references executeShutdown, cannot form an // initialization cycle with the shutdown coordinator. g.shutdownHook = g.RequestShutdown - g.Economy = newEconomyJournal(dataDir) - // Lifecycle-journal rollback MUST run before economy recovery: a rolled- - // back rename transaction restores an older character before-image, and - // if money compensation ran first its settled/compacted records would no - // longer be recoverable — the rollback would silently erase a payout - // that was already re-applied. Rollback also precedes every store read - // below (courses, mods, techs, craft index) that a leftover transaction - // may reference. See the TestEconomyRecoveryRunsAfterLifecycleRollback - // regression. + // Lifecycle-journal rollback MUST run before pending-wager recovery: a + // rolled-back rename transaction restores an older character + // before-image, and a refund applied to the post-rename file would be + // lost when the rollback replaced it with the before-image. Rollback + // also precedes every store read below (courses, mods, techs, craft + // index) that a leftover transaction may reference. g.recoverJournals() - // Compensate open money records from any prior crash before any - // session can connect. - g.recoverEconomy() + // Refund any stake left in flight by an ungraceful stop before any + // session can connect. The character file is the only money record. + g.recoverPendingWagers() g.CourseStore.SetWorld(g.World) g.CourseStore.LoadAll() g.LoadMods() diff --git a/internal/game/hardening_economy_durability_test.go b/internal/game/hardening_economy_durability_test.go deleted file mode 100644 index 55fff50..0000000 --- a/internal/game/hardening_economy_durability_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package game - -// Persistence/economy durability regressions: the economy journal's directory -// entry must be synced, recovery must order payout evidence by the record's -// creation order (not by its last-written line), the recovery checkpoint -// re-anchor must respect the economy cap, the journal-unavailable settlement -// fallback must only resolve the stakes it settles, and a bank credit move -// whose save failed must be refused instead of leaving memory ahead of the -// durable file. - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "testing" - "time" - - "thehouseoficarus/internal/casino" - "thehouseoficarus/internal/player" - - "gopkg.in/yaml.v3" -) - -// waitForConnText blocks until the session's writer flushes text containing -// substr; the writer goroutine is asynchronous. -func waitForConnText(t *testing.T, conn *chanConn, substr string) { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for !conn.Contains(substr) && time.Now().Before(deadline) { - time.Sleep(time.Millisecond) - } - if !conn.Contains(substr) { - t.Errorf("expected %q in session output; got=%s", substr, conn.Bytes()) - } -} - -// TestRecoveryIgnoresRewrittenOlderPayoutAsStakeEvidence: payout evidence for -// a stake is ordered by the payout's CREATION sequence, not by the line of its -// latest write. A payout created before the stake that is rewritten afterwards -// (a settle marker, or a recovery checkpoint re-anchor) must not be read as -// the stake's settlement — otherwise recovery settles the stake without -// refunding it and the wager is silently lost. -func TestRecoveryIgnoresRewrittenOlderPayoutAsStakeEvidence(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 1000 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - // An older payout record (created first) ... - old, err := h.g.Economy.start("payout", p, 0, 20, 1000, 1020, 0, 0) - if err != nil { - t.Error(err) - return - } - // ... then the stake ... - if _, err := h.g.Economy.start("wager", p, 0, 50, 1000, 950, 0, 0); err != nil { - t.Error(err) - return - } - // ... and finally a later write of the OLD payout lands after the - // stake's line, so line order alone would call it the stake's - // settlement. - if !h.g.Economy.settleDurable(old) { - t.Error("the older payout's settle marker could not be journaled") - return - } - // The stake's debit is durable; the crashed round is recoverable. - p.Credits = 950 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - h.g.recoverEconomy() - }) - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 1000 { - t.Errorf("balance after recovery = %d, want 1000: a payout created before the stake must not settle it (the stake is refunded, not silently kept)", - loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Errorf("records left open after recovery: %+v", open) - } -} - -// TestRecoveryReanchorKeepsCheckpointsWithinCreditCap: when the economy cap -// blocks a payout's re-application, the re-anchored checkpoints must stay -// within the cap. An impossible post-state above the cap can never be read as -// durability, and the record is owed at the current balance: it must keep -// reading that way across repeated recoveries instead of being settled as -// "already durable". Legacy records without the tracked-evidence flag take -// this checkpoint-compensating path. -func TestRecoveryReanchorKeepsCheckpointsWithinCreditCap(t *testing.T) { - dir := t.TempDir() - seedWorld(t, dir) - - hash, _ := player.HashPassword("pw") - accData, _ := yaml.Marshal(&player.Account{Name: "alice", PasswordHash: hash}) - writeSeedFile(t, filepath.Join(dir, "players", "accounts", "alice.yaml"), accData) - p := player.New("alice") - p.Credits = casino.MaxCredits - 30 - p.Account = "alice" - pData, _ := yaml.Marshal(p) - writeSeedFile(t, filepath.Join(dir, "players", "characters", "alice.yaml"), pData) - - // Two identical open payouts of 20 at the cap: the first can be - // re-applied, the second cannot. Each record is its own journal line. - var journal []byte - for i := 0; i < 2; i++ { - line, err := json.Marshal(economyRecord{ - ID: fmt.Sprintf("unpaid-%d", i), Player: "alice", Kind: "payout", - Credit: 20, Status: "open", - CreditsBefore: casino.MaxCredits - 30, CreditsAfter: casino.MaxCredits - 10, - }) - if err != nil { - t.Fatal(err) - } - journal = append(journal, line...) - journal = append(journal, '\n') - } - journalDir := filepath.Join(dir, "economy") - if err := os.MkdirAll(journalDir, 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(journalDir, "journal.jsonl"), journal, 0600); err != nil { - t.Fatal(err) - } - - // The first recovery runs in New. - h := harnessFromDir(t, dir) - for round := 1; round <= 3; round++ { - if round > 1 { - h.g.recoverEconomy() - } - open := h.g.Economy.mustLoadOpen(t) - if len(open) != 1 { - t.Fatalf("recovery %d: the unpaid liability must stay open exactly once, open=%d: %+v", round, len(open), open) - } - for _, rec := range open { - if rec.CreditsBefore > casino.MaxCredits || rec.CreditsAfter > casino.MaxCredits { - t.Fatalf("recovery %d re-anchored %s outside the economy cap: before=%d after=%d cap=%d", - round, rec.ID, rec.CreditsBefore, rec.CreditsAfter, casino.MaxCredits) - } - } - } - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != casino.MaxCredits-10 { - t.Fatalf("balance after repeated recovery = %d, want %d (one 20-credit payout applied, the other still owed)", - loaded.Credits, casino.MaxCredits-10) - } -} - -// TestJournalUnavailableSettlementSettlesOnlyItsOwnStakes: when the economy -// journal is unavailable, the character-file fallback records resolution -// evidence only for the stakes the payout settles. A sibling hand's stake is -// settled by its own payout, so a crash before that payout must still refund -// it. -func TestJournalUnavailableSettlementSettlesOnlyItsOwnStakes(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - w := casinoWallet{g: h.g, p: p} - // Two sibling stakes (a split, or two spots). The sibling is placed - // first so its post-debit balance is the durable one. - if _, err := w.Wager(10); err != nil { - t.Error(err) - return - } - settled, err := w.Wager(10) - if err != nil { - t.Error(err) - return - } - // The journal breaks for the second hand's push: the payout lands in - // the character file only, and may record evidence only for the - // stake it settles. - failJournalDuring(h, func() { - if err := w.PayStakeCover(10, []string{settled.ID}); err != nil { - t.Errorf("fallback settlement failed: %v", err) - return - } - }) - if p.Credits != 90 { - t.Errorf("after the fallback settlement: credits=%d, want 90", p.Credits) - return - } - h.g.recoverEconomy() - }) - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 100 { - t.Errorf("balance after recovery = %d, want 100: the sibling hand's stake must be refunded, not resolved by the other hand's settlement", - loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Errorf("records left open after recovery: %+v", open) - } -} - -// TestBankCreditTransferRefusedWhenSaveFails: a credit move between the wallet -// and the bank that cannot be made durable is reverted and refused. Leaving it -// in memory only would put the balance ahead of the character file, and a -// wager taken from that phantom balance journals checkpoints recovery can -// never match. -func TestBankCreditTransferRefusedWhenSaveFails(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - p.Credits = 100 - p.BankedCredits = 50 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Fatal(err) - } - sess, conn := h.connect("alice") - - h.g.RunOnOwner(func() { - failingSaveDuring(h.g, h.dir, func() { - h.g.doBankDepositCredits(sess, []string{"deposit", "credits", "40"}) - }) - }) - if sess.Player.Credits != 100 || sess.Player.BankedCredits != 50 { - t.Errorf("a deposit whose save failed must be reverted in memory: credits=%d banked=%d, want 100/50", - sess.Player.Credits, sess.Player.BankedCredits) - } - waitForConnText(t, conn, "nothing was moved") - if loaded, err := h.g.AccountStore.LoadCharacter("alice"); err != nil { - t.Error(err) - } else if loaded.Credits != 100 || loaded.BankedCredits != 50 { - t.Errorf("durable balances after a refused deposit: credits=%d banked=%d, want 100/50", - loaded.Credits, loaded.BankedCredits) - } - - h.g.RunOnOwner(func() { - failingSaveDuring(h.g, h.dir, func() { - h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"}) - }) - }) - if sess.Player.Credits != 100 || sess.Player.BankedCredits != 50 { - t.Errorf("a withdrawal whose save failed must be reverted in memory: credits=%d banked=%d, want 100/50", - sess.Player.Credits, sess.Player.BankedCredits) - } - waitForConnText(t, conn, "nothing was moved") - - // A durable move still works. - h.g.RunOnOwner(func() { - h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"}) - }) - if sess.Player.Credits != 110 || sess.Player.BankedCredits != 40 { - t.Errorf("successful withdrawal: credits=%d banked=%d, want 110/40", - sess.Player.Credits, sess.Player.BankedCredits) - } - if loaded, err := h.g.AccountStore.LoadCharacter("alice"); err != nil { - t.Error(err) - } else if loaded.Credits != 110 || loaded.BankedCredits != 40 { - t.Errorf("durable balances after a successful withdrawal: credits=%d banked=%d, want 110/40", - loaded.Credits, loaded.BankedCredits) - } -} diff --git a/internal/game/hardening_fixes_batch2_test.go b/internal/game/hardening_fixes_batch2_test.go index e81dd9c..cdebc4c 100644 --- a/internal/game/hardening_fixes_batch2_test.go +++ b/internal/game/hardening_fixes_batch2_test.go @@ -1,11 +1,9 @@ package game // Regression tests for the second hardening pass: transactional trigger -// effects, ammo preservation on death, safespot clearing on teleport, and -// economy-journal torn-tail repair. +// effects, ammo preservation on death, and safespot clearing on teleport. import ( - "os" "path/filepath" "testing" @@ -165,39 +163,3 @@ func TestTeleportPlayerClearsSafespot(t *testing.T) { t.Fatalf("room after teleport = %d, want 2", p.RoomID) } } - -// TestEconomyJournalRepairsTornTail: a torn final line must be truncated -// before the next append, so the journal does not become permanently -// unreadable (which would disable recovery and compaction). -func TestEconomyJournalRepairsTornTail(t *testing.T) { - dir := t.TempDir() - j := newEconomyJournal(dir) - if err := os.MkdirAll(filepath.Join(dir, "economy"), 0700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(j.path, []byte(`{"id":"torn","kind":"payout","status":"open"`), 0600); err != nil { - t.Fatal(err) - } - - if err := j.append(economyRecord{ID: "good", Player: "alice", Kind: "payout", Status: "open", Credit: 5}); err != nil { - t.Fatalf("append after torn tail: %v", err) - } - open, err := j.loadOpen() - if err != nil { - t.Fatalf("loadOpen after torn-tail repair: %v", err) - } - for _, rec := range open { - if rec.ID == "torn" { - t.Fatal("torn record survived repair") - } - } - found := false - for _, rec := range open { - if rec.ID == "good" { - found = true - } - } - if !found { - t.Fatal("valid record missing after torn-tail repair") - } -} diff --git a/internal/game/journal_damage_regression_test.go b/internal/game/journal_damage_regression_test.go deleted file mode 100644 index d8577fe..0000000 --- a/internal/game/journal_damage_regression_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package game - -// Journal-damage regressions: a malformed record followed by other records -// must abort the fold (the journal is preserved for inspection — records -// beyond the damage may be owed money), an open failure is never "an empty -// healthy journal", and compaction can never run over an untrusted fold. - -import ( - "os" - "testing" -) - -// TestMalformedJournalRecordAbortsFold: a malformed line followed by a valid -// record makes loadFolded fail — skipping the line and continuing would let -// compensation and compaction run over records that may owe money. -func TestMalformedJournalRecordAbortsFold(t *testing.T) { - j := newEconomyJournal(t.TempDir()) - original := []byte("{\"id\":\"damaged\",\"kind\":\"payout\"\n{\"id\":\"valid\",\"kind\":\"payout\",\"status\":\"open\",\"credit\":20}\n") - if err := os.WriteFile(j.path, original, 0600); err != nil { - t.Fatal(err) - } - if _, err := j.loadFolded(); err == nil { - t.Fatal("malformed mid-file record silently accepted") - } - // Compaction must abort and preserve the journal verbatim. - j.compact() - after, err := os.ReadFile(j.path) - if err != nil { - t.Fatal(err) - } - if string(after) != string(original) { - t.Errorf("compaction rewrote a damaged journal: %s", after) - } -} - -// TestTornFinalJournalLineIsTolerated: a torn LAST line (crash mid-append) -// is skipped — every valid record before it still folds. -func TestTornFinalJournalLineIsTolerated(t *testing.T) { - j := newEconomyJournal(t.TempDir()) - data := []byte("{\"id\":\"a\",\"kind\":\"wager\",\"status\":\"open\"}\n{\"id\":\"b\",\"kind\":\"payo") - if err := os.WriteFile(j.path, data, 0600); err != nil { - t.Fatal(err) - } - folded, err := j.loadFolded() - if err != nil { - t.Fatalf("torn final line rejected a healthy fold: %v", err) - } - if len(folded) != 1 || folded[0].rec.ID != "a" { - t.Fatalf("unexpected fold: %+v", folded) - } -} - -// TestJournalOpenErrorIsNotAnEmptyFold: an open failure (permissions, a -// symlink loop) must be an error, never "no journal" — recovery would -// otherwise treat owed money as never-charged. -func TestJournalOpenErrorIsNotAnEmptyFold(t *testing.T) { - j := newEconomyJournal(t.TempDir()) - if err := os.Symlink(j.path, j.path); err != nil { - t.Fatal(err) - } - if _, err := j.loadFolded(); err == nil { - t.Error("ELOOP open failure reported as an empty healthy journal") - } -} - -// TestOpenFailureAbortsRecovery: recoverEconomy over a journal that cannot -// be fully read (malformed record with later records present) must abort -// without paying owed money or compacting. -func TestOpenFailureAbortsRecovery(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - // Corrupt the journal mid-file (a damaged record followed by an - // owed payout — appends after a torn merged line), then attempt - // recovery. - f, err := os.OpenFile(h.g.Economy.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) - if err != nil { - t.Error(err) - return - } - if _, werr := f.WriteString("{\"id\":\"damaged\",\"kind\":\"payout\"\n"); werr != nil { - t.Error(werr) - } - f.Close() - if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil { - t.Errorf("owed payout could not be journaled: %v", err) - return - } - h.g.recoverEconomy() - // The damaged fold aborted recovery: no compensation ran and no - // compaction erased the records. - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - if loaded.Credits != 100 { - t.Errorf("recovery compensated over a damaged journal: credits=%d, want 100 (preserved for inspection)", loaded.Credits) - } - data, err := os.ReadFile(h.g.Economy.path) - if err != nil { - t.Fatal(err) - } - if !containsBytes(data, "kind\":\"payout\"") { - t.Error("recovery compacted a damaged journal, erasing records preserved for inspection") - } - }) -} - -func containsBytes(data []byte, s string) bool { - for i := 0; i+len(s) <= len(data); i++ { - if string(data[i:i+len(s)]) == s { - return true - } - } - return false -} diff --git a/internal/game/journal_failure_regression_test.go b/internal/game/journal_failure_regression_test.go deleted file mode 100644 index 043afeb..0000000 --- a/internal/game/journal_failure_regression_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package game - -// Journal-failure regressions: a broken economy journal (persistent append -// failure) must never lose money silently. Settlements pay through the -// character file, stake resolutions are recorded as file evidence, and an -// unreadable journal aborts recovery/compaction instead of erasing records. - -import ( - "os" - "strings" - "testing" - "time" - - "thehouseoficarus/internal/casino" - "thehouseoficarus/internal/player" -) - -// failJournalDuring redirects the economy journal path for the duration of -// fn, simulating a persistent append failure, and restores it afterwards. -func failJournalDuring(h *harness, fn func()) { - path := h.g.Economy.path - h.g.Economy.path = h.dir - defer func() { h.g.Economy.path = path }() - fn() -} - -// TestSettlementFallbackKeepsWholeBalance: a full table round whose payout -// journal append fails still ends with the player whole — the settlement is -// paid through the character file, the stake resolution is recorded as -// evidence, and recovery leaves the balance exact. -func TestSettlementFallbackKeepsWholeBalance(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 1000 - h.g.Casino.Join(1, casino.TableConfig{ID: "b", Game: casino.GameBaccarat}, "alice") - var totalPaid int - failed := false - w := casinoWallet{g: h.g, p: p} - // Wrap the wallet so exactly one settlement hits a broken journal. - wallet := &fallbackProbeWallet{harness: h, inner: w, onPay: func(credits int, failedCall bool) { - totalPaid += credits - if failedCall { - failed = true - } - }} - rounds := 0 - for rounds < 100 && !failed { - rounds++ - h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet) - } - if !failed { - t.Fatal("probe never observed a settlement payout") - return - } - // One more full round after the failure: the table keeps working. - rounds++ - h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet) - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - want := 1000 - 10*rounds + totalPaid - if loaded.Credits != want { - t.Errorf("balance after journal-failure round: credits=%d, want %d (wagers %d, paid %d)", - loaded.Credits, want, 10*rounds, totalPaid) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Errorf("records left open: %+v", open) - } - }) -} - -// fallbackProbeWallet routes PayCredits through the real wallet once with a -// broken journal (the first settlement), then normally. -type fallbackProbeWallet struct { - harness *harness - inner casinoWallet - onPay func(credits int, failedCall bool) - broke bool -} - -func (w *fallbackProbeWallet) Wager(amount int) (casino.Wager, error) { return w.inner.Wager(amount) } - -func (w *fallbackProbeWallet) PayCredits(amount int) error { - if w.broke { - w.onPay(amount, false) - return w.inner.PayCredits(amount) - } - w.broke = true - w.onPay(amount, true) - failJournalDuring(w.harness, func() { - _ = w.inner.PayCredits(amount) - }) - return nil -} - -func (w *fallbackProbeWallet) ResolveStakes() { w.inner.ResolveStakes() } - -func (w *fallbackProbeWallet) PayStakeCover(amount int, stakeIDs []string) error { - if w.broke { - w.onPay(amount, false) - return w.inner.PayStakeCover(amount, stakeIDs) - } - w.broke = true - w.onPay(amount, true) - failJournalDuring(w.harness, func() { - _ = w.inner.PayStakeCover(amount, stakeIDs) - }) - return nil -} - -func (w *fallbackProbeWallet) ResolveStake(stakeID string) { w.inner.ResolveStake(stakeID) } -func (w *fallbackProbeWallet) RefundOutstanding() (int, int, error) { - return w.inner.RefundOutstanding() -} - -// TestChipStakeSettlementFallbackNoDoublePay: a chip-funded stake whose win -// is paid through the character-file fallback must not ALSO get its chips -// returned in kind by recovery — the settlement returned the stake as -// credits inside the payout. -func TestChipStakeSettlementFallbackNoDoublePay(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10}) - w := casinoWallet{g: h.g, p: p} - if _, err := w.Wager(10); err != nil { - t.Error(err) - return - } - // The journal is broken for BOTH the settlement and the resolution: - // the win lands in the character file, the resolution in evidence. - failJournalDuring(h, func() { - if err := w.PayCredits(20); err != nil { - t.Errorf("fallback settlement failed: %v", err) - return - } - w.ResolveStakes() - }) - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - if loaded.Credits != 120 || countChips(loaded) != 0 { - t.Errorf("after recovery: %d credits / %d chips, want 120 / 0 (chip stake debited, win of 20 paid once in credits; no in-kind chip return on top)", - loaded.Credits, countChips(loaded)) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Errorf("open records: %+v", open) - } - }) -} - -// TestCorruptJournalAbortsRecoveryWithoutErasure: an unreadable journal -// region must abort compensation and compaction — the owed payout record -// beyond the corruption stays in the file for operator inspection instead -// of being compacted away. -func TestCorruptJournalAbortsRecoveryWithoutErasure(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - // Corrupt the journal with an oversized line, then journal an owed - // payout BEYOND it (as appends would after the corruption). - f, err := os.OpenFile(h.g.Economy.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) - if err != nil { - t.Error(err) - return - } - _, werr := f.WriteString(strings.Repeat("x", 1024*1024+1) + "\n") - f.Close() - if werr != nil { - t.Error(werr) - return - } - if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil { - t.Errorf("payout could not be journaled: %v", err) - return - } - h.g.recoverEconomy() - // The owed payout record is preserved verbatim: no compensation ran - // (credits unchanged) and no compaction erased the record. - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - data, err := os.ReadFile(h.g.Economy.path) - if err != nil { - t.Fatal(err) - } - if loaded.Credits != 100 { - t.Errorf("aborted recovery paid the payout: credits=%d, want 100 (preserved for inspection)", loaded.Credits) - } - if !strings.Contains(string(data), `"kind":"payout"`) { - t.Error("corrupt journal recovery erased the owed payout record instead of preserving it") - } - }) -} - -// TestBankWithdrawalHonorsCreditCap: a withdrawal that would push the -// on-hand balance past the shared economy cap is rejected; neither balance -// changes. -func TestBankWithdrawalHonorsCreditCap(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - p.Credits = casino.MaxCredits - p.BankedCredits = 10 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Fatal(err) - } - sess, conn := h.connect("alice") - h.g.RunOnOwner(func() { - h.g.doBankWithdrawCredits(sess, []string{"withdraw", "credits", "10"}) - }) - if sess.Player.Credits != casino.MaxCredits || sess.Player.BankedCredits != 10 { - t.Errorf("rejected withdrawal changed balances: credits=%d banked=%d", - sess.Player.Credits, sess.Player.BankedCredits) - } - // The session writer goroutine flushes asynchronously; wait for the - // rejection to arrive instead of racing it. - deadline := time.Now().Add(2 * time.Second) - for !conn.Contains("cannot hold") && time.Now().Before(deadline) { - time.Sleep(time.Millisecond) - } - if !conn.Contains("cannot hold") { - t.Errorf("withdrawal rejection not reported; output=%s", conn.Bytes()) - } -} diff --git a/internal/game/money.go b/internal/game/money.go new file mode 100644 index 0000000..35355ca --- /dev/null +++ b/internal/game/money.go @@ -0,0 +1,182 @@ +package game + +// Single-file money: the character file is the ONLY durable money record. +// Every casino money mutation is one atomic SaveCharacter that carries both +// the balance and the Player.PendingWagers ledger. There is no separate +// economy journal and no two-phase recovery: startup simply refunds any stake +// still listed as pending, because a listed stake means the process died +// before the round resolved. Two-phase journal recovery was deliberately +// removed as out of scope for a game this size; see +// building_guide/concurrency.md ("Durable money") before reintroducing it. + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/player" +) + +// newPendingWagerID returns an opaque wallet handle for a committed stake. A +// game keeps it to name the stake when its settlement covers it (a table +// hand) or consumes it (a loss). +func newPendingWagerID() string { + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("pending wager id: %v", err)) + } + return hex.EncodeToString(b[:]) +} + +// removePendingWager drops the stake with id from the player's ledger and +// reports whether it was present. +func removePendingWager(p *player.Player, id string) bool { + if id == "" { + return false + } + kept := p.PendingWagers[:0] + removed := false + for _, pending := range p.PendingWagers { + if pending.ID == id { + removed = true + continue + } + kept = append(kept, pending) + } + p.PendingWagers = kept + return removed +} + +// takePendingWagers removes and returns the pending stakes whose IDs are +// listed, leaving the rest. It is used by a settlement that returns a +// specific set of stakes (a table hand). +func takePendingWagers(p *player.Player, ids []string) []player.PendingWager { + want := make(map[string]bool, len(ids)) + for _, id := range ids { + want[id] = true + } + var removed []player.PendingWager + kept := p.PendingWagers[:0] + for _, pending := range p.PendingWagers { + if want[pending.ID] { + removed = append(removed, pending) + continue + } + kept = append(kept, pending) + } + p.PendingWagers = kept + return removed +} + +// giveChips re-credits chips to a player's inventory (used by refund paths). +// It stacks onto an existing chips slot when one exists. It reports false +// when the chips do NOT fit (no existing stack and no free slot): the caller +// must then treat the chips as undelivered — they are never silently dropped. +func (g *Game) giveChips(p *player.Player, qty int) bool { + for _, slot := range p.Inventory { + if slot != nil && slot.ItemID == "chips" { + slot.Quantity += qty + return true + } + } + slot := p.FirstFreeSlot() + if slot < 0 { + return false + } + p.SetInvSlot(slot, &player.InventorySlot{ItemID: "chips", Quantity: qty}) + return true +} + +// grantCredits adds credits from ordinary gameplay (shop sales, credit +// pickups, activity payouts). The economy cap is enforced by clamping: the +// cap is far above any reachable balance, so hitting it means a data bug — +// clamp (never wrap) and log loudly for the operator. +func (g *Game) grantCredits(p *player.Player, amount int) { + if amount <= 0 { + return + } + sum, err := addCreditsChecked(p.Credits, amount) + if err != nil { + fmt.Fprintf(os.Stderr, "credit grant for %s clamped at the economy cap: %v\n", p.Name, err) + p.Credits = casino.MaxCredits + return + } + p.Credits = sum +} + +// addCreditsChecked is the game-side checked addition against the shared +// economy cap (casino.MaxCredits, mirrored by the casino wallet: a second, +// independently drifting cap would let the two sides of the money path +// disagree about what a balance may hold). +func addCreditsChecked(a, b int) (int, error) { + if b > 0 && a > casino.MaxCredits-b { + return casino.MaxCredits, fmt.Errorf("credit overflow: %d + %d exceeds %d", a, b, casino.MaxCredits) + } + if b < 0 { + return 0, fmt.Errorf("negative credit add %d", b) + } + return a + b, nil +} + +// recoverPendingWagers refunds every stake left in flight by an ungraceful +// stop. The character file is the only durable money record, so a pending +// wager on disk means the process died before the round resolved: the player +// is made whole by returning the stake (chips in kind, credits as credits). +// Rounds that resolved are already gone from PendingWagers, removed by the +// same save that settled them. +// +// This is intentionally the entire recovery story. Two-phase journal recovery +// is out of scope for this game (see building_guide/concurrency.md). +func (g *Game) recoverPendingWagers() { + names, err := g.AccountStore.ListCharacterNames() + if err != nil { + fmt.Fprintf(os.Stderr, "pending wager recovery: list characters: %v\n", err) + return + } + for _, name := range names { + p, err := g.AccountStore.LoadCharacter(name) + if err != nil { + fmt.Fprintf(os.Stderr, "pending wager recovery: load %s: %v\n", name, err) + continue + } + if len(p.PendingWagers) == 0 { + continue + } + before := append([]player.PendingWager(nil), p.PendingWagers...) + credits, chips := 0, 0 + for _, pending := range before { + credits += pending.Credits + chips += pending.Chips + } + creditsBefore := p.Credits + p.PendingWagers = nil + if chips > 0 && !g.giveChips(p, chips) { + // No inventory room to return the chip stakes: leave the ledger + // intact so a later startup (once the player has room) retries. + p.PendingWagers = before + fmt.Fprintf(os.Stderr, "pending wager recovery: %s: no inventory room for %d chips — stake retained for a later attempt\n", name, chips) + continue + } + sum, capErr := addCreditsChecked(creditsBefore, credits) + if capErr != nil { + if chips > 0 { + p.RemoveItem("chips", chips) + } + p.PendingWagers = before + fmt.Fprintf(os.Stderr, "pending wager recovery: %s: refund blocked at the credit cap: %v — stake retained for a later attempt\n", name, capErr) + continue + } + p.Credits = sum + if err := g.AccountStore.SaveCharacter(p); err != nil { + p.Credits = creditsBefore + if chips > 0 { + p.RemoveItem("chips", chips) + } + p.PendingWagers = before + fmt.Fprintf(os.Stderr, "pending wager recovery: refund save %s: %v\n", name, err) + continue + } + } +} diff --git a/internal/game/payout_evidence_regression_test.go b/internal/game/payout_evidence_regression_test.go deleted file mode 100644 index b58bf9d..0000000 --- a/internal/game/payout_evidence_regression_test.go +++ /dev/null @@ -1,76 +0,0 @@ -package game - -// Regression: when the economy journal is unavailable, a payout paid -// through the character file must record the round's stake-resolution -// evidence in that same file. Otherwise a crash before the games' own -// ResolveStakes call leaves the wager records open with checkpoint -// balances that no longer match, and recovery refunds chip stakes the -// paid settlement had already consumed — duplicating the round's value. - -import ( - "testing" - - "thehouseoficarus/internal/player" -) - -func TestUnjournalablePayoutCrashDoesNotRefundChips(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10}) - w := casinoWallet{g: h.g, p: p} - if _, err := w.Wager(10); err != nil { - t.Error(err) - return - } - path := h.g.Economy.path - h.g.Economy.path = h.dir // persistent append failure - if err := w.PayCredits(20); err != nil { - t.Errorf("fallback payout failed: %v", err) - return - } - h.g.Economy.path = path - // Process loss BEFORE the game's own ResolveStakes call: the - // fallback payout itself must be the durable resolution. - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - if loaded.Credits != 120 || countChips(loaded) != 0 { - t.Errorf("recovered credits=%d chips=%d; want 120 credits and no chips (the paid settlement consumed the stake)", loaded.Credits, countChips(loaded)) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Errorf("records left open after recovery: %+v", open) - } - }) -} - -// The stakes-open count is a proxy for "recovery did not silently refund": -// with the fix the wager record settles through the file-side evidence. -func TestUnjournalablePayoutEvidenceRecorded(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - w := casinoWallet{g: h.g, p: p} - if _, err := w.Wager(10); err != nil { - t.Error(err) - return - } - path := h.g.Economy.path - h.g.Economy.path = h.dir - if err := w.PayCredits(20); err != nil { - t.Errorf("fallback payout failed: %v", err) - return - } - h.g.Economy.path = path - if len(p.ResolvedStakes) == 0 { - t.Error("fallback payout recorded no stake-resolution evidence; a crash before ResolveStakes would double-refund chips") - } - }) -} diff --git a/internal/game/quit_casino_test.go b/internal/game/quit_casino_test.go index f373457..02dadb0 100644 --- a/internal/game/quit_casino_test.go +++ b/internal/game/quit_casino_test.go @@ -15,7 +15,7 @@ import ( // quitTestGame builds the minimal live state quit and stop need: a casino // manager, a combat tracker, a tick engine for the rest countdown, and a -// durable wallet (economy journal + account store) so wagers can commit. +// wallet backed by the account store so wagers can commit. func quitTestGame(t *testing.T) (*Game, *net.Session) { t.Helper() dataDir := t.TempDir() @@ -28,7 +28,6 @@ func quitTestGame(t *testing.T) (*Game, *net.Session) { Deps: Deps{AccountStore: player.NewAccountStore(dataDir), Ticks: engine.New()}, Casino: casino.NewManager(1), Combat: combat.NewTracker(), - Economy: newEconomyJournal(t.TempDir()), safespot: NewSafespotManager(), loggedInChars: map[string]*net.Session{"alice": sess}, restTimers: make(map[string]uint64), diff --git a/internal/game/readiness_regression_test.go b/internal/game/readiness_regression_test.go index 41170b7..442bb1f 100644 --- a/internal/game/readiness_regression_test.go +++ b/internal/game/readiness_regression_test.go @@ -1,18 +1,15 @@ package game -// Readiness regressions for the account-rename, disconnect, and durable-money -// paths reviewed before world/casino expansion: a transport disconnect must -// be race-free, renames must not break ownership or strand owed payouts, -// reserved-loot pickups must never destroy items, and recovery failure paths -// must retry or surface instead of silently dropping money. +// Readiness regressions for the account-rename and disconnect paths reviewed +// before world/casino expansion: a transport disconnect must be race-free, +// renames must not break ownership, and reserved-loot pickups must never +// destroy items. import ( - "os" "path/filepath" "testing" "thehouseoficarus/internal/net" - "thehouseoficarus/internal/player" ) // TestDisconnectRaceIsFreeAndNeverLost drives DisconnectTick (owner) while a @@ -72,40 +69,6 @@ func TestReservedPickupDoesNotDestroyItems(t *testing.T) { }) } -// TestOwedPayoutSurvivesCharacterRename: economy records resolve their -// subject by the character's STABLE ID, so renaming the character between -// the crash and recovery still delivers the owed payout. -func TestOwedPayoutSurvivesCharacterRename(t *testing.T) { - h := newHarness(t) - h.seedAccount("acct") - p := h.seedCharacter("alice", "acct") - h.g.RunOnOwner(func() { - p.ID = "stable-char-id" // production characters carry a stable ID - p.Credits = 90 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { - t.Error(err) - return - } - if err := h.g.renameCharacterTxn("acct", "alice", "alicia"); err != nil { - t.Error(err) - return - } - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alicia") - if err != nil { - t.Error(err) - return - } - if loaded.Credits != 110 { - t.Errorf("renamed character never receives owed payout: credits=%d, want 110", loaded.Credits) - } - }) -} - // TestAccountRenamePreservesAccessAndOwnership: renaming an account // rewrites the owned character files' owner field in the same transaction // and republishes stale sessions (still carrying the old cached name) so @@ -127,174 +90,3 @@ func TestAccountRenamePreservesAccessAndOwnership(t *testing.T) { } }) } - -// TestSettledSurrenderDoesNotRefundChipStake: a settlement payout — even a -// partial one like a surrendered hand paying half a chip stake — proves the -// round resolved, so recovery must not ALSO return the chip stake in kind. -func TestSettledSurrenderDoesNotRefundChipStake(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 10}) - w := casinoWallet{g: h.g, p: p} - if _, err := w.Wager(10); err != nil { - t.Error(err) - return - } - // Surrender settles for half the stake; the crash lands before the - // resolution marker. - if err := w.PayCredits(5); err != nil { - t.Error(err) - return - } - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - if loaded.Credits != 105 || countChips(loaded) != 0 { - t.Errorf("settled surrender recovered %d credits / %d chips; want 105 / 0", loaded.Credits, countChips(loaded)) - } - }) -} - -// TestRecoverySaveFailureRetriesOnNextStartup: a TRANSIENT save failure -// during recovery must not re-anchor the owed record to unreadable -// checkpoints — the record keeps its original checkpoints and a later -// recovery (after the disk is fixed) re-applies the owed payout. -func TestRecoverySaveFailureRetriesOnNextStartup(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - charDir := filepath.Join(h.dir, "players", "characters") - t.Cleanup(func() { os.Chmod(charDir, 0755) }) - h.g.RunOnOwner(func() { - p.Credits = 90 - if err := h.g.AccountStore.SaveCharacter(p); err != nil { - t.Error(err) - return - } - if _, err := h.g.Economy.start("payout", p, 0, 20, 90, 110, 0, 0); err != nil { - t.Error(err) - return - } - if err := os.Chmod(charDir, 0500); err != nil { - t.Error(err) - return - } - h.g.recoverEconomy() // every save fails - if err := os.Chmod(charDir, 0755); err != nil { - t.Error(err) - return - } - h.g.recoverEconomy() // retry with a healthy disk - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - if loaded.Credits != 110 { - t.Errorf("after fixing the disk and retrying: %d credits, want 110; open=%+v", loaded.Credits, h.g.Economy.mustLoadOpen(t)) - } - }) -} - -// TestResolvedStakeEvidenceSurvivesJournalFailure: a resolution marker -// that cannot be journaled (persistent append failure) leaves the stake -// OPEN — never silently forgotten — and records the resolution in the -// character file as durable evidence. Recovery then settles the stake -// WITHOUT refunding it: the round genuinely resolved, and the file is the -// proof. When the file save also fails, no evidence exists and recovery -// applies the documented refund-on-lost-state policy instead. -func TestResolvedStakeEvidenceSurvivesJournalFailure(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - w := casinoWallet{g: h.g, p: p} - if _, err := w.Wager(10); err != nil { - t.Error(err) - return - } - path := h.g.Economy.path - h.g.Economy.path = h.dir // persistent append failure - w.ResolveStakes() - h.g.Economy.path = path - if open := h.g.Economy.openStakes(economyKey(p)); len(open) != 1 { - t.Errorf("unresolvable stake was silently dropped: open=%d", len(open)) - } - // The resolution is recorded durably in the character file. - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - if len(loaded.ResolvedStakes) != 1 { - t.Errorf("resolved-stake evidence not recorded: %+v", loaded.ResolvedStakes) - return - } - h.g.recoverEconomy() - loaded, err = h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - if loaded.Credits != 90 { - t.Errorf("after recovery: credits=%d, want 90 (round resolved; evidence prevents a refund)", loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Errorf("records left open after recovery: %+v", open) - } - if len(loaded.ResolvedStakes) != 0 { - t.Errorf("stale resolved-stake evidence not pruned: %+v", loaded.ResolvedStakes) - } - }) -} - -// TestUnjournalablePayoutPaidThroughCharacterFile: a payout that cannot be -// journaled (persistent append failure) is still made durable through the -// character file — the save is the commit point, so the win is real and -// announced. The resolved stake is recorded as file evidence so recovery -// neither refunds the stake (double pay) nor drops the round. -func TestUnjournalablePayoutPaidThroughCharacterFile(t *testing.T) { - h := newHarness(t) - h.seedAccount("alice") - p := h.seedCharacter("alice", "alice") - h.g.RunOnOwner(func() { - p.Credits = 100 - w := casinoWallet{g: h.g, p: p} - if _, err := w.Wager(10); err != nil { - t.Error(err) - return - } - path := h.g.Economy.path - h.g.Economy.path = h.dir - err := w.PayCredits(20) - h.g.Economy.path = path - if err != nil { - t.Errorf("character-file fallback failed: %v", err) - return - } - if p.Credits != 110 { - t.Errorf("fallback payout did not land: credits=%d, want 110", p.Credits) - } - // As the games do after a successful settlement: resolve the stake. - w.ResolveStakes() - h.g.recoverEconomy() - loaded, err := h.g.AccountStore.LoadCharacter("alice") - if err != nil { - t.Error(err) - return - } - if loaded.Credits != 110 { - t.Errorf("after recovery: credits=%d, want 110 (win durable once; no stake refund on top)", loaded.Credits) - } - if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 { - t.Errorf("open records: %+v", open) - } - }) -} diff --git a/internal/game/wallet_test.go b/internal/game/wallet_test.go new file mode 100644 index 0000000..218ce36 --- /dev/null +++ b/internal/game/wallet_test.go @@ -0,0 +1,240 @@ +package game + +// Single-file money wallet regressions: a wager debits and records a pending +// stake in one atomic character save, a settlement credits and clears the +// stakes it returns in one save, a failed save reverts, and startup refunds +// anything left in flight. See internal/game/money.go for the design and +// building_guide/concurrency.md for why two-phase recovery is out of scope. + +import ( + "os" + "path/filepath" + "testing" + "time" + + "thehouseoficarus/internal/casino" + "thehouseoficarus/internal/config" + "thehouseoficarus/internal/net" + "thehouseoficarus/internal/player" + + "gopkg.in/yaml.v3" +) + +// harnessFromDir builds a Game over an existing directory (used when tests +// must pre-seed character files before startup). +func harnessFromDir(t *testing.T, dir string) *harness { + t.Helper() + colors := &config.ColorsConfig{} + g := New(dir, colors, colors, config.ValidationConfig{}, 1) + hub := net.NewHub() + g.SetHub(hub) + stopTick := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + tick := time.NewTicker(2 * time.Millisecond) + defer tick.Stop() + for { + select { + case <-tick.C: + g.Ticks.Tick() + case <-stopTick: + return + } + } + }() + h := &harness{ + t: t, dir: dir, g: g, hub: hub, + sessions: make(map[string]*net.Session), + conns: make(map[string]*chanConn), + } + h.stop = func() { close(stopTick); <-done } + t.Cleanup(h.stop) + return h +} + +func writeSeedFile(t *testing.T, path string, data []byte) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } +} + +func TestWagerDebitsDurablyAndLeavesPendingStake(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + }) + w := casinoWallet{g: h.g, p: p} + + var wager casino.Wager + var wagerErr error + h.g.RunOnOwner(func() { + wager, wagerErr = w.Wager(10) + }) + h.barrier() + if wagerErr != nil { + t.Fatalf("wager failed: %v", wagerErr) + } + if p.Credits != 90 { + t.Fatalf("credits after wager = %d, want 90", p.Credits) + } + if len(p.PendingWagers) != 1 || p.PendingWagers[0].Credits != 10 || p.PendingWagers[0].ID != wager.ID { + t.Fatalf("pending stake after wager = %+v, want one 10-credit stake %q", p.PendingWagers, wager.ID) + } + // The debit and the pending entry are already durable in the file. + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 90 || len(loaded.PendingWagers) != 1 { + t.Fatalf("durable state after wager = credits %d, pending %+v; want 90 and one stake", loaded.Credits, loaded.PendingWagers) + } + + // A settlement that covers the stake credits and clears it in one save. + h.g.RunOnOwner(func() { + if err := w.PayStakeCover(10, []string{wager.ID}); err != nil { + t.Error(err) + } + }) + h.barrier() + if p.Credits != 100 || len(p.PendingWagers) != 0 { + t.Fatalf("after covered settlement = credits %d, pending %+v; want 100 and none", p.Credits, p.PendingWagers) + } +} + +func TestFailedWagerSaveReverts(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + p := h.seedCharacter("alice", "alice") + h.g.RunOnOwner(func() { + p.Credits = 100 + }) + + // Make character saves fail: read-only characters directory. + charDir := filepath.Join(h.dir, "players", "characters") + if err := os.Chmod(charDir, 0500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chmod(charDir, 0700) }) + + var wagerErr error + h.g.RunOnOwner(func() { + _, wagerErr = casinoWallet{g: h.g, p: p}.Wager(10) + }) + h.barrier() + if wagerErr != casino.ErrWalletPersistence { + t.Fatalf("wager error = %v, want ErrWalletPersistence", wagerErr) + } + // The in-memory mutation must have been reverted: nothing was charged. + if p.Credits != 100 || len(p.PendingWagers) != 0 { + t.Fatalf("after failed wager = credits %d, pending %+v; want 100 and none (reverted)", p.Credits, p.PendingWagers) + } + os.Chmod(charDir, 0700) +} + +// seedWageringCharacter writes a character whose stake is durable and still +// pending, simulating a process death with a round in flight. +func seedWageringCharacter(t *testing.T, dir, name string, credits int, pending []player.PendingWager) { + t.Helper() + hash, _ := player.HashPassword("pw") + accData, _ := yaml.Marshal(&player.Account{Name: name, PasswordHash: hash}) + writeSeedFile(t, filepath.Join(dir, "players", "accounts", name+".yaml"), accData) + p := player.New(name) + p.Credits = credits + p.Account = name + p.PendingWagers = pending + pData, _ := yaml.Marshal(p) + writeSeedFile(t, filepath.Join(dir, "players", "characters", name+".yaml"), pData) +} + +func TestRecoveryRefundsPendingWagers(t *testing.T) { + dir := t.TempDir() + seedWorld(t, dir) + seedWageringCharacter(t, dir, "alice", 90, []player.PendingWager{{ID: "lost-wager", Credits: 10}}) + + h := harnessFromDir(t, dir) + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loaded.Credits != 100 { + t.Fatalf("credits after recovery = %d, want 100 (stake refunded)", loaded.Credits) + } + if len(loaded.PendingWagers) != 0 { + t.Fatalf("pending wagers after recovery = %+v, want none", loaded.PendingWagers) + } +} + +func TestRecoveryRefundsChipWagersInKind(t *testing.T) { + dir := t.TempDir() + seedWorld(t, dir) + seedWageringCharacter(t, dir, "alice", 100, []player.PendingWager{{ID: "chips-only", Chips: 10}}) + + h := harnessFromDir(t, dir) + loaded, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if got := countChips(loaded); got != 10 { + t.Fatalf("chips after recovery = %d, want 10 (stake refunded in kind)", got) + } + if len(loaded.PendingWagers) != 0 { + t.Fatalf("pending wagers after recovery = %+v, want none", loaded.PendingWagers) + } +} + +func TestShutdownSavesOnlineCharactersAndRefundsWagers(t *testing.T) { + h := newHarness(t) + h.seedAccount("alice") + h.seedAccount("bob") + h.seedCharacter("alice", "alice") + h.seedCharacter("bob", "bob") + sA, _ := h.connect("alice") + pA := sA.Player + sB, _ := h.connect("bob") + pB := sB.Player + + // bob sits at a machine with a committed stake; shutdown must refund. + h.g.RunOnOwner(func() { + pB.Credits = 100 + h.g.Casino.JoinMachine(1, casino.MachineConfig{ID: "slots", Game: casino.GameSlots, MinBet: 1}, "bob") + h.g.Casino.SetMachineBet(1, "slots", "bob", 20, casinoWallet{g: h.g, p: pB}) + }) + h.barrier() + if pB.Credits != 80 { + t.Fatalf("bob's credits after wager = %d, want 80", pB.Credits) + } + + transportClosed := make(chan struct{}) + h.g.SetTransportStop(func() { close(transportClosed) }) + h.g.RequestShutdown("test") + // The shutdown goroutine closes the transports at the end of the + // ordered sequence; block for it. + select { + case <-transportClosed: + case <-time.After(5 * time.Second): + t.Fatal("transport stop was never invoked during shutdown") + } + + // Both characters must be saved with the wager refunded. + loadedA, err := h.g.AccountStore.LoadCharacter("alice") + if err != nil { + t.Fatal(err) + } + if loadedA.Credits != pA.Credits { + t.Fatalf("alice save mismatch: file %d, memory %d", loadedA.Credits, pA.Credits) + } + loadedB, err := h.g.AccountStore.LoadCharacter("bob") + if err != nil { + t.Fatal(err) + } + if loadedB.Credits != 100 { + t.Fatalf("bob's file = %d, want 100 (stake refunded at shutdown)", loadedB.Credits) + } +} diff --git a/internal/player/player.go b/internal/player/player.go index efa906b..22ef04a 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -186,20 +186,13 @@ type Player struct { HP int `yaml:"hp"` Credits int `yaml:"credits"` BankedCredits int `yaml:"banked_credits"` - // OpenPayouts is durable evidence for the economy journal (see - // internal/game/economy.go): the IDs of journaled payout records whose - // amounts are ALREADY INCLUDED in this file's credit balance. Recovery - // uses membership to prove a payout became durable instead of guessing - // from balance checkpoints, which unrelated credit changes can fake. - OpenPayouts []string `yaml:"open_payouts,omitempty"` - // ResolvedStakes is durable evidence for the economy journal (see - // internal/game/economy.go): the IDs of outstanding wager records the - // session RESOLVED (won, lost, pushed, refunded) but whose settled - // markers could not be appended to the journal (e.g. a broken economy - // path). Recovery treats membership as proof the round resolved — the - // stake must not be refunded and its chip part must not return in kind - // on top of a settlement that already returned it as credits. - ResolvedStakes []string `yaml:"resolved_stakes,omitempty"` + // PendingWagers is the durable ledger of committed casino stakes whose + // round is still in flight. The character file is the only durable money + // record: the same atomic SaveCharacter that debits a stake appends it + // here, and the same save that settles or refunds the round removes it. + // If the process dies with an entry still listed, startup returns the + // stake (refund-on-lost-round). See PendingWager. + PendingWagers []PendingWager `yaml:"pending_wagers,omitempty"` Description string `yaml:"description"` AttackStyle AttackStyle `yaml:"attack_style"` Prompt string `yaml:"prompt"` @@ -249,6 +242,16 @@ type MapSymbolData struct { Color string `yaml:"color,omitempty"` } +// PendingWager is one committed casino stake whose round is still in flight. +// It is the entire durable representation of that obligation; there is no +// separate economy journal. Chips and Credits are exactly the amounts debited +// by the wager, so a startup refund returns them in kind (chips as chips). +type PendingWager struct { + ID string `yaml:"id"` + Chips int `yaml:"chips,omitempty"` + Credits int `yaml:"credits,omitempty"` +} + // PendingSequence is the persisted snapshot of an in-flight LOCKED trigger // sequence (Trigger.Lock == true). Unlocked sequences are never persisted. // On reconnect, the game rebuilds a sequence from this snapshot and resumes -- cgit v1.2.3