# 퀘스트 시스템

> 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는 플레이어가 마지막으로 진행을 수행한 시점을 확인하여 액션이 진행도에 반영되는 것을 허용하거나 거부하며, 이는 다음 제한 사항에 따라 달라집니다.

* 진행도는 분당 특정 속도로 제한됩니다.
* 플레이어는 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`: 퀘스트에 대한 고유 ID입니다.
* `name`: 퀘스트 이름입니다.
* `progress_required`: 퀘스트를 완수하는 데 필요한 진행도 포인트 양입니다.
* `progress_per_minute`: 플레이어가 분당 얻을 수 있는 진행도 포인트 양입니다.
* `reward`: 퀘스트 완수에 따라 플레이어가 받을 수 있는 보상입니다.

예를 들어 1분의 대기 시간이 끝날 때마다 플레이어가 액션을 수행한다고 가정할 때 5분 후에 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`가 0이 되면 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)를 참고하십시오.
