Documentation index for AI agents

GS2-Formation

파티·장비 편성 기능

소유하고 있는 리소스를 조합하여 하나의 무언가를 편성한다는 사양은 일반적입니다. 여러 캐릭터를 편성하여 파티를 만들거나, 무기·방어구 같은 아이템을 편성하여 장착하는 것 등이 그 예입니다.

GS2-Formation은 “어떤 슬롯이 존재하는지”, “각 슬롯에 무엇을 장착할 수 있는지”, “같은 종류의 편성을 몇 개까지 보유할 수 있는지"를 마스터 데이터로 정의하고, 플레이어별 편성 내용을 관리하는 마이크로서비스입니다.

용어

graph LR
  MoldModel --> FormModel
  FormModel --> SlotModel
  PropertyFormModel --> SlotModel2[SlotModel]

  Mold --> Form
  Form --> Slot
  PropertyForm --> Slot2[Slot]
용어설명
MoldModel같은 종류의 편성(파티·장비 세트 등)을 여러 개 보유하기 위한 타입. 용량(저장 슬롯)의 개념을 가짐
FormModelMold에 연결된 Form의 구성 정의. 어떤 슬롯을 가지는지를 정의
PropertyFormModel프로퍼티ID로 참조하는 단독 편성 정의. Mold를 거치지 않고 캐릭터 1개당 편성 1개와 같은 형태로 사용
SlotModel슬롯의 정의. 슬롯에 장착 가능한 프로퍼티를 정규 표현식으로 제한할 수 있음
Mold플레이어별 Mold 실체. capacity를 가지며 최대 용량까지 Form을 생성할 수 있음
FormMold 내의 편성 인스턴스. index로 식별됨
PropertyForm프로퍼티ID 단위의 편성 인스턴스
Slot플레이어가 실제로 장착하고 있는 리소스를 보유

캐릭터의 장비 기능을 구현할 때 무기·투구·건틀릿·상의·하의·신발 같은 여러 슬롯을 준비하고, 각 슬롯에 적합한 아이템만 장착할 수 있도록 설정할 수 있습니다.

Form Model에서는 어떤 슬롯이 존재하는지, 각 슬롯에는 어떤 아이템을 편성할 수 있는지를 마스터 데이터로 정의합니다.

Mold와 PropertyForm의 구분 사용

flowchart LR
  subgraph Mold
    M1["Form #0"]
    M2["Form #1"]
    M3["Form #2 (빈 슬롯)"]
  end
  subgraph PropertyForm
    P1["character-001의 편성"]
    P2["character-002의 편성"]
  end
  • Mold를 사용하는 경우: 파티 편성 1~N처럼, 편성 세트를 번호(index)로 관리하고 최대 보유 수(capacity)를 성장 요소로 확장하고 싶은 경우
  • PropertyForm을 사용하는 경우: 캐릭터별·장비 세트별 등, 외부 리소스ID(propertyId)에 1대1로 연결되는 편성

MoldMoldModelinitialMaxCapacity(초기 용량)와 maxCapacity(확장 상한)를 가지며, 플레이어의 성장이나 수익화 시책에 따라 용량을 늘리거나 줄일 수 있습니다.

슬롯에 장착 가능한 대상

Form / PropertyForm의 Slot에는 다음 리소스를 장착할 수 있습니다. 장착 시에는 GS2가 발행하는 “소유 증명 서명"을 SlotWithSignature로 전달합니다.

propertyType장착 대상
gs2_inventoryGS2-Inventory에서 관리하는 ItemSet
gs2_simple_inventoryGS2-Inventory(Simple)에서 관리하는 SimpleItem
gs2_dictionaryGS2-Dictionary에서 관리하는 Entry

슬롯 모델(SlotModel)의 propertyRegex에 정규 표현식을 설정하면 특정 슬롯에 장착 가능한 프로퍼티ID를 제한할 수 있습니다. 예를 들어 “무기 슬롯에는 weapon-*인 Item만 장착 가능"과 같은 제한을 구현할 수 있습니다.

마스터 데이터 운용

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

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

  • MoldModel: Mold의 저장 슬롯 구성
  • FormModel: Mold 하위 Form의 슬롯 구성
  • PropertyFormModel: PropertyForm의 슬롯 구성

마스터 데이터 JSON 예시

{
  "version": "2019-09-09",
  "moldModels": [
    {
      "name": "party",
      "metadata": "파티 편성",
      "initialMaxCapacity": 3,
      "maxCapacity": 10,
      "formModel": {
        "name": "party",
        "slots": [
          { "name": "leader", "propertyRegex": "character-.*" },
          { "name": "member-1", "propertyRegex": "character-.*" },
          { "name": "member-2", "propertyRegex": "character-.*" }
        ]
      }
    }
  ],
  "propertyFormModels": [
    {
      "name": "equipment",
      "metadata": "캐릭터 장비",
      "slots": [
        { "name": "weapon", "propertyRegex": "weapon-.*" },
        { "name": "armor",  "propertyRegex": "armor-.*" }
      ]
    }
  ]
}

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

스크립트 트리거

네임스페이스에 updateMoldScript, updateFormScript, updatePropertyFormScript를 설정하면 편성 데이터의 갱신 전후로 커스텀 스크립트를 실행할 수 있습니다. 스크립트는 동기·비동기 실행 방식을 선택할 수 있으며, 비동기의 경우 GS2-Script나 Amazon EventBridge를 통한 외부 연동에도 대응합니다.

설정 가능한 주요 이벤트 트리거와 스크립트 설정명은 다음과 같습니다.

  • updateMoldScript(완료 알림: updateMoldDone): Mold 갱신 전후
  • updateFormScript(완료 알림: updateFormDone): Form 갱신 전후
  • updatePropertyFormScript(완료 알림: updatePropertyFormDone): PropertyForm 갱신 전후

트랜잭션 액션

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

  • 검증 액션: 용량(저장 수량 상한)의 검증
  • 소비 액션: 용량의 감산
  • 입수 액션: 용량의 가산, 용량의 설정, 편성 내용(Form / PropertyForm)의 설정, 편성되어 있는 리소스에 대한 입수 액션의 적용

“편성되어 있는 리소스에 대한 입수 액션의 적용"을 입수 액션으로 활용하면, 특정 파티 슬롯에 편성되어 있는 캐릭터에 직접 경험치를 가산하는 것과 같은 처리가 가능해집니다. 또한, “용량의 가산"을 보상으로 설정함으로써 특정 미션을 달성했을 때 편성 슬롯을 자동으로 확장하는 것과 같은 운용도 가능합니다.

구현 예제

Mold에서의 편성 영속화

Form의 Slot에는 GS2-Inventory에서 관리하는 ItemSet / SimpleItem 또는 GS2-Dictionary에서 관리하는 Entry를 등록할 수 있습니다. 각각을 설정하려면 소유 증명 서명을 첨부해야 합니다.

소유 증명 서명의 취득 방법에 대해서는 각 서비스의 설명을 확인해 주세요.

propertyType의 종류

  • gs2_inventory – GS2-Inventory에서 관리하는 ItemSet을 장착
  • gs2_simple_inventory – GS2-Inventory에서 관리하는 SimpleItem을 장착
  • gs2_dictionary – GS2-Dictionary에서 관리하는 Entry를 장착
    var result = await gs2.Formation.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Mold(
        moldName: "mold-0001"
    ).Form(
        index: 0
    ).SetFormAsync(
        slots: new [] {
            new Gs2.Unity.Gs2Formation.Model.EzSlotWithSignature
            {
                Name = "slot-0001",
                PropertyType = "gs2_dictionary",
                Body = "body",
                Signature = "signature",
            },
        },
        keyId: "key-0001"
    );
    const auto Domain = Gs2->Formation->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Mold(
        "mold-0001" // moldName
    )->Form(
        0 // index
    );
    const auto Future = Domain->SetForm(
        []
        {
            const auto v = MakeShared<TArray<TSharedPtr<Gs2::Formation::Model::FSlotWithSignature>>>();
            v->Add({'name': 'slot-0001', 'propertyType': 'gs2_dictionary', 'body': 'body', 'signature': 'signature'});
            return v;
        }(),
        "key-0001"
    );
    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 false;
    const auto Result = Future2->GetTask().Result();
var domain = ez.formation.namespace_(
        "namespace-0001"
    ).me(game_session).mold(
        "mold-0001"
    ).form(
        0
    )

var async_result = await domain.set_form(
    [
        Gs2FormationEzSlotWithSignature.new()
            .with_name("slot-0001")
            .with_property_type("gs2_dictionary")
            .with_body("body")
            .with_signature("signature"),
    ], # slots
    "key-0001" # key_id
)
if async_result.error != null:
    push_error(str(async_result.error))
    return

var result = async_result.result

Mold의 용량 인상

용량(저장 수량 상한)의 인상은 게임 엔진용 SDK에서 직접 처리할 수 없습니다. 클라이언트 주도의 용량 조작을 허용하면 부정 조작의 대상이 될 수 있기 때문에, 용량 변경은 트랜잭션 액션을 통해 실시하는 설계로 되어 있습니다.

GS2-Exchange에서 과금 통화와의 교환 등의 보상으로 용량을 인상하도록 하십시오. GS2-Exchange의 acquireActions에 Formation의 용량 가산 액션을 설정함으로써, 과금 아이템이나 미션 보상 지급의 연장선상에서 용량 확장을 구현할 수 있습니다.

Mold 정보 취득

Mold 단독 정보(현재 용량)를 취득합니다.

    var item = await gs2.Formation.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Mold(
        moldName: "mold-0001"
    ).ModelAsync();
    const auto Domain = Gs2->Formation->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Mold(
        "mold-0001" // moldName
    );
    const auto Future = Domain->Model();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
    const auto Item = Future->GetTask().Result();
var domain = ez.formation.namespace_(
        "namespace-0001"
    ).me(game_session).mold(
        "mold-0001"
    )

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

var result = async_result.result

Mold에서의 편성 내용 목록 취득

    var items = await gs2.Formation.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Mold(
        moldName: "mold-0001"
    ).FormsAsync(
    ).ToListAsync();
    const auto Domain = Gs2->Formation->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Mold(
        "mold-0001" // moldName
    );
    const auto It = Domain->Forms(
    );
    TArray<Gs2::UE5::Formation::Model::FEzMoldPtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }
var iterator = ez.formation.namespace_(
        "namespace-0001"
    ).me(
        game_session
    ).mold(
        "mold-0001"
    ).forms(
    )

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

var items = async_result.result

Mold에서의 편성 내용 취득

    var item = await gs2.Formation.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Mold(
        moldName: "mold-0001"
    ).Form(
        index: 0
    ).ModelAsync();
    const auto Domain = Gs2->Formation->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Mold(
        "mold-0001" // moldName
    )->Form(
        0 // index
    );
    const auto Future = Domain->Model();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
    const auto Item = Future->GetTask().Result();
var domain = ez.formation.namespace_(
        "namespace-0001"
    ).me(game_session).mold(
        "mold-0001"
    ).form(
        0
    )

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

var result = async_result.result

Form 삭제

불필요해진 편성 내용을 삭제합니다. Mold의 용량 자체는 유지되며, index의 슬롯이 빈 슬롯으로 재사용 가능해집니다.

    var result = await gs2.Formation.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Mold(
        moldName: "mold-0001"
    ).Form(
        index: 0
    ).DeleteFormAsync(
    );
    const auto Future = Gs2->Formation->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Mold(
        "mold-0001" // moldName
    )->Form(
        0 // index
    )->DeleteForm(
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
var domain = ez.formation.namespace_(
        "namespace-0001"
    ).me(game_session).mold(
        "mold-0001"
    ).form(
        0
    )

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

var result = async_result.result

Property Form에서의 편성 영속화

Form의 Slot에는 GS2-Inventory에서 관리하는 ItemSet 또는 GS2-Dictionary에서 관리하는 Entry를 등록할 수 있습니다. 각각을 설정하려면 소유 증명 서명을 첨부해야 합니다.

소유 증명 서명의 취득 방법에 대해서는 각 서비스의 설명을 확인해 주세요.

    var result = await gs2.Formation.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).PropertyForm(
        formModelName: "form-0001",
        propertyId: "property-0001"
    ).SetPropertyFormAsync(
        slots: new [] {
            new Gs2.Unity.Gs2Formation.Model.EzSlotWithSignature
            {
                Name = "slot-0001",
                PropertyType = "gs2_dictionary",
                Body = "body",
                Signature = "signature",
            },
        },
        keyId: "key-0001"
    );
    const auto Domain = Gs2->Formation->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->PropertyForm(
        "form-0001", // formModelName
        "property-0001" // propertyId
    );
    const auto Future = Domain->SetPropertyForm(
        []
        {
            const auto v = MakeShared<TArray<TSharedPtr<Gs2::Formation::Model::FSlotWithSignature>>>();
            v->Add({'name': 'slot-0001', 'propertyType': 'gs2_dictionary', 'body': 'body', 'signature': 'signature'});
            return v;
        }(),
        "key-0001"
    );
    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 false;
    const auto Result = Future2->GetTask().Result();
var domain = ez.formation.namespace_(
        "namespace-0001"
    ).me(game_session).property_form(
        "form-0001",
        "property-0001"
    )

var async_result = await domain.set_property_form(
    [
        Gs2FormationEzSlotWithSignature.new()
            .with_name("slot-0001")
            .with_property_type("gs2_dictionary")
            .with_body("body")
            .with_signature("signature"),
    ], # slots
    "key-0001" # key_id
)
if async_result.error != null:
    push_error(str(async_result.error))
    return

var result = async_result.result

Property Form에서의 편성 내용 취득

    var item = await gs2.Formation.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).PropertyForm(
        formModelName: "form-0001",
        propertyId: "property-0001"
    ).ModelAsync();
    const auto Domain = Gs2->Formation->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->PropertyForm(
        "form-0001", // formModelName
        "property-0001" // propertyId
    );
    const auto Future = Domain->Model();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
    const auto Item = Future->GetTask().Result();
var domain = ez.formation.namespace_(
        "namespace-0001"
    ).me(game_session).property_form(
        "form-0001",
        "property-0001"
    )

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

var result = async_result.result

Property Form 삭제

    var result = await gs2.Formation.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).PropertyForm(
        formModelName: "form-0001",
        propertyId: "property-0001"
    ).DeletePropertyFormAsync(
    );
    const auto Future = Gs2->Formation->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->PropertyForm(
        "form-0001", // formModelName
        "property-0001" // propertyId
    )->DeletePropertyForm(
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
var domain = ez.formation.namespace_(
        "namespace-0001"
    ).me(game_session).property_form(
        "form-0001",
        "property-0001"
    )

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

var result = async_result.result

편성 정보에 대한 서명 취득

Form / PropertyForm의 내용이 변조되지 않았음을 보증한 형태로 다른 시스템에 전달하고 싶은 경우, 서명이 포함된 편성 정보를 취득할 수 있습니다. 배틀 시작 시 서버로 편성 내용을 전송하는 사용 사례 등에서 활용할 수 있습니다.

    var domain = await gs2.Formation.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Mold(
        moldName: "mold-0001"
    ).Form(
        index: 0
    ).GetFormWithSignatureAsync(
        keyId: "key-0001"
    );
    const auto Future = Gs2->Formation->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Mold(
        "mold-0001" // moldName
    )->Form(
        0 // index
    )->GetFormWithSignature(
        "key-0001" // keyId
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
var domain = ez.formation.namespace_(
        "namespace-0001"
    ).me(game_session).mold(
        "mold-0001"
    ).form(
        0
    )

var async_result = await domain.get_form_with_signature(
    "key-0001" # key_id
)
if async_result.error != null:
    push_error(str(async_result.error))
    return

var result = async_result.result

편성 리소스의 관리와 주의 사항

프로퍼티 지정 방법

폼의 슬롯에 편성하는 프로퍼티는 프로퍼티ID로 지정합니다. 슬롯 모델의 propertyRegex로 프로퍼티ID의 정규 표현식을 지정함으로써, 해당 슬롯에 편성할 수 있는 값을 한정할 수 있습니다.

소유 증명 서명과 편성 후 주의 사항

GS2-Inventory에서 관리하는 아이템을 슬롯에 설정하는 경우, GS2-Inventory가 발행하는 서명이 포함된 아이템 세트(소유 증명 서명)를 사용해 편성 대상으로 지정할 수 있습니다. 서명이 포함된 아이템 세트가 보증하는 “소유하고 있음"은 편성 시점에서의 보증이며, 편성 후에 해당 아이템을 매각·소비하더라도 폼의 데이터는 그대로 남아 있습니다. 이 때문에 GS2-Inventory에서 아이템을 소비·매각하는 경우, 사전에 GS2-Formation의 편성에서 사용되고 있지 않은지를 클라이언트 또는 스크립트로 확인할 필요가 있습니다.

ItemSet.referenceOf를 이용한 편성 중 아이템 삭제 방지

스탠다드 인벤토리(GS2-Inventory)를 사용하는 경우, ItemSetreferenceOf 필드를 이용함으로써 편성 중인 아이템이 실수로 소비·삭제되는 것을 방지할 수 있습니다.

폼의 슬롯에 아이템을 설정할 때, 해당 슬롯을 식별하는 문자열(GRN 등)을 ItemSet.referenceOf에 설정합니다. 스탠다드 인벤토리의 인벤토리 모델 설정에는 “referenceOf에 값이 존재하는 경우 소비를 금지한다“는 옵션이 있습니다. 이 옵션을 활성화하면 ItemSet.referenceOf가 비어 있지 않은 아이템은 소비·매각할 수 없게 되므로, 편성 중인 아이템에 대해 시스템 레벨에서 삭제 제한을 걸 수 있습니다.

상세 레퍼런스