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) } } }) } } }