Documentation index for AI agents

GS2-StateMachine

스테이트 머신 관리 기능

GS2-Quest 는 퀘스트의 시작·종료를 관리하고, 시작한 퀘스트에 따라 종료 시 보상을 받을 수 있는 구조를 제공했습니다. 하지만 인게임의 무작위성이 강해 보상을 사전에 특정하기 어려운 게임 사양은 GS2-Quest 로는 잘 다루지 못하는 과제가 있었습니다.

GS2-StateMachine 은 더 세밀한 단위로 인게임의 상태 관리를 하기 위해 개발되었습니다.

스테이트 머신

인게임의 상태 관리에 사용하는 것이 스테이트 머신입니다.

flowchart TD
  Start ----> MainStateMachine_Initialize
  MainStateMachine_Pass ----> Exit
  subgraph MainStateMachine
    MainStateMachine_Initialize[[Initialize]] -->|Pass| MainStateMachine_ChoiceSkill

    MainStateMachine_ChoiceSkill[/ChoiceSkill/]

    MainStateMachine_InGame([InGame]) -->|Pass| MainStateMachine_NextTurn
    MainStateMachine_InGame([InGame]) -->|Fail| MainStateMachine_Pass

    MainStateMachine_NextTurn[[NextTurn]] -->|Next| MainStateMachine_ChoiceSkill
    MainStateMachine_NextTurn[[NextTurn]] -->|Exit| MainStateMachine_Pass

    MainStateMachine_Pass[\Pass/]


    subgraph ChoiceSkill
        ChoiceSkill_Initialize[[Initialize]] -->|Pass| ChoiceSkill_LotterySkills

        ChoiceSkill_LotterySkills[[LotterySkills]] -->|Pass| ChoiceSkill_WaitChoiceSkill

        ChoiceSkill_WaitChoiceSkill([WaitChoiceSkill]) -->|ChoiceSkill| ChoiceSkill_ChoiceSkill
        ChoiceSkill_WaitChoiceSkill([WaitChoiceSkill]) -->|ReLotterySkill| ChoiceSkill_ReLotterySkill

        ChoiceSkill_ReLotterySkill[[ReLotterySkill]] -->|Pass| ChoiceSkill_LotterySkills
        ChoiceSkill_ReLotterySkill[[ReLotterySkill]] -->|AlreadyReLottery| ChoiceSkill_WaitChoiceSkill

        ChoiceSkill_ChoiceSkill[[ChoiceSkill]] -->|Pass| ChoiceSkill_Pass
        ChoiceSkill_ChoiceSkill[[ChoiceSkill]] -->|InvalidSkillIndex| ChoiceSkill_WaitChoiceSkill

        ChoiceSkill_Pass[\Pass/]


    end
  end

  MainStateMachine_ChoiceSkill --> ChoiceSkill_Initialize
  ChoiceSkill_Pass -->|Pass| MainStateMachine_InGame

  Player ----->|Interaction| MainStateMachine_InGame
  Player ----->|Interaction| ChoiceSkill_WaitChoiceSkill

당신이 프로그래머라면 기획자로부터 위와 같은 플로우차트를 받아본 적이 있을 것입니다. 이러한 상태 전이를 표현한 것이 스테이트 머신입니다.

현재 플레이어가 어떤 스테이트에 있는지, 스테이트 머신 내에서 사용할 수 있는 변수가 어떤 값을 가지고 있는지를 관리합니다. 스테이트 머신은 언젠가 종료 스테이트로 전이하며, 그 시점의 스테이트 머신이 가진 상태 변수에 따라 보상을 확정할 수 있게 됩니다.

이벤트와 트랜지션

스테이트 머신의 스테이트 사이를 연결하는 것이 트랜지션입니다. 트랜지션에서는 특정 스테이트에서 다음 스테이트로 전이하는 조건을 설정합니다.

조건에는 이벤트 수신을 설정할 수 있으며, 이벤트의 종류에 따라 다음에 전이할 스테이트를 다르게 지정할 수 있습니다. 이벤트는 스테이트 머신 내에서 실행 중인 스크립트에서 발행할 수도 있지만, 플레이어로부터의 발행도 받아들일 수 있습니다. 이를 통해 플레이어가 취한 선택이나 게임 결과에 따라 처리를 분기시킬 수 있습니다.

이벤트에는 파라미터를 붙일 수 있으므로, 선택지마다 이벤트를 따로 준비하지 않아도 《선택지에서 선택했다》라는 이벤트와 《선택한 내용》이라는 파라미터를 전달하도록 함으로써 스테이트 머신을 단순하게 유지할 수도 있습니다.

스테이트 머신의 버전 관리

스테이트 머신은 버전 관리되며, 스테이트 머신의 내용을 업데이트해도 기동 시점에 사용하던 스테이트 머신 정의로 계속 동작합니다. 이를 통해 스테이트 머신의 정의를 변경해도 동작 중인 스테이트 머신에 영향을 주지 않고 새로운 스테이트 머신 정의를 적용할 수 있습니다.

호환성이 깨지는 변경을 스테이트 머신에 가할 경우에는, 네임스페이스 설정에서 지정한 버전 이하로 기동된 스테이트 머신을 삭제된 것으로 취급하는 구조를 이용하십시오.

또한 버전 관리되는 것은 스테이트 머신의 정의뿐입니다. 스테이트 머신이 참조하는 GS2-Script 는 버전 관리되지 않으므로 주의가 필요합니다.

기동된 스테이트 머신에는 분 단위로 유효 기간을 설정할 수 있으며, 유효 기간이 만료되면 자동으로 삭제됩니다.

스테이트 머신 정의 언어

스테이트 머신의 정의에는 GS2 가 독자적으로 개발한 GS2 States Language(GSL) 를 사용합니다. GSL 은 다음과 같은 표기법으로 작성합니다.

StateMachine MainStateMachine {
  Variables {
    int turn;
    int choiceSkill;
    array skills;
  }

  EntryPoint Initialize;

  Task Initialize() {
    Event Pass();
    Event Error(string reason);
    Script grn:gs2:{region}:{ownerId}:script:statemachine-script:script:MainStateMachine_Initialize
  }

  SubStateMachineTask ChoiceSkill {
    using ChoiceSkill;
    in (turn <- turn);
    out (choiceSkill -> choiceSkill);
  }

  WaitTask InGame {
    Event Pass();
    Event Fail();
    Event Error(string reason);
  }

  Task NextTurn() {
    Event Next();
    Event Exit();
    Event Error(string reason);
    Script grn:gs2:{region}:{ownerId}:script:statemachine-script:script:MainStateMachine_NextTurn
  }

  PassTask Pass;

  ErrorTask Error(string reason);

  Transition Initialize handling Pass -> ChoiceSkill;
  Transition Initialize handling Error -> Error;
  Transition ChoiceSkill handling Pass -> InGame;
  Transition InGame handling Pass -> NextTurn;
  Transition InGame handling Fail -> Pass;
  Transition InGame handling Error -> Error;
  Transition NextTurn handling Next -> ChoiceSkill;
  Transition NextTurn handling Exit -> Pass;
  Transition NextTurn handling Error -> Error;
}

자세한 사양은 GS2 States Language 의 언어 사양에 대하여 를 참조하십시오.

스테이트 머신의 투기적 실행

GS2-StateMachine 이 제공하는 스테이트 머신은 Unity 와 Godot 에서 투기적 실행을 이용할 수 있습니다. 이 기능을 이용함으로써, 복잡한 로직을 가진 서버 프로그램을 플레이어가 통신 시간을 체감하지 않도록 하면서도 치트 행위는 할 수 없는 형태로 구현할 수 있습니다.

스테이트 머신의 투기적 실행 메커니즘

actor Player
participant "Game"
participant "GS2-SDK"
participant "Local State Machine"
participant "Event Stream"
participant "GS2-StateMachine"
Player -> "Game" : 플레이
"Game" -> "GS2-SDK" : 스테이트 머신 로드
"GS2-SDK" -> "GS2-StateMachine" : 스테이트 머신 로드
"GS2-SDK" <- "GS2-StateMachine" : 스테이트 머신
"GS2-SDK" -> "Local State Machine" : 로컬 스테이트 머신 시작
"Local State Machine" -> "Event Stream" : 이벤트 스트림 생성
"GS2-SDK" <- "Local State Machine" : 준비 완료
"Game" <- "GS2-SDK" : 로드 완료
group 게임 루프
    Player -> "Game" : 조작
    "Game" -> "Local State Machine" : 메시지 전송
    "Local State Machine" -> "Event Stream" : 수신한 메시지의 이벤트 기록
    "Local State Machine" -> "Local State Machine" : 상태 변화
    "Local State Machine" -> "Event Stream" : 변화 후 상태의 해시값 이벤트 기록
    "Game" <- "Local State Machine" : 상태 변수
    note over "Game" : 상태 변수를 기반으로 게임 화면 렌더링
    group 이벤트 리포트 [3초마다]
        "Event Stream" -> "GS2-StateMachine" : 이벤트 리포트 전송
        note over "GS2-StateMachine" : 서버는 이벤트를 재생하여\n상태 변수의 해시값이 일치하는지 검증
        group 해시값 불일치
            "GS2-SDK" <- "GS2-StateMachine" : 상태 불일치 통지
            "Game" <- "GS2-SDK" : OnDetectStateMismatch
            note over "Game" : 스테이트 머신 재로드
            "Game" -> "GS2-SDK" : 스테이트 머신 로드
            "GS2-SDK" -> "GS2-StateMachine" : 스테이트 머신 로드
            "GS2-SDK" <- "GS2-StateMachine" : 스테이트 머신
            "GS2-SDK" -> "Local State Machine" : 로컬 스테이트 머신 재기동(최대 3초간 롤백)
        end
    end
end

그림에서 보여준 것처럼, 로컬 스테이트 머신은 서버로부터 받은 GSL 을 실행하는 기능을 가지고 있습니다. 다만, 서버상의 사용자 데이터를 다시 쓸 권한은 없으며, 스테이트 머신 내에서 발생한 사용자 데이터의 변경은 SDK 의 로컬 캐시 변경만 수행합니다.

로컬 스테이트 머신은 게임으로부터 받은 메시지를 바탕으로 상태 전이를 수행하는데, 수신한 메시지와 스테이트 머신의 상태 변수의 해시값을 이벤트 스트림에 기록합니다. 이벤트 스트림은 3초마다 발생한 이벤트가 있으면 GS2-StateMachine 에 리포트를 전송합니다.

GS2-StateMachine 은 리포트를 수신하면, 거기에 기록된 이벤트를 서버가 보유하고 있는 스테이트 머신에 대해 실행합니다. 상태가 전이되었을 때는 리포트에 포함된 상태 변수의 해시값과 일치하는지 검증하며, 마지막 이벤트까지 문제가 없으면 스테이트 머신이 실행한 사용자 데이터의 변경도 실제로 실행하고, 스테이트 머신의 상태를 데이터베이스에 저장합니다.

만약 전이 대상 스테이트가 다르거나 상태 변수의 해시값에 불일치가 발생한 경우에는 “상태 불일치” 오류를 반환합니다. 이는 게임 프로그램 쪽에서 로컬 스테이트 머신의 “OnDetectStateMismatch” 콜백으로 처리할 수 있습니다. 불일치가 발생하면 이벤트 스트림은 GS2-StateMachine 으로의 리포트를 중단하므로, 로컬 스테이트 머신을 다시 생성해야 합니다.

이때, 마지막으로 정합성이 확인된 상태(데이터베이스에 저장된 상태)까지 롤백될 수 있습니다.

스테이트 머신에서의 난수

스테이트 머신이 난수를 기반으로 처리를 분기하고 싶은 경우가 있을 것입니다. 그러한 용도를 위해 서버와 난수 시드를 공유한 난수 생성기를 이용할 수 있습니다.

category = 1
result = util.shared_random(category)
if result.isError then
  fail(result['statusCode'], result['errorMessage'])
end
random_value = result["result"]

category 에는 용도별로 다른 값을 지정함으로써, 서로 다른 난수열을 기반으로 난수를 얻을 수 있습니다. 이 구조를 활용하면 난수값을 골라내는 행위(치트성 시행)에 대해 높은 내성을 얻을 수 있습니다.

이 방법으로 생성한 난수는 서버에서도 완전히 동일한 난수값을 얻을 수 있음이 보장되어 있어, 난수가 원인이 되어 상태 불일치가 발생하는 일은 없습니다.

스테이트 머신에서의 트랜잭션 처리

스테이트 머신의 실행 과정에서 사용자 데이터를 변경할 경우에는 GS2-SDK for Lua 를 사용하지 않고 전용 구문을 사용합니다. 그렇게 함으로써, 투기적 실행 시에도 통신 처리 없이 SDK 가 가진 캐시 데이터를 변경하는 형태로 사용자 데이터 변경에 대해서도 투기적 실행이 가능해집니다.

자세한 API 에 대해서는 각 마이크로서비스의 트랜잭션 액션 문서를 확인하십시오.

transaction.execute({
consumeActions={},
acquireActions={
  transaction.service("inventory").acquire.acquire_simple_items_by_user_id({
    namespaceName="namespace",
    inventoryName="inventory",
    acquireCounts={
      {
        itemName="item",
        count=1,
      },
    },
  })
}
})

스테이트 머신에서의 통신 처리

투기적 실행을 이용하는 스테이트 머신에서는 외부와 통신하는 것을 권장하지 않습니다. 왜냐하면 외적 요인에 의해 스테이트 머신의 실행 결과에 차이가 생기면 상태 불일치가 발생할 가능성이 비약적으로 높아지기 때문입니다.

로컬 스테이트 머신의 실행 환경

Unity 에서는 로컬 스테이트 머신을 이용할 수 있도록 하기 위해, GS2-SDK 와는 별도로 LocalStateMachineKit 을 설치해야 합니다. GS2-SDK Installer 에서 LocalStateMachineKit 을 설치할 수 있습니다.

Godot 에서는 로컬 스테이트 머신의 실행 환경이 Godot SDK 에 동봉되어 있습니다. SDK 를 도입하면 addons/gs2/state_machine/local 이하도 함께 배치되므로, 추가 패키지 설치는 필요하지 않습니다.

다만, 추가적인 오픈 소스 라이브러리를 도입하게 되므로 라이선스 표기에 주의가 필요합니다. Unity 버전의 자세한 내용은 GS2 LocalStateMachineKit for Unity 를 확인하십시오.

스크립트 연동

스테이트 머신 내에서 Task 스테이트를 실행할 때, GS2-Script 를 호출할 수 있습니다. 스크립트의 실행 결과로서 “이벤트"를 반환함으로써, 스크립트의 로직에 따라 스테이트 머신을 전이시킬 수 있습니다.

마스터 데이터 관리

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

마스터 데이터의 종류에는 다음이 있습니다.

  • StateMachineMaster: 스테이트 머신 정의(GSL). 버전 관리되며, 호환성이 깨지는 변경을 하고 싶은 경우에는 네임스페이스 설정의 lowestStateMachineVersion 을 이용하여 이전 버전의 스테이트 머신을 삭제된 것으로 취급할 수 있습니다

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

스크립트 트리거

스테이트 머신의 시작 시점이나 종료 상태에 도달한 시점에 GS2-Script 를 호출할 수 있습니다.

설정할 수 있는 주요 이벤트 트리거와 스크립트 설정명은 다음과 같습니다.

  • startScript: 스테이트 머신 시작 시
  • passScript: 스테이트 머신이 PassTask 에 도달했을 때
  • errorScript: 스테이트 머신이 ErrorTask 에 도달했을 때

또한, 스테이트 머신 내의 Task 스테이트에서 호출되는 GS2-Script 는 별도로 GSL 의 Script 지시자로 지정하며, 상태 전이 자체를 구동하는 용도로 사용됩니다.

트랜잭션 액션

GS2-StateMachine 에서는 다음의 트랜잭션 액션을 제공합니다.

  • 입수 액션: 스테이트 머신의 시작

“스테이트 머신의 시작"을 입수 액션으로 이용함으로써, 상점에서의 상품 구매 시나 미션 달성 시의 보상으로, 특정 인게임(로그라이크한 던전 공략 등, 복잡한 상태 관리를 수반하는 게임 루프)을 직접 시작시키는 처리를 트랜잭션 내에서 안전하게 실행할 수 있습니다. 이를 통해 구매부터 플레이 시작까지를 매끄럽게 연결하는 경험을 제공할 수 있습니다.

스테이트 머신 내부에서의 사용자 데이터 변경에 대해서는, transaction.execute 구문을 이용함으로써 로컬 스테이트 머신의 투기적 실행과 정합되는 트랜잭션 액션을 발행할 수 있습니다. 자세한 내용은 스테이트 머신에서의 트랜잭션 처리 를 참조하십시오.

구현 예제

스테이트 머신 시작

스테이트 머신의 시작은 게임 엔진용 SDK 에서는 처리할 수 없습니다. GS2-Quest 등의 마이크로서비스의 보상으로 설정하십시오.

스테이트 머신에 이벤트 전송

    var result = await gs2.StateMachine.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Status(
        statusName: status1.Name
    ).EmitAsync(
        eventName: "event-0001",
        args: "{\"value1\": \"value1\", \"value2\": 2.0, \"value3\": 3}"
    );
    var item = await result.ModelAsync();
    const auto Domain = Gs2->StateMachine->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Status(
        "status-0001" // statusName
    );
    const auto Future = Domain->Emit(
        "event-0001",
        "{\"value1\": \"value1\", \"value2\": 2.0, \"value3\": 3}" // args
    );
    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.state_machine.namespace_(
        "namespace-0001"
    ).me(game_session).status(
        "status-0001"
    )

var async_result = await domain.emit(
    "event-0001", # event_name
    "{\"value1\": \"value1\", \"value2\": 2.0, \"value3\": 3}" # args
)
if async_result.error != null:
    push_error(str(async_result.error))
    return

var result = async_result.result

스테이트 머신의 상태 취득

    var item = await gs2.StateMachine.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Status(
        statusName: "status-0001"
    ).ModelAsync();
    const auto Domain = Gs2->StateMachine->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Status(
        "status-0001" // statusName
    );
    const auto Future = Domain->Model();
    Future->StartSynchronousTask();
    if (Future->GetTask().IsError()) return false;
    const auto Item = Future->GetTask().Result();
var domain = ez.state_machine.namespace_(
        "namespace-0001"
    ).me(game_session).status(
        "status-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 localStateMachine = await LocalStateMachineExecutor.StartAsync(
        gs2: gs2,
        gameSession: GameSession,
        stateMachineNamespaceName: "namespace-0001",
        statusName: "status-0001"
    );
var local_state_machine = Gs2StateMachineLocalActiveStateMachine.setup(
    game_session.get_user_id(), gsl_source, "MainStateMachine"
)
var event_stream = Gs2StateMachineLocalEventStream.new()
event_stream.attach(local_state_machine)
local_state_machine.start({})

로컬 스테이트 머신에 이벤트 전송

    localStateMachineExecutor.Emit(
        "Select",
        new MapVariableValue(new Dictionary<string, IVariableValue> {
            ["x"] = new IntVariableValue(x),
            ["y"] = new IntVariableValue(y),
        }
    ));
local_state_machine.emit(
    "Select",
    {"x": x, "y": y}
)

상태 불일치 처리

상태 불일치 콜백을 받으면, 최신 스테이트 머신의 상태를 서버에서 다시 가져와 로컬 스테이트 머신을 처음부터 다시 시작하십시오.

    localStateMachineExecutor.OnDetectStateMismatch += (namespaceName, statusName) =>
    {
        Debug.LogWarning("detect state mismatch!");
    };
local_state_machine.changed_state.connect(func(task_name, hash_value):
    # Compare the local state with the state confirmed by the server.
    # Reload the latest state if a mismatch is detected.
    pass
)

이벤트 스트림의 디스패치

로컬 스테이트 머신을 이용하는 동안, 이벤트 스트림이 GS2-StateMachine 에 이벤트를 전송하도록 일정 간격으로 Dispatch 함수를 호출해야 합니다.

    private async UniTask Dispatch() {
        while (true) {
            await this._localStateMachineExecutor.DispatchAsync(
                Login.Gs2,
                Login.GameSession
            );
            await UniTask.Delay(TimeSpan.FromMilliseconds(100));
        }
    }
# Send the events accumulated in event_stream.events to GS2-StateMachine
# at a fixed interval, then clear the confirmed events.
while true:
    await get_tree().create_timer(0.1).timeout
    await dispatch_event_stream(event_stream.events, game_session)

상세 레퍼런스