package game // 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" "testing" "thehouseoficarus/internal/casino" "thehouseoficarus/internal/player" ) // failingSaveDuring swaps the account store for one that cannot save, // simulating a character-save failure, and restores it afterwards. func failingSaveDuring(g *Game, dir string, fn func()) { realStore := g.AccountStore g.AccountStore = player.NewAccountStore(filepath.Join(dir, "unwritable")) defer func() { g.AccountStore = realStore }() fn() } // 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") h.g.RunOnOwner(func() { p.Credits = 100 w := casinoWallet{g: h.g, p: p} wager, err := w.Wager(10) if err != nil { t.Error(err) return } failingSaveDuring(h.g, h.dir, func() { if err := w.PayCredits(20, wager.ID); err == nil { t.Error("expected the settlement save to fail") } }) 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) } } // 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} wager, err := w.Wager(10) if err != nil { t.Error(err) return } if err := w.PayCredits(20, 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 settlement, the refund path // returns an outstanding stake (the refund-on-shutdown policy). func TestUncoveredStakeStillRefunds(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 } chips, credits, err := w.RefundOutstanding() if err != nil || chips != 0 || credits != 10 { 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) } }) } // 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") 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") w := casinoWallet{g: h.g, p: p} // 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()) }) 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) } }