> For the complete documentation index, see [llms.txt](/llms.txt)

# GS2-Quest

진행 관리 기능




게임의 진행 관리와 퀘스트의 진행 관리를 수행합니다.

GS2-Quest는 게임의 인게임(전투나 스테이지)에 도전하기 위한 '입구'와 '출구'만을 서버에서 관리하는 마이크로서비스입니다.
인게임 내부의 로직에는 관여하지 않으며, 시작 시 비용 소비, 클리어 시 보상 지급, 전제 조건 판정과 같이 서버에서 신뢰해야 할 처리를 담당합니다.

```mermaid
graph LR
  Start["퀘스트 시작<br/>StartAsync"] --> Battle["인게임 실행<br/>(클라이언트 / 전용 서버)"]
  Battle -- 성공 --> End1["퀘스트 종료 보고<br/>EndAsync(isComplete:true)"]
  Battle -- 실패 --> End2["퀘스트 종료 보고<br/>EndAsync(isComplete:false)"]
  End1 --> Reward["클리어 보상 지급"]
  End2 --> FailedReward["실패 보상 지급"]
```

## 퀘스트

퀘스트는 인게임의 기본 단위로, 인게임을 시작할 때 선택하는 엔티티입니다.
퀘스트에는 도전에 필요한 비용과 도전을 통해 얻을 수 있는 보상을 설정할 수 있으며, GS2-Quest는 그 시작과 종료를 API로 받아들입니다.
즉, GS2-Quest는 인게임의 내용에는 관여하지 않습니다.

### 퀘스트 도전 비용

퀘스트를 시작 상태로 만들기 위해 필요한 비용을 설정합니다.
일반적으로 GS2-Stamina에서 관리하는 스태미나를 소비하거나 GS2-Inventory에서 관리하는 아이템을 소비하는 형태의 비용을 설정합니다.

`QuestModel`의 `consumeActions`에 소비 액션을 설정하면 `Start` 실행 시 트랜잭션으로 원자적으로 처리됩니다.

### 퀘스트 검증 조건

`QuestModel`의 `verifyActions`에 검증 액션을 설정하면 퀘스트 시작 시 추가적인 조건 체크를 수행할 수 있습니다.
예를 들어 "특정 아이템을 소지하고 있을 것", "GS2-Dictionary에 특정 엔트리가 등록되어 있을 것"과 같이, 소비하지 않고 상태만 확인하는 조건을 표현할 수 있습니다.

### 퀘스트 클리어 보상

퀘스트에 도전하여 클리어했을 때 얻을 수 있는 보상을 설정할 수 있습니다.
보상에는 여러 종류(`Contents`)를 준비할 수 있습니다. 각 `Contents`에는 추첨용 `weight`를 설정할 수 있으며, 확률에 따라 어느 보상 패턴이 적용될지가 결정됩니다.
이 기능을 이용하면 일정 확률로 레어 몬스터가 출현하는 버전의 퀘스트가 시작되어 보상이 평소보다 화려해지는 설정도 가능합니다.

#### 첫 클리어 보상

퀘스트를 처음 클리어했을 때만 추가 보상을 얻을 수 있도록 설정할 수 있습니다.
`QuestModel`의 `firstCompleteAcquireActions`에 입수 액션을 설정합니다.

#### 클리어 보상 감액

퀘스트 내에서 출현한 몬스터를 쓰러뜨리지 않았거나 보물 상자를 놓친 경우, 퀘스트 보상을 줄일 수 있습니다.

퀘스트 시작 API의 응답에는 퀘스트 내에서 얻을 수 있는 보상의 최댓값이 포함되며, 퀘스트 완료 API에는 그중 실제로 입수한 수량을 보고합니다.
이때 보상을 줄여서 보고하면 감액이 이루어집니다.
보고 시 최댓값을 초과하는 보상을 보고하려고 하면 오류가 발생합니다.

### 퀘스트 실패 보상

퀘스트에 도전했지만 클리어하지 못한 경우 얻을 수 있는 보상을 설정할 수 있습니다.
`QuestModel`의 `failedAcquireActions`에 설정합니다.
퀘스트에 실패한 경우, 도전 시 지불한 스태미나를 환불하는 처리를 구현할 수 있습니다.

### 퀘스트 전제 조건

퀘스트에 도전하기 위해 다른 퀘스트를 클리어했음을 조건으로 설정할 수 있습니다.
`QuestModel`의 `premiseQuestNames`에 퀘스트 이름의 배열을 설정하면, 지정한 모든 퀘스트를 클리어하지 않으면 도전할 수 없게 됩니다.
이를 통해 퀘스트를 체인처럼 연결할 수 있습니다.

### 퀘스트 도전 가능 기간

퀘스트에는 도전 가능 기간으로 GS2-Schedule의 이벤트를 연결할 수 있습니다.
도전 가능 기간은 시작 API 실행 시에 판정되며, 종료 처리 시에는 판정되지 않습니다.

따라서 종료 보고 시점까지 기간이 지나더라도 퀘스트 보상을 받을 수 없게 되는 현상은 발생하지 않습니다.

## 퀘스트 그룹

여러 퀘스트를 묶는 엔티티입니다.
챕터나 월드 단위로 퀘스트를 묶어 관리하는 용도로 활용할 수 있습니다.

### 퀘스트 그룹의 도전 가능 기간

퀘스트 그룹에도 도전 가능 기간으로 GS2-Schedule의 이벤트를 연결할 수 있습니다.
퀘스트 그룹에 도전 가능 기간을 설정하면 하위의 모든 퀘스트에 조건이 적용됩니다.

퀘스트 그룹과 퀘스트 양쪽에 도전 가능 기간을 설정한 경우, 두 이벤트가 모두 개최 기간일 때에만 퀘스트에 도전할 수 있습니다.

## 진행 중인 퀘스트(Progress)

퀘스트를 시작하면 사용자별로 1건의 진행 중인 `Progress`가 서버에 기록됩니다.
`Progress`에는 추첨이 완료된 보상 최댓값과 서버 측에서 생성된 난수 시드가 보관되어 있으며, 이를 이용해 보고되는 보상 수량의 타당성을 검증합니다.

진행 중인 `Progress`는 사용자당 1건만 보유할 수 있으므로, 통신 단절 등으로 완료 보고를 할 수 없었던 경우에는 `DeleteProgress`로 폐기한 후 다음 퀘스트를 시작해야 합니다.
다른 퀘스트를 시작하려는 경우, `StartAsync`에 `force: true`를 지정하면 진행 중인 `Progress`를 폐기하면서 새로 시작할 수도 있습니다.

## 클리어 상황 관리

퀘스트를 클리어하면 해당 퀘스트의 이름이 `CompletedQuestList`에 기록됩니다.
퀘스트 그룹 단위로 별도의 엔티티로 관리되며, 특정 퀘스트 그룹의 클리어 상황을 한꺼번에 가져올 수 있습니다.

클리어 상황은 서버 측에서 전제 조건 판정에도 사용되므로, 외부에서 다시 쓸 때는 트랜잭션 액션을 거쳐야 합니다.

## 스크립트 트리거

네임스페이스에 `startQuestScript`·`completeQuestScript`·`failedQuestScript`를 설정하면 퀘스트 시작·클리어·실패 처리 시점에 커스텀 스크립트를 호출할 수 있습니다.

설정할 수 있는 주요 이벤트 트리거와 스크립트 설정 이름은 다음과 같습니다.

- `startQuestScript`: 퀘스트 시작 시
- `completeQuestScript`: 퀘스트 클리어 시
- `failedQuestScript`: 퀘스트 실패 시

스크립트 내에서 보상 덮어쓰기나 퀘스트 시작 거부와 같은 판단을 수행할 수 있습니다.

## 마스터 데이터 운용

마스터 데이터를 등록하면 마이크로서비스에서 이용 가능한 데이터와 동작을 설정할 수 있습니다.

마스터 데이터의 종류에는 다음과 같은 것이 있습니다.

- `QuestGroupModel`: 퀘스트의 묶음과 도전 기간
- `QuestModel`: 비용과 보상의 정의

마스터 데이터의 등록은 관리 콘솔에서 등록하는 것 외에도, GitHub에서 데이터를 반영하거나 GS2-Deploy를 사용하여 CI에서 등록하는 워크플로우를 구성할 수도 있습니다.

퀘스트 모델의 주요 설정 항목은 다음과 같습니다.

| 항목 | 내용 |
| -- | -- |
| `name` | 퀘스트 이름 |
| `contents` | 추첨되는 보상 패턴(`completeAcquireActions`와 `weight`) |
| `firstCompleteAcquireActions` | 첫 클리어 시에만 지급되는 보상 |
| `failedAcquireActions` | 실패 시 지급되는 보상 |
| `consumeActions` | 시작 시 소비하는 리소스 |
| `verifyActions` | 시작 시 전제 조건 체크 |
| `premiseQuestNames` | 전제가 되는 클리어 완료 퀘스트 |
| `challengePeriodEventId` | 도전 가능 기간을 나타내는 GS2-Schedule 이벤트 |

마스터 데이터의 JSON 예시:

```json
{
  "version": "2022-02-15",
  "questGroupModels": [
    {
      "name": "main",
      "metadata": "main-story",
      "quests": [
        {
          "name": "quest-0001",
          "metadata": "intro",
          "contents": [
            {
              "metadata": "normal",
              "completeAcquireActions": [
                {
                  "action": "Gs2Inventory:AcquireItemSetByUserId",
                  "request": "{...}"
                }
              ],
              "weight": 9
            },
            {
              "metadata": "rare",
              "completeAcquireActions": [
                {
                  "action": "Gs2Inventory:AcquireItemSetByUserId",
                  "request": "{...}"
                }
              ],
              "weight": 1
            }
          ],
          "consumeActions": [
            {
              "action": "Gs2Stamina:ConsumeStaminaByUserId",
              "request": "{...}"
            }
          ],
          "premiseQuestNames": []
        }
      ]
    }
  ]
}
```

## 버프에 의한 보정

GS2-Buff와 연동하면 퀘스트 모델의 `completeAcquireActions`·`firstCompleteAcquireActions`·`failedAcquireActions`·`verifyActions`·`consumeActions`를 버프로 보정할 수 있습니다. 이벤트나 캠페인에 맞춰 보상, 참가 조건, 소비 비용을 유연하게 조정할 수 있습니다.

"로그인 캠페인 중에는 퀘스트 보상 1.5배", "특정 장비를 장착하고 있는 동안에는 도전 비용 절반"과 같은 게임 경험을, 마스터 데이터를 다시 쓰지 않고도 구현할 수 있습니다.

## 트랜잭션 액션

GS2-Quest에서는 다음과 같은 트랜잭션 액션을 제공합니다.

- 소비 액션: 퀘스트 진행 상황(`Progress`)의 삭제
- 입수 액션: 퀘스트 진행 상황(`Progress`)의 생성

"퀘스트 진행 상황 생성"을 입수 액션으로 이용하면, 상점에서 상품을 구매할 때나 특정 미션을 달성했을 때의 보상으로 특정 퀘스트를 직접 시작 상태로 만드는 처리를 트랜잭션 내에서 안전하게 실행할 수 있습니다. 이를 통해 특정 아이템을 구매한 직후 스페셜 퀘스트로 바로 유도하는 것과 같은 매끄러운 플레이 경험을 제공하기가 쉬워집니다.

## 구현 예제

### 퀘스트 그룹 목록 가져오기



**Unity**
```csharp

    var items = await gs2.Quest.Namespace(
        namespaceName: "namespace-0001"
    ).QuestGroupModelsAsync(
    ).ToListAsync();
```
**Unreal Engine**
```cpp

    const auto It = Gs2->Quest->Namespace(
        "namespace-0001" // namespaceName
    )->QuestGroupModels();
    TArray<Gs2::UE5::Quest::Model::FEzQuestGroupModelPtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }
```
**Godot**
```gdscript

var iterator = ez.quest.namespace_(
        "namespace-0001"
    ).quest_group_models(
    )

var async_result = await iterator.load()
if async_result.error != null:
    # 오류 처리
    push_error(str(async_result.error))
    return

var items = async_result.result

```


### 퀘스트 모델 목록 가져오기



**Unity**
```csharp

    var items = await gs2.Quest.Namespace(
        namespaceName: "namespace-0001"
    ).QuestGroupModel(
        questGroupName: "quest-group-0001"
    ).QuestModelsAsync(
    ).ToListAsync();
```
**Unreal Engine**
```cpp

    const auto Domain = Gs2->Quest->Namespace(
        "namespace-0001" // namespaceName
    )->QuestGroupModel(
        "quest-group-0001" // questGroupName
    );
    const auto It = Domain->QuestModels(
    );
    TArray<Gs2::UE5::Quest::Model::FEzQuestModelPtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }
```
**Godot**
```gdscript

var iterator = ez.quest.namespace_(
        "namespace-0001"
    ).quest_group_model(
        "quest-group-0001"
    ).quest_models(
    )

var async_result = await iterator.load()
if async_result.error != null:
    # 오류 처리
    push_error(str(async_result.error))
    return

var items = async_result.result

```


### 퀘스트 클리어 상황 가져오기



**Unity**
```csharp

    var item = await gs2.Quest.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).CompletedQuestList(
        questGroupName: "main"
    ).ModelAsync();

    var completedQuestNames = item.CompleteQuestNames;
```
**Unreal Engine**
```cpp

    const auto Domain = Gs2->Quest->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->CompletedQuestList(
        "main" // questGroupName
    );
    const auto Future = Domain->Model();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
    const auto Item = Future->GetTask().Result();
```
**Godot**
```gdscript

var domain = ez.quest.namespace_(
        "namespace-0001"
    ).me(game_session).completed_quest_list(
        "main"
    )

var async_result = await domain.model()
if async_result.error != null:
    push_error(str(async_result.error))
    return

var result = async_result.result

```


### 퀘스트 시작



**Unity**
```csharp

    var result = await gs2.Quest.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).StartAsync(
        questGroupName: "group-0001",
        questName: "quest-0001"
    );

    await result.WaitAsync();
```
**Unreal Engine**
```cpp

    const auto Domain = Gs2->Quest->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    );
    const auto Future = Domain->Start(
        "group-0001", // questGroupName
        "quest-0001" // questName
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;

    const auto Transaction = Future->GetTask().Result();
    const auto Future2 = Transaction->Wait();
    Future2->StartSynchronousTask();
    if (Future2->GetTask().IsError()) return false;
```
**Godot**
```gdscript

var domain = ez.quest.namespace_(
        "namespace-0001"
    ).me(game_session)

var async_result = await domain.start(
    "group-0001", # quest_group_name
    "quest-0001", # quest_name
    null, # force
    null # config
)
if async_result.error != null:
    if async_result.error.type == "InProgressException":
        # 퀘스트가 이미 진행 중입니다
        pass
    push_error(str(async_result.error))
    return

var result = async_result.result

```


### 진행 중인 퀘스트 가져오기

퀘스트 시작 시 추첨된 보상 최댓값을 가져와 인게임 클리어 연출에 활용할 수 있습니다.



**Unity**
```csharp

    var item = await gs2.Quest.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Progress(
    ).ModelAsync();

    var rewards = item.Rewards;
```
**Unreal Engine**
```cpp

    const auto Domain = Gs2->Quest->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Progress(
    );
    const auto Future = Domain->Model();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
    const auto Item = Future->GetTask().Result();
```
**Godot**
```gdscript

var domain = ez.quest.namespace_(
        "namespace-0001"
    ).me(game_session).progress(
    )

var async_result = await domain.model()
if async_result.error != null:
    push_error(str(async_result.error))
    return

var result = async_result.result

```


### 퀘스트 종료 보고

`isComplete`에 클리어 여부를, `rewards`에 실제로 획득한 보상 수량을 보고합니다.
진행 중인 `Progress`가 응답한 최댓값 범위 내라면 그 수량의 보상이 지급됩니다.



**Unity**
```csharp

    var result = await gs2.Quest.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Progress(
    ).EndAsync(
        isComplete: true,
        rewards: new [] {
            new Gs2.Unity.Gs2Quest.Model.EzReward {
                Action = "Gs2Inventory:AcquireItemSetByUserId",
                ItemId = "grn:gs2:{region}:{ownerId}:inventory:namespace-0001:model:item-0001",
                Value = 3,
            },
        },
        config: null
    );

    await result.WaitAsync();
```
**Unreal Engine**
```cpp

    const auto Domain = Gs2->Quest->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Progress(
    );
    const auto Future = Domain->End(
        true,
        []
        {
            const auto v = MakeShared<TArray<TSharedPtr<Gs2::Quest::Model::FReward>>>();
            v->Add(MakeShared<Gs2::Quest::Model::FReward>()
                ->WithAction(TOptional<FString>("Gs2Inventory:AcquireItemSetByUserId"))
                ->WithItemId(TOptional<FString>("grn:gs2:{region}:{ownerId}:inventory:namespace-0001:model:item-0001"))
                ->WithValue(TOptional<int32>(3)));
            return v;
        }(), // rewards
        nullptr // config
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;

    const auto Transaction = Future->GetTask().Result();
    const auto Future2 = Transaction->Wait();
    Future2->StartSynchronousTask();
    if (Future2->GetTask().IsError()) return false;
```
**Godot**
```gdscript

var domain = ez.quest.namespace_(
        "namespace-0001"
    ).me(game_session).progress(
    )

var async_result = await domain.end(
    true, # is_complete
    null, # rewards
    null # config
)
if async_result.error != null:
    push_error(str(async_result.error))
    return

var result = async_result.result

```


### 진행 중인 퀘스트 폐기

통신 단절 등으로 `End`를 호출할 수 없었던 경우의 복구에 사용합니다.



**Unity**
```csharp

    await gs2.Quest.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Progress(
    ).DeleteProgressAsync();
```
**Unreal Engine**
```cpp

    const auto Future = Gs2->Quest->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Progress(
    )->DeleteProgress();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
```
**Godot**
```gdscript

var domain = ez.quest.namespace_(
        "namespace-0001"
    ).me(game_session).progress(
    )

var async_result = await domain.delete_progress(
)
if async_result.error != null:
    push_error(str(async_result.error))
    return

var result = async_result.result

```


## 기타 기능

### 퀘스트 분기

일반적인 사양에서는 퀘스트를 분기시킬 수 없습니다.
퀘스트 내에 2개의 출구를 마련하고, 어느 출구를 이용했는지에 따라 다음에 도전할 수 있는 퀘스트가 달라지도록 구현하고 싶다면 다음과 같은 데이터 구조를 검토해 보세요.

| 퀘스트 이름 | 전제 퀘스트 |
| -------- | ---------- |
| Quest1   | |
| Quest1a  | Phantom |
| Quest1b  | Phantom |
| Quest2a  | Quest1a |
| Quest2b  | Quest1b |

다소 까다롭지만, 퀘스트의 전제 조건이 되는 퀘스트에는 마스터 데이터 안에 존재하지 않는 퀘스트 이름을 설정할 수 있습니다.

이번 예시에서 Quest1a / Quest1b는 Phantom이라는 이름의 퀘스트를 전제 조건으로 하고 있지만, Phantom이라는 퀘스트는 마스터 데이터 안에 존재하지 않습니다.
따라서 Quest1a / Quest1b는 절대로 도전 가능한 상태가 되지 않는 퀘스트라는 뜻이 됩니다.

Quest2a / Quest2b는 Quest1a / Quest1b를 전제 퀘스트로 하고 있습니다.
이 상태에서 Quest1의 클리어 보상으로 "Quest1a를 클리어 상태로 만든다", "Quest1b를 클리어 상태로 만든다"라는 보상을 설정해 두고,
이용한 출구에 따라 어느 쪽의 클리어 상태를 조작하는 보상을 플레이어에게 줄지 결정합니다.

Quest1a / Quest1b가 존재하는 이유는, 마스터 데이터 안에 존재하지 않는 퀘스트는 클리어 상태로 만들 수 없기 때문입니다.

```mermaid
graph TD
  Quest1 -- if use exit A --> Quest1a
  Quest1 -- if use exit B --> Quest1b
  phantom --- Quest1a
  phantom --- Quest1b
  Quest1a --> Quest2a
  Quest1b --> Quest2b
  linkStyle 2 stroke:#ccc,stroke-dasharray:4
  linkStyle 3 stroke:#ccc,stroke-dasharray:4
  class phantom pale
  class Quest1a pale
  class Quest1b pale
```

### Config를 사용한 스크립트로의 파라미터 전달

`StartAsync` / `EndAsync`에는 `config` 파라미터를 지정할 수 있으며, 스크립트 트리거 실행 시 임의의 키-값 쌍을 전달할 수 있습니다.
플레이어가 선택한 난이도나 사용한 아이템 정보 등, 게임 고유의 맥락을 스크립트에 전달할 수 있습니다.

### 클리어 상황 리셋

`CompletedQuestList`를 삭제하면 특정 퀘스트 그룹의 클리어 상황을 초기화할 수 있습니다.
이벤트 재주회나 챕터의 뉴 게임+ 등의 구현에 활용할 수 있습니다.

## 상세 레퍼런스

[GS2-Quest API 레퍼런스](../../api_reference/quest)



