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

# GS2-SeasonRating

시즌 레이팅 기능



플레이어 실력에 따라 플레이어를 분류하여, 더 비슷한 실력의 플레이어와의 매치메이킹을 실현하기 위한 기능입니다.
레이트 값에 의한 매치메이킹 기능은 GS2-Matchmaking에 갖추어져 있지만, 최근 늘어나고 있는 현실 시간으로 몇 주에서 몇 달 정도의 시즌을 통해 플레이어를 랭크 매기는 방식에는 대응하지 못했습니다.

이 마이크로서비스는 정해진 기간을 시즌으로 간주하고, 시즌 기간 동안 더 높은 티어를 목표로 대전을 반복하는 게임 사이클을 실현하기 위해 이용할 수 있습니다.
그렇기 때문에 이 기능에서 강함을 나타내는 지표는 레이트 값이 아니라 어느 티어에 속해 있는지로 표현됩니다.

```mermaid
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가 제공하는 랭크 값 검증 기능과 같은 고급 기능을 이용할 수 있습니다.

```mermaid
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 예시입니다.

```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의 세션 생성을 호출하는 스크립트를 설정함으로써, 매치메이킹 성립과 동시에 대응하는 세션을 자동 생성할 수 있습니다.

### 투표용지 획득



**Unity**
```csharp

    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();
```
**Unreal Engine**
```cpp

    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();
```
**Godot**
```gdscript

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

```


### 투표 실행



**Unity**
```csharp

    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"
    );
```
**Unreal Engine**
```cpp

    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;
    }
```
**Godot**
```gdscript

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전 결과나, 참가자 전원의 투표용지를 한 명의 플레이어가 대표하여 투표하는 경우에 이용합니다.



**Unity**
```csharp

    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"
    );
```
**Unreal Engine**
```cpp

    const auto Future = Gs2->SeasonRating->Namespace(
        "namespace-0001" // namespaceName
    )->VoteMultiple(
        SignedBallots,
        GameResults,
        "key-0001" // keyId
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError())
    {
        return false;
    }
```
**Godot**
```gdscript

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

```


## 상세 레퍼런스

[GS2-SeasonRating API 레퍼런스](../../api_reference/season_rating)



