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 が独自に開発した 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 において投機的実行が利用できます。 この機能を利用することで、複雑なロジックを持つサーバープログラムをプレイヤーに通信時間を体感させずに、なおかつチート行為も行えない形で実現が可能となります。

ステートマシンの投機的実行のメカニズム

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,
      },
    },
  })
}
})

ステートマシンにおける通信処理

投機的実行を利用したステートマシンでは外部と通信することは推奨できません。 なぜなら、外的要因によってステートマシンの実行結果に差が生じると状態の不一致になる可能性が飛躍的に向上してしまうためです。

ローカルステートマシンの実行環境

ローカルステートマシンを利用できるようにするには、GS2-SDK とは別に LocalStateMachineKit をインストールする必要があります。 GS2-SDK Installer から LocalStateMachineKit のインストールが可能です。

ただし、追加のオープンソースライブラリを導入することになりますので、ライセンス表記について注意が必要です。 詳細は GS2 LocalStateMachineKit for Unity を確認してください。

実装例

ステートマシンを開始

ステートマシンを開始はゲームエンジン用の 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(
        "$status1.name" // 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 item = await gs2.StateMachine.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Status(
        statusName: status1.Name
    ).ModelAsync();
    const auto item = Gs2->StateMachine->Namespace(
        "namespace-0001" // namespaceName
    )->Me(
        AccessToken
    )->Status(
        "$status1.name" // statusName
    ).Model();

ローカルステートマシン(ステートマシンの投機的実行)

ローカルステートマシンを開始

    var localStateMachine = await LocalStateMachineExecutor.StartAsync(
        gs2: gs2,
        gameSession: GameSession,
        stateMachineNamespaceName: "namespace-0001,
        statusName: status1.Name
    );

ローカルステートマシンにイベントを送信

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

状態の不一致をハンドリング

状態の不一致のコールバックを受けとったら、最新のステートマシンの状態をサーバーから取得し直してローカルステートマシンの起動からやり直してください。

    localStateMachineExecutor.OnDetectStateMismatch += (namespaceName, statusName) =>
    {
        Debug.LogWarning("detect state mismatch!");
    };

イベントストリームのディスパッチ

ローカルステートマシンを利用している間、イベントストリームが GS2-StateMachine にイベントを送信するように一定間隔で Dispatch 関数を呼び出す必要があります。

    private async UniTask Dispatch() {
        while (true) {
            await this._localStateMachineExecutor.DispatchAsync(
                Login.Gs2,
                Login.GameSession
            );
            await UniTask.Delay(TimeSpan.FromMilliseconds(100));
        }
    }

詳細なリファレンス