# クエストシステム

> 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 から取得し、1 つのクエストをランダムに選択します。
* クエストをプレイヤーに割り当て、データを Cloud Save に格納します。

プレイヤーは、クエストの進行状況に加算されるアクションを実行できます。

Cloud Code は、プレイヤーが最後に進行したのはいつかを確認し、以下の制限事項に応じて進行状況に加算されるアクションを許可または拒否します。

* 進行の 1 分あたりの速度は制限されます。
* 毎分、プレイヤーはクエスト設定で許可されたアクション数までしか実行できません。

これは、プレイヤーがクエストの進行ポイントを受け取ることができるレートを制限することで、シンプルなチート対策手法として機能します。

プレイヤーがクエストを完了すると、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`: プレイヤーが 1 分あたりに実行できる進行ポイントの数。
* `reward`: プレイヤーがクエストを完了したときに受け取る報酬。

例えば、1 分間のクールダウンが終わるたびにプレイヤーがアクションを実行することを想定すると、Chop Trees クエストを完了できるのは 5 分後です。

## 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) を参照してください。
