Documentation index for AI agents

GS2-Dictionary

도감 기능

GS2-Dictionary 에서는 게임 내에서 입수한 아이템이나 캐릭터의 도감 기능을 구현합니다.

기본적으로 GS2-Inventory 의 단순화된 구현 버전이라고 생각하시면 되며, 입수 완료/미입수라는 2값의 소지 상태를 관리할 수 있습니다.

도감 이외의 용도로도 사용할 수 있습니다. 예를 들어 아바타 파츠의 소지 상태가 좋은 예입니다. 아바타 파츠를 가지고 있는지 여부는 파츠별로 2값으로 상태를 관리할 수 있으면 충분하므로 GS2-Dictionary 에 적합합니다. 그 외에도 완료된 튜토리얼 단계 관리, 해금된 컷신 관리, 해금된 BGM 목록 등 2값 상태로 표현할 수 있는 요소 전반에 활용할 수 있습니다.

graph TD
  Boss["보스를 토벌"] -- 보상으로 엔트리 추가 --> Dictionary["GS2-Dictionary 에 기록"]
  Shop["상점에서 구매"] -- 보상으로 엔트리 추가 --> Dictionary
  Dictionary --> Browse["플레이어가 도감을 열람"]
  Dictionary -- 소지 증명 서명 --> Formation["GS2-Formation 등에서 이용"]

GS2-Inventory 와의 차이점

스택 개념의 유무

GS2-Inventory 는 동일한 아이템을 여러 개 소지할 때 스택이라는 개념이 있습니다. 이는 포션을 최대 99개까지 스택할 수 있고, 99개를 초과하면 두 번째 스택을 생성하는 것과 같은 사양입니다.

이 사양이 있기 때문에 GS2-Inventory 에서는 “포션의 소지 수량을 조회한다"는 API 의 반환값이 리스트 형태입니다. 이는 포션이 여러 스택으로 존재할 가능성이 있기 때문입니다. 이 사양은 포션과 같은 데이터를 관리하는 데는 편리하지만, 도감과 같은 단순한 소지 상태를 관리하기에는 과도한 사양이며, 리스트를 다루면 코드량도 늘어납니다.

입수 처리로 여러 엔트리를 등록 가능

GS2-Inventory 는 포션 입수와 엘릭서 입수를 각각 별도의 API 요청으로 처리해야 합니다. GS2-Dictionary 는 한 번의 API 요청으로 최대 100개의 엔트리를 등록할 수 있습니다.

이를 통해 퀘스트 클리어 보상으로 “쓰러뜨린 몬스터 전종을 한꺼번에 도감에 등록"과 같은 처리를 한 번의 요청으로 효율적으로 수행할 수 있습니다.

엔트리의 검증과 삭제

GS2-Dictionary 에서는 한 번 기록한 엔트리를 삭제하거나, 특정 엔트리를 입수했는지(또는 미입수인지) 검증할 수 있습니다. 기간 한정 이벤트 아이템의 소지 상태를 초기화하거나, 특정 아이템을 입수한 경우에만 퀘스트를 시작할 수 있도록 제한하는 등의 운영이 가능합니다.

기능 비교

항목GS2-DictionaryGS2-Inventory
상태 관리소지/미소지의 2값수량·스택 관리
1회 요청당 등록 수최대 100건1건씩
즐겨찾기 기능있음없음
소지 증명 서명있음있음(ItemSet 단위)
주요 용도도감, 아바타 파츠, 해금 상태일반적인 아이템 소지

마스터 데이터(EntryModel)

도감에 등록 가능한 엔트리를 마스터 데이터로 정의합니다. EntryModel 의 주요 설정 항목은 다음과 같습니다.

항목설명
name엔트리 이름(플레이어별 기록 키)
metadata클라이언트에서 이용하는 임의의 메타데이터(표시 이름, 이미지 참조, 희귀도 등)

마스터 데이터의 JSON 예:

{
  "version": "2020-04-30",
  "entryModels": [
    {
      "name": "monster-0001",
      "metadata": "{\"displayName\":\"슬라임\",\"rarity\":1}"
    },
    {
      "name": "monster-0002",
      "metadata": "{\"displayName\":\"고블린\",\"rarity\":2}"
    }
  ]
}

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

즐겨찾기 기능

GS2-Dictionary 에서는 기록된 엔트리에 “즐겨찾기"를 지정할 수 있습니다. AddLikesDeleteLikes 로 즐겨찾기 목록을 관리하고, Likes API 로 목록 조회나 변경 알림 구독이 가능합니다. 즐겨찾기 정보를 활용하면 도감 화면의 필터링이나 UI 에서의 우선 표시를 구현할 수 있습니다.

즐겨찾기 엔트리는 내부적으로 LikeToc 라는 단위로 정리되어 저장되므로, 한 사용자가 대량의 즐겨찾기를 등록하더라도 효율적으로 조회할 수 있습니다.

소지 증명 서명

GS2-Dictionary 는 엔트리의 소지 상태에 대해 서명된 데이터를 발급할 수 있습니다. 다른 GS2 마이크로서비스와 연동할 때, 서버 간 통신을 거치지 않고도 “실제로 해당 엔트리를 소지하고 있음"을 보증할 수 있습니다.

sequenceDiagram
  participant Player
  participant Dictionary as GS2-Dictionary
  participant Formation as GS2-Formation
  Player ->> Dictionary: GetEntryWithSignature(entry, keyId)
  Dictionary -->> Player: Body + Signature
  Player ->> Formation: SetForm(itemId, body, signature)
  Formation ->> Formation: 서명 검증
  Formation -->> Player: 성공

검증하는 측의 서비스는 body 안에 포함된 정보(엔트리 이름이나 사용자ID)를 signature 를 사용하여 검증할 수 있습니다.

스크립트 트리거

네임스페이스에 entryScriptduplicateEntryScript 를 설정하면 엔트리 등록 전후나 중복 등록 시에 커스텀 스크립트를 호출할 수 있습니다. 스크립트는 동기·비동기 실행 방식을 선택할 수 있으며, 비동기에서는 GS2-Script 나 Amazon EventBridge 를 이용한 외부 연동도 가능합니다.

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

  • entryScript(완료 알림: entryDone): 엔트리 등록 전후
  • duplicateEntryScript: 이미 등록된 엔트리를 재등록했을 때

duplicateEntryScript 를 이용하면, 예를 들어 “이미 도감에 등록된 희귀 몬스터를 다시 드롭한 경우에는 대신 다른 통화를 지급한다"와 같은 전환 처리를 구현할 수 있습니다.

트랜잭션 액션

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

  • 검증 액션: 엔트리 소지 상황 검증
  • 소비 액션: 엔트리 삭제
  • 입수 액션: 엔트리 추가

“엔트리 소지 상황 검증"을 검증 액션으로 이용하면, 특정 몬스터를 토벌 완료한(도감에 등록된) 플레이어만 도전할 수 있는 퀘스트나, 특정 아이템을 한 번이라도 입수한 적이 있는 플레이어만 구매할 수 있는 상품과 같은 제한을 설정할 수 있습니다.

도감에 기록하는 방법

도감에 대한 기록(Entry 추가)은 게임 엔진용 SDK 에서 직접 호출할 수 없습니다. GS2 의 보안 모델상, 클라이언트에서 일방적으로 변경하는 것을 금지하고 서버가 신뢰하는 경로에서만 추가되도록 설계되어 있기 때문입니다.

구체적으로는 다음과 같은 형태로 트랜잭션 액션에 포함시켜 사용합니다.

  • GS2-Quest 의 퀘스트 클리어 보상으로 “쓰러뜨린 몬스터를 도감에 등록"하는 입수 액션을 설정
  • GS2-Showcase 에서 구매한 아이템의 보상으로 “해당하는 엔트리를 도감에 등록"하는 입수 액션을 설정
  • GS2-Mission 의 미션 달성 보상으로 “특정 엔트리를 등록"하는 입수 액션을 설정

구현 예제

도감에 기록 가능한 마스터 데이터 목록 조회

    var items = await gs2.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).EntryModelsAsync(
    ).ToListAsync();
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    );
    const auto It = Domain->EntryModels(
    );
    TArray<Gs2::UE5::Dictionary::Model::FEzEntryModelPtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }
var iterator = ez.dictionary.namespace_(
        "namespace-0001"
    ).entry_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.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).EntriesAsync(
    ).ToListAsync();
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    );
    const auto It = Domain->Entries(
    );
    TArray<Gs2::UE5::Dictionary::Model::FEzEntryPtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }
var iterator = ez.dictionary.namespace_(
        "namespace-0001"
    ).me(
        game_session
    ).entries(
    )

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.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Entry(
        entryModelName: "entry-0001"
    ).ModelAsync();

    var acquiredAt = item.AcquiredAt;
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Entry(
        "entry-0001" // entryModelName
    );
    const auto Future = Domain->Model();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
    const auto Result = Future->GetTask().Result();
    const auto AcquiredAt = Result->GetAcquiredAt();
var domain = ez.dictionary.namespace_(
        "namespace-0001"
    ).me(game_session).entry(
        "entry-0001"
    )

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

var result = async_result.result

소지 증명 서명 조회

GS2 내의 다른 마이크로서비스와 연동할 때, 실제로 GS2-Dictionary 에 엔트리가 기록되어 있는지 보증된 데이터를 요구받는 경우가 있습니다.

예를 들어, GS2-Dictionary 로 아바타 파츠의 소지 상태를 관리하고, GS2-Formation 으로 아바타 파츠의 편성 상태를 관리한다고 가정해 봅시다. GS2-Formation 에 헤어스타일을 설정할 때 “hair-0001"이라는 파츠를 설정하도록 API 요청을 보내게 되는데, GS2-Formation 은 소지 증명 서명과 함께 “hair-0001"을 지정하도록 요구합니다.

이를 통해 GS2-Formation 은 뒤에서 GS2-Dictionary 와 통신하여 실제로 소지하고 있는 파츠인지 판단할 필요가 없어집니다.

    var result = await gs2.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Entry(
        entryModelName: "entry-0001"
    ).GetEntryWithSignatureAsync(
        keyId: "grn:gs2:{region}:{yourOwnerId}:key:namespace-0001:key:key-0001"
    );

    var body = result.Body;
    var signature = result.Signature;
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Entry(
        "entry-0001" // entryModelName
    );
    const auto Future = Domain->GetEntryWithSignature(
        "grn:gs2:{region}:{yourOwnerId}:key:namespace-0001:key:key-0001" // keyId
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;

    const auto Future2 = Future->GetTask().Result()->Model();
    Future2->StartSynchronousTask();
    if (Future2->GetTask().IsError()) return false;
    const auto Result = Future2->GetTask().Result();
    const auto Body = Result->Body;
    const auto Signature = Result->Signature;
var domain = ez.dictionary.namespace_(
        "namespace-0001"
    ).me(game_session).entry(
        "entry-0001"
    )

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

var result = async_result.result

즐겨찾기 엔트리 등록

    var domain = gs2.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    );
    var result = await domain.AddLikesAsync(
        entryModelNames: new List<string> {
            "entry-0001",
        }
    );
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    );
    const auto Future = Domain->AddLikes(
        {
            "entry-0001",
        }
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
var domain = ez.dictionary.namespace_(
        "namespace-0001"
    ).me(game_session)

var async_result = await domain.add_likes(
    [
        "entry-0001",
        "entry-0002",
        "entry-0003",
    ] # entry_model_names
)
if async_result.error != null:
    push_error(str(async_result.error))
    return

var result = async_result.result

즐겨찾기 엔트리 목록 조회

    var domain = gs2.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    );
    var items = await domain.LikesAsync(
    ).ToListAsync();
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    );
    const auto It = Domain->Likes(
    );
    TArray<Gs2::UE5::Dictionary::Model::FEzLikePtr> Result;
    for (auto Item : *It)
    {
        if (Item.IsError())
        {
            return false;
        }
        Result.Add(Item.Current());
    }
var iterator = ez.dictionary.namespace_(
        "namespace-0001"
    ).me(
        game_session
    ).likes(
    )

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

var items = async_result.result

즐겨찾기 엔트리 삭제

    var domain = gs2.Dictionary.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    );

    await domain.DeleteLikesAsync(
        entryModelNames: new List<string> {
            "entry-0001",
        }
    );
    const auto Domain = Gs2->Dictionary->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    );

    const auto Future = Domain->DeleteLikes(
        {
            "entry-0001",
        }
    );
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
var domain = ez.dictionary.namespace_(
        "namespace-0001"
    ).me(game_session)

var async_result = await domain.delete_likes(
    [
        "entry-0001",
        "entry-0002",
        "entry-0003",
    ] # entry_model_names
)
if async_result.error != null:
    push_error(str(async_result.error))
    return

var result = async_result.result

상세 레퍼런스