# 任务系统

> Implement a quest system that issues players with quests using Remote Config and Cloud Save.

任务系统示例演示了如何使用 Cloud Code 通过 [Remote Config](/remote-config.md) 和 [Cloud Save](/cloud-save.md) 服务实现任务示例。此示例涵盖服务器授权反作弊、其他 UGS 服务响应的缓存以及玩家 Cloud Save 数据的修改。此示例使用[推送消息](../how-to-guides/push-messages)在玩家完成任务时通知玩家。

## 概览##overview

任务系统允许玩家调用 Cloud Code 模块来发布任务。为了向玩家提供任务，Cloud Code 可以执行以下操作：

* 将任务存储在 [Remote Config](/remote-config.md) 中。
* 从 Remote Config 中获取活动任务列表，并随机选择一个任务。
* 将任务分配给玩家，并将数据存储在 Cloud Save 中。

玩家可以执行计入任务进度的操作。

Cloud Code 会检查玩家上次取得进度的时间，并根据以下限制允许或拒绝该操作对进度的影响：

* 每分钟的进度被限制在一定的速率以内。
* 玩家每分钟只能执行任务配置所允许的操作数量。

这是一种简单的反作弊方法，通过限制玩家获取任务进度点的速度来防止作弊。

一旦玩家完成任务，Cloud Save 便会删除任务数据并通过[推送消息](../how-to-guides/push-messages)通知玩家。

## 设置任务##set-up-quests

您需要在 Remote Config 服务中定义您的任务：

1. 导航到 [Unity Dashboard](https://cloud.unity.com)。
2. 选择 **Products（产品）**> **Remote Config**。
3. 选择 **Config（配置）** 选项卡。
4. 选择 **Add a key（添加键）**。
5. 输入 `QUESTS` 作为键名称。
6. 选择 **JSON** 作为类型。
7. 粘贴以下内容作为值：
   ```JSON
   [
     {
       "id": 1,
       "name": "Chop Trees",
       "progress_required": 5,
       "progress_per_minute": 1,
       "reward": 5
     },
     {
       "id": 2,
       "name": "Mine Gold",
       "progress_required": 5,
       "progress_per_minute": 1,
       "reward": 10
     }
   ]
   ```
8. 发布该配置。

该配置充当活动任务数据库。

任务结构如下：

* `id`：任务的唯一标识符。
* `name`：任务的名称。
* `progress_required`：完成任务所需的进度点数。
* `progress_per_minute`：玩家每分钟可以获得的进度点数。
* `reward`：玩家完成任务后获得的奖励。

例如，假设玩家在每一分钟冷却时间结束时执行操作，Chop Trees（砍树）任务只能在五分钟内完成。

## 设置 Cloud Code##set-up-cloud-code

设置一个名为 `QuestSystem` 的 Cloud Code 模块来处理任务系统。

### 限制对 Cloud Save 的访问##restrict-access-to-cloud-save

要为您的 Cloud Save 数据增加一层安全保护，您可以使用[访问控制](../how-to-guides/access-control)来创建资源策略。

为了防止用户通过修改自己的 Cloud Save 数据来作弊，您需要为 UGS 创建资源策略。

创建一个名为 `cloud-save-resource-policy.json` 的文件并粘贴以下内容：

```json
{
    "statements": [
        {
            "Sid": "deny-cloud-save-data-write-access",
            "Effect": "Deny",
            "Action": ["Write"],
            "Principal": "Player",
            "Resource": "urn:ugs:cloud-save:/v1/data/projects/*/players/*/items**"
        }
    ]
}
```

您可以使用 UGS CLI 来部署该文件：

```bash
ugs access upsert-project-policy cloud-save-resource-policy.json
```

要了解有关资源策略的更多信息，请参阅[访问控制](../how-to-guides/access-control)。

### 设置任务服务##set-up-quest-service

创建一个名为 `QuestService.cs` 的文件。

任务服务从 Remote Config 获取任务并将任务缓存一段时间。

```csharp
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;
using Unity.Services.CloudCode.Shared;

namespace QuestSystem;

public interface IQuestService
{
    List<Quest> GetAvailableQuests(IExecutionContext context, IGameApiClient gameApiClient);
}

public class QuestService : IQuestService
{
    private const string QuestKey = "QUESTS";

    private readonly ILogger<QuestService> _logger;
    public QuestService(ILogger<QuestService> logger)
    {
        _logger = logger;
    }

    private DateTime? CacheExpiryTime { get; set; }

    // Reminder: cache cannot be guaranteed to be consistent across all requests
    private List<Quest>? QuestCache { get; set; }

    public List<Quest> GetAvailableQuests(IExecutionContext context, IGameApiClient gameApiClient)
    {
        if (QuestCache == null || DateTime.Now > CacheExpiryTime)
        {
            var quests = FetchQuestsFromConfig(context, gameApiClient);
            QuestCache = quests;
            CacheExpiryTime = DateTime.Now.AddMinutes(5); // data in cache expires after 5 mins
        }

        return QuestCache;
    }

    private List<Quest> FetchQuestsFromConfig(IExecutionContext ctx, IGameApiClient gameApiClient)
    {
        try
        {
            var result = gameApiClient.RemoteConfigSettings.AssignSettingsGetAsync(ctx, ctx.AccessToken, ctx.ProjectId,
                ctx.EnvironmentId, null, new List<string> { "QUESTS" });

            var settings = result.Result.Data.Configs.Settings;

            return JsonConvert.DeserializeObject<List<Quest>>(settings[QuestKey].ToString());
        }
        catch (ApiException e)
        {
            _logger.LogError($"Failed to assign Remote Config settings. Error: {e.Message}");
            throw new Exception($"Failed to assign Remote Config settings. Error: {e.Message}");
        }
    }
}
```

### 数据类##data-classes

创建一个名为 `DataClasses.cs` 的文件来存储数据类。数据类可用于对 Cloud Code 从 Remote Config 和 Cloud Save 接收的响应进行反序列化。创建一个名为 `DataClasses.cs` 的文件来存储数据类。

```csharp
using Newtonsoft.Json;

namespace QuestSystem;

public class Quest
{
    [JsonProperty("id")]
    public int ID { get; set; }

    [JsonProperty("name")]
    public string? Name { get; set; }

    [JsonProperty("reward")]
    public int Reward { get; set; }

    [JsonProperty("progress_required")]
    public int ProgressRequired { get; set; }

    [JsonProperty("progress_per_minute")]
    public int ProgressPerMinute { get; set; }
}

public class QuestData
{
    public QuestData()
    {

    }

    public QuestData(string questName, int reward, int progressLeft, int progressPerMinute, DateTime questStartTime)
    {
        QuestName = questName;
        Reward = reward;
        ProgressLeft = progressLeft;
        ProgressPerMinute = progressPerMinute;
        QuestStartTime = questStartTime;
        LastProgressTime = new DateTime();
    }

    [JsonProperty("quest-name")]
    public string? QuestName { get; set; }

    [JsonProperty("reward")]
    public long Reward { get; set; }

    [JsonProperty("progress-left")]
    public long ProgressLeft { get; set; }

    [JsonProperty("progress-per-minute")]
    public long ProgressPerMinute { get; set; }

    [JsonProperty("quest-start-time")]
    public DateTime QuestStartTime { get; set; }

    [JsonProperty("last-progress-time")]
    public DateTime LastProgressTime { get; set; }
}
```

要了解有关自定义序列化的更多信息，请参阅[自定义序列化](../how-to-guides/initialize-modules/custom-serialization)。

### 依赖项设置##dependency-setup

要连接 Cloud Code 模块，您需要设置 `QuestService`、`GameApiClient` 和 `PushClient`：

* `QuestService` 管理和缓存任务。
* 需要 `GameApiClient` 来调用 Remote Config 和 Cloud Save 服务。
* 需要 `PushClient` 来向玩家发送推送通知。

`ICloudCodeSetup` 接口可用于管理模块的依赖项。请在[依赖项注入](../how-to-guides/initialize-modules/dependency-injection)文档中了解更多相关信息。

```csharp
using Microsoft.Extensions.DependencyInjection;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;

namespace QuestSystem;

public class CloudCodeSetup : ICloudCodeSetup
{
    public void Setup(ICloudCodeConfig config)
    {
        config.Dependencies.AddSingleton<IQuestService, QuestService>();
        config.Dependencies.AddSingleton(GameApiClient.Create());
        config.Dependencies.AddSingleton(PushClient.Create());
    }
}
```

### 任务控制器##quest-controller

创建 `QuestController` 类作为服务的主类和用户流量入口点。

```csharp
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Unity.Services.CloudCode.Apis;
using Unity.Services.CloudCode.Core;
using Unity.Services.CloudCode.Shared;
using Unity.Services.CloudSave.Model;

namespace QuestSystem;

public class QuestController
{
    private const string QuestDataKey = "quest-data";

    private const string PlayerHasQuestInProgress = "Player already has a quest in progress!";
    private const string PlayerHasNoQuestInProgress = "Player does not have a quest in progress!";
    private const string PlayerHasCompletedTheQuest = "Player has already completed their quest!";
    private const string PlayerCannotProgress = "Player cannot make quest progress yet!";
    private const string PlayerProgressed = "Player made quest progress!";
    private const string PlayerHasFinishedTheQuest = "Player has finished the quest!";

    private ILogger<QuestController> _logger;
    public QuestController(ILogger<QuestController> logger)
    {
        _logger = logger;
    }

    [CloudCodeFunction("AssignQuest")]
    public async Task<string> AssignQuest(IExecutionContext context, IQuestService questService,
        IGameApiClient gameApiClient)
    {
        var questData = await GetQuestData(context, gameApiClient);

        if (questData?.QuestName != null) return PlayerHasQuestInProgress;

        var availableQuests = questService.GetAvailableQuests(context, gameApiClient);
        var random = new Random();
        var index = random.Next(availableQuests.Count);
        var quest = availableQuests[index];

        questData = new QuestData(quest.Name, quest.Reward, quest.ProgressRequired, quest.ProgressPerMinute,
            DateTime.Now);

        await SetQuestData(context, gameApiClient, QuestDataKey, JsonConvert.SerializeObject(questData));

        return $"Player was assigned quest: {quest.Name}!";
    }

    [CloudCodeFunction("PerformAction")]
    public async Task<string> PerformAction(IExecutionContext context, IGameApiClient gameApiClient, PushClient pushClient)
    {
        var questData = await GetQuestData(context, gameApiClient);

        if (questData?.QuestName == null) return PlayerHasNoQuestInProgress;
        if (questData.ProgressLeft == 0) return PlayerHasCompletedTheQuest;

        if (DateTime.Now < questData.LastProgressTime.AddSeconds(60 / questData.ProgressPerMinute))
            return PlayerCannotProgress;

        questData.LastProgressTime = DateTime.Now;
        questData.ProgressLeft--;

        await SetQuestData(context, gameApiClient, QuestDataKey, JsonConvert.SerializeObject(questData));
        if (questData.ProgressLeft <= 0)
        {
            await HandleQuestCompletion(context, gameApiClient, pushClient);
            return PlayerHasFinishedTheQuest;
        }

        return PlayerProgressed;
    }

    public async Task HandleQuestCompletion(IExecutionContext context, IGameApiClient gameApiClient, PushClient pushClient)
    {
        await NotifyPlayer(context, pushClient);
        try
        {
            await gameApiClient.CloudSaveData.DeleteItemAsync(context, context.AccessToken, QuestDataKey,
                context.ProjectId, context.PlayerId);
        }
        catch (ApiException e)
        {
            _logger.LogError("Failed to delete a quest for player. Error: {Error}",  e.Message);
            throw new Exception($"Failed to delete a quest for player. Error. Error: {e.Message}");
        }
    }

    private async Task NotifyPlayer(IExecutionContext context, PushClient pushClient)
    {
        const string message = "Quest completed!";
        const string messageType = "Announcement";

        try
        {
            await pushClient.SendPlayerMessageAsync(context, message, messageType, context.PlayerId);
        }
        catch (ApiException e)
        {
            _logger.LogError("Failed to send player message. Error: {Error}",  e.Message);
            throw new Exception($"Failed to send player message. Error: {e.Message}");
        }
    }

    private async Task<QuestData?> GetQuestData(IExecutionContext context, IGameApiClient gameApiClient)
    {
        try
        {
            var result = await gameApiClient.CloudSaveData.GetItemsAsync(
                context, context.AccessToken, context.ProjectId, context.PlayerId,
                new List<string> { QuestDataKey });

            if (result.Data.Results.Count == 0) return null;
            return JsonConvert.DeserializeObject<QuestData>(result.Data.Results.First().Value.ToString());
        }
        catch (ApiException e)
        {
            _logger.LogError($"Failed to retrieve data from Cloud Save. Error: {e.Message}");
            throw new Exception($"Failed to retrieve data from Cloud Save. Error: {e.Message}");
        }
    }

    private async Task SetQuestData(IExecutionContext context, IGameApiClient gameApiClient, string key,
        string value)
    {
        try
        {
            await gameApiClient.CloudSaveData
                .SetItemAsync(context, context.ServiceToken, context.ProjectId, context.PlayerId,
                    new SetItemBody(key, value));
        }
        catch (ApiException e)
        {
            _logger.LogError($"Failed to save data in Cloud Save. Error: {e.Message}");
            throw new Exception($"Failed to save data in Cloud Save. Error: {e.Message}");
        }
    }
}
```

## 为玩家分配任务##assign-quests-to-players

您可以调用 `AssignQuest` 函数为玩家分配任务。此函数：

* 为玩家分配 Remote Config 服务中的任务。
* 为玩家创建一个 `QuestData` 对象并将其存储在 Cloud Save 中。

如果玩家已经在进行某个任务，此函数会做出相应的响应。

## 推进任务##progress-quests

您可以调用 `PerformAction` 函数来推进任务。要更新进度，请在玩家每次执行推进任务进度的操作时调用此函数。

* 如果冷却时间已过期，或者这是玩家的第一次进度函数调用，那么玩家将获得一个进度点，并且他们在 Cloud Save 中的 `progress-left` 值会下降。
* 如果在冷却期间调用此函数，Cloud Code 将做出响应：`Player cannot make quest progress yet!`。
* 如果调用成功，Cloud Code 将做出响应：`Player made quest progress!`。在 Cloud Save 中，Cloud Code 会减小玩家的 `progress-left` 值并更新他们的 `last-progress-time`。
* 如果最后一次成功调用使 `progress-left` 为零，Cloud Code 会通知玩家他们已完成任务。任务数据会从 Cloud Save 中删除。

## 验证模块##validate-the-module

要验证对 Cloud Save 数据的更改，您可以在 Unity Dashboard 中检查玩家的 Cloud Save 数据。

1. 导航到 [Unity Dashboard](https://cloud.unity.com)。
2. 选择 **Products（产品）**> **Player Management（玩家管理）**。
3. 选择 **Player Management（玩家管理）**。
4. 在搜索字段中，输入玩家 ID，然后选择 **Find Player（查找玩家）**。
5. 导航到 **Cloud Save** > **Data（数据）** 部分。
6. 在 `quest-data` 键中追踪任务的进度。

要确认您的推送通知有效，请参阅[推送消息](../how-to-guides/push-messages)。
