GS2-SeasonRating
플레이어 실력에 따라 플레이어를 분류하여, 더 비슷한 실력의 플레이어와의 매치메이킹을 실현하기 위한 기능입니다. 레이트 값에 의한 매치메이킹 기능은 GS2-Matchmaking에 갖추어져 있지만, 최근 늘어나고 있는 현실 시간으로 몇 주에서 몇 달 정도의 시즌을 통해 플레이어를 랭크 매기는 방식에는 대응하지 못했습니다.
이 마이크로서비스는 정해진 기간을 시즌으로 간주하고, 시즌 기간 동안 더 높은 티어를 목표로 대전을 반복하는 게임 사이클을 실현하기 위해 이용할 수 있습니다. 그렇기 때문에 이 기능에서 강함을 나타내는 지표는 레이트 값이 아니라 어느 티어에 속해 있는지로 표현됩니다.
graph TD
Match["GS2-Matchmaking<br/>매치메이킹 성립"] --> Session["MatchSession 생성"]
Session --> Ballot["각 플레이어가 투표용지를 획득<br/>(Ballot / SignedBallot)"]
Ballot --> GamePlay["게임 플레이"]
GamePlay --> Vote["Vote / VoteMultiple<br/>순위를 투표"]
Vote --> Aggregate{"다수결로 결과 확정"}
Aggregate -- 확정 --> Calc["포인트 변동량을 계산<br/>(TierModel 기반)"]
Calc --> Experience["GS2-Experience에<br/>포인트 / 랭크를 반영"]
Aggregate -- 분단 --> Skip["집계하지 않고 종료"]티어
티어는 일반적으로 브론즈부터 시작하여, 승리를 거듭할수록 실버, 골드, 플래티넘… 으로 올라가는 사양이 일반적입니다. 티어를 올리려면 같은 티어 내의 플레이어와 대전하여, 순위에 따라 변동되는 획득 포인트가 임계값을 넘으면 다음 티어로 올라갈 수 있습니다. 승부에서 패배하면 포인트가 줄어들 수 있으며, 포인트가 임계값을 밑돌면 이전 티어로 내려갈 수 있습니다.
티어 모델 설정 항목
각 티어는 TierModel로 정의하며, 다음 파라미터로 포인트의 증감이나 승급 시의 동작을 제어할 수 있습니다.
| 항목 | 설명 |
|---|---|
metadata | 티어 이름 등 자유 기재(브론즈/실버/골드 등) |
entryFee | 게임 참가 시 소비하는 포인트. 연승이 아니면 승급할 수 없는 난이도 설계에 사용 |
minimumChangePoint | 최하위를 했을 때의 포인트 감산량 |
maximumChangePoint | 최상위를 했을 때의 포인트 가산량 |
raiseRankBonus | 랭크업했을 때 부여하는 보너스 포인트. 직후에 강등되지 않도록 하는 채터링 방지에 유효 |
티어를 넘나드는 대전
레이팅전에서 매치메이킹 규칙은 원칙적으로 같은 티어로 구성해야 합니다. 하지만 플레이어 수 부족 등의 이유로 앞뒤 티어의 플레이어를 포함한 상태로 매치메이킹을 하는 경우에도, 각 플레이어가 속한 티어의 최소·최대 변동량과 순위를 바탕으로 포인트 변동량을 결정합니다. 낮은 티어의 플레이어가 높은 티어의 플레이어를 이겼을 때 보너스 포인트를 가산하는 구조나 그 반대와 같은 구조는 없습니다. 원래 다른 강함의 플레이어끼리 대전시킨 후 포인트 변동량에 조작을 가하는 것보다, 각 티어에 최소한 게임플레이가 성립할 정도의 플레이어가 머무르도록 티어 임계값을 설계하는 것을 권장합니다.
포인트
변동 범위
각 티어마다 최하위일 때의 포인트 감산량, 최상위일 때의 포인트 가산량을 설정할 수 있습니다. 중간 순위를 얻은 경우에는 보고된 순위의 패턴 수로 균등하게 나누어 포인트 가감산량을 결정합니다.
참가료
티어에 따라 게임에 참가할 때 포인트를 소비해야 하도록 설정할 수 있습니다. 이를 통해 연승도 티어를 올리기 위해 필요한 조건으로 표현할 수 있습니다. 참가료 지불은 게임 결과를 서버에 보고하기 위한 투표용지를 획득할 때 지불합니다.
랭크업 보너스
포인트를 가산하여 랭크업했을 때 보너스 포인트를 가산할 수 있습니다. 이를 통해 승급 직후 바로 강등되는 채터링을 방지할 수 있습니다.
사용자 데이터 관리
GS2-SeasonRating은 포인트와 랭크를 관리하지 않습니다. 실제 사용자 데이터 관리는 GS2-Experience를 사용합니다.
시즌의 마스터 데이터로서 시즌의 포인트를 관리하는 GS2-Experience의 경험치 모델을 지정하고, 프로퍼티ID에 시즌 모델의 ID를 지정하여, 경험치에 포인트를, 랭크에 어느 티어에 속해 있는지를 관리합니다. 즉, 포인트의 증감량은 GS2-SeasonRating의 마스터 데이터로 관리하지만, 포인트에 따라 랭크를 결정하는 임계값 관리나 플레이어가 어느 티어에 속해 있는지와 같은 사용자 데이터는 GS2-Experience가 관리합니다. 이를 통해 시즌 종료 후 특정 랭크라면 아이템을 받을 수 있는 것과 같은 교환 처리에 GS2-Experience가 제공하는 랭크 값 검증 기능과 같은 고급 기능을 이용할 수 있습니다.
graph LR SeasonModel["SeasonModel<br/>(GS2-SeasonRating)"] -- "experienceModelId" --> ExpModel["ExperienceModel<br/>(GS2-Experience)"] ExpModel -- "랭크 임계값" --> Status["플레이어의 랭크 상태<br/>(GS2-Experience에 저장)"] Vote["투표 결과 확정"] -- "포인트 증감" --> Status
매치 세션
레이팅전을 진행하려면 먼저 GS2-SeasonRating에 매치 세션 리소스를 생성해야 합니다. GS2-Matchmaking에는 매치메이킹이 성립되었을 때, 매치메이킹이 성립된 게더링 이름으로 매치 세션을 생성하는 연계 기능이 있습니다. 특별한 이유가 없다면 이 방법으로 매치 세션을 생성하시기 바랍니다.
매치 세션의 유효 기간
매치 세션에는 유효 기간을 초 단위로, 최대 24시간의 범위에서 지정할 수 있습니다. 이 기간 내에 결과 투표를 진행해야 하며, 첫 투표로부터 5분이 경과했는데도 모든 투표가 이루어지지 않으면 그 시점에서 결과 집계가 이루어집니다.
결과 투표
매치메이킹이 완료되면 각 플레이어는 매치 세션에서 투표용지를 획득합니다. 투표용지를 사용하여 결과 투표를 진행합니다. 투표 내용에는 대전에 참가한 플레이어의 사용자ID와 순위 목록을 전달합니다.
투표용지 서명
Ballot을 획득하면 SignedBallot으로서 GS2가 서명한 투표용지가 발급됩니다.
서명은 keyId에 지정한 GS2-Key로 이루어지며, 투표 시 서버 측에서 서명 검증이 이루어지므로 투표 내용의 변조를 방지할 수 있습니다.
1경기 복수 투표(VoteMultiple)
싱글 플레이어 시점의 게임(CPU와의 대전 결과를 투표하는 것을 상정)처럼 참가자 전원의 투표용지를 한 명의 플레이어가 한꺼번에 획득하여 투표해야 하는 경우에는 VoteMultiple을 사용합니다.
복수의 SignedBallot을 일괄로 전송할 수 있습니다.
투표 결과의 분단
서버가 접수한 투표의 다수결을 취하려고 할 때, 결과가 동수여서 최종 결과를 확정할 수 없는 경우에는 레이트 계산이 이루어지지 않습니다. 그렇기 때문에 1대1 게임에서는 올바른 레이트 값을 구하기가 어렵습니다. 이 문제를 해결하려면 뒤에서 대전에 직접 관여하지 않는 세 번째 플레이어를 매치메이킹하여, 그 플레이어가 제3자 시점에서 투표하도록 하는 등의 방법이 필요합니다.
스크립트 트리거
GS2-SeasonRating은 스크립트 트리거를 제공하지 않습니다.
트랜잭션 액션
GS2-SeasonRating은 트랜잭션 액션을 제공하지 않습니다.
포인트와 랭크의 증감은 내부적으로 GS2-Experience의 트랜잭션 액션을 통해 반영됩니다. 시즌 종료 후 특정 랭크 이상의 플레이어에게 보상을 배포하는 경우에는 GS2-Exchange의 교환 처리에 GS2-Experience의 VerifyRankAction을 조합하는 등의 구현이 일반적입니다.
마스터 데이터 운용
마스터 데이터를 등록함으로써 마이크로서비스에서 이용 가능한 데이터나 동작을 설정할 수 있습니다.
마스터 데이터의 종류에는 다음이 있습니다.
SeasonModel: 시즌에 포함되는 티어와, 연결되는 GS2-Experience의 경험치 모델TierModel: 각 티어별 포인트 변동량·참가료·랭크업 보너스
마스터 데이터 등록은 관리 콘솔에서 등록하는 것 외에도, GitHub에서 데이터를 반영하거나 GS2-Deploy를 사용하여 CI에서 등록하는 등의 워크플로우를 구성할 수 있습니다.
다음은 마스터 데이터의 JSON 예시입니다.
{
"version": "2023-04-05",
"seasonModels": [
{
"name": "season-0001",
"metadata": "시즌 1",
"experienceModelId": "grn:gs2:{region}:{ownerId}:experience:experience-0001:model:season",
"tiers": [
{
"metadata": "브론즈",
"raiseRankBonus": 100,
"entryFee": 0,
"minimumChangePoint": 10,
"maximumChangePoint": 30
},
{
"metadata": "실버",
"raiseRankBonus": 150,
"entryFee": 10,
"minimumChangePoint": 20,
"maximumChangePoint": 40
}
]
}
]
}구현 예제
현재 포인트나 랭크 획득
GS2-Experience의 API를 이용하여 상태를 취득해 주세요. “NamespaceName”, “ExperienceName"에는 시즌 마스터 데이터에 지정한 값을, “PropertyId"에는 시즌 모델 ID를 지정해 주세요.
매치 세션 생성
매치 세션 생성은 게임 엔진용 SDK에서는 처리할 수 없습니다.
GS2-Matchmaking의 연계 기능을 사용해 주세요. GS2-Matchmaking의 네임스페이스 설정에서 매치메이킹 성립 시 스크립트에 GS2-SeasonRating의 세션 생성을 호출하는 스크립트를 설정함으로써, 매치메이킹 성립과 동시에 대응하는 세션을 자동 생성할 수 있습니다.
투표용지 획득
var item = await gs2.SeasonRating.Namespace(
namespaceName: "namespace-0001"
).Me(
gameSession: GameSession
).Ballot(
seasonName: "rating-0001",
sessionName: "gathering-0001",
numberOfPlayer: 4,
keyId: "key-0001"
).ModelAsync(); const auto Future = Gs2->SeasonRating->Namespace(
"namespace-0001" // namespaceName
)->Me(
GameSession
)->Ballot(
"rating-0001", // seasonName
"gathering-0001", // sessionName
4, // numberOfPlayer
"key-0001" // keyId
)->Model();
Future->StartSynchronousTask();
if (Future->GetTask().IsError())
{
return false;
}
const auto Result = Future->GetTask().Result();var domain = ez.season_rating.namespace_(
"namespace-0001"
).me(game_session).ballot(
"season-0001",
"gathering-0001",
4,
"key-0001"
)
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.SeasonRating.Namespace(
namespaceName: "namespace-0001"
).VoteAsync(
ballotBody: "ballotBody",
ballotSignature: "ballotSignature",
gameResults: new List<Gs2.Unity.Gs2SeasonRating.Model.EzGameResult> {
new Gs2.Unity.Gs2SeasonRating.Model.EzGameResult() {
Rank = 1,
UserId = "user-0001",
},
new Gs2.Unity.Gs2SeasonRating.Model.EzGameResult() {
Rank = 2,
UserId = "user-0002",
},
new Gs2.Unity.Gs2SeasonRating.Model.EzGameResult() {
Rank = 2,
UserId = "user-0003",
},
new Gs2.Unity.Gs2SeasonRating.Model.EzGameResult() {
Rank = 3,
UserId = "user-0004",
},
},
keyId: "key-0001"
); const auto Future = Gs2->SeasonRating->Namespace(
"namespace-0001" // namespaceName
)->Vote(
"ballotBody", // ballotBody
"ballotSignature", // ballotSignature
[]
{
auto v = MakeShared<TArray<TSharedPtr<Gs2::UE5::SeasonRating::Model::FEzGameResult>>>();
v->Add(
MakeShared<Gs2::UE5::SeasonRating::Model::FEzGameResult>()
->WithRank(TOptional<int32>(1))
->WithUserId(TOptional<FString>("user-0001"))
);
v->Add(
MakeShared<Gs2::UE5::SeasonRating::Model::FEzGameResult>()
->WithRank(TOptional<int32>(2))
->WithUserId(TOptional<FString>("user-0002"))
);
v->Add(
MakeShared<Gs2::UE5::SeasonRating::Model::FEzGameResult>()
->WithRank(TOptional<int32>(2))
->WithUserId(TOptional<FString>("user-0003"))
);
v->Add(
MakeShared<Gs2::UE5::SeasonRating::Model::FEzGameResult>()
->WithRank(TOptional<int32>(3))
->WithUserId(TOptional<FString>("user-0004"))
);
return v;
}(), // gameResults
"key-0001" // keyId
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError())
{
return false;
}var domain = ez.season_rating.namespace_(
"namespace-0001"
)
var async_result = await domain.vote(
"ballotBody...", # ballot_body
"ballotSignature...", # ballot_signature
[
Gs2SeasonRatingEzGameResult.new()
.with_rank(1)
.with_user_id("user-0001"),
Gs2SeasonRatingEzGameResult.new()
.with_rank(2)
.with_user_id("user-0002"),
Gs2SeasonRatingEzGameResult.new()
.with_rank(2)
.with_user_id("user-0003"),
Gs2SeasonRatingEzGameResult.new()
.with_rank(3)
.with_user_id("user-0004"),
], # game_results
"key-0001" # key_id
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result복수의 투표용지를 한꺼번에 투표
CPU전 결과나, 참가자 전원의 투표용지를 한 명의 플레이어가 대표하여 투표하는 경우에 이용합니다.
var result = await gs2.SeasonRating.Namespace(
namespaceName: "namespace-0001"
).VoteMultipleAsync(
signedBallots: new [] {
signedBallot1,
signedBallot2,
},
gameResults: new [] {
new Gs2.Unity.Gs2SeasonRating.Model.EzGameResult() {
Rank = 1,
UserId = "user-0001",
},
new Gs2.Unity.Gs2SeasonRating.Model.EzGameResult() {
Rank = 2,
UserId = "user-0002",
},
},
keyId: "key-0001"
); const auto Future = Gs2->SeasonRating->Namespace(
"namespace-0001" // namespaceName
)->VoteMultiple(
SignedBallots,
GameResults,
"key-0001" // keyId
);
Future->StartSynchronousTask();
if (Future->GetTask().IsError())
{
return false;
}var domain = ez.season_rating.namespace_(
"namespace-0001"
)
var async_result = await domain.vote_multiple(
[
Gs2SeasonRatingEzSignedBallot.new()
.with_body("aaa")
.with_signature("bbb"),
Gs2SeasonRatingEzSignedBallot.new()
.with_body("aaa")
.with_signature("bbb"),
Gs2SeasonRatingEzSignedBallot.new()
.with_body("aaa")
.with_signature("bbb"),
Gs2SeasonRatingEzSignedBallot.new()
.with_body("aaa")
.with_signature("bbb"),
], # signed_ballots
[
Gs2SeasonRatingEzGameResult.new()
.with_rank(1)
.with_user_id("user-0001"),
Gs2SeasonRatingEzGameResult.new()
.with_rank(2)
.with_user_id("user-0002"),
Gs2SeasonRatingEzGameResult.new()
.with_rank(2)
.with_user_id("user-0003"),
Gs2SeasonRatingEzGameResult.new()
.with_rank(3)
.with_user_id("user-0004"),
], # game_results
"key-0001" # key_id
)
if async_result.error != null:
push_error(str(async_result.error))
return
var result = async_result.result