aboutsummaryrefslogtreecommitdiff
path: root/internal/game/casino_durability_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game/casino_durability_test.go')
-rw-r--r--internal/game/casino_durability_test.go135
1 files changed, 135 insertions, 0 deletions
diff --git a/internal/game/casino_durability_test.go b/internal/game/casino_durability_test.go
new file mode 100644
index 0000000..227d391
--- /dev/null
+++ b/internal/game/casino_durability_test.go
@@ -0,0 +1,135 @@
+package game
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/persistence"
+ "thehouseoficarus/internal/player"
+)
+
+// Exercise every wallet mutation with both pre-replacement and post-replacement
+// failures. The latter writes the actual character file, then reports an
+// uncertain result, matching a failed directory fsync or post-save chmod.
+func TestCasinoWalletSaveFailureConsistency(t *testing.T) {
+ type balance struct {
+ credits, chips int
+ pending map[string]player.PendingWager
+ }
+ state := func(p *player.Player) balance {
+ b := balance{credits: p.Credits, chips: countChips(p), pending: make(map[string]player.PendingWager)}
+ for _, stake := range p.PendingWagers {
+ b.pending[stake.ID] = stake
+ }
+ return b
+ }
+ tests := []struct {
+ name string
+ run func(casinoWallet) error
+ reportsError bool
+ credits, chips, stakes int
+ }{
+ {"wager", func(w casinoWallet) error {
+ _, err := w.Wager(4)
+ return err
+ }, true, 68, 0, 4},
+ {"payout", func(w casinoWallet) error { return w.PayCredits(40, "a", "b") }, true, 110, 2, 1},
+ {"unlinked payout", func(w casinoWallet) error { return w.PayCredits(20) }, true, 90, 2, 3},
+ {"resolve all", func(w casinoWallet) error { w.ResolveStakes(); return nil }, false, 70, 2, 0},
+ {"resolve one", func(w casinoWallet) error { w.ResolveStake("a"); return nil }, false, 70, 2, 2},
+ {"resolve batch", func(w casinoWallet) error { w.ResolveStakesByIDs("a", "b"); return nil }, false, 70, 2, 1},
+ {"refund", func(w casinoWallet) error {
+ chips, credits, err := w.RefundOutstanding()
+ if err == nil && (chips != 5 || credits != 30) {
+ return fmt.Errorf("refund = %d chips, %d credits; want 5, 30", chips, credits)
+ }
+ return err
+ }, true, 100, 7, 0},
+ }
+ for _, tt := range tests {
+ for _, replaced := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/replaced=%t", tt.name, replaced), func(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "players", "characters"), 0700); err != nil {
+ t.Fatal(err)
+ }
+ store := player.NewAccountStore(dir)
+ g := &Game{Deps: Deps{AccountStore: store}}
+ p := player.New("alice")
+ p.Credits = 70
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "chips", Quantity: 2})
+ p.PendingWagers = []player.PendingWager{
+ {ID: "a", Credits: 10, Chips: 5},
+ {ID: "b", Credits: 10},
+ {ID: "c", Credits: 10},
+ }
+ if err := store.SaveCharacter(p); err != nil {
+ t.Fatal(err)
+ }
+ before := state(p)
+ calls := 0
+ w := casinoWallet{g: g, p: p, saveCharacter: func(p *player.Player) error {
+ calls++
+ if !replaced {
+ return errors.New("injected failure before replacement")
+ }
+ if err := store.SaveCharacter(p); err != nil {
+ t.Fatal(err)
+ }
+ return fmt.Errorf("injected directory sync failure: %w", persistence.ErrDurabilityUncertain)
+ }}
+ err := tt.run(w)
+ if calls != 1 {
+ t.Fatalf("save calls = %d, want 1", calls)
+ }
+ if !replaced && tt.reportsError {
+ if !errors.Is(err, casino.ErrWalletPersistence) {
+ t.Fatalf("error = %v, want ErrWalletPersistence", err)
+ }
+ } else if err != nil {
+ t.Fatal(err)
+ }
+ if replaced {
+ if p.Credits != tt.credits || countChips(p) != tt.chips || len(p.PendingWagers) != tt.stakes {
+ t.Fatalf("applied state = %+v; want credits=%d chips=%d stakes=%d", state(p), tt.credits, tt.chips, tt.stakes)
+ }
+ } else if !reflect.DeepEqual(state(p), before) {
+ t.Fatalf("failed replacement changed money: got %+v, want %+v", state(p), before)
+ }
+ disk, err := store.LoadCharacter(p.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(state(p), state(disk)) {
+ t.Fatalf("memory = %+v; visible file = %+v", state(p), state(disk))
+ }
+
+ // A later ordinary save must not erase the payout or resurrect
+ // its stakes. Recovery refunds only the remaining ledger, once.
+ wantCredits, wantChips := disk.Credits, countChips(disk)
+ for _, stake := range disk.PendingWagers {
+ wantCredits += stake.Credits
+ wantChips += stake.Chips
+ }
+ if err := store.SaveCharacter(p); err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 2; i++ {
+ g.recoverPendingWagers()
+ disk, err = store.LoadCharacter(p.Name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if disk.Credits != wantCredits || countChips(disk) != wantChips || len(disk.PendingWagers) != 0 {
+ t.Fatalf("recovery %d = %+v; want credits=%d chips=%d no stakes", i, state(disk), wantCredits, wantChips)
+ }
+ }
+ })
+ }
+ }
+}