GS2-Quest
게임의 진행 관리와 퀘스트의 진행 관리를 수행합니다.
GS2-Quest는 게임의 인게임(전투나 스테이지)에 도전하기 위한 ‘입구’와 ‘출구’만을 서버에서 관리하는 마이크로서비스입니다. 인게임 내부의 로직에는 관여하지 않으며, 시작 시 비용 소비, 클리어 시 보상 지급, 전제 조건 판정과 같이 서버에서 신뢰해야 할 처리를 담당합니다.
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 예시:
{
"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)의 생성
“퀘스트 진행 상황 생성"을 입수 액션으로 이용하면, 상점에서 상품을 구매할 때나 특정 미션을 달성했을 때의 보상으로 특정 퀘스트를 직접 시작 상태로 만드는 처리를 트랜잭션 내에서 안전하게 실행할 수 있습니다. 이를 통해 특정 아이템을 구매한 직후 스페셜 퀘스트로 바로 유도하는 것과 같은 매끄러운 플레이 경험을 제공하기가 쉬워집니다.
구현 예제
퀘스트 그룹 목록 가져오기
var items = await gs2.Quest.Namespace(
namespaceName: "namespace-0001"
).QuestGroupModelsAsync(
).ToListAsync(); 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());
}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퀘스트 모델 목록 가져오기
var items = await gs2.Quest.Namespace(
namespaceName: "namespace-0001"
).QuestGroupModel(
questGroupName: "quest-group-0001"
).QuestModelsAsync(
).ToListAsync(); 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());
}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퀘스트 클리어 상황 가져오기
var item = await gs2.Quest.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).CompletedQuestList(
questGroupName: "main"
).ModelAsync();
var completedQuestNames = item.CompleteQuestNames; 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();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퀘스트 시작
var result = await gs2.Quest.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).StartAsync(
questGroupName: "group-0001",
questName: "quest-0001"
);
await result.WaitAsync(); 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;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진행 중인 퀘스트 가져오기
퀘스트 시작 시 추첨된 보상 최댓값을 가져와 인게임 클리어 연출에 활용할 수 있습니다.
var item = await gs2.Quest.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).Progress(
).ModelAsync();
var rewards = item.Rewards; 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();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가 응답한 최댓값 범위 내라면 그 수량의 보상이 지급됩니다.
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(); 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;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를 호출할 수 없었던 경우의 복구에 사용합니다.
await gs2.Quest.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).Progress(
).DeleteProgressAsync(); const auto Future = Gs2->Quest->Namespace(
"namespace-0001" // namespaceName
)->Me(
AccessToken
)->Progress(
)->DeleteProgress();
Future->StartSynchronousTask();
if (Future->GetTask().IsError()) return false;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가 존재하는 이유는, 마스터 데이터 안에 존재하지 않는 퀘스트는 클리어 상태로 만들 수 없기 때문입니다.
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를 삭제하면 특정 퀘스트 그룹의 클리어 상황을 초기화할 수 있습니다.
이벤트 재주회나 챕터의 뉴 게임+ 등의 구현에 활용할 수 있습니다.