Adsense

2018年1月20日土曜日

Spring Statemachineを使ってドラクエ11のポーカーを自動プレイするプログラムを作ってロイヤルストレートスライムを出すまでの道のり 第7回

※ ツールのソースコードはGitHubにあがっていますので参照してください。

前回まででSpring Statemachineに関連する重要な部分は説明してしまいました。(状態遷移マシンの定義・テスト・イベント送信)

今回は細かいところでSpring Statemachineに関連するところをいくつかご紹介します。

@WithStateMachine

@WithStateMachineのアノテーションをクラスに付与することで、状態遷移マシンの状態遷移やAction実行に合わせて処理を行うことができます。Spring Statemachineのリファレンスでは、Context Integrationの章で説明されています。

以下、状態遷移時にログ出力を行うクラスのソースです。

/**
 * {@link StateMachine}の状態遷移に関連するログを出力するリスナ。
 */
@WithStateMachine
public class LoggingStatemachineListener {

    private static final Logger LOGGER = LoggerFactory.getLogger(LoggingStatemachineListener.class);
    
    @OnTransition
    public void onTransition(StateContext<States, Events> stateContext) {
        String eventId = stateContext.getMessageHeaders().get("eventId", String.class);
        if (eventId == null) {
            eventId = "-";
        }
        String eventName = "-";
        if (stateContext.getEvent() != null) {
            eventName = stateContext.getEvent().name();
        }
        String from = "-";
        if (stateContext.getSource() != null) {
            from = stateContext.getSource().getId().toString();
        }
        String to = "-";
        if (stateContext.getTarget() != null) {
            to = stateContext.getTarget().getId().toString();
        }
        String transitionKind = "-";
        if (stateContext.getTransition() != null) {
            transitionKind = stateContext.getTransition().getKind().toString();
        }

        LOGGER.info("[Transition] EventId: {}, Event: {}, from: {}, to: {}, transitionKind: {}",
                eventId, eventName, from, to, transitionKind);
    }
}

5行目: @WithStateMachineアノテーションを付与します。

10行目: 状態遷移が発生したときにコールバックしてもらうために、@OnTransitionアノテーションを付与しています。なお、アノテーション属性で"source"と"target"に状態名を指定することで、状態遷移の遷移元と遷移先の状態を特定することができます。ただし、この属性はStringなので、Enumの状態を指定することはできません。Enumの型はユーザーが定義するものなので仕方ないですが、ちょっと不便ですね。

23行目: 前回、sendEventにMessageを渡すことで、イベントに付加情報を付与する方法を説明しましたが、ここではイベントにくっついてきたイベントIDを、StateContextのgetMessageHeadersメソッドで取得しています。

@WithStateMachineの中で使える@OnTransition以外のアノテーションは、org.springframework.statemachine.annotationパッケージ内で、@On~ではじまっているアノテーションになります。また、メソッド引数で受け取れるものも各アノテーションのJavadocに記載されているので参考にしていください。なお、Javadocだと受け取れる引数の中にStateContextが書いてないのですが、多分全てのアノテーションでStateContextは受け取れると思います。

@WithStateMachine vs StateMachineListener

ところで、以前ご紹介したStateMachineListenerというインターフェースも状態遷移マシンの変化に反応してメソッドがコールバックされるものでした。@WithStateMachineと何が違うのでしょうか?

以前に載せたStateMachineListenerのサンプルコードを再掲します。

public class SampleStateListener extends StateMachineListenerAdapter<States, Events> {

    private static final Logger LOGGER = LoggerFactory
            .getLogger(SampleStateListener.class);

    @Override
    public void stateChanged(State<States, Events> from, State<States, Events> to) {
        String fromState = (from == null) ? "-" : from.getId().toString();
        String toState = (to == null) ? "-" : to.getId().toString();
        LOGGER.info("from: {}, to: {}", fromState, toState);
    }
}

stateChangedメソッドの引数に注目するとStateContextがありません。実は、StateMachineListenerのコールバックメソッドのほとんどは、StateContextにアクセスすることができないのです。

このあたりはstack overflowでSpring Statemachineの作者が説明をしています。

どうやら、StateContextは後から追加されたインターフェースで、そのときに後方互換性を重視したため、StateMachineListenerの各コールバックメソッドの引数にStateContextを追加しなかったそうです。
代わりにStateMachineListenerにはstateContextというStateContextの変化に反応するコールバックメソッドが追加され、このメソッドでのみStateContextを引数に受け取れます。

また、StateContextにはStageというものが格納されており、Stageを見ることでこのStateContextがどのステージ(状態の変化、遷移の開始、など)によってアタッチされたものなのかを判定することができます。

上記のSampleStateListenerをstateContextメソッドを使用して書き換えると次のようになります。


    @Override
    public void stateContext(StateContext<States, Events> stateContext) {
        Stage stage = stateContext.getStage();
        if (Stage.STATE_CHANGED.equals(stage)) {
            State<States, Events> from = stateContext.getSource();
            State<States, Events> to = stateContext.getTarget();

            String fromState = (from == null) ? "-" : from.getId().toString();
            String toState = (to == null) ? "-" : to.getId().toString();
            LOGGER.info("from: {}, to: {}", fromState, toState);
        }
    }

5行目でStageがSTATE_CHANGED(状態の変化)である場合だけログ出力を行うよう判定を行っています。

・・・微妙ですね。StateContextを使いたい場合で、かつ他のStageでも処理を仕掛けたい場合には、stateContextメソッドの中にStageのif文による分岐が羅列されることになります。これなら、最初から@WithStateMachineを使えばいいと思うのですが、stack overflowではなぜ@WithStateMachineを代替案に挙げなかったのでしょうか?(このときはまだ@WithStateMachineがなかったのか?)

リファレンスを読んでも@WithStateMachineとStateMachineListenerの使い分けについては言及されていないようでした。GitHubのissueにそれっぽい話題が登録されていますが、結論がよく分からない・・・。

とりあえず、今回のツールでは@WithStateMachineで目的を達成できているのでこちらを使っていきます。

Extended State

DEALT_CARDS_STATE(カード配布済み状態)のEntry Actionでは、残すカードを決定した後、下キー → Enterキーと押して"くばる"ボタンを押下するという処理を実行します。
以前にも書いたように、ここで下キーを押すことが、ポーカーを延々プレイし続ける鍵になっています。(他の状況では、状態やカードの読み取りをミスったとしても、Enterキーさえ押し続けていればポーカーは進行するので、やがて正常な状態に戻れる)

そのため、DEALT_CARDS_STATEから1分間(デフォルト値)状態遷移が起こらなかった場合に、再度下キー → Enterキーを押して"くばる"ボタンを押下するという、保険的なアクションを設定しています。

具体的には、以前に見せたStateMachineConfigクラスの状態遷移で、下記のように内部遷移(Internal Transition)を使用して設定を行っています。

// カード配布済み状態が長く続く場合、"くばる"ボタンの押下に失敗したとみなし、再度ボタンの押下を試みる。
.withInternal()
    .source(DEALT_CARDS_STATE)
    .event(DEAL_CARDS_EVENT)
    .guard(retryPushDealButtonGuard())
    .action(pushDealButtonAction())
    .and();

ここでガード条件として設定されているRetryPushDealButtonGuardクラスを見てみます。

/**
 * カード配布済み状態で指定時間が経過した後に、再度"くばる"ボタンを押すかどうかのガード条件。
 * なんらかの原因(キー操作のとりこぼし等)で"くばる"ボタンが押せなかった場合の救済として、再度"くばる"ボタンを押すべきか判断する。
 */
@WithStateMachine
@ConfigurationProperties(prefix = "autoplay.dq11.poker.retry-push-deal-button-guard")
public class RetryPushDealButtonGuard implements Guard<States, Events> {

    private static final Logger LOGGER = LoggerFactory.getLogger(RetryPushDealButtonGuard.class);

    private static final String DEALT_CARDS_STATE_START_DATE = "DEALT_CARDS_STATE.startDate";

    /** カード配布済み状態になってから"くばる"ボタンを再度押すまでに待つ時間(ms)。 */
    private int waitPeriod = 60 * 1000;

    @Override
    public boolean evaluate(StateContext<States, Events> context) {
        LocalDateTime dealCardsStateStartDate = context.getExtendedState().get(DEALT_CARDS_STATE_START_DATE,
                LocalDateTime.class);

        if (dealCardsStateStartDate == null) {
            throw new IllegalStateException("dealCardsStateStartDate is null");
        }
        LocalDateTime now = LocalDateTime.now();
        Duration duration = Duration.between(dealCardsStateStartDate, now);

        boolean result = false;

        if (duration.toMillis() > waitPeriod) {
            context.getExtendedState().getVariables().put(DEALT_CARDS_STATE_START_DATE, now);
            result = true;
        }
        LOGGER.info("[Guard     ] RetryPushDealButtonGuard: {}, now wait(ms): {} (until {}ms)", result,
                duration.toMillis(), waitPeriod);
        return result;
    }

    /**
     * DEALT_CARDS_STATEに入った時間をExtendedStateとして記録する。
     * 他の状態に遷移した場合はExtendedStateから時間を削除する。
     * 
     * @param stateContext StateContext.
     */
    @OnStateChanged
    public void onStateChanged(StateContext<States, Events> stateContext) {
        LocalDateTime dealtCardsStateStartDate = null;
        if (States.DEALT_CARDS_STATE.equals(stateContext.getTarget().getId())) {
            dealtCardsStateStartDate = LocalDateTime.now();
            stateContext.getExtendedState().getVariables().put(DEALT_CARDS_STATE_START_DATE, dealtCardsStateStartDate);
        } else {
            stateContext.getExtendedState().getVariables().remove(DEALT_CARDS_STATE_START_DATE);
        }
        LOGGER.debug("save dealt cards state start Date: {}", dealtCardsStateStartDate);
    }

    public int getWaitPeriod() {
        return waitPeriod;
    }

    public void setWaitPeriod(int waitPeriod) {
        this.waitPeriod = waitPeriod;
    }
}

このクラスは@WithStateMachineが付与されており、ガード条件として呼び出されると同時に、状態遷移時にも呼びさられるクラスになっています。

まず、下の方にあるonStateChangedメソッドから見ていきます。(45行目)
このメソッドは@OnStateChangedが付与されているため、状態遷移に反応して呼び出され、DEALT_CARDS_STATE以外の状態からDEALT_CARDS_STATE状態に入ってきたときだけ現在時刻を記録します。

現在時刻を格納する先ですが、50行目でstateContextのgetExtendedStateを呼び出し、そこにキー名とともにputしています。これはExtended Stateと呼ばれるもので、StateMachine自体が持つマップのようなものです。

リファレンスで説明されている例で説明すると、キーを100回押すと終了する状態遷移を考えたときに、キーが1回押された状態、キーが2回押された状態・・・というふうに状態を定義していくと状態数がとんでもないことになるので、状態としてはキーが押された状態を1つだけ定義し、補足的な状態としてキーの押された回数をExtended Stateという形で保持できるようになっています。

次に、ガード条件を判定するevaluateメソッド内では、19行目でStateContextからgetExtendedStateメソッドでDEALT_CARDS_STATEに進入した時間を取り出し、1分以上経過していればtrueを返すことで、状態に進入してから1分たったらInternal Transitionを発生させるという処理を実現しています。

補足: 状態遷移のtimerメソッド について

当初は、この処理をInternal Transitionとtimerメソッドで実現しようとしていました。

                .withInternal()
                    .source(DEALT_CARDS_STATE)
                    .event(DEAL_CARDS_EVENT)
                    .timer(60 * 1000)
                    .action(pushDealButtonAction())
                    .and();

上記5行目のようにtransitionにはtimerメソッド(およびtimerOnceメソッド)が用意されており、引数にタイマーのミリ秒を設定することができます。(timerは繰り返し、timerOnceは1回だけ遷移を行います)

この設定で、DEALT_CARDS_STATEに入ってから60秒後にInternal Transitionとそれに紐付いたPushDealButtonActionを実行できるのではないかと思ったのですが、これは私の勘違いでした。実際は、タイマーの時間は状態遷移マシンが開始してから計測開始されるもので、sourceに指定した状態に入ってからタイマーが開始されるわけではありません。

で、結局ロイヤルストレートスライムは出たの?

さて、ここまででSpring Statemachineに関連したツールのソースコードを眺めてきました。

他にも色々と問題にぶつかって工夫しながら実装してった部分が多々あるのですが、Spring Statemachineとは関係がない泥臭い部分になるので、説明は割愛します。ご興味があれば、GitHubのソースコードをご参照ください。
(PS4リモートプレイのウィンドウをアクティブ化するためにMacではAppleScriptを使ってみたり、判定に使うキャプチャ画像を用意するための動作モードを実装してみたり、Robotの操作を簡易化するためのユーティリティを作成してみたり・・・)

で、結局このツールで目的は達成できたのか?というと・・・

やってやったぜ・・・。

正確に時間を記録していなかったのですが、開発中にツール実行していた時間も考慮すると、40~50時間ぐらい回して3回ロイヤルストレートスライムを達成しています。

ちなみにツールの開発と、このブログを書くのにかけた時間は・・・ちょっと考えたくないですね・・・。年末年始のちょっとした宿題のつもりだったのに、どうしてこうなってしまったのか・・・。




「 こんな け゛ーむに まし゛に なっちゃって と゛うするの 」






(追記)
このブログはとなりでツールを自動実行させながら書いていたのですが、とんでもない事実に気付いてしましました。
次回、この事実に基づいてツールを改良して、本企画にとどめを刺したいと思います。

0 件のコメント:

コメントを投稿