diff options
Diffstat (limited to 'internal/game/casino_durability_test.go')
| -rw-r--r-- | internal/game/casino_durability_test.go | 135 |
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) + } + } + }) + } + } +} |
