# 创建财物

**URL:** https://heroiclabs.com/docs/zh/nakama/guides/concepts/economy/
**Summary:** 本指南提供了使用IAP、虚拟钱包和存储引擎创建游戏内财物的实用指南。

---


# 正在创建财物

本指南将介绍如何使用Nakama的[IAP验证](../../../concepts/iap-validation/)、[虚拟钱包](../../../concepts/user-accounts/#virtual-wallet)和[存储引擎](../../../concepts/storage/)功能开发游戏内的财物系统。

本指南将允许玩家通过IAP购买高级货币、宝石。然后可以在游戏中消耗这些宝石购买硬币，这些硬币可以购买游戏中的物品。

我们还将允许玩家使用IAP成为高级玩家，然后在必要时恢复购买。

## 使用IAP购买高级货币

以下服务器运行时代码示例假定使用[Unity IAP](https://docs.unity3d.com/Packages/com.unity.purchasing@4.0/manual/Overview.html)包将购买收据提交到自定义Nakama RPC中。 

此RPC首先检查有效负载，查看进行购买行为所使用的应用商店，然后使用适当的应用商店验证购买行为。如果购买行为有效，它会为阵列中的每个经过验证的购买行为调用一个单独的函数，以检查购买的产品ID，并在玩家的虚拟钱包中向玩家提供正确数量的宝石。

请注意，为了让Nakama验证购买，您必须提供[适用于每个应用商店的配置变量](https://heroiclabs.com/docs/nakama/getting-started/configuration/#iap-in-app-purchase)。

{{< code type="server" >}}
```typescript
let RpcValidateIAP: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  // Assumes payload is a Unity IAP receipt
  const iap = JSON.parse(payload);

  // Validate the purchases depending on which app store was used
  let validatePurchaseResponse : nkruntime.ValidatePurchaseResponse;
  switch (iap.store) {
    case 'GooglePlay':
      validatePurchaseResponse = nk.purchaseValidateGoogle(ctx.userId, iap.payload);
      break;
    case 'AppleAppStore':
      validatePurchaseResponse = nk.purchaseValidateApple(ctx.userId, iap.payload);
      break;
    default:
      logger.warn('Unrecognised app store in payload')
      return JSON.stringify({ success: false });
      break;
  }

  validatePurchaseResponse.validatedPurchases.forEach(p => rewardPurchase(ctx, logger, nk, p));
  return JSON.stringify({ success: true });
};

let rewardPurchase = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, validatedPurchase: nkruntime.ValidatedPurchase): void {
  // Here we are just dealing with consumable IAPs
  switch (validatedPurchase.productId) {
    case 'gems_100':
      nk.walletUpdate(ctx.userId, { gems: 100 }, null, true);
      break;
    case 'gems_1000':
      nk.walletUpdate(ctx.userId, { gems: 1000 }, null, true);
      break;
  }
};
```
{{< / code >}}

{{< code type="server" >}}
```go
type IAPPayload struct {
	Store     string `json:"store"`
	ProductId int    `json:"productId"`
	Payload   string `json:"payload"`
}

func RpcValidateAPI(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Get the user ID
	userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok {
		logger.Error("no user id found")
		return "", runtime.NewError("no user id found", 16)
	}

	// Assumes payload is a Unity IAP receipt
	iap := &IAPPayload{}
	if err := json.Unmarshal([]byte(payload), iap); err != nil {
		logger.Error("error unmarshaling payload")
		return "", runtime.NewError("iap payload invalid", 3)
	}

	// Validate the purchases depending on which app store was used
	switch iap.Store {
	case "GooglePlay":
		validatePurchaseResponse, err := nk.PurchaseValidateGoogle(ctx, userId, iap.Payload, true)
		if err != nil {
			logger.Error("error validated purchases with Google")
			return "", runtime.NewError("error validated purchases with Google", 13)
		}
		rewardPurchases(ctx, userId, logger, nk, validatePurchaseResponse)
		break
	case "AppleAppStore":
		validatePurchaseResponse, err := nk.PurchaseValidateApple(ctx, userId, iap.Payload, true)
		if err != nil {
			logger.Error("error validated purchases with Apple")
			return "", runtime.NewError("error validated purchases with Apple", 13)
		}
		rewardPurchases(ctx, userId, logger, nk, validatePurchaseResponse)
		break
	default:
		logger.Warn("unrecognised app store in payload")
		return "", runtime.NewError("unrecognised app store", 13)
	}

	return "{}", nil
}

func rewardPurchases(ctx context.Context, userId string, logger runtime.Logger, nk runtime.NakamaModule, validatedPurchaseResponse *api.ValidatePurchaseResponse) {
	for _, p := range validatedPurchaseResponse.ValidatedPurchases {
		// Here we are just dealing with consumable IAPs
		switch p.ProductId {
		case "gems_100":
			walletUpdate := []*runtime.WalletUpdate{
				{
					UserID: userId,
					Changeset: map[string]int64{
						"gems": 100,
					},
				},
			}
			nk.WalletsUpdate(ctx, walletUpdate, true)
			break
		case "gems_1000":
			walletUpdate := []*runtime.WalletUpdate{
				{
					UserID: userId,
					Changeset: map[string]int64{
						"gems": 1000,
					},
				},
			}
			nk.WalletsUpdate(ctx, walletUpdate, true)
			break
		}
	}
}
```
{{< / code >}}

{{< missing type="server" lang="lua" / >}}

## 使用高级货币购买游戏内货币

玩家已购买高级货币，可以用高级货币购买游戏内的货币，硬币。以下RPC允许用户指定购买硬币所需要的宝石数量。这里的转换率（1宝石＝1000硬币）采用硬编码。

{{< code type="server" >}}
```typescript
let RpcPurchaseCoins: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  const request = JSON.parse(payload);

  if (!request.gemsToSpend) {
    logger.warn('No gemsToSpend specified in RpcPurchaseCoins payload.');
    return JSON.stringify({ success: false, error: 'Failed to provide gems to spend amount.' });
  }

  // Check that the user has enough gems to spend
  const account = nk.accountGetId(ctx.userId);
  if (account.wallet['gems'] < request.gemsToSpend) {
    logger.warn('User does not have enough gems.');
    return JSON.stringify({ success: false, error: 'Not enough gems.' });
  }

  // Spend
  const coinsPerGem = 1000;
  nk.walletUpdate(ctx.userId, { coins: coinsPerGem * request.gemsToSpend, gems: -request.gemsToSpend });

  return JSON.stringify({ success: true });
};
```
{{< / code >}}

{{< code type="server" >}}
```go
type PurchaseCoinsPayload struct {
	GemsToSpend int `json:"gemsToSpend"`
}

func RpcPurchaseCoins(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Get the user ID
	userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok {
		logger.Error("no user id found")
		return "", runtime.NewError("no user id found", 16)
	}

	// Unmarshal the payload
	request := &PurchaseCoinsPayload{}
	if err := json.Unmarshal([]byte(payload), request); err != nil {
		logger.Error("error unmarshaling payload")
		return "", runtime.NewError("purchase coins payload invalid", 3)
	}

	if request.GemsToSpend <= 0 {
		logger.Warn("no gemsToSpend specified in payload")
		return "", runtime.NewError("no gemsToSpend specified in payload", 3)
	}

	// Check that the user has enough gems to spend
	account, err := nk.AccountGetId(ctx, userId)
	if err != nil {
		logger.Error("error getting account data")
		return "", runtime.NewError("error getting account data", 13)
	}

	var wallet map[string]int
	if err := json.Unmarshal([]byte(account.Wallet), &wallet); err != nil {
		logger.Error("error unmarshaling wallet")
		return "", runtime.NewError("error unmarshaling wallet", 13)
	}

	if wallet["gems"] < request.GemsToSpend {
		logger.Warn("user does not have enough gems")
		return "", runtime.NewError("you do not have enough gems", 9)
	}

	// Spend
	coinsPerGem := 1000
	coins := coinsPerGem *  request.GemsToSpend
	_, _, err = nk.WalletUpdate(ctx, userId, map[string]int64{"coins": int64(coins), "gems": int64(-request.GemsToSpend)}, nil, true)
	if err != nil {
		return "", runtime.NewError("unable to update wallet", 13)
	}

	return "{}", nil
}
```
{{< / code >}}

{{< missing type="server" lang="lua" / >}}

## 使用游戏内货币购买物品

在本例中，我们将在Nakama存储引擎中存储一个配置对象，它将游戏中的每个项目映射到以硬币计算的价格。我们将在服务器的`InitModule`函数中进行此项配置。

{{< code type="server" >}}
```typescript
  const itemPrices = {
    'iron-sword': 100,
    'iron-shield': 150,
    'steel-sword': 500
  };

  const writeRequest: nkruntime.StorageWriteRequest = {
    collection: 'configuration',
    key: 'prices',
    userId: '00000000-0000-0000-0000-000000000000', // Owned by the system user
    permissionRead: 2, // Public read
    permissionWrite: 0, // No write
    value: itemPrices
  };

  nk.storageWrite([ writeRequest ]);
```
{{< / code >}}

{{< code type="server" >}}
```go
itemPrices := map[string]int64 {
  "iron-sword": 100,
  "iron-shield": 150,
  "steel-sword": 500,
}

itemJson, err := json.Marshal(itemPrices)
if err != nil {
  logger.Error("error marshaling item prices")
  return runtime.NewError("error marshaling item prices", 13)
}

write := &runtime.StorageWrite{
  Collection: "configuration",
  Key: "prices",
  UserID: "00000000-0000-0000-0000-000000000000",
  PermissionRead: 2, // Public read
  PermissionWrite: 0, // No write
  Value: string(itemJson),
}

if _, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{write}); err != nil {
  logger.Error("error writing storage objects")
  return runtime.NewError("error writing storage objects", 13)
}
```
{{< / code >}}

{{< missing type="server" lang="lua" / >}}

将我们的价格存储在存储引擎中，我们可以编写一个允许用户购买物品的RPC（前提是用户有足够的硬币）。

{{< code type="server" >}}
```typescript
let RpcPurchaseItem: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  const request = JSON.parse(payload);

  // Make sure the user specified an item to buy
  if (!request.itemName) {
    logger.warn('No item name specified.');
    return JSON.stringify({ success: false, error: 'No item name specified.'});
  }

  // Lookup the item prices
  const readRequest: nkruntime.StorageReadRequest = {
    collection: 'configuration',
    key: 'prices',
    userId: '00000000-0000-0000-0000-000000000000'
  };

  const readResult = nk.storageRead([readRequest]);
  if (readResult.length == 0)
  {
    logger.warn('No item prices in storage.');
    return JSON.stringify({ success: false, error: 'No item prices available.' });
  }
  
  const prices = readResult[0].value;

  // Check if there is a price for the requested item
  if (!prices[request.itemName]) {
    logger.warn(`No price available for ${request.itemName}`);
    return JSON.stringify({ success: false, error: `No price available for ${request.itemName}` });
  }

  // Check that the player has enough coins
  const account = nk.accountGetId(ctx.userId);

  if (account.wallet['coins'] < prices[request.itemName]) {
    logger.warn('Not enough coins to purchase item.');
    return JSON.stringify({ success: false, error: 'Not enough coins to purchase item.' });
  }

  // Decrease the player's coins
  nk.walletUpdate(ctx.userId, { coins: -prices[request.itemName] });

  // Get the player's current inventory.
  let inventory = {};

  const inventoryReadRequest: nkruntime.StorageReadRequest = {
    collection: 'economy',
    key: 'inventory',
    userId: ctx.userId
  };

  const result = nk.storageRead([inventoryReadRequest]);
  if (result.length > 0) {
    inventory = result[0].value;
  }

  // Give the player the item (either increase quantity if they already possessed it or add one)
  if (inventory[request.itemName]) {
    inventory[request.itemName] += 1;
  } else {
    inventory[request.itemName] = 1;
  }

  // Define the storage write request to update the player's inventory.
  const writeRequest: nkruntime.StorageWriteRequest = {
    collection: 'economy',
    key: 'inventory',
    userId: ctx.userId,
    permissionWrite: 1,
    permissionRead: 1,
    value: inventory
  };

  // Write the updated inventory to storage.
  const storageWriteAck = nk.storageWrite([writeRequest]);

  // Return an error if the write does not succeed.
  if (!storageWriteAck || storageWriteAck.length == 0) {
    return JSON.stringify({ success: false, error: 'Error saving inventory.' });
  }

  return JSON.stringify({ success: true });
};
```
{{< / code >}}

{{< code type="server" >}}
```go
type PurchaseItemPayload struct {
	ItemName string `json:"itemName"`
}

func RpcPurchaseItem(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Get the user ID
	userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok {
		logger.Error("no user id found")
		return "", runtime.NewError("no user id found", 16)
	}

	// Unmarshal the payload
	request := &PurchaseItemPayload{}
	if err := json.Unmarshal([]byte(payload), request); err != nil {
		logger.Error("error unmarshaling payload", err)
		return "", runtime.NewError("purchase item payload invalid", 3)
	}

	// Make sure the user specified an item to buy
	if request.ItemName == "" {
		logger.Warn("no item name specified")
		return "", runtime.NewError("no item name specified", 3)
	}

	// Lookup the item prices
	readRequest := &runtime.StorageRead{
		Collection: "configuration",
		Key:        "prices",
		UserID:     "00000000-0000-0000-0000-000000000000",
	}

	readResult, err := nk.StorageRead(ctx, []*runtime.StorageRead{readRequest})
	if err != nil {
		logger.Error("error reading item prices from storage", err)
		return "", runtime.NewError("error reading item prices from storage", 13)
	}

	if len(readResult) == 0 {
		logger.Warn("no item prices in storage")
		return "", runtime.NewError("no item prices in storage", 13)
	}

	// Check if there is a price for the requested item
	var prices map[string]int
	if err := json.Unmarshal([]byte(readResult[0].Value), &prices); err != nil {
		logger.Error("error unmarshaling prices", err)
		return "", runtime.NewError("error unmarshaling prices", 13)
	}

	if _, ok := prices[request.ItemName]; !ok {
		logger.Warn("no price available for %s", request.ItemName)
		return "", runtime.NewError(fmt.Sprintf("no price available for %s", request.ItemName), 5)
	}

	// Check that the player has enough coins to spend
	account, err := nk.AccountGetId(ctx, userId)
	if err != nil {
		logger.Error("error getting account data", err)
		return "", runtime.NewError("error getting account data", 13)
	}

	var wallet map[string]int
	if err := json.Unmarshal([]byte(account.Wallet), &wallet); err != nil {
		logger.Error("error unmarshaling wallet", err)
		return "", runtime.NewError("error unmarshaling wallet", 13)
	}

	if wallet["coins"] < prices[request.ItemName] {
		logger.Warn("not enough coins to purchase item")
		return "", runtime.NewError("not enough coins to purchase item", 9)
	}

	// Decrease the player's coins
	_, _, err = nk.WalletUpdate(ctx, userId, map[string]int64{"coins": int64(-prices[request.ItemName])}, nil, true)
	if err != nil {
		logger.Error("unable to update wallet", err)
		return "", runtime.NewError("unable to update wallet", 13)
	}

	// Get the player's current inventory
	var inventory map[string]int

	readRequest = &runtime.StorageRead{
		Collection: "economy",
		Key:        "inventory",
		UserID:     userId,
	}

	readResult, err = nk.StorageRead(ctx, []*runtime.StorageRead{readRequest})
	if err != nil {
		logger.Error("error reading inventory from storage", err)
		return "", runtime.NewError("error reading inventory from storage", 13)
	}

	if len(readResult) > 0 {
		if err := json.Unmarshal([]byte(readResult[0].Value), &inventory); err != nil {
			logger.Error("error unmarshaling inventory", err)
			return "", runtime.NewError("error unmarshaling inventory", 13)
		}
	} else {
		inventory = make(map[string]int)
	}

	// Give the player the item (either increase quantity if they already possessed it or add one)
	if _, ok := inventory[request.ItemName]; ok {
		inventory[request.ItemName] += 1
	} else {
		inventory[request.ItemName] = 1
	}

	// Write the updated inventory to storage
	inventoryJson, err := json.Marshal(inventory)
	if err != nil {
		logger.Error("error marshaling inventory")
		return "", runtime.NewError("error marshaling inventory", 13)
	}

	writeRequest := &runtime.StorageWrite{
		Collection:      "economy",
		Key:             "inventory",
		UserID:          userId,
		PermissionRead:  1,
		PermissionWrite: 1,
		Value:           string(inventoryJson),
	}

	// Return an error if the write does not succeed
	storageWriteAck, err := nk.StorageWrite(ctx, []*runtime.StorageWrite{writeRequest})
	if err != nil || len(storageWriteAck) == 0 {
		logger.Error("error saving inventory")
		return "", runtime.NewError("error saving inventory", 13)
	}

	return "{}", nil
}
```
{{< / code >}}

{{< missing type="server" lang="lua" / >}}

## 使用IAP购买非消费品

除了能够通过应用程序内的购买行为购买虚拟货币之外，您还可能希望让玩家可以直接购买非消费品。

在这种情况下，我们将重新访问我们之前允许玩家购买宝石的服务器运行时RPC。但是，我们现在将添加购买非消费品的功能，以便稍后可以在其他设备上恢复这个功能。在这种情况下，通过购买非消费品，玩家将能够成为高级玩家。

为此，购买一经验证，我们将在用户的元数据中设置一个标志，表明他们是高级玩家。然后可以在整个游戏中凭借该标志提供各种被动技能/奖励。

{{< code type="server" >}}
```typescript
let rewardPurchase = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, validatedPurchase: nkruntime.ValidatedPurchase): void {
  switch (validatedPurchase.productId) {
    // ...existing cases omitted
    case 'premium_status':
      const account = nk.accountGetId(ctx.userId);
      const metadata = account.user.metadata;
      metadata['premium'] = true;
      nk.accountUpdateId(ctx.userId, null, null, null, null, null, null, metadata);
      break;
  }
};
```
{{< / code >}}

{{< code type="server" >}}
```go
func rewardPurchases(ctx context.Context, userId string, logger runtime.Logger, nk runtime.NakamaModule, validatedPurchaseResponse *api.ValidatePurchaseResponse) {
	for _, p := range validatedPurchaseResponse.ValidatedPurchases {
		switch p.ProductId {
		// ...existing cases omitted
		case "premium_status":
			account, err := nk.AccountGetId(ctx, userId)
			if err != nil {
				logger.Error("unable to get account", err)
				return
			}

			var metadata map[string]interface{}
			if err := json.Unmarshal([]byte(account.User.Metadata), &metadata); err != nil {
				logger.Error("error unmarshaling account metadata", err)
				return
			}

			metadata["premium"] = true
			nk.AccountUpdateId(ctx, userId, "", metadata, "", "", "", "", "")
			break
		}
	}
}
```
{{< / code >}}

{{< missing type="server" lang="lua" / >}}

## 恢复IAP购买

如果用户更改了设备，应该可以恢复先前发生的所有购买行为。这不适用于在购买时已经“消费”的消费性购买行为（例如虚拟货币），但对于解锁完整游戏、移除广告或成为高级会员等事项，用户应该在安装您的游戏的任何新设备上享受所有相同的权益。

为此，我们将提供一个RPC，游戏客户端可以调用该RPC接收所有已在Nakama中验证为成功购买的IAP产品ID和购买时间戳的列表。之后，客户端可以使用此信息在新设备上恢复适当的功能。

{{< code type="server" >}}
```typescript
let RpcRestorePurchases: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string {
  const purchases = nk.purchasesList(ctx.userId);

  const response = {
    purchases = purchases.validatedPurchases.map(v => { v.productId, v.purchaseTime })
  };

  return JSON.stringify(response);
};
```
{{< / code >}}

{{< code type="server" >}}
```go
type RestorePurchasesResponse struct {
	Purchases []*ValidatedPurchaseItem `json:"purchases"`
}

type ValidatedPurchaseItem struct {
	ProductId    string `json:"productId"`
	PurchaseTime int64  `json:"purchaseTime"`
}

func RpcRestorePurchases(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, payload string) (string, error) {
	// Get the user ID
	userId, ok := ctx.Value(runtime.RUNTIME_CTX_USER_ID).(string)
	if !ok {
		logger.Error("no user id found")
		return "", runtime.NewError("no user id found", 16)
	}

	// Get the list of validated purchases
	purchases, err := nk.PurchasesList(ctx, userId, 100, "")
	if err != nil {
		logger.Error("error retrieving purchases", err)
		return "", runtime.NewError("error retrieving purchases", 13)
	}

	// Construct the response
	purchasesResponse := &RestorePurchasesResponse{
		Purchases: []*ValidatedPurchaseItem{},
	}

	for _, purchase := range purchases.ValidatedPurchases {
		purchasesResponse.Purchases = append(purchasesResponse.Purchases, &ValidatedPurchaseItem{ProductId: purchase.ProductId, PurchaseTime: purchase.PurchaseTime.Seconds})
	}

	// Marshal the response
	jsonResponse, err := json.Marshal(purchasesResponse)
	if err != nil {
		logger.Error("error marshaling response", err)
		return "", runtime.NewError("error marshaling response", 13)
	}

	return string(jsonResponse), nil
}
```
{{< / code >}}

{{< missing type="server" lang="lua" / >}}