GS2-Ranking2
게임의 점수나 클리어 타임을 겨루는 랭킹 기능을 실현합니다.
GS2-Ranking2 는 다음 3가지 종류의 모드를 제공합니다.
- 글로벌 랭킹
- 클러스터 랭킹
- 구독 랭킹
게임에 필요한 랭킹 기능의 대부분은 이 중 하나의 모드로 요건을 충족할 수 있을 것입니다.
랭킹 모드
글로벌 랭킹
글로벌 랭킹은 모든 플레이어와 경쟁하기 위한 랭킹 기능을 제공합니다. GS2-Ranking2 에서는 상위 1000위 플레이어만 랭킹에 참가할 수 있으며, 1000위 이하 플레이어의 점수는 전송하더라도 랭킹에 등록되지 않습니다.
이전 버전인 GS2-Ranking 이 1억 명이 넘는 플레이어의 정확한 순위를 반환할 수 있는 형태로 제공되었기 때문에, 이전 버전과 비교해 사양이 크게 변경되었습니다. GS2-Ranking 에서는 많은 점수를 다룰 수 있는 대신, 15분~24시간 범위에서 지정한 집계 간격으로 집계가 이루어질 때까지 랭킹 등록이 이루어지지 않는 사양으로, 집계할 때마다 참가 인원 수에 따른 비용이 발생했습니다.
GS2-Ranking 을 이용한 개발자들의 피드백을 받아 GS2-Ranking2 는 재설계되었습니다. 구체적으로 다음과 같은 사항을 중요한 피드백으로 받아들여 재설계했습니다.
- 대부분의 사용 사례에서 상위 플레이어만 표시할 수 있으면 충분하다
- 순위 변화를 즉시 랭킹에 반영하고 싶다
- 플레이어 증가에 따른 랭킹 집계 비용 증가는 바람직하지 않다
그 결과, 앞서 설명한 대로 상위 1000명의 플레이어만 랭킹에 참가할 수 있고, 1001위 이하의 플레이어는 “순위권 밖"으로 처리하는 구조가 되었습니다. 대신 점수 등록 직후 바로 랭킹에 반영되며, 일반적인 API 요청 비용 이상의 추가 비용은 발생하지 않습니다.
클러스터 랭킹
대체로 글로벌 랭킹과 동일한 사양이지만, 유일하게 다른 점은 클러스터ID별로 서로 다른 랭킹이 생성된다는 점입니다.
클러스터ID에 GS2-Guild 의 길드ID를 지정함으로써 길드 멤버끼리 경쟁하는 랭킹을 구현하는 데 사용할 수 있습니다.
클러스터 참가 판정
클러스터 랭킹에서는 클러스터의 종류를 정의해 두면 점수 등록 시 클러스터에 참가하고 있는지 확인한 후 점수 등록을 실행할 수 있습니다.
예를 들어 GS2-Guild 의 길드를 클러스터로 하는 랭킹을 구현하는 경우, 랭킹 모드 설정에서 클러스터 종류에 “Gs2Guild::Guild"를 지정하면 점수 등록 시 클러스터ID로 지정된 길드에 점수를 등록하려는 플레이어가 멤버로 등록되어 있는지 확인한 후 점수를 등록하도록 할 수 있습니다.
구독 랭킹
GS2-Ranking 의 스코프 랭킹과 유사한 사양의 랭킹 기능입니다. 다른 플레이어를 구독함으로써 자신의 랭킹 보드에 다른 플레이어의 최신 점수를 포함시킬 수 있습니다.
친구 간 랭킹처럼 플레이어 간 비대칭성이 강한 랭킹을 구현하기 위해 사용합니다.
구독 랭킹에서의 반영 지연
점수 등록을 실행하면, 해당 플레이어를 구독하고 있는 플레이어의 랭킹에 비동기로 점수 등록이 실행됩니다. 이 처리는 일반적으로 1초 이내에 실행되지만, 비동기 처리이기 때문에 점수가 반영될 때까지 약간의 지연이 발생합니다.
시즌
각 랭킹에는 점수 등록을 받는 기간으로 GS2-Schedule 의 이벤트를 연결할 수 있습니다. GS2-Schedule 의 이벤트에는 반복 설정이 가능하며, 각 랭킹은 이벤트가 반복될 때마다 초기화됩니다.
이 기능을 구현하기 위해 GS2-Ranking2 는 각 랭킹에 시즌 이라는 속성을 가지고 있습니다.
랭킹 결과는 시즌별로 저장되며, 과거 시즌의 결과를 언제든지 참조할 수 있습니다.
랭킹 보상
글로벌 랭킹·클러스터 랭킹에서는 랭킹 순위 보상을 설정할 수 있습니다.
보상을 설정하려면 순위 임계값 과 보상 내용 을 설정합니다.
임계값에 3을 지정하면 1, 2, 3위 플레이어에게 보상이 지급되고, 이어서 10을 지정하면 4, 5, 6, 7, 8, 9, 10위 플레이어에게 보상을 설정할 수 있습니다.
순위권 밖 플레이어에 대한 보상
1001을 임계값으로 지정하면 순위권 밖 플레이어에 대한 보상을 설정할 수 있습니다. 1001을 임계값으로 하는 랭킹 보상 설정은 선택 사항이며, 지정하지 않은 경우 순위권 밖 플레이어는 보상을 받을 수 없습니다.
과거 시즌 보상 수령
과거 시즌의 랭킹 보상은 보상 수령 API에 과거 시즌 번호를 지정하여 호출함으로써 언제든지 받을 수 있습니다.
점수의 유효 범위
점수로 등록을 허용할 값의 범위를 설정할 수 있습니다. 이를 통해 명백히 부적절한 점수 등록이 있을 때 등록 처리를 하지 않고 폐기할 수 있습니다.
기간 설정
점수 등록 가능 기간
점수 등록을 받는 기간 설정으로 GS2-Schedule 의 이벤트를 연결할 수 있습니다. 점수 접수 기간 외에 점수를 전송해도 점수는 폐기됩니다.
랭킹 데이터 접근 가능 기간
랭킹 데이터에 접근할 수 있는 기간으로 GS2-Schedule 의 이벤트를 연결할 수 있습니다. 이벤트 종료 후에는 점수 조회도 할 수 없게 하는 경우 등에 활용할 수 있습니다.
트랜잭션 액션
GS2-Ranking2 에서는 다음과 같은 트랜잭션 액션을 제공합니다.
- 검증 액션: 글로벌·클러스터·구독 각 랭킹의 점수 검증
- 소비 액션: 랭킹 보상 수령 이력 기록
“랭킹 점수 검증"을 검증 액션으로 이용함으로써, 특정 점수 이상을 기록한 플레이어만 구매할 수 있는 상품이나 도전할 수 있는 퀘스트와 같은 제한을 트랜잭션 내에서 안전하게 설정할 수 있습니다. 이를 통해 상위 랭커 한정의 특별 보상 획득 이벤트 등을 확실하게 구현할 수 있습니다.
마스터 데이터 운용
마스터 데이터를 등록함으로써 마이크로서비스에서 사용할 수 있는 데이터나 동작을 설정할 수 있습니다.
마스터 데이터의 종류에는 다음이 있습니다.
RankingModel: 랭킹 모드나 보상 임계값 설정
마스터 데이터 등록은 관리 콘솔에서 등록하는 것 외에도, GitHub에서 데이터를 반영하거나 GS2-Deploy를 사용해 CI에서 등록하는 워크플로우를 구성할 수 있습니다.
구현 예제
점수 등록
이 API는 편의성 관점에서 ApplicationAccess 로 호출할 수 있게 되어 있습니다. 그러나 임의의 점수로 전송할 수 있다는 것은 취약점이 됩니다.
따라서 가능하다면 이 API를 클라이언트에서 호출할 수 없도록 설정하고, 신뢰할 수 있는 발신처로부터만 점수 등록을 받을 수 있도록 해야 합니다.
예를 들어 아이템 소지 수량 랭킹을 구현하고 싶다면, GS2-Inventory 의 아이템 획득 시 트리거되는 스크립트에서 점수로 아이템 소지 수량을 등록하는 편이 더 안전하게 처리할 수 있습니다.
글로벌 랭킹
var result = await gs2.Ranking2.Namespace(
namespaceName: "namespace-0001"
).GlobalRankingModel(
rankingName: "ranking-0001"
).GlobalRankingSeason(
gameSession: GameSession,
season: null // current season
).PutGlobalRankingAsync(
score: 100L,
metadata: null
);
var item = await result.ModelAsync(); const auto Future = Gs2->Ranking2->Namespace(
"namespace-0001" // namespaceName
)->GlobalRankingModel(
"ranking-0001" // rankingName
)->GlobalRankingSeason(
GameSession,
TOptional<int64>() // current season
)->PutGlobalRanking(
100L, // score
TOptional<FString>() // metadata
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError())
{
return false;
}
// obtain changed values / result values
const auto Future2 = Future->GetTask().Result()->Model();
Future2->StartSynchronousTask();
if (Future2->GetTask().IsError())
{
return Future2->GetTask().Error();
}
const auto Result = Future2->GetTask().Result();var domain = ez.ranking2.namespace_(
"namespace-0001"
).global_ranking_model(
"ranking-0001"
).global_ranking_season(
0,
null
)
var async_result = await domain.put_global_ranking(
100, # score
null # metadata
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result클러스터 랭킹
var result = await gs2.Ranking2.Namespace(
namespaceName: "namespace-0001"
).ClusterRankingModel(
rankingName: "ranking-0001"
).ClusterRankingSeason(
clusterName: "cluster-0001",
gameSession: GameSession,
season: null // current season
).PutClusterRankingAsync(
score: 100L,
metadata: null
);
var item = await result.ModelAsync(); const auto Future = Gs2->Ranking2->Namespace(
"namespace-0001" // namespaceName
)->ClusterRankingModel(
"ranking-0001" // rankingName
)->ClusterRankingSeason(
"cluster-0001", // clusterName
GameSession,
TOptional<int64>() // current season
)->PutClusterRanking(
100L, // score
TOptional<FString>() // metadata
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError())
{
return false;
}
// obtain changed values / result values
const auto Future2 = Future->GetTask().Result()->Model();
Future2->StartSynchronousTask();
if (Future2->GetTask().IsError())
{
return Future2->GetTask().Error();
}
const auto Result = Future2->GetTask().Result();var domain = ez.ranking2.namespace_(
"namespace-0001"
).cluster_ranking_model(
"ranking-0001"
).cluster_ranking_season(
"cluster-0001",
0,
null
)
var async_result = await domain.put_cluster_ranking(
100, # score
null # metadata
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result구독 랭킹
var result = await gs2.Ranking2.Namespace(
namespaceName: "namespace-0001"
).SubscribeRankingModel(
rankingName: "ranking-0001"
).SubscribeRankingSeason(
gameSession: GameSession,
season: null // current season
).PutSubscribeRankingAsync(
score: 100L,
metadata: null
);
var item = await result.ModelAsync(); const auto Future = Gs2->Ranking2->Namespace(
"namespace-0001" // namespaceName
)->SubscribeRankingModel(
"ranking-0001" // rankingName
)->SubscribeRankingSeason(
GameSession,
TOptional<int64>() // current season
)->PutSubscribeRanking(
100L, // score
TOptional<FString>() // metadata
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError())
{
return false;
}
// obtain changed values / result values
const auto Future2 = Future->GetTask().Result()->Model();
Future2->StartSynchronousTask();
if (Future2->GetTask().IsError())
{
return Future2->GetTask().Error();
}
const auto Result = Future2->GetTask().Result();var domain = ez.ranking2.namespace_(
"namespace-0001"
).subscribe_ranking_model(
"ranking-0001"
).subscribe_ranking_season(
0,
null
)
var async_result = await domain.put_subscribe_ranking(
100, # score
null # metadata
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result순위 조회
글로벌 랭킹
var result = await gs2.Ranking2.Namespace(
namespaceName: "namespace-0001"
).GlobalRankingModel(
rankingName: "ranking-0001"
).GlobalRankingSeason(
gameSession: GameSession,
season: null // current season
).GetGlobalRankingRankAsync(
);
var item = await result.ModelAsync(); const auto Future = Gs2->Ranking2->Namespace(
"namespace-0001" // namespaceName
)->GlobalRankingModel(
"ranking-0001" // rankingName
)->GlobalRankingSeason(
GameSession,
TOptional<int64>() // current season
)->GetGlobalRankingRank(
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError())
{
return false;
}
// obtain changed values / result values
const auto Future2 = Future->GetTask().Result()->Model();
Future2->StartSynchronousTask();
if (Future2->GetTask().IsError())
{
return Future2->GetTask().Error();
}
const auto Result = Future2->GetTask().Result();var domain = ez.ranking2.namespace_(
"namespace-0001"
).global_ranking_model(
"ranking-0001"
).global_ranking_season(
null,
null
).global_ranking_data(
null
)
var async_result = await domain.get_global_ranking_rank(
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result클러스터 랭킹
var result = await gs2.Ranking2.Namespace(
namespaceName: "namespace-0001"
).ClusterRankingModel(
rankingName: "ranking-0001"
).ClusterRankingSeason(
clusterName: "cluster-0001",
gameSession: GameSession,
season: null // current season
).GetClusterRankingRankAsync(
);
var item = await result.ModelAsync(); const auto Future = Gs2->Ranking2->Namespace(
"namespace-0001" // namespaceName
)->ClusterRankingModel(
"ranking-0001" // rankingName
)->ClusterRankingSeason(
"cluster-0001", // clusterName
GameSession,
TOptional<int64>() // current season
)->GetClusterRankingRank(
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError())
{
return false;
}
// obtain changed values / result values
const auto Future2 = Future->GetTask().Result()->Model();
Future2->StartSynchronousTask();
if (Future2->GetTask().IsError())
{
return Future2->GetTask().Error();
}
const auto Result = Future2->GetTask().Result();var domain = ez.ranking2.namespace_(
"namespace-0001"
).cluster_ranking_model(
"ranking-0001"
).cluster_ranking_season(
"cluster-0001",
null,
null
).cluster_ranking_data(
null
)
var async_result = await domain.get_cluster_ranking_rank(
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result구독 랭킹
var result = await gs2.Ranking2.Namespace(
namespaceName: "namespace-0001"
).SubscribeRankingModel(
rankingName: "ranking-0001"
).SubscribeRankingSeason(
gameSession: GameSession,
season: null // current season
).SubscribeRankingData(
scorerUserId: "user-0001"
).GetSubscribeRankingRankAsync(
);
var item = await result.ModelAsync(); const auto Future = Gs2->Ranking2->Namespace(
"namespace-0001" // namespaceName
)->SubscribeRankingModel(
"ranking-0001" // rankingName
)->SubscribeRankingSeason(
GameSession,
TOptional<int64>() // current season
)->SubscribeRankingData(
"user-0001" // scorerUserId
)->GetSubscribeRankingRank(
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError())
{
return false;
}
// obtain changed values / result values
const auto Future2 = Future->GetTask().Result()->Model();
Future2->StartSynchronousTask();
if (Future2->GetTask().IsError())
{
return Future2->GetTask().Error();
}
const auto Result = Future2->GetTask().Result();var domain = ez.ranking2.namespace_(
"namespace-0001"
).subscribe_ranking_model(
"ranking-0001"
).subscribe_ranking_season(
null,
null
).subscribe_ranking_data(
null
)
var async_result = await domain.get_subscribe_ranking_rank(
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result랭킹 조회
글로벌 랭킹
var items = await gs2.Ranking2.Namespace(
namespaceName: "namespace-0001"
).GlobalRankingModel(
rankingName: "ranking-0001"
).GlobalRankingSeason(
gameSession: GameSession,
season: null // current season
).GlobalRankingsAsync(
).ToListAsync(); const auto It = Gs2->Ranking2->Namespace(
"namespace-0001" // namespaceName
)->GlobalRankingModel(
"ranking-0001" // rankingName
)->GlobalRankingSeason(
GameSession,
TOptional<int64>() // current season
)->GlobalRankings(
);
TArray<Gs2::UE5::Ranking2::Model::FEzGlobalRankingDataPtr> Result;
for (auto Item : *It)
{
if (Item.IsError())
{
return false;
}
Result.Add(Item.Current());
}var iterator = ez.ranking2.namespace_(
"namespace-0001"
).global_ranking_model(
"ranking-0001"
).global_ranking_season(
game_session,
null
).global_rankings(
)
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.Ranking2.Namespace(
namespaceName: "namespace-0001"
).ClusterRankingModel(
rankingName: "ranking-0001"
).ClusterRankingSeason(
clusterName: "cluster-0001",
gameSession: GameSession,
season: null // current season
).ClusterRankingsAsync(
).ToListAsync(); const auto It = Gs2->Ranking2->Namespace(
"namespace-0001" // namespaceName
)->ClusterRankingModel(
"ranking-0001" // rankingName
)->ClusterRankingSeason(
"cluster-0001", // clusterName
GameSession,
TOptional<int64>() // current season
)->ClusterRankings(
);
TArray<Gs2::UE5::Ranking2::Model::FEzClusterRankingDataPtr> Result;
for (auto Item : *It)
{
if (Item.IsError())
{
return false;
}
Result.Add(Item.Current());
}var iterator = ez.ranking2.namespace_(
"namespace-0001"
).cluster_ranking_model(
"ranking-0001"
).cluster_ranking_season(
"cluster-0001",
game_session,
null
).cluster_rankings(
)
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.Ranking2.Namespace(
namespaceName: "namespace-0001"
).SubscribeRankingModel(
rankingName: "ranking-0001"
).SubscribeRankingSeason(
gameSession: GameSession,
season: null // current season
).SubscribeRankingsAsync(
).ToListAsync(); const auto It = Gs2->Ranking2->Namespace(
"namespace-0001" // namespaceName
)->SubscribeRankingModel(
"ranking-0001" // rankingName
)->SubscribeRankingSeason(
GameSession,
TOptional<int64>() // current season
)->SubscribeRankings(
);
TArray<Gs2::UE5::Ranking2::Model::FEzSubscribeRankingDataPtr> Result;
for (auto Item : *It)
{
if (Item.IsError())
{
return false;
}
Result.Add(Item.Current());
}var iterator = ez.ranking2.namespace_(
"namespace-0001"
).subscribe_ranking_model(
"ranking-0001"
).subscribe_ranking_season(
game_session,
null
).subscribe_rankings(
)
var async_result = await iterator.load()
if async_result.error != null:
# 오류를 처리
push_error(str(async_result.error))
return
var items = async_result.result랭킹 보상 받기
랭킹 보상은 보상 임계값에 해당하는 플레이어가 API를 호출함으로써 받을 수 있습니다. 수령 처리는 트랜잭션으로 실행되며, 설정된 입수 액션이 순서대로 평가됩니다.
과거 시즌의 보상을 받는 경우에는 season 에 대상 시즌 번호를 지정합니다.
글로벌 랭킹
var transaction = await gs2.Ranking2.Namespace(
namespaceName: "namespace-0001"
).GlobalRankingModel(
rankingName: "ranking-0001"
).GlobalRankingSeason(
gameSession: GameSession,
season: null // current season
).GlobalRankingReceivedReward(
).ReceiveGlobalRankingRewardAsync(
);
await transaction.WaitAsync(); const auto Future = Gs2->Ranking2->Namespace(
"namespace-0001" // namespaceName
)->GlobalRankingModel(
"ranking-0001" // rankingName
)->GlobalRankingSeason(
GameSession,
TOptional<int64>() // current season
)->GlobalRankingReceivedReward(
)->ReceiveGlobalRankingReward(
);
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.ranking2.namespace_(
"namespace-0001"
).global_ranking_model(
"ranking-0001"
).global_ranking_season(
0,
null
).global_ranking_received_reward(
)
var async_result = await domain.receive_global_ranking_reward(
null # config
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result클러스터 랭킹
var transaction = await gs2.Ranking2.Namespace(
namespaceName: "namespace-0001"
).ClusterRankingModel(
rankingName: "ranking-0001"
).ClusterRankingSeason(
clusterName: "cluster-0001",
gameSession: GameSession,
season: null // current season
).ClusterRankingReceivedReward(
).ReceiveClusterRankingRewardAsync(
);
await transaction.WaitAsync(); const auto Future = Gs2->Ranking2->Namespace(
"namespace-0001" // namespaceName
)->ClusterRankingModel(
"ranking-0001" // rankingName
)->ClusterRankingSeason(
"cluster-0001", // clusterName
GameSession,
TOptional<int64>() // current season
)->ClusterRankingReceivedReward(
)->ReceiveClusterRankingReward(
);
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.ranking2.namespace_(
"namespace-0001"
).cluster_ranking_model(
"ranking-0001"
).cluster_ranking_season(
"cluster-0001",
0,
null
).cluster_ranking_received_reward(
)
var async_result = await domain.receive_cluster_ranking_reward(
null # config
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result