aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy_recovery_regression_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/economy_recovery_regression_test.go')
-rw-r--r--internal/game/economy_recovery_regression_test.go546
1 files changed, 0 insertions, 546 deletions
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)
- }
- })
-}