Adsense

2018年1月20日土曜日

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

さて、前回の最後で気づいてしまった事実、それは・・・。

「ロイヤルストレートスライムってジョーカーが含まれていても成立するんですね。」

ロイヤルストレートフラッシュとかだと、ジョーカーが含まれていると成立しないものだと思っていました。実際のところは、成立するルールもあれば、成立しないルールもあるらしいですね。

で、なぜ気づいたかというと、前回のブログを書いているときに横で自動実行ツールを走らせていたのですが、長いファンファーレが鳴り始めたので何事かと画面を見てみたら、たまたまジョーカー入りのロイヤルストレートスライムが成立していたのです。

しかし、今のツールはジョーカー入りをロイヤルストレートスライムとは認識しないため、ツールを終了せず、そのままダブルアップチャンスに突入し、1回目であっさり敗北しました・・・。

ジョーカー対応

最後にジョーカーを含めてロイヤルストレートスライムを認識するようにツールを改造することにします。

・・・2行追加するだけで対応できてしまった。

本当なら、改造の様子をこのブログで紹介しようと思っていたのですが、特に書けるようなことはありませんでした・・・。

というわけで、ツールのバージョン1.1.0.RELEASEからジョーカーもロイヤルストレートスライムのカードとして認識されるようになっています。


前回は10時間以上かかりましたが、今回は2時間程度であっさり出てくれました。やっぱりジョーカー入りで確率がかなり上がったからだろうか?

ダブルアップチャンス継続/停止対応

もう一つやりたいと思っていたことは、役が成立したときにダブルアップチャンスを断って時間短縮する改造です。

これについては、実はプロトタイプをすでに実装しており、動作も問題なく動いている所まで来ていますが、細部を詰める気力がなくなってしまったので、そのまま放置してあります。

ご興味があれば、GitHubのdoubleup_chanceブランチにコミットされていますのでご参照ください。

やっぱり気になったので、実装してmasterに取り込みました。1.1.0.RELEASEから利用可能です。

デフォルトではダブルアップチャンスに挑戦しますが、設定で断ることが可能です。
なお、ダブルアップチャンスを断る場合は、ダブルアップチャンスの挑戦選択時のダイアログとメッセージのキャプチャを取る必要があるため、準備は少し大変になります。

詳細はREADMEを御覧ください。

本当に終わり


というわけで、最後は尻切れトンボみたいになってしまいましたが、これにて本当の本当に終わりです。

Spring Statemachineはなかなかおもしろいプロダクトなので、状態遷移の考え方を使いたいなら使ってみると良いのではないでしょうか。

ホント、つかれた・・・。

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回ロイヤルストレートスライムを達成しています。

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




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





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

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

前々回では状態遷移図に従って状態遷移マシンを実装するやり方を、前回ではテストコード内で状態遷移マシンにイベントを投げてテストする方法を見てきました。

今回は、実際にツール内で状態遷移マシンにイベントを投げるイベントディスパッチャを見ていきます。

@Scheduledによるイベントディスパッチ

今回のツールでは、定期的(デフォルトで2秒)に画面キャプチャを行い、画面内にイベントを発生させる契機となる画面部品(例:"くばる"ボタン)が検出された場合に、それに応じたイベントを状態遷移マシンに送信するという仕組みにしています。なお、画面内に特にイベントを発生させる画面部品が見つからなかった場合は、OTHER_EVENTを送信します。

AutoplayConfig

定期的な画面キャプチャの実行には、Springの@Scheduledを使用します。以下は、@Scheduledを有効にしているAutoplayConfigクラスのコードです。

/**
 * プロパティ"mode"が"AUTOPLAY"の時のみ有効となる設定。
 */
@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "mode", havingValue = "AUTOPLAY")
@ConfigurationProperties(prefix = "autoplay.dq11.poker.autoplay-config")
public class AutoplayConfig implements SchedulingConfigurer {

    // 中略

    @Bean
    public TaskScheduler taskSchedulerForAutoplay() {
        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.setThreadNamePrefix("autoplay-pool-");
        taskScheduler.setPoolSize(2);
        return taskScheduler;
    }

    // 以下略

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setTaskScheduler(taskSchedulerForAutoplay());
    }


6行目: @EnableSchedulingのアノテーションで@Scheduledによる定期実行を有効化します。

9行目: 本ツールでは@Scheduledを実行するTaskSchedulerを指定したかったので、SchedulingConfigurerインターフェースを実装します。

13-19行目: @Scheduledを実行するTaskSchedulerをBean登録します。

25行目: @Scheduledで実行するTaskSchedulerを明示的に設定します。

TaskSchedulerについての補足

上記コードではTaskSchedulerを明示的に設定していますが、明示的に設定しない場合は以下のような流れで使用するTaskSchedulerが決定されます。
  • TaskSchedulerの型のBeanが1つだけ存在する場合はそれを使う
  • "taskScheduler"という名前のBeanがあればそれを使う
  • Beanがなければシングルスレッドのスケジューラ(ConcurrentTaskScheduler)をセットアップして使う
Spring Statemachineでは、第3回で説明したようにDo Actionなどを別スレッドで実行しますが、このときにも"taskScheduler"という名前のBeanを使います。そのため、意図せず@ScheduledとSpring Statemachineで同じTaskSchedulerを共有してしまうことがあるため注意が必要です。

今回のツールでは、使用するTaskSchedulerを別にしたかったので、Bean名を"taskScheduler"にせず、"taskSchedulerForAutoplay"にしています。

EventDispatcherTask

実際にイベント送信を定期実行するクラスは下記になります。

/**
 * イベントを送信するクラス。 {@link @Scheduled}で定期的に画面キャプチャし、条件に応じたイベントを{@link StateMachine}
 * に送信する。
 */
@ConfigurationProperties(prefix = "autoplay.dq11.poker.event")
public class EventDispatcherTask {

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

    @Autowired
    StateMachine<States, Events> stateMachine;

    @Autowired
    RobotUtil robotUtil;

    @Autowired
    ImageComparator imageComparator;

    @Autowired
    CardReader cardReader;

    @Autowired
    CaptureManager captureManager;

    @Autowired
    CaptureHistory captureHistory;

    /**
     * イベント検知の周期(ms)。 application.propertiesでの補完の有効化、および、
     * デバッグログ出力のためのフィールドであり、実際にはこのフィールドは設定には使われない。 
     * <code>@Scheduled</code>の<code>fixedDelayString</code>に指定された値が使われる。
     */
    private int timerInterval;

    /**
     * デバッグ用。指定回数目のイベント送信でロイヤルストレートスライム検出イベントを送信する。 0以下の数値が設定されている場合は無視される。
     */
    private int debugRssEventCount = 0;

    /** 画面キャプチャを連続何回取得するか。 */
    private int captureBurstCount;

    /** 画面キャプチャを連続取得する際の間隔(ms)。 */
    private int captureBurstInterval;

    /**
     * 定期的に画面をキャプチャし、条件に応じたイベントを{@link StateMachine}に送信する。
     */
    @Scheduled(fixedDelayString = "${autoplay.dq11.poker.event.timer-interval}")
    public void dispatchEvent() {
        LOGGER.debug("dispatchEvent start. timer-interval: {}ms", timerInterval);

        /*
         * 役が成立した際にカードが光るアニメーションが入るため、1回のキャプチャだとカード認識に失敗する可能性がある。
         * そのため、キャプチャを連続取得し、それぞれのキャプチャでカードの読み取りを行い、補正を行う。
         */
        List<BufferedImage> captureList = robotUtil.captureGameScreenBurst(captureBurstCount, captureBurstInterval);
        LocalDateTime captureDateTime = LocalDateTime.now();

        // それぞれのキャプチャでカード読み取りを実施。
        List<List<Card>> readCardFromAllCapture = captureList.stream()
                .map(screen -> cardReader.readAllCards(screen))
                .collect(Collectors.toList());

        // それぞれのカード読み取り結果をマージ。
        List<Card> cards = readCardFromAllCapture.stream()
                .reduce((list1, list2) -> cardReader.mergeCardList(list1, list2))
                .get();

        // キャプチャ画像には最後のキャプチャを使う。
        BufferedImage screen = captureList.get(captureList.size() - 1);

        LOGGER.info("read cards: {}", cards.stream()
                .map(c -> c.name())
                .collect(Collectors.joining(",")));

        Events event = null;
        if (shouldSendRoyalStraightSlimeEvent(cards)) {
            event = Events.ROYAL_STRAIGHT_SLIME_EVENT;
        } else if (shouldSendDealCardsEvent(cards, screen)) {
            event = Events.DEAL_CARDS_EVENT;
        } else if (shouldSendBeforeBetCoinInputEvent(screen)) {
            event = Events.BEFORE_BET_COIN_EVENT;
        } else {
            event = Events.OTHER_EVENT;
        }

50行目: @ScheduledでdispatchEventメソッドを定期実行する設定をします。実行間隔はappliation.propertiesから取ってきます。

54-72行目: ここはゲーム画面のキャプチャを取得し、5枚のカードの種別を読み取る処理を行っています。カードの読み取りは、あらかじめキャプチャしてファイルに保存しておいたカードの画像と、ゲーム画面キャプチャの指定位置の画像が一致するかで行います。 読み取る種別はロイヤルストレートスライムに必要なカードのみで、それ以外はその他のカード(またはそもそもカードが画面に表示されていない)として処理します。
ここでちょっとやっかいな問題があり、ポーカーの役が成立している状態だと、カードが光るアニメーションが定期的に入るため、光ったタイミングで画像比較をしてしまうと正しくカードを認識できないという問題があります。そのため、ここでは短い間隔で3回画面キャプチャを取り、どれかのキャプチャでカードが読み取れたらその結果を使うという緩和策を取っています。

79-87行目: 送信するイベントを決定します。shoud~ではじまるメソッドは、画面キャプチャや読み取ったカード種別から、イベントを送信するべきか判定するメソッドです。

以下、dispatchEventメソッドの続きです。


        // イベントを一意に表すID。
        String eventId = UUID.randomUUID().toString();

        // イベント送信。
        Message<Events> message = MessageBuilder
                .withPayload(event)
                .setHeader("cards", cards)
                .setHeader("eventId", eventId)
                .build();
        stateMachine.sendEvent(message);

        // ゲーム画面のキャプチャ画像を履歴に保持。
        captureHistory.add(screen, eventId, event, captureDateTime);

        LOGGER.debug("dispatchEvent end. dispatched event: {}", event);
    }

    /**
     * カード配布済みイベントを送信するべきか判定する。
     * 
     * @param cards 配布されたカードのカード種別のリスト。
     * @param screen ゲーム画面のキャプチャ。
     * @return イベントを送信するべきならtrue。それ以外はfalse。
     */
    private boolean shouldSendDealCardsEvent(List<Card> cards, BufferedImage screen) {
        CaptureRectangle captureRect = captureManager.getCaptureRectangle(DEAL_CARDS_BUTTON_CAPTURE);
        String filePath = captureManager.getCaptureFilePath(DEAL_CARDS_BUTTON_CAPTURE);
        try {
            // "くばる"ボタンの画像とキャプチャが一致したらイベント送信。
            if (imageComparator.compare(filePath, captureRect.getSubImage(screen))) {
                return true;
            }
        } catch (IOException e) {
            // ファイル読み込みに失敗したらログを出して継続。
            LOGGER.error("fail to read deal cards button image file: {}", filePath, e);
        }
        return false;
    }

    // 以下略

4行目: イベント送信時に付与するイベントを一意に表すIDを生成します。イベント送信を契機に状態遷移マシンで様々なActionが実行されるわけですが、どのイベントを契機に実行されたのかが分からないとデバッグがやりづらかったので、イベントにIDを持たせて、その先のActionや状態遷移に関するログでは必ず契機となったイベントIDを出力するように工夫しました。

7-12行目: イベントを送信しています。これまで見てきたやりかたでは、sendEventメソッドにイベントのenumを直接渡していましたが、このようにMessageという型でラップすることで、イベントに付加情報を付与して送信することができるようになります。
MessageBuilderのwithPayloadで送信するイベントを、setHeaderで付加する情報を設定して、最後にbuildすることでMessageを作成できます。
受信側でこの付加情報を取得する場合は、StateContextのgetMessageHeadersメソッドを使えばOKです。

15行目: ツール終了時に直近の画面キャプチャをファイル保存するために、キャプチャ画像を保持します。デバッグ目的です。キャプチャ画像とイベントIDの紐付けもログによって行われるため、どのキャプチャからどういうイベントを送信してその結果どういうActionが実行されたのかまで追跡することができます。

27-40行目: イベントを送信するべきか判定するshoud~メソッドの1つを参考までに掲載しています。特筆すべき事は何もやっていなくて、キャプチャした画像から"くばる"ボタンが表示される範囲を切り取って、あらかじめ保存しておいた"くばる"ボタンのキャプチャ画像ファイルと比較を行います。
画像比較については、こちらのサイト(toridgeの勉強部屋 wiki)のコードを参考にさせていただきました。画像比較についてはまったく知識がなかったので、本当にありがたいです。

以上、今回はイベントディスパッチャのコードを見てみました。

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

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

さて、前回は状態遷移図を作って、Configクラスで状態遷移マシンの定義を行うところまでやりました。

今回は、前回作成した状態遷移マシンをテストしてみるところからはじめます。

spring-statemachine-testの設定

pom.xmlで下記の依存関係が定義されていない場合は追加します。


        <dependency>
            <groupId>org.springframework.statemachine</groupId>
            <artifactId>spring-statemachine-test</artifactId>
            <version>1.2.7.RELEASE</version>
            <scope>test</scope>
        </dependency>


テストコード

テストコードはこんな感じになります。

@RunWith(SpringRunner.class)
@SpringBootTest(properties = "mode=TEST")
public class AutoplayStatemachineTest {

    @Autowired
    StateMachine<States, Events> stateMachine;

    @MockBean
    Robot robot;

    @MockBean
    ActivateWindowUtil activateWindowUtil;

    @Before
    public void setUp() {
        when(robot.createScreenCapture(anyObject()))
            .thenReturn(new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB));
        doNothing().when(activateWindowUtil).activate();
    }

    @Test
    public void testStateMachine01() throws Exception {
        List<Card> cards = Arrays.asList(Card.S10, Card.SJ, Card.SQ, Card.XX, Card.XX);
        Message<Events> dealCardsEventMessage = MessageBuilder
                .withPayload(Events.DEAL_CARDS_EVENT)
                .setHeader("cards", cards)
                .build();

        StateMachineTestPlanBuilder.<States, Events>builder()
            .stateMachine(stateMachine)
            .step()
                .expectStates(PLAYING_POKER_STATE, OTHER_STATE)
                .and()
            .step()
                .sendEvent(dealCardsEventMessage)
                .expectStateChanged(1)
                .expectStates(PLAYING_POKER_STATE, DEALT_CARDS_STATE)
                .and()
            .step()
                .sendEvent(OTHER_EVENT)
                .expectStateChanged(1)
                .expectStates(PLAYING_POKER_STATE, OTHER_STATE)
                .and()
            .step()
                .sendEvent(OTHER_EVENT)
                .expectStateChanged(1)
                .expectStates(PLAYING_POKER_STATE, OTHER_STATE)
                .and()
            .step()
                .sendEvent(ROYAL_STRAIGHT_SLIME_EVENT)
                .expectStateChanged(1)
                .expectState(FINAL_STATE)
                .expectStateMachineStopped(2)
                .and()
            .build()
            .test();
    }

まず、6-7行目でStateMachineをインジェクションしています。

9-20行目ではモック化の設定を行っていますが、これはSpring Statemachineとは直接関係がない処理なので、あまり気にしなくても大丈夫です。今回のツールでは状態遷移に伴うActionの中でキーボードやマウスの操作等を行っていますが、テスト中にそのような操作が発生してしまうと困るため、操作を行うクラスをモック化して回避しています。

30行目のStateMachineTestPlanBuilderがspring-statemachine-testの肝となるクラスです。このBuilderに対してメソッドチェーンでstepを定義してきます。step内では、状態遷移マシンに送信するイベントと、その結果期待される状態遷移マシンの状態を定義していきます。

StateMachineTestPlanBuilderで使用できる主なメソッドは以下になります。詳細はJavadocをご参照ください。
  • sendEvent(event): 状態遷移マシンにイベントを送信する
  • expectState(state): 期待する状態
  • expectStates(state...): 期待する状態(複数)。状態に親子関係がある場合はこちらを使う
  • expectStateChanged(count): 期待する状態遷移の発生回数。
  • expectStateMachineStopped(count): 状態遷移マシンが停止した回数。
expectStateMachineStoppedメソッドについて、状態遷移マシンが複数回停止することあるの?と思われるかもしれませんが、どうやら状態が親子関係にある場合、親状態内の状態遷移は別の状態遷移マシンとしてカウントされるようです。上記ソースコードでは、PLAYING_POKER_STATE内の状態遷移マシンと、全体の状態遷移マシンが停止するのでcountを2にしています。

テスト結果

テストを実行してみます。
成功してもつまらないので、上記コードの37行目でexpectStateChangedを1から2に変更して、テストを失敗させてみます。実行結果は下記の通り。

java.lang.AssertionError: StateChanged Await not matched for machine FINAL_STATE PLAYING_POKER_STATE DEALT_CARDS_STATE OTHER_STATE RETRY_OR_END_STATE  / PLAYING_POKER_STATE,DEALT_CARDS_STATE / uuid=b1ae81c3-d178-4ff0-8465-228b380a5500 / id=null
Expected: is <true>
     but: was <false>
 at org.hamcrest.MatcherAssert.assertThat(MatcherAssert.java:20)
 at org.junit.Assert.assertThat(Assert.java:956)
 at org.springframework.statemachine.test.StateMachineTestPlan.test(StateMachineTestPlan.java:167)
 at com.github.lenemarix.autoplay.dq11.poker.statemachine.AutoplayStatemachineTest.testStateMachine01(AutoplayStatemachineTest.java:92)
...(以下略)...

うーん、どうやら2回めに発生するはずの状態変化を待ったが結局発生しなかったのでエラーになったという事のようですが・・・。
一体全体、どこのexpectで失敗したのか分からないですね・・・。expectStatechangedで整数を指定したはずなのに、Expectedやbutの値がbooleanになってるし。

ちなみに、スタックトレース内のAutoplayStatemachineTest.java:92は、上記テストコードの57行目のtest()メソッド呼び出しを指しています。例外発生は全てtest()メソッドになってしまうので、スタックトレースが役に立ちませんね・・・。

ちょっと使うには辛い印象。

2018年1月18日木曜日

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

今回からいいかげんドラクエ11のポーカーの話をしていきたいと思います。

状態遷移図

さっそくですが、今回のツールで作成した状態遷移図は下記のとおりです。

初期状態

まず、状態遷移マシンが起動すると最初のアクションとしてPS4リモートプレイのウィンドウを前面に出す処理を実行します。JavaのRobot APIでキー操作するには、PS4リモートプレイのウィンドウが最前面に出ていないといけないので、初期処理として実行しています。

PLAYING_POKER_STATE

続いてPLAYING_POKER_STATEという状態に入ります。これは単純にポーカーを自動プレイ中の状態を表しているだけですね。

重要なのはPLAYING_POKER_STATEの子ステートである、OTHER_STATEとDEALT_CARDS_STATEです。

DEALT_CARDS_STATE

まずは右側のDEALT_CARDS_STATE状態ですが、これは5枚のカードが配られてカードを選択する状態を示します。下記の画像の状態ですね。

この状態では、残すカードを選択して"くばる"ボタンを押すことでポーカーを進める必要があるわけですが、"くばる"ボタンを押すためにはカーソルを下に移動する必要があります。つまり、この画面ではEnterキー(○ボタン)を押しているだけではポーカーは進まないわけです。(1番左のカードの"かえる"と"のこす"が交互に切り替わるだけ)
この画面の状態を正しく認識して"くばる"ボタンを押せるようになることが、ポーカーを延々自動プレイするための一番の鍵になるということです。

OTHER_STATE

次にOTHER_STATEというやる気のない名前の状態が左側にありますが、これは簡単に言うとEnterキー(○ボタン)さえ押していれば進んでいく状態です。本来は役が完成した後のダブルアップチャンスをチャレンジしている状態や、役ができなかった場合に再度かけ金を設定する状態など、より細かい状態を定義することはできるのですが、今回のツールの性質を考えればそのような細かい状態を認識する必要は(今のところ)ありません。このツールとしてはEnterさえ押しておけばOKという状態を認識出れば十分です。

PLAYING_POKER状態内では、上記の2つの状態を行ったりきたりしながら操作を行っていきます。具体的には、OTHER_STATE中に画面キャプチャから"くばる"ボタンが表示されていることを検出したら、DEAL_CARDS_EVENTを投げてDEALT_CARDS_STATEに遷移します。DEALT_CARDS_STATEのEntryアクションには、配られたカードを認識した上で残すカードを選択する操作をRobot APIを通じて行い、最後に下キー→Enterキーと押して"くばる"ボタンを押下します。

また、DEALT_CARDS_STATEではOTHER_EVENT(特に画面キャプチャに特殊な状態を表すものが存在しない場合に発行される)を受け取るとOTHER_STATEに状態を戻します。OTHER_STATEではEntryアクションで単純にEnterキーを押すというアクションを設定していて、OTHER_EVENTが受信されるたびにEnterキーを押してポーカーを進めます。(OTHER_STATEからOTHER_STATEの遷移は自己遷移ですが、External Transitionなので一旦OTHER_STATEから出て再度OTHER_STATEに進入するため、Entryアクションは毎回実行されます)

やがて、再度カードが5枚配られ、画面上に"くばる"ボタンが表示されると、DEAL_CARDS_EVENTが発行されて、DEALT_CARDS_STATEに状態を移すという塩梅です。

ROYAL_STRAIGHT_SLIME_EVENT

ここからは、この状態遷移マシンの終了について見ていきます。

まずは中央下のROYAL_STRAIGHT_SLIME_EVENTの遷移ですが、これは画面上の5枚のカードがロイヤルストレートスライムを構成するカードになっていることを検出したら発火されるイベントです。このイベントが発生すると30秒待機したのち、Shareボタンを押してから状態遷移マシンが終了します。

Shareボタンを押すのは、そのままビデオクリップを保存すればロイヤルストレートスライム達成時の動画を保存することができるためです。また、30秒待つのはロイヤルストレートスライム達成時のファンファーレが終了してからビデオクリップを保存したいからです。

BEFORE_BET_COIN_EVENT

次に、右下のBEFORE_BET_COIN_EVENTの線ですが、これは通常は起こり得ない遷移なのですが、念のため保険として設定しているものです。

開発中にツールを実行していると何度か掛け金の設定画面でポーカーが進まなくなっている状況がありました。よくよく見てみると、かけ金の設定が0になっており、Enterキーを押しても進まないという状態です。
どうやら、ポーカーで負けたときに、ポーカーを続けるか聞いてくるダイアログで"いいえ"を選択してしまい、ポーカーを一度終了してしまっているようでした。ポーカーを終了しても状態遷移としてはOTHER_STATEとして認識されるだけなので、OTHER_EVENTが受信され続け、Enterキーをどんどん押していきます。そうすると、ディーラーに再度話しかけ、ポーカーをプレイし始めるところまでは行くのですが、掛け金のデフォルトが0のため、そこでEnterキーを押しても進めなくなっているということのようでした。

通常、ポーカーの継続で"いいえ"を選択することはありえないのですが、ネットワーク遅延やその他要因によるキーの取りこぼし等の予期せぬ動作が重なり、そういったことが発生しうるようです。そこで、掛け金入力欄が表示されており、その値が0であることを認識した場合は、BEFOR_BET_COIN_EVENTを送信して、RETRY_OR_END_STATEという選択状態に遷移し、ポーカーをリトライするか、ツールの終了を選択するようモデリングしました。


RETRY_OR_END_STATE

BEFORE_BET_COIN_EVENTを受信して、予期せぬ状態(一旦ポーカーを終了してしまった状態)になった場合に、ポーカーをリトライするか、ツールを終了するかを選択する状態に入りします。

デフォルトの設定ではリトライを行うようになっており、上キーを押してかけ金の決定を行った後にEnterキーを押すアクションを設定しています。このアクションの結果、OTHER_STATEに復帰します。

DEALT_CARDS_STATEのInternal Transition

最後に、DEALT_CARDS_STATEの箱の中に、「状態に入って60秒たった」、「"くばる"ボタンを再度押下」という記述があります。これも、予期せぬ状態になったときの防護策として入れているもので、DEALT_CARDS_STATEに入ってから60秒たっても他の状態に遷移しない場合は、"くばる"ボタンを押すのに失敗したとみなし、下キー→Enterキーと押して再度"くばる"ボタンを押そうとします。

これはキーの取りこぼし等でうまく下キーが押せずに、"くばる"ボタンにカーソルが移動しなかったときのことを考慮した結果入れている処理です。上でも書いたように、ここで下キーを押せるかどうかがポーカーを延々プレイし続けることができるかどうかの鍵になっているので、下キーの取りこぼしが万が一起きても復帰できるように念には念を入れています。

なお、この処理はInternal Transition(内部遷移)とガード条件を組み合わせてモデリングしています。内部遷移は状態変化を伴わない遷移です。DEALT_CARDS_STATE中にDEAL_CARDS_EVENT("くばる"ボタンの検出イベント)を受信すると内部遷移のガード条件が検証されます。ガード条件では、あらかじめ記録してあったDEALT_CARDS_STATEの状態に入った際の時刻を参照し、60秒経過しているかを確認します。60秒経過していなければガード条件はfalseとなり内部遷移は実行されません。60秒が経過していれば内部遷移が実行され、遷移に紐付いたアクションで"くばる"ボタンを再度押しに行きます。

ソースコード

では、さっそく上記のようにモデリングした状態遷移図のSpring Statemachineでの実装を見ていきましょう。

なお、ソースはGitHubにあげていますので、詳細が見たければご参照ください。

States


/**
 * 状態。
 */
public enum States {

    /** ポーカープレイ中の状態。 */
    PLAYING_POKER_STATE,
    
    /** カード配布済み状態("くばる"ボタンが表示されている)。 */
    DEALT_CARDS_STATE,

    /** 予期せぬ状態になったとき、リトライかアプリケーション終了を選択する状態。 */
    RETRY_OR_END_STATE,

    /** その他の状態。 */
    OTHER_STATE,

    /** 終了状態。 */
    FINAL_STATE

}

Events


/**
 * イベント。
 */
public enum Events {

    /** カード配布検知イベント。 */
    DEAL_CARDS_EVENT,

    /** ロイヤルストレートスライム検知イベント。 */
    ROYAL_STRAIGHT_SLIME_EVENT,

    /** かけ金入力待ち検知イベント。 */
    BEFORE_BET_COIN_EVENT,

    /** その他イベント。 */
    OTHER_EVENT,

}

Config


    @Override
    public void configure(StateMachineStateConfigurer<States, Events> states) throws Exception {
        // 各状態とEntry Actionの定義。
        states.withStates()
                .initial(PLAYING_POKER_STATE, activateWindowAction())
                .choice(RETRY_OR_END_STATE)
                .end(FINAL_STATE)
                .and()
                .withStates()
                    .parent(PLAYING_POKER_STATE)
                    .initial(OTHER_STATE)
                    .state(OTHER_STATE, enterKeyPushAction(), null)
                    .state(DEALT_CARDS_STATE, decideExchangeCardAction(), null);
    }

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions) throws Exception {
        transitions
                // ポーカープレイ中状態でロイヤルストレートスライムを検出。終了状態へ遷移。
                .withExternal()
                    .source(PLAYING_POKER_STATE)
                    .target(FINAL_STATE)
                    .event(ROYAL_STRAIGHT_SLIME_EVENT)
                    .action(pushShareButtonAction())
                    .and()
                // かけ金入力欄が0の状態で見つかった場合、何らかの原因でポーカーを終了してしまったとみなし、リトライ・終了選択状態へ遷移。
                .withExternal()
                    .source(PLAYING_POKER_STATE)
                    .target(RETRY_OR_END_STATE)
                    .event(BEFORE_BET_COIN_EVENT)
                    .and()
                // "くばる"ボタンを見つけたら、その他状態からカード配布済み状態へ遷移。
                .withExternal()
                    .source(OTHER_STATE)
                    .target(DEALT_CARDS_STATE)
                    .event(DEAL_CARDS_EVENT)
                    .and()
                // その他状態の継続。Entry ActionでEnterキーを押して先に進める。
                .withExternal()
                    .source(OTHER_STATE)
                    .target(OTHER_STATE)
                    .event(OTHER_EVENT)
                    .and()
                // カード配布済み状態でロイヤルストレートスライムを検出。終了状態へ遷移。
                .withExternal()
                    .source(DEALT_CARDS_STATE)
                    .target(OTHER_STATE)
                    .event(OTHER_EVENT)
                    .and()
                // 予期せぬ状態になったときにリトライかアプリケーション終了を判定する。
                .withChoice()
                    .source(RETRY_OR_END_STATE)
                    .first(PLAYING_POKER_STATE, retryOnUnexpectedStateGuard(), betCoinAction())
                    .last(FINAL_STATE)
                    .and()
                // カード配布済み状態が長く続く場合、"くばる"ボタンの押下に失敗したとみなし、再度ボタンの押下を試みる。
                .withInternal()
                    .source(DEALT_CARDS_STATE)
                    .event(DEAL_CARDS_EVENT)
                    .guard(retryPushDealButtonGuard())
                    .action(pushDealButtonAction())
                    .and();
    }

}

Configクラスでは今まで説明していない要素が出てきています。ひとつひとつ見ていきましょう。

選択状態の定義

7行目でchoiceメソッドを使って選択状態を定義しています。通常の状態定義に使うstateメソッドではないことに注意が必要です。

状態の親子関係定義

状態の親子関係を定義する場合は、10行目のようにwithStates()メソッドのチェーンの中でさらにwithStates()メソッドを呼び出します。さらに11行目のようにparentメソッドを使用して親となる状態を定義します。後は通常通りの状態定義を行っていけば、parentメソッドで定義した状態の子となる状態を定義していくことができます。

選択状態からの遷移

52行目から55行目までは選択状態に入ってからの遷移を定義しています。withChoiceメソッドを呼び出した後、souceメソッドで選択状態を指定します。さらにfirstメソッドでは、第1引数に遷移先状態、第2引数にガード条件(trueになったらこの遷移が行われる)、遷移時のActionを指定します。最後にlastメソッドでfirstメソッドのガード条件がfalseだった場合の遷移先状態を設定しています。

これは、if-else文のうち、ifがfirstメソッド、elseがlastメソッドだと思えば理解しやすいです。なお、3分岐以上する場合(if-elseif-else)、firstメソッドとlastメソッドの間にthenメソッドを呼び出すことで実現できます。

内部遷移(Internal Transition)

58行目では状態遷移を伴わない内部遷移をwithInternalメソッドで定義しています。遷移先がないのでtargetメソッドを呼び出しません。ここの処理については別途後日に取り上げたいと思います。

とりあえず、状態・イベント・Configクラス(状態遷移の設定)の実装をざっと見てみました。

2018年1月16日火曜日

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

前回に引き続き、もう少しSpring Statemachineを触ってみます。

Action

前回はラムダ式でActionを設定していましたが、複雑な処理を行う場合はActionインターフェースを実装したクラスを作成することになります。

public class StateEntryAction implements Action<States, Events> {

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

    @Override
    public void execute(StateContext<States, Events> context) {
        LOGGER.info("StateEntryAction.execute()");
    }
}

上記のように唯一のメソッドであるexecuteをオーバーライドして処理を実装します。
引数のStateContextは状態に関する様々な情報にアクセスできます。使い方は後日説明する予定です。

Actionは様々な場所に設定できます。
前回はTransition(状態遷移)に対して設定しましたが、以下のように設定するとState(状態)に対してActionを設定できます。

    @Bean
    public InitialAction initialAction() { return new InitialAction(); }
    @Bean
    public StateEntryAction stateEntryAction() { return new StateEntryAction(); }
    @Bean
    public StateExitAction stateExitAction() { return new StateExitAction(); }
    @Bean
    public StateDoAction stateDoAction() { return new StateDoAction(); }

    @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        states.withStates()
            .initial(INITIAL_STATE, initialAction()) // 初期処理 
            .state(READY_STATE, stateEntryAction(), stateExitAction())  //Entry Action, Exit Action
            .state(RUN_STATE, stateDoAction())  //Do Action
            .end(FINAL_STATE);
    }

initialメソッドの第2引数にActionを設定することで、INITIAL_STATEに入るときだけ(= 一度だけ)実行するActionを設定できます。初期化処理を実装する場合に最適です。

stateメソッドは注意が必要です。
まず、引数が3つのstateメソッドの場合、第2引数が状態に入ったときに実行されるEntry Action、第3引数が状態を出るときに実行されるExit Actionになります。Etnry ActionかExit Actionのどちらか一つでいい場合は、いらない方の引数にnullを渡せばOKです。
そして、引数が2つのstateメソッドの場合、第2引数のActionはその状態中に実行されるDo Actionになります。

さて、Do ActionとはEntry Actionとどう違うのでしょうか?
通常、状態遷移マシンで do アクティビティといえば、その状態にとどまっているときに実行され続ける動作を書くものです。
Spring Statemachineでは、Do Actionは別スレッドで動作します。
先程の状態遷移マシンを動かしてみると以下のようになります。


2018-01-16 21:37:35.609  INFO 7740 --- [           main] c.e.s.demo.statemachine.InitialAction    : InitialAction.execute()
2018-01-16 21:37:35.609  INFO 7740 --- [           main] c.e.s.d.statemachine.StateEntryAction    : StateEntryAction.execute()
2018-01-16 21:37:35.609  INFO 7740 --- [           main] c.e.s.demo.statemachine.StateExitAction  : StateExitAction.execute()
2018-01-16 21:37:35.609  INFO 7740 --- [pool-2-thread-1] c.e.s.demo.statemachine.StateDoAction    : StateDoAction.execute()


IntialAction, EntryAction, ExitActionはmainスレッドで動いていますが、DoActionはpool-2-thread-1 という別のスレッドで動いていることが分かります。

状態に入ったときに完了まで確実に実行したいActionはEntryActionとして定義し、状態に入っているときに継続して実行し続けるような処理はDoActionとして定義すると良いでしょう。

Spring Statemachineのリファレンスを見るとDo ActionについてはState Actionsという表現で説明されています。リファレンスによれば、State ActionsはSpringのTaskSchedulerで実行されるようです。別の状態に遷移した場合にはtaskがキャンセルされるため、InterruptedExceptionをキャッチしたり、Thread#interrupted()で割り込まれたことを検知してState Actionsを停止する処理を実装する必要があるようです。

また、Entry ActionやExit Actionは同期的に動いている(sendEventしたスレッドでそのまま実行される)のですが、これはStateMachineに設定されたtaskExecutorのデフォルトがSyncTaskExecutorを使用しているためです。taskExecutorを非同期的なものに変更すればEntry ActionやExit Actionも別スレッドで動作させることができます。

Guard

次にガード条件を表すGuardインターフェースを見てみます。

public class SampleGuard implements Guard<States, Events> {

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

    @Override
    public boolean evaluate(StateContext<States, Events> context) {
        Integer count = context.getMessageHeaders().get("count", Integer.class);

        if (count != null && count > 10) {
            LOGGER.info("ガード条件が trueなので遷移が実行される");
            return true;
        }
        return false;
    }
}

evaluateメソッドがtrueを返すとこのガード条件が設定された状態遷移が実行されますが、falseを返すと状態遷移は実行されません。ここでは、引数のStateContext経由で渡されたcountの値を元にガード条件を判定しています。

状態遷移に対してガード条件を設定するには以下のようにConfigクラスでguardメソッドを使用します。

    @Bean
    public SampleGuard sampleGuard() {
        return new SampleGuard();
    }
    
    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
            .withExternal()
                .source(READY_STATE)
                .target(RUN_STATE)
                .event(RUN_EVENT)
                .guard(sampleGuard())
                .action(c -> System.out.println("READY to RUN"))
                .and()
            // 以下略


StateMachineListener

状態遷移マシンに各種変化が起きたときに、ロギングなどの共通的な処理を仕掛けたい場合は、StateMachineListenerインターフェースが使えます。
StateMachineListenerAdapterクラスを継承すれば、必要なメソッドのみをオーバーライドできるので便利です。

下記では、状態の変化が発生したときにロギングを行うリスナを実装しています。
stateChangedメソッドの引数には、遷移元の状態と遷移先の状態が渡されます。
初期状態(INITIAL_STATE)に入るときには、fromの引数がnullになるため注意が必要です。

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


リスナの設定はConfigクラスで下記のように行います。

    @Bean
    public SampleStateListener sampleStateListener() { return new SampleStateListener(); }
    
    @Override
    public void configure(StateMachineConfigurationConfigurer<States, Events> config)
            throws Exception {
        config.withConfiguration()
            .listener(sampleStateListener());
    }

リスナは他にも状態遷移が発生した場合や、状態遷移マシンが開始・停止した場合等にも処理を仕掛けられます。詳しくはStateMachineListenerAdapterのJavadocを参照してください。メソッド名からどのタイミングで処理を仕掛けられるか大体分かると思います。
(extendedStateChangedとstateContextメソッドは分かりにくいかもしれませんが、後日説明する予定です。)

そろそろ教科書的なお勉強は飽きてきましたね。
次回はドラクエ11のポーカーを自動実行する話に入りたいと思います。

2018年1月13日土曜日

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

Spring Statemachineを少し触ってみます。
バージョンはSpring Boot 1.5.9 + Spring Statemachine 1.2.7 を使います。

pom.xmlの設定

Spring Initializrでプロジェクトを作成しますが、DependenciesにSpring Statemachineはないので、自分でpom.xmlに依存関係を追加します。


        <dependency>
            <groupId>org.springframework.statemachine</groupId>
            <artifactId>spring-statemachine-core</artifactId>
            <version>1.2.7.RELEASE</version>
        </dependency> 

        <dependency>
            <groupId>org.springframework.statemachine</groupId>
            <artifactId>spring-statemachine-test</artifactId>
            <version>1.2.7.RELEASE</version>
            <scope>test</scope>
        </dependency>


spring-statemachine-coreだけで使えますが、テスト用のspring-statemachine-testも追加しておきます。

状態の定義

状態の定義にはenumが使えます。
以下のように状態のenumを作成します。

public enum States {
    INITIAL_STATE, READY_STATE, RUN_STATE, FINAL_STATE
}


イベントの定義

Spring Statemachineでは、状態遷移マシンがイベントを受け取ることで状態遷移(transition)を発生させます。
イベントの定義も同様にenumで作成できます。

public enum Events {
    READY_EVENT, RUN_EVENT, STOP_EVENT, FINAL_EVENT
}


Configクラス

Enumで状態とイベントを定義した場合は、EnumStateMachineConfigurerAdapterクラスを継承してConfigクラスを作成するのが一般的です。
クラスには、Spring Statemachineを有効化するための@EnableStateMachineアノテーションも付与します。

@Configuration
@EnableStateMachine
public class StateMachineConfig
    extends EnumStateMachineConfigurerAdapter<States, Events> {

Configクラス内では以下のようにconfigureメソッドをオーバーライドしてenumで定義した各状態を登録します。

    @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        // 状態を定義
        states.withStates()
            .initial(INITIAL_STATE) // 初期状態
            .state(READY_STATE)
            .state(RUN_STATE)
            .end(FINAL_STATE); // 終了状態
    }

同様にconfigureメソッドをオーバーライドして状態遷移を定義します。

    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
            .withExternal()
                .source(INITIAL_STATE)
                .target(READY_STATE)
                .event(READY_EVENT)
                .action(c -> System.out.println("INITIAL to READY"))
                .and()
            .withExternal()
                .source(READY_STATE)
                .target(RUN_STATE)
                .event(RUN_EVENT)
                .action(c -> System.out.println("READY to RUN"))
                .and()
            .withExternal()
                .source(RUN_STATE)
                .target(READY_STATE)
                .event(READY_EVENT)
                .action(c -> System.out.println("RUN to READY"))
                .and()
            .withExternal()
                .source(RUN_STATE)
                .target(FINAL_STATE)
                .event(FINAL_EVENT)
                .action(c -> System.out.println("RUN to FINAL"))
                .and();
    }

withExternal()メソッドはExternal Transitionを定義するメソッドです。
External Transitionは状態から状態へ移るための遷移で、一般的に状態遷移と聞いて思い浮かべるものです。他に、状態の変更を伴わないInternal Transitionなどもあります。

withExternal()の後はsourceメソッドで遷移元の状態を、targetメソッドで遷移先の状態を指定します。

また、eventメソッドでこの遷移を発火させるイベントを指定します。

最後にactionメソッドでこの遷移時のアクションを指定します。
actionメソッドには Actionインターフェースを実装したクラスのオブジェクトを指定しますが、簡単な動作であれば上記のようにラムダを使って書くこともできます。
また、Actionは遷移だけでなく、状態に対しても設定できます(状態に入ったとき、状態中、状態を出るときのAction)。これについては次回以降説明します。

Configクラスの全体を下記に載せます。

@Configuration
@EnableStateMachine
public class StateMachineConfig
        extends EnumStateMachineConfigurerAdapter<States, Events> {
    @Override
    public void configure(StateMachineStateConfigurer<States, Events> states)
            throws Exception {
        // 状態を定義
        states.withStates()
            .initial(INITIAL_STATE) // 初期状態
            .state(READY_STATE)
            .state(RUN_STATE)
            .end(FINAL_STATE); // 終了状態
    }
  
    @Override
    public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
            throws Exception {
        transitions
            .withExternal()
                .source(INITIAL_STATE)
                .target(READY_STATE)
                .event(READY_EVENT)
                .action(c -> System.out.println("INITIAL to READY"))
                .and()
            .withExternal()
                .source(READY_STATE)
                .target(RUN_STATE)
                .event(RUN_EVENT)
                .action(c -> System.out.println("READY to RUN"))
                .and()
            .withExternal()
                .source(RUN_STATE)
                .target(READY_STATE)
                .event(READY_EVENT)
                .action(c -> System.out.println("RUN to READY"))
                .and()
            .withExternal()
                .source(RUN_STATE)
                .target(FINAL_STATE)
                .event(FINAL_EVENT)
                .action(c -> System.out.println("RUN to FINAL"))
                .and();
    }
}

起動クラス

最後に起動クラスでStateMachineを操作するコードを書いてみます。

@SpringBootApplication
public class SpringstatemachineDemoApplication implements CommandLineRunner { 

    @Autowired 
    StateMachine<States, Events> stateMachine; 

    public static void main(String[] args) { 
        SpringApplication.run(SpringstatemachineDemoApplication.class, args); 
    } 

    @Override 
    public void run(String... args) throws Exception { 
        stateMachine.start(); 
    
        stateMachine.sendEvent(READY_EVENT); 
        stateMachine.sendEvent(RUN_EVENT); 
        stateMachine.sendEvent(READY_EVENT); 
        stateMachine.sendEvent(RUN_EVENT); 
        stateMachine.sendEvent(FINAL_EVENT); 
    }
}

stateMachine.start() で状態遷移マシンを起動します。(設定で自動的に起動することも可能)

その後はstateMachine.sendEventメソッドを使ってイベントを投げることで状態遷移を発生させます。
なお、Configクラスで遷移を設定していない状態とイベントの組み合わせでは、単純にイベントが無視されます。(例: INITIAL_STATEでRUN_EVENTを投げても無視される)

では、実際に動かしてみます。
標準出力に下記のように期待通りの ログが出力されました。

INITIAL to READY
READY to RUN
RUN to READY
READY to RUN
RUN to FINAL

今日はここまで。

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

PS4版ドラゴンクエスト11(以下、ドラクエ11)のポーカーで、ロイヤルストレートスライムを出すまで自動実行するツールをSpring Boot + Spring Statemachine で作ろうという連載企画。
第1回は準備編です。(どうしてこんなものを作ろうと思ったのかを書いているだけなので、あまり読む必要はないです)

あくまでSpring Statemachineの勉強が目的なので、ツールは大した出来にはならないと思います。お察しください。

ロイヤルストレートスライムって?

ドラクエ11のカジノではポーカーがプレイできますが、そのポーカーで最強の役として設定されているのがロイヤルストレートスライムです。

通常のポーカーではロイヤルストレートフラッシュという役が最強ですが、ドラクエ11のポーカーではさらに上の役として、ロイヤルストレートフラッシュの各5枚のカードをスライムのマークで統一した「ロイヤルストレートスライム」という独自の役を定義しています。ロイヤルストレートフラッシュよりもさらに低確率の超レアな役です。
スライムの10、J、Q、K、A が揃えばロイヤルストレートスライム

称号コンプにはロイヤルストレートスライムを出す必要がある 

ドラクエ11では称号というやりこみ要素があり、ある条件を満たすと称号を獲得することができます。
その称号の中に、カジノでロイヤルストレートスライムを出さないと取ることができない「ロイヤルスライマー」という鬼畜極まりない称号が存在します。(なんでこんな称号を設定するのかね・・・)

別に称号を取っても得することはないのでただの自己満足なのですが、やはりできることならコンプリートしたいのです。

ロイヤルストレートスライムってどれくらいやれば出せるの?

運次第ですが、ネットで調べてみると2~3時間で出た人から15時間かかった人、さらにそれ以上の時間をかけているけどまだ出てない人、など・・・やはりとにかく低確率な模様です。

数十時間もPS4のコントローラを操作してポーカーをやり続ける・・・これは人間のやる作業なのか?コンピュータになんとか自動実行させることはできないものか?

ということで、ポーカーを自動実行させるツールの実現性について、妄想をし始めました。

どうやってコンピュータにやらせる?

PS4はゲーム機です。ゲーム機の自動実行を実現するなんて、不可能ではないのでしょうが、知識のない私にはとても無理です。なんとかしてPCの世界に土俵を持ってこないと・・・。

そこで、PS4リモートプレイを使います。

PS4リモートプレイは公式に提供されているアプリケーションで、Windows/Mac上でリモートのPS4につないでゲームをプレイできるという超すごいやつです。

一度、実家から遠く離れた自宅のPS4につないでゲームをやってみましたが本当に感動しました。まぁ、遅延があったり接続が切れたりするので、現実的にそこまでしてプレイするか?というと微妙ですが。

PS4リモートプレイを使えばキーボードで操作できますし、プログラミングで自動実行を実現できそうです。

操作はJavaのRobot APIで

低レベルプログラマの私はJavaしかまともに使えませんので、今回もJavaでなんとかします。

キーボードやマウスの操作はJava標準で備わっているRobot APIが使えそうです。
画面キャプチャもできるので、画面の確認にも使えそうです。

状態遷移マシンが必要だ

ドラクエ11のポーカーを注意深くやってみると分かるのですが、ほとんどの場合○ボタン(PS4リモートプレイだとEnterキーでもよい)さえ押していればポーカーが進行していくようになっています。

○ボタン連打だけで進行しないのは、最初のカードが5枚配られたときです。
この時だけは左右キーと○ボタンを使って残すカードを選択し、下キーを押して「くばる」ボタンにカーソルを合わせ、○ボタンを押す、という操作が必要になってきます。

つまり、今から作ろうとしているツールは「カードが配られた状態」を認識し、操作を切り替える必要があるということです。

状態に応じてアクションを切り替えるということは、状態遷移マシンの考え方が使えそうです。

そう言えば、Spring Frameworkの関連プロジェクトでSpring Statemachineというマニアックなやつがあったよな・・・。

Spring Statemachineの導入

Spring Statemachine はSpring Frameworkの関連プロジェクトで、状態遷移を扱うことができるフレームワークです。

これまでその存在は知っていて、いつか勉強がてら使ってみたいと思っていたのですが、なかなかいい機会がありませんでした。
しかし、今回のこのツールがSpring Statemachineを勉強するいい題材となりそうです。

実際のところ、時間をかけてこんなツールを作るなんて時間の無駄なので、実現性の妄想だけで終わる予定でしたが、Spring Statemachineの勉強という大義名分が立つなら話は別です。
実際にツールを実装してみようということになりました。

使用する技術のまとめ

  • PS4リモートプレイ: PC上でPS4を操作するために使用
  • Java: プログラミング言語として使用
  • Java Robot API: キーボード・マウスの操作、画面キャプチャに使用
  • Spring Boot: アプリケーションの基盤として使用
  • Spring Statemachine: 状態遷移の管理に使用
道具は揃った。


次回からSpring Statemachineを少し触ってみます。