aboutsummaryrefslogtreecommitdiff
path: root/internal/game/economy_recovery_regression_test.go
diff options
context:
space:
mode:
authorhistoria <[not public]>2026-09-15 19:55:21 -0400
committerhistoria <[not public]>2026-09-15 19:55:21 -0400
commit6f3c43aca62a7d33a19f14f6ad7c16891952673a (patch)
tree0b0665ef624ef442df87ec7131129aaecc3420bd /internal/game/economy_recovery_regression_test.go
parentcd95de60364aba8f5bdf8ec86f7234a9903eacc8 (diff)
downloadthehouseoficarus-6f3c43aca62a7d33a19f14f6ad7c16891952673a.tar.gz
fix: outstanding-stake ledger, owner backpressure, editor state preservation
Diffstat (limited to 'internal/game/economy_recovery_regression_test.go')
-rw-r--r--internal/game/economy_recovery_regression_test.go169
1 files changed, 169 insertions, 0 deletions
diff --git a/internal/game/economy_recovery_regression_test.go b/internal/game/economy_recovery_regression_test.go
new file mode 100644
index 0000000..e085e67
--- /dev/null
+++ b/internal/game/economy_recovery_regression_test.go
@@ -0,0 +1,169 @@
+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", "pw", false)
+ 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", "pw", false)
+ 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", "pw", false)
+ 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.loadOpen(); 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", "pw", false)
+ 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)
+ }
+}
+
+// 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", "pw", false)
+ 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)
+ }
+ if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 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 != 95 {
+ t.Fatalf("ambiguous recovery mutated the balance: %d, want 95", loaded.Credits)
+ }
+ if open := h.g.Economy.loadOpen(); len(open) != 1 {
+ t.Fatalf("ambiguous payout must stay open for operator inspection: %+v", open)
+ }
+}